Skip to content

fix(billing): send CPF/CNPJ to Asaas when subscribing - #552

Open
rodolfoabenito-pixel wants to merge 117 commits into
ArnasDon:mainfrom
rodolfoabenito-pixel:worktree-billing-cpf-cnpj
Open

fix(billing): send CPF/CNPJ to Asaas when subscribing#552
rodolfoabenito-pixel wants to merge 117 commits into
ArnasDon:mainfrom
rodolfoabenito-pixel:worktree-billing-cpf-cnpj

Conversation

@rodolfoabenito-pixel

Copy link
Copy Markdown

Resumo

  • Ao testar a assinatura no sandbox do Asaas, a rota /api/billing/subscribe estava retornando erro 502. O log do servidor mostrou: "Para criar esta cobrança é necessário preencher o CPF ou CNPJ do cliente." — o Asaas exige esse dado pra gerar a cobrança, e o app nunca coletava isso.
  • Adiciona a coluna accounts.cpf_cnpj (migração 043_billing_cpf_cnpj.sql), um campo de CPF/CNPJ na tela /billing antes do botão de assinar, validação de formato (11 ou 14 dígitos) no navegador e no servidor, e envio desse dado pro Asaas na criação do cliente.

Testado

  • npx vitest run — 1106/1106 passando
  • npm run typecheck — sem erros
  • npm run lint — sem erros
  • Falta: testar de novo no sandbox depois do deploy, pra confirmar que a assinatura completa

rodolfoabenito-pixel and others added 30 commits August 27, 2026 21:25
Spec: suporte a segundo provedor de WhatsApp (UAZAPI) — decisoes de
schema, seam de provedor, inbound, provisionamento por QR Code e as
quatro ondas de entrega.

Plano da Onda 0: extracao do seam de envio (refactor puro, so Meta).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e repo

O portao da Task 9 esperava que whatsapp_config sobrasse so em
resolve-connection.ts; resolve-conversation.ts tambem consulta a tabela
(fora do escopo da Onda 0) e encryption.ts/meta-api.ts a citam em
comentario. O grep passa a ser por .from('whatsapp_config').

O grep de uazapi esperava uma ocorrencia, esquecendo o caso de teste que
verifica a rejeicao de provider nao implementado.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…entation

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…n in resolveConnection

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Task 3 of the Wave 0 send-seam refactor: extracts the copy-pasted
resolve-connection -> check-capability -> resolve-contact -> send ->
persist-message -> update-conversation -> pause-flow sequence from
send-message.ts, flows/meta-send.ts and automations/meta-send.ts into
one function. Nothing consumes it yet (Tasks 4-6 rewire the call sites).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Task 4 tinha um hedge ("se o typecheck reclamar, reproduza a coercao
atual"). Verificado contra o codigo: send-message.ts passa
messageParams: templateMessageParams ?? undefined, e meta-api le o
objeto so por optional chaining, entao null e undefined sao
equivalentes ali. O plano agora traz a expressao exata.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
O plano mandava resolver a conexao antes do contato. send-message.ts faz
conversa -> telefone -> E.164 -> config, e os dois engines seguem a mesma
ordem relativa. Numa conta sem whatsapp_config, a ordem do plano faria um
envio para conversa inexistente devolver whatsapp_not_configured/400 onde
hoje devolve not_found/404: mudanca observavel no envelope v1 publico,
contra o criterio de aceite da propria onda.

A lista de passos da spec 4.2 sugere conexao primeiro; entre ela e o zero
mudanca observavel da mesma secao, vale o segundo. O codigo agora carrega
um comentario explicando por que a ordem nao e arbitraria.

Tambem fecha dois furos de contrato: createTransport podia escapar com
Error cru, e a mensagem de falha de insert podia interpolar a string
undefined.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…fill test gaps

Fix round 1 review findings on Task 3 (send-core):
- Reorder sendViaConnection so contact/phone resolution happens before
  connection/capability checks, matching send-message.ts's today order.
  A send to a nonexistent/foreign conversation on an unconfigured
  account now still 404s instead of 400ing, preserving the public v1
  contract.
- Wrap resolveConnection + createTransport so a bare Error escaping
  createTransport (e.g. missing phone_number_id) becomes a
  SendMessageError, keeping the "always throws SendMessageError"
  contract; SendMessageError from resolveConnection still passes
  through untouched.
- Avoid interpolating the literal string "undefined" into the DB-insert
  failure message when messageRecord and msgError are both null.
- Add coverage for template-kind persistence, the contactId branch of
  loadContact (including account_id scoping), the reply-target
  resolution path, and the args that reach the transport for media and
  template sends.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fix round 2 review findings on Task 3 (send-core):
- Narrow the try/catch from Fix round 1 so it wraps ONLY
  createTransport, not resolveConnection. resolveConnection already
  throws SendMessageError deliberately, and its unguarded decrypt()
  call can raise a raw Error on a corrupted credential; that must
  propagate as-is (matching send-message.ts today), not get
  reclassified as whatsapp_not_configured/400. The now-dead
  `err instanceof SendMessageError` re-throw branch is removed since
  createTransport cannot produce a SendMessageError today.
- Fix the step 3 comment, which still claimed the capability check ran
  "before any DB work" after the Fix round 1 reorder made that false;
  wording now matches the corrected plan doc.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…core

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fix round 1 review: the file header still described the pre-rewire
plumbing (conversation/config load, Meta send, persistence, flow
pause) instead of what the file does now — validate, resolve/abort
on template rows, compute persisted template text, and delegate to
sendViaConnection. The re-export comment also claimed
resolve-conversation.ts imports SendMessageError from send-error.ts;
it still imports from send-message.ts, which is the actual reason
the re-export exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rewires engineSendText/Media/InteractiveButtons/InteractiveList to call
sendViaConnection instead of repeating the contact lookup, config
lookup, decrypt, phone-variant retry, and DB persistence in each
function. Adds toEngineError to translate the core's SendMessageError
back into the exact strings these engines have always thrown, since
they surface in automation_logs and runner logs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replaces the private sendViaMeta + SendInput duplication with thin
wrappers over sendViaConnection, mirroring the flows rewire. Preserves
this call site's two divergences: it sends the caller's raw language to
Meta (not the resolved one) and tolerates a malformed/missing local
template row rather than aborting. engineSendInteractive is untouched —
it already delegates to the flows interactive senders. Rewrites the
stale file header, which described a convergence with the flows/
automations senders that has now happened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A spec 4.2 diz "copiada em 5 lugares" e nomeia cinco. Durante a execucao
apareceu um sexto: src/app/api/whatsapp/broadcast/route.ts, o broadcast do
dashboard, distinto do broadcast da API publica que vive em
broadcast-core.ts. Ele carrega a copia inteira -- lookup de
whatsapp_config, decrypt, resolucao de template, loop de phoneVariants e
chamada a sendTemplateMessage.

Deixa-lo de fora faria a Onda 1 encontrar exatamente o ramo
if (provider === ...) que esta onda existe para evitar, e reprovaria o
proprio portao da Task 9, cujo grep espera phoneVariants apenas em
providers/meta-transport.ts.

Entra na Task 7 como Step 4b, mesma forma dos outros dois arquivos de
broadcast.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… via the transport

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…nsport at the point of use

The bare `catch {}` around `resolveConnection` swallowed a raw `decrypt`
failure (rotated key / corrupt ciphertext) and returned 400 "WhatsApp not
configured"; it now only maps `SendMessageError` with code
`whatsapp_not_configured` and rethrows everything else, so a corrupt
credential propagates to the outer handler as a 500 exactly as it did
before this wave.

`createMetaTransport` throws synchronously on a null `phone_number_id`.
Hoisting `createTransport` above the send loop meant that throw aborted
`deliverBroadcast` inside `after()` before any `broadcast_recipients` row
was stamped, wedging the broadcast in `sending` with no recovery. The
transport is now built at the point of use, inside the per-recipient
try/catch (and, for the reaction route, inside the send try that returns
the 502), so a construction failure marks the row failed and
`finalizeBroadcastStatus` still runs -> terminal `failed`, the old
outcome.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
It came in alongside the spec and plan in 0cfde7a but sat at the repo
root. No code references it in wave 0; it belongs with the other design
docs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…nd-seam

refactor(whatsapp): extract the provider send seam (UAZAPI wave 0)
Decomposes the mother spec's Wave 1 into 1a (migration 040 + lockstep
rename, Meta-only, zero behaviour change) / 1b (UAZAPI transport +
provisioning + QR) / 1c (inbound pipeline). Records the brainstorming
decisions and corrects the rename-safety argument: this repo has no
generated Supabase types, so the net is exhaustive grep + loud runtime
failure + the self-checking column rename, not the compiler.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
8 tasks: the deferred Onda-0 coverage tests (flagged for a possible move
to 1b), migration 040 with full SQL + a review checklist, resolveConnection
+ WhatsAppConfig type, the config route (rename + provider scoping), two
batched rename passes over the ~20 call sites, and the acceptance gate.
Migration is validated by migrations.yml on the PR — no local Supabase.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
They don't guard the 1a rename (the 3 transport-direct paths never
reference whatsapp_config), and deliverBroadcast has no test harness yet
— that's 1b work. 1a is now a pure rename: 7 tasks, no new tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ing 1a-6)

Task 1 review found the rename scope missed SQL-resident refs (Postgres
doesn't rewrite function bodies on rename) and that NOT NULL connection_id
would force touching conversation-creation paths that belong to 1c. SET
NOT NULL + ON DELETE RESTRICT + the archive lifecycle move to 1b/1c as a
package.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…sk 2

Task 3 surfaced 11 regressions: 4 test files (send/route, broadcast-core,
broadcast-resume, send-message) mock the DB by the old table name and
flow through resolveConnection, which Task 2 renamed. The plan had
scoped those mock renames to Task 5's incomplete list. New rule stated
in Global Constraints; Task 2 file list + steps updated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
rodolfoabenito-pixel and others added 29 commits August 31, 2026 11:12
…oser template gate

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…i-inbound-webhook

feat(uazapi): inbound webhook + media + inbox (UAZAPI wave 1c-ii)
Both were environment leaks into the test suite, not app bugs. On a
machine west of UTC, new Date("2026-05-18") (date-only ISO, parsed as
UTC) reads as the previous local day, breaking mondayIndex assertions.
And Intl.NumberFormat(undefined, ...) follows the host's OS locale by
design — pt-BR groups thousands with "." instead of ",", which the
currency tests hardcoded.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CgXCQs7Vs7PwGwbQyot9JX
configureWebhook never sent enabled:true, so UAZAPI silently created
every webhook disabled — zero deliveries, zero errors logged, nothing
to debug against. Once enabled, the adapter's envelope-shape guesses
turned out wrong against real traffic:

- `messages` nests the Message under `payload.message`, not `data` or
  flattened as the OpenAPI examples suggested.
- `messages_update` nests under `payload.event`, with `MessageIDs`
  (an array — batches multiple ids per event), `Type`, and `Timestamp`
  in SECONDS (unlike `messageTimestamp`'s milliseconds on `messages`).
- `messageType` uses whatsmeow's Go struct names (`ImageMessage`,
  `AudioMessage`, `ExtendedTextMessage`), not the plain kind strings
  the adapter's MEDIA_KINDS map and text check assumed.

Confirmed end-to-end against a live instance: text, image, audio, and
batched read receipts all land correctly now.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CgXCQs7Vs7PwGwbQyot9JX
fast-uri, browserslist, qs, @humanfs/node, and postcss-selector-parser
were flagged (Hostinger's dependency scan, 10 CVEs across the 5
packages). All are transitive — pulled in by shadcn (CLI scaffolding
tool, never imported at runtime) and eslint (lint-time only) — so none
were reachable from the running app. `npm audit fix` bumped them via
package-lock.json only, no direct dependency ranges changed: 0
vulnerabilities now, typecheck/lint/test/build all still green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CgXCQs7Vs7PwGwbQyot9JX
The connection EventType is the last unconfirmed envelope shape from
the 1c-ii plan's "confirmar na prática" list. A real disconnect just
got silently rejected by the instance/token defense-in-depth guard
(payload.instance !== row.uazapi_instance_id) — logging the payload on
that specific path to see what UAZAPI actually sends. Temporary; will
be removed once the real shape is confirmed and the check is fixed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CgXCQs7Vs7PwGwbQyot9JX
Confirmed via a live disconnect/reconnect: the `connection` EventType's
`payload.instance` is an OBJECT (`{name, status, qrcode?}`), never the
bare id string the defense-in-depth check compared it against — so
every real connection event was silently rejected as an "instance
mismatch", and whatsapp_connections.status never updated on
disconnect/reconnect.

Replaced the check with `payload.instanceName` (a string, confirmed
present on every event type: messages, messages_update, connection)
compared against `wacrm-${account_id}` — the name createInstance()
already gives the instance, reconstructible with no new column.

handleConnectionEvent also read the wrong shape: `payload.instance` IS
the state object (not `payload.data`), and the connected phone number
is the top-level `payload.owner`, not a nested phone/jid field.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CgXCQs7Vs7PwGwbQyot9JX
Confirmed in production: messages the operator types directly on the
connected phone reach the webhook as EventType 'messages' with
fromMe: true, completely unfiltered (excludeMessages only covers
wasSentByApi — our own API sends, not manual phone replies). The
adapter never checked this, so every operator reply got processed as
an inbound customer message:

- findOrCreateContact overwrote the real customer's name with the
  operator's own WhatsApp profile name (name comes from the message's
  senderName, which for a fromMe message is the operator, not the
  customer).
- The operator's own words got stored with sender_type 'customer' —
  misattributing what was said in the conversation.

Added uazapiMessageToInbound's sibling `isFromMe()` and skip the
'message' case entirely when it's true. No data backfill in this
commit — existing corrupted contact names / misattributed messages
need a separate remediation pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CgXCQs7Vs7PwGwbQyot9JX
The 5-CVE dependency bump (ba60ed2) was run with npm 11.17.0 (this
machine's local npm), not the npm@10.9.9 this repo pins via
packageManager and CI installs via actions/setup-node. npm 11 silently
deduped next-intl's own nested @swc/helpers@0.5.23 peer dependency into
the top-level one without recording it in the lockfile; npm 10.9.9's
`npm ci` — used in CI, unlike local `npm install` — is stricter about
package.json/package-lock.json sync and rejected it outright:
"Missing: @swc/helpers@0.5.23 from lock file". Reproduced locally via
`npx npm@10.9.9 ci`, fixed by regenerating the lockfile with that same
npm version. Every push since ba60ed2 failed CI at the install step
(before lint/typecheck/test/build ever ran) — confirmed via the GitHub
Actions API job/step breakdown.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CgXCQs7Vs7PwGwbQyot9JX
…timestamp

Confirmed in production: UAZAPI can send a "FileDownloaded"
notification through the same status-update path as real delivery
receipts — a media-pipeline notice, not one of messages_status_check's
allowed values (sending/sent/delivered/read/failed). Writing it
straight into messages.status crashed the request on the DB's CHECK
constraint; on the same event, a malformed/absent timestamp field
produced `new Date(NaN)`, and `.toISOString()` on that threw
uncaught, aborting the whole webhook processing callback.

Both guards are provider-agnostic (this pipeline is shared with Meta):
skip the messages-table write for any status outside the known set,
and write null instead of throwing when the timestamp is invalid —
sent_at/delivered_at/read_at are nullable.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CgXCQs7Vs7PwGwbQyot9JX
Full Brazilian Portuguese translation — all 1495 keys, matching
en.json's structure exactly (ICU plural forms, placeholders, and
inline tags preserved as-is). Added 'pt-BR' to messages.test.ts's
TRANSLATED_LOCALES so key parity with en.json stays enforced going
forward.

Not wired up yet: set NEXT_PUBLIC_APP_LOCALE=pt-BR and rebuild to
switch the whole app to Portuguese (it's a build-time env var, single
locale for every user — see src/i18n/request.ts).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CgXCQs7Vs7PwGwbQyot9JX
…able header

Self-review pass — the broadcasts list table pairs "Entrega" (a noun)
with what read as an adjective ("Lida"). Matching noun forms across
sibling column headers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CgXCQs7Vs7PwGwbQyot9JX
…d block)

Design approved in brainstorming 2026-09-03. Adds subscription columns
to accounts (already the multi-tenant boundary since 017), a
service-role-only write trigger protecting them from client tampering,
Asaas customer/subscription/cancel client, webhook to sync status, and
a block gate in the dashboard layout + defense-in-depth in
getCurrentAccount(). Inbound WhatsApp ingestion is explicitly
unaffected by a blocked account — only authenticated CRM access gates.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CgXCQs7Vs7PwGwbQyot9JX
7 tasks: accounts subscription columns + protection trigger + trial
(migration 042), isAccountBlocked pure function, Asaas API client,
PaymentRequiredError + allowBlocked gate in getCurrentAccount/
requireRole, subscribe+cancel routes, webhook, dashboard block gate +
/billing page. i18n across all three locales (en/ko/pt-BR).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CgXCQs7Vs7PwGwbQyot9JX
createCustomer email param was optional in the pre-flight ruling but
had silently reverted to required in the plan text (uncommitted worktree
state got lost). Re-applies the optional-email signature, the matching
test case, the pre-flight table note, and confirms the /billing route
path and git-add quoting fixes already survived.
…, and DB-error 500s

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CgXCQs7Vs7PwGwbQyot9JX
Task 7's brief added canManageBilling to roles.ts but never added the
matching unit test, breaking this codebase's established convention
(every role predicate in roles.ts has a case in roles.test.ts, e.g.
canDeleteAccount/canTransferOwnership). Caught during pre-dispatch
review of Task 7's generated brief, before dispatch rather than via
the review/fix-loop this time. Renumbers Task 7's remaining steps and
fixes cross-references accordingly.
…e, harden webhook auth, add billing nav entry

Fixes from the final whole-branch review:
- Critical: subscribe no longer creates a second Asaas subscription
  when one already exists (was double-billing on any re-subscribe,
  e.g. after past_due) — cancels the stale one first.
- Subscribing during the 7-day trial no longer charges the next day;
  nextDueDate now respects trial_ends_at.
- cancelSubscription tolerates a 404 (already gone at Asaas), fixing
  both the double-billing cancel-first call and cancel becoming
  permanently stuck if Asaas already deleted the subscription.
- Cancel now nulls asaas_subscription_id, so a late/retried webhook
  can't silently reactivate a canceled account.
- Webhook token compared with timingSafeEqual (was plain !==); returns
  503 (not 401) when ASAAS_WEBHOOK_TOKEN itself is unset.
- protect_billing_columns() now mirrors 034's current_user =
  'authenticated' + RAISE EXCEPTION pattern instead of auth.role(),
  which silently reverted (no error) any non-PostgREST write — the
  only manual escape hatch this no-cron system has for a stuck
  subscription_status.
- subscribe/route.ts's error handling checks the three typed account
  errors explicitly instead of a fragile "status" in err duck-type
  check, and no longer leaks err.message to the client.
- Documented the four ASAAS_* env vars in .env.local.example.
- Added a Billing nav entry (sidebar, all 3 locales) — /billing was
  previously unreachable except via the block-redirect, making
  self-service cancel structurally unreachable from the product.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CgXCQs7Vs7PwGwbQyot9JX
…ption

Sandbox smoke test showed POST /subscribe failing with 502: Asaas
rejects subscription creation without a customer CPF/CNPJ ("Para
criar esta cobrança é necessário preencher o CPF ou CNPJ do
cliente."), which wasn't part of the original spec's "confirm in
practice" list. Adds an accounts.cpf_cnpj column, collects it on the
/billing page before subscribing, validates the format client- and
server-side, and forwards it to Asaas's createCustomer call.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ExDkAYNqqDdjakeorpzgmx
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant