feat(billing): integração Asaas — checkout PIX/Cartão/Boleto + webhook + UI upgrade
BACKEND - Nova tabela payments (user_id, asaas_payment_id, plan, cycle, value, billing_type, status, ...) - Coluna users.asaas_customer_id (cache pra reaproveitar customer entre payments) - server/src/billing.js: cliente Asaas v3 com getOrCreateCustomer, createPayment, getPixQrCode, status mapping - Endpoint POST /api/billing/checkout — cria cobrança + retorna URL/QR PIX - Endpoint GET /api/billing/payment/:id — verifica status, faz reconciliação se webhook falhou - Endpoint POST /api/billing/asaas-webhook — ativa licença em RECEIVED/CONFIRMED, revoga em REFUNDED - Endpoint GET /api/billing/payments — histórico do user - 503 se ASAAS_API_KEY não configurado (graceful degradation) - Webhook valida ASAAS_WEBHOOK_TOKEN (shared secret) se setado FRONTEND (sincronizado app/ + server/public/) - openUpgradeModal() — modal dinâmico com seleção plano (Pro/Captain) + ciclo (mensal/anual) + tipo (PIX/Cartão/Boleto) - _doCheckout() — chama backend, exibe QR Code PIX OU link invoice - checkPaymentStatus() — verifica e ativa licença quando pago ENV VARS NECESSÁRIAS NO COOLIFY (próximo passo manual): - ASAAS_API_KEY=$aact_prod_... (chave Asaas que Karlão já usa em outros projetos) - ASAAS_API_URL=https://api.asaas.com/v3 (default) - ASAAS_WEBHOOK_TOKEN=whsec_... (gere um valor aleatório, configure no painel Asaas → Integrações) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a80adc7bdf
commit
ca9de52ae1
6 changed files with 456 additions and 3 deletions
|
|
@ -2430,7 +2430,7 @@ function renderAuthBox(){
|
|||
const u=state.auth.user;
|
||||
const lic=state.license||{plan:'free',features:[]};
|
||||
const planLabel={free:'Free (Âncora)',pro:'Pro',captain:'Captain'}[lic.plan]||lic.plan;
|
||||
box.innerHTML=`<div style="font-family:var(--f-mono);font-size:11px;line-height:1.7"><div><strong>${escapeHtml(u.email)}</strong> ${u.name?'· '+escapeHtml(u.name):''}</div><div>Plano: <strong style="color:var(--brass)">${planLabel}</strong> ${lic.expires_at?'· expira '+new Date(lic.expires_at).toLocaleDateString('pt-BR'):''}</div></div><button class="btn btn-block" style="margin-top:10px" onclick="authLogout()">Sair</button>${lic.plan==='free'?'<button class="btn btn-block btn-brass" style="margin-top:6px" onclick="alert(\'Upgrade pra Pro: R$19/mês ou R$149/ano. Em breve cobrança via Asaas (PIX).\')">⚡ Fazer upgrade pra Pro</button>':''}`;
|
||||
box.innerHTML=`<div style="font-family:var(--f-mono);font-size:11px;line-height:1.7"><div><strong>${escapeHtml(u.email)}</strong> ${u.name?'· '+escapeHtml(u.name):''}</div><div>Plano: <strong style="color:var(--brass)">${planLabel}</strong> ${lic.expires_at?'· expira '+new Date(lic.expires_at).toLocaleDateString('pt-BR'):''}</div></div><button class="btn btn-block" style="margin-top:10px" onclick="authLogout()">Sair</button>${lic.plan==='free'?'<button class="btn btn-block btn-brass" style="margin-top:6px" onclick="openUpgradeModal()">⚡ Fazer upgrade pra Pro</button>':''}`;
|
||||
}else{
|
||||
box.innerHTML=`
|
||||
<div style="font-family:var(--f-mono);font-size:11px;color:var(--sepia);margin-bottom:10px;letter-spacing:.04em">CONTA · multi-usuário SaaS</div>
|
||||
|
|
@ -2462,6 +2462,85 @@ async function onAuthLoginClick(){
|
|||
try{await authLogin(e,p);msg.style.color='var(--algae)';msg.textContent='Logado!';toast('Bem-vindo')}catch(err){msg.textContent=err.message}
|
||||
}
|
||||
|
||||
// ===== Upgrade modal (Asaas billing) =====
|
||||
let _upgradeChosen={plan:'pro',cycle:'yearly',billingType:'PIX'};
|
||||
|
||||
async function openUpgradeModal(){
|
||||
if(!state.auth){toast('Faça login primeiro');return}
|
||||
// Cria modal dinamicamente (não precisa adicionar HTML no body)
|
||||
const existing=document.getElementById('upgrade-modal');
|
||||
if(existing)existing.remove();
|
||||
const m=document.createElement('div');
|
||||
m.id='upgrade-modal';
|
||||
m.className='modal-backdrop';
|
||||
m.style.cssText='align-items:center;justify-content:center;display:flex;z-index:9999';
|
||||
m.innerHTML=`<div class="modal" style="max-width:420px;padding:24px"><h3 style="margin:0 0 12px;font-family:var(--f-display),Georgia,serif;font-style:italic;color:var(--brass)">Upgrade · escolha seu plano</h3>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin:12px 0">
|
||||
<button id="up-pro" class="btn" onclick="_upgradeChoose('pro')" style="padding:10px;text-align:left"><strong>Pro</strong><br><small style="opacity:.8">R\$19/mês ou R\$149/ano</small></button>
|
||||
<button id="up-captain" class="btn" onclick="_upgradeChoose('captain')" style="padding:10px;text-align:left"><strong>Captain</strong><br><small style="opacity:.8">R\$39/mês ou R\$299/ano</small></button>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;margin-bottom:10px">
|
||||
<button id="up-monthly" class="btn btn-sm" onclick="_upgradeCycle('monthly')" style="flex:1">Mensal</button>
|
||||
<button id="up-yearly" class="btn btn-sm" onclick="_upgradeCycle('yearly')" style="flex:1">Anual (-35%)</button>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;margin-bottom:14px">
|
||||
<button id="up-pix" class="btn btn-sm" onclick="_upgradeType('PIX')" style="flex:1">PIX (instantâneo)</button>
|
||||
<button id="up-cc" class="btn btn-sm" onclick="_upgradeType('CREDIT_CARD')" style="flex:1">Cartão</button>
|
||||
<button id="up-bol" class="btn btn-sm" onclick="_upgradeType('BOLETO')" style="flex:1">Boleto</button>
|
||||
</div>
|
||||
<div id="up-summary" style="font-family:var(--f-mono);font-size:12px;color:var(--ink);background:var(--bg-aged);padding:10px;border-radius:4px;margin-bottom:12px"></div>
|
||||
<button class="btn btn-block btn-primary" onclick="_doCheckout()">Continuar pra pagamento</button>
|
||||
<button class="btn btn-block" onclick="document.getElementById('upgrade-modal').remove()" style="margin-top:6px">Cancelar</button>
|
||||
<div id="up-result" style="margin-top:14px"></div>
|
||||
</div>`;
|
||||
document.body.appendChild(m);
|
||||
_upgradeRefresh();
|
||||
}
|
||||
function _upgradeChoose(p){_upgradeChosen.plan=p;_upgradeRefresh()}
|
||||
function _upgradeCycle(c){_upgradeChosen.cycle=c;_upgradeRefresh()}
|
||||
function _upgradeType(t){_upgradeChosen.billingType=t;_upgradeRefresh()}
|
||||
function _upgradeRefresh(){
|
||||
const c=_upgradeChosen;
|
||||
['up-pro','up-captain'].forEach(i=>document.getElementById(i)?.classList.remove('btn-brass'));
|
||||
['up-monthly','up-yearly'].forEach(i=>document.getElementById(i)?.classList.remove('btn-brass'));
|
||||
['up-pix','up-cc','up-bol'].forEach(i=>document.getElementById(i)?.classList.remove('btn-brass'));
|
||||
document.getElementById('up-'+c.plan)?.classList.add('btn-brass');
|
||||
document.getElementById('up-'+c.cycle)?.classList.add('btn-brass');
|
||||
document.getElementById('up-'+(c.billingType==='PIX'?'pix':c.billingType==='CREDIT_CARD'?'cc':'bol'))?.classList.add('btn-brass');
|
||||
const prices={pro:{monthly:19,yearly:149},captain:{monthly:39,yearly:299}};
|
||||
const v=prices[c.plan][c.cycle];
|
||||
document.getElementById('up-summary').textContent=`Plano ${c.plan==='pro'?'Pro':'Captain'} · ${c.cycle==='monthly'?'mensal':'anual'} · R$ ${v.toFixed(2)} via ${c.billingType==='PIX'?'PIX':c.billingType==='CREDIT_CARD'?'Cartão':'Boleto'}`;
|
||||
}
|
||||
async function _doCheckout(){
|
||||
const out=document.getElementById('up-result');out.innerHTML='<em>Criando cobrança…</em>';
|
||||
try{
|
||||
const r=await cloudFetch('/api/billing/checkout',{method:'POST',body:JSON.stringify(_upgradeChosen)});
|
||||
const data=await r.json();
|
||||
let html='';
|
||||
if(data.pix&&data.pix.qrCode){
|
||||
html=`<div style="text-align:center"><strong>Escaneie o QR Code PIX:</strong><br><img src="data:image/png;base64,${data.pix.qrCode}" style="max-width:240px;margin:10px auto;border:4px solid #fff;border-radius:8px"><br><div style="font-family:var(--f-mono);font-size:10px;word-break:break-all;background:var(--bg-aged);padding:6px;border-radius:4px;margin:6px 0"><strong>Copia e cola:</strong><br>${data.pix.payload||''}</div><button class="btn btn-sm" onclick="navigator.clipboard.writeText('${data.pix.payload||''}').then(()=>toast('PIX copiado!'))">Copiar PIX</button></div>`;
|
||||
}else if(data.invoiceUrl){
|
||||
html=`<a href="${data.invoiceUrl}" target="_blank" class="btn btn-block btn-primary">Abrir página de pagamento ↗</a>`;
|
||||
}
|
||||
html+=`<div style="margin-top:10px;font-size:11px;opacity:.8">Após pagar, sua licença ativa em segundos. Pode fechar esta tela e voltar quando quiser.</div>`;
|
||||
html+=`<button class="btn btn-block" style="margin-top:10px" onclick="checkPaymentStatus('${data.paymentId}')">Verificar pagamento</button>`;
|
||||
out.innerHTML=html;
|
||||
}catch(e){out.innerHTML='<span style="color:var(--storm)">Erro: '+e.message+'</span>'}
|
||||
}
|
||||
async function checkPaymentStatus(paymentId){
|
||||
try{
|
||||
const r=await cloudFetch('/api/billing/payment/'+paymentId);
|
||||
const p=await r.json();
|
||||
if(['RECEIVED','CONFIRMED','RECEIVED_IN_CASH'].includes(p.status)){
|
||||
toast('Pago! Licença ativada 🎉');
|
||||
await refreshLicense();renderAuthBox();
|
||||
document.getElementById('upgrade-modal')?.remove();
|
||||
}else{
|
||||
toast('Status: '+p.status);
|
||||
}
|
||||
}catch(e){toast('Erro: '+e.message)}
|
||||
}
|
||||
|
||||
async function onAuthSignupClick(){
|
||||
const e=document.getElementById('signup-email').value.trim();
|
||||
const n=document.getElementById('signup-name').value.trim();
|
||||
|
|
|
|||
|
|
@ -2410,7 +2410,7 @@ function renderAuthBox(){
|
|||
const u=state.auth.user;
|
||||
const lic=state.license||{plan:'free',features:[]};
|
||||
const planLabel={free:'Free (Âncora)',pro:'Pro',captain:'Captain'}[lic.plan]||lic.plan;
|
||||
box.innerHTML=`<div style="font-family:var(--f-mono);font-size:11px;line-height:1.7"><div><strong>${escapeHtml(u.email)}</strong> ${u.name?'· '+escapeHtml(u.name):''}</div><div>Plano: <strong style="color:var(--brass)">${planLabel}</strong> ${lic.expires_at?'· expira '+new Date(lic.expires_at).toLocaleDateString('pt-BR'):''}</div></div><button class="btn btn-block" style="margin-top:10px" onclick="authLogout()">Sair</button>${lic.plan==='free'?'<button class="btn btn-block btn-brass" style="margin-top:6px" onclick="alert(\'Upgrade pra Pro: R$19/mês ou R$149/ano. Em breve cobrança via Asaas (PIX).\')">⚡ Fazer upgrade pra Pro</button>':''}`;
|
||||
box.innerHTML=`<div style="font-family:var(--f-mono);font-size:11px;line-height:1.7"><div><strong>${escapeHtml(u.email)}</strong> ${u.name?'· '+escapeHtml(u.name):''}</div><div>Plano: <strong style="color:var(--brass)">${planLabel}</strong> ${lic.expires_at?'· expira '+new Date(lic.expires_at).toLocaleDateString('pt-BR'):''}</div></div><button class="btn btn-block" style="margin-top:10px" onclick="authLogout()">Sair</button>${lic.plan==='free'?'<button class="btn btn-block btn-brass" style="margin-top:6px" onclick="openUpgradeModal()">⚡ Fazer upgrade pra Pro</button>':''}`;
|
||||
}else{
|
||||
box.innerHTML=`
|
||||
<div style="font-family:var(--f-mono);font-size:11px;color:var(--sepia);margin-bottom:10px;letter-spacing:.04em">CONTA · multi-usuário SaaS</div>
|
||||
|
|
@ -2441,6 +2441,84 @@ async function onAuthLoginClick(){
|
|||
try{await authLogin(e,p);msg.style.color='var(--algae)';msg.textContent='Logado!';toast('Bem-vindo')}catch(err){msg.textContent=err.message}
|
||||
}
|
||||
|
||||
// ===== Upgrade modal (Asaas billing) =====
|
||||
let _upgradeChosen={plan:'pro',cycle:'yearly',billingType:'PIX'};
|
||||
|
||||
async function openUpgradeModal(){
|
||||
if(!state.auth){toast('Faça login primeiro');return}
|
||||
const existing=document.getElementById('upgrade-modal');
|
||||
if(existing)existing.remove();
|
||||
const m=document.createElement('div');
|
||||
m.id='upgrade-modal';
|
||||
m.className='modal-backdrop';
|
||||
m.style.cssText='align-items:center;justify-content:center;display:flex;z-index:9999';
|
||||
m.innerHTML=`<div class="modal" style="max-width:420px;padding:24px"><h3 style="margin:0 0 12px;font-family:var(--f-display),Georgia,serif;font-style:italic;color:var(--brass)">Upgrade · escolha seu plano</h3>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin:12px 0">
|
||||
<button id="up-pro" class="btn" onclick="_upgradeChoose('pro')" style="padding:10px;text-align:left"><strong>Pro</strong><br><small style="opacity:.8">R\$19/mês ou R\$149/ano</small></button>
|
||||
<button id="up-captain" class="btn" onclick="_upgradeChoose('captain')" style="padding:10px;text-align:left"><strong>Captain</strong><br><small style="opacity:.8">R\$39/mês ou R\$299/ano</small></button>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;margin-bottom:10px">
|
||||
<button id="up-monthly" class="btn btn-sm" onclick="_upgradeCycle('monthly')" style="flex:1">Mensal</button>
|
||||
<button id="up-yearly" class="btn btn-sm" onclick="_upgradeCycle('yearly')" style="flex:1">Anual (-35%)</button>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;margin-bottom:14px">
|
||||
<button id="up-pix" class="btn btn-sm" onclick="_upgradeType('PIX')" style="flex:1">PIX (instantâneo)</button>
|
||||
<button id="up-cc" class="btn btn-sm" onclick="_upgradeType('CREDIT_CARD')" style="flex:1">Cartão</button>
|
||||
<button id="up-bol" class="btn btn-sm" onclick="_upgradeType('BOLETO')" style="flex:1">Boleto</button>
|
||||
</div>
|
||||
<div id="up-summary" style="font-family:var(--f-mono);font-size:12px;color:var(--ink);background:var(--bg-aged);padding:10px;border-radius:4px;margin-bottom:12px"></div>
|
||||
<button class="btn btn-block btn-primary" onclick="_doCheckout()">Continuar pra pagamento</button>
|
||||
<button class="btn btn-block" onclick="document.getElementById('upgrade-modal').remove()" style="margin-top:6px">Cancelar</button>
|
||||
<div id="up-result" style="margin-top:14px"></div>
|
||||
</div>`;
|
||||
document.body.appendChild(m);
|
||||
_upgradeRefresh();
|
||||
}
|
||||
function _upgradeChoose(p){_upgradeChosen.plan=p;_upgradeRefresh()}
|
||||
function _upgradeCycle(c){_upgradeChosen.cycle=c;_upgradeRefresh()}
|
||||
function _upgradeType(t){_upgradeChosen.billingType=t;_upgradeRefresh()}
|
||||
function _upgradeRefresh(){
|
||||
const c=_upgradeChosen;
|
||||
['up-pro','up-captain'].forEach(i=>document.getElementById(i)?.classList.remove('btn-brass'));
|
||||
['up-monthly','up-yearly'].forEach(i=>document.getElementById(i)?.classList.remove('btn-brass'));
|
||||
['up-pix','up-cc','up-bol'].forEach(i=>document.getElementById(i)?.classList.remove('btn-brass'));
|
||||
document.getElementById('up-'+c.plan)?.classList.add('btn-brass');
|
||||
document.getElementById('up-'+c.cycle)?.classList.add('btn-brass');
|
||||
document.getElementById('up-'+(c.billingType==='PIX'?'pix':c.billingType==='CREDIT_CARD'?'cc':'bol'))?.classList.add('btn-brass');
|
||||
const prices={pro:{monthly:19,yearly:149},captain:{monthly:39,yearly:299}};
|
||||
const v=prices[c.plan][c.cycle];
|
||||
document.getElementById('up-summary').textContent=`Plano ${c.plan==='pro'?'Pro':'Captain'} · ${c.cycle==='monthly'?'mensal':'anual'} · R$ ${v.toFixed(2)} via ${c.billingType==='PIX'?'PIX':c.billingType==='CREDIT_CARD'?'Cartão':'Boleto'}`;
|
||||
}
|
||||
async function _doCheckout(){
|
||||
const out=document.getElementById('up-result');out.innerHTML='<em>Criando cobrança…</em>';
|
||||
try{
|
||||
const r=await cloudFetch('/api/billing/checkout',{method:'POST',body:JSON.stringify(_upgradeChosen)});
|
||||
const data=await r.json();
|
||||
let html='';
|
||||
if(data.pix&&data.pix.qrCode){
|
||||
html=`<div style="text-align:center"><strong>Escaneie o QR Code PIX:</strong><br><img src="data:image/png;base64,${data.pix.qrCode}" style="max-width:240px;margin:10px auto;border:4px solid #fff;border-radius:8px"><br><div style="font-family:var(--f-mono);font-size:10px;word-break:break-all;background:var(--bg-aged);padding:6px;border-radius:4px;margin:6px 0"><strong>Copia e cola:</strong><br>${data.pix.payload||''}</div><button class="btn btn-sm" onclick="navigator.clipboard.writeText('${data.pix.payload||''}').then(()=>toast('PIX copiado!'))">Copiar PIX</button></div>`;
|
||||
}else if(data.invoiceUrl){
|
||||
html=`<a href="${data.invoiceUrl}" target="_blank" class="btn btn-block btn-primary">Abrir página de pagamento ↗</a>`;
|
||||
}
|
||||
html+=`<div style="margin-top:10px;font-size:11px;opacity:.8">Após pagar, sua licença ativa em segundos. Pode fechar esta tela e voltar quando quiser.</div>`;
|
||||
html+=`<button class="btn btn-block" style="margin-top:10px" onclick="checkPaymentStatus('${data.paymentId}')">Verificar pagamento</button>`;
|
||||
out.innerHTML=html;
|
||||
}catch(e){out.innerHTML='<span style="color:var(--storm)">Erro: '+e.message+'</span>'}
|
||||
}
|
||||
async function checkPaymentStatus(paymentId){
|
||||
try{
|
||||
const r=await cloudFetch('/api/billing/payment/'+paymentId);
|
||||
const p=await r.json();
|
||||
if(['RECEIVED','CONFIRMED','RECEIVED_IN_CASH'].includes(p.status)){
|
||||
toast('Pago! Licença ativada 🎉');
|
||||
await refreshLicense();renderAuthBox();
|
||||
document.getElementById('upgrade-modal')?.remove();
|
||||
}else{
|
||||
toast('Status: '+p.status);
|
||||
}
|
||||
}catch(e){toast('Erro: '+e.message)}
|
||||
}
|
||||
|
||||
async function onAuthSignupClick(){
|
||||
const e=document.getElementById('signup-email').value.trim();
|
||||
const n=document.getElementById('signup-name').value.trim();
|
||||
|
|
|
|||
119
server/src/billing.js
Normal file
119
server/src/billing.js
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
// Billing — Asaas (PIX/Boleto/Cartão) pra licenciar Pro/Captain
|
||||
// Docs: https://docs.asaas.com/reference/
|
||||
|
||||
const ASAAS_URL = process.env.ASAAS_API_URL || 'https://api.asaas.com/v3';
|
||||
const ASAAS_KEY = process.env.ASAAS_API_KEY;
|
||||
const ASAAS_WEBHOOK_TOKEN = process.env.ASAAS_WEBHOOK_TOKEN; // shared secret pro webhook
|
||||
|
||||
export function isAsaasConfigured() {
|
||||
return !!ASAAS_KEY;
|
||||
}
|
||||
|
||||
async function asaasRequest(path, method = 'GET', body = null) {
|
||||
if (!ASAAS_KEY) throw new Error('Asaas não configurado (env ASAAS_API_KEY ausente)');
|
||||
const url = ASAAS_URL.replace(/\/$/, '') + path;
|
||||
const r = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'access_token': ASAAS_KEY,
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'Shivao-SaaS/1.0',
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await r.text();
|
||||
let json;
|
||||
try { json = JSON.parse(text); } catch { json = { raw: text }; }
|
||||
if (!r.ok) {
|
||||
const msg = json.errors?.[0]?.description || json.message || `HTTP ${r.status}`;
|
||||
throw new Error(`Asaas: ${msg}`);
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
// Cria customer (idempotente — Asaas dedup por externalReference)
|
||||
export async function getOrCreateCustomer(user) {
|
||||
// Busca por externalReference primeiro
|
||||
try {
|
||||
const search = await asaasRequest(`/customers?externalReference=user_${user.id}`);
|
||||
if (search.data && search.data.length > 0) return search.data[0].id;
|
||||
} catch (e) { /* ignore search errors, fallback create */ }
|
||||
|
||||
const created = await asaasRequest('/customers', 'POST', {
|
||||
name: user.name || user.email.split('@')[0],
|
||||
email: user.email,
|
||||
externalReference: `user_${user.id}`,
|
||||
notificationDisabled: false,
|
||||
});
|
||||
return created.id;
|
||||
}
|
||||
|
||||
// Cria cobrança avulsa (1 pagamento). PIX/CREDIT_CARD/BOLETO.
|
||||
// dueDays: até quantos dias da hoje vence (PIX padrão 3 dias, boleto 7)
|
||||
export async function createPayment({ customerId, plan, cycle, value, billingType, description, dueDays = 3 }) {
|
||||
const due = new Date();
|
||||
due.setDate(due.getDate() + dueDays);
|
||||
const dueStr = due.toISOString().slice(0, 10); // YYYY-MM-DD
|
||||
|
||||
const body = {
|
||||
customer: customerId,
|
||||
billingType, // 'PIX' | 'CREDIT_CARD' | 'BOLETO' | 'UNDEFINED' (deixa cliente escolher)
|
||||
value,
|
||||
dueDate: dueStr,
|
||||
description: description || `Shivao ${plan} (${cycle})`,
|
||||
externalReference: `plan_${plan}_${cycle}_${Date.now()}`,
|
||||
};
|
||||
return asaasRequest('/payments', 'POST', body);
|
||||
}
|
||||
|
||||
// Pega QR Code PIX do payment (se billingType=PIX)
|
||||
export async function getPixQrCode(paymentId) {
|
||||
return asaasRequest(`/payments/${paymentId}/pixQrCode`);
|
||||
}
|
||||
|
||||
// Verifica status atual do payment (pra reconciliação se webhook falhar)
|
||||
export async function getPaymentStatus(paymentId) {
|
||||
return asaasRequest(`/payments/${paymentId}`);
|
||||
}
|
||||
|
||||
// Mapeia status Asaas → ações na licença
|
||||
// PENDING/AWAITING_RISK_ANALYSIS: aguardar
|
||||
// CONFIRMED: pré-confirmado (cartão capturado, ainda não creditado)
|
||||
// RECEIVED: dinheiro caiu, ATIVAR licença
|
||||
// OVERDUE/REFUNDED/RECEIVED_IN_CASH/CHARGEBACK_REQUESTED/REFUND_REQUESTED: handle apropriado
|
||||
export function isPaidStatus(status) {
|
||||
return ['RECEIVED', 'CONFIRMED', 'RECEIVED_IN_CASH'].includes(status);
|
||||
}
|
||||
|
||||
export function isFailedStatus(status) {
|
||||
return ['REFUNDED', 'CHARGEBACK_REQUESTED', 'CHARGEBACK_DISPUTE', 'AWAITING_CHARGEBACK_REVERSAL'].includes(status);
|
||||
}
|
||||
|
||||
// Validação básica de webhook: se ASAAS_WEBHOOK_TOKEN setado, verifica header.
|
||||
// Asaas v3 envia o token no header `asaas-access-token`.
|
||||
export function verifyWebhookToken(headerToken) {
|
||||
if (!ASAAS_WEBHOOK_TOKEN) {
|
||||
console.warn('[billing] ASAAS_WEBHOOK_TOKEN não configurado — webhook aceita sem verificação');
|
||||
return true;
|
||||
}
|
||||
return headerToken === ASAAS_WEBHOOK_TOKEN;
|
||||
}
|
||||
|
||||
// Calcula expiração baseada em ciclo + plano
|
||||
export function computeExpiresAt(cycle) {
|
||||
const now = Date.now();
|
||||
const oneDay = 24 * 3600 * 1000;
|
||||
if (cycle === 'monthly') return now + 30 * oneDay;
|
||||
if (cycle === 'yearly') return now + 365 * oneDay;
|
||||
return now + 30 * oneDay; // default mensal
|
||||
}
|
||||
|
||||
// Preço por plano + ciclo
|
||||
import { PLANS } from './auth.js';
|
||||
export function priceFor(plan, cycle) {
|
||||
const p = PLANS[plan];
|
||||
if (!p) throw new Error('Plano inválido');
|
||||
if (cycle === 'monthly') return p.price_brl_monthly;
|
||||
if (cycle === 'yearly') return p.price_brl_yearly;
|
||||
throw new Error('Ciclo inválido');
|
||||
}
|
||||
|
|
@ -111,6 +111,26 @@ db.exec(`
|
|||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sp_token_ts ON share_positions(token, ts DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS payments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
asaas_payment_id TEXT UNIQUE,
|
||||
asaas_customer_id TEXT,
|
||||
plan TEXT NOT NULL,
|
||||
cycle TEXT NOT NULL,
|
||||
value REAL NOT NULL,
|
||||
billing_type TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'PENDING',
|
||||
invoice_url TEXT,
|
||||
due_date INTEGER NOT NULL,
|
||||
paid_at INTEGER,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_payments_user ON payments(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_payments_asaas ON payments(asaas_payment_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts INTEGER NOT NULL,
|
||||
|
|
@ -361,6 +381,41 @@ export function cleanupExpiredShares() {
|
|||
return toDelete.length;
|
||||
}
|
||||
|
||||
// ---- Payments (Asaas) ----
|
||||
export function createPayment(p) {
|
||||
const now = Date.now();
|
||||
const info = db.prepare(`INSERT INTO payments (user_id, asaas_payment_id, asaas_customer_id, plan, cycle, value, billing_type, status, invoice_url, due_date, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||
.run(p.user_id, p.asaas_payment_id || null, p.asaas_customer_id || null, p.plan, p.cycle, p.value, p.billing_type, p.status || 'PENDING', p.invoice_url || null, p.due_date, now, now);
|
||||
return info.lastInsertRowid;
|
||||
}
|
||||
export function findPaymentByAsaasId(asaasId) {
|
||||
return db.prepare('SELECT * FROM payments WHERE asaas_payment_id = ?').get(asaasId);
|
||||
}
|
||||
export function updatePaymentStatus(asaasId, status, paidAt) {
|
||||
db.prepare('UPDATE payments SET status = ?, paid_at = ?, updated_at = ? WHERE asaas_payment_id = ?')
|
||||
.run(status, paidAt || null, Date.now(), asaasId);
|
||||
}
|
||||
export function listUserPayments(userId, limit = 50) {
|
||||
return db.prepare('SELECT * FROM payments WHERE user_id = ? ORDER BY created_at DESC LIMIT ?').all(userId, limit);
|
||||
}
|
||||
export function setUserAsaasCustomerId(userId, customerId) {
|
||||
// Cache de mapeamento user → asaas customer pra reaproveitar em pagamentos futuros
|
||||
// Guardado no campo do user (vou adicionar coluna se não existir)
|
||||
try {
|
||||
const cols = db.prepare("PRAGMA table_info(users)").all();
|
||||
if (!cols.some(c => c.name === 'asaas_customer_id')) {
|
||||
db.exec('ALTER TABLE users ADD COLUMN asaas_customer_id TEXT');
|
||||
}
|
||||
db.prepare('UPDATE users SET asaas_customer_id = ? WHERE id = ?').run(customerId, userId);
|
||||
} catch (e) { console.warn('[db] setAsaasCustomerId:', e.message); }
|
||||
}
|
||||
export function getUserAsaasCustomerId(userId) {
|
||||
try {
|
||||
const row = db.prepare('SELECT asaas_customer_id FROM users WHERE id = ?').get(userId);
|
||||
return row?.asaas_customer_id || null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// ---- Audit log (per-user) ----
|
||||
export function audit(userId, action, entity, entityId, summary, ip) {
|
||||
db.prepare('INSERT INTO audit_log (user_id, ts, action, entity, entity_id, summary, ip) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
||||
|
|
|
|||
|
|
@ -6,8 +6,9 @@ import fs from 'node:fs';
|
|||
import { fileURLToPath } from 'node:url';
|
||||
import * as db from './db.js';
|
||||
import { dispatchAlarm, dispatchTest, listConfiguredChannels } from './notifications.js';
|
||||
import { validate, setStateSchema, updateZonesSchema, signupSchema, loginSchema } from './schemas/index.js';
|
||||
import { validate, setStateSchema, updateZonesSchema, signupSchema, loginSchema, checkoutSchema } from './schemas/index.js';
|
||||
import { hashPassword, verifyPassword, signAccessToken, signRefreshToken, verifyToken, PLANS, planFeatures } from './auth.js';
|
||||
import * as billing from './billing.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PORT = parseInt(process.env.PORT || '3000');
|
||||
|
|
@ -131,9 +132,123 @@ app.get('/api/license', requireAuth, (req, res) => {
|
|||
expires_at: lic.expires_at,
|
||||
features: planFeatures(lic.plan),
|
||||
plans: PLANS,
|
||||
billingEnabled: billing.isAsaasConfigured(),
|
||||
});
|
||||
});
|
||||
|
||||
// ===== Billing endpoints (Asaas) =====
|
||||
app.get('/api/billing/status', (req, res) => {
|
||||
res.json({ configured: billing.isAsaasConfigured() });
|
||||
});
|
||||
|
||||
app.post('/api/billing/checkout', requireAuth, validate(checkoutSchema), async (req, res) => {
|
||||
if (!billing.isAsaasConfigured()) return res.status(503).json({ error: 'Billing não configurado no servidor (ASAAS_API_KEY ausente)' });
|
||||
if (req.user.viaBoatToken) return res.status(403).json({ error: 'Use uma conta com login (não BOAT_TOKEN) pra fazer upgrade' });
|
||||
|
||||
const { plan, cycle, billingType } = req.body;
|
||||
try {
|
||||
const value = billing.priceFor(plan, cycle);
|
||||
let customerId = db.getUserAsaasCustomerId(req.user.id);
|
||||
if (!customerId) {
|
||||
customerId = await billing.getOrCreateCustomer(req.user);
|
||||
db.setUserAsaasCustomerId(req.user.id, customerId);
|
||||
}
|
||||
const payment = await billing.createPayment({
|
||||
customerId, plan, cycle, value, billingType,
|
||||
description: `Shivao ${PLANS[plan].name} (${cycle === 'monthly' ? 'mensal' : 'anual'})`,
|
||||
});
|
||||
db.createPayment({
|
||||
user_id: req.user.id,
|
||||
asaas_payment_id: payment.id,
|
||||
asaas_customer_id: customerId,
|
||||
plan, cycle, value, billing_type: billingType,
|
||||
status: payment.status,
|
||||
invoice_url: payment.invoiceUrl,
|
||||
due_date: new Date(payment.dueDate).getTime(),
|
||||
});
|
||||
db.audit(req.user.id, 'checkout_created', 'payment', payment.id, { plan, cycle, value, billingType }, req.ip);
|
||||
|
||||
// Pra PIX, busca QR code
|
||||
let pix = null;
|
||||
if (billingType === 'PIX') {
|
||||
try { pix = await billing.getPixQrCode(payment.id); } catch (e) { console.warn('[pix qrcode]', e.message); }
|
||||
}
|
||||
res.json({
|
||||
paymentId: payment.id,
|
||||
invoiceUrl: payment.invoiceUrl,
|
||||
bankSlipUrl: payment.bankSlipUrl,
|
||||
status: payment.status,
|
||||
value: payment.value,
|
||||
dueDate: payment.dueDate,
|
||||
pix: pix ? { qrCode: pix.encodedImage, payload: pix.payload, expiresAt: pix.expirationDate } : null,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[checkout]', e);
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/billing/payment/:id', requireAuth, async (req, res) => {
|
||||
const local = db.findPaymentByAsaasId(req.params.id);
|
||||
if (!local || local.user_id !== req.user.id) return res.status(404).json({ error: 'not found' });
|
||||
// Reconcilia com Asaas se ainda PENDING (pra caso webhook ter falhado)
|
||||
if (local.status === 'PENDING' && billing.isAsaasConfigured()) {
|
||||
try {
|
||||
const fresh = await billing.getPaymentStatus(req.params.id);
|
||||
if (fresh.status !== local.status) {
|
||||
db.updatePaymentStatus(req.params.id, fresh.status, billing.isPaidStatus(fresh.status) ? Date.now() : null);
|
||||
if (billing.isPaidStatus(fresh.status)) {
|
||||
db.setLicense(local.user_id, local.plan, billing.computeExpiresAt(local.cycle), local.asaas_payment_id);
|
||||
db.audit(local.user_id, 'license_activated', 'payment', req.params.id, { plan: local.plan, cycle: local.cycle, source: 'reconcile' }, req.ip);
|
||||
}
|
||||
}
|
||||
return res.json({ ...local, status: fresh.status });
|
||||
} catch (e) { /* fallthrough returns cached */ }
|
||||
}
|
||||
res.json(local);
|
||||
});
|
||||
|
||||
app.get('/api/billing/payments', requireAuth, (req, res) => {
|
||||
res.json(db.listUserPayments(req.user.id));
|
||||
});
|
||||
|
||||
// Webhook Asaas: chama isso quando status muda. Precisa ser configurado no painel Asaas → Integrações
|
||||
// URL: https://shivao.pontualtech.work/api/billing/asaas-webhook
|
||||
// Header asaas-access-token: ASAAS_WEBHOOK_TOKEN (defina o mesmo no Coolify env)
|
||||
app.post('/api/billing/asaas-webhook', (req, res) => {
|
||||
const headerToken = req.headers['asaas-access-token'];
|
||||
if (!billing.verifyWebhookToken(headerToken)) {
|
||||
console.warn('[asaas-webhook] invalid token');
|
||||
return res.status(401).json({ error: 'Invalid webhook token' });
|
||||
}
|
||||
const event = req.body;
|
||||
if (!event || !event.event || !event.payment) {
|
||||
return res.status(400).json({ error: 'Invalid payload' });
|
||||
}
|
||||
const p = event.payment;
|
||||
const local = db.findPaymentByAsaasId(p.id);
|
||||
if (!local) {
|
||||
console.warn('[asaas-webhook] payment not found:', p.id);
|
||||
// Aceitar 200 mesmo assim — Asaas não retentar
|
||||
return res.json({ ok: true, ignored: true });
|
||||
}
|
||||
db.updatePaymentStatus(p.id, p.status, billing.isPaidStatus(p.status) ? Date.now() : null);
|
||||
// Ativar licença se pago
|
||||
if (billing.isPaidStatus(p.status)) {
|
||||
db.setLicense(local.user_id, local.plan, billing.computeExpiresAt(local.cycle), p.id);
|
||||
db.audit(local.user_id, 'license_activated', 'payment', p.id, { plan: local.plan, cycle: local.cycle, event: event.event }, req.ip);
|
||||
console.log(`[asaas-webhook] license activated user=${local.user_id} plan=${local.plan}`);
|
||||
} else if (billing.isFailedStatus(p.status)) {
|
||||
// Pagamento estornado: revogar se a licença vinculada a esse payment
|
||||
const lic = db.getActiveLicense(local.user_id);
|
||||
if (lic && lic.asaas_subscription_id === p.id) {
|
||||
db.setLicense(local.user_id, 'free', null, null);
|
||||
db.audit(local.user_id, 'license_revoked', 'payment', p.id, { reason: p.status }, req.ip);
|
||||
}
|
||||
}
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// Digital Asset Links — verifica TWA do APK Android e remove a barra de URL do Chrome
|
||||
app.get('/.well-known/assetlinks.json', (req, res) => {
|
||||
res.json([{
|
||||
|
|
|
|||
|
|
@ -42,6 +42,13 @@ export const loginSchema = z.object({
|
|||
password: z.string().min(1).max(100),
|
||||
});
|
||||
|
||||
// ===== Billing (checkout) =====
|
||||
export const checkoutSchema = z.object({
|
||||
plan: z.enum(['pro', 'captain']),
|
||||
cycle: z.enum(['monthly', 'yearly']),
|
||||
billingType: z.enum(['PIX', 'CREDIT_CARD', 'BOLETO', 'UNDEFINED']).default('PIX'),
|
||||
});
|
||||
|
||||
// ===== Middleware genérico =====
|
||||
// Uso: app.post('/x', requireAuth, validate(mySchema), handler)
|
||||
// Em caso de falha: 400 com até 5 issues do Zod (path + message).
|
||||
|
|
|
|||
Loading…
Reference in a new issue