fix(billing): auto-$5 starter credit on first chat + per-user billing subject

New-account chat now works end to end, fail-closed, no shared-org bleed:

- resolveHanzoCloudKey computes the canonical per-user billing subject
  (object.BillingSubject parity: 'owner/name' for the shared hanzo catch-all),
  stamps it on req.user, and ensures the one-time $5 Commerce starter credit on
  THAT subject (POST /v1/billing/grant-starter) BEFORE forwarding the user's
  hk- key — so the gateway's first balance check sees $5 instead of 402-ing.
  Idempotent (in-process + commerce tag-dedupe), best-effort (gateway enforces).
- CommerceClient: checkBalance/tier/breakdown key on the subject and derive the
  X-Hanzo-Org namespace from it; replace the wrong-ledger createTrialGrant
  (credit-grants, invisible to /billing/balance) with grantStarter (Deposit).
- balanceMethods gate keys on the per-user subject (req.user.billingSubject).
- Drop chat-side Commerce usage write (single debit = the gateway).
- Remove the dead createTrialGrant call in user.ts (never fired for SSO users,
  wrong ledger).
This commit is contained in:
Hanzo AI
2026-06-27 17:57:43 -07:00
parent 0becb68c80
commit 44d41f6235
6 changed files with 200 additions and 85 deletions
+15 -6
View File
@@ -1,5 +1,6 @@
const { logger } = require('@librechat/data-schemas');
const { ViolationTypes } = require('librechat-data-provider');
const { billingSubject } = require('@hanzochat/api');
const { createAutoRefillTransaction } = require('./Transaction');
const { logViolation } = require('~/cache');
const { getMultiplier } = require('./tx');
@@ -106,16 +107,24 @@ const checkBalanceRecord = async function ({
const commerceClient = getCommerceClient();
const billingOrg = (req?.user?.organization ?? '').toString().trim();
// Per-user billing subject — prefer the one resolveHanzoCloudKey stamped from
// the authoritative IAM identity; otherwise derive it from organization + email
// (name == email for individual signups). This keys the gate on the SAME
// account the gateway debits (per-user for the shared "hanzo" catch-all), not
// the shared org balance.
const subject =
(req?.user?.billingSubject ?? '').toString().trim() ||
billingSubject(billingOrg, req?.user?.email);
// Commerce-first authoritative gate (per-org, fail closed).
if (commerceClient && billingOrg) {
// Commerce-first authoritative gate (per-subject, fail closed).
if (commerceClient && subject) {
let commerceBalance;
try {
commerceBalance = await commerceClient.checkBalance(billingOrg);
commerceBalance = await commerceClient.checkBalance(subject);
} catch (err) {
logger.error('[Balance.check] Commerce unreachable — blocking (fail-closed)', {
user,
billingOrg,
subject,
error: err.message,
});
return { canSpend: false, balance: 0, tokenCost, reason: 'commerce_unavailable' };
@@ -126,7 +135,7 @@ const checkBalanceRecord = async function ({
if (available < minCents) {
logger.debug('[Balance.check] Commerce balance insufficient', {
user,
billingOrg,
subject,
available,
minCents,
});
@@ -136,7 +145,7 @@ const checkBalanceRecord = async function ({
// Tier/model access (fail-open: a tier hiccup must not block a funded user).
if (model) {
try {
const modelAccess = await commerceClient.isModelAllowed(billingOrg, model);
const modelAccess = await commerceClient.isModelAllowed(subject, model);
if (!modelAccess.allowed) {
return {
canSpend: false,
+55 -34
View File
@@ -43,14 +43,31 @@ class CommerceClient {
}
/**
* Check user's balance. Returns cached result if fresh, triggers async
* refresh if stale, synchronous fetch on cache miss. Fails open on error.
* The Commerce namespace (X-Hanzo-Org) for a billing subject. The subject is
* object.BillingSubject(owner, name): "owner/name" (per-user) or "owner"
* (pooled) — so the namespace is always the part before the first "/", or the
* whole subject. Deriving it here keeps every read/write scoped to the right
* tenant without callers having to thread the org separately.
*
* @param {string} userId - Commerce user ID (e.g. "hanzo/alice")
* @param {string} subject
* @returns {string}
*/
_namespaceOf(subject) {
const s = (subject ?? '').toString();
const i = s.indexOf('/');
return i > 0 ? s.slice(0, i) : s;
}
/**
* Check a billing subject's balance. Returns cached result if fresh, triggers
* async refresh if stale, synchronous fetch on cache miss. Fails CLOSED (the
* cold-miss fetch throws) so the caller blocks rather than bleeding.
*
* @param {string} subject - Commerce billing subject (e.g. "hanzo/alice@gmail.com")
* @returns {Promise<{sufficient: boolean, available: number}>}
*/
async checkBalance(userId) {
const cached = this._balanceCache.get(userId);
async checkBalance(subject) {
const cached = this._balanceCache.get(subject);
const now = Date.now();
if (cached) {
@@ -61,13 +78,13 @@ class CommerceClient {
// Stale: serve cached, refresh async
if (!cached.refreshing) {
cached.refreshing = true;
this._fetchBalance(userId).catch(() => {});
this._fetchBalance(subject).catch(() => {});
}
return cached.data;
}
// Cache miss: synchronous fetch
return this._fetchBalance(userId);
return this._fetchBalance(subject);
}
/**
@@ -146,30 +163,31 @@ class CommerceClient {
}
/**
* Create a trial credit grant for a new user.
* Ensure a subject has the one-time $5 starter credit, idempotently.
*
* @param {string} userId - Commerce user ID
* @param {number} amountCents - Grant amount in cents (e.g. 500 = $5)
* @param {number} expiryDays - Days until expiry
* @param {string[]} [eligibility] - Meter IDs (empty = all meters)
* @returns {Promise<Object|null>} Grant object or null on failure
* This posts to /v1/billing/grant-starter, which creates a real Deposit
* transaction (tag "starter-credit", $5, 30-day expiry) — so it nets into
* GET /v1/billing/balance, the account the gateway gate reads and debits.
* (NOT a credit-grant record: those live in a separate ledger the balance
* endpoint does not read, so they would never unblock the gate.)
*
* Idempotent + race-safe in Commerce (tag-deduped inside a transaction): safe
* to call on every first chat; duplicate/concurrent calls never double-grant.
*
* @param {string} subject - Commerce billing subject (e.g. "hanzo/alice@gmail.com")
* @returns {Promise<{granted: boolean}|null>} or null on failure
*/
async createTrialGrant(userId, amountCents, expiryDays, eligibility = []) {
async grantStarter(subject) {
try {
const expiresIn = `${expiryDays * 24}h`;
const resp = await this._request('POST', '/v1/billing/credit-grants', {
userId,
name: 'Trial Credit',
amountCents,
currency: 'usd',
expiresIn,
priority: 100, // Trial burns before purchased (200)
eligibility,
tags: 'trial',
});
const resp = await this._request(
'POST',
'/v1/billing/grant-starter',
{ user: subject, trigger: 'chat_first_use' },
this._namespaceOf(subject),
);
return resp;
} catch (err) {
logger.error('[CommerceClient] Failed to create trial grant', err);
logger.error('[CommerceClient] Failed to ensure starter credit', err);
return null;
}
}
@@ -185,6 +203,8 @@ class CommerceClient {
const resp = await this._request(
'GET',
`/v1/billing/credit-balance/breakdown?userId=${encodeURIComponent(userId)}`,
undefined,
this._namespaceOf(userId),
);
const breakdown = resp.breakdown || {};
return {
@@ -200,24 +220,25 @@ class CommerceClient {
// ── Internal methods ──
async _fetchBalance(userId) {
async _fetchBalance(subject) {
// FAIL CLOSED: this is the money gate. On error we THROW so the caller blocks
// the request rather than letting unfunded/unknown users spend. The cache
// (serve-stale on refresh) smooths transient blips for already-known users;
// only a cold miss + error propagates. `userId` is the billing ORG slug —
// we stamp it as both the `user=` selector and the X-Hanzo-Org namespace so
// the read is correctly scoped per-org (matches cloud-api's keying).
// only a cold miss + error propagates. `subject` is the billing account
// (object.BillingSubject) used as `?user=`; the namespace (X-Hanzo-Org) is
// its org prefix — matching the gateway's keying so chat reads the SAME
// account the gateway debits.
const resp = await this._request(
'GET',
`/v1/billing/balance?user=${encodeURIComponent(userId)}&currency=usd`,
`/v1/billing/balance?user=${encodeURIComponent(subject)}&currency=usd`,
undefined,
userId,
this._namespaceOf(subject),
);
const data = {
sufficient: (resp.available || 0) > 0,
available: resp.available || 0,
};
this._balanceCache.set(userId, {
this._balanceCache.set(subject, {
data,
fetchedAt: Date.now(),
refreshing: false,
@@ -231,7 +252,7 @@ class CommerceClient {
if (tierName) {
url += `&tier=${encodeURIComponent(tierName)}`;
}
const resp = await this._request('GET', url);
const resp = await this._request('GET', url, undefined, this._namespaceOf(userId));
const tier = resp.tier || null;
if (tier) {
this._tierCache.set(userId, {
+5 -20
View File
@@ -139,26 +139,11 @@ export async function recordCollectedUsage(
});
}
// Fire usage to Commerce if configured (fire-and-forget)
try {
// Dynamic require to avoid hard dependency — CommerceClient is in the api layer
const { getCommerceClient } = require('~/server/services/CommerceClient');
const commerceClient = getCommerceClient();
if (commerceClient && user) {
// Estimate cost in cents from total tokens (rough: 1M tokenCredits = $1 = 100 cents)
const totalTokens = input_tokens + total_output_tokens;
const estimatedCents = Math.ceil(totalTokens / 10000); // 10,000 tokens ≈ 1 cent
commerceClient.recordUsage({
userId: user,
model: model || 'unknown',
promptTokens: input_tokens,
completionTokens: total_output_tokens,
amountCents: estimatedCents,
});
}
} catch {
// Commerce not available — local tracking is authoritative
}
// NOTE: Commerce is debited exactly once, by the cloud gateway (api.hanzo.ai),
// against the user's per-user billing subject when their hk- key is forwarded.
// Chat must NOT also record usage to Commerce here — that was a second debit
// (to a mis-keyed account) and breaks the single-debit money path. Local
// token accounting above remains for the in-app usage display.
return {
input_tokens,
@@ -41,6 +41,12 @@ export type HanzoBillingUser = {
provider?: string;
/** Guest (anonymous preview) users carry this and must NOT get a per-user key. */
guest?: boolean;
/**
* Canonical Commerce billing subject (object.BillingSubject), stamped by
* resolveHanzoCloudKey from the authoritative IAM record. The balance gate
* reads this so it keys on the SAME account the gateway debits.
*/
billingSubject?: string | null;
};
const KEY_TTL_MS = 10 * 60 * 1000; // 10 minutes
@@ -89,6 +95,107 @@ function basicAuthHeader(): string {
return `Basic ${Buffer.from(raw).toString('base64')}`;
}
// ── Per-user billing identity + starter credit ───────────────────────────────
/**
* Orgs whose MEMBERS are billed per-user (default: the shared "hanzo" catch-all,
* the home of every unaffiliated individual signup). MUST mirror the gateway's
* PERSONAL_BILLING_ORGS / object.BillingSubject (hanzoai/ai) so chat and the
* gateway derive the identical subject.
*/
function personalBillingOrgs(): Set<string> {
const raw = (
process.env.HANZO_PERSONAL_BILLING_ORGS ||
process.env.HANZO_DEFAULT_ORG ||
'hanzo'
)
.split(',')
.map((o) => o.trim().toLowerCase())
.filter(Boolean);
return new Set(raw.length ? raw : ['hanzo']);
}
/**
* Canonical Commerce billing subject for an IAM (owner, name) identity — the
* account the cloud gateway debits and reads. Personal-billing org → "owner/name"
* (per-user), pooled org → "owner". Always lowercased so it nets against the
* gateway's usage writes. Byte-identical to object.BillingSubject in hanzoai/ai.
*/
export function billingSubject(owner?: string | null, name?: string | null): string {
const o = (owner ?? '').toString().trim().toLowerCase();
if (!o) {
return '';
}
if (personalBillingOrgs().has(o)) {
const n = (name ?? '').toString().trim().toLowerCase();
return n ? `${o}/${n}` : o;
}
return o;
}
function commerceBaseUrl(): string {
return (process.env.COMMERCE_API_URL || process.env.COMMERCE_ENDPOINT || '').replace(/\/+$/, '');
}
function commerceToken(): string {
return process.env.COMMERCE_TOKEN || process.env.COMMERCE_API_TOKEN || '';
}
/** Subjects whose starter credit this process has already ensured (commerce is also idempotent). */
const starterEnsured = new Set<string>();
/**
* Ensure the new-user $5 welcome credit exists on THIS user's billing subject,
* exactly once. Idempotent two ways: an in-process set (avoids re-hitting
* commerce every key refresh) and commerce's own tag-deduped, transaction-guarded
* grant (`POST /v1/billing/grant-starter`) — so concurrent chats can never
* double-grant (no bleed).
*
* Best-effort but awaited: the credit must land on the subject BEFORE we forward
* the user's hk- key to the gateway, otherwise the gateway sees a $0 balance and
* 402s the very first chat. A commerce hiccup must NOT break key resolution,
* though — the gateway still enforces balance, and the next message retries.
*/
async function ensureStarterCredit(subject: string, owner: string): Promise<void> {
const base = commerceBaseUrl();
if (!base || !subject || starterEnsured.has(subject)) {
return;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), IAM_TIMEOUT_MS);
try {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-Hanzo-Org': owner,
};
const tok = commerceToken();
if (tok) {
headers['Authorization'] = `Bearer ${tok}`;
}
const resp = await fetch(`${base}/v1/billing/grant-starter`, {
method: 'POST',
headers,
body: JSON.stringify({ user: subject, trigger: 'chat_first_use' }),
signal: controller.signal,
});
if (resp.ok) {
starterEnsured.add(subject); // ensured this process; commerce dedupes across pods
} else {
logger.warn('[hanzoCloudKey] starter-credit grant non-OK (continuing)', {
subject,
status: resp.status,
});
}
} catch (err) {
logger.warn('[hanzoCloudKey] starter-credit grant failed (continuing; gateway enforces)', {
subject,
error: err instanceof Error ? err.message : String(err),
});
} finally {
clearTimeout(timeoutId);
}
}
/**
* Single IAM HTTP primitive (confidential-client Basic auth, `/v1/iam/*` JSON
* API). Throws on transport error / non-ok status so callers fail closed.
@@ -198,12 +305,21 @@ export async function resolveHanzoCloudKey(
// super-org "admin" for some users, which is NOT where their hk- key bills —
// so the downstream Commerce balance gate must use this resolved owner, not
// the login-time value. This keeps the key and the gate on ONE org.
const subject = billingSubject(record.owner, record.name);
try {
user.organization = record.owner;
// Stamp the canonical billing subject so the balance gate keys on the SAME
// account the gateway debits (per-user for the shared "hanzo" catch-all).
user.billingSubject = subject;
} catch {
/* req.user may be a frozen/lean doc — non-fatal; gate still has gateway as backstop */
}
// Ensure THIS user's one-time $5 welcome credit exists on THEIR subject
// before we hand back the key — so the gateway's first balance check sees it
// instead of 402-ing a brand-new account. Idempotent + best-effort.
await ensureStarterCredit(subject, record.owner);
let key = (record.accessKey ?? '').trim();
if (!key) {
// Mint on first chat — the key is theirs going forward.
@@ -1,2 +1,3 @@
export * from './config';
export * from './hanzoCloudKey';
export * from './initialize';
+8 -25
View File
@@ -105,35 +105,18 @@ export function createUserMethods(mongoose: typeof import('mongoose')) {
$set: setFields,
};
const balanceDoc = await Balance.findOneAndUpdate({ user: user._id }, update, {
await Balance.findOneAndUpdate({ user: user._id }, update, {
upsert: true,
new: true,
}).lean();
// Create trial credit grant in Commerce if configured (fire-and-forget)
try {
const { getCommerceClient } = require('~/server/services/CommerceClient');
const commerceClient = getCommerceClient?.();
if (commerceClient && balanceConfig.startBalance) {
// Derive Commerce userId from user data (org/username or email)
const commerceUserId = data.username
? `hanzo/${data.username}`
: data.email || String(user._id);
const amountCents = Math.round(balanceConfig.startBalance / 10000); // tokenCredits → cents
const expiryDays = balanceConfig.creditExpiryDays || 30;
// Store commerceUserId on the balance record
await Balance.findOneAndUpdate(
{ user: user._id },
{ $set: { commerceUserId, creditType: 'trial', tierId: 'free' } },
);
// Create trial grant in Commerce (async, non-blocking)
commerceClient.createTrialGrant(commerceUserId, amountCents, expiryDays).catch(() => {});
}
} catch {
// Commerce not available — local balance is sufficient
}
// NOTE: the real new-user $5 is the Commerce starter credit, granted
// idempotently on first chat by resolveHanzoCloudKey (→ /v1/billing/
// grant-starter), keyed on the user's per-user billing subject. It is NOT
// granted here: this createUser path does not fire for SSO/OIDC signups
// (which is how hanzo.chat users arrive), and the old credit-grants call
// wrote a separate ledger the balance gate never reads. The local
// tokenCredits above stay as the in-app display only.
}
if (returnUser) {