Skip to main content

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 — reason are 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 reads 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 shared fields.

Metadata

FieldValueNotes
StatusIDEAYAML status: carries the remapped linter enum draft
DRIaddo.hernando@mekari.comSingle accountable owner (initiative DRI). Per-task staffing lives in delivery/.
TeambifrostAdvisory squad slug from the PRD / initiative README
Author(s)Grehasta Mahardika (BE), Syafrizal M. (FE)Primary authors / implementors
ReviewersBifrost Eng Lead; qontak-billing service ownerCross-squad tech reviewers (the deduction engine is owned by the billing service)
Approver(s)Bifrost Tech Lead; InfosecTech leaders + infosec approver
Submitted Date2026-07-01Date RFC opened for discussion
Last Updated2026-07-01Bump on every material edit
Target Release2026-Q3Quarter
Target Quarter2026-Q3Advisory, from PRD / initiative README
Deliverynot yet handed to deliveryWill point at ../delivery/timeline.md once handed off
RelatedPRD — One CID Multiple WABA Phase 1Source PRD
Discussion#bifrost-billing (TBD thread)Slack

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

Sections at a Glance

  1. Overview (Design References — FE half; PRD-to-Schema Derivation — BE half; traceability; per-story change map)
  2. Technical Design (Infrastructure Topology → Technical Decisions [ADR] → Repo Reading Guide → Architecture → Sequence diagrams → DDL → APIs → integrity/concurrency/async → cross-layer verification)
  3. High-Availability & Security
  4. Backwards Compatibility and Rollout Plan (incl. Agent Execution Plan + Verification & Rollback Recipe)
  5. Concern, Questions, or Known Limitations
  6. Comment logs
  7. 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_core already carries the organization_package_id FK on whatsapp_packages, the billing_version/billing_v3? gating, the three component quota tables, the monthly-reset workers, and stamps waba_id + organization_package_id on WaConversationLog.
  • hub_service already accepts waba_id on mcc_logs and already gates a WABA ID CSV column on the billing_report_show_waba_id preference.
  • hub-chat already renders a (data-driven) waba_id column on both report tables and already computes billingM1Version.

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_log audit vs. expected bucket order (PRD WABA-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_initial flat fields (PRD Non-Goal 2).
  • Mobile app; real-time WebSocket balance push (PRD Non-Goal 3, 4).
  • Multi-currency (PRD Non-Goal 6).
  • Historical WaConversationLog waba_id backfill (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.

Assumptions

Each is either verified by grounding (cited) or to-confirm (also in §5 Open Questions):

  1. [VERIFIED] Embedded signup already sets organization_package_id on the new WhatsappPackage when waba_save_organization_id is 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.
  2. [VERIFIED] The deduction API is already company-scoped, keyed by company_id+billing_codeqontak-billing/internal/pkg/request/quota_management.go:17-20,42-50.
  3. [VERIFIED] One billing_log row is written per deduction, carrying the primary bucket in credited_to + quota_typeqontak-billing/internal/app/usecase/quota_management/deduction.go:264-284. This answers PRD Open Question Q2 (one row, primary bucket).
  4. [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 on whatsapp_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.
  5. [TO CONFIRM] The organization_package_component_* tables are physically the same Postgres tables across hub_core, moderator-be, and qontak-billing (each repo carries migrations for identically-named tables). qontak-billing is treated as the write authority for deduction.
  6. [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 route POST /iag/v1/quota-managements/deduction.

Dependencies

DependencyOwning teamAvailabilityBlocking?
qontak-billing quota-aggregation change (Decision 1)Bifrost / billing-service ownerneeds buildingYES — 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 CIDBifrost Eng (hub_core Services::Preference)exists (mechanism) — needs per-CID seedingYES for WABA-S05
Low-balance threshold decision (default value / per-org vs global)Bifrost PM + Financenot decidedYES for WABA-S06 (PRD Open Q1)
Figma frame-level designs for tooltip / banner / report columnsDesign (Wulan/Bulan)file exists; frame links pendingYES for FE chunks (PRD §13)
Meta Coexistence embedded signup links each new WABA to the shared poolBifrost / hub_serviceverified present; Decision 1 Option A requires dropping the unique FK indexConditional 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 surfaceFigma / design linkFrame nameDesign system versionDesign QA contactNotes
Package Usage — WhatsApp balance section (aggregated)Figma — Subscriptionn/a — frame pending@mekari/pixel3@1.0.12 (hub-chat/package.json:80)Bulan (TBD)Extends existing UsageDetail.vue
Shared Balance TooltipFigma — Subscriptionn/a — frame pending@mekari/pixel3@1.0.12Bulan (TBD)Follows v-mp-tooltip / <mp-tooltip> pattern
Broadcast deduction table — waba_id columnFigma — Subscriptionn/a — frame pending@mekari/pixel3@1.0.12Bulan (TBD)Column already exists (data-driven) — re-gate to preference
Conversation/MCC log table — waba_id column + filter dropdownFigma — Subscription; sample sheetn/a — frame pending@mekari/pixel3@1.0.12Bulan (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.12Bulan (TBD)Follows <MpBanner> pattern

PRD-to-Schema Derivation (backend half — required)

PRD entity / attribute / rulePersisted as (table.column)Exposed via (endpoint / event)Enforced whereSource
A V3 company's WhatsApp balance is a single pool shared across all WABAsorganization_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/infoqontak-billing GetQuotaForUpdate (must aggregate across WABAs — Decision 1)PRD §7.1, CHG-003, WABA-S04
Deduct WABI→WAB-Additional→Postpaid, in orderbilling_logs.quota_type ∈ {initial, additional, postpaid}; .credited_to = component_codeqontak-billing POST /iag/v1/quota-managements/deductionqontak-billing/.../quota_management/deduction.go:169-241 (exists)PRD §7.2, WABA-S04
Never deduct for a failed/non-billable messageno billing_log row; caller passes billable statusdeduction caller in hub_core/app/core/domains/services/broadcasts/billing_deduction.rbhub_core builds body only for billable msgsPRD §7.2, WABA-S04/AC-2
Which WABA originated each deductionwa_conversation_logs.waba_id (exists)GET /mcc_logs?waba_id=; GET /download_broadcast_deductionhub_core stamps waba_id into log + deduction extra_attrsPRD §5, §7.5, WABA-S05
Monthly WABI reset to initial_quota, no carry-overorganization_package_component_initials.remaining_quota; billing_components.is_initial_monthly_reset=true, is_carry_over_monthly=falseqontak-billing monthly-reset workerqontak-billing/.../worker/monthly_reset_billing_component.go:107-111 (exists)PRD §7.3, WABA-S01
WAB-Additional carry-over on contract renewalcarry_over_quota_items + organization_package_component_additionals.remaining_quota; is_carry_over_contract=trueqontak-billing carry-over pathqontak-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 preferencebilling_report_show_waba_id (OrganizationPreference, Flipper/Redis)preference read in hub_service + FE appConfighub_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 balancebilling_alert_logs; alert_config (hub_core); threshold in Redis / billing_components.threshold_running_outlow_balance_warning / balance_below_zero events (net-new distinct below-zero)qontak-billing alert worker (threshold ≤40% exists); below-zero is net-newPRD §7.7, §10, WABA-S06
Tooltip visible only for V3reads billing_version (organization_packages.billing_version)GET /billings/info (must add billing_version to payload)FE billingM1Version computedPRD §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 idFE section / componentBE section / endpoint
WABA-S01/AC-1..4, ERR-1..2n/a — backend job§2 Decision 4; qontak-billing monthly-reset worker
WABA-S02/AC-1..4, ERR-1n/a — top-up UI unchanged§2.4 component register/top-up (moderator-be → qontak-billing); carry-over path
WABA-S03/AC-1..3, ERR-1n/a — Modpanel config§2.4 components/{code}/update; deduction postpaid branch
WABA-S04/AC-1..4, ERR-1..2n/a — backend§2 Decision 1 (aggregation) + Decision 2 (concurrency); POST /iag/v1/.../deduction
WABA-S05/AC-1..5, ERR-1..2TableComponentCampaignBroadcast.vue, TableComponentWhatsappBalance.vue (+ filter dropdown)§2.4 mcc_logs?waba_id=, download_broadcast_deduction; preference gate
WABA-S06/AC-1..3, ERR-1..2LowBalanceBanner.vue (net-new)§2 Decision 5; low_balance_warning/balance_below_zero
WABA-S07/AC-1..3, ERR-1SharedBalanceTooltip.vue (net-new)billing_version added to /billings/info payload

Reverse (RFC → PRD AC):

New RFC decision / artifactPRD composite AC id it serves
Decision 1 — quota aggregation across WABAsWABA-S04/AC-1, WABA-S04/AC-4, WABA-S03/AC-3
Decision 2 — cross-WABA concurrency on shared poolWABA-S04/AC-4, WABA-S04/ERR-1
Decision 5 — aggregated notification + below-zero eventWABA-S06/AC-1, WABA-S06/AC-2, WABA-S06/ERR-2
billing_version in /billings/infoWABA-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 surfaceConsumerRequired reads (BE)Required writes (BE)FE componentStatus surface
Package Usage — balance sectionwebGET /billings/info (+billing_version); GET /reports/billing/summarynoneUsageDetail.vue, PackageInfoComponent.vueaggregated remaining_* fields
Broadcast deduction table + CSVwebGET /{org}/billings/broadcast_deduction; GET /{org}/billings/download_broadcast_deductionnoneTableComponentCampaignBroadcast.vuewaba_id per row (pref-gated)
Conversation/MCC log table + filter + CSVwebGET /{org}/reports/billing/mcc_logs?waba_id=; MCC exportnoneTableComponentWhatsappBalance.vue (+ filter)waba_id per row (pref-gated)
Shared Balance TooltipwebGET /billings/info (billing_version)noneSharedBalanceTooltip.vue (net-new)static; gated on V3
Low balance bannerwebGET /billings/info / balancenoneLowBalanceBanner.vue (net-new)aggregated balance vs threshold
Postpaid limit configsupport (Modpanel)qontak-billing GET .../components/{code}qontak-billing PUT .../components/{code}/updatemoderator-be UIinitial_quota on postpaid table

Role Coverage

PRD roleAuthorization mechanismEndpoints permittedUI surface visibilityCross-tenant?Audit trail
Company Admin / OwnerOAuth2 scope :admin, :owner (hub_service oauth2 guard)/billings/info, /mcc_logs, /download_broadcast_deductionfull Package Usageno (own org)billing_log, PackageLog
Supervisor / Agent / MemberOAuth2 scopes :supervisor, :agent, :member/mcc_logs, reports they may viewreports (read-only)nobilling_log
Qontak Finance (Modpanel)Modpanel scope + Mekari SSO (moderator-be → qontak-billing)components/{code}/update (postpaid), top-upModpanel onlyyes (ops, cross-tenant)qontak-billing billing_log, moderator-be audit
System (cron/worker)internal service token / job queueqontak-billing deduction, monthly-reset, alert workersnonen/a (all V3 orgs)billing_log, billing_alert_logs

PRD Section Coverage

PRD §TitleWhere covered
1One-liner + Problem§1 Overview
2Target Users + Persona§1 (Role Coverage)
3Non-Goals§1 Out of Scope
Scope Changessurfaces§1 PRD-to-Schema; §2.D scope boundaries
4 / 4.1Constraints / Data Lifecycle§3 Performance; §2.3 retention
5Feature Changes (CHG-001..003)§2.4 APIs; §2.A FE (report columns, aggregated display)
6New Features (tooltip)Decision (FE); SharedBalanceTooltip.vue
7API & Webhook Behavior (1..7)§2.4 APIs; §2.C async; §2 Decisions
8 / 8.1 / 8.2System Flow + Stories + ACsDetail 1.A/1.C; §2.2 sequences
9 / 9.1Rollout / Migration window§4 Rollout
10 / 10.1Observability§3 Monitoring
11Success Metrics§1 Success Criteria
12Launch Plan & Stage Gates§4 Rollout stages
13Dependencies§1 Dependencies
14Key Decisions + Alternatives§2 Technical Decisions (ADR); Detail 1.B
15Open Questions§5

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

#DecisionChosen option§2 blockLayer
1How to make the quota pool shared across a company's WABAsSingle OrganizationPackage pool; allow N WhatsappPackages (WABAs) → 1 OrganizationPackage by dropping the unique FK index (confirmed 2026-07-01: one company = one org package)Decision 1BE
2Prevent over-deduction under concurrent sends from ≥2 WABAs on one poolReuse existing optimistic lock + requeue (no new mechanism)Decision 2BE
3Aggregated balance read path for /billings/infoRead-through the existing qontak-billing quota info, exposed via hub_core builder; short Redis cacheDecision 3BE
4WABI monthly reset & priority on the shared poolReuse qontak-billing monthly-reset worker unchanged once pool is single-packageDecision 4BE
5Company-level low-balance + distinct below-zero notificationExtend existing threshold alert; add net-new balance_below_zero event + dedup windowDecision 5BE
6waba_id report column gatingPreference-driven (billing_report_show_waba_id) replacing FE data-driven hasWabaIdDecision 6FE + BE
7Reuse vs new endpointsAll read/report/deduction endpoints reused/extended; no new HTTP surface except FE filter param passthrough§2.4both

Detail 1.C — Per-Story Change Map

Story idTitleLayer scopeChanges (concrete artifacts)Composite AC idsAcceptance criteria (verifiable)RFC anchors
WABA-S01WABI monthly reset & deduction priorityRuntime / behavior (BE)qontak-billing monthly-reset worker (reuse); deduction initial-bucket branch (reuse); ensure reset scoped to single shared poolWABA-S01/AC-1..4, ERR-1..2rspec/go test: after reset, initial.remaining_quota == initial_quota; wabi_reset_completed logged; V1/V2 skippedDecision 1 · Decision 4 · §2.C
WABA-S02WAB-Additional purchase & carry-overCross-squad (Modpanel → qontak-billing)top-up increments additionals.remaining_quota; carry-over path on renewal; verify is_carry_over_contract triggerWABA-S02/AC-1..4, ERR-1go test: renewal transfers remaining additional to new contract; wab_additional_carried_over logged§2.4 · Decision 4 · §5 Open Q
WABA-S03Postpaid limit config & blockingCross-squad (Modpanel → qontak-billing)PUT components/{code}/update sets postpaid initial_quota; deduction postpaid branch + block when 0WABA-S03/AC-1..3, ERR-1go test: postpaid decremented; quota_exceeded when all buckets 0; 403 for non-Modpanel§2.4 · Decision 1
WABA-S04Multi-bucket deduction hierarchyBE-onlyqontak-billing deduction.go (reuse hierarchy); aggregate quota across WABAs (net-new); concurrency reuseWABA-S04/AC-1..4, ERR-1..2go test: 500/400/100 split across buckets; failed msg → no row; concurrent sends never over-deductDecision 1 · Decision 2 · §2.2
WABA-S05WABA ID column in usage reportsBE + FE consumes existingBE: preference gate (hub_service exists); FE: re-gate column to preference + filter dropdown (net-new) + CSVWABA-S05/AC-1..5, ERR-1..2vitest: column shown iff pref ON; filter returns matching rows; empty state on no-match; V1/V2 no columnDecision 6 · §2.4 · §2.A
WABA-S06Company-level low balance notificationsBE + FE consumes newBE: aggregated threshold + net-new below-zero event + dedup; FE: LowBalanceBanner.vue (net-new)WABA-S06/AC-1..3, ERR-1..2go test: low_balance_warning/balance_below_zero emitted once per crossing (dedup); vitest: banner renders/clearsDecision 5 · §2.C · §3.A.1
WABA-S07Shared balance tooltipFE + BE consumes newBE: add billing_version to /billings/info; FE: SharedBalanceTooltip.vue (net-new), gated on billingM1VersionWABA-S07/AC-1..3, ERR-1vitest: 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 single OrganizationPackage; the quota tables (already keyed by organization_package_id) are by construction a single shared pool.
    • Pros: Zero change to the deduction engine — company-scoped GetQuotaForUpdate returns the one pool correctly; monthly-reset/carry-over/alerts already operate per-package = per-company. hub_core's create_whatsapp_package already sets organization_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_package must stop short-circuiting when a package already exists so additional WABAs create their own WhatsappPackage row against the same organization_package_id.
  • Option B — Many OrganizationPackages per company; aggregate quota by summing across them. Keep 1:1 WABA↔OrganizationPackage; change GetQuotaForUpdate to SUM(remaining_quota) across all of the company's packages, and deduct across multiple physical rows (each with its own lock_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 OrganizationPackage and every WABA's WhatsappPackage points 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 WhatsappPackage row 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, on rowAffected == 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 UPDATE pessimistic 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 info and expose via hub_core builder + short Redis cache. hub_core reads the (already single-pool) quota via qontak-billing GET /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_zero branch + 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 wiredbilling_components.threshold_running_out DECIMAL(5,2) (migration qontak-billing/db/migrations/20260126000001_*), read in deduction.go:299-301 and quota_management_alert.go:97-98 with 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).
  • 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 appConfig and gate the column + filter + tooltip on preference && billingM1Version. Follows the existing appConfig.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 AppConfig payload + billing_version to BillingStore.
  • Option B — Keep data-driven hasWabaId. Rejected — violates WABA-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/info cache). Multi-tenancy → company-scoped company_id everywhere (deduction, quota read); OAuth2 org scope at hub_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

PathWhy the agent reads itWhat pattern it teaches
qontak-billing/internal/app/usecase/quota_management/deduction.goThe deduction hierarchy + retry to extendinitial→additional→postpaid branch (L169-241); requeue on lock conflict (L71-90); one billing_log/deduction (L264-284)
qontak-billing/db/queries/organization_package_components.sqlThe quota read that must become pool-correctGetQuotaForUpdate :one filtered WHERE op.company_id = $2 (L87-137)
qontak-billing/db/queries/organization_package_component_initials.sqlOptimistic-lock UPDATE to reuseOPCIDeductionQuota WHERE id=$1 AND lock_version=$2 (L42-51)
qontak-billing/internal/app/usecase/worker/monthly_reset_billing_component.goReset + carry-over reusereset gated on is_initial_monthly_reset (L107-111); carryOverAdditionalQuota (L422-451)
qontak-billing/internal/app/server/rest_router.goRoute registration styler.Method(http.MethodPost, "/deduction", myHandler(...)) (L91)
hub_core/app/core/domains/services/billing/wa_package.rbEmbedded-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.rbV3 gate + pool lookup helpersis_billing_v3 (L35-37); find_whatsapp_package by organization_package_id (L217-232); thresholds (L107-121)
hub_core/app/apps/billings/services/quota_management.rbThe HTTP client to qontak-billingdeduct_quota/internal/qontak/billing/v1/quota-managements/deduction; base MEKARI_API_BASE_URL (L7)
hub_service/app/services/api/core/v1/billings/resources/billings.rbThe 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.vueMCC table + column + exporthasWabaId (L395-397); pref-gated export (L646-649)
hub-chat/features/subscriptions/usages/TableComponentCampaignBroadcast.vueBroadcast table + CSV blob downloadhasWabaId (L308-310); download_broadcast_deduction blob (L445-484)
hub-chat/features/finances/topup/store/useTopupStore.tsbillingM1Version computedpackageInfo.value.billing_version === "3.0.0" (L115-117)

Existing Contracts to Reuse, Extend, or Replace

ContractStatusJustificationOwner
POST /iag/v1/quota-managements/deductionextendedAdd pool-correct quota resolution (Decision 1); response/shape unchangedqontak-billing
GET /iag/v1/quota-managements/info, info/{billing_code}reusedSource for aggregated /billings/info (Decision 3)qontak-billing
PUT /iag/v1/quota-managements/components/{code}/updatereusedFinance sets postpaid initial_quota (WABA-S03)qontak-billing
PUT /iag/v1/quota-managements/components/{company_id}/invalidate-cachereusedBust /billings/info cache on writeqontak-billing
GET /api/core/v1/billings/infoextendedAdd billing_version + aggregated balancehub_service/hub_core
GET /api/core/v1/billings/mcc_logs?waba_id=reusedParam already accepted (L489)hub_service
GET /api/core/v1/billings/download_broadcast_deductionreusedCSV waba_id column already pref-gated (L559-596)hub_service
wa_conversation_logs (waba_id, organization_package_id, credited_to)reusedColumns already presenthub_core

Patterns to Follow

ConcernPattern in repoReference fileDeviation in this RFC?
Go HTTP handler shapefunc(w,r)(ResponseBody,error) via myHandlerqontak-billing/internal/pkg/http/handler.go; rest_router.go:91none
Go DB accesssqlc generated queries + WithTx/BeginTxqontak-billing/internal/app/repository/*.sql.go; sqlc.yamlnone
Go error envelope{resp_code, resp_desc{id,en}, meta}qontak-billing/internal/pkg/http/default_error.go:8-95none
Go logginglog/slog structured, slog.ErrorContext.../quota_management/deduction.go:36none
Ruby domain logicDry::Monads Success/Failure, interactors/repositorieshub_core/app/core/domains/interactors/*none
Ruby HTTP client to billingRepositories::AbstractHttp post(...)hub_core/app/apps/billings/services/quota_management.rbnone
Grape endpoint + OAuth2 scopeparams do ... end; oauth2 :admin,...; get '/info'hub_service/.../billings.rb:607-629none
FE statePinia store + $customFetchhub-chat/common/store/BillingStore.ts; plugins/customFetch.ts:146-227none
FE component<script lang="ts" setup> + @mekari/pixel3 css()hub-chat/features/subscriptions/packages/PackageInfoComponent.vuenone
FE feature-flag readappConfig.value?.billing_reports?.*hub-chat/.../TableComponentWhatsappBalance.vue:646-649none

Reading Order for the Agent

  1. qontak-billing/internal/app/usecase/quota_management/deduction.go — the deduction hot path (hierarchy + retry).
  2. qontak-billing/db/queries/organization_package_components.sqlGetQuotaForUpdate, the one query Decision 1 changes.
  3. qontak-billing/db/queries/organization_package_component_initials.sql — optimistic-lock UPDATE to reuse.
  4. hub_core/app/core/domains/services/billing/wa_package.rb — WABA→package linking (Decision 1 FE-of-BE).
  5. hub_core/app/core/domains/repositories/billings/helpers.rb — V3 gate + pool helpers + thresholds.
  6. hub_core/app/apps/billings/services/quota_management.rb — the HTTP client + gateway path.
  7. hub_service/app/services/api/core/v1/billings/resources/billings.rb — the API surface (info / mcc_logs / download).
  8. hub-chat/features/subscriptions/usages/TableComponentWhatsappBalance.vue — column + export + flag read.
  9. hub-chat/features/finances/topup/store/useTopupStore.tsbillingM1Version.
  10. qontak-billing/internal/app/usecase/worker/monthly_reset_billing_component.go — reset + carry-over.

Source Verification (anti-hallucination)

Anchor / pattern / contractVerified byEvidence
deduction hierarchyreaddeduction.go:169-241 initial→additional→postpaid→fallback; creditedTo = quota.InitialComponentCode...
one billing_log/deductionreaddeduction.go:264-284 CreateBillingLog(... CreditedTo, QuotaType ...) (answers PRD Q2)
deduction retry/requeuereaddeduction.go:71-90 if rowAffected == 0 && err == nil { ... EnqueueQuotaManagementDeduction }
company-scoped quota readreadorganization_package_components.sql:87-137 WHERE op.company_id = $2, :one
optimistic lock UPDATEreadorganization_package_component_initials.sql:42-51 WHERE id = $1 AND lock_version = $2
monthly resetreadmonthly_reset_billing_component.go:107-111 if !bc.IsInitialMonthlyReset { continue }
carry-over (contract trigger unverified)readmonthly_reset_billing_component.go:422-451 carryOverAdditionalQuota; contract-renewal trigger not traced → §5 Open Q
V3 gate (Go)readqontak-billing/internal/app/repository/model_helper.go:10-12 IsBillingV3() == "3.0.0"
unique FK index (Decision 1)readhub_core/.../20260113000000_*.rb add_index :whatsapp_packages, :organization_package_id, unique: true
WABA→package linkreadhub_core/.../services/billing/wa_package.rb:213-240 { organization_package_id: org_package.id }
V3 gate (Ruby)readhub_core/.../billings/helpers.rb:35-37 is_billing_v3 == "3.0.0"; organization_package.rb:144-146 billing_v3?
QM client + gateway pathreadhub_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 routereadhub_service/.../billings.rb:607-629 get '/info'Interactors::Billings::BillingInfo
mcc_logs waba_id paramreadhub_service/.../billings.rb:489 optional :waba_id, type: String
CSV waba_id gatereadhub_service/.../billings.rb:559,582,596 show_waba_id = Services::Preference.new.enabled?(:billing_report_show_waba_id)
hub_core in-process gemreadhub_service/Gemfile:103 gem 'hub_core', path: '../hub_core'
FE waba_id column (data-driven)readTableComponentWhatsappBalance.vue:395-397, TableComponentCampaignBroadcast.vue:308-310 hasWabaId
FE billingM1VersionreaduseTopupStore.ts:115-117 billing_version === "3.0.0"
FE CSV blob downloadreadTableComponentCampaignBroadcast.vue:445-484 blob → <a download>
FE tooltip/banner patternsreadInvoicesDetailPage.vue:159-167 <mp-tooltip>; BannerRingGroup.vue <MpBanner variant="warning">
FE DS versionreadhub-chat/package.json:80 @mekari/pixel3 1.0.12
gateway path vs /iag/v1 (unresolved)grepqontak-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_versionqontak-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_logs grow per billable message (existing rate; 90-day TTL per PRD §4.1).
  • PII: wa_conversation_logs.customer_name, .phone_number are Lockbox-encrypted (hub_core/.../models/billing/wa_conversation_log.rb has_encrypted). waba_id is not PII.
  • Retention: wa_conversation_logs 90 days (existing TTL); billing_alert_logs per existing policy; component quota snapshots per contract lifecycle (PRD §4.1).
  • Per-status lifecycle: organization_packages.billing_version is 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); the api_spec.yaml /iag/v1 paths are authoritative for the service.

Outbound endpoints (consumers call us)

EndpointMethodAuthN/AuthZRequestResponseStatusIdempotencyVersioningReuse?
/api/core/v1/billings/infoGETOAuth2 :admin,:owner,:supervisor,:agent,:membernone (org from token)+ billing_version, aggregated wabi/additional/postpaid200/401/422n/a (read)v1 additiveextended
/api/core/v1/billings/mcc_logsGETOAuth2 :admin,:owner,:supervisor,:agent,:member,:botwaba_id?, start_date?, end_date?, paginationrows incl. waba_id (pref ON)200/401/422n/av1reused (param exists)
/api/core/v1/billings/download_broadcast_deductionGETOAuth2 :modpanel,:admin,...organization_id?, dates, waba_id?CSV blob (+WABA ID col pref ON)200/401/422/504n/av1reused
POST /iag/v1/quota-managements/deduction (via gateway)POSTinternal SSO + X-Api-Keycompany_id, billing_code, deduction_code, unique_code, quantity, is_free, extra_attrs{waba_id}value_before, value_after, credited_to200/400/500unique_code (dedup)/iag/v1extended (pool-correct)
PUT /iag/v1/quota-managements/components/{parent_component_code}/updatePUTinternal SSO + X-Api-KeyRegisterOrUpdateComponentRequest (postpaid/additional codes, flags){id}200/400upsert by code/iag/v1reused
GET /iag/v1/quota-managements/info / info/{billing_code}GETSSO (client)company_idpool quota info200n/a/iag/v1reused
PUT /iag/v1/quota-managements/components/{company_id}/invalidate-cachePUTinternal SSOcompany_idok200n/a/iag/v1reused

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 pathTransaction scopePartial failureIdempotency key + TTLConsistencyDuplicate-eventStale-read
Deduction (bucket update + billing_log)single Go tx in qontak-billing (BeginTx/WithTx)rowAffected 0 → rollback + requeueunique_code (per conversation event)strong (row + lock_version)unique_code dedupn/a (FOR UPDATE within tx)
WABI monthly resetper-pool tx; lock_version resetretry 3 → wabi_reset_failed + on-callpool + cyclestrongreset idempotent per cyclen/a
Carry-over on renewalupsert carry_over_quota_itemsfail → wab_additional_carry_over_failed + on-callcompany_id+ref_external_idstrongupsertn/a
/billings/info aggregated readread-only + Redis SETcache miss → live read; timeout → last-knowncache key billing_info:{company} short TTLeventual (cache)n/ashort stale window acceptable (PRD: refresh-based)

Detail 2.B — Concurrency Collision Map

ResourceWritersCollisionResolutionBehavior on lock fail
organization_package_component_initials/_additionals/_postpaid_limits.remaining_quota (shared pool row)concurrent deductions from N WABAs; monthly-reset workertwo deductions read same lock_versionoptimistic lock WHERE id=? AND lock_version=? + incrementrowAffected 0 → rollback + requeue (bounded, Decision 2); never over-deduct
billing_info:{company} cachededuction (invalidate), top-up, page readsstale after writeinvalidate-cache endpoint on write + short TTLserve last-known on read; refresh next miss

Detail 2.C — Async Job / Event Consumer Spec

Job/ConsumerTriggerInputRetryDLQConcurrencyIdempotencyTimeoutPoison handling
QuotaManagementDeduction (requeue)lock conflict (rowAffected==0)DeductionRequestbounded (align 3) via job max_failsqueue deadletterqueue-managedunique_codequeue defaulton exhaustion → billing_deduction_failed + manual-reconcile flag
Monthly reset workercron (cycle start)packageID / company3 → wabi_reset_failed + on-calldeadletterper-packageper cycleworker defaultalert #bifrost-billing-alerts
Carry-over workercontract renewalsubscription payloadwarn on fail (carryOverAdditionalQuota L422-451)n/aper-companyref_external_idworker defaultwab_additional_carry_over_failed
QuotaManagementAlertremaining ≤ threshold post-deductioncompany_idqueue retrydeadletterper-companydedup key low_balance:{org}:{cycle} (net-new)worker defaultdrop after dedup window

Detail 2.D — Responsibility Boundary Matrix

Step (execution order)Owning serviceInbound triggerOutbound effectFailure handlerPRD anchor
1. Message send → build deduction body (stamp waba_id)hub_service + hub_coreagent sends billable msgPOST deduction to gatewaylog; don't block delivery§7.2, WABA-S04
2. Resolve pool + deduct in bucket orderqontak-billingdeduction requestupdate quota row + billing_logrollback + requeue§7.2, WABA-S04/AC-1
3. Threshold/below-zero alertqontak-billing workerpost-deduction remainingemit low_balance_warning/balance_below_zeroretry; never block deduction§7.7, WABA-S06
4. Monthly WABI resetqontak-billing workercronreset initial remaining3 retries + on-call§7.3, WABA-S01
5. Postpaid config / top-upmoderator-be → qontak-billingFinance actioncomponents/{code}/update / additional credit403 unauthorized; log§7 rows, WABA-S02/S03
6. Aggregated balance displayhub-chat → hub_service → hub_core → qontak-billingpage loadread + cachelast-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

EntityState field / eventDefaultUpdated byRead viaStale window
Shared pool balanceremaining_quota (3 buckets)initial_quota at resetdeduction / reset / top-up/billings/info (cached)short cache TTL
Deduction attributionwa_conversation_logs.waba_id, credited_todeduction billing_log/mcc_logs, /download_broadcast_deductionnone (persisted)
Low-balance statelow_balance_warning / balance_below_zero event; banner flagnot shownalert workerbanner reads balance vs thresholduntil next page load
V3 gateorganization_packages.billing_version"1.0.0"provisioning (moderator-be flags)/billings/info billing_versionnone

Detail 2.G — Cross-Layer Contract Verification

EndpointSatisfies 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" rowspending 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" rowyes (BE ready; filter FE net-new)
GET /download_broadcast_deductionbroadcast table + CSV (frame pending)"waba_id gated by preference" rowyes (BE ready)
POST /iag/.../deductionn/a — backendhierarchy + shared-pool rowsyes (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 findings REV-2/3/4/5/6 all 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):

  1. Package Usage aggregated balance section for V3 — single WABI / WAB-Additional / Postpaid figures replacing the per-WABA view (CHG-003).
  2. Shared Balance Tooltip — info-icon placement next to the balance heading + popover copy + hover/click behavior (WABA-S07).
  3. waba_id column styling in the Broadcast deduction table and the Conversation/MCC log table (WABA-S05, CHG-001/002).
  4. waba_id filter dropdown on the MCC view — control placement, default state, option list, and the no-match empty state (WABA-S05/AC-3,4).
  5. Low-balance banner — the warning variant and the distinct below-zero variant: 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 to billingM1Version), 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_id filter dropdown — props: option source (distinct waba_ids), selected value; event: @change → sets the waba_id query param on GET /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_failed for any org → Slack #bifrost-billing-alerts within 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/slog with company_id, waba_id, unique_code, credited_to, quota_type; hub_core Rails.logger + Rollbar.
  • PII: customer_name/phone_number remain Lockbox-encrypted; never log raw. waba_id, company_id are safe to log.

Security Implications

  • Threat model: cross-tenant balance read/deduction; unauthorized postpaid config. Entry points: hub_service OAuth2 endpoints, qontak-billing internal SSO + X-Api-Key.
  • Multi-tenancy isolation: every deduction and quota read is keyed by company_id; hub_service resolves org from the token (me.organization_id) and rejects cross-org (helpers.rb user_is_from_different_organization?).

Role × Endpoint Authorization Matrix

RoleEndpoint(s)MethodsTenant scopeConstraintAudit
Admin / Owner/billings/info, /mcc_logs, /download_broadcast_deductionGETown orgfullbilling_log, PackageLog
Supervisor / Agent / Member/mcc_logs, reports permittedGETown orgread-onlybilling_log
Bot/mcc_logs, /download_broadcast_deductionGETown orgread-onlybilling_log
Modpanel / Financeqontak-billing components/{code}/update, top-upPUT/POSTcross-tenant (ops)Mekari SSO + X-Api-Keyqontak-billing billing_log + moderator-be audit
System (workers)deduction / reset / alertinternalall V3 orgsservice tokenbilling_log, billing_alert_logs
  • Input validation: qontak-billing validates quantity >= 0.01, required company_id/billing_code/deduction_code/unique_code (request/quota_management.go validate tags).
  • 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 callTimeoutRetriesCircuit breakerDLQPersistent-failure behavior
hub_core → qontak-billing deductionclient @timeout (quota_management.rb)requeue bounded (align 3)n/a (job requeue)queue deadletterbilling_deduction_failed + manual reconcile; delivery not blocked
hub_core → qontak-billing quota info (/billings/info)short (≤500ms budget)none (serve cache)n/an/alast-known cache + billing_aggregate_balance_timeout
monthly resetworker default3n/adeadletterwabi_reset_failed + on-call
notification deliveryprovider default3 exp. backoffn/an/alow_balance_notification_failed; don't block deduction

Detail 3.A.1 — Branch & Skip Catalog

Branch triggerWhere checkedDownstream effectAuditUser-visible?
Non-billable/failed messagehub_core deduction body buildno deduction, no billing_log (or total_price=0)noneno
is_free = trueqontak-billing deduction (req.IsFree)no threshold alert (skips alert calc)billing_log.is_free, free_reasonno
billing_report_show_waba_id OFFhub_service + FE (Decision 6)waba_id column/filter/CSV col absentnoneyes (column hidden)
V1/V2 org (billing_version != 3.0.0)everywhere (is_billing_v3/billingM1Version)entire shared-pool/tooltip/column path skippednoneyes (unchanged UI)
Low-balance dedup within cyclealert worker (net-new key)suppress duplicate notificationdedup logno

Detail 3.B — Error Response Catalog

EndpointError codeHTTPMessageWhenUser-facing?
deductionquota_exceeded200 (credited_to signals) / business errorall buckets exhaustedpostpaid = 0yes (send blocked)
deductionretry-deduction (credited_to)200lock conflictconcurrent sendno (internal requeue)
/billings/infobilling_aggregate_balance_timeout (log)200 (cached)qontak-billing slowno
/download_broadcast_deductionexport timeout504"Export is taking too long. Try reducing the date range.">10k rowsyes
/mcc_logs?waba_id= no match200 emptyno rows for WABAfilter no matchyes ("No records found for WABA ID")
qontak-billing validation400400{resp_code, resp_desc{id,en}} "Invalid request: ..."bad quantity/paramsno (internal)

Detail 3.C — Compliance & Data Governance

FieldClassificationLegal basisRetentionEncryptionAccess auditRight-to-delete
wa_conversation_logs.customer_name, .phone_numberPIIUU PDP90 daysLockbox at rest + TLS transitbilling_logexisting customer-data deletion flow (unchanged)
waba_id, company_id, organization_package_idnon-PII business identifierswith recordTLS transitbilling_logn/a
balance / quota valuessensitive (financial)ISO 27001contract + auditTLS transitbilling_log, billing_alert_logsn/a

4. Backwards Compatibility and Rollout Plan

Compatibility

  • Endpoints: all changes additive — /billings/info gains fields (billing_version, aggregated balance); mcc_logs/download_broadcast_deduction already accept waba_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_id index 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_id on existing V3 WhatsappPackage rows; 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 or quota_exceeded spike unresolved in 4h (PRD §10.1) → revert.
  • Rollback mechanism: disable waba_save_organization_id for 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 / configTypeDefaultRequiredProvisionerSecret?
waba_save_organization_idpreference (Flipper/Redis)OFFyes (gates Decision 1)hub_core Services::Preferenceno
billing_report_show_waba_idpreference per CIDOFFyes (WABA-S05)hub_core Services::Preference / Modpanelno
billing_reports.show_waba_id (FE mirror)appConfig boolOFFyes/client_configs/configno
threshold_running_outDB column DECIMAL(5,2) on billing_components40% (existing default when NULL)already existsqontak-billing (billing_components, shared DB)no
MEKARI_API_BASE_URLenvprod gatewayyesdeploy configno (URL)

Detail 4.B — Test Plan (commands sourced from repos)

LayerCommand (source)What it must prove
Go unit/integration (qontak-billing)make testCGO_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 migrationmake 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 testvitest --dom --pool=forks (source: hub-chat/package.json:15)column/filter/tooltip/banner gate on pref && billingM1Version; V1/V2 no column
FE lintpnpm lint (source: hub-chat/package.json:13)lint clean

Detail 4.C — Agent Execution Plan

OrderChunkFiles to modify/createCommandsAcceptance criteria
1qontak-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.gosqlc generate; make testgo test: company with N WABAs deducts from one pool in WABI→Add→Postpaid order; -race clean
2qontak-billing: bounded retry + billing_deduction_failed on exhaustion (Decision 2)internal/app/usecase/quota_management/deduction.go; job configmake testtest: concurrent deductions never over-deduct; exhaustion logs billing_deduction_failed
3hub_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.rbRAILS_ENV=test bundle exec rails app:db:migrate; rspec app/core/domains/services/billing/wa_package_spec.rbmigrate up/down works; 2nd WABA creates new WhatsappPackage on same organization_package_id when waba_save_organization_id ON
4Backfill organization_package_id for existing V3 WhatsappPackages + verifyhub_core rake task/scriptrun backfill on staging; verification query100% of V3 WhatsappPackage rows have organization_package_id
5hub_core/hub_service: /billings/info returns billing_version + aggregated balance + Redis cache (Decision 3)hub_core Billings::BillingInfo builder; hub_service/.../billings.rbrspec app/... billings_spec.rbresponse includes billing_version:"3.0.0" + aggregated WABI/Add/Postpaid; timeout → cached + log
6qontak-billing: net-new balance_below_zero event + low-balance dedup key (Decision 5)internal/app/usecase/worker/quota_management_alert.gomake testtest: below-zero emits distinct event; duplicate crossing within cycle suppressed
7FE: add billing_version to BillingStore, billing_reports.show_waba_id to AppConfighub-chat/common/store/BillingStore.ts; common/store/AppConfigStore.tspnpm teststore exposes billing_version; appConfig exposes flag
8FE: re-gate waba_id column to pref && billingM1Version on both tables (Decision 6)TableComponentWhatsappBalance.vue, TableComponentCampaignBroadcast.vuepnpm testvitest: column shown iff pref ON AND V3; hidden for V1/V2 and pref OFF
9FE: waba_id filter dropdown on MCC view (net-new)hub-chat/pages/subscriptions/usages/index.vue + TableComponentWhatsappBalance.vuepnpm testfilter passes waba_id to /mcc_logs; empty-state on no match
10FE: SharedBalanceTooltip.vue (V3 only) + aggregated balance display (WABA-S07, CHG-003)hub-chat/features/subscriptions/packages/SharedBalanceTooltip.vue, UsageDetail.vuepnpm testtooltip renders for V3 only; hidden when billing info fails (fail-safe)
11FE: LowBalanceBanner.vue aggregated (WABA-S06)hub-chat/features/subscriptions/packages/LowBalanceBanner.vue, PackageDetails.vuepnpm testbanner shows below threshold / below-zero; clears when restored

Detail 4.D — Verification & Rollback Recipe

  • Pre-merge (in order):
    1. qontak-billing: make test (source Makefile:68-72) — -race clean.
    2. hub_core + hub_service: RAILS_ENV=test bundle exec rspec app --tag ~@is_skip_pipeline.
    3. moderator-be: bundle exec rspec app.
    4. hub-chat: pnpm lint && pnpm test.
  • Post-deploy signals:
    • Datadog billing dashboard: billing_deduction_failed rate < 0.1% over 15 min; quota_exceeded within baseline; billing_aggregate_balance_timeout < 1%.
    • wabi_reset_completed count == active V3 orgs on cycle start; zero wabi_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.
  • Rollback recipe (in order):
    1. Disable waba_save_organization_id for affected CIDs (reverts to per-WABA package creation).
    2. Set billing_report_show_waba_id OFF for affected CIDs (hides columns/filter).
    3. If deduction regression: revert the qontak-billing deduction PR (GetQuotaForUpdate change) — pool falls back to single-row behavior.
    4. make migrate-down on the index migration only if the non-unique index causes an issue (rare — non-unique is a superset).
    5. Confirm billing_deduction_failed returns < 0.1% within 15 min.

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/info cache miss.
  • Storage: wa_conversation_logs/billing_log growth 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 one organization_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 migration 20260126000001_*) with a 40% default when NULL, read in deduction.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:

  1. 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-7dropped 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

DateComment(s) FromAction Item(s)
2026-07-01RFC 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-01Initiative DRIConfirmed 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-01rfc-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-01OwnerTwo 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.