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 realhub_corecheckout. The hold/settlement layer is greenfield — a repo-wide grep found no pre-existingwa_balance_holds/wa_reconciliation_batches/wa_hold_settlement/CreateHold/SettleDailycode.
Verified repo facts (drive every task below):
- Specs are colocated beside source (
*_spec.rb),require 'rails_helper', run viabundle exec rspec <path>+bundle exec rubocop. - Migrations:
database/billing/db/migrate/(YYYYMMDDHHMMSS_slug.rb); models extendModels::AbstractModelBillingon the:billingshard. Helpers#available_wa_balancedoes not exist (onlyfind_whatsapp_package) — net-new.config/sidekiq_schedule.ymldoes not exist — T7 creates it (loaded byconfig/initializers/sidekiq.rb:27-30).Services::Preferenceis defined in moderator-be; flag registration is an admin/console task, not app code.
Effort Summary
| Task | BE days | QA days | Total |
|---|---|---|---|
| T1 — Hold ledger schema, models & flag | 2 | — | 2 |
| T2 — Create hold at send | 2 | 0.5 | 2.5 |
| T3 — Available-balance helper, send-gate & Available-only display | 2 | 0.5 | 2.5 |
| T4 — Webhook state transitions + broadcast dual-emit guard | 2 | 0.5 | 2.5 |
| T5 — EOD settlement against Meta | 4 | 1 | 5 |
| T6 — Stale-hold sweeper (30-day expiry) | 0.5 | 0.5 | 1 |
| T7 — Cron scheduling + docs | 0.5 | — | 0.5 |
| Grand total | 13 | 3 | 16 |
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::Preferenceentry point callable fromhub_core(the definition was located inmoderator-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_settlementflag — 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
| Action | File | What changes |
|---|---|---|
| create | hub_core/database/billing/db/migrate/<ts>_create_wa_balance_holds.rb | one row per billable message; cols per plan §Data-model; indexes incl. partial unique (external_id, state) where external_id IS NOT NULL |
| create | hub_core/database/billing/db/migrate/<ts>_create_wa_reconciliation_batches.rb | one row per settled bucket; unique (organization_id, waba_id, phone_recipient, category, meta_date) + (organization_id, meta_date) |
| create | hub_core/app/core/domains/models/billing/wa_balance_hold.rb | < Models::AbstractModelBilling; enum state: { held: 'held', delivered: 'delivered', settled: 'settled', refunded: 'refunded', expired: 'expired' }; lock_version |
| create | hub_core/app/core/domains/models/billing/wa_reconciliation_batch.rb | < Models::AbstractModelBilling; enum state: { pending: 'pending', settled: 'settled' }; lock_version |
| create | hub_core/app/core/domains/models/billing/wa_balance_hold_spec.rb | enum transitions, default state held, uniqueness guard |
| create | hub_core/app/core/domains/models/billing/wa_reconciliation_batch_spec.rb | uniqueness index, default state pending |
Implementation steps
- Explore — open
hub_core/app/core/domains/models/billing/wa_conversation_log.rb(baseModels::AbstractModelBilling, lockbox +enum billed_to: {...}style) and the 3 most recent files indatabase/billing/db/migrate/to copy the timestamp+slug naming and the:billing-shard migration shape. - Write failing model specs (red) — create the two
*_spec.rbcolocated with the models; assert default states and the enum value maps.bundle exec rspec <path>→ fails. - Migrations — create
wa_balance_holdswith all columns from the plan (organization_id/organization_package_id/channel_id/message_iduuid;external_id,conversation_category,pricing_model default 'PMP',estimated_amount decimal(20,4),country,phone_recipient,customer_refnullable,state default 'held',settled_amount,message_broadcast_id,reconciliation_batch_id,held_at/delivered_at/settled_at/expired_at,lock_version) and all six indexes. Createwa_reconciliation_batcheslikewise. Standardize all tenant ids asuuid(plan note — the EOD draft's bigint is wrong for this codebase). - Models — add both models with state enums and confirm optimistic locking is active (
lock_versionpresent +AbstractModelBilling.locking_enabled?). - Register the flag — document the admin/console step:
Services::Preference.new.add(:wa_hold_settlement, target: 'feature', author: 'hafriz.damarsidi@mekari.com', …)then.enableper org. (Not app code — runbook line in the ticket.) - Go green —
bundle exec rails db:migrate(billing DB) up and down; rerun specs until green. - Quality gate —
bundle exec rubocopon the new files.
Acceptance criteria
- Both tables created with every column + index from the plan; partial-unique
(external_id, state)and the batch unique key exist. - Migration is reversible (up + down dry-run clean).
-
WaBalanceHold.new.state == 'held'by default;WaReconciliationBatch.new.state == 'pending'. - All tenant id columns are
uuid(matchWaConversationLog/WhatsappUsageComparison). -
wa_hold_settlementregistration 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
| Discipline | Days |
|---|---|
| Frontend | — |
| Backend | 2 |
| QA | — |
| Total | 2 |
Assumptions: reuses
AbstractModelBilling+ existing migration patterns; no Lockbox PII columns on holds (customer_refis display-only plaintext per plan). QA deferred — behavior is exercised in T2–T6.
Run to verify
cd /Users/kerja/qontak/hub_core && bundle exec rspec app/core/domains/models/billing/wa_balance_hold_spec.rb app/core/domains/models/billing/wa_reconciliation_batch_spec.rb && 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
heldrow is reserved against its balance the moment thewamidis 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
| Action | File | What changes |
|---|---|---|
| create | hub_core/app/apps/wa_cloud/repositories/billings/create_hold.rb | INSERT-only hold; estimated_amount/country via Services::Billing::V2::WaPricing; phone_recipient = channel.settings['server_wa_id']; customer_ref display-only; idempotent on external_id (rescue ActiveRecord::RecordNotUnique → Success) |
| extend | hub_core/app/core/domains/repositories/v2/billings/new_pricing_wa_deduction.rb | extract is_free_deduction? (~199-205) into a shared predicate both send-time and webhook-time call |
| extend | hub_core/app/core/events/subscribers/message_send.rb | call CreateHold in the success branch (~line 43, external_id = result.success[:id]), guarded by target_channel == 'wa_cloud' + flag |
| extend | hub_core/app/apps/wa_cloud/repositories/broadcast/send.rb | call CreateHold where broadcast wamid is persisted to MessageBroadcastLog.external_id (~227-232) |
| create | hub_core/app/apps/wa_cloud/repositories/billings/create_hold_spec.rb | Success/Failure/wrong-org/free-category skip; flag on/off; idempotent double-insert |
Implementation steps
- Explore — read
new_pricing_wa_deduction.rblines 199-205 (is_free_deduction?) and theWaPricing#total_price(org_id, package_id)inputs inapp/core/domains/services/billing/v2/wa_pricing.rb; readmessage_send.rb:43andbroadcast/send.rb:227-232to see exactly whereexternal_idbecomes available. - Extract the predicate — move
is_free_deduction?to a shared location (e.g.Repositories::Billings::Helpers) so send-time skip and webhook-time agree (plan risk: free-category divergence). KeepNewPricingWaDeductioncalling it. - Write failing spec (red) —
create_hold_spec.rb: billable PMP message → oneheldrow with correctestimated_amount,phone_recipient,external_id;service/free category → no row; flag off → no row; duplicateexternal_id→ single row, returns Success. - Scaffold CreateHold — new repo,
Dry::MonadsSuccess/Failure likenew_pricing_wa_deduction.rb; INSERT only, no pool mutation. - Wire send points — single-message subscriber + broadcast persist point, both flag-gated; failure branch (no wamid) → no hold.
- Go green then quality gate (
rubocop).
Acceptance criteria
- Billable PMP send (flag on) creates exactly one
heldhold withestimated_amountfromWaPricingandphone_recipient=channel.settings['server_wa_id']. - Free categories (
service/UI/referral_conversion) create no hold, via the shared predicate. - Duplicate webhook/
external_id→ idempotent single row. - Flag off → zero behavior change (no hold).
- No pool/
balancefield is mutated by this path.
Test strategy
RSpec on CreateHold with both flag branches, wrong-organization_id, and a duplicate-insert idempotency case; stub WaPricing to a fixed estimate and assert the row's estimated_amount/phone_recipient.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | — |
| Backend | 2 |
| QA | 0.5 |
| Total | 2.5 |
Assumptions: reuses
WaPricingas-is for the estimate;external_idis reliably set at the two wiring points per recon. QA 0.5 — send-time money reservation is customer-visible via Available balance.
Run to verify
cd /Users/kerja/qontak/hub_core && bundle exec rspec app/apps/wa_cloud/repositories/billings/create_hold_spec.rb && bundle exec rubocop
Depends on
- [T1] (holds table + model)
T3: [BE] Available-balance helper, send-gate switch & Available-only display
An org can no longer over-spend money already committed to in-flight messages — the send-gate and the displayed balance both enforce
Available = Pooled − Reserved.
Status: ✅ 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
| Action | File | What changes |
|---|---|---|
| extend | hub_core/app/core/domains/repositories/billings/helpers.rb | add available_wa_balance(wa_package) = balance_initial + balance + postpaid_limit − Σ(holds in held|delivered); keep the existing postpaid conditional (v3-postpaid only) |
| extend | hub_core/app/core/domains/interactors/abstract_iteractor.rb | replace estimate-only check (~65-76) with available_wa_balance − balance_deduct < 0 → Failure, flag-gated |
| extend | hub_core/app/apps/wa_cloud/repositories/broadcast/send.rb | same substitution in #validate_balance (~179-202), flag-gated |
| extend | <get-usage-balance endpoint interactor> | return available_wa_balance when flag on (Available-only) |
| create | hub_core/app/core/domains/repositories/billings/helpers_spec.rb (or extend existing) | helper math with/without holds; postpaid conditional |
Implementation steps
- Explore — read
helpers.rb:218-232(find_whatsapp_package, the balance fields) andabstract_iteractor.rb:27-78(validate_wa_balance, esp. 65-76) to see the exact comparison being replaced; locate theget usage balanceinteractor that reads the package. - Write failing spec (red) — helper returns
pooled − Σholdsforheld|delivered, ignoressettled/refunded/expired; gate fails whenavailable − deduct < 0; both flag branches. - Helper — add
available_wa_balance; this is the single source so display and gate never disagree. - Switch the two gates — substitute in
abstract_iteractor.rbandbroadcast/send.rb, flag-gated (flag off = byte-identical current behavior). - Switch the balance endpoint — Available-only when flag on, reusing the same helper.
- Go green + quality gate.
Acceptance criteria
-
available_wa_balancesubtracts onlyheld|deliveredholds; reused by gate and display. - Send blocked when
available − cost < 0(flag on); unchanged when flag off. -
get usage balancereturns Available (not Pooled) when flag on; Pooled-only behavior preserved when off. - Postpaid conditional preserved (only v3-postpaid orgs count
postpaid_limit).
Test strategy
RSpec on the helper (seeded holds across all states), plus both gate call-sites with flag on/off; assert the displayed-balance interactor and the gate return the same number for the same fixture.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | — |
| Backend | 2 |
| QA | 0.5 |
| Total | 2.5 |
Assumptions: balance fields are exactly
balance_initial/balance/postpaid_limitper recon; bounded gate↔hold race is accepted (estimate only; EOD is the backstop). QA 0.5 — directly changes what a paying customer sees and can send.
Run to verify
cd /Users/kerja/qontak/hub_core && bundle exec rspec app/core/domains/repositories/billings/helpers_spec.rb app/core/domains/interactors/abstract_iteractor_spec.rb && bundle exec rubocop
Depends on
- [T1] (holds model). Pairs with [T2] (T2 writes the holds this sums) — can be built in parallel with T2.
T4: [BE] Webhook state transitions + broadcast dual-emit guard
Delivery/failure webhooks now just move a hold's state (no live pool deduction), and the legacy live broadcast report is suppressed so settlement won't double-count.
Status: ✅ Actionable
What to build
Branch NewPricingWaDeduction#call (flag-gated): delivered/read → held→delivered;
failed/expired → held|delivered→refunded; keep the existing dedupe +
StaleObjectError/QueryCanceled re-enqueue; move pool-deduction/WaConversationLog/broadcast-report
out to EOD; flag-gate the legacy live broadcast report off.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| extend | hub_core/app/core/domains/repositories/v2/billings/new_pricing_wa_deduction.rb | flag-on branch (~62-186): find hold by external_id, held→delivered (idempotent; read-after-delivered no-op); new failed/expired → refunded branch; no hold found → log + "missing hold" metric + Success (do not deduct); keep dedupe + re-enqueue (~164-173); flag-gate the live broadcast report (~215-248) off when flag on |
| extend | hub_core/app/core/domains/repositories/v2/billings/new_pricing_wa_deduction_spec.rb | webhook idempotency (call 2× → one transition); refund branch; flag on/off; missing-hold path |
Implementation steps
- Explore — read
new_pricing_wa_deduction.rb#call(11-186), the ladder (87-127), dedupe/re-enqueue (164-173), and the broadcast report (215-248) to see precisely what moves to EOD vs stays. - Write failing spec (red) — flag-on
deliveredtransitionsheld→delivered, no pool change; seconddelivered/readis a no-op;failed→refunded; missing hold → Success + metric, no deduction; flag-off path byte-identical to today. - Add the transition branches — flag-gated; preserve
WaUniqConvIdLogdedupe and theStaleObjectError/QueryCanceledre-enqueue exactly. - Suppress live deduction + broadcast report when flag on (these move to T5).
- Go green + quality gate.
Acceptance criteria
- Flag-on
delivered/read→held→delivered, idempotent, zero pool mutation. - Flag-on
failed/expired→held|delivered→refunded(reserve released, nothing charged). - Missing hold on
delivered→ Success + "missing hold" metric, never a fallback deduction. - Flag-off path is byte-identical to current behavior (no regression).
- Live broadcast report suppressed when flag on (no double-count with settlement).
Test strategy
RSpec on NewPricingWaDeduction covering both flag branches, webhook idempotency (2× → one transition), the refund branch, and the missing-hold metric path; assert no WaConversationLog row and no pool change on the flag-on transition.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | — |
| Backend | 2 |
| QA | 0.5 |
| Total | 2.5 |
Assumptions: modifying the hot webhook path — flag-off must early-return with negligible overhead. QA 0.5 — regression risk on the live money path. Merged the plan's "broadcast dual-emit guard" here since it edits the same file.
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'sbase_price, and one actual-amountWaConversationLogrow 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
| Action | File | What changes |
|---|---|---|
| create | hub_core/app/apps/billings/repositories/v1/wa_hold_settlement/settle_daily.rb | per [waba_id, timezone]: window = (today−7d)..(today−1d) + 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' |
| create | hub_core/app/core/workers/billings/settle_wa_holds_dispatcher_worker.rb | retry: 0; fan out per active non-trial OrganizationPackage plus orgs with open holds; flag-gated |
| create | hub_core/app/core/workers/billings/settle_wa_holds_worker.rb | retry: 0; per org resolve WABA tz via Concerns::WaTimezoneResolver; call repo per [waba_id, tz]; rescue StaleObjectError → re-enqueue |
| extend | hub_core/app/core/workers/billings/broadcast_deduction_report_worker.rb | enqueued from settlement for settled holds carrying message_broadcast_id, with the actual amount |
| create | colocated *_spec.rb for the repo + both workers | FIFO, residual, idempotent re-run, count-mismatch reopen, shortfall, sweeper interplay, monthly-reset is_auto_deduct=false |
Implementation steps
- Explore — read
record_daily_meta.rb(find_or_create_by 97-100,index_by_phone_and_category78-93,phone_recipient81/136,persist_status_only!),meta_pricing_analytics.rb.callkw-args, the ladder innew_pricing_wa_deduction.rb:87-127, andsingle_reset_package.rb:222-238(the gap queries that must ignoreis_auto_deduct=false). - Write failing specs (red) — stub Meta via
WhatsappCloudStubber/WebMock: assert pool deducted by exactlybase_price, holdssettled,Σ WaConversationLog actual == base_price; re-run 2× → one money movement;consumed.size < meta_volumeleaves batchpending+ reopens; 30d horizon writes onereconciliation_shortfall;COST==0settles free. - SettleDaily — model on
record_daily_meta.rb; bucket key(waba_id, phone_recipient, conversation_category, meta_date); FIFO candidate poolstate:'delivered', reconciliation_batch_id: nilordereddelivered_at ASC, no proximity/customer filter (immune to phone→bsuid migration); money movement once per bucket inside a txn, optimistic-locked. - Dispatcher + worker — mirror
record_daily_wa_usage_comparison_worker.rb/record_daily_wa_usage_worker.rb(bothretry: 0, the per-org one includesWaTimezoneResolver). - Broadcast report at settlement — enqueue
BroadcastDeductionReportWorkerwith actual amount formessage_broadcast_idholds. - Go green + quality gate.
Acceptance criteria
- Per bucket,
Σ settled_amount == meta_cost_totalexactly (residual to last hold). - Pool deducted by exactly Meta
base_price; one actual-amountWaConversationLogper 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 NULLfilter). - Late delivery:
consumed.size < meta_volumesettles available, batch stayspending, reopens within 30d window. - 30-day horizon with
settled_count < meta_volume→ singlereconciliation_shortfallrow + 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
| Discipline | Days |
|---|---|
| Frontend | — |
| Backend | 4 |
| QA | 1 |
| Total | 5 |
Assumptions: reuses the existing ladder,
MetaPricingAnalytics, and the daily-worker scaffold; Meta-currency conversion handled as an optional sub-step (capturecurrency, convert if ≠ pool currency). QA 1.0 (~25%) — this is the correctness core; a silent settlement bug hits every paying V2 org's books.
Run to verify
cd /Users/kerja/qontak/hub_core && bundle exec rspec app/apps/billings/repositories/v1/wa_hold_settlement/settle_daily_spec.rb app/core/workers/billings/settle_wa_holds_worker_spec.rb && bundle exec rubocop
Depends on
- [T1] (batches table), [T4] (
deliveredholds to settle)
T6: [BE] Stale-hold sweeper (30-day expiry)
Holds for messages that were sent but never delivered are released after 30 days, so reserved balance can't leak forever.
Status: ✅ 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
| Action | File | What changes |
|---|---|---|
| create | hub_core/app/core/workers/billings/sweep_stale_wa_holds_worker.rb | retry: 0; only touches state='held'; in_batches(of: 500) + with_lock re-check before → expired |
| create | hub_core/app/core/workers/billings/sweep_stale_wa_holds_worker_spec.rb | expires only stale held; leaves delivered/settled untouched; no pool change |
Implementation steps
- Explore — read
record_daily_wa_usage_worker.rbfor theretry: 0worker shape and any existingin_batches/with_lockusage insingle_reset_package.rb:51. - Write failing spec (red) — a
heldhold older than 30d →expired; adeliveredor recentheld→ untouched; assert nobalance/pool change. - Implement — batch + per-row
with_lockre-check (a hold may flip todeliveredmid-sweep). - Go green + quality gate.
Acceptance criteria
- Only
state='held'rows withheld_at < 30.days.ago→expired. -
delivered/settled/refundedholds never touched. - No money movement; reserve released (Available reconverges).
- Safe with concurrent delivery webhook (
with_lockre-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
| Discipline | Days |
|---|---|
| Frontend | — |
| Backend | 0.5 |
| QA | 0.5 |
| Total | 1 |
Assumptions: schedule after settlement (30d ≫ 7d window = safe margin) — see T7. QA 0.5 (min) — releasing reserve is customer-visible on Available balance.
Run to verify
cd /Users/kerja/qontak/hub_core && bundle exec rspec app/core/workers/billings/sweep_stale_wa_holds_worker_spec.rb && bundle exec rubocop
Depends on
- [T1] (holds)
T7: [BE] Cron scheduling + docs
The settlement and sweeper jobs run automatically every night, and the new tables/states/flag are documented for the team.
Status: ✅ 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
| Action | File | What changes |
|---|---|---|
| create | hub_core/config/sidekiq_schedule.yml | cron entries: SettleWaHoldsDispatcherWorker ~01:00 ICT, SweepStaleWaHoldsWorker ~02:00 ICT (sweeper after settlement) |
| extend | hub_core/GLOSSARY.md | add wa_balance_holds, wa_reconciliation_batches, wa_hold_settlement, hold states |
| extend | hub_core/docs/architecture/flows/billings/README.md | update the Billings spoke sequence diagram for hold→settle |
Implementation steps
- Explore — read
config/initializers/sidekiq.rb:27-30(it loadsconfig/sidekiq_schedule.ymlif present — confirm thesidekiq-cronYAML format expected) and any existing cron declaration to copy thecron:/class:/queue:shape. - Create the schedule file — two entries, correct ICT cron expressions, sweeper strictly after settlement.
- Docs — GLOSSARY entries + spoke diagram per the AGENTS.md rule.
- Quality gate — YAML lint / boot check that
Sidekiq::Cron::Job.load_from_hashparses the file.
Acceptance criteria
-
config/sidekiq_schedule.ymlexists and parses; both jobs registered with correct cron + queue. - 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
| Discipline | Days |
|---|---|
| Frontend | — |
| Backend | 0.5 |
| QA | — |
| Total | 0.5 |
Assumptions:
sidekiq-cronis the scheduler (initializer referencesSidekiq::Cron::Job). QA — verified via T5/T6 specs; cron is config glue.
Run to verify
cd /Users/kerja/qontak/hub_core && ruby -ryaml -e "YAML.load_file('config/sidekiq_schedule.yml')" && bundle exec rubocop
Depends on
- [T5] (dispatcher), [T6] (sweeper)
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_deductedexposure — the "Available-only" display is a pure BE endpoint change (T3). The only non-code prerequisite is registering thewa_hold_settlementflag (admin task, in T1).
Skipped stories
| Story / item | Reason |
|---|---|
PRD DED-S05/DED-S06 — delayed local-midnight toggle (deduction_v2_effective_date/_end_date) | Belongs to the earlier debit-at-webhook design. This design gates with a plain wa_hold_settlement flag, no effective/end-date columns. Dropped. |
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 branch | From the earlier design; replaced here by wa_balance_holds + wa_reconciliation_batches + SettleDaily. Dropped. |