promotions/docs/wallet_refactor.md
Ali Asadi 45a64f0013 FEATURE(promotions): route capture/first_ad_create payouts to advertising transit wallet
PromotionTypeChoices was an empty enum, so Recipient.promotion_type could
never hold a real value and every payout without an explicit
Recipient.wallet_uuid fell through to the same default wallet regardless of
what kind of promotion it was.

Give PromotionTypeChoices real values (first_ad_view, capture,
first_ad_create) and branch Recipient.get_wallet_category_uuid() on it:
first_ad_view (or unset) still pays into the user's cash-like reward wallet
(WALLET_USER_BILLBOARD_VISIT_INCOME); capture/first_ad_create are billboard/ad
credit, not a cash reward, so they route to the new WALLET_ADVERTISING_TRANSIT
setting (same wallet-service UUID as the advertising repo's own setting of
that name) instead. An explicit Recipient.wallet_uuid still wins over
promotion_type.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 16:57:44 +03:30

13 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.

6. Follow-up: per-promotion-type wallet routing (2026-08-25)

Not every payout is a cash-like user reward. Some promotion types fund billboard/ad credit instead — money that should land in the advertising service's own transit wallet, not the user's personal reward wallet:

Promotion type Nature Destination
first_ad_view (or unset) Cash-like user reward WALLET_USER_BILLBOARD_VISIT_INCOME (user-side)
capture Billboard/ad credit WALLET_ADVERTISING_TRANSIT (advertising's own company pool)
first_ad_create Billboard/ad credit WALLET_ADVERTISING_TRANSIT (advertising's own company pool)

Before this follow-up, none of this was implemented: PromotionTypeChoices (in apps/promotions/handlers.py) was an empty TextChoices enum, so Recipient.promotion_type could never hold a real value, and get_wallet_category_uuid() had no way to distinguish one promotion from another — every payout without an explicit Recipient.wallet_uuid fell through to the same single default.

Changes

  • apps/promotions/handlers.py — PromotionTypeChoices now has three real members: FIRST_AD_VIEW, CAPTURE, FIRST_AD_CREATE.

  • 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 — Recipient.get_wallet_category_uuid() now branches:

    def get_wallet_category_uuid(self):
        if self.wallet_uuid:
            return self.wallet_uuid
    
        if self.promotion_type in (PromotionTypeChoices.CAPTURE, PromotionTypeChoices.FIRST_AD_CREATE):
            return settings.WALLET_ADVERTISING_TRANSIT
    
        return settings.WALLET_USER_BILLBOARD_VISIT_INCOME
    

    Priority order: an explicit Recipient.wallet_uuid always wins (per-recipient override, from the original refactor above); otherwise promotion_type picks the wallet; otherwise the default cash-reward wallet.

  • apps/promotions/tests.py — the first-ad-create fixture now sets promotion_type=PromotionTypeChoices.FIRST_AD_CREATE; 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; a new 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 promotion_type on a new Recipient.

What's still manual

  • No existing Plan/Recipient rows in a live database get promotion_type set automatically — this is a schema/behavior change, not a data migration. Whoever owns the capture and first_ad_create plans needs to set promotion_type on their Recipient rows via admin (or a data migration) for this routing to take effect on existing data.
  • 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.
  • Recipient.promotion_type's field-level choices= metadata changed (empty → three values). This has no DB-level effect (Django doesn't enforce choices with a CHECK constraint), but running manage.py makemigrations will want to record a no-op migration for it — harmless to generate, just for makemigrations --check cleanliness in CI.