make_post_sell/docs/tickets/mps-21.md
russell@unturf.com b3d9b2b39c
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.
2026-05-09 16:13:57 -04:00

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_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:

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.