# 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 | |---|---| | `` | 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
{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//deposit/ Wal-->>Promo: transaction uuid, state=PENDING Promo->>Wal: GET .../deposit//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//` 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/