Files
agenciapsilmno/development/02-auditoria/DESIGN_ASAAS_GATEWAY.md
T
Leonardo de3898878a 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>
2026-05-21 04:20:52 -03:00

326 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Design — Asaas Gateway (Tier 1: Cobrança de Paciente · PIX + Boleto)
> **Data:** 2026-05-21
> **Tipo:** Design doc + foundation (sem credenciais reais ainda)
> **Resolve:** Item #1-#3 da Fase 1 do ROADMAP (Monetização)
> **Decisões confirmadas:** Tier 1 primeiro (paciente paga terapeuta) · PIX + Boleto · Approach foundation com stops
---
## 1. Estado atual
### 1.1 O que JÁ EXISTE no projeto
- **Edge Function `asaas-webhook`** (`supabase/functions/asaas-webhook/index.ts`) — porém só lida com `whatsapp_credit_purchases`. Token `ASAAS_WEBHOOK_TOKEN` validado.
- **Edge Function `create-whatsapp-credit-charge`** — cria cobrança Asaas para créditos WhatsApp. Pattern de chamada à API Asaas estabelecido.
- **Tabela `whatsapp_credit_purchases`** com coluna `asaas_payment_id`. Modelo: 1 purchase ↔ 1 Asaas payment.
- **Coluna `financial_records.payment_link`** (migration 20260514000001) — espera o URL Asaas quando integração existir.
- **Tabela `payment_settings`** com pix_chave, deposito_*, etc — config manual de pagamento por owner (NÃO é Asaas).
- **Asaas mencionado em 9 arquivos client** — todos relacionados a WhatsApp credits ou docs.
### 1.2 O que FALTA pra patient billing
- Schema: tabelas `asaas_customers` (mapping patient → Asaas customer) e `asaas_payments` (1 row por cobrança gerada)
- Schema: ENCRYPTED storage da API key Asaas por tenant (se modelo B — ver §3)
- Edge Function `asaas-create-customer-patient` — upsert customer no Asaas
- Edge Function `asaas-create-payment-record` — gera cobrança a partir de financial_record
- Edge Function `asaas-cancel-payment` — cancela
- Edge Function `asaas-webhook` ESTENDIDA — handler pra eventos de financial_records (atualmente só whatsapp_credit_purchases)
- Cliente JS: `asaasGatewayService.js` em `features/financeiro/services/`
- UI: botão "Gerar cobrança Asaas" no record do `financial_records` (não escopo desta sessão)
---
## 2. Arquitetura
### 2.1 Camadas
```
┌─────────────────────────────────────────────────┐
│ Browser (Vue) │
│ ├ asaasGatewayService.js │
│ │ • createPaymentForRecord(recordId, opts) │ ← invoca Edge Function via supabase.functions.invoke
│ │ • cancelPayment(asaasPaymentId) │
│ │ • getPaymentInfo(asaasPaymentId) │
└─────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────┐
│ Supabase Edge Functions (Deno) │
│ ├ asaas-create-customer-patient │
│ │ • Recebe patient_id │
│ │ • Upsert asaas_customers (cache) │
│ │ • Chama Asaas POST /customers │
│ ├ asaas-create-payment-record │
│ │ • Recebe financial_record_id + method │
│ │ • Garante customer existe (cascade) │
│ │ • Chama Asaas POST /payments │
│ │ • Salva asaas_payments + update financial_records.payment_link │
│ ├ asaas-cancel-payment │
│ │ • Asaas DELETE /payments/:id │
│ └ asaas-webhook (EXTENDER) │
│ • Adiciona handler pra events linkados a │
│ financial_records (não só credits) │
└─────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────┐
│ Asaas REST API (sandbox/prod) │
└─────────────────────────────────────────────────┘
```
### 2.2 Por que Edge Functions e não client-side?
**Crítico:** API key do Asaas NUNCA pode chegar ao browser. Browser vê script + DevTools = key vaza = qualquer pessoa cria cobrança em nome da plataforma/tenant. Edge Functions rodam server-side, key fica em env vars do Supabase.
---
## 3. Modelo de negócio (DECISÃO PENDENTE)
**Quem detém a conta Asaas que recebe o dinheiro?**
### Opção A — Plataforma (marketplace)
- 1 conta Asaas global da plataforma AgenciaPsi
- Plataforma recebe TUDO + repassa pra terapeutas (split payment ou reconciliação manual)
- Asaas tem feature de SubAccounts/Split — pode ser configurado
- **Pros:** simples, 1 chave ENV no Supabase
- **Cons:** plataforma fica como intermediadora financeira (regulatório + impostos + compliance)
### Opção B — Tenant-level (recommended pra MVP solo-therapist)
- Cada tenant tem SUA conta Asaas
- API key encrypted em `payment_settings.asaas_api_key` (Supabase Vault ou pgsodium)
- Terapeuta recebe direto na própria conta
- Edge Function lê chave do tenant requisitante
- **Pros:** sem complexidade regulatória pra plataforma
- **Cons:** terapeuta precisa configurar Asaas próprio (UX onboarding)
### Opção C — Híbrido
- Plataforma cobra mensalidade SaaS via SUA Asaas (Tier 2 — fora desta sessão)
- Terapeuta cobra paciente via SUA Asaas (Tier 1)
- 2 contas em mundos separados
**Recomendação desta sessão:** **Opção B (Tenant-level)** pra Tier 1. Tier 2 (SaaS subscriptions) decide depois — pode ser A com mesma infra.
---
## 4. Schema additions
### 4.1 Tabela `asaas_customers`
Mapping patient ↔ Asaas customer. Cacheado (não recriar a cada cobrança).
```sql
CREATE TABLE public.asaas_customers (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL,
patient_id uuid NOT NULL REFERENCES patients(id) ON DELETE CASCADE,
asaas_customer_id text NOT NULL,
-- ambiente: sandbox ou prod (mesmo patient pode ter ambos)
environment text NOT NULL DEFAULT 'prod' CHECK (environment IN ('sandbox', 'prod')),
-- dados cacheados (sincronizados quando atualizar)
name text NOT NULL,
email text,
cpf_cnpj text,
phone text,
address jsonb,
-- audit
created_at timestamptz DEFAULT now() NOT NULL,
updated_at timestamptz DEFAULT now() NOT NULL,
deleted_at timestamptz,
UNIQUE (tenant_id, patient_id, environment)
);
CREATE INDEX idx_asaas_customers_lookup
ON public.asaas_customers (tenant_id, patient_id)
WHERE deleted_at IS NULL;
```
### 4.2 Tabela `asaas_payments`
1 row por cobrança Asaas gerada. Link com financial_record.
```sql
CREATE TABLE public.asaas_payments (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL,
financial_record_id uuid NOT NULL REFERENCES financial_records(id) ON DELETE CASCADE,
asaas_customer_id uuid REFERENCES asaas_customers(id),
asaas_payment_id text NOT NULL,
asaas_invoice_id text,
environment text NOT NULL DEFAULT 'prod' CHECK (environment IN ('sandbox', 'prod')),
billing_type text NOT NULL CHECK (billing_type IN ('PIX', 'BOLETO', 'CREDIT_CARD', 'UNDEFINED')),
status text NOT NULL, -- raw Asaas status; mapeamento pra financial_records.status no JS
value numeric(10, 2) NOT NULL,
net_value numeric(10, 2),
due_date date NOT NULL,
payment_date timestamptz,
invoice_url text,
payment_url text, -- URL pra paciente abrir e pagar
bank_slip_url text, -- PDF boleto
pix_qr_code text, -- base64 do QR
pix_copy_paste text, -- payload PIX
-- audit
created_at timestamptz DEFAULT now() NOT NULL,
updated_at timestamptz DEFAULT now() NOT NULL,
cancelled_at timestamptz,
UNIQUE (asaas_payment_id, environment)
);
CREATE INDEX idx_asaas_payments_record
ON public.asaas_payments (financial_record_id);
CREATE INDEX idx_asaas_payments_lookup
ON public.asaas_payments (tenant_id, status, due_date);
```
### 4.3 Tabela `asaas_webhook_events`
Idempotência + audit. Cada webhook recebido vai aqui ANTES de processar.
```sql
CREATE TABLE public.asaas_webhook_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
event_id text, -- id que Asaas mandar (se mandar)
event_type text NOT NULL, -- PAYMENT_RECEIVED, PAYMENT_OVERDUE, etc
asaas_payment_id text, -- pra linkar com asaas_payments
payload jsonb NOT NULL, -- raw event pra debug
processed_at timestamptz, -- quando processou; NULL = pendente/falha
processing_error text,
received_at timestamptz DEFAULT now() NOT NULL,
UNIQUE (event_id) DEFERRABLE INITIALLY DEFERRED
);
CREATE INDEX idx_asaas_webhook_events_payment
ON public.asaas_webhook_events (asaas_payment_id);
```
### 4.4 Coluna ENCRYPTED na `payment_settings`
Tenant-level API key (Opção B). Usa pgsodium ou Supabase Vault.
```sql
-- Sandbox e prod separados — terapeuta começa em sandbox, migra pra prod quando OK.
ALTER TABLE public.payment_settings
ADD COLUMN IF NOT EXISTS asaas_api_key_sandbox text, -- prefixado com "$pgsodium-encrypted$" via trigger
ADD COLUMN IF NOT EXISTS asaas_api_key_prod text,
ADD COLUMN IF NOT EXISTS asaas_environment text DEFAULT 'sandbox' CHECK (asaas_environment IN ('sandbox', 'prod')),
ADD COLUMN IF NOT EXISTS asaas_webhook_token text;
```
**⚠️ Atenção:** API keys EM TEXTO NA TABELA é vulnerabilidade séria. Em produção precisa Supabase Vault (pgsodium) ou KMS externo. Pra MVP sandbox dá pra deixar plaintext + RLS restritiva, mas tem que documentar como dívida.
---
## 5. Status mapping Asaas → financial_records
| Asaas | financial_records.status |
|---|---|
| PENDING | pending |
| RECEIVED / CONFIRMED | paid |
| RECEIVED_IN_CASH | paid (manual) |
| OVERDUE | overdue |
| REFUNDED / CHARGEBACK_REQUESTED | refunded |
| DELETED / CHARGEBACK_DISPUTE | cancelled |
---
## 6. Flow completo (happy path) — Tier 1 PIX
1. Terapeuta cria sessão na agenda → trigger gera `financial_records` row (status=pending, payment_method=null, payment_link=null)
2. Terapeuta vê record na UI e clica **"Gerar cobrança Asaas (PIX)"**
3. Cliente JS chama `asaasGatewayService.createPaymentForRecord(recordId, { method: 'PIX' })`
4. Service invoca Edge Function `asaas-create-payment-record`
5. Edge Function:
- Lê financial_record + patient + tenant_settings (API key)
- Garante asaas_customers row (chama asaas-create-customer-patient se não existe)
- POST `https://sandbox.asaas.com/api/v3/payments` com `customer`, `value`, `dueDate`, `billingType=PIX`, `externalReference=<financial_record_id>`
- Asaas retorna `id`, `invoiceUrl`, e (pra PIX) `id` de QR code
- Edge Function chama POST `/payments/:id/pixQrCode` pra pegar QR base64
- INSERT em `asaas_payments` com toda metadata
- UPDATE `financial_records.payment_link = invoiceUrl, payment_method = 'pix_asaas'`
6. Service retorna `{ paymentUrl, qrCode, copyPaste }` pro cliente
7. UI mostra QR code + link pra paciente
8. Paciente paga via PIX
9. Asaas dispara webhook PAYMENT_RECEIVED → Edge Function `asaas-webhook`:
- INSERT em `asaas_webhook_events` (idempotência via event_id)
- Busca `asaas_payment` por `asaas_payment_id`
- Se status=='paid' já: skip
- UPDATE `asaas_payments.status='RECEIVED'`, `payment_date=now()`
- UPDATE `financial_records.status='paid'`, `paid_at=now()`
- Marca event como `processed_at=now()`
---
## 7. Decisões de implementação
| Decisão | Confirmada |
|---|---|
| Tier 1 (paciente paga terapeuta) primeiro | ✅ |
| PIX + boleto primeiro (cartão depois) | ✅ |
| Modelo tenant-level (Opção B) | ⚠️ PROPOSTO — confirme antes de implementar |
| Sandbox first, prod depois | ⚠️ default — confirme |
| Storage de API key plaintext em `payment_settings` (com RLS) pra MVP | ⚠️ DÍVIDA conhecida — vault depois |
| `externalReference` no Asaas = financial_records.id | ⚠️ PROPOSTO — facilita reconciliação |
| Webhook compartilha mesma Edge Function (`asaas-webhook` estendida) | ⚠️ PROPOSTO — evita duplicar token validation |
---
## 8. Checklist do que VOCÊ precisa fornecer
Antes da Fase B (implementação real):
- [ ] Criar conta Asaas (https://asaas.com)
- [ ] Habilitar PIX (gera chave PIX automática) + Boleto na conta
- [ ] Pegar API key de SANDBOX (Configurações → Integrações)
- [ ] Configurar webhook no Asaas: `https://<seu-projeto>.supabase.co/functions/v1/asaas-webhook` + token
- [ ] Setar ENV vars no Supabase (Dashboard → Edge Functions → Secrets):
- `ASAAS_API_URL_SANDBOX=https://sandbox.asaas.com/api/v3`
- `ASAAS_API_URL_PROD=https://api.asaas.com/v3`
- `ASAAS_WEBHOOK_TOKEN=<token-aleatorio>` (já existe? checar)
- [ ] Decidir modelo de negócio (Opção A/B/C — §3) — recomendo **B** pra MVP solo
---
## 9. Phasing — entrega faseada
### Fase A — Foundation (esta sessão)
- [x] Design doc (este arquivo)
- [ ] Migration de schema (asaas_customers + asaas_payments + asaas_webhook_events + colunas em payment_settings)
- [ ] Client service `asaasGatewayService.js` (skeleton)
- [ ] Edge Function stubs (3 novas + nota sobre estender webhook existente)
- [ ] README/Checklist no service file
### Fase B — Implementação real (próxima sessão, requer credenciais + decisões §7)
- [ ] Edge Functions: chamadas reais ao Asaas
- [ ] Webhook extension: handler pra `financial_records`
- [ ] UI: botão "Gerar cobrança Asaas" no card do financial_record
- [ ] UI: dialog mostrando QR code PIX + link boleto
### Fase C — Onboarding (após B testar)
- [ ] Página de config Asaas no `/configuracoes/financeiro`
- [ ] Wizard pra terapeuta inserir API key + testar conexão
- [ ] Migration de sandbox→prod com confirm
### Fase D — Avançado (futuro)
- [ ] Cartão on file (Asaas tokenizado)
- [ ] Auto-billing recorrente (sessão realizada → gera Asaas automático)
- [ ] Split payment se Opção A
- [ ] Cobrança SaaS (Tier 2)
---
## 10. Riscos conhecidos
1. **API key vazada** — se plaintext em `payment_settings`, qualquer breach da DB compromete. **Mitigação:** RLS restritiva + Vault em produção.
2. **Duplicate billing** — webhook dispara 2× (retry Asaas). **Mitigação:** `asaas_webhook_events.event_id` UNIQUE + check de status atual antes de mutar.
3. **Cancelamento race** — paciente paga enquanto terapeuta cancela. **Mitigação:** UPDATE `financial_records` só se `status='pending'` (CAS).
4. **Reconciliação manual** — se webhook falha 3× e dá up, paciente pagou mas record fica pending. **Mitigação:** Edge Function `asaas-sync-payments` (manual trigger) que consulta `/payments` por externalReference e força update.
5. **CPF/CNPJ obrigatório no Asaas** — paciente sem CPF não pode receber cobrança. **Mitigação:** validação client-side antes de chamar service.
---
## 11. Referências
- Asaas API docs: https://docs.asaas.com/
- Existing webhook pattern: `supabase/functions/asaas-webhook/index.ts`
- Migration `financial_records.payment_link`: `20260514000001_financial_records_payment_link.sql`
- Memory: `project_agenda_billing_decisoes` (decisões #1, #4, #5, #7, #8 confirmadas; #2/#3/#6 pendentes)
- ROADMAP: `development/04-roadmap/ROADMAP.md` Fase 1.1 (#1-#4 Monetização)