prepaid tiered entities create inline prices

This commit is contained in:
John Yeo
2026-02-27 16:23:31 +00:00
parent 0011fb12b7
commit fd0ac798ad
52 changed files with 3346 additions and 422 deletions

View File

@@ -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). 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). 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 ## 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. - After writing or editing test files, ALWAYS run `bun ts` in the `server/` directory to check for type errors before considering the task done.

View File

@@ -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 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. - **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 - **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:** **DON'T:**
- Use plain `test()` - **ALWAYS use `test.concurrent()`** - Use plain `test()` - **ALWAYS use `test.concurrent()`**

View File

@@ -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 { expectCustomerProducts, expectProductActive, expectProductCanceling, expectProductScheduled, expectProductNotPresent } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { expectProductTrialing, expectProductNotTrialing } from "@tests/integration/billing/utils/expectCustomerProductTrialing"; import { expectProductTrialing, expectProductNotTrialing } from "@tests/integration/billing/utils/expectCustomerProductTrialing";
import { expectPreviewNextCycleCorrect } from "@tests/integration/billing/utils/expectPreviewNextCycleCorrect"; import { expectPreviewNextCycleCorrect } from "@tests/integration/billing/utils/expectPreviewNextCycleCorrect";
import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect";
import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect"; import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect";
import { expectProductAttached, expectScheduledApiSub } from "@tests/utils/expectUtils/expectProductAttached"; import { expectProductAttached, expectScheduledApiSub } from "@tests/utils/expectUtils/expectProductAttached";
``` ```
@@ -274,7 +275,56 @@ expectPreviewNextCycleCorrect({
This ensures the Stripe subscription state matches Autumn's internal state. 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.** Deep verification of subscription state in database. **Use for paid products.**
@@ -315,11 +365,11 @@ await expectNoStripeSubscription({
| Scenario | Utility | | Scenario | Utility |
|----------|---------| |----------|---------|
| Attached paid product | `expectSubToBeCorrect` | | New test with paid product | `expectStripeSubscriptionCorrect` |
| Attached free product | `expectNoStripeSubscription` | | New test with entity-scoped inline prices | `expectStripeSubscriptionCorrect` |
| Upgraded free → paid | `expectSubToBeCorrect` | | Existing test (don't change unless updating) | `expectSubToBeCorrect` |
| Downgraded paid → free (after cycle) | `expectNoStripeSubscription` | | Free product / downgrade to free | `expectNoStripeSubscription` |
| Scheduled downgrade (before cycle) | `expectSubToBeCorrect` (sub still exists until cycle end) | | Scheduled downgrade (before cycle) | `expectStripeSubscriptionCorrect` (validates schedule phases) |
## Complete Example ## Complete Example
@@ -399,14 +449,8 @@ test.concurrent(`${chalk.yellowBright("trial: full lifecycle")}`, async () => {
latestTotal: 20, latestTotal: 20,
}); });
// Verify subscription state in DB // Verify Stripe subscription matches expected state
await expectSubToBeCorrect({ await expectStripeSubscriptionCorrect({ ctx, customerId });
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
flags: { checkNotTrialing: true },
});
}); });
``` ```

View File

@@ -3,6 +3,7 @@ import {
type DeferredAutumnBillingPlanData, type DeferredAutumnBillingPlanData,
featureOptionUtils, featureOptionUtils,
getStartingBalance, getStartingBalance,
isCustomerProductEntityScoped,
priceUtils, priceUtils,
} from "@autumn/shared"; } from "@autumn/shared";
import { stripeCheckoutSessionUtils } from "@/external/stripe/checkoutSessions/utils"; import { stripeCheckoutSessionUtils } from "@/external/stripe/checkoutSessions/utils";
@@ -36,6 +37,12 @@ export const updateOptionsFromStripeCheckoutSession = async ({
if (!price || priceUtils.isTieredOneOff({ price, product: fullProduct })) if (!price || priceUtils.isTieredOneOff({ price, product: fullProduct }))
continue; 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 = const featureOptionsQuantity =
stripeCheckoutSessionUtils.convert.toFeatureOptionsQuantity({ stripeCheckoutSessionUtils.convert.toFeatureOptionsQuantity({
stripeCheckoutSession, stripeCheckoutSession,

View File

@@ -1,3 +1,4 @@
import type { BillingContext } from "@autumn/shared";
import { import {
type BillingPeriod, type BillingPeriod,
cloneEntitlementWithUpdatedQuantity, cloneEntitlementWithUpdatedQuantity,
@@ -14,7 +15,6 @@ import {
usagePriceToLineItem, usagePriceToLineItem,
} from "@autumn/shared"; } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv"; import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { BillingContext } from "@autumn/shared";
export const computeUpdateQuantityLineItems = ({ export const computeUpdateQuantityLineItems = ({
ctx, ctx,

View File

@@ -112,6 +112,11 @@ export const executeStripeBillingPlan = async ({
stripeSubscriptionScheduleId: stripeSubscriptionSchedule.id, stripeSubscriptionScheduleId: stripeSubscriptionSchedule.id,
}); });
} }
console.log(
"STRIPE SUBSCRIPTION SCHEDULE: ",
JSON.stringify(stripeSubscriptionSchedule, null, 2),
);
} }
const stripeInvoice = const stripeInvoice =

View File

@@ -18,6 +18,7 @@ const toCreatePhase = (
items: phase.items?.map((item) => ({ items: phase.items?.map((item) => ({
price: item.price, price: item.price,
quantity: item.quantity, quantity: item.quantity,
...(item.metadata && { metadata: item.metadata }),
})), })),
end_date: typeof phase.end_date === "number" ? phase.end_date : undefined, end_date: typeof phase.end_date === "number" ? phase.end_date : undefined,
discounts: phase.discounts as discounts: phase.discounts as
@@ -29,13 +30,18 @@ const toCreatePhase = (
* Builds phases for updating a schedule that was created from a subscription. * 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. * 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 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 = ({ const buildAnchoredPhases = ({
params, params,
existingSchedule, existingSchedule,
stripeSubscription,
}: { }: {
params: { phases?: Stripe.SubscriptionScheduleUpdateParams.Phase[] }; params: { phases?: Stripe.SubscriptionScheduleUpdateParams.Phase[] };
existingSchedule: Stripe.SubscriptionSchedule; existingSchedule: Stripe.SubscriptionSchedule;
stripeSubscription?: Stripe.Subscription;
}): Stripe.SubscriptionScheduleUpdateParams.Phase[] => { }): Stripe.SubscriptionScheduleUpdateParams.Phase[] => {
const inputPhases = params.phases ?? []; const inputPhases = params.phases ?? [];
if (inputPhases.length === 0) return []; if (inputPhases.length === 0) return [];
@@ -45,12 +51,39 @@ const buildAnchoredPhases = ({
throw new Error("Cannot update schedule: missing current phase start_date"); 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) // 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"] = const existingFirstPhaseItems: Stripe.SubscriptionScheduleUpdateParams.Phase["items"] =
existingSchedule.phases[0]?.items.map((item) => ({ existingSchedule.phases[0]?.items.map((item) => {
price: typeof item.price === "string" ? item.price : item.price?.id, const priceId =
quantity: item.quantity ?? undefined, 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 // First phase: preserve start_date AND items from existing schedule
// Stripe doesn't allow modifying items in an active/in-progress phase // Stripe doesn't allow modifying items in an active/in-progress phase
@@ -74,16 +107,22 @@ const createScheduleFromSubscription = async ({
stripeCli, stripeCli,
subscriptionId, subscriptionId,
params, params,
stripeSubscription,
}: { }: {
stripeCli: Stripe; stripeCli: Stripe;
subscriptionId: string; subscriptionId: string;
params: Stripe.SubscriptionScheduleUpdateParams; params: Stripe.SubscriptionScheduleUpdateParams;
stripeSubscription?: Stripe.Subscription;
}): Promise<Stripe.SubscriptionSchedule> => { }): Promise<Stripe.SubscriptionSchedule> => {
const schedule = await stripeCli.subscriptionSchedules.create({ const schedule = await stripeCli.subscriptionSchedules.create({
from_subscription: subscriptionId, from_subscription: subscriptionId,
}); });
const phases = buildAnchoredPhases({ params, existingSchedule: schedule }); const phases = buildAnchoredPhases({
params,
existingSchedule: schedule,
stripeSubscription,
});
return await stripeCli.subscriptionSchedules.update(schedule.id, { return await stripeCli.subscriptionSchedules.update(schedule.id, {
phases, phases,
@@ -125,6 +164,7 @@ export const executeStripeSubscriptionScheduleAction = async ({
stripeCli, stripeCli,
subscriptionId: stripeSubscription.id, subscriptionId: stripeSubscription.id,
params, params,
stripeSubscription,
}); });
} }
@@ -162,6 +202,7 @@ export const executeStripeSubscriptionScheduleAction = async ({
? subscriptionId ? subscriptionId
: subscriptionId.id, : subscriptionId.id,
params, params,
stripeSubscription,
}); });
// Update existing customer products with the new schedule ID // Update existing customer products with the new schedule ID

View File

@@ -10,6 +10,7 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { customerProductsToOneOffStripeItemSpecs } from "@/internal/billing/v2/providers/stripe/utils/stripeItemSpec/customerProductsToOneOffStripeItemSpecs"; import { customerProductsToOneOffStripeItemSpecs } from "@/internal/billing/v2/providers/stripe/utils/stripeItemSpec/customerProductsToOneOffStripeItemSpecs";
import { customerProductsToRecurringStripeItemSpecs } from "@/internal/billing/v2/providers/stripe/utils/stripeItemSpec/customerProductsToRecurringStripeItemSpecs"; import { customerProductsToRecurringStripeItemSpecs } from "@/internal/billing/v2/providers/stripe/utils/stripeItemSpec/customerProductsToRecurringStripeItemSpecs";
import { filterStripeItemSpecsByLargestInterval } from "@/internal/billing/v2/providers/stripe/utils/stripeItemSpec/filterStripeItemSpecsByLargestInterval"; 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"; import { updateOneOffTieredItems } from "./updateOneOffTieredItems";
export const buildStripeCheckoutSessionItems = ({ export const buildStripeCheckoutSessionItems = ({
@@ -50,7 +51,8 @@ export const buildStripeCheckoutSessionItems = ({
// 5. Convert recurring item specs to line items // 5. Convert recurring item specs to line items
const recurringLineItems = recurringStripeItemSpecs.map((item) => { 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 it's a prepaid price, allow adjustable quantity
if (autumnPrice && autumnEntitlement && isPrepaidPrice(autumnPrice)) { if (autumnPrice && autumnEntitlement && isPrepaidPrice(autumnPrice)) {
@@ -60,8 +62,7 @@ export const buildStripeCheckoutSessionItems = ({
); );
return { return {
price: stripePriceId, ...lineItem,
quantity: quantity ?? 0,
adjustable_quantity: isAdjustable adjustable_quantity: isAdjustable
? { ? {
enabled: true, enabled: true,
@@ -75,11 +76,7 @@ export const buildStripeCheckoutSessionItems = ({
} as Stripe.Checkout.SessionCreateParams.LineItem; } as Stripe.Checkout.SessionCreateParams.LineItem;
} }
// Fixed price return lineItem;
return {
price: stripePriceId,
quantity: quantity ?? 0,
};
}); });
// 6. Convert one-off item specs to line items (handles tiered one-off prices) // 6. Convert one-off item specs to line items (handles tiered one-off prices)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 { 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"; import type { AutumnContext } from "@/honoUtils/HonoEnv";
/** /**
* Convert customer products to recurring stripe item specs. * Converts customer products to recurring stripe item specs.
* For metered prices (quantity undefined), we preserve undefined as Stripe requires. * Deduplicates stored-price items by stripePriceId (accumulating quantities).
* @param ctx - The context * Entity-scoped inline items are never deduplicated — each entity gets its own item.
* @param billingContext - The billing context
* @param customerProducts - The customer products
* @returns The recurring stripe item specs
*/ */
export const customerProductsToRecurringStripeItemSpecs = ({ export const customerProductsToRecurringStripeItemSpecs = ({
ctx, ctx,
@@ -20,7 +20,8 @@ export const customerProductsToRecurringStripeItemSpecs = ({
billingContext: BillingContext; billingContext: BillingContext;
customerProducts: FullCusProduct[]; customerProducts: FullCusProduct[];
}): StripeItemSpec[] => { }): StripeItemSpec[] => {
const stripeItemSpecsByPriceId = new Map<string, StripeItemSpec>(); const storedPriceSpecs = new Map<string, StripeItemSpec>();
const inlineSpecs: StripeItemSpec[] = [];
for (const customerProduct of customerProducts) { for (const customerProduct of customerProducts) {
const { recurringItems } = customerProductToStripeItemSpecs({ const { recurringItems } = customerProductToStripeItemSpecs({
@@ -29,31 +30,29 @@ export const customerProductsToRecurringStripeItemSpecs = ({
customerProduct, customerProduct,
}); });
for (const recurringItem of recurringItems) { for (const item of recurringItems) {
const existingItem = stripeItemSpecsByPriceId.get( // Entity-scoped inline items are never deduplicated
recurringItem.stripePriceId, if (item.stripeInlinePrice) {
); inlineSpecs.push(item);
continue;
}
if (existingItem) { const priceId = item.stripePriceId!;
// For metered prices, quantity is undefined and should stay undefined const existing = storedPriceSpecs.get(priceId);
if (
recurringItem.quantity === undefined && if (existing) {
existingItem.quantity === undefined // Metered prices: quantity is undefined, keep undefined
) { if (item.quantity === undefined && existing.quantity === undefined) {
// Both metered - keep undefined // Both metered keep as-is
} else { } else {
// Licensed prices - accumulate quantity // Licensed prices accumulate quantity
existingItem.quantity = existing.quantity = (existing.quantity ?? 0) + (item.quantity ?? 0);
(existingItem.quantity ?? 0) + (recurringItem.quantity ?? 0);
} }
} else { } else {
stripeItemSpecsByPriceId.set( storedPriceSpecs.set(priceId, item);
recurringItem.stripePriceId,
recurringItem,
);
} }
} }
} }
return Array.from(stripeItemSpecsByPriceId.values()); return [...Array.from(storedPriceSpecs.values()), ...inlineSpecs];
}; };

View File

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

View File

@@ -9,14 +9,12 @@ import { stripeSubscriptionItemToStripePriceId } from "@/external/stripe/subscri
import { findStripeSubscriptionItemByStripePriceId } from "@/external/stripe/subscriptions/subscriptionItems/utils/findStripeSubscriptionItemUtils"; import { findStripeSubscriptionItemByStripePriceId } from "@/external/stripe/subscriptions/subscriptionItems/utils/findStripeSubscriptionItemUtils";
import type { AutumnContext } from "@/honoUtils/HonoEnv"; import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { customerProductsToRecurringStripeItemSpecs } from "@/internal/billing/v2/providers/stripe/utils/stripeItemSpec/customerProductsToRecurringStripeItemSpecs"; 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"; import { findStripeItemSpecByStripePriceId } from "./findStripeItemSpec";
/** /**
* Convert stripe item specs to stripe subscription update params items. * Diffs desired stripe item specs against current subscription items.
* For metered prices (quantity undefined), we don't include quantity as Stripe requires. * Handles both stored-price and entity-scoped inline-price items.
* @param billingContext - The billing context
* @param stripeItemSpecs - The stripe item specs
* @returns The subscription item update params
*/ */
const stripeItemSpecsToSubItemsUpdate = ({ const stripeItemSpecsToSubItemsUpdate = ({
billingContext, billingContext,
@@ -29,48 +27,51 @@ const stripeItemSpecsToSubItemsUpdate = ({
const currentSubscriptionItems = stripeSubscription?.items.data ?? []; const currentSubscriptionItems = stripeSubscription?.items.data ?? [];
const subItemsUpdate: Stripe.SubscriptionUpdateParams.Item[] = []; 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({ const existingItem = findStripeSubscriptionItemByStripePriceId({
stripePriceId: stripeItemSpec.stripePriceId, stripePriceId: spec.stripePriceId,
stripeSubscriptionItems: currentSubscriptionItems, stripeSubscriptionItems: currentSubscriptionItems,
}); });
const shouldUpdateItem = const shouldUpdateItem =
existingItem && existingItem.quantity !== stripeItemSpec.quantity; existingItem && existingItem.quantity !== spec.quantity;
const shouldCreateItem = !existingItem; const shouldCreateItem = !existingItem;
if (shouldUpdateItem) { if (shouldUpdateItem) {
// For metered prices, don't include quantity subItemsUpdate.push({
if (stripeItemSpec.quantity === undefined) { id: existingItem.id,
subItemsUpdate.push({ id: existingItem.id }); ...(spec.quantity !== undefined && { quantity: spec.quantity }),
} else { ...(spec.metadata && { metadata: spec.metadata }),
subItemsUpdate.push({ });
id: existingItem.id,
quantity: stripeItemSpec.quantity,
});
}
} }
if (shouldCreateItem) { if (shouldCreateItem) {
// For metered prices, don't include quantity subItemsUpdate.push({
if (stripeItemSpec.quantity === undefined) { price: spec.stripePriceId,
subItemsUpdate.push({ price: stripeItemSpec.stripePriceId }); ...(spec.quantity !== undefined && { quantity: spec.quantity }),
} else { ...(spec.metadata && { metadata: spec.metadata }),
subItemsUpdate.push({ });
price: stripeItemSpec.stripePriceId,
quantity: stripeItemSpec.quantity,
});
}
} }
} }
// Remove subscription items that are no longer in the desired specs
for (const subItem of currentSubscriptionItems) { for (const subItem of currentSubscriptionItems) {
const stripeItemSpec = findStripeItemSpecByStripePriceId({ const matchingSpec = findStripeItemSpecByStripePriceId({
stripePriceId: stripeSubscriptionItemToStripePriceId(subItem), stripePriceId: stripeSubscriptionItemToStripePriceId(subItem),
stripeItemSpecs, stripeItemSpecs,
}); });
const shouldRemoveItem = !stripeItemSpec; if (!matchingSpec) {
if (shouldRemoveItem) {
subItemsUpdate.push({ id: subItem.id, deleted: true }); subItemsUpdate.push({ id: subItem.id, deleted: true });
} }
} }
@@ -98,14 +99,14 @@ export const buildStripeSubscriptionItemsUpdate = ({
customerProducts: relatedCustomerProducts, 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({ const recurringStripeItemSpecs = customerProductsToRecurringStripeItemSpecs({
ctx, ctx,
billingContext, billingContext,
customerProducts: activeCustomerProducts, customerProducts: activeCustomerProducts,
}); });
// 5. Diff it with the current subscription items // 4. Diff against current subscription items
return stripeItemSpecsToSubItemsUpdate({ return stripeItemSpecsToSubItemsUpdate({
billingContext, billingContext,
stripeItemSpecs: recurringStripeItemSpecs, stripeItemSpecs: recurringStripeItemSpecs,

View File

@@ -1,30 +1,15 @@
import type { BillingContext } from "@autumn/shared";
import { import {
addCusProductToCusEnt, type BillingContext,
BillingVersion,
cusPriceToCusEnt,
cusProductToProduct,
entToOptions,
type FeatureOptions,
type FullCusProduct, type FullCusProduct,
formatPrice,
InternalError,
isAllocatedCustomerEntitlement,
isOneOffPrice, isOneOffPrice,
priceUtils,
type StripeItemSpec, type StripeItemSpec,
} from "@autumn/shared"; } 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 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 * Converts a customer product to stripe item specs (recurring + one-off).
* A stripe item spec is an internal intermediate type containing the stripe price id and quantity of the item. * Delegates each cusPrice to cusPriceToStripeItemSpec.
* @param ctx - The context
* @param customerProduct - The customer product
* @param billingContext - The billing context
* @returns The stripe item specs
*/ */
export const customerProductToStripeItemSpecs = ({ export const customerProductToStripeItemSpecs = ({
ctx, ctx,
@@ -38,86 +23,23 @@ export const customerProductToStripeItemSpecs = ({
recurringItems: StripeItemSpec[]; recurringItems: StripeItemSpec[];
oneOffItems: 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 recurringItems: StripeItemSpec[] = [];
const oneOffItems: StripeItemSpec[] = []; const oneOffItems: StripeItemSpec[] = [];
for (const cusPrice of cusPrices) { for (const cusPrice of customerProduct.customer_prices) {
const price = cusPrice.price; const spec = cusPriceToStripeItemSpec({
const cusEnt = cusPriceToCusEnt({ cusPrice, cusEnts }); ctx,
const ent = cusEnt?.entitlement; cusPrice,
cusProduct: customerProduct,
let options: FeatureOptions | undefined; billingContext,
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,
}); });
if (!stripeItem) continue; if (!spec) continue;
const { lineItem } = stripeItem; if (isOneOffPrice(cusPrice.price)) {
oneOffItems.push(spec);
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,
});
} else { } else {
recurringItems.push({ recurringItems.push(spec);
stripePriceId: lineItem.price ?? "",
quantity: lineItem?.quantity,
autumnPrice: price,
autumnEntitlement: ent,
autumnProduct: product,
autumnCusEnt: cusEntWithCusProduct,
});
} }
} }

View File

@@ -7,6 +7,7 @@ import {
import type Stripe from "stripe"; import type Stripe from "stripe";
import { logPhase } from "@/external/stripe/subscriptionSchedules/utils/logStripeSchedulePhaseUtils"; import { logPhase } from "@/external/stripe/subscriptionSchedules/utils/logStripeSchedulePhaseUtils";
import type { AutumnContext } from "@/honoUtils/HonoEnv"; 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 { customerProductToStripeItemSpecs } from "@/internal/billing/v2/providers/stripe/utils/subscriptionItems/customerProductToStripeItemSpecs";
import { isCustomerProductActiveDuringPeriod } from "@/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/isCustomerProductActiveAtEpochMs"; import { isCustomerProductActiveDuringPeriod } from "@/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/isCustomerProductActiveAtEpochMs";
import { buildTransitionPoints } from "./buildTransitionPoints"; import { buildTransitionPoints } from "./buildTransitionPoints";
@@ -28,8 +29,8 @@ const normalizeCustomerProductTimestamps = (
/** /**
* Converts customer products to Stripe schedule phase items. * Converts customer products to Stripe schedule phase items.
* Merges quantities for duplicate price IDs. * Merges quantities for duplicate stored price IDs.
* For metered prices (quantity undefined), we don't set quantity as Stripe requires. * Entity-scoped inline items are kept separate (never merged).
*/ */
const customerProductsToPhaseItems = ({ const customerProductsToPhaseItems = ({
ctx, ctx,
@@ -40,8 +41,8 @@ const customerProductsToPhaseItems = ({
billingContext: BillingContext; billingContext: BillingContext;
customerProducts: FullCusProduct[]; customerProducts: FullCusProduct[];
}): Stripe.SubscriptionScheduleUpdateParams.Phase.Item[] => { }): Stripe.SubscriptionScheduleUpdateParams.Phase.Item[] => {
// Track stripePriceId -> quantity (undefined means metered/no quantity) const storedPriceMap = new Map<string, number | undefined>();
const itemMap = new Map<string, number | undefined>(); const inlineItems: Stripe.SubscriptionScheduleUpdateParams.Phase.Item[] = [];
for (const customerProduct of customerProducts) { for (const customerProduct of customerProducts) {
const { recurringItems } = customerProductToStripeItemSpecs({ const { recurringItems } = customerProductToStripeItemSpecs({
@@ -51,26 +52,34 @@ const customerProductsToPhaseItems = ({
}); });
for (const item of recurringItems) { 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) { if (item.quantity === undefined) {
// Metered price - don't set quantity if (!storedPriceMap.has(priceId)) {
if (!itemMap.has(item.stripePriceId)) { storedPriceMap.set(priceId, undefined);
itemMap.set(item.stripePriceId, undefined);
} }
} else { } else {
// Licensed price - accumulate quantity const current = storedPriceMap.get(priceId) ?? 0;
const currentQuantity = itemMap.get(item.stripePriceId) ?? 0; storedPriceMap.set(priceId, current + item.quantity);
itemMap.set(item.stripePriceId, currentQuantity + item.quantity);
} }
} }
} }
return Array.from(itemMap.entries()).map(([price, quantity]) => { const storedItems = Array.from(storedPriceMap.entries()).map(
if (quantity === undefined) { ([price, quantity]) => {
return { price }; if (quantity === undefined) {
} return { price };
return { price, quantity }; }
}); return { price, quantity };
},
);
return [...storedItems, ...inlineItems];
}; };
/** /**

View File

@@ -28,8 +28,11 @@ export const buildStripeSubscriptionCreateAction = ({
const stripeSubscriptionCreateParams: Stripe.SubscriptionCreateParams = { const stripeSubscriptionCreateParams: Stripe.SubscriptionCreateParams = {
customer: stripeCustomer.id, customer: stripeCustomer.id,
items: subItemsUpdate.map((item) => ({ items: subItemsUpdate.map((item) => ({
price: item.price, ...(item.price_data
? { price_data: item.price_data }
: { price: item.price }),
quantity: item.quantity, quantity: item.quantity,
...(item.metadata && { metadata: item.metadata }),
})), })),
billing_mode: { type: "flexible" }, billing_mode: { type: "flexible" },

View File

@@ -78,10 +78,27 @@ export const subToNewSchedule = async ({
await stripeCli.subscriptionSchedules.update(newScheduleId, { await stripeCli.subscriptionSchedules.update(newScheduleId, {
phases: [ phases: [
{ {
items: newSchedule.phases[0].items.map((item) => ({ items: newSchedule.phases[0].items.map((item) => {
price: item.price as string, const priceId = item.price as string;
quantity: item.quantity,
})), // 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, start_date: newSchedule.phases[0].start_date,
end_date: endOfBillingPeriod, end_date: endOfBillingPeriod,
trial_end: sub?.trial_end || undefined, trial_end: sub?.trial_end || undefined,

View File

@@ -4,5 +4,11 @@ export const billingV2: TestGroup = {
name: "billing-v2", name: "billing-v2",
description: "V2 billing tests: migrations, attach, update-subscription", description: "V2 billing tests: migrations, attach, update-subscription",
tier: "domain", tier: "domain",
paths: ["migrations", "billing/attach", "billing/update-subscription"], paths: [
"migrations",
"billing/attach",
"billing/update-subscription",
"billing/multi-attach",
"billing/setup-payment",
],
}; };

View File

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

View File

@@ -15,11 +15,11 @@ import { updateBalance } from "./domains/balances/updateBalance";
import { billing } from "./domains/billing/billing"; import { billing } from "./domains/billing/billing";
import { billingV1 } from "./domains/billing/billingV1"; import { billingV1 } from "./domains/billing/billingV1";
import { billingV2 } from "./domains/billing/billingV2"; import { billingV2 } from "./domains/billing/billingV2";
import { prepaidVolume } from "./domains/billing/prepaidVolume";
import { crud } from "./domains/crud"; import { crud } from "./domains/crud";
import { misc } from "./domains/misc"; import { misc } from "./domains/misc";
import { webhooks } from "./domains/webhooks"; import { webhooks } from "./domains/webhooks";
import { suites } from "./suites"; import { suites } from "./suites";
import { temp } from "./temp";
import type { TestGroup, TestSuite } from "./types"; import type { TestGroup, TestSuite } from "./types";
export type { TestGroup, TestSuite, TestTier } from "./types"; export type { TestGroup, TestSuite, TestTier } from "./types";
@@ -39,7 +39,7 @@ const allGroups: TestGroup[] = [
billing, billing,
billingV1, billingV1,
billingV2, billingV2,
prepaidVolume, temp,
crud, crud,
webhooks, webhooks,
advanced, advanced,

View 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",
],
};

View File

@@ -1,46 +1,49 @@
import { test } from "bun:test"; import { test } from "bun:test";
import { import { TestFeature } from "@tests/setup/v2Features";
FreeTrialDuration,
type UpdateSubscriptionV1Params,
} from "@autumn/shared";
import { items } from "@tests/utils/fixtures/items"; import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products"; import { products } from "@tests/utils/fixtures/products";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk"; 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 () => { test.concurrent(`${chalk.yellowBright("temp: rest update then rpc inverse update returns product to baseline")}`, async () => {
const proProd = products.pro({ const customerId = "prepaid-ent-two-included";
id: "pro", const quantity1 = 300;
items: [items.monthlyCredits({ includedUsage: 100 })],
});
const customerId = "temp";
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, customerId,
actions: [],
setup: [ setup: [
s.customer({ paymentMethod: "success" }), s.customer({}),
s.products({ list: [proProd] }), 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, customer_id: customerId,
product_id: proProd.id, product_id: pro.id,
free_trial: { entity_id: entities[0].id,
length: 14, options: [{ feature_id: TestFeature.Messages, quantity: quantity1 }],
duration: FreeTrialDuration.Day, redirect_mode: "if_required",
},
}); });
console.log(result); console.log("attach1", attach1);
return;
const updateResult =
await autumnV2_1.subscriptions.update<UpdateSubscriptionV1Params>({
customer_id: customerId,
plan_id: proProd.id,
});
console.log(updateResult);
}); });

View File

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

View File

@@ -27,7 +27,7 @@ import {
expectCustomerProducts, expectCustomerProducts,
expectProductActive, expectProductActive,
} from "@tests/integration/billing/utils/expectCustomerProductCorrect"; } 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 { TestFeature } from "@tests/setup/v2Features";
import { items } from "@tests/utils/fixtures/items"; import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products"; import { products } from "@tests/utils/fixtures/products";
@@ -240,12 +240,7 @@ test.concurrent(`${chalk.yellowBright("attach-prepaid-volume-entities: graduated
count: 4, count: 4,
}); });
await expectSubToBeCorrect({ await expectStripeSubscriptionCorrect({ ctx, customerId });
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
}); });
// ═══════════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════════
@@ -426,10 +421,5 @@ test.concurrent(`${chalk.yellowBright("attach-prepaid-volume-entities: graduated
count: 4, count: 4,
}); });
await expectSubToBeCorrect({ await expectStripeSubscriptionCorrect({ ctx, customerId });
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
}); });

View File

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

View File

@@ -1,17 +1,18 @@
/** /**
* Attach Prepaid Volume vs Graduated — Entity-Level Initial Attach Test * Attach Prepaid Volume vs Graduated — Entity-Level Initial Attach Test
* *
* Two entities share one customer. One is on graduated pricing, the other on * Test 1: Two entities, one graduated, one volume, same tier structure.
* volume pricing, for the same tier structure. Confirms that the initial * 800 units, tier 2:
* invoice totals correctly reflect each pricing model independently. * 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): * Tiers (billingUnits = 100):
* Tier 1: 0500 units @ $10/pack * Tier 1: 0500 units @ $10/pack
* Tier 2: 501+ units @ $5/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"; 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 { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect";
import { TestFeature } from "@tests/setup/v2Features"; import { TestFeature } from "@tests/setup/v2Features";
import { items } from "@tests/utils/fixtures/items"; import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products"; 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 gradPro = products.pro({ id: "grad-pro-ent-800", items: [gradItem] });
const volPro = products.pro({ id: "vol-pro-ent-800", items: [volItem] }); const volPro = products.pro({ id: "vol-pro-ent-800", items: [volItem] });
const { autumnV1, entities } = await initScenario({ const { autumnV1, entities, ctx } = await initScenario({
customerId, customerId,
setup: [ setup: [
s.customer({ paymentMethod: "success" }), s.customer({ paymentMethod: "success" }),
@@ -152,4 +154,137 @@ test.concurrent(`${chalk.yellowBright("attach-prepaid-volume-entities: graduated
invoiceIndex: 1, invoiceIndex: 1,
latestTotal: BASE_PRICE + gradExpectedPrepaid, 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 });
}); });

View File

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

View File

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

View File

@@ -1,14 +1,13 @@
import { expect, test } from "bun:test"; 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 { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect.js";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect.js"; import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect.js";
import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect.js"; import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect.js";
import { expectLatestInvoiceCorrect } from "@tests/integration/billing/utils/expectLatestInvoiceCorrect.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 { TestFeature } from "@tests/setup/v2Features.js";
import { items } from "@tests/utils/fixtures/items.js"; import { items } from "@tests/utils/fixtures/items.js";
import { products } from "@tests/utils/fixtures/products.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 { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk"; import chalk from "chalk";
@@ -22,7 +21,6 @@ import chalk from "chalk";
* - Entity 1 increases quantity while Entity 2 remains unchanged * - Entity 1 increases quantity while Entity 2 remains unchanged
* - Entity 2 decreases quantity while Entity 1 remains unchanged * - Entity 2 decreases quantity while Entity 1 remains unchanged
* - Cross-entity mixed changes (increase one, decrease other) * - Cross-entity mixed changes (increase one, decrease other)
* - OnDecrease.None config (no credit invoice on decrease)
* - Different products per entity with quantity updates * - Different products per entity with quantity updates
* - Multiple features per entity * - Multiple features per entity
*/ */
@@ -48,11 +46,7 @@ test.concurrent(`${chalk.yellowBright("multi-entity-quantity: entity 1 increases
const initialQuantity2 = 5 * billingUnits; // 60 const initialQuantity2 = 5 * billingUnits; // 60
const newQuantity1 = 20 * billingUnits; // 240 const newQuantity1 = 20 * billingUnits; // 240
const { const { autumnV1, ctx, entities } = await initScenario({
autumnV1,
ctx: testContext,
entities,
} = await initScenario({
customerId, customerId,
setup: [ setup: [
s.customer({ paymentMethod: "success" }), s.customer({ paymentMethod: "success" }),
@@ -120,13 +114,11 @@ test.concurrent(`${chalk.yellowBright("multi-entity-quantity: entity 1 increases
amount: 10 * pricePerUnit, amount: 10 * pricePerUnit,
}); });
// Verify subscription count // Verify Stripe subscription matches expected state
await expectSubToBeCorrect({ await expectStripeSubscriptionCorrect({
db: testContext.db, ctx,
customerId, customerId,
org: testContext.org, options: { subCount: 1 },
env: testContext.env,
subCount: 1,
}); });
}); });
@@ -151,7 +143,7 @@ test.concurrent(`${chalk.yellowBright("multi-entity-quantity: entity 2 decreases
const initialQuantity2 = 15 * billingUnits; // 180 const initialQuantity2 = 15 * billingUnits; // 180
const newQuantity2 = 5 * billingUnits; // 60 const newQuantity2 = 5 * billingUnits; // 60
const { autumnV1, entities } = await initScenario({ const { autumnV1, entities, ctx } = await initScenario({
customerId, customerId,
setup: [ setup: [
s.customer({ paymentMethod: "success" }), s.customer({ paymentMethod: "success" }),
@@ -218,6 +210,8 @@ test.concurrent(`${chalk.yellowBright("multi-entity-quantity: entity 2 decreases
productId: product.id, productId: product.id,
amount: -10 * pricePerUnit, amount: -10 * pricePerUnit,
}); });
await expectStripeSubscriptionCorrect({ ctx, customerId });
}); });
// Test 3: Cross-Entity Mixed Changes // 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 newQuantity1 = 15 * billingUnits; // 150 (increase)
const newQuantity2 = 10 * billingUnits; // 100 (decrease) const newQuantity2 = 10 * billingUnits; // 100 (decrease)
const { autumnV1, entities } = await initScenario({ const { autumnV1, entities, ctx } = await initScenario({
customerId, customerId,
setup: [ setup: [
s.customer({ paymentMethod: "success" }), s.customer({ paymentMethod: "success" }),
@@ -321,119 +315,11 @@ test.concurrent(`${chalk.yellowBright("multi-entity-quantity: mixed changes acro
customer, customer,
count: 4, count: 4,
}); });
await expectStripeSubscriptionCorrect({ ctx, customerId });
}); });
// Test 4: OnDecrease.None Config - No Credit Invoice // Test 4: Different Products Per Entity (was Test 5)
// 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.concurrent(`${chalk.yellowBright("multi-entity-quantity: different products per entity")}`, async () => { test.concurrent(`${chalk.yellowBright("multi-entity-quantity: different products per entity")}`, async () => {
const customerId = "multi-ent-qty-diff-products"; const customerId = "multi-ent-qty-diff-products";
const billingUnits = 10; const billingUnits = 10;
@@ -464,7 +350,7 @@ test.concurrent(`${chalk.yellowBright("multi-entity-quantity: different products
const initialQuantityPro = 10 * billingUnits; // 100 const initialQuantityPro = 10 * billingUnits; // 100
const newQuantityPro = 15 * billingUnits; // 150 (increase) const newQuantityPro = 15 * billingUnits; // 150 (increase)
const { autumnV1, entities } = await initScenario({ const { autumnV1, entities, ctx } = await initScenario({
customerId, customerId,
setup: [ setup: [
s.customer({ paymentMethod: "success" }), s.customer({ paymentMethod: "success" }),
@@ -539,9 +425,11 @@ test.concurrent(`${chalk.yellowBright("multi-entity-quantity: different products
productId: proProduct.id, productId: proProduct.id,
amount: 5 * 8, 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 () => { test.concurrent(`${chalk.yellowBright("multi-entity-quantity: multiple features per entity")}`, async () => {
const customerId = "multi-ent-qty-multi-feat"; const customerId = "multi-ent-qty-multi-feat";
const messagesBillingUnits = 10; const messagesBillingUnits = 10;
@@ -566,7 +454,7 @@ test.concurrent(`${chalk.yellowBright("multi-entity-quantity: multiple features
items: [messagesItem, wordsItem], items: [messagesItem, wordsItem],
}); });
const { autumnV1, entities } = await initScenario({ const { autumnV1, entities, ctx } = await initScenario({
customerId, customerId,
setup: [ setup: [
s.customer({ paymentMethod: "success" }), s.customer({ paymentMethod: "success" }),
@@ -662,4 +550,6 @@ test.concurrent(`${chalk.yellowBright("multi-entity-quantity: multiple features
productId: product.id, productId: product.id,
amount: 5 * messagesPrice - 1 * wordsPrice, amount: 5 * messagesPrice - 1 * wordsPrice,
}); });
await expectStripeSubscriptionCorrect({ ctx, customerId });
}); });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
export {
classifyPhaseScenario,
type PhaseScenario,
} from "./classifyPhaseScenario";
export { expectStripeSubscriptionCorrect } from "./expectStripeSubscriptionCorrect";
export type { ExpectStripeSubOptions, NormalizedItem } from "./types";

View File

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

View File

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

View File

@@ -9,7 +9,7 @@ export const USE_KERNEL = !!process.env.USE_KERNEL_BROWSER;
// export const USE_KERNEL = false; // export const USE_KERNEL = false;
/** Run browsers in headless mode (set false to watch the browser) */ /** 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) */ /** Path to local Chromium/Chrome executable (auto-detected if not set in env) */
export const CHROMIUM_PATH = export const CHROMIUM_PATH =

View File

@@ -1,11 +1,29 @@
import type { FullCusEntWithFullCusProduct } from "@models/cusProductModels/cusEntModels/cusEntWithProduct"; import type { FullCusEntWithFullCusProduct } from "@models/cusProductModels/cusEntModels/cusEntWithProduct";
import type { EntitlementWithFeature } from "@models/productModels/entModels/entModels"; import type { EntitlementWithFeature } from "@models/productModels/entModels/entModels";
import type Stripe from "stripe";
import type { Price } from "../../productModels/priceModels/priceModels"; import type { Price } from "../../productModels/priceModels/priceModels";
import type { FullProduct } from "../../productModels/productModels"; 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 = { export type StripeItemSpec = {
stripePriceId: string; // stripe price ID stripePriceId?: string;
stripeInlinePrice?: StripeInlinePrice;
quantity?: number; quantity?: number;
metadata?: Record<string, string>;
autumnPrice?: Price; autumnPrice?: Price;
autumnEntitlement?: EntitlementWithFeature; autumnEntitlement?: EntitlementWithFeature;
autumnProduct?: FullProduct; autumnProduct?: FullProduct;

View File

@@ -11,9 +11,11 @@ import { cusProductToFeatureOptions } from "../../cusProductUtils/convertCusProd
export const cusEntToPrepaidQuantity = ({ export const cusEntToPrepaidQuantity = ({
cusEnt, cusEnt,
sumAcrossEntities = false, sumAcrossEntities = false,
useUpcomingQuantity = false,
}: { }: {
cusEnt: FullCusEntWithFullCusProduct; cusEnt: FullCusEntWithFullCusProduct;
sumAcrossEntities?: boolean; sumAcrossEntities?: boolean;
useUpcomingQuantity?: boolean;
}) => { }) => {
// 2. If cus ent is not prepaid, skip // 2. If cus ent is not prepaid, skip
const cusPrice = cusEntToCusPrice({ cusEnt }); const cusPrice = cusEntToCusPrice({ cusEnt });
@@ -30,7 +32,11 @@ export const cusEntToPrepaidQuantity = ({
if (!options) return 0; 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) .mul(cusPrice.price.config.billing_units ?? 1)
.toNumber(); .toNumber();
@@ -46,13 +52,19 @@ export const cusEntToPrepaidQuantity = ({
export const cusEntsToPrepaidQuantity = ({ export const cusEntsToPrepaidQuantity = ({
cusEnts, cusEnts,
sumAcrossEntities = false, sumAcrossEntities = false,
useUpcomingQuantity = false,
}: { }: {
cusEnts: FullCusEntWithFullCusProduct[]; cusEnts: FullCusEntWithFullCusProduct[];
sumAcrossEntities?: boolean; sumAcrossEntities?: boolean;
useUpcomingQuantity?: boolean;
}) => { }) => {
return sumValues( return sumValues(
cusEnts.map((cusEnt) => cusEnts.map((cusEnt) =>
cusEntToPrepaidQuantity({ cusEnt, sumAcrossEntities }), cusEntToPrepaidQuantity({
cusEnt,
sumAcrossEntities,
useUpcomingQuantity,
}),
), ),
); );
}; };

View File

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

View File

@@ -1,4 +1,7 @@
// Balance utils // Balance utils
// Balance utils barrel
export * from "./balanceUtils";
export * from "./balanceUtils/cusEntsToBalance"; export * from "./balanceUtils/cusEntsToBalance";
export * from "./balanceUtils/cusEntsToCurrentBalance"; export * from "./balanceUtils/cusEntsToCurrentBalance";
export * from "./balanceUtils/cusEntsToPrepaidQuantity"; export * from "./balanceUtils/cusEntsToPrepaidQuantity";
@@ -19,22 +22,19 @@ export * from "./balanceUtils/rollovers/cusEntsToRolloverGranted";
export * from "./balanceUtils/rollovers/cusEntsToRolloverUsage"; export * from "./balanceUtils/rollovers/cusEntsToRolloverUsage";
export * from "./balanceUtils/rollovers/cusEntsToRolloverUsage"; export * from "./balanceUtils/rollovers/cusEntsToRolloverUsage";
// Balance utils barrel
export * from "./balanceUtils";
// Classify utils // Classify utils
export * from "./classifyCusEntUtils"; export * from "./classifyCusEntUtils";
// Convert utils barrel
export * from "./convertCusEntUtils";
// Convert utils // Convert utils
export * from "./convertCusEntUtils/cusEntsToMaxPurchase"; export * from "./convertCusEntUtils/cusEntsToMaxPurchase";
export * from "./convertCusEntUtils/cusEntsToStartingBalance"; export * from "./convertCusEntUtils/cusEntsToStartingBalance";
export * from "./convertCusEntUtils/cusEntToBillingObjects";
export * from "./convertCusEntUtils/cusEntToCusPrice"; export * from "./convertCusEntUtils/cusEntToCusPrice";
export * from "./convertCusEntUtils/cusEntToKey"; export * from "./convertCusEntUtils/cusEntToKey";
export * from "./convertCusEntUtils/cusEntToStripeIds"; export * from "./convertCusEntUtils/cusEntToStripeIds";
// Convert utils barrel // Convert utils barrel
export * from "./convertCusEntUtils/customerEntitlementToOptions"; export * from "./convertCusEntUtils/customerEntitlementToOptions";
// Convert utils barrel
export * from "./convertCusEntUtils";
// Core utils // Core utils
export * from "./cusEntUtils"; export * from "./cusEntUtils";
export * from "./filterCusEntUtils"; export * from "./filterCusEntUtils";

View File

@@ -0,0 +1,11 @@
import { deduplicateArray, type FullCusProduct } from "../../..";
export const customerProductsToStripeSubscriptionIds = ({
customerProducts,
}: {
customerProducts: FullCusProduct[];
}) => {
return deduplicateArray(
customerProducts.flatMap((cp) => cp.subscription_ids),
);
};

View File

@@ -2,9 +2,10 @@ import { customerProductToFeaturesToCarryUsagesFor } from "@utils/cusProductUtil
export * from "./classifyCustomerProduct/classifyCustomerProduct"; export * from "./classifyCustomerProduct/classifyCustomerProduct";
export * from "./classifyCustomerProduct/cpBuilder"; export * from "./classifyCustomerProduct/cpBuilder";
export * from "./convertCusProduct";
export * from "./convertCusProduct/cusProductToConvertedFeatureOptions"; export * from "./convertCusProduct/cusProductToConvertedFeatureOptions";
export * from "./convertCusProduct/cusProductToFeatureOptions"; export * from "./convertCusProduct/cusProductToFeatureOptions";
export * from "./convertCusProduct"; export * from "./convertCusProduct/customerProductsToStripeSubscriptionIds";
export * from "./cusProductConstants"; export * from "./cusProductConstants";
export * from "./cusProductUtils"; export * from "./cusProductUtils";
export * from "./featureOptionUtils/findFeatureOptions"; export * from "./featureOptionUtils/findFeatureOptions";