Skip to main content

Deduction V2.0 — Task Breakdown

Source design. This breakdown is the execution view of the authorize-at-send + settle-against-Meta (hold/settlement) design documented in deduction-v2.md. Slicing: vertical, backend end-to-end. All file paths are grounded against the real hub_core checkout. The hold/settlement layer is greenfield — a repo-wide grep found no pre-existing wa_balance_holds / wa_reconciliation_batches / wa_hold_settlement / CreateHold / SettleDaily code.

Verified repo facts (drive every task below):

  • Specs are colocated beside source (*_spec.rb), require 'rails_helper', run via bundle exec rspec <path> + bundle exec rubocop.
  • Migrations: database/billing/db/migrate/ (YYYYMMDDHHMMSS_slug.rb); models extend Models::AbstractModelBilling on the :billing shard.
  • Helpers#available_wa_balance does not exist (only find_whatsapp_package) — net-new.
  • config/sidekiq_schedule.yml does not exist — T7 creates it (loaded by config/initializers/sidekiq.rb:27-30).
  • Services::Preference is defined in moderator-be; flag registration is an admin/console task, not app code.

Effort Summary

TaskBE daysQA daysTotal
T1 — Hold ledger schema, models & flag22
T2 — Create hold at send20.52.5
T3 — Available-balance helper, send-gate & Available-only display20.52.5
T4 — Webhook state transitions + broadcast dual-emit guard20.52.5
T5 — EOD settlement against Meta415
T6 — Stale-hold sweeper (30-day expiry)0.50.51
T7 — Cron scheduling + docs0.50.5
Grand total13316

Confidence: medium. The design is concrete and every anchor is verified against real code. Risk concentrates in T5 (settlement) — FIFO late-delivery reopen, count-mismatch shortfall at the 30-day horizon, Meta-currency conversion, and the monthly-reset / double-charge mutual-exclusion invariant are the unknowns that could move the estimate. Smaller unknown: the exact Services::Preference entry point callable from hub_core (the definition was located in moderator-be) — flagged in T1.


T1: [BE] Hold ledger schema, models & feature flag

The system can persist per-message holds and per-bucket settlement batches, gated by a new wa_hold_settlement flag — the foundation every other task builds on.

Status: ✅ Actionable

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, and registration of the org-scoped wa_hold_settlement flag (admin/console task).

Implementation Plan

ActionFileWhat changes
createhub_core/database/billing/db/migrate/<ts>_create_wa_balance_holds.rbone row per billable message; cols per plan §Data-model; indexes incl. partial unique (external_id, state) where external_id IS NOT NULL
createhub_core/database/billing/db/migrate/<ts>_create_wa_reconciliation_batches.rbone row per settled bucket; unique (organization_id, waba_id, phone_recipient, category, meta_date) + (organization_id, meta_date)
createhub_core/app/core/domains/models/billing/wa_balance_hold.rb< Models::AbstractModelBilling; enum state: { held: 'held', delivered: 'delivered', settled: 'settled', refunded: 'refunded', expired: 'expired' }; lock_version
createhub_core/app/core/domains/models/billing/wa_reconciliation_batch.rb< Models::AbstractModelBilling; enum state: { pending: 'pending', settled: 'settled' }; lock_version
createhub_core/app/core/domains/models/billing/wa_balance_hold_spec.rbenum transitions, default state held, uniqueness guard
createhub_core/app/core/domains/models/billing/wa_reconciliation_batch_spec.rbuniqueness index, default state pending

Implementation steps

  1. Explore — open hub_core/app/core/domains/models/billing/wa_conversation_log.rb (base Models::AbstractModelBilling, lockbox + enum billed_to: {...} style) and the 3 most recent files in database/billing/db/migrate/ to copy the timestamp+slug naming and the :billing-shard migration shape.
  2. Write failing model specs (red) — create the two *_spec.rb colocated with the models; assert default states and the enum value maps. bundle exec rspec <path> → fails.
  3. Migrations — create wa_balance_holds with all columns from the plan (organization_id/organization_package_id/channel_id/message_id uuid; external_id, conversation_category, pricing_model default 'PMP', estimated_amount decimal(20,4), country, phone_recipient, customer_ref nullable, state default 'held', settled_amount, message_broadcast_id, reconciliation_batch_id, held_at/delivered_at/settled_at/expired_at, lock_version) and all six indexes. Create wa_reconciliation_batches likewise. Standardize all tenant ids as uuid (plan note — the EOD draft's bigint is wrong for this codebase).
  4. Models — add both models with state enums and confirm optimistic locking is active (lock_version present + AbstractModelBilling.locking_enabled?).
  5. Register the flag — document the admin/console step: Services::Preference.new.add(:wa_hold_settlement, target: 'feature', author: 'hafriz.damarsidi@mekari.com', …) then .enable per org. (Not app code — runbook line in the ticket.)
  6. Go greenbundle exec rails db:migrate (billing DB) up and down; rerun specs until green.
  7. Quality gatebundle exec rubocop on the new files.

Acceptance criteria

  • Both tables created with every column + index from the plan; partial-unique (external_id, state) and the batch unique key exist.
  • Migration is reversible (up + down dry-run clean).
  • WaBalanceHold.new.state == 'held' by default; WaReconciliationBatch.new.state == 'pending'.
  • All tenant id columns are uuid (match WaConversationLog/WhatsappUsageComparison).
  • wa_hold_settlement registration step documented; flag defaults OFF.

Test strategy

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

Effort estimate

DisciplineDays
Frontend
Backend2
QA
Total2

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

Run to verify

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

Depends on

  • — (foundation)

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

When an org on the flag sends a billable WhatsApp message, a held row is reserved against its balance the moment the wamid is known — no pool money moves yet.

Status: ✅ Actionable

What to build

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

Implementation Plan

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

Implementation steps

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

Acceptance criteria

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

Test strategy

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

Effort estimate

DisciplineDays
Frontend
Backend2
QA0.5
Total2.5

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

Run to verify

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

Depends on

  • [T1] (holds table + model)

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

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

Status: ✅ Actionable

What to build

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

Implementation Plan

ActionFileWhat changes
extendhub_core/app/core/domains/repositories/billings/helpers.rbadd available_wa_balance(wa_package) = balance_initial + balance + postpaid_limit − Σ(holds in held|delivered); keep the existing postpaid conditional (v3-postpaid only)
extendhub_core/app/core/domains/interactors/abstract_iteractor.rbreplace estimate-only check (~65-76) with available_wa_balance − balance_deduct < 0 → Failure, flag-gated
extendhub_core/app/apps/wa_cloud/repositories/broadcast/send.rbsame substitution in #validate_balance (~179-202), flag-gated
extend<get-usage-balance endpoint interactor>return available_wa_balance when flag on (Available-only)
createhub_core/app/core/domains/repositories/billings/helpers_spec.rb (or extend existing)helper math with/without holds; postpaid conditional

Implementation steps

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

Acceptance criteria

  • available_wa_balance subtracts only held|delivered holds; reused by gate and display.
  • Send blocked when available − cost < 0 (flag on); unchanged when flag off.
  • get usage balance returns Available (not Pooled) when flag on; Pooled-only behavior preserved when off.
  • Postpaid conditional preserved (only v3-postpaid orgs count postpaid_limit).

Test strategy

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

Effort estimate

DisciplineDays
Frontend
Backend2
QA0.5
Total2.5

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

Run to verify

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

Depends on

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

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

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

Status: ✅ Actionable

What to build

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

Implementation Plan

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

Implementation steps

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

Acceptance criteria

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

Test strategy

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

Effort estimate

DisciplineDays
Frontend
Backend2
QA0.5
Total2.5

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

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 exactly Meta's base_price, and one actual-amount WaConversationLog row written per hold.

Status: ✅ Actionable

What to build

SettleDaily repo (modeled on record_daily_meta.rb) + dispatcher + per-org worker: FIFO allocation across the bucket's unsettled delivered holds (residual-to-last-hold so Σ settled == meta_cost_total), idempotent reconciliation batches, money movement via the existing ladder, actual-amount WaConversationLog rows with is_auto_deduct=false, late-delivery reopen + 30-day shortfall, and settlement-time broadcast report.

Implementation Plan

ActionFileWhat changes
createhub_core/app/apps/billings/repositories/v1/wa_hold_settlement/settle_daily.rbper [waba_id, timezone]: window = (today−7d)..(today−1d) + any pending batch within 30d; per Meta day ascending, MetaPricingAnalytics.new(...).call; per bucket find_or_create_by on wa_reconciliation_batches; FIFO consume oldest delivered holds up to meta_volume; per_hold = meta_cost_total/consumed.size residual-to-last; ladder deduction (reuse 87-127); WaConversationLog actual rows origin_type='reconciliation_settlement', is_auto_deduct=false; keep batch pending until settled_count == meta_volume; shortfall row only at 30d horizon; COST==0 → settle at 0 billed_to='free'
createhub_core/app/core/workers/billings/settle_wa_holds_dispatcher_worker.rbretry: 0; fan out per active non-trial OrganizationPackage plus orgs with open holds; flag-gated
createhub_core/app/core/workers/billings/settle_wa_holds_worker.rbretry: 0; per org resolve WABA tz via Concerns::WaTimezoneResolver; call repo per [waba_id, tz]; rescue StaleObjectError → re-enqueue
extendhub_core/app/core/workers/billings/broadcast_deduction_report_worker.rbenqueued from settlement for settled holds carrying message_broadcast_id, with the actual amount
createcolocated *_spec.rb for the repo + both workersFIFO, residual, idempotent re-run, count-mismatch reopen, shortfall, sweeper interplay, monthly-reset is_auto_deduct=false

Implementation steps

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

Acceptance criteria

  • Per bucket, Σ settled_amount == meta_cost_total exactly (residual to last hold).
  • Pool deducted by exactly Meta base_price; one actual-amount WaConversationLog per settled hold, origin_type='reconciliation_settlement', is_auto_deduct=false.
  • Idempotent: re-running a day → one money movement (batch unique key + reconciliation_batch_id IS NULL filter).
  • Late delivery: consumed.size < meta_volume settles available, batch stays pending, reopens within 30d window.
  • 30-day horizon with settled_count < meta_volume → single reconciliation_shortfall row + Rollbar.
  • COST==0 & holds present → settle at 0, billed_to='free', reserve released.
  • Monthly reset's gap queries ignore settlement rows (is_auto_deduct=false).

Test strategy

RSpec with a stubbed MetaPricingAnalytics bucket: tally invariant, idempotency (run 2×), the three count-mismatch branches, and a monthly-reset interaction test proving settlement rows are excluded from reset gap math. Tag elasticsearch: false.

Effort estimate

DisciplineDays
Frontend
Backend4
QA1
Total5

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

Run to verify

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

Depends on

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

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

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

Status: ✅ Actionable

What to build

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

Implementation Plan

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

Implementation steps

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

Acceptance criteria

  • Only state='held' rows with held_at < 30.days.agoexpired.
  • delivered/settled/refunded holds never touched.
  • No money movement; reserve released (Available reconverges).
  • Safe with concurrent delivery webhook (with_lock re-check).

Test strategy

RSpec: stale-held expiry, non-stale and non-held exclusion, and a concurrency case where a row flips to delivered before the lock — assert it is not expired.

Effort estimate

DisciplineDays
Frontend
Backend0.5
QA0.5
Total1

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

Run to verify

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

Depends on

  • [T1] (holds)

T7: [BE] Cron scheduling + docs

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

Status: ✅ Actionable

What to build

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

Implementation Plan

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

Implementation steps

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

Acceptance criteria

  • config/sidekiq_schedule.yml exists and parses; both jobs registered with correct cron + queue.
  • Sweeper scheduled after settlement.
  • GLOSSARY + Billings spoke diagram updated.

Test strategy

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

Effort estimate

DisciplineDays
Frontend
Backend0.5
QA
Total0.5

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

Run to verify

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

Depends on

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

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 total) and the highest-risk — front-load design review on its FIFO/reopen/shortfall logic and lock in the double-charge mutual-exclusion check (a message must never be both live-deducted and held+settled) before coding.
  • Nothing is externally blocked. Unlike the earlier debit-at-webhook design, this design needs no Figma (no aggregate-history UI) and no Modpanel held_deducted exposure — the "Available-only" display is a pure BE endpoint change (T3). The only non-code prerequisite is registering the wa_hold_settlement flag (admin task, in T1).

Skipped stories

Story / itemReason
PRD DED-S05/DED-S06 — delayed local-midnight toggle (deduction_v2_effective_date/_end_date)Belongs to the earlier debit-at-webhook design. This design gates with a plain wa_hold_settlement flag, no effective/end-date columns. Dropped.
RFC DED-S07 — daily-aggregate history UI (TableComponentWhatsappBalance.vue)RFC-only; was blocked on Figma. The plan changes only the balance number (Available-only, BE-side in T3), not the history table. Out of scope here.
wa_held_deduction_logs table + WaHeldDeduction repo + mcc_logs aggregate branchFrom the earlier design; replaced here by wa_balance_holds + wa_reconciliation_batches + SettleDaily. Dropped.