Task Breakdown — Meta x Modpanel Call Usage Comparison (by RFC execution chunks)
RFC: meta-modpanel-call-usage.md · PRD: ../prds/meta-modpanel-call-usage.md
Scope. Backend-only (no UI / Figma). All new code lands in the hub_core
gem (/Users/mekari/Documents/repos/hub_core, branch production); the cron +
queue wiring lands in the hub-worker host app. Tasks are ordered by the
RFC's §4.C Agent Execution Plan (dependency order) — finish task N before N+1.
The pipeline mirrors the proven V2 message-comparison pipeline; every task names
the exact V2 file to copy from.
Definition of done (from RFC Success Criteria): ≥99% WABA daily coverage; 0 windows exceeding 4,000 Meta calls/hr; inbound+outbound rows per WABA/day; system-wide Rp 5M Google Chat alert delivered on breach days.
Reconnaissance basis (verified in
hub_core@production): worker baseAbstractSidekiqWorker(app/core/workers/abstract_sidekiq_worker.rb); repo baseBillings::Repositories::V1::AbstractRepository; interactor baseInteractors::AbstractIteractor(note the upstream spelling); model baseModels::AbstractModelBilling; specs are co-located next to code as*_spec.rb; test runnerbundle exec rspec <path>(.rspecrequiresrails_helper); lintbundle exec rubocop; securitybrakeman; billing migrations live indatabase/billing/db/migrate/(ActiveRecord[6.1], Postgres,create_table id: :uuid).
Effort Summary
| Task | Area | BE days | QA days | Total |
|---|---|---|---|---|
| T1 — Migrations (2 tables) | hub_core | 0.5 | — | 0.5 |
| T2 — Models (2) | hub_core | 0.5 | — | 0.5 |
| T3 — Local call-usage repo | hub_core | 1.5 | 0.5 | 2.0 |
| T4 — MetaCallAnalytics service | hub_core | 2.0 | 0.5 | 2.5 |
| T5 — Meta call-usage repo | hub_core | 1.5 | 0.5 | 2.0 |
| T6 — Workers (dispatcher + local + throttled meta) | hub_core | 1.5 | 0.5 | 2.0 |
| T7 — Alert interactor + worker | hub_core | 2.0 | 1.0 | 3.0 |
| T8 — Balance snapshot worker | hub_core | 1.0 | 0.5 | 1.5 |
| T9 — Retention cleaner | hub_core | 0.5 | — | 0.5 |
| T10 — Cron + queue wiring | hub-worker | 0.5 | 0.5 | 1.0 |
| Grand total | 11.5 | 4.0 | 15.5 |
Confidence: medium. The V2 template de-risks every task (each has a real file to copy). The biggest unknowns that could move the estimate: (1) the exact Meta COST
data_pointfield name + currency unit, verified by a 1-call live spike in T4 (OQ-3 residual); (2) behaviour toggles OQ-1 (timezone unknown-warn), OQ-4 (keep V2's GMT+7 alert filter?), OQ-5 (cleaner shape), OQ-6 (snapshot balance source) — none block building, but could add ~0.5–1 day if they change. Lint + Brakeman are folded into each task's quality gate, not a separate task.
T1: [BE] Billing migrations — waba_call_comparison + waba_call_balance_snapshot (MMCU-S03, S05)
Foundation: the two billing tables every downstream task reads/writes.
Status: ✅ Actionable
What to build
Two ActiveRecord migrations in the billing DB creating waba_call_comparison
(WABA + direction grain, mirroring whatsapp_usage_comparison) and
waba_call_balance_snapshot, with the indexes from RFC §2.3.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| create | hub_core: database/billing/db/migrate/<ts>_create_waba_call_comparison.rb | create_table :waba_call_comparison, id: :uuid; columns per RFC §2.3 DDL; unique idx_wcc_unique_daily_direction on (organization_id, start_date, end_date, waba_id, direction) + 5 secondary indexes |
| create | hub_core: database/billing/db/migrate/<ts>_create_waba_call_balance_snapshot.rb | create_table :waba_call_balance_snapshot, id: :uuid; columns per §2.3; unique idx_wcbs_unique_daily on (organization_id, waba_id, snapshot_date) |
Implementation steps
- Explore — open
hub_core: database/billing/db/migrate/20260223000000_create_whatsapp_usage_comparison.rband the follow-on20260506000000_add_timezone_and_difference_to_whatsapp_usage_comparison.rbto copy the column helpers (t.bigint … comment: 'Unix timestamp (seconds)',t.decimal precision: 20, scale: 4). - Write both migration files with the exact columns/indexes from RFC §2.3 (use
decimal(20,4)for Rp columns,bigintforstart_date/end_date/snapshot_date). - Apply in the host/dummy app:
bundle exec rails db:migrateagainst the billing DB; confirmbilling_schema.rbdumps both tables. - Quality gate —
bundle exec rubocopon the new files; verifyrails db:rollbackreverts cleanly (both directions).
Acceptance criteria
- Both tables exist with all columns + types from RFC §2.3.
-
idx_wcc_unique_daily_directionis unique;idx_wcbs_unique_dailyis unique. -
bundle exec rails db:migratethendb:rollbackboth succeed.
Test strategy
No model logic yet — verification is schema-level (migrate up/down + schema dump diff).
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 0.5 |
| QA | — |
| Total | 0.5 |
Assumptions: mirrors an existing billing migration; no data backfill; additive only.
Run to verify
bundle exec rails db:migrate && bundle exec rails db:rollback && bundle exec rubocop
Depends on
- none
T2: [BE] Billing models — WabaCallComparison + WabaCallBalanceSnapshot (MMCU-S03, S05)
ActiveRecord models the repos/workers persist through.
Status: ✅ Actionable
What to build
Two thin models under Models::Billing, each setting table_name and the
meta_response_status constants (mirror WhatsappUsageComparison).
Implementation Plan
| Action | File | What changes |
|---|---|---|
| create | hub_core: app/core/domains/models/billing/waba_call_comparison.rb | < Models::AbstractModelBilling; self.table_name = 'waba_call_comparison'; META_RESPONSE_STATUS_{SUCCESS,BLANK,ERROR} constants |
| create | hub_core: app/core/domains/models/billing/waba_call_comparison_spec.rb | asserts table mapping + constants |
| create | hub_core: app/core/domains/models/billing/waba_call_balance_snapshot.rb | < Models::AbstractModelBilling; self.table_name = 'waba_call_balance_snapshot' |
| create | hub_core: app/core/domains/models/billing/waba_call_balance_snapshot_spec.rb | asserts table mapping |
Implementation steps
- Explore — open
hub_core: app/core/domains/models/billing/whatsapp_usage_comparison.rb(the constants pattern) andvoice_call_log.rb(model conventions in this folder). - Red — write both
*_spec.rbassertingtable_nameand constant values;bundle exec rspec→ fail. - Implement — create both models.
- Green + gate —
bundle exec rspec app/core/domains/models/billing/waba_call_comparison_spec.rb app/core/domains/models/billing/waba_call_balance_snapshot_spec.rb;bundle exec rubocop.
Acceptance criteria
-
Models::Billing::WabaCallComparison.table_name == 'waba_call_comparison'. -
Models::Billing::WabaCallBalanceSnapshot.table_name == 'waba_call_balance_snapshot'. -
meta_response_statusconstants present and public.
Test strategy
Model specs assert table_name and constant values — no DB rows needed.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 0.5 |
| QA | — |
| Total | 0.5 |
Assumptions: thin models, no associations needed (joins done in repos).
Run to verify
bundle exec rspec app/core/domains/models/billing/waba_call_comparison_spec.rb && bundle exec rubocop
Depends on
- T1
T3: [BE] Local call-usage repository — WaCallUsageComparison::RecordDaily (MMCU-S03)
Computes Modpanel's per-direction call Rp from
voice_call_logsand upserts it intowaba_call_comparisonasusage_local.
Status: ✅ Actionable
What to build
A repository that, for one organization_id + WABA + target_date (+ resolved
timezone), resolves company_id, sums voice_call_logs.total_price grouped by
direction where channel_source='whatsapp' within the WABA-local day UTC
window, and upserts one waba_call_comparison row per direction.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| create | hub_core: app/apps/billings/repositories/v1/wa_call_usage_comparison/record_daily.rb | < Billings::Repositories::V1::AbstractRepository; initialize(organization_id:, waba_id:, cid:, start_date:, timezone:, timezone_id:); start/end_timestamp via in_time_zone(@timezone); group-by-direction sum; find_or_create_by upsert of usage_local |
| create | hub_core: app/apps/billings/repositories/v1/wa_call_usage_comparison/record_daily_spec.rb | asserts 2 rows (inbound+outbound), correct sum, DST window, replica read |
Implementation steps
- Explore — open
hub_core: app/apps/billings/repositories/v1/wa_usage_comparison/record_daily.rb(the upsert + timestamp pattern) andapp/core/domains/repositories/billings/gets/voice_log.rb(the realVoiceCallLog.where(company_id:, created_at: range, …)query +switch_replica_billing_db). - Resolve
company_id— mirrorRecordDailyWaMetaUsageWorker#resolve_cid:Models::Organization.find_by(id: org_id)&.company_idelseModels::Billing::OrganizationPackage.find_by(organization_id:)&.company_id. - Red — write
record_daily_spec.rbseedingvoice_call_logsrows (inbound + outbound,channel_source: 'whatsapp') and asserting two upserted rows with summedusage_local; runbundle exec rspec→ fail. - Implement — query
Models::Billing::VoiceCallLog.where(company_id:, channel_source: 'whatsapp', created_at: start_ts_time..end_ts_time).group(:direction).sum(:total_price)insideswitch_replica_billing_db; upsert per direction viafind_or_create_by(organization_id:, start_date:, end_date:, waba_id:, direction:)thenassign_attributes(usage_local:, usage_difference: usage_meta.to_f - usage_local, waba_timezone:, waba_timezone_id:). - Green + gate —
bundle exec rspec .../record_daily_spec.rb;bundle exec rubocop.
Acceptance criteria
- Sums
total_priceperdirectionforchannel_source='whatsapp'+ resolvedcompany_idwithin the WABA-local day window. - Writes one row per direction (
'inbound','outbound'— lowercase, Decision 9). - DST-correct window via
in_time_zone(@timezone)(23h day on a spring-forward date — MMCU-S03/AC-4). - Idempotent: re-run updates the same rows (unique index), no duplicates.
Test strategy
Spec seeds VoiceCallLog fixtures across both directions + a non-whatsapp row (excluded); asserts the two upserted WabaCallComparison rows and their usage_local sums. Key mock: time frozen to a known target_date; key assertion: per-direction sum + row count.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 1.5 |
| QA | 0.5 |
| Total | 2.0 |
Assumptions: reuses the verified
VoiceLogquery filters; confirm whether thecredited_to/is_auto_deductfilters fromgets/voice_log.rbshould apply here (default: include them to match billed usage) — minor, see RFC OQ-6 family.
Run to verify
bundle exec rspec app/apps/billings/repositories/v1/wa_call_usage_comparison/record_daily_spec.rb && bundle exec rubocop
Depends on
- T2
T4: [BE] Billings::Services::MetaCallAnalytics — Meta Call Analytics client (MMCU-S03)
Fetches per-direction daily call COST from Meta's
call_analyticsedge with the same resiliency as the message pricing client.
Status: ✅ Actionable — OQ-3 request grammar resolved (RFC Decision 8). One
1-call live spike confirms the COST data_point field name (parser fails loud if
the shape differs).
What to build
A service mirroring MetaPricingAnalytics that calls
GET /{GRAPH_FACEBOOK_VERSION}/{waba_id}/call_analytics with
start/end (Unix s), granularity=DAILY, metric_types=["COST"],
dimensions=["DIRECTION"], parses data → data_points, and returns per-direction
COST mapping Meta's USER_INITIATED→'inbound' / BUSINESS_INITIATED→'outbound'.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| create | hub_core: app/apps/billings/services/meta_call_analytics.rb | < Repositories::AbstractHttp; env base URL (GRAPH_FACEBOOK_URL/GRAPH_FACEBOOK_VERSION) + FACEBOOK_ACCESS_TOKEN; MAX_REQUEST_ATTEMPTS=3; retryable 408/425/429/5xx; GET path: "/#{waba_id}/call_analytics" with query params; parse per-direction COST; direction enum mapping |
| create | hub_core: app/apps/billings/services/meta_call_analytics_spec.rb | stubs HTTP; asserts request params, retry on 429/5xx, auth classification, direction mapping, blank/empty handling |
Implementation steps
- Explore — open
hub_core: app/apps/billings/services/meta_pricing_analytics.rband copy: constructor (env URL/token),fetch_with_retry,RETRYABLE_HTTP_STATUSES,parse_response,build_data, Rollbaralert_failure. - Red — write
meta_call_analytics_spec.rbstubbingget(...)to return a sample{ data: [...] }payload and 429/5xx/empty cases; assert request path/params, retry behaviour, andUSER_INITIATED→inbound/BUSINESS_INITIATED→outboundmapping; run → fail. - Implement — build the edge call (
get(path: "/#{@waba_id}/call_analytics", body: { start:, end:, granularity: 'DAILY', metric_types: '["COST"]', dimensions: '["DIRECTION"]', access_token: @token })); parsedata → data_points, sum COST per direction. Spike: run one real call against a test WABA, log the raw payload, and pin the COSTdata_pointfield name (e.g.cost/amount) + currency in the parser; assert loudly (Rollbar) on mismatch. - Green + gate —
bundle exec rspec .../meta_call_analytics_spec.rb;bundle exec rubocop;brakeman(no new SSRF — fixed Meta host).
Acceptance criteria
- Issues
GET /{version}/{waba_id}/call_analyticswithgranularity=DAILY,metric_types=["COST"],dimensions=["DIRECTION"], Unixstart/end. - Retries up to 3× on
408/425/429/5xx; classifies401/403as auth error. - Returns per-direction COST with
direction ∈ {'inbound','outbound'}(mapped from Meta enum). - Blank/empty Meta response → empty result (not an error); failure →
Failure(...)for the caller to markerror. - (Spike) COST
data_pointfield name confirmed against a live response and asserted in the parser.
Test strategy
Spec stubs Repositories::AbstractHttp#get; asserts the request body params, the retry loop (429 then success), auth classification, and the direction-enum mapping. Key mock: stubbed HTTP response fixture; key assertion: per-direction COST map + retry count.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 2.0 |
| QA | 0.5 |
| Total | 2.5 |
Assumptions: resiliency copied wholesale from
MetaPricingAnalytics; only the edge path, query params, response parse, and direction mapping are new. The live spike is ~1 call, not a separate task.
Run to verify
bundle exec rspec app/apps/billings/services/meta_call_analytics_spec.rb && bundle exec rubocop
Depends on
- none (independent; T5 consumes it)
T5: [BE] Meta call-usage repository — WaCallUsageComparison::RecordDailyMeta (MMCU-S03)
Calls
MetaCallAnalytics, upsertsusage_meta+usage_differenceper direction intowaba_call_comparison, and records the Meta response status.
Status: ✅ Actionable
What to build
A repository mirroring WaUsageComparison::RecordDailyMeta: fetch Meta call usage
for one WABA + target_date, build per-direction records, upsert usage_meta and
usage_difference = usage_meta − usage_local (Decision 6), and write
meta_response_status (success/blank/error) sentinels.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| create | hub_core: app/apps/billings/repositories/v1/wa_call_usage_comparison/record_daily_meta.rb | < Billings::Repositories::V1::AbstractRepository; calls Billings::Services::MetaCallAnalytics; per-direction upsert; usage_difference = usage_meta − usage_record.usage_local.to_f; persist_status_only! on error/blank |
| create | hub_core: app/apps/billings/repositories/v1/wa_call_usage_comparison/record_daily_meta_spec.rb | asserts upsert, sign, status sentinels, error path |
Implementation steps
- Explore — open
hub_core: app/apps/billings/repositories/v1/wa_usage_comparison/record_daily_meta.rb(thefetch → build → persistshape +META_RESPONSE_STATUS_*sentinels +usage_differencewrite). - Red — write
record_daily_meta_spec.rbstubbingMetaCallAnalytics(success/blank/failure); assert per-directionusage_metaupsert,usage_differencesign, andmeta_response_statustransitions; run → fail. - Implement — replace the message client with
MetaCallAnalytics; group bydirection(not phone/category); upsert viafind_or_create_by(organization_id:, start_date:, end_date:, waba_id:, direction:)thenassign_attributes(usage_meta:, usage_difference: usage_meta - usage_record.usage_local.to_f, meta_response_status:, waba_timezone:, waba_timezone_id:). - Green + gate —
bundle exec rspec .../record_daily_meta_spec.rb;bundle exec rubocop.
Acceptance criteria
- Writes
usage_metaper direction andusage_difference = usage_meta − usage_local(Decision 6). - Meta failure →
meta_response_status='error', no partialusage_metaoverwrite (MMCU-S03/ERR-2). - Meta blank →
meta_response_status='blank'. - Idempotent upsert on the unique direction key.
Test strategy
Spec stubs MetaCallAnalytics#call (Success/Failure monads); asserts the upserted row's usage_meta, usage_difference, and meta_response_status per branch. Key mock: stubbed service result; key assertion: sign + status sentinel.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 1.5 |
| QA | 0.5 |
| Total | 2.0 |
Assumptions: reuses the V2
RecordDailyMetaskeleton; direction grain replaces phone/category grain.
Run to verify
bundle exec rspec app/apps/billings/repositories/v1/wa_call_usage_comparison/record_daily_meta_spec.rb && bundle exec rubocop
Depends on
- T3, T4
T6: [BE] Workers — dispatcher + local + throttled meta (MMCU-S02, S03)
The daily dispatcher fans out per-org local + Meta workers; the Meta worker is rate-limited to 4,000 calls/hr via
sidekiq-throttled. Replaces the PRD's hourly enqueuer (RFC Decision 3).
Status: ✅ Actionable
What to build
Three coupled workers (the dispatcher calls the other two), mirroring the V2
RecordDailyWaUsageComparisonWorker / RecordDailyWaUsageWorker /
RecordDailyWaMetaUsageWorker, gated on the new Preference flag and reusing
Concerns::WaTimezoneResolver.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| create | hub_core: app/core/workers/billings/record_daily_wa_call_usage_comparison_worker.rb | < AbstractSidekiqWorker; feature_enabled? → Services::Preference.new.enabled?(:daily_wa_call_usage_comparison); iterate active non-trial OrganizationPackage; perform_async local + meta per org |
| create | hub_core: app/core/workers/billings/record_daily_wa_call_usage_worker.rb | < AbstractSidekiqWorker; resolves timezone + cid; calls RecordDaily (T3) per WABA |
| create | hub_core: app/core/workers/billings/record_daily_wa_call_meta_usage_worker.rb | < AbstractSidekiqWorker; include Sidekiq::Throttled::Worker; include Concerns::WaTimezoneResolver; LIMIT_PER_HOUR = 4_000; sidekiq_throttle(threshold: {limit: LIMIT_PER_HOUR, period: 1.hour, key_suffix: ->(_){'all'}}); calls RecordDailyMeta (T5) per WABA |
| create | 3× co-located *_spec.rb | dispatch counts, flag gate, throttle config, tz-missing skip |
Implementation steps
- Explore — open all three V2 workers under
hub_core: app/core/workers/billings/(record_daily_wa_usage_comparison_worker.rb,record_daily_wa_usage_worker.rb,record_daily_wa_meta_usage_worker.rb) — copy the dispatch loop,eligible_packages, throttle block, andresolve_waba_accountsusage. - Red — specs: dispatcher dispatches 2 workers per eligible org and returns early when flag off (
MMCU-S02/AC-1); meta worker skips WABAs with nil timezone (MMCU-S03/ERR-3); throttle config present withlimit: 4_000. Run → fail. - Implement — wire the three workers; set queue names
billing_record_daily_wa_call_usage_comparison,billing_record_daily_wa_call_usage,billing_record_daily_wa_call_meta_usage;retry: 0(mirror V2); test-mode synchronous dispatch (if Rails.env.test?→.new.perform) as V2 does. - Green + gate —
bundle exec rspec app/core/workers/billings/;bundle exec rubocop.
Acceptance criteria
- Dispatcher returns
Success('feature disabled')when flag OFF; otherwise dispatches local + meta per active non-trial org (MMCU-S02/AC-1). - Meta worker declares
sidekiq_throttlewithlimit: 4_000, period: 1.hour(MMCU-S03/AC-3). - Meta worker does not run when flag OFF; skips + logs when timezone unresolved (
MMCU-S03/ERR-3); no Meta call at dispatch step (MMCU-S02/NEG-1). - No job dropped under throttle — excess deferred to next window (
MMCU-S03/NEG-2, via sidekiq-throttled).
Test strategy
Dispatcher spec uses expect(...).to receive(:perform_async) counts + a flag stub; meta-worker spec asserts the get_sidekiq_options/throttle config and the tz-skip branch. Key mock: Services::Preference#enabled?; key assertion: dispatch count + throttle limit.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 1.5 |
| QA | 0.5 |
| Total | 2.0 |
Assumptions: three workers copied near-verbatim from V2; only class names, queue names, the 4,000 limit, and the call repos differ.
Run to verify
bundle exec rspec app/core/workers/billings/ && bundle exec rubocop
Depends on
- T3, T5
T7: [BE] Alert interactor + worker — system-wide Rp 5M Google Chat alert (MMCU-S04)
The user-facing outcome: Finance gets a daily Google Chat alert with overcharge /leakage totals + top-5 WABA+direction contributors when system-wide variance exceeds Rp 5,000,000.
Status: ✅ Actionable
What to build
An interactor mirroring WaUsageComparison::CalculateDailyDiscrepancy that
aggregates SUM(usage_difference) over the day's waba_call_comparison rows
(revenue-loss = sum >0, overcharge = sum of abs(<0)), and, when either exceeds the
Rp threshold, sends a cardsV2 Google Chat message via
Services::Gchat::Webhooks::SendMessage with top-5 contributors per bucket
grouped by (waba_id, direction). Plus a thin worker to run it.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| create | hub_core: app/apps/billings/interactors/v1/wa_call_usage_comparison/calculate_daily_discrepancy.rb | < Interactors::AbstractIteractor; Rp threshold from BILLINGS_WA_CALL_USAGE_RUPIAH_ALERT_THRESHOLD / Redis / default 5_000_000; system_wide_rupiah_summary; top_waba_offenders(group_by: [:waba_id, :direction]); GChat send gated on Preference :notif_billing_wa_call_usage_system_wide_alert |
| create | hub_core: app/core/workers/billings/calculate_daily_wa_call_discrepancy_worker.rb | < AbstractSidekiqWorker; queue billing_calculate_wa_call_daily_discrepancy; runs the interactor |
| create | 2× co-located *_spec.rb | aggregation sums, threshold gating, top-5, no-data path |
Implementation steps
- Explore — open
hub_core: app/apps/billings/interactors/v1/wa_usage_comparison/calculate_daily_discrepancy.rb— copysystem_wide_rupiah_summary,top_waba_offenders,build_rupiah_alert_sections,format_rupiah, the Redis/ENV threshold loader, and theServices::Gchat::Webhooks::SendMessagecall. - Red — spec: overcharge =
SUM(abs(usage_difference<0)), leakage =SUM(usage_difference>0); alert sent only when a bucket > Rp 5M; top-5 grouped by(waba_id, direction); zero rows → no alert +alert_no_data. Run → fail. - Implement — point all queries at
Models::Billing::WabaCallComparison; group offenders bywaba_id, direction(not category); new constants for the call Redis/ENV threshold keys + Preference alert key; decide OQ-4 (keep V2'sgmt7_waba_idsfilter or alert all timezones — default: keep, conservative). - Green + gate —
bundle exec rspec .../calculate_daily_discrepancy_spec.rb app/core/workers/billings/calculate_daily_wa_call_discrepancy_worker_spec.rb;bundle exec rubocop.
Acceptance criteria
-
overcharge = SUM(ABS(usage_difference<0)),leakage = SUM(usage_difference>0)over the day window (MMCU-S04/AC-1). - Alert sent iff overcharge or leakage > Rp 5,000,000 (
MMCU-S04/AC-2); both sections when both breach (AC-4). - Top-5 WABA+direction contributors per triggered bucket, sorted desc (
AC-3). - Neither breach → no alert,
alert_threshold_not_metlogged (AC-5); zero rows → no alert,alert_no_data(ERR-2). - GChat non-2xx → retry then
alert_delivery_failed(critical) (ERR-1).
Test strategy
Spec seeds WabaCallComparison rows with known usage_difference values; stubs Services::Gchat::Webhooks::SendMessage and Services::Preference; asserts whether the send is invoked and the payload's top-5 ordering. Key mock: GChat sender; key assertion: send-or-not + bucket totals.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 2.0 |
| QA | 1.0 |
| Total | 3.0 |
Assumptions: copies the V2 system-wide Rp alert (BIF-8386); offender grouping changes from category to direction; QA higher because this is the Finance-facing output validated during the Finance Closed Review stage.
Run to verify
bundle exec rspec app/apps/billings/interactors/v1/wa_call_usage_comparison/calculate_daily_discrepancy_spec.rb && bundle exec rubocop
Depends on
- T2 (reads
waba_call_comparison; meaningful data once T5/T6 run)
T8: [BE] Balance snapshot worker — SnapshotDailyWaCallBalanceWorker (MMCU-S05)
Records each active WABA's ending call balance daily for the Anomaly Checker dashboard.
Status: ✅ Actionable
What to build
A daily worker that, per active WABA/org, reads voice_packages.balance and
inserts one waba_call_balance_snapshot row (snapshot_date, ending_balance,
snapshotted_at), idempotent on the unique day key, retrying on DB error then
logging snapshot_failed.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| create | hub_core: app/core/workers/billings/snapshot_daily_wa_call_balance_worker.rb | < AbstractSidekiqWorker; queue billing_snapshot_daily_wa_call_balance; iterate active orgs; read Models::Billing::VoicePackage#balance; insert snapshot row |
| create | hub_core: app/core/workers/billings/snapshot_daily_wa_call_balance_worker_spec.rb | one row/active WABA/day; distinct across days; retry→snapshot_failed |
Implementation steps
- Explore — open
hub_core: app/apps/billings/repositories/v1/wa_usage_comparison/record_daily.rb→upsert_remaining_estimation!(how V2 snapshotswhatsapp_packagebalance intowhatsapp_daily_remaining_estimation) andapp/core/domains/models/billing/voice_package.rb(balance,company_id). - Red — spec: writes one row per active WABA with
snapshot_date= WIB day; consecutive days create distinct rows (no overwrite —MMCU-S05/AC-2); DB failure retries 3× thensnapshot_failed(ERR-1). Run → fail. - Implement — resolve active WABAs/orgs, read
VoicePackage#balance,find_or_create_by(organization_id:, waba_id:, snapshot_date:)then setending_balance,snapshotted_at; confirm OQ-6 (balance source =voice_packages.balance). - Green + gate —
bundle exec rspec .../snapshot_daily_wa_call_balance_worker_spec.rb;bundle exec rubocop.
Acceptance criteria
- One row per active WABA per day with
ending_balancefromvoice_packages.balance(MMCU-S05/AC-1). - Consecutive days → distinct rows, no overwrite (
AC-2). - DB failure → retry 3× then
snapshot_failedcritical, no partial row (ERR-1).
Test strategy
Spec seeds VoicePackage rows and a frozen clock; asserts row count + snapshot_date; simulates a DB error to assert the retry→snapshot_failed path. Key mock: frozen time + forced query error; key assertion: row-per-WABA + failure log.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 1.0 |
| QA | 0.5 |
| Total | 1.5 |
Assumptions:
voice_packages.balanceis the correct "ending call balance" (OQ-6 — confirm with Data before GA); standalone table per RFC Decision 7 (Hybrid).
Run to verify
bundle exec rspec app/core/workers/billings/snapshot_daily_wa_call_balance_worker_spec.rb && bundle exec rubocop
Depends on
- T1
T9: [BE] Retention cleaner (RFC §5.1, OQ-5)
Nightly cleanup so the two new tables honour 90-day retention.
Status: ⚠️ Partially blocked — OQ-5: confirm whether this is a new dedicated cleaner vs. folding into an existing billing maintenance job, and where the 7-day failed-job record lives (Sidekiq dead set vs a table). The 90-day row deletion is actionable now regardless.
What to build
A worker that deletes waba_call_comparison and waba_call_balance_snapshot rows
older than 90 days (by created_at / snapshot_date).
Implementation Plan
| Action | File | What changes |
|---|---|---|
| create | hub_core: app/core/workers/billings/cleanup_wa_call_comparison_worker.rb | < AbstractSidekiqWorker; queue billing_cleanup_wa_call_comparison; delete_all rows older than 90 days in batches |
| create | co-located *_spec.rb | deletes aged rows, keeps fresh rows |
Implementation steps
- Explore — search
hub_core: app/core/workers/billings/for an existing cleaner (e.g.MuvQueueCleanerWorker, scheduled asbilling_muv_queue_cleaner) to follow the batch-delete + cron pattern; decide OQ-5 from what you find. - Red — spec seeds old + fresh rows; assert only aged rows deleted. Run → fail.
- Implement —
Models::Billing::WabaCallComparison.where('created_at < ?', 90.days.ago).in_batches.delete_all(+ snapshot bysnapshot_date). - Green + gate —
bundle exec rspec .../cleanup_wa_call_comparison_worker_spec.rb;bundle exec rubocop.
Acceptance criteria
- Rows older than 90 days are deleted; fresh rows retained.
- (pending OQ-5) failed-job 7-day retention mechanism decided + implemented.
Test strategy
Spec uses a frozen clock + seeded rows straddling the 90-day boundary; asserts deletion set.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 0.5 |
| QA | — |
| Total | 0.5 |
Assumptions: follows the existing
billing_muv_queue_cleanerpattern; pure internal cleanup (no QA).
Run to verify
bundle exec rspec app/core/workers/billings/cleanup_wa_call_comparison_worker_spec.rb && bundle exec rubocop
Depends on
- T1
T10: [BE] Cron + queue wiring in hub-worker (MMCU-S02/S03/S04/S05)
Schedules the pipeline and registers its queues so it actually runs in production.
Status: ✅ Actionable
What to build
Add Sidekiq-Cron entries (mirroring the V2 billing_record_daily_wa_usage_comparison
/ billing_calculate_whatsapp_daily_disrepancy entries) and register the new
queues in the host app.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| extend | hub-worker: config/sidekiq_schedule.yml | add 4 cron entries: comparison "1 0 * * * Asia/Jakarta", snapshot "0 0 * * * Asia/Jakarta", alert "0 9 * * * Asia/Jakarta", cleaner "0 2 * * * Asia/Jakarta" (class + queue per worker) |
| extend | hub-worker: config/sidekiq.yml | register the new billing_* queues (comparison, local, meta, calculate, snapshot, cleanup) |
Implementation steps
- Explore — open
hub-worker: config/sidekiq_schedule.ymllines 51–59 (the V2 comparison + discrepancy entries) andconfig/sidekiq.ymllines 33–36 (the V2billing_*queue list) to match exact formatting. - Implement — add the 4 cron entries (alert at 09:00 WIB per PRD; comparison at 00:01, snapshot at 00:00, cleaner at 02:00) and the new queue names.
- Verify — boot hub-worker (or load the schedule) and confirm
Sidekiq::Cron::Jobregisters all 4 jobs; confirm queues appear in the Sidekiq dashboard. - Quality gate — YAML lints; staging dry-run with the Preference flag ON for a test WABA.
Acceptance criteria
- 4 cron entries present with correct class/queue/cron (alert 09:00 WIB).
- New
billing_*queues registered insidekiq.yml. - On boot,
Sidekiq::Cron::Job.allincludes the 4 new jobs.
Test strategy
Manual/staging: load the schedule, assert Sidekiq::Cron::Job count delta; dry-run the dispatcher with the flag ON against a test WABA and confirm rows + a (suppressed) alert payload.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 0.5 |
| QA | 0.5 |
| Total | 1.0 |
Assumptions: host app already loads
config/sidekiq_schedule.ymlvia the V2 initializer; no new infra.
Run to verify
# in hub-worker, after wiring:
bundle exec ruby -e "require './config/environment'; puts Sidekiq::Cron::Job.all.map(&:name)"
Depends on
- T6, T7, T8, T9 (worker classes must exist to schedule)
Ordering rationale
- T1→T2 first (schema + models) unblock every downstream read/write — they're half-day foundations on the critical path.
- T4 (MetaCallAnalytics) can run in parallel with T1–T3 since it has no repo
deps; doing the 1-call live spike early retires the only residual unknown
(COST
data_pointfield name) before T5 depends on it. - T3 + T5 → T6: both repos must exist before the workers wire them; T6 is where the rate-limit (4,000/hr) and the daily-dispatch behaviour land.
- T7 (alert) is the user-facing payoff and the highest-QA task — it can be
built once
waba_call_comparisonexists (T2) but is only meaningfully testable end-to-end after T6 produces rows. - T10 (cron wiring) is last — it activates the whole pipeline and is the go/no-go for each rollout stage; keep it behind the Preference flag (default OFF).
- Push externally now: confirm OQ-4 (keep V2's GMT+7 alert filter?), OQ-5 (cleaner shape + 7-day failed-job store), and OQ-6 (snapshot balance source) with PM/Data so T7/T8/T9 don't need rework — none block starting.
Skipped stories
| Story | Reason |
|---|---|
| — | No stories were fully blocked. MMCU-S01 (timezone) is not a build task — it's satisfied by reusing Concerns::WaTimezoneResolver (RFC Decision 2), exercised within T6; the only added work is the unknown-timezone warning log (OQ-1), folded into T6. T9's 7-day failed-job retention piece is the only partially-blocked item (OQ-5). |