Persistir pagamentos¶
Como guardar pagamentos eupago na tua base de dados de forma a não perder nada quando um processo crasha, um webhook chega duas vezes (ou antes do que esperavas), ou um cliente paga um Pay By Link com um método que não previste.
Esta recipe dá-te um esquema de referência (PostgreSQL, com o mapeamento DynamoDB no fim), a máquina de estados e as quatro funções que a conduzem. É agnóstica de framework — combina com a recipe de FastAPI, Django ou Flask para a camada HTTP.
A regra única: escreve antes de chamar¶
Insere a linha do pagamento antes de chamar a eupago, no mesmo processo, com commit:
Se tudo desmoronar entre o INSERT e a resposta — processo morto, rede
caída, deploy a meio do request — fica uma linha initiated. Esse órfão
é visível: um job de reconciliação encontra-o e decide (consultar a
eupago, ou marcar como abandonado). Sem write-ahead, um crash deixa-te
com um pagamento que a eupago conhece e tu não — invisível até um
cliente zangado ligar.
O write-ahead tem um segundo benefício menos óbvio: a linha existe sempre antes de a eupago saber do pagamento, logo um webhook nunca pode chegar antes de existir algo a que se agarrar. Uma classe inteira de race conditions desaparece por construção.
A máquina de estados¶
stateDiagram-v2
[*] --> initiated: INSERT (antes da chamada API)
initiated --> pending: eupago aceitou (201)
initiated --> submit_failed: erro API / validação
pending --> paid: webhook PAID ou confirmação síncrona
pending --> authorized: A&C authorize (cativação)
authorized --> paid: capture
authorized --> released: libertado / cativação expirou
pending --> declined
pending --> expired: webhook, ou LOCAL para Pay By Link
pending --> cancelled
pending --> error
paid --> refund_pending: reembolso pedido (Multibanco liquida assíncrono)
paid --> refunded: reembolso liquidado (ou síncrono MB WAY / Cartão)
refund_pending --> refunded: webhook de liquidação
initiated e submit_failed são estados locais — a eupago nunca os
vê. Tudo o resto mapeia para o enum PaymentStatus do SDK, que já
normaliza os códigos crus da eupago ("Paga", "Canceled",
"REFUNDED", …) por ti.
A confirmação converge para o mesmo sítio quer seja síncrona (a
resposta da API já diz pago — ex.: reembolsos MB WAY / Cartão devolvem
"Reembolsado" imediato) quer assíncrona (o webhook chega minutos
ou dias depois — Multibanco pode demorar até 30 dias). O teu código não
deve querer saber qual dos caminhos trouxe a verdade.
Esquema (PostgreSQL)¶
Três tabelas: estado corrente, histórico append-only e subscrições.
CREATE TABLE payments (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
-- A chave de correlação. Tu gera-la, a eupago devolve-a em todas
-- as respostas e webhooks ("identifier"). Faz match sempre por aqui.
order_id TEXT NOT NULL UNIQUE,
-- O que pediste vs o que o webhook disse que foi realmente usado.
-- Diferem no Pay By Link: pedes 'pay_by_link', o cliente escolhe
-- MB WAY, o webhook reporta 'mbway'.
method_requested TEXT NOT NULL,
method_paid TEXT,
-- Dinheiro é NUMERIC, nunca float. amount_refunded acumula:
-- reembolsos parciais somam-se, não viram um boolean.
amount NUMERIC(12,2) NOT NULL,
currency CHAR(3) NOT NULL DEFAULT 'EUR',
amount_refunded NUMERIC(12,2) NOT NULL DEFAULT 0,
-- Estado normalizado do ciclo de vida (CHECK, não ENUM nativo —
-- acrescentar um estado depois é um ALTER, não uma migração de tipo).
status TEXT NOT NULL DEFAULT 'initiated'
CHECK (status IN ('initiated', 'submit_failed', 'pending',
'authorized', 'released', 'paid', 'declined',
'expired', 'cancelled', 'error',
'refund_pending', 'refunded')),
-- O que a eupago disse literalmente, para debug ("Paga", "REFUNDED", …)
raw_status TEXT,
-- A eupago dá-te DOIS ids diferentes para a mesma transação:
-- provider_payment_id: "transactionID" da resposta do create
-- (string hex nos fluxos hospedados Cartão / Apple / Google Pay)
-- provider_trid: "trid" do webhook (numérico) — é ESTE que o
-- endpoint de reembolso quer.
-- Guarda os dois. Provado live: um create de Google Pay devolveu
-- 019ebcbb… e o webhook trouxe trid 29748670.
provider_payment_id TEXT,
provider_trid TEXT,
-- Multibanco
reference TEXT,
entity TEXT,
-- Fluxos hospedados (Pay By Link, form de cartão, wallet sheets)
payment_url TEXT,
-- A expiração do Pay By Link é SILENCIOSA: nenhum webhook dispara,
-- o link vira 404. Este prazo é teu — ver reconcile().
expires_at TIMESTAMPTZ,
-- Preenchido em cobranças recorrentes de uma subscrição
subscription_id BIGINT REFERENCES subscriptions (id),
initiated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
submitted_at TIMESTAMPTZ,
confirmed_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX payments_status_idx ON payments (status);
CREATE INDEX payments_trid_idx ON payments (provider_trid);
Repara no que não está aqui: nenhum dado de cartão (os forms
hospedados mantêm o PAN fora do teu servidor — que continue assim), e
nenhum PII do cliente. Se precisas de ligar um pagamento a uma pessoa,
acrescenta um foreign key customer_id para a tua própria tabela de
utilizadores em vez de copiar telefone/email.
CREATE TABLE payment_events (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
payment_id BIGINT NOT NULL REFERENCES payments (id),
type TEXT NOT NULL
CHECK (type IN ('initiated', 'create_requested', 'create_ok',
'create_failed', 'webhook_received',
'webhook_rejected', 'status_changed',
'refund_requested', 'refund_ok', 'refund_failed',
'expired_locally', 'reconciled')),
-- De onde veio a verdade
source TEXT NOT NULL
CHECK (source IN ('api', 'webhook', 'reconciliation',
'backoffice', 'local')),
-- O payload bruto — guardado SEMPRE já redigido (ver snippet).
-- É a tua trilha de auditoria e a tábua de salvação no debug.
raw JSONB,
-- Redelivery de webhook vira no-op em vez de duplicado.
dedup_hash TEXT UNIQUE,
-- A HMAC verificou? Payloads rejeitados também se guardam (type
-- 'webhook_rejected') mas NUNCA transitam estado.
signature_verified BOOLEAN,
-- Retenção RGPD: um job apaga payloads brutos depois desta data.
-- O estado financeiro em `payments` vive para sempre; os payloads
-- com PII não precisam.
purge_after TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX payment_events_payment_idx ON payment_events (payment_id);
CREATE TABLE subscriptions (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
-- A eupago dá às subscrições DOIS identificadores. Trocá-los é o
-- bug #1 das integrações de subscrição:
-- eupago_token: string hex devolvida pelo create_subscription —
-- o charge_subscription() recebe ESTE.
-- provider_subscription_id: o id inteiro (visível no URL do
-- backoffice) — get/edit/revoke recebem ESTE.
eupago_token TEXT UNIQUE,
provider_subscription_id INTEGER UNIQUE,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'active', 'revoked', 'error')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Tranca o histórico¶
payment_events é append-only. Garante isso com grants, não com
disciplina — o role da aplicação simplesmente não consegue reescrever
história:
GRANT SELECT, INSERT ON payment_events TO app_role;
-- sem UPDATE, sem DELETE
GRANT SELECT, INSERT, UPDATE ON payments TO app_role;
-- sem DELETE em payments também: pagamento cancelado é um status,
-- não uma linha em falta
(O job de purga de retenção corre com um role de manutenção separado que
pode fazer UPDATE payment_events SET raw = NULL depois de purge_after.)
As quatro funções¶
O ciclo de vida inteiro é conduzido por quatro funções pequenas. Mostradas com SQL puro — adapta ao teu driver/ORM.
1. begin_payment() — write-ahead¶
import uuid
from decimal import Decimal
def begin_payment(db, method: str, amount: Decimal) -> str:
# Não-adivinhável de propósito: o order_id viaja em URLs e webhooks.
# Ids sequenciais vazam o teu volume de vendas e convidam a enumeração.
order_id = f"ORD-{uuid.uuid4().hex[:16]}"
db.execute(
"""INSERT INTO payments (order_id, method_requested, amount)
VALUES (%s, %s, %s)""",
(order_id, method, amount),
)
db.execute(
"""INSERT INTO payment_events (payment_id, type, source)
SELECT id, 'initiated', 'local' FROM payments
WHERE order_id = %s""",
(order_id,),
)
db.commit() # ← commit ANTES de a eupago saber
return order_id
2. on_create_response() — registar o que a eupago disse¶
from eupago import EupagoClient, ValidationError, ApiError
from eupago.utils import redact_pii
def create_mbway_payment(db, client: EupagoClient, amount, phone):
order_id = begin_payment(db, "mbway", amount)
try:
result = client.mbway.create_payment(
order_id=order_id, amount=amount, phone_number=phone,
)
except (ValidationError, ApiError) as exc:
db.execute(
"""UPDATE payments SET status = 'submit_failed',
updated_at = now() WHERE order_id = %s""",
(order_id,),
)
_add_event(db, order_id, "create_failed", "api",
raw={"error": str(exc)})
db.commit()
raise
db.execute(
"""UPDATE payments SET
status = 'pending',
raw_status = %s,
provider_payment_id = %s,
payment_url = %s,
expires_at = %s, -- Pay By Link: NOT NULL aqui!
submitted_at = now(),
updated_at = now()
WHERE order_id = %s""",
(str(result.status), result.transaction_id, result.payment_url,
getattr(result, "expires_at", None), order_id),
)
_add_event(db, order_id, "create_ok", "api",
raw=redact_pii(result.raw_response))
db.commit()
return result
3. on_webhook() — o único sítio onde o estado vira paid¶
import hashlib
import json
from eupago.exceptions import WebhookSignatureError
from eupago.utils import redact_pii
def on_webhook(db, client, body: bytes, headers: dict) -> None:
dedup = hashlib.sha256(body).hexdigest()
try:
event = client.webhooks.parse(body=body, headers=headers)
except WebhookSignatureError:
# Quarentena: guarda a evidência, não muda NADA. Um webhook
# "Paid" sem assinatura é o ataque mais velho do e-commerce.
_add_quarantine_event(db, dedup, redact_pii(body.decode()))
db.commit()
return # responde 200 para um sender mal configurado parar de repetir
row = db.fetch_one(
"SELECT id, amount, currency, status FROM payments WHERE order_id = %s",
(event.order_id,),
)
if row is None:
# Possível para pagamentos criados fora desta app (ex.: reembolso
# feito no backoffice). Guarda como evento sem match para o job
# de reconciliação.
_add_quarantine_event(db, dedup, redact_pii(body.decode()),
note="no matching payment")
db.commit()
return
# Verifica o dinheiro antes de acreditar no estado. Um webhook que
# diz PAID com o valor/moeda errados não fulfilla a encomenda.
if (event.amount, event.currency) != (row.amount, row.currency):
_add_event_by_id(db, row.id, "webhook_rejected", "webhook",
raw=redact_pii(json.loads(body)),
dedup_hash=dedup, signature_verified=True)
db.commit()
return
inserted = _add_event_by_id(
db, row.id, "webhook_received", "webhook",
raw=redact_pii(json.loads(body)),
dedup_hash=dedup, signature_verified=True,
)
if not inserted: # UNIQUE(dedup_hash) bateu → redelivery
db.commit()
return # no-op idempotente
db.execute(
"""UPDATE payments SET
status = %s,
raw_status = %s,
method_paid = %s, -- Pay By Link: o método REAL
provider_trid = %s, -- o id que o reembolso vai querer
confirmed_at = CASE WHEN %s = 'paid'
THEN now() ELSE confirmed_at END,
updated_at = now()
WHERE id = %s""",
(event.status.value, event.raw_status, event.method,
event.transaction_id, event.status.value, row.id),
)
db.commit()
if event.status.value == "paid" and row.status != "paid":
fulfil_order(event.order_id) # exactamente uma vez, após commit
4. reconcile() — a rede de segurança¶
def reconcile(db, client) -> None:
# (a) Órfãos: initiated há > 15 min e nunca submetidos.
# O crash aconteceu entre o INSERT e a chamada API (ou a
# resposta perdeu-se).
for row in db.fetch_all(
"""SELECT order_id FROM payments
WHERE status = 'initiated'
AND initiated_at < now() - interval '15 minutes'"""
):
# A Management API tem GET /api/management/v1.02/transactions
# (OAuth) para procurar por identifier — o SDK ainda não a
# embrulha; ou chamas directamente com o teu Bearer, ou tomas o
# caminho simples: marca 'submit_failed' e deixa o webhook
# ressuscitar a linha se afinal a eupago conhecer a encomenda
# (a linha do write-ahead continua lá para receber o webhook).
... # update + event(type='reconciled', source='reconciliation')
# (b) Expiração silenciosa do Pay By Link: NUNCA virá webhook.
db.execute(
"""UPDATE payments SET status = 'expired', updated_at = now()
WHERE method_requested = 'pay_by_link'
AND status = 'pending'
AND expires_at < now()"""
)
... # um event(type='expired_locally', source='local') por linha
# (c) Purga de payloads brutos passada a retenção.
db.execute(
"UPDATE payment_events SET raw = NULL WHERE purge_after < now()"
)
db.commit()
Corre via cron / Celery beat de poucos em poucos minutos. É intencionalmente aborrecido — reconciliação aborrecida é a que funciona às 3 da manhã.
Os fluxos¶
Confirmação assíncrona (MB WAY, Multibanco, fluxos hospedados)¶
sequenceDiagram
participant App
participant BD
participant eupago
App->>BD: INSERT initiated (commit)
App->>eupago: create_payment(order_id, …)
eupago-->>App: 201 pending (transactionID, reference…)
App->>BD: UPDATE pending + event create_ok
Note over eupago: cliente paga (minutos… ou 30 dias)
eupago->>App: webhook (assinado HMAC)
App->>App: verifica assinatura + valor
App->>BD: event webhook_received (dedup) + UPDATE paid
App->>App: fulfil_order() — exactamente uma vez
Pay By Link — as duas excepções¶
sequenceDiagram
participant App
participant eupago
participant Cliente
App->>eupago: pay_by_link.create_payment(expires_at=…)
eupago-->>App: payment_url
App->>Cliente: envia o link
alt cliente paga
Cliente->>eupago: abre o link, escolhe MB WAY
eupago->>App: webhook method=MB:PT ← não "paybylink"!
Note over App: match por order_id, guarda method_paid='mbway'
else link expira
Note over eupago: NENHUM webhook. Link vira 404.
App->>App: reconcile() marca expired localmente (expires_at)
end
Reembolsos — uma transação própria¶
sequenceDiagram
participant App
participant eupago
App->>eupago: refunds.refund(transaction_id=provider_trid, amount)
alt MB WAY / Cartão
eupago-->>App: 201 "Reembolsado" (síncrono) → amount_refunded += x
else Multibanco
eupago-->>App: 201 "Pendente" → status refund_pending
eupago->>App: webhook RB:PT + originalTrid (horas depois)
Note over App: original_transaction_id linka de volta à<br/>linha do pagamento → refunded
end
O webhook de reembolso traz method = "RB:PT" e um originalTrid a
apontar para o pagamento reembolsado — WebhookEvent.original_transaction_id
no SDK. Faz match por aí, não por order_id (um reembolso iniciado no
backoffice pode trazer um identifier que nunca emitiste).
Authorize & capture¶
O authorize cativa o valor (status='authorized', guarda o montante
autorizado), o capture liquida por ≤ o autorizado → paid. Se nunca
capturares, a cativação cai → released. Guarda os dois montantes: o
capturado é o que acaba em amount.
Checklist de idiossincrasias¶
| # | Idiossincrasia | O que o esquema faz |
|---|---|---|
| 1 | O webhook do Pay By Link reporta o método que o cliente escolheu, nunca paybylink |
method_requested vs method_paid; match só por order_id |
| 2 | A expiração do Pay By Link é silenciosa — sem webhook, o link dá 404 | expires_at + reconcile() marca expired localmente |
| 3 | transactionID do create ≠ trid do webhook (fluxos hospedados; provado live: 019ebcbb… vs 29748670) |
Duas colunas; reembolsos usam provider_trid |
| 4 | Reembolsos são transações separadas (RB:PT + originalTrid); parciais acumulam |
amount_refunded acumulador numérico, nunca boolean |
| 5 | Webhooks podem ser re-entregues e correr contra as tuas escritas | dedup_hash UNIQUE no-op + write-ahead garante que a linha existe primeiro |
| 6 | Multibanco confirma em 1–30 dias; reembolso Multibanco liquida assíncrono (Pendente → webhook) |
pending é um estado longo perfeitamente saudável; refund_pending |
| 7 | Subscrições têm dois ids (eupagoToken hex vs subscriptionId inteiro) |
Ambas as colunas em subscriptions, cobranças linkam via FK |
| 8 | Os estados crus da eupago são inconsistentes ("Paga", "Canceled", "REFUNDED") |
O SDK normaliza em status; o literal fica em raw_status |
Notas de segurança¶
O esquema já codifica a maior parte da postura de segurança — esta é a versão curta; o guia de Segurança tem o raciocínio:
- Dados de cartão: nunca. Os forms hospedados mantêm o PAN fora do teu servidor (território SAQ A). Não faças proxy do form para "melhorar" o UX.
- Só webhooks com assinatura verificada mudam estado. HMAC falhada →
linha
webhook_rejectedem quarentena, sem transição. - O redirect do browser não é confirmação. Qualquer um navega para o
teu
success_url. Só o webhook assinado (ou poll autenticado) virastatusparapaid. - Verifica valor + moeda contra a linha guardada antes de fulfillar.
- Payloads brutos guardam-se redigidos (
eupago.utils.redact_pii) e expiram viapurge_after. O estado vive para sempre; o PII não. - O histórico é append-only por grant, não por convenção.
- O
order_idé não-enumerável (UUID) — viaja em URLs.
Mapeamento DynamoDB¶
O mesmo desenho cabe numa single-table:
| Item | PK | SK | Notas |
|---|---|---|---|
| Pagamento (estado corrente) | ORDER#{order_id} |
META |
Todas as colunas de payments como atributos |
| Evento | ORDER#{order_id} |
EVENT#{iso_ts}#{seq} |
Payload redigido; atributo ttl = purge_after (TTL nativo apaga) |
| Subscrição | SUB#{eupago_token} |
META |
Guarda os dois identificadores |
Global secondary indexes:
- GSI1
provider_trid→ encontrar o pagamento quando um webhook/reembolso só te dá um trid (reembolsos iniciados no backoffice). - GSI2
status(+ range keyinitiated_at) → os scans doreconcile(): órfãosinitiatede Pay By Linkspendingexpirados.
Diferenças que importam vs PostgreSQL:
- Idempotência usa conditional put no item do evento
(
attribute_not_exists(PK)com o dedup hash na chave) em vez de UNIQUE constraint. - Append-only é uma policy IAM que permite
PutItem/UpdateItemnos itemsMETAmas sóPutItemnosEVENT#. - Retenção é o atributo TTL nativo — define-o nos items de evento na escrita e o DynamoDB apaga-os de graça.
- Dinheiro: guarda montantes como strings (
"49.90") ou cêntimos inteiros — nunca o tipo float.Decimalfaz round-trip limpo com boto3.
Checklist antes de ir para produção¶
- [ ]
begin_paymentfaz commit antes da chamada à eupago - [ ] Handler de webhook: verificar → dedup → check de valor → transição → fulfil
- [ ]
reconcile()agendado (órfãos, expiry PBL, purga) - [ ] Grants: o role da app não pode UPDATE/DELETE
payment_events - [ ] Payloads brutos passam por
redact_pii()à entrada - [ ] O caminho de reembolso usa
provider_trid(do webhook), não o id da resposta do create - [ ]
fulfil_order()dispara exactamente uma vez (guarda na mudança de status)