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>
13 KiB
Wallet Refactor — 2026-08-25
Brings promotions' wallet settings and deposit routing in line with the naming/routing
convention already rolled out to advertising, settlement, and ipg. No behavior in
those sibling repos changed as part of this — this document covers promotions only, with
the sibling commits cited as precedent for why the shape of the fix looks the way it does.
1. The bug
Promotion.promote() (apps/promotions/models.py) submits every payout as a wallet
deposit. A deposit call takes two wallet references:
payer_wallet— the call-target / URL param — the company-side pool the money is drawn from.payee_wallet— a field in the request body — the user-side wallet type the recipient is credited into.
Before this change, both were the same setting:
"payee_wallet": settings.WALLET_REWARD,
...
submit_response = deposit_to_user_wallet_submit(settings.WALLET_REWARD, data)
...
verify_response = deposit_to_user_wallet_verify(settings.WALLET_REWARD, submit_response.uuid)
So every promotion payout was routed out of and into the same wallet type — there was
no real company-owned pool distinct from the user-side wallet category. This is the same
defect fixed in settlement (commit cc2ffb9, 2026-08-16):
"Withdraw requests were passing the user's own source wallet (WALLET_RIAL/WALLET_REWARD) as the withdrawal's destination wallet param, so every settlement payout and commission was routed back into the same wallet type it came from."
and still open, but flagged, in advertising's wallet_service_integration.md §6.2 for
AdPayment.refund_balance().
A second, smaller bug rode along: Recipient.get_wallet_category_uuid() fell back to
settings.WALLET_PROMOTION_CATEGORY_UUID — a setting that was never defined anywhere in
main/settings.py. It happened to never be called from the live deposit path (which
hardcoded WALLET_REWARD instead), so this was latent, not yet crashing anything — but it
would have raised AttributeError the moment anyone wired it in, which is exactly what
this change does.
2. The fix
2.1 Settings renamed to match the shared cross-repo convention
The two user-side wallet-type UUIDs are the same wallet-service UUIDs referenced by
advertising, settlement, and ipg — they're renamed to match those repos' naming
(advertising, then propagated to settlement in cc2ffb9 and ipg in 7dcb730):
| Before | After |
|---|---|
WALLET_RIAL |
WALLET_RIAL_DEPOSIT |
WALLET_REWARD |
WALLET_USER_BILLBOARD_VISIT_INCOME |
| (did not exist) | WALLET_PROMOTIONS_TRANSIT — new |
WALLET_RIAL_DEPOSIT isn't read by any code path in promotions today (it wasn't before
either, under its old name) — it's renamed for consistency and in case a future feature
here needs to read a user's rial balance via get_user_wallets().
2.2 A dedicated company-side wallet for payouts
WALLET_PROMOTIONS_TRANSIT is new: the company pool promotion payouts are drawn from,
used only as payer_wallet (the deposit call-target). It is never the same UUID as any
user-side wallet type. This is the promotions equivalent of WALLET_SETTLEMENT_TRANSIT
(settlement) / WALLET_ADVERTISING_TRANSIT (advertising).
This is a required new environment variable. It ships in .env.example as a
placeholder UUID (00000000-...) with a TODO(you) comment — same pattern as
settlement's .env.example. It needs a real wallet-type UUID provisioned by the
wallet-service team before this service can submit a deposit in any environment, and
your local/staging/prod .env files need the rename applied (WALLET_RIAL →
WALLET_RIAL_DEPOSIT, WALLET_REWARD → WALLET_USER_BILLBOARD_VISIT_INCOME) plus this
new key added, or main/settings.py will fail at startup with
decouple.UndefinedValueError.
2.3 Per-recipient destination routing wired in
Recipient.get_wallet_category_uuid() existed already (self.wallet_uuid or <fallback>)
but nothing called it — every payout hardcoded WALLET_REWARD as payee_wallet
regardless of what a Recipient might specify. It's now the actual source of
payee_wallet in Promotion.promote():
payee_wallet = self.recipient.get_wallet_category_uuid()
So a Recipient with its own wallet_uuid set now routes its payout to that wallet type
instead of the default; a Recipient with wallet_uuid=None falls back to
WALLET_USER_BILLBOARD_VISIT_INCOME. This mirrors EscrowWalletPayment.destination_wallet
in advertising — a per-row field read at call time instead of one hardcoded constant —
without inventing a new abstraction: Recipient.wallet_uuid and
get_wallet_category_uuid() already existed in this codebase, they just weren't
connected to anything.
3. Before / after, side by side
main/settings.py
WALLET_BASE_PUBLIC_URL = config('WALLET_BASE_PUBLIC_URL', default=None, cast=str)
-WALLET_RIAL = config('WALLET_RIAL', cast=str)
-WALLET_REWARD = config('WALLET_REWARD', cast=str)
+WALLET_RIAL_DEPOSIT = config('WALLET_RIAL_DEPOSIT', cast=str)
+WALLET_USER_BILLBOARD_VISIT_INCOME = config('WALLET_USER_BILLBOARD_VISIT_INCOME', cast=str)
+# Company-side pool promotion payouts are drawn from; must be distinct from the user-side
+# wallet above. TODO(you): replace with the real wallet-type UUID from the wallet service.
+WALLET_PROMOTIONS_TRANSIT = config('WALLET_PROMOTIONS_TRANSIT', cast=str)
apps/promotions/models.py — Recipient.get_wallet_category_uuid()
def get_wallet_category_uuid(self):
- return self.wallet_uuid or settings.WALLET_PROMOTION_CATEGORY_UUID
+ return self.wallet_uuid or settings.WALLET_USER_BILLBOARD_VISIT_INCOME
apps/promotions/models.py — Promotion.promote()
payment_uuid = str(self.uuid)
+ payee_wallet = self.recipient.get_wallet_category_uuid()
+
data = {
"uuid": payment_uuid,
"payee": str(self.user_uuid),
"payee_type": 1,
- "payee_wallet": settings.WALLET_REWARD,
+ "payee_wallet": payee_wallet,
"amount": self.promotion_amount,
"details": {
'description': str(_(self.recipient.label)),
'reference_id': str(self.pk),
'application_details_url': ''
},
}
try:
- submit_response = deposit_to_user_wallet_submit(settings.WALLET_REWARD, data)
+ submit_response = deposit_to_user_wallet_submit(settings.WALLET_PROMOTIONS_TRANSIT, data)
...
try:
- verify_response = deposit_to_user_wallet_verify(settings.WALLET_REWARD, submit_response.uuid)
+ verify_response = deposit_to_user_wallet_verify(settings.WALLET_PROMOTIONS_TRANSIT, submit_response.uuid)
What the deposit call looks like now, end to end
| Before | After | |
|---|---|---|
payer_wallet (call-target, company money source) |
WALLET_REWARD |
WALLET_PROMOTIONS_TRANSIT |
payee_wallet (body, user-side credit type) |
WALLET_REWARD (same UUID as source) |
Recipient.wallet_uuid, falling back to WALLET_USER_BILLBOARD_VISIT_INCOME |
| Per-recipient routing | Not possible — one hardcoded constant | Possible — set Recipient.wallet_uuid |
4. Files touched
| File | Change |
|---|---|
main/settings.py |
Renamed WALLET_RIAL→WALLET_RIAL_DEPOSIT, WALLET_REWARD→WALLET_USER_BILLBOARD_VISIT_INCOME; added WALLET_PROMOTIONS_TRANSIT. |
apps/promotions/models.py |
Recipient.get_wallet_category_uuid() fallback fixed to point at a setting that actually exists; Promotion.promote() now uses WALLET_PROMOTIONS_TRANSIT as payer_wallet and recipient.get_wallet_category_uuid() as payee_wallet. |
.env.example |
New — didn't exist before. Documents every config() var read by main/settings.py, including the new wallet keys as placeholders. |
README.md |
Config reference table updated to the renamed/new settings. |
Not touched: apps/promotions/tests.py — its wallet mocks patch the client functions
directly (patch('apps.promotions.models.deposit_to_user_wallet_submit', ...)) rather
than asserting on which UUID was passed, so they don't need updating for this change, but
they also don't exercise the routing fix — there's no test asserting payer_wallet /
payee_wallet on the call. apps/promotions/handlers.py (the dead processor/handler
scaffolding noted in the README's watch list) — unrelated, left as-is.
5. What you need to do before this runs anywhere
- Get a real wallet-type UUID for
WALLET_PROMOTIONS_TRANSITfrom the wallet-service team — it must be a genuine, dedicated pool, not a reused existing UUID (that's the exact bug this change fixes). - In every environment's
.env(local, staging, prod — none are checked into this repo):- Rename
WALLET_RIAL→WALLET_RIAL_DEPOSIT(same value, key renamed). - Rename
WALLET_REWARD→WALLET_USER_BILLBOARD_VISIT_INCOME(same value, key renamed). - Add
WALLET_PROMOTIONS_TRANSITwith the real UUID from step 1.
- Rename
- Until step 2 is done in a given environment,
main/settings.pywill fail to import withdecouple.UndefinedValueError: WALLET_RIAL_DEPOSIT not found— the service won't start at all, not just fail at payout time. Treat this as a deploy-blocking config change, not a code-only one.
6. Follow-up: per-promotion-type wallet routing (2026-08-25)
Not every payout is a cash-like user reward. Some promotion types fund billboard/ad credit instead — money that should land in the advertising service's own transit wallet, not the user's personal reward wallet:
| Promotion type | Nature | Destination |
|---|---|---|
first_ad_view (or unset) |
Cash-like user reward | WALLET_USER_BILLBOARD_VISIT_INCOME (user-side) |
capture |
Billboard/ad credit | WALLET_ADVERTISING_TRANSIT (advertising's own company pool) |
first_ad_create |
Billboard/ad credit | WALLET_ADVERTISING_TRANSIT (advertising's own company pool) |
Before this follow-up, none of this was implemented: PromotionTypeChoices (in
apps/promotions/handlers.py) was an empty TextChoices enum, so Recipient.promotion_type
could never hold a real value, and get_wallet_category_uuid() had no way to distinguish one
promotion from another — every payout without an explicit Recipient.wallet_uuid fell
through to the same single default.
Changes
-
apps/promotions/handlers.py—PromotionTypeChoicesnow has three real members:FIRST_AD_VIEW,CAPTURE,FIRST_AD_CREATE. -
main/settings.py— newWALLET_ADVERTISING_TRANSITsetting. Same wallet-service UUID as advertising's own setting of the same name — copy that repo's real value in, don't provision a second UUID for the same wallet. -
apps/promotions/models.py—Recipient.get_wallet_category_uuid()now branches:def get_wallet_category_uuid(self): if self.wallet_uuid: return self.wallet_uuid if self.promotion_type in (PromotionTypeChoices.CAPTURE, PromotionTypeChoices.FIRST_AD_CREATE): return settings.WALLET_ADVERTISING_TRANSIT return settings.WALLET_USER_BILLBOARD_VISIT_INCOMEPriority order: an explicit
Recipient.wallet_uuidalways wins (per-recipient override, from the original refactor above); otherwisepromotion_typepicks the wallet; otherwise the default cash-reward wallet. -
apps/promotions/tests.py— thefirst-ad-createfixture now setspromotion_type=PromotionTypeChoices.FIRST_AD_CREATE;test_first_ad_create_event_status_processedasserts the deposit call actually receivesWALLET_PROMOTIONS_TRANSITaspayer_walletandWALLET_ADVERTISING_TRANSITaspayee_wallet; a newtest_get_wallet_category_uuid_routingunit-tests all four routing cases directly againstRecipient.get_wallet_category_uuid(). -
README.md— config reference table and the playbook's step 3 example updated to show settingpromotion_typeon a newRecipient.
What's still manual
- No existing
Plan/Recipientrows in a live database getpromotion_typeset automatically — this is a schema/behavior change, not a data migration. Whoever owns thecaptureandfirst_ad_createplans needs to setpromotion_typeon theirRecipientrows via admin (or a data migration) for this routing to take effect on existing data. WALLET_ADVERTISING_TRANSITis a second required env var on top ofWALLET_PROMOTIONS_TRANSIT— same deploy-blocking caveat as §5: missing it failsmain/settings.pyimport, not just a payout at runtime.Recipient.promotion_type's field-levelchoices=metadata changed (empty → three values). This has no DB-level effect (Django doesn't enforcechoiceswith aCHECKconstraint), but runningmanage.py makemigrationswill want to record a no-op migration for it — harmless to generate, just formakemigrations --checkcleanliness in CI.