Skip to content

feat(fx): consume the shared card-markup endpoint, delete both frontend copies - #2658

Merged
abalinda merged 4 commits into
mainfrom
feat/fx-card-markup-ui
Aug 11, 2026
Merged

feat(fx): consume the shared card-markup endpoint, delete both frontend copies#2658
abalinda merged 4 commits into
mainfrom
feat/fx-card-markup-ui

Conversation

@abalinda

@abalinda abalinda commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Summary

Consumes the new GET /fx/card-markup endpoint and deletes both frontend copies of the card-comparison model.

The "how much more does a foreign card cost than Peanut" number was computed twice in this repo:

Each had its own ISSUER_FX_FEE = 0.03, its own dolarapi.com call, and its own 9.13% fallback, and each fetched its own Peanut price — so neither could be guaranteed to agree with the rate rendered beside it. Both are gone. The model now lives in one place in the backend and is computed against the same market snapshot the displayed rate comes from, so the two agree by construction.

Also adds CompareSavings, an MDX component for the /compare/* pages that turns a dated, sourced competitor claim into a live savings sentence. It is registered but not yet used by any content — the content leg embeds it only after this ships to prod.

Depends on peanutprotocol/peanut-api-ts#1322. Deploy the backend first.

Task

TASK-20265. Brief: mono/ops/plans/2026-08-10-fx-card-markup-unification.md.

Design notes / accepted trade-offs

  • The hook's signature and never-fails contract are unchanged, so qr-pay, LocalRailNudge and their tests are untouched. A frontend deploy that lands before the backend degrades to today's static numbers rather than blanking a surface.
  • 404 and "outage" are deliberately not the same failure. A 404 is the backend proving it has nothing to publish — an unmodeled currency, or live observations showing the rates have converged. Falling back to the static table there would advertise a 9% saving against evidence of none, so 404 renders no row. Any other failure means the model was unreachable, which is exactly what the static assumption is for. (This split came out of review; see the backend PR.)
  • Locked prices are recomputed client-side. qr-pay holds a locked Manteca price, so fetchCardMarkup re-derives the markup from components against that locked rate. Otherwise the saving shown is not the saving the user gets.
  • CARD_FX_MARKUP_BY_CURRENCY stays. It is both the client's last-resort fallback and the eligibility gate hasCardMarkupComparison reads. Its doc comment now points at the backend.
  • dolarapi.com is out of the CSP connect-src (precedent: 5fa013fcc removing frankfurter). git grep dolarapi is clean apart from one stale comment, now reworded.
  • CompareSavings props are all string literals because mdx-security rejects JSX expression props. markupPct is in PERCENT units ("4" = 4%) — the 100× trap is documented loudly in the JSDoc, and an unparsable value degrades to a weaker dated sentence instead of publishing a wrong number.
  • The component's fallback lane is the important one. Content pages are statically generated, so the sentence rendered without a live rate is what search engines and JS-less readers see. It is complete, concrete and dated on its own; the live lane only adds today's local-currency amounts.
  • Only the /fx/card-markup path was added to src/types/api.openapi.json rather than re-copying the whole spec — the committed snapshot already carries unrelated drift from the API repo, and pulling it in would bury this diff.

Risks / breaking changes

  • Cross-repo deploy order: backend first. [TASK-15759] Add I did it button on deposit USDC screen #1322 must be live before this. If it is not, every call 404s or fails and the hook serves the static table — which is exactly today's behaviour, so the failure mode is "no change", not "broken".
  • Two server actions are deleted. git grep confirms three importers, all updated in this PR.
  • CompareSavings ships unused; no content references it yet. Adding an unknown MDX component to content would fail the SSG build, which is why the content leg is gated on this reaching prod main.
  • No DB, no migrations, no money paths.

QA

  • pnpm prettier --check . clean · npm run typecheck clean · npm test 226/226 suites, 2900 passing · pnpm build green (1045 static pages generated) · npm run check:api green.
  • New/extended suites: fx.utils.test.ts (live contract, locked-price recompute, 9 rejection cases), useCardMarkupRate.test.tsx (served / static-fallback / 404-hides-row / disabled), CompareSavings.test.tsx (static lane, live lane, range upper bound, degraded props).
  • Manual after backend deploy: /m/stain should show the live rate banner and the strike-through card price, both driven by the shared endpoint.

Screenshots: ⚠️ NONE — the two rendered surfaces (/m/[slug] banner + menu card strike-through) are pixel-identical by design: same numbers, same components, different data source. The only new rendering is CompareSavings, which no page references yet. Flagging it rather than shipping silently.

Summary by CodeRabbit

  • New Features

    • Added savings comparisons to marketing content, including fee ranges, dates, amounts, exchange-rate conversions, source links, and graceful handling of incomplete information.
    • Added validated card markup rate retrieval for supported currencies, with live and static rate indicators.
    • Documented the card markup rates endpoint and its response behavior.
  • Improvements

    • Improved comparison messaging while rates load or when no comparison is available.
    • Suppressed savings nudges when a valid comparison cannot be published.

…end copies

The card-vs-Peanut markup was modeled twice in the frontend — once as a server
action, once copied into the merchant page with a header saying to delete it
when #2108 landed. #2108 landed. Both computed their own dolarapi call and
their own issuer fee, and neither could agree with the rate on screen.

The hook keeps its signature and its never-fails contract: a backend outage
falls back to the same static table as before, so a frontend deploy that
precedes the backend degrades to today's numbers instead of blanking a
surface.
…compute

Also splits the two failure kinds: a 404 is the backend proving there is no
comparison to publish, so the row is hidden rather than filled with the static
assumption, which exists for an unreachable backend.
@vercel

vercel Bot commented Aug 10, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
peanut-wallet Ready Ready Preview Aug 11, 2026 10:15am

Request Review

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: e760cd7e-3d62-4863-b4ff-fbf714b61a18

📥 Commits

Reviewing files that changed from the base of the PR and between 259ca47 and 6d34db0.

📒 Files selected for processing (4)
  • src/components/Marketing/mdx/CompareSavings.tsx
  • src/components/Marketing/mdx/__tests__/CompareSavings.test.tsx
  • src/utils/__tests__/fx.utils.test.ts
  • src/utils/fx.utils.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/utils/tests/fx.utils.test.ts
  • src/components/Marketing/mdx/CompareSavings.tsx

📝 Walkthrough

Walkthrough

The card-markup flow now uses a documented FX API, validated client utilities, and React Query fallback handling. Consumers distinguish unavailable rates from loading states. The PR adds the CompareSavings MDX component and removes the previous server-action implementations.

Changes

Card markup API migration

Layer / File(s) Summary
Card-markup API contract and validation
src/types/api.openapi.json, src/utils/fx.utils.ts, src/utils/__tests__/fx.utils.test.ts
Adds the GET /fx/card-markup contract. Validates response data, timestamps, sources, rates, fees, freshness, and locked Peanut rates.
Hook and card-markup consumer integration
src/hooks/useCardMarkupRate.ts, src/hooks/__tests__/useCardMarkupRate.test.tsx, src/app/m/[slug]/MerchantLandingPage.tsx, src/components/TransactionDetails/provider-rows/LocalRailNudge.tsx, src/constants/payment.consts.ts, next.config.js
The hook calls fetchCardMarkup and applies static fallbacks. Consumers distinguish loading from unavailable results. CSP and card-markup documentation are updated.
CompareSavings rendering and MDX registration
src/components/Marketing/mdx/CompareSavings.tsx, src/components/Marketing/mdx/__tests__/CompareSavings.test.tsx, src/components/Marketing/mdx/components.tsx
Adds claim validation, static and live savings rendering, source links, localized dates, tests, and MDX registration.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant MerchantLandingPage
  participant useCardMarkupRate
  participant fetchCardMarkup
  participant CardMarkupEndpoint
  MerchantLandingPage->>useCardMarkupRate: request ARS markup
  useCardMarkupRate->>fetchCardMarkup: fetch card markup
  fetchCardMarkup->>CardMarkupEndpoint: GET /fx/card-markup
  CardMarkupEndpoint-->>fetchCardMarkup: API response
  fetchCardMarkup-->>useCardMarkupRate: live, static, or null result
  useCardMarkupRate-->>MerchantLandingPage: consumer markup state
Loading

Possibly related PRs

Suggested labels: enhancement

Suggested reviewers: jjramirezn

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adopting the shared FX endpoint and removing duplicated frontend implementations.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/fx-card-markup-ui

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Code-analysis diff

Painscore total: 7141.73 → 7137.94 (-3.79)
Findings: +5 net (+247 new, -242 resolved)

🆕 New findings (247)

  • critical complexity — src/app/m/[slug]/MerchantLandingPage.tsx — CC 51, MI 59.94, SLOC 184
  • high structural-dup — types/api.generated.ts:10233 — 83 duplicate lines / 388 tokens with types/api.generated.ts:10821
  • high structural-dup — types/api.generated.ts:10243 — 74 duplicate lines / 348 tokens with types/api.generated.ts:10831
  • high structural-dup — types/api.generated.ts:5008 — 64 duplicate lines / 198 tokens with types/api.generated.ts:5110
  • high structural-dup — types/api.generated.ts:5011 — 61 duplicate lines / 188 tokens with types/api.generated.ts:5215
  • medium structural-dup — types/api.generated.ts:8116 — 49 duplicate lines / 149 tokens with types/api.generated.ts:8540
  • medium structural-dup — types/api.generated.ts:7 — 48 duplicate lines / 185 tokens with types/api.generated.ts:10961
  • medium structural-dup — types/api.generated.ts:8619 — 43 duplicate lines / 123 tokens with types/api.generated.ts:8900
  • medium structural-dup — types/api.generated.ts:8619 — 42 duplicate lines / 125 tokens with types/api.generated.ts:8978
  • medium high-mdd — src/app/m/[slug]/MerchantLandingPage.tsx:272 — MenuFold: MDD 39.7 (uses across many lines from declarations)
  • medium structural-dup — types/api.generated.ts:7538 — 40 duplicate lines / 116 tokens with types/api.generated.ts:8116
  • medium structural-dup — types/api.generated.ts:8116 — 40 duplicate lines / 118 tokens with types/api.generated.ts:8954
  • medium structural-dup — types/api.generated.ts:8399 — 40 duplicate lines / 122 tokens with types/api.generated.ts:8697
  • medium structural-dup — types/api.generated.ts:267 — 38 duplicate lines / 141 tokens with types/api.generated.ts:11230
  • medium structural-dup — types/api.generated.ts:1343 — 38 duplicate lines / 131 tokens with types/api.generated.ts:7047
  • medium structural-dup — types/api.generated.ts:1343 — 38 duplicate lines / 131 tokens with types/api.generated.ts:7121
  • medium structural-dup — types/api.generated.ts:1343 — 38 duplicate lines / 131 tokens with types/api.generated.ts:10751
  • medium structural-dup — types/api.generated.ts:7613 — 38 duplicate lines / 122 tokens with types/api.generated.ts:7823
  • medium structural-dup — types/api.generated.ts:267 — 36 duplicate lines / 127 tokens with types/api.generated.ts:6527
  • medium structural-dup — types/api.generated.ts:267 — 36 duplicate lines / 127 tokens with types/api.generated.ts:9027

…and 227 more.

✅ Resolved (242)

  • src/app/m/[slug]/MerchantLandingPage.tsx — CC 53, MI 60.34, SLOC 196
  • types/api.generated.ts:10133 — 83 duplicate lines / 388 tokens with types/api.generated.ts:10721
  • types/api.generated.ts:10143 — 74 duplicate lines / 348 tokens with types/api.generated.ts:10731
  • types/api.generated.ts:5013 — 61 duplicate lines / 188 tokens with types/api.generated.ts:5115
  • types/api.generated.ts:8016 — 49 duplicate lines / 149 tokens with types/api.generated.ts:8440
  • types/api.generated.ts:7 — 48 duplicate lines / 185 tokens with types/api.generated.ts:10861
  • types/api.generated.ts:8519 — 43 duplicate lines / 123 tokens with types/api.generated.ts:8800
  • types/api.generated.ts:8519 — 42 duplicate lines / 125 tokens with types/api.generated.ts:8878
  • types/api.generated.ts:7438 — 40 duplicate lines / 116 tokens with types/api.generated.ts:8016
  • types/api.generated.ts:8016 — 40 duplicate lines / 118 tokens with types/api.generated.ts:8854
  • types/api.generated.ts:8299 — 40 duplicate lines / 122 tokens with types/api.generated.ts:8597
  • types/api.generated.ts:267 — 38 duplicate lines / 141 tokens with types/api.generated.ts:11130
  • types/api.generated.ts:1343 — 38 duplicate lines / 131 tokens with types/api.generated.ts:6947
  • types/api.generated.ts:1343 — 38 duplicate lines / 131 tokens with types/api.generated.ts:7021
  • types/api.generated.ts:1343 — 38 duplicate lines / 131 tokens with types/api.generated.ts:10651
  • types/api.generated.ts:7513 — 38 duplicate lines / 122 tokens with types/api.generated.ts:7723
  • types/api.generated.ts:267 — 36 duplicate lines / 127 tokens with types/api.generated.ts:6427
  • types/api.generated.ts:267 — 36 duplicate lines / 127 tokens with types/api.generated.ts:8927
  • types/api.generated.ts:267 — 36 duplicate lines / 127 tokens with types/api.generated.ts:9441
  • types/api.generated.ts:267 — 36 duplicate lines / 127 tokens with types/api.generated.ts:9751

…and 222 more.

📈 Painscore deltas (top movers)

File Before After Δ
src/components/Marketing/mdx/CompareSavings.tsx 0.0 8.7 +8.7
src/hooks/useCardMarkupRate.ts 3.9 5.5 +1.7
src/utils/fx.utils.ts 1.6 2.4 +0.8
src/app/m/[slug]/card-comparison.ts 7.5 0.0 -7.5
src/app/actions/card-comparison.ts 8.3 0.0 -8.3

@abalinda

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

🧪 UI test report — ✅ all green

Suites

  • unit: 2911 ran, 0 failed, 0 skipped, 50.2s

📊 Coverage (unit)

metric %
statements 66.1%
branches 51.1%
functions 56.2%
lines 66.9%
⏱ 10 slowest test cases
time test
3.5s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › never places two stickers in heavy overlap (broad seed sweep)
1.0s src/utils/__tests__/demo-api.test.ts › isDemoMode() is false when not running under Capacitor
0.3s src/utils/__tests__/sentry.utils.test.ts › defaults to the client budget under a browser global
0.3s src/utils/__tests__/auth-token.test.ts › returns the token hydrated from Preferences after authReady
0.3s src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx › Bank withdrawal keeps the $1 minimum for sub-$1 amounts
0.3s src/app/actions/__tests__/api-headers.test.ts › should include Content-Type in validateInviteCode
0.3s src/utils/__tests__/sentry.utils.test.ts › still lets a per-call timeoutMs win over the default
0.3s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › every sticker stays within canvas at any count
0.3s src/utils/__tests__/auth-token.test.ts › authReady does not park — hydrates the plain token without an unlock
0.3s src/app/actions/__tests__/api-headers-extended.test.ts › should not include apiKey in validateInviteCode body
📍 Inline annotations are in the **Unit test report** check above. Coverage artifact: `coverage-unit`. Generated by `.github/workflows/tests.yml`.

Review found the distinction collapsing at four points, each publishing a
saving claim on evidence there is none:

- a well-formed zero markup threw, so the hook fell back to the static 9.13%
- an out-of-band locked-price recompute silently returned the market markup,
  still tagged live — the exact failure the recompute exists to prevent
- the merchant page and the post-card-spend nudge both turned the hook's null
  into the static table, striking through every menu price by 9.13%

fetchCardMarkup now returns null for 'the backend published no comparison' and
throws only when the response could not be obtained or trusted. Also bounds the
live observation age client-side, formats the CompareSavings date in UTC (a
hydration mismatch on statically generated pages for any viewer west of
Greenwich), rejects a negative percent instead of publishing it as a range, and
formats amounts in the page locale.
@abalinda
abalinda marked this pull request as ready for review August 11, 2026 09:53

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/components/Marketing/mdx/CompareSavings.tsx`:
- Around line 55-56: Update the verifiedAt parsing logic in CompareSavings to
require exact YYYY-MM-DD input, reject invalid formats and calendar overflows
such as 2026-02-30, and verify the parsed UTC year, month, and day match the
input components before continuing. Add a regression test covering an overflow
date.

In `@src/utils/fx.utils.ts`:
- Around line 200-206: Update the zero check in the markup parsing flow around
positiveDecimal so every valid zero-valued decimal string, including “0.0” and
“0.00”, returns NONE before invalid or fallback handling; compare the parsed
numeric value with 0 rather than matching only the literal “0”. Add a regression
test covering alternate zero representations and their NONE result.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 1a469522-a4b1-47af-aa1f-d2984a0c74e9

📥 Commits

Reviewing files that changed from the base of the PR and between aa6fee2 and 259ca47.

⛔ Files ignored due to path filters (1)
  • src/types/api.generated.ts is excluded by !**/*.generated.*
📒 Files selected for processing (15)
  • next.config.js
  • src/app/actions/__tests__/card-comparison.test.ts
  • src/app/actions/card-comparison.ts
  • src/app/m/[slug]/MerchantLandingPage.tsx
  • src/app/m/[slug]/card-comparison.ts
  • src/components/Marketing/mdx/CompareSavings.tsx
  • src/components/Marketing/mdx/__tests__/CompareSavings.test.tsx
  • src/components/Marketing/mdx/components.tsx
  • src/components/TransactionDetails/provider-rows/LocalRailNudge.tsx
  • src/constants/payment.consts.ts
  • src/hooks/__tests__/useCardMarkupRate.test.tsx
  • src/hooks/useCardMarkupRate.ts
  • src/types/api.openapi.json
  • src/utils/__tests__/fx.utils.test.ts
  • src/utils/fx.utils.ts
💤 Files with no reviewable changes (4)
  • src/app/actions/tests/card-comparison.test.ts
  • src/app/m/[slug]/card-comparison.ts
  • next.config.js
  • src/app/actions/card-comparison.ts

Comment thread src/components/Marketing/mdx/CompareSavings.tsx Outdated
Comment thread src/utils/fx.utils.ts
…dates

Both CodeRabbit findings are the same defect class as the last round — a
literal check that misses an equivalent representation and falls through to the
static claim.

The wire pattern admits "0.0" and "0.00", not just "0". Matching the text
sent those down the invalid path, so a backend saying 'no gap to show' produced
a 9.13% saving claim. The check now compares the parsed value.

new Date('2026-02-30') is 2 March, so a typo in a hand-authored MDX date would
publish a verification date that never happened. verifiedAt is now round-tripped
against an exact YYYY-MM-DD.

@kushagrasarathe kushagrasarathe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agent review (advisory, on behalf of @kushagrasarathe) — full pass on the diff, no blockers. Two inline notes below, plus one repo-level note:

Back-merge: this PR targets main directly. After it lands, a main → dev back-merge is needed (same for peanut-api-ts#1322) or the branches drift.

Merge order stands as stated: peanut-api-ts#1322 must be merged and live on Render before this merges.

return await fetchCardMarkup(code, mantecaPriceUsdToLocal)
} catch (error) {
// The backend has no comparison to publish — render nothing.
if (error instanceof FxApiError && error.status === 404) return null

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR body claims a UI-first deploy "degrades to today's static numbers" — it doesn't. An undeployed backend route returns Fastify's default 404, which is an FxApiError with status 404, so this line maps it to null and the comparison row is hidden, not served from the static table, until the backend ships.

Benign failure (a marketing row disappears, nothing breaks), and moot if #1322 merges first — but if you want the stated degradation to be true, distinguish the backend's deliberate 404 by its error body (error === 'FX_CARD_MARKUP_UNAVAILABLE') and only return null for that; let a route-missing 404 fall through to the static fallback.

* ⚠️ The 100× trap: writing `"0.04"` for 4% publishes a claim a hundred
* times too small, and nothing downstream can tell the difference.
*/
markupPct: string

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Naming trap: markupPct here is in percent units ("4" = 4%), while the backend's /fx/card-markup field of the same name is a fraction ("0.04"). Both are loudly documented, but same name + 100× different units in the same PR pair is a foot-gun for the next author. Consider renaming this prop (e.g. feePercent) before content starts embedding it — cheap now, breaking later.

@kushagrasarathe kushagrasarathe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved — merge only after peanut-api-ts#1322 is live on prod. Inline notes (404 fallback, markupPct units) are non-blocking.

@abalinda
abalinda merged commit 51d3ef8 into main Aug 11, 2026
26 checks passed
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.

2 participants