Tagging Messages Free / Paid
Business view
From 1 October, WhatsApp bills every outgoing business message under one of four categories:
- Service — a free-form reply typed by a human or the bot, inside an
open conversation.
- Utility — a template message about an existing transaction
(appointment reminders, receipts, etc.).
- Marketing — a promotional template.
- Authentication — an OTP/verification template.
Every message Dentolize sends from now on is stamped with which of these it is, and with whether it happened to be free (inside the 72-hour ad window) or paid. Messages sent before this feature shipped have neither value — they show up as "Unknown" in the reports, because the system genuinely doesn't know what they would have cost.
Technical view
The new fields
packages/prisma/schema.prisma:4194-4197 adds two nullable columns to ConversationMessage:
model ConversationMessage {
...
free Boolean?
category WhatsAppMessageCategory?
...
@@index(free)
@@index(category)
}
WhatsAppMessageCategory is a new enum (packages/prisma/schema.prisma:7146-7151, mirrored in packages/server/src/enums.graphql:1410-1415): SERVICE | UTILITY | MARKETING | AUTHENTICATION.
Both columns are left null on any row written before this migration ran — that's intentional, not a data gap to backfill (see packages/server/src/resolvers/queries/actions/officialWhatsApp/conversationMessagesInfo.js:1-9, and the UNKNOWN bucket described in Cost Reporting & Analytics).
Where free and category get set
Every code path that creates a ConversationMessage for an outgoing message now computes replyWindowOf(conversation) (see Conversation Windows) first, and writes:
- Human replies from the app —
packages/server/src/resolvers/mutations/actions/officialWhatsApp/handleSendWhatsappMessage.js:170-181. Always category: 'SERVICE' (a human free-typing a reply is always a session message, never a template).
- The bot's automated replies —
packages/whatsapp-official/src/messages/messages.service.ts:504-539. Also always WhatsAppMessageCategory.SERVICE.
- Scheduled/cron messages (appointment reminders, follow-ups) —
packages/server/src/cronJobs/messages/handleSendWhatsApp.js:168-185 and :294-303. These can be either a free-form reply (SERVICE, when the conversation is still open) or a template — in which case the category comes from the template itself: category: whatsAppMsg.category || undefined (line 300), reading message.template.category via packages/server/src/cronJobs/messages/embeddedValues.js:590. Templates already carried a category field before this PR (TemplateCategory — UTILITY/MARKETING; this PR adds the parallel WhatsAppMessageCategory enum used specifically for the message-level cost record, which also covers AUTHENTICATION and SERVICE).
- The phone-number-request nudge —
official-whats-app.utils.js:137-152 (handleSendWhatsAppMessageUrl) — always SERVICE.
In every case, every query that loads a template for sending now also selects category — see the repeated template: { select: { ..., category: true } } edits across messagesCron.js, sendMsgWhen.js, addOperationStep.js, updateOperationStepStatus.js, updateOperationsStatus.js, createNewOperations.js, and companyMutations.js.
Inbound messages are never tagged
free and category only ever get set on outgoing messages (incoming: false). Nothing a patient sends is billed, so inbound rows keep both columns null forever — this is also why the cost report query explicitly excludes incoming messages (see Cost Reporting & Analytics).
The bot side, in TypeScript
packages/whatsapp-official/src/messages/paid-messages.ts is a from-scratch port of the same replyWindowOf / freeWindowOpened logic for the NestJS bot service, since it can't import the server's JS utils directly. The comment in the file is explicit about the duplication: "Kept in step with the bot's own paid-messages helpers." Any future change to the windowing rules needs to be made in three places: official-whats-app.utils.js (server), paid-messages.ts (bot), and the two frontend whatsappUtils.js copies (mobile and web) — there is no shared package between them.