docs: tickets MPS-18..21 — karaoke/torrent fixes + auction/offer proposals

MPS-18: diagnose and fix broken karaoke vocal isolation pipeline.
MPS-19: diagnose and fix broken torrent / magnet link distribution.
MPS-20: propose eBay-style auction house mode (bidding, reserve,
soft-close, proxy, buy-now).
MPS-21: propose make-an-offer mode (counter/accept/decline/expire
state machine, auto-accept and auto-decline thresholds).

Each ticket carries proposal, full file list, models, state machine,
GTM plan, and unit/integration/functional test requirements.

architecture.md ticket index extended with MPS-17 (was missing) plus
the four new tickets.
This commit is contained in:
russell@unturf.com 2026-05-09 16:13:57 -04:00
parent f38e9d58ec
commit b3d9b2b39c
No known key found for this signature in database
5 changed files with 986 additions and 0 deletions

View file

@ -229,6 +229,11 @@ mps_page_session (raw rows)
| [MPS-14](tickets/mps-14.md) | Shop Environment — Dev & Stage Shops | Complete |
| [MPS-15](tickets/mps-15.md) | 21-Day Free Trial | Complete |
| [MPS-16](tickets/mps-16.md) | Bring Your Own Bucket (BYOB) | Complete |
| [MPS-17](tickets/mps-17.md) | REST API v1 — HMAC-signed product/content + upload | Open |
| [MPS-18](tickets/mps-18.md) | Karaoke Mode — Diagnose & Fix Vocal Isolation | Open (Broken in prod) |
| [MPS-19](tickets/mps-19.md) | BitTorrent / Magnet Link — Diagnose & Fix Distribution | Open (Broken in prod) |
| [MPS-20](tickets/mps-20.md) | Auction House Mode (eBay-style Bidding) | Proposed |
| [MPS-21](tickets/mps-21.md) | Make-an-Offer Mode | Proposed |
## Related Docs

176
docs/tickets/mps-18.md Normal file
View file

@ -0,0 +1,176 @@
# MPS-18: Karaoke Mode — Diagnose & Fix Vocal Isolation Pipeline
## Status
**BROKEN in production.** Code paths exist (`lib/karaoke.py`, `lib/voxsplit.c`,
`scripts/backfill_karaoke.py`, on-demand route `POST /karaoke/{product_id}`,
backfill button in shop settings), but end-to-end runs do not produce playable
instrumental + vocal tracks attached to a product. Watch mode toggle never
finds populated karaoke URLs, so listeners never hear it.
## Problem
Karaoke depends on a long chain — every link must hold:
1. Shop has `unsandbox_public_key` + `unsandbox_secret_key` configured
2. Source media reachable via presigned `get_object` from shop bucket
3. Source streams in 64KB chunks to unsandbox `POST /upload` (HMAC-signed
against **empty body** because API skips body parsing for `/upload`)
4. `voxsplit.c` source uploaded the same way (compiled inside container)
5. `POST /execute` references both `upload_id`s, runs voxsplit in zerotrust
container, returns instrumentals + vocals as base64 or upload_ids
6. Output streamed back, written to disk, uploaded to S3 at deterministic
paths under `karaoke/{product_id}/{instrumentals,vocals}.{ext}`
7. Product row updated with karaoke URLs
8. Watch JSON + content/watch templates surface URLs to player.js
9. Player.js toggle swaps audio source between original and instrumentals
A failure at any link looks identical to a user: toggle does nothing.
## Diagnosis Required
Before the fix lands, run a one-shot reproducible test against staging:
| Probe | Expected | Actual |
|-------|----------|--------|
| Shop has unsandbox creds set | yes | ? |
| `POST /upload` with empty-body HMAC succeeds | 200 + `upload_id` | ? |
| `POST /execute` returns instrumentals + vocals | 200 + bytes | ? |
| S3 write to karaoke path succeeds | object exists in bucket | ? |
| `product.karaoke_instrumentals_url` / `_vocals_url` columns populated | URL strings | ? |
| Watch JSON includes `karaoke_*_url` keys | populated when eligible | ? |
| Player toggle swaps audio src | swap occurs | ? |
Log every link with structured records (`karaoke.step=upload_input`,
`step=execute`, `step=mirror_output`, `step=db_write`). A silent failure
upstream is the worst kind — fail-loud at every step.
## Proposal
### 1. Add karaoke health endpoint
`GET /s/{shop_id}/karaoke-health` — owner-only, returns JSON:
```json
{
"credentials_present": true,
"api_reachable": true,
"last_run_at": "2026-05-09T14:00:00Z",
"last_run_status": "success|failed|never",
"last_error": null,
"products_with_karaoke": 12,
"products_eligible": 47
}
```
Backed by a new `MpsKaraokeRun` model row written for every detached run.
### 2. Persist run state
New table `mps_karaoke_run`:
| Column | Type | Purpose |
|--------|------|---------|
| `id` | UUID | row id |
| `shop_id` | UUID FK | shop |
| `product_id` | UUID FK nullable | product (null for backfill batch) |
| `started_timestamp` | int (ms) | run start |
| `finished_timestamp` | int (ms) nullable | run end |
| `status` | int | 0=running 1=success 2=failed 3=cancelled |
| `step` | str | last completed step name |
| `error_message` | UnicodeText nullable | failure detail |
| `bytes_in` | int nullable | source size |
| `bytes_out` | int nullable | output size |
Replaces fire-and-forget invisibility. Owner UI lists last 10 runs per shop.
### 3. Fix the most likely broken link first
Grep current `lib/karaoke.py` for:
- `_sign_body` called with empty string for `/upload` — confirm API still
signs empty body for streaming endpoints. If API changed, signing is wrong.
- `_resolve_credentials(shop, app)` — confirm shop creds picked up before
app fallback, BYOB-aware
- `process_karaoke_detached` double-fork — confirm child survives parent
request teardown (no leaked DB sessions, no closed file handles)
- Output retrieval — confirm we pull from `GET /internal/upload/{id}` not
from base64 in `/execute` response (memory blow-up on 3.6 GB inputs)
### 4. Wire on-demand auto-refresh
`watch.js` already polls every 10s while karaoke is running. Confirm it
flips on completion. If polling is broken, add SSE or a single targeted
refresh after `MpsKaraokeRun.status` flips to success.
### 5. Backfill button for shop owners
`POST /s/{shop_id}/settings` `form_section=backfill-karaoke` exists.
Validate:
- Concurrency capped at unsandbox account's `max_concurrent_executions`
- Per-product idempotency — skip if `karaoke_instrumentals_url` populated
AND mtime newer than source
- Backfill records one `MpsKaraokeRun` per product, not one per batch
## Go-to-Market
| Surface | Action |
|---------|--------|
| `docs/karaoke-pipeline.md` | Update with run-state model + health endpoint |
| `docs/architecture.md` | Add karaoke run table to feature toggle matrix |
| `/styleguide` | Karaoke health card pattern (status + last run + error) |
| `~/git/www.makepostsell.com/index.html` | Feature card: "Vocal isolation, on-demand or batched. No ML deps. Spectral mid-side Wiener masking." |
| `~/git/www.makepostsell.com/pricing.html` | Mention karaoke as included on all paid plans (depends on unsandbox account) |
| Shop settings UI | Health card next to backfill button — show current state without surprise |
## Files
| File | Change |
|------|--------|
| `make_post_sell/models/karaoke_run.py` | New: `MpsKaraokeRun` |
| `make_post_sell/models/__init__.py` | Import `MpsKaraokeRun` |
| `make_post_sell/models/meta.py` | Register in `CLASS_TO_TABLE` |
| `make_post_sell/lib/karaoke.py` | Write `MpsKaraokeRun` rows at every step; harden empty-body HMAC; verify creds at entry |
| `make_post_sell/views/shop.py` | `karaoke_health` route handler |
| `make_post_sell/routes.py` | Add `/s/{shop_id}/karaoke-health` |
| `make_post_sell/templates/shop_settings.j2` | Health card; recent runs list |
| `make_post_sell/static/js/watch.js` | Confirm 10s poll triggers reload on completion; fall back to single refresh on `MpsKaraokeRun` flip |
| `make_post_sell/scripts/alembic/versions/XXXX_mps_karaoke_run_table.py` | Migration with `_table_exists` guard |
| `make_post_sell/tests/test_models.py` | `MpsKaraokeRun` unit tests |
| `make_post_sell/tests/test_integration.py` | End-to-end run record write/read; backfill idempotency |
| `make_post_sell/tests/test_functional.py` | Health endpoint auth, JSON shape, settings page renders card |
## Tests
### Unit (`test_models.py`)
- `MpsKaraokeRun.is_running` / `is_success` / `is_failed` properties
- `MpsKaraokeRun.duration_ms` calculated from start/finish timestamps
- `Shop.karaoke_eligible` returns `True` only when both unsandbox keys set
- `Shop.karaoke_health_summary` returns dict matching health endpoint shape
### Integration (`test_integration.py`)
- Backfill skips products with fresh karaoke URLs (idempotency)
- Failed run leaves `status=failed` and populated `error_message`
- Concurrent runs respect shop unsandbox concurrency limit
- BYOB: outputs land in shop's bucket, not MPS default
### Functional (`test_functional.py`)
- `GET /s/{shop_id}/karaoke-health` — anon 403, non-owner 403, owner 200 JSON
- Health JSON includes all keys above; numbers match seeded fixtures
- Shop settings page renders karaoke health card with correct current status
- `POST /karaoke/{product_id}` — anon 403, owner 200 + run row created
- Backfill button POST creates run rows for eligible products only
## Verification
1. `source vars.sh && make test` — all pass
2. Local dev: configure unsandbox keys, upload an audio product, click karaoke
button, watch run row write, watch URLs populate, watch player toggle work
3. Health endpoint reflects each step (running → success)
4. Force a failure (revoke creds mid-run) — `status=failed`, error captured
5. Push → CI green → deploy → check `https://my.makepostsell.com/version`
6. Bump GIT_HASH, push, verify

187
docs/tickets/mps-19.md Normal file
View file

@ -0,0 +1,187 @@
# MPS-19: BitTorrent / Magnet Link Mode — Diagnose & Fix Distribution
## Status
**BROKEN in production.** Schema and code paths exist:
- `mps_shop.torrent_enabled` (Boolean) — shop-level toggle
- `mps_product.torrent_opt_in` (Boolean) — per-product opt-in
- `mps_product.torrent_magnet_link` + `torrent_file_url` columns
- `make_post_sell/lib/torrent.py` — bundle + .torrent generation pipeline
- Migrations `0ee5654cfe7d_*` and `c0236e351476_torrent_distribution_support.py`
- Backfill route `/s/{shop_id}/torrent-backfill-status`
What does **not** work end-to-end: opting a public product in does not yield
a populated magnet link or a `bundle.torrent` reachable on the CDN. Listeners
never see a magnet button. Files never seed.
## Problem
The torrent path has more moving parts than karaoke. Each is a candidate
break point:
1. Opt-in only fires for `visibility=1` (public). Off for unlisted/private.
2. Bundle assembly downloads source + thumbnails + description.md from S3
into a temp dir — a single missing fetch leaves an incomplete bundle
3. `torf.Torrent` builds multi-file `.torrent` with web seeds (BEP 19) and
trackers (BEP 12) — wrong web-seed URL = no seeders forever
4. `.torrent` upload back to S3 at `{s3_path}/bundle.torrent` — must use
shop-aware client, not MPS default
5. Magnet link derived from info hash + display name + trackers + web seeds
6. DB write of `torrent_magnet_link` + `torrent_file_url` is the user-visible
commit — if it fails silently, every step before it was wasted
7. Free content vs paid product bundles differ — paid never includes the
paid file (preview only). A copy-paste defect here = piracy via opt-in
## Diagnosis Required
Same fail-loud principle as MPS-18. Add an `MpsTorrentRun` row per attempt.
| Probe | Expected | Actual |
|-------|----------|--------|
| Shop `torrent_enabled=True` | yes | ? |
| Product `torrent_opt_in=True` AND `visibility=1` | yes | ? |
| Bundle dir contains source + thumbs + description.md | full set | ? |
| `.torrent` bytes generated by `torf.Torrent` | non-zero | ? |
| `.torrent` uploaded to shop bucket at `{s3_path}/bundle.torrent` | object exists | ? |
| Web seed URL in `.torrent` matches CDN URL of source file | exact match | ? |
| `torrent_magnet_link` column populated with `magnet:?xt=urn:btih:...` | string | ? |
| `torrent_file_url` column populated with CDN URL of `.torrent` | string | ? |
| Watch JSON / content / product templates surface magnet button | rendered | ? |
| Magnet link opens in BitTorrent client and pulls bundle | swarm fetches bundle | ? |
## Proposal
### 1. Persist run state — `MpsTorrentRun` table
| Column | Type | Purpose |
|--------|------|---------|
| `id` | UUID | row id |
| `shop_id` | UUID FK | shop |
| `product_id` | UUID FK | product |
| `started_timestamp` | int (ms) | run start |
| `finished_timestamp` | int (ms) nullable | run end |
| `status` | int | 0=running 1=success 2=failed 3=cancelled |
| `step` | str | last completed step |
| `error_message` | UnicodeText nullable | failure detail |
| `info_hash` | str(40) nullable | hex info hash on success |
| `bundle_bytes` | int nullable | total bundle size |
### 2. Verify paid-product safety
Add explicit unit test: paid product (`is_sellable=True`) bundle includes
`preview.{ext}` and never the source file. Bundle assembly must read
`product.is_sellable` and pick the right path. Single boolean defect here
ships the paid file as freely-seedable. Test guards this.
### 3. Web seed URL correctness
The web seed URL inside `.torrent` is the **CDN** URL (public, no
presigning). For shops using BYOB, that's `request.shop_cdn_endpoint`,
not the MPS default CDN. Confirm `lib/torrent.py` uses the shop-aware
endpoint when building the torrent metadata.
### 4. Backfill UX
Existing `/s/{shop_id}/torrent-backfill-status` is a status endpoint.
Add a backfill button to settings that POSTs `form_section=backfill-torrent`
into `views/shop.py`. Concurrency-bound. Idempotent (skip products with
populated `torrent_magnet_link` + fresh mtime).
### 5. UI surface
Magnet button on:
- `templates/product.j2` — under download/CTA area
- `templates/content.j2` — same position
- `templates/snippets/related_content.j2` — small magnet icon next to free items
- Watch mode (`watch.js` `updatePageContent`, `watch.py` JSON)
Button visibility rules:
| Visibility | Sellable | torrent_opt_in | Show magnet? |
|------------|----------|----------------|--------------|
| public (1) | False (free) | True | yes |
| public (1) | True (paid) | True | yes — bundle is preview only |
| unlisted (2) | any | any | **no** |
| private (0) | any | any | **no** |
### 6. Tracker / web-seed strategy
Default `DEFAULT_TRACKERS` list in `lib/torrent.py` is acceptable but
hardcoded — make it shop-configurable via a new column
`mps_shop.torrent_trackers` (Text, newline-separated). NULL = use defaults.
Sites that want their own tracker in front (private tracker, CDN-friendly
tracker) can override.
## Go-to-Market
| Surface | Action |
|---------|--------|
| `docs/torrent-distribution.md` | New: end-to-end pipeline doc with dot diagram (mirror karaoke-pipeline.md) |
| `docs/architecture.md` | Add torrent run table + opt-in matrix to feature toggle matrix |
| `/styleguide` | Magnet button component + torrent health card |
| `~/git/www.makepostsell.com/index.html` | Feature card: "BitTorrent distribution. Web seeds. No friction. Free your bandwidth bill." |
| `~/git/www.makepostsell.com/pricing.html` | Note torrent included on plans that include uploads |
| Shop settings UI | Torrent health card + tracker config + backfill button |
## Files
| File | Change |
|------|--------|
| `make_post_sell/models/torrent_run.py` | New: `MpsTorrentRun` |
| `make_post_sell/models/__init__.py` | Import |
| `make_post_sell/models/meta.py` | Register |
| `make_post_sell/models/shop.py` | Add `torrent_trackers` (Text, nullable) |
| `make_post_sell/lib/torrent.py` | Persist run rows; shop-aware CDN; paid/free bundle branching with explicit tests |
| `make_post_sell/views/shop.py` | `backfill-torrent` form section handler; torrent health endpoint |
| `make_post_sell/views/product.py` | Trigger torrent generation on opt-in flip + visibility=public |
| `make_post_sell/views/watch.py` | Surface `torrent_magnet_link` + `torrent_file_url` in watch JSON |
| `make_post_sell/routes.py` | Add `/s/{shop_id}/torrent-health` |
| `make_post_sell/templates/shop_settings.j2` | Torrent health card + tracker config + backfill button |
| `make_post_sell/templates/product.j2` | Magnet button (server-rendered) |
| `make_post_sell/templates/content.j2` | Magnet button (server-rendered) |
| `make_post_sell/templates/snippets/related_content.j2` | Small magnet icon |
| `make_post_sell/static/js/watch.js` | `updatePageContent` swaps magnet href on SPA nav |
| `make_post_sell/scripts/alembic/versions/XXXX_torrent_runs_and_trackers.py` | Migration |
| `make_post_sell/tests/test_models.py` | `MpsTorrentRun` + visibility/opt-in matrix |
| `make_post_sell/tests/test_integration.py` | End-to-end bundle generation; paid vs free; BYOB |
| `make_post_sell/tests/test_functional.py` | Magnet button render rules; backfill POST; health endpoint auth |
## Tests
### Unit (`test_models.py`)
- `Product.show_magnet_button` matrix — 8 combinations (visibility × sellable × opt_in)
- `Product.torrent_bundle_paths` returns preview path for paid, source for free
- `MpsTorrentRun` status helpers
- `Shop.effective_trackers` — falls back to `DEFAULT_TRACKERS` when column NULL
### Integration (`test_integration.py`)
- Free content opt-in: bundle includes source + thumbs + description.md
- Paid product opt-in: bundle includes **preview** + thumbs + description.md, **never** source
- Web seed URL inside `.torrent` matches shop's CDN endpoint (BYOB-aware)
- Magnet link round-trip: `torf` parses our generated magnet and returns same info hash
- Backfill skips products with populated `torrent_magnet_link` + fresh mtime
- Failed run leaves status=failed + populated error_message; does not corrupt product columns
### Functional (`test_functional.py`)
- Toggling `torrent_opt_in` on a public product fires generation (run row created)
- Toggling on private/unlisted product does **not** fire
- Magnet button renders on public product, not on private/unlisted
- Backfill button POST creates run rows for eligible products only
- Anon `GET /s/{shop_id}/torrent-health` → 403; owner → 200 JSON
- Watch JSON includes `torrent_magnet_link` + `torrent_file_url` when populated
## Verification
1. `source vars.sh && make test` — all pass
2. Local dev: enable `torrent_enabled` on shop, opt-in a public free product,
confirm `bundle.torrent` lands in bucket and magnet link populates
3. Open magnet link in a real BitTorrent client, watch it pull bundle from
web seeds (no peers needed for first download)
4. Repeat for paid product — confirm bundle has preview file, **not** source
5. Force a failure (revoke S3 creds mid-run) — run row reflects failure
6. Push → CI green → deploy → bump GIT_HASH

300
docs/tickets/mps-20.md Normal file
View file

@ -0,0 +1,300 @@
# MPS-20: Auction House Mode (eBay-style Bidding)
## Status
**PROPOSED — does not exist.** Today every product has a fixed `price` column.
No code path supports rising-price bidding, reserve prices, soft-close
extensions, or proxy bidding.
## Why This Belongs in MPS
MPS already owns:
- Product lifecycle (visibility, ownership, downloads)
- Shop settings + form-section pattern
- Cart + checkout pipeline (Stripe / PayPal / crypto / gift cards)
- Comments + watch mode + SPA navigation
- Email notifications
An auction is a product whose price function is `max(bids)` instead of a
constant. We extend, not rebuild. Same checkout. Same payouts. Same fees.
## Modes
A shop owner toggles per product:
| `pricing_mode` | Behavior |
|----------------|----------|
| 0 (fixed) | Today's behavior — buy now at `price` |
| 1 (auction) | Rising bids until end timestamp; winner pays high bid |
| 2 (auction + buy_now) | Both paths live; buy-now ends the auction immediately |
| 3 (offer) | See MPS-21 — make-an-offer mode |
This ticket covers modes 1 and 2. MPS-21 covers mode 3.
## State Machine
```
┌──────────┐
│ draft │ owner editing, not visible
└────┬─────┘
│ schedule
┌──────────┐
│scheduled │ visible, countdown to start
└────┬─────┘
│ start_timestamp passes
┌──────────┐ bid placed
│ active │ ◄──────────┐
└────┬─────┘ │
│ │
end_timestamp │ ┌────────────┴────────┐
passes │ │ soft-close: bid in │
no buy_now │ │ last N seconds │
▼ │ extends end by N │
┌──────────┴┐
│ ended │ winner determined; payment window opens
└────┬──────┘
│ winner pays via cart
┌──────────┐
│ settled │ funds captured, product transferred
└──────────┘
Side branches:
active --buy_now-→ ended (winner = buy_now buyer; bid refunds n/a)
ended --no-pay-→ relisted or default-to-second-bidder (configurable)
any --cancel-→ cancelled (owner action; pre-active only without admin override)
```
## Models
### `MpsAuction`
| Column | Type | Purpose |
|--------|------|---------|
| `id` | UUID | row id |
| `product_id` | UUID FK unique | one auction per product |
| `shop_id` | UUID FK | shop |
| `state` | int | 0=draft 1=scheduled 2=active 3=ended 4=settled 5=cancelled |
| `start_timestamp` | int (ms) | when bidding opens |
| `end_timestamp` | int (ms) | when bidding closes (extended by soft-close) |
| `original_end_timestamp` | int (ms) | scheduled close; never updated |
| `start_price_in_cents` | int | minimum opening bid |
| `reserve_price_in_cents` | int nullable | hidden floor; below = no winner |
| `buy_now_price_in_cents` | int nullable | mode 2 only |
| `bid_increment_in_cents` | int | min step between bids; default 5% of current high |
| `soft_close_seconds` | int | default 60; bid in last N → extend end by N |
| `winner_user_id` | UUID FK nullable | populated when state=ended |
| `winning_bid_id` | UUID FK nullable | populated when state=ended |
| `payment_deadline_timestamp` | int (ms) nullable | winner-pay-by; default end + 48h |
| `currency` | str(3) | inherits shop default |
### `MpsBid`
| Column | Type | Purpose |
|--------|------|---------|
| `id` | UUID | row id |
| `auction_id` | UUID FK | auction |
| `bidder_user_id` | UUID FK | bidder |
| `amount_in_cents` | int | actual bid amount (proxy bidding fills up) |
| `max_proxy_in_cents` | int | bidder's secret max; proxy auto-bids up to this |
| `created_timestamp` | int (ms) | bid placement |
| `outbid_timestamp` | int (ms) nullable | when this bid was passed |
| `is_winning` | bool | true for current high bid only |
### `MpsAuctionWatcher`
| Column | Type | Purpose |
|--------|------|---------|
| `id` | UUID | row id |
| `auction_id` | UUID FK | auction |
| `user_id` | UUID FK | watcher |
| `created_timestamp` | int (ms) | when added |
| `notify_on_outbid` | bool | default True |
| `notify_on_ending_soon` | bool | default True (1h, 5min) |
## Views / Routes
```
GET /a/{auction_id} auction page (live)
GET /a/{auction_id}.json auction state poll (1s for active, 10s for scheduled)
POST /a/{auction_id}/bid place bid (form_section=bid)
POST /a/{auction_id}/buy-now buy-now (mode 2)
POST /a/{auction_id}/watch toggle watcher
POST /a/{auction_id}/cancel owner cancel (pre-active only)
GET /s/{shop_id}/auctions owner's auction dashboard
POST /s/{shop_id}/products/{id}/auction create or update auction (form_section=auction)
GET /u/{user_id}/auctions user's bids + watches
```
State transitions (`scheduled → active → ended`) run via a periodic job —
add a tick to the existing background task system (or a new
`scripts/auction_tick.py` cron). On settled-by-payment, the cart's normal
post-payment hooks already do product transfer; no new path.
## Soft-Close Algorithm
```python
def place_bid(auction, bidder, amount, max_proxy):
now = now_ms()
if auction.state != STATE_ACTIVE:
raise BidRejected("auction not active")
if amount < (auction.current_high + auction.bid_increment_in_cents):
raise BidRejected("bid too low")
bid = MpsBid(auction_id=auction.id, bidder_user_id=bidder.id,
amount_in_cents=amount, max_proxy_in_cents=max_proxy)
DBSession.add(bid)
_resolve_proxy(auction, bid) # auto-bid against existing max_proxy bids
if (auction.end_timestamp - now) < (auction.soft_close_seconds * 1000):
auction.end_timestamp = now + (auction.soft_close_seconds * 1000)
notify_outbid(auction) # email + watcher inbox
```
Pure-function `_resolve_proxy` — unit-testable in isolation, no DB hit
beyond bid insert. Tarjan-style: handle the bid + proxy chain in a single
pass (no O(N²) loop over all bids).
## Cart Integration
When `state=ended` and `winner_user_id` matches the request user, the
auction product appears in cart at `winning_bid.amount_in_cents`. Existing
checkout (`views/cart.py`) handles payment + transfer. After cart success
hook, set `auction.state=4` (settled).
If `payment_deadline_timestamp` passes without payment, run a settle-task:
- Default: relist to second-highest bidder at their bid price (configurable
`auction_default_to_second_bidder` shop setting)
- Alternative: cancel + return to seller (auctioneer chooses)
## Form Section
Add `auction` to `form_section` routing in `views/product.py`:
| field | purpose |
|-------|---------|
| `pricing_mode` | 0/1/2/3 |
| `auction_start_timestamp` | datetime-local input → ms |
| `auction_end_timestamp` | datetime-local input → ms |
| `start_price` | dollars → cents |
| `reserve_price` | dollars → cents (nullable) |
| `buy_now_price` | dollars → cents (mode 2 only) |
| `bid_increment` | dollars → cents |
| `soft_close_seconds` | int |
## Notifications
Email + on-site:
- Bid received (to seller)
- Outbid (to previous high bidder)
- Auction ending in 1h / 5min (to watchers + bidders)
- Won — pay by `{deadline}` (to winner)
- Lost (to underbidders)
- Payment received / settled (to seller + winner)
## Anti-Abuse
- Rate limit `POST /a/{id}/bid` — 1 bid per bidder per second
- Min bidder account age (configurable per shop, default 0 = open)
- Min cart history (configurable, default 0)
- Block self-bid (bidder_user_id == seller_user_id) at form layer
- Reserve-not-met UX: show "reserve not met" indicator without leaking
reserve price
## Go-to-Market
| Surface | Action |
|---------|--------|
| `docs/auction-house.md` | New: state machine + dot diagram + APIs |
| `docs/architecture.md` | Add auction tables + pricing_mode to feature matrix |
| `/styleguide` | Bid form, countdown clock, reserve indicator, watcher toggle, soft-close pulse |
| `~/git/www.makepostsell.com/index.html` | Feature card: "Run auctions. Reserve prices, soft-close, proxy bids. Same payments, same fees." |
| `~/git/www.makepostsell.com/pricing.html` | Auction mode listed in plan includes |
| Marketing copy | Position: commission-free auctions vs. eBay's 13.25% final value fee |
## Files
| File | Change |
|------|--------|
| `make_post_sell/models/auction.py` | New: `MpsAuction`, `MpsBid`, `MpsAuctionWatcher` |
| `make_post_sell/models/__init__.py` | Imports |
| `make_post_sell/models/meta.py` | Register all three tables |
| `make_post_sell/models/product.py` | Add `pricing_mode` (Integer, default=0), `auction` relationship |
| `make_post_sell/models/cart.py` | Auction-product cart line item handling |
| `make_post_sell/views/auction.py` | New: all auction route handlers |
| `make_post_sell/views/product.py` | `form_section=auction` handler |
| `make_post_sell/views/cart.py` | Recognize auction-won line item; price = winning bid |
| `make_post_sell/views/shop.py` | `auctions_dashboard` view |
| `make_post_sell/lib/auction.py` | Pure-function bid resolution + proxy + soft-close logic |
| `make_post_sell/lib/auction_tick.py` | Scheduled state transitions + ending-soon notifications |
| `make_post_sell/lib/email_notifications.py` | Auction email templates |
| `make_post_sell/routes.py` | All auction routes |
| `make_post_sell/templates/auction.j2` | Live auction page |
| `make_post_sell/templates/auctions_dashboard.j2` | Owner dashboard |
| `make_post_sell/templates/product_edit.j2` | Auction config form section |
| `make_post_sell/templates/styleguide.j2` | Component examples |
| `make_post_sell/static/js/auction.js` | Live countdown, bid form, soft-close pulse, JSON poll |
| `make_post_sell/static/css/common.css` | Auction component styles (tokens-only) |
| `make_post_sell/scripts/alembic/versions/XXXX_auction_tables.py` | Migration with `_table_exists` + `_column_exists` guards |
| `make_post_sell/scripts/auction_tick.py` | CLI entry point for cron |
| `make_post_sell/tests/test_models.py` | Unit tests |
| `make_post_sell/tests/test_integration.py` | Integration tests |
| `make_post_sell/tests/test_functional.py` | Functional tests |
## Tests
### Unit (`test_models.py`)
- `MpsAuction` state helpers (`is_active`, `is_ended`, `time_remaining_ms`)
- `MpsAuction.current_high` returns max bid amount
- `MpsAuction.reserve_met` boolean
- `MpsBid.is_winning` flag flips correctly
- `lib/auction._resolve_proxy` — proxy bidding outcomes:
- solo proxy bid: bid recorded at start_price + increment
- two competing proxies: high proxy wins at low_proxy + increment
- chained proxies: terminate cleanly, no infinite loop
- Soft-close: bid > N sec from end → no extension; bid < N sec end pushed
- `Product.is_auction` / `Product.is_buy_now_only` based on `pricing_mode`
### Integration (`test_integration.py`)
- Place bid below increment → rejected; above → accepted; outbid email sent
- Buy-now in mode 2 ends auction, sets winner, refunds bid hold (n/a — no holds)
- Auction ends without bids meeting reserve → no winner; `state=ended`,
`winner_user_id IS NULL`
- Winner pays via cart → `state=settled`; product transfer fires
- Winner does not pay by deadline → second bidder gets the option
(configurable per shop)
- Watcher receives ending-soon email at 1h and 5min before end
- BYOB: auction cover/preview images use shop bucket
- `auction_tick` job: scheduled → active when `start_timestamp` passes;
active → ended when `end_timestamp` passes
### Functional (`test_functional.py`)
- `GET /a/{id}` renders for anon, signed-in non-bidder, owner, current high bidder
- `POST /a/{id}/bid` — anon → redirect to login; signed in → 200
- `POST /a/{id}/bid` rate limit: 2 bids in 1s → second 429
- `POST /a/{id}/bid` self-bid → 403 with flash message
- `POST /a/{id}/buy-now` — mode 1 → 404; mode 2 → 200, auction ends
- `POST /a/{id}/watch` toggle inserts/removes `MpsAuctionWatcher` row
- `POST /a/{id}/cancel` — pre-active by owner → 200; active by owner → 403
- Owner dashboard `/s/{shop_id}/auctions` lists all states with counts
- Watch mode SPA: navigating between auction products updates auction-specific
elements (current high, time left, bid form `auction_id`)
- `auction.json` endpoint returns full state shape for poll
## Verification
1. `source vars.sh && make test` — all pass
2. Local dev: create draft auction, schedule, watch state → active, place bids
from two browser sessions, observe soft-close extension, end auction,
pay as winner, verify settled state + product transferred
3. Test reserve-not-met path
4. Test second-bidder fallback
5. Email log shows all 6 notification types fire
6. Push → CI green → deploy → bump GIT_HASH
7. Update marketing portal index.html + pricing.html

318
docs/tickets/mps-21.md Normal file
View file

@ -0,0 +1,318 @@
# MPS-21: Make-an-Offer Mode
## Status
**PROPOSED — does not exist.** Today every product has a fixed `price`. No
path lets a buyer propose a different price, no path lets a seller counter,
no path lets either party walk away.
## Why This Belongs in MPS
Make-an-offer is the everyday cousin of MPS-20's auction mode. Instead of
many bidders pushing a price up over a fixed window, two parties (one
buyer, one seller) negotiate to a number both accept. Same checkout
pipeline. Same payouts. Same fee math. Different state machine.
Use cases:
- Digital art / commissioned work where price is conversation
- Used / one-of-a-kind physical goods (think Facebook Marketplace, OfferUp)
- B2B catalog items where listed price is starting point
- Soft-launch pricing — let buyers tell you what they'd pay
## Mode
Reuses `pricing_mode` column from MPS-20:
| `pricing_mode` | Behavior |
|----------------|----------|
| 0 (fixed) | Today |
| 1 (auction) | MPS-20 |
| 2 (auction + buy_now) | MPS-20 |
| 3 (offer) | This ticket — buyer proposes, seller counters/accepts/declines |
| 4 (offer + buy_now) | Both — listed price = instant; offer = negotiate |
Modes 3 and 4 add `make_offer_enabled=True` semantics on the product.
## State Machine
```
┌──────────┐
buyer │ open │ product listed, accepting offers
submits ───► └────┬─────┘
┌──────────┐
│ pending │ offer waiting on seller
└────┬─────┘
┌─────────────┼──────────────┬─────────────┐
│ │ │ │
▼ ▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ accepted │ │countered │ │ declined │ │ expired │
└────┬─────┘ └────┬─────┘ └──────────┘ └──────────┘
│ │
│ │ buyer responds: accept / counter / decline / withdraw
│ ▼
│ ┌──────────┐
│ │ pending │ ◄── back to seller (capped at offer_max_rounds)
│ └──────────┘
┌──────────┐
│ paid │ buyer paid via cart at agreed price
└──────────┘
Auto-accept lane (skips pending if offer >= auto_accept_threshold):
open → accepted
Auto-decline lane (skips pending if offer < auto_decline_threshold):
open → declined
```
## Models
### `MpsOffer`
| Column | Type | Purpose |
|--------|------|---------|
| `id` | UUID | row id |
| `product_id` | UUID FK | product |
| `shop_id` | UUID FK | shop |
| `buyer_user_id` | UUID FK | buyer |
| `state` | int | 0=pending 1=accepted 2=countered 3=declined 4=expired 5=withdrawn 6=paid |
| `current_amount_in_cents` | int | latest amount on the table |
| `current_party` | int | 0=buyer's turn 1=seller's turn |
| `created_timestamp` | int (ms) | initial offer time |
| `last_action_timestamp` | int (ms) | last counter/accept/etc |
| `expires_timestamp` | int (ms) | offer auto-expires (default created + 7 days) |
| `paid_timestamp` | int (ms) nullable | populated on cart success |
| `round_count` | int | counter rounds used; cap at shop's `offer_max_rounds` |
| `buyer_message` | UnicodeText nullable | optional buyer note (initial offer) |
| `seller_message` | UnicodeText nullable | optional seller counter note |
### `MpsOfferEvent` (audit log)
| Column | Type | Purpose |
|--------|------|---------|
| `id` | UUID | row id |
| `offer_id` | UUID FK | offer |
| `actor_user_id` | UUID FK | who acted |
| `event_type` | int | 0=open 1=counter 2=accept 3=decline 4=withdraw 5=expire 6=pay |
| `amount_in_cents` | int nullable | amount at this step |
| `message` | UnicodeText nullable | actor's message |
| `created_timestamp` | int (ms) | event time |
## Shop Settings (Form Section: `offer-settings`)
| Field | Default | Purpose |
|-------|---------|---------|
| `offer_enabled` | False | shop master toggle |
| `offer_min_in_cents` | NULL | reject offers below this (per shop floor) |
| `offer_auto_accept_threshold_pct` | 95 | offer ≥ N% of list → auto-accept |
| `offer_auto_decline_threshold_pct` | 50 | offer < N% of list auto-decline |
| `offer_expiration_hours` | 168 (7d) | how long an offer stays open |
| `offer_max_rounds` | 3 | counter cap before forcing accept/decline |
| `offer_min_buyer_account_age_hours` | 0 | anti-spam (default open) |
Per-product override: `Product.allow_offers` (Boolean). NULL = inherit shop;
True/False = override.
## Views / Routes
```
POST /p/{product_id}/offer buyer submits new offer
GET /o/{offer_id} offer detail (both parties + admin)
POST /o/{offer_id}/counter seller or buyer counters
POST /o/{offer_id}/accept accepts current amount → cart
POST /o/{offer_id}/decline declines, no further action
POST /o/{offer_id}/withdraw buyer pulls offer (pre-acceptance only)
GET /s/{shop_id}/offers seller dashboard
GET /u/{user_id}/offers buyer dashboard
```
When state flips to `accepted`, cart auto-creates a line item at
`current_amount_in_cents`. Buyer hits checkout. Existing payment paths
fire. Cart success hook sets `state=paid` + writes `MpsOfferEvent`.
## Cart Integration
Accepted offer becomes a line item (`mps_cart_offer` association proxy,
mirroring `CartCoupon` and `CartGiftCard`). Cart total is the **agreed
amount**, not the listed price. The line item carries:
- `offer_id` for traceability
- `amount_in_cents` = `current_amount_in_cents` at time of accept
- Standard product transfer on payment
If buyer abandons cart, offer stays `accepted` until `expires_timestamp`
passes (configurable seller setting: re-open or terminal).
## Counter Algorithm
Pure function, fully unit-testable:
```python
def counter_offer(offer, actor, new_amount, message, now_ms):
_validate_actor_turn(offer, actor)
_validate_round_cap(offer)
_validate_floor(offer, new_amount)
offer.current_amount_in_cents = new_amount
offer.current_party = OTHER_PARTY[offer.current_party]
offer.last_action_timestamp = now_ms
offer.round_count += 1
offer.state = STATE_COUNTERED if actor != offer.buyer else STATE_PENDING
DBSession.add(MpsOfferEvent(...))
return offer
```
Round cap forces resolution — no infinite haggling.
## Anti-Abuse
- Min buyer account age (configurable)
- Rate limit: 5 new offers per buyer per shop per day
- Block self-offer (buyer == seller)
- Block offers below `offer_min_in_cents` (silent reject if seller wants
to keep floor secret — return generic flash message)
- Auto-decline threshold filters lowball spam without seller seeing it
## Notifications
Email + on-site:
- New offer received (to seller)
- Offer countered by seller (to buyer)
- Offer countered by buyer (to seller)
- Offer accepted (to other party)
- Offer declined (to other party)
- Offer expiring in 24h (to active party)
- Offer expired (to both)
- Offer paid → standard cart purchase confirmation
## UI Surface
- Product page: "Make an Offer" button (when `allow_offers` resolves True)
next to "Add to Cart"; opens form modal
- Form: amount input, optional message, expires-at indicator
- Auto-accept preview: "Offers ≥ $X are accepted instantly" (only shows the
threshold when shop opts to disclose; default hidden)
- Seller dashboard: offers grouped by state (pending / countered-out / accepted-unpaid / paid / declined / expired)
- Buyer dashboard: same shape, buyer-side terminology
- Offer detail page: full event timeline (renders `MpsOfferEvent` rows)
## Watch Mode SPA
Per CLAUDE.md, all product-specific UI must round-trip through
`updatePageContent` + `watch.py` JSON. Make-offer button + state badge
must update on SPA navigation.
## Go-to-Market
| Surface | Action |
|---------|--------|
| `docs/make-offer.md` | New: state machine + dot diagram + API |
| `docs/architecture.md` | Add offer tables + pricing_mode mode 3/4 to feature matrix |
| `/styleguide` | Offer form, offer state badges, event timeline component |
| `~/git/www.makepostsell.com/index.html` | Feature card: "Make an offer. Negotiate without leaving the listing. Auto-accept good offers. Auto-decline lowballs. Standard checkout." |
| `~/git/www.makepostsell.com/pricing.html` | Make-offer mode listed in plan includes |
| Marketing copy | Position: commission-free negotiation vs. eBay/OfferUp listing fees |
## Files
| File | Change |
|------|--------|
| `make_post_sell/models/offer.py` | New: `MpsOffer`, `MpsOfferEvent` |
| `make_post_sell/models/__init__.py` | Imports |
| `make_post_sell/models/meta.py` | Register both tables |
| `make_post_sell/models/shop.py` | Add 7 offer-* columns |
| `make_post_sell/models/product.py` | Add `allow_offers` (Boolean nullable). If MPS-20 lands first, `pricing_mode` already exists |
| `make_post_sell/models/cart.py` | `CartOffer` association proxy |
| `make_post_sell/views/offer.py` | New: all offer route handlers |
| `make_post_sell/views/shop.py` | `form_section=offer-settings` handler; `offers_dashboard` view |
| `make_post_sell/views/cart.py` | Recognize offer-accepted line item |
| `make_post_sell/views/product.py` | `allow_offers` per-product toggle |
| `make_post_sell/lib/offer.py` | Pure-function counter / accept / decline / withdraw / expire logic |
| `make_post_sell/lib/offer_tick.py` | Scheduled expirations + ending-soon notifications |
| `make_post_sell/lib/email_notifications.py` | Offer email templates |
| `make_post_sell/routes.py` | All offer routes |
| `make_post_sell/templates/offer.j2` | Offer detail page with event timeline |
| `make_post_sell/templates/offers_dashboard.j2` | Seller + buyer dashboard (single template, dual-mode) |
| `make_post_sell/templates/product.j2` | Make-offer button (server-rendered) |
| `make_post_sell/templates/content.j2` | Same |
| `make_post_sell/templates/shop_settings.j2` | `offer-settings` form section |
| `make_post_sell/templates/product_edit.j2` | `allow_offers` toggle |
| `make_post_sell/templates/styleguide.j2` | Component examples |
| `make_post_sell/static/js/offer.js` | Modal form, AJAX submit, state-badge update |
| `make_post_sell/static/js/watch.js` | `updatePageContent` swaps make-offer button + state |
| `make_post_sell/static/css/common.css` | Offer component styles (tokens-only) |
| `make_post_sell/scripts/alembic/versions/XXXX_offer_tables_and_settings.py` | Migration with guards |
| `make_post_sell/scripts/offer_tick.py` | CLI entry point for cron |
| `make_post_sell/tests/test_models.py` | Unit tests |
| `make_post_sell/tests/test_integration.py` | Integration tests |
| `make_post_sell/tests/test_functional.py` | Functional tests |
## Tests
### Unit (`test_models.py`)
- `MpsOffer` state helpers (`is_open`, `is_terminal`, `time_remaining_ms`)
- `MpsOffer.current_party_user` returns buyer or seller correctly
- `lib/offer.counter_offer`:
- wrong-turn raises
- over-cap raises
- below-floor raises
- happy path flips party, increments round, sets state
- `lib/offer.accept_offer` — flips to accepted, writes event, creates cart line
- `lib/offer.decline_offer` / `withdraw_offer` — terminal, no further action
- Auto-accept: offer ≥ threshold → state=accepted directly
- Auto-decline: offer < threshold state=declined directly
- `Shop.offer_settings_dict` returns shape consumed by template
- `Product.offers_allowed` resolves per-product override + shop default
### Integration (`test_integration.py`)
- Full negotiation: buyer offers → seller counters → buyer accepts → cart
populated at agreed amount → checkout → state=paid
- Round cap forces accept/decline at round_count == max
- Expiration: offer past `expires_timestamp` flipped to expired by tick job
- Auto-accept threshold path
- Auto-decline threshold path (silent — no seller notification)
- BYOB: offer-related thumbnails use shop bucket
- Anti-abuse: 6th offer in 24h from same buyer → 429
- Self-offer (buyer_user_id == seller_user_id) → 403
### Functional (`test_functional.py`)
- `POST /p/{id}/offer` — anon redirect, signed-in 200, self-offer 403
- `GET /o/{id}` — anon 403, buyer 200, seller 200, third party 403, admin 200
- `POST /o/{id}/counter` — wrong turn 400; right turn 200
- `POST /o/{id}/accept` — populates cart line item; cart total = agreed amount
- `POST /o/{id}/decline` / `withdraw` — terminal state, button disappears
- Make-offer button rendered when `allow_offers` resolves True; hidden otherwise
- Watch mode SPA: navigating updates make-offer button visibility + state badge
- Seller dashboard `/s/{shop_id}/offers` — only shop owner; 403 for others
- Buyer dashboard `/u/{user_id}/offers` — only buyer or admin
- `offer-settings` form section POST round-trips all 7 fields
## Verification
1. `source vars.sh && make test` — all pass
2. Local dev: enable offers on a shop, opt a product in, submit offer from
a different account, counter from owner, accept, pay via cart, verify
`state=paid` + transfer
3. Test auto-accept (high offer) and auto-decline (low offer) branches
4. Test round cap (3 default) — 4th counter forced to terminal
5. Test expiration via cron + manual tick
6. Email log shows all 8 notification types fire across happy + sad paths
7. Push → CI green → deploy → bump GIT_HASH
8. Update marketing portal index.html + pricing.html
## Coupling Notes
- **MPS-20** introduces `pricing_mode` on `Product`. If MPS-20 lands first,
this ticket reuses that column (modes 3 + 4). If MPS-21 lands first,
add `pricing_mode` here and MPS-20 extends it.
- Cart association proxy pattern: mirror `CartCoupon` / `CartGiftCard`
exactly — ordering rules already defined (gift cards apply after coupons).
Offer line is its own item (single-product cart with agreed price);
doesn't interact with coupon/gift-card discount stack.