fix: 🐛 bugs

This commit is contained in:
amianthus
2026-04-27 15:46:22 +01:00
parent 266664292f
commit 982e68d086
10 changed files with 965 additions and 14 deletions

View File

@@ -17,8 +17,9 @@ import {
buildUpdatedOptions,
updateCusEntOptionsInline,
} from "../helpers/autoTopUpUtils.js";
import { computeRebalancedAutoTopUp } from "./computeRebalancedAutoTopUp.js";
/** Compute the auto top-up billing plan + stripe invoice action. Returns null if line item amount is <= 0. */
/** Compute the auto top-up billing plan + stripe invoice action. Throws if line item amount is <= 0. */
export const computeAutoTopupPlan = ({
ctx,
autoTopupContext,
@@ -72,17 +73,24 @@ export const computeAutoTopupPlan = ({
});
}
// C. Build autumn billing plan
// C. Compute paydown + prepaid remainder deltas from the context's FullCustomer.
// Deltas apply atomically at execute time via `balance + delta` SQL increments.
const { deltas } = computeRebalancedAutoTopUp({
fullCustomer: autoTopupContext.fullCustomer,
featureId: feature.id,
quantity,
prepaidCustomerEntitlementId: customerEntitlement.id,
});
// D. Build autumn billing plan. `options.quantity` bumps by the FULL topUpPacks
// because the customer is charged for the full purchase regardless of where the
// balance landed.
const autumnBillingPlan: AutumnBillingPlan = {
customerId: autoTopupContext.fullCustomer?.id ?? "",
insertCustomerProducts: [],
lineItems: [lineItem],
updateCustomerEntitlements: [
{
customerEntitlement,
balanceChange: quantity,
},
],
updateCustomerEntitlements: [],
autoTopupRebalance: { deltas },
updateCustomerProduct: {
customerProduct: cusProduct,
updates: {

View File

@@ -0,0 +1,121 @@
import {
type FullCusEntWithFullCusProduct,
type FullCustomer,
fullCustomerToCustomerEntitlements,
isBooleanCusEnt,
isEntityScopedCusEnt,
isUnlimitedCusEnt,
} from "@autumn/shared";
import { runDeductionPass } from "@/internal/balances/track/deductUtils/deductFromCusEntsTypescript.js";
import type { DeductionUpdates } from "@/internal/balances/utils/types/deductionUpdate.js";
import type { MutationLogItem } from "@/internal/balances/utils/types/mutationLogItem.js";
export type AutoTopupRebalanceDelta = {
cusEntId: string;
featureId: string;
delta: number;
};
/**
* Sort order for paydown — mirrors deductFromCusEntsTypescript's pass-2 sort so overage
* heals on the cusEnts that accrued it first (`usage_allowed: true`), with a stable
* `created_at` tiebreaker.
*/
const sortForPaydown = (
cusEnts: FullCusEntWithFullCusProduct[],
): FullCusEntWithFullCusProduct[] => {
return [...cusEnts].sort((a, b) => {
const leftUsageAllowed = a.usage_allowed ?? false;
const rightUsageAllowed = b.usage_allowed ?? false;
if (leftUsageAllowed !== rightUsageAllowed) {
return leftUsageAllowed ? -1 : 1;
}
return (a.created_at ?? 0) - (b.created_at ?? 0);
});
};
const isPaydownCandidate = (cusEnt: FullCusEntWithFullCusProduct): boolean => {
if (isBooleanCusEnt({ cusEnt })) return false;
if (isUnlimitedCusEnt(cusEnt)) return false;
if (isEntityScopedCusEnt(cusEnt)) return false;
return true;
};
const hasTopLevelOverage = (cusEnt: FullCusEntWithFullCusProduct): boolean =>
(cusEnt.balance ?? 0) < 0;
/**
* Compute the list of balance deltas needed to rebalance an auto top-up:
* 1. Pay down overage on non-prepaid, non-entity-scoped top-level cusEnts first
* (capped at 0 per cusEnt — the paydown primitive).
* 2. Route the remainder to the prepaid one-off cusEnt.
*
* Deltas are applied at execute time via atomic SQL balance + delta increments, so
* they're race-safe against concurrent deductions. Entity-scoped cusEnts are excluded
* because there's no per-entity atomic primitive today (future work).
*/
export const computeRebalancedAutoTopUp = ({
fullCustomer,
featureId,
quantity,
prepaidCustomerEntitlementId,
}: {
fullCustomer: FullCustomer;
featureId: string;
quantity: number;
prepaidCustomerEntitlementId: string;
}): { deltas: AutoTopupRebalanceDelta[] } => {
if (quantity <= 0) return { deltas: [] };
const cusEntsForFeature = fullCustomerToCustomerEntitlements({
fullCustomer,
featureId,
});
const prepaidCusEnt = cusEntsForFeature.find(
(cusEnt) => cusEnt.id === prepaidCustomerEntitlementId,
);
if (!prepaidCusEnt) return { deltas: [] };
const candidates = cusEntsForFeature.filter(
(cusEnt) =>
cusEnt.id !== prepaidCustomerEntitlementId &&
isPaydownCandidate(cusEnt) &&
hasTopLevelOverage(cusEnt),
);
const deltas: AutoTopupRebalanceDelta[] = [];
let remainder = quantity;
if (candidates.length > 0) {
const sortedCandidates = sortForPaydown(candidates);
const updates: DeductionUpdates = {};
const mutationLogs: MutationLogItem[] = [];
const passResult = runDeductionPass({
cusEnts: sortedCandidates,
amountToDeduct: -quantity,
maxBalance: 0,
updates,
mutationLogs,
});
remainder = Math.abs(passResult.amountToDeduct);
for (const [cusEntId, update] of Object.entries(updates)) {
const delta = -update.deducted;
if (delta === 0) continue;
deltas.push({ cusEntId, featureId, delta });
}
}
if (remainder > 0) {
deltas.push({ cusEntId: prepaidCusEnt.id, featureId, delta: remainder });
}
return { deltas };
};

View File

@@ -27,10 +27,6 @@ const getAutoTopupFullCustomer = async ({
ctx: AutumnContext;
customerId: string;
}): Promise<FullCustomer | undefined> => {
// console.log(`GETTING AUTO TOP UP CUSTOMER ${customerId}`);
// console.log(
// `IS FULL SUBJECT ROLLOUT ENABLED: ${isFullSubjectRolloutEnabled({ ctx })}`,
// );
if (isFullSubjectRolloutEnabled({ ctx })) {
const { fullSubject: cachedFullSubject } = await getCachedFullSubject({
ctx,
@@ -68,7 +64,6 @@ const getAutoTopupFullCustomer = async ({
let fullCustomer = await getCachedFullCustomer({ ctx, customerId });
if (!fullCustomer) {
console.log(`NO CACHED FULL CUSTOMER, FETCHING FROM DB`);
fullCustomer = await CusService.getFull({
ctx,
idOrInternalId: customerId,

View File

@@ -93,8 +93,12 @@ const buildMutationLogs = ({
/**
* Runs a single deduction pass over customer entitlements, tracking updates and mutation logs.
* Extracted to avoid duplicating the per-cusEnt loop logic across passes.
*
* Also exported for reuse by callers that need the "paydown primitive" — e.g. the auto
* top-up rebalancer uses a single pass with `maxBalance: 0` and a negative `amountToDeduct`
* to heal overage'd cusEnts up to (but not past) zero.
*/
const runDeductionPass = ({
export const runDeductionPass = ({
cusEnts,
amountToDeduct,
targetEntityId,

View File

@@ -0,0 +1,33 @@
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import type { AutoTopupRebalanceDelta } from "@/internal/balances/autoTopUp/compute/computeRebalancedAutoTopUp.js";
import { customerEntitlementActions } from "@/internal/customers/cusProducts/cusEnts/actions/index.js";
/**
* Apply pre-computed auto top-up rebalance deltas. Each delta is an atomic SQL
* `balance + delta` increment (+ Redis JSON.NUMINCRBY), so concurrent deductions
* between compute and execute are preserved.
*
* The deltas themselves are computed earlier by `computeRebalancedAutoTopUp` from
* the context's FullCustomer snapshot; this executor step is purely mechanical.
*/
export const executeAutoTopupRebalance = async ({
ctx,
customerId,
deltas,
}: {
ctx: AutumnContext;
customerId: string;
deltas: AutoTopupRebalanceDelta[];
}): Promise<void> => {
for (const { cusEntId, featureId, delta } of deltas) {
if (delta === 0) continue;
await customerEntitlementActions.adjustBalanceDbAndCache({
ctx,
customerId,
cusEntId,
featureId,
delta,
});
}
};

View File

@@ -1,6 +1,7 @@
import type { AutumnBillingPlan, Invoice } from "@autumn/shared";
import type Stripe from "stripe";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { executeAutoTopupRebalance } from "@/internal/billing/v2/execute/executeAutumnActions/executeAutoTopupRebalance";
import { insertNewCusProducts } from "@/internal/billing/v2/execute/executeAutumnActions/insertNewCusProducts";
import { updateCustomerEntitlements } from "@/internal/billing/v2/execute/executeAutumnActions/updateCustomerEntitlements";
import {
@@ -114,6 +115,16 @@ export const executeAutumnBillingPlan = async ({
updates: autumnBillingPlan.updateCustomerEntitlements,
});
// 5a. Auto top-up rebalance: apply pre-computed paydown + remainder deltas as
// atomic SQL `balance + delta` increments.
if (autumnBillingPlan.autoTopupRebalance) {
await executeAutoTopupRebalance({
ctx,
customerId: autumnBillingPlan.customerId,
deltas: autumnBillingPlan.autoTopupRebalance.deltas,
});
}
// 6. Upsert subscription (if provided)
if (autumnBillingPlan.upsertSubscription) {
await SubService.upsertByStripeId({

View File

@@ -64,6 +64,15 @@ export const logAutumnBillingPlan = ({
item: `${item.description}: ${item.amountAfterDiscounts}`,
effectivePeriod: `${formatMs(item.context.effectivePeriod?.start)} - ${formatMs(item.context.effectivePeriod?.end)}`,
})) ?? "none",
autoTopupRebalance: plan.autoTopupRebalance
? plan.autoTopupRebalance.deltas
.map(
({ cusEntId, delta }) =>
`${cusEntId}: ${delta > 0 ? "+" : ""}${delta}`,
)
.join(", ") || "no-op"
: "none",
},
},
});

View File

@@ -0,0 +1,286 @@
import { test } from "bun:test";
import type { ApiCustomerV5 } from "@autumn/shared";
import { setCustomerOverageAllowed } from "@tests/integration/balances/utils/overage-allowed-utils/customerOverageAllowedUtils.js";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect";
import { expectCustomerProductOptions } from "@tests/integration/utils/expectCustomerProductOptions";
import { TestFeature } from "@tests/setup/v2Features.js";
import { items } from "@tests/utils/fixtures/items.js";
import { products } from "@tests/utils/fixtures/products.js";
import { timeout } from "@tests/utils/genUtils.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
import { makeAutoTopupConfig } from "./utils/makeAutoTopupConfig.js";
/** Wait time for SQS auto top-up processing */
const AUTO_TOPUP_WAIT_MS = 40000;
/**
* ATU Rebalance: verifies that auto top-up quantities first pay down existing overage
* on non-prepaid, non-entity-scoped top-level cusEnts before routing the remainder to
* the one-off prepaid cusEnt.
*
* Architecture note: paydown computation runs at compute time from the billing
* context's FullCustomer snapshot. The billing plan carries pre-computed deltas
* (`autoTopupRebalance: { deltas: [{ cusEntId, featureId, delta }] }`), and execute
* applies them with race-safe atomic delta writes via `adjustBalanceDbAndCache`.
*
* Cache v2 sequencing note: `customers.update(billing_controls)` invalidates the
* FullSubject cache. If the prior usage deduction has not been synced to Postgres
* yet, the next read rehydrates from a stale DB and the post-track ATU trigger
* misses the overage. So we configure billing_controls FIRST, then drive usage,
* so the deduction itself triggers ATU against the cached, deducted state.
*
* Entity-scoped cusEnts are intentionally excluded from paydown — there is no
* race-safe per-entity atomic increment primitive today. Entity-scoped overage is
* left in place and the full top-up quantity flows to prepaid as remainder. Adding
* safe entity paydown is a separate follow-up that requires JSONB-path atomic updates.
*
* Each test attaches TWO products:
* - Base: `products.base` + `items.lifetimeMessages({ includedUsage })` — the
* overage'd cusEnt (`usage_allowed` after enabling overage) that will absorb
* paydown.
* - Top-up: `products.oneOffAddOn` + `items.oneOffMessages` — the one-off prepaid
* cusEnt that ATU targets. Starts at 0/0 when attached with `quantity: 0`.
*/
test.concurrent(
`${chalk.yellowBright("auto-topup rebalance-1: paydown + remainder to prepaid")}`,
async () => {
const baseProd = products.base({
id: "topup-rb1-base",
items: [items.lifetimeMessages({ includedUsage: 1000 })],
});
const oneOffItem = items.oneOffMessages({
includedUsage: 0,
billingUnits: 100,
price: 10,
});
const oneOffProd = products.oneOffAddOn({
id: "topup-rb1-addon",
items: [oneOffItem],
});
const { customerId, autumnV2_1, ctx } = await initScenario({
customerId: "auto-topup-rb1",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [baseProd, oneOffProd] }),
],
actions: [
s.attach({ productId: baseProd.id }),
s.attach({
productId: oneOffProd.id,
options: [{ feature_id: TestFeature.Messages, quantity: 0 }],
}),
],
});
// Allow the base cusEnt to go into overage.
await setCustomerOverageAllowed({
autumn: autumnV2_1,
customerId,
featureId: TestFeature.Messages,
enabled: true,
});
// Configure ATU FIRST (before any deduction) so that the post-track trigger
// fires against the cached, deducted state — see cache v2 sequencing note.
await autumnV2_1.customers.update(customerId, {
billing_controls: makeAutoTopupConfig({ threshold: 0, quantity: 600 }),
});
// Drive base into -500 overage (usage = 1500 against allowance 1000).
// Post-track ATU trigger sees combined balance = -500 ≤ threshold 0 → fires.
await autumnV2_1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 1500,
});
await timeout(AUTO_TOPUP_WAIT_MS);
// Post-ATU expected: base 0/1000, prepaid 100/100, combined remaining 100.
const after = await autumnV2_1.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer: after,
featureId: TestFeature.Messages,
remaining: 100,
});
// Invoice: 600 credits / 100 billing_units = 6 packs × $10 = $60.
await expectCustomerInvoiceCorrect({
customerId,
count: 2,
latestTotal: 60,
latestStatus: "paid",
latestInvoiceProductId: oneOffProd.id,
});
// options.quantity tracks FULL top-up purchase (6 packs).
await expectCustomerProductOptions({
ctx,
customerId,
productId: oneOffProd.id,
featureId: TestFeature.Messages,
quantity: 6,
});
},
);
test.concurrent(
`${chalk.yellowBright("auto-topup rebalance-4: no overage, full remainder to prepaid (backward compat)")}`,
async () => {
const baseProd = products.base({
id: "topup-rb4-base",
items: [items.lifetimeMessages({ includedUsage: 1000 })],
});
const oneOffItem = items.oneOffMessages({
includedUsage: 0,
billingUnits: 100,
price: 10,
});
const oneOffProd = products.oneOffAddOn({
id: "topup-rb4-addon",
items: [oneOffItem],
});
const { customerId, autumnV2_1, ctx } = await initScenario({
customerId: "auto-topup-rb4",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [baseProd, oneOffProd] }),
],
actions: [
s.attach({ productId: baseProd.id }),
s.attach({
productId: oneOffProd.id,
options: [{ feature_id: TestFeature.Messages, quantity: 1 }], // 1 pack = 100 credits prepaid
}),
],
});
// Configure ATU first.
await autumnV2_1.customers.update(customerId, {
billing_controls: makeAutoTopupConfig({ threshold: 300, quantity: 600 }),
});
// Use 800 → base=200, prepaid=100 → combined=300. threshold=300 so trigger fires.
await autumnV2_1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 800,
});
await timeout(AUTO_TOPUP_WAIT_MS);
// Base unchanged at 200, prepaid grows by 600 to 700. Combined = 900.
const after = await autumnV2_1.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer: after,
featureId: TestFeature.Messages,
remaining: 900,
});
// Invoice: 600 credits = 6 packs × $10 = $60. Plus the attach invoice for 1 pack = $10.
await expectCustomerInvoiceCorrect({
customerId,
count: 2,
latestTotal: 60,
latestStatus: "paid",
latestInvoiceProductId: oneOffProd.id,
});
// options.quantity: attached with 1 pack, ATU added 6 → 7.
await expectCustomerProductOptions({
ctx,
customerId,
productId: oneOffProd.id,
featureId: TestFeature.Messages,
quantity: 7,
});
},
);
test.concurrent(
`${chalk.yellowBright("auto-topup rebalance-5: overage exceeds top-up, no remainder to prepaid")}`,
async () => {
const baseProd = products.base({
id: "topup-rb5-base",
items: [items.lifetimeMessages({ includedUsage: 1000 })],
});
const oneOffItem = items.oneOffMessages({
includedUsage: 0,
billingUnits: 100,
price: 10,
});
const oneOffProd = products.oneOffAddOn({
id: "topup-rb5-addon",
items: [oneOffItem],
});
const { customerId, autumnV2_1, ctx } = await initScenario({
customerId: "auto-topup-rb5",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [baseProd, oneOffProd] }),
],
actions: [
s.attach({ productId: baseProd.id }),
s.attach({
productId: oneOffProd.id,
options: [{ feature_id: TestFeature.Messages, quantity: 0 }],
}),
],
});
await setCustomerOverageAllowed({
autumn: autumnV2_1,
customerId,
featureId: TestFeature.Messages,
enabled: true,
});
// Configure ATU first.
await autumnV2_1.customers.update(customerId, {
billing_controls: makeAutoTopupConfig({ threshold: 0, quantity: 600 }),
});
// Drive base to -1000 overage (usage=2000 vs allowance=1000).
await autumnV2_1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 2000,
});
await timeout(AUTO_TOPUP_WAIT_MS);
// Base balance after paydown: -400. Prepaid unchanged at 0.
// Combined: -400, reported as remaining: 0 (API clamps negative to 0).
const after = await autumnV2_1.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer: after,
featureId: TestFeature.Messages,
remaining: 0,
});
// Invoice still charged full 600 (6 packs × $10 = $60).
await expectCustomerInvoiceCorrect({
customerId,
count: 2,
latestTotal: 60,
latestStatus: "paid",
latestInvoiceProductId: oneOffProd.id,
});
// options.quantity still tracks FULL purchase (6 packs) even though balance landed
// entirely in base paydown.
await expectCustomerProductOptions({
ctx,
customerId,
productId: oneOffProd.id,
featureId: TestFeature.Messages,
quantity: 6,
});
},
);

View File

@@ -0,0 +1,467 @@
import { describe, expect, test } from "bun:test";
import {
BillWhen,
BillingInterval,
type EntityBalance,
type FullCusEntWithFullCusProduct,
type FullCustomer,
PriceType,
} from "@autumn/shared";
import { computeRebalancedAutoTopUp } from "@/internal/balances/autoTopUp/compute/computeRebalancedAutoTopUp";
const createCustomerEntitlement = ({
id,
balance,
quantity = 0,
usageAllowed = false,
entities,
createdAt = 1,
allowance = 0,
entityFeatureId = null,
}: {
id: string;
balance: number;
quantity?: number;
usageAllowed?: boolean;
entities?: Record<string, EntityBalance>;
createdAt?: number;
allowance?: number;
entityFeatureId?: string | null;
}): FullCusEntWithFullCusProduct => {
const entitlementId = `ent-${id}`;
const customerProductId = `cus-prod-${id}`;
return {
id: `cus-ent-${id}`,
internal_customer_id: "internal-customer",
internal_entity_id: null,
internal_feature_id: "internal-feature-messages",
customer_id: "customer-1",
feature_id: "messages",
entitlement_id: entitlementId,
customer_product_id: customerProductId,
created_at: createdAt,
unlimited: false,
balance,
additional_balance: 0,
adjustment: 0,
entities: entities ?? null,
usage_allowed: usageAllowed,
next_reset_at: null,
expires_at: null,
cache_version: 0,
external_id: null,
replaceables: [],
rollovers: [],
entitlement: {
id: entitlementId,
internal_feature_id: "internal-feature-messages",
internal_product_id: "internal-product-1",
is_custom: false,
allowance_type: "fixed",
allowance,
interval: BillingInterval.Month,
interval_count: 1,
carry_from_previous: false,
entity_feature_id: entityFeatureId,
org_id: "org-1",
feature_id: "messages",
usage_limit: null,
rollover: null,
feature: {
id: "messages",
internal_id: "internal-feature-messages",
name: "Messages",
type: "metered",
config: {},
org_id: "org-1",
env: "sandbox",
created_at: 1,
deleted_at: null,
},
},
customer_product: {
id: customerProductId,
internal_id: customerProductId,
internal_customer_id: "internal-customer",
internal_product_id: "internal-product-1",
internal_entity_id: null,
customer_id: "customer-1",
product_id: `product-${id}`,
name: `Product ${id}`,
group: "",
created_at: 1,
ended_at: null,
canceled_at: null,
cancel_at: null,
expires_at: null,
trial_ends_at: null,
trial_started_at: null,
anchor_at: null,
quantity: 1,
status: "active",
canceled: false,
version: 1,
entity_id: null,
replaces_customer_product_id: null,
options: [
{
feature_id: "messages",
internal_feature_id: "internal-feature-messages",
quantity,
},
],
product: {
internal_id: "internal-product-1",
id: `product-${id}`,
name: `Product ${id}`,
description: null,
org_id: "org-1",
created_at: 1,
env: "sandbox",
is_add_on: false,
is_default: false,
group: "",
version: 1,
processor: {},
base_variant_id: null,
archived: false,
free_trials: [],
free_trial: null,
prices: [],
entitlements: [],
},
customer_entitlements: [],
customer_prices: [
{
id: `cus-price-${id}`,
price_id: `price-${id}`,
customer_product_id: customerProductId,
created_at: 1,
price: {
id: `price-${id}`,
org_id: "org-1",
internal_product_id: "internal-product-1",
config: {
type: PriceType.Usage,
bill_when: BillWhen.InAdvance,
billing_units: 100,
internal_feature_id: "internal-feature-messages",
feature_id: "messages",
usage_tiers: [{ to: "inf", amount: 10 }],
interval: BillingInterval.Month,
interval_count: 1,
stripe_meter_id: null,
stripe_price_id: null,
stripe_empty_price_id: null,
stripe_product_id: null,
stripe_placeholder_price_id: null,
stripe_event_name: null,
stripe_prepaid_price_v2_id: null,
should_prorate: false,
},
created_at: 1,
billing_type: null,
tier_behavior: null,
is_custom: false,
entitlement_id: entitlementId,
proration_config: {},
},
},
],
},
} as unknown as FullCusEntWithFullCusProduct;
};
const buildFullCustomer = (
cusEnts: FullCusEntWithFullCusProduct[],
): FullCustomer => {
const productsById = new Map<string, FullCusEntWithFullCusProduct[]>();
for (const cusEnt of cusEnts) {
const cusProduct = cusEnt.customer_product;
if (!cusProduct) continue;
const existing = productsById.get(cusProduct.id) ?? [];
existing.push(cusEnt);
productsById.set(cusProduct.id, existing);
}
const customer_products = Array.from(productsById.entries()).map(
([, ents]) => {
const sampleProduct = ents[0]?.customer_product!;
return {
...sampleProduct,
customer_entitlements: ents.map((e) => ({ ...e })),
};
},
);
return {
id: "customer-1",
internal_id: "internal-customer",
org_id: "org-1",
env: "sandbox",
customer_products,
auto_topups: [],
extra_customer_entitlements: [],
invoices: [],
entities: [],
} as unknown as FullCustomer;
};
describe("computeRebalancedAutoTopUp", () => {
test("1. no overage: single delta to prepaid for full quantity", () => {
const prepaid = createCustomerEntitlement({ id: "prepaid", balance: 0 });
const base = createCustomerEntitlement({
id: "base",
balance: 200,
usageAllowed: true,
});
const fullCustomer = buildFullCustomer([base, prepaid]);
const { deltas } = computeRebalancedAutoTopUp({
fullCustomer,
featureId: "messages",
quantity: 600,
prepaidCustomerEntitlementId: prepaid.id,
});
expect(deltas).toEqual([
{ cusEntId: prepaid.id, featureId: "messages", delta: 600 },
]);
});
test("2. single overage cusEnt: paydown delta then prepaid remainder delta", () => {
const prepaid = createCustomerEntitlement({ id: "prepaid", balance: 0 });
const base = createCustomerEntitlement({
id: "base",
balance: -500,
usageAllowed: true,
});
const fullCustomer = buildFullCustomer([base, prepaid]);
const { deltas } = computeRebalancedAutoTopUp({
fullCustomer,
featureId: "messages",
quantity: 600,
prepaidCustomerEntitlementId: prepaid.id,
});
expect(deltas).toEqual([
{ cusEntId: base.id, featureId: "messages", delta: 500 },
{ cusEntId: prepaid.id, featureId: "messages", delta: 100 },
]);
});
test("3. overage exceeds quantity: only paydown delta, no prepaid delta", () => {
const prepaid = createCustomerEntitlement({ id: "prepaid", balance: 0 });
const base = createCustomerEntitlement({
id: "base",
balance: -1000,
usageAllowed: true,
});
const fullCustomer = buildFullCustomer([base, prepaid]);
const { deltas } = computeRebalancedAutoTopUp({
fullCustomer,
featureId: "messages",
quantity: 600,
prepaidCustomerEntitlementId: prepaid.id,
});
expect(deltas).toEqual([
{ cusEntId: base.id, featureId: "messages", delta: 600 },
]);
});
test("4. prepaid cusEnt missing: empty deltas", () => {
const base = createCustomerEntitlement({
id: "base",
balance: -200,
usageAllowed: true,
});
const fullCustomer = buildFullCustomer([base]);
const { deltas } = computeRebalancedAutoTopUp({
fullCustomer,
featureId: "messages",
quantity: 600,
prepaidCustomerEntitlementId: "cus-ent-missing",
});
expect(deltas).toEqual([]);
});
test("5. quantity <= 0: empty deltas", () => {
const prepaid = createCustomerEntitlement({ id: "prepaid", balance: 0 });
const base = createCustomerEntitlement({
id: "base",
balance: -500,
usageAllowed: true,
});
const fullCustomer = buildFullCustomer([base, prepaid]);
const { deltas } = computeRebalancedAutoTopUp({
fullCustomer,
featureId: "messages",
quantity: 0,
prepaidCustomerEntitlementId: prepaid.id,
});
expect(deltas).toEqual([]);
});
test("6. entity-scoped cusEnt excluded from paydown; full quantity to prepaid", () => {
const prepaid = createCustomerEntitlement({ id: "prepaid", balance: 0 });
const entityScoped = createCustomerEntitlement({
id: "entity-scoped",
balance: 0,
usageAllowed: true,
entityFeatureId: "some-entity-feature",
entities: {
"entity-a": {
id: "entity-a",
balance: -100,
adjustment: 0,
additional_balance: 0,
},
"entity-b": {
id: "entity-b",
balance: -100,
adjustment: 0,
additional_balance: 0,
},
},
});
const fullCustomer = buildFullCustomer([entityScoped, prepaid]);
const { deltas } = computeRebalancedAutoTopUp({
fullCustomer,
featureId: "messages",
quantity: 300,
prepaidCustomerEntitlementId: prepaid.id,
});
expect(deltas).toEqual([
{ cusEntId: prepaid.id, featureId: "messages", delta: 300 },
]);
});
test("7. usage_allowed sorts before non-usage_allowed", () => {
const prepaid = createCustomerEntitlement({ id: "prepaid", balance: 0 });
const nonUsageAllowed = createCustomerEntitlement({
id: "non-usage-allowed",
balance: -100,
usageAllowed: false,
createdAt: 1,
});
const usageAllowedCe = createCustomerEntitlement({
id: "usage-allowed",
balance: -100,
usageAllowed: true,
createdAt: 2,
});
const fullCustomer = buildFullCustomer([
nonUsageAllowed,
usageAllowedCe,
prepaid,
]);
const { deltas } = computeRebalancedAutoTopUp({
fullCustomer,
featureId: "messages",
quantity: 150,
prepaidCustomerEntitlementId: prepaid.id,
});
expect(deltas).toEqual([
{ cusEntId: usageAllowedCe.id, featureId: "messages", delta: 100 },
{ cusEntId: nonUsageAllowed.id, featureId: "messages", delta: 50 },
]);
});
test("8. same usage_allowed value: oldest created_at paid down first", () => {
const prepaid = createCustomerEntitlement({ id: "prepaid", balance: 0 });
const olderCe = createCustomerEntitlement({
id: "older",
balance: -100,
usageAllowed: true,
createdAt: 1,
});
const newerCe = createCustomerEntitlement({
id: "newer",
balance: -100,
usageAllowed: true,
createdAt: 100,
});
const fullCustomer = buildFullCustomer([newerCe, olderCe, prepaid]);
const { deltas } = computeRebalancedAutoTopUp({
fullCustomer,
featureId: "messages",
quantity: 100,
prepaidCustomerEntitlementId: prepaid.id,
});
// Only enough to zero one cusEnt. Older (createdAt=1) paid down first.
expect(deltas).toEqual([
{ cusEntId: olderCe.id, featureId: "messages", delta: 100 },
]);
});
test("9. multi-cusEnt paydown with prepaid remainder", () => {
const prepaid = createCustomerEntitlement({ id: "prepaid", balance: 0 });
const cusEntA = createCustomerEntitlement({
id: "cus-a",
balance: -300,
usageAllowed: true,
createdAt: 1,
});
const cusEntB = createCustomerEntitlement({
id: "cus-b",
balance: -200,
usageAllowed: true,
createdAt: 2,
});
const fullCustomer = buildFullCustomer([cusEntA, cusEntB, prepaid]);
const { deltas } = computeRebalancedAutoTopUp({
fullCustomer,
featureId: "messages",
quantity: 1000,
prepaidCustomerEntitlementId: prepaid.id,
});
expect(deltas).toEqual([
{ cusEntId: cusEntA.id, featureId: "messages", delta: 300 },
{ cusEntId: cusEntB.id, featureId: "messages", delta: 200 },
{ cusEntId: prepaid.id, featureId: "messages", delta: 500 },
]);
});
test("10. prepaid cusEnt filtered from paydown pool even when it has overage", () => {
// If the prepaid somehow has a negative balance, the paydown pool must NOT
// include it — that's what the remainder delta is for.
const prepaid = createCustomerEntitlement({
id: "prepaid",
balance: -50,
});
const base = createCustomerEntitlement({
id: "base",
balance: -100,
usageAllowed: true,
});
const fullCustomer = buildFullCustomer([base, prepaid]);
const { deltas } = computeRebalancedAutoTopUp({
fullCustomer,
featureId: "messages",
quantity: 600,
prepaidCustomerEntitlementId: prepaid.id,
});
expect(deltas).toEqual([
{ cusEntId: base.id, featureId: "messages", delta: 100 },
{ cusEntId: prepaid.id, featureId: "messages", delta: 500 },
]);
});
});

View File

@@ -85,6 +85,23 @@ export const AutumnBillingPlanSchema = z.object({
.array(UpdateCustomerEntitlementSchema)
.optional(),
/**
* Pre-computed auto top-up rebalance deltas. The compute step sizes paydown + prepaid
* remainder from the context's FullCustomer snapshot; the executor just loops these
* and applies each via adjustBalanceDbAndCache (atomic SQL balance + delta).
*/
autoTopupRebalance: z
.object({
deltas: z.array(
z.object({
cusEntId: z.string(),
featureId: z.string(),
delta: z.number(),
}),
),
})
.optional(),
// Upsert operations (populated during webhook handling, e.g., checkout.session.completed)
upsertSubscription: SubscriptionSchema.optional(),
upsertInvoice: z.custom<InsertInvoice>().optional(),