Runbook — Create hold at send (BIF-8741 / T2)
When an org enabled for hold/settlement (see
wa_hold_settlement-flag-registration.md) sends a
billable WA Cloud message, a wa_balance_holds row (state='held') is inserted the moment the
wamid is known. INSERT only — no pool/balance money moves at send time; settlement (T5)
moves money later.
What fires where
| Send path | Wiring point | Category → billable? |
|---|---|---|
| Broadcast | WaCloud::Repositories::Broadcast::Send#call (after Meta returns the wamid) | template category (marketing/utility/authentication → hold; estimated via WaPricing) |
| Single message (inbox reply) | Subscribers::MessageSend success branch (wa_cloud only) | service (session) → free, no hold |
| Single message (Direct Send room) | same subscriber wiring | utility (Direct Send is always utility) → hold |
Free categories are skipped by the shared predicate is_free_deduction? (now in
Repositories::Billings::Helpers, used by BOTH send-time CreateHold and webhook-time
NewPricingWaDeduction) — PMP rule: free unless Meta prices it regular; service and
referral_conversion are free.
Fail-safe properties (by design)
- Idempotent — duplicate
wamidhits the partial unique(external_id, state)index →rescue RecordNotUnique → Success, single row. - Fail-open to the send — any CreateHold error is rescued at both wiring points (Rollbar / log) and never fails the message or dead-letters the queue. Worst case is a missing hold (under-reserve), surfaced by the T4 missing-hold metric and bounded by the T6 sweeper.
- Toggle enforced inside the repo —
wa_hold_settlement_enabled?(package); toggle off ⇒ zero behavior change on the send path.
Verify a pilot org is creating holds
-- chat_billing shard
SELECT state, conversation_category, count(*), sum(estimated_amount)
FROM wa_balance_holds
WHERE organization_id = '<organization_id>'
AND created_at >= now() - interval '1 day'
GROUP BY 1, 2;
Expect: held rows for billable categories only (no service/referral_conversion rows),
estimated_amount > 0, phone_recipient = the business sending number
(channel.settings['server_wa_id']) normalized via normalize_phone_recipient (.to_phone,
E.164 no +) so it matches the settlement batch key (BIF-9080 Bug #1), external_id = wamid.
MM Lite broadcasts (send_marketing_message) tag the hold conversation_category='marketing_lite'
— Meta reports that traffic as MARKETING_LITE (BIF-9080 Bug #2).
# Console spot-check for one broadcast:
Models::Billing::WaBalanceHold.where(message_broadcast_id: '<messages_broadcast_id>')
.group(:state).count
Rollback
Per-org: clear the package extras toggle (or global flag off) — holds stop immediately; already
held rows are released by the webhook refund branch (T4) or the 30-day sweeper (T6). No data
migration needed (INSERT-only path).
Sources (grounded against hub_core)
- Repo:
app/apps/wa_cloud/repositories/billings/create_hold.rb(+ co-located spec, 10 examples). - Shared predicate:
Repositories::Billings::Helpers#is_free_deduction?(moved fromnew_pricing_wa_deduction.rb; webhook flow unchanged — the class includes the module). - Wiring:
app/core/events/subscribers/message_send.rb(create_wa_balance_hold, wa_cloud guard) andapp/apps/wa_cloud/repositories/broadcast/send.rb(create_wa_balance_holdbefore the success return). - Estimate:
Services::Billing::V2::WaPricing#total_price(org_id, package_id);countryfromWaPricing#code(customer phone /ext_country_code).