Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 172 additions & 0 deletions __tests__/unit/services/ai/assistant-charge.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
201 changes: 201 additions & 0 deletions __tests__/unit/services/cat/credit-topup.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {
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<string, unknown> = {}) {
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();
});
});
Loading
Loading