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:
parent
c77bd72a12
commit
39a72ec9f8
3 changed files with 143 additions and 13 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue