ADR-0002: Modpanel V2-detection via replicated FeatureFlag; new usage/download APIs owned by hub_core
Delivery / Program Management — decision record (ADR-style).
- Status: approved (2026-07-20)
- Deciders: Bifrost eng (Hafriz)
- Scope: the modpanel-UI workstream (Part 2 of
deduction-v2.task-breakdown.md, tasks M1–M5; Jira stories DED-S07/DED-S08 = BIF-8881/BIF-8882). Supersedes ADR-0001 in full.
Context
ADR-0001 answered two questions for moderator-be: (a) is this CID on Deduction V2? and (b) from what date? — and chose to derive (b) from the first settlement row so DED-S08 could gate MCC downloads by date range (per-message allowed before the boundary, daily-aggregate only after/spanning it).
Revisiting the design after grounding against the real, checked-out hub_core and
moderator-be repos surfaced three things that change the answer:
- Question (b) turns out to be unnecessary. ADR-0001 itself already named and rejected "Option 2 — if a CID is V2-on now, force daily aggregation for all MCC ranges" as over-gating. On reflection this over-gating is acceptable: a V2-on company has no per-message settlement path going forward, so offering "per message" for any range (even a pre-activation one) is a UX/reconciliation trap for Finance, not a feature worth preserving. Simplifying to a flat per-company on/off removes the entire boundary-derivation problem.
- Question (a) already has a real, shipped answer that ADR-0001 didn't use. T1
(BIF-8740, merged) gates hold/settlement in
hub_coreviaServices::Billing::FeatureFlag.new.enabled?(:wa_hold_settlement, unique_id: organization_package&.company_id)— confirmed by readinghub_core/app/core/domains/repositories/billings/helpers.rb:61-63andhub_core/app/core/domains/services/billing/feature_flag.rb. This reads a billing-DBPreference/PreferenceUniqueIdpair viaREDIS_BILLING_R-cached lookups — not theorganization_packages.extras['wa_hold_settlement']two-tier design the RFC's Decision 6 documents. (That RFC text is itself stale against the shipped code; out of scope to fix here, noted for the RFC's own next revision.) ADeductionV2Statusservice built from scratch in moderator-be — as ADR-0001 and the original M1 assumed — would re-derive a signal that already exists and risk disagreeing with it. - moderator-be already has everything needed to replicate the real flag, not re-derive a
proxy for it:
Billings::ApplicationRecordalreadyestablish_connection :billing(same shard hub_core'sModels::AbstractModelBillinguses), andCore::Services::RedisBilling::AbstractRedisBillingalready opens aREDIS_BILLINGRedis::Namespace.new(Rails.env.to_sym, …)connection — the same key-namespacing schemehub_core/config/initializers/redis.rb'sREDIS_BILLING_Ruses (verified: both namespace byRails.env.to_sym; the two apps point at what should be the same physical Redis via different env var names —REDIS_BILLING_URLvsREDIS_BILLING_R_URL— flagged as an infra assumption to confirm, not re-derived here).
Separately, grounding the DED-S07 "get usage" requirement against a reference screenshot of the
target table (columns: Created at, WhatsApp Business, Category, Message count, Deducted
balance) found no matching view or aggregation logic in either moderator-be or hub_core.
The existing hub_core→hub_service MCC endpoints
(Interactors::Billings::UserGetsMccLog/UserDownloadsMccLogs, mounted at
GET /reports/billing/mcc_logs and GET /reports/billing/download/{core/}mcc_logs) return only
live per-message rows (origin_type: ['BI','UI','RC'], is_auto_deduct: true) — the deliberate
opposite of what a V2 aggregate view needs (origin_type='reconciliation_settlement',
is_auto_deduct=false, the rows SettleDaily/T5 already writes). Building the aggregate query a
second time inside moderator-be (direct Billings::WaConversationLog read, as the original M4
planned) would duplicate aggregation logic that hub_core already owns the source data for.
Decision
We will:
- Drop the per-range activation boundary entirely. DED-S08 gates per company, not per
date range: if
wa_hold_settlementis enabled for the company, the modpanel MCC download offers daily-aggregate only — full stop, regardless of the requested range. This adopts ADR-0001's own rejected Option 2. - Answer "is this CID on V2?" by replicating the real flag, not deriving a proxy.
moderator-be gets its own
Core::Services::Billing::FeatureFlag(newBillings::Preference+Billings::PreferenceUniqueIdmodels on the billing shard, mirroringhub_core/app/core/domains/models/billing/{preference,preference_unique_id}.rb, plus a Redis read path built on the existingAbstractRedisBillingconnection) — a line-for-line port ofhub_core/app/core/domains/services/billing/feature_flag.rb#enabled?. Noactive_since, noDeductionV2Statusservice. - hub_core owns the new "get usage" and "download usage" aggregate APIs, not
moderator-be. Two new interactor/repository pairs in
hub_core(modeled on the existingUserGetsMccLog/Gets::MccLogandUserDownloadsMccLogs/Downloads::MccLogs), queryingModels::Billing::WaConversationLogfiltered toorigin_type='reconciliation_settlement', grouped by day × WhatsApp Business (channel/waba_id) × category — the dimensions the reference screenshot actually shows, not the PRD's literaldestination_countrygrouping (flagged below as a PRD divergence). Exposed via two newhub_serviceGrape routes. moderator-be's modpanel download flow calls the new hub_core download route instead of building its own aggregate query. - Expose the same on/off signal to client FE via the existing
billing_infoAPI.Builders::Billings::BillingInfogains one boolean field,is_wa_hold_settlement_enabled, read the same way that builder already reads other one-off flags in-line (e.g.Services::Billing::BillingEcommerceValidation) — no new endpoint. - Report from the pre-aggregated daily rollup, not from
wa_conversation_logs. (Added 2026-07-20 after a review point thatwa_conversation_logscarries 200M+ rows, making a read-timeGROUP BYfor every report/download request untenable.) The GET usage API (M2) and the download API (M3) readwa_reconciliation_batches— whichSettleDaily(T5) already writes as one row per(organization_id, waba_id, phone_recipient, category, meta_date)withmeta_volume(message count) and cost, indexed on(organization_id, meta_date). That is orders of magnitude smaller than the per-message log, and itsmeta_dateis the true usage day (a settled log row'screated_atis the settlement-run day, ~T+1 — the wrong bucket). The one figure the batch lacks — the client-charged (margin-inclusive) "Deducted balance" — is added by M0: a newsettled_charged_amountcolumn onwa_reconciliation_batches, accumulated inSettleDaily#settle_holds!beside the existing Meta-basesettled_amount.
We did not choose to keep ADR-0001's derived active_since boundary (unnecessary once (1)
holds), did not choose to have moderator-be build its own aggregate query against
Billings::WaConversationLog directly (duplicates ownership of data hub_core already owns and
already has a settlement-writing contract for), and did not choose to aggregate
wa_conversation_logs at read time (too heavy at 200M+ rows; the batch rollup already exists).
We also did not create a separate net-new rollup table — wa_reconciliation_batches already
is the rollup and only lacked the one charged-amount column.
Consequences
- DED-S08's ACs change materially. PRD
DED-S08/AC-2("range entirely before activation → both options available") andDED-S08/AC-3("range spanning activation → daily only") no longer apply — replaced by a single flat rule: V2-on company ⇒ daily-aggregate only, for any range. This needs a PRD refresh (a second divergence on top of the RFC's already-tracked Open Question 1); flagged in the task breakdown, not silently applied to the PRD file. - DED-S07's literal example row changes. PRD's
"May 18 — Marketing — ID: 500 conversations"(day × category × destination country) becomes day × WhatsApp Business number × category, matching the reference screenshot rather than the PRD's literal grouping. Same flag: needs PM sign-off, not silently applied. - M0 edits shipped money-path code. Adding
settled_charged_amount+ its accumulation touchesSettleDaily(T5/BIF-8744, the settlement correctness core, already merged). The change is small and purely additive (the existing Meta-basesettled_amount/meta_cost_totalsemantics and thesettled_amount + unmatched_cost == meta_cost_totalinvariant are untouched), but it is on the money path and needs full QA — hence M0 is its own ticket (BIF-8959), not folded into M2. - Reporting is now correct on the usage day, not the settlement-run day. Reading
meta_datefrom the batch fixes a latent bug in the earlier M2/M3 design, which groupedwa_conversation_logsbyDATE(created_at)— the day settlement ran (~T+1), not the day the conversations happened. - moderator-be gains a new outbound HTTP dependency on hub_service for the download flow
(M5) — no prior client/pattern for this exists in moderator-be today (grepped: no
HUB_SERVICE/HUB_CORE/Faraday/HTTPartycall site found). This is new surface area and a new failure mode (timeout/5xx from hub_service) that M5 must handle explicitly. - Two independent
wa_hold_settlementreaders must never drift. hub_core's canonicalServices::Billing::FeatureFlagand moderator-be's replica read the same underlying Preference tables + Redis cache, but are two codebases — a future change to one must be mirrored to the other by hand. Accepted for now (no cross-repo shared gem exists); flagged as a follow-up risk, not solved here. REDIS_BILLING_URL(moderator-be) vsREDIS_BILLING_R_URL(hub_core) pointing at the same physical Redis is assumed, not verified (no infra access from this checkout). If they differ, moderator-be's replica cold-reads the DB every time instead of sharing hub_core's warm cache — correct but slower; does not break correctness.- hub_core's DED-S07/S08 API surface needs FE consumers. The GET usage API's client-facing
consumer (the PRD's original
hubNuxt app target,pages/finances/package_usage.vue/ReportingMcc.vue) is not available in this checkout to ground file paths against — M4 in the task breakdown is written with unverified paths, flagged for a follow-up grounding pass oncehubis checked out alongside this repo. - Follow-up: if a future re-vendor gives hub_core/moderator-be a shared flag-reading gem, both
Services::Billing::FeatureFlagimplementations should collapse into one and this ADR's Decision 2 point should be superseded.