From 9bc33b379bc5c4a2000eb04bfe4684e049737393 Mon Sep 17 00:00:00 2001 From: Victoria Earl Date: Wed, 6 Nov 2024 13:12:38 -0500 Subject: [PATCH 01/67] Better distinguish which receipt items to close We now close purchase items with payments and credit items with refunds. Also adds a list of refunds transactions to payment transactions, allowing us to better track refunds in the future. Also adds a frequently-called check to look at any lingering open receipt items and close them as needed (e.g., a purchase should close open credit items if the receipt balance is now $0) --- ...add_list_of_refund_txns_to_receipt_txns.py | 63 +++++++++++++++++++ ...dd_header_thumbnail_flag_to_mivs_images.py | 6 -- uber/models/__init__.py | 5 ++ uber/models/commerce.py | 45 +++++++++++-- uber/payments.py | 26 ++++---- uber/site_sections/art_show_admin.py | 1 + uber/site_sections/group_admin.py | 1 + uber/site_sections/marketplace.py | 2 + uber/site_sections/preregistration.py | 2 +- uber/site_sections/reg_admin.py | 30 +++------ uber/site_sections/registration.py | 1 + uber/templates/reg_admin/receipt_items.html | 4 +- 12 files changed, 140 insertions(+), 46 deletions(-) create mode 100644 alembic/versions/9c23621e5e12_add_list_of_refund_txns_to_receipt_txns.py diff --git a/alembic/versions/9c23621e5e12_add_list_of_refund_txns_to_receipt_txns.py b/alembic/versions/9c23621e5e12_add_list_of_refund_txns_to_receipt_txns.py new file mode 100644 index 000000000..6126fab46 --- /dev/null +++ b/alembic/versions/9c23621e5e12_add_list_of_refund_txns_to_receipt_txns.py @@ -0,0 +1,63 @@ +"""Add list of refund txns to receipt txns + +Revision ID: 9c23621e5e12 +Revises: f01a2ad10d79 +Create Date: 2024-11-06 17:59:12.695586 + +""" + + +# revision identifiers, used by Alembic. +revision = '9c23621e5e12' +down_revision = 'f01a2ad10d79' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa +import residue + + +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(): + op.create_index(op.f('ix_receipt_item_fk_id'), 'receipt_item', ['fk_id'], unique=False) + op.add_column('receipt_transaction', sa.Column('refunded_txn_id', residue.UUID(), nullable=True)) + op.create_foreign_key(op.f('fk_receipt_transaction_refunded_txn_id_receipt_transaction'), 'receipt_transaction', 'receipt_transaction', ['refunded_txn_id'], ['id'], ondelete='SET NULL') + + +def downgrade(): + op.drop_constraint(op.f('fk_receipt_transaction_refunded_txn_id_receipt_transaction'), 'receipt_transaction', type_='foreignkey') + op.drop_column('receipt_transaction', 'refunded_txn_id') + op.drop_index(op.f('ix_receipt_item_fk_id'), table_name='receipt_item') diff --git a/alembic/versions/f01a2ad10d79_add_header_thumbnail_flag_to_mivs_images.py b/alembic/versions/f01a2ad10d79_add_header_thumbnail_flag_to_mivs_images.py index 204a04055..6ca30a767 100644 --- a/alembic/versions/f01a2ad10d79_add_header_thumbnail_flag_to_mivs_images.py +++ b/alembic/versions/f01a2ad10d79_add_header_thumbnail_flag_to_mivs_images.py @@ -52,16 +52,10 @@ def sqlite_column_reflect_listener(inspector, table, column_info): def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### op.add_column('indie_game_image', sa.Column('is_header', sa.Boolean(), server_default='False', nullable=False)) op.add_column('indie_game_image', sa.Column('is_thumbnail', sa.Boolean(), server_default='False', nullable=False)) - op.create_unique_constraint(op.f('uq_lottery_application_attendee_id'), 'lottery_application', ['attendee_id']) - # ### end Alembic commands ### def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.drop_constraint(op.f('uq_lottery_application_attendee_id'), 'lottery_application', type_='unique') op.drop_column('indie_game_image', 'is_thumbnail') op.drop_column('indie_game_image', 'is_header') - # ### end Alembic commands ### diff --git a/uber/models/__init__.py b/uber/models/__init__.py index 4adfa2874..56e553625 100644 --- a/uber/models/__init__.py +++ b/uber/models/__init__.py @@ -1224,6 +1224,11 @@ def refresh_receipt_and_model(self, model, is_prereg=False): pass return receipt + + def check_receipt_closed(self, receipt): + self.refresh(receipt) + if receipt.current_receipt_amount == 0: + receipt.close_all_items(self) def get_terminal_settlements(self): from uber.models import TerminalSettlement diff --git a/uber/models/commerce.py b/uber/models/commerce.py index 4c49198f9..bdfe68031 100644 --- a/uber/models/commerce.py +++ b/uber/models/commerce.py @@ -100,6 +100,22 @@ class ModelReceipt(MagModel): owner_model = Column(UnicodeText) closed = Column(UTCDateTime, nullable=True) + def close_all_items(self, session): + for item in self.open_receipt_items: + if item.receipt_txn: + item.closed = item.receipt_txn.added + else: + if item.amount < 0: + latest_txn = self.sorted_txns[-1] + else: + latest_txn = sorted([txn for txn in self.receipt_txns if txn.amount > 0], + key=lambda x: x.added, reverse=True) + + item.receipt_txn = latest_txn + item.closed = datetime.now() + session.add(item) + session.commit() + @property def all_sorted_items_and_txns(self): return sorted(self.receipt_items + self.receipt_txns, key=lambda x: x.added) @@ -125,12 +141,16 @@ def open_receipt_items(self): return [item for item in self.receipt_items if not item.closed] @property - def closed_receipt_items(self): - return [item for item in self.receipt_items if item.closed] + def open_purchase_items(self): + return [item for item in self.open_receipt_items if item.amount >= 0] + + @property + def open_credit_items(self): + return [item for item in self.open_receipt_items if item.amount < 0] @property def charge_description_list(self): - return ", ".join([item.desc + " x" + str(item.count) for item in self.open_receipt_items if item.amount > 0]) + return ", ".join([item.desc + " x" + str(item.count) for item in self.open_purchase_items]) @property def cancelled_txns(self): @@ -283,6 +303,13 @@ class ReceiptTransaction(MagModel): receipt_info = relationship('ReceiptInfo', foreign_keys=receipt_info_id, cascade='save-update, merge', backref=backref('receipt_txns', cascade='save-update, merge')) + refunded_txn_id = Column(UUID, ForeignKey('receipt_transaction.id', ondelete='SET NULL'), nullable=True) + refunded_txn = relationship('ReceiptTransaction', foreign_keys='ReceiptTransaction.refunded_txn_id', + backref=backref('refund_txns', order_by='ReceiptTransaction.added'), + cascade='save-update,merge,refresh-expire,expunge', + remote_side='ReceiptTransaction.id', + single_parent=True) + refunded = Column(Integer, default=0) intent_id = Column(UnicodeText) charge_id = Column(UnicodeText) refund_id = Column(UnicodeText) @@ -291,7 +318,6 @@ class ReceiptTransaction(MagModel): 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) who = Column(UnicodeText) @@ -483,7 +509,6 @@ class ReceiptItem(MagModel): receipt_txn = relationship('ReceiptTransaction', foreign_keys=txn_id, cascade='save-update, merge', backref=backref('receipt_items', cascade='save-update, merge')) - # TODO: Add a view-only "refund_txns" relationship to ReceiptTransaction so credit items can track their associated refunds fk_id = Column(UUID, index=True, nullable=True) fk_model = Column(UnicodeText) department = Column(Choice(c.RECEIPT_ITEM_DEPT_OPTS), default=c.OTHER_RECEIPT_ITEM) @@ -507,6 +532,16 @@ def paid(self): if not self.closed: return return self.receipt_txn.added and self.receipt_txn.amount > 0 + + @property + def closed_type(self): + if not self.closed: + return "" + if self.amount > 0 and self.receipt_txn.amount > 0: + return "Paid" + if self.amount < 0 and self.receipt_txn.amount < 0: + return "Refunded" + return "Closed" @property def available_actions(self): diff --git a/uber/payments.py b/uber/payments.py index 8f30ec478..8ad44ba3a 100644 --- a/uber/payments.py +++ b/uber/payments.py @@ -472,7 +472,7 @@ def _process_refund(self, txn, department=None): if message: return message - self.receipt_manager.create_refund_transaction(txn.receipt, + self.receipt_manager.create_refund_transaction(txn, "Automatic refund of transaction " + txn.stripe_id, str(self.response_id), self.amount, @@ -1046,7 +1046,7 @@ def _process_refund(self, txn, department=None): self.receipt_manager.items_to_add.append(self.tracker) - refund_txn = self.receipt_manager.create_refund_transaction(txn.receipt, + refund_txn = self.receipt_manager.create_refund_transaction(txn, "Automatic refund of transaction " + txn.stripe_id, self.intent_id_from_txn_tracker(self.tracker), refund_amount, @@ -1190,29 +1190,30 @@ def create_payment_transaction(self, desc='', intent=None, amount=0, txn_total=0 department=department or self.receipt.default_department, amount=amount, txn_total=txn_total or amount, - receipt_items=self.receipt.open_receipt_items, + receipt_items=self.receipt.open_purchase_items, desc=desc, who=self.who or AdminAccount.admin_name() or 'non-admin' )) if not intent: - for item in self.receipt.open_receipt_items: + for item in self.receipt.open_purchase_items: item.closed = datetime.now() self.items_to_add.append(item) - def create_refund_transaction(self, receipt, desc, refund_id, amount, method=c.STRIPE, department=None): + def create_refund_transaction(self, refunded_txn, desc, refund_id, amount, method=c.STRIPE, department=None): from uber.models import AdminAccount, ReceiptTransaction - receipt_txn = ReceiptTransaction(receipt_id=receipt.id, + receipt_txn = ReceiptTransaction(receipt_id=refunded_txn.receipt.id, refund_id=refund_id, + refunded_txn_id=refunded_txn.id, method=method, - department=department or receipt.default_department, + department=department or refunded_txn.receipt.default_department, amount=amount * -1, - receipt_items=receipt.open_receipt_items, + receipt_items=refunded_txn.receipt.open_credit_items, desc=desc, who=self.who or AdminAccount.admin_name() or 'non-admin' ) - for item in receipt.open_receipt_items: + for item in refunded_txn.receipt.open_credit_items: self.items_to_add.append(item) item.closed = datetime.now() @@ -1562,8 +1563,9 @@ def mark_paid_from_ids(intent_id, charge_id): session.add(model) session.commit() + session.check_receipt_closed(txn_receipt) - if model and isinstance(model, Group) and model.is_dealer and not txn.receipt.open_receipt_items: + if model and isinstance(model, Group) and model.is_dealer and not txn.receipt.open_purchase_items: try: send_email.delay( c.MARKETPLACE_EMAIL, @@ -1573,7 +1575,7 @@ def mark_paid_from_ids(intent_id, charge_id): model=model.to_dict('id')) except Exception: log.error('Unable to send {} payment confirmation email'.format(c.DEALER_TERM), exc_info=True) - if model and isinstance(model, ArtShowApplication) and not txn.receipt.open_receipt_items: + if model and isinstance(model, ArtShowApplication) and not txn.receipt.open_purchase_items: try: send_email.delay( c.ART_SHOW_EMAIL, @@ -1583,7 +1585,7 @@ def mark_paid_from_ids(intent_id, charge_id): model=model.to_dict('id')) except Exception: log.error('Unable to send Art Show payment confirmation email', exc_info=True) - if model and isinstance(model, ArtistMarketplaceApplication) and not txn.receipt.open_receipt_items: + if model and isinstance(model, ArtistMarketplaceApplication) and not txn.receipt.open_purchase_items: send_email.delay( c.ARTIST_MARKETPLACE_EMAIL, c.ARTIST_MARKETPLACE_EMAIL, diff --git a/uber/site_sections/art_show_admin.py b/uber/site_sections/art_show_admin.py index 0a3c61952..dfc45d046 100644 --- a/uber/site_sections/art_show_admin.py +++ b/uber/site_sections/art_show_admin.py @@ -957,6 +957,7 @@ def paid_with_cash(self, session, id): session.add_all(receipt_manager.items_to_add) session.commit() + session.check_receipt_closed(receipt) raise HTTPRedirect('form?id={}&message={}', id, f"Cash payment of {format_currency(amount_owed / 100)} recorded.") diff --git a/uber/site_sections/group_admin.py b/uber/site_sections/group_admin.py index beb738b90..9cb190c52 100644 --- a/uber/site_sections/group_admin.py +++ b/uber/site_sections/group_admin.py @@ -263,6 +263,7 @@ def paid_with_cash(self, session, id): session.add_all(receipt_manager.items_to_add) session.commit() + session.check_receipt_closed(receipt) raise HTTPRedirect('form?id={}&message={}', id, f"Cash payment of {format_currency(amount_owed / 100)} recorded.") diff --git a/uber/site_sections/marketplace.py b/uber/site_sections/marketplace.py index 43852f589..0c86c052c 100644 --- a/uber/site_sections/marketplace.py +++ b/uber/site_sections/marketplace.py @@ -166,6 +166,8 @@ def cancel(self, session, id): f"There was an issue processing your refund. " "Please contact us at {email_only(c.ARTIST_MARKETPLACE_EMAIL)}.") session.add_all(refund.get_receipt_items_to_add()) + session.commit() + session.check_receipt_closed(session.get_receipt_by_model(app.attendee)) if app.status == c.ACCEPTED: send_email.delay( diff --git a/uber/site_sections/preregistration.py b/uber/site_sections/preregistration.py index be8dc79c7..aa67009ac 100644 --- a/uber/site_sections/preregistration.py +++ b/uber/site_sections/preregistration.py @@ -2046,7 +2046,7 @@ def purchase_upgrades(self, session, id, **params): except Exception: return {'error': "Cannot find your receipt, please contact registration"} - if receipt.open_receipt_items and receipt.current_amount_owed: + if receipt.open_purchase_items and receipt.current_amount_owed: return {'error': "You already have an outstanding balance, please refresh the page to pay \ for your current items or contact {}".format(email_only(c.REGDESK_EMAIL))} diff --git a/uber/site_sections/reg_admin.py b/uber/site_sections/reg_admin.py index ebe5b23b2..f28a61e86 100644 --- a/uber/site_sections/reg_admin.py +++ b/uber/site_sections/reg_admin.py @@ -332,7 +332,7 @@ def comp_refund_receipt_item(self, session, id='', **params): session.add(txn) txn.refunded = txn.amount refund_id = str(refund.response_id) or getattr(refund, 'ref_id') - refund.receipt_manager.create_refund_transaction(txn.receipt, + refund.receipt_manager.create_refund_transaction(txn, "Automatic void of transaction " + txn.stripe_id, refund_id, txn.amount, method=refund.method) @@ -351,6 +351,7 @@ def comp_refund_receipt_item(self, session, id='', **params): item.comped = True session.commit() + session.check_receipt_closed(item.receipt) return {'message': "Item comped{}".format(message_add)} @@ -403,6 +404,7 @@ def undo_refund_receipt_item(self, session, id='', **params): item.reverted = True session.commit() + session.check_receipt_closed(item.receipt) return {'message': "Item reverted{}".format(message_add)} @@ -442,7 +444,7 @@ def add_receipt_txn(self, session, id='', **params): session.refresh(receipt) if receipt.current_amount_owed == 0: - for item in receipt.open_receipt_items: + for item in receipt.open_purchase_items + receipt.open_credit_items: if item.receipt_txn: item.closed = item.receipt_txn.added else: @@ -508,11 +510,11 @@ def refresh_receipt_txn(self, session, id='', **params): refund_id=last_refund_id, department=txn.receipt.default_department, amount=(new_amount - prior_amount) * -1, - receipt_items=txn.receipt.open_receipt_items, + receipt_items=txn.receipt.open_credit_items, desc="Automatic refund of Stripe transaction " + txn.stripe_id, who=AdminAccount.admin_name() or 'non-admin' )) - for item in txn.receipt.open_receipt_items: + for item in txn.receipt.open_credit_items: item.closed = datetime.now() session.commit() @@ -524,23 +526,6 @@ def refresh_receipt_txn(self, session, id='', **params): 'message': ' '.join(messages) if messages else "Transaction already up-to-date.", } - @ajax - def refund_receipt_txn(self, session, id, amount, **params): - txn = session.receipt_transaction(id) - return {'error': float(params.get('amount', 0)) * 100} - - refund = TransactionRequest(txn.receipt, amount=Decimal(amount) * 100) - error = refund.refund_or_cancel(txn) - if error: - return {'error': error} - - return { - 'refunded': id, - 'message': "Successfully refunded {}".format(format_currency(txn.refunded)), - 'refund_total': txn.refunded, - 'new_total': txn.receipt.total_str, - } - @ajax def resend_receipt(self, session, id, **params): from uber.tasks.registration import send_receipt_email @@ -582,6 +567,8 @@ def settle_up(self, session, id=''): session.add_all(refund.get_receipt_items_to_add()) session.commit() + session.check_receipt_closed(receipt) + raise HTTPRedirect('../reg_admin/receipt_items?id={}&message={}', session.get_model_by_receipt(receipt).id, f"{format_currency(refund_amount / 100)} refunded.") @@ -709,6 +696,7 @@ def process_full_refund(self, session, id='', attendee_id='', group_id='', exclu 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() + session.check_receipt_closed(receipt) message_end = f" Their group leader was refunded {format_currency(group_refund_amount / 100)}."\ if group_refund_amount else "" diff --git a/uber/site_sections/registration.py b/uber/site_sections/registration.py index dabb2fef1..b76818c2e 100644 --- a/uber/site_sections/registration.py +++ b/uber/site_sections/registration.py @@ -1096,6 +1096,7 @@ def mark_as_paid(self, session, id, payment_method): attendee.reg_station = cherrypy.session.get('reg_station') session.commit() + session.check_receipt_closed(receipt) return { 'success': True, 'message': 'Attendee{} marked as paid.'.format('s' if account else ''), diff --git a/uber/templates/reg_admin/receipt_items.html b/uber/templates/reg_admin/receipt_items.html index 040456f40..1542c847b 100644 --- a/uber/templates/reg_admin/receipt_items.html +++ b/uber/templates/reg_admin/receipt_items.html @@ -615,12 +615,14 @@