Merge pull request 'feature/wallet-changes' (#7) from feature/wallet-changes into master
Reviewed-on: #7
This commit is contained in:
commit
f68af8a8af
7 changed files with 421 additions and 17 deletions
48
.env.example
Normal file
48
.env.example
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# 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
|
||||
|
||||
# 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=
|
||||
10
README.md
10
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 `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,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"
|
||||
wallet_destination=WalletDestinationChoices.ADVERTISING_TRANSIT,
|
||||
)
|
||||
```
|
||||
|
||||
|
|
@ -313,9 +314,10 @@ 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_DEPOSIT` | UUID of the rial currency pool row in the wallet service. Not currently referenced by any code path in this checkout. |
|
||||
| `WALLET_USER_BILLBOARD_VISIT_INCOME` | UUID of the reward-type wallet on the user's side — the `payee_wallet` for every promotion deposit. Same UUID/naming as the identically-named var in `advertising`/`settlement`; despite the name, it's the generic reward-currency wallet type, not billboard-specific. |
|
||||
| `WALLET_PROMOTION` | UUID of this service's own pool — the `payer_wallet` for every promotion deposit. Previously shared the `WALLET_REWARD` value with the payee side; now a distinct wallet dedicated to promotions. |
|
||||
| `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` 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` 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`). |
|
||||
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
@ -220,7 +228,13 @@ 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
|
||||
if self.wallet_uuid:
|
||||
return self.wallet_uuid
|
||||
|
||||
if self.wallet_destination == WalletDestinationChoices.ADVERTISING_TRANSIT:
|
||||
return settings.WALLET_ADVERTISING_TRANSIT
|
||||
|
||||
return settings.WALLET_USER_BILLBOARD_VISIT_INCOME
|
||||
|
||||
def is_user_allowed(self, user_uuid):
|
||||
if self.access_type != RecipientTypeChoices.RESTRICTED:
|
||||
|
|
@ -485,11 +499,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_USER_BILLBOARD_VISIT_INCOME,
|
||||
"payee_wallet": payee_wallet,
|
||||
"amount": self.promotion_amount,
|
||||
"details": {
|
||||
'description': str(_(self.recipient.label)),
|
||||
|
|
@ -499,7 +515,7 @@ class Promotion(BaseModel):
|
|||
}
|
||||
|
||||
try:
|
||||
submit_response = deposit_to_user_wallet_submit(settings.WALLET_PROMOTION, 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 +531,7 @@ class Promotion(BaseModel):
|
|||
raise Exception('Failed to submit promotion. 2')
|
||||
|
||||
try:
|
||||
verify_response = deposit_to_user_wallet_verify(settings.WALLET_PROMOTION, 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')
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +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
|
||||
PaymentStateChoices, WalletDestinationChoices
|
||||
|
||||
AccessToken = get_access_token_model()
|
||||
Application = get_application_model()
|
||||
|
|
@ -790,6 +790,7 @@ class ApplicationApiFlowsTests(APITestCase):
|
|||
defaults={
|
||||
'recipient_uuid_field': '->event:user',
|
||||
'base_amount_field': str(promotion_amount),
|
||||
'wallet_destination': WalletDestinationChoices.ADVERTISING_TRANSIT,
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -822,6 +823,8 @@ class ApplicationApiFlowsTests(APITestCase):
|
|||
},
|
||||
}
|
||||
|
||||
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,
|
||||
|
|
@ -830,6 +833,15 @@ class ApplicationApiFlowsTests(APITestCase):
|
|||
)
|
||||
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}),
|
||||
HTTP_AUTHORIZATION=auth,
|
||||
|
|
@ -845,6 +857,25 @@ 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
|
||||
|
||||
default_recipient = Recipient()
|
||||
self.assertEqual(default_recipient.get_wallet_category_uuid(),
|
||||
settings.WALLET_USER_BILLBOARD_VISIT_INCOME)
|
||||
|
||||
user_reward_recipient = Recipient(wallet_destination=WalletDestinationChoices.USER_REWARD)
|
||||
self.assertEqual(user_reward_recipient.get_wallet_category_uuid(),
|
||||
settings.WALLET_USER_BILLBOARD_VISIT_INCOME)
|
||||
|
||||
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(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):
|
||||
plan, event_label, promotion_amount = self._create_first_ad_create_plan()
|
||||
auth = self._create_authorization_header(self.user_access_token.token)
|
||||
|
|
|
|||
282
docs/wallet_refactor.md
Normal file
282
docs/wallet_refactor.md
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
# 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 <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()`:
|
||||
|
||||
```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.
|
||||
|
||||
---
|
||||
|
||||
## 6. Follow-up: explicit per-recipient wallet destination (2026-08-25)
|
||||
|
||||
Not every payout is a cash-like user reward. Some fund billboard/ad credit instead — money
|
||||
that should land in the **advertising** service's own transit wallet, not the user's
|
||||
personal reward wallet.
|
||||
|
||||
An earlier version of this follow-up tried to infer the destination from
|
||||
`PromotionTypeChoices` (`first_ad_view` / `capture` / `first_ad_create`), a promotion
|
||||
*category* field that existed but had never been given real values. That was reverted: it
|
||||
buried a wallet-routing decision inside a general-purpose categorization field, coupling
|
||||
two things that should vary independently — a promotion's category and where its money
|
||||
goes are not the same fact, and the next new promotion type would need someone to remember
|
||||
to also classify it for wallet purposes.
|
||||
|
||||
Instead, `Recipient` gets a field that says the routing decision directly:
|
||||
|
||||
```python
|
||||
class WalletDestinationChoices(models.TextChoices):
|
||||
USER_REWARD = 'user_reward', _('user reward wallet')
|
||||
ADVERTISING_TRANSIT = 'advertising_transit', _('advertising transit wallet')
|
||||
```
|
||||
|
||||
| `wallet_destination` | Nature | Resolves to |
|
||||
|---|---|---|
|
||||
| `user_reward` (default) | Cash-like user reward | `WALLET_USER_BILLBOARD_VISIT_INCOME` |
|
||||
| `advertising_transit` | Billboard/ad credit | `WALLET_ADVERTISING_TRANSIT` (advertising's own company pool) |
|
||||
|
||||
`PromotionTypeChoices` is left as it was before any of this — an empty enum, unused. It's
|
||||
not part of this decision.
|
||||
|
||||
### Changes
|
||||
|
||||
- `main/settings.py` — new `WALLET_ADVERTISING_TRANSIT` setting. Same wallet-service UUID
|
||||
as advertising's own setting of the same name — copy that repo's real value in, don't
|
||||
provision a second UUID for the same wallet.
|
||||
- `apps/promotions/models.py`:
|
||||
- New `WalletDestinationChoices` enum, next to the existing `RecipientTypeChoices`.
|
||||
- New `Recipient.wallet_destination` field (`CharField`, `db_index=True`, default
|
||||
`USER_REWARD`) — a real DB column, unlike the earlier `promotion_type` attempt which
|
||||
only changed field-level `choices=` metadata.
|
||||
- `Recipient.get_wallet_category_uuid()`:
|
||||
|
||||
```python
|
||||
def get_wallet_category_uuid(self):
|
||||
if self.wallet_uuid:
|
||||
return self.wallet_uuid
|
||||
|
||||
if self.wallet_destination == WalletDestinationChoices.ADVERTISING_TRANSIT:
|
||||
return settings.WALLET_ADVERTISING_TRANSIT
|
||||
|
||||
return settings.WALLET_USER_BILLBOARD_VISIT_INCOME
|
||||
```
|
||||
|
||||
Priority order: an explicit `Recipient.wallet_uuid` always wins (the per-recipient raw
|
||||
override from the original refactor above); otherwise `wallet_destination` picks the
|
||||
wallet type; `user_reward` is the default so existing rows behave exactly as before
|
||||
this change until someone opts them into `advertising_transit`.
|
||||
- `apps/promotions/migrations/0010_recipient_wallet_destination.py` — new migration adding
|
||||
the column, `AddField` with `default='user_reward'` so existing rows backfill safely.
|
||||
- `apps/promotions/tests.py` — the `first-ad-create` fixture now sets
|
||||
`wallet_destination=WalletDestinationChoices.ADVERTISING_TRANSIT`;
|
||||
`test_first_ad_create_event_status_processed` asserts the deposit call actually receives
|
||||
`WALLET_PROMOTIONS_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 `wallet_destination` on a new `Recipient`.
|
||||
|
||||
### What's still manual
|
||||
|
||||
- Existing `Plan`/`Recipient` rows in a live database default to `wallet_destination='user_reward'`
|
||||
on migrate — behavior for them doesn't change. Whoever owns the real `capture` /
|
||||
`first-ad-create` plans needs to explicitly set `wallet_destination='advertising_transit'`
|
||||
on their `Recipient` rows (via admin or a follow-up data migration) for those specific
|
||||
payouts to actually route to the advertising transit wallet.
|
||||
- `WALLET_ADVERTISING_TRANSIT` is a second **required** env var on top of
|
||||
`WALLET_PROMOTIONS_TRANSIT` — same deploy-blocking caveat as §5: missing it fails
|
||||
`main/settings.py` import, not just a payout at runtime.
|
||||
- This migration hasn't been run against a real database in this environment (no local
|
||||
`.env`/DB configured here) — run `manage.py migrate` and confirm `0010` applies cleanly
|
||||
before deploying.
|
||||
|
|
@ -387,6 +387,12 @@ ACCOUNTS_BASE_PUBLIC_URL = config('ACCOUNTS_BASE_PUBLIC_URL', default=None, cast
|
|||
WALLET_BASE_PUBLIC_URL = config('WALLET_BASE_PUBLIC_URL', default=None, cast=str)
|
||||
WALLET_RIAL_DEPOSIT = config('WALLET_RIAL_DEPOSIT', cast=str)
|
||||
WALLET_USER_BILLBOARD_VISIT_INCOME = config('WALLET_USER_BILLBOARD_VISIT_INCOME', cast=str)
|
||||
WALLET_PROMOTION = config('WALLET_PROMOTION', 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)
|
||||
# Same wallet-service UUID as the advertising repo's own WALLET_ADVERTISING_TRANSIT setting.
|
||||
# Destination for a Recipient whose wallet_destination is advertising_transit — billboard/ad
|
||||
# credit rather than a cash-like user reward — 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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue