promotions/docs/wallet_refactor.md
2026-08-31 18:27:16 +03:30

14 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_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 <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_CREDIT = config('WALLET_PROMOTIONS_CREDIT', 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_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:

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

      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.