fix(billing): send CPF/CNPJ to Asaas when subscribing - #552
Open
rodolfoabenito-pixel wants to merge 117 commits into
Open
fix(billing): send CPF/CNPJ to Asaas when subscribing#552rodolfoabenito-pixel wants to merge 117 commits into
rodolfoabenito-pixel wants to merge 117 commits into
Conversation
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>
…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.
…Account/requireRole
…; add cancel-without-subscription test
…, 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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Resumo
Testado