fix: payment 502s + bump existing shops 168h→72h offer expiration

Two issues fox flagged from prod observation:

1. PayPal capture succeeded but MPS returned 502 Bad Gateway —
   buyer was charged, no invoice landed. Root cause traced via
   tmux-hosts journalctl on mps-uwsgi1:

     transaction.interfaces.NoTransaction
       File "pyramid_tm/__init__.py", line 146, in tm_tween
         if manager.isDoomed():
       File "transaction/_manager.py", line 88, in get
         raise NoTransaction()

   All four payment-complete-checkout exception handlers in
   views/cart.py called `request.tm.abort()` and returned
   HTTPFound. abort() yanked the transaction out from under
   pyramid_tm.tm_tween, whose post-view manager.isDoomed() check
   then raised NoTransaction → uwsgi 500 → Caddy 502.

   Fix: `request.tm.doom()` instead. Flags the txn for abort but
   leaves it for pyramid_tm to clean up — the documented pattern.
   Also added logging.getLogger(__name__).exception() at each
   except so the original failure is captured in journalctl
   instead of being swallowed into a flash message we can't see.
   The flash + 302 redirect path still works for the user.

   Four sites patched:
     - user_cart_complete_checkout (Stripe) x2 (CardError + Exception)
     - paypal_complete_checkout
     - adyen_complete_checkout

   The original inner exception in fox's PayPal case is still
   unknown — the bug masked it. Next failed payment will surface
   the real stack in journalctl.

2. Existing shops still showing 7-day (168h) seller-response
   window even after DEFAULT_OFFER_EXPIRATION_HOURS dropped to 72.
   The shop column default is for new rows only; rows already in
   the DB kept 168. Migration 7d6af811b6a1 bumps any shop still
   at the literal 168 down to 72; shops that explicitly customized
   (any other value) are left alone.
This commit is contained in:
russell@unturf.com 2026-05-14 20:45:11 -04:00
parent 7b9d7dc25e
commit 31e06ffb0a
No known key found for this signature in database
2 changed files with 72 additions and 4 deletions

View file

@ -0,0 +1,37 @@
"""bump existing shops offer_expiration_hours 168 to 72
Revision ID: 7d6af811b6a1
Revises: 5d01b163b805
Create Date: 2026-05-14 20:42:51.956494
Default offer expiration (pre-acceptance window) dropped from 168h
(7 days) 48h 72h across recent commits. Existing shops kept
whatever value they had at table-create time. This migration bumps
any shop still at the literal old default 168 to the new default
72. Shops that explicitly customized via the offer-settings form
(any value other than 168) are left alone.
"""
from alembic import op
import sqlalchemy as sa
revision = "7d6af811b6a1"
down_revision = "5d01b163b805"
branch_labels = None
depends_on = None
def upgrade():
op.execute(
sa.text(
"UPDATE mps_shop "
"SET offer_expiration_hours = 72 "
"WHERE offer_expiration_hours = 168"
)
)
def downgrade():
# No safe reversal — we don't know which shops were at 168 by
# default vs explicit. Leave them at 72 on rollback.
pass

View file

@ -21,6 +21,8 @@ from ..lib.mail import (
)
from ..lib.notifications import notify_purchase_and_sale
import logging
import stripe
import traceback
from datetime import datetime
@ -895,13 +897,24 @@ def cart_complete_checkout(request):
return HTTPFound(redirect_url)
except stripe.error.CardError as e:
request.tm.abort()
# doom() — not abort() — so pyramid_tm.tm_tween still owns
# the txn lifecycle. abort() removes the txn outright, then
# tm_tween's post-view manager.isDoomed() check raises
# NoTransaction → uwsgi 500 → Caddy 502. doom() leaves it
# for tm_tween to clean up.
request.tm.doom()
logging.getLogger(__name__).exception(
"stripe checkout failed (user-visible)"
)
msg = ("Payment failed. Please check your card details.", "error")
request.session.flash(msg)
return HTTPFound("/billing")
except Exception as e:
request.tm.abort()
request.tm.doom()
logging.getLogger(__name__).exception(
"stripe checkout unexpected failure"
)
msg = (f"Payment failed: {str(e)}", "error")
request.session.flash(msg)
return HTTPFound("/billing")
@ -1117,7 +1130,16 @@ def paypal_complete_checkout(request):
return HTTPFound("/cart")
except Exception as e:
request.tm.abort()
# doom() not abort() — see Stripe paths above for the
# pyramid_tm rationale (502 root cause on 2026-05-14).
# The original tm.abort() yanked the txn out from under
# pyramid_tm.tm_tween, whose post-view manager.isDoomed()
# check then raised NoTransaction → uwsgi 500 → Caddy 502.
# User got charged via PayPal/Adyen but no invoice landed.
request.tm.doom()
logging.getLogger(__name__).exception(
"payment complete-checkout unexpected failure"
)
request.session.flash((f"Payment processing failed: {str(e)}", "error"))
return HTTPFound("/cart")
@ -1359,7 +1381,16 @@ def adyen_complete_checkout(request):
return HTTPFound("/cart")
except Exception as e:
request.tm.abort()
# doom() not abort() — see Stripe paths above for the
# pyramid_tm rationale (502 root cause on 2026-05-14).
# The original tm.abort() yanked the txn out from under
# pyramid_tm.tm_tween, whose post-view manager.isDoomed()
# check then raised NoTransaction → uwsgi 500 → Caddy 502.
# User got charged via PayPal/Adyen but no invoice landed.
request.tm.doom()
logging.getLogger(__name__).exception(
"payment complete-checkout unexpected failure"
)
request.session.flash((f"Payment processing failed: {str(e)}", "error"))
return HTTPFound("/cart")