Skip to main content

RFC: WhatsApp Service-Message Billing & Margin — Meta Oct 2026 Pricing

Document Conventions (do not remove)

This RFC follows the Qontak RFC Template format for governance — the metadata table, Confluence sections 1–6, and Comment logs are mandatory. Sections marked N/A — reason are intentional, not omissions.

It is also agent-execution-ready: §1 Design References (FE) + PRD-to-Schema Derivation (BE), §2 Repo Reading Guide (Detail 2.0) with Source Verification, mermaid diagrams, §2.3 DDL, §2.4 APIs, §2.G Cross-Layer Contract Verification, and §4 Agent Execution Plan + Verification & Rollback Recipe are present.

Delivery & project management live elsewhere. This RFC is the technical artifact only — no staffing, effort, or schedule. Delivery not yet handed to delivery.

The YAML frontmatter is the machine-readable index; the metadata table is the human-readable governance record. Both agree on every shared field.

⚠️ Source PRD grounding correction (tracked — OQ-1, OQ-2). The source PRD ../prds/service-message-billing.md was written assuming hub_core is not checked out and treats the core deduction logic (WSVC-S01) as unverified. hub-core is available in this workspace and was read directly. That read changes three of the PRD's premises (each carried into the design below and flagged in §5):

  1. Fallback price is 596.33, not 569.33. The PMP money path reads Models::Billing::V2WaConversationPrice::DEFAULT_FALLBACK_PRICE = 596.33 (hub-core/app/core/domains/models/billing/v2_wa_conversation_price.rb:6). The 569.33 / WaDefaultFallbackPrice the PRD cites is a different constant in qontak-billing (Go, helper/consts/v2_conversation_price.go:6) that belongs to the legacy CBP path, not the PMP engine that actually moves money.
  2. There is no category=='service' "always free" special-case to remove. Under PMP, is_free_deduction? keys only on pricing.type == 'regular' (new_pricing_wa_deduction.rb:199-205); the "UI/service is free" rule (free_conversations = ['RC','UI']) applies only on the CBP path and is bypassed under PMP. So the 1-Oct switch bills service automaticallyif a price row and an export-eligible log row exist.
  3. The real blocker is is_auto_deduct, not just the export filter. new_pricing_wa_deduction.rb:84 forces @is_auto_deduct = false for every UI conversation (service is UI). A billed service log row is therefore written is_auto_deduct=false, origin_type='UI' — which is excluded by every read/export surface (usage table, client export, modpanel export). This is the linchpin (Decision 1), a change the PRD did not identify.

The PRD's other structural claims (per-message dedup on message_id, price catalog shape, export filter origin_type='BI', modpanel forwards to a billing service) were confirmed against the code — see §2.0 Source Verification.

Meta doc reground (fetched 2026-07-06). The Meta pricing page (non-template-messages) confirms the effective date (1 Oct 2026), per-message billing at utility/authentication rates, and the exact billable payload shape: "pricing": { "billable": true, "pricing_model": "PMP", "type": "regular", "category": "service" }. That matches hub-core's parser fields exactly. It also surfaced a fourth field — pricing.billable — that the code grounding refined: hub-core reads billable only on the CBP path (new_pricing_wa_deduction.rb:35 hardcodes billable=true for PMP), so the PMP path decides purely on type=='regular'. Meta's doc does not state how a still-free in-window service message is represented after 1 Oct 2026; if it can arrive as type=regular, billable=false, the PMP path would over-charge. This RFC therefore adds a defensive billable != false guard for service (Decision 2) and tracks the representation question (OQ-12).

Metadata

FieldValueNotes
StatusIDEAYAML status: carries the remapped linter enum draft
DRIpm-group.qontak@mekari.comCarried from the initiative README / PRD. A named engineering DRI must be assigned before in-review (OQ-8).
TeambifrostCarried from source PRD
Author(s)Claude (from PRD + direct code grounding of hub-core, qontak-billing, report-worker, moderator-be, hub-service, hub-chat)Primary author
Reviewersbifrost-backend, bifrost-tech-lead, billing-team, hub-chat-feTech reviewers across affected squads (BE + FE)
Approver(s)bifrost-tech-lead, infosecTech leaders + infosec approver
Submitted Date2026-07-06Date RFC opened for discussion
Last Updated2026-07-06Bump on every material edit
Target Release2026-Q3Must be live before Meta's 1 Oct 2026 effective date
Target Quarter2026-Q3Carried from source PRD
Deliverynot yet handed to deliveryNo delivery/ artifacts yet
RelatedPRD · impact-analysis · monetary-impactSource PRD + grounding docs
Discussion#bifrost-billingSlack channel

Type: full-stack Frontend sub-type: enhancement Backend sub-type: enhancement

Sections at a Glance

  1. Overview (incl. §1 Design References — FE half, and §1 PRD-to-Schema Derivation — BE half)
  2. Technical Design (Infrastructure Topology → Technical Decisions [ADR] → Repo Reading Guide → Architecture → Sequence → DDL/Data → APIs → Cross-Layer Contract Verification → Async/Integrity)
  3. High-Availability & Security
  4. Backwards Compatibility and Rollout Plan (incl. §4 Agent Execution Plan + Verification & Rollback Recipe)
  5. Concern, Questions, or Known Limitations
  6. Comment logs
  7. Ready for agent execution

1. Overview

From 1 October 2026 Meta stops treating WhatsApp service (non-template) messages — those sent inside the 24-hour customer service window — as free and bills them per message at rates matching the utility/authentication categories (Meta docs). This is a continuation of the July 2025 Per-Message Pricing (PMP) migration (BIF-4331): Qontak's hub-core deduction engine already bills PMP type=regular messages once per unique message_id at their category price + margin. Today a service message arrives from Meta as pricing.type=free_customer_service and is skipped; after 1 Oct 2026 it arrives as pricing.type=regular.

The problem is that service will not bill correctly by simply flipping on Meta's side, for three concrete, code-verified reasons:

  1. No service price row. hub-core's WaPricing#get_price_from_cache reads v2_wa_conversation_prices for (code, conversation_type='UI', conversation_category='service'); with no row it returns the hardcoded DEFAULT_FALLBACK_PRICE = 596.33 (v2_wa_conversation_price.rb:6). Billing every client's service traffic at a blind 596.33 IDR fallback would mis-bill the entire base (§ monetary-impact: ~42.85M service messages/month).
  2. No service margin. The margin (fee) for a service message resolves from a ConversationFee row keyed conversation_type = 'service' (WaPricing#get_db_cost_tax_conversation_fee, wa_pricing.rb:115-133); with no row it falls back to DEFAULT_FALLBACK_UI_COST = 0.00 (conversation_fee.rb:8) — i.e. zero margin.
  3. The billed log is invisible. new_pricing_wa_deduction.rb:84 sets @is_auto_deduct = false for every UI conversation. A billed service log row is written is_auto_deduct=false, origin_type='UI', and every read/export surface filters is_auto_deduct = TRUE (and most also filter origin_type='BI'), so the deduction never appears in the usage table, the client export, or the modpanel export — a silent charge with no client visibility and no reconciliation trail.

This RFC makes service a first-class billable PMP category behind an org-scoped billing feature flag bill_service_messages (default OFF; backed by Services::Billing::FeatureFlag on the billing DB — Decision 6): it (a) adds a fail-safe billing guard + is_auto_deduct fix in hub-core, (b) seeds the service base price and margin config, (c) widens the two export queries (qontak-billing, report-worker) to include billable service, and (d) adds a friendly service label in the hub-chat usage table. When OFF, behavior is byte-identical to today (service stays free even if Meta sends regular).

Success Criteria

  • SC-1 (Bills correctly): With the flag ON and a seeded service price + margin, a Meta webhook carrying category=service, pricing.type=regular and a new message_id deducts once at service base_price + service margin, and free service (free_customer_service/free_entry_point or pre-1-Oct) is never billed.
  • SC-2 (No blind fallback): A billable service message with no seeded price row does not deduct the 596.33 fallback; it logs service_price_missing and alerts (fail-safe).
  • SC-3 (Visible everywhere): Every billed service deduction appears in the client usage table, the client quota export, and the modpanel MCC export, with conversation_category='service' and its message_id.
  • SC-4 (No double-charge): Redelivered/duplicate message_id never double-deducts (existing PMP idempotency on wa_uniq_conv_id_logs).
  • SC-5 (No regression): Orgs not on bill_service_messages observe byte-identical current deduction, balance, usage-table, and export behavior; marketing/utility/authentication pricing, margins, and is_auto_deduct semantics are unchanged.

Out of Scope

  1. Not re-architecting the deduction engine. Rides the existing PMP path in hub-core.
  2. Not Meta Business Agent per-token AI billing (Meta's Aug 2026 change) — Qontak does not use it.
  3. Not changing marketing/utility/authentication pricing, margins, or is_auto_deduct. Only service is added; the @is_auto_deduct=false change is scoped to billable service alone.
  4. Not building new client-facing screens. Reuses the usage table, quota export, and modpanel MCC export. No Figma (see §1 Design References).
  5. Not repricing historical service. Service before 1 Oct 2026 (and any free_customer_service) stays free; no retroactive deduction.
  6. Not migrating the legacy CBP path (qontak-billing/whatsapp_deduction.go, conversation-id keyed). All clients are on PMP; the money path is hub-core.
  7. Not resurrecting custom_margin_by_packages. Per-package service overrides (PRD WSVC-S04) depend on a table that is dead end-to-end (no schema in moderator-be, no read in qontak-billing/hub-core) — deferred (OQ-3). Default per-category ConversationFee (WSVC-S03) is the margin mechanism this RFC uses.
  8. Not changing Mekari Pay / invoice / self-topup. Only what is deducted from balance changes.
  • Source PRD: service-message-billing.md (NEW PRD v1.0).
  • Impact analysis (grounding): impact-analysis.md.
  • Monetary impact (grounding): monetary-impact-to-clients.md — ~42.85M service msgs/month, ~14–15 bn IDR/month becomes billable across the base.
  • Prior art: July 2025 PMP migration (BIF-4331/4332/4626/4616) — the engine this rides on.
  • Meta pricing doc (external, fetched 2026-07-06): Non-template messages — Technical implementation — confirms 1 Oct 2026 effective date, per-message billing at utility/authentication rates, and the billable pricing payload shape (billable/pricing_model/type/category).

Assumptions

  • A-1: Meta sends billable service as pricing = { billable: true, pricing_model: "PMP", type: "regular", category: "service" } from 1 Oct 2026 — confirmed by the Meta doc (fetched 2026-07-06) and matching hub-core's parser (get_pricing_attributes, new_pricing_wa_deduction.rb:188-197, reads pricing_model/type/category). What remains not stated by Meta is the representation of a still-free in-window service message after the date (type=free_customer_service vs type=regular, billable=false) — see A-1a / OQ-12.
  • A-1a: Free/skippable service is assumed to arrive as type=free_customer_service / free_entry_point (July 2025 PMP semantics per impact-analysis). Because hub-core's PMP path ignores pricing.billable (new_pricing_wa_deduction.rb:35 forces billable=true for PMP), this RFC defensively also treats billable == false as free for service (Decision 2). (OQ-12)
  • A-2: v2_wa_conversation_prices and conversation_fees are on the shared billing DB read by hub-core (Models::AbstractModelBilling on the :billing shard) and written by moderator-be (Billings::ApplicationRecordestablish_connection :billing). Both point at the same physical tables. (OQ-4 — confirm the shared-DB assumption operationally.)
  • A-3 (now a hard precondition): :deduction_conversation_fee (existing flag) is ON for every target org, so the margin resolves via ConversationFee (per-category) = 0; if OFF, service falls back to PackageFee.ui_fee (default 5, not 0) which has no per-service granularity and would bill a non-zero markup — violating the charge-at-cost (0-margin) decision. Any org still on the legacy path must not be enabled for bill_service_messages until migrated (see Decision 3).
  • A-4: The Meta service rate card (per country/code) fits v2_wa_conversation_prices.cost numeric(6,2) (hard cap 9999.99). (OQ-5) The Qontak margin is settled at 0 (charge at cost) — no margin value to confirm at launch.

Dependencies

DependencyOwning teamDeliverable neededStatusBlocking?
hub-core PMP engine (new_pricing_wa_deduction.rb)bifrostFlag guard + is_auto_deduct fix + fail-safe on missing priceneeds buildingYES — the money path (SC-1/SC-2/SC-3)
Meta webhook semantics on 1 Oct 2026Meta (external)Service delivered as pricing.type=regularexternalYES — the trigger
Meta service rate cardFinance / ProductPer-country service cost values (margin policy settled: 0 at launch, so no margin value needed; OQ-2 moot)pending (rate card)YES — Stage 0 config
Services::Billing::FeatureFlag (enabled? in hub-core) + preferences records: bill_service_messages, bill_service_messages_global, bill_service_messages_excludedbifrost + managing repohub-core: implement read-only service + AR models; managing repo: seed the three preferences records and manage preference_unique_ids (pilot enroll, global flip, kill switch)hub-core: needs building; records: needs provisioningno (admin task); Detail 2.K
qontak-billing MCC export query widenbifrost / billingWiden FetchWaConversationLogsBy* filterneeds buildingYES for modpanel export (WSVC-S08)
report-worker MCC export query widenbifrost / billingWiden FetchMCCLogsExport filter + regenerate sqlcneeds buildingYES for client export (WSVC-S07)
moderator-be margin list service entrybifrost / billingSurface + persist a service ConversationFee marginneeds buildingYES for margin config (WSVC-S03)
hub-chat friendly service labelbifrost / hub-chat-feCategory label mapneeds buildingno (cosmetic; rows render without it once BE fix ships)

Design References (frontend half)

PRD-named surfaceFigma / design linkFrame nameDesign system versionDesign QA contactNotes
subscriptions/usages usage tablen/a — no net-new screensn/a@mekari/pixel (in-repo; version not pinned in this RFC)n/aPRD header declares "Figma Master: N/A". Only change is a friendly label for an existing free-text cell (TableComponentWhatsappBalance.vue:269-274). No layout/component change.
reports/export/quota drawern/a — no net-new screensn/a@mekari/pixeln/aNo FE change; the export file gains service rows purely from the BE query widen.

No Figma frames exist because there are no net-new or restyled screens; the FE work is a one-cell label map plus verifying existing rows render. This is the template's sanctioned n/a — reason path, not design pending.

PRD-to-Schema Derivation (backend half)

PRD-described entity / attribute / rulePersisted as (table.column)Exposed viaEnforced whereSource
Service is a billable PMP category when type=regularwa_conversation_logs row, conversation_category='service', origin_type='UI', pricing_type='regular', credit/total_priceusage table + exportshub-core NewPricingWaDeduction#call (is_free_deduction? already keys on pricing.type)PRD §5 CHG-001; WSVC-S01
A billed service log must be export-eligiblewa_conversation_logs.is_auto_deduct=TRUE for billable serviceread/export queries filter ithub-core deduction: do not force is_auto_deduct=false for billable service (new_pricing_wa_deduction.rb:84)new — not in PRD; Decision 1
Billing is gated for safe rollout (per-org pilot)preferences record bill_service_messages (state=true, is_global=false) + preference_unique_ids allow-listServices::Billing::FeatureFlag.new.enabled?(:bill_service_messages, unique_id: org_id)use_service_billing? in hub-core (Decision 6; Detail 2.K)PRD §4 Feature flag; WSVC-S01
New flow is the default at Meta release (global + kill switch)preferences records bill_service_messages_global (is_global=true) + bill_service_messages_excluded (is_global=false, deny-list)enabled?(:bill_service_messages_global) + !enabled?(:bill_service_messages_excluded, unique_id:)use_service_billing? in hub-core (Decision 6; Detail 2.K)PRD §4 Feature flag; Decision 2
Service base price = Meta rate per countryv2_wa_conversation_prices(code, conversation_type='UI', conversation_category='service', cost numeric(6,2))read by WaPricing#get_price_from_cacheseed job / migration; read hub-corePRD §5, §Constraints; WSVC-S02
Missing price must not charge fallbackabsence of a service rowlog service_price_missing, skiphub-core fail-safe branch (new)PRD §7 behavior 1/3; WSVC-S01/ERR-1, WSVC-S02/ERR-1
Service margin (fee) per packageconversation_fees(organization_package_id, conversation_type='service', cost, tax)moderator-be margin list; read by WaPricing#get_db_cost_tax_conversation_feeseed/backfill; moderator-be CRUD → Chat Panel pushPRD §6, §Constraints; WSVC-S03/S05
Per-package custom service margincustom_margin_by_packages(package_id, conversation_type='service', cost, tax)dead — no read path— (deferred)PRD §6, WSVC-S04 — OQ-3
Client usage table shows servicewa_conversation_logs read via MccLog (origin_type IN ('BI','UI','RC') AND is_auto_deduct=true)GET /api/core/v1/reports/billing/mcc_logshub-core Repositories::Billings::Gets::MccLogPRD §5 CHG-003; WSVC-S06
Client quota export shows servicewa_conversation_logs via FetchMCCLogsExport (widen origin_type='BI')report-worker export filereport-worker ExportMCCLogsPRD §5 CHG-002; WSVC-S07
Modpanel MCC export shows servicewa_conversation_logs via FetchWaConversationLogsBy* (widen origin_type='BI')qontak-billing /iag/v1/reports/mcc-logsqontak-billing ReportUsecase.MCCLogsPRD §5 CHG-002; WSVC-S08
Backfill default service margins on releaseconversation_fees bulk seedone-off idempotent jobprecedent report-worker/worker_seed_blind_index.go or hub-core rakePRD §6, WSVC-S05

Every §2.3/§2.4 row traces back to a row here. The is_auto_deduct row and the custom_margin deferral are the two places this derivation corrects the PRD.

Detail 1.A — PRD Traceability (cross-layer)

Forward (PRD AC → RFC):

PRD composite AC idFE section / componentBE section / endpoint
WSVC-S01/AC-1..3, WSVC-S01/ERR-1n/a§2.2 deduction seq · hub-core NewPricingWaDeduction (Decision 1)
WSVC-S01-NEG/NEG-1..2n/a§3.A.1 Branch & Skip · is_free_deduction? + flag guard
WSVC-S02/AC-1..3, WSVC-S02/ERR-1n/a§2.3 Data (price seed) · WaPricing#get_price_from_cache
WSVC-S03/AC-1..3, WSVC-S03/ERR-1modpanel margin list (server-rendered)§2.4 · moderator-be get_margin_list + ConversationFee
WSVC-S03-NEG/NEG-1n/a§3 Security (untouched categories) · Decision 3
WSVC-S04/AC-1..3, WSVC-S04/ERR-1n/adeferred (OQ-3)custom_margin_by_packages dead
WSVC-S05/AC-1..3, WSVC-S05/ERR-1n/a§2.F Async · backfill job
WSVC-S06/AC-1..3, WSVC-S06/ERR-1TableComponentWhatsappBalance.vue (label)§2.4 · GET .../reports/billing/mcc_logs (MccLog)
WSVC-S07/AC-1..3, WSVC-S07/ERR-1export drawer (no change)§2.4 · report-worker FetchMCCLogsExport widen
WSVC-S08/AC-1..3, WSVC-S08/ERR-1modpanel download (no change)§2.4 · qontak-billing FetchWaConversationLogsBy* widen

Reverse (RFC → PRD AC):

New artifactPRD AC it serves (or net-new)
Flag guard on service billing in hub-coreWSVC-S01/AC-1..2, WSVC-S01-NEG/NEG-1
is_auto_deduct=TRUE for billable service (new_pricing_wa_deduction.rb:84)net-new (unblocks WSVC-S06/S07/S08)
Fail-safe on missing service priceWSVC-S01/ERR-1, WSVC-S02/ERR-1
service price seed in v2_wa_conversation_pricesWSVC-S02/AC-1..2
service ConversationFee seed + margin-list entryWSVC-S03/AC-1..3, WSVC-S05/AC-1
qontak-billing + report-worker filter widenWSVC-S07/*, WSVC-S08/*
hub-chat service label mapWSVC-S06/AC-1,AC-3

UI / Consumer Surface Coverage

PRD-named surfaceConsumerRequired reads (BE)Required writes (BE)FE componentStatus surface
subscriptions/usagesweb (client)GET /api/core/v1/reports/billing/mcc_logs (hub-core MccLog)n/a — read-onlyfeatures/subscriptions/usages/TableComponentWhatsappBalance.vueconversation_category cell
reports/export/quotaweb (client)POST {IAG}/report/v1/billings/logs/exportreport-worker FetchMCCLogsExportn/a — export filefeatures/report/export/views/components/ExportQuotaUsageDrawer.vue (no change)download link (existing)
modpanel download-muv-mccsupport tool (internal)qontak-billing /iag/v1/reports/mcc-logs (via moderator-be proxy)n/a — export filemodpanel (server-rendered, no change)download (existing)
modpanel get_margin_listsupport tool (internal)moderator-be GetMarginListDb (ConversationFee+PackageFee)custom-margin CRUD (default path via margin config)modpanel (server-rendered)conversation_fee[] incl. service

Role Coverage

PRD roleAuthorization mechanismEndpoints permitted (BE)UI surface visibility (FE)Cross-tenant?Audit trail
Qontak Client Adminexisting oauth2 (own org) + report_omnichannel_view for exportmcc_logs read (own org), quota export (own org)usage table + export drawernorequest logs
Modpanel Adminmodpanel auth (oauth2 :modpanel)get_margin_list, export_margin, download-muv-mcc (any org)modpanel margin + MCC downloadyesmodpanel logs; flag/margin author
Billing/Finance Opsconsole / migrationseed price/margin; run backfill; enable flagn/ayesmigration + job logs
System (webhook/worker)internalnone actor-facingn/an/ahold-free deduction: WaConversationLog rows + events

PRD Section Coverage

PRD §TitleWhere covered (RFC) or n/a — reason
Header block§1 Metadata
1One-liner + Problem§1 Overview
2Target Users + Persona§1.A Role Coverage
3Non-Goals§1 Out of Scope
Scope Changes§2.I Scope Boundaries; frontmatter type: full-stack
4 / 4.1Constraints / Data Lifecycle§2 Technical Decisions; §2.3 Data; §3
5Feature Changes (CHG-001/002/003)§2.2 sequences; §2.4 APIs; Decisions 1–4
6New Features (modpanel margin fields)§2.4 (margin list); custom_margin deferred (OQ-3)
7API & Webhook Behavior§2.4 APIs; §2.2 sequences; §3.A failure modes
8 / 8.1 / 8.2System Flow + User Stories§2.2 sequences; §1.C Per-Story Change Map
9 / 9.1Rollout / Migration window§4 Rollout Strategy; §4.A Compat Matrix
10 / 10.1Observability§3 Monitoring & Alerting
11Success Metrics§1 Success Criteria; §3
12Launch Plan & Stage Gates§4 Rollout Strategy
13Dependencies§1 Dependencies
14Key Decisions + Alternatives§2 Technical Decisions (ADRs); §1.B
15Open Questions§5
Changelog§6 Comment logs

Detail 1.B — Decisions Closed (cross-layer)

#DecisionChosen optionAlternatives rejectedWhy rejectedLayer§2 block
1Make billed service export-eligibleis_auto_deduct=TRUE for billable service + gate on flagWiden every read/export to include is_auto_deduct=false UIWould pull unbilled/free UI rows into money reports; changes is_auto_deduct semantics globallyBEDecision 1
2Billing gateTwo flags: bill_service_messages (per-org pilot allow-list) + bill_service_messages_global (global default + deny-list kill switch); bill/skip driven on Meta pricing.type/billableSingle flag; global calendar cutoverSingle flag can't express "default ON for all at release + per-org exception"; calendar has no pilot/rollbackBEDecision 2
3Margin sourcePer-category ConversationFee(conversation_type='service')PackageFee.ui_fee; custom_margin_by_packagesui_fee has no per-service granularity; custom_margin_by_packages is dead end-to-endBE/DataDecision 3
4Missing-price behaviorFail-safe: skip + alert, never charge 596.33Charge fallbackFallback is a legacy safety net; charging it mis-bills unconfigured countriesBEDecision 4
5Export filter widen locationWiden the two query owners (qontak-billing, report-worker), scoped to serviceRewrite modpanel; broaden to all UIModpanel only forwards; broadening pulls in referral_conversion (RC/UI)BEDecision 5
6Price seed storeExisting v2_wa_conversation_prices (shared billing DB)New table; margin column on price tableSplit source of truth; hub-core already reads this tableDataDecision 3 (consequences)
7Per-package override (WSVC-S04)DeferredWire custom_margin_by_packages nowNo schema in moderator-be, no read anywhere; out of critical pathBE§5 OQ-3

Detail 1.C — Per-Story Change Map

Story idTitleLayer scopeFE changesBE changesComposite AC idsAcceptance criteria (verifiable)RFC anchors
WSVC-S01Service follows Meta + is deductedRuntime/behavior (BE)n/a — BE-onlyflag bill_service_messages; service branch gated on type=='regular' AND billable != false; keeps is_auto_deduct=TRUE; reuse PMP dedupWSVC-S01/AC-1..3, WSVC-S01/ERR-1rspec: type=regular+billable=true+service+flag ON → 1 deduction, log is_auto_deduct=true,conversation_category='service'; free_customer_service→none; type=regular+billable=false→none; dup message_id→none; flag OFF→none§2.2 · §4.D chunk 2 · PRD-to-Schema r1–r3
WSVC-S01-NEGFree service never billedConfig/behavior (BE)n/aflag guard + is_free_deduction?WSVC-S01-NEG/NEG-1..2rspec: pricing.type != regular→no deduction; pre-1-Oct reprocess→no charge§3.A.1 · §4.D chunk 2
WSVC-S02Seed service base priceDatan/aseed v2_wa_conversation_prices rowsWSVC-S02/AC-1..3, WSVC-S02/ERR-1migration/seed: get_price_from_cache('..','UI','service') returns seeded cost (not 596.33); per-code; value ≥ 10000 rejected by numeric(6,2)§2.3 · §4.D chunk 4
WSVC-S03Default service margin in Margin ListBE + modpanel UImodpanel server-rendered row (no hub-chat)moderator-be margin list shows service ConversationFee; push to Chat PanelWSVC-S03/AC-1..3, WSVC-S03/ERR-1rspec (moderator): service margin resolves on GetMarginListDb and Chat Panel paths; export_margin includes it§2.4 · §4.D chunk 5
WSVC-S03-NEGOther margins untouchedBEn/aadditive onlyWSVC-S03-NEG/NEG-1rspec: ui_fee/bi_fee + other conversation_fees unchanged after adding service§3 Security · §4.D chunk 5
WSVC-S04Custom per-package service margindeferredn/acustom_margin_by_packages dead — not wiredWSVC-S04/*deferred — OQ-3 (no schema in moderator-be; no read path)§5 OQ-3
WSVC-S05Backfill default service marginsRuntime/behavior (BE)n/aidempotent batched conversation_fees seedWSVC-S05/AC-1..3, WSVC-S05/ERR-1job: seeds default per eligible package lacking one; re-run→0 dup (NOT EXISTS); batched by offset; per-row error counted, batch continues§2.F · §4.D chunk 6
WSVC-S06Show service in usage tableFE + BETableComponentWhatsappBalance.vue friendly service labelnone beyond S01 (MccLog already allows UI+is_auto_deduct)WSVC-S06/AC-1..3, WSVC-S06/ERR-1vitest: service renders "Service" label + deducted_credit; message_id shown; empty state unchanged§2.A · §2.G · §4.D chunk 7
WSVC-S07Show service in client quota exportBE + FE consumesexport drawer unchangedreport-worker FetchMCCLogsExport filter widen + sqlc regenWSVC-S07/AC-1..3, WSVC-S07/ERR-1go test: widened query returns service rows (conversation_category='service', per-message); pre-Oct range → none§2.4 · §4.D chunk 3
WSVC-S08Show service in modpanel MCC exportBEn/aqontak-billing FetchWaConversationLogsBy* filter widen + sqlc regenWSVC-S08/AC-1..3, WSVC-S08/ERR-1go test: widened query returns service; free service excluded; aggregation_by variants§2.4 · §4.D chunk 3

Cross-layer rule: only WSVC-S06 is a genuine FE + BE story (and its BE half is already covered by S01's is_auto_deduct fix — the FE half is the label). WSVC-S04 is the one deferred story (OQ-3), recorded rather than dropped.


2. Technical Design

Infrastructure Topology

Deployment topology

flowchart TB
meta(["Meta Cloud API + WhatsApp clients"]) -->|"HTTPS webhook"| lb["Load Balancer / API Gateway"]
lb -->|"POST webhook status"| hs["hub-service (Grape API) xN"]
hs -->|"in-process (hub_core gem)"| core["hub-core domain + Sidekiq workers"]
core -->|"enqueue billing_wa_deduction"| q[["Redis / Sidekiq"]]
q -->|consume| core
core -->|"read / write (:billing shard)"| db[("Postgres billing DB")]
core -->|"price/fee cache"| redis[("Redis")]
client(["Client web (hub-chat)"]) -->|"GET mcc_logs / POST export"| lb
lb -->|"usage read"| hs
lb -->|"async export enqueue (IAG)"| rw["report-worker (Sidekiq/gocraft)"]
rw -->|"read (:billing shard)"| db
modp(["Modpanel (moderator-be)"]) -->|"GET /iag/v1/reports/mcc-logs"| qb["qontak-billing (Go)"]
modp -->|"margin config + Chat Panel push"| core
qb -->|"read (:billing shard)"| db

Per-service responsibility

flowchart LR
subgraph hubcore["hub-core (Rails engine — the money path)"]
dedu["NewPricingWaDeduction<br/>(bill service if regular + flag)"]
price["WaPricing<br/>(base price + service fee)"]
mcc["MccLog read<br/>(usage table)"]
end
subgraph qbilling["qontak-billing (Go)"]
qmcc["ReportUsecase.MCCLogs<br/>(modpanel/IAG export)"]
qseed["v2_wa_conversation_prices<br/>(price catalog owner)"]
end
subgraph rworker["report-worker (Go)"]
rmcc["ExportMCCLogs<br/>(client quota export)"]
rback["service margin backfill<br/>(precedent: seed_blind_index)"]
end
subgraph modbe["moderator-be (Rails)"]
mrg["get_margin_list / update_margin<br/>(service ConversationFee)"]
end
subgraph fe["hub-chat (Nuxt/Vue)"]
tbl["TableComponentWhatsappBalance.vue<br/>(service label)"]
end
dedu --> price
dedu -->|"writes wa_conversation_logs"| DB[("billing DB")]
price -->|"reads v2_wa_conversation_prices + conversation_fees"| DB
mcc --> DB
qmcc --> DB
rmcc --> DB
rback --> DB
mrg -->|"writes conversation_fees + Chat Panel push"| DB
tbl -->|"GET mcc_logs"| mcc

Technical Decisions


Decision 1: Make a billed service message export-eligible (is_auto_deduct=TRUE)

Context new_pricing_wa_deduction.rb:84 sets @is_auto_deduct = false if conversation_type == 'UI'. Service maps to conversation_type='UI' (get_pricing, new_pricing_wa_deduction.rb:262-265). So a billed service log row is written is_auto_deduct=false, origin_type='UI'. Every read/export surface filters is_auto_deduct=TRUE (usage table MccLog; client export FetchMCCLogsExport; modpanel FetchWaConversationLogsBy*), and the two exports also filter origin_type='BI'. A billed service deduction would therefore be invisible everywhere — a silent charge. This is the root blocker the PRD did not identify.

Options considered

  • Option A — Fix the writer: when a service message is billable (PMP type=regular + flag ON), do not force is_auto_deduct=false; write is_auto_deduct=true like any other billable category.
    • Pros: single, localized change; the usage-table read (MccLog) already allows UI, so it starts showing service immediately; keeps is_auto_deduct semantically "this row moved money".
    • Cons: must be scoped precisely so free/CBP UI (referral, free service) keep is_auto_deduct=false.
  • Option B — Fix every reader: leave is_auto_deduct=false; teach all four read/export queries to include is_auto_deduct=false rows where conversation_category='service'.
    • Pros: no change to the deduction writer.
    • Cons: four query changes across three repos; pollutes the meaning of is_auto_deduct; risks pulling free/unbilled UI rows into money reports.

Decision: Option A — write is_auto_deduct=true only for a billable service message (flag ON, pricing.type=regular, conversation_category='service'). The two exports still need origin_type widened (Decision 5) because service is UI; but is_auto_deduct is fixed at the source.

Rationale is_auto_deduct means "this row is a real auto-deduction". A billed service row is one, so it should carry true. Option A is one guarded line change and immediately satisfies WSVC-S06 (the usage table already permits UI). Option B multiplies surface area and semantic risk.

Consequences The deduction writer gains a service-aware branch; downstream reset-gap / audit queries that key on is_auto_deduct now (correctly) count billed service. This must be regression-tested (SC-5) — notably any monthly-reset gap logic that assumes UI is never auto-deducted.

Reversibility Flag bill_service_messages OFF restores is_auto_deduct=false for service (no billing at all). Fully additive; no migration.


Decision 2: Two-toggle gating — per-org pilot flag + global default flag — driven on Meta pricing.type/billable

Context Once Meta sends service as pricing.type=regular, is_free_deduction? (new_pricing_wa_deduction.rb:199-205) returns false on the PMP path and the engine will deduct automatically — before Qontak has seeded prices/margins or verified anything. Two distinct operational needs follow: (a) test the new flow on specific organizations without disturbing everyone else (before and after the Meta date), and (b) flip the whole base to the new flow by default the moment Meta releases, while keeping a per-org escape hatch. The grounded flag API (Services::Preference, preference.rb) supports exactly this: enabled?(feature, organization_id:) = global boolean gate AND an allow-list; enabled?(feature, except_organization_id:) = global boolean gate AND NOT in a deny-list; enabled?(feature) = the plain global boolean; plus deterministic per-org percentage rollout (change_adoption_rate_of, TARGET_FEATURE_ADOPTION, hashes organization_id).

Options considered

  • Option A — Single flag (bill_service_messages), org-scoped allow-list only.
    • Pros: one flag; matches deduction_conversation_fee etc.
    • Cons: cannot express "default ON for everyone at release" cleanly — you would have to add every org id to the allow-list. No separate global master switch.
  • Option B — Two flags: per-org pilot flag + global default flag (chosen).
    • Pros: bill_service_messages (allow-list) drives safe per-org pilot testing; a separate bill_service_messages_global (global boolean) is the one-flip Meta-release master switch, with a per-org deny-list kill switch. Clear separation of "pilot" vs "GA default".
    • Cons: two flags to register and one precedence rule in code.
  • Option C — Global calendar cutover (bill from 1 Oct 2026 by date).
    • Cons: no rollback, no pilot, no per-org exception; a mispriced country mis-bills the whole base at once.

Decision: Option B — two-toggle model (three preferences records in practice — see Decision 6 for the storage layer change).

Implementation note (Decision 6 supersedes the storage detail here): the two flags are persisted as preferences records on the billing DB via Services::Billing::FeatureFlag, not as Services::Preference/Flipper records on the chat DB. The rollout semantics (pilot allow-list → global master switch → per-org kill switch) are identical; only the backing service changes. See Detail 2.K for the concrete flag → record mapping and console commands.

  1. bill_service_messages — the per-organization pilot toggle. A preferences record with state=true, is_global=false; preference_unique_ids rows = allowed orgs. Read in code as flag.enabled?(:bill_service_messages, unique_id: org_id). Used to test on internal + pilot CIDs without touching anyone else.
  2. bill_service_messages_global — the global default toggle (the Meta-release master switch). preferences record with state=true, is_global=true. At Meta release, the managing repo flips state=true, is_global=true → hub-core reads it as enabled?(:bill_service_messages_global) = true for every org. A per-org kill switch is modelled as a third record bill_service_messages_excluded (state=true, is_global=false) — the managing repo adds the org to its preference_unique_ids; use_service_billing? reads !enabled?(:bill_service_messages_excluded, unique_id: org_id).

Effective decision in hub-core (use_service_billing?(org_id)), evaluated only for conversation_category == 'service' on the PMP path.

Uses Services::Billing::FeatureFlag (Decision 6) — backed by preferences on the billing DB, Redis-cached with qontak-preferences key format (see Detail 2.K):

def use_service_billing?(org_id)
flag = Services::Billing::FeatureFlag.new
# 1) Global default (Meta-release master switch): bill_service_messages_global is_global=true
if flag.enabled?(:bill_service_messages_global)
# Per-org kill switch: org in bill_service_messages_excluded unique_ids → excluded from global
return !flag.enabled?(:bill_service_messages_excluded, unique_id: org_id)
end
# 2) Per-org pilot allow-list: org in bill_service_messages unique_ids → enabled for testing
flag.enabled?(:bill_service_messages, unique_id: org_id)
end

Above this, the hard conditions still hold: bill only when pricing_model=='PMP', pricing.type=='regular', pricing.billable != false, and category=='service' (Decision 1/4 apply). Defensive billable guard: Meta's payload carries pricing.billable (confirmed in the Meta doc); the PMP path ignores it today (new_pricing_wa_deduction.rb:35 forces billable=true for PMP), so the service branch bills only when type=='regular' AND billable != false — a still-free in-window service message sent as type=regular, billable=false is not over-charged (A-1a / OQ-12).

Rationale The two-flag split maps 1:1 to the two operational needs and to the grounded Preference API: the allow-list flag isolates pilots (SC-5 for everyone else); the global flag is a single, reversible flip for the Meta-release cutover; the deny-list gives a per-org kill switch after GA. Bill/skip is still ultimately driven by Meta (pricing.type/billable); the flags only gate the new billable path.

Consequences Three preferences records to seed + one precedence helper (use_service_billing?) read on the deduction hot path (cheap; Redis-backed via Services::Billing::FeatureFlag). Stage-0 gate: bill_service_messages_global must be flipped ON at/just before the Meta date for full-base coverage (or an org absorbs the Meta cost). Three records means three things to audit in observability (which toggle enabled a given deduction).

Reversibility Instant, at either grain (via the managing repo — not hub-core): remove a pilot org from preference_unique_ids for bill_service_messages; or set state=false/is_global=false on bill_service_messages_global to revert the whole base to free; or add the org to preference_unique_ids for bill_service_messages_excluded for a per-org kill switch. All additive; no migration (billing-DB records are inert when the service is disabled).


Decision 3: Margin via per-category ConversationFee(conversation_type='service')

Context The PRD (v1.1, 2026-07-22) sets the service margin to 0 (charge at cost) at launch — 0 by default for new CIDs (at account creation) and backfilled to 0 for existing CIDs on release. (This supersedes the PRD v1.0 assumption of a default-10 package_fees margin + custom-50 custom_margin_by_packages override.) Grounding shows the PMP margin resolves in hub-core WaPricing: get_db_cost_tax_conversation_fee (wa_pricing.rb:115-133) reads ConversationFee.find_by(organization_package_id, conversation_type: @conversation_category) when :deduction_conversation_fee is ON; otherwise get_db_cost_tax_package_fee uses PackageFee.ui_fee/bi_fee. For service, @conversation_category='service' and @conversation_type='UI'. With no ConversationFee row the fee falls back to DEFAULT_FALLBACK_UI_COST = 0.00 (conversation_fee.rb:8) — i.e. the code's natural fallback already equals the target launch margin (0). The remaining work is therefore to make the 0 explicit and auditable (visible in the margin list / backfilled), not to compute a markup.

Options considered

  • Option A — Seed a ConversationFee row conversation_type='service' per package (the modern, per-category margin path).
    • Pros: correct per-category granularity; moderator-be get_margin_list_db already reads ConversationFee; matches how utility/marketing/auth margins are modeled.
    • Cons: only effective when :deduction_conversation_fee is ON (A-3).
  • Option B — Reuse PackageFee.ui_fee (legacy path).
    • Pros: works when :deduction_conversation_fee is OFF.
    • Cons: ui_fee is shared by all UI conversations (referral etc.); no service-specific margin.
  • Option C — Wire custom_margin_by_packages (PRD WSVC-S04).
    • Pros: per-package override.
    • Cons: dead — no table migration in moderator-be, no read in qontak-billing/hub-core (verified). Would be a net-new subsystem.

Decision: Option A for the margin (seed ConversationFee service rows with cost = 0; surface in the margin list). New CIDs get the service ConversationFee = 0 at account creation (do not mirror bi_fee=10); existing CIDs are backfilled to 0 on release (WSVC-S05). Relying on the bare DEFAULT_FALLBACK_UI_COST = 0.00 would also yield 0, but an explicit row makes the 0 visible in the margin list and auditable. Defer Option C (OQ-3).

Rationale Option A is the path the money code actually reads and the margin config UI already models. It keeps service margin independent of other UI conversations, and an explicit 0 row is auditable (vs. an invisible fallback).

Consequences ⚠️ The 0-margin decision is only guaranteed on the ConversationFee (Option A) path. Where :deduction_conversation_fee is OFF, WaPricing falls back to PackageFee.ui_fee (Option B), whose default is 5, not 0 — and ui_fee is shared by all UI conversations, so it cannot carry a service-specific 0. Such orgs would bill service at ui_fee (a non-zero markup), violating the charge-at-cost decision. Therefore Stage 0 must confirm :deduction_conversation_fee is ON for every org enabled for bill_service_messages; any org still on the legacy path must not be enabled until migrated (or is a known, documented exception). This makes :deduction_conversation_fee = ON a hard precondition of the 0-margin launch, not just a rollout dependency.

Reversibility Additive conversation_fees rows; deleting them reverts to the 0.00 UI fallback (base price only).


Decision 4: Fail-safe on missing service price — never charge the 596.33 fallback

Context WaPricing#get_price_from_cache (wa_pricing.rb:149-157) returns DEFAULT_FALLBACK_PRICE = 596.33 when no v2_wa_conversation_prices row matches (code, 'UI', 'service'). For service across the whole base (~42.85M msgs/month), a blind 596.33 charge on an unconfigured country is a mass mis-bill.

Options considered

  • Option A — Detect the fallback for billable service and skip + alert (service_price_missing), do not move money.
    • Pros: protects against mass mis-bill; surfaces config gaps loudly.
    • Cons: a genuinely-missing country under-charges (Qontak absorbs) until seeded — an accepted, monitored trade-off.
  • Option B — Charge the fallback (today's behavior for other categories).
    • Pros: no code change.
    • Cons: mis-bills unconfigured countries at a fixed, wrong rate.

Decision: Option A, scoped to service. When flag ON and service resolves to the fallback (no seeded row), skip the deduction, log service_price_missing (org, country, code, message_id), and alert.

Rationale Under-charge (absorb) is recoverable; a wrong per-message charge to clients is a trust/finance incident. The alert makes the gap a Stage-0/ops action, not a silent leak.

Consequences Requires a way to distinguish "resolved 596.33 from a real row" vs "fell back". Implementation: check row presence in get_price_from_cache (return a sentinel / nil) rather than comparing the numeric value. Verified there is exactly one fallback constant to guard.

Reversibility Flag OFF removes the branch. Additive.


Decision 5: Widen the two export queries at their owners, scoped to service

Context Two export surfaces filter origin_type='BI' and so exclude service (UI): report-worker FetchMCCLogsExport (client quota export) and qontak-billing FetchWaConversationLogsByOrganizationID/ByChannelID (modpanel/IAG export). The usage-table read (hub-core MccLog) already allows UI and needs no filter change once Decision 1 fixes is_auto_deduct.

Options considered

  • Option A — Add conversation_category='service' (UI) to each query's filter, e.g. (origin_type='BI' OR (origin_type='UI' AND conversation_category='service')) AND is_auto_deduct=TRUE.
    • Pros: precise; excludes non-service UI (referral_conversion).
    • Cons: two .sql edits + sqlc regeneration in two repos.
  • Option B — Broaden to origin_type IN ('BI','UI').
    • Pros: one-word change.
    • Cons: pulls in referral_conversion (RC maps price to UI/service but is credited/typed as RC/UI); over-includes non-service UI. Rejected in PRD §14 too.

Decision: Option A in both qontak-billing/db/queries/wa_conversation_logs.sql and report-worker/db/billingdb/queries/wa_conversation_logs.sql, then regenerate sqlc in each. Modpanel (moderator-be) only forwards params — no change there.

Rationale Precision avoids money-report contamination; the change is at the query owner, not the forwarder.

Consequences sqlc must be regenerated manually in both repos (no sqlc generate step exists in either Makefile/CI — see §2.0). The generated .sql.go must be committed. pricing_type is not a selected column today; if reconciliation wants it, add it to the SELECT (optional, OQ-7).

Reversibility Revert the query + regenerate. Additive; no schema change (conversation_category already selected).


Decision 6: Isolate billing feature flags in Services::Billing::FeatureFlag — not Services::Preference

Context The two rollout flags (bill_service_messages, bill_service_messages_global) gate real-money PMP deductions. Decision 2's original design wires them to Services::Preference (backed by Models::Preference on the main chat DB + Redis/Flipper). This couples billing-path flag reads to the chat DB's failure domain: a degraded chat DB can block flag resolution on the deduction hot path.

Separately, qontak-preferences (the Go canonical flag management service) already defines a compatible persistence schema — preferences + preference_unique_ids — on the billing DB itself, including a Redis caching strategy with a documented key format (preference:{feature}:state, preference:{feature}:global, preference:{feature}:{unique_id}:unique). The tables are already provisioned in the billing DB; no migration is required.

Options considered

  • Option A — Keep Services::Preference (chat DB + Redis/Flipper, original plan).
    • Pros: no new models or service class; already grounded in the codebase.
    • Cons: billing flag reads cross-shard into the chat DB; chat-DB degradation affects billing; Flipper overhead for flags that are few and operationally managed.
  • Option B — Services::Billing::FeatureFlag (billing DB + same Redis key format as qontak-preferences, no Flipper dependency).
    • Pros: billing flags live on the billing DB (same failure domain as the deduction engine, price table, and conversation logs); Redis keys are interoperable with qontak-preferences Go service; preferences + preference_unique_ids tables already exist; Flipper is not needed for billing-specific, operationally seeded flags.
    • Cons: one new service class + two new AR models to write; no Flipper admin UI (flag records seeded via console/rake).

Decision: Option B — Services::Billing::FeatureFlag for all billing-path feature flags.

Rationale Billing flag reads should share the billing DB's failure domain, not the chat DB's. The qontak-preferences table schema is already provisioned in the billing DB; adding a thin Ruby service over it is the minimal-change path. Flipper is a convenience for general feature flags; billing flags are few, change rarely, and are managed operationally — they do not need Flipper's actor-targeting overhead. The Redis key format alignment with qontak-preferences is a bonus that enables cross-service visibility in a shared Redis.

See Detail 2.K for the full technical specification of Services::Billing::FeatureFlag.

Consequences use_service_billing? now calls Services::Billing::FeatureFlag.new.enabled?(...) instead of Services::Preference.new.enabled?(...) (Decision 2's code block updated below). Three preferences records on the billing DB replace two Flipper + chat-DB records (see Detail 2.K — Flag → record mapping). The Services::Preference flags bill_service_messages and bill_service_messages_global are not registered in Flipper; the Flipper admin UI is not involved. hub-core is read-only — flag provisioning and unique-id management live in qontak-preferences (the managing repo).

Reversibility Services::Billing::FeatureFlag can be swapped back to Services::Preference in use_service_billing? with a one-line change; the billing-DB flag records are inert if code reverts.


Detail 2.0 — Repo Reading Guide

Repo Map (mermaid)

flowchart LR
subgraph hc["hub-core"]
npd["repositories/v2/billings/new_pricing_wa_deduction.rb"]
wp["services/billing/v2/wa_pricing.rb"]
cf["models/billing/conversation_fee.rb"]
v2p["models/billing/v2_wa_conversation_price.rb"]
mcclog["repositories/billings/gets/mcc_log.rb"]
wkr["workers/billings/wa_deduction_worker.rb"]
end
subgraph qb["qontak-billing"]
qsql["db/queries/wa_conversation_logs.sql"]
qseed["db/migrations/*_v2_wa_conversation_prices"]
end
subgraph rw["report-worker"]
rsql["db/billingdb/queries/wa_conversation_logs.sql"]
rback["internal/chat/worker/worker_seed_blind_index.go"]
end
subgraph mb["moderator-be"]
gml["use_cases/accounts/get_margin_list.rb"]
um["repositories/.../chat_panel/update_margin.rb"]
end
subgraph fe["hub-chat"]
tbl["features/subscriptions/usages/TableComponentWhatsappBalance.vue"]
end
wkr --> npd --> wp
wp --> cf
wp --> v2p
tbl -->|"GET mcc_logs"| mcclog
gml --> cf

Existing Code Anchors

LayerPathWhy the agent reads itWhat pattern it teaches
BE (hub-core)app/core/domains/repositories/v2/billings/new_pricing_wa_deduction.rbthe PMP engine; the branch to changeis_free_deduction? L199-205 (PMP keys on pricing.type); @is_auto_deduct=false if UI L84; dedup unique_id=status.id L64-65; create_conversation_log L289-325
BE (hub-core)app/core/domains/services/billing/v2/wa_pricing.rbwhere price + margin resolvetotal_price L50 (price+fee, discount L52, tax L54); get_price_from_cache L149-157 (fallback DEFAULT_FALLBACK_PRICE); get_db_cost_tax_conversation_fee L115-133 (ConversationFee by @conversation_category)
BE (hub-core)app/core/domains/models/billing/v2_wa_conversation_price.rbthe price model + fallback constDEFAULT_FALLBACK_PRICE = 596.33 L6; find_by(code, conversation_type, conversation_category)
BE (hub-core)app/core/domains/models/billing/conversation_fee.rbmargin model + UI fallbackDEFAULT_FALLBACK_UI_COST = 0.00; per-organization_package_id+conversation_type
BE (hub-core)app/core/domains/repositories/billings/gets/mcc_log.rbusage-table read (already allows UI)where_clause origin_type ['BI','UI','RC'] AND is_auto_deduct: true; build_log deducted_credit=log.credit, conversation_category, message_id=external_id
BE (hub-core)app/core/workers/billings/wa_deduction_worker.rbworker → enginequeue :billing_wa_deduction; L21 NewPricingWaDeduction.new(params:).call
BE (hub-core)app/core/domains/services/preference.rbflag patternenabled?(feature, organization_id:) L61-71 wrapping Flipper
BE (qontak-billing)db/queries/wa_conversation_logs.sqlmodpanel/IAG export query to widenFetchWaConversationLogsByOrganizationID L75 / ByChannelID L104; WHERE origin_type='BI' AND is_auto_deduct=TRUE
BE (qontak-billing)internal/app/usecase/reports/mcc_logs.goexport usecaseMCCLogs L46; mapOrgRow/mapChannelRow L161/183
BE (qontak-billing)db/migrations/20240401040054_add_existing_schema.up.sqlprice catalog DDLv2_wa_conversation_prices L554-563 (cost numeric(6,2), no margin col)
BE (report-worker)db/billingdb/queries/wa_conversation_logs.sqlclient-export query to widenFetchMCCLogsExport L12-40 WHERE origin_type='BI' AND is_auto_deduct=TRUE; selects conversation_category L19, message_id L31
BE (report-worker)internal/chat/worker/worker_seed_blind_index.gobackfill precedentgocraft/work batch+offset loop; NOT-EXISTS idempotency; registered service_worker_pool.go:84; enqueued cmd/workenqueue/main.go
BE (moderator-be)app/domains/core/use_cases/accounts/get_margin_list.rbmargin list, two source pathsflag get_margin_list_from_db L23-27 → GetMarginListDb vs Chat Panel proxy
BE (moderator-be)app/domains/core/repositories/billing/margins/get_margin_list_db.rbhow margins are builtbuild_item L63-70 (conversation_fee from conversation_fees)
BE (moderator-be)app/domains/core/repositories/app_integrations/chat_panel/update_margin.rbChat Panel pushpigeon_put L14 /api/core/v1/billings/package_fee; build_params L29-44
BE (hub-service)app/services/api/core/v1/reports/resources/billing.rbusage-table read endpointget '/mcc_logs' L156; organization_id=me.organization_id L157 → Interactors::Billings::UserGetsMccLog (hub_core)
FE (hub-chat)features/subscriptions/usages/TableComponentWhatsappBalance.vueusage table (label)Category cell L269-274 (raw conversation_category); tableHeaders L370-392; message_id col L378; deducted_credit L281; fetch L515-530

Existing Contracts to Reuse, Extend, or Replace (BE)

ContractStatusJustificationOwner
POST Meta status webhook → WaDeductionWorkerNewPricingWaDeductionreuseunchanged ingress; service branch is downstreambifrost
NewPricingWaDeduction#callextendadd flag guard + is_auto_deduct fix + fail-safe (Decisions 1,2,4)bifrost
WaPricing (price + fee)extendfail-safe on missing price; service fee via ConversationFeebifrost
v2_wa_conversation_pricesreuse (seed data)add service rows; no schema changebifrost/billing
conversation_feesreuse (seed data)add service rows; no schema changebifrost/billing
GET /api/core/v1/reports/billing/mcc_logs (MccLog)reusealready allows UI; no query change neededbifrost
qontak-billing FetchWaConversationLogsBy*extendwiden filter to include service (Decision 5)billing
report-worker FetchMCCLogsExportextendwiden filter to include service (Decision 5)billing
moderator-be get_margin_list / update_marginextendsurface + push service ConversationFeebilling
Services::Billing::FeatureFlag + preferences records (3 flags)newbilling-path gate (Decision 2 + Decision 6; Detail 2.K)bifrost
custom_margin_by_packagesnew-with-justification (deferred)dead end-to-end; wiring it is net-new work, out of critical path (OQ-3)bifrost

Patterns to Follow (and where to find them)

LayerConcernPattern in repoReference fileDeviation?
BE (hub-core)Feature flag (general)Services::Preference.new.enabled?(:flag, organization_id:)services/preference.rb:61-71; siblings in new_pricing_wa_deduction.rbBilling-path flags deviate (Decision 6): use Services::Billing::FeatureFlag.new.enabled?(:flag, unique_id: org_id) backed by preferences on the billing DB — see Detail 2.K
BE (hub-core)Model base / shardModels::AbstractModelBilling on :billingv2_wa_conversation_price.rb, conversation_fee.rbnone
BE (hub-core)Result monadDry::Monads Success/Failurenew_pricing_wa_deduction.rbnone
BE (Go)Query + codegensqlc .sql.sql.goreport-worker/db/billingdb/queries/*.sql; qontak-billing/db/queries/*.sqlregen is manual (no CI step)
BE (Go)Backfill jobgocraft/work batch+offset+NOT-EXISTSreport-worker/internal/chat/worker/worker_seed_blind_index.gonone
BE (Go)Migrationgolang-migrate .up.sql/.down.sql, BEGIN;/COMMIT;qontak-billing/db/migrations/*none
BE (moderator-be)Margin configConversationFee/PackageFee + Chat Panel pigeon_putupdate_margin.rb, get_margin_list_db.rbnone
FE (hub-chat)Cell renderingraw field render in MpTableCellTableComponentWhatsappBalance.vue:269-274add a small label map (none exists today)
Crosssnake_case API → FE fieldsmcc_logs returns conversation_category etc. verbatimhub-core mcc_log.rb build_logTableComponentWhatsappBalance.vuenone

Reading Order for the Agent

  1. hub-core/.../new_pricing_wa_deduction.rb — the engine; is_free_deduction?, @is_auto_deduct line 84, create_conversation_log.
  2. hub-core/.../wa_pricing.rb — price + fee resolution, the 596.33 and 0.00 fallbacks.
  3. hub-core/.../models/billing/{v2_wa_conversation_price,conversation_fee}.rb — price/margin models + constants.
  4. hub-core/.../repositories/billings/gets/mcc_log.rb — usage-table read (already allows UI).
  5. hub-core/.../services/preference.rb — the flag pattern to follow.
  6. report-worker/db/billingdb/queries/wa_conversation_logs.sql — client-export filter to widen.
  7. qontak-billing/db/queries/wa_conversation_logs.sql — modpanel-export filter to widen.
  8. report-worker/.../worker_seed_blind_index.go — backfill precedent.
  9. moderator-be/.../get_margin_list_db.rb + update_margin.rb — margin config surface.
  10. hub-chat/features/subscriptions/usages/TableComponentWhatsappBalance.vue — the label change.

Source Verification (anti-hallucination — required)

LayerAnchor / contractVerified byEvidence
BEnew_pricing_wa_deduction.rb enginereadreads pricing_model L16,24, @pricing.type L25, @pricing.category L251; is_free_deduction? L199-205 (PMP → !pricing_type.eql?('regular')); free_conversations=['RC','UI'] L83; @is_auto_deduct=false if UI L84; dedup unique_id = PMP ? status.id : conv_id L64, WaUniqConvIdLog.create! L65; create_conversation_log L289-325, create! L324
BEpricing.billable handlingreadL34-36 billable = pricing_model.eql?('CBP') ? (@pricing.billable || false) : true; return success unless billablePMP hardcodes billable=true, so PMP billability is decided by type=='regular' alone (defensive guard added in Decision 2)
ExternalMeta pricing doc (technical implementation)WebFetch 2026-07-06confirms effective date 1 Oct 2026; per-message billing at utility/authentication rates; billable payload { billable:true, pricing_model:"PMP", type:"regular", category:"service" }; free-message representation after the date not stated (OQ-12)
BEPMP dedup unique indexreadmigration database/billing/db/migrate/20220125034812_create_wa_uniq_conv_id_log.rb:13 add_index :wa_uniq_conv_id_logs, :conversation_id, unique: true; rescue RecordNotUnique → success L164-165
BEfallback price 596.33readmodels/billing/v2_wa_conversation_price.rb:6 DEFAULT_FALLBACK_PRICE = 596.33; used wa_pricing.rb:153
BEservice price readreadwa_pricing.rb:149-157 V2WaConversationPrice.find_by(code:, conversation_type:, conversation_category:); cache key WaPrice::{code}::{type}::{category}
BEservice margin (fee)readwa_pricing.rb:115-133 ConversationFee.find_by(organization_package_id, conversation_type: @conversation_category); UI fallback conversation_fee.rb DEFAULT_FALLBACK_UI_COST = 0.00
BEusage-table read allows UIreadmcc_log.rb origin_type ['BI','UI','RC'] AND is_auto_deduct: true; build_log deducted_credit=log.credit.to_s, conversation_category, message_id=log.external_id
BEflag patternreadservices/preference.rb:61-71 Flipper.enabled?; no existing bill_service flag (grep negative)
BEwebhook entryreadinteractors/whatsapp/webhooks/message_status_notification.rb:30 (delivered/read → WaDeductionWorker.perform_async); worker wa_deduction_worker.rb:21
BEqontak-billing export filterreaddb/queries/wa_conversation_logs.sql FetchWaConversationLogsByOrganizationID L75 / ByChannelID L104; generated .sql.go WHERE origin_type='BI' L85/183, is_auto_deduct=TRUE L86/184; selects conversation_category L66/164; router rest_router.go:68 /reports/mcc-logs; usecase mcc_logs.go:46
BEqontak-billing price DDLreaddb/migrations/20240401040054_add_existing_schema.up.sql:554-563 cost numeric(6,2), cols country,code,conversation_type,conversation_category; no margin column
BEqontak-billing fallback (CBP, not money path)readhelper/consts/v2_conversation_price.go:6 WaDefaultFallbackPrice = 569.33; CBP path whatsapp_deduction.go conversation-id keyed
BEreport-worker export filterreaddb/billingdb/queries/wa_conversation_logs.sql FetchMCCLogsExport L12-40 WHERE origin_type='BI' L39, is_auto_deduct=TRUE; conversation_category L19; COALESCE(external_id,'n/a') AS message_id L31; sqlc v1.26.0 (sqlc-billing.yaml)
BEreport-worker export pathreadIAG /report/v1/billings/logs/exportBillingLogsExport worker_billing_logs.go:13IsMCC()ExportMCCLogs worker_export_mcc_logs.go:22FetchMCCLogsExport
BEreport-worker billing_logs is_free (NOT the WA path)readdb/billingdb/queries/billing_logs.sql FetchBillingLogsExport is_free=FALSE; is_free on billing_logs table only; wa_balance export uses FetchMCCLogsExport (wa_conversation_logs) — so PRD's billing_logs is_free change is not applicable
BEbackfill precedentreadworker_seed_blind_index.go gocraft/work, batch loop + offset += batchSize, NOT-EXISTS via search_tokens.sql.go; registered service_worker_pool.go:84; cmd/workenqueue/main.go enqueuer
BEmoderator-be margin listreadaccounts_controller.rb:122 get_margin_list; get_margin_list.rb:23-27 flag get_margin_list_from_dbGetMarginListDb; get_margin_list_db.rb:63-70 build_item; conversation_fees db/billing_schema.rb:523; defaults create_account.rb:155-156 `ui_fee
BEmoderator-be Chat Panel pushreadupdate_margin.rb:14 pigeon_put /api/core/v1/billings/package_fee; build_params ui_fee,bi_fee,conversation_fee,voice_margin
BEmoderator-be custom_margin deadread/grepcustom_margin_by_packages_controller exists; model custom_margin_by_package.rb:15; no table migration in moderator-be db/; uniqueness via use-case exists? (create_custom_margin_by_package.rb:19-21); custom_margin_field flag view-only
BEmodpanel MCC forwards to qontak-billingreadmoderator-be .../billing_service/reports/mcc_logs.rb:12-17 pigeon_get service:'billing_service', path:'/internal/qontak/billing/v1/reports/mcc-logs' (= qontak-billing)
BEhub-service usage endpointreadreports/resources/billing.rb:156 get '/mcc_logs'; organization_id=me.organization_id L157; served in-process via hub_core UserGetsMccLogMccLog; not a path param
FEusage table componentreadTableComponentWhatsappBalance.vue Category cell L269-274 (raw conversation_category, no label map); tableHeaders L370-392; message_id col L378; deducted_credit L281; fetch GET /api/core/v1/${org}/reports/billing/mcc_logs L521
FEexport drawerreadExportQuotaUsageDrawer.vue POST {IAGServiceUrl}/report/v1/billings/logs/export, aggregation_by:'message', quota_type via getQuotaType(); route pages/reports/export/quota.vue:11-15 guarded by report_omnichannel_view
FEframework + commandsreadpackage.json nuxt ~4.2.2, vue ^3.5.29, vitest, pnpm@10; lint=eslint, coverage=vitest, build=nuxt build
Crossshared billing DB assumptioninferred (OQ-4)hub-core :billing shard + moderator-be establish_connection :billing + Go services read same tables — operationally confirm

Design ↔ Code Mapping (frontend half)

Figma frame / componentImplementing fileReuse vs newDesign tokens usedBacking API endpoint(s)Deviation from design
n/a — no Figma (usage table Category cell)features/subscriptions/usages/TableComponentWhatsappBalance.vueextendedexisting MpText/MpTableCell (Pixel)GET /api/core/v1/reports/billing/mcc_logsnone — adds a label map for an existing cell, no visual redesign

Detail 2.1 — Architecture

End-to-end component diagram

flowchart TB
meta(["Meta webhook (delivered/read)"]) --> mns["hub-core MessageStatusNotification"]
mns --> wkr[["WaDeductionWorker (Sidekiq :billing_wa_deduction)"]]
wkr --> npd["NewPricingWaDeduction#call"]
npd --> flag{"use_service_billing?(org)<br/>AND category=service<br/>AND pricing.type=regular<br/>AND billable != false?"}
flag -- "no (free / not billable / not enabled)" --> free["skip — free (no deduction)"]
flag -- yes --> wp["WaPricing: base price + service fee"]
wp --> priceok{"service price row exists?"}
priceok -- no --> failsafe["log service_price_missing + alert;<br/>do NOT charge 596.33"]
priceok -- yes --> dedup{"message_id already billed?"}
dedup -- yes --> noop["no double deduction"]
dedup -- no --> deduct["deduct once; write wa_conversation_logs<br/>category=service, is_auto_deduct=TRUE"]
deduct --> db[("billing DB")]
db --> usage["hub-chat usage table (MccLog)"]
db --> cexport["client quota export (report-worker)"]
db --> mexport["modpanel MCC export (qontak-billing)"]

Data model (mermaid erDiagram)

erDiagram
ORGANIZATION_PACKAGES ||--o{ CONVERSATION_FEES : "has service margin"
V2_WA_CONVERSATION_PRICES ||--o{ WA_CONVERSATION_LOGS : "prices"
ORGANIZATION_PACKAGES ||--o{ WA_CONVERSATION_LOGS : "deducts from"
V2_WA_CONVERSATION_PRICES {
uuid id PK
string country
string code "price lookup key"
string conversation_type "UI for service"
string conversation_category "service (new rows)"
numeric cost "numeric(6,2), cap 9999.99"
}
CONVERSATION_FEES {
uuid id PK
uuid organization_package_id FK
string conversation_type "service (new rows)"
numeric cost "margin"
numeric tax
}
WA_CONVERSATION_LOGS {
uuid id PK
uuid organization_id
string conversation_id "message_id under PMP"
string external_id "wamid — shown as message_id"
string origin_type "UI for service"
string conversation_category "service"
string pricing_model "PMP"
string pricing_type "regular"
boolean is_auto_deduct "TRUE for billable service (Decision 1)"
string credited_to
numeric base_price
numeric total_price
numeric credit
}

State machine — service message billability

stateDiagram-v2
[*] --> Free: pre 1 Oct 2026 OR pricing.type not regular
[*] --> NotEnabled: type regular AND service billing not enabled for org
NotEnabled --> Free: treated as free (guard)
[*] --> Billable: type regular AND billable AND service billing enabled for org
Billable --> Skipped: no service price row (fail-safe)
Billable --> Deduped: message_id already billed
Billable --> Deducted: price present AND new message_id
Deducted --> Reported: appears in usage table + exports
Free --> [*]
Skipped --> [*]
Deduped --> [*]
Reported --> [*]

Branch & skip flow

flowchart TD
ev(["service webhook (delivered/read)"]) --> t{"pricing.type = regular<br/>AND billable != false?"}
t -- no --> skipfree["skip — free (unchanged)"]
t -- yes --> f{"service billing enabled for org?<br/>(global default OR pilot allow-list,<br/>minus deny-list)"}
f -- no --> skipflag["skip — free (guard, Decision 2)"]
f -- yes --> p{"service price row?"}
p -- no --> skipmiss["skip + log service_price_missing (Decision 4)"]
p -- yes --> bill["bill once; is_auto_deduct=TRUE"]
skipfree --> done(["done"])
skipflag --> done
skipmiss --> done
bill --> done

Detail 2.2 — Sequence (mermaid, end-to-end incl. failure paths)

Happy path — billable service deducted and reported

sequenceDiagram
actor Meta as Meta Cloud API
participant LB as Load Balancer
participant HS as hub-service (Grape)
participant W as WaDeductionWorker (Sidekiq)
participant E as NewPricingWaDeduction
participant Cache as Redis
participant DBR as Postgres billing (replica read)
participant DBW as Postgres billing (primary write)
Meta->>LB: POST status webhook (category=service, pricing.type=regular, message_id)
LB->>HS: HTTP
HS->>W: perform_async(params) [delivered/read]
W->>E: call
E->>E: is_free_deduction? (PMP → pricing.type != regular) = false
E->>E: service guard — use_service_billing?(org) AND pricing.billable != false? yes
E->>Cache: GET WaPrice::{code}::UI::service
alt cache miss
Cache-->>E: nil
E->>DBR: V2WaConversationPrice.find_by(code, UI, service)
DBR-->>E: cost
E->>Cache: SET WaPrice cost
else cache hit
Cache-->>E: cost
end
E->>DBR: ConversationFee.find_by(pkg, service) [margin]
DBR-->>E: cost, tax
E->>DBW: WaUniqConvIdLog.create!(conversation_id=message_id)
E->>DBW: WaConversationLog.create!(category=service, is_auto_deduct=TRUE, credit=base+fee)
DBW-->>E: committed
E-->>W: Success

Failure path — missing service price (fail-safe, Decision 4)

sequenceDiagram
participant E as NewPricingWaDeduction
participant Cache as Redis
participant DBR as Postgres billing (replica)
participant Alert as Rollbar / metric
E->>Cache: GET WaPrice::{code}::UI::service
Cache-->>E: nil
E->>DBR: V2WaConversationPrice.find_by(code, UI, service)
DBR-->>E: no row
E->>Alert: emit service_price_missing (org, code, message_id)
E-->>E: skip deduction (do NOT charge 596.33)

Failure path — duplicate message_id (idempotency)

sequenceDiagram
participant E as NewPricingWaDeduction
participant DBW as Postgres billing (primary)
E->>DBW: WaUniqConvIdLog.create!(conversation_id=message_id)
DBW--xE: ActiveRecord::RecordNotUnique (unique index)
E-->>E: rescue → return Success (no second deduction)

Read path — client usage table (failure branch: empty)

sequenceDiagram
actor U as Client Admin
participant FE as hub-chat usage table
participant HS as hub-service /mcc_logs
participant M as hub-core MccLog
participant DBR as Postgres billing (replica)
U->>FE: open subscriptions/usages
FE->>HS: GET /api/core/v1/reports/billing/mcc_logs
HS->>M: UserGetsMccLog(org from token)
M->>DBR: WHERE origin_type IN (BI,UI,RC) AND is_auto_deduct=true
alt rows
DBR-->>M: service rows (category=service)
M-->>FE: data (deducted_credit, message_id, category)
FE-->>U: table with "Service" label
else no rows
DBR-->>M: empty
M-->>FE: empty page
FE-->>U: existing empty state (no fabricated row)
end

Detail 2.3 — Database Model (DDL / Data)

No schema changes. All required columns already exist. This RFC seeds data into two existing tables and (Decision 5) edits two .sql query files + regenerates sqlc. Migration dialects confirmed: qontak-billing uses golang-migrate .up.sql/.down.sql; hub-core uses Rails database/billing/db/migrate/YYYYMMDDHHMMSS_slug.rb on the :billing shard.

Existing table — v2_wa_conversation_prices (seed service rows; no DDL change):

-- existing (qontak-billing/db/migrations/20240401040054_add_existing_schema.up.sql:554-563)
-- CREATE TABLE v2_wa_conversation_prices (
-- id uuid PK, country varchar, code varchar,
-- conversation_type varchar, conversation_category varchar,
-- cost numeric(6,2) NOT NULL DEFAULT 0.0, created_at, updated_at );

-- SEED (one row per country/code; example, values from Meta rate card — OQ-5):
INSERT INTO v2_wa_conversation_prices (id, country, code, conversation_type, conversation_category, cost, created_at, updated_at)
VALUES (gen_random_uuid(), 'Indonesia', 'ID', 'UI', 'service', 332.00, now(), now());
-- read by hub-core: V2WaConversationPrice.find_by(code:'ID', conversation_type:'UI', conversation_category:'service')
  • Cardinality: ~1 row per active country/code (tens of rows). No growth on hot path.
  • Constraint note: cost numeric(6,2) caps at 9999.99. Validate the full rate card fits (OQ-5); if any country exceeds, a schema widen migration is required first.
  • PII: none (catalog).

Existing table — conversation_fees (seed service margin rows; no DDL change):

-- existing (moderator-be db/billing_schema.rb:523; hub-core Models::Billing::ConversationFee)
-- columns: organization_package_id, conversation_type, cost, tax (+ timestamps)

-- SEED per active organization_package — service margin = 0 (charge at cost; PRD v1.1 decision).
-- New CIDs get this 0 at account creation; this backfill covers existing CIDs on release.
INSERT INTO conversation_fees (organization_package_id, conversation_type, cost, tax, created_at, updated_at)
SELECT op.id, 'service', 0.00, 0.0, now(), now()
FROM organization_packages op
WHERE NOT EXISTS (
SELECT 1 FROM conversation_fees cf
WHERE cf.organization_package_id = op.id AND cf.conversation_type = 'service'
);
-- read by hub-core WaPricing#get_db_cost_tax_conversation_fee (conversation_type = @conversation_category = 'service')
  • Cardinality: ~1 row per active organization_package. Backfilled by WSVC-S05 (idempotent NOT EXISTS).
  • PII: none (config).
  • Per-status lifecycle: n/a — neither table has a status enum. wa_conversation_logs rows are a permanent financial audit trail (no status enum; is_auto_deduct is a boolean, not a lifecycle).

No table has a new status enum → no stateDiagram for a status column (the service state machine above is behavioral, not a persisted enum).

Detail 2.4 — APIs

Outbound endpoints (consumers call us)

EndpointMethodAuthN/AuthZChangeStatus
/api/core/v1/reports/billing/mcc_logs (usage table; hub-servicehub-core MccLog)GEToauth2, org from token (me.organization_id)none — already returns UI; service appears once is_auto_deduct=TRUE (Decision 1)reuse
{IAG}/report/v1/billings/logs/export (quota_type=wa_balance) → report-workerPOSToauth2 + report_omnichannel_viewexport file includes service after FetchMCCLogsExport widenextended (query)
/iag/v1/reports/mcc-logs (qontak-billing; modpanel forwards /internal/qontak/billing/v1/reports/mcc-logs)GETmodpanel auth (upstream)export includes service after FetchWaConversationLogsBy* widenextended (query)
moderator-be get_margin_list / export_marginGETmodpanel authshows/export service ConversationFee marginextended
moderator-be update_margin → Chat Panel /api/core/v1/billings/package_feePUTmodpanel authpushes service margin (both get_margin_list_from_db paths)extended

Inbound webhooks (other services call us)

EndpointMethodSourceChangeStatus
Meta WhatsApp message-status webhook → hub-core MessageStatusNotification (delivered/read) → WaDeductionWorkerPOSTMeta Cloud APIunchanged ingress; the service-billing branch is downstream in NewPricingWaDeduction; idempotent on message_id via wa_uniq_conv_id_logs unique indexreuse

No net-new HTTP surface. Two existing export queries are widened; one existing deduction path gains a flag-gated branch; the margin-config endpoints gain a service row.

Detail 2.A — UI Contract

  • Component: features/subscriptions/usages/TableComponentWhatsappBalance.vue (Category cell, lines 269-274).
  • Change: introduce a small category → friendly-label map (none exists today) so conversation_category='service' renders as "Service"; other categories fall through to their raw value (no regression). No prop/type change; WhatsAppBalanceLog.conversation_category is already string (features/subscriptions/usages/types.ts).
  • Data source: GET /api/core/v1/${organization_id}/reports/billing/mcc_logs (existing; fetchWhatsAppUsageLog L515-530).
  • Fields consumed for service rows: conversation_category (label), conversation_type (=UI), message_id (existing column, L378), deducted_credit (L281).
  • A11y: unchanged — reuses existing MpTableCell/MpText semantics.

Detail 2.B — Data-Fetching Strategy

  • Library: Nuxt 4 $customFetch (existing). No new fetch layer.
  • Cache key / TTL: existing table pagination params (params in fetchWhatsAppUsageLog); no new caching.
  • Refetch triggers: existing (filter/page change). No optimistic updates (read-only report).

Detail 2.C — UI State Matrix

SurfaceLoadingEmptyErrorPartialSuccess
Usage table (service rows)existing skeletonexisting empty state (no fabricated service row)existing error statemixed categories render together; service labeled "Service"service rows show label + deducted_credit + message_id

Detail 2.D — Data Integrity Matrix

Write pathTransaction scopePartial failureIdempotency key + TTLConsistencyDuplicate-event handlingStale-read handling
Service deduction (hub-core)Models::AbstractModelBilling.transaction (WaUniqConvIdLog + WaConversationLog + balance)rollback on error; worker self-requeues on StaleObjectError/QueryCanceled (existing)wa_uniq_conv_id_logs.conversation_id (=message_id) unique index (permanent)strong (single txn)RecordNotUnique → Success, no second deductionreads via replica for pricing; write on primary
Price/margin seedsingle INSERT (idempotent NOT EXISTS)per-row skip(code,UI,service) / (organization_package_id,service) natural keysstrongNOT EXISTS guardn/a

Detail 2.E — Concurrency Collision Map

ResourceWritersCollisionResolution
wa_uniq_conv_id_logs (same message_id)duplicate/out-of-order webhooksdouble deductionunique index on conversation_idRecordNotUnique rescue (existing)
package balanceconcurrent deductions vs monthly resetover/under deductexisting with_lock + reset-gap logic; service now counts as is_auto_deduct=true (Decision 1) — regression-test reset gap (SC-5)
conversation_fees (same pkg+service)backfill vs manual margin editduplicate rowNOT EXISTS guard + (pkg, conversation_type) natural key

Detail 2.F — Async Job / Event Consumer Spec

JobTriggerInputRetryIdempotency keyNotes
WaDeductionWorkerNewPricingWaDeductionMeta delivered/read webhook(params, webhook)existing throttle + self-requeue on race/timeoutmessage_id via wa_uniq_conv_id_logsqueue :billing_wa_deduction; flag-gated service branch
Service-margin backfill (WSVC-S05)release deploy → operator enqueue{batch_size}retry: 0 (precedent)NOT EXISTS on (pkg, service)precedent report-worker/worker_seed_blind_index.go (gocraft/work) or hub-core rake seeding ConversationFee — see OQ-9 for which repo owns the job

Detail 2.F.1 — Responsibility Boundary Matrix

Step (execution order)Owning squad / serviceInbound triggerOutbound effectFailure handlerPRD anchor
1. Seed price + marginbifrost/billing (qontak-billing data + moderator-be)Stage 0 configrows in v2_wa_conversation_prices, conversation_feesbackfill re-run (idempotent)WSVC-S02/S03/S05
2. Enable togglebifrost (console)Stage 1+ (pilot) / Meta release (global)pilot: add org to bill_service_messages allow-list; GA: flip bill_service_messages_globalremove from allow-list / add to global deny-list / disable globalWSVC-S01
3. Deduct servicebifrost (hub-core)Meta regular webhookwa_conversation_logs row (is_auto_deduct=TRUE)fail-safe on missing price (Decision 4)WSVC-S01
4. Show in usage tablebifrost (hub-core MccLog + hub-chat)client opens usagesservice rows + labelexisting empty/error stateWSVC-S06
5. Client exportbifrost/billing (report-worker)client exportfile includes servicereconciliation (§10)WSVC-S07
6. Modpanel exportbifrost/billing (qontak-billing)modpanel downloadfile includes servicereconciliation (§10)WSVC-S08

Detail 2.F.2 — State Surface Contract

EntityState field / eventDefaultUpdated byRead viaStale window
Service deductionwa_conversation_logs.is_auto_deduct (=TRUE when billed)today: false for UINewPricingWaDeduction (Decision 1)MccLog, exportsreplica lag (seconds)
Service marginconversation_fees(service).costUI fallback 0.00 (fee)moderator-be / backfillWaPricing, get_margin_listRedis fee cache TTL (existing)
Service pricev2_wa_conversation_prices(code,UI,service).costfallback 596.33 (guarded)seedWaPricingRedis WaPrice TTL 30d

Detail 2.G — Cross-Layer Contract Verification

EndpointBE response schemaFE expected schemaMatch?Gaps
GET /api/core/v1/reports/billing/mcc_logsMccLog build_log: conversation_category, conversation_type, message_id, deducted_credit (=credit.to_s), country, recipient, customer_nameTableComponentWhatsappBalance.vue: conversation_category, conversation_type, message_id, deducted_credityesnone — FE renders conversation_category verbatim; RFC adds a display-only label map (no contract change)
POST {IAG}/report/v1/billings/logs/exportfile rows incl. conversation_category, message_id (from FetchMCCLogsExport)export drawer downloads file (no field binding)yesnone — service rows appear once query widened
/iag/v1/reports/mcc-logsrows incl. origin_type AS conversation_type, conversation_categorymodpanel renders downloadyesnone — query widen only

Detail 2.H — End-to-End Data Flow

Meta regular service webhook → hub-service (webhook ingress) → WaDeductionWorker → NewPricingWaDeduction (flag + is_free_deduction? + price/margin + fail-safe) → wa_conversation_logs (is_auto_deduct=TRUE) → [read] hub-service /mcc_logs → hub-core MccLog → hub-chat usage table (label) ; [export] report-worker FetchMCCLogsExport → client file ; qontak-billing FetchWaConversationLogsBy* → modpanel file.

  • Side effects: observability events (service_message_deducted, service_price_missing, dedup hit).
  • Ownership: all steps bifrost (billing sub-domain); FE label is hub-chat-fe.

Detail 2.I — Scope Boundaries

  • BE files to modify: hub-core/app/core/domains/repositories/v2/billings/new_pricing_wa_deduction.rb (flag guard, is_auto_deduct, fail-safe); hub-core/app/core/domains/services/billing/v2/wa_pricing.rb (missing-price sentinel); qontak-billing/db/queries/wa_conversation_logs.sql (+ regenerated .sql.go); report-worker/db/billingdb/queries/wa_conversation_logs.sql (+ regenerated .sql.go); moderator-be margin-list + update_margin service support.
  • BE data/seed: v2_wa_conversation_prices (service rows); conversation_fees (service rows) + backfill job.
  • FE files to modify: hub-chat/features/subscriptions/usages/TableComponentWhatsappBalance.vue (label map).
  • Files explicitly NOT touched: qontak-billing/internal/app/usecase/package/whatsapp_deduction.go (CBP); custom_margin_by_packages (dead, OQ-3); marketing/utility/authentication pricing/margins; report-worker billing_logs.sql (is_free path — not the wa_balance route).
  • Shared modules: NewPricingWaDeduction is the shared WA deduction path for all categories — the service branch must be additive and regression-tested (SC-5).

Detail 2.J — Asset Inventory

n/a — no new icons/illustrations/fonts. The only FE change is a text label map.


Detail 2.K — Billing Feature Flag Layer (Services::Billing::FeatureFlag)

Purpose

Services::Billing::FeatureFlag is a stateless service in hub-core that reads and writes billing-specific feature flags from the billing DB, using Redis as a write-through cache. It replaces Services::Preference for billing-path flags (Decision 6). No Flipper dependency.

Location

hub-core/app/core/domains/services/billing/feature_flag.rb ← new service
hub-core/app/core/domains/models/billing/preference.rb ← new AR model
hub-core/app/core/domains/models/billing/preference_unique_id.rb ← new AR model

Backing models

ModelTableDB shardBase class
Models::Billing::Preferencepreferences:billingModels::AbstractModelBilling
Models::Billing::PreferenceUniqueIdpreference_unique_ids:billingModels::AbstractModelBilling

Table schema mirrors qontak-preferences/repository/models.go (Preference + PreferenceUniqueID). Tables already exist in the billing DB; no migration is required. Key columns: feature (string, unique), state (boolean), is_global (boolean), extra (jsonb), expires_in, adoption_rate; unique-id table: preference_id (uuid FK), unique_id (string).

hub-core scope: read-only. Services::Billing::FeatureFlag in hub-core only provides enabled? (read + Redis cache population). Creating, updating, and managing flag records and unique-id memberships is handled by a separate repo (qontak-preferences or equivalent). hub-core never writes to the preferences or preference_unique_ids tables directly.

Redis key format

Follows qontak-preferences/service/util.go exactly — keys are interoperable when a shared Redis is used between hub-core and the Go service:

KeyPatternValue typeMeaning
Statepreference:{feature}:state"true" / "false"Preference.state — global on/off
Globalpreference:{feature}:global"true" / "false"Preference.is_global — skip unique-id check
Unique-idpreference:{feature}:{unique_id}:unique"true" / "false"whether this org/id is whitelisted

All keys are auto-namespaced by REDIS_BILLING_R's Redis::Namespace (Rails env prefix). REDIS_BILLING_R is used for both reads and writes (no REDIS_BILLING_W exists).

enabled? logic (mirrors qontak-preferences IsEnabled)

1. GET preference:{feature}:state from Redis
→ cache miss → Models::Billing::Preference.find_by(feature:)
→ not found → return false (fail-safe: flag not seeded yet)
→ found → SET state key; SET global key; continue
2. state == false → return false
3. GET preference:{feature}:global
→ true (is_global=true) → return true (all orgs — skip unique-id)
4. unique_id blank? → return false (not globally on; no id to check)
5. GET preference:{feature}:{unique_id}:unique
→ cache miss → PreferenceUniqueId.exists?(preference_id, unique_id)
→ SET key (caches both positive and negative)
→ return result

Flag → preferences record mapping

Flag keystateis_globalunique_ids (preference_unique_ids)Semantics
bill_service_messagestruefalsepilot org_idsPer-org pilot allow-list: enabled?(:bill_service_messages, unique_id: org_id) returns true only if org is in the list
bill_service_messages_globaltruetrueGlobal default master switch: enabled?(:bill_service_messages_global) returns true for all once flipped
bill_service_messages_excludedtruefalseorg_ids to excludePer-org kill switch after global flip: enabled?(:bill_service_messages_excluded, unique_id: org_id) returns true = org is excluded → use_service_billing? returns false

This maps 1:1 to Decision 2's rollout model: pilot allow-list, global master switch, per-org deny-list kill switch — all using the same whitelist enabled? semantics.

use_service_billing? (updated — Decision 2 + Decision 6)

def use_service_billing?(org_id)
flag = Services::Billing::FeatureFlag.new
if flag.enabled?(:bill_service_messages_global)
return !flag.enabled?(:bill_service_messages_excluded, unique_id: org_id)
end
flag.enabled?(:bill_service_messages, unique_id: org_id)
end

Public API (hub-core)

hub-core exposes only the read path. Write operations (seeding flags, adding/removing unique-ids, toggling state) are handled in the repo that owns the preferences table (qontak-preferences).

MethodSignatureNotes
enabled?(feature, unique_id: nil) → BooleanRedis-first read; falls back to billing DB on cache miss; returns false when flag not found (fail-safe)

Spec coverage

Co-located at services/billing/feature_flag_spec.rb. Must cover all enabled? branches: flag not seeded → false (fail-safe); state=false in DB (cold cache) → false, populates Redis; Redis warm-false → false, no DB hit; state=true, is_global=true → true; warm-true → true, no DB hit; state=true, is_global=false, no unique_id → false; unique_id present in DB → true, caches result; unique_id absent from DB → false, caches result; warm "true"/"false" → no DB hit.


3. High-Availability & Security

Service billing is fail-safe to the client's balance: with the flag OFF nothing changes; with it ON, a missing price row skips + alerts rather than charging a blind fallback (Decision 4), and a duplicate message_id cannot double-charge (unique index). The worst case is an under-charge (Qontak absorbs) surfaced by service_price_missing, never a wrong client charge.

Performance Requirement

  • Deduction hot path: adds one flag read + one ConversationFee lookup (cached) — within the existing per-message PMP SLA; other billing flags are already read here. Flag OFF early-returns to today's path (SC-5).
  • Exports: unchanged async paths (report-worker Sidekiq/gocraft; qontak-billing HTTP). Widened WHERE adds a bounded predicate on an already-scanned range.
  • Load: service is ~42.85M msgs/month (monetary-impact doc) — same webhook volume already processed today (they arrive now, just free); no new ingress load, only a new deduction branch.

Monitoring & Alerting

EventTriggerProperties
service_message_deductedbillable service deductedorganization_id, message_id, country/code, base_price, margin, total, credited_to, enabled_via (pilot/global)
service_message_skipped_freeservice with free pricing.type/billable=false, or service billing not enabledorganization_id, message_id, pricing_type, reason (free_type/not_billable/not_enabled)
service_price_missingno service price row for a billable msgorganization_id, country, code, message_id
service_deduction_dedup_hitduplicate message_idorganization_id, message_id
  • Alert 1: service_price_missing > 0 in any 5-min window → PagerDuty on-call (a billable message could not be priced).
  • Alert 2: Meta service invoice vs service_message_deducted total diverges > 2%/day → #bifrost-billing + Finance.
  • Dashboard owner: Bifrost. Metric naming follows existing billing events in hub-core (Rollbar/metric emit pattern).

Logging

Structured: organization_id, message_id, country/code, conversation_category, decision branch. No customer phone/name in the new service-billing log lines (existing PII handling on WaConversationLog via Lockbox is unchanged).

Security Implications

  • Threat — mass mis-bill / silent charge: mitigated by the flag (per-org), the missing-price fail-safe (Decision 4), and making the charge visible on all surfaces (Decision 1).
  • Tenancy: every deduction keyed by organization_id; usage read scoped to me.organization_id (token, not path param); modpanel export is cross-tenant by design (modpanel auth).
  • Injection: ActiveRecord parameterized (hub-core); sqlc parameterized (Go); no string-built SQL.
  • Static analysis: Brakeman (hub-core/moderator-be CI), staticcheck+gosec (Go CI) — existing.

Role × Endpoint Authorization Matrix

RoleEndpoint(s)MethodsTenant scopeUI visibilityConstraintAudit
Client Adminmcc_logs read; quota exportGET/POSTown orgusage table + export drawerreport_omnichannel_view for exportrequest logs
Modpanel Adminget_margin_list, export_margin, update_margin, download-muv-mccGET/PUTany orgmodpanelmodpanel authmodpanel logs
Billing/Finance Opsseed price/margin, backfill, flag enableany orgn/aconsolemigration/job logs
System (webhook/worker)deduction (internal)per-orgn/aflag-gatedWaConversationLog + events

Detail 3.A — Failure Mode Catalog (merged)

SurfaceFE behavior on failureBE response on failureCode-shape consistency
Usage table readexisting error/empty stateMccLog returns Failure on missing org/package; empty page on no rowsyes
Deduction (missing price)n/a (backend)skip + service_price_missing alert; return Success (no charge)yes
Deduction (duplicate)n/aRecordNotUnique → Successyes
Export (filter not deployed)file lacks service rowsdetectable via export-vs-deduction reconciliation (§10)yes

Detail 3.A.1 — Branch & Skip Catalog

Branch triggerWhere checkedDownstream effectAuditUser-visible?
pricing.type != regular (free service)hub-core is_free_deduction? L199-205no deductionservice_message_skipped_freeno
pricing.billable == false (service, PMP)hub-core service branch (new defensive guard, Decision 2)no deduction (free)service_message_skipped_free (reason=not_billable)no
service billing not enabled for org (use_service_billing? false — not in pilot allow-list and global default OFF, or in the global deny-list)hub-core service branch (new, Decision 2)no deduction (free)flag state (pilot/global)no
missing service pricehub-core WaPricing (new, Decision 4)no deduction + alertservice_price_missingno (ops-visible)
duplicate message_idwa_uniq_conv_id_logs unique indexno second deductionservice_deduction_dedup_hitno

Detail 3.B — Error Response Catalog (BE)

EndpointErrorHTTPWhenUser-facing?
mcc_logs readOrganization not found / no packages4xx (existing shape)invalid orgno (guarded)
exportsexisting export-failure shape5xxworker/query errorno

Detail 3.C — Error Message Catalog (FE)

n/a — no new FE error states. The label map reuses existing table loading/empty/error states.

Detail 3.D — Compliance & Data Governance

N/A — no new compliance trigger; no new PII column. wa_conversation_logs already stores billing records with existing Lockbox PII handling (customer_name/phone_number) unchanged by this RFC.

Detail 3.E — Accessibility

n/a — no new interactive UI; the service label reuses existing MpText semantics (WCAG AA already met by the table).


4. Backwards Compatibility and Rollout Plan

Compatibility

  • Flag OFF ⇒ byte-identical current behavior (SC-5): service stays free (guard), is_auto_deduct=false for UI, exports/usage unchanged.
  • No schema migration — only data seed + two query edits (additive) + one FE label. conversation_category is already a selected export column.
  • Deploy order: BE (seed + query widen + hub-core guard, all flag-OFF) → enable flag per org → FE label (independent; the label is cosmetic and safe with old/new BE).

Rollout Strategy

Release toggle model (Decisions 2 + 6). Three preferences records on the billing DB, read by hub-core via Services::Billing::FeatureFlag; managed (seeded, toggled, unique-ids added/removed) by the managing repo (qontak-preferences) — not by hub-core:

  • bill_service_messages — per-org pilot allow-list (state=true, is_global=false). The managing repo adds preference_unique_ids rows to enroll pilot orgs; hub-core reads enabled?(:bill_service_messages, unique_id: org_id).
  • bill_service_messages_global — global default master switch (state=true, is_global=true). The managing repo flips is_global=true at Meta release → hub-core reads enabled?(:bill_service_messages_global) = true for all orgs.
  • bill_service_messages_excluded — per-org kill switch after global flip (state=true, is_global=false). The managing repo adds an org to preference_unique_ids → hub-core reads !enabled?(:bill_service_messages_excluded, unique_id: org_id).

Precedence in use_service_billing?(org_id): global-default AND NOT excluded OR pilot allow-list → bill; else free.

  • Stage 0 — Config (all flags OFF, before 1 Oct 2026): seed service price (v2_wa_conversation_prices) for all active codes; seed default service ConversationFee margins + run backfill (WSVC-S05); deploy the two widened export queries; deploy hub-core guard code; managing repo seeds all three preferences records with state=false. Gate: config completeness = 100%, backfill re-run = 0 dup.
  • Stage 1 — Internal: managing repo adds ≤5 internal CIDs to bill_service_messages preference_unique_ids; send regular service; verify deduction, usage table, both exports, 0 service_price_missing. Everyone else untouched.
  • Stage 2 — Pilot: managing repo enrolls 5–10 real CIDs with service volume; 1 week; Meta-invoice-vs-deduction variance ≤ 2%; 0 double-charge.
  • Stage 3 — Batched GA (optional ramp): managing repo grows the allow-list in batches timed so coverage reaches 100% by 1 Oct 2026.
  • Meta-release cutover (the global default): at/just before the Meta effective date, managing repo sets bill_service_messages_global to is_global=true → the new flow becomes the default for all orgs. Any org already on the pilot allow-list keeps billing seamlessly.
  • Stop conditions: any double-charge; service_price_missing > 0 sustained; export-vs-deduction gap; Meta-invoice variance > 2%/day.
  • Rollback (either grain, instant, via managing repo): remove a pilot org from preference_unique_ids; or add the org to bill_service_messages_excluded unique-ids for a per-org kill switch after GA; or flip bill_service_messages_global to state=false/is_global=false to revert the whole base to free. Data seeds are inert when service billing is not enabled.

Detail 4.A — Cross-Layer Rollout Compatibility Matrix

ScenarioFEBEWorks?Mitigation
Pre-deployOldOldyesbaseline
Backend first (seed+query+guard, both toggles OFF)OldNewyesno behavior change until a toggle enables an org
Frontend first (label)NewOldyeslabel map is display-only; no service rows yet → no effect
Both deployed, service billing enabled (pilot or global)NewNewyestarget state
Backend rollback (disable both toggles)NewOldyeslabel map harmless with no service rows
Frontend rollbackOldNewyesservice rows render with raw service text (no label) — acceptable

Detail 4.B — Configuration Contract

LayerEnv var / flagTypeDefaultRequiredProvisionerSecret?
Billing DB (preferences)bill_service_messages (read by Services::Billing::FeatureFlag)per-org allow-list (state=true, is_global=false)OFFyesmanaging repo (qontak-preferences) — not hub-coreno
Billing DB (preferences)bill_service_messages_global (read by Services::Billing::FeatureFlag)global master switch (state=true, is_global=true)OFFyes (flip at Meta release)managing repono
Billing DB (preferences)bill_service_messages_excluded (read by Services::Billing::FeatureFlag)per-org kill switch after GA (state=true, is_global=false)emptyas-neededmanaging repono
BE (hub-core)deduction_conversation_fee (existing)boolper-orgdependency (A-3)existingno
Datav2_wa_conversation_prices service rowsseedabsentyes (Stage 0)migration/seedno
Dataconversation_fees service rowsseedabsent → 0.00 fallbackyes (Stage 0)seed + backfillno

Detail 4.C — Test Plan (commands sourced from repo)

LayerCommand (source)What it must prove
BE hub-core lintbundle exec rubocop (.rubocop.yml; CI bitbucket-pipelines.yml)lint passes
BE hub-core unitbundle exec rspec app/core/domains/repositories/v2/billings/new_pricing_wa_deduction_spec.rb (.rspec; CI min-cov 90% on app/core/domains/repositories)flag guard; service billed on regular; free stays free; dup idempotent; is_auto_deduct=TRUE; fail-safe on missing price
BE qontak-billingmake test (Makefile:68-72, go test -race ./internal/app/...); make lint (Makefile:84-87, staticcheck)widened FetchWaConversationLogsBy* returns service; free service excluded
BE qontak-billing sqlcsqlc generate (manual — no Makefile/CI step; §2.0) then commit .sql.gogenerated query matches widened .sql
BE report-workermake test (Makefile:47-52); make lint (staticcheck)widened FetchMCCLogsExport returns service; pre-Oct range → none
BE report-worker sqlcsqlc generate (manual; both sqlc.yaml+sqlc-billing.yaml)regenerated .sql.go committed
BE moderator-bebundle exec rspec (Makefile:83-90); bundle exec rubocopservice margin resolves on DB + Chat Panel paths; other margins unchanged
FE hub-chatpnpm lint (package.json:13); pnpm coverage (package.json:18, vitest)service renders "Service" label; other categories unregressed
FE hub-chat buildnuxt build (package.json:6)build passes

Detail 4.D — Agent Execution Plan

OrderLayerChunkFiles to modify/createCommandsAcceptance criteria
1Data (BE)Seed service base priceqontak-billing/db/migrations/<ts>_seed_service_wa_prices.up.sql/.down.sql (per code; values OQ-5)make migrate-up (qontak-billing)V2WaConversationPrice.find_by(code, 'UI','service') returns seeded cost (not 596.33)
2BEhub-core billing flag layer (read-only enabled?) + two-toggle gate + billable/type guard + is_auto_deduct + fail-safehub-core/app/core/domains/models/billing/preference.rb (new, read-only AR model); hub-core/app/core/domains/models/billing/preference_unique_id.rb (new, read-only AR model); hub-core/app/core/domains/services/billing/feature_flag.rb (new — Services::Billing::FeatureFlag, enabled? only); hub-core/.../new_pricing_wa_deduction.rb (add use_service_billing?(org) via FeatureFlag; service branch: gate on type=='regular' AND billable != false AND use_service_billing?; is_auto_deduct=TRUE); .../wa_pricing.rb (missing-price sentinel); managing repo seeds the 3 preferences records with state=false before Stage 1 (+specs for all hub-core files)bundle exec rspec .../new_pricing_wa_deduction_spec.rb .../feature_flag_spec.rb; rubocopSC-1/2/4/5 rspec pass; pilot allow-list org bills; non-listed org free (global OFF); global ON bills all except excluded; type=regular,billable=false→free; all OFF byte-identical
3BEWiden both export queriesqontak-billing/db/queries/wa_conversation_logs.sql (+ regen .sql.go); report-worker/db/billingdb/queries/wa_conversation_logs.sql (+ regen .sql.go)sqlc generate (each repo); make test (each)widened queries return service (conversation_category='service'); free service excluded
4Data (BE)Seed default service marginconversation_fees seed (SQL or hub-core seed)seed run; rspec on WaPricing feeget_db_cost_tax_conversation_fee returns seeded margin (not 0.00) for a package with a row
5BEmoderator-be margin list servicemoderator-be get_margin_list_db.rb / update_margin.rb (+specs)bundle exec rspec; rubocopservice margin shown (both paths) + pushed to Chat Panel; other margins unchanged (S03-NEG)
6BEBackfill default service marginsbackfill job (report-worker gocraft precedent or hub-core rake — OQ-9)job run twiceseeds eligible packages; re-run = 0 dup (NOT EXISTS); per-row error counted, batch continues
7FEUsage-table service labelhub-chat/.../TableComponentWhatsappBalance.vue (+vitest)pnpm coverage; pnpm lint; nuxt buildservice → "Service" label; deducted_credit+message_id shown; empty state unchanged

Order rationale: seed price (1) → engine bills correctly (2) → make it exportable (3) → margin config (4,5) → backfill existing packages (6) → cosmetic label (7). All ship flag-OFF; the flag is enabled per Stage 1+ after chunks 1–6 are verified.

Detail 4.E — Verification & Rollback Recipe

  • Pre-merge (per chunk):
    • hub-core: 1) bundle exec rubocop; 2) bundle exec rspec .../new_pricing_wa_deduction_spec.rb.
    • Go: 1) sqlc generate (commit .sql.go); 2) make lint; 3) make test; 4) (chunk 1) make migrate-up + make migrate-down dry-run.
    • moderator-be: 1) bundle exec rubocop; 2) bundle exec rspec on changed specs.
    • hub-chat: 1) pnpm lint; 2) pnpm coverage; 3) nuxt build.
  • Post-deploy (flag-OFF soak): confirm non-enabled orgs show byte-identical deduction/balance/usage/exports; then enable one pilot org and watch service_message_deducted > 0, service_price_missing = 0, service visible in usage table + both exports, first day reconciles to Meta within 2%.
  • Rollback: 1) set bill_service_messages OFF for the org (instant revert to free); 2) if needed, revert the widened queries (+ regen) and the FE label; 3) confirm service_price_missing / variance metrics return to baseline. Seeds are inert with the flag OFF, so no data rollback is required.

Detail 4.F — Resource & Cost Notes

  • Compute: no new pods; one extra cached lookup on the existing deduction path.
  • DB: tens of price rows + ~one margin row per package; negligible storage. Widened export queries add a bounded predicate.
  • Business impact (not infra cost): ~14–15 bn IDR/month of previously-free traffic becomes billable (monetary-impact doc) — the intended outcome, concentrated (top-10 CIDs ≈ 14% of impact).

5. Concern, Questions, or Known Limitations

#TypeQuestion / limitationOwnerStatus
OQ-1Grounding correctionFallback is 596.33 (hub-core DEFAULT_FALLBACK_PRICE), not 569.33; the PMP money path is hub-core, not qontak-billing. PRD should be updated.Bifrost PMopen
OQ-2Resolved (2026-07-22)Is the service margin a percentage or fixed amount, and the value(s)? Moot — the margin is settled at 0 (charge at cost) at launch (PRD v1.1 / §14). ConversationFee.cost = 0, so unit is irrelevant (total_price = price + 0). No longer blocks seeding/Stage 0. Field representation revisited only if a future non-zero markup is introduced.Finance + Bifrostclosed
OQ-3Limitationcustom_margin_by_packages (PRD WSVC-S04) is dead end-to-end: no schema in moderator-be, no read in qontak-billing/hub-core (model-only). Per-package overrides are deferred; default per-category ConversationFee is the margin mechanism. Wiring S04 is net-new work.Bifrost Engopen
OQ-4RiskConfirm v2_wa_conversation_prices + conversation_fees are the same physical tables on the shared :billing DB read by hub-core and written by moderator-be/seeded by qontak-billing. Design assumes yes (A-2).Bifrost Engopen
OQ-5RiskDoes the full Meta service rate card (per code) fit cost numeric(6,2) (cap 9999.99)? If any country exceeds, a schema-widen migration precedes Stage 0.Bifrost Engopen
OQ-6Assumption (doc-confirmed)Meta sends billable service as {billable:true, pricing_model:"PMP", type:"regular", category:"service"} from 1 Oct 2026 — confirmed by the Meta doc (fetched 2026-07-06) and matching hub-core's parser. Residual risk: Meta could change the payload before GA.PMlow
OQ-12RiskThe Meta doc does not state how a still-free in-window service message is represented after 1 Oct 2026 (type=free_customer_service vs type=regular, billable=false). hub-core's PMP path ignores pricing.billable (new_pricing_wa_deduction.rb:35). Mitigation (this RFC): bill service only when type=='regular' AND billable != false; confirm the free representation with Meta / a webhook sample during Stage 1 and adjust the guard if needed.Bifrost Engopen
OQ-7Nice-to-haveAdd pricing_type to the export SELECT (both query owners) for reconciliation? Currently not selected.Bifrost Engopen
OQ-8GovernanceAssign a named engineering DRI (RFC dri currently the PM group).Bifrostopen
OQ-9Open QuestionWhich repo owns the margin backfill (WSVC-S05)? conversation_fees is read by hub-core (Ruby) — a hub-core rake/migration is the natural owner; the report-worker gocraft precedent is Go and would need billing-DB write access. Confirm.Bifrost Engopen
OQ-10RiskDecision 1 makes billed service is_auto_deduct=TRUE. Verify no monthly-reset gap / audit query in hub-core mis-behaves now that a UI row can be is_auto_deduct=true (SC-5 regression).Bifrost Engopen
OQ-11Precondition (A-3) — elevatedConfirm :deduction_conversation_fee is ON for every org enabled for bill_service_messages, so ConversationFee (= 0) governs the margin, not PackageFee.ui_fee (default 5). With the 0-margin decision, an org on the legacy path would bill a non-zero ui_fee markup — violating charge-at-cost. Do not enable such orgs until migrated. Now a hard precondition, not just a dependency.Bifrost Engopen

6. Comment logs

DateComment(s) FromAction Item(s)
2026-07-06RFC authorDrafted from PRD service-message-billing.md + direct code grounding of hub-core, qontak-billing, report-worker, moderator-be, hub-service, hub-chat (Source Verification §2.0 complete — every anchor read). Corrected three PRD premises (fallback 596.33; no service-free special-case; is_auto_deduct is the real blocker) and identified custom_margin_by_packages as dead (OQ-3). All mermaid blocks authored to avoid parser pitfalls (no ; in notes; parens/slashes quoted; <br/> for line breaks) and validated with mmdc before save.
2026-07-06RFC author (release toggles)Reworked the rollout to a two-toggle model per stakeholder ask: bill_service_messages (per-organization pilot allow-list, enabled?(..., organization_id:)) to test without disturbing the existing flow, and bill_service_messages_global (global default master switch flipped at Meta release, with a per-org deny-list kill switch via enabled?(..., except_organization_id:)). Grounded against hub-core/app/core/domains/services/preference.rb (allow-list/deny-list/adoption-rate semantics). Added use_service_billing? precedence helper; corrected flag kwarg to organization_id: (not company_id:) throughout. Updated Decision 2, §4 Rollout, §4.A/§4.B, Dependencies, PRD-to-Schema, §2.F.1, Branch & Skip catalog, observability, and 3 diagrams; re-validated all mermaid.
2026-07-06RFC author (billing feature flag layer)Added Decision 6 + Detail 2.K: replaced Services::Preference/Flipper with Services::Billing::FeatureFlag for all billing-path feature flags. Backing models Models::Billing::Preference + Models::Billing::PreferenceUniqueId on the billing DB (tables already provisioned; no migration); Redis key format mirrors qontak-preferences/service/util.go (preference:{feature}:state, :global, :{unique_id}:unique). Two-toggle design (Decision 2) revised to three billing-DB records: bill_service_messages (allow-list), bill_service_messages_global (global master switch), bill_service_messages_excluded (deny-list kill switch). use_service_billing? updated to use the new service; Dependencies, Config Contract (§4.B), and Rollout sections updated accordingly.
2026-07-06RFC author (Meta-doc reground)Re-grounded against the Meta pricing doc (fetched via WebFetch). Confirmed: 1 Oct 2026 effective date, per-message billing at utility/authentication rates, and the billable payload {billable:true, pricing_model:"PMP", type:"regular", category:"service"} (matches hub-core parser). New finding: the payload also carries pricing.billable, which hub-core's PMP path ignores (new_pricing_wa_deduction.rb:35 hardcodes billable=true for PMP) — added a defensive billable != false guard for service (Decision 2) and OQ-12 (free in-window representation not stated by Meta). Updated 3 mermaid diagrams (branch flow, component flow, state machine) + sequence; re-validated all 11 blocks with mmdc. Open blockers: OQ-2 (margin unit), OQ-4 (shared-DB), OQ-5 (rate-card cap), OQ-9 (backfill owner), OQ-10 (is_auto_deduct regression), OQ-12 (free service representation).
2026-07-22RFC author (margin-policy decision)Synced to PRD v1.1: service margin = 0 (charge at cost) at launch — new CIDs default to 0 at account creation, existing CIDs backfilled to 0 on release. OQ-2 (margin unit) resolved as moot (0% = 0 fixed) — removed from execution blockers. Decision 3 updated: seed ConversationFee('service') at cost 0.00 (was 10.00); seed SQL changed accordingly. Elevated A-3/OQ-11 to a hard precondition: the 0-margin guarantee holds only on the ConversationFee path — orgs with :deduction_conversation_fee OFF fall back to PackageFee.ui_fee (default 5), which would bill a non-zero markup; such orgs must not be enabled for bill_service_messages until migrated. No code-structure changes; diagrams unaffected.

7. Ready for agent execution

  • no — the BE/FE chunks are concrete and grounded, but money-moving blockers remain:
    • OQ-2 (margin unit/value) resolved 2026-07-22 — margin is 0 at launch; conversation_fees seed value is 0.00, no Finance confirmation needed.
    • OQ-5 (rate-card cap) — must confirm the Meta service rate card fits numeric(6,2) before Stage 0.
    • OQ-10 (is_auto_deduct regression) — must verify reset-gap/audit queries before enabling the flag.
    • OQ-4/OQ-9 — confirm shared-DB assumption and backfill owner before chunks 4/6.
    • OQ-11 (precondition) — confirm :deduction_conversation_fee is ON for every enabled org, else ui_fee (5) applies and breaks charge-at-cost.
    • OQ-12 (free service representation) — confirm during Stage 1 whether still-free in-window service arrives as type=free_customer_service or type=regular, billable=false, and that the defensive billable != false guard (Decision 2) matches Meta's actual payload.
  • Status of execution-readiness gates:
    • PRD-to-Schema / Traceability: complete; every PRD story mapped (WSVC-S04 deferred with reason).
    • Detail 1.C Per-Story Change Map: complete; only WSVC-S06 is genuinely FE+BE.
    • Repo Reading Guide + Source Verification: complete — every anchor verified against real files across 6 repos, with file:line evidence; three PRD corrections recorded.
    • Infrastructure Topology + per-service responsibility: present.
    • Technical Decisions: 5 ADR-format blocks (Decisions 1–5; the two remaining §1.B rows fold into Decision 3 and OQ-3) — minimum coverage addressed (storage/seed, sync-async via WaDeductionWorker, Redis price/fee caching, Meta third-party trigger, strong per-txn consistency, per-org multi-tenancy, reuse-vs-new).
    • Mermaid diagrams: topology, per-service, repo map, component, ER, state, branch/skip, 4 sequences (incl. failure paths) — validated.
    • DDL/Data: no schema change; seed DDL + idempotent backfill; dialects confirmed.
    • APIs: outbound (reads/exports extended) + inbound (Meta webhook reused); no net-new surface.
    • Cross-Layer Contract Verification: all Match? = yes.
    • Async / Integrity / Concurrency / Branch & Skip: complete (PMP idempotency, fail-safe, flag guard).
    • Rollout / Compatibility Matrix / Config Contract: complete — two toggles (bill_service_messages per-org pilot allow-list + bill_service_messages_global global default with per-org deny-list kill switch); Meta-release cutover = one global flip; rollback at either grain.
    • Agent Execution Plan: 7 ordered chunks with files + repo-sourced commands + verifiable ACs.
    • Verification & Rollback Recipe: per-layer commands runnable; signals named.

Optional: hand off to rfc-reviewer for a second-pass score once OQ-2, OQ-5, and OQ-10 are closed.