199 lines
7.3 KiB
Markdown
199 lines
7.3 KiB
Markdown
# 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: string`
|
|
- `entity_id?: string`
|
|
- `plans: [{ plan_id, customize (no free_trial), feature_quantities?, version? }]` — min 1 plan
|
|
- `free_trial: FreeTrialParamsV1Schema.nullable().optional()` — top-level only
|
|
- `invoice_mode?: InvoiceModeParamsSchema`
|
|
- `discounts?: AttachDiscountSchema[]`
|
|
- `success_url?: string`
|
|
- `checkout_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.**
|
|
|
|
```typescript
|
|
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:
|
|
1. `setupFullCustomerContext` — single call
|
|
2. `Promise.all` over plans → for each: `setupAttachProductContext` (pass plan_id, customize, version) + `setupFeatureQuantitiesContext`
|
|
3. Single `setupStripeBillingContext` (no `targetCustomerProduct`, no forced new subscription)
|
|
4. `setupMultiAttachTrialContext` — top-level free_trial only
|
|
5. `setupBillingCycleAnchor`, `setupResetCycleAnchor` (no currentCustomerProduct)
|
|
6. `setupMultiAttachCheckoutMode`
|
|
7. `setupInvoiceModeContext`
|
|
8. `setupTransitionConfigs` (pass empty-ish params since no transitions)
|
|
9. Assemble and return `MultiAttachBillingContext`
|
|
|
|
For `setupAttachProductContext`, each plan item is mapped to `AttachParamsV1` shape:
|
|
```typescript
|
|
{ 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.fullProduct`
|
|
- `currentCustomerProduct: undefined`
|
|
- `scheduledCustomerProduct: undefined`
|
|
- `planTiming: "immediate"`
|
|
- `endOfCycleMs: undefined`
|
|
- `checkoutMode: multiAttachContext.checkoutMode`
|
|
- `featureQuantities: productContext.featureQuantities`
|
|
- `customPrices: productContext.customPrices`
|
|
- `customEnts: productContext.customEnts`
|
|
|
|
Call `computeAttachNewCustomerProduct` with each temporary context to get all new customer products.
|
|
|
|
Then call `buildAutumnLineItems` once with:
|
|
- `newCustomerProducts: [all new products]`
|
|
- `deletedCustomerProduct: undefined`
|
|
- `includeArrearLineItems: false`
|
|
|
|
Build `AutumnBillingPlan`:
|
|
- `insertCustomerProducts: [all new customer products]`
|
|
- `updateCustomerProduct: undefined`
|
|
- `deleteCustomerProduct: undefined`
|
|
- `customPrices: merged from all plans`
|
|
- `customEntitlements: merged from all plans`
|
|
- `customFreeTrial: trialContext?.customFreeTrial`
|
|
- `lineItems, updateCustomerEntitlements` from buildAutumnLineItems
|
|
|
|
Apply `finalizeLineItems` to handle trial line item filtering.
|
|
|
|
### 7. `server/src/internal/billing/v2/actions/multiAttach/multiAttach.ts`
|
|
**Main orchestrator.**
|
|
|
|
```typescript
|
|
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()`, then `billingResultToResponse()`
|
|
|
|
## 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:
|
|
```typescript
|
|
import { multiAttach } from "./multiAttach/multiAttach";
|
|
export const billingActions = {
|
|
attach,
|
|
multiAttach,
|
|
updateSubscription,
|
|
migrate,
|
|
legacy: { ... },
|
|
};
|
|
```
|
|
|
|
### 13. `server/src/internal/billing/billingRouter.ts`
|
|
Add route:
|
|
```typescript
|
|
import { handleMultiAttach } from "./v2/handlers/handleMultiAttach.js";
|
|
billingRpcRouter.post("/billing.multi_attach", ...handleMultiAttach);
|
|
```
|
|
|
|
## Implementation Order
|
|
1. Shared types (schema + context type + exports + AffectedResource)
|
|
2. Server setup functions (checkout mode, trial, billing context)
|
|
3. Server compute function
|
|
4. Server orchestrator
|
|
5. Handler + route registration
|
|
6. Lint check
|