Skip to content

Commit

Permalink
Allow refunds to exclude processing fees
Browse files Browse the repository at this point in the history
You can now exclude processing fees for full refunds and for refunds where you undo an item, which is like 99.9999% of cases where we want to exclude processing fees in a refund.
  • Loading branch information
kitsuta committed Sep 28, 2023
1 parent 58f1d9b commit 48c5181
Show file tree
Hide file tree
Showing 6 changed files with 177 additions and 22 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""Add processing fee column to receipt txns
Revision ID: ceb6dd682832
Revises: 41c34c63d54c
Create Date: 2023-09-28 21:49:20.259609
"""


# revision identifiers, used by Alembic.
revision = 'ceb6dd682832'
down_revision = '41c34c63d54c'
branch_labels = None
depends_on = None

from alembic import op
import sqlalchemy as sa



try:
is_sqlite = op.get_context().dialect.name == 'sqlite'
except Exception:
is_sqlite = False

if is_sqlite:
op.get_context().connection.execute('PRAGMA foreign_keys=ON;')
utcnow_server_default = "(datetime('now', 'utc'))"
else:
utcnow_server_default = "timezone('utc', current_timestamp)"

def sqlite_column_reflect_listener(inspector, table, column_info):
"""Adds parenthesis around SQLite datetime defaults for utcnow."""
if column_info['default'] == "datetime('now', 'utc')":
column_info['default'] = utcnow_server_default

sqlite_reflect_kwargs = {
'listeners': [('column_reflect', sqlite_column_reflect_listener)]
}

# ===========================================================================
# HOWTO: Handle alter statements in SQLite
#
# def upgrade():
# if is_sqlite:
# with op.batch_alter_table('table_name', reflect_kwargs=sqlite_reflect_kwargs) as batch_op:
# batch_op.alter_column('column_name', type_=sa.Unicode(), server_default='', nullable=False)
# else:
# op.alter_column('table_name', 'column_name', type_=sa.Unicode(), server_default='', nullable=False)
#
# ===========================================================================


def upgrade():
with op.batch_alter_table("receipt_transaction") as batch_op:
batch_op.add_column(sa.Column('processing_fee', sa.Integer(), server_default='0', nullable=False))


def downgrade():
op.drop_column('receipt_transaction', 'processing_fee')
1 change: 0 additions & 1 deletion uber/config.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import ast
import decimal
import hashlib
import inspect
import math
Expand Down
41 changes: 36 additions & 5 deletions uber/models/commerce.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,14 @@ class ModelReceipt(MagModel):
@property
def all_sorted_items_and_txns(self):
return sorted(self.receipt_items + self.receipt_txns, key=lambda x: x.added)

@property
def total_processing_fees(self):
return sum([txn.calc_partial_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])

@property
def open_receipt_items(self):
Expand Down Expand Up @@ -245,6 +253,7 @@ class ReceiptTransaction(MagModel):
method = Column(Choice(c.PAYMENT_METHOD_OPTS), default=c.STRIPE)
amount = Column(Integer)
txn_total = Column(Integer, default=0)
processing_fee = Column(Integer, default=0)
refunded = Column(Integer, default=0)
added = Column(UTCDateTime, default=lambda: datetime.now(UTC))
cancelled = Column(UTCDateTime, nullable=True)
Expand Down Expand Up @@ -274,7 +283,7 @@ def available_actions(self):

@property
def refundable(self):
return self.charge_id and self.amount_left
return self.charge_id and self.amount_left and self.amount > 0

@property
def stripe_url(self):
Expand Down Expand Up @@ -304,6 +313,28 @@ 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:
return self.processing_fee

if c.AUTHORIZENET_LOGIN_ID:
return 0

intent = self.get_stripe_intent(expand=['charges.data.balance_transaction'])
if not intent.charges.data:
return 0

return intent.charges.data[0].balance_transaction.fee_details[0].amount

def calc_partial_processing_fee(self, refund_amount):
from decimal import Decimal

if not refund_amount:
return 0

refund_pct = Decimal(refund_amount) / Decimal(self.txn_total)
return refund_pct * Decimal(self.get_processing_fee())

def check_stripe_id(self):
# Check all possible Stripe IDs for invalid request errors
# Stripe IDs become invalid if, for example, the Stripe API keys change
Expand Down Expand Up @@ -343,22 +374,22 @@ def get_intent_id_from_refund(self):
else:
return refund.payment_intent

def get_stripe_intent(self):
def get_stripe_intent(self, expand=[]):
if not self.stripe_id or c.AUTHORIZENET_LOGIN_ID:
return

intent_id = self.intent_id or self.get_intent_id_from_refund()

try:
return stripe.PaymentIntent.retrieve(intent_id)
return stripe.PaymentIntent.retrieve(intent_id, expand=expand)
except Exception as e:
log.error(e)

def check_paid_from_stripe(self):
def check_paid_from_stripe(self, intent=None):
if self.charge_id or c.AUTHORIZENET_LOGIN_ID:
return

intent = self.get_stripe_intent()
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)
Expand Down
20 changes: 10 additions & 10 deletions uber/payments.py
Original file line number Diff line number Diff line change
Expand Up @@ -701,7 +701,7 @@ def send_authorizenet_txn(self, txn_type=c.AUTHCAPTURE, **params):
transaction.customerIP = cherrypy.request.headers.get('X-Forwarded-For', cherrypy.request.remote.ip)

if self.amount:
transaction.amount = Decimal(int(self.amount) / 100)
transaction.amount = Decimal(int(self.amount)) / Decimal(100)

transactionRequest = apicontractsv1.createTransactionRequest()
transactionRequest.merchantAuthentication = self.merchant_auth
Expand Down Expand Up @@ -985,9 +985,9 @@ def auto_update_receipt(self, model, receipt, params):
receipt_items = []

model_overridden_price = getattr(model, 'overridden_price', None)
overridden_unset = model_overridden_price and not changed_params.get('overridden_price')
overridden_unset = model_overridden_price and not params.get('overridden_price')
model_auto_recalc = getattr(model, 'auto_recalc', True) if isinstance(model, Group) else None
auto_recalc_unset = not model_auto_recalc and changed_params.get('auto_recalc', None)
auto_recalc_unset = not model_auto_recalc and params.get('auto_recalc', None)

if overridden_unset or auto_recalc_unset:
# Note: we can't use preview models here because the full default cost
Expand Down Expand Up @@ -1015,18 +1015,18 @@ def auto_update_receipt(self, model, receipt, params):
revert_change=revert_change,
)]

if not changed_params.get('no_override') and changed_params.get('overridden_price'):
receipt_item = self.add_receipt_item_from_param(model, receipt, 'overridden_price', changed_params)
if not params.get('no_override') and params.get('overridden_price'):
receipt_item = self.add_receipt_item_from_param(model, receipt, 'overridden_price', params)
return [receipt_item] if receipt_item else []

if not changed_params.get('auto_recalc') and isinstance(model, Group):
receipt_item = self.add_receipt_item_from_param(model, receipt, 'cost', changed_params)
if not params.get('auto_recalc') and isinstance(model, Group):
receipt_item = self.add_receipt_item_from_param(model, receipt, 'cost', params)
return [receipt_item] if receipt_item else []
else:
changed_params.pop('cost', None)
params.pop('cost', None)

if changed_params.get('power_fee', None) != None and c.POWER_PRICES.get(int(changed_params.get('power'), 0), None) == None:
receipt_item = self.add_receipt_item_from_param(model, receipt, 'power_fee', changed_params)
if params.get('power_fee', None) != None and c.POWER_PRICES.get(int(params.get('power'), 0), None) == None:
receipt_item = self.add_receipt_item_from_param(model, receipt, 'power_fee', params)
receipt_items += [receipt_item] if receipt_item else []
params.pop('power')
params.pop('power_fee')
Expand Down
28 changes: 25 additions & 3 deletions uber/site_sections/reg_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,18 @@ def undo_refund_receipt_item(self, session, id='', **params):
return {'error': message}

if item.receipt_txn and item.receipt_txn.amount_left:
refund = TransactionRequest(item.receipt, amount=min(item.amount, 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)
session.add(ReceiptItem(
receipt_id=item.receipt.id,
desc=f"Processing Fees for Refunding {item.desc}",
amount=processing_fees,
who=AdminAccount.admin_name() or 'non-admin',
))
refund_amount -= processing_fees

refund = TransactionRequest(item.receipt, amount=refund_amount)
error = refund.refund_or_cancel(item.receipt_txn)
if error:
return {'error': error}
Expand Down Expand Up @@ -411,11 +422,22 @@ def refund_receipt_txn(self, session, id, amount, **params):
}

@not_site_mappable
def process_full_refund(self, session, id='', attendee_id='', group_id=''):
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 = TransactionRequest(receipt, amount=txn.amount_left)
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

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)
Expand Down
49 changes: 46 additions & 3 deletions uber/templates/reg_admin/receipt_items.html
Original file line number Diff line number Diff line change
Expand Up @@ -207,8 +207,8 @@
});
};

var compOrUndoRefundItem = function (page_handler, itemId, amount) {
$.post(page_handler, {csrf_token: csrf_token, id: itemId, amount: amount}, function (response) {
var compOrUndoRefundItem = function (page_handler, itemId, amount, exclude_fees = false) {
$.post(page_handler, {csrf_token: csrf_token, id: itemId, amount: amount, exclude_fees: exclude_fees}, function (response) {
if (response.error) {
showErrorMessage(response.error);
} else {
Expand All @@ -221,7 +221,7 @@
var bootboxBtns = {
cancel: {
label: 'Nevermind',
className: 'btn-default',
className: 'btn-outline-secondary',
},
comp: {
label: 'Comp and Refund',
Expand All @@ -244,6 +244,17 @@
}
}
}
{% if not c.AUTHORIZENET_LOGIN_ID %}
bootboxBtns.revertExcludeFees = {
label: 'Undo and Refund Excluding Fees',
className: 'btn-danger',
callback: function (result) {
if(result) {
compOrUndoRefundItem('undo_refund_receipt_item', itemId, amount.substring(1), true)
}
}
}
{% endif %}
}

bootbox.dialog({
Expand Down Expand Up @@ -295,6 +306,34 @@
});
};

var mostlyFullRefund = function (receiptId, fee_str) {
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?",
buttons: {
confirm: {
label: 'Fully Refund and Cancel',
className: 'btn-danger'
},
cancel: {
label: 'Nevermind',
className: 'btn-outline-secondary'
}
},
callback: function (result) {
if(result) {
window.location = 'process_full_refund?id=' + receiptId + '&exclude_fees=True' +
{% if attendee %}
'&attendee_id={{ attendee.id }}'
{% else %}
'&group_id={{ group.id }}'
{% endif %}
}
}
});
};

$().ready(function () {
$('#refundTxnForm').submit(function(event) {
return false;
Expand Down Expand Up @@ -597,6 +636,10 @@ <h3 class="card-title">Current Receipt</h3>
{% if receipt %}
{{ receipt_table(receipt, receipt.all_sorted_items_and_txns) }}
<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>
{% 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>
Expand Down

0 comments on commit 48c5181

Please sign in to comment.