Billing

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.one Serviço: biazap-payment (porta 8097 interna) Persistencia: todas as tabelas billing vivem em biazap_master (NÃO no tenant DB) Provider: Asaas atrás da interface provider.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:

json
{
  "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:

json
{
  "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:

json
{ "status": "ok", "service": "biazap-payment" }

GET /ready#

Readiness. Stub — sempre 200 (checks de DB/Redis ainda não implementados).

Resposta 200:

json
{ "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 (use PATCH).
  • 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 email, phone, legal_name, trade_name e address são mutaveis. document_type / document_number sã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:

json
{
  "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_FOUNDplan_code inválido.
  • 400 PROFILE_REQUIRED — empresa sem BillingProfile.
  • 400 INVALID_BILLING_TYPEbilling_type fora de pix|boleto|credit_card.
  • 400 CARD_REQUIREDbilling_type=credit_card sem card_id.
  • 400 INVALID_JSON — corpo não e JSON válido.
  • 400 INVALID_QUANTITYquantity de add-on < 1.
  • 400 ADDON_NOT_FOUNDcode de add-on no array addons desconhecido.
  • 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:

json
{ "new_plan_code": "pro_monthly" }

Resposta 202:

json
{ "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_FOUNDnew_plan_code inválido.
  • 404 SUBSCRIPTION_NOT_FOUND — assinatura não encontrada para a empresa.
  • 400 ALREADY_ON_PLANnew_plan_code igual ao atual.
  • 400 SUBSCRIPTION_NOT_ACTIVE — assinatura não está em trialing|active|past_due.

POST /v1/billing/subscriptions/{id}/cancel#

Cancela a assinatura.

Auth: JWT.

Request body:

json
{
  "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:

json
{
  "addon_code": "extra_instance",
  "quantity": 2
}

Resposta 202:

json
{
  "id": 12,
  "subscription_id": 7,
  "addon_code": "extra_instance",
  "quantity": 2,
  "scheduled_for_next_cycle": true
}

quantity omitido ou 0 e tratado como 1.

Erros:

  • 400 INVALID_ID — id da assinatura não numerico.
  • 400 INVALID_JSON — corpo não e JSON válido.
  • 404 ADDON_NOT_FOUNDaddon_code inválido.
  • 404 SUBSCRIPTION_NOT_FOUND — assinatura não encontrada para a empresa.
  • 400 INVALID_QUANTITYquantity acima do max_quantity do 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 WithUserContentPaths no endpoint tokenize, 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:

json
{ "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:

json
{
  "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_FIELDSbilling_profile_id / number / cvv / exp_month / exp_year ausentes.
  • 404 PROFILE_NOT_FOUNDbilling_profile_id nã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:

json
{ "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:

json
{ "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:

json
{ "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#

json
{
  "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#

json
{
  "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#

json
{
  "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#

json
{
  "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_cycle derivam do catalogo. next_plan_* so vem preenchido quando ha mudanca de plano agendada (via change-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#

json
{
  "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#

json
{
  "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#

json
{
  "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#

json
{
  "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)#

bash
# 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}'