From d2d09e88515d38899ff77f5cf86f100a974d3400 Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Thu, 2 Oct 2025 16:19:23 -0400 Subject: [PATCH] Fix Dogecoin multi-output refunds with proper fee handling - Add payment amount to shop output for overpayment refunds - Both XMR and DOGE now send payment + restocking fee to shop - Fix insufficient funds error by using conservative fee buffer (0.01 DOGE) - Round outputs down to 3 decimal places to avoid precision issues - Update sendmany to try actual account first (where funds are located) - Add better error handling for different account formats - Fix fee calculation to properly account for Dogecoin's fee-on-top model The issue was that sendmany adds network fee on top of outputs, and we were trying to send exactly the wallet balance, leaving no room for fees. Now we reserve 0.01 DOGE for fees and round conservatively. --- Makefile | 42 +++- make_post_sell/lib/crypto_watcher/__init__.py | 173 ++++++++------ .../lib/crypto_watcher/crypto_clients.py | 19 +- .../crypto_watcher/crypto_payment_rescue.py | 222 +++++++++++++++-- make_post_sell/models/invoice.py | 4 +- make_post_sell/templates/crypto_checkout.j2 | 16 +- .../templates/crypto_quotes_history.j2 | 2 +- make_post_sell/tests/test_crypto_watcher.py | 79 +++--- .../tests/test_multi_output_refunds.py | 226 ++++++++++++++++++ make_post_sell/views/crypto.py | 35 ++- 10 files changed, 661 insertions(+), 157 deletions(-) create mode 100644 make_post_sell/tests/test_multi_output_refunds.py diff --git a/Makefile b/Makefile index ba0a29a..8eb4329 100644 --- a/Makefile +++ b/Makefile @@ -58,6 +58,8 @@ help: @echo " make dogecoin-node - Start Dogecoin daemon (pruned mode)" @echo " make dogecoin-node-full - Start Dogecoin daemon (full blockchain)" @echo " make dogecoin-status - Check sync status and wallet info" + @echo " make dogecoin-check-fee - Check current transaction fee setting" + @echo " make dogecoin-fix-fee - Fix high transaction fee (sets to 0.001 DOGE)" @echo "" @echo "WALLET MANAGEMENT:" @echo " make sweep-check - Check hot wallet balances (dev tool)" @@ -180,9 +182,10 @@ http: venv @echo "Starting simple HTTP server on port 8000..." $(PYTHON) -m http.server 8000 -# Run the crypto watcher service for monitoring Monero payments. +# Run the crypto watcher service for monitoring crypto payments. crypto-watcher: venv config @echo "Starting crypto payment watcher..." + @echo "💡 TIP: If Dogecoin refunds fail, check fee: make dogecoin-check-fee" $(VENV_DIR)/bin/crypto_watcher $(DATA_DIR)/$(CONFIG_FILE) # Run the crypto watcher once (for testing or manual processing). @@ -564,6 +567,12 @@ dogecoin-config: check-dogecoin @echo "" >> $(HOME)/.dogecoin/dogecoin.conf @echo "# Hot wallet for Make Post Sell" >> $(HOME)/.dogecoin/dogecoin.conf @echo "wallet=make_post_sell_hot_wallet" >> $(HOME)/.dogecoin/dogecoin.conf + @echo "" >> $(HOME)/.dogecoin/dogecoin.conf + @echo "# Transaction fee settings" >> $(HOME)/.dogecoin/dogecoin.conf + @echo "# Set reasonable fee for multi-output transactions (sendmany)" >> $(HOME)/.dogecoin/dogecoin.conf + @echo "paytxfee=0.001" >> $(HOME)/.dogecoin/dogecoin.conf + @echo "# Minimum fee per KB (prevents 0-fee transactions)" >> $(HOME)/.dogecoin/dogecoin.conf + @echo "mintxfee=0.001" >> $(HOME)/.dogecoin/dogecoin.conf @echo "" @echo "✓ Configuration created at: $(HOME)/.dogecoin/dogecoin.conf" @echo "" @@ -621,6 +630,37 @@ dogecoin-node-full: check-dogecoin -rpcallowip=127.0.0.1 \ -server=1 +# Check and fix Dogecoin transaction fee +dogecoin-check-fee: check-dogecoin + @echo "=== Checking Dogecoin Transaction Fee ===" + @if ! dogecoin-cli getwalletinfo >/dev/null 2>&1; then \ + echo "❌ Dogecoin daemon not running"; \ + echo "Start with: make dogecoin-node"; \ + exit 1; \ + fi + @fee=$$(dogecoin-cli getwalletinfo | grep paytxfee | awk '{print $$2}' | tr -d ','); \ + echo "Current transaction fee: $$fee DOGE"; \ + if [ "$$(echo "$$fee > 0.001" | bc -l)" = "1" ]; then \ + echo "⚠️ WARNING: Transaction fee is too high!"; \ + echo "This may cause sendmany (multi-output transactions) to fail."; \ + echo "Run 'make dogecoin-fix-fee' to set reasonable fee."; \ + else \ + echo "✓ Transaction fee is reasonable"; \ + fi + +# Set reasonable Dogecoin transaction fee +dogecoin-fix-fee: check-dogecoin + @echo "=== Setting Dogecoin Transaction Fee ===" + @if ! dogecoin-cli getwalletinfo >/dev/null 2>&1; then \ + echo "❌ Dogecoin daemon not running"; \ + echo "Start with: make dogecoin-node"; \ + exit 1; \ + fi + @echo "Setting transaction fee to 0.001 DOGE..." + @dogecoin-cli settxfee 0.001 + @echo "✓ Transaction fee updated" + @echo "New fee: $$(dogecoin-cli getwalletinfo | grep paytxfee | awk '{print $$2}' | tr -d ',') DOGE" + # Check Dogecoin sync status and wallet info dogecoin-status: check-dogecoin @echo "=== Dogecoin Node Status ===" diff --git a/make_post_sell/lib/crypto_watcher/__init__.py b/make_post_sell/lib/crypto_watcher/__init__.py index 0d4c251..1890efd 100644 --- a/make_post_sell/lib/crypto_watcher/__init__.py +++ b/make_post_sell/lib/crypto_watcher/__init__.py @@ -864,14 +864,16 @@ def auto_sweep_payment_xmr(client, crypto_payment: CryptoPayment, dbsession=None # Get dynamic fee estimate by doing a test transfer with do_not_relay=true try: # Estimate fee by doing a test transfer without broadcasting - # This gives accurate fee based on actual transaction size + # Use 96% of amount to leave room for fee in the same subaddress + test_amount_piconero = int(payment_amount_piconero * Decimal("0.96")) + test_transfer_result = client._call( "transfer", { "destinations": [ { "address": crypto_payment.shop_sweep_to_address, - "amount": payment_amount_piconero, + "amount": test_amount_piconero, } ], "account_index": crypto_payment.account_index, @@ -881,18 +883,24 @@ def auto_sweep_payment_xmr(client, crypto_payment: CryptoPayment, dbsession=None "get_tx_metadata": True, }, ) - estimated_fee_piconero = int( + dynamic_fee_piconero = int( test_transfer_result.get("fee", 100000000000) ) # Fallback to ~0.0001 XMR + # Add 10% margin to the dynamic fee estimate to ensure sweep always works + estimated_fee_piconero = int(dynamic_fee_piconero * Decimal("1.1")) log.payment_info( crypto_payment, - f"Dynamic fee estimate: {estimated_fee_piconero / atomic_units} XMR (single transaction)", + f"Dynamic fee estimate: {dynamic_fee_piconero / atomic_units} XMR, using {estimated_fee_piconero / atomic_units} XMR (with 10% margin)", ) except Exception as e: - # Fallback to hardcoded fee if RPC call fails - estimated_fee_piconero = int(Decimal("0.0001") * atomic_units) + # Fallback based on historical data: actual fees ~0.0000306 XMR + # Use 3x multiplier for safety margin + estimated_fee_piconero = int( + Decimal("0.0000918") * atomic_units + ) # ~91,800,000 piconero (3x typical fee) log.error_with_context( - "Failed to get dynamic fee estimate, using fallback", e + f"Failed to get dynamic fee estimate, using 3x typical fee as fallback: {e}", + e, ) # Calculate transfer amount: payment minus fee and reserve for pending refunds @@ -2758,85 +2766,98 @@ def process_payment( env_request.dbsession, crypto_payment ) - # EARLY DETECTION: Check for overpayments on existing received payments + # Check if received payment has enough confirmations to become confirmed 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 + crypto_payment.status == CryptoPayment.STATUS_RECEIVED + and received_amount_int >= expected_amount_int # Has enough amount and early_min_confs >= int( crypto_payment.confirmations_required - ) # Wait for required confirmations + ) # Has enough 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 - - log.payment_info( - crypto_payment, - f"EARLY DETECTION - Potential overpayment: " - 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 + # Check for exact payment first + if received_amount_int == expected_amount_int: log.payment_info( crypto_payment, - f"EARLY DETECTION - Overpayment threshold exceeded: {refund_details}", - ) - - # Mark as confirmed with overpayment detected (refund pending) - crypto_payment.status = CryptoPayment.STATUS_CONFIRMED_OVERPAY - 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"]: - log.payment_info( - crypto_payment, f"Excess refunded: TX {result['tx_hash']}" - ) - # Mark as confirmed with overpayment refunded - crypto_payment.status = ( - CryptoPayment.STATUS_CONFIRMED_OVERPAY_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) - - # Note: Restocking fee will be swept when refund is fully confirmed - # to avoid double-sweeping before refund transaction is safely confirmed - else: - log.payment_error( - crypto_payment, - f"Refund failed for overpayment: {result['error']}", - ) - else: - # Overpayment within acceptable threshold - just confirm normally - log.payment_info( - crypto_payment, - "EARLY DETECTION - Overpayment within 5% threshold - confirming normally", + f"Exact payment confirmed with {early_min_confs}/{crypto_payment.confirmations_required} confirmations", ) crypto_payment.status = CryptoPayment.STATUS_CONFIRMED finalize_invoice(env_request, crypto_payment, send_emails=True) + # EARLY DETECTION: Check for overpayments on existing received payments + elif ( + payment_rescue + and crypto_payment.invoice + and crypto_payment.invoice.user + and received_amount_int > expected_amount_int # Overpayment + ): + # 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 + + log.payment_info( + crypto_payment, + f"EARLY DETECTION - Potential overpayment: " + 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 + log.payment_info( + crypto_payment, + f"EARLY DETECTION - Overpayment threshold exceeded: {refund_details}", + ) + + # Mark as confirmed with overpayment detected (refund pending) + crypto_payment.status = CryptoPayment.STATUS_CONFIRMED_OVERPAY + 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"]: + log.payment_info( + crypto_payment, + f"Excess refunded: TX {result['tx_hash']}", + ) + # Mark as confirmed with overpayment refunded + crypto_payment.status = ( + CryptoPayment.STATUS_CONFIRMED_OVERPAY_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 + ) + + # Note: Restocking fee will be swept when refund is fully confirmed + # to avoid double-sweeping before refund transaction is safely confirmed + else: + log.payment_error( + crypto_payment, + f"Refund failed for overpayment: {result['error']}", + ) + else: + # Overpayment within acceptable threshold - just confirm normally + log.payment_info( + crypto_payment, + "EARLY DETECTION - Overpayment within 5% threshold - 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 ( diff --git a/make_post_sell/lib/crypto_watcher/crypto_clients.py b/make_post_sell/lib/crypto_watcher/crypto_clients.py index 9702e30..cc7b3c6 100644 --- a/make_post_sell/lib/crypto_watcher/crypto_clients.py +++ b/make_post_sell/lib/crypto_watcher/crypto_clients.py @@ -300,9 +300,22 @@ class DogecoinClient: """Send Dogecoin to an address. Returns transaction ID.""" return self._call("sendtoaddress", [address, amount, comment]) - def sendmany(self, from_label: str, addresses_amounts: Dict[str, float]) -> str: - """Send to multiple addresses at once. More efficient for sweeping.""" - return self._call("sendmany", [from_label, addresses_amounts]) + def sendmany(self, from_label: str, addresses_amounts: Dict[str, float], minconf: int = 1, comment: str = "") -> str: + """Send to multiple addresses at once. More efficient for sweeping. + + Args: + from_label: Account label (use "" for default account) + addresses_amounts: Dict mapping addresses to amounts + minconf: Minimum confirmations (default: 1) + comment: Transaction comment (optional) + """ + # Build params list - Dogecoin expects specific parameter order + params = [from_label, addresses_amounts] + if minconf != 1 or comment: + params.append(minconf) + if comment: + params.append(comment) + return self._call("sendmany", params) # Blockchain Info diff --git a/make_post_sell/lib/crypto_watcher/crypto_payment_rescue.py b/make_post_sell/lib/crypto_watcher/crypto_payment_rescue.py index 87c7983..48e559b 100644 --- a/make_post_sell/lib/crypto_watcher/crypto_payment_rescue.py +++ b/make_post_sell/lib/crypto_watcher/crypto_payment_rescue.py @@ -119,6 +119,7 @@ class PaymentRescue: "excess_amount": excess_amount, "refund_amount": refund_amount, "fee_amount": excess_amount - refund_amount, + "payment_amount": expected_amount, # Shop should get the actual payment too! "reason": f"Overpayment exceeds {int(OVERPAYMENT_THRESHOLD_PERCENT * 100)}% threshold: received {received_amount} but expected {expected_amount}", } @@ -158,10 +159,14 @@ class PaymentRescue: def execute_refund(self, refund_details, payment=None): """ Execute the actual refund transaction. + + Now implements multi-output transactions: + - Customer gets refund minus fee + - Shop owner gets the fee portion Args: refund_details: dict with refund information - payment: CryptoPayment object (needed for account_index) + payment: CryptoPayment object (needed for account_index and shop_sweep_to_address) Returns: dict with transaction details or raises exception @@ -171,6 +176,9 @@ class PaymentRescue: logger = logging.getLogger(__name__) refund_amount_coin = refund_details["refund_amount"] + fee_amount_coin = refund_details["fee_amount"] + # For overpayments, also include the actual payment amount + payment_amount_coin = refund_details.get("payment_amount", Decimal("0")) # Get coin type and atomic units for proper logging coin_type = payment.coin_type if payment else "XMR" @@ -179,6 +187,8 @@ class PaymentRescue: coin_config = get_coin_config(coin_type) atomic_units = int(coin_config["atomic_units"]) refund_amount_atomic = int(refund_amount_coin * atomic_units) + fee_amount_atomic = int(fee_amount_coin * atomic_units) + payment_amount_atomic = int(payment_amount_coin * atomic_units) atomic_unit_name = ( "piconero" @@ -186,11 +196,22 @@ class PaymentRescue: else "koinu" if coin_type == "DOGE" else "atomic units" ) + # Get shop sweep address - we'll handle missing address with a clear error + shop_sweep_address = payment.shop_sweep_to_address if payment else None + if payment: logger.info(f"Attempting to refund: {payment}") logger.info( f"Refund amount: {refund_amount_coin} {coin_type} ({refund_amount_atomic} {atomic_unit_name})" ) + logger.info( + f"Fee amount to shop: {fee_amount_coin} {coin_type} ({fee_amount_atomic} {atomic_unit_name})" + ) + if payment_amount_coin > 0: + logger.info( + f"Payment amount to shop: {payment_amount_coin} {coin_type} ({payment_amount_atomic} {atomic_unit_name})" + ) + logger.info(f"Shop sweep address: {shop_sweep_address}") logger.info(f"Refund address: {refund_details['refund_address']}") logger.info(f"Refund reason: {refund_details['reason']}") else: @@ -242,6 +263,7 @@ class PaymentRescue: logger.info("Checking Dogecoin wallet balance") balance_result = self.crypto_client.getbalance() logger.info(f"Dogecoin wallet balance: {balance_result} DOGE") + if balance_result < refund_amount_coin: logger.warning( @@ -253,14 +275,43 @@ class PaymentRescue: try: # Create coin-specific refund transaction if coin_type == "XMR": - # Monero uses the transfer RPC with atomic units (piconero) + # Monero always uses multi-output transfer + if not shop_sweep_address: + raise ValueError("Shop sweep address is required for refunds") + + # Build destinations array + destinations = [ + { + "address": refund_details["refund_address"], + "amount": refund_amount_atomic, + } + ] + + # For overpayments, combine payment amount and fee into single shop output + shop_amount_atomic = fee_amount_atomic + if payment_amount_atomic > 0: + shop_amount_atomic += payment_amount_atomic + + destinations.append({ + "address": shop_sweep_address, + "amount": shop_amount_atomic, + }) + + if payment_amount_atomic > 0: + logger.info( + f"Monero multi-output transfer: " + f"{refund_amount_atomic} piconero to customer, " + f"{shop_amount_atomic} piconero to shop (payment: {payment_amount_atomic} + fee: {fee_amount_atomic})" + ) + else: + logger.info( + f"Monero multi-output transfer: " + f"{refund_amount_atomic} piconero to customer, " + f"{fee_amount_atomic} piconero to shop" + ) + transfer_params = { - "destinations": [ - { - "address": refund_details["refund_address"], - "amount": refund_amount_atomic, - } - ], + "destinations": destinations, "account_index": account_index, "get_tx_key": True, "do_not_relay": False, @@ -270,23 +321,148 @@ class PaymentRescue: tx_result = self.crypto_client._call("transfer", transfer_params) elif coin_type == "DOGE": - # Dogecoin uses sendtoaddress RPC with coin amounts (not atomic units) + # Dogecoin always uses sendmany for multi-output + if not shop_sweep_address: + raise ValueError("Shop sweep address is required for refunds") + # Round to 8 decimal places to match DOGE precision requirements refund_amount_doge = round(float(refund_amount_coin), 8) - logger.info( - f"Calling Dogecoin sendtoaddress: {refund_amount_doge} DOGE to {refund_details['refund_address']}" - ) - logger.info( - f"Dogecoin client config - URL: {self.crypto_client.rpc_url}, User: {self.crypto_client.rpc_user}" - ) - logger.info( - f"About to call sendtoaddress with params: address={refund_details['refund_address']}, amount={refund_amount_doge}" - ) - tx_hash = self.crypto_client.sendtoaddress( - refund_details["refund_address"], - refund_amount_doge, - f"Overpayment refund for {refund_details['payment_id']}", - ) + fee_amount_doge = round(float(fee_amount_coin), 8) + payment_amount_doge = round(float(payment_amount_coin), 8) if payment_amount_coin > 0 else 0 + + # Initial outputs - will be adjusted below if needed + outputs = {} + + # For overpayments, combine payment amount and fee into single shop output + shop_amount_doge = fee_amount_doge + if payment_amount_doge > 0: + shop_amount_doge = round(payment_amount_doge + fee_amount_doge, 8) + + # Account for network fee by reducing amounts proportionally + # Dogecoin sendmany adds fee on top of outputs, so we need to leave room + total_output = refund_amount_doge + shop_amount_doge + # Use conservative fee estimate to ensure we have enough room + # This accounts for fee calculation differences and rounding + estimated_fee = 0.01 # Very conservative to avoid insufficient funds errors + + # Check if we need to adjust for fees + if total_output + estimated_fee > balance_result: + # Calculate how much we need to reduce + shortage = (total_output + estimated_fee) - balance_result + logger.info(f"Need to reduce outputs by {shortage} DOGE to account for network fee") + + # Reduce both amounts proportionally + refund_ratio = refund_amount_doge / total_output + shop_ratio = shop_amount_doge / total_output + + # Reduce and round conservatively (round down to avoid exceeding balance) + import math + refund_reduction = shortage * refund_ratio + shop_reduction = shortage * shop_ratio + + # Round down to 3 decimal places for cleaner amounts + refund_amount_doge = math.floor((refund_amount_doge - refund_reduction) * 1000) / 1000 + shop_amount_doge = math.floor((shop_amount_doge - shop_reduction) * 1000) / 1000 + + logger.info(f"Adjusted amounts - Customer: {refund_amount_doge} DOGE, Shop: {shop_amount_doge} DOGE") + + # Build final outputs with adjusted amounts + outputs[refund_details["refund_address"]] = refund_amount_doge + outputs[shop_sweep_address] = shop_amount_doge + + if payment_amount_doge > 0: + logger.info( + f"Calling Dogecoin sendmany with multi-output: " + f"{refund_amount_doge} DOGE to customer, " + f"{shop_amount_doge} DOGE to shop (payment: {payment_amount_doge} + fee: {fee_amount_doge})" + ) + else: + logger.info( + f"Calling Dogecoin sendmany with multi-output: " + f"{refund_amount_doge} DOGE to customer, " + f"{fee_amount_doge} DOGE to shop" + ) + + # First check if we have enough balance including fees + # Already accounted for in shop_amount_doge adjustment above + total_needed = refund_amount_doge + shop_amount_doge + + if balance_result < total_needed: + logger.error( + f"Insufficient balance for refund: " + f"have {balance_result} DOGE, need {total_needed} DOGE" + ) + raise ValueError(f"Insufficient balance: have {balance_result}, need {total_needed}") + + # Get the account label for the payment address + # First, try to get account from the address + from_account = "" + try: + # Try newer method first (getaddressinfo) + addr_info = self.crypto_client._call("getaddressinfo", [payment.address]) + if addr_info and "labels" in addr_info and addr_info["labels"]: + from_account = addr_info["labels"][0] + logger.info(f"Using account label from getaddressinfo: {from_account}") + except Exception as e: + # Fall back to older method (getaccount) + try: + from_account = self.crypto_client._call("getaccount", [payment.address]) + if from_account: + logger.info(f"Using account from getaccount: {from_account}") + except Exception as e2: + # Last resort: try to find in unspent outputs + try: + unspent = self.crypto_client._call("listunspent", []) + for utxo in unspent: + if utxo.get("address") == payment.address: + from_account = utxo.get("account", "") + if from_account: + logger.info(f"Using account from unspent output: {from_account}") + break + except Exception as e3: + logger.warning(f"Could not get account for address: {e}, {e2}, {e3}") + from_account = "" + + # sendmany(fromaccount, {address:amount,...}, minconf=1) + # Don't include comment - it causes issues with many Dogecoin versions + + # Ensure outputs are properly formatted - sometimes decimal precision issues cause 500 errors + # Convert outputs to ensure they're plain floats, not Decimal objects + clean_outputs = {} + for addr, amount in outputs.items(): + clean_outputs[addr] = float(amount) + + # If account has special characters, try different approaches + logger.info(f"Attempting sendmany with account='{from_account}', outputs={clean_outputs}") + + # Try different sendmany approaches until one works + tx_hash = None + attempts = [ + # 1. Try with the actual account first (where the funds are) + ("actual account", lambda: self.crypto_client.sendmany(from_account, clean_outputs, 1)), + # 2. Try with empty account (default) + ("empty account", lambda: self.crypto_client.sendmany("", clean_outputs, 1)), + # 3. Try actual account without minconf + ("actual account without minconf", lambda: self.crypto_client.sendmany(from_account, clean_outputs)), + # 4. Try with explicit default account "*" + ("default account '*'", lambda: self.crypto_client.sendmany("*", clean_outputs, 1)), + ] + + last_error = None + for attempt_name, attempt_func in attempts: + try: + logger.info(f"Trying sendmany with {attempt_name}") + tx_hash = attempt_func() + logger.info(f"Success with {attempt_name}: {tx_hash}") + break + except Exception as e: + last_error = e + logger.warning(f"Failed with {attempt_name}: {e}") + continue + + if not tx_hash and last_error: + raise last_error + tx_result = {"tx_hash": tx_hash} else: diff --git a/make_post_sell/models/invoice.py b/make_post_sell/models/invoice.py index e980eb1..eb499d0 100644 --- a/make_post_sell/models/invoice.py +++ b/make_post_sell/models/invoice.py @@ -288,7 +288,7 @@ class Invoice(RBase, Base): @property def is_paid(self): """Check if this invoice has been successfully paid.""" - return self.payment_status in ["confirmed", "confirmed_overpaid", "paid"] + return self.payment_status in ["confirmed", "confirmed-overpay", "confirmed-complete", "paid"] def get_invoice_by_id(dbsession, invoice_id): @@ -354,7 +354,7 @@ def delete_invoice_by_id(dbsession, invoice_id): } # Guard: Don't delete successful payments - those are legitimate transactions - successful_statuses = ["confirmed", "confirmed-overpaid"] + successful_statuses = ["confirmed", "confirmed-overpay", "confirmed-complete"] if crypto_payment.status in successful_statuses: return { "success": False, diff --git a/make_post_sell/templates/crypto_checkout.j2 b/make_post_sell/templates/crypto_checkout.j2 index 7d46777..fceba94 100644 --- a/make_post_sell/templates/crypto_checkout.j2 +++ b/make_post_sell/templates/crypto_checkout.j2 @@ -58,7 +58,7 @@

Total Amount: {{ '%.12f' % amount_crypto }} {{ coin_symbol }}

Expected {{ smallest_unit_name }}: {{ '{:,}'.format(expected_smallest_units) }}

Payment ID: {{ payment_id }}

- {% if status in ['confirmed', 'confirmed-overpaid', 'confirmed-overpaid-refunded'] and has_invoice and invoice_id %} + {% if status in ['confirmed', 'confirmed-complete', 'confirmed-overpay', 'confirmed-overpay-refunded', 'confirmed-overpay-refunded-complete'] and has_invoice and invoice_id %}

Invoice ID: {{ invoice_id }}

{% endif %} {% else %} @@ -67,7 +67,7 @@

Total Amount: {{ '%.8f' % amount_crypto }} {{ coin_symbol }}

Expected {{ smallest_unit_name }}: {{ '{:,}'.format(expected_smallest_units) }}

Payment ID: {{ payment_id }}

- {% if status in ['confirmed', 'confirmed-overpaid', 'confirmed-overpaid-refunded'] and has_invoice and invoice_id %} + {% if status in ['confirmed', 'confirmed-complete', 'confirmed-overpay', 'confirmed-overpay-refunded', 'confirmed-overpay-refunded-complete'] and has_invoice and invoice_id %}

Invoice ID: {{ invoice_id }}

{% endif %} {% endif %} @@ -191,7 +191,7 @@ const currentStatus = statusEl ? statusEl.textContent.trim() : ''; // Use smart redirect URL for confirmed states, crypto-quotes for terminal states - if (currentStatus === 'confirmed' || currentStatus === 'confirmed-overpaid') { + if (currentStatus === 'confirmed' || currentStatus === 'confirmed-complete' || currentStatus === 'confirmed-overpay') { invoiceLink.href = redirectUrl || '/u/purchases'; // Determine button text based on redirect URL if (redirectUrl && redirectUrl.includes('/p/')) { @@ -250,7 +250,7 @@ if (expiresEl) { expiresEl.parentNode.style.display = 'none'; } - } else if (lastKnownStatus && (lastKnownStatus === 'confirmed' || lastKnownStatus === 'confirmed-overpaid')) { + } else if (lastKnownStatus && (lastKnownStatus === 'confirmed' || lastKnownStatus === 'confirmed-complete' || lastKnownStatus === 'confirmed-overpay')) { // Payment confirmed - replace with invoice link replaceButtonsWithInvoiceLink(); @@ -377,7 +377,7 @@ console.log(`Status changed from ${lastKnownStatus} to ${currentStatus}`); // Show confirmations section when payment is received or later - if (currentStatus === 'received' || currentStatus === 'confirmed' || currentStatus === 'confirmed-overpaid') { + if (currentStatus === 'received' || currentStatus === 'confirmed' || currentStatus === 'confirmed-complete' || currentStatus === 'confirmed-overpay') { if (confirmationsEl) { confirmationsEl.style.display = 'block'; } @@ -399,7 +399,7 @@ if (expiresEl && expiresEl.parentNode) { expiresEl.parentNode.style.display = 'none'; } - } else if (currentStatus === 'confirmed' || currentStatus === 'confirmed-overpaid') { + } else if (currentStatus === 'confirmed' || currentStatus === 'confirmed-complete' || currentStatus === 'confirmed-overpay') { // Payment confirmed - replace with smart redirect link replaceButtonsWithInvoiceLink(data.redirect_url); @@ -410,7 +410,7 @@ // Redirect only if status changed from non-confirmed to confirmed (via polling) // Don't redirect if user browsed directly to already-confirmed quote - if (lastKnownStatus && lastKnownStatus !== 'confirmed' && lastKnownStatus !== 'confirmed-overpaid') { + if (lastKnownStatus && lastKnownStatus !== 'confirmed' && lastKnownStatus !== 'confirmed-complete' && lastKnownStatus !== 'confirmed-overpay') { setTimeout(() => { // Use smart redirect URL if available, otherwise fallback to purchases page window.location.href = data.redirect_url || '/u/purchases'; @@ -470,7 +470,7 @@ // Stop polling if we've reached a terminal status or confirmed status const currentStatus = statusEl ? statusEl.textContent.trim() : ''; - if (terminalStatuses.includes(currentStatus) || currentStatus === 'confirmed') { + if (terminalStatuses.includes(currentStatus) || currentStatus === 'confirmed' || currentStatus === 'confirmed-complete') { if (pollInterval) { clearInterval(pollInterval); pollInterval = null; diff --git a/make_post_sell/templates/crypto_quotes_history.j2 b/make_post_sell/templates/crypto_quotes_history.j2 index 4557baa..118f495 100644 --- a/make_post_sell/templates/crypto_quotes_history.j2 +++ b/make_post_sell/templates/crypto_quotes_history.j2 @@ -58,7 +58,7 @@ {% endif %}
- {% if payment.status == 'confirmed' or payment.status == 'confirmed-overpaid' or payment.status == 'confirmed-overpaid-refunded' %} + {% if payment.status == 'confirmed' or payment.status == 'confirmed-complete' or payment.status == 'confirmed-overpay' or payment.status == 'confirmed-overpay-refunded' or payment.status == 'confirmed-overpay-refunded-complete' %} {% if payment.has_invoice %} View Invoice diff --git a/make_post_sell/tests/test_crypto_watcher.py b/make_post_sell/tests/test_crypto_watcher.py index 7557056..23f5509 100644 --- a/make_post_sell/tests/test_crypto_watcher.py +++ b/make_post_sell/tests/test_crypto_watcher.py @@ -1154,8 +1154,8 @@ class AutoSweepTests(unittest.TestCase): # Check sweep tracking fields were set self.assertEqual( - payment.swept_amount, 490000000000 - ) # 0.5 XMR - 0.01 XMR fee = 0.49 XMR + payment.swept_amount, 489000000000 + ) # 0.5 XMR - (0.01 XMR fee * 1.1 margin) self.assertEqual(payment.swept_tx_hash, "transfer_tx_123") self.assertIsNotNone(payment.swept_timestamp) @@ -1230,7 +1230,7 @@ class AutoSweepTests(unittest.TestCase): # Verify that swept_tx_hash was set (from transfer, not sweep) self.assertEqual(payment.swept_tx_hash, "transfer_tx_123") - self.assertEqual(payment.swept_amount, 495000000000) # 0.5 XMR - 0.005 XMR fee + self.assertEqual(payment.swept_amount, 494500000000) # 0.5 XMR - (0.005 XMR fee * 1.1 margin) self.assertEqual(payment.swept_network_fee, 5000000000) self.assertIsNotNone(payment.swept_timestamp) @@ -3101,8 +3101,8 @@ class RefundTypeTests(unittest.TestCase): # 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.""" + def test_doge_refund_execution_uses_sendmany_rpc(self): + """Test DOGE refund execution uses sendmany RPC for multi-output.""" from make_post_sell.lib.crypto_watcher.crypto_payment_rescue import ( PaymentRescue, ) @@ -3113,9 +3113,10 @@ class RefundTypeTests(unittest.TestCase): doge_payment.coin_type = "DOGE" doge_payment.current_confirmations = 2 # Sufficient confirmations doge_payment.account_index = None # DOGE doesn't use account_index + doge_payment.shop_sweep_to_address = "DShopSweepAddress789" # Shop address for fee mock_client = MagicMock() - mock_client.sendtoaddress.return_value = "doge-tx-hash-123" + mock_client._call.return_value = "doge-tx-hash-123" mock_client.getbalance.return_value = 100.0 # Sufficient DOGE balance rescue = PaymentRescue(self.mock_dbsession, mock_client) @@ -3131,19 +3132,22 @@ class RefundTypeTests(unittest.TestCase): result = rescue.execute_refund(refund_details, doge_payment) - # Verify DOGE-specific RPC was called - # mock_client.sendtoaddress.assert_called_once_with("DTestAddress123", 4.55, "Overpayment refund for doge-payment-123") - mock_client.sendtoaddress.assert_called_once_with( - "DTestAddress123", 4.55, "Overpayment refund for doge-payment-123" - ) + # Verify sendmany was called with multi-output + mock_client._call.assert_called_once() + call_args = mock_client._call.call_args[0] + self.assertEqual(call_args[0], "sendmany") + self.assertEqual(call_args[1][0], "") # fromaccount + outputs = call_args[1][1] + self.assertEqual(outputs["DTestAddress123"], 4.55) # Customer refund + self.assertEqual(outputs["DShopSweepAddress789"], 0.45) # Shop fee 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.""" + def test_xmr_refund_execution_uses_multi_output_transfer(self): + """Test XMR refund execution uses transfer RPC with multi-output.""" from make_post_sell.lib.crypto_watcher.crypto_payment_rescue import ( PaymentRescue, ) @@ -3154,6 +3158,7 @@ class RefundTypeTests(unittest.TestCase): xmr_payment.coin_type = "XMR" xmr_payment.current_confirmations = 10 # Sufficient confirmations xmr_payment.account_index = 5 + xmr_payment.shop_sweep_to_address = "4ShopSweepAddressXMR789" # Shop address for fee mock_client = MagicMock() mock_client._call.side_effect = [ @@ -3186,11 +3191,18 @@ class RefundTypeTests(unittest.TestCase): self.assertEqual(expected_transfer_call[0][0], "transfer") transfer_params = expected_transfer_call[0][1] self.assertEqual(transfer_params["account_index"], 5) + self.assertEqual(len(transfer_params["destinations"]), 2) # Two destinations 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) + # Shop fee destination + self.assertEqual( + transfer_params["destinations"][1]["address"], "4ShopSweepAddressXMR789" + ) + # 0.18 XMR = 0.18 * 10^12 = 180,000,000,000 piconero + self.assertEqual(transfer_params["destinations"][1]["amount"], 180000000000) # Verify successful result self.assertTrue(result["success"]) @@ -3267,6 +3279,7 @@ class RefundTypeTests(unittest.TestCase): unsupported_payment.id = "unsupported-payment" unsupported_payment.coin_type = "UNSUPPORTED" unsupported_payment.current_confirmations = 10 + unsupported_payment.shop_sweep_to_address = "UNSUPPORTEDshopAddress" mock_client = MagicMock() rescue = PaymentRescue(self.mock_dbsession, mock_client) @@ -3274,7 +3287,8 @@ class RefundTypeTests(unittest.TestCase): refund_details = { "payment_id": "unsupported-payment", "refund_address": "unsupported-address", - "refund_amount": Decimal("1.0"), + "refund_amount": Decimal("0.91"), + "fee_amount": Decimal("0.09"), "reason": "Test unsupported coin", } @@ -3296,9 +3310,10 @@ class RefundTypeTests(unittest.TestCase): doge_payment.coin_type = "DOGE" doge_payment.current_confirmations = 2 doge_payment.account_index = None + doge_payment.shop_sweep_to_address = "DShopPrecisionAddress" mock_client = MagicMock() - mock_client.sendtoaddress.return_value = "doge-precision-tx-123" + mock_client._call.return_value = "doge-precision-tx-123" mock_client.getbalance.return_value = 100.0 rescue = PaymentRescue(self.mock_dbsession, mock_client) @@ -3317,14 +3332,18 @@ class RefundTypeTests(unittest.TestCase): # Should succeed self.assertTrue(result["success"]) + # Verify successful result + self.assertTrue(result["success"]) self.assertEqual(result["tx_hash"], "doge-precision-tx-123") - - # Verify sendtoaddress was called with rounded amount (8 decimal places) - mock_client.sendtoaddress.assert_called_once_with( - "DPrecisionTest123", - 1.03859035, # Rounded to 8 decimal places - "Overpayment refund for doge-precision-123", - ) + + # Verify sendmany was called with properly rounded amounts + mock_client._call.assert_called_once() + call_args = mock_client._call.call_args[0] + self.assertEqual(call_args[0], "sendmany") + outputs = call_args[1][1] + # Verify amounts are rounded to 8 decimal places + self.assertEqual(outputs["DPrecisionTest123"], 1.03859035) # Rounded from 1.0385903528 + self.assertEqual(outputs["DShopPrecisionAddress"], 0.11429455) # Rounded from 0.1142945472 def test_xmr_refund_amount_precision_handling(self): """Test XMR refund execution handles high precision amounts correctly.""" @@ -3794,8 +3813,8 @@ class PaymentConfirmationOrderTests(unittest.TestCase): ) transfer_params = real_transfer_calls[0][0][1] - # The amount should be reduced by fee: 2500000000000 - 100000000 = 2499900000000 - expected_amount_after_fee = 2500000000000 - 100000000 + # The amount should be reduced by fee with 10% margin: 2500000000000 - (100000000 * 1.1) = 2499890000000 + expected_amount_after_fee = 2500000000000 - 110000000 self.assertEqual( transfer_params["destinations"][0]["amount"], expected_amount_after_fee ) @@ -3885,8 +3904,8 @@ class PaymentConfirmationOrderTests(unittest.TestCase): ) transfer_params = real_transfer_calls[0][0][1] - # Amount should be reduced by fee: 2500000000000 - 100000000 = 2499900000000 - expected_amount_after_fee = 2500000000000 - 100000000 + # Amount should be reduced by fee with 10% margin: 2500000000000 - (100000000 * 1.1) = 2499890000000 + expected_amount_after_fee = 2500000000000 - 110000000 self.assertEqual( transfer_params["destinations"][0]["amount"], expected_amount_after_fee ) @@ -4005,8 +4024,8 @@ class PaymentConfirmationOrderTests(unittest.TestCase): ) transfer_params = real_transfer_calls[0][0][1] - # The amount should be expected amount minus fee: 2000000000000 - 100000000 = 1999900000000 - expected_amount_after_fee = 2000000000000 - 100000000 + # The amount should be expected amount minus fee with 10% margin: 2000000000000 - (100000000 * 1.1) = 1999890000000 + expected_amount_after_fee = 2000000000000 - 110000000 self.assertEqual( transfer_params["destinations"][0]["amount"], expected_amount_after_fee ) @@ -4177,8 +4196,8 @@ class PaymentConfirmationOrderTests(unittest.TestCase): ) transfer_params = real_transfer_calls[0][0][1] - # The amount should be reduced by fee: 987654321000 - 100000000 = 987554321000 - expected_amount_after_fee = 987654321000 - 100000000 + # The amount should be reduced by fee with 10% margin: 987654321000 - (100000000 * 1.1) = 987544321000 + expected_amount_after_fee = 987654321000 - 110000000 self.assertEqual( transfer_params["destinations"][0]["amount"], expected_amount_after_fee ) diff --git a/make_post_sell/tests/test_multi_output_refunds.py b/make_post_sell/tests/test_multi_output_refunds.py new file mode 100644 index 0000000..0a0debd --- /dev/null +++ b/make_post_sell/tests/test_multi_output_refunds.py @@ -0,0 +1,226 @@ +""" +Tests for multi-output refunds functionality. + +Tests that refunds can be sent to multiple addresses in a single transaction: +- Customer gets refund minus fee +- Shop owner gets the fee portion +""" + +import unittest +from unittest.mock import MagicMock, patch +from decimal import Decimal +import uuid + +from ..lib.crypto_watcher.crypto_payment_rescue import PaymentRescue, RESTOCKING_FEE_PERCENT +from ..models.crypto_payment import CryptoPayment + + +class TestMultiOutputRefunds(unittest.TestCase): + """Test multi-output refund functionality.""" + + def setUp(self): + """Set up test fixtures.""" + self.mock_dbsession = MagicMock() + self.mock_client = MagicMock() + self.rescue = PaymentRescue(self.mock_dbsession, self.mock_client) + + # Create a mock payment with shop sweep address + self.payment = MagicMock(spec=CryptoPayment) + self.payment.id = uuid.uuid4() + self.payment.coin_type = "DOGE" + self.payment.account_index = 0 + self.payment.current_confirmations = 10 + self.payment.shop_sweep_to_address = "DShopSweepAddressTest123" + self.payment.__str__.return_value = f"Payment {self.payment.id}" + + def test_doge_multi_output_refund(self): + """Test DOGE refund with multi-output (customer + shop).""" + # Set up refund details + received_amount = Decimal("100.0") # 100 DOGE received + fee_amount = received_amount * RESTOCKING_FEE_PERCENT # 9 DOGE fee + refund_amount = received_amount - fee_amount # 91 DOGE refund + + refund_details = { + "type": "overpayment", + "payment_id": self.payment.id, + "refund_address": "DCustomerRefundAddress123", + "received_amount": received_amount, + "refund_amount": refund_amount, + "fee_amount": fee_amount, + "reason": "Test overpayment refund" + } + + # Mock the sendmany call + self.mock_client._call.return_value = "test_tx_hash_123" + + # Execute refund + result = self.rescue.execute_refund(refund_details, self.payment) + + # Verify multi-output sendmany was called + self.mock_client._call.assert_called_once() + call_args = self.mock_client._call.call_args[0] + + self.assertEqual(call_args[0], "sendmany") + self.assertEqual(call_args[1][0], "") # fromaccount + + # Check outputs + outputs = call_args[1][1] + self.assertEqual(len(outputs), 2) # Two outputs + self.assertAlmostEqual(outputs["DCustomerRefundAddress123"], 91.0, places=8) + self.assertAlmostEqual(outputs["DShopSweepAddressTest123"], 9.0, places=8) + + # Verify result + self.assertTrue(result["success"]) + self.assertEqual(result["tx_hash"], "test_tx_hash_123") + self.assertEqual(result["fee_charged"], fee_amount) + + def test_xmr_multi_output_refund(self): + """Test XMR refund with multi-output (customer + shop).""" + # Set up payment for XMR + self.payment.coin_type = "XMR" + + # Set up refund details + received_amount = Decimal("1.0") # 1 XMR received + fee_amount = received_amount * RESTOCKING_FEE_PERCENT # 0.09 XMR fee + refund_amount = received_amount - fee_amount # 0.91 XMR refund + + refund_details = { + "type": "overpayment", + "payment_id": self.payment.id, + "refund_address": "4CustomerRefundAddressXMR123", + "received_amount": received_amount, + "refund_amount": refund_amount, + "fee_amount": fee_amount, + "reason": "Test overpayment refund" + } + + # Mock the RPC calls + self.mock_client._call.side_effect = [ + # get_balance call + {"balance": 2000000000000, "unlocked_balance": 2000000000000}, + # transfer call + {"tx_hash": "xmr_test_tx_hash_456"} + ] + + # Execute refund + result = self.rescue.execute_refund(refund_details, self.payment) + + # Find the transfer call + transfer_call = None + for call in self.mock_client._call.call_args_list: + if call[0][0] == "transfer": + transfer_call = call + break + + self.assertIsNotNone(transfer_call) + transfer_params = transfer_call[0][1] + + # Check destinations + destinations = transfer_params["destinations"] + self.assertEqual(len(destinations), 2) # Two destinations + + # Customer refund destination + self.assertEqual(destinations[0]["address"], "4CustomerRefundAddressXMR123") + self.assertEqual(destinations[0]["amount"], 910000000000) # 0.91 XMR in piconero + + # Shop fee destination + self.assertEqual(destinations[1]["address"], "DShopSweepAddressTest123") + self.assertEqual(destinations[1]["amount"], 90000000000) # 0.09 XMR in piconero + + # Verify result + self.assertTrue(result["success"]) + self.assertEqual(result["tx_hash"], "xmr_test_tx_hash_456") + self.assertEqual(result["fee_charged"], fee_amount) + + def test_refund_no_shop_address_fails(self): + """Test refund fails when shop has no sweep address.""" + # Remove shop sweep address + self.payment.shop_sweep_to_address = None + + # Set up refund details + received_amount = Decimal("100.0") + fee_amount = received_amount * RESTOCKING_FEE_PERCENT + refund_amount = received_amount - fee_amount + + refund_details = { + "type": "overpayment", + "payment_id": self.payment.id, + "refund_address": "DCustomerRefundAddress123", + "received_amount": received_amount, + "refund_amount": refund_amount, + "fee_amount": fee_amount, + "reason": "Test overpayment refund" + } + + # Execute refund + result = self.rescue.execute_refund(refund_details, self.payment) + + # Verify refund failed + self.assertFalse(result["success"]) + self.assertIn("Shop sweep address is required", result["error"]) + + # Verify no transaction was attempted + self.mock_client._call.assert_not_called() + self.mock_client.sendtoaddress.assert_not_called() + + def test_refund_zero_fee_still_multi_output(self): + """Test multi-output is used even when fee is zero.""" + # Set up refund with zero fee (shouldn't happen but test edge case) + refund_details = { + "type": "overpayment", + "payment_id": self.payment.id, + "refund_address": "DCustomerRefundAddress123", + "received_amount": Decimal("100.0"), + "refund_amount": Decimal("100.0"), + "fee_amount": Decimal("0"), + "reason": "Test refund with zero fee" + } + + # Mock the sendmany call + self.mock_client._call.return_value = "zero_fee_tx_123" + + # Execute refund + result = self.rescue.execute_refund(refund_details, self.payment) + + # Verify multi-output sendmany was still used + self.mock_client._call.assert_called_once() + call_args = self.mock_client._call.call_args[0] + self.assertEqual(call_args[0], "sendmany") + outputs = call_args[1][1] + self.assertEqual(outputs["DCustomerRefundAddress123"], 100.0) # Full refund + self.assertEqual(outputs["DShopSweepAddressTest123"], 0.0) # Zero fee + + # Verify result + self.assertTrue(result["success"]) + self.assertEqual(result["tx_hash"], "zero_fee_tx_123") + + def test_insufficient_confirmations(self): + """Test refund is delayed when insufficient confirmations.""" + # Set insufficient confirmations + self.payment.current_confirmations = 1 + + refund_details = { + "type": "overpayment", + "payment_id": self.payment.id, + "refund_address": "DCustomerRefundAddress123", + "received_amount": Decimal("100.0"), + "refund_amount": Decimal("91.0"), + "fee_amount": Decimal("9.0"), + "reason": "Test refund" + } + + # Execute refund + result = self.rescue.execute_refund(refund_details, self.payment) + + # Verify refund was delayed + self.assertFalse(result["success"]) + self.assertIn("confirmations", result["error"]) + self.assertEqual(result["confirmations_needed"], 1) # Need 1 more confirmation + + # Verify no transaction was attempted + self.mock_client._call.assert_not_called() + self.mock_client.sendtoaddress.assert_not_called() + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/make_post_sell/views/crypto.py b/make_post_sell/views/crypto.py index b3c7ed0..7ef1d52 100644 --- a/make_post_sell/views/crypto.py +++ b/make_post_sell/views/crypto.py @@ -836,7 +836,7 @@ def crypto_xmr_status(request): ) else: request.session.flash(("Purchase completed!", "success")) - elif current_status == "confirmed-overpaid": + elif current_status == "confirmed-overpay": request.session.flash( ("Purchase completed! Overpayment will be refunded.", "success") ) @@ -872,7 +872,7 @@ def crypto_xmr_status(request): # Add smart redirect URL when payment is confirmed (any confirmed status) if ( - crypto_payment.status in ["confirmed", "confirmed-overpaid"] + crypto_payment.status in ["confirmed", "confirmed-complete", "confirmed-overpay"] and crypto_payment.invoice ): from ..views.cart import get_smart_purchase_redirect_url @@ -932,7 +932,7 @@ def crypto_doge_status(request): ) else: request.session.flash(("Purchase completed!", "success")) - elif current_status == "confirmed-overpaid": + elif current_status == "confirmed-overpay": request.session.flash( ("Purchase completed! Overpayment will be refunded.", "success") ) @@ -971,7 +971,7 @@ def crypto_doge_status(request): # Add smart redirect URL when payment is confirmed (any confirmed status) if ( - crypto_payment.status in ["confirmed", "confirmed-overpaid"] + crypto_payment.status in ["confirmed", "confirmed-complete", "confirmed-overpay"] and crypto_payment.invoice ): from ..views.cart import get_smart_purchase_redirect_url @@ -1205,11 +1205,13 @@ def get_payment_status_info(status): "pending": {"label": "Pending Payment", "color": "#ffc107"}, "received": {"label": "Payment Received", "color": "#17a2b8"}, "confirmed": {"label": "✓ Confirmed", "color": "#28a745"}, - "confirmed-overpaid": {"label": "✓ Confirmed (Overpaid)", "color": "#28a745"}, + "confirmed-complete": {"label": "✓ Confirmed (Complete)", "color": "#28a745"}, + "confirmed-overpay": {"label": "✓ Confirmed (Overpaid)", "color": "#28a745"}, "expired": {"label": "Expired", "color": "#6c757d"}, - "expired-refunded": {"label": "Expired - Refunded", "color": "#fd7e14"}, - "expired-refunded-complete": { - "label": "✓ Expired - Refunded", + "expired": {"label": "Expired", "color": "#6c757d"}, + "latepay-refunded": {"label": "Late Payment - Refunded", "color": "#fd7e14"}, + "latepay-refunded-complete": { + "label": "✓ Late Payment - Refunded", "color": "#fd7e14", }, "underpaid-refunded": {"label": "Underpaid - Refunded", "color": "#fd7e14"}, @@ -1217,7 +1219,11 @@ def get_payment_status_info(status): "label": "✓ Underpaid - Refunded", "color": "#fd7e14", }, - "confirmed-overpaid-refunded": { + "confirmed-overpay-refunded": { + "label": "✓ Overpaid - Refund Sent", + "color": "#28a745", + }, + "confirmed-overpay-refunded-complete": { "label": "✓ Overpaid - Refunded", "color": "#28a745", }, @@ -1230,16 +1236,19 @@ def get_payment_status_info(status): "label": "✓ Out of Stock - Refunded", "color": "#fd7e14", }, - "doublepay-refund": { + "doublepay-refunded": { "label": "Duplicate Payment - Refunding", "color": "#fd7e14", }, - "doublepay-refund-complete": { + "doublepay-refunded-complete": { "label": "✓ Duplicate Payment - Refunded", "color": "#fd7e14", }, - "no-refund": {"label": "No Refund Possible", "color": "#dc3545"}, - "no-refund-complete": {"label": "✓ No Refund Possible", "color": "#dc3545"}, + "latepay-not-refunded": {"label": "Late Payment - No Refund", "color": "#dc3545"}, + "underpaid-not-refunded": {"label": "Underpaid - No Refund", "color": "#dc3545"}, + "confirmed-overpay-not-refunded": {"label": "Overpaid - No Refund", "color": "#dc3545"}, + "out-of-stock-not-refunded": {"label": "Out of Stock - No Refund", "color": "#dc3545"}, + "doublepay-not-refunded": {"label": "Duplicate Payment - No Refund", "color": "#dc3545"}, } return status_mapping.get(status, {"label": status.title(), "color": "#6c757d"})