diff --git a/frontend/src/views/customers/customer/product/CustomerProductView.tsx b/frontend/src/views/customers/customer/product/CustomerProductView.tsx index 8bca9633b..6553e0b9d 100644 --- a/frontend/src/views/customers/customer/product/CustomerProductView.tsx +++ b/frontend/src/views/customers/customer/product/CustomerProductView.tsx @@ -93,6 +93,8 @@ export default function CustomerProductView({ const [hasChanges, setHasChanges] = useState(false); const [useInvoice, setUseInvoice] = useState(false); const initialProductRef = useRef(null); + const [selectedEntitlementAllowance, setSelectedEntitlementAllowance] = + useState<"unlimited" | number>(0); const searchParams = useSearchParams(); @@ -298,6 +300,8 @@ export default function CustomerProductView({ env, product, setProduct, + selectedEntitlementAllowance, + setSelectedEntitlementAllowance, // prices: product.prices, // entitlements: product.entitlements, org, diff --git a/server/package.json b/server/package.json index 2016489a3..1aeeb37ea 100644 --- a/server/package.json +++ b/server/package.json @@ -5,7 +5,7 @@ "main": "index.js", "type": "module", "scripts": { - "dev": "nodemon --no-deprecation --exec tsx src/index.ts --ignore scripts", + "dev": "nodemon --no-deprecation --exec tsx src/index.ts --ignore scripts --ignore tests", "start": "tsx src/index.ts", "queue:dev": "tsx watch src/queue.ts", "build": "tsc -b", @@ -17,7 +17,8 @@ "clean": "tsx src/clean.ts", "test-all": "mocha 'tests/**/*.ts'", "test": "mocha 'tests/**/*.ts'", - "test-custom": "mocha 'tests/02_usage.ts'" + "test-upgrade": "mocha 'tests/06_upgrade.ts' 'tests/07_downgrade.ts'", + "test-custom": "mocha 'tests/06_upgrade.ts'" }, "mocha": { "node-option": [ diff --git a/server/src/external/autumn/autumnWebhookRouter.ts b/server/src/external/autumn/autumnWebhookRouter.ts new file mode 100644 index 000000000..aacfd2f46 --- /dev/null +++ b/server/src/external/autumn/autumnWebhookRouter.ts @@ -0,0 +1,52 @@ +import express from "express"; +import { Webhook } from "svix"; + +export const autumnWebhookRouter = express.Router(); + +const verifyAutumnWebhook = async (req: any, res: any) => { + const wh = new Webhook(process.env.AUTUMN_WEBHOOK_SECRET!); + + const headers = req.headers; + const payload = req.body; + + const svix_id = headers["svix-id"]; + const svix_timestamp = headers["svix-timestamp"]; + const svix_signature = headers["svix-signature"]; + + if (!svix_id || !svix_timestamp || !svix_signature) { + res.status(400).json({ + success: false, + message: "Error: Missing svix headers", + }); + return; + } + + let evt: any; + try { + evt = wh.verify(payload, { + "svix-id": svix_id as string, + "svix-timestamp": svix_timestamp as string, + "svix-signature": svix_signature as string, + }); + } catch (err) { + console.log("Error: Could not verify webhook"); + res.status(400).json({ + success: false, + message: "Error: Could not verify webhook", + }); + return; + } + + return evt; +}; + +autumnWebhookRouter.post( + "", + express.raw({ type: "application/json" }), + async (req, res) => { + console.log("Webhook from autumn"); + + const evt = await verifyAutumnWebhook(req, res); + console.log(evt); + } +); diff --git a/server/src/external/stripe/stripeCusUtils.ts b/server/src/external/stripe/stripeCusUtils.ts index 43c178b58..fdded22a1 100644 --- a/server/src/external/stripe/stripeCusUtils.ts +++ b/server/src/external/stripe/stripeCusUtils.ts @@ -71,22 +71,26 @@ export const getCusPaymentMethod = async ({ stripeId )) as Stripe.Customer; - const paymentMethod = stripeCustomer.invoice_settings.default_payment_method; + let paymentMethodId = stripeCustomer.invoice_settings.default_payment_method; - if (!paymentMethod) { - const paymentMethods = await stripeCli.paymentMethods.list({ + if (!paymentMethodId) { + let res = await stripeCli.paymentMethods.list({ customer: stripeId, - type: "card", }); - if (paymentMethods.data.length === 0) { + // const paymentMethods = res.data.filter((pm) => pm.type === "card" ); + + const paymentMethods = res.data; + paymentMethods.sort((a, b) => b.created - a.created); + + if (res.data.length === 0) { return null; } - return paymentMethods.data[0].id; + return paymentMethods[0].id; } - return paymentMethod; + return paymentMethodId; }; // 2. Create a payment method and attach to customer @@ -107,7 +111,7 @@ export const attachPmToCus = async ({ }) => { // 1. Create stripe customer if not exists - let stripeCusId = customer.processor?.stripe_id; + let stripeCusId = customer.processor?.id; if (!stripeCusId) { const stripeCustomer = await createStripeCustomer({ org, @@ -126,6 +130,10 @@ export const attachPmToCus = async ({ }) .eq("internal_id", customer.internal_id); stripeCusId = stripeCustomer.id; + customer.processor = { + id: stripeCustomer.id, + type: "stripe", + }; } const stripeCli = createStripeCli({ org, env }); diff --git a/server/src/external/stripe/stripePriceUtils.ts b/server/src/external/stripe/stripePriceUtils.ts index 02a13b85e..cfe67bebf 100644 --- a/server/src/external/stripe/stripePriceUtils.ts +++ b/server/src/external/stripe/stripePriceUtils.ts @@ -2,7 +2,6 @@ import { BillingInterval, BillingType, FixedPriceConfig, - PriceOptions, Organization, FullProduct, Price, @@ -13,6 +12,7 @@ import { Product, AllowanceType, EntitlementWithFeature, + CusProductStatus, } from "@autumn/shared"; import RecaseError from "@/utils/errorUtils.js"; @@ -20,6 +20,8 @@ import { ErrCode } from "@/errors/errCodes.js"; import Stripe from "stripe"; import { getBillingType, + getCheckoutRelevantPrices, + getEntOptions, getPriceAmount, getPriceEntitlement, getPriceOptions, @@ -27,6 +29,7 @@ import { import { PriceService } from "@/internal/prices/PriceService.js"; import { SupabaseClient } from "@supabase/supabase-js"; import { AttachParams } from "@/internal/customers/products/AttachParams.js"; +import { createStripeCli } from "./utils.js"; export const billingIntervalToStripe = (interval: BillingInterval) => { switch (interval) { case BillingInterval.Month: @@ -54,20 +57,19 @@ export const billingIntervalToStripe = (interval: BillingInterval) => { } }; +// GET STRIPE LINE / SUB ITEM export const priceToStripeItem = ({ price, product, org, options, isCheckout = false, - relatedEnt, }: { price: Price; product: FullProduct; org: Organization; options: FeatureOptions | undefined | null; isCheckout: boolean; - relatedEnt: EntitlementWithFeature | undefined; }) => { // TODO: Implement this const billingType = price.billing_type; @@ -108,7 +110,12 @@ export const priceToStripeItem = ({ }; } else if (billingType == BillingType.UsageInAdvance) { const config = price.config as UsagePriceConfig; - const quantity = options?.quantity || 1; + // const quantity = options?.quantity || 1; + + if (options?.quantity === 0 && isCheckout) { + console.log(`Quantity for ${config.feature_id} is 0`); + return null; + } const adjustableQuantity = isCheckout ? { @@ -126,7 +133,7 @@ export const priceToStripeItem = ({ lineItem = { price: config.stripe_price_id, - quantity, + quantity: options?.quantity!, adjustable_quantity: adjustableQuantity, }; lineItemMeta = { @@ -158,6 +165,74 @@ export const priceToStripeItem = ({ }; }; +// STRIPE TO SUB ITEMS +export const getStripeSubItems = async ({ + attachParams, + isCheckout = false, +}: { + attachParams: AttachParams; + isCheckout?: boolean; +}) => { + const { product, prices, entitlements, optionsList, org, curCusProduct } = + attachParams; + const checkoutRelevantPrices = getCheckoutRelevantPrices(prices); + + let subItems: any[] = []; + let itemMetas: any[] = []; + + // TODO: Check if non bill now prices can be added to stripe subscription...? + + // // 1. Check current period end... + // if (curCusProduct && curCusProduct.processor?.subscription_id) { + // const subId = curCusProduct.processor.subscription_id; + // const stripeCli = createStripeCli({ + // org, + // env: curCusProduct.customer.env, + // }); + + // const sub = await stripeCli.subscriptions.retrieve(subId); + + // const prorationConfig: any = {}; + // if (sub.status !== CusProductStatus.Trialing) { + // const curPeriodStart = sub.current_period_start * 1000; + // const curPeriodEnd = sub.current_period_end * 1000; + + // prorationConfig.current_period_start = curPeriodStart; + // prorationConfig.current_period_end = curPeriodEnd; + + // const curPrices = curCusProduct.customer_prices.map((p) => p.price!); + + // prorationConfig.curPrices = curPrices; + // } + // } + + for (const price of checkoutRelevantPrices) { + const priceEnt = getPriceEntitlement(price, entitlements); + const options = getEntOptions(optionsList, priceEnt); + + const stripeItem = priceToStripeItem({ + price, + product, + org, + options, + isCheckout, + }); + + if (!stripeItem) { + continue; + } + + const { lineItem, lineItemMeta } = stripeItem; + + subItems.push(lineItem); + itemMetas.push(lineItemMeta); + } + + console.log("Line items: ", subItems); + + return { items: subItems, itemMetas }; +}; + export const inAdvanceToStripeTiers = ( price: Price, entitlement: Entitlement diff --git a/server/src/external/stripe/stripeSubUtils.ts b/server/src/external/stripe/stripeSubUtils.ts index f6cf34518..9b82a05cf 100644 --- a/server/src/external/stripe/stripeSubUtils.ts +++ b/server/src/external/stripe/stripeSubUtils.ts @@ -127,7 +127,7 @@ export const updateStripeSubscription = async ({ }); return subUpdate; } catch (error: any) { - console.log("Error updating stripe subscription", error.message); + console.log("Error updating stripe subscription.", error.message); if (isStripeCardDeclined(error)) { throw new RecaseError({ diff --git a/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted.ts b/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted.ts index 834899545..391ca607b 100644 --- a/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted.ts +++ b/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted.ts @@ -3,7 +3,7 @@ import { Stripe } from "stripe"; import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct.js"; import { CusProductService } from "@/internal/customers/products/CusProductService.js"; import { getMetadataFromCheckoutSession } from "@/internal/metadata/metadataUtils.js"; -import { AppEnv, Organization, UsagePriceConfig } from "@autumn/shared"; +import { AppEnv, Organization } from "@autumn/shared"; import { AttachParams } from "@/internal/customers/products/AttachParams.js"; import { createStripeCli } from "../utils.js"; import { InvoiceService } from "@/internal/customers/invoices/InvoiceService.js"; @@ -121,6 +121,8 @@ export const handleCheckoutSessionCompleted = async ({ } } + // Handle upgrade / downgrade + console.log(" - checkout.completed: creating full customer product"); await createFullCusProduct({ diff --git a/server/src/external/svix/svixUtils.ts b/server/src/external/svix/svixUtils.ts new file mode 100644 index 000000000..b852284d1 --- /dev/null +++ b/server/src/external/svix/svixUtils.ts @@ -0,0 +1,39 @@ +import { AppEnv } from "@shared/models/genModels.js"; +import { Svix } from "svix"; + +export const createSvixCli = () => { + return new Svix(process.env.SVIX_API_KEY as string); +}; + +export const createSvixApp = async ({ + name, + orgId, + env, +}: { + name: string; + orgId: string; + env: AppEnv; +}) => { + const svix = createSvixCli(); + const app = await svix.application.create({ + name, + metadata: { + org_id: orgId, + env, + }, + }); + return app; +}; + +export const deleteSvixApp = async ({ appId }: { appId: string }) => { + const svix = createSvixCli(); + await svix.application.delete(appId); +}; + +export const sendSvixEvent = async (event: any) => { + const svix = createSvixCli(); + await svix.message.create("app_2tKDzBZtEBMQoybfckgdnb3BlJ0", { + eventType: "product.attached", + payload: event, + }); +}; diff --git a/server/src/external/webhooks/clerkWebhooks.ts b/server/src/external/webhooks/clerkWebhooks.ts index 5d277e6d9..aee0eb0f5 100644 --- a/server/src/external/webhooks/clerkWebhooks.ts +++ b/server/src/external/webhooks/clerkWebhooks.ts @@ -24,6 +24,12 @@ import { ProductService } from "@/internal/products/ProductService.js"; import { EntitlementService } from "@/internal/products/entitlements/EntitlementService.js"; import { PriceService } from "@/internal/prices/PriceService.js"; import { getBillingType } from "@/internal/prices/priceUtils.js"; +import { createSvixApp, deleteSvixApp } from "../svix/svixUtils.js"; +import { + deleteStripeWebhook, + initOrgSvixApps, +} from "@/internal/orgs/orgUtils.js"; +import { createStripeCli } from "../stripe/utils.js"; const defaultFeatures = [ { @@ -222,11 +228,13 @@ export const handleClerkWebhook = async (req: any, res: any) => { break; case "organization.deleted": - await OrgService.delete({ - sb: req.sb, - orgId: eventData.id, - }); - console.log(`Deleted org ${eventData.id}`); + await handleOrgDeleted(req.sb, eventData); + break; + // await OrgService.delete({ + // sb: req.sb, + // orgId: eventData.id, + // }); + // console.log(`Deleted org ${eventData.id}`); default: break; @@ -248,7 +256,17 @@ export const handleClerkWebhook = async (req: any, res: any) => { }; const handleOrgCreated = async (sb: SupabaseClient, eventData: any) => { + console.log( + `Handling organization.created: ${eventData.slug} (${eventData.id})` + ); try { + // 1. Create svix webhoooks + const { sandboxApp, liveApp } = await initOrgSvixApps({ + slug: eventData.slug, + id: eventData.id, + }); + + // 2. Insert org await OrgService.insert({ sb, org: { @@ -259,6 +277,11 @@ const handleOrgCreated = async (sb: SupabaseClient, eventData: any) => { stripe_config: null, test_pkey: generatePublishableKey(AppEnv.Sandbox), live_pkey: generatePublishableKey(AppEnv.Live), + created_at: eventData.created_at, + svix_config: { + sandbox_app_id: sandboxApp.id, + live_app_id: liveApp.id, + }, }, }); @@ -292,3 +315,63 @@ const handleOrgCreated = async (sb: SupabaseClient, eventData: any) => { ); } }; + +const handleOrgDeleted = async (sb: SupabaseClient, eventData: any) => { + console.log( + `Handling organization.deleted: ${eventData.slug} (${eventData.id})` + ); + + const org = await OrgService.getFullOrg({ + sb, + orgId: eventData.id, + }); + + // 1. Delete svix webhooks + + try { + console.log("1. Deleting svix webhooks"); + const batch = []; + if (org.svix_config.sandbox_app_id) { + batch.push( + deleteSvixApp({ + appId: org.svix_config.sandbox_app_id, + }) + ); + } + if (org.svix_config.live_app_id) { + batch.push( + deleteSvixApp({ + appId: org.svix_config.live_app_id, + }) + ); + } + + await Promise.all(batch); + + // 2. Delete stripe webhooks + console.log("2. Deleting stripe webhooks"); + if (org.stripe_config) { + await deleteStripeWebhook({ + org: org, + env: AppEnv.Sandbox, + }); + + await deleteStripeWebhook({ + org: org, + env: AppEnv.Live, + }); + } + + // 3. Delete org + console.log("3. Deleting org"); + await OrgService.delete({ + sb, + orgId: eventData.id, + }); + + console.log(`Deleted org ${org.slug} (${org.id})`); + } catch (error) { + console.log("Failed to delete organization", error); + return; + } +}; diff --git a/server/src/external/webhooks/webhooksRouter.ts b/server/src/external/webhooks/webhooksRouter.ts index 863fc0123..a6ec2db4e 100644 --- a/server/src/external/webhooks/webhooksRouter.ts +++ b/server/src/external/webhooks/webhooksRouter.ts @@ -2,11 +2,14 @@ import express from "express"; import bodyParser from "body-parser"; import { handleClerkWebhook } from "./clerkWebhooks.js"; import { stripeWebhookRouter } from "../stripe/stripeWebhooks.js"; +import { autumnWebhookRouter } from "../autumn/autumnWebhookRouter.js"; const webhooksRouter = express.Router(); webhooksRouter.use("/stripe", stripeWebhookRouter); +webhooksRouter.use("/autumn", autumnWebhookRouter); + webhooksRouter.post( "/clerk", bodyParser.raw({ type: "application/json" }), diff --git a/server/src/internal/api/customers/cusRouter.ts b/server/src/internal/api/customers/cusRouter.ts index 2ee49ad3f..5033c21e5 100644 --- a/server/src/internal/api/customers/cusRouter.ts +++ b/server/src/internal/api/customers/cusRouter.ts @@ -557,15 +557,17 @@ cusRouter.post( // Reactivate current product const curActiveProducts = await CusService.getFullCusProducts({ sb: req.sb, - internalCustomerId: cusProduct.customer.internal_id, - withProduct: true, + internalCustomerId: cusProduct.internal_customer_id, inStatuses: [CusProductStatus.Active], + productGroup: cusProduct.product.group, + withProduct: true, }); - const activeProducts = curActiveProducts.filter( - (p: any) => p.product.group == cusProduct.product.group - ); - for (const activeProduct of activeProducts) { + for (const activeProduct of curActiveProducts) { + console.log( + "Reactivating current product:", + activeProduct.product.name + ); await stripeCli.subscriptions.update( activeProduct.processor.subscription_id!, { @@ -596,61 +598,6 @@ cusRouter.post( }); } } - // console.log(cusProduct); - // if (!cusProduct.product.is_add_on) { - // if (cusProduct.status == CusProductStatus.Scheduled) { - // console.log( - // `Cancelling scheduled product ${cusProduct.product.name} for ${customerId}` - // ); - - // try { - // await stripeCli.subscriptionSchedules.cancel( - // cusProduct.processor.subscription_schedule_id! - // ); - // } catch (error: any) { - // console.log("Failed to cancel scheduled product:", error.message); - // } - - // console.log("Updating status to expired"); - // await CusProductService.deleteFutureProduct({ - // sb: req.sb, - // internalCustomerId: cusProduct.customer.internal_id, - // productGroup: cusProduct.product.group, - // }); - - // // Re activate current product - // console.log("Reactivating current product"); - // const curActiveProducts = await CusService.getFullCusProducts({ - // sb: req.sb, - // internalCustomerId: cusProduct.customer.internal_id, - // withProduct: true, - // inStatuses: [CusProductStatus.Active], - // }); - - // const activeProducts = curActiveProducts.filter( - // (p: any) => p.product.group == cusProduct.product.group - // ); - // for (const activeProduct of activeProducts) { - // await stripeCli.subscriptions.update( - // activeProduct.processor.subscription_id!, - // { - // cancel_at: null, - // } - // ); - // } - // } else if (!cusProduct.processor.subscription_id) { - // // Don't need to delete, stripe will do it... - // // console.log( - // // `Expiring product ${cusProduct.product.name} for ${customerId} (attaching default if exists)` - // // ); - // await expireAndAddDefaultProduct({ - // sb: req.sb, - // org, - // env: req.env, - // cusProduct, - // }); - // } - // } } if (!cusProduct) { diff --git a/server/src/internal/api/customers/products/cusProductRouter.ts b/server/src/internal/api/customers/products/cusProductRouter.ts index 0178fab8d..22120703d 100644 --- a/server/src/internal/api/customers/products/cusProductRouter.ts +++ b/server/src/internal/api/customers/products/cusProductRouter.ts @@ -26,7 +26,10 @@ import { } from "@/internal/prices/priceUtils.js"; import { PricesInput } from "@autumn/shared"; import { getFullCusProductData } from "../../../customers/products/cusProductUtils.js"; -import { isFreeProduct } from "@/internal/products/productUtils.js"; +import { + isFreeProduct, + isProductUpgrade, +} from "@/internal/products/productUtils.js"; import { handleAddFreeProduct } from "@/internal/customers/add-product/handleAddFreeProduct.js"; import { handleCreateCheckout } from "@/internal/customers/add-product/handleCreateCheckout.js"; import { createStripeCli } from "@/external/stripe/utils.js"; @@ -41,6 +44,7 @@ import { createStripePriceIFNotExist, } from "@/external/stripe/stripePriceUtils.js"; import { handleInvoiceOnly } from "@/internal/customers/add-product/handleInvoiceOnly.js"; +import { notNullOrUndefined } from "@/utils/genUtils.js"; export const attachRouter = Router(); @@ -88,13 +92,21 @@ export const checkAddProductErrors = async ({ // Get options for price let priceEnt = getPriceEntitlement(price, entitlements); let options = getEntOptions(optionsList, priceEnt); - if (!options?.quantity) { + if (!notNullOrUndefined(options?.quantity)) { throw new RecaseError({ message: `Pass in 'quantity' for feature ${priceEnt.feature_id} in options`, code: ErrCode.InvalidOptions, statusCode: 400, }); } + + if (options?.quantity === 0 && prices.length === 0) { + throw new RecaseError({ + message: `When there's only one price, quantity must be greater than 0`, + code: ErrCode.InvalidOptions, + statusCode: 400, + }); + } } else if (billingType === BillingType.UsageBelowThreshold) { let priceEnt = getPriceEntitlement(price, entitlements); let options = getEntOptions(optionsList, priceEnt); @@ -194,23 +206,30 @@ export const handleExistingProduct = async ({ }); } - const curPrices = - currentProduct?.customer_prices.map((cp: any) => cp.price) || []; - // If there's current product and it's not free and new product is a switch - if ( - currentProduct && - !isFreeProduct(curPrices) && - !product.is_add_on && - useCheckout - ) { - throw new RecaseError({ - message: `Can't use checkout for upgrades / downgrades`, - code: ErrCode.InvalidRequest, - statusCode: 400, - }); + if (currentProduct && useCheckout) { + // If not downgrade to free, throw error + let downgradeToFree = + !isProductUpgrade(currentProduct.product, product) && + isFreeProduct(attachParams.prices); + + let upgradeFromFree = + isProductUpgrade(currentProduct.product, product) && + isFreeProduct( + currentProduct?.customer_prices.map((cp: any) => cp.price) || [] + ); + + let isAddOn = attachParams.product.is_add_on; + + if (!downgradeToFree && !upgradeFromFree && !isAddOn) { + throw new RecaseError({ + message: `Either payment method not found, or force_checkout is true: unable to perform upgrade / downgrade`, + code: ErrCode.InvalidRequest, + statusCode: 400, + }); + } } - return { currentProduct, done: false }; + return { curCusProduct: currentProduct, done: false }; }; export const checkStripeConnections = async ({ @@ -295,6 +314,21 @@ export const checkStripeConnections = async ({ await Promise.all(batchPriceUpdates); }; +export const customerHasPm = async ({ + attachParams, +}: { + attachParams: AttachParams; +}) => { + // SCENARIO 3: No payment method, checkout required + const paymentMethod = await getCusPaymentMethod({ + org: attachParams.org, + env: attachParams.customer.env, + stripeId: attachParams.customer.processor.id, + }); + + return notNullOrUndefined(paymentMethod) ? true : false; +}; + attachRouter.post("/attach", async (req: any, res) => { const { customer_id, @@ -318,14 +352,13 @@ attachRouter.post("/attach", async (req: any, res) => { const optionsListInput: FeatureOptions[] = options || []; const invoiceOnly = invoice_only || false; - const useCheckout = force_checkout || false; + let forceCheckout = force_checkout || false; console.log("--------------------------------"); console.log(`ATTACH PRODUCT REQUEST (from ${req.minOrg.slug})`); try { z.array(FeatureOptionsSchema).parse(optionsListInput); - // 1. Get full customer product data - const attachParams = await getFullCusProductData({ + const attachParams: AttachParams = await getFullCusProductData({ sb, customerId: customer_id, productId: product_id, @@ -339,6 +372,23 @@ attachRouter.post("/attach", async (req: any, res) => { isCustom: is_custom, }); + console.log( + `Customer: ${chalk.yellow( + `${attachParams.customer.id} (${attachParams.customer.name})` + )}` + ); + + // 3. Check for stripe connection + await checkStripeConnections({ req, res, attachParams }); + + let hasPm = await customerHasPm({ attachParams }); + const useCheckout = !hasPm || forceCheckout; + console.log( + `Has PM: ${chalk.yellow(hasPm)}, Force Checkout: ${chalk.yellow( + forceCheckout + )}, Use Checkout: ${chalk.yellow(useCheckout)}` + ); + // -------------------- ERROR CHECKING -------------------- // 1. Check for normal errors (eg. options, different recurring intervals) @@ -347,14 +397,8 @@ attachRouter.post("/attach", async (req: any, res) => { useCheckout, }); - console.log( - `Customer: ${chalk.yellow( - `${attachParams.customer.id} (${attachParams.customer.name})` - )}` - ); - // 2. Check for existing product and fetch - const { currentProduct, done } = await handleExistingProduct({ + const { curCusProduct, done } = await handleExistingProduct({ req, res, attachParams, @@ -363,20 +407,18 @@ attachRouter.post("/attach", async (req: any, res) => { if (done) return; - // 3. Check for stripe connection - await checkStripeConnections({ req, res, attachParams }); - // -------------------- ATTACH PRODUCT -------------------- // SCENARIO 1: Free product, no existing product - const curProductFree = isFreeProduct( - currentProduct?.customer_prices.map((cp: any) => cp.price) || [] // if no current product... + curCusProduct?.customer_prices.map((cp: any) => cp.price) || [] // if no current product... ); + const newProductFree = isFreeProduct(attachParams.prices); + attachParams.curCusProduct = !curProductFree ? curCusProduct : null; if ( - (!currentProduct && newProductFree) || + (!curCusProduct && newProductFree) || (curProductFree && newProductFree) || (attachParams.product.is_add_on && newProductFree) ) { @@ -395,19 +437,12 @@ attachRouter.post("/attach", async (req: any, res) => { req, res, attachParams, - curCusProduct: currentProduct, + curCusProduct, }); return; } - // SCENARIO 3: No payment method, checkout required - const paymentMethod = await getCusPaymentMethod({ - org: attachParams.org, - env: attachParams.customer.env, - stripeId: attachParams.customer.processor.id, - }); - - if (!paymentMethod || useCheckout) { + if (useCheckout) { console.log("SCENARIO 2: NO PAYMENT METHOD, CHECKOUT REQUIRED"); await handleCreateCheckout({ sb, @@ -418,13 +453,14 @@ attachRouter.post("/attach", async (req: any, res) => { } // SCENARIO 4: Switching product - if (!attachParams.product.is_add_on && currentProduct) { + + if (!attachParams.product.is_add_on && curCusProduct) { console.log("SCENARIO 3: SWITCHING PRODUCT (PAYMENT METHOD EXISTS)"); await handleChangeProduct({ req, res, attachParams, - curCusProduct: currentProduct, + curCusProduct, }); return; } diff --git a/server/src/internal/customers/CusService.ts b/server/src/internal/customers/CusService.ts index 0e666b899..054f5ab0d 100644 --- a/server/src/internal/customers/CusService.ts +++ b/server/src/internal/customers/CusService.ts @@ -536,12 +536,14 @@ export class CusService { withPrices = false, withProduct = false, inStatuses, + productGroup, }: { sb: SupabaseClient; internalCustomerId: string; withProduct?: boolean; withPrices?: boolean; inStatuses?: CusProductStatus[]; + productGroup?: string; }) { const selectQuery = [ "*", @@ -549,7 +551,7 @@ export class CusService { withPrices ? "customer_prices:customer_prices(*, price:prices!inner(*))" : "", - `customer_entitlements:customer_entitlements!inner(*, + `customer_entitlements:customer_entitlements(*, entitlement:entitlements(*, feature:features!inner(*) ) @@ -567,6 +569,10 @@ export class CusService { query.in("status", inStatuses); } + if (productGroup) { + query.eq("product.group", productGroup); + } + // query.limit(100); // TODO: Limit 100 cus products? (for one time add ons...) // SORT by created_at? diff --git a/server/src/internal/customers/add-product/createFullCusProduct.ts b/server/src/internal/customers/add-product/createFullCusProduct.ts index 3231bf25d..3d0331fb0 100644 --- a/server/src/internal/customers/add-product/createFullCusProduct.ts +++ b/server/src/internal/customers/add-product/createFullCusProduct.ts @@ -9,12 +9,11 @@ import { FeatureOptions, FreeTrial, BillingType, - UsagePriceConfig, CollectionMethod, } from "@autumn/shared"; import { generateId } from "@/utils/genUtils.js"; import { getNextEntitlementReset } from "@/utils/timeUtils.js"; -import { Customer, Feature, FeatureType } from "@autumn/shared"; +import { Customer, FeatureType } from "@autumn/shared"; import { EntitlementWithFeature, FullProduct } from "@autumn/shared"; import { SupabaseClient } from "@supabase/supabase-js"; import { ErrCode } from "@/errors/errCodes.js"; diff --git a/server/src/internal/customers/add-product/handleAddProduct.ts b/server/src/internal/customers/add-product/handleAddProduct.ts index 5826a543b..f5d96c903 100644 --- a/server/src/internal/customers/add-product/handleAddProduct.ts +++ b/server/src/internal/customers/add-product/handleAddProduct.ts @@ -3,7 +3,6 @@ import { getBillNowPrices, getPriceEntitlement, getPriceOptions, - getStripeSubItems, pricesOnlyOneOff, } from "@/internal/prices/priceUtils.js"; @@ -20,6 +19,7 @@ import { InvoiceService } from "../invoices/InvoiceService.js"; import { payForInvoice } from "@/external/stripe/stripeInvoiceUtils.js"; import { createStripeSubscription } from "@/external/stripe/stripeSubUtils.js"; import { handleCreateCheckout } from "./handleCreateCheckout.js"; +import { getStripeSubItems } from "@/external/stripe/stripePriceUtils.js"; const handleBillNowPrices = async ({ sb, @@ -34,7 +34,7 @@ const handleBillNowPrices = async ({ const stripeCli = createStripeCli({ org, env: customer.env }); - const { items, itemMetas } = getStripeSubItems({ + const { items, itemMetas } = await getStripeSubItems({ attachParams, }); diff --git a/server/src/internal/customers/add-product/handleCreateCheckout.ts b/server/src/internal/customers/add-product/handleCreateCheckout.ts index 452032372..bde66e8f0 100644 --- a/server/src/internal/customers/add-product/handleCreateCheckout.ts +++ b/server/src/internal/customers/add-product/handleCreateCheckout.ts @@ -1,15 +1,14 @@ import { createStripeCli } from "@/external/stripe/utils.js"; -import { - getStripeSubItems, - pricesContainRecurring, -} from "@/internal/prices/priceUtils.js"; +import { pricesContainRecurring } from "@/internal/prices/priceUtils.js"; import { createCheckoutMetadata } from "@/internal/metadata/metadataUtils.js"; import { AttachParams } from "../products/AttachParams.js"; import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/freeTrialUtils.js"; import { SupabaseClient } from "@supabase/supabase-js"; - +import { BillingType, FixedPriceConfig } from "@autumn/shared"; +import { differenceInDays, format } from "date-fns"; +import { getStripeSubItems } from "@/external/stripe/stripePriceUtils.js"; export const handleCreateCheckout = async ({ sb, res, @@ -23,7 +22,7 @@ export const handleCreateCheckout = async ({ `Creating checkout for customer ${attachParams.customer.id}, product ${attachParams.product.name}` ); - const { customer, org, freeTrial } = attachParams; + const { customer, org, freeTrial, curCusProduct } = attachParams; const stripeCli = createStripeCli({ org, @@ -31,7 +30,7 @@ export const handleCreateCheckout = async ({ }); // Get stripeItems - const { items, itemMetas } = getStripeSubItems({ + const { items, itemMetas } = await getStripeSubItems({ attachParams, isCheckout: true, }); diff --git a/server/src/internal/customers/add-product/handleInvoiceOnly.ts b/server/src/internal/customers/add-product/handleInvoiceOnly.ts index 3e813d32e..3eaceb9e6 100644 --- a/server/src/internal/customers/add-product/handleInvoiceOnly.ts +++ b/server/src/internal/customers/add-product/handleInvoiceOnly.ts @@ -2,7 +2,6 @@ import { createStripeSubscription } from "@/external/stripe/stripeSubUtils.js"; import { createStripeCli } from "@/external/stripe/utils.js"; import { getBillNowPrices, - getStripeSubItems, pricesOnlyOneOff, } from "@/internal/prices/priceUtils.js"; import { createFullCusProduct } from "./createFullCusProduct.js"; @@ -18,7 +17,10 @@ import { import { SupabaseClient } from "@supabase/supabase-js"; import { CusProductService } from "../products/CusProductService.js"; import Stripe from "stripe"; -import { pricesToInvoiceItems } from "@/external/stripe/stripePriceUtils.js"; +import { + getStripeSubItems, + pricesToInvoiceItems, +} from "@/external/stripe/stripePriceUtils.js"; export const voidLatestInvoice = async ({ stripeCli, @@ -174,7 +176,7 @@ export const handleInvoiceOnly = async ({ // 1. Create stripe subscription (with invoice) console.log(" - Creating stripe subscription"); - const { items, itemMetas } = getStripeSubItems({ + const { items, itemMetas } = await getStripeSubItems({ attachParams, }); diff --git a/server/src/internal/customers/change-product/handleChangeProduct.ts b/server/src/internal/customers/change-product/handleChangeProduct.ts index cddfad0c4..694448941 100644 --- a/server/src/internal/customers/change-product/handleChangeProduct.ts +++ b/server/src/internal/customers/change-product/handleChangeProduct.ts @@ -1,5 +1,5 @@ import { createStripeCli } from "@/external/stripe/utils.js"; -import { getStripeSubItems } from "@/internal/prices/priceUtils.js"; +import { getStripeSubItems } from "@/external/stripe/stripePriceUtils.js"; import { ProductService } from "@/internal/products/ProductService.js"; import { isFreeProduct, @@ -16,10 +16,8 @@ import { handleAddProduct } from "../add-product/handleAddProduct.js"; import { CusProductService } from "../products/CusProductService.js"; import { AttachParams } from "../products/AttachParams.js"; import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/freeTrialUtils.js"; -import chalk from "chalk"; -import RecaseError, { isPaymentDeclined } from "@/utils/errorUtils.js"; import { updateStripeSubscription } from "@/external/stripe/stripeSubUtils.js"; -import { handleCreateCheckout } from "../add-product/handleCreateCheckout.js"; +import { InvoiceService } from "../invoices/InvoiceService.js"; const scheduleStripeSubscription = async ({ attachParams, @@ -32,7 +30,7 @@ const scheduleStripeSubscription = async ({ }) => { const { org, customer } = attachParams; - const { items, itemMetas } = getStripeSubItems({ + const { items, itemMetas } = await getStripeSubItems({ attachParams, }); @@ -151,7 +149,7 @@ const handleStripeSubUpdate = async ({ const subscription = await stripeCli.subscriptions.retrieve(subscriptionId); // Get stripe subscription from product - const { items, itemMetas } = getStripeSubItems({ + const { items, itemMetas } = await getStripeSubItems({ attachParams, }); @@ -215,10 +213,27 @@ const handleUpgrade = async ({ return; } - const disableFreeTrial = - curFullProduct.free_trial && org.config?.free_trial_paid_to_paid; + // 2. If current product is a trial, just start a new period + if (curCusProduct.trial_ends_at && curCusProduct.trial_ends_at > Date.now()) { + console.log( + "NOTE: Current product is a trial, cancel and start new subscription" + ); - // Maybe do it such that if cur cus product has no subscription ID, we just create a new one? + await handleAddProduct({ + req, + res, + attachParams, + }); + + await stripeCli.subscriptions.cancel( + curCusProduct.processor?.subscription_id! + ); + + return; + } + // const disableFreeTrial = + // curCusProduct.free_trial_id && org.config?.free_trial_paid_to_paid; + const disableFreeTrial = false; console.log("1. Updating current subscription to new product"); let subUpdate; @@ -226,6 +241,7 @@ const handleUpgrade = async ({ subscriptionId: curCusProduct.processor?.subscription_id!, stripeCli, attachParams, + disableFreeTrial, }); // Handle backend @@ -240,6 +256,19 @@ const handleUpgrade = async ({ disableFreeTrial, }); + // // Insert latest invoice + // const stripeInvoice = await stripeCli.invoices.retrieve( + // subUpdate.latest_invoice as string + // ); + // await InvoiceService.createInvoiceFromStripe({ + // sb: req.sb, + // stripeInvoice, + // internalCustomerId: customer.id, + // org: org, + // productIds: [product.id], + // internalProductIds: [product.id], + // }); + res.status(200).json({ success: true, message: "Product change handled" }); }; @@ -259,7 +288,7 @@ export const handleChangeProduct = async ({ const { org, customer, product, prices, entitlements, optionsList } = attachParams; - const curFullProduct = await ProductService.getFullProduct({ + const curFullProduct = await ProductService.getFullProductStrict({ sb: req.sb, productId: curProduct.id, orgId: org.id, diff --git a/server/src/internal/customers/products/AttachParams.ts b/server/src/internal/customers/products/AttachParams.ts index 5f3205967..866c026b5 100644 --- a/server/src/internal/customers/products/AttachParams.ts +++ b/server/src/internal/customers/products/AttachParams.ts @@ -3,6 +3,7 @@ import { EntitlementWithFeature, FeatureOptions, FreeTrial, + FullCusProduct, FullProduct, Organization, Price, @@ -17,4 +18,6 @@ export type AttachParams = { entitlements: EntitlementWithFeature[]; freeTrial: FreeTrial | null; optionsList: FeatureOptions[]; + + curCusProduct?: FullCusProduct | undefined; }; diff --git a/server/src/internal/customers/products/CusProductService.ts b/server/src/internal/customers/products/CusProductService.ts index 80e5c2c59..cac72f890 100644 --- a/server/src/internal/customers/products/CusProductService.ts +++ b/server/src/internal/customers/products/CusProductService.ts @@ -90,15 +90,29 @@ export class CusProductService { static async getByInternalCusId({ sb, cusId, + inStatuses, + productGroup, }: { sb: SupabaseClient; cusId: string; + inStatuses?: string[]; + productGroup?: string; }) { - const { data, error } = await sb + const query = sb .from("customer_products") .select("*, product:products!inner(*)") .eq("internal_customer_id", cusId); + if (inStatuses) { + query.in("status", inStatuses); + } + + if (productGroup) { + query.eq("product.group", productGroup); + } + + const { data, error } = await query; + if (error) { throw error; } @@ -120,7 +134,7 @@ export class CusProductService { .select( ` *, - product:products!inner(*), + product:products!inner(*, prices(*)), customer_prices:customer_prices(*, price:prices!inner(*)) ` ) diff --git a/server/src/internal/metadata/metadataUtils.ts b/server/src/internal/metadata/metadataUtils.ts index 86295a18c..660efbe4f 100644 --- a/server/src/internal/metadata/metadataUtils.ts +++ b/server/src/internal/metadata/metadataUtils.ts @@ -8,6 +8,8 @@ import { Organization, AppEnv, FeatureOptions, + CusProduct, + FullCusProduct, } from "@autumn/shared"; import { generateId } from "@/utils/genUtils.js"; diff --git a/server/src/internal/orgs/orgUtils.ts b/server/src/internal/orgs/orgUtils.ts index 745fde622..8e7b24f17 100644 --- a/server/src/internal/orgs/orgUtils.ts +++ b/server/src/internal/orgs/orgUtils.ts @@ -1,6 +1,60 @@ import { decryptData } from "@/utils/encryptUtils.js"; import RecaseError from "@/utils/errorUtils.js"; import { AppEnv, ErrCode, Organization } from "@autumn/shared"; +import { createSvixApp } from "@/external/svix/svixUtils.js"; +import { createStripeCli } from "@/external/stripe/utils.js"; + +export const initOrgSvixApps = async ({ + id, + slug, +}: { + id: string; + slug: string; +}) => { + const batchCreate = []; + batchCreate.push( + createSvixApp({ + name: `${slug}_${AppEnv.Sandbox}`, + orgId: id, + env: AppEnv.Sandbox, + }) + ); + batchCreate.push( + createSvixApp({ + name: `${slug}_${AppEnv.Live}`, + orgId: id, + env: AppEnv.Live, + }) + ); + + const [sandboxApp, liveApp] = await Promise.all(batchCreate); + + return { sandboxApp, liveApp }; +}; + +export const deleteStripeWebhook = async ({ + org, + env, +}: { + org: Organization; + env: AppEnv; +}) => { + const stripeCli = createStripeCli({ org, env }); + const webhookEndpoints = await stripeCli.webhookEndpoints.list({ + limit: 100, + }); + + for (const webhook of webhookEndpoints.data) { + if (webhook.url.includes(org.id)) { + try { + await stripeCli.webhookEndpoints.del(webhook.id); + } catch (error: any) { + console.log(`Failed to delete stripe webhook (${env}) ${webhook.url}`); + console.log(error.message); + } + } + } +}; export const getStripeWebhookSecret = (org: Organization, env: AppEnv) => { if (!org.stripe_config) { diff --git a/server/src/internal/prices/priceUtils.ts b/server/src/internal/prices/priceUtils.ts index 28c9a2a53..da0cee279 100644 --- a/server/src/internal/prices/priceUtils.ts +++ b/server/src/internal/prices/priceUtils.ts @@ -215,44 +215,6 @@ export function compareBillingIntervals( return priority[a] - priority[b]; } -// Stripe items -export const getStripeSubItems = ({ - attachParams, - isCheckout = false, -}: { - attachParams: AttachParams; - isCheckout?: boolean; -}) => { - const { product, prices, entitlements, optionsList, org } = attachParams; - // const billNowPrices = getBillNowPrices(prices); - const checkoutRelevantPrices = getCheckoutRelevantPrices(prices); - - let subItems: any[] = []; - let itemMetas: any[] = []; - - // TODO: Check if non bill now prices can be added to stripe subscription...? - for (const price of checkoutRelevantPrices) { - const priceEnt = getPriceEntitlement(price, entitlements); - const options = getEntOptions(optionsList, priceEnt); - - const { lineItem, lineItemMeta } = priceToStripeItem({ - price, - product, - org, - options, - isCheckout, - relatedEnt: priceEnt, - }); - - subItems.push(lineItem); - itemMetas.push(lineItemMeta); - } - - console.log("Line items: ", subItems); - - return { items: subItems, itemMetas }; -}; - export const getUsageTier = (price: Price, quantity: number) => { let usageConfig = price.config as UsagePriceConfig; for (let i = 0; i < usageConfig.usage_tiers.length; i++) { diff --git a/server/src/internal/products/ProductService.ts b/server/src/internal/products/ProductService.ts index 7545971ca..31601d1bb 100644 --- a/server/src/internal/products/ProductService.ts +++ b/server/src/internal/products/ProductService.ts @@ -197,40 +197,6 @@ export class ProductService { return data; } - static async getFullProduct({ - sb, - productId, - orgId, - env, - }: { - sb: SupabaseClient; - productId: string; - orgId: string; - env: AppEnv; - }) { - const { data, error } = await sb - .from("products") - .select( - `*, - entitlements ( - *, - feature:features (id, name, type) - ), - prices (*) - ` - ) - .eq("id", productId) - .eq("org_id", orgId) - .eq("env", env) - .single(); - - if (error) { - throw error; - } - - return data; - } - static async getFullProductStrict({ sb, productId, diff --git a/shared/models/orgModels.ts b/shared/models/orgModels.ts index 241c8ff9f..e60abcc7e 100644 --- a/shared/models/orgModels.ts +++ b/shared/models/orgModels.ts @@ -13,6 +13,11 @@ export const StripeConfigSchema = z.object({ success_url: z.string(), }); +export const SvixConfigSchema = z.object({ + sandbox_app_id: z.string(), + live_app_id: z.string(), +}); + export const OrganizationSchema = z.object({ id: z.string(), slug: z.string(), @@ -21,6 +26,12 @@ export const OrganizationSchema = z.object({ stripe_config: StripeConfigSchema.optional().nullable(), test_pkey: z.string(), live_pkey: z.string(), + created_at: z.number(), + + svix_config: z.object({ + sandbox_app_id: z.string(), + live_app_id: z.string(), + }), config: z .object({