From 0e95fab85fe35af70e7d62abf3d1ff64646c6d66 Mon Sep 17 00:00:00 2001 From: Ali Asadi Date: Mon, 3 Aug 2026 16:18:32 +0330 Subject: [PATCH] DOCS: add service brief for promotions Co-Authored-By: Claude Sonnet 5 --- README.md | 322 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 322 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..80f157a --- /dev/null +++ b/README.md @@ -0,0 +1,322 @@ +# 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/