diff --git a/CLAUDE.md b/CLAUDE.md index 208ca6b..76c668d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,6 +67,15 @@ env/bin/py.test --cov=make_post_sell.models.cart --cov-report=term-missing make_ - Integration tests verify the original AttributeError bug fix for free coupon checkout - Functional tests provide end-to-end coverage of cart/checkout/payment flows +## Database Location + +The SQLite database is located at: `data/make_post_sell.sqlite` + +Query crypto payments: +```sql +SELECT * FROM mps_crypto_payment WHERE id = 'payment-uuid-here'; +``` + ## Common Issues and Solutions ### UUID Objects diff --git a/make_post_sell/lib/crypto_watcher.py b/make_post_sell/lib/crypto_watcher.py index b0544c6..b7a4b0f 100644 --- a/make_post_sell/lib/crypto_watcher.py +++ b/make_post_sell/lib/crypto_watcher.py @@ -242,8 +242,8 @@ def get_dogecoin_incoming_transfers(client, crypto_payment: CryptoPayment): try: address = crypto_payment.address - # Get received amount by address (with minimum 1 confirmation) - received_amount = client.getreceivedbyaddress(address, 1) + # Get received amount by address (with minimum 0 confirmations for mempool detection) + received_amount = client.getreceivedbyaddress(address, 0) # Get recent transactions for this address transactions = client.listtransactions( diff --git a/make_post_sell/models/invoice.py b/make_post_sell/models/invoice.py index 8876a3e..8a25297 100644 --- a/make_post_sell/models/invoice.py +++ b/make_post_sell/models/invoice.py @@ -294,3 +294,101 @@ class Invoice(RBase, Base): def get_invoice_by_id(dbsession, invoice_id): """Try to get Invoice object by id or return None.""" return get_object_by_id(dbsession, invoice_id, Invoice) + + +def delete_invoice_by_id(dbsession, invoice_id): + """ + Safely delete an invoice and its line items, but only if it's from a terminated/unsuccessful crypto payment. + + This function guards against deleting: + 1. Stripe invoices (contain important financial data) + 2. Successful crypto payment invoices (legitimate transactions) + + Only failed/terminated crypto payments should have their invoices deleted to clean up + unsuccessful transactions. Successful crypto payments should keep their invoices as + proof of purchase. + + Args: + dbsession: Database session + invoice_id: UUID of the invoice to delete + + Returns: + dict: { + "success": bool, + "message": str, + "deleted": bool + } + """ + try: + # Get the invoice + invoice = get_invoice_by_id(dbsession, invoice_id) + if not invoice: + return {"success": False, "message": "Invoice not found", "deleted": False} + + # Check if this has an associated crypto payment + crypto_payment = None + try: + if hasattr(invoice, "crypto_payment") and invoice.crypto_payment: + # crypto_payment might be a list or single object + if ( + hasattr(invoice.crypto_payment, "__len__") + and len(invoice.crypto_payment) > 0 + ): + crypto_payment = invoice.crypto_payment[0] + elif hasattr(invoice.crypto_payment, "status"): + crypto_payment = invoice.crypto_payment + except Exception: + pass + + # Guard: Only delete if this is a crypto payment invoice + if not crypto_payment: + return { + "success": False, + "message": "Cannot delete non-crypto invoices - may contain important financial data", + "deleted": False, + } + + # Import CryptoPayment to access TERMINAL_STATUSES + from .crypto_payment import CryptoPayment + + # Guard: Only delete invoices for terminated/unsuccessful crypto payments + if crypto_payment.status not in CryptoPayment.TERMINAL_STATUSES: + return { + "success": False, + "message": f"Cannot delete invoice for active crypto payment (status: {crypto_payment.status})", + "deleted": False, + } + + # Guard: Don't delete successful payments - those are legitimate transactions + successful_statuses = ["confirmed", "confirmed-overpaid"] + if crypto_payment.status in successful_statuses: + return { + "success": False, + "message": f"Cannot delete invoice for successful crypto payment (status: {crypto_payment.status})", + "deleted": False, + } + + # Safe to delete - this is a failed/terminated crypto payment + # Delete line items first + for line_item in invoice.line_items: + dbsession.delete(line_item) + + # Delete coupon redemptions + for coupon_redemption in invoice.coupon_redemptions: + dbsession.delete(coupon_redemption) + + # Delete the invoice itself + dbsession.delete(invoice) + + return { + "success": True, + "message": f"Successfully deleted failed crypto invoice {invoice_id} (payment status: {crypto_payment.status})", + "deleted": True, + } + + except Exception as e: + return { + "success": False, + "message": f"Error deleting invoice: {str(e)}", + "deleted": False, + } diff --git a/make_post_sell/routes.py b/make_post_sell/routes.py index cd27dfa..2e2e1a7 100644 --- a/make_post_sell/routes.py +++ b/make_post_sell/routes.py @@ -154,3 +154,4 @@ def includeme(config): config.add_route("crypto_doge_status", "/crypto/doge/status/{payment_id}") config.add_route("crypto_quote", "/crypto/quote/{payment_id}") config.add_route("crypto_cancel", "/crypto/cancel/{payment_id}") + config.add_route("crypto_quotes_history", "/u/crypto-quotes") diff --git a/make_post_sell/scripts/alembic/versions/5bbb5df5bf1b_add_refund_tracking_columns_to_crypto_.py b/make_post_sell/scripts/alembic/versions/5bbb5df5bf1b_add_refund_tracking_columns_to_crypto_.py index be7e0af..a445423 100644 --- a/make_post_sell/scripts/alembic/versions/5bbb5df5bf1b_add_refund_tracking_columns_to_crypto_.py +++ b/make_post_sell/scripts/alembic/versions/5bbb5df5bf1b_add_refund_tracking_columns_to_crypto_.py @@ -5,13 +5,14 @@ Revises: 0f59018f6537 Create Date: 2025-09-24 09:51:53.586990 """ + from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. -revision = '5bbb5df5bf1b' -down_revision = '0f59018f6537' +revision = "5bbb5df5bf1b" +down_revision = "0f59018f6537" branch_labels = None depends_on = None @@ -20,13 +21,21 @@ from make_post_sell.models.meta import UUIDType def upgrade(): # Add refund tracking columns to crypto_payment table - op.add_column('mps_crypto_payment', sa.Column('refund_reason', sa.UnicodeText(), nullable=True)) - op.add_column('mps_crypto_payment', sa.Column('refund_tx_hash', sa.Unicode(128), nullable=True)) - op.add_column('mps_crypto_payment', sa.Column('refund_amount', sa.BigInteger(), nullable=True)) + op.add_column( + "mps_crypto_payment", + sa.Column("refund_reason", sa.UnicodeText(), nullable=True), + ) + op.add_column( + "mps_crypto_payment", + sa.Column("refund_tx_hash", sa.Unicode(128), nullable=True), + ) + op.add_column( + "mps_crypto_payment", sa.Column("refund_amount", sa.BigInteger(), nullable=True) + ) def downgrade(): # Remove refund tracking columns from crypto_payment table - op.drop_column('mps_crypto_payment', 'refund_amount') - op.drop_column('mps_crypto_payment', 'refund_tx_hash') - op.drop_column('mps_crypto_payment', 'refund_reason') + op.drop_column("mps_crypto_payment", "refund_amount") + op.drop_column("mps_crypto_payment", "refund_tx_hash") + op.drop_column("mps_crypto_payment", "refund_reason") diff --git a/make_post_sell/templates/crypto_checkout.j2 b/make_post_sell/templates/crypto_checkout.j2 index d6ec66e..4b2297e 100644 --- a/make_post_sell/templates/crypto_checkout.j2 +++ b/make_post_sell/templates/crypto_checkout.j2 @@ -137,7 +137,52 @@ } } - // Replace action buttons with invoice link for non-pending statuses + // Remove payment elements to prevent double-sending (for received status) + function removePaymentElements() { + const buttonGrid = document.querySelector('div[style*="grid-template-columns: 1fr 1fr 1fr"]'); + const qrContainer = document.getElementById('payment-qr'); + const cryptoAmount = document.getElementById('crypto-amount'); + const cryptoAddress = document.getElementById('crypto-address'); + + // Remove QR code and payment instructions + if (qrContainer && qrContainer.parentNode) { + qrContainer.parentNode.style.display = 'none'; + } + + // Remove payment amount and address elements + if (cryptoAmount && cryptoAmount.parentNode) { + cryptoAmount.parentNode.style.display = 'none'; + } + if (cryptoAddress) { + cryptoAddress.style.display = 'none'; + } + + // Disable payment buttons but keep them visible for context + if (buttonGrid) { + const copyAddrBtn = document.getElementById('copy-addr'); + const copyAmtBtn = document.getElementById('copy-amt'); + const walletLink = buttonGrid.querySelector('a[href^="monero:"], a[href^="dogecoin:"]'); + + [copyAddrBtn, copyAmtBtn].forEach(btn => { + if (btn) { + btn.disabled = true; + btn.style.opacity = '0.5'; + btn.style.cursor = 'not-allowed'; + btn.style.background = '#ccc'; + btn.textContent = btn.textContent.replace('Copy', 'Payment Received'); + } + }); + + if (walletLink) { + walletLink.style.opacity = '0.5'; + walletLink.style.pointerEvents = 'none'; + walletLink.style.background = '#ccc'; + walletLink.textContent = 'Payment Received'; + } + } + } + + // Replace action buttons with invoice link for terminal statuses function replaceButtonsWithInvoiceLink(invoiceUrl) { const buttonGrid = document.querySelector('div[style*="grid-template-columns: 1fr 1fr 1fr"]'); const qrContainer = document.getElementById('payment-qr'); @@ -187,18 +232,39 @@ } } + // Terminal statuses from the model + const terminalStatuses = {{ terminal_statuses|tojson }}; + + // Track initial status to detect changes + let lastKnownStatus = statusEl ? statusEl.textContent.trim() : ''; + // Check payment status on page load - const initialStatus = statusEl ? statusEl.textContent.trim() : ''; - if (initialStatus && initialStatus !== 'pending') { - // For ALL non-pending statuses, replace buttons with invoice link + if (lastKnownStatus && lastKnownStatus === 'received') { + // Payment received - remove payment elements to prevent double-sending + removePaymentElements(); + + // Hide countdown since payment is received + if (expiresEl) { + expiresEl.parentNode.style.display = 'none'; + } + } else if (lastKnownStatus && (lastKnownStatus === 'confirmed' || lastKnownStatus === 'confirmed-overpaid')) { + // Payment confirmed - replace with invoice link + replaceButtonsWithInvoiceLink(); + + // Hide countdown + if (expiresEl) { + expiresEl.parentNode.style.display = 'none'; + } + } else if (lastKnownStatus && terminalStatuses.includes(lastKnownStatus)) { + // Terminal status - replace buttons with invoice link replaceButtonsWithInvoiceLink(); // Add specific warnings for expired status - if (initialStatus === 'expired') { + if (lastKnownStatus === 'expired') { addExpiredWarning(); } - // Hide countdown for non-pending statuses + // Hide countdown for terminal statuses if (expiresEl) { expiresEl.parentNode.style.display = 'none'; } @@ -277,22 +343,59 @@ const res = await fetch(`/crypto/{{ coin_symbol.lower() }}/status/${paymentId}`, { cache: 'no-store' }); if (!res.ok) return; const data = await res.json(); - const previousStatus = statusEl.textContent.trim(); - if (data.status) statusEl.textContent = data.status; + const currentStatus = data.status || ''; - // If status changed from pending to something else, replace buttons - if (previousStatus === 'pending' && data.status !== 'pending') { - replaceButtonsWithInvoiceLink(data.invoice_url); + // Update status display + if (currentStatus && statusEl) { + statusEl.textContent = currentStatus; + } + + // If status changed from what we last knew, update UI + if (lastKnownStatus !== currentStatus) { + console.log(`Status changed from ${lastKnownStatus} to ${currentStatus}`); - // Hide countdown - if (expiresEl && expiresEl.parentNode) { - expiresEl.parentNode.style.display = 'none'; + // Show confirmations section when payment is received or later + if (currentStatus === 'received' || currentStatus === 'confirmed' || currentStatus === 'confirmed-overpaid') { + if (confirmationsEl) { + confirmationsEl.style.display = 'block'; + } } - // Add expired warning if needed - if (data.status === 'expired') { - addExpiredWarning(); + // Handle different status transitions + if (currentStatus === 'received') { + // Payment received - remove payment elements to prevent double-sending + // but keep polling for confirmation updates + removePaymentElements(); + + // Hide countdown since payment is received + if (expiresEl && expiresEl.parentNode) { + expiresEl.parentNode.style.display = 'none'; + } + } else if (currentStatus === 'confirmed' || currentStatus === 'confirmed-overpaid') { + // Payment confirmed - replace with invoice link and redirect + replaceButtonsWithInvoiceLink(data.invoice_url); + + // Hide countdown + if (expiresEl && expiresEl.parentNode) { + expiresEl.parentNode.style.display = 'none'; + } + } else if (terminalStatuses.includes(currentStatus)) { + // Terminal status - replace with invoice link + replaceButtonsWithInvoiceLink(data.invoice_url); + + // Hide countdown + if (expiresEl && expiresEl.parentNode) { + expiresEl.parentNode.style.display = 'none'; + } + + // Add expired warning if needed + if (currentStatus === 'expired') { + addExpiredWarning(); + } } + + // Update our tracking variable + lastKnownStatus = currentStatus; } // Show confirmations if payment has been received @@ -312,8 +415,8 @@ expEl.textContent = fmtPico(data[expectedKey]); recvWrap.style.display = 'block'; } - // Optional: redirect when confirmed - if (data.status === 'confirmed') { + // Optional: redirect when payment is successful (any confirmed status) + if (data.status === 'confirmed' || data.status === 'confirmed-overpaid') { // Use smart redirect URL if provided, otherwise fallback to purchases page const redirectUrl = data.redirect_url || '/u/purchases'; window.location.href = redirectUrl; diff --git a/make_post_sell/templates/crypto_quotes_history.j2 b/make_post_sell/templates/crypto_quotes_history.j2 new file mode 100644 index 0000000..0f1829e --- /dev/null +++ b/make_post_sell/templates/crypto_quotes_history.j2 @@ -0,0 +1,128 @@ +{% extends "base.j2" -%} + +{% block content -%} +
+

💰 Crypto Payment History

+ +

Your complete crypto payment history including all attempts, successful payments, and failed transactions.

+ + {% if payments %} +
+ {% for payment in payments %} +
+
+
+

+ {{ payment.coin_name }} ({{ payment.coin_type }}) Payment +

+

+ {{ payment.shop_name }} • + {% set dt = moment(payment.created_timestamp / 1000) %} + {{ dt.strftime('%Y-%m-%d %H:%M:%S') }} +

+
+
+ + {{ payment.status_label }} + +
+
+ +
+
+ Expected Amount:
+ {% if payment.coin_type == 'XMR' %} + {{ '%.12f' % payment.amount_crypto }} {{ payment.coin_type }} + {% else %} + {{ '%.8f' % payment.amount_crypto }} {{ payment.coin_type }} + {% endif %} +
+ ${{ '%.2f' % payment.usd_total }} USD +
+ + {% if payment.received_crypto > 0 %} +
+ Received Amount:
+ {% if payment.coin_type == 'XMR' %} + {{ '%.12f' % payment.received_crypto }} {{ payment.coin_type }} + {% else %} + {{ '%.8f' % payment.received_crypto }} {{ payment.coin_type }} + {% endif %} + {% if payment.confirmations > 0 %} +
+ {{ payment.confirmations }}/{{ payment.confirmations_required }} confirmations + {% endif %} +
+ {% endif %} + +
+ {% if payment.status == 'confirmed' or payment.status == 'confirmed-overpaid' %} + {% if payment.has_invoice %} + + View Invoice + + {% endif %} + {% elif payment.status == 'pending' or payment.status == 'received' %} + + View Payment + + {% else %} + + + Payment ID: {{ payment.id[:8] }}... + + {% endif %} +
+
+ + {% if payment.status == 'expired' or payment.status == 'cancelled' %} +
+ + {% if payment.status == 'expired' %} + ⏰ This quote expired before payment was received. You can start a new checkout from your cart. + {% elif payment.status == 'cancelled' %} + ❌ This quote was manually cancelled before any payment was sent. + {% endif %} + +
+ {% elif payment.status.endswith('-refunded') %} +
+ + 💸 This payment was automatically refunded. + {% if payment.status == 'out-of-stock-refunded' %} + The item became unavailable after your payment - full refund issued. + {% else %} + A 9% restocking fee was deducted to cover network costs. + {% endif %} + +
+ {% elif payment.status == 'out-of-stock-no-refund' %} +
+ + 📦 Item became unavailable after payment. No refund address was configured, so funds were transferred to shop's cold storage. + +
+ {% endif %} +
+ {% endfor %} +
+ +
+

💡 About Crypto Payment History

+ +
+ {% else %} +
+

No crypto payment history yet

+

When you attempt crypto payments, they'll show up here.

+ Start Shopping +
+ {% endif %} +
+{%- endblock %} \ No newline at end of file diff --git a/make_post_sell/views/cart.py b/make_post_sell/views/cart.py index 1372b9b..ae6b245 100644 --- a/make_post_sell/views/cart.py +++ b/make_post_sell/views/cart.py @@ -51,7 +51,7 @@ def get_smart_purchase_redirect_url(invoices): # Handle single invoice with single digital product if len(invoices) == 1: invoice = invoices[0] - line_items = invoice.line_items + line_items = list(invoice.line_items) # Single digital product - redirect to download page if len(line_items) == 1 and line_items[0].product.has_product_file: @@ -59,7 +59,7 @@ def get_smart_purchase_redirect_url(invoices): return f"/p/{product.id}/{product.slug}" # Multiple products or physical products - redirect to invoice - return f"/u/invoice/{invoice.id}" + return f"/invoice/{invoice.id}" # Multiple invoices - redirect to general purchases page return "/u/purchases" diff --git a/make_post_sell/views/crypto.py b/make_post_sell/views/crypto.py index c1196e0..8701242 100644 --- a/make_post_sell/views/crypto.py +++ b/make_post_sell/views/crypto.py @@ -3,7 +3,7 @@ from pyramid.httpexceptions import HTTPFound, HTTPBadRequest from pyramid.response import Response from ..models.cart import get_cart_by_id -from ..models.invoice import Invoice +from ..models.invoice import Invoice, delete_invoice_by_id from ..models.crypto_payment import CryptoPayment from ..lib.crypto_clients import ( @@ -572,6 +572,7 @@ def crypto_quote(request): "has_refund_address": bool(user_refund_address), "refund_address": user_refund_address, "now": int(time.time() * 1000), + "terminal_statuses": list(CryptoPayment.TERMINAL_STATUSES), } @@ -642,11 +643,14 @@ def crypto_xmr_status(request): # Add invoice URL for non-pending statuses if crypto_payment.status != "pending" and crypto_payment.invoice: payload["invoice_url"] = request.route_url( - "user_invoice_by_id", invoice_id=crypto_payment.invoice.id + "view_invoice", invoice_id=crypto_payment.invoice.id ) - # Add smart redirect URL when payment is confirmed - if crypto_payment.status == "confirmed" and crypto_payment.invoice: + # Add smart redirect URL when payment is confirmed (any confirmed status) + if ( + crypto_payment.status in ["confirmed", "confirmed-overpaid"] + and crypto_payment.invoice + ): from ..views.cart import get_smart_purchase_redirect_url redirect_url = get_smart_purchase_redirect_url([crypto_payment.invoice]) @@ -692,11 +696,14 @@ def crypto_doge_status(request): # Add invoice URL for non-pending statuses if crypto_payment.status != "pending" and crypto_payment.invoice: payload["invoice_url"] = request.route_url( - "user_invoice_by_id", invoice_id=crypto_payment.invoice.id + "view_invoice", invoice_id=crypto_payment.invoice.id ) - # Add smart redirect URL when payment is confirmed - if crypto_payment.status == "confirmed" and crypto_payment.invoice: + # Add smart redirect URL when payment is confirmed (any confirmed status) + if ( + crypto_payment.status in ["confirmed", "confirmed-overpaid"] + and crypto_payment.invoice + ): from ..views.cart import get_smart_purchase_redirect_url redirect_url = get_smart_purchase_redirect_url([crypto_payment.invoice]) @@ -742,22 +749,19 @@ def crypto_cancel(request): # Cancel the payment crypto_payment.status = CryptoPayment.STATUS_CANCELLED request.dbsession.add(crypto_payment) - - # Delete the invoice since this was never a real purchase + + # Delete the invoice using the guarded function # Keep the crypto_payment for transaction history invoice = crypto_payment.invoice if invoice: - # Delete invoice line items first - for line_item in invoice.line_items: - request.dbsession.delete(line_item) - - # Clear the invoice reference from crypto payment - crypto_payment.invoice_id = None - crypto_payment.invoice = None - - # Delete the invoice - request.dbsession.delete(invoice) - + delete_result = delete_invoice_by_id(request.dbsession, invoice.id) + if delete_result["success"]: + # Clear the invoice reference from crypto payment + crypto_payment.invoice_id = None + crypto_payment.invoice = None + # Note: We don't fail the cancellation if invoice deletion fails + # The payment cancellation is more important than invoice cleanup + request.dbsession.flush() # Get the cart URL for redirect @@ -770,3 +774,86 @@ def crypto_cancel(request): content_type="application/json", charset="utf-8", ) + + +@view_config( + route_name="crypto_quotes_history", + renderer="crypto_quotes_history.j2", +) +@user_required() +def crypto_quotes_history(request): + """Show user's crypto payment history (all attempts, successful and failed).""" + + # Get all crypto payments for this user, ordered by newest first + crypto_payments = ( + request.dbsession.query(CryptoPayment) + .join(CryptoPayment.invoice) + .filter(Invoice.user_id == request.user.id) + .order_by(CryptoPayment.created_timestamp.desc()) + .all() + ) + + # Process payments for display + payment_history = [] + for payment in crypto_payments: + # Get coin info for display + coin_info = get_coin_info(payment.coin_type) + + # Calculate display amounts + amount_crypto = payment.expected_amount / coin_info["smallest_unit_divisor"] + received_crypto = (payment.received_amount or 0) / coin_info[ + "smallest_unit_divisor" + ] + + # Determine status display info + status_info = get_payment_status_info(payment.status) + + payment_data = { + "id": str(payment.id), + "coin_type": payment.coin_type, + "coin_name": coin_info["name"], + "status": payment.status, + "status_label": status_info["label"], + "status_color": status_info["color"], + "amount_crypto": amount_crypto, + "received_crypto": received_crypto, + "usd_total": float(payment.invoice.total) if payment.invoice else 0, + "rate_usd_per_coin": payment.rate_locked_usd_per_coin, + "created_timestamp": payment.created_timestamp, + "quote_expires_at": payment.quote_expires_at, + "confirmations": payment.current_confirmations or 0, + "confirmations_required": payment.confirmations_required, + "has_invoice": bool(payment.invoice), + "invoice_id": payment.invoice.id if payment.invoice else None, + "shop_name": ( + payment.invoice.shop.display_name + if payment.invoice and payment.invoice.shop + else "Unknown Shop" + ), + } + payment_history.append(payment_data) + + return { + "payments": payment_history, + "user": request.user, + } + + +def get_payment_status_info(status): + """Get display information for payment status.""" + status_mapping = { + "pending": {"label": "Pending Payment", "color": "#ffc107"}, + "received": {"label": "Payment Received", "color": "#17a2b8"}, + "confirmed": {"label": "Confirmed ✓", "color": "#28a745"}, + "confirmed-overpaid": {"label": "Confirmed (Overpaid) ✓", "color": "#28a745"}, + "expired": {"label": "Expired", "color": "#6c757d"}, + "expired-refunded": {"label": "Expired - Refunded", "color": "#fd7e14"}, + "underpaid-refunded": {"label": "Underpaid - Refunded", "color": "#fd7e14"}, + "cancelled": {"label": "Cancelled", "color": "#6c757d"}, + "out-of-stock-refunded": { + "label": "Out of Stock - Refunded", + "color": "#fd7e14", + }, + "out-of-stock-no-refund": {"label": "Out of Stock", "color": "#dc3545"}, + } + return status_mapping.get(status, {"label": status.title(), "color": "#6c757d"})