Implement robust crypto payment processing with inventory checks

- Add inventory availability check before finalizing crypto payments
- Implement full refund without restocking fee for out-of-stock items
- Add new payment statuses: out_of_stock_refund_pending, out_of_stock_refunded
- Process crypto payments in FIFO order (first come, first serve) by creation timestamp
- Add transaction-level savepoints for atomic payment processing
- Refresh payment status before processing to avoid race conditions
- Add refund tracking fields: refund_reason, refund_tx_hash, refund_amount
- Hide credit card button when no active card is configured
- Move checkout confirmation message outside Stripe-specific section
- Clean up pending quotes display (remove redundant labels)

This prevents the Black Friday scenario where multiple customers pay with
different cryptocurrencies for the same limited inventory item. The first
payment to be created will be processed first, and subsequent payments
for out-of-stock items will receive full refunds automatically.
This commit is contained in:
Russell Ballestrini 2025-09-23 18:05:04 -04:00
parent c77bd72a12
commit 39a72ec9f8
3 changed files with 143 additions and 13 deletions

View file

@ -358,8 +358,106 @@ def create_shop_context_request(env_request, crypto_payment: CryptoPayment):
return ShopContextRequestWrapper(env_request, shop)
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
if getattr(product, "is_physical", False):
inv = get_inventory_by_product_and_shop_location(
env_request.dbsession, product.id, crypto_payment.shop_location.id
)
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
})
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)
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]}"
)
# Mark payment for full refund (no restocking fee for out of stock)
crypto_payment.status = CryptoPayment.STATUS_OUT_OF_STOCK_REFUND_PENDING
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
if crypto_payment.refund_address:
# Get the crypto client
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"]
try:
# Process the refund sweep
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,
})
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)
])
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 = crypto_payment.received_amount
logger.info(
f"Full refund processed for out of stock payment {crypto_payment.id} "
f"Amount: {refund_amount_crypto} {crypto_payment.coin_type} "
f"TX: {tx_hash}"
)
else:
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}")
else:
logger.warning(
f"Cannot refund out of stock payment {crypto_payment.id} - no refund address"
)
# 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)
for line_item in invoice.line_items:
@ -688,11 +786,13 @@ def run_once(env, interval):
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()
logger.info(
f"Found {len(payments)} payments to process: {[p.status for p in payments]}"
f"Found {len(payments)} payments to process (ordered by creation time): {[p.status for p in payments]}"
)
# Group payments by coin type to get appropriate clients
@ -717,6 +817,14 @@ 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:
logger.info(
f"Skipping payment {crypto_payment.id} - status changed to {crypto_payment.status}"
)
continue
# Query transfers for the payment address
if coin_type == "XMR":
# Monero: Query transfers for subaddress
@ -735,7 +843,22 @@ def run_once(env, interval):
logger.error(f"Unsupported coin type for monitoring: {coin_type}")
incoming = []
process_payment(request, crypto_payment, incoming, client)
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
)
def main(argv=sys.argv):

View file

@ -30,6 +30,8 @@ class CryptoPayment(RBase, Base):
STATUS_EXPIRED_REFUNDED = "expired_refunded"
STATUS_UNDERPAID_REFUNDED = "underpaid_refunded"
STATUS_CANCELLED = "cancelled"
STATUS_OUT_OF_STOCK_REFUND_PENDING = "out_of_stock_refund_pending"
STATUS_OUT_OF_STOCK_REFUNDED = "out_of_stock_refunded"
# Active statuses that should be processed by the watcher
ACTIVE_STATUSES = [
@ -45,6 +47,7 @@ class CryptoPayment(RBase, Base):
STATUS_EXPIRED_REFUNDED,
STATUS_UNDERPAID_REFUNDED,
STATUS_CANCELLED,
STATUS_OUT_OF_STOCK_REFUNDED,
]
id = Column(UUIDType, primary_key=True, index=True)
@ -87,6 +90,15 @@ 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_amount = Column(BigInteger, nullable=True) # Amount refunded in atomic units
# Sweep tracking
swept_amount = Column(BigInteger, nullable=True) # Amount swept in atomic units

View file

@ -42,13 +42,11 @@
<h2>Cart Total: ${{ '{:,.2f}'.format(cart.total) }}</h2>
<br/>
{% if stripe_enabled %}
{% if cart.requires_payment %}
Are you sure you want to confirm checkout & pay <br/>
<b>${{ '{:,.2f}'.format(cart.total) }}</b>?
{% else %}
{% if cart.requires_payment %}
Choose a payment method below.
{% endif %}
Are you sure you want to confirm checkout?
{% endif %}
<br/>
@ -56,7 +54,7 @@
<br/>
<br/>
{% if stripe_enabled %}
{% if stripe_enabled and active_card %}
<form method="POST" action="{{ request.route_url('user_cart_complete_checkout', cart_id=cart.id) }}">
{% include "snippets/csrf.j2" %}
<button type="submit" class="cart-checkout-button mps-button">Yes, Confirm Checkout with Active Credit Card</button>
@ -122,15 +120,15 @@
{# Show pending crypto quotes if user has any #}
{% if pending_crypto_quotes %}
<div style="background:#fff3cd; border:1px solid #ffc107; border-radius:4px; padding:15px; margin:20px 0;">
<h3 style="margin-top:0;">📋 Your Pending Crypto Quotes</h3>
<h3 style="margin-top:0;">📋 {{ pending_crypto_quotes|length }} Pending Crypto Quote{{ 's' if pending_crypto_quotes|length != 1 else '' }}</h3>
<p style="margin-bottom:15px;">You have {{ pending_crypto_quotes|length }} pending crypto payment{{ 's' if pending_crypto_quotes|length != 1 else '' }}:</p>
{% for quote in pending_crypto_quotes %}
<div style="background:#f8f9fa; border:1px solid #dee2e6; border-radius:3px; padding:10px; margin:8px 0;">
<div style="display:grid; grid-template-columns: 1fr auto; align-items:start;">
<div>
<strong>{{ quote.coin_type }} Payment</strong>
<strong>{{ quote.coin_type }}</strong>
<span style="color:#666; margin-left:10px;">
Status: {{ quote.status.title() }}
{{ quote.status.title() }}
{% if quote.current_confirmations > 0 %}
({{ quote.confirmation_status }})
{% endif %}
@ -145,9 +143,6 @@
</div>
</div>
{% endfor %}
<p style="margin-bottom:0; font-size:14px; color:#856404;">
💡 Complete an existing payment or create a new quote below.
</p>
</div>
{% endif %}