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>
198 lines
9.3 KiB
Markdown
198 lines
9.3 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.
|