promotions/README.md
2026-08-31 18:27:16 +03:30

326 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.*