fix: 🐛 bugs
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user