From 45a64f00132a99b75245082382ab48a7e1b755c4 Mon Sep 17 00:00:00 2001 From: Ali Asadi Date: Tue, 25 Aug 2026 16:57:44 +0330 Subject: [PATCH] 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)