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:
@@ -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.';
|
||||
}
|
||||
Reference in New Issue
Block a user