From e6eba44624aa3f112647f9896fc4167f0659bddd Mon Sep 17 00:00:00 2001 From: Victoria Earl Date: Fri, 29 Sep 2023 15:54:56 -0400 Subject: [PATCH 1/8] Set processing fee on transactions Also slightly refactors mark_paid_from_intent_id to split it into a Stripe-intent driven function and a function that just takes the intent and charge ID. This allows us to add some extra checks that are Stripe-specific before we actually mark payments as paid. --- uber/models/commerce.py | 2 +- uber/payments.py | 27 +++++++++++++++++++++++---- uber/site_sections/api.py | 2 +- uber/tasks/registration.py | 2 +- 4 files changed, 26 insertions(+), 7 deletions(-) diff --git a/uber/models/commerce.py b/uber/models/commerce.py index 3e629d67c..541653413 100644 --- a/uber/models/commerce.py +++ b/uber/models/commerce.py @@ -392,7 +392,7 @@ def check_paid_from_stripe(self, intent=None): intent = intent or self.get_stripe_intent() if intent and intent.status == "succeeded": new_charge_id = intent.charges.data[0].id - ReceiptManager.mark_paid_from_intent_id(self.intent_id, new_charge_id) + ReceiptManager.mark_paid_from_stripe_intent(intent) return new_charge_id def update_amount_refunded(self): diff --git a/uber/payments.py b/uber/payments.py index 4f4ea00a7..69d4ff2c1 100644 --- a/uber/payments.py +++ b/uber/payments.py @@ -722,7 +722,7 @@ def send_authorizenet_txn(self, txn_type=c.AUTHCAPTURE, **params): log.debug(f"Transaction {self.tracking_id} request successful. Transaction ID: {auth_txn_id}") if txn_type in [c.AUTHCAPTURE, c.CAPTURE]: - ReceiptManager.mark_paid_from_intent_id(params.get('intent_id'), auth_txn_id) + ReceiptManager.mark_paid_from_ids(params.get('intent_id'), auth_txn_id) else: error_code = str(response.transactionResponse.errors.error[0].errorCode) error_msg = str(response.transactionResponse.errors.error[0].errorText) @@ -1062,16 +1062,35 @@ def add_receipt_item_from_param(self, model, receipt, param_name, params, func_n log.error(str(e)) @staticmethod - def mark_paid_from_intent_id(intent_id, charge_id): + def mark_paid_from_stripe_intent(payment_intent): + if not payment_intent.charges.data: + log.error(f"Tried to mark payments with intent ID {payment_intent.id} as paid but that intent doesn't have a charge!") + return [] + + if payment_intent.status != "succeeded": + log.error(f"Tried to mark payments with intent ID {payment_intent.id} as paid but the charge on this intent wasn't successful!") + return [] + + ReceiptManager.mark_paid_from_ids(payment_intent.id, payment_intent.charges.data[0].id) + + @staticmethod + def mark_paid_from_ids(intent_id, charge_id): from uber.models import Attendee, ArtShowApplication, MarketplaceApplication, Group, ReceiptTransaction, Session from uber.tasks.email import send_email from uber.decorators import render - + session = Session().session matching_txns = session.query(ReceiptTransaction).filter_by(intent_id=intent_id).filter( ReceiptTransaction.charge_id == '').all() - + + if not matching_txns: + log.debug(f"Tried to mark payments with intent ID {intent_id} as paid but we couldn't find any!") + return [] + for txn in matching_txns: + if not c.AUTHORIZENET_LOGIN_ID: + txn.processing_fee = txn.calc_processing_fee() + txn.charge_id = charge_id session.add(txn) txn_receipt = txn.receipt diff --git a/uber/site_sections/api.py b/uber/site_sections/api.py index 0af69f721..d5634e806 100644 --- a/uber/site_sections/api.py +++ b/uber/site_sections/api.py @@ -173,7 +173,7 @@ def stripe_webhook_handler(self): if event and event['type'] == 'payment_intent.succeeded': payment_intent = event['data']['object'] - matching_txns = ReceiptManager.mark_paid_from_intent_id(payment_intent['id'], payment_intent.charges.data[0].id) + matching_txns = ReceiptManager.mark_paid_from_stripe_intent(payment_intent) if not matching_txns: cherrypy.response.status = 400 return "No matching Stripe transactions" diff --git a/uber/tasks/registration.py b/uber/tasks/registration.py index 859481493..821580261 100644 --- a/uber/tasks/registration.py +++ b/uber/tasks/registration.py @@ -217,7 +217,7 @@ def check_missed_stripe_payments(): payment_intent = event.data.object if payment_intent.id in pending_ids: paid_ids.append(payment_intent.id) - ReceiptManager.mark_paid_from_intent_id(payment_intent.id, payment_intent.charges.data[0].id) + ReceiptManager.mark_paid_from_stripe_intent(payment_intent) return paid_ids From fcf5ccb59b0ccf65636d78057ce35a2ff89bbd31 Mon Sep 17 00:00:00 2001 From: Victoria Earl Date: Fri, 29 Sep 2023 15:57:42 -0400 Subject: [PATCH 2/8] Fix history table styling I was sick of looking at it. Also fixes the tabs for receipts on the receipt items page. --- uber/templates/art_show_admin/history.html | 2 +- uber/templates/email_admin/index.html | 2 +- uber/templates/guests_macros.html | 2 +- uber/templates/marketplace_admin/history.html | 2 +- uber/templates/model_history_base.html | 4 ++-- uber/templates/reg_admin/receipt_items.html | 14 +++++++------- uber/templates/registration/feed.html | 2 +- 7 files changed, 14 insertions(+), 14 deletions(-) diff --git a/uber/templates/art_show_admin/history.html b/uber/templates/art_show_admin/history.html index 7e1f2b6d7..56710fd25 100644 --- a/uber/templates/art_show_admin/history.html +++ b/uber/templates/art_show_admin/history.html @@ -13,7 +13,7 @@

Changelog for {{ app.attendee.full_name }}'s Art Show Application

- +
diff --git a/uber/templates/email_admin/index.html b/uber/templates/email_admin/index.html index 95ae7cb02..a83836b8d 100644 --- a/uber/templates/email_admin/index.html +++ b/uber/templates/email_admin/index.html @@ -16,7 +16,7 @@
{{ pages(page, count) }} -
Which What
+
diff --git a/uber/templates/guests_macros.html b/uber/templates/guests_macros.html index a31df53bc..9a33af7ca 100644 --- a/uber/templates/guests_macros.html +++ b/uber/templates/guests_macros.html @@ -283,7 +283,7 @@
{{ variety_label }}
-
Sent Subject
+
{% for cut_value, cut_label in cuts_opts %} {% for size_value, size_label in sizes_opts %} diff --git a/uber/templates/marketplace_admin/history.html b/uber/templates/marketplace_admin/history.html index 54fdc9326..1cfbc1db9 100644 --- a/uber/templates/marketplace_admin/history.html +++ b/uber/templates/marketplace_admin/history.html @@ -11,7 +11,7 @@

Changelog for {{ app.attendee.full_name }}'s Marketplace Application

-
+
diff --git a/uber/templates/model_history_base.html b/uber/templates/model_history_base.html index 405a41d1c..255b1e4c0 100644 --- a/uber/templates/model_history_base.html +++ b/uber/templates/model_history_base.html @@ -3,7 +3,7 @@ {% set admin_area=True %}

Changelog for {{ model.full_name|default(model.name) }} {% if c.AT_THE_CON and model.badge_num %}({{ model.badge_num }}){% endif %}

-
Which What
+
@@ -24,7 +24,7 @@

Changelog for {{ model.full_name|default(model.name) }} {% if c.AT_THE_CON a

Page View History for {{ model.full_name|default(model.name) }} {% if c.AT_THE_CON and model.badge_num %}({{ model.badge_num }}){% endif %}

-
Which What
+
diff --git a/uber/templates/reg_admin/receipt_items.html b/uber/templates/reg_admin/receipt_items.html index d1e936f36..4f494c849 100644 --- a/uber/templates/reg_admin/receipt_items.html +++ b/uber/templates/reg_admin/receipt_items.html @@ -489,19 +489,19 @@

Receipt{% if other_receipts %}s{% endif %} for {% if model.attendee %}{{ mod {% endmacro %} {% macro receipt_table(receipt, items) %} -
-
+

When Who
@@ -605,8 +605,8 @@

Receipt{% if other_receipts %}s{% endif %} for {% if model.attendee %}{{ mod {% endif %}

Added
-
- +
+
diff --git a/uber/templates/registration/feed.html b/uber/templates/registration/feed.html index baefea0f9..cc2129f43 100644 --- a/uber/templates/registration/feed.html +++ b/uber/templates/registration/feed.html @@ -33,7 +33,7 @@

Feed of Database Actions

{{ pages(page,count) }} -
Which What
+
From 42ac3c22a6809fc0d860e36057c54aad846fd257 Mon Sep 17 00:00:00 2001 From: Victoria Earl Date: Fri, 29 Sep 2023 16:00:05 -0400 Subject: [PATCH 3/8] Account for 'refunded' promo codes Adds a paid_codes property to help calculate the cost of promo code groups that have refunded badges. Also displays refunded badges as refunded on the admin and attendee pc group pages. Also stops removing promo codes from self-refunding attendees. --- uber/models/promo_code.py | 20 ++++++++++++++++--- uber/site_sections/preregistration.py | 3 --- .../preregistration/group_promo_codes.html | 4 +++- .../registration/promo_code_group_form.html | 8 +++++++- 4 files changed, 27 insertions(+), 8 deletions(-) diff --git a/uber/models/promo_code.py b/uber/models/promo_code.py index 54b02f9af..e59ddf2a0 100644 --- a/uber/models/promo_code.py +++ b/uber/models/promo_code.py @@ -9,7 +9,7 @@ from pytz import UTC from dateutil import parser as dateparser from residue import CoerceUTF8 as UnicodeText, UTCDateTime, UUID -from sqlalchemy import func, select, CheckConstraint +from sqlalchemy import exists, func, select, CheckConstraint from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.schema import Index, ForeignKey from sqlalchemy.types import Integer @@ -172,13 +172,17 @@ def email(self): @hybrid_property def total_cost(self): - return sum(code.cost for code in self.promo_codes if code.cost) + return sum(code.cost for code in self.paid_codes if code.cost) @total_cost.expression def total_cost(cls): return select([func.sum(PromoCode.cost)] - ).where(PromoCode.group_id == cls.id + ).where(PromoCode.group_id == cls.id).where(PromoCode.refunded == False ).label('total_cost') + + @property + def paid_codes(self): + return [code for code in self.promo_codes if not code.refunded] @property def valid_codes(self): @@ -456,6 +460,16 @@ def uses_remaining(cls): def uses_remaining_str(self): uses = self.uses_remaining return 'Unlimited uses' if uses is None else '{} use{} remaining'.format(uses, '' if uses == 1 else 's') + + @hybrid_property + def refunded(self): + return self.used_by and self.used_by[0].badge_status == c.REFUNDED_STATUS + + @refunded.expression + def refunded(cls): + from uber.models import Attendee + return exists().select_from(Attendee).where(cls.id == Attendee.promo_code_id + ).where(Attendee.badge_status == c.REFUNDED_STATUS) @presave_adjustment def _attribute_adjustments(self): diff --git a/uber/site_sections/preregistration.py b/uber/site_sections/preregistration.py index e23e4eebd..2a0ca2fab 100644 --- a/uber/site_sections/preregistration.py +++ b/uber/site_sections/preregistration.py @@ -1350,9 +1350,6 @@ def abandon_badge(self, session, id): if attendee.paid == c.HAS_PAID: attendee.paid = c.REFUNDED - if attendee.in_promo_code_group: - attendee.promo_code = None - # if attendee is part of a group, we must delete attendee and remove them from the group if attendee.group: session.assign_badges( diff --git a/uber/templates/preregistration/group_promo_codes.html b/uber/templates/preregistration/group_promo_codes.html index a06a0ca17..25a1e8697 100644 --- a/uber/templates/preregistration/group_promo_codes.html +++ b/uber/templates/preregistration/group_promo_codes.html @@ -17,7 +17,7 @@ {% include 'confirm_tabs.html' with context %}

Members of "{{ group.name }}"

-

You have bought {{ group.promo_codes|length }} promo codes for a total of {{ group.total_cost|format_currency }} (not including your badge).

+

You have bought {{ group.paid_codes|length }} promo codes for a total of {{ group.total_cost|format_currency }} (not including your badge).

{% if group.valid_codes %}Anyone can claim one of the {{ group.valid_codes|length }} remaining badges in this group using either an unclaimed promo code below or this group's universal code, {{ group.code }}. {% else %}All promo codes have been claimed. Please email us at {{ c.REGDESK_EMAIL|email_to_link }} if a promo code was claimed in error.{% endif %}

@@ -28,6 +28,7 @@

Members of "{{ group.name }}"

+ {% for code in group.sorted_promo_codes %} @@ -55,6 +56,7 @@

Members of "{{ group.name }}"

{% endif %} + {% endfor %}
When WhoPromo Code Used By Last Sent To
{% if emailed_codes[code.code] %}{{ emailed_codes[code.code] }}{% endif %}{% if code.refunded %}Refunded{% endif %}
diff --git a/uber/templates/registration/promo_code_group_form.html b/uber/templates/registration/promo_code_group_form.html index 4b1b06fe4..cb40809b2 100644 --- a/uber/templates/registration/promo_code_group_form.html +++ b/uber/templates/registration/promo_code_group_form.html @@ -155,7 +155,13 @@

Group Info for "{{ group.name }}"

{{ code.created.when|datetime_local }} {{ code.cost|format_currency }} - {% if not code.valid_used_by %}{% endif %} + + {% if not code.valid_used_by %} + + {% elif code.refunded %} + Refunded + {% endif %} + {% endfor -%} From f9af724ba52b23513201b58ece61e7b2681111a5 Mon Sep 17 00:00:00 2001 From: Victoria Earl Date: Fri, 29 Sep 2023 16:01:00 -0400 Subject: [PATCH 4/8] Fix error when changing your mind about a group If you started paying, then realized you forgot to register as a group leader, it would fail silently -- this fixes that. --- uber/site_sections/preregistration.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/uber/site_sections/preregistration.py b/uber/site_sections/preregistration.py index 2a0ca2fab..9759cd0a3 100644 --- a/uber/site_sections/preregistration.py +++ b/uber/site_sections/preregistration.py @@ -745,7 +745,7 @@ def prereg_payment(self, session, message='', **params): pending_attendee = session.query(Attendee).filter_by(id=attendee.id).first() if pending_attendee: pending_attendee.apply(attendee.to_dict(), restricted=True) - if attendee.badges: + if attendee.badges and pending_attendee.promo_code_groups: pc_group = pending_attendee.promo_code_groups[0] pc_group.name = attendee.name @@ -755,6 +755,9 @@ def prereg_payment(self, session, message='', **params): session.add_codes_to_pc_group(pc_group, pc_codes - pending_codes) elif pc_codes < pending_codes: session.remove_codes_from_pc_group(pc_group, pending_codes - pc_codes) + elif attendee.badges: + pc_group = session.create_promo_code_group(pending_attendee, attendee.name, int(attendee.badges) - 1) + session.add(pc_group) elif pending_attendee.promo_code_groups: pc_group = pending_attendee.promo_code_groups[0] session.delete(pc_group) From dd4f3a6a26b3044f369159146d7837903fe9b1e5 Mon Sep 17 00:00:00 2001 From: Victoria Earl Date: Fri, 29 Sep 2023 16:04:51 -0400 Subject: [PATCH 5/8] Update processing fee and refundable calculations The processing fee calculation will now default to calculating based on the transaction's amount, which I think was actually needed in an earlier commit (whoops). Also marks transactions as nonrefundable if they're on a closed receipt or their amount left equals their processing fees. Also updates the "total" text on closed receipts because it looked silly. --- uber/models/commerce.py | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/uber/models/commerce.py b/uber/models/commerce.py index 541653413..5964a5ff0 100644 --- a/uber/models/commerce.py +++ b/uber/models/commerce.py @@ -104,11 +104,11 @@ def all_sorted_items_and_txns(self): @property def total_processing_fees(self): - return sum([txn.calc_partial_processing_fee(txn.amount) for txn in self.refundable_txns]) + return sum([txn.calc_processing_fee(txn.amount) for txn in self.refundable_txns]) @property def remaining_processing_fees(self): - return sum([txn.calc_partial_processing_fee(txn.amount_left) for txn in self.refundable_txns]) + return sum([txn.calc_processing_fee(txn.amount_left) for txn in self.refundable_txns]) @property def open_receipt_items(self): @@ -189,6 +189,10 @@ def txn_total(self): @property def total_str(self): + if self.closed: + return "{} in {}".format(format_currency(abs(self.txn_total / 100)), + "Payments" if self.txn_total >= 0 else "Refunds") + return "{} in {} and {} in {} = {} owe {}".format(format_currency(abs(self.item_total / 100)), "Purchases" if self.item_total >= 0 else "Credit", format_currency(abs(self.txn_total / 100)), @@ -283,7 +287,8 @@ def available_actions(self): @property def refundable(self): - return self.charge_id and self.amount_left and self.amount > 0 + return not self.receipt.closed and self.charge_id and self.amount > 0 and \ + self.amount_left and self.amount_left != self.calc_processing_fee() @property def stripe_url(self): @@ -313,8 +318,9 @@ def stripe_id(self): # Return the most relevant Stripe ID for admins return self.refund_id or self.charge_id or self.intent_id - def get_processing_fee(self): - if self.processing_fee: + @request_cached_property + def total_processing_fee(self): + if self.processing_fee and self.amount == self.txn_total: return self.processing_fee if c.AUTHORIZENET_LOGIN_ID: @@ -326,14 +332,17 @@ def get_processing_fee(self): return intent.charges.data[0].balance_transaction.fee_details[0].amount - def calc_partial_processing_fee(self, refund_amount): + def calc_processing_fee(self, amount=0): from decimal import Decimal - if not refund_amount: - return 0 + if not amount: + if self.processing_fee: + return self.processing_fee + + amount = self.amount - refund_pct = Decimal(refund_amount) / Decimal(self.txn_total) - return refund_pct * Decimal(self.get_processing_fee()) + refund_pct = Decimal(amount) / Decimal(self.txn_total) + return refund_pct * Decimal(self.total_processing_fee) def check_stripe_id(self): # Check all possible Stripe IDs for invalid request errors From 9c8379b9e3aa0bb937087b63b79f040974e2b7a5 Mon Sep 17 00:00:00 2001 From: Victoria Earl Date: Fri, 29 Sep 2023 17:08:37 -0400 Subject: [PATCH 6/8] Add ability to refund promo code group badges 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. --- uber/models/commerce.py | 2 +- uber/site_sections/reg_admin.py | 131 +++++++++++++++----- uber/templates/reg_admin/receipt_items.html | 46 +++++-- 3 files changed, 139 insertions(+), 40 deletions(-) diff --git a/uber/models/commerce.py b/uber/models/commerce.py index 5964a5ff0..e56f05b8d 100644 --- a/uber/models/commerce.py +++ b/uber/models/commerce.py @@ -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 diff --git a/uber/site_sections/reg_admin.py b/uber/site_sections/reg_admin.py index 96a4f6971..01e576a0f 100644 --- a/uber/site_sections/reg_admin.py +++ b/uber/site_sections/reg_admin.py @@ -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." @@ -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." @@ -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) @@ -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, @@ -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 @@ -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}", @@ -297,6 +310,8 @@ 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) @@ -304,7 +319,7 @@ def add_receipt_txn(self, session, id='', **params): 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 @@ -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 diff --git a/uber/templates/reg_admin/receipt_items.html b/uber/templates/reg_admin/receipt_items.html index 4f494c849..0419902c7 100644 --- a/uber/templates/reg_admin/receipt_items.html +++ b/uber/templates/reg_admin/receipt_items.html @@ -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', @@ -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', @@ -439,8 +462,7 @@

Receipt{% if other_receipts %}s{% endif %} for {% if model.attendee %}{{ mod
$ - - .00 +
@@ -638,11 +660,15 @@

Current Receipt

{% if not c.AUTHORIZENET_LOGIN_ID %} OR - + {% endif %} {% else %} -There are no active receipts for this {{ model_str }}. -
Create Default Receipt Create Blank Receipt + There are no active receipts for this {{ model_str }}. +
Create Default Receipt Create Blank Receipt + {% if group_leader_receipt and attendee and attendee.badge_status != c.REFUNDED_STATUS %} + +
From cb35bfd8548a5ad474596d328830d63981b3edbd Mon Sep 17 00:00:00 2001 From: Victoria Earl Date: Sat, 30 Sep 2023 02:12:35 -0400 Subject: [PATCH 7/8] Fix off-by-one issue for processing fees --- uber/site_sections/reg_admin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uber/site_sections/reg_admin.py b/uber/site_sections/reg_admin.py index 01e576a0f..5cb579b4e 100644 --- a/uber/site_sections/reg_admin.py +++ b/uber/site_sections/reg_admin.py @@ -514,7 +514,7 @@ def process_full_refund(self, session, id='', attendee_id='', group_id='', exclu )) if exclude_fees: - processing_fees = txn.calc_processing_fee(group_refund_amount) + processing_fees = int(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", From fc53f86c23df5e3127e0329f0056b9470bccd1df Mon Sep 17 00:00:00 2001 From: Victoria Earl Date: Sat, 30 Sep 2023 02:22:46 -0400 Subject: [PATCH 8/8] Make handling of rounding more consistent The calc_processing_fee itself returns an integer now so that the fee displayed and the fee charged match. --- uber/models/commerce.py | 2 +- uber/site_sections/reg_admin.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/uber/models/commerce.py b/uber/models/commerce.py index e56f05b8d..26f66da58 100644 --- a/uber/models/commerce.py +++ b/uber/models/commerce.py @@ -342,7 +342,7 @@ def calc_processing_fee(self, amount=0): amount = self.amount refund_pct = Decimal(amount) / Decimal(self.txn_total) - return refund_pct * Decimal(self.total_processing_fee) + return int(refund_pct * Decimal(self.total_processing_fee)) def check_stripe_id(self): # Check all possible Stripe IDs for invalid request errors diff --git a/uber/site_sections/reg_admin.py b/uber/site_sections/reg_admin.py index 5cb579b4e..01e576a0f 100644 --- a/uber/site_sections/reg_admin.py +++ b/uber/site_sections/reg_admin.py @@ -514,7 +514,7 @@ def process_full_refund(self, session, id='', attendee_id='', group_id='', exclu )) if exclude_fees: - processing_fees = int(txn.calc_processing_fee(group_refund_amount)) + 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",