Runbook — EOD settlement against Meta (BIF-8744 / T5)
At end of day the hold ledger is reconciled against Meta's actual cost to the cent:
Billings::SettleWaHoldsDispatcherWorker → Billings::SettleWaHoldsWorker →
Billings::Repositories::V1::WaHoldSettlement::SettleDaily (all in hub_core). Holds created at
send (create-hold-at-send.md) and marked delivered by webhooks
(webhook-hold-transitions.md) are settled FIFO here — this is
the only place money moves for orgs on the hold/settlement path.
What it does
For every (waba_id, phone_recipient, category, meta_date) bucket:
-
Meta cost/volume are fetched per Meta day (
MetaPricingAnalytics, window(today−7d)..(today−1d)in the WABA timezone) and frozen into awa_reconciliation_batchesrow on first sight (find_or_create_byon the unique bucket key — idempotent across re-runs and concurrent workers). The window is lazy: a day that already has batch rows is skipped (totals are frozen anyway), so steady state is one Meta call per WABA per night (~6k calls fleet-wide at GA, not ~42k); the trailing week is only fetched for days a previous run missed. A zero-usage day yields no rows and is re-probed until it ages out of the window — a cheap, empty response. -
Unassigned
wa_balance_holdsare consumed FIFO (delivered_at ASC, agedheldlast) up tometa_volume. Consumed states:delivered, plusheldholds older thanAGED_HELD_THRESHOLD(1h) — a still-heldhold Meta already charged for is a lostdeliveredwebhook, so it settles with its real customer_ref/country/broadcast_id (BIF-9080 Enhancement A); recentheldrows are genuinely in-flight and skipped. The batch↔hold match is normalization-tolerant (BIF-9080 Bug #1/#2):phone_recipientmatches both the normalized.to_phonekey and the legacy+-prefixed twin, and the category match accepts hold-vocabulary aliases — aMARKETING_LITEbatch also consumes legacymarketing-tagged MM Lite holds. -
Each hold settles at the Meta unit price (
meta_cost_total / meta_volume); the hold that completes the batch takes the rounding residual soΣ settled_amount == meta_cost_totalexactly. -
Pools move through the live-deduction ladder —
balance_initial → balance → postpaid (v3 postpaid only) → negative balance. Each hold is charged its Meta base share + the org's configured margin (WaPricingtotal_price − base_price: conversation fee, discount, tax — the same components live deduction charges). One optimistic-lockedwhatsapp_packageswrite per bucket. -
One actual-amount
wa_conversation_logsrow per hold withorigin_type='reconciliation_settlement',is_auto_deduct=false.base_pricemirrors Meta exactly (reporting/reconciliation);total_price/creditcarry the amount actually deducted (base + margin). Monthly-reset gap queries (which filteris_auto_deduct: true,single_reset_package.rb) ignore these rows. -
Holds with a
message_broadcast_idenqueueBillings::BroadcastDeductionReportWorkerwith the actual settled amount. -
Phantom settlement (BIF-9080 Enhancement B): at the 30-day horizon a bucket whose Meta volume was never fully covered by holds no longer under-charges — the residual (
meta_cost_total − Σ settled base) is deducted as one syntheticwa_conversation_logsrow (origin_type='reconciliation_gap_deduct',conversation_id='gap-deduct-<batch.id>') soΣ settled_amount == meta_cost_totalexactly. Base is Meta's residual; margin is WaPricing's markup × phantom count, priced with a blended country (most common country among the batch's already-settled sibling holds; WaPricingOTHER/business-number fallback if the bucket settled no hold). Always on (new flow, not flag-gated); idempotent (the batch closessettled, so a late hold for the slot can never re-settle).
Key code (hub_core): app/apps/billings/repositories/v1/wa_hold_settlement/settle_daily.rb
(find_or_create_batch, fifo_holds / phone_recipient_candidates / hold_categories_for,
hold_amount, apply_ladder, close_at_horizon! / gap_deduct_phantom! / phantom_margin /
blended_country, report_broadcast_deductions). Phone normalization on the hold write path:
normalize_phone_recipient in app/core/domains/repositories/billings/helpers.rb
(applied in create_hold.rb); MM Lite hold tagging in
app/apps/wa_cloud/repositories/broadcast/send.rb.
Scheduling
Add the dispatcher to the host app's Sidekiq Cron schedule (hub_core has no production cron registry — it is not self-scheduling):
settle_wa_holds_dispatcher:
cron: '0 21 * * *' # 04:00 Asia/Jakarta — after Meta closes the WABA day
class: 'Billings::SettleWaHoldsDispatcherWorker'
queue: billing_settle_wa_holds
Both workers run with retry: 0 on queue billing_settle_wa_holds; the per-org worker is
throttled to 1 000 jobs/hour. Because the fetch window covers 7 days and re-runs are idempotent,
a missed night self-heals on the next run — no manual catch-up needed inside 7 days.
Feature gating / kill switch
Enablement gate — company-scoped Services::Billing::FeatureFlag (as-built; see
wa_hold_settlement-flag-registration.md):
| Tier | How | Effect on dispatcher |
|---|---|---|
| Global GA | wa_hold_settlement preference is_global = true (billing-preferences service) | every package is dispatched — all statuses, trial or not (the dispatcher enumerates organization_packages and filters via wa_hold_settlement_enabled?) |
| Pilot | add the company_id to the wa_hold_settlement preference_unique_ids allow-list | only opted-in companies (any status/trial) |
organization_packages.extras['wa_hold_settlement']is not the mechanism — that two-tierServices::Preference∨extrasdesign (RFC Decision 6) was not shipped.
Turning the flag off does NOT stop settlement for organizations that still have open
(held/delivered) holds — the dispatcher keeps enqueuing them until the ledger drains. This is
deliberate: the webhook path never live-deducts a wamid that has a hold row (dual-emit guard,
T4), so draining via settlement is the only way that money moves. To stop settlement entirely,
stop the cron entry.
SettleDaily itself is not flag-gated (drain semantics) — invoking it manually settles
regardless of the flag.
Invariants (what "correct" looks like)
Two ledgers, one per audience: batch amounts are Meta-base-only (reconciliation against
Meta); hold settled_amount and log total_price/credit are the client charge (base +
margin); the log's base_price bridges the two.
- Per settled batch:
settled_amount + unmatched_cost == meta_cost_totalandΣ wa_conversation_logs.base_price (origin_type='reconciliation_settlement', batch's holds) == settled_amount. - Pool movement per batch ==
Σ wa_conversation_logs.credit== Meta base share + margin × holds. - A hold is settled at most once:
state='settled'⇒reconciliation_batch_idset, and the(external_id, state)partial unique index blocks duplicates. - Re-running any day produces zero additional movement (Meta totals are frozen on batch creation; settled batches are skipped).
- Settlement never touches
wa_credit.COST==0buckets settle their holds at 0 withbilled_to='free', no pool movement and no margin.
Routine operations
Re-run settlement for one organization
Safe at any time (idempotent):
Billings::SettleWaHoldsWorker.perform_async(organization_id)
# or synchronously, one WABA, explicit day:
Billings::Repositories::V1::WaHoldSettlement::SettleDaily.new(
organization_id: org_id, waba_id: waba_id, timezone: 'Asia/Jakarta'
).call
Inspect pending (unreconciled) batches
-- chat_billing shard
SELECT waba_id, phone_recipient, category, meta_date, meta_volume, settled_count,
meta_cost_total, settled_amount
FROM wa_reconciliation_batches
WHERE state = 'pending'
ORDER BY meta_date ASC;
A batch stays pending while settled_count < meta_volume (late deliveries). It is reopened by
every subsequent run and force-closed at the 30-day horizon (SETTLEMENT_HORIZON_DAYS), where
any uncovered residual is phantom-deducted (Enhancement B) so the closed batch has
unmatched_cost == 0.
Verify a batch settled to the cent
SELECT b.meta_cost_total, b.settled_amount, b.unmatched_cost,
COALESCE(SUM(l.base_price), 0) AS meta_base_total, -- must == b.settled_amount
COALESCE(SUM(l.credit), 0) AS client_charged -- base + margin (pool movement)
FROM wa_reconciliation_batches b
LEFT JOIN wa_balance_holds h ON h.reconciliation_batch_id = b.id
LEFT JOIN wa_conversation_logs l ON l.external_id = h.external_id
AND l.origin_type = 'reconciliation_settlement'
WHERE b.id = :batch_id
GROUP BY b.id;
Expect meta_base_total == settled_amount and settled_amount + unmatched_cost == meta_cost_total;
client_charged exceeds meta_base_total by the org's margin per message.
Alerts and what to do
Rollbar: [wa_hold_settlement] reconciliation gap phantom-deducted at 30-day horizon
A bucket hit the horizon with settled_count < meta_volume: Meta billed for messages we hold no
matching reservation for. As of BIF-9080 Enhancement B the gap is deducted, not left as a
shortfall — the residual is charged as one synthetic origin_type='reconciliation_gap_deduct'
log row (client charge = Meta base + blended-country margin), unmatched_cost closes at 0, and
Σ settled_amount == meta_cost_total. The alert is now observability, not a lost charge.
Datadog counter: wa_hold_settlement_shortfall.
The pre-BIF-9080 behaviour — record
unmatched_cost+ areconciliation_shortfalllog, never deducted (RFC SC-4 bounded under-charge) — has been removed. There is no longer areconciliation_shortfallorigin type.
Triage (the charge is now correct; alert tells you why holds were missing):
- Compare hold count vs Meta volume for the bucket — a systematic gap usually means
CreateHoldwas not wired for that send path or the wamid never reached the send subscriber (correlate with the T4wa_hold_missing_on_webhookmetric). Fix the hold-creation gap so real holds (with exact per-country margin) settle instead of the blended phantom. - One-off small gaps are expected noise (Meta counts a message we never created a hold for, e.g. sent outside Qontak) — now billed via the phantom entry, no action needed.
- The batch is closed and fully billed. Do not manually reopen; a late hold for the slot is ignored (no double-charge).
Sidekiq: ActiveRecord::StaleObjectError re-enqueues
Optimistic-lock conflict (webhook transition or a concurrent run touched the same hold /
whatsapp_packages row). The worker swallows it and re-enqueues the whole org; each bucket
commits in its own transaction, so the retry only picks up what did not commit. A tight
re-enqueue loop (> ~10/min for one org) means something is hammering the same
whatsapp_packages row — check for a stuck webhook replay before doing anything else.
Meta API failures
A failed pricing_analytics fetch skips that Meta day only (logged as
SettleDaily meta fetch failed); pending batches still process. A missed day has no batch rows,
so the lazy window keeps retrying it on every run while inside the 7-day window
(META_FETCH_WINDOW_DAYS). If Meta was down for longer than 7 days, run SettleDaily manually
with today: set so the affected days fall inside the window — batch creation is idempotent.
Note the lazy-window edge: a day that already produced some batch rows is never re-fetched, so
a bucket Meta reports late for that day is not picked up — it surfaces as a shortfall at the
horizon instead.
Known sharp edges
- Meta totals are frozen at first sight. If Meta restates a day's cost after the batch row
was created, settlement will not pick up the new figure. Detect via the
whatsapp_usage_comparisonreports (daily-usage recorder); remediation is a manual finance adjustment, not a code path. - Buckets match holds on
(waba_id, phone_recipient, category), normalization-tolerant.phone_recipientmatches the normalized.to_phonekey or its legacy+-prefixed twin, and the category match accepts hold-vocabulary aliases (MARKETING_LITE⇄marketing). If a channel'sserver_wa_idgenuinely changes (different number), old holds keep the old number and will not match new-number buckets; they are covered by the phantom deduct on the new number at the horizon and eventually swept (T6). - Residual allocation is unit-price, not
cost/consumed.size. On a partial (late-delivery) pass each hold settles atmeta_cost_total / meta_volume; the batch-completing hold (real or the horizon phantom) takes the residual. Dividing byconsumed.sizewould front-load the whole cost onto early holds and mis-price the per-message base. - Phantom margin is blended, not country-exact. The horizon gap-deduct prices its margin with
the most common country among the batch's already-settled sibling holds (or an
OTHER/ business-number fallback). Base is Meta-exact; only the smaller margin component is approximated (bounded error). Country-exact phantom margin is out of scope unless finance needs per-country phantom reporting.