fix: ask 3-D Secure of debit cards only, and send the cardholder as the card payer
All checks were successful
Build and deploy / Validate source (push) Successful in 4s
Build and deploy / Integration suite on a real stack (push) Successful in 2m13s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images (push) Successful in 1m9s
All checks were successful
Build and deploy / Validate source (push) Successful in 4s
Build and deploy / Integration suite on a real stack (push) Successful in 2m13s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images (push) Successful in 1m9s
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 <noreply@anthropic.com>
This commit is contained in:
@@ -192,6 +192,10 @@ class PaymentMethod(StrictModel):
|
|||||||
payment_method_id: str | None = Field(default=None, max_length=40, pattern=r'^[a-z_]+$')
|
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)
|
installments: int = Field(default=1, ge=1, le=12, strict=True)
|
||||||
issuer_id: str | None = Field(default=None, max_length=40)
|
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')
|
@model_validator(mode='after')
|
||||||
def card_needs_token(self):
|
def card_needs_token(self):
|
||||||
|
|||||||
@@ -80,11 +80,15 @@ class MercadoPagoPayment:
|
|||||||
expires_at = (datetime.now(BRASILIA) + timedelta(minutes=PIX_MINUTES)).isoformat(timespec='milliseconds')
|
expires_at = (datetime.now(BRASILIA) + timedelta(minutes=PIX_MINUTES)).isoformat(timespec='milliseconds')
|
||||||
body['date_of_expiration'] = expires_at
|
body['date_of_expiration'] = expires_at
|
||||||
elif method['type'] == 'card':
|
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'],
|
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')
|
# 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.
|
# No issuer_id: Mercado Pago takes the issuer from the card number.
|
||||||
# The form's suggestion was refused for its own test cards
|
# The form's suggestion was refused for its own test cards
|
||||||
# (10111, "the issuer does not have the BIN configured").
|
# (10111, "the issuer does not have the BIN configured").
|
||||||
|
|||||||
@@ -139,7 +139,15 @@ class MercadoPagoTests(unittest.TestCase):
|
|||||||
{'type': 'card', 'token': 'tok_def', 'payment_method_id': 'visa'})
|
{'type': 'card', 'token': 'tok_def', 'payment_method_id': 'visa'})
|
||||||
self.assertNotEqual(self.requests[-1].headers['x-idempotency-key'], first_key)
|
self.assertNotEqual(self.requests[-1].headers['x-idempotency-key'], first_key)
|
||||||
self.assertTrue(first_key.startswith('dtf-quote-q-card-'))
|
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'},
|
self.mp.create('q', 1000, {'mail': 'a@example.test', 'cnpj': '11222333000181'},
|
||||||
{'type': 'card', 'token': 'tok_iss', 'payment_method_id': 'master', 'issuer_id': '24'})
|
{'type': 'card', 'token': 'tok_iss', 'payment_method_id': 'master', 'issuer_id': '24'})
|
||||||
self.assertNotIn('issuer_id', json.loads(self.requests[-1].content))
|
self.assertNotIn('issuer_id', json.loads(self.requests[-1].content))
|
||||||
|
|||||||
@@ -427,6 +427,13 @@
|
|||||||
});
|
});
|
||||||
return sdkLoading;
|
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;
|
let cardBrick=null;
|
||||||
function unmountCard() {
|
function unmountCard() {
|
||||||
if (cardBrick) { try { cardBrick.unmount(); } catch(_) {} cardBrick=null; }
|
if (cardBrick) { try { cardBrick.unmount(); } catch(_) {} cardBrick=null; }
|
||||||
@@ -463,7 +470,8 @@
|
|||||||
result=await api('/payments/intent',{quote_id:draftId,method:{
|
result=await api('/payments/intent',{quote_id:draftId,method:{
|
||||||
type:'card', token:data.token, payment_method_id:data.payment_method_id,
|
type:'card', token:data.token, payment_method_id:data.payment_method_id,
|
||||||
installments:Number(data.installments)||1,
|
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) {
|
} catch(error) {
|
||||||
// Rejecting stops the form's spinner; the reason is shown above it.
|
// 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.');
|
message(error.message || 'Não foi possível concluir o pagamento. Tente de novo.');
|
||||||
|
|||||||
Reference in New Issue
Block a user