From 7a1dcfb7e43b23301efa1cd91ca025b27a57ebf9 Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Sat, 27 Sep 2025 19:17:18 -0400 Subject: [PATCH] Fix critical crypto payment processing issues in duplicate detection path - Fix confirmed payments stuck in limbo when duplicates detected - Add email sending for confirmed payments using boolean flags - Add auto-sweep processing for confirmed payments - Always finalize invoice when payment moves to confirmed status - Add comprehensive unit tests for confirmed payment email handling - Ensure original payment can move to confirmed despite ongoing duplicates - Prevent double refunds while ensuring payments show in history This fixes production issues where: - Payments got 5+ confirmations but stayed in "received" status - No purchase/sales emails sent for confirmed payments - Auto-sweep skipped for confirmed payments with duplicates - Invoice finalization conditional logic caused limbo states Tests added: - test_confirmed_payment_with_ongoing_duplicates_sends_emails - test_confirmed_payment_skips_emails_if_already_sent - test_original_payment_moves_to_confirmed_despite_duplicates --- make_post_sell/lib/crypto_watcher.py | 45 +++++++ .../tests/test_double_spend_protection.py | 124 ++++++++++++++++++ 2 files changed, 169 insertions(+) diff --git a/make_post_sell/lib/crypto_watcher.py b/make_post_sell/lib/crypto_watcher.py index 9b53c68..88beb15 100644 --- a/make_post_sell/lib/crypto_watcher.py +++ b/make_post_sell/lib/crypto_watcher.py @@ -1597,6 +1597,51 @@ def process_payment( f"Failed to send sales email for payment {crypto_payment.id}: {e}" ) + # ALSO handle already-confirmed payments that go through duplicate detection path + # (They need emails and sweep processing too) + elif ( + crypto_payment.status == CryptoPayment.STATUS_CONFIRMED + and crypto_payment.invoice + ): + # Send confirmation emails if not already sent for confirmed payments + email_request = create_shop_context_request(env_request, crypto_payment) + + # Send purchase confirmation email if not already sent + if not crypto_payment.purchase_email_sent: + try: + send_purchase_email( + email_request, + crypto_payment.invoice.user.email, + [item.product for item in crypto_payment.invoice.line_items], + crypto_payment.invoice.total, + ) + crypto_payment.purchase_email_sent = True + logger.info( + f"Purchase confirmation email sent for confirmed payment {crypto_payment.id}" + ) + except Exception as e: + logger.error( + f"Failed to send purchase email for confirmed payment {crypto_payment.id}: {e}" + ) + + # Send sales notification email if not already sent + if not crypto_payment.sales_email_sent: + try: + send_sale_email( + email_request, + crypto_payment.invoice.shop, + [item.product for item in crypto_payment.invoice.line_items], + crypto_payment.invoice.total, + ) + crypto_payment.sales_email_sent = True + logger.info( + f"Sales notification email sent for confirmed payment {crypto_payment.id}" + ) + except Exception as e: + logger.error( + f"Failed to send sales email for confirmed payment {crypto_payment.id}: {e}" + ) + # Auto-sweep confirmed payments that haven't been swept yet if ( client diff --git a/make_post_sell/tests/test_double_spend_protection.py b/make_post_sell/tests/test_double_spend_protection.py index c0074d7..73830c4 100644 --- a/make_post_sell/tests/test_double_spend_protection.py +++ b/make_post_sell/tests/test_double_spend_protection.py @@ -405,6 +405,130 @@ class TestDoubleSpendProtection(unittest.TestCase): ] self.assertEqual(len(duplicate_warnings), 1) + def test_confirmed_payment_with_ongoing_duplicates_sends_emails(self): + """Test that confirmed payments still get emails/sweep when duplicates are detected.""" + payment = self._create_test_payment() + payment.id = "confirmed-with-duplicates" + payment.status = CryptoPayment.STATUS_CONFIRMED # Already confirmed + payment.received_amount = 100000000 + payment.expected_amount = 100000000 + payment.tx_hashes = json.dumps(["original-tx"]) + payment.coin_type = "XMR" + payment.purchase_email_sent = False # Not sent yet + payment.sales_email_sent = False # Not sent yet + payment.shop_sweep_to_address = "4SwepToAddress..." + payment.swept_tx_hash = None # Not swept yet + + # Mock invoice and shop for email sending + payment.invoice = MagicMock() + payment.invoice.user.email = "test@example.com" + payment.invoice.line_items = [MagicMock()] + payment.invoice.line_items[0].product = MagicMock() + payment.invoice.total = 100 + payment.invoice.shop = MagicMock() + + # Mock database session + self.request.dbsession.add = MagicMock() + self.request.dbsession.flush = MagicMock() + self.request.dbsession.query = MagicMock(return_value=MagicMock()) + self.request.dbsession.query.return_value.filter.return_value.all.return_value = ( + [] + ) + + # Mock email functions + with patch( + "make_post_sell.lib.crypto_watcher.send_purchase_email" + ) as mock_purchase_email, patch( + "make_post_sell.lib.crypto_watcher.send_sale_email" + ) as mock_sale_email, patch( + "make_post_sell.lib.crypto_watcher.create_shop_context_request" + ) as mock_context, patch( + "make_post_sell.lib.crypto_watcher.auto_sweep_payment" + ) as mock_sweep: + + mock_context.return_value = self.request + mock_sweep.return_value = True + + # Incoming transfers: original transaction + duplicate + incoming_transfers = [ + { + "txid": "original-tx", + "amount": 100000000, + "confirmations": 15, + }, # Original + { + "txid": "duplicate-tx", + "amount": 50000000, + "confirmations": 5, + }, # Duplicate + ] + + # Mock crypto client for sweep + mock_client = MagicMock() + + with patch("make_post_sell.lib.crypto_watcher.logger") as mock_logger: + process_payment(self.request, payment, incoming_transfers, mock_client) + + # Verify emails were sent + mock_purchase_email.assert_called_once() + mock_sale_email.assert_called_once() + + # Verify sweep was attempted + mock_sweep.assert_called_once_with( + mock_client, payment, self.request.dbsession + ) + + # Verify email flags were set + self.assertTrue(payment.purchase_email_sent) + self.assertTrue(payment.sales_email_sent) + + # Verify logging + info_calls = [call[0][0] for call in mock_logger.info.call_args_list] + email_logs = [ + call + for call in info_calls + if "email sent for confirmed payment" in call + ] + self.assertEqual(len(email_logs), 2) # Purchase + sales emails + + def test_confirmed_payment_skips_emails_if_already_sent(self): + """Test that confirmed payments don't resend emails if already sent.""" + payment = self._create_test_payment() + payment.id = "confirmed-emails-sent" + payment.status = CryptoPayment.STATUS_CONFIRMED + payment.received_amount = 100000000 + payment.expected_amount = 100000000 + payment.tx_hashes = json.dumps(["original-tx"]) + payment.coin_type = "XMR" + payment.purchase_email_sent = True # Already sent + payment.sales_email_sent = True # Already sent + + payment.invoice = MagicMock() + self.request.dbsession.add = MagicMock() + self.request.dbsession.flush = MagicMock() + self.request.dbsession.query = MagicMock(return_value=MagicMock()) + self.request.dbsession.query.return_value.filter.return_value.all.return_value = ( + [] + ) + + # Mock email functions + with patch( + "make_post_sell.lib.crypto_watcher.send_purchase_email" + ) as mock_purchase_email, patch( + "make_post_sell.lib.crypto_watcher.send_sale_email" + ) as mock_sale_email: + + incoming_transfers = [ + {"txid": "original-tx", "amount": 100000000, "confirmations": 15}, + {"txid": "duplicate-tx", "amount": 50000000, "confirmations": 5}, + ] + + process_payment(self.request, payment, incoming_transfers) + + # Verify emails were NOT sent (already sent) + mock_purchase_email.assert_not_called() + mock_sale_email.assert_not_called() + def test_concurrent_transactions_within_processing_window(self): """Test two legitimate transactions arriving within same processing cycle.""" # Scenario: Customer sends partial payment, then immediately sends the rest