From f59b8810d8460997f62f743b6f9138d14ca7fb20 Mon Sep 17 00:00:00 2001 From: Ali Asadi Date: Tue, 25 Aug 2026 13:27:49 +0330 Subject: [PATCH 1/3] 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 --- .env.example | 43 +++++++++ README.md | 4 +- apps/promotions/models.py | 10 +- docs/wallet_refactor.md | 198 ++++++++++++++++++++++++++++++++++++++ main/settings.py | 7 +- 5 files changed, 255 insertions(+), 7 deletions(-) create mode 100644 .env.example create mode 100644 docs/wallet_refactor.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8ee0a7a --- /dev/null +++ b/.env.example @@ -0,0 +1,43 @@ +# No .env.example was previously tracked in this repo (.env is gitignored). +# This file documents every var read via decouple's config() in main/settings.py. +# Copy to .env and fill in real values before running the service. + +SECRET_KEY= +DEBUG=True + +DB_NAME= +DB_USER= +DB_PASSWORD= +DB_HOST=127.0.0.1 +DB_PORT=5432 + +OAUTH2_PROVIDER_PUBLIC_URL= +OAUTH2_PROVIDER_PRIVATE_URL= +OAUTH2_CLIENT_ID= +OAUTH2_CLIENT_SECRET= +OAUTH2_SCOPES= + +REDIS_BASE_URL= +LOKI_BASE_PUBLIC_URL= + +MINIO_ENDPOINT=drive.gooyal.com +MINIO_USE_HTTPS=True +MINIO_EXTERNAL_ENDPOINT=drive.gooyal.com +MINIO_EXTERNAL_ENDPOINT_USE_HTTPS=True +MINIO_ACCESS_KEY= +MINIO_SECRET_KEY= +MINIO_MEDIA_FILES_BUCKET= + +ACCOUNTS_BASE_PUBLIC_URL= + +WALLET_BASE_PUBLIC_URL= +WALLET_RIAL_DEPOSIT= +WALLET_USER_BILLBOARD_VISIT_INCOME= + +# NEW — wallet-naming refactor (2026-08-25), matches advertising/settlement/ipg. +# Do not reuse WALLET_RIAL_DEPOSIT or WALLET_USER_BILLBOARD_VISIT_INCOME here — this must +# be a distinct, dedicated platform wallet the wallet-service team provisions for promotions. +# TODO(you): replace with the real wallet-type UUID from the wallet service. +WALLET_PROMOTIONS_TRANSIT=00000000-0000-0000-0000-000000000000 + +NOTIFICATIONS_BASE_PUBLIC_URL= diff --git a/README.md b/README.md index 80f157a..747b448 100644 --- a/README.md +++ b/README.md @@ -313,7 +313,9 @@ Non-secret values only — pulled from `main/settings.py` and this checkout's ow | `OAUTH2_PROVIDER_PUBLIC_URL` / `_PRIVATE_URL` | Central accounts service's OAuth2 endpoints (token issuance, introspection). Points at the accounts service's staging environment — see this checkout's own `.env` for the actual hostname. | | `ACCOUNTS_BASE_PUBLIC_URL` | Accounts service REST API (user/application profile reads). | | `WALLET_BASE_PUBLIC_URL` | Wallet service REST API (deposit submit/verify). | -| `WALLET_RIAL` / `WALLET_REWARD` | UUIDs of specific `Wallet` (currency pool) rows in the wallet service — not amounts. `WALLET_REWARD` is the pool promotions pays out of; passed as both the deposit's `payer_wallet` and its `payee_wallet`, since payer (this app's pool) and payee (the user) share one currency. | +| `WALLET_RIAL_DEPOSIT` | User-side "real money" wallet type UUID. Not currently read by any code path in this service — kept for parity with the shared naming convention used across the other Gooyal repos that touch the same wallet-service UUIDs (`advertising`, `settlement`, `ipg`). | +| `WALLET_USER_BILLBOARD_VISIT_INCOME` | User-side reward-token wallet type UUID (formerly `WALLET_REWARD`). Default `payee_wallet` for a payout — see `Recipient.get_wallet_category_uuid()` — unless the `Recipient` has its own `wallet_uuid`. | +| `WALLET_PROMOTIONS_TRANSIT` | Company-side pool payouts are drawn from — the deposit's `payer_wallet` (formerly hardcoded to the same UUID as the payee side; see [`docs/wallet_refactor.md`](docs/wallet_refactor.md)). Needs a real UUID from the wallet-service team before this service can submit a deposit. | | `NOTIFICATIONS_BASE_PUBLIC_URL` | Notifications service REST API. | | `OAUTH2_CLIENT_ID` / `_SECRET` / `_SCOPES` | This service's own client-credentials identity, used for every outbound call above via `login_as_client_credentials()` (token cached under the key `promotions_access_token`). | diff --git a/apps/promotions/models.py b/apps/promotions/models.py index 6f9a661..c6e379a 100644 --- a/apps/promotions/models.py +++ b/apps/promotions/models.py @@ -220,7 +220,7 @@ class Recipient(BaseModel): return f"{self.label} --> {self.plan}" 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 def is_user_allowed(self, user_uuid): if self.access_type != RecipientTypeChoices.RESTRICTED: @@ -485,11 +485,13 @@ class Promotion(BaseModel): 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)), @@ -499,7 +501,7 @@ class Promotion(BaseModel): } try: - submit_response = deposit_to_user_wallet_submit(settings.WALLET_REWARD, data) + submit_response = deposit_to_user_wallet_submit(settings.WALLET_PROMOTIONS_TRANSIT, data) if not submit_response.uuid: raise Exception('Failed to submit promotion. 1') @@ -515,7 +517,7 @@ class Promotion(BaseModel): raise Exception('Failed to submit promotion. 2') 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) if not verify_response.uuid: raise Exception('Failed to verify promotion. 1') diff --git a/docs/wallet_refactor.md b/docs/wallet_refactor.md new file mode 100644 index 0000000..12d8e68 --- /dev/null +++ b/docs/wallet_refactor.md @@ -0,0 +1,198 @@ +# 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 `) +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. diff --git a/main/settings.py b/main/settings.py index ddc2513..c3ce17a 100644 --- a/main/settings.py +++ b/main/settings.py @@ -385,7 +385,10 @@ CELERY_RESULT_BACKEND = REDIS_BASE_URL ACCOUNTS_BASE_PUBLIC_URL = config('ACCOUNTS_BASE_PUBLIC_URL', default=None, cast=str) 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) NOTIFICATIONS_BASE_PUBLIC_URL = config('NOTIFICATIONS_BASE_PUBLIC_URL', default=None, cast=str) -- 2.45.3 From 45a64f00132a99b75245082382ab48a7e1b755c4 Mon Sep 17 00:00:00 2001 From: Ali Asadi Date: Tue, 25 Aug 2026 16:57:44 +0330 Subject: [PATCH 2/3] 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 --- .env.example | 5 +++ README.md | 6 ++-- apps/promotions/handlers.py | 4 ++- apps/promotions/models.py | 10 +++++- apps/promotions/tests.py | 48 +++++++++++++++++++++++---- docs/wallet_refactor.md | 65 +++++++++++++++++++++++++++++++++++++ main/settings.py | 4 +++ 7 files changed, 131 insertions(+), 11 deletions(-) diff --git a/.env.example b/.env.example index 8ee0a7a..1278b35 100644 --- a/.env.example +++ b/.env.example @@ -40,4 +40,9 @@ WALLET_USER_BILLBOARD_VISIT_INCOME= # TODO(you): replace with the real wallet-type UUID from the wallet service. WALLET_PROMOTIONS_TRANSIT=00000000-0000-0000-0000-000000000000 +# NEW — per-promotion-type wallet routing (2026-08-25). Same wallet-service UUID as +# advertising's own WALLET_ADVERTISING_TRANSIT setting — copy that repo's real value here, +# don't provision a second one. +WALLET_ADVERTISING_TRANSIT=00000000-0000-0000-0000-000000000000 + NOTIFICATIONS_BASE_PUBLIC_URL= diff --git a/README.md b/README.md index 747b448..741fbd4 100644 --- a/README.md +++ b/README.md @@ -203,7 +203,7 @@ Concrete steps, mirroring the real `first-ad-create` fixture in `apps/promotions ) ``` -3. **Attach a recipient.** The common case: pay the user who fired the event, a flat amount, no percentage math. +3. **Attach a recipient.** The common case: pay the user who fired the event, a flat amount, no percentage math. Set `promotion_type` to route the payout to the right wallet — `first_ad_view` (or unset) pays into the user's cash-like reward wallet; `capture`/`first_ad_create` fund billboard/ad credit instead (`WALLET_ADVERTISING_TRANSIT`) — see `Recipient.get_wallet_category_uuid()`. A `wallet_uuid` set directly on the `Recipient` always wins over `promotion_type`. ```python Recipient.objects.create( @@ -211,6 +211,7 @@ Concrete steps, mirroring the real `first-ad-create` fixture in `apps/promotions label="first-ad-create", recipient_uuid_field="->event:user", base_amount_field="30000", # literal, or "event:data_key" + promotion_type=PromotionTypeChoices.FIRST_AD_CREATE, ) ``` @@ -314,8 +315,9 @@ Non-secret values only — pulled from `main/settings.py` and this checkout's ow | `ACCOUNTS_BASE_PUBLIC_URL` | Accounts service REST API (user/application profile reads). | | `WALLET_BASE_PUBLIC_URL` | Wallet service REST API (deposit submit/verify). | | `WALLET_RIAL_DEPOSIT` | User-side "real money" wallet type UUID. Not currently read by any code path in this service — kept for parity with the shared naming convention used across the other Gooyal repos that touch the same wallet-service UUIDs (`advertising`, `settlement`, `ipg`). | -| `WALLET_USER_BILLBOARD_VISIT_INCOME` | User-side reward-token wallet type UUID (formerly `WALLET_REWARD`). Default `payee_wallet` for a payout — see `Recipient.get_wallet_category_uuid()` — unless the `Recipient` has its own `wallet_uuid`. | +| `WALLET_USER_BILLBOARD_VISIT_INCOME` | User-side reward-token wallet type UUID (formerly `WALLET_REWARD`). `payee_wallet` for a cash-like user reward payout (e.g. `first_ad_view`) — see `Recipient.get_wallet_category_uuid()`. | | `WALLET_PROMOTIONS_TRANSIT` | Company-side pool payouts are drawn from — the deposit's `payer_wallet` (formerly hardcoded to the same UUID as the payee side; see [`docs/wallet_refactor.md`](docs/wallet_refactor.md)). Needs a real UUID from the wallet-service team before this service can submit a deposit. | +| `WALLET_ADVERTISING_TRANSIT` | Same wallet-service UUID as advertising's own setting of the same name. `payee_wallet` for promotion types that fund billboard/ad credit rather than a user reward (`capture`, `first_ad_create`) — see `Recipient.get_wallet_category_uuid()`. | | `NOTIFICATIONS_BASE_PUBLIC_URL` | Notifications service REST API. | | `OAUTH2_CLIENT_ID` / `_SECRET` / `_SCOPES` | This service's own client-credentials identity, used for every outbound call above via `login_as_client_credentials()` (token cached under the key `promotions_access_token`). | diff --git a/apps/promotions/handlers.py b/apps/promotions/handlers.py index 9852867..bc2481f 100644 --- a/apps/promotions/handlers.py +++ b/apps/promotions/handlers.py @@ -15,7 +15,9 @@ class ProcessorTypeChoices(models.TextChoices): OTHERS = 'others', _('others') class PromotionTypeChoices(models.TextChoices): - pass + FIRST_AD_VIEW = 'first_ad_view', _('first ad view') + CAPTURE = 'capture', _('capture') + FIRST_AD_CREATE = 'first_ad_create', _('first ad create') class BasePromotionHandler: diff --git a/apps/promotions/models.py b/apps/promotions/models.py index c6e379a..bfd1f03 100644 --- a/apps/promotions/models.py +++ b/apps/promotions/models.py @@ -220,7 +220,15 @@ class Recipient(BaseModel): return f"{self.label} --> {self.plan}" def get_wallet_category_uuid(self): - return self.wallet_uuid or settings.WALLET_USER_BILLBOARD_VISIT_INCOME + if self.wallet_uuid: + return self.wallet_uuid + + if self.promotion_type in (PromotionTypeChoices.CAPTURE, PromotionTypeChoices.FIRST_AD_CREATE): + # Billboard/ad credit, not a cash-like user reward — funds the advertising + # service's own transit wallet instead of the user's reward wallet. + return settings.WALLET_ADVERTISING_TRANSIT + + return settings.WALLET_USER_BILLBOARD_VISIT_INCOME def is_user_allowed(self, user_uuid): if self.access_type != RecipientTypeChoices.RESTRICTED: diff --git a/apps/promotions/tests.py b/apps/promotions/tests.py index 2b37159..b681927 100644 --- a/apps/promotions/tests.py +++ b/apps/promotions/tests.py @@ -11,6 +11,7 @@ from apps.promotions.tasks import analyze_event_task from apps.users.models import User from apps.promotions.models import Plan, Promotion, EventSaver, ProcessorTypeChoices, Event, Recipient, \ PaymentStateChoices +from apps.promotions.handlers import PromotionTypeChoices AccessToken = get_access_token_model() Application = get_application_model() @@ -790,6 +791,7 @@ class ApplicationApiFlowsTests(APITestCase): defaults={ 'recipient_uuid_field': '->event:user', 'base_amount_field': str(promotion_amount), + 'promotion_type': PromotionTypeChoices.FIRST_AD_CREATE, }, ) @@ -822,13 +824,24 @@ class ApplicationApiFlowsTests(APITestCase): }, } - response = self.client.post( - reverse('promotions:promotion-create', kwargs={'plan': plan.uuid}), - event_create_data, - HTTP_AUTHORIZATION=auth, - format='json', - ) - self.assertEqual(response.status_code, 201) + with patch('apps.promotions.models.deposit_to_user_wallet_submit', + side_effect=mock_submit_deposit_success) as submit_mock: + response = self.client.post( + reverse('promotions:promotion-create', kwargs={'plan': plan.uuid}), + event_create_data, + HTTP_AUTHORIZATION=auth, + format='json', + ) + self.assertEqual(response.status_code, 201) + + # first_ad_create is billboard/ad credit, not a cash-like user reward: the deposit + # is drawn from the promotions transit pool and credited to the advertising + # transit wallet, not the user's own reward wallet. + from django.conf import settings + submit_mock.assert_called_once() + call_payer_wallet, call_data = submit_mock.call_args.args + self.assertEqual(call_payer_wallet, settings.WALLET_PROMOTIONS_TRANSIT) + self.assertEqual(call_data['payee_wallet'], settings.WALLET_ADVERTISING_TRANSIT) response = self.client.get( reverse('promotions:event-status', kwargs={'event_label': event_label}), @@ -845,6 +858,27 @@ class ApplicationApiFlowsTests(APITestCase): plan.refresh_from_db() self.assertEqual(plan.balance, promotion_amount * 1000 - promotion_amount) + def test_get_wallet_category_uuid_routing(self): + from django.conf import settings + + first_ad_view_recipient = Recipient(promotion_type=PromotionTypeChoices.FIRST_AD_VIEW) + self.assertEqual(first_ad_view_recipient.get_wallet_category_uuid(), + settings.WALLET_USER_BILLBOARD_VISIT_INCOME) + + unset_type_recipient = Recipient() + self.assertEqual(unset_type_recipient.get_wallet_category_uuid(), + settings.WALLET_USER_BILLBOARD_VISIT_INCOME) + + capture_recipient = Recipient(promotion_type=PromotionTypeChoices.CAPTURE) + self.assertEqual(capture_recipient.get_wallet_category_uuid(), settings.WALLET_ADVERTISING_TRANSIT) + + first_ad_create_recipient = Recipient(promotion_type=PromotionTypeChoices.FIRST_AD_CREATE) + self.assertEqual(first_ad_create_recipient.get_wallet_category_uuid(), settings.WALLET_ADVERTISING_TRANSIT) + + explicit_wallet_uuid = uuid.uuid4() + override_recipient = Recipient(promotion_type=PromotionTypeChoices.CAPTURE, wallet_uuid=explicit_wallet_uuid) + self.assertEqual(override_recipient.get_wallet_category_uuid(), explicit_wallet_uuid) + def test_first_ad_create_event_status_not_processed_when_only_event_exists(self): plan, event_label, promotion_amount = self._create_first_ad_create_plan() auth = self._create_authorization_header(self.user_access_token.token) diff --git a/docs/wallet_refactor.md b/docs/wallet_refactor.md index 12d8e68..22aca1d 100644 --- a/docs/wallet_refactor.md +++ b/docs/wallet_refactor.md @@ -196,3 +196,68 @@ scaffolding noted in the README's watch list) — unrelated, left as-is. 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: + + ```python + 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. diff --git a/main/settings.py b/main/settings.py index c3ce17a..64566a7 100644 --- a/main/settings.py +++ b/main/settings.py @@ -390,5 +390,9 @@ WALLET_USER_BILLBOARD_VISIT_INCOME = config('WALLET_USER_BILLBOARD_VISIT_INCOME' # 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) +# Same wallet-service UUID as the advertising repo's own WALLET_ADVERTISING_TRANSIT setting. +# Destination for promotion types that fund billboard/ad credit rather than a cash-like user +# reward (CAPTURE, FIRST_AD_CREATE) — see Recipient.get_wallet_category_uuid(). +WALLET_ADVERTISING_TRANSIT = config('WALLET_ADVERTISING_TRANSIT', cast=str) NOTIFICATIONS_BASE_PUBLIC_URL = config('NOTIFICATIONS_BASE_PUBLIC_URL', default=None, cast=str) -- 2.45.3 From 0fe4a3ed79f0ac37ec21ba838e30a85fe46e525e Mon Sep 17 00:00:00 2001 From: Ali Asadi Date: Tue, 25 Aug 2026 17:12:01 +0330 Subject: [PATCH 3/3] FIX(promotions): route payouts by explicit Recipient.wallet_destination, not promotion type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 8 +- apps/promotions/handlers.py | 4 +- .../0010_recipient_wallet_destination.py | 19 ++++ apps/promotions/models.py | 12 ++- apps/promotions/tests.py | 23 ++--- docs/wallet_refactor.md | 99 +++++++++++-------- 6 files changed, 102 insertions(+), 63 deletions(-) create mode 100644 apps/promotions/migrations/0010_recipient_wallet_destination.py diff --git a/README.md b/README.md index 741fbd4..ad7e483 100644 --- a/README.md +++ b/README.md @@ -203,7 +203,7 @@ Concrete steps, mirroring the real `first-ad-create` fixture in `apps/promotions ) ``` -3. **Attach a recipient.** The common case: pay the user who fired the event, a flat amount, no percentage math. Set `promotion_type` to route the payout to the right wallet — `first_ad_view` (or unset) pays into the user's cash-like reward wallet; `capture`/`first_ad_create` fund billboard/ad credit instead (`WALLET_ADVERTISING_TRANSIT`) — see `Recipient.get_wallet_category_uuid()`. A `wallet_uuid` set directly on the `Recipient` always wins over `promotion_type`. +3. **Attach a recipient.** The common case: pay the user who fired the event, a flat amount, no percentage math. Set `wallet_destination` to pick which wallet the payout lands in — `user_reward` (the default) pays into the user's cash-like reward wallet; `advertising_transit` funds billboard/ad credit instead (`WALLET_ADVERTISING_TRANSIT`) — see `Recipient.get_wallet_category_uuid()`. A `wallet_uuid` set directly on the `Recipient` always wins over `wallet_destination`. ```python Recipient.objects.create( @@ -211,7 +211,7 @@ Concrete steps, mirroring the real `first-ad-create` fixture in `apps/promotions label="first-ad-create", recipient_uuid_field="->event:user", base_amount_field="30000", # literal, or "event:data_key" - promotion_type=PromotionTypeChoices.FIRST_AD_CREATE, + wallet_destination=WalletDestinationChoices.ADVERTISING_TRANSIT, ) ``` @@ -315,9 +315,9 @@ Non-secret values only — pulled from `main/settings.py` and this checkout's ow | `ACCOUNTS_BASE_PUBLIC_URL` | Accounts service REST API (user/application profile reads). | | `WALLET_BASE_PUBLIC_URL` | Wallet service REST API (deposit submit/verify). | | `WALLET_RIAL_DEPOSIT` | User-side "real money" wallet type UUID. Not currently read by any code path in this service — kept for parity with the shared naming convention used across the other Gooyal repos that touch the same wallet-service UUIDs (`advertising`, `settlement`, `ipg`). | -| `WALLET_USER_BILLBOARD_VISIT_INCOME` | User-side reward-token wallet type UUID (formerly `WALLET_REWARD`). `payee_wallet` for a cash-like user reward payout (e.g. `first_ad_view`) — see `Recipient.get_wallet_category_uuid()`. | +| `WALLET_USER_BILLBOARD_VISIT_INCOME` | User-side reward-token wallet type UUID (formerly `WALLET_REWARD`). `payee_wallet` when `Recipient.wallet_destination` is `user_reward` (the default) — see `Recipient.get_wallet_category_uuid()`. | | `WALLET_PROMOTIONS_TRANSIT` | Company-side pool payouts are drawn from — the deposit's `payer_wallet` (formerly hardcoded to the same UUID as the payee side; see [`docs/wallet_refactor.md`](docs/wallet_refactor.md)). Needs a real UUID from the wallet-service team before this service can submit a deposit. | -| `WALLET_ADVERTISING_TRANSIT` | Same wallet-service UUID as advertising's own setting of the same name. `payee_wallet` for promotion types that fund billboard/ad credit rather than a user reward (`capture`, `first_ad_create`) — see `Recipient.get_wallet_category_uuid()`. | +| `WALLET_ADVERTISING_TRANSIT` | Same wallet-service UUID as advertising's own setting of the same name. `payee_wallet` when `Recipient.wallet_destination` is `advertising_transit` — billboard/ad credit rather than a user reward — see `Recipient.get_wallet_category_uuid()`. | | `NOTIFICATIONS_BASE_PUBLIC_URL` | Notifications service REST API. | | `OAUTH2_CLIENT_ID` / `_SECRET` / `_SCOPES` | This service's own client-credentials identity, used for every outbound call above via `login_as_client_credentials()` (token cached under the key `promotions_access_token`). | diff --git a/apps/promotions/handlers.py b/apps/promotions/handlers.py index bc2481f..9852867 100644 --- a/apps/promotions/handlers.py +++ b/apps/promotions/handlers.py @@ -15,9 +15,7 @@ class ProcessorTypeChoices(models.TextChoices): OTHERS = 'others', _('others') class PromotionTypeChoices(models.TextChoices): - FIRST_AD_VIEW = 'first_ad_view', _('first ad view') - CAPTURE = 'capture', _('capture') - FIRST_AD_CREATE = 'first_ad_create', _('first ad create') + pass class BasePromotionHandler: diff --git a/apps/promotions/migrations/0010_recipient_wallet_destination.py b/apps/promotions/migrations/0010_recipient_wallet_destination.py new file mode 100644 index 0000000..42c87e6 --- /dev/null +++ b/apps/promotions/migrations/0010_recipient_wallet_destination.py @@ -0,0 +1,19 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('promotions', '0009_recipient_access_type_alloweduser'), + ] + + operations = [ + migrations.AddField( + model_name='recipient', + name='wallet_destination', + field=models.CharField(choices=[('user_reward', 'user reward wallet'), + ('advertising_transit', 'advertising transit wallet')], + db_index=True, default='user_reward', max_length=32, + verbose_name='wallet destination'), + ), + ] diff --git a/apps/promotions/models.py b/apps/promotions/models.py index bfd1f03..98b984b 100644 --- a/apps/promotions/models.py +++ b/apps/promotions/models.py @@ -193,12 +193,20 @@ class RecipientTypeChoices(models.TextChoices): PUBLIC = 'public', _('public') +class WalletDestinationChoices(models.TextChoices): + USER_REWARD = 'user_reward', _('user reward wallet') + ADVERTISING_TRANSIT = 'advertising_transit', _('advertising transit wallet') + + class Recipient(BaseModel): label = models.CharField(max_length=255, db_index=True) plan = models.ForeignKey(Plan, on_delete=models.PROTECT, related_name='recipients', null=True, blank=True) wallet_uuid = models.UUIDField(null=True, blank=True) promotion_type = models.CharField(max_length=64, verbose_name=_('promotion type'), db_index=True, choices=PromotionTypeChoices.choices, null=True, blank=True) + wallet_destination = models.CharField(max_length=32, verbose_name=_('wallet destination'), db_index=True, + choices=WalletDestinationChoices.choices, + default=WalletDestinationChoices.USER_REWARD) access_type = models.CharField(max_length=64, verbose_name=_('access type'), db_index=True, choices=RecipientTypeChoices.choices, default=RecipientTypeChoices.PUBLIC) @@ -223,9 +231,7 @@ class Recipient(BaseModel): if self.wallet_uuid: return self.wallet_uuid - if self.promotion_type in (PromotionTypeChoices.CAPTURE, PromotionTypeChoices.FIRST_AD_CREATE): - # Billboard/ad credit, not a cash-like user reward — funds the advertising - # service's own transit wallet instead of the user's reward wallet. + if self.wallet_destination == WalletDestinationChoices.ADVERTISING_TRANSIT: return settings.WALLET_ADVERTISING_TRANSIT return settings.WALLET_USER_BILLBOARD_VISIT_INCOME diff --git a/apps/promotions/tests.py b/apps/promotions/tests.py index b681927..cbdd984 100644 --- a/apps/promotions/tests.py +++ b/apps/promotions/tests.py @@ -10,8 +10,7 @@ from rest_framework.test import APITestCase, override_settings, APIClient from apps.promotions.tasks import analyze_event_task from apps.users.models import User from apps.promotions.models import Plan, Promotion, EventSaver, ProcessorTypeChoices, Event, Recipient, \ - PaymentStateChoices -from apps.promotions.handlers import PromotionTypeChoices + PaymentStateChoices, WalletDestinationChoices AccessToken = get_access_token_model() Application = get_application_model() @@ -791,7 +790,7 @@ class ApplicationApiFlowsTests(APITestCase): defaults={ 'recipient_uuid_field': '->event:user', 'base_amount_field': str(promotion_amount), - 'promotion_type': PromotionTypeChoices.FIRST_AD_CREATE, + 'wallet_destination': WalletDestinationChoices.ADVERTISING_TRANSIT, }, ) @@ -861,22 +860,20 @@ class ApplicationApiFlowsTests(APITestCase): def test_get_wallet_category_uuid_routing(self): from django.conf import settings - first_ad_view_recipient = Recipient(promotion_type=PromotionTypeChoices.FIRST_AD_VIEW) - self.assertEqual(first_ad_view_recipient.get_wallet_category_uuid(), + default_recipient = Recipient() + self.assertEqual(default_recipient.get_wallet_category_uuid(), settings.WALLET_USER_BILLBOARD_VISIT_INCOME) - unset_type_recipient = Recipient() - self.assertEqual(unset_type_recipient.get_wallet_category_uuid(), + user_reward_recipient = Recipient(wallet_destination=WalletDestinationChoices.USER_REWARD) + self.assertEqual(user_reward_recipient.get_wallet_category_uuid(), settings.WALLET_USER_BILLBOARD_VISIT_INCOME) - capture_recipient = Recipient(promotion_type=PromotionTypeChoices.CAPTURE) - self.assertEqual(capture_recipient.get_wallet_category_uuid(), settings.WALLET_ADVERTISING_TRANSIT) - - first_ad_create_recipient = Recipient(promotion_type=PromotionTypeChoices.FIRST_AD_CREATE) - self.assertEqual(first_ad_create_recipient.get_wallet_category_uuid(), settings.WALLET_ADVERTISING_TRANSIT) + transit_recipient = Recipient(wallet_destination=WalletDestinationChoices.ADVERTISING_TRANSIT) + self.assertEqual(transit_recipient.get_wallet_category_uuid(), settings.WALLET_ADVERTISING_TRANSIT) explicit_wallet_uuid = uuid.uuid4() - override_recipient = Recipient(promotion_type=PromotionTypeChoices.CAPTURE, wallet_uuid=explicit_wallet_uuid) + override_recipient = Recipient(wallet_destination=WalletDestinationChoices.ADVERTISING_TRANSIT, + wallet_uuid=explicit_wallet_uuid) self.assertEqual(override_recipient.get_wallet_category_uuid(), explicit_wallet_uuid) def test_first_ad_create_event_status_not_processed_when_only_event_exists(self): diff --git a/docs/wallet_refactor.md b/docs/wallet_refactor.md index 22aca1d..8285ea8 100644 --- a/docs/wallet_refactor.md +++ b/docs/wallet_refactor.md @@ -199,65 +199,84 @@ scaffolding noted in the README's watch list) — unrelated, left as-is. --- -## 6. Follow-up: per-promotion-type wallet routing (2026-08-25) +## 6. Follow-up: explicit per-recipient wallet destination (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: +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. -| Promotion type | Nature | Destination | +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 | |---|---|---| -| `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) | +| `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) | -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. +`PromotionTypeChoices` is left as it was before any of this — an empty enum, unused. It's +not part of this decision. ### 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: +- `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 + ```python + 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 + if self.wallet_destination == WalletDestinationChoices.ADVERTISING_TRANSIT: + return settings.WALLET_ADVERTISING_TRANSIT - return settings.WALLET_USER_BILLBOARD_VISIT_INCOME - ``` + 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. + 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 - `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()`. + `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 `promotion_type` on a new `Recipient`. + setting `wallet_destination` 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. +- 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. -- `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. +- 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. -- 2.45.3