feat: /u/carts surfaces product list, checked-out marker, Activate button

- New mps_invoice.cart_id (nullable FK to mps_cart.id, idempotent
  migration 2dbdb8c89e66). Invoice.apply_cart_negotiation(cart) now also
  tags the source cart, so we have a one→many Cart.invoices back-ref —
  every checkout flow already routes through that method.
- /u/carts list: each row now renders the cart's product titles as
  links (so the user can re-open them). When a cart is empty but has
  invoices (the json_cart was cleared / replaced after checkout), the
  row falls back to listing the line items from those invoices plus a
  "View receipt" button, so the user can repurchase without digging
  through their invoice history.
- Status indicators: a green "active" tag on the current cart, a navy
  "checked out" tag on rows with linked invoices.
- Activate button alongside Delete on every non-active row (POSTs to
  the existing /u/cart/{id}/activate route).
- Layout uses grid-template-areas (summary | actions / products span
  both) and collapses to a single column at ≤600px.

Tests: TestUserCartsList grows three new cases — Activate flips the
active flag, non-empty rows show product titles + links, checked-out
empty carts surface the invoice line items + receipt link.
6 in class pass; full Checkout/Cart slice (42 tests) still green.
This commit is contained in:
russell@unturf.com 2026-05-15 10:01:35 -04:00
parent bb54152d47
commit 55137af986
No known key found for this signature in database
6 changed files with 230 additions and 27 deletions

View file

@ -69,6 +69,14 @@ class Cart(RBase, Base):
user = relationship(argument="User", uselist=False, lazy="joined")
shop = relationship(argument="Shop", uselist=False, lazy="joined")
# One cart can produce many invoices (multi-shop checkouts split into
# one invoice per shop). lazy=dynamic so /u/carts can cheaply check
# `cart.invoices.count()` without loading rows.
invoices = relationship(
argument="Invoice", lazy="dynamic", foreign_keys="Invoice.cart_id",
back_populates="cart",
)
def __init__(self, user=None):
# since shopping carts are a private thing which may optionally
# be granted public access, we use random uuid4 UUIDs to prevent

View file

@ -93,6 +93,12 @@ class Invoice(RBase, Base):
# the marketplace is responsible for charging the customer
# and paying out the shop owner, while taking a 15-35% cut.
market_id = Column(UUIDType, foreign_key("Market", "id"), nullable=True)
# the source cart this invoice was built from. nullable: historical
# invoices predate the link, and tests may build an invoice without
# a cart. apply_cart_negotiation() sets it during checkout so the
# /u/carts listing can mark a cart as "checked out" and surface a
# link to the resulting receipt.
cart_id = Column(UUIDType, foreign_key("Cart", "id"), nullable=True)
# the timestamp when the invoice was created.
created_timestamp = Column(BigInteger, nullable=False)
@ -129,6 +135,12 @@ class Invoice(RBase, Base):
# one to one.
market = relationship(argument="Market", uselist=False, lazy="joined")
# back-ref to the source cart, if known.
cart = relationship(
argument="Cart", uselist=False, foreign_keys=[cart_id],
back_populates="invoices",
)
# one to many.
# returns all the InvoiceLineItems.
# lazy="dynamic" returns a query object instead of an InstrumentedList.
@ -200,13 +212,22 @@ class Invoice(RBase, Base):
)
def apply_cart_negotiation(self, cart):
"""MPS-20 + MPS-21: if cart is offer- or auction-bound, copy
the negotiated override total onto this invoice so payment
processors charge the agreed price, not the list-price sum
of line items. Idempotent safe to call after invoice
construction at any point. Coupons / gift cards don't stack
on a negotiated price; this short-circuits both.
"""Bind this invoice to the source cart.
Two jobs (one method, called at exactly one place per checkout
flow, so it's where we wire both):
1. Tag self.cart_id so /u/carts can flag the cart as "checked
out" and link to this receipt.
2. MPS-20 + MPS-21: if the cart is offer- or auction-bound,
copy the negotiated override total so payment processors
charge the agreed price, not the list-price sum of line
items. Coupons / gift cards don't stack on a negotiated
price; this short-circuits both.
Idempotent safe to call multiple times during construction.
"""
if cart is not None:
self.cart_id = cart.id
if cart.is_negotiated:
self.negotiation_override_in_cents = (
cart.auction_offer_override_in_cents

View file

@ -0,0 +1,43 @@
"""add invoice.cart_id linking checkouts to source cart
Revision ID: 2dbdb8c89e66
Revises: 73c5cb973915
Create Date: 2026-05-15 09:49:59.440555
"""
from alembic import op
import sqlalchemy as sa
from make_post_sell.models.meta import UUIDType
# revision identifiers, used by Alembic.
revision = '2dbdb8c89e66'
down_revision = '73c5cb973915'
branch_labels = None
depends_on = None
def _column_exists(table, column):
conn = op.get_bind()
result = conn.execute(sa.text(f"PRAGMA table_info({table})"))
return any(row[1] == column for row in result.fetchall())
def upgrade():
"""Add nullable mps_invoice.cart_id (FK to mps_cart.id).
Historical invoices have NULL cart_id fine, the /u/carts listing
just won't link to a receipt for those rows. New checkouts set it
via Invoice.apply_cart_negotiation(cart)."""
if not _column_exists("mps_invoice", "cart_id"):
op.add_column(
"mps_invoice",
sa.Column("cart_id", UUIDType, nullable=True),
)
def downgrade():
if _column_exists("mps_invoice", "cart_id"):
with op.batch_alter_table("mps_invoice") as batch:
batch.drop_column("cart_id")

View file

@ -2378,33 +2378,90 @@ section.checkout-page .well {
justify-self: start;
}
/* Saved-carts list (/u/carts). Each row is link / meta / delete on a
3-column grid; the active row gets a subtle accent so it's obvious
which cart the user is currently working in. Grid only. */
/* Saved-carts list (/u/carts). Each row is a small grid:
[summary | actions]
[products spans both]
Active rows get a green accent; checked-out rows get a navy accent
and surface the invoice line items so the user can re-purchase from
here. Grid only. */
.carts-list {
display: grid;
gap: var(--space-2, 8px);
}
.cart-row {
display: grid;
grid-template-columns: 1fr auto auto;
align-items: center;
gap: var(--space-3, 12px);
padding: var(--space-2, 8px) var(--space-3, 12px);
grid-template-columns: 1fr auto;
grid-template-areas:
"summary actions"
"products products";
align-items: start;
column-gap: var(--space-3, 12px);
row-gap: var(--space-2, 8px);
padding: var(--space-3, 12px);
border-radius: var(--radius-md, 8px);
background: var(--surface-dim, #f9f9fa);
}
.cart-row-summary {
grid-area: summary;
display: grid;
gap: var(--space-1, 4px);
}
.cart-row-actions {
grid-area: actions;
margin: 0;
display: grid;
grid-auto-flow: column;
gap: var(--space-2, 8px);
align-items: start;
}
.cart-row-action-form { margin: 0; }
.cart-row-products,
.cart-row-products-block { grid-area: products; }
.cart-row-active {
background: color-mix(in srgb, var(--color-green, #a3c765) 14%, var(--surface-base, #fff) 86%);
border-left: 3px solid var(--color-green, #a3c765);
padding-left: calc(var(--space-3, 12px) - 3px);
}
.cart-row-checked-out {
background: color-mix(in srgb, var(--color-navy, #5871ad) 10%, var(--surface-base, #fff) 90%);
border-left: 3px solid var(--color-navy, #5871ad);
padding-left: calc(var(--space-3, 12px) - 3px);
}
.cart-row-link { font-weight: 600; text-decoration: none; }
.cart-row-meta {
color: var(--text-muted, #777);
font-size: var(--text-sm, 0.875rem);
}
.cart-row-actions { margin: 0; }
.cart-row-tag {
text-transform: uppercase;
font-size: var(--text-xs, 0.75rem);
letter-spacing: 0.04em;
}
.cart-row-tag-active { color: var(--color-green, #4d7a1f); }
.cart-row-tag-checked-out { color: var(--color-navy, #5871ad); }
.cart-row-products {
margin: 0;
padding-left: var(--space-5, 20px);
display: grid;
gap: var(--space-1, 4px);
font-size: var(--text-sm, 0.875rem);
}
.cart-row-products-block {
display: grid;
gap: var(--space-2, 8px);
justify-items: start;
}
.cart-row-receipt { justify-self: start; }
.cart-row-activate {
background: var(--color-green, #a3c765);
color: #fff;
border: 1px solid var(--color-green, #a3c765);
}
.cart-row-activate:hover { filter: brightness(1.05); }
.cart-row-delete {
background: var(--color-danger, #CC6958);
color: #fff;
@ -2415,8 +2472,12 @@ section.checkout-page .well {
@media (max-width: 600px) {
.cart-row {
grid-template-columns: 1fr;
gap: var(--space-1, 4px);
grid-template-areas:
"summary"
"products"
"actions";
}
.cart-row-actions { justify-self: end; }
}
[data-theme="dark"] .one-column.well.well-green,

View file

@ -12,18 +12,64 @@
<div class="carts-list">
{% for cart in carts %}
{% set is_active = cart == request.active_cart %}
<div class="cart-row{% if is_active %} cart-row-active{% endif %}">
<a class="cart-row-link" href="/cart/{{ cart.id }}">
&#128722; Cart ${{ '%0.2f' % cart.total }} ({{ cart.count }} item{{ '' if cart.count == 1 else 's' }})
</a>
<span class="cart-row-meta">{{ cart.human_updated_timestamp }}{% if is_active %} &middot; <strong>active</strong>{% endif %}</span>
{% set first_invoice = cart.invoices.first() %}
{% set checked_out = first_invoice is not none %}
<div class="cart-row{% if is_active %} cart-row-active{% elif checked_out %} cart-row-checked-out{% endif %}">
<div class="cart-row-summary">
<a class="cart-row-link" href="/cart/{{ cart.id }}">
&#128722; Cart ${{ '%0.2f' % cart.total }} ({{ cart.count }} item{{ '' if cart.count == 1 else 's' }})
</a>
<span class="cart-row-meta">
{{ cart.human_updated_timestamp }}
{% if is_active %} &middot; <strong class="cart-row-tag cart-row-tag-active">active</strong>{% endif %}
{% if checked_out %} &middot; <strong class="cart-row-tag cart-row-tag-checked-out">checked out</strong>{% endif %}
</span>
</div>
{# Product list: current contents when non-empty, OR the line
items from the resulting invoice(s) when the cart's been
checked out (the json_cart was emptied / replaced but the
invoice line items preserve what was bought, so the user
can re-purchase from this view). #}
{% if cart.count > 0 %}
<ul class="cart-row-products">
{% for pid, product in cart.products.items() %}
<li>
<a href="{{ product.absolute_url(request) }}">{{ product.title }}</a>
{% if cart.cart[pid] > 1 %} &times; {{ cart.cart[pid] }}{% endif %}
</li>
{% endfor %}
</ul>
{% elif checked_out %}
<div class="cart-row-products-block">
<ul class="cart-row-products">
{% for invoice in cart.invoices %}
{% for line_item in invoice.line_items %}
<li>
<a href="{{ line_item.product.absolute_url(request) }}">{{ line_item.product.title }}</a>
{% if line_item.quantity > 1 %} &times; {{ line_item.quantity }}{% endif %}
</li>
{% endfor %}
{% endfor %}
</ul>
<a href="/i/{{ first_invoice.id }}" class="mps-button-small cart-row-receipt">View receipt</a>
</div>
{% endif %}
{% if not is_active %}
<form method="POST"
action="{{ request.route_url('user_cart_delete', cart_id=cart.uuid_str) }}"
class="cart-row-actions"
onsubmit="return confirm('Delete this cart? This cannot be undone.');">
<button type="submit" class="mps-button-small cart-row-delete">Delete</button>
</form>
<div class="cart-row-actions">
<form method="POST"
action="{{ request.route_url('user_cart_activate', cart_id=cart.uuid_str) }}"
class="cart-row-action-form">
<button type="submit" class="mps-button-small cart-row-activate">Activate</button>
</form>
<form method="POST"
action="{{ request.route_url('user_cart_delete', cart_id=cart.uuid_str) }}"
class="cart-row-action-form"
onsubmit="return confirm('Delete this cart? This cannot be undone.');">
<button type="submit" class="mps-button-small cart-row-delete">Delete</button>
</form>
</div>
{% endif %}
</div>
{% endfor %}

View file

@ -7809,6 +7809,27 @@ class TestUserCartsList(_AuthenticatedBase):
body = self.testapp.get("/u/carts", status=200).body.decode()
self.assertIn(active_id, body)
def test_activate_button_shown_for_inactive_and_swaps_active(self):
"""Each non-active cart row shows an Activate form alongside
Delete; POSTing it flips the row to active."""
from ..models.cart import get_cart_by_id
_shop, active_id, inactive_id = self._two_carts_for_user1()
body = self.testapp.get("/u/carts", status=200).body.decode()
self.assertIn(
f"/u/cart/{inactive_id}/activate", body,
)
self.assertNotIn(f"/u/cart/{active_id}/activate", body)
self.testapp.post(
f"/u/cart/{inactive_id}/activate", status=302,
)
# Inactive cart is now active.
cart_inactive = get_cart_by_id(self.dbsession, inactive_id)
self.assertTrue(cart_inactive.active)
cart_active = get_cart_by_id(self.dbsession, active_id)
self.assertFalse(cart_active.active)
def test_carts_list_shows_product_titles_with_links(self):
"""A non-empty cart row renders each product's title as a link
to the product page so the user can re-open it."""
@ -7847,10 +7868,13 @@ class TestUserCartsList(_AuthenticatedBase):
shop = self._create_shop_helper(user_creds=self.user1_creds)
product = Product(title="Already Bought", description="...")
product.shop = shop
product.price_in_cents = 500
product.is_physical = False
product.is_sellable = True
# set_price() also creates a Price history row; InvoiceLineItem
# needs that to resolve product.current_price.
price = product.set_price("5.00")
self.dbsession.add(product)
self.dbsession.add(price)
self.dbsession.flush()
product_id = product.uuid_str