diff --git a/make_post_sell/lib/crypto_watcher.py b/make_post_sell/lib/crypto_watcher.py index 2bda2c8..034feed 100644 --- a/make_post_sell/lib/crypto_watcher.py +++ b/make_post_sell/lib/crypto_watcher.py @@ -328,6 +328,402 @@ def get_coin_config(coin_type): return COIN_CONFIGS.get(coin_type, COIN_CONFIGS["XMR"]) +def sweep_restocking_fee(settings, payment, refund_details, dbsession, context=""): + """ + Sweep the restocking fee to the shop owner's address after a refund. + + Args: + settings: Pyramid settings + payment: CryptoPayment object + refund_details: Dict with refund details including fee_amount + dbsession: Database session + context: String describing the refund context (for logging) + """ + import logging + import time + from decimal import Decimal + + logger = logging.getLogger(__name__) + + if not payment.shop_sweep_to_address: + return + + coin_config = get_coin_config(payment.coin_type) + atomic_units = coin_config["atomic_units"] + fee_amount = int(refund_details["fee_amount"] * atomic_units) + + if fee_amount <= 0: + return + + try: + # Small delay to ensure refund tx is processed + time.sleep(2) + + if payment.coin_type == "XMR": + # Sweep remaining balance from this specific subaddress only + sweep_result = get_crypto_client(settings, "XMR")._call( + "sweep_all", + { + "account_index": payment.account_index, + "subaddr_indices": [payment.subaddress_index], + "address": payment.shop_sweep_to_address, + "priority": 1, + "get_tx_hex": True, + "do_not_relay": False, + }, + ) + fee_tx_hash = ( + sweep_result.get("tx_hash_list", [None])[0] + if sweep_result.get("tx_hash_list") + else None + ) + actual_swept = ( + sweep_result.get("amount_list", [0])[0] + if sweep_result.get("amount_list") + else 0 + ) + elif payment.coin_type == "DOGE": + # Send exact restocking fee amount + fee_crypto_amount = float(Decimal(fee_amount) / atomic_units) + fee_tx_hash = get_crypto_client(settings, "DOGE").sendtoaddress( + payment.shop_sweep_to_address, fee_crypto_amount + ) + actual_swept = fee_amount + else: + logger.warning( + f"Unsupported coin type for restocking fee sweep: {payment.coin_type}" + ) + return + + if fee_tx_hash: + logger.info( + f"{context} restocking fee swept for payment {payment.id}: " + f"{Decimal(actual_swept) / atomic_units} {payment.coin_type} " + f"TX: {fee_tx_hash}" + ) + + except Exception as e: + logger.error( + f"Failed to sweep {context.lower()} restocking fee for payment {payment.id}: {e}" + ) + + +def process_confirmed_payment(env_request, crypto_payment, client, payment_rescue=None): + """ + Process a fully confirmed payment with proper order of operations: + 1. Finalize invoice first (always) + 2. Handle overpayment refunds (if applicable) + 3. Sweep restocking fees (if refund occurred) + 4. Auto-sweep remaining invoice amount to shop (last) + + Args: + env_request: Environment request object + crypto_payment: CryptoPayment object + client: Crypto client + payment_rescue: PaymentRescue instance (optional) + + Returns: + dict with processing results + """ + logger = logging.getLogger(__name__) + results = { + "invoice_finalized": False, + "overpayment_refund": None, + "restocking_fee_swept": False, + "auto_sweep": None, + "final_status": None, + } + + logger.info( + f"Processing confirmed payment {crypto_payment.id} with proper order of operations" + ) + + try: + # STEP 1: Always finalize invoice first (customer gets their product) + # Check if invoice already finalized to make this idempotent + if not crypto_payment.is_finalized(): + logger.info(f"Step 1: Finalizing invoice for payment {crypto_payment.id}") + finalize_invoice(env_request, crypto_payment, send_emails=True) + results["invoice_finalized"] = True + else: + logger.info( + f"Step 1: Invoice for payment {crypto_payment.id} already finalized - skipping" + ) + results["invoice_finalized"] = True # Already done + + # STEP 2: Handle overpayment refunds (if applicable) + if payment_rescue and crypto_payment.refund_address: + coin_config = get_coin_config(crypto_payment.coin_type) + atomic_units = coin_config["atomic_units"] + received_crypto = Decimal(crypto_payment.received_amount) / atomic_units + expected_crypto = Decimal(crypto_payment.expected_amount) / atomic_units + + # Check if this is an overpayment that exceeds threshold + refund_details = payment_rescue.handle_overpayment( + crypto_payment, + expected_crypto, + received_crypto, + crypto_payment.invoice.user if crypto_payment.invoice else None, + ) + + if refund_details: + logger.info( + f"Step 2: Processing overpayment refund for {crypto_payment.id}" + ) + crypto_payment.status = CryptoPayment.STATUS_CONFIRMED_OVERPAID + crypto_payment.refund_reason = refund_details["reason"] + + # Check if refund already processed to make this idempotent + if not crypto_payment.refund_tx_hash: + # Attempt overpayment refund + result = payment_rescue.execute_refund( + refund_details, crypto_payment + ) + results["overpayment_refund"] = result + else: + # Refund already processed successfully in previous attempt + logger.info( + f"Overpayment refund for {crypto_payment.id} already processed - TX: {crypto_payment.refund_tx_hash}" + ) + result = {"success": True, "tx_hash": crypto_payment.refund_tx_hash} + results["overpayment_refund"] = result + + if result["success"]: + logger.info( + f"Overpayment refund successful for {crypto_payment.id}: TX {result['tx_hash']}" + ) + # Set refund details but keep in overpaid status for now + crypto_payment.refund_tx_hash = result["tx_hash"] + crypto_payment.refund_confirmations = 0 + + # STEP 3: Sweep restocking fee to shop owner + logger.info( + f"Step 3: Sweeping restocking fee for overpayment {crypto_payment.id}" + ) + sweep_restocking_fee( + env_request.registry.settings, + crypto_payment, + refund_details, + env_request.dbsession, + "Overpayment", + ) + results["restocking_fee_swept"] = True + # Will set final status at end of function + else: + logger.error( + f"Overpayment refund failed for {crypto_payment.id}: {result['error']} - will retry" + ) + # Don't proceed to auto-sweep if refund failed - keep in overpaid status for retry + results["final_status"] = CryptoPayment.STATUS_CONFIRMED_OVERPAID + return results + else: + # Overpayment within threshold - will confirm normally at end + logger.info(f"Overpayment within 5% threshold for {crypto_payment.id}") + else: + # Normal payment - will confirm at end + pass + + # STEP 4: Auto-sweep only the invoice amount (not entire wallet) - LAST + if crypto_payment.shop_sweep_to_address: + logger.info( + f"Step 4: Auto-sweeping invoice amount for payment {crypto_payment.id}" + ) + + # Calculate exact amount to sweep (expected invoice amount only) + coin_config = get_coin_config(crypto_payment.coin_type) + atomic_units = coin_config["atomic_units"] + invoice_amount_crypto = ( + Decimal(crypto_payment.expected_amount) / atomic_units + ) + + sweep_result = auto_sweep_invoice_amount( + client, crypto_payment, invoice_amount_crypto, env_request.dbsession + ) + results["auto_sweep"] = sweep_result + + if sweep_result and sweep_result.get("success"): + logger.info( + f"Invoice amount auto-sweep successful for {crypto_payment.id}: " + f"TX {sweep_result['tx_hash']}, Amount: {invoice_amount_crypto} {crypto_payment.coin_type}" + ) + else: + logger.error( + f"Auto-sweep failed for {crypto_payment.id}: {sweep_result}" + ) + else: + logger.info( + f"No shop sweep address configured for payment {crypto_payment.id}" + ) + + # STEP 5: Set terminal status ONLY at the very end when everything succeeded + if results.get("overpayment_refund") and results["overpayment_refund"].get( + "success" + ): + # Overpayment refund completed successfully + crypto_payment.status = CryptoPayment.STATUS_CONFIRMED_OVERPAID_REFUNDED + results["final_status"] = CryptoPayment.STATUS_CONFIRMED_OVERPAID_REFUNDED + logger.info( + f"Payment {crypto_payment.id} fully processed - overpayment refunded and completed" + ) + else: + # Normal payment or overpayment within threshold + crypto_payment.status = CryptoPayment.STATUS_CONFIRMED + results["final_status"] = CryptoPayment.STATUS_CONFIRMED + logger.info(f"Payment {crypto_payment.id} fully processed - confirmed") + + return results + + except Exception as e: + logger.error(f"Error processing confirmed payment {crypto_payment.id}: {e}") + results["error"] = str(e) + return results + + +def auto_sweep_invoice_amount( + client, crypto_payment: CryptoPayment, amount_to_sweep, dbsession=None +): + """ + Auto-sweep a specific amount (not entire wallet) to shop's cold wallet. + + Args: + client: Crypto client + crypto_payment: CryptoPayment object + amount_to_sweep: Decimal amount in crypto units to sweep (not atomic units) + dbsession: Database session + + Returns: + dict with sweep results + """ + logger = logging.getLogger(__name__) + + logger.info( + f"Auto-sweeping specific amount {amount_to_sweep} {crypto_payment.coin_type} " + f"for payment {crypto_payment.id} to {crypto_payment.shop_sweep_to_address}" + ) + + if crypto_payment.coin_type == "DOGE": + return auto_sweep_doge_amount( + client, crypto_payment, amount_to_sweep, dbsession + ) + elif crypto_payment.coin_type == "XMR": + return auto_sweep_xmr_amount(client, crypto_payment, amount_to_sweep, dbsession) + else: + return { + "success": False, + "error": f"Unsupported coin type: {crypto_payment.coin_type}", + } + + +def auto_sweep_doge_amount( + client, crypto_payment: CryptoPayment, amount_to_sweep, dbsession=None +): + """ + Sweep specific DOGE amount (not entire wallet balance). + """ + logger = logging.getLogger(__name__) + + try: + # Check if we have enough balance for the specific amount + balance = client.getbalance() + logger.info( + f"Wallet balance: {balance} DOGE, need to sweep: {amount_to_sweep} DOGE" + ) + + if balance < float(amount_to_sweep): + return { + "success": False, + "error": f"Insufficient balance: need {amount_to_sweep} DOGE but only have {balance} DOGE", + } + + # Send specific amount (not entire balance) + tx_hash = client.sendtoaddress( + crypto_payment.shop_sweep_to_address, float(amount_to_sweep) + ) + + # Update database + crypto_payment.swept_tx_hash = tx_hash + crypto_payment.swept_amount = int( + float(amount_to_sweep) * 100000000 + ) # Convert to koinu + crypto_payment.swept_confirmations = 0 + + if dbsession: + try: + dbsession.flush() + except Exception as e: + logger.warning(f"Failed to update sweep info in database: {e}") + + return {"success": True, "tx_hash": tx_hash, "amount_swept": amount_to_sweep} + + except Exception as e: + logger.error(f"DOGE amount sweep error for payment {crypto_payment.id}: {e}") + return {"success": False, "error": str(e)} + + +def auto_sweep_xmr_amount( + client, crypto_payment: CryptoPayment, amount_to_sweep, dbsession=None +): + """ + Sweep specific XMR amount using transfer (not sweep_all). + """ + logger = logging.getLogger(__name__) + + try: + account_index = crypto_payment.account_index or 0 + amount_atomic = int( + float(amount_to_sweep) * 1000000000000 + ) # Convert to piconero + + # Check balance + balance_result = client._call("get_balance", {"account_index": account_index}) + unlocked_balance = balance_result.get("unlocked_balance", 0) + + logger.info( + f"Account {account_index} unlocked balance: {unlocked_balance} piconero, " + f"need to sweep: {amount_atomic} piconero" + ) + + if unlocked_balance < amount_atomic: + return { + "success": False, + "error": f"Insufficient unlocked balance: need {amount_atomic} but only have {unlocked_balance} piconero", + } + + # Transfer specific amount (not sweep_all) + transfer_params = { + "destinations": [ + { + "address": crypto_payment.shop_sweep_to_address, + "amount": amount_atomic, + } + ], + "account_index": account_index, + "get_tx_key": True, + "do_not_relay": False, + "priority": 1, + } + + result = client._call("transfer", transfer_params) + tx_hash = result.get("tx_hash") + + # Update database + crypto_payment.swept_tx_hash = tx_hash + crypto_payment.swept_amount = amount_atomic + crypto_payment.swept_confirmations = 0 + + if dbsession: + try: + dbsession.flush() + except Exception as e: + logger.warning(f"Failed to update sweep info in database: {e}") + + return {"success": True, "tx_hash": tx_hash, "amount_swept": amount_to_sweep} + + except Exception as e: + logger.error(f"XMR amount sweep error for payment {crypto_payment.id}: {e}") + return {"success": False, "error": str(e)} + + def auto_sweep_payment(client, crypto_payment: CryptoPayment, dbsession=None): """Auto-sweep funds from a confirmed payment to the shop's cold wallet.""" logger.info(f"Starting auto-sweep check for payment {crypto_payment.id}") @@ -1893,76 +2289,35 @@ def process_payment( invoice_needs_finalization = True break + # Use new organized payment confirmation flow with proper order of operations if crypto_payment.status != CryptoPayment.STATUS_CONFIRMED: - crypto_payment.status = CryptoPayment.STATUS_CONFIRMED - # Finalize the invoice: unlock products, notify, and update inventory - if crypto_payment.invoice: - finalize_invoice(env_request, crypto_payment) + # Process confirmed payment with proper order: finalize → refund → sweep + process_results = process_confirmed_payment( + env_request, crypto_payment, client, payment_rescue + ) + + if process_results: + # Safe extraction of nested values + overpay_refund = process_results.get("overpayment_refund") or {} + auto_sweep = process_results.get("auto_sweep") or {} + + logger.info( + f"Payment confirmation results for {crypto_payment.id}: " + f"finalized={process_results.get('invoice_finalized', False)}, " + f"overpay_refund={overpay_refund.get('success', 'N/A')}, " + f"restocking_swept={process_results.get('restocking_fee_swept', False)}, " + f"auto_sweep={auto_sweep.get('success', 'N/A')}" + ) + else: + logger.warning( + f"Payment confirmation failed for {crypto_payment.id} - no results returned" + ) elif invoice_needs_finalization: # Handle stuck confirmed payments that were never finalized logger.warning( - f"Payment {crypto_payment.id} is confirmed but invoice not finalized - finalizing now (no duplicate emails)" + f"Payment {crypto_payment.id} is confirmed but invoice not finalized - finalizing now" ) finalize_invoice(env_request, crypto_payment, send_emails=False) - - # Check for overpayment refund AFTER confirming the order (applies to both new and stuck payments) - if ( - payment_rescue - and crypto_payment.refund_address - and crypto_payment.received_amount > crypto_payment.expected_amount - and crypto_payment.invoice - and crypto_payment.invoice.user - ): - coin_config = get_coin_config(crypto_payment.coin_type) - atomic_units = coin_config["atomic_units"] - received_xmr = Decimal(crypto_payment.received_amount) / atomic_units - expected_xmr = Decimal(crypto_payment.expected_amount) / atomic_units - - refund_details = payment_rescue.handle_overpayment( - crypto_payment, - expected_xmr, - received_xmr, - crypto_payment.invoice.user, - ) - - if refund_details: - logger.info( - f"Overpayment detected for {crypto_payment.id}: {refund_details}" - ) - # Mark as confirmed with overpayment detected (refund pending) - crypto_payment.status = CryptoPayment.STATUS_CONFIRMED_OVERPAID - - result = payment_rescue.execute_refund(refund_details, crypto_payment) - if result["success"]: - logger.info( - f"Excess refunded for payment {crypto_payment.id}: TX {result['tx_hash']}" - ) - # Mark as confirmed with overpayment refunded - crypto_payment.status = ( - CryptoPayment.STATUS_CONFIRMED_OVERPAID_REFUNDED - ) - else: - logger.error( - f"Excess refund failed for payment {crypto_payment.id}: {result['error']}" - ) - - # Auto-sweep confirmed payments that haven't been swept yet - if ( - client - and crypto_payment.shop_sweep_to_address - and not crypto_payment.is_swept - ): - logger.info(f"Payment {crypto_payment.id} confirmed - attempting sweep") - try: - sweep_success = auto_sweep_payment( - client, crypto_payment, env_request.dbsession - ) - if sweep_success and crypto_payment.is_swept: - logger.info(f"Payment {crypto_payment.id} swept successfully") - except Exception as e: - logger.error( - f"Auto-sweep failed for confirmed payment {crypto_payment.id}: {e}" - ) else: # Early detection - as soon as we receive ANY funds, determine the refund scenario # This gets payments out of the active processing queue immediately @@ -1994,19 +2349,25 @@ def process_payment( ) # For first payment, use new_sum directly since we don't allow multiple payments + # Also check existing received payments that haven't been evaluated yet if ( payment_rescue and crypto_payment.invoice and crypto_payment.invoice.user - and new_sum_int < expected_amount_int - and crypto_payment.status != CryptoPayment.STATUS_UNDERPAID_REFUNDED - and crypto_payment.received_amount == 0 # First payment check + and crypto_payment.status + == CryptoPayment.STATUS_RECEIVED # Only check received status + and received_amount_int + < expected_amount_int # Check current received amount + and early_min_confs + >= int( + crypto_payment.confirmations_required + ) # Wait for required confirmations ): - # Underpayment detected - immediately mark for refund (even in mempool) + # Underpayment detected - mark for refund after required confirmations coin_config = get_coin_config(crypto_payment.coin_type) atomic_units = coin_config["atomic_units"] - # Use new_sum for the actual amount received - received_xmr = Decimal(new_sum_int) / atomic_units + # Use received_amount for the actual amount received + received_xmr = Decimal(received_amount_int) / atomic_units expected_xmr = Decimal(crypto_payment.expected_amount) / atomic_units logger.info( @@ -2045,6 +2406,19 @@ def process_payment( ) crypto_payment.refund_tx_hash = result["tx_hash"] crypto_payment.refund_reason = refund_details["reason"] + + # Commit the refund first + env_request.dbsession.add(crypto_payment) + env_request.dbsession.flush() + + # Sweep restocking fee to shop owner + sweep_restocking_fee( + env_request.registry.settings, + crypto_payment, + refund_details, + env_request.dbsession, + "Underpayment", + ) else: logger.error( f"Refund failed for underpayment {crypto_payment.id}: {result['error']}" @@ -2063,6 +2437,88 @@ def process_payment( env_request.dbsession, crypto_payment ) + # EARLY DETECTION: Check for overpayments on existing received payments + elif ( + payment_rescue + and crypto_payment.invoice + and crypto_payment.invoice.user + and crypto_payment.status + == CryptoPayment.STATUS_RECEIVED # Only check received status + and received_amount_int + > expected_amount_int # Check current received amount for overpayment + and early_min_confs + >= int( + crypto_payment.confirmations_required + ) # Wait for required confirmations + ): + # Overpayment detected - check if it exceeds threshold + coin_config = get_coin_config(crypto_payment.coin_type) + atomic_units = coin_config["atomic_units"] + received_crypto = Decimal(received_amount_int) / atomic_units + expected_crypto = Decimal(expected_amount_int) / atomic_units + + logger.info( + f"EARLY DETECTION - Potential overpayment for payment {crypto_payment.id}: " + f"received {received_crypto} {crypto_payment.coin_type}, expected {expected_crypto} {crypto_payment.coin_type} " + f"({early_min_confs}/{crypto_payment.confirmations_required} confirmations) - checking threshold" + ) + + # Check if overpayment exceeds 5% threshold using PaymentRescue logic + refund_details = payment_rescue.handle_overpayment( + crypto_payment, + expected_crypto, + received_crypto, + crypto_payment.invoice.user, + ) + + if refund_details: + # Overpayment exceeds threshold - process refund + logger.info( + f"EARLY DETECTION - Overpayment threshold exceeded for {crypto_payment.id}: {refund_details}" + ) + + # Mark as confirmed with overpayment detected (refund pending) + crypto_payment.status = CryptoPayment.STATUS_CONFIRMED_OVERPAID + crypto_payment.refund_reason = refund_details["reason"] + + # Try refund immediately since we have enough confirmations + result = payment_rescue.execute_refund( + refund_details, crypto_payment + ) + if result["success"]: + logger.info( + f"Excess refunded for payment {crypto_payment.id}: TX {result['tx_hash']}" + ) + # Mark as confirmed with overpayment refunded + crypto_payment.status = ( + CryptoPayment.STATUS_CONFIRMED_OVERPAID_REFUNDED + ) + crypto_payment.refund_tx_hash = result["tx_hash"] + crypto_payment.refund_confirmations = 0 # Just sent + + # Finalize invoice since core payment amount is sufficient + finalize_invoice(env_request, crypto_payment, send_emails=True) + + # Sweep restocking fee to shop owner + sweep_restocking_fee( + env_request.registry.settings, + crypto_payment, + refund_details, + env_request.dbsession, + "Overpayment", + ) + else: + logger.error( + f"Refund failed for overpayment {crypto_payment.id}: {result['error']}" + ) + else: + # Overpayment within acceptable threshold - just confirm normally + logger.info( + f"EARLY DETECTION - Overpayment within 5% threshold for {crypto_payment.id} - confirming normally" + ) + crypto_payment.status = CryptoPayment.STATUS_CONFIRMED + finalize_invoice(env_request, crypto_payment, send_emails=True) + # Check for overpayment (exact match or overpaid) - for first payment elif ( new_sum_int >= expected_amount_int @@ -2206,6 +2662,18 @@ def process_refund_confirmations(request, settings): logger.info( f"REFUND RETRY SUCCESSFUL for payment {payment.id}: TX {result['tx_hash']} (incoming payment now has {payment.current_confirmations} confirmations)" ) + + # Commit the refund first + db.flush() + + # Sweep restocking fee to shop owner + sweep_restocking_fee( + request.registry.settings, + payment, + refund_details, + db, + "Refund retry", + ) continue logger.debug( diff --git a/make_post_sell/models/crypto_payment.py b/make_post_sell/models/crypto_payment.py index 4ba6aec..306c43f 100644 --- a/make_post_sell/models/crypto_payment.py +++ b/make_post_sell/models/crypto_payment.py @@ -252,3 +252,27 @@ class CryptoPayment(RBase, Base): def is_fully_confirmed(self) -> bool: """Check if payment has reached required confirmation count.""" return self.current_confirmations >= self.confirmations_required + + def is_finalized(self) -> bool: + """Check if payment has been finalized (customer received their product). + + Only payments in confirmed statuses with sufficient funds and confirmations + can be considered truly finalized. + """ + # Must be in a status that could potentially be finalized + if self.status not in [ + self.STATUS_CONFIRMED, + self.STATUS_CONFIRMED_OVERPAID, + self.STATUS_CONFIRMED_OVERPAID_REFUNDED, + ]: + return False + + # Must have received sufficient funds (at least expected amount) + if not (self.received_amount >= self.expected_amount): + return False + + # Must have sufficient confirmations + if not (self.current_confirmations >= self.confirmations_required): + return False + + return True diff --git a/make_post_sell/tests/test_crypto_watcher.py b/make_post_sell/tests/test_crypto_watcher.py index 87caf5e..ab19ce4 100644 --- a/make_post_sell/tests/test_crypto_watcher.py +++ b/make_post_sell/tests/test_crypto_watcher.py @@ -35,6 +35,7 @@ from ..lib.crypto_watcher import ( get_coin_config, get_crypto_client, scan_wallet_for_double_or_late_payments, + sweep_restocking_fee, COIN_CONFIGS, ) from ..lib.crypto_clients import MockMoneroClient, MockDogecoinClient @@ -613,7 +614,9 @@ class CryptoWatcherIntegrationTests(unittest.TestCase): self.assertEqual(json.loads(payment.tx_hashes), ["tx123"]) # Invoice should be finalized - mock_finalize.assert_called_once_with(mock_request, payment) + mock_finalize.assert_called_once_with( + mock_request, payment, send_emails=True + ) @patch("make_post_sell.lib.crypto_watcher.finalize_invoice") def test_process_payment_insufficient_confirmations(self, mock_finalize): @@ -703,7 +706,9 @@ class CryptoWatcherIntegrationTests(unittest.TestCase): mock_rescue.handle_overpayment.return_value = { "payment_id": payment.id, "refund_address": "refund_address_123", - "amount_xmr": Decimal("0.5"), # Excess amount + "refund_amount": Decimal("0.5"), # Excess amount + "fee_amount": Decimal("0.05"), # 9% fee + "reason": "Overpayment exceeds 5% threshold", } mock_rescue.execute_refund.return_value = { "success": True, @@ -877,7 +882,7 @@ class CryptoWatcherIntegrationTests(unittest.TestCase): duplicate_payment.received_amount, 30000000000 ) # The duplicate amount - @patch("make_post_sell.lib.crypto_watcher.auto_sweep_payment") + @patch("make_post_sell.lib.crypto_watcher.auto_sweep_invoice_amount") @patch("make_post_sell.lib.crypto_watcher.finalize_invoice") def test_process_payment_triggers_auto_sweep(self, mock_finalize, mock_sweep): """Test that confirming payment triggers auto-sweep.""" @@ -900,8 +905,12 @@ class CryptoWatcherIntegrationTests(unittest.TestCase): process_payment(mock_request, payment, transfers, client=mock_client) - # Auto-sweep should be called after balance check with dbsession - mock_sweep.assert_called_with(mock_client, payment, mock_request.dbsession) + # Auto-sweep should be called with client, payment, invoice_amount_crypto, dbsession + # The invoice amount in crypto units should be 0.1 (100000000000 / 1e12 atomic units for XMR) + expected_invoice_amount = Decimal("0.1") + mock_sweep.assert_called_with( + mock_client, payment, expected_invoice_amount, mock_request.dbsession + ) self.assertEqual(mock_sweep.call_count, 1) def test_doge_payment_processing_with_correct_atomic_units(self): @@ -2432,5 +2441,1513 @@ class TransactionMatchingIntegrationTests(unittest.TestCase): ) +class SweepRestockingFeeTests(unittest.TestCase): + """Unit tests for sweep_restocking_fee function.""" + + def setUp(self): + """Set up test fixtures.""" + # Create mock payment objects + self.mock_xmr_payment = MagicMock() + self.mock_xmr_payment.id = "test-xmr-payment-id" + self.mock_xmr_payment.coin_type = "XMR" + self.mock_xmr_payment.shop_sweep_to_address = "45nBNFrADasdf123456789" + self.mock_xmr_payment.account_index = 1 + self.mock_xmr_payment.subaddress_index = 5 + + self.mock_doge_payment = MagicMock() + self.mock_doge_payment.id = "test-doge-payment-id" + self.mock_doge_payment.coin_type = "DOGE" + self.mock_doge_payment.shop_sweep_to_address = ( + "DE2ET4uMRYMQ3nhtSjiTcbbopA3VNn1Ckh" + ) + + # Mock refund details + self.refund_details = { + "fee_amount": Decimal("0.36"), # 0.36 DOGE or XMR fee + "refund_amount": Decimal("3.64"), + "payment_id": "test-payment-id", + } + + # Mock settings + self.mock_settings = {} + self.mock_dbsession = MagicMock() + + @patch("make_post_sell.lib.crypto_watcher.get_crypto_client") + @patch("make_post_sell.lib.crypto_watcher.time.sleep") + def test_sweep_restocking_fee_doge_success(self, mock_sleep, mock_get_client): + """Test successful DOGE restocking fee sweep.""" + # Setup mock DOGE client + mock_doge_client = MagicMock() + mock_doge_client.sendtoaddress.return_value = "doge_tx_hash_123" + mock_get_client.return_value = mock_doge_client + + # Execute sweep + sweep_restocking_fee( + self.mock_settings, + self.mock_doge_payment, + self.refund_details, + self.mock_dbsession, + "Test", + ) + + # Verify client was called correctly + mock_get_client.assert_called_once_with(self.mock_settings, "DOGE") + mock_doge_client.sendtoaddress.assert_called_once_with( + "DE2ET4uMRYMQ3nhtSjiTcbbopA3VNn1Ckh", 0.36 # fee_amount as float + ) + + # Verify sleep was called (2 second delay) + mock_sleep.assert_called_once_with(2) + + @patch("make_post_sell.lib.crypto_watcher.get_crypto_client") + @patch("make_post_sell.lib.crypto_watcher.time.sleep") + def test_sweep_restocking_fee_xmr_success(self, mock_sleep, mock_get_client): + """Test successful XMR restocking fee sweep.""" + # Setup mock XMR client + mock_xmr_client = MagicMock() + mock_xmr_client._call.return_value = { + "tx_hash_list": ["xmr_tx_hash_456"], + "amount_list": [360000000000], # 0.36 XMR in piconero + } + mock_get_client.return_value = mock_xmr_client + + # Execute sweep + sweep_restocking_fee( + self.mock_settings, + self.mock_xmr_payment, + self.refund_details, + self.mock_dbsession, + "Test", + ) + + # Verify client was called correctly + mock_get_client.assert_called_once_with(self.mock_settings, "XMR") + mock_xmr_client._call.assert_called_once_with( + "sweep_all", + { + "account_index": 1, + "subaddr_indices": [5], + "address": "45nBNFrADasdf123456789", + "priority": 1, + "get_tx_hex": True, + "do_not_relay": False, + }, + ) + + # Verify sleep was called + mock_sleep.assert_called_once_with(2) + + def test_sweep_restocking_fee_no_shop_address(self): + """Test that sweep is skipped when no shop address is configured.""" + payment_no_address = MagicMock() + payment_no_address.shop_sweep_to_address = None + + with patch( + "make_post_sell.lib.crypto_watcher.get_crypto_client" + ) as mock_get_client: + sweep_restocking_fee( + self.mock_settings, + payment_no_address, + self.refund_details, + self.mock_dbsession, + "Test", + ) + + # Verify no client calls were made + mock_get_client.assert_not_called() + + def test_sweep_restocking_fee_zero_fee(self): + """Test that sweep is skipped when fee amount is zero.""" + zero_fee_details = { + "fee_amount": Decimal("0"), + "refund_amount": Decimal("4.0"), + "payment_id": "test-payment-id", + } + + with patch( + "make_post_sell.lib.crypto_watcher.get_crypto_client" + ) as mock_get_client: + sweep_restocking_fee( + self.mock_settings, + self.mock_doge_payment, + zero_fee_details, + self.mock_dbsession, + "Test", + ) + + # Verify no client calls were made + mock_get_client.assert_not_called() + + @patch("make_post_sell.lib.crypto_watcher.get_crypto_client") + def test_sweep_restocking_fee_unsupported_coin(self, mock_get_client): + """Test handling of unsupported coin types.""" + unsupported_payment = MagicMock() + unsupported_payment.id = "test-unsupported-payment" + unsupported_payment.coin_type = "UNSUPPORTED" + unsupported_payment.shop_sweep_to_address = "bc1qtest123" + + with patch( + "make_post_sell.lib.crypto_watcher.logging.getLogger" + ) as mock_logger: + mock_log = MagicMock() + mock_logger.return_value = mock_log + + sweep_restocking_fee( + self.mock_settings, + unsupported_payment, + self.refund_details, + self.mock_dbsession, + "Test", + ) + + # Verify warning was logged + mock_log.warning.assert_called_once_with( + "Unsupported coin type for restocking fee sweep: UNSUPPORTED" + ) + + # Verify no client calls were made + mock_get_client.assert_not_called() + + @patch("make_post_sell.lib.crypto_watcher.get_crypto_client") + @patch("make_post_sell.lib.crypto_watcher.time.sleep") + def test_sweep_restocking_fee_doge_exception(self, mock_sleep, mock_get_client): + """Test exception handling for DOGE sweep failures.""" + # Setup mock DOGE client that throws exception + mock_doge_client = MagicMock() + mock_doge_client.sendtoaddress.side_effect = Exception("RPC connection error") + mock_get_client.return_value = mock_doge_client + + with patch( + "make_post_sell.lib.crypto_watcher.logging.getLogger" + ) as mock_logger: + mock_log = MagicMock() + mock_logger.return_value = mock_log + + sweep_restocking_fee( + self.mock_settings, + self.mock_doge_payment, + self.refund_details, + self.mock_dbsession, + "Test", + ) + + # Verify error was logged + mock_log.error.assert_called_once_with( + "Failed to sweep test restocking fee for payment test-doge-payment-id: RPC connection error" + ) + + @patch("make_post_sell.lib.crypto_watcher.get_crypto_client") + @patch("make_post_sell.lib.crypto_watcher.time.sleep") + def test_sweep_restocking_fee_xmr_exception(self, mock_sleep, mock_get_client): + """Test exception handling for XMR sweep failures.""" + # Setup mock XMR client that throws exception + mock_xmr_client = MagicMock() + mock_xmr_client._call.side_effect = Exception("Wallet locked") + mock_get_client.return_value = mock_xmr_client + + with patch( + "make_post_sell.lib.crypto_watcher.logging.getLogger" + ) as mock_logger: + mock_log = MagicMock() + mock_logger.return_value = mock_log + + sweep_restocking_fee( + self.mock_settings, + self.mock_xmr_payment, + self.refund_details, + self.mock_dbsession, + "Test", + ) + + # Verify error was logged + mock_log.error.assert_called_once_with( + "Failed to sweep test restocking fee for payment test-xmr-payment-id: Wallet locked" + ) + + @patch("make_post_sell.lib.crypto_watcher.get_crypto_client") + @patch("make_post_sell.lib.crypto_watcher.time.sleep") + def test_sweep_restocking_fee_context_logging(self, mock_sleep, mock_get_client): + """Test that context parameter is used correctly in logging.""" + # Setup mock DOGE client + mock_doge_client = MagicMock() + mock_doge_client.sendtoaddress.return_value = "doge_tx_hash_123" + mock_get_client.return_value = mock_doge_client + + with patch( + "make_post_sell.lib.crypto_watcher.logging.getLogger" + ) as mock_logger: + mock_log = MagicMock() + mock_logger.return_value = mock_log + + sweep_restocking_fee( + self.mock_settings, + self.mock_doge_payment, + self.refund_details, + self.mock_dbsession, + "Underpayment", # Specific context + ) + + # Verify success log includes context + mock_log.info.assert_called_once() + log_call_args = mock_log.info.call_args[0][0] + self.assertIn("Underpayment restocking fee swept", log_call_args) + self.assertIn("test-doge-payment-id", log_call_args) + self.assertIn("0.36", log_call_args) + self.assertIn("DOGE", log_call_args) + self.assertIn("doge_tx_hash_123", log_call_args) + + def test_sweep_restocking_fee_fee_amount_calculation(self): + """Test that fee amounts are calculated correctly in atomic units.""" + # Test with different fee amounts + test_cases = [ + (Decimal("0.1"), "DOGE", 10000000), # 0.1 DOGE = 10M koinu + (Decimal("0.01"), "XMR", 10000000000), # 0.01 XMR = 10B piconero + (Decimal("1.5"), "DOGE", 150000000), # 1.5 DOGE = 150M koinu + ] + + for fee_amount, coin_type, expected_atomic in test_cases: + with self.subTest(fee_amount=fee_amount, coin_type=coin_type): + payment = MagicMock() + payment.coin_type = coin_type + payment.shop_sweep_to_address = None # Will skip actual sweep + + refund_details = {"fee_amount": fee_amount} + + # This should not crash and should handle the calculation correctly + sweep_restocking_fee( + self.mock_settings, + payment, + refund_details, + self.mock_dbsession, + "Test", + ) + + # Test passes if no exception is raised + + +class RefundTypeTests(unittest.TestCase): + """Unit tests for different refund scenarios and fee calculations.""" + + def setUp(self): + self.mock_settings = { + "make_post_sell.crypto_watcher.dogecoin_rpc_url": "http://localhost:22555", + "make_post_sell.crypto_watcher.monero_rpc_url": "http://localhost:18083", + } + self.mock_dbsession = MagicMock() + + # Mock user and shop + self.mock_user = MagicMock() + self.mock_user.id = "user-123" + + self.mock_shop = MagicMock() + self.mock_shop.id = "shop-456" + self.mock_shop.crypto_sweep_to_address = "shop-sweep-address" + + # Create a basic payment for testing + self.mock_payment = MagicMock() + self.mock_payment.id = "payment-123" + self.mock_payment.coin_type = "XMR" + self.mock_payment.shop = self.mock_shop + self.mock_payment.shop_sweep_to_address = "shop-sweep-address" + + # Create invoice with shop reference + self.mock_invoice = MagicMock() + self.mock_invoice.shop = self.mock_shop + self.mock_payment.invoice = self.mock_invoice + + def test_underpayment_refund_with_fee(self): + """Test underpayment refund applies 9% restocking fee.""" + from make_post_sell.lib.crypto_payment_rescue import PaymentRescue + + mock_client = MagicMock() + rescue = PaymentRescue(self.mock_dbsession, mock_client) + + # Mock refund address lookup + with patch( + "make_post_sell.lib.crypto_payment_rescue.get_user_crypto_refund_address" + ) as mock_get_addr: + mock_refund_record = MagicMock() + mock_refund_record.address = "refund-address-123" + mock_get_addr.return_value = mock_refund_record + + expected_amount = Decimal("1.0") # 1 XMR expected + received_amount = Decimal("0.8") # 0.8 XMR received (underpayment) + + result = rescue.handle_underpayment( + self.mock_payment, expected_amount, received_amount, self.mock_user + ) + + # Verify refund details + self.assertEqual(result["type"], "underpayment") + self.assertEqual(result["payment_id"], "payment-123") + self.assertEqual(result["refund_address"], "refund-address-123") + self.assertEqual(result["received_amount"], received_amount) + self.assertEqual(result["expected_amount"], expected_amount) + + # Calculate expected refund: 0.8 - (0.8 * 0.09) = 0.8 - 0.072 = 0.728 + expected_refund = received_amount * Decimal("0.91") # 91% after 9% fee + expected_fee = received_amount * Decimal("0.09") # 9% fee + + self.assertEqual(result["refund_amount"], expected_refund) + self.assertEqual(result["fee_amount"], expected_fee) + self.assertIn("Underpayment", result["reason"]) + + def test_overpayment_refund_within_threshold(self): + """Test overpayment within 5% threshold requires no refund.""" + from make_post_sell.lib.crypto_payment_rescue import PaymentRescue + + mock_client = MagicMock() + rescue = PaymentRescue(self.mock_dbsession, mock_client) + + expected_amount = Decimal("1.0") # 1 XMR expected + received_amount = Decimal( + "1.04" + ) # 1.04 XMR received (4% overpayment - within threshold) + + result = rescue.handle_overpayment( + self.mock_payment, expected_amount, received_amount, self.mock_user + ) + + # Should return None - no refund needed within threshold + self.assertIsNone(result) + + def test_overpayment_refund_above_threshold(self): + """Test overpayment above 5% threshold triggers refund with 9% fee.""" + from make_post_sell.lib.crypto_payment_rescue import PaymentRescue + + mock_client = MagicMock() + rescue = PaymentRescue(self.mock_dbsession, mock_client) + + # Mock refund address lookup + with patch( + "make_post_sell.lib.crypto_payment_rescue.get_user_crypto_refund_address" + ) as mock_get_addr: + mock_refund_record = MagicMock() + mock_refund_record.address = "refund-address-456" + mock_get_addr.return_value = mock_refund_record + + expected_amount = Decimal("1.0") # 1 XMR expected + received_amount = Decimal( + "1.1" + ) # 1.1 XMR received (10% overpayment - above threshold) + + result = rescue.handle_overpayment( + self.mock_payment, expected_amount, received_amount, self.mock_user + ) + + # Verify refund details + self.assertEqual(result["type"], "overpayment") + self.assertEqual(result["payment_id"], "payment-123") + self.assertEqual(result["refund_address"], "refund-address-456") + self.assertEqual(result["received_amount"], received_amount) + self.assertEqual(result["expected_amount"], expected_amount) + + # Calculate expected refund on excess: 0.1 - (0.1 * 0.09) = 0.1 - 0.009 = 0.091 + excess_amount = received_amount - expected_amount # 0.1 XMR excess + expected_refund = excess_amount * Decimal( + "0.91" + ) # 91% of excess after 9% fee + expected_fee = excess_amount * Decimal("0.09") # 9% fee on excess + + self.assertEqual(result["excess_amount"], excess_amount) + self.assertEqual(result["refund_amount"], expected_refund) + self.assertEqual(result["fee_amount"], expected_fee) + self.assertIn("Overpayment exceeds 5% threshold", result["reason"]) + + def test_expired_payment_refund_with_fee(self): + """Test expired payment refund applies 9% restocking fee.""" + from make_post_sell.lib.crypto_payment_rescue import PaymentRescue + + mock_client = MagicMock() + rescue = PaymentRescue(self.mock_dbsession, mock_client) + + # Mock refund address lookup + with patch( + "make_post_sell.lib.crypto_payment_rescue.get_user_crypto_refund_address" + ) as mock_get_addr: + mock_refund_record = MagicMock() + mock_refund_record.address = "refund-address-789" + mock_get_addr.return_value = mock_refund_record + + received_amount = Decimal("2.5") # 2.5 XMR received after expiration + + result = rescue.handle_expired_payment( + self.mock_payment, received_amount, self.mock_user + ) + + # Verify refund details + self.assertEqual(result["type"], "expired") + self.assertEqual(result["payment_id"], "payment-123") + self.assertEqual(result["refund_address"], "refund-address-789") + self.assertEqual(result["received_amount"], received_amount) + + # Calculate expected refund: 2.5 - (2.5 * 0.09) = 2.5 - 0.225 = 2.275 + expected_refund = received_amount * Decimal("0.91") # 91% after 9% fee + expected_fee = received_amount * Decimal("0.09") # 9% fee + + self.assertEqual(result["refund_amount"], expected_refund) + self.assertEqual(result["fee_amount"], expected_fee) + self.assertEqual( + result["reason"], "Payment received after quote expiration" + ) + + def test_refund_without_address_returns_none(self): + """Test that refund returns None when no refund address is configured.""" + from make_post_sell.lib.crypto_payment_rescue import PaymentRescue + + mock_client = MagicMock() + rescue = PaymentRescue(self.mock_dbsession, mock_client) + + # Mock no refund address found + with patch( + "make_post_sell.lib.crypto_payment_rescue.get_user_crypto_refund_address" + ) as mock_get_addr: + mock_get_addr.return_value = None # No refund address + + result = rescue.handle_underpayment( + self.mock_payment, Decimal("1.0"), Decimal("0.8"), self.mock_user + ) + + self.assertIsNone(result) + + def test_zero_refund_amount_returns_none(self): + """Test that refund returns None when calculated refund amount is zero or negative.""" + from make_post_sell.lib.crypto_payment_rescue import PaymentRescue + + mock_client = MagicMock() + rescue = PaymentRescue(self.mock_dbsession, mock_client) + + # Mock refund address lookup + with patch( + "make_post_sell.lib.crypto_payment_rescue.get_user_crypto_refund_address" + ) as mock_get_addr: + mock_refund_record = MagicMock() + mock_refund_record.address = "refund-address-123" + mock_get_addr.return_value = mock_refund_record + + # Tiny amount where 9% fee would leave zero or negative refund + received_amount = Decimal("0.01") # Very small amount + + result = rescue.handle_underpayment( + self.mock_payment, Decimal("1.0"), received_amount, self.mock_user + ) + + # Should return None since refund amount would be too small + # 0.01 - (0.01 * 0.09) = 0.01 - 0.0009 = 0.0091 (still positive, but might be rounded to 0) + # Let's test with even smaller amount + tiny_amount = Decimal("0.001") + result2 = rescue.handle_underpayment( + self.mock_payment, Decimal("1.0"), tiny_amount, self.mock_user + ) + + # This should still be positive: 0.001 * 0.91 = 0.00091 + # So let's test the calculate_refund_amount function directly + from make_post_sell.lib.crypto_payment_rescue import calculate_refund_amount + + # Test edge case where fee would exceed amount (shouldn't happen with 9% but test anyway) + zero_result = calculate_refund_amount(Decimal("0"), Decimal("0.09")) + self.assertEqual(zero_result, Decimal("0")) + + def test_out_of_stock_refund_no_fee(self): + """Test out of stock refunds have NO fee (full refund).""" + # Out of stock refunds are handled directly in crypto_watcher.py, not PaymentRescue + # But we can test the principle by checking that full refunds have no fee deduction + + from make_post_sell.lib.crypto_payment_rescue import calculate_refund_amount + + received_amount = Decimal("3.5") # 3.5 XMR received + + # Out of stock should refund full amount (fee_percent = 0) + full_refund = calculate_refund_amount(received_amount, Decimal("0")) + self.assertEqual(full_refund, received_amount) + + # Compare with normal refund that has 9% fee + normal_refund = calculate_refund_amount(received_amount, Decimal("0.09")) + expected_normal = received_amount * Decimal("0.91") # 91% after 9% fee + self.assertEqual(normal_refund, expected_normal) + + # Verify out of stock refund is larger + self.assertGreater(full_refund, normal_refund) + + def test_doge_refund_execution_uses_correct_rpc(self): + """Test DOGE refund execution uses sendtoaddress RPC.""" + from make_post_sell.lib.crypto_payment_rescue import PaymentRescue + + # Create DOGE payment + doge_payment = MagicMock() + doge_payment.id = "doge-payment-123" + doge_payment.coin_type = "DOGE" + doge_payment.current_confirmations = 2 # Sufficient confirmations + doge_payment.account_index = None # DOGE doesn't use account_index + + mock_client = MagicMock() + mock_client.sendtoaddress.return_value = "doge-tx-hash-123" + mock_client.getbalance.return_value = 100.0 # Sufficient DOGE balance + + rescue = PaymentRescue(self.mock_dbsession, mock_client) + + refund_details = { + "payment_id": "doge-payment-123", + "refund_address": "DTestAddress123", + "refund_amount": Decimal("4.55"), # 4.55 DOGE refund + "fee_amount": Decimal("0.45"), # 0.45 DOGE fee + "reason": "Test DOGE refund", + "type": "underpayment", + } + + result = rescue.execute_refund(refund_details, doge_payment) + + # Verify DOGE-specific RPC was called + mock_client.sendtoaddress.assert_called_once_with("DTestAddress123", 4.55) + mock_client.getbalance.assert_called_once() # Balance check + + # Verify successful result + self.assertTrue(result["success"]) + self.assertEqual(result["tx_hash"], "doge-tx-hash-123") + + def test_xmr_refund_execution_uses_correct_rpc(self): + """Test XMR refund execution uses transfer RPC with atomic units.""" + from make_post_sell.lib.crypto_payment_rescue import PaymentRescue + + # Create XMR payment + xmr_payment = MagicMock() + xmr_payment.id = "xmr-payment-456" + xmr_payment.coin_type = "XMR" + xmr_payment.current_confirmations = 10 # Sufficient confirmations + xmr_payment.account_index = 5 + + mock_client = MagicMock() + mock_client._call.side_effect = [ + { + "balance": 2000000000000, + "unlocked_balance": 1500000000000, + }, # Balance check + {"tx_hash": "xmr-tx-hash-456"}, # Transfer result + ] + + rescue = PaymentRescue(self.mock_dbsession, mock_client) + + refund_details = { + "payment_id": "xmr-payment-456", + "refund_address": "4TestXMRAddress123", + "refund_amount": Decimal("1.82"), # 1.82 XMR refund + "fee_amount": Decimal("0.18"), # 0.18 XMR fee + "reason": "Test XMR refund", + "type": "overpayment", + } + + result = rescue.execute_refund(refund_details, xmr_payment) + + # Verify XMR-specific RPC calls + expected_balance_call = mock_client._call.call_args_list[0] + self.assertEqual(expected_balance_call[0][0], "get_balance") + self.assertEqual(expected_balance_call[0][1]["account_index"], 5) + + expected_transfer_call = mock_client._call.call_args_list[1] + self.assertEqual(expected_transfer_call[0][0], "transfer") + transfer_params = expected_transfer_call[0][1] + self.assertEqual(transfer_params["account_index"], 5) + self.assertEqual( + transfer_params["destinations"][0]["address"], "4TestXMRAddress123" + ) + # 1.82 XMR = 1.82 * 10^12 = 1,820,000,000,000 piconero + self.assertEqual(transfer_params["destinations"][0]["amount"], 1820000000000) + + # Verify successful result + self.assertTrue(result["success"]) + self.assertEqual(result["tx_hash"], "xmr-tx-hash-456") + + @patch("make_post_sell.lib.crypto_watcher.get_coin_config") + def test_insufficient_confirmations_delays_refund(self, mock_get_coin_config): + """Test refund is delayed when incoming payment lacks confirmations.""" + from make_post_sell.lib.crypto_payment_rescue import PaymentRescue + + # Mock coin config to require 10 confirmations + mock_get_coin_config.return_value = { + "confirmations_required": 10, + "atomic_units": 1000000000000, # XMR atomic units + } + + # Create payment with insufficient confirmations + payment = MagicMock() + payment.id = "payment-insufficient" + payment.coin_type = "XMR" + payment.current_confirmations = 2 # Only 2 confirmations, need 10 + + mock_client = MagicMock() + rescue = PaymentRescue(self.mock_dbsession, mock_client) + + refund_details = { + "payment_id": "payment-insufficient", + "refund_address": "test-address", + "refund_amount": Decimal("1.0"), + "fee_amount": Decimal("0.1"), + "reason": "Test insufficient confirmations", + } + + result = rescue.execute_refund(refund_details, payment) + + # Verify refund was delayed + self.assertFalse(result["success"]) + self.assertIn("confirmations before refund", result["error"]) + self.assertIn("confirmations_needed", result) + self.assertEqual(result["confirmations_needed"], 8) # 10 - 2 = 8 + + def test_calculate_refund_amount_precision(self): + """Test refund amount calculation with high precision decimals.""" + from make_post_sell.lib.crypto_payment_rescue import ( + calculate_refund_amount, + RESTOCKING_FEE_PERCENT, + ) + + # Test with various amounts and verify the calculation is correct + amount1 = Decimal("1.23456789") + result1 = calculate_refund_amount(amount1, RESTOCKING_FEE_PERCENT) + expected1 = amount1 * (Decimal("1") - RESTOCKING_FEE_PERCENT) # amount1 * 0.91 + self.assertEqual(result1, expected1) + + amount2 = Decimal("100") + result2 = calculate_refund_amount(amount2, RESTOCKING_FEE_PERCENT) + expected2 = Decimal("91") # 100 * 0.91 + self.assertEqual(result2, expected2) + + # Test zero case + result_zero = calculate_refund_amount(Decimal("0"), RESTOCKING_FEE_PERCENT) + self.assertEqual(result_zero, Decimal("0")) + + def test_unsupported_coin_refund_execution(self): + """Test unsupported coin type raises appropriate error in refund execution.""" + from make_post_sell.lib.crypto_payment_rescue import PaymentRescue + + # Create payment with unsupported coin + unsupported_payment = MagicMock() + unsupported_payment.id = "unsupported-payment" + unsupported_payment.coin_type = "UNSUPPORTED" + unsupported_payment.current_confirmations = 10 + + mock_client = MagicMock() + rescue = PaymentRescue(self.mock_dbsession, mock_client) + + refund_details = { + "payment_id": "unsupported-payment", + "refund_address": "unsupported-address", + "refund_amount": Decimal("1.0"), + "reason": "Test unsupported coin", + } + + result = rescue.execute_refund(refund_details, unsupported_payment) + + # Verify refund failed with appropriate error + self.assertFalse(result["success"]) + self.assertIn("not supported for coin type", result["error"]) + + +class PaymentConfirmationOrderTests(unittest.TestCase): + """Unit tests for proper order of operations in payment confirmation.""" + + def setUp(self): + self.mock_env_request = MagicMock() + self.mock_settings = { + "make_post_sell.crypto_watcher.dogecoin_rpc_url": "http://localhost:22555", + "make_post_sell.crypto_watcher.monero_rpc_url": "http://localhost:18083", + "make_post_sell.dogecoin.rpc_url": "http://localhost:22555", # Add this for sweep functions + "make_post_sell.monero.rpc_url": "http://localhost:18083", + } + self.mock_env_request.registry.settings = self.mock_settings + self.mock_env_request.dbsession = MagicMock() + + # Mock user, shop, and invoice + self.mock_user = MagicMock() + self.mock_user.id = "user-123" + + self.mock_shop = MagicMock() + self.mock_shop.id = "shop-456" + + self.mock_invoice = MagicMock() + self.mock_invoice.user = self.mock_user + self.mock_invoice.shop = self.mock_shop + + # Create DOGE payment for testing (default) + self.mock_payment = MagicMock() + self.mock_payment.id = "payment-123" + self.mock_payment.coin_type = "DOGE" + self.mock_payment.invoice = self.mock_invoice + self.mock_payment.shop_sweep_to_address = "DShopSweepAddress123" + self.mock_payment.refund_address = "DRefundAddress456" + self.mock_payment.status = "received" # Not finalized yet + self.mock_payment.current_confirmations = 2 + self.mock_payment.confirmations_required = 2 + self.mock_payment.is_finalized.return_value = False # Mock method + self.mock_payment.refund_tx_hash = None # No refund processed yet + + # Create XMR payment for testing + self.mock_xmr_payment = MagicMock() + self.mock_xmr_payment.id = "payment-xmr-456" + self.mock_xmr_payment.coin_type = "XMR" + self.mock_xmr_payment.invoice = self.mock_invoice + self.mock_xmr_payment.shop_sweep_to_address = "4XMRShopSweepAddress123" + self.mock_xmr_payment.refund_address = "4XMRRefundAddress456" + self.mock_xmr_payment.account_index = 3 + self.mock_xmr_payment.status = "received" # Not finalized yet + self.mock_xmr_payment.current_confirmations = 10 + self.mock_xmr_payment.confirmations_required = 10 + self.mock_xmr_payment.is_finalized.return_value = False # Mock method + self.mock_xmr_payment.refund_tx_hash = None # No refund processed yet + + # Mock client + self.mock_client = MagicMock() + self.mock_client.getbalance.return_value = 20.0 # Sufficient DOGE balance + self.mock_client.sendtoaddress.return_value = "sweep-tx-hash-123" + + @patch("make_post_sell.lib.crypto_watcher.finalize_invoice") + @patch("make_post_sell.lib.crypto_watcher.get_coin_config") + def test_normal_payment_order_finalize_then_sweep( + self, mock_get_coin_config, mock_finalize + ): + """Test normal payment: 1) finalize invoice, 2) auto-sweep invoice amount.""" + from make_post_sell.lib.crypto_watcher import process_confirmed_payment + + # Mock coin config + mock_get_coin_config.return_value = {"atomic_units": 100000000} # DOGE + + # Normal payment (no overpayment) + self.mock_payment.received_amount = 500000000 # 5 DOGE + self.mock_payment.expected_amount = 500000000 # 5 DOGE (exact) + + # Mock auto-sweep success + self.mock_client.getbalance.return_value = 10.0 # Sufficient balance + self.mock_client.sendtoaddress.return_value = "sweep-tx-hash-123" + + # No payment rescue (normal payment) + result = process_confirmed_payment( + self.mock_env_request, + self.mock_payment, + self.mock_client, + payment_rescue=None, + ) + + # Verify order: finalize invoice first + mock_finalize.assert_called_once_with( + self.mock_env_request, self.mock_payment, send_emails=True + ) + + # Verify results + self.assertTrue(result["invoice_finalized"]) + self.assertIsNone(result["overpayment_refund"]) + self.assertFalse(result["restocking_fee_swept"]) + self.assertTrue(result["auto_sweep"]["success"]) + + # Verify auto-sweep called with correct amount (5 DOGE) + self.mock_client.sendtoaddress.assert_called_once_with( + "DShopSweepAddress123", 5.0 + ) + + @patch("make_post_sell.lib.crypto_watcher.finalize_invoice") + @patch("make_post_sell.lib.crypto_watcher.get_coin_config") + @patch("make_post_sell.lib.crypto_watcher.sweep_restocking_fee") + def test_overpayment_order_finalize_refund_fee_sweep( + self, mock_sweep_fee, mock_get_coin_config, mock_finalize + ): + """Test overpayment: 1) finalize, 2) refund excess, 3) sweep fee, 4) auto-sweep invoice amount.""" + from make_post_sell.lib.crypto_watcher import process_confirmed_payment + from make_post_sell.lib.crypto_payment_rescue import PaymentRescue + + # Mock coin config + mock_get_coin_config.return_value = {"atomic_units": 100000000} # DOGE + + # Overpayment scenario + self.mock_payment.received_amount = 600000000 # 6 DOGE received + self.mock_payment.expected_amount = ( + 400000000 # 4 DOGE expected (50% overpayment!) + ) + + # Mock payment rescue + mock_payment_rescue = MagicMock(spec=PaymentRescue) + + # Mock overpayment detection + overpayment_details = { + "type": "overpayment", + "payment_id": "payment-123", + "refund_address": "DRefundAddress456", + "received_amount": Decimal("6"), + "expected_amount": Decimal("4"), + "excess_amount": Decimal("2"), + "refund_amount": Decimal("1.82"), # 2 - (2 * 0.09) = 1.82 + "fee_amount": Decimal("0.18"), + "reason": "Overpayment exceeds 5% threshold", + } + mock_payment_rescue.handle_overpayment.return_value = overpayment_details + + # Mock successful refund + refund_result = {"success": True, "tx_hash": "refund-tx-hash-456"} + mock_payment_rescue.execute_refund.return_value = refund_result + + # Mock auto-sweep success + self.mock_client.getbalance.return_value = 10.0 + self.mock_client.sendtoaddress.return_value = "sweep-tx-hash-789" + + result = process_confirmed_payment( + self.mock_env_request, + self.mock_payment, + self.mock_client, + mock_payment_rescue, + ) + + # Verify order of operations: + # 1) Finalize invoice first + mock_finalize.assert_called_once_with( + self.mock_env_request, self.mock_payment, send_emails=True + ) + + # 2) Handle overpayment refund + mock_payment_rescue.handle_overpayment.assert_called_once() + mock_payment_rescue.execute_refund.assert_called_once_with( + overpayment_details, self.mock_payment + ) + + # 3) Sweep restocking fee + mock_sweep_fee.assert_called_once_with( + self.mock_settings, + self.mock_payment, + overpayment_details, + self.mock_env_request.dbsession, + "Overpayment", + ) + + # 4) Auto-sweep invoice amount (4 DOGE, not entire balance) + self.mock_client.sendtoaddress.assert_called_once_with( + "DShopSweepAddress123", 4.0 + ) + + # Verify results + self.assertTrue(result["invoice_finalized"]) + self.assertTrue(result["overpayment_refund"]["success"]) + self.assertTrue(result["restocking_fee_swept"]) + self.assertTrue(result["auto_sweep"]["success"]) + from make_post_sell.models.crypto_payment import CryptoPayment + + self.assertEqual( + result["final_status"], CryptoPayment.STATUS_CONFIRMED_OVERPAID_REFUNDED + ) + + @patch("make_post_sell.lib.crypto_watcher.finalize_invoice") + @patch("make_post_sell.lib.crypto_watcher.get_coin_config") + def test_overpayment_refund_fails_no_auto_sweep( + self, mock_get_coin_config, mock_finalize + ): + """Test overpayment refund failure prevents auto-sweep (preserves customer funds).""" + from make_post_sell.lib.crypto_watcher import process_confirmed_payment + from make_post_sell.lib.crypto_payment_rescue import PaymentRescue + + # Mock coin config + mock_get_coin_config.return_value = {"atomic_units": 100000000} # DOGE + + # Overpayment scenario + self.mock_payment.received_amount = 600000000 # 6 DOGE received + self.mock_payment.expected_amount = 400000000 # 4 DOGE expected + + # Mock payment rescue + mock_payment_rescue = MagicMock(spec=PaymentRescue) + + # Mock overpayment detection + overpayment_details = { + "type": "overpayment", + "refund_amount": Decimal("1.82"), + "fee_amount": Decimal("0.18"), # 9% fee + "reason": "Overpayment test", + } + mock_payment_rescue.handle_overpayment.return_value = overpayment_details + + # Mock FAILED refund (e.g., RPC error) + refund_result = {"success": False, "error": "RPC connection failed"} + mock_payment_rescue.execute_refund.return_value = refund_result + + result = process_confirmed_payment( + self.mock_env_request, + self.mock_payment, + self.mock_client, + mock_payment_rescue, + ) + + # Verify order: + # 1) Invoice finalized + mock_finalize.assert_called_once() + + # 2) Refund attempted but failed + self.assertFalse(result["overpayment_refund"]["success"]) + + # 3) NO auto-sweep should occur (preserves customer funds) + self.assertIsNone(result["auto_sweep"]) + self.mock_client.sendtoaddress.assert_not_called() + + # 4) Payment should be in "confirmed-overpaid" status (waiting for retry) + from make_post_sell.models.crypto_payment import CryptoPayment + + self.assertEqual( + result["final_status"], CryptoPayment.STATUS_CONFIRMED_OVERPAID + ) + + @patch("make_post_sell.lib.crypto_watcher.finalize_invoice") + @patch("make_post_sell.lib.crypto_watcher.get_coin_config") + def test_overpayment_within_threshold_normal_sweep( + self, mock_get_coin_config, mock_finalize + ): + """Test overpayment within 5% threshold: normal confirmation + full auto-sweep.""" + from make_post_sell.lib.crypto_watcher import process_confirmed_payment + from make_post_sell.lib.crypto_payment_rescue import PaymentRescue + + # Mock coin config + mock_get_coin_config.return_value = {"atomic_units": 100000000} # DOGE + + # Small overpayment within threshold + self.mock_payment.received_amount = 420000000 # 4.2 DOGE received + self.mock_payment.expected_amount = ( + 400000000 # 4.0 DOGE expected (5% overpayment - at threshold) + ) + + # Mock payment rescue + mock_payment_rescue = MagicMock(spec=PaymentRescue) + + # Mock overpayment within threshold (returns None) + mock_payment_rescue.handle_overpayment.return_value = None + + # Mock auto-sweep success + self.mock_client.getbalance.return_value = 10.0 + self.mock_client.sendtoaddress.return_value = "sweep-tx-hash-789" + + result = process_confirmed_payment( + self.mock_env_request, + self.mock_payment, + self.mock_client, + mock_payment_rescue, + ) + + # Verify no refund attempted + self.assertIsNone(result["overpayment_refund"]) + self.assertFalse(result["restocking_fee_swept"]) + + # Verify normal auto-sweep of invoice amount (4.0 DOGE) + self.mock_client.sendtoaddress.assert_called_once_with( + "DShopSweepAddress123", 4.0 + ) + self.assertTrue(result["auto_sweep"]["success"]) + from make_post_sell.models.crypto_payment import CryptoPayment + + self.assertEqual(result["final_status"], CryptoPayment.STATUS_CONFIRMED) + + @patch("make_post_sell.lib.crypto_watcher.get_coin_config") + def test_auto_sweep_amount_boundaries(self, mock_get_coin_config): + """Test auto-sweep only takes invoice amount, not entire wallet balance.""" + from make_post_sell.lib.crypto_watcher import auto_sweep_doge_amount + + # Mock coin config - not needed for DOGE amount sweep but good practice + mock_get_coin_config.return_value = {"atomic_units": 100000000} + + # Large wallet balance + self.mock_client.getbalance.return_value = 25.75 # 25.75 DOGE total balance + self.mock_client.sendtoaddress.return_value = "amount-sweep-tx-hash" + + # Only sweep specific amount (not entire balance) + invoice_amount = Decimal("3.25") # Only 3.25 DOGE for this invoice + + result = auto_sweep_doge_amount( + self.mock_client, self.mock_payment, invoice_amount, None + ) + + # Verify only invoice amount was swept, not entire wallet + self.mock_client.sendtoaddress.assert_called_once_with( + "DShopSweepAddress123", 3.25 # Specific amount, not 25.75 + ) + self.assertTrue(result["success"]) + self.assertEqual(result["amount_swept"], invoice_amount) + + @patch("make_post_sell.lib.crypto_watcher.get_coin_config") + def test_xmr_auto_sweep_amount_boundaries(self, mock_get_coin_config): + """Test XMR auto-sweep uses transfer (not sweep_all) for specific amounts.""" + from make_post_sell.lib.crypto_watcher import auto_sweep_xmr_amount + + # Mock coin config + mock_get_coin_config.return_value = {"atomic_units": 1000000000000} + + # XMR payment setup + self.mock_payment.coin_type = "XMR" + self.mock_payment.account_index = 5 + self.mock_payment.shop_sweep_to_address = "4XMRShopAddress123" + + # Large account balance + self.mock_client._call.side_effect = [ + { + "balance": 50000000000000, + "unlocked_balance": 40000000000000, + }, # 40 XMR unlocked + {"tx_hash": "xmr-amount-sweep-tx-hash"}, # Transfer result + ] + + # Only sweep specific amount + invoice_amount = Decimal("2.5") # Only 2.5 XMR for this invoice + + result = auto_sweep_xmr_amount( + self.mock_client, self.mock_payment, invoice_amount, None + ) + + # Verify transfer was called with specific amount (not sweep_all) + transfer_call = self.mock_client._call.call_args_list[1] + self.assertEqual(transfer_call[0][0], "transfer") + transfer_params = transfer_call[0][1] + + # Verify specific amount: 2.5 XMR = 2,500,000,000,000 piconero + expected_atomic = 2500000000000 + self.assertEqual(transfer_params["destinations"][0]["amount"], expected_atomic) + self.assertEqual( + transfer_params["destinations"][0]["address"], "4XMRShopAddress123" + ) + + self.assertTrue(result["success"]) + self.assertEqual(result["amount_swept"], invoice_amount) + + @patch("make_post_sell.lib.crypto_watcher.finalize_invoice") + @patch("make_post_sell.lib.crypto_watcher.get_coin_config") + def test_xmr_normal_payment_order_finalize_then_sweep( + self, mock_get_coin_config, mock_finalize + ): + """Test XMR normal payment: 1) finalize invoice, 2) auto-sweep invoice amount.""" + from make_post_sell.lib.crypto_watcher import process_confirmed_payment + + # Mock coin config for XMR + mock_get_coin_config.return_value = { + "atomic_units": 1000000000000 + } # XMR piconero + + # Normal XMR payment (no overpayment) + self.mock_xmr_payment.received_amount = 2500000000000 # 2.5 XMR + self.mock_xmr_payment.expected_amount = 2500000000000 # 2.5 XMR (exact) + + # Mock XMR auto-sweep success + self.mock_client._call.side_effect = [ + { + "balance": 10000000000000, + "unlocked_balance": 5000000000000, + }, # Balance check + {"tx_hash": "xmr-sweep-tx-hash-123"}, # Transfer result + ] + + # No payment rescue (normal payment) + result = process_confirmed_payment( + self.mock_env_request, + self.mock_xmr_payment, + self.mock_client, + payment_rescue=None, + ) + + # Verify order: finalize invoice first + mock_finalize.assert_called_once_with( + self.mock_env_request, self.mock_xmr_payment, send_emails=True + ) + + # Verify results + self.assertTrue(result["invoice_finalized"]) + self.assertIsNone(result["overpayment_refund"]) + self.assertFalse(result["restocking_fee_swept"]) + self.assertTrue(result["auto_sweep"]["success"]) + + # Verify XMR transfer was called with correct amount (2.5 XMR = 2,500,000,000,000 piconero) + transfer_call = self.mock_client._call.call_args_list[1] + self.assertEqual(transfer_call[0][0], "transfer") + transfer_params = transfer_call[0][1] + self.assertEqual(transfer_params["destinations"][0]["amount"], 2500000000000) + self.assertEqual( + transfer_params["destinations"][0]["address"], "4XMRShopSweepAddress123" + ) + + @patch("make_post_sell.lib.crypto_watcher.finalize_invoice") + @patch("make_post_sell.lib.crypto_watcher.get_coin_config") + @patch("make_post_sell.lib.crypto_watcher.sweep_restocking_fee") + def test_xmr_overpayment_order_finalize_refund_fee_sweep( + self, mock_sweep_fee, mock_get_coin_config, mock_finalize + ): + """Test XMR overpayment: 1) finalize, 2) refund excess, 3) sweep fee, 4) auto-sweep invoice amount.""" + from make_post_sell.lib.crypto_watcher import process_confirmed_payment + from make_post_sell.lib.crypto_payment_rescue import PaymentRescue + + # Mock coin config for XMR + mock_get_coin_config.return_value = { + "atomic_units": 1000000000000 + } # XMR piconero + + # XMR overpayment scenario + self.mock_xmr_payment.received_amount = 3000000000000 # 3.0 XMR received + self.mock_xmr_payment.expected_amount = ( + 2000000000000 # 2.0 XMR expected (50% overpayment!) + ) + + # Mock payment rescue + mock_payment_rescue = MagicMock(spec=PaymentRescue) + + # Mock overpayment detection + overpayment_details = { + "type": "overpayment", + "payment_id": "payment-xmr-456", + "refund_address": "4XMRRefundAddress456", + "received_amount": Decimal("3"), + "expected_amount": Decimal("2"), + "excess_amount": Decimal("1"), + "refund_amount": Decimal("0.91"), # 1 - (1 * 0.09) = 0.91 + "fee_amount": Decimal("0.09"), + "reason": "XMR overpayment exceeds 5% threshold", + } + mock_payment_rescue.handle_overpayment.return_value = overpayment_details + + # Mock successful XMR refund + refund_result = {"success": True, "tx_hash": "xmr-refund-tx-hash-456"} + mock_payment_rescue.execute_refund.return_value = refund_result + + # Mock XMR auto-sweep success + self.mock_client._call.side_effect = [ + { + "balance": 10000000000000, + "unlocked_balance": 5000000000000, + }, # Balance check + {"tx_hash": "xmr-sweep-tx-hash-789"}, # Transfer result + ] + + result = process_confirmed_payment( + self.mock_env_request, + self.mock_xmr_payment, + self.mock_client, + mock_payment_rescue, + ) + + # Verify order of operations: + # 1) Finalize invoice first + mock_finalize.assert_called_once_with( + self.mock_env_request, self.mock_xmr_payment, send_emails=True + ) + + # 2) Handle overpayment refund + mock_payment_rescue.handle_overpayment.assert_called_once() + mock_payment_rescue.execute_refund.assert_called_once_with( + overpayment_details, self.mock_xmr_payment + ) + + # 3) Sweep restocking fee + mock_sweep_fee.assert_called_once_with( + self.mock_settings, + self.mock_xmr_payment, + overpayment_details, + self.mock_env_request.dbsession, + "Overpayment", + ) + + # 4) Auto-sweep invoice amount (2.0 XMR = 2,000,000,000,000 piconero) + transfer_call = self.mock_client._call.call_args_list[1] + self.assertEqual(transfer_call[0][0], "transfer") + transfer_params = transfer_call[0][1] + self.assertEqual(transfer_params["destinations"][0]["amount"], 2000000000000) + self.assertEqual( + transfer_params["destinations"][0]["address"], "4XMRShopSweepAddress123" + ) + + # Verify results + self.assertTrue(result["invoice_finalized"]) + self.assertTrue(result["overpayment_refund"]["success"]) + self.assertTrue(result["restocking_fee_swept"]) + self.assertTrue(result["auto_sweep"]["success"]) + from make_post_sell.models.crypto_payment import CryptoPayment + + self.assertEqual( + result["final_status"], CryptoPayment.STATUS_CONFIRMED_OVERPAID_REFUNDED + ) + + @patch("make_post_sell.lib.crypto_watcher.finalize_invoice") + @patch("make_post_sell.lib.crypto_watcher.get_coin_config") + def test_xmr_overpayment_refund_fails_no_auto_sweep( + self, mock_get_coin_config, mock_finalize + ): + """Test XMR overpayment refund failure prevents auto-sweep (preserves customer funds).""" + from make_post_sell.lib.crypto_watcher import process_confirmed_payment + from make_post_sell.lib.crypto_payment_rescue import PaymentRescue + + # Mock coin config for XMR + mock_get_coin_config.return_value = { + "atomic_units": 1000000000000 + } # XMR piconero + + # XMR overpayment scenario + self.mock_xmr_payment.received_amount = 3500000000000 # 3.5 XMR received + self.mock_xmr_payment.expected_amount = 2000000000000 # 2.0 XMR expected + + # Mock payment rescue + mock_payment_rescue = MagicMock(spec=PaymentRescue) + + # Mock overpayment detection + overpayment_details = { + "type": "overpayment", + "refund_amount": Decimal("1.365"), # 1.5 - (1.5 * 0.09) + "reason": "XMR overpayment test", + } + mock_payment_rescue.handle_overpayment.return_value = overpayment_details + + # Mock FAILED XMR refund (e.g., insufficient balance) + refund_result = {"success": False, "error": "Insufficient unlocked balance"} + mock_payment_rescue.execute_refund.return_value = refund_result + + result = process_confirmed_payment( + self.mock_env_request, + self.mock_xmr_payment, + self.mock_client, + mock_payment_rescue, + ) + + # Verify order: + # 1) Invoice finalized + mock_finalize.assert_called_once() + + # 2) Refund attempted but failed + self.assertFalse(result["overpayment_refund"]["success"]) + + # 3) NO auto-sweep should occur (preserves customer funds) + self.assertIsNone(result["auto_sweep"]) + self.mock_client._call.assert_not_called() # No XMR transfer calls + + # 4) Payment should be in "confirmed-overpaid" status (waiting for retry) + from make_post_sell.models.crypto_payment import CryptoPayment + + self.assertEqual( + result["final_status"], CryptoPayment.STATUS_CONFIRMED_OVERPAID + ) + + @patch("make_post_sell.lib.crypto_watcher.finalize_invoice") + @patch("make_post_sell.lib.crypto_watcher.get_coin_config") + def test_mixed_currency_boundary_conditions( + self, mock_get_coin_config, mock_finalize + ): + """Test both DOGE and XMR auto-sweep respects invoice boundaries in mixed wallet scenarios.""" + from make_post_sell.lib.crypto_watcher import ( + auto_sweep_doge_amount, + auto_sweep_xmr_amount, + ) + + # Test DOGE with large wallet balance + mock_get_coin_config.return_value = {"atomic_units": 100000000} # DOGE koinu + + self.mock_client.getbalance.return_value = 50.12345 # Large DOGE balance + self.mock_client.sendtoaddress.return_value = "doge-precise-sweep-tx" + + # Sweep precise invoice amount only + doge_result = auto_sweep_doge_amount( + self.mock_client, self.mock_payment, Decimal("1.23456789"), None + ) + + # Verify precise DOGE amount (not entire wallet) + self.mock_client.sendtoaddress.assert_called_with( + "DShopSweepAddress123", 1.23456789 + ) + self.assertTrue(doge_result["success"]) + + # Test XMR with large account balance + mock_get_coin_config.return_value = { + "atomic_units": 1000000000000 + } # XMR piconero + + self.mock_client._call.side_effect = [ + { + "balance": 100000000000000, + "unlocked_balance": 75000000000000, + }, # 75 XMR unlocked + {"tx_hash": "xmr-precise-sweep-tx"}, + ] + + # Sweep precise XMR invoice amount only + xmr_result = auto_sweep_xmr_amount( + self.mock_client, self.mock_xmr_payment, Decimal("0.987654321"), None + ) + + # Verify precise XMR amount: 0.987654321 XMR = 987,654,321,000 piconero + transfer_call = self.mock_client._call.call_args_list[1] + transfer_params = transfer_call[0][1] + self.assertEqual(transfer_params["destinations"][0]["amount"], 987654321000) + self.assertEqual( + transfer_params["destinations"][0]["address"], "4XMRShopSweepAddress123" + ) + self.assertTrue(xmr_result["success"]) + + def test_payment_confirmation_comprehensive_coverage(self): + """Test that all payment confirmation scenarios are covered for both currencies.""" + test_cases = [ + # (currency, test_name, expected_behavior) + ("DOGE", "normal_payment", "invoice_finalized + auto_sweep"), + ( + "DOGE", + "overpayment_success", + "invoice_finalized + refund + fee_sweep + auto_sweep", + ), + ( + "DOGE", + "overpayment_failure", + "invoice_finalized + refund_failed + no_auto_sweep", + ), + ( + "DOGE", + "overpayment_threshold", + "invoice_finalized + no_refund + auto_sweep", + ), + ("XMR", "normal_payment", "invoice_finalized + auto_sweep"), + ( + "XMR", + "overpayment_success", + "invoice_finalized + refund + fee_sweep + auto_sweep", + ), + ( + "XMR", + "overpayment_failure", + "invoice_finalized + refund_failed + no_auto_sweep", + ), + ( + "XMR", + "overpayment_threshold", + "invoice_finalized + no_refund + auto_sweep", + ), + ] + + # This is a documentation test - verifies we have comprehensive coverage + implemented_tests = [ + # DOGE tests + "test_normal_payment_order_finalize_then_sweep", + "test_overpayment_order_finalize_refund_fee_sweep", + "test_overpayment_refund_fails_no_auto_sweep", + "test_overpayment_within_threshold_normal_sweep", + "test_auto_sweep_amount_boundaries", + # XMR tests + "test_xmr_normal_payment_order_finalize_then_sweep", + "test_xmr_overpayment_order_finalize_refund_fee_sweep", + "test_xmr_overpayment_refund_fails_no_auto_sweep", + "test_xmr_auto_sweep_amount_boundaries", + # Mixed tests + "test_mixed_currency_boundary_conditions", + ] + + # Verify we have comprehensive test coverage + self.assertEqual( + len(implemented_tests), + 10, + "Should have 10 comprehensive payment confirmation tests", + ) + + # Verify critical scenarios are covered for both currencies + critical_scenarios = [ + ("normal", ["normal"]), + ("overpay_success", ["overpayment", "finalize", "refund", "sweep"]), + ("overpay_fail", ["overpayment", "fails", "no"]), + ("boundaries", ["boundaries", "amount"]), + ] + + for scenario_name, keywords in critical_scenarios: + # DOGE coverage (tests that don't start with "test_xmr") + doge_tests = [ + test for test in implemented_tests if not test.startswith("test_xmr") + ] + doge_covered = any( + all(keyword.lower() in test.lower() for keyword in keywords) + for test in doge_tests + ) + + # XMR coverage (tests that start with "test_xmr") + xmr_tests = [ + test for test in implemented_tests if test.startswith("test_xmr") + ] + xmr_covered = any( + all(keyword.lower() in test.lower() for keyword in keywords) + for test in xmr_tests + ) + + self.assertTrue( + doge_covered, + f"DOGE {scenario_name} scenario should be covered in {doge_tests}", + ) + self.assertTrue( + xmr_covered, + f"XMR {scenario_name} scenario should be covered in {xmr_tests}", + ) + + @patch("make_post_sell.lib.crypto_watcher.finalize_invoice") + @patch("make_post_sell.lib.crypto_watcher.get_coin_config") + def test_idempotent_resumable_on_refund_failure( + self, mock_get_coin_config, mock_finalize + ): + """Test function is idempotent - customer gets product even if refund fails, can retry.""" + from make_post_sell.lib.crypto_watcher import process_confirmed_payment + from make_post_sell.lib.crypto_payment_rescue import PaymentRescue + from make_post_sell.models.crypto_payment import CryptoPayment + + # Mock coin config + mock_get_coin_config.return_value = {"atomic_units": 100000000} # DOGE + + # Overpayment scenario + self.mock_payment.received_amount = 800000000 # 8 DOGE received + self.mock_payment.expected_amount = 500000000 # 5 DOGE expected + + # Mock payment rescue + mock_payment_rescue = MagicMock(spec=PaymentRescue) + overpayment_details = { + "type": "overpayment", + "refund_amount": Decimal("2.73"), # 3 - (3 * 0.09) + "fee_amount": Decimal("0.27"), # 9% fee + "reason": "Test idempotent behavior", + } + mock_payment_rescue.handle_overpayment.return_value = overpayment_details + + # Mock FAILED refund (temporary RPC issue) + refund_result = {"success": False, "error": "Connection timeout"} + mock_payment_rescue.execute_refund.return_value = refund_result + + # First attempt - refund fails + result1 = process_confirmed_payment( + self.mock_env_request, + self.mock_payment, + self.mock_client, + mock_payment_rescue, + ) + + # Verify customer got their product (invoice finalized) + self.assertTrue(result1["invoice_finalized"]) + mock_finalize.assert_called_once() + + # Verify payment in non-terminal state (can be retried) + self.assertEqual( + result1["final_status"], CryptoPayment.STATUS_CONFIRMED_OVERPAID + ) + self.assertFalse(result1["overpayment_refund"]["success"]) + + # Reset mocks for retry attempt + mock_finalize.reset_mock() + + # Mock successful refund on retry + refund_success = {"success": True, "tx_hash": "retry-refund-tx-123"} + mock_payment_rescue.execute_refund.return_value = refund_success + self.mock_client.sendtoaddress.return_value = "retry-sweep-tx-456" + + # Second attempt - refund succeeds + result2 = process_confirmed_payment( + self.mock_env_request, + self.mock_payment, + self.mock_client, + mock_payment_rescue, + ) + + # Verify invoice not finalized again (idempotent) + mock_finalize.assert_called_once() # Should still be just once total + + # Verify successful completion + self.assertTrue(result2["overpayment_refund"]["success"]) + self.assertEqual( + result2["final_status"], CryptoPayment.STATUS_CONFIRMED_OVERPAID_REFUNDED + ) + + # Verify auto-sweep happened (5 DOGE invoice amount) + self.mock_client.sendtoaddress.assert_called_once_with( + "DShopSweepAddress123", 5.0 + ) + + if __name__ == "__main__": unittest.main()