Compare commits

...

2 commits

Author SHA1 Message Date
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
5 changed files with 247 additions and 224 deletions

View file

@ -2,14 +2,14 @@ from functools import update_wrapper
from django.contrib import admin
from .models import Promotion, Plan, Event, Recipient, EventSaver, AllowedUser, PromotionRollback
from .models import Promotion, Plan, Event, Recipient, EventSaver, AllowedUser, PromotionTransaction
class PromotionAdmin(admin.ModelAdmin):
list_display = ['user_uuid', 'plan', 'promotion_amount', 'recipient', 'event', 'rolled_back_at', 'created_at']
list_display = ['user_uuid', 'plan', 'promotion_amount', 'recipient', 'event', 'state', 'created_at']
class PromotionRollbackAdmin(admin.ModelAdmin):
list_display = ['user_uuid', 'event_label', 'amount', 'settled_at', 'promotion', 'created_at']
list_filter = ['settled_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):
@ -24,5 +24,5 @@ admin.site.register(Event)
admin.site.register(EventSaver)
admin.site.register(Recipient)
admin.site.register(AllowedUser, AllowedUserAdmin)
admin.site.register(PromotionRollback, PromotionRollbackAdmin)
admin.site.register(PromotionTransaction, PromotionTransactionAdmin)

View file

@ -1,39 +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.AddField(
model_name='promotion',
name='rolled_back_at',
field=models.DateTimeField(blank=True, db_index=True, null=True),
),
migrations.CreateModel(
name='PromotionRollback',
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)),
('amount', models.IntegerField(default=0)),
('settled_at', models.DateTimeField(blank=True, db_index=True, null=True)),
('promotion', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='rollback_requests', to='promotions.promotion')),
],
options={
'abstract': False,
},
),
migrations.AddConstraint(
model_name='promotionrollback',
constraint=models.UniqueConstraint(fields=('user_uuid', 'event_label'), name='unique_promotion_rollback'),
),
]

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

@ -364,18 +364,21 @@ class Recipient(BaseModel):
base_amount=base_amount,
)
if recipient and promotion_amount and created:
pending_rollback = PromotionRollback.objects.filter(
user_uuid=recipient, event_label=event.label, settled_at__isnull=True,
).first()
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)
Promotion.objects.filter(pk=promotion.pk, rolled_back_at__isnull=True).update(
rolled_back_at=timezone.now(), updated_at=timezone.now())
PromotionRollback.objects.filter(pk=pending_rollback.pk, settled_at__isnull=True).update(
promotion=promotion, amount=0, settled_at=timezone.now(), updated_at=timezone.now())
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
@ -392,13 +395,9 @@ class Recipient(BaseModel):
promotion.refresh_from_db()
# A rollback request may have arrived while the payout above was in
# flight (so the pre-check missed it). Now that it has settled,
# reverse the money.
late_rollback = PromotionRollback.objects.filter(
user_uuid=recipient, event_label=event.label, settled_at__isnull=True,
).first()
if late_rollback is not None:
late_rollback.resolve()
# 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
@ -460,10 +459,6 @@ class Promotion(BaseModel):
base_amount = models.IntegerField(null=True, blank=True)
promotion_amount = models.IntegerField(null=True, blank=True)
data = models.JSONField(null=True, blank=True)
# Set once the promotion's money has been pulled back out of the advertising
# transit wallet (see rollback_to_credit / PromotionRollback). The promotion
# stays SUCCESS and still counts as "used" -- only the money is returned.
rolled_back_at = models.DateTimeField(null=True, blank=True, db_index=True)
objects = PromotionQuerySet.as_manager()
@ -525,60 +520,38 @@ class Promotion(BaseModel):
error_message='Cannot promote. not in current state'
)
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": payee_wallet,
"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_PROMOTIONS_CREDIT, 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_PROMOTIONS_CREDIT, 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',
)
@ -622,126 +595,182 @@ class Promotion(BaseModel):
and self.recipient.wallet_destination == WalletDestinationChoices.ADVERTISING_TRANSIT
)
def rollback_to_credit(self):
"""Move an already-paid promotion's money from the advertising transit
wallet back to the promotions credit wallet.
Called (via the rollback endpoint) when the advertising service discards
whatever the promotion paid for -- e.g. a captured billboard deleted
while still pending approval. The Promotion row stays SUCCESS and keeps
counting as "used"; the user does not get to earn it again. Only the
money moves.
Returns the amount returned (0 when there is nothing to reverse:
not yet paid, already rolled back, zero amount, or a payout that went
straight to the user's wallet). Idempotent -- safe to call repeatedly
and concurrently.
"""
with transaction.atomic():
promo = Promotion.objects.select_for_update().get(pk=self.pk)
if promo.rolled_back_at is not None:
return 0
if promo.state != PaymentStateChoices.SUCCESS:
return 0
if not promo.promotion_amount:
# balance_holder plans / zero payouts never moved any money
Promotion.objects.filter(pk=promo.pk, rolled_back_at__isnull=True).update(
rolled_back_at=timezone.now(), updated_at=timezone.now())
self.rolled_back_at = timezone.now()
return 0
if not promo.is_rollbackable():
return 0
amount = promo.promotion_amount
payment_uuid = str(uuid.uuid4())
data = {
"uuid": payment_uuid,
"payee": str(promo.user_uuid),
"payee_type": 1,
"payee_wallet": settings.WALLET_PROMOTIONS_CREDIT,
"amount": amount,
"details": {
'description': str(_('promotion rollback')) + f": {promo.recipient.label}",
'reference_id': str(promo.pk),
'application_details_url': '',
},
}
submit_response = deposit_to_user_wallet_submit(settings.WALLET_ADVERTISING_TRANSIT, data)
if not getattr(submit_response, 'uuid', None):
raise Exception('Failed to submit promotion rollback')
verify_response = deposit_to_user_wallet_verify(
settings.WALLET_ADVERTISING_TRANSIT, submit_response.uuid)
if not getattr(verify_response, 'uuid', None):
raise Exception('Failed to verify promotion rollback')
if verify_response.state != 5:
raise Exception('Promotion rollback not confirmed by wallet service')
claimed = Promotion.objects.filter(pk=promo.pk, rolled_back_at__isnull=True).update(
rolled_back_at=timezone.now(), updated_at=timezone.now())
if not claimed:
# someone else stamped it between our checks -- but we already
# moved the money. This should be impossible under select_for_update;
# surface it loudly rather than silently double-pay on a retry.
raise Exception('Promotion rollback raced after wallet transfer')
self.rolled_back_at = timezone.now()
return amount
def is_rolled_back(self):
return self.transactions.filter(
transaction_type=PromotionTransaction.TypeChoices.ROLLBACK,
state=PaymentStateChoices.SUCCESS,
).exists()
class PromotionRollback(BaseModel):
"""A request from the advertising service to pull a promotion's money back
out of the advertising transit wallet (the thing it paid for was removed
before it counted -- e.g. a captured billboard deleted while pending
approval).
IN_FLIGHT_PAYMENT_STATES = (
PaymentStateChoices.CREATED,
PaymentStateChoices.PENDING,
PaymentStateChoices.DELAYED,
PaymentStateChoices.INCOMPLETE,
)
Keyed by (user, event label) because that is all the advertising side
knows. The matching Promotion may not exist yet when the request arrives
(the payout runs in an async task), so this row is also checked by
Recipient.promote() before it pays out: an unsettled request there means
the money is never sent in the first place.
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)
promotion = models.ForeignKey(
Promotion, on_delete=models.SET_NULL, null=True, blank=True, related_name='rollback_requests')
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)
# Set once we've either moved the money back or confirmed there was nothing
# to move (payout suppressed before it happened, or not rollbackable).
settled_at = models.DateTimeField(null=True, blank=True, db_index=True)
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=['user_uuid', 'event_label'], name='unique_promotion_rollback'),
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"rollback {self.event_label} / {self.user_uuid}"
return f"{self.get_transaction_type_display()} {self.amount} ({self.event_label})"
def resolve(self):
"""Try to settle this request against a promotion that already exists.
If none exists yet, stay unsettled -- Recipient.promote() will pick it
up when (if) the payout is processed."""
if self.settled_at is not None:
return self.amount
def _set_state(self, state):
PromotionTransaction.objects.filter(pk=self.pk).update(state=state, updated_at=timezone.now())
self.state = state
promotion = Promotion.objects.filter(
user_uuid=self.user_uuid, event__label=self.event_label,
).order_by('-created_at').first()
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
if promotion is None:
return 0
self._set_state(PaymentStateChoices.PENDING)
if promotion.state in (PaymentStateChoices.CREATED, PaymentStateChoices.PENDING,
PaymentStateChoices.DELAYED, PaymentStateChoices.INCOMPLETE):
# payout is in flight in the worker; let Recipient.promote() settle it
return 0
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': '',
},
}
amount = promotion.rollback_to_credit()
PromotionRollback.objects.filter(pk=self.pk, settled_at__isnull=True).update(
promotion=promotion, amount=amount, settled_at=timezone.now(), updated_at=timezone.now())
self.amount = amount
self.settled_at = timezone.now()
return amount
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

@ -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, PromotionRollback, 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,
@ -160,17 +160,7 @@ class ApplicationEventViewSet(
yet the request is recorded and the payout is suppressed when it runs.
"""
user = self._resolve_user()
rb, _created = PromotionRollback.objects.get_or_create(
user_uuid=user.uuid, event_label=event_label,
)
amount = rb.resolve()
if rb.settled_at is None:
state = 'deferred'
elif amount:
state = 'reversed'
else:
state = 'nothing'
state, amount = rollback_promotion_payout(user.uuid, event_label)
serializer = self.get_serializer({
'event_label': event_label,