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 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): 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): class PromotionTransactionAdmin(admin.ModelAdmin):
list_display = ['user_uuid', 'event_label', 'amount', 'settled_at', 'promotion', 'created_at'] list_display = ['user_uuid', 'event_label', 'transaction_type', 'amount', 'state', 'promotion', 'reverses', 'created_at']
list_filter = ['settled_at'] list_filter = ['transaction_type', 'state']
search_fields = ['user_uuid', 'event_label'] search_fields = ['user_uuid', 'event_label']
class PlanAdmin(admin.ModelAdmin): class PlanAdmin(admin.ModelAdmin):
@ -24,5 +24,5 @@ admin.site.register(Event)
admin.site.register(EventSaver) admin.site.register(EventSaver)
admin.site.register(Recipient) admin.site.register(Recipient)
admin.site.register(AllowedUser, AllowedUserAdmin) 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, base_amount=base_amount,
) )
if recipient and promotion_amount and created: if recipient and promotion_amount and created:
pending_rollback = PromotionRollback.objects.filter( def _pending_rollback():
user_uuid=recipient, event_label=event.label, settled_at__isnull=True, return PromotionTransaction.objects.filter(
).first() 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: if pending_rollback is not None:
# The advertising service already asked to reverse this promotion # The advertising service already asked to reverse this promotion
# before it was even paid -- don't move the money at all. # before it was even paid -- don't move the money at all.
promotion.change_state(from_states=[promotion.state], promotion.change_state(from_states=[promotion.state],
to_state=PaymentStateChoices.SUCCESS, same_ok=True) to_state=PaymentStateChoices.SUCCESS, same_ok=True)
Promotion.objects.filter(pk=promotion.pk, rolled_back_at__isnull=True).update( PromotionTransaction.objects.filter(pk=pending_rollback.pk).update(
rolled_back_at=timezone.now(), updated_at=timezone.now()) promotion=promotion, amount=0, state=PaymentStateChoices.SUCCESS,
PromotionRollback.objects.filter(pk=pending_rollback.pk, settled_at__isnull=True).update( updated_at=timezone.now())
promotion=promotion, amount=0, settled_at=timezone.now(), updated_at=timezone.now())
promotion.refresh_from_db() promotion.refresh_from_db()
return promotion return promotion
@ -392,13 +395,9 @@ class Recipient(BaseModel):
promotion.refresh_from_db() promotion.refresh_from_db()
# A rollback request may have arrived while the payout above was in # A rollback request may have arrived while the payout above was in
# flight (so the pre-check missed it). Now that it has settled, # flight (so the pre-check missed it). Reverse it now that it has settled.
# reverse the money. if _pending_rollback() is not None:
late_rollback = PromotionRollback.objects.filter( rollback_promotion_payout(recipient, event.label)
user_uuid=recipient, event_label=event.label, settled_at__isnull=True,
).first()
if late_rollback is not None:
late_rollback.resolve()
return promotion return promotion
return None return None
@ -460,10 +459,6 @@ class Promotion(BaseModel):
base_amount = models.IntegerField(null=True, blank=True) base_amount = models.IntegerField(null=True, blank=True)
promotion_amount = models.IntegerField(null=True, blank=True) promotion_amount = models.IntegerField(null=True, blank=True)
data = models.JSONField(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() objects = PromotionQuerySet.as_manager()
@ -525,60 +520,38 @@ class Promotion(BaseModel):
error_message='Cannot promote. not in current state' error_message='Cannot promote. not in current state'
) )
payment_uuid = str(self.uuid) # The wallet transfer itself is recorded and driven by a PromotionTransaction
# ledger row (mirrors advertising's AdPayment / escrow's EscrowWalletPayment),
payee_wallet = self.recipient.get_wallet_category_uuid() # so every promotion payout has an auditable record and can be reversed.
payout, _created = PromotionTransaction.objects.get_or_create(
data = { promotion=self,
"uuid": payment_uuid, transaction_type=PromotionTransaction.TypeChoices.PAYOUT,
"payee": str(self.user_uuid), defaults=dict(
"payee_type": 1, user_uuid=self.user_uuid,
"payee_wallet": payee_wallet, event_label=self.event.label if self.event_id else '',
"amount": self.promotion_amount, holder_wallet=settings.WALLET_PROMOTIONS_CREDIT,
"details": { destination_wallet=self.recipient.get_wallet_category_uuid(),
'description': str(_(self.recipient.label)), amount=self.promotion_amount,
'reference_id': str(self.pk), ),
'application_details_url': ''
},
}
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: try:
verify_response = deposit_to_user_wallet_verify(settings.WALLET_PROMOTIONS_CREDIT, submit_response.uuid) transferred = payout.execute()
if not verify_response.uuid: except Exception:
raise Exception('Failed to verify promotion. 1')
except Exception as e:
self.change_state( self.change_state(
from_states=[self.state], from_states=[self.state],
to_state=PaymentStateChoices.EXPECTED_FAILURE, to_state=PaymentStateChoices.EXPECTED_FAILURE,
same_ok=False, same_ok=True,
raise_exception=True, raise_exception=True,
error_message='Failed to update state' error_message='Failed to update state',
) )
# return False raise Exception('Failed to pay promotion')
raise Exception('Failed to verify promition. 2')
if verify_response.state != 5: if not transferred:
to_pay_failed = self.change_state( self.change_state(
from_states=[self.state], from_states=[self.state],
to_state=PaymentStateChoices.FAILED, to_state=PaymentStateChoices.FAILED,
same_ok=False, same_ok=True,
raise_exception=True, raise_exception=True,
error_message='Failed to update state payment', error_message='Failed to update state payment',
) )
@ -622,126 +595,182 @@ class Promotion(BaseModel):
and self.recipient.wallet_destination == WalletDestinationChoices.ADVERTISING_TRANSIT and self.recipient.wallet_destination == WalletDestinationChoices.ADVERTISING_TRANSIT
) )
def rollback_to_credit(self): def is_rolled_back(self):
"""Move an already-paid promotion's money from the advertising transit return self.transactions.filter(
wallet back to the promotions credit wallet. transaction_type=PromotionTransaction.TypeChoices.ROLLBACK,
state=PaymentStateChoices.SUCCESS,
).exists()
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: IN_FLIGHT_PAYMENT_STATES = (
not yet paid, already rolled back, zero amount, or a payout that went PaymentStateChoices.CREATED,
straight to the user's wallet). Idempotent -- safe to call repeatedly PaymentStateChoices.PENDING,
and concurrently. 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``).
""" """
with transaction.atomic():
promo = Promotion.objects.select_for_update().get(pk=self.pk)
if promo.rolled_back_at is not None: class TypeChoices(models.IntegerChoices):
return 0 PAYOUT = 1, _('payout') # promotions credit -> recipient wallet (transit or user)
if promo.state != PaymentStateChoices.SUCCESS: ROLLBACK = 2, _('rollback') # advertising transit -> promotions credit
return 0
if not promo.promotion_amount: promotion = models.ForeignKey(Promotion, on_delete=models.SET_NULL, null=True, blank=True,
# balance_holder plans / zero payouts never moved any money related_name='transactions')
Promotion.objects.filter(pk=promo.pk, rolled_back_at__isnull=True).update( user_uuid = models.UUIDField(db_index=True)
rolled_back_at=timezone.now(), updated_at=timezone.now()) event_label = models.CharField(max_length=255, db_index=True)
self.rolled_back_at = timezone.now() transaction_type = models.IntegerField(choices=TypeChoices.choices, db_index=True)
return 0 holder_wallet = models.UUIDField() # 1st arg to deposit_to_user_wallet_submit
if not promo.is_rollbackable(): destination_wallet = models.UUIDField() # data['payee_wallet']
return 0 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)
amount = promo.promotion_amount
payment_uuid = str(uuid.uuid4())
data = { data = {
"uuid": payment_uuid, "uuid": str(self.external_uuid),
"payee": str(promo.user_uuid), "payee": str(self.user_uuid),
"payee_type": 1, "payee_type": 1,
"payee_wallet": settings.WALLET_PROMOTIONS_CREDIT, "payee_wallet": str(self.destination_wallet),
"amount": amount, "amount": self.amount,
"details": { "details": {
'description': str(_('promotion rollback')) + f": {promo.recipient.label}", 'description': str(self.get_transaction_type_display()),
'reference_id': str(promo.pk), 'reference_id': str(self.pk),
'application_details_url': '', 'application_details_url': '',
}, },
} }
submit_response = deposit_to_user_wallet_submit(settings.WALLET_ADVERTISING_TRANSIT, data) try:
submit_response = deposit_to_user_wallet_submit(str(self.holder_wallet), data)
if not getattr(submit_response, 'uuid', None): if not getattr(submit_response, 'uuid', None):
raise Exception('Failed to submit promotion rollback') raise Exception('wallet submit returned no uuid')
except Exception:
self._set_state(PaymentStateChoices.FAILED)
raise
verify_response = deposit_to_user_wallet_verify( try:
settings.WALLET_ADVERTISING_TRANSIT, submit_response.uuid) verify_response = deposit_to_user_wallet_verify(str(self.holder_wallet), submit_response.uuid)
if not getattr(verify_response, 'uuid', None): if not getattr(verify_response, 'uuid', None):
raise Exception('Failed to verify promotion rollback') raise Exception('wallet verify returned no uuid')
except Exception:
self._set_state(PaymentStateChoices.EXPECTED_FAILURE)
raise
if verify_response.state != 5: if verify_response.state != 5:
raise Exception('Promotion rollback not confirmed by wallet service') self._set_state(PaymentStateChoices.FAILED)
return False
claimed = Promotion.objects.filter(pk=promo.pk, rolled_back_at__isnull=True).update( self._set_state(PaymentStateChoices.SUCCESS)
rolled_back_at=timezone.now(), updated_at=timezone.now()) return True
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
class PromotionRollback(BaseModel): def rollback_promotion_payout(user_uuid, event_label):
"""A request from the advertising service to pull a promotion's money back """Reverse a promotion payout for ``(user_uuid, event_label)`` back out of
out of the advertising transit wallet (the thing it paid for was removed the advertising transit wallet into the promotions credit wallet.
before it counted -- e.g. a captured billboard deleted while pending
approval).
Keyed by (user, event label) because that is all the advertising side The promotion stays consumed -- the user cannot earn it again -- only the
knows. The matching Promotion may not exist yet when the request arrives money moves, and only if the payout actually landed in the transit wallet.
(the payout runs in an async task), so this row is also checked by Idempotent. Returns ``(status, amount)`` where status is one of
Recipient.promote() before it pays out: an unsettled request there means ``'reversed'`` (money moved), ``'deferred'`` (payout not processed yet --
the money is never sent in the first place. ``Recipient.promote()`` will suppress it), ``'nothing'`` (nothing to
reverse: zero payout, or paid straight to the user's wallet).
""" """
user_uuid = models.UUIDField(db_index=True) with transaction.atomic():
event_label = models.CharField(max_length=255, db_index=True) existing = PromotionTransaction.objects.select_for_update().filter(
promotion = models.ForeignKey( user_uuid=user_uuid, event_label=event_label,
Promotion, on_delete=models.SET_NULL, null=True, blank=True, related_name='rollback_requests') transaction_type=PromotionTransaction.TypeChoices.ROLLBACK,
amount = models.IntegerField(default=0) ).first()
# Set once we've either moved the money back or confirmed there was nothing if existing is not None and existing.state == PaymentStateChoices.SUCCESS:
# to move (payout suppressed before it happened, or not rollbackable). return ('reversed' if existing.amount else 'nothing', existing.amount)
settled_at = models.DateTimeField(null=True, blank=True, db_index=True)
class Meta: payout = PromotionTransaction.objects.select_for_update().filter(
constraints = [ user_uuid=user_uuid, event_label=event_label,
models.UniqueConstraint(fields=['user_uuid', 'event_label'], name='unique_promotion_rollback'), transaction_type=PromotionTransaction.TypeChoices.PAYOUT,
] ).first()
def __str__(self): payout_in_flight = payout is not None and payout.state in IN_FLIGHT_PAYMENT_STATES
return f"rollback {self.event_label} / {self.user_uuid}" promotion_settled_without_payout = payout is None and Promotion.objects.filter(
user_uuid=user_uuid, event__label=event_label, state=PaymentStateChoices.SUCCESS,
).exists()
def resolve(self): if payout is not None and payout.state == PaymentStateChoices.SUCCESS \
"""Try to settle this request against a promotion that already exists. and payout.promotion_id and payout.promotion.is_rollbackable() and payout.amount:
If none exists yet, stay unsettled -- Recipient.promote() will pick it amount = payout.amount
up when (if) the payout is processed.""" else:
if self.settled_at is not None: amount = 0
return self.amount
promotion = Promotion.objects.filter( promo = payout.promotion if payout is not None else None
user_uuid=self.user_uuid, event__label=self.event_label,
).order_by('-created_at').first()
if promotion is None: rollback, created = PromotionTransaction.objects.get_or_create(
return 0 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 promotion.state in (PaymentStateChoices.CREATED, PaymentStateChoices.PENDING, if payout is None and not promotion_settled_without_payout:
PaymentStateChoices.DELAYED, PaymentStateChoices.INCOMPLETE): return ('deferred', 0) # honoured later by Recipient.promote()
# payout is in flight in the worker; let Recipient.promote() settle it if payout_in_flight:
return 0 return ('deferred', 0)
if amount == 0:
rollback._set_state(PaymentStateChoices.SUCCESS)
return ('nothing', 0)
amount = promotion.rollback_to_credit() rollback.execute() # raises on wallet failure -> caller aborts + retries
PromotionRollback.objects.filter(pk=self.pk, settled_at__isnull=True).update( return ('reversed', amount)
promotion=promotion, amount=amount, settled_at=timezone.now(), updated_at=timezone.now())
self.amount = amount
self.settled_at = timezone.now()
return amount

View file

@ -12,7 +12,7 @@ from apps.gooyal_oauth2.rest_framework import IsAuthenticatedOrTokenMatchesOASRe
from apps.gooyal_oauth2.utils import get_application from apps.gooyal_oauth2.utils import get_application
from utils.clients.accounts_client import get_user_info from utils.clients.accounts_client import get_user_info
from utils.exceptions import UnprocessableEntity 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 ( from .serializers import (
PlanSerializer, PlanSerializer,
PromotionSerializer, PromotionSerializer,
@ -160,17 +160,7 @@ class ApplicationEventViewSet(
yet the request is recorded and the payout is suppressed when it runs. yet the request is recorded and the payout is suppressed when it runs.
""" """
user = self._resolve_user() user = self._resolve_user()
rb, _created = PromotionRollback.objects.get_or_create( state, amount = rollback_promotion_payout(user.uuid, event_label)
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'
serializer = self.get_serializer({ serializer = self.get_serializer({
'event_label': event_label, 'event_label': event_label,