Dentolize · WhatsApp Paid Messages Walkthrough
On this pageWhat to test

For Quality

What to test

Conversation windows

  • A conversation whose patient wrote < 24h ago and has no freeUntil:

service window open, Paid shown, no free tag.

  • A conversation whose patient wrote > 24h ago and has no freeUntil:

conversation shows expired / not repliable.

  • A conversation with freeUntil in the future but the patient's last

message was > 24h ago: still repliable (the free window alone keeps it open), tag shows Free.

  • An ad-referred conversation (adReferralAt set, < 24h old): tag shows

Free (ad), with the "reply to open 72 free hours" note.

  • Reply to that ad-referred conversation within the 24h window: confirm

freeUntil extends to +72h from the reply, adReferralAt clears to null, and the tag switches from Free (ad) to plain Free.

  • Let the 24h ad-reply window lapse without replying: confirm no free

window opens, freeUntil stays whatever it was (likely already expired), and the conversation reads as Paid from then on.

  • Edge case worth targeted testing: a conversation already sitting in

an active 72-hour free window (from a prior ad reply) receives another inbound message that also carries ctwa_clid. Per packages/whatsapp-official/src/messages/messages.service.ts:211-225, the new ad fields are written unconditionally whenever fromAd is true — verify whether this shortens an already-longer freeUntil back down to +24h, and decide whether that's the intended behavior.

  • Countdown display past 24 hours: with a 72-hour free window active,

confirm the header timer reads e.g. 70:15:02 instead of wrapping to 22:15:02 (regression target for the secondsToHms change in WhatsAppChatHeader.js).

Message tagging

  • Send a manual reply from the app inside a paid window → confirm the

created ConversationMessage has free: false, category: SERVICE.

  • Send a manual reply inside a free window → free: true, `category:

SERVICE`.

  • Trigger a cron/reminder template send → confirm category matches the

template's own category, and is undefined/null for templates that predate this feature (no category set on the MessageTemplate itself).

  • Confirm inbound (patient-sent) messages never get free/category

populated, under any window state.

  • A message sent before this migration ran: confirm it reports as

"Unknown" in both the table and the analytics boxes, on both mobile and web.

Cost reporting

  • Company-wide Messages Cost report only includes outgoing messages —

seed a conversation with only inbound messages and confirm it produces zero rows.

  • Per-conversation analytics (conversationMessagesInfo with a

conversation argument) vs. company-wide (without) — confirm the company-wide one is further scoped to the requesting user's company.

  • Clicking the Free/Paid analytics box on web toggles the table filter;

clicking it again clears the filter (toggleFilter in ConversationMessagesInfo.js) — verify the "Unknown" box is genuinely not clickable (notFilterable: true), since there's no free: null filter wired server-side.

  • Category column filter in the web table includes an explicit "null"

value option for uncategorized messages — verify it actually matches rows with category: null and not just an accidental string "null".

Daily spending limits

  • Set maxUserPaidMessages to a small number (e.g. 2), send that many

paid replies, then attempt one more → expect the mutation to throw Daily Paid Messages Limit Reached[Client Error] and no message sent to Meta.

  • Set a limit of exactly 0 → confirm all paid replies are blocked

immediately (tests the userLimit === 0 special case in both handleSendWhatsappMessage.js and messages.service.ts), while free replies still go through.

  • Leave both limits blank → confirm no cap is ever enforced regardless of

volume.

  • Free replies (inside a free window) should never decrement or check

either counter — send a burst of free replies and confirm the Redis paid-count key is untouched.

  • Concurrency edge case: the check-then-increment sequence

(get → compare → later incr/setex) is not atomic. Fire several paid replies concurrently right at the limit boundary and check whether the count can overshoot the configured limit under load — this is a plausible race, not yet guarded with a Lua script or INCR-first pattern.

  • Confirm the ~24h rolling reset: send a paid message, wait until its

Redis key's TTL should have expired, and confirm a fresh allowance is available rather than waiting for a calendar-day boundary.

  • Bot limit reached: confirm the bot simply does not send (no error

surfaced anywhere) and that no ConversationMessage row is created for the suppressed attempt.

Cross-cutting

  • Duplicated logic across 4 implementations. The windowing math exists

separately in packages/server, packages/whatsapp-official, and both frontend whatsappUtils.js files. Regression-test all four independently after any change to the windowing rules — a fix applied to only one will not propagate.

  • i18n: all 9 locale files got new keys (freeMessages, paidMessages,

freeAdMessages, adReplyWindow, category names, unknownMessage, freeWindow, etc.). Spot-check the RTL locales (Arabic, Kurdish Sorani ckb, Kurdish Kurmanji ku) for tag/badge overflow given the new, sometimes-longer strings (e.g. "Free (ad)" / "مجاني (إعلان)").

  • Permissions: confirm a user with only VIEW_CREATED_WA (not the

broader VIEW_WA) can still see the new cost report and analytics — the permission rule ORs the two, same as the existing conversation-view permission.