diff --git a/apps/web/lib/chat/budget.test.ts b/apps/web/lib/chat/budget.test.ts new file mode 100644 index 0000000..7e48912 --- /dev/null +++ b/apps/web/lib/chat/budget.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; +import { budgetAmounts, parseBudgetAmount } from "./budget"; +import { budgetFor, parsePrice } from "./payment-choice"; + +describe("budgetAmounts", () => { + it("formats each option with the currency's decimals", () => { + expect(budgetAmounts("USD").map((m) => m.value)).toEqual(["20.00", "50.00", "100.00"]); + expect(budgetAmounts("KWD").map((m) => m.value)).toEqual(["20.000", "50.000", "100.000"]); + expect(budgetAmounts("CLP", { value: "10000", currency: "CLP" })).toEqual([ + { value: "10000", currency: "CLP" }, + ]); + }); +}); + +describe("parseBudgetAmount", () => { + it("emits a zero-decimal currency without decimals", () => { + expect(parseBudgetAmount("10000", "CLP")).toEqual({ + amount: { value: "10000", currency: "CLP" }, + }); + expect(parseBudgetAmount("500", "JPY")).toEqual({ + amount: { value: "500", currency: "JPY" }, + }); + }); + + it("emits a three-decimal currency with three decimals", () => { + expect(parseBudgetAmount("1.25", "KWD")).toEqual({ + amount: { value: "1.250", currency: "KWD" }, + }); + }); + + it("rejects decimals the currency does not allow", () => { + expect(parseBudgetAmount("10000.5", "CLP")).toHaveProperty("problem"); + expect(parseBudgetAmount("10.005", "USD")).toHaveProperty("problem"); + }); + + it("accepts zero-padded decimals the currency does not show", () => { + expect(parseBudgetAmount("10000.00", "CLP")).toEqual({ + amount: { value: "10000", currency: "CLP" }, + }); + }); + + it("reads a comma followed by three digits as a thousands separator", () => { + expect(parseBudgetAmount("10,000", "KWD")).toEqual({ + amount: { value: "10000.000", currency: "KWD" }, + }); + expect(parseBudgetAmount("1,234", "USD")).toEqual({ + amount: { value: "1234.00", currency: "USD" }, + }); + }); + + it("reads a comma followed by fewer digits as a decimal point", () => { + expect(parseBudgetAmount("10,50", "USD")).toEqual({ + amount: { value: "10.50", currency: "USD" }, + }); + }); + + it("keeps USD at two decimals", () => { + expect(parseBudgetAmount("75", "USD")).toEqual({ + amount: { value: "75.00", currency: "USD" }, + }); + }); +}); + +describe("parsePrice", () => { + it("parses catalog prices with up to four decimals", () => { + expect(parsePrice("35.99 USD")).toEqual({ amount: "35.99", currency: "USD" }); + expect(parsePrice("1.250 KWD")).toEqual({ amount: "1.250", currency: "KWD" }); + expect(parsePrice("1.2345 CLF")).toEqual({ amount: "1.2345", currency: "CLF" }); + expect(parsePrice("1.23456 CLF")).toBeUndefined(); + }); +}); + +describe("budgetFor", () => { + it("pads the price into the currency's decimals", () => { + expect(budgetFor({ amount: "8000", currency: "CLP" })).toEqual({ + value: "10000", + currency: "CLP", + }); + expect(budgetFor({ amount: "100", currency: "USD" })).toEqual({ + value: "125.00", + currency: "USD", + }); + }); + + it("formats a three-decimal price and budget", () => { + expect(budgetFor({ amount: "1.250", currency: "KWD" })).toEqual({ + value: "12.000", + currency: "KWD", + }); + }); + + it("falls back to two decimals for a currency code it does not know", () => { + expect(budgetFor({ amount: "100", currency: "ZZZ" })).toEqual({ + value: "125.00", + currency: "ZZZ", + }); + expect(budgetAmounts("ZZZ").map((m) => m.value)).toEqual(["20.00", "50.00", "100.00"]); + expect(parseBudgetAmount("75", "ZZZ")).toEqual({ + amount: { value: "75.00", currency: "ZZZ" }, + }); + }); +}); diff --git a/apps/web/lib/chat/budget.ts b/apps/web/lib/chat/budget.ts index ef2b014..e2842c3 100644 --- a/apps/web/lib/chat/budget.ts +++ b/apps/web/lib/chat/budget.ts @@ -5,7 +5,7 @@ * data and arithmetic, so the tool schema on the server and the picker in * the chat share one source. */ -import { formatAmount } from "@agent-commerce/ui"; +import { currencyFractionDigits, formatAmount, significantDecimals } from "@agent-commerce/core"; import type { Money } from "./payment-choice"; /** How long a budget can last, in days, as the picker offers it. */ @@ -34,7 +34,11 @@ export function budgetAmounts(currency: string, floor?: Money): Money[] { const min = floor ? Number.parseFloat(floor.value) : 0; const fits = STEPS.filter((v) => v >= min).slice(0, 3); const values = fits.length ? fits : [Math.ceil(min)]; - return values.map((v) => ({ value: v.toFixed(2), currency: currency.toUpperCase() })); + const decimals = currencyFractionDigits(currency, 2); + return values.map((v) => ({ + value: v.toFixed(decimals), + currency: currency.toUpperCase(), + })); } /** @@ -62,15 +66,27 @@ export function parseBudgetAmount( currency: string, floor?: Money, ): { amount: Money } | { problem: string } { - const cleaned = text.replace(/[^\d.,]/g, "").replace(",", "."); + // A comma followed by exactly three digits is a thousands separator + // ("10,000"), not a decimal mark — reading it as a point would turn ten + // thousand into ten in a 3-decimal currency. + const cleaned = text + .replace(/[^\d.,]/g, "") + .replace(/,(?=\d{3}(?:\D|$))/g, "") + .replace(",", "."); const value = Number.parseFloat(cleaned); - if (!/^\d+(\.\d{1,2})?$/.test(cleaned) || !(value > 0)) { + const decimals = currencyFractionDigits(currency, 2); + if (!/^\d+(\.\d+)?$/.test(cleaned) || !(value > 0)) { return { problem: "Enter an amount, such as 75." }; } + if (significantDecimals(cleaned) > decimals) { + return { problem: `${currency.toUpperCase()} takes at most ${decimals} decimals.` }; + } if (floor && value < Number.parseFloat(floor.value)) { - return { problem: `Enter at least ${formatAmount(floor.value, floor.currency)}, so it covers this purchase.` }; + return { + problem: `Enter at least ${formatAmount(floor.value, floor.currency)}, so it covers this purchase.`, + }; } - return { amount: { value: value.toFixed(2), currency: currency.toUpperCase() } }; + return { amount: { value: value.toFixed(decimals), currency: currency.toUpperCase() } }; } /** What the agent asks over the picker. */ diff --git a/apps/web/lib/chat/payment-choice.ts b/apps/web/lib/chat/payment-choice.ts index d7bbf7c..3096578 100644 --- a/apps/web/lib/chat/payment-choice.ts +++ b/apps/web/lib/chat/payment-choice.ts @@ -12,6 +12,8 @@ * their Shop account when the store asks. * - other: any other way the store takes, in the user's words. */ +import { currencyFractionDigits } from "@agent-commerce/core"; + export const PAYMENT_CHOICE_METHODS = ["agent_card", "card", "shop_pay", "other"] as const; export type PaymentChoiceMethod = (typeof PAYMENT_CHOICE_METHODS)[number]; @@ -30,11 +32,14 @@ export function budgetFor(price: { amount: string; currency: string }): Money | const amount = Number.parseFloat(price.amount); if (!Number.isFinite(amount) || amount <= 0) return undefined; const value = Math.ceil(amount + Math.max(amount * 0.25, 10)); - return { value: value.toFixed(2), currency: price.currency.toUpperCase() }; + const decimals = currencyFractionDigits(price.currency, 2); + return { value: value.toFixed(decimals), currency: price.currency.toUpperCase() }; } /** "35.99 USD", as the catalog writes a price, as an amount and a currency. */ -export function parsePrice(text: string | undefined): { amount: string; currency: string } | undefined { - const match = text?.trim().match(/^(\d+(?:\.\d{1,2})?)\s+([A-Za-z]{3})$/); +export function parsePrice( + text: string | undefined, +): { amount: string; currency: string } | undefined { + const match = text?.trim().match(/^(\d+(?:\.\d{1,4})?)\s+([A-Za-z]{3})$/); return match ? { amount: match[1]!, currency: match[2]!.toUpperCase() } : undefined; } diff --git a/apps/web/lib/chat/shopify-catalog.ts b/apps/web/lib/chat/shopify-catalog.ts index 9ce52a9..02b5a97 100644 --- a/apps/web/lib/chat/shopify-catalog.ts +++ b/apps/web/lib/chat/shopify-catalog.ts @@ -1,3 +1,4 @@ +import { currencyFractionDigits } from "@agent-commerce/core"; import { serverEnv } from "@/lib/env"; /** @@ -73,18 +74,21 @@ interface CatalogProduct { export async function searchProducts(opts: { query: string; - /** Most the buyer wants to pay, in major units of the currency. */ + /** Most the buyer wants to pay, in major units of `currency`. */ maxPrice?: number; + /** The currency `maxPrice` is stated in; the filter takes minor units. */ + currency?: string; /** ISO 3166-1 alpha-2. Default US. */ shipsTo?: string; limit?: number; }): Promise { + const factor = 10 ** currencyFractionDigits(opts.currency ?? "USD", 2); return call("search_catalog", { query: opts.query, filters: { available: true, ships_to: { country: (opts.shipsTo ?? "US").toUpperCase() }, - ...(opts.maxPrice ? { price: { max: Math.round(opts.maxPrice * 100) } } : {}), + ...(opts.maxPrice ? { price: { max: Math.round(opts.maxPrice * factor) } } : {}), }, pagination: { limit: Math.min(Math.max(opts.limit ?? 5, 1), 10) }, }); @@ -142,7 +146,7 @@ function toFound(p: CatalogProduct): FoundProduct | undefined { about.length > 280 ? `${about.slice(0, 277).replace(/\s+\S*$/, "")}…` : about, } : {}), - ...(money ? { price: `${(money.amount / 100).toFixed(2)} ${money.currency}` } : {}), + ...(money ? { price: catalogPrice(money) } : {}), store: cheapest.seller?.name ?? hostOf(cheapest.url), url: cleanUrl(cheapest.url), ...(p.rating?.value @@ -165,6 +169,17 @@ function toFound(p: CatalogProduct): FoundProduct | undefined { }; } +/** + * The catalog states prices in minor units (the search filter takes cents + * too), so "599" is "$5.99" and "1000" in JPY is "¥1000". The divisor is the + * currency's own factor, not always 100. + */ +function catalogPrice(money: Money): string { + // An unknown code still gets shown, in the shape most currencies use. + const decimals = currencyFractionDigits(money.currency, 2); + return `${(money.amount / 10 ** decimals).toFixed(decimals)} ${money.currency}`; +} + /** * A card-sized picture. Shopify's CDN resizes on `width`, and store pictures * can be huge: IQBAR's main one is a 12 MB PNG, 200 KB at this size. diff --git a/apps/web/lib/chat/tools.ts b/apps/web/lib/chat/tools.ts index 5308386..8c70023 100644 --- a/apps/web/lib/chat/tools.ts +++ b/apps/web/lib/chat/tools.ts @@ -69,8 +69,10 @@ type ChatTools = Record, unknown>; const amountSchema = z.object({ value: z .string() - .regex(/^\d+(\.\d{1,2})?$/, 'Decimal string like "50.00"') - .describe('Decimal string, e.g. "50.00".'), + .regex(/^\d+(\.\d{1,4})?$/, 'Decimal string like "50.00"') + .describe( + 'Decimal string with the currency\'s decimals, e.g. "50.00" USD, "500" JPY, "1.250" KWD.', + ), currency: z.string().length(3).describe(PARAM_DOCS.currency), }); @@ -415,7 +417,6 @@ function budgetPlanFor(messages: ModelMessage[]): { agentCardId?: string } | und return plan; } - /** * How the user chose to pay for the store a checkout starts at, as the chat * settled it: the newest payment choice for that store, and what happened @@ -603,7 +604,7 @@ export function createChatTools(api: AgentCommerceClient, opts: { userEmail?: st .object({ amount: z .string() - .regex(/^\d+(\.\d{1,2})?$/) + .regex(/^\d+(\.\d{1,4})?$/) .describe('Decimal string, e.g. "35.99".'), currency: z.string().length(3).describe(PARAM_DOCS.currency), }) @@ -626,7 +627,12 @@ export function createChatTools(api: AgentCommerceClient, opts: { userEmail?: st inputSchema: z.object({ category: z.string().min(1).max(60).describe(paramDoc("await_budget", "category")), amount: amountSchema.optional().describe(paramDoc("await_budget", "amount")), - purchase: z.string().min(1).max(80).optional().describe(paramDoc("await_budget", "purchase")), + purchase: z + .string() + .min(1) + .max(80) + .optional() + .describe(paramDoc("await_budget", "purchase")), }), outputSchema: budgetOutcomeSchema, }), @@ -717,7 +723,7 @@ export function createChatTools(api: AgentCommerceClient, opts: { userEmail?: st .object({ amount: z .string() - .regex(/^\d+(\.\d{1,2})?$/) + .regex(/^\d+(\.\d{1,4})?$/) .describe('Decimal string, e.g. "50.00".'), currency: z.string().length(3).describe(PARAM_DOCS.currency), }) @@ -879,6 +885,7 @@ export function createChatTools(api: AgentCommerceClient, opts: { userEmail?: st .positive() .optional() .describe(paramDoc("search_products", "maxPrice")), + currency: z.string().length(3).optional().describe(paramDoc("search_products", "currency")), shipsTo: z.string().length(2).optional().describe(paramDoc("search_products", "shipsTo")), }), execute: (input) => @@ -947,7 +954,7 @@ export function createChatTools(api: AgentCommerceClient, opts: { userEmail?: st label: z.string().min(1).max(60), amount: z .string() - .regex(/^\d+(\.\d{1,2})?$/) + .regex(/^\d+(\.\d{1,4})?$/) .optional() .describe(paramDoc("show_receipt", "itemAmount")), }), diff --git a/apps/web/package.json b/apps/web/package.json index d0f6baf..76df631 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -7,6 +7,7 @@ "build": "next build", "start": "next start", "lint": "eslint .", + "test": "vitest run", "typecheck": "next typegen && tsc --noEmit", "clean": "rm -rf .next .turbo", "db:generate": "drizzle-kit generate", @@ -57,6 +58,7 @@ "eslint-config-next": "16.3.5", "tailwindcss": "^4", "tw-animate-css": "^1.4.0", - "typescript": "^5" + "typescript": "^5", + "vitest": "^5.0.1" } } \ No newline at end of file diff --git a/packages/cli/src/commands/agent-card.ts b/packages/cli/src/commands/agent-card.ts index dafb354..d6a5c71 100644 --- a/packages/cli/src/commands/agent-card.ts +++ b/packages/cli/src/commands/agent-card.ts @@ -51,7 +51,7 @@ export function buildRequestBody( ctx: Pick, ): CreateAgentCardRequestBody { const body: CreateAgentCardRequestBody = { - amount: { value: toDecimalString(opts.amount), currency: opts.currency.toUpperCase() }, + amount: { value: toDecimalString(opts.amount, opts.currency), currency: opts.currency.toUpperCase() }, description: opts.description, expiresInHours: opts.expiresInHours, requester: opts.requester ?? detectRequester(ctx.env, ctx.hostname()), @@ -197,7 +197,7 @@ export function registerAgentCardCommands(program: Command, ctx: CliContext): vo const body: MintCredentialBody = { format: "card" }; if (opts.amount !== undefined) body.amount = { - value: toDecimalString(opts.amount), + value: toDecimalString(opts.amount, opts.currency), currency: opts.currency.toUpperCase(), }; const merchant = merchantFromOptions(opts); diff --git a/packages/cli/src/commands/checkout.ts b/packages/cli/src/commands/checkout.ts index de3198f..ecd6edd 100644 --- a/packages/cli/src/commands/checkout.ts +++ b/packages/cli/src/commands/checkout.ts @@ -59,7 +59,7 @@ interface AnswerOptions extends WaitOptions { export function buildCheckoutBody(opts: CreateOptions): CreateCheckoutBody { const body: CreateCheckoutBody = { startUrl: opts.url, - maxCost: { amount: toDecimalString(opts.maxCost), currency: opts.currency.toUpperCase() }, + maxCost: { amount: toDecimalString(opts.maxCost, opts.currency), currency: opts.currency.toUpperCase() }, }; if (opts.agentCard) body.agentCardId = opts.agentCard; const task = opts.task ?? opts.request; diff --git a/packages/core/src/amount.ts b/packages/core/src/amount.ts index 7b5ad24..a75bc1d 100644 --- a/packages/core/src/amount.ts +++ b/packages/core/src/amount.ts @@ -9,12 +9,86 @@ export function formatAmount(value: string | number, currency: string, locale = } } -/** Normalize user input like "50", "50.5", "$50" into Crossmint's decimal string. */ -export function toDecimalString(input: string | number): string { +/** + * Where `Intl`'s display digits differ from ISO 4217 minor units. Amounts + * here feed payment rails and minor-unit prices, so the ISO count is the + * one that matters: COP displays with no decimals but its minor unit is the + * centavo, so 5,000,000 minor units is 50,000.00, not 5,000,000. + */ +const ISO_MINOR_UNITS: Record = { + COP: 2, + HUF: 2, + IDR: 2, + PKR: 2, + TWD: 2, + MGA: 2, + MRU: 2, + IQD: 3, +}; + +/** + * The decimals a currency allows: 2 for USD, 0 for CLP or JPY, 3 for KWD. + * Throws on a code `Intl` does not know, so a typo fails loudly here rather + * than as a rejected payment downstream. `fallback` returns instead of + * throwing, for display paths that should degrade to the common shape. + */ +export function currencyFractionDigits(currency: string, fallback?: number): number { + const code = currency.toUpperCase(); + const unknown = (): number => { + if (fallback !== undefined) return fallback; + throw new Error(`Unknown currency: ${currency}`); + }; + // NumberFormat only checks the code's shape: "ZZZ" formats without + // throwing, so membership has to come from the ISO list itself. + if (SUPPORTED_CURRENCIES !== null && !SUPPORTED_CURRENCIES.has(code)) { + return unknown(); + } + if (code in ISO_MINOR_UNITS) return ISO_MINOR_UNITS[code]!; + try { + const digits = new Intl.NumberFormat("en", { + style: "currency", + currency: code, + }).resolvedOptions().maximumFractionDigits; + if (digits === undefined) return unknown(); + return digits; + } catch { + return unknown(); + } +} + +const SUPPORTED_CURRENCIES = + typeof Intl.supportedValuesOf === "function" ? new Set(Intl.supportedValuesOf("currency")) : null; + +/** Fraction digits that carry a value — trailing zeros don't count: "1.250" → 2, "10000.00" → 0. */ +export function significantDecimals(value: string): number { + const frac = value.split(".")[1] ?? ""; + return frac.replace(/0+$/, "").length; +} + +/** + * Normalize user input like "50", "50.5", "$50" into Crossmint's decimal + * string, with exactly the decimals the currency allows: "10000" for CLP, + * "25.00" for USD, "1.250" for KWD. An input that needs more decimals than + * the currency has — "10000.5" in CLP — throws rather than silently rounding. + */ +export function toDecimalString(input: string | number, currency: string): string { const cleaned = typeof input === "number" ? String(input) : input.replace(/[^0-9.]/g, ""); const n = Number.parseFloat(cleaned); if (!Number.isFinite(n) || n < 0) throw new Error(`Invalid amount: ${input}`); - return n.toFixed(2); + const digits = currencyFractionDigits(currency); + const rounded = Number(n.toFixed(digits)); + const excess = + typeof input === "string" + ? // A written-out amount states its decimals exactly; trailing zeros + // don't count, any other digit past the currency's does. + significantDecimals(cleaned) > digits + : // A number only carries float dust (0.1 + 0.2); compare within a few + // ulps so real fractions throw at any magnitude. + Math.abs(n - rounded) > Number.EPSILON * Math.max(1, Math.abs(n)) * 8; + if (excess) { + throw new Error(`Invalid amount: ${input} has more decimals than ${currency} allows`); + } + return rounded.toFixed(digits); } /** ISO timestamp `hours` from now, for `expiresAt`. */ diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index f5cc4e3..0d259dc 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -5,11 +5,7 @@ export class CrossmintApiError extends Error { readonly url: string; constructor(opts: { status: number; url: string; body: unknown; message?: string }) { - const bodyMessage = - typeof opts.body === "object" && opts.body !== null && "message" in opts.body - ? String((opts.body as { message: unknown }).message) - : undefined; - super(opts.message ?? bodyMessage ?? `Crossmint request failed with ${opts.status}`); + super(opts.message ?? bodyMessage(opts.body) ?? `Crossmint request failed with ${opts.status}`); this.name = "CrossmintApiError"; this.status = opts.status; this.url = opts.url; @@ -39,6 +35,37 @@ export class CrossmintApiError extends Error { } } +/** + * The message in an error body, wherever Crossmint puts it: top-level + * `message`, or nested under `error`, `provider`/`providerError`, or + * `details` — a 502's real reason sits in one of these, not in the status. + */ +function bodyMessage(body: unknown): string | undefined { + if (typeof body !== "object" || body === null) return undefined; + const record = body as Record; + const at = (obj: unknown, ...keys: string[]): string | undefined => { + if (typeof obj !== "object" || obj === null) return undefined; + const rec = obj as Record; + for (const key of keys) { + const value = rec[key]; + if (typeof value === "string" && value.length > 0) return value; + // NestJS-style validation errors carry message: string[]. + if (Array.isArray(value)) { + const joined = value + .filter((v): v is string => typeof v === "string" && v.length > 0) + .join("; "); + if (joined) return joined; + } + if (typeof value === "object" && value !== null) { + const nested = at(value, "message", "detail", "error"); + if (nested) return nested; + } + } + return undefined; + }; + return at(record, "message") ?? at(record, "error", "provider", "providerError", "details"); +} + /** Crossmint's code for a mint that needs the security code typed again. */ export const CVC_RECOLLECTION_REQUIRED = "ORDER_INTENT_CVC_RECOLLECTION_REQUIRED"; diff --git a/packages/core/src/tool-docs.ts b/packages/core/src/tool-docs.ts index 9f589c1..e636dc5 100644 --- a/packages/core/src/tool-docs.ts +++ b/packages/core/src/tool-docs.ts @@ -224,6 +224,7 @@ export const TOOL_DOCS = { params: { query: "What to look for, in plain words, e.g. sour gummy candy or a black wool beanie.", maxPrice: "Most the user wants to pay per item, in major units, when they said so.", + currency: "The currency maxPrice is in. Default USD.", shipsTo: "Two-letter country the item must ship to. Default US.", }, surfaces: ["chat"], @@ -299,7 +300,8 @@ export const TOOL_DOCS = { checkoutId: PARAM_DOCS.checkoutId, requestId: "The pending request id from get_checkout.", action: "submit (default), decline, or alternative.", - values: "For submit: an answer for each field, keyed by field key. Text as a string, a number, true or false, a choice's option value, or a list of option values for a choice of many.", + values: + "For submit: an answer for each field, keyed by field key. Text as a string, a number, true or false, a choice's option value, or a list of option values for a choice of many.", text: "Free text: the alternative, or a note for the agent.", }, surfaces: ["mcp", "chat"], diff --git a/packages/core/test/amount.test.ts b/packages/core/test/amount.test.ts new file mode 100644 index 0000000..29bd478 --- /dev/null +++ b/packages/core/test/amount.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { currencyFractionDigits, significantDecimals, toDecimalString } from "../src/amount.js"; + +describe("currencyFractionDigits", () => { + it("returns the currency's decimals", () => { + expect(currencyFractionDigits("USD")).toBe(2); + expect(currencyFractionDigits("CLP")).toBe(0); + expect(currencyFractionDigits("JPY")).toBe(0); + expect(currencyFractionDigits("KWD")).toBe(3); + expect(currencyFractionDigits("BHD")).toBe(3); + }); + + it("uses ISO minor units where CLDR display digits differ", () => { + expect(currencyFractionDigits("COP")).toBe(2); + expect(currencyFractionDigits("HUF")).toBe(2); + expect(currencyFractionDigits("IDR")).toBe(2); + expect(currencyFractionDigits("PKR")).toBe(2); + expect(currencyFractionDigits("TWD")).toBe(2); + expect(currencyFractionDigits("MGA")).toBe(2); + expect(currencyFractionDigits("MRU")).toBe(2); + expect(currencyFractionDigits("IQD")).toBe(3); + }); + + it("throws on an unknown currency", () => { + expect(() => currencyFractionDigits("ZZZ")).toThrow(/Unknown currency/); + }); + + it("returns the fallback for an unknown currency instead", () => { + expect(currencyFractionDigits("ZZZ", 2)).toBe(2); + }); +}); + +describe("significantDecimals", () => { + it("ignores trailing zeros", () => { + expect(significantDecimals("10000.00")).toBe(0); + expect(significantDecimals("1.250")).toBe(2); + expect(significantDecimals("1.25")).toBe(2); + expect(significantDecimals("10000.5")).toBe(1); + expect(significantDecimals("10")).toBe(0); + }); +}); + +describe("toDecimalString", () => { + it("formats with the currency's decimals", () => { + expect(toDecimalString(10000, "CLP")).toBe("10000"); + expect(toDecimalString("500", "JPY")).toBe("500"); + expect(toDecimalString(5.5, "USD")).toBe("5.50"); + expect(toDecimalString(1.25, "KWD")).toBe("1.250"); + }); + + it("pads shorter input instead of shrinking it", () => { + expect(toDecimalString("1.2", "KWD")).toBe("1.200"); + expect(toDecimalString("50", "USD")).toBe("50.00"); + }); + + it("accepts zero-padded input beyond the decimals", () => { + expect(toDecimalString("10000.00", "CLP")).toBe("10000"); + }); + + it("formats currencies CLDR shows without decimals at their ISO minor units", () => { + expect(toDecimalString(50000, "COP")).toBe("50000.00"); + expect(toDecimalString("1500", "IQD")).toBe("1500.000"); + }); + + it("throws on a non-zero digit past the currency's decimals", () => { + expect(() => toDecimalString("10000.5", "CLP")).toThrow(/more decimals/); + expect(() => toDecimalString("1.234", "USD")).toThrow(/more decimals/); + }); + + it("throws on a fractional unit at any magnitude", () => { + expect(() => toDecimalString("500000000.5", "CLP")).toThrow(/more decimals/); + expect(() => toDecimalString(500000000.5, "CLP")).toThrow(/more decimals/); + }); + + it("accepts float dust from number arithmetic", () => { + expect(toDecimalString(0.1 + 0.2, "USD")).toBe("0.30"); + }); + + it("throws on an unknown currency", () => { + expect(() => toDecimalString(10, "ZZZ")).toThrow(/Unknown currency/); + }); + + it("still rejects bad input", () => { + expect(() => toDecimalString(-5, "USD")).toThrow(/Invalid amount/); + expect(() => toDecimalString("abc", "USD")).toThrow(/Invalid amount/); + expect(() => toDecimalString("", "USD")).toThrow(/Invalid amount/); + }); +}); diff --git a/packages/core/test/errors.test.ts b/packages/core/test/errors.test.ts new file mode 100644 index 0000000..0d3146e --- /dev/null +++ b/packages/core/test/errors.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { CrossmintApiError } from "../src/errors.js"; + +describe("CrossmintApiError", () => { + it("reads a top-level message", () => { + const err = new CrossmintApiError({ + status: 502, + url: "https://api.test/x", + body: { error: true, message: "The order intent provider could not process the request" }, + }); + expect(err.message).toBe("The order intent provider could not process the request"); + }); + + it("reads a message nested under error", () => { + const err = new CrossmintApiError({ + status: 502, + url: "https://api.test/x", + body: { error: { code: "ORDER_INTENT_PROVIDER_ERROR", message: "provider failed" } }, + }); + expect(err.message).toBe("provider failed"); + }); + + it("reads a message nested under a provider object", () => { + const err = new CrossmintApiError({ + status: 502, + url: "https://api.test/x", + body: { provider: { message: "basis theory rejected the amount" } }, + }); + expect(err.message).toBe("basis theory rejected the amount"); + }); + + it("joins a NestJS-style array message", () => { + const err = new CrossmintApiError({ + status: 400, + url: "https://api.test/x", + body: { + message: ["amount.value must be a decimal string", "currency is invalid"], + error: "Bad Request", + }, + }); + expect(err.message).toBe("amount.value must be a decimal string; currency is invalid"); + }); + + it("falls back to the status when the body has no message", () => { + const err = new CrossmintApiError({ status: 502, url: "https://api.test/x", body: {} }); + expect(err.message).toBe("Crossmint request failed with 502"); + const plain = new CrossmintApiError({ + status: 502, + url: "https://api.test/x", + body: "Bad Gateway", + }); + expect(plain.message).toBe("Crossmint request failed with 502"); + }); +}); diff --git a/packages/mcp/src/tools.ts b/packages/mcp/src/tools.ts index d48a2c6..047639a 100644 --- a/packages/mcp/src/tools.ts +++ b/packages/mcp/src/tools.ts @@ -117,7 +117,7 @@ export function registerAgentCommerceTools( guard(async (args) => { const req = await api.createAgentCardRequest({ amount: { - value: toDecimalString(args.amount), + value: toDecimalString(args.amount, args.currency ?? "USD"), currency: (args.currency ?? "USD").toUpperCase(), }, description: args.description, @@ -211,7 +211,7 @@ export function registerAgentCommerceTools( if (args.amount !== undefined) { const currency = args.currency ?? (await api.getAgentCard(args.agentCardId)).amount.currency; - amount = { value: toDecimalString(args.amount), currency: currency.toUpperCase() }; + amount = { value: toDecimalString(args.amount, currency), currency: currency.toUpperCase() }; } const cred = await api.mintCredential(args.agentCardId, { amount, @@ -283,7 +283,7 @@ export function registerAgentCommerceTools( ? {} : { maxCost: { - amount: toDecimalString(args.maxCost), + amount: toDecimalString(args.maxCost, args.currency ?? "USD"), currency: (args.currency ?? "USD").toUpperCase(), }, }), diff --git a/packages/server/src/handlers/agent-card-requests.ts b/packages/server/src/handlers/agent-card-requests.ts index b92bd03..85bf13b 100644 --- a/packages/server/src/handlers/agent-card-requests.ts +++ b/packages/server/src/handlers/agent-card-requests.ts @@ -5,6 +5,7 @@ import { isReadyForAgent, needsCvcRecollection, pendingVerificationRails, + toDecimalString, withAgentRails, } from "@agent-commerce/core"; import { parseBody, requireUser, type Ctx, resolveEmail } from "../context.js"; @@ -93,6 +94,22 @@ export async function approveRequest(req: Request, ctx: Ctx, params: Params): Pr const email = await resolveEmail(user, ctx, body.email); const jwt = { jwt: user.jwt }; + // Stored amounts from before per-currency validation may still carry extra + // decimals; the order intent wants the currency's own precision. Normalized + // before any side effects, so an unusable amount is a clean 400 rather + // than a revoked card plus a 500. + let amount: { value: string; currency: string }; + try { + amount = { + value: toDecimalString(request.amount.value, request.amount.currency), + currency: request.amount.currency.toUpperCase(), + }; + } catch (e) { + throw invalidRequest( + `This request's amount is not usable: ${e instanceof Error ? e.message : e}`, + ); + } + await revokePreviousCard(ctx, jwt, request); // Idempotent. Turns on the network rails the card supports. @@ -117,14 +134,17 @@ export async function approveRequest(req: Request, ctx: Ctx, params: Params): Pr try { agentCard = await ctx.crossmint.orderIntents.create(jwt, { paymentMethodId: body.paymentMethodId, - amount: request.amount, + amount, description: request.description, expiresAt: request.expiresAt, ...(request.merchant ? { merchant: request.merchant } : {}), }); } catch (e) { // No network rail, and Crossmint added no encrypted-card fallback. - if (e instanceof CrossmintApiError && e.code === "ORDER_INTENT_PAYMENT_METHOD_NO_ACTIVE_RAILS") { + if ( + e instanceof CrossmintApiError && + e.code === "ORDER_INTENT_PAYMENT_METHOD_NO_ACTIVE_RAILS" + ) { throw new HttpError( 409, "no_usable_rail", diff --git a/packages/server/src/handlers/checkouts.ts b/packages/server/src/handlers/checkouts.ts index 9331b66..b2aedfd 100644 --- a/packages/server/src/handlers/checkouts.ts +++ b/packages/server/src/handlers/checkouts.ts @@ -6,6 +6,7 @@ import { buyerProfileProblems, canPayAtCheckout, CrossmintApiError, + currencyFractionDigits, declineResponse, expiresInHours, isPaymentRequest, @@ -207,7 +208,15 @@ async function runCeiling( } catch { return fallback; } - const available = Math.floor(Number.parseFloat(card.amount.available) * 100) / 100; + const currency = card.amount.currency.toUpperCase(); + let decimals: number; + try { + decimals = currencyFractionDigits(currency); + } catch { + return fallback; + } + const factor = 10 ** decimals; + const available = Math.floor(Number.parseFloat(card.amount.available) * factor) / factor; if (!(available > 0)) { throw new HttpError( 409, @@ -216,12 +225,11 @@ async function runCeiling( { agentCardId: body.agentCardId }, ); } - const currency = card.amount.currency.toUpperCase(); const limit = body.maxCost && body.maxCost.currency.toUpperCase() === currency ? Number.parseFloat(body.maxCost.amount) : Number.POSITIVE_INFINITY; - return { amount: Math.min(available, limit).toFixed(2), currency }; + return { amount: Math.min(available, limit).toFixed(decimals), currency }; } /** GET /v1/checkouts/:id */ diff --git a/packages/server/src/schemas.ts b/packages/server/src/schemas.ts index c716489..f8f35c1 100644 --- a/packages/server/src/schemas.ts +++ b/packages/server/src/schemas.ts @@ -1,9 +1,44 @@ import { z } from "zod"; +import { currencyFractionDigits, significantDecimals } from "@agent-commerce/core"; -export const amountSchema = z.object({ - value: z.string().regex(/^\d+(\.\d{1,2})?$/, 'Use a decimal string like "50.00"'), - currency: z.string().length(3), -}); +/** + * The significant decimals in an amount's `value` must not exceed what its + * `currency` allows — trailing zeros carry no value, so "10000.00" CLP is + * fine while "10000.5" CLP is a 400 here rather than a 502 at Crossmint. + * An unknown currency is rejected too. + */ +function checkCurrencyDecimals( + amount: { value: string; currency: string }, + ctx: z.RefinementCtx, + valueKey = "value", +): void { + let allowed: number; + try { + allowed = currencyFractionDigits(amount.currency); + } catch { + ctx.addIssue({ + code: "custom", + path: ["currency"], + message: `Unknown currency: ${amount.currency}`, + }); + return; + } + const used = significantDecimals(amount.value); + if (used > allowed) { + ctx.addIssue({ + code: "custom", + path: [valueKey], + message: `${amount.currency.toUpperCase()} amounts support at most ${allowed} decimal places`, + }); + } +} + +export const amountSchema = z + .object({ + value: z.string().regex(/^\d+(\.\d{1,8})?$/, 'Use a decimal string like "50.00"'), + currency: z.string().length(3), + }) + .superRefine((amount, ctx) => checkCurrencyDecimals(amount, ctx)); export const merchantSchema = z.object({ name: z.string().min(1), @@ -43,7 +78,7 @@ export const credentialsSchema = z.object({ format: z.literal("card").optional(), }); -const decimalAmount = z.string().regex(/^\d+(\.\d{1,6})?$/); +const decimalAmount = z.string().regex(/^\d+(\.\d{1,8})?$/); /** * Body of POST /v1/checkouts. `startUrl` and `task` mirror Crossmint's @@ -68,6 +103,9 @@ export const createCheckoutSchema = z amount: decimalAmount, currency: z.string().length(3), }) + .superRefine((cost, ctx) => + checkCurrencyDecimals({ value: cost.amount, currency: cost.currency }, ctx, "amount"), + ) .optional(), /** The store's currency, for the default ceiling when there is no maxCost and no agent card. */ currency: z.string().length(3).optional(), diff --git a/packages/server/test/handlers.test.ts b/packages/server/test/handlers.test.ts index 5136b4d..0606c1d 100644 --- a/packages/server/test/handlers.test.ts +++ b/packages/server/test/handlers.test.ts @@ -148,7 +148,12 @@ describe("agent card requests", () => { body: { paymentMethodId: "pm_1", rails: [ - { rail: "agentic-token", provider: "vic", status: "error", error: { code: "CARD_NOT_ELIGIBLE" } }, + { + rail: "agentic-token", + provider: "vic", + status: "error", + error: { code: "CARD_NOT_ELIGIBLE" }, + }, ], }, }, @@ -239,6 +244,86 @@ describe("agent card requests", () => { expect(polled.status).toBe("active"); }); + it("rejects an amount with more decimals than its currency allows", async () => { + const { handlers } = makeServer(); + const res = await call(handlers, "POST", "/v1/agent-card-requests", { + body: { ...requestBody, amount: { value: "10000.5", currency: "CLP" } }, + }); + expect(res.status).toBe(400); + expect((await res.json()).error.code).toBe("invalid_request"); + }); + + it("accepts a zero-padded amount for a zero-decimal currency", async () => { + const { handlers } = makeServer(); + const res = await call(handlers, "POST", "/v1/agent-card-requests", { + body: { ...requestBody, amount: { value: "10000.00", currency: "CLP" } }, + }); + expect(res.status).toBe(201); + }); + + it("approve rejects an unusable stored amount before touching Crossmint", async () => { + const { handlers, store, calls } = makeServer(); + await store.create({ + id: "acr_bad", + userId: "user-test-1", + requester: "Claude Code", + amount: { value: "10000.50", currency: "CLP" }, + description: "Mercado Libre order", + expiresAt: "2099-01-01T00:00:00.000Z", + requestExpiresAt: "2099-01-01T00:00:00.000Z", + status: "approved", + agentCardId: "oi_prev", + approvalUrl: "https://wallet.test/approve/acr_bad", + }); + + const res = await call(handlers, "POST", "/v1/agent-card-requests/acr_bad/approve", { + body: { paymentMethodId: "pm_1" }, + }); + expect(res.status).toBe(400); + expect((await res.json()).error.code).toBe("invalid_request"); + expect(findCall(calls, "POST", "/unstable/order-intents")).toBeUndefined(); + }); + + it("approve sends a zero-decimal currency without decimals", async () => { + const { handlers, store, calls } = makeServer([ + { + method: "PUT", + path: "/order-intent-registration", + reply: { + body: { + paymentMethodId: "pm_1", + rails: [{ rail: "agentic-token", provider: "vic", status: "enabled" }], + }, + }, + }, + { + method: "POST", + path: "/unstable/order-intents", + reply: { status: 201, body: activeOrderIntent() }, + }, + ]); + // A request stored before per-currency validation kept its padded + // decimals; the order intent still goes out in the currency's own. + await store.create({ + id: "acr_clp", + userId: "user-test-1", + requester: "Claude Code", + amount: { value: "10000.00", currency: "CLP" }, + description: "Mercado Libre order", + expiresAt: "2099-01-01T00:00:00.000Z", + requestExpiresAt: "2099-01-01T00:00:00.000Z", + status: "pending", + approvalUrl: "https://wallet.test/approve/acr_clp", + }); + + const res = await call(handlers, "POST", "/v1/agent-card-requests/acr_clp/approve", { + body: { paymentMethodId: "pm_1" }, + }); + expect(res.status).toBe(200); + const create = findCall(calls, "POST", "/unstable/order-intents")!; + expect(create.body).toMatchObject({ amount: { value: "10000", currency: "CLP" } }); + }); + it("approve stays approved when the rail needs verification, then verified flips it", async () => { const pending = activeOrderIntent({ rails: [{ rail: "agentic-token", provider: "vic", status: "pending_verification" }], @@ -543,7 +628,12 @@ describe("POST /v1/agent-cards/:id/credentials", () => { reply: { body: activeOrderIntent({ rails: [ - { rail: "agentic-token", provider: "vic", status: "active", credentialFormats: ["card"] }, + { + rail: "agentic-token", + provider: "vic", + status: "active", + credentialFormats: ["card"], + }, { rail: "encrypted-card", status: "pending_cvc_recollection" }, ], }), @@ -785,7 +875,13 @@ describe("checkouts", () => { { method: "GET", path: "/unstable/agent-checkouts/run_named", - reply: { body: run({ runId: "run_named", status: "awaiting_input", requiredAction: paymentRequest }) }, + reply: { + body: run({ + runId: "run_named", + status: "awaiting_input", + requiredAction: paymentRequest, + }), + }, }, ]); await call(handlers, "POST", "/v1/checkouts", { @@ -1000,9 +1096,11 @@ describe("checkouts", () => { }, }); const sentCeiling = (calls: FakeCall[]) => - (findCall(calls, "POST", /\/unstable\/agent-checkouts$/)!.body as { - constraints: { maxCost: unknown }; - }).constraints.maxCost; + ( + findCall(calls, "POST", /\/unstable\/agent-checkouts$/)!.body as { + constraints: { maxCost: unknown }; + } + ).constraints.maxCost; it("is what the agent card has left, not a higher limit", async () => { const { handlers, calls } = makeServer([createRoute, card("42.50")]); @@ -1227,7 +1325,9 @@ describe("checkouts", () => { method: "GET", path: "/unstable/agent-checkouts/run_mine", once: true, - reply: { body: run({ runId: "run_mine", status: "awaiting_input", requiredAction: payStep }) }, + reply: { + body: run({ runId: "run_mine", status: "awaiting_input", requiredAction: payStep }), + }, }, { method: "GET", path: "/unstable/order-intents/oi_1", reply: { body: activeOrderIntent() } }, { @@ -1254,13 +1354,24 @@ describe("checkouts", () => { body: { agentCardId: "oi_1" }, }); expect(res.status).toBe(200); - expect(await res.json()).toMatchObject({ id: "run_mine", status: "running", agentCardId: "oi_1" }); + expect(await res.json()).toMatchObject({ + id: "run_mine", + status: "running", + agentCardId: "oi_1", + }); expect(calls.some((c) => c.path.endsWith("/credentials"))).toBe(false); expect(findCall(calls, "POST", "/run_mine/messages")!.body).toMatchObject( paidWith("oi_1", "req_pay_existing"), ); // No new agent card request: the user chose a card they had. - expect(calls.some((c) => c.method === "POST" && c.path.includes("order-intents") && !c.path.endsWith("/credentials"))).toBe(false); + expect( + calls.some( + (c) => + c.method === "POST" && + c.path.includes("order-intents") && + !c.path.endsWith("/credentials"), + ), + ).toBe(false); vi.restoreAllMocks(); }); @@ -1279,7 +1390,9 @@ describe("checkouts", () => { method: "GET", path: "/unstable/agent-checkouts/run_retry", once: true, - reply: { body: run({ runId: "run_retry", status: "awaiting_input", requiredAction: first }) }, + reply: { + body: run({ runId: "run_retry", status: "awaiting_input", requiredAction: first }), + }, }, { method: "GET", path: "/unstable/order-intents/oi_1", reply: { body: activeOrderIntent() } }, { @@ -1290,13 +1403,17 @@ describe("checkouts", () => { { method: "GET", path: "/unstable/agent-checkouts/run_retry", - reply: { body: run({ runId: "run_retry", status: "awaiting_input", requiredAction: again }) }, + reply: { + body: run({ runId: "run_retry", status: "awaiting_input", requiredAction: again }), + }, }, ]); await call(handlers, "POST", "/v1/checkouts", { body: { startUrl: "https://shop.example/p/1", maxCost: { amount: "30.00", currency: "USD" } }, }); - await call(handlers, "POST", "/v1/checkouts/run_retry/agent-card", { body: { agentCardId: "oi_1" } }); + await call(handlers, "POST", "/v1/checkouts/run_retry/agent-card", { + body: { agentCardId: "oi_1" }, + }); expect(calls.filter((c) => c.path.endsWith("/run_retry/messages"))).toHaveLength(1); const view = await (await call(handlers, "GET", "/v1/checkouts/run_retry")).json(); @@ -1330,7 +1447,9 @@ describe("checkouts", () => { method: "GET", path: "/unstable/agent-checkouts/run_note", once: true, - reply: { body: run({ runId: "run_note", status: "awaiting_input", requiredAction: question }) }, + reply: { + body: run({ runId: "run_note", status: "awaiting_input", requiredAction: question }), + }, }, { method: "POST", @@ -1352,7 +1471,14 @@ describe("checkouts", () => { expect(res.status).toBe(200); const sent = calls.filter((c) => c.method === "POST" && c.path.endsWith("/run_note/messages")); expect(sent.map((c) => (c.body as { parts: unknown[] }).parts)).toEqual([ - [{ type: "input_response", requestId: "req_size", action: "submit", response: { kind: "form", answers: { size: "M" } } }], + [ + { + type: "input_response", + requestId: "req_size", + action: "submit", + response: { kind: "form", answers: { size: "M" } }, + }, + ], [{ type: "text", text: "Gift wrap it, please" }], ]); }); @@ -1375,7 +1501,13 @@ describe("checkouts", () => { reply: { body: activeOrderIntent({ orderIntentId: "oi_spent", - amount: { currency: "USD", total: "50.00", available: "0.00", reserved: "0.00", spent: "50.00" }, + amount: { + currency: "USD", + total: "50.00", + available: "0.00", + reserved: "0.00", + spent: "50.00", + }, }), }, }, @@ -1532,7 +1664,10 @@ describe("checkouts", () => { ]); // The form renders with the protected field's descriptor, for Crossmint's field. const view = await (await call(handlers, "GET", "/v1/checkouts/run_pw")).json(); - expect(view.rendered.fields.map((f: { kind: string }) => f.kind)).toEqual(["text", "protected"]); + expect(view.rendered.fields.map((f: { kind: string }) => f.kind)).toEqual([ + "text", + "protected", + ]); expect(view.rendered.fields[1].protectedField).toEqual(password); // Where the user answers it, for a caller that cannot show the field itself. expect(view.protectedRequest).toEqual({ @@ -1552,7 +1687,9 @@ describe("checkouts", () => { expect(refused.status).toBe(409); expect((await refused.json()).error.code).toBe("protected_input_required"); } - expect(calls.some((c) => c.method === "POST" && c.path.endsWith("/run_pw/messages"))).toBe(false); + expect(calls.some((c) => c.method === "POST" && c.path.endsWith("/run_pw/messages"))).toBe( + false, + ); // A protected input id on a standard field is refused too. const misplaced = await call(handlers, "POST", "/v1/checkouts/run_pw/messages", { @@ -1563,7 +1700,9 @@ describe("checkouts", () => { }); expect(misplaced.status).toBe(409); expect((await misplaced.json()).error.code).toBe("not_a_protected_field"); - expect(calls.some((c) => c.method === "POST" && c.path.endsWith("/run_pw/messages"))).toBe(false); + expect(calls.some((c) => c.method === "POST" && c.path.endsWith("/run_pw/messages"))).toBe( + false, + ); const reply = await call(handlers, "POST", "/v1/checkouts/run_pw/messages", { body: { @@ -1581,7 +1720,10 @@ describe("checkouts", () => { type: "input_response", requestId: "req_pw", action: "submit", - response: { kind: "form", answers: { email: "a@b.co", password: { protectedInputId: "pi_1" } } }, + response: { + kind: "form", + answers: { email: "a@b.co", password: { protectedInputId: "pi_1" } }, + }, }, ], }); @@ -1604,7 +1746,9 @@ describe("checkouts", () => { { method: "GET", path: "/unstable/agent-checkouts/run_pwf", - reply: { body: run({ runId: "run_pwf", status: "awaiting_input", requiredAction: passwordForm }) }, + reply: { + body: run({ runId: "run_pwf", status: "awaiting_input", requiredAction: passwordForm }), + }, }, ]); const view = await (await call(handlers, "GET", "/v1/checkouts/run_pwf")).json(); @@ -1616,7 +1760,9 @@ describe("checkouts", () => { }); expect(res.status).toBe(409); expect((await res.json()).error.code).toBe("password_in_form"); - expect(calls.some((c) => c.method === "POST" && c.path.endsWith("/run_pwf/messages"))).toBe(false); + expect(calls.some((c) => c.method === "POST" && c.path.endsWith("/run_pwf/messages"))).toBe( + false, + ); }); it("refuses card fields from callers and maps blocked runs to a failure", async () => { @@ -1663,8 +1809,15 @@ describe("checkouts", () => { describe("sticky browser sessions", () => { const created = { status: 202, body: { runId: "run_1", status: "queued" } }; - const createRoute = { method: "POST" as const, path: /\/unstable\/agent-checkouts$/, reply: created }; - const body = { startUrl: "https://shop.example/p/1", maxCost: { amount: "5.00", currency: "USD" } }; + const createRoute = { + method: "POST" as const, + path: /\/unstable\/agent-checkouts$/, + reply: created, + }; + const body = { + startUrl: "https://shop.example/p/1", + maxCost: { amount: "5.00", currency: "USD" }, + }; it("makes the user's profile the first time and reuses it after", async () => { let profiles: Array<{ id: string; label: string }> = []; @@ -1689,7 +1842,10 @@ describe("sticky browser sessions", () => { const made = findCall(calls, "POST", "/browser-profiles")!; // Server key plus the user id: without the header every end user's logins // would pile into the project's own shared profile. - expect(made.headers).toMatchObject({ "X-API-KEY": "sk_test", "x-crossmint-user-id": "user-test-1" }); + expect(made.headers).toMatchObject({ + "X-API-KEY": "sk_test", + "x-crossmint-user-id": "user-test-1", + }); expect(made.body).toEqual({ label: "Merchant logins" }); expect(findCall(calls, "POST", /\/unstable\/agent-checkouts$/)!.body).toMatchObject({ browserProfileId: "bp_new", @@ -1717,7 +1873,10 @@ describe("sticky browser sessions", () => { { method: "POST", path: "/unstable/agent-checkouts/browser-profiles", - reply: { status: 409, body: { code: "already_exists", message: "The user already has a browser profile." } }, + reply: { + status: 409, + body: { code: "already_exists", message: "The user already has a browser profile." }, + }, }, { method: "GET", @@ -1761,7 +1920,9 @@ describe("sticky browser sessions", () => { expect(findCall(calls, "GET", "/browser-profiles")).toBeUndefined(); calls.length = 0; - await call(handlers, "POST", "/v1/checkouts", { body: { ...body, browserProfileId: "bp_theirs" } }); + await call(handlers, "POST", "/v1/checkouts", { + body: { ...body, browserProfileId: "bp_theirs" }, + }); expect(findCall(calls, "POST", /\/unstable\/agent-checkouts$/)!.body).toMatchObject({ browserProfileId: "bp_theirs", }); @@ -1771,7 +1932,11 @@ describe("sticky browser sessions", () => { it("deletes the saved logins and makes a new profile next time", async () => { const { handlers, calls } = makeServer([ createRoute, - { method: "DELETE", path: "/unstable/agent-checkouts/browser-profiles/", reply: { status: 204 } }, + { + method: "DELETE", + path: "/unstable/agent-checkouts/browser-profiles/", + reply: { status: 204 }, + }, ]); const res = await call(handlers, "DELETE", "/v1/browser-profile"); expect(res.status).toBe(204); @@ -1793,8 +1958,15 @@ describe("sticky browser sessions", () => { describe("saved buyer details", () => { const created = { status: 202, body: { runId: "run_1", status: "queued" } }; - const createRoute = { method: "POST" as const, path: /\/unstable\/agent-checkouts$/, reply: created }; - const body = { startUrl: "https://shop.example/p/1", maxCost: { amount: "5.00", currency: "USD" } }; + const createRoute = { + method: "POST" as const, + path: /\/unstable\/agent-checkouts$/, + reply: created, + }; + const body = { + startUrl: "https://shop.example/p/1", + maxCost: { amount: "5.00", currency: "USD" }, + }; const details = { label: "Home", name: { first: "Ada", last: "Lovelace" }, @@ -1818,7 +1990,12 @@ describe("saved buyer details", () => { body: { data: [ { id: "byp_old", ...details, createdAt: "2026-01-01T00:00:00.000Z" }, - { id: "byp_new", ...details, label: "New flat", createdAt: "2026-06-01T00:00:00.000Z" }, + { + id: "byp_new", + ...details, + label: "New flat", + createdAt: "2026-06-01T00:00:00.000Z", + }, ], nextCursor: null, }, @@ -1831,8 +2008,12 @@ describe("saved buyer details", () => { }); // A caller that names its own profile keeps it. - await call(handlers, "POST", "/v1/checkouts", { body: { ...body, buyerProfileId: "byp_mine" } }); - expect(calls.filter((c) => c.method === "POST" && /agent-checkouts$/.test(c.path)).at(-1)!.body).toMatchObject({ + await call(handlers, "POST", "/v1/checkouts", { + body: { ...body, buyerProfileId: "byp_mine" }, + }); + expect( + calls.filter((c) => c.method === "POST" && /agent-checkouts$/.test(c.path)).at(-1)!.body, + ).toMatchObject({ buyerProfileId: "byp_mine", }); }); @@ -1941,7 +2122,11 @@ describe("saved buyer details", () => { reply: { body: { data: [], nextCursor: null } }, }, // One is gone already: that is no reason to fail. - { method: "DELETE", path: "/buyer-profiles/byp_old", reply: { status: 404, body: { message: "Not found" } } }, + { + method: "DELETE", + path: "/buyer-profiles/byp_old", + reply: { status: 404, body: { message: "Not found" } }, + }, { method: "DELETE", path: "/buyer-profiles/byp_new", reply: { status: 204 } }, { method: "POST", @@ -1973,7 +2158,11 @@ describe("saved buyer details", () => { path: "/unstable/agent-checkouts/buyer-profiles", reply: { body: { data: [{ id: "byp_1", ...details }], nextCursor: null } }, }, - { method: "DELETE", path: "/buyer-profiles/byp_1", reply: { status: 403, body: { message: "Missing scope" } } }, + { + method: "DELETE", + path: "/buyer-profiles/byp_1", + reply: { status: 403, body: { message: "Missing scope" } }, + }, ]); const res = await call(handlers, "DELETE", "/v1/buyer-profile"); expect(res.status).toBeGreaterThanOrEqual(400); @@ -1988,7 +2177,10 @@ describe("saved buyer details", () => { }, ]); const res = await call(handlers, "POST", "/v1/buyer-profiles", { - body: { ...details, shipping: { ...details.shipping, administrativeAreaCode: "il", countryCode: "us" } }, + body: { + ...details, + shipping: { ...details.shipping, administrativeAreaCode: "il", countryCode: "us" }, + }, }); expect(res.status).toBe(201); expect(findCall(calls, "POST", "/buyer-profiles")!.body).toMatchObject({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 842c984..11f4dec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -147,6 +147,9 @@ importers: typescript: specifier: ^5 version: 5.9.3 + vitest: + specifier: ^5.0.1 + version: 5.0.1(@types/node@20.19.43)(vite@8.3.0(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.1)) packages/auth: dependencies: @@ -14963,6 +14966,15 @@ snapshots: '@vercel/cli-exec': 1.0.1 jose: 5.10.0 + '@vitest/mocker@5.0.1(vite@8.3.0(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.1))': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.0.1 + estree-walker: 3.0.3 + magic-string: 1.4.1 + optionalDependencies: + vite: 8.3.0(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.1) + '@vitest/mocker@5.0.1(vite@8.3.0(@types/node@22.20.2)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.1))': dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -20866,6 +20878,22 @@ snapshots: - utf-8-validate - zod + vite@8.3.0(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.1): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.28 + rolldown: 1.2.8 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 20.19.43 + esbuild: 0.28.2 + fsevents: 2.3.3 + jiti: 2.7.0 + terser: 5.51.2 + tsx: 4.23.13 + yaml: 2.9.1 + vite@8.3.0(@types/node@22.20.2)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.1): dependencies: lightningcss: 1.33.0 @@ -20898,6 +20926,27 @@ snapshots: tsx: 4.23.13 yaml: 2.9.1 + vitest@5.0.1(@types/node@20.19.43)(vite@8.3.0(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.1)): + dependencies: + '@types/chai': 5.2.3 + '@vitest/mocker': 5.0.1(vite@8.3.0(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.1)) + chai: 6.2.2 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 1.4.1 + obug: 2.2.1 + picomatch: 4.0.7 + std-env: 4.2.0 + tinybench: 6.1.4 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + vite: 8.3.0(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 20.19.43 + transitivePeerDependencies: + - msw + vitest@5.0.1(@types/node@22.20.2)(vite@8.3.0(@types/node@22.20.2)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.1)): dependencies: '@types/chai': 5.2.3