Skip to content

Commit

Permalink
Add ability to refund promo code group badges
Browse files Browse the repository at this point in the history
Adds a way to fully refund attendee group badges both with and without extra add-ons, including or excluding processing fees. This has a one-cent-off issue with recording processing fees, but it works otherwise.
  • Loading branch information
kitsuta committed Sep 29, 2023
1 parent dd4f3a6 commit 9c8379b
Show file tree
Hide file tree
Showing 3 changed files with 139 additions and 40 deletions.
2 changes: 1 addition & 1 deletion uber/models/commerce.py
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,7 @@ def stripe_id(self):
# Return the most relevant Stripe ID for admins
return self.refund_id or self.charge_id or self.intent_id

@request_cached_property
@property
def total_processing_fee(self):
if self.processing_fee and self.amount == self.txn_total:
return self.processing_fee
Expand Down
131 changes: 102 additions & 29 deletions uber/site_sections/reg_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,12 @@
from uber.payments import ReceiptManager, TransactionRequest

def check_custom_receipt_item_txn(params, is_txn=False):
from decimal import Decimal
if not params.get('amount'):
return "You must enter a positive or negative amount."

try:
amount = int(params['amount'])
amount = Decimal(params['amount'])
except Exception:
return "The amount must be a number."

Expand All @@ -37,7 +38,7 @@ def check_custom_receipt_item_txn(params, is_txn=False):
if is_txn:
if not params.get('method'):
return "You must choose a payment method."
if int(params['amount']) < 0 and not params.get('desc'):
if Decimal(params['amount']) < 0 and not params.get('desc'):
return "You must enter a description when adding a refund."
elif not params.get('desc'):
return "You must describe the item you are adding or crediting."
Expand Down Expand Up @@ -89,8 +90,16 @@ def assign_account_by_email(session, attendee, account_email):
@all_renderable()
class Root:
def receipt_items(self, session, id, message=''):
group_leader_receipt = None
group_processing_fee = 0
try:
model = session.attendee(id)
if model.in_promo_code_group and model.promo_code.group.buyer:
group_leader_receipt = session.get_receipt_by_model(model.promo_code.group.buyer)
potential_refund_amount = model.promo_code.cost * 100
txn = sorted([txn for txn in group_leader_receipt.refundable_txns if txn.amount_left >= potential_refund_amount],
key=lambda x: x.added)[0]
group_processing_fee = txn.calc_processing_fee(potential_refund_amount)
except NoResultFound:
try:
model = session.group(id)
Expand Down Expand Up @@ -121,6 +130,8 @@ def receipt_items(self, session, id, message=''):
'attendee': model if isinstance(model, Attendee) else None,
'group': model if isinstance(model, Group) else None,
'art_show_app': model if isinstance(model, ArtShowApplication) else None,
'group_leader_receipt': group_leader_receipt,
'group_processing_fee': group_processing_fee,
'receipt': receipt,
'other_receipts': other_receipts,
'closed_receipts': session.query(ModelReceipt).filter(ModelReceipt.owner_id == id,
Expand All @@ -144,13 +155,15 @@ def create_receipt(self, session, id='', blank=False):

@ajax
def add_receipt_item(self, session, id='', **params):
from decimal import Decimal

receipt = session.model_receipt(id)

message = check_custom_receipt_item_txn(params)
if message:
return {'error': message}

amount = int(params.get('amount', 0))
amount = Decimal(params.get('amount', 0))

if params.get('item_type', '') == 'credit':
amount = amount * -1
Expand Down Expand Up @@ -266,7 +279,7 @@ def undo_refund_receipt_item(self, session, id='', **params):
if item.receipt_txn and item.receipt_txn.amount_left:
refund_amount = min(item.amount, item.receipt_txn.amount_left)
if params.get('exclude_fees'):
processing_fees = item.receipt_txn.calc_partial_processing_fee(refund_amount)
processing_fees = item.receipt_txn.calc_processing_fee(refund_amount)
session.add(ReceiptItem(
receipt_id=item.receipt.id,
desc=f"Processing Fees for Refunding {item.desc}",
Expand Down Expand Up @@ -297,14 +310,16 @@ def undo_refund_receipt_item(self, session, id='', **params):

@ajax
def add_receipt_txn(self, session, id='', **params):
from decimal import Decimal

receipt = session.model_receipt(id)
model = session.get_model_by_receipt(receipt)

message = check_custom_receipt_item_txn(params, is_txn=True)
if message:
return {'error': message}

amount = int(params.get('amount', 0))
amount = Decimal(params.get('amount', 0))

if params.get('txn_type', '') == 'refund':
amount = amount * -1
Expand Down Expand Up @@ -425,44 +440,102 @@ def refund_receipt_txn(self, session, id, amount, **params):
def process_full_refund(self, session, id='', attendee_id='', group_id='', exclude_fees=False):
receipt = session.model_receipt(id)
refund_total = 0
for txn in receipt.refundable_txns:
refund_amount = txn.amount_left
if exclude_fees:
processing_fees = txn.calc_partial_processing_fee(txn.amount_left)
session.add(ReceiptItem(
receipt_id=txn.receipt.id,
desc=f"Processing Fees for Full Refund of {txn.desc}",
amount=processing_fees,
who=AdminAccount.admin_name() or 'non-admin',
))
refund_amount -= processing_fees
processing_fee_total = 0
group_leader_receipt = None
group_refund_amount = 0

refund = TransactionRequest(receipt, amount=refund_amount)
error = refund.refund_or_skip(txn)
if error:
raise HTTPRedirect('../reg_admin/receipt_items?id={}&message={}', attendee_id or group_id, error)
session.add_all(refund.get_receipt_items_to_add())
refund_total += refund.amount
if attendee_id:
model = session.attendee(attendee_id)
if model.in_promo_code_group and model.promo_code.group.buyer:
group_leader_receipt = session.get_receipt_by_model(model.promo_code.group.buyer)
group_refund_amount = model.promo_code.cost * 100
elif group_id:
model = session.group(group_id)

receipt.closed = datetime.now()
session.add(receipt)
if session.get_receipt_by_model(model) == receipt:
for txn in receipt.refundable_txns:
refund_amount = txn.amount_left
if exclude_fees:
processing_fees = txn.calc_processing_fee(txn.amount_left)
session.add(ReceiptItem(
receipt_id=txn.receipt.id,
desc=f"Processing Fees for Full Refund of {txn.desc}",
amount=processing_fees,
who=AdminAccount.admin_name() or 'non-admin',
))
refund_amount -= processing_fees
processing_fee_total += processing_fees

refund = TransactionRequest(receipt, amount=refund_amount)
error = refund.refund_or_skip(txn)
if error:
raise HTTPRedirect('../reg_admin/receipt_items?id={}&message={}', attendee_id or group_id, error)
session.add_all(refund.get_receipt_items_to_add())
refund_total += refund.amount

session.add(ReceiptItem(
receipt_id=receipt.id,
desc=f"Refunding and Cancelling {model.full_name}'s Badge",
amount=-(refund_total + processing_fee_total),
who=AdminAccount.admin_name() or 'non-admin',
))

receipt.closed = datetime.now()
session.add(receipt)

if attendee_id:
model = session.attendee(attendee_id)
model.badge_status = c.REFUNDED_STATUS
model.paid = c.REFUNDED

if group_id:
model = session.group(group_id)
model.status = c.CANCELLED

session.add(model)

session.commit()

if group_refund_amount:
if refund_total:
error_start = f"This attendee was refunded {format_currency(refund_total / 100)}, but their"
else:
error_start = "This attendee's"

txn = sorted([txn for txn in group_leader_receipt.refundable_txns if txn.amount_left >= group_refund_amount],
key=lambda x: x.added)[0]
if not txn:
message = f"{error_start} group leader could not be refunded \
because there wasn't a transaction with enough money left on it for {model.full_name}'s badge."
raise HTTPRedirect('../reg_admin/receipt_items?id={}&message={}', attendee_id or group_id, message)

session.add(ReceiptItem(
receipt_id=txn.receipt.id,
desc=f"Refunding {model.full_name}'s Promo Code",
amount=-group_refund_amount,
who=AdminAccount.admin_name() or 'non-admin',
))

if exclude_fees:
processing_fees = txn.calc_processing_fee(group_refund_amount)
session.add(ReceiptItem(
receipt_id=txn.receipt.id,
desc=f"Processing Fees for Refund of {model.full_name}'s Promo Code",
amount=processing_fees,
who=AdminAccount.admin_name() or 'non-admin',
))
group_refund_amount -= processing_fees

refund = TransactionRequest(txn.receipt, amount=group_refund_amount)
error = refund.refund_or_cancel(txn)
if error:
message = f"{error_start} group leader could not be refunded: {error}"
raise HTTPRedirect('../reg_admin/receipt_items?id={}&message={}', attendee_id or group_id, message)
session.add_all(refund.get_receipt_items_to_add())
session.commit()

message_end = f" Their group leader was refunded {format_currency(group_refund_amount / 100)}." if group_refund_amount else ""
raise HTTPRedirect('../reg_admin/receipt_items?id={}&message={}',
attendee_id or group_id,
"{}'s registration has been cancelled and they have been refunded {}.".format(
getattr(model, 'full_name', None) or model.name, format_currency(refund_total / 100)
"{}'s registration has been cancelled and they have been refunded {}.{}".format(
getattr(model, 'full_name', None) or model.name, format_currency(refund_total / 100), message_end
))

@not_site_mappable
Expand Down
46 changes: 36 additions & 10 deletions uber/templates/reg_admin/receipt_items.html
Original file line number Diff line number Diff line change
Expand Up @@ -279,10 +279,20 @@
};

var fullRefund = function (receiptId) {
let message = '';
{% if group_leader_receipt and not receipt %}
message = "This will refund this attendee's group leader the cost of one badge.";
{% else %}
let extra_message = '';
{% if group_leader_receipt %}
extra_message = " for this attendee, plus the cost of one badge for their group leader";
{% endif %}
message = "This will refund ALL existing {{ processor_str }} transactions that have not already been refunded" +
extra_message + ".";
{% endif %}
bootbox.confirm({
title: "Refund and Cancel Registration?",
message: "This will refund ALL existing {{ processor_str }} transactions that have not already been refunded. " +
"It will also cancel this {{ model_str }}'s registration. Are you sure?",
message: message + " It will also cancel this {{ model_str }}'s registration. Are you sure?",
buttons: {
confirm: {
label: 'Fully Refund and Cancel',
Expand All @@ -306,11 +316,24 @@
});
};

var mostlyFullRefund = function (receiptId, fee_str) {
var mostlyFullRefund = function (receiptId, fee_str, group_leader_fee_str = '') {
let message = '';
{% if group_leader_receipt and not receipt %}
message = "This will refund this attendee's group leader the cost of one badge minus " + fee_str + " in processing fees. It will also";
{% else %}
let extra_message = '';
{% if group_leader_receipt %}
extra_message = " It will also refund their group leader the cost of one badge minus " + group_leader_fee_str + " in processing fees, and it will";
{% else %}
extra_message = " It will also"
{% endif %}
message = "This will refund ALL existing {{ processor_str }} transactions that have not already been refunded, " +
"minus " + fee_str + " in processing fees." + extra_message
{% endif %}

bootbox.confirm({
title: "Refund and Cancel Registration?",
message: "This will refund ALL existing {{ processor_str }} transactions that have not already been refunded, "+
"minus " + fee_str + " in processing fees. It will also cancel this {{ model_str }}'s registration. Are you sure?",
message: message + " cancel this {{ model_str }}'s registration. Are you sure?",
buttons: {
confirm: {
label: 'Fully Refund and Cancel',
Expand Down Expand Up @@ -439,8 +462,7 @@ <h2>Receipt{% if other_receipts %}s{% endif %} for {% if model.attendee %}{{ mod
<div class="col-auto">
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" name="amount" class="form-control" placeholder="0" required>
<span class="input-group-text">.00</span>
<input type="number" name="amount" class="form-control" placeholder="0" required step=".01">
</div>
</div>
<label for="count" class="col-auto col-form-label">Quantity</label>
Expand Down Expand Up @@ -638,11 +660,15 @@ <h3 class="card-title">Current Receipt</h3>
<button class="btn btn-danger" onClick="fullRefund('{{ receipt.id }}')">Refund and Cancel This {{ model_str|title }}</button>
{% if not c.AUTHORIZENET_LOGIN_ID %}
OR
<button class="btn btn-danger" onClick="mostlyFullRefund('{{ receipt.id }}', '{{ (receipt.remaining_processing_fees / 100)|format_currency }}')">Refund and Cancel This {{ model_str|title }} Excluding Processing Fees</button>
<button class="btn btn-danger" onClick="mostlyFullRefund('{{ receipt.id }}', '{{ (receipt.remaining_processing_fees / 100)|format_currency }}', '{{ (group_processing_fee / 100)|format_currency if group_leader_receipt else '' }}')">Refund and Cancel This {{ model_str|title }} Excluding Processing Fees</button>
{% endif %}
{% else %}
There are no active receipts for this {{ model_str }}.
<br/><a href="create_receipt?id={{ model.id }}" class="btn btn-success">Create Default Receipt</a> <a href="create_receipt?id={{ model.id }}&blank=true" class="btn btn-info">Create Blank Receipt</a>
There are no active receipts for this {{ model_str }}.
<br/><a href="create_receipt?id={{ model.id }}" class="btn btn-success">Create Default Receipt</a> <a href="create_receipt?id={{ model.id }}&blank=true" class="btn btn-info">Create Blank Receipt</a>
{% if group_leader_receipt and attendee and attendee.badge_status != c.REFUNDED_STATUS %}
<button class="btn btn-danger" onClick="fullRefund('{{ group_leader_receipt.id }}')">Refund Badge Cost To Group Leader</button>
<button class="btn btn-danger" onClick="mostlyFullRefund('{{ group_leader_receipt.id }}', '{{ (group_processing_fee / 100)|format_currency }}')">Refund Badge Cost To Group Leader Excluding Processing Fees</a>
{% endif %}
{% endif %}
</div>
</div>
Expand Down

0 comments on commit 9c8379b

Please sign in to comment.