Fix Dogecoin multi-output refunds with proper fee handling
- Add payment amount to shop output for overpayment refunds - Both XMR and DOGE now send payment + restocking fee to shop - Fix insufficient funds error by using conservative fee buffer (0.01 DOGE) - Round outputs down to 3 decimal places to avoid precision issues - Update sendmany to try actual account first (where funds are located) - Add better error handling for different account formats - Fix fee calculation to properly account for Dogecoin's fee-on-top model The issue was that sendmany adds network fee on top of outputs, and we were trying to send exactly the wallet balance, leaving no room for fees. Now we reserve 0.01 DOGE for fees and round conservatively.
This commit is contained in:
parent
958a003f35
commit
d2d09e8851
10 changed files with 661 additions and 157 deletions
42
Makefile
42
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 ==="
|
||||
|
|
|
|||
|
|
@ -864,14 +864,16 @@ def auto_sweep_payment_xmr(client, crypto_payment: CryptoPayment, dbsession=None
|
|||
# Get dynamic fee estimate by doing a test transfer with do_not_relay=true
|
||||
try:
|
||||
# Estimate fee by doing a test transfer without broadcasting
|
||||
# This gives accurate fee based on actual transaction size
|
||||
# Use 96% of amount to leave room for fee in the same subaddress
|
||||
test_amount_piconero = int(payment_amount_piconero * Decimal("0.96"))
|
||||
|
||||
test_transfer_result = client._call(
|
||||
"transfer",
|
||||
{
|
||||
"destinations": [
|
||||
{
|
||||
"address": crypto_payment.shop_sweep_to_address,
|
||||
"amount": payment_amount_piconero,
|
||||
"amount": test_amount_piconero,
|
||||
}
|
||||
],
|
||||
"account_index": crypto_payment.account_index,
|
||||
|
|
@ -881,18 +883,24 @@ def auto_sweep_payment_xmr(client, crypto_payment: CryptoPayment, dbsession=None
|
|||
"get_tx_metadata": True,
|
||||
},
|
||||
)
|
||||
estimated_fee_piconero = int(
|
||||
dynamic_fee_piconero = int(
|
||||
test_transfer_result.get("fee", 100000000000)
|
||||
) # Fallback to ~0.0001 XMR
|
||||
# Add 10% margin to the dynamic fee estimate to ensure sweep always works
|
||||
estimated_fee_piconero = int(dynamic_fee_piconero * Decimal("1.1"))
|
||||
log.payment_info(
|
||||
crypto_payment,
|
||||
f"Dynamic fee estimate: {estimated_fee_piconero / atomic_units} XMR (single transaction)",
|
||||
f"Dynamic fee estimate: {dynamic_fee_piconero / atomic_units} XMR, using {estimated_fee_piconero / atomic_units} XMR (with 10% margin)",
|
||||
)
|
||||
except Exception as e:
|
||||
# Fallback to hardcoded fee if RPC call fails
|
||||
estimated_fee_piconero = int(Decimal("0.0001") * atomic_units)
|
||||
# Fallback based on historical data: actual fees ~0.0000306 XMR
|
||||
# Use 3x multiplier for safety margin
|
||||
estimated_fee_piconero = int(
|
||||
Decimal("0.0000918") * atomic_units
|
||||
) # ~91,800,000 piconero (3x typical fee)
|
||||
log.error_with_context(
|
||||
"Failed to get dynamic fee estimate, using fallback", e
|
||||
f"Failed to get dynamic fee estimate, using 3x typical fee as fallback: {e}",
|
||||
e,
|
||||
)
|
||||
|
||||
# Calculate transfer amount: payment minus fee and reserve for pending refunds
|
||||
|
|
@ -2758,85 +2766,98 @@ def process_payment(
|
|||
env_request.dbsession, crypto_payment
|
||||
)
|
||||
|
||||
# EARLY DETECTION: Check for overpayments on existing received payments
|
||||
# Check if received payment has enough confirmations to become confirmed
|
||||
elif (
|
||||
payment_rescue
|
||||
and crypto_payment.invoice
|
||||
and crypto_payment.invoice.user
|
||||
and crypto_payment.status
|
||||
== CryptoPayment.STATUS_RECEIVED # Only check received status
|
||||
and received_amount_int
|
||||
> expected_amount_int # Check current received amount for overpayment
|
||||
crypto_payment.status == CryptoPayment.STATUS_RECEIVED
|
||||
and received_amount_int >= expected_amount_int # Has enough amount
|
||||
and early_min_confs
|
||||
>= int(
|
||||
crypto_payment.confirmations_required
|
||||
) # Wait for required confirmations
|
||||
) # Has enough confirmations
|
||||
):
|
||||
# Overpayment detected - check if it exceeds threshold
|
||||
coin_config = get_coin_config(crypto_payment.coin_type)
|
||||
atomic_units = coin_config["atomic_units"]
|
||||
received_crypto = Decimal(received_amount_int) / atomic_units
|
||||
expected_crypto = Decimal(expected_amount_int) / atomic_units
|
||||
|
||||
log.payment_info(
|
||||
crypto_payment,
|
||||
f"EARLY DETECTION - Potential overpayment: "
|
||||
f"received {received_crypto} {crypto_payment.coin_type}, expected {expected_crypto} {crypto_payment.coin_type} "
|
||||
f"({early_min_confs}/{crypto_payment.confirmations_required} confirmations) - checking threshold",
|
||||
)
|
||||
|
||||
# Check if overpayment exceeds 5% threshold using PaymentRescue logic
|
||||
refund_details = payment_rescue.handle_overpayment(
|
||||
crypto_payment,
|
||||
expected_crypto,
|
||||
received_crypto,
|
||||
crypto_payment.invoice.user,
|
||||
)
|
||||
|
||||
if refund_details:
|
||||
# Overpayment exceeds threshold - process refund
|
||||
# Check for exact payment first
|
||||
if received_amount_int == expected_amount_int:
|
||||
log.payment_info(
|
||||
crypto_payment,
|
||||
f"EARLY DETECTION - Overpayment threshold exceeded: {refund_details}",
|
||||
)
|
||||
|
||||
# Mark as confirmed with overpayment detected (refund pending)
|
||||
crypto_payment.status = CryptoPayment.STATUS_CONFIRMED_OVERPAY
|
||||
crypto_payment.refund_reason = refund_details["reason"]
|
||||
|
||||
# Try refund immediately since we have enough confirmations
|
||||
result = payment_rescue.execute_refund(
|
||||
refund_details, crypto_payment
|
||||
)
|
||||
if result["success"]:
|
||||
log.payment_info(
|
||||
crypto_payment, f"Excess refunded: TX {result['tx_hash']}"
|
||||
)
|
||||
# Mark as confirmed with overpayment refunded
|
||||
crypto_payment.status = (
|
||||
CryptoPayment.STATUS_CONFIRMED_OVERPAY_REFUNDED
|
||||
)
|
||||
crypto_payment.refund_tx_hash = result["tx_hash"]
|
||||
crypto_payment.refund_confirmations = 0 # Just sent
|
||||
|
||||
# Finalize invoice since core payment amount is sufficient
|
||||
finalize_invoice(env_request, crypto_payment, send_emails=True)
|
||||
|
||||
# Note: Restocking fee will be swept when refund is fully confirmed
|
||||
# to avoid double-sweeping before refund transaction is safely confirmed
|
||||
else:
|
||||
log.payment_error(
|
||||
crypto_payment,
|
||||
f"Refund failed for overpayment: {result['error']}",
|
||||
)
|
||||
else:
|
||||
# Overpayment within acceptable threshold - just confirm normally
|
||||
log.payment_info(
|
||||
crypto_payment,
|
||||
"EARLY DETECTION - Overpayment within 5% threshold - confirming normally",
|
||||
f"Exact payment confirmed with {early_min_confs}/{crypto_payment.confirmations_required} confirmations",
|
||||
)
|
||||
crypto_payment.status = CryptoPayment.STATUS_CONFIRMED
|
||||
finalize_invoice(env_request, crypto_payment, send_emails=True)
|
||||
# EARLY DETECTION: Check for overpayments on existing received payments
|
||||
elif (
|
||||
payment_rescue
|
||||
and crypto_payment.invoice
|
||||
and crypto_payment.invoice.user
|
||||
and received_amount_int > expected_amount_int # Overpayment
|
||||
):
|
||||
# Overpayment detected - check if it exceeds threshold
|
||||
coin_config = get_coin_config(crypto_payment.coin_type)
|
||||
atomic_units = coin_config["atomic_units"]
|
||||
received_crypto = Decimal(received_amount_int) / atomic_units
|
||||
expected_crypto = Decimal(expected_amount_int) / atomic_units
|
||||
|
||||
log.payment_info(
|
||||
crypto_payment,
|
||||
f"EARLY DETECTION - Potential overpayment: "
|
||||
f"received {received_crypto} {crypto_payment.coin_type}, expected {expected_crypto} {crypto_payment.coin_type} "
|
||||
f"({early_min_confs}/{crypto_payment.confirmations_required} confirmations) - checking threshold",
|
||||
)
|
||||
|
||||
# Check if overpayment exceeds 5% threshold using PaymentRescue logic
|
||||
refund_details = payment_rescue.handle_overpayment(
|
||||
crypto_payment,
|
||||
expected_crypto,
|
||||
received_crypto,
|
||||
crypto_payment.invoice.user,
|
||||
)
|
||||
|
||||
if refund_details:
|
||||
# Overpayment exceeds threshold - process refund
|
||||
log.payment_info(
|
||||
crypto_payment,
|
||||
f"EARLY DETECTION - Overpayment threshold exceeded: {refund_details}",
|
||||
)
|
||||
|
||||
# Mark as confirmed with overpayment detected (refund pending)
|
||||
crypto_payment.status = CryptoPayment.STATUS_CONFIRMED_OVERPAY
|
||||
crypto_payment.refund_reason = refund_details["reason"]
|
||||
|
||||
# Try refund immediately since we have enough confirmations
|
||||
result = payment_rescue.execute_refund(
|
||||
refund_details, crypto_payment
|
||||
)
|
||||
if result["success"]:
|
||||
log.payment_info(
|
||||
crypto_payment,
|
||||
f"Excess refunded: TX {result['tx_hash']}",
|
||||
)
|
||||
# Mark as confirmed with overpayment refunded
|
||||
crypto_payment.status = (
|
||||
CryptoPayment.STATUS_CONFIRMED_OVERPAY_REFUNDED
|
||||
)
|
||||
crypto_payment.refund_tx_hash = result["tx_hash"]
|
||||
crypto_payment.refund_confirmations = 0 # Just sent
|
||||
|
||||
# Finalize invoice since core payment amount is sufficient
|
||||
finalize_invoice(
|
||||
env_request, crypto_payment, send_emails=True
|
||||
)
|
||||
|
||||
# Note: Restocking fee will be swept when refund is fully confirmed
|
||||
# to avoid double-sweeping before refund transaction is safely confirmed
|
||||
else:
|
||||
log.payment_error(
|
||||
crypto_payment,
|
||||
f"Refund failed for overpayment: {result['error']}",
|
||||
)
|
||||
else:
|
||||
# Overpayment within acceptable threshold - just confirm normally
|
||||
log.payment_info(
|
||||
crypto_payment,
|
||||
"EARLY DETECTION - Overpayment within 5% threshold - confirming normally",
|
||||
)
|
||||
crypto_payment.status = CryptoPayment.STATUS_CONFIRMED
|
||||
finalize_invoice(env_request, crypto_payment, send_emails=True)
|
||||
|
||||
# Check for overpayment (exact match or overpaid) - for first payment
|
||||
elif (
|
||||
|
|
|
|||
|
|
@ -300,9 +300,22 @@ class DogecoinClient:
|
|||
"""Send Dogecoin to an address. Returns transaction ID."""
|
||||
return self._call("sendtoaddress", [address, amount, comment])
|
||||
|
||||
def sendmany(self, from_label: str, addresses_amounts: Dict[str, float]) -> str:
|
||||
"""Send to multiple addresses at once. More efficient for sweeping."""
|
||||
return self._call("sendmany", [from_label, addresses_amounts])
|
||||
def sendmany(self, from_label: str, addresses_amounts: Dict[str, float], minconf: int = 1, comment: str = "") -> str:
|
||||
"""Send to multiple addresses at once. More efficient for sweeping.
|
||||
|
||||
Args:
|
||||
from_label: Account label (use "" for default account)
|
||||
addresses_amounts: Dict mapping addresses to amounts
|
||||
minconf: Minimum confirmations (default: 1)
|
||||
comment: Transaction comment (optional)
|
||||
"""
|
||||
# Build params list - Dogecoin expects specific parameter order
|
||||
params = [from_label, addresses_amounts]
|
||||
if minconf != 1 or comment:
|
||||
params.append(minconf)
|
||||
if comment:
|
||||
params.append(comment)
|
||||
return self._call("sendmany", params)
|
||||
|
||||
# Blockchain Info
|
||||
|
||||
|
|
|
|||
|
|
@ -119,6 +119,7 @@ class PaymentRescue:
|
|||
"excess_amount": excess_amount,
|
||||
"refund_amount": refund_amount,
|
||||
"fee_amount": excess_amount - refund_amount,
|
||||
"payment_amount": expected_amount, # Shop should get the actual payment too!
|
||||
"reason": f"Overpayment exceeds {int(OVERPAYMENT_THRESHOLD_PERCENT * 100)}% threshold: received {received_amount} but expected {expected_amount}",
|
||||
}
|
||||
|
||||
|
|
@ -158,10 +159,14 @@ class PaymentRescue:
|
|||
def execute_refund(self, refund_details, payment=None):
|
||||
"""
|
||||
Execute the actual refund transaction.
|
||||
|
||||
Now implements multi-output transactions:
|
||||
- Customer gets refund minus fee
|
||||
- Shop owner gets the fee portion
|
||||
|
||||
Args:
|
||||
refund_details: dict with refund information
|
||||
payment: CryptoPayment object (needed for account_index)
|
||||
payment: CryptoPayment object (needed for account_index and shop_sweep_to_address)
|
||||
|
||||
Returns:
|
||||
dict with transaction details or raises exception
|
||||
|
|
@ -171,6 +176,9 @@ class PaymentRescue:
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
refund_amount_coin = refund_details["refund_amount"]
|
||||
fee_amount_coin = refund_details["fee_amount"]
|
||||
# For overpayments, also include the actual payment amount
|
||||
payment_amount_coin = refund_details.get("payment_amount", Decimal("0"))
|
||||
|
||||
# Get coin type and atomic units for proper logging
|
||||
coin_type = payment.coin_type if payment else "XMR"
|
||||
|
|
@ -179,6 +187,8 @@ class PaymentRescue:
|
|||
coin_config = get_coin_config(coin_type)
|
||||
atomic_units = int(coin_config["atomic_units"])
|
||||
refund_amount_atomic = int(refund_amount_coin * atomic_units)
|
||||
fee_amount_atomic = int(fee_amount_coin * atomic_units)
|
||||
payment_amount_atomic = int(payment_amount_coin * atomic_units)
|
||||
|
||||
atomic_unit_name = (
|
||||
"piconero"
|
||||
|
|
@ -186,11 +196,22 @@ class PaymentRescue:
|
|||
else "koinu" if coin_type == "DOGE" else "atomic units"
|
||||
)
|
||||
|
||||
# Get shop sweep address - we'll handle missing address with a clear error
|
||||
shop_sweep_address = payment.shop_sweep_to_address if payment else None
|
||||
|
||||
if payment:
|
||||
logger.info(f"Attempting to refund: {payment}")
|
||||
logger.info(
|
||||
f"Refund amount: {refund_amount_coin} {coin_type} ({refund_amount_atomic} {atomic_unit_name})"
|
||||
)
|
||||
logger.info(
|
||||
f"Fee amount to shop: {fee_amount_coin} {coin_type} ({fee_amount_atomic} {atomic_unit_name})"
|
||||
)
|
||||
if payment_amount_coin > 0:
|
||||
logger.info(
|
||||
f"Payment amount to shop: {payment_amount_coin} {coin_type} ({payment_amount_atomic} {atomic_unit_name})"
|
||||
)
|
||||
logger.info(f"Shop sweep address: {shop_sweep_address}")
|
||||
logger.info(f"Refund address: {refund_details['refund_address']}")
|
||||
logger.info(f"Refund reason: {refund_details['reason']}")
|
||||
else:
|
||||
|
|
@ -242,6 +263,7 @@ class PaymentRescue:
|
|||
logger.info("Checking Dogecoin wallet balance")
|
||||
balance_result = self.crypto_client.getbalance()
|
||||
logger.info(f"Dogecoin wallet balance: {balance_result} DOGE")
|
||||
|
||||
|
||||
if balance_result < refund_amount_coin:
|
||||
logger.warning(
|
||||
|
|
@ -253,14 +275,43 @@ class PaymentRescue:
|
|||
try:
|
||||
# Create coin-specific refund transaction
|
||||
if coin_type == "XMR":
|
||||
# Monero uses the transfer RPC with atomic units (piconero)
|
||||
# Monero always uses multi-output transfer
|
||||
if not shop_sweep_address:
|
||||
raise ValueError("Shop sweep address is required for refunds")
|
||||
|
||||
# Build destinations array
|
||||
destinations = [
|
||||
{
|
||||
"address": refund_details["refund_address"],
|
||||
"amount": refund_amount_atomic,
|
||||
}
|
||||
]
|
||||
|
||||
# For overpayments, combine payment amount and fee into single shop output
|
||||
shop_amount_atomic = fee_amount_atomic
|
||||
if payment_amount_atomic > 0:
|
||||
shop_amount_atomic += payment_amount_atomic
|
||||
|
||||
destinations.append({
|
||||
"address": shop_sweep_address,
|
||||
"amount": shop_amount_atomic,
|
||||
})
|
||||
|
||||
if payment_amount_atomic > 0:
|
||||
logger.info(
|
||||
f"Monero multi-output transfer: "
|
||||
f"{refund_amount_atomic} piconero to customer, "
|
||||
f"{shop_amount_atomic} piconero to shop (payment: {payment_amount_atomic} + fee: {fee_amount_atomic})"
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"Monero multi-output transfer: "
|
||||
f"{refund_amount_atomic} piconero to customer, "
|
||||
f"{fee_amount_atomic} piconero to shop"
|
||||
)
|
||||
|
||||
transfer_params = {
|
||||
"destinations": [
|
||||
{
|
||||
"address": refund_details["refund_address"],
|
||||
"amount": refund_amount_atomic,
|
||||
}
|
||||
],
|
||||
"destinations": destinations,
|
||||
"account_index": account_index,
|
||||
"get_tx_key": True,
|
||||
"do_not_relay": False,
|
||||
|
|
@ -270,23 +321,148 @@ class PaymentRescue:
|
|||
tx_result = self.crypto_client._call("transfer", transfer_params)
|
||||
|
||||
elif coin_type == "DOGE":
|
||||
# Dogecoin uses sendtoaddress RPC with coin amounts (not atomic units)
|
||||
# Dogecoin always uses sendmany for multi-output
|
||||
if not shop_sweep_address:
|
||||
raise ValueError("Shop sweep address is required for refunds")
|
||||
|
||||
# Round to 8 decimal places to match DOGE precision requirements
|
||||
refund_amount_doge = round(float(refund_amount_coin), 8)
|
||||
logger.info(
|
||||
f"Calling Dogecoin sendtoaddress: {refund_amount_doge} DOGE to {refund_details['refund_address']}"
|
||||
)
|
||||
logger.info(
|
||||
f"Dogecoin client config - URL: {self.crypto_client.rpc_url}, User: {self.crypto_client.rpc_user}"
|
||||
)
|
||||
logger.info(
|
||||
f"About to call sendtoaddress with params: address={refund_details['refund_address']}, amount={refund_amount_doge}"
|
||||
)
|
||||
tx_hash = self.crypto_client.sendtoaddress(
|
||||
refund_details["refund_address"],
|
||||
refund_amount_doge,
|
||||
f"Overpayment refund for {refund_details['payment_id']}",
|
||||
)
|
||||
fee_amount_doge = round(float(fee_amount_coin), 8)
|
||||
payment_amount_doge = round(float(payment_amount_coin), 8) if payment_amount_coin > 0 else 0
|
||||
|
||||
# Initial outputs - will be adjusted below if needed
|
||||
outputs = {}
|
||||
|
||||
# For overpayments, combine payment amount and fee into single shop output
|
||||
shop_amount_doge = fee_amount_doge
|
||||
if payment_amount_doge > 0:
|
||||
shop_amount_doge = round(payment_amount_doge + fee_amount_doge, 8)
|
||||
|
||||
# Account for network fee by reducing amounts proportionally
|
||||
# Dogecoin sendmany adds fee on top of outputs, so we need to leave room
|
||||
total_output = refund_amount_doge + shop_amount_doge
|
||||
# Use conservative fee estimate to ensure we have enough room
|
||||
# This accounts for fee calculation differences and rounding
|
||||
estimated_fee = 0.01 # Very conservative to avoid insufficient funds errors
|
||||
|
||||
# Check if we need to adjust for fees
|
||||
if total_output + estimated_fee > balance_result:
|
||||
# Calculate how much we need to reduce
|
||||
shortage = (total_output + estimated_fee) - balance_result
|
||||
logger.info(f"Need to reduce outputs by {shortage} DOGE to account for network fee")
|
||||
|
||||
# Reduce both amounts proportionally
|
||||
refund_ratio = refund_amount_doge / total_output
|
||||
shop_ratio = shop_amount_doge / total_output
|
||||
|
||||
# Reduce and round conservatively (round down to avoid exceeding balance)
|
||||
import math
|
||||
refund_reduction = shortage * refund_ratio
|
||||
shop_reduction = shortage * shop_ratio
|
||||
|
||||
# Round down to 3 decimal places for cleaner amounts
|
||||
refund_amount_doge = math.floor((refund_amount_doge - refund_reduction) * 1000) / 1000
|
||||
shop_amount_doge = math.floor((shop_amount_doge - shop_reduction) * 1000) / 1000
|
||||
|
||||
logger.info(f"Adjusted amounts - Customer: {refund_amount_doge} DOGE, Shop: {shop_amount_doge} DOGE")
|
||||
|
||||
# Build final outputs with adjusted amounts
|
||||
outputs[refund_details["refund_address"]] = refund_amount_doge
|
||||
outputs[shop_sweep_address] = shop_amount_doge
|
||||
|
||||
if payment_amount_doge > 0:
|
||||
logger.info(
|
||||
f"Calling Dogecoin sendmany with multi-output: "
|
||||
f"{refund_amount_doge} DOGE to customer, "
|
||||
f"{shop_amount_doge} DOGE to shop (payment: {payment_amount_doge} + fee: {fee_amount_doge})"
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"Calling Dogecoin sendmany with multi-output: "
|
||||
f"{refund_amount_doge} DOGE to customer, "
|
||||
f"{fee_amount_doge} DOGE to shop"
|
||||
)
|
||||
|
||||
# First check if we have enough balance including fees
|
||||
# Already accounted for in shop_amount_doge adjustment above
|
||||
total_needed = refund_amount_doge + shop_amount_doge
|
||||
|
||||
if balance_result < total_needed:
|
||||
logger.error(
|
||||
f"Insufficient balance for refund: "
|
||||
f"have {balance_result} DOGE, need {total_needed} DOGE"
|
||||
)
|
||||
raise ValueError(f"Insufficient balance: have {balance_result}, need {total_needed}")
|
||||
|
||||
# Get the account label for the payment address
|
||||
# First, try to get account from the address
|
||||
from_account = ""
|
||||
try:
|
||||
# Try newer method first (getaddressinfo)
|
||||
addr_info = self.crypto_client._call("getaddressinfo", [payment.address])
|
||||
if addr_info and "labels" in addr_info and addr_info["labels"]:
|
||||
from_account = addr_info["labels"][0]
|
||||
logger.info(f"Using account label from getaddressinfo: {from_account}")
|
||||
except Exception as e:
|
||||
# Fall back to older method (getaccount)
|
||||
try:
|
||||
from_account = self.crypto_client._call("getaccount", [payment.address])
|
||||
if from_account:
|
||||
logger.info(f"Using account from getaccount: {from_account}")
|
||||
except Exception as e2:
|
||||
# Last resort: try to find in unspent outputs
|
||||
try:
|
||||
unspent = self.crypto_client._call("listunspent", [])
|
||||
for utxo in unspent:
|
||||
if utxo.get("address") == payment.address:
|
||||
from_account = utxo.get("account", "")
|
||||
if from_account:
|
||||
logger.info(f"Using account from unspent output: {from_account}")
|
||||
break
|
||||
except Exception as e3:
|
||||
logger.warning(f"Could not get account for address: {e}, {e2}, {e3}")
|
||||
from_account = ""
|
||||
|
||||
# sendmany(fromaccount, {address:amount,...}, minconf=1)
|
||||
# Don't include comment - it causes issues with many Dogecoin versions
|
||||
|
||||
# Ensure outputs are properly formatted - sometimes decimal precision issues cause 500 errors
|
||||
# Convert outputs to ensure they're plain floats, not Decimal objects
|
||||
clean_outputs = {}
|
||||
for addr, amount in outputs.items():
|
||||
clean_outputs[addr] = float(amount)
|
||||
|
||||
# If account has special characters, try different approaches
|
||||
logger.info(f"Attempting sendmany with account='{from_account}', outputs={clean_outputs}")
|
||||
|
||||
# Try different sendmany approaches until one works
|
||||
tx_hash = None
|
||||
attempts = [
|
||||
# 1. Try with the actual account first (where the funds are)
|
||||
("actual account", lambda: self.crypto_client.sendmany(from_account, clean_outputs, 1)),
|
||||
# 2. Try with empty account (default)
|
||||
("empty account", lambda: self.crypto_client.sendmany("", clean_outputs, 1)),
|
||||
# 3. Try actual account without minconf
|
||||
("actual account without minconf", lambda: self.crypto_client.sendmany(from_account, clean_outputs)),
|
||||
# 4. Try with explicit default account "*"
|
||||
("default account '*'", lambda: self.crypto_client.sendmany("*", clean_outputs, 1)),
|
||||
]
|
||||
|
||||
last_error = None
|
||||
for attempt_name, attempt_func in attempts:
|
||||
try:
|
||||
logger.info(f"Trying sendmany with {attempt_name}")
|
||||
tx_hash = attempt_func()
|
||||
logger.info(f"Success with {attempt_name}: {tx_hash}")
|
||||
break
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
logger.warning(f"Failed with {attempt_name}: {e}")
|
||||
continue
|
||||
|
||||
if not tx_hash and last_error:
|
||||
raise last_error
|
||||
|
||||
tx_result = {"tx_hash": tx_hash}
|
||||
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -288,7 +288,7 @@ class Invoice(RBase, Base):
|
|||
@property
|
||||
def is_paid(self):
|
||||
"""Check if this invoice has been successfully paid."""
|
||||
return self.payment_status in ["confirmed", "confirmed_overpaid", "paid"]
|
||||
return self.payment_status in ["confirmed", "confirmed-overpay", "confirmed-complete", "paid"]
|
||||
|
||||
|
||||
def get_invoice_by_id(dbsession, invoice_id):
|
||||
|
|
@ -354,7 +354,7 @@ def delete_invoice_by_id(dbsession, invoice_id):
|
|||
}
|
||||
|
||||
# Guard: Don't delete successful payments - those are legitimate transactions
|
||||
successful_statuses = ["confirmed", "confirmed-overpaid"]
|
||||
successful_statuses = ["confirmed", "confirmed-overpay", "confirmed-complete"]
|
||||
if crypto_payment.status in successful_statuses:
|
||||
return {
|
||||
"success": False,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1154,8 +1154,8 @@ class AutoSweepTests(unittest.TestCase):
|
|||
|
||||
# Check sweep tracking fields were set
|
||||
self.assertEqual(
|
||||
payment.swept_amount, 490000000000
|
||||
) # 0.5 XMR - 0.01 XMR fee = 0.49 XMR
|
||||
payment.swept_amount, 489000000000
|
||||
) # 0.5 XMR - (0.01 XMR fee * 1.1 margin)
|
||||
self.assertEqual(payment.swept_tx_hash, "transfer_tx_123")
|
||||
self.assertIsNotNone(payment.swept_timestamp)
|
||||
|
||||
|
|
@ -1230,7 +1230,7 @@ class AutoSweepTests(unittest.TestCase):
|
|||
|
||||
# Verify that swept_tx_hash was set (from transfer, not sweep)
|
||||
self.assertEqual(payment.swept_tx_hash, "transfer_tx_123")
|
||||
self.assertEqual(payment.swept_amount, 495000000000) # 0.5 XMR - 0.005 XMR fee
|
||||
self.assertEqual(payment.swept_amount, 494500000000) # 0.5 XMR - (0.005 XMR fee * 1.1 margin)
|
||||
self.assertEqual(payment.swept_network_fee, 5000000000)
|
||||
self.assertIsNotNone(payment.swept_timestamp)
|
||||
|
||||
|
|
@ -3101,8 +3101,8 @@ class RefundTypeTests(unittest.TestCase):
|
|||
# Verify out of stock refund is larger
|
||||
self.assertGreater(full_refund, normal_refund)
|
||||
|
||||
def test_doge_refund_execution_uses_correct_rpc(self):
|
||||
"""Test DOGE refund execution uses sendtoaddress RPC."""
|
||||
def test_doge_refund_execution_uses_sendmany_rpc(self):
|
||||
"""Test DOGE refund execution uses sendmany RPC for multi-output."""
|
||||
from make_post_sell.lib.crypto_watcher.crypto_payment_rescue import (
|
||||
PaymentRescue,
|
||||
)
|
||||
|
|
@ -3113,9 +3113,10 @@ class RefundTypeTests(unittest.TestCase):
|
|||
doge_payment.coin_type = "DOGE"
|
||||
doge_payment.current_confirmations = 2 # Sufficient confirmations
|
||||
doge_payment.account_index = None # DOGE doesn't use account_index
|
||||
doge_payment.shop_sweep_to_address = "DShopSweepAddress789" # Shop address for fee
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.sendtoaddress.return_value = "doge-tx-hash-123"
|
||||
mock_client._call.return_value = "doge-tx-hash-123"
|
||||
mock_client.getbalance.return_value = 100.0 # Sufficient DOGE balance
|
||||
|
||||
rescue = PaymentRescue(self.mock_dbsession, mock_client)
|
||||
|
|
@ -3131,19 +3132,22 @@ class RefundTypeTests(unittest.TestCase):
|
|||
|
||||
result = rescue.execute_refund(refund_details, doge_payment)
|
||||
|
||||
# Verify DOGE-specific RPC was called
|
||||
# mock_client.sendtoaddress.assert_called_once_with("DTestAddress123", 4.55, "Overpayment refund for doge-payment-123")
|
||||
mock_client.sendtoaddress.assert_called_once_with(
|
||||
"DTestAddress123", 4.55, "Overpayment refund for doge-payment-123"
|
||||
)
|
||||
# Verify sendmany was called with multi-output
|
||||
mock_client._call.assert_called_once()
|
||||
call_args = mock_client._call.call_args[0]
|
||||
self.assertEqual(call_args[0], "sendmany")
|
||||
self.assertEqual(call_args[1][0], "") # fromaccount
|
||||
outputs = call_args[1][1]
|
||||
self.assertEqual(outputs["DTestAddress123"], 4.55) # Customer refund
|
||||
self.assertEqual(outputs["DShopSweepAddress789"], 0.45) # Shop fee
|
||||
mock_client.getbalance.assert_called_once() # Balance check
|
||||
|
||||
# Verify successful result
|
||||
self.assertTrue(result["success"])
|
||||
self.assertEqual(result["tx_hash"], "doge-tx-hash-123")
|
||||
|
||||
def test_xmr_refund_execution_uses_correct_rpc(self):
|
||||
"""Test XMR refund execution uses transfer RPC with atomic units."""
|
||||
def test_xmr_refund_execution_uses_multi_output_transfer(self):
|
||||
"""Test XMR refund execution uses transfer RPC with multi-output."""
|
||||
from make_post_sell.lib.crypto_watcher.crypto_payment_rescue import (
|
||||
PaymentRescue,
|
||||
)
|
||||
|
|
@ -3154,6 +3158,7 @@ class RefundTypeTests(unittest.TestCase):
|
|||
xmr_payment.coin_type = "XMR"
|
||||
xmr_payment.current_confirmations = 10 # Sufficient confirmations
|
||||
xmr_payment.account_index = 5
|
||||
xmr_payment.shop_sweep_to_address = "4ShopSweepAddressXMR789" # Shop address for fee
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client._call.side_effect = [
|
||||
|
|
@ -3186,11 +3191,18 @@ class RefundTypeTests(unittest.TestCase):
|
|||
self.assertEqual(expected_transfer_call[0][0], "transfer")
|
||||
transfer_params = expected_transfer_call[0][1]
|
||||
self.assertEqual(transfer_params["account_index"], 5)
|
||||
self.assertEqual(len(transfer_params["destinations"]), 2) # Two destinations
|
||||
self.assertEqual(
|
||||
transfer_params["destinations"][0]["address"], "4TestXMRAddress123"
|
||||
)
|
||||
# 1.82 XMR = 1.82 * 10^12 = 1,820,000,000,000 piconero
|
||||
self.assertEqual(transfer_params["destinations"][0]["amount"], 1820000000000)
|
||||
# Shop fee destination
|
||||
self.assertEqual(
|
||||
transfer_params["destinations"][1]["address"], "4ShopSweepAddressXMR789"
|
||||
)
|
||||
# 0.18 XMR = 0.18 * 10^12 = 180,000,000,000 piconero
|
||||
self.assertEqual(transfer_params["destinations"][1]["amount"], 180000000000)
|
||||
|
||||
# Verify successful result
|
||||
self.assertTrue(result["success"])
|
||||
|
|
@ -3267,6 +3279,7 @@ class RefundTypeTests(unittest.TestCase):
|
|||
unsupported_payment.id = "unsupported-payment"
|
||||
unsupported_payment.coin_type = "UNSUPPORTED"
|
||||
unsupported_payment.current_confirmations = 10
|
||||
unsupported_payment.shop_sweep_to_address = "UNSUPPORTEDshopAddress"
|
||||
|
||||
mock_client = MagicMock()
|
||||
rescue = PaymentRescue(self.mock_dbsession, mock_client)
|
||||
|
|
@ -3274,7 +3287,8 @@ class RefundTypeTests(unittest.TestCase):
|
|||
refund_details = {
|
||||
"payment_id": "unsupported-payment",
|
||||
"refund_address": "unsupported-address",
|
||||
"refund_amount": Decimal("1.0"),
|
||||
"refund_amount": Decimal("0.91"),
|
||||
"fee_amount": Decimal("0.09"),
|
||||
"reason": "Test unsupported coin",
|
||||
}
|
||||
|
||||
|
|
@ -3296,9 +3310,10 @@ class RefundTypeTests(unittest.TestCase):
|
|||
doge_payment.coin_type = "DOGE"
|
||||
doge_payment.current_confirmations = 2
|
||||
doge_payment.account_index = None
|
||||
doge_payment.shop_sweep_to_address = "DShopPrecisionAddress"
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.sendtoaddress.return_value = "doge-precision-tx-123"
|
||||
mock_client._call.return_value = "doge-precision-tx-123"
|
||||
mock_client.getbalance.return_value = 100.0
|
||||
|
||||
rescue = PaymentRescue(self.mock_dbsession, mock_client)
|
||||
|
|
@ -3317,14 +3332,18 @@ class RefundTypeTests(unittest.TestCase):
|
|||
|
||||
# Should succeed
|
||||
self.assertTrue(result["success"])
|
||||
# Verify successful result
|
||||
self.assertTrue(result["success"])
|
||||
self.assertEqual(result["tx_hash"], "doge-precision-tx-123")
|
||||
|
||||
# Verify sendtoaddress was called with rounded amount (8 decimal places)
|
||||
mock_client.sendtoaddress.assert_called_once_with(
|
||||
"DPrecisionTest123",
|
||||
1.03859035, # Rounded to 8 decimal places
|
||||
"Overpayment refund for doge-precision-123",
|
||||
)
|
||||
|
||||
# Verify sendmany was called with properly rounded amounts
|
||||
mock_client._call.assert_called_once()
|
||||
call_args = mock_client._call.call_args[0]
|
||||
self.assertEqual(call_args[0], "sendmany")
|
||||
outputs = call_args[1][1]
|
||||
# Verify amounts are rounded to 8 decimal places
|
||||
self.assertEqual(outputs["DPrecisionTest123"], 1.03859035) # Rounded from 1.0385903528
|
||||
self.assertEqual(outputs["DShopPrecisionAddress"], 0.11429455) # Rounded from 0.1142945472
|
||||
|
||||
def test_xmr_refund_amount_precision_handling(self):
|
||||
"""Test XMR refund execution handles high precision amounts correctly."""
|
||||
|
|
@ -3794,8 +3813,8 @@ class PaymentConfirmationOrderTests(unittest.TestCase):
|
|||
)
|
||||
transfer_params = real_transfer_calls[0][0][1]
|
||||
|
||||
# The amount should be reduced by fee: 2500000000000 - 100000000 = 2499900000000
|
||||
expected_amount_after_fee = 2500000000000 - 100000000
|
||||
# The amount should be reduced by fee with 10% margin: 2500000000000 - (100000000 * 1.1) = 2499890000000
|
||||
expected_amount_after_fee = 2500000000000 - 110000000
|
||||
self.assertEqual(
|
||||
transfer_params["destinations"][0]["amount"], expected_amount_after_fee
|
||||
)
|
||||
|
|
@ -3885,8 +3904,8 @@ class PaymentConfirmationOrderTests(unittest.TestCase):
|
|||
)
|
||||
transfer_params = real_transfer_calls[0][0][1]
|
||||
|
||||
# Amount should be reduced by fee: 2500000000000 - 100000000 = 2499900000000
|
||||
expected_amount_after_fee = 2500000000000 - 100000000
|
||||
# Amount should be reduced by fee with 10% margin: 2500000000000 - (100000000 * 1.1) = 2499890000000
|
||||
expected_amount_after_fee = 2500000000000 - 110000000
|
||||
self.assertEqual(
|
||||
transfer_params["destinations"][0]["amount"], expected_amount_after_fee
|
||||
)
|
||||
|
|
@ -4005,8 +4024,8 @@ class PaymentConfirmationOrderTests(unittest.TestCase):
|
|||
)
|
||||
transfer_params = real_transfer_calls[0][0][1]
|
||||
|
||||
# The amount should be expected amount minus fee: 2000000000000 - 100000000 = 1999900000000
|
||||
expected_amount_after_fee = 2000000000000 - 100000000
|
||||
# The amount should be expected amount minus fee with 10% margin: 2000000000000 - (100000000 * 1.1) = 1999890000000
|
||||
expected_amount_after_fee = 2000000000000 - 110000000
|
||||
self.assertEqual(
|
||||
transfer_params["destinations"][0]["amount"], expected_amount_after_fee
|
||||
)
|
||||
|
|
@ -4177,8 +4196,8 @@ class PaymentConfirmationOrderTests(unittest.TestCase):
|
|||
)
|
||||
transfer_params = real_transfer_calls[0][0][1]
|
||||
|
||||
# The amount should be reduced by fee: 987654321000 - 100000000 = 987554321000
|
||||
expected_amount_after_fee = 987654321000 - 100000000
|
||||
# The amount should be reduced by fee with 10% margin: 987654321000 - (100000000 * 1.1) = 987544321000
|
||||
expected_amount_after_fee = 987654321000 - 110000000
|
||||
self.assertEqual(
|
||||
transfer_params["destinations"][0]["amount"], expected_amount_after_fee
|
||||
)
|
||||
|
|
|
|||
226
make_post_sell/tests/test_multi_output_refunds.py
Normal file
226
make_post_sell/tests/test_multi_output_refunds.py
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
"""
|
||||
Tests for multi-output refunds functionality.
|
||||
|
||||
Tests that refunds can be sent to multiple addresses in a single transaction:
|
||||
- Customer gets refund minus fee
|
||||
- Shop owner gets the fee portion
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
from decimal import Decimal
|
||||
import uuid
|
||||
|
||||
from ..lib.crypto_watcher.crypto_payment_rescue import PaymentRescue, RESTOCKING_FEE_PERCENT
|
||||
from ..models.crypto_payment import CryptoPayment
|
||||
|
||||
|
||||
class TestMultiOutputRefunds(unittest.TestCase):
|
||||
"""Test multi-output refund functionality."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures."""
|
||||
self.mock_dbsession = MagicMock()
|
||||
self.mock_client = MagicMock()
|
||||
self.rescue = PaymentRescue(self.mock_dbsession, self.mock_client)
|
||||
|
||||
# Create a mock payment with shop sweep address
|
||||
self.payment = MagicMock(spec=CryptoPayment)
|
||||
self.payment.id = uuid.uuid4()
|
||||
self.payment.coin_type = "DOGE"
|
||||
self.payment.account_index = 0
|
||||
self.payment.current_confirmations = 10
|
||||
self.payment.shop_sweep_to_address = "DShopSweepAddressTest123"
|
||||
self.payment.__str__.return_value = f"Payment {self.payment.id}"
|
||||
|
||||
def test_doge_multi_output_refund(self):
|
||||
"""Test DOGE refund with multi-output (customer + shop)."""
|
||||
# Set up refund details
|
||||
received_amount = Decimal("100.0") # 100 DOGE received
|
||||
fee_amount = received_amount * RESTOCKING_FEE_PERCENT # 9 DOGE fee
|
||||
refund_amount = received_amount - fee_amount # 91 DOGE refund
|
||||
|
||||
refund_details = {
|
||||
"type": "overpayment",
|
||||
"payment_id": self.payment.id,
|
||||
"refund_address": "DCustomerRefundAddress123",
|
||||
"received_amount": received_amount,
|
||||
"refund_amount": refund_amount,
|
||||
"fee_amount": fee_amount,
|
||||
"reason": "Test overpayment refund"
|
||||
}
|
||||
|
||||
# Mock the sendmany call
|
||||
self.mock_client._call.return_value = "test_tx_hash_123"
|
||||
|
||||
# Execute refund
|
||||
result = self.rescue.execute_refund(refund_details, self.payment)
|
||||
|
||||
# Verify multi-output sendmany was called
|
||||
self.mock_client._call.assert_called_once()
|
||||
call_args = self.mock_client._call.call_args[0]
|
||||
|
||||
self.assertEqual(call_args[0], "sendmany")
|
||||
self.assertEqual(call_args[1][0], "") # fromaccount
|
||||
|
||||
# Check outputs
|
||||
outputs = call_args[1][1]
|
||||
self.assertEqual(len(outputs), 2) # Two outputs
|
||||
self.assertAlmostEqual(outputs["DCustomerRefundAddress123"], 91.0, places=8)
|
||||
self.assertAlmostEqual(outputs["DShopSweepAddressTest123"], 9.0, places=8)
|
||||
|
||||
# Verify result
|
||||
self.assertTrue(result["success"])
|
||||
self.assertEqual(result["tx_hash"], "test_tx_hash_123")
|
||||
self.assertEqual(result["fee_charged"], fee_amount)
|
||||
|
||||
def test_xmr_multi_output_refund(self):
|
||||
"""Test XMR refund with multi-output (customer + shop)."""
|
||||
# Set up payment for XMR
|
||||
self.payment.coin_type = "XMR"
|
||||
|
||||
# Set up refund details
|
||||
received_amount = Decimal("1.0") # 1 XMR received
|
||||
fee_amount = received_amount * RESTOCKING_FEE_PERCENT # 0.09 XMR fee
|
||||
refund_amount = received_amount - fee_amount # 0.91 XMR refund
|
||||
|
||||
refund_details = {
|
||||
"type": "overpayment",
|
||||
"payment_id": self.payment.id,
|
||||
"refund_address": "4CustomerRefundAddressXMR123",
|
||||
"received_amount": received_amount,
|
||||
"refund_amount": refund_amount,
|
||||
"fee_amount": fee_amount,
|
||||
"reason": "Test overpayment refund"
|
||||
}
|
||||
|
||||
# Mock the RPC calls
|
||||
self.mock_client._call.side_effect = [
|
||||
# get_balance call
|
||||
{"balance": 2000000000000, "unlocked_balance": 2000000000000},
|
||||
# transfer call
|
||||
{"tx_hash": "xmr_test_tx_hash_456"}
|
||||
]
|
||||
|
||||
# Execute refund
|
||||
result = self.rescue.execute_refund(refund_details, self.payment)
|
||||
|
||||
# Find the transfer call
|
||||
transfer_call = None
|
||||
for call in self.mock_client._call.call_args_list:
|
||||
if call[0][0] == "transfer":
|
||||
transfer_call = call
|
||||
break
|
||||
|
||||
self.assertIsNotNone(transfer_call)
|
||||
transfer_params = transfer_call[0][1]
|
||||
|
||||
# Check destinations
|
||||
destinations = transfer_params["destinations"]
|
||||
self.assertEqual(len(destinations), 2) # Two destinations
|
||||
|
||||
# Customer refund destination
|
||||
self.assertEqual(destinations[0]["address"], "4CustomerRefundAddressXMR123")
|
||||
self.assertEqual(destinations[0]["amount"], 910000000000) # 0.91 XMR in piconero
|
||||
|
||||
# Shop fee destination
|
||||
self.assertEqual(destinations[1]["address"], "DShopSweepAddressTest123")
|
||||
self.assertEqual(destinations[1]["amount"], 90000000000) # 0.09 XMR in piconero
|
||||
|
||||
# Verify result
|
||||
self.assertTrue(result["success"])
|
||||
self.assertEqual(result["tx_hash"], "xmr_test_tx_hash_456")
|
||||
self.assertEqual(result["fee_charged"], fee_amount)
|
||||
|
||||
def test_refund_no_shop_address_fails(self):
|
||||
"""Test refund fails when shop has no sweep address."""
|
||||
# Remove shop sweep address
|
||||
self.payment.shop_sweep_to_address = None
|
||||
|
||||
# Set up refund details
|
||||
received_amount = Decimal("100.0")
|
||||
fee_amount = received_amount * RESTOCKING_FEE_PERCENT
|
||||
refund_amount = received_amount - fee_amount
|
||||
|
||||
refund_details = {
|
||||
"type": "overpayment",
|
||||
"payment_id": self.payment.id,
|
||||
"refund_address": "DCustomerRefundAddress123",
|
||||
"received_amount": received_amount,
|
||||
"refund_amount": refund_amount,
|
||||
"fee_amount": fee_amount,
|
||||
"reason": "Test overpayment refund"
|
||||
}
|
||||
|
||||
# Execute refund
|
||||
result = self.rescue.execute_refund(refund_details, self.payment)
|
||||
|
||||
# Verify refund failed
|
||||
self.assertFalse(result["success"])
|
||||
self.assertIn("Shop sweep address is required", result["error"])
|
||||
|
||||
# Verify no transaction was attempted
|
||||
self.mock_client._call.assert_not_called()
|
||||
self.mock_client.sendtoaddress.assert_not_called()
|
||||
|
||||
def test_refund_zero_fee_still_multi_output(self):
|
||||
"""Test multi-output is used even when fee is zero."""
|
||||
# Set up refund with zero fee (shouldn't happen but test edge case)
|
||||
refund_details = {
|
||||
"type": "overpayment",
|
||||
"payment_id": self.payment.id,
|
||||
"refund_address": "DCustomerRefundAddress123",
|
||||
"received_amount": Decimal("100.0"),
|
||||
"refund_amount": Decimal("100.0"),
|
||||
"fee_amount": Decimal("0"),
|
||||
"reason": "Test refund with zero fee"
|
||||
}
|
||||
|
||||
# Mock the sendmany call
|
||||
self.mock_client._call.return_value = "zero_fee_tx_123"
|
||||
|
||||
# Execute refund
|
||||
result = self.rescue.execute_refund(refund_details, self.payment)
|
||||
|
||||
# Verify multi-output sendmany was still used
|
||||
self.mock_client._call.assert_called_once()
|
||||
call_args = self.mock_client._call.call_args[0]
|
||||
self.assertEqual(call_args[0], "sendmany")
|
||||
outputs = call_args[1][1]
|
||||
self.assertEqual(outputs["DCustomerRefundAddress123"], 100.0) # Full refund
|
||||
self.assertEqual(outputs["DShopSweepAddressTest123"], 0.0) # Zero fee
|
||||
|
||||
# Verify result
|
||||
self.assertTrue(result["success"])
|
||||
self.assertEqual(result["tx_hash"], "zero_fee_tx_123")
|
||||
|
||||
def test_insufficient_confirmations(self):
|
||||
"""Test refund is delayed when insufficient confirmations."""
|
||||
# Set insufficient confirmations
|
||||
self.payment.current_confirmations = 1
|
||||
|
||||
refund_details = {
|
||||
"type": "overpayment",
|
||||
"payment_id": self.payment.id,
|
||||
"refund_address": "DCustomerRefundAddress123",
|
||||
"received_amount": Decimal("100.0"),
|
||||
"refund_amount": Decimal("91.0"),
|
||||
"fee_amount": Decimal("9.0"),
|
||||
"reason": "Test refund"
|
||||
}
|
||||
|
||||
# Execute refund
|
||||
result = self.rescue.execute_refund(refund_details, self.payment)
|
||||
|
||||
# Verify refund was delayed
|
||||
self.assertFalse(result["success"])
|
||||
self.assertIn("confirmations", result["error"])
|
||||
self.assertEqual(result["confirmations_needed"], 1) # Need 1 more confirmation
|
||||
|
||||
# Verify no transaction was attempted
|
||||
self.mock_client._call.assert_not_called()
|
||||
self.mock_client.sendtoaddress.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -836,7 +836,7 @@ def crypto_xmr_status(request):
|
|||
)
|
||||
else:
|
||||
request.session.flash(("Purchase completed!", "success"))
|
||||
elif current_status == "confirmed-overpaid":
|
||||
elif current_status == "confirmed-overpay":
|
||||
request.session.flash(
|
||||
("Purchase completed! Overpayment will be refunded.", "success")
|
||||
)
|
||||
|
|
@ -872,7 +872,7 @@ def crypto_xmr_status(request):
|
|||
|
||||
# Add smart redirect URL when payment is confirmed (any confirmed status)
|
||||
if (
|
||||
crypto_payment.status in ["confirmed", "confirmed-overpaid"]
|
||||
crypto_payment.status in ["confirmed", "confirmed-complete", "confirmed-overpay"]
|
||||
and crypto_payment.invoice
|
||||
):
|
||||
from ..views.cart import get_smart_purchase_redirect_url
|
||||
|
|
@ -932,7 +932,7 @@ def crypto_doge_status(request):
|
|||
)
|
||||
else:
|
||||
request.session.flash(("Purchase completed!", "success"))
|
||||
elif current_status == "confirmed-overpaid":
|
||||
elif current_status == "confirmed-overpay":
|
||||
request.session.flash(
|
||||
("Purchase completed! Overpayment will be refunded.", "success")
|
||||
)
|
||||
|
|
@ -971,7 +971,7 @@ def crypto_doge_status(request):
|
|||
|
||||
# Add smart redirect URL when payment is confirmed (any confirmed status)
|
||||
if (
|
||||
crypto_payment.status in ["confirmed", "confirmed-overpaid"]
|
||||
crypto_payment.status in ["confirmed", "confirmed-complete", "confirmed-overpay"]
|
||||
and crypto_payment.invoice
|
||||
):
|
||||
from ..views.cart import get_smart_purchase_redirect_url
|
||||
|
|
@ -1205,11 +1205,13 @@ def get_payment_status_info(status):
|
|||
"pending": {"label": "Pending Payment", "color": "#ffc107"},
|
||||
"received": {"label": "Payment Received", "color": "#17a2b8"},
|
||||
"confirmed": {"label": "✓ Confirmed", "color": "#28a745"},
|
||||
"confirmed-overpaid": {"label": "✓ Confirmed (Overpaid)", "color": "#28a745"},
|
||||
"confirmed-complete": {"label": "✓ Confirmed (Complete)", "color": "#28a745"},
|
||||
"confirmed-overpay": {"label": "✓ Confirmed (Overpaid)", "color": "#28a745"},
|
||||
"expired": {"label": "Expired", "color": "#6c757d"},
|
||||
"expired-refunded": {"label": "Expired - Refunded", "color": "#fd7e14"},
|
||||
"expired-refunded-complete": {
|
||||
"label": "✓ Expired - Refunded",
|
||||
"expired": {"label": "Expired", "color": "#6c757d"},
|
||||
"latepay-refunded": {"label": "Late Payment - Refunded", "color": "#fd7e14"},
|
||||
"latepay-refunded-complete": {
|
||||
"label": "✓ Late Payment - Refunded",
|
||||
"color": "#fd7e14",
|
||||
},
|
||||
"underpaid-refunded": {"label": "Underpaid - Refunded", "color": "#fd7e14"},
|
||||
|
|
@ -1217,7 +1219,11 @@ def get_payment_status_info(status):
|
|||
"label": "✓ Underpaid - Refunded",
|
||||
"color": "#fd7e14",
|
||||
},
|
||||
"confirmed-overpaid-refunded": {
|
||||
"confirmed-overpay-refunded": {
|
||||
"label": "✓ Overpaid - Refund Sent",
|
||||
"color": "#28a745",
|
||||
},
|
||||
"confirmed-overpay-refunded-complete": {
|
||||
"label": "✓ Overpaid - Refunded",
|
||||
"color": "#28a745",
|
||||
},
|
||||
|
|
@ -1230,16 +1236,19 @@ def get_payment_status_info(status):
|
|||
"label": "✓ Out of Stock - Refunded",
|
||||
"color": "#fd7e14",
|
||||
},
|
||||
"doublepay-refund": {
|
||||
"doublepay-refunded": {
|
||||
"label": "Duplicate Payment - Refunding",
|
||||
"color": "#fd7e14",
|
||||
},
|
||||
"doublepay-refund-complete": {
|
||||
"doublepay-refunded-complete": {
|
||||
"label": "✓ Duplicate Payment - Refunded",
|
||||
"color": "#fd7e14",
|
||||
},
|
||||
"no-refund": {"label": "No Refund Possible", "color": "#dc3545"},
|
||||
"no-refund-complete": {"label": "✓ No Refund Possible", "color": "#dc3545"},
|
||||
"latepay-not-refunded": {"label": "Late Payment - No Refund", "color": "#dc3545"},
|
||||
"underpaid-not-refunded": {"label": "Underpaid - No Refund", "color": "#dc3545"},
|
||||
"confirmed-overpay-not-refunded": {"label": "Overpaid - No Refund", "color": "#dc3545"},
|
||||
"out-of-stock-not-refunded": {"label": "Out of Stock - No Refund", "color": "#dc3545"},
|
||||
"doublepay-not-refunded": {"label": "Duplicate Payment - No Refund", "color": "#dc3545"},
|
||||
}
|
||||
return status_mapping.get(status, {"label": status.title(), "color": "#6c757d"})
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue