updated attach
This commit is contained in:
18
bun.lock
18
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=="],
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user