fix: multi attach sub ID test

This commit is contained in:
John Yeo
2026-03-02 13:56:44 +00:00
parent eb47070375
commit 0933f0b80a
45 changed files with 1832 additions and 184 deletions

View File

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

View File

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

View File

@@ -70,7 +70,7 @@ export async function attach({
};
// 4. Errors (requires full billing plan)
handleAttachV2Errors({
await handleAttachV2Errors({
ctx,
billingContext,
billingPlan,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -37,7 +37,8 @@ export async function multiAttach({
});
// 2. Errors
handleMultiAttachErrors({
await handleMultiAttachErrors({
db: ctx.db,
billingContext,
redirectMode: params.redirect_mode,
});

View File

@@ -71,6 +71,7 @@ export const setupMultiAttachBillingContext = async ({
featureQuantities,
currentCustomerProduct,
scheduledCustomerProduct,
externalId: plan.subscription_id,
};
}),
);

View File

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

View File

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

View File

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

View File

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

View File

@@ -154,6 +154,7 @@ const initCusProduct = ({
entity_id: entityId,
api_semver: apiVersion || null,
billing_version: BillingVersion.V1,
external_id: null,
};
};

View File

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

View File

@@ -1,5 +1,7 @@
import { batchUpdateCustomerProducts } from "./batchUpdateCustomerProducts";
import { getByExternalIds } from "./getByExternalIds";
export const customerProductRepo = {
batchUpdate: batchUpdateCustomerProducts,
getByExternalIds,
};

View File

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

View File

@@ -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<string, ApiSubscriptionV1> = {};
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,
};
};

View File

@@ -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<AttachParamsV1Input>({
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<ApiCustomerV5>(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<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
});
const customer = await autumnV2_1.customers.get<ApiCustomerV5>(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<ApiCustomerV5>(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<AttachParamsV1Input>({
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<AttachParamsV1Input>({
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<ApiCustomerV5>(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<ApiCustomer>(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<ApiCustomerV3>(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);
});

View File

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

View File

@@ -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<UpdateSubscriptionV1ParamsInput>({
customer_id: customerId,
cancel_action: "cancel_end_of_cycle",
});
const customer = await autumnV2_1.customers.get<ApiCustomerV5>(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<UpdateSubscriptionV1ParamsInput>({
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<ApiCustomerV5>(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 });
});

View File

@@ -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<UpdateSubscriptionV1ParamsInput>({
customer_id: customerId,
subscription_id: "addon-cancel",
cancel_action: "cancel_end_of_cycle",
});
const customer = await autumnV2_1.customers.get<ApiCustomerV5>(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<UpdateSubscriptionV1ParamsInput>({
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<ApiCustomerV5>(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 });
});

View File

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

View File

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

View File

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

View File

@@ -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<number | null> => {
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);

View File

@@ -67,6 +67,7 @@ const create = ({
product: product ?? (products.createFull({ id: productId }) as FullProduct),
free_trial: null,
billing_version: BillingVersion.V2,
external_id: null,
});
// ═══════════════════════════════════════════════════════════════════

View File

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

View File

@@ -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({

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string, ApiSubscriptionV1> = {};
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 }),
);
};

View File

@@ -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<typeof ApiEntityV2Schema>;
}): z.infer<typeof ApiEntityV1Schema> => {
// 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<string, ApiBalance> = {};
if (input.balances) {
for (const [featureId, balance] of Object.entries(input.balances)) {

View File

@@ -88,6 +88,7 @@ export const ErrCode = {
// Cus Product
CusProductNotFound: "cus_product_not_found",
DuplicateSubscriptionId: "duplicate_subscription_id",
// Entitlements
InvalidEntitlement: "invalid_entitlement",

View File

@@ -31,6 +31,9 @@ export interface AttachBillingContext extends BillingContext {
// Checkout
checkoutMode: CheckoutMode;
// User-provided subscription ID for targeting
externalId?: string;
}
// export interface AttachBillingContextOverride {

View File

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

View File

@@ -58,4 +58,5 @@ export interface InitFullCustomerProductOptions {
// Optional + random
apiSemver?: ApiVersion;
collectionMethod?: CollectionMethod;
externalId?: string;
}

View File

@@ -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({

View File

@@ -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({

View File

@@ -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 && (
<InfoRow
icon={<TagIcon size={16} weight="duotone" />}
label="Sub ID"
value={cusProduct.external_id}
mono
/>
)}
{cusProduct.subscription_ids?.length > 0 && (
<div className="flex items-center gap-2 min-w-0 overflow-hidden">
<div className="text-t4/60 shrink-0">
@@ -255,7 +264,7 @@ export function SubscriptionDetailSheet() {
</div>
<div className="flex min-w-0 items-center overflow-hidden">
<div className="text-t3 text-sm font-medium w-20 shrink-0 whitespace-nowrap">
Sub ID
Stripe ID
</div>
<div className="min-w-0 overflow-hidden">
<MiniCopyButton