promotions/docs/wallet_refactor.md
Ali Asadi f59b8810d8 FEATURE(wallets): rename WALLET_RIAL/WALLET_REWARD, add WALLET_PROMOTIONS_TRANSIT, wire per-recipient routing
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>
2026-08-25 13:27:49 +03:30

9.3 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_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():

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_TRANSIT = config('WALLET_PROMOTIONS_TRANSIT', 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_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.