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..680c420 100644 --- a/make_post_sell/lib/crypto_watcher/__init__.py +++ b/make_post_sell/lib/crypto_watcher/__init__.py @@ -28,6 +28,13 @@ from ...models.user_crypto_refund_address import UserCryptoRefundAddress logger = logging.getLogger(__name__) +# Global confirmation requirements for outbound transactions (sweeps and refunds) +# These apply AFTER the payment is already confirmed incoming +OUTBOUND_CONFIRMATIONS_REQUIRED = { + "XMR": 10, # Monero: 10 confirmations for both sweeps and refunds + "DOGE": 2, # Dogecoin: 2 confirmations for both sweeps and refunds +} + class CryptoWatcherLogger: """Centralized logging helper for crypto watcher operations.""" @@ -542,8 +549,15 @@ def sweep_restocking_fee(settings, payment, refund_details, dbsession, context=" amount_to_send_crypto = float( Decimal(amount_to_send_atomic) / atomic_units ) - fee_tx_hash = client.sendtoaddress( - payment.shop_sweep_to_address, amount_to_send_crypto + fee_tx_hash = client._call( + "sendtoaddress", + [ + payment.shop_sweep_to_address, + amount_to_send_crypto, + "", # comment + "", # comment_to + True, # subtractfeefromamount + ], ) actual_swept = amount_to_send_atomic log.sweep_operation( @@ -864,14 +878,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 +897,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 @@ -940,8 +962,9 @@ def auto_sweep_payment_xmr(client, crypto_payment: CryptoPayment, dbsession=None crypto_payment.swept_tx_hash = tx_hash crypto_payment.swept_timestamp = now_timestamp() crypto_payment.swept_network_fee = fee - # Update status to confirmed-complete after successful sweep - crypto_payment.status = CryptoPayment.STATUS_CONFIRMED_COMPLETE + crypto_payment.swept_confirmations = 0 # Start tracking confirmations + # Status stays as CONFIRMED until sweep is confirmed + # crypto_payment.status = CryptoPayment.STATUS_CONFIRMED_COMPLETE if dbsession: dbsession.add(crypto_payment) log.sweep_operation( @@ -1078,10 +1101,16 @@ def auto_sweep_payment_doge(client, crypto_payment: CryptoPayment, dbsession=Non ) # Send the sweep transaction - tx_hash = client.sendtoaddress( - crypto_payment.shop_sweep_to_address, - float(sweep_amount), - f"Sweep for invoice {crypto_payment.invoice.id}", + # Use subtractfeefromamount=True so network fee is deducted from the sweep amount + tx_hash = client._call( + "sendtoaddress", + [ + crypto_payment.shop_sweep_to_address, + float(sweep_amount), + f"Sweep for invoice {crypto_payment.invoice.id}", + "", # comment_to + True, # subtractfeefromamount + ], ) if tx_hash: @@ -1093,8 +1122,9 @@ def auto_sweep_payment_doge(client, crypto_payment: CryptoPayment, dbsession=Non crypto_payment.swept_timestamp = now_timestamp() # Note: DOGE RPC doesn't return fee info easily, so we leave it None crypto_payment.swept_network_fee = None - # Update status to confirmed-complete after successful sweep - crypto_payment.status = CryptoPayment.STATUS_CONFIRMED_COMPLETE + crypto_payment.swept_confirmations = 0 # Start tracking confirmations + # Status stays as CONFIRMED until sweep is confirmed + # crypto_payment.status = CryptoPayment.STATUS_CONFIRMED_COMPLETE if dbsession: dbsession.add(crypto_payment) @@ -2758,85 +2788,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 ( @@ -3055,8 +3098,12 @@ def process_refund_confirmations(request, settings): if confirmations != old_confirmations: log.confirmation_update(payment, old_confirmations, confirmations) - # Check if refund is now fully confirmed (10+ confirmations) - if confirmations >= 10: + # Check if refund is now fully confirmed based on coin type requirements + required_confirmations = OUTBOUND_CONFIRMATIONS_REQUIRED.get( + coin_type, 10 + ) + + if confirmations >= required_confirmations: old_status = payment.status # Transition to final refunded status @@ -3314,6 +3361,123 @@ def update_payment_confirmations_only(client, crypto_payment, coin_type): ) +def process_sweep_confirmations(request, settings): + """ + Monitor sweep transactions for confirmation status. + Updates swept_confirmations and transitions status when fully confirmed. + """ + log.processing_cycle("Starting sweep confirmation monitoring") + db = request.dbsession + + # Query payments that need sweep confirmation monitoring + sweep_queue = [] + + # Query each coin type with its specific threshold + for coin_type, required_confirmations in OUTBOUND_CONFIRMATIONS_REQUIRED.items(): + coin_sweeps = ( + db.query(CryptoPayment) + .options( + sa.orm.joinedload(CryptoPayment.user), + sa.orm.joinedload(CryptoPayment.shop), + ) + .filter( + CryptoPayment.status == CryptoPayment.STATUS_CONFIRMED, + CryptoPayment.coin_type == coin_type, + CryptoPayment.swept_tx_hash != None, + CryptoPayment.swept_confirmations < required_confirmations, + ) + .all() + ) + sweep_queue.extend(coin_sweeps) + + if not sweep_queue: + log.processing_cycle("No sweep transactions need confirmation monitoring") + return + + log.processing_cycle("Found sweep transactions to monitor", len(sweep_queue)) + + # Group by coin type to get appropriate clients + sweeps_by_coin = {} + for payment in sweep_queue: + coin_type = payment.coin_type + if coin_type not in sweeps_by_coin: + sweeps_by_coin[coin_type] = [] + sweeps_by_coin[coin_type].append(payment) + + # Process each coin type + for coin_type, coin_sweeps in sweeps_by_coin.items(): + log.processing_cycle( + f"Monitoring {coin_type} sweep transactions", len(coin_sweeps) + ) + + try: + client = get_crypto_client(settings, coin_type) + except ValueError as e: + log.error_with_context( + f"Failed to get {coin_type} client for sweep monitoring", e + ) + continue + + for payment in coin_sweeps: + try: + # Get confirmation count for the sweep transaction + confirmations = 0 + if coin_type == "XMR": + confirmations = get_monero_tx_confirmations( + client, payment.swept_tx_hash + ) + elif coin_type == "DOGE": + confirmations = get_dogecoin_tx_confirmations( + client, payment.swept_tx_hash + ) + else: + log.payment_error( + payment, + f"Unsupported coin type for sweep monitoring: {coin_type}", + ) + continue + + # Update confirmation count + old_confirmations = payment.swept_confirmations + payment.swept_confirmations = confirmations + + if confirmations != old_confirmations: + log.payment_info( + payment, + f"Sweep confirmations: {old_confirmations} → {confirmations}", + ) + + # Check if sweep is now fully confirmed based on coin type requirements + required_confirmations = OUTBOUND_CONFIRMATIONS_REQUIRED.get( + coin_type, 10 + ) + + if confirmations >= required_confirmations: + old_status = payment.status + # Transition to confirmed-complete + payment.status = CryptoPayment.STATUS_CONFIRMED_COMPLETE + log.state_transition( + payment, + old_status, + payment.status, + f"sweep confirmed with {confirmations} confirmations", + ) + log.payment_info( + payment, + f"Sweep fully confirmed - funds have left hot wallet", + ) + + # Update timestamp + payment.updated_timestamp = int(time.time() * 1000) + db.add(payment) + + except Exception as e: + log.payment_error(payment, "Failed to check sweep confirmations", e) + continue + + log.processing_cycle("Finished sweep confirmation monitoring") + + def scan_wallet_for_double_or_late_payments(request, settings): """ Scan wallet for new incoming transactions since last scan position and match them to payments. @@ -4039,6 +4203,9 @@ def run_once(env, interval): # Process refund confirmation monitoring process_refund_confirmations(request, settings) + # Process sweep confirmation monitoring + process_sweep_confirmations(request, settings) + def main(argv=sys.argv): args = parse_args(argv) diff --git a/make_post_sell/lib/crypto_watcher/crypto_clients.py b/make_post_sell/lib/crypto_watcher/crypto_clients.py index 9702e30..1c24e9b 100644 --- a/make_post_sell/lib/crypto_watcher/crypto_clients.py +++ b/make_post_sell/lib/crypto_watcher/crypto_clients.py @@ -300,9 +300,28 @@ 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..bc60a63 100644 --- a/make_post_sell/lib/crypto_watcher/crypto_payment_rescue.py +++ b/make_post_sell/lib/crypto_watcher/crypto_payment_rescue.py @@ -16,6 +16,11 @@ logger = logging.getLogger(__name__) RESTOCKING_FEE_PERCENT = Decimal("0.09") # 9% restocking fee OVERPAYMENT_THRESHOLD_PERCENT = Decimal("0.05") # 5% overpayment allowed before refund +# Dogecoin fee buffer - increase if you see "INSUFFICIENT FUNDS" errors in logs +# This accounts for network fees that Dogecoin adds on top of outputs +# Actual fees are typically 0.001-0.003 DOGE for 2-output transactions +DOGE_REFUND_FEE_BUFFER = 0.005 # Conservative buffer to avoid insufficient funds + def calculate_refund_amount(amount, fee_percent=RESTOCKING_FEE_PERCENT): """Calculate refund amount after deducting restocking fee.""" @@ -119,6 +124,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}", } @@ -159,9 +165,13 @@ class PaymentRescue: """ 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 +181,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 +192,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,20 +201,21 @@ 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"Refund address: {refund_details['refund_address']}") - logger.info(f"Refund reason: {refund_details['reason']}") - else: - logger.info(f"Attempting to refund payment {refund_details['payment_id']}") - logger.info( - f"Refund amount: {refund_amount_coin} {coin_type} ({refund_amount_atomic} {atomic_unit_name})" - ) - logger.info(f"Refund address: {refund_details['refund_address']}") - logger.info(f"Refund reason: {refund_details['reason']}") + if payment_amount_coin > 0: + logger.info( + f"Multi-output refund - Customer: {refund_amount_coin} {coin_type}, " + f"Shop: {payment_amount_coin + fee_amount_coin} {coin_type} (payment + fee)" + ) + else: + logger.info( + f"Refund: {refund_amount_coin} {coin_type} to customer, " + f"{fee_amount_coin} {coin_type} fee to shop" + ) # Check if incoming payment has enough confirmations before allowing refund from . import get_coin_config @@ -217,78 +233,179 @@ class PaymentRescue: - payment.current_confirmations, } - # Check wallet balance before attempting refund (coin-specific) - try: - if coin_type == "XMR": - account_index = payment.account_index if payment else 0 - logger.info(f"Checking Monero balance for account {account_index}") - balance_result = self.crypto_client._call( - "get_balance", {"account_index": account_index} - ) - unlocked_balance = balance_result.get("unlocked_balance", 0) - total_balance = balance_result.get("balance", 0) - logger.info( - f"Account {account_index} balance - Total: {total_balance} piconero ({Decimal(total_balance) / Decimal('1e12')} XMR)" - ) - logger.info( - f"Account {account_index} balance - Unlocked: {unlocked_balance} piconero ({Decimal(unlocked_balance) / Decimal('1e12')} XMR)" - ) - - if unlocked_balance < refund_amount_atomic: - logger.warning( - f"Insufficient unlocked balance in account {account_index}: need {refund_amount_atomic} but only have {unlocked_balance} unlocked" - ) - elif coin_type == "DOGE": - logger.info("Checking Dogecoin wallet balance") + # Get wallet balance for fee calculations + balance_result = None + if coin_type == "DOGE": + try: balance_result = self.crypto_client.getbalance() - logger.info(f"Dogecoin wallet balance: {balance_result} DOGE") - - if balance_result < refund_amount_coin: - logger.warning( - f"Insufficient Dogecoin balance: need {refund_amount_coin} DOGE but only have {balance_result} DOGE" - ) - except Exception as balance_error: - logger.warning(f"Could not check wallet balance: {balance_error}") + except Exception: + pass 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") + + account_index = payment.account_index if payment else 0 + + # 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, + } + ) + 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, "priority": 1, } - logger.info(f"Calling Monero transfer with params: {transfer_params}") 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']}" + 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 ) - 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']}", + + # 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 + + # Fee estimate - can be overridden via environment variable + import os + + estimated_fee = float( + os.environ.get( + "DOGE_REFUND_FEE_BUFFER", str(DOGE_REFUND_FEE_BUFFER) + ) ) + if estimated_fee != DOGE_REFUND_FEE_BUFFER: + logger.info( + f"Using custom fee buffer from env: {estimated_fee} DOGE" + ) + + # Check if we need to adjust for fees + if balance_result and total_output + estimated_fee > balance_result: + # Calculate how much we need to reduce + shortage = (total_output + estimated_fee) - balance_result + + # Reduce both amounts proportionally + refund_ratio = refund_amount_doge / total_output + shop_ratio = shop_amount_doge / total_output + + # Round down to 3 decimal places + import math + + refund_amount_doge = ( + math.floor( + (refund_amount_doge - shortage * refund_ratio) * 1000 + ) + / 1000 + ) + shop_amount_doge = ( + math.floor((shop_amount_doge - shortage * shop_ratio) * 1000) + / 1000 + ) + + # Build final outputs with adjusted amounts + outputs[refund_details["refund_address"]] = refund_amount_doge + outputs[shop_sweep_address] = shop_amount_doge + + # Get the account for the payment address + from_account = "" + try: + from_account = self.crypto_client._call( + "getaccount", [payment.address] + ) + except Exception: + pass + + # Clean outputs - ensure plain floats + clean_outputs = { + addr: float(amount) for addr, amount in outputs.items() + } + + # Try sendmany with the account that has the funds + try: + tx_hash = self.crypto_client.sendmany( + from_account, clean_outputs, 1 + ) + except Exception as e: + error_msg = str(e).lower() + if "insufficient funds" in error_msg: + # Log detailed fee information when we hit insufficient funds + logger.error(f"INSUFFICIENT FUNDS - Fee estimate too low!") + logger.error(f"Current fee buffer: {estimated_fee} DOGE") + logger.error( + f"Total outputs: {sum(clean_outputs.values())} DOGE" + ) + logger.error(f"Available balance: {balance_result} DOGE") + shortfall = ( + sum(clean_outputs.values()) + estimated_fee - balance_result + ) + logger.error( + f"Shortfall: {shortfall:.8f} DOGE (may need more for actual network fee)" + ) + logger.error( + f"ACTION REQUIRED: Increase DOGE_REFUND_FEE_BUFFER constant at top of crypto_payment_rescue.py" + ) + + # Try with default account as fallback + try: + tx_hash = self.crypto_client.sendmany("", clean_outputs, 1) + except Exception as e2: + if "insufficient funds" in str(e2).lower(): + logger.error( + f"Both accounts failed - fee definitely too low!" + ) + raise e2 + else: + raise + tx_result = {"tx_hash": tx_hash} + # Log success with fee info for monitoring + logger.info(f"Refund sent successfully! TX: {tx_hash}") + if balance_result: + buffer_used = balance_result - sum(clean_outputs.values()) + logger.info( + f"Fee buffer used: {buffer_used:.8f} DOGE (estimated: {estimated_fee})" + ) + else: raise ValueError(f"Refund not supported for coin type: {coin_type}") diff --git a/make_post_sell/lib/render.py b/make_post_sell/lib/render.py index 58a7691..adb7f46 100644 --- a/make_post_sell/lib/render.py +++ b/make_post_sell/lib/render.py @@ -13,6 +13,8 @@ def make_cleaner_from_shop(shop): """Given a Shop return a bleach Cleaner object.""" cleaner = default_cleaner() cleaner.link_protection = True + # Store shop reference for link color styling + cleaner.shop = shop if shop.domain_name: apex_domain_name = shop.domain_name.split(".")[-2:] cleaner.whitelist_domains.append("makepostsell.com") diff --git a/make_post_sell/lib/sanitize_html.py b/make_post_sell/lib/sanitize_html.py index 71b6792..9533178 100644 --- a/make_post_sell/lib/sanitize_html.py +++ b/make_post_sell/lib/sanitize_html.py @@ -10,6 +10,8 @@ from bleach.callbacks import nofollow, target_blank from bleach_allowlist import markdown_tags, markdown_attrs, all_styles +# We implement our own CSS validation in protect_links() for security + from bs4 import BeautifulSoup import miniuri @@ -58,6 +60,11 @@ def default_cleaner(tag_acl=None): attrs["img"].append("width") attrs["img"].append("style") attrs["span"] = ["class"] + + # Allow style attribute on anchor tags for link color styling + if "a" not in attrs: + attrs["a"] = [] + attrs["a"].append("style") # Allow both whitelist and blacklist tag_name/attr/attr_value # to get past bleach. @@ -78,6 +85,8 @@ def default_cleaner(tag_acl=None): # attributes for the given tag_name. attrs[tag_name].append(attr_name) + # We allow style attributes and validate CSS in protect_links function + # This provides targeted validation for the specific CSS we add (color property) cleaner = Cleaner(tags=tags, attributes=attrs) # doesn't do anything, but i used to be able to pass it via constructor. @@ -172,6 +181,23 @@ def protect_links(soup, cleaner): for a_tag in soup.find_all("a"): uri = miniuri.Uri(a_tag.attrs.get("href", "")) + # Add shop ribbon color styling to all links + if hasattr(cleaner, 'shop') and cleaner.shop: + link_color = cleaner.shop.theme_link_color + if link_color: + # Validate that the color looks like a valid CSS color + # Allow hex colors (#fff, #ffffff), rgb(), rgba(), hsl(), hsla(), and named colors + import re + color_pattern = r'^(#[0-9a-fA-F]{3}|#[0-9a-fA-F]{6}|rgb\([^)]+\)|rgba\([^)]+\)|hsl\([^)]+\)|hsla\([^)]+\)|[a-zA-Z]+)$' + if re.match(color_pattern, link_color.strip()): + # Get existing style or create new one + existing_style = a_tag.attrs.get("style", "") + if existing_style and not existing_style.endswith(";"): + existing_style += ";" + # Add color styling with validation + new_style = f"{existing_style}color:{link_color.strip()};" + a_tag.attrs["style"] = new_style + if uri.hostname in cleaner.whitelist_domains: # domain in whitelist or relative URI so remove rel="nofollow". a_tag.attrs.pop("rel", None) diff --git a/make_post_sell/models/crypto_payment.py b/make_post_sell/models/crypto_payment.py index b7905bd..b869f8f 100644 --- a/make_post_sell/models/crypto_payment.py +++ b/make_post_sell/models/crypto_payment.py @@ -219,6 +219,9 @@ class CryptoPayment(RBase, Base): swept_network_fee = Column( BigInteger, nullable=True ) # Network fee paid (in atomic units, from node) + swept_confirmations = Column( + Integer, nullable=False, default=0 + ) # Current confirmation count of sweep transaction # Current confirmation count current_confirmations = Column( diff --git a/make_post_sell/models/invoice.py b/make_post_sell/models/invoice.py index e980eb1..2e96fa2 100644 --- a/make_post_sell/models/invoice.py +++ b/make_post_sell/models/invoice.py @@ -288,7 +288,12 @@ 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 +359,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/scripts/alembic/versions/0915b3ff883d_add_swept_confirmations_to_track_sweep_.py b/make_post_sell/scripts/alembic/versions/0915b3ff883d_add_swept_confirmations_to_track_sweep_.py new file mode 100644 index 0000000..52853a2 --- /dev/null +++ b/make_post_sell/scripts/alembic/versions/0915b3ff883d_add_swept_confirmations_to_track_sweep_.py @@ -0,0 +1,34 @@ +"""Add swept_confirmations to track sweep transaction confirmations + +Revision ID: 0915b3ff883d +Revises: 07908c8c840d +Create Date: 2025-10-02 19:00:08.788508 + +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "0915b3ff883d" +down_revision = "07908c8c840d" +branch_labels = None +depends_on = None + +from make_post_sell.models.meta import UUIDType + + +def upgrade(): + # Add swept_confirmations column to mps_crypto_payment table + op.add_column( + "mps_crypto_payment", + sa.Column( + "swept_confirmations", sa.Integer(), nullable=False, server_default="10" + ), + ) + + +def downgrade(): + # Remove swept_confirmations column from mps_crypto_payment table + op.drop_column("mps_crypto_payment", "swept_confirmations") 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..816537f 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,9 @@ 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) @@ -1355,7 +1357,7 @@ class DogecoinWatcherUnitTests(unittest.TestCase): """Test successful Dogecoin auto-sweep.""" mock_client = MagicMock() mock_client.getbalance.return_value = 100.5 - mock_client.sendtoaddress.return_value = "sweep_tx_hash_123" + mock_client._call.return_value = "sweep_tx_hash_123" payment = MagicMock() payment.id = "payment_123" @@ -1369,11 +1371,28 @@ class DogecoinWatcherUnitTests(unittest.TestCase): result = auto_sweep_payment_doge(mock_client, payment) self.assertTrue(result) - mock_client.sendtoaddress.assert_called_once_with( - "DColdWalletAddress123", - 10.0, # Now sweeps only the expected amount (10 DOGE) - "Sweep for invoice invoice_123", + # Check that both estimatesmartfee and sendtoaddress were called + self.assertEqual(mock_client._call.call_count, 2) + + # First call should be estimatesmartfee + first_call = mock_client._call.call_args_list[0] + self.assertEqual(first_call[0][0], "estimatesmartfee") + self.assertEqual(first_call[0][1], [6]) # 6 block target + + # Second call should be sendtoaddress + second_call = mock_client._call.call_args_list[1] + self.assertEqual(second_call[0][0], "sendtoaddress") + self.assertEqual( + second_call[0][1], + [ + "DColdWalletAddress123", + 10.0, # Now sweeps only the expected amount (10 DOGE) + "Sweep for invoice invoice_123", + "", # comment_to + True, # subtractfeefromamount + ], ) + self.assertEqual(payment.swept_tx_hash, "sweep_tx_hash_123") def test_auto_sweep_payment_doge_no_address(self): @@ -1462,7 +1481,7 @@ class DogecoinWatcherUnitTests(unittest.TestCase): """Test that DOGE auto-sweep properly updates payment object in database session.""" mock_client = MagicMock() mock_client.getbalance.return_value = 10.0 # 10 DOGE - mock_client.sendtoaddress.return_value = "doge_sweep_tx_456" + mock_client._call.return_value = "doge_sweep_tx_456" # Create a mock dbsession mock_dbsession = MagicMock() @@ -2543,7 +2562,7 @@ class SweepRestockingFeeTests(unittest.TestCase): # Setup mock DOGE client mock_doge_client = MagicMock() mock_doge_client.getbalance.return_value = 1.0 # 1 DOGE balance - mock_doge_client.sendtoaddress.return_value = "doge_tx_hash_123" + mock_doge_client._call.return_value = "doge_tx_hash_123" mock_get_client.return_value = mock_doge_client # Execute sweep @@ -2557,8 +2576,16 @@ class SweepRestockingFeeTests(unittest.TestCase): # Verify client was called correctly mock_get_client.assert_called_once_with(self.mock_settings, "DOGE") - mock_doge_client.sendtoaddress.assert_called_once_with( - "DE2ET4uMRYMQ3nhtSjiTcbbopA3VNn1Ckh", 0.36 # fee_amount as float + # sweep_restocking_fee only calls sendtoaddress (no estimatesmartfee) + mock_doge_client._call.assert_called_once_with( + "sendtoaddress", + [ + "DE2ET4uMRYMQ3nhtSjiTcbbopA3VNn1Ckh", + 0.36, # fee_amount as float + "", # comment + "", # comment_to + True, # subtractfeefromamount + ], ) # Verify sleep was called (2 second delay) @@ -2718,7 +2745,7 @@ class SweepRestockingFeeTests(unittest.TestCase): # Setup mock DOGE client that throws exception mock_doge_client = MagicMock() mock_doge_client.getbalance.return_value = 1.0 # 1 DOGE balance - mock_doge_client.sendtoaddress.side_effect = Exception("RPC connection error") + mock_doge_client._call.side_effect = Exception("RPC connection error") mock_get_client.return_value = mock_doge_client with patch("make_post_sell.lib.crypto_watcher.log") as mock_logger: @@ -2788,7 +2815,7 @@ class SweepRestockingFeeTests(unittest.TestCase): # Setup mock DOGE client mock_doge_client = MagicMock() mock_doge_client.getbalance.return_value = 1.0 # 1 DOGE balance - mock_doge_client.sendtoaddress.return_value = "doge_tx_hash_123" + mock_doge_client._call.return_value = "doge_tx_hash_123" mock_get_client.return_value = mock_doge_client with patch("make_post_sell.lib.crypto_watcher.log") as mock_logger: @@ -3101,8 +3128,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,10 +3140,14 @@ 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.sendmany.return_value = "doge-tx-hash-123" mock_client.getbalance.return_value = 100.0 # Sufficient DOGE balance + mock_client._call.return_value = "" # For getaccount call rescue = PaymentRescue(self.mock_dbsession, mock_client) @@ -3131,19 +3162,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.sendmany.assert_called_once() + call_args = mock_client.sendmany.call_args[0] + self.assertEqual(call_args[0], "") # fromaccount + outputs = call_args[1] + self.assertEqual(outputs["DTestAddress123"], 4.55) # Customer refund + self.assertEqual(outputs["DShopSweepAddress789"], 0.45) # Shop fee + self.assertEqual(call_args[2], 1) # minconf 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,15 +3188,14 @@ 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 = [ - { - "balance": 2000000000000, - "unlocked_balance": 1500000000000, - }, # Balance check - {"tx_hash": "xmr-tx-hash-456"}, # Transfer result - ] + mock_client._call.return_value = { + "tx_hash": "xmr-tx-hash-456" + } # Transfer result rescue = PaymentRescue(self.mock_dbsession, mock_client) @@ -3177,20 +3210,23 @@ class RefundTypeTests(unittest.TestCase): result = rescue.execute_refund(refund_details, xmr_payment) - # Verify XMR-specific RPC calls - expected_balance_call = mock_client._call.call_args_list[0] - self.assertEqual(expected_balance_call[0][0], "get_balance") - self.assertEqual(expected_balance_call[0][1]["account_index"], 5) - - expected_transfer_call = mock_client._call.call_args_list[1] + # Verify XMR-specific RPC calls - only transfer, no balance check + expected_transfer_call = mock_client._call.call_args_list[0] 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 +3303,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 +3311,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,10 +3334,12 @@ 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.sendmany.return_value = "doge-precision-tx-123" mock_client.getbalance.return_value = 100.0 + mock_client._call.return_value = "" # For getaccount call rescue = PaymentRescue(self.mock_dbsession, mock_client) @@ -3317,14 +3357,22 @@ 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.sendmany.assert_called_once() + call_args = mock_client.sendmany.call_args[0] + self.assertEqual(call_args[0], "") # fromaccount + outputs = call_args[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.""" @@ -3338,16 +3386,13 @@ class RefundTypeTests(unittest.TestCase): xmr_payment.coin_type = "XMR" xmr_payment.current_confirmations = 10 xmr_payment.account_index = 2 + xmr_payment.shop_sweep_to_address = ( + "4ShopPrecisionAddressXMR" # Required for fee destination + ) mock_client = MagicMock() mock_client._call.return_value = {"tx_hash": "xmr-precision-tx-123"} - # Mock balance check - mock_client._call.side_effect = [ - {"unlocked_balance": 100000000000000}, # get_balance call - {"tx_hash": "xmr-precision-tx-123"}, # transfer call - ] - rescue = PaymentRescue(self.mock_dbsession, mock_client) # Test with high precision amount (12 decimal places for XMR) @@ -3370,16 +3415,25 @@ class RefundTypeTests(unittest.TestCase): # 0.123456789123 XMR * 1e12 = 123456789123 piconero expected_atomic_amount = 123456789123 - # Check the transfer call (second call) - transfer_call = mock_client._call.call_args_list[1] + # Check the transfer call (first and only call) + transfer_call = mock_client._call.call_args_list[0] self.assertEqual(transfer_call[0][0], "transfer") # method transfer_params = transfer_call[0][1] # params + # Check refund destination self.assertEqual( transfer_params["destinations"][0]["amount"], expected_atomic_amount ) self.assertEqual( transfer_params["destinations"][0]["address"], "4XMRPrecisionTest123" ) + # Check fee destination + expected_fee_atomic = 13717354347 # 0.013717354347 XMR * 1e12 + self.assertEqual( + transfer_params["destinations"][1]["amount"], expected_fee_atomic + ) + self.assertEqual( + transfer_params["destinations"][1]["address"], "4ShopPrecisionAddressXMR" + ) self.assertEqual(transfer_params["account_index"], 2) @@ -3444,7 +3498,7 @@ class PaymentConfirmationOrderTests(unittest.TestCase): # Mock client self.mock_client = MagicMock() self.mock_client.getbalance.return_value = 20.0 # Sufficient DOGE balance - self.mock_client.sendtoaddress.return_value = "sweep-tx-hash-123" + self.mock_client._call.return_value = "sweep-tx-hash-123" @patch("make_post_sell.lib.crypto_watcher.finalize_invoice") @patch("make_post_sell.lib.crypto_watcher.get_coin_config") @@ -3466,7 +3520,7 @@ class PaymentConfirmationOrderTests(unittest.TestCase): # Mock auto-sweep success self.mock_client.getbalance.return_value = 10.0 # Sufficient balance - self.mock_client.sendtoaddress.return_value = "sweep-tx-hash-123" + self.mock_client._call.return_value = "sweep-tx-hash-123" # No payment rescue (normal payment) result = process_confirmed_payment( @@ -3488,10 +3542,18 @@ class PaymentConfirmationOrderTests(unittest.TestCase): self.assertTrue(result["auto_sweep"]["success"]) # Verify auto-sweep called with correct amount (5 DOGE) - self.mock_client.sendtoaddress.assert_called_once_with( - "DShopSweepAddress123", - 5.0, - f"Sweep for invoice {self.mock_payment.invoice.id}", + # Last call should be sendtoaddress (after estimatesmartfee) + last_call = self.mock_client._call.call_args_list[-1] + self.assertEqual(last_call[0][0], "sendtoaddress") + self.assertEqual( + last_call[0][1], + [ + "DShopSweepAddress123", + 5.0, + f"Sweep for invoice {self.mock_payment.invoice.id}", + "", # comment_to + True, # subtractfeefromamount + ], ) @patch("make_post_sell.lib.crypto_watcher.finalize_invoice") @@ -3541,7 +3603,7 @@ class PaymentConfirmationOrderTests(unittest.TestCase): # Mock auto-sweep success self.mock_client.getbalance.return_value = 10.0 - self.mock_client.sendtoaddress.return_value = "sweep-tx-hash-789" + self.mock_client._call.return_value = "sweep-tx-hash-789" result = process_confirmed_payment( self.mock_env_request, @@ -3566,10 +3628,18 @@ class PaymentConfirmationOrderTests(unittest.TestCase): mock_sweep_fee.assert_not_called() # 4) Auto-sweep invoice amount (4 DOGE, not entire balance) - self.mock_client.sendtoaddress.assert_called_once_with( - "DShopSweepAddress123", - 4.0, - f"Sweep for invoice {self.mock_payment.invoice.id}", + # Last call should be sendtoaddress (after estimatesmartfee) + last_call = self.mock_client._call.call_args_list[-1] + self.assertEqual(last_call[0][0], "sendtoaddress") + self.assertEqual( + last_call[0][1], + [ + "DShopSweepAddress123", + 4.0, + f"Sweep for invoice {self.mock_payment.invoice.id}", + "", # comment_to + True, # subtractfeefromamount + ], ) # Verify results @@ -3676,7 +3746,7 @@ class PaymentConfirmationOrderTests(unittest.TestCase): # Mock auto-sweep success self.mock_client.getbalance.return_value = 10.0 - self.mock_client.sendtoaddress.return_value = "sweep-tx-hash-789" + self.mock_client._call.return_value = "sweep-tx-hash-789" result = process_confirmed_payment( self.mock_env_request, @@ -3690,10 +3760,18 @@ class PaymentConfirmationOrderTests(unittest.TestCase): self.assertFalse(result["restocking_fee_swept"]) # Verify normal auto-sweep of invoice amount (4.0 DOGE) - self.mock_client.sendtoaddress.assert_called_once_with( - "DShopSweepAddress123", - 4.0, - f"Sweep for invoice {self.mock_payment.invoice.id}", + # Last call should be sendtoaddress (after estimatesmartfee) + last_call = self.mock_client._call.call_args_list[-1] + self.assertEqual(last_call[0][0], "sendtoaddress") + self.assertEqual( + last_call[0][1], + [ + "DShopSweepAddress123", + 4.0, + f"Sweep for invoice {self.mock_payment.invoice.id}", + "", # comment_to + True, # subtractfeefromamount + ], ) self.assertTrue(result["auto_sweep"]["success"]) from make_post_sell.models.crypto_payment import CryptoPayment @@ -3719,17 +3797,25 @@ class PaymentConfirmationOrderTests(unittest.TestCase): # Large wallet balance self.mock_client.getbalance.return_value = 25.75 # 25.75 DOGE total balance - self.mock_client.sendtoaddress.return_value = "amount-sweep-tx-hash" + self.mock_client._call.return_value = "amount-sweep-tx-hash" result = auto_sweep_payment( self.mock_client, self.mock_payment, self.mock_env_request.dbsession ) # Verify only expected amount was swept, not entire wallet - self.mock_client.sendtoaddress.assert_called_once_with( - "DShopSweepAddress123", - 3.25, - "Sweep for invoice invoice-789", # Specific amount, not 25.75 + # Last call should be sendtoaddress (after estimatesmartfee) + last_call = self.mock_client._call.call_args_list[-1] + self.assertEqual(last_call[0][0], "sendtoaddress") + self.assertEqual( + last_call[0][1], + [ + "DShopSweepAddress123", + 3.25, + "Sweep for invoice invoice-789", # Specific amount, not 25.75 + "", # comment_to + True, # subtractfeefromamount + ], ) self.assertTrue(result) @@ -3794,8 +3880,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 +3971,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 +4091,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 ) @@ -4105,7 +4191,7 @@ class PaymentConfirmationOrderTests(unittest.TestCase): self.mock_payment.is_swept = False self.mock_client.getbalance.return_value = 50.12345 # Large DOGE balance - self.mock_client.sendtoaddress.return_value = "doge-precise-sweep-tx" + self.mock_client._call.return_value = "doge-precise-sweep-tx" # Sweep expected amount only (not entire wallet) doge_result = auto_sweep_payment( @@ -4113,8 +4199,23 @@ class PaymentConfirmationOrderTests(unittest.TestCase): ) # Verify precise DOGE amount (not entire wallet) - self.mock_client.sendtoaddress.assert_called_with( - "DShopSweepAddress123", 1.23456789, "Sweep for invoice invoice-789" + # Find the sendtoaddress call (should be after estimatesmartfee) + sendtoaddress_calls = [ + call + for call in self.mock_client._call.call_args_list + if call[0][0] == "sendtoaddress" + ] + self.assertTrue(sendtoaddress_calls) + last_send = sendtoaddress_calls[-1] + self.assertEqual( + last_send[0][1], + [ + "DShopSweepAddress123", + 1.23456789, + "Sweep for invoice invoice-789", + "", # comment_to + True, # subtractfeefromamount + ], ) self.assertTrue(doge_result) @@ -4177,8 +4278,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 ) @@ -4345,7 +4446,7 @@ class PaymentConfirmationOrderTests(unittest.TestCase): # Mock successful refund on retry refund_success = {"success": True, "tx_hash": "retry-refund-tx-123"} mock_payment_rescue.execute_refund.return_value = refund_success - self.mock_client.sendtoaddress.return_value = "retry-sweep-tx-456" + self.mock_client._call.return_value = "retry-sweep-tx-456" # Second attempt - refund succeeds result2 = process_confirmed_payment( @@ -4365,10 +4466,23 @@ class PaymentConfirmationOrderTests(unittest.TestCase): ) # Verify auto-sweep happened (5 DOGE invoice amount) - self.mock_client.sendtoaddress.assert_called_once_with( - "DShopSweepAddress123", - 5.0, - f"Sweep for invoice {self.mock_payment.invoice.id}", + # Find the sendtoaddress call + sendtoaddress_calls = [ + call + for call in self.mock_client._call.call_args_list + if call[0][0] == "sendtoaddress" + ] + self.assertTrue(sendtoaddress_calls) + last_send = sendtoaddress_calls[-1] + self.assertEqual( + last_send[0][1], + [ + "DShopSweepAddress123", + 5.0, + f"Sweep for invoice {self.mock_payment.invoice.id}", + "", # comment_to + True, # subtractfeefromamount + ], ) 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..ce552e7 --- /dev/null +++ b/make_post_sell/tests/test_multi_output_refunds.py @@ -0,0 +1,235 @@ +""" +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 and getbalance + self.mock_client.sendmany.return_value = "test_tx_hash_123" + self.mock_client.getbalance.return_value = 100.0 # Sufficient balance + self.mock_client._call.return_value = "" # For getaccount + + # Execute refund + result = self.rescue.execute_refund(refund_details, self.payment) + + # Verify multi-output sendmany was called + self.mock_client.sendmany.assert_called_once() + call_args = self.mock_client.sendmany.call_args[0] + + self.assertEqual(call_args[0], "") # fromaccount + + # Check outputs + outputs = call_args[1] + self.assertEqual(len(outputs), 2) # Two outputs + self.assertAlmostEqual(outputs["DCustomerRefundAddress123"], 91.0, places=2) + self.assertAlmostEqual(outputs["DShopSweepAddressTest123"], 9.0, places=2) + + # 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 + def mock_call(method, params=None): + if method == "transfer": + return {"tx_hash": "xmr_test_tx_hash_456"} + else: + raise ValueError(f"Unexpected method: {method}") + + self.mock_client._call.side_effect = mock_call + + # 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.sendmany.return_value = "zero_fee_tx_123" + self.mock_client.getbalance.return_value = 100.0 + self.mock_client._call.return_value = "" + + # Execute refund + result = self.rescue.execute_refund(refund_details, self.payment) + + # Verify multi-output sendmany was still used + self.mock_client.sendmany.assert_called_once() + call_args = self.mock_client.sendmany.call_args[0] + outputs = call_args[1] + # With 0.005 fee buffer, customer gets slightly less + self.assertAlmostEqual(outputs["DCustomerRefundAddress123"], 99.995, places=3) + 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() diff --git a/make_post_sell/views/cart.py b/make_post_sell/views/cart.py index ae6b245..7207da6 100644 --- a/make_post_sell/views/cart.py +++ b/make_post_sell/views/cart.py @@ -48,15 +48,37 @@ def get_smart_purchase_redirect_url(invoices): - Invoice URL if multiple products or physical products - General purchases page as fallback """ + import logging + + logger = logging.getLogger(__name__) + # Handle single invoice with single digital product if len(invoices) == 1: invoice = invoices[0] line_items = list(invoice.line_items) + logger.info( + f"Smart redirect check - Invoice {invoice.id} has {len(line_items)} line items" + ) + # Single digital product - redirect to download page - if len(line_items) == 1 and line_items[0].product.has_product_file: + if len(line_items) == 1: product = line_items[0].product - return f"/p/{product.id}/{product.slug}" + logger.info(f"Single product: {product.title} (ID: {product.id})") + logger.info(f"Is physical: {product.is_physical}") + logger.info(f"Has product file: {product.has_product_file}") + logger.info(f"Product extensions: {product.extensions}") + + # Check if it's a digital product with a file + if not product.is_physical and product.has_product_file: + redirect_url = f"/p/{product.id}/{product.slug}" + logger.info(f"Redirecting to product page: {redirect_url}") + return redirect_url + else: + if product.is_physical: + logger.info(f"Product is physical, redirecting to invoice") + else: + logger.info(f"Digital product has no file, redirecting to invoice") # Multiple products or physical products - redirect to invoice return f"/invoice/{invoice.id}" diff --git a/make_post_sell/views/crypto.py b/make_post_sell/views/crypto.py index b3c7ed0..c90149f 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,8 @@ 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 +933,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 +972,8 @@ 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 +1207,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 +1221,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 +1238,34 @@ 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"})