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=`Upgrade · escolha seu plano
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
`;
+ 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=`Upgrade · escolha seu plano
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
`;
+ 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).