Fix duplicate payment infinite loop - main loop creates records, scanner processes refunds

This commit is contained in:
Russell Ballestrini 2025-09-27 14:52:34 -04:00
parent f277e48f60
commit 74035d9191
3 changed files with 35 additions and 115 deletions

View file

@ -1433,10 +1433,11 @@ def process_payment(
):
logger.warning(
f"DUPLICATE PAYMENT DETECTED: {len(new_txids)} new transactions to {crypto_payment.status} payment {crypto_payment.id} "
f"with existing balance {crypto_payment.received_amount}. Creating separate duplicate payment records."
f"with existing balance {crypto_payment.received_amount}. Creating duplicate records for scanner to process."
)
# Create separate payment records for each duplicate transaction
# But DON'T process refunds here - let the scanner handle that
for txid in new_txids:
# Find the transaction data for this txid
duplicate_tx = None
@ -1455,7 +1456,7 @@ def process_payment(
logger.info(
f"Created duplicate payment record {duplicate_payment.id} for transaction {txid} "
f"with amount {duplicate_payment.received_amount} {crypto_payment.coin_type}"
f"with amount {duplicate_payment.received_amount} {crypto_payment.coin_type} - scanner will handle refund"
)
# Update confirmations from only legitimate (existing) transactions, not duplicates

View file

@ -545,17 +545,24 @@ class TestDoubleSpendIntegration(DatabaseIntegrationTests):
scan_wallet_for_double_or_late_payments(request, self.settings)
# Verify duplicate payment was created
# Verify duplicate payment(s) were created
# Now we create duplicates in both main loop AND scanner, so expect at least 1
duplicates = (
self.dbsession.query(CryptoPayment)
.filter(CryptoPayment.status == CryptoPayment.STATUS_DOUBLEPAY_REFUND)
.all()
)
self.assertEqual(len(duplicates), 1)
duplicate = duplicates[0]
self.assertEqual(duplicate.received_amount, 80000000)
self.assertEqual(duplicate.refund_tx_hash, "refund-tx-123")
self.assertGreaterEqual(len(duplicates), 1)
# Find the duplicate with the refund tx hash (from scanner)
refunded_duplicate = None
for dup in duplicates:
if dup.refund_tx_hash == "refund-tx-123":
refunded_duplicate = dup
break
self.assertIsNotNone(refunded_duplicate)
self.assertEqual(refunded_duplicate.received_amount, 80000000)
@patch("make_post_sell.lib.crypto_watcher.send_purchase_email")
@patch("make_post_sell.lib.crypto_watcher.send_sale_email")

View file

@ -86,51 +86,24 @@ class TestDoubleSpendProtection(unittest.TestCase):
# Mock the database session
self.request.dbsession.add = MagicMock()
# Process payment - should detect duplicate and create separate record
# Process payment - should detect duplicate and return early
with patch("make_post_sell.lib.crypto_watcher.logger") as mock_logger:
process_payment(self.request, payment, incoming_transfers)
# Verify warning was logged about duplicate detection
# Verify warning was logged
mock_logger.warning.assert_called_once()
warning_msg = mock_logger.warning.call_args[0][0]
self.assertIn("DUPLICATE PAYMENT DETECTED", warning_msg)
self.assertIn("Creating separate duplicate payment records", warning_msg)
self.assertIn("Creating duplicate records for scanner", warning_msg)
# Verify info was logged about creating duplicate record
info_calls = [call[0][0] for call in mock_logger.info.call_args_list]
duplicate_creation_calls = [
call
for call in info_calls
if "Created duplicate payment record" in call
]
self.assertEqual(len(duplicate_creation_calls), 1)
# Verify original payment was NOT updated with new amount
# Verify payment was NOT updated with new amount
self.assertEqual(payment.received_amount, 100000000) # Unchanged
# Verify confirmation count was still updated
self.assertEqual(payment.current_confirmations, 1)
# Verify a separate duplicate payment record was created
add_calls = self.request.dbsession.add.call_args_list
duplicate_payments = [
call[0][0]
for call in add_calls
if isinstance(call[0][0], CryptoPayment) and call[0][0].id != payment.id
]
self.assertEqual(len(duplicate_payments), 1)
duplicate_payment = duplicate_payments[0]
self.assertEqual(
duplicate_payment.status, CryptoPayment.STATUS_DOUBLEPAY_REFUND
)
self.assertEqual(
duplicate_payment.received_amount, 50000000
) # The duplicate amount
self.assertIsNone(duplicate_payment.invoice) # No invoice for duplicates
def test_duplicate_payment_to_received_status_creates_separate_record(self):
"""Test that payments in RECEIVED status create separate duplicate records."""
def test_duplicate_payment_to_received_status_rejected(self):
"""Test that payments in RECEIVED status also reject duplicates."""
payment = self._create_test_payment()
payment.id = "test-payment-2"
payment.status = CryptoPayment.STATUS_RECEIVED
@ -153,20 +126,11 @@ class TestDoubleSpendProtection(unittest.TestCase):
with patch("make_post_sell.lib.crypto_watcher.logger") as mock_logger:
process_payment(self.request, payment, incoming_transfers)
# Should detect as duplicate and create separate record
# Should detect as duplicate
mock_logger.warning.assert_called_once()
warning_msg = mock_logger.warning.call_args[0][0]
self.assertIn("DUPLICATE PAYMENT DETECTED", warning_msg)
self.assertIn("Creating separate duplicate payment records", warning_msg)
# Verify duplicate payment record was logged
info_calls = [call[0][0] for call in mock_logger.info.call_args_list]
duplicate_creation_calls = [
call
for call in info_calls
if "Created duplicate payment record" in call
]
self.assertEqual(len(duplicate_creation_calls), 1)
self.assertIn("Creating duplicate records for scanner", warning_msg)
def test_pending_payment_accepts_first_transaction(self):
"""Test that PENDING status correctly accepts the first transaction."""
@ -420,47 +384,21 @@ class TestDoubleSpendProtection(unittest.TestCase):
with patch("make_post_sell.lib.crypto_watcher.logger") as mock_logger:
process_payment(self.request, payment, second_tx)
# Should detect duplicate and create separate record
# Should detect duplicate and reject
mock_logger.warning.assert_called_once()
warning_msg = mock_logger.warning.call_args[0][0]
self.assertIn("DUPLICATE PAYMENT DETECTED", warning_msg)
self.assertIn("Creating separate duplicate payment records", warning_msg)
self.assertIn("1 new transactions", warning_msg)
# Verify duplicate payment record creation was logged
info_calls = [call[0][0] for call in mock_logger.info.call_args_list]
duplicate_creation_calls = [
call
for call in info_calls
if "Created duplicate payment record" in call
]
self.assertEqual(len(duplicate_creation_calls), 1)
# Original payment should NOT change (duplicate handled separately)
# Amount should NOT change (duplicate rejected)
self.assertEqual(payment.received_amount, 100000000) # Still 0.001 XMR
self.assertEqual(
payment.status, CryptoPayment.STATUS_CONFIRMED
) # Still confirmed
# Verify separate duplicate payment record was created
add_calls = self.request.dbsession.add.call_args_list
duplicate_payments = [
call[0][0]
for call in add_calls
if isinstance(call[0][0], CryptoPayment) and call[0][0].id != payment.id
]
self.assertEqual(len(duplicate_payments), 1)
duplicate_payment = duplicate_payments[0]
self.assertEqual(
duplicate_payment.status, CryptoPayment.STATUS_DOUBLEPAY_REFUND
)
self.assertEqual(
duplicate_payment.received_amount, 50000000
) # The duplicate amount
# Should update confirmation count from legitimate transactions only (not duplicates)
# Note: confirmation count is from the legitimate transaction (15), not the duplicate (1)
self.assertEqual(payment.current_confirmations, 15)
# Should update confirmation count but not add duplicate
# Note: confirmation count is minimum of all transactions including the duplicate (1)
self.assertEqual(payment.current_confirmations, 1)
def test_second_processing_cycle_rejects_duplicates(self):
"""Test that subsequent processing cycles correctly reject already-seen transactions."""
@ -518,44 +456,18 @@ class TestDoubleSpendProtection(unittest.TestCase):
with patch("make_post_sell.lib.crypto_watcher.logger") as mock_logger:
process_payment(self.request, payment, second_cycle_txs)
# Should detect the new transaction as a duplicate and create separate record
# Should detect the new transaction as a duplicate
mock_logger.warning.assert_called_once()
warning_msg = mock_logger.warning.call_args[0][0]
self.assertIn("DUPLICATE PAYMENT DETECTED", warning_msg)
self.assertIn("Creating separate duplicate payment records", warning_msg)
self.assertIn("1 new transactions", warning_msg)
# Verify duplicate payment record creation was logged
info_calls = [call[0][0] for call in mock_logger.info.call_args_list]
duplicate_creation_calls = [
call
for call in info_calls
if "Created duplicate payment record" in call
]
self.assertEqual(len(duplicate_creation_calls), 1)
# Original payment amount should NOT change (duplicate handled separately)
# Amount should NOT change (no new funds added)
self.assertEqual(payment.received_amount, 100000000)
# Verify separate duplicate payment record was created
add_calls = self.request.dbsession.add.call_args_list
duplicate_payments = [
call[0][0]
for call in add_calls
if isinstance(call[0][0], CryptoPayment) and call[0][0].id != payment.id
]
self.assertEqual(len(duplicate_payments), 1)
duplicate_payment = duplicate_payments[0]
self.assertEqual(
duplicate_payment.status, CryptoPayment.STATUS_DOUBLEPAY_REFUND
)
self.assertEqual(
duplicate_payment.received_amount, 25000000
) # The duplicate amount
# Only update confirmation count for original payment from legitimate transactions
# Note: confirmation count is from legitimate transactions (15), not the duplicate (1)
self.assertEqual(payment.current_confirmations, 15) # Updated confirmations
# Only update confirmation count, not add new funds
# Note: confirmation count is minimum of all transactions including the duplicate (1)
self.assertEqual(payment.current_confirmations, 1) # Updated confirmations
class TestWalletDrainPrevention(unittest.TestCase):