From 82b86c4a4dde752e58a473b4e95d0f9606e46bf4 Mon Sep 17 00:00:00 2001 From: Georgy Butaev <41178744+g-but@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:13:14 +0200 Subject: [PATCH 1/2] test(credits): cover paid-assistant charge money path before go-live MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The paid ai_assistant charge path (95/5 split, debit payer → grant creator) goes live the moment PLATFORM_NWC_URI is provisioned, yet had zero tests. Pin the balance-protecting rules: free/non-per_message/invalid prices bill 0, the SANE_MAX guard refuses a mis-scaled legacy sats-in-BTC-column value, and settlement never pays the creator when the payer debit didn't land. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../unit/services/ai/assistant-charge.test.ts | 172 ++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 __tests__/unit/services/ai/assistant-charge.test.ts diff --git a/__tests__/unit/services/ai/assistant-charge.test.ts b/__tests__/unit/services/ai/assistant-charge.test.ts new file mode 100644 index 000000000..d67743f00 --- /dev/null +++ b/__tests__/unit/services/ai/assistant-charge.test.ts @@ -0,0 +1,172 @@ +/** + * Paid AI-assistant charging — money path. + * + * This is one of the two real-money paths that go live the moment the platform + * wallet (PLATFORM_NWC_URI) is provisioned: a paid `ai_assistant` message debits + * the chatter's Cat Credits and grants the creator's 95% share. This suite pins + * the rules that protect balances: + * - free / non-per_message / misconfigured prices bill 0 (never drain a balance), + * - the SANE_MAX guard refuses a mis-scaled legacy sats-in-BTC-column value, + * - settlement is 95/5, and the creator is NEVER paid when the payer debit + * didn't land (no money minted out of a failed charge). + * The unit is BTC everywhere. + */ + +import { + computeCreatorChargeBtc, + checkAffordability, + settleAssistantCharge, +} from '@/services/ai/assistant-charge'; + +jest.mock('@/utils/logger', () => ({ + logger: { warn: jest.fn(), error: jest.fn(), info: jest.fn(), debug: jest.fn() }, +})); + +const appendCreditEntry = jest.fn(); +const getCreditBalance = jest.fn(); +jest.mock('@/services/cat/credits', () => ({ + appendCreditEntry: (...a: unknown[]) => appendCreditEntry(...a), + getCreditBalance: (...a: unknown[]) => getCreditBalance(...a), +})); + +// bumpAssistantRevenue reads then updates a counter row; a chainable stub is enough. +const revenueRow = { total_revenue: 0 }; +jest.mock('@/lib/supabase/untyped', () => ({ + fromTable: () => ({ + select: () => ({ eq: () => ({ single: async () => ({ data: revenueRow }) }) }), + update: () => ({ eq: async () => ({ data: null, error: null }) }), + }), +})); + +const admin = {} as never; + +beforeEach(() => { + jest.clearAllMocks(); + revenueRow.total_revenue = 0; +}); + +describe('computeCreatorChargeBtc', () => { + const perMessage = (price: number | null) => ({ + pricing_model: 'per_message', + price_per_message: price, + }); + + it('charges the per_message price for a paid message', () => { + expect(computeCreatorChargeBtc(perMessage(0.0001), false)).toBe(0.0001); + }); + + it('a free message costs nothing regardless of price', () => { + expect(computeCreatorChargeBtc(perMessage(0.0001), true)).toBe(0); + }); + + it('non-per_message pricing models are not metered yet → 0', () => { + expect(computeCreatorChargeBtc({ pricing_model: 'free', price_per_message: 0 }, false)).toBe(0); + expect( + computeCreatorChargeBtc({ pricing_model: 'per_token', price_per_message: 0.0001 }, false) + ).toBe(0); + expect( + computeCreatorChargeBtc({ pricing_model: 'subscription', price_per_message: 0.0001 }, false) + ).toBe(0); + }); + + it('null / zero / negative / non-finite prices bill 0', () => { + expect(computeCreatorChargeBtc(perMessage(null), false)).toBe(0); + expect(computeCreatorChargeBtc(perMessage(0), false)).toBe(0); + expect(computeCreatorChargeBtc(perMessage(-0.0001), false)).toBe(0); + expect(computeCreatorChargeBtc(perMessage(NaN), false)).toBe(0); + }); + + it('refuses a price above the sane max (mis-scaled legacy sats-in-BTC-column value)', () => { + // 100000 "sats" that never got divided by 1e8 would sit as 100000 in a BTC column. + expect(computeCreatorChargeBtc(perMessage(100000), false)).toBe(0); + // Just over the ceiling is still refused; exactly at a sane price bills. + expect(computeCreatorChargeBtc(perMessage(0.0100001), false)).toBe(0); + expect(computeCreatorChargeBtc(perMessage(0.01), false)).toBe(0.01); + }); + + it('rounds to satoshi precision (numeric(18,8))', () => { + // Sub-satoshi resolution is rounded away: 0.0000000260 BTC → 3 sats. + expect(computeCreatorChargeBtc(perMessage(0.000000026), false)).toBe(0.00000003); + // A whole-satoshi price passes through unchanged. + expect(computeCreatorChargeBtc(perMessage(0.00000004), false)).toBe(0.00000004); + }); +}); + +describe('checkAffordability', () => { + it('ok when balance covers the charge', async () => { + getCreditBalance.mockResolvedValue(0.001); + await expect(checkAffordability(admin, 'payer', 0.0005)).resolves.toEqual({ ok: true }); + }); + + it('exact balance is affordable', async () => { + getCreditBalance.mockResolvedValue(0.0005); + await expect(checkAffordability(admin, 'payer', 0.0005)).resolves.toEqual({ ok: true }); + }); + + it('reports the balance when funds are short', async () => { + getCreditBalance.mockResolvedValue(0.0001); + await expect(checkAffordability(admin, 'payer', 0.0005)).resolves.toEqual({ + ok: false, + balance: 0.0001, + }); + }); +}); + +describe('settleAssistantCharge', () => { + const baseArgs = { + payerUserId: 'payer', + creatorUserId: 'creator', + assistantId: 'asst-1', + messageId: 'msg-1', + chargeBtc: 0.001, + model: 'some-model', + totalTokens: 1234, + }; + + it('debits the payer and grants the creator 95%, keeping 5% for the platform', async () => { + appendCreditEntry.mockResolvedValue(0.5); // non-null → debit landed + await settleAssistantCharge(admin, baseArgs); + + // Debit: negative usage entry, idempotent on the message id. + const debit = appendCreditEntry.mock.calls[0] as [unknown, string, any]; + expect(debit[1]).toBe('payer'); + expect(debit[2].kind).toBe('usage'); + expect(debit[2].amountBtc).toBeCloseTo(-0.001, 10); + expect(debit[2].ref).toBe('msg-1'); + + // Grant: creator gets exactly 95% of the gross, tagged as assistant revenue. + const grant = appendCreditEntry.mock.calls[1] as [unknown, string, any]; + expect(grant[1]).toBe('creator'); + expect(grant[2].kind).toBe('grant'); + expect(grant[2].amountBtc).toBeCloseTo(0.00095, 10); // 0.001 * 0.95 + expect(grant[2].ref).toBe('msg-1:creator'); + expect(grant[2].metadata.grossBtc).toBe(0.001); + + expect(appendCreditEntry).toHaveBeenCalledTimes(2); + }); + + it('does NOT pay the creator when the payer debit did not land (no money minted)', async () => { + appendCreditEntry.mockResolvedValueOnce(null); // debit returned null (race/dup/transient) + await settleAssistantCharge(admin, baseArgs); + + expect(appendCreditEntry).toHaveBeenCalledTimes(1); // debit only, no creator grant + expect(appendCreditEntry.mock.calls[0][1]).toBe('payer'); + }); + + it('does not pay when the creator is the payer (self-chat)', async () => { + appendCreditEntry.mockResolvedValue(0.5); + await settleAssistantCharge(admin, { ...baseArgs, creatorUserId: 'payer' }); + + // Only the debit; no self-payout. + expect(appendCreditEntry).toHaveBeenCalledTimes(1); + expect(appendCreditEntry.mock.calls[0][2].kind).toBe('usage'); + }); + + it('a failed creator payout does not throw (platform retains the share)', async () => { + appendCreditEntry + .mockResolvedValueOnce(0.5) // debit ok + .mockResolvedValueOnce(null); // creator grant fails + await expect(settleAssistantCharge(admin, baseArgs)).resolves.toBeUndefined(); + expect(appendCreditEntry).toHaveBeenCalledTimes(2); + }); +}); From dd700256769f92d6be61c70824db8fc21c49a545 Mon Sep 17 00:00:00 2001 From: Georgy Butaev <41178744+g-but@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:18:25 +0200 Subject: [PATCH 2/2] test(credits): cover Lightning top-up + ledger-wrapper money paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the two remaining untested money paths that go live with PLATFORM_NWC_URI: - credit-topup.ts (money IN): both entry points hard-gate on platformReceiveEnabled(); amounts are bounded (NaN/out-of-range never mint an invoice); settlement credits the top-up's recorded OWNER idempotently on payment_hash; an already-paid row is never credited twice; an unsettled invoice past its window expires instead of hanging pending. - credits.ts: the cat_credit_append / cat_credit_balance RPC contract — param mapping, numeric coercion, and the never-throw / return-null-or-0 rule callers rely on. (Atomicity/overdraw live in the SECURITY DEFINER RPC.) 33 new tests; money-path coverage now 46 across metering + charge + top-up. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../unit/services/cat/credit-topup.test.ts | 201 ++++++++++++++++++ __tests__/unit/services/cat/credits.test.ts | 89 ++++++++ 2 files changed, 290 insertions(+) create mode 100644 __tests__/unit/services/cat/credit-topup.test.ts create mode 100644 __tests__/unit/services/cat/credits.test.ts diff --git a/__tests__/unit/services/cat/credit-topup.test.ts b/__tests__/unit/services/cat/credit-topup.test.ts new file mode 100644 index 000000000..5ce841902 --- /dev/null +++ b/__tests__/unit/services/cat/credit-topup.test.ts @@ -0,0 +1,201 @@ +/** + * Cat Credits — Lightning top-up (money IN). + * + * This is the path that credits real Bitcoin into a user's ledger the moment + * PLATFORM_NWC_URI is provisioned. It had zero tests. This suite pins the rules + * that protect money and honesty of balance: + * - both entry points hard-gate on platformReceiveEnabled(), + * - top-up amounts are bounded (and NaN/out-of-range never mint an invoice), + * - settlement credits the top-up's recorded OWNER, idempotent on payment_hash, + * - an already-paid row is never credited twice, + * - an unsettled invoice past its window expires instead of hanging pending. + * BTC is the unit; sats appear only at the Lightning protocol boundary. + */ + +import { + initiateTopUp, + checkTopUp, + MIN_TOPUP_BTC, + MAX_TOPUP_BTC, +} from '@/services/cat/credit-topup'; + +jest.mock('@/utils/logger', () => ({ + logger: { warn: jest.fn(), error: jest.fn(), info: jest.fn(), debug: jest.fn() }, +})); + +const platformReceiveEnabled = jest.fn(); +const getPlatformNwcClient = jest.fn(); +jest.mock('@/lib/bitcoin/platform-wallet', () => ({ + platformReceiveEnabled: () => platformReceiveEnabled(), + getPlatformNwcClient: () => getPlatformNwcClient(), +})); + +const appendCreditEntry = jest.fn(); +const getCreditBalance = jest.fn(); +jest.mock('@/services/cat/credits', () => ({ + appendCreditEntry: (...a: unknown[]) => appendCreditEntry(...a), + getCreditBalance: (...a: unknown[]) => getCreditBalance(...a), +})); + +const adminFrom = jest.fn(); +jest.mock('@/lib/supabase/admin', () => ({ + getAdminClient: () => ({ from: (...a: unknown[]) => adminFrom(...a) }), +})); + +/** + * Minimal fluent query-builder stub. Chain methods return the builder; awaiting + * the builder (e.g. `.update().eq()`) resolves to `awaitResult`; `.single()` / + * `.maybeSingle()` resolve to the configured terminal. + */ +function qb(opts: { terminal?: unknown; awaitResult?: unknown } = {}) { + const terminal = opts.terminal ?? { data: null, error: null }; + const awaitResult = opts.awaitResult ?? { data: null, error: null }; + const builder: Record = { + insert: () => builder, + update: () => builder, + select: () => builder, + eq: () => builder, + order: () => builder, + limit: () => builder, + single: async () => terminal, + maybeSingle: async () => terminal, + then: (resolve: (v: unknown) => unknown) => resolve(awaitResult), + }; + return builder; +} + +function nwcClient(over: Record = {}) { + return { + makeInvoice: jest.fn().mockResolvedValue({ invoice: 'lnbc1...', payment_hash: 'ph_abc' }), + lookupInvoice: jest.fn().mockResolvedValue({ settled_at: null }), + disconnect: jest.fn(), + ...over, + }; +} + +beforeEach(() => { + jest.clearAllMocks(); + platformReceiveEnabled.mockReturnValue(true); +}); + +describe('initiateTopUp', () => { + it('returns null when top-up is not enabled (no wallet), without minting anything', async () => { + platformReceiveEnabled.mockReturnValue(false); + await expect(initiateTopUp('u1', 0.001)).resolves.toBeNull(); + expect(getPlatformNwcClient).not.toHaveBeenCalled(); + }); + + it.each([ + ['below the minimum', MIN_TOPUP_BTC / 2], + ['above the maximum', MAX_TOPUP_BTC * 2], + ['NaN', NaN], + ])('rejects an out-of-bounds amount (%s) before touching the wallet', async (_label, amt) => { + await expect(initiateTopUp('u1', amt as number)).resolves.toBeNull(); + expect(getPlatformNwcClient).not.toHaveBeenCalled(); + }); + + it('mints an invoice for the exact sats, records a pending top-up, and disconnects', async () => { + const client = nwcClient(); + getPlatformNwcClient.mockResolvedValue(client); + adminFrom.mockReturnValue(qb({ terminal: { data: { id: 'tp1' }, error: null } })); + + const res = await initiateTopUp('u1', 0.0005); // 50k sats + expect(res).toMatchObject({ + topupId: 'tp1', + bolt11: 'lnbc1...', + paymentHash: 'ph_abc', + amountBtc: 0.0005, + }); + // Sats only at the protocol boundary: 0.0005 BTC = 50_000 sats. + expect(client.makeInvoice).toHaveBeenCalledWith(50000, expect.any(String), expect.any(Number)); + expect(client.disconnect).toHaveBeenCalledTimes(1); + }); + + it('returns null (and still disconnects) when recording the top-up row fails', async () => { + const client = nwcClient(); + getPlatformNwcClient.mockResolvedValue(client); + adminFrom.mockReturnValue(qb({ terminal: { data: null, error: { message: 'db down' } } })); + + await expect(initiateTopUp('u1', 0.001)).resolves.toBeNull(); + expect(client.disconnect).toHaveBeenCalledTimes(1); + }); + + it('returns null when the platform wallet client is unavailable', async () => { + getPlatformNwcClient.mockResolvedValue(null); + await expect(initiateTopUp('u1', 0.001)).resolves.toBeNull(); + }); +}); + +describe('checkTopUp', () => { + const pendingRow = { + id: 'tp1', + user_id: 'owner', + amount_btc: 0.0005, + payment_hash: 'ph_abc', + status: 'pending', + expires_at: new Date(Date.now() + 3_600_000).toISOString(), + }; + + it('reports not_enabled when the platform wallet is unset', async () => { + platformReceiveEnabled.mockReturnValue(false); + await expect(checkTopUp('owner', 'tp1')).resolves.toEqual({ status: 'not_enabled' }); + }); + + it('reports not_found when the row does not belong to the caller', async () => { + adminFrom.mockReturnValue(qb({ terminal: { data: null } })); + await expect(checkTopUp('owner', 'nope')).resolves.toEqual({ status: 'not_found' }); + expect(getPlatformNwcClient).not.toHaveBeenCalled(); + }); + + it('an already-paid row returns its balance and is NOT credited again', async () => { + adminFrom.mockReturnValue(qb({ terminal: { data: { ...pendingRow, status: 'paid' } } })); + getCreditBalance.mockResolvedValue(0.0005); + + await expect(checkTopUp('owner', 'tp1')).resolves.toEqual({ + status: 'paid', + balanceBtc: 0.0005, + }); + expect(appendCreditEntry).not.toHaveBeenCalled(); + expect(getPlatformNwcClient).not.toHaveBeenCalled(); + }); + + it('credits the OWNER (idempotent on payment_hash) when the invoice has settled', async () => { + adminFrom.mockReturnValue(qb({ terminal: { data: pendingRow } })); + getPlatformNwcClient.mockResolvedValue( + nwcClient({ lookupInvoice: jest.fn().mockResolvedValue({ settled_at: 1_700_000_000 }) }) + ); + getCreditBalance.mockResolvedValue(0.0005); + + const res = await checkTopUp('owner', 'tp1'); + expect(res).toEqual({ status: 'paid', balanceBtc: 0.0005 }); + + expect(appendCreditEntry).toHaveBeenCalledTimes(1); + const [, creditedUser, entry] = appendCreditEntry.mock.calls[0] as [unknown, string, any]; + expect(creditedUser).toBe('owner'); // the row's owner, never the caller argument alone + expect(entry.kind).toBe('topup'); + expect(entry.amountBtc).toBe(0.0005); + expect(entry.ref).toBe('ph_abc'); // idempotency key = payment hash + }); + + it('stays pending on a transient lookup failure (client polls again)', async () => { + adminFrom.mockReturnValue(qb({ terminal: { data: pendingRow } })); + getPlatformNwcClient.mockResolvedValue( + nwcClient({ lookupInvoice: jest.fn().mockRejectedValue(new Error('relay timeout')) }) + ); + await expect(checkTopUp('owner', 'tp1')).resolves.toEqual({ status: 'pending' }); + expect(appendCreditEntry).not.toHaveBeenCalled(); + }); + + it('expires an unsettled invoice once its window has passed', async () => { + adminFrom.mockReturnValue( + qb({ + terminal: { + data: { ...pendingRow, expires_at: new Date(Date.now() - 1000).toISOString() }, + }, + }) + ); + getPlatformNwcClient.mockResolvedValue(nwcClient()); // settled_at: null + await expect(checkTopUp('owner', 'tp1')).resolves.toEqual({ status: 'expired' }); + expect(appendCreditEntry).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/unit/services/cat/credits.test.ts b/__tests__/unit/services/cat/credits.test.ts new file mode 100644 index 000000000..7f343a70e --- /dev/null +++ b/__tests__/unit/services/cat/credits.test.ts @@ -0,0 +1,89 @@ +/** + * Cat Credits ledger primitives (credits.ts). + * + * The atomicity / overdraw / idempotency logic lives in the SECURITY DEFINER + * `cat_credit_append` Postgres RPC (covered by DB migrations, not Jest). What + * this thin wrapper owns — and what every money path depends on — is the RPC + * contract: correct RPC name + param mapping, numeric coercion of the result, + * and the "never throw; unavailable reads as no-credits / failed-write" rule. + */ + +import { getCreditBalance, appendCreditEntry } from '@/services/cat/credits'; + +jest.mock('@/utils/logger', () => ({ + logger: { warn: jest.fn(), error: jest.fn(), info: jest.fn(), debug: jest.fn() }, +})); + +const rpc = jest.fn(); +const supabase = { rpc: (...a: unknown[]) => rpc(...a) } as never; + +beforeEach(() => jest.clearAllMocks()); + +describe('getCreditBalance', () => { + it('reads the balance via cat_credit_balance', async () => { + rpc.mockResolvedValue({ data: 0.0025, error: null }); + await expect(getCreditBalance(supabase, 'u1')).resolves.toBe(0.0025); + expect(rpc).toHaveBeenCalledWith('cat_credit_balance', { p_user_id: 'u1' }); + }); + + it('coerces a numeric-string balance', async () => { + rpc.mockResolvedValue({ data: '0.001', error: null }); + await expect(getCreditBalance(supabase, 'u1')).resolves.toBe(0.001); + }); + + it('returns 0 (never throws) on RPC error', async () => { + rpc.mockResolvedValue({ data: null, error: { message: 'rls' } }); + await expect(getCreditBalance(supabase, 'u1')).resolves.toBe(0); + }); + + it('returns 0 when the RPC throws', async () => { + rpc.mockRejectedValue(new Error('network')); + await expect(getCreditBalance(supabase, 'u1')).resolves.toBe(0); + }); +}); + +describe('appendCreditEntry', () => { + it('maps the entry to cat_credit_append params and returns the new balance', async () => { + rpc.mockResolvedValue({ data: 0.003, error: null }); + const balance = await appendCreditEntry(supabase, 'u1', { + kind: 'topup', + amountBtc: 0.001, + ref: 'ph_1', + metadata: { source: 'lightning' }, + }); + expect(balance).toBe(0.003); + expect(rpc).toHaveBeenCalledWith('cat_credit_append', { + p_user_id: 'u1', + p_kind: 'topup', + p_amount_btc: 0.001, + p_ref: 'ph_1', + p_metadata: { source: 'lightning' }, + }); + }); + + it('defaults optional ref and metadata to null', async () => { + rpc.mockResolvedValue({ data: 0, error: null }); + await appendCreditEntry(supabase, 'u1', { kind: 'usage', amountBtc: -0.0001 }); + expect(rpc).toHaveBeenCalledWith( + 'cat_credit_append', + expect.objectContaining({ p_ref: null, p_metadata: null }) + ); + }); + + it('returns null on RPC error (incl. insufficient_credits) — never throws at the caller', async () => { + rpc.mockResolvedValue({ + data: null, + error: { code: '23514', message: 'insufficient_credits' }, + }); + await expect( + appendCreditEntry(supabase, 'u1', { kind: 'usage', amountBtc: -1 }) + ).resolves.toBeNull(); + }); + + it('returns null when the RPC throws', async () => { + rpc.mockRejectedValue(new Error('boom')); + await expect( + appendCreditEntry(supabase, 'u1', { kind: 'grant', amountBtc: 0.001 }) + ).resolves.toBeNull(); + }); +});