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:
parent
f38e9d58ec
commit
b3d9b2b39c
5 changed files with 986 additions and 0 deletions
|
|
@ -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
176
docs/tickets/mps-18.md
Normal 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
187
docs/tickets/mps-19.md
Normal 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
300
docs/tickets/mps-20.md
Normal 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
318
docs/tickets/mps-21.md
Normal 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue