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:
parent
bb54152d47
commit
55137af986
6 changed files with 230 additions and 27 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }}">
|
||||
🛒 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 %} · <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 }}">
|
||||
🛒 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 %} · <strong class="cart-row-tag cart-row-tag-active">active</strong>{% endif %}
|
||||
{% if checked_out %} · <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 %} × {{ 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 %} × {{ 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 %}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue