No description
Find a file
Ali Asadi c7af6bb14b feat(notifications): accept optional click_url in notifications_push_user
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>
2026-08-22 09:36:24 +03:30
apps FIX(promotions): resolve user UUID before saving application events 2026-08-18 14:26:14 +03:30
main get user from accounts 2026-07-07 16:42:34 +03:30
run new entry_point 2025-07-29 20:36:12 +03:30
static add -y 2025-07-29 20:49:43 +03:30
templates add static and template 2025-07-22 17:46:41 +03:30
utils feat(notifications): accept optional click_url in notifications_push_user 2026-08-22 09:36:24 +03:30
.gitignore init 2025-07-22 16:05:03 +03:30
Dockerfile add -y 2025-07-29 20:39:46 +03:30
manage.py init 2025-07-22 16:05:03 +03:30
README.md DOCS: add service brief for promotions 2026-08-03 16:18:32 +03:30
README.srt init 2025-07-22 16:05:03 +03:30
requirements.in basic promote api 2025-08-03 15:54:22 +03:30
requirements.txt update libs 2026-02-22 12:08:32 +03:30

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.

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.

  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.

    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.

    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.

    Recipient.objects.create(
        plan=plan,
        label="first-ad-create",
        recipient_uuid_field="->event:user",
        base_amount_field="30000",   # literal, or "event:data_key"
    )
    
  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.

    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 / 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.