asaas: Tier 1 Fase A foundation — migrations + service + edge function stubs

DESIGN_ASAAS_GATEWAY.md documenta arquitetura. Schema novo: 2
migrations (tables + RLS) cobrindo asaas_customers + asaas_payments
+ asaas_webhook_events. Client service asaasGatewayService.js no
features/financeiro. 3 Edge Function stubs (create-payment-record,
cancel-payment, sync-payment) — webhook financial_records eh Fase B.

Bloqueadores Fase B (implementacao real): user precisa criar conta
Asaas, gerar API keys, configurar webhook, setar ENV vars no
Supabase. Decisao modelo de negocio (A/B/C) tambem pendente.
Stops marcados claramente no DESIGN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-05-21 04:20:52 -03:00
parent ee2967a075
commit de3898878a
7 changed files with 1043 additions and 0 deletions
@@ -0,0 +1,182 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/financeiro/services/asaasGatewayService.js
|
| Cliente JS pra orquestrar cobranças Asaas via Edge Functions.
| Browser NUNCA fala direto com Asaas — API key não pode chegar aqui.
|
| Arquitetura: ver development/02-auditoria/DESIGN_ASAAS_GATEWAY.md
|
| ⚠️ FOUNDATION SKELETON. Edge Functions ainda são stubs — chamadas vão
| retornar erro até deploy real. Requer credenciais Asaas configuradas.
|
| Pré-requisitos (do user):
| 1. Migration 20260521000001_asaas_gateway_tables.sql aplicada
| 2. Migration 20260521000002_asaas_gateway_rls.sql aplicada
| 3. Edge Functions deployadas (asaas-create-payment-record, asaas-cancel-payment)
| 4. API key Asaas inserida em payment_settings (via UI futura ou SQL manual)
| 5. payment_settings.asaas_enabled = true
|--------------------------------------------------------------------------
*/
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
// ─── Status mapping Asaas → financial_records.status ────────────────────────
const ASAAS_TO_STATUS = {
PENDING: 'pending',
RECEIVED: 'paid',
CONFIRMED: 'paid',
RECEIVED_IN_CASH: 'paid',
OVERDUE: 'overdue',
REFUNDED: 'refunded',
CHARGEBACK_REQUESTED: 'refunded',
CHARGEBACK_DISPUTE: 'cancelled',
DELETED: 'cancelled'
};
export function mapAsaasStatus(asaasStatus) {
return ASAAS_TO_STATUS[asaasStatus] || 'pending';
}
// ─── Validations ────────────────────────────────────────────────────────────
function assertTenantId(tenantId) {
if (!tenantId || tenantId === 'null' || tenantId === 'undefined') {
throw new Error('Tenant ativo inválido pra operar Asaas.');
}
}
function resolveTenantId() {
const tenantStore = useTenantStore();
const tid = tenantStore.activeTenantId || tenantStore.tenantId;
assertTenantId(tid);
return tid;
}
// ─── Core API ───────────────────────────────────────────────────────────────
/**
* Cria cobrança Asaas pra um financial_record existente.
*
* Invoca Edge Function `asaas-create-payment-record` que:
* 1. Lê financial_record + patient
* 2. Garante asaas_customer existe (cascade pra create-customer-patient)
* 3. POST /payments no Asaas com externalReference=financial_record.id
* 4. INSERT asaas_payments + UPDATE financial_records.payment_link
*
* @param {string} financialRecordId
* @param {Object} opts
* @param {'PIX'|'BOLETO'|'CREDIT_CARD'} [opts.method='PIX']
* @param {string} [opts.dueDate] - YYYY-MM-DD. Default = financial_record.due_date
* @returns {Promise<{asaas_payment_id, payment_url, pix_qr_code?, pix_copy_paste?, bank_slip_url?}>}
*/
export async function createPaymentForRecord(financialRecordId, opts = {}) {
if (!financialRecordId) throw new Error('financialRecordId obrigatório.');
const tenantId = resolveTenantId();
const method = opts.method || 'PIX';
if (!['PIX', 'BOLETO', 'CREDIT_CARD'].includes(method)) {
throw new Error(`Método inválido: ${method}. Aceitos: PIX, BOLETO, CREDIT_CARD.`);
}
const { data, error } = await supabase.functions.invoke('asaas-create-payment-record', {
body: {
tenant_id: tenantId,
financial_record_id: financialRecordId,
billing_type: method,
due_date: opts.dueDate || null
}
});
if (error) throw new Error(formatEdgeError(error));
if (!data?.ok) throw new Error(data?.error || 'Falha ao criar cobrança Asaas.');
return data.payment;
}
/**
* Cancela cobrança Asaas. Não afeta o financial_record diretamente — webhook
* processará PAYMENT_DELETED e fará o sync.
*
* @param {string} asaasPaymentId
*/
export async function cancelPayment(asaasPaymentId) {
if (!asaasPaymentId) throw new Error('asaasPaymentId obrigatório.');
const tenantId = resolveTenantId();
const { data, error } = await supabase.functions.invoke('asaas-cancel-payment', {
body: { tenant_id: tenantId, asaas_payment_id: asaasPaymentId }
});
if (error) throw new Error(formatEdgeError(error));
if (!data?.ok) throw new Error(data?.error || 'Falha ao cancelar cobrança.');
return data;
}
/**
* Busca info de pagamento Asaas (PIX QR code, boleto URL, status atual).
* Read-only: vai na tabela asaas_payments. Não chama API Asaas.
*
* @param {string} financialRecordId
* @returns {Promise<Object|null>}
*/
export async function getPaymentForRecord(financialRecordId) {
if (!financialRecordId) return null;
const tenantId = resolveTenantId();
const { data, error } = await supabase
.from('asaas_payments')
.select('id, asaas_payment_id, billing_type, status, value, due_date, payment_date, invoice_url, payment_url, bank_slip_url, pix_qr_code, pix_copy_paste, cancelled_at')
.eq('tenant_id', tenantId)
.eq('financial_record_id', financialRecordId)
.is('cancelled_at', null)
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle();
if (error) throw error;
return data || null;
}
/**
* Sincroniza força um pagamento Asaas (consulta API Asaas + atualiza row local).
* Use quando suspeitar que webhook falhou (record fica pending mas paciente diz que pagou).
*
* @param {string} asaasPaymentId
*/
export async function syncPayment(asaasPaymentId) {
if (!asaasPaymentId) throw new Error('asaasPaymentId obrigatório.');
const tenantId = resolveTenantId();
const { data, error } = await supabase.functions.invoke('asaas-sync-payment', {
body: { tenant_id: tenantId, asaas_payment_id: asaasPaymentId }
});
if (error) throw new Error(formatEdgeError(error));
if (!data?.ok) throw new Error(data?.error || 'Falha ao sincronizar.');
return data;
}
/**
* Verifica se gateway Asaas está habilitado pro tenant ativo.
* Usado pra mostrar/esconder botões de cobrança Asaas na UI.
*/
export async function isGatewayEnabled() {
const tenantId = resolveTenantId();
const { data, error } = await supabase.from('payment_settings').select('asaas_enabled, asaas_environment').eq('tenant_id', tenantId).maybeSingle();
if (error) return false;
return !!data?.asaas_enabled;
}
// ─── Helpers ────────────────────────────────────────────────────────────────
function formatEdgeError(err) {
if (typeof err === 'string') return err;
if (err?.message) return err.message;
if (err?.error) return String(err.error);
return 'Erro desconhecido na Edge Function.';
}