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 realhub_corecheckout. The hold/settlement layer was greenfield — a repo-wide grep found no pre-existingwa_balance_holds/wa_reconciliation_batches/wa_hold_settlement/CreateHold/SettleDailycode.- 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
hubapp, 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
| Task | BE days | FE days | QA days | Total |
|---|---|---|---|---|
| T1 — Hold ledger schema, models & enablement toggle | 2 | — | — | 2 |
| T2 — Create hold at send | 2 | — | 0.5 | 2.5 |
| T3 — Available-balance helper, send-gate & Available-only display | 2 | — | 0.5 | 2.5 |
| T4 — Webhook state transitions + broadcast dual-emit guard | 2 | — | 0.5 | 2.5 |
| T5 — EOD settlement against Meta | 4 | — | 1 | 5 |
| T6 — Stale-hold sweeper (30-day expiry) | 0.5 | — | 0.5 | 1 |
| T7 — Cron scheduling + docs | 0.5 | — | — | 0.5 |
T8 — Guard NULL held_at (NOT NULL migration + sweeper safety net) — post-ship hardening, not started | 0.5 | — | 0.5 | 1 |
| Subtotal — Part 1 (hub_core engine) — T1–T7 shipped (16), T8 hardening not started (1) | 13.5 | — | 3.5 | 17 |
M0 — Persist client-charged amount on wa_reconciliation_batches (hub_core settlement) | 1 | — | 0.5 | 1.5 |
M1a — V2-detection: expose wa_hold_settlement on billing_info (hub_core) | 0.5 | — | — | 0.5 |
M1b — V2-detection: replicate Services::Billing::FeatureFlag (moderator-be) | 1.5 | — | — | 1.5 |
| M2 — New GET usage API, reads the daily rollup (hub_core, DED-S07 backend) | 2 | — | 0.5 | 2.5 |
| M3 — New download API, daily rollup CSV (hub_core, DED-S08 backend) | 1.5 | — | 0.5 | 2 |
| M4 — Client usage page (hub app; unverified, DED-S07 frontend) | — | 2 | 0.5 | 2.5 |
| M5 — Modpanel download wiring → hub_core (moderator-be, DED-S08 frontend/glue) | 1.5 | 0.5 | 0.5 | 2.5 |
| M6 — Manual "trigger settle daily" (moderator-be Finance menu, single/many/all CID → hub-service → hub-core, DED-S03) | 1.5 | 0.5 | 0.5 | 2.5 |
| Subtotal — Part 2 (modpanel/client + ops) | 9.5 | 3 | 3 | 15.5 |
| Grand total | 23 | 3 | 6.5 | 32.5 |
Confidence — Part 1: n/a (shipped). All 7 tasks are
Doneand 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 inhub_core/hub_service/moderator-be. M4 is the exception: thehubclient 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 id | Jira | Type | SP | Split from story |
|---|---|---|---|---|
| DED-S07 | BIF-8881 | Story | — | — |
| DED-S08 | BIF-8882 | Story | — | — |
| M0 | BIF-8959 | Task | 1.5 | DED-S07 |
| M1a | BIF-8883 | Task | 0.5 | DED-S07 |
| M1b | BIF-8958 | Task | 1.5 | DED-S08 |
| M2 | BIF-8884 | Task | 2.5 | DED-S07 |
| M3 | BIF-8885 | Task | 2 | DED-S08 |
| M4 | BIF-8886 | Task | 2.5 | DED-S07 |
| M5 | BIF-8887 | Task | 2.5 | DED-S08 |
| M6 | BIF-9016 | Task | 2.5 | DED-S03 (manual re-trigger) |
| T8 | BIF-9015 | Task | 1 | net-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_batchesrollup instead of the 200M-rowwa_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_athardening) 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 viabundle exec rspec <path>+bundle exec rubocop. - Migrations:
database/billing/db/migrate/(YYYYMMDDHHMMSS_slug.rb); models extendModels::AbstractModelBillingon the:billingshard. Helpers#available_wa_balancedoes not exist (onlyfind_whatsapp_package) — net-new.config/sidekiq_schedule.ymldoes not exist — T7 creates it (loaded byconfig/initializers/sidekiq.rb:27-30).- Enablement is the company-scoped
wa_hold_settlementfeature flag — as-built:Services::Billing::FeatureFlag.new.enabled?(:wa_hold_settlement, unique_id: company_id), backed by the billingpreferences/preference_unique_idstables +REDIS_BILLING_Rcache. Tiers: global (preferences.is_global = true→ all companies) or per-company (apreference_unique_idsallow-list entry percompany_id). A sharedHelpers#wa_hold_settlement_enabled?(organization_package)predicate — delegating toServices::Billing::FeatureFlagwithunique_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-tierServices::Preference∨organization_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_settlementfeature 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
| Action | File | What changes |
|---|---|---|
| create | hub_core/database/billing/db/migrate/<ts>_create_wa_balance_holds.rb | one row per billable message; cols per plan §Data-model; indexes incl. partial unique (external_id, state) where external_id IS NOT NULL |
| create | hub_core/database/billing/db/migrate/<ts>_create_wa_reconciliation_batches.rb | one row per settled bucket; unique (organization_id, waba_id, phone_recipient, category, meta_date) + (organization_id, meta_date) |
| create | hub_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 |
| create | hub_core/app/core/domains/models/billing/wa_reconciliation_batch.rb | < Models::AbstractModelBilling; enum state: { pending: 'pending', settled: 'settled' }; lock_version |
| create | hub_core/app/core/domains/models/billing/wa_balance_hold_spec.rb | enum transitions, default state held, uniqueness guard |
| create | hub_core/app/core/domains/models/billing/wa_reconciliation_batch_spec.rb | uniqueness index, default state pending |
| extend | hub_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/extend | hub_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
- Explore — open
hub_core/app/core/domains/models/billing/wa_conversation_log.rb(baseModels::AbstractModelBilling, lockbox +enum billed_to: {...}style) and the 3 most recent files indatabase/billing/db/migrate/to copy the timestamp+slug naming and the:billing-shard migration shape. - Write failing model specs (red) — create the two
*_spec.rbcolocated with the models; assert default states and the enum value maps.bundle exec rspec <path>→ fails. - Migrations — create
wa_balance_holdswith all columns from the plan (organization_id/organization_package_id/channel_id/message_iduuid;external_id,conversation_category,pricing_model default 'PMP',estimated_amount decimal(20,4),country,phone_recipient,customer_refnullable,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. Createwa_reconciliation_batcheslikewise. Standardize all tenant ids asuuid(plan note — the EOD draft's bigint is wrong for this codebase). - Models — add both models with state enums and confirm optimistic locking is active (
lock_versionpresent +AbstractModelBilling.locking_enabled?). - 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).Helpersis a module —includeit (the interactor + deduction repo already do). This is the single gate T2–T6 reuse, so all branches agree (mutual-exclusion invariant).Services::Billing::FeatureFlagis read-only in hub_core and resolves the flag from the billingpreferences/preference_unique_idstables viaREDIS_BILLING_R. Spec its truth table:state=false/absent → false;is_global=true→ true; per-company allow-list hit → true; nilcompany_id→ false (fail-safe). - Register the flag — the
wa_hold_settlementbilling preference is registered + its per-companypreference_unique_idsallow-list managed in the billing-preferences service (hub_core only reads). Defaults OFF; pilot = addcompany_idto the allow-list; GA =is_global=true. (Not app code — see the runbook.) - Go green —
bundle exec rails db:migrate(billing DB) up and down; rerun specs until green. - Quality gate —
bundle exec rubocopon 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(matchWaConversationLog/WhatsappUsageComparison). -
Helpers#wa_hold_settlement_enabled?(organization_package)delegates toServices::Billing::FeatureFlag.new.enabled?(:wa_hold_settlement, unique_id: organization_package&.company_id); true iff the flag isis_globalor the company is in the allow-list; nilcompany_id/absent flag → false (fail-safe). -
wa_hold_settlementbilling-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
| Discipline | Days |
|---|---|
| Frontend | — |
| Backend | 2 |
| QA | — |
| Total | 2 |
Assumptions: reuses
AbstractModelBilling+ existing migration patterns; no Lockbox PII columns on holds (customer_refis 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 ∨ packageextras) sends a billable WhatsApp message, aheldrow is reserved against its balance the moment thewamidis 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
| Action | File | What changes |
|---|---|---|
| create | hub_core/app/apps/wa_cloud/repositories/billings/create_hold.rb | INSERT-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) |
| extend | hub_core/app/core/domains/repositories/v2/billings/new_pricing_wa_deduction.rb | extract is_free_deduction? (~199-205) into a shared predicate both send-time and webhook-time call |
| extend | hub_core/app/core/events/subscribers/message_send.rb | call CreateHold in the success branch (~line 43, external_id = result.success[:id]), guarded by target_channel == 'wa_cloud' + flag |
| extend | hub_core/app/apps/wa_cloud/repositories/broadcast/send.rb | call CreateHold where broadcast wamid is persisted to MessageBroadcastLog.external_id (~227-232) |
| create | hub_core/app/apps/wa_cloud/repositories/billings/create_hold_spec.rb | Success/Failure/wrong-org/free-category skip; flag on/off; idempotent double-insert |
Implementation steps
- Explore — read
new_pricing_wa_deduction.rblines 199-205 (is_free_deduction?) and theWaPricing#total_price(org_id, package_id)inputs inapp/core/domains/services/billing/v2/wa_pricing.rb; readmessage_send.rb:43andbroadcast/send.rb:227-232to see exactly whereexternal_idbecomes available. - 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). KeepNewPricingWaDeductioncalling it. - Write failing spec (red) —
create_hold_spec.rb: billable PMP message → oneheldrow with correctestimated_amount,phone_recipient,external_id;service/free category → no row; flag off → no row; duplicateexternal_id→ single row, returns Success. - Scaffold CreateHold — new repo,
Dry::MonadsSuccess/Failure likenew_pricing_wa_deduction.rb; INSERT only, no pool mutation. - Wire send points — single-message subscriber + broadcast persist point, both flag-gated; failure branch (no wamid) → no hold.
- Go green then quality gate (
rubocop).
Acceptance criteria
- Billable PMP send (flag on) creates exactly one
heldhold withestimated_amountfromWaPricingandphone_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/
balancefield 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
| Discipline | Days |
|---|---|
| Frontend | — |
| Backend | 2 |
| QA | 0.5 |
| Total | 2.5 |
Assumptions: reuses
WaPricingas-is for the estimate;external_idis 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
| Action | File | What changes |
|---|---|---|
| extend | hub_core/app/core/domains/repositories/billings/helpers.rb | add available_wa_balance(wa_package) = balance_initial + balance + postpaid_limit − Σ(holds in held|delivered); keep the existing postpaid conditional (v3-postpaid only) |
| extend | hub_core/app/core/domains/interactors/abstract_iteractor.rb | replace estimate-only check (~65-76) with available_wa_balance − balance_deduct < 0 → Failure, flag-gated |
| extend | hub_core/app/apps/wa_cloud/repositories/broadcast/send.rb | same 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 |
| create | hub_core/database/billing/db/migrate/<ts>_add_active_reservation_index_to_wa_balance_holds.rb | perf: partial composite index (organization_package_id, estimated_amount) WHERE state IN ('held','delivered') → index-only Σ; inactive rows drop out |
| extend | helpers.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 |
| create | hub_core/app/core/domains/repositories/billings/helpers_spec.rb (or extend existing) | helper math with/without holds; postpaid conditional; cache TTL + invalidation |
Implementation steps
- Explore — read
helpers.rb:218-232(find_whatsapp_package, the balance fields) andabstract_iteractor.rb:27-78(validate_wa_balance, esp. 65-76) to see the exact comparison being replaced; locate theget usage balanceinteractor that reads the package. - Write failing spec (red) — helper returns
pooled − Σholdsforheld|delivered, ignoressettled/refunded/expired; gate fails whenavailable − deduct < 0; both flag branches. - Helper — add
available_wa_balance; this is the single source so display and gate never disagree. - Switch the two gates — substitute in
abstract_iteractor.rbandbroadcast/send.rb, flag-gated (flag off = byte-identical current behavior). - Switch the balance endpoint — Available-only when flag on, reusing the same helper.
- Go green + quality gate.
Acceptance criteria
-
available_wa_balancesubtracts onlyheld|deliveredholds; reused by gate and display. - Send blocked when
available − cost < 0(flag on); unchanged when flag off. -
get usage balancereturns 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;CreateHoldinvalidates 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
| Discipline | Days |
|---|---|
| Frontend | — |
| Backend | 2 |
| QA | 0.5 |
| Total | 2.5 |
Assumptions: balance fields are exactly
balance_initial/balance/postpaid_limitper 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/read → held→delivered;
failed/expired → held|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
| Action | File | What changes |
|---|---|---|
| extend | hub_core/app/core/domains/repositories/v2/billings/new_pricing_wa_deduction.rb | flag-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 |
| extend | hub_core/app/core/domains/repositories/v2/billings/new_pricing_wa_deduction_spec.rb | webhook idempotency (call 2× → one transition); refund branch; flag on/off; missing-hold path |
Implementation steps
- 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. - Write failing spec (red) — flag-on
deliveredtransitionsheld→delivered, no pool change; seconddelivered/readis a no-op;failed→refunded; missing hold → Success + metric, no deduction; flag-off path byte-identical to today. - Add the transition branches — flag-gated; preserve
WaUniqConvIdLogdedupe and theStaleObjectError/QueryCanceledre-enqueue exactly. - Suppress live deduction + broadcast report when flag on (these move to T5).
- Go green + quality gate.
Acceptance criteria
- Flag-on
delivered/read→held→delivered, idempotent, zero pool mutation. - Flag-on
failed/expired→held|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
| Discipline | Days |
|---|---|
| Frontend | — |
| Backend | 2 |
| QA | 0.5 |
| Total | 2.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
WaConversationLogrows at all (legacy wrotebilled_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'sbase_priceplus the org's configured margin (review feedback: the log'sbase_pricecolumn mirrors Meta exactly for reporting; the client charge adds margin), and one actual-amountWaConversationLogrow 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
| Action | File | What changes |
|---|---|---|
| create | hub_core/app/apps/billings/repositories/v1/wa_hold_settlement/settle_daily.rb | per [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 |
| create | hub_core/app/core/workers/billings/settle_wa_holds_dispatcher_worker.rb | retry: 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 |
| create | hub_core/app/core/workers/billings/settle_wa_holds_worker.rb | retry: 0, throttled 1000/h; per org resolve WABA tz via Concerns::WaTimezoneResolver; call repo per [waba_id, tz]; rescue StaleObjectError/QueryCanceled → re-enqueue |
| reuse | hub_core/app/core/workers/billings/broadcast_deduction_report_worker.rb | no 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 |
| create | colocated *_spec.rb for the repo + both workers | FIFO, residual, margin-vs-base split, idempotent re-run, count-mismatch reopen, shortfall, sweeper interplay, monthly-reset is_auto_deduct=false — 29 examples |
Implementation steps
- Explore — read
record_daily_meta.rb(find_or_create_by 97-100,index_by_phone_and_category78-93,phone_recipient81/136,persist_status_only!),meta_pricing_analytics.rb.callkw-args, the ladder innew_pricing_wa_deduction.rb:87-127, andsingle_reset_package.rb:222-238(the gap queries that must ignoreis_auto_deduct=false). - Write failing specs (red) — stub
MetaPricingAnalytics(per-dayinstance_double) andWaPricing(margin =total_price − base_price, default 0): assert pool deducted by Meta base + margin, holdssettled,Σ WaConversationLog.base_price == meta_cost_total(reporting mirrors Meta); re-run 2× → one money movement;consumed.size < meta_volumeleaves batchpending+ reopens; 30d horizon writes onereconciliation_shortfall;COST==0settles free. - SettleDaily — model on
record_daily_meta.rb; bucket key(waba_id, phone_recipient, conversation_category, meta_date); FIFO candidate poolstate:'delivered', reconciliation_batch_id: nilordereddelivered_at ASC, no proximity/customer filter (immune to phone→bsuid migration); money movement once per bucket inside a txn, optimistic-locked. - Dispatcher + worker — mirror
record_daily_wa_usage_comparison_worker.rb/record_daily_wa_usage_worker.rb(bothretry: 0, the per-org one includesWaTimezoneResolver). - Broadcast report at settlement — enqueue
BroadcastDeductionReportWorkerwith actual amount formessage_broadcast_idholds. - Go green + quality gate.
Acceptance criteria
- Per bucket,
Σ log.base_price == batch.settled_amount, andsettled_amount + unmatched_cost == meta_cost_totalexactly (residual to the batch-completing hold). - Pool deducted by Meta base share + org margin; log
base_pricemirrors Meta exactly; one actual-amountWaConversationLogper 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 NULLfilter). - Late delivery:
consumed.size < meta_volumesettles available, batch stayspending, reopens on later runs. - 30-day horizon with
settled_count < meta_volume→ singlereconciliation_shortfallrow + Rollbar + Datadog counter; gap recorded inunmatched_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
| Discipline | Days |
|---|---|
| Frontend | — |
| Backend | 4 |
| QA | 1 |
| Total | 5 |
Assumptions: reuses the existing ladder,
MetaPricingAnalytics, and the daily-worker scaffold; Meta-currency conversion handled as an optional sub-step (capturecurrency, 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] (
deliveredholds 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
| Action | File | What changes |
|---|---|---|
| create | hub_core/app/core/workers/billings/sweep_stale_wa_holds_worker.rb | retry: 0; only touches state='held'; in_batches(of: 500) + with_lock re-check before → expired |
| create | hub_core/app/core/workers/billings/sweep_stale_wa_holds_worker_spec.rb | expires only stale held; leaves delivered/settled untouched; no pool change |
Implementation steps
- Explore — read
record_daily_wa_usage_worker.rbfor theretry: 0worker shape and any existingin_batches/with_lockusage insingle_reset_package.rb:51. - Write failing spec (red) — a
heldhold older than 30d →expired; adeliveredor recentheld→ untouched; assert nobalance/pool change. - Implement — batch + per-row
with_lockre-check (a hold may flip todeliveredmid-sweep). - Go green + quality gate.
Acceptance criteria
- Only
state='held'rows withheld_at < 30.days.ago→expired(+expired_atstamped; NULLheld_atskipped — nothing to age against). -
delivered/settled/refundedholds never touched. - No money movement; reserve released (Available reconverges — the per-package
reserved_wa_balanceRedis cache is cleared for every touched package). - Safe with concurrent delivery webhook (
with_lockre-check; a mid-sweepdeliveredflip 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
| Discipline | Days |
|---|---|
| Frontend | — |
| Backend | 0.5 |
| QA | 0.5 |
| Total | 1 |
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_core — hub_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
| Action | File | What changes |
|---|---|---|
| create | hub_core/config/sidekiq_schedule.yml | cron entries: SettleWaHoldsDispatcherWorker ~01:00 ICT, SweepStaleWaHoldsWorker ~02:00 ICT (sweeper after settlement) |
| extend | hub_core/GLOSSARY.md | add wa_balance_holds, wa_reconciliation_batches, wa_hold_settlement, hold states |
| extend | hub_core/docs/architecture/flows/billings/README.md | update the Billings spoke sequence diagram for hold→settle |
Implementation steps
- Explore — read
config/initializers/sidekiq.rb:27-30(it loadsconfig/sidekiq_schedule.ymlif present — confirm thesidekiq-cronYAML format expected) and any existing cron declaration to copy thecron:/class:/queue:shape. - Create the schedule file — two entries, correct ICT cron expressions, sweeper strictly after settlement.
- Docs — GLOSSARY entries + spoke diagram per the AGENTS.md rule.
- Quality gate — YAML lint / boot check that
Sidekiq::Cron::Job.load_from_hashparses the file.
Acceptance criteria
-
config/sidekiq_schedule.ymlexists and parses; both jobs registered with correct cron + queue. (inhub-worker) - Sweeper scheduled after settlement (01:00 dispatcher → 02:00 sweeper, ICT).
- GLOSSARY + billing flow diagram updated (in
hub-workerdocs/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
| Discipline | Days |
|---|---|
| Frontend | — |
| Backend | 0.5 |
| QA | — |
| Total | 0.5 |
Assumptions:
sidekiq-cronis the scheduler (initializer referencesSidekiq::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). Aheldrow withheld_at IS NULLis 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
| Action | File | What changes |
|---|---|---|
| create | hub_core/database/billing/db/migrate/<ts>_backfill_and_require_wa_balance_holds_held_at.rb | batched 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 |
| extend | hub_core/app/core/workers/billings/sweep_stale_wa_holds_worker.rb | defensive branch: a state='held' AND held_at IS NULL row → Rollbar + Datadog counter (fail loud), never silently filtered |
| extend | sweep_stale_wa_holds_worker_spec.rb + migration spec | sweeper alerts (not skips) on NULL-held_at; migration backfills, adds NOT NULL, reverses clean |
Acceptance criteria
-
wa_balance_holds.held_atisNOT NULL; migration backfills existing NULLs (held_at := created_at), verifies zero NULLs before the constraint, and is reversible. - A
heldhold can no longer persist with NULLheld_at; if one is somehow present, the sweeper alerts (Rollbar/Datadog), never silently skips. - Sweeper spec covers the NULL-
held_atcase explicitly.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 0.5 |
| QA | 0.5 |
| Total | 1 |
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_deductedexposure — the "Available-only" display is a pure BE endpoint change (T3). The only non-code prerequisite is registering the globalwa_hold_settlementflag (default off) and, for the pilot, settingorganization_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
SettleWaHoldsWorkerper org, orSettleWaHoldsDispatcherWorkerfor All); gated so a CID settles only when itswa_hold_settlementflag 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:
Repo What lands there Why hub_core(+hub_serviceGrape routes)M1a (billing_info flag exposure), M2 (new GET usage API), M3 (new download API) hub_core already owns the settled wa_conversation_logsrows (T5) and the canonicalwa_hold_settlementflag (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_controller→MuvMccLogs) 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:
- 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.
- 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_countrycolumn 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_settlementflag already exists and is already used —hub_core/app/core/domains/repositories/billings/helpers.rb:61-63:wa_hold_settlement_enabled?(organization_package)callsServices::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, keyspreference:<feature>:{state,global,<unique_id>:unique}) pair of billing-DB models,Models::Billing::Preference(tablepreferences) andModels::Billing::PreferenceUniqueId(tablepreference_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) alreadyestablish_connection :billing— the same shardModels::AbstractModelBillinguses in hub_core.Core::Services::RedisBilling::AbstractRedisBilling(app/domains/core/services/redis_billing/abstract_redis_billing.rb) already opensREDIS_BILLING = Redis::Namespace.new(Rails.env.to_sym, redis: …)— same namespacing scheme as hub_core'sREDIS_BILLING_R(hub_core/config/initializers/redis.rb:17-21), but viaENV['REDIS_BILLING_URL']instead ofENV['REDIS_BILLING_R_URL']— assumed to be the same physical Redis, not verified from this checkout (infra access needed). Grepped: noBillings::Preference/Billings::PreferenceUniqueIdmodel exists yet in moderator-be. moderator-be's own top-levelPreferencemodel (app/models/preference.rb) is an unrelated, differently-shaped table (company_ids: jsonbarray) on moderator-be's main DB — do not confuse the two; the new models must be namespacedBillings::to avoid collision. - The reporting source is the pre-aggregated
wa_reconciliation_batchesrollup, NOTwa_conversation_logs.wa_conversation_logscarries 200M+ rows; a read-timeGROUP BYover it per report request is untenable.SettleDaily(T5) already writeswa_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 — carryingmeta_volume bigint(message count),meta_cost_total/settled_amount(Meta base cost),state(pending|settled), and a unique indexuq_wrb_bucket+idx_wrb_org_dateon(organization_id, meta_date).meta_dateis the true usage day (a settled log row'screated_atis 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 intobatch.settled_amount += total_base(line 230; the per-hold charged amount goes toWaConversationLog.total_price/creditandWaBalanceHold.settled_amount, not the batch). M0 adds asettled_charged_amountcolumn + 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.rbmountsGET /reports/billing/mcc_logs(→Interactors::Billings::UserGetsMccLog→Repositories::Billings::Gets::MccLog) andGET /reports/billing/download/{core/}mcc_logs(→Interactors::Billings::UserDownloadsMccLogs→Repositories::Billings::Downloads::MccLogs). Those filterModels::Billing::WaConversationLogtoorigin_type: ['BI','UI','RC'], is_auto_deduct: true(the live V1 per-message path) — the new repos copy their interactor/route/CSV shape but querywa_reconciliation_batchesinstead. billing_infoalready has a same-file precedent for one-off flag exposure —hub_core/app/core/domains/builders/billings/billing_info.rbreads several unrelated flags inline inattributes_for_entity(e.g.billing_ecommerce: Services::Billing::BillingEcommerceValidation.new.enabled?(...)); addingis_wa_hold_settlement_enabledfollows the exact same pattern. Mounted atGET /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 above —
moderator-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 thedownload_billings/index.html.erbview +billing--downloadsStimulus controller. This is the one DED-S08 actually targets ("in the mod panel..."). It has no existing HTTP client callinghub_service/hub_core(grepped: noHUB_SERVICE/HUB_CORE/Faraday/HTTPartyhit) — 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_batches — BIF-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 code —
SettleDailyis 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
| Action | File | What changes |
|---|---|---|
| create | hub_core/database/billing/db/migrate/<ts>_add_settled_charged_amount_to_wa_reconciliation_batches.rb | add_column :wa_reconciliation_batches, :settled_charged_amount, :decimal, precision: 20, scale: 4, null: false, default: 0.0 |
| extend | hub_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. |
| extend | hub_core/app/.../models/billing/wa_reconciliation_batch.rb + colocated settle_daily_spec.rb | assert 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_amounton a fully-settled batch equals the sum of its holds'settled_amount(=Σ WaConversationLog.creditfor the bucket). - Accumulates correctly across partial/late-delivery passes (batch
pending→settled). - 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
| Discipline | Days |
|---|---|
| Backend | 1 |
| QA | 0.5 |
| Total | 1.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_info — BIF-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.
| Action | File | What changes |
|---|---|---|
| extend | hub_core/app/core/domains/builders/billings/billing_info.rb | add 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 |
| extend | hub_core/app/core/domains/entities/billings/billing_info.rb | add attribute :is_wa_hold_settlement_enabled, Types::Strict::Bool |
| extend | hub_core/app/core/domains/builders/billings/billing_info_spec.rb | on/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/inforesponse includesis_wa_hold_settlement_enabled, true iffServices::Billing::FeatureFlag.new.enabled?(:wa_hold_settlement, unique_id: company_id)is true for that org'sorganization_package.company_id.
M1b — [BE][moderator-be] Replicate Services::Billing::FeatureFlag — BIF-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.
| Action | File | What changes |
|---|---|---|
| create | moderator-be/app/models/billings/preference.rb | class Billings::Preference < Billings::ApplicationRecord; self.table_name = 'preferences'; end — mirrors hub_core/.../models/billing/preference.rb, same billing shard |
| create | moderator-be/app/models/billings/preference_unique_id.rb | class Billings::PreferenceUniqueId < Billings::ApplicationRecord; self.table_name = 'preference_unique_ids'; belongs_to :preference, class_name: 'Billings::Preference'; end |
| create | moderator-be/app/domains/core/services/billing/feature_flag.rb | Core::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 |
| create | colocated feature_flag_spec.rb | port 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
Preferencemodel (different table, different DB connection, different class namespace).
Effort estimate
| Discipline | Days |
|---|---|
| Backend (M1a) | 0.5 |
| Backend (M1b) | 1.5 |
| Total | 2 |
Assumption flagged in ADR-0002:
REDIS_BILLING_URL(moderator-be) andREDIS_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_settlementflag +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_batchesrollup (one row per bucket per day), NOT aGROUP BYover the 200M-rowwa_conversation_logs— modeled on the existingGets::MccLogpair 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)
| Action | File | What changed |
|---|---|---|
| create | hub_core/app/core/domains/repositories/billings/gets/wa_deduction_history.rb | shape 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) |
| create | hub_core/app/core/domains/interactors/billings/user_gets_wa_deduction_history.rb | contract mirrors UserGetsMccLog (organization_id, actor_id, optional date range/pagination/timezone/waba_id) plus optional states array; check_range_date guard identical to UserGetsMccLog |
| create | colocated *_spec.rb for repo + interactor | grouping grain, meta_date bucketing, empty state, wrong-org isolation, gate-off, waba_id filter, pagination, states filter (default / explicit / unknown-fallback / comma-string) |
| extend | hub_service/app/services/api/core/v1/reports/resources/billing.rb | new 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 literaldestination_countrygrouping — flagged above.) - Reads
wa_reconciliation_batchesonly — no aggregation overwa_conversation_logs. - Buckets by the true usage day (
meta_date), not the settlement-run day (created_aton 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
statesquery 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
| Discipline | Days |
|---|---|
| Backend | 2 |
| QA | 0.5 |
| Total | 2.5 |
Depends on
- [M0] (
settled_charged_amounton 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_batchesrollup as M2; modeled onDownloads::MccLogsfor 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)
| Action | File | What changed |
|---|---|---|
| create | hub_core/app/core/domains/repositories/billings/wa_deduction_history_query.rb | new 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 |
| refactor | hub_core/app/core/domains/repositories/billings/gets/wa_deduction_history.rb | M2 repo now includes the shared module instead of inlining the query |
| create | hub_core/app/core/domains/repositories/billings/downloads/wa_deduction_history.rb | shape 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) |
| create | hub_core/app/core/domains/interactors/billings/user_downloads_wa_deduction_history.rb | contract mirrors UserDownloadsMccLogs + optional states array |
| create | colocated *_spec.rb for repo + interactor | grouping 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 |
| extend | hub_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 noaggregation_byparam, 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_batchesonly — nowa_conversation_logsaggregation. - CSV rows match M2's GET rows exactly for the same org/range (single source of query truth — enforced by the shared
WaDeductionHistoryQuerymodule + 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
| Discipline | Days |
|---|---|
| Backend | 1.5 |
| QA | 0.5 |
| Total | 2 |
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)
| Action | File | What changes |
|---|---|---|
| extend | hub/pages/finances/package_usage.vue (PRD-cited, unverified) | branch on billing_info.is_wa_hold_settlement_enabled |
| extend | hub/.../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_failedlogged, 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
| Discipline | Days |
|---|---|
| Frontend | 2 |
| QA | 0.5 |
| Total | 2.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
hubrepo.
Depends on
- [M2], M1a (
billing_infoflag 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
- 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). - Wiring in
download_billings_controller/MuvMccLogs: whenCore::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. - Hide the "Per message" radio unconditionally (not date-range-gated) in the download dialog when the company is V2-on.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| create | app/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) |
| extend | app/domains/core/use_cases/app_integrations/chat_panel/reports/muv_mcc_logs.rb | before 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 |
| extend | app/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 |
| extend | app/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) |
| extend | muv_mcc_logs_spec.rb + a request spec for the new HTTP client | V2-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
| Discipline | Days |
|---|---|
| Backend | 1.5 |
| Frontend | 0.5 |
| QA | 0.5 |
| Total | 2.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:
SettleWaHoldsWorkerper org,SettleWaHoldsDispatcherWorkerfleet-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
| Action | File | What changes |
|---|---|---|
| create | hub_core/app/core/domains/interactors/billings/trigger_wa_hold_settlement.rb | contract (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.rb | accept 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) |
| extend | hub_service/app/services/api/core/v1/billings/resources/billings.rb | new 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) |
| extend | moderator-be/app/views/shared/_sidebar_finance.html.erb | new Finance-menu item (gated by a new can? permission) linking to the trigger page |
| create | moderator-be/app/controllers/billing/wa_hold_settlements_controller.rb + app/views/billing/wa_hold_settlements/ + Stimulus controller | Finance-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 |
| create | colocated 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) orSettleWaHoldsDispatcherWorker(All). - Gating: a CID is settled only if its per-company
wa_hold_settlementflag 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
| Discipline | Days |
|---|---|
| Backend | 1.5 |
| Frontend | 0.5 |
| QA | 0.5 |
| Total | 2.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_settlementflag they already read via T1'sHelpersmodule. 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 / item | Reason |
|---|---|
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 UI | Backend 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 gating | Backend 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 dimension | Superseded 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 branch | From the earlier design; replaced here by wa_balance_holds + wa_reconciliation_batches + SettleDaily. Dropped. |