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.
|
# Generous: a provider legitimately retries, and a signature check is cheap.
|
||||||
# This exists so an unsigned flood cannot keep the database busy.
|
# This exists so an unsigned flood cannot keep the database busy.
|
||||||
WEBHOOK_LIMIT = 600
|
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')
|
@router.post('/api/payments/webhook')
|
||||||
@@ -73,8 +75,14 @@ def intent(body: PaymentIntent, session_id=Depends(owner)):
|
|||||||
raise HTTPException(409, 'Quote is already paid')
|
raise HTTPException(409, 'Quote is already paid')
|
||||||
# Never a second charge: an approved payment is waiting for its
|
# Never a second charge: an approved payment is waiting for its
|
||||||
# notification to become the order, and a card in review may still be.
|
# 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
|
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')
|
raise HTTPException(409, 'A payment for this quote is already approved or in review')
|
||||||
if body.method.type == 'pix':
|
if body.method.type == 'pix':
|
||||||
existing = c.execute('''SELECT * FROM dtf_local.payment_intents WHERE quote_id=%s AND method='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':
|
if method['type'] == 'pix':
|
||||||
body['payment_method_id'] = 'pix'
|
body['payment_method_id'] = 'pix'
|
||||||
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')
|
||||||
if method.get('issuer_id'):
|
if method.get('issuer_id'):
|
||||||
body['issuer_id'] = method['issuer_id']
|
body['issuer_id'] = method['issuer_id']
|
||||||
else:
|
else:
|
||||||
@@ -88,13 +91,20 @@ class MercadoPagoPayment:
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
payment = response.json()
|
payment = response.json()
|
||||||
transaction = (payment.get('point_of_interaction') or {}).get('transaction_data') or {}
|
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']),
|
return {'provider': self.name, 'id': str(payment['id']),
|
||||||
'status': STATUSES.get(payment.get('status'), 'pending'),
|
'status': STATUSES.get(payment.get('status'), 'pending'),
|
||||||
'status_detail': payment.get('status_detail'),
|
'status_detail': payment.get('status_detail'),
|
||||||
'total_cents': total_cents,
|
'total_cents': total_cents,
|
||||||
'pix_qr_code': transaction.get('qr_code'),
|
'pix_qr_code': transaction.get('qr_code'),
|
||||||
'pix_qr_code_base64': transaction.get('qr_code_base64'),
|
'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:
|
def lookup(self, payment_id: str) -> dict:
|
||||||
response = self.http.get(f'/v1/payments/{payment_id}')
|
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):
|
def quote_view(c, row):
|
||||||
from .quote_review import review_reason # it imports this module
|
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()
|
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)
|
expired = row['approved_at'] and row['approved_at'] < datetime.now(timezone.utc)-timedelta(hours=24)
|
||||||
return {'id': row['id'], 'created_at': row['created_at'],
|
return {'id': row['id'], 'created_at': row['created_at'],
|
||||||
'draft': row['draft'], 'approved': row['approved'],
|
'draft': row['draft'], 'approved': row['approved'],
|
||||||
'status': 'paid' if order else 'expired' if expired else 'approved' if row['approved'] else 'pending_review',
|
'status': 'paid' if order else 'expired' if expired else 'approved' if row['approved'] else 'pending_review',
|
||||||
'auto_approved': row.get('reviewed_by') == 'auto',
|
'auto_approved': row.get('reviewed_by') == 'auto',
|
||||||
'review_reason': None if row['approved'] else review_reason(row['draft']),
|
'review_reason': None if row['approved'] else review_reason(row['draft']),
|
||||||
|
'payment': attempt,
|
||||||
'order': order}
|
'order': order}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -198,6 +198,7 @@ services:
|
|||||||
S3_PUBLIC_ENDPOINT: ${S3_PUBLIC_ENDPOINT:-http://localhost:${STORAGE_PORT:-9000}}
|
S3_PUBLIC_ENDPOINT: ${S3_PUBLIC_ENDPOINT:-http://localhost:${STORAGE_PORT:-9000}}
|
||||||
# Empty unless testing Mercado Pago's card form; see docs/LOCAL_SETUP.md.
|
# Empty unless testing Mercado Pago's card form; see docs/LOCAL_SETUP.md.
|
||||||
PAYMENT_CSP_SOURCES: ${PAYMENT_CSP_SOURCES:-}
|
PAYMENT_CSP_SOURCES: ${PAYMENT_CSP_SOURCES:-}
|
||||||
|
PAYMENT_CHALLENGE_SOURCES: ${PAYMENT_CHALLENGE_SOURCES:-}
|
||||||
ports:
|
ports:
|
||||||
# Published ports are host-wide even bound to loopback, so on a shared
|
# Published ports are host-wide even bound to loopback, so on a shared
|
||||||
# machine any of them can collide with something unrelated. CI overrides
|
# machine any of them can collide with something unrelated. CI overrides
|
||||||
@@ -222,6 +223,7 @@ services:
|
|||||||
WEB_INDEX: kanban.html
|
WEB_INDEX: kanban.html
|
||||||
S3_PUBLIC_ENDPOINT: ${S3_PUBLIC_ENDPOINT:-http://localhost:${STORAGE_PORT:-9000}}
|
S3_PUBLIC_ENDPOINT: ${S3_PUBLIC_ENDPOINT:-http://localhost:${STORAGE_PORT:-9000}}
|
||||||
PAYMENT_CSP_SOURCES: ""
|
PAYMENT_CSP_SOURCES: ""
|
||||||
|
PAYMENT_CHALLENGE_SOURCES: ""
|
||||||
ports: ["127.0.0.1:${KANBAN_PORT:-8081}:80"]
|
ports: ["127.0.0.1:${KANBAN_PORT:-8081}:80"]
|
||||||
networks: [local, edge]
|
networks: [local, edge]
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ server {
|
|||||||
add_header Referrer-Policy no-referrer always;
|
add_header Referrer-Policy no-referrer always;
|
||||||
add_header X-Frame-Options DENY always;
|
add_header X-Frame-Options DENY always;
|
||||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" 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 = /health { access_log off; return 200 'ok'; }
|
||||||
location /api/ {
|
location /api/ {
|
||||||
|
|||||||
@@ -175,6 +175,10 @@ services:
|
|||||||
# Mercado Pago's card form loads from these origins; empty keeps the
|
# Mercado Pago's card form loads from these origins; empty keeps the
|
||||||
# Site at script-src 'self'. Set together with the Mercado Pago adapter.
|
# Site at script-src 'self'. Set together with the Mercado Pago adapter.
|
||||||
PAYMENT_CSP_SOURCES: ${PAYMENT_CSP_SOURCES:-}
|
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]
|
networks: [backend]
|
||||||
ports:
|
ports:
|
||||||
- target: 8080
|
- target: 8080
|
||||||
@@ -198,6 +202,7 @@ services:
|
|||||||
PUBLIC_HOST: ${KANBAN_DOMAIN:?set KANBAN_DOMAIN}
|
PUBLIC_HOST: ${KANBAN_DOMAIN:?set KANBAN_DOMAIN}
|
||||||
S3_PUBLIC_ENDPOINT: ${R2_ENDPOINT:?set R2_ENDPOINT}
|
S3_PUBLIC_ENDPOINT: ${R2_ENDPOINT:?set R2_ENDPOINT}
|
||||||
PAYMENT_CSP_SOURCES: ""
|
PAYMENT_CSP_SOURCES: ""
|
||||||
|
PAYMENT_CHALLENGE_SOURCES: ""
|
||||||
networks: [backend]
|
networks: [backend]
|
||||||
ports:
|
ports:
|
||||||
- target: 8080
|
- 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
|
`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
|
`script-src 'self'`. The payment page offers credit card (the default), debit
|
||||||
is in review, the API refuses any further attempt for that quote. The order is created only by the signed
|
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
|
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
|
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
|
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 Referrer-Policy no-referrer always;
|
||||||
add_header X-Frame-Options DENY always;
|
add_header X-Frame-Options DENY always;
|
||||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" 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 = /health { access_log off; return 200 'ok'; }
|
||||||
location /api/ {
|
location /api/ {
|
||||||
limit_req zone=api_limit burst=100 nodelay;
|
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'})
|
{'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')
|
||||||
|
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__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
@@ -225,10 +225,29 @@
|
|||||||
pedido=[]; itemAtual=null; limpaPaineis();
|
pedido=[]; itemAtual=null; limpaPaineis();
|
||||||
await window.dtfClearCart?.();
|
await window.dtfClearCart?.();
|
||||||
await refresh();
|
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(_) {}
|
} catch(_) {}
|
||||||
},3000);
|
},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) {
|
function confirmado(quote) {
|
||||||
clearInterval(waitTimer);
|
clearInterval(waitTimer);
|
||||||
const box=node('div',null,'confirmado');
|
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).
|
// Paying: card (the default, paid on this page) or PIX (paid on its own page).
|
||||||
async function escolhaPagamento(quote, version) {
|
async function escolhaPagamento(quote, version) {
|
||||||
const session=await ready;
|
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];
|
let escolhido=opcoes[0][0];
|
||||||
const grupo=node('div',null,'metodos');
|
const grupo=node('div',null,'metodos');
|
||||||
grupo.setAttribute('role','radiogroup'); grupo.setAttribute('aria-label','Forma de pagamento');
|
grupo.setAttribute('role','radiogroup'); grupo.setAttribute('aria-label','Forma de pagamento');
|
||||||
@@ -256,7 +275,7 @@
|
|||||||
async function pinta() {
|
async function pinta() {
|
||||||
radios.forEach(el=>el.setAttribute('aria-checked',String(el.dataset.metodo===escolhido)));
|
radios.forEach(el=>el.setAttribute('aria-checked',String(el.dataset.metodo===escolhido)));
|
||||||
unmountCard(); area.replaceChildren(); message('');
|
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'));
|
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=>{
|
button('Pagar',async event=>{
|
||||||
if (!cartOk()) { await refresh(); return; }
|
if (!cartOk()) { await refresh(); return; }
|
||||||
@@ -325,7 +344,7 @@
|
|||||||
function unmountCard() {
|
function unmountCard() {
|
||||||
if (cardBrick) { try { cardBrick.unmount(); } catch(_) {} cardBrick=null; }
|
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');
|
const holder=node('div');
|
||||||
holder.id='cardPaymentBrick';
|
holder.id='cardPaymentBrick';
|
||||||
into.append(holder);
|
into.append(holder);
|
||||||
@@ -338,7 +357,10 @@
|
|||||||
const mp=new window.MercadoPago(session.payment_public_key,{locale:'pt-BR'});
|
const mp=new window.MercadoPago(session.payment_public_key,{locale:'pt-BR'});
|
||||||
cardBrick=await mp.bricks().create('cardPayment','cardPaymentBrick',{
|
cardBrick=await mp.bricks().create('cardPayment','cardPaymentBrick',{
|
||||||
initialization:{amount:approved.total_cents/100, payer:{email:approved.customer.mail}},
|
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'}}}},
|
visual:{style:{customVariables:{baseColor:'#FFA81A',buttonTextColor:'#03060B'}}}},
|
||||||
callbacks:{
|
callbacks:{
|
||||||
onReady:()=>{},
|
onReady:()=>{},
|
||||||
@@ -350,6 +372,7 @@
|
|||||||
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)}});
|
||||||
|
if (result.challenge) { showChallenge(result.challenge, into); waitForOrder(); return; }
|
||||||
if (result.status==='approved' || result.status==='pending') {
|
if (result.status==='approved' || result.status==='pending') {
|
||||||
unmountCard();
|
unmountCard();
|
||||||
into.replaceChildren(node('p',result.status==='approved'
|
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 .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{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}
|
.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{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::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)}
|
.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:hover:not(:disabled){background:#FFB53D}
|
||||||
.pagBtn:disabled{background:#EDEDED;color:#8A8D92;cursor:default}
|
.pagBtn:disabled{background:#EDEDED;color:#8A8D92;cursor:default}
|
||||||
.pagBtn.sec{background:#fff;border:1px solid #D6D6D6}
|
.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{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 img{border:1px solid var(--linha);border-radius:12px;padding:8px;background:#fff}
|
||||||
.pixBox .codigo{display:flex;gap:10px;width:100%}
|
.pixBox .codigo{display:flex;gap:10px;width:100%}
|
||||||
|
|||||||
Reference in New Issue
Block a user