Skip to content

Commit

Permalink
🛑 Add django admin action to refund a transaction
Browse files Browse the repository at this point in the history
Add a Django admin action that triggers a refund of a transaction.
Consequently, add a hook to the payment callback class that performs
post-refund actions. For tournament, this post-refund hook finds any
registration paid linked to the tournament and user in question,
destroys it, and cancels associated tickets.
  • Loading branch information
Lymkwi committed Oct 23, 2023
1 parent 5229559 commit eb0fc89
Show file tree
Hide file tree
Showing 5 changed files with 112 additions and 5 deletions.
16 changes: 15 additions & 1 deletion insalan/payment/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
# pylint: disable=R0903

from django import forms
from django.contrib import admin
from django.contrib import admin, messages

from django.utils.translation import gettext_lazy as _

from .models import Product, Transaction

Expand All @@ -18,6 +20,16 @@ class ProductAdmin(admin.ModelAdmin):
admin.site.register(Product, ProductAdmin)


@admin.action(description=_("Rembourser la transaction"))
def reimburse_transactions(modeladmin, request, queryset):
"""Reimburse all selected actions"""
for transaction in queryset:
(is_err, msg) = transaction.refund(request.user.username)
if is_err:
modeladmin.message_user(request, _("Erreur: %s") % msg, messages.ERROR)
break


class TransactionAdmin(admin.ModelAdmin):
"""
Admin handler for Transactions
Expand All @@ -44,6 +56,8 @@ class TransactionAdmin(admin.ModelAdmin):
"amount",
]

actions = [reimburse_transactions]

def has_add_permission(self, request):
"""Remove the ability to add a transaction from the backoffice """
return False
Expand Down
10 changes: 10 additions & 0 deletions insalan/payment/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,5 +102,15 @@ def payment_failure(_transaction, _product, _count):
and that `.payment_status` on the transaction object is set to `FAILED`.
"""

@staticmethod
def payment_refunded(_transaction, _product, _count):
"""
Payment Refund Handler
This method handles the process of cleaning up after a refund of a
transaction. By this point, you can safely assume that the payment has
been refunded on the side of helloasso.
"""


# vim: set tw=80:
44 changes: 44 additions & 0 deletions insalan/payment/models.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import logging
import itertools

import requests
import uuid

from decimal import Decimal
from datetime import datetime
from os import getenv

from django.db import models
from django.utils.translation import gettext_lazy as _
Expand All @@ -14,6 +16,8 @@
from insalan.tournament.models import Tournament
from insalan.user.models import User

from .tokens import Token

logger = logging.getLogger(__name__)


Expand All @@ -23,6 +27,7 @@ class TransactionStatus(models.TextChoices):
FAILED = "FAILED", _("échouée")
SUCCEEDED = "SUCCEEDED", _("Réussie")
PENDING = "PENDING", _("En attente")
REFUNDED = "REFUNDED", _("Remboursé")


class ProductCategory(models.TextChoices):
Expand Down Expand Up @@ -173,6 +178,45 @@ def run_success_hooks(self):
"""Run the success hooks on all products"""
self.product_callback(lambda cls: cls.payment_success)

def run_refunded_hooks(self):
"""Run the refund hooks on all products"""
self.product_callback(lambda cls: cls.payment_refunded)

def refund(self, requester) -> tuple[bool, str]:
"""Refund this transaction"""
if self.payment_status == TransactionStatus.REFUNDED:
return (False, "")

helloasso_url = getenv("HELLOASS_ENDPOINT")
token = Token()
body_refund = {"comment": f"Refunded by {requester}"}
headers_refund = {
"authorization": "Bearer " + token.get_token(),
"Content-Type": "application/json",
}

refund_init = requests.post(
f"{helloasso_url}/v5/payment/{self.intent_id}/refund",
data=body_refund,
headers=headers_refund,
timeout=1,
)

if refund_init.status_code != 200:
return (
False,
_("Erreur de remboursement: code %s obtenu via l'API")
% refund_init.status_code,
)

self.payment_status = TransactionStatus.REFUNDED

self.run_refunded_hooks()
self.touch()
self.save()

return (False, "")

def synchronize_amount(self):
"""Recompute the amount from the product list"""
self.amount = Decimal(0.00)
Expand Down
1 change: 1 addition & 0 deletions insalan/payment/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ def create(self, request):
f"{HELLOASSO_URL}/v5/organizations/insalan-test/checkout-intents",
data=json.dumps(intent_body),
headers=headers,
timeout=1
) # initiate a helloasso intent
logger.debug(checkout_init.text)
checkout_json = checkout_init.json()
Expand Down
46 changes: 42 additions & 4 deletions insalan/tournament/payment.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Handling of the payment of a registration (player and manager)"""

import logging

from insalan.payment.models import ProductCategory
from insalan.payment.hooks import PaymentHooks, PaymentCallbackSystem

Expand All @@ -9,6 +11,9 @@
from insalan.tournament.models import Player, Manager, PaymentStatus


logger = logging.getLogger("insalan.tournament.hooks")


class PaymentHandler(PaymentHooks):
"""Handler of the payment of a ticket/registration"""

Expand Down Expand Up @@ -95,13 +100,46 @@ def payment_failure(transaction, product, _count):
# Whatever happens, just delete the registration
reg.delete()

@staticmethod
def payment_refunded(transaction, product, _count):
"""Handle a refund of a registration"""

# Find a registration that was ongoing for the user
assoc_tourney = product.associated_tournament
if assoc_tourney is None:
raise RuntimeError(_("Tournoi associé à un produit acheté nul!"))

reg_list = Player.objects.filter(
user=transaction.payer, team__tournament=product.associated_tournament
)
if len(reg_list) == 0:
reg_list = Manager.objects.filter(
user=transaction.payer, team__tournament=product.associated_tournament
)
if len(reg_list) == 0:
logger.warn(
_("Aucune inscription à détruire trouvée pour le refund de %s")
% transaction.id
)
return

reg = reg_list[0]
team = reg.team
ticket = reg.ticket
reg.delete()

team.refresh_validation()

if ticket is not None:
ticket.status = Ticket.Status.CANCELLED
ticket.save()


def payment_handler_register():
"""Register the callbacks"""
PaymentCallbackSystem.register_handler(
ProductCategory.REGISTRATION_PLAYER, PaymentHandler,
overwrite = True
ProductCategory.REGISTRATION_PLAYER, PaymentHandler, overwrite=True
)
PaymentCallbackSystem.register_handler(
ProductCategory.REGISTRATION_MANAGER, PaymentHandler,
overwrite = True
ProductCategory.REGISTRATION_MANAGER, PaymentHandler, overwrite=True
)

0 comments on commit eb0fc89

Please sign in to comment.