user experience
modified: CLAUDE.md modified: make_post_sell/lib/crypto_watcher.py modified: make_post_sell/models/invoice.py modified: make_post_sell/routes.py modified: make_post_sell/scripts/alembic/versions/5bbb5df5bf1b_add_refund_tracking_columns_to_crypto_.py modified: make_post_sell/templates/crypto_checkout.j2 new file: make_post_sell/templates/crypto_quotes_history.j2 modified: make_post_sell/views/cart.py modified: make_post_sell/views/crypto.py
This commit is contained in:
parent
b352430b63
commit
13eb2abbb2
9 changed files with 486 additions and 51 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
128
make_post_sell/templates/crypto_quotes_history.j2
Normal file
128
make_post_sell/templates/crypto_quotes_history.j2
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
{% extends "base.j2" -%}
|
||||
|
||||
{% block content -%}
|
||||
<section class="one-column">
|
||||
<h2>💰 Crypto Payment History</h2>
|
||||
|
||||
<p>Your complete crypto payment history including all attempts, successful payments, and failed transactions.</p>
|
||||
|
||||
{% if payments %}
|
||||
<div style="margin: 20px 0;">
|
||||
{% for payment in payments %}
|
||||
<div style="border: 1px solid #dee2e6; border-radius: 4px; padding: 15px; margin-bottom: 15px; background: #f8f9fa;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 10px;">
|
||||
<div>
|
||||
<h4 style="margin: 0 0 5px 0;">
|
||||
{{ payment.coin_name }} ({{ payment.coin_type }}) Payment
|
||||
</h4>
|
||||
<p style="margin: 0; color: #6c757d; font-size: 0.9em;">
|
||||
{{ payment.shop_name }} •
|
||||
{% set dt = moment(payment.created_timestamp / 1000) %}
|
||||
{{ dt.strftime('%Y-%m-%d %H:%M:%S') }}
|
||||
</p>
|
||||
</div>
|
||||
<div style="text-align: right;">
|
||||
<span style="background: {{ payment.status_color }}; color: white; padding: 4px 8px; border-radius: 3px; font-size: 0.85em; font-weight: bold;">
|
||||
{{ payment.status_label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 15px; margin: 10px 0;">
|
||||
<div>
|
||||
<strong>Expected Amount:</strong><br>
|
||||
{% if payment.coin_type == 'XMR' %}
|
||||
{{ '%.12f' % payment.amount_crypto }} {{ payment.coin_type }}
|
||||
{% else %}
|
||||
{{ '%.8f' % payment.amount_crypto }} {{ payment.coin_type }}
|
||||
{% endif %}
|
||||
<br>
|
||||
<small style="color: #6c757d;">${{ '%.2f' % payment.usd_total }} USD</small>
|
||||
</div>
|
||||
|
||||
{% if payment.received_crypto > 0 %}
|
||||
<div>
|
||||
<strong>Received Amount:</strong><br>
|
||||
{% 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 %}
|
||||
<br>
|
||||
<small style="color: #6c757d;">{{ payment.confirmations }}/{{ payment.confirmations_required }} confirmations</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="text-align: right;">
|
||||
{% if payment.status == 'confirmed' or payment.status == 'confirmed-overpaid' %}
|
||||
{% if payment.has_invoice %}
|
||||
<a href="{{ request.route_url('view_invoice', invoice_id=payment.invoice_id) }}" class="mps-button" style="background: #28a745;">
|
||||
View Invoice
|
||||
</a>
|
||||
{% endif %}
|
||||
{% elif payment.status == 'pending' or payment.status == 'received' %}
|
||||
<a href="{{ request.route_url('crypto_quote', payment_id=payment.id) }}" class="mps-button" style="background: #17a2b8;">
|
||||
View Payment
|
||||
</a>
|
||||
{% else %}
|
||||
<!-- Terminal failed states -->
|
||||
<small style="color: #6c757d;">
|
||||
Payment ID: {{ payment.id[:8] }}...
|
||||
</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if payment.status == 'expired' or payment.status == 'cancelled' %}
|
||||
<div style="background: #fff3cd; border: 1px solid #ffeaa7; border-radius: 3px; padding: 8px; margin-top: 10px;">
|
||||
<small>
|
||||
{% 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 %}
|
||||
</small>
|
||||
</div>
|
||||
{% elif payment.status.endswith('-refunded') %}
|
||||
<div style="background: #d1ecf1; border: 1px solid #bee5eb; border-radius: 3px; padding: 8px; margin-top: 10px;">
|
||||
<small>
|
||||
💸 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 %}
|
||||
</small>
|
||||
</div>
|
||||
{% elif payment.status == 'out-of-stock-no-refund' %}
|
||||
<div style="background: #f8d7da; border: 1px solid #f5c6cb; border-radius: 3px; padding: 8px; margin-top: 10px;">
|
||||
<small>
|
||||
📦 Item became unavailable after payment. No refund address was configured, so funds were transferred to shop's cold storage.
|
||||
</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 30px; padding: 15px; background: #e9ecef; border-radius: 4px;">
|
||||
<h4>💡 About Crypto Payment History</h4>
|
||||
<ul style="margin: 10px 0;">
|
||||
<li><strong>Successful payments</strong> create invoices and unlock your purchased products</li>
|
||||
<li><strong>Failed payments</strong> are automatically refunded when possible (9% restocking fee applies)</li>
|
||||
<li><strong>Out-of-stock refunds</strong> are issued in full since it wasn't your fault</li>
|
||||
<li><strong>Expired/cancelled quotes</strong> show attempts that never received payment</li>
|
||||
<li>Configure a refund address in <a href="/u/settings/crypto">crypto settings</a> to enable automatic refunds</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="text-align: center; padding: 40px; color: #6c757d;">
|
||||
<h3>No crypto payment history yet</h3>
|
||||
<p>When you attempt crypto payments, they'll show up here.</p>
|
||||
<a href="/cart" class="mps-button" style="background: #007bff;">Start Shopping</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{%- endblock %}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue