Files
cfw-autumn/server/tests/integration/balances/auto-topup/auto-topup-basic.test.ts
2026-04-07 13:35:28 +01:00

527 lines
13 KiB
TypeScript

import { expect, test } from "bun:test";
import type { ApiCustomerV5, CustomerBillingControls } from "@autumn/shared";
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 { Decimal } from "decimal.js";
/** Wait time for SQS auto top-up processing */
const AUTO_TOPUP_WAIT_MS = 20000;
const makeAutoTopupConfig = ({
threshold = 20,
quantity = 100,
enabled = true,
}: {
threshold?: number;
quantity?: number;
enabled?: boolean;
} = {}): CustomerBillingControls => ({
auto_topups: [
{
feature_id: TestFeature.Messages,
enabled,
threshold,
quantity,
},
],
});
test.concurrent(`${chalk.yellowBright("auto-topup basic: track below threshold triggers top-up")}`, async () => {
const oneOffItem = items.oneOffMessages({
includedUsage: 0,
billingUnits: 100,
price: 10,
});
const oneOffProd = products.oneOffAddOn({
id: "topup-b1",
items: [oneOffItem],
});
const { customerId, autumnV2_1, ctx } = await initScenario({
customerId: "auto-topup-b1",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [oneOffProd] }),
],
actions: [
s.attach({
productId: oneOffProd.id,
options: [{ feature_id: TestFeature.Messages, quantity: 100 }],
}),
],
});
// Configure auto top-up: threshold=20, quantity=100
await autumnV2_1.customers.update(customerId, {
billing_controls: makeAutoTopupConfig({
threshold: 20,
quantity: 100,
}),
});
// Verify starting balance
const before = await autumnV2_1.customers.get<ApiCustomerV5>(customerId);
expect(before.balances[TestFeature.Messages].remaining).toBe(100);
// Track 85 → balance drops to 15 (below threshold of 20)
await autumnV2_1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 85,
});
// Wait for auto top-up to process via SQS
await timeout(AUTO_TOPUP_WAIT_MS);
// Balance should be: 100 - 85 + 100 = 115
const after = await autumnV2_1.customers.get<ApiCustomerV5>(customerId);
const expectedBalance = new Decimal(100).sub(85).add(100).toNumber();
expectBalanceCorrect({
customer: after,
featureId: TestFeature.Messages,
remaining: expectedBalance,
});
await expectCustomerInvoiceCorrect({
customerId,
count: 2,
latestTotal: 10,
latestStatus: "paid",
latestInvoiceProductId: oneOffProd.id,
});
// Verify cusProduct.options.quantity incremented correctly (in packs).
// Initial: 100 credits / 100 billingUnits = 1 pack.
// After 1 top-up of 100 credits (1 pack): expected = 2 packs.
await expectCustomerProductOptions({
ctx,
customerId,
productId: oneOffProd.id,
featureId: TestFeature.Messages,
quantity: 2,
});
});
test.concurrent(`${chalk.yellowBright("auto-topup basic: track above threshold does NOT trigger")}`, async () => {
const oneOffItem = items.oneOffMessages({
includedUsage: 0,
billingUnits: 100,
price: 10,
});
const oneOffProd = products.base({
id: "topup-b2",
items: [oneOffItem],
});
const { customerId, autumnV2_1 } = await initScenario({
customerId: "auto-topup-b2",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [oneOffProd] }),
],
actions: [
s.attach({
productId: oneOffProd.id,
options: [{ feature_id: TestFeature.Messages, quantity: 100 }],
}),
],
});
// Configure auto top-up: threshold=20, quantity=100
await autumnV2_1.customers.update(customerId, {
billing_controls: makeAutoTopupConfig({
threshold: 20,
quantity: 100,
}),
});
// Get initial invoice count
const before = await autumnV2_1.customers.get<ApiCustomerV5>(customerId);
const initialInvoiceCount = before.invoices?.length ?? 0;
// Track 50 → balance drops to 50 (above threshold of 20)
await autumnV2_1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 50,
});
await timeout(AUTO_TOPUP_WAIT_MS);
// Balance should remain at 50 — no top-up
const after = await autumnV2_1.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer: after,
featureId: TestFeature.Messages,
remaining: 50,
});
expect(initialInvoiceCount).toBe(1);
await expectCustomerInvoiceCorrect({
customerId,
count: 1,
latestTotal: 10,
latestStatus: "paid",
latestInvoiceProductId: oneOffProd.id,
});
});
test.concurrent(`${chalk.yellowBright("auto-topup basic: disabled config does NOT trigger")}`, async () => {
const oneOffItem = items.oneOffMessages({
includedUsage: 0,
billingUnits: 100,
price: 10,
});
const oneOffProd = products.base({
id: "topup-b3",
items: [oneOffItem],
});
const { customerId, autumnV2_1 } = await initScenario({
customerId: "auto-topup-b3",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [oneOffProd] }),
],
actions: [
s.attach({
productId: oneOffProd.id,
options: [{ feature_id: TestFeature.Messages, quantity: 100 }],
}),
],
});
// Configure auto top-up but DISABLED
await autumnV2_1.customers.update(customerId, {
billing_controls: makeAutoTopupConfig({
threshold: 20,
quantity: 100,
enabled: false,
}),
});
// Track 85 → balance drops to 15 (below threshold)
await autumnV2_1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 85,
});
await timeout(AUTO_TOPUP_WAIT_MS);
// Balance should stay at 15 — disabled, no top-up
const after = await autumnV2_1.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer: after,
featureId: TestFeature.Messages,
remaining: 15,
});
await expectCustomerInvoiceCorrect({
customerId,
count: 1,
latestTotal: 10,
latestStatus: "paid",
latestInvoiceProductId: oneOffProd.id,
});
});
test.concurrent(`${chalk.yellowBright("auto-topup basic: sequential tracks each trigger separate top-ups")}`, async () => {
const oneOffItem = items.oneOffMessages({
includedUsage: 0,
billingUnits: 100,
price: 10,
});
const oneOffProd = products.oneOffAddOn({
id: "topup-b4",
items: [oneOffItem],
});
const { customerId, autumnV2_1, ctx } = await initScenario({
customerId: "auto-topup-b4",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [oneOffProd] }),
],
actions: [
s.attach({
productId: oneOffProd.id,
options: [{ feature_id: TestFeature.Messages, quantity: 200 }],
}),
],
});
// Configure auto top-up: threshold=30, quantity=100
await autumnV2_1.customers.update(customerId, {
billing_controls: makeAutoTopupConfig({
threshold: 30,
quantity: 100,
}),
});
// Starting balance: 200
// Track 180 → balance = 20 (below threshold 30) → top-up fires → balance = 120
await autumnV2_1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 180,
});
await timeout(AUTO_TOPUP_WAIT_MS);
const mid = await autumnV2_1.customers.get<ApiCustomerV5>(customerId);
// 200 - 180 + 100 = 120
const expectedMid = new Decimal(200).sub(180).add(100).toNumber();
expectBalanceCorrect({
customer: mid,
featureId: TestFeature.Messages,
remaining: expectedMid,
});
await expectCustomerInvoiceCorrect({
customerId,
count: 2,
latestTotal: 10,
latestStatus: "paid",
latestInvoiceProductId: oneOffProd.id,
});
// Track 100 → balance = 20 (below threshold 30 again) → second top-up fires → balance = 120
await autumnV2_1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 100,
});
await timeout(AUTO_TOPUP_WAIT_MS);
const after = await autumnV2_1.customers.get<ApiCustomerV5>(customerId);
// 120 - 100 + 100 = 120
const expectedAfter = new Decimal(120).sub(100).add(100).toNumber();
expectBalanceCorrect({
customer: after,
featureId: TestFeature.Messages,
remaining: expectedAfter,
});
await expectCustomerInvoiceCorrect({
customerId,
count: 3,
latestTotal: 10,
latestStatus: "paid",
latestInvoiceProductId: oneOffProd.id,
});
// Verify cusProduct.options.quantity after two top-ups (in packs).
// Initial: 200 credits / 100 billingUnits = 2 packs.
// After 2 top-ups of 100 credits (1 pack each): expected = 4 packs.
await expectCustomerProductOptions({
ctx,
customerId,
productId: oneOffProd.id,
featureId: TestFeature.Messages,
quantity: 4,
});
});
test.concurrent(`${chalk.yellowBright("auto-topup basic: cache and DB agree after top-up")}`, async () => {
const oneOffItem = items.oneOffMessages({
includedUsage: 0,
billingUnits: 100,
price: 10,
});
const oneOffProd = products.oneOffAddOn({
id: "topup-b5",
items: [oneOffItem],
});
const { customerId, autumnV2_1 } = await initScenario({
customerId: "auto-topup-b5",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [oneOffProd] }),
],
actions: [
s.attach({
productId: oneOffProd.id,
options: [{ feature_id: TestFeature.Messages, quantity: 100 }],
}),
],
});
await autumnV2_1.customers.update(customerId, {
billing_controls: makeAutoTopupConfig({
threshold: 20,
quantity: 100,
}),
});
// Track 85 → triggers top-up
await autumnV2_1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 85,
});
// Wait for top-up + DB sync
await timeout(AUTO_TOPUP_WAIT_MS);
// Verify cached balance (from Redis)
const cached = await autumnV2_1.customers.get<ApiCustomerV5>(customerId);
const expectedBalance = new Decimal(100).sub(85).add(100).toNumber();
expectBalanceCorrect({
customer: cached,
featureId: TestFeature.Messages,
remaining: expectedBalance,
});
await expectCustomerInvoiceCorrect({
customerId,
count: 2,
latestTotal: 10,
latestStatus: "paid",
latestInvoiceProductId: oneOffProd.id,
});
// Wait additional time for DB sync to settle
await timeout(3000);
// Verify DB balance (skip cache)
const fromDb = await autumnV2_1.customers.get<ApiCustomerV5>(customerId, {
skip_cache: "true",
});
expectBalanceCorrect({
customer: fromDb,
featureId: TestFeature.Messages,
remaining: expectedBalance,
});
});
test.concurrent(`${chalk.yellowBright("auto-topup basic: billing units 1000 with top-up quantity 100")}`, async () => {
const oneOffItem = items.oneOffMessages({
includedUsage: 0,
billingUnits: 1000,
price: 10,
});
const oneOffProd = products.oneOffAddOn({
id: "topup-b6",
items: [oneOffItem],
});
const { customerId, autumnV2_1, ctx } = await initScenario({
customerId: "auto-topup-b6",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [oneOffProd] }),
],
actions: [
s.attach({
productId: oneOffProd.id,
options: [{ feature_id: TestFeature.Messages, quantity: 1000 }],
}),
],
});
await autumnV2_1.customers.update(customerId, {
billing_controls: makeAutoTopupConfig({
threshold: 200,
quantity: 100,
}),
});
await autumnV2_1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 950,
});
await timeout(AUTO_TOPUP_WAIT_MS);
const after = await autumnV2_1.customers.get<ApiCustomerV5>(customerId);
const expectedBalance = new Decimal(1000).sub(950).add(1000).toNumber();
expectBalanceCorrect({
customer: after,
featureId: TestFeature.Messages,
remaining: expectedBalance,
});
await expectCustomerInvoiceCorrect({
customerId,
count: 2,
latestTotal: 10,
latestStatus: "paid",
latestInvoiceProductId: oneOffProd.id,
});
await expectCustomerProductOptions({
ctx,
customerId,
productId: oneOffProd.id,
featureId: TestFeature.Messages,
quantity: 2,
});
});
test.concurrent(`${chalk.yellowBright("auto-topup basic: billing units 1000 with top-up quantity 1100")}`, async () => {
const oneOffItem = items.oneOffMessages({
includedUsage: 0,
billingUnits: 1000,
price: 10,
});
const oneOffProd = products.oneOffAddOn({
id: "topup-b7",
items: [oneOffItem],
});
const { customerId, autumnV2_1, ctx } = await initScenario({
customerId: "auto-topup-b7",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [oneOffProd] }),
],
actions: [
s.attach({
productId: oneOffProd.id,
options: [{ feature_id: TestFeature.Messages, quantity: 1000 }],
}),
],
});
await autumnV2_1.customers.update(customerId, {
billing_controls: makeAutoTopupConfig({
threshold: 200,
quantity: 1100,
}),
});
await autumnV2_1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 950,
});
await timeout(AUTO_TOPUP_WAIT_MS);
const after = await autumnV2_1.customers.get<ApiCustomerV5>(customerId);
const expectedBalance = new Decimal(1000).sub(950).add(2000).toNumber();
expectBalanceCorrect({
customer: after,
featureId: TestFeature.Messages,
remaining: expectedBalance,
});
await expectCustomerInvoiceCorrect({
customerId,
count: 2,
latestTotal: 20,
latestStatus: "paid",
latestInvoiceProductId: oneOffProd.id,
});
await expectCustomerProductOptions({
ctx,
customerId,
productId: oneOffProd.id,
featureId: TestFeature.Messages,
quantity: 3,
});
});