From 0a28944996dc6b1fdc8943eaae5ea400a9d2bea6 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Fri, 6 Feb 2026 14:40:39 -0800 Subject: [PATCH] updated attach --- bun.lock | 18 ------ .../billing/v2/actions/attach/attach.ts | 2 + .../attach/compute/computeAttachPlan.ts | 9 ++- .../attach/compute/finalizeAttachPlan.ts | 16 ++++- .../handleAttachBillingBehaviorErrors.ts | 64 +++++++++++++++++++ .../attach/errors/handleAttachV2Errors.ts | 16 ++++- .../attach/setup/setupAttachCheckoutMode.ts | 4 ++ .../v2/execute/executeAutumnBillingPlan.ts | 2 +- .../upsertSubscriptionFromBilling.ts | 2 +- .../internal/products/prices/priceUtils.ts | 2 +- .../src/internal/subscriptions/SubService.ts | 55 ++++++++++++++-- shared/api/billing/attachV2/attachParamsV0.ts | 8 ++- 12 files changed, 166 insertions(+), 32 deletions(-) create mode 100644 server/src/internal/billing/v2/actions/attach/errors/handleAttachBillingBehaviorErrors.ts diff --git a/bun.lock b/bun.lock index 4936fc9f0..d13f0c657 100644 --- a/bun.lock +++ b/bun.lock @@ -55,17 +55,11 @@ "vite-tsconfig-paths": "^6.0.5", }, "devDependencies": { - "@eslint/js": "^9.39.1", "@types/node": "^24.10.1", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", - "eslint": "^9.39.1", - "eslint-plugin-react-hooks": "^7.0.1", - "eslint-plugin-react-refresh": "^0.4.24", - "globals": "^16.5.0", "typescript": "~5.9.3", - "typescript-eslint": "^8.46.4", "vite": "^7.2.4", }, }, @@ -2562,10 +2556,6 @@ "help-me": ["help-me@5.0.0", "", {}, "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg=="], - "hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="], - - "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], - "hono": ["hono@4.9.9", "", {}, "sha512-Hxw4wT6zjJGZJdkJzAx9PyBdf7ZpxaTSA0NfxqjLghwMrLBX8p33hJBzoETRakF3UJu6OdNQBZAlNSkGqKFukw=="], "hono-rate-limiter": ["hono-rate-limiter@0.4.2", "", { "peerDependencies": { "hono": "^4.1.1" } }, "sha512-AAtFqgADyrmbDijcRTT/HJfwqfvhalya2Zo+MgfdrMPas3zSMD8SU03cv+ZsYwRU1swv7zgVt0shwN059yzhjw=="], @@ -3780,8 +3770,6 @@ "zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], - "zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], - "zustand": ["zustand@5.0.11", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg=="], "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], @@ -4430,10 +4418,6 @@ "checkout/@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.2", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.53", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ=="], - "checkout/eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.0.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA=="], - - "checkout/globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="], - "checkout/react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], "checkout/react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], @@ -5094,8 +5078,6 @@ "checkout/@vitejs/plugin-react/react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], - "checkout/eslint-plugin-react-hooks/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], - "checkout/react-dom/scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "chevrotain-allstar/chevrotain/@chevrotain/cst-dts-gen": ["@chevrotain/cst-dts-gen@11.0.3", "", { "dependencies": { "@chevrotain/gast": "11.0.3", "@chevrotain/types": "11.0.3", "lodash-es": "4.17.21" } }, "sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ=="], diff --git a/server/src/internal/billing/v2/actions/attach/attach.ts b/server/src/internal/billing/v2/actions/attach/attach.ts index da04db1d6..bd97aab97 100644 --- a/server/src/internal/billing/v2/actions/attach/attach.ts +++ b/server/src/internal/billing/v2/actions/attach/attach.ts @@ -49,6 +49,7 @@ export async function attach({ const autumnBillingPlan = computeAttachPlan({ ctx, attachBillingContext: billingContext, + params, }); logAutumnBillingPlan({ ctx, plan: autumnBillingPlan, billingContext }); @@ -58,6 +59,7 @@ export async function attach({ ctx, billingContext, autumnBillingPlan, + params, }); // 4. Evaluate Stripe billing plan (handles checkout mode internally) diff --git a/server/src/internal/billing/v2/actions/attach/compute/computeAttachPlan.ts b/server/src/internal/billing/v2/actions/attach/compute/computeAttachPlan.ts index f35eab4bc..117c7f52e 100644 --- a/server/src/internal/billing/v2/actions/attach/compute/computeAttachPlan.ts +++ b/server/src/internal/billing/v2/actions/attach/compute/computeAttachPlan.ts @@ -1,4 +1,8 @@ -import type { AttachBillingContext, AutumnBillingPlan } from "@autumn/shared"; +import type { + AttachBillingContext, + AttachParamsV0, + AutumnBillingPlan, +} from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { buildAutumnLineItems } from "@/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems"; import { computeAttachNewCustomerProduct } from "./computeAttachNewCustomerProduct"; @@ -17,9 +21,11 @@ import { finalizeAttachPlan } from "./finalizeAttachPlan"; export const computeAttachPlan = ({ ctx, attachBillingContext, + params, }: { ctx: AutumnContext; attachBillingContext: AttachBillingContext; + params: AttachParamsV0; }): AutumnBillingPlan => { const { currentCustomerProduct, @@ -65,6 +71,7 @@ export const computeAttachPlan = ({ ctx, plan, attachBillingContext, + params, }); return plan; diff --git a/server/src/internal/billing/v2/actions/attach/compute/finalizeAttachPlan.ts b/server/src/internal/billing/v2/actions/attach/compute/finalizeAttachPlan.ts index 5cf1ef6cc..73ac3fecb 100644 --- a/server/src/internal/billing/v2/actions/attach/compute/finalizeAttachPlan.ts +++ b/server/src/internal/billing/v2/actions/attach/compute/finalizeAttachPlan.ts @@ -1,18 +1,25 @@ -import type { AttachBillingContext, AutumnBillingPlan } from "@autumn/shared"; +import type { + AttachBillingContext, + AttachParamsV0, + AutumnBillingPlan, +} from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { finalizeLineItems } from "@/internal/billing/v2/compute/finalize/finalizeLineItems"; /** - * Finalizes the attach billing plan by processing line items. + * Finalizes the attach billing plan by processing line items + * and applying attach-specific guards. */ export const finalizeAttachPlan = ({ ctx, plan, attachBillingContext, + params, }: { ctx: AutumnContext; plan: AutumnBillingPlan; attachBillingContext: AttachBillingContext; + params: AttachParamsV0; }): AutumnBillingPlan => { plan.lineItems = finalizeLineItems({ ctx, @@ -21,5 +28,10 @@ export const finalizeAttachPlan = ({ autumnBillingPlan: plan, }); + // Guard: if billing_behavior is 'next_cycle_only', clear line items (skip proration charges) + if (params.billing_behavior === "next_cycle_only") { + plan.lineItems = []; + } + return plan; }; diff --git a/server/src/internal/billing/v2/actions/attach/errors/handleAttachBillingBehaviorErrors.ts b/server/src/internal/billing/v2/actions/attach/errors/handleAttachBillingBehaviorErrors.ts new file mode 100644 index 000000000..e56fa5bcc --- /dev/null +++ b/server/src/internal/billing/v2/actions/attach/errors/handleAttachBillingBehaviorErrors.ts @@ -0,0 +1,64 @@ +import { + cusProductToPrices, + ErrCode, + isFreeProduct, + RecaseError, + type AttachBillingContext, + type AttachParamsV0, + type AutumnBillingPlan, +} from "@autumn/shared"; +import { getTrialStateTransition } from "@/internal/billing/v2/utils/billingContext/getTrialStateTransition"; + +/** + * Validates that billing_behavior: 'next_cycle_only' is not used + * in scenarios where deferring charges is invalid: + * 1. Free → Paid transitions (must charge immediately) + * 2. Trial → Non-trial transitions (removing a trial) + */ +export const handleAttachBillingBehaviorErrors = ({ + billingContext, + autumnBillingPlan, + params, +}: { + billingContext: AttachBillingContext; + autumnBillingPlan: AutumnBillingPlan; + params: AttachParamsV0; +}) => { + // Only validate when billing_behavior is 'next_cycle_only' (defer charges) + if (params.billing_behavior !== "next_cycle_only") return; + + // Check 1: Free -> Paid transition + const newCustomerProduct = autumnBillingPlan.insertCustomerProducts?.[0]; + if (newCustomerProduct && billingContext.currentCustomerProduct) { + const currentPrices = cusProductToPrices({ + cusProduct: billingContext.currentCustomerProduct, + }); + const newPrices = cusProductToPrices({ cusProduct: newCustomerProduct }); + + const currentIsFree = isFreeProduct({ prices: currentPrices }); + const newIsFree = isFreeProduct({ prices: newPrices }); + + if (currentIsFree && !newIsFree) { + throw new RecaseError({ + message: + "Cannot set billing_behavior to 'next_cycle_only' when upgrading from a free product to a paid product", + code: ErrCode.InvalidRequest, + statusCode: 400, + }); + } + } + + // Check 2: Trial -> Non-trial transition (removing trial) + const { isTrialing, willBeTrialing } = getTrialStateTransition({ + billingContext, + }); + + if (isTrialing && !willBeTrialing) { + throw new RecaseError({ + message: + "Cannot set billing_behavior to 'next_cycle_only' when removing a free trial", + code: ErrCode.InvalidRequest, + statusCode: 400, + }); + } +}; diff --git a/server/src/internal/billing/v2/actions/attach/errors/handleAttachV2Errors.ts b/server/src/internal/billing/v2/actions/attach/errors/handleAttachV2Errors.ts index 628203a35..c0d2d1b86 100644 --- a/server/src/internal/billing/v2/actions/attach/errors/handleAttachV2Errors.ts +++ b/server/src/internal/billing/v2/actions/attach/errors/handleAttachV2Errors.ts @@ -1,5 +1,10 @@ -import type { AttachBillingContext, AutumnBillingPlan } from "@autumn/shared"; +import type { + AttachBillingContext, + AttachParamsV0, + AutumnBillingPlan, +} from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { handleAttachBillingBehaviorErrors } from "@/internal/billing/v2/actions/attach/errors/handleAttachBillingBehaviorErrors"; import { handleAttachInvoiceModeErrors } from "@/internal/billing/v2/actions/attach/errors/handleAttachInvoiceModeErrors"; import { handleScheduledSwitchOneOffErrors } from "@/internal/billing/v2/actions/attach/errors/handleScheduledSwitchOneOffErrors"; import { handleStripeCheckoutErrors } from "@/internal/billing/v2/actions/attach/errors/handleStripeCheckoutErrors"; @@ -13,10 +18,12 @@ export const handleAttachV2Errors = ({ ctx, billingContext, autumnBillingPlan, + params, }: { ctx: AutumnContext; billingContext: AttachBillingContext; autumnBillingPlan: AutumnBillingPlan; + params: AttachParamsV0; }) => { // 1. External PSP errors (RevenueCat) handleExternalPSPErrors({ @@ -35,4 +42,11 @@ export const handleAttachV2Errors = ({ // 5. Transition config errors (reset_after_trial_end on allocated features) handleTransitionConfigErrors({ ctx, billingContext }); + + // 6. Billing behavior errors (next_cycle_only restrictions) + handleAttachBillingBehaviorErrors({ + billingContext, + autumnBillingPlan, + params, + }); }; diff --git a/server/src/internal/billing/v2/actions/attach/setup/setupAttachCheckoutMode.ts b/server/src/internal/billing/v2/actions/attach/setup/setupAttachCheckoutMode.ts index 4f9b58e4f..43b4e06bf 100644 --- a/server/src/internal/billing/v2/actions/attach/setup/setupAttachCheckoutMode.ts +++ b/server/src/internal/billing/v2/actions/attach/setup/setupAttachCheckoutMode.ts @@ -35,6 +35,10 @@ export const setupAttachCheckoutMode = ({ const productIsFree = isFreeProduct({ prices }); const productIsPaidRecurring = !productIsOneOff && !productIsFree; + if (redirectMode === "never") { + return null; + } + const getStripeCheckoutOrDirectBilling = () => { // A. if no payment method if (hasPaymentMethod) return null; diff --git a/server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts b/server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts index db59b5a6f..800c248c2 100644 --- a/server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts +++ b/server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts @@ -86,7 +86,7 @@ export const executeAutumnBillingPlan = async ({ // 6. Upsert subscription (if provided) if (autumnBillingPlan.upsertSubscription) { - await SubService.upsert({ + await SubService.upsertByStripeId({ db, subscription: autumnBillingPlan.upsertSubscription, }); diff --git a/server/src/internal/billing/v2/utils/upsertFromStripe/upsertSubscriptionFromBilling.ts b/server/src/internal/billing/v2/utils/upsertFromStripe/upsertSubscriptionFromBilling.ts index 5d43372f4..b03e82fa9 100644 --- a/server/src/internal/billing/v2/utils/upsertFromStripe/upsertSubscriptionFromBilling.ts +++ b/server/src/internal/billing/v2/utils/upsertFromStripe/upsertSubscriptionFromBilling.ts @@ -11,5 +11,5 @@ export const upsertSubscriptionFromBilling = async ({ stripeSubscription: Stripe.Subscription; }) => { const subscription = initSubscriptionFromStripe({ ctx, stripeSubscription }); - await SubService.upsert({ db: ctx.db, subscription }); + await SubService.upsertByStripeId({ db: ctx.db, subscription }); }; diff --git a/server/src/internal/products/prices/priceUtils.ts b/server/src/internal/products/prices/priceUtils.ts index 95220d897..6e8187da6 100644 --- a/server/src/internal/products/prices/priceUtils.ts +++ b/server/src/internal/products/prices/priceUtils.ts @@ -26,7 +26,7 @@ import { Decimal } from "decimal.js"; import { StatusCodes } from "http-status-codes"; import { compareBillingIntervals } from "./priceUtils/priceIntervalUtils.js"; -const constructPrice = ({ +export const constructPrice = ({ internalProductId, entitlementId, orgId, diff --git a/server/src/internal/subscriptions/SubService.ts b/server/src/internal/subscriptions/SubService.ts index 6f1244b3e..5df5dee4e 100644 --- a/server/src/internal/subscriptions/SubService.ts +++ b/server/src/internal/subscriptions/SubService.ts @@ -4,7 +4,6 @@ import { type Subscription, subscriptions, } from "@autumn/shared"; -import { buildConflictUpdateColumns } from "@server/db/dbUtils.js"; import type { DrizzleCli } from "@server/db/initDrizzle.js"; import { subToPeriodStartEnd } from "@server/external/stripe/stripeSubUtils/convertSubUtils.js"; import RecaseError from "@server/utils/errorUtils.js"; @@ -97,6 +96,22 @@ export class SubService { return updateResult[0] as Subscription; } + static async update({ + db, + subscriptionId, + updates, + }: { + db: DrizzleCli; + subscriptionId: string; + updates: Partial; + }) { + return await db + .update(subscriptions) + .set(updates) + .where(eq(subscriptions.id, subscriptionId)) + .returning(); + } + static async updateFromStripe({ db, stripeSub, @@ -183,17 +198,45 @@ export class SubService { .where(inArray(subscriptions.stripe_id, ids))) as Subscription[]; } - static async upsert({ + static async getByStripeId({ + db, + stripeId, + }: { + db: DrizzleCli; + stripeId: string; + }) { + return await db.query.subscriptions.findFirst({ + where: eq(subscriptions.stripe_id, stripeId), + }); + } + + static async upsertByStripeId({ db, subscription, }: { db: DrizzleCli; subscription: Subscription; }) { - const updateColumns = buildConflictUpdateColumns(subscriptions, ["id"]); - await db.insert(subscriptions).values(subscription).onConflictDoUpdate({ - target: subscriptions.stripe_id, - set: updateColumns, + // 1. Get by stripe ID + const existingSub = await SubService.getByStripeId({ + db, + stripeId: subscription.stripe_id ?? "", }); + + if (existingSub) { + return await SubService.update({ + db, + subscriptionId: existingSub.id, + updates: { + current_period_start: subscription.current_period_start, + current_period_end: subscription.current_period_end, + }, + }); + } else { + return await SubService.createSub({ + db, + sub: subscription, + }); + } } } diff --git a/shared/api/billing/attachV2/attachParamsV0.ts b/shared/api/billing/attachV2/attachParamsV0.ts index bc8155bdc..665bd4009 100644 --- a/shared/api/billing/attachV2/attachParamsV0.ts +++ b/shared/api/billing/attachV2/attachParamsV0.ts @@ -1,9 +1,10 @@ import { z } from "zod/v4"; import { PlanTimingSchema } from "../../../models/billingModels/context/attachBillingContext.js"; import { ProductItemSchema } from "../../../models/productV2Models/productItemModels/productItemModels.js"; +import { BillingBehaviorSchema } from "../common/billingBehavior.js"; import { BillingParamsBaseSchema } from "../common/billingParamsBase.js"; -export const RedirectModeSchema = z.enum(["always", "if_required"]); +export const RedirectModeSchema = z.enum(["always", "if_required", "never"]); export type RedirectMode = z.infer; export const ExtAttachParamsV0Schema = BillingParamsBaseSchema.extend({ @@ -22,6 +23,11 @@ export const ExtAttachParamsV0Schema = BillingParamsBaseSchema.extend({ new_billing_subscription: z.boolean().optional(), plan_schedule: PlanTimingSchema.optional(), + + // Billing behavior for attach operations (product transitions): + // - 'prorate_immediately' (default): Invoice line items are charged immediately + // - 'next_cycle_only': Do NOT create any charges due to the attach + billing_behavior: BillingBehaviorSchema.optional(), }); export const AttachParamsV0Schema = ExtAttachParamsV0Schema.extend({