Payments, anti-fraud & saved cards

How a card charge is scored, how a card is saved, and where installment money is decided

Card payments in a C2C marketplace carry chargeback risk that lands on the platform. This is what sits between a buyer tapping "pay" and the money arriving.

Anti-fraud

Card charges are submitted with anti-fraud enabled, so the provider scores the transaction before authorizing it. A scored charge may not settle immediately — it comes back as analyzing, and a webhook resolves it later.

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

analyzing is system-only. No buyer, seller or admin can move an order into it, and webhook handlers accept paid or cancelled from either pending or analyzing.

Real customer data is what makes the score worth anything. The charge is built from the buyer's own profile and address rows, server-side — name, tax id where the market uses one, phone split into its parts, and the selected or default address. Never from the client, and never from placeholders.

Saved cards

The provider's vault holds the card. We hold only what is needed to show it back to the buyer:

ColumnPurpose
provider_card_idThe vault token to charge again
last4, brandDisplaying the card
holder_nameDisplaying the card
exp_month, exp_yearExpiry display

user_saved_cards is unique on (user_id, provider_card_id), protected by RLS so a user only ever sees their own, and cascades when the user is deleted. The column is provider_card_id, not a provider-specific name — the same table serves every rail.

Saving happens after a card charge succeeds (paid or analyzing), from the provider's own response, with onConflictDoNothing().

Reusing one: the client sends our row's id, never the vault token. The server checks the row belongs to the caller, resolves the provider's card id, and charges it. No CVV — that is what the vault is for.

Installments

One authority, both sides: getInstallmentChargeTotalCents (packages/commerce/payments/payment.installments.ts) decides what the card is charged, and getInstallmentOptions builds the list the picker shows. The picker cannot disagree with the charge because it is the same function.

The terms are a market config value, not a constant: installments: { max, interestFreeMax, monthlyRate }. BR today is 12×, all interest-free. A market with no installments block gets a single pay-in-full option, which is what the EU sees.

Buyer-paid interest, where it exists, is platform revenue — the seller's cut never changes.

Webhook idempotency

Providers redeliver. webhook_logs records what has been seen, and a handler that recognises an event returns success without repeating the work. That sits on top of the status checks in the machine itself, which reject a transition the order has already made — two independent reasons a duplicate is harmless.

Balance verification before a withdrawal

Before a payout leaves, the seller's internal balance is checked against the provider's own view of the recipient's balance. If the provider reports less than we think we hold, the withdrawal is blocked and the discrepancy is logged.

This is the safety net for balance drift — a missed webhook, a race — and it is a capability-guarded check: a rail that cannot report a balance renders as "not supported", never as zero. See reconciliation.

Where things live

ConcernWhere
The port every provider implementspackages/payments/core/payment-gateway.ts
Adapterspackages/payments/pagarme/, packages/payments/stripe/
Installment mathpackages/commerce/payments/payment.installments.ts
Saved cards tablepackages/db/schema/user-saved-cards.ts
Webhook logpackages/db/schema/webhook-logs.ts
Webhook handlingpackages/features/payments/services/process-normalized-webhook.service.ts
Checkout orchestrationpackages/features/checkout/services/run-checkout.service.ts

Adapters translate their provider's dialect at the boundary. Nothing above payment-gateway.ts names a provider.