Multi-item vendor offers · plan · 29 September 2026 · nothing built yet
Every offer becomes a bundle
Today a vendor offer covers exactly one marketplace listing. This plan lets one offer cover several listings from the same facility, for one total price, negotiated once and accepted or turned down as a whole. Every offer moves onto that one path, so a single-listing offer is simply a bundle of one, and the offers that exist today are converted into bundles of one.
- New table
- 1
marketplace_offer_bundles - Columns added
- 2
bundle_idon the two offer tables - Migrations
- 2additive first, then required
- Open decisions
- 7each with a default
One flow, and one negotiation per offer. A new bundle row owns the offer, and each offer or counter-offer is one row for the whole bundle. Every listing in the bundle keeps a row of its own, and all of those rows point at the bundle’s current offer row — so accepting, rejecting or cancelling is one update, and every listing sees it at the same moment.
Decisions takenD1–D6
- D1One total price for the whole bundle. Both sides send and counter one number.decided
- D2All or nothing. Counter, accept, reject and cancel act on the whole bundle, and it ends in one PA and one transaction.decided
- D3One seller facility per bundle. Every listing in it belongs to the same facility account.decided
- D4Vendors send bundles from the marketplace.Admins sending on a vendor’s behalf is Q5 below.decided
- D5One flow for every offer. A single-listing offer is a bundle of one, and every existing offer is converted into a bundle of one.decided
- D6The negotiation is per bundle: each offer or counter-offer is one row for the whole bundle, not one row per listing.decided
The modelthree levels
The bundle is new. The other two tables exist today and each gains a link to its bundle. Column-by-column detail is on Tables & changes.
| Level | Table | One row per | Holds |
|---|---|---|---|
| Bundle | marketplace_offer_bundles | offer | The vendor, the seller facility and the lead listing: the listing the PA and the order completion attach to. |
| Listing row | marketplace_offer_associations | listing in a bundle | The listing, and pointers to the bundle’s current offer row and to each side’s last offer — the same pointers on every listing of the bundle. |
| Offer row | marketplace_counter_offers | bundle, per negotiation step | Status, who acted and the total, for the whole bundle. Its listing column holds the lead listing. |
Why every listing keeps a rowthe core design choice
The negotiation lives on the bundle, but each listing keeps its row: together they are the bundle’s list of listings, and every one of them points at the bundle’s current offer row. Everything that reaches an offer through a listing’s row keeps working unchanged — the listing’s offer-status rollup, the PA locks, the check for an offer already sent, and the helpers that close offers when a listing sells.
Code that looks offer rows up by listing id directly. A bundle’s rows are stored under its lead listing, so for any other listing in it those lookups find nothing. There are nine groups of them — counter, history, cancel, the competing-offers views, completion, the auction cron, the latest-rejected-offer subqueries, PA voiding, and totals added up across listings — each listed on Build & lifecycle and redone through the listing’s row.
Dropping the listing rows and keeping only the bundle would put every listing-level reader — rollup, PA locks, the sent check, the close helpers and action feeds — onto a new join. Keeping them costs one extra update per counter-offer: pointing the bundle’s listing rows at the new offer row.
How the total is storedone number
The offer row’s amount is the one total both sides negotiate, as it is for a single offer today. No per-listing share is stored while the offer is negotiated. The total is split into per-listing prices once, at completion, for the transaction’s lines — the split rule is Q1. A screen that shows an offer on one listing shows the bundle total and its item count.
The lifecycleper bundle
The rows each step writes are on Build & lifecycle.
- Send. The vendor picks one or more listings from one facility and one total. One offer row, one row per listing.
- Counter. Either side answers with a new total, as often as needed. One new offer row each time; the listing rows move to it.
- Accept. The bundle’s offer row moves to Awaiting Signature, competing offers on its listings close, and one PA goes out listing every item.
- PA signed. One transaction covers every listing and its components, and all of them are marked sold.
- Or it ends early. Reject, cancel, a declined or voided PA, or one of its listings going to someone else closes the bundle’s offer row, and with it every listing.
Out of scopenon-goals
- A price per listing, or accepting only some of the listings.
- Bundles across more than one seller facility.
- Adding or removing listings after sending: the vendor cancels and sends again.
- Any change to the offline marketplace.
Still to decideQ1–Q7 · default first
- Q1How the total splits into the transaction’s line prices at completion: by FMV, equal when FMV is missing (default), or the whole amount on the lead listing’s line, the way components work today.price
- Q2When one listing in a live bundle goes to someone else, the bundle’s offer row closes as Sold or Removed, exactly as a single offer does today, and the vendor may offer again on the listings still available (default).Today a new offer is allowed only after Canceled, so the sent check also accepts Sold and Removed.status
- Q3At most 50 listings in one bundle (default). It bounds the PA document and the transaction.limit
- Q4Divestiture and regular listings may share a bundle when each passes its own checks (default).rule
- Q5Admins sending on a vendor’s behalf send one listing, as today (default), or bundles too.sender
- Q6Vendor action-feed cards stay one per listing row (default), so a five-listing bundle shows five cards until they are grouped.feeds
- Q7The one-listing send route stays for one release while the front end moves to the new route, then goes (default).route