Skip to main content

Qontak | Billing | WhatsApp Service Message Billing & Margin — Phase 1: Meta Oct 2026 Pricing

Product Requirements Document · NEW PRD v1.0


HEADER BLOCK

FieldValue
PMQontak Billing PM (Bifrost)
PRD Version1.1
StatusDRAFT
PRD TypeNEW
EpicBIF-8755 — minted 2026-07-06 (continuation of BIF-4331 PMP lineage)
SquadBifrost
RFC LinkTBD — pending PRD approval
Figma MasterN/A — no net-new screens; existing usage/report surfaces reused (see §6)
AnchorNo — standalone, single-squad (Bifrost owns all surfaces)
Labelsepic:qontak-billing | module:billing | feature:wa-service-message-billing
Last Updated2026-07-22
SourceGrounded in documentations/impact-analysis.md + direct code review of qontak-billing, moderator-be, report-worker, hub-chat, hub-service

Table of Contents


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

PersonaRoleGoalPainWorkaround
Primary — Qontak Billing/Finance Ops (internal)Bifrost/Finance operator who owns WhatsApp margin configuration and reconciles Meta invoices against client deductionsRecover Meta's new per-message service cost from clients at a controlled margin, and reconcile Meta charges to client deductions with zero leakAfter 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 unrecoverableManual 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 exportsConfigure a service margin (default and per-package override) and export usage that includes service deductions for audit/billingThe margin list and custom-margin-by-packages screens have no service entry; the download-muv-mcc export excludes service rowsNone — cannot configure or export what the system does not model
Secondary — Qontak Client AdminClient account owner/admin viewing usage at chat.qontak.com/subscriptions/usages and exporting quota reportsUnderstand and audit the new per-message service charge appearing on their WhatsApp balanceService deductions either do not appear or appear without a clear category, so the client cannot reconcile their own balance dropContacts Qontak CS to explain unexpected balance movement


3. Non-Goals

  1. Not re-architecting the deduction engine. This PRD rides the existing July 2025 Per-Message Pricing (PMP) engine in hub_core that already keys on unique message_id and reads pricing_model/pricing_type/category. We do not build a new per-message billing model — we make category=service billable within it.
  2. 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.
  3. Not changing marketing / utility / authentication category pricing or margins. Only the service category is added; existing category prices, ui_fee/bi_fee margins, and their deduction behavior are unchanged.
  4. 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.
  5. Not repricing historical service conversations. Service messages before the Meta effective date (1 Oct 2026) stay free/unbilled — no retroactive deduction.
  6. Not migrating the legacy conversation-based (CBP) deduction path in qontak-billing (whatsapp_deduction.go, keyed by conversation_id). All clients are on PMP; the CBP path is not extended for service.
  7. Not changing the Mekari Pay / invoice / self-topup flow. How clients pay for balance is untouched — only what is deducted from balance changes.
  8. Not adding percentage-vs-fixed margin configurability beyond what already exists. We use the existing margin storage; the exact margin unit for service is 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.

  • Backendhub_core (not checked out; Gemfile path ../hub_core): the PMP deduction engine must bill category=service when Meta sends pricing_type=regular and continue to skip it for free_customer_service/free_entry_point, applying service base price + service margin; it also writes service rows to wa_conversation_logs with origin_type/credited_to/is_auto_deduct set so they are export-eligible. report-worker: the MCC export query (FetchMCCLogsExport/IsMCCLogsExists in db/billingdb/queries/wa_conversation_logs.sql) filters origin_type = 'BI' and so excludes service (a UI conversation) — must be widened to include billable service rows; 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 a service entry to the margin list (accounts#get_margin_listconversation_fees/package_fees) and to custom-margin-by-packages (custom_margin_by_packages_controllercustom_margin_by_packages(package_id, conversation_type, cost, tax)). qontak-billing: recognize service in the price/deduction path (mapPricing already maps serviceUI/service; GetWhatsappBasePrice reads v2_wa_conversation_prices).
  • Frontendhub-chat: subscriptions/usages (TableComponentWhatsappBalance.vue) already renders conversation_category verbatim — add a friendly/styled service label 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 service price row(s) in v2_wa_conversation_prices (per country/code, conversation_category='service', cost numeric(6,2)); set the default service margin to 0 in the margin config (applied at account creation for new CIDs); one-off backfill of the service margin = 0 across existing packages/CIDs on the release date. (Per-package override via custom_margin_by_packages is a deferred future lever, not seeded at launch — see §6/§13/§15.)


4. Constraints

FieldValue
Meta effective dateCRITICAL — 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 modelPMP (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 mappingservice 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 catalogv2_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 marginFuture 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 unitFollows 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.
PlatformBackend + web. Client UI is web (hub-chat, Nuxt). No mobile change.
PerformanceNo 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 compatibilityMarketing/utility/authentication pricing, margins, and deduction behavior unchanged. Service conversations before 1 Oct 2026 remain free (no retroactive deduction).

4.1 Data Lifecycle

Artifact TypeRetention PeriodCleanup TriggerUser-Visible Effect
service price row in v2_wa_conversation_pricesPermanent (config)Manual update onlyNone — internal catalog
service default + per-package margin rows (conversation_fees / custom_margin_by_packages)Permanent (config)Manual update / package deletionNone — internal config
One-off margin backfill job runJob record transient; written margin rows permanentJob completes (idempotent — safe to re-run; guarded by WHERE ... NOT EXISTS)None — internal; logged count of rows seeded/skipped
wa_conversation_logs service deduction rowsPermanent (financial record)None — billing audit trailAppears in client usage/export and modpanel MCC export
MCC/quota export files (report-worker)Existing export retention (unchanged by this PRD)Existing cleanup cronDownload link per existing behavior

5. Feature Changes

Change ID: CHG-001 — Service becomes a billable category in the PMP deduction engine (hub_core)

FieldDetail
Change TypeModified deduction logic
TriggerMeta webhook delivers a service message with pricing_type = regular (from 1 Oct 2026)
BeforeService arrives as pricing_type = free_customer_service → PMP engine skips deduction (free).
AfterWith 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.
Backendhub_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)

FieldDetail
Change TypeModified export query filter
TriggerClient (subscriptions/usages export) or modpanel (download-muv-mcc) requests an MCC/wa_balance export
BeforeFetchMCCLogsExport / 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.
AfterFilter 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.
Backendreport-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)

FieldDetail
Change TypeModified display (label/styling)
Pagechat.qontak.com/subscriptions/usages
BeforeTableComponentWhatsappBalance.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.
AfterAdd 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.
Frontendhub-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)

FieldDetail
URLmoderator.qontak.com/billing/accounts/get_margin_list
AccessModpanel Admin (internal)
What's newThe 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:

StateDescription
EmptyAccount with no service margin configured → shows the backfilled default (0), never blank.
LoadingExisting margin-list skeleton/spinner (unchanged).
ErrorExisting error handling for the margin list (unchanged); log if the service field cannot resolve.
Successservice margin value shown per account and included in the margin export (export_margin).

Feature: service custom margin in Custom Margin by Packages (modpanel)

FieldDetail
URLmoderator.qontak.com/billing/custom-margin-by-packages
AccessModpanel Admin (internal); gated by existing Flipper flag custom_margin_field (default OFF)
What's newFuture 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:

StateDescription
EmptyPackage with no custom service margin → default (0) applies; row absent from this screen. This is the state for all packages at launch.
LoadingExisting custom-margin list loading state (unchanged).
ErrorUniqueness violation on (package_id, service) → existing validation error surfaced (create use case enforces uniqueness).
SuccessA 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_packages is defined by a migration in qontak-billing (20260402000000_create_custom_margin_by_packages) but has no migration/schema entry in moderator-be — moderator reads/writes it via Billings::ApplicationRecord and it is provisioned out-of-band. It is currently defined but not yet wired into the qontak-billing/hub_core deduction 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

#BehaviorEntity AffectedTriggered ByExpected BehaviorFailure Behavior
1Deduct a billable service messagewa_conversation_logs (new row); org WhatsApp balance/creditMeta webhook: category = service, pricing_type = regular, unique message_id (from 1 Oct 2026), flag bill_service_messages = ONPMP 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.
2Skip a free service messageNone (no deduction)Meta webhook: category = service, pricing_type = free_customer_service or free_entry_pointNo 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).
3Resolve service base pricev2_wa_conversation_prices readDeduction (behavior 1) or a pricing previewGetWhatsappBasePrice(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.
4Read/write service margin (modpanel)conversation_fees / package_fees (default)Modpanel Admin views get_margin_list; update_margin pushes to Chat PanelMargin 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.
5Backfill service margin = 0 on release (existing CIDs)conversation_fees (bulk)Release deploy → operator enqueues backfill jobIdempotent 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.
6Export MCC/quota logs including serviceExport 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

  1. Config (pre-1-Oct-2026): Ops seeds the service base price into v2_wa_conversation_prices (per country/code) and sets the default service margin to 0 in the margin config (new CIDs inherit 0 at account creation); the release-date backfill job writes service margin = 0 for every existing package/CID that lacks one. No per-package override is seeded.
  2. A customer sends / an agent replies with a WhatsApp service (non-template) message inside the customer service window.
  3. Meta delivers the message webhook. Before 1 Oct 2026 it carries pricing_type = free_customer_service; from 1 Oct 2026 a billable one carries pricing_type = regular, category = service, with a unique message_id.
  4. The hub_core PMP engine reads pricing_model / pricing_type / category.
  5. Decision — billable? If pricing_type != regular (free_customer_service / free_entry_point) → skip deduction (free), end.
  6. If pricing_type = regular and flag bill_service_messages = ON: resolve service base price (GetWhatsappBasePrice, country/code) and the service margin (0 at launch → deducted amount = base price only).
  7. Decision — price present? If no service price row resolves → do not deduct the 569.33 fallback for real money; log service_price_missing, alert, end (fail-safe).
  8. Deduct once per unique message_id (idempotent); write a wa_conversation_logs row with conversation_category='service', pricing_type, pricing_model, is_auto_deduct=TRUE, export-eligible credited_to.
  9. Client views the deduction at subscriptions/usages (Category = "Service") and can export it via reports/export/quota.
  10. Ops/Admin pulls the modpanel download-muv-mcc export; service rows are included (widened filter).
  11. Reconciliation: service deductions are compared against Meta's service invoice to confirm zero leak (§10).

Failure branches:

  • Duplicate message_id → no second deduction (idempotent).
  • service price 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 StoryImportanceMockupTechnical NotesAcceptance 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 HaveN/A — backendData 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 OFF

Before-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 HaveN/A — data/configData 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.00

Before-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 HaveModpanel — get_margin_listData Fields:
conversation_type (string) — "service"
cost / margin value (numeric) — default 0
tax (numeric)
get_margin_list_from_db (flag) — governs DB vs Chat Panel source

Before-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.
DeferredModpanel — custom-margin-by-packagesData 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 HaveN/A — backend jobData 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 Havehub-chat — subscriptions/usagesData 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_logs

Before-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 Havehub-chat — reports/export/quotaData 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 HaveModpanel — download-muv-mccData 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 SELECTed

Before-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 RailNEG-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 RailNEG-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

FieldValue
Feature flagbill_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 — ConfigSeed 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 — InternalEnable 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 — PilotEnable on 5–10 real CIDs with known WhatsApp service volume; monitor deduction accuracy and export completeness for 1 week.
Stage 3 — Batched GAEnable across all PMP CIDs in batches (25% → 50% → 100%), timed so 100% is live by 1 Oct 2026.
GAbill_service_messages = ON for all clients; service billed exactly as Meta bills Qontak.
Backward compatMarketing/utility/authentication unchanged; service before the Meta date and free pricing_types stay free.
MigrationData 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

FieldDetail
Old record behaviorService 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 behaviorService 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 periodThe 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 stateAll 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 NameTriggerProperties
service_message_deductedBillable service message deductedcompany_id, message_id, country, base_price, margin, total_deducted, credited_to, timestamp
service_message_skipped_freeService message with free pricing_typecompany_id, message_id, pricing_type, timestamp
service_price_missingNo service price row resolved for a billable messagecompany_id, country, code, message_id
service_margin_backfill_runBackfill job completesseeded_count, skipped_count, error_count, batch_size, duration_ms
service_deduction_dedup_hitDuplicate message_id skippedcompany_id, message_id
service_export_vs_deduction_gapReconciliation finds export rows ≠ deduction rowscompany_id, deduction_count, export_count, date_range
FieldDetail
Dashboard ownerBifrost squad
Alert 1service_price_missing count > 0 in any 5-minute window → PagerDuty on-call (a billable message could not be priced).
Alert 2Sum of Meta service invoice vs. service_message_deducted total diverges by > 2% for a day → Slack #bifrost-billing + Finance.
Alert 3service_export_vs_deduction_gap fires for any CID → Slack #bifrost-billing (export filter regression).

10.1 Post-Launch Monitoring Cadence

FieldDetail
Review cadenceDaily for the first 2 weeks around 1 Oct 2026, then weekly for 4 weeks, then monthly.
OwnerBifrost PM + Finance Ops
Review scopeservice_message_deducted, service_price_missing, service_export_vs_deduction_gap, and Meta-invoice-vs-deduction reconciliation totals.
Trigger threshold 1Meta-vs-deduction divergence > 2% for 1 day → investigate pricing/margin/flag config immediately.
Trigger threshold 2service_price_missing > 0 for any country for 2 consecutive hours → escalate to Bifrost eng.
Rollback considerationIf 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:

MetricDefinitionBaselineTarget
Service billing coverage% of Meta-billed service messages that produce a matching client deductionN/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, monthlyN/A — new≤ 2% every month post-GA
Mispriced service deductionsService deductions using the 569.33 fallback or a wrong-country rateN/A — new0 (fail-safe blocks fallback charges)

Efficiency & Impact:

MetricDefinitionBaselineTarget
Service cost recoveredMeta 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 effortOps hours/month spent reconciling service charges by spreadsheetCurrent manual effort (to baseline in Stage 1)≥ 80% reduction once exports include service

Adoption:

MetricDefinitionBaselineTarget
Config completeness% of active PMP packages with a service price + margin configured0%100% before 1 Oct 2026 (Stage 0 gate)

12. Launch Plan & Stage Gates

StageAudienceDurationSuccess Gate to AdvanceOwner
Stage 0 — Config & BackfillInternal / all packages (config only, flag OFF)Until complete, before 1 Oct 2026service 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 weekEnd-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 matchesPM + QA
Stage 2 — Pilot5–10 real CIDs with service volume1 weekMeta-invoice-to-deduction variance ≤ 2%; 0 double-charges; export-vs-deduction gap = 0; no P0/P1PM + Eng Lead
Stage 3 — Batched GAAll PMP CIDs (25% → 50% → 100%)Timed to reach 100% by 1 Oct 2026Each batch sustains pilot gates for 48h before the next; no regression in other categoriesPM + Eng Lead
GAAll clientsOngoingService billing coverage ≥ 99.5% sustained for 2 weeks; variance ≤ 2%PM + Finance

13. Dependencies

DependencyOwning TeamDeliverable NeededBlocking?
hub_core PMP deduction engine (Gemfile ../hub_core, not checked out)BifrostConfirm/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 rowsYES — this is where the money moves; PRD cannot deliver S01 without it
Meta webhook semantics for service on 1 Oct 2026Meta (external)Meta actually sending service as pricing_type=regular from the effective dateYES — the trigger for billing
custom_margin_by_packages wiring into deductionBifrostConfirm 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 applyYES for S04 (default S03 works without it)
Chat Panel package_fee API (update_margin push)Bifrost / Chat PanelAccept + persist the service margin on both DB and proxy pathsYES for S03 on the proxy path
report-worker MCC/quota export filter changeBifrostWiden FetchMCCLogsExport (and billing_logs is_free) so service rows are includedYES for S07/S08
Meta service rate card + margin policyFinance / ProductThe 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 markupYES (rate card) — Stage 0 config cannot start without the per-country cost values


14. Key Decisions + Alternatives Rejected

14a — Decisions Made

DateDecisionRationale
2026-07-06Ride the existing hub_core PMP engine rather than build a new per-message billing modelPMP 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-06Split "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-22Service margin = 0 at launch (charge at cost / pure pass-through) — recover Meta's per-message service cost from clients with no Qontak markupGo-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-22Two mechanisms to reach margin 0 — new CIDs get 0 at account creation; existing CIDs are backfilled to 0 on the release dateNew 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-22Defer 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-06Gate deduction behind bill_service_messages flag; drive the actual bill/skip on Meta's pricing_type, not a Qontak date toggleEnables safe staged rollout/rollback and guarantees free service (pre-date / free_customer_service) is never billed.
2026-07-06Fix the export exclusion in report-worker, not by rewriting modpanelRoot cause is the origin_type='BI' filter in FetchMCCLogsExport; modpanel only forwards params and delegates.
2026-07-06Fail-safe on missing price: do NOT charge the 569.33 fallback for serviceThe fallback is a legacy safety net; using it for real money on an unconfigured country would mis-bill clients.

14b — Alternatives Rejected

AlternativeWhy RejectedDate
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 charge2026-07-06
Add a margin column directly to v2_wa_conversation_pricesMargin already lives in dedicated config (package_fees/conversation_fees/custom_margin_by_packages); duplicating it on the price table splits the source of truth2026-07-06
Widen the MCC export by changing origin_type='BI' to include all UIWould pull in non-service UI conversations (e.g. referral_conversion); filter must target conversation_category='service'/pricing_type specifically2026-07-06
Retroactively bill historical service messagesClients were told service was free before the Meta date; retroactive billing is a trust/compliance risk2026-07-06


15. Open Questions

#TypeQuestionOwnerDeadline
1Resolved (2026-07-22)Is the service margin a percentage or a fixed amount? Moot at launch — the launch margin is 0 (0% = 0 fixed), so the unit does not affect any deduction. The field representation is still confirmed at RFC so a future non-zero markup can be applied, but this no longer blocks Stage 0.Finance + BifrostClosed
2Open QuestionDoes 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.Bifrost2026-08-01
3Riskhub_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 Eng2026-07-20
4Riskcustom_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 Eng2026-08-01
5Riskv2_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 Eng2026-08-15
6Open QuestionExact 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 Eng2026-08-15
7AssumptionMeta 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.PM2026-09-01

PRD CHANGELOG

VersionDateBySectionTypeSummary
1.12026-07-22Claude (PM)§1, Scope Changes, §4, §6, §7, §8, §9, §12–15, ChangelogCHANGEDService 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.02026-07-06ClaudeAllCREATEDInitial 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.