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:
@@ -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,
|
||||
|
||||
@@ -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)}¤cy=usd`,
|
||||
`/v1/billing/balance?user=${encodeURIComponent(subject)}¤cy=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, {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user