14 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_CREDIT — 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_CREDIT 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_CREDIT = config('WALLET_PROMOTIONS_CREDIT', 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_CREDIT, 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_CREDIT, 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_CREDIT |
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_CREDIT. |
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_CREDIT 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_CREDITfrom 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_CREDITwith 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: explicit per-recipient wallet destination (2026-08-25)
Not every payout is a cash-like user reward. Some fund billboard/ad credit instead — money that should land in the advertising service's own transit wallet, not the user's personal reward wallet.
An earlier version of this follow-up tried to infer the destination from
PromotionTypeChoices (first_ad_view / capture / first_ad_create), a promotion
category field that existed but had never been given real values. That was reverted: it
buried a wallet-routing decision inside a general-purpose categorization field, coupling
two things that should vary independently — a promotion's category and where its money
goes are not the same fact, and the next new promotion type would need someone to remember
to also classify it for wallet purposes.
Instead, Recipient gets a field that says the routing decision directly:
class WalletDestinationChoices(models.TextChoices):
USER_REWARD = 'user_reward', _('user reward wallet')
ADVERTISING_TRANSIT = 'advertising_transit', _('advertising transit wallet')
wallet_destination |
Nature | Resolves to |
|---|---|---|
user_reward (default) |
Cash-like user reward | WALLET_USER_BILLBOARD_VISIT_INCOME |
advertising_transit |
Billboard/ad credit | WALLET_ADVERTISING_TRANSIT (advertising's own company pool) |
PromotionTypeChoices is left as it was before any of this — an empty enum, unused. It's
not part of this decision.
Changes
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:-
New
WalletDestinationChoicesenum, next to the existingRecipientTypeChoices. -
New
Recipient.wallet_destinationfield (CharField,db_index=True, defaultUSER_REWARD) — a real DB column, unlike the earlierpromotion_typeattempt which only changed field-levelchoices=metadata. -
Recipient.get_wallet_category_uuid():def get_wallet_category_uuid(self): if self.wallet_uuid: return self.wallet_uuid if self.wallet_destination == WalletDestinationChoices.ADVERTISING_TRANSIT: return settings.WALLET_ADVERTISING_TRANSIT return settings.WALLET_USER_BILLBOARD_VISIT_INCOMEPriority order: an explicit
Recipient.wallet_uuidalways wins (the per-recipient raw override from the original refactor above); otherwisewallet_destinationpicks the wallet type;user_rewardis the default so existing rows behave exactly as before this change until someone opts them intoadvertising_transit.
-
apps/promotions/migrations/0010_recipient_wallet_destination.py— new migration adding the column,AddFieldwithdefault='user_reward'so existing rows backfill safely.apps/promotions/tests.py— thefirst-ad-createfixture now setswallet_destination=WalletDestinationChoices.ADVERTISING_TRANSIT;test_first_ad_create_event_status_processedasserts the deposit call actually receivesWALLET_PROMOTIONS_CREDITaspayer_walletandWALLET_ADVERTISING_TRANSITaspayee_wallet;test_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 settingwallet_destinationon a newRecipient.
What's still manual
- Existing
Plan/Recipientrows in a live database default towallet_destination='user_reward'on migrate — behavior for them doesn't change. Whoever owns the realcapture/first-ad-createplans needs to explicitly setwallet_destination='advertising_transit'on theirRecipientrows (via admin or a follow-up data migration) for those specific payouts to actually route to the advertising transit wallet. WALLET_ADVERTISING_TRANSITis a second required env var on top ofWALLET_PROMOTIONS_CREDIT— same deploy-blocking caveat as §5: missing it failsmain/settings.pyimport, not just a payout at runtime.- This migration hasn't been run against a real database in this environment (no local
.env/DB configured here) — runmanage.py migrateand confirm0010applies cleanly before deploying.