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 — reasonare 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.mdwas written assuminghub_coreis not checked out and treats the core deduction logic (WSVC-S01) as unverified.hub-coreis 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):
- Fallback price is
596.33, not569.33. The PMP money path readsModels::Billing::V2WaConversationPrice::DEFAULT_FALLBACK_PRICE = 596.33(hub-core/app/core/domains/models/billing/v2_wa_conversation_price.rb:6). The569.33/WaDefaultFallbackPricethe PRD cites is a different constant inqontak-billing(Go,helper/consts/v2_conversation_price.go:6) that belongs to the legacy CBP path, not the PMP engine that actually moves money.- There is no
category=='service'"always free" special-case to remove. Under PMP,is_free_deduction?keys only onpricing.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 automatically — if a price row and an export-eligible log row exist.- The real blocker is
is_auto_deduct, not just the export filter.new_pricing_wa_deduction.rb:84forces@is_auto_deduct = falsefor everyUIconversation (service isUI). A billed service log row is therefore writtenis_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 filterorigin_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 readsbillableonly on the CBP path (new_pricing_wa_deduction.rb:35hardcodesbillable=truefor PMP), so the PMP path decides purely ontype=='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 astype=regular, billable=false, the PMP path would over-charge. This RFC therefore adds a defensivebillable != falseguard for service (Decision 2) and tracks the representation question (OQ-12).
Metadata
| Field | Value | Notes |
|---|---|---|
| Status | IDEA | YAML status: carries the remapped linter enum draft |
| DRI | pm-group.qontak@mekari.com | Carried from the initiative README / PRD. A named engineering DRI must be assigned before in-review (OQ-8). |
| Team | bifrost | Carried 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 |
| Reviewers | bifrost-backend, bifrost-tech-lead, billing-team, hub-chat-fe | Tech reviewers across affected squads (BE + FE) |
| Approver(s) | bifrost-tech-lead, infosec | Tech leaders + infosec approver |
| Submitted Date | 2026-07-06 | Date RFC opened for discussion |
| Last Updated | 2026-07-06 | Bump on every material edit |
| Target Release | 2026-Q3 | Must be live before Meta's 1 Oct 2026 effective date |
| Target Quarter | 2026-Q3 | Carried from source PRD |
| Delivery | not yet handed to delivery | No delivery/ artifacts yet |
| Related | PRD · impact-analysis · monetary-impact | Source PRD + grounding docs |
| Discussion | #bifrost-billing | Slack channel |
Type: full-stack Frontend sub-type: enhancement Backend sub-type: enhancement
Sections at a Glance
- Overview (incl. §1 Design References — FE half, and §1 PRD-to-Schema Derivation — BE half)
- Technical Design (Infrastructure Topology → Technical Decisions [ADR] → Repo Reading Guide → Architecture → Sequence → DDL/Data → APIs → Cross-Layer Contract Verification → Async/Integrity)
- High-Availability & Security
- Backwards Compatibility and Rollout Plan (incl. §4 Agent Execution Plan + Verification & Rollback Recipe)
- Concern, Questions, or Known Limitations
- Comment logs
- 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:
- No service price row.
hub-core'sWaPricing#get_price_from_cachereadsv2_wa_conversation_pricesfor(code, conversation_type='UI', conversation_category='service'); with no row it returns the hardcodedDEFAULT_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). - No service margin. The margin (fee) for a
servicemessage resolves from aConversationFeerow keyedconversation_type = 'service'(WaPricing#get_db_cost_tax_conversation_fee,wa_pricing.rb:115-133); with no row it falls back toDEFAULT_FALLBACK_UI_COST = 0.00(conversation_fee.rb:8) — i.e. zero margin. - The billed log is invisible.
new_pricing_wa_deduction.rb:84sets@is_auto_deduct = falsefor everyUIconversation. A billed service log row is writtenis_auto_deduct=false, origin_type='UI', and every read/export surface filtersis_auto_deduct = TRUE(and most also filterorigin_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=regularand a newmessage_iddeducts once atservice base_price + service margin, and free service (free_customer_service/free_entry_pointor 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.33fallback; it logsservice_price_missingand 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 itsmessage_id. - SC-4 (No double-charge): Redelivered/duplicate
message_idnever double-deducts (existing PMP idempotency onwa_uniq_conv_id_logs). - SC-5 (No regression): Orgs not on
bill_service_messagesobserve byte-identical current deduction, balance, usage-table, and export behavior; marketing/utility/authentication pricing, margins, andis_auto_deductsemantics are unchanged.
Out of Scope
- Not re-architecting the deduction engine. Rides the existing PMP path in
hub-core. - Not Meta Business Agent per-token AI billing (Meta's Aug 2026 change) — Qontak does not use it.
- Not changing marketing/utility/authentication pricing, margins, or
is_auto_deduct. Onlyserviceis added; the@is_auto_deduct=falsechange is scoped to billable service alone. - Not building new client-facing screens. Reuses the usage table, quota export, and modpanel MCC export. No Figma (see §1 Design References).
- Not repricing historical service. Service before 1 Oct 2026 (and any
free_customer_service) stays free; no retroactive deduction. - Not migrating the legacy CBP path (
qontak-billing/whatsapp_deduction.go, conversation-id keyed). All clients are on PMP; the money path ishub-core. - 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 inmoderator-be, no read inqontak-billing/hub-core) — deferred (OQ-3). Default per-categoryConversationFee(WSVC-S03) is the margin mechanism this RFC uses. - Not changing Mekari Pay / invoice / self-topup. Only what is deducted from balance changes.
Related Documents
- 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
pricingpayload 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, readspricing_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_servicevstype=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 ignorespricing.billable(new_pricing_wa_deduction.rb:35forcesbillable=truefor PMP), this RFC defensively also treatsbillable == falseas free for service (Decision 2). (OQ-12) - A-2:
v2_wa_conversation_pricesandconversation_feesare on the shared billing DB read byhub-core(Models::AbstractModelBillingon the:billingshard) and written bymoderator-be(Billings::ApplicationRecord→establish_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 viaConversationFee(per-category) = 0; if OFF, service falls back toPackageFee.ui_fee(default 5, not 0) which has no per-servicegranularity 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 forbill_service_messagesuntil migrated (see Decision 3). - A-4: The Meta service rate card (per country/
code) fitsv2_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
| Dependency | Owning team | Deliverable needed | Status | Blocking? |
|---|---|---|---|---|
hub-core PMP engine (new_pricing_wa_deduction.rb) | bifrost | Flag guard + is_auto_deduct fix + fail-safe on missing price | needs building | YES — the money path (SC-1/SC-2/SC-3) |
| Meta webhook semantics on 1 Oct 2026 | Meta (external) | Service delivered as pricing.type=regular | external | YES — the trigger |
| Meta service rate card | Finance / Product | Per-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_excluded | bifrost + managing repo | hub-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 provisioning | no (admin task); Detail 2.K |
qontak-billing MCC export query widen | bifrost / billing | Widen FetchWaConversationLogsBy* filter | needs building | YES for modpanel export (WSVC-S08) |
report-worker MCC export query widen | bifrost / billing | Widen FetchMCCLogsExport filter + regenerate sqlc | needs building | YES for client export (WSVC-S07) |
moderator-be margin list service entry | bifrost / billing | Surface + persist a service ConversationFee margin | needs building | YES for margin config (WSVC-S03) |
hub-chat friendly service label | bifrost / hub-chat-fe | Category label map | needs building | no (cosmetic; rows render without it once BE fix ships) |
Design References (frontend half)
| PRD-named surface | Figma / design link | Frame name | Design system version | Design QA contact | Notes |
|---|---|---|---|---|---|
subscriptions/usages usage table | n/a — no net-new screens | n/a | @mekari/pixel (in-repo; version not pinned in this RFC) | n/a | PRD 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 drawer | n/a — no net-new screens | n/a | @mekari/pixel | n/a | No 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 — reasonpath, notdesign pending.
PRD-to-Schema Derivation (backend half)
| PRD-described entity / attribute / rule | Persisted as (table.column) | Exposed via | Enforced where | Source |
|---|---|---|---|---|
Service is a billable PMP category when type=regular | wa_conversation_logs row, conversation_category='service', origin_type='UI', pricing_type='regular', credit/total_price | usage table + exports | hub-core NewPricingWaDeduction#call (is_free_deduction? already keys on pricing.type) | PRD §5 CHG-001; WSVC-S01 |
| A billed service log must be export-eligible | wa_conversation_logs.is_auto_deduct=TRUE for billable service | read/export queries filter it | hub-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-list | Services::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 country | v2_wa_conversation_prices(code, conversation_type='UI', conversation_category='service', cost numeric(6,2)) | read by WaPricing#get_price_from_cache | seed job / migration; read hub-core | PRD §5, §Constraints; WSVC-S02 |
| Missing price must not charge fallback | absence of a service row | log service_price_missing, skip | hub-core fail-safe branch (new) | PRD §7 behavior 1/3; WSVC-S01/ERR-1, WSVC-S02/ERR-1 |
| Service margin (fee) per package | conversation_fees(organization_package_id, conversation_type='service', cost, tax) | moderator-be margin list; read by WaPricing#get_db_cost_tax_conversation_fee | seed/backfill; moderator-be CRUD → Chat Panel push | PRD §6, §Constraints; WSVC-S03/S05 |
| Per-package custom service margin | custom_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 service | wa_conversation_logs read via MccLog (origin_type IN ('BI','UI','RC') AND is_auto_deduct=true) | GET /api/core/v1/reports/billing/mcc_logs | hub-core Repositories::Billings::Gets::MccLog | PRD §5 CHG-003; WSVC-S06 |
| Client quota export shows service | wa_conversation_logs via FetchMCCLogsExport (widen origin_type='BI') | report-worker export file | report-worker ExportMCCLogs | PRD §5 CHG-002; WSVC-S07 |
| Modpanel MCC export shows service | wa_conversation_logs via FetchWaConversationLogsBy* (widen origin_type='BI') | qontak-billing /iag/v1/reports/mcc-logs | qontak-billing ReportUsecase.MCCLogs | PRD §5 CHG-002; WSVC-S08 |
| Backfill default service margins on release | conversation_fees bulk seed | one-off idempotent job | precedent report-worker/worker_seed_blind_index.go or hub-core rake | PRD §6, WSVC-S05 |
Every §2.3/§2.4 row traces back to a row here. The
is_auto_deductrow and thecustom_margindeferral are the two places this derivation corrects the PRD.
Detail 1.A — PRD Traceability (cross-layer)
Forward (PRD AC → RFC):
| PRD composite AC id | FE section / component | BE section / endpoint |
|---|---|---|
WSVC-S01/AC-1..3, WSVC-S01/ERR-1 | n/a | §2.2 deduction seq · hub-core NewPricingWaDeduction (Decision 1) |
WSVC-S01-NEG/NEG-1..2 | n/a | §3.A.1 Branch & Skip · is_free_deduction? + flag guard |
WSVC-S02/AC-1..3, WSVC-S02/ERR-1 | n/a | §2.3 Data (price seed) · WaPricing#get_price_from_cache |
WSVC-S03/AC-1..3, WSVC-S03/ERR-1 | modpanel margin list (server-rendered) | §2.4 · moderator-be get_margin_list + ConversationFee |
WSVC-S03-NEG/NEG-1 | n/a | §3 Security (untouched categories) · Decision 3 |
WSVC-S04/AC-1..3, WSVC-S04/ERR-1 | n/a | deferred (OQ-3) — custom_margin_by_packages dead |
WSVC-S05/AC-1..3, WSVC-S05/ERR-1 | n/a | §2.F Async · backfill job |
WSVC-S06/AC-1..3, WSVC-S06/ERR-1 | TableComponentWhatsappBalance.vue (label) | §2.4 · GET .../reports/billing/mcc_logs (MccLog) |
WSVC-S07/AC-1..3, WSVC-S07/ERR-1 | export drawer (no change) | §2.4 · report-worker FetchMCCLogsExport widen |
WSVC-S08/AC-1..3, WSVC-S08/ERR-1 | modpanel download (no change) | §2.4 · qontak-billing FetchWaConversationLogsBy* widen |
Reverse (RFC → PRD AC):
| New artifact | PRD AC it serves (or net-new) |
|---|---|
Flag guard on service billing in hub-core | WSVC-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 price | WSVC-S01/ERR-1, WSVC-S02/ERR-1 |
service price seed in v2_wa_conversation_prices | WSVC-S02/AC-1..2 |
service ConversationFee seed + margin-list entry | WSVC-S03/AC-1..3, WSVC-S05/AC-1 |
qontak-billing + report-worker filter widen | WSVC-S07/*, WSVC-S08/* |
hub-chat service label map | WSVC-S06/AC-1,AC-3 |
UI / Consumer Surface Coverage
| PRD-named surface | Consumer | Required reads (BE) | Required writes (BE) | FE component | Status surface |
|---|---|---|---|---|---|
subscriptions/usages | web (client) | GET /api/core/v1/reports/billing/mcc_logs (hub-core MccLog) | n/a — read-only | features/subscriptions/usages/TableComponentWhatsappBalance.vue | conversation_category cell |
reports/export/quota | web (client) | POST {IAG}/report/v1/billings/logs/export → report-worker FetchMCCLogsExport | n/a — export file | features/report/export/views/components/ExportQuotaUsageDrawer.vue (no change) | download link (existing) |
modpanel download-muv-mcc | support tool (internal) | qontak-billing /iag/v1/reports/mcc-logs (via moderator-be proxy) | n/a — export file | modpanel (server-rendered, no change) | download (existing) |
modpanel get_margin_list | support 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 role | Authorization mechanism | Endpoints permitted (BE) | UI surface visibility (FE) | Cross-tenant? | Audit trail |
|---|---|---|---|---|---|
| Qontak Client Admin | existing oauth2 (own org) + report_omnichannel_view for export | mcc_logs read (own org), quota export (own org) | usage table + export drawer | no | request logs |
| Modpanel Admin | modpanel auth (oauth2 :modpanel) | get_margin_list, export_margin, download-muv-mcc (any org) | modpanel margin + MCC download | yes | modpanel logs; flag/margin author |
| Billing/Finance Ops | console / migration | seed price/margin; run backfill; enable flag | n/a | yes | migration + job logs |
| System (webhook/worker) | internal | none actor-facing | n/a | n/a | hold-free deduction: WaConversationLog rows + events |
PRD Section Coverage
| PRD § | Title | Where covered (RFC) or n/a — reason |
|---|---|---|
| Header block | — | §1 Metadata |
| 1 | One-liner + Problem | §1 Overview |
| 2 | Target Users + Persona | §1.A Role Coverage |
| 3 | Non-Goals | §1 Out of Scope |
| Scope Changes | — | §2.I Scope Boundaries; frontmatter type: full-stack |
| 4 / 4.1 | Constraints / Data Lifecycle | §2 Technical Decisions; §2.3 Data; §3 |
| 5 | Feature Changes (CHG-001/002/003) | §2.2 sequences; §2.4 APIs; Decisions 1–4 |
| 6 | New Features (modpanel margin fields) | §2.4 (margin list); custom_margin deferred (OQ-3) |
| 7 | API & Webhook Behavior | §2.4 APIs; §2.2 sequences; §3.A failure modes |
| 8 / 8.1 / 8.2 | System Flow + User Stories | §2.2 sequences; §1.C Per-Story Change Map |
| 9 / 9.1 | Rollout / Migration window | §4 Rollout Strategy; §4.A Compat Matrix |
| 10 / 10.1 | Observability | §3 Monitoring & Alerting |
| 11 | Success Metrics | §1 Success Criteria; §3 |
| 12 | Launch Plan & Stage Gates | §4 Rollout Strategy |
| 13 | Dependencies | §1 Dependencies |
| 14 | Key Decisions + Alternatives | §2 Technical Decisions (ADRs); §1.B |
| 15 | Open Questions | §5 |
| Changelog | — | §6 Comment logs |
Detail 1.B — Decisions Closed (cross-layer)
| # | Decision | Chosen option | Alternatives rejected | Why rejected | Layer | §2 block |
|---|---|---|---|---|---|---|
| 1 | Make billed service export-eligible | is_auto_deduct=TRUE for billable service + gate on flag | Widen every read/export to include is_auto_deduct=false UI | Would pull unbilled/free UI rows into money reports; changes is_auto_deduct semantics globally | BE | Decision 1 |
| 2 | Billing gate | Two 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/billable | Single flag; global calendar cutover | Single flag can't express "default ON for all at release + per-org exception"; calendar has no pilot/rollback | BE | Decision 2 |
| 3 | Margin source | Per-category ConversationFee(conversation_type='service') | PackageFee.ui_fee; custom_margin_by_packages | ui_fee has no per-service granularity; custom_margin_by_packages is dead end-to-end | BE/Data | Decision 3 |
| 4 | Missing-price behavior | Fail-safe: skip + alert, never charge 596.33 | Charge fallback | Fallback is a legacy safety net; charging it mis-bills unconfigured countries | BE | Decision 4 |
| 5 | Export filter widen location | Widen the two query owners (qontak-billing, report-worker), scoped to service | Rewrite modpanel; broaden to all UI | Modpanel only forwards; broadening pulls in referral_conversion (RC/UI) | BE | Decision 5 |
| 6 | Price seed store | Existing v2_wa_conversation_prices (shared billing DB) | New table; margin column on price table | Split source of truth; hub-core already reads this table | Data | Decision 3 (consequences) |
| 7 | Per-package override (WSVC-S04) | Deferred | Wire custom_margin_by_packages now | No schema in moderator-be, no read anywhere; out of critical path | BE | §5 OQ-3 |
Detail 1.C — Per-Story Change Map
| Story id | Title | Layer scope | FE changes | BE changes | Composite AC ids | Acceptance criteria (verifiable) | RFC anchors |
|---|---|---|---|---|---|---|---|
| WSVC-S01 | Service follows Meta + is deducted | Runtime/behavior (BE) | n/a — BE-only | flag bill_service_messages; service branch gated on type=='regular' AND billable != false; keeps is_auto_deduct=TRUE; reuse PMP dedup | WSVC-S01/AC-1..3, WSVC-S01/ERR-1 | rspec: 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-NEG | Free service never billed | Config/behavior (BE) | n/a | flag guard + is_free_deduction? | WSVC-S01-NEG/NEG-1..2 | rspec: pricing.type != regular→no deduction; pre-1-Oct reprocess→no charge | §3.A.1 · §4.D chunk 2 |
| WSVC-S02 | Seed service base price | Data | n/a | seed v2_wa_conversation_prices rows | WSVC-S02/AC-1..3, WSVC-S02/ERR-1 | migration/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-S03 | Default service margin in Margin List | BE + modpanel UI | modpanel server-rendered row (no hub-chat) | moderator-be margin list shows service ConversationFee; push to Chat Panel | WSVC-S03/AC-1..3, WSVC-S03/ERR-1 | rspec (moderator): service margin resolves on GetMarginListDb and Chat Panel paths; export_margin includes it | §2.4 · §4.D chunk 5 |
| WSVC-S03-NEG | Other margins untouched | BE | n/a | additive only | WSVC-S03-NEG/NEG-1 | rspec: ui_fee/bi_fee + other conversation_fees unchanged after adding service | §3 Security · §4.D chunk 5 |
| WSVC-S04 | Custom per-package service margin | deferred | n/a | custom_margin_by_packages dead — not wired | WSVC-S04/* | deferred — OQ-3 (no schema in moderator-be; no read path) | §5 OQ-3 |
| WSVC-S05 | Backfill default service margins | Runtime/behavior (BE) | n/a | idempotent batched conversation_fees seed | WSVC-S05/AC-1..3, WSVC-S05/ERR-1 | job: 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-S06 | Show service in usage table | FE + BE | TableComponentWhatsappBalance.vue friendly service label | none beyond S01 (MccLog already allows UI+is_auto_deduct) | WSVC-S06/AC-1..3, WSVC-S06/ERR-1 | vitest: service renders "Service" label + deducted_credit; message_id shown; empty state unchanged | §2.A · §2.G · §4.D chunk 7 |
| WSVC-S07 | Show service in client quota export | BE + FE consumes | export drawer unchanged | report-worker FetchMCCLogsExport filter widen + sqlc regen | WSVC-S07/AC-1..3, WSVC-S07/ERR-1 | go test: widened query returns service rows (conversation_category='service', per-message); pre-Oct range → none | §2.4 · §4.D chunk 3 |
| WSVC-S08 | Show service in modpanel MCC export | BE | n/a | qontak-billing FetchWaConversationLogsBy* filter widen + sqlc regen | WSVC-S08/AC-1..3, WSVC-S08/ERR-1 | go 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 + BEstory (and its BE half is already covered by S01'sis_auto_deductfix — 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 forceis_auto_deduct=false; writeis_auto_deduct=truelike any other billable category.- Pros: single, localized change; the usage-table read (
MccLog) already allowsUI, so it starts showing service immediately; keepsis_auto_deductsemantically "this row moved money". - Cons: must be scoped precisely so free/CBP UI (referral, free service) keep
is_auto_deduct=false.
- Pros: single, localized change; the usage-table read (
- Option B — Fix every reader: leave
is_auto_deduct=false; teach all four read/export queries to includeis_auto_deduct=falserows whereconversation_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_feeetc. - 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.
- Pros: one flag; matches
- 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 separatebill_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.
- Pros:
- 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
preferencesrecords on the billing DB viaServices::Billing::FeatureFlag, not asServices::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.
bill_service_messages— the per-organization pilot toggle. Apreferencesrecord withstate=true, is_global=false;preference_unique_idsrows = allowed orgs. Read in code asflag.enabled?(:bill_service_messages, unique_id: org_id). Used to test on internal + pilot CIDs without touching anyone else.bill_service_messages_global— the global default toggle (the Meta-release master switch).preferencesrecord withstate=true, is_global=true. At Meta release, the managing repo flipsstate=true, is_global=true→ hub-core reads it asenabled?(:bill_service_messages_global)= true for every org. A per-org kill switch is modelled as a third recordbill_service_messages_excluded(state=true, is_global=false) — the managing repo adds the org to itspreference_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
ConversationFeerowconversation_type='service'per package (the modern, per-category margin path).- Pros: correct per-category granularity;
moderator-be get_margin_list_dbalready readsConversationFee; matches how utility/marketing/auth margins are modeled. - Cons: only effective when
:deduction_conversation_feeis ON (A-3).
- Pros: correct per-category granularity;
- Option B — Reuse
PackageFee.ui_fee(legacy path).- Pros: works when
:deduction_conversation_feeis OFF. - Cons:
ui_feeis shared by all UI conversations (referral etc.); no service-specific margin.
- Pros: works when
- Option C — Wire
custom_margin_by_packages(PRD WSVC-S04).- Pros: per-package override.
- Cons: dead — no table migration in
moderator-be, no read inqontak-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
.sqledits + sqlc regeneration in two repos.
- Pros: precise; excludes non-service UI (
- 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 asqontak-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-preferencesGo service;preferences+preference_unique_idstables 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).
- 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
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
| Layer | Path | Why the agent reads it | What pattern it teaches |
|---|---|---|---|
| BE (hub-core) | app/core/domains/repositories/v2/billings/new_pricing_wa_deduction.rb | the PMP engine; the branch to change | is_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.rb | where price + margin resolve | total_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.rb | the price model + fallback const | DEFAULT_FALLBACK_PRICE = 596.33 L6; find_by(code, conversation_type, conversation_category) |
| BE (hub-core) | app/core/domains/models/billing/conversation_fee.rb | margin model + UI fallback | DEFAULT_FALLBACK_UI_COST = 0.00; per-organization_package_id+conversation_type |
| BE (hub-core) | app/core/domains/repositories/billings/gets/mcc_log.rb | usage-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.rb | worker → engine | queue :billing_wa_deduction; L21 NewPricingWaDeduction.new(params:).call |
| BE (hub-core) | app/core/domains/services/preference.rb | flag pattern | enabled?(feature, organization_id:) L61-71 wrapping Flipper |
| BE (qontak-billing) | db/queries/wa_conversation_logs.sql | modpanel/IAG export query to widen | FetchWaConversationLogsByOrganizationID L75 / ByChannelID L104; WHERE origin_type='BI' AND is_auto_deduct=TRUE |
| BE (qontak-billing) | internal/app/usecase/reports/mcc_logs.go | export usecase | MCCLogs L46; mapOrgRow/mapChannelRow L161/183 |
| BE (qontak-billing) | db/migrations/20240401040054_add_existing_schema.up.sql | price catalog DDL | v2_wa_conversation_prices L554-563 (cost numeric(6,2), no margin col) |
| BE (report-worker) | db/billingdb/queries/wa_conversation_logs.sql | client-export query to widen | FetchMCCLogsExport 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.go | backfill precedent | gocraft/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.rb | margin list, two source paths | flag 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.rb | how margins are built | build_item L63-70 (conversation_fee from conversation_fees) |
| BE (moderator-be) | app/domains/core/repositories/app_integrations/chat_panel/update_margin.rb | Chat Panel push | pigeon_put L14 /api/core/v1/billings/package_fee; build_params L29-44 |
| BE (hub-service) | app/services/api/core/v1/reports/resources/billing.rb | usage-table read endpoint | get '/mcc_logs' L156; organization_id=me.organization_id L157 → Interactors::Billings::UserGetsMccLog (hub_core) |
| FE (hub-chat) | features/subscriptions/usages/TableComponentWhatsappBalance.vue | usage 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)
| Contract | Status | Justification | Owner |
|---|---|---|---|
POST Meta status webhook → WaDeductionWorker → NewPricingWaDeduction | reuse | unchanged ingress; service branch is downstream | bifrost |
NewPricingWaDeduction#call | extend | add flag guard + is_auto_deduct fix + fail-safe (Decisions 1,2,4) | bifrost |
WaPricing (price + fee) | extend | fail-safe on missing price; service fee via ConversationFee | bifrost |
v2_wa_conversation_prices | reuse (seed data) | add service rows; no schema change | bifrost/billing |
conversation_fees | reuse (seed data) | add service rows; no schema change | bifrost/billing |
GET /api/core/v1/reports/billing/mcc_logs (MccLog) | reuse | already allows UI; no query change needed | bifrost |
qontak-billing FetchWaConversationLogsBy* | extend | widen filter to include service (Decision 5) | billing |
report-worker FetchMCCLogsExport | extend | widen filter to include service (Decision 5) | billing |
moderator-be get_margin_list / update_margin | extend | surface + push service ConversationFee | billing |
Services::Billing::FeatureFlag + preferences records (3 flags) | new | billing-path gate (Decision 2 + Decision 6; Detail 2.K) | bifrost |
custom_margin_by_packages | new-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)
| Layer | Concern | Pattern in repo | Reference file | Deviation? |
|---|---|---|---|---|
| BE (hub-core) | Feature flag (general) | Services::Preference.new.enabled?(:flag, organization_id:) | services/preference.rb:61-71; siblings in new_pricing_wa_deduction.rb | Billing-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 / shard | Models::AbstractModelBilling on :billing | v2_wa_conversation_price.rb, conversation_fee.rb | none |
| BE (hub-core) | Result monad | Dry::Monads Success/Failure | new_pricing_wa_deduction.rb | none |
| BE (Go) | Query + codegen | sqlc .sql → .sql.go | report-worker/db/billingdb/queries/*.sql; qontak-billing/db/queries/*.sql | regen is manual (no CI step) |
| BE (Go) | Backfill job | gocraft/work batch+offset+NOT-EXISTS | report-worker/internal/chat/worker/worker_seed_blind_index.go | none |
| BE (Go) | Migration | golang-migrate .up.sql/.down.sql, BEGIN;/COMMIT; | qontak-billing/db/migrations/* | none |
| BE (moderator-be) | Margin config | ConversationFee/PackageFee + Chat Panel pigeon_put | update_margin.rb, get_margin_list_db.rb | none |
| FE (hub-chat) | Cell rendering | raw field render in MpTableCell | TableComponentWhatsappBalance.vue:269-274 | add a small label map (none exists today) |
| Cross | snake_case API → FE fields | mcc_logs returns conversation_category etc. verbatim | hub-core mcc_log.rb build_log ↔ TableComponentWhatsappBalance.vue | none |
Reading Order for the Agent
hub-core/.../new_pricing_wa_deduction.rb— the engine;is_free_deduction?,@is_auto_deductline 84,create_conversation_log.hub-core/.../wa_pricing.rb— price + fee resolution, the596.33and0.00fallbacks.hub-core/.../models/billing/{v2_wa_conversation_price,conversation_fee}.rb— price/margin models + constants.hub-core/.../repositories/billings/gets/mcc_log.rb— usage-table read (already allows UI).hub-core/.../services/preference.rb— the flag pattern to follow.report-worker/db/billingdb/queries/wa_conversation_logs.sql— client-export filter to widen.qontak-billing/db/queries/wa_conversation_logs.sql— modpanel-export filter to widen.report-worker/.../worker_seed_blind_index.go— backfill precedent.moderator-be/.../get_margin_list_db.rb+update_margin.rb— margin config surface.hub-chat/features/subscriptions/usages/TableComponentWhatsappBalance.vue— the label change.
Source Verification (anti-hallucination — required)
| Layer | Anchor / contract | Verified by | Evidence |
|---|---|---|---|
| BE | new_pricing_wa_deduction.rb engine | read | reads 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 |
| BE | pricing.billable handling | read | L34-36 billable = pricing_model.eql?('CBP') ? (@pricing.billable || false) : true; return success unless billable — PMP hardcodes billable=true, so PMP billability is decided by type=='regular' alone (defensive guard added in Decision 2) |
| External | Meta pricing doc (technical implementation) | WebFetch 2026-07-06 | confirms 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) |
| BE | PMP dedup unique index | read | migration 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 |
| BE | fallback price 596.33 | read | models/billing/v2_wa_conversation_price.rb:6 DEFAULT_FALLBACK_PRICE = 596.33; used wa_pricing.rb:153 |
| BE | service price read | read | wa_pricing.rb:149-157 V2WaConversationPrice.find_by(code:, conversation_type:, conversation_category:); cache key WaPrice::{code}::{type}::{category} |
| BE | service margin (fee) | read | wa_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 |
| BE | usage-table read allows UI | read | mcc_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 |
| BE | flag pattern | read | services/preference.rb:61-71 Flipper.enabled?; no existing bill_service flag (grep negative) |
| BE | webhook entry | read | interactors/whatsapp/webhooks/message_status_notification.rb:30 (delivered/read → WaDeductionWorker.perform_async); worker wa_deduction_worker.rb:21 |
| BE | qontak-billing export filter | read | db/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 |
| BE | qontak-billing price DDL | read | db/migrations/20240401040054_add_existing_schema.up.sql:554-563 cost numeric(6,2), cols country,code,conversation_type,conversation_category; no margin column |
| BE | qontak-billing fallback (CBP, not money path) | read | helper/consts/v2_conversation_price.go:6 WaDefaultFallbackPrice = 569.33; CBP path whatsapp_deduction.go conversation-id keyed |
| BE | report-worker export filter | read | db/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) |
| BE | report-worker export path | read | IAG /report/v1/billings/logs/export → BillingLogsExport worker_billing_logs.go:13 → IsMCC() → ExportMCCLogs worker_export_mcc_logs.go:22 → FetchMCCLogsExport |
| BE | report-worker billing_logs is_free (NOT the WA path) | read | db/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 |
| BE | backfill precedent | read | worker_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 |
| BE | moderator-be margin list | read | accounts_controller.rb:122 get_margin_list; get_margin_list.rb:23-27 flag get_margin_list_from_db → GetMarginListDb; get_margin_list_db.rb:63-70 build_item; conversation_fees db/billing_schema.rb:523; defaults create_account.rb:155-156 `ui_fee |
| BE | moderator-be Chat Panel push | read | update_margin.rb:14 pigeon_put /api/core/v1/billings/package_fee; build_params ui_fee,bi_fee,conversation_fee,voice_margin |
| BE | moderator-be custom_margin dead | read/grep | custom_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 |
| BE | modpanel MCC forwards to qontak-billing | read | moderator-be .../billing_service/reports/mcc_logs.rb:12-17 pigeon_get service:'billing_service', path:'/internal/qontak/billing/v1/reports/mcc-logs' (= qontak-billing) |
| BE | hub-service usage endpoint | read | reports/resources/billing.rb:156 get '/mcc_logs'; organization_id=me.organization_id L157; served in-process via hub_core UserGetsMccLog → MccLog; not a path param |
| FE | usage table component | read | TableComponentWhatsappBalance.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 |
| FE | export drawer | read | ExportQuotaUsageDrawer.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 |
| FE | framework + commands | read | package.json nuxt ~4.2.2, vue ^3.5.29, vitest, pnpm@10; lint=eslint, coverage=vitest, build=nuxt build |
| Cross | shared billing DB assumption | inferred (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 / component | Implementing file | Reuse vs new | Design tokens used | Backing API endpoint(s) | Deviation from design |
|---|---|---|---|---|---|
n/a — no Figma (usage table Category cell) | features/subscriptions/usages/TableComponentWhatsappBalance.vue | extended | existing MpText/MpTableCell (Pixel) | GET /api/core/v1/reports/billing/mcc_logs | none — 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
.sqlquery files + regenerates sqlc. Migration dialects confirmed:qontak-billinguses golang-migrate.up.sql/.down.sql;hub-coreuses Railsdatabase/billing/db/migrate/YYYYMMDDHHMMSS_slug.rbon the:billingshard.
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_logsrows are a permanent financial audit trail (no status enum;is_auto_deductis 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)
| Endpoint | Method | AuthN/AuthZ | Change | Status |
|---|---|---|---|---|
/api/core/v1/reports/billing/mcc_logs (usage table; hub-service→hub-core MccLog) | GET | oauth2, 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-worker | POST | oauth2 + report_omnichannel_view | export file includes service after FetchMCCLogsExport widen | extended (query) |
/iag/v1/reports/mcc-logs (qontak-billing; modpanel forwards /internal/qontak/billing/v1/reports/mcc-logs) | GET | modpanel auth (upstream) | export includes service after FetchWaConversationLogsBy* widen | extended (query) |
moderator-be get_margin_list / export_margin | GET | modpanel auth | shows/export service ConversationFee margin | extended |
moderator-be update_margin → Chat Panel /api/core/v1/billings/package_fee | PUT | modpanel auth | pushes service margin (both get_margin_list_from_db paths) | extended |
Inbound webhooks (other services call us)
| Endpoint | Method | Source | Change | Status |
|---|---|---|---|---|
Meta WhatsApp message-status webhook → hub-core MessageStatusNotification (delivered/read) → WaDeductionWorker | POST | Meta Cloud API | unchanged ingress; the service-billing branch is downstream in NewPricingWaDeduction; idempotent on message_id via wa_uniq_conv_id_logs unique index | reuse |
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
servicerow.
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_categoryis alreadystring(features/subscriptions/usages/types.ts). - Data source:
GET /api/core/v1/${organization_id}/reports/billing/mcc_logs(existing;fetchWhatsAppUsageLogL515-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/MpTextsemantics.
Detail 2.B — Data-Fetching Strategy
- Library: Nuxt 4
$customFetch(existing). No new fetch layer. - Cache key / TTL: existing table pagination params (
paramsinfetchWhatsAppUsageLog); no new caching. - Refetch triggers: existing (filter/page change). No optimistic updates (read-only report).
Detail 2.C — UI State Matrix
| Surface | Loading | Empty | Error | Partial | Success |
|---|---|---|---|---|---|
| Usage table (service rows) | existing skeleton | existing empty state (no fabricated service row) | existing error state | mixed categories render together; service labeled "Service" | service rows show label + deducted_credit + message_id |
Detail 2.D — Data Integrity Matrix
| Write path | Transaction scope | Partial failure | Idempotency key + TTL | Consistency | Duplicate-event handling | Stale-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 deduction | reads via replica for pricing; write on primary |
| Price/margin seed | single INSERT (idempotent NOT EXISTS) | per-row skip | (code,UI,service) / (organization_package_id,service) natural keys | strong | NOT EXISTS guard | n/a |
Detail 2.E — Concurrency Collision Map
| Resource | Writers | Collision | Resolution |
|---|---|---|---|
wa_uniq_conv_id_logs (same message_id) | duplicate/out-of-order webhooks | double deduction | unique index on conversation_id → RecordNotUnique rescue (existing) |
| package balance | concurrent deductions vs monthly reset | over/under deduct | existing 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 edit | duplicate row | NOT EXISTS guard + (pkg, conversation_type) natural key |
Detail 2.F — Async Job / Event Consumer Spec
| Job | Trigger | Input | Retry | Idempotency key | Notes |
|---|---|---|---|---|---|
WaDeductionWorker → NewPricingWaDeduction | Meta delivered/read webhook | (params, webhook) | existing throttle + self-requeue on race/timeout | message_id via wa_uniq_conv_id_logs | queue :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 / service | Inbound trigger | Outbound effect | Failure handler | PRD anchor |
|---|---|---|---|---|---|
| 1. Seed price + margin | bifrost/billing (qontak-billing data + moderator-be) | Stage 0 config | rows in v2_wa_conversation_prices, conversation_fees | backfill re-run (idempotent) | WSVC-S02/S03/S05 |
| 2. Enable toggle | bifrost (console) | Stage 1+ (pilot) / Meta release (global) | pilot: add org to bill_service_messages allow-list; GA: flip bill_service_messages_global | remove from allow-list / add to global deny-list / disable global | WSVC-S01 |
| 3. Deduct service | bifrost (hub-core) | Meta regular webhook | wa_conversation_logs row (is_auto_deduct=TRUE) | fail-safe on missing price (Decision 4) | WSVC-S01 |
| 4. Show in usage table | bifrost (hub-core MccLog + hub-chat) | client opens usages | service rows + label | existing empty/error state | WSVC-S06 |
| 5. Client export | bifrost/billing (report-worker) | client export | file includes service | reconciliation (§10) | WSVC-S07 |
| 6. Modpanel export | bifrost/billing (qontak-billing) | modpanel download | file includes service | reconciliation (§10) | WSVC-S08 |
Detail 2.F.2 — State Surface Contract
| Entity | State field / event | Default | Updated by | Read via | Stale window |
|---|---|---|---|---|---|
| Service deduction | wa_conversation_logs.is_auto_deduct (=TRUE when billed) | today: false for UI | NewPricingWaDeduction (Decision 1) | MccLog, exports | replica lag (seconds) |
| Service margin | conversation_fees(service).cost | UI fallback 0.00 (fee) | moderator-be / backfill | WaPricing, get_margin_list | Redis fee cache TTL (existing) |
| Service price | v2_wa_conversation_prices(code,UI,service).cost | fallback 596.33 (guarded) | seed | WaPricing | Redis WaPrice TTL 30d |
Detail 2.G — Cross-Layer Contract Verification
| Endpoint | BE response schema | FE expected schema | Match? | Gaps |
|---|---|---|---|---|
GET /api/core/v1/reports/billing/mcc_logs | MccLog build_log: conversation_category, conversation_type, message_id, deducted_credit (=credit.to_s), country, recipient, customer_name | TableComponentWhatsappBalance.vue: conversation_category, conversation_type, message_id, deducted_credit | yes | none — FE renders conversation_category verbatim; RFC adds a display-only label map (no contract change) |
POST {IAG}/report/v1/billings/logs/export | file rows incl. conversation_category, message_id (from FetchMCCLogsExport) | export drawer downloads file (no field binding) | yes | none — service rows appear once query widened |
/iag/v1/reports/mcc-logs | rows incl. origin_type AS conversation_type, conversation_category | modpanel renders download | yes | none — 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-bemargin-list +update_marginservicesupport. - BE data/seed:
v2_wa_conversation_prices(servicerows);conversation_fees(servicerows) + 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_freepath — not thewa_balanceroute). - Shared modules:
NewPricingWaDeductionis 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
| Model | Table | DB shard | Base class |
|---|---|---|---|
Models::Billing::Preference | preferences | :billing | Models::AbstractModelBilling |
Models::Billing::PreferenceUniqueId | preference_unique_ids | :billing | Models::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::FeatureFlagin hub-core only providesenabled?(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 thepreferencesorpreference_unique_idstables 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:
| Key | Pattern | Value type | Meaning |
|---|---|---|---|
| State | preference:{feature}:state | "true" / "false" | Preference.state — global on/off |
| Global | preference:{feature}:global | "true" / "false" | Preference.is_global — skip unique-id check |
| Unique-id | preference:{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 key | state | is_global | unique_ids (preference_unique_ids) | Semantics |
|---|---|---|---|---|
bill_service_messages | true | false | pilot org_ids | Per-org pilot allow-list: enabled?(:bill_service_messages, unique_id: org_id) returns true only if org is in the list |
bill_service_messages_global | true | true | — | Global default master switch: enabled?(:bill_service_messages_global) returns true for all once flipped |
bill_service_messages_excluded | true | false | org_ids to exclude | Per-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).
| Method | Signature | Notes |
|---|---|---|
enabled? | (feature, unique_id: nil) → Boolean | Redis-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
ConversationFeelookup (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
WHEREadds 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
| Event | Trigger | Properties |
|---|---|---|
service_message_deducted | billable service deducted | organization_id, message_id, country/code, base_price, margin, total, credited_to, enabled_via (pilot/global) |
service_message_skipped_free | service with free pricing.type/billable=false, or service billing not enabled | organization_id, message_id, pricing_type, reason (free_type/not_billable/not_enabled) |
service_price_missing | no service price row for a billable msg | organization_id, country, code, message_id |
service_deduction_dedup_hit | duplicate message_id | organization_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_deductedtotal 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 tome.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
| Role | Endpoint(s) | Methods | Tenant scope | UI visibility | Constraint | Audit |
|---|---|---|---|---|---|---|
| Client Admin | mcc_logs read; quota export | GET/POST | own org | usage table + export drawer | report_omnichannel_view for export | request logs |
| Modpanel Admin | get_margin_list, export_margin, update_margin, download-muv-mcc | GET/PUT | any org | modpanel | modpanel auth | modpanel logs |
| Billing/Finance Ops | seed price/margin, backfill, flag enable | — | any org | n/a | console | migration/job logs |
| System (webhook/worker) | deduction (internal) | — | per-org | n/a | flag-gated | WaConversationLog + events |
Detail 3.A — Failure Mode Catalog (merged)
| Surface | FE behavior on failure | BE response on failure | Code-shape consistency |
|---|---|---|---|
| Usage table read | existing error/empty state | MccLog returns Failure on missing org/package; empty page on no rows | yes |
| Deduction (missing price) | n/a (backend) | skip + service_price_missing alert; return Success (no charge) | yes |
| Deduction (duplicate) | n/a | RecordNotUnique → Success | yes |
| Export (filter not deployed) | file lacks service rows | detectable via export-vs-deduction reconciliation (§10) | yes |
Detail 3.A.1 — Branch & Skip Catalog
| Branch trigger | Where checked | Downstream effect | Audit | User-visible? |
|---|---|---|---|---|
pricing.type != regular (free service) | hub-core is_free_deduction? L199-205 | no deduction | service_message_skipped_free | no |
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 price | hub-core WaPricing (new, Decision 4) | no deduction + alert | service_price_missing | no (ops-visible) |
duplicate message_id | wa_uniq_conv_id_logs unique index | no second deduction | service_deduction_dedup_hit | no |
Detail 3.B — Error Response Catalog (BE)
| Endpoint | Error | HTTP | When | User-facing? |
|---|---|---|---|---|
mcc_logs read | Organization not found / no packages | 4xx (existing shape) | invalid org | no (guarded) |
| exports | existing export-failure shape | 5xx | worker/query error | no |
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=falsefor UI, exports/usage unchanged. - No schema migration — only data seed + two query edits (additive) + one FE label.
conversation_categoryis already a selected export column. - Deploy order: BE (seed + query widen +
hub-coreguard, 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 addspreference_unique_idsrows to enroll pilot orgs; hub-core readsenabled?(:bill_service_messages, unique_id: org_id).bill_service_messages_global— global default master switch (state=true, is_global=true). The managing repo flipsis_global=trueat Meta release → hub-core readsenabled?(: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 topreference_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
serviceprice (v2_wa_conversation_prices) for all activecodes; seed defaultserviceConversationFeemargins + run backfill (WSVC-S05); deploy the two widened export queries; deployhub-coreguard code; managing repo seeds all threepreferencesrecords withstate=false. Gate: config completeness = 100%, backfill re-run = 0 dup. - Stage 1 — Internal: managing repo adds ≤5 internal CIDs to
bill_service_messagespreference_unique_ids; sendregularservice; verify deduction, usage table, both exports, 0service_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_globaltois_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 tobill_service_messages_excludedunique-ids for a per-org kill switch after GA; or flipbill_service_messages_globaltostate=false/is_global=falseto revert the whole base to free. Data seeds are inert when service billing is not enabled.
Detail 4.A — Cross-Layer Rollout Compatibility Matrix
| Scenario | FE | BE | Works? | Mitigation |
|---|---|---|---|---|
| Pre-deploy | Old | Old | yes | baseline |
| Backend first (seed+query+guard, both toggles OFF) | Old | New | yes | no behavior change until a toggle enables an org |
| Frontend first (label) | New | Old | yes | label map is display-only; no service rows yet → no effect |
| Both deployed, service billing enabled (pilot or global) | New | New | yes | target state |
| Backend rollback (disable both toggles) | New | Old | yes | label map harmless with no service rows |
| Frontend rollback | Old | New | yes | service rows render with raw service text (no label) — acceptable |
Detail 4.B — Configuration Contract
| Layer | Env var / flag | Type | Default | Required | Provisioner | Secret? |
|---|---|---|---|---|---|---|
Billing DB (preferences) | bill_service_messages (read by Services::Billing::FeatureFlag) | per-org allow-list (state=true, is_global=false) | OFF | yes | managing repo (qontak-preferences) — not hub-core | no |
Billing DB (preferences) | bill_service_messages_global (read by Services::Billing::FeatureFlag) | global master switch (state=true, is_global=true) | OFF | yes (flip at Meta release) | managing repo | no |
Billing DB (preferences) | bill_service_messages_excluded (read by Services::Billing::FeatureFlag) | per-org kill switch after GA (state=true, is_global=false) | empty | as-needed | managing repo | no |
| BE (hub-core) | deduction_conversation_fee (existing) | bool | per-org | dependency (A-3) | existing | no |
| Data | v2_wa_conversation_prices service rows | seed | absent | yes (Stage 0) | migration/seed | no |
| Data | conversation_fees service rows | seed | absent → 0.00 fallback | yes (Stage 0) | seed + backfill | no |
Detail 4.C — Test Plan (commands sourced from repo)
| Layer | Command (source) | What it must prove |
|---|---|---|
| BE hub-core lint | bundle exec rubocop (.rubocop.yml; CI bitbucket-pipelines.yml) | lint passes |
| BE hub-core unit | bundle 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-billing | make 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 sqlc | sqlc generate (manual — no Makefile/CI step; §2.0) then commit .sql.go | generated query matches widened .sql |
| BE report-worker | make test (Makefile:47-52); make lint (staticcheck) | widened FetchMCCLogsExport returns service; pre-Oct range → none |
| BE report-worker sqlc | sqlc generate (manual; both sqlc.yaml+sqlc-billing.yaml) | regenerated .sql.go committed |
| BE moderator-be | bundle exec rspec (Makefile:83-90); bundle exec rubocop | service margin resolves on DB + Chat Panel paths; other margins unchanged |
| FE hub-chat | pnpm lint (package.json:13); pnpm coverage (package.json:18, vitest) | service renders "Service" label; other categories unregressed |
| FE hub-chat build | nuxt build (package.json:6) | build passes |
Detail 4.D — Agent Execution Plan
| Order | Layer | Chunk | Files to modify/create | Commands | Acceptance criteria |
|---|---|---|---|---|---|
| 1 | Data (BE) | Seed service base price | qontak-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) |
| 2 | BE | hub-core billing flag layer (read-only enabled?) + two-toggle gate + billable/type guard + is_auto_deduct + fail-safe | hub-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; rubocop | SC-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 |
| 3 | BE | Widen both export queries | qontak-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 |
| 4 | Data (BE) | Seed default service margin | conversation_fees seed (SQL or hub-core seed) | seed run; rspec on WaPricing fee | get_db_cost_tax_conversation_fee returns seeded margin (not 0.00) for a package with a row |
| 5 | BE | moderator-be margin list service | moderator-be get_margin_list_db.rb / update_margin.rb (+specs) | bundle exec rspec; rubocop | service margin shown (both paths) + pushed to Chat Panel; other margins unchanged (S03-NEG) |
| 6 | BE | Backfill default service margins | backfill job (report-worker gocraft precedent or hub-core rake — OQ-9) | job run twice | seeds eligible packages; re-run = 0 dup (NOT EXISTS); per-row error counted, batch continues |
| 7 | FE | Usage-table service label | hub-chat/.../TableComponentWhatsappBalance.vue (+vitest) | pnpm coverage; pnpm lint; nuxt build | service → "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-downdry-run. - moderator-be: 1)
bundle exec rubocop; 2)bundle exec rspecon changed specs. - hub-chat: 1)
pnpm lint; 2)pnpm coverage; 3)nuxt build.
- hub-core: 1)
- 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_messagesOFF for the org (instant revert to free); 2) if needed, revert the widened queries (+ regen) and the FE label; 3) confirmservice_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
| # | Type | Question / limitation | Owner | Status |
|---|---|---|---|---|
| OQ-1 | Grounding correction | Fallback 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 PM | open |
| OQ-2 | Resolved (2026-07-22) | service margin a percentage or fixed amount, and the value(s)?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 + Bifrost | closed |
| OQ-3 | Limitation | custom_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 Eng | open |
| OQ-4 | Risk | Confirm 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 Eng | open |
| OQ-5 | Risk | Does 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 Eng | open |
| OQ-6 | Assumption (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. | PM | low |
| OQ-12 | Risk | The 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 Eng | open |
| OQ-7 | Nice-to-have | Add pricing_type to the export SELECT (both query owners) for reconciliation? Currently not selected. | Bifrost Eng | open |
| OQ-8 | Governance | Assign a named engineering DRI (RFC dri currently the PM group). | Bifrost | open |
| OQ-9 | Open Question | Which 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 Eng | open |
| OQ-10 | Risk | Decision 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 Eng | open |
| OQ-11 | Precondition (A-3) — elevated | Confirm :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 Eng | open |
6. Comment logs
| Date | Comment(s) From | Action Item(s) |
|---|---|---|
| 2026-07-06 | RFC author | Drafted 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-06 | RFC 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-06 | RFC 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-06 | RFC 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-22 | RFC 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_feesseed value is0.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_feeis ON for every enabled org, elseui_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_serviceortype=regular, billable=false, and that the defensivebillable != falseguard (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_messagesper-org pilot allow-list +bill_service_messages_globalglobal 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-reviewerfor a second-pass score once OQ-2, OQ-5, and OQ-10 are closed.