PIX payment (GBP / EUR → BRL)
The main use case. User scans a merchant's PIX QR (or enters the key manually), sees a locked-rate price in their home currency, taps Pay, and Infinia settles the PIX in ~15–20 seconds.
Sequence
Payload detail for the calls above:
POST /pix/quote→{ amountBrl }POST /pix/payments→{ quoteId, pixKey, pixKeyType, recipientName? }- Leg 1
internal-transfer—idempotency_key = "xfer" + paymentId(dashes stripped), derived from the payment id. - Leg 2
internal-transfer—idempotency_key = "x2" + paymentId(dashes stripped). Leg 2 runs once per payment (a definitive leg-2 failure unwinds to a refund, no re-quote), so the key is derived from the payment UUID, not the provider quote id (which isn't UUID-shaped and could collide); a re-driven ambiguous leg 2 reuses the same key and dedupes. The reverse USDC→home refund leg uses"rfnd" + paymentId. - Infinia
payoutsusesoriginId = "payout-" + paymentId.
Total time: ~20s from tap to merchant "paid".
The 60-second locked rate
The FX quote endpoint (POST /v1/accounts/internal-transfer/quote/) returns a quoteId valid for a caller-specified lock window (5s–1800s). We use 60s — long enough to cover realistic user think-time, short enough that the spread doesn't widen meaningfully.
The quote call isn't "extra" — we need to display a price on the review screen anyway. Reusing its quoteId when we execute the transfer is free.
Quote expiry handling. If the user takes >60s and taps Pay after the quote expires:
- The API silently re-quotes server-side.
- If the new price differs by >0.1%, the mobile app shows a "rate updated to £77.14, confirm?" prompt.
- Otherwise it proceeds transparently.
Users don't see a stressful countdown timer.
Lazy BRL account
The user's Infinia BRL account is created on their first PIX payment, not at KYC completion. First PIX pays ~1–2s for the account creation call; subsequent PIX payments reuse the cached account (provider_accounts row for (user, 'infinia', 'BRL')). The per-user USDC pivot account used for the two-leg conversion is provisioned the same lazy way, and stays internal — it is excluded from /me and GET /virtual-accounts and never shown to the user.
Screens
apps/mobile/app/pix/index.tsx— entry: Scan vs Manual.apps/mobile/app/pix/scan.tsx—expo-cameraQR scan.apps/mobile/app/pix/manual.tsx— form entry.apps/mobile/app/pix/review.tsx— shows quote + expiry countdown + Pay button.apps/mobile/app/pix/receipt.tsx— polls status; surfacespixEndToEndIdon completion.
Pricing math
packages/shared/src/pix/pricing.ts:
target_amount = amountBrl (what merchant is paid)
source_amount = target_amount / rate (rate = composed GBP/EUR→USDC→BRL rate,
the product of the two legs Infinia quotes)
displayed_source = source_amount × (1 + gringoSpreadBps / 10_000)
Because Infinia has no direct GBP/EUR→BRL pair, the rate we price against is the composition of the two internal legs (GBP/EUR→USDC and USDC→BRL). Our spread is the delta between that composed Infinia rate and what we bill the user. Currently DEFAULT_GRINGO_SPREAD_BPS = 200 (2.0%) — subject to revision once we have Infinia's actual fee schedule (now two spreads) to compare against.
Failure modes
| What broke | Where it surfaces | Recovery |
|---|---|---|
| Invalid PIX key format | POST /pix/payments → 400 | User re-enters |
| Infinia quote endpoint fails | POST /pix/quote → 502 | User retries; no ledger row created |
| Leg 1 definitively rejected (4xx) | POST /pix/payments → 200 { status: "failed" }; a failed ledger row is created (no funds moved — the response contract needs a transactionId) | Surfaced on receipt as failed |
| Leg 1 ambiguous error (timeout/5xx/409) | POST /pix/payments → 502; no ledger row; row stays converting, recoverable via the deterministic idempotency key | Reconcile-on-read / cron re-drives it |
| Leg 1 webhook says FAILED | status = failed; funds untouched in GBP/EUR account | Surfaced on receipt as failed with reason |
| Leg 2 (USDC→BRL) definitively FAILED / insufficient USDC | Auto-unwind: status = refunding → refunded; parked USDC reversed back to the user's home currency, returning exactly what they paid | Automatic — no user retry (a point-of-sale flow can't wait) |
| Refund can't return the full amount paid, or the reverse leg fails | status = manual_review; home amount still owed recorded in refund_absorbed | Human reconciliation (terminal, non-retryable) |
| Payout API call rejected / webhook FAILED | status = payout_failed; BRL parked in the user's own BRL account | POST …/retry re-issues the payout (same originId, no second FX) |
| No terminal webhook ever arrives | Payment stuck in converting or paying. Nightly reconciliation cron polls GET /v2/payouts/{payout_id}/ and the transfer legs to detect and resolve. | Manual poll available via GET /pix/payments/:id |
| Duplicate webhook | Idempotent — (provider, external_event_id) unique short-circuits | Automatic no-op |
The two-hop route has two money-left-mid-flow cases worth flagging, handled differently (a point-of-sale flow can't wait on a user retry for the conversion leg):
- Leg 2 definitively failed → auto-unwind refund (
refunding→refunded) — leg 1 done but the USDC→BRL leg definitively failed (no funds moved on leg 2). The parked USDC is automatically reversed back to the user's home currency and refunded — target-anchored onhome_debited, so the user gets back exactly what they paid, never more; any surplus USDC stays parked. No user retry. An ambiguous leg 2 staysconvertingand is re-driven by recovery first, so the USDC is never unwound while leg 2 might still land. If the parked USDC can't cover the full amount paid (adverse move, no treasury to top up yet) or the reverse leg fails, the row goes tomanual_reviewwith the shortfall recorded inrefund_absorbed. - BRL parked (
payout_failed) — both legs done but the PIX payout failed. This one is user-retryable:POST /pix/payments/:id/retryre-issues the payout with the sameoriginId(no second FX). The BRL sits in the user's own BRL account meanwhile. - The refund makes GrinGo eat the round-trip FX spread (now across two legs). Factored into unit economics as an expected-loss line item.
See payment-providers for the mechanism detail.
What we're storing
transactions row (the coarse ledger view). PIX reuses the generic ledger row (ensureLedgerRow) as a withdrawal — there are no per-flow source_*/dest_*/fx_rate/provider_* columns:
type = "withdrawal",fiat_amount= merchant BRL in minor units (centavos),fiat_currency = "brl",amount_usdc = nullprovider_event_id = "pix:<pixPaymentId>"(keys the idempotent upsert)statusprogressespending → completed | failed
pix_payments row (1:1 with transaction) — where all the PIX/two-hop detail lives:
statuswalks the detailed PIX lifecyclequoted → converting → paying → completed, with the leg-2-failure refund statesrefunding(reverse USDC→home in flight) andrefunded(user returned exactly what they paid), recoverablepayout_failed(BRL parked), terminal non-retryablemanual_review, terminalfailed, and the legacy/unusedconversion_failedamount_brl,amount_usdc(the intermediate USDC leg), andexchange_rate— the single customer-facing composed rate (there is no separate quote/execute rate column)infinia_quote_id+infinia_transfer_id(leg 1, GBP/EUR→USDC),infinia_quote_id_2+infinia_transfer_id_2(leg 2, USDC→BRL),infinia_payout_id,voucher_idusdc_account_id,brl_account_idpix_key,pix_key_type,recipient_namepix_end_to_end_id(populated by payout webhook)
The receipt shows the composed exchange_rate and the voucher_id as proof.