Improve crypto payment handling and out-of-stock refunds

- Add proper inventory checking before crypto payment completion
- Implement sequential (FIFO) crypto payment processing to prevent race conditions
- Add comprehensive out-of-stock refund handling with two statuses:
  - STATUS_OUT_OF_STOCK_REFUNDED: Full refund to customer (no restocking fee)
  - STATUS_OUT_OF_STOCK_NO_REFUND: Sweep to shop when no refund address
- Use sweep_all with subaddr_indices for Monero to ensure complete fund recovery
- Use sendtoaddress with subtractfeefromamount for Dogecoin fee handling
- Add refund tracking fields (refund_reason, refund_tx_hash, refund_amount)
- Update MONERO.rst with subaddress isolation and refund processing documentation
- Ensure action buttons are removed for non-pending crypto quotes to prevent double payments
This commit is contained in:
Russell Ballestrini 2025-09-24 09:35:07 -04:00
parent 663c0e9546
commit c4d9192540
3 changed files with 412 additions and 118 deletions

View file

@ -195,6 +195,88 @@ Why This Is Amazing
- **Security by Design**: Account isolation prevents fund mixing
- **Operational Excellence**: Automated deployment and monitoring
Subaddress Isolation and Refund Processing
===========================================
Our Monero implementation uses **subaddress isolation** to ensure complete security between customer payments and enable safe refund processing without affecting other transactions.
**Account and Subaddress Hierarchy**
------------------------------------
Each payment operates within a strict isolation model:
.. code-block:: text
Monero Wallet Structure:
├── Account 0: System (unused)
├── Account 1: Shop A
│ ├── Subaddress 0: Shop's primary address
│ ├── Subaddress 1: Customer Payment #1
│ ├── Subaddress 2: Customer Payment #2
│ └── Subaddress N: Customer Payment #N
├── Account 2: Shop B
│ ├── Subaddress 0: Shop's primary address
│ ├── Subaddress 1: Customer Payment #1
│ └── ...
└── Account N: Shop N...
**Payment Isolation Security**
------------------------------
Each crypto payment receives a unique subaddress within the shop's account, ensuring:
- **Complete Financial Isolation**: Funds from different customers never mix
- **Targeted Refunds**: Specific payments can be refunded without affecting others
- **Safe Sweep Operations**: Only intended funds are moved during sweeps
- **Audit Trail**: Each payment's funds are traceable throughout the lifecycle
**Refund Processing Flow**
--------------------------
When refunds are required (expired quotes, out-of-stock items, overpayments):
1. **Customer Refund**: Uses ``sweep_single`` to transfer customer's funds to their refund address
2. **Restocking Fee Sweep**: Uses ``sweep_all`` with specific ``subaddr_indices`` to collect restocking fees
3. **Transaction Ordering**: Refunds are always processed BEFORE restocking fee sweeps
.. code-block:: python
# Safe refund processing with subaddress isolation
# Step 1: Refund customer (specific subaddress)
refund_result = monero_rpc.sweep_single(
address=crypto_payment.refund_address,
account_index=crypto_payment.account_index,
subaddr_indices=[crypto_payment.subaddress_index]
)
# Step 2: Sweep restocking fee (same subaddress)
restocking_result = monero_rpc.sweep_all(
address=shop_cold_wallet,
account_index=crypto_payment.account_index,
subaddr_indices=[crypto_payment.subaddress_index]
)
**Why sweep_all is Safe**
-------------------------
The ``sweep_all`` operation with ``subaddr_indices`` parameter ensures security:
- **Without subaddr_indices**: Would sweep ALL funds in the account (DANGEROUS)
- **With subaddr_indices**: Only sweeps funds from specified subaddresses (SAFE)
This allows us to:
- Collect restocking fees from specific failed payments
- Leave other customers' payments untouched
- Maintain complete isolation between transactions
**Network Fee Considerations**
------------------------------
Monero network fees are automatically calculated and deducted:
- **Standard Fee**: ~0.0001 XMR for most transactions
- **Automatic Deduction**: Fees are subtracted from the swept amount
- **Fee Tracking**: All network fees are recorded for accounting
- **Confirmation Requirements**: Funds must be unlocked (~10 confirmations) before sweeping
This subaddress isolation architecture ensures that our multi-tenant Monero system maintains enterprise-grade security while providing the flexibility needed for complex refund scenarios.
The Result: A truly **amazing** multi-tenant cryptocurrency cash register that makes Monero payments as easy as traditional payment processing, while maintaining the privacy, security, and decentralization that makes cryptocurrency revolutionary.
**Welcome to the future of commerce.** 🚀

View file

@ -360,13 +360,13 @@ def create_shop_context_request(env_request, crypto_payment: CryptoPayment):
def check_inventory_availability(env_request, crypto_payment: CryptoPayment):
"""Check if all physical products in the invoice have sufficient inventory.
Returns:
tuple: (is_available: bool, out_of_stock_items: list)
"""
invoice = crypto_payment.invoice
out_of_stock_items = []
if crypto_payment.shop_location:
for item in invoice.line_items:
product = item.product
@ -376,62 +376,81 @@ def check_inventory_availability(env_request, crypto_payment: CryptoPayment):
)
available_qty = inv.quantity if inv else 0
if available_qty < item.quantity:
out_of_stock_items.append({
'product': product,
'requested': item.quantity,
'available': available_qty
})
out_of_stock_items.append(
{
"product": product,
"requested": item.quantity,
"available": available_qty,
}
)
return len(out_of_stock_items) == 0, out_of_stock_items
def finalize_invoice(env_request, crypto_payment: CryptoPayment):
invoice: Invoice = crypto_payment.invoice
# Check inventory availability for physical goods BEFORE finalizing
is_available, out_of_stock = check_inventory_availability(env_request, crypto_payment)
is_available, out_of_stock = check_inventory_availability(
env_request, crypto_payment
)
if not is_available:
# Inventory not available - need to refund WITHOUT restocking fee
logger.warning(
f"Payment {crypto_payment.id} cannot be fulfilled - out of stock items: "
f"{[item['product'].name for item in out_of_stock]}"
)
# Set refund reason for out of stock
crypto_payment.refund_reason = f"Out of stock: {', '.join([item['product'].name for item in out_of_stock])}"
env_request.dbsession.add(crypto_payment)
# Trigger full refund without restocking fee
# Handle out of stock situation
if crypto_payment.refund_address:
# Get the crypto client
client = get_crypto_client(env_request.registry.settings, crypto_payment.coin_type)
# Customer provided refund address - do full refund
client = get_crypto_client(
env_request.registry.settings, crypto_payment.coin_type
)
coin_config = get_coin_config(crypto_payment.coin_type)
# Calculate full refund amount
refund_amount_crypto = Decimal(crypto_payment.received_amount) / coin_config["atomic_units"]
refund_amount_crypto = (
Decimal(crypto_payment.received_amount) / coin_config["atomic_units"]
)
try:
# Process the refund sweep
# Process the refund sweep - use sweep_all to get ALL funds from this subaddress
if crypto_payment.coin_type == "XMR":
sweep_result = client._call("sweep_single", {
"account_index": crypto_payment.account_index,
"address": crypto_payment.refund_address,
"amount": int(crypto_payment.received_amount), # Full amount, no fee deduction
"priority": 1, # Normal priority
"get_tx_hex": True,
"do_not_relay": False,
})
sweep_result = client._call(
"sweep_all",
{
"account_index": crypto_payment.account_index,
"address": crypto_payment.refund_address,
"subaddr_indices": [
crypto_payment.subaddress_index
], # Only this payment's subaddress
"priority": 1, # Normal priority
"get_tx_hex": True,
"do_not_relay": False,
},
)
tx_hash = sweep_result.get("tx_hash_list", [None])[0]
elif crypto_payment.coin_type == "DOGE":
# For Dogecoin, use sendtoaddress
tx_hash = client._call("sendtoaddress", [
crypto_payment.refund_address,
float(refund_amount_crypto)
])
# For Dogecoin, use sendtoaddress with fee subtraction for full refund
tx_hash = client._call(
"sendtoaddress",
[
crypto_payment.refund_address,
float(refund_amount_crypto),
"Out of stock refund", # comment
"", # comment_to
True, # subtractfeefromamount - ensures ALL funds are sent
],
)
else:
tx_hash = None
if tx_hash:
crypto_payment.status = CryptoPayment.STATUS_OUT_OF_STOCK_REFUNDED
crypto_payment.refund_tx_hash = tx_hash
@ -442,20 +461,92 @@ def finalize_invoice(env_request, crypto_payment: CryptoPayment):
f"TX: {tx_hash}"
)
else:
logger.error(f"Failed to process full refund for payment {crypto_payment.id} - no tx hash")
logger.error(
f"Failed to process full refund for payment {crypto_payment.id} - no tx hash"
)
except Exception as e:
logger.error(f"Failed to process full refund for payment {crypto_payment.id}: {e}")
logger.error(
f"Failed to process full refund for payment {crypto_payment.id}: {e}"
)
else:
logger.warning(
f"Cannot refund out of stock payment {crypto_payment.id} - no refund address"
)
# No refund address - sweep all funds to shop's cold storage
if crypto_payment.shop_sweep_to_address:
client = get_crypto_client(
env_request.registry.settings, crypto_payment.coin_type
)
coin_config = get_coin_config(crypto_payment.coin_type)
try:
# Sweep all funds from this subaddress to shop's cold storage
if crypto_payment.coin_type == "XMR":
sweep_result = client._call(
"sweep_all",
{
"account_index": crypto_payment.account_index,
"address": crypto_payment.shop_sweep_to_address,
"subaddr_indices": [
crypto_payment.subaddress_index
], # Only this payment's subaddress
"priority": 1, # Normal priority
"get_tx_hex": True,
"do_not_relay": False,
},
)
tx_hash = sweep_result.get("tx_hash_list", [None])[0]
swept_amount = sweep_result.get("amount_list", [0])[0]
elif crypto_payment.coin_type == "DOGE":
# For Dogecoin, sweep ALL funds to shop's address
refund_amount_crypto = (
Decimal(crypto_payment.received_amount)
/ coin_config["atomic_units"]
)
tx_hash = client._call(
"sendtoaddress",
[
crypto_payment.shop_sweep_to_address,
float(refund_amount_crypto),
"Out of stock - no refund address", # comment
"", # comment_to
True, # subtractfeefromamount - ensures ALL funds are sent
],
)
swept_amount = crypto_payment.received_amount
else:
tx_hash = None
swept_amount = 0
if tx_hash:
crypto_payment.status = (
CryptoPayment.STATUS_OUT_OF_STOCK_NO_REFUND
)
crypto_payment.swept_tx_hash = tx_hash
crypto_payment.swept_amount = swept_amount
crypto_payment.swept_timestamp = now_timestamp()
logger.info(
f"Out of stock payment {crypto_payment.id} swept to shop cold storage "
f"Amount: {swept_amount} atomic units TX: {tx_hash}"
)
else:
logger.error(
f"Failed to sweep out of stock payment {crypto_payment.id} to shop storage"
)
except Exception as e:
logger.error(
f"Failed to sweep out of stock payment {crypto_payment.id} to shop: {e}"
)
else:
logger.warning(
f"Out of stock payment {crypto_payment.id} has no refund address and no shop sweep address"
)
crypto_payment.status = CryptoPayment.STATUS_OUT_OF_STOCK_NO_REFUND
# Send notification email about the refund
if invoice.user.email:
# TODO: Send out of stock refund notification email
pass
return False # Invoice not finalized
# Unlock products for the purchasing user and notify via email (mirrors Stripe flow)
@ -561,45 +652,131 @@ def process_payment(
total_recv, _, _ = summarize_txs(incoming_transfers)
if total_recv > 0:
# Check if items are out of stock - if so, do FULL refund
is_available, out_of_stock = check_inventory_availability(env_request, crypto_payment)
if not is_available and crypto_payment.refund_address:
# Out of stock - do full refund
is_available, out_of_stock = check_inventory_availability(
env_request, crypto_payment
)
if not is_available:
# Out of stock - handle based on whether refund address exists
crypto_payment.refund_reason = f"Expired + Out of stock: {', '.join([item['product'].name for item in out_of_stock])}"
coin_config = get_coin_config(crypto_payment.coin_type)
refund_amount_crypto = Decimal(total_recv) / coin_config["atomic_units"]
try:
if crypto_payment.coin_type == "XMR":
sweep_result = client._call("sweep_single", {
"account_index": crypto_payment.account_index,
"address": crypto_payment.refund_address,
"amount": int(total_recv), # Full amount
"priority": 1,
"get_tx_hex": True,
"do_not_relay": False,
})
tx_hash = sweep_result.get("tx_hash_list", [None])[0]
elif crypto_payment.coin_type == "DOGE":
tx_hash = client._call("sendtoaddress", [
crypto_payment.refund_address,
float(refund_amount_crypto)
])
else:
tx_hash = None
if tx_hash:
crypto_payment.status = CryptoPayment.STATUS_OUT_OF_STOCK_REFUNDED
crypto_payment.refund_tx_hash = tx_hash
crypto_payment.refund_amount = total_recv
logger.info(
f"Full refund for expired+out of stock payment {crypto_payment.id} "
f"Amount: {refund_amount_crypto} {crypto_payment.coin_type} TX: {tx_hash}"
refund_amount_crypto = (
Decimal(total_recv) / coin_config["atomic_units"]
)
if crypto_payment.refund_address:
# Customer provided refund address - do full refund
try:
if crypto_payment.coin_type == "XMR":
# Use sweep_all to get ALL funds from this subaddress
sweep_result = client._call(
"sweep_all",
{
"account_index": crypto_payment.account_index,
"address": crypto_payment.refund_address,
"subaddr_indices": [
crypto_payment.subaddress_index
], # Only this payment's subaddress
"priority": 1,
"get_tx_hex": True,
"do_not_relay": False,
},
)
tx_hash = sweep_result.get("tx_hash_list", [None])[0]
elif crypto_payment.coin_type == "DOGE":
# Use subtractfeefromamount for full refund
tx_hash = client._call(
"sendtoaddress",
[
crypto_payment.refund_address,
float(refund_amount_crypto),
"Out of stock refund", # comment
"", # comment_to
True, # subtractfeefromamount
],
)
else:
tx_hash = None
if tx_hash:
crypto_payment.status = (
CryptoPayment.STATUS_OUT_OF_STOCK_REFUNDED
)
crypto_payment.refund_tx_hash = tx_hash
crypto_payment.refund_amount = total_recv
logger.info(
f"Full refund for expired+out of stock payment {crypto_payment.id} "
f"Amount: {refund_amount_crypto} {crypto_payment.coin_type} TX: {tx_hash}"
)
except Exception as e:
logger.error(
f"Failed to process full refund for expired payment {crypto_payment.id}: {e}"
)
except Exception as e:
logger.error(f"Failed to process full refund for expired payment {crypto_payment.id}: {e}")
else:
# No refund address - sweep to shop's cold storage
if crypto_payment.shop_sweep_to_address:
try:
if crypto_payment.coin_type == "XMR":
sweep_result = client._call(
"sweep_all",
{
"account_index": crypto_payment.account_index,
"address": crypto_payment.shop_sweep_to_address,
"subaddr_indices": [
crypto_payment.subaddress_index
], # Only this payment's subaddress
"priority": 1,
"get_tx_hex": True,
"do_not_relay": False,
},
)
tx_hash = sweep_result.get("tx_hash_list", [None])[
0
]
swept_amount = sweep_result.get("amount_list", [0])[
0
]
elif crypto_payment.coin_type == "DOGE":
# Sweep ALL funds to shop with fee subtraction
tx_hash = client._call(
"sendtoaddress",
[
crypto_payment.shop_sweep_to_address,
float(refund_amount_crypto),
"Out of stock - no refund address", # comment
"", # comment_to
True, # subtractfeefromamount
],
)
swept_amount = total_recv
else:
tx_hash = None
swept_amount = 0
if tx_hash:
crypto_payment.status = (
CryptoPayment.STATUS_OUT_OF_STOCK_NO_REFUND
)
crypto_payment.swept_tx_hash = tx_hash
crypto_payment.swept_amount = swept_amount
crypto_payment.swept_timestamp = now_timestamp()
logger.info(
f"Expired+out of stock payment {crypto_payment.id} swept to shop cold storage "
f"Amount: {swept_amount} atomic units TX: {tx_hash}"
)
except Exception as e:
logger.error(
f"Failed to sweep expired+out of stock payment {crypto_payment.id}: {e}"
)
else:
logger.warning(
f"Expired+out of stock payment {crypto_payment.id} has no refund or sweep address"
)
crypto_payment.status = (
CryptoPayment.STATUS_OUT_OF_STOCK_NO_REFUND
)
elif payment_rescue and crypto_payment.refund_address:
# Not out of stock - do normal expired refund with restocking fee
coin_config = get_coin_config(crypto_payment.coin_type)
@ -617,49 +794,76 @@ def process_payment(
f"Refund executed for expired payment {crypto_payment.id}: TX {result['tx_hash']}"
)
# Track the customer refund
crypto_payment.refund_amount = int(refund_details["refund_amount"] * coin_config["atomic_units"])
crypto_payment.refund_amount = int(
refund_details["refund_amount"]
* coin_config["atomic_units"]
)
crypto_payment.refund_tx_hash = result["tx_hash"]
crypto_payment.refund_reason = refund_details["reason"]
crypto_payment.status = CryptoPayment.STATUS_EXPIRED_REFUNDED
crypto_payment.status = (
CryptoPayment.STATUS_EXPIRED_REFUNDED
)
# Commit the refund first
env_request.dbsession.add(crypto_payment)
env_request.dbsession.flush()
# Now sweep the remaining restocking fee to shop's wallet (AFTER refund)
fee_amount = int(refund_details["fee_amount"] * coin_config["atomic_units"])
fee_amount = int(
refund_details["fee_amount"]
* coin_config["atomic_units"]
)
if fee_amount > 0 and crypto_payment.shop_sweep_to_address:
try:
# Small delay to ensure refund tx is processed
import time
time.sleep(2)
if crypto_payment.coin_type == "XMR":
# Send exact restocking fee amount (9%)
sweep_result = client._call("transfer", {
"account_index": crypto_payment.account_index,
"destinations": [{
# Sweep remaining balance from this specific subaddress only
sweep_result = client._call(
"sweep_all",
{
"account_index": crypto_payment.account_index,
"subaddr_indices": [
crypto_payment.subaddress_index
],
"address": crypto_payment.shop_sweep_to_address,
"amount": fee_amount
}],
"priority": 1,
"get_tx_hex": True,
"do_not_relay": False,
})
fee_tx_hash = sweep_result.get("tx_hash")
actual_swept = fee_amount
"priority": 1,
"get_tx_hex": True,
"do_not_relay": False,
},
)
fee_tx_hash = (
sweep_result.get("tx_hash_list", [None])[0]
if sweep_result.get("tx_hash_list")
else None
)
# Get actual amount swept (whatever remained after refund, minus network fees)
actual_swept = (
sweep_result.get("amount_list", [0])[0]
if sweep_result.get("amount_list")
else 0
)
elif crypto_payment.coin_type == "DOGE":
# Send exact restocking fee amount
fee_crypto_amount = float(Decimal(fee_amount) / coin_config["atomic_units"])
fee_tx_hash = client._call("sendtoaddress", [
crypto_payment.shop_sweep_to_address,
fee_crypto_amount
])
fee_crypto_amount = float(
Decimal(fee_amount)
/ coin_config["atomic_units"]
)
fee_tx_hash = client._call(
"sendtoaddress",
[
crypto_payment.shop_sweep_to_address,
fee_crypto_amount,
],
)
actual_swept = fee_amount
else:
fee_tx_hash = None
actual_swept = 0
if fee_tx_hash and actual_swept > 0:
crypto_payment.swept_amount = actual_swept
crypto_payment.swept_tx_hash = fee_tx_hash
@ -670,7 +874,9 @@ def process_payment(
f"TX: {fee_tx_hash}"
)
except Exception as e:
logger.error(f"Failed to sweep restocking fee for payment {crypto_payment.id}: {e}")
logger.error(
f"Failed to sweep restocking fee for payment {crypto_payment.id}: {e}"
)
else:
logger.error(
f"Refund failed for expired payment {crypto_payment.id}: {result['error']}"
@ -877,13 +1083,15 @@ def run_once(env, interval):
with request.tm:
db = request.dbsession
q = db.query(CryptoPayment).filter(
CryptoPayment.status.in_(CryptoPayment.ACTIVE_STATUSES),
CryptoPayment.swept_tx_hash.is_(
None
), # Only process payments that are not swept
).order_by(
CryptoPayment.created_timestamp.asc() # First come, first serve
q = (
db.query(CryptoPayment)
.filter(
CryptoPayment.status.in_(CryptoPayment.ACTIVE_STATUSES),
CryptoPayment.swept_tx_hash.is_(
None
), # Only process payments that are not swept
)
.order_by(CryptoPayment.created_timestamp.asc()) # First come, first serve
)
payments = q.all()
@ -913,7 +1121,7 @@ def run_once(env, interval):
logger.info(
f"Processing payment {crypto_payment.id} (status: {crypto_payment.status}, coin: {crypto_payment.coin_type})"
)
# Skip if payment status changed (another process may have handled it)
db.refresh(crypto_payment)
if crypto_payment.status not in CryptoPayment.ACTIVE_STATUSES:
@ -942,18 +1150,18 @@ def run_once(env, interval):
try:
# Process payment with a savepoint for rollback capability
savepoint = db.begin_nested()
process_payment(request, crypto_payment, incoming, client)
# Commit the savepoint if successful
savepoint.commit()
except Exception as e:
# Rollback on any error
savepoint.rollback()
logger.error(
f"Failed to process payment {crypto_payment.id}: {e}",
exc_info=True
exc_info=True,
)

View file

@ -31,6 +31,7 @@ class CryptoPayment(RBase, Base):
STATUS_UNDERPAID_REFUNDED = "underpaid-refunded"
STATUS_CANCELLED = "cancelled"
STATUS_OUT_OF_STOCK_REFUNDED = "out-of-stock-refunded"
STATUS_OUT_OF_STOCK_NO_REFUND = "out-of-stock-no-refund"
# Active statuses that should be processed by the watcher
ACTIVE_STATUSES = [
@ -47,6 +48,7 @@ class CryptoPayment(RBase, Base):
STATUS_UNDERPAID_REFUNDED,
STATUS_CANCELLED,
STATUS_OUT_OF_STOCK_REFUNDED,
STATUS_OUT_OF_STOCK_NO_REFUND,
]
id = Column(UUIDType, primary_key=True, index=True)
@ -89,14 +91,16 @@ class CryptoPayment(RBase, Base):
refund_address = Column(
Unicode(256), nullable=True
) # Where to send refunds if payment fails/expires
# Refund reason for audit trail
refund_reason = Column(
UnicodeText, nullable=True
) # Why the refund was issued (out of stock, expired, etc.)
# Refund tracking
refund_tx_hash = Column(Unicode(128), nullable=True) # Transaction hash of the refund
refund_tx_hash = Column(
Unicode(128), nullable=True
) # Transaction hash of the refund
refund_amount = Column(BigInteger, nullable=True) # Amount refunded in atomic units
# Sweep tracking