chore: enable plan immediately on checkout session

This commit is contained in:
John Yeo
2026-04-30 15:04:05 +01:00
parent 88a18f502b
commit 9b8d930476
46 changed files with 1110 additions and 49 deletions

View File

@@ -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": {

View File

@@ -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",

View File

@@ -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, {

View File

@@ -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,

View File

@@ -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<string | null> => {
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;
};

View File

@@ -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<void> => {
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,
});
}
};

View File

@@ -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}`,
);
};

View File

@@ -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":

View File

@@ -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,
});
}
};

View File

@@ -242,6 +242,7 @@ export const setupAttachBillingContext = async ({
: params.proration_behavior,
invoiceMode,
enablePlanImmediately: params.enable_plan_immediately ?? false,
customPrices,
customEnts,

View File

@@ -231,6 +231,7 @@ export const setupImmediateMultiProductBillingContext = async ({
.map((featureQuantity) => featureQuantity.feature_id) ?? [],
),
invoiceMode,
enablePlanImmediately: params.enable_plan_immediately ?? false,
currentEpochMs,
billingCycleAnchorMs,
resetCycleAnchorMs,

View File

@@ -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,

View File

@@ -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

View File

@@ -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({

View File

@@ -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;
}
};

View File

@@ -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,

View File

@@ -109,6 +109,8 @@ export const initCustomerProduct = ({
billing_version: billingVersion,
external_id: externalId ?? null,
stripe_checkout_session_id: null,
};
};

View File

@@ -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,

View File

@@ -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,
},
});
};

View File

@@ -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"],
};

View File

@@ -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: 0500 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 }],
});
},
);

View File

@@ -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<ApiCustomerV3>(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<ApiCustomerV3>(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<ApiCustomerV3>(customerId);
await expect(
expectProductActive({ customer: customerAfter, productId: pro.id }),
).rejects.toThrow();
});

View File

@@ -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<ApiCustomerV3>(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);
});

View File

@@ -272,6 +272,7 @@ const buildCustomerProduct = ({
api_version: null,
api_semver: ApiVersion.V2_2,
external_id: null,
stripe_checkout_session_id: null,
});
const buildCustomerPrice = ({

View File

@@ -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<typeof AttachParamsV1Schema>;

View File

@@ -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,

View File

@@ -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,
};

View File

@@ -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)

View File

@@ -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";

View File

@@ -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({

View File

@@ -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,
),
],
);

View File

@@ -9,6 +9,7 @@ export enum MetadataType {
DeferredInvoice = "deferred_invoice",
CheckoutSessionV2 = "checkout_session_v2",
CheckoutSessionEnabledImmediately = "checkout_session_enabled_immediately",
SetupPaymentV2 = "setup_payment_v2",
}

View File

@@ -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(),

View File

@@ -110,6 +110,7 @@ export function AttachAdvancedSection() {
newBillingSubscription,
resetBillingCycle,
noBillingChanges,
enablePlanImmediately,
carryOverBalances,
carryOverBalanceFeatureIds,
carryOverUsages,
@@ -368,6 +369,19 @@ export function AttachAdvancedSection() {
/>
}
/>
<ConfigRow
title="Enable Plan Immediately"
description="Grant access as soon as the checkout session is created, before payment is completed"
action={
<Switch
checked={enablePlanImmediately}
onCheckedChange={(checked) =>
form.setFieldValue("enablePlanImmediately", !!checked)
}
/>
}
/>
</>
);

View File

@@ -153,7 +153,7 @@ export function AttachFooter() {
<Button
variant="primary"
className="w-full"
onClick={handleConfirm}
onClick={() => handleConfirm()}
isLoading={isPending}
>
{confirmLabel}

View File

@@ -94,7 +94,7 @@ export function AttachFooterV3() {
<Button
variant="primary"
className="w-full"
onClick={handleConfirm}
onClick={() => handleConfirm()}
isLoading={isPending}
>
{confirmLabel}

View File

@@ -84,7 +84,7 @@ interface AttachFormContextValue {
handleGrantFreeToggle: (params: { enabled: boolean }) => void;
isPending: boolean;
handleConfirm: () => void;
handleConfirm: (params?: { enableProductImmediately?: boolean }) => void;
handleInvoiceAttach: (params: {
enableProductImmediately: boolean;
finalizeInvoice: boolean;
@@ -178,6 +178,7 @@ export function AttachFormProvider({
discounts,
grantFree,
noBillingChanges,
enablePlanImmediately,
carryOverBalances,
carryOverBalanceFeatureIds,
carryOverUsages,
@@ -381,6 +382,7 @@ export function AttachFormProvider({
resetBillingCycle,
discounts,
noBillingChanges,
enablePlanImmediately,
carryOverBalances,
carryOverBalanceFeatureIds,
carryOverUsages,

View File

@@ -35,6 +35,7 @@ export function useAttachForm({
discounts: [],
grantFree: false,
noBillingChanges: false,
enablePlanImmediately: false,
carryOverBalances: false,
carryOverBalanceFeatureIds: [],
carryOverUsages: false,

View File

@@ -80,8 +80,12 @@ export function useAttachMutation({
},
});
const handleConfirm = () => {
mutation.mutate({ useInvoice: false });
const handleConfirm = ({
enableProductImmediately,
}: {
enableProductImmediately?: boolean;
} = {}) => {
mutation.mutate({ useInvoice: false, enableProductImmediately });
};
const handleInvoiceAttach = async ({

View File

@@ -36,6 +36,7 @@ export interface BuildAttachRequestBodyParams {
resetBillingCycle: boolean;
discounts: FormDiscount[];
noBillingChanges: boolean;
enablePlanImmediately: boolean;
carryOverBalances: boolean;
carryOverBalanceFeatureIds: string[];
carryOverUsages: boolean;
@@ -63,6 +64,7 @@ export function buildAttachRequestBody({
resetBillingCycle,
discounts,
noBillingChanges,
enablePlanImmediately,
carryOverBalances,
carryOverBalanceFeatureIds = [],
carryOverUsages,
@@ -148,6 +150,10 @@ export function buildAttachRequestBody({
body.no_billing_changes = true;
}
if (enablePlanImmediately) {
body.enable_product_immediately = true;
}
if (carryOverBalances) {
body.carry_over_balances =
carryOverBalanceFeatureIds.length > 0
@@ -194,6 +200,7 @@ export function useAttachRequestBody(params: BuildAttachRequestBodyParams) {
resetBillingCycle,
discounts,
noBillingChanges,
enablePlanImmediately,
carryOverBalances,
carryOverBalanceFeatureIds,
carryOverUsages,
@@ -222,6 +229,7 @@ export function useAttachRequestBody(params: BuildAttachRequestBodyParams) {
resetBillingCycle,
discounts,
noBillingChanges,
enablePlanImmediately,
carryOverBalances,
carryOverBalanceFeatureIds,
carryOverUsages,
@@ -247,6 +255,7 @@ export function useAttachRequestBody(params: BuildAttachRequestBodyParams) {
resetBillingCycle,
discounts,
noBillingChanges,
enablePlanImmediately,
carryOverBalances,
carryOverBalanceFeatureIds,
carryOverUsages,
@@ -273,10 +282,17 @@ export function useAttachRequestBody(params: BuildAttachRequestBodyParams) {
if (useInvoice) {
body.invoice = true;
body.enable_product_immediately = enableProductImmediately;
body.finalize_invoice = finalizeInvoice ?? false;
}
// `enable_product_immediately` applies to both invoice mode and the
// stripe_checkout "enable plan immediately" flow. Keep it independent
// of `useInvoice` so the dashboard can attach the cusProduct when
// copying a checkout URL too.
if (enableProductImmediately !== undefined) {
body.enable_product_immediately = enableProductImmediately;
}
return body;
},
[requestBody],

View File

@@ -12,8 +12,10 @@ import {
import { useCreateScheduleFormContext } from "../context/CreateScheduleFormProvider";
export function CreateScheduleAdvancedSection() {
const { form, formValues } = useCreateScheduleFormContext();
const { billingBehavior, resetBillingCycle, phases } = formValues;
const { form, formValues, preview } = useCreateScheduleFormContext();
const { billingBehavior, resetBillingCycle, enablePlanImmediately, phases } =
formValues;
const isCheckoutRedirect = preview?.redirect_to_checkout === true;
const isProrate = billingBehavior !== "none";
const hasMultipleImmediatePlans = (phases[0]?.plans.length ?? 0) > 1;
@@ -62,6 +64,20 @@ export function CreateScheduleAdvancedSection() {
form.setFieldValue("resetBillingCycle", !!checked),
})}
/>
{isCheckoutRedirect && (
<ConfigRow
title="Enable Plan Immediately"
description="Activate the plan as soon as the checkout URL is generated, before the customer pays."
action={
<Switch
checked={enablePlanImmediately}
onCheckedChange={(checked) =>
form.setFieldValue("enablePlanImmediately", !!checked)
}
/>
}
/>
)}
</AdvancedSection>
);
}

View File

@@ -25,14 +25,8 @@ import { SchedulePreview } from "./SchedulePreview";
const CUSTOMER_LEVEL_VALUE = "__customer__";
export function CreateScheduleSheetContent() {
const {
form,
formValues,
entityId,
handleAddPhase,
error,
onScopeChange,
} = useCreateScheduleFormContext();
const { form, formValues, entityId, handleAddPhase, error, onScopeChange } =
useCreateScheduleFormContext();
const { closeSheet, setSheet } = useSheetStore();
const hasSchedule = useHasSchedule();
const { customer } = useCusQuery();
@@ -129,10 +123,13 @@ export function CreateScheduleSheetContent() {
function getConfirmLabel({
preview,
}: {
preview: {
redirect_to_checkout?: boolean;
total: number;
} | null | undefined;
preview:
| {
redirect_to_checkout?: boolean;
total: number;
}
| null
| undefined;
}): string {
if (!preview) return "Create Schedule";
if (preview.redirect_to_checkout) return "Copy Checkout URL";
@@ -214,7 +211,7 @@ export function CreateScheduleReviewContent() {
<Button
variant="primary"
className="w-full"
onClick={handleSubmit}
onClick={() => handleSubmit()}
isLoading={isPending}
disabled={isDisabled}
>

View File

@@ -150,6 +150,11 @@ export function CreateScheduleFormProvider({
[form.store],
);
const getEnablePlanImmediately = useCallback(
() => form.store.state.values.enablePlanImmediately ?? false,
[form.store],
);
const buildRequestBody = useBuildCreateScheduleRequestBody({
customerId,
entityId,
@@ -159,6 +164,7 @@ export function CreateScheduleFormProvider({
getPhases,
getBillingBehavior,
getResetBillingCycle,
getEnablePlanImmediately,
});
const previewRequestBody = useCreateScheduleRequestBody({

View File

@@ -171,6 +171,7 @@ export const CreateScheduleFormSchema = z
phases: z.array(SchedulePhaseSchema).min(1),
billingBehavior: BillingBehaviorSchema.nullable(),
resetBillingCycle: z.boolean(),
enablePlanImmediately: z.boolean(),
})
.refine(
(data) =>

View File

@@ -10,7 +10,10 @@ import { productItemsToPlanItemsV1 } from "@autumn/shared";
import { useMemo } from "react";
import { convertPrepaidOptionsToFeatureOptions } from "@/utils/billing/prepaidQuantityUtils";
type CreatePlanItemParams = Omit<ApiPlanItemV1, "reset" | "price" | "rollover"> & {
type CreatePlanItemParams = Omit<
ApiPlanItemV1,
"reset" | "price" | "rollover"
> & {
reset?: ApiPlanItemV1["reset"];
price?: ApiPlanItemV1["price"];
rollover?: ApiPlanItemV1["rollover"];
@@ -236,6 +239,7 @@ export function useBuildCreateScheduleRequestBody({
getPhases,
getBillingBehavior,
getResetBillingCycle,
getEnablePlanImmediately,
}: {
customerId: string | undefined;
entityId: string | undefined;
@@ -245,6 +249,7 @@ export function useBuildCreateScheduleRequestBody({
getPhases: () => SchedulePhase[];
getBillingBehavior?: () => BillingBehavior | null;
getResetBillingCycle?: () => boolean;
getEnablePlanImmediately?: () => boolean;
}) {
return useMemo(
() =>
@@ -281,6 +286,17 @@ export function useBuildCreateScheduleRequestBody({
};
}
// `enable_plan_immediately` also applies to the stripe_checkout flow:
// when the form toggle is on, cusProducts (immediate Active + scheduled
// Scheduled) are inserted at request time and the schedule rows
// materialize on checkout.session.completed.
if (getEnablePlanImmediately?.()) {
return {
...requestBody,
enable_plan_immediately: true,
};
}
return requestBody;
},
[
@@ -292,6 +308,7 @@ export function useBuildCreateScheduleRequestBody({
getPhases,
getBillingBehavior,
getResetBillingCycle,
getEnablePlanImmediately,
],
);
}

View File

@@ -145,6 +145,7 @@ export function buildInitialValues({
})),
billingBehavior: null,
resetBillingCycle: false,
enablePlanImmediately: false,
};
}
@@ -169,6 +170,7 @@ export function buildInitialValues({
],
billingBehavior: null,
resetBillingCycle: false,
enablePlanImmediately: false,
};
}