Skip to content

Commit

Permalink
Add dispute webhook information handling (#301)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
rechner authored Feb 2, 2024
1 parent c1c4b0f commit 2c847a5
Show file tree
Hide file tree
Showing 15 changed files with 421 additions and 26 deletions.
11 changes: 8 additions & 3 deletions registration/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
22 changes: 21 additions & 1 deletion registration/emails.py
Original file line number Diff line number Diff line change
Expand Up @@ -302,14 +302,34 @@ 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,
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")
Expand Down
35 changes: 34 additions & 1 deletion registration/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -685,14 +694,38 @@ 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"),
(COMPLETED, "Completed"),
(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)
Expand Down
63 changes: 58 additions & 5 deletions registration/payments.py
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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"]
Expand All @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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
6 changes: 3 additions & 3 deletions registration/templates/registration/dealer/dealer-form.html
Original file line number Diff line number Diff line change
Expand Up @@ -206,11 +206,11 @@ <h2>Placement Options</h2>
</div>
</div>
<hr/>
<div>
<div>
<div class="form-group">
<div class="checkbox col-sm-12">
<label>
<input type="checkbox" id="agreeToRules" name="agreeToRules" class="form-control-checkbox"
required>
required data-error="You must agree to the event code of conduct to register.">
I have read and understand the rules pertaining to both {{ event.name }} and the Markeplace. I agree to
abide by the {{ event.name }} <a href="{{ event.codeOfConduct }}" target="_blank">Code of Conduct</a>. By
registering for {{ event }} you attest that you are not listed on any sexual offender registry.
Expand Down
23 changes: 23 additions & 0 deletions registration/templates/registration/emails/chargeback-notice.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<p>Hello {{ order.billingName }},</p>

<p>Our systems have detected a chargeback dispute initated in relation to your registration transaction.</p>

<p>
Consistent with our refund policy as outlined by the attendee <a href="{{ event.codeOfConduct }}">code of conduct</a>,
your order has been placed on hold, and you will be unable to retrieve your badge or register for {{ event.name }}
again at least until the dispute is settled.
</p>

<p>
Chargeback disputes (denying a charge) made for the sole purpose of avoiding payment, made without sufficient cause,
or that are made without first attempting to resolve the dispute directly may result in permanent revocation of
membership privileges.
</p>
<p>
Please contact {{ event.registrationEmail }} for any questions or assistance.
</p>

<p>
--<br>
{{ event.name }} <a href="mailto:{{ event.registrationEmail }}">{{ event.registrationEmail }}</a>
</p>
17 changes: 17 additions & 0 deletions registration/templates/registration/emails/chargeback-notice.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
Hello {{ order.billingName }},

Our systems have detected a chargeback dispute initated in relation to your registration transaction.

Consistent with our refund policy as outlined by the attendee code of conduct, your order has been placed on hold,
and you will be unable to retrieve your badge or register for {{ event.name }} again at least until
the dispute is settled.

Chargeback disputes (denying a charge) made for the sole purpose of avoiding payment, made without sufficient cause,
or that are made without first attempting to resolve the dispute directly may result in permanent revocation of
membership privileges.

Please contact {{ event.registrationEmail }} for any questions or assistance.

--
{{ event.name }} <{{ event.registrationEmail }}>
{{ event.codeOfConduct }}
7 changes: 4 additions & 3 deletions registration/templates/registration/onsite.html
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,11 @@ <h2>Registration Info</h2>
<div class="row" id="levelContainer"></div>
<br/>
<hr/>
<div>
<div>
<div class="form-group">
<div class="checkbox col-sm-12">
<label>
<input type="checkbox" id="agreeToRules" name="agreeToRules" class="form-control-checkbox" required>
<input type="checkbox" id="agreeToRules" name="agreeToRules" class="form-control-checkbox" required
data-error="You must agree to the event code of conduct to register.">
I agree to abide by the {{ event.name }} <a href="{{ event.codeOfConduct }}" target="_blank">Code of
Conduct</a>. By registering for {{ event }} you attest that you are not listed on any sexual offender
registry.
Expand Down
7 changes: 4 additions & 3 deletions registration/templates/registration/registration-form.html
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,11 @@ <h3><a name="level"></a>Select your registration options and level.</h3>
</div>
<br/>
<hr/>
<div>
<div>
<div class="form-group">
<div class="checkbox col-sm-12">
<label>
<input type="checkbox" id="agreeToRules" name="agreeToRules" class="form-control-checkbox" required>
<input type="checkbox" id="agreeToRules" name="agreeToRules" class="form-control form-control-checkbox"
required data-error="You must agree to the code of conduct to register." />
I agree to abide by the {{ event.name }} <a href="{{ event.codeOfConduct }}" target="_blank">Code of
Conduct</a>. By registering for {{ event }} you attest that you are not listed on any sexual offender
registry.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,9 @@ <h3>Badge Level</h3>
<hr/>

<div class="form-group">
<div class="col-sm-12">
<div class="checkbox col-sm-12">
<input type="checkbox" id="agreeToRules" name="agreeToRules" class="form-control form-control-checkbox"
required/>
required data-error="You must agree to the event code of conduct to register.">
I agree to abide by the {{ event.name }} <a href="{{ event.codeOfConduct }}" target="_blank">Code of
Conduct</a>. By registering for {{ event }} you attest that you are not listed on any sexual offender
registry.
Expand Down
14 changes: 14 additions & 0 deletions registration/tests/test_emails.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,3 +186,17 @@ def test_send_dealer_assistant_registration_invite(self, mock_send_email):
self.assertIn(self.assistant.registrationToken, html_text)
self.assistant.refresh_from_db()
self.assertTrue(self.assistant.sent)


class TestChargebackEmail(EmailTestCase):
@patch("registration.emails.send_email")
def test_send_chargeback_notice_email(self, mock_send_email):
order = Order(
total="88.04",
status=Order.COMPLETED,
reference="FOOBAR",
billingEmail="[email protected]",
lastFour="1111",
)
emails.send_chargeback_notice_email(order)
mock_send_email.assert_called_once()
23 changes: 22 additions & 1 deletion registration/tests/test_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@

from django.test import TestCase

from registration.models import Attendee, Charity, Venue
from registration.models import (
Attendee,
Charity,
HoldType,
Venue,
get_hold_type,
)
from registration.tests.common import DEFAULT_VENUE_ARGS, TEST_ATTENDEE_ARGS


Expand Down Expand Up @@ -41,3 +47,18 @@ def test_preferredName(self):
preferredName = "Someone else"
self.attendee.preferredName = preferredName
self.assertEqual(self.attendee.getFirst(), preferredName)


class TestHoldType(TestCase):
def setUp(self):
self.existing_hold = HoldType(name="Existing Hold")
self.existing_hold.save()
self.existing_hold.refresh_from_db()

def test_get_hold_type_existing_hold(self):
hold = get_hold_type("Existing Hold")
self.assertEqual(self.existing_hold, hold)

def test_get_hold_type_new(self):
hold = get_hold_type("New Hold")
self.assertNotEqual(self.existing_hold, hold)
Loading

0 comments on commit 2c847a5

Please sign in to comment.