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"})