feature/wallet-changes #7
7 changed files with 131 additions and 11 deletions
|
|
@ -40,4 +40,9 @@ WALLET_USER_BILLBOARD_VISIT_INCOME=
|
||||||
# TODO(you): replace with the real wallet-type UUID from the wallet service.
|
# TODO(you): replace with the real wallet-type UUID from the wallet service.
|
||||||
WALLET_PROMOTIONS_TRANSIT=00000000-0000-0000-0000-000000000000
|
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=
|
NOTIFICATIONS_BASE_PUBLIC_URL=
|
||||||
|
|
|
||||||
|
|
@ -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
|
```python
|
||||||
Recipient.objects.create(
|
Recipient.objects.create(
|
||||||
|
|
@ -211,6 +211,7 @@ Concrete steps, mirroring the real `first-ad-create` fixture in `apps/promotions
|
||||||
label="first-ad-create",
|
label="first-ad-create",
|
||||||
recipient_uuid_field="->event:user",
|
recipient_uuid_field="->event:user",
|
||||||
base_amount_field="30000", # literal, or "event:data_key"
|
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). |
|
| `ACCOUNTS_BASE_PUBLIC_URL` | Accounts service REST API (user/application profile reads). |
|
||||||
| `WALLET_BASE_PUBLIC_URL` | Wallet service REST API (deposit submit/verify). |
|
| `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_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_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. |
|
| `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`). |
|
| `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`). |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,9 @@ class ProcessorTypeChoices(models.TextChoices):
|
||||||
OTHERS = 'others', _('others')
|
OTHERS = 'others', _('others')
|
||||||
|
|
||||||
class PromotionTypeChoices(models.TextChoices):
|
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:
|
class BasePromotionHandler:
|
||||||
|
|
|
||||||
|
|
@ -220,7 +220,15 @@ class Recipient(BaseModel):
|
||||||
return f"{self.label} --> {self.plan}"
|
return f"{self.label} --> {self.plan}"
|
||||||
|
|
||||||
def get_wallet_category_uuid(self):
|
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):
|
def is_user_allowed(self, user_uuid):
|
||||||
if self.access_type != RecipientTypeChoices.RESTRICTED:
|
if self.access_type != RecipientTypeChoices.RESTRICTED:
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ from apps.promotions.tasks import analyze_event_task
|
||||||
from apps.users.models import User
|
from apps.users.models import User
|
||||||
from apps.promotions.models import Plan, Promotion, EventSaver, ProcessorTypeChoices, Event, Recipient, \
|
from apps.promotions.models import Plan, Promotion, EventSaver, ProcessorTypeChoices, Event, Recipient, \
|
||||||
PaymentStateChoices
|
PaymentStateChoices
|
||||||
|
from apps.promotions.handlers import PromotionTypeChoices
|
||||||
|
|
||||||
AccessToken = get_access_token_model()
|
AccessToken = get_access_token_model()
|
||||||
Application = get_application_model()
|
Application = get_application_model()
|
||||||
|
|
@ -790,6 +791,7 @@ class ApplicationApiFlowsTests(APITestCase):
|
||||||
defaults={
|
defaults={
|
||||||
'recipient_uuid_field': '->event:user',
|
'recipient_uuid_field': '->event:user',
|
||||||
'base_amount_field': str(promotion_amount),
|
'base_amount_field': str(promotion_amount),
|
||||||
|
'promotion_type': PromotionTypeChoices.FIRST_AD_CREATE,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -822,13 +824,24 @@ class ApplicationApiFlowsTests(APITestCase):
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
response = self.client.post(
|
with patch('apps.promotions.models.deposit_to_user_wallet_submit',
|
||||||
reverse('promotions:promotion-create', kwargs={'plan': plan.uuid}),
|
side_effect=mock_submit_deposit_success) as submit_mock:
|
||||||
event_create_data,
|
response = self.client.post(
|
||||||
HTTP_AUTHORIZATION=auth,
|
reverse('promotions:promotion-create', kwargs={'plan': plan.uuid}),
|
||||||
format='json',
|
event_create_data,
|
||||||
)
|
HTTP_AUTHORIZATION=auth,
|
||||||
self.assertEqual(response.status_code, 201)
|
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(
|
response = self.client.get(
|
||||||
reverse('promotions:event-status', kwargs={'event_label': event_label}),
|
reverse('promotions:event-status', kwargs={'event_label': event_label}),
|
||||||
|
|
@ -845,6 +858,27 @@ class ApplicationApiFlowsTests(APITestCase):
|
||||||
plan.refresh_from_db()
|
plan.refresh_from_db()
|
||||||
self.assertEqual(plan.balance, promotion_amount * 1000 - promotion_amount)
|
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):
|
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()
|
plan, event_label, promotion_amount = self._create_first_ad_create_plan()
|
||||||
auth = self._create_authorization_header(self.user_access_token.token)
|
auth = self._create_authorization_header(self.user_access_token.token)
|
||||||
|
|
|
||||||
|
|
@ -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
|
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
|
start at all, not just fail at payout time. Treat this as a deploy-blocking config
|
||||||
change, not a code-only one.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
# 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 above. TODO(you): replace with the real wallet-type UUID from the wallet service.
|
||||||
WALLET_PROMOTIONS_TRANSIT = config('WALLET_PROMOTIONS_TRANSIT', cast=str)
|
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)
|
NOTIFICATIONS_BASE_PUBLIC_URL = config('NOTIFICATIONS_BASE_PUBLIC_URL', default=None, cast=str)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue