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:
parent
8945ee106b
commit
d757cbecbe
7 changed files with 117 additions and 4 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
@ -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>
|
||||
·
|
||||
<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 %}
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue