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 () => { 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 10438429d..5dc29d6ab 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)", () => { - 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 new file mode 100644 index 000000000..16d996530 --- /dev/null +++ b/lib/credits/__tests__/creditsToUsd.test.ts @@ -0,0 +1,15 @@ +import { describe, it, expect } from "vitest"; +import { creditsToUsd } from "../creditsToUsd"; +import { usdToCredits } from "../usdToCredits"; + +describe("creditsToUsd", () => { + it("keeps the two directions consistent at the unit boundary", () => { + 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 new file mode 100644 index 000000000..c01818ac9 --- /dev/null +++ b/lib/credits/__tests__/usdToCredits.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from "vitest"; +import { usdToCredits } from "../usdToCredits"; +import { CREDIT_DECIMALS } from "../creditDecimals"; + +describe("usdToCredits", () => { + 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 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 + // micro-dollar. + expect(usdToCredits(0)).toBe(1); + expect(usdToCredits(0.0000001)).toBe(1); + }); +}); diff --git a/lib/credits/const.ts b/lib/credits/const.ts index 4cb6405ad..0a2e704bf 100644 --- a/lib/credits/const.ts +++ b/lib/credits/const.ts @@ -1,15 +1,23 @@ +import { usdToCredits } from "@/lib/credits/usdToCredits"; + /** - * 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 = usdToCredits(DEFAULT_CREDITS_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 = usdToCredits(PRO_CREDITS_USD); /** * Where a caller that ran out of credits is pointed. A constant, so a 402 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/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 new file mode 100644 index 000000000..f629b531c --- /dev/null +++ b/lib/credits/creditsToUsd.ts @@ -0,0 +1,13 @@ +import { formatUnits } from "viem"; +import { CREDIT_DECIMALS } from "@/lib/credits/creditDecimals"; + +/** + * Dollar value of a credit amount (viem's `formatUnits` at the credit + * precision, so 92,440,000 → 92.44 exactly). + * + * @param credits - Credit amount, whole ledger units. + * @returns Dollars, unformatted. + */ +export function creditsToUsd(credits: number): number { + return Number(formatUnits(BigInt(Math.trunc(credits)), CREDIT_DECIMALS)); +} diff --git a/lib/credits/formatCentsAsUsd.ts b/lib/credits/formatCentsAsUsd.ts index 9b229312b..6eb3b21cd 100644 --- a/lib/credits/formatCentsAsUsd.ts +++ b/lib/credits/formatCentsAsUsd.ts @@ -1,9 +1,15 @@ +import { creditsToUsd } from "@/lib/credits/creditsToUsd"; + /** - * 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/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 diff --git a/lib/credits/handleChatCredits.ts b/lib/credits/handleChatCredits.ts index 98c8ecdbc..68f2b8c8c 100644 --- a/lib/credits/handleChatCredits.ts +++ b/lib/credits/handleChatCredits.ts @@ -1,4 +1,5 @@ import { getCreditUsage } from "./getCreditUsage"; +import { usdToCredits } from "@/lib/credits/usdToCredits"; 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, 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 new file mode 100644 index 000000000..e92acb5c7 --- /dev/null +++ b/lib/credits/usdToCredits.ts @@ -0,0 +1,24 @@ +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 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. + * + * @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))); +} 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); }