# 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.
