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
| Mobile | Web | |
|---|---|---|
| New tab | ConversationMessagesReportScreen — packages/clinic-mobile/src/components/dashboard/WhatsApp/conversationMessagesReport/ConversationMessagesReportScreen.js, wired into the tab navigator at WhatsApp.js:77-87 | ConversationMessages — packages/clinic-web/src/components/dashboard/officialWhatsapp/conversationMessages/ConversationMessages.js, wired into OfficialWhatsapp.js:29-33 |
| List/table row | ConversationMessageReport.js (new) | CommonTable columns defined inline in ConversationMessages.js:29-99 |
| Analytics boxes | ConversationMessagesInfo.js (new), rendered via CommonList's LazyContent prop | ConversationMessagesInfo.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.