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
This commit is contained in:
Russell Ballestrini 2025-09-27 19:17:18 -04:00
parent bb322be252
commit 7a1dcfb7e4
2 changed files with 169 additions and 0 deletions

View file

@ -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

View file

@ -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