Commit graph

1169 commits

Author SHA1 Message Date
546e85416e
feat: MPS-24 Phase 2.1 — drop shop-vocabulary stems, surface more candidates
First Phase 2 deploy surfaced the wrong candidates on
shop.printableprompts.com: Students (53%), Resource (32%), Activities
(32%), Writing (31%), Practice (30%). These are shop vocabulary —
words that describe the whole shop, not categories within it. A stem
in 53% of products gives a shopper almost no information about which
subset a product belongs to.

- lib/tag_suggest.py: new max_share filter (default 0.4). Stems whose
  product share exceeds this fraction auto-drop as shop vocabulary.
  suggest_clusters now returns (clusters, filtered_count) so the UI
  can show how many stems were filtered.
- top_n default 20 → 50 so the long tail of niche categories surfaces.
- views/shop.py: ?max_share=0.3 (stricter), ?max_share=1 (disable),
  ?top_n=200 URL knobs on the suggestions endpoint — power users tune
  in the browser without redeploying. Floats over 1.0 are interpreted
  as percentages (40 → 0.4) so the URL accepts either form.
- templates/shop_tags.j2: filtered-count hint with copy-paste tuning
  knobs ("?max_share=0.3 stricter, ?max_share=1 to disable").
- scripts/backfill_tags.py: --max-share=0.4 CLI flag.
- Tests: test_suggest_clusters_filters_shop_vocabulary +
  test_suggest_clusters_max_share_one_disables_filter. Existing pure-
  function tests pass max_share=1.0 since their tiny fixtures would
  otherwise be penalised for being small. 1067 total passing.
2026-05-15 09:51:16 -04:00
60a6f02cbc
feat: delete buttons on /u/carts + on empty saved carts, with confirm
The saved-carts list (/u/carts) had no delete affordance, and the cart
detail page's Delete Cart button was hidden the moment a cart went empty
(the entire .cart-right column was gated on `not cart.is_empty`), so
abandoned empty saved carts couldn't be cleaned up.

- /u/carts now renders each row in a .content-card with a Delete form
  on every non-active cart (the active cart is marked "active" and
  cannot be deleted — the existing view rejects it anyway). The form
  carries onsubmit="return confirm(...)" so a stray click can't nuke a
  saved cart by accident.
- The cart detail page's right column now also renders when the cart is
  empty AND the viewer owns it AND it's not active — so the Delete Cart
  button (already gated on non-active) becomes reachable from there too.
  Added the same confirm prompt on that form.
- carts.j2 rewritten to use the design system (.content-card,
  --surface-dim cart-rows, --color-green active accent, --color-danger
  delete button) instead of the prior bare <h2> + <section> + <a> stack.

Tests: TestUserCartsList — 3 functional tests covering inactive cart
shows delete (active doesn't), POST deletes and redirects + row gone,
POST against active cart is rejected (flash + redirect). 3 pass.
2026-05-15 09:45:11 -04:00
14ebda23a0
fix: analytics charts now show y-axis units & tick labels
5 tick labels (0/25/50/75/100% of max) appear on every analytics
chart's y-axis — line_chart macro accepts unit ("s") or as_pct=True
for percentage charts, plus an optional axis_label rendered top-left
above the highest tick. Daily-views bar chart gets the same treatment
inline (unit "views").

Per chart:
  Daily views        bar  → "views" axis, integer ticks
  Session Duration   line → "s" unit, "seconds" axis label
  Engagement         line → 0-100% formatting
  Bounce Rate        line → 0-100% formatting
  External Referrer  line → "visits" axis, integer ticks

viewBox widens from 560→610 to reserve ~50px on the left for the
y-tick labels; SVG scales to container so no CSS change is needed.
2026-05-15 09:35:01 -04:00
4cbd7e4b20
fix: scope /u/notifications + navbar badge to the current shop
Operator users may own multiple custom-domain shops (e.g. shop.unturf.com
and media.unturf.com). Notifications carried a shop_id at insert time
already (lib/notifications.py, views/offer.py:_drop_offer_notification),
but the listing + the navbar unread count queried by user_id only, so
each shop's site surfaced every other shop's offers / sales / auctions.

Both count_unread_notifications and get_notifications_for_user now take
an optional `shop` kwarg. When supplied, they filter to that shop_id
plus shop_id IS NULL (so account-level rows — logins, etc. — still
follow the user regardless of which shop's site they're on). The
notifications view passes request.shop; the navbar badge does too via
add_unread_notification_count.

Tests: TestNotifications gains test_notifications_isolated_per_shop —
two shops owned by the same user, three rows (one per shop + one
shop-less); shop_a context shows shop_a + global, shop_b context shows
shop_b + global, no-shop call still returns all three. 4 in class pass.
2026-05-15 09:25:17 -04:00
1075004419
chore: one-off data fix — paid-but-stuck offers + orphan cart_offers
Two ACCEPTED offers on shop.unturf.com were paid via PayPal but never
transitioned ACCEPTED → PAID because the cart-drain TypeError 502'd
the complete-checkout flow before mark_paid could fire (fixed by
ef91469 + 31e06ff + 6e44027). The /u/offers page still lists them
as Accepted with active Pay $X buttons that would re-charge.

Migration 73c5cb973915:
  1. Flips the two specific offer IDs to PAID, stamps
     paid_timestamp, writes a synthetic OFFER_EVENT_PAY entry in
     the offer-event log so the History panel reflects reality.
  2. Sweeps every cart_offer row whose offer is now in PAID state.
     These are orphans from pre-6e44027 drained carts — the
     symptom is an empty cart still rendering the green
     "Offer accepted" banner with a $X.XX total in the navbar
     even though the offer is settled.
  3. Same sweep for cart_auction → SETTLED auctions, for parity.

The flip-to-PAID is guarded — it only runs on offers still in
ACCEPTED state, so a re-run is a no-op. The cart_offer / cart_auction
sweep joins to terminal-state offers/auctions, so it's idempotent.
2026-05-15 09:20:36 -04:00
5dbbe697b6
feat: MPS-24 Phase 2 — auto-suggest tags from title + description
Operator with 481 untagged products (printableprompts.com) gets a
one-click path to a usable categorization without hand-tagging each
product. Strictly suggest-then-approve — nothing writes Tag or
ProductTag rows until the operator clicks Apply on a cluster.

- lib/tag_suggest.py: pure-function clusterer. Tokenize title (weight 3)
  + description (weight 1, capped at 100 unique tokens per product),
  strip markdown / URLs / HTML, English + per-shop stopwords, simple
  suffix-strip stemmer, group by stem, drop stems matching existing
  tag slugs, rank by product count, label each cluster with the most
  frequent original word for its stem. No new deps, no ML.
- scripts/backfill_tags.py: CLI preview + --apply for a single shop.
- views/shop.py: shop_tags gains action=apply_suggestion (creates tag +
  bulk-attaches every product in cluster) and action=dismiss_suggestion
  (adds the cluster's words to shop.tag_stopwords_json so it never
  resurfaces). ?show_suggestions=1 triggers the cluster compute.
- templates/shop_tags.j2: "Suggest categories from titles + descriptions"
  button + suggestions well with per-cluster sample titles, Apply, and
  Dismiss buttons.
- 15 new tests (11 unit over tokenize / stem / cluster + 4 functional
  over the suggest/apply/dismiss flow). 1064 total passing.

On a printableprompts-style sample the clusterer surfaces Math, Reading,
Literacy, Seasonal, Novel, Activities, Comprehension — matching what an
operator would manually pick.
2026-05-15 09:09:40 -04:00
c03ca53fb4
feat: MPS-24 — shop home page categorization (tags + chips + sectioned lanes)
Operator feedback on shop.printableprompts.com flagged our flat default
home page as the reason for considering a move to Shopify. This adds an
opt-in home_layout selector with the navigation primitives shoppers expect
from a modern catalog — fewer clicks to a relevant product.

Phase 1 shipped (default unchanged for every existing shop):

- New Tag + ProductTag models, shop-scoped, many-per-product, flat (no tree)
- Shop.home_layout (0=flat / 1=chips / 2=lanes) plus tag/lane caps, optional
  featured strip, and per-shop tag stopwords for the Phase 2 auto-tagger
- home-layout-settings form section in shop_settings.j2
- Bulk tag editor at /s/{shop_id}/tags with apply/remove per product
- Public tag detail page at /s/{shop_id}/tag/{slug} (works without JS)
- Comma-separated tag input on the product edit form
- home.j2 / shop.j2 branch on layout — chip strip for layout 1, sectioned
  lanes for layout 2, flat unchanged for layout 0
- /search results page also receives the chip strip so shoppers can narrow
  keyword results by tag
- static/js/tag_filter.js progressively enhances chip clicks into in-place
  grid filtering via data-tag-slugs — zero navigation cost, capability-driven
  fallback to ?tag= URL nav with no JS
- New chip / lane CSS in common.css — tokens only, Grid only (no flexbox)
- Live tag-chip + tag-lane examples in /styleguide under #cards
- Idempotent Alembic migration creates 2 tables + 5 shop columns with
  server_default + _table_exists / _column_exists guards
- 24 new tests across unit + functional layers (1049 total passing)
- New "Ticket Scoping — One Feature, One Ticket" rule in CLAUDE.md;
  Phase 2 (deterministic auto-tag from titles) and Phase 3 (uncloseai-
  backed ML categorization behind a kill switch) stay under this ticket
2026-05-15 08:48:25 -04:00
3e4e663498
style: product page polish — content-card layering, comment form fix
Wrap the product page's Description, File info, and Comments sections in
a new .content-card pattern: white surface, --border-light, --elevation-1
shadow, --radius-lg, --space-5 padding. Replaces the loose <br/><br/>
separators each section used to rely on with proper visual chunks layered
above the page surface (material-style).

Comment form fixes:
- Add input[type=email] to the global input style block (was unstyled,
  picking up browser default narrow width — the inline style="width:100%"
  on .comment-form-field's <input> got swamped by browser default).
- Replace ad-hoc <div>/<br/> markup with .comment-form-field rows; inputs
  and textarea fill width via box-sizing:border-box; submit button is
  justify-self:start instead of stretching across the form.
- Individual comments now sit on a --surface-dim soft inset card with
  --color-navy left accent on .comment-reply; .comment-header is a
  baseline-aligned 3-col grid (name / date / title).

Back-to-shop link is now a small secondary anchor (.mps-button-small),
left-aligned, instead of a full-width button.

Styleguide gains three subsections: Content card (under #wells), Comment
form, and a sample rendered comment with reply (under #comments).

Tests: targeted product/comment/styleguide slice — 51 passed.
2026-05-15 08:27:34 -04:00
6e44027354
fix: delete cart_offer / cart_auction rows after payment finalizes
The previous fix drained line items from a paid cart but left the
cart_offer (or cart_auction) association row alive. The cart then
reads as is_negotiated=True with zero products — the cart page
renders the green "Offer accepted" card on top of an empty cart,
the cart total shows the override amount, and the navbar reads
"Cart $1.00 (0)" — 0 items, $1.00 total.

_finalize_auction_offer_state now also dbsession.delete()s the
association row immediately after flipping offer.state=PAID and
auction.state=SETTLED. The negotiation is single-use; once the
offer is PAID, the cart should be a plain empty cart.

The Cart.auction_offer_override_in_cents property short-circuits
on cart_auctions/cart_offers truthiness, so removing the row
makes is_negotiated return False, the negotiation card disappears,
and total_in_cents stops returning the override.
2026-05-15 07:55:17 -04:00
ef9146956d
fix: PayPal + Adyen checkout — drop bad quantity arg to cart.remove_product
This is the inner exception the tm.doom switch was meant to surface.
The PayPal complete-checkout flash now reads:

  "Payment processing failed: Cart.remove_product() takes 2 positional
   arguments but 3 were given"

Cart.remove_product(self, product) deletes the cart entry entirely;
it doesn't take a quantity. Two callsites in cart.py were passing
line_item.quantity as a second positional arg — both inside the
post-payment "drain the cart" loop that runs AFTER PayPal capture
succeeded:

  views/cart.py:1094 — paypal_complete_checkout
  views/cart.py:1345 — adyen_complete_checkout

PayPal got the buyer's money, the capture API succeeded, then the
cart-drain raised TypeError. Pre-tm.doom that bubbled into the
except block, hit tm.abort, blew up pyramid_tm.tm_tween →
uwsgi 500 → Caddy 502 — buyer charged, no invoice on our side.

Drop the quantity arg. cart.remove_product deletes the cart entry
unconditionally; the line item's full quantity is removed in one
shot, which is what the post-checkout drain wants anyway.

Stripe's user_cart_complete_checkout doesn't call remove_product at
all (it relies on cart.update_inventory + a session-clear elsewhere)
— that's why this only ever bit PayPal + Adyen.
2026-05-15 06:46:34 -04:00
31e06ffb0a
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.
2026-05-14 20:45:11 -04:00
7b9d7dc25e
fix: remove every mid-page state-notice alert on the offer page
Fox flagged this three times. Each pass I removed one or two of these
orphan stripes — green Accepted, blue can_act/is_open. Four were
still left in:

  is_declined  → red    "automatically declined — try a higher amount"
  is_withdrawn → yellow "This offer was withdrawn by the buyer."
  is_expired   → yellow "This offer expired before it was accepted."
  is_paid      → green  "Paid — this offer is complete."

The red declined alert was also actively wrong: it asserted "below
the seller's minimum and was automatically declined" even when the
seller manually declined a perfectly reasonable offer. That copy
was hardcoded; the template didn't know whether the auto-decline
threshold fired or a seller hit the button.

Burn all of them. The offer-state-badge in the header well already
shows the state (Accepted / Declined / Withdrawn / Expired / Paid /
Cancelled by buyer) and the action wells below carry every actionable
detail. The orphan stripes were redundant at best and lying at worst.

Functional test test_offer_page_shows_declined_notice → renamed to
test_offer_page_shows_declined_state_in_badge. Asserts:
  - offer-state-badge offer-state-3 + "Declined" text in header
  - "offer-state-notice" string NOT in body
  - "automatically declined" string NOT in body
2026-05-14 17:41:36 -04:00
3c8dbca7c6
fix: PayPal + Adyen complete paths flip offer.state ACCEPTED → PAID
A negotiated cart paid through Stripe's user_cart_complete_checkout
already fired _finalize_auction_offer_state(cart, request) — which
flips linked offers to PAID and linked auctions to SETTLED. But the
PayPal complete-checkout (cart.py:paypal_complete_checkout) and the
Adyen complete-checkout (cart.py:adyen_complete_checkout) paths
never called it.

Symptom: buyer pays a negotiated offer through PayPal or Adyen,
invoice is written, sale email fires — but offer.state stays at
ACCEPTED. The offer detail page keeps rendering the Pay $X / Cancel
buttons because `{% if is_accepted and not is_paid %}` is still True.
Even worse, the buyer could click Cancel after the payment had
processed (the cancel endpoint guarded on `state != ACCEPTED`, which
also still permitted it).

Fix: add the _finalize_auction_offer_state(cart, request) call to
both paypal_complete_checkout and adyen_complete_checkout, right
when successful_invoices is populated — before the cart line items
get drained. Each takes (cart, request) which both sites have in
scope. With the offer now in PAID:
- is_paid=True flips the offer.j2 gate, hiding Pay/Cancel.
- /o/{id}/cancel raises OfferRejected ("only accepted offers can
  be cancelled by buyer").
- _user_party / shop_offers / buyer dashboards all read the right
  terminal state.

Note: this only patches the cart.py callsites. Webhook fallbacks
(views/webhooks.py) and the crypto-watcher finalize path
(lib/crypto_watcher/__init__.py) still don't call mark_paid because
they only have `invoice`, not `cart`. Follow-up will route those
through a `_finalize_invoice_negotiation(invoice, session)` helper
once that's needed; the cart.py paths cover the common case.
2026-05-14 17:36:44 -04:00
9fc280fd7b
fix: email images — propagate shop_cdn_endpoint + thumbnails in offers
Two coupled fixes the rendered sale email exposed:

  <img src="None/<shop_id>/<product_id>/thumbnail1?ts=...">

1. ShopContextRequestWrapper (lib/crypto_watcher/__init__.py:1243)
   wraps env_request for email-rendering inside the watcher loop. It
   overrode domain / host_url / app and proxied everything else via
   __getattr__. send_purchase_email + send_sale_email read
   `request.shop_cdn_endpoint` — but that's a reified Pyramid request
   method, not a static attr on env_request. __getattr__'s default
   returned None, and the email's <img src> became `None/.../...`.

   Fix: add explicit `shop_cdn_endpoint` (and `shop`) properties on
   the wrapper, derived from the wrapper's `_shop`. Order:
     - BYOB shop with primary_s3_cdn_endpoint set → that
     - else `app["bucket.secure_uploads.get_endpoint"]` (MPS default)
     - else None
   The BYOB branch also defends against an enabled-but-blank
   primary_s3_cdn_endpoint — drops to the default rather than
   returning None.

2. Offer + auction-outbid emails carried no product thumbnail at
   all — bummer, since the recipient can't visually identify which
   item the negotiation is about. New _product_thumbnail_html(request,
   product) helper renders a 184px-max <img> identical to the
   purchase/sale shape (or empty string if the product has no
   thumbnail1 extension / no CDN endpoint).

   Wired into:
     - send_offer_received_email
     - send_offer_accepted_email
     - send_offer_countered_email
     - send_offer_declined_email
     - send_offer_withdrawn_email
     - send_offer_buyer_cancelled_email
     - send_auction_outbid_email

   Templates in lib/mail_messages.py gained a `{thumbnail}` slot
   between the headline and the click-through link. Text variants are
   unchanged (no inline images in text email).

Both fixes target the same surface: every transactional email now
renders the right image, regardless of whether it's sent from a
view (Pyramid request) or the crypto watcher loop (wrapped env_req).
2026-05-14 17:30:02 -04:00
da79b11cae
fix: buyer can withdraw offer while waiting on seller
The withdraw form on /o/<id> was nested inside the buyer's
`can_act` (your-turn) block, so a buyer who'd just opened an offer
or whose counter was awaiting the seller's response saw the
"Waiting on the other party" panel with no way to back out — they
either had to wait for auto-expiry or message the seller.

Add a symmetric withdraw form inside the `is_open` waiting block,
gated on actor_party == 0 (viewer is the buyer). The lib already
permits withdraw at any non-terminal pre-accept state
(lib/offer.py withdraw_offer); only the UI was the bottleneck.

Sellers don't get a symmetric "pull out" here — they decline
instead, which lives in their can_act block.

Regression test test_buyer_sees_withdraw_button_while_waiting_on_seller
asserts the form action and "Withdraw offer" copy render on a
PENDING offer from the buyer's view.
2026-05-14 17:03:09 -04:00
f9216befd6
fix: notification helpers never propagate exceptions
CI on commit 5f735ee broke two double-spend-protection unit tests:

  TypeError: unsupported format string passed to MagicMock.__format__

The new notify_purchase_and_sale() call inside the crypto-watcher
finalization path runs `f"${invoice.total:.2f}"` to compose the
notification body. The double-spend tests pass a MagicMock as the
invoice — its .total is a MagicMock too — and the format-spec call
fails before _safe_add even runs.

Fix: wrap every public notify_* helper in a top-level
try/except-and-log. A notification persist failure must not
propagate up the payment-finalization stack (it didn't matter in
prod because real invoices format fine, but the unit-test mocks
exposed the contract gap).

All five helpers updated: notify_purchase_and_sale,
notify_auction_outbid, notify_auction_won,
notify_auction_ended_no_winner, notify_offer_expired. Inner
functions hold the actual logic; the outer wrapper is just the
swallow-and-log shield.

This also stops a transient DB or attribute error inside the
helper from rolling back the payment txn — same defense the
_safe_email wrapper provides for mail sends.
2026-05-14 16:44:45 -04:00
5f735ee1d3
fix: drop redundant blue state-notice on offer page; default 48h → 72h
Two cleanups:

1. The orphaned blue "Waiting on the other party to respond" stripe
   (alert-info-bg) appeared between the offer header and the
   "Waiting on the other party · They have in X to respond" well
   below — identical copy, twice. Same pattern as the previously
   removed green "Offer accepted" banner. Dropping both the can_act
   ("It's your turn — accept, counter, or decline below") and
   is_open branches of the state-notice. The "Your turn" section
   heading and the waiting well right below already carry the
   message, with the live countdown that the alert lacked.

   Kept: DECLINED, WITHDRAWN, EXPIRED, PAID — those have no
   follow-on action block, so the alert is the only signal.

2. DEFAULT_OFFER_EXPIRATION_HOURS 48 → 72 (3 days). Fox: 48h still
   too tight for sellers checking shop mail intermittently. 7 days
   was too generous, 48 hours was on the strict side. 72 hours
   covers a long weekend.

Per-shop overrides are unchanged — operators tweak
offer_expiration_hours via offer-settings on /s/<shop>/settings.
2026-05-14 16:26:23 -04:00
547cc14589
docs: notification system + design-system surface for offers/auctions
- New docs/notifications.md: schema, kind matrix, breadcrumb walk,
  per-call-site wiring, UI surfaces, read-but-not-deleted semantics,
  non-fatal design.
- docs/architecture.md feature-toggle matrix extended with
  make-an-offer (shop + per-product gate), pre-accept expiration
  window, post-accept pay window, and the always-on notification
  surface. Related-docs section now links the new notifications doc
  + the existing auction-house / make-offer state-machine docs.
- docs/design-system.md component library extended with every class
  shipped this offer/auction/notification cycle: cart-negotiation-card
  + deadline + pill, offer-pay-cta-actions row, auction-winner-pay
  well, product-add-disabled-note, shop-offers-page width override,
  notification-badge pill + row + breadcrumbs + read-fade behavior,
  billing redesign classes, and the [data-pay-deadline] tick
  convention.
2026-05-14 14:49:39 -04:00
d9bd95bfee
refactor: /version reads CI commit-hash.txt with layered fallbacks
Ported remarkbox's pattern over to MPS. views/version.py now tries
sources in order:

  1. /opt/make_post_sell/commit-hash.txt          (CI deploy artifact)
  2. /opt/make_post_sell/env/commit-hash.txt      (alt salt layout)
  3. <package>/../commit-hash.txt                 (relative)
  4. <package>/GIT_HASH                           (setup.py legacy)
  5. git rev-parse --short HEAD                   (dev environment)
  6. "unknown"                                    (last resort)

MPS CI's build stage already writes commit-hash.txt to the artifact
tarball (.gitlab-ci.yml:38 `echo $CI_COMMIT_SHA >> commit-hash.txt`);
salt deploys it. setup.py's GIT_HASH rewrite still runs at install
time as a redundant fallback, so any environment that hasn't migrated
to commit-hash.txt yet keeps working.

Dev environments fall through to git rev-parse, which is faster and
more accurate than the previous setup.py-rewrites-source pattern that
required the file to be in the package at runtime. With this change,
/version returns the actual deployed commit hash everywhere — no
stale GIT_HASH file in git history (the file is .gitignored as of
c383c41).
2026-05-14 14:49:27 -04:00
a352a9381d
feat: auction-won / auction-ended / offer-expired notifications
Plug the remaining state-machine gaps the previous notification
batch left dark. With auction_tick and offer_tick running on cron,
buyers and sellers now learn about every passive transition:

- auction_tick ACTIVE → ENDED (with winner) drops
  notify_auction_won for the winner — they see the pay CTA + 48h
  payment_deadline before it lapses. Previously only the email
  fired (and only if SMTP succeeded); now the in-app row also
  exists with breadcrumbs Shop → Product → Auction.
- auction_tick ACTIVE → ENDED (without winner / reserve not met)
  drops notify_auction_ended_no_winner for shop owners so they
  can decide to relist.
- offer_tick PENDING/COUNTERED → EXPIRED and ACCEPTED → EXPIRED
  both drop notify_offer_expired for buyer + shop owners. The
  buyer was the most-affected silent case: they made an offer,
  the seller never responded, the offer auto-expired, the buyer
  never knew.

Four new notification kinds in models/notification.py:
auction_won, auction_ended_no_winner, auction_cancelled
(reserved — no call site yet), offer_expired.

lib/notifications.py orchestrator refactored: _resolve_session()
accepts either a Pyramid request or a SQLAlchemy session so the
same helpers work in views AND tick jobs.

Integration tests in TestAuctionTickIntegration and
TestOfferTickIntegration assert the right row exists with the
right kind + FK after each tick.
2026-05-14 14:49:08 -04:00
1977d3361e
feat: purchase/sale + auction-outbid notifications + read-fade UX
Three remaining notification surfaces wired:

1. Purchase (buyer) + sale (every shop owner) notifications drop on
   every cart-completion site. Coverage:
   - views/cart.py x3 (Stripe / PayPal-create / Adyen) — alongside the
     existing send_purchase_email + send_sale_email pair
   - views/webhooks.py x4 (PayPal capture / approved / Stripe / Adyen)
   - lib/crypto_watcher/__init__.py x3 (Monero / Dogecoin / confirmed
     duplicate-path) — gated on the existing sales_email_sent flag so
     a rescan can't write duplicate rows
   Each notification carries invoice_id, so MpsNotification.breadcrumbs
   walks Shop → Product (first line item) → Invoice (/i/<id>).

2. Auction outbid: when a new bid lands and the prior bidder is
   bumped, alongside send_auction_outbid_email the prior bidder gets
   a row with auction_id set — breadcrumbs walk Shop → Product →
   Auction (/a/<id>).

3. Read rows stay visible. Fox's clarification: mark-read must not
   delete; just fade. The row stays in the list (still clickable,
   breadcrumb still works); only the unread accent class and the
   opacity differ. Tokens: .notification-row has opacity 0.65;
   .notification-row-unread overrides to 1 + alert-info-bg +
   left accent border. test_read_notifications_remain_visible_but_faded
   asserts the row count stays the same in DB and the rendered
   subject is still in the HTML after dismiss.

Shared orchestration in new lib/notifications.py — notify_purchase_and_sale
and notify_auction_outbid keep the call sites to one line each.

3 new functional tests, 108/108 in the offer/auction/cart sweep.
2026-05-14 10:22:04 -04:00
236763b329
feat: in-app notifications with badge, list page, and breadcrumb backlinks
Every transactional email on the offer state machine now also drops a
row in a new mps_notification table — the user has a permanent in-app
inbox even if they never opened the email.

Schema (mps_notification, migration 5d01b163b805):
- user_id           recipient
- shop_id           which shop this is about (nullable)
- kind              discriminator (offer_received, offer_accepted,
                    offer_countered, offer_declined, offer_withdrawn,
                    offer_buyer_cancelled, purchase, sale,
                    auction_outbid)
- subject, body     denormalized text so deleting the source entity
                    doesn't blank the row
- link_url          primary click-through (/o/<id>, /a/<id>, /i/<id>)
- offer_id, auction_id, invoice_id
                    optional FKs for breadcrumb rendering
- created/updated_timestamp
- read              bool, drives the unread badge
- read_timestamp
Composite index on (user_id, read, created_timestamp) for cheap
unread-count queries.

Surfaces:
- request.unread_notification_count (reified) drives a navbar badge
  next to the profile name and a duplicate badge on the /u/settings
  "Notifications" button.
- /u/notifications lists rows newest-first with the kind label, time
  delta (ago.human), subject/body, and a breadcrumb chain back to
  source entities. MpsNotification.breadcrumbs walks shop → product
  → offer/auction/invoice for any combination of attached FKs.
- /u/notifications/{id}/read marks a single row read; bulk
  "Mark all read" on the list page hits /u/notifications/read-all.

Wired into every offer state transition (open, counter, accept,
decline, withdraw, buyer-cancel) alongside the existing email sends.
_safe_email and notification persist are now decoupled — SMTP outages
no longer block notification creation. (This was the regression the
new TestNotifications suite caught: pre-fix, a refused SMTP swallowed
the notification persist call too.)

Tokenized CSS for the badge (.notification-badge pill, danger color)
and the list rows (.notification-row, .notification-row-unread with
left accent border, .notification-row-breadcrumbs trail). Grid only,
no flex, no inline styles.

Tests TestNotifications.test_offer_open_drops_received_notification_for_seller
and test_badge_count_and_mark_read drive the full flow: buyer opens
offer → seller's row exists with breadcrumbs (Shop → Product → Offer)
→ /u/settings badge renders → /u/notifications/read-all clears.
2026-05-14 09:54:24 -04:00
9dc6b7be81
feat: email both parties on every offer state transition
Previously the only emails on the offer state machine were:
  - PENDING → seller (offer received)
  - auto-accept / seller-accept → buyer (offer accepted)
  - cart paid → buyer + seller (purchase / sale)

Every other transition was silent — buyer countered, seller
countered, seller manually declined, buyer withdrew before accept,
buyer cancelled after accept. The buyer-cancelled-after-accept case
stung most: the seller had accepted and was awaiting payment that
was never coming, with no notice except by polling /s/<shop>/offers.

Five gap-plugs:

1. Buyer counters back → seller(s) emailed (all shop owners).
2. Seller counters → buyer emailed.
3. Seller manually declines → buyer emailed. (auto-decline stays
   silent — the submit flash already conveys it inline.)
4. Buyer withdraws (pre-accept) → seller(s) emailed.
5. Buyer cancels (post-accept) → seller(s) emailed with explicit
   "buyer cancelled accepted offer" copy so the seller stops
   expecting payment.

Templates: OFFER_COUNTERED_{TEXT,HTML}, OFFER_DECLINED_{TEXT,HTML},
OFFER_WITHDRAWN_{TEXT,HTML}, OFFER_BUYER_CANCELLED_{TEXT,HTML} in
lib/mail_messages.py.

Helpers: send_offer_countered_email, send_offer_declined_email,
send_offer_withdrawn_email, send_offer_buyer_cancelled_email in
lib/mail.py — same pattern as send_offer_received_email +
send_offer_accepted_email.

Each handler in views/offer.py snapshots the pre-action state, runs
the action, and only emails on the actual transition (so a retried
POST against an already-terminal offer doesn't re-fire the email).
All sends go through _safe_email which logs + swallows exceptions:
mail failure cannot 500 an offer-state HTTP response.
2026-05-14 09:21:23 -04:00
7870c9425e
fix: dedupe flash messages — render-level + request.flash_once helper
Crypto status-poll endpoints flashed "Payment received! Waiting for
confirmations…" on every refresh while a quote was in the
received-but-not-confirmed state. The flash queue is a list, so
identical alerts piled up — fox's screenshot showed the same line
repeated 18+ times stacked down the page.

Two-layer fix:

1. Render-level dedupe in templates/snippets/flash-alerts.j2.
   Pop the queue once, walk it, render each unique (message, level)
   pair only the first time it's seen. Safe regardless of how the
   queue was populated — covers any other view that might flash
   duplicates without the helper.

2. New request.flash_once(message, level) helper (request_methods.py)
   that peeks the queue and skips the append if (message, level) is
   already there. Switch the two crypto status-poll handlers
   (views/crypto.py — Monero block ~820, Dogecoin block ~910) to use
   it. Both endpoints are hit on every poll, so the queue-level
   dedupe matters even with render-level dedupe (other consumers of
   the queue would still see duplicates, and the queue would balloon).

The flash queue itself stays a list (order-preserving) — set would
lose insertion order, which matters when multiple distinct alerts
need to render top-to-bottom. The peek-then-skip pattern keeps the
list semantics while preventing dup growth.
2026-05-13 20:00:54 -04:00
89156a5d6c
fix: invoices charge negotiated price, not list (Stripe/PayPal/Monero/DOGE)
CRITICAL financial defect. Cart UI showed the negotiated $21 total
for an accepted offer (list was $42), but EVERY payment pipeline —
Stripe (cart.py:848), PayPal (cart.py:1164), Monero quote
(crypto.py:269), Dogecoin quote (crypto.py:538) — pulled
invoice.total_in_cents, which summed line items and ignored the
cart_offer / cart_auction override entirely. Buyer's DOGE quote
asked for ~373 DOGE (≈$42 USD) for an offer they negotiated to $21.
Seller would have eaten $21 per accepted offer.

Fix:

- New column mps_invoice.negotiation_override_in_cents (nullable
  BigInteger). Migration 27bc1bfc33dc adds it idempotently.
- Invoice.total_in_cents short-circuits to the override + handling
  when the column is set. Coupons / discount math is bypassed (the
  buyer already negotiated; we don't stack on top).
- Invoice.apply_cart_negotiation(cart) helper copies the cart's
  override onto the invoice in one call. Idempotent. No-op for
  non-negotiated carts.
- Wired into every invoice-from-cart construction site (7 total):
  - views/crypto.py:161 (Monero quote)
  - views/crypto.py:454 (Dogecoin quote — fox's screenshot)
  - views/cart.py:827 (Stripe checkout)
  - views/cart.py:974 (PayPal create-order)
  - views/cart.py:1153 (Adyen sessions)
  - views/cart.py:1258 (PayPal complete-checkout)
  - views/cart.py:1388 (post-PayPal-approval finalize)

Two integration tests cover the model:
- test_negotiation_override_short_circuits_total walks the
  invoice-from-negotiated-cart flow, asserts $42 → $21 transition
  after apply_cart_negotiation, and confirms handling still adds
  on top.
- test_apply_cart_negotiation_noop_when_not_negotiated proves the
  helper is safe to call on plain carts (no negotiation_override
  set, line items sum normally).

Legacy invoices already in the DB have NULL on the new column —
they keep their line-item-summed totals, untouched.
2026-05-13 18:29:50 -04:00
309fae3a61
fix: offers/bids inbox polish + cart-checkout spacing + Pay/Cancel row
Three UI fixes batched (all touched adjacent templates):

1. /u/offers, /u/bids, and /s/{shop}/offers tables were cramped on
   desktop: max-width was the .one-column 600px constraint, so the
   Product column word-wrapped and timestamps wrapped onto multiple
   lines. Widen .shop-offers-page to 1100px on desktop, give
   Product the auto-flow column and tag the rest with .col-narrow
   (nowrap) / .col-action (right-aligned). Replace strftime UTC
   strings with ago.human() relative deltas ("2 hours ago",
   "5 minutes ago") in the three views that feed those tables.
   Mobile (<720px) collapses each row to a block list — the dense
   table layout is desktop-only.

2. /u/cart/{id}/checkout had a visible gap between the
   "Pay with Credit Card — Add Card" CTA and the PayPal button —
   a redundant <br/> at the top of the PayPal block. Drop it; the
   buttons' own margins now space them naturally.

3. Buyer's accepted-offer pay panel had Pay and Cancel stacked
   vertically with Cancel rendered as a faint text link. Per fox:
   put them on the same line, Cancel on the left. New
   .offer-pay-cta-actions grid (auto 1fr) renders Cancel as a
   neutral mps-button at start, Pay as the green primary at end.
   Stacks on viewports under 600px.

All token-driven CSS, Grid only (no flexbox), no inline styles.
2026-05-13 18:22:20 -04:00
2067961a64
fix(tests): update accepted-offer assertion to match removed banner
CI on commit 5272988 (drop orphaned green "Offer accepted" banner)
broke one stray assertion in TestOfferRoutes that still searched for
the removed string. Update the test to assert on the surfaces that
DO carry the state now:

- the offer-state-badge with class offer-state-1 (visible "Accepted")
- the Pay $96.00 now CTA (the actual call to action)
- the /o/{id}/checkout form action

Same intent, current markup.
2026-05-13 17:10:11 -04:00
36bb6b2ea6
fix: disable Add To Cart on product page when active cart is negotiated
The server-side guards on /cart/add and /cart/{id}/quantity stop the
exploit, but the buyer still saw an enabled "Add To Cart" button that
bounced them. Per fox: disable the button with a one-sentence caption
explaining why, instead of hiding it. A hidden button confuses users;
a disabled one with caption keeps the affordance visible and teaches
the user how to re-enable it (check out or save the current cart).

product.j2 now:
- Adds `disabled` to both Add To Cart buttons (physical + digital
  paths) when `request.active_cart and request.active_cart.is_negotiated`.
- Renders a `.product-add-disabled-note` paragraph beneath with the
  reason: "Your active cart is locked to an accepted offer/auction.
  Save or check out that cart before adding other items."

Token-driven CSS for the caption (no inline styles, no hardcoded
colors). The button keeps its mps-button styling — browser disabled
state is sufficient visual contrast.

Test test_product_page_disables_add_to_cart_on_negotiated_cart walks
the realistic flow: buyer accepts an offer on product A, then visits
product B (a separate buy-now product on the same shop) and sees the
button disabled with the caption.
2026-05-13 16:50:27 -04:00
cb9b3df0cf
fix: lock negotiated cart quantity — refuse /cart/add + quantity bump
The cart override returns offer.current_amount_in_cents regardless
of line-item quantity. So a buyer who:
  1. Got an offer accepted at $21 (list $42)
  2. Hit /o/{id}/checkout → got a cart with 1 unit + cart_offer
  3. Went back to the product page and clicked "Add to cart"
…would end up with a cart showing 2 units of a $42 product but
charged the single negotiated $21. Seller eats $84 of merch for $21.
Same hole on /cart/{id}/quantity — bumping quantity directly skipped
the override re-check.

Plug both:
- cart_add_product refuses when active_cart.is_negotiated. Flashes
  "This cart is locked to your accepted offer/auction — save or
  check out this cart before adding other items."
- cart_quantity_product refuses when cart.is_negotiated. Flashes
  "Quantity is locked on an accepted offer/auction — the agreed
  price is for one unit only."

Two regression tests cover the gap: one POSTs /cart/add of the same
product, one POSTs /cart/{id}/quantity setting quantity=2. Both
assert cart.get_product_quantity(product) stays at 1 and the
response flashes "locked".

UI tightening (hiding the add-to-cart button on the product page
when the active cart is already negotiated to a different product)
is a follow-up — this commit is the server-side defense.
2026-05-13 16:43:16 -04:00
527298874e
fix: drop redundant green "Offer accepted" banner on offer page
The ACCEPTED state-notice alert floated between the offer-header
well and the offer-pay-cta well — a green stripe in the middle of
the page with nothing visually anchoring it. Worse, the message it
carried duplicated content the next block already showed:

- Buyer: the "Pay $X now" CTA right below is itself the call to
  action. "Offer accepted — pay now to complete your purchase" was
  noise on top.
- Seller: the "Awaiting payment" well right below already names the
  buyer + amount and explains the auto-email + share link.

Drop the is_accepted branch. The state badge in the offer header
still shows "Accepted" — so the state is never invisible — and the
pay / awaiting blocks carry the per-role copy.

Other state-notice alerts (DECLINED, WITHDRAWN, EXPIRED, PAID,
can_act, is_open) stay — they're useful precisely because their
states have no follow-on action block.
2026-05-13 16:38:53 -04:00
c383c41f92
chore: stop tracking GIT_HASH — setup.py owns it at install time
Each feature commit was being followed by a "bump GIT_HASH to X"
commit whose value was always one behind HEAD (the bump itself
shifted HEAD again). The file in git was effectively a stale
record that setup.py overwrote on every CI install anyway via
`git rev-parse --short HEAD`. Pure churn — half the commit log
was these bump commits.

Untrack the file, .gitignore it, drop the bump from CLAUDE.md's
AUTO-PUSH and post-work-chores guidance. setup.py keeps writing
the real HEAD at install; views/version.py still reads it. The
/version endpoint behavior is unchanged — it just stops requiring
a follow-up commit per ship.
2026-05-13 14:11:48 -04:00
3133ab24f1
bump GIT_HASH to becd6e3 2026-05-13 14:02:57 -04:00
becd6e359f
fix: tighten default offer-expiration window from 7 days to 48 hours
7 days for a seller to even respond to a buyer's offer was too
generous. eBay-style Best Offer caps at 48 hours; nobody enjoys
waiting a week to find out their offer is dead.

Change is purely the new-shop default — existing shops keep
whatever their offer_expiration_hours column is set to. Operators
can tighten or widen via the offer-settings form on their shop
settings page.

(Default-to-default coverage in test_integration.py updated to
match.)
2026-05-13 14:02:50 -04:00
fc02c8189c
bump GIT_HASH to 762462a 2026-05-13 13:38:38 -04:00
762462a16c
feat: separate "respond" countdown on PENDING/COUNTERED offers
The offer page already showed a pay-by countdown once the offer
was ACCEPTED. The negotiation window (pre-acceptance) had a
deadline server-side (offer.expires_timestamp) but no countdown
in the UI — buyers and sellers had to guess how long they had to
respond.

offer_page view now exposes respond_deadline_human (ago.human)
and respond_deadline_timestamp_ms alongside the existing pay
deadline pair. offer.j2 surfaces it in two places:

- "Your turn" panel: "Respond in 5 days, 12 hours, or this offer
   auto-expires." (the user is the current_party, can act).
- "Waiting on the other party" panel: "They have in 5 days, 12
   hours to respond, or this offer auto-expires." (the other
   party owes the next move).

Both render through the same [data-pay-deadline] attribute the
existing ticker scans — the surrounding copy disambiguates
respond-vs-pay. One countdown shape, two semantic uses, depending
on state.

Regression test test_pending_offer_renders_respond_countdown locks
in the markup (PENDING offer, seller view, "Respond" + the regex
for the prose ago.human() output).
2026-05-13 13:38:32 -04:00
fedd6abe35
bump GIT_HASH to 0c0258f 2026-05-13 13:35:44 -04:00
0c0258f9bb
fix: legacy offer countdown + add countdown to cart page
Two coupled fixes the live shop.unturf.com data exposed:

1. Legacy accepted offers (those flipped to ACCEPTED before the
   accepted_timestamp column existed) have NULL there, so
   acceptance_pay_deadline_ms returned None and the countdown never
   rendered. Fall back to last_action_timestamp — for an
   untouched-since-acceptance offer that IS the moment of acceptance
   (the accept event was the last action recorded). Also guard the
   property to only return a deadline when state == ACCEPTED, so
   PAID / EXPIRED / WITHDRAWN offers don't accidentally surface
   stale deadlines.

2. Cart page (/cart/{id}) had no countdown — the buyer landed in
   the cart from the offer accept email, saw the agreed price, but
   no live indicator of when this deal expires. Cart.negotiation_pay_
   deadline_ms exposes the linked offer or auction's deadline;
   cart.negotiation_pay_deadline_human renders ago.human() for the
   no-JS fallback. cart.j2 adds a "Pay <strong>in 23 hours, 14
   minutes</strong>, or this offer expires." line inside the green
   negotiation card.

Pulled the countdown tick out of offer.js into a shared
static/js/pay-countdown.js so cart.j2 can include just the ticker
without pulling the offer-detail form wiring it doesn't need.
Other pages still load their own JS — offer.js and auction.js keep
their own implementations for now; this is the cart-page addition.
2026-05-13 13:35:38 -04:00
764e0ae1ef
bump GIT_HASH to 7ce8e54 2026-05-13 13:31:14 -04:00
7ce8e5431c
fix: My Offers button respects per-product allow_offers override
Shop.offer_enabled is the shop-level *default* — products can override
via allow_offers=True even when the shop default is off. The original
button gate on /u/settings only checked the shop-level flag, so a
buyer in a shop with offer_enabled=False but at least one product
opting in via allow_offers=True saw no button.

Add Shop.has_offer_products mirroring has_auction_products, but
accounting for the override:

  product accepts offers iff
      product.allow_offers IS TRUE
      OR (product.allow_offers IS NULL AND shop.offer_enabled IS TRUE)

Wire user_settings.j2 to gate on has_offer_products, and update the
/u/offers view's 404 guard to the same property — otherwise the
button appeared but the page 404'd.

Tests:
- test_shop_has_offer_products_property covers the four-cell matrix
  (shop default × product override).
- test_settings_offers_button_for_product_level_opt_in is the
  regression for the case fox hit on shop.unturf.com.
2026-05-13 13:31:08 -04:00
a7d9577631
bump GIT_HASH to cea6c39 2026-05-13 13:25:27 -04:00
cea6c398cd
fix(checkout): move PayPal save toggle below button, footnote at bottom
Two reorderings on the cart checkout right column:

1. The "Save PayPal for faster checkout next time" checkbox now lives
   *under* the PayPal button instead of above it. The primary action
   (the yellow PayPal button) stays at the top of the panel; the
   secondary toggle (remember this method?) follows. Saved-PayPal
   buyers see a "PayPal saved for quick checkout" banner above the
   button instead.

2. The "You can manage saved payment methods…" copy was inline under
   the checkbox. It's now a footnote at the bottom of the right
   column, with "account settings" linking to /billing — the actual
   surface where the user can disconnect PayPal or manage cards.
   Lives outside the save-toggle branch so saved-PayPal users see it
   too (they may want to disconnect from there).

Cleanup:
- All inline styles on the saved-banner / save-checkbox replaced
  with tokenized .cart-paypal-saved-banner / .cart-paypal-save-row /
  .cart-paypal-save-label / .cart-paypal-saved-check /
  .cart-paypal-manage-note classes. Grid only, design tokens only.
2026-05-13 13:25:20 -04:00
62e65beff6
bump GIT_HASH to e77784c 2026-05-13 13:06:44 -04:00
e77784cd61
fix: pay-by countdown reads as human time delta, not UTC wall-clock
Replaces the previous "2026-05-14 11:00 UTC" deadline strings and
the secondary "23h 14m 8s remaining" pill with a single in-place
prose delta on both offer and auction pages.

Server (no-JS fallback): ago.human(deadline, future_tense="in {}")
renders "in 23 hours, 14 minutes". Reaches for the same precision
the buyer cares about, in their reading style, without a wall-clock
string to mentally subtract from. Same library Russell Ballestrini
wrote — public domain, already a dep.

Client (JS-enhanced): offer.js / auction.js rewrite the same
<strong data-pay-deadline="..."> element once per second with a
prose delta computed locally ("in 23 hours, 14 minutes, 8 seconds").
The previous fmtRemaining returned compact "23h 14m 8s" which read
as code — switched to prose to match the server fallback.

Auction page also drops the dual element (separate countdown chip +
human deadline span); JS rewrites in place so the markup is half
the size.

Test test_accepted_offer_renders_pay_countdown_for_buyer locks in
the new markup: a `data-pay-deadline="…">in N units` regex match.
2026-05-13 13:06:34 -04:00
61cda404e9
bump GIT_HASH to b27c074 2026-05-13 13:01:18 -04:00
b27c0748a6
feat: live pay-by countdown on offer + auction pages (JS-enhanced)
When JS is available, the buyer now sees a live countdown next to the
absolute pay-by date on both the accepted-offer page and the
auction-won page. Without JS, the existing static "You have until
<date>" copy still renders — the countdown element is .js-only.

Offer (/o/{id}):
- offer_page view now exposes pay_deadline_timestamp_ms alongside the
  human string.
- offer.j2 adds a [data-pay-deadline] span next to the deadline copy
  on both the buyer (pay-now) and seller (awaiting-payment) sides.
- offer.js scans for [data-pay-deadline] every second and writes the
  formatted remaining time into .offer-pay-countdown-value. Reuses the
  same fmtRemaining shape as auction.js (Nd Nh Nm / Nh Nm Ns / Nm Ns).

Auction (/a/{id}):
- _serialize_auction adds payment_deadline_timestamp, user_is_winner,
  and is_settled.
- auction.j2 renders a new "You won this auction!" well when state is
  ENDED, the current user is the winner, the auction isn't SETTLED,
  and payment_deadline_timestamp is set. The well carries the pay
  button + a countdown that auction.js fills in. The human deadline
  is also resolved client-side (toLocaleString) so the buyer sees
  it in their own timezone.
- auction.js adds tickPayCountdown() in addition to the existing
  tickCountdown() that counts auction end.

Test fixture _accepted_offer now sets offer.accepted_timestamp (the
fixture bypasses accept_offer() which would set it for free).
test_accepted_offer_renders_pay_countdown_for_buyer locks in the
markup.
2026-05-13 13:01:11 -04:00
cd1ede638c
bump GIT_HASH to c5e966c 2026-05-13 12:33:55 -04:00
c5e966c33b
perf(tests): boot app + schema once per worker, wipe rows per test
Test suite hit GitLab's 1-hour pipeline timeout (58:41) — the test
step alone exceeded the cap and the deploy step never ran. Root cause
was per-test infrastructure cost:

- FunctionalTests.setUp/tearDown rebuilt the entire WSGI app and ran
  Base.metadata.create_all + drop_all for *every* test. Measured at
  ~640ms of pure DDL per test (36 tables + indexes); the app boot adds
  another ~400ms. ~1s of overhead per test before the test body even
  starts.
- DatabaseIntegrationTests had the same pattern.

Fix: lift app + engine + schema to classmethods that run *once per
worker process*. Per-test setUp now just hands the shared infra to
instance attrs and creates a fresh webtest.TestApp + dbsession.
Per-test tearDown aborts the pyramid_tm txn and wipes every row via
table.delete() in reverse FK order, with PRAGMA foreign_keys OFF
around the wipe so we don't have to compute a safe order for
circular refs.

Class-level state lives on FunctionalTests / DatabaseIntegrationTests
themselves (not `cls`) so all subclasses see the same instances on
attribute lookup. pytest-xdist worker isolation is unchanged — each
worker has its own sqlite file (conftest.py) and its own Python
process, so the class-level cache is per-worker.

Local timings (8 cores, -n auto):
- test_functional.py: 3:30 → 2:14   (36% faster, 291/291 pass)
- full suite:         ~7m → 3:49    (1012/1012 pass)

On CI (fewer cores), expected to drop from 58:41 to roughly
25-30 min — well under the 1h pipeline cap.
2026-05-13 12:33:44 -04:00
ffdcbf41d5
bump GIT_HASH to 10074a1 2026-05-13 11:06:39 -04:00
10074a1d20
fix: redesign /billing layout with design tokens + grid
The page was using the .two-column class (designed for the product
page mobile stack) which left the Stripe Element floating to the
right with massive whitespace and orphaned Review Order / Checkout
buttons disconnected from the content above. Inline styles all over
the PayPal section meant no token consumption.

Rebuild:
- New .billing-page wrapper, max-width 1000px, centered, CSS Grid.
- Active Card + Add Card now sit in a .settings-form-grid (1 col
  mobile, 2 cols desktop) so they align as equal-width siblings.
  Heading copy adapts: "Add a Card" when no card on file, "Add
  Another Card" when there is one.
- PayPal section moved to its own full-width well below; inline
  styles replaced with tokenized .billing-paypal-card,
  .billing-paypal-header, .billing-meta classes. Grid only.
- Review Order + Checkout buttons land in a .billing-actions well
  centered as a 2-col grid; Checkout is the green primary CTA.
  Collapses to a single stretched column on mobile.

No design system tokens are hard-coded — every color, gap, radius,
and font size pulls from tokens.css. CSS Grid only, no flexbox.
2026-05-13 11:06:33 -04:00
7b7d643b15
bump GIT_HASH to 8633470 2026-05-13 10:59:13 -04:00