diff --git a/app/api/payments.py b/app/api/payments.py index 963255e..6ccf1a1 100644 --- a/app/api/payments.py +++ b/app/api/payments.py @@ -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' diff --git a/app/mercadopago.py b/app/mercadopago.py index c0b722c..4b8c747 100644 --- a/app/mercadopago.py +++ b/app/mercadopago.py @@ -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}') diff --git a/app/runtime.py b/app/runtime.py index 83a1fe4..929dedd 100644 --- a/app/runtime.py +++ b/app/runtime.py @@ -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} diff --git a/compose.local.yaml b/compose.local.yaml index 0ed4b79..58d75d7 100644 --- a/compose.local.yaml +++ b/compose.local.yaml @@ -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: diff --git a/deploy/nginx.conf.template b/deploy/nginx.conf.template index 97c8a2d..eb33d83 100644 --- a/deploy/nginx.conf.template +++ b/deploy/nginx.conf.template @@ -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/ { diff --git a/docker-compose.yml b/docker-compose.yml index 5884480..a175560 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docs/LOCAL_SETUP.md b/docs/LOCAL_SETUP.md index 24137ac..b708968 100644 --- a/docs/LOCAL_SETUP.md +++ b/docs/LOCAL_SETUP.md @@ -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 diff --git a/infra/nginx.conf.template b/infra/nginx.conf.template index b49d5ec..10fc420 100644 --- a/infra/nginx.conf.template +++ b/infra/nginx.conf.template @@ -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; diff --git a/tests/test_mercadopago.py b/tests/test_mercadopago.py index 9cef52b..d23af0f 100644 --- a/tests/test_mercadopago.py +++ b/tests/test_mercadopago.py @@ -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__': diff --git a/web/checkout.js b/web/checkout.js index 80a1049..279b149 100644 --- a/web/checkout.js +++ b/web/checkout.js @@ -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' diff --git a/web/site-v2.css b/web/site-v2.css index 5933b00..2c957ac 100644 --- a/web/site-v2.css +++ b/web/site-v2.css @@ -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%}