feat: offer credit card, debit card and PIX, with the bank's 3-D Secure step
All checks were successful
Build and deploy / Validate source (push) Successful in 5s
Build and deploy / Integration suite on a real stack (push) Successful in 2m3s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images (push) Successful in 1m31s

The payment page lists credit card (preselected), debit card and PIX. Each
card option limits Mercado Pago's form to its kind; debit is paid at once.
Card payments ask for 3-D Secure when the issuer requires it, and a
challenge opens the bank's page in a frame, which needs
PAYMENT_CHALLENGE_SOURCES=https: (frames and form posts only). A card left
waiting for that confirmation stops blocking a new attempt after ten
minutes, and a refusal reported by the notification returns the customer to
the payment choice. Written from the documentation; not yet run with a real
debit card.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Cauê Faleiros
2026-09-28 13:24:06 -03:00
parent 7b6675d4d4
commit eae3dad306
11 changed files with 86 additions and 12 deletions

View File

@@ -21,6 +21,8 @@ router = APIRouter()
# Generous: a provider legitimately retries, and a signature check is cheap.
# This exists so an unsigned flood cannot keep the database busy.
WEBHOOK_LIMIT = 600
# How long a card waiting for the bank's confirmation holds off a new attempt.
CHALLENGE_MINUTES = 10
@router.post('/api/payments/webhook')
@@ -73,8 +75,14 @@ def intent(body: PaymentIntent, session_id=Depends(owner)):
raise HTTPException(409, 'Quote is already paid')
# Never a second charge: an approved payment is waiting for its
# notification to become the order, and a card in review may still be.
# A card waiting for the bank's confirmation (3-D Secure) blocks only
# for CHALLENGE_MINUTES: a customer who gave up on it must still be able
# to pay, and an unanswered challenge is not charged.
if c.execute('''SELECT 1 FROM dtf_local.payment_intents WHERE quote_id=%s
AND (status='approved' OR (method='card' AND status='pending'))''', (body.quote_id,)).fetchone():
AND (status='approved' OR (method='card' AND status='pending'
AND NOT (jsonb_typeof(response->'challenge')='object'
AND created_at < now() - make_interval(mins => %s))))''',
(body.quote_id, CHALLENGE_MINUTES)).fetchone():
raise HTTPException(409, 'A payment for this quote is already approved or in review')
if body.method.type == 'pix':
existing = c.execute('''SELECT * FROM dtf_local.payment_intents WHERE quote_id=%s AND method='pix'

View File

@@ -73,8 +73,11 @@ class MercadoPagoPayment:
if method['type'] == 'pix':
body['payment_method_id'] = 'pix'
elif method['type'] == 'card':
# The issuer decides whether the cardholder must confirm in the
# bank's app or page (3-D Secure); debit cards usually must.
body.update(token=method['token'], payment_method_id=method['payment_method_id'],
installments=int(method.get('installments', 1)))
installments=int(method.get('installments', 1)),
three_d_secure_mode='optional')
if method.get('issuer_id'):
body['issuer_id'] = method['issuer_id']
else:
@@ -88,13 +91,20 @@ class MercadoPagoPayment:
response.raise_for_status()
payment = response.json()
transaction = (payment.get('point_of_interaction') or {}).get('transaction_data') or {}
# A payment waiting for 3-D Secure carries the bank's challenge page,
# which the Site shows in a frame by posting `creq` to that address.
three_ds = payment.get('three_ds_info') or {}
challenge = ({'url': three_ds['external_resource_url'], 'creq': three_ds['creq']}
if payment.get('status_detail') == 'pending_challenge'
and three_ds.get('external_resource_url') and three_ds.get('creq') else None)
return {'provider': self.name, 'id': str(payment['id']),
'status': STATUSES.get(payment.get('status'), 'pending'),
'status_detail': payment.get('status_detail'),
'total_cents': total_cents,
'pix_qr_code': transaction.get('qr_code'),
'pix_qr_code_base64': transaction.get('qr_code_base64'),
'ticket_url': transaction.get('ticket_url')}
'ticket_url': transaction.get('ticket_url'),
'challenge': challenge}
def lookup(self, payment_id: str) -> dict:
response = self.http.get(f'/v1/payments/{payment_id}')

View File

@@ -72,12 +72,17 @@ def upload_row(c, upload_id, session_id, lock=False):
def quote_view(c, row):
from .quote_review import review_reason # it imports this module
order = c.execute('SELECT id,number,state FROM dtf_local.orders WHERE quote_id=%s', (row['id'],)).fetchone()
# The latest payment attempt, so the payment page can tell a refused card
# (the notification updates it) from one still waiting.
attempt = c.execute('''SELECT method,status,response->>'status_detail' AS status_detail
FROM dtf_local.payment_intents WHERE quote_id=%s ORDER BY created_at DESC LIMIT 1''', (row['id'],)).fetchone()
expired = row['approved_at'] and row['approved_at'] < datetime.now(timezone.utc)-timedelta(hours=24)
return {'id': row['id'], 'created_at': row['created_at'],
'draft': row['draft'], 'approved': row['approved'],
'status': 'paid' if order else 'expired' if expired else 'approved' if row['approved'] else 'pending_review',
'auto_approved': row.get('reviewed_by') == 'auto',
'review_reason': None if row['approved'] else review_reason(row['draft']),
'payment': attempt,
'order': order}

View File

@@ -198,6 +198,7 @@ services:
S3_PUBLIC_ENDPOINT: ${S3_PUBLIC_ENDPOINT:-http://localhost:${STORAGE_PORT:-9000}}
# Empty unless testing Mercado Pago's card form; see docs/LOCAL_SETUP.md.
PAYMENT_CSP_SOURCES: ${PAYMENT_CSP_SOURCES:-}
PAYMENT_CHALLENGE_SOURCES: ${PAYMENT_CHALLENGE_SOURCES:-}
ports:
# Published ports are host-wide even bound to loopback, so on a shared
# machine any of them can collide with something unrelated. CI overrides
@@ -222,6 +223,7 @@ services:
WEB_INDEX: kanban.html
S3_PUBLIC_ENDPOINT: ${S3_PUBLIC_ENDPOINT:-http://localhost:${STORAGE_PORT:-9000}}
PAYMENT_CSP_SOURCES: ""
PAYMENT_CHALLENGE_SOURCES: ""
ports: ["127.0.0.1:${KANBAN_PORT:-8081}:80"]
networks: [local, edge]
depends_on:

View File

@@ -27,7 +27,7 @@ server {
add_header Referrer-Policy no-referrer always;
add_header X-Frame-Options DENY always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' @SCRIPT_HASHES@ ${PAYMENT_CSP_SOURCES}; script-src-attr 'none'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob: https://cdn.vnda.com.br ${PAYMENT_CSP_SOURCES}; connect-src 'self' ${S3_PUBLIC_ENDPOINT} https://cdn.vnda.com.br ${PAYMENT_CSP_SOURCES}; frame-src 'self' ${PAYMENT_CSP_SOURCES}; worker-src 'self' blob:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' @SCRIPT_HASHES@ ${PAYMENT_CSP_SOURCES}; script-src-attr 'none'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob: https://cdn.vnda.com.br ${PAYMENT_CSP_SOURCES}; connect-src 'self' ${S3_PUBLIC_ENDPOINT} https://cdn.vnda.com.br ${PAYMENT_CSP_SOURCES}; frame-src 'self' ${PAYMENT_CSP_SOURCES} ${PAYMENT_CHALLENGE_SOURCES}; worker-src 'self' blob:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self' ${PAYMENT_CHALLENGE_SOURCES}" always;
location = /health { access_log off; return 200 'ok'; }
location /api/ {

View File

@@ -175,6 +175,10 @@ services:
# Mercado Pago's card form loads from these origins; empty keeps the
# Site at script-src 'self'. Set together with the Mercado Pago adapter.
PAYMENT_CSP_SOURCES: ${PAYMENT_CSP_SOURCES:-}
# The bank's confirmation page for debit and other 3-D Secure cards is
# on the issuer's own domain, so it cannot be listed: "https:" lets
# frames and form posts reach it (never scripts). Empty turns it off.
PAYMENT_CHALLENGE_SOURCES: ${PAYMENT_CHALLENGE_SOURCES:-}
networks: [backend]
ports:
- target: 8080
@@ -198,6 +202,7 @@ services:
PUBLIC_HOST: ${KANBAN_DOMAIN:?set KANBAN_DOMAIN}
S3_PUBLIC_ENDPOINT: ${R2_ENDPOINT:?set R2_ENDPOINT}
PAYMENT_CSP_SOURCES: ""
PAYMENT_CHALLENGE_SOURCES: ""
networks: [backend]
ports:
- target: 8080

View File

@@ -328,8 +328,14 @@ PAYMENT_CSP_SOURCES=https://sdk.mercadopago.com https://*.mercadopago.com https:
```
`PAYMENT_CSP_SOURCES` is empty by default, which keeps the Site at
`script-src 'self'`. Once a payment for a quote is approved, or a card payment
is in review, the API refuses any further attempt for that quote. The order is created only by the signed
`script-src 'self'`. The payment page offers credit card (the default), debit
card and PIX. Card payments ask for 3-D Secure when the issuer requires it,
which debit cards usually do: the bank's confirmation page opens in a frame on
the issuer's own domain, so it needs `PAYMENT_CHALLENGE_SOURCES=https:` (frames
and form posts only, never scripts; empty by default). Once a payment for a
quote is approved, or a card payment is in review, the API refuses any further
attempt for that quote; a card left waiting for the bank's confirmation stops
blocking after ten minutes. The order is created only by the signed
notification, after the payment is fetched from the Mercado Pago API and its
BRL amount matches the approved total. Mercado Pago must be able to reach the
webhook, so a local run needs a public HTTPS tunnel to the Site port. A paid

View File

@@ -9,7 +9,7 @@ server {
add_header Referrer-Policy no-referrer always;
add_header X-Frame-Options DENY always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' @SCRIPT_HASHES@ ${PAYMENT_CSP_SOURCES}; script-src-attr 'none'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob: https://cdn.vnda.com.br ${PAYMENT_CSP_SOURCES}; connect-src 'self' ${S3_PUBLIC_ENDPOINT} https://cdn.vnda.com.br ${PAYMENT_CSP_SOURCES}; frame-src 'self' ${PAYMENT_CSP_SOURCES}; worker-src 'self' blob:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' @SCRIPT_HASHES@ ${PAYMENT_CSP_SOURCES}; script-src-attr 'none'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob: https://cdn.vnda.com.br ${PAYMENT_CSP_SOURCES}; connect-src 'self' ${S3_PUBLIC_ENDPOINT} https://cdn.vnda.com.br ${PAYMENT_CSP_SOURCES}; frame-src 'self' ${PAYMENT_CSP_SOURCES} ${PAYMENT_CHALLENGE_SOURCES}; worker-src 'self' blob:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self' ${PAYMENT_CHALLENGE_SOURCES}" always;
location = /health { access_log off; return 200 'ok'; }
location /api/ {
limit_req zone=api_limit burst=100 nodelay;

View File

@@ -129,6 +129,20 @@ class MercadoPagoTests(unittest.TestCase):
{'type': 'card', 'token': 'tok_def', 'payment_method_id': 'visa'})
self.assertNotEqual(self.requests[-1].headers['x-idempotency-key'], first_key)
self.assertTrue(first_key.startswith('dtf-quote-q-card-'))
self.assertEqual(body['three_d_secure_mode'], 'optional')
self.assertIsNone(self.mp.create('q', 1000, {'mail': 'a@example.test', 'cnpj': '11222333000181'},
{'type': 'card', 'token': 'tok_ghi', 'payment_method_id': 'visa'})['challenge'])
def test_a_card_the_bank_must_confirm_returns_its_challenge(self):
def handler(request):
return httpx.Response(201, json={'id': 777, 'status': 'pending', 'status_detail': 'pending_challenge',
'three_ds_info': {'external_resource_url': 'https://acs.bank.example/challenge',
'creq': 'eyJjcmVxIjoiMSJ9'}})
mp = MercadoPagoPayment('TEST-token', SECRET, transport=httpx.MockTransport(handler), clock=lambda: NOW)
created = mp.create('q', 1000, {'mail': 'a@example.test', 'cnpj': '11222333000181'},
{'type': 'card', 'token': 'tok_debit', 'payment_method_id': 'debvisa'})
self.assertEqual((created['status'], created['status_detail']), ('pending', 'pending_challenge'))
self.assertEqual(created['challenge'], {'url': 'https://acs.bank.example/challenge', 'creq': 'eyJjcmVxIjoiMSJ9'})
if __name__ == '__main__':

View File

@@ -225,10 +225,29 @@
pedido=[]; itemAtual=null; limpaPaineis();
await window.dtfClearCart?.();
await refresh();
} else if (quote.payment?.status==='rejected') {
// Refused after the bank's confirmation or the provider's review.
clearInterval(waitTimer);
await refresh();
message('Pagamento recusado. Tente de novo ou escolha outra forma de pagamento.');
}
} catch(_) {}
},3000);
}
// 3-D Secure: the bank's own page, opened by posting its request in a frame.
function showChallenge(challenge, into) {
unmountCard();
const frame=document.createElement('iframe');
frame.name='confirmacaoBanco'; frame.title='Confirmação do banco'; frame.className='desafio';
const form=document.createElement('form');
form.method='POST'; form.action=challenge.url; form.target=frame.name;
const input=document.createElement('input');
input.type='hidden'; input.name='creq'; input.value=challenge.creq;
form.append(input);
into.replaceChildren(node('p','Confirme o pagamento na página do seu banco, abaixo.','pagNota'),frame,form);
form.submit();
form.remove();
}
function confirmado(quote) {
clearInterval(waitTimer);
const box=node('div',null,'confirmado');
@@ -242,7 +261,7 @@
// Paying: card (the default, paid on this page) or PIX (paid on its own page).
async function escolhaPagamento(quote, version) {
const session=await ready;
const opcoes=[...(session.payment_public_key?[['card','Cartão de crédito']]:[]),['pix','PIX']];
const opcoes=[...(session.payment_public_key?[['credito','Cartão de crédito'],['debito','Cartão de débito']]:[]),['pix','PIX']];
let escolhido=opcoes[0][0];
const grupo=node('div',null,'metodos');
grupo.setAttribute('role','radiogroup'); grupo.setAttribute('aria-label','Forma de pagamento');
@@ -256,7 +275,7 @@
async function pinta() {
radios.forEach(el=>el.setAttribute('aria-checked',String(el.dataset.metodo===escolhido)));
unmountCard(); area.replaceChildren(); message('');
if (escolhido==='card') { await showCard(quote.approved, area, version); return; }
if (escolhido!=='pix') { await showCard(quote.approved, area, version, escolhido); return; }
area.append(node('p','Ao pagar, mostramos o QR code e o código copia e cola. O pedido vai para a produção assim que o PIX é confirmado.','pagNota'));
button('Pagar',async event=>{
if (!cartOk()) { await refresh(); return; }
@@ -325,7 +344,7 @@
function unmountCard() {
if (cardBrick) { try { cardBrick.unmount(); } catch(_) {} cardBrick=null; }
}
async function showCard(approved, into, version) {
async function showCard(approved, into, version, tipo) {
const holder=node('div');
holder.id='cardPaymentBrick';
into.append(holder);
@@ -338,7 +357,10 @@
const mp=new window.MercadoPago(session.payment_public_key,{locale:'pt-BR'});
cardBrick=await mp.bricks().create('cardPayment','cardPaymentBrick',{
initialization:{amount:approved.total_cents/100, payer:{email:approved.customer.mail}},
customization:{paymentMethods:{maxInstallments:12},
// Each option takes only its kind of card; debit is paid at once.
customization:{paymentMethods:tipo==='debito'
? {maxInstallments:1,types:{excluded:['credit_card']}}
: {maxInstallments:12,types:{excluded:['debit_card']}},
visual:{style:{customVariables:{baseColor:'#FFA81A',buttonTextColor:'#03060B'}}}},
callbacks:{
onReady:()=>{},
@@ -350,6 +372,7 @@
type:'card', token:data.token, payment_method_id:data.payment_method_id,
installments:Number(data.installments)||1,
issuer_id:data.issuer_id==null?null:String(data.issuer_id)}});
if (result.challenge) { showChallenge(result.challenge, into); waitForOrder(); return; }
if (result.status==='approved' || result.status==='pending') {
unmountCard();
into.replaceChildren(node('p',result.status==='approved'

View File

@@ -239,7 +239,7 @@ html:is([data-rota="pagamento"],[data-rota="pix"]) .pagGrid{display:grid;grid-te
.pagResumo .l span{color:var(--texto2)}
.pagResumo .tot{display:flex;justify-content:space-between;align-items:baseline;border-top:1px solid #EFEFEF;margin-top:10px;padding-top:12px}
.pagResumo .tot b{font-family:"Inter",sans-serif;font-weight:900;font-size:24px}
.metodos{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin-bottom:20px}
.metodos{display:grid;grid-template-columns:1fr;gap:10px;max-width:420px;margin-bottom:20px}
.metodo{height:56px;display:flex;align-items:center;gap:12px;padding:0 16px;border:2px solid var(--linha);border-radius:var(--raio2);background:#fff;color:var(--preto);font-family:"Inter",sans-serif;font-weight:700;font-size:15px;text-align:left;cursor:pointer}
.metodo::before{content:"";width:18px;height:18px;flex-shrink:0;border-radius:50%;border:2px solid #B5B8BD;box-sizing:border-box}
.metodo[aria-checked="true"]{border-color:var(--laranja);background:var(--aviso-fundo)}
@@ -248,6 +248,7 @@ html:is([data-rota="pagamento"],[data-rota="pix"]) .pagGrid{display:grid;grid-te
.pagBtn:hover:not(:disabled){background:#FFB53D}
.pagBtn:disabled{background:#EDEDED;color:#8A8D92;cursor:default}
.pagBtn.sec{background:#fff;border:1px solid #D6D6D6}
.desafio{display:block;width:100%;max-width:520px;height:560px;border:1px solid var(--linha);border-radius:12px;background:#fff}
.pixBox{display:flex;flex-direction:column;align-items:flex-start;gap:12px}
.pixBox img{border:1px solid var(--linha);border-radius:12px;padding:8px;background:#fff}
.pixBox .codigo{display:flex;gap:10px;width:100%}