Skip to main content

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-transferidempotency_key = "xfer" + paymentId (dashes stripped), derived from the payment id.
  • Leg 2 internal-transferidempotency_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 payouts uses originId = "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.tsxexpo-camera QR 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; surfaces pixEndToEndId on 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 brokeWhere it surfacesRecovery
Invalid PIX key formatPOST /pix/payments → 400User re-enters
Infinia quote endpoint failsPOST /pix/quote → 502User retries; no ledger row created
Leg 1 definitively rejected (4xx)POST /pix/payments200 { 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 keyReconcile-on-read / cron re-drives it
Leg 1 webhook says FAILEDstatus = failed; funds untouched in GBP/EUR accountSurfaced on receipt as failed with reason
Leg 2 (USDC→BRL) definitively FAILED / insufficient USDCAuto-unwind: status = refundingrefunded; parked USDC reversed back to the user's home currency, returning exactly what they paidAutomatic — no user retry (a point-of-sale flow can't wait)
Refund can't return the full amount paid, or the reverse leg failsstatus = manual_review; home amount still owed recorded in refund_absorbedHuman reconciliation (terminal, non-retryable)
Payout API call rejected / webhook FAILEDstatus = payout_failed; BRL parked in the user's own BRL accountPOST …/retry re-issues the payout (same originId, no second FX)
No terminal webhook ever arrivesPayment 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 webhookIdempotent — (provider, external_event_id) unique short-circuitsAutomatic 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 (refundingrefunded) — 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 on home_debited, so the user gets back exactly what they paid, never more; any surplus USDC stays parked. No user retry. An ambiguous leg 2 stays converting and 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 to manual_review with the shortfall recorded in refund_absorbed.
  • BRL parked (payout_failed) — both legs done but the PIX payout failed. This one is user-retryable: POST /pix/payments/:id/retry re-issues the payout with the same originId (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 = null
  • provider_event_id = "pix:<pixPaymentId>" (keys the idempotent upsert)
  • status progresses pending → completed | failed

pix_payments row (1:1 with transaction) — where all the PIX/two-hop detail lives:

  • status walks the detailed PIX lifecycle quoted → converting → paying → completed, with the leg-2-failure refund states refunding (reverse USDC→home in flight) and refunded (user returned exactly what they paid), recoverable payout_failed (BRL parked), terminal non-retryable manual_review, terminal failed, and the legacy/unused conversion_failed
  • amount_brl, amount_usdc (the intermediate USDC leg), and exchange_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_id
  • usdc_account_id, brl_account_id
  • pix_key, pix_key_type, recipient_name
  • pix_end_to_end_id (populated by payout webhook)

The receipt shows the composed exchange_rate and the voucher_id as proof.