Dentolize · WhatsApp Paid Messages Walkthrough
On this pageBusiness viewTechnical view

Cost Reporting & Analytics

Business view

Two new screens answer "what did our WhatsApp usage cost, and what kind of messages were they?":

  • Messages Cost — a new tab next to Requests and Message Templates

(web: WhatsApp Bot → Messages Cost; mobile: a new tab in the WhatsApp stack). It lists every message the clinic has sent in a date range, who sent it, what category WhatsApp bills it under, and whether it was free or paid.

  • The same breakdown, per conversation — open any single chat and a

small strip of stat boxes above the messages (mobile) or a lazy-loaded summary (web, same component reused) shows Free / Paid / Unknown counts for that conversation alone, each expandable into a per-category breakdown.

Clicking "Free" or "Paid" on the company-wide report filters the table down to just that group. The "Unknown" box only appears once there's at least one message from before this feature existed — it's not a filter, just a count, since there's nothing meaningful to filter it to.

Nothing a patient sends counts toward these numbers — only what the clinic sent.

Technical view

GraphQL surface

Two new queries (packages/server/src/schema.graphql:401-402):

conversationMessagesInfo(rangeDate: [DateTime!]!, filters: Filter, conversation: ID): [TypeSum!]!
conversationMessagesReport(orderBy: String!, skip: Int!, take: Int!, rangeDate: [DateTime!]!, filters: Filter): [ConversationMessage]!

TypeSum gained a free: Boolean field (packages/server/src/types.graphql:3477) alongside its existing type.

conversationMessagesInfo — the analytics boxes

packages/server/src/resolvers/queries/actions/officialWhatsApp/conversationMessagesInfo.js:1-23 runs a single Prisma groupBy:

const messages = await prisma.conversationMessage.groupBy({
  by: ['category', 'free'],
  _count: true,
  where
})

Called two ways:

  • Per conversation — mobile chat screen passes conversation: <id>;

getWhereForConversationMessages scopes to that one conversation (packages/server/src/utils/helpers.js:3230-3243).

  • Company-wide — the report screens call it with no conversation

argument; the where-clause then adds { companyId: request.session.user.company.id }, { incoming: false } (helpers.js:3238-3240) — the incoming: false is what keeps patient-sent messages out of every cost number.

The frontend groups the raw {type, free, _count} rows client-side by cost first, then by category, in an identical groupByCost() helper duplicated across mobile (packages/clinic-mobile/src/components/dashboard/WhatsApp/conversationMessagesReport/ConversationMessagesInfo.js:11-24) and web (packages/clinic-web/src/components/dashboard/officialWhatsapp/conversationMessages/ConversationMessagesInfo.js:11-24, byte-for-byte the same logic, ~114 lines apart in two different UI frameworks — React Native / UI Kitten vs. antd). A row with free: null (no cost was ever recorded) is bucketed under the UNKNOWN key, alongside type: null messages bucketed as UNKNOWN category.

conversationMessagesReport — the table

packages/server/src/resolvers/queries/actions/officialWhatsApp/conversationMessagesReport.js:1-31 is a plain paginated findMany, deliberately separate from the existing conversationMessages query (used by a single chat's infinite scroll) — the code comment explains why: that one merges every page it fetches for an infinite-scroll feed, while this one is a table page that replaces its rows each time. It selects the conversation's name/phone/username/ bsuid so the table can link back to the chat (packages/clinic-web/.../ConversationMessages.js:41-46, onRow: item => navigate('/whatsapp/chats/' + item.conversation.id)).

Screens

MobileWeb
New tabConversationMessagesReportScreenpackages/clinic-mobile/src/components/dashboard/WhatsApp/conversationMessagesReport/ConversationMessagesReportScreen.js, wired into the tab navigator at WhatsApp.js:77-87ConversationMessagespackages/clinic-web/src/components/dashboard/officialWhatsapp/conversationMessages/ConversationMessages.js, wired into OfficialWhatsapp.js:29-33
List/table rowConversationMessageReport.js (new)CommonTable columns defined inline in ConversationMessages.js:29-99
Analytics boxesConversationMessagesInfo.js (new), rendered via CommonList's LazyContent propConversationMessagesInfo.js (new), rendered via CommonTable's LazyContent prop

Both screens reuse hasLazyQuery / LAZY_QUERY / LazyContent props already supported by the shared CommonList (mobile) and CommonTable (web) components — the analytics query is fired lazily alongside the main paginated query rather than being folded into it, since it needs the same filters but a different (aggregated) shape.

Permissions

Both new queries require the existing VIEW_WA or VIEW_CREATED_WA permission (packages/server/src/permissions/permissions.js:2364-2373) — the same permission that already gates seeing WhatsApp conversations at all. There is no separate, more restrictive permission for cost visibility: anyone who can see the chats can see what they cost.