diff --git a/make_post_sell/lib/crypto_watcher.py b/make_post_sell/lib/crypto_watcher.py index 326db52..cbe1eb6 100644 --- a/make_post_sell/lib/crypto_watcher.py +++ b/make_post_sell/lib/crypto_watcher.py @@ -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): diff --git a/make_post_sell/models/crypto_payment.py b/make_post_sell/models/crypto_payment.py index a9d9994..1b63b00 100644 --- a/make_post_sell/models/crypto_payment.py +++ b/make_post_sell/models/crypto_payment.py @@ -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 diff --git a/make_post_sell/templates/cart_checkout.j2 b/make_post_sell/templates/cart_checkout.j2 index 4df97bf..2249f83 100644 --- a/make_post_sell/templates/cart_checkout.j2 +++ b/make_post_sell/templates/cart_checkout.j2 @@ -42,13 +42,11 @@

Cart Total: ${{ '{:,.2f}'.format(cart.total) }}


- {% if stripe_enabled %} + {% if cart.requires_payment %} Are you sure you want to confirm checkout & pay
${{ '{:,.2f}'.format(cart.total) }}? {% else %} - {% if cart.requires_payment %} - Choose a payment method below. - {% endif %} + Are you sure you want to confirm checkout? {% endif %}
@@ -56,7 +54,7 @@

- {% if stripe_enabled %} + {% if stripe_enabled and active_card %}
{% include "snippets/csrf.j2" %} @@ -122,15 +120,15 @@ {# Show pending crypto quotes if user has any #} {% if pending_crypto_quotes %}
-

📋 Your Pending Crypto Quotes

+

📋 {{ pending_crypto_quotes|length }} Pending Crypto Quote{{ 's' if pending_crypto_quotes|length != 1 else '' }}

You have {{ pending_crypto_quotes|length }} pending crypto payment{{ 's' if pending_crypto_quotes|length != 1 else '' }}:

{% for quote in pending_crypto_quotes %}
- {{ quote.coin_type }} Payment + {{ quote.coin_type }} - Status: {{ quote.status.title() }} + {{ quote.status.title() }} {% if quote.current_confirmations > 0 %} ({{ quote.confirmation_status }}) {% endif %} @@ -145,9 +143,6 @@
{% endfor %} -

- 💡 Complete an existing payment or create a new quote below. -

{% endif %}