The first attempt at this inferred the payout destination from PromotionTypeChoices (first_ad_view/capture/first_ad_create), a general promotion-category field that had never held real values. That coupled two independent facts together — a promotion's category and where its money goes aren't the same thing, and every new promotion type would need someone to remember to also classify it for wallet purposes. Revert PromotionTypeChoices to empty (left as pre-existing dead scaffolding, untouched) and add Recipient.wallet_destination instead — a real field that states the routing decision directly. Recipient.get_wallet_category_uuid() now branches on it: user_reward (default) resolves to WALLET_USER_BILLBOARD_VISIT_INCOME, advertising_transit resolves to WALLET_ADVERTISING_TRANSIT. An explicit Recipient.wallet_uuid still wins over either. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
326 lines
21 KiB
Markdown
326 lines
21 KiB
Markdown
# 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_TRANSIT` | Company-side pool payouts are drawn from — the deposit's `payer_wallet` (formerly hardcoded to the same UUID as the payee side; see [`docs/wallet_refactor.md`](docs/wallet_refactor.md)). Needs a real UUID from the wallet-service team before this service can submit a deposit. |
|
||
| `WALLET_ADVERTISING_TRANSIT` | Same wallet-service UUID as advertising's own setting of the same name. `payee_wallet` when `Recipient.wallet_destination` is `advertising_transit` — billboard/ad credit rather than a user reward — see `Recipient.get_wallet_category_uuid()`. |
|
||
| `NOTIFICATIONS_BASE_PUBLIC_URL` | Notifications service REST API. |
|
||
| `OAUTH2_CLIENT_ID` / `_SECRET` / `_SCOPES` | This service's own client-credentials identity, used for every outbound call above via `login_as_client_credentials()` (token cached under the key `promotions_access_token`). |
|
||
|
||
---
|
||
|
||
*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.*
|