From f5fed013abcbcec340832cdc2bed4dbd70636e80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Tue, 29 Sep 2026 13:57:19 -0300 Subject: [PATCH] fix: ask 3-D Secure of debit cards only, and send the cardholder as the card payer A credit card payment with the test credentials was refused with 10113 ("the payment method is excluded by a rule"). Every card was sent with three_d_secure_mode, which only debit needs, and with the order's CNPJ as payer instead of the cardholder's document from the card form. Debit methods keep 3-D Secure, and the card form's document is the payer. Co-Authored-By: Claude Opus 5.5 --- app/core/models.py | 4 ++++ app/mercadopago.py | 12 ++++++++---- tests/test_mercadopago.py | 10 +++++++++- web/checkout.js | 10 +++++++++- 4 files changed, 30 insertions(+), 6 deletions(-) diff --git a/app/core/models.py b/app/core/models.py index 7aa2228..b9d0507 100644 --- a/app/core/models.py +++ b/app/core/models.py @@ -192,6 +192,10 @@ class PaymentMethod(StrictModel): payment_method_id: str | None = Field(default=None, max_length=40, pattern=r'^[a-z_]+$') installments: int = Field(default=1, ge=1, le=12, strict=True) issuer_id: str | None = Field(default=None, max_length=40) + # The cardholder's document from the card form: for a card, the payer is + # the cardholder, not necessarily the company on the invoice. + payer_document_type: Literal['CPF', 'CNPJ'] | None = None + payer_document: str | None = Field(default=None, pattern=r'^[0-9]{11,14}$') @model_validator(mode='after') def card_needs_token(self): diff --git a/app/mercadopago.py b/app/mercadopago.py index 0bfb84a..3dd6dc6 100644 --- a/app/mercadopago.py +++ b/app/mercadopago.py @@ -80,11 +80,15 @@ class MercadoPagoPayment: expires_at = (datetime.now(BRASILIA) + timedelta(minutes=PIX_MINUTES)).isoformat(timespec='milliseconds') body['date_of_expiration'] = expires_at 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)), - three_d_secure_mode='optional') + installments=int(method.get('installments', 1))) + # 3-D Secure (the bank's confirmation) only for debit, which needs + # it; asking it of every card was refused as a rule (10113). + if method['payment_method_id'].startswith('deb'): + body['three_d_secure_mode'] = 'optional' + if method.get('payer_document_type') and method.get('payer_document'): + body['payer']['identification'] = {'type': method['payer_document_type'], + 'number': method['payer_document']} # No issuer_id: Mercado Pago takes the issuer from the card number. # The form's suggestion was refused for its own test cards # (10111, "the issuer does not have the BIN configured"). diff --git a/tests/test_mercadopago.py b/tests/test_mercadopago.py index bb4854f..90f9ebf 100644 --- a/tests/test_mercadopago.py +++ b/tests/test_mercadopago.py @@ -139,7 +139,15 @@ 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') + # 3-D Secure is asked of debit only; the cardholder is the payer. + self.assertNotIn('three_d_secure_mode', body) + self.mp.create('q', 1000, {'mail': 'a@example.test', 'cnpj': '11222333000181'}, + {'type': 'card', 'token': 'tok_deb', 'payment_method_id': 'debmaster', + 'payer_document_type': 'CPF', 'payer_document': '12345678909'}) + debit = json.loads(self.requests[-1].content) + self.assertEqual(debit['three_d_secure_mode'], 'optional') + self.assertEqual(debit['payer']['identification'], {'type': 'CPF', 'number': '12345678909'}) + self.assertEqual(body['payer']['identification'], {'type': 'CNPJ', 'number': '11222333000181'}) self.mp.create('q', 1000, {'mail': 'a@example.test', 'cnpj': '11222333000181'}, {'type': 'card', 'token': 'tok_iss', 'payment_method_id': 'master', 'issuer_id': '24'}) self.assertNotIn('issuer_id', json.loads(self.requests[-1].content)) diff --git a/web/checkout.js b/web/checkout.js index abcdbd6..cb2d335 100644 --- a/web/checkout.js +++ b/web/checkout.js @@ -427,6 +427,13 @@ }); return sdkLoading; } + // The cardholder's document as the card form collected it. + function cardholder(id) { + const number=String(id?.number||'').replace(/\D/g,''); + const type=String(id?.type||'').toUpperCase(); + return ['CPF','CNPJ'].includes(type) && /^[0-9]{11,14}$/.test(number) + ? {payer_document_type:type, payer_document:number} : {}; + } let cardBrick=null; function unmountCard() { if (cardBrick) { try { cardBrick.unmount(); } catch(_) {} cardBrick=null; } @@ -463,7 +470,8 @@ result=await api('/payments/intent',{quote_id:draftId,method:{ 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)}}); + issuer_id:data.issuer_id==null?null:String(data.issuer_id), + ...cardholder(data.payer?.identification)}}); } catch(error) { // Rejecting stops the form's spinner; the reason is shown above it. message(error.message || 'Não foi possível concluir o pagamento. Tente de novo.');