fix: multi attach sub ID test
This commit is contained in:
291
.opencode/plans/billing-key-plan.md
Normal file
291
.opencode/plans/billing-key-plan.md
Normal 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 |
|
||||
2
bun.lock
2
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",
|
||||
|
||||
@@ -70,7 +70,7 @@ export async function attach({
|
||||
};
|
||||
|
||||
// 4. Errors (requires full billing plan)
|
||||
handleAttachV2Errors({
|
||||
await handleAttachV2Errors({
|
||||
ctx,
|
||||
billingContext,
|
||||
billingPlan,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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],
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -37,7 +37,8 @@ export async function multiAttach({
|
||||
});
|
||||
|
||||
// 2. Errors
|
||||
handleMultiAttachErrors({
|
||||
await handleMultiAttachErrors({
|
||||
db: ctx.db,
|
||||
billingContext,
|
||||
redirectMode: params.redirect_mode,
|
||||
});
|
||||
|
||||
@@ -71,6 +71,7 @@ export const setupMultiAttachBillingContext = async ({
|
||||
featureQuantities,
|
||||
currentCustomerProduct,
|
||||
scheduledCustomerProduct,
|
||||
externalId: plan.subscription_id,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -154,6 +154,7 @@ const initCusProduct = ({
|
||||
entity_id: entityId,
|
||||
api_semver: apiVersion || null,
|
||||
billing_version: BillingVersion.V1,
|
||||
external_id: null,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,7 @@
|
||||
import { batchUpdateCustomerProducts } from "./batchUpdateCustomerProducts";
|
||||
import { getByExternalIds } from "./getByExternalIds";
|
||||
|
||||
export const customerProductRepo = {
|
||||
batchUpdate: batchUpdateCustomerProducts,
|
||||
getByExternalIds,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@ const create = ({
|
||||
product: product ?? (products.createFull({ id: productId }) as FullProduct),
|
||||
free_trial: null,
|
||||
billing_version: BillingVersion.V2,
|
||||
external_id: null,
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.",
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
// };
|
||||
@@ -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 }),
|
||||
);
|
||||
};
|
||||
@@ -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)) {
|
||||
|
||||
@@ -88,6 +88,7 @@ export const ErrCode = {
|
||||
|
||||
// Cus Product
|
||||
CusProductNotFound: "cus_product_not_found",
|
||||
DuplicateSubscriptionId: "duplicate_subscription_id",
|
||||
|
||||
// Entitlements
|
||||
InvalidEntitlement: "invalid_entitlement",
|
||||
|
||||
@@ -31,6 +31,9 @@ export interface AttachBillingContext extends BillingContext {
|
||||
|
||||
// Checkout
|
||||
checkoutMode: CheckoutMode;
|
||||
|
||||
// User-provided subscription ID for targeting
|
||||
externalId?: string;
|
||||
}
|
||||
|
||||
// export interface AttachBillingContextOverride {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -58,4 +58,5 @@ export interface InitFullCustomerProductOptions {
|
||||
// Optional + random
|
||||
apiSemver?: ApiVersion;
|
||||
collectionMethod?: CollectionMethod;
|
||||
externalId?: string;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user