chore: enable plan immediately on checkout session
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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}`,
|
||||
);
|
||||
};
|
||||
@@ -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":
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -242,6 +242,7 @@ export const setupAttachBillingContext = async ({
|
||||
: params.proration_behavior,
|
||||
|
||||
invoiceMode,
|
||||
enablePlanImmediately: params.enable_plan_immediately ?? false,
|
||||
|
||||
customPrices,
|
||||
customEnts,
|
||||
|
||||
@@ -231,6 +231,7 @@ export const setupImmediateMultiProductBillingContext = async ({
|
||||
.map((featureQuantity) => featureQuantity.feature_id) ?? [],
|
||||
),
|
||||
invoiceMode,
|
||||
enablePlanImmediately: params.enable_plan_immediately ?? false,
|
||||
currentEpochMs,
|
||||
billingCycleAnchorMs,
|
||||
resetCycleAnchorMs,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -109,6 +109,8 @@ export const initCustomerProduct = ({
|
||||
billing_version: billingVersion,
|
||||
|
||||
external_id: externalId ?? null,
|
||||
|
||||
stripe_checkout_session_id: null,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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"],
|
||||
};
|
||||
|
||||
60
server/tests/_temp/volume-tiers-inspect.test.ts
Normal file
60
server/tests/_temp/volume-tiers-inspect.test.ts
Normal 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: 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 }],
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -272,6 +272,7 @@ const buildCustomerProduct = ({
|
||||
api_version: null,
|
||||
api_semver: ApiVersion.V2_2,
|
||||
external_id: null,
|
||||
stripe_checkout_session_id: null,
|
||||
});
|
||||
|
||||
const buildCustomerPrice = ({
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ export enum MetadataType {
|
||||
|
||||
DeferredInvoice = "deferred_invoice",
|
||||
CheckoutSessionV2 = "checkout_session_v2",
|
||||
CheckoutSessionEnabledImmediately = "checkout_session_enabled_immediately",
|
||||
SetupPaymentV2 = "setup_payment_v2",
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
@@ -153,7 +153,7 @@ export function AttachFooter() {
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
onClick={handleConfirm}
|
||||
onClick={() => handleConfirm()}
|
||||
isLoading={isPending}
|
||||
>
|
||||
{confirmLabel}
|
||||
|
||||
@@ -94,7 +94,7 @@ export function AttachFooterV3() {
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
onClick={handleConfirm}
|
||||
onClick={() => handleConfirm()}
|
||||
isLoading={isPending}
|
||||
>
|
||||
{confirmLabel}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -35,6 +35,7 @@ export function useAttachForm({
|
||||
discounts: [],
|
||||
grantFree: false,
|
||||
noBillingChanges: false,
|
||||
enablePlanImmediately: false,
|
||||
carryOverBalances: false,
|
||||
carryOverBalanceFeatureIds: [],
|
||||
carryOverUsages: false,
|
||||
|
||||
@@ -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 ({
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user