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>
282 lines
14 KiB
Markdown
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.
|