The promotions service had no record of the wallet transfers it makes --
the Promotion row's state was the only trace. Add PromotionTransaction, a
ledger row per transfer (mirrors advertising's AdPayment / escrow's
EscrowWalletPayment): PAYOUT (promotions credit -> recipient wallet) and
ROLLBACK (advertising transit -> promotions credit).
- Promotion.promote() now drives its payout through a PromotionTransaction
PAYOUT row (.execute() does the submit/verify dance) instead of an
inline, unrecorded wallet call.
- rollback_promotion_payout(user, event_label): reverses a payout that
landed in the advertising transit wallet, back to the promotions credit
wallet, when the advertising side discards what it paid for (e.g. a
captured billboard deleted while pending approval). The Promotion stays
consumed -- only the money moves; payouts straight to the user's wallet
are not reversible. Idempotent; returns reversed | deferred | nothing.
- The ROLLBACK row doubles as the async-race marker: when the request
arrives before the Celery payout task has run, a ROLLBACK row is
recorded and Recipient.promote() suppresses (or, if it raced, reverses)
the payout. Replaces the separate PromotionRollback table from the first
cut of this change.
- POST .../application/<user>/event/<event_label>/rollback/
- migration 0011 (hand-written; verified via makemigrations --dry-run + check)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
When the advertising service discards what a promotion paid for (e.g. a
captured billboard deleted while still pending approval), the promotion
money sitting in the advertising transit wallet needs to go back to the
promotions credit wallet. The promotion itself stays consumed -- only the
money is returned -- and only payouts that landed in the transit wallet
are reversible (a payout straight to the user's wallet is the user's).
- Promotion.rolled_back_at + rollback_to_credit(): row-locked, CAS-stamped,
idempotent transfer transit -> credit for SUCCESS/transit-destined payouts.
- PromotionRollback model: keyed (user, event_label) -- all the advertising
side knows. Handles the async race (payout runs in a Celery task, so the
Promotion may not exist yet): Recipient.promote() checks for an unsettled
request before paying (suppresses the payout) and after (reverses a
payout that landed mid-request).
- POST .../event/<event_label>/rollback/ -> {status: reversed|deferred|nothing, amount}.
- migration 0011 (hand-written; verified via makemigrations --dry-run + check).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>