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>
119 lines
4.3 KiB
JavaScript
119 lines
4.3 KiB
JavaScript
// 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');
|
|
}
|