diff --git a/.github/workflows/update-content.yml b/.github/workflows/update-content.yml index ee1132ce3..528da2f03 100644 --- a/.github/workflows/update-content.yml +++ b/.github/workflows/update-content.yml @@ -13,7 +13,14 @@ jobs: update: runs-on: ubuntu-latest steps: + # Base the bump on `dev` (the PR target), NOT the repo default branch. + # Branching off a different branch than the base is what let unrelated + # code drift leak into the auto-PR (#2226) and made it conflict with dev + # once dev's content pointer moved (#2247). Off `dev`, the PR is + # content-only by construction and always cleanly mergeable. - uses: actions/checkout@v4 + with: + ref: dev - name: Init and update submodule env: @@ -34,14 +41,19 @@ jobs: echo "changed=true" >> "$GITHUB_OUTPUT" fi - - name: Create PR + - name: Create PR + auto-merge (content-only) if: steps.check.outputs.changed == 'true' + # Prefer a PAT (CONTENT_BOT_TOKEN) so the PR triggers CI and auto-merge + # can fire on green checks. Falls back to GITHUB_TOKEN — the PR still + # opens, but GITHUB_TOKEN-created PRs don't trigger CI, so auto-merge + # won't fire until the PAT is configured (no regression vs today). env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.CONTENT_BOT_TOKEN || secrets.GITHUB_TOKEN }} run: | + set -euo pipefail BRANCH="auto/update-content-$(date -u +%Y%m%d-%H%M%S)" SUBMODULE_SHA=$(git -C src/content rev-parse HEAD) - PARENT=$(git rev-parse HEAD) + PARENT=$(git rev-parse HEAD) # dev tip — bump is content-only vs dev BASE_TREE=$(gh api repos/${{ github.repository }}/git/commits/$PARENT --jq '.tree.sha') # Create tree via API with updated submodule pointer TREE=$(gh api repos/${{ github.repository }}/git/trees \ @@ -64,8 +76,18 @@ jobs: --method POST \ -f "ref=refs/heads/$BRANCH" \ -f "sha=$COMMIT_SHA" - gh pr create \ + PR_URL=$(gh pr create \ --head "$BRANCH" \ --base dev \ --title "Update content submodule" \ - --body "Auto-generated: updates content submodule to latest peanut-content main." + --body "Auto-generated: bumps the content submodule to latest peanut-content main. Based on dev, so it's content-only and auto-merges once checks pass.") + # Guard: only auto-merge when the PR touches NOTHING but the submodule + # pointer. If anything else slipped in, leave it for a human. + FILES=$(gh pr diff "$PR_URL" --name-only) + if [ "$FILES" = "src/content" ]; then + gh pr merge "$PR_URL" --auto --squash \ + || echo "::warning::auto-merge not enabled/permitted — left for manual merge ($PR_URL)" + else + echo "::warning::PR touches more than src/content — left for manual review:" + echo "$FILES" + fi diff --git a/.verify-content-baseline b/.verify-content-baseline index 5f6e60798..278c95299 100644 --- a/.verify-content-baseline +++ b/.verify-content-baseline @@ -1 +1 @@ -745 +740 diff --git a/e2e/flows/add-money.spec.ts b/e2e/flows/add-money.spec.ts index 2e7ebdef5..16c490f3d 100644 --- a/e2e/flows/add-money.spec.ts +++ b/e2e/flows/add-money.spec.ts @@ -81,4 +81,69 @@ test.describe('Add money flow', () => { consoleLogs.flush(testInfo, 'add-money-us-bank') await context.close() }) + + // Regression: the NavHeader back button must LEAVE the amount screen on the first tap. + // Before fix/addmoney-back-nuqs-replace the flow used nuqs { history: 'push' }, so every + // amount keystroke stacked a same-screen history entry and useSafeBack's router.back() + // only stepped through stale amounts — the back button looked dead (MP/bank reports). + test('add-money/AR/bank — back button leaves the amount screen after typing (verified-ar)', async ({ + browser, + }, testInfo) => { + const context = await browser.newContext({ ...devices['Pixel 7'] }) + await usePersona(context, 'verified-ar') + + const page = await context.newPage() + const consoleLogs = collectConsoleLogs(page) + await installApiMocks(page) + + await page.goto('/add-money/AR/bank') + + // amount step renders (verified persona skips the "country not found" gate) + const amountInput = page.locator('input[inputmode="decimal"]').first() + await amountInput.waitFor({ state: 'visible', timeout: 15000 }) + + // typing writes ?amount= — with the old { history: 'push' } this stacked back-stack + // entries; with the default 'replace' it does not. + await amountInput.fill('100') + await captureStep(page, testInfo, { name: '01-add-money-ar-bank-amount-typed' }) + + // one tap must exit to the country page, not linger on /bank with a stale amount + await page.locator('[data-testid="nav-back"]').first().click() + await expect(page).toHaveURL(/\/add-money\/AR(?:\?.*)?$/) + await captureStep(page, testInfo, { name: '02-add-money-ar-bank-back-left-screen' }) + + consoleLogs.flush(testInfo, 'add-money-ar-bank-back') + await context.close() + }) + + // Same regression, the originally-reported flow: Manteca (MP) AR deposit at + // /add-money/argentina/manteca. Both add-money amount screens shared the + // { history: 'push' } bug; this covers the reported variant directly. + test('add-money/argentina/manteca — back button leaves the amount screen after typing (verified-ar)', async ({ + browser, + }, testInfo) => { + const context = await browser.newContext({ ...devices['Pixel 7'] }) + await usePersona(context, 'verified-ar') + + const page = await context.newPage() + const consoleLogs = collectConsoleLogs(page) + await installApiMocks(page) + + await page.goto('/add-money/argentina/manteca') + + // amount step renders (verified persona; currency rate is mocked) + const amountInput = page.locator('input[inputmode="decimal"]').first() + await amountInput.waitFor({ state: 'visible', timeout: 15000 }) + + await amountInput.fill('100') + await captureStep(page, testInfo, { name: '01-add-money-manteca-amount-typed' }) + + // one tap must exit to the country page, not linger on /manteca with a stale amount + await page.locator('[data-testid="nav-back"]').first().click() + await expect(page).toHaveURL(/\/add-money\/argentina(?:\?.*)?$/) + await captureStep(page, testInfo, { name: '02-add-money-manteca-back-left-screen' }) + + consoleLogs.flush(testInfo, 'add-money-manteca-back') + await context.close() + }) }) diff --git a/eslint.config.js b/eslint.config.js index 44dbeecb0..051b8f20b 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -127,6 +127,16 @@ module.exports = [ message: "window.history.length is the pre-useSafeBack idiom (history.length > 1 ? back : push). It misfires on cold-load from external referrers — useSafeBack's pushState counter is more accurate. See PR #1965.", }, + { + // nuqs `history: 'push'` stacks a browser-history entry on every URL write. + // For per-keystroke params (e.g. `amount`) that poisons the back stack: + // useSafeBack → router.back() then steps through stale same-screen states + // and the back button looks dead (add-money MP/bank reports, June 2026). + selector: + "CallExpression[callee.name=/^useQueryStates?$/] Property[key.name='history'][value.value='push']", + message: + "Don't pass { history: 'push' } to nuqs useQueryState(s) — a history entry per URL write breaks the back button (useSafeBack steps through same-screen states instead of leaving). Use the default 'replace'; the URL stays shareable. If a flow genuinely needs push-per-step, add a scoped file exemption with a comment (see useNativePlugins).", + }, ], }, }, diff --git a/public/llms-full.txt b/public/llms-full.txt index 906b896de..14b336285 100644 --- a/public/llms-full.txt +++ b/public/llms-full.txt @@ -1,77 +1,78 @@ # Peanut — Full Product Description -> Instant global peer-to-peer payments in digital dollars. +> Spend, send, and cash out digital dollars anywhere — pay local QR codes in Argentina and Brazil with just your passport, spend on a card, and move money across borders without a local bank account or local tax ID. ## Overview -Peanut is a peer-to-peer payments app that lets users send and receive money globally using digital dollars (USDC stablecoins). It provides a consumer-grade UX on top of blockchain infrastructure — users never need to understand crypto, manage wallets, or handle gas fees. +Peanut is a self-custody money app built on digital dollars (USDC). Users hold dollars in an account that only they control and spend them like a local — scanning the same QR codes locals use, paying with a card, sending a link, or cashing out to a bank. The experience is consumer-grade: users never manage wallets, seed phrases, or gas fees. Where identity verification is needed it takes a passport and under two minutes — no local ID, bank account, or residency required. Core crypto features require no verification at all. -## Key Features +## Headline differentiators -### Instant P2P Transfers -Send digital dollars to any Peanut user instantly. No waiting for bank processing, no wire fees. +### Pay PIX in Brazil without a CPF +PIX is Brazil's instant payment network — ~150 million people use it daily. Normally it requires a CPF (Brazilian tax ID) and a Brazilian bank account, which locks out tourists, nomads, and expats. Peanut lets anyone scan a merchant PIX QR and pay in BRL using a passport-verified account funded with digital dollars. The merchant receives BRL in seconds and sees an ordinary PIX payment. The conversion isn't subject to IOF (Brazil's financial-operations tax), saving up to ~3.5% versus cards and bank transfers. Free, 24/7/365, QR scan only. -### Payment Links -Generate a shareable link containing funds. The recipient clicks the link to claim the money — no account needed. Links work across messaging apps, email, and social media. +### Pay MercadoPago in Argentina without a DNI +MercadoPago QR is accepted at 1,000,000+ merchants in Argentina. Locals need a DNI to open an account; Peanut gives access to the payment network without one — pay in pesos with a passport-verified account. Conversion uses the cripto dólar rate (a direct dollar-to-peso market rate that beats the regulated MEP rate cards use), typically several percent to ~11% more pesos per dollar. Free, instant, rate locked at payment. -### Bank Cash-Out -Connect a local bank account and convert digital dollars to local currency. Supported rails: -- **Argentina**: Bank transfer, MercadoPago -- **Brazil**: PIX, bank transfer -- **Mexico**: SPEI, bank transfer -- **Colombia**: Bank transfer -- **Peru**: Bank transfer -- **Bolivia**: Bank transfer (via Meru) +### The Peanut Card +A virtual Visa card that spends the user's digital-dollar balance anywhere Visa is accepted. Free to issue and top up; the only cost is the exchange-rate spread on currency conversion. Available in most countries (the US, Europe, the UK, Latin America, Africa, and more) and requires identity verification. A short list of regions is excluded — the card issuer's restricted-issuance list (China, India, Russia, Turkey, Vietnam, Iran, Israel, and a handful of others). -### Crypto Deposit -Fund your account by depositing crypto from any exchange (Coinbase, Binance, Kraken, Bybit, OKX, etc.) or external wallet. +## Core features -### Card Payments -Physical and virtual debit cards for spending digital dollars at any merchant that accepts card payments. +### Peanut Links +Put money in a shareable link or QR; the recipient claims it with no account needed. Works across messengers, email, and social. Unclaimed links are reclaimable; claimed is final. -### QR Payments -Generate and scan QR codes for in-person payments. +### Peanut Requests +A collection link for splitting bills, tips, or donations. Multiple contributors, funds arrive instantly to the balance. -## Security Model +### Add money (deposit) +- Crypto: USDC or USDT (and ETH on major EVM networks, auto-converted) from any exchange or wallet on Solana, Arbitrum, Base, Tron, Polygon, or Ethereum. No verification. Gas covered. +- Bank: SEPA (EUR), ACH (USD), SPEI (MXN), Faster Payments (GBP), and incoming wires (USD/EUR). +- All deposits free. -- **Self-custodied smart accounts**: User funds sit in ERC-4337 smart accounts, not on Peanut servers -- **Biometric passkeys**: Account access is secured by the device's Secure Enclave (face/fingerprint). The private key never leaves the device -- **No server-side keys**: Peanut cannot access, freeze, or move user funds — even under regulatory pressure -- **Independent recovery**: If Peanut goes offline, users can recover access via any ERC-4337-compatible wallet +### Cash out (withdraw) +- Bank: SEPA, ACH, SPEI, Faster Payments — to your own or any third party. +- Crypto: to any wallet on any supported network; gas covered. +- Argentina: MercadoPago or a local account. +- All withdrawals free from Peanut. -## KYC / Compliance +### Direct bank transfers +Send from balance straight to any third-party bank account, and receive into the balance, via SEPA/ACH/SPEI/Faster Payments — no link required. -- Core features (send, receive, payment links) work without KYC -- Bank connections trigger a one-time identity check via Persona (SOC2 Type 2, GDPR, ISO 27001) -- Peanut only receives a pass/fail result — no documents stored on Peanut servers +## Security & custody -## Fee Structure +- Non-custodial smart accounts (ERC-4337) on Arbitrum. User funds never sit on Peanut servers. +- Biometric passkey auth: the private key is generated from device biometrics and sealed in the device Secure Enclave — never exported, never sent to Peanut. +- Peanut cannot freeze, seize, or move user funds. If Peanut went offline, users keep full control via any ERC-4337-compatible wallet. +- Every transaction requires an on-device biometric signature. -- Peer-to-peer transfers: minimal fees -- Bank cash-out: small conversion spread -- No monthly subscription or account fees -- Merchant payments planned with fees lower than Visa/Mastercard +## Verification & limits -## Target Markets +- Required only for bank deposits/withdrawals and local QR payments. Passport or national ID from any country (US driver's license accepted for US users). No local ID anywhere. +- Crypto deposits, crypto withdrawals, and browsing require no verification. +- Limits: Latin America ~$2,000/month combined (raisable with documents); US/Europe/Mexico/UK no hard limit (very large transactions reviewed); crypto unlimited. -Primary focus on Latin America: -- Argentina, Brazil, Mexico (largest markets) -- Colombia, Peru, Bolivia, Chile, Ecuador +## Fees -Use cases: remittances, freelancer payments, cross-border transfers, savings in stable currency, merchant payments. +- No per-transaction fee from Peanut. Same-currency moves (USD↔USDC) are free; cross-currency conversions carry a small spread embedded in the displayed, locked rate. Gas is always covered. No monthly or account fees. -## Technical Stack +## Where Peanut works -- Next.js web application (progressive web app) -- ERC-4337 smart accounts on Base (Ethereum L2) -- Biometric passkeys via WebAuthn / Secure Enclave -- Licensed banking partners for fiat on/off ramps +- Local QR spending live: Argentina (MercadoPago), Brazil (PIX). +- Bank rails live: 36 SEPA-zone countries (EUR), US (ACH/wire), Mexico (SPEI), UK (Faster Payments). +- Crypto deposit/withdraw: global, all non-restricted countries. +- Card: available in most countries; excludes a short restricted-issuance list (China, India, Russia, Turkey, Vietnam, and others). +- Roadmap (not live): local payment methods in many more countries (CoDi, Transfiya, Yape, Bizum, MB WAY, UPI, and others). Only Argentina and Brazil have live local spending today. + +## Who it's for + +Tourists, digital nomads, expats, remote workers, freelancers, and families moving money across borders — especially anyone wanting to spend or save in dollars in Latin America without a local ID or bank account. ## Company -- Founded by Konrad Kononenko and Hugo Montenegro -- Based in Europe, serving Latin America - Website: https://peanut.me -- Twitter: https://twitter.com/PeanutProtocol +- Twitter/X: https://twitter.com/PeanutProtocol - GitHub: https://github.com/peanutprotocol - LinkedIn: https://www.linkedin.com/company/peanut-trade/ +- Careers: https://peanut.me/careers +- Support: https://peanut.me/support diff --git a/public/llms.txt b/public/llms.txt index eb344e2cc..bac7c3b4d 100644 --- a/public/llms.txt +++ b/public/llms.txt @@ -1,27 +1,81 @@ # Peanut -> Instant global peer-to-peer payments in digital dollars. +> Spend, send, and cash out digital dollars anywhere — pay local QR codes in Argentina and Brazil with just your passport, spend on a card, and move money across borders without a local bank account or local tax ID. -Peanut is the easiest way to send digital dollars to anyone, anywhere. No banks, no borders — just fast, cheap money transfers. +Peanut is a self-custody money app built on digital dollars (USDC). You hold dollars in an account only you control, then spend them like a local — scanning the same QR codes locals use, paying with a card, or sending a link to anyone. Verification (when needed) takes a passport and under two minutes: no DNI, no CPF, no local bank account, no residency. Core crypto features need no verification at all. -## What Peanut Does +## Pay PIX in Brazil — no CPF needed -- **Send & receive money instantly** — peer-to-peer transfers powered by digital dollars (USDC) -- **Cash out to local banks** — connect bank accounts in Argentina, Brazil, Mexico, and more -- **No KYC required for core features** — send and receive without identity verification -- **Self-custodied accounts** — your funds sit in your own smart account, secured by biometric passkeys -- **Payment links** — share a link to send money to anyone, even without an account +- Scan any merchant **PIX QR code** and pay in seconds — the same instant payment network ~150 million Brazilians use every day. +- **No CPF (Brazilian tax ID) and no Brazilian bank account required** — normally both are mandatory to use PIX. Verify with a passport from any country instead. +- The merchant receives BRL instantly and sees a normal PIX payment — Peanut is invisible on their end. +- Your dollars convert to BRL at the market ("cripto dólar") rate, locked the moment you confirm. IOF (Brazil's financial-operations tax) does not apply to the conversion — saving up to ~3.5% versus cards and bank transfers on cross-border spending. +- No Peanut fee on PIX payments. Available 24/7/365. QR scan only (the "copia e cola" paste-code flow is not supported). +- To pay a person rather than a merchant, share a Peanut Link — they claim it and can withdraw to their own PIX instantly, no Peanut account needed. -## Supported Corridors +## Pay MercadoPago in Argentina — no DNI needed -- Argentina (bank transfer, MercadoPago) -- Brazil (PIX, bank transfer) -- Mexico (SPEI, bank transfer) -- Colombia, Peru, Bolivia, and more +- Scan **MercadoPago QR codes** at 1,000,000+ merchants in Argentina and pay in pesos. +- **No Argentine DNI, no local bank account, no CBU or CVU required** — locals need a DNI to open MercadoPago; Peanut gives you the payment network without one. Verify with a passport. +- You get the **cripto dólar** rate — typically a few percent to ~11% more pesos per dollar than a credit card (which uses the regulated MEP rate), depending on the day and your card's fees. +- No Peanut fee. Rate locks at payment. Merchant sees a normal MercadoPago payment. +- Only MercadoPago QR is supported (MODO and Cuenta DNI use incompatible formats). + +## The Peanut Card + +- A **virtual Visa card** that spends your digital-dollar balance anywhere Visa is accepted. +- Free to issue, free to top up. You pay only the exchange-rate spread when spending in another currency — no separate card fee. +- **Available in most countries** — the US, Europe, the UK, Latin America, Africa, and more. Requires identity verification. +- A short list of countries can't be issued the card (the issuer's restricted-region list — including China, India, Russia, Turkey, Vietnam, Iran, and Israel, among a handful of others). + +## Send & receive money + +- **Peanut Links** — put money in a shareable link or QR. The recipient claims it with no Peanut account needed; works over WhatsApp, email, or any messenger. Unclaimed links are reclaimable; claimed is final. +- **Peanut Requests** — a collection link for splitting bills, tips, or donations; multiple people can contribute, instantly to your balance. +- **Direct transfers** between Peanut accounts are instant and free. +- **Direct bank transfers** — send from your balance straight to any third-party bank account (and receive into your balance) via SEPA, ACH, SPEI, or Faster Payments. No link required. + +## Add money (deposit) + +- **From crypto** — deposit USDC or USDT from any exchange (Coinbase, Binance, Kraken, Bybit, OKX, etc.) or wallet, on Solana, Arbitrum, Base, Tron, Polygon, or Ethereum. ETH is also accepted on major EVM networks and auto-converts. No verification needed. Peanut covers gas. +- **From a bank** — SEPA (EUR, 36 European countries, ~90% under 20 min), ACH (USD, US, 1–3 business days), SPEI (MXN, Mexico, seconds, 24/7), Faster Payments (GBP, UK, seconds, 24/7), and incoming wires (USD/EUR, global). +- All deposits are free from Peanut. + +## Cash out (withdraw) + +- **To a local bank** — SEPA, ACH, SPEI, and Faster Payments, to your own or anyone's account. +- **To crypto** — withdraw to any wallet on any supported network; Peanut covers gas. +- **In Argentina** — cash out to MercadoPago or a local account. +- All withdrawals are free from Peanut (your bank may charge). + +## How it works (the basics) + +- Your balance is held in **digital dollars (USDC)** and shown in USD. It doesn't move with local exchange rates until you spend. +- **Self-custody:** funds sit in your own smart account secured by a biometric passkey (face/fingerprint). Peanut never holds your keys and cannot freeze or move your money. If Peanut went offline, you'd still control your account. +- **Verification:** required only for bank deposits/withdrawals and local QR payments. A passport or national ID (US driver's license also accepted for US users) — no local ID anywhere. Crypto deposits, crypto withdrawals, and browsing need no verification. +- **Fees:** Peanut adds no per-transaction fee. Same-currency moves (USD↔USDC) are free; cross-currency conversions carry a small spread already embedded in the rate you see and confirm. Gas is always covered. +- **Limits:** Latin America has a ~$2,000/month combined limit (raisable with more documents); the US, Europe, Mexico, and UK have no hard limit (very large transactions may be reviewed); crypto has no limits. + +## Where Peanut works + +- **Local QR spending live now:** Argentina (MercadoPago) and Brazil (PIX). +- **Bank deposit/withdraw live:** 36 SEPA-zone European countries (EUR), United States (ACH/wire), Mexico (SPEI), United Kingdom (Faster Payments). +- **Crypto deposit/withdraw:** available globally in all non-restricted countries. +- **Peanut Card:** available in most countries (a short restricted list is excluded — e.g. China, India, Russia, Turkey, Vietnam). +- **Coming soon (not live):** local QR/payment methods in many more countries — Mexico (CoDi), Colombia (Transfiya), Peru (Yape), Spain (Bizum), Portugal (MB WAY), India (UPI), and others. These are roadmap; only Argentina and Brazil have live local spending today. + +## Who it's for + +Tourists, digital nomads, expats, remote workers, freelancers, and families moving money across borders — especially anyone who wants to spend or save in dollars in Latin America without a local ID or bank account. ## Links - Website: https://peanut.me +- Pay with PIX (Brazil): https://peanut.me/en/pay-with/pix +- Pay with MercadoPago (Argentina): https://peanut.me/en/pay-with/mercadopago +- Brazil guide: https://peanut.me/en/brazil +- Argentina guide: https://peanut.me/en/argentina +- Help center: https://peanut.me/en/help - Careers: https://peanut.me/careers - Support: https://peanut.me/support - Full description: https://peanut.me/llms-full.txt diff --git a/redirects.json b/redirects.json index c8db74a25..e66e25ab5 100644 --- a/redirects.json +++ b/redirects.json @@ -154,5 +154,25 @@ "source": "/press-kit", "destination": "https://peanutprotocol.notion.site/Press-Kit-12f83811757981fc9ca5de581b20f50d", "permanent": true + }, + { + "source": "/:locale/deposit/via-avalanche", + "destination": "/:locale/supported-networks", + "permanent": true + }, + { + "source": "/:locale/withdraw/avalanche", + "destination": "/:locale/supported-networks", + "permanent": true + }, + { + "source": "/:locale/deposit/from-faster-payments", + "destination": "/:locale/deposit/via-faster-payments", + "permanent": true + }, + { + "source": "/:locale/deposit/from-spei", + "destination": "/:locale/deposit/via-spei", + "permanent": true } ] diff --git a/scripts/verify-content.ts b/scripts/verify-content.ts index 7d9ed1e85..9f7374ce7 100644 --- a/scripts/verify-content.ts +++ b/scripts/verify-content.ts @@ -26,6 +26,7 @@ import fs from 'fs' import path from 'path' +import { RAIL_SLUGS } from '../src/data/seo/deposit-rails' const ROOT = path.join(process.cwd(), 'src/content') const CONTENT_DIR = path.join(ROOT, 'content') @@ -37,20 +38,9 @@ const PRIMARY_LOCALES = ['en', 'es-419', 'pt-br'] // `content/deposit/` mixes two URL families on the same dynamic route: // exchanges → /{locale}/deposit/from-{slug} // rails → /{locale}/deposit/via-{slug} -// The rail list lives in src/data/seo/exchanges.ts (DEPOSIT_RAILS). Keep in sync. -// Anything in content/deposit/ that isn't a rail is an exchange. -const RAIL_SLUGS = new Set([ - 'ach', - 'arbitrum', - 'avalanche', - 'base', - 'ethereum', - 'polygon', - 'sepa', - 'solana', - 'tron', - 'wire', -]) +// RAIL_SLUGS is the single source of truth (src/data/seo/deposit-rails), shared +// with the app's route + sitemap generation. Anything in content/deposit/ that +// isn't a rail is an exchange. const exchangeSlugs = listDirs(path.join(ROOT, 'content/deposit')).filter((s) => !RAIL_SLUGS.has(s)) // --- Diagnostics --- diff --git a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx index a704a2db1..e94ae231a 100644 --- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx @@ -34,6 +34,8 @@ import { useTosGuard } from '@/hooks/useTosGuard' import { BridgeTosStep } from '@/components/Kyc/BridgeTosStep' import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal' +import AdvisoryPreemptModal from '@/components/Kyc/AdvisoryPreemptModal' +import { useAdvisoryPreempt } from '@/hooks/useAdvisoryPreempt' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' import { addMoneyCountryUrl } from '@/utils/native-routes' @@ -49,13 +51,15 @@ export default function OnrampBankPage() { // URL state - persisted in query params // Example: /add-money/mexico/bank?step=inputAmount&amount=500 - const [urlState, setUrlState] = useQueryStates( - { - step: parseAsStringEnum(['inputAmount', 'showDetails']), - amount: parseAsString, - }, - { history: 'push' } - ) + // history stays at the nuqs default ('replace'): `amount` is rewritten on every + // keystroke, so 'push' would stack a browser-history entry per character and the + // NavHeader back button (useSafeBack → router.back()) would only step through stale + // amounts of this same screen instead of leaving it. The URL stays shareable either + // way. Enforced by the no-restricted-syntax guard in eslint.config.js. + const [urlState, setUrlState] = useQueryStates({ + step: parseAsStringEnum(['inputAmount', 'showDetails']), + amount: parseAsString, + }) // Amount from URL const rawTokenAmount = urlState.amount ?? '' @@ -111,6 +115,18 @@ export default function OnrampBankPage() { const { gateFor } = useCapabilities() const bankCountry = useMemo(() => railJurisdictionForBank(selectedCountry?.id), [selectedCountry?.id]) const gate = useMemo(() => gateFor('deposit', { channel: 'bank', country: bankCountry }), [gateFor, bankCountry]) + // A ready bank rail can still carry a future-dated requirement (the gate's + // `advisory`). Offer it as a skippable pre-empt at the proceed step. + const advisory = gate.kind === 'ready' ? gate.advisory : undefined + const { intercept: advisoryIntercept, modalProps: advisoryModalProps } = useAdvisoryPreempt({ + advisory, + isLoading: sumsubFlow.isLoading, + // Route through the self-heal resubmit path (reheal-tagged action) so the + // completed submission round-trips to Bridge. start-action mints a plain + // token whose webhook completion has no Bridge relay → answers are dropped. + onCompleteNow: () => + advisory ? sumsubFlow.handleSelfHealResubmit('BRIDGE', advisory.requirementKey) : Promise.resolve(), + }) const { guardWithTos, showBridgeTos, hideTos } = useTosGuard() const { setIsSupportModalOpen } = useModalsContext() @@ -231,12 +247,18 @@ export default function OnrampBankPage() { return } - posthog.capture(ANALYTICS_EVENTS.DEPOSIT_AMOUNT_ENTERED, { - amount_usd: usdEquivalent, - method_type: 'bank', - country: selectedCountryPath, + // ready — offer the skippable advisory pre-empt once; on proceed (now, or + // after "Not now") record the amount-entered event and open the + // confirmation modal. Firing inside the proceed avoids double-counting if + // the user dismisses the advisory and re-clicks. + advisoryIntercept(() => { + posthog.capture(ANALYTICS_EVENTS.DEPOSIT_AMOUNT_ENTERED, { + amount_usd: usdEquivalent, + method_type: 'bank', + country: selectedCountryPath, + }) + setShowWarningModal(true) }) - setShowWarningModal(true) } const handleWarningConfirm = async () => { @@ -438,6 +460,8 @@ export default function OnrampBankPage() { regionName={selectedCountry?.title} /> + + { expect(screen.getByText('Select your country')).toBeInTheDocument() }) - test('selecting a country from list navigates to country page', () => { + test('selecting a country (already in bank flow) navigates straight to the bank step', () => { resetQueryState({ method: 'bank' }) renderWithProviders() fireEvent.click(screen.getByTestId('country-argentina')) - expect(mockRouterPush).toHaveBeenCalledWith('/add-money/argentina') + // Method was already chosen ('bank'), so skip the redundant per-country + // method picker and go straight to the bank step. + expect(mockRouterPush).toHaveBeenCalledWith('/add-money/argentina/bank') }) test('back from method selection navigates to /home', () => { diff --git a/src/app/(mobile-ui)/add-money/page.tsx b/src/app/(mobile-ui)/add-money/page.tsx index 495b65140..656c5c56e 100644 --- a/src/app/(mobile-ui)/add-money/page.tsx +++ b/src/app/(mobile-ui)/add-money/page.tsx @@ -17,7 +17,7 @@ import { useQueryState, parseAsStringEnum } from 'nuqs' import { checkIfInternalNavigation, getRedirectUrl, clearRedirectUrl, getFromLocalStorage } from '@/utils/general.utils' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' -import { addMoneyCountryUrl } from '@/utils/native-routes' +import { addMoneyBankUrl } from '@/utils/native-routes' export default function AddMoneyPage() { const router = useRouter() @@ -68,7 +68,10 @@ export default function AddMoneyPage() { method_type: 'bank', country: country.path, }) - router.push(addMoneyCountryUrl(country.path)) + // User already chose Bank Transfer (this handler only renders in the bank + // branch), so go straight to the bank step — don't re-show the method + // picker on /add-money/[country] (that was the double "select bank twice"). + router.push(addMoneyBankUrl(country.path)) } // native app: render sub-views based on query params diff --git a/src/app/(mobile-ui)/claim/page.tsx b/src/app/(mobile-ui)/claim/page.tsx index 9bd47c0dd..43646bb0e 100644 --- a/src/app/(mobile-ui)/claim/page.tsx +++ b/src/app/(mobile-ui)/claim/page.tsx @@ -1,4 +1,32 @@ -import { Claim } from '@/components' +import { Claim } from '@/components/Claim' +import { BASE_URL } from '@/constants/general.consts' +import getOrigin from '@/lib/hosting/get-origin' +import { buildClaimMetadata, getClaimLinkData } from '@/utils/claim-metadata.utils' +import { type Metadata } from 'next' + +// Claim previews are resolved per-request from the link's query params, so the +// page must render dynamically on the web. The native (Capacitor static-export) +// build strips this export — scripts/native-build.js replaces this file with a +// metadata-free stub at build time, so SSR-only concerns never reach native. +export const dynamic = 'force-dynamic' + +export async function generateMetadata({ + searchParams, +}: { + searchParams: Promise<{ [key: string]: string | string[] | undefined }> +}): Promise { + const resolvedSearchParams = await searchParams + + let siteUrl = BASE_URL + try { + siteUrl = (await getOrigin()) || BASE_URL + } catch { + // getOrigin throws on a missing/invalid host header — fall back to BASE_URL + } + + const claimData = await getClaimLinkData(resolvedSearchParams, siteUrl) + return buildClaimMetadata({ claimData, siteUrl }) +} export default function ClaimPage() { return diff --git a/src/app/(mobile-ui)/home/page.tsx b/src/app/(mobile-ui)/home/page.tsx index 41a33bd68..b25cadca6 100644 --- a/src/app/(mobile-ui)/home/page.tsx +++ b/src/app/(mobile-ui)/home/page.tsx @@ -75,7 +75,7 @@ export default function Home() { const [showBalanceWarningModal, setShowBalanceWarningModal] = useState(false) const [isPostSignupActionModalVisible, setIsPostSignupActionModalVisible] = useState(false) - const [showKycModal, setShowKycModal] = useState(user?.user.showKycCompletedModal ?? false) + const [showKycModal, setShowKycModal] = useState(false) // Track if this is a fresh signup session - captured once on mount so it persists // even after NoMoreJailModal clears the sessionStorage key @@ -89,12 +89,16 @@ export default function Home() { fetchUser() }, []) // eslint-disable-line react-hooks/exhaustive-deps - // sync modal state with user data when it changes + // Show the "You're unlocked" celebration exactly once: the user has a usable + // rail (isKycApproved) and has never dismissed it (activationCelebratedAt is + // null, stamped server-side on dismiss). A KYC re-approval can't resurface it + // — unlike the old showKycCompletedModal flag, which re-fired on every + // `→ approved` transition (e.g. the faster_payments endorsement backfill). useEffect(() => { - if (user?.user.showKycCompletedModal !== undefined) { - setShowKycModal(user.user.showKycCompletedModal) + if (isKycApproved && !user?.user.activationCelebratedAt) { + setShowKycModal(true) } - }, [user?.user.showKycCompletedModal]) + }, [isKycApproved, user?.user.activationCelebratedAt]) const userFullName = useMemo(() => { if (!user) return @@ -256,7 +260,7 @@ export default function Home() { if (user?.user.userId) { await updateUserById({ userId: user.user.userId, - showKycCompletedModal: false, + dismissActivationCelebration: true, }) // refetch user to ensure the modal doesn't reappear await fetchUser() diff --git a/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx b/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx index cdcfc5213..83e367ace 100644 --- a/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx +++ b/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx @@ -137,6 +137,9 @@ jest.mock('@/hooks/useRainCardOverview', () => ({ })) jest.mock('@/utils/balance.utils', () => ({ + // keep the real isAmountWithinBalance / messages so the gate is genuinely + // exercised; only stub the Rain widening helper. + ...jest.requireActual('@/utils/balance.utils'), rainCentsToUsdcUnits: jest.fn(() => 0n), })) @@ -560,6 +563,8 @@ function applyDefaults() { mockUseWallet.mockReturnValue({ balance: parseUnits('100', 6), // $100 USDC + spendableBalance: parseUnits('100', 6), + hasSufficientSpendableBalance: () => true, // affordable by default sendMoney: jest.fn(), }) @@ -780,14 +785,14 @@ describe('GROUP 2: Payment Form States', () => { expect(screen.getByRole('button', { name: 'Pay' })).toBeInTheDocument() }) - test.skip('Insufficient balance shows pay button disabled + error', async () => { - // SKIP 2026-04-24: feat/card-ui merge surfaced post-merge balance - // path mismatch in qr-pay state tests. Mock signature for useWallet - // drifted vs new spendable-balance shape. FOLLOW-UP: rewrite or delete - // these state tests after the card-ui apply flow stabilises. - // Set balance to $5 but payment needs $18.4 + test('Insufficient balance shows pay button disabled + error', async () => { + // Payment needs ~$18.4 but the displayed spendable is only $5, so the gate + // (hasSufficientSpendableBalance) returns false. Revived from skip once the + // gate moved onto the shared hook predicate (the original mock-shape drift). mockUseWallet.mockReturnValue({ balance: parseUnits('5', 6), + spendableBalance: parseUnits('5', 6), + hasSufficientSpendableBalance: () => false, sendMoney: jest.fn(), }) diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx index c2012710f..0759ae013 100644 --- a/src/app/(mobile-ui)/qr-pay/page.tsx +++ b/src/app/(mobile-ui)/qr-pay/page.tsx @@ -22,7 +22,12 @@ import { useStaleSessionGuard } from '@/hooks/wallet/useStaleSessionGuard' import { InsufficientSpendableError, SessionKeyGrantRequiredError } from '@/hooks/wallet/useSpendBundle' import { rainCollateralErrorMessage } from '@/utils/friendly-error.utils' import { useRainCardOverview } from '@/hooks/useRainCardOverview' -import { rainCentsToUsdcUnits } from '@/utils/balance.utils' +import { + rainCentsToUsdcUnits, + INSUFFICIENT_BALANCE_MESSAGE, + BALANCE_SETTLING_MESSAGE, + isAmountWithinBalance, +} from '@/utils/balance.utils' import { isTxReverted, saveRedirectUrl, formatNumberForDisplay } from '@/utils/general.utils' import { getShakeClass, type ShakeIntensity } from '@/utils/perk.utils' import { @@ -98,7 +103,7 @@ export default function QRPayPage() { const qrCode = decodeURIComponent(searchParams.get('qrCode') || '') const timestamp = searchParams.get('t') const qrType = searchParams.get('type') - const { spendableBalance: balance, sendMoney } = useWallet() + const { spendableBalance: balance } = useWallet() const { signSpend } = useSignSpendBundle() const handleStaleSession = useStaleSessionGuard() const { overview: rainCardOverview } = useRainCardOverview() @@ -406,7 +411,13 @@ export default function QRPayPage() { }, [paymentLock?.code, paymentProcessor]) const isBlockingError = useMemo(() => { - return !!errorMessage && errorMessage !== 'Please confirm the transaction.' + // The settling failure says "try again in a few seconds" — keep the Pay + // button enabled so the user can retry, don't dead-end it like a hard error. + return ( + !!errorMessage && + errorMessage !== 'Please confirm the transaction.' && + errorMessage !== BALANCE_SETTLING_MESSAGE + ) }, [errorMessage]) const usdAmount = useMemo(() => { @@ -628,7 +639,7 @@ export default function QRPayPage() { } catch (error) { const rainMsg = rainCollateralErrorMessage(error) if (error instanceof InsufficientSpendableError) { - setErrorMessage('Not enough USDC in your wallet or card to cover this payment.') + setErrorMessage(BALANCE_SETTLING_MESSAGE) } else if (error instanceof SessionKeyGrantRequiredError) { setErrorMessage("One-time card authorization needed. You'll be asked to confirm once.") } else if (rainMsg) { @@ -937,8 +948,10 @@ export default function QRPayPage() { setBalanceErrorMessage(`QR payment amount exceeds maximum limit of $${MAX_QR_PAYMENT_AMOUNT}`) } else if (paymentAmount < parseUnits(MIN_QR_PAYMENT_AMOUNT, PEANUT_WALLET_TOKEN_DECIMALS)) { setBalanceErrorMessage(`QR payment amount must be at least $${MIN_QR_PAYMENT_AMOUNT}`) - } else if (paymentAmount > balance) { - setBalanceErrorMessage('Not enough balance to complete payment. Add funds!') + } else if (!isAmountWithinBalance(usdAmount, balance)) { + // gate on the displayed total; an in-transit shortfall passes here and + // fails late with the settling message at execution. + setBalanceErrorMessage(INSUFFICIENT_BALANCE_MESSAGE) } else { setBalanceErrorMessage(null) } diff --git a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx index 4f91076d9..ec7045921 100644 --- a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx @@ -8,11 +8,7 @@ import InfoCard from '@/components/Global/InfoCard' import NavHeader from '@/components/Global/NavHeader' import PeanutActionDetailsCard from '@/components/Global/PeanutActionDetailsCard' import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow' -import { - PEANUT_WALLET_CHAIN, - PEANUT_WALLET_TOKEN_SYMBOL, - PEANUT_WALLET_TOKEN_DECIMALS, -} from '@/constants/zerodev.consts' +import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN_SYMBOL } from '@/constants/zerodev.consts' import { useWithdrawFlow } from '@/context/WithdrawFlowContext' import { useWallet } from '@/hooks/wallet/useWallet' import { usePendingTransactions } from '@/hooks/wallet/usePendingTransactions' @@ -24,8 +20,9 @@ import { useQueryClient } from '@tanstack/react-query' import { TRANSACTIONS } from '@/constants/query.consts' import PaymentSuccessView from '@/features/payments/shared/components/PaymentSuccessView' import { ErrorHandler } from '@/utils/friendly-error.utils' +import { INSUFFICIENT_BALANCE_MESSAGE, isAmountWithinBalance } from '@/utils/balance.utils' import { getBridgeChainName } from '@/utils/bridge-accounts.utils' -import { getOfframpCurrencyConfig, getCountryFromPath, railJurisdictionForBank } from '@/utils/bridge.utils' +import { getOfframpConfigFromAccount, getCountryFromPath, railJurisdictionForBank } from '@/utils/bridge.utils' import { createOfframp, confirmOfframp } from '@/app/actions/offramp' import { useAuth } from '@/context/authContext' import { useTosGuard } from '@/hooks/useTosGuard' @@ -33,6 +30,8 @@ import { BridgeTosStep } from '@/components/Kyc/BridgeTosStep' import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow' import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal' +import AdvisoryPreemptModal from '@/components/Kyc/AdvisoryPreemptModal' +import { useAdvisoryPreempt } from '@/hooks/useAdvisoryPreempt' import { useCapabilities } from '@/hooks/useCapabilities' import { getKycModalVariant, getGateUserMessage } from '@/utils/capability-gate' import { useModalsContext } from '@/context/ModalsContext' @@ -41,7 +40,6 @@ import countryCurrencyMappings, { isNonEuroSepaCountry } from '@/constants/count import { isBridgeSupportedCountry, getRegionIntent } from '@/utils/regions.utils' import { PointsAction } from '@/services/services.types' import { usePointsCalculation } from '@/hooks/usePointsCalculation' -import { parseUnits } from 'viem' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' import { withdrawCountryUrl } from '@/utils/native-routes' @@ -92,6 +90,18 @@ export default function WithdrawBankPage() { const bankCountry = useMemo(() => railJurisdictionForBank(getCountryFromPath(country)?.id), [country]) const gate = useMemo(() => gateFor('withdraw', { channel: 'bank', country: bankCountry }), [gateFor, bankCountry]) const sumsubFlow = useMultiPhaseKycFlow({}) + // A ready bank rail can still carry a future-dated requirement (the gate's + // `advisory`). Offer it as a skippable pre-empt before the withdrawal. + const advisory = gate.kind === 'ready' ? gate.advisory : undefined + const { intercept: advisoryIntercept, modalProps: advisoryModalProps } = useAdvisoryPreempt({ + advisory, + isLoading: sumsubFlow.isLoading, + // Route through the self-heal resubmit path (reheal-tagged action) so the + // completed submission round-trips to Bridge. start-action mints a plain + // token whose webhook completion has no Bridge relay → answers are dropped. + onCompleteNow: () => + advisory ? sumsubFlow.handleSelfHealResubmit('BRIDGE', advisory.requirementKey) : Promise.resolve(), + }) const [showKycModal, setShowKycModal] = useState(false) const { setIsSupportModalOpen } = useModalsContext() @@ -146,32 +156,17 @@ export default function WithdrawBankPage() { }, [bankAccount, router, amountToWithdraw, country, view]) const destinationDetails = (account: Account) => { - let countryId: string - - switch (account.type) { - case AccountType.US: - countryId = 'US' - break - case AccountType.IBAN: - // Default to a European country that uses EUR/SEPA - countryId = 'DE' // Germany as default EU country - break - case AccountType.CLABE: - countryId = 'MX' - break - case AccountType.GB: - countryId = 'GB' - break - default: - return { - currency: '', - paymentRail: '', - externalAccountId: null, - } - } - - const { currency, paymentRail } = getOfframpCurrencyConfig(countryId) - + // Derive currency + rail from the account's actual type (GB→GBP, IBAN→EUR, + // US→USD, CLABE→MXN) rather than re-deriving from a country switch whose + // `default` returned an empty currency/rail. A UK account that arrived typed + // anything but GB (the pre-BANK_GB BE mistype, or a Prisma-shaped 'BANK_GB' + // string) fell through that default → empty payload → "External account ID + // is missing.". getOfframpConfigFromAccount tolerates both the projected + // ('gb') and Prisma-shaped ('BANK_GB') strings and keeps this flow + // consistent with the Claim flow (BankFlowManager). Manteca accounts never + // reach this Bridge page (separate /withdraw/manteca route), so its throw + // cannot fire here. + const { currency, paymentRail } = getOfframpConfigFromAccount(account) return { currency, paymentRail, @@ -193,7 +188,7 @@ export default function WithdrawBankPage() { return 'N/A' } - const handleCreateAndInitiateOfframp = async () => { + const proceedWithOfframp = async () => { if (gate.kind !== 'ready') { // Loading and waiting-on-provider both mean "user has no action to // take" — silently no-op instead of bouncing them through Sumsub. @@ -327,6 +322,11 @@ export default function WithdrawBankPage() { } } + // Offer the skippable advisory pre-empt once, then run the offramp. When the + // gate isn't `ready` (or nothing is future-dated) this is a no-op and + // proceedWithOfframp runs straight away (it handles the not-ready cases). + const handleCreateAndInitiateOfframp = () => advisoryIntercept(() => void proceedWithOfframp()) + const countryCodeForFlag = () => { if (!bankAccount?.details?.countryCode) return '' const code = @@ -351,12 +351,9 @@ export default function WithdrawBankPage() { return } - const withdrawAmount = parseUnits(amountToWithdraw, PEANUT_WALLET_TOKEN_DECIMALS) - if (withdrawAmount > balance) { - setBalanceErrorMessage('Not enough balance to complete withdrawal.') - } else { - setBalanceErrorMessage(null) - } + // gate on the displayed total; an in-transit shortfall passes here and + // fails late with the settling message at execution. + setBalanceErrorMessage(isAmountWithinBalance(amountToWithdraw, balance) ? null : INSUFFICIENT_BALANCE_MESSAGE) }, [amountToWithdraw, balance, hasPendingTransactions, isLoading]) if (!bankAccount) { @@ -550,6 +547,7 @@ export default function WithdrawBankPage() { providerMessage={getGateUserMessage(gate)} regionName={getCountryFromPath(country)?.title} /> + ) diff --git a/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx b/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx index a6602a68c..bb51cd3c8 100644 --- a/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx +++ b/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx @@ -248,8 +248,11 @@ function applyDefaults() { mockWithdrawFlow.selectedBankAccount = null mockUseWallet.mockReturnValue({ - // component destructures `spendableBalance` (not `balance`) — CodeRabbit nit + // component gates on the displayed `spendableBalance` (= maxDecimalAmount). spendableBalance: parseUnits('100', 6), + formattedSpendableBalance: '100.00', + // amount-aware: over-$100 entries are a true shortfall + hasSufficientSpendableBalance: (amt: string | number) => Number(amt) <= 100, }) mockUseGetExchangeRate.mockReturnValue({ @@ -364,10 +367,10 @@ describe('GROUP 3: Amount Validation', () => { test('Error state shows ErrorAlert', () => { mockWithdrawFlow.selectedMethod = { type: 'bridge', countryPath: 'us' } - mockWithdrawFlow.error = { showError: true, errorMessage: 'Amount exceeds your wallet balance.' } + mockWithdrawFlow.error = { showError: true, errorMessage: 'Not enough balance. Add funds to continue.' } renderWithdraw() - expect(screen.getByTestId('error-alert')).toHaveTextContent('Amount exceeds your wallet balance.') + expect(screen.getByTestId('error-alert')).toHaveTextContent('Not enough balance. Add funds to continue.') }) test('Error hidden when limits blocking card is displayed', () => { @@ -492,7 +495,11 @@ describe('GROUP 6: Continue never dead-buttons', () => { // feedback (Sentry: incomplete-app-router-transaction, 6 users/14d). mockGetCountryFromAccount.mockReturnValue(undefined) - mockUseWallet.mockReturnValue({ spendableBalance: parseUnits('100', 6) }) + mockUseWallet.mockReturnValue({ + spendableBalance: parseUnits('100', 6), + formattedSpendableBalance: '100.00', + hasSufficientSpendableBalance: (amt: string | number) => Number(amt) <= 100, + }) mockWithdrawFlow.selectedMethod = { type: 'bridge', countryPath: 'us' } mockWithdrawFlow.selectedBankAccount = { type: 'iban', details: { countryName: '', countryCode: '' } } mockWithdrawFlow.amountToWithdraw = '50' @@ -513,7 +520,11 @@ describe('GROUP 6: Continue never dead-buttons', () => { // Manteca (AR/BR) accounts set selectedBankAccount too; the manteca // method check must win over the generic bank branch so they reach // /withdraw/manteca rather than the Bridge bank page (or the throw). - mockUseWallet.mockReturnValue({ spendableBalance: parseUnits('100', 6) }) + mockUseWallet.mockReturnValue({ + spendableBalance: parseUnits('100', 6), + formattedSpendableBalance: '100.00', + hasSufficientSpendableBalance: (amt: string | number) => Number(amt) <= 100, + }) mockWithdrawFlow.selectedMethod = { type: 'manteca', countryPath: 'argentina', title: 'Bank Transfer' } mockWithdrawFlow.selectedBankAccount = { type: 'manteca', details: { countryName: 'argentina' } } mockWithdrawFlow.amountToWithdraw = '50' diff --git a/src/app/(mobile-ui)/withdraw/manteca/page.tsx b/src/app/(mobile-ui)/withdraw/manteca/page.tsx index 121e8fc99..4dd2b4abf 100644 --- a/src/app/(mobile-ui)/withdraw/manteca/page.tsx +++ b/src/app/(mobile-ui)/withdraw/manteca/page.tsx @@ -5,7 +5,12 @@ import { useSignSpendBundle } from '@/hooks/wallet/useSignSpendBundle' import { useStaleSessionGuard } from '@/hooks/wallet/useStaleSessionGuard' import { InsufficientSpendableError, SessionKeyGrantRequiredError } from '@/hooks/wallet/useSpendBundle' import { rainCollateralErrorMessage } from '@/utils/friendly-error.utils' -import { rainCentsToUsdcUnits } from '@/utils/balance.utils' +import { + rainCentsToUsdcUnits, + INSUFFICIENT_BALANCE_MESSAGE, + BALANCE_SETTLING_MESSAGE, + isAmountWithinBalance, +} from '@/utils/balance.utils' import { useRainCardOverview } from '@/hooks/useRainCardOverview' import { useState, useMemo, useContext, useEffect, useCallback, useId } from 'react' import { useRouter, useSearchParams } from 'next/navigation' @@ -22,11 +27,11 @@ import { loadingStateContext } from '@/context/loadingStates.context' import { countryData } from '@/components/AddMoney/consts' import { getFlagUrl } from '@/constants/countryCurrencyMapping' import Image from 'next/image' -import { formatAmount, formatNumberForDisplay } from '@/utils/general.utils' +import { formatNumberForDisplay } from '@/utils/general.utils' import { validateCbuCvuAlias, validatePixKey, normalizePixInput, isPixEmvcoQr } from '@/utils/withdraw.utils' import ValidatedInput from '@/components/Global/ValidatedInput' import AmountInput from '@/components/Global/AmountInput' -import { formatUnits, parseUnits } from 'viem' +import { parseUnits } from 'viem' import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow' import { useModalsContext } from '@/context/ModalsContext' import Select from '@/components/Global/Select' @@ -105,7 +110,7 @@ function MantecaBankWithdrawFlow() { const [priceLock, setPriceLock] = useState(null) const [isLockingPrice, setIsLockingPrice] = useState(false) const router = useRouter() - const { spendableBalance: balance } = useWallet() + const { spendableBalance: balance, formattedSpendableBalance } = useWallet() const { signSpend } = useSignSpendBundle() const handleStaleSession = useStaleSessionGuard() const { overview: rainCardOverview } = useRainCardOverview() @@ -358,7 +363,7 @@ function MantecaBankWithdrawFlow() { } catch (error) { const rainMsg = rainCollateralErrorMessage(error) if (error instanceof InsufficientSpendableError) { - setErrorMessage('Not enough USDC in your wallet or card to cover this withdrawal.') + setErrorMessage(BALANCE_SETTLING_MESSAGE) } else if (error instanceof SessionKeyGrantRequiredError) { // Grant prompt was attempted inside signSpend and failed. // Telling the user "you'll be asked" is misleading — they @@ -496,8 +501,10 @@ function MantecaBankWithdrawFlow() { // only check min amount and balance here - max amount is handled by limits validation if (paymentAmount < parseUnits(MIN_MANTECA_WITHDRAW_AMOUNT.toString(), PEANUT_WALLET_TOKEN_DECIMALS)) { setBalanceErrorMessage(`Withdraw amount must be at least $${MIN_MANTECA_WITHDRAW_AMOUNT}`) - } else if (paymentAmount > balance) { - setBalanceErrorMessage('Not enough balance to complete withdrawal.') + } else if (!isAmountWithinBalance(usdAmount, balance)) { + // gate on the displayed total; an in-transit shortfall passes here and + // fails late with the settling message at execution. + setBalanceErrorMessage(INSUFFICIENT_BALANCE_MESSAGE) } else { setBalanceErrorMessage(null) } @@ -687,9 +694,7 @@ function MantecaBankWithdrawFlow() { price: 1, decimals: 2, }} - walletBalance={ - balance ? formatAmount(formatUnits(balance, PEANUT_WALLET_TOKEN_DECIMALS)) : undefined - } + walletBalance={balance !== undefined ? formattedSpendableBalance : undefined} /> {/* limits warning/error card - uses centralized helper for props */} @@ -895,7 +900,8 @@ function MantecaBankWithdrawFlow() { icon="arrow-up" onClick={handleWithdraw} loading={isLoading} - disabled={!!errorMessage || isLoading} + // settling failure is retryable — don't dead-end the button on it + disabled={(!!errorMessage && errorMessage !== BALANCE_SETTLING_MESSAGE) || isLoading} shadowSize="4" > {isLoading ? loadingState : 'Withdraw'} diff --git a/src/app/(mobile-ui)/withdraw/page.tsx b/src/app/(mobile-ui)/withdraw/page.tsx index fb84273e0..6eb1a7f91 100644 --- a/src/app/(mobile-ui)/withdraw/page.tsx +++ b/src/app/(mobile-ui)/withdraw/page.tsx @@ -9,7 +9,7 @@ import { PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants/zerodev.consts' import { useWithdrawFlow } from '@/context/WithdrawFlowContext' import { useWallet } from '@/hooks/wallet/useWallet' import { tokenSelectorContext } from '@/context/tokenSelector.context' -import { formatAmount } from '@/utils/general.utils' +import { INSUFFICIENT_BALANCE_MESSAGE } from '@/utils/balance.utils' import { getCountryFromAccount, getCountryFromPath, getMinimumAmount } from '@/utils/bridge.utils' import useGetExchangeRate from '@/hooks/useGetExchangeRate' import { AccountType } from '@/interfaces' @@ -81,15 +81,20 @@ export default function WithdrawPage() { // raw amount currently typed in the input const [rawTokenAmount, setRawTokenAmount] = useState(amountFromContext || '') - const { spendableBalance: balance } = useWallet() + const { spendableBalance: balance, formattedSpendableBalance } = useWallet() + // Spend ceiling = the displayed total spendable. We gate on display (not an + // available-now subset) so we never block funds the live withdraw could route; + // an in-transit shortfall fails late with a settling message. See useWallet. const maxDecimalAmount = useMemo(() => { return balance !== undefined ? Number(formatUnits(balance, PEANUT_WALLET_TOKEN_DECIMALS)) : 0 }, [balance]) + // Displayed total spendable (smart + collateral), single-sourced + formatted + // by the hook. Empty while loading so we don't flash "$0.00". const peanutWalletBalance = useMemo(() => { - return balance !== undefined ? formatAmount(formatUnits(balance, PEANUT_WALLET_TOKEN_DECIMALS)) : '' - }, [balance]) + return balance === undefined ? '' : formattedSpendableBalance + }, [balance, formattedSpendableBalance]) // derive country and account type for minimum amount validation const { countryIso2, rateAccountType } = useMemo(() => { @@ -186,7 +191,11 @@ export default function WithdrawPage() { const price = selectedTokenData?.price ?? 0 // 0 for safety; will fail below const usdEquivalent = price ? amount * price : amount // if no price assume token pegged 1 USD - if (usdEquivalent >= minUsdAmount && amount <= maxDecimalAmount) { + // While the balance is still loading, maxDecimalAmount is 0 — skip the + // balance check so a pre-filled amount isn't false-blocked; the effect + // re-validates once it lands (validateAmount is in its deps). + const balanceLoaded = balance !== undefined + if (usdEquivalent >= minUsdAmount && (!balanceLoaded || amount <= maxDecimalAmount)) { setError({ showError: false, errorMessage: '' }) return true } @@ -198,15 +207,15 @@ export default function WithdrawPage() { message = isFromSendFlow ? `Minimum send amount is ${minDisplay}.` : `Minimum withdrawal is ${minDisplay}.` - } else if (amount > maxDecimalAmount) { - message = 'Amount exceeds your wallet balance.' + } else if (balanceLoaded && amount > maxDecimalAmount) { + message = INSUFFICIENT_BALANCE_MESSAGE } else { message = 'Please enter a valid amount.' } setError({ showError: true, errorMessage: message }) return false }, - [maxDecimalAmount, setError, selectedTokenData?.price, isFromSendFlow, minUsdAmount] + [balance, maxDecimalAmount, setError, selectedTokenData?.price, isFromSendFlow, minUsdAmount] ) const handleTokenAmountChange = useCallback( @@ -338,8 +347,10 @@ export default function WithdrawPage() { const usdEq = (selectedTokenData?.price ?? 1) * numericAmount if (usdEq < minUsdAmount) return true // below country-specific minimum - return numericAmount > maxDecimalAmount || error.showError - }, [rawTokenAmount, maxDecimalAmount, error.showError, selectedTokenData?.price, minUsdAmount]) + // only apply the balance ceiling once it has loaded (maxDecimalAmount is 0 + // while spendableBalance is undefined) — else Continue is disabled during load + return (balance !== undefined && numericAmount > maxDecimalAmount) || error.showError + }, [rawTokenAmount, balance, maxDecimalAmount, error.showError, selectedTokenData?.price, minUsdAmount]) // native app: render country-specific views when ?country= is present const viewFromQuery = searchParams.get('view') diff --git a/src/app/[...recipient]/page.tsx b/src/app/[...recipient]/page.tsx index 70b4beb0e..af5c7de1a 100644 --- a/src/app/[...recipient]/page.tsx +++ b/src/app/[...recipient]/page.tsx @@ -7,6 +7,7 @@ import { isAddress } from 'viem' import { printableAddress, isStableCoin } from '@/utils/general.utils' import { chargesApi } from '@/services/charges' import { parseAmountAndToken } from '@/lib/url-parser/parser' +import { buildOgImageUrl } from '@/utils/og.utils' import { notFound } from 'next/navigation' import { couldBeRecipient, isReservedRoute } from '@/constants/routes' @@ -99,30 +100,19 @@ export async function generateMetadata({ params, searchParams }: any) { if (!siteUrl) { console.error('Error: Unable to determine site origin') } else { - const ogUrl = new URL(`${siteUrl}/api/og`) - ogUrl.searchParams.set('type', 'request') - ogUrl.searchParams.set('username', recipient) - - if (amount) { - ogUrl.searchParams.set('amount', String(amount)) - if (token) { - ogUrl.searchParams.set('token', token.toUpperCase()) - } - } else { - // For ETH addresses/ENS without amount, set to 0 to show "is requesting funds" - ogUrl.searchParams.set('amount', '0') - } - - // Only show as receipt if there's both a chargeId AND it's paid - if (chargeId && isPaid) { - ogUrl.searchParams.set('isReceipt', 'true') - } - - if (isPeanutUsername) { - ogUrl.searchParams.set('isPeanutUsername', 'true') - } - - ogImageUrl = ogUrl.toString() + ogImageUrl = buildOgImageUrl( + { + type: 'request', + username: recipient, + // ETH addresses/ENS without an amount use 0 to show "is requesting funds" + amount: amount ? String(amount) : '0', + token: amount && token ? token.toUpperCase() : undefined, + // only a receipt when there's a chargeId AND it's paid + isReceipt: Boolean(chargeId && isPaid), + isPeanutUsername, + }, + siteUrl + ) } } diff --git a/src/app/actions/sumsub.ts b/src/app/actions/sumsub.ts index a3f72895b..519e8f43c 100644 --- a/src/app/actions/sumsub.ts +++ b/src/app/actions/sumsub.ts @@ -86,12 +86,15 @@ export const restartIdentityVerification = async (): Promise<{ // initiate self-heal document resubmission for a provider-rejected user export const initiateSelfHealResubmission = async ( - provider: 'BRIDGE' | 'MANTECA' + provider: 'BRIDGE' | 'MANTECA', + // Optional — target a specific (e.g. future-dated advisory) Bridge requirement + // by key. Omitted for the legacy blocking flow (current nextAction). + requirementKey?: string ): Promise<{ data?: SelfHealResubmissionResponse; error?: string }> => { try { const response = await serverFetch('/users/identity/resubmit', { method: 'POST', - body: JSON.stringify({ provider }), + body: JSON.stringify({ provider, ...(requirementKey ? { requirementKey } : {}) }), }) const responseJson = await response.json() @@ -112,3 +115,44 @@ export const initiateSelfHealResubmission = async ( return { error: message } } } + +export interface StartKycActionResponse { + token: string + levelName: string + externalActionId?: string +} + +/** + * Mint a Sumsub WebSDK token for a capability nextAction by its `key` + * (POST /users/kyc/start-action). The capability model returns action + * descriptors (a stable key + a registry levelKey) and never carries a token; + * the FE posts the key here to get an unexpired token bound to the right RFI + * level. Used by the advisory pre-empt — an already-approved user starting a + * future-dated RFI early, where /users/identity would short-circuit on + * "already approved" and never mint a token. + */ +export const startKycAction = async (key: string): Promise<{ data?: StartKycActionResponse; error?: string }> => { + try { + const response = await serverFetch('/users/kyc/start-action', { + method: 'POST', + body: JSON.stringify({ key }), + }) + const responseJson = await response.json() + if (!response.ok) { + return { error: responseJson.userMessage || responseJson.error || 'Failed to start verification' } + } + if (!responseJson.sumsubAccessToken) { + return { error: 'Invalid response from server' } + } + return { + data: { + token: responseJson.sumsubAccessToken, + levelName: responseJson.levelName, + externalActionId: responseJson.externalActionId, + }, + } + } catch (e: unknown) { + const message = e instanceof Error ? e.message : 'An unexpected error occurred' + return { error: message } + } +} diff --git a/src/app/invite/page.tsx b/src/app/invite/page.tsx index ad6f00809..dbe935fb8 100644 --- a/src/app/invite/page.tsx +++ b/src/app/invite/page.tsx @@ -3,6 +3,7 @@ import getOrigin from '@/lib/hosting/get-origin' import { type Metadata } from 'next' import { validateInviteCode } from '../actions/invites' import { BASE_URL } from '@/constants/general.consts' +import { buildOgImageUrl } from '@/utils/og.utils' export const dynamic = 'force-dynamic' @@ -46,14 +47,10 @@ export async function generateMetadata({ description = 'Join Peanut to send and receive money instantly, shop with merchants, and move funds across borders.' - const ogUrl = new URL(`${siteUrl}/api/og`) - ogUrl.searchParams.set('isInvite', 'true') - ogUrl.searchParams.set('username', inviteCodeData.username) - if (!siteUrl) { console.error('Error: Unable to determine site origin') } else { - ogImageUrl = ogUrl.toString() + ogImageUrl = buildOgImageUrl({ username: inviteCodeData.username, isInvite: true }, siteUrl) } } diff --git a/src/app/m/[slug]/merchants.ts b/src/app/m/[slug]/merchants.ts index e7812a7a0..314d03039 100644 --- a/src/app/m/[slug]/merchants.ts +++ b/src/app/m/[slug]/merchants.ts @@ -187,7 +187,7 @@ export const MERCHANTS: Record = { { id: 'funding', question: 'How do I fund it?', - answer: 'Two ways. Crypto: USDC or USDT on Solana, Arbitrum, Base, Tron, Avalanche, Polygon, or Ethereum, and we cover the network fees. Bank: SEPA, ACH, or wire from any country. No Argentine bank account needed.', + answer: 'Two ways. Crypto: USDC or USDT on Solana, Arbitrum, Base, Tron, Polygon, or Ethereum, and we cover the network fees. Bank: SEPA, ACH, or wire from any country. No Argentine bank account needed.', }, { id: 'receiving', diff --git a/src/app/receipt/[entryId]/page.tsx b/src/app/receipt/[entryId]/page.tsx index f55158c76..68a4131f3 100644 --- a/src/app/receipt/[entryId]/page.tsx +++ b/src/app/receipt/[entryId]/page.tsx @@ -13,6 +13,7 @@ import { generateMetadata as generateBaseMetadata } from '@/app/metadata' import { type Metadata } from 'next' import { BASE_URL } from '@/constants/general.consts' import { formatAmount, formatCurrency, isStableCoin } from '@/utils/general.utils' +import { buildOgImageUrl } from '@/utils/og.utils' import getOrigin from '@/lib/hosting/get-origin' import PageContainer from '@/components/0_Bruddle/PageContainer' @@ -138,32 +139,31 @@ export async function generateMetadata({ // Generate dynamic OG image URL const origin = (await getOrigin()) || BASE_URL - const ogUrl = new URL(`${origin}/api/og`) - - // Map transaction type for OG image const ogType = mapTransactionTypeToOGType(transactionDetails.extraDataForDrawer?.transactionCardType || 'send') - ogUrl.searchParams.set('type', ogType) - ogUrl.searchParams.set('isReceipt', 'true') - - // Add amount if available (always use USD amount) - if (transactionDetails.amount > 0) { - ogUrl.searchParams.set('amount', formatCurrency(Number(transactionDetails.amount).toString())) - ogUrl.searchParams.set('token', 'USDC') - } - - // Add username if available and not an address-like string - if ( + const hasAmount = transactionDetails.amount > 0 + // include username only when present and not an address-like string + const hasUsername = Boolean( transactionDetails.userName && transactionDetails.userName.length < 20 && !transactionDetails.userName.startsWith('0x') - ) { - ogUrl.searchParams.set('username', transactionDetails.userName) - } + ) + + const ogImageUrl = buildOgImageUrl( + { + type: ogType, + isReceipt: true, + username: hasUsername ? transactionDetails.userName : undefined, + // always denominate the receipt amount in USD + amount: hasAmount ? formatCurrency(Number(transactionDetails.amount).toString()) : undefined, + token: hasAmount ? 'USDC' : undefined, + }, + origin + ) return generateBaseMetadata({ title, description, - image: ogUrl.toString(), + image: ogImageUrl, keywords: 'crypto receipt, transaction receipt, payment receipt, Peanut Protocol', }) } diff --git a/src/components/AddMoney/components/MantecaAddMoney.tsx b/src/components/AddMoney/components/MantecaAddMoney.tsx index a43a54724..a6435d34e 100644 --- a/src/components/AddMoney/components/MantecaAddMoney.tsx +++ b/src/components/AddMoney/components/MantecaAddMoney.tsx @@ -41,15 +41,17 @@ const MantecaAddMoney: FC = () => { // URL state - persisted in query params // Example: /add-money/argentina/manteca?step=inputAmount&amount=100¤cy=ARS - // The `amount` is stored in whatever denomination `currency` specifies - const [urlState, setUrlState] = useQueryStates( - { - step: parseAsStringEnum(['inputAmount', 'depositDetails']), - amount: parseAsString, - currency: parseAsStringEnum(['USD', 'ARS', 'BRL', 'MXN', 'EUR']), - }, - { history: 'push' } - ) + // The `amount` is stored in whatever denomination `currency` specifies. + // history stays at the nuqs default ('replace'): `amount` is rewritten on every + // keystroke, so 'push' would stack a browser-history entry per character and the + // NavHeader back button (useSafeBack → router.back()) would only step through stale + // amounts of this same screen instead of leaving it. The URL stays shareable either + // way. Enforced by the no-restricted-syntax guard in eslint.config.js. + const [urlState, setUrlState] = useQueryStates({ + step: parseAsStringEnum(['inputAmount', 'depositDetails']), + amount: parseAsString, + currency: parseAsStringEnum(['USD', 'ARS', 'BRL', 'MXN', 'EUR']), + }) // Derive state from URL (with defaults) const step: MantecaStep = urlState.step ?? 'inputAmount' diff --git a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx index 6698a3044..c00fc73de 100644 --- a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx +++ b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx @@ -209,6 +209,14 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { // fallback to the previous method if we can't find the new account // this can happen if the user object is not updated immediately const newAccountFromResponse = result.data as Account + // The freshly-added account hasn't surfaced in the user refetch yet. + // The add-bank-account response is the projected wire shape, so it + // already carries bridgeAccountId + the legacy `type`. Guard: without + // a bridgeAccountId the confirm step dead-ends on "Bank account is + // missing", so surface a retryable error rather than navigating. + if (!newAccountFromResponse?.bridgeAccountId) { + return { error: 'Your bank account is still being set up. Please try again in a moment.' } + } // ensure details has accountOwnerName for confirmation page display newAccountFromResponse.details = { ...(newAccountFromResponse.details || {}), diff --git a/src/components/AddWithdraw/DynamicBankAccountForm.tsx b/src/components/AddWithdraw/DynamicBankAccountForm.tsx index 0b27f89ff..3b65d8488 100644 --- a/src/components/AddWithdraw/DynamicBankAccountForm.tsx +++ b/src/components/AddWithdraw/DynamicBankAccountForm.tsx @@ -10,6 +10,7 @@ import { BRIDGE_ALPHA3_TO_ALPHA2, ALL_COUNTRIES_ALPHA3_TO_ALPHA2 } from '@/compo import { useParams, useRouter } from 'next/navigation' import { validateIban, + validateBankAccount, validateBic, isValidRoutingNumber, isValidSortCode, @@ -19,7 +20,12 @@ import ErrorAlert from '@/components/Global/ErrorAlert' import { getBicFromIban } from '@/app/actions/ibanToBic' import PeanutActionDetailsCard, { type PeanutActionDetailsCardProps } from '../Global/PeanutActionDetailsCard' import { useWithdrawFlow } from '@/context/WithdrawFlowContext' -import { getCountryFromIban, validateMXCLabeAccount, validateUSBankAccount } from '@/utils/withdraw.utils' +import { + getCountryFromIban, + getCountryCodeForWithdraw, + validateMXCLabeAccount, + validateUSBankAccount, +} from '@/utils/withdraw.utils' import useSavedAccounts from '@/hooks/useSavedAccounts' import { useAppDispatch, useAppSelector } from '@/redux/hooks' import { bankFormActions } from '@/redux/slices/bank-form-slice' @@ -213,11 +219,29 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D ? accountNumber.replace(/\s/g, '').padStart(8, '0') : accountNumber.replace(/\s/g, '') + // SEPA routes by IBAN, so the destination country must come from the + // IBAN itself — not the country picked on the previous screen. We + // relaxed the "IBAN must match the selected country" gate, so without + // this a German IBAN entered under "Spain" would reach Bridge with + // countryCode=ESP and 400. Derive all three country fields from the + // IBAN to keep the Bridge payload internally consistent (exactly what + // the old equality gate guaranteed, just sourced from the IBAN). + // Single normalized IBAN source: the IBAN is entered in the + // accountNumber field (→ cleanedAccountNumber), but fall back to the + // separate `iban` form value so the country never derives off an empty + // string (which would send an empty countryCode to Bridge → 400). + const normalizedIban = isIban ? cleanedAccountNumber || (iban || '').replace(/\s/g, '') : '' + const ibanCountryCode = isIban + ? getCountryCodeForWithdraw(normalizedIban.slice(0, 2).toUpperCase()) + : '' + const ibanCountryName = isIban ? (getCountryFromIban(normalizedIban) ?? selectedCountry) : '' + const resolvedCountryCode = isUs ? 'USA' : isIban ? ibanCountryCode : country.toUpperCase() + const payload: Partial = { accountType, accountNumber: cleanedAccountNumber, - countryCode: isUs ? 'USA' : country.toUpperCase(), - countryName: selectedCountry, + countryCode: resolvedCountryCode, + countryName: isIban ? ibanCountryName : selectedCountry, accountOwnerType: BridgeAccountOwnerType.INDIVIDUAL, accountOwnerName: { firstName: firstName.trim(), @@ -228,7 +252,7 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D city: data.city ?? '', state: data.state ?? '', postalCode: data.postalCode ?? '', - country: isUs ? 'USA' : country.toUpperCase(), + country: resolvedCountryCode, }, ...(bic && { bic }), } @@ -453,9 +477,14 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D const isValidIban = await validateIban(val) if (!isValidIban) return 'Invalid IBAN' - if (getCountryFromIban(val)?.toLowerCase() !== selectedCountry) { - return 'IBAN does not match the selected country' - } + // SEPA routes by IBAN — the country picked on the + // previous screen is cosmetic for a EUR payout. Don't + // force the IBAN's country to equal the dropdown: that + // false-rejected a German IBAN with Spain selected, and + // blocked UK users withdrawing EUR to a GB IBAN. Gate on + // actual support instead (BE allowedCountries: SEPA/US/CA). + const isSupported = await validateBankAccount(val) + if (!isSupported) return 'This IBAN isn’t supported for withdrawals' return true }, diff --git a/src/components/Card/CardFace.tsx b/src/components/Card/CardFace.tsx index 663660de0..14bff2756 100644 --- a/src/components/Card/CardFace.tsx +++ b/src/components/Card/CardFace.tsx @@ -11,6 +11,9 @@ export interface RevealedCardDetails { cvv: string expiryMonth: number expiryYear: number + /** Registered cardholder name from Rain. Optional — the backend resolves it + * best-effort, so a Rain hiccup leaves it absent and the field is hidden. */ + cardholderName?: string } interface Props { @@ -125,10 +128,17 @@ const CardFace: FC = ({ )} + {/* Registered cardholder name — PII, kept out of session + * recordings like the other revealed fields. */} + {revealed.cardholderName && ( + + {revealed.cardholderName} + + )}
-
Expiry
+ {/* "Expiry" label dropped — value row stays one line so PAN/name clear the artwork */} {/* ph-no-capture: expiry digits out of recordings. */}
{String(revealed.expiryMonth).padStart(2, '0')}/ @@ -137,7 +147,7 @@ const CardFace: FC = ({
-
CVV
+ {/* "CVV" label dropped — value only */} {/* ph-no-capture: CVV out of recordings. */}
{revealed.cvv}
@@ -170,11 +180,11 @@ const CardFace: FC = ({
-
Expiry
+ {/* label dropped to match the revealed layout — no height jump on reveal */}
-
CVV
+ {/* label dropped to match the revealed layout */}
diff --git a/src/components/Card/__tests__/CardFace.test.tsx b/src/components/Card/__tests__/CardFace.test.tsx new file mode 100644 index 000000000..2c50a501b --- /dev/null +++ b/src/components/Card/__tests__/CardFace.test.tsx @@ -0,0 +1,43 @@ +/** + * CardFace — registered cardholder name. + * + * The name comes from Rain (best-effort) and is shown ONLY in the revealed + * state, alongside PAN/CVV/expiry. It must never appear on the masked card, and + * the card must still render when the reveal payload omits the name (backend + * degraded the Rain lookup). + */ +import React from 'react' +import { render, screen } from '@testing-library/react' +import CardFace, { type RevealedCardDetails } from '@/components/Card/CardFace' + +const revealed: RevealedCardDetails = { + pan: '4111111111111234', + cvv: '123', + expiryMonth: 12, + expiryYear: 2030, + cardholderName: 'Jane Doe', +} + +describe('CardFace cardholder name', () => { + it('shows the registered name when the card is revealed', () => { + render() + const name = screen.getByText('Jane Doe') + expect(name).toBeInTheDocument() + // PII guard: the name must stay inside the ph-no-capture wrapper so it's + // kept out of session recordings — assert the class, not just the text. + expect(name).toHaveClass('ph-no-capture') + }) + + it('hides the name when the card is masked (not revealed)', () => { + render() + expect(screen.queryByText('Jane Doe')).not.toBeInTheDocument() + }) + + it('still renders the revealed card when the name is absent', () => { + const { cardholderName: _omitted, ...withoutName } = revealed + render() + expect(screen.queryByText('Jane Doe')).not.toBeInTheDocument() + // PAN still renders, proving reveal works without the name. + expect(screen.getByText('4111 1111 1111 1234')).toBeInTheDocument() + }) +}) diff --git a/src/components/Claim/Link/views/BankFlowManager.view.tsx b/src/components/Claim/Link/views/BankFlowManager.view.tsx index f66aacdef..e520f9c14 100644 --- a/src/components/Claim/Link/views/BankFlowManager.view.tsx +++ b/src/components/Claim/Link/views/BankFlowManager.view.tsx @@ -15,7 +15,7 @@ import useClaimLink from '../../useClaimLink' import { type AddBankAccountPayload } from '@/app/actions/types/users.types' import { useAuth } from '@/context/authContext' import { type TCreateOfframpRequest, type TCreateOfframpResponse } from '@/services/services.types' -import { getOfframpCurrencyConfig } from '@/utils/bridge.utils' +import { getOfframpConfigFromAccount } from '@/utils/bridge.utils' import { getBridgeChainName, getBridgeTokenName } from '@/utils/bridge-accounts.utils' import { generateKeysFromString, getParamsFromLink } from '@/utils/peanut-link.utils' import { getContractAddress } from '@/utils/peanut-claim.utils' @@ -239,7 +239,13 @@ export const BankFlowManager = (props: IClaimScreenProps) => { const externalAccountId = (account.bridgeAccountId ?? account.id) as string - const destination = getOfframpCurrencyConfig(account.country ?? selectedCountry!.id) + // Derive destination currency + rail from the SELECTED ACCOUNT's + // type, not from `selectedCountry`. Pairing a GB/GBP account with + // a SEPA destination is semantically impossible — Bridge rejects + // with "country is not supported for SEPA" (PEANUT-API-5P/5M/5N + // on 2026-06-02). The account's `type` already carries the right + // answer for every Bridge destination we support. + const destination = getOfframpConfigFromAccount(account) // handle offramp request creation const offrampRequestParams: TCreateOfframpRequest = { @@ -257,6 +263,23 @@ export const BankFlowManager = (props: IClaimScreenProps) => { externalAccountId, }, features: { allowAnyFromAddress: true }, + // travel rule: pass claimer details for third-party guest claims + ...(isGuestFlow && + account.firstName && + account.lastName && { + beneficiaryName: `${account.firstName} ${account.lastName}`, + ...(account.street && + account.city && + account.country && { + beneficiaryAddress: { + street: account.street, + city: account.city, + country: account.country, + state: account.state || undefined, + postalCode: account.postalCode || undefined, + }, + }), + }), } const offrampResponse = isGuestFlow @@ -321,6 +344,9 @@ export const BankFlowManager = (props: IClaimScreenProps) => { } if (addBankAccountResponse.data?.id) { const bankDetails = { + // carry the account type so getOfframpConfigFromAccount() derives + // the rail from it (GB→GBP) instead of falling back to country + type: addBankAccountResponse.data.type, name: addBankAccountResponse.data.details.accountOwnerName || user?.user.fullName || '', iban: addBankAccountResponse.data.type === 'iban' @@ -410,6 +436,18 @@ export const BankFlowManager = (props: IClaimScreenProps) => { // merge the external account details with the user's details const finalBankDetails = { + // derive the account type from the response shape so + // getOfframpConfigFromAccount() routes by rail (GB sort_code → gb) + // instead of falling back to country + type: externalAccountResponse?.iban + ? 'iban' + : externalAccountResponse?.clabe + ? 'clabe' + : externalAccountResponse?.account?.sort_code + ? 'gb' + : externalAccountResponse?.account + ? 'us' + : undefined, id: externalAccountResponse.id, bridgeAccountId: externalAccountResponse.id, name: externalAccountResponse.bank_name ?? rawData.name, @@ -459,6 +497,9 @@ export const BankFlowManager = (props: IClaimScreenProps) => { const lastName = lastNameParts.join(' ') const bankDetails = { + // carry the account type so getOfframpConfigFromAccount() + // derives the rail from it instead of falling back to country + type: account.type, name: account.details?.accountOwnerName || user?.user.fullName || '', iban: account.type === 'iban' ? account.identifier || '' : '', clabe: account.type === 'clabe' ? account.identifier || '' : '', diff --git a/src/components/Global/AmountInput/index.tsx b/src/components/Global/AmountInput/index.tsx index d2f256fbe..d3bcf3788 100644 --- a/src/components/Global/AmountInput/index.tsx +++ b/src/components/Global/AmountInput/index.tsx @@ -216,6 +216,14 @@ const AmountInput = ({ } }, [displayValue]) + // Autofocus the amount field on mount (desktop only). Done explicitly via the + // ref instead of React's `autoFocus` prop, which only fires at the exact moment + // of mount and silently no-ops when the input mounts after a client-side + // navigation/step transition (the add-money amount screen regressed this way). + useEffect(() => { + if (shouldAutoFocus) inputRef.current?.focus() + }, [shouldAutoFocus]) + return (
{ diff --git a/src/components/Global/NavHeader/index.tsx b/src/components/Global/NavHeader/index.tsx index d1be84f07..9946fd6bc 100644 --- a/src/components/Global/NavHeader/index.tsx +++ b/src/components/Global/NavHeader/index.tsx @@ -32,7 +32,7 @@ const NavHeader = ({
{!onPrev ? ( -
diff --git a/src/components/Kyc/AdvisoryPreemptModal.tsx b/src/components/Kyc/AdvisoryPreemptModal.tsx new file mode 100644 index 000000000..bf0a62a11 --- /dev/null +++ b/src/components/Kyc/AdvisoryPreemptModal.tsx @@ -0,0 +1,65 @@ +import ActionModal from '@/components/Global/ActionModal' + +interface AdvisoryPreemptModalProps { + visible: boolean + /** ISO date the requirement becomes blocking; drives the deadline copy. */ + effectiveDate?: string + isLoading?: boolean + /** Launch the verification flow early. */ + onCompleteNow: () => void + /** Dismiss and continue with what the user was doing. */ + onSkip: () => void + onClose: () => void +} + +function formatEffectiveDate(iso?: string): string | null { + if (!iso) return null + const date = new Date(iso) + // `iso` is a date-only YYYY-MM-DD, so `new Date()` parses it at UTC midnight. + // Format in UTC too, or Americas timezones render the day before the deadline. + return Number.isNaN(date.getTime()) + ? null + : date.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC' }) +} + +/** + * Skippable pre-empt for a future-dated verification requirement on a rail that + * still works today (the gate's `ready` + `advisory`). "Complete now" launches + * the verification early; "Not now" lets the user carry on and resolve it later. + * Once the effective date passes the backend reclassifies the requirement to + * blocking and the non-skippable InitiateKycModal takes over — there is no FE + * cutover logic here. + */ +export default function AdvisoryPreemptModal({ + visible, + effectiveDate, + isLoading = false, + onCompleteNow, + onSkip, + onClose, +}: AdvisoryPreemptModalProps) { + const formatted = formatEffectiveDate(effectiveDate) + return ( + + ) +} diff --git a/src/components/Request/__tests__/request-states.test.tsx b/src/components/Request/__tests__/request-states.test.tsx index 3cadcf7dc..00e5c5c97 100644 --- a/src/components/Request/__tests__/request-states.test.tsx +++ b/src/components/Request/__tests__/request-states.test.tsx @@ -138,7 +138,7 @@ jest.mock('@/components/0_Bruddle/Toast', () => ({ jest.mock('@/components/Global/AmountInput', () => ({ __esModule: true, default: (props: any) => ( -
+
{ return { loadingStateContext } }) +// DirectRequestInitialView deps — only this view uses them (PayRequestLink does +// not), so stubbing them globally is safe. Defaults resolve to a logged-in user +// viewing a valid recipient, so the main form (incl. AmountInput) renders. +const mockUseUserStore = jest.fn(() => ({ user: { user: { userId: 'user-1', username: 'me' } } })) +jest.mock('@/redux/hooks', () => ({ + useUserStore: () => mockUseUserStore(), +})) + +const mockUseUserByUsername = jest.fn(() => ({ + user: { userId: 'recip-1', username: 'test-user', fullName: 'Test User', isVerified: false }, + isLoading: false, + error: undefined, +})) +jest.mock('@/hooks/useUserByUsername', () => ({ + useUserByUsername: () => mockUseUserByUsername(), +})) + +const mockUseUserInteractions = jest.fn(() => ({ interactions: {} })) +jest.mock('@/hooks/useUserInteractions', () => ({ + useUserInteractions: () => mockUseUserInteractions(), +})) + +jest.mock('@/components/Global/PeanutLoading', () => ({ + __esModule: true, + default: () =>
, +})) + +jest.mock('@/components/User/UserCard', () => ({ + __esModule: true, + default: () =>
, +})) + // ---------- import components under test AFTER all mocks ---------- import { CreateRequestLinkView } from '../link/views/Create.request.link.view' import { PayRequestLink } from '../Pay/Pay' +import DirectRequestInitialView from '../direct-request/views/Initial.direct.request.view' // ---------- helpers ---------- @@ -323,6 +356,16 @@ function renderPayRequest(params: Record = {}) { ) } +function renderDirectRequest() { + const queryClient = createQueryClient() + + return render( + + + + ) +} + // ---------- default mock values ---------- function applyDefaults() { @@ -411,6 +454,41 @@ beforeEach(() => { applyDefaults() }) +// ============================================================ +// GROUP 0: Balance affordance — spendable (smart + card collateral) +// ============================================================ +describe('GROUP 0: Balance affordance', () => { + // Regression for the report where /request read lower than /home: both entry + // views must show the spendable total (smart + card collateral), sourced from + // the hook's `formattedSpendableBalance` — NOT the smart-only `formattedBalance`. + // Distinct sentinels prove which field reaches the AmountInput's walletBalance. + const SPENDABLE = '250.00 (spendable)' + const SMART_ONLY = '100.00 (smart-only)' + const walletWithSplit = { + address: '0x1234567890abcdef1234567890abcdef12345678', + isConnected: true, + spendableBalance: BigInt(250_000_000), // defined → not the loading branch + formattedSpendableBalance: SPENDABLE, + formattedBalance: SMART_ONLY, + } + + test('create-request shows the spendable balance, not smart-only', () => { + mockUseWallet.mockReturnValue(walletWithSplit) + + renderCreateRequest() + + expect(screen.getByTestId('amount-input')).toHaveAttribute('data-wallet-balance', SPENDABLE) + }) + + test('direct-request shows the spendable balance, not smart-only', () => { + mockUseWallet.mockReturnValue(walletWithSplit) + + renderDirectRequest() + + expect(screen.getByTestId('amount-input')).toHaveAttribute('data-wallet-balance', SPENDABLE) + }) +}) + // ============================================================ // GROUP 1: CreateRequestLinkView — Initial Form States // ============================================================ diff --git a/src/components/Request/direct-request/views/Initial.direct.request.view.tsx b/src/components/Request/direct-request/views/Initial.direct.request.view.tsx index dfad01ffc..33b8b2b36 100644 --- a/src/components/Request/direct-request/views/Initial.direct.request.view.tsx +++ b/src/components/Request/direct-request/views/Initial.direct.request.view.tsx @@ -15,7 +15,6 @@ import { useUserStore } from '@/redux/hooks' import { type IAttachmentOptions } from '@/interfaces/attachment' import { usersApi } from '@/services/users' import { formatAmount } from '@/utils/general.utils' -import { printableUsdc } from '@/utils/balance.utils' import { captureException } from '@sentry/nextjs' import { useCallback, useContext, useEffect, useMemo, useState } from 'react' import { useUserInteractions } from '@/hooks/useUserInteractions' @@ -29,7 +28,7 @@ interface DirectRequestInitialViewProps { const DirectRequestInitialView = ({ username }: DirectRequestInitialViewProps) => { const onBack = useSafeBack('/home') const { user: authUser } = useUserStore() - const { balance, address } = useWallet() + const { spendableBalance: balance, formattedSpendableBalance, address } = useWallet() const [attachmentOptions, setAttachmentOptions] = useState({ message: undefined, fileUrl: undefined, @@ -66,9 +65,11 @@ const DirectRequestInitialView = ({ username }: DirectRequestInitialViewProps) = }) } + // Displayed total spendable, single-sourced + formatted by the hook; empty + // while loading so we don't flash "$0.00". const peanutWalletBalance = useMemo(() => { - return balance !== undefined ? printableUsdc(balance) : '' - }, [balance]) + return balance === undefined ? '' : formattedSpendableBalance + }, [balance, formattedSpendableBalance]) const handleTokenValueChange = (value: string | undefined) => { setCurrentInputValue(value || '') diff --git a/src/components/Request/link/views/Create.request.link.view.tsx b/src/components/Request/link/views/Create.request.link.view.tsx index b43724bc7..09d730837 100644 --- a/src/components/Request/link/views/Create.request.link.view.tsx +++ b/src/components/Request/link/views/Create.request.link.view.tsx @@ -22,7 +22,6 @@ import { type IToken } from '@/interfaces' import { type IAttachmentOptions } from '@/interfaces/attachment' import { requestsApi } from '@/services/requests' import { fetchTokenSymbol, formatTokenAmount, getRequestLink, isNativeCurrency } from '@/utils/general.utils' -import { printableUsdc } from '@/utils/balance.utils' import * as Sentry from '@sentry/nextjs' import * as peanutInterfaces from '@/interfaces/peanut-sdk-types' import { useQueryClient } from '@tanstack/react-query' @@ -34,7 +33,7 @@ import { useSafeBack } from '@/hooks/useSafeBack' export const CreateRequestLinkView = () => { const toast = useToast() const onBack = useSafeBack('/home') - const { address, isConnected, balance } = useWallet() + const { address, isConnected, spendableBalance: balance, formattedSpendableBalance } = useWallet() const { user } = useAuth() const { selectedChainID, setSelectedChainID, selectedTokenAddress, setSelectedTokenAddress, selectedTokenData } = useContext(tokenSelectorContext) @@ -79,8 +78,12 @@ export const CreateRequestLinkView = () => { // Refs for cleanup const createLinkAbortRef = useRef(null) - // Derived state - const peanutWalletBalance = useMemo(() => (balance !== undefined ? printableUsdc(balance) : ''), [balance]) + // Derived state — displayed total spendable, single-sourced + formatted by the + // hook; empty while loading so we don't flash "$0.00". + const peanutWalletBalance = useMemo( + () => (balance === undefined ? '' : formattedSpendableBalance), + [balance, formattedSpendableBalance] + ) const usdValue = useMemo(() => { if (!selectedTokenData?.price || !tokenValue) return '' diff --git a/src/components/Send/link/views/Initial.link.send.view.tsx b/src/components/Send/link/views/Initial.link.send.view.tsx index 570c8fbf8..2e9bf40d1 100644 --- a/src/components/Send/link/views/Initial.link.send.view.tsx +++ b/src/components/Send/link/views/Initial.link.send.view.tsx @@ -10,7 +10,7 @@ import { useLinkSendFlow } from '@/context/LinkSendFlowContext' import { useWallet } from '@/hooks/wallet/useWallet' import { sendLinksApi } from '@/services/sendLinks' import { ErrorHandler } from '@/utils/friendly-error.utils' -import { printableUsdc } from '@/utils/balance.utils' +import { INSUFFICIENT_BALANCE_MESSAGE, isAmountWithinBalance } from '@/utils/balance.utils' import { captureException } from '@sentry/nextjs' import { useQueryClient } from '@tanstack/react-query' import { useCallback, useContext, useEffect, useMemo } from 'react' @@ -38,18 +38,30 @@ const LinkSendInitialView = () => { const { setLoadingState, isLoading } = useContext(loadingStateContext) - const { fetchBalance, spendableBalance: balance } = useWallet() + const { fetchBalance, spendableBalance: balance, formattedSpendableBalance } = useWallet() const queryClient = useQueryClient() const { hasPendingTransactions } = usePendingTransactions() + // Displayed total spendable (smart + collateral), single-sourced + formatted + // by the hook. Empty while loading so we don't flash "$0.00". const peanutWalletBalance = useMemo(() => { - return balance !== undefined ? printableUsdc(balance) : '' - }, [balance]) + return balance === undefined ? '' : formattedSpendableBalance + }, [balance, formattedSpendableBalance]) const handleOnNext = useCallback(async () => { try { if (isLoading || !tokenValue) return + // Re-check affordability at submit too: the Retry button isn't disabled + // on a balance error (unlike the other flows), so without this a blocked + // amount could reach createLink. Only when the balance has loaded — else + // a tap before the query resolves would false-reject. Gates on the + // displayed total; an in-transit shortfall fails late with the settling copy. + if (balance !== undefined && !isAmountWithinBalance(tokenValue, balance)) { + setErrorState({ showError: true, errorMessage: INSUFFICIENT_BALANCE_MESSAGE }) + return + } + setLoadingState('Loading') // clear any previous errors @@ -118,6 +130,7 @@ const LinkSendInitialView = () => { setLink, setView, setErrorState, + balance, ]) useEffect(() => { @@ -133,15 +146,25 @@ const LinkSendInitialView = () => { setErrorState({ showError: false, errorMessage: '' }) return } - if ( - parseUnits(peanutWalletBalance, PEANUT_WALLET_TOKEN_DECIMALS) < - parseUnits(tokenValue, PEANUT_WALLET_TOKEN_DECIMALS) - ) { - setErrorState({ showError: true, errorMessage: 'Insufficient balance' }) - } else { + // Gate on the displayed total: block only a true shortfall. An in-transit + // amount passes and fails late (settling message + refetch) — the FE balance + // is ~30s-polled, so blocking it here would over-reject routable funds. + if (!isAmountWithinBalance(tokenValue, balance)) { + setErrorState({ showError: true, errorMessage: INSUFFICIENT_BALANCE_MESSAGE }) + } else if (errorState?.errorMessage === INSUFFICIENT_BALANCE_MESSAGE) { + // only clear OUR balance-gate error — never wipe a submit-time failure + // message (e.g. the settling copy) that handleOnNext set on a late failure. setErrorState({ showError: false, errorMessage: '' }) } - }, [peanutWalletBalance, tokenValue, setErrorState, hasPendingTransactions, isLoading]) + }, [ + peanutWalletBalance, + balance, + tokenValue, + setErrorState, + hasPendingTransactions, + isLoading, + errorState?.errorMessage, + ]) return (
diff --git a/src/components/TransactionDetails/TransactionDetailsReceipt.tsx b/src/components/TransactionDetails/TransactionDetailsReceipt.tsx index dc82087ed..4ead262e3 100644 --- a/src/components/TransactionDetails/TransactionDetailsReceipt.tsx +++ b/src/components/TransactionDetails/TransactionDetailsReceipt.tsx @@ -49,9 +49,11 @@ import { isPerkReward as isPerkRewardTransaction, isRequestEntry, isSendLinkEntry, + isSplittable, usesCompletedTimestampLabel, } from './transaction-predicates' import { useReceiptViewModel } from './useReceiptViewModel' +import { buildSplitBillRequestUrl } from './splitBill.utils' import { CardPaymentRows } from './provider-rows/CardPaymentRows' import { LocalRailNudge } from './provider-rows/LocalRailNudge' import { MantecaDepositInfo } from './provider-rows/MantecaDepositInfo' @@ -316,7 +318,10 @@ export const TransactionDetailsReceipt = ({ isAvatarClickable={isAvatarClickable} showProgessBar={transaction.isRequestPotLink} goal={Number(transaction.amount)} - progress={Number(formattedTotalAmountCollected)} + // Use the raw numeric field, NOT formattedTotalAmountCollected — the + // latter is comma-grouped ("1,234.56"), so Number() → NaN for any pot + // that has collected ≥ $1,000, blanking the progress bar. + progress={Number(transaction.totalAmountCollected)} isRequestPotTransaction={transaction.isRequestPotLink} isTransactionClosed={transaction.status === 'closed'} convertedAmount={convertedAmount ?? undefined} @@ -739,11 +744,9 @@ export const TransactionDetailsReceipt = ({
)} - {!isPublic && isQRPayment && transaction.status !== 'refunded' && ( + {!isPublic && isSplittable(transaction) && (