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:
PontualTech / Karlão 2026-04-27 15:55:08 -03:00
parent a80adc7bdf
commit ca9de52ae1
6 changed files with 456 additions and 3 deletions

View file

@ -2430,7 +2430,7 @@ function renderAuthBox(){
const u=state.auth.user; const u=state.auth.user;
const lic=state.license||{plan:'free',features:[]}; const lic=state.license||{plan:'free',features:[]};
const planLabel={free:'Free (Âncora)',pro:'Pro',captain:'Captain'}[lic.plan]||lic.plan; 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{ }else{
box.innerHTML=` 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> <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} 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(){ async function onAuthSignupClick(){
const e=document.getElementById('signup-email').value.trim(); const e=document.getElementById('signup-email').value.trim();
const n=document.getElementById('signup-name').value.trim(); const n=document.getElementById('signup-name').value.trim();

View file

@ -2410,7 +2410,7 @@ function renderAuthBox(){
const u=state.auth.user; const u=state.auth.user;
const lic=state.license||{plan:'free',features:[]}; const lic=state.license||{plan:'free',features:[]};
const planLabel={free:'Free (Âncora)',pro:'Pro',captain:'Captain'}[lic.plan]||lic.plan; 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{ }else{
box.innerHTML=` 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> <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} 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(){ async function onAuthSignupClick(){
const e=document.getElementById('signup-email').value.trim(); const e=document.getElementById('signup-email').value.trim();
const n=document.getElementById('signup-name').value.trim(); const n=document.getElementById('signup-name').value.trim();

119
server/src/billing.js Normal file
View 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');
}

View file

@ -111,6 +111,26 @@ db.exec(`
); );
CREATE INDEX IF NOT EXISTS idx_sp_token_ts ON share_positions(token, ts DESC); 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 ( CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL, ts INTEGER NOT NULL,
@ -361,6 +381,41 @@ export function cleanupExpiredShares() {
return toDelete.length; 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) ---- // ---- Audit log (per-user) ----
export function audit(userId, action, entity, entityId, summary, ip) { 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 (?, ?, ?, ?, ?, ?, ?)') db.prepare('INSERT INTO audit_log (user_id, ts, action, entity, entity_id, summary, ip) VALUES (?, ?, ?, ?, ?, ?, ?)')

View file

@ -6,8 +6,9 @@ import fs from 'node:fs';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import * as db from './db.js'; import * as db from './db.js';
import { dispatchAlarm, dispatchTest, listConfiguredChannels } from './notifications.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 { 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 __dirname = path.dirname(fileURLToPath(import.meta.url));
const PORT = parseInt(process.env.PORT || '3000'); const PORT = parseInt(process.env.PORT || '3000');
@ -131,9 +132,123 @@ app.get('/api/license', requireAuth, (req, res) => {
expires_at: lic.expires_at, expires_at: lic.expires_at,
features: planFeatures(lic.plan), features: planFeatures(lic.plan),
plans: PLANS, 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 // Digital Asset Links — verifica TWA do APK Android e remove a barra de URL do Chrome
app.get('/.well-known/assetlinks.json', (req, res) => { app.get('/.well-known/assetlinks.json', (req, res) => {
res.json([{ res.json([{

View file

@ -42,6 +42,13 @@ export const loginSchema = z.object({
password: z.string().min(1).max(100), 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 ===== // ===== Middleware genérico =====
// Uso: app.post('/x', requireAuth, validate(mySchema), handler) // Uso: app.post('/x', requireAuth, validate(mySchema), handler)
// Em caso de falha: 400 com até 5 issues do Zod (path + message). // Em caso de falha: 400 com até 5 issues do Zod (path + message).