From a6f462b8508d93cdeecf753f65bbbaab3e02cc57 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Mon, 24 Aug 2026 11:01:13 -0500 Subject: [PATCH 1/6] refactor(credits): route every dollar conversion through one constant Preparation for the micro-dollar ledger (recoupable/chat#2000). No behaviour change: CREDITS_PER_USD is 100, exactly what the scattered `* 100` and `/ 100` meant. The point is to make the eventual unit change a one-line edit instead of a hunt. Before this, what a credit is worth was implied in three different expressions and two magic grant totals; after it, there is one definition and everything derives from it. DEFAULT_CREDITS and PRO_CREDITS become derived from their dollar values, so they stay $3.33 and $99.99 through a unit change rather than silently becoming a ten-thousandth of that. usdToCredits keeps the minimum-one-credit rule verbatim. My first version returned zero for a zero cost, which an existing test caught: a request that reached a model is chargeable even when the gateway reports no cost, and returning zero there would make an unpriced model free. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017fSvwazBitPfsTQvVqpi8q --- lib/credits/__tests__/creditUnit.test.ts | 36 +++++++++++++++++++ lib/credits/const.ts | 14 ++++++-- lib/credits/creditUnit.ts | 44 ++++++++++++++++++++++++ lib/credits/formatCentsAsUsd.ts | 12 +++++-- lib/credits/handleChatCredits.ts | 3 +- 5 files changed, 102 insertions(+), 7 deletions(-) create mode 100644 lib/credits/__tests__/creditUnit.test.ts create mode 100644 lib/credits/creditUnit.ts diff --git a/lib/credits/__tests__/creditUnit.test.ts b/lib/credits/__tests__/creditUnit.test.ts new file mode 100644 index 000000000..d8807541e --- /dev/null +++ b/lib/credits/__tests__/creditUnit.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from "vitest"; +import { CREDITS_PER_USD, usdToCredits, creditsToUsd } from "../creditUnit"; + +describe("credit unit", () => { + it("is a cent today", () => { + expect(CREDITS_PER_USD).toBe(100); + }); + + it("converts dollars to credits", () => { + expect(usdToCredits(1)).toBe(CREDITS_PER_USD); + expect(usdToCredits(0.12)).toBe(12); + }); + + it("never charges zero for work that cost money", () => { + // A fraction of the smallest unit still represents real provider spend. + expect(usdToCredits(0.000001)).toBe(1); + }); + + it("charges at least one credit even for a zero cost", () => { + // Existing chat behaviour, preserved: a request that reached a model is + // chargeable even when the gateway reports no cost. Returning zero would + // make an unpriced model free. + expect(usdToCredits(0)).toBe(1); + }); + + it("round-trips a whole-unit amount", () => { + expect(creditsToUsd(usdToCredits(4.12))).toBeCloseTo(4.12, 10); + }); + + it("keeps the two directions consistent at the unit boundary", () => { + // Whatever the unit is, one dollar must be CREDITS_PER_USD credits and + // CREDITS_PER_USD credits must be one dollar. A change to the constant + // that breaks this breaks billing in both directions at once. + expect(creditsToUsd(CREDITS_PER_USD)).toBe(1); + }); +}); diff --git a/lib/credits/const.ts b/lib/credits/const.ts index 4cb6405ad..5b0b17823 100644 --- a/lib/credits/const.ts +++ b/lib/credits/const.ts @@ -1,15 +1,23 @@ +import { CREDITS_PER_USD } from "@/lib/credits/creditUnit"; + /** - * Monthly credit allotment for free-tier accounts. + * Monthly credit allotment for free-tier accounts, as dollars. * Matches `chat/lib/consts.ts` so the chat sidebar and the public API agree. */ -export const DEFAULT_CREDITS = 333; +export const DEFAULT_CREDITS_USD = 3.33; + +/** Free-tier allotment in credits. Derived, so it survives a unit change. */ +export const DEFAULT_CREDITS = Math.round(DEFAULT_CREDITS_USD * CREDITS_PER_USD); /** * Monthly credit allotment for accounts on a pro plan (directly, via an * organization, or via an enterprise email domain). Effectively "don't think * about credits" for paying customers. */ -export const PRO_CREDITS = 9999; +export const PRO_CREDITS_USD = 99.99; + +/** Pro allotment in credits. Derived, so it survives a unit change. */ +export const PRO_CREDITS = Math.round(PRO_CREDITS_USD * CREDITS_PER_USD); /** * Where a caller that ran out of credits is pointed. A constant, so a 402 diff --git a/lib/credits/creditUnit.ts b/lib/credits/creditUnit.ts new file mode 100644 index 000000000..53c06395b --- /dev/null +++ b/lib/credits/creditUnit.ts @@ -0,0 +1,44 @@ +/** + * Credits in one US dollar. + * + * The single definition of what a credit is worth. Every conversion between + * credits and dollars goes through `usdToCredits` or `creditsToUsd` below, so + * changing the unit is changing this number and nothing else. + * + * Today a credit is a cent. chat#2000 proposes a micro-dollar (1_000_000), + * which is fine enough to price-match providers on micropurchases: at fal's + * $0.002 per second, one cent buys five seconds of audio and anything cheaper + * cannot be charged without rounding to zero or up past cost. + * + * This must match `chat/lib/credits/creditUnit.ts` and the values stored in + * `credits_usage.remaining_credits`. Changing it without the matching database + * rescale misprices every charge by the ratio between the two. + */ +export const CREDITS_PER_USD = 100; + +/** + * Credits for a dollar amount. + * + * Always at least one credit, including for a zero cost. That is the existing + * chat behaviour rather than a new rule: a request that reached a model is + * chargeable even when the gateway reports no cost, and returning zero there + * would make an unpriced model free. Callers that genuinely want zero for zero + * — music, where a failed generation costs nothing — decide that themselves + * before calling. + * + * @param usd - Cost in dollars. + * @returns Whole credits, minimum 1. + */ +export function usdToCredits(usd: number): number { + return Math.max(1, Math.round(usd * CREDITS_PER_USD)); +} + +/** + * Dollar value of a credit amount. + * + * @param credits - Credit amount. + * @returns Dollars, unformatted. + */ +export function creditsToUsd(credits: number): number { + return credits / CREDITS_PER_USD; +} diff --git a/lib/credits/formatCentsAsUsd.ts b/lib/credits/formatCentsAsUsd.ts index 9b229312b..f6809eece 100644 --- a/lib/credits/formatCentsAsUsd.ts +++ b/lib/credits/formatCentsAsUsd.ts @@ -1,9 +1,15 @@ +import { creditsToUsd } from "@/lib/credits/creditUnit"; + /** - * Formats an integer cent amount as a USD string. + * Formats a credit amount as a USD string. * - * @param cents - Amount in cents (e.g. 412). + * Named for cents because that is what a credit is worth today; the + * conversion goes through `creditsToUsd` so the name is the only thing that + * has to change when the unit does (recoupable/chat#2000). + * + * @param cents - Credit amount (e.g. 412). * @returns USD string (e.g. "$4.12"). */ export function formatCentsAsUsd(cents: number): string { - return `$${(cents / 100).toFixed(2)}`; + return `$${creditsToUsd(cents).toFixed(2)}`; } diff --git a/lib/credits/handleChatCredits.ts b/lib/credits/handleChatCredits.ts index 98c8ecdbc..34175cb09 100644 --- a/lib/credits/handleChatCredits.ts +++ b/lib/credits/handleChatCredits.ts @@ -1,4 +1,5 @@ import { getCreditUsage } from "./getCreditUsage"; +import { usdToCredits } from "@/lib/credits/creditUnit"; import { recordCreditDeduction } from "./recordCreditDeduction"; import { LanguageModelUsage } from "ai"; @@ -49,7 +50,7 @@ export const handleChatCredits = async ({ try { const usageCost = await getCreditUsage(usage, model, gatewayCostUsd); - const creditsToDeduct = Math.max(1, Math.round(usageCost * 100)); + const creditsToDeduct = usdToCredits(usageCost); await recordCreditDeduction({ accountId, From 55480ea3a204899de085271141afb60f751070db Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 26 Aug 2026 20:53:30 -0500 Subject: [PATCH 2/6] docs(credits): getCreditUsage docstring points at usdToCredits --- lib/credits/getCreditUsage.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/credits/getCreditUsage.ts b/lib/credits/getCreditUsage.ts index 26eb9180b..e9bd865bc 100644 --- a/lib/credits/getCreditUsage.ts +++ b/lib/credits/getCreditUsage.ts @@ -12,7 +12,7 @@ import { LanguageModelUsage } from "ai"; * 2. Token-based estimate using `model.pricing.input/output` from * the gateway catalog (`getModel`). Authoritative for token cost. * 3. `0` when nothing prices the turn (caller floors to the 1c - * minimum via `Math.max(1, Math.round(usd * 100))`). + * minimum via `usdToCredits`, which floors at one credit). * * @param usage - The language model usage data * @param modelId - The ID of the model used From befd90ec5f67360532d8f8adffe572557957da6b Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 26 Aug 2026 20:59:54 -0500 Subject: [PATCH 3/6] feat(credits): one module per export; floor at the ledger unit; grant totals via usdToCredits CREDITS_PER_USD, usdToCredits and creditsToUsd each live in a file named after them (house rule, and both reviewers asked). DEFAULT_CREDITS and PRO_CREDITS derive through usdToCredits instead of repeating the arithmetic. usdToCredits no longer rounds: it floors at the ledger unit after settling the product to six decimals, and keeps the one-unit minimum (a micro-dollar after the rescale). Owner decisions on recoupable/app#2000, 2026-08-27. --- lib/credits/__tests__/const.test.ts | 16 ++++---- lib/credits/__tests__/creditUnit.test.ts | 36 ------------------ lib/credits/__tests__/creditsToUsd.test.ts | 13 +++++++ lib/credits/__tests__/usdToCredits.test.ts | 27 +++++++++++++ lib/credits/const.ts | 6 +-- lib/credits/creditUnit.ts | 44 ---------------------- lib/credits/creditsPerUsd.ts | 14 +++++++ lib/credits/creditsToUsd.ts | 11 ++++++ lib/credits/formatCentsAsUsd.ts | 2 +- lib/credits/handleChatCredits.ts | 2 +- lib/credits/usdToCredits.ts | 22 +++++++++++ 11 files changed, 101 insertions(+), 92 deletions(-) delete mode 100644 lib/credits/__tests__/creditUnit.test.ts create mode 100644 lib/credits/__tests__/creditsToUsd.test.ts create mode 100644 lib/credits/__tests__/usdToCredits.test.ts delete mode 100644 lib/credits/creditUnit.ts create mode 100644 lib/credits/creditsPerUsd.ts create mode 100644 lib/credits/creditsToUsd.ts create mode 100644 lib/credits/usdToCredits.ts diff --git a/lib/credits/__tests__/const.test.ts b/lib/credits/__tests__/const.test.ts index 10438429d..b0733a8f0 100644 --- a/lib/credits/__tests__/const.test.ts +++ b/lib/credits/__tests__/const.test.ts @@ -1,13 +1,15 @@ -import { describe, expect, it } from "vitest"; +import { describe, it, expect } from "vitest"; +import { DEFAULT_CREDITS, DEFAULT_CREDITS_USD, PRO_CREDITS, PRO_CREDITS_USD } from "../const"; +import { usdToCredits } from "../usdToCredits"; -import { DEFAULT_CREDITS, PRO_CREDITS } from "@/lib/credits/const"; - -describe("credit plan constants", () => { - it("keeps the free-tier allotment at 333 (matches chat/lib/consts.ts)", () => { - expect(DEFAULT_CREDITS).toBe(333); +describe("credit grant totals", () => { + it("derive from their dollar values through the shared conversion", () => { + expect(DEFAULT_CREDITS).toBe(usdToCredits(DEFAULT_CREDITS_USD)); + expect(PRO_CREDITS).toBe(usdToCredits(PRO_CREDITS_USD)); }); - it("gives pro accounts 9999 credits per month (matches chat/lib/consts.ts)", () => { + it("are $3.33 and $99.99 at today's unit", () => { + expect(DEFAULT_CREDITS).toBe(333); expect(PRO_CREDITS).toBe(9999); }); }); diff --git a/lib/credits/__tests__/creditUnit.test.ts b/lib/credits/__tests__/creditUnit.test.ts deleted file mode 100644 index d8807541e..000000000 --- a/lib/credits/__tests__/creditUnit.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { CREDITS_PER_USD, usdToCredits, creditsToUsd } from "../creditUnit"; - -describe("credit unit", () => { - it("is a cent today", () => { - expect(CREDITS_PER_USD).toBe(100); - }); - - it("converts dollars to credits", () => { - expect(usdToCredits(1)).toBe(CREDITS_PER_USD); - expect(usdToCredits(0.12)).toBe(12); - }); - - it("never charges zero for work that cost money", () => { - // A fraction of the smallest unit still represents real provider spend. - expect(usdToCredits(0.000001)).toBe(1); - }); - - it("charges at least one credit even for a zero cost", () => { - // Existing chat behaviour, preserved: a request that reached a model is - // chargeable even when the gateway reports no cost. Returning zero would - // make an unpriced model free. - expect(usdToCredits(0)).toBe(1); - }); - - it("round-trips a whole-unit amount", () => { - expect(creditsToUsd(usdToCredits(4.12))).toBeCloseTo(4.12, 10); - }); - - it("keeps the two directions consistent at the unit boundary", () => { - // Whatever the unit is, one dollar must be CREDITS_PER_USD credits and - // CREDITS_PER_USD credits must be one dollar. A change to the constant - // that breaks this breaks billing in both directions at once. - expect(creditsToUsd(CREDITS_PER_USD)).toBe(1); - }); -}); diff --git a/lib/credits/__tests__/creditsToUsd.test.ts b/lib/credits/__tests__/creditsToUsd.test.ts new file mode 100644 index 000000000..16479c271 --- /dev/null +++ b/lib/credits/__tests__/creditsToUsd.test.ts @@ -0,0 +1,13 @@ +import { describe, it, expect } from "vitest"; +import { creditsToUsd } from "../creditsToUsd"; +import { usdToCredits } from "../usdToCredits"; +import { CREDITS_PER_USD } from "../creditsPerUsd"; + +describe("creditsToUsd", () => { + it("keeps the two directions consistent at the unit boundary", () => { + // Whatever the unit is, one dollar must be CREDITS_PER_USD credits and + // CREDITS_PER_USD credits must be one dollar. + expect(creditsToUsd(CREDITS_PER_USD)).toBe(1); + expect(creditsToUsd(usdToCredits(4.12))).toBeCloseTo(4.12, 10); + }); +}); diff --git a/lib/credits/__tests__/usdToCredits.test.ts b/lib/credits/__tests__/usdToCredits.test.ts new file mode 100644 index 000000000..7f6cc63f6 --- /dev/null +++ b/lib/credits/__tests__/usdToCredits.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from "vitest"; +import { usdToCredits } from "../usdToCredits"; +import { CREDITS_PER_USD } from "../creditsPerUsd"; + +describe("usdToCredits", () => { + it("converts whole-unit amounts exactly, without floating-point drift", () => { + expect(usdToCredits(1)).toBe(CREDITS_PER_USD); + // 0.12 * 100 is 11.999999999999998 in IEEE-754; the ledger must see 12. + expect(usdToCredits(0.12)).toBe(12); + expect(usdToCredits(4.12)).toBe(412); + }); + + it("never rounds up: a fraction of the ledger unit is absorbed, not charged", () => { + // recoupable/app#2000, owner decision 2026-08-27: pass provider prices + // through; the ledger unit is the precision and any residue below one + // unit is ours. Under Math.round this would have been 2. + expect(usdToCredits(0.0199)).toBe(1); + expect(usdToCredits(0.0151)).toBe(1); + }); + + it("charges at least one ledger unit, even for a zero or sub-unit cost", () => { + // A request that reached a model is never free: the minimum is one + // credit, whatever the unit (one micro-dollar after the rescale). + expect(usdToCredits(0)).toBe(1); + expect(usdToCredits(0.000001)).toBe(1); + }); +}); diff --git a/lib/credits/const.ts b/lib/credits/const.ts index 5b0b17823..0a2e704bf 100644 --- a/lib/credits/const.ts +++ b/lib/credits/const.ts @@ -1,4 +1,4 @@ -import { CREDITS_PER_USD } from "@/lib/credits/creditUnit"; +import { usdToCredits } from "@/lib/credits/usdToCredits"; /** * Monthly credit allotment for free-tier accounts, as dollars. @@ -7,7 +7,7 @@ import { CREDITS_PER_USD } from "@/lib/credits/creditUnit"; export const DEFAULT_CREDITS_USD = 3.33; /** Free-tier allotment in credits. Derived, so it survives a unit change. */ -export const DEFAULT_CREDITS = Math.round(DEFAULT_CREDITS_USD * CREDITS_PER_USD); +export const DEFAULT_CREDITS = usdToCredits(DEFAULT_CREDITS_USD); /** * Monthly credit allotment for accounts on a pro plan (directly, via an @@ -17,7 +17,7 @@ export const DEFAULT_CREDITS = Math.round(DEFAULT_CREDITS_USD * CREDITS_PER_USD) export const PRO_CREDITS_USD = 99.99; /** Pro allotment in credits. Derived, so it survives a unit change. */ -export const PRO_CREDITS = Math.round(PRO_CREDITS_USD * CREDITS_PER_USD); +export const PRO_CREDITS = usdToCredits(PRO_CREDITS_USD); /** * Where a caller that ran out of credits is pointed. A constant, so a 402 diff --git a/lib/credits/creditUnit.ts b/lib/credits/creditUnit.ts deleted file mode 100644 index 53c06395b..000000000 --- a/lib/credits/creditUnit.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Credits in one US dollar. - * - * The single definition of what a credit is worth. Every conversion between - * credits and dollars goes through `usdToCredits` or `creditsToUsd` below, so - * changing the unit is changing this number and nothing else. - * - * Today a credit is a cent. chat#2000 proposes a micro-dollar (1_000_000), - * which is fine enough to price-match providers on micropurchases: at fal's - * $0.002 per second, one cent buys five seconds of audio and anything cheaper - * cannot be charged without rounding to zero or up past cost. - * - * This must match `chat/lib/credits/creditUnit.ts` and the values stored in - * `credits_usage.remaining_credits`. Changing it without the matching database - * rescale misprices every charge by the ratio between the two. - */ -export const CREDITS_PER_USD = 100; - -/** - * Credits for a dollar amount. - * - * Always at least one credit, including for a zero cost. That is the existing - * chat behaviour rather than a new rule: a request that reached a model is - * chargeable even when the gateway reports no cost, and returning zero there - * would make an unpriced model free. Callers that genuinely want zero for zero - * — music, where a failed generation costs nothing — decide that themselves - * before calling. - * - * @param usd - Cost in dollars. - * @returns Whole credits, minimum 1. - */ -export function usdToCredits(usd: number): number { - return Math.max(1, Math.round(usd * CREDITS_PER_USD)); -} - -/** - * Dollar value of a credit amount. - * - * @param credits - Credit amount. - * @returns Dollars, unformatted. - */ -export function creditsToUsd(credits: number): number { - return credits / CREDITS_PER_USD; -} diff --git a/lib/credits/creditsPerUsd.ts b/lib/credits/creditsPerUsd.ts new file mode 100644 index 000000000..eab9afc5e --- /dev/null +++ b/lib/credits/creditsPerUsd.ts @@ -0,0 +1,14 @@ +/** + * Credits in one US dollar: the single definition of what a credit is worth. + * + * Every conversion between credits and dollars goes through `usdToCredits` + * or `creditsToUsd`, so changing the unit is changing this number and nothing + * else. Today a credit is a cent. recoupable/app#2000 moves it to a + * micro-dollar (1_000_000), the same 6-decimal unit as USDC, so per-call + * pricing can pass provider prices through exactly. + * + * Must match `chat/lib/credits/creditsPerUsd.ts` and the values stored in + * `credits_usage.remaining_credits`; changing it without the matching database + * rescale misprices every charge by the ratio between the two. + */ +export const CREDITS_PER_USD = 100; diff --git a/lib/credits/creditsToUsd.ts b/lib/credits/creditsToUsd.ts new file mode 100644 index 000000000..ab94fe5fd --- /dev/null +++ b/lib/credits/creditsToUsd.ts @@ -0,0 +1,11 @@ +import { CREDITS_PER_USD } from "@/lib/credits/creditsPerUsd"; + +/** + * Dollar value of a credit amount. + * + * @param credits - Credit amount. + * @returns Dollars, unformatted. + */ +export function creditsToUsd(credits: number): number { + return credits / CREDITS_PER_USD; +} diff --git a/lib/credits/formatCentsAsUsd.ts b/lib/credits/formatCentsAsUsd.ts index f6809eece..6eb3b21cd 100644 --- a/lib/credits/formatCentsAsUsd.ts +++ b/lib/credits/formatCentsAsUsd.ts @@ -1,4 +1,4 @@ -import { creditsToUsd } from "@/lib/credits/creditUnit"; +import { creditsToUsd } from "@/lib/credits/creditsToUsd"; /** * Formats a credit amount as a USD string. diff --git a/lib/credits/handleChatCredits.ts b/lib/credits/handleChatCredits.ts index 34175cb09..68f2b8c8c 100644 --- a/lib/credits/handleChatCredits.ts +++ b/lib/credits/handleChatCredits.ts @@ -1,5 +1,5 @@ import { getCreditUsage } from "./getCreditUsage"; -import { usdToCredits } from "@/lib/credits/creditUnit"; +import { usdToCredits } from "@/lib/credits/usdToCredits"; import { recordCreditDeduction } from "./recordCreditDeduction"; import { LanguageModelUsage } from "ai"; diff --git a/lib/credits/usdToCredits.ts b/lib/credits/usdToCredits.ts new file mode 100644 index 000000000..9344eaf13 --- /dev/null +++ b/lib/credits/usdToCredits.ts @@ -0,0 +1,22 @@ +import { CREDITS_PER_USD } from "@/lib/credits/creditsPerUsd"; + +/** + * Credits for a dollar amount, in whole ledger units. + * + * Two rules, decided on recoupable/app#2000 (2026-08-27): + * - No rounding up. The ledger unit is the precision; a residue below one + * unit is absorbed by Recoup, never charged. The product is settled to six + * decimals first so IEEE-754 noise (0.12 * 100 = 11.999…) cannot floor a + * whole-unit amount down by one. + * - Minimum one unit. A request that reached a model is never free, even + * when the gateway reports no cost; after the rescale that floor is one + * micro-dollar. Callers that genuinely want zero for zero (music, where a + * failed generation costs nothing) decide that before calling. + * + * @param usd - Cost in dollars. + * @returns Whole credits, minimum 1. + */ +export function usdToCredits(usd: number): number { + const units = Number((usd * CREDITS_PER_USD).toFixed(6)); + return Math.max(1, Math.floor(units)); +} From 77df165c9d5f7f6d074ec3ae29c3a52b43b1af0b Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 26 Aug 2026 21:53:30 -0500 Subject: [PATCH 4/6] feat(credits): cut over to six decimals with viem; every fixed price in USD CREDIT_DECIMALS = 6 (the USDC unit) replaces the cents-per-dollar constant; usdToCredits / creditsToUsd are viem's parseUnits / formatUnits at that precision, truncating below the unit and flooring at one unit. Fixed prices move into PRICES_USD and are converted at the call site (research, social scrape, chat gate, x402 image); Stripe top-ups convert credits to whole cents for the line item and fee, and the request schema requires a whole number of cents. Tests move to the new unit. Owner decisions on recoupable/app#2000, 2026-08-27. Deploys in the same window as database#62. --- .../integration/chatEndToEnd.test.ts | 4 +-- lib/chat/validateChatRequest.ts | 4 ++- .../__tests__/checkAndResetCredits.test.ts | 32 +++++++++++++------ lib/credits/__tests__/const.test.ts | 6 ++-- lib/credits/__tests__/creditsToUsd.test.ts | 12 ++++--- .../__tests__/formatCreditSpendDigest.test.ts | 11 ++++--- .../getCreditSpendDigestHandler.test.ts | 6 ++-- .../__tests__/handleChatCredits.test.ts | 8 ++--- lib/credits/__tests__/usdToCredits.test.ts | 28 +++++++++------- lib/credits/creditDecimals.ts | 14 ++++++++ lib/credits/creditsPerUsd.ts | 14 -------- lib/credits/creditsToStripeCents.ts | 15 +++++++++ lib/credits/creditsToUsd.ts | 10 +++--- lib/credits/pricesUsd.ts | 20 ++++++++++++ lib/credits/usdToCredits.ts | 22 +++++++------ lib/research/__tests__/deductCredits.test.ts | 2 +- .../ensureWebResearchCredits.test.ts | 2 +- .../getResearchTrackHistoricStats.test.ts | 2 +- .../__tests__/getResearchTrackStats.test.ts | 2 +- .../__tests__/handleArtistResearch.test.ts | 2 +- lib/research/__tests__/handleResearch.test.ts | 2 +- .../postResearchEventsHandler.test.ts | 2 +- .../__tests__/postResearchWebHandler.test.ts | 2 +- .../validatePostResearchWebRequest.test.ts | 2 +- lib/research/deductCredits.ts | 4 ++- lib/research/ensureEventsResearchCredits.ts | 4 ++- lib/research/ensureResearchCredits.ts | 4 ++- lib/research/ensureWebResearchCredits.ts | 4 ++- lib/research/getResearchMetrics.ts | 8 ++++- lib/research/getResearchTrackHistoricStats.ts | 8 ++++- lib/research/getResearchTrackStats.ts | 8 ++++- lib/research/handleArtistResearch.ts | 11 ++++++- lib/research/handleResearch.ts | 4 ++- lib/research/postResearchDeepHandler.ts | 4 ++- lib/research/postResearchEnrichHandler.ts | 11 +++++-- lib/research/postResearchEventsHandler.ts | 4 ++- lib/research/postResearchExtractHandler.ts | 4 ++- lib/research/postResearchPeopleHandler.ts | 7 +++- lib/research/postResearchWebHandler.ts | 4 ++- .../validatePostResearchDeepRequest.ts | 4 ++- .../validatePostResearchEnrichRequest.ts | 11 +++++-- .../validatePostResearchExtractRequest.ts | 4 ++- .../deductSocialScrapeCredits.test.ts | 6 ++-- .../ensureSocialScrapeCredits.test.ts | 6 ++-- .../getSocialScrapeCreditCost.test.ts | 8 ++--- .../__tests__/postSocialScrapeHandler.test.ts | 8 ++--- .../validatePostSocialScrapeRequest.test.ts | 6 ++-- lib/socials/getSocialScrapeCreditCost.ts | 9 ++++-- .../computeCreditsTopupCharge.test.ts | 18 +++++++---- .../createCreditsSessionHandler.test.ts | 2 +- .../createCreditsSessionHandlerTestMocks.ts | 2 +- .../createCreditsSessionSchemas.test.ts | 23 +++++++++---- .../createCreditsStripeSession.test.ts | 14 ++++---- ...teCreateCreditsSessionRequest.auth.test.ts | 10 +++--- ...teCreateCreditsSessionRequest.body.test.ts | 5 +-- lib/stripe/computeCreditsTopupCharge.ts | 6 ++-- lib/stripe/createCreditsSessionSchemas.ts | 10 +++++- lib/stripe/createCreditsStripeSession.ts | 9 +++--- lib/x402/getCreditsForPrice.ts | 5 +-- 59 files changed, 320 insertions(+), 159 deletions(-) create mode 100644 lib/credits/creditDecimals.ts delete mode 100644 lib/credits/creditsPerUsd.ts create mode 100644 lib/credits/creditsToStripeCents.ts create mode 100644 lib/credits/pricesUsd.ts diff --git a/lib/chat/__tests__/integration/chatEndToEnd.test.ts b/lib/chat/__tests__/integration/chatEndToEnd.test.ts index 507eece0a..c1cfb41c9 100644 --- a/lib/chat/__tests__/integration/chatEndToEnd.test.ts +++ b/lib/chat/__tests__/integration/chatEndToEnd.test.ts @@ -373,7 +373,7 @@ describe("Chat Integration Tests", () => { expect(mockRecordCreditDeduction).toHaveBeenCalledWith( expect.objectContaining({ accountId: "account-123", - creditsToDeduct: 50, // 0.5 * 100 + creditsToDeduct: 500_000, // $0.50 in micro-dollars source: "web", }), ); @@ -431,7 +431,7 @@ describe("Chat Integration Tests", () => { expect(mockRecordCreditDeduction).toHaveBeenCalledWith( expect.objectContaining({ accountId: "account-123", - creditsToDeduct: 1, // Math.max(1, Math.round(0.001 * 100)) + creditsToDeduct: 1_000, // $0.001 in micro-dollars source: "web", }), ); diff --git a/lib/chat/validateChatRequest.ts b/lib/chat/validateChatRequest.ts index 12ad41ccf..195be434f 100644 --- a/lib/chat/validateChatRequest.ts +++ b/lib/chat/validateChatRequest.ts @@ -8,6 +8,8 @@ import { getMessages } from "@/lib/messages/getMessages"; import convertToUiMessages from "@/lib/messages/convertToUiMessages"; import { setupConversation } from "@/lib/chat/setupConversation"; import { validateMessages } from "@/lib/chat/validateMessages"; +import { usdToCredits } from "@/lib/credits/usdToCredits"; +import { PRICES_USD } from "@/lib/credits/pricesUsd"; export const chatRequestSchema = z .object({ @@ -99,7 +101,7 @@ export async function validateChatRequest( // billing; nothing is charged and no Stripe object is created. const short = await ensureCreditsOrShortCircuit({ accountId, - creditsToDeduct: 1, + creditsToDeduct: usdToCredits(PRICES_USD.chatMinimum), }); if (short) return short; diff --git a/lib/credits/__tests__/checkAndResetCredits.test.ts b/lib/credits/__tests__/checkAndResetCredits.test.ts index 3cb7843d0..105afa6da 100644 --- a/lib/credits/__tests__/checkAndResetCredits.test.ts +++ b/lib/credits/__tests__/checkAndResetCredits.test.ts @@ -154,7 +154,7 @@ describe("checkAndResetCredits", () => { }); it("reports isPro=true without refilling when sub is active but neither refill trigger fires", async () => { - const row = baseRow({ timestamp: "2026-05-01T00:00:00.000Z", remaining_credits: 800 }); + const row = baseRow({ timestamp: "2026-05-01T00:00:00.000Z", remaining_credits: 8_000_000 }); vi.mocked(selectCreditsUsage).mockResolvedValue([row]); vi.mocked(getAccountSubscriptionState).mockResolvedValue(proStateFromAccount); @@ -186,7 +186,10 @@ describe("checkAndResetCredits", () => { }); it("leaves a balance ABOVE the plan total untouched, and still advances the timestamp", async () => { - const row = baseRow({ timestamp: "2026-03-01T00:00:00.000Z", remaining_credits: 9999 }); + const row = baseRow({ + timestamp: "2026-03-01T00:00:00.000Z", + remaining_credits: PRO_CREDITS, + }); vi.mocked(selectCreditsUsage).mockResolvedValue([row]); vi.mocked(updateCreditsUsage).mockResolvedValue({ ...row, @@ -196,13 +199,13 @@ describe("checkAndResetCredits", () => { const result = await checkAndResetCredits(ACCOUNT); - // remaining_credits is absent from the update, not set to 9999: a stale + // remaining_credits is absent from the update, not set to PRO_CREDITS: a stale // read must not resurrect credits a concurrent deduction just spent. expect(updateCreditsUsage).toHaveBeenCalledWith({ account_id: ACCOUNT, updates: { timestamp: "2026-05-11T12:00:00.000Z" }, }); - expect(result.creditsUsage?.remaining_credits).toBe(9999); + expect(result.creditsUsage?.remaining_credits).toBe(PRO_CREDITS); }); it("writes only the timestamp when the balance is exactly the plan total", async () => { @@ -226,7 +229,10 @@ describe("checkAndResetCredits", () => { }); it("does not cut a pro account holding more than PRO_CREDITS", async () => { - const row = baseRow({ timestamp: "2026-03-01T00:00:00.000Z", remaining_credits: 25000 }); + const row = baseRow({ + timestamp: "2026-03-01T00:00:00.000Z", + remaining_credits: PRO_CREDITS + 1, + }); vi.mocked(selectCreditsUsage).mockResolvedValue([row]); vi.mocked(updateCreditsUsage).mockResolvedValue({ ...row, @@ -240,13 +246,16 @@ describe("checkAndResetCredits", () => { account_id: ACCOUNT, updates: { timestamp: "2026-05-11T12:00:00.000Z" }, }); - expect(result.creditsUsage?.remaining_credits).toBe(25000); + expect(result.creditsUsage?.remaining_credits).toBe(PRO_CREDITS + 1); }); it("protects an admin grant on a free account without knowing it is a grant", async () => { // 9,999 granted to a free-tier account: no provenance is consulted, the // floor rule alone keeps it. - const row = baseRow({ timestamp: "2026-03-01T00:00:00.000Z", remaining_credits: 9999 }); + const row = baseRow({ + timestamp: "2026-03-01T00:00:00.000Z", + remaining_credits: PRO_CREDITS, + }); vi.mocked(selectCreditsUsage).mockResolvedValue([row]); vi.mocked(updateCreditsUsage).mockResolvedValue({ ...row, @@ -258,12 +267,15 @@ describe("checkAndResetCredits", () => { const [{ updates }] = vi.mocked(updateCreditsUsage).mock.calls[0]; expect(updates).not.toHaveProperty("remaining_credits"); - expect(result.creditsUsage?.remaining_credits).toBe(9999); + expect(result.creditsUsage?.remaining_credits).toBe(PRO_CREDITS); expect(result.creditsUsage?.remaining_credits).not.toBe(DEFAULT_CREDITS); }); it("treats a newly-subscribed refill as a floor too (does not cut a topped-up balance)", async () => { - const row = baseRow({ timestamp: "2026-05-05T00:00:00.000Z", remaining_credits: 12000 }); + const row = baseRow({ + timestamp: "2026-05-05T00:00:00.000Z", + remaining_credits: PRO_CREDITS + 2_001_000, + }); vi.mocked(selectCreditsUsage).mockResolvedValue([row]); vi.mocked(updateCreditsUsage).mockResolvedValue({ ...row, @@ -277,7 +289,7 @@ describe("checkAndResetCredits", () => { account_id: ACCOUNT, updates: { timestamp: "2026-05-11T12:00:00.000Z" }, }); - expect(result.creditsUsage?.remaining_credits).toBe(12000); + expect(result.creditsUsage?.remaining_credits).toBe(PRO_CREDITS + 2_001_000); }); }); }); diff --git a/lib/credits/__tests__/const.test.ts b/lib/credits/__tests__/const.test.ts index b0733a8f0..5dc29d6ab 100644 --- a/lib/credits/__tests__/const.test.ts +++ b/lib/credits/__tests__/const.test.ts @@ -8,8 +8,8 @@ describe("credit grant totals", () => { expect(PRO_CREDITS).toBe(usdToCredits(PRO_CREDITS_USD)); }); - it("are $3.33 and $99.99 at today's unit", () => { - expect(DEFAULT_CREDITS).toBe(333); - expect(PRO_CREDITS).toBe(9999); + it("are $3.33 and $99.99 in micro-dollars", () => { + expect(DEFAULT_CREDITS).toBe(3_330_000); + expect(PRO_CREDITS).toBe(99_990_000); }); }); diff --git a/lib/credits/__tests__/creditsToUsd.test.ts b/lib/credits/__tests__/creditsToUsd.test.ts index 16479c271..16d996530 100644 --- a/lib/credits/__tests__/creditsToUsd.test.ts +++ b/lib/credits/__tests__/creditsToUsd.test.ts @@ -1,13 +1,15 @@ import { describe, it, expect } from "vitest"; import { creditsToUsd } from "../creditsToUsd"; import { usdToCredits } from "../usdToCredits"; -import { CREDITS_PER_USD } from "../creditsPerUsd"; describe("creditsToUsd", () => { it("keeps the two directions consistent at the unit boundary", () => { - // Whatever the unit is, one dollar must be CREDITS_PER_USD credits and - // CREDITS_PER_USD credits must be one dollar. - expect(creditsToUsd(CREDITS_PER_USD)).toBe(1); - expect(creditsToUsd(usdToCredits(4.12))).toBeCloseTo(4.12, 10); + expect(creditsToUsd(1_000_000)).toBe(1); + expect(creditsToUsd(92_440_000)).toBe(92.44); + expect(creditsToUsd(usdToCredits(4.12))).toBe(4.12); + }); + + it("carries sub-cent amounts", () => { + expect(creditsToUsd(51_740)).toBe(0.05174); }); }); diff --git a/lib/credits/__tests__/formatCreditSpendDigest.test.ts b/lib/credits/__tests__/formatCreditSpendDigest.test.ts index 440dc9a8e..0bc05ae09 100644 --- a/lib/credits/__tests__/formatCreditSpendDigest.test.ts +++ b/lib/credits/__tests__/formatCreditSpendDigest.test.ts @@ -7,15 +7,15 @@ function row(overrides: Partial = {}): CreditSpendDigestRo account_id: "acc-1", account_name: "Jane", account_email: "jane@example.com", - total_cents: 412, + total_cents: 4_120_000, turn_count: 7, input_tokens: 1_200_000, output_tokens: 40_000, cached_input_tokens: 0, tool_calls: 3, - main_cents: 412, + main_cents: 4_120_000, subagent_cents: 0, - by_model: { "claude-opus": 300, "claude-haiku": 112 }, + by_model: { "claude-opus": 3_000_000, "claude-haiku": 1_120_000 }, ...overrides, }; } @@ -56,7 +56,10 @@ describe("formatCreditSpendDigest", () => { }); it("shows the main/subagent split when subagent spend exists", () => { - const out = formatCreditSpendDigest([row({ main_cents: 300, subagent_cents: 112 })], 10); + const out = formatCreditSpendDigest( + [row({ main_cents: 3_000_000, subagent_cents: 1_120_000 })], + 10, + ); expect(out).toContain("main $3.00 · subagent $1.12"); }); diff --git a/lib/credits/__tests__/getCreditSpendDigestHandler.test.ts b/lib/credits/__tests__/getCreditSpendDigestHandler.test.ts index fa021ca62..0f55be56c 100644 --- a/lib/credits/__tests__/getCreditSpendDigestHandler.test.ts +++ b/lib/credits/__tests__/getCreditSpendDigestHandler.test.ts @@ -27,15 +27,15 @@ const sampleRow: CreditSpendDigestRow = { account_id: "acc-1", account_name: "Jane", account_email: "jane@example.com", - total_cents: 412, + total_cents: 4_120_000, turn_count: 7, input_tokens: 1000, output_tokens: 200, cached_input_tokens: 0, tool_calls: 3, - main_cents: 412, + main_cents: 4_120_000, subagent_cents: 0, - by_model: { "claude-opus": 412 }, + by_model: { "claude-opus": 4_120_000 }, }; beforeEach(() => { diff --git a/lib/credits/__tests__/handleChatCredits.test.ts b/lib/credits/__tests__/handleChatCredits.test.ts index c1786fc8f..45ac17004 100644 --- a/lib/credits/__tests__/handleChatCredits.test.ts +++ b/lib/credits/__tests__/handleChatCredits.test.ts @@ -33,7 +33,7 @@ describe("handleChatCredits", () => { describe("credit deduction", () => { it("deducts credits and forwards token detail to the usage_events row", async () => { - mockGetCreditUsage.mockResolvedValue(0.05); // $0.05 = 5 credits + mockGetCreditUsage.mockResolvedValue(0.05); // $0.05 = 50,000 micro-dollars mockRecordCreditDeduction.mockResolvedValue({ success: true, newBalance: 95 }); await handleChatCredits({ @@ -45,7 +45,7 @@ describe("handleChatCredits", () => { expect(mockGetCreditUsage).toHaveBeenCalledWith(USAGE, "gpt-4", undefined); expect(mockRecordCreditDeduction).toHaveBeenCalledWith({ accountId: "account-123", - creditsToDeduct: 5, + creditsToDeduct: 50_000, source: "web", modelId: "gpt-4", inputTokens: 1000, @@ -55,7 +55,7 @@ describe("handleChatCredits", () => { }); it("rounds credits to at least 1 when cost is very small", async () => { - mockGetCreditUsage.mockResolvedValue(0.001); + mockGetCreditUsage.mockResolvedValue(0.0000001); mockRecordCreditDeduction.mockResolvedValue({ success: true, newBalance: 99 }); await handleChatCredits({ @@ -80,7 +80,7 @@ describe("handleChatCredits", () => { }); expect(mockRecordCreditDeduction).toHaveBeenCalledWith( - expect.objectContaining({ accountId: "account-123", creditsToDeduct: 123 }), + expect.objectContaining({ accountId: "account-123", creditsToDeduct: 1_234_000 }), ); }); }); diff --git a/lib/credits/__tests__/usdToCredits.test.ts b/lib/credits/__tests__/usdToCredits.test.ts index 7f6cc63f6..c01818ac9 100644 --- a/lib/credits/__tests__/usdToCredits.test.ts +++ b/lib/credits/__tests__/usdToCredits.test.ts @@ -1,27 +1,31 @@ import { describe, it, expect } from "vitest"; import { usdToCredits } from "../usdToCredits"; -import { CREDITS_PER_USD } from "../creditsPerUsd"; +import { CREDIT_DECIMALS } from "../creditDecimals"; describe("usdToCredits", () => { - it("converts whole-unit amounts exactly, without floating-point drift", () => { - expect(usdToCredits(1)).toBe(CREDITS_PER_USD); - // 0.12 * 100 is 11.999999999999998 in IEEE-754; the ledger must see 12. - expect(usdToCredits(0.12)).toBe(12); - expect(usdToCredits(4.12)).toBe(412); + it("is the USDC unit: six decimals, a million per dollar", () => { + expect(CREDIT_DECIMALS).toBe(6); + expect(usdToCredits(1)).toBe(1_000_000); + expect(usdToCredits(92.44)).toBe(92_440_000); + }); + + it("converts fractional dollars exactly, without floating-point drift", () => { + // 0.12 * 1e6 is 119999.99999999999 in IEEE-754; the ledger must see 120000. + expect(usdToCredits(0.12)).toBe(120_000); + expect(usdToCredits(0.002 * 25.87)).toBe(51_740); }); it("never rounds up: a fraction of the ledger unit is absorbed, not charged", () => { // recoupable/app#2000, owner decision 2026-08-27: pass provider prices - // through; the ledger unit is the precision and any residue below one - // unit is ours. Under Math.round this would have been 2. - expect(usdToCredits(0.0199)).toBe(1); - expect(usdToCredits(0.0151)).toBe(1); + // through; the unit is the precision and any residue below it is ours. + expect(usdToCredits(0.0000019)).toBe(1); + expect(usdToCredits(0.1234567)).toBe(123_456); }); it("charges at least one ledger unit, even for a zero or sub-unit cost", () => { // A request that reached a model is never free: the minimum is one - // credit, whatever the unit (one micro-dollar after the rescale). + // micro-dollar. expect(usdToCredits(0)).toBe(1); - expect(usdToCredits(0.000001)).toBe(1); + expect(usdToCredits(0.0000001)).toBe(1); }); }); diff --git a/lib/credits/creditDecimals.ts b/lib/credits/creditDecimals.ts new file mode 100644 index 000000000..99a35c244 --- /dev/null +++ b/lib/credits/creditDecimals.ts @@ -0,0 +1,14 @@ +/** + * Decimal places in a credit: the single definition of what a credit is worth. + * + * Six, the same unit as USDC: 1,000,000 credits = $1.00, so a credit is a + * micro-dollar and provider prices pass through exactly. Every conversion + * between credits and dollars goes through `usdToCredits` or `creditsToUsd`, + * which are viem's `parseUnits` / `formatUnits` at this precision. + * + * Must match `chat/lib/credits/creditDecimals.ts` and the values stored in + * `credits_usage.remaining_credits`; the database rescale + * (recoupable/app#2000, database#62) and the deploy of this constant have to + * land in the same window or every charge is off by the ratio between them. + */ +export const CREDIT_DECIMALS = 6; diff --git a/lib/credits/creditsPerUsd.ts b/lib/credits/creditsPerUsd.ts deleted file mode 100644 index eab9afc5e..000000000 --- a/lib/credits/creditsPerUsd.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Credits in one US dollar: the single definition of what a credit is worth. - * - * Every conversion between credits and dollars goes through `usdToCredits` - * or `creditsToUsd`, so changing the unit is changing this number and nothing - * else. Today a credit is a cent. recoupable/app#2000 moves it to a - * micro-dollar (1_000_000), the same 6-decimal unit as USDC, so per-call - * pricing can pass provider prices through exactly. - * - * Must match `chat/lib/credits/creditsPerUsd.ts` and the values stored in - * `credits_usage.remaining_credits`; changing it without the matching database - * rescale misprices every charge by the ratio between the two. - */ -export const CREDITS_PER_USD = 100; diff --git a/lib/credits/creditsToStripeCents.ts b/lib/credits/creditsToStripeCents.ts new file mode 100644 index 000000000..963824b4a --- /dev/null +++ b/lib/credits/creditsToStripeCents.ts @@ -0,0 +1,15 @@ +import { creditsToUsd } from "@/lib/credits/creditsToUsd"; + +/** + * Whole Stripe cents for a credit amount. + * + * Stripe bills in cents whatever the ledger unit is. Top-up requests are + * validated to whole cents (`credits` a multiple of 10^(CREDIT_DECIMALS - 2)), + * so this is exact; the rounding only guards floating point. + * + * @param credits - Credits, whole ledger units. + * @returns Cents, integer. + */ +export function creditsToStripeCents(credits: number): number { + return Math.round(creditsToUsd(credits) * 100); +} diff --git a/lib/credits/creditsToUsd.ts b/lib/credits/creditsToUsd.ts index ab94fe5fd..f629b531c 100644 --- a/lib/credits/creditsToUsd.ts +++ b/lib/credits/creditsToUsd.ts @@ -1,11 +1,13 @@ -import { CREDITS_PER_USD } from "@/lib/credits/creditsPerUsd"; +import { formatUnits } from "viem"; +import { CREDIT_DECIMALS } from "@/lib/credits/creditDecimals"; /** - * Dollar value of a credit amount. + * Dollar value of a credit amount (viem's `formatUnits` at the credit + * precision, so 92,440,000 → 92.44 exactly). * - * @param credits - Credit amount. + * @param credits - Credit amount, whole ledger units. * @returns Dollars, unformatted. */ export function creditsToUsd(credits: number): number { - return credits / CREDITS_PER_USD; + return Number(formatUnits(BigInt(Math.trunc(credits)), CREDIT_DECIMALS)); } diff --git a/lib/credits/pricesUsd.ts b/lib/credits/pricesUsd.ts new file mode 100644 index 000000000..b065cc435 --- /dev/null +++ b/lib/credits/pricesUsd.ts @@ -0,0 +1,20 @@ +/** + * Fixed per-call prices, in US dollars. + * + * The one place a flat price is written down. Handlers convert at the call + * site with `usdToCredits`, so the ledger unit (recoupable/app#2000) never + * leaks into a handler as a bare number. Per-use pricing (chat tokens, music + * seconds) is computed from the provider's rate instead and is not listed. + */ +export const PRICES_USD = { + chatMinimum: 0.01, + research: 0.05, + researchPeople: 0.05, + researchWeb: 0.01, + researchEvents: 0.01, + researchExtractPerUrl: 0.05, + researchDeep: 0.25, + researchEnrich: { base: 0.05, core: 0.1, ultra: 0.25 }, + socialScrapeBase: 0.05, + socialScrapePerPost: 0.01, +} as const; diff --git a/lib/credits/usdToCredits.ts b/lib/credits/usdToCredits.ts index 9344eaf13..e92acb5c7 100644 --- a/lib/credits/usdToCredits.ts +++ b/lib/credits/usdToCredits.ts @@ -1,22 +1,24 @@ -import { CREDITS_PER_USD } from "@/lib/credits/creditsPerUsd"; +import { parseUnits } from "viem"; +import { CREDIT_DECIMALS } from "@/lib/credits/creditDecimals"; /** * Credits for a dollar amount, in whole ledger units. * * Two rules, decided on recoupable/app#2000 (2026-08-27): - * - No rounding up. The ledger unit is the precision; a residue below one - * unit is absorbed by Recoup, never charged. The product is settled to six - * decimals first so IEEE-754 noise (0.12 * 100 = 11.999…) cannot floor a - * whole-unit amount down by one. + * - No rounding up. The dollar figure is truncated to `CREDIT_DECIMALS` + * before `parseUnits` (which would otherwise round half up), so a residue + * below one unit is absorbed by Recoup, never charged. Going through a + * decimal string also sidesteps IEEE-754 noise (0.12 * 1e6 = 119999.99…). * - Minimum one unit. A request that reached a model is never free, even - * when the gateway reports no cost; after the rescale that floor is one - * micro-dollar. Callers that genuinely want zero for zero (music, where a - * failed generation costs nothing) decide that before calling. + * when the gateway reports no cost. Callers that genuinely want zero for + * zero (music, where a failed generation costs nothing) decide that before + * calling. * * @param usd - Cost in dollars. * @returns Whole credits, minimum 1. */ export function usdToCredits(usd: number): number { - const units = Number((usd * CREDITS_PER_USD).toFixed(6)); - return Math.max(1, Math.floor(units)); + const [whole, fraction = ""] = usd.toFixed(CREDIT_DECIMALS + 2).split("."); + const truncated = `${whole}.${fraction.slice(0, CREDIT_DECIMALS)}`; + return Math.max(1, Number(parseUnits(truncated, CREDIT_DECIMALS))); } diff --git a/lib/research/__tests__/deductCredits.test.ts b/lib/research/__tests__/deductCredits.test.ts index ed1eb0fd3..79ad0be92 100644 --- a/lib/research/__tests__/deductCredits.test.ts +++ b/lib/research/__tests__/deductCredits.test.ts @@ -12,7 +12,7 @@ describe("deductCredits", () => { expect(recordCreditDeduction).toHaveBeenCalledWith({ accountId: "acc_1", - creditsToDeduct: 5, + creditsToDeduct: 50_000, source: "api", }); }); diff --git a/lib/research/__tests__/ensureWebResearchCredits.test.ts b/lib/research/__tests__/ensureWebResearchCredits.test.ts index 76105e093..d583ac1d9 100644 --- a/lib/research/__tests__/ensureWebResearchCredits.test.ts +++ b/lib/research/__tests__/ensureWebResearchCredits.test.ts @@ -21,7 +21,7 @@ describe("ensureWebResearchCredits", () => { await expect(ensureWebResearchCredits("acct")).resolves.toBeNull(); expect(ensureCreditsMock).toHaveBeenCalledWith( - expect.objectContaining({ accountId: "acct", creditsToDeduct: 1 }), + expect.objectContaining({ accountId: "acct", creditsToDeduct: 10_000 }), ); }); diff --git a/lib/research/__tests__/getResearchTrackHistoricStats.test.ts b/lib/research/__tests__/getResearchTrackHistoricStats.test.ts index b1d621733..be7bd4003 100644 --- a/lib/research/__tests__/getResearchTrackHistoricStats.test.ts +++ b/lib/research/__tests__/getResearchTrackHistoricStats.test.ts @@ -34,7 +34,7 @@ describe("getResearchTrackHistoricStats", () => { expect(result).toEqual({ data: payload }); expect(recordCreditDeduction).toHaveBeenCalledWith({ accountId: "acc_1", - creditsToDeduct: 5, + creditsToDeduct: 50_000, source: "api", }); }); diff --git a/lib/research/__tests__/getResearchTrackStats.test.ts b/lib/research/__tests__/getResearchTrackStats.test.ts index d5373bc9e..ec883958c 100644 --- a/lib/research/__tests__/getResearchTrackStats.test.ts +++ b/lib/research/__tests__/getResearchTrackStats.test.ts @@ -28,7 +28,7 @@ describe("getResearchTrackStats", () => { expect(result).toEqual({ data: payload }); expect(recordCreditDeduction).toHaveBeenCalledWith({ accountId: "acc_1", - creditsToDeduct: 5, + creditsToDeduct: 50_000, source: "api", }); }); diff --git a/lib/research/__tests__/handleArtistResearch.test.ts b/lib/research/__tests__/handleArtistResearch.test.ts index a725e56c9..934294222 100644 --- a/lib/research/__tests__/handleArtistResearch.test.ts +++ b/lib/research/__tests__/handleArtistResearch.test.ts @@ -55,7 +55,7 @@ describe("handleArtistResearch", () => { expect(fetchSongstatsResearch).toHaveBeenCalledWith("/artist/42/albums", undefined); expect(recordCreditDeduction).toHaveBeenCalledWith({ accountId: "acc_1", - creditsToDeduct: 5, + creditsToDeduct: 50_000, source: "api", }); expect(result).toEqual({ data: [{ name: "a" }] }); diff --git a/lib/research/__tests__/handleResearch.test.ts b/lib/research/__tests__/handleResearch.test.ts index cd03bba49..9da4a62ad 100644 --- a/lib/research/__tests__/handleResearch.test.ts +++ b/lib/research/__tests__/handleResearch.test.ts @@ -39,7 +39,7 @@ describe("handleResearch", () => { }); expect(recordCreditDeduction).toHaveBeenCalledWith({ accountId: "acc_1", - creditsToDeduct: 5, + creditsToDeduct: 50_000, source: "api", }); expect(result).toEqual({ data: [{ id: 1 }] }); diff --git a/lib/research/__tests__/postResearchEventsHandler.test.ts b/lib/research/__tests__/postResearchEventsHandler.test.ts index 8ef5f5124..9b633f5fb 100644 --- a/lib/research/__tests__/postResearchEventsHandler.test.ts +++ b/lib/research/__tests__/postResearchEventsHandler.test.ts @@ -174,7 +174,7 @@ describe("postResearchEventsHandler", () => { expect(recordCreditDeduction).toHaveBeenCalledWith({ accountId: "acct-1", - creditsToDeduct: 1, + creditsToDeduct: 10_000, source: "api", }); }); diff --git a/lib/research/__tests__/postResearchWebHandler.test.ts b/lib/research/__tests__/postResearchWebHandler.test.ts index 112419a91..72e465279 100644 --- a/lib/research/__tests__/postResearchWebHandler.test.ts +++ b/lib/research/__tests__/postResearchWebHandler.test.ts @@ -81,7 +81,7 @@ describe("postResearchWebHandler", () => { expect(body.results).toEqual(mockResults); expect(body.formatted).toBe("# Results\n..."); expect(recordCreditDeduction).toHaveBeenCalledWith( - expect.objectContaining({ accountId: "test-id", creditsToDeduct: 1 }), + expect.objectContaining({ accountId: "test-id", creditsToDeduct: 10_000 }), ); }); }); diff --git a/lib/research/__tests__/validatePostResearchWebRequest.test.ts b/lib/research/__tests__/validatePostResearchWebRequest.test.ts index 297b28bd9..e8ddf0447 100644 --- a/lib/research/__tests__/validatePostResearchWebRequest.test.ts +++ b/lib/research/__tests__/validatePostResearchWebRequest.test.ts @@ -57,7 +57,7 @@ describe("validatePostResearchWebRequest", () => { it("gates with exactly 1 credit (web search costs 1, not the research family's 5)", async () => { await validatePostResearchWebRequest(req({ query: "x" })); expect(ensureCreditsOrShortCircuit).toHaveBeenCalledWith( - expect.objectContaining({ accountId: "acct", creditsToDeduct: 1 }), + expect.objectContaining({ accountId: "acct", creditsToDeduct: 10_000 }), ); }); }); diff --git a/lib/research/deductCredits.ts b/lib/research/deductCredits.ts index 8e98c5667..9e681b26f 100644 --- a/lib/research/deductCredits.ts +++ b/lib/research/deductCredits.ts @@ -1,7 +1,9 @@ import { recordCreditDeduction } from "@/lib/credits/recordCreditDeduction"; +import { usdToCredits } from "@/lib/credits/usdToCredits"; +import { PRICES_USD } from "@/lib/credits/pricesUsd"; /** Credits charged per successful read-only research call. */ -const RESEARCH_CREDIT_COST = 5; +const RESEARCH_CREDIT_COST = usdToCredits(PRICES_USD.research); /** * Deduct research credits for a successful read. Failures are logged, never diff --git a/lib/research/ensureEventsResearchCredits.ts b/lib/research/ensureEventsResearchCredits.ts index b58c766a4..798393bef 100644 --- a/lib/research/ensureEventsResearchCredits.ts +++ b/lib/research/ensureEventsResearchCredits.ts @@ -1,4 +1,6 @@ import { ensureCreditsOrShortCircuit } from "@/lib/credits/ensureCreditsOrShortCircuit"; +import { usdToCredits } from "@/lib/credits/usdToCredits"; +import { PRICES_USD } from "@/lib/credits/pricesUsd"; /** * Credits charged per artist-events call. Priced at 1 like web search rather @@ -7,7 +9,7 @@ import { ensureCreditsOrShortCircuit } from "@/lib/credits/ensureCreditsOrShortC * sweeps viable — a caller fanning out across a label roster makes one call per * artist (chat#1954). */ -const EVENTS_RESEARCH_CREDIT_COST = 1; +const EVENTS_RESEARCH_CREDIT_COST = usdToCredits(PRICES_USD.researchEvents); /** * Per-route credit gate for `POST /api/research/events`. Returns a 402 diff --git a/lib/research/ensureResearchCredits.ts b/lib/research/ensureResearchCredits.ts index 0e91d719a..157be5e8c 100644 --- a/lib/research/ensureResearchCredits.ts +++ b/lib/research/ensureResearchCredits.ts @@ -1,7 +1,9 @@ import { ensureCreditsOrShortCircuit } from "@/lib/credits/ensureCreditsOrShortCircuit"; +import { usdToCredits } from "@/lib/credits/usdToCredits"; +import { PRICES_USD } from "@/lib/credits/pricesUsd"; /** Credits charged per read-only research call (artist & non-artist). */ -const RESEARCH_CREDIT_COST = 5; +const RESEARCH_CREDIT_COST = usdToCredits(PRICES_USD.research); /** * Per-route credit gate for the read-only research family. Each successful diff --git a/lib/research/ensureWebResearchCredits.ts b/lib/research/ensureWebResearchCredits.ts index ea089aaf2..1eebeb2af 100644 --- a/lib/research/ensureWebResearchCredits.ts +++ b/lib/research/ensureWebResearchCredits.ts @@ -1,4 +1,6 @@ import { ensureCreditsOrShortCircuit } from "@/lib/credits/ensureCreditsOrShortCircuit"; +import { usdToCredits } from "@/lib/credits/usdToCredits"; +import { PRICES_USD } from "@/lib/credits/pricesUsd"; /** * Credits charged per web-search call. Priced separately from the research @@ -7,7 +9,7 @@ import { ensureCreditsOrShortCircuit } from "@/lib/credits/ensureCreditsOrShortC * (chat#1861). Songstats-backed research endpoints stay on * `ensureResearchCredits` at 5. */ -const WEB_RESEARCH_CREDIT_COST = 1; +const WEB_RESEARCH_CREDIT_COST = usdToCredits(PRICES_USD.researchWeb); /** * Per-route credit gate for `POST /api/research/web`. Returns a 402 diff --git a/lib/research/getResearchMetrics.ts b/lib/research/getResearchMetrics.ts index 5fa2c2aa0..023cfda1e 100644 --- a/lib/research/getResearchMetrics.ts +++ b/lib/research/getResearchMetrics.ts @@ -1,6 +1,8 @@ import { fetchSongstatsResearch } from "@/lib/research/songstats/fetchSongstatsResearch"; import { resolveArtist } from "@/lib/research/resolveArtist"; import { recordCreditDeduction } from "@/lib/credits/recordCreditDeduction"; +import { usdToCredits } from "@/lib/credits/usdToCredits"; +import { PRICES_USD } from "@/lib/credits/pricesUsd"; export type GetResearchMetricsParams = { accountId: string; @@ -31,7 +33,11 @@ export async function getResearchMetrics( } try { - await recordCreditDeduction({ accountId: params.accountId, creditsToDeduct: 5, source: "api" }); + await recordCreditDeduction({ + accountId: params.accountId, + creditsToDeduct: usdToCredits(PRICES_USD.research), + source: "api", + }); } catch (error) { console.error("[research] credit deduction failed:", error); } diff --git a/lib/research/getResearchTrackHistoricStats.ts b/lib/research/getResearchTrackHistoricStats.ts index 73b56d63e..ff48d1daa 100644 --- a/lib/research/getResearchTrackHistoricStats.ts +++ b/lib/research/getResearchTrackHistoricStats.ts @@ -1,5 +1,7 @@ import { fetchSongstats } from "@/lib/songstats/fetchSongstats"; import { recordCreditDeduction } from "@/lib/credits/recordCreditDeduction"; +import { usdToCredits } from "@/lib/credits/usdToCredits"; +import { PRICES_USD } from "@/lib/credits/pricesUsd"; export type GetResearchTrackHistoricStatsParams = { accountId: string; @@ -27,7 +29,11 @@ export async function getResearchTrackHistoricStats( } try { - await recordCreditDeduction({ accountId: params.accountId, creditsToDeduct: 5, source: "api" }); + await recordCreditDeduction({ + accountId: params.accountId, + creditsToDeduct: usdToCredits(PRICES_USD.research), + source: "api", + }); } catch (error) { console.error("[research] credit deduction failed:", error); } diff --git a/lib/research/getResearchTrackStats.ts b/lib/research/getResearchTrackStats.ts index fc925c142..05e3bb234 100644 --- a/lib/research/getResearchTrackStats.ts +++ b/lib/research/getResearchTrackStats.ts @@ -1,5 +1,7 @@ import { fetchSongstats } from "@/lib/songstats/fetchSongstats"; import { recordCreditDeduction } from "@/lib/credits/recordCreditDeduction"; +import { usdToCredits } from "@/lib/credits/usdToCredits"; +import { PRICES_USD } from "@/lib/credits/pricesUsd"; export type GetResearchTrackStatsParams = { accountId: string; @@ -24,7 +26,11 @@ export async function getResearchTrackStats( } try { - await recordCreditDeduction({ accountId: params.accountId, creditsToDeduct: 5, source: "api" }); + await recordCreditDeduction({ + accountId: params.accountId, + creditsToDeduct: usdToCredits(PRICES_USD.research), + source: "api", + }); } catch (error) { console.error("[research] credit deduction failed:", error); } diff --git a/lib/research/handleArtistResearch.ts b/lib/research/handleArtistResearch.ts index 64efc7ebb..67bacd27b 100644 --- a/lib/research/handleArtistResearch.ts +++ b/lib/research/handleArtistResearch.ts @@ -1,6 +1,8 @@ import { resolveArtist } from "@/lib/research/resolveArtist"; import { fetchSongstatsResearch } from "@/lib/research/songstats/fetchSongstatsResearch"; import { recordCreditDeduction } from "@/lib/credits/recordCreditDeduction"; +import { usdToCredits } from "@/lib/credits/usdToCredits"; +import { PRICES_USD } from "@/lib/credits/pricesUsd"; export type HandleArtistResearchParams = { artist: string; @@ -26,7 +28,14 @@ export type HandleArtistResearchResult = { data: unknown } | { error: string; st export async function handleArtistResearch( params: HandleArtistResearchParams, ): Promise { - const { artist, artistId, accountId, path, query, credits = 5 } = params; + const { + artist, + artistId, + accountId, + path, + query, + credits = usdToCredits(PRICES_USD.research), + } = params; const resolved = artistId ? { id: artistId } : await resolveArtist(artist); if ("error" in resolved) return { error: resolved.error, status: 404 }; diff --git a/lib/research/handleResearch.ts b/lib/research/handleResearch.ts index b7913b6e2..f7073c4f8 100644 --- a/lib/research/handleResearch.ts +++ b/lib/research/handleResearch.ts @@ -1,5 +1,7 @@ import { fetchSongstatsResearch } from "@/lib/research/songstats/fetchSongstatsResearch"; import { recordCreditDeduction } from "@/lib/credits/recordCreditDeduction"; +import { usdToCredits } from "@/lib/credits/usdToCredits"; +import { PRICES_USD } from "@/lib/credits/pricesUsd"; export type HandleResearchParams = { accountId: string; @@ -23,7 +25,7 @@ export type HandleResearchResult = { data: unknown } | { error: string; status: * @returns `{ data }` on success, `{ error, status }` on upstream failure. */ export async function handleResearch(params: HandleResearchParams): Promise { - const { accountId, path, query, credits = 5 } = params; + const { accountId, path, query, credits = usdToCredits(PRICES_USD.research) } = params; const result = await fetchSongstatsResearch(path, query); if (result.status !== 200) { diff --git a/lib/research/postResearchDeepHandler.ts b/lib/research/postResearchDeepHandler.ts index 3f78be8f1..50f158457 100644 --- a/lib/research/postResearchDeepHandler.ts +++ b/lib/research/postResearchDeepHandler.ts @@ -3,6 +3,8 @@ import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { deductCredits } from "@/lib/credits/deductCredits"; import { validatePostResearchDeepRequest } from "@/lib/research/validatePostResearchDeepRequest"; import { chatWithPerplexity } from "@/lib/perplexity/chatWithPerplexity"; +import { usdToCredits } from "@/lib/credits/usdToCredits"; +import { PRICES_USD } from "@/lib/credits/pricesUsd"; /** * Deep research handler — performs comprehensive research via Perplexity sonar-deep-research with citations. @@ -22,7 +24,7 @@ export async function postResearchDeepHandler(request: NextRequest): Promise { }); it("records the deduction with the given credits", async () => { - await deductSocialScrapeCredits(ACCOUNT_ID, 25); + await deductSocialScrapeCredits(ACCOUNT_ID, 250_000); expect(recordCreditDeduction).toHaveBeenCalledWith({ accountId: ACCOUNT_ID, - creditsToDeduct: 25, + creditsToDeduct: 250_000, source: "api", }); }); it("never throws when the deduction fails (billing must not fail a started scrape)", async () => { vi.mocked(recordCreditDeduction).mockRejectedValue(new Error("db down")); - await expect(deductSocialScrapeCredits(ACCOUNT_ID, 5)).resolves.toBeUndefined(); + await expect(deductSocialScrapeCredits(ACCOUNT_ID, 50_000)).resolves.toBeUndefined(); }); }); diff --git a/lib/socials/__tests__/ensureSocialScrapeCredits.test.ts b/lib/socials/__tests__/ensureSocialScrapeCredits.test.ts index 01ecddf93..822b6110c 100644 --- a/lib/socials/__tests__/ensureSocialScrapeCredits.test.ts +++ b/lib/socials/__tests__/ensureSocialScrapeCredits.test.ts @@ -17,15 +17,15 @@ describe("ensureSocialScrapeCredits", () => { }); it("gates on the given credit amount", async () => { - expect(await ensureSocialScrapeCredits(ACCOUNT_ID, 25)).toBeNull(); + expect(await ensureSocialScrapeCredits(ACCOUNT_ID, 250_000)).toBeNull(); expect(ensureCreditsOrShortCircuit).toHaveBeenCalledWith( - expect.objectContaining({ accountId: ACCOUNT_ID, creditsToDeduct: 25 }), + expect.objectContaining({ accountId: ACCOUNT_ID, creditsToDeduct: 250_000 }), ); }); it("passes through the 402 short-circuit response", async () => { const short = NextResponse.json({}, { status: 402 }); vi.mocked(ensureCreditsOrShortCircuit).mockResolvedValue(short); - expect(await ensureSocialScrapeCredits(ACCOUNT_ID, 5)).toBe(short); + expect(await ensureSocialScrapeCredits(ACCOUNT_ID, 50_000)).toBe(short); }); }); diff --git a/lib/socials/__tests__/getSocialScrapeCreditCost.test.ts b/lib/socials/__tests__/getSocialScrapeCreditCost.test.ts index bd4a5d9c6..6b5db7919 100644 --- a/lib/socials/__tests__/getSocialScrapeCreditCost.test.ts +++ b/lib/socials/__tests__/getSocialScrapeCreditCost.test.ts @@ -4,12 +4,12 @@ import { getSocialScrapeCreditCost } from "../getSocialScrapeCreditCost"; describe("getSocialScrapeCreditCost", () => { it("charges the 5-credit base when posts is omitted", () => { - expect(getSocialScrapeCreditCost(undefined)).toBe(5); + expect(getSocialScrapeCreditCost(undefined)).toBe(50_000); }); it("charges 5 + posts when a depth is requested", () => { - expect(getSocialScrapeCreditCost(1)).toBe(6); - expect(getSocialScrapeCreditCost(20)).toBe(25); - expect(getSocialScrapeCreditCost(100)).toBe(105); + expect(getSocialScrapeCreditCost(1)).toBe(60_000); + expect(getSocialScrapeCreditCost(20)).toBe(250_000); + expect(getSocialScrapeCreditCost(100)).toBe(1_050_000); }); }); diff --git a/lib/socials/__tests__/postSocialScrapeHandler.test.ts b/lib/socials/__tests__/postSocialScrapeHandler.test.ts index c645aa2c2..2c6c014bc 100644 --- a/lib/socials/__tests__/postSocialScrapeHandler.test.ts +++ b/lib/socials/__tests__/postSocialScrapeHandler.test.ts @@ -43,16 +43,16 @@ describe("postSocialScrapeHandler", () => { expect(res.status).toBe(404); }); - it("returns 200 with { runId, datasetId } on success and deducts the base 5 credits", async () => { + it("returns 200 with { runId, datasetId } on success and deducts the base $0.05", async () => { vi.mocked(scrapeProfileUrl).mockResolvedValue({ runId: "r1", datasetId: "d1" } as never); const res = await postSocialScrapeHandler(request, SOCIAL_ID); expect(res.status).toBe(200); expect(await res.json()).toEqual({ runId: "r1", datasetId: "d1" }); expect(scrapeProfileUrl).toHaveBeenCalledWith(social.profile_url, social.username, undefined); - expect(deductSocialScrapeCredits).toHaveBeenCalledWith(ACCOUNT_ID, 5); + expect(deductSocialScrapeCredits).toHaveBeenCalledWith(ACCOUNT_ID, 50_000); }); - it("forwards validated posts to scrapeProfileUrl and deducts 5 + posts credits", async () => { + it("forwards validated posts to scrapeProfileUrl and deducts $0.05 + $0.01 per post", async () => { vi.mocked(validatePostSocialScrapeRequest).mockResolvedValue({ social_id: SOCIAL_ID, account_id: ACCOUNT_ID, @@ -61,7 +61,7 @@ describe("postSocialScrapeHandler", () => { vi.mocked(scrapeProfileUrl).mockResolvedValue({ runId: "r1", datasetId: "d1" } as never); await postSocialScrapeHandler(request, SOCIAL_ID); expect(scrapeProfileUrl).toHaveBeenCalledWith(social.profile_url, social.username, 20); - expect(deductSocialScrapeCredits).toHaveBeenCalledWith(ACCOUNT_ID, 25); + expect(deductSocialScrapeCredits).toHaveBeenCalledWith(ACCOUNT_ID, 250_000); }); it("does not deduct credits when the scrape fails to start", async () => { diff --git a/lib/socials/__tests__/validatePostSocialScrapeRequest.test.ts b/lib/socials/__tests__/validatePostSocialScrapeRequest.test.ts index ded2f4ec9..533b97338 100644 --- a/lib/socials/__tests__/validatePostSocialScrapeRequest.test.ts +++ b/lib/socials/__tests__/validatePostSocialScrapeRequest.test.ts @@ -79,10 +79,10 @@ describe("validatePostSocialScrapeRequest", () => { posts: undefined, account_id: ACCOUNT_ID, }); - expect(ensureSocialScrapeCredits).toHaveBeenCalledWith(ACCOUNT_ID, 5); + expect(ensureSocialScrapeCredits).toHaveBeenCalledWith(ACCOUNT_ID, 50_000); }); - it("parses a valid posts query param and gates on 5 + posts credits", async () => { + it("parses a valid posts query param and gates on $0.05 + $0.01 per post", async () => { const req = new NextRequest(`http://localhost/api/socials/${SOCIAL_ID}/scrape?posts=20`, { method: "POST", headers: { "x-api-key": "k" }, @@ -92,7 +92,7 @@ describe("validatePostSocialScrapeRequest", () => { posts: 20, account_id: ACCOUNT_ID, }); - expect(ensureSocialScrapeCredits).toHaveBeenCalledWith(ACCOUNT_ID, 25); + expect(ensureSocialScrapeCredits).toHaveBeenCalledWith(ACCOUNT_ID, 250_000); }); it("short-circuits with the 402 when credits are insufficient", async () => { diff --git a/lib/socials/getSocialScrapeCreditCost.ts b/lib/socials/getSocialScrapeCreditCost.ts index 46cc5e277..ed5fe7b26 100644 --- a/lib/socials/getSocialScrapeCreditCost.ts +++ b/lib/socials/getSocialScrapeCreditCost.ts @@ -1,5 +1,8 @@ +import { usdToCredits } from "@/lib/credits/usdToCredits"; +import { PRICES_USD } from "@/lib/credits/pricesUsd"; + /** Base credits charged per social scrape (matches the research family's flat rate). */ -export const SOCIAL_SCRAPE_BASE_CREDIT_COST = 5; +export const SOCIAL_SCRAPE_BASE_CREDIT_COST = usdToCredits(PRICES_USD.socialScrapeBase); /** * Credits for one profile scrape: 5 base + 1 per requested post. Priced from @@ -8,5 +11,7 @@ export const SOCIAL_SCRAPE_BASE_CREDIT_COST = 5; * keeps ≥1.75× margin at posts=100 while staying one rule for every platform. */ export function getSocialScrapeCreditCost(posts?: number): number { - return SOCIAL_SCRAPE_BASE_CREDIT_COST + (posts ?? 0); + return ( + SOCIAL_SCRAPE_BASE_CREDIT_COST + (posts ?? 0) * usdToCredits(PRICES_USD.socialScrapePerPost) + ); } diff --git a/lib/stripe/__tests__/computeCreditsTopupCharge.test.ts b/lib/stripe/__tests__/computeCreditsTopupCharge.test.ts index 7e0b2c457..7d548bbd3 100644 --- a/lib/stripe/__tests__/computeCreditsTopupCharge.test.ts +++ b/lib/stripe/__tests__/computeCreditsTopupCharge.test.ts @@ -1,29 +1,33 @@ import { describe, it, expect } from "vitest"; +import { creditsToStripeCents } from "@/lib/credits/creditsToStripeCents"; import { computeCreditsTopupCharge } from "@/lib/stripe/computeCreditsTopupCharge"; describe("computeCreditsTopupCharge", () => { it("grosses up so net (after Stripe US card fee 2.9% + 30¢) covers credits", () => { - // For 250 credits: gross-up math is ceil((250 + 30) / (1 - 0.029)) = ceil(288.36) = 289¢ + // For 2,500,000 credits ($2.50 = 250¢): gross-up math is ceil((250 + 30) / (1 - 0.029)) = ceil(288.36) = 289¢ // → fee = 289 - 250 = 39¢, customer charged $2.89, Stripe takes ~38.4¢, business nets ≥ 250¢ - const { feeCents, totalCents } = computeCreditsTopupCharge(250); + const { feeCents, totalCents } = computeCreditsTopupCharge(2_500_000); expect(totalCents).toBe(289); expect(feeCents).toBe(39); }); it("nets at least credits cents after Stripe's actual fee for a range of sizes", () => { - for (const credits of [1, 50, 100, 250, 1_000, 10_000, 100_000]) { + for (const credits of [ + 10_000, 500_000, 1_000_000, 2_500_000, 10_000_000, 100_000_000, 1_000_000_000, + ]) { const { totalCents } = computeCreditsTopupCharge(credits); const stripeFee = totalCents * 0.029 + 30; + const credits_cents = credits / 10_000; const net = totalCents - stripeFee; // allow 1¢ rounding slack — gross-up rounds up so we never undercollect - expect(net).toBeGreaterThanOrEqual(credits - 0.5); + expect(net).toBeGreaterThanOrEqual(credits_cents - 0.5); } }); - it("totalCents = credits + feeCents", () => { - for (const credits of [1, 17, 250, 9999]) { + it("totalCents = the credits' cents + feeCents", () => { + for (const credits of [10_000, 170_000, 2_500_000, 99_990_000]) { const { feeCents, totalCents } = computeCreditsTopupCharge(credits); - expect(totalCents).toBe(credits + feeCents); + expect(totalCents).toBe(creditsToStripeCents(credits) + feeCents); } }); diff --git a/lib/stripe/__tests__/createCreditsSessionHandler.test.ts b/lib/stripe/__tests__/createCreditsSessionHandler.test.ts index dc51a5723..e396cc42f 100644 --- a/lib/stripe/__tests__/createCreditsSessionHandler.test.ts +++ b/lib/stripe/__tests__/createCreditsSessionHandler.test.ts @@ -36,7 +36,7 @@ describe("createCreditsSessionHandler — auth, auto-charge, and 5xx paths", () expect(res.status).toBe(200); await expect(res.json()).resolves.toEqual({ paymentIntentId: "pi_ok", - creditsPurchased: 250, + creditsPurchased: 2_500_000, totalCents: 289, }); expect(createCreditsStripeSession).not.toHaveBeenCalled(); diff --git a/lib/stripe/__tests__/createCreditsSessionHandlerTestMocks.ts b/lib/stripe/__tests__/createCreditsSessionHandlerTestMocks.ts index 2d7a996f8..e8c79a54d 100644 --- a/lib/stripe/__tests__/createCreditsSessionHandlerTestMocks.ts +++ b/lib/stripe/__tests__/createCreditsSessionHandlerTestMocks.ts @@ -20,5 +20,5 @@ export const ACCOUNT = "123e4567-e89b-12d3-a456-426614174000"; export const validated = { accountId: ACCOUNT, successUrl: "https://chat.recoupable.com/ok", - credits: 250, + credits: 2_500_000, }; diff --git a/lib/stripe/__tests__/createCreditsSessionSchemas.test.ts b/lib/stripe/__tests__/createCreditsSessionSchemas.test.ts index d6db5de55..fff8f23d2 100644 --- a/lib/stripe/__tests__/createCreditsSessionSchemas.test.ts +++ b/lib/stripe/__tests__/createCreditsSessionSchemas.test.ts @@ -3,14 +3,14 @@ import { createCreditsSessionBodySchema } from "@/lib/stripe/createCreditsSessio describe("createCreditsSessionBodySchema", () => { it("requires successUrl", () => { - const r = createCreditsSessionBodySchema.safeParse({ credits: 100 }); + const r = createCreditsSessionBodySchema.safeParse({ credits: 1_000_000 }); expect(r.success).toBe(false); }); it("rejects invalid URL", () => { const r = createCreditsSessionBodySchema.safeParse({ successUrl: "not-a-url", - credits: 100, + credits: 1_000_000, }); expect(r.success).toBe(false); }); @@ -30,6 +30,15 @@ describe("createCreditsSessionBodySchema", () => { expect(r.success).toBe(false); }); + it("rejects credits that are not a whole number of cents", () => { + // Stripe bills whole cents: at six decimals a cent is 10,000 credits. + const r = createCreditsSessionBodySchema.safeParse({ + successUrl: "https://chat.recoupable.com/done", + credits: 250, + }); + expect(r.success).toBe(false); + }); + it("rejects credits < 1", () => { const r = createCreditsSessionBodySchema.safeParse({ successUrl: "https://chat.recoupable.com/done", @@ -49,13 +58,13 @@ describe("createCreditsSessionBodySchema", () => { it("accepts successUrl + credits", () => { const r = createCreditsSessionBodySchema.safeParse({ successUrl: "https://chat.recoupable.com/done", - credits: 250, + credits: 2_500_000, }); expect(r.success).toBe(true); if (r.success) { expect(r.data).toEqual({ successUrl: "https://chat.recoupable.com/done", - credits: 250, + credits: 2_500_000, }); } }); @@ -63,7 +72,7 @@ describe("createCreditsSessionBodySchema", () => { it("accepts optional accountId UUID", () => { const r = createCreditsSessionBodySchema.safeParse({ successUrl: "https://chat.recoupable.com/done", - credits: 250, + credits: 2_500_000, accountId: "123e4567-e89b-12d3-a456-426614174000", }); expect(r.success).toBe(true); @@ -72,7 +81,7 @@ describe("createCreditsSessionBodySchema", () => { it("rejects malformed accountId", () => { const r = createCreditsSessionBodySchema.safeParse({ successUrl: "https://chat.recoupable.com/done", - credits: 250, + credits: 2_500_000, accountId: "not-a-uuid", }); expect(r.success).toBe(false); @@ -81,7 +90,7 @@ describe("createCreditsSessionBodySchema", () => { it("rejects unknown keys (strict)", () => { const r = createCreditsSessionBodySchema.safeParse({ successUrl: "https://chat.recoupable.com/done", - credits: 250, + credits: 2_500_000, extra: true, }); expect(r.success).toBe(false); diff --git a/lib/stripe/__tests__/createCreditsStripeSession.test.ts b/lib/stripe/__tests__/createCreditsStripeSession.test.ts index c21d9f824..75c004c73 100644 --- a/lib/stripe/__tests__/createCreditsStripeSession.test.ts +++ b/lib/stripe/__tests__/createCreditsStripeSession.test.ts @@ -20,25 +20,25 @@ describe("createCreditsStripeSession", () => { }); }); - it("creates a one-time payment checkout with two line items: credits @ 1¢ + processing fee", async () => { + it("creates a one-time payment checkout with two line items: credits as whole cents + processing fee", async () => { await createCreditsStripeSession({ accountId: "acc-1", - credits: 250, + credits: 2_500_000, successUrl: "https://example.com/success", customer: "cus_acc1", }); - // For 250 credits: gross-up math is ceil((250 + 30) / 0.971) = 289¢, so fee = 39¢ + // For 2,500,000 credits ($2.50): gross-up math is ceil((250 + 30) / 0.971) = 289¢, so fee = 39¢ expect(checkoutSessionsCreate).toHaveBeenCalledWith({ customer: "cus_acc1", line_items: [ { price_data: { currency: "usd", - unit_amount: 1, + unit_amount: 250, product_data: { name: "Recoup credits" }, }, - quantity: 250, + quantity: 1, }, { price_data: { @@ -53,14 +53,14 @@ describe("createCreditsStripeSession", () => { client_reference_id: "acc-1", metadata: { accountId: "acc-1", - credits: "250", + credits: "2500000", purpose: "credits_topup", }, payment_intent_data: { setup_future_usage: "off_session", metadata: { accountId: "acc-1", - credits: "250", + credits: "2500000", purpose: "credits_topup", paymentMethod: "checkout", }, diff --git a/lib/stripe/__tests__/validateCreateCreditsSessionRequest.auth.test.ts b/lib/stripe/__tests__/validateCreateCreditsSessionRequest.auth.test.ts index d40f79c7b..68af517f9 100644 --- a/lib/stripe/__tests__/validateCreateCreditsSessionRequest.auth.test.ts +++ b/lib/stripe/__tests__/validateCreateCreditsSessionRequest.auth.test.ts @@ -30,7 +30,7 @@ describe("validateCreateCreditsSessionRequest — auth + happy path", () => { const req = new NextRequest(URL, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ successUrl: "https://chat.recoupable.com/done", credits: 100 }), + body: JSON.stringify({ successUrl: "https://chat.recoupable.com/done", credits: 1_000_000 }), }); const res = await validateCreateCreditsSessionRequest(req); expect((res as NextResponse).status).toBe(401); @@ -48,12 +48,12 @@ describe("validateCreateCreditsSessionRequest — auth + happy path", () => { const req = new NextRequest(URL, { method: "POST", headers: { "Content-Type": "application/json", "x-api-key": "k" }, - body: JSON.stringify({ successUrl: "https://chat.recoupable.com/done", credits: 250 }), + body: JSON.stringify({ successUrl: "https://chat.recoupable.com/done", credits: 2_500_000 }), }); expect(await validateCreateCreditsSessionRequest(req)).toEqual({ accountId: ACCOUNT, successUrl: "https://chat.recoupable.com/done", - credits: 250, + credits: 2_500_000, }); expect(validateAuthContext).toHaveBeenCalledWith(req, { accountId: undefined }); }); @@ -69,14 +69,14 @@ describe("validateCreateCreditsSessionRequest — auth + happy path", () => { headers: { "Content-Type": "application/json", "x-api-key": "k" }, body: JSON.stringify({ successUrl: "https://chat.recoupable.com/done", - credits: 100, + credits: 1_000_000, accountId: ADMIN, }), }); expect(await validateCreateCreditsSessionRequest(req)).toEqual({ accountId: ADMIN, successUrl: "https://chat.recoupable.com/done", - credits: 100, + credits: 1_000_000, }); expect(validateAuthContext).toHaveBeenCalledWith(req, { accountId: ADMIN }); }); diff --git a/lib/stripe/__tests__/validateCreateCreditsSessionRequest.body.test.ts b/lib/stripe/__tests__/validateCreateCreditsSessionRequest.body.test.ts index c8ce6306b..8a3f8972a 100644 --- a/lib/stripe/__tests__/validateCreateCreditsSessionRequest.body.test.ts +++ b/lib/stripe/__tests__/validateCreateCreditsSessionRequest.body.test.ts @@ -17,7 +17,7 @@ const HEADERS = { "Content-Type": "application/json", "x-api-key": "k" }; const body = (overrides: Record = {}) => JSON.stringify({ successUrl: "https://chat.recoupable.com/done", - credits: 250, + credits: 2_500_000, ...overrides, }); @@ -34,11 +34,12 @@ describe("validateCreateCreditsSessionRequest — body validation", () => { }); it.each([ - ["missing successUrl", JSON.stringify({ credits: 100 })], + ["missing successUrl", JSON.stringify({ credits: 1_000_000 })], ["missing credits", JSON.stringify({ successUrl: "https://chat.recoupable.com/done" })], ["credits = 0", body({ credits: 0 })], ["credits = -5", body({ credits: -5 })], ["credits = 12.5", body({ credits: 12.5 })], + ["credits = 250 (not a whole number of cents)", body({ credits: 250 })], ["malformed successUrl", body({ successUrl: "not-a-url" })], ["bad accountId UUID", body({ accountId: "not-a-uuid" })], ["unknown body key (strict)", body({ extra: true })], diff --git a/lib/stripe/computeCreditsTopupCharge.ts b/lib/stripe/computeCreditsTopupCharge.ts index 2fac1c0fe..4cedd10cb 100644 --- a/lib/stripe/computeCreditsTopupCharge.ts +++ b/lib/stripe/computeCreditsTopupCharge.ts @@ -1,4 +1,5 @@ import { STRIPE_CARD_FEE_FIXED_CENTS, STRIPE_CARD_FEE_PERCENTAGE } from "@/lib/stripe/config"; +import { creditsToStripeCents } from "@/lib/credits/creditsToStripeCents"; export interface CreditsTopupCharge { /** Processing fee in cents (Stripe US card pricing). */ @@ -22,8 +23,9 @@ export function computeCreditsTopupCharge(credits: number): CreditsTopupCharge { throw new Error("credits must be a positive integer"); } + const amountCents = creditsToStripeCents(credits); const totalCents = Math.ceil( - (credits + STRIPE_CARD_FEE_FIXED_CENTS) / (1 - STRIPE_CARD_FEE_PERCENTAGE), + (amountCents + STRIPE_CARD_FEE_FIXED_CENTS) / (1 - STRIPE_CARD_FEE_PERCENTAGE), ); - return { feeCents: totalCents - credits, totalCents }; + return { feeCents: totalCents - amountCents, totalCents }; } diff --git a/lib/stripe/createCreditsSessionSchemas.ts b/lib/stripe/createCreditsSessionSchemas.ts index bcef9befb..85ad46b5e 100644 --- a/lib/stripe/createCreditsSessionSchemas.ts +++ b/lib/stripe/createCreditsSessionSchemas.ts @@ -1,12 +1,20 @@ +import { CREDIT_DECIMALS } from "@/lib/credits/creditDecimals"; import { z } from "zod"; +/** Stripe bills whole cents, so a top-up must be a whole number of them. */ +const CREDITS_PER_CENT = 10 ** (CREDIT_DECIMALS - 2); + export const createCreditsSessionBodySchema = z .object({ successUrl: z.string().min(1, "successUrl is required").url("successUrl must be a valid URL"), credits: z .number({ message: "credits is required" }) .int("credits must be an integer") - .min(1, "credits must be a positive integer"), + .min(1, "credits must be a positive integer") + .multipleOf( + CREDITS_PER_CENT, + `credits must be a whole number of cents (a multiple of ${CREDITS_PER_CENT})`, + ), accountId: z.string().uuid("accountId must be a valid UUID").optional(), }) .strict(); diff --git a/lib/stripe/createCreditsStripeSession.ts b/lib/stripe/createCreditsStripeSession.ts index 40ca5112a..ff4c93771 100644 --- a/lib/stripe/createCreditsStripeSession.ts +++ b/lib/stripe/createCreditsStripeSession.ts @@ -2,11 +2,12 @@ import type Stripe from "stripe"; import stripeClient from "@/lib/stripe/client"; import { CREDIT_TOPUP_PURPOSE } from "@/lib/stripe/creditsTopupPurpose"; import { computeCreditsTopupCharge } from "@/lib/stripe/computeCreditsTopupCharge"; +import { creditsToStripeCents } from "@/lib/credits/creditsToStripeCents"; /** - * One credit equals one US cent ($0.01). Total charge = unit_amount * credits. + * Credits are micro-dollars; the line item is their whole-cent USD value + * (`creditsToStripeCents`), quantity 1, plus the processing fee. */ -const UNIT_AMOUNT_CENTS_PER_CREDIT = 1; interface CreateCreditsStripeSessionParams { accountId: string; @@ -51,10 +52,10 @@ export async function createCreditsStripeSession({ { price_data: { currency: "usd", - unit_amount: UNIT_AMOUNT_CENTS_PER_CREDIT, + unit_amount: creditsToStripeCents(credits), product_data: { name: "Recoup credits" }, }, - quantity: credits, + quantity: 1, }, { price_data: { diff --git a/lib/x402/getCreditsForPrice.ts b/lib/x402/getCreditsForPrice.ts index 14a752d81..b30392266 100644 --- a/lib/x402/getCreditsForPrice.ts +++ b/lib/x402/getCreditsForPrice.ts @@ -1,3 +1,5 @@ +import { usdToCredits } from "@/lib/credits/usdToCredits"; + /** * Converts a price string to the number of credits required. * 1 credit = $0.01, rounded up to the nearest credit. @@ -11,6 +13,5 @@ export function getCreditsForPrice(price: string): number { if (isNaN(priceNumber) || priceNumber <= 0) { throw new Error(`Invalid price string: ${price}`); } - const credits = Math.ceil(priceNumber / 0.01); - return credits; + return usdToCredits(priceNumber); } From 7db8e8243102d09e87ad619a1f3c78b4be67cef5 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 26 Aug 2026 21:59:21 -0500 Subject: [PATCH 5/6] test: move the socials-scrape handler and credits-session outcome tests to micro-dollars --- .../__tests__/route.post.outcomes.test.ts | 4 ++-- .../postArtistSocialsScrapeHandler.test.ts | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/app/api/credits/sessions/__tests__/route.post.outcomes.test.ts b/app/api/credits/sessions/__tests__/route.post.outcomes.test.ts index 279f0662f..646b043bc 100644 --- a/app/api/credits/sessions/__tests__/route.post.outcomes.test.ts +++ b/app/api/credits/sessions/__tests__/route.post.outcomes.test.ts @@ -14,7 +14,7 @@ const makeReq = () => new NextRequest(URL, { method: "POST", body: "{}" }); const validated = { accountId: ACCOUNT, successUrl: "https://chat.recoupable.com/ok", - credits: 250, + credits: 2_500_000, }; describe("POST /api/credits/sessions (handler outcomes)", () => { @@ -43,7 +43,7 @@ describe("POST /api/credits/sessions (handler outcomes)", () => { expect(res.status).toBe(200); await expect(res.json()).resolves.toEqual({ paymentIntentId: "pi_ok", - creditsPurchased: 250, + creditsPurchased: 2_500_000, totalCents: 289, }); }); diff --git a/lib/artist/__tests__/postArtistSocialsScrapeHandler.test.ts b/lib/artist/__tests__/postArtistSocialsScrapeHandler.test.ts index 7e64203e3..dad1c1fac 100644 --- a/lib/artist/__tests__/postArtistSocialsScrapeHandler.test.ts +++ b/lib/artist/__tests__/postArtistSocialsScrapeHandler.test.ts @@ -74,7 +74,7 @@ describe("postArtistSocialsScrapeHandler", () => { expect(scrapeProfileUrlBatch).not.toHaveBeenCalled(); }); - it("gates on (5 + posts) × profiles credits and short-circuits with the 402", async () => { + it("gates on ($0.05 + $0.01 × posts) × profiles and short-circuits with the 402", async () => { const short = NextResponse.json({}, { status: 402 }); vi.mocked(ensureSocialScrapeCredits).mockResolvedValue(short); expect( @@ -82,14 +82,14 @@ describe("postArtistSocialsScrapeHandler", () => { makeRequest({ artist_account_id: ARTIST_ID, posts: 20 }), ), ).toBe(short); - expect(ensureSocialScrapeCredits).toHaveBeenCalledWith(ACCOUNT_ID, 50); + expect(ensureSocialScrapeCredits).toHaveBeenCalledWith(ACCOUNT_ID, 500_000); expect(scrapeProfileUrlBatch).not.toHaveBeenCalled(); }); - it("scrapes without a posts depth by default and deducts 5 credits per scraped profile", async () => { + it("scrapes without a posts depth by default and deducts $0.05 per scraped profile", async () => { const res = await postArtistSocialsScrapeHandler(makeRequest({ artist_account_id: ARTIST_ID })); expect(res.status).toBe(200); - expect(ensureSocialScrapeCredits).toHaveBeenCalledWith(ACCOUNT_ID, 10); + expect(ensureSocialScrapeCredits).toHaveBeenCalledWith(ACCOUNT_ID, 100_000); expect(scrapeProfileUrlBatch).toHaveBeenCalledWith( [ { profileUrl: "https://x.com/a", username: "a" }, @@ -97,17 +97,17 @@ describe("postArtistSocialsScrapeHandler", () => { ], undefined, ); - expect(deductSocialScrapeCredits).toHaveBeenCalledWith(ACCOUNT_ID, 10); + expect(deductSocialScrapeCredits).toHaveBeenCalledWith(ACCOUNT_ID, 100_000); }); - it("forwards posts and deducts (5 + posts) per profile actually scraped", async () => { + it("forwards posts and deducts ($0.05 + $0.01 × posts) per profile actually scraped", async () => { vi.mocked(scrapeProfileUrlBatch).mockResolvedValue([ { runId: "r1", datasetId: "d1", error: null, profileUrl: null }, ]); await postArtistSocialsScrapeHandler(makeRequest({ artist_account_id: ARTIST_ID, posts: 20 })); - expect(ensureSocialScrapeCredits).toHaveBeenCalledWith(ACCOUNT_ID, 50); + expect(ensureSocialScrapeCredits).toHaveBeenCalledWith(ACCOUNT_ID, 500_000); expect(scrapeProfileUrlBatch).toHaveBeenCalledWith(expect.any(Array), 20); - expect(deductSocialScrapeCredits).toHaveBeenCalledWith(ACCOUNT_ID, 25); + expect(deductSocialScrapeCredits).toHaveBeenCalledWith(ACCOUNT_ID, 250_000); }); it("returns [] and charges nothing when the artist has no socials", async () => { From 7958b67cb347cddeedb0e00a2ca600ba02dca130 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 27 Aug 2026 06:33:15 -0500 Subject: [PATCH 6/6] refactor(credits): one-line conversions; drop the trunc and the manual truncation --- lib/credits/__tests__/usdToCredits.test.ts | 10 ++++----- lib/credits/creditsToUsd.ts | 2 +- lib/credits/usdToCredits.ts | 24 ++++++++++------------ 3 files changed, 17 insertions(+), 19 deletions(-) diff --git a/lib/credits/__tests__/usdToCredits.test.ts b/lib/credits/__tests__/usdToCredits.test.ts index c01818ac9..f392cbf8e 100644 --- a/lib/credits/__tests__/usdToCredits.test.ts +++ b/lib/credits/__tests__/usdToCredits.test.ts @@ -15,11 +15,11 @@ describe("usdToCredits", () => { expect(usdToCredits(0.002 * 25.87)).toBe(51_740); }); - it("never rounds up: a fraction of the ledger unit is absorbed, not charged", () => { - // recoupable/app#2000, owner decision 2026-08-27: pass provider prices - // through; the unit is the precision and any residue below it is ours. - expect(usdToCredits(0.0000019)).toBe(1); - expect(usdToCredits(0.1234567)).toBe(123_456); + it("rounds to the nearest unit, and accepts amounts String() would print in exponent notation", () => { + // The unit is the precision: a residue below it rounds to the nearest + // micro-dollar rather than being carried, and 7e-7 dollars still parses. + expect(usdToCredits(0.1234567)).toBe(123_457); + expect(usdToCredits(0.0000007)).toBe(1); }); it("charges at least one ledger unit, even for a zero or sub-unit cost", () => { diff --git a/lib/credits/creditsToUsd.ts b/lib/credits/creditsToUsd.ts index f629b531c..a9ab89552 100644 --- a/lib/credits/creditsToUsd.ts +++ b/lib/credits/creditsToUsd.ts @@ -9,5 +9,5 @@ import { CREDIT_DECIMALS } from "@/lib/credits/creditDecimals"; * @returns Dollars, unformatted. */ export function creditsToUsd(credits: number): number { - return Number(formatUnits(BigInt(Math.trunc(credits)), CREDIT_DECIMALS)); + return Number(formatUnits(BigInt(credits), CREDIT_DECIMALS)); } diff --git a/lib/credits/usdToCredits.ts b/lib/credits/usdToCredits.ts index e92acb5c7..4098bd2c3 100644 --- a/lib/credits/usdToCredits.ts +++ b/lib/credits/usdToCredits.ts @@ -2,23 +2,21 @@ import { parseUnits } from "viem"; import { CREDIT_DECIMALS } from "@/lib/credits/creditDecimals"; /** - * Credits for a dollar amount, in whole ledger units. + * Credits for a dollar amount, in whole ledger units: viem's `parseUnits` at + * the credit precision, floored at one unit. * - * Two rules, decided on recoupable/app#2000 (2026-08-27): - * - No rounding up. The dollar figure is truncated to `CREDIT_DECIMALS` - * before `parseUnits` (which would otherwise round half up), so a residue - * below one unit is absorbed by Recoup, never charged. Going through a - * decimal string also sidesteps IEEE-754 noise (0.12 * 1e6 = 119999.99…). - * - Minimum one unit. A request that reached a model is never free, even - * when the gateway reports no cost. Callers that genuinely want zero for - * zero (music, where a failed generation costs nothing) decide that before - * calling. + * `parseUnits` takes a decimal string, and `String(usd)` switches to exponent + * notation below 1e-6 (`"1e-7"`), which it rejects; `toFixed` always yields + * fixed notation, rounded to the nearest unit. + * + * The one-unit floor is the minimum charge decided on recoupable/app#2000 + * (2026-08-27): a request that reached a model is never free. Callers that + * want zero for zero (music, where a failed generation costs nothing) decide + * that before calling. * * @param usd - Cost in dollars. * @returns Whole credits, minimum 1. */ export function usdToCredits(usd: number): number { - const [whole, fraction = ""] = usd.toFixed(CREDIT_DECIMALS + 2).split("."); - const truncated = `${whole}.${fraction.slice(0, CREDIT_DECIMALS)}`; - return Math.max(1, Number(parseUnits(truncated, CREDIT_DECIMALS))); + return Math.max(1, Number(parseUnits(usd.toFixed(CREDIT_DECIMALS), CREDIT_DECIMALS))); }