updated attach

This commit is contained in:
John Yeo
2026-02-06 14:40:39 -08:00
parent 3f1be17034
commit 0a28944996
12 changed files with 166 additions and 32 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<typeof RedirectModeSchema>;
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({