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
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:
@@ -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'
|
||||
|
||||
@@ -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}')
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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/ {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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__':
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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%}
|
||||
|
||||
Reference in New Issue
Block a user