Compare commits

...
Sign in to create a new pull request.

31 commits

Author SHA1 Message Date
8300f09003 fix(promotion): fix promotion not valid condition
fix promotion not valid condition
2026-09-14 16:21:26 +03:30
50e69d985c Merge pull request 'FIX/promotion-rollback-to-credit' (#9) from FIX/promotion-rollback-to-credit into master
Reviewed-on: #9
2026-09-05 09:54:36 -04:00
db2f0afcd5 Merge branch 'FIX/promotion-rollback-to-credit' of https://git.addwin.ir/addwin/promotions into FIX/promotion-rollback-to-credit
# Conflicts:
#	apps/promotions/admin.py
#	apps/promotions/models.py
#	apps/promotions/views_application.py
2026-09-01 15:10:25 +03:30
ea070ad293 FEATURE(promotions): wallet-transaction ledger + reversible payouts
The promotions service had no record of the wallet transfers it makes --
the Promotion row's state was the only trace. Add PromotionTransaction, a
ledger row per transfer (mirrors advertising's AdPayment / escrow's
EscrowWalletPayment): PAYOUT (promotions credit -> recipient wallet) and
ROLLBACK (advertising transit -> promotions credit).

- Promotion.promote() now drives its payout through a PromotionTransaction
  PAYOUT row (.execute() does the submit/verify dance) instead of an
  inline, unrecorded wallet call.
- rollback_promotion_payout(user, event_label): reverses a payout that
  landed in the advertising transit wallet, back to the promotions credit
  wallet, when the advertising side discards what it paid for (e.g. a
  captured billboard deleted while pending approval). The Promotion stays
  consumed -- only the money moves; payouts straight to the user's wallet
  are not reversible. Idempotent; returns reversed | deferred | nothing.
- The ROLLBACK row doubles as the async-race marker: when the request
  arrives before the Celery payout task has run, a ROLLBACK row is
  recorded and Recipient.promote() suppresses (or, if it raced, reverses)
  the payout. Replaces the separate PromotionRollback table from the first
  cut of this change.
- POST .../application/<user>/event/<event_label>/rollback/
- migration 0011 (hand-written; verified via makemigrations --dry-run + check)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 15:07:37 +03:30
18875af772 FEATURE(promotions): reversible promotion payouts to the advertising transit wallet
When the advertising service discards what a promotion paid for (e.g. a
captured billboard deleted while still pending approval), the promotion
money sitting in the advertising transit wallet needs to go back to the
promotions credit wallet. The promotion itself stays consumed -- only the
money is returned -- and only payouts that landed in the transit wallet
are reversible (a payout straight to the user's wallet is the user's).

- Promotion.rolled_back_at + rollback_to_credit(): row-locked, CAS-stamped,
  idempotent transfer transit -> credit for SUCCESS/transit-destined payouts.
- PromotionRollback model: keyed (user, event_label) -- all the advertising
  side knows. Handles the async race (payout runs in a Celery task, so the
  Promotion may not exist yet): Recipient.promote() checks for an unsettled
  request before paying (suppresses the payout) and after (reverses a
  payout that landed mid-request).
- POST .../event/<event_label>/rollback/ -> {status: reversed|deferred|nothing, amount}.
- migration 0011 (hand-written; verified via makemigrations --dry-run + check).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 14:47:05 +03:30
161664cee9 Merge pull request 'RENAME(promotions): WALLET_PROMOTIONS_TRANSIT -> WALLET_PROMOTIONS_CREDIT' (#8) from feature/wallet-changes into master
Reviewed-on: #8
2026-08-31 11:25:04 -04:00
8725cdf699 RENAME(promotions): WALLET_PROMOTIONS_TRANSIT -> WALLET_PROMOTIONS_CREDIT
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 18:27:16 +03:30
f68af8a8af Merge pull request 'feature/wallet-changes' (#7) from feature/wallet-changes into master
Reviewed-on: #7
2026-08-25 10:28:43 -04:00
d3800584b5 Merge branch 'master' into feature/wallet-changes
# Conflicts:
#	README.md
#	apps/promotions/models.py
#	main/settings.py
2026-08-25 17:49:45 +03:30
5c10096a5f Merge pull request 'feat(notifications): accept optional click_url in notifications_push_user' (#5) from feature/notification-client-update into master
Reviewed-on: #5
2026-08-25 09:50:53 -04:00
059de2914d Merge pull request 'FEATURE(wallets): rename WALLET_RIAL/WALLET_REWARD and add WALLET_PROMOTION' (#4) from feature/wallet-refactor into master
Reviewed-on: #4
2026-08-25 09:50:35 -04:00
0fe4a3ed79 FIX(promotions): route payouts by explicit Recipient.wallet_destination, not promotion type
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>
2026-08-25 17:12:01 +03:30
45a64f0013 FEATURE(promotions): route capture/first_ad_create payouts to advertising transit wallet
PromotionTypeChoices was an empty enum, so Recipient.promotion_type could
never hold a real value and every payout without an explicit
Recipient.wallet_uuid fell through to the same default wallet regardless of
what kind of promotion it was.

Give PromotionTypeChoices real values (first_ad_view, capture,
first_ad_create) and branch Recipient.get_wallet_category_uuid() on it:
first_ad_view (or unset) still pays into the user's cash-like reward wallet
(WALLET_USER_BILLBOARD_VISIT_INCOME); capture/first_ad_create are billboard/ad
credit, not a cash reward, so they route to the new WALLET_ADVERTISING_TRANSIT
setting (same wallet-service UUID as the advertising repo's own setting of
that name) instead. An explicit Recipient.wallet_uuid still wins over
promotion_type.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 16:57:44 +03:30
f59b8810d8 FEATURE(wallets): rename WALLET_RIAL/WALLET_REWARD, add WALLET_PROMOTIONS_TRANSIT, wire per-recipient routing
Promotion.promote() passed the same wallet UUID (WALLET_REWARD) as both
payer_wallet and payee_wallet, so every payout was routed out of and into
the same wallet type with no real company-owned pool. Add
WALLET_PROMOTIONS_TRANSIT as the dedicated payer_wallet, and rename
WALLET_RIAL/WALLET_REWARD to WALLET_RIAL_DEPOSIT/WALLET_USER_BILLBOARD_VISIT_INCOME
to match the naming used for the same wallet-service UUIDs in advertising/
settlement/ipg.

Also wires Recipient.get_wallet_category_uuid() (previously dead, and
falling back to an undefined setting) into the deposit call as payee_wallet,
so a Recipient can route its payout to its own wallet_uuid instead of every
payout hardcoding one constant.

Adds .env.example (none existed before) and docs/wallet_refactor.md
documenting the before/after and required .env changes per deploy target.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 13:27:49 +03:30
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
92bea29816 FEATURE(wallets): rename WALLET_RIAL/WALLET_REWARD and add WALLET_PROMOTION
WALLET_RIAL -> WALLET_RIAL_DEPOSIT, WALLET_REWARD -> WALLET_USER_BILLBOARD_VISIT_INCOME
to match the naming used for the same wallet-service UUIDs in the advertising/settlement
repos. Also introduces WALLET_PROMOTION as this service's own pool and routes
Promotion.promote()'s payer_wallet through it instead of reusing the payee's
WALLET_REWARD value.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 14:50:42 +03:30
079c795065 Merge pull request 'FIX(promotions): resolve user UUID before saving application events' (#3) from feature/debug-v2 into master
Reviewed-on: #3
2026-08-18 07:05:14 -04:00
1536016f14 FIX(promotions): resolve user UUID before saving application events
ApplicationEventViewSet.perform_create() was passing the full request.user
model instance into Event.user (a plain UUIDField), which raises on every
real call. Use the same _resolve_user() -> user.uuid pattern already used by
status(), and raise UnprocessableEntity on save_event failure to match the
v1 endpoint's error-handling convention.
2026-08-18 14:26:14 +03:30
1a63acee32 Merge pull request 'FIX(promotions): default event status promotion_amount to 0' (#2) from feature/allow-users into master
Reviewed-on: #2
2026-08-03 11:04:17 -04:00
54de7fdb48 PERF(promotions): collapse allowed-user check into a single query
Fold the restricted-recipient access check into the recipients
queryset itself via Q(public OR restricted-and-allowed), joining
against AllowedUser instead of running a separate lookup query.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 18:21:23 +03:30
33aa0ed1b4 PERF(promotions): batch allowed-user lookup in get_configured_promotion_amount
Checking each recipient via Recipient.is_user_allowed() inside the
loop issued one AllowedUser query per restricted recipient (N+1).
Fetch all matching AllowedUser rows for the plan's recipients in a
single query up front instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 17:55:42 +03:30
ac37f15bea FIX(promotions): default event status promotion_amount to 0
get_event_status_for_user returned None whenever the event was
already processed, no plan matched, or the user lacked access to the
recipient. Callers now always get a concrete amount: the real payout
if accessible, 0 otherwise.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 17:33:59 +03:30
c2a0c2f1d8 Merge pull request 'feature/allow-users' (#1) from feature/allow-users into master
Reviewed-on: #1
2026-08-03 09:24:13 -04:00
0e95fab85f DOCS: add service brief for promotions
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 16:18:32 +03:30
c4ff4cf4cb FEATURE(promotions): restrict recipients to allowed users
Add Recipient.access_type (restricted/public) and an AllowedUser
table linking users to the recipients they may be paid through.
Restricted recipients are now checked against AllowedUser both when
computing a promotion payout and in the event-status endpoints that
report eligibility before submission.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 16:11:33 +03:30
3cef84a346 FEATURE(promotions): adding promotion is used status for user
adding promotion is used status for user
2026-07-26 15:25:49 +03:30
ab348d5134 Merge pull request 'from FEATURE/refactore-application-views into master' (#2) from FEATURE/refactore-application-views into master
Reviewed-on: https://git.mindplus-dev.ir/gooyal/promotions/pulls/2
2026-07-11 06:58:18 -04:00
Sayyid Hamid Mahdavi
a8b10b79a5 user availability test 2026-07-08 12:05:42 +03:30
Sayyid Hamid Mahdavi
1eca11e4a4 better exceptions 2026-07-07 17:24:32 +03:30
Sayyid Hamid Mahdavi
e7db91d1da get user from accounts 2026-07-07 16:42:34 +03:30
Sayyid Hamid Mahdavi
3320ff93e3 temp 2026-07-07 12:10:19 +03:30
20 changed files with 1699 additions and 89 deletions

48
.env.example Normal file
View file

@ -0,0 +1,48 @@
# No .env.example was previously tracked in this repo (.env is gitignored).
# This file documents every var read via decouple's config() in main/settings.py.
# Copy to .env and fill in real values before running the service.
SECRET_KEY=
DEBUG=True
DB_NAME=
DB_USER=
DB_PASSWORD=
DB_HOST=127.0.0.1
DB_PORT=5432
OAUTH2_PROVIDER_PUBLIC_URL=
OAUTH2_PROVIDER_PRIVATE_URL=
OAUTH2_CLIENT_ID=
OAUTH2_CLIENT_SECRET=
OAUTH2_SCOPES=
REDIS_BASE_URL=
LOKI_BASE_PUBLIC_URL=
MINIO_ENDPOINT=drive.gooyal.com
MINIO_USE_HTTPS=True
MINIO_EXTERNAL_ENDPOINT=drive.gooyal.com
MINIO_EXTERNAL_ENDPOINT_USE_HTTPS=True
MINIO_ACCESS_KEY=
MINIO_SECRET_KEY=
MINIO_MEDIA_FILES_BUCKET=
ACCOUNTS_BASE_PUBLIC_URL=
WALLET_BASE_PUBLIC_URL=
WALLET_RIAL_DEPOSIT=
WALLET_USER_BILLBOARD_VISIT_INCOME=
# NEW — wallet-naming refactor (2026-08-25), matches advertising/settlement/ipg.
# Do not reuse WALLET_RIAL_DEPOSIT or WALLET_USER_BILLBOARD_VISIT_INCOME here — this must
# be a distinct, dedicated platform wallet the wallet-service team provisions for promotions.
# TODO(you): replace with the real wallet-type UUID from the wallet service.
WALLET_PROMOTIONS_CREDIT=00000000-0000-0000-0000-000000000000
# NEW — per-promotion-type wallet routing (2026-08-25). Same wallet-service UUID as
# advertising's own WALLET_ADVERTISING_TRANSIT setting — copy that repo's real value here,
# don't provision a second one.
WALLET_ADVERTISING_TRANSIT=00000000-0000-0000-0000-000000000000
NOTIFICATIONS_BASE_PUBLIC_URL=

326
README.md Normal file
View file

@ -0,0 +1,326 @@
# 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_CREDIT` | 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.*

View file

@ -2,17 +2,27 @@ from functools import update_wrapper
from django.contrib import admin
from .models import Promotion, Plan, Event, Recipient, EventSaver
from .models import Promotion, Plan, Event, Recipient, EventSaver, AllowedUser, PromotionTransaction
class PromotionAdmin(admin.ModelAdmin):
list_display = ['user_uuid', 'plan', 'promotion_amount', 'recipient', 'event', 'created_at']
list_display = ['user_uuid', 'plan', 'promotion_amount', 'recipient', 'event', 'state', 'created_at']
class PromotionTransactionAdmin(admin.ModelAdmin):
list_display = ['user_uuid', 'event_label', 'transaction_type', 'amount', 'state', 'promotion', 'reverses', 'created_at']
list_filter = ['transaction_type', 'state']
search_fields = ['user_uuid', 'event_label']
class PlanAdmin(admin.ModelAdmin):
list_display = ['uuid', 'title', 'balance']
class AllowedUserAdmin(admin.ModelAdmin):
list_display = ['user', 'recipient', 'created_at']
admin.site.register(Plan, PlanAdmin)
admin.site.register(Promotion, PromotionAdmin)
admin.site.register(Event)
admin.site.register(EventSaver)
admin.site.register(Recipient)
admin.site.register(AllowedUser, AllowedUserAdmin)
admin.site.register(PromotionTransaction, PromotionTransactionAdmin)

View file

@ -0,0 +1,40 @@
import uuid
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('promotions', '0008_plan_balance_holder_alter_plan_description_details'),
]
operations = [
migrations.AddField(
model_name='recipient',
name='access_type',
field=models.CharField(choices=[('restricted', 'restricted'), ('public', 'public')],
db_index=True, default='public', max_length=64, verbose_name='access type'),
),
migrations.CreateModel(
name='AllowedUser',
fields=[
('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True,
serialize=False, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True, db_index=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE,
related_name='allowed_recipients', to=settings.AUTH_USER_MODEL)),
('recipient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE,
related_name='allowed_users', to='promotions.recipient')),
],
options={
'constraints': [
models.UniqueConstraint(fields=('user', 'recipient'), name='unique_allowed_user_recipient'),
],
},
),
]

View file

@ -0,0 +1,19 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('promotions', '0009_recipient_access_type_alloweduser'),
]
operations = [
migrations.AddField(
model_name='recipient',
name='wallet_destination',
field=models.CharField(choices=[('user_reward', 'user reward wallet'),
('advertising_transit', 'advertising transit wallet')],
db_index=True, default='user_reward', max_length=32,
verbose_name='wallet destination'),
),
]

View file

@ -0,0 +1,43 @@
import uuid
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('promotions', '0010_recipient_wallet_destination'),
]
operations = [
migrations.CreateModel(
name='PromotionTransaction',
fields=[
('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True, db_index=True)),
('user_uuid', models.UUIDField(db_index=True)),
('event_label', models.CharField(db_index=True, max_length=255)),
('transaction_type', models.IntegerField(choices=[(1, 'payout'), (2, 'rollback')], db_index=True)),
('holder_wallet', models.UUIDField()),
('destination_wallet', models.UUIDField()),
('amount', models.IntegerField(default=0)),
('state', models.IntegerField(choices=[(1, 'created'), (2, 'delayed'), (3, 'pending'), (4, 'incomplete'), (5, 'success'), (6, 'failed'), (7, 'expected_failure')], db_index=True, default=1)),
('external_uuid', models.UUIDField(default=uuid.uuid4, editable=False)),
('promotion', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transactions', to='promotions.promotion')),
('reverses', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='reversed_by', to='promotions.promotiontransaction')),
],
options={
'ordering': ['created_at'],
},
),
migrations.AddConstraint(
model_name='promotiontransaction',
constraint=models.UniqueConstraint(fields=('promotion', 'transaction_type'), name='unique_promotion_transaction_type'),
),
migrations.AddConstraint(
model_name='promotiontransaction',
constraint=models.UniqueConstraint(condition=models.Q(('transaction_type', 2)), fields=('user_uuid', 'event_label'), name='unique_promotion_rollback'),
),
]

View file

@ -3,7 +3,7 @@ import uuid
from django.conf import settings
from django.contrib.postgres.indexes import GinIndex
from django.db import models, transaction
from django.db.models import F
from django.db.models import F, Q
from django.utils import timezone
from django_minio_backend import MinioBackend, iso_date_prefix
@ -135,6 +135,26 @@ class Plan(BaseModel):
def __str__(self):
return self.title
def get_configured_promotion_amount(self, user_uuid=None):
access_filter = Q(access_type=RecipientTypeChoices.PUBLIC)
if user_uuid:
access_filter |= Q(access_type=RecipientTypeChoices.RESTRICTED, allowed_users__user__uuid=user_uuid)
recipients = self.recipients.filter(
recipient_uuid_field='->event:user' #TODO: instead of hard code use better soloution
).filter(access_filter).distinct()
amount = 0
for recipient in recipients:
base_amount, promotion_amount = recipient.get_promotion_amount()
amount += promotion_amount
return amount
def is_promotion_used_by(self, user_uuid):
return self.promotions.filter(user_uuid=user_uuid).exclude(
state__in=[PaymentStateChoices.FAILED, PaymentStateChoices.EXPECTED_FAILURE]
).exists()
def process_event(self, event=None):
# TODO: processor
return self.promote_all(event=event)
@ -168,12 +188,27 @@ class Plan(BaseModel):
return updated
class RecipientTypeChoices(models.TextChoices):
RESTRICTED = 'restricted', _('restricted')
PUBLIC = 'public', _('public')
class WalletDestinationChoices(models.TextChoices):
USER_REWARD = 'user_reward', _('user reward wallet')
ADVERTISING_TRANSIT = 'advertising_transit', _('advertising transit wallet')
class Recipient(BaseModel):
label = models.CharField(max_length=255, db_index=True)
plan = models.ForeignKey(Plan, on_delete=models.PROTECT, related_name='recipients', null=True, blank=True)
wallet_uuid = models.UUIDField(null=True, blank=True)
promotion_type = models.CharField(max_length=64, verbose_name=_('promotion type'), db_index=True,
choices=PromotionTypeChoices.choices, null=True, blank=True)
wallet_destination = models.CharField(max_length=32, verbose_name=_('wallet destination'), db_index=True,
choices=WalletDestinationChoices.choices,
default=WalletDestinationChoices.USER_REWARD)
access_type = models.CharField(max_length=64, verbose_name=_('access type'), db_index=True,
choices=RecipientTypeChoices.choices, default=RecipientTypeChoices.PUBLIC)
recipient_uuid_field = models.CharField(max_length=255, db_index=True)
base_amount_field = models.CharField(max_length=255, db_index=True)
@ -193,7 +228,20 @@ class Recipient(BaseModel):
return f"{self.label} --> {self.plan}"
def get_wallet_category_uuid(self):
return self.wallet_uuid or settings.WALLET_PROMOTION_CATEGORY_UUID
if self.wallet_uuid:
return self.wallet_uuid
if self.wallet_destination == WalletDestinationChoices.ADVERTISING_TRANSIT:
return settings.WALLET_ADVERTISING_TRANSIT
return settings.WALLET_USER_BILLBOARD_VISIT_INCOME
def is_user_allowed(self, user_uuid):
if self.access_type != RecipientTypeChoices.RESTRICTED:
return True
if not user_uuid:
return False
return self.allowed_users.filter(user__uuid=user_uuid).exists()
def get_recipient_uuid(self, plan=None, event=None):
if ':' in self.recipient_uuid_field and not self.recipient_uuid_field.startswith('QS:'):
@ -303,6 +351,9 @@ class Recipient(BaseModel):
if not recipient:
return None
if not self.is_user_allowed(recipient):
return None
base_amount, promotion_amount = self.get_promotion_amount(plan=plan, event=event)
promotion, created = Promotion.objects.get_or_create(
promotion_amount=promotion_amount,
@ -313,6 +364,24 @@ class Recipient(BaseModel):
base_amount=base_amount,
)
if recipient and promotion_amount and created:
def _pending_rollback():
return PromotionTransaction.objects.filter(
user_uuid=recipient, event_label=event.label,
transaction_type=PromotionTransaction.TypeChoices.ROLLBACK,
).exclude(state=PaymentStateChoices.SUCCESS).first()
pending_rollback = _pending_rollback()
if pending_rollback is not None:
# The advertising service already asked to reverse this promotion
# before it was even paid -- don't move the money at all.
promotion.change_state(from_states=[promotion.state],
to_state=PaymentStateChoices.SUCCESS, same_ok=True)
PromotionTransaction.objects.filter(pk=pending_rollback.pk).update(
promotion=promotion, amount=0, state=PaymentStateChoices.SUCCESS,
updated_at=timezone.now())
promotion.refresh_from_db()
return promotion
try:
with transaction.atomic():
reserved = self.plan.reserve_promotion_amount(promotion_amount)
@ -325,10 +394,28 @@ class Recipient(BaseModel):
same_ok=True)
promotion.refresh_from_db()
# A rollback request may have arrived while the payout above was in
# flight (so the pre-check missed it). Reverse it now that it has settled.
if _pending_rollback() is not None:
rollback_promotion_payout(recipient, event.label)
return promotion
return None
class AllowedUser(BaseModel):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='allowed_recipients')
recipient = models.ForeignKey(Recipient, on_delete=models.CASCADE, related_name='allowed_users')
class Meta:
constraints = [
models.UniqueConstraint(fields=['user', 'recipient'], name='unique_allowed_user_recipient')
]
def __str__(self):
return f"{self.user} -> {self.recipient}"
class PaymentStateChoices(models.IntegerChoices):
CREATED = 1, _('created')
DELAYED = 2, _('delayed') # delayed as user wish
@ -343,6 +430,23 @@ class PromotionQuerySet(models.QuerySet):
def used(self, user):
return self.filter(user=user, state__in=[Promotion.PaymentStateChoices.CREATED])
def is_processed_for_user(self, user_uuid, event_label):
return self.filter(user_uuid=user_uuid, event__label=event_label).exists()
def get_event_status_for_user(user_uuid, event_label):
processed = Promotion.objects.is_processed_for_user(user_uuid, event_label)
promotion_amount = 0
if not processed:
plan = Plan.objects.filter(event_list__icontains=event_label).first()
if plan:
promotion_amount = plan.get_configured_promotion_amount(user_uuid=user_uuid)
return {
'event_label': event_label,
'processed': processed,
'promotion_amount': promotion_amount,
}
class Promotion(BaseModel):
user_uuid = models.UUIDField() # user_uuid
@ -398,7 +502,7 @@ class Promotion(BaseModel):
if self.state not in [PaymentStateChoices.CREATED]:
raise Exception(_('Cannot promote. not in correct state'))
if not self.promotion_amount or self.plan.balance_holder:
if not self.promotion_amount:
to_not_payed = self.change_state(
from_states=[PaymentStateChoices.CREATED],
to_state=PaymentStateChoices.SUCCESS,
@ -416,58 +520,38 @@ class Promotion(BaseModel):
error_message='Cannot promote. not in current state'
)
payment_uuid = str(self.uuid)
data = {
"uuid": payment_uuid,
"payee": str(self.user_uuid),
"payee_type": 1,
"payee_wallet": settings.WALLET_REWARD,
"amount": self.promotion_amount,
"details": {
'description': str(_(self.recipient.label)),
'reference_id': str(self.pk),
'application_details_url': ''
},
}
try:
submit_response = deposit_to_user_wallet_submit(settings.WALLET_REWARD, data)
if not submit_response.uuid:
raise Exception('Failed to submit promotion. 1')
except Exception as e:
self.change_state(
from_states=[self.state],
to_state=PaymentStateChoices.FAILED,
same_ok=False,
raise_exception=True,
error_message='Failed to update state promotions'
# The wallet transfer itself is recorded and driven by a PromotionTransaction
# ledger row (mirrors advertising's AdPayment / escrow's EscrowWalletPayment),
# so every promotion payout has an auditable record and can be reversed.
payout, _created = PromotionTransaction.objects.get_or_create(
promotion=self,
transaction_type=PromotionTransaction.TypeChoices.PAYOUT,
defaults=dict(
user_uuid=self.user_uuid,
event_label=self.event.label if self.event_id else '',
holder_wallet=settings.WALLET_PROMOTIONS_CREDIT,
destination_wallet=self.recipient.get_wallet_category_uuid(),
amount=self.promotion_amount,
),
)
# return False
raise Exception('Failed to submit promotion. 2')
try:
verify_response = deposit_to_user_wallet_verify(settings.WALLET_REWARD, submit_response.uuid)
if not verify_response.uuid:
raise Exception('Failed to verify promotion. 1')
except Exception as e:
transferred = payout.execute()
except Exception:
self.change_state(
from_states=[self.state],
to_state=PaymentStateChoices.EXPECTED_FAILURE,
same_ok=False,
same_ok=True,
raise_exception=True,
error_message='Failed to update state'
error_message='Failed to update state',
)
# return False
raise Exception('Failed to verify promition. 2')
raise Exception('Failed to pay promotion')
if verify_response.state != 5:
to_pay_failed = self.change_state(
if not transferred:
self.change_state(
from_states=[self.state],
to_state=PaymentStateChoices.FAILED,
same_ok=False,
same_ok=True,
raise_exception=True,
error_message='Failed to update state payment',
)
@ -501,3 +585,192 @@ class Promotion(BaseModel):
error_message='Failed to update state payment',
)
return False
def is_rollbackable(self):
"""A promotion payout can only be reversed if it actually landed in the
advertising transit wallet. A payout made straight to the user's income
wallet is the user's money and is never clawed back."""
return bool(
self.recipient_id
and self.recipient.wallet_destination == WalletDestinationChoices.ADVERTISING_TRANSIT
)
def is_rolled_back(self):
return self.transactions.filter(
transaction_type=PromotionTransaction.TypeChoices.ROLLBACK,
state=PaymentStateChoices.SUCCESS,
).exists()
IN_FLIGHT_PAYMENT_STATES = (
PaymentStateChoices.CREATED,
PaymentStateChoices.PENDING,
PaymentStateChoices.DELAYED,
PaymentStateChoices.INCOMPLETE,
)
class PromotionTransaction(BaseModel):
"""Ledger of every wallet transfer the promotions service makes -- the
payout for a promotion, and any later reversal of it. Mirrors advertising's
``AdPayment`` and escrow's ``EscrowWalletPayment``: each row drives one
external wallet call through its own CREATED -> PENDING -> SUCCESS/FAILED
state, so the money movement is auditable and reversible.
``user_uuid`` / ``event_label`` are denormalised onto the row so a ROLLBACK
can be recorded before the async payout task has even created its
``Promotion`` (see :func:`rollback_promotion_payout` and
``Recipient.promote``).
"""
class TypeChoices(models.IntegerChoices):
PAYOUT = 1, _('payout') # promotions credit -> recipient wallet (transit or user)
ROLLBACK = 2, _('rollback') # advertising transit -> promotions credit
promotion = models.ForeignKey(Promotion, on_delete=models.SET_NULL, null=True, blank=True,
related_name='transactions')
user_uuid = models.UUIDField(db_index=True)
event_label = models.CharField(max_length=255, db_index=True)
transaction_type = models.IntegerField(choices=TypeChoices.choices, db_index=True)
holder_wallet = models.UUIDField() # 1st arg to deposit_to_user_wallet_submit
destination_wallet = models.UUIDField() # data['payee_wallet']
amount = models.IntegerField(default=0)
state = models.IntegerField(choices=PaymentStateChoices.choices,
default=PaymentStateChoices.CREATED, db_index=True)
external_uuid = models.UUIDField(default=uuid.uuid4, editable=False) # idempotency key to the wallet service
reverses = models.ForeignKey('self', on_delete=models.PROTECT, null=True, blank=True,
related_name='reversed_by')
class Meta:
ordering = ['created_at']
constraints = [
models.UniqueConstraint(fields=['promotion', 'transaction_type'],
name='unique_promotion_transaction_type'),
models.UniqueConstraint(fields=['user_uuid', 'event_label'],
condition=models.Q(transaction_type=2),
name='unique_promotion_rollback'),
]
def __str__(self):
return f"{self.get_transaction_type_display()} {self.amount} ({self.event_label})"
def _set_state(self, state):
PromotionTransaction.objects.filter(pk=self.pk).update(state=state, updated_at=timezone.now())
self.state = state
def execute(self):
"""Run the wallet transfer once. Returns True only on a confirmed
transfer; leaves the row FAILED / EXPECTED_FAILURE (and re-raises) on
error, same as the promote()/AdPayment style. No automatic retry."""
if self.state == PaymentStateChoices.SUCCESS:
return True
if self.state != PaymentStateChoices.CREATED:
return False
if not self.amount:
self._set_state(PaymentStateChoices.SUCCESS)
return True
self._set_state(PaymentStateChoices.PENDING)
data = {
"uuid": str(self.external_uuid),
"payee": str(self.user_uuid),
"payee_type": 1,
"payee_wallet": str(self.destination_wallet),
"amount": self.amount,
"details": {
'description': str(self.get_transaction_type_display()),
'reference_id': str(self.pk),
'application_details_url': '',
},
}
try:
submit_response = deposit_to_user_wallet_submit(str(self.holder_wallet), data)
if not getattr(submit_response, 'uuid', None):
raise Exception('wallet submit returned no uuid')
except Exception:
self._set_state(PaymentStateChoices.FAILED)
raise
try:
verify_response = deposit_to_user_wallet_verify(str(self.holder_wallet), submit_response.uuid)
if not getattr(verify_response, 'uuid', None):
raise Exception('wallet verify returned no uuid')
except Exception:
self._set_state(PaymentStateChoices.EXPECTED_FAILURE)
raise
if verify_response.state != 5:
self._set_state(PaymentStateChoices.FAILED)
return False
self._set_state(PaymentStateChoices.SUCCESS)
return True
def rollback_promotion_payout(user_uuid, event_label):
"""Reverse a promotion payout for ``(user_uuid, event_label)`` back out of
the advertising transit wallet into the promotions credit wallet.
The promotion stays consumed -- the user cannot earn it again -- only the
money moves, and only if the payout actually landed in the transit wallet.
Idempotent. Returns ``(status, amount)`` where status is one of
``'reversed'`` (money moved), ``'deferred'`` (payout not processed yet --
``Recipient.promote()`` will suppress it), ``'nothing'`` (nothing to
reverse: zero payout, or paid straight to the user's wallet).
"""
with transaction.atomic():
existing = PromotionTransaction.objects.select_for_update().filter(
user_uuid=user_uuid, event_label=event_label,
transaction_type=PromotionTransaction.TypeChoices.ROLLBACK,
).first()
if existing is not None and existing.state == PaymentStateChoices.SUCCESS:
return ('reversed' if existing.amount else 'nothing', existing.amount)
payout = PromotionTransaction.objects.select_for_update().filter(
user_uuid=user_uuid, event_label=event_label,
transaction_type=PromotionTransaction.TypeChoices.PAYOUT,
).first()
payout_in_flight = payout is not None and payout.state in IN_FLIGHT_PAYMENT_STATES
promotion_settled_without_payout = payout is None and Promotion.objects.filter(
user_uuid=user_uuid, event__label=event_label, state=PaymentStateChoices.SUCCESS,
).exists()
if payout is not None and payout.state == PaymentStateChoices.SUCCESS \
and payout.promotion_id and payout.promotion.is_rollbackable() and payout.amount:
amount = payout.amount
else:
amount = 0
promo = payout.promotion if payout is not None else None
rollback, created = PromotionTransaction.objects.get_or_create(
user_uuid=user_uuid, event_label=event_label,
transaction_type=PromotionTransaction.TypeChoices.ROLLBACK,
defaults=dict(
promotion=promo,
holder_wallet=settings.WALLET_ADVERTISING_TRANSIT,
destination_wallet=settings.WALLET_PROMOTIONS_CREDIT,
amount=amount,
reverses=payout,
),
)
if not created and rollback.state != PaymentStateChoices.SUCCESS:
# refresh the amount/links now that the payout may have appeared
PromotionTransaction.objects.filter(pk=rollback.pk).exclude(
state=PaymentStateChoices.SUCCESS).update(
promotion=promo, amount=amount, reverses=payout, updated_at=timezone.now())
rollback.amount = amount
if payout is None and not promotion_settled_without_payout:
return ('deferred', 0) # honoured later by Recipient.promote()
if payout_in_flight:
return ('deferred', 0)
if amount == 0:
rollback._set_state(PaymentStateChoices.SUCCESS)
return ('nothing', 0)
rollback.execute() # raises on wallet failure -> caller aborts + retries
return ('reversed', amount)

View file

@ -84,6 +84,21 @@ class UserRecipientSerializer(serializers.ModelSerializer):
"base_amount_field"]
class PromotionStatusSerializer(serializers.Serializer):
event_label = serializers.CharField(read_only=True)
processed = serializers.BooleanField(read_only=True)
promotion_amount = serializers.IntegerField(read_only=True, allow_null=True)
class PromotionRollbackSerializer(serializers.Serializer):
event_label = serializers.CharField(read_only=True)
# reversed -> money moved back out of the advertising transit wallet
# deferred -> payout not settled yet; it will be suppressed when it runs
# nothing -> nothing to reverse (already rolled back, or paid to the user)
status = serializers.ChoiceField(read_only=True, choices=['reversed', 'deferred', 'nothing'])
amount = serializers.IntegerField(read_only=True)
class UserPlanSerializer(serializers.ModelSerializer):
recipients = UserRecipientSerializer(many=True, read_only=True)
class Meta:

View file

@ -1,10 +1,17 @@
import logging
from apps.promotions.models import Event
from main import celery_app
logger = logging.getLogger(__name__)
@celery_app.task
def analyze_event_task(event_uuid):
event = Event.objects.get(uuid=event_uuid)
for plan_analyze_result in event.analyze():
try:
for promotion in plan_analyze_result:
print(promotion)
except Exception:
logger.exception('Failed to process plan for event %s', event_uuid)

View file

@ -10,7 +10,7 @@ from rest_framework.test import APITestCase, override_settings, APIClient
from apps.promotions.tasks import analyze_event_task
from apps.users.models import User
from apps.promotions.models import Plan, Promotion, EventSaver, ProcessorTypeChoices, Event, Recipient, \
PaymentStateChoices
PaymentStateChoices, WalletDestinationChoices
AccessToken = get_access_token_model()
Application = get_application_model()
@ -65,7 +65,7 @@ class ApplicationApiFlowsTests(APITestCase):
application_uuid = uuid.UUID(application_uuid_hex)
application_access_token = '14naVsdKCbKNOhnElPyXcrwSnqqFbm'
user_access_token = '24naVsdKCbKNOhnElPyXcrwSnqqFbm'
user_1_uuid = uuid.UUID('b14e8b86-8f4a-44d9-b29d-badceb470051')
user_1_uuid = uuid.UUID('b14e8b86-8f4a-44d9-b29d-badceb47005f')
user_2_uuid = uuid.UUID('b14e8b86-8f4a-44d9-b29d-badceb470052')
first_ad_view_reward_label = 'first_ad_view_reward_label'
referral_reward_label = 'referral_reward_label'
@ -611,10 +611,290 @@ class ApplicationApiFlowsTests(APITestCase):
self.first_ad_view_reward_plan.refresh_from_db()
self.assertEqual(self.first_ad_view_reward_plan.balance, 10000 - 800)
# response = self.client.get(reverse('promotions:promotion-list', kwargs={"plan": self.first_ad_view_reward_plan.uuid}), event_create_data, HTTP_AUTHORIZATION=auth,
# format='json')
# print(response.json())
# response = self.client.post(reverse('promotions:promotion-create', kwargs={"plan": self.first_ad_view_reward_plan.uuid}), event_create_data, HTTP_AUTHORIZATION=auth, format='json')
# print(response.json())
def test_application_promote_referral_reward_success(self):
self.referral_reward_event_saver, _created = EventSaver.objects.get_or_create(
title=self.referral_reward_label,
event_label=self.referral_reward_label,
save_once=True,
)
self.referral_reward_plan, _created = Plan.objects.get_or_create(
title=self.referral_reward_label,
balance=10000,
user=self.user_1,
application=self.application,
event_list=[self.referral_reward_label]
)
self.referral_reward_recipient, _created = Recipient.objects.get_or_create(
label=self.referral_reward_label,
plan=self.referral_reward_plan,
wallet_uuid=self.user_1_uuid,
recipient_uuid_field="->event:referral",
base_amount_field="1000",
)
auth = self._create_authorization_header(self.application_access_token.token)
event_create_data = {
'label': self.referral_reward_label,
"data": {
'user': self.user_1_uuid,
'referral': self.user_2_uuid
}
}
url = reverse('promotions-application:application-user-plan-promote',
kwargs={
"pk": str(self.referral_reward_plan.uuid),
"user_uuid": str(self.user_1_uuid)
}
)
response = self.client.post(url, event_create_data, HTTP_AUTHORIZATION=auth, format='json')
print(response.json())
self.referral_reward_plan.refresh_from_db()
self.assertEqual(self.referral_reward_plan.balance, 10000 - 1000)
self.assertEqual(response.json()['promotions'][0]['user'], str(self.user_2_uuid))
def test_application_get_promotion_plan_success(self):
self.first_ad_view_reward_event_saver, _created = EventSaver.objects.get_or_create(
title=self.first_ad_view_reward_label,
event_label=self.first_ad_view_reward_label,
save_once=True,
)
self.first_ad_view_reward_plan, _created = Plan.objects.get_or_create(
title=self.first_ad_view_reward_label,
balance=10000,
user=self.user_1,
application=self.application,
event_list=[self.first_ad_view_reward_label]
)
self.first_ad_view_reward_recipient, _created = Recipient.objects.get_or_create(
label=self.first_ad_view_reward_label,
plan=self.first_ad_view_reward_plan,
wallet_uuid=self.user_1_uuid,
recipient_uuid_field="->event:user",
base_amount_field="event:base_amount",
)
auth = self._create_authorization_header(self.application_access_token.token)
event_create_data = {
'label': self.first_ad_view_reward_label,
"data": {
'user': self.user_1_uuid,
'base_amount': 800
}
}
url_promote = reverse('promotions-application:application-user-plan-promote',
kwargs={"pk": self.first_ad_view_reward_plan.uuid,
"user_uuid": '1f9c72e4-3cc3-420d-928b-b0a3d65a4611'})
response = self.client.post(url_promote, event_create_data, HTTP_AUTHORIZATION=auth, format='json')
print(response.json())
def test_application_user__user_not_fount__404(self):
self.first_ad_view_reward_plan, _created = Plan.objects.get_or_create(
title=self.first_ad_view_reward_label,
balance=10000,
user=self.user_1,
application=self.application,
event_list=[self.first_ad_view_reward_label]
)
auth = self._create_authorization_header(self.application_access_token.token)
event_create_data = {}
url_promote = reverse('promotions-application:application-user-plan-promote',
kwargs={"pk": self.first_ad_view_reward_plan.uuid,
"user_uuid": '11111111-1111-1111-1111-111111111111'})
response = self.client.post(url_promote, event_create_data, HTTP_AUTHORIZATION=auth, format='json')
print(response.json())
self.assertEqual(response.status_code, 404)
def test_application_event_viewset_create_success(self):
EventSaver.objects.get_or_create(
title=self.first_ad_view_reward_label,
event_label=self.first_ad_view_reward_label,
save_once=False,
)
auth = self._create_authorization_header(self.application_access_token.token)
event_create_data = {
'label': self.first_ad_view_reward_label,
'data': {'foo': 'bar'},
}
url = reverse('promotions-application:event-list', kwargs={'user_uuid': str(self.user_1_uuid)})
response = self.client.post(url, event_create_data, HTTP_AUTHORIZATION=auth, format='json')
self.assertEqual(response.status_code, 201)
event = Event.objects.get(label=self.first_ad_view_reward_label)
self.assertEqual(event.user, self.user_1_uuid)
def test_application_event_viewset_create_duplicate_returns_unprocessable_entity(self):
EventSaver.objects.get_or_create(
title=self.first_ad_view_reward_label,
event_label=self.first_ad_view_reward_label,
save_once=True,
)
auth = self._create_authorization_header(self.application_access_token.token)
event_create_data = {
'label': self.first_ad_view_reward_label,
'data': {'foo': 'bar'},
}
url = reverse('promotions-application:event-list', kwargs={'user_uuid': str(self.user_1_uuid)})
first_response = self.client.post(url, event_create_data, HTTP_AUTHORIZATION=auth, format='json')
self.assertEqual(first_response.status_code, 201)
second_response = self.client.post(url, event_create_data, HTTP_AUTHORIZATION=auth, format='json')
self.assertEqual(second_response.status_code, 422)
def _create_first_ad_create_plan(self):
event_label = 'ads::first-ad-create'
plan_title = 'first-ad-create'
promotion_amount = 30000
EventSaver.objects.get_or_create(
event_label=event_label,
defaults={
'title': plan_title,
'save_once': True,
},
)
plan, _ = Plan.objects.get_or_create(
title=plan_title,
defaults={
'balance': promotion_amount * 1000,
'user': self.user_1,
'application': self.application,
'event_list': [event_label],
'processor': ProcessorTypeChoices.OTHERS,
},
)
Recipient.objects.update_or_create(
label=plan_title,
plan=plan,
defaults={
'recipient_uuid_field': '->event:user',
'base_amount_field': str(promotion_amount),
'wallet_destination': WalletDestinationChoices.ADVERTISING_TRANSIT,
},
)
return plan, event_label, promotion_amount
def test_first_ad_create_event_status_not_processed(self):
plan, event_label, promotion_amount = self._create_first_ad_create_plan()
auth = self._create_authorization_header(self.user_access_token.token)
response = self.client.get(
reverse('promotions:event-status', kwargs={'event_label': event_label}),
HTTP_AUTHORIZATION=auth,
format='json',
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {
'event_label': event_label,
'processed': False,
'promotion_amount': promotion_amount,
})
def test_first_ad_create_event_status_processed(self):
plan, event_label, promotion_amount = self._create_first_ad_create_plan()
auth = self._create_authorization_header(self.user_access_token.token)
event_create_data = {
'label': event_label,
'data': {
'user': str(self.user_1_uuid),
},
}
with patch('apps.promotions.models.deposit_to_user_wallet_submit',
side_effect=mock_submit_deposit_success) as submit_mock:
response = self.client.post(
reverse('promotions:promotion-create', kwargs={'plan': plan.uuid}),
event_create_data,
HTTP_AUTHORIZATION=auth,
format='json',
)
self.assertEqual(response.status_code, 201)
# first_ad_create is billboard/ad credit, not a cash-like user reward: the deposit
# is drawn from the promotions transit pool and credited to the advertising
# transit wallet, not the user's own reward wallet.
from django.conf import settings
submit_mock.assert_called_once()
call_payer_wallet, call_data = submit_mock.call_args.args
self.assertEqual(call_payer_wallet, settings.WALLET_PROMOTIONS_CREDIT)
self.assertEqual(call_data['payee_wallet'], settings.WALLET_ADVERTISING_TRANSIT)
response = self.client.get(
reverse('promotions:event-status', kwargs={'event_label': event_label}),
HTTP_AUTHORIZATION=auth,
format='json',
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {
'event_label': event_label,
'processed': True,
'promotion_amount': None,
})
plan.refresh_from_db()
self.assertEqual(plan.balance, promotion_amount * 1000 - promotion_amount)
def test_get_wallet_category_uuid_routing(self):
from django.conf import settings
default_recipient = Recipient()
self.assertEqual(default_recipient.get_wallet_category_uuid(),
settings.WALLET_USER_BILLBOARD_VISIT_INCOME)
user_reward_recipient = Recipient(wallet_destination=WalletDestinationChoices.USER_REWARD)
self.assertEqual(user_reward_recipient.get_wallet_category_uuid(),
settings.WALLET_USER_BILLBOARD_VISIT_INCOME)
transit_recipient = Recipient(wallet_destination=WalletDestinationChoices.ADVERTISING_TRANSIT)
self.assertEqual(transit_recipient.get_wallet_category_uuid(), settings.WALLET_ADVERTISING_TRANSIT)
explicit_wallet_uuid = uuid.uuid4()
override_recipient = Recipient(wallet_destination=WalletDestinationChoices.ADVERTISING_TRANSIT,
wallet_uuid=explicit_wallet_uuid)
self.assertEqual(override_recipient.get_wallet_category_uuid(), explicit_wallet_uuid)
def test_first_ad_create_event_status_not_processed_when_only_event_exists(self):
plan, event_label, promotion_amount = self._create_first_ad_create_plan()
auth = self._create_authorization_header(self.user_access_token.token)
Event.objects.create(
user=self.user_1_uuid,
application=self.application,
label=event_label,
data={'user': str(self.user_1_uuid)},
)
response = self.client.get(
reverse('promotions:event-status', kwargs={'event_label': event_label}),
HTTP_AUTHORIZATION=auth,
format='json',
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {
'event_label': event_label,
'processed': False,
'promotion_amount': promotion_amount,
})

View file

@ -54,7 +54,8 @@ class ApplicationRouter(DefaultRouter):
router = DefaultRouter()
router.register('plan', views_application.ApplicationPlanViewSet, basename='plan')
router.register('plan', views_application.ApplicationUserPlanViewSet, basename='application-user-plan')
# router.register('plan', views_application.ApplicationPlanViewSet, basename='application-plan')
router.register('event', views_application.ApplicationEventViewSet, basename='event')
urlpatterns = router.urls

View file

@ -5,7 +5,12 @@ from utils.router import ProfileRouter
from . import views_user
from django.urls import NoReverseMatch, path, re_path, include
from .views_user import ApplicationPromoteUserApiView, ApplicationEventSubmitAPIView, ApplicationPromotionListApiView
from .views_user import (
ApplicationPromoteUserApiView,
ApplicationEventSubmitAPIView,
ApplicationPromotionListApiView,
ApplicationEventStatusApiView,
)
app_name = 'promotions'
@ -19,6 +24,7 @@ router.register(r'plans', views_user.UserPlanViewSet, basename='user-plans')
urlpatterns = [
path('api/v1/plans/<plan>/', ApplicationPromoteUserApiView.as_view(), name='promotion-create'),
path('api/v1/plans/<plan>/promotins', ApplicationPromotionListApiView.as_view(), name='promotion-list'),
path('api/v1/events/<path:event_label>/status/', ApplicationEventStatusApiView.as_view(), name='event-status'),
path('api/v1/events/submit', ApplicationEventSubmitAPIView.as_view(), name='event-create'),
# path('api/v1/events/get', ApplicationEventRetrieveAPIView.as_view(), name='event-retrieve'),
path('api/v2/', include(router.urls)),

View file

@ -2,6 +2,7 @@ from drf_spectacular.utils import extend_schema
from rest_framework import mixins, status
from rest_framework.decorators import action
from rest_framework import exceptions
from rest_framework.exceptions import NotFound
from rest_framework.generics import CreateAPIView, get_object_or_404, RetrieveAPIView, ListAPIView
from rest_framework.response import Response
from rest_framework.settings import api_settings
@ -9,20 +10,30 @@ from rest_framework.viewsets import GenericViewSet
from apps.gooyal_oauth2.rest_framework import IsAuthenticatedOrTokenMatchesOASRequirements, TokenMatchesViewSetActions
from apps.gooyal_oauth2.utils import get_application
from utils.clients.accounts_client import get_user_info
from utils.exceptions import UnprocessableEntity
from .models import Plan, Promotion, EventSaver
from .serializers import PlanSerializer, PromotionSerializer, EventSerializer, PromoteSerializer, UserPlanSerializer
from .models import Plan, Promotion, EventSaver, get_event_status_for_user, rollback_promotion_payout
from .serializers import (
PlanSerializer,
PromotionSerializer,
EventSerializer,
PromoteSerializer,
UserPlanSerializer,
PromotionStatusSerializer,
PromotionRollbackSerializer,
)
from .tasks import analyze_event_task
from ..users.models import User
from django.utils.translation import gettext as _
class ApplicationPlanViewSet(
class ApplicationUserPlanViewSet(
mixins.RetrieveModelMixin,
mixins.ListModelMixin,
# mixins.CreateModelMixin,
GenericViewSet
):
serializer_class = PromotionSerializer
serializer_class = UserPlanSerializer
permission_classes = [TokenMatchesViewSetActions]
required_action_scopes = {
@ -33,6 +44,18 @@ class ApplicationPlanViewSet(
def get_queryset(self):
application = get_application(self.request)
if not self.request.user:
user_uuid = self.kwargs.get('user_uuid')
user = User.objects.filter(uuid=user_uuid).first()
if not user:
user_info = get_user_info(user_uuid)
if user_info:
user, created = User.objects.get_or_create(uuid=user_uuid)
if not user:
raise NotFound(_('user {user_uuid} not found').format(user_uuid=user_uuid))
self.request.user = user
# return Promotion.objects.filter(application=application)
return Plan.objects.all()
@ -61,8 +84,8 @@ class ApplicationPlanViewSet(
# TODO
# try:
user_uuid = self.kwargs.get('user_uuid')
event = event_saver.save_event(user=user_uuid, application=application, **serializer.validated_data)
event = event_saver.save_event(user=self.request.user.pk, application=application, **serializer.validated_data)
# except Exception as e:
# raise exceptions.ValidationError(str(e))
@ -90,25 +113,94 @@ class ApplicationEventViewSet(
permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements]
required_alternate_scopes = {
"POST": [["promotions.application.event:submit"]],
"GET": [["promotions.application.event:submit"]],
}
def _resolve_user(self):
user = getattr(self.request, 'user', None)
if user and getattr(user, 'uuid', None):
return user
user_uuid = self.kwargs.get('user_uuid')
user = User.objects.filter(uuid=user_uuid).first()
if not user:
user_info = get_user_info(user_uuid)
if user_info:
user, _ = User.objects.get_or_create(uuid=user_uuid)
if not user:
raise NotFound(_('user {user_uuid} not found').format(user_uuid=user_uuid))
self.request.user = user
return user
@action(
detail=False,
methods=['GET'],
url_path=r'(?P<event_label>.+)/status',
serializer_class=PromotionStatusSerializer,
)
def status(self, request, user_uuid=None, event_label=None):
user = self._resolve_user()
serializer = self.get_serializer(get_event_status_for_user(user.uuid, event_label))
return Response(serializer.data)
@action(
detail=False,
methods=['POST'],
url_path=r'(?P<event_label>.+)/rollback',
serializer_class=PromotionRollbackSerializer,
)
def rollback(self, request, user_uuid=None, event_label=None):
"""Pull a promotion's payout back out of the advertising transit wallet.
Idempotent. The promotion stays "used" -- only the money is returned,
and only if it landed in the transit wallet (a payout straight to the
user's wallet is not reversible). If the payout has not been processed
yet the request is recorded and the payout is suppressed when it runs.
"""
user = self._resolve_user()
state, amount = rollback_promotion_payout(user.uuid, event_label)
serializer = self.get_serializer({
'event_label': event_label,
'status': state,
'amount': amount,
})
return Response(serializer.data)
def perform_create(self, serializer: EventSerializer):
if hasattr(self.request, 'user'):
user = self.request.user
else:
user = None
user = self._resolve_user()
application = get_application(self.request)
# EventSaver.objects.first(event_label=serializer.validated_data['event_label'], valid_application__contains=application.uuid)
event_saver: EventSaver = EventSaver.objects.filter(event_label=serializer.validated_data['label']).first()
try:
event = event_saver.save_event(user=user, application=application, **serializer.validated_data)
event = event_saver.save_event(user=user.uuid, application=application, **serializer.validated_data)
except Exception as e:
raise exceptions.ValidationError(str(e))
raise UnprocessableEntity(str(e))
analyze_event_task.delay(event.uuid)
serializer.instance = event
class ApplicationPlanViewSet(
GenericViewSet
):
serializer_class = PlanSerializer
permission_classes = [TokenMatchesViewSetActions]
required_action_scopes = {
"retrieve": [["promotions.application.plan:list-retrieve"]],
"list": [["promotions.application.plan:list-retrieve"]],
}
def get_queryset(self):
application = get_application(self.request)
# return Promotion.objects.filter(application=application)
return Plan.objects.all()
# NOT
class UserPlanViewSet(mixins.RetrieveModelMixin,

View file

@ -9,8 +9,15 @@ from rest_framework.viewsets import GenericViewSet
from apps.gooyal_oauth2.rest_framework import IsAuthenticatedOrTokenMatchesOASRequirements
from apps.gooyal_oauth2.utils import get_application
from utils.exceptions import UnprocessableEntity
from .models import Plan, Promotion, EventSaver
from .serializers import PlanSerializer, PromotionSerializer, EventSerializer, PromoteSerializer, UserPlanSerializer
from .models import Plan, Promotion, EventSaver, get_event_status_for_user
from .serializers import (
PlanSerializer,
PromotionSerializer,
EventSerializer,
PromoteSerializer,
UserPlanSerializer,
PromotionStatusSerializer,
)
from .tasks import analyze_event_task
from ..users.models import User
@ -107,6 +114,21 @@ class ApplicationPromoteUserApiView(CreateAPIView):
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
class ApplicationEventStatusApiView(RetrieveAPIView):
serializer_class = PromotionStatusSerializer
permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements]
required_alternate_scopes = {
"GET": [[]],
}
def retrieve(self, request, *args, **kwargs):
event_label = kwargs['event_label']
user_uuid = request.user.uuid
serializer = self.get_serializer(get_event_status_for_user(user_uuid, event_label))
return Response(serializer.data)
class ApplicationPromotionListApiView(ListAPIView):
model = Promotion
serializer_class = PromotionSerializer

282
docs/wallet_refactor.md Normal file
View file

@ -0,0 +1,282 @@
# Wallet Refactor — 2026-08-25
Brings promotions' wallet settings and deposit routing in line with the naming/routing
convention already rolled out to `advertising`, `settlement`, and `ipg`. No behavior in
those sibling repos changed as part of this — this document covers promotions only, with
the sibling commits cited as precedent for why the shape of the fix looks the way it does.
---
## 1. The bug
`Promotion.promote()` (`apps/promotions/models.py`) submits every payout as a wallet
deposit. A deposit call takes two wallet references:
- `payer_wallet` — the call-target / URL param — the **company-side** pool the money is
drawn from.
- `payee_wallet` — a field in the request body — the **user-side** wallet type the
recipient is credited into.
Before this change, both were the same setting:
```python
"payee_wallet": settings.WALLET_REWARD,
...
submit_response = deposit_to_user_wallet_submit(settings.WALLET_REWARD, data)
...
verify_response = deposit_to_user_wallet_verify(settings.WALLET_REWARD, submit_response.uuid)
```
So every promotion payout was routed **out of and into the same wallet type** — there was
no real company-owned pool distinct from the user-side wallet category. This is the same
defect fixed in `settlement` (commit `cc2ffb9`, 2026-08-16):
> "Withdraw requests were passing the user's own source wallet (WALLET_RIAL/WALLET_REWARD)
> as the withdrawal's destination wallet param, so every settlement payout and commission
> was routed back into the same wallet type it came from."
and still open, but flagged, in `advertising`'s `wallet_service_integration.md` §6.2 for
`AdPayment.refund_balance()`.
A second, smaller bug rode along: `Recipient.get_wallet_category_uuid()` fell back to
`settings.WALLET_PROMOTION_CATEGORY_UUID` — a setting that was never defined anywhere in
`main/settings.py`. It happened to never be called from the live deposit path (which
hardcoded `WALLET_REWARD` instead), so this was latent, not yet crashing anything — but it
would have raised `AttributeError` the moment anyone wired it in, which is exactly what
this change does.
---
## 2. The fix
### 2.1 Settings renamed to match the shared cross-repo convention
The two user-side wallet-type UUIDs are the same wallet-service UUIDs referenced by
`advertising`, `settlement`, and `ipg` — they're renamed to match those repos' naming
(`advertising`, then propagated to `settlement` in `cc2ffb9` and `ipg` in `7dcb730`):
| Before | After |
|---|---|
| `WALLET_RIAL` | `WALLET_RIAL_DEPOSIT` |
| `WALLET_REWARD` | `WALLET_USER_BILLBOARD_VISIT_INCOME` |
| *(did not exist)* | `WALLET_PROMOTIONS_CREDIT` — new |
`WALLET_RIAL_DEPOSIT` isn't read by any code path in promotions today (it wasn't before
either, under its old name) — it's renamed for consistency and in case a future feature
here needs to read a user's rial balance via `get_user_wallets()`.
### 2.2 A dedicated company-side wallet for payouts
`WALLET_PROMOTIONS_CREDIT` is new: the company pool promotion payouts are drawn from,
used only as `payer_wallet` (the deposit call-target). It is never the same UUID as any
user-side wallet type. This is the promotions equivalent of `WALLET_SETTLEMENT_TRANSIT`
(settlement) / `WALLET_ADVERTISING_TRANSIT` (advertising).
**This is a required new environment variable.** It ships in `.env.example` as a
placeholder UUID (`00000000-...`) with a `TODO(you)` comment — same pattern as
settlement's `.env.example`. **It needs a real wallet-type UUID provisioned by the
wallet-service team before this service can submit a deposit in any environment**, and
your local/staging/prod `.env` files need the rename applied (`WALLET_RIAL` →
`WALLET_RIAL_DEPOSIT`, `WALLET_REWARD` → `WALLET_USER_BILLBOARD_VISIT_INCOME`) plus this
new key added, or `main/settings.py` will fail at startup with
`decouple.UndefinedValueError`.
### 2.3 Per-recipient destination routing wired in
`Recipient.get_wallet_category_uuid()` existed already (`self.wallet_uuid or <fallback>`)
but nothing called it — every payout hardcoded `WALLET_REWARD` as `payee_wallet`
regardless of what a `Recipient` might specify. It's now the actual source of
`payee_wallet` in `Promotion.promote()`:
```python
payee_wallet = self.recipient.get_wallet_category_uuid()
```
So a `Recipient` with its own `wallet_uuid` set now routes its payout to that wallet type
instead of the default; a `Recipient` with `wallet_uuid=None` falls back to
`WALLET_USER_BILLBOARD_VISIT_INCOME`. This mirrors `EscrowWalletPayment.destination_wallet`
in `advertising` — a per-row field read at call time instead of one hardcoded constant —
without inventing a new abstraction: `Recipient.wallet_uuid` and
`get_wallet_category_uuid()` already existed in this codebase, they just weren't
connected to anything.
---
## 3. Before / after, side by side
### `main/settings.py`
```diff
WALLET_BASE_PUBLIC_URL = config('WALLET_BASE_PUBLIC_URL', default=None, cast=str)
-WALLET_RIAL = config('WALLET_RIAL', cast=str)
-WALLET_REWARD = config('WALLET_REWARD', cast=str)
+WALLET_RIAL_DEPOSIT = config('WALLET_RIAL_DEPOSIT', cast=str)
+WALLET_USER_BILLBOARD_VISIT_INCOME = config('WALLET_USER_BILLBOARD_VISIT_INCOME', cast=str)
+# Company-side pool promotion payouts are drawn from; must be distinct from the user-side
+# wallet above. TODO(you): replace with the real wallet-type UUID from the wallet service.
+WALLET_PROMOTIONS_CREDIT = config('WALLET_PROMOTIONS_CREDIT', cast=str)
```
### `apps/promotions/models.py` — `Recipient.get_wallet_category_uuid()`
```diff
def get_wallet_category_uuid(self):
- return self.wallet_uuid or settings.WALLET_PROMOTION_CATEGORY_UUID
+ return self.wallet_uuid or settings.WALLET_USER_BILLBOARD_VISIT_INCOME
```
### `apps/promotions/models.py` — `Promotion.promote()`
```diff
payment_uuid = str(self.uuid)
+ payee_wallet = self.recipient.get_wallet_category_uuid()
+
data = {
"uuid": payment_uuid,
"payee": str(self.user_uuid),
"payee_type": 1,
- "payee_wallet": settings.WALLET_REWARD,
+ "payee_wallet": payee_wallet,
"amount": self.promotion_amount,
"details": {
'description': str(_(self.recipient.label)),
'reference_id': str(self.pk),
'application_details_url': ''
},
}
try:
- submit_response = deposit_to_user_wallet_submit(settings.WALLET_REWARD, data)
+ submit_response = deposit_to_user_wallet_submit(settings.WALLET_PROMOTIONS_CREDIT, data)
...
try:
- verify_response = deposit_to_user_wallet_verify(settings.WALLET_REWARD, submit_response.uuid)
+ verify_response = deposit_to_user_wallet_verify(settings.WALLET_PROMOTIONS_CREDIT, submit_response.uuid)
```
### What the deposit call looks like now, end to end
| | Before | After |
|---|---|---|
| `payer_wallet` (call-target, company money source) | `WALLET_REWARD` | `WALLET_PROMOTIONS_CREDIT` |
| `payee_wallet` (body, user-side credit type) | `WALLET_REWARD` (same UUID as source) | `Recipient.wallet_uuid`, falling back to `WALLET_USER_BILLBOARD_VISIT_INCOME` |
| Per-recipient routing | Not possible — one hardcoded constant | Possible — set `Recipient.wallet_uuid` |
---
## 4. Files touched
| File | Change |
|---|---|
| `main/settings.py` | Renamed `WALLET_RIAL`→`WALLET_RIAL_DEPOSIT`, `WALLET_REWARD`→`WALLET_USER_BILLBOARD_VISIT_INCOME`; added `WALLET_PROMOTIONS_CREDIT`. |
| `apps/promotions/models.py` | `Recipient.get_wallet_category_uuid()` fallback fixed to point at a setting that actually exists; `Promotion.promote()` now uses `WALLET_PROMOTIONS_CREDIT` as `payer_wallet` and `recipient.get_wallet_category_uuid()` as `payee_wallet`. |
| `.env.example` | New — didn't exist before. Documents every `config()` var read by `main/settings.py`, including the new wallet keys as placeholders. |
| `README.md` | Config reference table updated to the renamed/new settings. |
Not touched: `apps/promotions/tests.py` — its wallet mocks patch the client functions
directly (`patch('apps.promotions.models.deposit_to_user_wallet_submit', ...)`) rather
than asserting on which UUID was passed, so they don't need updating for this change, but
they also don't exercise the routing fix — there's no test asserting `payer_wallet` /
`payee_wallet` on the call. `apps/promotions/handlers.py` (the dead processor/handler
scaffolding noted in the README's watch list) — unrelated, left as-is.
---
## 5. What you need to do before this runs anywhere
1. Get a real wallet-type UUID for `WALLET_PROMOTIONS_CREDIT` from the wallet-service
team — it must be a genuine, dedicated pool, not a reused existing UUID (that's the
exact bug this change fixes).
2. In every environment's `.env` (local, staging, prod — none are checked into this repo):
- Rename `WALLET_RIAL` → `WALLET_RIAL_DEPOSIT` (same value, key renamed).
- Rename `WALLET_REWARD` → `WALLET_USER_BILLBOARD_VISIT_INCOME` (same value, key renamed).
- Add `WALLET_PROMOTIONS_CREDIT` with the real UUID from step 1.
3. Until step 2 is done in a given environment, `main/settings.py` will fail to import
with `decouple.UndefinedValueError: WALLET_RIAL_DEPOSIT not found` — the service won't
start at all, not just fail at payout time. Treat this as a deploy-blocking config
change, not a code-only one.
---
## 6. Follow-up: explicit per-recipient wallet destination (2026-08-25)
Not every payout is a cash-like user reward. Some fund billboard/ad credit instead — money
that should land in the **advertising** service's own transit wallet, not the user's
personal reward wallet.
An earlier version of this follow-up tried to infer the destination from
`PromotionTypeChoices` (`first_ad_view` / `capture` / `first_ad_create`), a promotion
*category* field that existed but had never been given real values. That was reverted: it
buried a wallet-routing decision inside a general-purpose categorization field, coupling
two things that should vary independently — a promotion's category and where its money
goes are not the same fact, and the next new promotion type would need someone to remember
to also classify it for wallet purposes.
Instead, `Recipient` gets a field that says the routing decision directly:
```python
class WalletDestinationChoices(models.TextChoices):
USER_REWARD = 'user_reward', _('user reward wallet')
ADVERTISING_TRANSIT = 'advertising_transit', _('advertising transit wallet')
```
| `wallet_destination` | Nature | Resolves to |
|---|---|---|
| `user_reward` (default) | Cash-like user reward | `WALLET_USER_BILLBOARD_VISIT_INCOME` |
| `advertising_transit` | Billboard/ad credit | `WALLET_ADVERTISING_TRANSIT` (advertising's own company pool) |
`PromotionTypeChoices` is left as it was before any of this — an empty enum, unused. It's
not part of this decision.
### Changes
- `main/settings.py` — new `WALLET_ADVERTISING_TRANSIT` setting. Same wallet-service UUID
as advertising's own setting of the same name — copy that repo's real value in, don't
provision a second UUID for the same wallet.
- `apps/promotions/models.py`:
- New `WalletDestinationChoices` enum, next to the existing `RecipientTypeChoices`.
- New `Recipient.wallet_destination` field (`CharField`, `db_index=True`, default
`USER_REWARD`) — a real DB column, unlike the earlier `promotion_type` attempt which
only changed field-level `choices=` metadata.
- `Recipient.get_wallet_category_uuid()`:
```python
def get_wallet_category_uuid(self):
if self.wallet_uuid:
return self.wallet_uuid
if self.wallet_destination == WalletDestinationChoices.ADVERTISING_TRANSIT:
return settings.WALLET_ADVERTISING_TRANSIT
return settings.WALLET_USER_BILLBOARD_VISIT_INCOME
```
Priority order: an explicit `Recipient.wallet_uuid` always wins (the per-recipient raw
override from the original refactor above); otherwise `wallet_destination` picks the
wallet type; `user_reward` is the default so existing rows behave exactly as before
this change until someone opts them into `advertising_transit`.
- `apps/promotions/migrations/0010_recipient_wallet_destination.py` — new migration adding
the column, `AddField` with `default='user_reward'` so existing rows backfill safely.
- `apps/promotions/tests.py` — the `first-ad-create` fixture now sets
`wallet_destination=WalletDestinationChoices.ADVERTISING_TRANSIT`;
`test_first_ad_create_event_status_processed` asserts the deposit call actually receives
`WALLET_PROMOTIONS_CREDIT` as `payer_wallet` and `WALLET_ADVERTISING_TRANSIT` as
`payee_wallet`; `test_get_wallet_category_uuid_routing` unit-tests all four routing cases
directly against `Recipient.get_wallet_category_uuid()`.
- `README.md` — config reference table and the playbook's step 3 example updated to show
setting `wallet_destination` on a new `Recipient`.
### What's still manual
- Existing `Plan`/`Recipient` rows in a live database default to `wallet_destination='user_reward'`
on migrate — behavior for them doesn't change. Whoever owns the real `capture` /
`first-ad-create` plans needs to explicitly set `wallet_destination='advertising_transit'`
on their `Recipient` rows (via admin or a follow-up data migration) for those specific
payouts to actually route to the advertising transit wallet.
- `WALLET_ADVERTISING_TRANSIT` is a second **required** env var on top of
`WALLET_PROMOTIONS_CREDIT` — same deploy-blocking caveat as §5: missing it fails
`main/settings.py` import, not just a payout at runtime.
- This migration hasn't been run against a real database in this environment (no local
`.env`/DB configured here) — run `manage.py migrate` and confirm `0010` applies cleanly
before deploying.

View file

@ -382,8 +382,17 @@ CELERY_TASK_TIME_LIMIT = 30 * 60
CELERY_BROKER_URL = REDIS_BASE_URL
CELERY_RESULT_BACKEND = REDIS_BASE_URL
ACCOUNTS_BASE_PUBLIC_URL = config('ACCOUNTS_BASE_PUBLIC_URL', default=None, cast=str)
WALLET_BASE_PUBLIC_URL = config('WALLET_BASE_PUBLIC_URL', default=None, cast=str)
WALLET_RIAL = config('WALLET_RIAL', cast=str)
WALLET_REWARD = config('WALLET_REWARD', cast=str)
WALLET_RIAL_DEPOSIT = config('WALLET_RIAL_DEPOSIT', cast=str)
WALLET_USER_BILLBOARD_VISIT_INCOME = config('WALLET_USER_BILLBOARD_VISIT_INCOME', cast=str)
# Company-side pool promotion payouts are drawn from; must be distinct from the user-side
# wallet above. TODO(you): replace with the real wallet-type UUID from the wallet service.
WALLET_PROMOTIONS_CREDIT = config('WALLET_PROMOTIONS_CREDIT', cast=str)
# Same wallet-service UUID as the advertising repo's own WALLET_ADVERTISING_TRANSIT setting.
# Destination for a Recipient whose wallet_destination is advertising_transit — billboard/ad
# credit rather than a cash-like user reward — see Recipient.get_wallet_category_uuid().
WALLET_ADVERTISING_TRANSIT = config('WALLET_ADVERTISING_TRANSIT', cast=str)
NOTIFICATIONS_BASE_PUBLIC_URL = config('NOTIFICATIONS_BASE_PUBLIC_URL', default=None, cast=str)

View file

@ -0,0 +1,89 @@
import time
import requests
from django.conf import settings
from django.core.cache import cache
from httpx import Request
from utils.clients.gooyal_accounts_client import AuthenticatedClient
from utils.clients.gooyal_accounts_client.models import PublicUser, Application
from utils.clients.gooyal_accounts_client.api.users import users_api_users_retrieve
from utils.clients.gooyal_accounts_client.api.oauth2 import oauth2_apis_applications_retrieve
from utils.clients.gooyal_accounts_client.models import Account
from utils.clients.gooyal_accounts_client.api.users import (users_api_users_details_retrieve)
def login_as_client_credentials():
access_token = cache.get('promotions_access_token')
# expires_in = self.auth_data.get('expires_in')
# created_at = self.auth_data.get('created_at')
#
# if access_token and expires_in > time.time() - created_at:
# return self.auth_data
if access_token:
print('already in cache')
return access_token
print('not in cache')
data = {
"grant_type": 'client_credentials',
"scope": settings.OAUTH2_SCOPES,
}
auth = (settings.OAUTH2_CLIENT_ID, settings.OAUTH2_CLIENT_SECRET)
response = requests.post(f'{settings.OAUTH2_PROVIDER_PUBLIC_URL}/token/',
data=data,
auth=auth)
auth_data = response.json()
print(auth_data)
if 'access_token' in auth_data:
auth_data['created_at'] = time.time()
cache.set('promotions_access_token', auth_data, timeout=auth_data.get('expires_in') - 5)
return auth_data
def log_request(request: Request):
print(request.headers['authorization'])
print(
f"Request event hook: {request.method} {request.url} {request.method} {request.headers} - Waiting for response")
def log_response(response):
request = response.request
print(f"Response event hook: {request.method} {request.url} - Status {response.status_code}")
def get_client():
access_token = login_as_client_credentials()['access_token']
client = AuthenticatedClient(base_url=settings.ACCOUNTS_BASE_PUBLIC_URL, token=access_token,
httpx_args={"event_hooks": {"request": [log_request], "response": [log_response]}}
)
return client
def get_user_info(uuid):
client = get_client()
response: PublicUser = users_api_users_retrieve.sync(client=client, uuid=uuid)
print(response)
return response
def get_user_detailed_info(uuid):
client = get_client()
response: Account = users_api_users_details_retrieve.sync(uuid=uuid, client=client)
print(response)
return response
def get_application_details(uuid):
client = get_client()
data: Application = oauth2_apis_applications_retrieve.sync(client=client, id=uuid)
print(data)
return data

View file

@ -75,7 +75,9 @@ def get_notifications_client():
return client
def notifications_push_user(user_uuid, title, message, priority=5, extras=None):
def notifications_push_user(user_uuid, title, message, priority=5, extras=None, click_url=None):
# click_url is optional — most notifications aren't clickable. When set,
# the notifications service embeds it as the tap destination.
class Tmp():
def to_dict(self):
return {
@ -83,6 +85,7 @@ def notifications_push_user(user_uuid, title, message, priority=5, extras=None):
'message': message,
'priority': priority,
'extras': extras,
'click_url': click_url,
}
push_data = Tmp()

View file

@ -6,7 +6,8 @@ from django.core.cache import cache
from httpx import Request
from utils.clients.gooyal_wallet_client import AuthenticatedClient
from utils.clients.gooyal_wallet_client.models import Transaction, Account, ApplicationDeposit, ApplicationWithdraw, PaginatedAccountList
from utils.clients.gooyal_wallet_client.models import Transaction, Account, ApplicationDeposit, ApplicationWithdraw, \
PaginatedAccountList
from utils.clients.gooyal_wallet_client.api.wallet import (wallet_api_application_deposit_create,
wallet_api_application_deposit_verify_retrieve,
wallet_api_application_withdraw_create,
@ -14,10 +15,8 @@ from utils.clients.gooyal_wallet_client.api.wallet import (wallet_api_applicatio
wallet_api_application_user_wallets_list)
def login_as_client_credentials():
access_token = cache.get('settlement_access_token')
access_token = cache.get('promotions_access_token')
# expires_in = self.auth_data.get('expires_in')
# created_at = self.auth_data.get('created_at')
#
@ -38,12 +37,13 @@ def login_as_client_credentials():
response = requests.post(f'{settings.OAUTH2_PROVIDER_PUBLIC_URL}/token/',
data=data,
auth=auth)
print(response.content)
auth_data = response.json()
if 'access_token' in auth_data:
auth_data['created_at'] = time.time()
cache.set('settlement_access_token', auth_data, timeout=auth_data.get('expires_in') - 5)
cache.set('promotions_access_token', auth_data, timeout=auth_data.get('expires_in') - 5)
return auth_data
@ -83,7 +83,8 @@ def deposit_to_user_wallet_submit(payer_wallet, data):
def deposit_to_user_wallet_verify(payer_wallet, uuid):
client = get_client()
response: ApplicationDeposit = wallet_api_application_deposit_verify_retrieve.sync(payer_wallet=payer_wallet, uuid=uuid, client=client)
response: ApplicationDeposit = wallet_api_application_deposit_verify_retrieve.sync(payer_wallet=payer_wallet,
uuid=uuid, client=client)
print(response)
return response
@ -95,14 +96,16 @@ def withdraw_from_user_wallet_submit(payee_wallet, data):
withdraw_data = Tmp()
client = get_client()
response: ApplicationWithdraw = wallet_api_application_withdraw_create.sync(payee_wallet=payee_wallet, client=client, body=withdraw_data)
response: ApplicationWithdraw = wallet_api_application_withdraw_create.sync(payee_wallet=payee_wallet,
client=client, body=withdraw_data)
print(response)
return response
def withdraw_from_user_wallet_verify(payee_wallet, uuid):
client = get_client()
response: ApplicationWithdraw = wallet_api_application_withdraw_verify_retrieve.sync(payee_wallet=payee_wallet, uuid=uuid, client=client)
response: ApplicationWithdraw = wallet_api_application_withdraw_verify_retrieve.sync(payee_wallet=payee_wallet,
uuid=uuid, client=client)
print(response)
return response
@ -110,7 +113,8 @@ def withdraw_from_user_wallet_verify(payee_wallet, uuid):
def get_user_wallets(owner, wallets):
client = get_client()
# by pass several wallet query params: [','.join([str(i) for i in wallets])]
response: PaginatedAccountList = wallet_api_application_user_wallets_list.sync(owner=owner, wallet=[','.join([str(i) for i in wallets])], client=client)
response: PaginatedAccountList = wallet_api_application_user_wallets_list.sync(owner=owner, wallet=[
','.join([str(i) for i in wallets])], client=client)
print(response)
balances = dict(map(lambda result: (str(result.wallet), result.balance), response.results))
return balances

View file

@ -1,5 +1,7 @@
import logging
from django.conf import settings
from django.utils import timezone
from rest_framework.exceptions import APIException
from rest_framework.views import exception_handler as drf_exception_handler
@ -12,17 +14,36 @@ def exception_handler(exc, context):
response = drf_exception_handler(exc, context)
if response is not None:
# ساختار دلخواه خود را تعریف می‌کنیم
response_data = {
'success': False,
'status_code': response.status_code,
'message': 'An error occurred',
'details': {}
'status_message': str(getattr(exc, 'default_detail', exc)),
'details': getattr(exc, 'detail', None),
}
if isinstance(exc, APIException):
response_data['message'] = exc.detail if isinstance(exc.detail, str) else "Validation error"
response_data['details'] = exc.detail if isinstance(exc.detail, dict) else {}
try:
error = getattr(getattr(exc, 'detail', None), 'code', None) or getattr(exc, 'code', None)
if not error:
error = getattr(exc, 'default_code')
except:
error = ''
# if isinstance(exc.detail, str):
# message = exc.detail
# elif isinstance(exc.detail, dict):
# if 'message' in exc.detail or 'string' in exc.detail:
# message = exc.detail.get('message') or exc.detail.get('string')
# else:
# message = error
#
# else:
# message = error
#
# response_data['message'] = message
response_data['details'] = {"message": exc.detail}
response_data['details']['error'] = error
response_data['details']['timestamp'] = timezone.now().isoformat()
response.data = response_data
@ -47,16 +68,36 @@ class ErrorMiddleware:
{
"success": False,
"status_code": 500,
"message": "Internal server error",
"details": str(exception) # فقط در حالت توسعه! در تولید بهتر است لاگ شود.
"status_message": "Internal server error",
"details": str(exception) if settings.DEBUG else None,
},
status=500
)
from rest_framework.exceptions import APIException
from rest_framework import status
from django.utils.translation import gettext_lazy as _
class UnprocessableEntity(APIException):
status_code = 422
default_detail = 'The request was well-formed but cannot be processed due to semantic errors.'
default_code = 'unprocessable_entity'
class ServiceUnavailable(APIException):
status_code = status.HTTP_503_SERVICE_UNAVAILABLE
default_ = _('SERVICE_UNAVAILABLE')
default_code = 'service_unavailable'
default_detail = 'Service Unavailable'
class Conflict(APIException):
status_code = status.HTTP_409_CONFLICT
default_ = _('CONFLICT')
default_code = 'conflict'
default_detail = 'Conflict'
# class NotFound(APIException):