Compare commits
31 commits
FEATURE/ge
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 8300f09003 | |||
| 50e69d985c | |||
| db2f0afcd5 | |||
| ea070ad293 | |||
| 18875af772 | |||
| 161664cee9 | |||
| 8725cdf699 | |||
| f68af8a8af | |||
| d3800584b5 | |||
| 5c10096a5f | |||
| 059de2914d | |||
| 0fe4a3ed79 | |||
| 45a64f0013 | |||
| f59b8810d8 | |||
| c7af6bb14b | |||
| 92bea29816 | |||
| 079c795065 | |||
| 1536016f14 | |||
| 1a63acee32 | |||
| 54de7fdb48 | |||
| 33aa0ed1b4 | |||
| ac37f15bea | |||
| c2a0c2f1d8 | |||
| 0e95fab85f | |||
| c4ff4cf4cb | |||
| 3cef84a346 | |||
| ab348d5134 | |||
|
|
a8b10b79a5 | ||
|
|
1eca11e4a4 | ||
|
|
e7db91d1da | ||
|
|
3320ff93e3 |
20 changed files with 1699 additions and 89 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_CREDIT=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=
|
||||
326
README.md
Normal file
326
README.md
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
# Promotions
|
||||
|
||||
**Gooyal platform — service brief**
|
||||
|
||||
The reward-payout engine: it turns "a user did X" events from other Gooyal apps into real wallet deposits. This is a map of how it works internally and how it fits into the rest of the platform, written for the next person extending it.
|
||||
|
||||
`apps/promotions` · Django 5.1 · DRF · Postgres · Celery/Redis
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
- **It's a rules engine, not a payment processor.** Other Gooyal apps report *events* ("user created their first ad"); promotions matches them against configured *plans* and decides who gets paid what.
|
||||
- **It never moves money itself.** Every payout is a deposit submitted to the separate **wallet** service, then verified. Promotions only tracks whether that deposit succeeded.
|
||||
- **Identity is borrowed, not owned.** Users and OAuth2 applications are lazily mirrored here from the central **accounts** service the moment they're first seen.
|
||||
- **Two front doors, one engine.** A user-token API (self-service) and an application-token API (server-to-server, keyed by `user_uuid`) both funnel into the same `Plan` / `Recipient` / `Promotion` models.
|
||||
|
||||
---
|
||||
|
||||
## System map
|
||||
|
||||
Gooyal is a constellation of small Django services (accounts, wallet, notifications, advertising, chat, campaign, settlement, ipg…) that all authenticate through one central OAuth2 provider. Promotions is a consumer-facing resource server that in turn calls three of its siblings.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
classDef svc fill:#3550d6,stroke:#3550d6,color:#fff,rx:6,ry:6
|
||||
classDef ext fill:#2e7d93,stroke:#2e7d93,color:#fff,rx:6,ry:6
|
||||
classDef infra fill:#eeeeee,stroke:#999,color:#333,rx:6,ry:6
|
||||
classDef caller fill:#ffffff,stroke:#888,color:#333,rx:6,ry:6
|
||||
|
||||
subgraph Callers[" calling applications, each with its own client-credentials token "]
|
||||
direction TB
|
||||
ADV["advertising"]:::caller
|
||||
CHAT["chat"]:::caller
|
||||
CAMP["campaign"]:::caller
|
||||
SET["settlement"]:::caller
|
||||
end
|
||||
|
||||
PROMO["promotions\n(this service)"]:::svc
|
||||
|
||||
ADV --> PROMO
|
||||
CHAT --> PROMO
|
||||
CAMP --> PROMO
|
||||
SET --> PROMO
|
||||
|
||||
PROMO -- "introspect bearer token\n+ fetch user profile" --> ACC["accounts\nOAuth2 provider + identity\n(external, not in this checkout)"]:::ext
|
||||
PROMO -- "submit + verify deposit" --> WAL["wallet\napplication & user ledgers\n(sibling repo)"]:::svc
|
||||
PROMO -- "push notification\n(fire-and-forget)" --> NOT["notifications\n(external, not in this checkout)"]:::ext
|
||||
PROMO -- "queue analyze_event_task" --> RED[("Redis")]:::infra
|
||||
RED -- "Celery worker" --> PROMO
|
||||
PROMO --- PG[("Postgres")]:::infra
|
||||
```
|
||||
|
||||
Solid arrows are outbound REST calls made by promotions via generated OpenAPI clients in `utils/clients/`. Blue = Django services; teal = platform services whose source isn't checked out next to this repo.
|
||||
|
||||
---
|
||||
|
||||
## Identity & auth
|
||||
|
||||
Every request is a borrowed identity. Promotions has no login form and no password table that matters. It's a pure OAuth2 *resource server*: the **accounts** service is the one real authorization server on the platform, and every sibling service — promotions included — just validates tokens against it.
|
||||
|
||||
The `apps/gooyal_oauth2` app looks like a full OAuth2 provider (it defines `Application`, `AccessToken`, `Grant`, `RefreshToken`, `IDToken` models) but that's boilerplate shared across every Gooyal service, not a second identity system. In practice it's used as a **local cache**: `apps/gooyal_oauth2/validators.py` overrides django-oauth-toolkit's introspection so that the first time a bearer token is seen, promotions POSTs it to accounts's `/oauth2/introspect/`, then a second time to `/oauth2/introspect_application/`, and materializes the result as local `Application` / `AccessToken` rows. That's what lets `Plan.application` and `Promotion.application` be plain foreign keys instead of remote lookups on every access.
|
||||
|
||||
`apps/users.User` works the same way: a slim shadow row (UUID primary key, almost no other fields) created on first contact — either by the introspection validator (`get_or_create_user_from_content`) or explicitly in the application-token views, which call `utils/clients/accounts_client.get_user_info()` when a `user_uuid` in the URL doesn't have a local row yet.
|
||||
|
||||
Authorization itself is scope-based, checked per DRF view via two custom permission classes in `apps/gooyal_oauth2/rest_framework.py`: `IsAuthenticatedOrTokenMatchesOASRequirements` (plain endpoints) and `TokenMatchesViewSetActions` (viewsets, matches scopes per `action` rather than per HTTP method). Real scopes in use, pulled from the test suite:
|
||||
|
||||
| Scope | Grants |
|
||||
|---|---|
|
||||
| `promotions.application.event:submit` | App-to-app: submit an event on a user's behalf |
|
||||
| `promotions.application.user-plan:promote` | App-to-app: trigger promotion evaluation for a plan directly |
|
||||
| `promotions.application.user-plan:list-retrieve` | App-to-app: read plan/recipient configuration |
|
||||
| `promotions.application.user-promotions:promote` | App-to-app: create promotions via `ApplicationPromoteUserApiView` |
|
||||
| `promotions.application.user-promotions:list-retrieve` | App-to-app: list a user's promotion history |
|
||||
| `promotions.user.self-plans:list-retrieve` | End user (session or user-token): browse their own plans |
|
||||
|
||||
---
|
||||
|
||||
## Domain model
|
||||
|
||||
Everything funnels through `apps/promotions/models.py`. It's compact — five real models — but `Recipient` carries a hand-rolled mini query language that's easy to misread the first time.
|
||||
|
||||
| Model | Role |
|
||||
|---|---|
|
||||
| `EventSaver` | Registers a valid `event_label` and whether it may only ever fire once per user (`save_once`). Acts as the allow-list for incoming events. |
|
||||
| `Event` | One occurrence: `user` (raw UUID, not FK), `application`, `label`, free-form `data` JSON. GIN-indexed on `data`. |
|
||||
| `Plan` | A campaign: a `balance`, the list of event labels that trigger it (`event_list`, GIN-indexed), a banner/description for display, and one or more `Recipient`s. |
|
||||
| `Recipient` | Who gets paid from a `Plan` and how much — expressed as two small DSL strings rather than fixed fields (below). |
|
||||
| `Promotion` | One payout attempt: state machine, links back to the triggering `Event`/`Plan`/`Recipient`, drives the real wallet deposit. |
|
||||
|
||||
### The recipient DSL
|
||||
|
||||
Two `Recipient` fields are tiny expression languages evaluated against whichever `Event` and `Plan` triggered the check.
|
||||
|
||||
`recipient_uuid_field` — who gets paid:
|
||||
|
||||
| Form | Meaning |
|
||||
|---|---|
|
||||
| `<a literal UUID>` | Fixed recipient, e.g. a promo operator account. |
|
||||
| `->event:user` | The most common case: pay the user who fired the event. (`event.user` is read directly — no filter clause before the `->`.) |
|
||||
| `model:key=value&…->model:key` | Conditional form: only resolves if every `key=value` check against `event`/`plan` (or their `data` JSON via `data__key`) matches; then reads the recipient off the named object. |
|
||||
| `QS:Model:key=value&…->field` | Looks the *most recent* matching `Event`/`Plan`/`Promotion` row up by filter, then returns `field` as a literal UUID if it parses as one, else `row.data[field]`. |
|
||||
|
||||
`base_amount_field` is simpler: either a raw integer literal, or `event:key` / `plan:key` to pull the base amount out of that object's `data` JSON. `Recipient.data` then layers `promotion_percentage` (default 100) and an optional `max_promotion_amount` cap on top — see the watch list below for a live bug in that capping path.
|
||||
|
||||
---
|
||||
|
||||
## Request flow
|
||||
|
||||
From event to money in a wallet. The generic, asynchronous path: an app submits an event, promotions figures out the rest on a Celery worker. (The synchronous "promote this specific plan right now" endpoints skip the queue but land on the same `Recipient.promote()` call.)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant App as Calling app
|
||||
participant Promo as promotions (API)
|
||||
participant Q as Celery / Redis
|
||||
participant Wal as wallet
|
||||
participant Notif as notifications
|
||||
|
||||
App->>Promo: POST /api/v1/events/submit<br/>{label, data} + Bearer token
|
||||
Promo->>Promo: EventSaver.save_event() → Event row
|
||||
Promo-->>App: 201 Created
|
||||
Promo->>Q: analyze_event_task.delay(event.uuid)
|
||||
Q->>Promo: Event.analyze()
|
||||
Promo->>Promo: Plan.objects.related_to_event(event)
|
||||
loop each matching Plan
|
||||
Promo->>Promo: Recipient.promote(plan, event)
|
||||
Promo->>Promo: resolve DSL → recipient uuid + amount
|
||||
Promo->>Promo: Plan.reserve_promotion_amount() (atomic)
|
||||
Promo->>Wal: POST application/<wallet>/deposit/
|
||||
Wal-->>Promo: transaction uuid, state=PENDING
|
||||
Promo->>Wal: GET .../deposit/<uuid>/verify
|
||||
Wal-->>Promo: state=SUCCESS
|
||||
Promo->>Notif: POST push (best-effort, errors swallowed)
|
||||
Promo->>Promo: Promotion.state → SUCCESS
|
||||
end
|
||||
```
|
||||
|
||||
`apps/promotions/tasks.py` · `apps/promotions/models.py` (`Event.analyze`, `Plan.process_event`, `Recipient.promote`, `Promotion.promote`)
|
||||
|
||||
---
|
||||
|
||||
## Payment states
|
||||
|
||||
One state machine, defined twice. `PaymentStateChoices` in promotions and `StateChoices` in wallet share the same integer values (1–7) by convention, not by import — worth knowing before you add a state to one and not the other.
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> CREATED
|
||||
CREATED --> SUCCESS: amount is 0, or plan has a balance_holder
|
||||
CREATED --> PENDING: deposit submitted to wallet
|
||||
PENDING --> SUCCESS: wallet verify returns state 5
|
||||
PENDING --> FAILED: wallet verify returns a non-success state
|
||||
PENDING --> EXPECTED_FAILURE: submit/verify call itself raised
|
||||
FAILED --> [*]
|
||||
SUCCESS --> [*]
|
||||
EXPECTED_FAILURE --> [*]
|
||||
```
|
||||
|
||||
`EXPECTED_FAILURE` means "we don't actually know" — the HTTP call to wallet errored, so promotions guesses failure but the deposit may have gone through. There's no automated reconciliation job for this state today.
|
||||
|
||||
---
|
||||
|
||||
## API surface
|
||||
|
||||
Two front doors into the same engine. User-token endpoints live under `/promotions/`; application-token endpoints live under `/api/v2/promotions/application/<user_uuid>/` for callers acting on behalf of a user they hold no session for.
|
||||
|
||||
| Path | View | Auth | Purpose |
|
||||
|---|---|---|---|
|
||||
| `POST /promotions/api/v1/events/submit` | `ApplicationEventSubmitAPIView` | token | Fire-and-forget: save the event, queue `analyze_event_task`. |
|
||||
| `GET /promotions/api/v1/events/<label>/status/` | `ApplicationEventStatusApiView` | token | Has this user already been paid for this event label, and if not, what would they get? |
|
||||
| `POST /promotions/api/v1/plans/<plan>/` | `ApplicationPromoteUserApiView` | token | Synchronous: evaluate one specific plan right now, no queue. |
|
||||
| `GET /promotions/api/v1/plans/<plan>/promotins` | `ApplicationPromotionListApiView` | token | List this user's promotion history for a plan. (Route typo — "promotins" — is load-bearing; don't casually rename.) |
|
||||
| `GET/POST /promotions/api/v2/plans/` | `UserPlanViewSet` | token | Self-service plan browsing (DRF router). |
|
||||
| `POST .../application/<user_uuid>/event/` | `ApplicationEventViewSet` | app | Same as event submit, but the caller supplies `user_uuid` instead of holding the user's own token; lazily creates the shadow `User` via accounts. |
|
||||
| `GET .../application/<user_uuid>/event/<label>/status/` | `ApplicationEventViewSet.status` | app | App-side equivalent of the status check above. |
|
||||
| `GET/POST .../application/<user_uuid>/plan/` | `ApplicationUserPlanViewSet` | app | List plans; `promote` action evaluates one on the user's behalf. |
|
||||
|
||||
---
|
||||
|
||||
## Playbook: adding a new promotion, end to end
|
||||
|
||||
Concrete steps, mirroring the real `first-ad-create` fixture in `apps/promotions/tests.py`. No code changes required for a straightforward percentage-of-nothing flat reward — it's entirely data.
|
||||
|
||||
1. **Register the trigger event.** Create an `EventSaver` row naming the event label the calling app will send, and whether it's once-per-user.
|
||||
|
||||
```python
|
||||
EventSaver.objects.create(
|
||||
event_label="ads::first-ad-create",
|
||||
title="first-ad-create",
|
||||
save_once=True, # duplicate submits raise, don't re-pay
|
||||
)
|
||||
```
|
||||
|
||||
2. **Create the plan.** Fund it with a `balance` and list the event label(s) that should trigger it. `processor` is required by the model but currently has no behavioral effect (see watch list) — `OTHERS` is the safe default.
|
||||
|
||||
```python
|
||||
Plan.objects.create(
|
||||
title="first-ad-create",
|
||||
event_list=["ads::first-ad-create"],
|
||||
balance=30_000_000,
|
||||
processor=ProcessorTypeChoices.OTHERS,
|
||||
)
|
||||
```
|
||||
|
||||
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(
|
||||
plan=plan,
|
||||
label="first-ad-create",
|
||||
recipient_uuid_field="->event:user",
|
||||
base_amount_field="30000", # literal, or "event:data_key"
|
||||
wallet_destination=WalletDestinationChoices.ADVERTISING_TRANSIT,
|
||||
)
|
||||
```
|
||||
|
||||
4. **Have the calling app submit the event.** Either the async application-token route, or the sync per-plan route if the app wants the result inline.
|
||||
|
||||
```http
|
||||
POST /api/v2/promotions/application/<user_uuid>/event/
|
||||
{ "label": "ads::first-ad-create", "data": {} }
|
||||
```
|
||||
|
||||
5. **Watch it resolve.** Async submits fan out through `analyze_event_task` to every `Plan` whose `event_list` matches, each producing a `Promotion` row you can inspect in admin or via the status endpoint.
|
||||
|
||||
---
|
||||
|
||||
## Watch list
|
||||
|
||||
Know these before you touch the calculation path. Ranked by how badly they'll surprise you, not by file order.
|
||||
|
||||
### 🔴 crash — Setting `max_promotion_amount` breaks amount calculation
|
||||
|
||||
`apps/promotions/models.py` — `Recipient.get_promotion_amount()`, lines ~276–307
|
||||
|
||||
`get_promotion_amount()` returns a single `int` when `max_promotion_amount` is set on `Recipient.data`, but returns a `(base_amount, promotion_amount)` tuple otherwise. Both call sites — `Recipient.promote()` and `Plan.get_configured_promotion_amount()` — unconditionally do `base_amount, promotion_amount = recipient.get_promotion_amount(...)`. The capped branch raises `TypeError: cannot unpack non-iterable int object`.
|
||||
|
||||
**Fix:** never set `max_promotion_amount` on a live `Recipient` today. If you need a cap, make `get_promotion_amount` always return the tuple and clamp `promotion_amount` inside it instead of short-circuiting.
|
||||
|
||||
### 🟠 correctness + perf — `event_list__icontains` is a substring text match, not JSON containment
|
||||
|
||||
`apps/promotions/models.py` — `PlanQuerySet.related_to_event()`, `get_event_status_for_user()`
|
||||
|
||||
Both matching paths filter with `event_list__icontains=event.label`. On a JSONField this casts to text and does a case-insensitive `LIKE` — it does not use the `GinIndex` already defined on `event_list`, and it will false-positive match a plan for `"ads::first-ad-create-v2"` when the incoming label is `"ads::first-ad-create"`.
|
||||
|
||||
**Fix:** use `event_list__contains=[event.label]` (jsonb `@>`), which is both exact and index-backed.
|
||||
|
||||
### ⚪ dead code — the processor/handler abstraction isn't wired up
|
||||
|
||||
`apps/promotions/handlers.py`, `models.py Plan.process_event()`
|
||||
|
||||
`Plan.process_event()` ignores `self.processor` entirely and always calls `promote_all()`. `PercentageHandler` and `ReferralHandler` in `handlers.py` both `return` before their real logic and reference an undefined `self.balance` — they're unreachable and would error if called.
|
||||
|
||||
**Fix:** if a new promotion type needs calculation logic different from the generic percentage-with-cap in `Recipient.get_promotion_amount`, this is the intended seam — but it needs to be actually dispatched from `process_event`, not assumed to work.
|
||||
|
||||
### ⚪ duplication — `views_user.py` and `views_application.py` largely repeat each other
|
||||
|
||||
`apps/promotions/views_user.py`, `views_application.py`
|
||||
|
||||
`ApplicationPromoteUserApiView`, `ApplicationPromotionListApiView`, and `ApplicationEventSubmitAPIView` are near-identical copies across both files. `get_queryset()`/`_resolve_user()` also mutate `self.request.user` as a side effect, which is easy to miss when tracing a bug.
|
||||
|
||||
**Fix:** before adding a third variant of any of these, factor the shared body into a mixin — otherwise a fix applied to one silently doesn't apply to its twin.
|
||||
|
||||
### ⚪ idempotency — `Promotion.get_or_create` keys on computed amounts, not just identity
|
||||
|
||||
`apps/promotions/models.py` — `Recipient.promote()`, lines ~319–326
|
||||
|
||||
`get_or_create(promotion_amount=..., user_uuid=..., event=..., plan=..., recipient=..., base_amount=...)` has no `defaults=`; every field is part of the lookup. If the amount calculation for the same (user, event, plan, recipient) ever produces a different number on a retry — a config change mid-flight, or the bug above getting fixed — you get a second `Promotion` row instead of a duplicate-prevented one.
|
||||
|
||||
**Fix:** the real duplicate guard is `EventSaver.save_once` one layer up; don't rely on this `get_or_create` for idempotency if you change how amounts are computed.
|
||||
|
||||
---
|
||||
|
||||
## File map
|
||||
|
||||
**apps/promotions — the engine**
|
||||
|
||||
| Path | Purpose |
|
||||
|---|---|
|
||||
| `models.py` | Every entity above, plus the recipient DSL and the payment state machine. |
|
||||
| `handlers.py` | Unwired processor/handler scaffolding — see watch list. |
|
||||
| `views_user.py` / `urls_user.py` | User-token API, mounted at `/promotions/`. |
|
||||
| `views_application.py` / `urls_application.py` | Application-token API, mounted at `/api/v2/promotions/application/<user_uuid>/`. |
|
||||
| `serializers.py` | Thin DRF serializers; business logic stays in models.py. |
|
||||
| `tasks.py` | One Celery task: `analyze_event_task`. |
|
||||
| `tests.py` | APITestCase flows with wallet calls mocked — the best template for testing a new plan. |
|
||||
|
||||
**apps/gooyal_oauth2 & apps/users — borrowed identity**
|
||||
|
||||
| Path | Purpose |
|
||||
|---|---|
|
||||
| `gooyal_oauth2/validators.py` | Custom introspection against the central accounts service; materializes local Application/AccessToken rows. |
|
||||
| `gooyal_oauth2/rest_framework.py` | Scope-checking DRF permission classes used across every view. |
|
||||
| `users/models.py` | Shadow `User` — UUID PK, created on first contact, no local auth. |
|
||||
|
||||
**utils/clients — outbound calls to siblings**
|
||||
|
||||
| Path | Purpose |
|
||||
|---|---|
|
||||
| `wallet_client.py` | Deposit submit/verify, withdraw, wallet balance lookups. |
|
||||
| `accounts_client.py` | User profile + application detail lookups. |
|
||||
| `notifications_client.py` | Push + email, both best-effort. |
|
||||
| `gooyal_*_client/` | Generated openapi-python-client SDKs per README.srt — regenerate, don't hand-edit. |
|
||||
|
||||
---
|
||||
|
||||
## Config reference
|
||||
|
||||
Non-secret values only — pulled from `main/settings.py` and this checkout's own `.env`.
|
||||
|
||||
| Variable | Points at |
|
||||
|---|---|
|
||||
| `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` | 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_CREDIT` | 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`). |
|
||||
|
||||
---
|
||||
|
||||
*Compiled by reading `apps/promotions`, `apps/gooyal_oauth2`, `apps/users`, `utils/clients` in this checkout, cross-referenced against the sibling `wallet` repo and the `.env` files of `chat`/`advertising`/`campaign` under `Winsoo/` to confirm which services are real network boundaries versus copy-pasted boilerplate. The `accounts` and `notifications` services are referenced only through their generated API clients — their source isn't checked out locally.*
|
||||
|
|
@ -2,17 +2,27 @@ from functools import update_wrapper
|
|||
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import Promotion, Plan, Event, Recipient, EventSaver
|
||||
from .models import Promotion, Plan, Event, Recipient, EventSaver, AllowedUser, PromotionTransaction
|
||||
|
||||
class PromotionAdmin(admin.ModelAdmin):
|
||||
list_display = ['user_uuid', 'plan', 'promotion_amount', 'recipient', 'event', 'created_at']
|
||||
list_display = ['user_uuid', 'plan', 'promotion_amount', 'recipient', 'event', 'state', 'created_at']
|
||||
|
||||
class PromotionTransactionAdmin(admin.ModelAdmin):
|
||||
list_display = ['user_uuid', 'event_label', 'transaction_type', 'amount', 'state', 'promotion', 'reverses', 'created_at']
|
||||
list_filter = ['transaction_type', 'state']
|
||||
search_fields = ['user_uuid', 'event_label']
|
||||
|
||||
class PlanAdmin(admin.ModelAdmin):
|
||||
list_display = ['uuid', 'title', 'balance']
|
||||
|
||||
class AllowedUserAdmin(admin.ModelAdmin):
|
||||
list_display = ['user', 'recipient', 'created_at']
|
||||
|
||||
admin.site.register(Plan, PlanAdmin)
|
||||
admin.site.register(Promotion, PromotionAdmin)
|
||||
admin.site.register(Event)
|
||||
admin.site.register(EventSaver)
|
||||
admin.site.register(Recipient)
|
||||
admin.site.register(AllowedUser, AllowedUserAdmin)
|
||||
admin.site.register(PromotionTransaction, PromotionTransactionAdmin)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
import uuid
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('promotions', '0008_plan_balance_holder_alter_plan_description_details'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='recipient',
|
||||
name='access_type',
|
||||
field=models.CharField(choices=[('restricted', 'restricted'), ('public', 'public')],
|
||||
db_index=True, default='public', max_length=64, verbose_name='access type'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='AllowedUser',
|
||||
fields=[
|
||||
('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True,
|
||||
serialize=False, unique=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True, db_index=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name='allowed_recipients', to=settings.AUTH_USER_MODEL)),
|
||||
('recipient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name='allowed_users', to='promotions.recipient')),
|
||||
],
|
||||
options={
|
||||
'constraints': [
|
||||
models.UniqueConstraint(fields=('user', 'recipient'), name='unique_allowed_user_recipient'),
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -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'),
|
||||
),
|
||||
]
|
||||
43
apps/promotions/migrations/0011_promotiontransaction.py
Normal file
43
apps/promotions/migrations/0011_promotiontransaction.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import uuid
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('promotions', '0010_recipient_wallet_destination'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='PromotionTransaction',
|
||||
fields=[
|
||||
('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True, db_index=True)),
|
||||
('user_uuid', models.UUIDField(db_index=True)),
|
||||
('event_label', models.CharField(db_index=True, max_length=255)),
|
||||
('transaction_type', models.IntegerField(choices=[(1, 'payout'), (2, 'rollback')], db_index=True)),
|
||||
('holder_wallet', models.UUIDField()),
|
||||
('destination_wallet', models.UUIDField()),
|
||||
('amount', models.IntegerField(default=0)),
|
||||
('state', models.IntegerField(choices=[(1, 'created'), (2, 'delayed'), (3, 'pending'), (4, 'incomplete'), (5, 'success'), (6, 'failed'), (7, 'expected_failure')], db_index=True, default=1)),
|
||||
('external_uuid', models.UUIDField(default=uuid.uuid4, editable=False)),
|
||||
('promotion', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transactions', to='promotions.promotion')),
|
||||
('reverses', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='reversed_by', to='promotions.promotiontransaction')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['created_at'],
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='promotiontransaction',
|
||||
constraint=models.UniqueConstraint(fields=('promotion', 'transaction_type'), name='unique_promotion_transaction_type'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='promotiontransaction',
|
||||
constraint=models.UniqueConstraint(condition=models.Q(('transaction_type', 2)), fields=('user_uuid', 'event_label'), name='unique_promotion_rollback'),
|
||||
),
|
||||
]
|
||||
|
|
@ -3,7 +3,7 @@ import uuid
|
|||
from django.conf import settings
|
||||
from django.contrib.postgres.indexes import GinIndex
|
||||
from django.db import models, transaction
|
||||
from django.db.models import F
|
||||
from django.db.models import F, Q
|
||||
from django.utils import timezone
|
||||
from django_minio_backend import MinioBackend, iso_date_prefix
|
||||
|
||||
|
|
@ -135,6 +135,26 @@ class Plan(BaseModel):
|
|||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
def get_configured_promotion_amount(self, user_uuid=None):
|
||||
access_filter = Q(access_type=RecipientTypeChoices.PUBLIC)
|
||||
if user_uuid:
|
||||
access_filter |= Q(access_type=RecipientTypeChoices.RESTRICTED, allowed_users__user__uuid=user_uuid)
|
||||
|
||||
recipients = self.recipients.filter(
|
||||
recipient_uuid_field='->event:user' #TODO: instead of hard code use better soloution
|
||||
).filter(access_filter).distinct()
|
||||
|
||||
amount = 0
|
||||
for recipient in recipients:
|
||||
base_amount, promotion_amount = recipient.get_promotion_amount()
|
||||
amount += promotion_amount
|
||||
return amount
|
||||
|
||||
def is_promotion_used_by(self, user_uuid):
|
||||
return self.promotions.filter(user_uuid=user_uuid).exclude(
|
||||
state__in=[PaymentStateChoices.FAILED, PaymentStateChoices.EXPECTED_FAILURE]
|
||||
).exists()
|
||||
|
||||
def process_event(self, event=None):
|
||||
# TODO: processor
|
||||
return self.promote_all(event=event)
|
||||
|
|
@ -168,12 +188,27 @@ class Plan(BaseModel):
|
|||
return updated
|
||||
|
||||
|
||||
class RecipientTypeChoices(models.TextChoices):
|
||||
RESTRICTED = 'restricted', _('restricted')
|
||||
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)
|
||||
|
||||
recipient_uuid_field = models.CharField(max_length=255, db_index=True)
|
||||
base_amount_field = models.CharField(max_length=255, db_index=True)
|
||||
|
|
@ -193,7 +228,20 @@ 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:
|
||||
return True
|
||||
if not user_uuid:
|
||||
return False
|
||||
return self.allowed_users.filter(user__uuid=user_uuid).exists()
|
||||
|
||||
def get_recipient_uuid(self, plan=None, event=None):
|
||||
if ':' in self.recipient_uuid_field and not self.recipient_uuid_field.startswith('QS:'):
|
||||
|
|
@ -303,6 +351,9 @@ class Recipient(BaseModel):
|
|||
if not recipient:
|
||||
return None
|
||||
|
||||
if not self.is_user_allowed(recipient):
|
||||
return None
|
||||
|
||||
base_amount, promotion_amount = self.get_promotion_amount(plan=plan, event=event)
|
||||
promotion, created = Promotion.objects.get_or_create(
|
||||
promotion_amount=promotion_amount,
|
||||
|
|
@ -313,6 +364,24 @@ class Recipient(BaseModel):
|
|||
base_amount=base_amount,
|
||||
)
|
||||
if recipient and promotion_amount and created:
|
||||
def _pending_rollback():
|
||||
return PromotionTransaction.objects.filter(
|
||||
user_uuid=recipient, event_label=event.label,
|
||||
transaction_type=PromotionTransaction.TypeChoices.ROLLBACK,
|
||||
).exclude(state=PaymentStateChoices.SUCCESS).first()
|
||||
|
||||
pending_rollback = _pending_rollback()
|
||||
if pending_rollback is not None:
|
||||
# The advertising service already asked to reverse this promotion
|
||||
# before it was even paid -- don't move the money at all.
|
||||
promotion.change_state(from_states=[promotion.state],
|
||||
to_state=PaymentStateChoices.SUCCESS, same_ok=True)
|
||||
PromotionTransaction.objects.filter(pk=pending_rollback.pk).update(
|
||||
promotion=promotion, amount=0, state=PaymentStateChoices.SUCCESS,
|
||||
updated_at=timezone.now())
|
||||
promotion.refresh_from_db()
|
||||
return promotion
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
reserved = self.plan.reserve_promotion_amount(promotion_amount)
|
||||
|
|
@ -325,10 +394,28 @@ class Recipient(BaseModel):
|
|||
same_ok=True)
|
||||
promotion.refresh_from_db()
|
||||
|
||||
# A rollback request may have arrived while the payout above was in
|
||||
# flight (so the pre-check missed it). Reverse it now that it has settled.
|
||||
if _pending_rollback() is not None:
|
||||
rollback_promotion_payout(recipient, event.label)
|
||||
|
||||
return promotion
|
||||
return None
|
||||
|
||||
|
||||
class AllowedUser(BaseModel):
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='allowed_recipients')
|
||||
recipient = models.ForeignKey(Recipient, on_delete=models.CASCADE, related_name='allowed_users')
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['user', 'recipient'], name='unique_allowed_user_recipient')
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user} -> {self.recipient}"
|
||||
|
||||
|
||||
class PaymentStateChoices(models.IntegerChoices):
|
||||
CREATED = 1, _('created')
|
||||
DELAYED = 2, _('delayed') # delayed as user wish
|
||||
|
|
@ -343,6 +430,23 @@ class PromotionQuerySet(models.QuerySet):
|
|||
def used(self, user):
|
||||
return self.filter(user=user, state__in=[Promotion.PaymentStateChoices.CREATED])
|
||||
|
||||
def is_processed_for_user(self, user_uuid, event_label):
|
||||
return self.filter(user_uuid=user_uuid, event__label=event_label).exists()
|
||||
|
||||
|
||||
def get_event_status_for_user(user_uuid, event_label):
|
||||
processed = Promotion.objects.is_processed_for_user(user_uuid, event_label)
|
||||
promotion_amount = 0
|
||||
if not processed:
|
||||
plan = Plan.objects.filter(event_list__icontains=event_label).first()
|
||||
if plan:
|
||||
promotion_amount = plan.get_configured_promotion_amount(user_uuid=user_uuid)
|
||||
return {
|
||||
'event_label': event_label,
|
||||
'processed': processed,
|
||||
'promotion_amount': promotion_amount,
|
||||
}
|
||||
|
||||
|
||||
class Promotion(BaseModel):
|
||||
user_uuid = models.UUIDField() # user_uuid
|
||||
|
|
@ -398,7 +502,7 @@ class Promotion(BaseModel):
|
|||
if self.state not in [PaymentStateChoices.CREATED]:
|
||||
raise Exception(_('Cannot promote. not in correct state'))
|
||||
|
||||
if not self.promotion_amount or self.plan.balance_holder:
|
||||
if not self.promotion_amount:
|
||||
to_not_payed = self.change_state(
|
||||
from_states=[PaymentStateChoices.CREATED],
|
||||
to_state=PaymentStateChoices.SUCCESS,
|
||||
|
|
@ -416,58 +520,38 @@ class Promotion(BaseModel):
|
|||
error_message='Cannot promote. not in current state'
|
||||
)
|
||||
|
||||
payment_uuid = str(self.uuid)
|
||||
|
||||
data = {
|
||||
"uuid": payment_uuid,
|
||||
"payee": str(self.user_uuid),
|
||||
"payee_type": 1,
|
||||
"payee_wallet": settings.WALLET_REWARD,
|
||||
"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)
|
||||
if not submit_response.uuid:
|
||||
raise Exception('Failed to submit promotion. 1')
|
||||
|
||||
except Exception as e:
|
||||
self.change_state(
|
||||
from_states=[self.state],
|
||||
to_state=PaymentStateChoices.FAILED,
|
||||
same_ok=False,
|
||||
raise_exception=True,
|
||||
error_message='Failed to update state promotions'
|
||||
# The wallet transfer itself is recorded and driven by a PromotionTransaction
|
||||
# ledger row (mirrors advertising's AdPayment / escrow's EscrowWalletPayment),
|
||||
# so every promotion payout has an auditable record and can be reversed.
|
||||
payout, _created = PromotionTransaction.objects.get_or_create(
|
||||
promotion=self,
|
||||
transaction_type=PromotionTransaction.TypeChoices.PAYOUT,
|
||||
defaults=dict(
|
||||
user_uuid=self.user_uuid,
|
||||
event_label=self.event.label if self.event_id else '',
|
||||
holder_wallet=settings.WALLET_PROMOTIONS_CREDIT,
|
||||
destination_wallet=self.recipient.get_wallet_category_uuid(),
|
||||
amount=self.promotion_amount,
|
||||
),
|
||||
)
|
||||
# return False
|
||||
raise Exception('Failed to submit promotion. 2')
|
||||
|
||||
try:
|
||||
verify_response = deposit_to_user_wallet_verify(settings.WALLET_REWARD, submit_response.uuid)
|
||||
if not verify_response.uuid:
|
||||
raise Exception('Failed to verify promotion. 1')
|
||||
|
||||
except Exception as e:
|
||||
transferred = payout.execute()
|
||||
except Exception:
|
||||
self.change_state(
|
||||
from_states=[self.state],
|
||||
to_state=PaymentStateChoices.EXPECTED_FAILURE,
|
||||
same_ok=False,
|
||||
same_ok=True,
|
||||
raise_exception=True,
|
||||
error_message='Failed to update state'
|
||||
error_message='Failed to update state',
|
||||
)
|
||||
# return False
|
||||
raise Exception('Failed to verify promition. 2')
|
||||
raise Exception('Failed to pay promotion')
|
||||
|
||||
if verify_response.state != 5:
|
||||
to_pay_failed = self.change_state(
|
||||
if not transferred:
|
||||
self.change_state(
|
||||
from_states=[self.state],
|
||||
to_state=PaymentStateChoices.FAILED,
|
||||
same_ok=False,
|
||||
same_ok=True,
|
||||
raise_exception=True,
|
||||
error_message='Failed to update state payment',
|
||||
)
|
||||
|
|
@ -501,3 +585,192 @@ class Promotion(BaseModel):
|
|||
error_message='Failed to update state payment',
|
||||
)
|
||||
return False
|
||||
|
||||
def is_rollbackable(self):
|
||||
"""A promotion payout can only be reversed if it actually landed in the
|
||||
advertising transit wallet. A payout made straight to the user's income
|
||||
wallet is the user's money and is never clawed back."""
|
||||
return bool(
|
||||
self.recipient_id
|
||||
and self.recipient.wallet_destination == WalletDestinationChoices.ADVERTISING_TRANSIT
|
||||
)
|
||||
|
||||
def is_rolled_back(self):
|
||||
return self.transactions.filter(
|
||||
transaction_type=PromotionTransaction.TypeChoices.ROLLBACK,
|
||||
state=PaymentStateChoices.SUCCESS,
|
||||
).exists()
|
||||
|
||||
|
||||
IN_FLIGHT_PAYMENT_STATES = (
|
||||
PaymentStateChoices.CREATED,
|
||||
PaymentStateChoices.PENDING,
|
||||
PaymentStateChoices.DELAYED,
|
||||
PaymentStateChoices.INCOMPLETE,
|
||||
)
|
||||
|
||||
|
||||
class PromotionTransaction(BaseModel):
|
||||
"""Ledger of every wallet transfer the promotions service makes -- the
|
||||
payout for a promotion, and any later reversal of it. Mirrors advertising's
|
||||
``AdPayment`` and escrow's ``EscrowWalletPayment``: each row drives one
|
||||
external wallet call through its own CREATED -> PENDING -> SUCCESS/FAILED
|
||||
state, so the money movement is auditable and reversible.
|
||||
|
||||
``user_uuid`` / ``event_label`` are denormalised onto the row so a ROLLBACK
|
||||
can be recorded before the async payout task has even created its
|
||||
``Promotion`` (see :func:`rollback_promotion_payout` and
|
||||
``Recipient.promote``).
|
||||
"""
|
||||
|
||||
class TypeChoices(models.IntegerChoices):
|
||||
PAYOUT = 1, _('payout') # promotions credit -> recipient wallet (transit or user)
|
||||
ROLLBACK = 2, _('rollback') # advertising transit -> promotions credit
|
||||
|
||||
promotion = models.ForeignKey(Promotion, on_delete=models.SET_NULL, null=True, blank=True,
|
||||
related_name='transactions')
|
||||
user_uuid = models.UUIDField(db_index=True)
|
||||
event_label = models.CharField(max_length=255, db_index=True)
|
||||
transaction_type = models.IntegerField(choices=TypeChoices.choices, db_index=True)
|
||||
holder_wallet = models.UUIDField() # 1st arg to deposit_to_user_wallet_submit
|
||||
destination_wallet = models.UUIDField() # data['payee_wallet']
|
||||
amount = models.IntegerField(default=0)
|
||||
state = models.IntegerField(choices=PaymentStateChoices.choices,
|
||||
default=PaymentStateChoices.CREATED, db_index=True)
|
||||
external_uuid = models.UUIDField(default=uuid.uuid4, editable=False) # idempotency key to the wallet service
|
||||
reverses = models.ForeignKey('self', on_delete=models.PROTECT, null=True, blank=True,
|
||||
related_name='reversed_by')
|
||||
|
||||
class Meta:
|
||||
ordering = ['created_at']
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['promotion', 'transaction_type'],
|
||||
name='unique_promotion_transaction_type'),
|
||||
models.UniqueConstraint(fields=['user_uuid', 'event_label'],
|
||||
condition=models.Q(transaction_type=2),
|
||||
name='unique_promotion_rollback'),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.get_transaction_type_display()} {self.amount} ({self.event_label})"
|
||||
|
||||
def _set_state(self, state):
|
||||
PromotionTransaction.objects.filter(pk=self.pk).update(state=state, updated_at=timezone.now())
|
||||
self.state = state
|
||||
|
||||
def execute(self):
|
||||
"""Run the wallet transfer once. Returns True only on a confirmed
|
||||
transfer; leaves the row FAILED / EXPECTED_FAILURE (and re-raises) on
|
||||
error, same as the promote()/AdPayment style. No automatic retry."""
|
||||
if self.state == PaymentStateChoices.SUCCESS:
|
||||
return True
|
||||
if self.state != PaymentStateChoices.CREATED:
|
||||
return False
|
||||
if not self.amount:
|
||||
self._set_state(PaymentStateChoices.SUCCESS)
|
||||
return True
|
||||
|
||||
self._set_state(PaymentStateChoices.PENDING)
|
||||
|
||||
data = {
|
||||
"uuid": str(self.external_uuid),
|
||||
"payee": str(self.user_uuid),
|
||||
"payee_type": 1,
|
||||
"payee_wallet": str(self.destination_wallet),
|
||||
"amount": self.amount,
|
||||
"details": {
|
||||
'description': str(self.get_transaction_type_display()),
|
||||
'reference_id': str(self.pk),
|
||||
'application_details_url': '',
|
||||
},
|
||||
}
|
||||
|
||||
try:
|
||||
submit_response = deposit_to_user_wallet_submit(str(self.holder_wallet), data)
|
||||
if not getattr(submit_response, 'uuid', None):
|
||||
raise Exception('wallet submit returned no uuid')
|
||||
except Exception:
|
||||
self._set_state(PaymentStateChoices.FAILED)
|
||||
raise
|
||||
|
||||
try:
|
||||
verify_response = deposit_to_user_wallet_verify(str(self.holder_wallet), submit_response.uuid)
|
||||
if not getattr(verify_response, 'uuid', None):
|
||||
raise Exception('wallet verify returned no uuid')
|
||||
except Exception:
|
||||
self._set_state(PaymentStateChoices.EXPECTED_FAILURE)
|
||||
raise
|
||||
|
||||
if verify_response.state != 5:
|
||||
self._set_state(PaymentStateChoices.FAILED)
|
||||
return False
|
||||
|
||||
self._set_state(PaymentStateChoices.SUCCESS)
|
||||
return True
|
||||
|
||||
|
||||
def rollback_promotion_payout(user_uuid, event_label):
|
||||
"""Reverse a promotion payout for ``(user_uuid, event_label)`` back out of
|
||||
the advertising transit wallet into the promotions credit wallet.
|
||||
|
||||
The promotion stays consumed -- the user cannot earn it again -- only the
|
||||
money moves, and only if the payout actually landed in the transit wallet.
|
||||
Idempotent. Returns ``(status, amount)`` where status is one of
|
||||
``'reversed'`` (money moved), ``'deferred'`` (payout not processed yet --
|
||||
``Recipient.promote()`` will suppress it), ``'nothing'`` (nothing to
|
||||
reverse: zero payout, or paid straight to the user's wallet).
|
||||
"""
|
||||
with transaction.atomic():
|
||||
existing = PromotionTransaction.objects.select_for_update().filter(
|
||||
user_uuid=user_uuid, event_label=event_label,
|
||||
transaction_type=PromotionTransaction.TypeChoices.ROLLBACK,
|
||||
).first()
|
||||
if existing is not None and existing.state == PaymentStateChoices.SUCCESS:
|
||||
return ('reversed' if existing.amount else 'nothing', existing.amount)
|
||||
|
||||
payout = PromotionTransaction.objects.select_for_update().filter(
|
||||
user_uuid=user_uuid, event_label=event_label,
|
||||
transaction_type=PromotionTransaction.TypeChoices.PAYOUT,
|
||||
).first()
|
||||
|
||||
payout_in_flight = payout is not None and payout.state in IN_FLIGHT_PAYMENT_STATES
|
||||
promotion_settled_without_payout = payout is None and Promotion.objects.filter(
|
||||
user_uuid=user_uuid, event__label=event_label, state=PaymentStateChoices.SUCCESS,
|
||||
).exists()
|
||||
|
||||
if payout is not None and payout.state == PaymentStateChoices.SUCCESS \
|
||||
and payout.promotion_id and payout.promotion.is_rollbackable() and payout.amount:
|
||||
amount = payout.amount
|
||||
else:
|
||||
amount = 0
|
||||
|
||||
promo = payout.promotion if payout is not None else None
|
||||
|
||||
rollback, created = PromotionTransaction.objects.get_or_create(
|
||||
user_uuid=user_uuid, event_label=event_label,
|
||||
transaction_type=PromotionTransaction.TypeChoices.ROLLBACK,
|
||||
defaults=dict(
|
||||
promotion=promo,
|
||||
holder_wallet=settings.WALLET_ADVERTISING_TRANSIT,
|
||||
destination_wallet=settings.WALLET_PROMOTIONS_CREDIT,
|
||||
amount=amount,
|
||||
reverses=payout,
|
||||
),
|
||||
)
|
||||
if not created and rollback.state != PaymentStateChoices.SUCCESS:
|
||||
# refresh the amount/links now that the payout may have appeared
|
||||
PromotionTransaction.objects.filter(pk=rollback.pk).exclude(
|
||||
state=PaymentStateChoices.SUCCESS).update(
|
||||
promotion=promo, amount=amount, reverses=payout, updated_at=timezone.now())
|
||||
rollback.amount = amount
|
||||
|
||||
if payout is None and not promotion_settled_without_payout:
|
||||
return ('deferred', 0) # honoured later by Recipient.promote()
|
||||
if payout_in_flight:
|
||||
return ('deferred', 0)
|
||||
if amount == 0:
|
||||
rollback._set_state(PaymentStateChoices.SUCCESS)
|
||||
return ('nothing', 0)
|
||||
|
||||
rollback.execute() # raises on wallet failure -> caller aborts + retries
|
||||
return ('reversed', amount)
|
||||
|
|
|
|||
|
|
@ -84,6 +84,21 @@ class UserRecipientSerializer(serializers.ModelSerializer):
|
|||
"base_amount_field"]
|
||||
|
||||
|
||||
class PromotionStatusSerializer(serializers.Serializer):
|
||||
event_label = serializers.CharField(read_only=True)
|
||||
processed = serializers.BooleanField(read_only=True)
|
||||
promotion_amount = serializers.IntegerField(read_only=True, allow_null=True)
|
||||
|
||||
|
||||
class PromotionRollbackSerializer(serializers.Serializer):
|
||||
event_label = serializers.CharField(read_only=True)
|
||||
# reversed -> money moved back out of the advertising transit wallet
|
||||
# deferred -> payout not settled yet; it will be suppressed when it runs
|
||||
# nothing -> nothing to reverse (already rolled back, or paid to the user)
|
||||
status = serializers.ChoiceField(read_only=True, choices=['reversed', 'deferred', 'nothing'])
|
||||
amount = serializers.IntegerField(read_only=True)
|
||||
|
||||
|
||||
class UserPlanSerializer(serializers.ModelSerializer):
|
||||
recipients = UserRecipientSerializer(many=True, read_only=True)
|
||||
class Meta:
|
||||
|
|
|
|||
|
|
@ -1,10 +1,17 @@
|
|||
import logging
|
||||
|
||||
from apps.promotions.models import Event
|
||||
from main import celery_app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@celery_app.task
|
||||
def analyze_event_task(event_uuid):
|
||||
event = Event.objects.get(uuid=event_uuid)
|
||||
for plan_analyze_result in event.analyze():
|
||||
try:
|
||||
for promotion in plan_analyze_result:
|
||||
print(promotion)
|
||||
except Exception:
|
||||
logger.exception('Failed to process plan for event %s', event_uuid)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -65,7 +65,7 @@ class ApplicationApiFlowsTests(APITestCase):
|
|||
application_uuid = uuid.UUID(application_uuid_hex)
|
||||
application_access_token = '14naVsdKCbKNOhnElPyXcrwSnqqFbm'
|
||||
user_access_token = '24naVsdKCbKNOhnElPyXcrwSnqqFbm'
|
||||
user_1_uuid = uuid.UUID('b14e8b86-8f4a-44d9-b29d-badceb470051')
|
||||
user_1_uuid = uuid.UUID('b14e8b86-8f4a-44d9-b29d-badceb47005f')
|
||||
user_2_uuid = uuid.UUID('b14e8b86-8f4a-44d9-b29d-badceb470052')
|
||||
first_ad_view_reward_label = 'first_ad_view_reward_label'
|
||||
referral_reward_label = 'referral_reward_label'
|
||||
|
|
@ -611,10 +611,290 @@ class ApplicationApiFlowsTests(APITestCase):
|
|||
self.first_ad_view_reward_plan.refresh_from_db()
|
||||
self.assertEqual(self.first_ad_view_reward_plan.balance, 10000 - 800)
|
||||
|
||||
|
||||
# response = self.client.get(reverse('promotions:promotion-list', kwargs={"plan": self.first_ad_view_reward_plan.uuid}), event_create_data, HTTP_AUTHORIZATION=auth,
|
||||
# format='json')
|
||||
# print(response.json())
|
||||
|
||||
# response = self.client.post(reverse('promotions:promotion-create', kwargs={"plan": self.first_ad_view_reward_plan.uuid}), event_create_data, HTTP_AUTHORIZATION=auth, format='json')
|
||||
# print(response.json())
|
||||
|
||||
def test_application_promote_referral_reward_success(self):
|
||||
self.referral_reward_event_saver, _created = EventSaver.objects.get_or_create(
|
||||
title=self.referral_reward_label,
|
||||
event_label=self.referral_reward_label,
|
||||
save_once=True,
|
||||
)
|
||||
|
||||
self.referral_reward_plan, _created = Plan.objects.get_or_create(
|
||||
title=self.referral_reward_label,
|
||||
balance=10000,
|
||||
user=self.user_1,
|
||||
application=self.application,
|
||||
event_list=[self.referral_reward_label]
|
||||
)
|
||||
|
||||
self.referral_reward_recipient, _created = Recipient.objects.get_or_create(
|
||||
label=self.referral_reward_label,
|
||||
plan=self.referral_reward_plan,
|
||||
wallet_uuid=self.user_1_uuid,
|
||||
recipient_uuid_field="->event:referral",
|
||||
base_amount_field="1000",
|
||||
|
||||
)
|
||||
|
||||
auth = self._create_authorization_header(self.application_access_token.token)
|
||||
event_create_data = {
|
||||
'label': self.referral_reward_label,
|
||||
"data": {
|
||||
'user': self.user_1_uuid,
|
||||
'referral': self.user_2_uuid
|
||||
}
|
||||
}
|
||||
url = reverse('promotions-application:application-user-plan-promote',
|
||||
kwargs={
|
||||
"pk": str(self.referral_reward_plan.uuid),
|
||||
"user_uuid": str(self.user_1_uuid)
|
||||
}
|
||||
)
|
||||
response = self.client.post(url, event_create_data, HTTP_AUTHORIZATION=auth, format='json')
|
||||
print(response.json())
|
||||
self.referral_reward_plan.refresh_from_db()
|
||||
self.assertEqual(self.referral_reward_plan.balance, 10000 - 1000)
|
||||
self.assertEqual(response.json()['promotions'][0]['user'], str(self.user_2_uuid))
|
||||
|
||||
def test_application_get_promotion_plan_success(self):
|
||||
self.first_ad_view_reward_event_saver, _created = EventSaver.objects.get_or_create(
|
||||
title=self.first_ad_view_reward_label,
|
||||
event_label=self.first_ad_view_reward_label,
|
||||
save_once=True,
|
||||
)
|
||||
|
||||
self.first_ad_view_reward_plan, _created = Plan.objects.get_or_create(
|
||||
title=self.first_ad_view_reward_label,
|
||||
balance=10000,
|
||||
user=self.user_1,
|
||||
application=self.application,
|
||||
event_list=[self.first_ad_view_reward_label]
|
||||
)
|
||||
|
||||
self.first_ad_view_reward_recipient, _created = Recipient.objects.get_or_create(
|
||||
label=self.first_ad_view_reward_label,
|
||||
plan=self.first_ad_view_reward_plan,
|
||||
wallet_uuid=self.user_1_uuid,
|
||||
recipient_uuid_field="->event:user",
|
||||
base_amount_field="event:base_amount",
|
||||
|
||||
)
|
||||
|
||||
auth = self._create_authorization_header(self.application_access_token.token)
|
||||
event_create_data = {
|
||||
'label': self.first_ad_view_reward_label,
|
||||
"data": {
|
||||
'user': self.user_1_uuid,
|
||||
'base_amount': 800
|
||||
}
|
||||
}
|
||||
|
||||
url_promote = reverse('promotions-application:application-user-plan-promote',
|
||||
kwargs={"pk": self.first_ad_view_reward_plan.uuid,
|
||||
"user_uuid": '1f9c72e4-3cc3-420d-928b-b0a3d65a4611'})
|
||||
response = self.client.post(url_promote, event_create_data, HTTP_AUTHORIZATION=auth, format='json')
|
||||
print(response.json())
|
||||
|
||||
def test_application_user__user_not_fount__404(self):
|
||||
self.first_ad_view_reward_plan, _created = Plan.objects.get_or_create(
|
||||
title=self.first_ad_view_reward_label,
|
||||
balance=10000,
|
||||
user=self.user_1,
|
||||
application=self.application,
|
||||
event_list=[self.first_ad_view_reward_label]
|
||||
)
|
||||
|
||||
auth = self._create_authorization_header(self.application_access_token.token)
|
||||
event_create_data = {}
|
||||
|
||||
url_promote = reverse('promotions-application:application-user-plan-promote',
|
||||
kwargs={"pk": self.first_ad_view_reward_plan.uuid,
|
||||
"user_uuid": '11111111-1111-1111-1111-111111111111'})
|
||||
response = self.client.post(url_promote, event_create_data, HTTP_AUTHORIZATION=auth, format='json')
|
||||
print(response.json())
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_application_event_viewset_create_success(self):
|
||||
EventSaver.objects.get_or_create(
|
||||
title=self.first_ad_view_reward_label,
|
||||
event_label=self.first_ad_view_reward_label,
|
||||
save_once=False,
|
||||
)
|
||||
|
||||
auth = self._create_authorization_header(self.application_access_token.token)
|
||||
event_create_data = {
|
||||
'label': self.first_ad_view_reward_label,
|
||||
'data': {'foo': 'bar'},
|
||||
}
|
||||
|
||||
url = reverse('promotions-application:event-list', kwargs={'user_uuid': str(self.user_1_uuid)})
|
||||
response = self.client.post(url, event_create_data, HTTP_AUTHORIZATION=auth, format='json')
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
event = Event.objects.get(label=self.first_ad_view_reward_label)
|
||||
self.assertEqual(event.user, self.user_1_uuid)
|
||||
|
||||
def test_application_event_viewset_create_duplicate_returns_unprocessable_entity(self):
|
||||
EventSaver.objects.get_or_create(
|
||||
title=self.first_ad_view_reward_label,
|
||||
event_label=self.first_ad_view_reward_label,
|
||||
save_once=True,
|
||||
)
|
||||
|
||||
auth = self._create_authorization_header(self.application_access_token.token)
|
||||
event_create_data = {
|
||||
'label': self.first_ad_view_reward_label,
|
||||
'data': {'foo': 'bar'},
|
||||
}
|
||||
|
||||
url = reverse('promotions-application:event-list', kwargs={'user_uuid': str(self.user_1_uuid)})
|
||||
first_response = self.client.post(url, event_create_data, HTTP_AUTHORIZATION=auth, format='json')
|
||||
self.assertEqual(first_response.status_code, 201)
|
||||
|
||||
second_response = self.client.post(url, event_create_data, HTTP_AUTHORIZATION=auth, format='json')
|
||||
self.assertEqual(second_response.status_code, 422)
|
||||
|
||||
def _create_first_ad_create_plan(self):
|
||||
event_label = 'ads::first-ad-create'
|
||||
plan_title = 'first-ad-create'
|
||||
promotion_amount = 30000
|
||||
|
||||
EventSaver.objects.get_or_create(
|
||||
event_label=event_label,
|
||||
defaults={
|
||||
'title': plan_title,
|
||||
'save_once': True,
|
||||
},
|
||||
)
|
||||
|
||||
plan, _ = Plan.objects.get_or_create(
|
||||
title=plan_title,
|
||||
defaults={
|
||||
'balance': promotion_amount * 1000,
|
||||
'user': self.user_1,
|
||||
'application': self.application,
|
||||
'event_list': [event_label],
|
||||
'processor': ProcessorTypeChoices.OTHERS,
|
||||
},
|
||||
)
|
||||
|
||||
Recipient.objects.update_or_create(
|
||||
label=plan_title,
|
||||
plan=plan,
|
||||
defaults={
|
||||
'recipient_uuid_field': '->event:user',
|
||||
'base_amount_field': str(promotion_amount),
|
||||
'wallet_destination': WalletDestinationChoices.ADVERTISING_TRANSIT,
|
||||
},
|
||||
)
|
||||
|
||||
return plan, event_label, promotion_amount
|
||||
|
||||
def test_first_ad_create_event_status_not_processed(self):
|
||||
plan, event_label, promotion_amount = self._create_first_ad_create_plan()
|
||||
auth = self._create_authorization_header(self.user_access_token.token)
|
||||
|
||||
response = self.client.get(
|
||||
reverse('promotions:event-status', kwargs={'event_label': event_label}),
|
||||
HTTP_AUTHORIZATION=auth,
|
||||
format='json',
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json(), {
|
||||
'event_label': event_label,
|
||||
'processed': False,
|
||||
'promotion_amount': promotion_amount,
|
||||
})
|
||||
|
||||
def test_first_ad_create_event_status_processed(self):
|
||||
plan, event_label, promotion_amount = self._create_first_ad_create_plan()
|
||||
auth = self._create_authorization_header(self.user_access_token.token)
|
||||
event_create_data = {
|
||||
'label': event_label,
|
||||
'data': {
|
||||
'user': str(self.user_1_uuid),
|
||||
},
|
||||
}
|
||||
|
||||
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_CREDIT)
|
||||
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,
|
||||
format='json',
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json(), {
|
||||
'event_label': event_label,
|
||||
'processed': True,
|
||||
'promotion_amount': None,
|
||||
})
|
||||
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)
|
||||
Event.objects.create(
|
||||
user=self.user_1_uuid,
|
||||
application=self.application,
|
||||
label=event_label,
|
||||
data={'user': str(self.user_1_uuid)},
|
||||
)
|
||||
|
||||
response = self.client.get(
|
||||
reverse('promotions:event-status', kwargs={'event_label': event_label}),
|
||||
HTTP_AUTHORIZATION=auth,
|
||||
format='json',
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json(), {
|
||||
'event_label': event_label,
|
||||
'processed': False,
|
||||
'promotion_amount': promotion_amount,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -54,7 +54,8 @@ class ApplicationRouter(DefaultRouter):
|
|||
|
||||
router = DefaultRouter()
|
||||
|
||||
router.register('plan', views_application.ApplicationPlanViewSet, basename='plan')
|
||||
router.register('plan', views_application.ApplicationUserPlanViewSet, basename='application-user-plan')
|
||||
# router.register('plan', views_application.ApplicationPlanViewSet, basename='application-plan')
|
||||
router.register('event', views_application.ApplicationEventViewSet, basename='event')
|
||||
|
||||
urlpatterns = router.urls
|
||||
|
|
|
|||
|
|
@ -5,7 +5,12 @@ from utils.router import ProfileRouter
|
|||
from . import views_user
|
||||
from django.urls import NoReverseMatch, path, re_path, include
|
||||
|
||||
from .views_user import ApplicationPromoteUserApiView, ApplicationEventSubmitAPIView, ApplicationPromotionListApiView
|
||||
from .views_user import (
|
||||
ApplicationPromoteUserApiView,
|
||||
ApplicationEventSubmitAPIView,
|
||||
ApplicationPromotionListApiView,
|
||||
ApplicationEventStatusApiView,
|
||||
)
|
||||
|
||||
app_name = 'promotions'
|
||||
|
||||
|
|
@ -19,6 +24,7 @@ router.register(r'plans', views_user.UserPlanViewSet, basename='user-plans')
|
|||
urlpatterns = [
|
||||
path('api/v1/plans/<plan>/', ApplicationPromoteUserApiView.as_view(), name='promotion-create'),
|
||||
path('api/v1/plans/<plan>/promotins', ApplicationPromotionListApiView.as_view(), name='promotion-list'),
|
||||
path('api/v1/events/<path:event_label>/status/', ApplicationEventStatusApiView.as_view(), name='event-status'),
|
||||
path('api/v1/events/submit', ApplicationEventSubmitAPIView.as_view(), name='event-create'),
|
||||
# path('api/v1/events/get', ApplicationEventRetrieveAPIView.as_view(), name='event-retrieve'),
|
||||
path('api/v2/', include(router.urls)),
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ from drf_spectacular.utils import extend_schema
|
|||
from rest_framework import mixins, status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework import exceptions
|
||||
from rest_framework.exceptions import NotFound
|
||||
from rest_framework.generics import CreateAPIView, get_object_or_404, RetrieveAPIView, ListAPIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.settings import api_settings
|
||||
|
|
@ -9,20 +10,30 @@ from rest_framework.viewsets import GenericViewSet
|
|||
|
||||
from apps.gooyal_oauth2.rest_framework import IsAuthenticatedOrTokenMatchesOASRequirements, TokenMatchesViewSetActions
|
||||
from apps.gooyal_oauth2.utils import get_application
|
||||
from utils.clients.accounts_client import get_user_info
|
||||
from utils.exceptions import UnprocessableEntity
|
||||
from .models import Plan, Promotion, EventSaver
|
||||
from .serializers import PlanSerializer, PromotionSerializer, EventSerializer, PromoteSerializer, UserPlanSerializer
|
||||
from .models import Plan, Promotion, EventSaver, get_event_status_for_user, rollback_promotion_payout
|
||||
from .serializers import (
|
||||
PlanSerializer,
|
||||
PromotionSerializer,
|
||||
EventSerializer,
|
||||
PromoteSerializer,
|
||||
UserPlanSerializer,
|
||||
PromotionStatusSerializer,
|
||||
PromotionRollbackSerializer,
|
||||
)
|
||||
from .tasks import analyze_event_task
|
||||
from ..users.models import User
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
|
||||
class ApplicationPlanViewSet(
|
||||
class ApplicationUserPlanViewSet(
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
# mixins.CreateModelMixin,
|
||||
GenericViewSet
|
||||
):
|
||||
serializer_class = PromotionSerializer
|
||||
serializer_class = UserPlanSerializer
|
||||
|
||||
permission_classes = [TokenMatchesViewSetActions]
|
||||
required_action_scopes = {
|
||||
|
|
@ -33,6 +44,18 @@ class ApplicationPlanViewSet(
|
|||
|
||||
def get_queryset(self):
|
||||
application = get_application(self.request)
|
||||
if not self.request.user:
|
||||
user_uuid = self.kwargs.get('user_uuid')
|
||||
user = User.objects.filter(uuid=user_uuid).first()
|
||||
if not user:
|
||||
user_info = get_user_info(user_uuid)
|
||||
if user_info:
|
||||
user, created = User.objects.get_or_create(uuid=user_uuid)
|
||||
|
||||
if not user:
|
||||
raise NotFound(_('user {user_uuid} not found').format(user_uuid=user_uuid))
|
||||
|
||||
self.request.user = user
|
||||
# return Promotion.objects.filter(application=application)
|
||||
return Plan.objects.all()
|
||||
|
||||
|
|
@ -61,8 +84,8 @@ class ApplicationPlanViewSet(
|
|||
|
||||
# TODO
|
||||
# try:
|
||||
user_uuid = self.kwargs.get('user_uuid')
|
||||
event = event_saver.save_event(user=user_uuid, application=application, **serializer.validated_data)
|
||||
|
||||
event = event_saver.save_event(user=self.request.user.pk, application=application, **serializer.validated_data)
|
||||
|
||||
# except Exception as e:
|
||||
# raise exceptions.ValidationError(str(e))
|
||||
|
|
@ -90,25 +113,94 @@ class ApplicationEventViewSet(
|
|||
permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements]
|
||||
required_alternate_scopes = {
|
||||
"POST": [["promotions.application.event:submit"]],
|
||||
"GET": [["promotions.application.event:submit"]],
|
||||
}
|
||||
|
||||
def _resolve_user(self):
|
||||
user = getattr(self.request, 'user', None)
|
||||
if user and getattr(user, 'uuid', None):
|
||||
return user
|
||||
|
||||
user_uuid = self.kwargs.get('user_uuid')
|
||||
user = User.objects.filter(uuid=user_uuid).first()
|
||||
if not user:
|
||||
user_info = get_user_info(user_uuid)
|
||||
if user_info:
|
||||
user, _ = User.objects.get_or_create(uuid=user_uuid)
|
||||
|
||||
if not user:
|
||||
raise NotFound(_('user {user_uuid} not found').format(user_uuid=user_uuid))
|
||||
|
||||
self.request.user = user
|
||||
return user
|
||||
|
||||
@action(
|
||||
detail=False,
|
||||
methods=['GET'],
|
||||
url_path=r'(?P<event_label>.+)/status',
|
||||
serializer_class=PromotionStatusSerializer,
|
||||
)
|
||||
def status(self, request, user_uuid=None, event_label=None):
|
||||
user = self._resolve_user()
|
||||
serializer = self.get_serializer(get_event_status_for_user(user.uuid, event_label))
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(
|
||||
detail=False,
|
||||
methods=['POST'],
|
||||
url_path=r'(?P<event_label>.+)/rollback',
|
||||
serializer_class=PromotionRollbackSerializer,
|
||||
)
|
||||
def rollback(self, request, user_uuid=None, event_label=None):
|
||||
"""Pull a promotion's payout back out of the advertising transit wallet.
|
||||
|
||||
Idempotent. The promotion stays "used" -- only the money is returned,
|
||||
and only if it landed in the transit wallet (a payout straight to the
|
||||
user's wallet is not reversible). If the payout has not been processed
|
||||
yet the request is recorded and the payout is suppressed when it runs.
|
||||
"""
|
||||
user = self._resolve_user()
|
||||
state, amount = rollback_promotion_payout(user.uuid, event_label)
|
||||
|
||||
serializer = self.get_serializer({
|
||||
'event_label': event_label,
|
||||
'status': state,
|
||||
'amount': amount,
|
||||
})
|
||||
return Response(serializer.data)
|
||||
|
||||
def perform_create(self, serializer: EventSerializer):
|
||||
if hasattr(self.request, 'user'):
|
||||
user = self.request.user
|
||||
else:
|
||||
user = None
|
||||
user = self._resolve_user()
|
||||
|
||||
application = get_application(self.request)
|
||||
# EventSaver.objects.first(event_label=serializer.validated_data['event_label'], valid_application__contains=application.uuid)
|
||||
event_saver: EventSaver = EventSaver.objects.filter(event_label=serializer.validated_data['label']).first()
|
||||
try:
|
||||
event = event_saver.save_event(user=user, application=application, **serializer.validated_data)
|
||||
event = event_saver.save_event(user=user.uuid, application=application, **serializer.validated_data)
|
||||
except Exception as e:
|
||||
raise exceptions.ValidationError(str(e))
|
||||
raise UnprocessableEntity(str(e))
|
||||
|
||||
analyze_event_task.delay(event.uuid)
|
||||
serializer.instance = event
|
||||
|
||||
class ApplicationPlanViewSet(
|
||||
GenericViewSet
|
||||
):
|
||||
serializer_class = PlanSerializer
|
||||
|
||||
permission_classes = [TokenMatchesViewSetActions]
|
||||
required_action_scopes = {
|
||||
"retrieve": [["promotions.application.plan:list-retrieve"]],
|
||||
"list": [["promotions.application.plan:list-retrieve"]],
|
||||
}
|
||||
|
||||
def get_queryset(self):
|
||||
application = get_application(self.request)
|
||||
# return Promotion.objects.filter(application=application)
|
||||
return Plan.objects.all()
|
||||
|
||||
|
||||
|
||||
|
||||
# NOT
|
||||
class UserPlanViewSet(mixins.RetrieveModelMixin,
|
||||
|
|
|
|||
|
|
@ -9,8 +9,15 @@ from rest_framework.viewsets import GenericViewSet
|
|||
from apps.gooyal_oauth2.rest_framework import IsAuthenticatedOrTokenMatchesOASRequirements
|
||||
from apps.gooyal_oauth2.utils import get_application
|
||||
from utils.exceptions import UnprocessableEntity
|
||||
from .models import Plan, Promotion, EventSaver
|
||||
from .serializers import PlanSerializer, PromotionSerializer, EventSerializer, PromoteSerializer, UserPlanSerializer
|
||||
from .models import Plan, Promotion, EventSaver, get_event_status_for_user
|
||||
from .serializers import (
|
||||
PlanSerializer,
|
||||
PromotionSerializer,
|
||||
EventSerializer,
|
||||
PromoteSerializer,
|
||||
UserPlanSerializer,
|
||||
PromotionStatusSerializer,
|
||||
)
|
||||
from .tasks import analyze_event_task
|
||||
from ..users.models import User
|
||||
|
||||
|
|
@ -107,6 +114,21 @@ class ApplicationPromoteUserApiView(CreateAPIView):
|
|||
headers = self.get_success_headers(serializer.data)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
||||
|
||||
class ApplicationEventStatusApiView(RetrieveAPIView):
|
||||
serializer_class = PromotionStatusSerializer
|
||||
|
||||
permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements]
|
||||
required_alternate_scopes = {
|
||||
"GET": [[]],
|
||||
}
|
||||
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
event_label = kwargs['event_label']
|
||||
user_uuid = request.user.uuid
|
||||
serializer = self.get_serializer(get_event_status_for_user(user_uuid, event_label))
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class ApplicationPromotionListApiView(ListAPIView):
|
||||
model = Promotion
|
||||
serializer_class = PromotionSerializer
|
||||
|
|
|
|||
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_CREDIT` — 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_CREDIT` 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_CREDIT = config('WALLET_PROMOTIONS_CREDIT', 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_CREDIT, 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_CREDIT, 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_CREDIT` |
|
||||
| `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_CREDIT`. |
|
||||
| `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_CREDIT` 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_CREDIT` 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_CREDIT` 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_CREDIT` 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_CREDIT` — 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.
|
||||
|
|
@ -382,8 +382,17 @@ CELERY_TASK_TIME_LIMIT = 30 * 60
|
|||
CELERY_BROKER_URL = REDIS_BASE_URL
|
||||
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_CREDIT = config('WALLET_PROMOTIONS_CREDIT', 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)
|
||||
|
|
|
|||
89
utils/clients/accounts_client.py
Normal file
89
utils/clients/accounts_client.py
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import time
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from httpx import Request
|
||||
|
||||
from utils.clients.gooyal_accounts_client import AuthenticatedClient
|
||||
from utils.clients.gooyal_accounts_client.models import PublicUser, Application
|
||||
from utils.clients.gooyal_accounts_client.api.users import users_api_users_retrieve
|
||||
from utils.clients.gooyal_accounts_client.api.oauth2 import oauth2_apis_applications_retrieve
|
||||
from utils.clients.gooyal_accounts_client.models import Account
|
||||
from utils.clients.gooyal_accounts_client.api.users import (users_api_users_details_retrieve)
|
||||
|
||||
|
||||
def login_as_client_credentials():
|
||||
access_token = cache.get('promotions_access_token')
|
||||
# expires_in = self.auth_data.get('expires_in')
|
||||
# created_at = self.auth_data.get('created_at')
|
||||
#
|
||||
# if access_token and expires_in > time.time() - created_at:
|
||||
# return self.auth_data
|
||||
if access_token:
|
||||
print('already in cache')
|
||||
return access_token
|
||||
|
||||
print('not in cache')
|
||||
|
||||
data = {
|
||||
"grant_type": 'client_credentials',
|
||||
"scope": settings.OAUTH2_SCOPES,
|
||||
}
|
||||
auth = (settings.OAUTH2_CLIENT_ID, settings.OAUTH2_CLIENT_SECRET)
|
||||
|
||||
response = requests.post(f'{settings.OAUTH2_PROVIDER_PUBLIC_URL}/token/',
|
||||
data=data,
|
||||
auth=auth)
|
||||
|
||||
auth_data = response.json()
|
||||
|
||||
print(auth_data)
|
||||
if 'access_token' in auth_data:
|
||||
auth_data['created_at'] = time.time()
|
||||
cache.set('promotions_access_token', auth_data, timeout=auth_data.get('expires_in') - 5)
|
||||
|
||||
return auth_data
|
||||
|
||||
|
||||
def log_request(request: Request):
|
||||
print(request.headers['authorization'])
|
||||
print(
|
||||
f"Request event hook: {request.method} {request.url} {request.method} {request.headers} - Waiting for response")
|
||||
|
||||
|
||||
def log_response(response):
|
||||
request = response.request
|
||||
print(f"Response event hook: {request.method} {request.url} - Status {response.status_code}")
|
||||
|
||||
|
||||
def get_client():
|
||||
access_token = login_as_client_credentials()['access_token']
|
||||
client = AuthenticatedClient(base_url=settings.ACCOUNTS_BASE_PUBLIC_URL, token=access_token,
|
||||
httpx_args={"event_hooks": {"request": [log_request], "response": [log_response]}}
|
||||
)
|
||||
return client
|
||||
|
||||
|
||||
def get_user_info(uuid):
|
||||
client = get_client()
|
||||
response: PublicUser = users_api_users_retrieve.sync(client=client, uuid=uuid)
|
||||
print(response)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def get_user_detailed_info(uuid):
|
||||
client = get_client()
|
||||
response: Account = users_api_users_details_retrieve.sync(uuid=uuid, client=client)
|
||||
print(response)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def get_application_details(uuid):
|
||||
client = get_client()
|
||||
data: Application = oauth2_apis_applications_retrieve.sync(client=client, id=uuid)
|
||||
print(data)
|
||||
|
||||
return data
|
||||
|
|
@ -75,7 +75,9 @@ def get_notifications_client():
|
|||
return client
|
||||
|
||||
|
||||
def notifications_push_user(user_uuid, title, message, priority=5, extras=None):
|
||||
def notifications_push_user(user_uuid, title, message, priority=5, extras=None, click_url=None):
|
||||
# click_url is optional — most notifications aren't clickable. When set,
|
||||
# the notifications service embeds it as the tap destination.
|
||||
class Tmp():
|
||||
def to_dict(self):
|
||||
return {
|
||||
|
|
@ -83,6 +85,7 @@ def notifications_push_user(user_uuid, title, message, priority=5, extras=None):
|
|||
'message': message,
|
||||
'priority': priority,
|
||||
'extras': extras,
|
||||
'click_url': click_url,
|
||||
}
|
||||
|
||||
push_data = Tmp()
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ from django.core.cache import cache
|
|||
from httpx import Request
|
||||
|
||||
from utils.clients.gooyal_wallet_client import AuthenticatedClient
|
||||
from utils.clients.gooyal_wallet_client.models import Transaction, Account, ApplicationDeposit, ApplicationWithdraw, PaginatedAccountList
|
||||
from utils.clients.gooyal_wallet_client.models import Transaction, Account, ApplicationDeposit, ApplicationWithdraw, \
|
||||
PaginatedAccountList
|
||||
from utils.clients.gooyal_wallet_client.api.wallet import (wallet_api_application_deposit_create,
|
||||
wallet_api_application_deposit_verify_retrieve,
|
||||
wallet_api_application_withdraw_create,
|
||||
|
|
@ -14,10 +15,8 @@ from utils.clients.gooyal_wallet_client.api.wallet import (wallet_api_applicatio
|
|||
wallet_api_application_user_wallets_list)
|
||||
|
||||
|
||||
|
||||
|
||||
def login_as_client_credentials():
|
||||
access_token = cache.get('settlement_access_token')
|
||||
access_token = cache.get('promotions_access_token')
|
||||
# expires_in = self.auth_data.get('expires_in')
|
||||
# created_at = self.auth_data.get('created_at')
|
||||
#
|
||||
|
|
@ -38,12 +37,13 @@ def login_as_client_credentials():
|
|||
response = requests.post(f'{settings.OAUTH2_PROVIDER_PUBLIC_URL}/token/',
|
||||
data=data,
|
||||
auth=auth)
|
||||
|
||||
print(response.content)
|
||||
|
||||
auth_data = response.json()
|
||||
if 'access_token' in auth_data:
|
||||
auth_data['created_at'] = time.time()
|
||||
cache.set('settlement_access_token', auth_data, timeout=auth_data.get('expires_in') - 5)
|
||||
cache.set('promotions_access_token', auth_data, timeout=auth_data.get('expires_in') - 5)
|
||||
|
||||
return auth_data
|
||||
|
||||
|
|
@ -83,7 +83,8 @@ def deposit_to_user_wallet_submit(payer_wallet, data):
|
|||
|
||||
def deposit_to_user_wallet_verify(payer_wallet, uuid):
|
||||
client = get_client()
|
||||
response: ApplicationDeposit = wallet_api_application_deposit_verify_retrieve.sync(payer_wallet=payer_wallet, uuid=uuid, client=client)
|
||||
response: ApplicationDeposit = wallet_api_application_deposit_verify_retrieve.sync(payer_wallet=payer_wallet,
|
||||
uuid=uuid, client=client)
|
||||
print(response)
|
||||
return response
|
||||
|
||||
|
|
@ -95,14 +96,16 @@ def withdraw_from_user_wallet_submit(payee_wallet, data):
|
|||
|
||||
withdraw_data = Tmp()
|
||||
client = get_client()
|
||||
response: ApplicationWithdraw = wallet_api_application_withdraw_create.sync(payee_wallet=payee_wallet, client=client, body=withdraw_data)
|
||||
response: ApplicationWithdraw = wallet_api_application_withdraw_create.sync(payee_wallet=payee_wallet,
|
||||
client=client, body=withdraw_data)
|
||||
print(response)
|
||||
return response
|
||||
|
||||
|
||||
def withdraw_from_user_wallet_verify(payee_wallet, uuid):
|
||||
client = get_client()
|
||||
response: ApplicationWithdraw = wallet_api_application_withdraw_verify_retrieve.sync(payee_wallet=payee_wallet, uuid=uuid, client=client)
|
||||
response: ApplicationWithdraw = wallet_api_application_withdraw_verify_retrieve.sync(payee_wallet=payee_wallet,
|
||||
uuid=uuid, client=client)
|
||||
print(response)
|
||||
return response
|
||||
|
||||
|
|
@ -110,7 +113,8 @@ def withdraw_from_user_wallet_verify(payee_wallet, uuid):
|
|||
def get_user_wallets(owner, wallets):
|
||||
client = get_client()
|
||||
# by pass several wallet query params: [','.join([str(i) for i in wallets])]
|
||||
response: PaginatedAccountList = wallet_api_application_user_wallets_list.sync(owner=owner, wallet=[','.join([str(i) for i in wallets])], client=client)
|
||||
response: PaginatedAccountList = wallet_api_application_user_wallets_list.sync(owner=owner, wallet=[
|
||||
','.join([str(i) for i in wallets])], client=client)
|
||||
print(response)
|
||||
balances = dict(map(lambda result: (str(result.wallet), result.balance), response.results))
|
||||
return balances
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from rest_framework.exceptions import APIException
|
||||
from rest_framework.views import exception_handler as drf_exception_handler
|
||||
|
||||
|
|
@ -12,17 +14,36 @@ def exception_handler(exc, context):
|
|||
response = drf_exception_handler(exc, context)
|
||||
|
||||
if response is not None:
|
||||
# ساختار دلخواه خود را تعریف میکنیم
|
||||
response_data = {
|
||||
'success': False,
|
||||
'status_code': response.status_code,
|
||||
'message': 'An error occurred',
|
||||
'details': {}
|
||||
'status_message': str(getattr(exc, 'default_detail', exc)),
|
||||
'details': getattr(exc, 'detail', None),
|
||||
}
|
||||
|
||||
if isinstance(exc, APIException):
|
||||
response_data['message'] = exc.detail if isinstance(exc.detail, str) else "Validation error"
|
||||
response_data['details'] = exc.detail if isinstance(exc.detail, dict) else {}
|
||||
try:
|
||||
error = getattr(getattr(exc, 'detail', None), 'code', None) or getattr(exc, 'code', None)
|
||||
if not error:
|
||||
error = getattr(exc, 'default_code')
|
||||
except:
|
||||
error = ''
|
||||
|
||||
# if isinstance(exc.detail, str):
|
||||
# message = exc.detail
|
||||
# elif isinstance(exc.detail, dict):
|
||||
# if 'message' in exc.detail or 'string' in exc.detail:
|
||||
# message = exc.detail.get('message') or exc.detail.get('string')
|
||||
# else:
|
||||
# message = error
|
||||
#
|
||||
# else:
|
||||
# message = error
|
||||
#
|
||||
# response_data['message'] = message
|
||||
response_data['details'] = {"message": exc.detail}
|
||||
response_data['details']['error'] = error
|
||||
response_data['details']['timestamp'] = timezone.now().isoformat()
|
||||
|
||||
response.data = response_data
|
||||
|
||||
|
|
@ -47,16 +68,36 @@ class ErrorMiddleware:
|
|||
{
|
||||
"success": False,
|
||||
"status_code": 500,
|
||||
"message": "Internal server error",
|
||||
"details": str(exception) # فقط در حالت توسعه! در تولید بهتر است لاگ شود.
|
||||
"status_message": "Internal server error",
|
||||
"details": str(exception) if settings.DEBUG else None,
|
||||
},
|
||||
status=500
|
||||
)
|
||||
|
||||
|
||||
from rest_framework.exceptions import APIException
|
||||
from rest_framework import status
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class UnprocessableEntity(APIException):
|
||||
status_code = 422
|
||||
default_detail = 'The request was well-formed but cannot be processed due to semantic errors.'
|
||||
default_code = 'unprocessable_entity'
|
||||
|
||||
|
||||
class ServiceUnavailable(APIException):
|
||||
status_code = status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
default_ = _('SERVICE_UNAVAILABLE')
|
||||
default_code = 'service_unavailable'
|
||||
default_detail = 'Service Unavailable'
|
||||
|
||||
|
||||
class Conflict(APIException):
|
||||
status_code = status.HTTP_409_CONFLICT
|
||||
default_ = _('CONFLICT')
|
||||
default_code = 'conflict'
|
||||
default_detail = 'Conflict'
|
||||
|
||||
|
||||
# class NotFound(APIException):
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue