From 18875af7722c6673fa2bcac23569f30e24ed4751 Mon Sep 17 00:00:00 2001 From: Ali Asadi Date: Tue, 1 Sep 2026 14:47:05 +0330 Subject: [PATCH 1/2] FEATURE(promotions): reversible promotion payouts to the advertising transit wallet When the advertising service discards what a promotion paid for (e.g. a captured billboard deleted while still pending approval), the promotion money sitting in the advertising transit wallet needs to go back to the promotions credit wallet. The promotion itself stays consumed -- only the money is returned -- and only payouts that landed in the transit wallet are reversible (a payout straight to the user's wallet is the user's). - Promotion.rolled_back_at + rollback_to_credit(): row-locked, CAS-stamped, idempotent transfer transit -> credit for SUCCESS/transit-destined payouts. - PromotionRollback model: keyed (user, event_label) -- all the advertising side knows. Handles the async race (payout runs in a Celery task, so the Promotion may not exist yet): Recipient.promote() checks for an unsettled request before paying (suppresses the payout) and after (reverses a payout that landed mid-request). - POST .../event//rollback/ -> {status: reversed|deferred|nothing, amount}. - migration 0011 (hand-written; verified via makemigrations --dry-run + check). Co-Authored-By: Claude Sonnet 5 --- apps/promotions/admin.py | 10 +- ...motion_rolled_back_at_promotionrollback.py | 39 +++++ apps/promotions/models.py | 161 ++++++++++++++++++ apps/promotions/serializers.py | 9 + apps/promotions/views_application.py | 37 +++- 5 files changed, 253 insertions(+), 3 deletions(-) create mode 100644 apps/promotions/migrations/0011_promotion_rolled_back_at_promotionrollback.py diff --git a/apps/promotions/admin.py b/apps/promotions/admin.py index 42451b2..da70181 100644 --- a/apps/promotions/admin.py +++ b/apps/promotions/admin.py @@ -2,10 +2,15 @@ from functools import update_wrapper from django.contrib import admin -from .models import Promotion, Plan, Event, Recipient, EventSaver, AllowedUser +from .models import Promotion, Plan, Event, Recipient, EventSaver, AllowedUser, PromotionRollback class PromotionAdmin(admin.ModelAdmin): - list_display = ['user_uuid', 'plan', 'promotion_amount', 'recipient', 'event', 'created_at'] + list_display = ['user_uuid', 'plan', 'promotion_amount', 'recipient', 'event', 'rolled_back_at', 'created_at'] + +class PromotionRollbackAdmin(admin.ModelAdmin): + list_display = ['user_uuid', 'event_label', 'amount', 'settled_at', 'promotion', 'created_at'] + list_filter = ['settled_at'] + search_fields = ['user_uuid', 'event_label'] class PlanAdmin(admin.ModelAdmin): list_display = ['uuid', 'title', 'balance'] @@ -19,4 +24,5 @@ admin.site.register(Event) admin.site.register(EventSaver) admin.site.register(Recipient) admin.site.register(AllowedUser, AllowedUserAdmin) +admin.site.register(PromotionRollback, PromotionRollbackAdmin) diff --git a/apps/promotions/migrations/0011_promotion_rolled_back_at_promotionrollback.py b/apps/promotions/migrations/0011_promotion_rolled_back_at_promotionrollback.py new file mode 100644 index 0000000..f6742dc --- /dev/null +++ b/apps/promotions/migrations/0011_promotion_rolled_back_at_promotionrollback.py @@ -0,0 +1,39 @@ +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'), + ), + ] diff --git a/apps/promotions/models.py b/apps/promotions/models.py index b93dba0..d9f5743 100644 --- a/apps/promotions/models.py +++ b/apps/promotions/models.py @@ -364,6 +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() + 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()) + promotion.refresh_from_db() + return promotion + try: with transaction.atomic(): reserved = self.plan.reserve_promotion_amount(promotion_amount) @@ -376,6 +391,15 @@ 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). 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() + return promotion return None @@ -436,6 +460,10 @@ 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() @@ -584,3 +612,136 @@ 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 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 + + +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). + + 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. + """ + 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') + 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) + + class Meta: + constraints = [ + models.UniqueConstraint(fields=['user_uuid', 'event_label'], name='unique_promotion_rollback'), + ] + + def __str__(self): + return f"rollback {self.event_label} / {self.user_uuid}" + + 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 + + promotion = Promotion.objects.filter( + user_uuid=self.user_uuid, event__label=self.event_label, + ).order_by('-created_at').first() + + if promotion is None: + return 0 + + 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 + + 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 diff --git a/apps/promotions/serializers.py b/apps/promotions/serializers.py index 50ed72d..f85964c 100644 --- a/apps/promotions/serializers.py +++ b/apps/promotions/serializers.py @@ -90,6 +90,15 @@ class PromotionStatusSerializer(serializers.Serializer): promotion_amount = serializers.IntegerField(read_only=True, allow_null=True) +class PromotionRollbackSerializer(serializers.Serializer): + event_label = serializers.CharField(read_only=True) + # reversed -> money moved back out of the advertising transit wallet + # deferred -> payout not settled yet; it will be suppressed when it runs + # nothing -> nothing to reverse (already rolled back, or paid to the user) + status = serializers.ChoiceField(read_only=True, choices=['reversed', 'deferred', 'nothing']) + amount = serializers.IntegerField(read_only=True) + + class UserPlanSerializer(serializers.ModelSerializer): recipients = UserRecipientSerializer(many=True, read_only=True) class Meta: diff --git a/apps/promotions/views_application.py b/apps/promotions/views_application.py index 2870634..5fb1fff 100644 --- a/apps/promotions/views_application.py +++ b/apps/promotions/views_application.py @@ -12,7 +12,7 @@ from apps.gooyal_oauth2.rest_framework import IsAuthenticatedOrTokenMatchesOASRe from apps.gooyal_oauth2.utils import get_application from utils.clients.accounts_client import get_user_info from utils.exceptions import UnprocessableEntity -from .models import Plan, Promotion, EventSaver, get_event_status_for_user +from .models import Plan, Promotion, PromotionRollback, EventSaver, get_event_status_for_user from .serializers import ( PlanSerializer, PromotionSerializer, @@ -20,6 +20,7 @@ from .serializers import ( PromoteSerializer, UserPlanSerializer, PromotionStatusSerializer, + PromotionRollbackSerializer, ) from .tasks import analyze_event_task from ..users.models import User @@ -144,6 +145,40 @@ 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.+)/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() + 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' + + 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() From ea070ad293cfa5e467224ce57ea8e453f5c05aa5 Mon Sep 17 00:00:00 2001 From: Ali Asadi Date: Tue, 1 Sep 2026 14:47:05 +0330 Subject: [PATCH 2/2] 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//event//rollback/ - migration 0011 (hand-written; verified via makemigrations --dry-run + check) Co-Authored-By: Claude Sonnet 5 --- apps/promotions/admin.py | 10 +- .../migrations/0011_promotiontransaction.py | 43 +++ apps/promotions/models.py | 278 +++++++++++++++--- apps/promotions/serializers.py | 9 + apps/promotions/views_application.py | 27 +- 5 files changed, 320 insertions(+), 47 deletions(-) create mode 100644 apps/promotions/migrations/0011_promotiontransaction.py diff --git a/apps/promotions/admin.py b/apps/promotions/admin.py index 42451b2..b38d835 100644 --- a/apps/promotions/admin.py +++ b/apps/promotions/admin.py @@ -2,10 +2,15 @@ from functools import update_wrapper from django.contrib import admin -from .models import Promotion, Plan, Event, Recipient, EventSaver, AllowedUser +from .models import Promotion, Plan, Event, Recipient, EventSaver, AllowedUser, PromotionTransaction class PromotionAdmin(admin.ModelAdmin): - list_display = ['user_uuid', 'plan', 'promotion_amount', 'recipient', 'event', 'created_at'] + list_display = ['user_uuid', 'plan', 'promotion_amount', 'recipient', 'event', 'state', 'created_at'] + +class PromotionTransactionAdmin(admin.ModelAdmin): + list_display = ['user_uuid', 'event_label', 'transaction_type', 'amount', 'state', 'promotion', 'reverses', 'created_at'] + list_filter = ['transaction_type', 'state'] + search_fields = ['user_uuid', 'event_label'] class PlanAdmin(admin.ModelAdmin): list_display = ['uuid', 'title', 'balance'] @@ -19,4 +24,5 @@ admin.site.register(Event) admin.site.register(EventSaver) admin.site.register(Recipient) admin.site.register(AllowedUser, AllowedUserAdmin) +admin.site.register(PromotionTransaction, PromotionTransactionAdmin) diff --git a/apps/promotions/migrations/0011_promotiontransaction.py b/apps/promotions/migrations/0011_promotiontransaction.py new file mode 100644 index 0000000..81900aa --- /dev/null +++ b/apps/promotions/migrations/0011_promotiontransaction.py @@ -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'), + ), + ] diff --git a/apps/promotions/models.py b/apps/promotions/models.py index b93dba0..35f7338 100644 --- a/apps/promotions/models.py +++ b/apps/promotions/models.py @@ -364,6 +364,24 @@ class Recipient(BaseModel): base_amount=base_amount, ) if recipient and promotion_amount and created: + def _pending_rollback(): + return PromotionTransaction.objects.filter( + user_uuid=recipient, event_label=event.label, + transaction_type=PromotionTransaction.TypeChoices.ROLLBACK, + ).exclude(state=PaymentStateChoices.SUCCESS).first() + + pending_rollback = _pending_rollback() + if pending_rollback is not None: + # The advertising service already asked to reverse this promotion + # before it was even paid -- don't move the money at all. + promotion.change_state(from_states=[promotion.state], + to_state=PaymentStateChoices.SUCCESS, same_ok=True) + PromotionTransaction.objects.filter(pk=pending_rollback.pk).update( + promotion=promotion, amount=0, state=PaymentStateChoices.SUCCESS, + updated_at=timezone.now()) + promotion.refresh_from_db() + return promotion + try: with transaction.atomic(): reserved = self.plan.reserve_promotion_amount(promotion_amount) @@ -376,6 +394,11 @@ class Recipient(BaseModel): same_ok=True) promotion.refresh_from_db() + # A rollback request may have arrived while the payout above was in + # flight (so the pre-check missed it). Reverse it now that it has settled. + if _pending_rollback() is not None: + rollback_promotion_payout(recipient, event.label) + return promotion return None @@ -497,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', ) @@ -584,3 +585,192 @@ class Promotion(BaseModel): error_message='Failed to update state payment', ) return False + + def is_rollbackable(self): + """A promotion payout can only be reversed if it actually landed in the + advertising transit wallet. A payout made straight to the user's income + wallet is the user's money and is never clawed back.""" + return bool( + self.recipient_id + and self.recipient.wallet_destination == WalletDestinationChoices.ADVERTISING_TRANSIT + ) + + def is_rolled_back(self): + return self.transactions.filter( + transaction_type=PromotionTransaction.TypeChoices.ROLLBACK, + state=PaymentStateChoices.SUCCESS, + ).exists() + + +IN_FLIGHT_PAYMENT_STATES = ( + PaymentStateChoices.CREATED, + PaymentStateChoices.PENDING, + PaymentStateChoices.DELAYED, + PaymentStateChoices.INCOMPLETE, +) + + +class PromotionTransaction(BaseModel): + """Ledger of every wallet transfer the promotions service makes -- the + payout for a promotion, and any later reversal of it. Mirrors advertising's + ``AdPayment`` and escrow's ``EscrowWalletPayment``: each row drives one + external wallet call through its own CREATED -> PENDING -> SUCCESS/FAILED + state, so the money movement is auditable and reversible. + + ``user_uuid`` / ``event_label`` are denormalised onto the row so a ROLLBACK + can be recorded before the async payout task has even created its + ``Promotion`` (see :func:`rollback_promotion_payout` and + ``Recipient.promote``). + """ + + class TypeChoices(models.IntegerChoices): + PAYOUT = 1, _('payout') # promotions credit -> recipient wallet (transit or user) + ROLLBACK = 2, _('rollback') # advertising transit -> promotions credit + + promotion = models.ForeignKey(Promotion, on_delete=models.SET_NULL, null=True, blank=True, + related_name='transactions') + user_uuid = models.UUIDField(db_index=True) + event_label = models.CharField(max_length=255, db_index=True) + transaction_type = models.IntegerField(choices=TypeChoices.choices, db_index=True) + holder_wallet = models.UUIDField() # 1st arg to deposit_to_user_wallet_submit + destination_wallet = models.UUIDField() # data['payee_wallet'] + amount = models.IntegerField(default=0) + state = models.IntegerField(choices=PaymentStateChoices.choices, + default=PaymentStateChoices.CREATED, db_index=True) + external_uuid = models.UUIDField(default=uuid.uuid4, editable=False) # idempotency key to the wallet service + reverses = models.ForeignKey('self', on_delete=models.PROTECT, null=True, blank=True, + related_name='reversed_by') + + class Meta: + ordering = ['created_at'] + constraints = [ + models.UniqueConstraint(fields=['promotion', 'transaction_type'], + name='unique_promotion_transaction_type'), + models.UniqueConstraint(fields=['user_uuid', 'event_label'], + condition=models.Q(transaction_type=2), + name='unique_promotion_rollback'), + ] + + def __str__(self): + return f"{self.get_transaction_type_display()} {self.amount} ({self.event_label})" + + def _set_state(self, state): + PromotionTransaction.objects.filter(pk=self.pk).update(state=state, updated_at=timezone.now()) + self.state = state + + def execute(self): + """Run the wallet transfer once. Returns True only on a confirmed + transfer; leaves the row FAILED / EXPECTED_FAILURE (and re-raises) on + error, same as the promote()/AdPayment style. No automatic retry.""" + if self.state == PaymentStateChoices.SUCCESS: + return True + if self.state != PaymentStateChoices.CREATED: + return False + if not self.amount: + self._set_state(PaymentStateChoices.SUCCESS) + return True + + self._set_state(PaymentStateChoices.PENDING) + + data = { + "uuid": str(self.external_uuid), + "payee": str(self.user_uuid), + "payee_type": 1, + "payee_wallet": str(self.destination_wallet), + "amount": self.amount, + "details": { + 'description': str(self.get_transaction_type_display()), + 'reference_id': str(self.pk), + 'application_details_url': '', + }, + } + + try: + submit_response = deposit_to_user_wallet_submit(str(self.holder_wallet), data) + if not getattr(submit_response, 'uuid', None): + raise Exception('wallet submit returned no uuid') + except Exception: + self._set_state(PaymentStateChoices.FAILED) + raise + + try: + verify_response = deposit_to_user_wallet_verify(str(self.holder_wallet), submit_response.uuid) + if not getattr(verify_response, 'uuid', None): + raise Exception('wallet verify returned no uuid') + except Exception: + self._set_state(PaymentStateChoices.EXPECTED_FAILURE) + raise + + if verify_response.state != 5: + self._set_state(PaymentStateChoices.FAILED) + return False + + self._set_state(PaymentStateChoices.SUCCESS) + return True + + +def rollback_promotion_payout(user_uuid, event_label): + """Reverse a promotion payout for ``(user_uuid, event_label)`` back out of + the advertising transit wallet into the promotions credit wallet. + + The promotion stays consumed -- the user cannot earn it again -- only the + money moves, and only if the payout actually landed in the transit wallet. + Idempotent. Returns ``(status, amount)`` where status is one of + ``'reversed'`` (money moved), ``'deferred'`` (payout not processed yet -- + ``Recipient.promote()`` will suppress it), ``'nothing'`` (nothing to + reverse: zero payout, or paid straight to the user's wallet). + """ + with transaction.atomic(): + existing = PromotionTransaction.objects.select_for_update().filter( + user_uuid=user_uuid, event_label=event_label, + transaction_type=PromotionTransaction.TypeChoices.ROLLBACK, + ).first() + if existing is not None and existing.state == PaymentStateChoices.SUCCESS: + return ('reversed' if existing.amount else 'nothing', existing.amount) + + payout = PromotionTransaction.objects.select_for_update().filter( + user_uuid=user_uuid, event_label=event_label, + transaction_type=PromotionTransaction.TypeChoices.PAYOUT, + ).first() + + payout_in_flight = payout is not None and payout.state in IN_FLIGHT_PAYMENT_STATES + promotion_settled_without_payout = payout is None and Promotion.objects.filter( + user_uuid=user_uuid, event__label=event_label, state=PaymentStateChoices.SUCCESS, + ).exists() + + if payout is not None and payout.state == PaymentStateChoices.SUCCESS \ + and payout.promotion_id and payout.promotion.is_rollbackable() and payout.amount: + amount = payout.amount + else: + amount = 0 + + promo = payout.promotion if payout is not None else None + + rollback, created = PromotionTransaction.objects.get_or_create( + user_uuid=user_uuid, event_label=event_label, + transaction_type=PromotionTransaction.TypeChoices.ROLLBACK, + defaults=dict( + promotion=promo, + holder_wallet=settings.WALLET_ADVERTISING_TRANSIT, + destination_wallet=settings.WALLET_PROMOTIONS_CREDIT, + amount=amount, + reverses=payout, + ), + ) + if not created and rollback.state != PaymentStateChoices.SUCCESS: + # refresh the amount/links now that the payout may have appeared + PromotionTransaction.objects.filter(pk=rollback.pk).exclude( + state=PaymentStateChoices.SUCCESS).update( + promotion=promo, amount=amount, reverses=payout, updated_at=timezone.now()) + rollback.amount = amount + + if payout is None and not promotion_settled_without_payout: + return ('deferred', 0) # honoured later by Recipient.promote() + if payout_in_flight: + return ('deferred', 0) + if amount == 0: + rollback._set_state(PaymentStateChoices.SUCCESS) + return ('nothing', 0) + + rollback.execute() # raises on wallet failure -> caller aborts + retries + return ('reversed', amount) diff --git a/apps/promotions/serializers.py b/apps/promotions/serializers.py index 50ed72d..f85964c 100644 --- a/apps/promotions/serializers.py +++ b/apps/promotions/serializers.py @@ -90,6 +90,15 @@ class PromotionStatusSerializer(serializers.Serializer): promotion_amount = serializers.IntegerField(read_only=True, allow_null=True) +class PromotionRollbackSerializer(serializers.Serializer): + event_label = serializers.CharField(read_only=True) + # reversed -> money moved back out of the advertising transit wallet + # deferred -> payout not settled yet; it will be suppressed when it runs + # nothing -> nothing to reverse (already rolled back, or paid to the user) + status = serializers.ChoiceField(read_only=True, choices=['reversed', 'deferred', 'nothing']) + amount = serializers.IntegerField(read_only=True) + + class UserPlanSerializer(serializers.ModelSerializer): recipients = UserRecipientSerializer(many=True, read_only=True) class Meta: diff --git a/apps/promotions/views_application.py b/apps/promotions/views_application.py index 2870634..56c6fbc 100644 --- a/apps/promotions/views_application.py +++ b/apps/promotions/views_application.py @@ -12,7 +12,7 @@ from apps.gooyal_oauth2.rest_framework import IsAuthenticatedOrTokenMatchesOASRe from apps.gooyal_oauth2.utils import get_application from utils.clients.accounts_client import get_user_info from utils.exceptions import UnprocessableEntity -from .models import Plan, Promotion, EventSaver, get_event_status_for_user +from .models import Plan, Promotion, EventSaver, get_event_status_for_user, rollback_promotion_payout from .serializers import ( PlanSerializer, PromotionSerializer, @@ -20,6 +20,7 @@ from .serializers import ( PromoteSerializer, UserPlanSerializer, PromotionStatusSerializer, + PromotionRollbackSerializer, ) from .tasks import analyze_event_task from ..users.models import User @@ -144,6 +145,30 @@ class ApplicationEventViewSet( serializer = self.get_serializer(get_event_status_for_user(user.uuid, event_label)) return Response(serializer.data) + @action( + detail=False, + methods=['POST'], + url_path=r'(?P.+)/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()