diff --git a/.opencode/plans/billing-key-plan.md b/.opencode/plans/billing-key-plan.md new file mode 100644 index 000000000..dffcbe663 --- /dev/null +++ b/.opencode/plans/billing-key-plan.md @@ -0,0 +1,291 @@ +# Billing Key Implementation Plan + +## Overview + +Add a `billing_key` field to customer products that allows users to: +1. **Tag** customer products with a unique key at attach time +2. **Target** specific customer products in updateSubscription (and future operations) by billing_key +3. **View** billing_key on each subscription/purchase in the API response + +Additionally: +- Make `plan_id` truly optional in updateSubscription, with smart auto-resolution of the target customer product +- Stop merging subscriptions in V2.1 API responses (each customer product = its own entry) +- Apply the existing merge logic as a backwards-compatibility transform for V2.0 and older + +--- + +## Phase 1: Schema & Database Changes + +### 1A. Add `billing_key` column to Drizzle table +**File:** `shared/models/cusProductModels/cusProductTable.ts` + +- Add `billing_key: text("billing_key")` column to the `customerProducts` table definition +- No database-level unique constraint (enforce at application level for flexibility) + +### 1B. Add `billing_key` to CusProduct Zod schema +**File:** `shared/models/cusProductModels/cusProductModels.ts` + +- Add `billing_key: z.string().nullish()` to `CusProductSchema` +- This propagates to `FullCusProductSchema` automatically + +### 1C. Generate database migration +- Run `bun drizzle-kit generate` from `shared/` to create the migration SQL for the new column + +--- + +## Phase 2: API Input Params - Add `billing_key` to Attach/UpdateSubscription + +### 2A. V1 params (new billing endpoints) + +**File:** `shared/api/billing/common/billingParamsBase/billingParamsBaseV1.ts` +- Add `billing_key: z.string().optional()` to `BillingParamsBaseV1Schema` +- This automatically propagates to `AttachParamsV1Schema`, `UpdateSubscriptionV1ParamsSchema`, and `ExtUpdateSubscriptionV1ParamsSchema` + +### 2B. V0 params (legacy billing endpoints) + +**File:** `shared/api/billing/common/billingParamsBase/billingParamsBaseV0.ts` +- Add `billing_key: z.string().optional()` to `BillingParamsBaseV0Schema` +- This propagates to `AttachParamsV0Schema`, `ExtAttachParamsV0Schema`, `UpdateSubscriptionV0ParamsSchema`, `ExtUpdateSubscriptionV0ParamsSchema` + +### 2C. MultiAttach plan-level `billing_key` +**File:** `shared/api/billing/attachV2/multiAttachParamsV0.ts` +- Add `billing_key: z.string().optional()` to `MultiAttachPlanSchema` (per-plan level) +- This allows each plan in a multi-attach to have its own billing_key + +### 2D. V0→V1 param transforms +**File:** `shared/api/billing/attachV2/requestChanges/V1.2_AttachParamsChange.ts` +- Pass `billing_key` through in the `transformRequest` from V0→V1 (it already spreads `...input`, so it should pass through. Verify this works since `billing_key` is in the base schema for both V0 and V1) + +**File:** `shared/api/billing/updateSubscription/requestChanges/V1.2_UpdateSubscriptionParamsChange.ts` +- Same: verify `billing_key` passes through via `...input` spread + +--- + +## Phase 3: Flow `billing_key` Through Attach & MultiAttach + +### 3A. Add `billing_key` to `InitFullCustomerProductOptions` +**File:** `shared/models/billingModels/customerProduct/initFullCustomerProductContext.ts` +- Add `billingKey?: string` to `InitFullCustomerProductOptions` + +### 3B. Set `billing_key` in `initCustomerProduct` +**File:** `server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerProduct.ts` +- Destructure `billingKey` from `initOptions` +- Add `billing_key: billingKey ?? null` to the returned `CusProduct` object + +### 3C. Pass `billing_key` from attach compute to init +**File:** `server/src/internal/billing/v2/actions/attach/compute/computeAttachNewCustomerProduct.ts` +- The `billing_key` needs to flow from `params` through `billingContext` to `initOptions` +- Option: Store `billing_key` on the `AttachBillingContext` interface + +**File:** `shared/models/billingModels/context/attachBillingContext.ts` +- Add `billingKey?: string` to `AttachBillingContext` + +**File:** `server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts` +- Set `billingKey: params.billing_key` on the returned context + +**File:** `server/src/internal/billing/v2/actions/attach/compute/computeAttachNewCustomerProduct.ts` +- Pass `billingKey: attachBillingContext.billingKey` in `initOptions` + +### 3D. Pass `billing_key` through multi-attach +**File:** `shared/models/billingModels/context/multiAttachBillingContext.ts` +- Add `billingKey?: string` to `MultiAttachProductContext` + +**File:** `server/src/internal/billing/v2/actions/multiAttach/setup/setupMultiAttachBillingContext.ts` +- When iterating over `params.plans`, pass each plan's `billing_key` into the `MultiAttachProductContext` + +**File:** `server/src/internal/billing/v2/actions/multiAttach/compute/computeMultiAttachPlan.ts` (and wherever the per-product `initFullCustomerProduct` is called for multi-attach) +- Pass `billingKey` through to `initOptions` for each product context + +### 3E. Uniqueness validation for `billing_key` +- **Where:** In `setupAttachBillingContext` (and `setupMultiAttachBillingContext`) after loading the full customer +- **Logic:** Query `fullCustomer.customer_products` to check if any existing customer product for this `internal_customer_id` (regardless of entity) already has the same `billing_key` +- **Error:** Throw `RecaseError` with code `duplicate_billing_key` if a duplicate is found +- **Also check:** Within the multi-attach plans array itself (no two plans in the same request can have the same billing_key) + +--- + +## Phase 4: `billing_key` in UpdateSubscription Targeting + +### 4A. Make `plan_id` explicitly optional in Ext schemas +**File:** `shared/api/billing/updateSubscription/updateSubscriptionV1Params.ts` +- Move `plan_id: z.string().optional()` from `UpdateSubscriptionV1ParamsSchema` up to `ExtUpdateSubscriptionV1ParamsSchema` (it's already optional in the inner schema, but this makes it visible at the Ext level) + +**File:** `shared/api/billing/updateSubscription/updateSubscriptionV0Params.ts` +- `product_id` is already `z.string().nullish()` in `ExtUpdateSubscriptionV0ParamsSchema` — good, no change needed + +### 4B. Add `billing_key` to `UpdateSubscriptionV1ParamsSchema` +- `billing_key` already flows from `BillingParamsBaseV1Schema` (Phase 2A), so it's available +- In `UpdateSubscriptionV1ParamsSchema`, `billing_key` is available alongside `plan_id` and `customer_product_id` as targeting filters + +### 4C. Rewrite `findTargetCustomerProduct` +**File:** `server/src/internal/billing/v2/actions/updateSubscription/setup/findTargetCustomerProduct.ts` + +New logic: + +``` +1. Filter by entity scope first (all candidates must match the entity_id / customer scope) +2. If customer_product_id is provided → find by ID (highest priority) +3. If billing_key is provided → find by billing_key (+ entity scope) +4. If plan_id is provided → find by product.id === plan_id (+ entity scope) +5. If NONE of the above filters are provided → auto-resolve: + a. Determine intent: + - If cancel_action is set (cancel/uncancel) OR customize is set (custom plan) → "plan-level" intent + - If feature_quantities is set → "update-quantity" intent + b. Sort customer_products by priority: + i. Paid recurring main products + ii. Free recurring main products + iii. Recurring add-ons + iv. One-off products + Within each tier: sort by created_at descending (most recent first) + c. For "update-quantity" intent: additionally filter to only customer products that have ALL feature IDs from feature_quantities as prepaid features on the customer product + d. Return first match +``` + +Import helpers from `classifyCustomerProduct.ts`: `isCustomerProductMain`, `isCustomerProductAddOn`, `isCustomerProductPaidRecurring`, `isCustomerProductRecurring`, `isCustomerProductOneOff`, `isCusProductOnEntity` + +--- + +## Phase 5: API Response - Add `billing_key` and Stop Merging + +### 5A. Add `billing_key` to `ApiSubscriptionV1Schema` +**File:** `shared/api/customers/cusPlans/apiSubscriptionV1.ts` +- Add `billing_key: z.string().nullable()` to `ApiSubscriptionV1Schema` +- Add `billing_key: z.string().nullable()` to `ApiPurchaseV0Schema` + +### 5B. Set `billing_key` in `getApiSubscription` +**File:** `server/src/internal/customers/cusUtils/apiCusUtils/getApiSubscription/getApiSubscription.ts` +- Add `billing_key: cusProduct.billing_key ?? null` to the constructed `ApiSubscriptionV1` object + +### 5C. Stop merging subscriptions in V2.1 +**File:** `server/src/internal/customers/cusUtils/apiCusUtils/getApiSubscription/getApiSubscriptions.ts` +- Remove the `mergeSubscriptionsResponses` call for subscriptions and purchases +- Return `apiSubs` directly instead of `merged` +- Return `apiPurchasesAsSubscriptions` mapped to purchases directly instead of `mergedPurchasesAsSubscriptions` + +### 5D. Move merge logic to shared utility +- Extract `mergeSubscriptionsResponses` into a shared utility that can be used by both `V2.0_CustomerChange` and `V2.0_EntityChange` +- Good location: `shared/api/customers/cusPlans/mergeSubscriptionResponses.ts` or similar + +### 5E. Apply merge logic as backward-compat transform in `V2.0_CustomerChange` +**File:** `shared/api/customers/changes/V2.0_CustomerChange.ts` +- In the `transformResponse`, before splitting into active/scheduled, apply the merge logic +- This means V2.0 and older API versions will see merged subscriptions (same as current behavior) + +### 5F. Apply merge logic in `V2.0_EntityChange` +**File:** `shared/api/entities/changes/V2.0_EntityChange.ts` +- Same as above — apply merge before splitting + +### 5G. Handle `billing_key` in backward transforms +**File:** `shared/api/customers/cusPlans/changes/V2.0_ApiSubscriptionChange.ts` +- Strip `billing_key` from the V0 `ApiSubscription` response (it doesn't exist in V0 schema) +- The existing `transformApiSubscriptionV1ToV0` function needs to explicitly omit `billing_key` + +### 5H. Strip `billing_key` in V1.2 change +**File:** `shared/api/customers/cusPlans/changes/V1.2_CusPlanChange.ts` +- When transforming from `ApiSubscriptionV1` to `ApiCusProductV3`, ensure `billing_key` is not passed through (it doesn't exist in the V3 products format) + +--- + +## Phase 6: Tests + +### 6A. Attach with billing_key +**Dir:** `server/tests/integration/billing/attach/billing-key/` + +Tests: +1. **attach-billing-key.test.ts** - Attach a plan with `billing_key`, verify the customer response (using autumnV2_1) includes `billing_key` on the subscription +2. **attach-billing-key-entity.test.ts** - Attach at entity level with `billing_key`, verify entity response includes it +3. Modify `expectCustomerProductCorrect.ts` to accept optional `expectedBillingKey` param and verify it matches + +### 6B. Multi-attach with billing_key +**Dir:** `server/tests/integration/billing/multi-attach/billing-key/` + +Tests: +1. **multi-attach-billing-key.test.ts** - Multi-attach same add-on twice with different billing_keys. Get customer (V2.1), verify two separate subscription entries each with their unique billing_key + +### 6C. Duplicate billing_key prevention +**Dir:** `server/tests/integration/billing/attach/billing-key/` + +Tests: +1. **duplicate-billing-key.test.ts** - Attach with billing_key "key-1", then try to attach again with "key-1" → expect error. Also test within multi-attach: two plans with same billing_key → expect error + +### 6D. UpdateSubscription targeting without plan_id +**Dir:** `server/tests/integration/billing/update-subscription/billing-key/` + +Tests: +1. **update-no-filter.test.ts** - Attach main + add-ons, call updateSubscription with cancel_action but no plan_id/billing_key/customer_product_id → verify correct target (paid recurring main prioritized) +2. **update-quantity-no-filter.test.ts** - Attach main + add-on (add-on has prepaid feature), call updateSubscription with feature_quantities but no plan_id → verify add-on with matching feature is targeted +3. **update-with-billing-key.test.ts** - Multi-attach same add-on twice with diff billing_keys, then updateSubscription with billing_key → verify correct customer product is updated (test with cancel/custom plan/update quantity) + +### 6E. Old API version backward compat +**Dir:** `server/tests/integration/billing/attach/billing-key/` + +Tests: +1. **old-version-merged-subs.test.ts** - Attach same add-on twice (with different billing_keys via multi-attach), get customer using autumnV1/autumnV2 (old versions) → verify subscriptions are merged (quantity summed, no billing_key field). Get customer using autumnV2_1 → verify unmerged with billing_keys visible + +### 6F. Additional suggested tests +- Attach with billing_key, then updateSubscription targeting by billing_key with feature_quantities → verify correct product updated +- Attach main product (no billing_key), cancel via updateSubscription without plan_id → verify main is auto-targeted +- Entity-scoped: attach on entity A and entity B, updateSubscription with entity_id for entity A but no plan_id → verify only entity A's product is targeted +- Auto-resolve with mixed product types: attach paid main + free add-on + one-off, cancel without plan_id → verify paid main is targeted first + +--- + +## Implementation Order + +1. **Phase 1** (Schema + DB) — foundation, everything depends on this +2. **Phase 2** (API Input Params) — define what users can send +3. **Phase 3** (Attach + MultiAttach flow) — wire billing_key through creation +4. **Phase 4** (UpdateSubscription targeting) — wire billing_key through targeting + auto-resolve +5. **Phase 5** (API Response + Unmerge) — expose billing_key + stop merging +6. **Phase 6** (Tests) — verify everything works + +--- + +## File Change Summary + +### Shared (schema/types) +| File | Change | +|------|--------| +| `shared/models/cusProductModels/cusProductTable.ts` | Add `billing_key` column | +| `shared/models/cusProductModels/cusProductModels.ts` | Add `billing_key` to CusProductSchema | +| `shared/models/billingModels/customerProduct/initFullCustomerProductContext.ts` | Add `billingKey` to InitFullCustomerProductOptions | +| `shared/models/billingModels/context/attachBillingContext.ts` | Add `billingKey` to AttachBillingContext | +| `shared/models/billingModels/context/multiAttachBillingContext.ts` | Add `billingKey` to MultiAttachProductContext | +| `shared/api/billing/common/billingParamsBase/billingParamsBaseV0.ts` | Add `billing_key` | +| `shared/api/billing/common/billingParamsBase/billingParamsBaseV1.ts` | Add `billing_key` | +| `shared/api/billing/attachV2/multiAttachParamsV0.ts` | Add `billing_key` to MultiAttachPlanSchema | +| `shared/api/billing/updateSubscription/updateSubscriptionV1Params.ts` | Move plan_id optional to Ext schema | +| `shared/api/customers/cusPlans/apiSubscriptionV1.ts` | Add `billing_key` to ApiSubscriptionV1Schema and ApiPurchaseV0Schema | +| `shared/api/customers/cusPlans/changes/V2.0_ApiSubscriptionChange.ts` | Strip `billing_key` in V1→V0 transform | +| `shared/api/customers/changes/V2.0_CustomerChange.ts` | Add merge logic before splitting subs | +| `shared/api/entities/changes/V2.0_EntityChange.ts` | Add merge logic before splitting subs | +| `shared/api/customers/cusPlans/mergeSubscriptionResponses.ts` | **NEW** — extracted merge utility | + +### Server (logic) +| File | Change | +|------|--------| +| `server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerProduct.ts` | Set `billing_key` from initOptions | +| `server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts` | Pass `billing_key` + uniqueness check | +| `server/src/internal/billing/v2/actions/attach/compute/computeAttachNewCustomerProduct.ts` | Pass `billingKey` to initOptions | +| `server/src/internal/billing/v2/actions/multiAttach/setup/setupMultiAttachBillingContext.ts` | Pass `billing_key` per plan + uniqueness check | +| `server/src/internal/billing/v2/actions/multiAttach/compute/computeMultiAttachPlan.ts` | Pass `billingKey` per product | +| `server/src/internal/billing/v2/actions/updateSubscription/setup/findTargetCustomerProduct.ts` | Full rewrite with priority-based auto-resolution | +| `server/src/internal/customers/cusUtils/apiCusUtils/getApiSubscription/getApiSubscription.ts` | Add `billing_key` to response | +| `server/src/internal/customers/cusUtils/apiCusUtils/getApiSubscription/getApiSubscriptions.ts` | Remove merge, return raw arrays | + +### Tests (new) +| File | Description | +|------|-------------| +| `server/tests/integration/billing/attach/billing-key/attach-billing-key.test.ts` | Attach + verify billing_key | +| `server/tests/integration/billing/attach/billing-key/attach-billing-key-entity.test.ts` | Entity-level billing_key | +| `server/tests/integration/billing/attach/billing-key/duplicate-billing-key.test.ts` | Duplicate prevention | +| `server/tests/integration/billing/attach/billing-key/old-version-merged-subs.test.ts` | Backward compat merge test | +| `server/tests/integration/billing/multi-attach/billing-key/multi-attach-billing-key.test.ts` | Multi-attach with billing_keys | +| `server/tests/integration/billing/update-subscription/billing-key/update-with-billing-key.test.ts` | Update targeting by billing_key | +| `server/tests/integration/billing/update-subscription/billing-key/update-no-filter.test.ts` | Auto-resolve target | +| `server/tests/integration/billing/update-subscription/billing-key/update-quantity-no-filter.test.ts` | Feature-quantity targeting | + +### Tests (modified) +| File | Change | +|------|--------| +| `server/tests/integration/billing/utils/expectCustomerProductCorrect.ts` | Add optional `expectedBillingKey` param | diff --git a/bun.lock b/bun.lock index ed2a8d7dc..fc7274a82 100644 --- a/bun.lock +++ b/bun.lock @@ -101,7 +101,7 @@ }, "packages/autumn-js": { "name": "autumn-js", - "version": "1.0.0-beta.5", + "version": "1.0.0-beta.6", "dependencies": { "query-string": "^9.2.2", "rou3": "^0.6.1", diff --git a/server/src/internal/billing/v2/actions/attach/attach.ts b/server/src/internal/billing/v2/actions/attach/attach.ts index 1365a4845..77c00e07d 100644 --- a/server/src/internal/billing/v2/actions/attach/attach.ts +++ b/server/src/internal/billing/v2/actions/attach/attach.ts @@ -70,7 +70,7 @@ export async function attach({ }; // 4. Errors (requires full billing plan) - handleAttachV2Errors({ + await handleAttachV2Errors({ ctx, billingContext, billingPlan, diff --git a/server/src/internal/billing/v2/actions/attach/compute/computeAttachNewCustomerProduct.ts b/server/src/internal/billing/v2/actions/attach/compute/computeAttachNewCustomerProduct.ts index 4e71ca9fe..5b1854a1e 100644 --- a/server/src/internal/billing/v2/actions/attach/compute/computeAttachNewCustomerProduct.ts +++ b/server/src/internal/billing/v2/actions/attach/compute/computeAttachNewCustomerProduct.ts @@ -35,6 +35,7 @@ export const computeAttachNewCustomerProduct = ({ isCustom, billingVersion, transitionConfig, + externalId, } = attachBillingContext; const currentCustomerEntitlements = @@ -91,6 +92,7 @@ export const computeAttachNewCustomerProduct = ({ subscriptionScheduleId: stripeSubscriptionSchedule?.id, status: isScheduled ? CusProductStatus.Scheduled : undefined, startsAt: isScheduled ? endOfCycleMs : undefined, + externalId, }, }); 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 84c9f6bad..7964c4b65 100644 --- a/server/src/internal/billing/v2/actions/attach/errors/handleAttachV2Errors.ts +++ b/server/src/internal/billing/v2/actions/attach/errors/handleAttachV2Errors.ts @@ -12,9 +12,10 @@ import { handleStripeCheckoutErrors } from "@/internal/billing/v2/actions/attach import { handleTransitionConfigErrors } from "@/internal/billing/v2/actions/attach/errors/handleTransitionConfigErrors"; import { handleProrationBehaviorErrors } from "@/internal/billing/v2/common/errors/handleBillingBehaviorErrors"; import { handleExternalPSPErrors } from "@/internal/billing/v2/common/errors/handleExternalPSPErrors"; +import { handleSubscriptionIdErrors } from "@/internal/billing/v2/common/errors/handleSubscriptionIdErrors"; /** Validates attach v2 request before executing the billing plan. */ -export const handleAttachV2Errors = ({ +export const handleAttachV2Errors = async ({ ctx, billingContext, billingPlan, @@ -58,4 +59,11 @@ export const handleAttachV2Errors = ({ billingPlan, params, }); + + // 9. Subscription ID uniqueness + await handleSubscriptionIdErrors({ + db: ctx.db, + internalCustomerId: billingContext.fullCustomer.internal_id, + subscriptionIds: [billingContext.externalId], + }); }; diff --git a/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts b/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts index 322653a4a..61f2a5b56 100644 --- a/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts +++ b/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts @@ -227,5 +227,7 @@ export const setupAttachBillingContext = async ({ billingVersion: contextOverride.billingVersion ?? BillingVersion.V2, successUrl: params.success_url ?? orgToReturnUrl({ org: ctx.org, env: ctx.env }), + + externalId: params.subscription_id, }; }; diff --git a/server/src/internal/billing/v2/actions/multiAttach/compute/computeMultiAttachPlan.ts b/server/src/internal/billing/v2/actions/multiAttach/compute/computeMultiAttachPlan.ts index b2cf15386..33e326bb1 100644 --- a/server/src/internal/billing/v2/actions/multiAttach/compute/computeMultiAttachPlan.ts +++ b/server/src/internal/billing/v2/actions/multiAttach/compute/computeMultiAttachPlan.ts @@ -49,6 +49,8 @@ export const computeMultiAttachPlan = ({ scheduledCustomerProduct: productContext.scheduledCustomerProduct, planTiming: "immediate", endOfCycleMs: undefined, + + externalId: productContext.externalId, }; // Track the transitioning product's context for computing updates diff --git a/server/src/internal/billing/v2/actions/multiAttach/errors/handleMultiAttachErrors.ts b/server/src/internal/billing/v2/actions/multiAttach/errors/handleMultiAttachErrors.ts index a141a625d..cebbd8a78 100644 --- a/server/src/internal/billing/v2/actions/multiAttach/errors/handleMultiAttachErrors.ts +++ b/server/src/internal/billing/v2/actions/multiAttach/errors/handleMultiAttachErrors.ts @@ -1,15 +1,17 @@ import type { MultiAttachBillingContext } from "@autumn/shared"; +import type { DrizzleCli } from "@/db/initDrizzle"; +import { handleSubscriptionIdErrors } from "@/internal/billing/v2/common/errors/handleSubscriptionIdErrors"; import { handleMultiAttachCurrentProductErrors } from "./handleMultiAttachCurrentProductErrors"; import { handleMultiAttachPrepaidErrors } from "./handleMultiAttachPrepaidErrors"; import { handleMultiAttachRedirectErrors } from "./handleMultiAttachRedirectErrors"; -/** - * Runs all multi-attach validation checks. - */ -export const handleMultiAttachErrors = ({ +/** Runs all multi-attach validation checks. */ +export const handleMultiAttachErrors = async ({ + db, billingContext, redirectMode, }: { + db: DrizzleCli; billingContext: MultiAttachBillingContext; redirectMode: string; }) => { @@ -25,4 +27,13 @@ export const handleMultiAttachErrors = ({ redirectMode, stripeSubscription: billingContext.stripeSubscription, }); + + // Subscription ID uniqueness + await handleSubscriptionIdErrors({ + db, + internalCustomerId: billingContext.fullCustomer.internal_id, + subscriptionIds: billingContext.productContexts.map( + (pc) => pc.externalId, + ), + }); }; diff --git a/server/src/internal/billing/v2/actions/multiAttach/multiAttach.ts b/server/src/internal/billing/v2/actions/multiAttach/multiAttach.ts index 080152c65..ee74b5f79 100644 --- a/server/src/internal/billing/v2/actions/multiAttach/multiAttach.ts +++ b/server/src/internal/billing/v2/actions/multiAttach/multiAttach.ts @@ -37,7 +37,8 @@ export async function multiAttach({ }); // 2. Errors - handleMultiAttachErrors({ + await handleMultiAttachErrors({ + db: ctx.db, billingContext, redirectMode: params.redirect_mode, }); diff --git a/server/src/internal/billing/v2/actions/multiAttach/setup/setupMultiAttachBillingContext.ts b/server/src/internal/billing/v2/actions/multiAttach/setup/setupMultiAttachBillingContext.ts index f05bebfe9..01f43e12d 100644 --- a/server/src/internal/billing/v2/actions/multiAttach/setup/setupMultiAttachBillingContext.ts +++ b/server/src/internal/billing/v2/actions/multiAttach/setup/setupMultiAttachBillingContext.ts @@ -71,6 +71,7 @@ export const setupMultiAttachBillingContext = async ({ featureQuantities, currentCustomerProduct, scheduledCustomerProduct, + externalId: plan.subscription_id, }; }), ); diff --git a/server/src/internal/billing/v2/actions/updateSubscription/setup/findTargetCustomerProduct.ts b/server/src/internal/billing/v2/actions/updateSubscription/setup/findTargetCustomerProduct.ts index 201111d0b..efa764657 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/setup/findTargetCustomerProduct.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/setup/findTargetCustomerProduct.ts @@ -1,32 +1,160 @@ import { + cusProductToPrices, + ErrCode, + type FullCusProduct, type FullCustomer, + findPriceByFeatureId, isCusProductOnEntity, + isCustomerProductAddOn, + isCustomerProductMain, + isCustomerProductOneOff, + isCustomerProductPaidRecurring, + isCustomerProductRecurring, + isPrepaidPrice, + RELEVANT_STATUSES, + RecaseError, type UpdateSubscriptionV1Params, } from "@autumn/shared"; +/** + * Assigns a numeric priority to a customer product for auto-resolution. + * Lower = higher priority. + */ +const PRODUCT_PRIORITY = [ + (cp: FullCusProduct) => + isCustomerProductMain(cp) && isCustomerProductPaidRecurring(cp), + + (cp: FullCusProduct) => + isCustomerProductMain(cp) && isCustomerProductRecurring(cp), + + (cp: FullCusProduct) => + isCustomerProductAddOn(cp) && isCustomerProductRecurring(cp), + + (cp: FullCusProduct) => isCustomerProductOneOff(cp), +]; + +const getProductPriority = (cp: FullCusProduct): number => { + const index = PRODUCT_PRIORITY.findIndex((matches) => matches(cp)); + return index === -1 ? PRODUCT_PRIORITY.length : index; +}; + +/** Returns true if the customer product has prepaid options for ALL given feature IDs. */ +const cusProductHasAllPrepaidFeatures = ({ + cp, + featureIds, +}: { + cp: FullCusProduct; + featureIds: string[]; +}): boolean => { + const prepaidPrices = cusProductToPrices({ cusProduct: cp }).filter( + isPrepaidPrice, + ); + + for (const featureId of featureIds) { + const prepaidPrice = findPriceByFeatureId({ + prices: prepaidPrices, + featureId, + }); + + if (!prepaidPrice) { + return false; + } + } + return true; +}; + +/** Resolves the target without throwing — returns undefined if no match. */ +const resolveTargetCustomerProduct = ({ + params, + candidates, +}: { + params: UpdateSubscriptionV1Params; + candidates: FullCusProduct[]; +}): FullCusProduct | undefined => { + // 1. Highest priority: customer_product_id + if (params.customer_product_id) { + return candidates.find((cp) => cp.id === params.customer_product_id); + } + + // 2. subscription_id + if (params.subscription_id) { + return candidates.find( + (cp) => + cp.external_id === params.subscription_id || + cp.id === params.subscription_id, + ); + } + + // 3. plan_id + if (params.plan_id) { + return candidates.find((cp) => cp.product.id === params.plan_id); + } + + // 4. Auto-resolve: no explicit filter provided + const sorted = [...candidates].sort( + (a, b) => + getProductPriority(a) - getProductPriority(b) || + b.created_at - a.created_at, + ); + + // If feature_quantities provided, find a product that has ALL features as prepaid + const featureIds = params.feature_quantities?.map((fq) => fq.feature_id); + if (featureIds && featureIds.length > 0) { + return sorted.find((cp) => + cusProductHasAllPrepaidFeatures({ cp, featureIds }), + ); + } + + return sorted[0]; +}; + +/** Builds a descriptive error message based on which filter was used. */ +const buildNotFoundMessage = ({ + params, + customerId, +}: { + params: UpdateSubscriptionV1Params; + customerId: string; +}): string => { + if (params.customer_product_id) { + return `No active subscription found with customer_product_id '${params.customer_product_id}' for customer '${customerId}'`; + } + if (params.subscription_id) { + return `No active subscription found with subscription_id '${params.subscription_id}' for customer '${customerId}'`; + } + if (params.plan_id) { + return `No active subscription found for plan '${params.plan_id}' on customer '${customerId}'`; + } + return `No active subscription found for customer '${customerId}'`; +}; + +/** Finds the target customer product for an updateSubscription call, or throws. */ export const findTargetCustomerProduct = ({ params, fullCustomer, }: { params: UpdateSubscriptionV1Params; fullCustomer: FullCustomer; -}) => { - const cusProducts = fullCustomer.customer_products; - const productId = params.plan_id; +}): FullCusProduct => { const internalEntityId = fullCustomer.entity?.internal_id; - const cusProductId = params.customer_product_id; - if (cusProductId) { - return cusProducts.find((cp) => cp.id === cusProductId); + const candidates = fullCustomer.customer_products.filter((cp) => { + if (!RELEVANT_STATUSES.includes(cp.status)) return false; + return isCusProductOnEntity({ cusProduct: cp, internalEntityId }); + }); + + const target = resolveTargetCustomerProduct({ params, candidates }); + + if (!target) { + throw new RecaseError({ + message: buildNotFoundMessage({ + params, + customerId: fullCustomer.id ?? "", + }), + code: ErrCode.CusProductNotFound, + statusCode: 404, + }); } - return cusProducts.find((cp) => { - const productIdMatch = cp.product.id === productId; - const entityIdMatch = isCusProductOnEntity({ - cusProduct: cp, - internalEntityId, - }); - - return productIdMatch && entityIdMatch; - }); + return target; }; diff --git a/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionProductContext.ts b/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionProductContext.ts index 455cc1f47..35e592133 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionProductContext.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionProductContext.ts @@ -2,7 +2,6 @@ import { cusProductToProduct, type FullCustomer, notNullish, - RecaseError, type UpdateSubscriptionBillingContextOverride, type UpdateSubscriptionV1Params, } from "@autumn/shared"; @@ -31,12 +30,6 @@ export const setupUpdateSubscriptionProductContext = async ({ fullCustomer, }); - if (!targetCustomerProduct) { - throw new RecaseError({ - message: `Customer ${fullCustomer.id} does not have the plan ${params.plan_id}.`, - }); - } - let fullProduct = cusProductToProduct({ cusProduct: targetCustomerProduct }); if ( diff --git a/server/src/internal/billing/v2/common/errors/handleSubscriptionIdErrors.ts b/server/src/internal/billing/v2/common/errors/handleSubscriptionIdErrors.ts new file mode 100644 index 000000000..9021b994c --- /dev/null +++ b/server/src/internal/billing/v2/common/errors/handleSubscriptionIdErrors.ts @@ -0,0 +1,47 @@ +import { ErrCode, RecaseError } from "@autumn/shared"; +import type { DrizzleCli } from "@/db/initDrizzle"; +import { customerProductRepo } from "@/internal/customers/cusProducts/repos"; + +/** Validates that a subscription_id is not already in use for the given customer. */ +export const handleSubscriptionIdErrors = async ({ + db, + internalCustomerId, + subscriptionIds: rawSubscriptionIds, +}: { + db: DrizzleCli; + internalCustomerId: string; + subscriptionIds: (string | undefined | null)[]; +}) => { + const subscriptionIds = rawSubscriptionIds.filter( + (id): id is string => !!id, + ); + if (subscriptionIds.length === 0) return; + + // Check for duplicates within the request itself + const seen = new Set(); + for (const id of subscriptionIds) { + if (seen.has(id)) { + throw new RecaseError({ + message: `Duplicate subscription_id '${id}' in the same request`, + code: ErrCode.DuplicateSubscriptionId, + statusCode: 400, + }); + } + seen.add(id); + } + + // Check for existing subscription IDs in the database + const existing = await customerProductRepo.getByExternalIds({ + db, + internalCustomerId, + externalIds: subscriptionIds, + }); + + if (existing.length > 0) { + throw new RecaseError({ + message: `subscription_id '${existing[0].external_id}' is already in use for this customer`, + code: ErrCode.DuplicateSubscriptionId, + statusCode: 409, + }); + } +}; diff --git a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerProduct.ts b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerProduct.ts index 0eb2f4c85..0a002c77e 100644 --- a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerProduct.ts +++ b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerProduct.ts @@ -33,6 +33,7 @@ export const initCustomerProduct = ({ collectionMethod, isCustom, apiSemver, + externalId, } = initOptions ?? {}; const internalEntityId = fullCustomer.entity?.internal_id; @@ -104,6 +105,8 @@ export const initCustomerProduct = ({ api_semver: apiSemver ?? null, billing_version: billingVersion, + + external_id: externalId ?? null, }; }; diff --git a/server/src/internal/customers/add-product/createFullCusProduct.ts b/server/src/internal/customers/add-product/createFullCusProduct.ts index 7bc81222e..625a9d222 100644 --- a/server/src/internal/customers/add-product/createFullCusProduct.ts +++ b/server/src/internal/customers/add-product/createFullCusProduct.ts @@ -154,6 +154,7 @@ const initCusProduct = ({ entity_id: entityId, api_semver: apiVersion || null, billing_version: BillingVersion.V1, + external_id: null, }; }; diff --git a/server/src/internal/customers/cusProducts/repos/getByExternalIds.ts b/server/src/internal/customers/cusProducts/repos/getByExternalIds.ts new file mode 100644 index 000000000..715ee0cbb --- /dev/null +++ b/server/src/internal/customers/cusProducts/repos/getByExternalIds.ts @@ -0,0 +1,29 @@ +import { customerProducts } from "@autumn/shared"; +import { and, eq, inArray } from "drizzle-orm"; +import type { DrizzleCli } from "@/db/initDrizzle"; + +/** Finds customer products matching any of the given external IDs for a customer. */ +export const getByExternalIds = async ({ + db, + internalCustomerId, + externalIds, +}: { + db: DrizzleCli; + internalCustomerId: string; + externalIds: string[]; +}) => { + if (externalIds.length === 0) return []; + + return db + .select({ + id: customerProducts.id, + external_id: customerProducts.external_id, + }) + .from(customerProducts) + .where( + and( + eq(customerProducts.internal_customer_id, internalCustomerId), + inArray(customerProducts.external_id, externalIds), + ), + ); +}; diff --git a/server/src/internal/customers/cusProducts/repos/index.ts b/server/src/internal/customers/cusProducts/repos/index.ts index 338b6637b..bae06cc8e 100644 --- a/server/src/internal/customers/cusProducts/repos/index.ts +++ b/server/src/internal/customers/cusProducts/repos/index.ts @@ -1,5 +1,7 @@ import { batchUpdateCustomerProducts } from "./batchUpdateCustomerProducts"; +import { getByExternalIds } from "./getByExternalIds"; export const customerProductRepo = { batchUpdate: batchUpdateCustomerProducts, + getByExternalIds, }; diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiSubscription/getApiSubscription.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiSubscription/getApiSubscription.ts index 95799a051..e539f6c9a 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiSubscription/getApiSubscription.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiSubscription/getApiSubscription.ts @@ -100,6 +100,7 @@ export const getApiSubscription = async < : undefined; const apiSubscription = ApiSubscriptionV1Schema.parse({ + id: cusProduct.external_id ?? cusProduct.id ?? "", plan: apiPlan, plan_id: fullProduct.id, diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiSubscription/getApiSubscriptions.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiSubscription/getApiSubscriptions.ts index 1b739e7ab..45a6ea415 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiSubscription/getApiSubscriptions.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiSubscription/getApiSubscriptions.ts @@ -1,54 +1,13 @@ import { - ACTIVE_STATUSES, type ApiSubscriptionV1, apiSubscription, type CusProductLegacyData, - type CusProductStatus, type FullCustomer, isCustomerProductOneOff, } from "@autumn/shared"; import type { RequestContext } from "@/honoUtils/HonoEnv.js"; import { getApiSubscription } from "./getApiSubscription.js"; -const getSubscriptionStatusKey = (cp: ApiSubscriptionV1) => { - if (!("status" in cp)) return undefined; - if (ACTIVE_STATUSES.includes(cp.status as CusProductStatus)) return "active"; - return cp.status; -}; - -const mergeSubscriptionsResponses = ({ - subscriptions, -}: { - subscriptions: ApiSubscriptionV1[]; -}) => { - const getPlanKey = (cp: ApiSubscriptionV1) => { - return `${cp.plan_id}:${getSubscriptionStatusKey(cp)}`; - }; - - const record: Record = {}; - - for (const curr of subscriptions) { - const key = getPlanKey(curr); - const latest = record[key]; - - const currStartedAt = curr.started_at; - - const curCanceledAt = "canceled_at" in curr ? curr.canceled_at : null; - const curQuantity = "quantity" in curr ? curr.quantity : 0; - - record[key] = { - ...(latest || curr), - canceled_at: curCanceledAt ? curCanceledAt : latest?.canceled_at || null, - started_at: latest?.started_at - ? Math.min(latest?.started_at, currStartedAt) - : currStartedAt, - quantity: (latest?.quantity || 0) + curQuantity, - }; - } - - return Object.values(record); -}; - export const getApiSubscriptions = async ({ ctx, fullCus, @@ -58,7 +17,6 @@ export const getApiSubscriptions = async ({ fullCus: FullCustomer; expandParams?: { plan?: boolean }; }) => { - // Process full subscriptions const apiSubs: ApiSubscriptionV1[] = []; const apiPurchasesAsSubscriptions: ApiSubscriptionV1[] = []; @@ -81,23 +39,15 @@ export const getApiSubscriptions = async ({ legacyData[processed.data.plan_id] = processed.legacyData; } - const merged = mergeSubscriptionsResponses({ - subscriptions: apiSubs, - }); - - const mergedPurchasesAsSubscriptions = mergeSubscriptionsResponses({ - subscriptions: apiPurchasesAsSubscriptions, - }); - - const mergedPurchases = mergedPurchasesAsSubscriptions.map((sub) => + const purchases = apiPurchasesAsSubscriptions.map((sub) => apiSubscription.map.v1ToPurchaseV0({ apiSubscriptionV1: sub, }), ); return { - subscriptions: merged, - purchases: mergedPurchases, + subscriptions: apiSubs, + purchases, legacyData, }; }; diff --git a/server/tests/integration/billing/attach/params/subscription-id.test.ts b/server/tests/integration/billing/attach/params/subscription-id.test.ts new file mode 100644 index 000000000..854b95af9 --- /dev/null +++ b/server/tests/integration/billing/attach/params/subscription-id.test.ts @@ -0,0 +1,225 @@ +import { expect, test } from "bun:test"; +import { + type ApiCustomer, + type ApiCustomerV3, + type ApiCustomerV5, + type AttachParamsV1Input, + ErrCode, +} from "@autumn/shared"; +import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect"; +import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +// ─── Test 1: Attach with subscription_id, verify id in response ─── + +test.concurrent(`${chalk.yellowBright("subscription_id: attach with subscription_id returns id in response")}`, async () => { + const customerId = "sub-id-attach-basic"; + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const pro = products.pro({ items: [messagesItem] }); + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [], + }); + + // Attach with a subscription_id + await autumnV2_1.billing.attach({ + customer_id: customerId, + plan_id: pro.id, + subscription_id: "my-custom-sub-id", + }); + + // Get customer with V2.1 and verify subscription has the custom id + const customer = await autumnV2_1.customers.get(customerId); + + expect(customer.subscriptions.length).toBe(1); + expect(customer.subscriptions[0].id).toBe("my-custom-sub-id"); + expect(customer.subscriptions[0].plan_id).toBe(pro.id); + await expectCustomerProducts({ customer, active: [pro.id] }); + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); + +// ─── Test 2: Attach without subscription_id, id falls back to internal id ─── + +test.concurrent(`${chalk.yellowBright("subscription_id: attach without subscription_id uses internal id")}`, async () => { + const customerId = "sub-id-attach-fallback"; + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const pro = products.pro({ items: [messagesItem] }); + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [], + }); + + // Attach without subscription_id + await autumnV2_1.billing.attach({ + customer_id: customerId, + plan_id: pro.id, + }); + + const customer = await autumnV2_1.customers.get(customerId); + + expect(customer.subscriptions.length).toBe(1); + // id should be a non-empty string (the internal cus_prod_xxx id) + expect(customer.subscriptions[0].id).toBeTruthy(); + expect(customer.subscriptions[0].id).toStartWith("cus_prod_"); + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); + +// ─── Test 2: Attach without subscription_id, id falls back to internal id ─── + +test.concurrent(`${chalk.yellowBright("subscription_id: attach same add on twice with different subscription_ids")}`, async () => { + const customerId = "sub-id-attach-same-add-on-twice"; + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const monthlyPrice = items.monthlyPrice({ price: 20 }); + const pro = products.base({ + items: [messagesItem, monthlyPrice], + isAddOn: true, + }); + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [ + s.billing.attach({ + productId: pro.id, + subscriptionId: "custom-sub-id-1", + }), + s.billing.attach({ + productId: pro.id, + subscriptionId: "custom-sub-id-2", + }), + ], + }); + + const customer = await autumnV2_1.customers.get(customerId); + + expect(customer.subscriptions.length).toBe(2); + + const findSubId1 = customer.subscriptions.find( + (sub) => sub.id === "custom-sub-id-1", + ); + const findSubId2 = customer.subscriptions.find( + (sub) => sub.id === "custom-sub-id-2", + ); + expect(findSubId1).toBeDefined(); + expect(findSubId2).toBeDefined(); + expect(findSubId1!.plan_id).toBe(pro.id); + expect(findSubId2!.plan_id).toBe(pro.id); + expect(findSubId1!.quantity).toBe(1); + expect(findSubId2!.quantity).toBe(1); + + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); + +// ─── Test 3: Duplicate subscription_id on same customer → error ─── + +test.concurrent(`${chalk.yellowBright("subscription_id: duplicate subscription_id on same customer throws error")}`, async () => { + const customerId = "sub-id-duplicate"; + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const pro = products.pro({ items: [messagesItem] }); + const addon = products.recurringAddOn({ + id: "addon", + items: [messagesItem], + }); + + const { autumnV2_1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, addon] }), + ], + actions: [], + }); + + // First attach with subscription_id "key-1" succeeds + await autumnV2_1.billing.attach({ + customer_id: customerId, + plan_id: pro.id, + subscription_id: "key-1", + }); + + // Second attach with same subscription_id "key-1" should fail + await expectAutumnError({ + errCode: ErrCode.DuplicateSubscriptionId, + func: async () => { + await autumnV2_1.billing.attach({ + customer_id: customerId, + plan_id: addon.id, + subscription_id: "key-1", + }); + }, + }); +}); + +// ─── Test 1: V2.1 shows unmerged subs with id, V2.0/V1.2 shows merged without id ─── + +test.concurrent(`${chalk.yellowBright("subscription_id compat: V2.1 unmerged vs V2.0/V1.2 merged")}`, async () => { + const customerId = "sub-id-compat-merge"; + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const addon = products.recurringAddOn({ + id: "addon", + items: [messagesItem], + }); + + const { autumnV1, autumnV2, autumnV2_1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [addon] }), + ], + actions: [], + }); + + // Multi-attach same add-on twice with different subscription_ids + await autumnV2_1.billing.multiAttach({ + customer_id: customerId, + plans: [ + { plan_id: addon.id, subscription_id: "inst-1" }, + { plan_id: addon.id, subscription_id: "inst-2" }, + ], + }); + + // V2.1 response: unmerged, each subscription has its own id + const customerV2_1 = + await autumnV2_1.customers.get(customerId); + + expect(customerV2_1.subscriptions.length).toBe(2); + const ids = customerV2_1.subscriptions.map((sub) => sub.id).sort(); + expect(ids).toEqual(["inst-1", "inst-2"]); + + // V2.0 response: merged (same plan_id + active status = 1 entry, quantity summed) + const customerV2 = await autumnV2.customers.get(customerId); + + // V2.0 merges subscriptions by plan_id + status + expect(customerV2.subscriptions.length).toBe(1); + expect(customerV2.subscriptions[0].quantity).toBe(2); + // V2.0 subscription schema (ApiSubscription) does not have `id` field + expect("id" in customerV2.subscriptions[0]).toBe(false); + + // V1.2 response: merged into products array + const customerV1 = await autumnV1.customers.get(customerId); + + // V1.2 uses "products" instead of "subscriptions" + const addonProducts = (customerV1.products ?? []).filter( + (p) => p.id === addon.id, + ); + // Should be merged into 1 product entry + expect(addonProducts.length).toBe(1); + expect(addonProducts[0].quantity).toBe(2); +}); diff --git a/server/tests/integration/billing/multi-attach/subscription-id/multi-attach-subscription-id.test.ts b/server/tests/integration/billing/multi-attach/subscription-id/multi-attach-subscription-id.test.ts new file mode 100644 index 000000000..a8eae2f8a --- /dev/null +++ b/server/tests/integration/billing/multi-attach/subscription-id/multi-attach-subscription-id.test.ts @@ -0,0 +1,140 @@ +import { expect, test } from "bun:test"; +import type { ApiCustomerV5 } from "@autumn/shared"; +import { ErrCode } from "@autumn/shared"; +import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect"; +import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +// ─── Test 1: Multi-attach same add-on twice with different subscription_ids ─── + +test.concurrent(`${chalk.yellowBright("multi-attach subscription_id: same add-on twice with different subscription_ids")}`, async () => { + const customerId = "ma-sub-id-two-addons"; + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const addon = products.recurringAddOn({ + id: "addon", + items: [messagesItem], + }); + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [addon] }), + ], + actions: [], + }); + + // Multi-attach same add-on with different subscription_ids + await autumnV2_1.billing.multiAttach({ + customer_id: customerId, + plans: [ + { plan_id: addon.id, subscription_id: "addon-instance-1" }, + { plan_id: addon.id, subscription_id: "addon-instance-2" }, + ], + }); + + // V2.1 response should show two separate subscription entries (unmerged) + const customer = await autumnV2_1.customers.get(customerId); + + expect(customer.subscriptions.length).toBe(2); + + const sub1 = customer.subscriptions.find( + (sub) => sub.id === "addon-instance-1", + ); + const sub2 = customer.subscriptions.find( + (sub) => sub.id === "addon-instance-2", + ); + + expect(sub1).toBeDefined(); + expect(sub2).toBeDefined(); + expect(sub1!.plan_id).toBe(addon.id); + expect(sub2!.plan_id).toBe(addon.id); + expect(sub1!.quantity).toBe(1); + expect(sub2!.quantity).toBe(1); + + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); + +// ─── Test 3: Multi-attach with duplicate subscription_ids in same request → error ─── + +test.concurrent(`${chalk.yellowBright("multi-attach subscription_id: duplicate subscription_ids in same request throws error")}`, async () => { + const customerId = "sub-id-multi-dup"; + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const addonA = products.recurringAddOn({ + id: "addon-a", + items: [messagesItem], + }); + const addonB = products.recurringAddOn({ + id: "addon-b", + items: [messagesItem], + }); + + const { autumnV2_1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [addonA, addonB] }), + ], + actions: [], + }); + + // Multi-attach with same subscription_id on both plans + await expectAutumnError({ + errCode: ErrCode.DuplicateSubscriptionId, + func: async () => { + await autumnV2_1.billing.multiAttach({ + customer_id: customerId, + plans: [ + { plan_id: addonA.id, subscription_id: "same-key" }, + { plan_id: addonB.id, subscription_id: "same-key" }, + ], + }); + }, + }); +}); + +// ─── Test 3: Multi-attach different products with subscription_ids ─── + +test.concurrent(`${chalk.yellowBright("multi-attach subscription_id: different products with subscription_ids")}`, async () => { + const customerId = "ma-sub-id-diff-products"; + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const pro = products.pro({ items: [messagesItem] }); + const addon = products.recurringAddOn({ + id: "addon", + items: [messagesItem], + }); + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, addon] }), + ], + actions: [], + }); + + await autumnV2_1.billing.multiAttach({ + customer_id: customerId, + plans: [ + { plan_id: pro.id, subscription_id: "main-sub" }, + { plan_id: addon.id, subscription_id: "addon-sub" }, + ], + }); + + const customer = await autumnV2_1.customers.get(customerId); + + expect(customer.subscriptions.length).toBe(2); + + const mainSub = customer.subscriptions.find((sub) => sub.id === "main-sub"); + const addonSub = customer.subscriptions.find((sub) => sub.id === "addon-sub"); + + expect(mainSub).toBeDefined(); + expect(addonSub).toBeDefined(); + expect(mainSub!.plan_id).toBe(pro.id); + expect(addonSub!.plan_id).toBe(addon.id); + + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); diff --git a/server/tests/integration/billing/update-subscription/subscription-id/update-auto-resolve.test.ts b/server/tests/integration/billing/update-subscription/subscription-id/update-auto-resolve.test.ts new file mode 100644 index 000000000..6854a2669 --- /dev/null +++ b/server/tests/integration/billing/update-subscription/subscription-id/update-auto-resolve.test.ts @@ -0,0 +1,119 @@ +import { expect, test } from "bun:test"; +import type { + ApiCustomerV5, + UpdateSubscriptionV1ParamsInput, +} from "@autumn/shared"; +import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +// ─── Test 1: Auto-resolve cancels paid recurring main over add-on ─── + +test.concurrent(`${chalk.yellowBright("auto-resolve: cancel without filter targets paid recurring main")}`, async () => { + const customerId = "auto-resolve-cancel-main"; + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const pro = products.pro({ items: [messagesItem] }); + const addon = products.recurringAddOn({ + id: "addon", + items: [messagesItem], + }); + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, addon] }), + ], + actions: [], + }); + + // Attach main + add-on + await autumnV2_1.billing.multiAttach({ + customer_id: customerId, + plans: [ + { plan_id: pro.id, subscription_id: "main-sub" }, + { plan_id: addon.id, subscription_id: "addon-sub" }, + ], + }); + + // Cancel without specifying plan_id or subscription_id + // Should auto-resolve to the paid recurring main (pro) + await autumnV2_1.subscriptions.update({ + customer_id: customerId, + cancel_action: "cancel_end_of_cycle", + }); + + const customer = await autumnV2_1.customers.get(customerId); + + const mainSub = customer.subscriptions.find((sub) => sub.id === "main-sub"); + const addonSub = customer.subscriptions.find((sub) => sub.id === "addon-sub"); + + expect(mainSub).toBeDefined(); + expect(addonSub).toBeDefined(); + + // Main should be canceling, add-on should remain active + expect(mainSub!.canceled_at).not.toBeNull(); + expect(addonSub!.canceled_at).toBeNull(); + + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); + +// ─── Test 2: Auto-resolve with feature_quantities targets matching product ─── + +test.concurrent(`${chalk.yellowBright("auto-resolve: feature_quantities targets product with matching prepaid features")}`, async () => { + const customerId = "auto-resolve-qty-match"; + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const prepaidItem = items.prepaidMessages({ + includedUsage: 0, + billingUnits: 100, + price: 10, + }); + const pro = products.pro({ items: [messagesItem] }); + const addon = products.recurringAddOn({ + id: "addon", + items: [prepaidItem], + }); + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, addon] }), + ], + actions: [], + }); + + await autumnV2_1.billing.multiAttach({ + customer_id: customerId, + plans: [ + { plan_id: pro.id, subscription_id: "main-sub" }, + { + plan_id: addon.id, + subscription_id: "addon-sub", + feature_quantities: [ + { feature_id: TestFeature.Messages, quantity: 100 }, + ], + }, + ], + }); + + // Update with feature_quantities for messages, no plan_id/subscription_id + // Should auto-resolve to the add-on (which has prepaid messages) + await autumnV2_1.subscriptions.update({ + customer_id: customerId, + feature_quantities: [{ feature_id: TestFeature.Messages, quantity: 200 }], + }); + + // Verify the add-on was updated (balance should reflect 200 messages from prepaid) + const customer = await autumnV2_1.customers.get(customerId); + + const messagesBalance = customer.balances[TestFeature.Messages]; + expect(messagesBalance).toBeDefined(); + // 100 from pro (monthly included) + 200 from updated addon (prepaid) = 300 + expect(messagesBalance.remaining).toBe(300); + + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); diff --git a/server/tests/integration/billing/update-subscription/subscription-id/update-with-subscription-id.test.ts b/server/tests/integration/billing/update-subscription/subscription-id/update-with-subscription-id.test.ts new file mode 100644 index 000000000..bcc98082a --- /dev/null +++ b/server/tests/integration/billing/update-subscription/subscription-id/update-with-subscription-id.test.ts @@ -0,0 +1,122 @@ +import { expect, test } from "bun:test"; +import type { + ApiCustomerV5, + UpdateSubscriptionV1ParamsInput, +} from "@autumn/shared"; +import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +// ─── Test 1: Cancel a specific add-on by subscription_id ─── + +test.concurrent(`${chalk.yellowBright("update subscription_id: cancel specific add-on by subscription_id")}`, async () => { + const customerId = "upd-sub-id-cancel"; + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const addon = products.recurringAddOn({ + id: "addon", + items: [messagesItem], + }); + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [addon] }), + ], + actions: [], + }); + + // Multi-attach same add-on twice with different subscription_ids + await autumnV2_1.billing.multiAttach({ + customer_id: customerId, + plans: [ + { plan_id: addon.id, subscription_id: "addon-keep" }, + { plan_id: addon.id, subscription_id: "addon-cancel" }, + ], + }); + + // Cancel only the second instance using subscription_id + await autumnV2_1.subscriptions.update({ + customer_id: customerId, + subscription_id: "addon-cancel", + cancel_action: "cancel_end_of_cycle", + }); + + const customer = await autumnV2_1.customers.get(customerId); + + // One should be active, one should be canceling + const keptSub = customer.subscriptions.find((sub) => sub.id === "addon-keep"); + const canceledSub = customer.subscriptions.find( + (sub) => sub.id === "addon-cancel", + ); + + expect(keptSub).toBeDefined(); + expect(canceledSub).toBeDefined(); + expect(keptSub!.canceled_at).toBeNull(); + expect(canceledSub!.canceled_at).not.toBeNull(); + + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); + +// ─── Test 2: Update quantity by subscription_id ─── + +test.concurrent(`${chalk.yellowBright("update subscription_id: update quantity by subscription_id")}`, async () => { + const customerId = "upd-sub-id-quantity"; + const prepaidItem = items.prepaidMessages({ + includedUsage: 0, + billingUnits: 100, + price: 10, + }); + const addon = products.recurringAddOn({ + id: "addon", + items: [prepaidItem], + }); + + const { autumnV2_1, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [addon] }), + ], + actions: [], + }); + + // Multi-attach same add-on twice with different subscription_ids and quantities + await autumnV2_1.billing.multiAttach({ + customer_id: customerId, + plans: [ + { + subscription_id: "addon-small", + feature_quantities: [ + { feature_id: TestFeature.Messages, quantity: 100 }, + ], + }, + { + subscription_id: "addon-large", + feature_quantities: [ + { feature_id: TestFeature.Messages, quantity: 200 }, + ], + }, + ], + }); + + // Update only the "addon-small" instance to increase quantity + await autumnV2_1.subscriptions.update({ + customer_id: customerId, + subscription_id: "addon-small", + feature_quantities: [{ feature_id: TestFeature.Messages, quantity: 300 }], + }); + + // Verify the update targeted the correct subscription + const customer = await autumnV2_1.customers.get(customerId); + + // Total messages balance: 300 (updated small) + 200 (large) = 500 + const messagesBalance = customer.balances[TestFeature.Messages]; + expect(messagesBalance).toBeDefined(); + expect(messagesBalance.remaining).toBe(500); + + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); diff --git a/server/tests/integration/billing/utils/expect-customer-products/expectSubscriptionState.ts b/server/tests/integration/billing/utils/expect-customer-products/expectSubscriptionState.ts new file mode 100644 index 000000000..f044eb135 --- /dev/null +++ b/server/tests/integration/billing/utils/expect-customer-products/expectSubscriptionState.ts @@ -0,0 +1,192 @@ +import { expect } from "bun:test"; +import type { ApiCustomerV5, ApiEntityV2 } from "@autumn/shared"; +import { formatMs } from "@autumn/shared"; + +type V5CustomerOrEntity = ApiCustomerV5 | ApiEntityV2; +type SubscriptionState = + | "active" + | "canceling" + | "scheduled" + | "past_due" + | "undefined"; + +/** Find a subscription by plan_id. */ +const findSubscription = ({ + customer, + productId, +}: { + customer: V5CustomerOrEntity; + productId: string; +}) => customer.subscriptions.find((sub) => sub.plan_id === productId); + +/** Find a purchase by plan_id. */ +const findPurchase = ({ + customer, + productId, +}: { + customer: V5CustomerOrEntity; + productId: string; +}) => customer.purchases.find((p) => p.plan_id === productId); + +/** Verify a V5 customer/entity has the expected subscription in the expected state. */ +export const expectSubscriptionCorrect = async ({ + customer, + productId, + state, +}: { + customer: V5CustomerOrEntity; + productId: string; + state: SubscriptionState; +}) => { + const sub = findSubscription({ customer, productId }); + + if (state === "undefined") { + const purchase = findPurchase({ customer, productId }); + expect(sub, `Subscription ${productId} should not exist`).toBeUndefined(); + expect(purchase, `Purchase ${productId} should not exist`).toBeUndefined(); + return; + } + + if (!sub) { + throw new Error( + `Subscription ${productId} not found but expected state: ${state}`, + ); + } + + if (state === "active") { + expect( + sub.status, + `Subscription ${productId} should have status "active" but got "${sub.status}"`, + ).toBe("active"); + expect( + sub.canceled_at, + `Subscription ${productId} should not be canceling (canceled_at: ${sub.canceled_at})`, + ).toBeNull(); + expect( + sub.past_due, + `Subscription ${productId} should not be past_due`, + ).toBe(false); + } else if (state === "canceling") { + expect( + sub.status, + `Subscription ${productId} should have status "active" but got "${sub.status}"`, + ).toBe("active"); + expect( + sub.canceled_at, + `Subscription ${productId} should be canceling (canceled_at should be set)`, + ).not.toBeNull(); + } else if (state === "scheduled") { + expect( + sub.status, + `Subscription ${productId} should have status "scheduled" but got "${sub.status}"`, + ).toBe("scheduled"); + } else if (state === "past_due") { + expect( + sub.past_due, + `Subscription ${productId} should be past_due`, + ).toBe(true); + } +}; + +/** Verify subscription is active. */ +export const expectSubscriptionActive = async (params: { + customer: V5CustomerOrEntity; + productId: string; +}) => expectSubscriptionCorrect({ ...params, state: "active" }); + +/** Verify subscription is canceling (active with canceled_at set). */ +export const expectSubscriptionCanceling = async (params: { + customer: V5CustomerOrEntity; + productId: string; +}) => expectSubscriptionCorrect({ ...params, state: "canceling" }); + +/** Verify subscription is scheduled. Optionally check started_at. */ +export const expectSubscriptionScheduled = async ({ + customer, + productId, + startsAt, + toleranceMs = 2 * 60 * 1000, +}: { + customer: V5CustomerOrEntity; + productId: string; + startsAt?: number; + toleranceMs?: number; +}) => { + await expectSubscriptionCorrect({ customer, productId, state: "scheduled" }); + + if (startsAt !== undefined) { + const sub = findSubscription({ customer, productId }); + if (!sub) { + throw new Error(`Subscription ${productId} not found for startsAt check`); + } + + const actualStartsAt = sub.started_at; + const diff = Math.abs(actualStartsAt - startsAt); + + expect( + diff <= toleranceMs, + `Subscription ${productId} started_at (${formatMs(actualStartsAt)}) should be within ${toleranceMs}ms of expected (${formatMs(startsAt)}), diff: ${diff}ms`, + ).toBe(true); + } +}; + +/** Verify subscription is past_due. */ +export const expectSubscriptionPastDue = async (params: { + customer: V5CustomerOrEntity; + productId: string; +}) => expectSubscriptionCorrect({ ...params, state: "past_due" }); + +/** Verify subscription/purchase does not exist. */ +export const expectSubscriptionNotPresent = async (params: { + customer: V5CustomerOrEntity; + productId: string; +}) => expectSubscriptionCorrect({ ...params, state: "undefined" }); + +/** Verify multiple subscription states in a single call. */ +export const expectSubscriptions = async ({ + customer, + active = [], + canceling = [], + scheduled = [], + pastDue = [], + notPresent = [], +}: { + customer: V5CustomerOrEntity; + active?: string[]; + canceling?: string[]; + scheduled?: string[]; + pastDue?: string[]; + notPresent?: string[]; +}) => { + for (const productId of active) { + await expectSubscriptionCorrect({ customer, productId, state: "active" }); + } + for (const productId of canceling) { + await expectSubscriptionCorrect({ + customer, + productId, + state: "canceling", + }); + } + for (const productId of scheduled) { + await expectSubscriptionCorrect({ + customer, + productId, + state: "scheduled", + }); + } + for (const productId of pastDue) { + await expectSubscriptionCorrect({ + customer, + productId, + state: "past_due", + }); + } + for (const productId of notPresent) { + await expectSubscriptionCorrect({ + customer, + productId, + state: "undefined", + }); + } +}; diff --git a/server/tests/integration/billing/utils/expect-customer-products/expectSubscriptionTrialing.ts b/server/tests/integration/billing/utils/expect-customer-products/expectSubscriptionTrialing.ts new file mode 100644 index 000000000..ea2169a6a --- /dev/null +++ b/server/tests/integration/billing/utils/expect-customer-products/expectSubscriptionTrialing.ts @@ -0,0 +1,98 @@ +import { expect } from "bun:test"; +import type { ApiCustomerV5, ApiEntityV2 } from "@autumn/shared"; +import { formatMs } from "@autumn/shared"; + +type V5CustomerOrEntity = ApiCustomerV5 | ApiEntityV2; + +const TEN_MINUTES_MS = 10 * 60 * 1000; +const ONE_HOUR_MS = 60 * 60 * 1000; + +/** Verify a V5 subscription is currently trialing with the expected trial end time. */ +export const expectSubscriptionTrialing = async ({ + customer, + productId, + trialEndsAt: expectedTrialEndsAt, + toleranceMs = TEN_MINUTES_MS, +}: { + customer: V5CustomerOrEntity; + productId: string; + trialEndsAt?: number; + toleranceMs?: number; +}) => { + const sub = customer.subscriptions.find((s) => s.plan_id === productId); + + expect( + sub, + `Subscription ${productId} not found for trialing check`, + ).toBeDefined(); + + // V5 trialing: status is "active" with trial_ends_at set + expect( + sub!.status, + `Subscription ${productId} should have status "active" but got "${sub!.status}"`, + ).toBe("active"); + expect( + sub!.trial_ends_at, + `Subscription ${productId} should have trial_ends_at set when trialing`, + ).not.toBeNull(); + + if (expectedTrialEndsAt !== undefined) { + const diff = Math.abs(sub!.trial_ends_at! - expectedTrialEndsAt); + expect( + diff <= toleranceMs, + `Subscription ${productId} trial_ends_at (${formatMs(sub!.trial_ends_at)}) should be within ${toleranceMs}ms of ${formatMs(expectedTrialEndsAt)}`, + ).toBe(true); + } + + return sub!.trial_ends_at; +}; + +/** Verify a V5 subscription is NOT trialing. */ +export const expectSubscriptionNotTrialing = async ({ + customer, + productId, +}: { + customer: V5CustomerOrEntity; + productId: string; +}) => { + const sub = customer.subscriptions.find((s) => s.plan_id === productId); + + expect( + sub, + `Subscription ${productId} not found for not-trialing check`, + ).toBeDefined(); + + expect( + sub!.trial_ends_at, + `Subscription ${productId} should not have trial_ends_at set`, + ).toBeNull(); +}; + +/** Verify a V5 subscription's current_period_end aligns with trial end time. */ +export const expectSubscriptionPeriodAlignedWithTrialEnd = async ({ + customer, + productId, + trialEndsAt, +}: { + customer: V5CustomerOrEntity; + productId: string; + trialEndsAt: number; +}) => { + const sub = customer.subscriptions.find((s) => s.plan_id === productId); + + expect( + sub, + `Subscription ${productId} not found for period alignment check`, + ).toBeDefined(); + + expect( + sub!.current_period_end, + `Subscription ${productId} should have current_period_end defined`, + ).not.toBeNull(); + + const diff = Math.abs(sub!.current_period_end! - trialEndsAt); + expect( + diff < ONE_HOUR_MS, + `Subscription ${productId} current_period_end (${sub!.current_period_end}) should align with trial_ends_at (${trialEndsAt})`, + ).toBe(true); +}; diff --git a/server/tests/integration/billing/utils/expectCustomerProductCorrect.ts b/server/tests/integration/billing/utils/expectCustomerProductCorrect.ts index e13be063c..055445a76 100644 --- a/server/tests/integration/billing/utils/expectCustomerProductCorrect.ts +++ b/server/tests/integration/billing/utils/expectCustomerProductCorrect.ts @@ -1,7 +1,21 @@ import { expect } from "bun:test"; -import type { ApiCustomerV3, ApiEntityV0 } from "@autumn/shared"; +import type { + ApiCustomerV3, + ApiCustomerV5, + ApiEntityV0, + ApiEntityV2, +} from "@autumn/shared"; import { ApiVersion, formatMs } from "@autumn/shared"; import { AutumnInt } from "@/external/autumn/autumnCli"; +import { + expectSubscriptionCorrect, + expectSubscriptionActive, + expectSubscriptionCanceling, + expectSubscriptionScheduled, + expectSubscriptionPastDue, + expectSubscriptionNotPresent, + expectSubscriptions, +} from "./expect-customer-products/expectSubscriptionState"; const defaultAutumn = new AutumnInt({ version: ApiVersion.V1_2 }); @@ -11,14 +25,27 @@ type ProductState = | "scheduled" | "past_due" | "undefined"; -type CustomerOrEntity = ApiCustomerV3 | ApiEntityV0; +type CustomerOrEntity = + | ApiCustomerV3 + | ApiEntityV0 + | ApiCustomerV5 + | ApiEntityV2; +type V5CustomerOrEntity = ApiCustomerV5 | ApiEntityV2; + +/** Type guard for V5/V2 customer/entity (has subscriptions instead of products). */ +const isV5Customer = ( + customer: CustomerOrEntity, +): customer is V5CustomerOrEntity => "subscriptions" in customer; + +/** Maps V3 state names to V5 state names. */ +const toV5State = ( + state: ProductState, +): "active" | "canceling" | "scheduled" | "past_due" | "undefined" => + state === "canceled" ? "canceling" : state; /** * Verify a customer/entity has the expected product in the expected state. - * - * @param customer - Customer or entity data (can also fetch by customerId) - * @param productId - The product ID to check - * @param state - Expected state: "active", "canceled", "scheduled", or "undefined" (product not present) + * Routes to V5 subscription checks when the customer has `subscriptions`. */ export const expectCustomerProductCorrect = async ({ customerId, @@ -27,7 +54,7 @@ export const expectCustomerProductCorrect = async ({ state, }: { customerId?: string; - customer?: ApiCustomerV3 | ApiEntityV0; + customer?: CustomerOrEntity; productId: string; state: ProductState; }) => { @@ -35,6 +62,15 @@ export const expectCustomerProductCorrect = async ({ ? providedCustomer : await defaultAutumn.customers.get(customerId!); + // Route to V5 functions + if (isV5Customer(customer)) { + return expectSubscriptionCorrect({ + customer, + productId, + state: toV5State(state), + }); + } + const products = customer.products ?? []; const product = products.find((p: { id?: string }) => p.id === productId); @@ -77,32 +113,42 @@ export const expectCustomerProductCorrect = async ({ } }; -/** - * Shorthand for checking product is active - */ +/** Shorthand for checking product is active. Prefer {@link expectCustomerProducts} for batch checks. */ export const expectProductActive = async (params: { customerId?: string; - customer?: ApiCustomerV3 | ApiEntityV0; + customer?: CustomerOrEntity; productId: string; -}) => expectCustomerProductCorrect({ ...params, state: "active" }); +}) => { + if (params.customer && isV5Customer(params.customer)) { + return expectSubscriptionActive({ + customer: params.customer, + productId: params.productId, + }); + } + return expectCustomerProductCorrect({ ...params, state: "active" }); +}; /** * Shorthand for checking product is canceling (active but with canceled_at set). - * This is the state a product enters after a downgrade - it remains active until - * the billing cycle ends, then transitions to the new product. + * Prefer {@link expectCustomerProducts} for batch checks. */ export const expectProductCanceling = async (params: { customerId?: string; - customer?: ApiCustomerV3 | ApiEntityV0; + customer?: CustomerOrEntity; productId: string; -}) => expectCustomerProductCorrect({ ...params, state: "canceled" }); +}) => { + if (params.customer && isV5Customer(params.customer)) { + return expectSubscriptionCanceling({ + customer: params.customer, + productId: params.productId, + }); + } + return expectCustomerProductCorrect({ ...params, state: "canceled" }); +}; /** - * Shorthand for checking product is scheduled. + * Shorthand for checking product is scheduled. Prefer {@link expectCustomerProducts} for batch checks. * Optionally verify the `started_at` timestamp is within a tolerance of the expected value. - * - * @param startsAt - Expected timestamp in milliseconds when the product will start - * @param toleranceMs - Allowed deviation in milliseconds (default: 2 minutes) */ export const expectProductScheduled = async ({ customerId, @@ -112,7 +158,7 @@ export const expectProductScheduled = async ({ toleranceMs = 2 * 60 * 1000, }: { customerId?: string; - customer?: ApiCustomerV3 | ApiEntityV0; + customer?: CustomerOrEntity; productId: string; startsAt?: number; toleranceMs?: number; @@ -121,8 +167,18 @@ export const expectProductScheduled = async ({ ? providedCustomer : await defaultAutumn.customers.get(customerId!); + // Route to V5 + if (isV5Customer(customer)) { + return expectSubscriptionScheduled({ + customer, + productId, + startsAt, + toleranceMs, + }); + } + await expectCustomerProductCorrect({ - customer: customer as ApiCustomerV3, + customer, productId, state: "scheduled", }); @@ -145,37 +201,39 @@ export const expectProductScheduled = async ({ } }; -/** - * Shorthand for checking product is past_due (payment failed). - */ +/** Shorthand for checking product is past_due. Prefer {@link expectCustomerProducts} for batch checks. */ export const expectProductPastDue = async (params: { customerId?: string; - customer?: ApiCustomerV3 | ApiEntityV0; + customer?: CustomerOrEntity; productId: string; -}) => expectCustomerProductCorrect({ ...params, state: "past_due" }); +}) => { + if (params.customer && isV5Customer(params.customer)) { + return expectSubscriptionPastDue({ + customer: params.customer, + productId: params.productId, + }); + } + return expectCustomerProductCorrect({ ...params, state: "past_due" }); +}; -/** - * Shorthand for checking product does not exist - */ +/** Shorthand for checking product does not exist. Prefer {@link expectCustomerProducts} for batch checks. */ export const expectProductNotPresent = async (params: { customerId?: string; - customer?: ApiCustomerV3 | ApiEntityV0; + customer?: CustomerOrEntity; productId: string; -}) => expectCustomerProductCorrect({ ...params, state: "undefined" }); +}) => { + if (params.customer && isV5Customer(params.customer)) { + return expectSubscriptionNotPresent({ + customer: params.customer, + productId: params.productId, + }); + } + return expectCustomerProductCorrect({ ...params, state: "undefined" }); +}; /** * Verify multiple product states in a single call. * Each array contains product IDs that should be in that state. - * - * @example - * await expectProducts({ - * customer, - * active: [pro.id, addon.id], - * canceling: [premium.id], - * scheduled: [free.id], - * pastDue: [overdue.id], - * notPresent: [oldProduct.id], - * }); */ export const expectCustomerProducts = async ({ customerId, @@ -198,9 +256,21 @@ export const expectCustomerProducts = async ({ ? providedCustomer : await defaultAutumn.customers.get(customerId!); + // Route to V5 + if (isV5Customer(customer)) { + return expectSubscriptions({ + customer, + active, + canceling, + scheduled, + pastDue, + notPresent, + }); + } + for (const productId of active) { await expectCustomerProductCorrect({ - customer: customer as ApiCustomerV3, + customer, productId, state: "active", }); @@ -208,7 +278,7 @@ export const expectCustomerProducts = async ({ for (const productId of canceling) { await expectCustomerProductCorrect({ - customer: customer as ApiCustomerV3, + customer, productId, state: "canceled", }); @@ -216,7 +286,7 @@ export const expectCustomerProducts = async ({ for (const productId of scheduled) { await expectCustomerProductCorrect({ - customer: customer as ApiCustomerV3, + customer, productId, state: "scheduled", }); @@ -224,7 +294,7 @@ export const expectCustomerProducts = async ({ for (const productId of pastDue) { await expectCustomerProductCorrect({ - customer: customer as ApiCustomerV3, + customer, productId, state: "past_due", }); @@ -232,7 +302,7 @@ export const expectCustomerProducts = async ({ for (const productId of notPresent) { await expectCustomerProductCorrect({ - customer: customer as ApiCustomerV3, + customer, productId, state: "undefined", }); diff --git a/server/tests/integration/billing/utils/expectCustomerProductTrialing.ts b/server/tests/integration/billing/utils/expectCustomerProductTrialing.ts index f961c8d0c..3c95ab81f 100644 --- a/server/tests/integration/billing/utils/expectCustomerProductTrialing.ts +++ b/server/tests/integration/billing/utils/expectCustomerProductTrialing.ts @@ -1,18 +1,39 @@ import { expect } from "bun:test"; -import type { ApiCustomerV3, ApiEntityV0 } from "@autumn/shared"; +import type { + ApiCustomerV3, + ApiCustomerV5, + ApiEntityV0, + ApiEntityV2, +} from "@autumn/shared"; import { ApiVersion, formatMs } from "@autumn/shared"; import { AutumnInt } from "@/external/autumn/autumnCli"; +import { + expectSubscriptionTrialing, + expectSubscriptionNotTrialing, + expectSubscriptionPeriodAlignedWithTrialEnd, +} from "./expect-customer-products/expectSubscriptionTrialing"; const defaultAutumn = new AutumnInt({ version: ApiVersion.V1_2 }); const ONE_HOUR_MS = 60 * 60 * 1000; const ONE_DAY_MS = 24 * ONE_HOUR_MS; - const TEN_MINUTES_MS = 10 * 60 * 1000; +type CustomerOrEntity = + | ApiCustomerV3 + | ApiEntityV0 + | ApiCustomerV5 + | ApiEntityV2; +type V5CustomerOrEntity = ApiCustomerV5 | ApiEntityV2; + +/** Type guard for V5/V2 customer/entity. */ +const isV5Customer = ( + customer: CustomerOrEntity, +): customer is V5CustomerOrEntity => "subscriptions" in customer; + /** * Verify a customer product is currently trialing with the expected trial end time. - * Uses `status === "trialing"` and `current_period_end` to determine trial state. + * Uses `status === "trialing"` and `current_period_end` for V3, `trial_ends_at` for V5. */ export const expectProductTrialing = async ({ customerId, @@ -22,9 +43,8 @@ export const expectProductTrialing = async ({ toleranceMs = TEN_MINUTES_MS, }: { customerId?: string; - customer?: ApiCustomerV3 | ApiEntityV0; + customer?: CustomerOrEntity; productId: string; - /** Expected trial end timestamp (10 min tolerance) */ trialEndsAt?: number; toleranceMs?: number; }) => { @@ -32,6 +52,16 @@ export const expectProductTrialing = async ({ ? providedCustomer : await defaultAutumn.customers.get(customerId!); + // Route to V5 + if (isV5Customer(customer)) { + return expectSubscriptionTrialing({ + customer, + productId, + trialEndsAt: expectedTrialEndsAt, + toleranceMs, + }); + } + const products = customer.products ?? []; const product = products.find((p: { id?: string }) => p.id === productId); @@ -66,8 +96,7 @@ export const expectProductTrialing = async ({ /** * Verify a customer product is NOT trialing. - * If nowMs is provided, checks if product is actually trialing based on test clock time - * (status may be "trialing" but if nowMs >= current_period_end, trial has ended). + * If nowMs is provided, checks if product is actually trialing based on test clock time. */ export const expectProductNotTrialing = async ({ customerId, @@ -76,15 +105,19 @@ export const expectProductNotTrialing = async ({ nowMs, }: { customerId?: string; - customer?: ApiCustomerV3 | ApiEntityV0; + customer?: CustomerOrEntity; productId: string; - /** Current time in ms (e.g., advancedTo from test clock). If provided, checks if trial is actually active. */ nowMs?: number; }) => { const customer = providedCustomer ? providedCustomer : await defaultAutumn.customers.get(customerId!); + // Route to V5 + if (isV5Customer(customer)) { + return expectSubscriptionNotTrialing({ customer, productId }); + } + const products = customer.products ?? []; const product = products.find((p: { id?: string }) => p.id === productId); @@ -118,9 +151,7 @@ export const expectProductNotTrialing = async ({ ).not.toBe("trialing"); }; -/** - * Verify a feature's next_reset_at aligns with trial end time. - */ +/** Verify a feature's next_reset_at aligns with trial end time. */ export const expectFeatureResetAlignedWithTrialEnd = async ({ customerId, customer: providedCustomer, @@ -169,7 +200,7 @@ export const expectPeriodEndsAlignedWithTrialEnd = async ({ trialEndsAt, }: { customerId?: string; - customer?: ApiCustomerV3 | ApiEntityV0; + customer?: CustomerOrEntity; productId: string; trialEndsAt: number; }) => { @@ -177,6 +208,15 @@ export const expectPeriodEndsAlignedWithTrialEnd = async ({ ? providedCustomer : await defaultAutumn.customers.get(customerId!); + // Route to V5 + if (isV5Customer(customer)) { + return expectSubscriptionPeriodAlignedWithTrialEnd({ + customer, + productId, + trialEndsAt, + }); + } + const products = customer.products ?? []; const product = products.find((p: { id?: string }) => p.id === productId); @@ -197,9 +237,7 @@ export const expectPeriodEndsAlignedWithTrialEnd = async ({ ).toBe(true); }; -/** - * Helper to calculate expected trial end time in milliseconds. - */ +/** Helper to calculate expected trial end time in milliseconds. */ export const calculateTrialEndMs = ({ trialDays, }: { @@ -208,22 +246,28 @@ export const calculateTrialEndMs = ({ return Date.now() + trialDays * ONE_DAY_MS; }; -/** - * Get the trial end time (current_period_end) from a trialing product. - */ +/** Get the trial end time from a trialing product. */ export const getTrialEndsAt = async ({ customerId, customer: providedCustomer, productId, }: { customerId?: string; - customer?: ApiCustomerV3 | ApiEntityV0; + customer?: CustomerOrEntity; productId: string; }): Promise => { const customer = providedCustomer ? providedCustomer : await defaultAutumn.customers.get(customerId!); + // Route to V5 + if (isV5Customer(customer)) { + const sub = customer.subscriptions.find( + (s) => s.plan_id === productId, + ); + return sub?.trial_ends_at ?? null; + } + const products = customer.products ?? []; const product = products.find((p: { id?: string }) => p.id === productId); diff --git a/server/tests/utils/fixtures/db/customerProducts.ts b/server/tests/utils/fixtures/db/customerProducts.ts index 03bcea42c..397ed0e55 100644 --- a/server/tests/utils/fixtures/db/customerProducts.ts +++ b/server/tests/utils/fixtures/db/customerProducts.ts @@ -67,6 +67,7 @@ const create = ({ product: product ?? (products.createFull({ id: productId }) as FullProduct), free_trial: null, billing_version: BillingVersion.V2, + external_id: null, }); // ═══════════════════════════════════════════════════════════════════ diff --git a/server/tests/utils/testInitUtils/initScenario.ts b/server/tests/utils/testInitUtils/initScenario.ts index 1b80fc027..2b08ee956 100644 --- a/server/tests/utils/testInitUtils/initScenario.ts +++ b/server/tests/utils/testInitUtils/initScenario.ts @@ -125,6 +125,7 @@ type BillingAttachAction = { planSchedule?: PlanTiming; timeout?: number; items?: ProductItem[]; // Custom product items (creates is_custom product) + subscriptionId?: string; }; type MultiAttachPlan = { @@ -713,6 +714,7 @@ const deleteCustomer = ( * @param planSchedule - Override plan timing: "immediate" or "end_of_cycle" * @param timeout - Optional timeout in milliseconds for the attach request * @param items - Custom product items (creates is_custom customer product) + * @param subscriptionId - Optional custom subscription ID for this attachment * @example s.billing.attach({ productId: "pro" }) // customer-level * @example s.billing.attach({ productId: "pro", customerId: "redeemer" }) // attach to other customer * @example s.billing.attach({ productId: "pro", entityIndex: 0 }) // attach to first entity @@ -728,6 +730,7 @@ const billingAttach = ({ planSchedule, timeout, items, + subscriptionId, }: { productId: string; customerId?: string; @@ -737,6 +740,7 @@ const billingAttach = ({ planSchedule?: PlanTiming; timeout?: number; items?: ProductItem[]; + subscriptionId?: string; }): ConfigFn => { const concurrency = Number(process.env.TEST_FILE_CONCURRENCY || "0"); const defaultTimeout = concurrency > 1 ? 8000 : 5000; @@ -754,6 +758,7 @@ const billingAttach = ({ planSchedule, timeout: timeout ?? defaultTimeout, items, + subscriptionId, }, ], }); @@ -1451,6 +1456,7 @@ export async function initScenario({ new_billing_subscription: action.newBillingSubscription, plan_schedule: action.planSchedule, items: action.items, + subscription_id: action.subscriptionId, }, { timeout: action.timeout }, ); diff --git a/shared/api/billing/attachV2/multiAttachParamsV0.ts b/shared/api/billing/attachV2/multiAttachParamsV0.ts index 9c9c7be7d..857060411 100644 --- a/shared/api/billing/attachV2/multiAttachParamsV0.ts +++ b/shared/api/billing/attachV2/multiAttachParamsV0.ts @@ -38,6 +38,10 @@ export const MultiAttachPlanSchema = z.object({ version: z.number().optional().meta({ description: "The version of the plan to attach.", }), + subscription_id: z.string().optional().meta({ + description: + "A unique ID to identify this subscription. Useful when attaching the same plan multiple times.", + }), }); export const MultiAttachParamsV0Schema = z.object({ diff --git a/shared/api/billing/common/billingParamsBase/billingParamsBaseV0.ts b/shared/api/billing/common/billingParamsBase/billingParamsBaseV0.ts index 2102160a1..34ccb68ca 100644 --- a/shared/api/billing/common/billingParamsBase/billingParamsBaseV0.ts +++ b/shared/api/billing/common/billingParamsBase/billingParamsBaseV0.ts @@ -19,6 +19,7 @@ export const BillingParamsBaseV0Schema = z.object({ items: z.array(ProductItemSchema).optional(), transition_rules: TransitionRulesSchema.optional(), + subscription_id: z.string().optional(), }); export type BillingParamsBaseV0 = z.infer; diff --git a/shared/api/billing/common/billingParamsBase/billingParamsBaseV1.ts b/shared/api/billing/common/billingParamsBase/billingParamsBaseV1.ts index 1d82fac0b..d78c6fa40 100644 --- a/shared/api/billing/common/billingParamsBase/billingParamsBaseV1.ts +++ b/shared/api/billing/common/billingParamsBase/billingParamsBaseV1.ts @@ -43,6 +43,11 @@ export const BillingParamsBaseV1Schema = z.object({ internal: true, }), + subscription_id: z.string().optional().meta({ + description: + "A unique ID to identify this subscription. Can be used to target specific subscriptions in update operations when a customer has multiple products with the same plan.", + }), + // Internal customer_data: CustomerDataSchema.optional().meta({ internal: true, diff --git a/shared/api/billing/updateSubscription/updateSubscriptionV1Params.ts b/shared/api/billing/updateSubscription/updateSubscriptionV1Params.ts index 260648eb7..3b833b525 100644 --- a/shared/api/billing/updateSubscription/updateSubscriptionV1Params.ts +++ b/shared/api/billing/updateSubscription/updateSubscriptionV1Params.ts @@ -4,6 +4,10 @@ import { CancelActionSchema } from "../common/cancelAction"; export const ExtUpdateSubscriptionV1ParamsSchema = BillingParamsBaseV1Schema.extend({ + plan_id: z.string().optional().meta({ + description: + "The ID of the plan to update. Optional if subscription_id is provided, or if the customer has only one product.", + }), cancel_action: CancelActionSchema.optional().meta({ description: "Action to perform for cancellation. 'cancel_immediately' cancels now with prorated refund, 'cancel_end_of_cycle' cancels at period end, 'uncancel' reverses a pending cancellation.", @@ -11,7 +15,6 @@ export const ExtUpdateSubscriptionV1ParamsSchema = }); export const UpdateSubscriptionV1ParamsSchema = ExtUpdateSubscriptionV1ParamsSchema.extend({ - plan_id: z.string().optional(), customer_product_id: z.string().optional().meta({ internal: true, }), diff --git a/shared/api/customers/changes/V2.0_CustomerChange.ts b/shared/api/customers/changes/V2.0_CustomerChange.ts index 3316eff4e..df5fbc3d6 100644 --- a/shared/api/customers/changes/V2.0_CustomerChange.ts +++ b/shared/api/customers/changes/V2.0_CustomerChange.ts @@ -10,8 +10,8 @@ import { ApiCustomerV5Schema } from "../apiCustomerV5"; import type { ApiBalance } from "../cusFeatures/apiBalance"; import { balanceV1ToV0 } from "../cusFeatures/mappers/balanceV1ToV0"; import type { ApiSubscription } from "../cusPlans/apiSubscription"; -import { apiPurchaseV0ToSubscriptionV0 } from "../cusPlans/mappers/apiPurchaseV0ToSubscriptionV0"; -import { apiSubscriptionV1ToV0 } from "../cusPlans/mappers/apiSubscriptionV1ToV0"; +import { apiPurchasesV0ToSubscriptionsV0 } from "../cusPlans/mappers/apiPurchasesV0ToSubscriptionsV0"; +import { apiSubscriptionsV1ToV0 } from "../cusPlans/mappers/apiSubscriptionsV1ToV0"; export const V2_0_CustomerChange = defineVersionChange({ name: "V2_0 Customer Change", @@ -38,23 +38,24 @@ export const V2_0_CustomerChange = defineVersionChange({ } } - // Transform and split subscriptions by status - // V5 has all subs in one array with status field, V4 splits them into two arrays - const allSubscriptions = input.subscriptions ?? []; + const mergedSubscriptions = apiSubscriptionsV1ToV0({ + ctx, + input: input.subscriptions ?? [], + }); - const transformedSubscriptions: ApiSubscription[] = allSubscriptions - .filter((sub) => sub.status !== "scheduled") - .map((sub) => apiSubscriptionV1ToV0({ ctx, input: sub })); + // Merge purchases as subscriptions + const purchasesAsSubscriptions: ApiSubscription[] = + apiPurchasesV0ToSubscriptionsV0({ + ctx, + purchases: input.purchases ?? [], + }); + + // Transform and split by status + const transformedSubscriptions: ApiSubscription[] = + mergedSubscriptions.filter((sub) => sub.status !== "scheduled"); const transformedScheduledSubscriptions: ApiSubscription[] = - allSubscriptions - .filter((sub) => sub.status === "scheduled") - .map((sub) => apiSubscriptionV1ToV0({ ctx, input: sub })); - - // Convert purchases to subscriptions and add to subscriptions array - const purchasesAsSubscriptions: ApiSubscription[] = ( - input.purchases ?? [] - ).map((purchase) => apiPurchaseV0ToSubscriptionV0({ ctx, input: purchase })); + mergedSubscriptions.filter((sub) => sub.status === "scheduled"); // Return V0 customer format (without purchases field) const { purchases: _purchases, ...rest } = input; diff --git a/shared/api/customers/cusPlans/apiSubscriptionV1.ts b/shared/api/customers/cusPlans/apiSubscriptionV1.ts index 1a4ea26f1..022fe6e2e 100644 --- a/shared/api/customers/cusPlans/apiSubscriptionV1.ts +++ b/shared/api/customers/cusPlans/apiSubscriptionV1.ts @@ -2,6 +2,10 @@ import { ApiPlanV1Schema } from "@api/products/apiPlanV1"; import { z } from "zod/v4"; export const ApiSubscriptionV1Schema = z.object({ + id: z.string().meta({ + description: + "The unique identifier of this subscription. If a subscription_id was provided at attach time, it is used; otherwise, falls back to the internal ID.", + }), plan: ApiPlanV1Schema.optional().meta({ description: "The full plan object if expanded.", }), diff --git a/shared/api/customers/cusPlans/mappers/apiPurchasesV0ToSubscriptionsV0.ts b/shared/api/customers/cusPlans/mappers/apiPurchasesV0ToSubscriptionsV0.ts new file mode 100644 index 000000000..2dec0ab56 --- /dev/null +++ b/shared/api/customers/cusPlans/mappers/apiPurchasesV0ToSubscriptionsV0.ts @@ -0,0 +1,49 @@ +import type { SharedContext } from "../../../../types/sharedContext"; +import type { ApiSubscription } from "../apiSubscription"; +import type { ApiPurchaseV0 } from "../apiSubscriptionV1"; +import { mergeSubscriptionResponses } from "./apiSubscriptionsV1ToV0"; +import { apiSubscriptionV1ToV0 } from "./apiSubscriptionV1ToV0"; + +export const apiPurchasesV0ToSubscriptionsV0 = ({ + ctx, + purchases, +}: { + ctx: SharedContext; + purchases: ApiPurchaseV0[]; +}): ApiSubscription[] => { + // Merge purchases as subscriptions + const mergedPurchases = mergeSubscriptionResponses({ + subscriptions: purchases.map((purchase) => ({ + ...purchase, + id: purchase.plan_id, + auto_enable: false, + add_on: true, + status: "active" as const, + past_due: false, + canceled_at: null, + trial_ends_at: null, + current_period_start: null, + current_period_end: null, + })), + }); + + return mergedPurchases.map((subscription) => + apiSubscriptionV1ToV0({ ctx, input: subscription }), + ); +}; + +// return { +// plan: input.plan ? planV1ToV0({ ctx, plan: input.plan }) : undefined, +// plan_id: input.plan_id, +// default: false, +// add_on: true, +// status: "active", +// past_due: false, +// canceled_at: null, +// expires_at: input.expires_at, +// trial_ends_at: null, +// started_at: input.started_at, +// current_period_start: null, +// current_period_end: null, +// quantity: input.quantity, +// }; diff --git a/shared/api/customers/cusPlans/mappers/apiSubscriptionsV1ToV0.ts b/shared/api/customers/cusPlans/mappers/apiSubscriptionsV1ToV0.ts new file mode 100644 index 000000000..2f61bddd9 --- /dev/null +++ b/shared/api/customers/cusPlans/mappers/apiSubscriptionsV1ToV0.ts @@ -0,0 +1,66 @@ +import type { CusProductStatus } from "@models/cusProductModels/cusProductEnums"; +import { ACTIVE_STATUSES } from "@utils/cusProductUtils/cusProductConstants"; +import type { SharedContext } from "../../../../types/sharedContext"; +import type { ApiSubscription } from "../apiSubscription"; +import type { ApiSubscriptionV1 } from "../apiSubscriptionV1"; +import { apiSubscriptionV1ToV0 } from "./apiSubscriptionV1ToV0"; + +const getSubscriptionStatusKey = (cp: ApiSubscriptionV1) => { + if (!("status" in cp)) return undefined; + if (ACTIVE_STATUSES.includes(cp.status as CusProductStatus)) return "active"; + return cp.status; +}; + +/** + * Merges subscription responses by plan_id + status. + * Subscriptions for the same plan in the same status group are combined: + * quantities are summed, started_at takes the earliest, canceled_at takes the latest non-null. + */ +export const mergeSubscriptionResponses = ({ + subscriptions, +}: { + subscriptions: ApiSubscriptionV1[]; +}): ApiSubscriptionV1[] => { + const getPlanKey = (cp: ApiSubscriptionV1) => { + return `${cp.plan_id}:${getSubscriptionStatusKey(cp)}`; + }; + + const record: Record = {}; + + for (const curr of subscriptions) { + const key = getPlanKey(curr); + const latest = record[key]; + + const currStartedAt = curr.started_at; + + const curCanceledAt = "canceled_at" in curr ? curr.canceled_at : null; + const curQuantity = "quantity" in curr ? curr.quantity : 0; + + record[key] = { + ...(latest || curr), + canceled_at: curCanceledAt ? curCanceledAt : latest?.canceled_at || null, + started_at: latest?.started_at + ? Math.min(latest?.started_at, currStartedAt) + : currStartedAt, + quantity: (latest?.quantity || 0) + curQuantity, + }; + } + + return Object.values(record); +}; + +export const apiSubscriptionsV1ToV0 = ({ + input, + ctx, +}: { + ctx: SharedContext; + input: ApiSubscriptionV1[]; +}): ApiSubscription[] => { + const mergedSubscriptions = mergeSubscriptionResponses({ + subscriptions: input, + }); + + return mergedSubscriptions.map((subscription) => + apiSubscriptionV1ToV0({ ctx, input: subscription }), + ); +}; diff --git a/shared/api/entities/changes/V2.0_EntityChange.ts b/shared/api/entities/changes/V2.0_EntityChange.ts index c31295aff..1d0196d7a 100644 --- a/shared/api/entities/changes/V2.0_EntityChange.ts +++ b/shared/api/entities/changes/V2.0_EntityChange.ts @@ -1,5 +1,7 @@ import type { ApiBalance } from "@api/customers/cusFeatures/apiBalance"; import { balanceV1ToV0 } from "@api/customers/cusFeatures/mappers/balanceV1ToV0"; +import { apiPurchasesV0ToSubscriptionsV0 } from "@api/customers/cusPlans/mappers/apiPurchasesV0ToSubscriptionsV0"; +import { apiSubscriptionsV1ToV0 } from "@api/customers/cusPlans/mappers/apiSubscriptionsV1ToV0"; import { ApiVersion } from "@api/versionUtils/ApiVersion"; import { AffectedResource, @@ -8,8 +10,6 @@ import { import type { z } from "zod/v4"; import type { SharedContext } from "../../../types/sharedContext"; import type { ApiSubscription } from "../../customers/cusPlans/apiSubscription"; -import { apiPurchaseV0ToSubscriptionV0 } from "../../customers/cusPlans/mappers/apiPurchaseV0ToSubscriptionV0"; -import { apiSubscriptionV1ToV0 } from "../../customers/cusPlans/mappers/apiSubscriptionV1ToV0"; import { ApiEntityV1Schema } from "../apiEntity"; import { ApiEntityV2Schema } from "../apiEntityV2"; @@ -24,6 +24,10 @@ import { ApiEntityV2Schema } from "../apiEntityV2"; * - V2.1+: Single "subscriptions" array with ApiSubscriptionV1 (auto_enable, ApiPlanV1) * - V2.0: Split arrays "subscriptions" + "scheduled_subscriptions" with ApiSubscription (default, ApiPlanV0) * + * 2. Subscription merging: + * - V2.1+: Each customer product is a separate entry (unmerged) + * - V2.0: Same plan_id + status are merged (quantities summed) + * * Input: ApiEntityV2 (V2.1+ format) * Output: ApiEntityV1 (V2.0 format) */ @@ -46,24 +50,26 @@ export const V2_0_EntityChange = defineVersionChange({ ctx: SharedContext; input: z.infer; }): z.infer => { - // Transform subscriptions from V1 to V0 - const allSubscriptions = input.subscriptions ?? []; + // Merge subscriptions first (V2.1 returns unmerged, V2.0 expects merged) + const mergedSubscriptions = apiSubscriptionsV1ToV0({ + ctx, + input: input.subscriptions ?? [], + }); - const activeSubscriptionsV0: ApiSubscription[] = allSubscriptions - .filter((sub) => sub.status === "active") - .map((sub) => apiSubscriptionV1ToV0({ ctx, input: sub })); + // Merge purchases as subscriptions + const purchasesAsSubscriptions: ApiSubscription[] = + apiPurchasesV0ToSubscriptionsV0({ + ctx, + purchases: input.purchases ?? [], + }); - const scheduledSubscriptionsV0: ApiSubscription[] = allSubscriptions - .filter((sub) => sub.status === "scheduled") - .map((sub) => apiSubscriptionV1ToV0({ ctx, input: sub })); - - // Convert purchases to subscriptions and add to active subscriptions - const purchasesAsSubscriptions: ApiSubscription[] = ( - input.purchases ?? [] - ).map((purchase) => - apiPurchaseV0ToSubscriptionV0({ ctx, input: purchase }), + const activeSubscriptionsV0: ApiSubscription[] = mergedSubscriptions.filter( + (sub) => sub.status === "active", ); + const scheduledSubscriptionsV0: ApiSubscription[] = + mergedSubscriptions.filter((sub) => sub.status === "scheduled"); + const balancesV0: Record = {}; if (input.balances) { for (const [featureId, balance] of Object.entries(input.balances)) { diff --git a/shared/enums/ErrCode.ts b/shared/enums/ErrCode.ts index 182ea584b..667acee82 100644 --- a/shared/enums/ErrCode.ts +++ b/shared/enums/ErrCode.ts @@ -88,6 +88,7 @@ export const ErrCode = { // Cus Product CusProductNotFound: "cus_product_not_found", + DuplicateSubscriptionId: "duplicate_subscription_id", // Entitlements InvalidEntitlement: "invalid_entitlement", diff --git a/shared/models/billingModels/context/attachBillingContext.ts b/shared/models/billingModels/context/attachBillingContext.ts index ac1d7d054..1445c4779 100644 --- a/shared/models/billingModels/context/attachBillingContext.ts +++ b/shared/models/billingModels/context/attachBillingContext.ts @@ -31,6 +31,9 @@ export interface AttachBillingContext extends BillingContext { // Checkout checkoutMode: CheckoutMode; + + // User-provided subscription ID for targeting + externalId?: string; } // export interface AttachBillingContextOverride { diff --git a/shared/models/billingModels/context/multiAttachBillingContext.ts b/shared/models/billingModels/context/multiAttachBillingContext.ts index a906fb161..b70d5f865 100644 --- a/shared/models/billingModels/context/multiAttachBillingContext.ts +++ b/shared/models/billingModels/context/multiAttachBillingContext.ts @@ -17,6 +17,8 @@ export interface MultiAttachProductContext { currentCustomerProduct?: FullCusProduct; /** A previously scheduled product in the same group to delete. */ scheduledCustomerProduct?: FullCusProduct; + /** User-provided subscription ID for this product. */ + externalId?: string; } export interface MultiAttachBillingContext extends BillingContext { diff --git a/shared/models/billingModels/customerProduct/initFullCustomerProductContext.ts b/shared/models/billingModels/customerProduct/initFullCustomerProductContext.ts index 778bc2450..40630da6d 100644 --- a/shared/models/billingModels/customerProduct/initFullCustomerProductContext.ts +++ b/shared/models/billingModels/customerProduct/initFullCustomerProductContext.ts @@ -58,4 +58,5 @@ export interface InitFullCustomerProductOptions { // Optional + random apiSemver?: ApiVersion; collectionMethod?: CollectionMethod; + externalId?: string; } diff --git a/shared/models/cusProductModels/cusProductModels.ts b/shared/models/cusProductModels/cusProductModels.ts index 5e4bbfbeb..649d15488 100644 --- a/shared/models/cusProductModels/cusProductModels.ts +++ b/shared/models/cusProductModels/cusProductModels.ts @@ -67,6 +67,8 @@ export const CusProductSchema = z.object({ is_custom: z.boolean().default(false), billing_version: z.enum(BillingVersion).default(BillingVersion.V1), + + external_id: z.string().nullable(), }); export const FullCusProductSchema = CusProductSchema.extend({ diff --git a/shared/models/cusProductModels/cusProductTable.ts b/shared/models/cusProductModels/cusProductTable.ts index cd566f759..3270c867e 100644 --- a/shared/models/cusProductModels/cusProductTable.ts +++ b/shared/models/cusProductModels/cusProductTable.ts @@ -55,6 +55,8 @@ export const customerProducts = pgTable( api_version: numeric({ mode: "number" }), api_semver: text("api_semver"), + + external_id: text("external_id"), }, (table) => [ foreignKey({ diff --git a/vite/src/views/customers2/components/sheets/SubscriptionDetailSheet.tsx b/vite/src/views/customers2/components/sheets/SubscriptionDetailSheet.tsx index 5fad54902..50cedba45 100644 --- a/vite/src/views/customers2/components/sheets/SubscriptionDetailSheet.tsx +++ b/vite/src/views/customers2/components/sheets/SubscriptionDetailSheet.tsx @@ -18,6 +18,7 @@ import { HeartbeatIcon, Info, SubtractIcon, + TagIcon, TimerIcon, XCircle, } from "@phosphor-icons/react"; @@ -248,6 +249,14 @@ export function SubscriptionDetailSheet() { value={cusProduct.quantity.toString()} /> )} + {cusProduct.external_id && ( + } + label="Sub ID" + value={cusProduct.external_id} + mono + /> + )} {cusProduct.subscription_ids?.length > 0 && (
@@ -255,7 +264,7 @@ export function SubscriptionDetailSheet() {
- Sub ID + Stripe ID