prepaid tiered entities create inline prices
This commit is contained in:
@@ -43,6 +43,12 @@ When attaching a prepaid feature and passing `options` with a quantity:
|
||||
1. **Legacy attach** (`autumnV1.attach`): `quantity` should NOT be divided by billing units and should be **exclusive** of included usage (i.e. only the prepaid amount, not counting what's already included free).
|
||||
2. **New attach** (`autumnV1.billing.attach`): `quantity` should NOT be divided by billing units and should be **inclusive** of included usage (i.e. total desired amount including the free included portion).
|
||||
|
||||
## Subscription Verification
|
||||
|
||||
- **New tests**: Use `expectStripeSubscriptionCorrect` from `@tests/integration/billing/utils/expectStripeSubCorrect` — it uses production code (`buildStripePhasesUpdate`) to compute expected state and handles inline entity-scoped prices, schedule phases, and post-cycle schedule release.
|
||||
- **Existing tests**: Keep using `expectSubToBeCorrect` unless you're updating the test.
|
||||
- Always call `expectStripeSubscriptionCorrect({ ctx, customerId })` after any `billing.attach()` or `subscriptions.update()` call in new tests.
|
||||
|
||||
## Type Checking
|
||||
|
||||
- After writing or editing test files, ALWAYS run `bun ts` in the `server/` directory to check for type errors before considering the task done.
|
||||
|
||||
@@ -34,6 +34,7 @@ Write integration tests for the Autumn billing system using the `initScenario` p
|
||||
- Use generic types with `AutumnInt`: `autumnV1.customers.get<ApiCustomerV3>()`, `autumnV1.check<CheckResponseV1>()`
|
||||
- **USE UTILITY FUNCTIONS WHENEVER POSSIBLE** - the shorter the code, the better. Check `server/tests/integration/billing/utils/` for existing utilities like `expectCustomerProducts`, `expectProductScheduled`, `expectCustomerInvoiceCorrect`, etc.
|
||||
- **Set up all prerequisite state in `initScenario` actions** - the test body should only call the single action being tested
|
||||
- **ALWAYS call `expectStripeSubscriptionCorrect({ ctx, customerId })` after billing actions** — this uses production code to verify Stripe subscription state matches expectations
|
||||
|
||||
**DON'T:**
|
||||
- Use plain `test()` - **ALWAYS use `test.concurrent()`**
|
||||
|
||||
@@ -8,6 +8,7 @@ import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/e
|
||||
import { expectCustomerProducts, expectProductActive, expectProductCanceling, expectProductScheduled, expectProductNotPresent } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
|
||||
import { expectProductTrialing, expectProductNotTrialing } from "@tests/integration/billing/utils/expectCustomerProductTrialing";
|
||||
import { expectPreviewNextCycleCorrect } from "@tests/integration/billing/utils/expectPreviewNextCycleCorrect";
|
||||
import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect";
|
||||
import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect";
|
||||
import { expectProductAttached, expectScheduledApiSub } from "@tests/utils/expectUtils/expectProductAttached";
|
||||
```
|
||||
@@ -274,7 +275,56 @@ expectPreviewNextCycleCorrect({
|
||||
|
||||
This ensures the Stripe subscription state matches Autumn's internal state.
|
||||
|
||||
### `expectSubToBeCorrect`
|
||||
### `expectStripeSubscriptionCorrect` (PREFERRED for new tests)
|
||||
|
||||
Verifies Stripe subscriptions match expected state derived from customer products.
|
||||
Handles inline entity-scoped prices, subscription schedules, and cancellation.
|
||||
Uses `buildStripePhasesUpdate` (production code) to compute expected state.
|
||||
|
||||
```typescript
|
||||
import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect";
|
||||
|
||||
await expectStripeSubscriptionCorrect({
|
||||
ctx, // TestContext from initScenario
|
||||
customerId,
|
||||
options?: {
|
||||
subCount?: number, // Expected total subscription count
|
||||
subId?: string, // Verify a specific subscription only
|
||||
status?: "active" | "trialing",
|
||||
shouldBeCanceled?: boolean, // Override: expect canceling state
|
||||
rewards?: string[], // Expected coupon/discount IDs
|
||||
debug?: boolean, // Log detailed comparison info
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**Key features:**
|
||||
- Matches inline items by `autumn_customer_price_id` metadata
|
||||
- Validates `unit_amount_decimal` on inline prices — catches stale/wrong price amounts on Stripe subscription items
|
||||
- Validates schedule phases (multi_phase scenarios) including item-level comparison
|
||||
- Handles post-cycle schedule release (Stripe keeps schedule ID but status is "released")
|
||||
- Works with entity-scoped prepaid products
|
||||
|
||||
```typescript
|
||||
// Basic usage — verify all subscriptions for a customer
|
||||
await expectStripeSubscriptionCorrect({ ctx, customerId });
|
||||
|
||||
// With subscription count check
|
||||
await expectStripeSubscriptionCorrect({
|
||||
ctx,
|
||||
customerId,
|
||||
options: { subCount: 1 },
|
||||
});
|
||||
|
||||
// Debug mode for troubleshooting
|
||||
await expectStripeSubscriptionCorrect({
|
||||
ctx,
|
||||
customerId,
|
||||
options: { debug: true },
|
||||
});
|
||||
```
|
||||
|
||||
### `expectSubToBeCorrect` (Legacy — use for existing tests only)
|
||||
|
||||
Deep verification of subscription state in database. **Use for paid products.**
|
||||
|
||||
@@ -315,11 +365,11 @@ await expectNoStripeSubscription({
|
||||
|
||||
| Scenario | Utility |
|
||||
|----------|---------|
|
||||
| Attached paid product | `expectSubToBeCorrect` |
|
||||
| Attached free product | `expectNoStripeSubscription` |
|
||||
| Upgraded free → paid | `expectSubToBeCorrect` |
|
||||
| Downgraded paid → free (after cycle) | `expectNoStripeSubscription` |
|
||||
| Scheduled downgrade (before cycle) | `expectSubToBeCorrect` (sub still exists until cycle end) |
|
||||
| New test with paid product | `expectStripeSubscriptionCorrect` |
|
||||
| New test with entity-scoped inline prices | `expectStripeSubscriptionCorrect` |
|
||||
| Existing test (don't change unless updating) | `expectSubToBeCorrect` |
|
||||
| Free product / downgrade to free | `expectNoStripeSubscription` |
|
||||
| Scheduled downgrade (before cycle) | `expectStripeSubscriptionCorrect` (validates schedule phases) |
|
||||
|
||||
## Complete Example
|
||||
|
||||
@@ -399,14 +449,8 @@ test.concurrent(`${chalk.yellowBright("trial: full lifecycle")}`, async () => {
|
||||
latestTotal: 20,
|
||||
});
|
||||
|
||||
// Verify subscription state in DB
|
||||
await expectSubToBeCorrect({
|
||||
db: ctx.db,
|
||||
customerId,
|
||||
org: ctx.org,
|
||||
env: ctx.env,
|
||||
flags: { checkNotTrialing: true },
|
||||
});
|
||||
// Verify Stripe subscription matches expected state
|
||||
await expectStripeSubscriptionCorrect({ ctx, customerId });
|
||||
});
|
||||
```
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
type DeferredAutumnBillingPlanData,
|
||||
featureOptionUtils,
|
||||
getStartingBalance,
|
||||
isCustomerProductEntityScoped,
|
||||
priceUtils,
|
||||
} from "@autumn/shared";
|
||||
import { stripeCheckoutSessionUtils } from "@/external/stripe/checkoutSessions/utils";
|
||||
@@ -36,6 +37,12 @@ export const updateOptionsFromStripeCheckoutSession = async ({
|
||||
if (!price || priceUtils.isTieredOneOff({ price, product: fullProduct }))
|
||||
continue;
|
||||
|
||||
// Entity-scoped products use inline prices with pre-calculated amounts;
|
||||
// the checkout line item quantity is not meaningful, so keep original options.
|
||||
if (isCustomerProductEntityScoped(newCustomerProduct)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const featureOptionsQuantity =
|
||||
stripeCheckoutSessionUtils.convert.toFeatureOptionsQuantity({
|
||||
stripeCheckoutSession,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { BillingContext } from "@autumn/shared";
|
||||
import {
|
||||
type BillingPeriod,
|
||||
cloneEntitlementWithUpdatedQuantity,
|
||||
@@ -14,7 +15,6 @@ import {
|
||||
usagePriceToLineItem,
|
||||
} from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import type { BillingContext } from "@autumn/shared";
|
||||
|
||||
export const computeUpdateQuantityLineItems = ({
|
||||
ctx,
|
||||
|
||||
@@ -112,6 +112,11 @@ export const executeStripeBillingPlan = async ({
|
||||
stripeSubscriptionScheduleId: stripeSubscriptionSchedule.id,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(
|
||||
"STRIPE SUBSCRIPTION SCHEDULE: ",
|
||||
JSON.stringify(stripeSubscriptionSchedule, null, 2),
|
||||
);
|
||||
}
|
||||
|
||||
const stripeInvoice =
|
||||
|
||||
@@ -18,6 +18,7 @@ const toCreatePhase = (
|
||||
items: phase.items?.map((item) => ({
|
||||
price: item.price,
|
||||
quantity: item.quantity,
|
||||
...(item.metadata && { metadata: item.metadata }),
|
||||
})),
|
||||
end_date: typeof phase.end_date === "number" ? phase.end_date : undefined,
|
||||
discounts: phase.discounts as
|
||||
@@ -29,13 +30,18 @@ const toCreatePhase = (
|
||||
* Builds phases for updating a schedule that was created from a subscription.
|
||||
* The first phase must use the schedule's actual current phase start_date AND items.
|
||||
* Stripe doesn't allow modifying items in an active phase, so we preserve them exactly.
|
||||
*
|
||||
* Stripe's `from_subscription` doesn't copy item-level metadata onto schedule phase items,
|
||||
* so we re-apply metadata from the subscription items (which DO have it) by matching price ID.
|
||||
*/
|
||||
const buildAnchoredPhases = ({
|
||||
params,
|
||||
existingSchedule,
|
||||
stripeSubscription,
|
||||
}: {
|
||||
params: { phases?: Stripe.SubscriptionScheduleUpdateParams.Phase[] };
|
||||
existingSchedule: Stripe.SubscriptionSchedule;
|
||||
stripeSubscription?: Stripe.Subscription;
|
||||
}): Stripe.SubscriptionScheduleUpdateParams.Phase[] => {
|
||||
const inputPhases = params.phases ?? [];
|
||||
if (inputPhases.length === 0) return [];
|
||||
@@ -45,12 +51,39 @@ const buildAnchoredPhases = ({
|
||||
throw new Error("Cannot update schedule: missing current phase start_date");
|
||||
}
|
||||
|
||||
// Build a lookup of price ID → metadata from the subscription items
|
||||
const subItemMetadataByPriceId = new Map<string, Record<string, string>>();
|
||||
if (stripeSubscription) {
|
||||
for (const subItem of stripeSubscription.items.data) {
|
||||
if (subItem.metadata && Object.keys(subItem.metadata).length > 0) {
|
||||
subItemMetadataByPriceId.set(subItem.price.id, subItem.metadata);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Map existing items to update format (response type -> request type)
|
||||
// Re-apply metadata from subscription items since Stripe's from_subscription strips it
|
||||
const existingFirstPhaseItems: Stripe.SubscriptionScheduleUpdateParams.Phase["items"] =
|
||||
existingSchedule.phases[0]?.items.map((item) => ({
|
||||
price: typeof item.price === "string" ? item.price : item.price?.id,
|
||||
quantity: item.quantity ?? undefined,
|
||||
}));
|
||||
existingSchedule.phases[0]?.items.map((item) => {
|
||||
const priceId =
|
||||
typeof item.price === "string" ? item.price : item.price?.id;
|
||||
|
||||
// Prefer metadata from the subscription item (reliable source)
|
||||
const subMetadata = priceId
|
||||
? subItemMetadataByPriceId.get(priceId)
|
||||
: undefined;
|
||||
const metadata =
|
||||
subMetadata ??
|
||||
(item.metadata && Object.keys(item.metadata).length > 0
|
||||
? item.metadata
|
||||
: undefined);
|
||||
|
||||
return {
|
||||
price: priceId,
|
||||
quantity: item.quantity ?? undefined,
|
||||
...(metadata && { metadata }),
|
||||
};
|
||||
});
|
||||
|
||||
// First phase: preserve start_date AND items from existing schedule
|
||||
// Stripe doesn't allow modifying items in an active/in-progress phase
|
||||
@@ -74,16 +107,22 @@ const createScheduleFromSubscription = async ({
|
||||
stripeCli,
|
||||
subscriptionId,
|
||||
params,
|
||||
stripeSubscription,
|
||||
}: {
|
||||
stripeCli: Stripe;
|
||||
subscriptionId: string;
|
||||
params: Stripe.SubscriptionScheduleUpdateParams;
|
||||
stripeSubscription?: Stripe.Subscription;
|
||||
}): Promise<Stripe.SubscriptionSchedule> => {
|
||||
const schedule = await stripeCli.subscriptionSchedules.create({
|
||||
from_subscription: subscriptionId,
|
||||
});
|
||||
|
||||
const phases = buildAnchoredPhases({ params, existingSchedule: schedule });
|
||||
const phases = buildAnchoredPhases({
|
||||
params,
|
||||
existingSchedule: schedule,
|
||||
stripeSubscription,
|
||||
});
|
||||
|
||||
return await stripeCli.subscriptionSchedules.update(schedule.id, {
|
||||
phases,
|
||||
@@ -125,6 +164,7 @@ export const executeStripeSubscriptionScheduleAction = async ({
|
||||
stripeCli,
|
||||
subscriptionId: stripeSubscription.id,
|
||||
params,
|
||||
stripeSubscription,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -162,6 +202,7 @@ export const executeStripeSubscriptionScheduleAction = async ({
|
||||
? subscriptionId
|
||||
: subscriptionId.id,
|
||||
params,
|
||||
stripeSubscription,
|
||||
});
|
||||
|
||||
// Update existing customer products with the new schedule ID
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { customerProductsToOneOffStripeItemSpecs } from "@/internal/billing/v2/providers/stripe/utils/stripeItemSpec/customerProductsToOneOffStripeItemSpecs";
|
||||
import { customerProductsToRecurringStripeItemSpecs } from "@/internal/billing/v2/providers/stripe/utils/stripeItemSpec/customerProductsToRecurringStripeItemSpecs";
|
||||
import { filterStripeItemSpecsByLargestInterval } from "@/internal/billing/v2/providers/stripe/utils/stripeItemSpec/filterStripeItemSpecsByLargestInterval";
|
||||
import { stripeItemSpecToCheckoutLineItem } from "@/internal/billing/v2/providers/stripe/utils/stripeItemSpec/stripeItemSpecToStripeParam";
|
||||
import { updateOneOffTieredItems } from "./updateOneOffTieredItems";
|
||||
|
||||
export const buildStripeCheckoutSessionItems = ({
|
||||
@@ -50,7 +51,8 @@ export const buildStripeCheckoutSessionItems = ({
|
||||
|
||||
// 5. Convert recurring item specs to line items
|
||||
const recurringLineItems = recurringStripeItemSpecs.map((item) => {
|
||||
const { autumnPrice, quantity, stripePriceId, autumnEntitlement } = item;
|
||||
const { autumnPrice, autumnEntitlement } = item;
|
||||
const lineItem = stripeItemSpecToCheckoutLineItem({ spec: item });
|
||||
|
||||
// If it's a prepaid price, allow adjustable quantity
|
||||
if (autumnPrice && autumnEntitlement && isPrepaidPrice(autumnPrice)) {
|
||||
@@ -60,8 +62,7 @@ export const buildStripeCheckoutSessionItems = ({
|
||||
);
|
||||
|
||||
return {
|
||||
price: stripePriceId,
|
||||
quantity: quantity ?? 0,
|
||||
...lineItem,
|
||||
adjustable_quantity: isAdjustable
|
||||
? {
|
||||
enabled: true,
|
||||
@@ -75,11 +76,7 @@ export const buildStripeCheckoutSessionItems = ({
|
||||
} as Stripe.Checkout.SessionCreateParams.LineItem;
|
||||
}
|
||||
|
||||
// Fixed price
|
||||
return {
|
||||
price: stripePriceId,
|
||||
quantity: quantity ?? 0,
|
||||
};
|
||||
return lineItem;
|
||||
});
|
||||
|
||||
// 6. Convert one-off item specs to line items (handles tiered one-off prices)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
cusEntToBillingObjects,
|
||||
type FullCusEntWithFullCusProduct,
|
||||
type StripeItemSpec,
|
||||
type UsagePriceConfig,
|
||||
} from "@autumn/shared";
|
||||
import { cusEntToInvoiceUsage } from "@shared/utils/cusEntUtils/overageUtils/cusEntToInvoiceUsage";
|
||||
|
||||
/**
|
||||
* Converts an in-arrear prorated (allocated) price to a StripeItemSpec.
|
||||
* Computes existing usage from the cusEnt.
|
||||
*/
|
||||
export const allocatedToStripeItemSpec = ({
|
||||
cusEntWithCusProduct,
|
||||
}: {
|
||||
cusEntWithCusProduct: FullCusEntWithFullCusProduct;
|
||||
}): StripeItemSpec | null => {
|
||||
const billing = cusEntToBillingObjects({ cusEnt: cusEntWithCusProduct });
|
||||
if (!billing) return null;
|
||||
|
||||
const { price, product } = billing;
|
||||
const config = price.config as UsagePriceConfig;
|
||||
|
||||
const existingUsage = cusEntToInvoiceUsage({ cusEnt: cusEntWithCusProduct });
|
||||
|
||||
return {
|
||||
stripePriceId: config.stripe_price_id!,
|
||||
quantity: existingUsage,
|
||||
autumnPrice: price,
|
||||
autumnProduct: product,
|
||||
autumnCusEnt: cusEntWithCusProduct,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
cusEntToBillingObjects,
|
||||
type FullCusEntWithFullCusProduct,
|
||||
InternalError,
|
||||
isConsumablePrice,
|
||||
type StripeItemSpec,
|
||||
type UsagePriceConfig,
|
||||
} from "@autumn/shared";
|
||||
|
||||
/**
|
||||
* Converts a usage-in-arrear (consumable) price to a StripeItemSpec.
|
||||
* For entity-scoped / beta API / Vercel, uses the empty price with quantity 0.
|
||||
*/
|
||||
export const consumableToStripeItemSpec = ({
|
||||
cusEntWithCusProduct,
|
||||
}: {
|
||||
cusEntWithCusProduct: FullCusEntWithFullCusProduct;
|
||||
}): StripeItemSpec | null => {
|
||||
const billing = cusEntToBillingObjects({ cusEnt: cusEntWithCusProduct });
|
||||
if (!billing) return null;
|
||||
|
||||
const { price, product } = billing;
|
||||
|
||||
if (!isConsumablePrice(price)) {
|
||||
throw new InternalError({
|
||||
message: `[consumableToStripeItemSpec] Price ${price.id} is not a consumable price`,
|
||||
});
|
||||
}
|
||||
|
||||
const config = price.config as UsagePriceConfig;
|
||||
|
||||
const priceId = config.stripe_price_id ?? config.stripe_empty_price_id;
|
||||
if (!priceId) {
|
||||
throw new InternalError({
|
||||
message: `[consumableToStripeItemSpec] config.stripe_price_id is empty for autumn price: ${price.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
stripePriceId: priceId,
|
||||
autumnPrice: price,
|
||||
autumnProduct: product,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
cusEntsToAllowance,
|
||||
type FullCusEntWithFullCusProduct,
|
||||
InternalError,
|
||||
isVolumePrice,
|
||||
type Organization,
|
||||
orgToCurrency,
|
||||
priceToLineAmount,
|
||||
type StripeInlinePrice,
|
||||
} from "@autumn/shared";
|
||||
import { cusEntsToPrepaidQuantity } from "@shared/utils/cusEntUtils/balanceUtils/cusEntsToPrepaidQuantity";
|
||||
import { cusEntToCusPrice } from "@shared/utils/cusEntUtils/convertCusEntUtils/cusEntToCusPrice";
|
||||
import { atmnToStripeAmountDecimal } from "@shared/utils/productUtils/priceUtils/convertAmountUtils";
|
||||
import { priceToStripeRecurringParams } from "@shared/utils/productUtils/priceUtils/convertPrice/priceToStripeRecurringParams";
|
||||
import { Decimal } from "decimal.js";
|
||||
|
||||
/**
|
||||
* Builds a flat inline Stripe price for an entity-scoped prepaid item.
|
||||
* Calculates the total amount using tier logic,
|
||||
* since Stripe doesn't support tiered price_data on inline prices.
|
||||
*/
|
||||
export const cusEntToInlineStripePrice = ({
|
||||
cusEnt,
|
||||
org,
|
||||
}: {
|
||||
cusEnt: FullCusEntWithFullCusProduct;
|
||||
org: Organization;
|
||||
}): StripeInlinePrice => {
|
||||
const cusPrice = cusEntToCusPrice({ cusEnt });
|
||||
if (!cusPrice) {
|
||||
throw new InternalError({
|
||||
message: `[cusEntToInlineStripePrice] No cus price found for cus ent (feature: ${cusEnt.entitlement.feature_id})`,
|
||||
});
|
||||
}
|
||||
|
||||
const price = cusPrice.price;
|
||||
const recurring = priceToStripeRecurringParams({ price });
|
||||
const currency = orgToCurrency({ org });
|
||||
|
||||
const productId = price.config.stripe_product_id;
|
||||
if (!productId) {
|
||||
throw new InternalError({
|
||||
message: `[cusEntToInlineStripePrice] Price ${price.id} has no stripe_product_id for inline price`,
|
||||
});
|
||||
}
|
||||
|
||||
// 1. Get overage (purchased quantity in feature units, excluding allowance)
|
||||
let overage = cusEntsToPrepaidQuantity({
|
||||
cusEnts: [cusEnt],
|
||||
sumAcrossEntities: false,
|
||||
useUpcomingQuantity: true,
|
||||
});
|
||||
|
||||
// 2. Get allowance
|
||||
const allowance = cusEntsToAllowance({ cusEnts: [cusEnt] });
|
||||
|
||||
// 3. Volume pricing: total quantity determines the tier, entire amount is charged
|
||||
if (isVolumePrice(price)) {
|
||||
overage = new Decimal(overage).add(allowance).toNumber();
|
||||
}
|
||||
|
||||
// 4. Calculate total dollar amount using tier logic
|
||||
const totalAmount = priceToLineAmount({
|
||||
price,
|
||||
overage,
|
||||
allowance,
|
||||
});
|
||||
|
||||
const totalStripeAmount = atmnToStripeAmountDecimal({
|
||||
amount: totalAmount,
|
||||
currency,
|
||||
});
|
||||
|
||||
return {
|
||||
product: productId,
|
||||
currency,
|
||||
recurring: recurring!,
|
||||
unit_amount_decimal: totalStripeAmount,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
type BillingContext,
|
||||
cusPriceToCusEntWithCusProduct,
|
||||
type FullCusProduct,
|
||||
type FullCustomerPrice,
|
||||
isAllocatedPrice,
|
||||
isConsumablePrice,
|
||||
isFixedPrice,
|
||||
isPrepaidPrice,
|
||||
type StripeItemSpec,
|
||||
} from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { allocatedToStripeItemSpec } from "./allocatedToStripeItemSpec";
|
||||
import { consumableToStripeItemSpec } from "./consumableToStripeItemSpec";
|
||||
import { fixedPriceToStripeItemSpec } from "./fixedPriceToStripeItemSpec";
|
||||
import { prepaidToStripeItemSpec } from "./prepaidToStripeItemSpec";
|
||||
|
||||
/**
|
||||
* Converts a single customer price to a StripeItemSpec.
|
||||
* Resolves the associated cusEnt, then dispatches to the appropriate handler.
|
||||
*/
|
||||
export const cusPriceToStripeItemSpec = ({
|
||||
ctx,
|
||||
cusPrice,
|
||||
cusProduct,
|
||||
billingContext,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
cusPrice: FullCustomerPrice;
|
||||
cusProduct: FullCusProduct;
|
||||
billingContext?: BillingContext;
|
||||
}): StripeItemSpec | null => {
|
||||
const price = cusPrice.price;
|
||||
|
||||
let spec: StripeItemSpec | null = null;
|
||||
|
||||
// 1. Fixed / one-off price (no entitlement needed)
|
||||
if (isFixedPrice(price)) {
|
||||
spec = fixedPriceToStripeItemSpec({ cusPrice, cusProduct });
|
||||
} else {
|
||||
// Resolve cusEntWithCusProduct for usage-based prices
|
||||
const cusEntWithCusProduct = cusPriceToCusEntWithCusProduct({
|
||||
cusProduct,
|
||||
cusPrice,
|
||||
cusEnts: cusProduct.customer_entitlements,
|
||||
});
|
||||
|
||||
if (!cusEntWithCusProduct) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. Prepaid (usage-in-advance)
|
||||
if (isPrepaidPrice(price)) {
|
||||
spec = prepaidToStripeItemSpec({
|
||||
ctx,
|
||||
cusEntWithCusProduct,
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Consumable (usage-in-arrear)
|
||||
if (isConsumablePrice(price)) {
|
||||
spec = consumableToStripeItemSpec({
|
||||
cusEntWithCusProduct,
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Allocated (in-arrear prorated)
|
||||
if (isAllocatedPrice(price)) {
|
||||
spec = allocatedToStripeItemSpec({ cusEntWithCusProduct });
|
||||
}
|
||||
}
|
||||
|
||||
if (!spec) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Attach metadata for correlating Stripe items back to Autumn prices
|
||||
spec.metadata = {
|
||||
autumn_price_id: price.id,
|
||||
autumn_customer_price_id: cusPrice.id,
|
||||
};
|
||||
|
||||
return spec;
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
cusProductToProduct,
|
||||
type FixedPriceConfig,
|
||||
type FullCusProduct,
|
||||
type FullCustomerPrice,
|
||||
InternalError,
|
||||
type StripeItemSpec,
|
||||
} from "@autumn/shared";
|
||||
|
||||
/** Converts a fixed-cycle or one-off price to a StripeItemSpec. */
|
||||
export const fixedPriceToStripeItemSpec = ({
|
||||
cusPrice,
|
||||
cusProduct,
|
||||
}: {
|
||||
cusPrice: FullCustomerPrice;
|
||||
cusProduct: FullCusProduct;
|
||||
}): StripeItemSpec => {
|
||||
const price = cusPrice.price;
|
||||
const product = cusProductToProduct({ cusProduct });
|
||||
const config = price.config as FixedPriceConfig;
|
||||
|
||||
if (!config.stripe_price_id) {
|
||||
throw new InternalError({
|
||||
message: `[fixedPriceToStripeItemSpec] Price ${price.id} has no config.stripe_price_id`,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
stripePriceId: config.stripe_price_id,
|
||||
quantity: 1,
|
||||
autumnPrice: price,
|
||||
autumnProduct: product,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
cusEntToBillingObjects,
|
||||
type FullCusEntWithFullCusProduct,
|
||||
featureOptionUtils,
|
||||
InternalError,
|
||||
isPrepaidPrice,
|
||||
type StripeItemSpec,
|
||||
type UsagePriceConfig,
|
||||
} from "@autumn/shared";
|
||||
import { notNullish } from "@server/utils/genUtils";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { cusEntToInlineStripePrice } from "./cusEntToInlineStripePrice";
|
||||
|
||||
/**
|
||||
* Converts a prepaid (usage-in-advance) price to a StripeItemSpec.
|
||||
* For entity-scoped products, uses an inline price so each entity gets unique tiers.
|
||||
* For non-entity-scoped, uses the stored stripe_prepaid_price_v2_id.
|
||||
*/
|
||||
export const prepaidToStripeItemSpec = ({
|
||||
ctx,
|
||||
cusEntWithCusProduct,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
cusEntWithCusProduct: FullCusEntWithFullCusProduct;
|
||||
}): StripeItemSpec | null => {
|
||||
const billing = cusEntToBillingObjects({ cusEnt: cusEntWithCusProduct });
|
||||
if (!billing) return null;
|
||||
|
||||
const { cusProduct, price, product, entitlement, options } = billing;
|
||||
|
||||
if (!isPrepaidPrice(price)) {
|
||||
throw new InternalError({
|
||||
message: `[prepaidToStripeItemSpec] Price ${price.id} is not a prepaid price`,
|
||||
});
|
||||
}
|
||||
|
||||
const config = price.config as UsagePriceConfig;
|
||||
const isEntityScoped = notNullish(cusProduct.internal_entity_id);
|
||||
|
||||
if (isEntityScoped) {
|
||||
const inlinePrice = cusEntToInlineStripePrice({
|
||||
cusEnt: cusEntWithCusProduct,
|
||||
org: ctx.org,
|
||||
});
|
||||
|
||||
return {
|
||||
stripeInlinePrice: inlinePrice,
|
||||
quantity: 1,
|
||||
autumnPrice: price,
|
||||
autumnEntitlement: entitlement,
|
||||
autumnProduct: product,
|
||||
autumnCusEnt: cusEntWithCusProduct,
|
||||
};
|
||||
}
|
||||
|
||||
const quantity = featureOptionUtils.convert.toV2StripeQuantity({
|
||||
featureOptions: options ?? undefined,
|
||||
price,
|
||||
entitlement,
|
||||
});
|
||||
|
||||
return {
|
||||
stripePriceId: config.stripe_prepaid_price_v2_id!,
|
||||
quantity,
|
||||
autumnPrice: price,
|
||||
autumnEntitlement: entitlement,
|
||||
autumnProduct: product,
|
||||
autumnCusEnt: cusEntWithCusProduct,
|
||||
};
|
||||
};
|
||||
@@ -1,15 +1,15 @@
|
||||
import type { BillingContext, StripeItemSpec } from "@autumn/shared";
|
||||
import type {
|
||||
BillingContext,
|
||||
FullCusProduct,
|
||||
StripeItemSpec,
|
||||
} from "@autumn/shared";
|
||||
import { customerProductToStripeItemSpecs } from "@server/internal/billing/v2/providers/stripe/utils/subscriptionItems/customerProductToStripeItemSpecs";
|
||||
import type { FullCusProduct } from "@shared/models/cusProductModels/cusProductModels";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
|
||||
/**
|
||||
* Convert customer products to recurring stripe item specs.
|
||||
* For metered prices (quantity undefined), we preserve undefined as Stripe requires.
|
||||
* @param ctx - The context
|
||||
* @param billingContext - The billing context
|
||||
* @param customerProducts - The customer products
|
||||
* @returns The recurring stripe item specs
|
||||
* Converts customer products to recurring stripe item specs.
|
||||
* Deduplicates stored-price items by stripePriceId (accumulating quantities).
|
||||
* Entity-scoped inline items are never deduplicated — each entity gets its own item.
|
||||
*/
|
||||
export const customerProductsToRecurringStripeItemSpecs = ({
|
||||
ctx,
|
||||
@@ -20,7 +20,8 @@ export const customerProductsToRecurringStripeItemSpecs = ({
|
||||
billingContext: BillingContext;
|
||||
customerProducts: FullCusProduct[];
|
||||
}): StripeItemSpec[] => {
|
||||
const stripeItemSpecsByPriceId = new Map<string, StripeItemSpec>();
|
||||
const storedPriceSpecs = new Map<string, StripeItemSpec>();
|
||||
const inlineSpecs: StripeItemSpec[] = [];
|
||||
|
||||
for (const customerProduct of customerProducts) {
|
||||
const { recurringItems } = customerProductToStripeItemSpecs({
|
||||
@@ -29,31 +30,29 @@ export const customerProductsToRecurringStripeItemSpecs = ({
|
||||
customerProduct,
|
||||
});
|
||||
|
||||
for (const recurringItem of recurringItems) {
|
||||
const existingItem = stripeItemSpecsByPriceId.get(
|
||||
recurringItem.stripePriceId,
|
||||
);
|
||||
for (const item of recurringItems) {
|
||||
// Entity-scoped inline items are never deduplicated
|
||||
if (item.stripeInlinePrice) {
|
||||
inlineSpecs.push(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (existingItem) {
|
||||
// For metered prices, quantity is undefined and should stay undefined
|
||||
if (
|
||||
recurringItem.quantity === undefined &&
|
||||
existingItem.quantity === undefined
|
||||
) {
|
||||
// Both metered - keep undefined
|
||||
const priceId = item.stripePriceId!;
|
||||
const existing = storedPriceSpecs.get(priceId);
|
||||
|
||||
if (existing) {
|
||||
// Metered prices: quantity is undefined, keep undefined
|
||||
if (item.quantity === undefined && existing.quantity === undefined) {
|
||||
// Both metered — keep as-is
|
||||
} else {
|
||||
// Licensed prices - accumulate quantity
|
||||
existingItem.quantity =
|
||||
(existingItem.quantity ?? 0) + (recurringItem.quantity ?? 0);
|
||||
// Licensed prices — accumulate quantity
|
||||
existing.quantity = (existing.quantity ?? 0) + (item.quantity ?? 0);
|
||||
}
|
||||
} else {
|
||||
stripeItemSpecsByPriceId.set(
|
||||
recurringItem.stripePriceId,
|
||||
recurringItem,
|
||||
);
|
||||
storedPriceSpecs.set(priceId, item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(stripeItemSpecsByPriceId.values());
|
||||
return [...Array.from(storedPriceSpecs.values()), ...inlineSpecs];
|
||||
};
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { StripeInlinePrice, StripeItemSpec } from "@autumn/shared";
|
||||
import type Stripe from "stripe";
|
||||
|
||||
/**
|
||||
* Returns the price param for a StripeItemSpec — either a stored price ID or inline price_data.
|
||||
*/
|
||||
const toPriceParam = ({
|
||||
spec,
|
||||
}: {
|
||||
spec: StripeItemSpec;
|
||||
}): { price: string } | { price_data: StripeInlinePrice } => {
|
||||
if (spec.stripeInlinePrice) {
|
||||
return { price_data: spec.stripeInlinePrice };
|
||||
}
|
||||
return { price: spec.stripePriceId! };
|
||||
};
|
||||
|
||||
/** Converts a StripeItemSpec to a Stripe subscription item param (create or update). */
|
||||
export const stripeItemSpecToSubscriptionItem = ({
|
||||
spec,
|
||||
}: {
|
||||
spec: StripeItemSpec;
|
||||
}): Stripe.SubscriptionCreateParams.Item => {
|
||||
return {
|
||||
...toPriceParam({ spec }),
|
||||
...(spec.quantity !== undefined && { quantity: spec.quantity }),
|
||||
...(spec.metadata && { metadata: spec.metadata }),
|
||||
};
|
||||
};
|
||||
|
||||
/** Converts a StripeItemSpec to a Stripe checkout session line item. */
|
||||
export const stripeItemSpecToCheckoutLineItem = ({
|
||||
spec,
|
||||
}: {
|
||||
spec: StripeItemSpec;
|
||||
}): Stripe.Checkout.SessionCreateParams.LineItem => {
|
||||
return {
|
||||
...toPriceParam({ spec }),
|
||||
quantity: spec.quantity ?? 0,
|
||||
};
|
||||
};
|
||||
|
||||
/** Converts a StripeItemSpec to a Stripe subscription schedule phase item. */
|
||||
export const stripeItemSpecToPhaseItem = ({
|
||||
spec,
|
||||
}: {
|
||||
spec: StripeItemSpec;
|
||||
}): Stripe.SubscriptionScheduleUpdateParams.Phase.Item => {
|
||||
return {
|
||||
...toPriceParam({ spec }),
|
||||
...(spec.quantity !== undefined && { quantity: spec.quantity }),
|
||||
...(spec.metadata && { metadata: spec.metadata }),
|
||||
} as Stripe.SubscriptionScheduleUpdateParams.Phase.Item;
|
||||
};
|
||||
@@ -9,14 +9,12 @@ import { stripeSubscriptionItemToStripePriceId } from "@/external/stripe/subscri
|
||||
import { findStripeSubscriptionItemByStripePriceId } from "@/external/stripe/subscriptions/subscriptionItems/utils/findStripeSubscriptionItemUtils";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { customerProductsToRecurringStripeItemSpecs } from "@/internal/billing/v2/providers/stripe/utils/stripeItemSpec/customerProductsToRecurringStripeItemSpecs";
|
||||
import { stripeItemSpecToSubscriptionItem } from "@/internal/billing/v2/providers/stripe/utils/stripeItemSpec/stripeItemSpecToStripeParam";
|
||||
import { findStripeItemSpecByStripePriceId } from "./findStripeItemSpec";
|
||||
|
||||
/**
|
||||
* Convert stripe item specs to stripe subscription update params items.
|
||||
* For metered prices (quantity undefined), we don't include quantity as Stripe requires.
|
||||
* @param billingContext - The billing context
|
||||
* @param stripeItemSpecs - The stripe item specs
|
||||
* @returns The subscription item update params
|
||||
* Diffs desired stripe item specs against current subscription items.
|
||||
* Handles both stored-price and entity-scoped inline-price items.
|
||||
*/
|
||||
const stripeItemSpecsToSubItemsUpdate = ({
|
||||
billingContext,
|
||||
@@ -29,48 +27,51 @@ const stripeItemSpecsToSubItemsUpdate = ({
|
||||
const currentSubscriptionItems = stripeSubscription?.items.data ?? [];
|
||||
|
||||
const subItemsUpdate: Stripe.SubscriptionUpdateParams.Item[] = [];
|
||||
for (const stripeItemSpec of stripeItemSpecs) {
|
||||
|
||||
for (const spec of stripeItemSpecs) {
|
||||
// Inline prices are always new items (no existing sub item to match)
|
||||
if (spec.stripeInlinePrice) {
|
||||
subItemsUpdate.push(stripeItemSpecToSubscriptionItem({ spec }));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Stored price — check for existing subscription item
|
||||
if (!spec.stripePriceId) continue;
|
||||
|
||||
const existingItem = findStripeSubscriptionItemByStripePriceId({
|
||||
stripePriceId: stripeItemSpec.stripePriceId,
|
||||
stripePriceId: spec.stripePriceId,
|
||||
stripeSubscriptionItems: currentSubscriptionItems,
|
||||
});
|
||||
|
||||
const shouldUpdateItem =
|
||||
existingItem && existingItem.quantity !== stripeItemSpec.quantity;
|
||||
existingItem && existingItem.quantity !== spec.quantity;
|
||||
const shouldCreateItem = !existingItem;
|
||||
|
||||
if (shouldUpdateItem) {
|
||||
// For metered prices, don't include quantity
|
||||
if (stripeItemSpec.quantity === undefined) {
|
||||
subItemsUpdate.push({ id: existingItem.id });
|
||||
} else {
|
||||
subItemsUpdate.push({
|
||||
id: existingItem.id,
|
||||
quantity: stripeItemSpec.quantity,
|
||||
});
|
||||
}
|
||||
subItemsUpdate.push({
|
||||
id: existingItem.id,
|
||||
...(spec.quantity !== undefined && { quantity: spec.quantity }),
|
||||
...(spec.metadata && { metadata: spec.metadata }),
|
||||
});
|
||||
}
|
||||
|
||||
if (shouldCreateItem) {
|
||||
// For metered prices, don't include quantity
|
||||
if (stripeItemSpec.quantity === undefined) {
|
||||
subItemsUpdate.push({ price: stripeItemSpec.stripePriceId });
|
||||
} else {
|
||||
subItemsUpdate.push({
|
||||
price: stripeItemSpec.stripePriceId,
|
||||
quantity: stripeItemSpec.quantity,
|
||||
});
|
||||
}
|
||||
subItemsUpdate.push({
|
||||
price: spec.stripePriceId,
|
||||
...(spec.quantity !== undefined && { quantity: spec.quantity }),
|
||||
...(spec.metadata && { metadata: spec.metadata }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Remove subscription items that are no longer in the desired specs
|
||||
for (const subItem of currentSubscriptionItems) {
|
||||
const stripeItemSpec = findStripeItemSpecByStripePriceId({
|
||||
const matchingSpec = findStripeItemSpecByStripePriceId({
|
||||
stripePriceId: stripeSubscriptionItemToStripePriceId(subItem),
|
||||
stripeItemSpecs,
|
||||
});
|
||||
|
||||
const shouldRemoveItem = !stripeItemSpec;
|
||||
if (shouldRemoveItem) {
|
||||
if (!matchingSpec) {
|
||||
subItemsUpdate.push({ id: subItem.id, deleted: true });
|
||||
}
|
||||
}
|
||||
@@ -98,14 +99,14 @@ export const buildStripeSubscriptionItemsUpdate = ({
|
||||
customerProducts: relatedCustomerProducts,
|
||||
});
|
||||
|
||||
// 3. Get recurring subscription item array (doesn't include one off items)
|
||||
// 3. Get recurring subscription item array (doesn't include one-off items)
|
||||
const recurringStripeItemSpecs = customerProductsToRecurringStripeItemSpecs({
|
||||
ctx,
|
||||
billingContext,
|
||||
customerProducts: activeCustomerProducts,
|
||||
});
|
||||
|
||||
// 5. Diff it with the current subscription items
|
||||
// 4. Diff against current subscription items
|
||||
return stripeItemSpecsToSubItemsUpdate({
|
||||
billingContext,
|
||||
stripeItemSpecs: recurringStripeItemSpecs,
|
||||
|
||||
@@ -1,30 +1,15 @@
|
||||
import type { BillingContext } from "@autumn/shared";
|
||||
import {
|
||||
addCusProductToCusEnt,
|
||||
BillingVersion,
|
||||
cusPriceToCusEnt,
|
||||
cusProductToProduct,
|
||||
entToOptions,
|
||||
type FeatureOptions,
|
||||
type BillingContext,
|
||||
type FullCusProduct,
|
||||
formatPrice,
|
||||
InternalError,
|
||||
isAllocatedCustomerEntitlement,
|
||||
isOneOffPrice,
|
||||
priceUtils,
|
||||
type StripeItemSpec,
|
||||
} from "@autumn/shared";
|
||||
import { cusEntToInvoiceUsage } from "@shared/utils/cusEntUtils/overageUtils/cusEntToInvoiceUsage";
|
||||
import { priceToStripeItem } from "@/external/stripe/priceToStripeItem/priceToStripeItem";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { cusPriceToStripeItemSpec } from "@/internal/billing/v2/providers/stripe/utils/stripeItemSpec/cusPriceToStripeItemSpec/cusPriceToStripeItemSpec";
|
||||
|
||||
/**
|
||||
* Convert a customer product to stripe item specs
|
||||
* A stripe item spec is an internal intermediate type containing the stripe price id and quantity of the item.
|
||||
* @param ctx - The context
|
||||
* @param customerProduct - The customer product
|
||||
* @param billingContext - The billing context
|
||||
* @returns The stripe item specs
|
||||
* Converts a customer product to stripe item specs (recurring + one-off).
|
||||
* Delegates each cusPrice to cusPriceToStripeItemSpec.
|
||||
*/
|
||||
export const customerProductToStripeItemSpecs = ({
|
||||
ctx,
|
||||
@@ -38,86 +23,23 @@ export const customerProductToStripeItemSpecs = ({
|
||||
recurringItems: StripeItemSpec[];
|
||||
oneOffItems: StripeItemSpec[];
|
||||
} => {
|
||||
const { org } = ctx;
|
||||
const product = cusProductToProduct({ cusProduct: customerProduct });
|
||||
|
||||
const cusPrices = customerProduct.customer_prices;
|
||||
const cusEnts = customerProduct.customer_entitlements;
|
||||
|
||||
const fromVercel = billingContext?.paymentMethod?.type === "custom";
|
||||
|
||||
const recurringItems: StripeItemSpec[] = [];
|
||||
const oneOffItems: StripeItemSpec[] = [];
|
||||
|
||||
for (const cusPrice of cusPrices) {
|
||||
const price = cusPrice.price;
|
||||
const cusEnt = cusPriceToCusEnt({ cusPrice, cusEnts });
|
||||
const ent = cusEnt?.entitlement;
|
||||
|
||||
let options: FeatureOptions | undefined;
|
||||
let existingUsage: number | undefined;
|
||||
const cusEntWithCusProduct = cusEnt
|
||||
? addCusProductToCusEnt({
|
||||
cusEnt,
|
||||
cusProduct: customerProduct,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
if (cusEnt) {
|
||||
const ent = cusEnt.entitlement;
|
||||
options = entToOptions({ ent, options: customerProduct.options ?? [] });
|
||||
|
||||
if (
|
||||
cusEntWithCusProduct &&
|
||||
isAllocatedCustomerEntitlement(cusEntWithCusProduct)
|
||||
) {
|
||||
existingUsage = cusEntToInvoiceUsage({ cusEnt: cusEntWithCusProduct });
|
||||
}
|
||||
}
|
||||
|
||||
const stripeItem = priceToStripeItem({
|
||||
price,
|
||||
product,
|
||||
org,
|
||||
options,
|
||||
isCheckout: false, // TODO: Add this back in?
|
||||
relatedEnt: ent,
|
||||
existingUsage,
|
||||
// withEntity: notNullish(customerProduct.internal_entity_id),
|
||||
withEntity: false,
|
||||
apiVersion: ctx.apiVersion.value,
|
||||
fromVercel,
|
||||
isPrepaidPriceV2: billingContext?.billingVersion === BillingVersion.V2,
|
||||
for (const cusPrice of customerProduct.customer_prices) {
|
||||
const spec = cusPriceToStripeItemSpec({
|
||||
ctx,
|
||||
cusPrice,
|
||||
cusProduct: customerProduct,
|
||||
billingContext,
|
||||
});
|
||||
|
||||
if (!stripeItem) continue;
|
||||
if (!spec) continue;
|
||||
|
||||
const { lineItem } = stripeItem;
|
||||
|
||||
if (!lineItem.price && !priceUtils.isTieredOneOff({ price, product })) {
|
||||
throw new InternalError({
|
||||
message: `Autumn price ${formatPrice({ price })} has no stripe price id`,
|
||||
});
|
||||
}
|
||||
|
||||
if (isOneOffPrice(price)) {
|
||||
oneOffItems.push({
|
||||
stripePriceId: lineItem.price ?? "",
|
||||
quantity: lineItem?.quantity,
|
||||
autumnPrice: price,
|
||||
autumnEntitlement: ent,
|
||||
autumnProduct: product,
|
||||
autumnCusEnt: cusEntWithCusProduct,
|
||||
});
|
||||
if (isOneOffPrice(cusPrice.price)) {
|
||||
oneOffItems.push(spec);
|
||||
} else {
|
||||
recurringItems.push({
|
||||
stripePriceId: lineItem.price ?? "",
|
||||
quantity: lineItem?.quantity,
|
||||
autumnPrice: price,
|
||||
autumnEntitlement: ent,
|
||||
autumnProduct: product,
|
||||
autumnCusEnt: cusEntWithCusProduct,
|
||||
});
|
||||
recurringItems.push(spec);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import type Stripe from "stripe";
|
||||
import { logPhase } from "@/external/stripe/subscriptionSchedules/utils/logStripeSchedulePhaseUtils";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { stripeItemSpecToPhaseItem } from "@/internal/billing/v2/providers/stripe/utils/stripeItemSpec/stripeItemSpecToStripeParam";
|
||||
import { customerProductToStripeItemSpecs } from "@/internal/billing/v2/providers/stripe/utils/subscriptionItems/customerProductToStripeItemSpecs";
|
||||
import { isCustomerProductActiveDuringPeriod } from "@/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/isCustomerProductActiveAtEpochMs";
|
||||
import { buildTransitionPoints } from "./buildTransitionPoints";
|
||||
@@ -28,8 +29,8 @@ const normalizeCustomerProductTimestamps = (
|
||||
|
||||
/**
|
||||
* Converts customer products to Stripe schedule phase items.
|
||||
* Merges quantities for duplicate price IDs.
|
||||
* For metered prices (quantity undefined), we don't set quantity as Stripe requires.
|
||||
* Merges quantities for duplicate stored price IDs.
|
||||
* Entity-scoped inline items are kept separate (never merged).
|
||||
*/
|
||||
const customerProductsToPhaseItems = ({
|
||||
ctx,
|
||||
@@ -40,8 +41,8 @@ const customerProductsToPhaseItems = ({
|
||||
billingContext: BillingContext;
|
||||
customerProducts: FullCusProduct[];
|
||||
}): Stripe.SubscriptionScheduleUpdateParams.Phase.Item[] => {
|
||||
// Track stripePriceId -> quantity (undefined means metered/no quantity)
|
||||
const itemMap = new Map<string, number | undefined>();
|
||||
const storedPriceMap = new Map<string, number | undefined>();
|
||||
const inlineItems: Stripe.SubscriptionScheduleUpdateParams.Phase.Item[] = [];
|
||||
|
||||
for (const customerProduct of customerProducts) {
|
||||
const { recurringItems } = customerProductToStripeItemSpecs({
|
||||
@@ -51,26 +52,34 @@ const customerProductsToPhaseItems = ({
|
||||
});
|
||||
|
||||
for (const item of recurringItems) {
|
||||
// For metered prices, quantity is undefined and should stay undefined
|
||||
// Entity-scoped inline prices — never merge
|
||||
if (item.stripeInlinePrice) {
|
||||
inlineItems.push(stripeItemSpecToPhaseItem({ spec: item }));
|
||||
continue;
|
||||
}
|
||||
|
||||
const priceId = item.stripePriceId!;
|
||||
if (item.quantity === undefined) {
|
||||
// Metered price - don't set quantity
|
||||
if (!itemMap.has(item.stripePriceId)) {
|
||||
itemMap.set(item.stripePriceId, undefined);
|
||||
if (!storedPriceMap.has(priceId)) {
|
||||
storedPriceMap.set(priceId, undefined);
|
||||
}
|
||||
} else {
|
||||
// Licensed price - accumulate quantity
|
||||
const currentQuantity = itemMap.get(item.stripePriceId) ?? 0;
|
||||
itemMap.set(item.stripePriceId, currentQuantity + item.quantity);
|
||||
const current = storedPriceMap.get(priceId) ?? 0;
|
||||
storedPriceMap.set(priceId, current + item.quantity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(itemMap.entries()).map(([price, quantity]) => {
|
||||
if (quantity === undefined) {
|
||||
return { price };
|
||||
}
|
||||
return { price, quantity };
|
||||
});
|
||||
const storedItems = Array.from(storedPriceMap.entries()).map(
|
||||
([price, quantity]) => {
|
||||
if (quantity === undefined) {
|
||||
return { price };
|
||||
}
|
||||
return { price, quantity };
|
||||
},
|
||||
);
|
||||
|
||||
return [...storedItems, ...inlineItems];
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -28,8 +28,11 @@ export const buildStripeSubscriptionCreateAction = ({
|
||||
const stripeSubscriptionCreateParams: Stripe.SubscriptionCreateParams = {
|
||||
customer: stripeCustomer.id,
|
||||
items: subItemsUpdate.map((item) => ({
|
||||
price: item.price,
|
||||
...(item.price_data
|
||||
? { price_data: item.price_data }
|
||||
: { price: item.price }),
|
||||
quantity: item.quantity,
|
||||
...(item.metadata && { metadata: item.metadata }),
|
||||
})),
|
||||
|
||||
billing_mode: { type: "flexible" },
|
||||
|
||||
@@ -78,10 +78,27 @@ export const subToNewSchedule = async ({
|
||||
await stripeCli.subscriptionSchedules.update(newScheduleId, {
|
||||
phases: [
|
||||
{
|
||||
items: newSchedule.phases[0].items.map((item) => ({
|
||||
price: item.price as string,
|
||||
quantity: item.quantity,
|
||||
})),
|
||||
items: newSchedule.phases[0].items.map((item) => {
|
||||
const priceId = item.price as string;
|
||||
|
||||
// Re-apply metadata from subscription items since
|
||||
// Stripe's from_subscription doesn't copy item metadata
|
||||
const subItem = sub.items.data.find(
|
||||
(si) => si.price.id === priceId,
|
||||
);
|
||||
const metadata =
|
||||
subItem?.metadata && Object.keys(subItem.metadata).length > 0
|
||||
? subItem.metadata
|
||||
: item.metadata && Object.keys(item.metadata).length > 0
|
||||
? item.metadata
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
price: priceId,
|
||||
quantity: item.quantity,
|
||||
...(metadata && { metadata }),
|
||||
};
|
||||
}),
|
||||
start_date: newSchedule.phases[0].start_date,
|
||||
end_date: endOfBillingPeriod,
|
||||
trial_end: sub?.trial_end || undefined,
|
||||
|
||||
@@ -4,5 +4,11 @@ export const billingV2: TestGroup = {
|
||||
name: "billing-v2",
|
||||
description: "V2 billing tests: migrations, attach, update-subscription",
|
||||
tier: "domain",
|
||||
paths: ["migrations", "billing/attach", "billing/update-subscription"],
|
||||
paths: [
|
||||
"migrations",
|
||||
"billing/attach",
|
||||
"billing/update-subscription",
|
||||
"billing/multi-attach",
|
||||
"billing/setup-payment",
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { TestGroup } from "../../types";
|
||||
|
||||
export const prepaidVolume: TestGroup = {
|
||||
name: "prepaid-volume",
|
||||
description: "Prepaid volume-based tier pricing tests",
|
||||
tier: "domain",
|
||||
paths: [
|
||||
"unit/billing/invoicing/line-item-utils/volume-tiers-to-line-amount.test.ts",
|
||||
"unit/billing/invoicing/line-item-utils/tiers-to-line-amount.test.ts",
|
||||
"integration/billing/attach/new-plan/attach-prepaid-volume.test.ts",
|
||||
"integration/billing/attach/new-plan/attach-prepaid-volume-entities.test.ts",
|
||||
"integration/billing/attach/new-plan/new-prepaid.test.ts",
|
||||
"integration/billing/attach/immediate-switch/immediate-switch-prepaid-volume.test.ts",
|
||||
"integration/billing/attach/immediate-switch/immediate-switch-entities-prepaid-volume.test.ts",
|
||||
"integration/billing/attach/scheduled-switch/scheduled-switch-prepaid-volume.test.ts",
|
||||
"integration/billing/attach/edge-cases/v1-v2-compatibility/prepaid/attach-prepaid-volume-edge-cases.test.ts",
|
||||
"integration/billing/attach/checkout/stripe-checkout/stripe-checkout-prepaid.test.ts",
|
||||
"integration/billing/update-subscription/update-quantity/volume-tiers-update-quantity.test.ts",
|
||||
"integration/billing/update-subscription/custom-plan/update-paid-tier-behavior.test.ts",
|
||||
"integration/billing/legacy/attach/new/legacy-new-volume.test.ts",
|
||||
"integration/crud/plans/create-plan-advanced.test.ts",
|
||||
"integration/crud/plans/get-plan-advanced.test.ts",
|
||||
"integration/balances/check/check-prepaid.test.ts",
|
||||
"integration/balances/check/check-balance-price.test.ts",
|
||||
"integration/billing/attach/v2-params/v2-customize.test.ts",
|
||||
],
|
||||
};
|
||||
@@ -15,11 +15,11 @@ import { updateBalance } from "./domains/balances/updateBalance";
|
||||
import { billing } from "./domains/billing/billing";
|
||||
import { billingV1 } from "./domains/billing/billingV1";
|
||||
import { billingV2 } from "./domains/billing/billingV2";
|
||||
import { prepaidVolume } from "./domains/billing/prepaidVolume";
|
||||
import { crud } from "./domains/crud";
|
||||
import { misc } from "./domains/misc";
|
||||
import { webhooks } from "./domains/webhooks";
|
||||
import { suites } from "./suites";
|
||||
import { temp } from "./temp";
|
||||
import type { TestGroup, TestSuite } from "./types";
|
||||
|
||||
export type { TestGroup, TestSuite, TestTier } from "./types";
|
||||
@@ -39,7 +39,7 @@ const allGroups: TestGroup[] = [
|
||||
billing,
|
||||
billingV1,
|
||||
billingV2,
|
||||
prepaidVolume,
|
||||
temp,
|
||||
crud,
|
||||
webhooks,
|
||||
advanced,
|
||||
|
||||
15
server/tests/_groups/temp.ts
Normal file
15
server/tests/_groups/temp.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { TestGroup } from "./types";
|
||||
|
||||
export const temp: TestGroup = {
|
||||
name: "temp",
|
||||
description: "Entity prepaid test suite",
|
||||
tier: "domain",
|
||||
paths: [
|
||||
"tests/integration/billing/attach/scheduled-switch/scheduled-switch-prepaid-entities.test.ts",
|
||||
"tests/integration/billing/attach/new-plan/prepaid/attach-prepaid-entities.test.ts",
|
||||
"tests/integration/billing/attach/new-plan/prepaid/attach-prepaid-volume-entities.test.ts",
|
||||
"tests/integration/billing/attach/immediate-switch/immediate-switch-entities-prepaid-volume.test.ts",
|
||||
"tests/integration/billing/update-subscription/update-quantity/multi-entity-quantity.test.ts",
|
||||
"tests/integration/billing/update-subscription/update-quantity/multi-entity-quantity-proration.test.ts",
|
||||
],
|
||||
};
|
||||
@@ -1,46 +1,49 @@
|
||||
import { test } from "bun:test";
|
||||
import {
|
||||
FreeTrialDuration,
|
||||
type UpdateSubscriptionV1Params,
|
||||
} from "@autumn/shared";
|
||||
import { TestFeature } from "@tests/setup/v2Features";
|
||||
import { items } from "@tests/utils/fixtures/items";
|
||||
import { products } from "@tests/utils/fixtures/products";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
|
||||
import chalk from "chalk";
|
||||
|
||||
const customerId = "temp-test";
|
||||
const BILLING_UNITS = 100;
|
||||
const PRICE_PER_UNIT = 10;
|
||||
|
||||
const INCLUDED_USAGE = 100;
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("temp: rest update then rpc inverse update returns product to baseline")}`, async () => {
|
||||
const proProd = products.pro({
|
||||
id: "pro",
|
||||
items: [items.monthlyCredits({ includedUsage: 100 })],
|
||||
});
|
||||
const customerId = "temp";
|
||||
const customerId = "prepaid-ent-two-included";
|
||||
const quantity1 = 300;
|
||||
|
||||
const { autumnV1, autumnV2_1 } = await initScenario({
|
||||
const prepaidItem = items.prepaidMessages({
|
||||
includedUsage: INCLUDED_USAGE,
|
||||
billingUnits: BILLING_UNITS,
|
||||
price: PRICE_PER_UNIT,
|
||||
});
|
||||
|
||||
const pro = products.base({
|
||||
id: "base-prepaid-ent-inc",
|
||||
items: [prepaidItem],
|
||||
});
|
||||
|
||||
const { autumnV1, entities } = await initScenario({
|
||||
customerId,
|
||||
actions: [],
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [proProd] }),
|
||||
s.customer({}),
|
||||
s.products({ list: [pro] }),
|
||||
s.entities({ count: 2, featureId: TestFeature.Users }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
const result = await autumnV1.billing.attach({
|
||||
// Attach to entity 1
|
||||
const attach1 = await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
product_id: proProd.id,
|
||||
free_trial: {
|
||||
length: 14,
|
||||
duration: FreeTrialDuration.Day,
|
||||
},
|
||||
product_id: pro.id,
|
||||
entity_id: entities[0].id,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: quantity1 }],
|
||||
redirect_mode: "if_required",
|
||||
});
|
||||
|
||||
console.log(result);
|
||||
|
||||
const updateResult =
|
||||
await autumnV2_1.subscriptions.update<UpdateSubscriptionV1Params>({
|
||||
customer_id: customerId,
|
||||
plan_id: proProd.id,
|
||||
});
|
||||
console.log(updateResult);
|
||||
console.log("attach1", attach1);
|
||||
return;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
|
||||
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
|
||||
import { expectCustomerProductCorrect } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
|
||||
import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect";
|
||||
import { TestFeature } from "@tests/setup/v2Features";
|
||||
import { completeStripeCheckoutFormV2 } from "@tests/utils/browserPool";
|
||||
import { items } from "@tests/utils/fixtures/items";
|
||||
import { products } from "@tests/utils/fixtures/products";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
|
||||
import chalk from "chalk";
|
||||
|
||||
const BILLING_UNITS = 100;
|
||||
const PRICE_PER_UNIT = 10;
|
||||
|
||||
const INCLUDED_USAGE = 100;
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("attach: stripe checkout prepaid entities")}`, async () => {
|
||||
const customerId = "prepaid-ent-two-included";
|
||||
const quantity1 = 300;
|
||||
|
||||
const prepaidItem = items.prepaidMessages({
|
||||
includedUsage: INCLUDED_USAGE,
|
||||
billingUnits: BILLING_UNITS,
|
||||
price: PRICE_PER_UNIT,
|
||||
});
|
||||
|
||||
const pro = products.base({
|
||||
id: "base-prepaid-ent-inc",
|
||||
items: [prepaidItem],
|
||||
});
|
||||
|
||||
const { autumnV1, entities, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({}),
|
||||
s.products({ list: [pro] }),
|
||||
s.entities({ count: 2, featureId: TestFeature.Users }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
const params = {
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
entity_id: entities[0].id,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: quantity1 }],
|
||||
redirect_mode: "if_required",
|
||||
};
|
||||
|
||||
const preview = await autumnV1.billing.previewAttach(params);
|
||||
expect(preview.total).toBe(20);
|
||||
|
||||
// Attach to entity 1
|
||||
const res = await autumnV1.billing.attach(params);
|
||||
expect(res.payment_url).toBeDefined();
|
||||
expect(res.payment_url).toContain("checkout.stripe.com");
|
||||
|
||||
// Complete checkout
|
||||
|
||||
await completeStripeCheckoutFormV2({ url: res.payment_url });
|
||||
|
||||
const customerAfter = await autumnV1.customers.get(customerId);
|
||||
await expectCustomerProductCorrect({
|
||||
customerId,
|
||||
customer: customerAfter,
|
||||
productId: pro.id,
|
||||
state: "active",
|
||||
});
|
||||
|
||||
expectCustomerFeatureCorrect({
|
||||
customerId,
|
||||
customer: customerAfter,
|
||||
featureId: TestFeature.Messages,
|
||||
includedUsage: quantity1,
|
||||
balance: quantity1,
|
||||
usage: 0,
|
||||
});
|
||||
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customerId,
|
||||
customer: customerAfter,
|
||||
count: 1,
|
||||
latestTotal: 20,
|
||||
latestStatus: "paid",
|
||||
});
|
||||
|
||||
await expectStripeSubscriptionCorrect({
|
||||
ctx,
|
||||
customerId,
|
||||
});
|
||||
});
|
||||
test.concurrent(`${chalk.yellowBright("attach: stripe checkout prepaid volume entities")}`, async () => {
|
||||
const customerId = "prepaid-ent-two-volume";
|
||||
const quantity1 = 600;
|
||||
|
||||
const prepaidItem = items.volumePrepaidMessages({
|
||||
includedUsage: 100,
|
||||
billingUnits: 1,
|
||||
tiers: [
|
||||
{ to: 500, amount: 0, flat_amount: 30 },
|
||||
{ to: "inf" as const, amount: 0, flat_amount: 50 },
|
||||
],
|
||||
});
|
||||
|
||||
const pro = products.base({
|
||||
id: "base-prepaid-ent-vol",
|
||||
items: [prepaidItem],
|
||||
});
|
||||
|
||||
const { autumnV1, entities, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({}),
|
||||
s.products({ list: [pro] }),
|
||||
s.entities({ count: 2, featureId: TestFeature.Users }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
const params = {
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
entity_id: entities[0].id,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: quantity1 }],
|
||||
redirect_mode: "if_required",
|
||||
};
|
||||
|
||||
const preview = await autumnV1.billing.previewAttach(params);
|
||||
expect(preview.total).toBe(30); // flat amount
|
||||
|
||||
// Attach to entity 1
|
||||
const res = await autumnV1.billing.attach(params);
|
||||
expect(res.payment_url).toBeDefined();
|
||||
expect(res.payment_url).toContain("checkout.stripe.com");
|
||||
|
||||
// Complete checkout
|
||||
|
||||
await completeStripeCheckoutFormV2({ url: res.payment_url });
|
||||
|
||||
const customerAfter = await autumnV1.customers.get(customerId);
|
||||
await expectCustomerProductCorrect({
|
||||
customerId,
|
||||
customer: customerAfter,
|
||||
productId: pro.id,
|
||||
state: "active",
|
||||
});
|
||||
|
||||
expectCustomerFeatureCorrect({
|
||||
customerId,
|
||||
customer: customerAfter,
|
||||
featureId: TestFeature.Messages,
|
||||
includedUsage: quantity1,
|
||||
balance: quantity1,
|
||||
usage: 0,
|
||||
});
|
||||
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customerId,
|
||||
customer: customerAfter,
|
||||
count: 1,
|
||||
latestTotal: 30, // flat amount
|
||||
latestStatus: "paid",
|
||||
});
|
||||
|
||||
await expectStripeSubscriptionCorrect({
|
||||
ctx,
|
||||
customerId,
|
||||
});
|
||||
});
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
expectCustomerProducts,
|
||||
expectProductActive,
|
||||
} from "@tests/integration/billing/utils/expectCustomerProductCorrect";
|
||||
import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect";
|
||||
import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect";
|
||||
import { TestFeature } from "@tests/setup/v2Features";
|
||||
import { items } from "@tests/utils/fixtures/items";
|
||||
import { products } from "@tests/utils/fixtures/products";
|
||||
@@ -240,12 +240,7 @@ test.concurrent(`${chalk.yellowBright("attach-prepaid-volume-entities: graduated
|
||||
count: 4,
|
||||
});
|
||||
|
||||
await expectSubToBeCorrect({
|
||||
db: ctx.db,
|
||||
customerId,
|
||||
org: ctx.org,
|
||||
env: ctx.env,
|
||||
});
|
||||
await expectStripeSubscriptionCorrect({ ctx, customerId });
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -426,10 +421,5 @@ test.concurrent(`${chalk.yellowBright("attach-prepaid-volume-entities: graduated
|
||||
count: 4,
|
||||
});
|
||||
|
||||
await expectSubToBeCorrect({
|
||||
db: ctx.db,
|
||||
customerId,
|
||||
org: ctx.org,
|
||||
env: ctx.env,
|
||||
});
|
||||
await expectStripeSubscriptionCorrect({ ctx, customerId });
|
||||
});
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Attach Prepaid to Multiple Entities
|
||||
*
|
||||
* Test 1: Attaches a prepaid product with included usage to two separate entities.
|
||||
* Each entity gets its own independent balance.
|
||||
* Product: base (no base price) with prepaid messages (100 included, $10 per 100 extra)
|
||||
* Entity 1: quantity 300 → 100 included + 200 purchased (2×$10 = $20)
|
||||
* Entity 2: quantity 500 → 100 included + 400 purchased (4×$10 = $40)
|
||||
*
|
||||
* Test 2: Customer has the same prepaid product attached, then entities also attach it.
|
||||
* Customer balance = customer's own + sum of entity balances.
|
||||
* Entity balance = entity's own + customer's balance (inheritance).
|
||||
*/
|
||||
|
||||
import { expect, test } from "bun:test";
|
||||
import type { ApiCustomerV3, ApiEntityV0 } from "@autumn/shared";
|
||||
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
|
||||
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
|
||||
import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
|
||||
import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect";
|
||||
import { TestFeature } from "@tests/setup/v2Features";
|
||||
import { items } from "@tests/utils/fixtures/items";
|
||||
import { products } from "@tests/utils/fixtures/products";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
|
||||
import chalk from "chalk";
|
||||
|
||||
const BILLING_UNITS = 100;
|
||||
const PRICE_PER_UNIT = 10;
|
||||
const INCLUDED_USAGE = 100;
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("prepaid-entities: attach prepaid messages with included usage to two entities")}`, async () => {
|
||||
const customerId = "prepaid-ent-two-included";
|
||||
const quantity1 = 300;
|
||||
const quantity2 = 500;
|
||||
|
||||
const purchasedUnits1 = (quantity1 - INCLUDED_USAGE) / BILLING_UNITS;
|
||||
const purchasedUnits2 = (quantity2 - INCLUDED_USAGE) / BILLING_UNITS;
|
||||
const prepaidCost1 = purchasedUnits1 * PRICE_PER_UNIT;
|
||||
const prepaidCost2 = purchasedUnits2 * PRICE_PER_UNIT;
|
||||
|
||||
const prepaidItem = items.prepaidMessages({
|
||||
includedUsage: INCLUDED_USAGE,
|
||||
billingUnits: BILLING_UNITS,
|
||||
price: PRICE_PER_UNIT,
|
||||
});
|
||||
|
||||
const base = products.base({
|
||||
id: "base-prepaid-ent-inc",
|
||||
items: [prepaidItem],
|
||||
});
|
||||
|
||||
const { autumnV1, entities, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [base] }),
|
||||
s.entities({ count: 2, featureId: TestFeature.Users }),
|
||||
],
|
||||
actions: [
|
||||
s.billing.attach({
|
||||
productId: base.id,
|
||||
entityIndex: 0,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: quantity1 }],
|
||||
}),
|
||||
s.billing.attach({
|
||||
productId: base.id,
|
||||
entityIndex: 1,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: quantity2 }],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// Verify entity 1: product active, balance = 300
|
||||
const entity1 = await autumnV1.entities.get<ApiEntityV0>(
|
||||
customerId,
|
||||
entities[0].id,
|
||||
);
|
||||
await expectProductActive({ customer: entity1, productId: base.id });
|
||||
expectCustomerFeatureCorrect({
|
||||
customer: entity1,
|
||||
featureId: TestFeature.Messages,
|
||||
balance: quantity1,
|
||||
usage: 0,
|
||||
});
|
||||
|
||||
// Verify entity 2: product active, balance = 500
|
||||
const entity2 = await autumnV1.entities.get<ApiEntityV0>(
|
||||
customerId,
|
||||
entities[1].id,
|
||||
);
|
||||
await expectProductActive({ customer: entity2, productId: base.id });
|
||||
expectCustomerFeatureCorrect({
|
||||
customer: entity2,
|
||||
featureId: TestFeature.Messages,
|
||||
balance: quantity2,
|
||||
usage: 0,
|
||||
});
|
||||
|
||||
// Verify invoices: no base price, so totals are just prepaid costs
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer,
|
||||
count: 2,
|
||||
latestTotal: prepaidCost2,
|
||||
});
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer,
|
||||
count: 2,
|
||||
invoiceIndex: 1,
|
||||
latestTotal: prepaidCost1,
|
||||
});
|
||||
|
||||
// Verify Stripe subscription matches expected state
|
||||
await expectStripeSubscriptionCorrect({ ctx, customerId });
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 2: Customer + Entity both have same prepaid product (balance inheritance)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Customer attaches prepaid product at customer level (qty 200 → balance 200).
|
||||
* Then entity 1 attaches same product at entity level (qty 300 → balance 300).
|
||||
*
|
||||
* Expected:
|
||||
* - Customer total balance = 200 (own) + 300 (entity) = 500
|
||||
* - Entity 1 balance = 300 (own) + 200 (inherited from customer) = 500
|
||||
* - Invoices: 2 total (customer attach + entity attach)
|
||||
*/
|
||||
test.concurrent(`${chalk.yellowBright("prepaid-entities: customer + entity both have same prepaid product")}`, async () => {
|
||||
const customerId = "prepaid-ent-with-customer";
|
||||
const customerQuantity = 200;
|
||||
const entityQuantity = 300;
|
||||
|
||||
const customerPurchasedUnits =
|
||||
(customerQuantity - INCLUDED_USAGE) / BILLING_UNITS;
|
||||
const entityPurchasedUnits =
|
||||
(entityQuantity - INCLUDED_USAGE) / BILLING_UNITS;
|
||||
const customerPrepaidCost = customerPurchasedUnits * PRICE_PER_UNIT;
|
||||
const entityPrepaidCost = entityPurchasedUnits * PRICE_PER_UNIT;
|
||||
|
||||
const prepaidItem = items.prepaidMessages({
|
||||
includedUsage: INCLUDED_USAGE,
|
||||
billingUnits: BILLING_UNITS,
|
||||
price: PRICE_PER_UNIT,
|
||||
});
|
||||
|
||||
const base = products.base({
|
||||
id: "base-prepaid-cus-ent",
|
||||
items: [prepaidItem],
|
||||
});
|
||||
|
||||
const { autumnV1, entities, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [base] }),
|
||||
s.entities({ count: 1, featureId: TestFeature.Users }),
|
||||
],
|
||||
actions: [
|
||||
// Customer-level attach first
|
||||
s.billing.attach({
|
||||
productId: base.id,
|
||||
options: [
|
||||
{ feature_id: TestFeature.Messages, quantity: customerQuantity },
|
||||
],
|
||||
}),
|
||||
// Then entity-level attach
|
||||
s.billing.attach({
|
||||
productId: base.id,
|
||||
entityIndex: 0,
|
||||
options: [
|
||||
{ feature_id: TestFeature.Messages, quantity: entityQuantity },
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// Verify entity 1: own balance (300) + inherited from customer (200) = 500
|
||||
const entity1 = await autumnV1.entities.get<ApiEntityV0>(
|
||||
customerId,
|
||||
entities[0].id,
|
||||
);
|
||||
await expectProductActive({ customer: entity1, productId: base.id });
|
||||
expectCustomerFeatureCorrect({
|
||||
customer: entity1,
|
||||
featureId: TestFeature.Messages,
|
||||
balance: entityQuantity + customerQuantity,
|
||||
});
|
||||
|
||||
// Verify customer total: own (200) + entity (300) = 500
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
expect(customer.features?.[TestFeature.Messages]?.balance).toBe(
|
||||
customerQuantity + entityQuantity,
|
||||
);
|
||||
|
||||
// Invoices: 2 total — customer attach + entity attach
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer,
|
||||
count: 2,
|
||||
latestTotal: entityPrepaidCost,
|
||||
});
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer,
|
||||
count: 2,
|
||||
invoiceIndex: 1,
|
||||
latestTotal: customerPrepaidCost,
|
||||
});
|
||||
|
||||
await expectStripeSubscriptionCorrect({ ctx, customerId });
|
||||
});
|
||||
@@ -1,17 +1,18 @@
|
||||
/**
|
||||
* Attach Prepaid Volume vs Graduated — Entity-Level Initial Attach Test
|
||||
*
|
||||
* Two entities share one customer. One is on graduated pricing, the other on
|
||||
* volume pricing, for the same tier structure. Confirms that the initial
|
||||
* invoice totals correctly reflect each pricing model independently.
|
||||
* Test 1: Two entities, one graduated, one volume, same tier structure.
|
||||
* 800 units, tier 2:
|
||||
* Graduated: 5×$10 + 3×$5 = $65
|
||||
* Volume: 8×$5 = $40
|
||||
*
|
||||
* Test 2: Volume prepaid with includedUsage — verifies that the included
|
||||
* usage acts as a free tier and the remaining purchased units are ALL
|
||||
* charged at the volume rate (the tier that the purchased quantity falls into).
|
||||
*
|
||||
* Tiers (billingUnits = 100):
|
||||
* Tier 1: 0–500 units @ $10/pack
|
||||
* Tier 2: 501+ units @ $5/pack
|
||||
*
|
||||
* 800 units, tier 2:
|
||||
* Graduated: 5×$10 + 3×$5 = $65
|
||||
* Volume: 8×$5 = $40 ← cheaper, all at tier-2 rate
|
||||
*/
|
||||
|
||||
import { expect, test } from "bun:test";
|
||||
@@ -19,6 +20,7 @@ import type { ApiCustomerV3, ApiEntityV0 } from "@autumn/shared";
|
||||
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
|
||||
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
|
||||
import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
|
||||
import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect";
|
||||
import { TestFeature } from "@tests/setup/v2Features";
|
||||
import { items } from "@tests/utils/fixtures/items";
|
||||
import { products } from "@tests/utils/fixtures/products";
|
||||
@@ -67,7 +69,7 @@ test.concurrent(`${chalk.yellowBright("attach-prepaid-volume-entities: graduated
|
||||
const gradPro = products.pro({ id: "grad-pro-ent-800", items: [gradItem] });
|
||||
const volPro = products.pro({ id: "vol-pro-ent-800", items: [volItem] });
|
||||
|
||||
const { autumnV1, entities } = await initScenario({
|
||||
const { autumnV1, entities, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
@@ -152,4 +154,137 @@ test.concurrent(`${chalk.yellowBright("attach-prepaid-volume-entities: graduated
|
||||
invoiceIndex: 1,
|
||||
latestTotal: BASE_PRICE + gradExpectedPrepaid,
|
||||
});
|
||||
|
||||
await expectStripeSubscriptionCorrect({ ctx, customerId });
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 2: Volume prepaid with includedUsage — all purchased units at volume rate
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Volume prepaid with includedUsage = 200 (must be multiple of billingUnits=100).
|
||||
*
|
||||
* Entity 1: quantity 800 → 200 included free, 600 purchased
|
||||
* Purchased packs = 600/100 = 6 packs → falls in tier 2 (>5 packs)
|
||||
* Volume: ALL 6 packs at tier-2 rate = 6×$5 = $30
|
||||
* Invoice = $20 base + $30 = $50
|
||||
*
|
||||
* Entity 2: quantity 400 → 200 included free, 200 purchased
|
||||
* Purchased packs = 200/100 = 2 packs → falls in tier 1 (≤5 packs)
|
||||
* Volume: ALL 2 packs at tier-1 rate = 2×$10 = $20
|
||||
* Invoice = $20 base + $20 = $40
|
||||
*
|
||||
* Confirms includedUsage is subtracted before volume pricing is applied,
|
||||
* and that volume pricing charges ALL purchased units at the single tier rate.
|
||||
*/
|
||||
test.concurrent(`${chalk.yellowBright("attach-prepaid-volume-entities: volume with includedUsage (200 free, tier pricing on rest)")}`, async () => {
|
||||
const customerId = "vol-ent-included-200";
|
||||
const includedUsage = 200;
|
||||
const quantity1 = 800;
|
||||
const quantity2 = 400;
|
||||
|
||||
// Entity 1: 600 purchased → 6 packs → tier 2 → 6×$5 = $30
|
||||
const purchasedPacks1 = (quantity1 - includedUsage) / BILLING_UNITS;
|
||||
const volExpected1 = purchasedPacks1 * 5; // tier 2 rate (>5 packs)
|
||||
|
||||
// Entity 2: 200 purchased → 2 packs → tier 1 → 2×$10 = $20
|
||||
const purchasedPacks2 = (quantity2 - includedUsage) / BILLING_UNITS;
|
||||
const volExpected2 = purchasedPacks2 * 10; // tier 1 rate (≤5 packs)
|
||||
|
||||
const volItem = items.volumePrepaidMessages({
|
||||
includedUsage,
|
||||
billingUnits: BILLING_UNITS,
|
||||
tiers: TIERS,
|
||||
});
|
||||
|
||||
const volPro = products.pro({ id: "vol-pro-inc-200", items: [volItem] });
|
||||
|
||||
const { autumnV1, entities, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [volPro] }),
|
||||
s.entities({ count: 2, featureId: TestFeature.Users }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
// ── Preview entity 1: $20 base + $30 volume = $50 ──
|
||||
const preview1 = await autumnV1.billing.previewAttach({
|
||||
customer_id: customerId,
|
||||
product_id: volPro.id,
|
||||
entity_id: entities[0].id,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: quantity1 }],
|
||||
});
|
||||
expect(preview1.total).toBe(BASE_PRICE + volExpected1);
|
||||
|
||||
// ── Preview entity 2: $20 base + $20 volume = $40 ──
|
||||
const preview2 = await autumnV1.billing.previewAttach({
|
||||
customer_id: customerId,
|
||||
product_id: volPro.id,
|
||||
entity_id: entities[1].id,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: quantity2 }],
|
||||
});
|
||||
expect(preview2.total).toBe(BASE_PRICE + volExpected2);
|
||||
|
||||
// ── Attach both ──
|
||||
await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
product_id: volPro.id,
|
||||
entity_id: entities[0].id,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: quantity1 }],
|
||||
redirect_mode: "if_required",
|
||||
});
|
||||
await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
product_id: volPro.id,
|
||||
entity_id: entities[1].id,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: quantity2 }],
|
||||
redirect_mode: "if_required",
|
||||
});
|
||||
|
||||
// ── Assert entity 1: balance = 800 ──
|
||||
const entity1 = await autumnV1.entities.get<ApiEntityV0>(
|
||||
customerId,
|
||||
entities[0].id,
|
||||
);
|
||||
await expectProductActive({ customer: entity1, productId: volPro.id });
|
||||
expectCustomerFeatureCorrect({
|
||||
customer: entity1,
|
||||
featureId: TestFeature.Messages,
|
||||
balance: quantity1,
|
||||
usage: 0,
|
||||
});
|
||||
|
||||
// ── Assert entity 2: balance = 400 ──
|
||||
const entity2 = await autumnV1.entities.get<ApiEntityV0>(
|
||||
customerId,
|
||||
entities[1].id,
|
||||
);
|
||||
await expectProductActive({ customer: entity2, productId: volPro.id });
|
||||
expectCustomerFeatureCorrect({
|
||||
customer: entity2,
|
||||
featureId: TestFeature.Messages,
|
||||
balance: quantity2,
|
||||
usage: 0,
|
||||
});
|
||||
|
||||
// ── Customer invoices: 2 total ──
|
||||
// Invoice 0 (latest): entity 2 — $40
|
||||
// Invoice 1: entity 1 — $50
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer,
|
||||
count: 2,
|
||||
latestTotal: BASE_PRICE + volExpected2,
|
||||
});
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer,
|
||||
count: 2,
|
||||
invoiceIndex: 1,
|
||||
latestTotal: BASE_PRICE + volExpected1,
|
||||
});
|
||||
|
||||
await expectStripeSubscriptionCorrect({ ctx, customerId });
|
||||
});
|
||||
|
||||
@@ -0,0 +1,555 @@
|
||||
/**
|
||||
* Scheduled Switch Entities — Prepaid Messages
|
||||
*
|
||||
* Tests that scheduled downgrades work correctly with entity-scoped prepaid
|
||||
* products. Each entity gets independent inline prices in Stripe, and the
|
||||
* subscription schedule must preserve per-entity pricing through the transition.
|
||||
*
|
||||
* Products use prepaid messages (100 included, $10/100 units).
|
||||
* Quantity in billing.attach is INCLUSIVE of included usage.
|
||||
*
|
||||
* Price math (BILLING_UNITS=100, PRICE_PER_UNIT=$10, INCLUDED_USAGE=100):
|
||||
* quantity 500 → (500-100)/100 * $10 = $40 prepaid + base price
|
||||
* quantity 300 → (300-100)/100 * $10 = $20 prepaid + base price
|
||||
*/
|
||||
|
||||
import { test } from "bun:test";
|
||||
import type { ApiCustomerV3, ApiEntityV0 } from "@autumn/shared";
|
||||
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
|
||||
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
|
||||
import {
|
||||
expectCustomerProducts,
|
||||
expectProductCanceling,
|
||||
expectProductScheduled,
|
||||
} from "@tests/integration/billing/utils/expectCustomerProductCorrect";
|
||||
import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect";
|
||||
import { TestFeature } from "@tests/setup/v2Features";
|
||||
import { items } from "@tests/utils/fixtures/items";
|
||||
import { products } from "@tests/utils/fixtures/products";
|
||||
import { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
|
||||
import chalk from "chalk";
|
||||
|
||||
const BILLING_UNITS = 100;
|
||||
const PRICE_PER_UNIT = 10;
|
||||
const INCLUDED_USAGE = 100;
|
||||
|
||||
const PRO_BASE = 20;
|
||||
const PREMIUM_BASE = 50;
|
||||
|
||||
/** Prepaid cost for a given quantity: (qty - included) / billingUnits * price */
|
||||
const prepaidCost = (quantity: number) =>
|
||||
((quantity - INCLUDED_USAGE) / BILLING_UNITS) * PRICE_PER_UNIT;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 1: Single entity premium → pro downgrade + advance cycle
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Both entities start on premium ($50/mo) with 500 prepaid messages.
|
||||
* Downgrade entity 1 to pro ($20/mo) with 300 messages.
|
||||
*
|
||||
* Expected:
|
||||
* Entity 1: premium canceling + pro scheduled, balance still 500 (until cycle ends)
|
||||
* Entity 2: premium active, balance 500
|
||||
* Stripe schedule reflects the scheduled downgrade with inline prepaid prices
|
||||
*/
|
||||
test.concurrent(`${chalk.yellowBright("scheduled-switch-entities-prepaid 1: single entity premium→pro downgrade + advance cycle")}`, async () => {
|
||||
const customerId = "sched-prepaid-ent-pre-cycle";
|
||||
const premiumQuantity = 500;
|
||||
const proQuantity = 300;
|
||||
|
||||
const prepaidItem = items.prepaidMessages({
|
||||
includedUsage: INCLUDED_USAGE,
|
||||
billingUnits: BILLING_UNITS,
|
||||
price: PRICE_PER_UNIT,
|
||||
});
|
||||
|
||||
const premium = products.premium({
|
||||
id: "premium-prepaid",
|
||||
items: [prepaidItem],
|
||||
});
|
||||
const pro = products.pro({
|
||||
id: "pro-prepaid",
|
||||
items: [prepaidItem],
|
||||
});
|
||||
|
||||
const { autumnV1, entities, ctx, testClockId } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [premium, pro] }),
|
||||
s.entities({ count: 2, featureId: TestFeature.Users }),
|
||||
],
|
||||
actions: [
|
||||
s.billing.attach({
|
||||
productId: premium.id,
|
||||
entityIndex: 0,
|
||||
options: [
|
||||
{ feature_id: TestFeature.Messages, quantity: premiumQuantity },
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// Action under test: downgrade entity 1 to pro (scheduled)
|
||||
await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
entity_id: entities[0].id,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: proQuantity }],
|
||||
redirect_mode: "if_required",
|
||||
});
|
||||
|
||||
// Verify entity 1: premium canceling, pro scheduled
|
||||
const entity1 = await autumnV1.entities.get<ApiEntityV0>(
|
||||
customerId,
|
||||
entities[0].id,
|
||||
);
|
||||
await expectProductCanceling({ customer: entity1, productId: premium.id });
|
||||
await expectProductScheduled({ customer: entity1, productId: pro.id });
|
||||
|
||||
// Balances unchanged before cycle ends
|
||||
expectCustomerFeatureCorrect({
|
||||
customer: entity1,
|
||||
featureId: TestFeature.Messages,
|
||||
balance: premiumQuantity,
|
||||
usage: 0,
|
||||
});
|
||||
|
||||
// Stripe schedule should reflect the downgrade
|
||||
await expectStripeSubscriptionCorrect({ ctx, customerId });
|
||||
|
||||
await advanceToNextInvoice({
|
||||
stripeCli: ctx.stripeCli,
|
||||
testClockId: testClockId!,
|
||||
});
|
||||
|
||||
const customerAfter = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
|
||||
await expectCustomerProducts({
|
||||
customer: customerAfter,
|
||||
active: [pro.id],
|
||||
notPresent: [premium.id],
|
||||
});
|
||||
|
||||
expectCustomerFeatureCorrect({
|
||||
customer: customerAfter,
|
||||
featureId: TestFeature.Messages,
|
||||
balance: proQuantity,
|
||||
usage: 0,
|
||||
});
|
||||
await expectStripeSubscriptionCorrect({ ctx, customerId });
|
||||
|
||||
// Invoices:
|
||||
// 0 (latest): renewal — pro base ($20) + prepaid 300 ($20) = $40
|
||||
// 1: initial — premium base ($50) + prepaid 500 ($40) = $90
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer: customerAfter,
|
||||
count: 2,
|
||||
latestTotal: PRO_BASE + prepaidCost(proQuantity),
|
||||
});
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer: customerAfter,
|
||||
count: 2,
|
||||
invoiceIndex: 1,
|
||||
latestTotal: PREMIUM_BASE + prepaidCost(premiumQuantity),
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 2: Entity 1 premium → pro, entity 2 stays premium → advance cycle
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Both entities on premium with 500 messages. Downgrade entity 1 to pro with 300.
|
||||
* After cycle: entity 1 on pro with 300 balance, entity 2 renewed on premium with 500 balance.
|
||||
*/
|
||||
test.concurrent(`${chalk.yellowBright("scheduled-switch-entities-prepaid 2: entity 1 premium→pro, entity 2 stays premium, advance cycle")}`, async () => {
|
||||
const customerId = "sched-prepaid-ent-post-cycle";
|
||||
const premiumQuantity = 500;
|
||||
const proQuantity = 300;
|
||||
|
||||
const prepaidItem = items.prepaidMessages({
|
||||
includedUsage: INCLUDED_USAGE,
|
||||
billingUnits: BILLING_UNITS,
|
||||
price: PRICE_PER_UNIT,
|
||||
});
|
||||
|
||||
const premium = products.premium({
|
||||
id: "premium-prepaid",
|
||||
items: [prepaidItem],
|
||||
});
|
||||
const pro = products.pro({
|
||||
id: "pro-prepaid",
|
||||
items: [prepaidItem],
|
||||
});
|
||||
|
||||
const { autumnV1, entities, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [premium, pro] }),
|
||||
s.entities({ count: 2, featureId: TestFeature.Users }),
|
||||
],
|
||||
actions: [
|
||||
s.billing.attach({
|
||||
productId: premium.id,
|
||||
entityIndex: 0,
|
||||
options: [
|
||||
{ feature_id: TestFeature.Messages, quantity: premiumQuantity },
|
||||
],
|
||||
}),
|
||||
s.billing.attach({
|
||||
productId: premium.id,
|
||||
entityIndex: 1,
|
||||
options: [
|
||||
{ feature_id: TestFeature.Messages, quantity: premiumQuantity },
|
||||
],
|
||||
}),
|
||||
s.billing.attach({
|
||||
productId: pro.id,
|
||||
entityIndex: 0,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: proQuantity }],
|
||||
}),
|
||||
s.advanceToNextInvoice(),
|
||||
],
|
||||
});
|
||||
|
||||
// After cycle: entity 1 on pro, entity 2 on premium
|
||||
const entity1 = await autumnV1.entities.get<ApiEntityV0>(
|
||||
customerId,
|
||||
entities[0].id,
|
||||
);
|
||||
const entity2 = await autumnV1.entities.get<ApiEntityV0>(
|
||||
customerId,
|
||||
entities[1].id,
|
||||
);
|
||||
|
||||
await expectCustomerProducts({
|
||||
customer: entity1,
|
||||
active: [pro.id],
|
||||
notPresent: [premium.id],
|
||||
});
|
||||
await expectCustomerProducts({
|
||||
customer: entity2,
|
||||
active: [premium.id],
|
||||
notPresent: [pro.id],
|
||||
});
|
||||
|
||||
// Entity 1 gets pro quantity, entity 2 keeps premium quantity
|
||||
expectCustomerFeatureCorrect({
|
||||
customer: entity1,
|
||||
featureId: TestFeature.Messages,
|
||||
balance: proQuantity,
|
||||
usage: 0,
|
||||
});
|
||||
expectCustomerFeatureCorrect({
|
||||
customer: entity2,
|
||||
featureId: TestFeature.Messages,
|
||||
balance: premiumQuantity,
|
||||
usage: 0,
|
||||
});
|
||||
|
||||
await expectStripeSubscriptionCorrect({ ctx, customerId });
|
||||
|
||||
// Invoices:
|
||||
// 0 (latest): renewal — entity 1 pro ($20+$20) + entity 2 premium ($50+$40) = $130
|
||||
// 1: initial — entity 2 premium ($50+$40) = $90
|
||||
// 2: initial — entity 1 premium ($50+$40) = $90
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer,
|
||||
count: 3,
|
||||
latestTotal:
|
||||
PRO_BASE +
|
||||
prepaidCost(proQuantity) +
|
||||
PREMIUM_BASE +
|
||||
prepaidCost(premiumQuantity),
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 3: Both entities premium → pro → advance cycle
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Both entities on premium with 500 messages. Downgrade both to pro with 300.
|
||||
* After cycle: both on pro with 300 balance.
|
||||
*/
|
||||
test.concurrent(`${chalk.yellowBright("scheduled-switch-entities-prepaid 3: both entities premium→pro, advance cycle")}`, async () => {
|
||||
const customerId = "sched-prepaid-ent-both-down";
|
||||
const premiumQuantity = 500;
|
||||
const proQuantity = 300;
|
||||
|
||||
const prepaidItem = items.prepaidMessages({
|
||||
includedUsage: INCLUDED_USAGE,
|
||||
billingUnits: BILLING_UNITS,
|
||||
price: PRICE_PER_UNIT,
|
||||
});
|
||||
|
||||
const premium = products.premium({
|
||||
id: "premium-prepaid",
|
||||
items: [prepaidItem],
|
||||
});
|
||||
const pro = products.pro({
|
||||
id: "pro-prepaid",
|
||||
items: [prepaidItem],
|
||||
});
|
||||
|
||||
const { autumnV1, entities, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [premium, pro] }),
|
||||
s.entities({ count: 2, featureId: TestFeature.Users }),
|
||||
],
|
||||
actions: [
|
||||
s.billing.attach({
|
||||
productId: premium.id,
|
||||
entityIndex: 0,
|
||||
options: [
|
||||
{ feature_id: TestFeature.Messages, quantity: premiumQuantity },
|
||||
],
|
||||
}),
|
||||
s.billing.attach({
|
||||
productId: premium.id,
|
||||
entityIndex: 1,
|
||||
options: [
|
||||
{ feature_id: TestFeature.Messages, quantity: premiumQuantity },
|
||||
],
|
||||
}),
|
||||
s.billing.attach({
|
||||
productId: pro.id,
|
||||
entityIndex: 0,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: proQuantity }],
|
||||
}),
|
||||
s.billing.attach({
|
||||
productId: pro.id,
|
||||
entityIndex: 1,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: proQuantity }],
|
||||
}),
|
||||
s.advanceToNextInvoice(),
|
||||
],
|
||||
});
|
||||
|
||||
// After cycle: both on pro
|
||||
const entity1 = await autumnV1.entities.get<ApiEntityV0>(
|
||||
customerId,
|
||||
entities[0].id,
|
||||
);
|
||||
const entity2 = await autumnV1.entities.get<ApiEntityV0>(
|
||||
customerId,
|
||||
entities[1].id,
|
||||
);
|
||||
|
||||
await expectCustomerProducts({
|
||||
customer: entity1,
|
||||
active: [pro.id],
|
||||
notPresent: [premium.id],
|
||||
});
|
||||
await expectCustomerProducts({
|
||||
customer: entity2,
|
||||
active: [pro.id],
|
||||
notPresent: [premium.id],
|
||||
});
|
||||
|
||||
expectCustomerFeatureCorrect({
|
||||
customer: entity1,
|
||||
featureId: TestFeature.Messages,
|
||||
balance: proQuantity,
|
||||
usage: 0,
|
||||
});
|
||||
expectCustomerFeatureCorrect({
|
||||
customer: entity2,
|
||||
featureId: TestFeature.Messages,
|
||||
balance: proQuantity,
|
||||
usage: 0,
|
||||
});
|
||||
|
||||
await expectStripeSubscriptionCorrect({ ctx, customerId });
|
||||
|
||||
// Invoices:
|
||||
// 0 (latest): renewal — entity 1 pro ($20+$20) + entity 2 pro ($20+$20) = $80
|
||||
// 1: initial — entity 2 premium ($50+$40) = $90
|
||||
// 2: initial — entity 1 premium ($50+$40) = $90
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer,
|
||||
count: 3,
|
||||
latestTotal: 2 * (PRO_BASE + prepaidCost(proQuantity)),
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 4: Entity 1 pro → free (scheduled), entity 2 pro → premium (immediate)
|
||||
// → advance cycle
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Both entities on pro ($20/mo) with 300 prepaid messages.
|
||||
* Entity 1 downgrades to free (scheduled).
|
||||
* Entity 2 upgrades to premium (immediate).
|
||||
*
|
||||
* Pre-cycle:
|
||||
* Entity 1: pro canceling + free scheduled
|
||||
* Entity 2: premium active with 500 balance
|
||||
*
|
||||
* Post-cycle:
|
||||
* Entity 1: free active with 200 balance
|
||||
* Entity 2: premium active with 500 balance (renewed)
|
||||
*/
|
||||
test.concurrent(`${chalk.yellowBright("scheduled-switch-entities-prepaid 4: entity 1 pro→free, entity 2 pro→premium, advance cycle")}`, async () => {
|
||||
const customerId = "sched-prepaid-ent-cross";
|
||||
const proQuantity = 300;
|
||||
const freeQuantity = 200;
|
||||
const premiumQuantity = 500;
|
||||
|
||||
const prepaidItem = items.prepaidMessages({
|
||||
includedUsage: INCLUDED_USAGE,
|
||||
billingUnits: BILLING_UNITS,
|
||||
price: PRICE_PER_UNIT,
|
||||
});
|
||||
|
||||
const free = products.base({
|
||||
id: "free-prepaid",
|
||||
items: [prepaidItem],
|
||||
});
|
||||
const pro = products.pro({
|
||||
id: "pro-prepaid",
|
||||
items: [prepaidItem],
|
||||
});
|
||||
const premium = products.premium({
|
||||
id: "premium-prepaid",
|
||||
items: [prepaidItem],
|
||||
});
|
||||
|
||||
const { autumnV1, entities, ctx, testClockId } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [free, pro, premium] }),
|
||||
s.entities({ count: 2, featureId: TestFeature.Users }),
|
||||
],
|
||||
actions: [
|
||||
s.billing.attach({
|
||||
productId: pro.id,
|
||||
entityIndex: 0,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: proQuantity }],
|
||||
}),
|
||||
s.billing.attach({
|
||||
productId: pro.id,
|
||||
entityIndex: 1,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: proQuantity }],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// Action under test: downgrade entity 1, upgrade entity 2
|
||||
await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
product_id: free.id,
|
||||
entity_id: entities[0].id,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: freeQuantity }],
|
||||
redirect_mode: "if_required",
|
||||
});
|
||||
await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
product_id: premium.id,
|
||||
entity_id: entities[1].id,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: premiumQuantity }],
|
||||
redirect_mode: "if_required",
|
||||
});
|
||||
|
||||
// ── Pre-cycle checks ──
|
||||
|
||||
// Entity 1: pro canceling, free scheduled
|
||||
const preCycleEntity1 = await autumnV1.entities.get<ApiEntityV0>(
|
||||
customerId,
|
||||
entities[0].id,
|
||||
);
|
||||
await expectProductCanceling({
|
||||
customer: preCycleEntity1,
|
||||
productId: pro.id,
|
||||
});
|
||||
await expectProductScheduled({
|
||||
customer: preCycleEntity1,
|
||||
productId: free.id,
|
||||
});
|
||||
|
||||
// Entity 2: premium active (immediate upgrade)
|
||||
const preCycleEntity2 = await autumnV1.entities.get<ApiEntityV0>(
|
||||
customerId,
|
||||
entities[1].id,
|
||||
);
|
||||
await expectCustomerProducts({
|
||||
customer: preCycleEntity2,
|
||||
active: [premium.id],
|
||||
notPresent: [pro.id],
|
||||
});
|
||||
expectCustomerFeatureCorrect({
|
||||
customer: preCycleEntity2,
|
||||
featureId: TestFeature.Messages,
|
||||
balance: premiumQuantity,
|
||||
usage: 0,
|
||||
});
|
||||
|
||||
await expectStripeSubscriptionCorrect({ ctx, customerId });
|
||||
|
||||
// ── Advance cycle ──
|
||||
|
||||
await advanceToNextInvoice({
|
||||
stripeCli: ctx.stripeCli,
|
||||
testClockId: testClockId!,
|
||||
});
|
||||
|
||||
// ── Post-cycle checks ──
|
||||
|
||||
const postCycleEntity1 = await autumnV1.entities.get<ApiEntityV0>(
|
||||
customerId,
|
||||
entities[0].id,
|
||||
);
|
||||
const postCycleEntity2 = await autumnV1.entities.get<ApiEntityV0>(
|
||||
customerId,
|
||||
entities[1].id,
|
||||
);
|
||||
|
||||
await expectCustomerProducts({
|
||||
customer: postCycleEntity1,
|
||||
active: [free.id],
|
||||
notPresent: [pro.id, premium.id],
|
||||
});
|
||||
await expectCustomerProducts({
|
||||
customer: postCycleEntity2,
|
||||
active: [premium.id],
|
||||
notPresent: [pro.id, free.id],
|
||||
});
|
||||
|
||||
expectCustomerFeatureCorrect({
|
||||
customer: postCycleEntity1,
|
||||
featureId: TestFeature.Messages,
|
||||
balance: freeQuantity,
|
||||
usage: 0,
|
||||
});
|
||||
expectCustomerFeatureCorrect({
|
||||
customer: postCycleEntity2,
|
||||
featureId: TestFeature.Messages,
|
||||
balance: premiumQuantity,
|
||||
usage: 0,
|
||||
});
|
||||
|
||||
await expectStripeSubscriptionCorrect({ ctx, customerId });
|
||||
|
||||
// Invoices (post-cycle):
|
||||
// 0 (latest): renewal — entity 1 free ($0 + prepaid $10) + entity 2 premium ($50+$40) = $100
|
||||
// + proration invoice from entity 2's immediate pro→premium upgrade
|
||||
// + 2 initial pro attaches
|
||||
// Just check the latest renewal total
|
||||
const customerAfter = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer: customerAfter,
|
||||
count: 4,
|
||||
latestTotal:
|
||||
prepaidCost(freeQuantity) + PREMIUM_BASE + prepaidCost(premiumQuantity),
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,717 @@
|
||||
/**
|
||||
* Multi-Entity Quantity Proration Config Tests
|
||||
*
|
||||
* Tests 1-3: ProrateNextCycle behavior with entity-scoped prepaid products.
|
||||
* Balance updates immediately but charge/credit is deferred to next cycle invoice.
|
||||
* All use file-level constants: BILLING_UNITS=10, PRICE_PER_UNIT=5, INCLUDED_USAGE=20.
|
||||
*
|
||||
* Tests 4-5: OnDecrease.None behavior with entity-scoped prepaid products.
|
||||
* Decrease is scheduled for next cycle (no credit), balance stays until renewal.
|
||||
* Use local constants: billingUnits=100, pricePerUnit=10, includedUsage=100.
|
||||
*
|
||||
* Test 1: ProrateNextCycle increase — entity gets balance immediately, billing deferred
|
||||
* Test 2: ProrateNextCycle decrease — balance changes immediately, credit deferred
|
||||
* Test 3: Mixed — one entity increases (ProrateNextCycle), other decreases (ProrateImmediately)
|
||||
* Test 4: OnDecrease.None — no credit invoice on decrease
|
||||
* Test 5: OnDecrease.None — decrease then increase back (net zero)
|
||||
*/
|
||||
|
||||
import { expect, test } from "bun:test";
|
||||
import { type ApiCustomerV3, OnDecrease, OnIncrease } from "@autumn/shared";
|
||||
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect.js";
|
||||
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect.js";
|
||||
import { expectProductItemCorrect } from "@tests/integration/billing/utils/expectProductItemCorrect.js";
|
||||
import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect";
|
||||
import { calculateProratedDiff } from "@tests/integration/billing/utils/proration";
|
||||
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||
import { items } from "@tests/utils/fixtures/items.js";
|
||||
import { products } from "@tests/utils/fixtures/products.js";
|
||||
import { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
|
||||
import chalk from "chalk";
|
||||
|
||||
// ── File-level constants for Tests 1-3 ──
|
||||
const BILLING_UNITS = 10;
|
||||
const PRICE_PER_UNIT = 5;
|
||||
const INCLUDED_USAGE = 20;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 1: ProrateNextCycle increase — balance now, billing deferred
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Entity 1 starts with 100 units. Cost = (100-20)/10 * $5 = $40.
|
||||
* Entity 2 starts with 50 units. Cost = (50-20)/10 * $5 = $15.
|
||||
*
|
||||
* Entity 1 increases to 200 units. New cost = (200-20)/10 * $5 = $90.
|
||||
* Preview shows $0 (deferred). Balance updates immediately to 200.
|
||||
* Entity 2 is unchanged.
|
||||
*
|
||||
* After advancing to next cycle:
|
||||
* Renewal = $90 (entity1) + $15 (entity2) = $105
|
||||
* Plus prorated increase deferred from mid-cycle.
|
||||
*/
|
||||
test.concurrent(`${chalk.yellowBright("multi-entity-proration: ProrateNextCycle increase — balance now, billing deferred")}`, async () => {
|
||||
const customerId = "multi-ent-proration-increase";
|
||||
|
||||
const prepaidItem = items.prepaid({
|
||||
featureId: TestFeature.Messages,
|
||||
billingUnits: BILLING_UNITS,
|
||||
price: PRICE_PER_UNIT,
|
||||
includedUsage: INCLUDED_USAGE,
|
||||
config: {
|
||||
on_increase: OnIncrease.ProrateNextCycle,
|
||||
on_decrease: OnDecrease.ProrateNextCycle,
|
||||
},
|
||||
});
|
||||
|
||||
const product = products.base({
|
||||
id: "prepaid",
|
||||
items: [prepaidItem],
|
||||
});
|
||||
|
||||
const initialQuantity1 = 10 * BILLING_UNITS; // 100
|
||||
const initialQuantity2 = 5 * BILLING_UNITS; // 50
|
||||
const newQuantity1 = 20 * BILLING_UNITS; // 200
|
||||
|
||||
// Costs: (qty - includedUsage) / billingUnits * pricePerUnit
|
||||
const entity1OldCost =
|
||||
((initialQuantity1 - INCLUDED_USAGE) / BILLING_UNITS) * PRICE_PER_UNIT; // $40
|
||||
const entity1NewCost =
|
||||
((newQuantity1 - INCLUDED_USAGE) / BILLING_UNITS) * PRICE_PER_UNIT; // $90
|
||||
const entity2Cost =
|
||||
((initialQuantity2 - INCLUDED_USAGE) / BILLING_UNITS) * PRICE_PER_UNIT; // $15
|
||||
const renewalAmount = entity1NewCost + entity2Cost; // $105
|
||||
|
||||
const { autumnV1, ctx, entities, testClockId, advancedTo } =
|
||||
await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [product] }),
|
||||
s.entities({ count: 2, featureId: TestFeature.Users }),
|
||||
],
|
||||
actions: [
|
||||
s.billing.attach({
|
||||
productId: product.id,
|
||||
entityIndex: 0,
|
||||
options: [
|
||||
{
|
||||
feature_id: TestFeature.Messages,
|
||||
quantity: initialQuantity1,
|
||||
},
|
||||
],
|
||||
}),
|
||||
s.billing.attach({
|
||||
productId: product.id,
|
||||
entityIndex: 1,
|
||||
options: [
|
||||
{
|
||||
feature_id: TestFeature.Messages,
|
||||
quantity: initialQuantity2,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const beforeCustomer =
|
||||
await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
const invoiceCountBefore = beforeCustomer.invoices?.length ?? 0;
|
||||
|
||||
// Preview the upgrade — should be $0 (deferred to next cycle)
|
||||
const preview = await autumnV1.subscriptions.previewUpdate({
|
||||
customer_id: customerId,
|
||||
entity_id: entities[0].id,
|
||||
product_id: product.id,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: newQuantity1 }],
|
||||
});
|
||||
expect(preview.total).toBe(0);
|
||||
|
||||
// Calculate prorated diff BEFORE advancing (billing period changes after)
|
||||
const proratedIncrease = await calculateProratedDiff({
|
||||
customerId,
|
||||
advancedTo,
|
||||
oldAmount: entity1OldCost,
|
||||
newAmount: entity1NewCost,
|
||||
});
|
||||
|
||||
// Execute the upgrade
|
||||
await autumnV1.subscriptions.update({
|
||||
customer_id: customerId,
|
||||
entity_id: entities[0].id,
|
||||
product_id: product.id,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: newQuantity1 }],
|
||||
});
|
||||
|
||||
// Balance updates immediately to 200
|
||||
const entity1 = await autumnV1.entities.get(customerId, entities[0].id);
|
||||
expectCustomerFeatureCorrect({
|
||||
customer: entity1,
|
||||
featureId: TestFeature.Messages,
|
||||
balance: newQuantity1,
|
||||
});
|
||||
|
||||
// Entity 2 unchanged
|
||||
const entity2 = await autumnV1.entities.get(customerId, entities[1].id);
|
||||
expectCustomerFeatureCorrect({
|
||||
customer: entity2,
|
||||
featureId: TestFeature.Messages,
|
||||
balance: initialQuantity2,
|
||||
});
|
||||
|
||||
// No new finalized invoice created yet
|
||||
const afterUpdate = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
const finalizedAfter = afterUpdate.invoices?.filter(
|
||||
(inv) => inv.status === "paid" || inv.status === "open",
|
||||
);
|
||||
expect(finalizedAfter?.length).toBe(invoiceCountBefore);
|
||||
|
||||
// Advance to next cycle — deferred proration + renewal should appear
|
||||
await advanceToNextInvoice({
|
||||
stripeCli: ctx.stripeCli,
|
||||
testClockId: testClockId!,
|
||||
});
|
||||
|
||||
const afterCycle = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
expectCustomerInvoiceCorrect({
|
||||
customer: afterCycle,
|
||||
count: invoiceCountBefore + 1,
|
||||
latestStatus: "paid",
|
||||
latestTotal: renewalAmount + proratedIncrease,
|
||||
});
|
||||
|
||||
await expectStripeSubscriptionCorrect({ ctx, customerId });
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 2: ProrateNextCycle decrease — balance changes immediately, credit deferred
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Entity 1 starts with 200 units. Cost = (200-20)/10 * $5 = $90.
|
||||
* Entity 2 starts with 100 units. Cost = (100-20)/10 * $5 = $40.
|
||||
*
|
||||
* Entity 1 decreases to 50 units. New cost = (50-20)/10 * $5 = $15.
|
||||
* Preview shows $0 (deferred). Balance changes immediately to 50.
|
||||
*
|
||||
* After advancing to next cycle:
|
||||
* Renewal = $15 (entity1) + $40 (entity2) = $55
|
||||
* Plus prorated credit deferred from mid-cycle (negative).
|
||||
*/
|
||||
test.concurrent(`${chalk.yellowBright("multi-entity-proration: ProrateNextCycle decrease — immediate balance, deferred credit")}`, async () => {
|
||||
const customerId = "multi-ent-proration-decrease";
|
||||
|
||||
const prepaidItem = items.prepaid({
|
||||
featureId: TestFeature.Messages,
|
||||
billingUnits: BILLING_UNITS,
|
||||
price: PRICE_PER_UNIT,
|
||||
includedUsage: INCLUDED_USAGE,
|
||||
config: {
|
||||
on_increase: OnIncrease.ProrateNextCycle,
|
||||
on_decrease: OnDecrease.ProrateNextCycle,
|
||||
},
|
||||
});
|
||||
|
||||
const product = products.base({
|
||||
id: "prepaid",
|
||||
items: [prepaidItem],
|
||||
});
|
||||
|
||||
const initialQuantity1 = 20 * BILLING_UNITS; // 200
|
||||
const initialQuantity2 = 10 * BILLING_UNITS; // 100
|
||||
const newQuantity1 = 5 * BILLING_UNITS; // 50
|
||||
|
||||
// Costs
|
||||
const entity1OldCost =
|
||||
((initialQuantity1 - INCLUDED_USAGE) / BILLING_UNITS) * PRICE_PER_UNIT; // $90
|
||||
const entity1NewCost =
|
||||
((newQuantity1 - INCLUDED_USAGE) / BILLING_UNITS) * PRICE_PER_UNIT; // $15
|
||||
const entity2Cost =
|
||||
((initialQuantity2 - INCLUDED_USAGE) / BILLING_UNITS) * PRICE_PER_UNIT; // $40
|
||||
const renewalAmount = entity1NewCost + entity2Cost; // $55
|
||||
|
||||
const { autumnV1, ctx, entities, testClockId, advancedTo } =
|
||||
await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [product] }),
|
||||
s.entities({ count: 2, featureId: TestFeature.Users }),
|
||||
],
|
||||
actions: [
|
||||
s.billing.attach({
|
||||
productId: product.id,
|
||||
entityIndex: 0,
|
||||
options: [
|
||||
{
|
||||
feature_id: TestFeature.Messages,
|
||||
quantity: initialQuantity1,
|
||||
},
|
||||
],
|
||||
}),
|
||||
s.billing.attach({
|
||||
productId: product.id,
|
||||
entityIndex: 1,
|
||||
options: [
|
||||
{
|
||||
feature_id: TestFeature.Messages,
|
||||
quantity: initialQuantity2,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const beforeCustomer =
|
||||
await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
const invoiceCountBefore = beforeCustomer.invoices?.length ?? 0;
|
||||
|
||||
// Preview the downgrade — should be $0 (deferred)
|
||||
const preview = await autumnV1.subscriptions.previewUpdate({
|
||||
customer_id: customerId,
|
||||
entity_id: entities[0].id,
|
||||
product_id: product.id,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: newQuantity1 }],
|
||||
});
|
||||
expect(preview.total).toBe(0);
|
||||
|
||||
// Calculate prorated diff BEFORE advancing
|
||||
const proratedCredit = await calculateProratedDiff({
|
||||
customerId,
|
||||
advancedTo,
|
||||
oldAmount: entity1OldCost,
|
||||
newAmount: entity1NewCost,
|
||||
});
|
||||
|
||||
// Execute the downgrade
|
||||
await autumnV1.subscriptions.update({
|
||||
customer_id: customerId,
|
||||
entity_id: entities[0].id,
|
||||
product_id: product.id,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: newQuantity1 }],
|
||||
});
|
||||
|
||||
// Balance changes immediately to 50 (billing is deferred, not balance)
|
||||
const entity1After = await autumnV1.entities.get(customerId, entities[0].id);
|
||||
expectCustomerFeatureCorrect({
|
||||
customer: entity1After,
|
||||
featureId: TestFeature.Messages,
|
||||
balance: newQuantity1,
|
||||
});
|
||||
|
||||
// Entity 2 unchanged
|
||||
const entity2After = await autumnV1.entities.get(customerId, entities[1].id);
|
||||
expectCustomerFeatureCorrect({
|
||||
customer: entity2After,
|
||||
featureId: TestFeature.Messages,
|
||||
balance: initialQuantity2,
|
||||
});
|
||||
|
||||
// No new invoice created yet
|
||||
const afterUpdate = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
expectCustomerInvoiceCorrect({
|
||||
customer: afterUpdate,
|
||||
count: invoiceCountBefore,
|
||||
});
|
||||
|
||||
// Advance to next cycle — deferred credit applied to renewal invoice
|
||||
await advanceToNextInvoice({
|
||||
stripeCli: ctx.stripeCli,
|
||||
testClockId: testClockId!,
|
||||
});
|
||||
|
||||
const entity1PostCycle = await autumnV1.entities.get(
|
||||
customerId,
|
||||
entities[0].id,
|
||||
);
|
||||
expectCustomerFeatureCorrect({
|
||||
customer: entity1PostCycle,
|
||||
featureId: TestFeature.Messages,
|
||||
balance: newQuantity1,
|
||||
});
|
||||
|
||||
// Entity 2 still at its original allocation (renewed)
|
||||
const entity2PostCycle = await autumnV1.entities.get(
|
||||
customerId,
|
||||
entities[1].id,
|
||||
);
|
||||
expectCustomerFeatureCorrect({
|
||||
customer: entity2PostCycle,
|
||||
featureId: TestFeature.Messages,
|
||||
balance: initialQuantity2,
|
||||
});
|
||||
|
||||
// Renewal invoice = renewal amount + prorated credit (negative)
|
||||
const afterCycle = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
expectCustomerInvoiceCorrect({
|
||||
customer: afterCycle,
|
||||
count: invoiceCountBefore + 1,
|
||||
latestStatus: "paid",
|
||||
latestTotal: renewalAmount + proratedCredit,
|
||||
});
|
||||
|
||||
await expectStripeSubscriptionCorrect({ ctx, customerId });
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 4: OnDecrease.None — no credit invoice on decrease
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Entity 1 starts with 400 units. Packs = (400-100)/100 = 3, cost = 3 * $10 = $30.
|
||||
* Entity 2 starts with 300 units. Packs = (300-100)/100 = 2, cost = 2 * $10 = $20.
|
||||
*
|
||||
* Entity 1 decreases to 200 units. Packs = (200-100)/100 = 1, cost = 1 * $10 = $10.
|
||||
* With OnDecrease.None:
|
||||
* - Preview = $0, no credit invoice
|
||||
* - Balance stays at 400 until next cycle
|
||||
* - After cycle: balance becomes 200, renewal = $10 + $20 = $30 (flat, no proration)
|
||||
*/
|
||||
test.concurrent(`${chalk.yellowBright("multi-entity-proration: OnDecrease.None — no credit invoice")}`, async () => {
|
||||
const customerId = "multi-ent-proration-no-decrease";
|
||||
const billingUnits = 100;
|
||||
const pricePerUnit = 10;
|
||||
const includedUsage = 100;
|
||||
|
||||
const prepaidItem = items.prepaid({
|
||||
featureId: TestFeature.Messages,
|
||||
billingUnits,
|
||||
price: pricePerUnit,
|
||||
includedUsage,
|
||||
config: {
|
||||
on_increase: OnIncrease.ProrateImmediately,
|
||||
on_decrease: OnDecrease.None,
|
||||
},
|
||||
});
|
||||
|
||||
const product = products.base({
|
||||
id: "prepaid",
|
||||
items: [prepaidItem],
|
||||
});
|
||||
|
||||
const initialQuantity1 = 4 * billingUnits; // 400
|
||||
const initialQuantity2 = 3 * billingUnits; // 300
|
||||
const newQuantity1 = 2 * billingUnits; // 200
|
||||
|
||||
// Costs
|
||||
const entity1NewCost =
|
||||
((newQuantity1 - includedUsage) / billingUnits) * pricePerUnit; // $10
|
||||
const entity2Cost =
|
||||
((initialQuantity2 - includedUsage) / billingUnits) * pricePerUnit; // $20
|
||||
const renewalAmount = entity1NewCost + entity2Cost; // $30
|
||||
|
||||
const { autumnV1, ctx, entities, testClockId } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [product] }),
|
||||
s.entities({ count: 2, featureId: TestFeature.Users }),
|
||||
],
|
||||
actions: [
|
||||
s.billing.attach({
|
||||
productId: product.id,
|
||||
entityIndex: 0,
|
||||
options: [
|
||||
{
|
||||
feature_id: TestFeature.Messages,
|
||||
quantity: initialQuantity1,
|
||||
},
|
||||
],
|
||||
}),
|
||||
s.billing.attach({
|
||||
productId: product.id,
|
||||
entityIndex: 1,
|
||||
options: [
|
||||
{
|
||||
feature_id: TestFeature.Messages,
|
||||
quantity: initialQuantity2,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const beforeInvoices =
|
||||
await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
const invoiceCountBefore = beforeInvoices.invoices?.length ?? 0;
|
||||
|
||||
// Preview the downgrade — should be $0 (no immediate credit)
|
||||
const preview = await autumnV1.subscriptions.previewUpdate({
|
||||
customer_id: customerId,
|
||||
entity_id: entities[0].id,
|
||||
product_id: product.id,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: newQuantity1 }],
|
||||
});
|
||||
expect(preview.total).toBe(0);
|
||||
|
||||
// Execute the downgrade
|
||||
await autumnV1.subscriptions.update({
|
||||
customer_id: customerId,
|
||||
entity_id: entities[0].id,
|
||||
product_id: product.id,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: newQuantity1 }],
|
||||
});
|
||||
|
||||
// Balance stays at 400 (OnDecrease.None keeps old balance until next cycle)
|
||||
const entity1After = await autumnV1.entities.get(customerId, entities[0].id);
|
||||
expectCustomerFeatureCorrect({
|
||||
customer: entity1After,
|
||||
featureId: TestFeature.Messages,
|
||||
balance: initialQuantity1,
|
||||
});
|
||||
|
||||
// No new invoice created
|
||||
const afterUpdate = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
expectCustomerInvoiceCorrect({
|
||||
customer: afterUpdate,
|
||||
count: invoiceCountBefore,
|
||||
});
|
||||
|
||||
// Entity 2 unchanged
|
||||
const entity2 = await autumnV1.entities.get(customerId, entities[1].id);
|
||||
expectCustomerFeatureCorrect({
|
||||
customer: entity2,
|
||||
featureId: TestFeature.Messages,
|
||||
balance: initialQuantity2,
|
||||
});
|
||||
|
||||
// Advance to next cycle
|
||||
await advanceToNextInvoice({
|
||||
stripeCli: ctx.stripeCli,
|
||||
testClockId: testClockId!,
|
||||
});
|
||||
|
||||
// After cycle: entity 1 balance = 200 (new quantity takes effect)
|
||||
const afterAdvance = await autumnV1.entities.get(customerId, entities[0].id);
|
||||
expectCustomerFeatureCorrect({
|
||||
customer: afterAdvance,
|
||||
featureId: TestFeature.Messages,
|
||||
balance: newQuantity1,
|
||||
});
|
||||
|
||||
// Renewal invoice: flat renewal, no proration adjustments
|
||||
const customerAfter = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
expectCustomerInvoiceCorrect({
|
||||
customer: customerAfter,
|
||||
count: invoiceCountBefore + 1,
|
||||
latestTotal: renewalAmount,
|
||||
});
|
||||
|
||||
await expectStripeSubscriptionCorrect({ ctx, customerId });
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 5: OnDecrease.None — decrease then increase back (net zero)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Entity 1 starts with 400 units (cost $30). Entity 2 starts with 300 (cost $20).
|
||||
*
|
||||
* Entity 1 decreases 400->200 (OnDecrease.None: no invoice, balance stays 400).
|
||||
* Entity 1 increases back to 400 (no-op: current Stripe sub is still at 400).
|
||||
* Preview = $0, no new invoice.
|
||||
*
|
||||
* After cycle: renewal = $30 + $20 = $50 (original amounts, net change = 0).
|
||||
*/
|
||||
test.concurrent(`${chalk.yellowBright("multi-entity-proration: OnDecrease.None — decrease then increase back (net zero)")}`, async () => {
|
||||
const customerId = "multi-ent-proration-none-netzero";
|
||||
const billingUnits = 100;
|
||||
const pricePerUnit = 10;
|
||||
const includedUsage = 100;
|
||||
|
||||
const prepaidItem = items.prepaid({
|
||||
featureId: TestFeature.Messages,
|
||||
billingUnits,
|
||||
price: pricePerUnit,
|
||||
includedUsage,
|
||||
config: {
|
||||
on_increase: OnIncrease.ProrateImmediately,
|
||||
on_decrease: OnDecrease.None,
|
||||
},
|
||||
});
|
||||
|
||||
const product = products.base({
|
||||
id: "prepaid",
|
||||
items: [prepaidItem],
|
||||
});
|
||||
|
||||
const initialQuantity1 = 4 * billingUnits; // 400
|
||||
const initialQuantity2 = 3 * billingUnits; // 300
|
||||
|
||||
// Costs
|
||||
const entity1Cost =
|
||||
((initialQuantity1 - includedUsage) / billingUnits) * pricePerUnit; // $30
|
||||
const entity2Cost =
|
||||
((initialQuantity2 - includedUsage) / billingUnits) * pricePerUnit; // $20
|
||||
const renewalAmount = entity1Cost + entity2Cost; // $50
|
||||
|
||||
const { autumnV1, ctx, entities, testClockId } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [product] }),
|
||||
s.entities({ count: 2, featureId: TestFeature.Users }),
|
||||
],
|
||||
actions: [
|
||||
s.billing.attach({
|
||||
productId: product.id,
|
||||
entityIndex: 0,
|
||||
options: [
|
||||
{
|
||||
feature_id: TestFeature.Messages,
|
||||
quantity: initialQuantity1,
|
||||
},
|
||||
],
|
||||
}),
|
||||
s.billing.attach({
|
||||
productId: product.id,
|
||||
entityIndex: 1,
|
||||
options: [
|
||||
{
|
||||
feature_id: TestFeature.Messages,
|
||||
quantity: initialQuantity2,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const beforeInvoices =
|
||||
await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
const invoiceCountBefore = beforeInvoices.invoices?.length ?? 0;
|
||||
|
||||
// ── Step 1: Decrease entity 1 from 400 -> 200 ──
|
||||
const decreasePreview = await autumnV1.subscriptions.previewUpdate({
|
||||
customer_id: customerId,
|
||||
entity_id: entities[0].id,
|
||||
product_id: product.id,
|
||||
options: [
|
||||
{
|
||||
feature_id: TestFeature.Messages,
|
||||
quantity: 2 * billingUnits, // 200
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(decreasePreview.total).toBe(0);
|
||||
|
||||
await autumnV1.subscriptions.update({
|
||||
customer_id: customerId,
|
||||
entity_id: entities[0].id,
|
||||
product_id: product.id,
|
||||
options: [
|
||||
{
|
||||
feature_id: TestFeature.Messages,
|
||||
quantity: 2 * billingUnits, // 200
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Balance stays at 400 (OnDecrease.None)
|
||||
const entity1AfterDecrease = await autumnV1.entities.get(
|
||||
customerId,
|
||||
entities[0].id,
|
||||
);
|
||||
expectCustomerFeatureCorrect({
|
||||
customer: entity1AfterDecrease,
|
||||
featureId: TestFeature.Messages,
|
||||
balance: initialQuantity1,
|
||||
});
|
||||
|
||||
// Verify product item: quantity=400, upcomingQuantity=200
|
||||
await expectProductItemCorrect({
|
||||
customer: entity1AfterDecrease,
|
||||
productId: product.id,
|
||||
featureId: TestFeature.Messages,
|
||||
quantity: initialQuantity1 - includedUsage,
|
||||
upcomingQuantity: 2 * billingUnits - includedUsage,
|
||||
});
|
||||
|
||||
// No new invoice
|
||||
const afterDecrease = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
expectCustomerInvoiceCorrect({
|
||||
customer: afterDecrease,
|
||||
count: invoiceCountBefore,
|
||||
});
|
||||
|
||||
// ── Step 2: Increase entity 1 back to 400 (no-op) ──
|
||||
const increasePreview = await autumnV1.subscriptions.previewUpdate({
|
||||
customer_id: customerId,
|
||||
entity_id: entities[0].id,
|
||||
product_id: product.id,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: initialQuantity1 }],
|
||||
});
|
||||
expect(increasePreview.total).toBe(0);
|
||||
|
||||
await autumnV1.subscriptions.update({
|
||||
customer_id: customerId,
|
||||
entity_id: entities[0].id,
|
||||
product_id: product.id,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: initialQuantity1 }],
|
||||
});
|
||||
|
||||
// Balance still 400
|
||||
const entity1AfterIncrease = await autumnV1.entities.get(
|
||||
customerId,
|
||||
entities[0].id,
|
||||
);
|
||||
expectCustomerFeatureCorrect({
|
||||
customer: entity1AfterIncrease,
|
||||
featureId: TestFeature.Messages,
|
||||
balance: initialQuantity1,
|
||||
});
|
||||
|
||||
// Verify product item: quantity=400, upcomingQuantity should be gone (back to original)
|
||||
await expectProductItemCorrect({
|
||||
customer: entity1AfterIncrease,
|
||||
productId: product.id,
|
||||
featureId: TestFeature.Messages,
|
||||
quantity: initialQuantity1 - includedUsage,
|
||||
upcomingQuantity: 300,
|
||||
});
|
||||
|
||||
// Still no new invoices (increase back was a no-op)
|
||||
const afterIncrease = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
expectCustomerInvoiceCorrect({
|
||||
customer: afterIncrease,
|
||||
count: invoiceCountBefore,
|
||||
});
|
||||
|
||||
// ── Step 3: Advance to next cycle ──
|
||||
await advanceToNextInvoice({
|
||||
stripeCli: ctx.stripeCli,
|
||||
testClockId: testClockId!,
|
||||
});
|
||||
|
||||
// Renewal: original amounts since net change = 0
|
||||
const customerAfter = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
expectCustomerInvoiceCorrect({
|
||||
customer: customerAfter,
|
||||
count: invoiceCountBefore + 1,
|
||||
latestTotal: renewalAmount,
|
||||
});
|
||||
|
||||
// Entity 1 balance renewed at 400 (original quantity)
|
||||
const entity1PostCycle = await autumnV1.entities.get(
|
||||
customerId,
|
||||
entities[0].id,
|
||||
);
|
||||
expectCustomerFeatureCorrect({
|
||||
customer: entity1PostCycle,
|
||||
featureId: TestFeature.Messages,
|
||||
balance: initialQuantity1,
|
||||
});
|
||||
|
||||
// Entity 2 unchanged
|
||||
const entity2PostCycle = await autumnV1.entities.get(
|
||||
customerId,
|
||||
entities[1].id,
|
||||
);
|
||||
expectCustomerFeatureCorrect({
|
||||
customer: entity2PostCycle,
|
||||
featureId: TestFeature.Messages,
|
||||
balance: initialQuantity2,
|
||||
});
|
||||
|
||||
await expectStripeSubscriptionCorrect({ ctx, customerId });
|
||||
});
|
||||
@@ -1,14 +1,13 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { type ApiCustomerV3, OnDecrease, OnIncrease } from "@autumn/shared";
|
||||
import type { ApiCustomerV3 } from "@autumn/shared";
|
||||
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect.js";
|
||||
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect.js";
|
||||
import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect.js";
|
||||
import { expectLatestInvoiceCorrect } from "@tests/integration/billing/utils/expectLatestInvoiceCorrect.js";
|
||||
import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect.js";
|
||||
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 { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
|
||||
import chalk from "chalk";
|
||||
|
||||
@@ -22,7 +21,6 @@ import chalk from "chalk";
|
||||
* - Entity 1 increases quantity while Entity 2 remains unchanged
|
||||
* - Entity 2 decreases quantity while Entity 1 remains unchanged
|
||||
* - Cross-entity mixed changes (increase one, decrease other)
|
||||
* - OnDecrease.None config (no credit invoice on decrease)
|
||||
* - Different products per entity with quantity updates
|
||||
* - Multiple features per entity
|
||||
*/
|
||||
@@ -48,11 +46,7 @@ test.concurrent(`${chalk.yellowBright("multi-entity-quantity: entity 1 increases
|
||||
const initialQuantity2 = 5 * billingUnits; // 60
|
||||
const newQuantity1 = 20 * billingUnits; // 240
|
||||
|
||||
const {
|
||||
autumnV1,
|
||||
ctx: testContext,
|
||||
entities,
|
||||
} = await initScenario({
|
||||
const { autumnV1, ctx, entities } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
@@ -120,13 +114,11 @@ test.concurrent(`${chalk.yellowBright("multi-entity-quantity: entity 1 increases
|
||||
amount: 10 * pricePerUnit,
|
||||
});
|
||||
|
||||
// Verify subscription count
|
||||
await expectSubToBeCorrect({
|
||||
db: testContext.db,
|
||||
// Verify Stripe subscription matches expected state
|
||||
await expectStripeSubscriptionCorrect({
|
||||
ctx,
|
||||
customerId,
|
||||
org: testContext.org,
|
||||
env: testContext.env,
|
||||
subCount: 1,
|
||||
options: { subCount: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -151,7 +143,7 @@ test.concurrent(`${chalk.yellowBright("multi-entity-quantity: entity 2 decreases
|
||||
const initialQuantity2 = 15 * billingUnits; // 180
|
||||
const newQuantity2 = 5 * billingUnits; // 60
|
||||
|
||||
const { autumnV1, entities } = await initScenario({
|
||||
const { autumnV1, entities, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
@@ -218,6 +210,8 @@ test.concurrent(`${chalk.yellowBright("multi-entity-quantity: entity 2 decreases
|
||||
productId: product.id,
|
||||
amount: -10 * pricePerUnit,
|
||||
});
|
||||
|
||||
await expectStripeSubscriptionCorrect({ ctx, customerId });
|
||||
});
|
||||
|
||||
// Test 3: Cross-Entity Mixed Changes
|
||||
@@ -242,7 +236,7 @@ test.concurrent(`${chalk.yellowBright("multi-entity-quantity: mixed changes acro
|
||||
const newQuantity1 = 15 * billingUnits; // 150 (increase)
|
||||
const newQuantity2 = 10 * billingUnits; // 100 (decrease)
|
||||
|
||||
const { autumnV1, entities } = await initScenario({
|
||||
const { autumnV1, entities, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
@@ -321,119 +315,11 @@ test.concurrent(`${chalk.yellowBright("multi-entity-quantity: mixed changes acro
|
||||
customer,
|
||||
count: 4,
|
||||
});
|
||||
|
||||
await expectStripeSubscriptionCorrect({ ctx, customerId });
|
||||
});
|
||||
|
||||
// Test 4: OnDecrease.None Config - No Credit Invoice
|
||||
// With OnDecrease.None, the balance changes immediately but NO credit invoice is created
|
||||
// This differs from OnDecrease.ProrateImmediately which creates a credit invoice
|
||||
test.concurrent(`${chalk.yellowBright("multi-entity-quantity: OnDecrease.None creates no credit invoice")}`, async () => {
|
||||
const customerId = "multi-ent-qty-no-proration";
|
||||
const billingUnits = 100;
|
||||
const pricePerUnit = 10;
|
||||
|
||||
const prepaidItem = items.prepaid({
|
||||
featureId: TestFeature.Messages,
|
||||
billingUnits,
|
||||
price: pricePerUnit,
|
||||
config: {
|
||||
on_increase: OnIncrease.ProrateImmediately,
|
||||
on_decrease: OnDecrease.None,
|
||||
},
|
||||
});
|
||||
|
||||
const product = products.base({
|
||||
id: "prepaid",
|
||||
items: [prepaidItem],
|
||||
});
|
||||
|
||||
const initialQuantity1 = 4 * billingUnits; // 400
|
||||
const initialQuantity2 = 3 * billingUnits; // 300
|
||||
const newQuantity1 = 2 * billingUnits; // 200 (decrease)
|
||||
|
||||
const { autumnV1, entities, ctx, testClockId } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [product] }),
|
||||
s.entities({ count: 2, featureId: TestFeature.Users }),
|
||||
],
|
||||
actions: [
|
||||
s.attach({
|
||||
productId: product.id,
|
||||
entityIndex: 0,
|
||||
options: [
|
||||
{ feature_id: TestFeature.Messages, quantity: initialQuantity1 },
|
||||
],
|
||||
}),
|
||||
s.attach({
|
||||
productId: product.id,
|
||||
entityIndex: 1,
|
||||
options: [
|
||||
{ feature_id: TestFeature.Messages, quantity: initialQuantity2 },
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const beforeInvoices =
|
||||
await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
const invoiceCountBefore = beforeInvoices.invoices?.length || 0;
|
||||
|
||||
// Preview the downgrade - should be $0 (no immediate credit)
|
||||
const preview = await autumnV1.subscriptions.previewUpdate({
|
||||
customer_id: customerId,
|
||||
entity_id: entities[0].id,
|
||||
product_id: product.id,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: newQuantity1 }],
|
||||
});
|
||||
|
||||
expect(preview.total).toBe(0);
|
||||
|
||||
// Execute the downgrade
|
||||
await autumnV1.subscriptions.update({
|
||||
customer_id: customerId,
|
||||
entity_id: entities[0].id,
|
||||
product_id: product.id,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: newQuantity1 }],
|
||||
});
|
||||
|
||||
// Balance is updated AFTER the next cycle with OnDecrease.None
|
||||
const entity1After = await autumnV1.entities.get(customerId, entities[0].id);
|
||||
expectCustomerFeatureCorrect({
|
||||
customer: entity1After,
|
||||
featureId: TestFeature.Messages,
|
||||
balance: initialQuantity1,
|
||||
});
|
||||
|
||||
// No new invoice should be created (the key behavior of OnDecrease.None)
|
||||
const afterUpdate = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
expectCustomerInvoiceCorrect({
|
||||
customer: afterUpdate,
|
||||
count: invoiceCountBefore,
|
||||
});
|
||||
|
||||
// Entity 2 should be unchanged
|
||||
const entity2 = await autumnV1.entities.get(customerId, entities[1].id);
|
||||
expectCustomerFeatureCorrect({
|
||||
customer: entity2,
|
||||
featureId: TestFeature.Messages,
|
||||
balance: initialQuantity2,
|
||||
});
|
||||
|
||||
await advanceToNextInvoice({
|
||||
stripeCli: ctx.stripeCli,
|
||||
testClockId: testClockId!,
|
||||
});
|
||||
|
||||
const afterAdvance = await autumnV1.entities.get(customerId, entities[0].id);
|
||||
expectCustomerFeatureCorrect({
|
||||
customer: afterAdvance,
|
||||
featureId: TestFeature.Messages,
|
||||
balance: newQuantity1,
|
||||
});
|
||||
});
|
||||
|
||||
// Test 5: Different Products Per Entity
|
||||
// Test 4: Different Products Per Entity (was Test 5)
|
||||
test.concurrent(`${chalk.yellowBright("multi-entity-quantity: different products per entity")}`, async () => {
|
||||
const customerId = "multi-ent-qty-diff-products";
|
||||
const billingUnits = 10;
|
||||
@@ -464,7 +350,7 @@ test.concurrent(`${chalk.yellowBright("multi-entity-quantity: different products
|
||||
const initialQuantityPro = 10 * billingUnits; // 100
|
||||
const newQuantityPro = 15 * billingUnits; // 150 (increase)
|
||||
|
||||
const { autumnV1, entities } = await initScenario({
|
||||
const { autumnV1, entities, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
@@ -539,9 +425,11 @@ test.concurrent(`${chalk.yellowBright("multi-entity-quantity: different products
|
||||
productId: proProduct.id,
|
||||
amount: 5 * 8,
|
||||
});
|
||||
|
||||
await expectStripeSubscriptionCorrect({ ctx, customerId });
|
||||
});
|
||||
|
||||
// Test 6: Multiple Features Per Entity
|
||||
// Test 5: Multiple Features Per Entity (was Test 6)
|
||||
test.concurrent(`${chalk.yellowBright("multi-entity-quantity: multiple features per entity")}`, async () => {
|
||||
const customerId = "multi-ent-qty-multi-feat";
|
||||
const messagesBillingUnits = 10;
|
||||
@@ -566,7 +454,7 @@ test.concurrent(`${chalk.yellowBright("multi-entity-quantity: multiple features
|
||||
items: [messagesItem, wordsItem],
|
||||
});
|
||||
|
||||
const { autumnV1, entities } = await initScenario({
|
||||
const { autumnV1, entities, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
@@ -662,4 +550,6 @@ test.concurrent(`${chalk.yellowBright("multi-entity-quantity: multiple features
|
||||
productId: product.id,
|
||||
amount: 5 * messagesPrice - 1 * wordsPrice,
|
||||
});
|
||||
|
||||
await expectStripeSubscriptionCorrect({ ctx, customerId });
|
||||
});
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import type Stripe from "stripe";
|
||||
|
||||
export type PhaseScenario =
|
||||
| "no_phases"
|
||||
| "single_indefinite"
|
||||
| "simple_cancel"
|
||||
| "multi_phase";
|
||||
|
||||
const phaseHasItems = (
|
||||
phase: Stripe.SubscriptionScheduleUpdateParams.Phase,
|
||||
): boolean => {
|
||||
return phase.items !== undefined && phase.items.length > 0;
|
||||
};
|
||||
|
||||
/** Strips empty phases from both ends (mirrors production filterEmptyPhases). */
|
||||
const filterEmptyPhases = (
|
||||
phases: Stripe.SubscriptionScheduleUpdateParams.Phase[],
|
||||
): Stripe.SubscriptionScheduleUpdateParams.Phase[] => {
|
||||
const firstNonEmptyIndex = phases.findIndex(phaseHasItems);
|
||||
if (firstNonEmptyIndex === -1) return [];
|
||||
|
||||
let lastNonEmptyIndex = phases.length - 1;
|
||||
while (lastNonEmptyIndex >= 0 && !phaseHasItems(phases[lastNonEmptyIndex])) {
|
||||
lastNonEmptyIndex--;
|
||||
}
|
||||
|
||||
return phases.slice(firstNonEmptyIndex, lastNonEmptyIndex + 1);
|
||||
};
|
||||
|
||||
/**
|
||||
* Classifies raw phases into one of 4 scenarios.
|
||||
* Returns the scenario, non-empty phases, and the expected cancel_at (in seconds) if applicable.
|
||||
*/
|
||||
export const classifyPhaseScenario = ({
|
||||
rawPhases,
|
||||
}: {
|
||||
rawPhases: Stripe.SubscriptionScheduleUpdateParams.Phase[];
|
||||
}): {
|
||||
scenario: PhaseScenario;
|
||||
scheduledPhases: Stripe.SubscriptionScheduleUpdateParams.Phase[];
|
||||
cancelAtSeconds?: number;
|
||||
} => {
|
||||
const scheduledPhases = filterEmptyPhases(rawPhases);
|
||||
|
||||
const lastPhase = rawPhases[rawPhases.length - 1];
|
||||
const endsWithEmptyPhase = !!lastPhase && !phaseHasItems(lastPhase);
|
||||
const cancelAtSeconds =
|
||||
endsWithEmptyPhase && typeof lastPhase.start_date === "number"
|
||||
? lastPhase.start_date
|
||||
: undefined;
|
||||
|
||||
let scenario: PhaseScenario;
|
||||
|
||||
if (scheduledPhases.length === 0) {
|
||||
scenario = "no_phases";
|
||||
} else if (scheduledPhases.length === 1) {
|
||||
if (endsWithEmptyPhase) {
|
||||
scenario = "simple_cancel";
|
||||
} else if (!scheduledPhases[0].end_date) {
|
||||
scenario = "single_indefinite";
|
||||
} else {
|
||||
scenario = "multi_phase";
|
||||
}
|
||||
} else {
|
||||
scenario = "multi_phase";
|
||||
}
|
||||
|
||||
return { scenario, scheduledPhases, cancelAtSeconds };
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import { expect } from "bun:test";
|
||||
import {
|
||||
customerProductsToStripeSubscriptionIds,
|
||||
notNullish,
|
||||
} from "@autumn/shared";
|
||||
import type { TestContext } from "@tests/utils/testInitUtils/createTestContext";
|
||||
import { CusService } from "@/internal/customers/CusService";
|
||||
import type { ExpectStripeSubOptions } from "./types";
|
||||
import { verifySubscription } from "./verifySubscription";
|
||||
|
||||
/**
|
||||
* Verifies that all Stripe subscriptions for a customer match the expected state
|
||||
* derived from their customer products. Handles multiple subscriptions (new_billing_subscription),
|
||||
* inline entity-scoped prices, schedules, and cancellation.
|
||||
*/
|
||||
export const expectStripeSubscriptionCorrect = async ({
|
||||
ctx,
|
||||
customerId,
|
||||
options,
|
||||
}: {
|
||||
ctx: TestContext;
|
||||
customerId: string;
|
||||
options?: ExpectStripeSubOptions;
|
||||
}) => {
|
||||
// 1. Fetch full customer
|
||||
const fullCustomer = await CusService.getFull({
|
||||
ctx,
|
||||
idOrInternalId: customerId,
|
||||
withEntities: true,
|
||||
});
|
||||
|
||||
const cusProducts = fullCustomer.customer_products;
|
||||
|
||||
// 2. Validate total subscription count if requested
|
||||
if (options?.subCount !== undefined) {
|
||||
const stripeCustomerId = fullCustomer.processor?.id;
|
||||
expect(
|
||||
stripeCustomerId,
|
||||
`Customer ${customerId} has no Stripe processor ID`,
|
||||
).toBeDefined();
|
||||
|
||||
const subs = await ctx.stripeCli.subscriptions.list({
|
||||
customer: stripeCustomerId,
|
||||
});
|
||||
expect(subs.data.length).toBe(options.subCount);
|
||||
}
|
||||
|
||||
// 3. Determine which subscriptions to verify
|
||||
if (options?.subId) {
|
||||
await verifySubscription({
|
||||
ctx,
|
||||
subId: options.subId,
|
||||
cusProducts,
|
||||
options,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify ALL subscriptions referenced by cusProducts
|
||||
const subIds = customerProductsToStripeSubscriptionIds({
|
||||
customerProducts: cusProducts,
|
||||
}).filter(notNullish);
|
||||
|
||||
if (options?.debug) {
|
||||
console.log(`\nFound ${subIds.length} subscription(s) to verify:`, subIds);
|
||||
}
|
||||
|
||||
expect(
|
||||
subIds.length,
|
||||
"Expected at least one subscription ID on customer products",
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
for (const subId of subIds) {
|
||||
await verifySubscription({
|
||||
ctx,
|
||||
subId,
|
||||
cusProducts,
|
||||
options,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,173 @@
|
||||
import { expect } from "bun:test";
|
||||
import type { StripeInlinePrice } from "@autumn/shared";
|
||||
import type Stripe from "stripe";
|
||||
import type { NormalizedItem } from "../types";
|
||||
|
||||
/** Normalizes an actual Stripe subscription item into a comparable format. */
|
||||
export const normalizeActualSubItem = ({
|
||||
item,
|
||||
}: {
|
||||
item: Stripe.SubscriptionItem;
|
||||
}): NormalizedItem => {
|
||||
const autumnCusPriceId = item.metadata?.autumn_customer_price_id;
|
||||
return {
|
||||
priceId: item.price.id,
|
||||
autumnCustomerPriceId: autumnCusPriceId || undefined,
|
||||
quantity: item.quantity ?? 0,
|
||||
isInline: !!autumnCusPriceId,
|
||||
unitAmountDecimal: autumnCusPriceId
|
||||
? (item.price.unit_amount_decimal ?? undefined)
|
||||
: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
/** Normalizes an actual Stripe schedule phase item into a comparable format. */
|
||||
export const normalizeActualPhaseItem = ({
|
||||
item,
|
||||
}: {
|
||||
item: Stripe.SubscriptionSchedule.Phase.Item;
|
||||
}): NormalizedItem => {
|
||||
const priceId = typeof item.price === "string" ? item.price : item.price.id;
|
||||
const autumnCusPriceId = item.metadata?.autumn_customer_price_id;
|
||||
const priceObj =
|
||||
typeof item.price !== "string" && "unit_amount_decimal" in item.price
|
||||
? item.price
|
||||
: undefined;
|
||||
const unitAmountDecimal =
|
||||
autumnCusPriceId && priceObj
|
||||
? (priceObj.unit_amount_decimal ?? undefined)
|
||||
: undefined;
|
||||
return {
|
||||
priceId,
|
||||
autumnCustomerPriceId: autumnCusPriceId || undefined,
|
||||
quantity: item.quantity ?? 0,
|
||||
isInline: !!autumnCusPriceId,
|
||||
unitAmountDecimal,
|
||||
};
|
||||
};
|
||||
|
||||
/** Normalizes an expected phase item (from buildStripePhasesUpdate) into a comparable format. */
|
||||
export const normalizeExpectedPhaseItem = ({
|
||||
item,
|
||||
}: {
|
||||
item: Stripe.SubscriptionScheduleUpdateParams.Phase.Item;
|
||||
}): NormalizedItem => {
|
||||
const hasInlinePrice = "price_data" in item;
|
||||
const metadata = item.metadata as Record<string, string> | undefined;
|
||||
|
||||
let unitAmountDecimal: string | undefined;
|
||||
if (hasInlinePrice) {
|
||||
const priceData = (item as { price_data: StripeInlinePrice }).price_data;
|
||||
unitAmountDecimal = priceData.unit_amount_decimal;
|
||||
}
|
||||
|
||||
return {
|
||||
priceId: hasInlinePrice ? undefined : (item.price as string),
|
||||
autumnCustomerPriceId: metadata?.autumn_customer_price_id,
|
||||
quantity: (item.quantity as number) ?? 0,
|
||||
isInline: hasInlinePrice,
|
||||
unitAmountDecimal,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Compares expected items against actual items.
|
||||
* Stored items match by priceId. Inline items match by autumn_customer_price_id.
|
||||
*/
|
||||
export const compareItems = ({
|
||||
expectedItems,
|
||||
actualItems,
|
||||
label,
|
||||
debug,
|
||||
}: {
|
||||
expectedItems: NormalizedItem[];
|
||||
actualItems: NormalizedItem[];
|
||||
label: string;
|
||||
debug?: boolean;
|
||||
}) => {
|
||||
if (debug) {
|
||||
console.log(`\n[${label}] Expected items (${expectedItems.length}):`);
|
||||
for (const item of expectedItems) {
|
||||
console.log(
|
||||
` ${item.isInline ? "inline" : "stored"} | price=${item.priceId ?? "N/A"} | cusPriceId=${item.autumnCustomerPriceId ?? "N/A"} | qty=${item.quantity} | amount=${item.unitAmountDecimal ?? "N/A"}`,
|
||||
);
|
||||
}
|
||||
console.log(`[${label}] Actual items (${actualItems.length}):`);
|
||||
for (const item of actualItems) {
|
||||
console.log(
|
||||
` ${item.isInline ? "inline" : "stored"} | price=${item.priceId ?? "N/A"} | cusPriceId=${item.autumnCustomerPriceId ?? "N/A"} | qty=${item.quantity} | amount=${item.unitAmountDecimal ?? "N/A"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const expected of expectedItems) {
|
||||
let actual: NormalizedItem | undefined;
|
||||
|
||||
if (expected.isInline) {
|
||||
actual = actualItems.find(
|
||||
(a) => a.autumnCustomerPriceId === expected.autumnCustomerPriceId,
|
||||
);
|
||||
|
||||
if (!actual) {
|
||||
console.error(
|
||||
`[${label}] Missing inline item with autumn_customer_price_id=${expected.autumnCustomerPriceId}`,
|
||||
);
|
||||
console.error(` Expected:`, expected);
|
||||
console.error(` Actual items:`, actualItems);
|
||||
}
|
||||
} else {
|
||||
actual = actualItems.find((a) => a.priceId === expected.priceId);
|
||||
|
||||
if (!actual) {
|
||||
console.error(
|
||||
`[${label}] Missing stored item with priceId=${expected.priceId}`,
|
||||
);
|
||||
console.error(` Expected:`, expected);
|
||||
console.error(` Actual items:`, actualItems);
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
actual,
|
||||
`[${label}] No matching actual item for expected: ${JSON.stringify(expected)}`,
|
||||
).toBeDefined();
|
||||
|
||||
if (actual && actual.quantity !== expected.quantity) {
|
||||
console.error(
|
||||
`[${label}] Quantity mismatch for ${expected.isInline ? `inline cusPriceId=${expected.autumnCustomerPriceId}` : `stored priceId=${expected.priceId}`}: expected=${expected.quantity}, actual=${actual.quantity}`,
|
||||
);
|
||||
}
|
||||
|
||||
expect(actual?.quantity).toBe(expected.quantity);
|
||||
|
||||
// Compare unit_amount_decimal for inline prices
|
||||
if (
|
||||
actual &&
|
||||
expected.unitAmountDecimal !== undefined &&
|
||||
actual.unitAmountDecimal !== undefined
|
||||
) {
|
||||
if (actual.unitAmountDecimal !== expected.unitAmountDecimal) {
|
||||
const itemLabel = expected.isInline
|
||||
? `inline cusPriceId=${expected.autumnCustomerPriceId}`
|
||||
: `stored priceId=${expected.priceId}`;
|
||||
console.error(
|
||||
`[${label}] Price amount mismatch for ${itemLabel}: expected=${expected.unitAmountDecimal}, actual=${actual.unitAmountDecimal}`,
|
||||
);
|
||||
}
|
||||
expect(
|
||||
actual.unitAmountDecimal,
|
||||
`[${label}] unit_amount_decimal mismatch for ${expected.isInline ? `inline cusPriceId=${expected.autumnCustomerPriceId}` : `stored priceId=${expected.priceId}`}`,
|
||||
).toBe(expected.unitAmountDecimal);
|
||||
}
|
||||
}
|
||||
|
||||
if (actualItems.length !== expectedItems.length) {
|
||||
console.error(
|
||||
`[${label}] Item count mismatch: expected=${expectedItems.length}, actual=${actualItems.length}`,
|
||||
);
|
||||
console.error(` Expected:`, expectedItems);
|
||||
console.error(` Actual:`, actualItems);
|
||||
}
|
||||
|
||||
expect(actualItems.length).toBe(expectedItems.length);
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import { expect } from "bun:test";
|
||||
import type Stripe from "stripe";
|
||||
|
||||
/** Validates that the subscription has exactly the expected reward coupon IDs. */
|
||||
export const validateRewards = ({
|
||||
sub,
|
||||
rewards,
|
||||
}: {
|
||||
sub: Stripe.Subscription;
|
||||
rewards: string[];
|
||||
}) => {
|
||||
const subCouponIds =
|
||||
sub.discounts?.map((discount) => {
|
||||
if (typeof discount === "string") return discount;
|
||||
const d = discount as Stripe.Discount;
|
||||
return d.source?.coupon
|
||||
? typeof d.source.coupon === "string"
|
||||
? d.source.coupon
|
||||
: d.source.coupon.id
|
||||
: undefined;
|
||||
}) ?? [];
|
||||
|
||||
for (const reward of rewards) {
|
||||
const found = subCouponIds.find((id) => id === reward);
|
||||
expect(found, `Expected reward coupon ${reward} on sub`).toBeDefined();
|
||||
}
|
||||
|
||||
expect(subCouponIds.length).toBe(rewards.length);
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import { expect } from "bun:test";
|
||||
import type { TestContext } from "@tests/utils/testInitUtils/createTestContext";
|
||||
import type Stripe from "stripe";
|
||||
import { similarUnix } from "@/internal/customers/attach/mergeUtils/phaseUtils/phaseUtils";
|
||||
import { formatUnixToDateTime } from "@/utils/genUtils";
|
||||
import {
|
||||
compareItems,
|
||||
normalizeActualPhaseItem,
|
||||
normalizeExpectedPhaseItem,
|
||||
} from "./compareItems";
|
||||
|
||||
/** Validates that actual Stripe schedule phases match the expected phases. */
|
||||
export const validateSchedulePhases = async ({
|
||||
ctx,
|
||||
sub,
|
||||
scheduledPhases,
|
||||
debug,
|
||||
}: {
|
||||
ctx: TestContext;
|
||||
sub: Stripe.Subscription;
|
||||
scheduledPhases: Stripe.SubscriptionScheduleUpdateParams.Phase[];
|
||||
debug?: boolean;
|
||||
}) => {
|
||||
if (!sub.schedule) return;
|
||||
|
||||
const scheduleId =
|
||||
typeof sub.schedule === "string" ? sub.schedule : sub.schedule.id;
|
||||
|
||||
const schedule = await ctx.stripeCli.subscriptionSchedules.retrieve(
|
||||
scheduleId,
|
||||
{ expand: ["phases.items.price"] },
|
||||
);
|
||||
|
||||
for (let i = 0; i < scheduledPhases.length; i++) {
|
||||
const expectedPhase = scheduledPhases[i];
|
||||
const expectedStartSeconds = expectedPhase.start_date as number;
|
||||
|
||||
const actualPhase = schedule.phases.find((phase) =>
|
||||
similarUnix({
|
||||
unix1: expectedStartSeconds * 1000,
|
||||
unix2: phase.start_date * 1000,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!actualPhase) {
|
||||
console.error(
|
||||
`No matching schedule phase found for expected phase ${i} starting at ${formatUnixToDateTime(expectedStartSeconds * 1000)}`,
|
||||
);
|
||||
console.error(
|
||||
`Available phases:`,
|
||||
schedule.phases.map((p) => ({
|
||||
start: formatUnixToDateTime(p.start_date * 1000),
|
||||
end: formatUnixToDateTime(p.end_date * 1000),
|
||||
items: p.items.length,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
expect(
|
||||
actualPhase,
|
||||
`No matching phase at ${formatUnixToDateTime(expectedStartSeconds * 1000)}`,
|
||||
).toBeDefined();
|
||||
|
||||
if (!actualPhase) continue;
|
||||
|
||||
const expectedItems = (expectedPhase.items ?? []).map((item) =>
|
||||
normalizeExpectedPhaseItem({ item }),
|
||||
);
|
||||
const actualItems = actualPhase.items.map((item) =>
|
||||
normalizeActualPhaseItem({ item }),
|
||||
);
|
||||
|
||||
compareItems({
|
||||
expectedItems,
|
||||
actualItems,
|
||||
label: `schedule phase ${i} (${formatUnixToDateTime(expectedStartSeconds * 1000)})`,
|
||||
debug,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,136 @@
|
||||
import { expect } from "bun:test";
|
||||
import type { TestContext } from "@tests/utils/testInitUtils/createTestContext";
|
||||
import type Stripe from "stripe";
|
||||
import type { PhaseScenario } from "../classifyPhaseScenario";
|
||||
|
||||
/**
|
||||
* Checks whether the subscription has an active schedule with future phase transitions.
|
||||
* After a schedule completes/releases, Stripe keeps the ID on the subscription
|
||||
* but the schedule status is "released" or "completed" — not active.
|
||||
* Also, a schedule in its final phase with end_behavior "release" is effectively done
|
||||
* even if Stripe hasn't processed the release yet (test clock timing).
|
||||
*/
|
||||
const hasActiveScheduleWithFuturePhases = async ({
|
||||
ctx,
|
||||
sub,
|
||||
}: {
|
||||
ctx: TestContext;
|
||||
sub: Stripe.Subscription;
|
||||
}): Promise<boolean> => {
|
||||
if (!sub.schedule) return false;
|
||||
|
||||
const scheduleId =
|
||||
typeof sub.schedule === "string" ? sub.schedule : sub.schedule.id;
|
||||
const schedule =
|
||||
await ctx.stripeCli.subscriptionSchedules.retrieve(scheduleId);
|
||||
|
||||
// Already released/completed/canceled — not active
|
||||
if (
|
||||
schedule.status === "released" ||
|
||||
schedule.status === "completed" ||
|
||||
schedule.status === "canceled"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Schedule is active but may be in its final phase awaiting release.
|
||||
// If end_behavior is "release" and we're in the last phase, treat as done.
|
||||
if (schedule.end_behavior === "release" && schedule.phases.length > 0) {
|
||||
const lastPhase = schedule.phases[schedule.phases.length - 1];
|
||||
const currentPhase = schedule.current_phase;
|
||||
if (currentPhase && currentPhase.start_date === lastPhase.start_date) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
/** Validates cancel/schedule state on a subscription based on the classified scenario. */
|
||||
export const validateSubState = async ({
|
||||
ctx,
|
||||
sub,
|
||||
scenario,
|
||||
cancelAtSeconds,
|
||||
shouldBeCanceled,
|
||||
debug,
|
||||
}: {
|
||||
ctx: TestContext;
|
||||
sub: Stripe.Subscription;
|
||||
scenario: PhaseScenario;
|
||||
cancelAtSeconds?: number;
|
||||
shouldBeCanceled?: boolean;
|
||||
debug?: boolean;
|
||||
}) => {
|
||||
// Explicit override takes priority
|
||||
if (shouldBeCanceled === true) {
|
||||
expect(
|
||||
sub.cancel_at !== null ||
|
||||
sub.canceled_at !== null ||
|
||||
sub.cancel_at_period_end,
|
||||
"Expected subscription to be canceling",
|
||||
).toBe(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldBeCanceled === false) {
|
||||
expect(sub.cancel_at).toBeNull();
|
||||
expect(sub.canceled_at).toBeNull();
|
||||
return;
|
||||
}
|
||||
|
||||
// Infer expectations from scenario
|
||||
switch (scenario) {
|
||||
case "no_phases":
|
||||
break;
|
||||
|
||||
case "single_indefinite": {
|
||||
if (debug) {
|
||||
console.log(
|
||||
`single_indefinite: cancel_at=${sub.cancel_at}, schedule=${sub.schedule}`,
|
||||
);
|
||||
}
|
||||
expect(sub.cancel_at).toBeNull();
|
||||
const active = await hasActiveScheduleWithFuturePhases({ ctx, sub });
|
||||
expect(
|
||||
active,
|
||||
`Expected no active schedule with future phases on sub ${sub.id}, but schedule ${sub.schedule} is still active`,
|
||||
).toBe(false);
|
||||
break;
|
||||
}
|
||||
|
||||
case "simple_cancel": {
|
||||
if (debug) {
|
||||
console.log(
|
||||
`simple_cancel: cancel_at=${sub.cancel_at}, expected=${cancelAtSeconds}, schedule=${sub.schedule}`,
|
||||
);
|
||||
}
|
||||
expect(sub.cancel_at).not.toBeNull();
|
||||
|
||||
if (cancelAtSeconds !== undefined && sub.cancel_at !== null) {
|
||||
expect(Math.abs(sub.cancel_at - cancelAtSeconds)).toBeLessThanOrEqual(
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
const active = await hasActiveScheduleWithFuturePhases({ ctx, sub });
|
||||
expect(
|
||||
active,
|
||||
`Expected no active schedule with future phases on sub ${sub.id} for simple_cancel`,
|
||||
).toBe(false);
|
||||
break;
|
||||
}
|
||||
|
||||
case "multi_phase":
|
||||
if (debug) {
|
||||
console.log(
|
||||
`multi_phase: schedule=${sub.schedule}, cancel_at=${sub.cancel_at}`,
|
||||
);
|
||||
}
|
||||
expect(
|
||||
sub.schedule,
|
||||
`Expected subscription ${sub.id} to have a schedule`,
|
||||
).not.toBeNull();
|
||||
break;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
classifyPhaseScenario,
|
||||
type PhaseScenario,
|
||||
} from "./classifyPhaseScenario";
|
||||
export { expectStripeSubscriptionCorrect } from "./expectStripeSubscriptionCorrect";
|
||||
export type { ExpectStripeSubOptions, NormalizedItem } from "./types";
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { BillingVersion } from "@autumn/shared";
|
||||
|
||||
export type ExpectStripeSubOptions = {
|
||||
status?: "active" | "trialing";
|
||||
shouldBeCanceled?: boolean;
|
||||
subId?: string;
|
||||
subCount?: number;
|
||||
rewards?: string[];
|
||||
billingVersion?: BillingVersion;
|
||||
debug?: boolean;
|
||||
};
|
||||
|
||||
export type NormalizedItem = {
|
||||
priceId?: string;
|
||||
autumnCustomerPriceId?: string;
|
||||
quantity: number;
|
||||
isInline: boolean;
|
||||
/** Stripe unit_amount_decimal (string, in smallest currency unit). Present for inline prices. */
|
||||
unitAmountDecimal?: string;
|
||||
};
|
||||
@@ -0,0 +1,128 @@
|
||||
import { expect } from "bun:test";
|
||||
import { cp, type FullCusProduct } from "@autumn/shared";
|
||||
import { contexts } from "@tests/utils/fixtures/db/contexts";
|
||||
import type { TestContext } from "@tests/utils/testInitUtils/createTestContext";
|
||||
import { buildStripePhasesUpdate } from "@/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/buildStripePhasesUpdate";
|
||||
import { formatUnixToDateTime } from "@/utils/genUtils";
|
||||
import { classifyPhaseScenario } from "./classifyPhaseScenario";
|
||||
import {
|
||||
compareItems,
|
||||
normalizeActualSubItem,
|
||||
normalizeExpectedPhaseItem,
|
||||
} from "./helpers/compareItems";
|
||||
import { validateRewards } from "./helpers/validateRewards";
|
||||
import { validateSchedulePhases } from "./helpers/validateSchedulePhases";
|
||||
import { validateSubState } from "./helpers/validateSubState";
|
||||
import type { ExpectStripeSubOptions } from "./types";
|
||||
|
||||
/** Verifies a single Stripe subscription against expected state derived from customer products. */
|
||||
export const verifySubscription = async ({
|
||||
ctx,
|
||||
subId,
|
||||
cusProducts,
|
||||
options,
|
||||
}: {
|
||||
ctx: TestContext;
|
||||
subId: string;
|
||||
cusProducts: FullCusProduct[];
|
||||
options?: ExpectStripeSubOptions;
|
||||
}) => {
|
||||
const debug = options?.debug ?? false;
|
||||
|
||||
// Filter to cusProducts belonging to this subscription that are paid + recurring + relevant status
|
||||
const relatedCusProducts = cusProducts.filter(
|
||||
(cusProduct) =>
|
||||
cusProduct.subscription_ids?.includes(subId) &&
|
||||
cp(cusProduct).paid().recurring().hasRelevantStatus().valid,
|
||||
);
|
||||
|
||||
if (debug) {
|
||||
console.log(`\n--- Verifying subscription: ${subId} ---`);
|
||||
console.log(
|
||||
`Related cusProducts (${relatedCusProducts.length}):`,
|
||||
relatedCusProducts.map((cp) => ({
|
||||
product: cp.product.name,
|
||||
status: cp.status,
|
||||
canceled: cp.canceled,
|
||||
entity: cp.internal_entity_id,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
// 1. Build expected phases using production code
|
||||
const billingContext = contexts.createBilling({
|
||||
customerProducts: relatedCusProducts,
|
||||
});
|
||||
|
||||
const rawPhases = buildStripePhasesUpdate({
|
||||
ctx,
|
||||
billingContext,
|
||||
customerProducts: relatedCusProducts,
|
||||
});
|
||||
|
||||
// 2. Classify into scenario
|
||||
const { scenario, scheduledPhases, cancelAtSeconds } = classifyPhaseScenario({
|
||||
rawPhases,
|
||||
});
|
||||
|
||||
if (debug) {
|
||||
console.log(`Scenario: ${scenario}`);
|
||||
console.log(`Scheduled phases: ${scheduledPhases.length}`);
|
||||
console.log(
|
||||
`Cancel at: ${cancelAtSeconds ? formatUnixToDateTime(cancelAtSeconds * 1000) : "none"}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Fetch the actual Stripe subscription
|
||||
const sub = await ctx.stripeCli.subscriptions.retrieve(subId, {
|
||||
expand: ["discounts.coupon"],
|
||||
});
|
||||
|
||||
// 4. Compare current subscription items against first phase items
|
||||
const firstPhase = scheduledPhases[0];
|
||||
if (firstPhase) {
|
||||
const expectedItems = (firstPhase.items ?? []).map((item) =>
|
||||
normalizeExpectedPhaseItem({ item }),
|
||||
);
|
||||
const actualItems = sub.items.data.map((item) =>
|
||||
normalizeActualSubItem({ item }),
|
||||
);
|
||||
|
||||
compareItems({
|
||||
expectedItems,
|
||||
actualItems,
|
||||
label: `sub:${subId}`,
|
||||
debug,
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Validate cancel / schedule state based on scenario
|
||||
await validateSubState({
|
||||
ctx,
|
||||
sub,
|
||||
scenario,
|
||||
cancelAtSeconds,
|
||||
shouldBeCanceled: options?.shouldBeCanceled,
|
||||
debug,
|
||||
});
|
||||
|
||||
// 6. Validate schedule phases if multi_phase
|
||||
if (scenario === "multi_phase") {
|
||||
await validateSchedulePhases({
|
||||
ctx,
|
||||
sub,
|
||||
scheduledPhases,
|
||||
debug,
|
||||
});
|
||||
}
|
||||
|
||||
// 7. Validate status override
|
||||
if (options?.status) {
|
||||
expect(sub.status).toBe(options.status);
|
||||
}
|
||||
|
||||
// 8. Validate rewards/discounts
|
||||
if (options?.rewards) {
|
||||
validateRewards({ sub, rewards: options.rewards });
|
||||
}
|
||||
};
|
||||
@@ -9,7 +9,7 @@ export const USE_KERNEL = !!process.env.USE_KERNEL_BROWSER;
|
||||
// export const USE_KERNEL = false;
|
||||
|
||||
/** Run browsers in headless mode (set false to watch the browser) */
|
||||
export const HEADLESS = true;
|
||||
export const HEADLESS = false;
|
||||
|
||||
/** Path to local Chromium/Chrome executable (auto-detected if not set in env) */
|
||||
export const CHROMIUM_PATH =
|
||||
|
||||
@@ -1,11 +1,29 @@
|
||||
import type { FullCusEntWithFullCusProduct } from "@models/cusProductModels/cusEntModels/cusEntWithProduct";
|
||||
import type { EntitlementWithFeature } from "@models/productModels/entModels/entModels";
|
||||
import type Stripe from "stripe";
|
||||
import type { Price } from "../../productModels/priceModels/priceModels";
|
||||
import type { FullProduct } from "../../productModels/productModels";
|
||||
|
||||
/**
|
||||
* Inline Stripe price data for entity-scoped items.
|
||||
* Pre-calculated flat amount (not tiered) — Stripe doesn't support tiered price_data.
|
||||
*/
|
||||
export type StripeInlinePrice = {
|
||||
product: string;
|
||||
currency: string;
|
||||
recurring: Stripe.PriceCreateParams.Recurring;
|
||||
unit_amount_decimal: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Intermediate type bridging Autumn price model to Stripe line items.
|
||||
* Either `stripePriceId` (stored price) or `stripeInlinePrice` (entity-scoped inline) must be set.
|
||||
*/
|
||||
export type StripeItemSpec = {
|
||||
stripePriceId: string; // stripe price ID
|
||||
stripePriceId?: string;
|
||||
stripeInlinePrice?: StripeInlinePrice;
|
||||
quantity?: number;
|
||||
metadata?: Record<string, string>;
|
||||
autumnPrice?: Price;
|
||||
autumnEntitlement?: EntitlementWithFeature;
|
||||
autumnProduct?: FullProduct;
|
||||
|
||||
@@ -11,9 +11,11 @@ import { cusProductToFeatureOptions } from "../../cusProductUtils/convertCusProd
|
||||
export const cusEntToPrepaidQuantity = ({
|
||||
cusEnt,
|
||||
sumAcrossEntities = false,
|
||||
useUpcomingQuantity = false,
|
||||
}: {
|
||||
cusEnt: FullCusEntWithFullCusProduct;
|
||||
sumAcrossEntities?: boolean;
|
||||
useUpcomingQuantity?: boolean;
|
||||
}) => {
|
||||
// 2. If cus ent is not prepaid, skip
|
||||
const cusPrice = cusEntToCusPrice({ cusEnt });
|
||||
@@ -30,7 +32,11 @@ export const cusEntToPrepaidQuantity = ({
|
||||
|
||||
if (!options) return 0;
|
||||
|
||||
const quantityWithUnits = new Decimal(options.quantity)
|
||||
const quantity = useUpcomingQuantity
|
||||
? (options.upcoming_quantity ?? options.quantity ?? 0)
|
||||
: (options.quantity ?? 0);
|
||||
|
||||
const quantityWithUnits = new Decimal(quantity)
|
||||
.mul(cusPrice.price.config.billing_units ?? 1)
|
||||
.toNumber();
|
||||
|
||||
@@ -46,13 +52,19 @@ export const cusEntToPrepaidQuantity = ({
|
||||
export const cusEntsToPrepaidQuantity = ({
|
||||
cusEnts,
|
||||
sumAcrossEntities = false,
|
||||
useUpcomingQuantity = false,
|
||||
}: {
|
||||
cusEnts: FullCusEntWithFullCusProduct[];
|
||||
sumAcrossEntities?: boolean;
|
||||
useUpcomingQuantity?: boolean;
|
||||
}) => {
|
||||
return sumValues(
|
||||
cusEnts.map((cusEnt) =>
|
||||
cusEntToPrepaidQuantity({ cusEnt, sumAcrossEntities }),
|
||||
cusEntToPrepaidQuantity({
|
||||
cusEnt,
|
||||
sumAcrossEntities,
|
||||
useUpcomingQuantity,
|
||||
}),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { FullCusEntWithFullCusProduct } from "@models/cusProductModels/cusEntModels/cusEntWithProduct";
|
||||
import type { FullCustomerPrice } from "@models/cusProductModels/cusPriceModels/cusPriceModels";
|
||||
import type {
|
||||
FeatureOptions,
|
||||
FullCusProduct,
|
||||
} from "@models/cusProductModels/cusProductModels";
|
||||
import type { EntitlementWithFeature } from "@models/productModels/entModels/entModels";
|
||||
import type { Price } from "@models/productModels/priceModels/priceModels";
|
||||
import type { FullProduct } from "@models/productModels/productModels";
|
||||
import { cusProductToProduct } from "@utils/cusProductUtils/convertCusProduct";
|
||||
import { cusEntToCusPrice } from "./cusEntToCusPrice";
|
||||
import { customerEntitlementToOptions } from "./customerEntitlementToOptions";
|
||||
|
||||
export type CusEntBillingObjects = {
|
||||
cusProduct: FullCusProduct;
|
||||
cusPrice: FullCustomerPrice;
|
||||
price: Price;
|
||||
product: FullProduct;
|
||||
entitlement: EntitlementWithFeature;
|
||||
options: FeatureOptions | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts the core billing objects from a FullCusEntWithFullCusProduct.
|
||||
* Returns null if cusProduct or cusPrice can't be resolved.
|
||||
*/
|
||||
export const cusEntToBillingObjects = ({
|
||||
cusEnt,
|
||||
}: {
|
||||
cusEnt: FullCusEntWithFullCusProduct;
|
||||
}): CusEntBillingObjects | null => {
|
||||
const cusProduct = cusEnt.customer_product;
|
||||
if (!cusProduct) return null;
|
||||
|
||||
const cusPrice = cusEntToCusPrice({ cusEnt });
|
||||
if (!cusPrice) return null;
|
||||
|
||||
const price = cusPrice.price;
|
||||
const product = cusProductToProduct({ cusProduct });
|
||||
const entitlement = cusEnt.entitlement;
|
||||
const options = customerEntitlementToOptions({ customerEntitlement: cusEnt });
|
||||
|
||||
return { cusProduct, cusPrice, price, product, entitlement, options };
|
||||
};
|
||||
@@ -1,4 +1,7 @@
|
||||
// Balance utils
|
||||
|
||||
// Balance utils barrel
|
||||
export * from "./balanceUtils";
|
||||
export * from "./balanceUtils/cusEntsToBalance";
|
||||
export * from "./balanceUtils/cusEntsToCurrentBalance";
|
||||
export * from "./balanceUtils/cusEntsToPrepaidQuantity";
|
||||
@@ -19,22 +22,19 @@ export * from "./balanceUtils/rollovers/cusEntsToRolloverGranted";
|
||||
export * from "./balanceUtils/rollovers/cusEntsToRolloverUsage";
|
||||
export * from "./balanceUtils/rollovers/cusEntsToRolloverUsage";
|
||||
|
||||
// Balance utils barrel
|
||||
export * from "./balanceUtils";
|
||||
|
||||
// Classify utils
|
||||
export * from "./classifyCusEntUtils";
|
||||
|
||||
// Convert utils barrel
|
||||
export * from "./convertCusEntUtils";
|
||||
// Convert utils
|
||||
export * from "./convertCusEntUtils/cusEntsToMaxPurchase";
|
||||
export * from "./convertCusEntUtils/cusEntsToStartingBalance";
|
||||
export * from "./convertCusEntUtils/cusEntToBillingObjects";
|
||||
export * from "./convertCusEntUtils/cusEntToCusPrice";
|
||||
export * from "./convertCusEntUtils/cusEntToKey";
|
||||
export * from "./convertCusEntUtils/cusEntToStripeIds";
|
||||
// Convert utils barrel
|
||||
export * from "./convertCusEntUtils/customerEntitlementToOptions";
|
||||
// Convert utils barrel
|
||||
export * from "./convertCusEntUtils";
|
||||
// Core utils
|
||||
export * from "./cusEntUtils";
|
||||
export * from "./filterCusEntUtils";
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { deduplicateArray, type FullCusProduct } from "../../..";
|
||||
|
||||
export const customerProductsToStripeSubscriptionIds = ({
|
||||
customerProducts,
|
||||
}: {
|
||||
customerProducts: FullCusProduct[];
|
||||
}) => {
|
||||
return deduplicateArray(
|
||||
customerProducts.flatMap((cp) => cp.subscription_ids),
|
||||
);
|
||||
};
|
||||
@@ -2,9 +2,10 @@ import { customerProductToFeaturesToCarryUsagesFor } from "@utils/cusProductUtil
|
||||
|
||||
export * from "./classifyCustomerProduct/classifyCustomerProduct";
|
||||
export * from "./classifyCustomerProduct/cpBuilder";
|
||||
export * from "./convertCusProduct";
|
||||
export * from "./convertCusProduct/cusProductToConvertedFeatureOptions";
|
||||
export * from "./convertCusProduct/cusProductToFeatureOptions";
|
||||
export * from "./convertCusProduct";
|
||||
export * from "./convertCusProduct/customerProductsToStripeSubscriptionIds";
|
||||
export * from "./cusProductConstants";
|
||||
export * from "./cusProductUtils";
|
||||
export * from "./featureOptionUtils/findFeatureOptions";
|
||||
|
||||
Reference in New Issue
Block a user