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>
The first attempt at this inferred the payout destination from
PromotionTypeChoices (first_ad_view/capture/first_ad_create), a general
promotion-category field that had never held real values. That coupled two
independent facts together — a promotion's category and where its money
goes aren't the same thing, and every new promotion type would need someone
to remember to also classify it for wallet purposes.
Revert PromotionTypeChoices to empty (left as pre-existing dead scaffolding,
untouched) and add Recipient.wallet_destination instead — a real field that
states the routing decision directly. Recipient.get_wallet_category_uuid()
now branches on it: user_reward (default) resolves to
WALLET_USER_BILLBOARD_VISIT_INCOME, advertising_transit resolves to
WALLET_ADVERTISING_TRANSIT. An explicit Recipient.wallet_uuid still wins
over either.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
PromotionTypeChoices was an empty enum, so Recipient.promotion_type could
never hold a real value and every payout without an explicit
Recipient.wallet_uuid fell through to the same default wallet regardless of
what kind of promotion it was.
Give PromotionTypeChoices real values (first_ad_view, capture,
first_ad_create) and branch Recipient.get_wallet_category_uuid() on it:
first_ad_view (or unset) still pays into the user's cash-like reward wallet
(WALLET_USER_BILLBOARD_VISIT_INCOME); capture/first_ad_create are billboard/ad
credit, not a cash reward, so they route to the new WALLET_ADVERTISING_TRANSIT
setting (same wallet-service UUID as the advertising repo's own setting of
that name) instead. An explicit Recipient.wallet_uuid still wins over
promotion_type.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Promotion.promote() passed the same wallet UUID (WALLET_REWARD) as both
payer_wallet and payee_wallet, so every payout was routed out of and into
the same wallet type with no real company-owned pool. Add
WALLET_PROMOTIONS_TRANSIT as the dedicated payer_wallet, and rename
WALLET_RIAL/WALLET_REWARD to WALLET_RIAL_DEPOSIT/WALLET_USER_BILLBOARD_VISIT_INCOME
to match the naming used for the same wallet-service UUIDs in advertising/
settlement/ipg.
Also wires Recipient.get_wallet_category_uuid() (previously dead, and
falling back to an undefined setting) into the deposit call as payee_wallet,
so a Recipient can route its payout to its own wallet_uuid instead of every
payout hardcoding one constant.
Adds .env.example (none existed before) and docs/wallet_refactor.md
documenting the before/after and required .env changes per deploy target.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Passes through to the notifications service, which embeds it as the
Gotify tap destination when set. Optional — no existing call site
changes, most notifications stay non-clickable.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
WALLET_RIAL -> WALLET_RIAL_DEPOSIT, WALLET_REWARD -> WALLET_USER_BILLBOARD_VISIT_INCOME
to match the naming used for the same wallet-service UUIDs in the advertising/settlement
repos. Also introduces WALLET_PROMOTION as this service's own pool and routes
Promotion.promote()'s payer_wallet through it instead of reusing the payee's
WALLET_REWARD value.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
ApplicationEventViewSet.perform_create() was passing the full request.user
model instance into Event.user (a plain UUIDField), which raises on every
real call. Use the same _resolve_user() -> user.uuid pattern already used by
status(), and raise UnprocessableEntity on save_event failure to match the
v1 endpoint's error-handling convention.
Fold the restricted-recipient access check into the recipients
queryset itself via Q(public OR restricted-and-allowed), joining
against AllowedUser instead of running a separate lookup query.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Checking each recipient via Recipient.is_user_allowed() inside the
loop issued one AllowedUser query per restricted recipient (N+1).
Fetch all matching AllowedUser rows for the plan's recipients in a
single query up front instead.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
get_event_status_for_user returned None whenever the event was
already processed, no plan matched, or the user lacked access to the
recipient. Callers now always get a concrete amount: the real payout
if accessible, 0 otherwise.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Add Recipient.access_type (restricted/public) and an AllowedUser
table linking users to the recipients they may be paid through.
Restricted recipients are now checked against AllowedUser both when
computing a promotion payout and in the event-status endpoints that
report eligibility before submission.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>