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:
Russell Ballestrini 2025-10-02 16:19:23 -04:00
parent 958a003f35
commit d2d09e8851
10 changed files with 661 additions and 157 deletions

View file

@ -58,6 +58,8 @@ help:
@echo " make dogecoin-node - Start Dogecoin daemon (pruned mode)"
@echo " make dogecoin-node-full - Start Dogecoin daemon (full blockchain)"
@echo " make dogecoin-status - Check sync status and wallet info"
@echo " make dogecoin-check-fee - Check current transaction fee setting"
@echo " make dogecoin-fix-fee - Fix high transaction fee (sets to 0.001 DOGE)"
@echo ""
@echo "WALLET MANAGEMENT:"
@echo " make sweep-check - Check hot wallet balances (dev tool)"
@ -180,9 +182,10 @@ http: venv
@echo "Starting simple HTTP server on port 8000..."
$(PYTHON) -m http.server 8000
# Run the crypto watcher service for monitoring Monero payments.
# Run the crypto watcher service for monitoring crypto payments.
crypto-watcher: venv config
@echo "Starting crypto payment watcher..."
@echo "💡 TIP: If Dogecoin refunds fail, check fee: make dogecoin-check-fee"
$(VENV_DIR)/bin/crypto_watcher $(DATA_DIR)/$(CONFIG_FILE)
# Run the crypto watcher once (for testing or manual processing).
@ -564,6 +567,12 @@ dogecoin-config: check-dogecoin
@echo "" >> $(HOME)/.dogecoin/dogecoin.conf
@echo "# Hot wallet for Make Post Sell" >> $(HOME)/.dogecoin/dogecoin.conf
@echo "wallet=make_post_sell_hot_wallet" >> $(HOME)/.dogecoin/dogecoin.conf
@echo "" >> $(HOME)/.dogecoin/dogecoin.conf
@echo "# Transaction fee settings" >> $(HOME)/.dogecoin/dogecoin.conf
@echo "# Set reasonable fee for multi-output transactions (sendmany)" >> $(HOME)/.dogecoin/dogecoin.conf
@echo "paytxfee=0.001" >> $(HOME)/.dogecoin/dogecoin.conf
@echo "# Minimum fee per KB (prevents 0-fee transactions)" >> $(HOME)/.dogecoin/dogecoin.conf
@echo "mintxfee=0.001" >> $(HOME)/.dogecoin/dogecoin.conf
@echo ""
@echo "✓ Configuration created at: $(HOME)/.dogecoin/dogecoin.conf"
@echo ""
@ -621,6 +630,37 @@ dogecoin-node-full: check-dogecoin
-rpcallowip=127.0.0.1 \
-server=1
# Check and fix Dogecoin transaction fee
dogecoin-check-fee: check-dogecoin
@echo "=== Checking Dogecoin Transaction Fee ==="
@if ! dogecoin-cli getwalletinfo >/dev/null 2>&1; then \
echo "❌ Dogecoin daemon not running"; \
echo "Start with: make dogecoin-node"; \
exit 1; \
fi
@fee=$$(dogecoin-cli getwalletinfo | grep paytxfee | awk '{print $$2}' | tr -d ','); \
echo "Current transaction fee: $$fee DOGE"; \
if [ "$$(echo "$$fee > 0.001" | bc -l)" = "1" ]; then \
echo "⚠️ WARNING: Transaction fee is too high!"; \
echo "This may cause sendmany (multi-output transactions) to fail."; \
echo "Run 'make dogecoin-fix-fee' to set reasonable fee."; \
else \
echo "✓ Transaction fee is reasonable"; \
fi
# Set reasonable Dogecoin transaction fee
dogecoin-fix-fee: check-dogecoin
@echo "=== Setting Dogecoin Transaction Fee ==="
@if ! dogecoin-cli getwalletinfo >/dev/null 2>&1; then \
echo "❌ Dogecoin daemon not running"; \
echo "Start with: make dogecoin-node"; \
exit 1; \
fi
@echo "Setting transaction fee to 0.001 DOGE..."
@dogecoin-cli settxfee 0.001
@echo "✓ Transaction fee updated"
@echo "New fee: $$(dogecoin-cli getwalletinfo | grep paytxfee | awk '{print $$2}' | tr -d ',') DOGE"
# Check Dogecoin sync status and wallet info
dogecoin-status: check-dogecoin
@echo "=== Dogecoin Node Status ==="

View file

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

View file

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

View file

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

View file

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

View file

@ -58,7 +58,7 @@
<p><strong>Total Amount:</strong> {{ '%.12f' % amount_crypto }} {{ coin_symbol }}</p>
<p><strong>Expected {{ smallest_unit_name }}:</strong> {{ '{:,}'.format(expected_smallest_units) }}</p>
<p><strong>Payment ID:</strong> <code id="payment-id">{{ payment_id }}</code></p>
{% if status in ['confirmed', 'confirmed-overpaid', 'confirmed-overpaid-refunded'] and has_invoice and invoice_id %}
{% if status in ['confirmed', 'confirmed-complete', 'confirmed-overpay', 'confirmed-overpay-refunded', 'confirmed-overpay-refunded-complete'] and has_invoice and invoice_id %}
<p><strong>Invoice ID:</strong> <a href="{{ request.route_url('view_invoice', invoice_id=invoice_id) }}">{{ invoice_id }}</a></p>
{% endif %}
{% else %}
@ -67,7 +67,7 @@
<p><strong>Total Amount:</strong> {{ '%.8f' % amount_crypto }} {{ coin_symbol }}</p>
<p><strong>Expected {{ smallest_unit_name }}:</strong> {{ '{:,}'.format(expected_smallest_units) }}</p>
<p><strong>Payment ID:</strong> <code id="payment-id">{{ payment_id }}</code></p>
{% if status in ['confirmed', 'confirmed-overpaid', 'confirmed-overpaid-refunded'] and has_invoice and invoice_id %}
{% if status in ['confirmed', 'confirmed-complete', 'confirmed-overpay', 'confirmed-overpay-refunded', 'confirmed-overpay-refunded-complete'] and has_invoice and invoice_id %}
<p><strong>Invoice ID:</strong> <a href="{{ request.route_url('view_invoice', invoice_id=invoice_id) }}">{{ invoice_id }}</a></p>
{% endif %}
{% endif %}
@ -191,7 +191,7 @@
const currentStatus = statusEl ? statusEl.textContent.trim() : '';
// Use smart redirect URL for confirmed states, crypto-quotes for terminal states
if (currentStatus === 'confirmed' || currentStatus === 'confirmed-overpaid') {
if (currentStatus === 'confirmed' || currentStatus === 'confirmed-complete' || currentStatus === 'confirmed-overpay') {
invoiceLink.href = redirectUrl || '/u/purchases';
// Determine button text based on redirect URL
if (redirectUrl && redirectUrl.includes('/p/')) {
@ -250,7 +250,7 @@
if (expiresEl) {
expiresEl.parentNode.style.display = 'none';
}
} else if (lastKnownStatus && (lastKnownStatus === 'confirmed' || lastKnownStatus === 'confirmed-overpaid')) {
} else if (lastKnownStatus && (lastKnownStatus === 'confirmed' || lastKnownStatus === 'confirmed-complete' || lastKnownStatus === 'confirmed-overpay')) {
// Payment confirmed - replace with invoice link
replaceButtonsWithInvoiceLink();
@ -377,7 +377,7 @@
console.log(`Status changed from ${lastKnownStatus} to ${currentStatus}`);
// Show confirmations section when payment is received or later
if (currentStatus === 'received' || currentStatus === 'confirmed' || currentStatus === 'confirmed-overpaid') {
if (currentStatus === 'received' || currentStatus === 'confirmed' || currentStatus === 'confirmed-complete' || currentStatus === 'confirmed-overpay') {
if (confirmationsEl) {
confirmationsEl.style.display = 'block';
}
@ -399,7 +399,7 @@
if (expiresEl && expiresEl.parentNode) {
expiresEl.parentNode.style.display = 'none';
}
} else if (currentStatus === 'confirmed' || currentStatus === 'confirmed-overpaid') {
} else if (currentStatus === 'confirmed' || currentStatus === 'confirmed-complete' || currentStatus === 'confirmed-overpay') {
// Payment confirmed - replace with smart redirect link
replaceButtonsWithInvoiceLink(data.redirect_url);
@ -410,7 +410,7 @@
// Redirect only if status changed from non-confirmed to confirmed (via polling)
// Don't redirect if user browsed directly to already-confirmed quote
if (lastKnownStatus && lastKnownStatus !== 'confirmed' && lastKnownStatus !== 'confirmed-overpaid') {
if (lastKnownStatus && lastKnownStatus !== 'confirmed' && lastKnownStatus !== 'confirmed-complete' && lastKnownStatus !== 'confirmed-overpay') {
setTimeout(() => {
// Use smart redirect URL if available, otherwise fallback to purchases page
window.location.href = data.redirect_url || '/u/purchases';
@ -470,7 +470,7 @@
// Stop polling if we've reached a terminal status or confirmed status
const currentStatus = statusEl ? statusEl.textContent.trim() : '';
if (terminalStatuses.includes(currentStatus) || currentStatus === 'confirmed') {
if (terminalStatuses.includes(currentStatus) || currentStatus === 'confirmed' || currentStatus === 'confirmed-complete') {
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = null;

View file

@ -58,7 +58,7 @@
{% endif %}
<div style="text-align: right; justify-self: end;">
{% if payment.status == 'confirmed' or payment.status == 'confirmed-overpaid' or payment.status == 'confirmed-overpaid-refunded' %}
{% if payment.status == 'confirmed' or payment.status == 'confirmed-complete' or payment.status == 'confirmed-overpay' or payment.status == 'confirmed-overpay-refunded' or payment.status == 'confirmed-overpay-refunded-complete' %}
{% if payment.has_invoice %}
<a href="{{ request.route_url('view_invoice', invoice_id=payment.invoice_id) }}" class="mps-button mps-button-small mps-button-green">
View Invoice

View file

@ -1154,8 +1154,8 @@ class AutoSweepTests(unittest.TestCase):
# Check sweep tracking fields were set
self.assertEqual(
payment.swept_amount, 490000000000
) # 0.5 XMR - 0.01 XMR fee = 0.49 XMR
payment.swept_amount, 489000000000
) # 0.5 XMR - (0.01 XMR fee * 1.1 margin)
self.assertEqual(payment.swept_tx_hash, "transfer_tx_123")
self.assertIsNotNone(payment.swept_timestamp)
@ -1230,7 +1230,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
)

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

View file

@ -836,7 +836,7 @@ def crypto_xmr_status(request):
)
else:
request.session.flash(("Purchase completed!", "success"))
elif current_status == "confirmed-overpaid":
elif current_status == "confirmed-overpay":
request.session.flash(
("Purchase completed! Overpayment will be refunded.", "success")
)
@ -872,7 +872,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"})