7.3 KiB
Multi-Attach Endpoint Implementation Plan
Overview
Implement a multiAttach billing endpoint that allows attaching multiple plans to a customer in a single request. Follows the V2 4-layer pattern (setup, compute, evaluate, execute). No transitions support (for now).
Files to Create
1. shared/api/billing/attachV2/multiAttachParamsV0.ts
Zod schema for the multi-attach request body.
Schema fields:
customer_id: stringentity_id?: stringplans: [{ plan_id, customize (no free_trial), feature_quantities?, version? }]— min 1 planfree_trial: FreeTrialParamsV1Schema.nullable().optional()— top-level onlyinvoice_mode?: InvoiceModeParamsSchemadiscounts?: AttachDiscountSchema[]success_url?: stringcheckout_session_params?: Record<string, unknown>redirect_mode: RedirectModeSchema.default("if_required")customer_data?: CustomerDataSchema(internal)entity_data?: EntityDataSchema(internal)
Per-plan customize uses a custom schema with just price and items (no free_trial, no refinement requiring at least one field — since it's optional). Import BasePriceParamsSchema and CreatePlanItemParamsV1Schema directly.
2. shared/models/billingModels/context/multiAttachBillingContext.ts
Type definition for multi-attach billing context.
import type { Entitlement, FeatureOptions, FullProduct, Price } from "@autumn/shared";
import { z } from "zod/v4";
import type { BillingContext } from "./billingContext";
import type { CheckoutMode } from "./attachBillingContext";
export interface MultiAttachProductContext {
fullProduct: FullProduct;
customPrices: Price[];
customEnts: Entitlement[];
featureQuantities: FeatureOptions[];
}
export interface MultiAttachBillingContext extends BillingContext {
productContexts: MultiAttachProductContext[];
checkoutMode: CheckoutMode;
}
No transition fields.
3. server/src/internal/billing/v2/actions/multiAttach/setup/setupMultiAttachCheckoutMode.ts
Simplified checkout mode for multi-attach.
Logic:
if redirect_mode === "never" → null
if has payment method AND redirect_mode === "always" → "stripe_checkout"
if has payment method AND redirect_mode === "if_required" → null
if no payment method → "stripe_checkout"
No "autumn_checkout" cases.
4. server/src/internal/billing/v2/actions/multiAttach/setup/setupMultiAttachTrialContext.ts
Simplified trial context — only uses top-level free_trial param.
If free_trial param provided → call handleFreeTrialParam with it (use first product for paid/recurring check).
If not → return undefined.
5. server/src/internal/billing/v2/actions/multiAttach/setup/setupMultiAttachBillingContext.ts
Full billing context assembly.
Steps:
setupFullCustomerContext— single callPromise.allover plans → for each:setupAttachProductContext(pass plan_id, customize, version) +setupFeatureQuantitiesContext- Single
setupStripeBillingContext(notargetCustomerProduct, no forced new subscription) setupMultiAttachTrialContext— top-level free_trial onlysetupBillingCycleAnchor,setupResetCycleAnchor(no currentCustomerProduct)setupMultiAttachCheckoutModesetupInvoiceModeContextsetupTransitionConfigs(pass empty-ish params since no transitions)- Assemble and return
MultiAttachBillingContext
For setupAttachProductContext, each plan item is mapped to AttachParamsV1 shape:
{ plan_id: plan.plan_id, customize: plan.customize, version: plan.version }
For setupFeatureQuantitiesContext, each plan's feature_quantities are resolved against its product.
6. server/src/internal/billing/v2/actions/multiAttach/compute/computeMultiAttachPlan.ts
Compute billing plan for all products.
For each product context, construct a temporary AttachBillingContext (spreading from the multi-attach context + per-plan fields):
attachProduct: productContext.fullProductcurrentCustomerProduct: undefinedscheduledCustomerProduct: undefinedplanTiming: "immediate"endOfCycleMs: undefinedcheckoutMode: multiAttachContext.checkoutModefeatureQuantities: productContext.featureQuantitiescustomPrices: productContext.customPricescustomEnts: productContext.customEnts
Call computeAttachNewCustomerProduct with each temporary context to get all new customer products.
Then call buildAutumnLineItems once with:
newCustomerProducts: [all new products]deletedCustomerProduct: undefinedincludeArrearLineItems: false
Build AutumnBillingPlan:
insertCustomerProducts: [all new customer products]updateCustomerProduct: undefineddeleteCustomerProduct: undefinedcustomPrices: merged from all planscustomEntitlements: merged from all planscustomFreeTrial: trialContext?.customFreeTriallineItems, updateCustomerEntitlementsfrom buildAutumnLineItems
Apply finalizeLineItems to handle trial line item filtering.
7. server/src/internal/billing/v2/actions/multiAttach/multiAttach.ts
Main orchestrator.
export async function multiAttach({ ctx, params }) {
// 1. Setup
const billingContext = await setupMultiAttachBillingContext({ ctx, params });
// 2. Compute
const autumnBillingPlan = computeMultiAttachPlan({ ctx, multiAttachBillingContext: billingContext });
// 3. Evaluate (reuse existing function)
const stripeBillingPlan = await evaluateStripeBillingPlan({
ctx,
billingContext,
autumnBillingPlan,
checkoutMode: billingContext.checkoutMode,
});
const billingPlan = { autumn: autumnBillingPlan, stripe: stripeBillingPlan };
// 4. Execute (reuse existing function)
const billingResult = await executeBillingPlan({ ctx, billingContext, billingPlan });
return { billingContext, billingPlan, billingResult };
}
No autumn_checkout. No preview support initially.
8. server/src/internal/billing/v2/handlers/handleMultiAttach.ts
Hono handler.
Uses createRoute with:
versionedBody: { latest: MultiAttachParamsV0Schema }resource: AffectedResource.MultiAttach- Lock:
lock:multi_attach:{orgId}:{env}:{customerId}with 120s TTL - Handler calls
billingActions.multiAttach(), thenbillingResultToResponse()
Files to Modify
9. shared/api/billing/index.ts
Add: export * from "./attachV2/multiAttachParamsV0";
10. shared/models/billingModels/ barrel exports
Export MultiAttachBillingContext and MultiAttachProductContext from the appropriate index file.
11. shared/api/versionUtils/versionChangeUtils/VersionChange.ts
Add MultiAttach = "multi_attach" to AffectedResource enum.
12. server/src/internal/billing/v2/actions/index.ts
Add multiAttach to billingActions object:
import { multiAttach } from "./multiAttach/multiAttach";
export const billingActions = {
attach,
multiAttach,
updateSubscription,
migrate,
legacy: { ... },
};
13. server/src/internal/billing/billingRouter.ts
Add route:
import { handleMultiAttach } from "./v2/handlers/handleMultiAttach.js";
billingRpcRouter.post("/billing.multi_attach", ...handleMultiAttach);
Implementation Order
- Shared types (schema + context type + exports + AffectedResource)
- Server setup functions (checkout mode, trial, billing context)
- Server compute function
- Server orchestrator
- Handler + route registration
- Lint check