Files
chat/api/models/balanceMethods.spec.js
zooqueenandHanzo Dev 09610dc91d chat: ask Commerce for the balance with the header it authorizes on
The credits row was missing for a funded account. The balance is real --
Commerce holds $149,863.17 for the hanzo org -- and chat could never read it:
CommerceClient scoped its service-token call with `X-Hanzo-Org`, which
Commerce does not read on that path. Every call answered 401 "sign in to view
billing", byte-identical to sending no credential at all. The header is
`X-Org-Id`, as `api/billing/me.go` documents and as the LLM gate in
hanzoai/ai has always sent.

Proven against the live service from inside the pod, same token, same URL:
X-Hanzo-Org -> 401, X-Org-Id -> 200 {"available":14986317}.

Nothing caught this because three deliberate fallbacks sit behind it, each
doing exactly what it was written to do: checkBalance fails CLOSED and throws,
the balance controller falls through to the local tokenCredits ledger,
production does not fund that ledger (balance.enabled=false), the read 404s,
and the client renders no balance rather than a wrong one. A wrong header name
came out the far end as silence.

So the header is pinned where it is written, including the spelling that
failed, and the refusal case asserts a THROW rather than a zero -- a 401 must
never read as "this account has no money". Reverting the header turns 3 of the
5 red.

`ask.js` and CloudAgentsClient already sent `X-Org-Id`; this was the one
caller that did not.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-08 05:27:31 -07:00

123 lines
4.9 KiB
JavaScript

// Money-gate decision matrix for the Commerce-first, FAIL-CLOSED balance gate
// (per-org via service token + X-Org-Id; the cloud gateway debits the user's
// own hk- key against the SAME org). Proves both directions: unmetered AI never
// leaks (block on any ambiguity), and funded users + capped guests are never
// wrongly blocked.
const mockClient = {
checkBalance: jest.fn(),
isModelAllowed: jest.fn(),
};
jest.mock('@hanzochat/data-schemas', () => ({
logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() },
}));
jest.mock('~/server/services/CommerceClient', () => ({
getCommerceClient: jest.fn(() => mockClient),
}));
jest.mock('~/cache', () => ({ logViolation: jest.fn().mockResolvedValue(undefined) }));
jest.mock('./tx', () => ({ getMultiplier: () => 1 }));
jest.mock('./Transaction', () => ({ createAutoRefillTransaction: jest.fn() }));
jest.mock('~/db/models', () => ({ Balance: { findOne: jest.fn() } }));
jest.mock('@hanzochat/data-provider', () => ({
ViolationTypes: { TOKEN_BALANCE: 'token_balance' },
}));
const { checkBalance } = require('./balanceMethods');
const { getCommerceClient } = require('~/server/services/CommerceClient');
const { Balance } = require('~/db/models');
const argsFor = (user) => ({
req: { user },
res: {},
txData: { user: user?.id ?? 'u1', tokenType: 'prompt', amount: 100, model: 'zen4-mini' },
});
// checkBalance resolves `true` when allowed, throws a JSON error (carrying
// `reason`) when blocked.
const decide = async (user = { id: 'u1', organization: 'acme' }) => {
try {
const ok = await checkBalance(argsFor(user));
return { allowed: ok, reason: null };
} catch (err) {
let reason = null;
try {
reason = JSON.parse(err.message).reason;
} catch (_) {
reason = err.message;
}
return { allowed: false, reason };
}
};
describe('checkBalance — Commerce-first money gate (fail closed)', () => {
beforeEach(() => {
jest.clearAllMocks();
getCommerceClient.mockReturnValue(mockClient);
mockClient.isModelAllowed.mockResolvedValue({ allowed: true, tier: 't', allowedModels: ['*'] });
process.env.HANZO_MIN_BALANCE = '1.00'; // 100 cents minimum
});
it('ALLOWS a guest WITHOUT consulting Commerce (capped by IP limiter + guest key)', async () => {
const { allowed } = await decide({ id: 'g1', guest: true });
expect(allowed).toBe(true);
expect(mockClient.checkBalance).not.toHaveBeenCalled();
expect(Balance.findOne).not.toHaveBeenCalled();
});
it('ALLOWS a funded org (available >= min)', async () => {
mockClient.checkBalance.mockResolvedValue({ sufficient: true, available: 500 });
const { allowed, reason } = await decide();
expect(allowed).toBe(true);
expect(reason).toBeNull();
expect(mockClient.checkBalance).toHaveBeenCalledWith('acme');
});
it('BLOCKS commerce_insufficient when balance below minimum ($0 → claim $5)', async () => {
mockClient.checkBalance.mockResolvedValue({ sufficient: false, available: 0 });
const { allowed, reason } = await decide();
expect(allowed).toBe(false);
expect(reason).toBe('commerce_insufficient');
});
it('BLOCKS commerce_insufficient when balance is a tiny non-zero below min', async () => {
mockClient.checkBalance.mockResolvedValue({ sufficient: true, available: 50 }); // $0.50 < $1
const { allowed, reason } = await decide();
expect(allowed).toBe(false);
expect(reason).toBe('commerce_insufficient');
});
it('FAILS CLOSED (commerce_unavailable) when Commerce throws — never bleeds', async () => {
mockClient.checkBalance.mockRejectedValue(new Error('ECONNREFUSED'));
const { allowed, reason } = await decide();
expect(allowed).toBe(false);
expect(reason).toBe('commerce_unavailable');
});
it('BLOCKS model_not_allowed for a funded org whose tier forbids the model', async () => {
mockClient.checkBalance.mockResolvedValue({ sufficient: true, available: 500 });
mockClient.isModelAllowed.mockResolvedValue({
allowed: false,
tier: 'free',
allowedModels: ['zen3-nano'],
});
const { allowed, reason } = await decide();
expect(allowed).toBe(false);
expect(reason).toBe('model_not_allowed');
});
it('ALLOWS a funded org even if the tier check itself errors (fail-open tier, balance ok)', async () => {
mockClient.checkBalance.mockResolvedValue({ sufficient: true, available: 500 });
mockClient.isModelAllowed.mockRejectedValue(new Error('tier-check 500'));
const { allowed } = await decide();
expect(allowed).toBe(true);
});
it('FAILS CLOSED for an authed user with NO billing org (no commerce branch, no local credits)', async () => {
Balance.findOne.mockReturnValue({ lean: () => Promise.resolve(null) });
const { allowed } = await decide({ id: 'u2', organization: '' });
expect(allowed).toBe(false);
expect(mockClient.checkBalance).not.toHaveBeenCalled();
});
});