Qontak | Billing | WhatsApp Service Message Billing & Margin — Phase 1: Meta Oct 2026 Pricing
Product Requirements Document · NEW PRD v1.0
HEADER BLOCK
| Field | Value |
|---|---|
| PM | Qontak Billing PM (Bifrost) |
| PRD Version | 1.1 |
| Status | DRAFT |
| PRD Type | NEW |
| Epic | BIF-8755 — minted 2026-07-06 (continuation of BIF-4331 PMP lineage) |
| Squad | Bifrost |
| RFC Link | TBD — pending PRD approval |
| Figma Master | N/A — no net-new screens; existing usage/report surfaces reused (see §6) |
| Anchor | No — standalone, single-squad (Bifrost owns all surfaces) |
| Labels | epic:qontak-billing | module:billing | feature:wa-service-message-billing |
| Last Updated | 2026-07-22 |
| Source | Grounded in documentations/impact-analysis.md + direct code review of qontak-billing, moderator-be, report-worker, hub-chat, hub-service |
Table of Contents
- HEADER BLOCK
- 1. One-liner + Problem
- 2. Target Users + Persona Context
- 3. Non-Goals
- Scope Changes
- 4. Constraints
- 5. Feature Changes
- 6. New Features
- 7. API & Webhook Behavior
- 8. System Flow + User Stories + ACs
- 9. Rollout
- 10. Observability
- 11. Success Metrics
- 12. Launch Plan & Stage Gates
- 13. Dependencies
- 14. Key Decisions + Alternatives Rejected
- 15. Open Questions
- PRD CHANGELOG
1. One-liner + Problem
One-liner: Bill WhatsApp service (non-template) messages per-message at their Meta category rate with a 0 Qontak margin at launch (pure cost pass-through — clients pay exactly Meta's cost, no markup) from 1 Oct 2026, and surface that new deduction in every client and modpanel usage/report surface. The service margin is a configurable field set to 0 for every client — 0 by default for new CIDs (at account creation) and backfilled to 0 for existing CIDs on the release date.
Problem:
From 1 October 2026 Meta stops treating WhatsApp service messages (non-template messages sent inside the 24-hour customer service window) as free and bills them per message at rates matching the utility/authentication categories (Meta docs). Today Qontak's billing stack treats service conversations as effectively free — they arrive from Meta as pricing_type = free_customer_service, there is no seeded service row in the v2_wa_conversation_prices catalog (the price path falls back to the hardcoded WaDefaultFallbackPrice = 569.33), no service margin is configured in modpanel's margin list, and the modpanel/client MCC exports filter on origin_type = 'BI' which structurally excludes service (a UI conversation). If nothing changes, every service message Meta invoices Qontak for after 1 Oct 2026 is a cost with no matching client deduction — a direct, uncapped cost-recovery gap across the entire WhatsApp client base — and clients get no visibility into a charge they are now paying. This PRD closes that gap by passing Meta's service cost through to clients at cost (0 margin) — recovering what Meta bills without marking it up — while keeping the margin a real, configurable field so a markup can be introduced later without re-architecting.
2. Target Users + Persona Context
| Persona | Role | Goal | Pain | Workaround |
|---|---|---|---|---|
| Primary — Qontak Billing/Finance Ops (internal) | Bifrost/Finance operator who owns WhatsApp margin configuration and reconciles Meta invoices against client deductions | Recover Meta's new per-message service cost from clients at a controlled margin, and reconcile Meta charges to client deductions with zero leak | After 1 Oct 2026 service messages are billed by Meta but not deducted from clients; there is no service price row, no service margin, and MCC exports omit service — so the cost is invisible and unrecoverable | Manual spreadsheet reconciliation of Meta invoices vs. Qontak deductions; no systematic way to price or recover the service cost |
| Secondary — Modpanel Admin (internal) | Ops admin using moderator (moderator.qontak.com/billing/*) to set default and per-package margins and to pull MUV/MCC usage exports | Configure a service margin (default and per-package override) and export usage that includes service deductions for audit/billing | The margin list and custom-margin-by-packages screens have no service entry; the download-muv-mcc export excludes service rows | None — cannot configure or export what the system does not model |
| Secondary — Qontak Client Admin | Client account owner/admin viewing usage at chat.qontak.com/subscriptions/usages and exporting quota reports | Understand and audit the new per-message service charge appearing on their WhatsApp balance | Service deductions either do not appear or appear without a clear category, so the client cannot reconcile their own balance drop | Contacts Qontak CS to explain unexpected balance movement |
3. Non-Goals
- Not re-architecting the deduction engine. This PRD rides the existing July 2025 Per-Message Pricing (PMP) engine in
hub_corethat already keys on uniquemessage_idand readspricing_model/pricing_type/category. We do not build a new per-message billing model — we makecategory=servicebillable within it. - Not Meta Business Agent per-token AI billing (Meta's Aug 2026 change). Qontak does not use Meta Business Agent; that per-token dimension is entirely out of scope.
- Not changing marketing / utility / authentication category pricing or margins. Only the
servicecategory is added; existing category prices,ui_fee/bi_feemargins, and their deduction behavior are unchanged. - Not building new client-facing screens. We reuse the existing usage table (
subscriptions/usages), quota export (reports/export/quota), and modpanel MCC export (download-muv-mcc); no new page or route. - Not repricing historical service conversations. Service messages before the Meta effective date (1 Oct 2026) stay free/unbilled — no retroactive deduction.
- Not migrating the legacy conversation-based (CBP) deduction path in
qontak-billing(whatsapp_deduction.go, keyed byconversation_id). All clients are on PMP; the CBP path is not extended for service. - Not changing the Mekari Pay / invoice / self-topup flow. How clients pay for balance is untouched — only what is deducted from balance changes.
- Not adding percentage-vs-fixed margin configurability beyond what already exists. We use the existing margin storage; the exact margin unit for
serviceis settled in §14 / §15, not redesigned here.
Scope Changes
Engineering surfaces this PRD touches (controlled vocab). Kept in sync with the scope_changes frontmatter above.
- Backend —
hub_core(not checked out; Gemfile path../hub_core): the PMP deduction engine must billcategory=servicewhen Meta sendspricing_type=regularand continue to skip it forfree_customer_service/free_entry_point, applyingservicebase price +servicemargin; it also writesservicerows towa_conversation_logswithorigin_type/credited_to/is_auto_deductset so they are export-eligible.report-worker: the MCC export query (FetchMCCLogsExport/IsMCCLogsExistsindb/billingdb/queries/wa_conversation_logs.sql) filtersorigin_type = 'BI'and so excludesservice(aUIconversation) — must be widened to include billableservicerows; the quota export (billing_logs.sql,is_free = FALSE) must include newly-billable service; plus a batched idempotent backfill worker (precedent:worker_seed_blind_index.go).moderator-be: add aserviceentry to the margin list (accounts#get_margin_list→conversation_fees/package_fees) and to custom-margin-by-packages (custom_margin_by_packages_controller→custom_margin_by_packages(package_id, conversation_type, cost, tax)).qontak-billing: recognizeservicein the price/deduction path (mapPricingalready mapsservice→UI/service;GetWhatsappBasePricereadsv2_wa_conversation_prices). - Frontend —
hub-chat:subscriptions/usages(TableComponentWhatsappBalance.vue) already rendersconversation_categoryverbatim — add a friendly/styledservicelabel and confirm service rows display;reports/export/quota(ExportQuotaUsageDrawer.vue) triggers the backend export — confirm service rows appear once the backend emits them. - Data — seed the
serviceprice row(s) inv2_wa_conversation_prices(percountry/code,conversation_category='service',costnumeric(6,2)); set the defaultservicemargin to 0 in the margin config (applied at account creation for new CIDs); one-off backfill of theservicemargin = 0 across existing packages/CIDs on the release date. (Per-package override viacustom_margin_by_packagesis a deferred future lever, not seeded at launch — see §6/§13/§15.)
4. Constraints
| Field | Value |
|---|---|
| Meta effective date | CRITICAL — 1 October 2026. Service messages arrive as pricing_type = regular (billable) from this date. Before it, service arrives as free_customer_service and must stay free. All price/margin/backfill config must be live before this date; deduction must switch behavior on Meta's flag, not on a Qontak calendar toggle. |
| Billing model | PMP (Per-Message Pricing) only — deduction is keyed on unique message_id in hub_core. All clients migrated to PMP on 1 July 2025 (BIF-4331). The legacy CBP conversation_id-keyed path (qontak-billing/whatsapp_deduction.go) is out of scope. |
| Category → conversation type mapping | service maps to conversation_type/origin_type = "UI", conversation_category = "service" (per qontak-billing/mapPricing, whatsapp_deduction.go:88-133). This is the root cause of the export exclusion (filters expect BI). |
| Price catalog | v2_wa_conversation_prices(country, code, conversation_type, conversation_category, cost numeric(6,2)). cost is numeric(6,2) → hard cap < 10000.00 per row. No margin column on this table. Missing-row fallback = 569.33 (consts.WaDefaultFallbackPrice) — a real service row must exist so deductions are not silently priced at the fallback. |
| Default service margin (new CID) | 0 (value). Service is charged at cost (0 markup) at launch. Set at account creation for new CIDs — does not mirror the existing bi_fee = 10 default; create_account.rb must set the service margin to 0 explicitly (or leave it unseeded so the hub-core DEFAULT_FALLBACK_UI_COST = 0.00 fallback applies — RFC Decision). ui_fee/bi_fee and other category defaults are unchanged. |
| Existing-CID service margin (backfill) | 0 (value). The release-date backfill job writes service margin = 0 for every existing package/CID that lacks one, so existing clients are billed at cost from 1 Oct 2026 exactly like new clients. |
| Per-package (custom) service margin | Future lever only — not seeded at launch. custom_margin_by_packages has no read path in the deduction flow today (dead code — see §6/§15/RFC OQ-3); it is retained as the mechanism for introducing a differentiated per-package markup later, but launch value for every package is the default 0. |
| Margin unit | Follows existing convention — package_fees.ui_fee/bi_fee are percent margins; billing_component_prices supports margin_type = percentage/fixed. At a value of 0 the unit is economically moot (0% = 0 fixed), so it is no longer a launch blocker; the field representation is confirmed at RFC only so a non-zero markup can be set later. |
| Feature flag (deduction) | bill_service_messages | default: OFF. When OFF, service continues to be treated as today (free). When ON (enabled ≤ 1 Oct 2026), hub_core bills category=service on pricing_type=regular. Gates the money-moving behavior for safe rollout/rollback. |
| Feature flag (margin source) | get_margin_list_from_db (existing, moderator-be, default OFF) governs whether the margin list reads the billing DB directly or proxies Chat Panel — the service margin must be visible on both paths. |
| Platform | Backend + web. Client UI is web (hub-chat, Nuxt). No mobile change. |
| Performance | No added latency budget on the hot deduction path — the PMP engine already resolves price per message; adding a service price/margin lookup must stay within the existing per-message deduction SLA. Export generation stays on the existing async report-worker path (no synchronous change). |
| Backward compatibility | Marketing/utility/authentication pricing, margins, and deduction behavior unchanged. Service conversations before 1 Oct 2026 remain free (no retroactive deduction). |
4.1 Data Lifecycle
| Artifact Type | Retention Period | Cleanup Trigger | User-Visible Effect |
|---|---|---|---|
service price row in v2_wa_conversation_prices | Permanent (config) | Manual update only | None — internal catalog |
service default + per-package margin rows (conversation_fees / custom_margin_by_packages) | Permanent (config) | Manual update / package deletion | None — internal config |
| One-off margin backfill job run | Job record transient; written margin rows permanent | Job completes (idempotent — safe to re-run; guarded by WHERE ... NOT EXISTS) | None — internal; logged count of rows seeded/skipped |
wa_conversation_logs service deduction rows | Permanent (financial record) | None — billing audit trail | Appears in client usage/export and modpanel MCC export |
| MCC/quota export files (report-worker) | Existing export retention (unchanged by this PRD) | Existing cleanup cron | Download link per existing behavior |
5. Feature Changes
Change ID: CHG-001 — Service becomes a billable category in the PMP deduction engine (hub_core)
| Field | Detail |
|---|---|
| Change Type | Modified deduction logic |
| Trigger | Meta webhook delivers a service message with pricing_type = regular (from 1 Oct 2026) |
| Before | Service arrives as pricing_type = free_customer_service → PMP engine skips deduction (free). |
| After | With flag bill_service_messages = ON: pricing_type = regular + category = service → deducts once per unique message_id at the service base price + service margin. free_customer_service / free_entry_point service messages remain free. |
| Backend | hub_core PMP engine (verify no "service is always free" special-case remains). Writes wa_conversation_logs row with conversation_category='service', origin_type, credited_to, is_auto_deduct=TRUE, pricing_type, pricing_model. |
Change ID: CHG-002 — MCC export includes billable service rows (report-worker)
| Field | Detail |
|---|---|
| Change Type | Modified export query filter |
| Trigger | Client (subscriptions/usages export) or modpanel (download-muv-mcc) requests an MCC/wa_balance export |
| Before | FetchMCCLogsExport / IsMCCLogsExists filter origin_type = 'BI' AND is_auto_deduct = TRUE AND credited_to IN ('wa_balance','wa_credit','wa_balance_initial','wa_postpaid_limit'). Service (origin_type = 'UI') is structurally excluded. |
| After | Filter widened so billable service rows appear (e.g. include origin_type = 'UI' where conversation_category = 'service', or filter on conversation_category/pricing_type rather than origin_type alone). conversation_category is already a SELECTed output column, so no schema change. |
| Backend | report-worker/db/billingdb/queries/wa_conversation_logs.sql (+ regenerated wa_conversation_logs.sql.go). Mirror in the client quota export (billing_logs.sql, is_free = FALSE) so service is not filtered out there. |
Change ID: CHG-003 — Service category surfaced in client usage table (hub-chat)
| Field | Detail |
|---|---|
| Change Type | Modified display (label/styling) |
| Page | chat.qontak.com/subscriptions/usages |
| Before | TableComponentWhatsappBalance.vue renders conversation_category as raw free text under the Category column; there is no category allow-list, so service already renders unstyled if the backend returns it. |
| After | Add a friendly/styled service label (introduce a small category-label map near tableHeaders/the Category cell, lines ~269-274). Confirm service rows fetched from GET /api/core/v1/{org}/reports/billing/mcc_logs display with correct deducted_credit. |
| Frontend | hub-chat/features/subscriptions/usages/TableComponentWhatsappBalance.vue |
6. New Features
This PRD introduces no net-new client-facing screens. The net-new elements are backend/config and internal (modpanel) config fields on existing pages. They are documented here for completeness.
Feature: service margin entry in the Margin List (modpanel)
| Field | Detail |
|---|---|
| URL | moderator.qontak.com/billing/accounts/get_margin_list |
| Access | Modpanel Admin (internal) |
| What's new | The margin list gains a service conversation-type margin, shown per account alongside ui_margin/bi_margin and the existing conversation_fee[] rows. Backed by conversation_fees(conversation_type='service', cost, tax) (and/or the service margin representation confirmed in §15). Default value 0 (charge at cost; no markup at launch). Must render on both the DB path (GetMarginListDb, flag get_margin_list_from_db = ON) and the Chat Panel proxy path (flag OFF). |
UI States:
| State | Description |
|---|---|
| Empty | Account with no service margin configured → shows the backfilled default (0), never blank. |
| Loading | Existing margin-list skeleton/spinner (unchanged). |
| Error | Existing error handling for the margin list (unchanged); log if the service field cannot resolve. |
| Success | service margin value shown per account and included in the margin export (export_margin). |
Feature: service custom margin in Custom Margin by Packages (modpanel)
| Field | Detail |
|---|---|
| URL | moderator.qontak.com/billing/custom-margin-by-packages |
| Access | Modpanel Admin (internal); gated by existing Flipper flag custom_margin_field (default OFF) |
| What's new | Future lever — not activated at launch. The capability to set a service margin per package (via custom_margin_by_packages_controller, params package_id, conversation_type, cost, tax, stored in custom_margin_by_packages keyed uniquely on (package_id, conversation_type)) is documented here for completeness, but at launch no per-package override is seeded — every package uses the default service margin of 0 (charge at cost). This path has no read wiring in the deduction flow today (dead code — see the provenance note and §15/RFC OQ-3), so it is deferred: it exists so a differentiated per-package markup can be introduced later without new schema, not as launch scope. |
UI States:
| State | Description |
|---|---|
| Empty | Package with no custom service margin → default (0) applies; row absent from this screen. This is the state for all packages at launch. |
| Loading | Existing custom-margin list loading state (unchanged). |
| Error | Uniqueness violation on (package_id, service) → existing validation error surfaced (create use case enforces uniqueness). |
| Success | A future service custom margin, once the read path is wired, saved and applied to the package's service deductions (overriding the default 0). Not exercised at launch. |
Provenance note:
custom_margin_by_packagesis defined by a migration inqontak-billing(20260402000000_create_custom_margin_by_packages) but has no migration/schema entry inmoderator-be— moderator reads/writes it viaBillings::ApplicationRecordand it is provisioned out-of-band. It is currently defined but not yet wired into the qontak-billing/hub_corededuction flow (no read path), which is why per-package overrides are deferred and launch relies solely on the default (0) — see §13/§15.
Figma: N/A — modpanel config fields on existing screens; no new design.
7. API & Webhook Behavior
| # | Behavior | Entity Affected | Triggered By | Expected Behavior | Failure Behavior |
|---|---|---|---|---|---|
| 1 | Deduct a billable service message | wa_conversation_logs (new row); org WhatsApp balance/credit | Meta webhook: category = service, pricing_type = regular, unique message_id (from 1 Oct 2026), flag bill_service_messages = ON | PMP engine (hub_core) resolves service base price from v2_wa_conversation_prices (per country/code) + applies the service margin (0 at launch — charge at cost; per-package override deferred), deducts once per unique message_id, writes a wa_conversation_logs row with conversation_category='service', pricing_type, pricing_model, is_auto_deduct=TRUE, and an export-eligible credited_to. | Duplicate message_id → no double deduction (idempotent on message id). No service price row → engine must NOT silently deduct the 569.33 fallback for money movement; log and alert (service_price_missing). Insufficient balance → existing negative-balance / postpaid handling applies. |
| 2 | Skip a free service message | None (no deduction) | Meta webhook: category = service, pricing_type = free_customer_service or free_entry_point | No deduction; message stays free exactly as today. Optionally logged for visibility, not billed. | If misclassified as regular upstream, the dedup + reconciliation report must catch the discrepancy (§10). |
| 3 | Resolve service base price | v2_wa_conversation_prices read | Deduction (behavior 1) or a pricing preview | GetWhatsappBasePrice(code, conversation_type='UI', conversation_category='service') returns the seeded service cost (numeric(6,2)); cached in Redis key WaPrice::{country}::UI::service. | Missing row → today returns 569.33 fallback. For service, treat missing as a config error (behavior 1 failure), not a silent charge. |
| 4 | Read/write service margin (modpanel) | conversation_fees / package_fees (default) | Modpanel Admin views get_margin_list; update_margin pushes to Chat Panel | Margin list shows the service margin (default 0) on both DB and Chat Panel paths. Per-package override via custom-margin-by-packages is deferred (dead read path, §15) — not part of launch. | Chat Panel push failure on update_margin → surfaced to admin; margin not silently dropped. |
| 5 | Backfill service margin = 0 on release (existing CIDs) | conversation_fees (bulk) | Release deploy → operator enqueues backfill job | Idempotent batched job writes the service margin = 0 for every existing eligible package/CID lacking one, so existing clients bill at cost from 1 Oct 2026 (new CIDs get 0 at account creation instead); logs seeded/skipped counts. No per-package override is seeded. | Re-run is safe (guarded by NOT EXISTS). Partial failure → job resumes from offset; per-row errors counted and logged, do not abort the batch. |
| 6 | Export MCC/quota logs including service | Export file (MCC/wa_balance; quota) | Client export (POST {IAG}/report/v1/billings/logs/export, quota_type=wa_balance, aggregation_by=message) or modpanel download-muv-mcc (GET .../muv_mcc_logs → billing/report service) | Billable service rows are included (filter widened off origin_type='BI'-only); conversation_category='service' and message_id appear per row; totals include service deductions. | If the widened filter is not deployed, service rows are silently missing → detectable as export-vs-deduction mismatch (§10 reconciliation). |
Claude resolves during RFC: exact hub_core interactor names, HTTP methods/paths for the billing/report internal endpoints, the precise widened SQL predicate, and the margin field representation.
8. System Flow + User Stories + ACs
8.1 System Flow
Flow: Billable Service Message — Price → Margin → Deduct → Report Type: API Sequence / State Lifecycle
- Config (pre-1-Oct-2026): Ops seeds the
servicebase price intov2_wa_conversation_prices(per country/code) and sets the defaultservicemargin to 0 in the margin config (new CIDs inherit 0 at account creation); the release-date backfill job writesservicemargin = 0 for every existing package/CID that lacks one. No per-package override is seeded. - A customer sends / an agent replies with a WhatsApp service (non-template) message inside the customer service window.
- Meta delivers the message webhook. Before 1 Oct 2026 it carries
pricing_type = free_customer_service; from 1 Oct 2026 a billable one carriespricing_type = regular,category = service, with a uniquemessage_id. - The
hub_corePMP engine readspricing_model/pricing_type/category. - Decision — billable? If
pricing_type != regular(free_customer_service / free_entry_point) → skip deduction (free), end. - If
pricing_type = regularand flagbill_service_messages = ON: resolveservicebase price (GetWhatsappBasePrice, country/code) and theservicemargin (0 at launch → deducted amount = base price only). - Decision — price present? If no
serviceprice row resolves → do not deduct the 569.33 fallback for real money; logservice_price_missing, alert, end (fail-safe). - Deduct once per unique
message_id(idempotent); write awa_conversation_logsrow withconversation_category='service',pricing_type,pricing_model,is_auto_deduct=TRUE, export-eligiblecredited_to. - Client views the deduction at
subscriptions/usages(Category = "Service") and can export it viareports/export/quota. - Ops/Admin pulls the modpanel
download-muv-mccexport; service rows are included (widened filter). - Reconciliation: service deductions are compared against Meta's service invoice to confirm zero leak (§10).
Failure branches:
- Duplicate
message_id→ no second deduction (idempotent). serviceprice row missing → fail-safe (no fallback charge), alert Ops.- Export filter not widened → service rows missing from exports; caught by export-vs-deduction reconciliation.
📊 System Flow — Billable Service Message
flowchart TD
Config[Ops seeds service price + margins;\nbackfill job fills gaps] --> Msg[Service message sent in CSW]
Msg --> Webhook[Meta delivers webhook:\ncategory=service, pricing_type, message_id]
Webhook --> Engine[hub_core PMP engine reads\npricing_model / pricing_type / category]
Engine --> Billable{pricing_type = regular\nAND flag ON?}
Billable -- No (free_customer_service / free_entry_point / flag OFF) --> Free[Skip deduction — free]
Billable -- Yes --> Resolve[Resolve service base price\n+ service margin]
Resolve --> PriceOk{service price row exists?}
PriceOk -- No --> FailSafe[Log service_price_missing,\nalert Ops, do NOT charge fallback]
PriceOk -- Yes --> Dedup{message_id already billed?}
Dedup -- Yes --> NoOp[No double deduction]
Dedup -- No --> Deduct[Deduct once per message_id;\nwrite wa_conversation_logs\ncategory=service, is_auto_deduct=TRUE]
Deduct --> ClientUI[Client sees deduction\nat subscriptions/usages]
Deduct --> ClientExport[Client quota export\nincludes service]
Deduct --> Modpanel[Modpanel download-muv-mcc\nincludes service]
Deduct --> Recon[Reconcile deductions\nvs Meta service invoice]
8.2 User Stories
| User Story | Importance | Mockup | Technical Notes | Acceptance Criteria |
|---|---|---|---|---|
| [WSVC-S01] — Service message follows Meta category + pricing_type and is deducted As the billing system, I want a WhatsApp service message to follow Meta's category and pricing_type and be deducted per message when billable, so that Qontak recovers Meta's Oct 2026 service cost without over- or under-charging. | Must Have | N/A — backend | Data Fields: • category (string) — from Meta webhook; "service"• pricing_type (string) — regular (billable) / free_customer_service / free_entry_point• pricing_model (string) — PMP• message_id (string) — dedup key• bill_service_messages (bool flag) — default OFFBefore-After Behavior: Before: service arrives as free_customer_service and the PMP engine skips it (free); any "service is always free" special-case must be removed. After: pricing_type=regular + category=service deducts once per message_id at service price + margin; free pricing_types stay free. Logic lives in hub_core (Gemfile ../hub_core, not checked out — hard dependency, §13). | — Happy Path — • AC-1: Given flag bill_service_messages=ON and a webhook with category=service, pricing_type=regular and a new message_id, when the engine processes it, then it deducts once at (service base price + service margin) and writes a wa_conversation_logs row with conversation_category='service', pricing_type='regular', is_auto_deduct=TRUE.• AC-2: Given category=service, pricing_type=free_customer_service, when processed, then no deduction occurs and balance is unchanged.• AC-3: Given a service message whose message_id was already billed, when the webhook is re-delivered, then no second deduction occurs (idempotent on message_id).— Error / Unhappy Path — • ERR-1: Given no service price row resolves, when a billable service message is processed, then the engine does NOT deduct the 569.33 fallback, logs service_price_missing, and alerts Ops.— Permission Model — • CAN: System (webhook-driven), gated by bill_service_messages• CANNOT: No manual per-message deduction • Unauthorized: N/A — UI States — • N/A — backend; surfaced via WSVC-S06/S07/S08 |
| [WSVC-S02] — Seed the service base price in the price catalog As Billing Ops, I want a service base price seeded in v2_wa_conversation_prices, so that a billable service message resolves a real Meta rate instead of the hardcoded fallback. | Must Have | N/A — data/config | Data Fields: • country / code (string) — per-country keys• conversation_type (string) — "UI" (service maps to UI)• conversation_category (string) — "service"• cost (numeric(6,2)) — Meta service rate; hard cap < 10000.00Before-After Behavior: Before: no service row exists; GetWhatsappBasePrice misses and returns consts.WaDefaultFallbackPrice = 569.33. After: a service row per country/code returns the real Meta rate; Redis key WaPrice::{country}::UI::service caches it. Read by qontak-billing/GetWhatsappBasePrice and the hub_core engine. | — Happy Path — • AC-1: Given a service row seeded for a country, when GetWhatsappBasePrice(code, 'UI', 'service') is called, then it returns the seeded cost (not 569.33).• AC-2: Given a per-country rate, when a service message from that country is priced, then the country-specific service cost is used.• AC-3: Given a Meta rate that would exceed numeric(6,2) (≥ 10000.00), when seeding, then the value is rejected/flagged (schema cap) and escalated (§15).— Error / Unhappy Path — • ERR-1: Given a country with no service row, when a billable service message arrives, then WSVC-S01/ERR-1 fail-safe fires (no fallback charge, alert).— Permission Model — • CAN: Billing Ops / migration • CANNOT: Client-facing users • Unauthorized: N/A — UI States — • N/A — data seed |
| [WSVC-S03] — Add a default service margin (0) to the Margin List As a Modpanel Admin, I want a service margin shown in the margin list with a default of 0, so that every package charges service at cost (no markup) while the margin remains a real, adjustable field. | Must Have | Modpanel — get_margin_list | Data Fields: • conversation_type (string) — "service"• cost / margin value (numeric) — default 0• tax (numeric)• get_margin_list_from_db (flag) — governs DB vs Chat Panel sourceBefore-After Behavior: Before: margin list exposes ui_margin(=ui_fee, default 5), bi_margin(=bi_fee, default 10), and conversation_fee[] — no service entry. After: a service margin (default 0) appears per account, on both GetMarginListDb (flag ON) and Chat Panel proxy (flag OFF) paths, and in export_margin. Storage via conversation_fees(conversation_type='service') and/or the representation confirmed in §15. The default of 0 is set for new CIDs at account creation and does not mirror bi_fee=10. | — Happy Path — • AC-1: Given an account with no explicit service margin, when the margin list loads, then a service margin of 0 is shown (never blank).• AC-2: Given get_margin_list_from_db=ON, when the list loads, then the service margin resolves from the billing DB; given the flag OFF, then it resolves via the Chat Panel proxy — both show the same value.• AC-3: Given an admin exports the margin list ( export_margin), when the file is generated, then the service margin column is included.— Error / Unhappy Path — • ERR-1: Given the service margin cannot be resolved on one path, when the list loads, then the row does not silently blank — it falls back to the default (0) and logs a warning. — Permission Model — • CAN: Modpanel Admin • CANNOT: Client users • Unauthorized: standard modpanel auth — UI States — • Loading: existing margin-list spinner • Empty: default 0 shown • Error: existing error surface • Success: service margin visible + exportable |
| [WSVC-S04] — Per-package service margin override (Deferred — post-launch future lever, RFC OQ-3) As a Modpanel Admin, I want to be able to set a per-package service custom margin, so that specific packages can carry a differentiated markup once a non-zero service margin is introduced. Not launch scope — at launch every package uses the default 0. | Deferred | Modpanel — custom-margin-by-packages | Data Fields: • package_id (string) — billing package identifier• conversation_type (string) — "service"• cost (decimal) — per-package override value (no launch seed)• tax (decimal)• unique on (package_id, conversation_type)• Flipper flag custom_margin_field (default OFF)Before-After Behavior: Before: custom-margin-by-packages CRUD exists but has no service rows and no read path in the deduction flow (dead code, §15/RFC OQ-3). After (deferred): once the read path is wired, a service row per package can be created/edited and overrides the default (WSVC-S03) where present. Writes custom_margin_by_packages(package_id, 'service', cost, tax) via existing controller. At launch this story is not delivered — the default margin (0) applies to all packages. | — Happy Path (deferred) — • AC-1: Given the custom_margin_field flag ON and the read path wired, when an admin creates a service custom margin for a package, then a row (package_id, 'service', cost, tax) is stored.• AC-2: Given a package with a service custom margin, when a service message on that package is priced, then the custom margin is applied instead of the default (0).• AC-3: Given a package without a custom service margin, when priced, then the default (0) applies.— Error / Unhappy Path — • ERR-1: Given a service custom margin already exists for a package, when the admin creates another, then the (package_id, conversation_type) uniqueness check rejects it with a validation error.— Permission Model — • CAN: Modpanel Admin with custom_margin_field ON• CANNOT: Client users; admins without the flag • Unauthorized: standard modpanel auth — UI States — • Loading: existing custom-margin list state • Empty: no row → default (0) applies • Error: uniqueness validation error • Success: custom service margin saved and applied (post-launch) |
| [WSVC-S05] — Backfill the service margin = 0 for existing CIDs on release As Billing Ops, I want the default service margin (0) backfilled across existing packages/CIDs on the release date, so that every existing client bills service at cost the moment Meta's change takes effect — matching new CIDs, which get 0 at account creation. | Must Have | N/A — backend job | Data Fields: • target table — conversation_fees (default service margin)• batch_size (int) — job payload (default per precedent)• seeded/skipped counters (logged) Before-After Behavior: Before: existing packages have no service margin row. After: a one-off idempotent batched job writes service margin = 0 for every existing eligible package/CID lacking one. (New CIDs do not need the backfill — they inherit 0 at account creation.) No per-package override is seeded. Precedent: report-worker gocraft/work batched idempotent jobs (worker_seed_blind_index.go, enqueued via workenqueue); if margins live in hub_core, a Rails migration/rake task there. | — Happy Path — • AC-1: Given existing packages without a service margin, when the backfill runs, then each eligible package gets the default (0) and the seeded/skipped counts are logged.• AC-2: Given the job is run a second time, when it processes, then already-seeded packages are skipped (idempotent, guarded by NOT EXISTS) — no duplicate or overwritten rows.• AC-3: Given a very large package set, when the job runs, then it processes in batches and advances by offset without loading all rows at once. — Error / Unhappy Path — • ERR-1: Given a per-row failure mid-batch, when it occurs, then the error is counted and logged and the batch continues (does not abort); the job can resume from the last offset. — Permission Model — • CAN: Ops (enqueue on release) • CANNOT: automatic unattended run without release gate • Unauthorized: N/A — UI States — • N/A — backend job (progress via logs/metrics) |
| [WSVC-S06] — Show service deduction in the client usage table As a Client Admin, I want service deductions to appear on subscriptions/usages with a clear "Service" category, so that I can see and reconcile the new per-message charge. | Should Have | hub-chat — subscriptions/usages | Data Fields: • conversation_category (string) — "service" (rendered under Category cell, lines ~269-274)• conversation_type, message_id, conversation_id, deducted_credit — existing columns• source API — GET /api/core/v1/{organization_id}/reports/billing/mcc_logsBefore-After Behavior: Before: TableComponentWhatsappBalance.vue renders conversation_category as raw text; service shows unstyled if returned, and (per WSVC-S08) service rows are excluded upstream anyway. After: with the upstream export filter widened, service rows appear; add a friendly/styled service label (introduce a small category-label map — none exists today). Per-message identity (Message ID column) already shipped (BIF-5621). | — Happy Path — • AC-1: Given the client's account has billable service deductions in range, when the usage table loads, then service rows appear with Category shown as "Service" and the correct deducted_credit.• AC-2: Given a service row, when rendered, then its message_id is shown/copyable in the existing Message ID column.• AC-3: Given a mix of categories, when the table loads, then service is visually distinguishable via the friendly label alongside marketing/utility/authentication. — Error / Unhappy Path — • ERR-1: Given the mcc_logs API returns no rows, when the table loads, then the existing empty state shows (no error), and no fabricated service row appears.— Permission Model — • CAN: Client Admin/roles with subscription access • CANNOT: users without billing view • Unauthorized: existing route guard — UI States — • Loading: existing skeleton • Empty: existing empty state • Error: existing error state • Success: service rows with "Service" label + deducted balance |
| [WSVC-S07] — Show service deduction in the client quota export As a Client Admin, I want the quota export from reports/export/quota to include service deductions, so that I can audit the charge offline. | Should Have | hub-chat — reports/export/quota | Data Fields: • export request — POST {IAGServiceUrl}/report/v1/billings/logs/export, quota_type=wa_balance, aggregation_by=message• output columns — include conversation_category (service), message_id, deducted credit• backend query — report-worker FetchMCCLogsExport (and billing_logs is_free=FALSE for the quota path)Before-After Behavior: Before: the file's columns/rows come from the backend; the MCC query filters origin_type='BI' and excludes service (UI), and the quota billing_logs query filters is_free=FALSE. After: with the filters widened (WSVC-S08 / CHG-002), billable service rows appear in the generated file; conversation_category is already an output column so no schema change. | — Happy Path — • AC-1: Given billable service deductions in the selected range, when the client generates the quota export, then the file contains service rows with conversation_category='service' and per-message granularity.• AC-2: Given the export completes, when downloaded, then service deductions are included in the totals consistent with the usage table (WSVC-S06). • AC-3: Given a range before 1 Oct 2026, when exported, then no service deductions appear (service was free then). — Error / Unhappy Path — • ERR-1: Given the backend filter is not yet widened, when the export runs, then service rows are missing — caught by export-vs-deduction reconciliation (§10) rather than surfacing wrong totals silently. — Permission Model — • CAN: Client roles with report_omnichannel_view• CANNOT: users without report permission • Unauthorized: existing route guard — UI States — • Loading: existing export-in-progress state • Empty: export with no service rows in range • Error: existing export failure state • Success: file includes service rows |
| [WSVC-S08] — Include service deduction in the modpanel MUV/MCC export As a Modpanel Admin, I want download-muv-mcc to include billable service rows, so that internal audit and Meta-invoice reconciliation are complete. | Must Have | Modpanel — download-muv-mcc | Data Fields: • request — GET /muv_mcc_logs (company_account_id, type=mcc, start/end, aggregation_by, timezone) → billing/report service• backend filter — report-worker FetchMCCLogsExport: today origin_type='BI' AND is_auto_deduct=TRUE AND credited_to IN (...)• output — conversation_category, message_id already SELECTedBefore-After Behavior: Before: MCC export filter origin_type='BI' structurally excludes service (origin_type='UI'). After: filter widened (e.g. include origin_type='UI' where conversation_category='service', or filter by conversation_category/pricing_type) so billable service rows appear; modpanel itself only forwards params (delegates to billing/report service), so the change is in report-worker. | — Happy Path — • AC-1: Given billable service deductions exist, when an admin pulls the MCC export, then service rows appear with conversation_category='service' and the correct credit.• AC-2: Given aggregation_by=message, when exported, then each service row shows its message_id; given aggregation_by=date, then service is aggregated as its own category line.• AC-3: Given free service messages (pre-Oct-2026 or free_customer_service), when exported, then they are not counted as billable rows.— Error / Unhappy Path — • ERR-1: Given the widened filter is not deployed, when the export runs, then service rows are absent; the reconciliation report (§10) flags the export-vs-deduction gap. — Permission Model — • CAN: Modpanel Admin ( oauth2 :modpanel)• CANNOT: client users • Unauthorized: modpanel auth — UI States — • Loading: existing async export/queued state • Empty: no service rows in range • Error: existing export error state • Success: export includes service rows |
| [WSVC-S01-NEG] — Free service messages are never billed (Guard Rail — from Non-Goal 5) As the billing system, when a service message is free (pre-Oct-2026 or free_customer_service/free_entry_point), then it must never be deducted. | Guard Rail | — | — | NEG-1: Given a service message with pricing_type != regular, when processed, then no deduction occurs.NEG-2: Given a service conversation dated before 1 Oct 2026, when reprocessed, then it is not retroactively billed. |
| [WSVC-S03-NEG] — Other categories' margins are untouched (Guard Rail — from Non-Goal 3) As a Modpanel Admin, when I add the service margin, then marketing/utility/authentication margins must be unchanged. | Guard Rail | — | — | NEG-1: Given the service margin (0) is added, when existing category margins are read, then ui_fee/bi_fee and other conversation_fees rows are unchanged. |
AC ids are numbered per story (each story restarts at AC-1). Composite ids minted here:
WSVC-S01/AC-1,WSVC-S01/AC-2,WSVC-S01/AC-3,WSVC-S01/ERR-1,WSVC-S01-NEG/NEG-1,WSVC-S01-NEG/NEG-2,WSVC-S02/AC-1,WSVC-S02/AC-2,WSVC-S02/AC-3,WSVC-S02/ERR-1,WSVC-S03/AC-1,WSVC-S03/AC-2,WSVC-S03/AC-3,WSVC-S03/ERR-1,WSVC-S03-NEG/NEG-1,WSVC-S04/AC-1,WSVC-S04/AC-2,WSVC-S04/AC-3,WSVC-S04/ERR-1,WSVC-S05/AC-1,WSVC-S05/AC-2,WSVC-S05/AC-3,WSVC-S05/ERR-1,WSVC-S06/AC-1,WSVC-S06/AC-2,WSVC-S06/AC-3,WSVC-S06/ERR-1,WSVC-S07/AC-1,WSVC-S07/AC-2,WSVC-S07/AC-3,WSVC-S07/ERR-1,WSVC-S08/AC-1,WSVC-S08/AC-2,WSVC-S08/AC-3,WSVC-S08/ERR-1.
9. Rollout
| Field | Value |
|---|---|
| Feature flag | bill_service_messages — default: OFF. Deduction of billable service stays OFF until config (price + margins + backfill) is verified live and the Meta date is reached. |
| Stage 0 — Config | Seed service price (v2_wa_conversation_prices), set the default service margin to 0 (new CIDs inherit it at account creation); run the release-date backfill job to write margin = 0 for existing CIDs (WSVC-S05). Verify on internal/test CIDs. Must complete before 1 Oct 2026. |
| Stage 1 — Internal | Enable bill_service_messages on ≤5 internal Bifrost test CIDs; send pricing_type=regular service messages; verify deduction, usage table, exports, and reconciliation end-to-end. |
| Stage 2 — Pilot | Enable on 5–10 real CIDs with known WhatsApp service volume; monitor deduction accuracy and export completeness for 1 week. |
| Stage 3 — Batched GA | Enable across all PMP CIDs in batches (25% → 50% → 100%), timed so 100% is live by 1 Oct 2026. |
| GA | bill_service_messages = ON for all clients; service billed exactly as Meta bills Qontak. |
| Backward compat | Marketing/utility/authentication unchanged; service before the Meta date and free pricing_types stay free. |
| Migration | Data seed + one-off idempotent backfill of margins (WSVC-S05). No destructive schema migration; conversation_category/pricing_type/message_id columns already exist. |
9.1 Migration Transition Window
| Field | Detail |
|---|---|
| Old record behavior | Service conversations logged before 1 Oct 2026 (or with pricing_type=free_customer_service) remain free — never retroactively deducted, and shown as free/absent in usage & exports. |
| New record behavior | Service messages from 1 Oct 2026 with pricing_type=regular are deducted per message_id at service price + margin and appear in all usage/report surfaces. |
| Coexistence period | The switch is driven by Meta's pricing_type per message, not a Qontak cutover — free and billable service rows coexist indefinitely, distinguished by pricing_type. Config (Stage 0) must be complete before the first billable webhook. |
| End state | All billable service messages priced (at cost, 0 margin) + deducted + reported; zero gap between Meta's service invoice and client deductions (reconciled per §10). |
10. Observability
Key Events:
| Event Name | Trigger | Properties |
|---|---|---|
service_message_deducted | Billable service message deducted | company_id, message_id, country, base_price, margin, total_deducted, credited_to, timestamp |
service_message_skipped_free | Service message with free pricing_type | company_id, message_id, pricing_type, timestamp |
service_price_missing | No service price row resolved for a billable message | company_id, country, code, message_id |
service_margin_backfill_run | Backfill job completes | seeded_count, skipped_count, error_count, batch_size, duration_ms |
service_deduction_dedup_hit | Duplicate message_id skipped | company_id, message_id |
service_export_vs_deduction_gap | Reconciliation finds export rows ≠ deduction rows | company_id, deduction_count, export_count, date_range |
| Field | Detail |
|---|---|
| Dashboard owner | Bifrost squad |
| Alert 1 | service_price_missing count > 0 in any 5-minute window → PagerDuty on-call (a billable message could not be priced). |
| Alert 2 | Sum of Meta service invoice vs. service_message_deducted total diverges by > 2% for a day → Slack #bifrost-billing + Finance. |
| Alert 3 | service_export_vs_deduction_gap fires for any CID → Slack #bifrost-billing (export filter regression). |
10.1 Post-Launch Monitoring Cadence
| Field | Detail |
|---|---|
| Review cadence | Daily for the first 2 weeks around 1 Oct 2026, then weekly for 4 weeks, then monthly. |
| Owner | Bifrost PM + Finance Ops |
| Review scope | service_message_deducted, service_price_missing, service_export_vs_deduction_gap, and Meta-invoice-vs-deduction reconciliation totals. |
| Trigger threshold 1 | Meta-vs-deduction divergence > 2% for 1 day → investigate pricing/margin/flag config immediately. |
| Trigger threshold 2 | service_price_missing > 0 for any country for 2 consecutive hours → escalate to Bifrost eng. |
| Rollback consideration | If deductions are systematically wrong (mispriced or double-charged) and cannot be resolved within 4 hours, PM sets bill_service_messages = OFF globally (service reverts to free) pending root cause. |
11. Success Metrics
Quality & Accuracy:
| Metric | Definition | Baseline | Target |
|---|---|---|---|
| ⭐ Service billing coverage | % of Meta-billed service messages that produce a matching client deduction | N/A — new (0% today; service is free) | ≥ 99.5% within 2 weeks of 1 Oct 2026 |
| Meta-invoice-to-deduction variance | |Meta service charge − total service deducted| ÷ Meta service charge, monthly | N/A — new | ≤ 2% every month post-GA |
| Mispriced service deductions | Service deductions using the 569.33 fallback or a wrong-country rate | N/A — new | 0 (fail-safe blocks fallback charges) |
Efficiency & Impact:
| Metric | Definition | Baseline | Target |
|---|---|---|---|
| Service cost recovered | Meta service cost recovered from clients vs. absorbed by Qontak (at cost, 0 margin) | 100% absorbed (no deduction today) | ≥ 99% of Meta's service cost recovered within 30 days of GA |
| Manual reconciliation effort | Ops hours/month spent reconciling service charges by spreadsheet | Current manual effort (to baseline in Stage 1) | ≥ 80% reduction once exports include service |
Adoption:
| Metric | Definition | Baseline | Target |
|---|---|---|---|
| Config completeness | % of active PMP packages with a service price + margin configured | 0% | 100% before 1 Oct 2026 (Stage 0 gate) |
12. Launch Plan & Stage Gates
| Stage | Audience | Duration | Success Gate to Advance | Owner |
|---|---|---|---|---|
| Stage 0 — Config & Backfill | Internal / all packages (config only, flag OFF) | Until complete, before 1 Oct 2026 | service price seeded for all active countries; default service margin = 0 (new CIDs); existing CIDs backfilled to margin = 0; Config completeness = 100%; backfill idempotency verified (re-run = 0 dup rows) | Bifrost + Ops |
| Stage 1 — Internal | ≤5 internal test CIDs (flag ON) | 1 week | End-to-end verified: pricing_type=regular service deducts once per message_id at correct price+margin; appears in usage table, client export, modpanel export; 0 service_price_missing; reconciliation matches | PM + QA |
| Stage 2 — Pilot | 5–10 real CIDs with service volume | 1 week | Meta-invoice-to-deduction variance ≤ 2%; 0 double-charges; export-vs-deduction gap = 0; no P0/P1 | PM + Eng Lead |
| Stage 3 — Batched GA | All PMP CIDs (25% → 50% → 100%) | Timed to reach 100% by 1 Oct 2026 | Each batch sustains pilot gates for 48h before the next; no regression in other categories | PM + Eng Lead |
| GA | All clients | Ongoing | Service billing coverage ≥ 99.5% sustained for 2 weeks; variance ≤ 2% | PM + Finance |
13. Dependencies
| Dependency | Owning Team | Deliverable Needed | Blocking? |
|---|---|---|---|
hub_core PMP deduction engine (Gemfile ../hub_core, not checked out) | Bifrost | Confirm/modify the type=regular branch to bill category=service (remove any "service always free" special-case); apply service price + margin; write export-eligible wa_conversation_logs rows | YES — this is where the money moves; PRD cannot deliver S01 without it |
Meta webhook semantics for service on 1 Oct 2026 | Meta (external) | Meta actually sending service as pricing_type=regular from the effective date | YES — the trigger for billing |
custom_margin_by_packages wiring into deduction | Bifrost | Confirm the table (defined in qontak-billing migration 20260402000000, not yet read by deduction code; no schema in moderator-be) is read by the pricing path so S04 overrides actually apply | YES for S04 (default S03 works without it) |
Chat Panel package_fee API (update_margin push) | Bifrost / Chat Panel | Accept + persist the service margin on both DB and proxy paths | YES for S03 on the proxy path |
| report-worker MCC/quota export filter change | Bifrost | Widen FetchMCCLogsExport (and billing_logs is_free) so service rows are included | YES for S07/S08 |
| Meta service rate card + margin policy | Finance / Product | The actual per-country Meta service cost values. Margin policy is settled: 0 (charge at cost) at launch — so only the rate card blocks Stage 0 config; the margin unit (%, fixed) is moot at 0 and confirmed at RFC only for a future markup | YES (rate card) — Stage 0 config cannot start without the per-country cost values |
14. Key Decisions + Alternatives Rejected
14a — Decisions Made
| Date | Decision | Rationale |
|---|---|---|
| 2026-07-06 | Ride the existing hub_core PMP engine rather than build a new per-message billing model | PMP already keys on unique message_id and reads pricing_model/pricing_type/category (BIF-4331, July 2025). Service is a config + branch change, not a new engine. |
| 2026-07-06 | Split "price" (Meta base cost, v2_wa_conversation_prices.cost) from "margin" (Qontak markup, margin config) | Mirrors the existing model where client charge = base price + fee/margin, surfaced in reporting as total_price − base_price. Keeps Meta cost and Qontak markup independently tunable. |
| 2026-07-22 | Service margin = 0 at launch (charge at cost / pure pass-through) — recover Meta's per-message service cost from clients with no Qontak markup | Go-to-market decision: introduce the new billable category without an added markup so clients pay exactly what Meta charges. The margin stays a real, configurable field (default 0), so a markup can be turned on later without re-architecting. Supersedes the 2026-07-06 "default 10 / per-package 50" decision. |
| 2026-07-22 | Two mechanisms to reach margin 0 — new CIDs get 0 at account creation; existing CIDs are backfilled to 0 on the release date | New CIDs must not inherit the bi_fee=10 default for service; existing CIDs need a one-off idempotent backfill (WSVC-S05) so all clients are at cost from 1 Oct 2026. Aligns with hub-core's DEFAULT_FALLBACK_UI_COST = 0.00 (a missing service fee already resolves to 0). |
| 2026-07-22 | Defer the per-package override (WSVC-S04) | custom_margin_by_packages has no read path in the deduction flow (dead code, §15/RFC OQ-3) and — with a flat 0 margin at launch — no override is needed. Retained as a future lever, not launch scope. |
| 2026-07-06 | Gate deduction behind bill_service_messages flag; drive the actual bill/skip on Meta's pricing_type, not a Qontak date toggle | Enables safe staged rollout/rollback and guarantees free service (pre-date / free_customer_service) is never billed. |
| 2026-07-06 | Fix the export exclusion in report-worker, not by rewriting modpanel | Root cause is the origin_type='BI' filter in FetchMCCLogsExport; modpanel only forwards params and delegates. |
| 2026-07-06 | Fail-safe on missing price: do NOT charge the 569.33 fallback for service | The fallback is a legacy safety net; using it for real money on an unconfigured country would mis-bill clients. |
14b — Alternatives Rejected
| Alternative | Why Rejected | Date |
|---|---|---|
| Absorb the Meta service cost (don't bill clients) | Uncapped, permanent cost leak across the entire WhatsApp base; not sustainable. (Note: billing at 0 margin still recovers the cost — absorbing recovers nothing.) | 2026-07-06 |
Bill service via the legacy CBP conversation_id path (qontak-billing/whatsapp_deduction.go) | All clients are on PMP; CBP is per-conversation not per-message and would mis-model Meta's per-message charge | 2026-07-06 |
Add a margin column directly to v2_wa_conversation_prices | Margin already lives in dedicated config (package_fees/conversation_fees/custom_margin_by_packages); duplicating it on the price table splits the source of truth | 2026-07-06 |
Widen the MCC export by changing origin_type='BI' to include all UI | Would pull in non-service UI conversations (e.g. referral_conversion); filter must target conversation_category='service'/pricing_type specifically | 2026-07-06 |
| Retroactively bill historical service messages | Clients were told service was free before the Meta date; retroactive billing is a trust/compliance risk | 2026-07-06 |
15. Open Questions
| # | Type | Question | Owner | Deadline |
|---|---|---|---|---|
| 1 | Resolved (2026-07-22) | service margin a percentage or a fixed amount? | Finance + Bifrost | Closed |
| 2 | Open Question | Does service (a UI conversation) reuse the account-level ui_margin, or does it get a dedicated service margin field? Stories assume a dedicated service conversation-type margin — confirm the storage representation in conversation_fees vs package_fees. | Bifrost | 2026-08-01 |
| 3 | Risk | hub_core is not checked out (../hub_core); the actual bill/skip logic and margin application cannot be verified from the accessible repos. Mitigation: get hub_core access at RFC kickoff; treat S01 as unverified until then; Stage 1 internal test gates on observed deduction behavior. | Bifrost Eng | 2026-07-20 |
| 4 | Risk | custom_margin_by_packages is defined in qontak-billing (migration 20260402000000) and CRUD'd by moderator-be, but no deduction code reads it yet and it has no moderator-be schema. Mitigation: confirm the live table shape and wire it into the pricing path before relying on per-package overrides (S04); default margin (S03) is the fallback. | Bifrost Eng | 2026-08-01 |
| 5 | Risk | v2_wa_conversation_prices.cost is numeric(6,2) (cap < 10000.00). If any country's Meta service rate + representation exceeds this, seeding fails. Mitigation: validate the full rate card against the cap during Stage 0; widen the column via migration if needed. | Bifrost Eng | 2026-08-15 |
| 6 | Open Question | Exact widened predicate for the MCC/quota export — filter on conversation_category='service', on pricing_type, or on origin_type IN ('BI','UI') scoped to service? Must include billable service without pulling in referral_conversion or other UI types. | Bifrost Eng | 2026-08-15 |
| 7 | Assumption | Meta will send service as pricing_type=regular from 1 Oct 2026 exactly as in the July 2025 PMP semantics, and Qontak's webhook parser already surfaces pricing_type/category per message. | PM | 2026-09-01 |
PRD CHANGELOG
| Version | Date | By | Section | Type | Summary |
|---|---|---|---|---|---|
| 1.1 | 2026-07-22 | Claude (PM) | §1, Scope Changes, §4, §6, §7, §8, §9, §12–15, Changelog | CHANGED | Service margin set to 0 (charge at cost / pass-through) at launch, superseding the 10/50 model. New CIDs default to 0 at account creation; existing CIDs are backfilled to 0 on the release date (WSVC-S05 reframed). Per-package override (WSVC-S04) deferred as a future lever (dead read path, OQ-3). Problem reframed from "margin leak" to "cost-recovery gap". Margin-unit open question (OQ-1) resolved as moot at 0. Other categories' margins unchanged. |
| 1.0 | 2026-07-06 | Claude | All | CREATED | Initial NEW PRD for WhatsApp service-message billing & margin (Meta Oct 2026), grounded in code review of qontak-billing, moderator-be, report-worker, hub-chat, hub-service; 8 user stories (WSVC-S01…S08) covering deduction, price catalog, default + package margin, backfill, and client/modpanel usage & export surfacing. |