// 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'); }