Skip to main content

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:

  1. 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.
  2. Question (a) already has a real, shipped answer that ADR-0001 didn't use. T1 (BIF-8740, merged) gates hold/settlement in hub_core via Services::Billing::FeatureFlag.new.enabled?(:wa_hold_settlement, unique_id: organization_package&.company_id) — confirmed by reading hub_core/app/core/domains/repositories/billings/helpers.rb:61-63 and hub_core/app/core/domains/services/billing/feature_flag.rb. This reads a billing-DB Preference/PreferenceUniqueId pair via REDIS_BILLING_R-cached lookups — not the organization_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.) A DeductionV2Status service 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.
  3. moderator-be already has everything needed to replicate the real flag, not re-derive a proxy for it: Billings::ApplicationRecord already establish_connection :billing (same shard hub_core's Models::AbstractModelBilling uses), and Core::Services::RedisBilling::AbstractRedisBilling already opens a REDIS_BILLING Redis::Namespace.new(Rails.env.to_sym, …) connection — the same key-namespacing scheme hub_core/config/initializers/redis.rb's REDIS_BILLING_R uses (verified: both namespace by Rails.env.to_sym; the two apps point at what should be the same physical Redis via different env var names — REDIS_BILLING_URL vs REDIS_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:

  1. Drop the per-range activation boundary entirely. DED-S08 gates per company, not per date range: if wa_hold_settlement is 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.
  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 (new Billings::Preference + Billings::PreferenceUniqueId models on the billing shard, mirroring hub_core/app/core/domains/models/billing/{preference,preference_unique_id}.rb, plus a Redis read path built on the existing AbstractRedisBilling connection) — a line-for-line port of hub_core/app/core/domains/services/billing/feature_flag.rb#enabled?. No active_since, no DeductionV2Status service.
  3. 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 existing UserGetsMccLog/Gets::MccLog and UserDownloadsMccLogs/Downloads::MccLogs), querying Models::Billing::WaConversationLog filtered to origin_type='reconciliation_settlement', grouped by day × WhatsApp Business (channel/waba_id) × category — the dimensions the reference screenshot actually shows, not the PRD's literal destination_country grouping (flagged below as a PRD divergence). Exposed via two new hub_service Grape routes. moderator-be's modpanel download flow calls the new hub_core download route instead of building its own aggregate query.
  4. Expose the same on/off signal to client FE via the existing billing_info API. Builders::Billings::BillingInfo gains 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.
  5. Report from the pre-aggregated daily rollup, not from wa_conversation_logs. (Added 2026-07-20 after a review point that wa_conversation_logs carries 200M+ rows, making a read-time GROUP BY for every report/download request untenable.) The GET usage API (M2) and the download API (M3) read wa_reconciliation_batches — which SettleDaily (T5) already writes as one row per (organization_id, waba_id, phone_recipient, category, meta_date) with meta_volume (message count) and cost, indexed on (organization_id, meta_date). That is orders of magnitude smaller than the per-message log, and its meta_date is the true usage day (a settled log row's created_at is 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 new settled_charged_amount column on wa_reconciliation_batches, accumulated in SettleDaily#settle_holds! beside the existing Meta-base settled_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") and DED-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 touches SettleDaily (T5/BIF-8744, the settlement correctness core, already merged). The change is small and purely additive (the existing Meta-base settled_amount/meta_cost_total semantics and the settled_amount + unmatched_cost == meta_cost_total invariant 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_date from the batch fixes a latent bug in the earlier M2/M3 design, which grouped wa_conversation_logs by DATE(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/HTTParty call 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_settlement readers must never drift. hub_core's canonical Services::Billing::FeatureFlag and 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) vs REDIS_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 hub Nuxt 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 once hub is 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::FeatureFlag implementations should collapse into one and this ADR's Decision 2 point should be superseded.