Merge branch 'feature/multi-output-refunds' into 'master'

Fix Dogecoin & Monero multi-output refunds with proper fee handling

See merge request engineering/make-post-sell/make_post_sell!48
This commit is contained in:
Russell Ballestrini 2025-10-03 15:15:28 +00:00
commit fd267aec0f
15 changed files with 1083 additions and 273 deletions

View file

@ -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 ==="

View file

@ -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)

View file

@ -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

View file

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

View file

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

View file

@ -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)

View file

@ -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(

View file

@ -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,

View file

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

View file

@ -58,7 +58,7 @@
<p><strong>Total Amount:</strong> {{ '%.12f' % amount_crypto }} {{ coin_symbol }}</p>
<p><strong>Expected {{ smallest_unit_name }}:</strong> {{ '{:,}'.format(expected_smallest_units) }}</p>
<p><strong>Payment ID:</strong> <code id="payment-id">{{ payment_id }}</code></p>
{% 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 %}
<p><strong>Invoice ID:</strong> <a href="{{ request.route_url('view_invoice', invoice_id=invoice_id) }}">{{ invoice_id }}</a></p>
{% endif %}
{% else %}
@ -67,7 +67,7 @@
<p><strong>Total Amount:</strong> {{ '%.8f' % amount_crypto }} {{ coin_symbol }}</p>
<p><strong>Expected {{ smallest_unit_name }}:</strong> {{ '{:,}'.format(expected_smallest_units) }}</p>
<p><strong>Payment ID:</strong> <code id="payment-id">{{ payment_id }}</code></p>
{% 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 %}
<p><strong>Invoice ID:</strong> <a href="{{ request.route_url('view_invoice', invoice_id=invoice_id) }}">{{ invoice_id }}</a></p>
{% 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;

View file

@ -58,7 +58,7 @@
{% endif %}
<div style="text-align: right; justify-self: end;">
{% 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 %}
<a href="{{ request.route_url('view_invoice', invoice_id=payment.invoice_id) }}" class="mps-button mps-button-small mps-button-green">
View Invoice

View file

@ -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
],
)

View file

@ -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()

View file

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

View file

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