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>
This commit is contained in:
parent
161664cee9
commit
ea070ad293
5 changed files with 320 additions and 47 deletions
|
|
@ -2,10 +2,15 @@ from functools import update_wrapper
|
||||||
|
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from .models import Promotion, Plan, Event, Recipient, EventSaver, AllowedUser
|
from .models import Promotion, Plan, Event, Recipient, EventSaver, AllowedUser, PromotionTransaction
|
||||||
|
|
||||||
class PromotionAdmin(admin.ModelAdmin):
|
class PromotionAdmin(admin.ModelAdmin):
|
||||||
list_display = ['user_uuid', 'plan', 'promotion_amount', 'recipient', 'event', 'created_at']
|
list_display = ['user_uuid', 'plan', 'promotion_amount', 'recipient', 'event', 'state', 'created_at']
|
||||||
|
|
||||||
|
class PromotionTransactionAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ['user_uuid', 'event_label', 'transaction_type', 'amount', 'state', 'promotion', 'reverses', 'created_at']
|
||||||
|
list_filter = ['transaction_type', 'state']
|
||||||
|
search_fields = ['user_uuid', 'event_label']
|
||||||
|
|
||||||
class PlanAdmin(admin.ModelAdmin):
|
class PlanAdmin(admin.ModelAdmin):
|
||||||
list_display = ['uuid', 'title', 'balance']
|
list_display = ['uuid', 'title', 'balance']
|
||||||
|
|
@ -19,4 +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(PromotionTransaction, PromotionTransactionAdmin)
|
||||||
|
|
||||||
|
|
|
||||||
43
apps/promotions/migrations/0011_promotiontransaction.py
Normal file
43
apps/promotions/migrations/0011_promotiontransaction.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -364,6 +364,24 @@ 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:
|
||||||
|
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:
|
try:
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
reserved = self.plan.reserve_promotion_amount(promotion_amount)
|
reserved = self.plan.reserve_promotion_amount(promotion_amount)
|
||||||
|
|
@ -376,6 +394,11 @@ class Recipient(BaseModel):
|
||||||
same_ok=True)
|
same_ok=True)
|
||||||
promotion.refresh_from_db()
|
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 promotion
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
@ -497,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:
|
try:
|
||||||
submit_response = deposit_to_user_wallet_submit(settings.WALLET_PROMOTIONS_CREDIT, data)
|
transferred = payout.execute()
|
||||||
if not submit_response.uuid:
|
except Exception:
|
||||||
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:
|
|
||||||
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',
|
||||||
)
|
)
|
||||||
|
|
@ -584,3 +585,192 @@ class Promotion(BaseModel):
|
||||||
error_message='Failed to update state payment',
|
error_message='Failed to update state payment',
|
||||||
)
|
)
|
||||||
return False
|
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)
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,15 @@ class PromotionStatusSerializer(serializers.Serializer):
|
||||||
promotion_amount = serializers.IntegerField(read_only=True, allow_null=True)
|
promotion_amount = serializers.IntegerField(read_only=True, allow_null=True)
|
||||||
|
|
||||||
|
|
||||||
|
class PromotionRollbackSerializer(serializers.Serializer):
|
||||||
|
event_label = serializers.CharField(read_only=True)
|
||||||
|
# reversed -> money moved back out of the advertising transit wallet
|
||||||
|
# deferred -> payout not settled yet; it will be suppressed when it runs
|
||||||
|
# nothing -> nothing to reverse (already rolled back, or paid to the user)
|
||||||
|
status = serializers.ChoiceField(read_only=True, choices=['reversed', 'deferred', 'nothing'])
|
||||||
|
amount = serializers.IntegerField(read_only=True)
|
||||||
|
|
||||||
|
|
||||||
class UserPlanSerializer(serializers.ModelSerializer):
|
class UserPlanSerializer(serializers.ModelSerializer):
|
||||||
recipients = UserRecipientSerializer(many=True, read_only=True)
|
recipients = UserRecipientSerializer(many=True, read_only=True)
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
|
||||||
|
|
@ -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, 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,
|
||||||
|
|
@ -20,6 +20,7 @@ from .serializers import (
|
||||||
PromoteSerializer,
|
PromoteSerializer,
|
||||||
UserPlanSerializer,
|
UserPlanSerializer,
|
||||||
PromotionStatusSerializer,
|
PromotionStatusSerializer,
|
||||||
|
PromotionRollbackSerializer,
|
||||||
)
|
)
|
||||||
from .tasks import analyze_event_task
|
from .tasks import analyze_event_task
|
||||||
from ..users.models import User
|
from ..users.models import User
|
||||||
|
|
@ -144,6 +145,30 @@ class ApplicationEventViewSet(
|
||||||
serializer = self.get_serializer(get_event_status_for_user(user.uuid, event_label))
|
serializer = self.get_serializer(get_event_status_for_user(user.uuid, event_label))
|
||||||
return Response(serializer.data)
|
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):
|
def perform_create(self, serializer: EventSerializer):
|
||||||
user = self._resolve_user()
|
user = self._resolve_user()
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue