diff --git a/server/src/external/autumn/autumnCli.ts b/server/src/external/autumn/autumnCli.ts index 153aedc55..6b8874da6 100644 --- a/server/src/external/autumn/autumnCli.ts +++ b/server/src/external/autumn/autumnCli.ts @@ -35,6 +35,8 @@ import { type LegacyVersion, type OrgConfig, type ProductItem, + type RestoreParamsV1, + type RestoreResponse, type RewardRedemption, type SetUsageParams, type SetupPaymentParamsV1, @@ -1111,5 +1113,9 @@ export class AutumnInt { }); return data; }, + + restore: async (params: RestoreParamsV1): Promise => { + return await this.post(`/billing.restore`, params); + }, }; } diff --git a/server/src/external/stripe/subscriptionSchedules/index.ts b/server/src/external/stripe/subscriptionSchedules/index.ts index 14011b70d..14ae764df 100644 --- a/server/src/external/stripe/subscriptionSchedules/index.ts +++ b/server/src/external/stripe/subscriptionSchedules/index.ts @@ -1,4 +1,5 @@ export * from "./operations/getStripeActiveSubscriptionSchedule"; +export * from "./utils/classifyStripeSubscriptionScheduleUtils"; export * from "./utils/convertStripeSubscriptionScheduleUtils"; export * from "./utils/logStripeSchedulePhaseUtils"; diff --git a/server/src/external/stripe/subscriptionSchedules/operations/getStripeActiveSubscriptionSchedule.ts b/server/src/external/stripe/subscriptionSchedules/operations/getStripeActiveSubscriptionSchedule.ts index f368c3d0b..ed2f1924d 100644 --- a/server/src/external/stripe/subscriptionSchedules/operations/getStripeActiveSubscriptionSchedule.ts +++ b/server/src/external/stripe/subscriptionSchedules/operations/getStripeActiveSubscriptionSchedule.ts @@ -3,12 +3,15 @@ import type Stripe from "stripe"; export const getStripeActiveSubscriptionSchedule = async ({ stripeClient, subscriptionScheduleId, + expand, }: { stripeClient: Stripe; subscriptionScheduleId: string; + expand?: string[]; }): Promise => { const schedule = await stripeClient.subscriptionSchedules.retrieve( subscriptionScheduleId, + expand ? { expand } : undefined, ); if (schedule.status === "canceled" || schedule.status === "released") { diff --git a/server/src/external/stripe/subscriptionSchedules/utils/classifyStripeSubscriptionScheduleUtils.ts b/server/src/external/stripe/subscriptionSchedules/utils/classifyStripeSubscriptionScheduleUtils.ts index 2d99c650d..8c045810b 100644 --- a/server/src/external/stripe/subscriptionSchedules/utils/classifyStripeSubscriptionScheduleUtils.ts +++ b/server/src/external/stripe/subscriptionSchedules/utils/classifyStripeSubscriptionScheduleUtils.ts @@ -1,6 +1,19 @@ import type Stripe from "stripe"; import { stripeSubscriptionScheduleToPhaseIndex } from "./convertStripeSubscriptionScheduleUtils"; +/** Checks if a Stripe subscription schedule phase is current. */ +export const isStripeSubscriptionSchedulePhaseCurrent = ({ + phase, + nowSeconds, +}: { + phase: Stripe.SubscriptionSchedule.Phase; + nowSeconds: number; +}): boolean => { + if (nowSeconds < phase.start_date) return false; + if (phase.end_date && nowSeconds >= phase.end_date) return false; + return true; +}; + /** Checks if a Stripe subscription schedule is in its last phase. */ export const isStripeSubscriptionScheduleInLastPhase = ({ stripeSubscriptionSchedule, diff --git a/server/src/external/stripe/subscriptions/utils/convertStripeSubscription.ts b/server/src/external/stripe/subscriptions/utils/convertStripeSubscription.ts index bc796f237..718be7204 100644 --- a/server/src/external/stripe/subscriptions/utils/convertStripeSubscription.ts +++ b/server/src/external/stripe/subscriptions/utils/convertStripeSubscription.ts @@ -68,6 +68,21 @@ export const stripeSubscriptionToTrialEndsAtMs = ({ : undefined; }; +/** Gets the earliest current period start for a Stripe subscription. */ +export const stripeSubscriptionToStartDate = ({ + stripeSubscription, +}: { + stripeSubscription: Stripe.Subscription; +}) => { + if (stripeSubscription.items.data.length === 0) { + return stripeSubscription.start_date; + } + + return stripeSubscription.items.data.reduce((earliestStartDate, item) => { + return Math.min(earliestStartDate, item.current_period_start); + }, stripeSubscription.items.data[0].current_period_start); +}; + /** * Gets the latest invoice for a Stripe subscription. * Handles both expanded (object) and unexpanded (string ID) cases. diff --git a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionCreated/tasks/autoSyncFromSubscription.ts b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionCreated/tasks/autoSyncFromSubscription.ts index 20bfb967d..b899de6ad 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionCreated/tasks/autoSyncFromSubscription.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionCreated/tasks/autoSyncFromSubscription.ts @@ -1,10 +1,19 @@ -import type { SyncMappingV0 } from "@autumn/shared"; import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext.js"; -import { sync } from "@/internal/billing/v2/actions/sync/sync.js"; -import { findAutumnProductsForSubscription } from "@/internal/billing/v2/providers/stripe/utils/sync/stripeToAutumn/findAutumnProductsForSubscription.js"; -import { subscriptionToPrepaidFeatureOptions } from "@/internal/billing/v2/providers/stripe/utils/sync/stripeToAutumn/subscriptionToFeatureOptions.js"; +import { billingActions } from "@/internal/billing/v2/actions"; +import { canAutoSync } from "@/internal/billing/v2/actions/sync/canAutoSync.js"; +import { subscriptionToSyncParams } from "@/internal/billing/v2/actions/sync/subscriptionToSyncParams.js"; import type { StripeSubscriptionCreatedContext } from "../setupStripeSubscriptionCreatedContext.js"; +/** + * On Stripe subscription create, run detection on the new subscription and + * (when safe) execute a syncV2 to materialize the matching Autumn customer + * products. + * + * Eligibility is gated by `canAutoSync` — conservative defaults: any + * unmatched Stripe item, custom feature price, plan warning, or + * unresolvable base price aborts auto-sync. Custom BASE prices are + * accepted (handled via `customize.price`). + */ export const autoSyncFromSubscription = async ({ ctx, subscriptionCreatedContext, @@ -13,53 +22,22 @@ export const autoSyncFromSubscription = async ({ subscriptionCreatedContext: StripeSubscriptionCreatedContext; }) => { const { logger } = ctx; - const { subscription, fullCustomer, candidateProducts } = - subscriptionCreatedContext; + const { subscription, fullCustomer } = subscriptionCreatedContext; + const customerId = fullCustomer.id ?? fullCustomer.internal_id; - const matchedProducts = findAutumnProductsForSubscription({ - stripeSubscription: subscription, - products: candidateProducts, + const { match, params } = await subscriptionToSyncParams({ + ctx, + customerId, + subscription, }); - if (matchedProducts.length === 0) { + const eligibility = canAutoSync({ match }); + if (!eligibility.eligible) { logger.info( - `sub.created auto-sync: no Autumn product matched stripe sub ${subscription.id}, skipping`, + `sub.created auto-sync skipping ${subscription.id}: ${eligibility.reason} — ${eligibility.details}`, ); return; } - if (matchedProducts.length > 1) { - logger.warn( - `sub.created auto-sync: stripe sub ${subscription.id} matched ${matchedProducts.length} Autumn products (${matchedProducts - .map((product) => product.id) - .join( - ", ", - )}); skipping due to ambiguity. Check for overlapping stripe price IDs across products.`, - ); - return; - } - - const [matchedProduct] = matchedProducts; - const prepaidFeatureOptions = subscriptionToPrepaidFeatureOptions({ - ctx, - stripeSubscription: subscription, - matchedProduct, - }); - - const mappings: SyncMappingV0[] = [ - { - stripe_subscription_id: subscription.id, - plan_id: matchedProduct.id, - prepaid_feature_options: prepaidFeatureOptions, - expire_previous: true, - }, - ]; - - await sync({ - ctx, - params: { - customer_id: fullCustomer.id ?? fullCustomer.internal_id, - mappings, - }, - }); + await billingActions.syncV2({ ctx, params }); }; diff --git a/server/src/internal/billing/billingRouter.ts b/server/src/internal/billing/billingRouter.ts index e4f0e8471..c5957a966 100644 --- a/server/src/internal/billing/billingRouter.ts +++ b/server/src/internal/billing/billingRouter.ts @@ -13,9 +13,12 @@ import { handlePreviewCreateSchedule } from "./v2/handlers/handlePreviewCreateSc import { handleMultiAttach } from "./v2/handlers/handleMultiAttach.js"; import { handlePreviewMultiAttach } from "./v2/handlers/handlePreviewMultiAttach.js"; import { handlePreviewUpdateSubscription } from "./v2/handlers/handlePreviewUpdateSubscription.js"; +import { handleRestore } from "./v2/handlers/handleRestore.js"; import { handleSetupPaymentV2 } from "./v2/handlers/handleSetupPaymentV2.js"; import { handleSync } from "./v2/handlers/handleSync.js"; import { handleSyncProposals } from "./v2/handlers/handleSyncProposals.js"; +import { handleSyncProposalsV2 } from "./v2/handlers/handleSyncProposalsV2.js"; +import { handleSyncV2 } from "./v2/handlers/handleSyncV2.js"; import { handleUpdateSubscription } from "./v2/handlers/handleUpdateSubscription.js"; export const billingRouter = new Hono(); @@ -52,4 +55,7 @@ billingRpcRouter.post( ...handleOpenCustomerPortalV2, ); billingRpcRouter.post("/billing.sync_proposals", ...handleSyncProposals); +billingRpcRouter.post("/billing.sync_proposals_v2", ...handleSyncProposalsV2); billingRpcRouter.post("/billing.sync", ...handleSync); +billingRpcRouter.post("/billing.sync_v2", ...handleSyncV2); +billingRpcRouter.post("/billing.restore", ...handleRestore); diff --git a/server/src/internal/billing/v2/actions/createSchedule/compute/computeScheduledCustomerProducts.ts b/server/src/internal/billing/v2/actions/createSchedule/compute/computeScheduledCustomerProducts.ts index 31353653e..1584b070e 100644 --- a/server/src/internal/billing/v2/actions/createSchedule/compute/computeScheduledCustomerProducts.ts +++ b/server/src/internal/billing/v2/actions/createSchedule/compute/computeScheduledCustomerProducts.ts @@ -1,41 +1,9 @@ import type { CreateScheduleBillingContext, FullCusProduct, - ScheduledPhaseContext, } from "@autumn/shared"; -import { BillingVersion, CusProductStatus } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; -import { initFullCustomerProduct } from "@/internal/billing/v2/utils/initFullCustomerProduct/initFullCustomerProduct"; - -const initScheduledCustomerProduct = ({ - ctx, - billingContext, - phaseContext, - productContext, -}: { - ctx: AutumnContext; - billingContext: CreateScheduleBillingContext; - phaseContext: ScheduledPhaseContext; - productContext: ScheduledPhaseContext["productContexts"][number]; -}): FullCusProduct => { - return initFullCustomerProduct({ - ctx, - initContext: { - fullCustomer: billingContext.fullCustomer, - fullProduct: productContext.fullProduct, - featureQuantities: productContext.featureQuantities, - resetCycleAnchor: phaseContext.startsAt, - freeTrial: null, - now: billingContext.currentEpochMs, - billingVersion: BillingVersion.V2, - }, - initOptions: { - startsAt: phaseContext.startsAt, - endedAt: phaseContext.endsAt, - status: CusProductStatus.Scheduled, - }, - }); -}; +import { initScheduledCustomerProduct } from "@/internal/billing/v2/utils/initFullCustomerProduct/initScheduledCustomerProduct"; /** Build scheduled customer products to insert and existing ones to delete. */ export const computeScheduledCustomerProducts = ({ @@ -59,9 +27,12 @@ export const computeScheduledCustomerProducts = ({ for (const productContext of phaseContext.productContexts) { const customerProduct = initScheduledCustomerProduct({ ctx, - billingContext, - phaseContext, - productContext, + fullCustomer: billingContext.fullCustomer, + fullProduct: productContext.fullProduct, + featureQuantities: productContext.featureQuantities, + startsAt: phaseContext.startsAt, + endsAt: phaseContext.endsAt, + currentEpochMs: billingContext.currentEpochMs, }); insertCustomerProducts.push(customerProduct); phaseCustomerProductIds.push(customerProduct.id); diff --git a/server/src/internal/billing/v2/actions/index.ts b/server/src/internal/billing/v2/actions/index.ts index 89fe808c1..abfe1cdb6 100644 --- a/server/src/internal/billing/v2/actions/index.ts +++ b/server/src/internal/billing/v2/actions/index.ts @@ -6,9 +6,12 @@ import { renew } from "@/internal/billing/v2/actions/legacy/renew"; import { updateQuantity } from "@/internal/billing/v2/actions/legacy/updateQuantity"; import { migrate } from "@/internal/billing/v2/actions/migrate/migrate"; import { multiAttach } from "@/internal/billing/v2/actions/multiAttach/multiAttach"; +import { restore } from "@/internal/billing/v2/actions/restore/restore"; import { setupPayment } from "@/internal/billing/v2/actions/setupPayment/setupPayment"; import { sync } from "@/internal/billing/v2/actions/sync/sync"; import { syncProposals } from "@/internal/billing/v2/actions/sync/syncProposals"; +import { syncProposalsV2 } from "@/internal/billing/v2/actions/sync/syncProposalsV2"; +import { syncV2 } from "@/internal/billing/v2/actions/sync/syncV2"; import { updateSubscription } from "@/internal/billing/v2/actions/updateSubscription/updateSubscription"; export const billingActions = { @@ -19,8 +22,11 @@ export const billingActions = { setupPayment: setupPayment, updateSubscription: updateSubscription, migrate: migrate, + restore: restore, sync: sync, + syncV2: syncV2, syncProposals: syncProposals, + syncProposalsV2: syncProposalsV2, legacy: { attach: legacyAttach, diff --git a/server/src/internal/billing/v2/actions/restore/errors/handleRestoreErrors.ts b/server/src/internal/billing/v2/actions/restore/errors/handleRestoreErrors.ts new file mode 100644 index 000000000..550728f65 --- /dev/null +++ b/server/src/internal/billing/v2/actions/restore/errors/handleRestoreErrors.ts @@ -0,0 +1,70 @@ +import { + InternalError, + type StripeBillingPlan, + type StripeSubscriptionAction, + type StripeSubscriptionScheduleAction, +} from "@autumn/shared"; + +const ALLOWED_SUB_ACTION_TYPES = new Set([ + "update", +]); + +const ALLOWED_SCHEDULE_ACTION_TYPES = new Set< + StripeSubscriptionScheduleAction["type"] +>(["update", "create"]); + +/** + * Restore must never create new subs, cancel subs, charge invoices, or release + * schedules. A "release" specifically signals the schedule was never imported + * into Autumn — silently releasing it would compound the drift restore is meant + * to fix. Anything other than no-op / update for the subscription, or no-op / + * update / create for the schedule, throws. + */ +export const handleRestoreErrors = ({ + stripeBillingPlan, + stripeSubscriptionId, +}: { + stripeBillingPlan: StripeBillingPlan; + stripeSubscriptionId: string; +}) => { + const { + subscriptionAction, + subscriptionScheduleAction, + invoiceAction, + invoiceItemsAction, + checkoutSessionAction, + refundAction, + } = stripeBillingPlan; + + if ( + subscriptionAction && + !ALLOWED_SUB_ACTION_TYPES.has(subscriptionAction.type) + ) { + throw new InternalError({ + message: `[Restore] Unexpected subscription action '${subscriptionAction.type}' for subscription ${stripeSubscriptionId}. Restore only allows 'update'.`, + code: "restore_unexpected_subscription_action", + }); + } + + if ( + subscriptionScheduleAction && + !ALLOWED_SCHEDULE_ACTION_TYPES.has(subscriptionScheduleAction.type) + ) { + throw new InternalError({ + message: `[Restore] Unexpected schedule action '${subscriptionScheduleAction.type}' for subscription ${stripeSubscriptionId}. Restore only allows 'update' or 'create'; 'release' usually means the schedule was never imported into Autumn.`, + code: "restore_unexpected_schedule_action", + }); + } + + if ( + invoiceAction || + invoiceItemsAction || + checkoutSessionAction || + refundAction + ) { + throw new InternalError({ + message: `[Restore] Unexpected non-subscription action produced for ${stripeSubscriptionId}. Restore should only mutate Stripe subscription/schedule state.`, + code: "restore_unexpected_action", + }); + } +}; diff --git a/server/src/internal/billing/v2/actions/restore/restore.ts b/server/src/internal/billing/v2/actions/restore/restore.ts new file mode 100644 index 000000000..78106f81b --- /dev/null +++ b/server/src/internal/billing/v2/actions/restore/restore.ts @@ -0,0 +1,71 @@ +import type { + AutumnBillingPlan, + RestoreParamsV1, + RestoreResponse, + RestoreSubscriptionResult, +} from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { evaluateStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan"; +import { executeStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/execute/executeStripeBillingPlan"; +import { handleRestoreErrors } from "./errors/handleRestoreErrors"; +import { buildRestoreBillingContext } from "./setup/buildRestoreBillingContext"; +import { setupRestoreContext } from "./setup/setupRestoreContext"; + +export const restore = async ({ + ctx, + params, +}: { + ctx: AutumnContext; + params: RestoreParamsV1; +}): Promise => { + const { customer_id: customerId } = params; + + const { fullCustomer, stripeCustomer, subscriptionIds } = + await setupRestoreContext({ ctx, customerId }); + + const restored: RestoreSubscriptionResult[] = []; + + for (const stripeSubscriptionId of subscriptionIds) { + const billingContext = await buildRestoreBillingContext({ + ctx, + fullCustomer, + stripeCustomer, + stripeSubscriptionId, + }); + + const autumnBillingPlan: AutumnBillingPlan = { + customerId, + insertCustomerProducts: [], + }; + + const stripeBillingPlan = await evaluateStripeBillingPlan({ + ctx, + billingContext, + autumnBillingPlan, + }); + + handleRestoreErrors({ stripeBillingPlan, stripeSubscriptionId }); + + await executeStripeBillingPlan({ + ctx, + billingPlan: { autumn: autumnBillingPlan, stripe: stripeBillingPlan }, + billingContext, + }); + + restored.push({ + stripe_subscription_id: stripeSubscriptionId, + stripe_schedule_id: billingContext.stripeSubscriptionSchedule?.id ?? null, + sub_action: stripeBillingPlan.subscriptionAction ? "update" : "noop", + schedule_action: stripeBillingPlan.subscriptionScheduleAction + ? (stripeBillingPlan.subscriptionScheduleAction.type as + | "update" + | "create") + : "noop", + }); + } + + return { + customer_id: customerId, + restored, + }; +}; diff --git a/server/src/internal/billing/v2/actions/restore/setup/buildRestoreBillingContext.ts b/server/src/internal/billing/v2/actions/restore/setup/buildRestoreBillingContext.ts new file mode 100644 index 000000000..e9b9a0e37 --- /dev/null +++ b/server/src/internal/billing/v2/actions/restore/setup/buildRestoreBillingContext.ts @@ -0,0 +1,63 @@ +import { + type BillingContext, + BillingVersion, + type FullCustomer, + InternalError, + secondsToMs, +} from "@autumn/shared"; +import type Stripe from "stripe"; +import { createStripeCli } from "@/external/connect/createStripeCli"; +import { getStripeActiveSubscriptionSchedule } from "@/external/stripe/subscriptionSchedules/index"; +import { isStripeSubscriptionCanceled } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils"; +import { stripeSubscriptionToScheduleId } from "@/external/stripe/subscriptions/utils/convertStripeSubscription"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; + +export const buildRestoreBillingContext = async ({ + ctx, + fullCustomer, + stripeCustomer, + stripeSubscriptionId, +}: { + ctx: AutumnContext; + fullCustomer: FullCustomer; + stripeCustomer?: Stripe.Customer; + stripeSubscriptionId: string; +}): Promise => { + const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env }); + + const stripeSubscription = await stripeCli.subscriptions.retrieve( + stripeSubscriptionId, + { expand: ["discounts.source.coupon.applies_to"] }, + ); + + if (isStripeSubscriptionCanceled(stripeSubscription)) { + throw new InternalError({ + message: `[Restore] Stripe subscription is canceled: ${stripeSubscriptionId}`, + }); + } + + const scheduleId = stripeSubscriptionToScheduleId({ stripeSubscription }); + + const stripeSubscriptionSchedule = scheduleId + ? await getStripeActiveSubscriptionSchedule({ + stripeClient: stripeCli, + subscriptionScheduleId: scheduleId, + }) + : undefined; + + return { + fullCustomer, + fullProducts: [], + featureQuantities: [], + currentEpochMs: Date.now(), + billingCycleAnchorMs: secondsToMs(stripeSubscription.billing_cycle_anchor), + resetCycleAnchorMs: "now", + + stripeCustomer, + stripeSubscription, + stripeSubscriptionSchedule, + + billingVersion: BillingVersion.V2, + actionSource: "restore", + }; +}; diff --git a/server/src/internal/billing/v2/actions/restore/setup/setupRestoreContext.ts b/server/src/internal/billing/v2/actions/restore/setup/setupRestoreContext.ts new file mode 100644 index 000000000..edd631ca0 --- /dev/null +++ b/server/src/internal/billing/v2/actions/restore/setup/setupRestoreContext.ts @@ -0,0 +1,60 @@ +import { + ACTIVE_STATUSES, + CusProductStatus, + cusProductToPrices, + type FullCusProduct, + type FullCustomer, + isFreeProduct, + isOneOffProduct, +} from "@autumn/shared"; +import type Stripe from "stripe"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { fetchStripeCustomerForBilling } from "@/internal/billing/v2/providers/stripe/setup/fetchStripeCustomerForBilling"; +import { setupFullCustomerContext } from "@/internal/billing/v2/setup/setupFullCustomerContext"; + +export type RestoreContext = { + fullCustomer: FullCustomer; + stripeCustomer?: Stripe.Customer; + subscriptionIds: string[]; +}; + +const isPaidRecurring = (customerProduct: FullCusProduct) => { + const hasActiveStatus = + ACTIVE_STATUSES.includes(customerProduct.status) || + customerProduct.status === CusProductStatus.Trialing; + if (!hasActiveStatus) return false; + + const prices = cusProductToPrices({ cusProduct: customerProduct }); + return !isOneOffProduct({ prices }) && !isFreeProduct({ prices }); +}; + +export const setupRestoreContext = async ({ + ctx, + customerId, +}: { + ctx: AutumnContext; + customerId: string; +}): Promise => { + const fullCustomer = await setupFullCustomerContext({ + ctx, + params: { customer_id: customerId }, + }); + + const { stripeCus } = await fetchStripeCustomerForBilling({ + ctx, + fullCus: fullCustomer, + }); + + const uniqueSubscriptionIds = new Set(); + for (const customerProduct of fullCustomer.customer_products) { + if (!isPaidRecurring(customerProduct)) continue; + const subscriptionId = customerProduct.subscription_ids?.[0]; + if (subscriptionId) uniqueSubscriptionIds.add(subscriptionId); + } + + return { + fullCustomer, + stripeCustomer: stripeCus, + subscriptionIds: [...uniqueSubscriptionIds], + }; +}; diff --git a/server/src/internal/billing/v2/actions/sync/buildSyncParams/buildFeatureQuantities.ts b/server/src/internal/billing/v2/actions/sync/buildSyncParams/buildFeatureQuantities.ts new file mode 100644 index 000000000..172879eb8 --- /dev/null +++ b/server/src/internal/billing/v2/actions/sync/buildSyncParams/buildFeatureQuantities.ts @@ -0,0 +1,90 @@ +import { + type FeatureQuantityParamsV0, + isPrepaidPrice, + priceToEnt, +} from "@autumn/shared"; +import { Decimal } from "decimal.js"; +import type { + ItemDiff, + MatchedPlan, +} from "@/internal/billing/v2/actions/sync/detect/types"; + +/** + * Build feature_quantities for a MatchedPlan by walking the product's + * prepaid prices. + * + * Stripe stores the same Autumn prepaid price under TWO Stripe price ids + * with different quantity semantics: + * + * - `price.config.stripe_price_id` (V1): Stripe quantity counts EXTRAS + * only — allowance is implicit, not billed. We add `allowance` back so + * `paramsToFeatureOptions`'s allowance subtraction resolves to extras. + * + * - `price.config.stripe_prepaid_price_v2_id` (V2): Stripe quantity is + * TOTAL packs (allowance included). Pass through unchanged. + * + * Either way the emitted `quantity` is in feature units, which is what + * `paramsToFeatureOptions` expects. + * + * If a prepaid price is defined in Autumn but absent from Stripe, the + * helper emits `{ quantity: 0 }` so the synced cusProduct surfaces the + * feature with zero allowance. + */ +export const buildFeatureQuantities = ({ + matchedPlan, + itemDiffs, +}: { + matchedPlan: MatchedPlan; + itemDiffs: ItemDiff[]; +}): FeatureQuantityParamsV0[] => { + const result: FeatureQuantityParamsV0[] = []; + + for (const price of matchedPlan.product.prices) { + if (!isPrepaidPrice(price)) continue; + + const entitlement = priceToEnt({ + price, + entitlements: matchedPlan.product.entitlements, + }); + if (!entitlement) continue; + + const matchedFeature = matchedPlan.features.find( + (f) => f.autumn_price_id === price.id, + ); + + if (!matchedFeature) { + result.push({ feature_id: entitlement.feature.id, quantity: 0 }); + continue; + } + + const itemDiff = itemDiffs.find( + (d) => d.stripe.id === matchedFeature.stripe_item_id, + ); + if (!itemDiff) { + result.push({ feature_id: entitlement.feature.id, quantity: 0 }); + continue; + } + + const billingUnits = price.config.billing_units ?? 1; + const allowance = entitlement.allowance ?? 0; + const stripePriceIdOnSub = itemDiff.stripe.stripe_price_id; + const isV2Prepaid = + "stripe_prepaid_price_v2_id" in price.config && + stripePriceIdOnSub === price.config.stripe_prepaid_price_v2_id; + + const stripeQuantityInUnits = new Decimal(itemDiff.stripe.quantity) + .mul(billingUnits) + .toNumber(); + const featureUnits = isV2Prepaid + ? stripeQuantityInUnits + : stripeQuantityInUnits + allowance; + + result.push({ + feature_id: entitlement.feature.id, + quantity: featureUnits, + stripe_price_id: stripePriceIdOnSub, + }); + } + + return result; +}; diff --git a/server/src/internal/billing/v2/actions/sync/canAutoSync.ts b/server/src/internal/billing/v2/actions/sync/canAutoSync.ts new file mode 100644 index 000000000..a11cee800 --- /dev/null +++ b/server/src/internal/billing/v2/actions/sync/canAutoSync.ts @@ -0,0 +1,159 @@ +import type { + PhaseMatch, + PlanWarning, + SubscriptionMatch, +} from "./detect/types"; + +export type AutoSyncRejectionReason = + | "no_matched_plans" + | "multiple_main_plans" + | "plan_warnings" + | "base_price_unresolvable" + | "custom_feature_price" + | "base_quantity_gt_one"; + +export type AutoSyncEligibility = + | { eligible: true } + | { + eligible: false; + reason: AutoSyncRejectionReason; + details: string; + }; + +const findCurrentPhase = ({ + match, +}: { + match: SubscriptionMatch; +}): PhaseMatch | null => + match.phaseMatches.find((phase) => phase.is_current) ?? null; + +/** + * Default warnings that auto-sync tolerates without human review: + * - `base_price_dropped` — Autumn product has a base price but the Stripe + * sub omits it; the sync materializes the cusProduct with + * `customize: { price: null }`. + * - `base_price_adopted` — strategy A: an unclaimed extra item filled in + * for the missing base. Captured via `customize.price`. + * + * Blocking warnings (NOT in this default set): + * - `extra_items_under_plan` — extras Autumn can't yet express; ambiguous. + * - `base_plan_quantity_gt_one` — handled separately below. + */ +const DEFAULT_ALLOWED_WARNINGS: PlanWarning["type"][] = [ + "base_price_dropped", + "base_price_adopted", +]; + +/** + * Decide whether a `SubscriptionMatch` is safe to sync without human review. + * + * Conservative defaults — eligible iff the current phase: + * - has at least one MatchedPlan + * - has no MatchedPlan with `base.kind === "absent"` (Autumn product + * expects a base price but none could be derived) + * - has no Stripe item matching a non-base Autumn price via product id + * (custom feature price) + * - has no non-add-on plan with Stripe item quantity > 1 + * - emits only PlanWarnings in `allowedWarnings` + * + * Stripe items that don't match anything (`ItemDiff.match.kind === "none"`) + * are tolerated — they're external/unrelated prices that don't influence + * the Autumn cusProducts being attached. + */ +export const canAutoSync = ({ + match, + allowedWarnings = DEFAULT_ALLOWED_WARNINGS, +}: { + match: SubscriptionMatch; + allowedWarnings?: PlanWarning["type"][]; +}): AutoSyncEligibility => { + const currentPhase = findCurrentPhase({ match }); + + if (!currentPhase || currentPhase.plans.length === 0) { + return { + eligible: false, + reason: "no_matched_plans", + details: "No Autumn plans matched the current phase.", + }; + } + + // More than one non-add-on plan in the current phase is ambiguous — + // "main" plans are mutually exclusive within a customer (typically a + // single tier per product group). Auto-sync requires a clear primary + // plan to attach; add-ons may stack on top. + const mainPlans = currentPhase.plans.filter( + (plan) => plan.product.is_add_on !== true, + ); + if (mainPlans.length > 1) { + return { + eligible: false, + reason: "multiple_main_plans", + details: `Stripe sub matched multiple non-add-on Autumn plans: ${mainPlans.map((p) => p.product.id).join(", ")}`, + }; + } + + // Block when any Stripe item matches a non-base Autumn price via + // stripe_product_id (priority-2). That signals a custom Stripe price + // for a feature/prepaid item — we don't auto-rewrite feature prices on + // sync. Custom BASE prices (priority-3, kind: "autumn_product") are + // allowed since `customize.price` captures them safely. + const customFeaturePriceItems = currentPhase.item_diffs.filter( + (diff) => + diff.match.kind === "autumn_price" && + diff.match.matched_on.type === "stripe_product_id", + ); + if (customFeaturePriceItems.length > 0) { + return { + eligible: false, + reason: "custom_feature_price", + details: `Stripe items use custom prices for feature items: ${customFeaturePriceItems + .map((d) => d.stripe.id) + .join(", ")}`, + }; + } + + const absentBase = currentPhase.plans.find( + (plan) => plan.base.kind === "absent", + ); + if (absentBase) { + return { + eligible: false, + reason: "base_price_unresolvable", + details: `Plan ${absentBase.product.id} has no resolvable base price.`, + }; + } + + // Stripe item quantity > 1 on a non-add-on plan is ambiguous (the + // detection rollup tags it via the `base_plan_quantity_gt_one` warning). + // We surface a dedicated rejection reason so logs are explicit. + const bigQuantityPlan = currentPhase.plans.find((plan) => + plan.warnings.some((w) => w.type === "base_plan_quantity_gt_one"), + ); + if (bigQuantityPlan) { + return { + eligible: false, + reason: "base_quantity_gt_one", + details: `Plan ${bigQuantityPlan.product.id} is not an add-on but its Stripe item has quantity > 1.`, + }; + } + + const allowedSet = new Set(allowedWarnings); + const blockingWarnings = currentPhase.plans.flatMap((plan) => + plan.warnings + .filter((warning) => !allowedSet.has(warning.type)) + .map( + (warning) => ({ planId: plan.product.id, type: warning.type }), + ), + ); + if (blockingWarnings.length > 0) { + return { + eligible: false, + reason: "plan_warnings", + details: blockingWarnings + .map((w) => `${w.planId}: ${w.type}`) + .join("; "), + }; + } + + return { eligible: true }; +}; diff --git a/server/src/internal/billing/v2/actions/sync/compute/computeSyncFuturePhases.ts b/server/src/internal/billing/v2/actions/sync/compute/computeSyncFuturePhases.ts new file mode 100644 index 000000000..e02eef07d --- /dev/null +++ b/server/src/internal/billing/v2/actions/sync/compute/computeSyncFuturePhases.ts @@ -0,0 +1,81 @@ +import type { + Entitlement, + FullCusProduct, + Price, + SyncBillingContext, +} from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { initScheduledCustomerProduct } from "@/internal/billing/v2/utils/initFullCustomerProduct/initScheduledCustomerProduct"; + +export type ComputedSchedulePhase = { + startsAt: number; + endsAt: number | null; + customerProductIds: string[]; +}; + +export type FuturePhasesResult = { + insertCustomerProducts: FullCusProduct[]; + customPrices: Price[]; + customEntitlements: Entitlement[]; + scheduledPhases: ComputedSchedulePhase[]; +}; + +/** + * Build cusProducts (status=Scheduled) for every non-immediate phase plus + * the per-phase descriptors that `persistSyncPhases` writes after execute. + */ +export const computeSyncFuturePhases = ({ + ctx, + syncContext, +}: { + ctx: AutumnContext; + syncContext: SyncBillingContext; +}): FuturePhasesResult => { + const { + futurePhases, + fullCustomer, + currentEpochMs, + stripeSubscription, + stripeSchedule, + } = syncContext; + + const insertCustomerProducts: FullCusProduct[] = []; + const customPrices: Price[] = []; + const customEntitlements: Entitlement[] = []; + const scheduledPhases: ComputedSchedulePhase[] = []; + + for (const phaseContext of futurePhases) { + const phaseIds: string[] = []; + + for (const productContext of phaseContext.productContexts) { + const cusProduct = initScheduledCustomerProduct({ + ctx, + fullCustomer, + fullProduct: productContext.fullProduct, + featureQuantities: productContext.featureQuantities, + startsAt: phaseContext.startsAt, + endsAt: phaseContext.endsAt, + currentEpochMs, + subscriptionId: stripeSubscription?.id, + subscriptionScheduleId: stripeSchedule?.id, + }); + insertCustomerProducts.push(cusProduct); + phaseIds.push(cusProduct.id); + customPrices.push(...productContext.customPrices); + customEntitlements.push(...productContext.customEntitlements); + } + + scheduledPhases.push({ + startsAt: phaseContext.startsAt, + endsAt: phaseContext.endsAt, + customerProductIds: phaseIds, + }); + } + + return { + insertCustomerProducts, + customPrices, + customEntitlements, + scheduledPhases, + }; +}; diff --git a/server/src/internal/billing/v2/actions/sync/compute/computeSyncImmediatePhase.ts b/server/src/internal/billing/v2/actions/sync/compute/computeSyncImmediatePhase.ts new file mode 100644 index 000000000..964e4e700 --- /dev/null +++ b/server/src/internal/billing/v2/actions/sync/compute/computeSyncImmediatePhase.ts @@ -0,0 +1,98 @@ +import { + type AutumnBillingPlan, + CusProductStatus, + type Entitlement, + type FullCusProduct, + type Price, + type SyncBillingContext, +} from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { initImmediateSyncCustomerProduct } from "./initImmediateSyncCustomerProduct"; + +type CustomerProductUpdate = NonNullable< + AutumnBillingPlan["updateCustomerProducts"] +>[number]; + +export type ImmediatePhaseResult = { + insertCustomerProducts: FullCusProduct[]; + updateCustomerProducts: CustomerProductUpdate[]; + customPrices: Price[]; + customEntitlements: Entitlement[]; +}; + +const expireCustomerProduct = ({ + customerProduct, + currentEpochMs, +}: { + customerProduct: FullCusProduct; + currentEpochMs: number; +}): CustomerProductUpdate => ({ + customerProduct, + updates: { + status: CusProductStatus.Expired, + ended_at: currentEpochMs, + canceled: true, + canceled_at: currentEpochMs, + }, +}); + +/** + * Build the immediate-phase cusProducts to insert plus the existing + * cusProducts to expire (when `expire_previous` was set on the input plan). + * + * Returns an empty result when the sync has no immediate phase or no live + * Stripe subscription to derive lifecycle metadata from. + */ +export const computeSyncImmediatePhase = ({ + ctx, + syncContext, +}: { + ctx: AutumnContext; + syncContext: SyncBillingContext; +}): ImmediatePhaseResult => { + const { immediatePhase, fullCustomer, stripeSubscription, currentEpochMs } = + syncContext; + if (!immediatePhase || !stripeSubscription) { + return { + insertCustomerProducts: [], + updateCustomerProducts: [], + customPrices: [], + customEntitlements: [], + }; + } + + const insertCustomerProducts: FullCusProduct[] = []; + const updateCustomerProducts: CustomerProductUpdate[] = []; + const customPrices: Price[] = []; + const customEntitlements: Entitlement[] = []; + + for (const productContext of immediatePhase.productContexts) { + insertCustomerProducts.push( + initImmediateSyncCustomerProduct({ + ctx, + fullCustomer, + productContext, + stripeSubscription, + currentEpochMs, + }), + ); + customPrices.push(...productContext.customPrices); + customEntitlements.push(...productContext.customEntitlements); + + if (productContext.currentCustomerProduct) { + updateCustomerProducts.push( + expireCustomerProduct({ + customerProduct: productContext.currentCustomerProduct, + currentEpochMs, + }), + ); + } + } + + return { + insertCustomerProducts, + updateCustomerProducts, + customPrices, + customEntitlements, + }; +}; diff --git a/server/src/internal/billing/v2/actions/sync/compute/computeSyncPlan.ts b/server/src/internal/billing/v2/actions/sync/compute/computeSyncPlan.ts new file mode 100644 index 000000000..e0a3531f4 --- /dev/null +++ b/server/src/internal/billing/v2/actions/sync/compute/computeSyncPlan.ts @@ -0,0 +1,87 @@ +import type { AutumnBillingPlan, SyncBillingContext } from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { initSubscriptionFromStripe } from "@/internal/subscriptions/utils/initSubscriptionFromStripe"; +import { + type ComputedSchedulePhase, + computeSyncFuturePhases, +} from "./computeSyncFuturePhases"; +import { computeSyncImmediatePhase } from "./computeSyncImmediatePhase"; + +export type { ComputedSchedulePhase } from "./computeSyncFuturePhases"; + +export type ComputedSyncPlan = { + autumnBillingPlan: AutumnBillingPlan; + /** + * Phase descriptors for `persistSyncPhases` to write. Empty when the + * sync is single-phase (immediate-only or schedule-only with one phase) — + * in that case no Autumn schedule is created. + */ + phases: ComputedSchedulePhase[]; +}; + +const hasMultiplePhases = ({ + syncContext, +}: { + syncContext: SyncBillingContext; +}): boolean => + (syncContext.immediatePhase ? 1 : 0) + syncContext.futurePhases.length > 1; + +/** Compose the AutumnBillingPlan from the immediate + future phase computations. */ +export const computeSyncPlan = ({ + ctx, + syncContext, +}: { + ctx: AutumnContext; + syncContext: SyncBillingContext; +}): ComputedSyncPlan => { + const immediate = computeSyncImmediatePhase({ ctx, syncContext }); + const future = computeSyncFuturePhases({ ctx, syncContext }); + + const upsertSubscription = syncContext.stripeSubscription + ? initSubscriptionFromStripe({ + ctx, + stripeSubscription: syncContext.stripeSubscription, + }) + : undefined; + + const autumnBillingPlan: AutumnBillingPlan = { + customerId: + syncContext.fullCustomer.id ?? syncContext.fullCustomer.internal_id, + insertCustomerProducts: [ + ...immediate.insertCustomerProducts, + ...future.insertCustomerProducts, + ], + updateCustomerProducts: + immediate.updateCustomerProducts.length > 0 + ? immediate.updateCustomerProducts + : undefined, + customPrices: [...immediate.customPrices, ...future.customPrices], + customEntitlements: [ + ...immediate.customEntitlements, + ...future.customEntitlements, + ], + upsertSubscription, + }; + + // Single-phase sync (no schedule) → don't materialize any Autumn schedule. + if (!hasMultiplePhases({ syncContext })) { + return { autumnBillingPlan, phases: [] }; + } + + const immediateDescriptor: ComputedSchedulePhase | null = + syncContext.immediatePhase + ? { + startsAt: syncContext.immediatePhase.startsAt, + endsAt: syncContext.immediatePhase.endsAt, + customerProductIds: immediate.insertCustomerProducts.map( + (cp) => cp.id, + ), + } + : null; + + const phases = immediateDescriptor + ? [immediateDescriptor, ...future.scheduledPhases] + : future.scheduledPhases; + + return { autumnBillingPlan, phases }; +}; diff --git a/server/src/internal/billing/v2/actions/sync/compute/initImmediateSyncCustomerProduct.ts b/server/src/internal/billing/v2/actions/sync/compute/initImmediateSyncCustomerProduct.ts new file mode 100644 index 000000000..7e2424a24 --- /dev/null +++ b/server/src/internal/billing/v2/actions/sync/compute/initImmediateSyncCustomerProduct.ts @@ -0,0 +1,73 @@ +import { + BillingVersion, + type FullCusProduct, + type FullCustomer, + secondsToMs, + type SyncProductContext, +} from "@autumn/shared"; +import type Stripe from "stripe"; +import { stripeSubscriptionToAutumnStatus } from "@/external/stripe/subscriptions/utils/convertStripeSubscription"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { + getCancelFieldsFromStripe, + getTrialEndsAtFromStripe, +} from "@/internal/billing/v2/actions/sync/utils/initSyncFromStripe"; +import { initFullCustomerProduct } from "@/internal/billing/v2/utils/initFullCustomerProduct/initFullCustomerProduct"; + +/** + * Build the immediate-phase cusProduct row for one plan instance, mirroring + * the legacy `processSyncMapping` flow: + * - inherit trial/cancel timestamps from the Stripe subscription + * - anchor the reset cycle to the Stripe billing_cycle_anchor + * - link the Stripe subscription id + * - apply prepaid feature quantities + customize-derived custom prices/ents + */ +export const initImmediateSyncCustomerProduct = ({ + ctx, + fullCustomer, + productContext, + stripeSubscription, + currentEpochMs, +}: { + ctx: AutumnContext; + fullCustomer: FullCustomer; + productContext: SyncProductContext; + stripeSubscription: Stripe.Subscription; + currentEpochMs: number; +}): FullCusProduct => { + const { plan, fullProduct, featureQuantities } = productContext; + + const trialEndsAt = getTrialEndsAtFromStripe({ stripeSubscription }); + const { canceledAt, endedAt } = getCancelFieldsFromStripe({ + stripeSubscription, + }); + const resetCycleAnchorMs = secondsToMs(stripeSubscription.billing_cycle_anchor); + + return initFullCustomerProduct({ + ctx, + initContext: { + fullCustomer, + fullProduct, + featureQuantities, + resetCycleAnchor: resetCycleAnchorMs, + now: currentEpochMs, + freeTrial: null, + trialEndsAt, + billingVersion: BillingVersion.V2, + }, + initOptions: { + subscriptionId: stripeSubscription.id, + isCustom: Boolean(plan.customize), + canceledAt, + endedAt, + startsAt: stripeSubscription.start_date + ? secondsToMs(stripeSubscription.start_date) + : undefined, + keepSubscriptionIds: true, + status: stripeSubscriptionToAutumnStatus({ + stripeStatus: stripeSubscription.status, + }), + }, + }); +}; + diff --git a/server/src/internal/billing/v2/actions/sync/detect/detectSubscriptionMatch.ts b/server/src/internal/billing/v2/actions/sync/detect/detectSubscriptionMatch.ts new file mode 100644 index 000000000..64d1384fa --- /dev/null +++ b/server/src/internal/billing/v2/actions/sync/detect/detectSubscriptionMatch.ts @@ -0,0 +1,73 @@ +import type Stripe from "stripe"; +import { stripeSubscriptionToScheduleId } from "@/external/stripe/subscriptions/utils/convertStripeSubscription"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { normalizeSubscriptionPhases } from "@/internal/billing/v2/providers/stripe/utils/sync/stripeItemSnapshot/normalizeSubscriptionPhases"; +import { findAutumnMatchForStripeItem } from "@/internal/billing/v2/providers/stripe/utils/sync/stripeToAutumn/findAutumnMatchForStripeItem"; +import { ProductService } from "@/internal/products/ProductService"; +import { rollupMatchedPlans } from "./rollupMatchedPlans"; +import type { PhaseMatch, SubscriptionMatch } from "./types"; + +/** + * Detect how a Stripe subscription and/or schedule maps to Autumn plans. + * + * - subscription only → single open-ended phase from sub.items + * - schedule only → one phase per schedule.phases (e.g. future- + * dated schedule with no subscription yet) + * - subscription + schedule → schedule phases (subscription provides id) + * + * Pipeline: normalize → per-item match → rollup. + */ +export const detectSubscriptionMatch = async ({ + ctx, + subscription, + schedule, + nowSec, +}: { + ctx: AutumnContext; + subscription?: Stripe.Subscription; + schedule?: Stripe.SubscriptionSchedule; + nowSec?: number; +}): Promise => { + if (!subscription && !schedule) { + throw new Error( + "detectSubscriptionMatch requires a subscription or a schedule", + ); + } + + const phaseSnapshots = normalizeSubscriptionPhases({ + subscription, + schedule, + nowSec, + }); + + const fullProducts = await ProductService.listFull({ + db: ctx.db, + orgId: ctx.org.id, + env: ctx.env, + }); + + const phaseMatches: PhaseMatch[] = phaseSnapshots.map((snapshot) => { + const itemDiffs = snapshot.items.map((item) => + findAutumnMatchForStripeItem({ item, fullProducts }), + ); + const plans = rollupMatchedPlans({ itemDiffs }); + return { + start_date: snapshot.start_date, + end_date: snapshot.end_date, + is_current: snapshot.is_current, + item_diffs: itemDiffs, + plans, + }; + }); + + const stripeScheduleId = + schedule?.id ?? + stripeSubscriptionToScheduleId({ stripeSubscription: subscription }) ?? + null; + + return { + stripe_subscription_id: subscription?.id ?? null, + stripe_schedule_id: stripeScheduleId, + phaseMatches, + }; +}; diff --git a/server/src/internal/billing/v2/actions/sync/detect/rollupMatchedPlans.ts b/server/src/internal/billing/v2/actions/sync/detect/rollupMatchedPlans.ts new file mode 100644 index 000000000..37cb0d0cd --- /dev/null +++ b/server/src/internal/billing/v2/actions/sync/detect/rollupMatchedPlans.ts @@ -0,0 +1,257 @@ +import { + BillingInterval, + type CustomizePlanV1, + type FullProduct, + isFixedPrice, + stripeToAtmnAmount, +} from "@autumn/shared"; +import type { StripeItemSnapshot } from "@/internal/billing/v2/providers/stripe/utils/sync/stripeItemSnapshot/types"; +import type { + ItemDiff, + MatchedPlan, + PlanBase, + PlanExtra, + PlanFeature, + PlanWarning, +} from "./types"; + +type CustomBasePrice = NonNullable; + +const STRIPE_TO_AUTUMN_INTERVAL: Record = { + week: BillingInterval.Week, + month: BillingInterval.Month, + year: BillingInterval.Year, +}; + +const stripeItemToBasePrice = ({ + item, +}: { + item: StripeItemSnapshot; +}): CustomBasePrice | null => { + if (item.unit_amount === null) return null; + if (!item.recurring_interval) return null; + const interval = STRIPE_TO_AUTUMN_INTERVAL[item.recurring_interval]; + if (!interval) return null; + return { + amount: stripeToAtmnAmount({ + amount: item.unit_amount, + currency: item.currency ?? "usd", + }), + interval, + stripe_price_id: item.stripe_price_id, + }; +}; + +const productFromDiff = ({ diff }: { diff: ItemDiff }): FullProduct | null => { + if (diff.match.kind === "none") return null; + return diff.match.product; +}; + +const groupByProduct = ({ + itemDiffs, +}: { + itemDiffs: ItemDiff[]; +}): Map => { + const byInternalId = new Map< + string, + { product: FullProduct; diffs: ItemDiff[] } + >(); + for (const diff of itemDiffs) { + const product = productFromDiff({ diff }); + if (!product) continue; + const existing = byInternalId.get(product.internal_id) ?? { + product, + diffs: [], + }; + existing.diffs.push(diff); + byInternalId.set(product.internal_id, existing); + } + return byInternalId; +}; + +const partitionDiffs = ({ + diffs, + autumnBasePriceId, +}: { + diffs: ItemDiff[]; + autumnBasePriceId: string | null; +}) => { + const matchedBase: ItemDiff[] = []; + const customBase: ItemDiff[] = []; + const features: ItemDiff[] = []; + for (const diff of diffs) { + const m = diff.match; + if (m.kind === "autumn_price") { + if (autumnBasePriceId && m.price.id === autumnBasePriceId) { + matchedBase.push(diff); + } else { + features.push(diff); + } + } else if (m.kind === "autumn_product") { + customBase.push(diff); + } + } + return { matchedBase, customBase, features }; +}; + +type BaseDecision = { + base: PlanBase; + customize?: CustomizePlanV1; + consumedCustomBase: ItemDiff | null; + warnings: PlanWarning[]; +}; + +const decideBase = ({ + matchedBase, + customBase, + autumnBasePrice, +}: { + matchedBase: ItemDiff[]; + customBase: ItemDiff[]; + autumnBasePrice: { id: string } | null; +}): BaseDecision => { + const warnings: PlanWarning[] = []; + + if (matchedBase.length > 0) { + const [chosen] = matchedBase; + if (chosen.match.kind !== "autumn_price") { + throw new Error("matchedBase diff lost its autumn_price match"); + } + return { + base: { + kind: "matched", + stripe_item_id: chosen.stripe.id, + autumn_price_id: chosen.match.price.id, + }, + consumedCustomBase: null, + warnings, + }; + } + + if (customBase.length > 0) { + const [chosen] = customBase; + const basePrice = stripeItemToBasePrice({ item: chosen.stripe }); + if (!basePrice) { + return { + base: autumnBasePrice ? { kind: "dropped" } : { kind: "absent" }, + customize: autumnBasePrice ? { price: null } : undefined, + consumedCustomBase: null, + warnings: autumnBasePrice ? [{ type: "base_price_dropped" }] : [], + }; + } + return { + base: { kind: "custom", stripe_item_id: chosen.stripe.id }, + customize: { price: basePrice }, + consumedCustomBase: chosen, + warnings, + }; + } + + if (autumnBasePrice) { + return { + base: { kind: "dropped" }, + customize: { price: null }, + consumedCustomBase: null, + warnings: [{ type: "base_price_dropped" }], + }; + } + + return { base: { kind: "absent" }, consumedCustomBase: null, warnings }; +}; + +const lookupBaseStripeItem = ({ + base, + diffs, +}: { + base: PlanBase; + diffs: ItemDiff[]; +}): StripeItemSnapshot | null => { + if (base.kind === "dropped" || base.kind === "absent") return null; + const found = diffs.find((d) => d.stripe.id === base.stripe_item_id); + return found?.stripe ?? null; +}; + +const isAddOn = ({ product }: { product: FullProduct }): boolean => + product.is_add_on === true; + +const rollupOnePlan = ({ + product, + diffs, +}: { + product: FullProduct; + diffs: ItemDiff[]; +}): MatchedPlan => { + const autumnBasePrice = product.prices.find(isFixedPrice) ?? null; + + const { matchedBase, customBase, features } = partitionDiffs({ + diffs, + autumnBasePriceId: autumnBasePrice?.id ?? null, + }); + + const decision = decideBase({ + matchedBase, + customBase, + autumnBasePrice, + }); + + const extraDiffs = customBase.filter( + (d) => d !== decision.consumedCustomBase, + ); + + const planFeatures: PlanFeature[] = features.map((d) => { + if (d.match.kind !== "autumn_price") { + throw new Error("feature diff lost its autumn_price match"); + } + return { + stripe_item_id: d.stripe.id, + autumn_price_id: d.match.price.id, + }; + }); + + const planExtras: PlanExtra[] = extraDiffs.map((d) => ({ + stripe_item_id: d.stripe.id, + })); + + const warnings = [...decision.warnings]; + if (planExtras.length > 0) { + warnings.push({ + type: "extra_items_under_plan", + stripe_item_ids: planExtras.map((e) => e.stripe_item_id), + }); + } + + const baseStripeItem = lookupBaseStripeItem({ base: decision.base, diffs }); + const quantity = + baseStripeItem?.quantity ?? features[0]?.stripe.quantity ?? 1; + + if (baseStripeItem && quantity > 1 && !isAddOn({ product })) { + warnings.push({ type: "base_plan_quantity_gt_one", quantity }); + } + + return { + product, + quantity, + base: decision.base, + features: planFeatures, + extras: planExtras, + customize: decision.customize, + warnings, + }; +}; + +/** + * Roll up per-item diffs into per-plan matches. Reads each diff's embedded + * FullProduct directly — no separate product map required. + */ +export const rollupMatchedPlans = ({ + itemDiffs, +}: { + itemDiffs: ItemDiff[]; +}): MatchedPlan[] => { + const byProduct = groupByProduct({ itemDiffs }); + const plans: MatchedPlan[] = []; + for (const { product, diffs } of byProduct.values()) { + plans.push(rollupOnePlan({ product, diffs })); + } + return plans; +}; diff --git a/server/src/internal/billing/v2/actions/sync/detect/types.ts b/server/src/internal/billing/v2/actions/sync/detect/types.ts new file mode 100644 index 000000000..7026d54e8 --- /dev/null +++ b/server/src/internal/billing/v2/actions/sync/detect/types.ts @@ -0,0 +1,88 @@ +import type { CustomizePlanV1, FullProduct, Price } from "@autumn/shared"; +import type { + PriceMatchCondition, + ProductMatchCondition, +} from "@/internal/billing/v2/providers/stripe/utils/sync/matchUtils/matchConditions"; +import type { StripeItemSnapshot } from "@/internal/billing/v2/providers/stripe/utils/sync/stripeItemSnapshot/types"; + +/* ------------------------------------------------------------------------- + * Item-level: per Stripe item, what did it match against in Autumn? + * Pure stripe-side fact. No sibling-aware decisions. + * + * Each variant mirrors which matchUtils finder produced the match, with + * the matched Autumn resource(s) embedded directly so consumers never need + * a side lookup map. + * ------------------------------------------------------------------------- */ + +export type ItemMatch = + | { + kind: "autumn_price"; + matched_on: PriceMatchCondition; + price: Price; + product: FullProduct; + } + | { + kind: "autumn_product"; + matched_on: ProductMatchCondition; + product: FullProduct; + } + | { kind: "none" }; + +export type ItemDiff = { + stripe: StripeItemSnapshot; + match: ItemMatch; +}; + +/* ------------------------------------------------------------------------- + * Plan-level: per Autumn product, the rolled-up structural verdict + * ------------------------------------------------------------------------- */ + +export type PlanBase = + | { kind: "matched"; stripe_item_id: string; autumn_price_id: string } + | { kind: "custom"; stripe_item_id: string } + | { kind: "adopted"; stripe_item_id: string } + | { kind: "dropped" } + | { kind: "absent" }; + +export type PlanFeature = { + stripe_item_id: string; + autumn_price_id: string; +}; + +export type PlanExtra = { + stripe_item_id: string; +}; + +export type PlanWarning = + | { type: "base_price_dropped" } + | { type: "base_price_adopted"; stripe_item_id: string } + | { type: "extra_items_under_plan"; stripe_item_ids: string[] } + | { type: "base_plan_quantity_gt_one"; quantity: number }; + +export type MatchedPlan = { + product: FullProduct; + quantity: number; + base: PlanBase; + features: PlanFeature[]; + extras: PlanExtra[]; + customize?: CustomizePlanV1; + warnings: PlanWarning[]; +}; + +/* ------------------------------------------------------------------------- + * Phase / subscription level + * ------------------------------------------------------------------------- */ + +export type PhaseMatch = { + start_date: number; + end_date: number | null; + is_current: boolean; + item_diffs: ItemDiff[]; + plans: MatchedPlan[]; +}; + +export type SubscriptionMatch = { + stripe_subscription_id: string | null; + stripe_schedule_id: string | null; + phaseMatches: PhaseMatch[]; +}; diff --git a/server/src/internal/billing/v2/actions/sync/errors/handleSyncErrors.ts b/server/src/internal/billing/v2/actions/sync/errors/handleSyncErrors.ts new file mode 100644 index 000000000..10ccc06d1 --- /dev/null +++ b/server/src/internal/billing/v2/actions/sync/errors/handleSyncErrors.ts @@ -0,0 +1,17 @@ +import type { SyncBillingContext } from "@autumn/shared"; + +/** + * Validate sync inputs against the detection result. Throws RecaseError on + * any unrecoverable problem; otherwise no-op. + * + * STUB — checks to add later: + * - Mapping plan_ids exist in the catalog + * - Each mapping points at a stripe sub/schedule we actually fetched + * - Detection PlanWarnings are all in `acknowledgedWarnings` + * - Customer has a Stripe id + */ +export const handleSyncErrors = (_args: { + syncContext: SyncBillingContext; +}): void => { + // no-op +}; diff --git a/server/src/internal/billing/v2/actions/sync/logs/logSyncContext.ts b/server/src/internal/billing/v2/actions/sync/logs/logSyncContext.ts new file mode 100644 index 000000000..3846426bf --- /dev/null +++ b/server/src/internal/billing/v2/actions/sync/logs/logSyncContext.ts @@ -0,0 +1,84 @@ +import { formatMs, type SyncBillingContext } from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { addToExtraLogs } from "@/utils/logging/addToExtraLogs"; + +const formatPhase = ({ + startsAt, + productCount, +}: { + startsAt: number; + productCount: number; +}) => `${formatMs(startsAt)} (${productCount} plan${productCount === 1 ? "" : "s"})`; + +export const logSyncContext = ({ + ctx, + syncContext, +}: { + ctx: AutumnContext; + syncContext: SyncBillingContext; +}) => { + const { + customer_id, + stripeSubscription, + stripeSchedule, + immediatePhase, + futurePhases, + currentEpochMs, + acknowledgedWarnings, + } = syncContext; + + addToExtraLogs({ + ctx, + extras: { + syncContext: { + customer: customer_id, + stripe: `${stripeSubscription?.id ?? "no sub"} | ${stripeSchedule?.id ?? "no schedule"}`, + currentEpochMs: formatMs(currentEpochMs), + immediatePhase: immediatePhase + ? formatPhase({ + startsAt: immediatePhase.startsAt, + productCount: immediatePhase.productContexts.length, + }) + : "none", + futurePhases: + futurePhases.length > 0 + ? futurePhases + .map((p) => + formatPhase({ + startsAt: p.startsAt, + productCount: p.productContexts.length, + }), + ) + .join(" -> ") + : "none", + acknowledgedWarnings: + acknowledgedWarnings.length > 0 + ? acknowledgedWarnings.join(", ") + : "none", + productContexts: [ + ...(immediatePhase ? immediatePhase.productContexts : []), + ...futurePhases.flatMap((p) => p.productContexts), + ] + .map((pc) => { + const customizeFlags = [ + pc.plan.customize?.price !== undefined && "price", + pc.plan.customize?.items && "items", + pc.plan.customize?.free_trial !== undefined && "trial", + ].filter(Boolean); + const customize = + customizeFlags.length > 0 + ? ` customize=[${customizeFlags.join(",")}]` + : ""; + const expire = pc.currentCustomerProduct + ? ` expire=${pc.currentCustomerProduct.id}` + : ""; + const entity = pc.plan.internal_entity_id + ? ` entity=${pc.plan.internal_entity_id}` + : ""; + return `${pc.fullProduct.id}${customize}${expire}${entity}`; + }) + .join(" | "), + }, + }, + }); +}; diff --git a/server/src/internal/billing/v2/actions/sync/logs/logSyncPlan.ts b/server/src/internal/billing/v2/actions/sync/logs/logSyncPlan.ts new file mode 100644 index 000000000..8917e1fa4 --- /dev/null +++ b/server/src/internal/billing/v2/actions/sync/logs/logSyncPlan.ts @@ -0,0 +1,62 @@ +import { type AutumnBillingPlan, formatMs } from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { addToExtraLogs } from "@/utils/logging/addToExtraLogs"; +import type { ComputedSchedulePhase } from "../compute/computeSyncFuturePhases"; + +const formatCustomerProduct = (cp: { + product_id: string; + product: { name: string }; +}) => `${cp.product.name} (${cp.product_id})`; + +export const logSyncPlan = ({ + ctx, + autumnBillingPlan, + phases, +}: { + ctx: AutumnContext; + autumnBillingPlan: AutumnBillingPlan; + phases: ComputedSchedulePhase[]; +}) => { + addToExtraLogs({ + ctx, + extras: { + syncPlan: { + insertCustomerProducts: + autumnBillingPlan.insertCustomerProducts + .map(formatCustomerProduct) + .join(", ") || "none", + + updateCustomerProducts: + (autumnBillingPlan.updateCustomerProducts ?? []) + .map( + (u) => + `${formatCustomerProduct(u.customerProduct)} -> ${u.updates.status ?? "n/a"}`, + ) + .join(", ") || "none", + + customPrices: + (autumnBillingPlan.customPrices ?? []).length > 0 + ? `${(autumnBillingPlan.customPrices ?? []).length} custom price(s)` + : "none", + + customEntitlements: + (autumnBillingPlan.customEntitlements ?? []).length > 0 + ? `${(autumnBillingPlan.customEntitlements ?? []).length} custom ent(s)` + : "none", + + upsertSubscription: + autumnBillingPlan.upsertSubscription?.stripe_id ?? "none", + + schedulePhases: + phases.length > 0 + ? phases + .map( + (p) => + `${formatMs(p.startsAt)} (${p.customerProductIds.length} cusProduct${p.customerProductIds.length === 1 ? "" : "s"})`, + ) + .join(" -> ") + : "none", + }, + }, + }); +}; diff --git a/server/src/internal/billing/v2/actions/sync/setup/setupSyncContext.ts b/server/src/internal/billing/v2/actions/sync/setup/setupSyncContext.ts new file mode 100644 index 000000000..c21106a4c --- /dev/null +++ b/server/src/internal/billing/v2/actions/sync/setup/setupSyncContext.ts @@ -0,0 +1,199 @@ +import { + ErrCode, + type FullCusProduct, + type FullCustomer, + RecaseError, + type SyncBillingContext, + type SyncParamsV1, + type SyncPhaseContext, + type SyncPlanInstance, + type SyncProductContext, +} from "@autumn/shared"; +import type Stripe from "stripe"; +import { createStripeCli } from "@/external/connect/createStripeCli"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { setupAttachProductContext } from "@/internal/billing/v2/actions/attach/setup/setupAttachProductContext"; +import { setupAttachTransitionContext } from "@/internal/billing/v2/actions/attach/setup/setupAttachTransitionContext"; +import { setupFeatureQuantitiesContext } from "@/internal/billing/v2/setup/setupFeatureQuantitiesContext"; +import { setupFullCustomerContext } from "@/internal/billing/v2/setup/setupFullCustomerContext"; + +const fetchStripeSubscription = async ({ + ctx, + stripeSubscriptionId, +}: { + ctx: AutumnContext; + stripeSubscriptionId?: string; +}): Promise => { + if (!stripeSubscriptionId) return null; + const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env }); + return stripeCli.subscriptions.retrieve(stripeSubscriptionId); +}; + +const fetchStripeSchedule = async ({ + ctx, + stripeScheduleId, +}: { + ctx: AutumnContext; + stripeScheduleId?: string; +}): Promise => { + if (!stripeScheduleId) return null; + const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env }); + return stripeCli.subscriptionSchedules.retrieve(stripeScheduleId); +}; + +const buildProductContext = async ({ + ctx, + fullCustomer, + plan, + isImmediate, +}: { + ctx: AutumnContext; + fullCustomer: FullCustomer; + plan: SyncPlanInstance; + isImmediate: boolean; +}): Promise => { + const { + fullProduct, + customPrices = [], + customEnts: customEntitlements = [], + } = await setupAttachProductContext({ ctx, params: plan }); + + const featureQuantities = setupFeatureQuantitiesContext({ + ctx, + featureQuantitiesParams: { feature_quantities: plan.feature_quantities }, + fullProduct, + initializeUndefinedQuantities: true, + }); + + let currentCustomerProduct: FullCusProduct | undefined; + if (isImmediate && plan.expire_previous) { + const transition = setupAttachTransitionContext({ + fullCustomer, + attachProduct: fullProduct, + }); + currentCustomerProduct = transition.currentCustomerProduct; + } + + return { + plan, + fullProduct, + customPrices, + customEntitlements, + featureQuantities, + currentCustomerProduct, + }; +}; + +const resolvePhaseStart = ({ + startsAt, + currentEpochMs, +}: { + startsAt: number | "now"; + currentEpochMs: number; +}): number => (startsAt === "now" ? currentEpochMs : startsAt); + +/** + * Setup the sync billing context. Mirrors createSchedule's setup but with + * Stripe subscription/schedule as the input rather than payment-method + * driven flow. + */ +export const setupSyncContext = async ({ + ctx, + params, +}: { + ctx: AutumnContext; + params: SyncParamsV1; +}): Promise => { + if (!params.stripe_subscription_id && !params.stripe_schedule_id) { + throw new RecaseError({ + message: + "sync requires either stripe_subscription_id or stripe_schedule_id", + code: ErrCode.InvalidRequest, + statusCode: 400, + }); + } + + const fullCustomer = await setupFullCustomerContext({ + ctx, + params: { customer_id: params.customer_id }, + }); + + const [stripeSubscription, stripeSchedule] = await Promise.all([ + fetchStripeSubscription({ + ctx, + stripeSubscriptionId: params.stripe_subscription_id, + }), + fetchStripeSchedule({ + ctx, + stripeScheduleId: params.stripe_schedule_id, + }), + ]); + + const currentEpochMs = Date.now(); + const inputPhases = params.phases ?? []; + + const phaseContexts: SyncPhaseContext[] = await Promise.all( + inputPhases.map(async (phase, index) => { + const startsAt = resolvePhaseStart({ + startsAt: phase.starts_at, + currentEpochMs, + }); + const nextPhase = inputPhases[index + 1]; + const endsAt = nextPhase + ? resolvePhaseStart({ + startsAt: nextPhase.starts_at, + currentEpochMs, + }) + : null; + + const isImmediatePhase = phase.starts_at === "now"; + + const productContextsPerPlan = await Promise.all( + phase.plans.map((plan) => + buildProductContext({ + ctx, + fullCustomer, + plan, + isImmediate: isImmediatePhase, + }), + ), + ); + + // Expand add-on plans with quantity > 1 into N independent + // product contexts so the executor inserts one cusProduct per + // add-on instance. Non-add-on plans always emit a single context + // regardless of quantity. + const productContexts = productContextsPerPlan.flatMap( + (productContext) => { + const requested = productContext.plan.quantity ?? 1; + const shouldExpand = + productContext.fullProduct.is_add_on === true && requested > 1; + return shouldExpand + ? Array.from({ length: requested }, () => productContext) + : [productContext]; + }, + ); + + return { startsAt, endsAt, productContexts }; + }), + ); + + const firstPhaseIsImmediate = inputPhases[0]?.starts_at === "now"; + const immediatePhase = firstPhaseIsImmediate + ? (phaseContexts[0] ?? null) + : null; + const futurePhases = firstPhaseIsImmediate + ? phaseContexts.slice(1) + : phaseContexts; + + return { + customer_id: params.customer_id, + fullCustomer, + stripeSubscription, + stripeSchedule, + immediatePhase, + futurePhases, + currentEpochMs, + acknowledgedWarnings: params.acknowledge_warnings ?? [], + }; +}; diff --git a/server/src/internal/billing/v2/actions/sync/subscriptionToSyncParams.ts b/server/src/internal/billing/v2/actions/sync/subscriptionToSyncParams.ts new file mode 100644 index 000000000..3d6e2d3d5 --- /dev/null +++ b/server/src/internal/billing/v2/actions/sync/subscriptionToSyncParams.ts @@ -0,0 +1,117 @@ +import { + secondsToMs, + type SyncParamsV1, + type SyncPhase, + type SyncPlanInstance, +} from "@autumn/shared"; +import type Stripe from "stripe"; +import { createStripeCli } from "@/external/connect/createStripeCli"; +import { getStripeActiveSubscriptionSchedule } from "@/external/stripe/subscriptionSchedules"; +import { stripeSubscriptionToScheduleId } from "@/external/stripe/subscriptions/utils/convertStripeSubscription"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { buildFeatureQuantities } from "./buildSyncParams/buildFeatureQuantities"; +import { detectSubscriptionMatch } from "./detect/detectSubscriptionMatch"; +import type { + ItemDiff, + MatchedPlan, + PhaseMatch, + SubscriptionMatch, +} from "./detect/types"; + +const matchedPlanToSyncPlan = ({ + matchedPlan, + itemDiffs, +}: { + matchedPlan: MatchedPlan; + itemDiffs: ItemDiff[]; +}): SyncPlanInstance => { + const featureQuantities = buildFeatureQuantities({ matchedPlan, itemDiffs }); + return { + plan_id: matchedPlan.product.id, + quantity: matchedPlan.quantity, + customize: matchedPlan.customize, + expire_previous: true, + feature_quantities: + featureQuantities.length > 0 ? featureQuantities : undefined, + }; +}; + +const phaseMatchToSyncPhase = ({ + phaseMatch, +}: { + phaseMatch: PhaseMatch; +}): SyncPhase => ({ + // PhaseMatch.start_date is Stripe-native (seconds). SyncPhase.starts_at + // is ms epoch (or the "now" sentinel for the immediate phase). + starts_at: phaseMatch.is_current + ? "now" + : secondsToMs(phaseMatch.start_date), + plans: phaseMatch.plans.map((matchedPlan) => + matchedPlanToSyncPlan({ + matchedPlan, + itemDiffs: phaseMatch.item_diffs, + }), + ), +}); + +/** + * Run subscription detection and shape the result into a `SyncParamsV1` + * draft. Returns BOTH the raw `SubscriptionMatch` (for eligibility checks + * and proposal display extras) and the derived params. + * + * Single canonical path used by: + * - `syncProposalsV2` — surfaces `params` to the dashboard for editing + * - `autoSyncFromSubscription` — feeds `params` into `syncV2` when + * `canAutoSync` says yes. + */ +export const subscriptionToSyncParams = async ({ + ctx, + customerId, + subscription, + schedule, +}: { + ctx: AutumnContext; + customerId: string; + subscription?: Stripe.Subscription; + /** Optional pre-fetched schedule. When omitted and `subscription` references + * a schedule, it's fetched (with `phases.items.price` expanded — required + * by `normalizePhaseItem` to resolve `stripe_product_id`). */ + schedule?: Stripe.SubscriptionSchedule; +}): Promise<{ + match: SubscriptionMatch; + params: SyncParamsV1; + schedule: Stripe.SubscriptionSchedule | null; +}> => { + let resolvedSchedule = schedule; + if (!resolvedSchedule) { + const scheduleId = stripeSubscriptionToScheduleId({ + stripeSubscription: subscription, + }); + if (scheduleId) { + resolvedSchedule = await getStripeActiveSubscriptionSchedule({ + stripeClient: createStripeCli({ org: ctx.org, env: ctx.env }), + subscriptionScheduleId: scheduleId, + expand: ["phases.items.price"], + }); + } + } + + const match = await detectSubscriptionMatch({ + ctx, + subscription, + schedule: resolvedSchedule, + }); + + const phases: SyncPhase[] = match.phaseMatches + .filter((phase) => phase.plans.length > 0) + .map((phaseMatch) => phaseMatchToSyncPhase({ phaseMatch })); + + const params: SyncParamsV1 = { + customer_id: customerId, + stripe_subscription_id: subscription?.id, + stripe_schedule_id: match.stripe_schedule_id ?? undefined, + phases, + }; + + return { match, params, schedule: resolvedSchedule ?? null }; +}; diff --git a/server/src/internal/billing/v2/actions/sync/syncMatchingDraft.md b/server/src/internal/billing/v2/actions/sync/syncMatchingDraft.md deleted file mode 100644 index 558b2faee..000000000 --- a/server/src/internal/billing/v2/actions/sync/syncMatchingDraft.md +++ /dev/null @@ -1,188 +0,0 @@ -# Stripe Sync Matching Draft - -## Status -The broader `customer.subscription.created` auto-sync feature is paused. This draft preserves the current research so we can resume later, while the immediate focus shifts to consolidating Stripe -> Autumn matching utilities. - -## Current Direction -We should build the new canonical utilities first and defer migration of existing callers until a later, safer phase. The first implementation pass should therefore be additive only: -- create the new utility home under [server/src/internal/billing/v2/providers/stripe/utils/sync](server/src/internal/billing/v2/providers/stripe/utils/sync) -- define canonical matcher types, priority semantics, and normalized Stripe input shapes -- add Stripe-keyed lookup builders that support the full identifier set -- leave existing sync, invoice, checkout, and legacy helpers untouched for now - -## Paused Auto-Sync Draft -- Event in scope: `customer.subscription.created` -- Intended skip guard: subscription metadata only, using an Autumn-owned marker such as `autumn_event` -- Same-group behavior: if an external Stripe subscription matches a product in a group with an existing active Autumn product, expire the old product and insert the new one -- Future implementation should follow the newer webhook handler pattern used by [server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/handleStripeSubscriptionUpdated.ts](server/src/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/handleStripeSubscriptionUpdated.ts) instead of the current stub at [server/src/external/stripe/webhookHandlers/handleSubCreated.ts](server/src/external/stripe/webhookHandlers/handleSubCreated.ts) - -## Why This Needs Cleanup First -Stripe -> Autumn matching is currently split across sync actions, invoice-line persistence, legacy subscription helpers, checkout helpers, log helpers, and service lookups. The same Stripe identifiers are interpreted differently depending on the path: -- `stripe_price_id` -- `stripe_empty_price_id` -- `stripe_prepaid_price_v2_id` -- `stripe_product_id` -- `product.processor.id` -- metadata like `autumn_line_item_id` and `autumn_customer_price_id` - -That makes the auto-sync feature too risky to build first. - -## Main Matching Surfaces - -### 1. Sync Proposal Matching -- [server/src/internal/billing/v2/actions/sync/utils/matchSubscriptionItemToAutumn.ts](server/src/internal/billing/v2/actions/sync/utils/matchSubscriptionItemToAutumn.ts) -- [server/src/internal/billing/v2/actions/sync/utils/matchStripeSubscriptionsToProducts.ts](server/src/internal/billing/v2/actions/sync/utils/matchStripeSubscriptionsToProducts.ts) -- [server/src/internal/products/prices/PriceService.ts](server/src/internal/products/prices/PriceService.ts) -- [server/src/internal/products/ProductService.ts](server/src/internal/products/ProductService.ts) - -Current behavior: -- sync proposals batch-load Autumn prices/products from Stripe ids -- matching priority is effectively `stripe_price_id` -> price-level `stripe_product_id` -> product-level `processor.id` -- the sync path currently depends on lookup maps more than canonical matching helpers - -Current gap: -- `PriceService.getByStripeId` and `PriceService.getByStripeIds` support `stripe_price_id` and `stripe_empty_price_id`, but not `stripe_prepaid_price_v2_id` - -### 2. Invoice Line Matching -- [shared/utils/billingUtils/invoicingUtils/lineItemUtils/billingLineItemMatchesStripeLineItem.ts](shared/utils/billingUtils/invoicingUtils/lineItemUtils/billingLineItemMatchesStripeLineItem.ts) -- [shared/utils/billingUtils/invoicingUtils/lineItemUtils/filterBillingLineItemsByStripeLineItem.ts](shared/utils/billingUtils/invoicingUtils/lineItemUtils/filterBillingLineItemsByStripeLineItem.ts) -- [shared/utils/billingUtils/invoicingUtils/lineItemUtils/findBillingLineItemByStripeLineItem.ts](shared/utils/billingUtils/invoicingUtils/lineItemUtils/findBillingLineItemByStripeLineItem.ts) -- [server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/convertToDbLineItem/stripeLineItemGroupToDbLineItems.ts](server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/convertToDbLineItem/stripeLineItemGroupToDbLineItems.ts) -- [server/src/internal/billing/v2/workflows/storeInvoiceLineItems/fetchSubscriptionItemsMetadata.ts](server/src/internal/billing/v2/workflows/storeInvoiceLineItems/fetchSubscriptionItemsMetadata.ts) - -Current behavior: -- invoice matching is metadata-first -- then it matches by Stripe price id -- then it matches by product processor id or `price.config.stripe_product_id` - -Current gaps: -- invoice matching supports `stripe_prepaid_price_v2_id` -- invoice matching does not consistently include `stripe_empty_price_id` -- `findBillingLineItemByStripeLineItem` does not accept subscription item metadata, while `filterBillingLineItemsByStripeLineItem` does - -### 3. Legacy Subscription and Schedule Matching -- [server/src/external/stripe/stripeSubUtils/stripeSubItemUtils.ts](server/src/external/stripe/stripeSubUtils/stripeSubItemUtils.ts) -- [server/src/internal/customers/attach/mergeUtils/paramsToSubItems.ts](server/src/internal/customers/attach/mergeUtils/paramsToSubItems.ts) -- [server/src/internal/customers/attach/mergeUtils/paramsToScheduleItems.ts](server/src/internal/customers/attach/mergeUtils/paramsToScheduleItems.ts) -- [server/src/external/stripe/subscriptions/subscriptionItems/utils/findSubscriptionItemByAutumnPrice.ts](server/src/external/stripe/subscriptions/subscriptionItems/utils/findSubscriptionItemByAutumnPrice.ts) -- [server/src/external/stripe/checkoutSessions/utils/findCheckoutLineItem.ts](server/src/external/stripe/checkoutSessions/utils/findCheckoutLineItem.ts) - -Current behavior: -- this area contains several older or narrower matchers -- some include `stripe_empty_price_id` -- some include `stripe_prepaid_price_v2_id` -- some only compare `stripe_price_id` -- some are marked deprecated but are still used - -### 4. Logging and Lookup Helpers -- [server/src/internal/billing/v2/utils/billingContextPriceLookup.ts](server/src/internal/billing/v2/utils/billingContextPriceLookup.ts) -- [server/src/external/stripe/subscriptionSchedules/utils/logStripeSchedulePhaseUtils.ts](server/src/external/stripe/subscriptionSchedules/utils/logStripeSchedulePhaseUtils.ts) - -Current behavior: -- these helpers are lightweight and mostly in-memory -- they encode still more matching logic for log formatting and phase debugging - -## Duplications and Inconsistencies -- Sync proposal matching and invoice matching use different priority ladders. -- Runtime matchers explicitly mention `stripe_prepaid_price_v2_id`, but service-backed sync lookup does not. -- Checkout and subscription-item helpers include `stripe_empty_price_id` more consistently than invoice helpers. -- Product-level fallback sometimes means `product.processor.id`, sometimes `price.config.stripe_product_id`, and sometimes either. -- Metadata-first matching is available for invoice lines and checkout lines, but not in the sync matcher. -- Deprecated legacy helpers in [server/src/external/stripe/stripeSubUtils/stripeSubItemUtils.ts](server/src/external/stripe/stripeSubUtils/stripeSubItemUtils.ts) still power live flows. - -## Proposed Destination -Move Stripe -> Autumn sync and matching utilities into: - -- [server/src/internal/billing/v2/providers/stripe/utils/sync](server/src/internal/billing/v2/providers/stripe/utils/sync) - -This should become the canonical home for inbound Stripe correlation logic. - -## Proposed Structure - -### `identity/` -Pure matching predicates with no DB calls. - -Suggested responsibilities: -- compare a Stripe price id against an Autumn price -- compare a Stripe product id against an Autumn price/product -- compare a Stripe subscription item against an Autumn price/product candidate -- compare a Stripe invoice line against an Autumn line item candidate -- expose one explicit match priority model instead of several incompatible enums - -Likely future residents: -- extracted logic from `matchSubscriptionItemToAutumn` -- extracted logic from `billingLineItemMatchesStripeLineItem` -- extracted logic from `findSubscriptionItemByAutumnPrice` -- extracted logic from `findCheckoutLineItemByAutumnPrice` -- extracted logic from `autumnStripePricesMatch` - -### `indexes/` -Server-side loaders and Stripe-keyed indexes that prepare lookup maps. - -Suggested responsibilities: -- build `priceByStripePriceId` -- build `priceByStripeProductId` -- build `productByStripeProductId` -- centralize support for `stripe_prepaid_price_v2_id` -- keep org and env scoping explicit where required - -Likely future residents: -- sync-oriented indexing now split across `PriceService`, `ProductService`, and `matchStripeSubscriptionsToProducts` - -### `adapters/` -Convert Stripe objects into normalized sync inputs before matching. - -Suggested responsibilities: -- normalize Stripe subscription items -- normalize Stripe invoice lines plus optional subscription item metadata -- normalize checkout line items -- normalize schedule phase items - -This keeps matcher code independent from raw Stripe API shape differences. - -### `orchestrators/` -Higher-level workflows that use normalized inputs plus indexes. - -Suggested responsibilities: -- build sync proposals from Stripe subscriptions -- match grouped invoice lines to Autumn billing line items -- find Stripe line items or subscription items for a given Autumn object in write paths - -Likely future residents: -- the current proposal builder -- invoice line conversion helpers -- selected operational helpers now scattered under `external/stripe` - -## Migration Principles -- Keep `providers/stripe/utils/sync` as the source of truth for Stripe -> Autumn matching. -- Let shared code own only the pieces that are truly server-agnostic and safe to reuse without DB access. -- Do not move orchestration-heavy code into shared if it depends on Stripe SDK types plus server services. -- Migrate callers gradually by first extracting pure matchers, then updating existing sites to delegate to them. -- Remove or deprecate legacy helpers only after all live callers are off them. - -## Recommended First Pass -1. Define one canonical identifier vocabulary and one matching priority model. -2. Build a new sync lookup layer that supports `stripe_prepaid_price_v2_id` in addition to the current ids. -3. Extract pure predicates from the current sync matcher and invoice matcher into the new `identity` area. -4. Add normalized adapters for subscription items, invoice lines, checkout line items, and schedule items. -5. Document how current helpers will migrate later, but do not change live callers yet. -6. Revisit migration only after the new utility surface is stable and reviewed. - -## Files To Revisit During Implementation -- [server/src/internal/billing/v2/actions/sync/utils/matchSubscriptionItemToAutumn.ts](server/src/internal/billing/v2/actions/sync/utils/matchSubscriptionItemToAutumn.ts) -- [server/src/internal/billing/v2/actions/sync/utils/matchStripeSubscriptionsToProducts.ts](server/src/internal/billing/v2/actions/sync/utils/matchStripeSubscriptionsToProducts.ts) -- [server/src/internal/products/prices/PriceService.ts](server/src/internal/products/prices/PriceService.ts) -- [shared/utils/billingUtils/invoicingUtils/lineItemUtils/billingLineItemMatchesStripeLineItem.ts](shared/utils/billingUtils/invoicingUtils/lineItemUtils/billingLineItemMatchesStripeLineItem.ts) -- [server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/convertToDbLineItem/stripeLineItemGroupToDbLineItems.ts](server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/convertToDbLineItem/stripeLineItemGroupToDbLineItems.ts) -- [server/src/external/stripe/subscriptions/subscriptionItems/utils/findSubscriptionItemByAutumnPrice.ts](server/src/external/stripe/subscriptions/subscriptionItems/utils/findSubscriptionItemByAutumnPrice.ts) -- [server/src/external/stripe/checkoutSessions/utils/findCheckoutLineItem.ts](server/src/external/stripe/checkoutSessions/utils/findCheckoutLineItem.ts) -- [server/src/external/stripe/stripeSubUtils/stripeSubItemUtils.ts](server/src/external/stripe/stripeSubUtils/stripeSubItemUtils.ts) -- [server/src/internal/billing/v2/utils/billingContextPriceLookup.ts](server/src/internal/billing/v2/utils/billingContextPriceLookup.ts) - -## Dedicated Utility Organization Plan -The next implementation plan should focus only on: -- creating the new `providers/stripe/utils/sync` structure -- defining canonical matcher APIs -- building additive utilities first without caller rewiring -- deciding what stays in `shared` versus what lives server-side -- documenting which legacy helpers become compatibility wrappers versus which are deleted later diff --git a/server/src/internal/billing/v2/actions/sync/syncProposalsV2.ts b/server/src/internal/billing/v2/actions/sync/syncProposalsV2.ts new file mode 100644 index 000000000..820088ea1 --- /dev/null +++ b/server/src/internal/billing/v2/actions/sync/syncProposalsV2.ts @@ -0,0 +1,115 @@ +import { + ErrCode, + type FullCusProduct, + RecaseError, + type SyncProposalsV2Params, + type SyncProposalsV2Response, + type SyncProposalV2, +} from "@autumn/shared"; +import type Stripe from "stripe"; +import { createStripeCli } from "@/external/connect/createStripeCli"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { CusService } from "@/internal/customers/CusService"; +import { subscriptionToSyncParams } from "./subscriptionToSyncParams"; + +const findAlreadyLinkedProductId = ({ + stripeSubscriptionId, + customerProducts, +}: { + stripeSubscriptionId: string; + customerProducts: FullCusProduct[]; +}): string | null => { + const linked = customerProducts.find((cp) => + cp.subscription_ids?.includes(stripeSubscriptionId), + ); + return linked?.product?.id ?? null; +}; + +const buildProposal = async ({ + ctx, + customerId, + subscription, + customerProducts, +}: { + ctx: AutumnContext; + customerId: string; + subscription: Stripe.Subscription; + customerProducts: FullCusProduct[]; +}): Promise => { + const { params, schedule } = await subscriptionToSyncParams({ + ctx, + customerId, + subscription, + }); + + return { + stripe_subscription_id: params.stripe_subscription_id, + stripe_schedule_id: params.stripe_schedule_id, + phases: params.phases ?? [], + stripe_subscription: subscription, + stripe_schedule: schedule, + already_linked_product_id: findAlreadyLinkedProductId({ + stripeSubscriptionId: subscription.id, + customerProducts, + }), + }; +}; + +/** + * V2 sync proposals — for each Stripe subscription, runs detection + + * `subscriptionToSyncParams` to produce a draft `SyncParamsV1` and packages + * it with display extras. Frontend can mutate `phases` and pass straight to + * `/billing.sync`. + */ +export const syncProposalsV2 = async ({ + ctx, + params, +}: { + ctx: AutumnContext; + params: SyncProposalsV2Params; +}): Promise => { + const { org, env } = ctx; + + const fullCustomer = await CusService.getFull({ + ctx, + idOrInternalId: params.customer_id, + withSubs: true, + }); + + const stripeCustomerId = fullCustomer.processor?.id; + if (!stripeCustomerId) { + throw new RecaseError({ + message: "Customer has no linked Stripe customer", + code: ErrCode.InvalidRequest, + statusCode: 400, + }); + } + + const stripeCli = createStripeCli({ org, env }); + const subscriptionList = await stripeCli.subscriptions.list({ + customer: stripeCustomerId, + limit: 100, + }); + + if (subscriptionList.data.length === 0) { + return { customer_id: params.customer_id, proposals: [] }; + } + + // Stripe caps `expand` at 4 levels, so retrieve each subscription with + // `items.data.price.product` (4 levels) for the UI. + const proposals = await Promise.all( + subscriptionList.data.map(async ({ id }) => { + const subscription = await stripeCli.subscriptions.retrieve(id, { + expand: ["items.data.price.product"], + }); + return buildProposal({ + ctx, + customerId: params.customer_id, + subscription, + customerProducts: fullCustomer.customer_products, + }); + }), + ); + + return { customer_id: params.customer_id, proposals }; +}; diff --git a/server/src/internal/billing/v2/actions/sync/syncV2.ts b/server/src/internal/billing/v2/actions/sync/syncV2.ts new file mode 100644 index 000000000..c97409340 --- /dev/null +++ b/server/src/internal/billing/v2/actions/sync/syncV2.ts @@ -0,0 +1,92 @@ +import type { SyncParamsV1 } from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { persistCreateSchedule } from "@/internal/billing/v2/actions/createSchedule/utils/persistCreateSchedule"; +import { executeAutumnBillingPlan } from "@/internal/billing/v2/execute/executeAutumnBillingPlan"; +import { computeSyncPlan } from "./compute/computeSyncPlan"; +import { handleSyncErrors } from "./errors/handleSyncErrors"; +import { logSyncContext } from "./logs/logSyncContext"; +import { logSyncPlan } from "./logs/logSyncPlan"; +import { setupSyncContext } from "./setup/setupSyncContext"; + +export type SyncV2PersistedPhase = { + phase_id: string; + starts_at: number; + customer_product_ids: string[]; +}; + +export type SyncV2Result = { + customer_id: string; + stripe_subscription_id: string | null; + stripe_schedule_id: string | null; + inserted_cus_product_ids: string[]; + expired_cus_product_ids: string[]; + schedule_id: string | null; + scheduled_phases: SyncV2PersistedPhase[]; +}; + +/** + * Sync a Stripe subscription/schedule into Autumn state. + * + * Mirrors the v2 action convention used by createSchedule.ts: + * 1. setup — fetch sub, schedule, customer, products + * 2. errors — validate inputs against detection result + * 3. compute — apply caller overrides and produce an AutumnBillingPlan + * 4. execute — run the billing plan (cusProduct inserts/updates) + * 5. persist — write any scheduled phase rows (reuses createSchedule's + * `persistCreateSchedule` so the schedule + schedule_phases + * tables are written identically across actions) + */ +export const syncV2 = async ({ + ctx, + params, +}: { + ctx: AutumnContext; + params: SyncParamsV1; +}): Promise => { + // 1. Setup + const syncContext = await setupSyncContext({ ctx, params }); + logSyncContext({ ctx, syncContext }); + + // 2. Errors + handleSyncErrors({ syncContext }); + + // 3. Compute + const { autumnBillingPlan, phases } = computeSyncPlan({ ctx, syncContext }); + logSyncPlan({ ctx, autumnBillingPlan, phases }); + + // 4. Execute + await executeAutumnBillingPlan({ ctx, autumnBillingPlan }); + + // 5. Persist scheduled phases (only when sync produced more than one phase) + let scheduleId: string | null = null; + let scheduledPhases: SyncV2PersistedPhase[] = []; + if (phases.length > 0) { + const persisted = await persistCreateSchedule({ + ctx, + customerId: syncContext.customer_id, + currentEpochMs: syncContext.currentEpochMs, + fullCustomer: syncContext.fullCustomer, + phases, + }); + scheduleId = persisted.scheduleId; + scheduledPhases = persisted.insertedPhases; + } + + return { + customer_id: syncContext.customer_id, + stripe_subscription_id: syncContext.stripeSubscription?.id ?? null, + stripe_schedule_id: syncContext.stripeSchedule?.id ?? null, + inserted_cus_product_ids: autumnBillingPlan.insertCustomerProducts.map( + (cp) => cp.id, + ), + expired_cus_product_ids: (autumnBillingPlan.updateCustomerProducts ?? []) + .concat( + autumnBillingPlan.updateCustomerProduct + ? [autumnBillingPlan.updateCustomerProduct] + : [], + ) + .map((u) => u.customerProduct.id), + schedule_id: scheduleId, + scheduled_phases: scheduledPhases, + }; +}; diff --git a/server/src/internal/billing/v2/handlers/handleRestore.ts b/server/src/internal/billing/v2/handlers/handleRestore.ts new file mode 100644 index 000000000..6c33055b1 --- /dev/null +++ b/server/src/internal/billing/v2/handlers/handleRestore.ts @@ -0,0 +1,19 @@ +import { RestoreParamsV1Schema, Scopes } from "@autumn/shared"; +import { createRoute } from "@/honoMiddlewares/routeHandler"; +import { billingActions } from "@/internal/billing/v2/actions"; + +export const handleRestore = createRoute({ + scopes: [Scopes.Billing.Write], + body: RestoreParamsV1Schema, + handler: async (c) => { + const ctx = c.get("ctx"); + const body = c.req.valid("json"); + + const result = await billingActions.restore({ + ctx, + params: body, + }); + + return c.json(result, 200); + }, +}); diff --git a/server/src/internal/billing/v2/handlers/handleSyncProposalsV2.ts b/server/src/internal/billing/v2/handlers/handleSyncProposalsV2.ts new file mode 100644 index 000000000..f4f269dcd --- /dev/null +++ b/server/src/internal/billing/v2/handlers/handleSyncProposalsV2.ts @@ -0,0 +1,19 @@ +import { Scopes, SyncProposalsV2ParamsSchema } from "@autumn/shared"; +import { createRoute } from "@/honoMiddlewares/routeHandler"; +import { billingActions } from "@/internal/billing/v2/actions"; + +export const handleSyncProposalsV2 = createRoute({ + scopes: [Scopes.Billing.Read], + body: SyncProposalsV2ParamsSchema, + handler: async (c) => { + const ctx = c.get("ctx"); + const body = c.req.valid("json"); + + const result = await billingActions.syncProposalsV2({ + ctx, + params: body, + }); + + return c.json(result, 200); + }, +}); diff --git a/server/src/internal/billing/v2/handlers/handleSyncV2.ts b/server/src/internal/billing/v2/handlers/handleSyncV2.ts new file mode 100644 index 000000000..9f5ebecd8 --- /dev/null +++ b/server/src/internal/billing/v2/handlers/handleSyncV2.ts @@ -0,0 +1,19 @@ +import { Scopes, SyncParamsV1Schema } from "@autumn/shared"; +import { createRoute } from "@/honoMiddlewares/routeHandler"; +import { billingActions } from "@/internal/billing/v2/actions"; + +export const handleSyncV2 = createRoute({ + scopes: [Scopes.Billing.Write], + body: SyncParamsV1Schema, + handler: async (c) => { + const ctx = c.get("ctx"); + const body = c.req.valid("json"); + + const result = await billingActions.syncV2({ + ctx, + params: body, + }); + + return c.json(result, 200); + }, +}); diff --git a/server/src/internal/billing/v2/providers/stripe/utils/sync/matchUtils/findStripeMatchForAutumnPrice.ts b/server/src/internal/billing/v2/providers/stripe/utils/sync/matchUtils/findStripeMatchForAutumnPrice.ts new file mode 100644 index 000000000..36687d414 --- /dev/null +++ b/server/src/internal/billing/v2/providers/stripe/utils/sync/matchUtils/findStripeMatchForAutumnPrice.ts @@ -0,0 +1,65 @@ +import { + type FixedPriceConfig, + isFixedPrice, + type Price, + type Product, + type UsagePriceConfig, +} from "@autumn/shared"; +import { getStripePriceIdsForAutumnPrice } from "./getStripePriceIdsForAutumnPrice"; +import type { PriceMatchCondition } from "./matchConditions"; + +/** + * Returns the Stripe product id that this Autumn price is *natively* keyed + * to (for prepaid/usage prices that store `stripe_product_id` on their + * config). Fixed prices intentionally return null — they are matched + * exclusively via `stripe_price_id` at priority 1; any other Stripe price + * under the same Stripe product should fall through to the priority-3 + * product-level match so the rollup can flag it as a custom base. + */ +const stripeProductIdForAutumnPrice = ({ + price, +}: { + price: Price; +}): string | null => { + if (isFixedPrice(price)) return null; + const config = price.config as FixedPriceConfig | UsagePriceConfig; + return config.stripe_product_id ?? null; +}; + +/** + * Find the highest-priority match condition between an Autumn price and a + * pool of Stripe ids (typically collected from a subscription's items). + * + * Priority: + * 1. stripe_price_id (exact price match) + * 2. stripe_product_id (prepaid/usage price explicitly keyed by product) + * + * Returns null when neither id type intersects. + */ +export const findStripeMatchForAutumnPrice = ({ + price, + stripePriceIds, + stripeProductIds, +}: { + price: Price; + product: Product; + stripePriceIds: Set; + stripeProductIds: Set; +}): PriceMatchCondition | null => { + const matchedPriceId = getStripePriceIdsForAutumnPrice({ price }).find((id) => + stripePriceIds.has(id), + ); + if (matchedPriceId) { + return { type: "stripe_price_id", stripe_price_id: matchedPriceId }; + } + + const stripeProductId = stripeProductIdForAutumnPrice({ price }); + if (stripeProductId && stripeProductIds.has(stripeProductId)) { + return { + type: "stripe_product_id", + stripe_product_id: stripeProductId, + }; + } + + return null; +}; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/sync/matchUtils/findStripeMatchForAutumnProduct.ts b/server/src/internal/billing/v2/providers/stripe/utils/sync/matchUtils/findStripeMatchForAutumnProduct.ts new file mode 100644 index 000000000..c5b4aeb4f --- /dev/null +++ b/server/src/internal/billing/v2/providers/stripe/utils/sync/matchUtils/findStripeMatchForAutumnProduct.ts @@ -0,0 +1,20 @@ +import { type Product, productToStripeId } from "@autumn/shared"; +import type { ProductMatchCondition } from "./matchConditions"; + +/** + * Find the match condition (if any) between an Autumn product and a pool + * of Stripe product ids. Returns null when the product's processor id is + * absent or not in the pool. + */ +export const findStripeMatchForAutumnProduct = ({ + product, + stripeProductIds, +}: { + product: Product; + stripeProductIds: Set; +}): ProductMatchCondition | null => { + const stripeProductId = productToStripeId({ product }); + if (!stripeProductId) return null; + if (!stripeProductIds.has(stripeProductId)) return null; + return { type: "stripe_product_id", stripe_product_id: stripeProductId }; +}; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/sync/matchUtils/matchConditions.ts b/server/src/internal/billing/v2/providers/stripe/utils/sync/matchUtils/matchConditions.ts new file mode 100644 index 000000000..1551988e3 --- /dev/null +++ b/server/src/internal/billing/v2/providers/stripe/utils/sync/matchUtils/matchConditions.ts @@ -0,0 +1,15 @@ +/** + * Describes how an Autumn price/product was matched against a Stripe id. + * Returned by the find* helpers in this folder so callers know which + * Stripe id (and which kind) put a given Autumn resource on the candidate + * list. + */ + +export type PriceMatchCondition = + | { type: "stripe_price_id"; stripe_price_id: string } + | { type: "stripe_product_id"; stripe_product_id: string }; + +export type ProductMatchCondition = { + type: "stripe_product_id"; + stripe_product_id: string; +}; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/sync/stripeItemSnapshot/normalizePhaseItem.ts b/server/src/internal/billing/v2/providers/stripe/utils/sync/stripeItemSnapshot/normalizePhaseItem.ts new file mode 100644 index 000000000..4624af8f8 --- /dev/null +++ b/server/src/internal/billing/v2/providers/stripe/utils/sync/stripeItemSnapshot/normalizePhaseItem.ts @@ -0,0 +1,76 @@ +import type Stripe from "stripe"; +import type { StripeItemSnapshot, StripeItemTier } from "./types"; + +const resolveStripeProductId = ({ + product, +}: { + product: string | Stripe.Product | Stripe.DeletedProduct | null | undefined; +}): string | null => { + if (!product) return null; + return typeof product === "string" ? product : (product.id ?? null); +}; + +const extractTiers = ({ + price, +}: { + price: Stripe.Price; +}): StripeItemTier[] | null => { + if (!price.tiers) return null; + return price.tiers.map((tier) => ({ + up_to: tier.up_to, + unit_amount: tier.unit_amount, + flat_amount: tier.flat_amount, + })); +}; + +/** + * Normalize a Stripe.SubscriptionSchedule.Phase.Item to a StripeItemSnapshot. + * + * Phase items don't carry a stable Stripe ID, so the caller supplies a + * synthetic id (typically `${phaseIndex}:${itemIndex}`). + * + * If the price was not expanded, only the price ID is available; pricing + * fields (amount, currency, tiers) will be null and must be enriched later. + */ +export const normalizePhaseItem = ({ + phaseItem, + syntheticId, +}: { + phaseItem: Stripe.SubscriptionSchedule.Phase.Item; + syntheticId: string; +}): StripeItemSnapshot | null => { + const rawPrice = phaseItem.price as string | Stripe.Price | undefined; + if (!rawPrice) return null; + + const expandedPrice = typeof rawPrice === "object" ? rawPrice : null; + const stripePriceId = + typeof rawPrice === "string" ? rawPrice : (expandedPrice?.id ?? null); + if (!stripePriceId) return null; + + const stripeProductId = expandedPrice + ? resolveStripeProductId({ product: expandedPrice.product }) + : null; + + // When unexpanded, we don't have product_id — the snapshot requires it. + // Caller must expand `phases.items.price` (or enrich post-normalize) + // before classification can succeed. + if (!stripeProductId) return null; + + return { + id: syntheticId, + stripe_price_id: stripePriceId, + stripe_product_id: stripeProductId, + unit_amount: expandedPrice?.unit_amount ?? null, + currency: expandedPrice?.currency ?? null, + quantity: phaseItem.quantity ?? 1, + billing_scheme: + (expandedPrice?.billing_scheme as "per_unit" | "tiered") ?? null, + tiers_mode: + (expandedPrice?.tiers_mode as "graduated" | "volume") ?? null, + tiers: expandedPrice ? extractTiers({ price: expandedPrice }) : null, + recurring_interval: expandedPrice?.recurring?.interval ?? null, + recurring_usage_type: + (expandedPrice?.recurring?.usage_type as "licensed" | "metered") ?? null, + metadata: phaseItem.metadata ?? {}, + }; +}; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/sync/stripeItemSnapshot/normalizeSubscriptionItem.ts b/server/src/internal/billing/v2/providers/stripe/utils/sync/stripeItemSnapshot/normalizeSubscriptionItem.ts new file mode 100644 index 000000000..642819991 --- /dev/null +++ b/server/src/internal/billing/v2/providers/stripe/utils/sync/stripeItemSnapshot/normalizeSubscriptionItem.ts @@ -0,0 +1,58 @@ +import type Stripe from "stripe"; +import type { StripeItemSnapshot, StripeItemTier } from "./types"; + +const resolveStripeProductId = ({ + product, +}: { + product: string | Stripe.Product | Stripe.DeletedProduct | null | undefined; +}): string | null => { + if (!product) return null; + return typeof product === "string" ? product : (product.id ?? null); +}; + +const extractTiers = ({ + price, +}: { + price: Stripe.Price; +}): StripeItemTier[] | null => { + if (!price.tiers) return null; + return price.tiers.map((tier) => ({ + up_to: tier.up_to, + unit_amount: tier.unit_amount, + flat_amount: tier.flat_amount, + })); +}; + +/** + * Normalize a Stripe.SubscriptionItem to the canonical StripeItemSnapshot + * shape consumed by sync detection. Returns null for degenerate items + * (missing price or product) so the caller can filter them out. + */ +export const normalizeSubscriptionItem = ({ + stripeItem, +}: { + stripeItem: Stripe.SubscriptionItem; +}): StripeItemSnapshot | null => { + const price = stripeItem.price; + if (!price) return null; + + const stripePriceId = price.id; + const stripeProductId = resolveStripeProductId({ product: price.product }); + if (!stripePriceId || !stripeProductId) return null; + + return { + id: stripeItem.id, + stripe_price_id: stripePriceId, + stripe_product_id: stripeProductId, + unit_amount: price.unit_amount ?? null, + currency: price.currency ?? null, + quantity: stripeItem.quantity ?? 1, + billing_scheme: (price.billing_scheme as "per_unit" | "tiered") ?? null, + tiers_mode: (price.tiers_mode as "graduated" | "volume") ?? null, + tiers: extractTiers({ price }), + recurring_interval: price.recurring?.interval ?? null, + recurring_usage_type: + (price.recurring?.usage_type as "licensed" | "metered") ?? null, + metadata: stripeItem.metadata ?? {}, + }; +}; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/sync/stripeItemSnapshot/normalizeSubscriptionPhases.ts b/server/src/internal/billing/v2/providers/stripe/utils/sync/stripeItemSnapshot/normalizeSubscriptionPhases.ts new file mode 100644 index 000000000..61b3b2686 --- /dev/null +++ b/server/src/internal/billing/v2/providers/stripe/utils/sync/stripeItemSnapshot/normalizeSubscriptionPhases.ts @@ -0,0 +1,68 @@ +import type Stripe from "stripe"; +import { isStripeSubscriptionSchedulePhaseCurrent } from "@/external/stripe/subscriptionSchedules/utils/classifyStripeSubscriptionScheduleUtils"; +import { stripeSubscriptionToStartDate } from "@/external/stripe/subscriptions/utils/convertStripeSubscription"; +import { normalizePhaseItem } from "./normalizePhaseItem"; +import { normalizeSubscriptionItem } from "./normalizeSubscriptionItem"; +import type { PhaseSnapshot } from "./types"; + +/** + * Build PhaseSnapshot[] from a Stripe subscription, schedule, or both. + * + * - schedule supplied → one snapshot per schedule phase + * - subscription only → single open-ended snapshot from subscription.items + * + * At least one input is required. + */ +export const normalizeSubscriptionPhases = ({ + subscription, + schedule, + nowSec = Math.floor(Date.now() / 1000), +}: { + subscription?: Stripe.Subscription; + schedule?: Stripe.SubscriptionSchedule | null; + nowSec?: number; +}): PhaseSnapshot[] => { + if (!schedule && !subscription) { + throw new Error( + "normalizeSubscriptionPhases requires a subscription or a schedule", + ); + } + + if (!schedule) { + const items = subscription!.items.data + .map((stripeItem) => normalizeSubscriptionItem({ stripeItem })) + .filter((item): item is NonNullable => item !== null); + + return [ + { + start_date: stripeSubscriptionToStartDate({ + stripeSubscription: subscription!, + }), + end_date: null, + is_current: true, + items, + }, + ]; + } + + return schedule.phases.map((phase, phaseIndex) => { + const items = phase.items + .map((phaseItem, itemIndex) => + normalizePhaseItem({ + phaseItem, + syntheticId: `${phaseIndex}:${itemIndex}`, + }), + ) + .filter((item): item is NonNullable => item !== null); + + return { + start_date: phase.start_date, + end_date: phase.end_date ?? null, + is_current: isStripeSubscriptionSchedulePhaseCurrent({ + phase, + nowSeconds: nowSec, + }), + items, + }; + }); +}; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/sync/stripeItemSnapshot/types.ts b/server/src/internal/billing/v2/providers/stripe/utils/sync/stripeItemSnapshot/types.ts new file mode 100644 index 000000000..07fe36980 --- /dev/null +++ b/server/src/internal/billing/v2/providers/stripe/utils/sync/stripeItemSnapshot/types.ts @@ -0,0 +1,29 @@ +import type Stripe from "stripe"; + +export type StripeItemTier = { + up_to: number | null; + unit_amount: number | null; + flat_amount: number | null; +}; + +export type StripeItemSnapshot = { + id: string; + stripe_price_id: string; + stripe_product_id: string; + unit_amount: number | null; + currency: string | null; + quantity: number; + billing_scheme: "per_unit" | "tiered" | null; + tiers_mode: "graduated" | "volume" | null; + tiers: StripeItemTier[] | null; + recurring_interval: Stripe.Price.Recurring.Interval | null; + recurring_usage_type: "licensed" | "metered" | null; + metadata: Stripe.Metadata; +}; + +export type PhaseSnapshot = { + start_date: number; + end_date: number | null; + is_current: boolean; + items: StripeItemSnapshot[]; +}; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/sync/stripeToAutumn/findAutumnMatchForStripeItem.ts b/server/src/internal/billing/v2/providers/stripe/utils/sync/stripeToAutumn/findAutumnMatchForStripeItem.ts new file mode 100644 index 000000000..3a4f310f9 --- /dev/null +++ b/server/src/internal/billing/v2/providers/stripe/utils/sync/stripeToAutumn/findAutumnMatchForStripeItem.ts @@ -0,0 +1,67 @@ +import type { FullProduct } from "@autumn/shared"; +import type { + ItemDiff, + ItemMatch, +} from "@/internal/billing/v2/actions/sync/detect/types"; +import { findStripeMatchForAutumnPrice } from "../matchUtils/findStripeMatchForAutumnPrice"; +import { findStripeMatchForAutumnProduct } from "../matchUtils/findStripeMatchForAutumnProduct"; +import type { StripeItemSnapshot } from "../stripeItemSnapshot/types"; + +/** + * Match a single StripeItemSnapshot against the supplied Autumn products. + * + * Walks every Autumn price (priority 1+2) before falling back to the + * product-level match (priority 3). The matched Autumn resource(s) are + * embedded on the returned ItemMatch so callers never need to re-look-up + * by id. + * + * Pure: no I/O, no sibling-aware decisions. + */ +export const findAutumnMatchForStripeItem = ({ + item, + fullProducts, +}: { + item: StripeItemSnapshot; + fullProducts: FullProduct[]; +}): ItemDiff => { + const stripePriceIds = new Set([item.stripe_price_id]); + const stripeProductIds = new Set([item.stripe_product_id]); + + for (const product of fullProducts) { + for (const price of product.prices) { + const matched_on = findStripeMatchForAutumnPrice({ + price, + product, + stripePriceIds, + stripeProductIds, + }); + if (matched_on) { + return { + stripe: item, + match: { + kind: "autumn_price", + matched_on, + price, + product, + }, + }; + } + } + } + + for (const product of fullProducts) { + const matched_on = findStripeMatchForAutumnProduct({ + product, + stripeProductIds, + }); + if (matched_on) { + return { + stripe: item, + match: { kind: "autumn_product", matched_on, product }, + }; + } + } + + const noMatch: ItemMatch = { kind: "none" }; + return { stripe: item, match: noMatch }; +}; diff --git a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initScheduledCustomerProduct.ts b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initScheduledCustomerProduct.ts new file mode 100644 index 000000000..5e576d13f --- /dev/null +++ b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initScheduledCustomerProduct.ts @@ -0,0 +1,62 @@ +import { + BillingVersion, + CusProductStatus, + type FeatureOptions, + type FullCusProduct, + type FullCustomer, + type FullProduct, +} from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { initFullCustomerProduct } from "./initFullCustomerProduct"; + +/** + * Build a Scheduled cusProduct for a future-dated phase. + * + * Shared between createSchedule's `computeScheduledCustomerProducts` and + * sync's `computeSyncFuturePhases` — the per-product init logic is identical + * regardless of the action that produced the phase context. + */ +export const initScheduledCustomerProduct = ({ + ctx, + fullCustomer, + fullProduct, + featureQuantities, + startsAt, + endsAt, + currentEpochMs, + subscriptionId, + subscriptionScheduleId, +}: { + ctx: AutumnContext; + fullCustomer: FullCustomer; + fullProduct: FullProduct; + featureQuantities: FeatureOptions[]; + startsAt: number; + endsAt: number | null | undefined; + currentEpochMs: number; + /** When syncing from an existing Stripe sub/schedule, link the resulting + * scheduled cusProduct back to it so the customer-products view shows the + * Stripe linkage and downstream actions (cancel, restore) can find it. */ + subscriptionId?: string; + subscriptionScheduleId?: string; +}): FullCusProduct => { + return initFullCustomerProduct({ + ctx, + initContext: { + fullCustomer, + fullProduct, + featureQuantities, + resetCycleAnchor: startsAt, + freeTrial: null, + now: currentEpochMs, + billingVersion: BillingVersion.V2, + }, + initOptions: { + startsAt, + endedAt: endsAt ?? undefined, + status: CusProductStatus.Scheduled, + subscriptionId, + subscriptionScheduleId, + }, + }); +}; diff --git a/server/src/internal/misc/rateLimiter/rateLimitConfigs.ts b/server/src/internal/misc/rateLimiter/rateLimitConfigs.ts index 16396a0a4..1e782c56a 100644 --- a/server/src/internal/misc/rateLimiter/rateLimitConfigs.ts +++ b/server/src/internal/misc/rateLimiter/rateLimitConfigs.ts @@ -47,7 +47,9 @@ const RATE_LIMIT_ROUTE_GROUPS: RateLimitRouteGroup[] = [ route({ method: "POST", url: "/v1/billing.setup_payment" }), route({ method: "POST", url: "/v1/billing.open_customer_portal" }), route({ method: "POST", url: "/v1/billing.sync_proposals" }), + route({ method: "POST", url: "/v1/billing.sync_proposals_v2" }), route({ method: "POST", url: "/v1/billing.sync" }), + route({ method: "POST", url: "/v1/billing.sync_v2" }), ], }, { diff --git a/server/tests/_groups/temp.ts b/server/tests/_groups/temp.ts index 2e2d65c8f..baa40da6a 100644 --- a/server/tests/_groups/temp.ts +++ b/server/tests/_groups/temp.ts @@ -2,7 +2,12 @@ import type { TestGroup } from "./types"; export const temp: TestGroup = { name: "temp", - description: "customer.subscription.created auto-sync + skip-sync coverage", + description: + "sub.created auto-sync + sync param detection + restore (Autumn → Stripe)", tier: "domain", - paths: ["integration/billing/stripe-webhooks/subscription-created"], + paths: [ + // "integration/billing/stripe-webhooks/subscription-created", + "integration/billing/sync/to-sync-params", + // "integration/billing/restore", + ], }; diff --git a/server/tests/integration/billing/restore/restore-advanced.test.ts b/server/tests/integration/billing/restore/restore-advanced.test.ts new file mode 100644 index 000000000..57169b808 --- /dev/null +++ b/server/tests/integration/billing/restore/restore-advanced.test.ts @@ -0,0 +1,181 @@ +/** + * Restore Advanced Tests + * + * Test 3: Pro + recurring add-on. Remove the add-on item from Stripe, restore + * should put it back. + * Test 4: Pro + prepaid messages. Set the prepaid quantity to a wrong value, + * restore should set it back to Autumn's expected quantity. + */ + +import { expect, test } from "bun:test"; +import { OnDecrease, OnIncrease } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import type { TestContext } from "@tests/utils/testInitUtils/createTestContext"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { CusService } from "@/internal/customers/CusService"; +import { ProductService } from "@/internal/products/ProductService"; +import { expectStripeSubscriptionCorrect } from "../utils/expectStripeSubCorrect/expectStripeSubscriptionCorrect"; +import { + corruptStripeSubscription, + listActiveStripeSubscriptions, +} from "./utils/corruptStripeSubscription"; + +const stripeCustomerIdFor = async ({ + ctx, + customerId, +}: { + ctx: TestContext; + customerId: string; +}) => { + const fullCustomer = await CusService.getFull({ + ctx, + idOrInternalId: customerId, + }); + const stripeCustomerId = fullCustomer.processor?.id; + if (!stripeCustomerId) { + throw new Error(`Customer ${customerId} has no Stripe customer ID`); + } + return stripeCustomerId; +}; + +const allStripePriceIdsFor = async ({ + ctx, + productId, +}: { + ctx: TestContext; + productId: string; +}) => { + const fullProduct = await ProductService.getFull({ + db: ctx.db, + idOrInternalId: productId, + orgId: ctx.org.id, + env: ctx.env, + }); + const ids: string[] = []; + for (const price of fullProduct.prices) { + const id = + price.config.stripe_price_id ?? price.config.stripe_empty_price_id; + if (id) ids.push(id); + } + return ids; +}; + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 3: Pro + recurring add-on, remove add-on item from Stripe, restore +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("restore-advanced 3: pro + recurring add-on, remove add-on item, restore")}`, async () => { + const customerId = "restore-advanced-addon"; + + const proMessages = items.monthlyMessages({ includedUsage: 200 }); + const pro = products.pro({ id: "pro", items: [proMessages] }); + + const addonWords = items.monthlyWords({ includedUsage: 200 }); + const addOn = products.recurringAddOn({ + id: "recurring-addon", + items: [addonWords], + }); + + const { autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, addOn] }), + ], + actions: [ + s.billing.attach({ productId: pro.id }), + s.billing.attach({ productId: addOn.id, timeout: 4000 }), + ], + }); + + const stripeCustomerId = await stripeCustomerIdFor({ ctx, customerId }); + const subs = await listActiveStripeSubscriptions({ ctx, stripeCustomerId }); + expect(subs.length).toBe(1); + const sub = subs[0]; + + // Remove the add-on's Stripe item from the subscription. + const addonPriceIds = await allStripePriceIdsFor({ + ctx, + productId: addOn.id, + }); + await corruptStripeSubscription({ + ctx, + subscriptionId: sub.id, + mutations: { removeItemPriceIds: addonPriceIds }, + }); + + await autumnV2_2.billing.restore({ customer_id: customerId }); + + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 4: Pro with prepaid messages — corrupt the prepaid quantity, restore +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("restore-advanced 4: pro + prepaid messages, wrong quantity, restore")}`, async () => { + const customerId = "restore-advanced-prepaid"; + const billingUnits = 100; + const includedUsage = 100; + + const prepaid = items.prepaidMessages({ + includedUsage, + billingUnits, + price: 10, + config: { + on_increase: OnIncrease.ProrateImmediately, + on_decrease: OnDecrease.ProrateImmediately, + }, + }); + const fixed = items.monthlyPrice({ price: 20 }); + const pro = products.base({ id: "pro", items: [prepaid, fixed] }); + + const initialPacks = 4; + const totalUnits = includedUsage + initialPacks * billingUnits; + + const { autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [ + s.billing.attach({ + productId: pro.id, + options: [{ feature_id: TestFeature.Messages, quantity: totalUnits }], + timeout: 4000, + }), + ], + }); + + const stripeCustomerId = await stripeCustomerIdFor({ ctx, customerId }); + const subs = await listActiveStripeSubscriptions({ ctx, stripeCustomerId }); + expect(subs.length).toBe(1); + const sub = subs[0]; + + const proPriceIds = await allStripePriceIdsFor({ ctx, productId: pro.id }); + + // Find the prepaid item on Stripe (one of the price ids will be on the sub + // with quantity > 0 and not equal to the fixed-base price item). The + // simplest corruption: set every Stripe item quantity to a wrong value. + const wrongQuantityUpdates = sub.items.data + .filter((item) => proPriceIds.includes(item.price.id)) + .filter((item) => item.quantity !== undefined) + .map((item) => ({ + priceId: item.price.id, + quantity: (item.quantity ?? 1) + 3, + })); + + await corruptStripeSubscription({ + ctx, + subscriptionId: sub.id, + mutations: { setItemQuantities: wrongQuantityUpdates }, + }); + + await autumnV2_2.billing.restore({ customer_id: customerId }); + + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); diff --git a/server/tests/integration/billing/restore/restore-basic.test.ts b/server/tests/integration/billing/restore/restore-basic.test.ts new file mode 100644 index 000000000..cba8e158e --- /dev/null +++ b/server/tests/integration/billing/restore/restore-basic.test.ts @@ -0,0 +1,184 @@ +/** + * Restore Basic Tests + * + * The `restore` billing action takes Autumn's customer_products as the source + * of truth and reshapes Stripe to match. Tests here drift Stripe out of sync + * deliberately, then verify restore brings it back. + * + * Test 1: Two entities (pro + premium). Corrupt entity 1's sub items, restore. + * Test 2: Schedule scenario — release schedule manually, restore recreates it. + */ + +import { expect, test } from "bun:test"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import type { TestContext } from "@tests/utils/testInitUtils/createTestContext"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { CusService } from "@/internal/customers/CusService"; +import { ProductService } from "@/internal/products/ProductService"; +import { expectStripeSubscriptionCorrect } from "../utils/expectStripeSubCorrect/expectStripeSubscriptionCorrect"; +import { + corruptStripeSubscription, + listActiveStripeSubscriptions, +} from "./utils/corruptStripeSubscription"; + +const stripeCustomerIdFor = async ({ + ctx, + customerId, +}: { + ctx: TestContext; + customerId: string; +}) => { + const fullCustomer = await CusService.getFull({ + ctx, + idOrInternalId: customerId, + }); + const stripeCustomerId = fullCustomer.processor?.id; + if (!stripeCustomerId) { + throw new Error(`Customer ${customerId} has no Stripe customer ID`); + } + return stripeCustomerId; +}; + +const firstStripePriceIdFor = async ({ + ctx, + productId, +}: { + ctx: TestContext; + productId: string; +}) => { + const fullProduct = await ProductService.getFull({ + db: ctx.db, + idOrInternalId: productId, + orgId: ctx.org.id, + env: ctx.env, + }); + for (const price of fullProduct.prices) { + const id = + price.config.stripe_price_id ?? price.config.stripe_empty_price_id; + if (id) return id; + } + throw new Error(`No Stripe price id on product ${productId}`); +}; + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 1: Two entities, corrupt one sub, restore +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("restore-basic 1: two entities pro+premium, corrupt one, restore")}`, async () => { + const customerId = "restore-basic-two-entities"; + + const proMessages = items.monthlyMessages({ includedUsage: 200 }); + const pro = products.pro({ id: "pro", items: [proMessages] }); + + const premiumMessages = items.monthlyMessages({ includedUsage: 1000 }); + const premium = products.premium({ id: "premium", items: [premiumMessages] }); + + const { autumnV2_2, ctx, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + s.billing.attach({ productId: pro.id, entityIndex: 0 }), + s.billing.attach({ + productId: premium.id, + entityIndex: 1, + timeout: 4000, + }), + ], + }); + expect(entities.length).toBe(2); + + // Corrupt the pro sub item by setting its quantity to a wrong value. Each + // entity may share a single Stripe subscription (entity-scoped items), so we + // avoid mutations that depend on the sub topology — a per-item quantity bump + // is a clean diff restore should reverse. + const stripeCustomerId = await stripeCustomerIdFor({ ctx, customerId }); + const subs = await listActiveStripeSubscriptions({ ctx, stripeCustomerId }); + const proStripePriceId = await firstStripePriceIdFor({ + ctx, + productId: pro.id, + }); + + const proSub = subs.find((sub) => + sub.items.data.some((it) => it.price.id === proStripePriceId), + ); + if (!proSub) throw new Error("Pro sub not found in Stripe"); + + await corruptStripeSubscription({ + ctx, + subscriptionId: proSub.id, + mutations: { + setItemQuantities: [{ priceId: proStripePriceId, quantity: 7 }], + }, + }); + + // Restore — should remove the junk item. + await autumnV2_2.billing.restore({ customer_id: customerId }); + + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 2: With schedule — release schedule manually, restore recreates it +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("restore-basic 2: scheduled downgrade, schedule released externally, restore recreates")}`, async () => { + const customerId = "restore-basic-released-schedule"; + + const proMessages = items.monthlyMessages({ includedUsage: 200 }); + const pro = products.pro({ id: "pro", items: [proMessages] }); + + const premiumMessages = items.monthlyMessages({ includedUsage: 1000 }); + const premium = products.premium({ id: "premium", items: [premiumMessages] }); + + const { autumnV2_2, ctx, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + s.billing.attach({ productId: pro.id, entityIndex: 0 }), + s.billing.attach({ productId: premium.id, entityIndex: 1 }), + // Schedule downgrade premium → pro on entity 1 + s.billing.attach({ + productId: pro.id, + entityIndex: 1, + timeout: 4000, + }), + ], + }); + expect(entities.length).toBe(2); + + // Locate the entity-1 (premium) Stripe subscription which now has a schedule + const stripeCustomerId = await stripeCustomerIdFor({ ctx, customerId }); + const subs = await listActiveStripeSubscriptions({ ctx, stripeCustomerId }); + + const premiumStripePriceId = await firstStripePriceIdFor({ + ctx, + productId: premium.id, + }); + const premiumSub = subs.find((sub) => + sub.items.data.some((it) => it.price.id === premiumStripePriceId), + ); + if (!premiumSub) throw new Error("Premium sub not found"); + + // Release the schedule directly via Stripe (corrupts state) + await corruptStripeSubscription({ + ctx, + subscriptionId: premiumSub.id, + mutations: { releaseSchedule: true }, + }); + + // Restore — should re-create the schedule + await autumnV2_2.billing.restore({ customer_id: customerId }); + + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); diff --git a/server/tests/integration/billing/restore/restore-schedule.test.ts b/server/tests/integration/billing/restore/restore-schedule.test.ts new file mode 100644 index 000000000..159da469f --- /dev/null +++ b/server/tests/integration/billing/restore/restore-schedule.test.ts @@ -0,0 +1,101 @@ +/** + * Restore Schedule Tests + * + * Verifies that `restore` rebuilds multi-phase Stripe schedules created via + * /billing.create_schedule when a developer releases the schedule directly. + * + * Test 5: Two-phase schedule (pro+addon now, pro-only in +30d) — release the + * schedule via Stripe, restore should re-create the schedule with both + * phases intact. + */ + +import { expect, test } from "bun:test"; +import { type CreateScheduleParamsV0Input, ms } from "@autumn/shared"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import type { TestContext } from "@tests/utils/testInitUtils/createTestContext"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { CusService } from "@/internal/customers/CusService"; +import { expectStripeSubscriptionCorrect } from "../utils/expectStripeSubCorrect/expectStripeSubscriptionCorrect"; +import { + corruptStripeSubscription, + listActiveStripeSubscriptions, +} from "./utils/corruptStripeSubscription"; + +const stripeCustomerIdFor = async ({ + ctx, + customerId, +}: { + ctx: TestContext; + customerId: string; +}) => { + const fullCustomer = await CusService.getFull({ + ctx, + idOrInternalId: customerId, + }); + const stripeCustomerId = fullCustomer.processor?.id; + if (!stripeCustomerId) { + throw new Error(`Customer ${customerId} has no Stripe customer ID`); + } + return stripeCustomerId; +}; + +test.concurrent(`${chalk.yellowBright("restore-schedule 5: two-phase schedule, release schedule, restore re-creates phases")}`, async () => { + const customerId = "restore-schedule-multi-phase"; + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + const addon = products.recurringAddOn({ + id: "addon", + items: [items.monthlyWords({ includedUsage: 25 })], + }); + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, addon] }), + ], + actions: [], + }); + + const now = Date.now(); + const params: CreateScheduleParamsV0Input = { + customer_id: customerId, + phases: [ + { + starts_at: now, + plans: [{ plan_id: pro.id }, { plan_id: addon.id }], + }, + { + starts_at: now + ms.days(30), + plans: [{ plan_id: pro.id }], + }, + ], + }; + + const response = await autumnV1.billing.createSchedule(params); + expect(response.status).toBe("created"); + expect(response.phases).toHaveLength(2); + + // Locate the Stripe sub for this customer (single sub backing both phases) + const stripeCustomerId = await stripeCustomerIdFor({ ctx, customerId }); + const subs = await listActiveStripeSubscriptions({ ctx, stripeCustomerId }); + expect(subs.length).toBe(1); + const sub = subs[0]; + + // Drift: release the schedule (caller "fixed" something in Stripe by hand). + await corruptStripeSubscription({ + ctx, + subscriptionId: sub.id, + mutations: { releaseSchedule: true }, + }); + + // Restore should re-create the schedule and put both phases back. + await autumnV2_2.billing.restore({ customer_id: customerId }); + + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); diff --git a/server/tests/integration/billing/restore/utils/corruptStripeSubscription.ts b/server/tests/integration/billing/restore/utils/corruptStripeSubscription.ts new file mode 100644 index 000000000..1824ff71d --- /dev/null +++ b/server/tests/integration/billing/restore/utils/corruptStripeSubscription.ts @@ -0,0 +1,115 @@ +import type { TestContext } from "@tests/utils/testInitUtils/createTestContext"; +import type Stripe from "stripe"; + +/** + * Mutates a Stripe subscription / its schedule to drift from Autumn's expected + * state, so that the restore action has work to do. Test-only utility. + */ +export const corruptStripeSubscription = async ({ + ctx, + subscriptionId, + mutations, +}: { + ctx: TestContext; + subscriptionId: string; + mutations: { + removeAllItems?: boolean; + removeItemPriceIds?: string[]; + addItems?: Array<{ price: string; quantity?: number }>; + setItemQuantities?: Array<{ priceId: string; quantity: number }>; + releaseSchedule?: boolean; + }; +}): Promise => { + const sub = await ctx.stripeCli.subscriptions.retrieve(subscriptionId); + + if (mutations.releaseSchedule) { + const scheduleId = + typeof sub.schedule === "string" ? sub.schedule : sub.schedule?.id; + if (scheduleId) { + await ctx.stripeCli.subscriptionSchedules.release(scheduleId); + } + } + + const updateItems: Stripe.SubscriptionUpdateParams.Item[] = []; + + if (mutations.removeAllItems) { + for (const item of sub.items.data) { + updateItems.push({ id: item.id, deleted: true }); + } + } + + if (mutations.removeItemPriceIds?.length) { + const priceIdSet = new Set(mutations.removeItemPriceIds); + for (const item of sub.items.data) { + if (priceIdSet.has(item.price.id)) { + updateItems.push({ id: item.id, deleted: true }); + } + } + } + + if (mutations.setItemQuantities?.length) { + for (const { priceId, quantity } of mutations.setItemQuantities) { + const item = sub.items.data.find((i) => i.price.id === priceId); + if (item) updateItems.push({ id: item.id, quantity }); + } + } + + if (mutations.addItems?.length) { + for (const add of mutations.addItems) { + updateItems.push({ price: add.price, quantity: add.quantity ?? 1 }); + } + } + + if (updateItems.length === 0) { + return ctx.stripeCli.subscriptions.retrieve(subscriptionId); + } + + return ctx.stripeCli.subscriptions.update(subscriptionId, { + items: updateItems, + proration_behavior: "none", + }); +}; + +/** + * Returns the (single) primary Stripe subscription for a customer in the test + * environment. Throws if there is more than one or zero. + */ +export const getStripeSubscriptionForCustomer = async ({ + ctx, + stripeCustomerId, +}: { + ctx: TestContext; + stripeCustomerId: string; +}): Promise => { + const subs = await ctx.stripeCli.subscriptions.list({ + customer: stripeCustomerId, + status: "all", + limit: 100, + }); + const active = subs.data.filter( + (s) => s.status === "active" || s.status === "trialing", + ); + if (active.length !== 1) { + throw new Error( + `Expected exactly 1 active Stripe subscription for customer ${stripeCustomerId}, found ${active.length}`, + ); + } + return active[0]; +}; + +export const listActiveStripeSubscriptions = async ({ + ctx, + stripeCustomerId, +}: { + ctx: TestContext; + stripeCustomerId: string; +}): Promise => { + const subs = await ctx.stripeCli.subscriptions.list({ + customer: stripeCustomerId, + status: "all", + limit: 100, + }); + return subs.data.filter( + (s) => s.status === "active" || s.status === "trialing", + ); +}; diff --git a/server/tests/integration/billing/stripe-webhooks/subscription-created/sub-created-auto-sync.test.ts b/server/tests/integration/billing/stripe-webhooks/subscription-created/sub-created-auto-sync.test.ts index 0ffa747bb..5d8aa48f0 100644 --- a/server/tests/integration/billing/stripe-webhooks/subscription-created/sub-created-auto-sync.test.ts +++ b/server/tests/integration/billing/stripe-webhooks/subscription-created/sub-created-auto-sync.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { type ApiCustomerV3, AppEnv, type FullCustomer } from "@autumn/shared"; +import { type ApiCustomerV3, AppEnv } from "@autumn/shared"; import { createStripeSubscriptionFromProduct, createStripeSubscriptionFromProducts, @@ -18,89 +18,10 @@ import { timeout } from "@tests/utils/genUtils"; import ctx from "@tests/utils/testInitUtils/createTestContext"; import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; import chalk from "chalk"; -import { handleStripeSubscriptionCreated } from "@/external/stripe/webhookHandlers/handleStripeSubscriptionCreated/handleStripeSubscriptionCreated"; -import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext"; import { CusService } from "@/internal/customers/CusService"; +import { CusProductService } from "@/internal/customers/cusProducts/CusProductService"; import { ProductService } from "@/internal/products/ProductService"; -const makeFullCustomer = ({ - subscriptionIds = [], -}: { - subscriptionIds?: string[]; -} = {}): FullCustomer => - ({ - id: "customer_external_id", - internal_id: "customer_internal_id", - customer_products: - subscriptionIds.length > 0 - ? [{ id: "customer_product_id", subscription_ids: subscriptionIds }] - : [], - }) as FullCustomer; - -const makeGuardrailContext = ({ - fullCustomer, - orgId = "org_123", - orgSlug = "org-slug", - subscriptionId = "sub_stripe_external", -}: { - fullCustomer?: FullCustomer; - orgId?: string; - orgSlug?: string; - subscriptionId?: string; -}): { - ctx: StripeWebhookContext; - retrieveCalls: string[]; -} => { - const retrieveCalls: string[] = []; - - return { - retrieveCalls, - ctx: { - db: "db", - org: { id: orgId, slug: orgSlug }, - env: AppEnv.Sandbox, - fullCustomer, - logger: { - error: () => undefined, - info: () => undefined, - warn: () => undefined, - }, - stripeCli: { - subscriptions: { - retrieve: async (stripeId: string) => { - retrieveCalls.push(stripeId); - throw new Error("Stripe retrieve should not be called"); - }, - }, - }, - stripeEvent: { - type: "customer.subscription.created", - data: { - object: { - id: subscriptionId, - customer: "cus_stripe_external", - }, - }, - }, - } as unknown as StripeWebhookContext, - }; -}; - -const withNodeEnv = async (nodeEnv: string, callback: () => Promise) => { - const originalNodeEnv = process.env.NODE_ENV; - process.env.NODE_ENV = nodeEnv; - - try { - return await callback(); - } finally { - if (originalNodeEnv === undefined) { - delete process.env.NODE_ENV; - } else { - process.env.NODE_ENV = originalNodeEnv; - } - } -}; - test(`${chalk.yellowBright("customer.subscription.created auto-sync: sync external Stripe sandbox sub")}`, async () => { const customerId = "sub-created-auto-sync"; @@ -153,37 +74,36 @@ test(`${chalk.yellowBright("customer.subscription.created auto-sync: sync extern }); test(`${chalk.yellowBright("customer.subscription.created auto-sync: skips unknown Autumn customer")}`, async () => { - const { ctx, retrieveCalls } = makeGuardrailContext({}); - - await handleStripeSubscriptionCreated({ ctx }); - - expect(retrieveCalls).toEqual([]); -}); - -test(`${chalk.yellowBright("customer.subscription.created auto-sync: skips already-linked subscription")}`, async () => { - const subscriptionId = "sub_already_linked"; - const { ctx, retrieveCalls } = makeGuardrailContext({ - subscriptionId, - fullCustomer: makeFullCustomer({ subscriptionIds: [subscriptionId] }), + const stripeCustomer = await ctx.stripeCli.customers.create({ + email: "sub-created-auto-sync-unknown@example.com", + }); + const stripeProduct = await ctx.stripeCli.products.create({ + name: "Sub Created Auto Sync Unknown Customer", + }); + const stripeSubscription = await ctx.stripeCli.subscriptions.create({ + customer: stripeCustomer.id, + items: [ + { + price_data: { + currency: "usd", + product: stripeProduct.id, + recurring: { interval: "month" }, + unit_amount: 4242, + }, + }, + ], + payment_behavior: "default_incomplete", }); - await handleStripeSubscriptionCreated({ ctx }); + await timeout(10000); - expect(retrieveCalls).toEqual([]); -}); - -test(`${chalk.yellowBright("customer.subscription.created auto-sync: production gate skips disabled org")}`, async () => { - const { ctx, retrieveCalls } = makeGuardrailContext({ - orgId: "org_sub_created_auto_sync_disabled", - orgSlug: "sub-created-auto-sync-disabled", - fullCustomer: makeFullCustomer(), + const linkedCusProducts = await CusProductService.getByStripeSubId({ + db: ctx.db, + stripeSubId: stripeSubscription.id, + orgId: ctx.org.id, + env: ctx.env, }); - - await withNodeEnv("production", async () => { - await handleStripeSubscriptionCreated({ ctx }); - }); - - expect(retrieveCalls).toEqual([]); + expect(linkedCusProducts).toEqual([]); }); test(`${chalk.yellowBright("customer.subscription.created auto-sync: skips Stripe sandbox sub with no product match")}`, async () => { diff --git a/server/tests/integration/billing/sync/to-sync-params/to-sync-params-basic.test.ts b/server/tests/integration/billing/sync/to-sync-params/to-sync-params-basic.test.ts new file mode 100644 index 000000000..c2c9cc549 --- /dev/null +++ b/server/tests/integration/billing/sync/to-sync-params/to-sync-params-basic.test.ts @@ -0,0 +1,436 @@ +/** + * subscriptionToSyncParams — basic detection cases + * + * Exercises the canonical "Stripe sub → SyncParamsV1" pipeline against a + * variety of subscription shapes. Each test: + * 1. Sets up an Autumn customer + a single Pro product + * 2. Creates a Stripe subscription with specific items + * 3. Calls `subscriptionToSyncParams` directly with the test ctx + * 4. Asserts via `expectSyncParamsCorrect` + `expectSubscriptionMatchCorrect` + * + * NOTE: Cases 3, 4, 5 codify desired contract — they exercise + * `feature_quantities` / custom-stripe-price-id propagation that may not be + * fully wired through `subscriptionToSyncParams` yet. Failures here are the + * spec for the next implementation pass. + */ + +import { test } from "bun:test"; +import { expectSubscriptionMatchCorrect } from "@tests/integration/billing/utils/sync/expectSubscriptionMatch"; +import { expectSyncParamsCorrect } from "@tests/integration/billing/utils/sync/expectSyncParams"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import ctx from "@tests/utils/testInitUtils/createTestContext"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { subscriptionToSyncParams } from "@/internal/billing/v2/actions/sync/subscriptionToSyncParams"; +import { createStripeSubscriptionFromProduct } from "../utils/syncTestUtils"; +import { + createStripeFixedPriceUnderProduct, + createStripeTieredPriceUnderProduct, + fetchFullProduct, + getBaseStripePriceId, + getPrepaidStripeProductId, + getProductStripeProductId, + getStripeCustomerId, +} from "../utils/syncProductHelpers"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// CASE 1: simple plan with base price, exact stripe_price_id match +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("to-sync-params: case 1 — base price exact match")}`, async () => { + const customerId = "to-sync-params-1-base"; + const pro = products.pro({ id: "pro", items: [] }); + + await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [], + }); + + const subscription = await createStripeSubscriptionFromProduct({ + ctx, + customerId, + productId: pro.id, + }); + + const { match, params } = await subscriptionToSyncParams({ + ctx, + customerId, + subscription, + }); + + expectSyncParamsCorrect({ + params, + customer_id: customerId, + stripe_subscription_id: subscription.id, + phases: [ + { + starts_at: "now", + plans: [{ plan_id: pro.id, quantity: 1, customize: null }], + }, + ], + }); + + expectSubscriptionMatchCorrect({ + match, + currentPhase: { + plans: [{ plan_id: pro.id, base_kind: "matched" }], + }, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// CASE 2: simple plan with custom base price under the same Stripe product +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("to-sync-params: case 2 — custom base price under same stripe product")}`, async () => { + const customerId = "to-sync-params-2-custom-base"; + const pro = products.pro({ id: "pro", items: [] }); + + await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [], + }); + + const fullProduct = await fetchFullProduct({ ctx, productId: pro.id }); + const stripeProductId = getProductStripeProductId({ fullProduct }); + const stripeCustomerId = await getStripeCustomerId({ ctx, customerId }); + + // Build a custom Stripe price ($50/mo) under Pro's Stripe product. + const customPrice = await createStripeFixedPriceUnderProduct({ + ctx, + stripeProductId, + unitAmount: 5000, + }); + + const subscription = await ctx.stripeCli.subscriptions.create({ + customer: stripeCustomerId, + items: [{ price: customPrice.id }], + }); + + const { match, params } = await subscriptionToSyncParams({ + ctx, + customerId, + subscription, + }); + + expectSyncParamsCorrect({ + params, + customer_id: customerId, + stripe_subscription_id: subscription.id, + phases: [ + { + starts_at: "now", + plans: [ + { + plan_id: pro.id, + customize: { + price: { + amount: 50, + interval: "month", + stripe_price_id: customPrice.id, + }, + }, + }, + ], + }, + ], + }); + + expectSubscriptionMatchCorrect({ + match, + currentPhase: { + plans: [{ plan_id: pro.id, base_kind: "custom" }], + }, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// CASE 3: plan with prepaid messages; sub omits the prepaid item → quantity 0 +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("to-sync-params: case 3 — prepaid messages initialized to quantity 0")}`, async () => { + const customerId = "to-sync-params-3-prepaid-zero"; + const pro = products.pro({ + id: "pro", + items: [items.prepaidMessages({ price: 10, billingUnits: 100 })], + }); + + await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [], + }); + + // Build a sub that ONLY uses Pro's base price — omit the prepaid item. + const fullProduct = await fetchFullProduct({ ctx, productId: pro.id }); + const baseStripePriceId = getBaseStripePriceId({ fullProduct }); + const stripeCustomerId = await getStripeCustomerId({ ctx, customerId }); + + const subscription = await ctx.stripeCli.subscriptions.create({ + customer: stripeCustomerId, + items: [{ price: baseStripePriceId }], + }); + + const { params } = await subscriptionToSyncParams({ + ctx, + customerId, + subscription, + }); + + expectSyncParamsCorrect({ + params, + customer_id: customerId, + stripe_subscription_id: subscription.id, + phases: [ + { + starts_at: "now", + plans: [ + { + plan_id: pro.id, + customize: null, + feature_quantities: [ + { feature_id: TestFeature.Messages, quantity: 0 }, + ], + }, + ], + }, + ], + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// CASE 4: plan with prepaid messages; sub has base + custom fixed price +// under prepaid Messages's stripe_product_id +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("to-sync-params: case 4 — custom fixed price under prepaid Messages stripe product")}`, async () => { + const customerId = "to-sync-params-4-prepaid-custom-fixed"; + const pro = products.pro({ + id: "pro", + items: [items.prepaidMessages({ price: 10, billingUnits: 100 })], + }); + + await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [], + }); + + const fullProduct = await fetchFullProduct({ ctx, productId: pro.id }); + const baseStripePriceId = getBaseStripePriceId({ fullProduct }); + const prepaidStripeProductId = getPrepaidStripeProductId({ fullProduct }); + const stripeCustomerId = await getStripeCustomerId({ ctx, customerId }); + + // Custom fixed Stripe price under the prepaid Messages product + const customPrepaidPrice = await createStripeFixedPriceUnderProduct({ + ctx, + stripeProductId: prepaidStripeProductId, + unitAmount: 1500, // $15/mo (vs. Autumn's $10 default) + }); + + const subscription = await ctx.stripeCli.subscriptions.create({ + customer: stripeCustomerId, + items: [{ price: baseStripePriceId }, { price: customPrepaidPrice.id }], + }); + + const { match, params } = await subscriptionToSyncParams({ + ctx, + customerId, + subscription, + }); + + expectSyncParamsCorrect({ + params, + customer_id: customerId, + stripe_subscription_id: subscription.id, + phases: [ + { + starts_at: "now", + plans: [ + { + plan_id: pro.id, + feature_quantities: [ + { + feature_id: TestFeature.Messages, + stripe_price_id: customPrepaidPrice.id, + }, + ], + }, + ], + }, + ], + }); + + expectSubscriptionMatchCorrect({ + match, + currentPhase: { + plans: [{ plan_id: pro.id }], + noUnmatchedItems: true, + }, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// CASE 5: plan with prepaid messages; sub has base + tiered Stripe price +// under prepaid Messages's stripe_product_id +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("to-sync-params: case 5 — tiered price under prepaid Messages stripe product")}`, async () => { + const customerId = "to-sync-params-5-prepaid-tiered"; + const pro = products.pro({ + id: "pro", + items: [items.prepaidMessages({ price: 10, billingUnits: 100 })], + }); + + await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [], + }); + + const fullProduct = await fetchFullProduct({ ctx, productId: pro.id }); + const baseStripePriceId = getBaseStripePriceId({ fullProduct }); + const prepaidStripeProductId = getPrepaidStripeProductId({ fullProduct }); + const stripeCustomerId = await getStripeCustomerId({ ctx, customerId }); + + // Custom tiered Stripe price under the prepaid Messages product + const customTieredPrice = await createStripeTieredPriceUnderProduct({ + ctx, + stripeProductId: prepaidStripeProductId, + tiersMode: "graduated", + tiers: [ + { up_to: 1000, unit_amount: 5 }, + { up_to: "inf", unit_amount: 2 }, + ], + }); + + const subscription = await ctx.stripeCli.subscriptions.create({ + customer: stripeCustomerId, + items: [{ price: baseStripePriceId }, { price: customTieredPrice.id }], + }); + + const { match, params } = await subscriptionToSyncParams({ + ctx, + customerId, + subscription, + }); + + expectSyncParamsCorrect({ + params, + customer_id: customerId, + stripe_subscription_id: subscription.id, + phases: [ + { + starts_at: "now", + plans: [ + { + plan_id: pro.id, + feature_quantities: [ + { + feature_id: TestFeature.Messages, + stripe_price_id: customTieredPrice.id, + }, + ], + }, + ], + }, + ], + }); + + expectSubscriptionMatchCorrect({ + match, + currentPhase: { + plans: [{ plan_id: pro.id }], + noUnmatchedItems: true, + }, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// CASE 6: Pro + add-on; add-on Stripe item has quantity 2 +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("to-sync-params: case 6 — add-on with stripe quantity 2")}`, async () => { + const customerId = "to-sync-params-6-addon-quantity"; + const pro = products.pro({ id: "pro", items: [] }); + const addOn = products.recurringAddOn({ id: "addon", items: [] }); + + await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, addOn] }), + ], + actions: [], + }); + + const proFull = await fetchFullProduct({ ctx, productId: pro.id }); + const addOnFull = await fetchFullProduct({ ctx, productId: addOn.id }); + const proStripePriceId = getBaseStripePriceId({ fullProduct: proFull }); + const addOnStripePriceId = getBaseStripePriceId({ + fullProduct: addOnFull, + }); + const stripeCustomerId = await getStripeCustomerId({ ctx, customerId }); + + // Subscribe to Pro (qty 1) + the add-on at quantity 2. + const subscription = await ctx.stripeCli.subscriptions.create({ + customer: stripeCustomerId, + items: [ + { price: proStripePriceId }, + { price: addOnStripePriceId, quantity: 2 }, + ], + }); + + const { match, params } = await subscriptionToSyncParams({ + ctx, + customerId, + subscription, + }); + + console.log("Params:", JSON.stringify(params, null, 2)); + + expectSyncParamsCorrect({ + params, + customer_id: customerId, + stripe_subscription_id: subscription.id, + phases: [ + { + starts_at: "now", + plans: [ + { plan_id: pro.id, quantity: 1 }, + { plan_id: addOn.id, quantity: 2 }, + ], + }, + ], + }); + + expectSubscriptionMatchCorrect({ + match, + currentPhase: { + plans: [ + { plan_id: pro.id, base_kind: "matched" }, + { plan_id: addOn.id, base_kind: "matched" }, + ], + noUnmatchedItems: true, + }, + }); +}); diff --git a/server/tests/integration/billing/sync/to-sync-params/to-sync-params-schedule.test.ts b/server/tests/integration/billing/sync/to-sync-params/to-sync-params-schedule.test.ts new file mode 100644 index 000000000..444c184c5 --- /dev/null +++ b/server/tests/integration/billing/sync/to-sync-params/to-sync-params-schedule.test.ts @@ -0,0 +1,323 @@ +/** + * subscriptionToSyncParams — schedule cases + * + * Exercises detection against Stripe subscription schedules where the live + * subscription (phase 0) plus future phases get folded into a multi-phase + * `SyncParamsV1`. Each test: + * 1. Sets up Autumn customer + product(s) + * 2. Creates a Stripe subscription schedule with multiple phases + * 3. Calls `subscriptionToSyncParams({ subscription, schedule })` + * 4. Asserts on `params.phases` + match-side `phaseMatches` + * + * Cases: + * A) One product (Pro), 3 phases, base price doubles each phase. Phase 0 + * uses Pro's catalog Stripe price (matched), phases 1+2 use custom + * Stripe prices under Pro's stripe_product_id. + * + * B) Two products. Phase 0 uses Pro's catalog price; phase 1 uses + * Premium's catalog price. + * + * C) Two products. Phase 0 uses Pro's catalog price; phase 1 uses a + * CUSTOM Stripe price under Premium's stripe_product_id. + */ + +import { test } from "bun:test"; +import { expectSubscriptionMatchCorrect } from "@tests/integration/billing/utils/sync/expectSubscriptionMatch"; +import { expectSyncParamsCorrect } from "@tests/integration/billing/utils/sync/expectSyncParams"; +import { products } from "@tests/utils/fixtures/products"; +import ctx from "@tests/utils/testInitUtils/createTestContext"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { subscriptionToSyncParams } from "@/internal/billing/v2/actions/sync/subscriptionToSyncParams"; +import { + createStripeFixedPriceUnderProduct, + createStripeSubscriptionSchedule, + fetchFullProduct, + getBaseStripePriceId, + getProductStripeProductId, +} from "../utils/syncProductHelpers"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// CASE A: Pro, 3 phases, base price doubles each phase ($20 → $40 → $80) +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("to-sync-params: schedule A — Pro, 3 phases doubling base")}`, async () => { + const customerId = "to-sync-params-sched-a"; + const pro = products.pro({ id: "pro", items: [] }); + + await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [], + }); + + const proFull = await fetchFullProduct({ ctx, productId: pro.id }); + const proStandardPriceId = getBaseStripePriceId({ fullProduct: proFull }); + const proStripeProductId = getProductStripeProductId({ + fullProduct: proFull, + }); + + // Custom Stripe prices under Pro's stripe product for phases 1 and 2 + const phase1Price = await createStripeFixedPriceUnderProduct({ + ctx, + stripeProductId: proStripeProductId, + unitAmount: 4000, // $40 + }); + const phase2Price = await createStripeFixedPriceUnderProduct({ + ctx, + stripeProductId: proStripeProductId, + unitAmount: 8000, // $80 + }); + + const { subscription, schedule } = await createStripeSubscriptionSchedule({ + ctx, + customerId, + phases: [ + { items: [{ price: proStandardPriceId }] }, + { items: [{ price: phase1Price.id }] }, + { items: [{ price: phase2Price.id }] }, + ], + }); + + const { match, params } = await subscriptionToSyncParams({ + ctx, + customerId, + subscription, + schedule, + }); + + expectSyncParamsCorrect({ + params, + customer_id: customerId, + stripe_subscription_id: subscription.id, + stripe_schedule_id: schedule.id, + phases: [ + { + starts_at: "now", + plans: [{ plan_id: pro.id, customize: null }], + }, + { + plans: [ + { + plan_id: pro.id, + customize: { + price: { + amount: 40, + interval: "month", + stripe_price_id: phase1Price.id, + }, + }, + }, + ], + }, + { + plans: [ + { + plan_id: pro.id, + customize: { + price: { + amount: 80, + interval: "month", + stripe_price_id: phase2Price.id, + }, + }, + }, + ], + }, + ], + }); + + expectSubscriptionMatchCorrect({ + match, + phaseMatches: [ + { + is_current: true, + plans: [{ plan_id: pro.id, base_kind: "matched" }], + noUnmatchedItems: true, + }, + { + is_current: false, + plans: [{ plan_id: pro.id, base_kind: "custom" }], + noUnmatchedItems: true, + }, + { + is_current: false, + plans: [{ plan_id: pro.id, base_kind: "custom" }], + noUnmatchedItems: true, + }, + ], + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// CASE B: schedule with phase 0 = Pro standard, phase 1 = Premium standard +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("to-sync-params: schedule B — Pro then Premium, both standard prices")}`, async () => { + const customerId = "to-sync-params-sched-b"; + const pro = products.pro({ id: "pro", items: [] }); + const premium = products.premium({ id: "premium", items: [] }); + + await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [], + }); + + const proFull = await fetchFullProduct({ ctx, productId: pro.id }); + const premiumFull = await fetchFullProduct({ + ctx, + productId: premium.id, + }); + const proPriceId = getBaseStripePriceId({ fullProduct: proFull }); + const premiumPriceId = getBaseStripePriceId({ fullProduct: premiumFull }); + + const { subscription, schedule } = await createStripeSubscriptionSchedule({ + ctx, + customerId, + phases: [ + { items: [{ price: proPriceId }] }, + { items: [{ price: premiumPriceId }] }, + ], + }); + + const { match, params } = await subscriptionToSyncParams({ + ctx, + customerId, + subscription, + schedule, + }); + + expectSyncParamsCorrect({ + params, + customer_id: customerId, + stripe_subscription_id: subscription.id, + stripe_schedule_id: schedule.id, + phases: [ + { + starts_at: "now", + plans: [{ plan_id: pro.id, customize: null }], + }, + { + plans: [{ plan_id: premium.id, customize: null }], + }, + ], + }); + + expectSubscriptionMatchCorrect({ + match, + phaseMatches: [ + { + is_current: true, + plans: [{ plan_id: pro.id, base_kind: "matched" }], + noUnmatchedItems: true, + }, + { + is_current: false, + plans: [{ plan_id: premium.id, base_kind: "matched" }], + noUnmatchedItems: true, + }, + ], + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// CASE C: phase 0 = Pro standard, phase 1 = custom price under Premium stripe product +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("to-sync-params: schedule C — Pro then custom-base under Premium")}`, async () => { + const customerId = "to-sync-params-sched-c"; + const pro = products.pro({ id: "pro", items: [] }); + const premium = products.premium({ id: "premium", items: [] }); + + await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [], + }); + + const proFull = await fetchFullProduct({ ctx, productId: pro.id }); + const premiumFull = await fetchFullProduct({ + ctx, + productId: premium.id, + }); + const proPriceId = getBaseStripePriceId({ fullProduct: proFull }); + const premiumStripeProductId = getProductStripeProductId({ + fullProduct: premiumFull, + }); + + // Custom Stripe price under Premium's stripe product (priority-3 match) + const customPremiumPrice = await createStripeFixedPriceUnderProduct({ + ctx, + stripeProductId: premiumStripeProductId, + unitAmount: 12000, // $120 — distinct from premium's catalog price + }); + + const { subscription, schedule } = await createStripeSubscriptionSchedule({ + ctx, + customerId, + phases: [ + { items: [{ price: proPriceId }] }, + { items: [{ price: customPremiumPrice.id }] }, + ], + }); + + const { match, params } = await subscriptionToSyncParams({ + ctx, + customerId, + subscription, + schedule, + }); + + expectSyncParamsCorrect({ + params, + customer_id: customerId, + stripe_subscription_id: subscription.id, + stripe_schedule_id: schedule.id, + phases: [ + { + starts_at: "now", + plans: [{ plan_id: pro.id, customize: null }], + }, + { + plans: [ + { + plan_id: premium.id, + customize: { + price: { + amount: 120, + interval: "month", + stripe_price_id: customPremiumPrice.id, + }, + }, + }, + ], + }, + ], + }); + + expectSubscriptionMatchCorrect({ + match, + phaseMatches: [ + { + is_current: true, + plans: [{ plan_id: pro.id, base_kind: "matched" }], + noUnmatchedItems: true, + }, + { + is_current: false, + plans: [{ plan_id: premium.id, base_kind: "custom" }], + noUnmatchedItems: true, + }, + ], + }); +}); diff --git a/server/tests/integration/billing/sync/utils/syncProductHelpers.ts b/server/tests/integration/billing/sync/utils/syncProductHelpers.ts new file mode 100644 index 000000000..9d79e432f --- /dev/null +++ b/server/tests/integration/billing/sync/utils/syncProductHelpers.ts @@ -0,0 +1,181 @@ +import { type FullProduct, isFixedPrice, isPrepaidPrice } from "@autumn/shared"; +import type { TestContext } from "@tests/utils/testInitUtils/createTestContext"; +import type Stripe from "stripe"; +import { CusService } from "@/internal/customers/CusService"; +import { ProductService } from "@/internal/products/ProductService"; + +export const fetchFullProduct = async ({ + ctx, + productId, +}: { + ctx: TestContext; + productId: string; +}): Promise => + ProductService.getFull({ + db: ctx.db, + idOrInternalId: productId, + orgId: ctx.org.id, + env: ctx.env, + }); + +export const getStripeCustomerId = async ({ + ctx, + customerId, +}: { + ctx: TestContext; + customerId: string; +}): Promise => { + const fullCustomer = await CusService.getFull({ + ctx, + idOrInternalId: customerId, + }); + const stripeCustomerId = fullCustomer.processor?.id; + if (!stripeCustomerId) { + throw new Error(`Customer ${customerId} has no Stripe customer ID`); + } + return stripeCustomerId; +}; + +export const getProductStripeProductId = ({ + fullProduct, +}: { + fullProduct: FullProduct; +}): string => { + const id = fullProduct.processor?.id; + if (!id) { + throw new Error(`Product ${fullProduct.id} has no processor.id`); + } + return id; +}; + +export const getBaseStripePriceId = ({ + fullProduct, +}: { + fullProduct: FullProduct; +}): string => { + const fixedPrice = fullProduct.prices.find((p) => isFixedPrice(p)); + if (!fixedPrice) { + throw new Error(`Product ${fullProduct.id} has no fixed (base) price`); + } + const id = fixedPrice.config.stripe_price_id; + if (!id) { + throw new Error(`Base price on ${fullProduct.id} has no stripe_price_id`); + } + return id; +}; + +/** Returns the Stripe product id used by the prepaid usage price. */ +export const getPrepaidStripeProductId = ({ + fullProduct, +}: { + fullProduct: FullProduct; +}): string => { + const prepaidPrice = fullProduct.prices.find((p) => isPrepaidPrice(p)); + if (!prepaidPrice) { + throw new Error(`Product ${fullProduct.id} has no prepaid price`); + } + const id = prepaidPrice.config.stripe_product_id; + if (!id) { + throw new Error( + `Prepaid price on ${fullProduct.id} has no stripe_product_id`, + ); + } + return id; +}; + +export const createStripeFixedPriceUnderProduct = async ({ + ctx, + stripeProductId, + unitAmount, + currency = "usd", + interval = "month", +}: { + ctx: TestContext; + stripeProductId: string; + unitAmount: number; + currency?: string; + interval?: "day" | "week" | "month" | "year"; +}): Promise => + ctx.stripeCli.prices.create({ + product: stripeProductId, + unit_amount: unitAmount, + currency, + recurring: { interval }, + }); + +export const createStripeTieredPriceUnderProduct = async ({ + ctx, + stripeProductId, + tiers, + tiersMode = "graduated", + currency = "usd", + interval = "month", +}: { + ctx: TestContext; + stripeProductId: string; + tiers: Stripe.PriceCreateParams.Tier[]; + tiersMode?: "graduated" | "volume"; + currency?: string; + interval?: "day" | "week" | "month" | "year"; +}): Promise => + ctx.stripeCli.prices.create({ + product: stripeProductId, + currency, + recurring: { interval, usage_type: "metered" }, + billing_scheme: "tiered", + tiers_mode: tiersMode, + tiers, + }); + +/** + * Create a Stripe subscription schedule with the supplied phases and return + * both the live subscription (running phase 0) and the schedule object. + * Each phase iterates once over its billing interval; the schedule releases + * after the final phase. + */ +export const createStripeSubscriptionSchedule = async ({ + ctx, + customerId, + phases, +}: { + ctx: TestContext; + customerId: string; + phases: { + items: { price: string; quantity?: number }[]; + iterations?: number; + }[]; +}): Promise<{ + subscription: Stripe.Subscription; + schedule: Stripe.SubscriptionSchedule; +}> => { + const stripeCustomerId = await getStripeCustomerId({ ctx, customerId }); + + const created = await ctx.stripeCli.subscriptionSchedules.create({ + customer: stripeCustomerId, + start_date: "now", + end_behavior: "release", + phases: phases.map((phase) => ({ + items: phase.items, + duration: { interval: "month", interval_count: phase.iterations ?? 1 }, + })), + }); + + const subscriptionId = + typeof created.subscription === "string" + ? created.subscription + : created.subscription?.id; + if (!subscriptionId) { + throw new Error( + `subscriptionSchedules.create did not return a subscription id (schedule ${created.id})`, + ); + } + + const [subscription, schedule] = await Promise.all([ + ctx.stripeCli.subscriptions.retrieve(subscriptionId), + ctx.stripeCli.subscriptionSchedules.retrieve(created.id, { + expand: ["phases.items.price"], + }), + ]); + + return { subscription, schedule }; +}; diff --git a/server/tests/integration/billing/utils/sync/expectSubscriptionMatch.ts b/server/tests/integration/billing/utils/sync/expectSubscriptionMatch.ts new file mode 100644 index 000000000..a2d193039 --- /dev/null +++ b/server/tests/integration/billing/utils/sync/expectSubscriptionMatch.ts @@ -0,0 +1,89 @@ +import { expect } from "bun:test"; +import type { + PlanBase, + SubscriptionMatch, +} from "@/internal/billing/v2/actions/sync/detect/types"; + +export type ExpectedMatchedPlan = { + plan_id?: string; + base_kind?: PlanBase["kind"]; +}; + +export type ExpectedPhaseMatch = { + plans: ExpectedMatchedPlan[]; + is_current?: boolean; + /** When true, asserts no Stripe items in this phase are unmatched. */ + noUnmatchedItems?: boolean; +}; + +export type ExpectedCurrentPhase = { + plans: ExpectedMatchedPlan[]; + /** When set, asserts no Stripe items in the current phase are unmatched. */ + noUnmatchedItems?: boolean; +}; + +const expectPhaseMatchCorrect = ({ + phase, + expected, +}: { + phase: SubscriptionMatch["phaseMatches"][number]; + expected: ExpectedPhaseMatch; +}) => { + if (expected.is_current !== undefined) { + expect(phase.is_current).toBe(expected.is_current); + } + + expect(phase.plans).toHaveLength(expected.plans.length); + + expected.plans.forEach((expectedPlan, planIndex) => { + const plan = phase.plans[planIndex]; + if (expectedPlan.plan_id !== undefined) { + expect(plan.product.id).toBe(expectedPlan.plan_id); + } + if (expectedPlan.base_kind !== undefined) { + expect(plan.base.kind).toBe(expectedPlan.base_kind); + } + }); + + if (expected.noUnmatchedItems) { + const unmatched = phase.item_diffs.filter((d) => d.match.kind === "none"); + expect(unmatched).toEqual([]); + } +}; + +/** + * Match-side counterpart to `expectSyncParamsCorrect` — asserts on the + * detection result (`SubscriptionMatch`). + * + * Pass `currentPhase` to assert against the current phase only. Pass + * `phaseMatches` to assert against every phase by index (the order matches + * `match.phaseMatches`). + */ +export const expectSubscriptionMatchCorrect = ({ + match, + currentPhase, + phaseMatches, +}: { + match: SubscriptionMatch; + currentPhase?: ExpectedCurrentPhase; + phaseMatches?: ExpectedPhaseMatch[]; +}) => { + if (currentPhase) { + const phase = match.phaseMatches.find((p) => p.is_current); + expect(phase).toBeDefined(); + expectPhaseMatchCorrect({ + phase: phase!, + expected: { + plans: currentPhase.plans, + noUnmatchedItems: currentPhase.noUnmatchedItems, + }, + }); + } + + if (phaseMatches) { + expect(match.phaseMatches).toHaveLength(phaseMatches.length); + phaseMatches.forEach((expected, i) => { + expectPhaseMatchCorrect({ phase: match.phaseMatches[i], expected }); + }); + } +}; diff --git a/server/tests/integration/billing/utils/sync/expectSyncParams.ts b/server/tests/integration/billing/utils/sync/expectSyncParams.ts new file mode 100644 index 000000000..6f7f50236 --- /dev/null +++ b/server/tests/integration/billing/utils/sync/expectSyncParams.ts @@ -0,0 +1,128 @@ +import { expect } from "bun:test"; +import type { SyncParamsV1 } from "@autumn/shared"; + +export type ExpectedPlan = { + plan_id: string; + quantity?: number; + internal_entity_id?: string; + expire_previous?: boolean; + customize?: + | null + | { + price?: + | null + | { + amount?: number; + interval?: string; + interval_count?: number; + stripe_price_id?: string; + }; + }; + feature_quantities?: { + feature_id: string; + quantity?: number; + stripe_price_id?: string; + }[]; +}; + +export type ExpectedPhase = { + /** Skip the assertion when undefined (useful for future-dated phases where the timestamp is dynamic). */ + starts_at?: number | "now"; + plans: ExpectedPlan[]; +}; + +const expectPlanCorrect = ({ + plan, + expected, +}: { + plan: NonNullable[number]["plans"][number]; + expected: ExpectedPlan; +}) => { + expect(plan.plan_id).toBe(expected.plan_id); + + if (expected.quantity !== undefined) { + expect(plan.quantity ?? 1).toBe(expected.quantity); + } + if (expected.internal_entity_id !== undefined) { + expect(plan.internal_entity_id).toBe(expected.internal_entity_id); + } + if (expected.expire_previous !== undefined) { + expect(plan.expire_previous).toBe(expected.expire_previous); + } + + if (expected.customize === undefined) { + // not asserted + } else if (expected.customize === null) { + expect(plan.customize).toBeUndefined(); + } else { + expect(plan.customize).toBeDefined(); + if (expected.customize.price === null) { + expect(plan.customize?.price).toBeNull(); + } else if (expected.customize.price !== undefined) { + expect(plan.customize?.price).toMatchObject(expected.customize.price); + } + } + + if (expected.feature_quantities !== undefined) { + expect(plan.feature_quantities).toBeDefined(); + for (const expectedFq of expected.feature_quantities) { + const actual = plan.feature_quantities?.find( + (fq) => fq.feature_id === expectedFq.feature_id, + ); + expect(actual).toBeDefined(); + if (expectedFq.quantity !== undefined) { + expect(actual?.quantity ?? 0).toBe(expectedFq.quantity); + } + if (expectedFq.stripe_price_id !== undefined) { + expect(actual?.stripe_price_id).toBe(expectedFq.stripe_price_id); + } + } + } +}; + +/** + * Verify a `SyncParamsV1` matches expected identity + phases + plans shape. + * + * Each `expected.phases[i]` asserts `starts_at` and per-plan fields. Fields + * that are `undefined` on the expected object are skipped. Pass + * `customize: null` to assert no customize was set. + */ +export const expectSyncParamsCorrect = ({ + params, + customer_id, + stripe_subscription_id, + stripe_schedule_id, + phases, +}: { + params: SyncParamsV1; + customer_id: string; + stripe_subscription_id?: string; + stripe_schedule_id?: string; + phases: ExpectedPhase[]; +}) => { + expect(params.customer_id).toBe(customer_id); + + if (stripe_subscription_id !== undefined) { + expect(params.stripe_subscription_id).toBe(stripe_subscription_id); + } + if (stripe_schedule_id !== undefined) { + expect(params.stripe_schedule_id).toBe(stripe_schedule_id); + } + + expect(params.phases).toBeDefined(); + expect(params.phases).toHaveLength(phases.length); + + phases.forEach((expectedPhase, phaseIndex) => { + const phase = params.phases![phaseIndex]; + if (expectedPhase.starts_at !== undefined) { + expect(phase.starts_at).toBe(expectedPhase.starts_at); + } + expect(phase.plans).toHaveLength(expectedPhase.plans.length); + expectedPhase.plans.forEach((expectedPlan, planIndex) => { + expectPlanCorrect({ + plan: phase.plans[planIndex], + expected: expectedPlan, + }); + }); + }); +}; diff --git a/server/tests/references/getCustomerV1Response.json b/server/tests/references/getCustomerV1Response.json index 34a7118ab..99c09a4c0 100644 --- a/server/tests/references/getCustomerV1Response.json +++ b/server/tests/references/getCustomerV1Response.json @@ -1,10 +1,10 @@ { "id": "get-cus-multi-ent-v2", - "created_at": 1776681093029, + "created_at": 1777974355227, "name": "get-cus-multi-ent-v2", "email": "get-cus-multi-ent-v2@example.com", "fingerprint": null, - "stripe_id": "cus_UMz5mvPwIP5PS3", + "stripe_id": "cus_USajNwvM0pax2M", "env": "sandbox", "metadata": {}, "send_email_receipts": false, @@ -15,13 +15,25 @@ "group": "get-cus-multi-ent-v2", "status": "active", "canceled_at": null, - "started_at": 1776681092000, + "started_at": 1777974354000, "is_default": false, "is_add_on": false, "version": 1, - "current_period_start": 1776681092000, - "current_period_end": 1779273092000, + "current_period_start": 1777974354000, + "current_period_end": 1780652754000, "items": [ + { + "type": "price", + "feature_id": null, + "feature": null, + "interval": "month", + "interval_count": 1, + "price": 20, + "display": { + "primary_text": "$20", + "secondary_text": "per month" + } + }, { "type": "feature", "feature_id": "dashboard", @@ -71,7 +83,7 @@ "group": "get-cus-multi-ent-v2", "status": "active", "canceled_at": null, - "started_at": 1776681113920, + "started_at": 1777974379436, "is_default": false, "is_add_on": false, "version": 1, @@ -92,7 +104,7 @@ "balance": 90, "usage": 10, "included_usage": 100, - "next_reset_at": 1779273092000, + "next_reset_at": 1780652754000, "overage_allowed": false, "breakdown": [ { @@ -101,7 +113,7 @@ "balance": 90, "usage": 10, "included_usage": 100, - "next_reset_at": 1779273092000, + "next_reset_at": 1780652754000, "expires_at": null, "overage_allowed": false } @@ -161,12 +173,12 @@ "product_ids": [ "cus-lvl_get-cus-multi-ent-v2" ], - "stripe_id": "in_1TOF756GVhH8pLcDfKcFgsHu", + "stripe_id": "in_1TTfYB6GVhvcrrMKf6xxH63K", "status": "paid", "total": 20, "currency": "usd", - "created_at": 1776681092000, - "hosted_invoice_url": "http://localhost:8080/invoices/hosted_invoice_url/inv_3CcNrtgUERTv8MnmpqncpHXIwRk" + "created_at": 1777974354000, + "hosted_invoice_url": "http://localhost:8080/invoices/hosted_invoice_url/inv_3DIfAkOjiIKZGi9svU7RnJrrSxy" } ] } \ No newline at end of file diff --git a/server/tests/references/getCustomerV2_1Response.json b/server/tests/references/getCustomerV2_1Response.json index bf2335477..cdc2578af 100644 --- a/server/tests/references/getCustomerV2_1Response.json +++ b/server/tests/references/getCustomerV2_1Response.json @@ -2,16 +2,16 @@ "id": "get-cus-multi-ent-v2", "name": "get-cus-multi-ent-v2", "email": "get-cus-multi-ent-v2@example.com", - "created_at": 1776681093029, + "created_at": 1777974355227, "fingerprint": null, - "stripe_id": "cus_UMz5mvPwIP5PS3", + "stripe_id": "cus_USajNwvM0pax2M", "env": "sandbox", "metadata": {}, "send_email_receipts": false, "billing_controls": {}, "subscriptions": [ { - "id": "cus_prod_3CcNrGiJGlmGt6kWD0DmRWURUQw", + "id": "cus_prod_3DIfA2BFN0oEtwT1ZjkWrehY58P", "plan_id": "cus-lvl_get-cus-multi-ent-v2", "auto_enable": false, "add_on": false, @@ -20,13 +20,13 @@ "canceled_at": null, "expires_at": null, "trial_ends_at": null, - "started_at": 1776681092000, - "current_period_start": 1776681092000, - "current_period_end": 1779273092000, + "started_at": 1777974354000, + "current_period_start": 1777974354000, + "current_period_end": 1780652754000, "quantity": 1 }, { - "id": "cus_prod_3CcNtXszM7noCNIdjl9H6gs8hdj", + "id": "cus_prod_3DIfCpfcDc0YS0zEcf1WvNNQvBp", "plan_id": "ent-prod_get-cus-multi-ent-v2", "auto_enable": false, "add_on": false, @@ -35,7 +35,7 @@ "canceled_at": null, "expires_at": null, "trial_ends_at": null, - "started_at": 1776681113920, + "started_at": 1777974379436, "current_period_start": null, "current_period_end": null, "quantity": 1 @@ -52,11 +52,11 @@ "unlimited": false, "overage_allowed": false, "max_purchase": null, - "next_reset_at": 1779273092000, + "next_reset_at": 1780652754000, "breakdown": [ { "object": "balance_breakdown", - "id": "cus_ent_3CcNrCvPuPFhzyhShn1g64uZeJ6", + "id": "cus_ent_3DIfA2PIor7pWp92N3fowXUmmue", "plan_id": "cus-lvl_get-cus-multi-ent-v2", "included_grant": 100, "prepaid_grant": 0, @@ -65,7 +65,7 @@ "unlimited": false, "reset": { "interval": "month", - "resets_at": 1779273092000 + "resets_at": 1780652754000 }, "price": null, "expires_at": null, @@ -89,23 +89,24 @@ "flags": { "dashboard": { "object": "flag", - "id": "cus_ent_3CcNrCFvKwLC3y0XoUiLazztjwy", + "id": "cus_ent_3DIfA2SgxKclfL3Px24NWnwwdag", "plan_id": "cus-lvl_get-cus-multi-ent-v2", "expires_at": null, "feature_id": "dashboard" } }, + "config": {}, "invoices": [ { "plan_ids": [ "cus-lvl_get-cus-multi-ent-v2" ], - "stripe_id": "in_1TOF756GVhH8pLcDfKcFgsHu", + "stripe_id": "in_1TTfYB6GVhvcrrMKf6xxH63K", "status": "paid", "total": 20, "currency": "usd", - "created_at": 1776681092000, - "hosted_invoice_url": "http://localhost:8080/invoices/hosted_invoice_url/inv_3CcNrtgUERTv8MnmpqncpHXIwRk" + "created_at": 1777974354000, + "hosted_invoice_url": "http://localhost:8080/invoices/hosted_invoice_url/inv_3DIfAkOjiIKZGi9svU7RnJrrSxy" } ] } \ No newline at end of file diff --git a/server/tests/references/getCustomerV2_2Response.json b/server/tests/references/getCustomerV2_2Response.json index bf2335477..cdc2578af 100644 --- a/server/tests/references/getCustomerV2_2Response.json +++ b/server/tests/references/getCustomerV2_2Response.json @@ -2,16 +2,16 @@ "id": "get-cus-multi-ent-v2", "name": "get-cus-multi-ent-v2", "email": "get-cus-multi-ent-v2@example.com", - "created_at": 1776681093029, + "created_at": 1777974355227, "fingerprint": null, - "stripe_id": "cus_UMz5mvPwIP5PS3", + "stripe_id": "cus_USajNwvM0pax2M", "env": "sandbox", "metadata": {}, "send_email_receipts": false, "billing_controls": {}, "subscriptions": [ { - "id": "cus_prod_3CcNrGiJGlmGt6kWD0DmRWURUQw", + "id": "cus_prod_3DIfA2BFN0oEtwT1ZjkWrehY58P", "plan_id": "cus-lvl_get-cus-multi-ent-v2", "auto_enable": false, "add_on": false, @@ -20,13 +20,13 @@ "canceled_at": null, "expires_at": null, "trial_ends_at": null, - "started_at": 1776681092000, - "current_period_start": 1776681092000, - "current_period_end": 1779273092000, + "started_at": 1777974354000, + "current_period_start": 1777974354000, + "current_period_end": 1780652754000, "quantity": 1 }, { - "id": "cus_prod_3CcNtXszM7noCNIdjl9H6gs8hdj", + "id": "cus_prod_3DIfCpfcDc0YS0zEcf1WvNNQvBp", "plan_id": "ent-prod_get-cus-multi-ent-v2", "auto_enable": false, "add_on": false, @@ -35,7 +35,7 @@ "canceled_at": null, "expires_at": null, "trial_ends_at": null, - "started_at": 1776681113920, + "started_at": 1777974379436, "current_period_start": null, "current_period_end": null, "quantity": 1 @@ -52,11 +52,11 @@ "unlimited": false, "overage_allowed": false, "max_purchase": null, - "next_reset_at": 1779273092000, + "next_reset_at": 1780652754000, "breakdown": [ { "object": "balance_breakdown", - "id": "cus_ent_3CcNrCvPuPFhzyhShn1g64uZeJ6", + "id": "cus_ent_3DIfA2PIor7pWp92N3fowXUmmue", "plan_id": "cus-lvl_get-cus-multi-ent-v2", "included_grant": 100, "prepaid_grant": 0, @@ -65,7 +65,7 @@ "unlimited": false, "reset": { "interval": "month", - "resets_at": 1779273092000 + "resets_at": 1780652754000 }, "price": null, "expires_at": null, @@ -89,23 +89,24 @@ "flags": { "dashboard": { "object": "flag", - "id": "cus_ent_3CcNrCFvKwLC3y0XoUiLazztjwy", + "id": "cus_ent_3DIfA2SgxKclfL3Px24NWnwwdag", "plan_id": "cus-lvl_get-cus-multi-ent-v2", "expires_at": null, "feature_id": "dashboard" } }, + "config": {}, "invoices": [ { "plan_ids": [ "cus-lvl_get-cus-multi-ent-v2" ], - "stripe_id": "in_1TOF756GVhH8pLcDfKcFgsHu", + "stripe_id": "in_1TTfYB6GVhvcrrMKf6xxH63K", "status": "paid", "total": 20, "currency": "usd", - "created_at": 1776681092000, - "hosted_invoice_url": "http://localhost:8080/invoices/hosted_invoice_url/inv_3CcNrtgUERTv8MnmpqncpHXIwRk" + "created_at": 1777974354000, + "hosted_invoice_url": "http://localhost:8080/invoices/hosted_invoice_url/inv_3DIfAkOjiIKZGi9svU7RnJrrSxy" } ] } \ No newline at end of file diff --git a/shared/api/billing/common/featureQuantity/featureQuantityParamsV0.ts b/shared/api/billing/common/featureQuantity/featureQuantityParamsV0.ts index 73a23f5a3..1f4c591a4 100644 --- a/shared/api/billing/common/featureQuantity/featureQuantityParamsV0.ts +++ b/shared/api/billing/common/featureQuantity/featureQuantityParamsV0.ts @@ -11,6 +11,11 @@ export const FeatureQuantityParamsV0Schema = z adjustable: z.boolean().optional().meta({ description: "Whether the customer can adjust the quantity.", }), + stripe_price_id: z.string().optional().meta({ + description: + "Stripe price id this prepaid feature is billed under. Set by sync flows when the Stripe sub references a price different from the catalog default.", + internal: true, + }), }) .meta({ title: "FeatureQuantity", diff --git a/shared/api/billing/common/index.ts b/shared/api/billing/common/index.ts index 7ebc6c8f9..9cfc436a8 100644 --- a/shared/api/billing/common/index.ts +++ b/shared/api/billing/common/index.ts @@ -13,6 +13,7 @@ export * from "./customLineItem"; export * from "./featureQuantity/featureQuantityParamsV0"; export * from "./featureQuantity/mappers/featureQuantityParamsToCusProductOptions"; export * from "./invoiceModeParams"; +export * from "./multi/index"; export * from "./redirectMode"; export * from "./refundLastPayment"; export * from "./transitionRules"; diff --git a/shared/api/billing/common/multi/index.ts b/shared/api/billing/common/multi/index.ts new file mode 100644 index 000000000..2b0df933e --- /dev/null +++ b/shared/api/billing/common/multi/index.ts @@ -0,0 +1 @@ +export * from "./multiPlanInstance"; diff --git a/shared/api/billing/common/multi/multiPlanInstance.ts b/shared/api/billing/common/multi/multiPlanInstance.ts new file mode 100644 index 000000000..a6a6b1dd3 --- /dev/null +++ b/shared/api/billing/common/multi/multiPlanInstance.ts @@ -0,0 +1,36 @@ +import { CustomizePlanV1Schema } from "@api/billing/common/customizePlan/customizePlanV1"; +import { FeatureQuantityParamsV0Schema } from "@api/billing/common/featureQuantity/featureQuantityParamsV0"; +import { z } from "zod/v4"; + +/** + * Per-plan attach intent shared across multi-plan billing actions + * (sync, multi-attach, create-schedule, future actions). + * + * Identifies one Autumn plan to attach plus optional per-plan overrides. + * Action-specific fields (entity binding, expire flags, subscription + * targeting, etc.) live on extending schemas. + */ +export const MultiPlanInstanceSchema = z + .object({ + plan_id: z.string().meta({ + description: "Autumn plan id to attach.", + }), + version: z.number().optional().meta({ + description: "Optional explicit plan version.", + }), + customize: CustomizePlanV1Schema.optional().meta({ + description: + "Override the plan's price, items, or free trial. Wins over anything detection inferred for the same plan.", + }), + feature_quantities: z.array(FeatureQuantityParamsV0Schema).optional().meta({ + description: + "Prepaid feature quantities to set on the resulting customer product.", + }), + }) + .meta({ + title: "MultiPlanInstance", + description: + "Per-plan attach intent shared across multi-plan billing actions.", + }); + +export type MultiPlanInstance = z.infer; diff --git a/shared/api/billing/index.ts b/shared/api/billing/index.ts index f14cfdaf4..5e138d2cf 100644 --- a/shared/api/billing/index.ts +++ b/shared/api/billing/index.ts @@ -20,9 +20,14 @@ export * from "./openBillingPortal/openBillingPortalResponse"; export * from "./setupPayment/setupPaymentParamsV0"; export * from "./setupPayment/setupPaymentParamsV1"; +// Restore +export * from "./restore/restoreParamsV1"; + // Sync export * from "./sync/syncParamsV0"; +export * from "./sync/syncParamsV1"; export * from "./sync/syncProposalsParamsV0"; +export * from "./sync/syncProposalsV2"; // Update Subscription export * from "./updateSubscription/previewUpdateSubscriptionResponse"; diff --git a/shared/api/billing/restore/restoreParamsV1.ts b/shared/api/billing/restore/restoreParamsV1.ts new file mode 100644 index 000000000..9d1fb9510 --- /dev/null +++ b/shared/api/billing/restore/restoreParamsV1.ts @@ -0,0 +1,28 @@ +import { z } from "zod/v4"; + +export const RestoreParamsV1Schema = z.object({ + customer_id: z.string().meta({ + description: + "Autumn customer whose Stripe state should be restored to match Autumn's customer_products.", + }), +}); + +export type RestoreParamsV1 = z.infer; + +export const RestoreSubscriptionResultSchema = z.object({ + stripe_subscription_id: z.string().nullable(), + stripe_schedule_id: z.string().nullable(), + sub_action: z.enum(["update", "noop"]), + schedule_action: z.enum(["update", "create", "noop"]), +}); + +export type RestoreSubscriptionResult = z.infer< + typeof RestoreSubscriptionResultSchema +>; + +export const RestoreResponseSchema = z.object({ + customer_id: z.string(), + restored: z.array(RestoreSubscriptionResultSchema), +}); + +export type RestoreResponse = z.infer; diff --git a/shared/api/billing/sync/syncParamsV1.ts b/shared/api/billing/sync/syncParamsV1.ts new file mode 100644 index 000000000..c6aa60ed2 --- /dev/null +++ b/shared/api/billing/sync/syncParamsV1.ts @@ -0,0 +1,110 @@ +import { MultiPlanInstanceSchema } from "@api/billing/common/multi/multiPlanInstance"; +import { z } from "zod/v4"; + +/** + * Per-plan sync intent. Extends the shared `MultiPlanInstance` with + * sync-specific overrides. + */ +export const SyncPlanInstanceSchema = MultiPlanInstanceSchema.extend({ + quantity: z.number().int().min(1).optional().meta({ + description: + "Number of customer product instances to create from this plan entry. Defaults to 1. Used to express add-ons with quantity > 1.", + }), + internal_entity_id: z.string().optional().meta({ + description: + "If set, the resulting customer product is bound to this entity.", + internal: true, + }), + expire_previous: z.boolean().optional().meta({ + description: + "If true, expire the customer's existing active customer product of the same plan family at sync time.", + }), +}); + +/** + * One phase of plan instances. Use `starts_at: "now"` for the immediate + * phase; numeric `starts_at` for future-dated phases. The first entry of + * a SyncParamsV1 `phases` array typically carries `"now"`. + */ +export const SyncPhaseSchema = z.object({ + starts_at: z.union([z.number(), z.literal("now")]).meta({ + description: + "Phase start (ms epoch) or the literal 'now' for the immediate phase.", + }), + plans: z.array(SyncPlanInstanceSchema).min(1).meta({ + description: "Plans to attach in this phase.", + }), +}); + +const isPhasesOrdered = ( + phases: z.infer[], +): boolean => { + let lastNumeric: number | null = null; + for (let i = 0; i < phases.length; i++) { + const entry = phases[i]; + if (!entry) continue; + // "now" is only allowed as the first entry + if (entry.starts_at === "now") { + if (i !== 0) return false; + continue; + } + if (lastNumeric !== null && entry.starts_at <= lastNumeric) return false; + lastNumeric = entry.starts_at; + } + return true; +}; + +/** + * Inputs to the sync-to-Autumn action. + * + * - `phases` omitted → pure auto-sync (run detection, use what it finds). + * - `phases` supplied → caller-specified plan instances; layered on top + * of detection. First entry with `starts_at: "now"` + * is the immediate phase. + * + * `acknowledge_warnings` whitelists detection warning types the caller + * accepts. Empty / omitted = strict (any detection warning fails). + */ +export const SyncParamsV1Schema = z + .object({ + customer_id: z.string().meta({ + description: "Autumn customer to sync into.", + }), + + stripe_subscription_id: z.string().optional().meta({ + description: "Stripe subscription to sync from.", + }), + stripe_schedule_id: z.string().optional().meta({ + description: + "Stripe subscription schedule to sync from (for future-dated schedules with no live subscription yet).", + }), + + phases: z.array(SyncPhaseSchema).optional().meta({ + description: + "Caller-supplied plan instances grouped by phase. Omit for pure auto-sync.", + }), + + acknowledge_warnings: z.array(z.string()).optional().meta({ + description: + "Detection warning types the caller accepts (e.g. 'extra_items_under_plan').", + }), + }) + .refine( + (d) => + d.stripe_subscription_id !== undefined || + d.stripe_schedule_id !== undefined, + { + message: + "Either stripe_subscription_id or stripe_schedule_id is required", + path: ["stripe_subscription_id"], + }, + ) + .refine((d) => !d.phases || isPhasesOrdered(d.phases), { + message: + "phases entries must be ordered: 'now' may only appear as the first entry, and remaining starts_at values must be strictly increasing", + path: ["phases"], + }); + +export type SyncPlanInstance = z.infer; +export type SyncPhase = z.infer; +export type SyncParamsV1 = z.infer; diff --git a/shared/api/billing/sync/syncProposalsV2.ts b/shared/api/billing/sync/syncProposalsV2.ts new file mode 100644 index 000000000..df1dfe097 --- /dev/null +++ b/shared/api/billing/sync/syncProposalsV2.ts @@ -0,0 +1,54 @@ +import { z } from "zod/v4"; +import type Stripe from "stripe"; +import { SyncPhaseSchema } from "./syncParamsV1"; + +export const SyncProposalsV2ParamsSchema = z.object({ + customer_id: z.string(), +}); + +export type SyncProposalsV2Params = z.infer; + +/** + * One sync proposal — fluid with `SyncParamsV1` so the frontend can edit + * the draft and pass it straight to `/billing.sync`. + * + * `phases` mirrors `SyncParamsV1.phases` exactly; `stripe_subscription_id` + * and `stripe_schedule_id` identify the Stripe object the proposal targets. + * + * Display-only extras are the raw Stripe objects (so the UI can render + * subscription items, schedules, etc. without re-fetching) plus the + * `already_linked_product_id` summary. + */ +const BaseSyncProposalV2Schema = z.object({ + stripe_subscription_id: z.string().optional(), + stripe_schedule_id: z.string().optional(), + phases: z.array(SyncPhaseSchema), + + /** Raw Stripe objects — type-cast on the consumer side. */ + stripe_subscription: z.unknown().nullable(), + stripe_schedule: z.unknown().nullable(), + + already_linked_product_id: z.string().nullable(), +}); + +export const SyncProposalV2Schema = BaseSyncProposalV2Schema; + +export type SyncProposalV2 = Omit< + z.infer, + "stripe_subscription" | "stripe_schedule" +> & { + stripe_subscription: Stripe.Subscription | null; + stripe_schedule: Stripe.SubscriptionSchedule | null; +}; + +export const SyncProposalsV2ResponseSchema = z.object({ + customer_id: z.string(), + proposals: z.array(SyncProposalV2Schema), +}); + +export type SyncProposalsV2Response = Omit< + z.infer, + "proposals" +> & { + proposals: SyncProposalV2[]; +}; diff --git a/shared/api/products/components/basePrice/basePrice.ts b/shared/api/products/components/basePrice/basePrice.ts index 541805e78..5bd473dce 100644 --- a/shared/api/products/components/basePrice/basePrice.ts +++ b/shared/api/products/components/basePrice/basePrice.ts @@ -31,6 +31,11 @@ export const BasePriceParamsSchema = BasePriceSchema.omit({ price_id: z.string().optional().meta({ internal: true, }), + stripe_price_id: z.string().optional().meta({ + description: + "Stripe price id this base price is billed under. Set by sync flows to capture the actual Stripe price when it differs from the catalog default.", + internal: true, + }), }) .meta({ title: "BasePrice", diff --git a/shared/models/billingModels/context/index.ts b/shared/models/billingModels/context/index.ts index 568e99162..6f1c9d788 100644 --- a/shared/models/billingModels/context/index.ts +++ b/shared/models/billingModels/context/index.ts @@ -3,4 +3,5 @@ export * from "./billingContext"; export * from "./billingContextOverride"; export * from "./createScheduleBillingContext"; export * from "./multiAttachBillingContext"; +export * from "./syncBillingContext"; export * from "./updateSubscriptionBillingContext"; diff --git a/shared/models/billingModels/context/syncBillingContext.ts b/shared/models/billingModels/context/syncBillingContext.ts new file mode 100644 index 000000000..60f4bb179 --- /dev/null +++ b/shared/models/billingModels/context/syncBillingContext.ts @@ -0,0 +1,45 @@ +import type Stripe from "stripe"; +import type { SyncParamsV1 } from "../../../api/billing/sync/syncParamsV1"; +import type { SyncPlanInstance } from "../../../api/billing/sync/syncParamsV1"; +import type { + FeatureOptions, + FullCusProduct, +} from "../../cusProductModels/cusProductModels"; +import type { FullCustomer } from "../../cusModels/fullCusModel"; +import type { Entitlement } from "../../productModels/entModels/entModels"; +import type { Price } from "../../productModels/priceModels/priceModels"; +import type { FullProduct } from "../../productModels/productModels"; + +export interface SyncProductContext { + plan: SyncPlanInstance; + fullProduct: FullProduct; + customPrices: Price[]; + customEntitlements: Entitlement[]; + featureQuantities: FeatureOptions[]; + /** Existing active cusProduct in the same product group, if `expire_previous` was set. */ + currentCustomerProduct?: FullCusProduct; +} + +export interface SyncPhaseContext { + /** Resolved phase start in ms epoch. `"now"` is materialized to `currentEpochMs`. */ + startsAt: number; + /** Resolved phase end in ms epoch — equals the next phase's `startsAt`, or null for the final phase. */ + endsAt: number | null; + productContexts: SyncProductContext[]; +} + +export interface SyncBillingContext { + customer_id: string; + fullCustomer: FullCustomer; + + stripeSubscription: Stripe.Subscription | null; + stripeSchedule: Stripe.SubscriptionSchedule | null; + + /** First phase if its `starts_at` was `"now"`, else null. */ + immediatePhase: SyncPhaseContext | null; + /** Remaining phases (or all phases if there is no immediate phase). */ + futurePhases: SyncPhaseContext[]; + + currentEpochMs: number; + acknowledgedWarnings: NonNullable; +} diff --git a/shared/utils/productUtils/convertProductUtils.ts b/shared/utils/productUtils/convertProductUtils.ts index f52d550be..1ac96c9a7 100644 --- a/shared/utils/productUtils/convertProductUtils.ts +++ b/shared/utils/productUtils/convertProductUtils.ts @@ -5,7 +5,10 @@ import type { EntitlementWithFeature, } from "../../models/productModels/entModels/entModels.js"; import type { Price } from "../../models/productModels/priceModels/priceModels.js"; -import type { FullProduct } from "../../models/productModels/productModels.js"; +import type { + FullProduct, + Product, +} from "../../models/productModels/productModels.js"; export const entToPrice = ({ ent, @@ -69,6 +72,12 @@ export const entToOptions = ({ ); }; +export const productToStripeId = ({ + product, +}: { + product: Product | FullProduct; +}): string | null => product.processor?.id ?? null; + export const productToEnt = ({ product, featureId, diff --git a/vite/src/components/forms/attach-v2/components/AttachPlanSection.tsx b/vite/src/components/forms/attach-v2/components/AttachPlanSection.tsx index b599cf646..b53bf6445 100644 --- a/vite/src/components/forms/attach-v2/components/AttachPlanSection.tsx +++ b/vite/src/components/forms/attach-v2/components/AttachPlanSection.tsx @@ -57,6 +57,10 @@ export function AttachPlanSection({ onEditPlan: handleEditPlan, gateDeletedItemsByDiff: true, readOnly: hideEditButton, + adminIds: { + stripe_product_id: product.stripe_id ?? null, + internal_product_id: product.internal_id ?? null, + }, } as const; const titleContent = readOnly ? ( diff --git a/vite/src/components/forms/shared/PlanItemsSection.tsx b/vite/src/components/forms/shared/PlanItemsSection.tsx index f69a4e92c..7271307ae 100644 --- a/vite/src/components/forms/shared/PlanItemsSection.tsx +++ b/vite/src/components/forms/shared/PlanItemsSection.tsx @@ -58,6 +58,10 @@ export interface PlanItemsSectionProps { gateDeletedItemsByDiff?: boolean; readOnly?: boolean; + + adminIds?: import( + "@/components/forms/shared/admin/AdminPlanIdsTooltip" + ).AdminPlanIds; } export function PlanItemsSection({ @@ -76,6 +80,7 @@ export function PlanItemsSection({ trialConfig, gateDeletedItemsByDiff = false, readOnly = false, + adminIds, }: PlanItemsSectionProps) { const originalItemsMap = new Map( originalItems @@ -138,6 +143,7 @@ export function PlanItemsSection({ priceChange={priceChange} product={product} currency={currency} + adminIds={adminIds} /> { + if (!value) return null; + return ( +
+ + {label} + + {value} +
+ ); +}; + +/** + * Wraps a child with an admin-only hover tooltip showing identifying IDs + * for the displayed plan/price. No-op for non-admin users. + */ +export const AdminPlanIdsTooltip = ({ + children, + ids, +}: { + children: ReactNode; + ids: AdminPlanIds; +}) => { + const { isAdmin } = useAdmin(); + + const hasAnyId = Boolean( + ids.stripe_price_id || ids.stripe_product_id || ids.internal_product_id, + ); + + if (!isAdmin || !hasAnyId) { + return <>{children}; + } + + return ( + + {children} + + + + + + + ); +}; diff --git a/vite/src/components/forms/shared/plan-items/PlanPriceHeader.tsx b/vite/src/components/forms/shared/plan-items/PlanPriceHeader.tsx index 84848837c..cb0c8966f 100644 --- a/vite/src/components/forms/shared/plan-items/PlanPriceHeader.tsx +++ b/vite/src/components/forms/shared/plan-items/PlanPriceHeader.tsx @@ -1,4 +1,8 @@ import type { FrontendProduct } from "@autumn/shared"; +import { + AdminPlanIdsTooltip, + type AdminPlanIds, +} from "@/components/forms/shared/admin/AdminPlanIdsTooltip"; import { PriceDisplay } from "@/components/forms/update-subscription-v2/components/PriceDisplay"; interface PriceChange { @@ -13,10 +17,12 @@ export function PlanPriceHeader({ priceChange, product, currency, + adminIds, }: { priceChange?: PriceChange | null; product: FrontendProduct | undefined; currency: string; + adminIds?: AdminPlanIds; }) { const content = priceChange ? ( @@ -32,9 +38,17 @@ export function PlanPriceHeader({ ); + const wrapped = adminIds ? ( + + {content} + + ) : ( + content + ); + return (
- {content} + {wrapped}
); } diff --git a/vite/src/components/forms/update-subscription-v2/components/EditPlanSection.tsx b/vite/src/components/forms/update-subscription-v2/components/EditPlanSection.tsx index e86d8cedc..c64af9fb0 100644 --- a/vite/src/components/forms/update-subscription-v2/components/EditPlanSection.tsx +++ b/vite/src/components/forms/update-subscription-v2/components/EditPlanSection.tsx @@ -104,6 +104,10 @@ export function EditPlanSection() { currency={currency} onEditPlan={handleEditPlan} priceChange={priceChange} + adminIds={{ + stripe_product_id: product?.stripe_id ?? null, + internal_product_id: product?.internal_id ?? null, + }} /> ); diff --git a/vite/src/hooks/stores/useSheetStore.ts b/vite/src/hooks/stores/useSheetStore.ts index 78cbebc45..b098a4d44 100644 --- a/vite/src/hooks/stores/useSheetStore.ts +++ b/vite/src/hooks/stores/useSheetStore.ts @@ -23,6 +23,7 @@ export type SheetType = | "balance-create" | "invoice-detail" | "sync-stripe" + | "sync-stripe-v2" | "billing-auto-topup-add" | "billing-auto-topup-edit" | "billing-spend-limit-add" diff --git a/vite/src/views/customers2/components/sheets/SubscriptionDetailSheet.tsx b/vite/src/views/customers2/components/sheets/SubscriptionDetailSheet.tsx index 1f9bb6a49..1d5666095 100644 --- a/vite/src/views/customers2/components/sheets/SubscriptionDetailSheet.tsx +++ b/vite/src/views/customers2/components/sheets/SubscriptionDetailSheet.tsx @@ -48,10 +48,14 @@ function SubscriptionDetailItems({ items, product, prepaidDisplayQuantities, + adminIds, }: { items: ProductItem[]; product: FrontendProduct; prepaidDisplayQuantities: Record; + adminIds?: import( + "@/components/forms/shared/admin/AdminPlanIdsTooltip" + ).AdminPlanIds; }) { const sortedItems = useMemo(() => sortPlanItems({ items }), [items]); const { visibleItems, collapsedBooleanItems } = useMemo( @@ -80,7 +84,11 @@ function SubscriptionDetailItems({ return (
- +
@@ -139,6 +147,16 @@ export function SubscriptionDetailSheet() { prepaidItems, }); + const baseCustomerPrice = cusProduct.customer_prices?.find( + (cp: { price: { config?: { stripe_price_id?: string } } }) => + Boolean(cp.price?.config?.stripe_price_id), + ); + const adminIds = { + stripe_price_id: baseCustomerPrice?.price?.config?.stripe_price_id ?? null, + stripe_product_id: cusProduct.product?.processor?.id ?? null, + internal_product_id: cusProduct.product?.internal_id ?? null, + }; + const formatDate = (timestamp: number | null | undefined) => { if (!timestamp) return "—"; return format(new Date(timestamp), "MMM d, yyyy, HH:mm"); @@ -184,6 +202,7 @@ export function SubscriptionDetailSheet() { items={productV2.items} product={productV2} prepaidDisplayQuantities={prepaidDisplayQuantities} + adminIds={adminIds} /> )} diff --git a/vite/src/views/customers2/components/sync-stripe-v2/SubscriptionEditorView.tsx b/vite/src/views/customers2/components/sync-stripe-v2/SubscriptionEditorView.tsx new file mode 100644 index 000000000..35faaa64d --- /dev/null +++ b/vite/src/views/customers2/components/sync-stripe-v2/SubscriptionEditorView.tsx @@ -0,0 +1,541 @@ +import { + type FrontendProduct, + FreeTrialDuration, + productV2ToFrontendProduct, + type SyncParamsV1, + type SyncPhase, + type SyncPlanInstance, + type SyncProposalV2, +} from "@autumn/shared"; +import { + ArrowLeftIcon, + ArrowSquareOutIcon, + PlusIcon, +} from "@phosphor-icons/react"; +import { useMemo, useState } from "react"; +import type Stripe from "stripe"; +import { buildCustomize } from "@/components/forms/create-schedule/hooks/useCreateScheduleRequestBody"; +import { ConfigRow } from "@/components/forms/shared/ConfigRow"; +import { + getProductWithSupportedPlanFormValues, + getSupportedPlanFormPatchFromDraftProduct, +} from "@/components/forms/shared/utils/planCustomizationUtils"; +import { Switch } from "@/components/ui/switch"; +import { Button } from "@/components/v2/buttons/Button"; +import { InlinePlanEditor } from "@/components/v2/inline-custom-plan-editor/InlinePlanEditor"; +import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery"; +import { useOrgStripeQuery } from "@/hooks/queries/useOrgStripeQuery"; +import { useProductsQuery } from "@/hooks/queries/useProductsQuery"; +import { useEnv } from "@/utils/envUtils"; +import { + getStripeConnectViewAsLink, + getStripeSubLink, +} from "@/utils/linkUtils"; +import { useAdmin } from "@/views/admin/hooks/useAdmin"; +import { useMasterStripeAccount } from "@/views/admin/hooks/useMasterStripeAccount"; +import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery"; +import { useCustomerContext } from "@/views/customers2/customer/CustomerContext"; +import { type DraftPlan, SyncPlanRow } from "./SyncPlanRow"; +import { applyCustomizeToProduct } from "./syncPlanRowUtils"; + +const generateKey = () => + `p_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + +type DisplayItem = { + key: string; + name: string; + priceLabel: string; +}; + +type PhaseSection = { + phase: SyncPhase; + displayItems: DisplayItem[]; +}; + +const formatPriceAmount = ({ + unitAmount, + billingScheme, +}: { + unitAmount?: number | null; + billingScheme?: string | null; +}): string => { + if (billingScheme === "tiered") return "tiered"; + if (unitAmount === null || unitAmount === undefined) return "—"; + return `$${(unitAmount / 100).toFixed(2)}`; +}; + +const itemsFromStripeSubscription = ({ + sub, +}: { + sub: Stripe.Subscription; +}): DisplayItem[] => + sub.items.data.map((item) => { + const product = item.price?.product; + const productName = + typeof product === "object" && product && "name" in product + ? (product as { name: string }).name + : (item.price?.id ?? "Unknown"); + return { + key: item.id, + name: productName, + priceLabel: formatPriceAmount({ + unitAmount: item.price?.unit_amount, + billingScheme: item.price?.billing_scheme, + }), + }; + }); + +const itemsFromSchedulePhase = ({ + phase, + phaseIndex, +}: { + phase: Stripe.SubscriptionSchedule.Phase; + phaseIndex: number; +}): DisplayItem[] => + phase.items.map((item, itemIndex) => { + const price = item.price as + | string + | (Stripe.Price & { product?: string | Stripe.Product }) + | undefined; + const expanded = typeof price === "object" ? price : null; + const priceId = typeof price === "string" ? price : (expanded?.id ?? ""); + const product = expanded?.product; + const productName = + typeof product === "object" && product && "name" in product + ? product.name + : priceId || "Unknown"; + return { + key: `${phaseIndex}:${itemIndex}`, + name: productName, + priceLabel: formatPriceAmount({ + unitAmount: expanded?.unit_amount, + billingScheme: expanded?.billing_scheme, + }), + }; + }); + +const formatPhaseStart = (startsAt: SyncPhase["starts_at"]): string => { + if (startsAt === "now") return "Starts now"; + return `Starts ${new Date(startsAt).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + })}`; +}; + +const findScheduleStartDateMs = ({ + phase, +}: { + phase: Stripe.SubscriptionSchedule.Phase; +}) => phase.start_date * 1000; + +const buildPhaseSections = ({ + proposal, +}: { + proposal: SyncProposalV2; +}): PhaseSection[] => { + const sub = proposal.stripe_subscription; + const schedule = proposal.stripe_schedule; + + return proposal.phases.map((phase): PhaseSection => { + // Map proposal phase → schedule phase by start_date when a schedule + // exists, since the backend filters out phases with zero plans and + // indices may not align. Fall back to subscription items for the + // current phase when no schedule is attached. + const matchingSchedulePhase = schedule + ? schedule.phases.find((schedulePhase) => { + if (phase.starts_at === "now") { + return findScheduleStartDateMs({ phase: schedulePhase }) <= + Date.now(); + } + return ( + findScheduleStartDateMs({ phase: schedulePhase }) === + phase.starts_at + ); + }) + : undefined; + + if (matchingSchedulePhase && schedule) { + const phaseIndex = schedule.phases.indexOf(matchingSchedulePhase); + return { + phase, + displayItems: itemsFromSchedulePhase({ + phase: matchingSchedulePhase, + phaseIndex, + }), + }; + } + + if (phase.starts_at === "now" && sub) { + return { phase, displayItems: itemsFromStripeSubscription({ sub }) }; + } + + return { phase, displayItems: [] }; + }); +}; + +const seedDraftPlansByPhase = ({ + proposal, +}: { + proposal: SyncProposalV2; +}): DraftPlan[][] => + proposal.phases.map((phase) => + phase.plans.map((plan) => ({ ...plan, _key: generateKey() })), + ); + +export function SubscriptionEditorView({ + proposal, + customerId, + onBack, + onSubmit, + isSubmitting, +}: { + proposal: SyncProposalV2; + customerId: string; + onBack: () => void; + onSubmit: (params: SyncParamsV1) => void; + isSubmitting: boolean; +}) { + const { products } = useProductsQuery(); + const { features } = useFeaturesQuery(); + const { customer } = useCusQuery(); + const entities = customer?.entities ?? []; + + const env = useEnv(); + const { stripeAccount } = useOrgStripeQuery(); + const { isAdmin } = useAdmin(); + const { masterStripeAccount } = useMasterStripeAccount(); + const { setIsInlineEditorOpen } = useCustomerContext(); + + const handleOpenStripe = () => { + const subId = proposal.stripe_subscription_id; + if (!subId) return; + const stripeAccountId = stripeAccount?.id; + const masterStripeAccountId = masterStripeAccount?.id; + const url = + isAdmin && masterStripeAccountId && stripeAccountId + ? getStripeConnectViewAsLink({ + masterAccountId: masterStripeAccountId, + connectedAccountId: stripeAccountId, + env, + path: `subscriptions/${subId}`, + }) + : getStripeSubLink({ + subscriptionId: subId, + env, + accountId: stripeAccountId, + }); + window.open(url, "_blank"); + }; + + const phaseSections = useMemo( + () => buildPhaseSections({ proposal }), + [proposal], + ); + const isMultiPhase = + (proposal.stripe_schedule?.phases.length ?? 0) > 1 && + phaseSections.length > 1; + + const [draftPlansByPhase, setDraftPlansByPhase] = useState( + () => seedDraftPlansByPhase({ proposal }), + ); + const [expirePrevious, setExpirePrevious] = useState(true); + const [editing, setEditing] = useState<{ + phaseIndex: number; + planIndex: number; + } | null>(null); + + const handlePlanChange = ( + phaseIndex: number, + planIndex: number, + next: DraftPlan, + ) => { + setDraftPlansByPhase((prev) => + prev.map((plans, idx) => { + if (idx !== phaseIndex) return plans; + const updated = [...plans]; + updated[planIndex] = next; + return updated; + }), + ); + }; + + const handleAddPlan = (phaseIndex: number) => { + setDraftPlansByPhase((prev) => + prev.map((plans, idx) => { + if (idx !== phaseIndex) return plans; + return [ + ...plans, + { + _key: generateKey(), + plan_id: "", + quantity: 1, + expire_previous: expirePrevious, + }, + ]; + }), + ); + }; + + const handleRemove = (phaseIndex: number, planIndex: number) => { + setDraftPlansByPhase((prev) => + prev.map((plans, idx) => { + if (idx !== phaseIndex) return plans; + return plans.filter((_, i) => i !== planIndex); + }), + ); + }; + + const handleStartCustomize = (phaseIndex: number, planIndex: number) => { + setEditing({ phaseIndex, planIndex }); + setIsInlineEditorOpen(true); + }; + + const handleCancelCustomize = () => { + setEditing(null); + setIsInlineEditorOpen(false); + }; + + const editingProduct: FrontendProduct | null = useMemo(() => { + if (editing === null || !products) return null; + const plan = draftPlansByPhase[editing.phaseIndex]?.[editing.planIndex]; + if (!plan?.plan_id) return null; + const productV2 = products.find((p) => p.id === plan.plan_id); + if (!productV2) return null; + + const baseProduct = productV2ToFrontendProduct({ product: productV2 }); + const customize = plan.customize; + if (!customize) return baseProduct; + + const customizedV2 = applyCustomizeToProduct({ + product: productV2, + customize, + }); + + return getProductWithSupportedPlanFormValues({ + baseProduct, + formValues: { + items: customizedV2.items, + version: undefined, + trialLength: null, + trialDuration: FreeTrialDuration.Day, + trialEnabled: false, + trialCardRequired: false, + }, + }); + }, [editing, draftPlansByPhase, products]); + + const handleCustomizeSave = (draftProduct: FrontendProduct) => { + if (editing === null || !products) { + handleCancelCustomize(); + return; + } + const plan = draftPlansByPhase[editing.phaseIndex]?.[editing.planIndex]; + if (!plan?.plan_id) { + handleCancelCustomize(); + return; + } + const productV2 = products.find((p) => p.id === plan.plan_id); + if (!productV2) { + handleCancelCustomize(); + return; + } + + const baseProduct = productV2ToFrontendProduct({ product: productV2 }); + const patch = getSupportedPlanFormPatchFromDraftProduct({ + baseProduct, + draftProduct, + }); + + const nextCustomize = patch.items + ? buildCustomize({ items: patch.items, features: features ?? [] }) + : undefined; + + const { phaseIndex, planIndex } = editing; + setDraftPlansByPhase((prev) => + prev.map((plans, idx) => { + if (idx !== phaseIndex) return plans; + const updated = [...plans]; + updated[planIndex] = { ...plan, customize: nextCustomize }; + return updated; + }), + ); + handleCancelCustomize(); + }; + + const handleSubmit = () => { + const phases: SyncPhase[] = phaseSections + .map((section, phaseIndex) => { + const validPlans = (draftPlansByPhase[phaseIndex] ?? []).filter((p) => + Boolean(p.plan_id), + ); + const planInstances: SyncPlanInstance[] = validPlans.map( + ({ _key: _ignore, ...rest }) => ({ + ...rest, + expire_previous: expirePrevious, + }), + ); + return { starts_at: section.phase.starts_at, plans: planInstances }; + }) + .filter((phase) => phase.plans.length > 0); + + if (phases.length === 0) return; + + const params: SyncParamsV1 = { + customer_id: customerId, + stripe_subscription_id: proposal.stripe_subscription_id, + stripe_schedule_id: proposal.stripe_schedule_id, + phases, + }; + onSubmit(params); + }; + + const totalPlanInstances = draftPlansByPhase + .flat() + .filter((p) => p.plan_id) + .reduce((acc, p) => acc + (p.quantity ?? 1), 0); + + return ( +
+
+ + +
+
Stripe subscription
+
+ + {proposal.stripe_subscription_id} + + {proposal.stripe_subscription_id && ( + + )} +
+
+ + {phaseSections.map((section, phaseIndex) => { + const phasePlans = draftPlansByPhase[phaseIndex] ?? []; + const usedPlanIds = new Set( + phasePlans.map((p) => p.plan_id).filter(Boolean) as string[], + ); + + return ( +
+ {isMultiPhase && ( +
+
+ Phase {phaseIndex + 1} +
+
+ {formatPhaseStart(section.phase.starts_at)} +
+
+ )} + + {section.displayItems.length > 0 && ( +
+
Subscription items
+
+ {section.displayItems.map((item) => ( +
+ {item.name} + {item.priceLabel} +
+ ))} +
+
+ )} + +
+
Autumn plans
+ {phasePlans.map((plan, planIndex) => ( + id !== plan.plan_id, + ), + ) + } + entities={entities} + onChange={(next) => + handlePlanChange(phaseIndex, planIndex, next) + } + onRemove={() => handleRemove(phaseIndex, planIndex)} + onCustomize={() => + handleStartCustomize(phaseIndex, planIndex) + } + /> + ))} + +
+
+ ); + })} + + setExpirePrevious(!!checked)} + /> + } + /> +
+ +
+ + +
+ + {editingProduct && ( + + )} +
+ ); +} diff --git a/vite/src/views/customers2/components/sync-stripe-v2/SubscriptionListView.tsx b/vite/src/views/customers2/components/sync-stripe-v2/SubscriptionListView.tsx new file mode 100644 index 000000000..f547c1fa1 --- /dev/null +++ b/vite/src/views/customers2/components/sync-stripe-v2/SubscriptionListView.tsx @@ -0,0 +1,204 @@ +import type { SyncProposalV2 } from "@autumn/shared"; +import { LinkIcon } from "@phosphor-icons/react"; +import type Stripe from "stripe"; +import SmallSpinner from "@/components/general/SmallSpinner"; +import { useProductsQuery } from "@/hooks/queries/useProductsQuery"; +import { cn } from "@/lib/utils"; + +const formatStripeCurrency = ({ + amount, + currency, +}: { + amount: number; + currency: string; +}): string => { + const dollars = amount / 100; + try { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: currency.toUpperCase(), + minimumFractionDigits: dollars % 1 === 0 ? 0 : 2, + maximumFractionDigits: 2, + }).format(dollars); + } catch { + return `${dollars.toFixed(2)} ${currency.toUpperCase()}`; + } +}; + +const formatItemPrice = ({ + price, +}: { + price: Stripe.Price | null | undefined; +}): string => { + if (!price) return "—"; + const currency = price.currency ?? "usd"; + + if (price.billing_scheme === "tiered") { + return price.tiers_mode === "volume" ? "Volume" : "Tiered"; + } + + const usageType = price.recurring?.usage_type; + if (usageType === "metered") { + if (price.unit_amount != null) { + return `${formatStripeCurrency({ amount: price.unit_amount, currency })}/unit (metered)`; + } + return "Metered usage"; + } + + if (price.unit_amount != null) { + return formatStripeCurrency({ amount: price.unit_amount, currency }); + } + + return "—"; +}; + +const getStripeProductName = ({ + product, +}: { + product: string | Stripe.Product | Stripe.DeletedProduct | undefined; +}): string | null => { + if (typeof product === "object" && product && "name" in product) { + return (product as { name: string }).name; + } + return null; +}; + +const StripeItemRow = ({ item }: { item: Stripe.SubscriptionItem }) => { + const price = item.price; + const productName = + getStripeProductName({ product: price?.product }) ?? "Item"; + const priceLabel = formatItemPrice({ price }); + const quantity = item.quantity ?? 1; + const showQuantity = quantity > 1 && price?.billing_scheme !== "tiered"; + + return ( +
+ {productName} +
+ {priceLabel} + {showQuantity && × {quantity}} +
+
+ ); +}; + +const ProposalCard = ({ + proposal, + onSelect, + productNamesById, +}: { + proposal: SyncProposalV2; + onSelect: () => void; + productNamesById: Record; +}) => { + const sub = proposal.stripe_subscription; + const isLinked = proposal.already_linked_product_id !== null; + const matchedPlans = proposal.phases[0]?.plans ?? []; + + return ( + + ); +}; + +export function SubscriptionListView({ + proposals, + isLoading, + error, + onSelect, +}: { + proposals: SyncProposalV2[]; + isLoading: boolean; + error: unknown; + onSelect: (stripeSubscriptionId: string) => void; +}) { + const { products } = useProductsQuery(); + const productNamesById = Object.fromEntries( + (products ?? []).map((p) => [p.id, p.name]), + ); + + return ( +
+ {isLoading && ( +
+ +
+ )} + {Boolean(error) && ( +
+ Failed to load Stripe subscriptions. +
+ )} + {!isLoading && !error && proposals.length === 0 && ( +
+ No Stripe subscriptions found for this customer. +
+ )} + {!isLoading && + proposals.map((proposal) => ( + + proposal.stripe_subscription_id && + onSelect(proposal.stripe_subscription_id) + } + productNamesById={productNamesById} + /> + ))} +
+ ); +} diff --git a/vite/src/views/customers2/components/sync-stripe-v2/SyncPlanRow.tsx b/vite/src/views/customers2/components/sync-stripe-v2/SyncPlanRow.tsx new file mode 100644 index 000000000..170efdfd3 --- /dev/null +++ b/vite/src/views/customers2/components/sync-stripe-v2/SyncPlanRow.tsx @@ -0,0 +1,290 @@ +import type { Entity, ProductV2, SyncPlanInstance } from "@autumn/shared"; +import { + BuildingsIcon, + PackageIcon, + PencilSimpleIcon, + PuzzlePieceIcon, + XIcon, +} from "@phosphor-icons/react"; +import { CheckIcon } from "lucide-react"; +import { useState } from "react"; +import { Badge } from "@/components/v2/badges/Badge"; +import { Button } from "@/components/v2/buttons/Button"; +import { Input } from "@/components/v2/inputs/Input"; +import { SearchableSelect } from "@/components/v2/selects/SearchableSelect"; +import { useOrg } from "@/hooks/common/useOrg"; +import { cn } from "@/lib/utils"; +import { + applyCustomizeToProduct, + getBasePriceLabel, +} from "./syncPlanRowUtils"; + +export type DraftPlan = SyncPlanInstance & { _key: string }; + +const CUSTOMER_LEVEL_VALUE = ""; + +const PriceLabel = ({ + label, + isCustom, +}: { + label: string; + isCustom: boolean; +}) => ( + + {label} + +); + +type EntityOption = Entity | null; + +const EntityScopeSubRow = ({ + entities, + scopeEntityId, + onChange, +}: { + entities: Entity[]; + scopeEntityId: string | undefined; + onChange: (entityId: string | undefined) => void; +}) => { + const entityOptions: EntityOption[] = [null, ...entities]; + return ( +
+ + value={scopeEntityId ?? CUSTOMER_LEVEL_VALUE} + onValueChange={(value) => + onChange(value === CUSTOMER_LEVEL_VALUE ? undefined : value) + } + options={entityOptions} + getOptionValue={(option) => + option === null + ? CUSTOMER_LEVEL_VALUE + : option.id || option.internal_id + } + getOptionLabel={(option) => + option === null ? "Customer-level" : option.name || option.id || "PENDING" + } + triggerClassName="w-full h-input" + placeholder="Select entity" + searchable + searchPlaceholder="Search entities..." + emptyText="No entities found" + renderValue={(option) => + option === null || option === undefined ? ( + Customer-level + ) : ( + + {option.name || option.id || "PENDING"} + + ) + } + renderOption={(option, isSelected) => { + if (option === null) { + return ( + <> + Customer-level + {isSelected && } + + ); + } + return ( + <> +
+ {option.name && ( + {option.name} + )} + + {option.id || "PENDING"} + +
+ {isSelected && } + + ); + }} + /> +
+ ); +}; + +export function SyncPlanRow({ + plan, + products, + usedPlanIds, + entities, + onChange, + onRemove, + onCustomize, +}: { + plan: DraftPlan; + products: ProductV2[]; + usedPlanIds: Set; + entities: Entity[]; + onChange: (plan: DraftPlan) => void; + onRemove: () => void; + onCustomize: () => void; +}) { + const { org } = useOrg(); + const currency = org?.default_currency ?? "USD"; + + const availableProducts = products.filter((p) => !p.archived); + const selectedProduct = products.find((p) => p.id === plan.plan_id); + const hasCustomize = Boolean(plan.customize); + const hasEntityScope = Boolean(plan.internal_entity_id); + + const [scopeOpen, setScopeOpen] = useState(hasEntityScope); + + if (!plan.plan_id) { + return ( + onChange({ ...plan, plan_id: value })} + options={availableProducts} + getOptionValue={(product) => product.id} + getOptionLabel={(product) => product.name} + getOptionDisabled={(product) => usedPlanIds.has(product.id)} + renderOption={(product) => ( + <> + {product.name} + {usedPlanIds.has(product.id) && ( + Already added + )} + + )} + placeholder="Select plan…" + searchable + searchPlaceholder="Search plans..." + emptyText="No plans found" + defaultOpen + /> + ); + } + + const isAddOn = selectedProduct?.is_add_on === true; + const customizedProduct = selectedProduct + ? applyCustomizeToProduct({ + product: selectedProduct, + customize: plan.customize, + }) + : null; + + const originalPriceLabel = selectedProduct + ? getBasePriceLabel({ product: selectedProduct, currency }) + : null; + const currentPriceLabel = customizedProduct + ? getBasePriceLabel({ product: customizedProduct, currency }) + : null; + const isPriceCustom = + hasCustomize && + originalPriceLabel !== null && + currentPriceLabel !== null && + originalPriceLabel !== currentPriceLabel; + + return ( +
+
+ {isAddOn ? ( + + ) : ( + + )} + + {selectedProduct?.name ?? plan.plan_id} + + + {isAddOn && ( + { + const next = Number.parseInt(e.target.value, 10); + onChange({ + ...plan, + quantity: Number.isFinite(next) && next >= 1 ? next : 1, + }); + }} + className="w-14 h-7 text-center text-xs" + /> + )} + +
+
+ {hasCustomize && ( + + Custom + + )} + {currentPriceLabel && ( + + )} +
+
+ {entities.length > 0 && ( + + )} + + +
+
+
+ + {entities.length > 0 && scopeOpen && ( + + onChange({ ...plan, internal_entity_id: entityId }) + } + /> + )} +
+ ); +} diff --git a/vite/src/views/customers2/components/sync-stripe-v2/SyncStripeSheetV2.tsx b/vite/src/views/customers2/components/sync-stripe-v2/SyncStripeSheetV2.tsx new file mode 100644 index 000000000..4cf45b07d --- /dev/null +++ b/vite/src/views/customers2/components/sync-stripe-v2/SyncStripeSheetV2.tsx @@ -0,0 +1,66 @@ +import { useState } from "react"; +import { SheetHeader } from "@/components/v2/sheets/SharedSheetComponents"; +import { useSheetStore } from "@/hooks/stores/useSheetStore"; +import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery"; +import { useSyncProposalsV2 } from "./hooks/useSyncProposalsV2"; +import { SubscriptionEditorView } from "./SubscriptionEditorView"; +import { SubscriptionListView } from "./SubscriptionListView"; + +export function SyncStripeSheetV2() { + const { customer, refetch: refetchCustomer } = useCusQuery(); + const closeSheet = useSheetStore((s) => s.closeSheet); + + const customerId = customer?.id ?? ""; + const { proposals, isLoading, error, syncMutation } = useSyncProposalsV2({ + customerId, + }); + + const [selectedSubscriptionId, setSelectedSubscriptionId] = useState< + string | null + >(null); + + const selectedProposal = selectedSubscriptionId + ? (proposals.find( + (p) => p.stripe_subscription_id === selectedSubscriptionId, + ) ?? null) + : null; + + const headerTitle = selectedProposal + ? "Configure sync" + : "Sync from Stripe"; + const headerDescription = selectedProposal + ? "Pick the Autumn plans to attach for this subscription" + : "Pick a Stripe subscription to import"; + + return ( +
+ + + {!selectedProposal && ( + + )} + + {selectedProposal && ( + setSelectedSubscriptionId(null)} + onSubmit={(params) => + syncMutation.mutate(params, { + onSuccess: () => { + refetchCustomer(); + closeSheet(); + }, + }) + } + isSubmitting={syncMutation.isPending} + /> + )} +
+ ); +} diff --git a/vite/src/views/customers2/components/sync-stripe-v2/hooks/useSyncProposalsV2.ts b/vite/src/views/customers2/components/sync-stripe-v2/hooks/useSyncProposalsV2.ts new file mode 100644 index 000000000..89a208430 --- /dev/null +++ b/vite/src/views/customers2/components/sync-stripe-v2/hooks/useSyncProposalsV2.ts @@ -0,0 +1,55 @@ +import type { SyncParamsV1, SyncProposalsV2Response } from "@autumn/shared"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { useQueryKeyFactory } from "@/hooks/common/useQueryKeyFactory"; +import { useAxiosInstance } from "@/services/useAxiosInstance"; +import { getBackendErr } from "@/utils/genUtils"; + +export const useSyncProposalsV2 = ({ + customerId, +}: { + customerId: string; +}) => { + const axiosInstance = useAxiosInstance(); + const queryKeyFactory = useQueryKeyFactory(); + const queryClient = useQueryClient(); + + const proposalsQuery = useQuery({ + queryKey: queryKeyFactory(["sync-proposals-v2", customerId]), + queryFn: async (): Promise => { + const { data } = await axiosInstance.post( + "/v1/billing.sync_proposals_v2", + { customer_id: customerId }, + ); + return data; + }, + enabled: Boolean(customerId), + }); + + const syncMutation = useMutation({ + mutationFn: async (params: SyncParamsV1) => { + const { data } = await axiosInstance.post( + "/v1/billing.sync_v2", + params, + ); + return data; + }, + onSuccess: () => { + toast.success("Stripe sync completed"); + queryClient.invalidateQueries({ + queryKey: queryKeyFactory(["customer"]), + }); + }, + onError: (error) => { + toast.error(getBackendErr(error, "Failed to sync from Stripe")); + }, + }); + + return { + proposals: proposalsQuery.data?.proposals ?? [], + isLoading: proposalsQuery.isLoading, + error: proposalsQuery.error, + refetch: proposalsQuery.refetch, + syncMutation, + }; +}; diff --git a/vite/src/views/customers2/components/sync-stripe-v2/syncPlanRowUtils.ts b/vite/src/views/customers2/components/sync-stripe-v2/syncPlanRowUtils.ts new file mode 100644 index 000000000..d3d594ceb --- /dev/null +++ b/vite/src/views/customers2/components/sync-stripe-v2/syncPlanRowUtils.ts @@ -0,0 +1,80 @@ +import { + type CustomizePlanV1, + formatAmount, + formatInterval, + isPriceItem, + type ProductItem, + type ProductV2, +} from "@autumn/shared"; + +/** + * Apply a `customize` block to a `ProductV2`, returning the effective + * product. Items override the full set when present; a base price override + * (`customize.price`) replaces the existing fixed price item or is + * appended. + */ +export const applyCustomizeToProduct = ({ + product, + customize, +}: { + product: ProductV2; + customize: CustomizePlanV1 | undefined; +}): ProductV2 => { + if (!customize) return product; + + let items: ProductItem[] = customize.items ?? product.items ?? []; + + if (customize.price !== undefined) { + if (customize.price === null) { + items = items.filter((item) => !isPriceItem(item)); + } else { + const newPriceItem: ProductItem = { + price: customize.price.amount, + interval: customize.price.interval, + interval_count: customize.price.interval_count ?? 1, + } as ProductItem; + const existingIndex = items.findIndex((item) => isPriceItem(item)); + if (existingIndex >= 0) { + items = items.map((item, i) => (i === existingIndex ? newPriceItem : item)); + } else { + items = [newPriceItem, ...items]; + } + } + } + + return { ...product, items }; +}; + +/** + * Format the base (fixed) price of a ProductV2 as a single-line label + * like "$20 per month" or "Free". + */ +export const getBasePriceLabel = ({ + product, + currency, +}: { + product: ProductV2; + currency: string; +}): string => { + const priceItem = product.items?.find((item) => isPriceItem(item)); + if (!priceItem || priceItem.price === 0 || priceItem.price === undefined) { + return "Free"; + } + + const formattedPrice = formatAmount({ + currency, + amount: priceItem.price ?? 0, + amountFormatOptions: { + style: "currency", + currencyDisplay: "narrowSymbol", + }, + }); + const intervalText = priceItem.interval + ? formatInterval({ + interval: priceItem.interval, + intervalCount: priceItem.interval_count ?? 1, + }) + : "one-off"; + + return `${formattedPrice} ${intervalText}`; +}; diff --git a/vite/src/views/customers2/customer/CustomerActions.tsx b/vite/src/views/customers2/customer/CustomerActions.tsx index 80e2fa366..3a73213aa 100644 --- a/vite/src/views/customers2/customer/CustomerActions.tsx +++ b/vite/src/views/customers2/customer/CustomerActions.tsx @@ -32,7 +32,7 @@ import { cn } from "@/lib/utils"; import { CusService } from "@/services/customers/CusService"; import { useAxiosInstance } from "@/services/useAxiosInstance"; import { useEnv } from "@/utils/envUtils"; -import { getBackendErr, notNullish } from "@/utils/genUtils"; +import { getBackendErr } from "@/utils/genUtils"; import { getRevenueCatCusLink, getStripeConnectViewAsLink, @@ -69,7 +69,7 @@ export function CustomerActions() { const stripeCustomerId = customer?.processor?.id; const stripeConnectViewAsCustomerLink = isAdmin && - notNullish(sessionData?.session?.impersonatedBy) && + // notNullish(sessionData?.session?.impersonatedBy) && masterStripeAccount?.id && stripeAccount?.id && stripeCustomerId @@ -174,6 +174,7 @@ export function CustomerActions() { Show customer object + {/* Old sync sheet — superseded by sync-stripe-v2 below. Kept for reference. {stripeCustomerId && customer?.processor?.type === ProcessorType.Stripe && ( )} + */} + {stripeCustomerId && + customer?.processor?.type === ProcessorType.Stripe && ( + { + setSheet({ type: "sync-stripe-v2" }); + setActionsOpen(false); + }} + className="flex gap-2" + > + + Sync from Stripe + + )} ; + case "sync-stripe-v2": + return ; case "billing-auto-topup-add": case "billing-auto-topup-edit": return ; diff --git a/vite/src/views/products/plan/components/plan-card/BasePriceDisplay.tsx b/vite/src/views/products/plan/components/plan-card/BasePriceDisplay.tsx index de216df35..76f991067 100644 --- a/vite/src/views/products/plan/components/plan-card/BasePriceDisplay.tsx +++ b/vite/src/views/products/plan/components/plan-card/BasePriceDisplay.tsx @@ -1,5 +1,9 @@ import type { FrontendProduct } from "@autumn/shared"; +import { + AdminPlanIdsTooltip, + type AdminPlanIds, +} from "@/components/forms/shared/admin/AdminPlanIdsTooltip"; import { Button } from "@/components/v2/buttons/Button"; import { useCurrentItem, @@ -14,10 +18,12 @@ export const BasePriceDisplay = ({ isOnboarding, product, readOnly = false, + adminIds, }: { isOnboarding?: boolean; product: FrontendProduct; readOnly?: boolean; + adminIds?: AdminPlanIds; }) => { const { sheetType, setSheet } = useSheet(); const { org } = useOrg(); @@ -67,7 +73,27 @@ export const BasePriceDisplay = ({ } }; - return ( + // readOnly: render a plain span so hover events reach the admin tooltip + // (the editable Button uses `pointer-events-none` on readOnly which would + // suppress hover detection). + if (readOnly) { + const content = ( + + {renderPriceContent()} + + ); + if (!adminIds) return content; + return ( + {content} + ); + } + + const button = ( ); + + if (!adminIds) return button; + return {button}; };