Compare commits

..

16 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
27 changed files with 759 additions and 534 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=

View file

@ -203,7 +203,7 @@ Concrete steps, mirroring the real `first-ad-create` fixture in `apps/promotions
)
```
3. **Attach a recipient.** The common case: pay the user who fired the event, a flat amount, no percentage math.
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(
@ -211,6 +211,7 @@ Concrete steps, mirroring the real `first-ad-create` fixture in `apps/promotions
label="first-ad-create",
recipient_uuid_field="->event:user",
base_amount_field="30000", # literal, or "event:data_key"
wallet_destination=WalletDestinationChoices.ADVERTISING_TRANSIT,
)
```
@ -313,7 +314,10 @@ Non-secret values only — pulled from `main/settings.py` and this checkout's ow
| `OAUTH2_PROVIDER_PUBLIC_URL` / `_PRIVATE_URL` | Central accounts service's OAuth2 endpoints (token issuance, introspection). Points at the accounts service's staging environment — see this checkout's own `.env` for the actual hostname. |
| `ACCOUNTS_BASE_PUBLIC_URL` | Accounts service REST API (user/application profile reads). |
| `WALLET_BASE_PUBLIC_URL` | Wallet service REST API (deposit submit/verify). |
| `WALLET_RIAL` / `WALLET_REWARD` | UUIDs of specific `Wallet` (currency pool) rows in the wallet service — not amounts. `WALLET_REWARD` is the pool promotions pays out of; passed as both the deposit's `payer_wallet` and its `payee_wallet`, since payer (this app's pool) and payee (the user) share one currency. |
| `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`). |

View file

@ -2,10 +2,15 @@ from functools import update_wrapper
from django.contrib import admin
from .models import Promotion, Plan, Event, Recipient, EventSaver, AllowedUser
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']
@ -19,4 +24,5 @@ admin.site.register(Event)
admin.site.register(EventSaver)
admin.site.register(Recipient)
admin.site.register(AllowedUser, AllowedUserAdmin)
admin.site.register(PromotionTransaction, PromotionTransactionAdmin)

View file

@ -1,36 +1,11 @@
import django_filters
from .handlers import ProcessorTypeChoices
from .models import Promotion, PaymentStateChoices
from .models import Sample
class AdminPromotionFilter(django_filters.FilterSet):
user_uuid = django_filters.UUIDFilter(field_name='user_uuid')
plan = django_filters.UUIDFilter(field_name='plan__uuid')
promotion_type = django_filters.ChoiceFilter(field_name='plan__processor', choices=ProcessorTypeChoices.choices)
state = django_filters.ChoiceFilter(field_name='state', choices=PaymentStateChoices.choices)
event_label = django_filters.CharFilter(field_name='event__label', lookup_expr='exact')
created_after = django_filters.DateTimeFilter(field_name='created_at', lookup_expr='gte')
created_before = django_filters.DateTimeFilter(field_name='created_at', lookup_expr='lte')
class SampleFilter(django_filters.FilterSet):
class Meta:
model = Promotion
fields = ['user_uuid', 'plan', 'promotion_type', 'state', 'event_label', 'created_after', 'created_before']
class AdminReferralFilter(django_filters.FilterSet):
# invited_by == Promotion.user_uuid: in the referral recipient DSL
# ("->event:referral") this is who gets paid, i.e. the referrer.
invited_by = django_filters.UUIDFilter(field_name='user_uuid')
# invited_user lives only in free-form JSON (event.data['user']), so this is a
# CharFilter, not UUIDFilter: JSONField key-transform lookups compare against
# the stored string, not a native UUID the adapter can serialize.
invited_user = django_filters.CharFilter(field_name='event__data__user')
plan = django_filters.UUIDFilter(field_name='plan__uuid')
state = django_filters.ChoiceFilter(field_name='state', choices=PaymentStateChoices.choices)
created_after = django_filters.DateTimeFilter(field_name='created_at', lookup_expr='gte')
created_before = django_filters.DateTimeFilter(field_name='created_at', lookup_expr='lte')
class Meta:
model = Promotion
fields = ['invited_user', 'invited_by', 'plan', 'state', 'created_after', 'created_before']
model = Sample
fields = {
'data': ['exact'],
}

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

@ -193,12 +193,20 @@ class RecipientTypeChoices(models.TextChoices):
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)
@ -220,7 +228,13 @@ 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:
@ -350,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)
@ -362,6 +394,11 @@ 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
@ -465,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,
@ -483,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': ''
},
}
# 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,
),
)
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'
)
# 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',
)
@ -568,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

@ -1,6 +1,6 @@
from rest_framework import serializers
from apps.promotions.models import Promotion, Plan, Event, Recipient
from .models import Promotion, Plan, Event, Recipient
class EventSerializer(serializers.ModelSerializer):
@ -90,6 +90,15 @@ class PromotionStatusSerializer(serializers.Serializer):
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,25 +0,0 @@
from .common import (
EventSerializer,
PromotionSerializer,
PlanSerializer,
PlanPromotSerializer,
PromoteSerializer,
UserRecipientSerializer,
PromotionStatusSerializer,
UserPlanSerializer,
)
from .admin import AdminPlanSummarySerializer, AdminPromotionSerializer, AdminReferralSerializer
__all__ = [
'EventSerializer',
'PromotionSerializer',
'PlanSerializer',
'PlanPromotSerializer',
'PromoteSerializer',
'UserRecipientSerializer',
'PromotionStatusSerializer',
'UserPlanSerializer',
'AdminPlanSummarySerializer',
'AdminPromotionSerializer',
'AdminReferralSerializer',
]

View file

@ -1,8 +0,0 @@
from .promotion import AdminPlanSummarySerializer, AdminPromotionSerializer
from .referral import AdminReferralSerializer
__all__ = [
'AdminPlanSummarySerializer',
'AdminPromotionSerializer',
'AdminReferralSerializer',
]

View file

@ -1,42 +0,0 @@
from rest_framework import serializers
from apps.promotions.models import Promotion, Plan
class AdminPlanSummarySerializer(serializers.ModelSerializer):
class Meta:
model = Plan
fields = ("uuid", "title", "processor")
class AdminPromotionSerializer(serializers.ModelSerializer):
"""Every Promotion row, i.e. every promotion received by a user."""
user = serializers.UUIDField(source='user_uuid', read_only=True)
plan = AdminPlanSummarySerializer(read_only=True)
promotion_type = serializers.SerializerMethodField()
event_label = serializers.SerializerMethodField()
state_display = serializers.CharField(source='get_state_display', read_only=True)
reward_amount = serializers.IntegerField(source='promotion_amount', read_only=True)
date = serializers.DateTimeField(source='created_at', read_only=True)
class Meta:
model = Promotion
fields = (
"uuid",
"user",
"plan",
"promotion_type",
"event_label",
"base_amount",
"reward_amount",
"state",
"state_display",
"date",
"updated_at",
)
def get_promotion_type(self, obj):
return obj.plan.processor if obj.plan_id else None
def get_event_label(self, obj):
return obj.event.label if obj.event_id else None

View file

@ -1,46 +0,0 @@
from rest_framework import serializers
from apps.promotions.models import Promotion
from .promotion import AdminPlanSummarySerializer
class AdminReferralSerializer(serializers.ModelSerializer):
"""
Referral activity, derived from Promotion rows on plans whose processor is
'referral'. There is no dedicated referral model and no referral_code field
in this codebase today (see docs/adminpanel_technical.md, "Known limitations").
In the recipient DSL a referral plan's Recipient resolves to
"->event:referral", so Promotion.user_uuid is the referrer being paid
(invited_by) -- not the person who triggered the event. The invited user
only exists as free-form JSON on the triggering Event (event.data['user']).
"""
invited_by = serializers.UUIDField(source='user_uuid', read_only=True)
invited_user = serializers.SerializerMethodField()
plan = AdminPlanSummarySerializer(read_only=True)
event_label = serializers.SerializerMethodField()
reward_amount = serializers.IntegerField(source='promotion_amount', read_only=True)
state_display = serializers.CharField(source='get_state_display', read_only=True)
date = serializers.DateTimeField(source='created_at', read_only=True)
class Meta:
model = Promotion
fields = (
"uuid",
"invited_user",
"invited_by",
"plan",
"event_label",
"reward_amount",
"state",
"state_display",
"date",
)
def get_invited_user(self, obj):
if obj.event_id and obj.event.data:
return obj.event.data.get('user')
return None
def get_event_label(self, obj):
return obj.event.label if obj.event_id else None

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():
for promotion in plan_analyze_result:
print(promotion)
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()
@ -790,6 +790,7 @@ class ApplicationApiFlowsTests(APITestCase):
defaults={
'recipient_uuid_field': '->event:user',
'base_amount_field': str(promotion_amount),
'wallet_destination': WalletDestinationChoices.ADVERTISING_TRANSIT,
},
)
@ -822,13 +823,24 @@ class ApplicationApiFlowsTests(APITestCase):
},
}
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)
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}),
@ -845,6 +857,25 @@ class ApplicationApiFlowsTests(APITestCase):
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)

View file

@ -1,6 +0,0 @@
from .router import router
from . import admin_urls
app_name = 'promotions-admin'
urlpatterns = router.urls + admin_urls.urlpatterns

View file

@ -1,8 +0,0 @@
from apps.promotions.views import AdminPromotionViewSet, AdminReferralViewSet
from .router import router
router.register('promotions', AdminPromotionViewSet, basename='admin-promotions')
router.register('referrals', AdminReferralViewSet, basename='admin-referrals')
urlpatterns = []

View file

@ -1,3 +0,0 @@
from rest_framework.routers import DefaultRouter
router = DefaultRouter()

View file

@ -1,6 +0,0 @@
from .admin import AdminPromotionViewSet, AdminReferralViewSet
__all__ = [
'AdminPromotionViewSet',
'AdminReferralViewSet',
]

View file

@ -1,7 +0,0 @@
from .promotion import AdminPromotionViewSet
from .referral import AdminReferralViewSet
__all__ = [
'AdminPromotionViewSet',
'AdminReferralViewSet',
]

View file

@ -1,26 +0,0 @@
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import mixins
from rest_framework.viewsets import GenericViewSet
from apps.gooyal_oauth2.rest_framework import IsAuthenticatedOrTokenMatchesOASRequirements
from apps.promotions.filters import AdminPromotionFilter
from apps.promotions.models import Promotion
from apps.promotions.serializers import AdminPromotionSerializer
class AdminPromotionViewSet(
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
GenericViewSet
):
"""Read-only: every Promotion received, per user, with type/amount/date."""
queryset = Promotion.objects.select_related('plan', 'event').order_by('-created_at')
serializer_class = AdminPromotionSerializer
lookup_field = 'uuid'
filter_backends = (DjangoFilterBackend,)
filterset_class = AdminPromotionFilter
permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements]
required_alternate_scopes = {
"GET": [["admin.promotions:retrieve"]],
}

View file

@ -1,35 +0,0 @@
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import mixins
from rest_framework.viewsets import GenericViewSet
from apps.gooyal_oauth2.rest_framework import IsAuthenticatedOrTokenMatchesOASRequirements
from apps.promotions.filters import AdminReferralFilter
from apps.promotions.handlers import ProcessorTypeChoices
from apps.promotions.models import Promotion
from apps.promotions.serializers import AdminReferralSerializer
class AdminReferralViewSet(
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
GenericViewSet
):
"""
Read-only: referral activity. Scoped to Promotion rows on plans whose
processor == 'referral'. No dedicated Referral model / referral_code
field exists in this codebase; see docs/adminpanel_technical.md.
"""
serializer_class = AdminReferralSerializer
lookup_field = 'uuid'
filter_backends = (DjangoFilterBackend,)
filterset_class = AdminReferralFilter
permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements]
required_alternate_scopes = {
"GET": [["admin.referrals:retrieve"]],
}
def get_queryset(self):
return Promotion.objects.select_related('plan', 'event').filter(
plan__processor=ProcessorTypeChoices.REFERRAL
).order_by('-created_at')

View file

@ -12,7 +12,7 @@ from apps.gooyal_oauth2.rest_framework import IsAuthenticatedOrTokenMatchesOASRe
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, get_event_status_for_user
from .models import Plan, Promotion, EventSaver, get_event_status_for_user, rollback_promotion_payout
from .serializers import (
PlanSerializer,
PromotionSerializer,
@ -20,6 +20,7 @@ from .serializers import (
PromoteSerializer,
UserPlanSerializer,
PromotionStatusSerializer,
PromotionRollbackSerializer,
)
from .tasks import analyze_event_task
from ..users.models import User
@ -144,6 +145,30 @@ class ApplicationEventViewSet(
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):
user = self._resolve_user()

View file

@ -1,227 +0,0 @@
# Admin panel — technical reference
Two read-only endpoints added for the admin panel: **Promotions** (every promotion received, per user) and **Referral System** (referral activity). They live under `apps/promotions`, structured to match the admin/users folder split used by the sibling `advertising` service (`apps/crm`, `apps/escrow`, `apps/stores` there each split `views/`, `serializers/`, and `urls/` into `admin/`/`users/` subpackages, sharing one `DefaultRouter`). Applied here for the admin slice only — the pre-existing `views_user.py` / `views_application.py` / `urls_user.py` / `urls_application.py` (user-token and application-token APIs) were intentionally left untouched, since restructuring those would rename URL namespaces already relied on by `tests.py` and any external caller.
| Path | Purpose |
|---|---|
| `apps/promotions/views/admin/promotion.py` | `AdminPromotionViewSet` |
| `apps/promotions/views/admin/referral.py` | `AdminReferralViewSet` |
| `apps/promotions/views/admin/__init__.py`, `apps/promotions/views/__init__.py` | re-export the two viewsets |
| `apps/promotions/urls/router.py` | shared `DefaultRouter` for the admin API |
| `apps/promotions/urls/admin_urls.py` | registers both viewsets on the router |
| `apps/promotions/urls/__init__.py` | combines `router.urls`, sets `app_name = 'promotions-admin'` |
| `apps/promotions/serializers/admin/promotion.py` | `AdminPlanSummarySerializer`, `AdminPromotionSerializer` |
| `apps/promotions/serializers/admin/referral.py` | `AdminReferralSerializer` |
| `apps/promotions/serializers/admin/__init__.py`, `apps/promotions/serializers/__init__.py` | re-export, alongside the pre-existing (now `serializers/common.py`) serializers |
| `apps/promotions/filters.py` | `AdminPromotionFilter`, `AdminReferralFilter` — stays a single flat file, matching how `advertising`'s `filters.py` (e.g. `apps/crm/filters.py`) is *not* split into subfolders even though views/serializers/urls are |
Mounted in `main/urls.py`:
```python
path('api/v2/promotions/admin/', include('apps.promotions.urls', namespace='promotions-admin')),
```
No models or migrations were changed. Both resources are `GenericViewSet` + `ListModelMixin`/`RetrieveModelMixin` (matching `AdminTicketViewSet` in `advertising`'s `apps/crm/views/admin/ticket.py`) registered on one router, not separate `ListAPIView`/`RetrieveAPIView` classes.
### URL names
Router-generated, under the `promotions-admin` namespace:
| Name | Path |
|---|---|
| `promotions-admin:admin-promotions-list` | `GET /api/v2/promotions/admin/promotions/` |
| `promotions-admin:admin-promotions-detail` | `GET /api/v2/promotions/admin/promotions/<uuid>/` |
| `promotions-admin:admin-referrals-list` | `GET /api/v2/promotions/admin/referrals/` |
| `promotions-admin:admin-referrals-detail` | `GET /api/v2/promotions/admin/referrals/<uuid>/` |
---
## Auth
Same pattern as every other view in this app: `IsAuthenticatedOrTokenMatchesOASRequirements` (`apps/gooyal_oauth2/rest_framework.py`) with a `required_alternate_scopes` dict keyed by HTTP method.
| Endpoint | Scope |
|---|---|
| Promotions (list + detail) | `admin.promotions:retrieve` |
| Referral System (list + detail) | `admin.referrals:retrieve` |
These are new scope names — introspected the same way as every other scope in this app, via the central **accounts** OAuth2 provider. They are not yet registered there; that's an operational step outside this checkout (see Known limitations).
---
## 1. Promotions
`GET /api/v2/promotions/admin/promotions/` — list
`GET /api/v2/promotions/admin/promotions/<uuid>/` — detail
Every `Promotion` row (i.e. every promotion a user has received or is in-flight for), across all plans and all users.
### Query params (filters)
| Param | Type | Maps to | Notes |
|---|---|---|---|
| `user_uuid` | UUID | `Promotion.user_uuid` | exact |
| `plan` | UUID | `Promotion.plan.uuid` | exact |
| `promotion_type` | choice: `percentage` \| `referral` \| `others` | `Promotion.plan.processor` | see "Promotion type", below |
| `state` | choice: `1`-`7` | `Promotion.state` | see state table below |
| `event_label` | string | `Promotion.event.label` | exact |
| `created_after` | ISO datetime | `Promotion.created_at >=` | |
| `created_before` | ISO datetime | `Promotion.created_at <=` | |
| `limit`, `offset` | int | pagination | `LimitOffsetPagination`, project default `PAGE_SIZE=200` |
### Response fields
| Field | Type | Description |
|---|---|---|
| `uuid` | UUID | Promotion row id |
| `user` | UUID | `Promotion.user_uuid` — who received/will receive the payout |
| `plan.uuid` | UUID | The triggering plan |
| `plan.title` | string | Plan title |
| `plan.processor` | string | Raw `Plan.processor` value |
| `promotion_type` | string \| null | Same as `plan.processor`; null only if `plan` itself is null (see limitations) |
| `event_label` | string \| null | Label of the `Event` that triggered this promotion; null for synchronous per-plan promotes with no linked event |
| `base_amount` | int \| null | Amount before percentage/cap math |
| `reward_amount` | int \| null | `Promotion.promotion_amount` — the actual payout amount |
| `state` | int | Raw `PaymentStateChoices` value (1-7) |
| `state_display` | string | Human-readable state |
| `date` | datetime | `Promotion.created_at` |
| `updated_at` | datetime | `Promotion.updated_at` |
### Payment states
| Value | Label |
|---|---|
| 1 | created |
| 2 | delayed |
| 3 | pending |
| 4 | incomplete |
| 5 | success |
| 6 | failed |
| 7 | expected_failure |
### Example
```
GET /api/v2/promotions/admin/promotions/?promotion_type=referral&state=5
```
```json
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"uuid": "7cb39284-cf95-43a5-b9d0-9c230f7e2f21",
"user": "1bb3b561-2823-4a3e-be12-edc5a6e623e8",
"plan": {
"uuid": "57a9f996-5e64-4541-b928-1e0f82ca3795",
"title": "referral-plan",
"processor": "referral"
},
"promotion_type": "referral",
"event_label": "referral::signup",
"base_amount": 1000,
"reward_amount": 1000,
"state": 5,
"state_display": "success",
"date": "2026-08-23T12:41:09.823169Z",
"updated_at": "2026-08-23T12:41:09.823175Z"
}
]
}
```
---
## 2. Referral System
`GET /api/v2/promotions/admin/referrals/` — list
`GET /api/v2/promotions/admin/referrals/<uuid>/` — detail
Scoped to `Promotion` rows whose `plan.processor == 'referral'`. There is **no dedicated referral model** in this codebase — see Known limitations before relying on any field name below.
### Field derivation (read this before trusting the data)
The referral recipient pattern used throughout `apps/promotions/tests.py` is:
```python
Recipient.objects.create(
recipient_uuid_field="->event:referral", # pays whoever event.data['referral'] names
...
)
```
i.e. the calling app submits an `Event` with `data = {"user": <the person who acted>, "referral": <the referrer>}`, and the `Recipient` DSL resolves the **payee** off `event.data['referral']`. That resolved payee becomes `Promotion.user_uuid`. So:
- **`invited_by`** = `Promotion.user_uuid` — the resolved recipient of the reward, i.e. the referrer. This comes straight off the `Promotion` row (the value Recipient DSL actually resolved and paid), not raw event JSON, so it's authoritative even for more complex referral recipient expressions (e.g. the `QS:Event:...` settlement pattern also in the test suite).
- **`invited_user`** = `event.data.get('user')` — the person who performed the referred action. This is *not* authoritative: it's whatever the calling app happened to put in `data['user']` on event submission, with no model-level guarantee the key exists or is even a UUID.
### Query params (filters)
| Param | Type | Maps to | Notes |
|---|---|---|---|
| `invited_by` | UUID | `Promotion.user_uuid` | exact; the referrer being paid |
| `invited_user` | string | `Event.data['user']` (JSON key lookup) | exact string match; not type-checked |
| `plan` | UUID | `Promotion.plan.uuid` | exact |
| `state` | choice: `1`-`7` | `Promotion.state` | |
| `created_after` / `created_before` | ISO datetime | `Promotion.created_at` | |
### Response fields
| Field | Type | Description |
|---|---|---|
| `uuid` | UUID | Promotion row id |
| `invited_user` | string \| null | `event.data['user']`, raw — **not a `referral_code`, doesn't exist** |
| `invited_by` | UUID | `Promotion.user_uuid` — the referrer who was paid |
| `plan.uuid` / `.title` / `.processor` | | The referral plan |
| `event_label` | string \| null | Triggering event's label |
| `reward_amount` | int \| null | `Promotion.promotion_amount` |
| `state` / `state_display` | int / string | Same as Promotions endpoint |
| `date` | datetime | `Promotion.created_at` |
There is **no `referral_code` field** in the response — it does not exist anywhere in this codebase (models, migrations, or elsewhere). See Known limitations.
### Example
```
GET /api/v2/promotions/admin/referrals/?invited_by=1bb3b561-2823-4a3e-be12-edc5a6e623e8
```
```json
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"uuid": "7cb39284-cf95-43a5-b9d0-9c230f7e2f21",
"invited_user": "fe208494-254c-412b-8f81-f6f816b219fb",
"invited_by": "1bb3b561-2823-4a3e-be12-edc5a6e623e8",
"plan": {
"uuid": "57a9f996-5e64-4541-b928-1e0f82ca3795",
"title": "referral-plan",
"processor": "referral"
},
"event_label": "referral::signup",
"reward_amount": 1000,
"state": 5,
"state_display": "success",
"date": "2026-08-23T12:41:09.823169Z"
}
]
}
```
---
## Known limitations
- **No `referral_code`.** There is no referral-code concept anywhere in this codebase — grepped for `referral_code` / `invite_code` / `invited_by` across every `.py` file; nothing exists outside this new admin code. Referral linking today is entirely raw UUIDs passed through `Event.data` by convention, not a schema-enforced relationship. If a real invite-code system is wanted, that's a model change (new field or table) requiring a migration — intentionally out of scope here.
- **`invited_user` is unreliable.** It's read from untyped JSON (`Event.data['user']`) with no model-level guarantee it exists, is a UUID, or even refers to a real user. Treat it as informational only; `invited_by` (a real FK-adjacent field, `Promotion.user_uuid`) is the trustworthy half of this endpoint.
- **`Recipient.promotion_type` is not used for `promotion_type`.** That field exists on the model but its choices enum (`PromotionTypeChoices` in `handlers.py`) is empty, so it's always blank in real data. `promotion_type` here is `Plan.processor` instead.
- **The `ReferralHandler` in `handlers.py` is dead code.** `calculate()` references an undefined `base_amount`, `promote()` opens with a bare `return`. It plays no role in producing the `Promotion` rows this endpoint reads — referral payouts happen through the same generic `Recipient.promote()` path as every other plan type (see the repo's `README.md` "Watch list").
- **Detail routes are of limited independent use.** `AdminPromotionDetailApiView` / `AdminReferralDetailApiView` return the same shape as one row of the list endpoint; included for REST completeness (e.g. deep-linking from a table row in the admin UI) but the list endpoint with `plan`/`state` filters covers most real usage.
- **New scopes (`admin.promotions:retrieve`, `admin.referrals:retrieve`) need to be registered on the central accounts OAuth2 provider** before any real token can carry them — that's outside this checkout.
- **No automated tests were added** for these two endpoints (matching the request's scope: verified via `manage.py check`, URL resolution, and an `APIRequestFactory` smoke test against an in-memory SQLite DB with the app's Postgres/JSONField-`GinIndex` usage stubbed around, run manually — see `apps/promotions/tests.py` for the existing `APITestCase` pattern if formal tests are wanted later).
- **The admin/users folder split was applied to the admin slice only.** `views_user.py`, `views_application.py`, `urls_user.py`, `urls_application.py`, and the non-admin serializers (now `serializers/common.py`) were left in place rather than also converted to `views/users/`, `views/application/`, etc., because that would rename the `promotions` / `promotions-application` URL namespaces already used by `tests.py`'s `reverse()` calls (and possibly external callers) — a breaking change outside what was asked for here.

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

@ -385,7 +385,14 @@ 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

@ -30,7 +30,6 @@ urlpatterns = [
path('oauth2/', include('oauth2_provider.urls', namespace='oauth2_provider')),
path('promotions/', include('apps.promotions.urls_user', namespace='promotions')),
path('api/v2/promotions/application/<user_uuid>/', include('apps.promotions.urls_application', namespace='promotions-application')),
path('api/v2/promotions/admin/', include('apps.promotions.urls', namespace='promotions-admin')),
]
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

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()