From c820940f7a04b6da65d645f403c9bffa8ebe23c5 Mon Sep 17 00:00:00 2001 From: Charlie Lamb Date: Thu, 1 Jan 2026 16:32:19 +0000 Subject: [PATCH] feat: subscription update invoice mode --- .../stripeSubUtils/getStripeSubItems.ts | 2 +- .../handleDeferredAutumnBillingPlan.ts | 28 ++ .../handleInvoicePaidMetadata.ts | 8 + .../internal/billing/v2/FOLDER_STRUCTURE.md | 2 +- server/src/internal/billing/v2/billingPlan.ts | 19 + .../billing/v2/execute/executeBillingPlan.ts | 61 ++- .../v2/execute/executeStripeInvoiceAction.ts | 31 -- .../buildStripeInvoiceAction.ts | 9 +- .../deferred/storeSubscriptionUpdatePlan.ts | 41 ++ .../utils/invoices/createAndPayInvoice.ts | 82 ---- .../utils/invoices/createInvoiceForBilling.ts | 69 +++ .../stripe/utils/invoices/stripeInvoiceOps.ts | 3 + .../evaluateSubscriptionUpdatePlan.ts | 13 +- .../subscription-update-invoice-mode.test.ts | 460 ++++++++++++++++++ .../subscription-update-invoicing.test.ts | 26 - server/tests/utils/stripeUtils.ts | 4 +- .../stripeUtils/completeInvoiceCheckout.ts | 4 +- .../completeInvoiceConfirmation.ts | 4 +- shared/models/otherModels/metadataTable.ts | 1 + 19 files changed, 696 insertions(+), 171 deletions(-) create mode 100644 server/src/external/stripe/webhookHandlers/handleInvoicePaid/handleDeferredAutumnBillingPlan.ts delete mode 100644 server/src/internal/billing/v2/execute/executeStripeInvoiceAction.ts create mode 100644 server/src/internal/billing/v2/providers/stripe/utils/deferred/storeSubscriptionUpdatePlan.ts delete mode 100644 server/src/internal/billing/v2/providers/stripe/utils/invoices/createAndPayInvoice.ts create mode 100644 server/src/internal/billing/v2/providers/stripe/utils/invoices/createInvoiceForBilling.ts create mode 100644 server/tests/billing/subscription-update/subscription-update-invoice-mode.test.ts diff --git a/server/src/external/stripe/stripeSubUtils/getStripeSubItems.ts b/server/src/external/stripe/stripeSubUtils/getStripeSubItems.ts index b2168dd73..a40de52e1 100644 --- a/server/src/external/stripe/stripeSubUtils/getStripeSubItems.ts +++ b/server/src/external/stripe/stripeSubUtils/getStripeSubItems.ts @@ -173,7 +173,7 @@ export const getStripeSubItems = async ({ continue; } - const lineItem = stripeItem; + const { lineItem } = stripeItem; subItems.push(lineItem); } diff --git a/server/src/external/stripe/webhookHandlers/handleInvoicePaid/handleDeferredAutumnBillingPlan.ts b/server/src/external/stripe/webhookHandlers/handleInvoicePaid/handleDeferredAutumnBillingPlan.ts new file mode 100644 index 000000000..34b06cc60 --- /dev/null +++ b/server/src/external/stripe/webhookHandlers/handleInvoicePaid/handleDeferredAutumnBillingPlan.ts @@ -0,0 +1,28 @@ +import type { Metadata } from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import type { DeferredAutumnBillingPlanData } from "@/internal/billing/v2/billingPlan"; +import { executeAutumnBillingPlan } from "@/internal/billing/v2/execute/executeAutumnBillingPlan"; +import { MetadataService } from "@/internal/metadata/MetadataService"; + +export const handleDeferredAutumnBillingPlan = async ({ + ctx, + metadata, +}: { + ctx: AutumnContext; + metadata: Metadata; +}) => { + const { logger, db } = ctx; + const data = metadata.data as DeferredAutumnBillingPlanData; + + if (data.orgId !== ctx.org.id || data.env !== ctx.env) { + logger.warn("Deferred billing plan org/env mismatch, skipping"); + return; + } + + await executeAutumnBillingPlan({ + ctx, + autumnBillingPlan: data.autumnBillingPlan, + }); + + await MetadataService.delete({ db, id: metadata.id }); +}; diff --git a/server/src/external/stripe/webhookHandlers/handleInvoicePaid/handleInvoicePaidMetadata.ts b/server/src/external/stripe/webhookHandlers/handleInvoicePaid/handleInvoicePaidMetadata.ts index 4cc295e85..adb20ac97 100644 --- a/server/src/external/stripe/webhookHandlers/handleInvoicePaid/handleInvoicePaidMetadata.ts +++ b/server/src/external/stripe/webhookHandlers/handleInvoicePaid/handleInvoicePaidMetadata.ts @@ -3,6 +3,7 @@ import type Stripe from "stripe"; import type { AutumnContext } from "../../../../honoUtils/HonoEnv"; import type { AttachParams } from "../../../../internal/customers/cusProducts/AttachParams"; import { MetadataService } from "../../../../internal/metadata/MetadataService"; +import { handleDeferredAutumnBillingPlan } from "./handleDeferredAutumnBillingPlan"; import { handleInvoiceActionRequiredCompleted } from "./handleInvoiceActionRequiredCompleted"; import { handleInvoiceCheckoutPaid } from "./handleInvoiceCheckoutPaid"; @@ -24,6 +25,13 @@ export const handleInvoicePaidMetadata = async ({ if (!metadata) return; + // Handle deferred billing plan (v2 flow) + if (metadata.type === MetadataType.DeferredAutumnBillingPlan) { + await handleDeferredAutumnBillingPlan({ ctx, metadata }); + return; + } + + // Legacy v1 flows below const data = metadata.data as unknown as AttachParams; const reqMatch = data.org?.id === ctx.org.id && data.customer?.env === ctx.env; diff --git a/server/src/internal/billing/v2/FOLDER_STRUCTURE.md b/server/src/internal/billing/v2/FOLDER_STRUCTURE.md index 529458298..289717ad4 100644 --- a/server/src/internal/billing/v2/FOLDER_STRUCTURE.md +++ b/server/src/internal/billing/v2/FOLDER_STRUCTURE.md @@ -47,7 +47,7 @@ server/src/internal/billing/v2/ │ │ │ │ │ │ │ ├── invoice/ # Invoice operations │ │ │ │ ├── lineItemsToStripeLines.ts -│ │ │ │ ├── createAndPayInvoice.ts +│ │ │ │ ├── createInvoiceForBilling.ts │ │ │ │ ├── payStripeInvoice.ts │ │ │ │ └── index.ts │ │ │ │ diff --git a/server/src/internal/billing/v2/billingPlan.ts b/server/src/internal/billing/v2/billingPlan.ts index c55165a39..be2076f52 100644 --- a/server/src/internal/billing/v2/billingPlan.ts +++ b/server/src/internal/billing/v2/billingPlan.ts @@ -1,4 +1,5 @@ import { + type AppEnv, EntitlementSchema, FreeTrialSchema, LineItemSchema, @@ -59,8 +60,16 @@ export const StripeSubscriptionScheduleActionSchema = z.discriminatedUnion( ], ); +export const InvoiceModeSchema = z.object({ + finalizeInvoice: z.boolean().default(false), + enableProductImmediately: z.boolean().default(true), +}); + +export type InvoiceMode = z.infer; + export const StripeInvoiceActionSchema = z.object({ addLineParams: z.custom(), + invoiceMode: InvoiceModeSchema.optional(), }); export type StripeSubscriptionScheduleAction = z.infer< @@ -103,3 +112,13 @@ export const BillingPlanSchema = z.object({ export type BillingPlan = z.infer; export type AutumnBillingPlan = z.infer; export type StripeBillingPlan = z.infer; + +export type StripeInvoiceMetadata = { + autumn_metadata_id: string; +}; + +export type DeferredAutumnBillingPlanData = { + orgId: string; + env: AppEnv; + autumnBillingPlan: AutumnBillingPlan; +}; diff --git a/server/src/internal/billing/v2/execute/executeBillingPlan.ts b/server/src/internal/billing/v2/execute/executeBillingPlan.ts index cdff520a1..b2e238b1b 100644 --- a/server/src/internal/billing/v2/execute/executeBillingPlan.ts +++ b/server/src/internal/billing/v2/execute/executeBillingPlan.ts @@ -1,20 +1,25 @@ -import type Stripe from "stripe"; +import { MetadataType } from "@autumn/shared"; import { isStripeSubscriptionCanceled } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import type { BillingContext } from "@/internal/billing/v2/billingContext"; -import type { BillingPlan } from "@/internal/billing/v2/billingPlan"; +import type { + BillingPlan, + StripeInvoiceMetadata, +} from "@/internal/billing/v2/billingPlan"; import { addStripeSubscriptionIdToBillingPlan } from "@/internal/billing/v2/execute/addStripeSubscriptionIdToBillingPlan"; import { addStripeSubscriptionScheduleIdToBillingPlan } from "@/internal/billing/v2/execute/addStripeSubscriptionScheduleIdToBillingPlan"; import { executeAutumnBillingPlan } from "@/internal/billing/v2/execute/executeAutumnBillingPlan"; -import { executeStripeInvoiceAction } from "@/internal/billing/v2/execute/executeStripeInvoiceAction"; import { handleStripeSubscriptionUncancel } from "@/internal/billing/v2/execute/executeStripeSubscriptionActions/handleStripeSubscriptionUncancel"; import { removeStripeSubscriptionIdFromBillingPlan } from "@/internal/billing/v2/execute/removeStripeSubscriptionIdFromBillingPlan"; import { executeStripeSubscriptionAction } from "@/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionAction"; import { executeStripeSubscriptionScheduleAction } from "@/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionScheduleAction"; +import { createInvoiceForBilling } from "@/internal/billing/v2/providers/stripe/utils/invoices/createInvoiceForBilling"; import { logBillingPlan } from "@/internal/billing/v2/utils/logBillingPlan"; import { upsertInvoiceFromBilling } from "@/internal/billing/v2/utils/upsertFromStripe/upsertInvoiceFromBilling"; import { upsertSubscriptionFromBilling } from "@/internal/billing/v2/utils/upsertFromStripe/upsertSubscriptionFromBilling"; import { addSubIdToCache } from "@/internal/customers/cusCache/subCacheUtils"; +import { MetadataService } from "@/internal/metadata/MetadataService"; +import { generateId } from "@/utils/genUtils"; export const executeBillingPlan = async ({ ctx, @@ -35,17 +40,40 @@ export const executeBillingPlan = async ({ await handleStripeSubscriptionUncancel({ ctx, billingContext, billingPlan }); + const enableProductImmediately = + stripeInvoiceAction?.invoiceMode?.enableProductImmediately !== false; + if (stripeInvoiceAction) { - const result = await executeStripeInvoiceAction({ + let invoiceMetadata: StripeInvoiceMetadata | undefined; + + if (!enableProductImmediately) { + const metadataId = generateId("meta"); + await MetadataService.insert({ + db: ctx.db, + data: { + id: metadataId, + type: MetadataType.DeferredAutumnBillingPlan, + data: { + orgId: ctx.org.id, + env: ctx.env, + autumnBillingPlan: billingPlan.autumn, + }, + }, + }); + invoiceMetadata = { autumn_metadata_id: metadataId }; + } + + const { invoice } = await createInvoiceForBilling({ ctx, billingContext, stripeInvoiceAction, + invoiceMetadata, }); - if (result.invoice) { + if (invoice) { await upsertInvoiceFromBilling({ ctx, - stripeInvoice: result.invoice, + stripeInvoice: invoice, fullProducts: billingContext.fullProducts, fullCustomer: billingContext.fullCustomer, }); @@ -113,18 +141,13 @@ export const executeBillingPlan = async ({ } } - console.log( - "Inserting new customer product:", - billingPlan.autumn.insertCustomerProducts.map((cp) => ({ - name: cp.product.name, - id: cp.id, - status: cp.status, - })), - ); - await executeAutumnBillingPlan({ - ctx, - autumnBillingPlan: billingPlan.autumn, - }); + // if not enabling product immediately, it will be handled in webhook + if (enableProductImmediately) { + await executeAutumnBillingPlan({ + ctx, + autumnBillingPlan: billingPlan.autumn, + }); + } - return billingPlan; + return { billingPlan }; }; diff --git a/server/src/internal/billing/v2/execute/executeStripeInvoiceAction.ts b/server/src/internal/billing/v2/execute/executeStripeInvoiceAction.ts deleted file mode 100644 index 30e10bb98..000000000 --- a/server/src/internal/billing/v2/execute/executeStripeInvoiceAction.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { BillingContext } from "@/internal/billing/v2/billingContext"; -import { createStripeCli } from "../../../../external/connect/createStripeCli"; -import type { AutumnContext } from "../../../../honoUtils/HonoEnv"; -import type { StripeInvoiceAction } from "../billingPlan"; -import { createAndPayInvoice } from "../providers/stripe/utils/invoices/createAndPayInvoice"; - -export const executeStripeInvoiceAction = async ({ - ctx, - billingContext, - stripeInvoiceAction, -}: { - ctx: AutumnContext; - billingContext: BillingContext; - stripeInvoiceAction: StripeInvoiceAction; -}) => { - const { org, env } = ctx; - const { addLineParams } = stripeInvoiceAction; - - const stripeCli = createStripeCli({ org, env }); - - // 1. Create and pay invoice - const result = await createAndPayInvoice({ - stripeCli, - stripeCusId: billingContext.stripeCustomer?.id, - stripeLineItems: addLineParams.lines, - paymentMethod: billingContext.paymentMethod, - onPaymentFailure: "return_url", - }); - - return result; -}; diff --git a/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeInvoiceAction.ts b/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeInvoiceAction.ts index 98e410221..13cdf3dda 100644 --- a/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeInvoiceAction.ts +++ b/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeInvoiceAction.ts @@ -1,5 +1,5 @@ import type { LineItem } from "@autumn/shared"; -import type { StripeInvoiceAction } from "../../../billingPlan"; +import type { InvoiceMode, StripeInvoiceAction } from "../../../billingPlan"; import { lineItemsToStripeLines } from "../utils/invoiceLines/lineItemsToStripeLines"; /** @@ -8,8 +8,10 @@ import { lineItemsToStripeLines } from "../utils/invoiceLines/lineItemsToStripeL */ export const buildStripeInvoiceAction = ({ autumnLineItems, + invoiceMode, }: { autumnLineItems: LineItem[]; + invoiceMode?: InvoiceMode; }): StripeInvoiceAction | undefined => { if (autumnLineItems.length === 0) { return undefined; @@ -17,5 +19,8 @@ export const buildStripeInvoiceAction = ({ const lines = lineItemsToStripeLines({ lineItems: autumnLineItems }); - return { addLineParams: { lines } }; + return { + addLineParams: { lines }, + invoiceMode, + }; }; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/deferred/storeSubscriptionUpdatePlan.ts b/server/src/internal/billing/v2/providers/stripe/utils/deferred/storeSubscriptionUpdatePlan.ts new file mode 100644 index 000000000..d879a1eed --- /dev/null +++ b/server/src/internal/billing/v2/providers/stripe/utils/deferred/storeSubscriptionUpdatePlan.ts @@ -0,0 +1,41 @@ +import { type AppEnv, MetadataType } from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { MetadataService } from "@/internal/metadata/MetadataService"; +import { generateId } from "@/utils/genUtils"; +import type { AutumnBillingPlan } from "../../../../billingPlan"; + +export type DeferredAutumnBillingPlanData = { + version: 2; + orgId: string; + env: AppEnv; + autumnBillingPlan: AutumnBillingPlan; +}; + +export const storeSubscriptionUpdatePlan = async ({ + ctx, + autumnBillingPlan, +}: { + ctx: AutumnContext; + autumnBillingPlan: AutumnBillingPlan; +}): Promise => { + const id = generateId("meta"); + const { db, org, env } = ctx; + + const data: DeferredAutumnBillingPlanData = { + version: 2, + orgId: org.id, + env, + autumnBillingPlan, + }; + + await MetadataService.insert({ + db, + data: { + id, + type: MetadataType.DeferredAutumnBillingPlan, + data, + }, + }); + + return id; +}; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/invoices/createAndPayInvoice.ts b/server/src/internal/billing/v2/providers/stripe/utils/invoices/createAndPayInvoice.ts deleted file mode 100644 index 9651b508a..000000000 --- a/server/src/internal/billing/v2/providers/stripe/utils/invoices/createAndPayInvoice.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { - type PayInvoiceResult, - type PaymentFailureMode, - payStripeInvoice, -} from "@server/internal/billing/v2/providers/stripe/utils/invoices/payStripeInvoice"; -import { - addStripeInvoiceLines, - createStripeInvoice, - finalizeStripeInvoice, -} from "@server/internal/billing/v2/providers/stripe/utils/invoices/stripeInvoiceOps"; -import type Stripe from "stripe"; - -// ============================================ -// Types -// ============================================ - -export type CreateAndPayInvoiceParams = { - stripeCli: Stripe; - stripeCusId: string; - stripeSubId?: string; - stripeLineItems: Stripe.InvoiceAddLinesParams.Line[]; - paymentMethod?: Stripe.PaymentMethod | null; - discounts?: { coupon: string }[]; - description?: string; - onPaymentFailure?: PaymentFailureMode; -}; - -export type CreateAndPayInvoiceResult = PayInvoiceResult; - -// ============================================ -// Create and Pay Invoice -// ============================================ - -/** - * Full invoice workflow: create → add lines → finalize → pay - */ -export const createAndPayInvoice = async ({ - stripeCli, - stripeCusId, - stripeSubId, - stripeLineItems, - paymentMethod, - description, - onPaymentFailure = "return_url", -}: CreateAndPayInvoiceParams): Promise => { - // 2. Create draft invoice - const invoice = await createStripeInvoice({ - stripeCli, - stripeCusId, - stripeSubId, - description, - }); - - // 3. Add lines to invoice - await addStripeInvoiceLines({ - stripeCli, - invoiceId: invoice.id, - lines: stripeLineItems, - }); - - // 4. Finalize invoice - const finalizedInvoice = await finalizeStripeInvoice({ - stripeCli, - invoiceId: invoice.id, - }); - - // 5. If already paid (e.g. total <= 0), return early - if (finalizedInvoice.status === "paid") { - return { - paid: true, - invoice: finalizedInvoice, - }; - } - - // 6. Pay invoice - return payStripeInvoice({ - stripeCli, - invoiceId: finalizedInvoice.id, - paymentMethod, - onFailure: onPaymentFailure, - }); -}; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/invoices/createInvoiceForBilling.ts b/server/src/internal/billing/v2/providers/stripe/utils/invoices/createInvoiceForBilling.ts new file mode 100644 index 000000000..b80b084e3 --- /dev/null +++ b/server/src/internal/billing/v2/providers/stripe/utils/invoices/createInvoiceForBilling.ts @@ -0,0 +1,69 @@ +import type { BillingContext } from "@server/internal/billing/v2/billingContext"; +import type { + StripeInvoiceAction, + StripeInvoiceMetadata, +} from "@server/internal/billing/v2/billingPlan"; +import { + type PayInvoiceResult, + payStripeInvoice, +} from "@server/internal/billing/v2/providers/stripe/utils/invoices/payStripeInvoice"; +import { + addStripeInvoiceLines, + createStripeInvoice, + finalizeStripeInvoice, +} from "@server/internal/billing/v2/providers/stripe/utils/invoices/stripeInvoiceOps"; +import { createStripeCli } from "@/external/connect/createStripeCli"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; + +export const createInvoiceForBilling = async ({ + ctx, + billingContext, + stripeInvoiceAction, + invoiceMetadata, +}: { + ctx: AutumnContext; + billingContext: BillingContext; + stripeInvoiceAction: StripeInvoiceAction; + invoiceMetadata?: StripeInvoiceMetadata; +}): Promise => { + const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env }); + const { addLineParams, invoiceMode } = stripeInvoiceAction; + const shouldFinalizeInvoice = invoiceMode?.finalizeInvoice ?? false; + const shouldPayImmediately = invoiceMode?.enableProductImmediately ?? true; + + const draftInvoice = await createStripeInvoice({ + stripeCli, + stripeCusId: billingContext.stripeCustomer.id, + metadata: invoiceMetadata, + }); + + await addStripeInvoiceLines({ + stripeCli, + invoiceId: draftInvoice.id, + lines: addLineParams.lines, + }); + + if (!shouldFinalizeInvoice) { + return { paid: false, invoice: draftInvoice }; + } + + const finalizedInvoice = await finalizeStripeInvoice({ + stripeCli, + invoiceId: draftInvoice.id, + }); + + if (finalizedInvoice.status === "paid") { + return { paid: true, invoice: finalizedInvoice }; + } + + if (!shouldPayImmediately) { + return { paid: false, invoice: finalizedInvoice }; + } + + return payStripeInvoice({ + stripeCli, + invoiceId: finalizedInvoice.id, + paymentMethod: billingContext.paymentMethod, + onFailure: "return_url", + }); +}; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/invoices/stripeInvoiceOps.ts b/server/src/internal/billing/v2/providers/stripe/utils/invoices/stripeInvoiceOps.ts index b87b7befa..530d3e91b 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/invoices/stripeInvoiceOps.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/invoices/stripeInvoiceOps.ts @@ -13,6 +13,7 @@ export type CreateInvoiceParams = { collectionMethod?: "charge_automatically" | "send_invoice"; daysUntilDue?: number; description?: string; + metadata?: Stripe.MetadataParam; }; export const createStripeInvoice = async ({ @@ -23,6 +24,7 @@ export const createStripeInvoice = async ({ collectionMethod = "charge_automatically", daysUntilDue, description, + metadata, }: CreateInvoiceParams): Promise => { const invoice = await stripeCli.invoices.create({ customer: stripeCusId, @@ -30,6 +32,7 @@ export const createStripeInvoice = async ({ ...(stripeSubId ? { subscription: stripeSubId } : {}), ...(currency ? { currency } : {}), ...(description ? { description } : {}), + ...(metadata ? { metadata } : {}), collection_method: collectionMethod, days_until_due: collectionMethod === "send_invoice" ? (daysUntilDue ?? 30) : undefined, diff --git a/server/src/internal/billing/v2/subscriptionUpdate/evaluate/evaluateSubscriptionUpdatePlan.ts b/server/src/internal/billing/v2/subscriptionUpdate/evaluate/evaluateSubscriptionUpdatePlan.ts index 85f189c73..a488f1891 100644 --- a/server/src/internal/billing/v2/subscriptionUpdate/evaluate/evaluateSubscriptionUpdatePlan.ts +++ b/server/src/internal/billing/v2/subscriptionUpdate/evaluate/evaluateSubscriptionUpdatePlan.ts @@ -26,12 +26,13 @@ export const evaluateSubscriptionUpdatePlan = ({ updatedCustomerProducts, }); - const shouldFinalizeInvoice = params.finalize_invoice !== false; - const stripeInvoiceAction = shouldFinalizeInvoice - ? buildStripeInvoiceAction({ - autumnLineItems: autumnBillingPlan.autumnLineItems, - }) - : undefined; + const stripeInvoiceAction = buildStripeInvoiceAction({ + autumnLineItems: autumnBillingPlan.autumnLineItems, + invoiceMode: { + finalizeInvoice: params.finalize_invoice === true, + enableProductImmediately: params.enable_product_immediately !== false, + }, + }); return { subscriptionAction: stripeSubscriptionAction, diff --git a/server/tests/billing/subscription-update/subscription-update-invoice-mode.test.ts b/server/tests/billing/subscription-update/subscription-update-invoice-mode.test.ts new file mode 100644 index 000000000..f7f84f3bb --- /dev/null +++ b/server/tests/billing/subscription-update/subscription-update-invoice-mode.test.ts @@ -0,0 +1,460 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { type ApiCustomer, ApiVersion } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { completeInvoiceCheckout } from "@tests/utils/stripeUtils/completeInvoiceCheckout.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { CusService } from "@/internal/customers/CusService.js"; +import { timeout } from "@/utils/genUtils.js"; +import { constructPrepaidItem } from "@/utils/scriptUtils/constructItem.js"; +import { constructRawProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; +import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0"; + +const billingUnits = 12; +const pricePerUnit = 8; + +describe(`${chalk.yellowBright("subscription-update: invoice mode - default behavior (draft invoice, immediate entitlements)")}`, () => { + const customerId = "sub-update-invoice-default"; + const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 }); + + const prepaidProduct = constructRawProduct({ + id: "prepaid_messages", + items: [ + constructPrepaidItem({ + featureId: TestFeature.Messages, + billingUnits, + price: pricePerUnit, + }), + ], + }); + + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + withTestClock: true, + attachPm: "success", + }); + + await initProductsV0({ + ctx, + products: [prepaidProduct], + prefix: customerId, + }); + + await autumnV1.attach({ + customer_id: customerId, + product_id: prepaidProduct.id, + options: [ + { + feature_id: TestFeature.Messages, + quantity: 10 * billingUnits, + }, + ], + }); + }); + + test("should default to draft invoice with immediate entitlements when only invoice: true is passed", async () => { + const beforeUpdate = await CusService.getFull({ + db: ctx.db, + idOrInternalId: customerId, + orgId: ctx.org.id, + env: ctx.env, + }); + + const customerProduct = beforeUpdate.customer_products.find( + (cp) => cp.product.id === prepaidProduct.id, + ); + const beforeEntitlement = customerProduct?.customer_entitlements.find( + (ent) => ent.entitlement.feature_id === TestFeature.Messages, + ); + const beforeBalance = beforeEntitlement?.balance || 0; + + await autumnV1.subscriptionUpdate({ + customer_id: customerId, + product_id: prepaidProduct.id, + options: [ + { + feature_id: TestFeature.Messages, + quantity: 15 * billingUnits, + }, + ], + invoice: true, + }); + + const afterUpdate = await CusService.getFull({ + db: ctx.db, + idOrInternalId: customerId, + orgId: ctx.org.id, + env: ctx.env, + }); + + const afterCustomerProduct = afterUpdate.customer_products.find( + (cp) => cp.product.id === prepaidProduct.id, + ); + const afterEntitlement = afterCustomerProduct?.customer_entitlements.find( + (ent) => ent.entitlement.feature_id === TestFeature.Messages, + ); + const afterBalance = afterEntitlement?.balance || 0; + + expect(afterBalance).toBe(beforeBalance + 60); + + const customer = await autumnV1.customers.get(customerId); + const balance = customer.balances?.[TestFeature.Messages]; + expect(balance?.purchased_balance).toBe(180); + + const draftInvoice = customer.invoices?.find( + (inv) => inv.status === "draft", + ); + expect(draftInvoice).toBeDefined(); + }); +}); + +describe(`${chalk.yellowBright("subscription-update: invoice mode - draft invoice with immediate entitlements (explicit)")}`, () => { + const customerId = "sub-update-invoice-draft"; + const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 }); + + const prepaidProduct = constructRawProduct({ + id: "prepaid_messages", + items: [ + constructPrepaidItem({ + featureId: TestFeature.Messages, + billingUnits, + price: pricePerUnit, + }), + ], + }); + + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + withTestClock: true, + attachPm: "success", + }); + + await initProductsV0({ + ctx, + products: [prepaidProduct], + prefix: customerId, + }); + + await autumnV1.attach({ + customer_id: customerId, + product_id: prepaidProduct.id, + options: [ + { + feature_id: TestFeature.Messages, + quantity: 10 * billingUnits, + }, + ], + }); + }); + + test("should create draft invoice and update entitlements immediately", async () => { + const beforeUpdate = await CusService.getFull({ + db: ctx.db, + idOrInternalId: customerId, + orgId: ctx.org.id, + env: ctx.env, + }); + + const customerProduct = beforeUpdate.customer_products.find( + (cp) => cp.product.id === prepaidProduct.id, + ); + const beforeEntitlement = customerProduct?.customer_entitlements.find( + (ent) => ent.entitlement.feature_id === TestFeature.Messages, + ); + const beforeBalance = beforeEntitlement?.balance || 0; + + await autumnV1.subscriptionUpdate({ + customer_id: customerId, + product_id: prepaidProduct.id, + options: [ + { + feature_id: TestFeature.Messages, + quantity: 15 * billingUnits, // +5 units + }, + ], + invoice: true, + finalize_invoice: false, + enable_product_immediately: true, + }); + + // Entitlements should be updated immediately + const afterUpdate = await CusService.getFull({ + db: ctx.db, + idOrInternalId: customerId, + orgId: ctx.org.id, + env: ctx.env, + }); + + const afterCustomerProduct = afterUpdate.customer_products.find( + (cp) => cp.product.id === prepaidProduct.id, + ); + const afterEntitlement = afterCustomerProduct?.customer_entitlements.find( + (ent) => ent.entitlement.feature_id === TestFeature.Messages, + ); + const afterBalance = afterEntitlement?.balance || 0; + + // +5 units × 12 billing_units = +60 messages + expect(afterBalance).toBe(beforeBalance + 60); + + // Verify via API that balance is updated and invoice is draft + const customer = await autumnV1.customers.get(customerId); + const balance = customer.balances?.[TestFeature.Messages]; + expect(balance?.purchased_balance).toBe(180); // 15 units × 12 = 180 + + const draftInvoice = customer.invoices?.find( + (inv) => inv.status === "draft", + ); + expect(draftInvoice).toBeDefined(); + }); +}); + +describe(`${chalk.yellowBright("subscription-update: invoice mode - finalized invoice with immediate entitlements")}`, () => { + const customerId = "sub-update-invoice-finalized"; + const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 }); + + const prepaidProduct = constructRawProduct({ + id: "prepaid_messages", + items: [ + constructPrepaidItem({ + featureId: TestFeature.Messages, + billingUnits, + price: pricePerUnit, + }), + ], + }); + + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + withTestClock: true, + attachPm: "success", + }); + + await initProductsV0({ + ctx, + products: [prepaidProduct], + prefix: customerId, + }); + + await autumnV1.attach({ + customer_id: customerId, + product_id: prepaidProduct.id, + options: [ + { + feature_id: TestFeature.Messages, + quantity: 10 * billingUnits, + }, + ], + }); + }); + + test("should finalize invoice immediately and update entitlements", async () => { + const beforeUpdate = await CusService.getFull({ + db: ctx.db, + idOrInternalId: customerId, + orgId: ctx.org.id, + env: ctx.env, + }); + + const customerProduct = beforeUpdate.customer_products.find( + (cp) => cp.product.id === prepaidProduct.id, + ); + const beforeEntitlement = customerProduct?.customer_entitlements.find( + (ent) => ent.entitlement.feature_id === TestFeature.Messages, + ); + const beforeBalance = beforeEntitlement?.balance || 0; + + await autumnV1.subscriptionUpdate({ + customer_id: customerId, + product_id: prepaidProduct.id, + options: [ + { + feature_id: TestFeature.Messages, + quantity: 20 * billingUnits, // +10 units + }, + ], + invoice: true, + finalize_invoice: true, + enable_product_immediately: true, + }); + + // Entitlements should be updated immediately + const afterUpdate = await CusService.getFull({ + db: ctx.db, + idOrInternalId: customerId, + orgId: ctx.org.id, + env: ctx.env, + }); + + const afterCustomerProduct = afterUpdate.customer_products.find( + (cp) => cp.product.id === prepaidProduct.id, + ); + const afterEntitlement = afterCustomerProduct?.customer_entitlements.find( + (ent) => ent.entitlement.feature_id === TestFeature.Messages, + ); + const afterBalance = afterEntitlement?.balance || 0; + + // +10 units × 12 billing_units = +120 messages + expect(afterBalance).toBe(beforeBalance + 120); + + // Verify via API that balance is updated and invoice is paid + const customer = await autumnV1.customers.get(customerId); + const balance = customer.balances?.[TestFeature.Messages]; + expect(balance?.purchased_balance).toBe(240); // 20 units × 12 = 240 + + const paidInvoice = customer.invoices?.find( + (inv) => inv.status === "paid", + ); + expect(paidInvoice).toBeDefined(); + }); +}); + +describe(`${chalk.yellowBright("subscription-update: invoice mode - entitlements after payment")}`, () => { + const customerId = "sub-update-invoice-payment-required"; + const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 }); + + const prepaidProduct = constructRawProduct({ + id: "prepaid_messages", + items: [ + constructPrepaidItem({ + featureId: TestFeature.Messages, + billingUnits, + price: pricePerUnit, + }), + ], + }); + + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + withTestClock: true, + attachPm: "success", + }); + + await initProductsV0({ + ctx, + products: [prepaidProduct], + prefix: customerId, + }); + + await autumnV1.attach({ + customer_id: customerId, + product_id: prepaidProduct.id, + options: [ + { + feature_id: TestFeature.Messages, + quantity: 10 * billingUnits, + }, + ], + }); + }); + + test("should not update entitlements until payment is received via checkout", async () => { + const beforeUpdate = await CusService.getFull({ + db: ctx.db, + idOrInternalId: customerId, + orgId: ctx.org.id, + env: ctx.env, + }); + + const customerProduct = beforeUpdate.customer_products.find( + (cp) => cp.product.id === prepaidProduct.id, + ); + const beforeEntitlement = customerProduct?.customer_entitlements.find( + (ent) => ent.entitlement.feature_id === TestFeature.Messages, + ); + const beforeBalance = beforeEntitlement?.balance || 0; + + await autumnV1.subscriptionUpdate({ + customer_id: customerId, + product_id: prepaidProduct.id, + options: [ + { + feature_id: TestFeature.Messages, + quantity: 25 * billingUnits, // +15 units + }, + ], + invoice: true, + finalize_invoice: true, + enable_product_immediately: false, + }); + + // Entitlements should NOT be updated yet (waiting for payment) + const afterUpdate = await CusService.getFull({ + db: ctx.db, + idOrInternalId: customerId, + orgId: ctx.org.id, + env: ctx.env, + }); + + const afterCustomerProduct = afterUpdate.customer_products.find( + (cp) => cp.product.id === prepaidProduct.id, + ); + const afterEntitlement = afterCustomerProduct?.customer_entitlements.find( + (ent) => ent.entitlement.feature_id === TestFeature.Messages, + ); + const afterBalance = afterEntitlement?.balance || 0; + + // Balance should remain unchanged until payment + expect(afterBalance).toBe(beforeBalance); + + // Verify via API that balance is NOT updated and invoice is open + const customer = await autumnV1.customers.get(customerId); + const balance = customer.balances?.[TestFeature.Messages]; + expect(balance?.purchased_balance).toBe(120); // Still 10 units × 12 = 120 + + const openInvoice = customer.invoices?.find( + (inv) => inv.status === "open", + ); + expect(openInvoice).toBeDefined(); + expect(openInvoice?.hosted_invoice_url).toBeDefined(); + + // Complete payment via checkout using Puppeteer + await completeInvoiceCheckout({ + url: openInvoice!.hosted_invoice_url!, + }); + + // Wait for webhook processing + await timeout(10000); + + // Entitlements should now be updated after payment + const afterPayment = await CusService.getFull({ + db: ctx.db, + idOrInternalId: customerId, + orgId: ctx.org.id, + env: ctx.env, + }); + + const paidCustomerProduct = afterPayment.customer_products.find( + (cp) => cp.product.id === prepaidProduct.id, + ); + const paidEntitlement = paidCustomerProduct?.customer_entitlements.find( + (ent) => ent.entitlement.feature_id === TestFeature.Messages, + ); + const paidBalance = paidEntitlement?.balance || 0; + + // +15 units × 12 billing_units = +180 messages + expect(paidBalance).toBe(beforeBalance + 180); + + // Verify via API that balance is now updated and invoice is paid + const customerAfterPayment = + await autumnV1.customers.get(customerId); + const balanceAfterPayment = + customerAfterPayment.balances?.[TestFeature.Messages]; + expect(balanceAfterPayment?.purchased_balance).toBe(300); // 25 units × 12 = 300 + + // All invoices should now be paid + const unpaidInvoices = customerAfterPayment.invoices?.filter( + (inv) => inv.status !== "paid", + ); + expect(unpaidInvoices?.length ?? 0).toBe(0); + }); +}); diff --git a/server/tests/billing/subscription-update/subscription-update-invoicing.test.ts b/server/tests/billing/subscription-update/subscription-update-invoicing.test.ts index 8b86940f1..00d04641a 100644 --- a/server/tests/billing/subscription-update/subscription-update-invoicing.test.ts +++ b/server/tests/billing/subscription-update/subscription-update-invoicing.test.ts @@ -89,30 +89,4 @@ describe(`${chalk.yellowBright("subscription-update: invoice generation")}`, () expect(latestInvoice?.total).toBeGreaterThan(0); }); - test("should not create invoice when finalize_invoice is false", async () => { - const beforeUpdate = await autumnV1.customers.get(customerId); - const invoiceCountBefore = beforeUpdate.invoices?.length || 0; - - await autumnV1.subscriptionUpdate({ - customer_id: customerId, - product_id: prepaidProduct.id, - options: [ - { - feature_id: TestFeature.Messages, - quantity: 25 * billingUnits, - }, - ], - finalize_invoice: false, - }); - - const afterUpdate = await autumnV1.customers.get(customerId); - const invoiceCountAfter = afterUpdate.invoices?.length || 0; - - // Should not have created a finalized invoice - expect(invoiceCountAfter).toBe(invoiceCountBefore); - - // But balance should still be updated - const balance = afterUpdate.balances?.[TestFeature.Messages]; - expect(balance?.purchased_balance).toBe(25 * billingUnits); - }); }); diff --git a/server/tests/utils/stripeUtils.ts b/server/tests/utils/stripeUtils.ts index 11f30c2aa..d25c1369b 100644 --- a/server/tests/utils/stripeUtils.ts +++ b/server/tests/utils/stripeUtils.ts @@ -27,7 +27,9 @@ export const completeCheckoutForm = async ( ) => { const browser = await puppeteer.launch({ headless: false, - executablePath: "/Applications/Chromium.app/Contents/MacOS/Chromium", + executablePath: + process.env.TESTS_CHROMIUM_PATH ?? + "/Applications/Chromium.app/Contents/MacOS/Chromium", args: ["--no-sandbox", "--disable-setuid-sandbox"], }); diff --git a/server/tests/utils/stripeUtils/completeInvoiceCheckout.ts b/server/tests/utils/stripeUtils/completeInvoiceCheckout.ts index 761eca0c6..61e89f469 100644 --- a/server/tests/utils/stripeUtils/completeInvoiceCheckout.ts +++ b/server/tests/utils/stripeUtils/completeInvoiceCheckout.ts @@ -27,7 +27,9 @@ export const completeInvoiceCheckout = async ({ // } browser = await puppeteer.launch({ headless: false, - executablePath: "/Applications/Chromium.app/Contents/MacOS/Chromium", + executablePath: + process.env.TESTS_CHROMIUM_PATH ?? + "/Applications/Chromium.app/Contents/MacOS/Chromium", args: ["--no-sandbox", "--disable-setuid-sandbox"], }); diff --git a/server/tests/utils/stripeUtils/completeInvoiceConfirmation.ts b/server/tests/utils/stripeUtils/completeInvoiceConfirmation.ts index 574dc0eca..a56d880a0 100644 --- a/server/tests/utils/stripeUtils/completeInvoiceConfirmation.ts +++ b/server/tests/utils/stripeUtils/completeInvoiceConfirmation.ts @@ -27,7 +27,9 @@ export const completeInvoiceConfirmation = async ({ // } browser = await puppeteer.launch({ headless: false, - executablePath: "/Applications/Chromium.app/Contents/MacOS/Chromium", + executablePath: + process.env.TESTS_CHROMIUM_PATH ?? + "/Applications/Chromium.app/Contents/MacOS/Chromium", args: ["--no-sandbox", "--disable-setuid-sandbox"], }); diff --git a/shared/models/otherModels/metadataTable.ts b/shared/models/otherModels/metadataTable.ts index 34b1d1b94..c534050ca 100644 --- a/shared/models/otherModels/metadataTable.ts +++ b/shared/models/otherModels/metadataTable.ts @@ -6,6 +6,7 @@ export enum MetadataType { InvoiceActionRequired = "invoice_action_required", InvoiceCheckout = "invoice_checkout", CheckoutSessionCompleted = "checkout_session_completed", + DeferredAutumnBillingPlan = "deferred_autumn_billing_plan", } export const metadata = pgTable("metadata", {