From d757cbecbe1a122a20bd2c955c32ef5aed5b6b0d Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Fri, 10 Apr 2026 14:37:07 -0400 Subject: [PATCH] fix(cart): enforce Stripe $0.50 minimum; add public sales stats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- make_post_sell/models/cart.py | 8 ++++ make_post_sell/models/invoice.py | 8 ++++ make_post_sell/models/shop.py | 3 ++ ...3c4d5e6f_add_public_sales_stats_to_shop.py | 37 +++++++++++++++++++ make_post_sell/templates/shop.j2 | 8 ++++ make_post_sell/views/cart.py | 27 +++++++++++++- make_post_sell/views/shop.py | 30 +++++++++++++-- 7 files changed, 117 insertions(+), 4 deletions(-) create mode 100644 make_post_sell/scripts/alembic/versions/1a2b3c4d5e6f_add_public_sales_stats_to_shop.py diff --git a/make_post_sell/models/cart.py b/make_post_sell/models/cart.py index 8dcc254..c29f265 100644 --- a/make_post_sell/models/cart.py +++ b/make_post_sell/models/cart.py @@ -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 diff --git a/make_post_sell/models/invoice.py b/make_post_sell/models/invoice.py index 2e6a6fd..e65d385 100644 --- a/make_post_sell/models/invoice.py +++ b/make_post_sell/models/invoice.py @@ -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.""" diff --git a/make_post_sell/models/shop.py b/make_post_sell/models/shop.py index b6406e4..40734ff 100644 --- a/make_post_sell/models/shop.py +++ b/make_post_sell/models/shop.py @@ -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) diff --git a/make_post_sell/scripts/alembic/versions/1a2b3c4d5e6f_add_public_sales_stats_to_shop.py b/make_post_sell/scripts/alembic/versions/1a2b3c4d5e6f_add_public_sales_stats_to_shop.py new file mode 100644 index 0000000..b58f7ed --- /dev/null +++ b/make_post_sell/scripts/alembic/versions/1a2b3c4d5e6f_add_public_sales_stats_to_shop.py @@ -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') diff --git a/make_post_sell/templates/shop.j2 b/make_post_sell/templates/shop.j2 index b4484a1..e69d615 100644 --- a/make_post_sell/templates/shop.j2 +++ b/make_post_sell/templates/shop.j2 @@ -2,6 +2,14 @@ {% block content -%} +{% if sales_count is defined %} +
+ {{ sales_count }} sale{{ 's' if sales_count != 1 else '' }} +  ·  + ${{ '%.2f'|format(sales_revenue) }} earned +
+{% endif %} +
{% if products %} {% for product in products %} diff --git a/make_post_sell/views/cart.py b/make_post_sell/views/cart.py index a930df5..78bc623 100644 --- a/make_post_sell/views/cart.py +++ b/make_post_sell/views/cart.py @@ -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 = ( diff --git a/make_post_sell/views/shop.py b/make_post_sell/views/shop.py index 8ff7e48..77c31a6 100644 --- a/make_post_sell/views/shop.py +++ b/make_post_sell/views/shop.py @@ -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