From 2c847a5ae66d68e0434f1f1ddc846e5811737500 Mon Sep 17 00:00:00 2001 From: Rechner Fox <659028+rechner@users.noreply.github.com> Date: Fri, 2 Feb 2024 01:20:52 -0800 Subject: [PATCH] Add dispute webhook information handling (#301) * Add dispute webhook information handling * Add order admin warning for disputed transactions * Woops * Fix agree to rules checkboxes * Add payment dispute email notification * Add email call * Fix dispute email salutation --- registration/admin.py | 11 +- registration/emails.py | 22 +- registration/models.py | 35 +++- registration/payments.py | 63 +++++- .../registration/dealer/dealer-form.html | 6 +- .../emails/chargeback-notice.html | 23 +++ .../registration/emails/chargeback-notice.txt | 17 ++ .../templates/registration/onsite.html | 7 +- .../registration/registration-form.html | 7 +- .../registration/staff/staff-new-payment.html | 4 +- registration/tests/test_emails.py | 14 ++ registration/tests/test_model.py | 23 ++- registration/tests/test_webhooks.py | 191 +++++++++++++++++- registration/views/webhooks.py | 7 +- .../admin/registration/order/change_form.html | 17 +- 15 files changed, 421 insertions(+), 26 deletions(-) create mode 100644 registration/templates/registration/emails/chargeback-notice.html create mode 100644 registration/templates/registration/emails/chargeback-notice.txt diff --git a/registration/admin.py b/registration/admin.py index 43bcaab6..3b5632a2 100644 --- a/registration/admin.py +++ b/registration/admin.py @@ -984,14 +984,14 @@ def generate_badge_labels(queryset, request): def get_badge_type(badge): - #check if staff + # check if staff try: staff = Staff.objects.get(attendee=badge.attendee, event=badge.event) except Staff.DoesNotExist: pass else: return "Staff" - #check if dealer + # check if dealer try: dealers = Dealer.objects.get(attendee=badge.attendee, event=badge.event) except Dealer.DoesNotExist: @@ -1330,8 +1330,13 @@ def render_change_form(self, request, context, *args, **kwargs): f"Error while loading JSON from apiData field for this order: {obj}", ) logger.warning( - f"Error while loading JSON from api_data for order {obj}" + f"Error while loading JSON from api_data for order {obj}", ) + else: + if "dispute" in obj.apiData: + messages.warning( + request, "This transaction has been disputed by the cardholder" + ) return super(OrderAdmin, self).render_change_form( request, context, *args, **kwargs diff --git a/registration/emails.py b/registration/emails.py index 4aef768a..bd68bdc7 100644 --- a/registration/emails.py +++ b/registration/emails.py @@ -302,7 +302,26 @@ def send_dealer_approval_email(dealerQueryset): ) -def send_email(reply_address, to_address_list, subject, message, html_message): +def send_chargeback_notice_email(order): + event = Event.objects.get(default=True) + data = { + "event": event, + "order": order, + } + msg_txt = render_to_string("registration/emails/chargeback-notice.txt", data) + msg_html = render_to_string("registration/emails/chargeback-notice.html", data) + registration_email = registration.views.common.get_registration_email(event) + send_email( + registration_email, + [order.billingEmail], + f"Important information about your {event.name} registration.", + msg_txt, + msg_html, + bcc=[registration_email], + ) + + +def send_email(reply_address, to_address_list, subject, message, html_message, bcc=[]): logger.debug("Enter send_email...") mail_message = EmailMultiAlternatives( subject, @@ -310,6 +329,7 @@ def send_email(reply_address, to_address_list, subject, message, html_message): settings.APIS_DEFAULT_EMAIL, to_address_list, reply_to=[reply_address], + bcc=bcc, ) logger.debug("Message to: {0}".format(to_address_list)) mail_message.attach_alternative(html_message, "text/html") diff --git a/registration/models.py b/registration/models.py index 442e7451..8172192e 100644 --- a/registration/models.py +++ b/registration/models.py @@ -22,6 +22,15 @@ class HoldType(LookupTable): pass +def get_hold_type(hold_name) -> HoldType: + try: + dispute_hold = HoldType.objects.get(name=hold_name) + except HoldType.DoesNotExist: + dispute_hold = HoldType(name=hold_name) + dispute_hold.save() + return dispute_hold + + class ShirtSizes(LookupTable): class Meta: db_table = "registration_shirt_sizes" @@ -685,6 +694,13 @@ class Order(models.Model): FAILED = "Failed" # Card was rejected by online authorization REFUNDED = "Refunded" REFUND_PENDING = "Refund Pending" + DISPUTE_EVIDENCE_REQUIRED = ( + "Dispute Evidence Required" # Initial state of a dispute with evidence required + ) + DISPUTE_PROCESSING = "Dispute Processing" # Dispute evidence has been submitted and the bank is processing + DISPUTE_WON = "Dispute Won" # The bank has completed processing the dispute and the seller has won + DISPUTE_LOST = "Dispute Lost" # The bank has completed processing the dispute and the seller has lost + DISPUTE_ACCEPTED = "Dispute Accepted" # The seller has accepted the dispute STATUS_CHOICES = ( (PENDING, "Pending"), (CAPTURED, "Captured"), @@ -692,7 +708,24 @@ class Order(models.Model): (REFUNDED, "Refunded"), (REFUND_PENDING, "Refund Pending"), (FAILED, "Failed"), - ) + (DISPUTE_EVIDENCE_REQUIRED, "Dispute Evidence Required"), + (DISPUTE_PROCESSING, "Dispute Processing"), + (DISPUTE_WON, "Dispute Won"), + (DISPUTE_LOST, "Dispute Lost"), + (DISPUTE_ACCEPTED, "Dispute Accepted"), + ) + # Maps Square dispute status to above status choices + DISPUTE_STATUS_MAP = { + "EVIDENCE_REQUIRED": DISPUTE_EVIDENCE_REQUIRED, + "PROCESSING": DISPUTE_PROCESSING, + "WON": DISPUTE_WON, + "LOST": DISPUTE_LOST, + "ACCEPTED": DISPUTE_ACCEPTED, + # Not certain what these states are for? + "INQUIRY_EVIDENCE_REQUIRED": DISPUTE_EVIDENCE_REQUIRED, + "INQUIRY_PROCESSING": DISPUTE_PROCESSING, + "INQUIRY_CLOSED": DISPUTE_WON, + } total = models.DecimalField(max_digits=8, decimal_places=2) status = models.CharField(max_length=50, choices=STATUS_CHOICES, default=PENDING) reference = models.CharField(max_length=50) diff --git a/registration/payments.py b/registration/payments.py index b58bddb5..b228bd84 100644 --- a/registration/payments.py +++ b/registration/payments.py @@ -1,10 +1,12 @@ import json import logging import uuid +from datetime import datetime from django.conf import settings from square.client import Client +from . import emails from .models import * client = Client( @@ -366,7 +368,9 @@ def process_webhook_refund_update(notification) -> bool: try: order = Order.objects.get(apiData__refunds__contains=[{"id": refund_id}]) except Order.DoesNotExist: - logger.warning(f"Got refund.updated webhook update for a refund id not found: {refund_id}") + logger.warning( + f"Got refund.updated webhook update for a refund id not found: {refund_id}" + ) return False webhook_refund = notification.body["data"]["object"]["refund"] @@ -390,9 +394,11 @@ def process_webhook_refund_update(notification) -> bool: def process_webhook_payment_updated(notification: PaymentWebhookNotification) -> bool: payment_id = notification.body["data"]["id"] try: - order = Order.objects.get(apiData__payment={"id": payment_id}) + order = Order.objects.get(apiData__payment__id=payment_id) except Order.DoesNotExist: - logger.warning(f"Got refund.updated webhook update for a payment id not found: {payment_id}") + logger.warning( + f"Got payment.updated webhook update for a payment id not found: {payment_id}" + ) return False # Store order update in api data @@ -409,9 +415,11 @@ def process_webhook_refund_created(notification: PaymentWebhookNotification) -> webhook_refund = notification.body["data"]["object"]["refund"] payment_id = webhook_refund["payment_id"] try: - order = Order.objects.get(apiData__payment={"id": payment_id}) + order = Order.objects.get(apiData__payment__id=payment_id) except Order.DoesNotExist: - logger.warning(f"Got refund.created webhook update for a payment id not found: {payment_id}") + logger.warning( + f"Got refund.created webhook update for a payment id not found: {payment_id}" + ) return False # Skip processing if we already have this refund id stored: @@ -446,3 +454,48 @@ def process_webhook_refund_created(notification: PaymentWebhookNotification) -> order.save() return True + + +def process_webhook_dispute_created_or_updated( + notification: PaymentWebhookNotification, +) -> bool: + webhook_dispute = notification.body["data"]["object"]["dispute"] + payment_id = webhook_dispute["disputed_payment"]["payment_id"] + try: + order = Order.objects.get(apiData__payment__id=payment_id) + except Order.DoesNotExist: + logger.warning( + f"Got dispute.created webhook update for a payment id not found: {payment_id}" + ) + return False + + # Add the dispute API data to the order: + order.apiData["dispute"] = webhook_dispute + order.status = Order.DISPUTE_STATUS_MAP[webhook_dispute["state"]] + order.save() + + # Place a hold on all new disputed orders, and add attendee to the ban list. Should only do this once, + # when the dispute is created (with state EVIDENCE_REQUIRED). + if webhook_dispute["state"] == "EVIDENCE_REQUIRED": + dispute_hold = get_hold_type("Chargeback") + order_items = OrderItem.objects.filter(order=order) + # Add dispute hold to all attendees on the order + for oi in order_items: + attendee = oi.badge.attendee + attendee.holdType = dispute_hold + attendee.save() + + # Add all attendees to the ban list + ban = BanList( + firstName=attendee.firstName, + lastName=attendee.lastName, + email=attendee.email, + reason=f"Initiated chargeback [APIS {datetime.now().isoformat()}]", + ) + + ban.save() + + # Send an email about it + emails.send_chargeback_notice_email(order) + + return True diff --git a/registration/templates/registration/dealer/dealer-form.html b/registration/templates/registration/dealer/dealer-form.html index ed4939e1..ed103bb5 100644 --- a/registration/templates/registration/dealer/dealer-form.html +++ b/registration/templates/registration/dealer/dealer-form.html @@ -206,11 +206,11 @@