23. Billing#
Endpoints da plataforma SaaS de billing — assinaturas, planos, add-ons, cartoes, cobrancas e webhooks de provedor de pagamento (Asaas).
Base URL:
https://pay.catcher.oneServiço:biazap-payment(porta 8097 interna) Persistencia: todas as tabelas billing vivem embiazap_master(NÃO no tenant DB) Provider: Asaas atrás da interfaceprovider.PaymentProvider(ACL — substituivel)
23.1 Catalogo (público, sem auth)#
GET /v1/billing/plans#
Lista os planos publicados ordenados por sort_order ASC.
Auth: não requer.
Query params:
| Param | Tipo | Padrão | Descricao |
|---|---|---|---|
cycle |
string | — | Filtra por ciclo (monthly ou yearly). Omita para todos. |
Resposta 200:
{
"data": [
{
"code": "starter_monthly",
"product_code": "starter",
"display_name": "Starter",
"display_subtitle": "Pra comecar",
"cycle": "monthly",
"currency": "BRL",
"price_cents": 9900,
"trial_days": 14,
"sort_order": 10
}
]
}
GET /v1/billing/addons#
Lista os add-ons publicados.
Auth: não requer.
Resposta 200:
{
"data": [
{
"code": "extra_instance",
"display_name": "Instancia adicional",
"description": "Uma sessao WhatsApp extra",
"cycle": "monthly",
"currency": "BRL",
"price_cents": 2900,
"unit_label": "instancia",
"max_quantity": 50
}
]
}
23.1b Infra (público, sem auth)#
GET /health#
Liveness do serviço. Sempre 200.
Resposta 200:
{ "status": "ok", "service": "biazap-payment" }
GET /ready#
Readiness. Stub — sempre 200 (checks de DB/Redis ainda não implementados).
Resposta 200:
{ "status": "ready", "service": "biazap-payment", "checks": {} }
23.2 Profile (perfil de cobranca da empresa)#
GET /v1/billing/profile#
Retorna o BillingProfile da empresa autenticada.
Auth: JWT.
Resposta 200: ProfileDTO (ver 23.8).
Erros: 404 PROFILE_NOT_FOUND quando ainda não foi criado.
POST /v1/billing/profile#
Cria o profile. Cria também o customer no Asaas e armazena asaas_customer_id.
Auth: JWT.
Request body: ProfileDTO SEM os campos id / asaas_customer_id (o servidor preenche).
Resposta 201: ProfileDTO completo.
Erros:
400 INVALID_DOCUMENT— CPF/CNPJ malformado.400 INVALID_JSON— corpo não e JSON válido.404 COMPANY_NOT_FOUND— empresa do token não existe no master DB.409 PROFILE_EXISTS— empresa já tem profile (usePATCH).500 INTERNAL— erro interno do serviço.
PATCH /v1/billing/profile#
Atualiza campos do profile (nome, endereço, telefone, etc.). Mudancas relevantes propagam para o Asaas customer.
Auth: JWT.
Request body: ProfileDTO parcial (apenas os campos a alterar).
Apenas
phone,legal_name,trade_nameeaddresssão mutaveis.document_type/document_numbersão imutaveis após a criação.
Resposta 200: ProfileDTO atualizado.
Erros: 404 PROFILE_NOT_FOUND quando ainda não foi criado.
23.3 Subscriptions#
GET /v1/billing/subscriptions/current#
Retorna a assinatura ativa (1 viavel por empresa) com seus items[].
Auth: JWT.
Resposta 200: SubscriptionDTO (com items[] populado).
Erros: 404 SUBSCRIPTION_NOT_FOUND quando a empresa nunca assinou.
POST /v1/billing/subscriptions#
Cria uma nova assinatura. Inicia em trialing (14 dias) e provisiona a primeira cobranca no Asaas conforme billing_type.
Auth: JWT. Requer BillingProfile previamente criado.
Request body:
{
"plan_code": "starter_monthly",
"billing_type": "pix",
"card_id": null,
"addons": [{ "code": "extra_instance", "quantity": 2 }]
}
| Campo | Tipo | Obrigatório | Descricao |
|---|---|---|---|
plan_code |
string | sim | Código de plano publicado em /v1/billing/plans. |
billing_type |
string | sim | pix, boleto ou credit_card. |
card_id |
string | so quando billing_type=credit_card |
ID de cartao previamente tokenizado. |
addons |
array | não | Lista {code, quantity} de add-ons aplicados no checkout. Pre-validados atomicamente — código desconhecido ou quantity < 1 aborta a criação com 400. |
Resposta 201: SubscriptionDTO.
Erros:
400 PLAN_NOT_FOUND—plan_codeinválido.400 PROFILE_REQUIRED— empresa semBillingProfile.400 INVALID_BILLING_TYPE—billing_typefora depix|boleto|credit_card.400 CARD_REQUIRED—billing_type=credit_cardsemcard_id.400 INVALID_JSON— corpo não e JSON válido.400 INVALID_QUANTITY—quantityde add-on < 1.400 ADDON_NOT_FOUND—codede add-on no arrayaddonsdesconhecido.409 SUBSCRIPTION_EXISTS— empresa já tem assinatura ativa.
POST /v1/billing/subscriptions/{id}/change-plan#
Agenda mudanca de plano para o próximo ciclo. Set next_plan_id; o swap real acontece quando o webhook PAYMENT_RECEIVED do próximo periodo chega.
Auth: JWT.
Request body:
{ "new_plan_code": "pro_monthly" }
Resposta 202:
{ "status": "scheduled", "new_plan_code": "pro_monthly" }
Erros:
400 INVALID_ID— id da assinatura não numerico.400 INVALID_JSON— corpo não e JSON válido.404 PLAN_NOT_FOUND—new_plan_codeinválido.404 SUBSCRIPTION_NOT_FOUND— assinatura não encontrada para a empresa.400 ALREADY_ON_PLAN—new_plan_codeigual ao atual.400 SUBSCRIPTION_NOT_ACTIVE— assinatura não está emtrialing|active|past_due.
POST /v1/billing/subscriptions/{id}/cancel#
Cancela a assinatura.
Auth: JWT.
Request body:
{
"immediate": false,
"reason": "Cliente migrou para plano externo"
}
| Campo | Tipo | Padrão | Descricao |
|---|---|---|---|
immediate |
bool | false |
true cancela na hora; false agenda cancel_at_period_end=true para encerrar quando o periodo atual fechar. |
reason |
string | — | Texto livre para auditoria interna. |
at_period_end |
bool? | — | Alias defensivo: quando presente, !at_period_end sobrepoe immediate ({"at_period_end": false} forca cancelamento imediato). |
O corpo e opcional — corpo vazio = cancelamento ao fim do periodo com
reason="user_request".
Resposta 204: sem corpo. Erros:
400 INVALID_ID— id da assinatura não numerico.404 SUBSCRIPTION_NOT_FOUND— assinatura não encontrada para a empresa.
POST /v1/billing/subscriptions/{id}/items#
Adiciona add-on a assinatura (vira BillingSubscriptionItem). Cobranca adicional entra na próxima fatura.
Auth: JWT.
Request body:
{
"addon_code": "extra_instance",
"quantity": 2
}
Resposta 202:
{
"id": 12,
"subscription_id": 7,
"addon_code": "extra_instance",
"quantity": 2,
"scheduled_for_next_cycle": true
}
quantityomitido ou0e tratado como1.
Erros:
400 INVALID_ID— id da assinatura não numerico.400 INVALID_JSON— corpo não e JSON válido.404 ADDON_NOT_FOUND—addon_codeinválido.404 SUBSCRIPTION_NOT_FOUND— assinatura não encontrada para a empresa.400 INVALID_QUANTITY—quantityacima domax_quantitydo add-on.
DELETE /v1/billing/subscriptions/{id}/items/{itemId}#
Remove um item da assinatura. O crédito proporcional, quando aplicavel, segue a politica do Asaas.
Auth: JWT.
Resposta 204: sem corpo.
Erros: 400 INVALID_ID (id/itemId não numerico), 404 ITEM_NOT_FOUND.
23.4 Cards (cartoes tokenizados)#
PCI SAQ-D. Tokenizacao acontece server-side. PAN e CVV NUNCA são persistidos: defesa em 3 camadas — Sentinel
WithUserContentPathsno endpointtokenize, incident recorder com paths excluidos da captura, e zerar PAN/CVV no service layer após chamada Asaas.
GET /v1/billing/cards#
Lista cartoes ativos da empresa.
Auth: JWT.
Resposta 200:
{ "data": [ /* CardDTO[] */ ] }
POST /v1/billing/cards/tokenize#
Tokeniza um cartao: chama Asaas, recebe token, persiste apenas brand, last4, exp_month, exp_year, holder_name e is_default. PAN/CVV são descartados após a chamada.
Auth: JWT.
Request body:
{
"billing_profile_id": 7,
"number": "4111111111111111",
"holder_name": "Joao Silva",
"exp_month": 12,
"exp_year": 2030,
"cvv": "123"
}
Resposta 201: CardDTO (sem PAN, sem CVV).
Erros:
400 INVALID_JSON— corpo não e JSON válido.400 MISSING_FIELDS—billing_profile_id/number/cvv/exp_month/exp_yearausentes.404 PROFILE_NOT_FOUND—billing_profile_idnão pertence a empresa.422 CARD_REJECTED— Asaas rejeitou a tokenizacao.
PATCH /v1/billing/cards/{id}/default#
Marca este cartao como o default; rebaixa os demais.
Auth: JWT.
Resposta 204: sem corpo.
Erros: 400 INVALID_ID (id não numerico), 500 DB_ERROR.
DELETE /v1/billing/cards/{id}#
Revoga o cartao localmente (e no Asaas, quando suportado).
Auth: JWT.
Resposta 204: sem corpo.
Erros: 400 INVALID_ID (id não numerico), 500 DB_ERROR.
23.5 Charges (cobrancas)#
GET /v1/billing/charges#
Lista cobrancas da empresa, ordenadas por due_date DESC.
Auth: JWT.
Query params:
| Param | Tipo | Padrão | Descricao |
|---|---|---|---|
status |
string | — | Filtra por status (pending, confirmed, received, overdue, refunded, chargeback, etc.). |
limit |
int | 50 | Máximo 200. Valores inválidos caem no padrão. |
Resposta 200:
{ "data": [ /* ChargeDTO[] */ ] }
23.6 Webhooks (entrada do Asaas)#
POST /v1/webhooks/asaas#
Endpoint de ingestao de webhooks do Asaas. Idempotente via UNIQUE em asaas_event_id.
Auth: header asaas-access-token validado por crypto/subtle.ConstantTimeCompare contra ASAAS_WEBHOOK_SECRET.
Request body: payload do Asaas (variavel por event).
Resposta:
200— evento aceito (novo OU duplicado — idempotencia retorna 200 sem reprocessar).401— assinatura inválida.
Eventos suportados (mapeamento → estado):
| Asaas event | Ação em BillingSubscription |
|---|---|
PAYMENT_CREATED |
Cria/atualiza BillingCharge (status pending). |
PAYMENT_CONFIRMED |
Marca charge confirmed (PIX confirmado, boleto compensado). |
PAYMENT_RECEIVED |
Marca charge received; promove subscription trialing → active; aplica next_plan_id (mudanca de plano agendada). |
PAYMENT_OVERDUE |
Marca charge overdue; promove subscription active → past_due. |
PAYMENT_FAILED (REPROVED_BY_RISK_ANALYSIS) |
Marca charge failed. |
PAYMENT_REFUNDED |
Marca charge refunded. |
PAYMENT_CHARGEBACK_REQUESTED |
Marca charge chargeback; promove subscription para suspended imediatamente; pública em biazap:billing:suspended. |
PAYMENT_DELETED |
Marca charge deleted. |
SUBSCRIPTION_DELETED |
Promove subscription para cancelled. |
Retry: processador Asynq com backoff capeado (1m / 5m / 15m / 1h / 6h). Falhas terminais marcam o evento como failed em billing_webhook_events.
23.7 Admin (superadmin)#
GET /v1/admin/billing/metrics#
Metricas agregadas da plataforma.
Auth: JWT + superadmin.
Resposta 200: AdminBillingMetricsDTO (ver 23.8).
GET /v1/admin/billing/subscriptions#
Últimas 200 subscriptions ordenadas por created_at DESC.
Auth: JWT + superadmin.
Resposta 200:
{ "data": [ /* SubscriptionDTO[] */ ] }
POST /v1/admin/billing/webhook-events/{id}/reprocess#
Re-enfileira um webhook event que ficou failed após esgotar retries (ex: bug do consumer já corrigido).
Auth: JWT + superadmin.
Resposta 200:
{ "status": "queued", "event_id": 1234 }
Erros:
400 INVALID_ID— id do evento não numerico.404 EVENT_NOT_FOUND— webhook event inexistente.502 ENQUEUE_FAILED— falha ao re-enfileirar no Asynq.503 SERVICE_UNAVAILABLE— fila de tasks indisponível.
23.8 DTOs#
PlanDTO#
{
"code": "starter_monthly",
"product_code": "starter",
"display_name": "Starter",
"display_subtitle": "Pra comecar",
"cycle": "monthly",
"currency": "BRL",
"price_cents": 9900,
"trial_days": 14,
"sort_order": 10
}
AddonDTO#
{
"code": "extra_instance",
"display_name": "Instancia adicional",
"description": "Uma sessao WhatsApp extra",
"cycle": "monthly",
"currency": "BRL",
"price_cents": 2900,
"unit_label": "instancia",
"max_quantity": 50
}
ProfileDTO#
{
"id": 7,
"document_type": "cnpj",
"document_number": "12345678000190",
"legal_name": "Acme Ltda",
"trade_name": "Acme",
"email": "billing@acme.com",
"phone": "554137984905",
"address": {
"zip": "80000000",
"street": "Rua das Acacias",
"number": "123",
"complement": "Sala 4",
"district": "Centro",
"city": "Curitiba",
"state": "PR",
"country": "BR"
},
"asaas_customer_id": "cus_abc123"
}
| Campo | Tipo | Obs |
|---|---|---|
document_type |
string | cpf ou cnpj. |
document_number |
string | digitos apenas. |
address |
object | endereço completo do faturamento. |
asaas_customer_id |
string | preenchido pelo servidor; não envie no POST. |
SubscriptionDTO#
{
"id": 7,
"company_id": 9,
"plan_code": "starter_monthly",
"plan_display_name": "Starter",
"plan_cycle": "monthly",
"status": "active",
"billing_type": "pix",
"trial_ends_at": "2026-05-10T00:00:00Z",
"current_period_end": "2026-06-10T00:00:00Z",
"asaas_subscription_id": "sub_xyz",
"cancel_at_period_end": false,
"next_plan_code": "",
"next_plan_display_name": "",
"next_plan_cycle": "",
"items": [ /* SubscriptionItemDTO[] */ ]
}
plan_display_name/plan_cyclederivam do catalogo.next_plan_*so vem preenchido quando ha mudanca de plano agendada (viachange-plan).
Campo status |
Significado |
|---|---|
trialing |
dentro dos 14 dias de teste. |
active |
pago e em vigor. |
past_due |
cobranca em atraso (PAYMENT_OVERDUE); em janela de graca de 3 dias antes da suspensao. |
suspended |
suspenso (gracioso ou chargeback); whats-worker desconectou as instâncias via LogoutInstancesByCompany. |
cancelled |
encerrado definitivamente. |
expired |
trial venceu sem conversao. |
SubscriptionItemDTO#
{
"id": 12,
"type": "addon",
"code": "extra_instance",
"display_name": "Instancia adicional",
"quantity": 2,
"unit_price_cents": 2900,
"scheduled_for_next_cycle": true
}
Campo type |
Significado |
|---|---|
plan |
linha do plano base (sempre 1 por subscription, quantity=1). |
addon |
linha de add-on (quantity >= 1, até max_quantity do add-on). |
CardDTO#
{
"id": 4,
"brand": "visa",
"last4": "1111",
"exp_month": 12,
"exp_year": 2030,
"holder_name": "Joao Silva",
"is_default": true
}
Nunca exposto: PAN, CVV, token bruto do Asaas.
ChargeDTO#
{
"id": 42,
"asaas_payment_id": "pay_abc",
"billing_type": "pix",
"status": "received",
"amount_cents": 9900,
"net_value_cents": 9655,
"fee_cents": 245,
"due_date": "2026-05-10",
"paid_at": "2026-05-09T17:42:11Z",
"invoice_url": "https://asaas.com/invoice/abc",
"bank_slip_url": null,
"pix_copy_paste": "00020126...",
"pix_qr_code": "data:image/png;base64,iVBOR..."
}
| Campo | Quando preenchido |
|---|---|
bank_slip_url |
so quando billing_type=boleto. |
pix_copy_paste / pix_qr_code |
so quando billing_type=pix e charge `pending |
paid_at |
so quando `status=received |
AdminBillingMetricsDTO#
{
"total_active_subs": 42,
"total_trialing": 7,
"total_past_due": 2,
"total_suspended": 1,
"monthly_recurring_cents": 415800
}
23.9 Códigos de erro especificos do billing#
Os códigos abaixo são emitidos pelos handlers do billing. Erros do nivel de handler usam o envelope reduzido { "error_code", "message" }; erros do middleware de autenticação (401/403/503) usam o envelope completo { "error_code", "message", "trace_id", "error" }:
error_code |
HTTP | Quando ocorre |
|---|---|---|
PROFILE_NOT_FOUND |
404 | GET /v1/billing/profile antes de criar. |
PROFILE_EXISTS |
409 | POST /v1/billing/profile com profile já existente. |
PROFILE_REQUIRED |
400 | Subscription create sem profile previo. |
INVALID_DOCUMENT |
400 | CPF/CNPJ falha na validação. |
PLAN_NOT_FOUND |
400 | plan_code ou new_plan_code inválido. |
SUBSCRIPTION_NOT_FOUND |
404 | GET /v1/billing/subscriptions/current sem assinatura. |
SUBSCRIPTION_EXISTS |
409 | POST /v1/billing/subscriptions com assinatura ativa. |
SUBSCRIPTION_NOT_ACTIVE |
400 | change-plan em assinatura `cancelled |
ALREADY_ON_PLAN |
400 | change-plan para o mesmo plan_code corrente. |
CARD_REQUIRED |
400 | billing_type=credit_card sem card_id. |
CARD_REJECTED |
422 | Asaas rejeitou a tokenizacao do cartao. |
INVALID_BILLING_TYPE |
400 | billing_type fora de `pix |
INVALID_QUANTITY |
400 | Item add-on com quantity <= 0 ou > max_quantity. |
ITEM_NOT_FOUND |
404 | DELETE em item inexistente. |
ADDON_NOT_FOUND |
404 | addon_code inválido. |
INVALID_JSON |
400 | Corpo não e JSON válido. |
INVALID_ID |
400 | UUID malformado em path param. |
UNAUTHENTICATED |
401 | Token ausente/inválido em endpoint protegido. |
INTERNAL |
500 | Erro interno (ver incident_id no envelope). |
COMPANY_NOT_FOUND |
404 | Empresa do token não existe no master DB. |
MISSING_FIELDS |
400 | Campos obrigatórios ausentes no tokenize. |
DB_ERROR |
500 | Falha de consulta ao banco. |
EVENT_NOT_FOUND |
404 | webhook-events/{id}/reprocess com id inexistente. |
ENQUEUE_FAILED |
502 | Falha ao re-enfileirar webhook event no Asynq. |
SERVICE_UNAVAILABLE |
503 | Fila de tasks indisponível. |
23.10 Fluxo completo (curl)#
# 1. Login para obter token
TOKEN=$(curl -sf https://api.catcher.one/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"owner@empresa.com","password":"..."}' | jq -r .token)
# 2. Criar profile de cobranca (CNPJ)
curl -sf -X POST https://pay.catcher.one/v1/billing/profile \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{
"document_type": "cnpj",
"document_number": "12345678000190",
"legal_name": "Acme Ltda",
"email": "billing@acme.com",
"phone": "554137984905",
"address": {
"zip": "80000000", "street": "Rua das Acacias", "number": "123",
"district": "Centro", "city": "Curitiba", "state": "PR", "country": "BR"
}
}' | jq
# 3. Listar planos disponiveis
curl -sf "https://pay.catcher.one/v1/billing/plans?cycle=monthly" | jq
# 4. Assinar (PIX, sem cartao)
curl -sf -X POST https://pay.catcher.one/v1/billing/subscriptions \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"plan_code":"starter_monthly","billing_type":"pix"}' | jq
# 5. Listar cobrancas para pegar o PIX copy-paste
curl -sf "https://pay.catcher.one/v1/billing/charges?limit=5" \
-H "Authorization: Bearer $TOKEN" | jq '.data[0] | {status,pix_copy_paste,due_date}'