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.
14 KiB
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_idfor traceabilityamount_in_cents=current_amount_in_centsat 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:
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_offersresolves 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
MpsOfferEventrows)
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)
MpsOfferstate helpers (is_open,is_terminal,time_remaining_ms)MpsOffer.current_party_userreturns buyer or seller correctlylib/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 linelib/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_dictreturns shape consumed by templateProduct.offers_allowedresolves 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_timestampflipped 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 403GET /o/{id}— anon 403, buyer 200, seller 200, third party 403, admin 200POST /o/{id}/counter— wrong turn 400; right turn 200POST /o/{id}/accept— populates cart line item; cart total = agreed amountPOST /o/{id}/decline/withdraw— terminal state, button disappears- Make-offer button rendered when
allow_offersresolves 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-settingsform section POST round-trips all 7 fields
Verification
source vars.sh && make test— all pass- 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 - Test auto-accept (high offer) and auto-decline (low offer) branches
- Test round cap (3 default) — 4th counter forced to terminal
- Test expiration via cron + manual tick
- Email log shows all 8 notification types fire across happy + sad paths
- Push → CI green → deploy → bump GIT_HASH
- Update marketing portal index.html + pricing.html
Coupling Notes
- MPS-20 introduces
pricing_modeonProduct. If MPS-20 lands first, this ticket reuses that column (modes 3 + 4). If MPS-21 lands first, addpricing_modehere and MPS-20 extends it. - Cart association proxy pattern: mirror
CartCoupon/CartGiftCardexactly — 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.