From 9b8d9304760079a90a78105a8f4a39611c51ca8e Mon Sep 17 00:00:00 2001 From: John Yeo Date: Thu, 30 Apr 2026 15:04:05 +0100 Subject: [PATCH] chore: enable plan immediately on checkout session --- knip.json | 8 +- .../external/stripe/common/stripeConstants.ts | 1 + .../stripe/handleStripeWebhookEvent.ts | 6 + .../handleStripeCheckoutSessionCompleted.ts | 10 +- .../createStripeScheduleFromCheckout.ts | 49 ++++ ...handleCheckoutSessionEnabledImmediately.ts | 156 +++++++++++ .../handleStripeCheckoutSessionExpired.ts | 69 +++++ .../stripeToAutumnCustomerMiddleware.ts | 1 + .../errors/handleStripeCheckoutErrors.ts | 16 ++ .../attach/setup/setupAttachBillingContext.ts | 1 + ...etupImmediateMultiProductBillingContext.ts | 1 + .../actions/createSchedule/createSchedule.ts | 6 +- .../errors/handleCreateScheduleErrors.ts | 14 + .../setupCreateScheduleBillingContext.ts | 1 + ...addStripeCheckoutSessionIdToBillingPlan.ts | 18 ++ .../executeStripeCheckoutSessionAction.ts | 37 ++- .../initCustomerProduct.ts | 2 + .../cusProducts/CusProductService.ts | 38 +++ .../utils/insertMetadataFromBillingPlan.ts | 29 +- server/tests/_groups/temp.ts | 8 +- .../tests/_temp/volume-tiers-inspect.test.ts | 60 +++++ ...e-checkout-enable-plan-immediately.test.ts | 227 ++++++++++++++++ ...e-schedule-enable-plan-immediately.test.ts | 248 ++++++++++++++++++ .../utils/fullSubjectScenarioBuilders.ts | 1 + shared/api/billing/attachV2/attachParamsV1.ts | 5 + .../billing/attachV2/multiAttachParamsV0.ts | 5 + .../requestChanges/V1.2_AttachParamsChange.ts | 1 + .../createSchedule/createScheduleParamsV0.ts | 4 + .../billingModels/context/billingContext.ts | 5 + .../cusProductModels/cusProductModels.ts | 2 + .../cusProductModels/cusProductTable.ts | 8 + shared/models/otherModels/metadataTable.ts | 1 + .../forms/attach-v2/attachFormSchema.ts | 1 + .../components/AttachAdvancedSection.tsx | 14 + .../attach-v2/components/AttachFooter.tsx | 2 +- .../attach-v2/components/AttachFooterV3.tsx | 2 +- .../attach-v2/context/AttachFormProvider.tsx | 4 +- .../forms/attach-v2/hooks/useAttachForm.ts | 1 + .../attach-v2/hooks/useAttachMutation.ts | 8 +- .../attach-v2/hooks/useAttachRequestBody.ts | 18 +- .../CreateScheduleAdvancedSection.tsx | 20 +- .../components/CreateScheduleSheetContent.tsx | 23 +- .../context/CreateScheduleFormProvider.tsx | 6 + .../createScheduleFormSchema.ts | 1 + .../hooks/useCreateScheduleRequestBody.ts | 19 +- .../components/sheets/CreateScheduleSheet.tsx | 2 + 46 files changed, 1110 insertions(+), 49 deletions(-) create mode 100644 server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionEnabledImmediately/createStripeScheduleFromCheckout.ts create mode 100644 server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionEnabledImmediately/handleCheckoutSessionEnabledImmediately.ts create mode 100644 server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionExpired/handleStripeCheckoutSessionExpired.ts create mode 100644 server/src/internal/billing/v2/execute/addStripeCheckoutSessionIdToBillingPlan.ts create mode 100644 server/tests/_temp/volume-tiers-inspect.test.ts create mode 100644 server/tests/integration/billing/attach/checkout/stripe-checkout/stripe-checkout-enable-plan-immediately.test.ts create mode 100644 server/tests/integration/billing/create-schedule/create-schedule-enable-plan-immediately.test.ts diff --git a/knip.json b/knip.json index d1163be75..bed35f60b 100644 --- a/knip.json +++ b/knip.json @@ -11,6 +11,7 @@ "enumMembers", "duplicates" ], + "ignore": ["ai/**"], "ignoreWorkspaces": [ "packages/atmn", "packages/autumn-js", @@ -62,7 +63,12 @@ "project": ["**/*.{ts,tsx}"] }, "apps/website": { - "entry": ["app/**/*.{ts,tsx,mdx}", "components/**/*.{ts,tsx}", "content/**/*.mdx", "*.mjs"], + "entry": [ + "app/**/*.{ts,tsx,mdx}", + "components/**/*.{ts,tsx}", + "content/**/*.mdx", + "*.mjs" + ], "project": ["**/*.{ts,tsx,mdx,mjs}"] }, "apps/checkout": { diff --git a/server/src/external/stripe/common/stripeConstants.ts b/server/src/external/stripe/common/stripeConstants.ts index 9a9bf261c..a3764df97 100644 --- a/server/src/external/stripe/common/stripeConstants.ts +++ b/server/src/external/stripe/common/stripeConstants.ts @@ -5,6 +5,7 @@ type StripeEventType = Stripe.WebhookEndpointCreateParams.EnabledEvent; /** Events Autumn actively handles in its webhook handler. */ export const MAIN_STRIPE_EVENT_TYPES: StripeEventType[] = [ "checkout.session.completed", + "checkout.session.expired", "customer.subscription.created", "customer.subscription.updated", "customer.subscription.deleted", diff --git a/server/src/external/stripe/handleStripeWebhookEvent.ts b/server/src/external/stripe/handleStripeWebhookEvent.ts index 5c1ec6aea..61a58ed3d 100644 --- a/server/src/external/stripe/handleStripeWebhookEvent.ts +++ b/server/src/external/stripe/handleStripeWebhookEvent.ts @@ -10,6 +10,7 @@ import { getSentryTags } from "../sentry/sentryUtils.js"; import { handleCusDiscountDeleted } from "./webhookHandlers/handleCusDiscountDeleted.js"; import { handleInvoiceUpdated } from "./webhookHandlers/handleInvoiceUpdated.js"; import { handleStripeCheckoutSessionCompleted } from "./webhookHandlers/handleStripeCheckoutSessionCompleted/handleStripeCheckoutSessionCompleted.js"; +import { handleStripeCheckoutSessionExpired } from "./webhookHandlers/handleStripeCheckoutSessionExpired/handleStripeCheckoutSessionExpired.js"; import { handleStripeInvoiceCreated } from "./webhookHandlers/handleStripeInvoiceCreated/handleStripeInvoiceCreated.js"; import { handleStripeInvoiceFinalized } from "./webhookHandlers/handleStripeInvoiceFinalized/handleStripeInvoiceFinalized.js"; import { handleStripeSubscriptionDeleted } from "./webhookHandlers/handleStripeSubscriptionDeleted/handleStripeSubscriptionDeleted.js"; @@ -84,6 +85,11 @@ export const handleStripeWebhookEvent = async ( await handleStripeCheckoutSessionCompleted({ ctx, event }); break; } + + case "checkout.session.expired": { + await handleStripeCheckoutSessionExpired({ ctx, event }); + break; + } } } catch (error) { Sentry.captureException(error, { diff --git a/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/handleStripeCheckoutSessionCompleted.ts b/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/handleStripeCheckoutSessionCompleted.ts index 7537dfa25..707a61b66 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/handleStripeCheckoutSessionCompleted.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/handleStripeCheckoutSessionCompleted.ts @@ -1,4 +1,5 @@ import type Stripe from "stripe"; +import { handleCheckoutSessionEnabledImmediately } from "@/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionEnabledImmediately/handleCheckoutSessionEnabledImmediately.js"; import { handleCheckoutSessionMetadataV2 } from "@/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/handleCheckoutSessionMetadataV2.js"; import type { StripeWebhookContext } from "../../webhookMiddlewares/stripeWebhookContext.js"; import { setupCheckoutSessionCompletedContext } from "./setupCheckoutSessionCompletedContext.js"; @@ -19,12 +20,19 @@ export const handleStripeCheckoutSessionCompleted = async ({ event, }); - // V2 flow + // V2 flow (deferred — cusProducts inserted here by webhook) await handleCheckoutSessionMetadataV2({ ctx, checkoutContext, }); + // V2 + enable_plan_immediately (cusProducts already inserted at attach time; + // patch subscription_ids + reconcile Stripe sub here) + await handleCheckoutSessionEnabledImmediately({ + ctx, + checkoutContext, + }); + // Setup payment with metadata (plan attachment after setup) await handleSetupPaymentMetadata({ ctx, diff --git a/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionEnabledImmediately/createStripeScheduleFromCheckout.ts b/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionEnabledImmediately/createStripeScheduleFromCheckout.ts new file mode 100644 index 000000000..d9ed5992b --- /dev/null +++ b/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionEnabledImmediately/createStripeScheduleFromCheckout.ts @@ -0,0 +1,49 @@ +import type { DeferredAutumnBillingPlanData } from "@autumn/shared"; +import type { CheckoutSessionCompletedContext } from "@/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/setupCheckoutSessionCompletedContext"; +import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext"; +import { executeStripeSubscriptionScheduleAction } from "@/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionScheduleAction"; + +/** + * Creates the Stripe `subscription_schedule` for an enable_plan_immediately + * createSchedule flow and returns its id. + * + * Today's request-time flow returns early on `stripeCheckoutSessionAction` + * (executeStripeBillingPlan.ts:37-44), so the schedule action — already on + * `billingPlan.stripe.subscriptionScheduleAction` from the request-time eval — + * never executed. We execute it here against the now-real Stripe subscription. + * + * Phase 0 of the schedule action's params is irrelevant — Stripe's + * `from_subscription` overwrites it with the subscription's items. Phases 1+ + * are deterministic from the schedule definition, so the request-time eval + * stays valid. + * + * Returns null when there's no subscription or no schedule action (i.e. attach). + */ +export const createStripeScheduleFromCheckout = async ({ + ctx, + checkoutContext, + deferredData, +}: { + ctx: StripeWebhookContext; + checkoutContext: CheckoutSessionCompletedContext; + deferredData: DeferredAutumnBillingPlanData; +}): Promise => { + const { stripeSubscription } = checkoutContext; + if (!stripeSubscription) return null; + + const subscriptionScheduleAction = + deferredData.billingPlan.stripe.subscriptionScheduleAction; + if (!subscriptionScheduleAction) return null; + + const stripeSchedule = await executeStripeSubscriptionScheduleAction({ + ctx, + billingContext: { + ...deferredData.billingContext, + stripeSubscription, + }, + subscriptionScheduleAction, + stripeSubscription, + }); + + return stripeSchedule?.id ?? null; +}; diff --git a/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionEnabledImmediately/handleCheckoutSessionEnabledImmediately.ts b/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionEnabledImmediately/handleCheckoutSessionEnabledImmediately.ts new file mode 100644 index 000000000..17196543b --- /dev/null +++ b/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionEnabledImmediately/handleCheckoutSessionEnabledImmediately.ts @@ -0,0 +1,156 @@ +import { + cp, + type DeferredAutumnBillingPlanData, + MetadataType, +} from "@autumn/shared"; +import type { CheckoutSessionCompletedContext } from "@/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/setupCheckoutSessionCompletedContext"; +import { createStripeScheduleFromCheckout } from "@/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionEnabledImmediately/createStripeScheduleFromCheckout"; +import { modifyStripeSubscriptionFromCheckout } from "@/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/modifyStripeSubscriptionFromCheckout"; +import { syncSubscriptionItemMetadataFromCheckout } from "@/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/syncSubscriptionItemMetadataFromCheckout"; +import { updateBillingPlanFromCheckout } from "@/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/updateBillingPlanFromCheckout"; +import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext"; +import { persistDeferredCreateSchedule } from "@/internal/billing/v2/actions/createSchedule/utils/persistDeferredCreateSchedule"; +import { executeAutumnBillingPlan } from "@/internal/billing/v2/execute/executeAutumnBillingPlan"; +import { CusProductService } from "@/internal/customers/cusProducts/CusProductService"; +import { MetadataService } from "@/internal/metadata/MetadataService"; +import { workflows } from "@/queue/workflows"; + +/** + * Webhook task: handles checkout.session.completed for the + * `enable_plan_immediately + stripe_checkout` flow. + * + * The cusProduct rows were already inserted at attach time and linked to the + * pending checkout session via `stripe_checkout_session_id`. This task: + * 1. Reconciles the Stripe subscription shape and (for createSchedule) creates + * the Stripe `subscription_schedule`. + * 2. Builds an "update-only" autumn billing plan that patches `subscription_ids` + * / `scheduled_ids` onto the existing rows + carries the `upsertSubscription` + * and `upsertInvoice` from `updateBillingPlanFromCheckout`. + * 3. Hands that plan to `executeAutumnBillingPlan`, which is the canonical path + * for cusProduct mutations + sub/invoice upserts + line-item workflow. + */ +export const handleCheckoutSessionEnabledImmediately = async ({ + ctx, + checkoutContext, +}: { + ctx: StripeWebhookContext; + checkoutContext: CheckoutSessionCompletedContext; +}): Promise => { + const { metadata, stripeCheckoutSession, stripeSubscription, stripeInvoice } = + checkoutContext; + + if (metadata?.type !== MetadataType.CheckoutSessionEnabledImmediately) return; + + ctx.logger.info( + `[checkout.completed] Handling enable_plan_immediately checkout: ${metadata.id}`, + ); + + const deferredData = metadata.data as DeferredAutumnBillingPlanData; + + // 1. Sync Autumn metadata onto subscription items created by checkout + await syncSubscriptionItemMetadataFromCheckout({ ctx, checkoutContext }); + + // 2. Build upsertSubscription / upsertInvoice from Stripe and update the + // in-memory billing plan. + const updatedDeferredData = await updateBillingPlanFromCheckout({ + ctx, + checkoutContext, + deferredData, + }); + + // 3. Reconcile the Stripe subscription shape (e.g. add monthly prepaid + // quantities when checkout only created an annual base sub). + await modifyStripeSubscriptionFromCheckout({ + ctx, + checkoutContext, + deferredData: updatedDeferredData, + }); + + // 4. For createSchedule contexts, create the Stripe subscription_schedule + // against the now-existing subscription. Returns null for attach (no + // schedule action on the plan). + const stripeScheduleId = await createStripeScheduleFromCheckout({ + ctx, + checkoutContext, + deferredData: updatedDeferredData, + }); + + // 5. Look up the cusProduct rows linked to this checkout session so we can + // patch subscription_ids / scheduled_ids onto them. One DB read serves + // both patches below. + const existingCusProducts = + await CusProductService.getByStripeCheckoutSessionId({ + db: ctx.db, + stripeCheckoutSessionId: stripeCheckoutSession.id, + orgId: ctx.org.id, + env: ctx.env, + }); + + // 6. Build update entries on the autumn plan instead of writing to DB + // directly — this is the canonical mutation path picked up by + // `executeAutumnBillingPlan`. Empty out `insertCustomerProducts` / + // `updateCustomerEntitlements` since both already ran at attach time. + const updatedAutumnPlan = updatedDeferredData.billingPlan.autumn; + const updateCustomerProducts = existingCusProducts.map((customerProduct) => { + const { valid: isPaidRecurring } = cp(customerProduct).paid().recurring(); + + const subscriptionIds = stripeSubscription + ? Array.from( + new Set([ + ...(customerProduct.subscription_ids ?? []), + stripeSubscription.id, + ]), + ) + : (customerProduct.subscription_ids ?? undefined); + + return { + customerProduct, + updates: { + ...(subscriptionIds !== undefined + ? { subscription_ids: subscriptionIds } + : {}), + ...(isPaidRecurring && stripeScheduleId + ? { scheduled_ids: [stripeScheduleId] } + : {}), + }, + }; + }); + + await executeAutumnBillingPlan({ + ctx, + autumnBillingPlan: { + ...updatedAutumnPlan, + insertCustomerProducts: [], + updateCustomerProducts, + insertCustomerEntitlements: undefined, + updateCustomerEntitlements: [], + }, + stripeInvoice, + }); + + // 7. Persist the Autumn schedule rows (createSchedule only — no-op for attach). + await persistDeferredCreateSchedule({ + ctx, + billingContext: updatedDeferredData.billingContext, + billingPlan: updatedDeferredData.billingPlan, + }); + + // 8. Cleanup metadata. + await MetadataService.delete({ db: ctx.db, id: metadata.id }); + + // 9. Trigger grant-checkout-reward workflow per inserted product. + // Note: feature quantities can't be changed on the Stripe checkout page in + // this flow — `handleStripeCheckoutErrors` blocks `enable_plan_immediately` + // + adjustable_quantity at attach time, so the cusProduct row inserted + // up-front is guaranteed to match what the customer pays for. + const customerId = ctx.fullCustomer?.id ?? ""; + for (const product of updatedAutumnPlan.insertCustomerProducts) { + await workflows.triggerGrantCheckoutReward({ + orgId: ctx.org.id, + env: ctx.env, + customerId, + productId: product.product.id, + stripeSubscriptionId: stripeSubscription?.id, + }); + } +}; diff --git a/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionExpired/handleStripeCheckoutSessionExpired.ts b/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionExpired/handleStripeCheckoutSessionExpired.ts new file mode 100644 index 000000000..34d295052 --- /dev/null +++ b/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionExpired/handleStripeCheckoutSessionExpired.ts @@ -0,0 +1,69 @@ +import { CusProductStatus } from "@autumn/shared"; +import type Stripe from "stripe"; +import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext"; +import { CusProductService } from "@/internal/customers/cusProducts/CusProductService"; +import { MetadataService } from "@/internal/metadata/MetadataService"; + +/** + * checkout.session.expired handler — cleans up cusProduct rows that were + * pre-inserted under the enable_plan_immediately flow but never got their + * subscription linked because the customer abandoned the checkout. + * + * Identifies rows by stripe_checkout_session_id. Skips any row that has + * subscription_ids populated (already completed via the success path). + */ +export const handleStripeCheckoutSessionExpired = async ({ + ctx, + event, +}: { + ctx: StripeWebhookContext; + event: Stripe.CheckoutSessionExpiredEvent; +}) => { + const session = event.data.object; + + const cusProducts = await CusProductService.getByStripeCheckoutSessionId({ + db: ctx.db, + stripeCheckoutSessionId: session.id, + orgId: ctx.org.id, + env: ctx.env, + }); + + if (cusProducts.length === 0) { + // Try to clean up the metadata row even if no cusProduct ever got created + // (e.g. a deferred-flow checkout that expired). + if (session.metadata?.autumn_metadata_id) { + await MetadataService.delete({ + db: ctx.db, + id: session.metadata.autumn_metadata_id, + }); + } + return; + } + + const now = Date.now(); + + for (const cusProduct of cusProducts) { + // If the success-path webhook already linked a subscription, leave it. + if ((cusProduct.subscription_ids ?? []).length > 0) continue; + + await CusProductService.update({ + ctx, + cusProductId: cusProduct.id, + updates: { + status: CusProductStatus.Expired, + ended_at: now, + }, + }); + } + + if (session.metadata?.autumn_metadata_id) { + await MetadataService.delete({ + db: ctx.db, + id: session.metadata.autumn_metadata_id, + }); + } + + ctx.logger.info( + `[checkout.session.expired] Expired ${cusProducts.length} cusProduct(s) linked to ${session.id}`, + ); +}; diff --git a/server/src/external/stripe/webhookMiddlewares/stripeToAutumnCustomerMiddleware.ts b/server/src/external/stripe/webhookMiddlewares/stripeToAutumnCustomerMiddleware.ts index 9a8371fe5..fccb56c41 100644 --- a/server/src/external/stripe/webhookMiddlewares/stripeToAutumnCustomerMiddleware.ts +++ b/server/src/external/stripe/webhookMiddlewares/stripeToAutumnCustomerMiddleware.ts @@ -17,6 +17,7 @@ const getAutumnCustomerId = async ({ ctx }: { ctx: StripeWebhookContext }) => { case "customer.subscription.updated": case "customer.subscription.deleted": case "checkout.session.completed": + case "checkout.session.expired": case "invoice.paid": case "invoice.updated": case "invoice.created": diff --git a/server/src/internal/billing/v2/actions/attach/errors/handleStripeCheckoutErrors.ts b/server/src/internal/billing/v2/actions/attach/errors/handleStripeCheckoutErrors.ts index c92429119..a413a56a0 100644 --- a/server/src/internal/billing/v2/actions/attach/errors/handleStripeCheckoutErrors.ts +++ b/server/src/internal/billing/v2/actions/attach/errors/handleStripeCheckoutErrors.ts @@ -63,4 +63,20 @@ export const handleStripeCheckoutErrors = ({ statusCode: 400, }); } + + // enable_plan_immediately pre-inserts the cusProduct (with its feature + // quantities) at attach time. If the customer can change quantities on the + // Stripe checkout page, those changes won't propagate back to the row, + // leaving Autumn out of sync with Stripe. Block the combination explicitly. + if ( + billingContext.enablePlanImmediately && + (billingContext.adjustableFeatureQuantities?.length ?? 0) > 0 + ) { + throw new RecaseError({ + message: + "enable_plan_immediately cannot be used with adjustable feature quantities — set adjustable_quantity to false on each option, or remove enable_plan_immediately.", + code: ErrCode.InvalidRequest, + statusCode: 400, + }); + } }; diff --git a/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts b/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts index 4b035f13d..73b1416c1 100644 --- a/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts +++ b/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts @@ -242,6 +242,7 @@ export const setupAttachBillingContext = async ({ : params.proration_behavior, invoiceMode, + enablePlanImmediately: params.enable_plan_immediately ?? false, customPrices, customEnts, diff --git a/server/src/internal/billing/v2/actions/common/immediateMultiProduct/setupImmediateMultiProductBillingContext.ts b/server/src/internal/billing/v2/actions/common/immediateMultiProduct/setupImmediateMultiProductBillingContext.ts index 697d36d4b..1c499526f 100644 --- a/server/src/internal/billing/v2/actions/common/immediateMultiProduct/setupImmediateMultiProductBillingContext.ts +++ b/server/src/internal/billing/v2/actions/common/immediateMultiProduct/setupImmediateMultiProductBillingContext.ts @@ -231,6 +231,7 @@ export const setupImmediateMultiProductBillingContext = async ({ .map((featureQuantity) => featureQuantity.feature_id) ?? [], ), invoiceMode, + enablePlanImmediately: params.enable_plan_immediately ?? false, currentEpochMs, billingCycleAnchorMs, resetCycleAnchorMs, diff --git a/server/src/internal/billing/v2/actions/createSchedule/createSchedule.ts b/server/src/internal/billing/v2/actions/createSchedule/createSchedule.ts index d565b2be0..b434a6505 100644 --- a/server/src/internal/billing/v2/actions/createSchedule/createSchedule.ts +++ b/server/src/internal/billing/v2/actions/createSchedule/createSchedule.ts @@ -122,7 +122,11 @@ export const createSchedule = async ({ : undefined, }); - if (billingResult.stripe.deferred) { + // When deferred (legacy stripe_checkout) OR enable_plan_immediately is set, + // the schedule rows are persisted in the webhook handler — at this point + // either no Stripe subscription exists yet, or we're explicitly delaying + // schedule materialization to the same point as the deferred flow. + if (billingResult.stripe.deferred || billingContext.enablePlanImmediately) { return buildPendingCreateScheduleResponse({ billingContext, billingResult, diff --git a/server/src/internal/billing/v2/actions/createSchedule/errors/handleCreateScheduleErrors.ts b/server/src/internal/billing/v2/actions/createSchedule/errors/handleCreateScheduleErrors.ts index 8f67f7ae4..8d75e5587 100644 --- a/server/src/internal/billing/v2/actions/createSchedule/errors/handleCreateScheduleErrors.ts +++ b/server/src/internal/billing/v2/actions/createSchedule/errors/handleCreateScheduleErrors.ts @@ -1,5 +1,6 @@ import { type CreateScheduleBillingContext, + ErrCode, ms, RecaseError, } from "@autumn/shared"; @@ -14,6 +15,19 @@ export const handleCreateScheduleErrors = ({ const { currentEpochMs, immediatePhase, stripeSubscriptionSchedule } = billingContext; + if ( + billingContext.checkoutMode === "stripe_checkout" && + billingContext.enablePlanImmediately && + (billingContext.adjustableFeatureQuantities?.length ?? 0) > 0 + ) { + throw new RecaseError({ + message: + "enable_plan_immediately cannot be used with adjustable feature quantities — set adjustable_quantity to false on each option, or remove enable_plan_immediately.", + code: ErrCode.InvalidRequest, + statusCode: 400, + }); + } + // Updates reuse the existing schedule's current-phase start_date downstream // (see executeStripeSubscriptionScheduleAction.buildAnchoredPhases), so the // caller-supplied starts_at for phase 0 is effectively ignored. The diff --git a/server/src/internal/billing/v2/actions/createSchedule/setup/setupCreateScheduleBillingContext.ts b/server/src/internal/billing/v2/actions/createSchedule/setup/setupCreateScheduleBillingContext.ts index 13ba640be..c2e41c980 100644 --- a/server/src/internal/billing/v2/actions/createSchedule/setup/setupCreateScheduleBillingContext.ts +++ b/server/src/internal/billing/v2/actions/createSchedule/setup/setupCreateScheduleBillingContext.ts @@ -92,6 +92,7 @@ export const setupCreateScheduleBillingContext = async ({ success_url: params.success_url, checkout_session_params: params.checkout_session_params, redirect_mode: params.redirect_mode ?? "if_required", + enable_plan_immediately: params.enable_plan_immediately, } satisfies MultiAttachParamsV0; const billingContext = await setupImmediateMultiProductBillingContext({ diff --git a/server/src/internal/billing/v2/execute/addStripeCheckoutSessionIdToBillingPlan.ts b/server/src/internal/billing/v2/execute/addStripeCheckoutSessionIdToBillingPlan.ts new file mode 100644 index 000000000..c4ffe3bba --- /dev/null +++ b/server/src/internal/billing/v2/execute/addStripeCheckoutSessionIdToBillingPlan.ts @@ -0,0 +1,18 @@ +import type { AutumnBillingPlan } from "@autumn/shared"; + +/** + * Links each customer product in a billing plan to a pending Stripe checkout + * session. Used by the enable_plan_immediately + stripe_checkout flow so the + * webhook can find the rows on session.completed / session.expired. + */ +export const addStripeCheckoutSessionIdToBillingPlan = ({ + autumnBillingPlan, + stripeCheckoutSessionId, +}: { + autumnBillingPlan: AutumnBillingPlan; + stripeCheckoutSessionId: string; +}) => { + for (const customerProduct of autumnBillingPlan.insertCustomerProducts) { + customerProduct.stripe_checkout_session_id = stripeCheckoutSessionId; + } +}; diff --git a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeCheckoutSessionAction.ts b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeCheckoutSessionAction.ts index 6b05b9ea5..ee106e333 100644 --- a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeCheckoutSessionAction.ts +++ b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeCheckoutSessionAction.ts @@ -1,12 +1,14 @@ -import type { - BillingContext, - BillingPlan, - StripeBillingPlanResult, - StripeCheckoutSessionAction, +import { + type BillingContext, + type BillingPlan, + MetadataType, + type StripeBillingPlanResult, + type StripeCheckoutSessionAction, } from "@autumn/shared"; import { addDays } from "date-fns"; import { createStripeCli } from "@/external/connect/createStripeCli"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { addStripeCheckoutSessionIdToBillingPlan } from "@/internal/billing/v2/execute/addStripeCheckoutSessionIdToBillingPlan"; import { buildCheckoutSessionParams } from "@/internal/billing/v2/providers/stripe/utils/checkoutSessions/buildCheckoutSessionParams"; import { createStripeSessionWithCardFallback } from "@/internal/billing/v2/providers/stripe/utils/checkoutSessions/createStripeSessionWithCardFallback"; import { @@ -31,6 +33,11 @@ export const executeStripeCheckoutSessionAction = async ({ const stripeCli = createStripeCli({ org, env: fullCustomer.env }); + const enablePlanImmediately = billingContext.enablePlanImmediately === true; + const metadataType = enablePlanImmediately + ? MetadataType.CheckoutSessionEnabledImmediately + : MetadataType.CheckoutSessionV2; + // 1. Insert metadata FIRST (without checkout session ID) const metadata = await insertMetadataFromBillingPlan({ ctx, @@ -38,6 +45,7 @@ export const executeStripeCheckoutSessionAction = async ({ billingContext, resumeAfter: undefined, expiresAt: addDays(Date.now(), 10).getTime(), + typeOverride: metadataType, }); // 2. Build full checkout params (merge variable + static params) @@ -70,9 +78,26 @@ export const executeStripeCheckoutSessionAction = async ({ ctx, metadataId: metadata.id, stripeCheckoutSessionId: stripeCheckoutSession.id, + type: metadataType, }); - // 5. Return result with checkout session + // 5. When enable_plan_immediately is set, link each cusProduct row that's + // about to be inserted to this checkout session, and let the Autumn billing + // plan continue executing (deferred=false). The webhook will patch in + // subscription_ids on completion. + if (enablePlanImmediately) { + addStripeCheckoutSessionIdToBillingPlan({ + autumnBillingPlan: billingPlan.autumn, + stripeCheckoutSessionId: stripeCheckoutSession.id, + }); + + return { + deferred: false, + stripeCheckoutSession, + }; + } + + // 6. Default: defer Autumn billing plan execution to the webhook handler. return { deferred: true, stripeCheckoutSession, diff --git a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerProduct.ts b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerProduct.ts index d7345a12c..90250dfb6 100644 --- a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerProduct.ts +++ b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerProduct.ts @@ -109,6 +109,8 @@ export const initCustomerProduct = ({ billing_version: billingVersion, external_id: externalId ?? null, + + stripe_checkout_session_id: null, }; }; diff --git a/server/src/internal/customers/cusProducts/CusProductService.ts b/server/src/internal/customers/cusProducts/CusProductService.ts index 37186e998..60a39c63c 100644 --- a/server/src/internal/customers/cusProducts/CusProductService.ts +++ b/server/src/internal/customers/cusProducts/CusProductService.ts @@ -295,6 +295,44 @@ export class CusProductService { }); } + static async getByStripeCheckoutSessionId({ + db, + stripeCheckoutSessionId, + orgId, + env, + inStatuses, + }: { + db: DrizzleCli; + stripeCheckoutSessionId: string; + orgId: string; + env: AppEnv; + inStatuses?: string[]; + }) { + const data = await db.query.customerProducts.findMany({ + where: (_table, { and, eq: dEq, inArray }) => + and( + dEq( + customerProducts.stripe_checkout_session_id, + stripeCheckoutSessionId, + ), + inStatuses ? inArray(customerProducts.status, inStatuses) : undefined, + ), + with: { + product: true, + customer: true, + ...getFullCusProdRelations(), + }, + }); + + const cusProducts = data as FullCusProduct[]; + + return filterByOrgAndEnv({ + cusProducts, + orgId, + env, + }); + } + static async getByStripeScheduledId({ db, stripeScheduledId, diff --git a/server/src/internal/metadata/utils/insertMetadataFromBillingPlan.ts b/server/src/internal/metadata/utils/insertMetadataFromBillingPlan.ts index d8f67bec4..7793a01c5 100644 --- a/server/src/internal/metadata/utils/insertMetadataFromBillingPlan.ts +++ b/server/src/internal/metadata/utils/insertMetadataFromBillingPlan.ts @@ -1,14 +1,14 @@ -import { InternalError, MetadataType } from "@autumn/shared"; -import { addDays } from "date-fns"; -import type Stripe from "stripe"; -import { createStripeCli } from "@/external/connect/createStripeCli"; -import type { AutumnContext } from "@/honoUtils/HonoEnv"; import type { BillingContext, BillingPlan, DeferredAutumnBillingPlanData, StripeBillingStage, } from "@autumn/shared"; +import { InternalError, MetadataType } from "@autumn/shared"; +import { addDays } from "date-fns"; +import type Stripe from "stripe"; +import { createStripeCli } from "@/external/connect/createStripeCli"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { generateId } from "@/utils/genUtils"; import { MetadataService } from "../MetadataService"; @@ -23,6 +23,7 @@ export const insertMetadataFromBillingPlan = async ({ stripeCheckoutSession, expiresAt, resumeAfter, + typeOverride, }: { ctx: AutumnContext; billingPlan: BillingPlan; @@ -31,14 +32,18 @@ export const insertMetadataFromBillingPlan = async ({ stripeCheckoutSession?: Stripe.Checkout.Session; resumeAfter?: StripeBillingStage; expiresAt: number; + /** Override the auto-detected metadata type. Used by the enable_plan_immediately checkout flow. */ + typeOverride?: MetadataType; }) => { const id = generateId("meta"); - let type: MetadataType | undefined; - if (stripeCheckoutSession) { - type = MetadataType.CheckoutSessionV2; - } else if (stripeInvoice) { - type = MetadataType.DeferredInvoice; + let type: MetadataType | undefined = typeOverride; + if (!type) { + if (stripeCheckoutSession) { + type = MetadataType.CheckoutSessionV2; + } else if (stripeInvoice) { + type = MetadataType.DeferredInvoice; + } } const data = { @@ -89,17 +94,19 @@ export const updateMetadataWithCheckoutSession = async ({ ctx, metadataId, stripeCheckoutSessionId, + type = MetadataType.CheckoutSessionV2, }: { ctx: AutumnContext; metadataId: string; stripeCheckoutSessionId: string; + type?: MetadataType; }) => { return MetadataService.update({ db: ctx.db, id: metadataId, updates: { stripe_checkout_session_id: stripeCheckoutSessionId, - type: MetadataType.CheckoutSessionV2, + type, }, }); }; diff --git a/server/tests/_groups/temp.ts b/server/tests/_groups/temp.ts index f204997a0..6c57bc904 100644 --- a/server/tests/_groups/temp.ts +++ b/server/tests/_groups/temp.ts @@ -4,11 +4,5 @@ export const temp: TestGroup = { name: "temp", description: "Billing rollover regression suite (rollover carry-over fix)", tier: "domain", - paths: [ - "integration/billing/attach/immediate-switch/immediate-switch-rollover.test.ts", - "integration/billing/attach/scheduled-switch/scheduled-switch-rollover.test.ts", - "integration/billing/attach/scheduled-switch/discounts/scheduled-switch-discounts-edge.test.ts", - "integration/billing/create-schedule/create-schedule-basic.test.ts", - "integration/billing/update-subscription/custom-plan/update-paid-prepaid-rollover.test.ts", - ], + paths: ["_temp/volume-tiers-inspect.test.ts"], }; diff --git a/server/tests/_temp/volume-tiers-inspect.test.ts b/server/tests/_temp/volume-tiers-inspect.test.ts new file mode 100644 index 000000000..d0da34238 --- /dev/null +++ b/server/tests/_temp/volume-tiers-inspect.test.ts @@ -0,0 +1,60 @@ +/** + * Scratch test: attach a plan with prepaid VOLUME tiers, then update + * quantity into a higher tier. No assertions — for manual Stripe inspection. + * + * Tier setup (billingUnits = 100): + * Tier 1: 0–500 units → $10 / pack + * Tier 2: 501+ units → $5 / pack + */ + +import { test } from "bun:test"; +import chalk from "chalk"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; + +test( + `${chalk.yellowBright("volume-tiers-inspect: attach 300, update to 800")}`, + async () => { + const customerId = "volume-tiers-inspect"; + const initQuantity = 300; // tier 1 + const newQuantity = 800; // tier 2 + + const volumeItem = items.volumePrepaidMessages({ + includedUsage: 0, + billingUnits: 100, + tiers: [ + { to: 500, amount: 10 }, + { to: "inf", amount: 5 }, + ], + }); + + const product = products.base({ + id: "volume-tiers-inspect", + items: [volumeItem], + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [product] }), + ], + actions: [ + s.billing.attach({ + productId: product.id, + options: [ + { feature_id: TestFeature.Messages, quantity: initQuantity }, + ], + }), + ], + }); + + await autumnV1.subscriptions.update({ + customer_id: customerId, + product_id: product.id, + options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }], + }); + }, +); diff --git a/server/tests/integration/billing/attach/checkout/stripe-checkout/stripe-checkout-enable-plan-immediately.test.ts b/server/tests/integration/billing/attach/checkout/stripe-checkout/stripe-checkout-enable-plan-immediately.test.ts new file mode 100644 index 000000000..583d3098e --- /dev/null +++ b/server/tests/integration/billing/attach/checkout/stripe-checkout/stripe-checkout-enable-plan-immediately.test.ts @@ -0,0 +1,227 @@ +/** + * Stripe Checkout — Top-level enable_plan_immediately + * + * Feature under test (currently unimplemented — these tests are RED on purpose): + * - Top-level `enable_plan_immediately` on attach params (no longer nested under `invoice_mode`). + * - When set on a stripe_checkout flow, the customer_product is inserted as Active + * BEFORE the customer completes the Stripe-hosted checkout, with a new + * `stripe_checkout_session_id` column linking the row to the pending session. + * - On checkout.session.completed, the webhook patches `subscription_ids` and + * reconciles the Stripe subscription to match cusProduct items (e.g. prepaid + * quantities) — so prepaid balances should land correctly post-completion. + * - On checkout.session.expired, the row is cleaned up. + */ + +import { expect, test } from "bun:test"; +import { + type ApiCustomerV3, + type AttachParamsV0Input, + CusProductStatus, + customers, +} from "@autumn/shared"; +import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +import { completeStripeCheckoutFormV2 as completeStripeCheckoutForm } from "@tests/utils/browserPool/completeStripeCheckoutFormV2"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { eq } from "drizzle-orm"; +import { CusProductService } from "@/internal/customers/cusProducts/CusProductService"; + +// Parses the cs_xxx checkout session id out of a Stripe-hosted checkout URL. +const parseCheckoutSessionId = (url: string): string | null => { + const match = url.match(/\/c\/pay\/(cs_[^/?#]+)/); + return match?.[1] ?? null; +}; + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 1: Happy path — pre-insert at attach time, webhook patches subscription_ids +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("stripe-checkout enable_plan_immediately: pre-inserts cusProduct, webhook links sub")}`, async () => { + const customerId = "stripe-checkout-eppi-happy"; + + const prepaidMessagesItem = items.prepaidMessages({ + includedUsage: 100, + billingUnits: 100, + price: 10, + }); + + const pro = products.pro({ + id: "pro-eppi-happy", + items: [prepaidMessagesItem], + }); + + const { autumnV1, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: true }), // No payment method → stripe_checkout + s.products({ list: [pro] }), + ], + actions: [], + }); + + // Resolve internal customer id once for direct DB lookups below. + const dbCustomer = await ctx.db.query.customers.findFirst({ + where: eq(customers.id, customerId), + }); + expect(dbCustomer).toBeDefined(); + const internalCustomerId = dbCustomer!.internal_id; + + // 1. Attach with the new top-level enable_plan_immediately flag. + // V1_Beta (V0) shape; `enable_product_immediately` is mapped to the new + // top-level `enable_plan_immediately` by V1.2_AttachParamsChange. + const attachParams: AttachParamsV0Input = { + customer_id: customerId, + product_id: pro.id, + options: [{ feature_id: TestFeature.Messages, quantity: 300 }], + enable_product_immediately: true, + }; + const result = await autumnV1.billing.attach(attachParams); + + expect(result.payment_url).toBeDefined(); + expect(result.payment_url).toContain("checkout.stripe.com"); + + const checkoutSessionId = parseCheckoutSessionId(result.payment_url!); + expect(checkoutSessionId).toBeTruthy(); + + // 2. BEFORE completing the form, the cusProduct should already exist as Active + // and be linked to the checkout session via stripe_checkout_session_id. + const cusProductsBeforeCheckout = await CusProductService.list({ + db: ctx.db, + internalCustomerId, + inStatuses: [CusProductStatus.Active], + }); + + const proCusProductBefore = cusProductsBeforeCheckout.find( + (cp) => cp.product.id === pro.id, + ); + expect(proCusProductBefore).toBeDefined(); + expect(proCusProductBefore!.status).toBe(CusProductStatus.Active); + expect(proCusProductBefore!.subscription_ids ?? []).toHaveLength(0); + + expect(proCusProductBefore!.stripe_checkout_session_id).toBe( + checkoutSessionId, + ); + + // API view should also report the product as active immediately. + const customerBefore = + await autumnV1.customers.get(customerId); + await expectProductActive({ + customer: customerBefore, + productId: pro.id, + }); + + // 3. Customer completes the Stripe-hosted checkout. + await completeStripeCheckoutForm({ url: result.payment_url! }); + + // 4. After completion: same cusProduct row, now with subscription_ids patched. + const cusProductsAfter = await CusProductService.list({ + db: ctx.db, + internalCustomerId, + inStatuses: [CusProductStatus.Active], + }); + const proCusProductAfter = cusProductsAfter.find( + (cp) => cp.product.id === pro.id, + ); + expect(proCusProductAfter).toBeDefined(); + expect(proCusProductAfter!.id).toBe(proCusProductBefore!.id); // same row + expect(proCusProductAfter!.subscription_ids ?? []).toHaveLength(1); + + // 5. Prepaid quantity must have been reconciled into the Stripe subscription + // AND into Autumn's entitlement balances (proves modifyStripeSubscription + // + balance setup ran during webhook handling). + const customerAfter = await autumnV1.customers.get(customerId); + await expectProductActive({ customer: customerAfter, productId: pro.id }); + expectCustomerFeatureCorrect({ + customer: customerAfter, + featureId: TestFeature.Messages, + includedUsage: 300, + balance: 300, + usage: 0, + }); + + // 6. Single invoice issued: $20 base + 2 paid packs @ $10 = $40. + await expectCustomerInvoiceCorrect({ + customer: customerAfter, + count: 1, + latestTotal: 40, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 2: Abandoned checkout — cusProduct cleaned up on session.expired +// ═══════════════════════════════════════════════════════════════════════════════ + +// NOTE: Skipped until the implementation lands. Stripe checkout sessions auto-expire +// 24h after creation; we'll drive expiry via `s.advanceTestClock` once the handler +// for `checkout.session.expired` exists. The assertions are written out so flipping +// `test.skip` → `test.concurrent` is the only change needed. +test.skip(`${chalk.yellowBright("stripe-checkout enable_plan_immediately: expired session cleans up cusProduct")}`, async () => { + const customerId = "stripe-checkout-eppi-expired"; + + const prepaidMessagesItem = items.prepaidMessages({ + includedUsage: 100, + billingUnits: 100, + price: 10, + }); + + const pro = products.pro({ + id: "pro-eppi-expired", + items: [prepaidMessagesItem], + }); + + const { autumnV1, ctx } = await initScenario({ + customerId, + setup: [s.customer({ testClock: true }), s.products({ list: [pro] })], + actions: [], + }); + + const dbCustomer = await ctx.db.query.customers.findFirst({ + where: eq(customers.id, customerId), + }); + const internalCustomerId = dbCustomer!.internal_id; + + // 1. Attach with enable_plan_immediately, do NOT complete the form. + // V1_Beta (V0) shape; `enable_product_immediately` is mapped to the new + // top-level `enable_plan_immediately` by V1.2_AttachParamsChange. + const attachParams: AttachParamsV0Input = { + customer_id: customerId, + product_id: pro.id, + options: [{ feature_id: TestFeature.Messages, quantity: 300 }], + enable_product_immediately: true, + }; + const result = await autumnV1.billing.attach(attachParams); + expect(result.payment_url).toBeDefined(); + + // Sanity: cusProduct exists Active before expiry. + const before = await CusProductService.list({ + db: ctx.db, + internalCustomerId, + inStatuses: [CusProductStatus.Active], + }); + expect(before.some((cp) => cp.product.id === pro.id)).toBe(true); + + // 2. Drive past the Stripe session expiry (sessions auto-expire after 24h). + // TODO: replace with the proper test-clock advance helper once we wire up + // `checkout.session.expired` simulation alongside the implementation. + // For now this test is `.skip`'d so the typed shape doesn't have to be exact. + void s; + + // 3. After expiry: cusProduct should no longer be Active. + const after = await CusProductService.list({ + db: ctx.db, + internalCustomerId, + inStatuses: [CusProductStatus.Active], + }); + expect(after.some((cp) => cp.product.id === pro.id)).toBe(false); + + // API view: pro is not active. + const customerAfter = await autumnV1.customers.get(customerId); + await expect( + expectProductActive({ customer: customerAfter, productId: pro.id }), + ).rejects.toThrow(); +}); diff --git a/server/tests/integration/billing/create-schedule/create-schedule-enable-plan-immediately.test.ts b/server/tests/integration/billing/create-schedule/create-schedule-enable-plan-immediately.test.ts new file mode 100644 index 000000000..6ccbea992 --- /dev/null +++ b/server/tests/integration/billing/create-schedule/create-schedule-enable-plan-immediately.test.ts @@ -0,0 +1,248 @@ +/** + * createSchedule + enable_plan_immediately + stripe_checkout + * + * Mirrors the attach test (`stripe-checkout-enable-plan-immediately.test.ts`) + * for the createSchedule action: + * + * - At request time, immediate-phase cusProducts (Active) and scheduled-phase + * cusProducts (Scheduled) are pre-inserted, all linked to the pending Stripe + * checkout session via `stripe_checkout_session_id`. + * - Autumn `schedules` + `schedule_phases` rows are NOT created at request time + * — they're persisted in the webhook handler on `checkout.session.completed` + * (via `persistDeferredCreateSchedule`). + * - Response is `pending_payment` with `schedule_id: null` and a `payment_url`. + * - On `checkout.session.expired`, all linked cusProducts are cleaned up. + */ + +import { expect, test } from "bun:test"; +import { + type ApiCustomerV3, + type CreateScheduleParamsV0Input, + CusProductStatus, + customers, + ms, + schedulePhases, + schedules, +} from "@autumn/shared"; +import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +import { completeStripeCheckoutFormV2 as completeStripeCheckoutForm } from "@tests/utils/browserPool/completeStripeCheckoutFormV2"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { eq } from "drizzle-orm"; +import { CusProductService } from "@/internal/customers/cusProducts/CusProductService"; + +const parseCheckoutSessionId = (url: string): string | null => { + const match = url.match(/\/c\/pay\/(cs_[^/?#]+)/); + return match?.[1] ?? null; +}; + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 1: Happy path +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("create-schedule enable_plan_immediately: pre-inserts both phases, webhook persists schedule")}`, async () => { + const customerId = "create-schedule-eppi-happy"; + + const pro = products.pro({ + id: "pro-eppi-cs", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + const growth = products.pro({ + id: "growth-eppi-cs", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const { autumnV1, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: true }), // No payment method → stripe_checkout + s.products({ list: [pro, growth] }), + ], + actions: [], + }); + + const dbCustomer = await ctx.db.query.customers.findFirst({ + where: eq(customers.id, customerId), + }); + expect(dbCustomer).toBeDefined(); + const internalCustomerId = dbCustomer!.internal_id; + + const now = Date.now(); + const params: CreateScheduleParamsV0Input = { + customer_id: customerId, + enable_plan_immediately: true, + phases: [ + { + starts_at: now, + plans: [{ plan_id: pro.id }], + }, + { + starts_at: now + ms.days(30), + plans: [{ plan_id: growth.id }], + }, + ], + }; + + const response = await autumnV1.billing.createSchedule(params); + + expect(response.status).toBe("pending_payment"); + expect(response.schedule_id).toBeNull(); + expect(response.payment_url).toBeDefined(); + expect(response.payment_url).toContain("checkout.stripe.com"); + + const checkoutSessionId = parseCheckoutSessionId(response.payment_url!); + expect(checkoutSessionId).toBeTruthy(); + + // Pre-completion: both cusProducts exist, linked to the same checkout session. + const cusProductsBefore = await CusProductService.list({ + db: ctx.db, + internalCustomerId, + inStatuses: [CusProductStatus.Active, CusProductStatus.Scheduled], + }); + + const proBefore = cusProductsBefore.find((cp) => cp.product.id === pro.id); + const growthBefore = cusProductsBefore.find( + (cp) => cp.product.id === growth.id, + ); + expect(proBefore).toBeDefined(); + expect(growthBefore).toBeDefined(); + + expect(proBefore!.status).toBe(CusProductStatus.Active); + expect(growthBefore!.status).toBe(CusProductStatus.Scheduled); + + expect(proBefore!.stripe_checkout_session_id).toBe(checkoutSessionId); + expect(growthBefore!.stripe_checkout_session_id).toBe(checkoutSessionId); + + expect(proBefore!.subscription_ids ?? []).toHaveLength(0); + expect(growthBefore!.subscription_ids ?? []).toHaveLength(0); + + // Pre-completion: no schedule rows yet. + const schedulesBefore = await ctx.db + .select() + .from(schedules) + .where(eq(schedules.internal_customer_id, internalCustomerId)); + expect(schedulesBefore).toHaveLength(0); + + // API view: pro is already active immediately. + const customerBefore = + await autumnV1.customers.get(customerId); + await expectProductActive({ customer: customerBefore, productId: pro.id }); + + // Customer completes checkout. + await completeStripeCheckoutForm({ url: response.payment_url! }); + + // Post-completion: subscription_ids patched on the immediate row, + // schedule + phases rows now exist. + const cusProductsAfter = await CusProductService.list({ + db: ctx.db, + internalCustomerId, + inStatuses: [CusProductStatus.Active, CusProductStatus.Scheduled], + }); + const proAfter = cusProductsAfter.find((cp) => cp.product.id === pro.id); + const growthAfter = cusProductsAfter.find( + (cp) => cp.product.id === growth.id, + ); + expect(proAfter!.id).toBe(proBefore!.id); + expect(proAfter!.subscription_ids ?? []).toHaveLength(1); + expect(growthAfter!.status).toBe(CusProductStatus.Scheduled); + + const schedulesAfter = await ctx.db + .select() + .from(schedules) + .where(eq(schedules.internal_customer_id, internalCustomerId)); + expect(schedulesAfter).toHaveLength(1); + + const phasesAfter = await ctx.db + .select() + .from(schedulePhases) + .where(eq(schedulePhases.schedule_id, schedulesAfter[0]!.id)); + expect(phasesAfter).toHaveLength(2); + + // scheduled_ids should be populated on paid+recurring rows once the Stripe + // subscription_schedule is created in the webhook. + expect(proAfter!.scheduled_ids ?? []).toHaveLength(1); + expect(growthAfter!.scheduled_ids ?? []).toHaveLength(1); + expect(proAfter!.scheduled_ids![0]).toBe(growthAfter!.scheduled_ids![0]); + + // Cross-checks the Stripe subscription_schedule phases against the Autumn + // cusProduct timeline. + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 2: Abandoned session — both phases cleaned up; no schedule rows ever exist +// ═══════════════════════════════════════════════════════════════════════════════ + +// Skipped until the implementation lands — same reasoning as the attach Test 2. +test.skip(`${chalk.yellowBright("create-schedule enable_plan_immediately: expired session cleans up both phases")}`, async () => { + const customerId = "create-schedule-eppi-expired"; + + const pro = products.pro({ + id: "pro-eppi-cs-exp", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + const growth = products.pro({ + id: "growth-eppi-cs-exp", + items: [items.monthlyMessages({ includedUsage: 500 })], + }); + + const { autumnV1, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: true }), + s.products({ list: [pro, growth] }), + ], + actions: [], + }); + + const dbCustomer = await ctx.db.query.customers.findFirst({ + where: eq(customers.id, customerId), + }); + const internalCustomerId = dbCustomer!.internal_id; + + const now = Date.now(); + const response = await autumnV1.billing.createSchedule({ + customer_id: customerId, + enable_plan_immediately: true, + phases: [ + { starts_at: now, plans: [{ plan_id: pro.id }] }, + { starts_at: now + ms.days(30), plans: [{ plan_id: growth.id }] }, + ], + }); + expect(response.payment_url).toBeDefined(); + + const before = await CusProductService.list({ + db: ctx.db, + internalCustomerId, + inStatuses: [CusProductStatus.Active, CusProductStatus.Scheduled], + }); + expect(before.length).toBeGreaterThanOrEqual(2); + + // TODO: drive past Stripe session expiry (24h) once the test harness + // supports a clock-advance for checkout.session.expired. + void TestFeature; + + const after = await CusProductService.list({ + db: ctx.db, + internalCustomerId, + inStatuses: [CusProductStatus.Active, CusProductStatus.Scheduled], + }); + expect( + after.some((cp) => cp.product.id === pro.id || cp.product.id === growth.id), + ).toBe(false); + + const schedulesAfter = await ctx.db + .select() + .from(schedules) + .where(eq(schedules.internal_customer_id, internalCustomerId)); + expect(schedulesAfter).toHaveLength(0); +}); diff --git a/server/tests/integration/db/full-subject/utils/fullSubjectScenarioBuilders.ts b/server/tests/integration/db/full-subject/utils/fullSubjectScenarioBuilders.ts index 807ee88f5..96de09cee 100644 --- a/server/tests/integration/db/full-subject/utils/fullSubjectScenarioBuilders.ts +++ b/server/tests/integration/db/full-subject/utils/fullSubjectScenarioBuilders.ts @@ -272,6 +272,7 @@ const buildCustomerProduct = ({ api_version: null, api_semver: ApiVersion.V2_2, external_id: null, + stripe_checkout_session_id: null, }); const buildCustomerPrice = ({ diff --git a/shared/api/billing/attachV2/attachParamsV1.ts b/shared/api/billing/attachV2/attachParamsV1.ts index 9cc5f0920..dba05a7cf 100644 --- a/shared/api/billing/attachV2/attachParamsV1.ts +++ b/shared/api/billing/attachV2/attachParamsV1.ts @@ -81,6 +81,11 @@ export const AttachParamsV1Schema = BillingParamsBaseV1Schema.extend({ no_billing_changes: z.boolean().optional().meta({ description: "If true, skips any billing changes for the attach operation.", }), + + enable_plan_immediately: z.boolean().optional().meta({ + description: + "If true, the customer's plan is activated immediately even when payment is deferred (invoice mode) or pending (Stripe checkout). For Stripe checkout, the customer_product is inserted before the customer completes the hosted form.", + }), }); export type AttachParamsV1 = z.infer; diff --git a/shared/api/billing/attachV2/multiAttachParamsV0.ts b/shared/api/billing/attachV2/multiAttachParamsV0.ts index 857060411..6cd7808b4 100644 --- a/shared/api/billing/attachV2/multiAttachParamsV0.ts +++ b/shared/api/billing/attachV2/multiAttachParamsV0.ts @@ -93,6 +93,11 @@ export const MultiAttachParamsV0Schema = z.object({ "Only applicable when the customer has an existing Stripe subscription. If true, creates a new separate subscription instead of merging into the existing one.", }), + enable_plan_immediately: z.boolean().optional().meta({ + description: + "If true, the cusProducts are activated immediately even when payment is pending via Stripe checkout.", + }), + // Internal customer_data: CustomerDataSchema.optional().meta({ internal: true, diff --git a/shared/api/billing/attachV2/requestChanges/V1.2_AttachParamsChange.ts b/shared/api/billing/attachV2/requestChanges/V1.2_AttachParamsChange.ts index a9d4a8a6b..c77e2e0da 100644 --- a/shared/api/billing/attachV2/requestChanges/V1.2_AttachParamsChange.ts +++ b/shared/api/billing/attachV2/requestChanges/V1.2_AttachParamsChange.ts @@ -60,6 +60,7 @@ export const V1_2_AttachParamsChange = defineVersionChange({ plan_id: newPlanId, feature_quantities: featureQuantities, invoice_mode: invoiceMode, + enable_plan_immediately: input.enable_product_immediately, customize: customizeV1, proration_behavior: input.billing_behavior, }; diff --git a/shared/api/billing/createSchedule/createScheduleParamsV0.ts b/shared/api/billing/createSchedule/createScheduleParamsV0.ts index f9f15851a..47f5651ac 100644 --- a/shared/api/billing/createSchedule/createScheduleParamsV0.ts +++ b/shared/api/billing/createSchedule/createScheduleParamsV0.ts @@ -91,6 +91,10 @@ export const CreateScheduleParamsV0Schema = z description: "Pass 'now' to reset the billing cycle anchor of the immediate phase to the current time.", }), + enable_plan_immediately: z.boolean().optional().meta({ + description: + "If true, the immediate-phase cusProducts are activated immediately (and scheduled-phase cusProducts pre-inserted) even when payment is pending via Stripe checkout. The Autumn schedule rows are persisted on checkout.session.completed.", + }), phases: z .tuple([CreateSchedulePhaseSchema]) .rest(CreateSchedulePhaseSchema) diff --git a/shared/models/billingModels/context/billingContext.ts b/shared/models/billingModels/context/billingContext.ts index 05f34c6bb..84a9ca7ad 100644 --- a/shared/models/billingModels/context/billingContext.ts +++ b/shared/models/billingModels/context/billingContext.ts @@ -88,6 +88,11 @@ export interface BillingContext { checkoutMode?: CheckoutMode; + // When true, the cusProduct is activated immediately even if a Stripe checkout + // session is required. Mirrors invoice-mode enable_plan_immediately for the + // stripe_checkout flow. + enablePlanImmediately?: boolean; + anchorResetRefund?: AnchorResetRefund; refundLastPayment?: "prorated" | "full"; diff --git a/shared/models/cusProductModels/cusProductModels.ts b/shared/models/cusProductModels/cusProductModels.ts index 8b667ad74..82dd320e1 100644 --- a/shared/models/cusProductModels/cusProductModels.ts +++ b/shared/models/cusProductModels/cusProductModels.ts @@ -70,6 +70,8 @@ export const CusProductSchema = z.object({ billing_version: z.enum(BillingVersion).default(BillingVersion.V1), external_id: z.string().nullable(), + + stripe_checkout_session_id: z.string().nullish(), }); export const FullCusProductSchema = CusProductSchema.extend({ diff --git a/shared/models/cusProductModels/cusProductTable.ts b/shared/models/cusProductModels/cusProductTable.ts index 1f835a391..195962c74 100644 --- a/shared/models/cusProductModels/cusProductTable.ts +++ b/shared/models/cusProductModels/cusProductTable.ts @@ -59,6 +59,11 @@ export const customerProducts = pgTable( api_semver: text("api_semver"), external_id: text("external_id"), + + // When the cusProduct was created via a Stripe checkout flow with + // enable_plan_immediately, this links the row to the pending checkout session + // so the webhook can patch in subscription_ids on completion (or expire on abandonment). + stripe_checkout_session_id: text("stripe_checkout_session_id"), }, (table) => [ foreignKey({ @@ -101,6 +106,9 @@ export const customerProducts = pgTable( "gin", table.scheduled_ids, ), + index("idx_customer_products_stripe_checkout_session_id").on( + table.stripe_checkout_session_id, + ), ], ); diff --git a/shared/models/otherModels/metadataTable.ts b/shared/models/otherModels/metadataTable.ts index c8a41ab2f..8683aa7e3 100644 --- a/shared/models/otherModels/metadataTable.ts +++ b/shared/models/otherModels/metadataTable.ts @@ -9,6 +9,7 @@ export enum MetadataType { DeferredInvoice = "deferred_invoice", CheckoutSessionV2 = "checkout_session_v2", + CheckoutSessionEnabledImmediately = "checkout_session_enabled_immediately", SetupPaymentV2 = "setup_payment_v2", } diff --git a/vite/src/components/forms/attach-v2/attachFormSchema.ts b/vite/src/components/forms/attach-v2/attachFormSchema.ts index 2d6c0c84b..fc79f579d 100644 --- a/vite/src/components/forms/attach-v2/attachFormSchema.ts +++ b/vite/src/components/forms/attach-v2/attachFormSchema.ts @@ -33,6 +33,7 @@ export const AttachFormSchema = z.object({ grantFree: z.boolean(), noBillingChanges: z.boolean(), + enablePlanImmediately: z.boolean(), carryOverBalances: z.boolean(), carryOverBalanceFeatureIds: z.array(z.string()), carryOverUsages: z.boolean(), diff --git a/vite/src/components/forms/attach-v2/components/AttachAdvancedSection.tsx b/vite/src/components/forms/attach-v2/components/AttachAdvancedSection.tsx index 126b57938..d700729bc 100644 --- a/vite/src/components/forms/attach-v2/components/AttachAdvancedSection.tsx +++ b/vite/src/components/forms/attach-v2/components/AttachAdvancedSection.tsx @@ -110,6 +110,7 @@ export function AttachAdvancedSection() { newBillingSubscription, resetBillingCycle, noBillingChanges, + enablePlanImmediately, carryOverBalances, carryOverBalanceFeatureIds, carryOverUsages, @@ -368,6 +369,19 @@ export function AttachAdvancedSection() { /> } /> + + + form.setFieldValue("enablePlanImmediately", !!checked) + } + /> + } + /> ); diff --git a/vite/src/components/forms/attach-v2/components/AttachFooter.tsx b/vite/src/components/forms/attach-v2/components/AttachFooter.tsx index eca6d1a5c..91e06a2d8 100644 --- a/vite/src/components/forms/attach-v2/components/AttachFooter.tsx +++ b/vite/src/components/forms/attach-v2/components/AttachFooter.tsx @@ -153,7 +153,7 @@ export function AttachFooter() {