Skip to content

amounts: format with each currency's decimal places - #6

Merged
antonio-xm merged 5 commits into
mainfrom
fix/amount-currency-decimals
Oct 26, 2026
Merged

antonio-xm merged 5 commits into
mainfrom
fix/amount-currency-decimals

Conversation

@antonio-xm

Copy link
Copy Markdown
Contributor

Summary

Amounts were always formatted with 2 decimals, which breaks zero-decimal currencies (CLP, JPY, KRW) and 3-decimal currencies (KWD, BHD). On 2026-10-23 a user approving a CLP 10,000 budget got repeated 502 errors: the server sent {"value":"10000.00","currency":"CLP"} to Crossmint and the order-intent provider rejected it because CLP supports at most 0 decimal places.

toDecimalString in @agent-commerce/core now takes a required currency argument and formats to exactly that currency's decimals via a new currencyFractionDigits helper (resolved through Intl.NumberFormat, with an Intl.supportedValuesOf("currency") membership check since NumberFormat accepts any 3-letter-shaped code). Inputs with a real digit beyond the allowed decimals throw instead of silently rounding, and every caller — MCP tools, CLI commands, budget.ts, payment-choice.ts, runCeiling, and the approve handler — passes its currency through, so CLP produces "10000", USD "25.00", KWD "1.250".

Changes beyond the helper:

  • amountSchema / decimalAmount in packages/server/src/schemas.ts validate decimal places against the currency via superRefine, so {"value":"10000.5","currency":"CLP"} now returns 400 from this server instead of reaching Crossmint as a 502.
  • approveRequest re-normalizes the stored amount with toDecimalString at send time, which also fixes legacy rows persisted in the old "10000.00" format.
  • shopify-catalog.ts: money.amount is in minor units (the price.max search filter multiplies by 100, i.e. cents), so it now divides by 10^decimals and formats with the currency's decimals instead of a fixed /100 + toFixed(2). The price: { max: maxPrice * 100 } filter still assumes 2-decimal currencies — left as-is since the catalog filter is approximate, but worth knowing.
  • Error surfacing: CrossmintApiError only read a top-level message. Crossmint's current 502 body is {error: true, message, code}, which that code already covers, so the generic "Crossmint request failed with 502" the user saw likely came from an older deployment or a non-JSON gateway body. The extraction is hardened anyway: bodyMessage now also searches error/provider/providerError/details objects for a nested message/detail/error string.
  • apps/web had no test script; added vitest run so the new budget.test.ts runs under pnpm test.

Test plan

  • pnpm build, pnpm lint, pnpm typecheck, pnpm test — all green (12/12 turbo tasks).
  • New tests, each failing on the previous code:
    • packages/core/test/amount.test.ts — per-currency digits (USD/CLP/JPY/KWD/BHD), "10000"/"500"/"5.50"/"1.250" formatting, extra-decimal and unknown-currency throws.
    • packages/core/test/errors.test.ts — nested message extraction.
    • apps/web/lib/chat/budget.test.ts — budgetAmounts/parseBudgetAmount/budgetFor emit "10000" for CLP.
    • packages/server/test/handlers.test.ts — POST /v1/agent-card-requests/:id/approve on a CLP request sends amount.value === "10000" to the mocked Crossmint client; amountSchema rejects {"value":"10000.5","currency":"CLP"} with 400.

Link to Devin session: https://crossmint.devinenterprise.com/sessions/d80cdb1ac71f4bef9ae514e311de28f3
Open in Devin Desktop: https://crossmint.devinenterprise.com/desktop/session/d80cdb1ac71f4bef9ae514e311de28f3?variant=devin
Requested by: @antonio-xm

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@devin-ai-integration

Copy link
Copy Markdown
Contributor

I'll fix CI failures and address comments from users with write access that start with 'Devin'.

  • Disable automatic comment, CI, and merge conflict monitoring

Original prompt from antonio.garcia

Repository: Crossmint/agent-commerce-sample-app.

Fix a bug where amounts are always formatted with 2 decimals, breaking zero-decimal currencies (CLP, JPY, KRW) and 3-decimal currencies (KWD, BHD). On 2026-10-23 a user approving a CLP 10,000 budget got repeated "502" errors because the server sent "amount":{"value":"10000.00","currency":"CLP"} and Basis Theory rejected it ("CLP amounts support at most 0 decimal places"). The sample app must format amounts with the correct number of decimals per currency and never depend on Crossmint to normalize them.

  1. In packages/core/src/amount.ts:

    • Add a helper that returns a currency's fraction digits via new Intl.NumberFormat("en", { style: "currency", currency }).resolvedOptions().maximumFractionDigits, throwing a clear error for an unknown/invalid currency.
    • Change toDecimalString(input, currency) to take a required currency argument. It must: clean/parse the input as today; reject negative/non-finite input; format to exactly the currency's number of decimals (CLP "10000", USD "25.00", KWD "1.250"); and throw a clear error (do NOT silently round) when the input has a non-zero digit beyond the allowed decimals (e.g. CLP "10000.5" throws). Keep it the single place that formats an amount for Crossmint.
    • Keep formatAmount working; the Intl path already respects currency decimals.
  2. Update every toDecimalString caller to pass the currency it already has:

    • packages/mcp/src/tools.ts (the request_agent_card, reveal_agent_card, and create_checkout tools) — pass the resolved currency (default "USD").
    • packages/cli/src/commands/agent-card.ts and packages/cli/src/commands/checkout.ts — pass opts.currency.
  3. In apps/web/lib/chat/budget.ts (budgetAmounts, parseBudgetAmount) and apps/web/lib/chat/payment-choice.ts (budgetFor), format the value with the currency's decimal places instead of toFixed(2). Import/use the core helper so CLP produces "10000". Keep ... (2632 chars truncated...)

@vercel

vercel Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
agent-commerce-sample-app Ready Ready Preview Oct 26, 2026 2:59pm UTC

Request Review

@antonio-xm
antonio-xm marked this pull request as ready for review October 26, 2026 13:59
Comment thread packages/server/src/schemas.ts Resolved
Comment thread packages/core/src/amount.ts Resolved
Comment thread apps/web/lib/chat/budget.ts Resolved
Comment thread packages/core/src/errors.ts Resolved
Comment thread apps/web/lib/chat/payment-choice.ts Resolved
Comment thread apps/web/lib/chat/budget.ts Resolved
Comment thread apps/web/lib/chat/shopify-catalog.ts Resolved
Comment thread apps/web/lib/chat/budget.ts Resolved
Comment thread pnpm-lock.yaml Resolved
…r handling

- currencyFractionDigits: ISO 4217 minor-unit overrides where CLDR
  display digits differ (COP, HUF, IDR, PKR, TWD, MGA, MRU at 2; IQD at
  3), plus an optional fallback for display-side callers
- schemas validate significant decimals so "10000.00" CLP is accepted
  while "10000.5" still 400s; shared via core significantDecimals
- web budget/parse helpers fall back to 2 decimals on unknown currency
  instead of throwing mid-render
- CrossmintApiError joins NestJS-style message arrays
- approveRequest normalizes the stored amount before revoking the card
  and wraps failure in a 400 HttpError
- widen chat amount patterns to 3 decimals and keep toDecimalString as
  the strict gate; parseBudgetAmount treats a comma with exactly 3
  trailing digits as a thousands separator
- search_products maxPrice filter multiplies by the currency's
  minor-unit factor; new optional currency param
- restore unrelated esbuild lockfile churn

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
devin-ai-integration[bot]

This comment was marked as resolved.

- toDecimalString: string inputs now throw on any significant digit past
  the currency's decimals at any magnitude (the old magnitude-scaled
  tolerance accepted "500000000.5" CLP and silently rounded up); float
  dust tolerance is isolated to number inputs and scaled to a few ulps
- parsePrice accepts four-decimal catalog prices (CLF), so a CLF price
  keeps its currency instead of falling back to a USD budget

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@antonio-xm
antonio-xm merged commit dd04890 into main Oct 26, 2026
3 checks passed

This branch was successfully deployed

1 active deployment
Preview — 67e914a7 Deployed Oct 26, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant