Files
dtf-system/web/checkout.js
Cauê Faleiros f5fed013ab
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
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 <noreply@anthropic.com>
2026-09-29 13:57:19 -03:00

513 lines
25 KiB
JavaScript

/* Checkout bridge only: approved commercial functions in web/index.html stay intact.
The cart sends the order; the quote is paid on its own page (/pagamento). */
(() => {
const status = document.getElementById('checkoutStatus');
const actions = document.getElementById('checkoutActions');
const resumo = document.getElementById('pagResumo');
const naPagina = () => ['pagamento','pix'].includes(document.documentElement.dataset.rota);
const naPix = () => document.documentElement.dataset.rota === 'pix';
let shownCart = null;
let pixClock = null;
let busy = false;
let draftId = localStorage.getItem('dtf-quote');
let requestKey = localStorage.getItem('dtf-request-key');
let requestBody = localStorage.getItem('dtf-request-body');
let quotedCart = localStorage.getItem('dtf-quote-cart');
let refreshVersion = 0;
function cartSnapshot() {
const items=[...pedido,...(itemAtual?[itemAtual]:[])];
return JSON.stringify({customer:cliente,delivery:entrega,items:items.map(item=>({
mode:item.modo,metres:item.metros,grade:item.nota,production:item.production,
quality:item.qualityStatus,
acknowledged:item.qualityAcknowledged,
files:(item.localFiles||[]).map(file=>({name:file.name,size:file.size,lastModified:file.lastModified}))
}))});
}
function clearDraft() {
draftId=null;quotedCart=null;requestKey=null;requestBody=null;
for(const key of ['dtf-quote','dtf-quote-cart','dtf-request-key','dtf-request-body'])
localStorage.removeItem(key);
actions.replaceChildren();
}
const api = async (path, body) => {
const response = await fetch('/api'+path, {
credentials: 'same-origin', headers: {'Content-Type':'application/json'},
...(body === undefined ? {} : {method:'POST', body:JSON.stringify(body)})
});
const text = await response.text();
let data;
try { data = text ? JSON.parse(text) : {}; }
catch (_) { throw new Error(response.ok ? 'Resposta inválida do serviço.' : 'O serviço está indisponível. Tente novamente em instantes.'); }
if (!response.ok) { const error=new Error(typeof data.detail === 'string' ? data.detail : 'Confira os dados do pedido ('+response.status+').');error.status=response.status;throw error; }
return data;
};
const ready = api('/session');
ready.then(session=>{
window.dtfUploadMaxBytes=session.max_upload_bytes;
const limit=document.getElementById('zLimite');
if(limit)limit.textContent='Até '+(session.max_upload_bytes/1073741824).toFixed(0)+' GB por arquivo.';
}).catch(()=>{});
window.dtfSessionReady=ready;
window.dtfApi=api;
ready.catch(error => { status.textContent = error.message; });
function message(text) { status.textContent = text; }
function button(label, handler, into = actions, className = 'pagBtn') {
const el = document.createElement('button');
el.type = 'button';
el.className = className;
el.textContent = label;
el.onclick = handler;
into.append(el);
return el;
}
function node(tag, text, className) {
const el=document.createElement(tag);
if (text!=null) el.textContent=text;
if (className) el.className=className;
return el;
}
// What the customer is paying for, from the server's approval.
function pintaResumo(quote) {
const approved = quote ? quote.approved : null;
resumo.hidden = !approved;
if (!approved) { resumo.replaceChildren(); return; }
const linha = (label, value, className='l') => {
const row=document.createElement('div'); row.className=className;
const name=document.createElement('span'); name.textContent=label;
const amount=document.createElement('b'); amount.textContent=value;
row.append(name,amount); return row;
};
const rows = approved.items.map(item => linha(
(MODOS[item.mode]?.tit || item.mode)+' · '+fmtM(Number(item.billed_metres))+' m', rs(item.total_cents/100)));
const pickup = approved.freight.service === 'pickup';
const prazo = approved.freight.days ? ' · até '+approved.freight.days+' dias úteis' : '';
rows.push(linha(pickup ? 'Retirada em Franca' : 'Frete'+prazo,
approved.freight.total_cents ? rs(approved.freight.total_cents/100) : 'Grátis'));
rows.push(linha('Total', rs(approved.total_cents/100), 'tot'));
resumo.replaceChildren(node('h4','Resumo do pedido'), ...rows);
}
function semPedido() {
pintaResumo(null);
actions.replaceChildren();
message('Nenhum pedido aguardando pagamento.');
button('Ir para o carrinho', () => vaiPara(CARRINHO));
}
// Files start uploading as soon as they are in the cart, so a sheet of
// several GB is on its way while the customer fills in the order. The
// checkout waits for whatever is still going.
const envios=new Map();
const chaveArquivo=f=>[f.name,f.size,f.lastModified].join('|');
function enviar(file) {
const k=chaveArquivo(file);
let e=envios.get(k);
if (!e) {
e={file, sent:0, done:false, failed:null};
e.promise=(async()=>{
const session=await ready;
return window.dtfUpload(file,{api,scope:session.cart_scope,progress:()=>{},
onBytes:n=>{e.sent=n;pintaEnvio();}});
})();
e.promise.then(()=>{e.done=true;pintaEnvio();},
error=>{e.failed=error;envios.delete(k);pintaEnvio();});
envios.set(k,e);
}
return e.promise;
}
const arquivosDoCarrinho=()=>[...pedido,...(busy&&itemAtual?[itemAtual]:[])].flatMap(it=>it.localFiles||[]);
const gb=n=>(n/1073741824).toLocaleString('pt-BR',{maximumFractionDigits:1})+' GB';
const mb=n=>n>=1073741824 ? gb(n) : Math.round(n/1048576)+' MB';
function textoEnvio() {
const files=arquivosDoCarrinho(); if(!files.length) return '';
let total=0, sent=0, pendentes=0;
for (const f of files) {
const e=envios.get(chaveArquivo(f));
total+=f.size; sent+=e ? Math.min(e.sent,f.size) : 0;
if (!e || !e.done) pendentes++;
}
if (!pendentes) return 'Arquivos enviados.';
if (sent>=total) return 'Arquivos enviados · verificando a segurança…';
return 'Enviando seus arquivos: '+Math.floor(sent/total*100)+'% ('+mb(sent)+' de '+mb(total)+')';
}
function pintaEnvio() {
const el=document.getElementById('envioArq');
const texto=textoEnvio();
if (el) el.textContent=texto;
if (busy && texto) message(texto);
}
// Only what is in the cart: the item on the product page may still change.
window.addEventListener('dtf-cart-changed',()=>{ arquivosDoCarrinho().forEach(f=>{ enviar(f).catch(()=>{}); }); pintaEnvio(); });
async function upload(file) {
return enviar(file);
}
// The cart's package: billed metres and value. The charged freight is
// quoted again by the server from the approved items.
const pacote = () => {
const items=[...pedido,...(itemAtual?[itemAtual]:[])];
return {metres:items.reduce((t,it)=>t+(it.cob||0),0), cents:Math.round(items.reduce((t,it)=>t+(it.total||0),0)*100)};
};
let freightQuoted = null;
window.dtfFreight = async () => {
const cep = entrega.cep;
const service = (await ready).freight_service;
if (!service) {
$('cepMsg').textContent = 'A entrega ainda não está disponível. Escolha a retirada em Franca.';
return;
}
const {metres, cents} = pacote();
if (!metres) { $('cepMsg').textContent = 'Adicione um item ao pedido para cotar o frete.'; return; }
$('cepMsg').textContent = 'Cotando o frete…';
try {
const result = await api('/freight',{service,postal_code:cep,metres:metres.toFixed(2),declared_cents:cents});
if (entrega.cep !== cep || entrega.tipo !== 'frete') return;
entrega.valor = result.total_cents/100;
entrega.dias = result.days || null;
entrega.cotado = true;
freightQuoted = JSON.stringify(pacote());
$('cepMsg').textContent = '';
pintaEntrega();
} catch(error) { $('cepMsg').textContent = error.message; }
};
// A different package is a different freight: quote it again.
window.addEventListener('dtf-cart-changed',()=>{
if (entrega.tipo==='frete' && entrega.cotado && freightQuoted && freightQuoted!==JSON.stringify(pacote())) {
entrega.cotado=false; freightQuoted=null; window.dtfFreight();
}
});
window.dtfCheckout = async () => {
if (busy) return;
// The cart that was sent goes straight to its payment; a changed cart is sent again.
if (draftId && quotedCart === cartSnapshot()) { await refresh(true); return; }
if (draftId) clearDraft();
if (!clienteOk() || !entrega.cotado) return;
if (!enderecoOk()) return message('Preencha o endereço de entrega.');
if (!cartPodeEnviar()) return message('Revise a qualidade e confirme a ressalva de cada item antes de enviar o pedido.');
const cart = [...pedido,...(itemAtual?[itemAtual]:[])];
if (!cart.length) return message('Adicione um item ao pedido.');
const initialCart=cartSnapshot();
busy = true;
$('bPagar').disabled = true;
try {
await ready;
if((await api('/session')).cart_scope !== (await ready).cart_scope) throw new Error('Sua conta ou sessão mudou. Recarregue a página antes de enviar o carrinho.');
const items=[];
pintaEnvio();
for (const item of cart) {
if (!item.localFiles?.length) throw new Error('Selecione novamente os arquivos deste item.');
const uploads=[];
for (const file of item.localFiles) uploads.push(await upload(file));
if (item.production?.version!==2 || item.production.sources?.length!==uploads.length)
throw new Error('A montagem deste item precisa ser refeita antes da cotação.');
items.push({mode:item.modo, metres:String(item.metros), grade:item.nota, uploads,
production:{...item.production,sources:item.production.sources.map((source,index)=>({
upload_id:uploads[index],...source}))},
quality_status:item.qualityStatus,quality_acknowledged:item.qualityAcknowledged});
}
const service = entrega.tipo==='retira' ? 'pickup' : (await ready).freight_service;
if (!service) throw new Error('A entrega ainda não está disponível. Escolha a retirada em Franca.');
const content = {customer:{...cliente},items,freight:{service,postal_code:entrega.tipo==='retira'?'':entrega.cep}};
const destination = destinoApi();
if (destination) content.destination = destination;
if (cartSnapshot()!==initialCart) throw new Error('O carrinho mudou durante o envio. Confira os itens e envie de novo.');
const serialized = JSON.stringify(content);
if (!requestKey || serialized !== requestBody) {
requestKey = crypto.randomUUID(); requestBody = serialized;
localStorage.setItem('dtf-request-key',requestKey);
localStorage.setItem('dtf-request-body',requestBody);
}
const quote = await api('/quotes',{request_key:requestKey,...content});
quotedCart=initialCart;localStorage.setItem('dtf-quote-cart',quotedCart);
draftId=quote.id; localStorage.setItem('dtf-quote',draftId);
await refresh(true);
} catch(error) { message(error.message); }
finally { busy=false; pintaEntrega(); }
};
// `go`: the customer asked to pay, so the cart page moves to the payment page.
async function refresh(go) {
if (!naPagina()) { if (!go || !draftId) return; }
else if (!draftId) { semPedido(); return; }
const version=++refreshVersion, shownId=draftId;
try {
await ready;
const quote=await api('/quotes/'+shownId);
if(version!==refreshVersion || draftId!==shownId) return;
if (!naPagina()) { message(''); vaiPara(PAGAMENTO); return; }
shownCart=cartSnapshot();
clearInterval(pixClock);
unmountCard();
actions.replaceChildren();
message('');
pintaResumo(quote);
document.querySelector('.pagVolta').hidden = quote.status==='paid';
if (quote.status==='paid') { confirmado(quote); return; }
if (naPix() && quote.status!=='approved') { vaiPara(PAGAMENTO); return; }
if (!quotedCart || quotedCart!==cartSnapshot()) {
message('O carrinho mudou ou não está disponível neste navegador. A cotação anterior continua separada; envie o carrinho atual para uma nova revisão.');
button('Enviar carrinho atual',()=>{clearDraft();window.dtfCheckout();});
return;
}
if (quote.status==='pending_review') {
message((await ready).environment==='local'
? 'Arquivos enviados. No Kanban, confira metragem e nota e aprove a cotação '+draftId.slice(0,8)+'.'
: 'Arquivos enviados. Nossa equipe está conferindo a cotação '+draftId.slice(0,8)+'; o valor final aparece aqui em seguida.');
} else if (quote.status==='approved') {
if ((await ready).payment_provider === 'mercadopago') {
if (naPix()) await paginaPix(quote, version);
else await escolhaPagamento(quote, version);
return;
}
if ((await ready).environment !== 'local') {
message('Cotação revisada. O pagamento online ainda não está disponível.');
return;
}
// Local stack only: the simulated payment.
message('Total validado no servidor: '+rs(quote.approved.total_cents/100)+' · inclui frete. Cotação válida por 24 horas.');
button('Criar pedido de teste',async event=>{
if (!quotedCart || quotedCart!==cartSnapshot()) { await refresh(); return; }
event.target.disabled=true;
try {
const order=await api('/orders/dev-paid',{quote_id:draftId});
pedido=[]; itemAtual=null; limpaPaineis();
await window.dtfClearCart?.();
message('Pedido #'+order.number+' criado e disponível no Kanban.');
await refresh();
} catch(error) { message(error.message); event.target.disabled=false; }
});
} else {
message('Cotação expirada. Envie o carrinho de novo para pagar.');
button('Voltar ao carrinho',()=>{clearDraft();vaiPara(CARRINHO);});
}
} catch(error) {
if(version!==refreshVersion || draftId!==shownId) return;
message(error.message);
actions.replaceChildren();
button('Limpar referência e tentar de novo',clearDraft);
}
}
// The order is created by Mercado Pago's notification, never by this page,
// so after paying the page only waits for the order to exist.
let waitTimer=null;
function waitForOrder() {
clearInterval(waitTimer);
waitTimer=setInterval(async()=>{
try {
const quote=await api('/quotes/'+draftId);
if (quote.status==='paid') {
clearInterval(waitTimer);
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');
box.append(node('h4','Pagamento confirmado'),
node('p','Pedido #'+quote.order.number+' recebido e enviado para a produção. Você acompanha cada etapa em Meus pedidos.'));
button('Ver meus pedidos',()=>location.assign('/portal.html?order='+quote.order.id),box);
actions.append(box);
}
const cartOk=()=>!!quotedCart && quotedCart===cartSnapshot();
// 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?[['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');
const area=node('div',null,'area');
const radios=opcoes.map(([key,label])=>{
const el=button(label,()=>{ if(escolhido!==key){ escolhido=key; pinta(); } },grupo,'metodo');
el.setAttribute('role','radio'); el.dataset.metodo=key;
return el;
});
actions.append(node('h4','Forma de pagamento'),grupo,area);
async function pinta() {
radios.forEach(el=>el.setAttribute('aria-checked',String(el.dataset.metodo===escolhido)));
unmountCard(); area.replaceChildren(); message('');
if (escolhido!=='pix') { await showCard(quote.approved, area, version, escolhido); return; }
area.append(node('p','Clique em Pagar para gerar o QR code do PIX.','pagNota'));
button('Pagar',async event=>{
if (!cartOk()) { await refresh(); return; }
event.target.disabled=true;
try {
await api('/payments/intent',{quote_id:draftId,method:{type:'pix'}});
vaiPara(PAGAMENTO_PIX);
} catch(error) { message(error.message); event.target.disabled=false; }
},area);
}
await pinta();
}
// The PIX page. The intent is idempotent on the quote, so reopening this
// page shows the same QR code instead of creating another charge.
async function paginaPix(quote, version) {
let intent;
try { intent=await api('/payments/intent',{quote_id:draftId,method:{type:'pix'}}); }
catch(error) {
if (version!==refreshVersion) return;
message(error.message);
button('Escolher outra forma de pagamento',()=>vaiPara(PAGAMENTO),actions,'pagBtn sec');
return;
}
if (version!==refreshVersion) return;
if (!intent.pix_qr_code) { message('Não foi possível gerar o PIX. Tente de novo em instantes.'); return; }
const box=node('div',null,'pixBox');
box.append(node('h4','Pague com PIX'),
node('p','Abra o app do seu banco, escolha pagar com PIX e leia o QR code ou cole o código abaixo.','pagNota'));
if (intent.pix_qr_code_base64) {
const img=document.createElement('img');
img.src='data:image/png;base64,'+intent.pix_qr_code_base64;
img.alt='QR code do PIX';img.width=240;img.height=240;
box.append(img);
}
const linha=node('div',null,'codigo');
const code=document.createElement('input');
code.readOnly=true;code.value=intent.pix_qr_code;code.setAttribute('aria-label','Código PIX copia e cola');
linha.append(code);
const copiar=button('Copiar código',async()=>{
try{ await navigator.clipboard.writeText(intent.pix_qr_code); copiar.textContent='Código copiado'; }
catch(_){ code.select(); }
},linha);
const prazo=node('p',null,'prazo');
box.append(linha,prazo,node('p','Aguardando a confirmação do pagamento…','aguarda'));
actions.append(box);
waitForOrder();
// The code stops working when it expires; a new one is a click away.
const fim=Date.parse(intent.expires_at);
if (!Number.isFinite(fim)) { prazo.remove(); return; }
const tick=()=>{
const s=Math.max(0,Math.ceil((fim-Date.now())/1000));
prazo.textContent='Pague em '+String(Math.floor(s/60)).padStart(2,'0')+':'+String(s%60).padStart(2,'0');
if (s>0) return;
clearInterval(pixClock);
const fimBox=node('div',null,'pixBox');
fimBox.append(node('h4','O código PIX expirou'),
node('p','Gere um novo código para pagar. O anterior não pode mais ser pago.','pagNota'));
button('Gerar novo PIX',()=>refresh(),fimBox);
actions.replaceChildren(fimBox);
};
tick();
pixClock=setInterval(tick,1000);
}
// Card: Mercado Pago's own form (Card Payment Brick). The card is typed into
// Mercado Pago's secure fields and becomes a one-time token; the number never
// reaches this page's code or our server.
let sdkLoading=null;
function loadMercadoPago() {
if (window.MercadoPago) return Promise.resolve();
sdkLoading = sdkLoading || new Promise((resolve,reject)=>{
const script=document.createElement('script');
script.src='https://sdk.mercadopago.com/js/v2';
script.onload=resolve;
script.onerror=()=>{sdkLoading=null;reject(new Error('Não foi possível carregar o formulário do Mercado Pago.'));};
document.head.append(script);
});
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; }
}
async function showCard(approved, into, version, tipo) {
const holder=node('div');
holder.id='cardPaymentBrick';
into.append(holder);
try { await loadMercadoPago(); }
catch(error) { message(error.message); return; }
const session=await ready;
// The customer may have switched to PIX, or the page re-rendered, meanwhile.
if (!holder.isConnected || version!==refreshVersion) return;
unmountCard();
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}},
// 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']}},
// The option above already names the card; the form's own title
// ("crédito ou débito") would contradict it.
visual:{hideFormTitle:true,
style:{customVariables:{baseColor:'#FFA81A',buttonTextColor:'#03060B'}}}},
callbacks:{
onReady:()=>{},
onError:error=>{ console.error(error); message('Erro no formulário do cartão. Confira os dados e tente de novo.'); },
onSubmit:async data=>{
if (!cartOk()) { await refresh(); throw new Error('cart changed'); }
message('');
let result;
try {
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),
...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.');
status.scrollIntoView({behavior:'smooth',block:'center'});
throw error;
}
if (result.challenge) { showChallenge(result.challenge, into); waitForOrder(); return; }
if (result.status==='approved' || result.status==='pending') {
unmountCard();
into.replaceChildren(node('p',result.status==='approved'
? 'Pagamento aprovado. Confirmando seu pedido…'
: 'Pagamento em análise pelo Mercado Pago. Esta página atualiza assim que ele for confirmado.','pagNota'));
waitForOrder();
} else {
message('Pagamento recusado pelo Mercado Pago ('+(result.status_detail||result.status)+'). '+
'Confira os dados ou use outro cartão.');
throw new Error('rejected');
}
}
}
});
if (!holder.isConnected || version!==refreshVersion) unmountCard();
}
const quoteFromPortal=new URLSearchParams(location.search).get('quote');
if(quoteFromPortal && /^[0-9a-f-]{36}$/.test(quoteFromPortal)){
if(draftId!==quoteFromPortal){quotedCart=null;localStorage.removeItem('dtf-quote-cart');}
draftId=quoteFromPortal;localStorage.setItem('dtf-quote',draftId);
}
// Re-render the payment page only when the cart really changed: re-rendering
// would remount the card form under the customer's typing.
window.addEventListener('dtf-cart-changed',()=>{if(draftId && naPagina() && shownCart!==cartSnapshot()) refresh();});
// The cart page keeps only the sending progress and errors, never a payment.
window.addEventListener('dtf-page-changed',()=>{
if (naPagina()) refresh();
else if (!busy) { refreshVersion++; clearInterval(pixClock); message(''); actions.replaceChildren(); pintaResumo(null); }
});
refresh();
})();