RFC: One CID Multiple WABA — Billing V3 Shared Balance Pool (Phase 1)
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 intentionally not deleted.It is agent-execution-ready: §1 PRD-to-Schema Derivation + Design References, §2 Repo Reading Guide, mermaid diagrams, §2.G Cross-Layer Contract Verification, and §4 Agent Execution Plan + Verification & Rollback Recipe are the gates for §7 Ready for agent execution.
Delivery & project management live elsewhere. This RFC is the technical artifact only. Staffing, effort, timeline, and rollout schedule live in the initiative's
delivery/folder. Delivery row readsnot yet handed to delivery.The YAML frontmatter is the machine-readable index; the metadata table is the human-readable governance record. Both agree on shared fields.
Metadata
| Field | Value | Notes |
|---|---|---|
| Status | IDEA | YAML status: carries the remapped linter enum draft |
| DRI | addo.hernando@mekari.com | Single accountable owner (initiative DRI). Per-task staffing lives in delivery/. |
| Team | bifrost | Advisory squad slug from the PRD / initiative README |
| Author(s) | Grehasta Mahardika (BE), Syafrizal M. (FE) | Primary authors / implementors |
| Reviewers | Bifrost Eng Lead; qontak-billing service owner | Cross-squad tech reviewers (the deduction engine is owned by the billing service) |
| Approver(s) | Bifrost Tech Lead; Infosec | Tech leaders + infosec approver |
| Submitted Date | 2026-07-01 | Date RFC opened for discussion |
| Last Updated | 2026-07-01 | Bump on every material edit |
| Target Release | 2026-Q3 | Quarter |
| Target Quarter | 2026-Q3 | Advisory, from PRD / initiative README |
| Delivery | not yet handed to delivery | Will point at ../delivery/timeline.md once handed off |
| Related | PRD — One CID Multiple WABA Phase 1 | Source PRD |
| Discussion | #bifrost-billing (TBD thread) | Slack |
Type: full-stack Frontend sub-type: enhancement Backend sub-type: enhancement
Sections at a Glance
- Overview (Design References — FE half; PRD-to-Schema Derivation — BE half; traceability; per-story change map)
- Technical Design (Infrastructure Topology → Technical Decisions [ADR] → Repo Reading Guide → Architecture → Sequence diagrams → DDL → APIs → integrity/concurrency/async → cross-layer verification)
- High-Availability & Security
- Backwards Compatibility and Rollout Plan (incl. Agent Execution Plan + Verification & Rollback Recipe)
- Concern, Questions, or Known Limitations
- Comment logs
- Ready for agent execution
1. Overview
This RFC specifies the engineering work to let a Billing V3 company (billing_version = "3.0.0") with multiple WhatsApp Business Account IDs (WABA IDs) — registered under Meta Coexistence — draw from a single, company-level shared WhatsApp balance pool rather than independent per-WABA balances, and to surface that pool (and per-WABA attribution) in the Package Usage UI and reports.
The dominant, and load-bearing, finding of repo grounding is that most of the mechanism already exists across the five services; Phase 1 is largely aggregation, gating, and surfacing, not greenfield billing logic:
- The deduction engine in
qontak-billing(billing_service) already runs the WABI→Additional→Postpaid hierarchy, is company-scoped (company_id+billing_code), and already has optimistic locking + retry, monthly WABI reset, additional-quota carry-over, and threshold alerts. hub_corealready carries theorganization_package_idFK onwhatsapp_packages, thebilling_version/billing_v3?gating, the three component quota tables, the monthly-reset workers, and stampswaba_id+organization_package_idonWaConversationLog.hub_servicealready acceptswaba_idonmcc_logsand already gates aWABA IDCSV column on thebilling_report_show_waba_idpreference.hub-chatalready renders a (data-driven)waba_idcolumn on both report tables and already computesbillingM1Version.
The genuine net-new work is: (1) make the quota pool aggregate correctly across all of a company's WABAs (the single true backend gap), (2) surface aggregated balance on the Package Usage page for V3, (3) turn the FE's data-driven waba_id visibility into preference-driven visibility + add the filter dropdown, (4) aggregated (company-level) low-balance / below-zero notifications, and (5) the shared-balance tooltip.
Success Criteria
Traceable to PRD §11 (Success Metrics) and §8 ACs:
- Deduction accuracy = 100% across the shared pool: for any V3 company, total deducted across all WABAs never exceeds available quota (WABI + WAB-Additional + Postpaid). Verified by
billing_logaudit vs. expected bucket order (PRDWABA-S04/AC-1,WABA-S04/AC-4). - Aggregated balance query p95 ≤ 500ms; Package Usage page ≤ 2s (PRD §4 Constraints).
- Zero P0/P1 deduction / reset / notification incidents in first 30 days (PRD §11).
- WABI reset reliability ≥ 99.9% per cycle for V3 orgs (PRD §11,
WABA-S01/AC-1,WABA-S01/ERR-1). - V1/V2 unaffected — verified by sampling ≥20 non-V3 CIDs (PRD §9, §12 Staged Rollout gate).
Out of Scope
Mirrors PRD §3 Non-Goals, plus RFC-level exclusions:
- Per-WABA balance isolation / sub-quotas / per-WABA spending caps (PRD Non-Goal 1, 5).
- Billing V1 / V2 orgs — no change to their
wa_credit/wa_balance/wa_balance_initialflat fields (PRD Non-Goal 2). - Mobile app; real-time WebSocket balance push (PRD Non-Goal 3, 4).
- Multi-currency (PRD Non-Goal 6).
- Historical
WaConversationLogwaba_idbackfill (PRD Non-Goal 7, §9.1). - Automatic top-up / auto-recharge (PRD Non-Goal 8).
- Building the deduction hierarchy from scratch — it already exists in
qontak-billing; this RFC extends its quota resolution to aggregate across WABAs, it does not re-implement bucket ordering.
Related Documents
- PRD: One CID Multiple WABA — Phase 1 — the driver for every section here.
- Confluence source: PRD Implementation of One CID Multiple Waba ID - Billing V3 (reviewed — domain context; repo is source of truth on contracts).
- Jira Epic: BIF-6428.
qontak-billing/api_spec.yaml— the authoritative contract for/iag/v1/quota-managements/*.
Assumptions
Each is either verified by grounding (cited) or to-confirm (also in §5 Open Questions):
- [VERIFIED] Embedded signup already sets
organization_package_idon the newWhatsappPackagewhenwaba_save_organization_idis enabled —hub_core/app/core/domains/services/billing/wa_package.rb:213-240(create_attributes = { organization_package_id: org_package.id }). This resolves PRD Open Question Q4. - [VERIFIED] The deduction API is already company-scoped, keyed by
company_id+billing_code—qontak-billing/internal/pkg/request/quota_management.go:17-20,42-50. - [VERIFIED] One
billing_logrow is written per deduction, carrying the primary bucket incredited_to+quota_type—qontak-billing/internal/app/usecase/quota_management/deduction.go:264-284. This answers PRD Open Question Q2 (one row, primary bucket). - [CONFIRMED — 2026-07-01, initiative DRI] A V3 company has exactly one
organization_package. This selects Decision 1 Option A (single shared pool; N WABAs → 1 OrganizationPackage). The existing UNIQUE index onwhatsapp_packages.organization_package_id(hub_core/database/billing/db/migrate/20260113000000_*.rb) is the one constraint that must be relaxed to non-unique so multiple WABAs can reference the single package. - [TO CONFIRM] The
organization_package_component_*tables are physically the same Postgres tables acrosshub_core,moderator-be, andqontak-billing(each repo carries migrations for identically-named tables). qontak-billing is treated as the write authority for deduction. - [TO CONFIRM] The Mekari API gateway maps caller path
MEKARI_API_BASE_URL/internal/qontak/billing/v1/quota-managements/deduction(used by hub_core/moderator-be) to qontak-billing's registered routePOST /iag/v1/quota-managements/deduction.
Dependencies
| Dependency | Owning team | Availability | Blocking? |
|---|---|---|---|
qontak-billing quota-aggregation change (Decision 1) | Bifrost / billing-service owner | needs building | YES — the shared pool is defined here |
whatsapp_packages.organization_package_id FK populated for all V3 orgs (backfill) | Bifrost Eng (hub_core) | needs building (verify 100% coverage) | YES (PRD §13) |
billing_report_show_waba_id preference seedable per CID | Bifrost Eng (hub_core Services::Preference) | exists (mechanism) — needs per-CID seeding | YES for WABA-S05 |
| Low-balance threshold decision (default value / per-org vs global) | Bifrost PM + Finance | not decided | YES for WABA-S06 (PRD Open Q1) |
| Figma frame-level designs for tooltip / banner / report columns | Design (Wulan/Bulan) | file exists; frame links pending | YES for FE chunks (PRD §13) |
| Meta Coexistence embedded signup links each new WABA to the shared pool | Bifrost / hub_service | verified present; Decision 1 Option A requires dropping the unique FK index | Conditional on Decision 1 |
Design References (frontend half — required)
The PRD links a single Figma file; frame-level links are not yet provided. Per the skill's rule, FE chunks that depend on pixel-faithful frames are blocked until frames land (tracked in §5 Open Questions). The existing hub-chat components are the interim implementation anchor.
| PRD-named surface | Figma / design link | Frame name | Design system version | Design QA contact | Notes |
|---|---|---|---|---|---|
| Package Usage — WhatsApp balance section (aggregated) | Figma — Subscription | n/a — frame pending | @mekari/pixel3@1.0.12 (hub-chat/package.json:80) | Bulan (TBD) | Extends existing UsageDetail.vue |
| Shared Balance Tooltip | Figma — Subscription | n/a — frame pending | @mekari/pixel3@1.0.12 | Bulan (TBD) | Follows v-mp-tooltip / <mp-tooltip> pattern |
Broadcast deduction table — waba_id column | Figma — Subscription | n/a — frame pending | @mekari/pixel3@1.0.12 | Bulan (TBD) | Column already exists (data-driven) — re-gate to preference |
Conversation/MCC log table — waba_id column + filter dropdown | Figma — Subscription; sample sheet | n/a — frame pending | @mekari/pixel3@1.0.12 | Bulan (TBD) | Column exists; filter dropdown is net-new |
| Low balance / below-zero banner (aggregated) | Figma — Subscription (Low Balance) | n/a — frame pending | @mekari/pixel3@1.0.12 | Bulan (TBD) | Follows <MpBanner> pattern |
PRD-to-Schema Derivation (backend half — required)
| PRD entity / attribute / rule | Persisted as (table.column) | Exposed via (endpoint / event) | Enforced where | Source |
|---|---|---|---|---|
| A V3 company's WhatsApp balance is a single pool shared across all WABAs | organization_package_component_initials/_additionals/_postpaid_limits.remaining_quota (keyed by organization_package_id) | GET /api/core/v1/billings/info (aggregated); qontak-billing GET /iag/v1/quota-managements/info | qontak-billing GetQuotaForUpdate (must aggregate across WABAs — Decision 1) | PRD §7.1, CHG-003, WABA-S04 |
| Deduct WABI→WAB-Additional→Postpaid, in order | billing_logs.quota_type ∈ {initial, additional, postpaid}; .credited_to = component_code | qontak-billing POST /iag/v1/quota-managements/deduction | qontak-billing/.../quota_management/deduction.go:169-241 (exists) | PRD §7.2, WABA-S04 |
| Never deduct for a failed/non-billable message | no billing_log row; caller passes billable status | deduction caller in hub_core/app/core/domains/services/broadcasts/billing_deduction.rb | hub_core builds body only for billable msgs | PRD §7.2, WABA-S04/AC-2 |
| Which WABA originated each deduction | wa_conversation_logs.waba_id (exists) | GET /mcc_logs?waba_id=; GET /download_broadcast_deduction | hub_core stamps waba_id into log + deduction extra_attrs | PRD §5, §7.5, WABA-S05 |
Monthly WABI reset to initial_quota, no carry-over | organization_package_component_initials.remaining_quota; billing_components.is_initial_monthly_reset=true, is_carry_over_monthly=false | qontak-billing monthly-reset worker | qontak-billing/.../worker/monthly_reset_billing_component.go:107-111 (exists) | PRD §7.3, WABA-S01 |
| WAB-Additional carry-over on contract renewal | carry_over_quota_items + organization_package_component_additionals.remaining_quota; is_carry_over_contract=true | qontak-billing carry-over path | qontak-billing/.../worker/monthly_reset_billing_component.go:422-451 (partial — verify contract-renewal trigger) | PRD §7.4, WABA-S02 |
Report waba_id column gated by preference | billing_report_show_waba_id (OrganizationPreference, Flipper/Redis) | preference read in hub_service + FE appConfig | hub_service/.../billings.rb:559 (Services::Preference.new.enabled?(:billing_report_show_waba_id)) | PRD §4, CHG-001/002, WABA-S05 |
| Company-level low-balance / below-zero notification on aggregated balance | billing_alert_logs; alert_config (hub_core); threshold in Redis / billing_components.threshold_running_out | low_balance_warning / balance_below_zero events (net-new distinct below-zero) | qontak-billing alert worker (threshold ≤40% exists); below-zero is net-new | PRD §7.7, §10, WABA-S06 |
| Tooltip visible only for V3 | reads billing_version (organization_packages.billing_version) | GET /billings/info (must add billing_version to payload) | FE billingM1Version computed | PRD §6, WABA-S07 |
Every §2.3 DDL row and §2.4 endpoint traces to a row here or to a Design Reference frame; unresolved traces are in §5 Open Questions, not invented.
Detail 1.A — PRD Traceability (cross-layer)
Composite AC ids per PRD §8 (<STORY-ID>/AC-n).
Forward (PRD AC → RFC):
| PRD composite AC id | FE section / component | BE section / endpoint |
|---|---|---|
WABA-S01/AC-1..4, ERR-1..2 | n/a — backend job | §2 Decision 4; qontak-billing monthly-reset worker |
WABA-S02/AC-1..4, ERR-1 | n/a — top-up UI unchanged | §2.4 component register/top-up (moderator-be → qontak-billing); carry-over path |
WABA-S03/AC-1..3, ERR-1 | n/a — Modpanel config | §2.4 components/{code}/update; deduction postpaid branch |
WABA-S04/AC-1..4, ERR-1..2 | n/a — backend | §2 Decision 1 (aggregation) + Decision 2 (concurrency); POST /iag/v1/.../deduction |
WABA-S05/AC-1..5, ERR-1..2 | TableComponentCampaignBroadcast.vue, TableComponentWhatsappBalance.vue (+ filter dropdown) | §2.4 mcc_logs?waba_id=, download_broadcast_deduction; preference gate |
WABA-S06/AC-1..3, ERR-1..2 | LowBalanceBanner.vue (net-new) | §2 Decision 5; low_balance_warning/balance_below_zero |
WABA-S07/AC-1..3, ERR-1 | SharedBalanceTooltip.vue (net-new) | billing_version added to /billings/info payload |
Reverse (RFC → PRD AC):
| New RFC decision / artifact | PRD composite AC id it serves |
|---|---|
| Decision 1 — quota aggregation across WABAs | WABA-S04/AC-1, WABA-S04/AC-4, WABA-S03/AC-3 |
| Decision 2 — cross-WABA concurrency on shared pool | WABA-S04/AC-4, WABA-S04/ERR-1 |
| Decision 5 — aggregated notification + below-zero event | WABA-S06/AC-1, WABA-S06/AC-2, WABA-S06/ERR-2 |
billing_version in /billings/info | WABA-S07/AC-1, WABA-S07/AC-3 |
waba_id filter dropdown (FE) | WABA-S05/AC-3, WABA-S05/AC-4 |
UI / Consumer Surface Coverage
| PRD-named surface | Consumer | Required reads (BE) | Required writes (BE) | FE component | Status surface |
|---|---|---|---|---|---|
| Package Usage — balance section | web | GET /billings/info (+billing_version); GET /reports/billing/summary | none | UsageDetail.vue, PackageInfoComponent.vue | aggregated remaining_* fields |
| Broadcast deduction table + CSV | web | GET /{org}/billings/broadcast_deduction; GET /{org}/billings/download_broadcast_deduction | none | TableComponentCampaignBroadcast.vue | waba_id per row (pref-gated) |
| Conversation/MCC log table + filter + CSV | web | GET /{org}/reports/billing/mcc_logs?waba_id=; MCC export | none | TableComponentWhatsappBalance.vue (+ filter) | waba_id per row (pref-gated) |
| Shared Balance Tooltip | web | GET /billings/info (billing_version) | none | SharedBalanceTooltip.vue (net-new) | static; gated on V3 |
| Low balance banner | web | GET /billings/info / balance | none | LowBalanceBanner.vue (net-new) | aggregated balance vs threshold |
| Postpaid limit config | support (Modpanel) | qontak-billing GET .../components/{code} | qontak-billing PUT .../components/{code}/update | moderator-be UI | initial_quota on postpaid table |
Role Coverage
| PRD role | Authorization mechanism | Endpoints permitted | UI surface visibility | Cross-tenant? | Audit trail |
|---|---|---|---|---|---|
| Company Admin / Owner | OAuth2 scope :admin, :owner (hub_service oauth2 guard) | /billings/info, /mcc_logs, /download_broadcast_deduction | full Package Usage | no (own org) | billing_log, PackageLog |
| Supervisor / Agent / Member | OAuth2 scopes :supervisor, :agent, :member | /mcc_logs, reports they may view | reports (read-only) | no | billing_log |
| Qontak Finance (Modpanel) | Modpanel scope + Mekari SSO (moderator-be → qontak-billing) | components/{code}/update (postpaid), top-up | Modpanel only | yes (ops, cross-tenant) | qontak-billing billing_log, moderator-be audit |
| System (cron/worker) | internal service token / job queue | qontak-billing deduction, monthly-reset, alert workers | none | n/a (all V3 orgs) | billing_log, billing_alert_logs |
PRD Section Coverage
| PRD § | Title | Where covered |
|---|---|---|
| 1 | One-liner + Problem | §1 Overview |
| 2 | Target Users + Persona | §1 (Role Coverage) |
| 3 | Non-Goals | §1 Out of Scope |
| Scope Changes | surfaces | §1 PRD-to-Schema; §2.D scope boundaries |
| 4 / 4.1 | Constraints / Data Lifecycle | §3 Performance; §2.3 retention |
| 5 | Feature Changes (CHG-001..003) | §2.4 APIs; §2.A FE (report columns, aggregated display) |
| 6 | New Features (tooltip) | Decision (FE); SharedBalanceTooltip.vue |
| 7 | API & Webhook Behavior (1..7) | §2.4 APIs; §2.C async; §2 Decisions |
| 8 / 8.1 / 8.2 | System Flow + Stories + ACs | Detail 1.A/1.C; §2.2 sequences |
| 9 / 9.1 | Rollout / Migration window | §4 Rollout |
| 10 / 10.1 | Observability | §3 Monitoring |
| 11 | Success Metrics | §1 Success Criteria |
| 12 | Launch Plan & Stage Gates | §4 Rollout stages |
| 13 | Dependencies | §1 Dependencies |
| 14 | Key Decisions + Alternatives | §2 Technical Decisions (ADR); Detail 1.B |
| 15 | Open Questions | §5 |
Detail 1.B — Decisions Closed (cross-layer)
| # | Decision | Chosen option | §2 block | Layer |
|---|---|---|---|---|
| 1 | How to make the quota pool shared across a company's WABAs | Single OrganizationPackage pool; allow N WhatsappPackages (WABAs) → 1 OrganizationPackage by dropping the unique FK index (confirmed 2026-07-01: one company = one org package) | Decision 1 | BE |
| 2 | Prevent over-deduction under concurrent sends from ≥2 WABAs on one pool | Reuse existing optimistic lock + requeue (no new mechanism) | Decision 2 | BE |
| 3 | Aggregated balance read path for /billings/info | Read-through the existing qontak-billing quota info, exposed via hub_core builder; short Redis cache | Decision 3 | BE |
| 4 | WABI monthly reset & priority on the shared pool | Reuse qontak-billing monthly-reset worker unchanged once pool is single-package | Decision 4 | BE |
| 5 | Company-level low-balance + distinct below-zero notification | Extend existing threshold alert; add net-new balance_below_zero event + dedup window | Decision 5 | BE |
| 6 | waba_id report column gating | Preference-driven (billing_report_show_waba_id) replacing FE data-driven hasWabaId | Decision 6 | FE + BE |
| 7 | Reuse vs new endpoints | All read/report/deduction endpoints reused/extended; no new HTTP surface except FE filter param passthrough | §2.4 | both |
Detail 1.C — Per-Story Change Map
| Story id | Title | Layer scope | Changes (concrete artifacts) | Composite AC ids | Acceptance criteria (verifiable) | RFC anchors |
|---|---|---|---|---|---|---|
WABA-S01 | WABI monthly reset & deduction priority | Runtime / behavior (BE) | qontak-billing monthly-reset worker (reuse); deduction initial-bucket branch (reuse); ensure reset scoped to single shared pool | WABA-S01/AC-1..4, ERR-1..2 | rspec/go test: after reset, initial.remaining_quota == initial_quota; wabi_reset_completed logged; V1/V2 skipped | Decision 1 · Decision 4 · §2.C |
WABA-S02 | WAB-Additional purchase & carry-over | Cross-squad (Modpanel → qontak-billing) | top-up increments additionals.remaining_quota; carry-over path on renewal; verify is_carry_over_contract trigger | WABA-S02/AC-1..4, ERR-1 | go test: renewal transfers remaining additional to new contract; wab_additional_carried_over logged | §2.4 · Decision 4 · §5 Open Q |
WABA-S03 | Postpaid limit config & blocking | Cross-squad (Modpanel → qontak-billing) | PUT components/{code}/update sets postpaid initial_quota; deduction postpaid branch + block when 0 | WABA-S03/AC-1..3, ERR-1 | go test: postpaid decremented; quota_exceeded when all buckets 0; 403 for non-Modpanel | §2.4 · Decision 1 |
WABA-S04 | Multi-bucket deduction hierarchy | BE-only | qontak-billing deduction.go (reuse hierarchy); aggregate quota across WABAs (net-new); concurrency reuse | WABA-S04/AC-1..4, ERR-1..2 | go test: 500/400/100 split across buckets; failed msg → no row; concurrent sends never over-deduct | Decision 1 · Decision 2 · §2.2 |
WABA-S05 | WABA ID column in usage reports | BE + FE consumes existing | BE: preference gate (hub_service exists); FE: re-gate column to preference + filter dropdown (net-new) + CSV | WABA-S05/AC-1..5, ERR-1..2 | vitest: column shown iff pref ON; filter returns matching rows; empty state on no-match; V1/V2 no column | Decision 6 · §2.4 · §2.A |
WABA-S06 | Company-level low balance notifications | BE + FE consumes new | BE: aggregated threshold + net-new below-zero event + dedup; FE: LowBalanceBanner.vue (net-new) | WABA-S06/AC-1..3, ERR-1..2 | go test: low_balance_warning/balance_below_zero emitted once per crossing (dedup); vitest: banner renders/clears | Decision 5 · §2.C · §3.A.1 |
WABA-S07 | Shared balance tooltip | FE + BE consumes new | BE: add billing_version to /billings/info; FE: SharedBalanceTooltip.vue (net-new), gated on billingM1Version | WABA-S07/AC-1..3, ERR-1 | vitest: icon+tooltip render for V3 only; hidden when billing info fails (fail-safe) | §2.4 · §2.A |
2. Technical Design
Infrastructure Topology
Five services participate; the deduction of record happens in qontak-billing (billing_service), reached over the Mekari internal API gateway. hub_service embeds hub_core in-process (Ruby gem, hub_service/Gemfile:103 gem 'hub_core', path: '../hub_core'), sharing one Postgres billing database. moderator-be is an ops facade calling both hub_service and qontak-billing.
Deployment topology
flowchart TB
browser(["Company Admin browser"]) -->|HTTPS| lb["Load Balancer / API Gateway"]
lb -->|HTTP| chat["hub-chat (Nuxt SSR/SPA pods)"]
chat -->|"HTTPS /api/core/v1/billings/*"| hs["hub_service-api pods (Grape)"]
hs -->|in-process gem| hc["hub_core (domain logic)"]
hc -->|"read / write"| dbw[("Postgres billing (primary)")]
hc -->|"read-only"| dbr[("Postgres billing (replica)")]
hc -->|"get / set"| redis[("Redis (preferences, thresholds, cache)")]
hc -->|"HTTPS via Mekari API gateway"| qb["qontak-billing (billing_service) pods"]
qb -->|"read / write quota"| dbw
qb -->|enqueue| q[["Job queue (gocraft/work + Redis)"]]
q -->|consume| qbw["qontak-billing worker pods (deduction, monthly-reset, alert)"]
qbw -->|write| dbw
modbe["moderator-be (Modpanel ops)"] -->|"Pigeon HTTPS"| qb
modbe -->|"HTTPS subscription create"| hs
modbe -->|Metabase embed| metabase(["Metabase (CID/WABA dashboards)"])
Per-service responsibility
flowchart LR
subgraph hubchat["hub-chat (FE, owner: bifrost)"]
fe1["Package Usage page — aggregated balance + tooltip"]
fe2["Report tables — waba_id column + filter + CSV"]
fe3["Low-balance banner"]
end
subgraph hubsvc["hub_service + hub_core (owner: bifrost)"]
uc1["GET /billings/info (aggregated + billing_version)"]
uc2["GET /mcc_logs?waba_id= ; download_broadcast_deduction"]
uc3["WhatsApp deduction orchestration (build body, stamp waba_id)"]
uc4["Preference gate billing_report_show_waba_id"]
end
subgraph billing["qontak-billing / billing_service (owner: bifrost billing)"]
b1["POST /iag/v1/quota-managements/deduction (WABI to Additional to Postpaid)"]
b2["components register/update (postpaid, additional)"]
b3["monthly-reset worker + carry-over"]
b4["threshold alert worker"]
end
fe1 -->|"HTTPS"| uc1
fe2 -->|"HTTPS"| uc2
uc3 -->|"HTTPS via gateway"| b1
modpanel(["moderator-be (Modpanel)"]) -->|"Pigeon HTTPS"| b2
b1 -->|"async"| b3
b1 -->|"async"| b4
uc1 -->|"HTTPS read quota info"| billing
Technical Decisions (ADR-format — the engineering heart)
Decision 1: How the balance pool becomes shared across a company's WABAs
Context
The PRD's core promise is "one company, one shared WhatsApp balance across all WABA IDs". Grounding shows the deduction engine is already company-scoped: POST /iag/v1/quota-managements/deduction takes company_id + billing_code (qontak-billing/internal/pkg/request/quota_management.go:17-20,42-50), and GetQuotaForUpdate filters WHERE op.company_id = $2 (qontak-billing/db/queries/organization_package_components.sql:87-137). However it returns a single row (:one) and the quota rows are keyed by organization_package_id. Meanwhile hub_core has a UNIQUE index on whatsapp_packages.organization_package_id (hub_core/database/billing/db/migrate/20260113000000_add_organization_package_id_to_whatsapp_packages.rb), i.e. today one WhatsappPackage maps to one OrganizationPackage. So the unresolved question is the target cardinality: does a company have one OrganizationPackage (with many WABAs pointing at it) or many?
Options considered
- Option A — One shared OrganizationPackage per company; N WhatsappPackages (WABAs) → 1 OrganizationPackage. Drop the unique index on
whatsapp_packages.organization_package_id; every WABA registered via embedded signup links to the company's singleOrganizationPackage; the quota tables (already keyed byorganization_package_id) are by construction a single shared pool.- Pros: Zero change to the deduction engine — company-scoped
GetQuotaForUpdatereturns the one pool correctly; monthly-reset/carry-over/alerts already operate per-package = per-company. hub_core'screate_whatsapp_packagealready setsorganization_package_id: org_package.id(hub_core/.../services/billing/wa_package.rb:213-240). Smallest, most reversible change. - Cons: requires dropping a UNIQUE constraint (migration + verifying nothing relies on 1:1); embedded-signup
find_whatsapp_packagemust stop short-circuiting when a package already exists so additional WABAs create their ownWhatsappPackagerow against the sameorganization_package_id.
- Pros: Zero change to the deduction engine — company-scoped
- Option B — Many OrganizationPackages per company; aggregate quota by summing across them. Keep 1:1 WABA↔OrganizationPackage; change
GetQuotaForUpdatetoSUM(remaining_quota)across all of the company's packages, and deduct across multiple physical rows (each with its ownlock_version).- Pros: no schema/constraint change to the FK.
- Cons: deduction must span multiple quota rows in one event (multi-row optimistic locking, partial-failure across rows), a substantially more complex change to the engine's hot path; monthly-reset/carry-over must fan out; higher risk to a money-critical path.
Decision: Option A (confirmed 2026-07-01 by the initiative DRI: a V3 company has exactly one organization_package).
Rationale
Option A makes the shared pool an emergent property of the existing, already-company-scoped engine, touching the money-critical deduction hot path not at all. The FK, quota tables, reset, carry-over, and alerts all already key on organization_package_id; collapsing a company to one organization_package means "shared pool" needs no new aggregation math and no multi-row transaction. Option B concentrates all risk in the exact place we least want it (concurrent multi-row deduction on live balances).
Consequences
- A data migration must guarantee every V3 company has exactly one
OrganizationPackageand every WABA'sWhatsappPackagepoints to it (PRD §13 backfill dependency). - Embedded-signup creation logic changes so a 2nd/3rd WABA does not reuse/short-circuit but creates a new
WhatsappPackagerow on the same pool. - The unique index removal is a one-way-ish schema change (re-adding it later would require re-splitting pools).
Reversibility
Medium. The index can be re-added only if pools are re-split per WABA (data surgery). Mitigation: gate the new many-to-one creation behind the existing waba_save_organization_id preference so it is per-org reversible before backfill.
Decision 2: Concurrency on the shared pool (two WABAs sending at once)
Context
With one shared pool, two WABAs sending simultaneously both deduct from the same organization_package_component_* row — the classic lost-update risk (PRD WABA-S04/AC-4, ERR-1).
Options considered
- Option A — Reuse existing optimistic lock + requeue. qontak-billing already does
UPDATE ... SET remaining_quota = remaining_quota - ?, lock_version = lock_version + 1 WHERE id = ? AND lock_version = ?(qontak-billing/db/queries/organization_package_component_initials.sql:42-51) and, onrowAffected == 0, rolls back and re-enqueues the deduction job (.../usecase/quota_management/deduction.go:71-90).- Pros: already implemented, already protects the shared row; no new mechanism.
- Cons: retry latency under high contention; retry count/backoff is governed by the job queue, not an explicit in-handler cap (PRD wants "up to 3 retries").
- Option B — Row-level
SELECT ... FOR UPDATEpessimistic lock. Serialize deductions on the pool row.- Pros: no retry churn.
- Cons: rewrites the hot path; throughput bottleneck on hot pools; contradicts the existing optimistic design.
Decision: Option A (reuse), with an explicit bounded-retry contract layered on the requeue (align to PRD's "3 attempts" via job max_fails), and billing_deduction_failed logged when exhausted.
Rationale The existing optimistic lock already makes over-deduction structurally impossible (the WHERE lock_version guard). The only gap vs. the PRD is making the retry bound explicit and the exhaustion log/metric named.
Consequences Under extreme concurrency a deduction may be delayed by requeue; message delivery is never blocked on it (PRD WABA-S04/ERR-1). Need a dashboard for retry rate.
Reversibility High — retry bound is config.
Decision 3: Aggregated balance read path for /billings/info
Context
The Package Usage page (PRD CHG-003) must show a single WABI / WAB-Additional / Postpaid figure for V3. hub_service GET /billings/info today returns org-level flags via Interactors::Billings::BillingInfo (hub_core) and the balance summary comes from GET /api/core/v1/reports/billing/summary (hub-chat/.../composables/usePackages.ts:55-74). Under Decision 1 the pool is already single-per-company, so aggregation is a read concern, not a math concern.
Options considered
- Option A — Read-through qontak-billing quota
infoand expose via hub_core builder + short Redis cache. hub_core reads the (already single-pool) quota via qontak-billingGET /iag/v1/quota-managements/info/info/{billing_code}and returns aggregated WABI/Additional/Postpaid; cache in Redis with short TTL for the ≤500ms p95 budget.- Pros: single source of truth (qontak-billing quota tables); reuses existing endpoints; cache meets latency budget.
- Cons: cross-service read on page load (mitigated by cache); cache invalidation on deduction.
- Option B — hub_core reads billing tables directly from the shared Postgres. Skip the service hop.
- Pros: lowest latency.
- Cons: two writers/readers of the same money tables from different services (coupling, drift risk); bypasses qontak-billing's caching/consistency.
Decision: Option A.
Rationale Keeps qontak-billing the authority for quota values; the ≤500ms budget is met with a short Redis TTL and the existing invalidate-cache endpoint (PUT /iag/v1/quota-managements/components/{company_id}/invalidate-cache) already exists to bust it on writes.
Consequences /billings/info gains a billing_version field (needed by FE billingM1Version) and aggregated balance fields; add a cache key + TTL + invalidation on deduction/top-up. On timeout, return last-known cached value and log billing_aggregate_balance_timeout (PRD §7.1 failure behavior).
Reversibility High — cache and read path are additive.
Decision 4: WABI monthly reset & priority on the shared pool
Context PRD WABA-S01: reset initial quota to initial_quota at cycle start, no carry-over; always deduct WABI first.
Decision Reuse the existing qontak-billing monthly-reset worker (.../worker/monthly_reset_billing_component.go:107-111, gated on billing_components.is_initial_monthly_reset) and the existing initial-first deduction branch (.../quota_management/deduction.go:169-189). Under Decision 1, per-package reset == per-company reset, so no logic change is required beyond confirming reset runs per shared pool.
Rationale no alternative considered — the behavior already exists and is correct for a single-package pool.
Consequences Depends on Decision 1 (single pool). If Option B were chosen, reset would need to fan out across packages.
Reversibility n/a — no change.
Decision 5: Company-level low-balance + distinct below-zero notification
Context PRD WABA-S06: notify on aggregated balance crossing a low threshold, and a distinct notification when balance drops below 0; dedup repeated crossings. Grounding: qontak-billing already emits a threshold alert (default 40%) after deduction (.../quota_management/deduction.go:99-103,293-306) writing billing_alert_logs; hub_core has alert_config + update_alert_v2. There is no below-zero-specific event and no explicit dedup window today.
Options considered
- Option A — Extend the existing threshold alert; add a net-new
balance_below_zerobranch + a per-org per-cycle dedup key in Redis.- Pros: reuses the alert worker +
billing_alert_logs; minimal surface. The threshold already exists and is wired —billing_components.threshold_running_out DECIMAL(5,2)(migrationqontak-billing/db/migrations/20260126000001_*), read indeduction.go:299-301andquota_management_alert.go:97-98with a 40% default when NULL. So the low-balance branch is not blocked on a product decision. - Cons: below-zero branch + dedup are net-new (small).
- Pros: reuses the alert worker +
- Option B — New notification service/path. Rejected — duplicates existing alert infra.
Decision Option A.
Rationale The alert pipeline and the threshold both already exist. Reuse billing_components.threshold_running_out (default 40%) for the low-balance branch; add one branch (aggregated_balance < 0 → distinct email/banner) and a dedup key (low_balance:{org}:{cycle}). A per-org threshold override is out of Phase 1 scope (the column is per-component); if Finance later needs it, it is a separate net-new field.
Consequences low_balance_warning (existing threshold) and balance_below_zero (net-new) become the two named events (PRD §10). Dedup prevents spam (PRD WABA-S06/ERR-2). Notification failure never blocks deduction (PRD ERR-1).
Reversibility High — additive branch + config.
Decision 6: waba_id report column gating — data-driven → preference-driven
Context FE shows the waba_id column when any row has a waba_id (hasWabaId in TableComponentCampaignBroadcast.vue:308-310 and TableComponentWhatsappBalance.vue:395-397). BE already gates the CSV column on billing_report_show_waba_id (hub_service/.../billings.rb:559,582,596). PRD WABA-S05/AC-5 + NEG-1 require the column/filter to be absent unless the preference is ON (and always for V1/V2).
Options considered
- Option A — Deliver the preference to the FE via
appConfigand gate the column + filter + tooltip onpreference && billingM1Version. Follows the existingappConfig.value?.billing_reports?.*pattern (TableComponentWhatsappBalance.vue:646-649).- Pros: consistent with existing feature-flag delivery; single gate for column/filter/CSV.
- Cons: requires adding the flag to
AppConfigpayload +billing_versiontoBillingStore.
- Option B — Keep data-driven
hasWabaId. Rejected — violatesWABA-S05/AC-5(column shows whenever data has any waba_id, even when preference OFF).
Decision Option A.
Rationale Matches PRD gating exactly and reuses the app-config feature-flag path already in the codebase.
Consequences Add billing_reports.show_waba_id to AppConfigStore.AppConfig and billing_version to BillingStore.BillingInfo; replace hasWabaId with shouldShowWabaId = pref && billingM1Version.
Reversibility High — a computed flag swap.
Minimum coverage: Storage → Decision 1. Sync/async → Decision 2 (async requeue), Decision 5 (async alert). Caching → Decision 3 (Redis short TTL + existing invalidate-cache). Third-party → n/a (no external 3rd-party API in Phase 1; Meta is upstream of embedded signup, unchanged). Consistency → Decision 1/2 (strong within the pool row via optimistic lock; eventual for the
/billings/infocache). Multi-tenancy → company-scopedcompany_ideverywhere (deduction, quota read); OAuth2 org scope athub_service. Reuse vs new → Detail 1.B row 7 (all endpoints reused/extended).
Detail 2.0 — Repo Reading Guide
Repo Map (mermaid)
flowchart LR
subgraph fe["hub-chat"]
feUsage["features/subscriptions/packages/"]
feReports["features/subscriptions/usages/"]
feStore["common/store/BillingStore.ts + useTopupStore.ts"]
end
subgraph svc["hub_service"]
grape["app/services/api/core/v1/billings/resources/billings.rb"]
end
subgraph core["hub_core (gem)"]
binfo["interactors/repositories Billings::BillingInfo"]
dedux["services/broadcasts/billing_deduction.rb"]
qmclient["apps/billings/services/quota_management.rb"]
pref["services/preference.rb"]
end
subgraph billing["qontak-billing"]
ded["usecase/quota_management/deduction.go"]
qfu["db/queries/organization_package_components.sql"]
reset["usecase/worker/monthly_reset_billing_component.go"]
end
db[("Postgres billing (shared)")]
feUsage --> grape
feReports --> grape
grape --> binfo
grape --> dedux
dedux --> qmclient
qmclient -->|HTTPS gateway| ded
ded --> qfu --> db
binfo --> db
reset --> db
Existing Code Anchors
| Path | Why the agent reads it | What pattern it teaches |
|---|---|---|
qontak-billing/internal/app/usecase/quota_management/deduction.go | The deduction hierarchy + retry to extend | initial→additional→postpaid branch (L169-241); requeue on lock conflict (L71-90); one billing_log/deduction (L264-284) |
qontak-billing/db/queries/organization_package_components.sql | The quota read that must become pool-correct | GetQuotaForUpdate :one filtered WHERE op.company_id = $2 (L87-137) |
qontak-billing/db/queries/organization_package_component_initials.sql | Optimistic-lock UPDATE to reuse | OPCIDeductionQuota WHERE id=$1 AND lock_version=$2 (L42-51) |
qontak-billing/internal/app/usecase/worker/monthly_reset_billing_component.go | Reset + carry-over reuse | reset gated on is_initial_monthly_reset (L107-111); carryOverAdditionalQuota (L422-451) |
qontak-billing/internal/app/server/rest_router.go | Route registration style | r.Method(http.MethodPost, "/deduction", myHandler(...)) (L91) |
hub_core/app/core/domains/services/billing/wa_package.rb | Embedded-signup WABA→package linking (Decision 1) | create_attributes = { organization_package_id: org_package.id } (L213-240); create short-circuit (L38-76) |
hub_core/app/core/domains/repositories/billings/helpers.rb | V3 gate + pool lookup helpers | is_billing_v3 (L35-37); find_whatsapp_package by organization_package_id (L217-232); thresholds (L107-121) |
hub_core/app/apps/billings/services/quota_management.rb | The HTTP client to qontak-billing | deduct_quota → /internal/qontak/billing/v1/quota-managements/deduction; base MEKARI_API_BASE_URL (L7) |
hub_service/app/services/api/core/v1/billings/resources/billings.rb | The API surface to extend | /info (L607-629); /mcc_logs optional :waba_id (L489); /download_broadcast_deduction pref gate (L559,582,596) |
hub-chat/features/subscriptions/usages/TableComponentWhatsappBalance.vue | MCC table + column + export | hasWabaId (L395-397); pref-gated export (L646-649) |
hub-chat/features/subscriptions/usages/TableComponentCampaignBroadcast.vue | Broadcast table + CSV blob download | hasWabaId (L308-310); download_broadcast_deduction blob (L445-484) |
hub-chat/features/finances/topup/store/useTopupStore.ts | billingM1Version computed | packageInfo.value.billing_version === "3.0.0" (L115-117) |
Existing Contracts to Reuse, Extend, or Replace
| Contract | Status | Justification | Owner |
|---|---|---|---|
POST /iag/v1/quota-managements/deduction | extended | Add pool-correct quota resolution (Decision 1); response/shape unchanged | qontak-billing |
GET /iag/v1/quota-managements/info, info/{billing_code} | reused | Source for aggregated /billings/info (Decision 3) | qontak-billing |
PUT /iag/v1/quota-managements/components/{code}/update | reused | Finance sets postpaid initial_quota (WABA-S03) | qontak-billing |
PUT /iag/v1/quota-managements/components/{company_id}/invalidate-cache | reused | Bust /billings/info cache on write | qontak-billing |
GET /api/core/v1/billings/info | extended | Add billing_version + aggregated balance | hub_service/hub_core |
GET /api/core/v1/billings/mcc_logs?waba_id= | reused | Param already accepted (L489) | hub_service |
GET /api/core/v1/billings/download_broadcast_deduction | reused | CSV waba_id column already pref-gated (L559-596) | hub_service |
wa_conversation_logs (waba_id, organization_package_id, credited_to) | reused | Columns already present | hub_core |
Patterns to Follow
| Concern | Pattern in repo | Reference file | Deviation in this RFC? |
|---|---|---|---|
| Go HTTP handler shape | func(w,r)(ResponseBody,error) via myHandler | qontak-billing/internal/pkg/http/handler.go; rest_router.go:91 | none |
| Go DB access | sqlc generated queries + WithTx/BeginTx | qontak-billing/internal/app/repository/*.sql.go; sqlc.yaml | none |
| Go error envelope | {resp_code, resp_desc{id,en}, meta} | qontak-billing/internal/pkg/http/default_error.go:8-95 | none |
| Go logging | log/slog structured, slog.ErrorContext | .../quota_management/deduction.go:36 | none |
| Ruby domain logic | Dry::Monads Success/Failure, interactors/repositories | hub_core/app/core/domains/interactors/* | none |
| Ruby HTTP client to billing | Repositories::AbstractHttp post(...) | hub_core/app/apps/billings/services/quota_management.rb | none |
| Grape endpoint + OAuth2 scope | params do ... end; oauth2 :admin,...; get '/info' | hub_service/.../billings.rb:607-629 | none |
| FE state | Pinia store + $customFetch | hub-chat/common/store/BillingStore.ts; plugins/customFetch.ts:146-227 | none |
| FE component | <script lang="ts" setup> + @mekari/pixel3 css() | hub-chat/features/subscriptions/packages/PackageInfoComponent.vue | none |
| FE feature-flag read | appConfig.value?.billing_reports?.* | hub-chat/.../TableComponentWhatsappBalance.vue:646-649 | none |
Reading Order for the Agent
qontak-billing/internal/app/usecase/quota_management/deduction.go— the deduction hot path (hierarchy + retry).qontak-billing/db/queries/organization_package_components.sql—GetQuotaForUpdate, the one query Decision 1 changes.qontak-billing/db/queries/organization_package_component_initials.sql— optimistic-lock UPDATE to reuse.hub_core/app/core/domains/services/billing/wa_package.rb— WABA→package linking (Decision 1 FE-of-BE).hub_core/app/core/domains/repositories/billings/helpers.rb— V3 gate + pool helpers + thresholds.hub_core/app/apps/billings/services/quota_management.rb— the HTTP client + gateway path.hub_service/app/services/api/core/v1/billings/resources/billings.rb— the API surface (info / mcc_logs / download).hub-chat/features/subscriptions/usages/TableComponentWhatsappBalance.vue— column + export + flag read.hub-chat/features/finances/topup/store/useTopupStore.ts—billingM1Version.qontak-billing/internal/app/usecase/worker/monthly_reset_billing_component.go— reset + carry-over.
Source Verification (anti-hallucination)
| Anchor / pattern / contract | Verified by | Evidence |
|---|---|---|
| deduction hierarchy | read | deduction.go:169-241 initial→additional→postpaid→fallback; creditedTo = quota.InitialComponentCode... |
| one billing_log/deduction | read | deduction.go:264-284 CreateBillingLog(... CreditedTo, QuotaType ...) (answers PRD Q2) |
| deduction retry/requeue | read | deduction.go:71-90 if rowAffected == 0 && err == nil { ... EnqueueQuotaManagementDeduction } |
| company-scoped quota read | read | organization_package_components.sql:87-137 WHERE op.company_id = $2, :one |
| optimistic lock UPDATE | read | organization_package_component_initials.sql:42-51 WHERE id = $1 AND lock_version = $2 |
| monthly reset | read | monthly_reset_billing_component.go:107-111 if !bc.IsInitialMonthlyReset { continue } |
| carry-over (contract trigger unverified) | read | monthly_reset_billing_component.go:422-451 carryOverAdditionalQuota; contract-renewal trigger not traced → §5 Open Q |
| V3 gate (Go) | read | qontak-billing/internal/app/repository/model_helper.go:10-12 IsBillingV3() == "3.0.0" |
| unique FK index (Decision 1) | read | hub_core/.../20260113000000_*.rb add_index :whatsapp_packages, :organization_package_id, unique: true |
| WABA→package link | read | hub_core/.../services/billing/wa_package.rb:213-240 { organization_package_id: org_package.id } |
| V3 gate (Ruby) | read | hub_core/.../billings/helpers.rb:35-37 is_billing_v3 == "3.0.0"; organization_package.rb:144-146 billing_v3? |
| QM client + gateway path | read | hub_core/app/apps/billings/services/quota_management.rb deduct_quota /internal/qontak/billing/v1/quota-managements/deduction; base MEKARI_API_BASE_URL (L7) |
/billings/info route | read | hub_service/.../billings.rb:607-629 get '/info' → Interactors::Billings::BillingInfo |
mcc_logs waba_id param | read | hub_service/.../billings.rb:489 optional :waba_id, type: String |
| CSV waba_id gate | read | hub_service/.../billings.rb:559,582,596 show_waba_id = Services::Preference.new.enabled?(:billing_report_show_waba_id) |
| hub_core in-process gem | read | hub_service/Gemfile:103 gem 'hub_core', path: '../hub_core' |
| FE waba_id column (data-driven) | read | TableComponentWhatsappBalance.vue:395-397, TableComponentCampaignBroadcast.vue:308-310 hasWabaId |
| FE billingM1Version | read | useTopupStore.ts:115-117 billing_version === "3.0.0" |
| FE CSV blob download | read | TableComponentCampaignBroadcast.vue:445-484 blob → <a download> |
| FE tooltip/banner patterns | read | InvoicesDetailPage.vue:159-167 <mp-tooltip>; BannerRingGroup.vue <MpBanner variant="warning"> |
| FE DS version | read | hub-chat/package.json:80 @mekari/pixel3 1.0.12 |
gateway path vs /iag/v1 (unresolved) | grep | qontak-billing registers /iag/v1/... (rest_router.go:91); callers use /internal/qontak/billing/v1/... → mapping in §5 Open Q |
Detail 2.1 — Architecture (mermaid)
Component diagram
flowchart TB
caller(["hub-chat / Modpanel"]) --> grape["hub_service Grape /billings/*"]
grape --> binfo["hub_core Billings::BillingInfo"]
grape --> dedux["hub_core Broadcasts::BillingDeduction"]
dedux --> qmc["hub_core QuotaManagement HTTP client"]
qmc -->|"POST deduction (gateway)"| qb["qontak-billing Deduction usecase"]
qb --> qfu["GetQuotaForUpdate (pool-correct)"]
qfu --> db[("organization_package_component_*")]
qb --> lock["OPC* DeductionQuota (lock_version)"]
lock --> db
qb --> log["CreateBillingLog (credited_to, quota_type)"]
log --> db
binfo -->|"read quota info + cache"| redis[("Redis cache")]
binfo --> qbinfo["qontak-billing GET quota info"]
qbinfo --> db
Data model (mermaid erDiagram)
erDiagram
ORGANIZATION_PACKAGES ||--o{ WHATSAPP_PACKAGES : "has (Decision 1: 1 to many)"
ORGANIZATION_PACKAGES ||--o{ ORGANIZATION_PACKAGE_COMPONENTS : has
ORGANIZATION_PACKAGE_COMPONENTS ||--o| OPC_INITIALS : "initial bucket"
ORGANIZATION_PACKAGE_COMPONENTS ||--o| OPC_ADDITIONALS : "additional bucket"
ORGANIZATION_PACKAGE_COMPONENTS ||--o| OPC_POSTPAID : "postpaid bucket"
ORGANIZATION_PACKAGES ||--o{ WA_CONVERSATION_LOGS : logs
ORGANIZATION_PACKAGES {
uuid id PK
uuid organization_id
string company_id
string billing_version "gate 3.0.0"
decimal wa_balance
}
WHATSAPP_PACKAGES {
uuid id PK
uuid organization_package_id FK "unique index dropped by Decision 1"
string waba_id "nullable in new flow"
decimal postpaid_limit "V1 only"
}
OPC_INITIALS {
uuid id PK
uuid organization_package_id FK
string component_code
decimal initial_quota
decimal remaining_quota
int lock_version
}
OPC_ADDITIONALS {
uuid id PK
uuid organization_package_id FK
decimal remaining_quota
int lock_version
}
OPC_POSTPAID {
uuid id PK
uuid organization_package_id FK
decimal initial_quota "Finance-set ceiling"
decimal remaining_quota
int lock_version
}
WA_CONVERSATION_LOGS {
uuid id PK
uuid organization_package_id FK
string waba_id
string credited_to
decimal total_price
bool is_auto_deduct
}
State machine — deduction bucket selection
stateDiagram-v2
[*] --> CheckBillable
CheckBillable --> NoDeduct: non-billable (failed)
CheckBillable --> CheckWABI: billable
CheckWABI --> DeductWABI: initial remaining sufficient
CheckWABI --> CheckAdditional: initial exhausted
CheckAdditional --> DeductAdditional: additional remaining sufficient
CheckAdditional --> CheckPostpaid: additional exhausted
CheckPostpaid --> DeductPostpaid: postpaid remaining sufficient
CheckPostpaid --> Blocked: all buckets exhausted
DeductWABI --> WriteLog
DeductAdditional --> WriteLog
DeductPostpaid --> WriteLog
WriteLog --> AlertCheck
AlertCheck --> [*]
NoDeduct --> [*]
Blocked --> [*]: quota_exceeded
Branch & skip flow — preference-gated waba_id column
flowchart TD
load(["Report table renders"]) --> v3{"billing_version is 3.0.0?"}
v3 -- no --> hide["No waba_id column or filter (V1/V2 unchanged)"]
v3 -- yes --> pref{"billing_report_show_waba_id ON?"}
pref -- no --> hide
pref -- yes --> show["Render waba_id column + filter dropdown + CSV column"]
hide --> done(["done"])
show --> done
Detail 2.2 — Sequence (end-to-end, with failure paths)
Happy path — outbound message deduction from the shared pool
sequenceDiagram
actor Agent as Agent (any WABA)
participant HS as hub_service-api
participant HC as hub_core BillingDeduction
participant GW as Mekari API gateway
participant QB as qontak-billing
participant DBW as Postgres primary
participant Q as Job queue
participant W as qontak-billing worker
Agent->>HS: send message (billable)
HS->>HC: deduct(waba_id, conversation)
HC->>GW: POST /internal/qontak/billing/v1/quota-managements/deduction
Note right of GW: maps to POST /iag/v1/quota-managements/deduction
GW->>QB: deduction(company_id, billing_code, quantity, extra_attrs.waba_id)
QB->>DBW: GetQuotaForUpdate (pool by company_id)
DBW-->>QB: initial/additional/postpaid remaining + lock_version
QB->>DBW: OPCIDeductionQuota WHERE lock_version = v
DBW-->>QB: rowAffected = 1
QB->>DBW: CreateBillingLog (credited_to, quota_type, waba_id)
QB-->>GW: 200 value_before/value_after/credited_to
GW-->>HC: 200
HC-->>HS: Success
QB->>Q: enqueue alert if remaining <= threshold
Q->>W: QuotaManagementAlert
W->>DBW: write billing_alert_logs
Failure path — concurrent send, optimistic-lock conflict
sequenceDiagram
participant QB as qontak-billing
participant DBW as Postgres primary
participant Q as Job queue
QB->>DBW: BEGIN — GetQuotaForUpdate (lock_version = v)
QB->>DBW: OPCIDeductionQuota WHERE lock_version = v
Note right of DBW: another WABA already bumped lock_version to v+1
DBW-->>QB: rowAffected = 0
QB->>DBW: ROLLBACK
QB->>Q: re-enqueue deduction (credited_to = retry-deduction)
Note over Q: bounded retries (align to 3) — on exhaustion log billing_deduction_failed
Aggregated balance read — Package Usage page (with cache)
sequenceDiagram
actor U as Company Admin
participant FE as hub-chat
participant HS as hub_service-api
participant HC as hub_core BillingInfo
participant Cache as Redis
participant QB as qontak-billing
U->>FE: open /subscriptions/packages
FE->>HS: GET /api/core/v1/billings/info
HS->>HC: BillingInfo(org)
HC->>Cache: GET billing_info:{company}
alt cache hit
Cache-->>HC: aggregated balance + billing_version
else miss
Cache-->>HC: nil
HC->>QB: GET /iag/v1/quota-managements/info (company_id)
QB-->>HC: pool WABI/Additional/Postpaid remaining
HC->>Cache: SET billing_info:{company} TTL short
end
HC-->>HS: aggregated balance + billing_version = 3.0.0
HS-->>FE: 200
FE-->>U: single pool figure + tooltip (V3)
Note over HS,QB: on timeout return last-known cache + log billing_aggregate_balance_timeout
Detail 2.3 — Database Model (DDL)
The billing tables already exist (grounded). Phase 1 DDL is one constraint change plus preference/config seeding — no new tables.
-- Decision 1 (Option A): allow N WhatsappPackages (WABAs) to share one OrganizationPackage.
-- Reverses hub_core migration 20260113000000's unique index.
-- Gate live behind the existing `waba_save_organization_id` preference before backfill.
DROP INDEX IF EXISTS index_whatsapp_packages_on_organization_package_id;
CREATE INDEX index_whatsapp_packages_on_organization_package_id
ON whatsapp_packages (organization_package_id); -- non-unique
- Existing tables (no schema change):
organization_package_component_initials/_additionals/_postpaid_limits(organization_package_id,component_code,initial_quota,remaining_quota,usage_quota,lock_version—qontak-billing/db/migrations/20251115031742_*,...757,...805);billing_components(is_initial_monthly_reset,is_carry_over_monthly,is_carry_over_contract,threshold_running_out);wa_conversation_logs(waba_id,organization_package_id,credited_to,total_price,is_auto_deduct). - Cardinality / growth: WhatsappPackage rows grow by number of WABAs per company (small, ×N per Coexistence). Quota rows unchanged (1 pool/company/component).
billing_log/wa_conversation_logsgrow per billable message (existing rate; 90-day TTL per PRD §4.1). - PII:
wa_conversation_logs.customer_name,.phone_numberare Lockbox-encrypted (hub_core/.../models/billing/wa_conversation_log.rbhas_encrypted).waba_idis not PII. - Retention:
wa_conversation_logs90 days (existing TTL);billing_alert_logsper existing policy; component quota snapshots per contract lifecycle (PRD §4.1). - Per-status lifecycle:
organization_packages.billing_versionis the gate, not a lifecycle enum; no status-enum table introduced.n/a — no new status enum(deduction bucket "state" is transient, modeled in the state diagram, not persisted as a status column). - Partition / sharding: none (reuse existing).
- NoSQL alternative: rejected — money-critical, transactional, already relational with optimistic locking.
Detail 2.4 — APIs
Route note: callers (hub_core/moderator-be) use the gateway path
MEKARI_API_BASE_URL/internal/qontak/billing/v1/quota-managements/*; qontak-billing registers/iag/v1/quota-managements/*(rest_router.go:91). Confirm the gateway mapping (§5 Open Q); theapi_spec.yaml/iag/v1paths are authoritative for the service.
Outbound endpoints (consumers call us)
| Endpoint | Method | AuthN/AuthZ | Request | Response | Status | Idempotency | Versioning | Reuse? |
|---|---|---|---|---|---|---|---|---|
/api/core/v1/billings/info | GET | OAuth2 :admin,:owner,:supervisor,:agent,:member | none (org from token) | + billing_version, aggregated wabi/additional/postpaid | 200/401/422 | n/a (read) | v1 additive | extended |
/api/core/v1/billings/mcc_logs | GET | OAuth2 :admin,:owner,:supervisor,:agent,:member,:bot | waba_id?, start_date?, end_date?, pagination | rows incl. waba_id (pref ON) | 200/401/422 | n/a | v1 | reused (param exists) |
/api/core/v1/billings/download_broadcast_deduction | GET | OAuth2 :modpanel,:admin,... | organization_id?, dates, waba_id? | CSV blob (+WABA ID col pref ON) | 200/401/422/504 | n/a | v1 | reused |
POST /iag/v1/quota-managements/deduction (via gateway) | POST | internal SSO + X-Api-Key | company_id, billing_code, deduction_code, unique_code, quantity, is_free, extra_attrs{waba_id} | value_before, value_after, credited_to | 200/400/500 | unique_code (dedup) | /iag/v1 | extended (pool-correct) |
PUT /iag/v1/quota-managements/components/{parent_component_code}/update | PUT | internal SSO + X-Api-Key | RegisterOrUpdateComponentRequest (postpaid/additional codes, flags) | {id} | 200/400 | upsert by code | /iag/v1 | reused |
GET /iag/v1/quota-managements/info / info/{billing_code} | GET | SSO (client) | company_id | pool quota info | 200 | n/a | /iag/v1 | reused |
PUT /iag/v1/quota-managements/components/{company_id}/invalidate-cache | PUT | internal SSO | company_id | ok | 200 | n/a | /iag/v1 | reused |
Inbound webhooks (other services call us)
n/a — reason: Phase 1 introduces no new inbound webhook. WABA registration (POST /api/core/v1/whatsapp_embedded_signup/waba) already exists and already sets organization_package_id (hub_core/.../services/billing/wa_package.rb:213-240); Meta's signup callback is upstream and unchanged. Top-up webhooks (Mekari Pay / Paid Invoice) are existing and unchanged.
Per-endpoint notes: pagination on mcc_logs is offset-based (use :offset_pagination, billings.rb). CSV export 504 on timeout (PRD WABA-S05/ERR-2). Deduction unique_code is the idempotency key (request/quota_management.go).
Detail 2.A — Data Integrity Matrix
| Write path | Transaction scope | Partial failure | Idempotency key + TTL | Consistency | Duplicate-event | Stale-read |
|---|---|---|---|---|---|---|
| Deduction (bucket update + billing_log) | single Go tx in qontak-billing (BeginTx/WithTx) | rowAffected 0 → rollback + requeue | unique_code (per conversation event) | strong (row + lock_version) | unique_code dedup | n/a (FOR UPDATE within tx) |
| WABI monthly reset | per-pool tx; lock_version reset | retry 3 → wabi_reset_failed + on-call | pool + cycle | strong | reset idempotent per cycle | n/a |
| Carry-over on renewal | upsert carry_over_quota_items | fail → wab_additional_carry_over_failed + on-call | company_id+ref_external_id | strong | upsert | n/a |
/billings/info aggregated read | read-only + Redis SET | cache miss → live read; timeout → last-known | cache key billing_info:{company} short TTL | eventual (cache) | n/a | short stale window acceptable (PRD: refresh-based) |
Detail 2.B — Concurrency Collision Map
| Resource | Writers | Collision | Resolution | Behavior on lock fail |
|---|---|---|---|---|
organization_package_component_initials/_additionals/_postpaid_limits.remaining_quota (shared pool row) | concurrent deductions from N WABAs; monthly-reset worker | two deductions read same lock_version | optimistic lock WHERE id=? AND lock_version=? + increment | rowAffected 0 → rollback + requeue (bounded, Decision 2); never over-deduct |
billing_info:{company} cache | deduction (invalidate), top-up, page reads | stale after write | invalidate-cache endpoint on write + short TTL | serve last-known on read; refresh next miss |
Detail 2.C — Async Job / Event Consumer Spec
| Job/Consumer | Trigger | Input | Retry | DLQ | Concurrency | Idempotency | Timeout | Poison handling |
|---|---|---|---|---|---|---|---|---|
QuotaManagementDeduction (requeue) | lock conflict (rowAffected==0) | DeductionRequest | bounded (align 3) via job max_fails | queue deadletter | queue-managed | unique_code | queue default | on exhaustion → billing_deduction_failed + manual-reconcile flag |
| Monthly reset worker | cron (cycle start) | packageID / company | 3 → wabi_reset_failed + on-call | deadletter | per-package | per cycle | worker default | alert #bifrost-billing-alerts |
| Carry-over worker | contract renewal | subscription payload | warn on fail (carryOverAdditionalQuota L422-451) | n/a | per-company | ref_external_id | worker default | wab_additional_carry_over_failed |
QuotaManagementAlert | remaining ≤ threshold post-deduction | company_id | queue retry | deadletter | per-company | dedup key low_balance:{org}:{cycle} (net-new) | worker default | drop after dedup window |
Detail 2.D — Responsibility Boundary Matrix
| Step (execution order) | Owning service | Inbound trigger | Outbound effect | Failure handler | PRD anchor |
|---|---|---|---|---|---|
| 1. Message send → build deduction body (stamp waba_id) | hub_service + hub_core | agent sends billable msg | POST deduction to gateway | log; don't block delivery | §7.2, WABA-S04 |
| 2. Resolve pool + deduct in bucket order | qontak-billing | deduction request | update quota row + billing_log | rollback + requeue | §7.2, WABA-S04/AC-1 |
| 3. Threshold/below-zero alert | qontak-billing worker | post-deduction remaining | emit low_balance_warning/balance_below_zero | retry; never block deduction | §7.7, WABA-S06 |
| 4. Monthly WABI reset | qontak-billing worker | cron | reset initial remaining | 3 retries + on-call | §7.3, WABA-S01 |
| 5. Postpaid config / top-up | moderator-be → qontak-billing | Finance action | components/{code}/update / additional credit | 403 unauthorized; log | §7 rows, WABA-S02/S03 |
| 6. Aggregated balance display | hub-chat → hub_service → hub_core → qontak-billing | page load | read + cache | last-known on timeout | §7.1, CHG-003 |
No disagreement with the PRD's squad allocation found; all steps are Bifrost-owned (billing-service is a Bifrost sub-team). If the billing-service owner disputes step 2's aggregation ownership, that is an Open Question.
Detail 2.E — State Surface Contract
| Entity | State field / event | Default | Updated by | Read via | Stale window |
|---|---|---|---|---|---|
| Shared pool balance | remaining_quota (3 buckets) | initial_quota at reset | deduction / reset / top-up | /billings/info (cached) | short cache TTL |
| Deduction attribution | wa_conversation_logs.waba_id, credited_to | — | deduction billing_log | /mcc_logs, /download_broadcast_deduction | none (persisted) |
| Low-balance state | low_balance_warning / balance_below_zero event; banner flag | not shown | alert worker | banner reads balance vs threshold | until next page load |
| V3 gate | organization_packages.billing_version | "1.0.0" | provisioning (moderator-be flags) | /billings/info billing_version | none |
Detail 2.G — Cross-Layer Contract Verification
| Endpoint | Satisfies Figma frame? | Satisfies PRD-to-Schema row? | Match? |
|---|---|---|---|
GET /billings/info (+billing_version, aggregated balance) | balance section + tooltip (frame pending) | "single shared pool" + "tooltip V3-only" rows | pending frame — contract derivable from PRD; FE gates on billingM1Version regardless of frame |
GET /mcc_logs?waba_id= | MCC table + filter (frame pending) | "which WABA originated" row | yes (BE ready; filter FE net-new) |
GET /download_broadcast_deduction | broadcast table + CSV (frame pending) | "waba_id gated by preference" row | yes (BE ready) |
POST /iag/.../deduction | n/a — backend | hierarchy + shared-pool rows | yes (extended per Decision 1) |
Rows marked pending frame are blockers only for pixel-faithful FE chunks; the API contract itself is unblocked (derivable from the PRD-to-Schema table). Tracked in §5.
Detail 2.F — Frontend Implementation Contract (FE-owner task — to be specified before FE chunks 9–11)
Ownership. This section is deliberately left for the Frontend owner (Syafrizal M.) to fill, in collaboration with Design. The backend contracts, the gating logic (Decision 6:
pref && billingM1Version), and the data plumbing are already decided and grounded; what remains is FE component specification and design fidelity. The backend chunks (1–6) do not depend on this section and can proceed in parallel. Review findingsREV-2/3/4/5/6all live here and clear when it is filled.
(a) Design inputs needed first — from Design (Bulan). Frame-level Figma links (direct to the frame, not the file root) for every surface below; until each lands, its FE chunk stays blocked (REV-2):
- Package Usage aggregated balance section for V3 — single WABI / WAB-Additional / Postpaid figures replacing the per-WABA view (CHG-003).
- Shared Balance Tooltip — info-icon placement next to the balance heading + popover copy + hover/click behavior (WABA-S07).
waba_idcolumn styling in the Broadcast deduction table and the Conversation/MCC log table (WABA-S05, CHG-001/002).waba_idfilter dropdown on the MCC view — control placement, default state, option list, and the no-match empty state (WABA-S05/AC-3,4).- Low-balance banner — the
warningvariant and the distinctbelow-zerovariant: color, copy, CTA, dismiss behavior (WABA-S06).
(b) Component contracts the FE owner then specifies (REV-3). For each net-new component, pin the Props interface + emitted events, anchored to the existing @mekari/pixel3@1.0.12 patterns already in the repo (<mp-tooltip>/v-mp-tooltip per InvoicesDetailPage.vue:159-167; <MpBanner> per BannerRingGroup.vue):
SharedBalanceTooltip— props: V3-gate (bound tobillingM1Version), tooltip copy; no events (static). File:hub-chat/features/subscriptions/packages/SharedBalanceTooltip.vue.LowBalanceBanner— props:state: 'warning' | 'below-zero', aggregated balance/threshold; events:dismiss. File:.../packages/LowBalanceBanner.vue.- MCC
waba_idfilter dropdown — props: option source (distinctwaba_ids), selected value; event:@change→ sets thewaba_idquery param onGET /mcc_logs. Lives in.../usages/index.vue+TableComponentWhatsappBalance.vue.
(c) UI-state matrix (REV-4). Define all five states (loading / empty / error / partial / success) per report surface. The PRD WABA-S05 "UI States" block already enumerates them (skeleton rows; "No records found"; "No records found for WABA ID [id]"; retry error; populated) — port them into a matrix so an agent doesn't invent them.
(d) Accessibility (REV-5). Keyboard reachability + focus management + ARIA for: the tooltip trigger (focusable, aria-describedby), the banner (role/live-region for below-zero), and the filter dropdown (combobox semantics).
(e) Analytics (REV-6). Define the event name + payload for the tooltip-interaction-rate metric the PRD §11 targets (≥20% of V3 sessions) — e.g. billing.shared_balance_tooltip.interact with { organization_id, billing_version }.
Gating (already decided — not the FE owner's to re-decide). Column/filter/tooltip visibility = preference (billing_report_show_waba_id) && billingM1Version (Decision 6). The waba_id columns already render today (data-driven hasWabaId); the change is only to swap that gate. BillingStore.BillingInfo must gain billing_version, and AppConfig must gain billing_reports.show_waba_id (execution-plan chunk 7).
3. High-Availability & Security
The deduction path is money-critical and already HA in qontak-billing (stateless pods, optimistic-locked writes, job-queue retries). This RFC adds no new stateful component. Graceful degradation: /billings/info serves last-known cached balance on qontak-billing timeout; deduction never blocks message delivery on alert/notification failure (PRD WABA-S04/ERR-1, WABA-S06/ERR-1).
Performance Requirement
- Aggregated balance read: p95 ≤ 500ms (PRD §4) via Redis cache; live read on miss.
- Package Usage page ≤ 2s; report table ≤ 2s for ≤500 rows; CSV ≤ 10s for ≤10k rows (PRD §4).
- Deduction throughput: unchanged from today's per-message rate; contention handled by optimistic lock + requeue.
- Scalability: reuse existing HPA on qontak-billing/hub_service pods; no new scaling factor.
- Load test: replay concurrent multi-WABA sends against one pool; assert no over-deduction and requeue rate within budget.
Monitoring & Alerting
Named per PRD §10 (reuse existing Datadog billing dashboard + billing_alert_logs):
- Metrics/events:
billing_deduction_completed,billing_deduction_failed,quota_exceeded,wabi_reset_completed/wabi_reset_failed,wab_additional_carried_over/_failed,low_balance_warning,balance_below_zero(net-new),low_balance_notification_failed,broadcast_report_export_timeout,billing_aggregate_balance_timeout. - Alert 1:
billing_deduction_failed> 0.1% of events / 5 min → PagerDuty Bifrost on-call. - Alert 2:
wabi_reset_failedfor any org → Slack#bifrost-billing-alertswithin 15 min. - Alert 3:
billing_aggregate_balance_timeout> 1% / 5 min → PagerDuty. - Trace spans: reuse qontak-billing OpenTelemetry spans on the deduction usecase.
Logging
- Structured: qontak-billing
log/slogwithcompany_id,waba_id,unique_code,credited_to,quota_type; hub_coreRails.logger+ Rollbar. - PII:
customer_name/phone_numberremain Lockbox-encrypted; never log raw.waba_id,company_idare safe to log.
Security Implications
- Threat model: cross-tenant balance read/deduction; unauthorized postpaid config. Entry points:
hub_serviceOAuth2 endpoints, qontak-billing internal SSO + X-Api-Key. - Multi-tenancy isolation: every deduction and quota read is keyed by
company_id;hub_serviceresolves org from the token (me.organization_id) and rejects cross-org (helpers.rbuser_is_from_different_organization?).
Role × Endpoint Authorization Matrix
| Role | Endpoint(s) | Methods | Tenant scope | Constraint | Audit |
|---|---|---|---|---|---|
| Admin / Owner | /billings/info, /mcc_logs, /download_broadcast_deduction | GET | own org | full | billing_log, PackageLog |
| Supervisor / Agent / Member | /mcc_logs, reports permitted | GET | own org | read-only | billing_log |
| Bot | /mcc_logs, /download_broadcast_deduction | GET | own org | read-only | billing_log |
| Modpanel / Finance | qontak-billing components/{code}/update, top-up | PUT/POST | cross-tenant (ops) | Mekari SSO + X-Api-Key | qontak-billing billing_log + moderator-be audit |
| System (workers) | deduction / reset / alert | internal | all V3 orgs | service token | billing_log, billing_alert_logs |
- Input validation: qontak-billing validates
quantity >= 0.01, requiredcompany_id/billing_code/deduction_code/unique_code(request/quota_management.govalidatetags). - Injection: sqlc parameterized queries (no string SQL); Grape strong params.
- Secrets:
MEKARI_API_BASE_URL+ SSO/X-Api-Key via env/config, not hard-coded. - Static analysis: Go
staticcheck(qontak-billing/staticcheck.conf); Ruby Brakeman (existing CI); FE ESLint + SonarScanner (hub-chat/sonar-scanner.properties). - Compliance: touches billing + encrypted PII (customer name/phone) → UU PDP / ISO 27001 applies; retention 90 days for logs. See Detail 3.C.
Detail 3.A — Failure Mode & Retry Catalog
| External call | Timeout | Retries | Circuit breaker | DLQ | Persistent-failure behavior |
|---|---|---|---|---|---|
| hub_core → qontak-billing deduction | client @timeout (quota_management.rb) | requeue bounded (align 3) | n/a (job requeue) | queue deadletter | billing_deduction_failed + manual reconcile; delivery not blocked |
hub_core → qontak-billing quota info (/billings/info) | short (≤500ms budget) | none (serve cache) | n/a | n/a | last-known cache + billing_aggregate_balance_timeout |
| monthly reset | worker default | 3 | n/a | deadletter | wabi_reset_failed + on-call |
| notification delivery | provider default | 3 exp. backoff | n/a | n/a | low_balance_notification_failed; don't block deduction |
Detail 3.A.1 — Branch & Skip Catalog
| Branch trigger | Where checked | Downstream effect | Audit | User-visible? |
|---|---|---|---|---|
| Non-billable/failed message | hub_core deduction body build | no deduction, no billing_log (or total_price=0) | none | no |
is_free = true | qontak-billing deduction (req.IsFree) | no threshold alert (skips alert calc) | billing_log.is_free, free_reason | no |
billing_report_show_waba_id OFF | hub_service + FE (Decision 6) | waba_id column/filter/CSV col absent | none | yes (column hidden) |
V1/V2 org (billing_version != 3.0.0) | everywhere (is_billing_v3/billingM1Version) | entire shared-pool/tooltip/column path skipped | none | yes (unchanged UI) |
| Low-balance dedup within cycle | alert worker (net-new key) | suppress duplicate notification | dedup log | no |
Detail 3.B — Error Response Catalog
| Endpoint | Error code | HTTP | Message | When | User-facing? |
|---|---|---|---|---|---|
| deduction | quota_exceeded | 200 (credited_to signals) / business error | all buckets exhausted | postpaid = 0 | yes (send blocked) |
| deduction | retry-deduction (credited_to) | 200 | lock conflict | concurrent send | no (internal requeue) |
/billings/info | billing_aggregate_balance_timeout (log) | 200 (cached) | — | qontak-billing slow | no |
/download_broadcast_deduction | export timeout | 504 | "Export is taking too long. Try reducing the date range." | >10k rows | yes |
/mcc_logs?waba_id= no match | — | 200 empty | no rows for WABA | filter no match | yes ("No records found for WABA ID") |
| qontak-billing validation | 400 | 400 | {resp_code, resp_desc{id,en}} "Invalid request: ..." | bad quantity/params | no (internal) |
Detail 3.C — Compliance & Data Governance
| Field | Classification | Legal basis | Retention | Encryption | Access audit | Right-to-delete |
|---|---|---|---|---|---|---|
wa_conversation_logs.customer_name, .phone_number | PII | UU PDP | 90 days | Lockbox at rest + TLS transit | billing_log | existing customer-data deletion flow (unchanged) |
waba_id, company_id, organization_package_id | non-PII business identifiers | — | with record | TLS transit | billing_log | n/a |
| balance / quota values | sensitive (financial) | ISO 27001 | contract + audit | TLS transit | billing_log, billing_alert_logs | n/a |
4. Backwards Compatibility and Rollout Plan
Compatibility
- Endpoints: all changes additive —
/billings/infogains fields (billing_version, aggregated balance);mcc_logs/download_broadcast_deductionalready acceptwaba_id; deduction request/response shape unchanged. No breaking change. - V1/V2: entirely unaffected — every new path gates on
billing_version = "3.0.0"/billingM1Version/is_billing_v3(PRD Non-Goal 2, §9 backward-compat). - Schema: the only DDL is dropping+recreating the
whatsapp_packages.organization_package_idindex as non-unique — additive to write capability, no data loss.
Rollout Strategy
Deploy order (dependencies first): (1) qontak-billing pool-correct deduction + index migration (behind confirm of Decision 1) → (2) backfill organization_package_id on all V3 WhatsappPackage rows + verify 100% coverage → (3) hub_core/hub_service /billings/info aggregation + billing_version field → (4) FE preference gating + tooltip + banner + filter → (5) enable billing_report_show_waba_id per CID in batches.
- Migration sequence: add non-unique index (keep old data) → backfill FK → enable many-to-one WABA creation behind
waba_save_organization_id→ verify → GA. - Schema during migration: index is non-unique throughout after step 1; no dual-write needed (single pool).
- Backfill: batch script setting
organization_package_idon existing V3WhatsappPackagerows; verification query asserts 100% coverage before Stage 1 (PRD §13 blocking dependency). - Feature flags:
waba_save_organization_id(existing — gates many-to-one),billing_report_show_waba_id(existing — default OFF, per-CID),billingM1Version(derived, not a flag). Shared-pool/deduction active for all V3 (no separate flag) per PRD §9. - Stages (PRD §12): Internal Alpha (≤5 Bifrost V3 CIDs, 2w) → Closed Beta (5–10 V3 clients w/ ≥2 WABAs, 2w) → Staged 25%→50%→100% (3w) → GA.
- Rollback trigger:
billing_deduction_failed> 0.5% in a week orquota_exceededspike unresolved in 4h (PRD §10.1) → revert. - Rollback mechanism: disable
waba_save_organization_idfor affected CIDs (reverts to per-WABA package creation); FE flag OFF hides columns; qontak-billing deduction change is behind the same gate. Data written during rollout (shared-pool deductions) remains valid. - Blast radius: V3 orgs only; worst case a V3 company's balance display/deduction — mitigated by cache fallback + optimistic lock (no over-deduction).
Detail 4.A — Configuration Contract
| Flag / config | Type | Default | Required | Provisioner | Secret? |
|---|---|---|---|---|---|
waba_save_organization_id | preference (Flipper/Redis) | OFF | yes (gates Decision 1) | hub_core Services::Preference | no |
billing_report_show_waba_id | preference per CID | OFF | yes (WABA-S05) | hub_core Services::Preference / Modpanel | no |
billing_reports.show_waba_id (FE mirror) | appConfig bool | OFF | yes | /client_configs/config | no |
threshold_running_out | DB column DECIMAL(5,2) on billing_components | 40% (existing default when NULL) | already exists | qontak-billing (billing_components, shared DB) | no |
MEKARI_API_BASE_URL | env | prod gateway | yes | deploy config | no (URL) |
Detail 4.B — Test Plan (commands sourced from repos)
| Layer | Command (source) | What it must prove |
|---|---|---|
| Go unit/integration (qontak-billing) | make test → CGO_ENABLED=1 go test -race -coverprofile=coverage.out ./internal/app/... (source: qontak-billing/Makefile:68-72) | deduction hierarchy + pool aggregation + lock conflict requeue; no over-deduction under -race |
| Go migration | make migrate-up (source: qontak-billing/Makefile:115-120) | non-unique index applies + rolls back |
| Ruby (hub_core/hub_service) | RAILS_ENV=test bundle exec rspec app --tag ~@is_skip_pipeline (source: hub_core/bitbucket-pipelines.yml; hub_service/bitbucket-pipelines.yml:166) | /billings/info returns billing_version+aggregated; deduction body stamps waba_id; V1/V2 unchanged |
| Ruby (moderator-be) | bundle exec rspec app (source: moderator-be/Makefile:83-84; bitbucket-pipelines.yml:74) | postpaid config → components/{code}/update; V3 top-up path |
| FE unit (hub-chat) | pnpm test → vitest --dom --pool=forks (source: hub-chat/package.json:15) | column/filter/tooltip/banner gate on pref && billingM1Version; V1/V2 no column |
| FE lint | pnpm lint (source: hub-chat/package.json:13) | lint clean |
Detail 4.C — Agent Execution Plan
| Order | Chunk | Files to modify/create | Commands | Acceptance criteria |
|---|---|---|---|---|
| 1 | qontak-billing: make GetQuotaForUpdate pool-correct for a company's single shared package (Decision 1) | qontak-billing/db/queries/organization_package_components.sql; regenerate sqlc; internal/app/usecase/quota_management/deduction.go | sqlc generate; make test | go test: company with N WABAs deducts from one pool in WABI→Add→Postpaid order; -race clean |
| 2 | qontak-billing: bounded retry + billing_deduction_failed on exhaustion (Decision 2) | internal/app/usecase/quota_management/deduction.go; job config | make test | test: concurrent deductions never over-deduct; exhaustion logs billing_deduction_failed |
| 3 | hub_core: drop unique index migration; allow many WABAs → one package (Decision 1) | hub_core/database/billing/db/migrate/2026xxxx_drop_unique_org_package_id_index.rb; app/core/domains/services/billing/wa_package.rb | RAILS_ENV=test bundle exec rails app:db:migrate; rspec app/core/domains/services/billing/wa_package_spec.rb | migrate up/down works; 2nd WABA creates new WhatsappPackage on same organization_package_id when waba_save_organization_id ON |
| 4 | Backfill organization_package_id for existing V3 WhatsappPackages + verify | hub_core rake task/script | run backfill on staging; verification query | 100% of V3 WhatsappPackage rows have organization_package_id |
| 5 | hub_core/hub_service: /billings/info returns billing_version + aggregated balance + Redis cache (Decision 3) | hub_core Billings::BillingInfo builder; hub_service/.../billings.rb | rspec app/... billings_spec.rb | response includes billing_version:"3.0.0" + aggregated WABI/Add/Postpaid; timeout → cached + log |
| 6 | qontak-billing: net-new balance_below_zero event + low-balance dedup key (Decision 5) | internal/app/usecase/worker/quota_management_alert.go | make test | test: below-zero emits distinct event; duplicate crossing within cycle suppressed |
| 7 | FE: add billing_version to BillingStore, billing_reports.show_waba_id to AppConfig | hub-chat/common/store/BillingStore.ts; common/store/AppConfigStore.ts | pnpm test | store exposes billing_version; appConfig exposes flag |
| 8 | FE: re-gate waba_id column to pref && billingM1Version on both tables (Decision 6) | TableComponentWhatsappBalance.vue, TableComponentCampaignBroadcast.vue | pnpm test | vitest: column shown iff pref ON AND V3; hidden for V1/V2 and pref OFF |
| 9 | FE: waba_id filter dropdown on MCC view (net-new) | hub-chat/pages/subscriptions/usages/index.vue + TableComponentWhatsappBalance.vue | pnpm test | filter passes waba_id to /mcc_logs; empty-state on no match |
| 10 | FE: SharedBalanceTooltip.vue (V3 only) + aggregated balance display (WABA-S07, CHG-003) | hub-chat/features/subscriptions/packages/SharedBalanceTooltip.vue, UsageDetail.vue | pnpm test | tooltip renders for V3 only; hidden when billing info fails (fail-safe) |
| 11 | FE: LowBalanceBanner.vue aggregated (WABA-S06) | hub-chat/features/subscriptions/packages/LowBalanceBanner.vue, PackageDetails.vue | pnpm test | banner shows below threshold / below-zero; clears when restored |
Detail 4.D — Verification & Rollback Recipe
- Pre-merge (in order):
- qontak-billing:
make test(sourceMakefile:68-72) —-raceclean. - hub_core + hub_service:
RAILS_ENV=test bundle exec rspec app --tag ~@is_skip_pipeline. - moderator-be:
bundle exec rspec app. - hub-chat:
pnpm lint && pnpm test.
- qontak-billing:
- Post-deploy signals:
- Datadog billing dashboard:
billing_deduction_failedrate < 0.1% over 15 min;quota_exceededwithin baseline;billing_aggregate_balance_timeout< 1%. wabi_reset_completedcount == active V3 orgs on cycle start; zerowabi_reset_failed.- Manual: for a V3 test CID with 2 WABAs, aggregated balance on Package Usage == sum of pool buckets; a send from each WABA both draw the same pool.
- Datadog billing dashboard:
- Rollback recipe (in order):
- Disable
waba_save_organization_idfor affected CIDs (reverts to per-WABA package creation). - Set
billing_report_show_waba_idOFF for affected CIDs (hides columns/filter). - If deduction regression: revert the qontak-billing deduction PR (
GetQuotaForUpdatechange) — pool falls back to single-row behavior. make migrate-downon the index migration only if the non-unique index causes an issue (rare — non-unique is a superset).- Confirm
billing_deduction_failedreturns < 0.1% within 15 min.
- Disable
Detail 4.E — Resource & Cost Notes
- Compute: no new pods; reuse existing HPA on qontak-billing/hub_service/hub-chat.
- DB: +1 Redis key per company for balance cache (short TTL); WhatsappPackage rows +N per multi-WABA company (small).
- Network: one extra intra-cluster read on
/billings/infocache miss. - Storage:
wa_conversation_logs/billing_loggrowth unchanged (existing message rate, 90-day TTL).
5. Concern, Questions, or Known Limitations
Resolved / not a concern:
Decision 1 confirmation (Assumption 4).RESOLVED 2026-07-01 — the initiative DRI confirmed a V3 company has exactly oneorganization_package. Decision 1 Option A stands; the execution plan is no longer conditional.Low-balance threshold.NOT A CONCERN — already implemented:billing_components.threshold_running_out DECIMAL(5,2)(qontak-billing migration20260126000001_*) with a 40% default when NULL, read indeduction.go:299-301/quota_management_alert.go:97-98. Phase 1 reuses it; a per-org override is explicitly out of scope (see Decision 5).
Blocking (must resolve before Ready: yes) — all owned by the Frontend owner:
- Frontend implementation contract (Detail 2.F). Design frames, net-new component contracts, UI-state matrix, a11y, and analytics for the FE surfaces — see Detail 2.F. Consolidates review findings
REV-2(Figma frames),REV-3(component prop/event contracts),REV-4(5-state UI matrix),REV-5(accessibility),REV-6(analytics event). The backend half (chunks 1–6) is unblocked and can proceed in parallel; only FE chunks 9–11 wait on this.
Non-blocking (verify during execution):
2. Gateway path mapping. Confirm MEKARI_API_BASE_URL/internal/qontak/billing/v1/quota-managements/* (callers) → qontak-billing /iag/v1/quota-managements/* (registered). Treat api_spec.yaml as authoritative (REV-12).
3. Shared billing DB. Confirm organization_package_component_* are physically the same tables across hub_core/moderator-be/qontak-billing. qontak-billing is the write authority for deduction.
4. Contract-renewal carry-over trigger. carryOverAdditionalQuota exists (monthly_reset_billing_component.go:422-451) but the contract-renewal trigger (vs monthly) was not fully traced — verify is_carry_over_contract path for WABA-S02/AC-3.
5. WaConversationLog.status. PRD WABA-S04 references a status field; it does not exist today. Billable/failed is decided upstream — confirm no column needed, or add it (REV-9).
6. Typed /billings/info fields + cache TTL. Pin the aggregated response field names/types (REV-8) and the balance-cache TTL + invalidation ordering (Decision 3).
7. credited_to vocabulary. REV-7 — dropped 2026-07-01 by owner; reconciled at implementation time, not a spec task.
Known limitations: balance display is refresh-based (no live push, PRD Non-Goal 4); no per-WABA sub-quota (Non-Goal 1/5); historical logs not backfilled with waba_id (Non-Goal 7).
See the companion review rfc-one-cid-multiple-waba-phase-1-review.md for the full findings ledger (REV-n) and scores.
6. Comment logs
| Date | Comment(s) From | Action Item(s) |
|---|---|---|
| 2026-07-01 | RFC author (Claude, grounded across hub_core / hub_service / moderator-be / qontak-billing / hub-chat) | Initial draft. All 10 mermaid blocks validated with mmdc. Three blocking Open Questions routed to Bifrost Eng Lead + billing-service owner + PM/Design. |
| 2026-07-01 | Initiative DRI | Confirmed a V3 company has exactly one organization_package → Decision 1 Option A locked; blocking Open Questions reduced to two (threshold value, Figma frames). |
| 2026-07-01 | rfc-reviewer (R1) | Reviewed at 6.5/10, HOLD. Backend half PROCEED-ready; FE contract layer + external blockers gate full readiness. Findings ledger in the companion review; open findings promoted to §5. |
| 2026-07-01 | Owner | Two refinements: (1) low-balance threshold omitted as a concern — already implemented (billing_components.threshold_running_out, 40% default), REV-1 closed; (2) all design/FE concerns consolidated into the FE-owner task (Detail 2.F), REV-2/3/4/5/6. Backend half is now the only PROCEED-ready path; FE half waits on Detail 2.F. |
7. Ready for agent execution
- Backend half: yes — chunks 1–6 are grounded, decided, and unblocked; a coding agent can execute them now.
- Frontend half: no — gated on the single FE-owner task in Detail 2.F (design frames + component contracts + UI-state + a11y + analytics). Decision 1 is confirmed and the low-balance threshold is already implemented, so no backend/product blockers remain.
Gate status:
- Infrastructure Topology — yes (deployment + per-service diagrams; real services only).
- Technical Decisions — yes (6 ADR blocks; minimum coverage addressed). Decision 1 confirmed 2026-07-01 (one company = one org package).
- §1 PRD-to-Schema Derivation — yes (every DDL/endpoint traces back).
- Detail 1.B / 1.C — yes (index + per-story map; all 7 stories mapped).
- Repo Reading Guide + Source Verification — yes (every anchor cited with file:line evidence across 5 repos).
- Mermaid diagrams — yes (topology, per-service, repo map, component, ER, state, 3 sequences, branch/skip; all parse — see Comment log).
- DDL — yes (one index change; existing tables reused; per-status lifecycle marked n/a with reason).
- APIs — yes (outbound + inbound; every endpoint tagged reused/extended/new).
- Data Integrity / Concurrency / Async specs — yes.
- Responsibility Boundary / Branch & Skip / Failure / Error catalogs — yes.
- Configuration Contract — yes (threshold value flagged undecided — Open Q2).
- Agent Execution Plan — yes (11 ordered chunks, files + commands + verifiable AC).
- Verification & Rollback Recipe — yes (commands runnable; signals named; rollback ordered).
Resolve Open Questions 1–3, then flip to yes and (optionally) hand to rfc-reviewer.