# 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: ```python "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 `) 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()`: ```python 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` ```diff 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()` ```diff 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()` ```diff 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 1. Get a real wallet-type UUID for `WALLET_PROMOTIONS_CREDIT` from the wallet-service team — it must be a genuine, dedicated pool, not a reused existing UUID (that's the exact bug this change fixes). 2. 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_CREDIT` with the real UUID from step 1. 3. Until step 2 is done in a given environment, `main/settings.py` will fail to import with `decouple.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: ```python 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` — new `WALLET_ADVERTISING_TRANSIT` setting. 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 `WalletDestinationChoices` enum, next to the existing `RecipientTypeChoices`. - New `Recipient.wallet_destination` field (`CharField`, `db_index=True`, default `USER_REWARD`) — a real DB column, unlike the earlier `promotion_type` attempt which only changed field-level `choices=` metadata. - `Recipient.get_wallet_category_uuid()`: ```python 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_INCOME ``` Priority order: an explicit `Recipient.wallet_uuid` always wins (the per-recipient raw override from the original refactor above); otherwise `wallet_destination` picks the wallet type; `user_reward` is the default so existing rows behave exactly as before this change until someone opts them into `advertising_transit`. - `apps/promotions/migrations/0010_recipient_wallet_destination.py` — new migration adding the column, `AddField` with `default='user_reward'` so existing rows backfill safely. - `apps/promotions/tests.py` — the `first-ad-create` fixture now sets `wallet_destination=WalletDestinationChoices.ADVERTISING_TRANSIT`; `test_first_ad_create_event_status_processed` asserts the deposit call actually receives `WALLET_PROMOTIONS_CREDIT` as `payer_wallet` and `WALLET_ADVERTISING_TRANSIT` as `payee_wallet`; `test_get_wallet_category_uuid_routing` unit-tests all four routing cases directly against `Recipient.get_wallet_category_uuid()`. - `README.md` — config reference table and the playbook's step 3 example updated to show setting `wallet_destination` on a new `Recipient`. ### What's still manual - Existing `Plan`/`Recipient` rows in a live database default to `wallet_destination='user_reward'` on migrate — behavior for them doesn't change. Whoever owns the real `capture` / `first-ad-create` plans needs to explicitly set `wallet_destination='advertising_transit'` on their `Recipient` rows (via admin or a follow-up data migration) for those specific payouts to actually route to the advertising transit wallet. - `WALLET_ADVERTISING_TRANSIT` is a second **required** env var on top of `WALLET_PROMOTIONS_CREDIT` — same deploy-blocking caveat as §5: missing it fails `main/settings.py` import, 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) — run `manage.py migrate` and confirm `0010` applies cleanly before deploying.