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)