Files
cfw-autumn/server/tests/integration/balances/lock/postgres/check-with-lock-postgres-edge-cases.test.ts
2026-03-10 16:46:37 +00:00

400 lines
14 KiB
TypeScript

import { test } from "bun:test";
import { type ApiCustomerV5, ResetInterval } from "@autumn/shared";
import { expectCustomerEventsCorrect } from "@tests/integration/balances/utils/events/expectCustomerEventsCorrect.js";
import { deleteLock } from "@tests/integration/balances/utils/lockUtils/deleteLock.js";
import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect";
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";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
// ─────────────────────────────────────────────────────────────────────────────
// Edge case: lock spans multiple entitlement types, then the product is upgraded
// mid-flight (lock held → confirm after upgrade).
//
// Setup:
// addonProd: lifetimeMessages(100) — never resets, add-on
// freeProd: monthlyMessages(50) — resets monthly, customer product
// proProd: monthlyMessages(80) — resets monthly, upgrade target
//
// Initial state: lifetime=100, monthly(free)=50. Total=150.
//
// Check lock=60 (no entity_id):
// Deduction order: monthly(free) → lifetime (monthly exhausted first).
// monthly: 50→0, lifetime: 100→90.
// Receipt records: [monthly:50, lifetime:10]. Total after check=90.
//
// Upgrade free→pro:
// Old monthly(free) entitlement is replaced by monthly(pro)=80.
// Lifetime addon bucket is unaffected (persists across upgrade).
// State after upgrade: lifetime=90, monthly(pro)=80. Total=170.
//
// Key: the lock receipt still references the OLD monthly entitlement ID (now gone)
// and the lifetime entitlement ID. On finalize:
//
// EC-1 (confirm override=57, delta=-3 → LIFO refund of 3):
// LIFO unwinds last bucket first = lifetime. Restore 3 → lifetime=93.
// monthly(pro) is untouched (not in receipt). Total=173.
//
// EC-2 (confirm override=63, delta=+3 → additional deduction of 3):
// Continue deducting from lifetime (last bucket). lifetime=90→87.
// monthly(pro) is untouched (not in receipt). Total=167.
// ─────────────────────────────────────────────────────────────────────────────
const makeAddonProd = () =>
products.base({
id: "addon",
isAddOn: true,
items: [items.lifetimeMessages({ includedUsage: 100 })],
});
const makeFreeProd = () =>
products.base({
id: "free",
items: [items.monthlyMessages({ includedUsage: 50 })],
});
const makeProProd = () =>
products.base({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 80 })],
});
// ─────────────────────────────────────────────────────────────────────────────
// PG-EC-1: lock=60 (crosses monthly→lifetime), upgrade free→pro, confirm=57 (delta=-3)
// Postgres path (skip_cache=true forces DB deduction).
// LIFO unwind restores 3 to lifetime. monthly(pro) stays at full 80.
// Final: lifetime=93, monthly(pro)=80. Total=173.
// ─────────────────────────────────────────────────────────────────────────────
test.concurrent(`${chalk.yellowBright("lock-pg-edge PG-EC-1: lock crosses monthly→lifetime, upgrade mid-flight, confirm with refund — postgres path, monthly(pro) untouched, lifetime refunded")}`, async () => {
const addonProd = makeAddonProd();
const freeProd = makeFreeProd();
const proProd = makeProProd();
const customerId = "lock-pg-edge-1";
const lockKey = `${customerId}-lock`;
const { autumnV2_1, ctx } = await initScenario({
customerId,
setup: [
s.customer({ testClock: false }),
s.products({ list: [addonProd, freeProd, proProd] }),
],
actions: [
// Attach addon first (lifetime bucket), then free (monthly bucket)
s.attach({ productId: addonProd.id }),
s.attach({ productId: freeProd.id }),
],
});
await deleteLock({ ctx, lockId: lockKey });
// Check lock=60: exhausts monthly(free)=50, then deducts 10 from lifetime.
// Receipt: [monthly(free):50, lifetime:10]
await autumnV2_1.check({
customer_id: customerId,
feature_id: TestFeature.Messages,
required_balance: 60,
lock: { enabled: true, lock_id: lockKey },
skip_cache: true,
});
// Verify state after check: total=90 (lifetime=90, monthly=0)
const afterCheck = await autumnV2_1.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer: afterCheck,
featureId: TestFeature.Messages,
remaining: 90,
});
// Upgrade: free→pro. New monthly entitlement (80) replaces old monthly (0).
// Lifetime addon is unaffected. Lock receipt still references old monthly ID.
await autumnV2_1.attach({
customer_id: customerId,
product_id: proProd.id,
});
// Verify state after upgrade: lifetime=90, monthly(pro)=80. Total=170.
const afterUpgrade =
await autumnV2_1.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer: afterUpgrade,
featureId: TestFeature.Messages,
remaining: 170,
});
// Confirm override=57 → delta = 57-60 = -3.
// LIFO: restore 3 to lifetime (last bucket touched). lifetime=90→93.
// monthly(pro) not in receipt → stays at 80.
await autumnV2_1.balances.finalize(
{
lock_id: lockKey,
action: "confirm",
override_value: 57,
},
{
skipCache: true,
},
);
const customer = await autumnV2_1.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer,
featureId: TestFeature.Messages,
remaining: 173,
breakdown: {
[ResetInterval.Month]: { remaining: 80, usage: 0 },
[ResetInterval.OneOff]: { remaining: 93, usage: 7 },
},
});
// Events newest-first: finalize(-3), check(60).
// The upgrade does not emit message events (free product, no billing).
await expectCustomerEventsCorrect({
customerId,
events: [{ value: -3 }, { value: 60 }],
});
await timeout(3000);
const customerDb = await autumnV2_1.customers.get<ApiCustomerV5>(customerId, {
skip_cache: "true",
});
expectBalanceCorrect({
customer: customerDb,
featureId: TestFeature.Messages,
remaining: 173,
breakdown: {
[ResetInterval.Month]: { remaining: 80, usage: 0 },
[ResetInterval.OneOff]: { remaining: 93, usage: 7 },
},
});
});
// ─────────────────────────────────────────────────────────────────────────────
// PG-EC-2: lock=60 (crosses monthly→lifetime), upgrade free→pro, confirm=63 (delta=+3)
// Postgres path (skip_cache=true forces DB deduction).
// Extra +3 deduction runs against current live entitlements in normal order:
// monthly(pro) first → monthly(pro)=80→77. lifetime stays at 90 (unchanged).
// Final: lifetime=90, monthly(pro)=77. Total=167.
// ─────────────────────────────────────────────────────────────────────────────
test.concurrent(`${chalk.yellowBright("lock-pg-edge PG-EC-2: lock crosses monthly→lifetime, upgrade mid-flight, confirm with extra deduction — postgres path, monthly deducted")}`, async () => {
const addonProd = makeAddonProd();
const freeProd = makeFreeProd();
const proProd = makeProProd();
const customerId = "lock-pg-edge-2";
const lockKey = `${customerId}-lock`;
const { autumnV2_1, ctx } = await initScenario({
customerId,
setup: [
s.customer({ testClock: false }),
s.products({ list: [addonProd, freeProd, proProd] }),
],
actions: [
s.attach({ productId: addonProd.id }),
s.attach({ productId: freeProd.id }),
],
});
await deleteLock({ ctx, lockId: lockKey });
// Check lock=60: exhausts monthly(free)=50, deducts 10 from lifetime.
// Receipt: [monthly(free):50, lifetime:10]
await autumnV2_1.check({
customer_id: customerId,
feature_id: TestFeature.Messages,
required_balance: 60,
lock: { enabled: true, lock_id: lockKey },
skip_cache: true,
});
// Verify state after check: total=90
const afterCheck = await autumnV2_1.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer: afterCheck,
featureId: TestFeature.Messages,
remaining: 90,
});
// Upgrade free→pro. New monthly(pro)=80 added. Lifetime addon unaffected.
await autumnV2_1.attach({
customer_id: customerId,
product_id: proProd.id,
});
// Verify state after upgrade: lifetime=90, monthly(pro)=80. Total=170.
const afterUpgrade =
await autumnV2_1.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer: afterUpgrade,
featureId: TestFeature.Messages,
remaining: 170,
});
// Confirm override=63 → delta = 63-60 = +3.
// Continue deducting from lifetime (last bucket in receipt). lifetime=90→87.
// monthly(pro) not in receipt → stays at 80.
await autumnV2_1.balances.finalize(
{
lock_id: lockKey,
action: "confirm",
override_value: 63,
},
{
skipCache: true,
},
);
const customer = await autumnV2_1.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer,
featureId: TestFeature.Messages,
remaining: 167,
breakdown: {
[ResetInterval.Month]: { remaining: 77, usage: 3 },
[ResetInterval.OneOff]: { remaining: 90, usage: 10 },
},
});
// Events newest-first: finalize(+3), check(60).
// The upgrade does not emit message events (free product, no billing).
await expectCustomerEventsCorrect({
customerId,
events: [{ value: 3 }, { value: 60 }],
});
await timeout(3000);
const customerDb = await autumnV2_1.customers.get<ApiCustomerV5>(customerId, {
skip_cache: "true",
});
expectBalanceCorrect({
customer: customerDb,
featureId: TestFeature.Messages,
remaining: 167,
breakdown: {
[ResetInterval.Month]: { remaining: 77, usage: 3 },
[ResetInterval.OneOff]: { remaining: 90, usage: 10 },
},
});
});
// ─────────────────────────────────────────────────────────────────────────────
// EC-3: lock on free-only product, upgrade to pro, track on pro, confirm with
// refund — skipped unwind redirects refund onto current (pro) entitlement.
//
// Setup: freeProd monthlyMessages(50) only (no lifetime addon).
// proProd monthlyMessages(80).
//
// check lock=40 on free: monthly(free)=50→10. Receipt: [monthly(free):40].
// Upgrade free→pro: monthly(free) entitlement replaced by monthly(pro)=80.
// Receipt still references old monthly(free) ID (now gone). Total=80.
// track 20 on pro: monthly(pro)=80→60.
// confirm override=30 → delta = 30-40 = -10 → unwind 10.
// LIFO: try monthly(free) — not found, skip.
// remaining_signed_unwind_value = -10 (positive lock, so negate).
// effective_additional = 0 + (-10) = -10 → refund 10 onto monthly(pro)=60→70.
// Final: monthly(pro)=70. Total=70.
// ─────────────────────────────────────────────────────────────────────────────
test.concurrent(`${chalk.yellowBright("lock-pg-edge PG-EC-3: lock on free, upgrade to pro, track, confirm refund — postgres path, skipped unwind redirects onto pro entitlement")}`, async () => {
const freeProd = makeFreeProd();
const proProd = makeProProd();
const customerId = "lock-pg-edge-3";
const lockKey = `${customerId}-lock`;
const { autumnV2_1, ctx } = await initScenario({
customerId,
setup: [
s.customer({ testClock: false }),
s.products({ list: [freeProd, proProd] }),
],
actions: [s.attach({ productId: freeProd.id })],
});
await deleteLock({ ctx, lockId: lockKey });
// check lock=40: deducts all 40 from monthly(free)=50→10.
// Receipt: [monthly(free):40]
await autumnV2_1.check({
customer_id: customerId,
feature_id: TestFeature.Messages,
required_balance: 40,
lock: { enabled: true, lock_id: lockKey },
skip_cache: true,
});
const afterCheck = await autumnV2_1.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer: afterCheck,
featureId: TestFeature.Messages,
remaining: 10,
});
// Upgrade free→pro. monthly(free) entitlement replaced by monthly(pro)=80.
// Lock receipt still references the now-gone monthly(free) ID.
await autumnV2_1.attach({
customer_id: customerId,
product_id: proProd.id,
});
const afterUpgrade =
await autumnV2_1.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer: afterUpgrade,
featureId: TestFeature.Messages,
remaining: 80,
});
// Track 20 on pro to consume some balance, giving room for the refund.
// monthly(pro)=80→60.
await autumnV2_1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 20,
});
await timeout(4000);
// confirm override=30 → delta = 30-40 = -10 → unwind 10.
// LIFO: monthly(free) not found → skip, remaining_signed_unwind_value=-10.
// effective_additional = 0 + (-10) = -10 → refund 10 onto monthly(pro)=60→70.
await autumnV2_1.balances.finalize(
{
lock_id: lockKey,
action: "confirm",
override_value: 30,
},
{
skipCache: true,
},
);
const customer = await autumnV2_1.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer,
featureId: TestFeature.Messages,
remaining: 70,
});
// Events newest-first: finalize(-10), track(20), check(40)
await expectCustomerEventsCorrect({
customerId,
events: [{ value: -10 }, { value: 20 }, { value: 40 }],
});
await timeout(3000);
const customerDb = await autumnV2_1.customers.get<ApiCustomerV5>(customerId, {
skip_cache: "true",
});
expectBalanceCorrect({
customer: customerDb,
featureId: TestFeature.Messages,
remaining: 70,
});
});