Skip to main content

Deduction V2.0 — Task Breakdown

Source design & scope. One breakdown, two workstreams (merged 2026-07-20 from the former companion deduction-v2-modpanel-ui.task-breakdown.md, so the initiative has a single authoritative effort total):

  • Part 1 — hub_core engine (T1–T7, ✅ all Done). The execution view of the authorize-at-send + settle-against-Meta (hold/settlement) design documented in deduction-v2.md. Slicing: vertical, backend end-to-end. All file paths are grounded against the real hub_core checkout. The hold/settlement layer was greenfield — a repo-wide grep found no pre-existing wa_balance_holds / wa_reconciliation_batches / wa_hold_settlement / CreateHold / SettleDaily code.
  • Part 2 — Modpanel/client surfaces + ops (M0–M6, not started; redesigned 2026-07-20). The two PRD stories the hub_core RFC put Out of Scope (RFC §1 Out of Scope #5): DED-S07 (daily-aggregate deduction/usage history — new GET API in hub_core, FE in the client hub app) and DED-S08 (Modpanel MCC download for V2.0 companies — new download API in hub_core, wiring in moderator-be). Work now spans hub_core, hub_service, the client hub app, and moderator-be — see Part 2's scope table below and ADR-0002, which supersedes the original all-in-moderator-be plan and its per-range activation-boundary gate (ADR-0001, superseded).

Effort Summary

TaskBE daysFE daysQA daysTotal
T1 — Hold ledger schema, models & enablement toggle22
T2 — Create hold at send20.52.5
T3 — Available-balance helper, send-gate & Available-only display20.52.5
T4 — Webhook state transitions + broadcast dual-emit guard20.52.5
T5 — EOD settlement against Meta415
T6 — Stale-hold sweeper (30-day expiry)0.50.51
T7 — Cron scheduling + docs0.50.5
T8 — Guard NULL held_at (NOT NULL migration + sweeper safety net) — post-ship hardening, not started0.50.51
Subtotal — Part 1 (hub_core engine) — T1–T7 shipped (16), T8 hardening not started (1)13.53.517
M0 — Persist client-charged amount on wa_reconciliation_batches (hub_core settlement)10.51.5
M1a — V2-detection: expose wa_hold_settlement on billing_info (hub_core)0.50.5
M1b — V2-detection: replicate Services::Billing::FeatureFlag (moderator-be)1.51.5
M2 — New GET usage API, reads the daily rollup (hub_core, DED-S07 backend)20.52.5
M3 — New download API, daily rollup CSV (hub_core, DED-S08 backend)1.50.52
M4 — Client usage page (hub app; unverified, DED-S07 frontend)20.52.5
M5 — Modpanel download wiring → hub_core (moderator-be, DED-S08 frontend/glue)1.50.50.52.5
M6 — Manual "trigger settle daily" (moderator-be Finance menu, single/many/all CID → hub-service → hub-core, DED-S03)1.50.50.52.5
Subtotal — Part 2 (modpanel/client + ops)9.53315.5
Grand total2336.532.5

Confidence — Part 1: n/a (shipped). All 7 tasks are Done and merged (BIF-8740…8746); the former risk concentration in T5 (FIFO reopen, shortfall, margin/base split) is resolved and code-corroborated. Part 2: medium-high for M1/M2/M3/M5, low for M4. The redesign in ADR-0002 removes the per-range activation-boundary problem entirely (no more pending tech-lead sign-off blocker) and grounds M1–M3/M5 against real, checked-out code in hub_core/hub_service/moderator-be. M4 is the exception: the hub client app is not checked out in this workspace, so its file paths are carried from the PRD's own (unverified) repo-analysis note — re-ground before starting.

Jira mirror — Part 2 (created 2026-07-15, Sprint "Bifrost 26Q3 Sprint 2" = 21464, all under Epic BIF-8644)

Repo idJiraTypeSPSplit from story
DED-S07BIF-8881Story
DED-S08BIF-8882Story
M0BIF-8959Task1.5DED-S07
M1aBIF-8883Task0.5DED-S07
M1bBIF-8958Task1.5DED-S08
M2BIF-8884Task2.5DED-S07
M3BIF-8885Task2DED-S08
M4BIF-8886Task2.5DED-S07
M5BIF-8887Task2.5DED-S08
M6BIF-9016Task2.5DED-S03 (manual re-trigger)
T8BIF-9015Task1net-new (sweeper hardening)

Revised 2026-07-20 (ADR-0002) — task content, story-point totals, and the DED-S07/DED-S08 split changed (M2/M3 swapped which story they serve; M4/M5 changed repo and content). M1 was split into two per-repo Tasks: M1a stays on BIF-8883 (hub_core), M1b is the new BIF-8958 (moderator-be). M0 (BIF-8959) was added — a settlement-side prerequisite so M2/M3 read the pre-aggregated wa_reconciliation_batches rollup instead of the 200M-row wa_conversation_logs. Summaries/descriptions on BIF-8881…8887 + BIF-8958 + BIF-8959 updated to match this revision on 2026-07-20. Later additions: T8 (BIF-9015, sweeper NULL-held_at hardening) 2026-07-21; M6 (BIF-9016, DED-S03 manual settle trigger) 2026-07-22. All these Tasks: assignee Hafriz (6108c0996051c5006addaf28), Epic Link BIF-8644, Sprint 21464. Story-point values mirror the task-breakdown Total man-days. (Part 1's T-tasks were tracked as BIF-8740…8746, all Done — see the per-task Status lines. M6/T8 relate to T5/T6 rather than a DED story split.)


Part 1 — hub_core engine (T1–T7)

Verified repo facts (drive T1–T7):

  • Specs are colocated beside source (*_spec.rb), require 'rails_helper', run via bundle exec rspec <path> + bundle exec rubocop.
  • Migrations: database/billing/db/migrate/ (YYYYMMDDHHMMSS_slug.rb); models extend Models::AbstractModelBilling on the :billing shard.
  • Helpers#available_wa_balance does not exist (only find_whatsapp_package) — net-new.
  • config/sidekiq_schedule.yml does not exist — T7 creates it (loaded by config/initializers/sidekiq.rb:27-30).
  • Enablement is the company-scoped wa_hold_settlement feature flagas-built: Services::Billing::FeatureFlag.new.enabled?(:wa_hold_settlement, unique_id: company_id), backed by the billing preferences / preference_unique_ids tables + REDIS_BILLING_R cache. Tiers: global (preferences.is_global = true → all companies) or per-company (a preference_unique_ids allow-list entry per company_id). A shared Helpers#wa_hold_settlement_enabled?(organization_package) predicate — delegating to Services::Billing::FeatureFlag with unique_id: organization_package&.company_id — is the single gate every task calls. Flag registration + allow-list management are admin tasks in the billing-preferences service, not app code. (This supersedes RFC Decision 6's un-shipped two-tier Services::Preferenceorganization_packages.extras['wa_hold_settlement'] design; extras['wa_hold_settlement'] is read nowhere.)

T1: [BE] Hold ledger schema, models & enablement toggle

The system can persist per-message holds and per-bucket settlement batches, gated by the company-scoped wa_hold_settlement feature flag (Services::Billing::FeatureFlag, as-built) — the foundation every other task builds on.

Status: ✅ Done — implemented in BIF-8740 (merged to hub_core, PR #8049, sha 070b20de02); runbook: ../documentations/wa_hold_settlement-flag-registration.md

What to build

Two new tables on the billing shard (wa_balance_holds, wa_reconciliation_batches) with their indexes, the two ActiveRecord models with state enums + optimistic locking, the shared Helpers#wa_hold_settlement_enabled? predicate (delegating to the company-scoped Services::Billing::FeatureFlag), and registration of the wa_hold_settlement billing preference (admin task in the billing-preferences service, default off).

Implementation Plan

ActionFileWhat changes
createhub_core/database/billing/db/migrate/<ts>_create_wa_balance_holds.rbone row per billable message; cols per plan §Data-model; indexes incl. partial unique (external_id, state) where external_id IS NOT NULL
createhub_core/database/billing/db/migrate/<ts>_create_wa_reconciliation_batches.rbone row per settled bucket; unique (organization_id, waba_id, phone_recipient, category, meta_date) + (organization_id, meta_date)
createhub_core/app/core/domains/models/billing/wa_balance_hold.rb< Models::AbstractModelBilling; enum state: { held: 'held', delivered: 'delivered', settled: 'settled', refunded: 'refunded', expired: 'expired' }; lock_version
createhub_core/app/core/domains/models/billing/wa_reconciliation_batch.rb< Models::AbstractModelBilling; enum state: { pending: 'pending', settled: 'settled' }; lock_version
createhub_core/app/core/domains/models/billing/wa_balance_hold_spec.rbenum transitions, default state held, uniqueness guard
createhub_core/app/core/domains/models/billing/wa_reconciliation_batch_spec.rbuniqueness index, default state pending
extendhub_core/app/core/domains/repositories/billings/helpers.rb (a module/mixin — already included by the interactor + deduction repo)as-built: wa_hold_settlement_enabled?(organization_package) = Services::Billing::FeatureFlag.new.enabled?(:wa_hold_settlement, unique_id: organization_package&.company_id) — the single gate T2–T6 call; nil-safe (&.company_id)
create/extendhub_core/app/core/domains/services/billing/feature_flag_spec.rb (+ helper spec)FeatureFlag truth table: state=false/absent → false; is_global=true → true (any company); is_global=false + company in preference_unique_ids → true, else false; warm/cold REDIS_BILLING_R cache

Implementation steps

  1. Explore — open hub_core/app/core/domains/models/billing/wa_conversation_log.rb (base Models::AbstractModelBilling, lockbox + enum billed_to: {...} style) and the 3 most recent files in database/billing/db/migrate/ to copy the timestamp+slug naming and the :billing-shard migration shape.
  2. Write failing model specs (red) — create the two *_spec.rb colocated with the models; assert default states and the enum value maps. bundle exec rspec <path> → fails.
  3. Migrations — create wa_balance_holds with all columns from the plan (organization_id/organization_package_id/channel_id/message_id uuid; external_id, conversation_category, pricing_model default 'PMP', estimated_amount decimal(20,4), country, phone_recipient, customer_ref nullable, state default 'held', settled_amount, message_broadcast_id, reconciliation_batch_id, held_at/delivered_at/settled_at/expired_at, lock_version) and all six indexes. Create wa_reconciliation_batches likewise. Standardize all tenant ids as uuid (plan note — the EOD draft's bigint is wrong for this codebase).
  4. Models — add both models with state enums and confirm optimistic locking is active (lock_version present + AbstractModelBilling.locking_enabled?).
  5. Shared enablement predicate (as-built) — add Helpers#wa_hold_settlement_enabled?(organization_package) = Services::Billing::FeatureFlag.new.enabled?(:wa_hold_settlement, unique_id: organization_package&.company_id). Helpers is a moduleinclude it (the interactor + deduction repo already do). This is the single gate T2–T6 reuse, so all branches agree (mutual-exclusion invariant). Services::Billing::FeatureFlag is read-only in hub_core and resolves the flag from the billing preferences/preference_unique_ids tables via REDIS_BILLING_R. Spec its truth table: state=false/absent → false; is_global=true → true; per-company allow-list hit → true; nil company_id → false (fail-safe).
  6. Register the flag — the wa_hold_settlement billing preference is registered + its per-company preference_unique_ids allow-list managed in the billing-preferences service (hub_core only reads). Defaults OFF; pilot = add company_id to the allow-list; GA = is_global=true. (Not app code — see the runbook.)
  7. Go greenbundle exec rails db:migrate (billing DB) up and down; rerun specs until green.
  8. Quality gatebundle exec rubocop on the new files.

Acceptance criteria

  • Both tables created with every column + index from the plan; partial-unique (external_id, state) and the batch unique key exist.
  • Migration is reversible (up + down dry-run clean).
  • WaBalanceHold.new.state == 'held' by default; WaReconciliationBatch.new.state == 'pending'.
  • All tenant id columns are uuid (match WaConversationLog/WhatsappUsageComparison).
  • Helpers#wa_hold_settlement_enabled?(organization_package) delegates to Services::Billing::FeatureFlag.new.enabled?(:wa_hold_settlement, unique_id: organization_package&.company_id); true iff the flag is is_global or the company is in the allow-list; nil company_id/absent flag → false (fail-safe).
  • wa_hold_settlement billing-preference registration + per-company allow-list documented (billing-preferences service); flag defaults OFF. extras['wa_hold_settlement'] is not used.

Test strategy

Model specs assert enum maps, default states, and that the DB-level unique indexes raise on duplicate (external_id, state) / batch key. No money movement here — pure schema/model.

Effort estimate

DisciplineDays
Frontend
Backend2
QA
Total2

Assumptions: reuses AbstractModelBilling + existing migration patterns; no Lockbox PII columns on holds (customer_ref is display-only plaintext per plan). QA deferred — behavior is exercised in T2–T6.

Run to verify

cd /Users/kerja/qontak/hub_core && bundle exec rspec app/core/domains/models/billing/wa_balance_hold_spec.rb app/core/domains/models/billing/wa_reconciliation_batch_spec.rb app/core/domains/repositories/billings/helpers_spec.rb && bundle exec rubocop

Depends on

  • — (foundation)

T2: [BE] Create hold at send (CreateHold + wiring + shared free-category predicate)

When an org enabled for hold/settlement (wa_hold_settlement_enabled? — global flag ∨ package extras) sends a billable WhatsApp message, a held row is reserved against its balance the moment the wamid is known — no pool money moves yet.

Status: ✅ Done — implemented in BIF-8741 (merged to hub_core, PR #8050, sha cd2a6d15f0); runbook: ../documentations/create-hold-at-send.md

What to build

A new CreateHold repo that inserts an idempotent held row (estimate from WaPricing, phone_recipient = business sending number), a shared free-category predicate extracted from the existing is_free_deduction?, and wiring at the single-message and broadcast send points — all flag-gated.

Implementation Plan

ActionFileWhat changes
createhub_core/app/apps/wa_cloud/repositories/billings/create_hold.rbINSERT-only hold; estimated_amount/country via Services::Billing::V2::WaPricing; phone_recipient = channel.settings['server_wa_id']; customer_ref display-only; idempotent on external_id (rescue ActiveRecord::RecordNotUnique → Success)
extendhub_core/app/core/domains/repositories/v2/billings/new_pricing_wa_deduction.rbextract is_free_deduction? (~199-205) into a shared predicate both send-time and webhook-time call
extendhub_core/app/core/events/subscribers/message_send.rbcall CreateHold in the success branch (~line 43, external_id = result.success[:id]), guarded by target_channel == 'wa_cloud' + flag
extendhub_core/app/apps/wa_cloud/repositories/broadcast/send.rbcall CreateHold where broadcast wamid is persisted to MessageBroadcastLog.external_id (~227-232)
createhub_core/app/apps/wa_cloud/repositories/billings/create_hold_spec.rbSuccess/Failure/wrong-org/free-category skip; flag on/off; idempotent double-insert

Implementation steps

  1. Explore — read new_pricing_wa_deduction.rb lines 199-205 (is_free_deduction?) and the WaPricing#total_price(org_id, package_id) inputs in app/core/domains/services/billing/v2/wa_pricing.rb; read message_send.rb:43 and broadcast/send.rb:227-232 to see exactly where external_id becomes available.
  2. Extract the predicate — move is_free_deduction? to a shared location (e.g. Repositories::Billings::Helpers) so send-time skip and webhook-time agree (plan risk: free-category divergence). Keep NewPricingWaDeduction calling it.
  3. Write failing spec (red)create_hold_spec.rb: billable PMP message → one held row with correct estimated_amount, phone_recipient, external_id; service/free category → no row; flag off → no row; duplicate external_id → single row, returns Success.
  4. Scaffold CreateHold — new repo, Dry::Monads Success/Failure like new_pricing_wa_deduction.rb; INSERT only, no pool mutation.
  5. Wire send points — single-message subscriber + broadcast persist point, both flag-gated; failure branch (no wamid) → no hold.
  6. Go green then quality gate (rubocop).

Acceptance criteria

  • Billable PMP send (flag on) creates exactly one held hold with estimated_amount from WaPricing and phone_recipient = channel.settings['server_wa_id'].
  • Free categories (service/UI/referral_conversion) create no hold, via the shared predicate.
  • Duplicate webhook/external_id → idempotent single row.
  • Flag off → zero behavior change (no hold).
  • No pool/balance field is mutated by this path.

Test strategy

RSpec on CreateHold with both flag branches, wrong-organization_id, and a duplicate-insert idempotency case; stub WaPricing to a fixed estimate and assert the row's estimated_amount/phone_recipient.

Effort estimate

DisciplineDays
Frontend
Backend2
QA0.5
Total2.5

Assumptions: reuses WaPricing as-is for the estimate; external_id is reliably set at the two wiring points per recon. QA 0.5 — send-time money reservation is customer-visible via Available balance.

Run to verify

cd /Users/kerja/qontak/hub_core && bundle exec rspec app/apps/wa_cloud/repositories/billings/create_hold_spec.rb && bundle exec rubocop

Depends on

  • [T1] (holds table + model)

T3: [BE] Available-balance helper, send-gate switch & Available-only display

An org can no longer over-spend money already committed to in-flight messages — the send-gate and the displayed balance both enforce Available = Pooled − Reserved.

Status: ✅ Done — implemented in BIF-8742 (merged to hub_core, PR #8051, sha 30daf7ac63); runbook: ../documentations/available-balance-gate-and-display.md

What to build

A net-new available_wa_balance helper (single source of truth), substituted into both send-gates and into the get usage balance endpoint (Available-only display), all flag-gated.

Implementation Plan

ActionFileWhat changes
extendhub_core/app/core/domains/repositories/billings/helpers.rbadd available_wa_balance(wa_package) = balance_initial + balance + postpaid_limit − Σ(holds in held|delivered); keep the existing postpaid conditional (v3-postpaid only)
extendhub_core/app/core/domains/interactors/abstract_iteractor.rbreplace estimate-only check (~65-76) with available_wa_balance − balance_deduct < 0 → Failure, flag-gated
extendhub_core/app/apps/wa_cloud/repositories/broadcast/send.rbsame substitution in #validate_balance (~179-202), flag-gated
extend<get-usage-balance endpoint interactor> (repositories/billings/report.rb and billings/repositories/v2/reports/summary.rb)return Available (not Pooled) when flag on — both displays net the reservation via the shared Helpers#net_wa_hold_reservation ladder
createhub_core/database/billing/db/migrate/<ts>_add_active_reservation_index_to_wa_balance_holds.rbperf: partial composite index (organization_package_id, estimated_amount) WHERE state IN ('held','delivered') → index-only Σ; inactive rows drop out
extendhelpers.rb (reserved_wa_balance)perf: 10s Redis cache of the Σ ("#{package_id}::wa_reserved"), DB fallback; CreateHold clears the key so fresh holds are visible to the next gate check
createhub_core/app/core/domains/repositories/billings/helpers_spec.rb (or extend existing)helper math with/without holds; postpaid conditional; cache TTL + invalidation

Implementation steps

  1. Explore — read helpers.rb:218-232 (find_whatsapp_package, the balance fields) and abstract_iteractor.rb:27-78 (validate_wa_balance, esp. 65-76) to see the exact comparison being replaced; locate the get usage balance interactor that reads the package.
  2. Write failing spec (red) — helper returns pooled − Σholds for held|delivered, ignores settled/refunded/expired; gate fails when available − deduct < 0; both flag branches.
  3. Helper — add available_wa_balance; this is the single source so display and gate never disagree.
  4. Switch the two gates — substitute in abstract_iteractor.rb and broadcast/send.rb, flag-gated (flag off = byte-identical current behavior).
  5. Switch the balance endpoint — Available-only when flag on, reusing the same helper.
  6. Go green + quality gate.

Acceptance criteria

  • available_wa_balance subtracts only held|delivered holds; reused by gate and display.
  • Send blocked when available − cost < 0 (flag on); unchanged when flag off.
  • get usage balance returns Available (not Pooled) when flag on; Pooled-only behavior preserved when off.
  • Postpaid conditional preserved (only v3-postpaid orgs count postpaid_limit).
  • Perf: the reservation Σ uses an index-only partial scan (idx_wbh_active_reservation, EXPLAIN-verified) and is served from a 10s Redis cache; CreateHold invalidates the key (fresh hold visible to the very next check).

Test strategy

RSpec on the helper (seeded holds across all states), plus both gate call-sites with flag on/off; assert the displayed-balance interactor and the gate return the same number for the same fixture.

Effort estimate

DisciplineDays
Frontend
Backend2
QA0.5
Total2.5

Assumptions: balance fields are exactly balance_initial/balance/postpaid_limit per recon; bounded gate↔hold race is accepted (estimate only; EOD is the backstop). QA 0.5 — directly changes what a paying customer sees and can send.

Run to verify

cd /Users/kerja/qontak/hub_core && bundle exec rspec app/core/domains/repositories/billings/helpers_spec.rb app/core/domains/interactors/abstract_iteractor_spec.rb && bundle exec rubocop

Depends on

  • [T1] (holds model). Pairs with [T2] (T2 writes the holds this sums) — can be built in parallel with T2.

T4: [BE] Webhook state transitions + broadcast dual-emit guard

Delivery/failure webhooks now just move a hold's state (no live pool deduction), and the legacy live broadcast report is suppressed so settlement won't double-count.

Status: ✅ Done — implemented in BIF-8743 (merged to hub_core, PR #8087, sha 43be469ab5); runbook: ../documentations/webhook-hold-transitions.md

What to build

Branch NewPricingWaDeduction#call (flag-gated): delivered/readheld→delivered; failed/expiredheld|delivered→refunded; keep the existing dedupe + StaleObjectError/QueryCanceled re-enqueue; move pool-deduction/WaConversationLog/broadcast-report out to EOD; flag-gate the legacy live broadcast report off.

Implementation Plan

ActionFileWhat changes
extendhub_core/app/core/domains/repositories/v2/billings/new_pricing_wa_deduction.rbflag-on branch (~62-186): find hold by external_id, held→delivered (idempotent; read-after-delivered no-op); new failed/expired → refunded branch; no hold found → log + "missing hold" metric + Success (do not deduct); keep dedupe + re-enqueue (~164-173); flag-gate the live broadcast report (~215-248) off when flag on
extendhub_core/app/core/domains/repositories/v2/billings/new_pricing_wa_deduction_spec.rbwebhook idempotency (call 2× → one transition); refund branch; flag on/off; missing-hold path

Implementation steps

  1. Explore — read new_pricing_wa_deduction.rb #call (11-186), the ladder (87-127), dedupe/re-enqueue (164-173), and the broadcast report (215-248) to see precisely what moves to EOD vs stays.
  2. Write failing spec (red) — flag-on delivered transitions held→delivered, no pool change; second delivered/read is a no-op; failedrefunded; missing hold → Success + metric, no deduction; flag-off path byte-identical to today.
  3. Add the transition branches — flag-gated; preserve WaUniqConvIdLog dedupe and the StaleObjectError/QueryCanceled re-enqueue exactly.
  4. Suppress live deduction + broadcast report when flag on (these move to T5).
  5. Go green + quality gate.

Acceptance criteria

  • Flag-on delivered/readheld→delivered, idempotent, zero pool mutation.
  • Flag-on failed/expiredheld|delivered→refunded (reserve released, nothing charged).
  • Missing hold on delivered → Success + "missing hold" metric, never a fallback deduction.
  • Flag-off path is byte-identical to current behavior (no regression).
  • Live broadcast report suppressed when flag on (no double-count with settlement).

Test strategy

RSpec on NewPricingWaDeduction covering both flag branches, webhook idempotency (2× → one transition), the refund branch, and the missing-hold metric path; assert no WaConversationLog row and no pool change on the flag-on transition.

Effort estimate

DisciplineDays
Frontend
Backend2
QA0.5
Total2.5

Assumptions: modifying the hot webhook path — flag-off must early-return with negligible overhead. QA 0.5 — regression risk on the live money path. Merged the plan's "broadcast dual-emit guard" here since it edits the same file.

⚠️ Surfaced in review (RFC OQ-7, tracked in BIF-8824, pilot gate): for toggled orgs, free/service conversations get no WaConversationLog rows at all (legacy wrote billed_to='free' rows) and billable rows arrive next-day via T5 settlement instead of live. Needs product/analytics sign-off; cheap fix if rejected = keep writing the free-only row from the webhook path.

Run to verify

cd /Users/kerja/qontak/hub_core && bundle exec rspec app/core/domains/repositories/v2/billings/new_pricing_wa_deduction_spec.rb && bundle exec rubocop

Depends on

  • [T1] (holds), [T2] (holds exist to transition)

T5: [BE] EOD settlement against Meta (SettleDaily + workers)

At end of day the ledger is reconciled to Meta's actual cost to the cent — delivered holds in each (waba, phone_recipient, category, meta_date) bucket are settled FIFO, pools deducted by Meta's base_price plus the org's configured margin (review feedback: the log's base_price column mirrors Meta exactly for reporting; the client charge adds margin), and one actual-amount WaConversationLog row written per hold.

Status: ✅ Done — implemented in BIF-8744 (staged on temp/deduction-v2); runbook: ../documentations/eod-settlement-settle-daily.md

What to build

SettleDaily repo (modeled on record_daily_meta.rb) + dispatcher + per-org worker: FIFO allocation across the bucket's unsettled delivered holds (Meta unit price per hold with the rounding residual on the batch-completing hold, so Σ base == meta_cost_total), idempotent reconciliation batches, money movement via the existing ladder at Meta base + org margin (WaPricing total_price − base_price: conversation fee, discount, tax — the same components live deduction charges), actual-amount WaConversationLog rows with is_auto_deduct=false, late-delivery reopen + 30-day shortfall, and settlement-time broadcast report.

Implementation Plan

ActionFileWhat changes
createhub_core/app/apps/billings/repositories/v1/wa_hold_settlement/settle_daily.rbper [waba_id, timezone]: window = (today−7d)..(today−1d) lazy (days that already have batch rows are skipped — steady state 1 Meta call/WABA/night, ~6k fleet-wide at GA; the window only re-fetches genuinely missed days) + every pending batch (no date floor — anything at/past the 30d horizon is force-closed, keeping the pending set small); per not-yet-ingested Meta day ascending, MetaPricingAnalytics.new(...).call; per bucket find_or_create_by on wa_reconciliation_batches (Meta totals frozen on create); FIFO consume oldest delivered holds up to meta_volume; as-built: per_hold = meta_cost_total/meta_volume (unit price) with the residual on the batch-completing hold — not /consumed.size, which would front-load the whole cost on a partial pass and zero the 30d shortfall; ladder deduction charged = Meta base share + org margin (WaPricing total − base, memoized per category+country); WaConversationLog actual rows origin_type='reconciliation_settlement', is_auto_deduct=false, base_price = Meta share, total_price/credit = charged; batch amounts stay Meta-base-only (settled_amount + unmatched_cost == meta_cost_total); keep batch pending until settled_count == meta_volume; shortfall row only at 30d horizon (recorded, never deducted); COST==0 → settle at 0 billed_to='free', no margin
createhub_core/app/core/workers/billings/settle_wa_holds_dispatcher_worker.rbretry: 0; review feedback: fan out per OrganizationPackage across ALL statuses and both trial flags (the global flag stores no organization ids, so organization_packages is the enumeration source; an inactive/trial package can still owe settlement) — extras-filtered when the global flag is off — plus orgs with open (held|delivered) holds
createhub_core/app/core/workers/billings/settle_wa_holds_worker.rbretry: 0, throttled 1000/h; per org resolve WABA tz via Concerns::WaTimezoneResolver; call repo per [waba_id, tz]; rescue StaleObjectError/QueryCanceled → re-enqueue
reusehub_core/app/core/workers/billings/broadcast_deduction_report_worker.rbno code change needed — existing interface already takes actual amounts; settlement enqueues it per settled hold carrying message_broadcast_id with deducted_wa_balance = settled_amount (charged), deducted_wa_credit = 0
createcolocated *_spec.rb for the repo + both workersFIFO, residual, margin-vs-base split, idempotent re-run, count-mismatch reopen, shortfall, sweeper interplay, monthly-reset is_auto_deduct=false — 29 examples

Implementation steps

  1. Explore — read record_daily_meta.rb (find_or_create_by 97-100, index_by_phone_and_category 78-93, phone_recipient 81/136, persist_status_only!), meta_pricing_analytics.rb .call kw-args, the ladder in new_pricing_wa_deduction.rb:87-127, and single_reset_package.rb:222-238 (the gap queries that must ignore is_auto_deduct=false).
  2. Write failing specs (red) — stub MetaPricingAnalytics (per-day instance_double) and WaPricing (margin = total_price − base_price, default 0): assert pool deducted by Meta base + margin, holds settled, Σ WaConversationLog.base_price == meta_cost_total (reporting mirrors Meta); re-run 2× → one money movement; consumed.size < meta_volume leaves batch pending + reopens; 30d horizon writes one reconciliation_shortfall; COST==0 settles free.
  3. SettleDaily — model on record_daily_meta.rb; bucket key (waba_id, phone_recipient, conversation_category, meta_date); FIFO candidate pool state:'delivered', reconciliation_batch_id: nil ordered delivered_at ASC, no proximity/customer filter (immune to phone→bsuid migration); money movement once per bucket inside a txn, optimistic-locked.
  4. Dispatcher + worker — mirror record_daily_wa_usage_comparison_worker.rb / record_daily_wa_usage_worker.rb (both retry: 0, the per-org one includes WaTimezoneResolver).
  5. Broadcast report at settlement — enqueue BroadcastDeductionReportWorker with actual amount for message_broadcast_id holds.
  6. Go green + quality gate.

Acceptance criteria

  • Per bucket, Σ log.base_price == batch.settled_amount, and settled_amount + unmatched_cost == meta_cost_total exactly (residual to the batch-completing hold).
  • Pool deducted by Meta base share + org margin; log base_price mirrors Meta exactly; one actual-amount WaConversationLog per settled hold, origin_type='reconciliation_settlement', is_auto_deduct=false.
  • Idempotent: re-running a day → one money movement (batch unique key + frozen Meta totals + reconciliation_batch_id IS NULL filter).
  • Late delivery: consumed.size < meta_volume settles available, batch stays pending, reopens on later runs.
  • 30-day horizon with settled_count < meta_volume → single reconciliation_shortfall row + Rollbar + Datadog counter; gap recorded in unmatched_cost, never deducted (bounded under-charge, SC-4).
  • COST==0 & holds present → settle at 0, billed_to='free', no margin, reserve released.
  • Monthly reset's gap queries ignore settlement rows (is_auto_deduct=false).

Test strategy

RSpec with stubbed MetaPricingAnalytics (per-day) + WaPricing (margin): tally invariant, margin-vs-base split, idempotency (run 2×), the count-mismatch branches, sweeper interplay (expired never consumed), and a monthly-reset interaction test proving settlement rows are excluded from reset gap math.

Effort estimate

DisciplineDays
Frontend
Backend4
QA1
Total5

Assumptions: reuses the existing ladder, MetaPricingAnalytics, and the daily-worker scaffold; Meta-currency conversion handled as an optional sub-step (capture currency, convert if ≠ pool currency). QA 1.0 (~25%) — this is the correctness core; a silent settlement bug hits every paying V2 org's books.

Run to verify

cd /Users/kerja/qontak/hub_core && bundle exec rspec app/apps/billings/repositories/v1/wa_hold_settlement/settle_daily_spec.rb app/core/workers/billings/settle_wa_holds_worker_spec.rb app/core/workers/billings/settle_wa_holds_dispatcher_worker_spec.rb && bundle exec rubocop

Depends on

  • [T1] (batches table), [T4] (delivered holds to settle)

T6: [BE] Stale-hold sweeper (30-day expiry)

Holds for messages that were sent but never delivered are released after 30 days, so reserved balance can't leak forever.

Status: ✅ Done — implemented in BIF-8745 (staged on temp/deduction-v2); runbook: ../documentations/stale-hold-sweeper.md

What to build

SweepStaleWaHoldsWorker (retry: 0): state='held' rows with held_at < 30.days.ago, in_batches(of: 500) + per-row with_lock re-check → expired. Releases reserve, no money movement.

Implementation Plan

ActionFileWhat changes
createhub_core/app/core/workers/billings/sweep_stale_wa_holds_worker.rbretry: 0; only touches state='held'; in_batches(of: 500) + with_lock re-check before → expired
createhub_core/app/core/workers/billings/sweep_stale_wa_holds_worker_spec.rbexpires only stale held; leaves delivered/settled untouched; no pool change

Implementation steps

  1. Explore — read record_daily_wa_usage_worker.rb for the retry: 0 worker shape and any existing in_batches/with_lock usage in single_reset_package.rb:51.
  2. Write failing spec (red) — a held hold older than 30d → expired; a delivered or recent held → untouched; assert no balance/pool change.
  3. Implement — batch + per-row with_lock re-check (a hold may flip to delivered mid-sweep).
  4. Go green + quality gate.

Acceptance criteria

  • Only state='held' rows with held_at < 30.days.agoexpired (+expired_at stamped; NULL held_at skipped — nothing to age against).
  • delivered/settled/refunded holds never touched.
  • No money movement; reserve released (Available reconverges — the per-package reserved_wa_balance Redis cache is cleared for every touched package).
  • Safe with concurrent delivery webhook (with_lock re-check; a mid-sweep delivered flip is left for T5 settlement).

Test strategy

RSpec (7 examples): stale-held expiry incl. expired_at, non-stale/non-held/NULL-held_at exclusion, pool untouched, reserve + cache released, a concurrency case where a row flips to delivered before the lock (not expired), and Rollbar on unexpected errors.

Effort estimate

DisciplineDays
Frontend
Backend0.5
QA0.5
Total1

Assumptions: schedule after settlement (30d ≫ 7d window = safe margin) — see T7. QA 0.5 (min) — releasing reserve is customer-visible on Available balance.

Run to verify

cd /Users/kerja/qontak/hub_core && bundle exec rspec app/core/workers/billings/sweep_stale_wa_holds_worker_spec.rb && bundle exec rubocop

Depends on

  • [T1] (holds)

T7: [BE] Cron scheduling + docs

The settlement and sweeper jobs run automatically every night, and the new tables/states/flag are documented for the team.

Status: ✅ Done — implemented in BIF-8746 (sha 8584f122). As-built: the schedule + docs landed in hub-worker, not hub_corehub_core is not self-scheduling; the Sidekiq-cron registry is the host worker app's (this matches the T5 runbook, which already said to register the schedule in the host app). Dispatcher 0 1 * * * Asia/Jakarta + sweeper 0 2 * * * Asia/Jakarta, both on queue billing_settle_wa_holds (registered in hub-worker config/sidekiq.yml + the production deploy args); docs/architecture/GLOSSARY.md + docs/architecture/flows/billing/README.md updated there.

What to build

Create config/sidekiq_schedule.yml (does not exist yet) with the settlement dispatcher (~01:00 ICT) and sweeper (~02:00 ICT); update GLOSSARY.md and the Billings spoke sequence diagram.

Implementation Plan

ActionFileWhat changes
createhub_core/config/sidekiq_schedule.ymlcron entries: SettleWaHoldsDispatcherWorker ~01:00 ICT, SweepStaleWaHoldsWorker ~02:00 ICT (sweeper after settlement)
extendhub_core/GLOSSARY.mdadd wa_balance_holds, wa_reconciliation_batches, wa_hold_settlement, hold states
extendhub_core/docs/architecture/flows/billings/README.mdupdate the Billings spoke sequence diagram for hold→settle

Implementation steps

  1. Explore — read config/initializers/sidekiq.rb:27-30 (it loads config/sidekiq_schedule.yml if present — confirm the sidekiq-cron YAML format expected) and any existing cron declaration to copy the cron:/class:/queue: shape.
  2. Create the schedule file — two entries, correct ICT cron expressions, sweeper strictly after settlement.
  3. Docs — GLOSSARY entries + spoke diagram per the AGENTS.md rule.
  4. Quality gate — YAML lint / boot check that Sidekiq::Cron::Job.load_from_hash parses the file.

Acceptance criteria

  • config/sidekiq_schedule.yml exists and parses; both jobs registered with correct cron + queue. (in hub-worker)
  • Sweeper scheduled after settlement (01:00 dispatcher → 02:00 sweeper, ICT).
  • GLOSSARY + billing flow diagram updated (in hub-worker docs/architecture/).

Test strategy

Boot-time verification that the schedule YAML loads without error; manual confirmation the two job classes resolve. No unit assertions (config + docs).

Effort estimate

DisciplineDays
Frontend
Backend0.5
QA
Total0.5

Assumptions: sidekiq-cron is the scheduler (initializer references Sidekiq::Cron::Job). QA — verified via T5/T6 specs; cron is config glue.

Run to verify

cd /Users/kerja/qontak/hub_core && ruby -ryaml -e "YAML.load_file('config/sidekiq_schedule.yml')" && bundle exec rubocop

Depends on

  • [T5] (dispatcher), [T6] (sweeper)

T8: [BE] Guard against NULL held_at holds — NOT NULL migration + sweeper safety net — BIF-9015

Post-ship hardening of T6, surfaced by the QA gap analysis (../documentations/deduction-v2-testcases-gap-analysis.md §3.8). A held row with held_at IS NULL is invisible to both the sweeper and settlement, so its reserve would leak forever — the exact leakage SC-4 + the sweeper exist to prevent.

Status: ✅ Actionable — not started. Latent/defensive (not reachable on today's happy path).

Problem

wa_balance_holds.held_at is nullable with no default (t.datetime :held_at). SweepStaleWaHoldsWorker selects state='held' AND held_at < cutoff; NULL < cutoff is NULL (not true) and the per-row re-check requires held_at.present?, so a NULL-held_at held row is never expired. Settlement (T5) only consumes delivered holds, so it never touches it either → the reserve is stuck with no release path. CreateHold (the only writer) always stamps held_at, so this can only arise from a future insert path, a backfill, a manual/console insert, or a bug — and if it does, it leaks silently (it doesn't even trip the sweeper's Rollbar, which only fires on exceptions).

Implementation Plan

ActionFileWhat changes
createhub_core/database/billing/db/migrate/<ts>_backfill_and_require_wa_balance_holds_held_at.rbbatched backfill of any held_at IS NULL rows (held_at := created_at); verify zero NULLs; then change_column_null :wa_balance_holds, :held_at, false (optionally default: -> { 'now()' }). Money table — batch + verify before the constraint
extendhub_core/app/core/workers/billings/sweep_stale_wa_holds_worker.rbdefensive branch: a state='held' AND held_at IS NULL row → Rollbar + Datadog counter (fail loud), never silently filtered
extendsweep_stale_wa_holds_worker_spec.rb + migration specsweeper alerts (not skips) on NULL-held_at; migration backfills, adds NOT NULL, reverses clean

Acceptance criteria

  • wa_balance_holds.held_at is NOT NULL; migration backfills existing NULLs (held_at := created_at), verifies zero NULLs before the constraint, and is reversible.
  • A held hold can no longer persist with NULL held_at; if one is somehow present, the sweeper alerts (Rollbar/Datadog), never silently skips.
  • Sweeper spec covers the NULL-held_at case explicitly.

Effort estimate

DisciplineDays
Backend0.5
QA0.5
Total1

Depends on

  • [T1] (holds schema), [T6] (sweeper). Relates: BIF-8745.

Part 1 ordering rationale

  • Critical path: T1 → T2 → T4 → T5 → T7. Schema first (everything keys off the two tables); holds must be created (T2) before they can be transitioned (T4) before they can be settled (T5); cron (T7) wires the live jobs last.
  • T3 and T6 parallelize off T1. The send-gate/display (T3) only needs the holds model to sum against, and the sweeper (T6) only needs the table — neither blocks the settlement spine, so a second engineer can take them in parallel.
  • T5 is the long pole (5 days, ~⅓ of the Part-1 total) and the highest-risk — front-load design review on its FIFO/reopen/shortfall logic and lock in the double-charge mutual-exclusion check (a message must never be both live-deducted and held+settled) before coding.
  • Nothing is externally blocked. Unlike the earlier debit-at-webhook design, this design needs no Figma (no aggregate-history UI) and no Modpanel held_deducted exposure — the "Available-only" display is a pure BE endpoint change (T3). The only non-code prerequisite is registering the global wa_hold_settlement flag (default off) and, for the pilot, setting organization_packages.extras['wa_hold_settlement'] per opt-in org (admin/data tasks, in T1).

Part 2 — Modpanel/client surfaces + ops (M0–M6)

Scope. The two PRD stories the hub_core RFC put Out of Scope (RFC §1 Out of Scope #5), plus the DED-S03 manual-trigger admin tooling (M6):

  • DED-S07 — daily-aggregate deduction/usage history view, sourced from settled records (screenshot-grounded: day × WhatsApp Business × category, with message count + deducted balance).
  • DED-S08 — Modpanel MCC download mode gating for V2.0 companies: a V2 company downloads usage as a daily-aggregate lump sum, never per message.
  • DED-S03 (M6) — a modpanel Finance-menu tool to manually trigger settle-daily for a single CID, many CIDs, or all CIDs (page → hub-service → hub-core SettleWaHoldsWorker per org, or SettleWaHoldsDispatcherWorker for All); gated so a CID settles only when its wa_hold_settlement flag is ON or the global flag is ON. Operational tooling over the shipped T5 engine.

Repo — revised 2026-07-20 (ADR-0002). Work now spans three codebases, not one:

RepoWhat lands thereWhy
hub_core (+ hub_service Grape routes)M1a (billing_info flag exposure), M2 (new GET usage API), M3 (new download API)hub_core already owns the settled wa_conversation_logs rows (T5) and the canonical wa_hold_settlement flag (T1) — it should own the read/export logic over its own data, not have moderator-be duplicate the aggregation query against a table moderator-be doesn't write.
hub (client Nuxt app)M4 (FE consumer of M2)the PRD's original DED-S07 FE target (pages/finances/package_usage.vue / ReportingMcc.vue). Not checked out in this workspace — file paths in M4 are unverified, flagged explicitly rather than fabricated.
moderator-be (Rails modpanel)M1b (FeatureFlag replica), M5 (download wiring)the modpanel MCC download flow (download_billings_controllerMuvMccLogs) lives here and needs to know the on/off signal and call out to M3.

This supersedes the 2026-07-15 plan (all five tasks in moderator-be, gated by a derived per-range activation boundary). See ADR-0002 for the full rationale; ADR-0001 is superseded and kept only for history.

Design basis / no dedicated RFC. These stories still have no dedicated RFC — the hub_core RFC is the authorize-at-send engine only. Design basis: PRD §9.2 DED-S07 / DED-S08 (with two flagged divergences below), the settled-records contract T5 produces, and ADR-0002.

⚠️ PRD divergence (flag, not silently applied — mirrors how the RFC tracks its own Open Question 1 against the PRD). Two concrete PRD details no longer match the shipped design:

  1. DED-S08/AC-2 and AC-3 (date-range-dependent gating: per-message allowed before the activation boundary, daily-only after/spanning it) are replaced by a flat per-company rule — V2-on ⇒ daily-aggregate only, for any requested range. No boundary date is computed or stored.
  2. DED-S07's example row ("May 18 — Marketing — ID: 500 conversations", grouped by destination country) is replaced by day × WhatsApp Business number × category, per the reference screenshot — no destination_country column in the new API.

Both need a PM sign-off / PRD refresh; recorded here so the gap is visible, not left implicit.

Verified repo facts (drive M0–M6; re-grounded 2026-07-20 against the real hub_core, hub_service, and moderator-be checkouts):

  • The canonical wa_hold_settlement flag already exists and is already usedhub_core/app/core/domains/repositories/billings/helpers.rb:61-63: wa_hold_settlement_enabled?(organization_package) calls Services::Billing::FeatureFlag.new.enabled?(:wa_hold_settlement, unique_id: organization_package&.company_id). The class itself (hub_core/app/core/domains/services/billing/feature_flag.rb) reads a Redis-cached (REDIS_BILLING_R, keys preference:<feature>:{state,global,<unique_id>:unique}) pair of billing-DB models, Models::Billing::Preference (table preferences) and Models::Billing::PreferenceUniqueId (table preference_unique_ids). This is the real mechanism — the RFC's Decision 6 text (organization_packages.extras['wa_hold_settlement']) is stale against the shipped T1 code; noted for the RFC's own next revision, not fixed here.
  • moderator-be already has the connection primitives to replicate it, but not the models. Billings::ApplicationRecord (app/models/billings/application_record.rb) already establish_connection :billing — the same shard Models::AbstractModelBilling uses in hub_core. Core::Services::RedisBilling::AbstractRedisBilling (app/domains/core/services/redis_billing/abstract_redis_billing.rb) already opens REDIS_BILLING = Redis::Namespace.new(Rails.env.to_sym, redis: …) — same namespacing scheme as hub_core's REDIS_BILLING_R (hub_core/config/initializers/redis.rb:17-21), but via ENV['REDIS_BILLING_URL'] instead of ENV['REDIS_BILLING_R_URL']assumed to be the same physical Redis, not verified from this checkout (infra access needed). Grepped: no Billings::Preference / Billings::PreferenceUniqueId model exists yet in moderator-be. moderator-be's own top-level Preference model (app/models/preference.rb) is an unrelated, differently-shaped table (company_ids: jsonb array) on moderator-be's main DB — do not confuse the two; the new models must be namespaced Billings:: to avoid collision.
  • The reporting source is the pre-aggregated wa_reconciliation_batches rollup, NOT wa_conversation_logs. wa_conversation_logs carries 200M+ rows; a read-time GROUP BY over it per report request is untenable. SettleDaily (T5) already writes wa_reconciliation_batches (hub_core/database/billing/db/migrate/20260707000001_create_wa_reconciliation_batches.rb) as one row per (organization_id, waba_id, phone_recipient, category, meta_date) — a natural daily rollup — carrying meta_volume bigint (message count), meta_cost_total / settled_amount (Meta base cost), state (pending|settled), and a unique index uq_wrb_bucket + idx_wrb_org_date on (organization_id, meta_date). meta_date is the true usage day (a settled log row's created_at is the settlement-run day, ~T+1 — wrong bucket). The one figure the batch lacks is the client-charged (margin-inclusive) amount the report's "Deducted balance" shows: SettleDaily#settle_holds! (settle_daily.rb:201-233) accumulates only Meta base into batch.settled_amount += total_base (line 230; the per-hold charged amount goes to WaConversationLog.total_price/credit and WaBalanceHold.settled_amount, not the batch). M0 adds a settled_charged_amount column + its accumulation so M2/M3 can read the charged total straight off the rollup.
  • hub_core already exposes a per-message MCC get/download pair to model the new APIs on (shape only, not the data source). hub_service/app/services/api/core/v1/reports/resources/billing.rb mounts GET /reports/billing/mcc_logs (→ Interactors::Billings::UserGetsMccLogRepositories::Billings::Gets::MccLog) and GET /reports/billing/download/{core/}mcc_logs (→ Interactors::Billings::UserDownloadsMccLogsRepositories::Billings::Downloads::MccLogs). Those filter Models::Billing::WaConversationLog to origin_type: ['BI','UI','RC'], is_auto_deduct: true (the live V1 per-message path) — the new repos copy their interactor/route/CSV shape but query wa_reconciliation_batches instead.
  • billing_info already has a same-file precedent for one-off flag exposurehub_core/app/core/domains/builders/billings/billing_info.rb reads several unrelated flags inline in attributes_for_entity (e.g. billing_ecommerce: Services::Billing::BillingEcommerceValidation.new.enabled?(...)); adding is_wa_hold_settlement_enabled follows the exact same pattern. Mounted at GET /billings/info (hub_service/.../billings/resources/billings.rb:607-629).
  • moderator-be's own modpanel MCC download flow is separate from the hub_service one abovemoderator-be/app/controllers/billing/download_billings_controller.rb + Core::UseCases::AppIntegrations::ChatPanel::Reports::MuvMccLogs (app/domains/core/use_cases/app_integrations/chat_panel/reports/muv_mcc_logs.rb), driven by the download_billings/index.html.erb view + billing--downloads Stimulus controller. This is the one DED-S08 actually targets ("in the mod panel..."). It has no existing HTTP client calling hub_service/hub_core (grepped: no HUB_SERVICE/HUB_CORE/Faraday/HTTParty hit) — M5 introduces this call pattern for the first time.
  • Specs are colocated beside source (*_spec.rb) in every repo touched here; hub_core/hub_service via RSpec (bundle exec rspec <path>), moderator-be via RSpec (.rspec, spec/rails_helper.rb).

M0: [BE][hub_core] Persist the client-charged amount on wa_reconciliation_batchesBIF-8959

The reporting rollup already exists (wa_reconciliation_batches, one row per bucket per day) but stores only Meta's base cost, not the client-charged (margin-inclusive) amount the report's "Deducted balance" column displays. M0 adds that one figure so M2/M3 can read it straight off the rollup instead of aggregating the 200M-row per-message log.

Status: ✅ Actionable — prerequisite for M2/M3; added 2026-07-20 per ADR-0002 Decision 5.

⚠️ Edits shipped money-path codeSettleDaily is T5/BIF-8744, the settlement correctness core, already merged. The change is small and purely additive, but it is on the money path, so it carries its own QA (0.5 md) and its own ticket rather than being folded into M2.

What to build

A new settled_charged_amount column on wa_reconciliation_batches, accumulated in SettleDaily#settle_holds! beside the existing Meta-base settled_amount — the sum of each hold's charged_amount (Meta base + the org's margin), which the settlement loop already computes.

Implementation Plan

ActionFileWhat changes
createhub_core/database/billing/db/migrate/<ts>_add_settled_charged_amount_to_wa_reconciliation_batches.rbadd_column :wa_reconciliation_batches, :settled_charged_amount, :decimal, precision: 20, scale: 4, null: false, default: 0.0
extendhub_core/app/apps/billings/repositories/v1/wa_hold_settlement/settle_daily.rb (settle_holds!, ~L201-233)add a total_charged = 0.to_d accumulator beside total_base (L204); total_charged += charged_amount in the loop (after the existing charged_amount at ~L211); batch.settled_charged_amount += total_charged beside batch.settled_amount += total_base (~L230). Do not touch the Meta-base settled_amount/meta_cost_total/unmatched_cost math.
extendhub_core/app/.../models/billing/wa_reconciliation_batch.rb + colocated settle_daily_spec.rbassert settled_charged_amount == Σ(hold.settled_amount) for the bucket across a full pass and a partial (late-delivery) pass; assert the Meta-base invariant settled_amount + unmatched_cost == meta_cost_total is unchanged; free bucket (meta_cost_total == 0) → settled_charged_amount == 0

Acceptance criteria

  • settled_charged_amount on a fully-settled batch equals the sum of its holds' settled_amount (= Σ WaConversationLog.credit for the bucket).
  • Accumulates correctly across partial/late-delivery passes (batch pendingsettled).
  • Existing Meta-base invariant (settled_amount + unmatched_cost == meta_cost_total) and all T5 tally invariants unchanged; migration reversible.

Test strategy

Extend the existing settle_daily_spec.rb fixtures (full settle, partial/reopen, free bucket, 30-day shortfall) to also assert the new charged total; run the full T5 spec suite to prove no regression on the money path.

Effort estimate

DisciplineDays
Backend1
QA0.5
Total1.5

Depends on

  • T5 settlement (BIF-8744, shipped — this modifies it).

M1: [BE] V2-detection — shared prerequisite (two per-repo tickets: M1a hub_core, M1b moderator-be)

Before anything else in this Part can gate on "is this company on Deduction V2", two different consumers need the answer: the client-facing FE (via an API) and moderator-be's own modpanel Rails code (server-side, no network hop). Two small, independent sub-deliverables answer both — tracked as two separate Jira Tasks, one per repo (M1a = BIF-8883 hub_core, M1b = BIF-8958 moderator-be), because they land in different codebases and can be picked up independently.

Status: ✅ Actionable — supersedes the old combined M1 (DeductionV2Status/active_since); see ADR-0002

M1a — [BE][hub_core] Expose wa_hold_settlement on billing_infoBIF-8883

What to build: one new boolean field on the existing GET /billings/info response, read the same way this builder already reads other one-off flags.

ActionFileWhat changes
extendhub_core/app/core/domains/builders/billings/billing_info.rbadd is_wa_hold_settlement_enabled: Services::Billing::FeatureFlag.new.enabled?(:wa_hold_settlement, unique_id: ar_model_instance.organization_package&.company_id) to attributes_for_entity
extendhub_core/app/core/domains/entities/billings/billing_info.rbadd attribute :is_wa_hold_settlement_enabled, Types::Strict::Bool
extendhub_core/app/core/domains/builders/billings/billing_info_spec.rbon/off for the same org id Interactors::Billings::BillingInfo already builds organization_package for

No interactor change needed — Interactors::Billings::BillingInfo#result already passes organization_package: into the builder's Models::Model.new(...).

Acceptance criteria:

  • GET /billings/info response includes is_wa_hold_settlement_enabled, true iff Services::Billing::FeatureFlag.new.enabled?(:wa_hold_settlement, unique_id: company_id) is true for that org's organization_package.company_id.

M1b — [BE][moderator-be] Replicate Services::Billing::FeatureFlagBIF-8958

What to build: a line-for-line port of hub_core's flag-read service, backed by two new billing-shard models moderator-be doesn't have yet.

ActionFileWhat changes
createmoderator-be/app/models/billings/preference.rbclass Billings::Preference < Billings::ApplicationRecord; self.table_name = 'preferences'; end — mirrors hub_core/.../models/billing/preference.rb, same billing shard
createmoderator-be/app/models/billings/preference_unique_id.rbclass Billings::PreferenceUniqueId < Billings::ApplicationRecord; self.table_name = 'preference_unique_ids'; belongs_to :preference, class_name: 'Billings::Preference'; end
createmoderator-be/app/domains/core/services/billing/feature_flag.rbCore::Services::Billing::FeatureFlag#enabled?(feature, unique_id: nil) — port hub_core's Redis-cached read logic (state key → global check → unique-id check) against Billings::Preference/Billings::PreferenceUniqueId and the existing Core::Services::RedisBilling::AbstractRedisBilling::REDIS_BILLING connection
createcolocated feature_flag_spec.rbport hub_core's feature_flag_spec.rb truth table (cold/warm cache × global/unique-id × state on/off) against the new models

Acceptance criteria:

  • Core::Services::Billing::FeatureFlag.new.enabled?(:wa_hold_settlement, unique_id: company_id) agrees with hub_core's answer for the same org, for every case in the ported truth table (flag-off no record, state-false, is_global-true, unique-id present/absent, warm/cold Redis).
  • No collision with moderator-be's existing top-level Preference model (different table, different DB connection, different class namespace).

Effort estimate

DisciplineDays
Backend (M1a)0.5
Backend (M1b)1.5
Total2

Assumption flagged in ADR-0002: REDIS_BILLING_URL (moderator-be) and REDIS_BILLING_R_URL (hub_core) are assumed to point at the same physical Redis; if not, M1b still works, just always cold-reads the DB.

Depends on

  • hub_core T1 (wa_hold_settlement flag + Services::Billing::FeatureFlag) — shipped (BIF-8740).

M2: [BE][hub_core] New GET usage API — daily-aggregate read (DED-S07)

hub_core exposes a new read endpoint returning usage grouped by day × WhatsApp Business × category, matching the reference screenshot. It reads the pre-aggregated wa_reconciliation_batches rollup (one row per bucket per day), NOT a GROUP BY over the 200M-row wa_conversation_logs — modeled on the existing Gets::MccLog pair for shape only.

Status: ✅ Implemented (BIF-8884) — hub_core repo + interactor + hub_service route shipped with colocated specs. See documentations/wa-deduction-history-usage-api.md.

What to build

A new repository + interactor, mounted as a new hub_service route, returning one row per (usage day = meta_date, WhatsApp Business = waba_id, category) with meta_volume as the message count and settled_charged_amount as the deducted balance, read directly from the rollup table.

Implementation Plan (as built)

ActionFileWhat changed
createhub_core/app/core/domains/repositories/billings/gets/wa_deduction_history.rbshape modeled on Repositories::Billings::Gets::MccLog; queries the rollup Models::Billing::WaReconciliationBatch.where(organization_id:, meta_date: range, state: @states), collapsing across phone_recipient via group(:meta_date, :waba_id, :category).pluck(:meta_date, :waba_id, :category, SUM(meta_volume), SUM(settled_charged_amount)). Presents usage_date (meta_date), waba_id, category, message_count (meta_volume), deducted_balance (settled_charged_amount). Optional waba_id filter + offset/limit pagination. states is queryable — a whitelisted subset of %w[pending settled], sanitized (unknown values dropped), defaulting to both when unset. Gated by wa_hold_settlement_enabled? (reuses Repositories::Billings::Helpers)
createhub_core/app/core/domains/interactors/billings/user_gets_wa_deduction_history.rbcontract mirrors UserGetsMccLog (organization_id, actor_id, optional date range/pagination/timezone/waba_id) plus optional states array; check_range_date guard identical to UserGetsMccLog
createcolocated *_spec.rb for repo + interactorgrouping grain, meta_date bucketing, empty state, wrong-org isolation, gate-off, waba_id filter, pagination, states filter (default / explicit / unknown-fallback / comma-string)
extendhub_service/app/services/api/core/v1/reports/resources/billing.rbnew get '/deduction_history' inside resource 'billing', plus a colocated request spec. oauth2 :admin, :owner, :supervisor, :agent, :member, :bot — mirrors the actual GET /mcc_logs route scope (no :modpanel; the client hub app is the consumer, M4). states exposed as Array[String], values: %w[pending settled]

Acceptance criteria

  • DED-S07/AC-1 (revised): one row per (usage day = meta_date, WhatsApp Business, category); message count = meta_volume, deducted balance = settled_charged_amount. (Supersedes the PRD's literal destination_country grouping — flagged above.)
  • Reads wa_reconciliation_batches only — no aggregation over wa_conversation_logs.
  • Buckets by the true usage day (meta_date), not the settlement-run day (created_at on a settled log row is ~T+1).
  • No batches for the org/range → empty result (DED-S07/ERR-1's empty-state signal is carried by the FE, M4).
  • Pending/partial batches (late deliveries still settling) reflect current progress; V2-off org → gated failure. New: the FE can narrow the view via the states query param (default = pending + settled).

Test strategy

RSpec seeding Models::Billing::WaReconciliationBatch fixtures across meta_dates/waba_ids/categories, including a partial (pending) batch; assert grain, meta_date bucketing, meta_volume/settled_charged_amount mapping, and empty state. No wa_conversation_logs fixtures needed.

Effort estimate

DisciplineDays
Backend2
QA0.5
Total2.5

Depends on

  • [M0] (settled_charged_amount on the rollup); hub_core T5 settlement (writes the rollup) — shipped (BIF-8744).

M3: [BE][hub_core] New download API — daily-aggregate CSV export (DED-S08 backend)

hub_core exposes a new export endpoint that always returns the daily-aggregate lump-sum CSV — there is no "per message" mode to select, because this endpoint never produces per-message rows. Reads the same wa_reconciliation_batches rollup as M2; modeled on Downloads::MccLogs for shape only.

Status: ✅ Implemented (BIF-8885) — hub_core download repo + interactor + hub_service modpanel route shipped with colocated specs. Shares M2's query via Repositories::Billings::WaDeductionHistoryQuery. See documentations/wa-deduction-history-usage-api.md.

What to build

A new repository + interactor + hub_service route that streams the same (usage day, WhatsApp Business, category) rollup rows as a CSV, for moderator-be (M5) to call instead of building its own export.

Implementation Plan (as built)

ActionFileWhat changed
createhub_core/app/core/domains/repositories/billings/wa_deduction_history_query.rbnew shared module Repositories::Billings::WaDeductionHistoryQuery — the single source of query truth (grouping, states sanitize, row builder) extracted from M2's repo so GET and download can't diverge
refactorhub_core/app/core/domains/repositories/billings/gets/wa_deduction_history.rbM2 repo now includes the shared module instead of inlining the query
createhub_core/app/core/domains/repositories/billings/downloads/wa_deduction_history.rbshape modeled on Downloads::MccLogs (extends Reports::AbstractReporting, Dry::Monads::Do.for(:call)); calls wa_deduction_history_rows (same meta_date range + state + waba_id), returns all rows (no pagination). Gated by wa_hold_settlement_enabled?; supports organization_id or moderator_account_id like Downloads::MccLogs. Empty result → Success([]) (header-only CSV, not a failure)
createhub_core/app/core/domains/interactors/billings/user_downloads_wa_deduction_history.rbcontract mirrors UserDownloadsMccLogs + optional states array
createcolocated *_spec.rb for repo + interactorgrouping grain, empty→Success([]), gate-off, states filter, and an explicit parity test asserting the download rows equal M2's GET rows for the same org/range
extendhub_service/app/services/api/core/v1/reports/resources/billing.rb (top-level resource 'download')new get 'deduction_history' → CSV (file_response_header + CSV.open, same idiom as the modpanel mcc_logs download), oauth2 :modpanel (the consumer is moderator-be/M5; the client uses M2's GET). Columns: WhatsApp Business, Category, Usage Day, Message Count, Deducted Balance. states exposed as Array[String], values: %w[pending settled]

Acceptance criteria

  • DED-S08 (revised): the response is always the daily-aggregate CSV (WhatsApp Business, category, usage day = meta_date, message count = meta_volume, deducted balance = settled_charged_amount) — there is no aggregation_by param, no per-message mode, and therefore no 422/rejection case to build. (Supersedes DED-S08/AC-1–3 and ERR-1's date-range/422 mechanics — flagged above.)
  • Reads wa_reconciliation_batches only — no wa_conversation_logs aggregation.
  • CSV rows match M2's GET rows exactly for the same org/range (single source of query truth — enforced by the shared WaDeductionHistoryQuery module + a parity spec).
  • No batches → CSV with header row only (empty body), not an error.

Test strategy

RSpec asserting the CSV body against the same rollup fixtures M2 uses; a contract test (or shared example) asserting M2 and M3 never disagree on row count/values for an identical fixture set.

Effort estimate

DisciplineDays
Backend1.5
QA0.5
Total2

Depends on

  • [M2] (shares its rollup query), [M0] (settled_charged_amount).

M4: [FE][hub — client Nuxt app; repo not checked out, paths unverified] Client usage page (DED-S07)

The PRD's original DED-S07 FE target — the client-facing "WhatsApp conversation usage" table (the reference screenshot) — consuming M2's new GET API.

Status: ⚠️ Actionable in design, not grounded — the hub repo is not checked out alongside documents/hub_core/hub_service/moderator-be in this workspace, so the file paths below are carried over from the PRD's own repo-analysis note (v1.4, §7 CHG-001), not independently verified. Re-ground this task before starting work, once hub is available.

What to build (per PRD, unverified against real code)

Render M2's aggregate rows in the existing usage page, replacing or branching from the current per-message table when the org is on V2 (per is_wa_hold_settlement_enabled from M1a's billing_info field).

Implementation Plan (unverified — re-ground before starting)

ActionFileWhat changes
extendhub/pages/finances/package_usage.vue (PRD-cited, unverified)branch on billing_info.is_wa_hold_settlement_enabled
extendhub/.../ReportingMcc.vue (PRD-cited, unverified)render M2's day × WhatsApp Business × category rows; loading/empty/error states per DED-S07's UI-states spec

Acceptance criteria

  • Renders one row per (day, WhatsApp Business, category) from M2; per-message rows never shown for a V2-on org.
  • Empty state ("No deduction history yet for this period.") when M2 returns no rows; error state + retry on load failure (deduction_history_load_failed logged, per PRD).
  • Feature-flag branch keys off billing_info.is_wa_hold_settlement_enabled (M1a) — no separate FE-side flag check.

Test strategy

Component test across populated/empty/error states once the real component location is confirmed.

Effort estimate

DisciplineDays
Frontend2
QA0.5
Total2.5

Confidence: low relative to the other tasks in this Part — grounded only against PRD prose, not code. Re-scope on first contact with the real hub repo.

Depends on

  • [M2], M1a (billing_info flag field).

M5: [BE+FE][moderator-be] Modpanel MCC download — route V2 companies to the new hub_core aggregate API (DED-S08 frontend/glue)

When a Finance user in modpanel requests an MCC export for a V2-on company, moderator-be no longer builds the export itself — it calls M3's new hub_core endpoint and streams that response back. The "Per message" option is hidden unconditionally for V2-on companies (no date-range branching — see ADR-0002).

Status: ✅ Actionable (after M1b, M3)

What to build

  1. A new outbound HTTP client in moderator-be calling hub_service's new download route (M3) — the first such call site in this codebase (no prior client/pattern exists).
  2. Wiring in download_billings_controller/MuvMccLogs: when Core::Services::Billing::FeatureFlag.new.enabled?(:wa_hold_settlement, unique_id: company_id) (M1b) is true, proxy the export request to M3 instead of running the existing per-message/ aggregate query.
  3. Hide the "Per message" radio unconditionally (not date-range-gated) in the download dialog when the company is V2-on.

Implementation Plan

ActionFileWhat changes
createapp/domains/core/services/hub_service_client.rb (or similar; new — no prior pattern)thin HTTP client (Faraday/HTTParty, whichever the repo's Gemfile already vendors) calling the M3 GET .../download/deduction_history route with the company's org id; explicit timeout + error handling (new failure mode for this codebase)
extendapp/domains/core/use_cases/app_integrations/chat_panel/reports/muv_mcc_logs.rbbefore the existing aggregation_by default/dispatch: if Core::Services::Billing::FeatureFlag.new.enabled?(:wa_hold_settlement, unique_id: company_id) → delegate to the new HTTP client and return its CSV; otherwise unchanged
extendapp/views/billing/download_billings/index.html.erb (#aggregation--field :65-82)hide #aggregation_by_message (:77) unconditionally when the company is V2-on — no data- attributes for a boundary date, since none exists anymore
extendapp/javascript/controllers/billing/downloads_controller.js (setToggle :257-281)simplify: V2-on → hide "By Message", force aggregation_by to a value the new flow ignores (or omit the param entirely, since M3's route takes no aggregation_by)
extendmuv_mcc_logs_spec.rb + a request spec for the new HTTP clientV2-on → HTTP client called, response streamed as-is; V2-off → existing behavior byte-identical; HTTP client timeout/5xx → user-facing error, not a silent empty export

Acceptance criteria

  • V2-on company → download dialog offers only daily-aggregate; the request is served by proxying M3's response, not by any local aggregation query.
  • V2-off company → byte-identical current behavior (per-message export via the existing path).
  • hub_service unavailable/errors → the user sees an explicit failure, not a silently empty or truncated CSV.
  • No date-range branching exists anywhere in this task (supersedes the old M2/M3's AC-2/AC-3/ERR-1 date-boundary mechanics).

Test strategy

RSpec on MuvMccLogs with the new HTTP client stubbed (success/timeout/5xx) across V2 on/off; a request spec asserting the download dialog hides "Per message" for a V2-on company regardless of any date range selected.

Effort estimate

DisciplineDays
Backend1.5
Frontend0.5
QA0.5
Total2.5

Depends on

  • [M1b] (flag replica), [M3] (the endpoint being called).

M6: [BE+FE] Manual "trigger settle daily" — modpanel Finance menu (single/many/all CID) → hub-service → hub-core (DED-S03) — BIF-9016

Serves PRD DED-S03 (Date-Specific Manual Re-trigger), broadened 2026-07-26 to a modpanel Finance-menu operator tool that can re-run settlement for one, many, or all companies (CIDs). The engine can already re-run settlement (T5: SettleWaHoldsWorker per org, SettleWaHoldsDispatcherWorker fleet-wide); this adds the missing operator-facing way to invoke it. Today it's console/rake only.

Status: ✅ Actionable (after M1b + M5's HTTP client). Not started.

What to build

A modpanel Finance-menu trigger page (not the account-detail page) that enqueues settlement for a single CID, a list of CIDs, or all CIDs, spanning the same three repos as M5: moderator-be (Finance-menu item + controller + client) → hub-service (endpoint) → hub-core (interactor → SettleWaHoldsWorker per org, or SettleWaHoldsDispatcherWorker for All). Optionally target-date-scoped for back-dated re-runs (DED-S03's date-specific recovery).

Trigger scope. The operator picks one of:

  • Single CID — one organization_id.
  • Many CIDs — a list of organization_ids.
  • All CIDs — fleet-wide; enqueues SettleWaHoldsDispatcherWorker, which enumerates the eligible companies.

Eligibility rule (gating). A CID is settled only when wa_hold_settlement_enabled? is true for it — i.e. its per-company wa_hold_settlement flag is ON, OR the global flag is ON (Services::Billing::FeatureFlag#enabled?(:wa_hold_settlement, unique_id: company_id); see flag runbook). Global ON → every CID passes; global OFF → only allow-listed CIDs (the preference_unique_ids opt-in) pass. Ineligible CIDs in a single/many request are skipped and reported (never a silent success); All enumerates only eligible companies (the dispatcher already extras/allow-list-filters when global is OFF).

Implementation Plan

ActionFileWhat changes
createhub_core/app/core/domains/interactors/billings/trigger_wa_hold_settlement.rbcontract (organization_id | organization_ids[] | scope: all, optional target_date). Single/many: filter by wa_hold_settlement_enabled?, enqueue Billings::SettleWaHoldsWorker.perform_async(organization_id) per eligible CID, return the enqueued + skipped(not-enabled) breakdown. All: enqueue Billings::SettleWaHoldsDispatcherWorker.perform_async. Informative Failure when nothing is eligible
extend (optional)hub_core/app/core/workers/billings/settle_wa_holds_worker.rbaccept perform(organization_id, target_date = nil) and forward a today:/window override into SettleDaily.new for a back-dated re-run (DED-S03/AC-1..2)
extendhub_service/app/services/api/core/v1/billings/resources/billings.rbnew post '/settle_wa_holds' (oauth2 :modpanel; moderator_account_id, one of organization_id / organization_ids / all, optional target_date) → interactor + Dry::Matcher::ResultMatcher, mirroring the existing modpanel POST actions. Response includes per-CID outcome (enqueued vs skipped-not-enabled)
extendmoderator-be/app/views/shared/_sidebar_finance.html.erbnew Finance-menu item (gated by a new can? permission) linking to the trigger page
createmoderator-be/app/controllers/billing/wa_hold_settlements_controller.rb + app/views/billing/wa_hold_settlements/ + Stimulus controllerFinance-menu page with a form to enter a single CID, paste many CIDs, or choose All; action calls the new hub-service route via the M5 HTTP client; confirm dialog + disable-on-submit + success/error toast; surface the per-CID enqueued/skipped result
createcolocated specs (hub-core interactor + worker; hub-service request; moderator-be request/controller)single enqueues; many enqueues only eligible + reports skipped; All enqueues the dispatcher; global-ON vs global-OFF gating; hub-service error surfaces to the operator

Acceptance criteria

  • DED-S03 (single/many/all): a modpanel operator on the Finance menu can trigger settlement for one CID, a list of CIDs, or all CIDs; the request reaches hub-core and enqueues SettleWaHoldsWorker (per CID) or SettleWaHoldsDispatcherWorker (All).
  • Gating: a CID is settled only if its per-company wa_hold_settlement flag is ON or the global flag is ON; ineligible CIDs are skipped and reported, never silently settled.
  • All-CIDs honors the same rule: global ON → every company; global OFF → only allow-listed companies.
  • Safe: settlement is per-bucket idempotent (T5), so a repeat trigger can't double-charge; button disables on submit.
  • hub-service/hub-core unavailable → explicit operator-facing error, never a silent success.
  • (If target-date scoped) a back-dated trigger re-runs only that window; other dates untouched.

Effort estimate

DisciplineDays
Backend1.5
Frontend0.5
QA0.5
Total2.5

Depends on

  • [T5] (SettleWaHoldsWorker/SettleDaily, shipped — BIF-8744), [M1b] (flag replica), [M5] (the moderator-be→hub-service HTTP client pattern this reuses).

Part 2 ordering rationale

  • Critical path: M0 → M2 → M3 → M5, with M1a/M1b and M4 alongside. M0 (the rollup's charged column) must land before M2/M3 can read the deducted-balance figure. M2 (GET) and M3 (download) share the rollup query, so M3 follows M2 directly. M5 (moderator-be wiring) needs both M1b (the flag) and M3 (the endpoint to call). M1a and M1b are independent of each other and of M0; M4 (client FE) needs M2 + M1a and can run parallel to M3/M5.
  • M0 is the one change to shipped money-path code — small and additive, but on the settlement core, so it is sequenced and QA'd first; everything downstream reads its output rather than re-deriving it.
  • Reporting reads the pre-aggregated rollup, never the 200M-row log — M2/M3 query wa_reconciliation_batches (one row per bucket per day), keyed on the true usage day (meta_date). This is the single largest performance decision in this Part (ADR-0002 Decision 5).
  • No boundary-date computation anywhere in this Part — the biggest scope simplification from the 2026-07-15 plan. M2/M3's queries need no "activation date" input; M5's gating needs no date-range comparison.
  • hub_core tasks (M0, M2, M3) have no hard dependency on moderator-be's M1b — they only need the wa_hold_settlement flag they already read via T1's Helpers module. M1b is only a prerequisite for M5 (moderator-be's own gating check), not for the hub_core side.
  • M4 carries the lowest confidence in this Part (repo not checked out) — schedule it with a re-grounding step as its first activity, not its last.

Relationship between the two parts

Part 2 consumes Part 1's output; its only new money-path touch is M0's additive settled_charged_amount column on the settlement batch. The rollup rows M2/M3 read (wa_reconciliation_batches) are written exclusively by SettleDaily (T5, BIF-8744); M0 just adds one derived total to them. Both stories' V2-detection keys off the same canonical flag T1 already shipped (Services::Billing::FeatureFlag/:wa_hold_settlement) — read directly via billing_info (M1a) for FE, and via a same-shard replica (M1b) for moderator-be's own server-side checks — rather than a moderator-be-specific derived signal.


Skipped stories

Story / itemReason
PRD DED-S05/DED-S06 — delayed local-midnight toggle (deduction_v2_effective_date/_end_date)Belongs to the earlier debit-at-webhook design. This design gates with a plain wa_hold_settlement flag, no effective/end-date columns. Dropped.
PRD DED-S07 — daily-aggregate deduction-history UIBackend read logic is now in hub_core (task M2, this Part), not moderator-be — hub_core owns the settled wa_conversation_logs rows (T5) it aggregates. FE consumer is task M4 in the client hub app.
PRD DED-S08 — modpanel MCC per-message download gatingBackend export logic is now in hub_core (task M3), not moderator-be. moderator-be (task M5) only calls it and hides the "Per message" option — no local aggregation query, no date-range gate.
PRD DED-S08/AC-2, AC-3, ERR-1 (date-range-dependent per-message/daily gating, 422 per_message_unavailable_for_v2)Superseded 2026-07-20 by ADR-0002 — replaced by a flat per-company rule (V2-on ⇒ daily-aggregate only, any range). Needs a PRD refresh; flagged, not silently applied.
PRD DED-S07's destination_country grouping dimensionSuperseded 2026-07-20 — the new hub_core API groups by day × WhatsApp Business number × category (screenshot-grounded), not day × category × destination country. Needs a PRD refresh; flagged, not silently applied.
Core::Services::Billing::DeductionV2Status + active_since (the 2026-07-15 M1 design; ADR-0001)Superseded — no boundary date is computed anywhere in the redesigned Part 2. Replaced by a direct Services::Billing::FeatureFlag replica (M1b).
wa_held_deduction_logs table + WaHeldDeduction repo + mcc_logs aggregate branchFrom the earlier design; replaced here by wa_balance_holds + wa_reconciliation_batches + SettleDaily. Dropped.