From ca9de52ae1bb3af35652a789b58e372601922c43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?PontualTech=20/=20Karl=C3=A3o?= Date: Mon, 27 Apr 2026 15:55:08 -0300 Subject: [PATCH] =?UTF-8?q?feat(billing):=20integra=C3=A7=C3=A3o=20Asaas?= =?UTF-8?q?=20=E2=80=94=20checkout=20PIX/Cart=C3=A3o/Boleto=20+=20webhook?= =?UTF-8?q?=20+=20UI=20upgrade?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- app/diario-bordo.html | 81 +++++++++++++++++++++++- server/public/index.html | 80 +++++++++++++++++++++++- server/src/billing.js | 119 ++++++++++++++++++++++++++++++++++++ server/src/db.js | 55 +++++++++++++++++ server/src/index.js | 117 ++++++++++++++++++++++++++++++++++- server/src/schemas/index.js | 7 +++ 6 files changed, 456 insertions(+), 3 deletions(-) create mode 100644 server/src/billing.js diff --git a/app/diario-bordo.html b/app/diario-bordo.html index c2e40d7..ffa4a2a 100644 --- a/app/diario-bordo.html +++ b/app/diario-bordo.html @@ -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=`
${escapeHtml(u.email)} ${u.name?'· '+escapeHtml(u.name):''}
Plano: ${planLabel} ${lic.expires_at?'· expira '+new Date(lic.expires_at).toLocaleDateString('pt-BR'):''}
${lic.plan==='free'?'':''}`; + box.innerHTML=`
${escapeHtml(u.email)} ${u.name?'· '+escapeHtml(u.name):''}
Plano: ${planLabel} ${lic.expires_at?'· expira '+new Date(lic.expires_at).toLocaleDateString('pt-BR'):''}
${lic.plan==='free'?'':''}`; }else{ box.innerHTML=`
CONTA · multi-usuário SaaS
@@ -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=``; + 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='Criando cobrança…'; + 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=`
Escaneie o QR Code PIX:

Copia e cola:
${data.pix.payload||''}
`; + }else if(data.invoiceUrl){ + html=`Abrir página de pagamento ↗`; + } + html+=`
Após pagar, sua licença ativa em segundos. Pode fechar esta tela e voltar quando quiser.
`; + html+=``; + out.innerHTML=html; + }catch(e){out.innerHTML='Erro: '+e.message+''} +} +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(); diff --git a/server/public/index.html b/server/public/index.html index cecd68b..4c9f482 100644 --- a/server/public/index.html +++ b/server/public/index.html @@ -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=`
${escapeHtml(u.email)} ${u.name?'· '+escapeHtml(u.name):''}
Plano: ${planLabel} ${lic.expires_at?'· expira '+new Date(lic.expires_at).toLocaleDateString('pt-BR'):''}
${lic.plan==='free'?'':''}`; + box.innerHTML=`
${escapeHtml(u.email)} ${u.name?'· '+escapeHtml(u.name):''}
Plano: ${planLabel} ${lic.expires_at?'· expira '+new Date(lic.expires_at).toLocaleDateString('pt-BR'):''}
${lic.plan==='free'?'':''}`; }else{ box.innerHTML=`
CONTA · multi-usuário SaaS
@@ -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=``; + 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='Criando cobrança…'; + 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=`
Escaneie o QR Code PIX:

Copia e cola:
${data.pix.payload||''}
`; + }else if(data.invoiceUrl){ + html=`Abrir página de pagamento ↗`; + } + html+=`
Após pagar, sua licença ativa em segundos. Pode fechar esta tela e voltar quando quiser.
`; + html+=``; + out.innerHTML=html; + }catch(e){out.innerHTML='Erro: '+e.message+''} +} +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(); diff --git a/server/src/billing.js b/server/src/billing.js new file mode 100644 index 0000000..43ca3ed --- /dev/null +++ b/server/src/billing.js @@ -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'); +} diff --git a/server/src/db.js b/server/src/db.js index 6eaf850..4dbbe90 100644 --- a/server/src/db.js +++ b/server/src/db.js @@ -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 (?, ?, ?, ?, ?, ?, ?)') diff --git a/server/src/index.js b/server/src/index.js index b3a950a..6cc9db7 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -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([{ diff --git a/server/src/schemas/index.js b/server/src/schemas/index.js index 84f21ee..a5563e0 100644 --- a/server/src/schemas/index.js +++ b/server/src/schemas/index.js @@ -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).