fix(cart): enforce Stripe $0.50 minimum; add public sales stats

Stripe rejects charges under $0.50 — a $0.33 item was silently freed
because requires_payment threshold (64¢) skipped all card processing.

Changes:
- Cart + Invoice: add requires_stripe_payment (>= 50¢)
- cart_checkout GET + cart_complete_checkout POST: block Stripe when
  total < $0.50 and only Stripe is available; message points to DOGE/XMR
- Invoice Stripe charge loop: guard on requires_stripe_payment (safety net)
- Shop model: add public_sales_stats boolean (default False)
- shop_settings: toggle for public_sales_stats
- shop() view: include sales_count + sales_revenue when flag is on
- shop.j2: conditional stats section (sales count + revenue)
- Migration: 1a2b3c4d5e6f add_public_sales_stats_to_shop
This commit is contained in:
russell@unturf.com 2026-04-10 14:37:07 -04:00
parent 8945ee106b
commit d757cbecbe
7 changed files with 117 additions and 4 deletions

View file

@ -412,6 +412,14 @@ class Cart(RBase, Base):
return True
return False
@property
def requires_stripe_payment(self):
"""Return True if Stripe can legally process this cart, else False.
Stripe enforces a hard floor of $0.50 USD — below that, Stripe rejects the charge.
Use DOGE or XMR for purchases under $0.50.
"""
return self.total_in_cents >= 50
@property
def is_not_public(self):
return not self.public

View file

@ -260,6 +260,14 @@ class Invoice(RBase, Base):
return True
return False
@property
def requires_stripe_payment(self):
"""Return True if Stripe can legally process this invoice, else False.
Stripe enforces a hard floor of $0.50 USD — below that, Stripe rejects the charge.
Use DOGE or XMR for purchases under $0.50.
"""
return self.total_in_cents >= 50
@property
def human_created_timestamp(self):
"""Return the created timestamp in a human-readable format."""

View file

@ -151,6 +151,9 @@ class Shop(RBase, Base):
# Grayscale mode: render entire site in black and white
grayscale_mode = Column(Boolean, default=False)
# Public sales stats: show sales counts and revenue on the shop page (off by default)
public_sales_stats = Column(Boolean, default=False)
# Color filter: 0=off, 1=grayscale, 2=red, 3=green, 4=blue,
# 5=red+green, 6=red+blue, 7=green+blue
color_filter = Column(BigInteger, default=0)

View file

@ -0,0 +1,37 @@
"""add public_sales_stats to shop
Revision ID: 1a2b3c4d5e6f
Revises: 0ee5654cfe7d
Create Date: 2026-04-10 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '1a2b3c4d5e6f'
down_revision = '0ee5654cfe7d'
branch_labels = None
depends_on = None
def _column_exists(table, column):
from alembic import op as _op
from sqlalchemy import inspect
bind = _op.get_bind()
insp = inspect(bind)
return column in [c["name"] for c in insp.get_columns(table)]
def upgrade():
if not _column_exists('mps_shop', 'public_sales_stats'):
op.add_column(
'mps_shop',
sa.Column('public_sales_stats', sa.Boolean(), nullable=False, server_default='0'),
)
def downgrade():
if _column_exists('mps_shop', 'public_sales_stats'):
op.drop_column('mps_shop', 'public_sales_stats')

View file

@ -2,6 +2,14 @@
{% block content -%}
{% if sales_count is defined %}
<section class="shop-stats">
<span>{{ sales_count }} sale{{ 's' if sales_count != 1 else '' }}</span>
&nbsp;&middot;&nbsp;
<span>${{ '%.2f'|format(sales_revenue) }} earned</span>
</section>
{% endif %}
<section class="serp{% if request.shop.grid_lanes_enabled %} grid-lanes-enabled{% endif %}">
{% if products %}
{% for product in products %}

View file

@ -591,6 +591,16 @@ def cart_checkout(request):
and not request.monero_enabled
and not request.dogecoin_enabled
)
# Stripe has a hard $0.50 minimum — block carts below that when only Stripe is available
if cart.total_in_cents > 0 and not cart.requires_stripe_payment and only_stripe_enabled:
msg = (
"Stripe requires a minimum of $0.50. Use DOGE or XMR for purchases under $0.50.",
"error",
)
request.session.flash(msg)
return HTTPFound("/cart")
if cart.requires_payment and only_stripe_enabled and stripe_user_shop is None:
msg = ("Please enter your payment information.", "info")
request.session.flash(msg)
@ -709,6 +719,21 @@ def cart_complete_checkout(request):
request.session.flash(msg)
return HTTPFound("/cart")
# Block Stripe for carts under Stripe's $0.50 minimum when only Stripe is available
_only_stripe_at_checkout = (
request.stripe_enabled
and not request.paypal_enabled
and not request.monero_enabled
and not request.dogecoin_enabled
)
if cart.total_in_cents > 0 and not cart.requires_stripe_payment and _only_stripe_at_checkout:
msg = (
"Stripe requires a minimum of $0.50. Use DOGE or XMR for purchases under $0.50.",
"error",
)
request.session.flash(msg)
return HTTPFound("/cart")
# If Stripe is enabled and payment is required, ensure a Stripe customer exists
if (
cart.requires_payment
@ -754,7 +779,7 @@ def cart_complete_checkout(request):
for invoice in invoices:
shop = invoice.shop
if invoice.requires_payment:
if invoice.requires_stripe_payment:
stripe_user_shop = shop.stripe_user_shop(request.user)
if stripe_user_shop is None:
msg = (

View file

@ -230,9 +230,19 @@ def shop(request):
if "slug" not in request.matchdict:
return HTTPFound(f"/s/{shop.id}/{shop.slug}")
return {
"products": get_products_from_a_shop(shop),
}
ctx = {"products": get_products_from_a_shop(shop)}
if shop.public_sales_stats:
from ..models.invoice import Invoice
from sqlalchemy import func as sa_func
invoices = shop.invoices
ctx["sales_count"] = invoices.count()
ctx["sales_revenue"] = invoices.with_entities(
sa_func.coalesce(sa_func.sum(Invoice.total_in_cents), 0)
).scalar() / 100.0
return ctx
@view_config(route_name="shop_products", renderer="shop_products.j2")
@ -832,6 +842,20 @@ def shop_settings(request):
)
)
# Handle public sales stats setting
public_sales_stats_value = request.params.get("public_sales_stats")
if public_sales_stats_value is not None:
public_sales_stats = int(public_sales_stats_value) == 1
if shop.public_sales_stats != public_sales_stats:
shop.public_sales_stats = public_sales_stats
status = "enabled" if public_sales_stats else "disabled"
request.session.flash(
(
f"Public sales stats are now {status}",
"success",
)
)
# Handle stripe settings form
if form_section == "stripe-settings":
# Handle disable action