Compare commits

..

No commits in common. "db2f0afcd5a87717f40d1fbb58141f224d359c1d" and "18875af7722c6673fa2bcac23569f30e24ed4751" have entirely different histories.

5 changed files with 228 additions and 251 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, PromotionTransaction from .models import Promotion, Plan, Event, Recipient, EventSaver, AllowedUser, PromotionRollback
class PromotionAdmin(admin.ModelAdmin): class PromotionAdmin(admin.ModelAdmin):
list_display = ['user_uuid', 'plan', 'promotion_amount', 'recipient', 'event', 'state', 'created_at'] list_display = ['user_uuid', 'plan', 'promotion_amount', 'recipient', 'event', 'rolled_back_at', 'created_at']
class PromotionTransactionAdmin(admin.ModelAdmin): class PromotionRollbackAdmin(admin.ModelAdmin):
list_display = ['user_uuid', 'event_label', 'transaction_type', 'amount', 'state', 'promotion', 'reverses', 'created_at'] list_display = ['user_uuid', 'event_label', 'amount', 'settled_at', 'promotion', 'created_at']
list_filter = ['transaction_type', 'state'] list_filter = ['settled_at']
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(PromotionTransaction, PromotionTransactionAdmin) admin.site.register(PromotionRollback, PromotionRollbackAdmin)

View file

@ -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'),
),
]

View file

@ -1,43 +0,0 @@
import uuid
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('promotions', '0010_recipient_wallet_destination'),
]
operations = [
migrations.CreateModel(
name='PromotionTransaction',
fields=[
('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True, db_index=True)),
('user_uuid', models.UUIDField(db_index=True)),
('event_label', models.CharField(db_index=True, max_length=255)),
('transaction_type', models.IntegerField(choices=[(1, 'payout'), (2, 'rollback')], db_index=True)),
('holder_wallet', models.UUIDField()),
('destination_wallet', models.UUIDField()),
('amount', models.IntegerField(default=0)),
('state', models.IntegerField(choices=[(1, 'created'), (2, 'delayed'), (3, 'pending'), (4, 'incomplete'), (5, 'success'), (6, 'failed'), (7, 'expected_failure')], db_index=True, default=1)),
('external_uuid', models.UUIDField(default=uuid.uuid4, editable=False)),
('promotion', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transactions', to='promotions.promotion')),
('reverses', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='reversed_by', to='promotions.promotiontransaction')),
],
options={
'ordering': ['created_at'],
},
),
migrations.AddConstraint(
model_name='promotiontransaction',
constraint=models.UniqueConstraint(fields=('promotion', 'transaction_type'), name='unique_promotion_transaction_type'),
),
migrations.AddConstraint(
model_name='promotiontransaction',
constraint=models.UniqueConstraint(condition=models.Q(('transaction_type', 2)), fields=('user_uuid', 'event_label'), name='unique_promotion_rollback'),
),
]

View file

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

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, EventSaver, get_event_status_for_user, rollback_promotion_payout from .models import Plan, Promotion, PromotionRollback, EventSaver, get_event_status_for_user
from .serializers import ( from .serializers import (
PlanSerializer, PlanSerializer,
PromotionSerializer, PromotionSerializer,
@ -160,7 +160,17 @@ 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()
state, amount = rollback_promotion_payout(user.uuid, event_label) 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({ serializer = self.get_serializer({
'event_label': event_label, 'event_label': event_label,