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:
Russell Ballestrini 2025-09-24 12:49:09 -04:00
parent b352430b63
commit 13eb2abbb2
9 changed files with 486 additions and 51 deletions

View file

@ -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

View file

@ -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(

View file

@ -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,
}

View file

@ -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")

View file

@ -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")

View file

@ -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;

View 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 %}

View file

@ -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"

View file

@ -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"})