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:
parent
bb322be252
commit
7a1dcfb7e4
2 changed files with 169 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue