promotions/docs/wallet_refactor.md
Ali Asadi 0fe4a3ed79 FIX(promotions): route payouts by explicit Recipient.wallet_destination, not promotion type
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>
2026-08-25 17:12:01 +03:30

282 lines
14 KiB
Markdown

# 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_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()`:
```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_TRANSIT = config('WALLET_PROMOTIONS_TRANSIT', 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_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
1. Get a real wallet-type UUID for `WALLET_PROMOTIONS_TRANSIT` 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_TRANSIT` 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_TRANSIT` 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_TRANSIT` — 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.