Skip to main content

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 pathWiring pointCategory → billable?
BroadcastWaCloud::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 wiringutility (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 wamid hits 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 repowa_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 from new_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) and app/apps/wa_cloud/repositories/broadcast/send.rb (create_wa_balance_hold before the success return).
  • Estimate: Services::Billing::V2::WaPricing#total_price(org_id, package_id); country from WaPricing#code (customer phone / ext_country_code).