Compare commits
3 commits
master
...
feature/ad
| Author | SHA1 | Date | |
|---|---|---|---|
| c359105019 | |||
| 1d77303f00 | |||
| d27ae32e27 |
27 changed files with 538 additions and 763 deletions
48
.env.example
48
.env.example
|
|
@ -1,48 +0,0 @@
|
|||
# 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=
|
||||
|
|
@ -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. 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`.
|
||||
3. **Attach a recipient.** The common case: pay the user who fired the event, a flat amount, no percentage math.
|
||||
|
||||
```python
|
||||
Recipient.objects.create(
|
||||
|
|
@ -211,7 +211,6 @@ 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,
|
||||
)
|
||||
```
|
||||
|
||||
|
|
@ -314,10 +313,7 @@ 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_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()`. |
|
||||
| `WALLET_RIAL` / `WALLET_REWARD` | UUIDs of specific `Wallet` (currency pool) rows in the wallet service — not amounts. `WALLET_REWARD` is the pool promotions pays out of; passed as both the deposit's `payer_wallet` and its `payee_wallet`, since payer (this app's pool) and payee (the user) share one currency. |
|
||||
| `NOTIFICATIONS_BASE_PUBLIC_URL` | Notifications service REST API. |
|
||||
| `OAUTH2_CLIENT_ID` / `_SECRET` / `_SCOPES` | This service's own client-credentials identity, used for every outbound call above via `login_as_client_credentials()` (token cached under the key `promotions_access_token`). |
|
||||
|
||||
|
|
|
|||
|
|
@ -2,15 +2,10 @@ from functools import update_wrapper
|
|||
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import Promotion, Plan, Event, Recipient, EventSaver, AllowedUser, PromotionTransaction
|
||||
from .models import Promotion, Plan, Event, Recipient, EventSaver, AllowedUser
|
||||
|
||||
class PromotionAdmin(admin.ModelAdmin):
|
||||
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']
|
||||
list_display = ['user_uuid', 'plan', 'promotion_amount', 'recipient', 'event', 'created_at']
|
||||
|
||||
class PlanAdmin(admin.ModelAdmin):
|
||||
list_display = ['uuid', 'title', 'balance']
|
||||
|
|
@ -24,5 +19,4 @@ admin.site.register(Event)
|
|||
admin.site.register(EventSaver)
|
||||
admin.site.register(Recipient)
|
||||
admin.site.register(AllowedUser, AllowedUserAdmin)
|
||||
admin.site.register(PromotionTransaction, PromotionTransactionAdmin)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,36 @@
|
|||
import django_filters
|
||||
|
||||
from .models import Sample
|
||||
from .handlers import ProcessorTypeChoices
|
||||
from .models import Promotion, PaymentStateChoices
|
||||
|
||||
|
||||
class SampleFilter(django_filters.FilterSet):
|
||||
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 Meta:
|
||||
model = Sample
|
||||
fields = {
|
||||
'data': ['exact'],
|
||||
}
|
||||
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']
|
||||
|
|
|
|||
|
|
@ -1,19 +0,0 @@
|
|||
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'),
|
||||
),
|
||||
]
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
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'),
|
||||
),
|
||||
]
|
||||
|
|
@ -193,20 +193,12 @@ 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)
|
||||
|
||||
|
|
@ -228,13 +220,7 @@ class Recipient(BaseModel):
|
|||
return f"{self.label} --> {self.plan}"
|
||||
|
||||
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
|
||||
return self.wallet_uuid or settings.WALLET_PROMOTION_CATEGORY_UUID
|
||||
|
||||
def is_user_allowed(self, user_uuid):
|
||||
if self.access_type != RecipientTypeChoices.RESTRICTED:
|
||||
|
|
@ -364,24 +350,6 @@ 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)
|
||||
|
|
@ -394,11 +362,6 @@ 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
|
||||
|
||||
|
|
@ -502,7 +465,7 @@ class Promotion(BaseModel):
|
|||
if self.state not in [PaymentStateChoices.CREATED]:
|
||||
raise Exception(_('Cannot promote. not in correct state'))
|
||||
|
||||
if not self.promotion_amount:
|
||||
if not self.promotion_amount or self.plan.balance_holder:
|
||||
to_not_payed = self.change_state(
|
||||
from_states=[PaymentStateChoices.CREATED],
|
||||
to_state=PaymentStateChoices.SUCCESS,
|
||||
|
|
@ -520,38 +483,58 @@ class Promotion(BaseModel):
|
|||
error_message='Cannot promote. not in current state'
|
||||
)
|
||||
|
||||
# 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,
|
||||
),
|
||||
)
|
||||
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:
|
||||
transferred = payout.execute()
|
||||
except Exception:
|
||||
self.change_state(
|
||||
from_states=[self.state],
|
||||
to_state=PaymentStateChoices.EXPECTED_FAILURE,
|
||||
same_ok=True,
|
||||
raise_exception=True,
|
||||
error_message='Failed to update state',
|
||||
)
|
||||
raise Exception('Failed to pay promotion')
|
||||
submit_response = deposit_to_user_wallet_submit(settings.WALLET_REWARD, data)
|
||||
if not submit_response.uuid:
|
||||
raise Exception('Failed to submit promotion. 1')
|
||||
|
||||
if not transferred:
|
||||
except Exception as e:
|
||||
self.change_state(
|
||||
from_states=[self.state],
|
||||
to_state=PaymentStateChoices.FAILED,
|
||||
same_ok=True,
|
||||
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:
|
||||
self.change_state(
|
||||
from_states=[self.state],
|
||||
to_state=PaymentStateChoices.EXPECTED_FAILURE,
|
||||
same_ok=False,
|
||||
raise_exception=True,
|
||||
error_message='Failed to update state'
|
||||
)
|
||||
# return False
|
||||
raise Exception('Failed to verify promition. 2')
|
||||
|
||||
if verify_response.state != 5:
|
||||
to_pay_failed = self.change_state(
|
||||
from_states=[self.state],
|
||||
to_state=PaymentStateChoices.FAILED,
|
||||
same_ok=False,
|
||||
raise_exception=True,
|
||||
error_message='Failed to update state payment',
|
||||
)
|
||||
|
|
@ -585,192 +568,3 @@ 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)
|
||||
|
|
|
|||
25
apps/promotions/serializers/__init__.py
Normal file
25
apps/promotions/serializers/__init__.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
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',
|
||||
]
|
||||
8
apps/promotions/serializers/admin/__init__.py
Normal file
8
apps/promotions/serializers/admin/__init__.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
from .promotion import AdminPlanSummarySerializer, AdminPromotionSerializer
|
||||
from .referral import AdminReferralSerializer
|
||||
|
||||
__all__ = [
|
||||
'AdminPlanSummarySerializer',
|
||||
'AdminPromotionSerializer',
|
||||
'AdminReferralSerializer',
|
||||
]
|
||||
42
apps/promotions/serializers/admin/promotion.py
Normal file
42
apps/promotions/serializers/admin/promotion.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
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
|
||||
46
apps/promotions/serializers/admin/referral.py
Normal file
46
apps/promotions/serializers/admin/referral.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
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
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
from rest_framework import serializers
|
||||
|
||||
from .models import Promotion, Plan, Event, Recipient
|
||||
from apps.promotions.models import Promotion, Plan, Event, Recipient
|
||||
|
||||
|
||||
class EventSerializer(serializers.ModelSerializer):
|
||||
|
|
@ -90,15 +90,6 @@ 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:
|
||||
|
|
@ -1,17 +1,10 @@
|
|||
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)
|
||||
for promotion in plan_analyze_result:
|
||||
print(promotion)
|
||||
|
|
|
|||
|
|
@ -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, WalletDestinationChoices
|
||||
PaymentStateChoices
|
||||
|
||||
AccessToken = get_access_token_model()
|
||||
Application = get_application_model()
|
||||
|
|
@ -790,7 +790,6 @@ class ApplicationApiFlowsTests(APITestCase):
|
|||
defaults={
|
||||
'recipient_uuid_field': '->event:user',
|
||||
'base_amount_field': str(promotion_amount),
|
||||
'wallet_destination': WalletDestinationChoices.ADVERTISING_TRANSIT,
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -823,24 +822,13 @@ class ApplicationApiFlowsTests(APITestCase):
|
|||
},
|
||||
}
|
||||
|
||||
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.post(
|
||||
reverse('promotions:promotion-create', kwargs={'plan': plan.uuid}),
|
||||
event_create_data,
|
||||
HTTP_AUTHORIZATION=auth,
|
||||
format='json',
|
||||
)
|
||||
self.assertEqual(response.status_code, 201)
|
||||
|
||||
response = self.client.get(
|
||||
reverse('promotions:event-status', kwargs={'event_label': event_label}),
|
||||
|
|
@ -857,25 +845,6 @@ 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)
|
||||
|
|
|
|||
6
apps/promotions/urls/__init__.py
Normal file
6
apps/promotions/urls/__init__.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from .router import router
|
||||
from . import admin_urls
|
||||
|
||||
app_name = 'promotions-admin'
|
||||
|
||||
urlpatterns = router.urls + admin_urls.urlpatterns
|
||||
8
apps/promotions/urls/admin_urls.py
Normal file
8
apps/promotions/urls/admin_urls.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
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 = []
|
||||
3
apps/promotions/urls/router.py
Normal file
3
apps/promotions/urls/router.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
router = DefaultRouter()
|
||||
6
apps/promotions/views/__init__.py
Normal file
6
apps/promotions/views/__init__.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from .admin import AdminPromotionViewSet, AdminReferralViewSet
|
||||
|
||||
__all__ = [
|
||||
'AdminPromotionViewSet',
|
||||
'AdminReferralViewSet',
|
||||
]
|
||||
7
apps/promotions/views/admin/__init__.py
Normal file
7
apps/promotions/views/admin/__init__.py
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
from .promotion import AdminPromotionViewSet
|
||||
from .referral import AdminReferralViewSet
|
||||
|
||||
__all__ = [
|
||||
'AdminPromotionViewSet',
|
||||
'AdminReferralViewSet',
|
||||
]
|
||||
26
apps/promotions/views/admin/promotion.py
Normal file
26
apps/promotions/views/admin/promotion.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
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"]],
|
||||
}
|
||||
35
apps/promotions/views/admin/referral.py
Normal file
35
apps/promotions/views/admin/referral.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
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')
|
||||
|
|
@ -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, rollback_promotion_payout
|
||||
from .models import Plan, Promotion, EventSaver, get_event_status_for_user
|
||||
from .serializers import (
|
||||
PlanSerializer,
|
||||
PromotionSerializer,
|
||||
|
|
@ -20,7 +20,6 @@ from .serializers import (
|
|||
PromoteSerializer,
|
||||
UserPlanSerializer,
|
||||
PromotionStatusSerializer,
|
||||
PromotionRollbackSerializer,
|
||||
)
|
||||
from .tasks import analyze_event_task
|
||||
from ..users.models import User
|
||||
|
|
@ -145,30 +144,6 @@ 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()
|
||||
|
||||
|
|
|
|||
227
docs/adminpanel_technical.md
Normal file
227
docs/adminpanel_technical.md
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
# 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.
|
||||
|
|
@ -1,282 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -385,14 +385,7 @@ 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_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)
|
||||
WALLET_RIAL = config('WALLET_RIAL', cast=str)
|
||||
WALLET_REWARD = config('WALLET_REWARD', cast=str)
|
||||
|
||||
NOTIFICATIONS_BASE_PUBLIC_URL = config('NOTIFICATIONS_BASE_PUBLIC_URL', default=None, cast=str)
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ 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)
|
||||
|
|
|
|||
|
|
@ -75,9 +75,7 @@ def get_notifications_client():
|
|||
return client
|
||||
|
||||
|
||||
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.
|
||||
def notifications_push_user(user_uuid, title, message, priority=5, extras=None):
|
||||
class Tmp():
|
||||
def to_dict(self):
|
||||
return {
|
||||
|
|
@ -85,7 +83,6 @@ 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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue