Fix Dogecoin & Monero multi-output refunds with proper fee handling #84
15 changed files with 1083 additions and 273 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 ==="
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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,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
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
235
make_post_sell/tests/test_multi_output_refunds.py
Normal file
235
make_post_sell/tests/test_multi_output_refunds.py
Normal 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()
|
||||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue