Payments, anti-fraud & saved cards

Pagar.me anti-fraud integration, saved card vault, and the payment page redesign

Overview

Credit card payments in a C2C marketplace carry chargeback risk that falls on the platform. This documents the anti-fraud integration, saved card system, and payment flow redesign.

Anti-fraud

All credit card payments include antifraud: { enabled: true } in the Pagar.me order payload. This triggers Pagar.me's built-in anti-fraud analysis before authorizing the charge.

The analyzing order status

When anti-fraud is enabled, Pagar.me may hold the transaction for review instead of immediately approving it. The payment returns status: "pending" while the fraud check runs.

We map this to a new analyzing order status in our state machine:

pending → analyzing → paid (anti-fraud approved)
pending → analyzing → cancelled (anti-fraud rejected)
pending → paid (instant approval, or PIX)

The analyzing status is system-only — no user or seller can trigger it. Webhook handlers accept transitions from both pending and analyzing to paid or cancelled.

Real customer data

Previously, customer data sent to Pagar.me was hardcoded with dummy values (test CPF, fake phone). Now the checkout API builds the customer object from the buyer's profile in the database:

  • Name: from user_profiles.full_name
  • CPF: from user_profiles.cpf
  • Phone: from user_profiles.phone (parsed into area code + number)
  • Address: from shipping_addresses (selected or default)

This is critical for anti-fraud accuracy — Pagar.me's fraud engine uses this data to score transactions.

Saved cards

Architecture

Pagar.me handles PCI compliance via their card vault. We store only non-sensitive metadata:

ColumnExamplePurpose
pagarme_card_idcard_abc123Vault token for reuse
last48990Display in UI
brandvisaBrand icon
holder_nameJoão SilvaDisplay
exp_month / exp_year12 / 30Expiry display

The user_saved_cards table has:

  • A unique constraint on (user_id, pagarme_card_id) to prevent duplicates
  • RLS policies so users can only access their own cards
  • Cascade delete when user is removed

Save flow

After a successful credit card payment (status paid or analyzing), the API extracts card metadata from charges[0].last_transaction.card and inserts it with onConflictDoNothing().

Reuse flow

When a saved card is selected at checkout:

  1. Client sends savedCardId (our UUID) instead of cardToken
  2. Server verifies the card belongs to the authenticated user
  3. Server resolves pagarme_card_id and sends it as card_id (not card_token) to Pagar.me

No CVV is needed for saved card transactions — Pagar.me handles this with their vault.

Payment page redesign

The payment step uses a multi-phase state machine within the component:

method-select → (PIX/balance → review)
             → (saved card → installments → review)
             → (new card → card form → installments → review)

Phase 1: Method selection

An Enjoei-inspired list showing all payment options as tappable rows:

  • Account balance (with current amount, disabled if insufficient)
  • PIX (instant payment)
  • Each saved card (brand icon + last4 + installment info)
  • "New credit card" option

Phase 2: Card form (new cards only)

Standard card fields plus a CPF field for the cardholder. Test defaults (card number, CVV, etc.) are gated behind NEXT_PUBLIC_PAGARME_ENV=TEST — production shows empty fields.

Phase 3: Installments picker

Shows 1x through 12x options:

  • First N installments are interest-free (configurable, default 3)
  • Beyond that, simple interest is applied at a configurable monthly rate
  • Each row shows the per-installment amount and total

Installment calculation

// Interest-free: simple division
installmentAmount = Math.floor(totalCents / count)
// With interest: total * (1 + rate * count)
totalWithInterest = Math.round(totalCents * (1 + monthlyRate * count))

The same calculateInstallments() utility is used client-side for the picker and can be reused server-side for validation.

Webhook idempotency

A processed_webhooks table stores webhook event IDs. Each handler checks this table before processing:

const existing = await db.query.processedWebhooks.findFirst({
  where: { id: webhookEventId },
});
if (existing) return { success: true, message: "Already processed" };

This complements the existing status-based checks (order.status !== "pending") — belt and suspenders against Pagar.me delivering the same webhook twice in quick succession.

Withdrawal balance verification

Before processing a seller withdrawal, we verify the internal balance against Pagar.me's real recipient balance API:

GET /core/v5/balance/{sellerId}

If Pagar.me reports less available than our internal tracking, the withdrawal is blocked and an error is logged. This acts as a safety net against balance drift (missed webhooks, race conditions).

Key files

FilePurpose
packages/db/schema/enums.tsanalyzing order status
packages/db/schema/user-saved-cards.tsSaved cards table
packages/db/schema/processed-webhooks.tsWebhook idempotency
packages/features/checkout/utils/pagarme.tsPagar.me order builder + anti-fraud
packages/features/checkout/utils/calculate-installments.tsInstallment math
packages/features/checkout/views/2-payment/Payment step UI
packages/features/checkout/hooks/use-place-order.tsOrder placement hook
packages/features/checkout/queries/get-user-saved-cards.tsSaved cards query
packages/features/pagarme/actions/webhook-handlers.action.tsWebhook handlers
packages/features/payments/queries/fetch-recipient-balance.tsBalance API helper
apps/web/app/api/v1/checkout/route.tsCheckout API