Passes through to the notifications service, which embeds it as the Gotify tap destination when set. Optional — no existing call site changes, most notifications stay non-clickable. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
||
|---|---|---|
| apps | ||
| main | ||
| run | ||
| static | ||
| templates | ||
| utils | ||
| .gitignore | ||
| Dockerfile | ||
| manage.py | ||
| README.md | ||
| README.srt | ||
| requirements.in | ||
| requirements.txt | ||
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 samePlan/Recipient/Promotionmodels.
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.
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 Recipients. |
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.)
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.
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.
-
Register the trigger event. Create an
EventSaverrow naming the event label the calling app will send, and whether it's once-per-user.EventSaver.objects.create( event_label="ads::first-ad-create", title="first-ad-create", save_once=True, # duplicate submits raise, don't re-pay ) -
Create the plan. Fund it with a
balanceand list the event label(s) that should trigger it.processoris required by the model but currently has no behavioral effect (see watch list) —OTHERSis the safe default.Plan.objects.create( title="first-ad-create", event_list=["ads::first-ad-create"], balance=30_000_000, processor=ProcessorTypeChoices.OTHERS, ) -
Attach a recipient. The common case: pay the user who fired the event, a flat amount, no percentage math.
Recipient.objects.create( plan=plan, label="first-ad-create", recipient_uuid_field="->event:user", base_amount_field="30000", # literal, or "event:data_key" ) -
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.
POST /api/v2/promotions/application/<user_uuid>/event/ { "label": "ads::first-ad-create", "data": {} } -
Watch it resolve. Async submits fan out through
analyze_event_taskto everyPlanwhoseevent_listmatches, each producing aPromotionrow 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 / WALLET_REWARD |
UUIDs of specific Wallet (currency pool) rows in the wallet service — not amounts. WALLET_REWARD is the pool promotions pays out of; passed as both the deposit's payer_wallet and its payee_wallet, since payer (this app's pool) and payee (the user) share one currency. |
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.