Skip to main content

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 base AbstractSidekiqWorker (app/core/workers/abstract_sidekiq_worker.rb); repo base Billings::Repositories::V1::AbstractRepository; interactor base Interactors::AbstractIteractor (note the upstream spelling); model base Models::AbstractModelBilling; specs are co-located next to code as *_spec.rb; test runner bundle exec rspec <path> (.rspec requires rails_helper); lint bundle exec rubocop; security brakeman; billing migrations live in database/billing/db/migrate/ (ActiveRecord [6.1], Postgres, create_table id: :uuid).

Effort Summary

TaskAreaBE daysQA daysTotal
T1 — Migrations (2 tables)hub_core0.50.5
T2 — Models (2)hub_core0.50.5
T3 — Local call-usage repohub_core1.50.52.0
T4 — MetaCallAnalytics servicehub_core2.00.52.5
T5 — Meta call-usage repohub_core1.50.52.0
T6 — Workers (dispatcher + local + throttled meta)hub_core1.50.52.0
T7 — Alert interactor + workerhub_core2.01.03.0
T8 — Balance snapshot workerhub_core1.00.51.5
T9 — Retention cleanerhub_core0.50.5
T10 — Cron + queue wiringhub-worker0.50.51.0
Grand total11.54.015.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_point field 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

ActionFileWhat changes
createhub_core: database/billing/db/migrate/<ts>_create_waba_call_comparison.rbcreate_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
createhub_core: database/billing/db/migrate/<ts>_create_waba_call_balance_snapshot.rbcreate_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

  1. Explore — open hub_core: database/billing/db/migrate/20260223000000_create_whatsapp_usage_comparison.rb and the follow-on 20260506000000_add_timezone_and_difference_to_whatsapp_usage_comparison.rb to copy the column helpers (t.bigint … comment: 'Unix timestamp (seconds)', t.decimal precision: 20, scale: 4).
  2. Write both migration files with the exact columns/indexes from RFC §2.3 (use decimal(20,4) for Rp columns, bigint for start_date/end_date/snapshot_date).
  3. Apply in the host/dummy app: bundle exec rails db:migrate against the billing DB; confirm billing_schema.rb dumps both tables.
  4. Quality gatebundle exec rubocop on the new files; verify rails db:rollback reverts cleanly (both directions).

Acceptance criteria

  • Both tables exist with all columns + types from RFC §2.3.
  • idx_wcc_unique_daily_direction is unique; idx_wcbs_unique_daily is unique.
  • bundle exec rails db:migrate then db:rollback both succeed.

Test strategy

No model logic yet — verification is schema-level (migrate up/down + schema dump diff).

Effort estimate

DisciplineDays
Backend0.5
QA
Total0.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

ActionFileWhat changes
createhub_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
createhub_core: app/core/domains/models/billing/waba_call_comparison_spec.rbasserts table mapping + constants
createhub_core: app/core/domains/models/billing/waba_call_balance_snapshot.rb< Models::AbstractModelBilling; self.table_name = 'waba_call_balance_snapshot'
createhub_core: app/core/domains/models/billing/waba_call_balance_snapshot_spec.rbasserts table mapping

Implementation steps

  1. Explore — open hub_core: app/core/domains/models/billing/whatsapp_usage_comparison.rb (the constants pattern) and voice_call_log.rb (model conventions in this folder).
  2. Red — write both *_spec.rb asserting table_name and constant values; bundle exec rspec → fail.
  3. Implement — create both models.
  4. Green + gatebundle 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_status constants present and public.

Test strategy

Model specs assert table_name and constant values — no DB rows needed.

Effort estimate

DisciplineDays
Backend0.5
QA
Total0.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_logs and upserts it into waba_call_comparison as usage_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

ActionFileWhat changes
createhub_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
createhub_core: app/apps/billings/repositories/v1/wa_call_usage_comparison/record_daily_spec.rbasserts 2 rows (inbound+outbound), correct sum, DST window, replica read

Implementation steps

  1. Explore — open hub_core: app/apps/billings/repositories/v1/wa_usage_comparison/record_daily.rb (the upsert + timestamp pattern) and app/core/domains/repositories/billings/gets/voice_log.rb (the real VoiceCallLog.where(company_id:, created_at: range, …) query + switch_replica_billing_db).
  2. Resolve company_id — mirror RecordDailyWaMetaUsageWorker#resolve_cid: Models::Organization.find_by(id: org_id)&.company_id else Models::Billing::OrganizationPackage.find_by(organization_id:)&.company_id.
  3. Red — write record_daily_spec.rb seeding voice_call_logs rows (inbound + outbound, channel_source: 'whatsapp') and asserting two upserted rows with summed usage_local; run bundle exec rspec → fail.
  4. Implement — query Models::Billing::VoiceCallLog.where(company_id:, channel_source: 'whatsapp', created_at: start_ts_time..end_ts_time).group(:direction).sum(:total_price) inside switch_replica_billing_db; upsert per direction via find_or_create_by(organization_id:, start_date:, end_date:, waba_id:, direction:) then assign_attributes(usage_local:, usage_difference: usage_meta.to_f - usage_local, waba_timezone:, waba_timezone_id:).
  5. Green + gatebundle exec rspec .../record_daily_spec.rb; bundle exec rubocop.

Acceptance criteria

  • Sums total_price per direction for channel_source='whatsapp' + resolved company_id within 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

DisciplineDays
Backend1.5
QA0.5
Total2.0

Assumptions: reuses the verified VoiceLog query filters; confirm whether the credited_to/is_auto_deduct filters from gets/voice_log.rb should 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_analytics edge 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

ActionFileWhat changes
createhub_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
createhub_core: app/apps/billings/services/meta_call_analytics_spec.rbstubs HTTP; asserts request params, retry on 429/5xx, auth classification, direction mapping, blank/empty handling

Implementation steps

  1. Explore — open hub_core: app/apps/billings/services/meta_pricing_analytics.rb and copy: constructor (env URL/token), fetch_with_retry, RETRYABLE_HTTP_STATUSES, parse_response, build_data, Rollbar alert_failure.
  2. Red — write meta_call_analytics_spec.rb stubbing get(...) to return a sample { data: [...] } payload and 429/5xx/empty cases; assert request path/params, retry behaviour, and USER_INITIATED→inbound/BUSINESS_INITIATED→outbound mapping; run → fail.
  3. Implement — build the edge call (get(path: "/#{@waba_id}/call_analytics", body: { start:, end:, granularity: 'DAILY', metric_types: '["COST"]', dimensions: '["DIRECTION"]', access_token: @token })); parse data → data_points, sum COST per direction. Spike: run one real call against a test WABA, log the raw payload, and pin the COST data_point field name (e.g. cost/amount) + currency in the parser; assert loudly (Rollbar) on mismatch.
  4. Green + gatebundle 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_analytics with granularity=DAILY, metric_types=["COST"], dimensions=["DIRECTION"], Unix start/end.
  • Retries up to 3× on 408/425/429/5xx; classifies 401/403 as 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 mark error.
  • (Spike) COST data_point field 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

DisciplineDays
Backend2.0
QA0.5
Total2.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, upserts usage_meta + usage_difference per direction into waba_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

ActionFileWhat changes
createhub_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
createhub_core: app/apps/billings/repositories/v1/wa_call_usage_comparison/record_daily_meta_spec.rbasserts upsert, sign, status sentinels, error path

Implementation steps

  1. Explore — open hub_core: app/apps/billings/repositories/v1/wa_usage_comparison/record_daily_meta.rb (the fetch → build → persist shape + META_RESPONSE_STATUS_* sentinels + usage_difference write).
  2. Red — write record_daily_meta_spec.rb stubbing MetaCallAnalytics (success/blank/failure); assert per-direction usage_meta upsert, usage_difference sign, and meta_response_status transitions; run → fail.
  3. Implement — replace the message client with MetaCallAnalytics; group by direction (not phone/category); upsert via find_or_create_by(organization_id:, start_date:, end_date:, waba_id:, direction:) then assign_attributes(usage_meta:, usage_difference: usage_meta - usage_record.usage_local.to_f, meta_response_status:, waba_timezone:, waba_timezone_id:).
  4. Green + gatebundle exec rspec .../record_daily_meta_spec.rb; bundle exec rubocop.

Acceptance criteria

  • Writes usage_meta per direction and usage_difference = usage_meta − usage_local (Decision 6).
  • Meta failure → meta_response_status='error', no partial usage_meta overwrite (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

DisciplineDays
Backend1.5
QA0.5
Total2.0

Assumptions: reuses the V2 RecordDailyMeta skeleton; 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

ActionFileWhat changes
createhub_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
createhub_core: app/core/workers/billings/record_daily_wa_call_usage_worker.rb< AbstractSidekiqWorker; resolves timezone + cid; calls RecordDaily (T3) per WABA
createhub_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
create3× co-located *_spec.rbdispatch counts, flag gate, throttle config, tz-missing skip

Implementation steps

  1. 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, and resolve_waba_accounts usage.
  2. 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 with limit: 4_000. Run → fail.
  3. 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.
  4. Green + gatebundle 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_throttle with limit: 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

DisciplineDays
Backend1.5
QA0.5
Total2.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

ActionFileWhat changes
createhub_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
createhub_core: app/core/workers/billings/calculate_daily_wa_call_discrepancy_worker.rb< AbstractSidekiqWorker; queue billing_calculate_wa_call_daily_discrepancy; runs the interactor
create2× co-located *_spec.rbaggregation sums, threshold gating, top-5, no-data path

Implementation steps

  1. Explore — open hub_core: app/apps/billings/interactors/v1/wa_usage_comparison/calculate_daily_discrepancy.rb — copy system_wide_rupiah_summary, top_waba_offenders, build_rupiah_alert_sections, format_rupiah, the Redis/ENV threshold loader, and the Services::Gchat::Webhooks::SendMessage call.
  2. 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.
  3. Implement — point all queries at Models::Billing::WabaCallComparison; group offenders by waba_id, direction (not category); new constants for the call Redis/ENV threshold keys + Preference alert key; decide OQ-4 (keep V2's gmt7_waba_ids filter or alert all timezones — default: keep, conservative).
  4. Green + gatebundle 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_met logged (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

DisciplineDays
Backend2.0
QA1.0
Total3.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

ActionFileWhat changes
createhub_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
createhub_core: app/core/workers/billings/snapshot_daily_wa_call_balance_worker_spec.rbone row/active WABA/day; distinct across days; retry→snapshot_failed

Implementation steps

  1. Explore — open hub_core: app/apps/billings/repositories/v1/wa_usage_comparison/record_daily.rbupsert_remaining_estimation! (how V2 snapshots whatsapp_package balance into whatsapp_daily_remaining_estimation) and app/core/domains/models/billing/voice_package.rb (balance, company_id).
  2. 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× then snapshot_failed (ERR-1). Run → fail.
  3. Implement — resolve active WABAs/orgs, read VoicePackage#balance, find_or_create_by(organization_id:, waba_id:, snapshot_date:) then set ending_balance, snapshotted_at; confirm OQ-6 (balance source = voice_packages.balance).
  4. Green + gatebundle exec rspec .../snapshot_daily_wa_call_balance_worker_spec.rb; bundle exec rubocop.

Acceptance criteria

  • One row per active WABA per day with ending_balance from voice_packages.balance (MMCU-S05/AC-1).
  • Consecutive days → distinct rows, no overwrite (AC-2).
  • DB failure → retry 3× then snapshot_failed critical, 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

DisciplineDays
Backend1.0
QA0.5
Total1.5

Assumptions: voice_packages.balance is 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

ActionFileWhat changes
createhub_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
createco-located *_spec.rbdeletes aged rows, keeps fresh rows

Implementation steps

  1. Explore — search hub_core: app/core/workers/billings/ for an existing cleaner (e.g. MuvQueueCleanerWorker, scheduled as billing_muv_queue_cleaner) to follow the batch-delete + cron pattern; decide OQ-5 from what you find.
  2. Red — spec seeds old + fresh rows; assert only aged rows deleted. Run → fail.
  3. ImplementModels::Billing::WabaCallComparison.where('created_at < ?', 90.days.ago).in_batches.delete_all (+ snapshot by snapshot_date).
  4. Green + gatebundle 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

DisciplineDays
Backend0.5
QA
Total0.5

Assumptions: follows the existing billing_muv_queue_cleaner pattern; 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

ActionFileWhat changes
extendhub-worker: config/sidekiq_schedule.ymladd 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)
extendhub-worker: config/sidekiq.ymlregister the new billing_* queues (comparison, local, meta, calculate, snapshot, cleanup)

Implementation steps

  1. Explore — open hub-worker: config/sidekiq_schedule.yml lines 51–59 (the V2 comparison + discrepancy entries) and config/sidekiq.yml lines 33–36 (the V2 billing_* queue list) to match exact formatting.
  2. 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.
  3. Verify — boot hub-worker (or load the schedule) and confirm Sidekiq::Cron::Job registers all 4 jobs; confirm queues appear in the Sidekiq dashboard.
  4. 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 in sidekiq.yml.
  • On boot, Sidekiq::Cron::Job.all includes 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

DisciplineDays
Backend0.5
QA0.5
Total1.0

Assumptions: host app already loads config/sidekiq_schedule.yml via 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_point field 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_comparison exists (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

StoryReason
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).