Offer Cart (CAP-4002)

Plan, not built · builds on branch CAP-4002-dev · backendApi, capExpertApp, dbMigrations · 16 Oct 2026

Status: plan agreed, nothing built yet. Vendors (and admins, for a vendor) collect listings in a cart grouped by facility, then send one offer per facility through the existing grouped-offer dialog and send-offer API. Offered items leave the cart in the same transaction.

Four points are left to confirm before building: the per-vendor cart limit, the grouped-offer limit (the API says 200, the front end says 50), the clean-up window, and the 1M-row timings, which are estimates until measured.

1 tablenew marketplace_offer_cart_items; no parent table; the purchase cart is untouched
3 endpointsadd, remove, read; plus 4 small changes to existing API code
1 pagethe offer cart, laid out like the current cart; plus 5 front-end touchpoints
4 openvendor limit · 50 vs 200 · clean-up window · measure at 1M rows

On this page

Decisions

TopicDecisionSource
Sending from the cartPer facility. Each facility card sends one grouped offer, for all its items or the ticked ones. A single item goes out as an individual offer.Agreed
Browse Market multiselectThe bulk bar's "Send offer" becomes "Add to cart". The cart is where multi-item offers are sent from.Agreed
Who owns a cartThe vendor company, shared by all its users.Agreed
AdminsIncluded. An admin picks a vendor and works on that vendor's cart, so items an admin adds show in the vendor's cart.Agreed
StorageOne new table, no parent table. The facility purchase cart (carts / cart_items) is not reused.Agreed
Cart pageSame layout as the current purchase cart: vendor header (admins only), one card per facility, item rows, "Sold by" footer.Agreed
FMVVendors never receive it. The cart API leaves it out of vendor responses; admins see it.Agreed
My Listing bulk "Send offer"Also becomes "Add to cart", so there is one way to send multi-item offers.Default
Detail viewKeeps its direct Send Offer for one item and gains Add to Cart. A direct send also removes the item from the cart.Default

How it works

Vendor

  1. Opens a listing and clicks Add to Cart, or ticks several on Browse Market and clicks Add to cart.
  2. Opens the cart from the header cart icon: one card per facility, each with its items.
  3. On a card, sends for all items or just the ticked ones. The existing offer dialog asks for one price.
  4. The offer is created, and those items leave the cart in the same transaction.

Admin

  1. Picks the vendor when adding (the same vendor picker as Send Offer).
  2. Opens the Offer Cart tab in the marketplace tabs, and selects a vendor before anything loads.
  3. Sends from a card exactly as a vendor does. The offer goes out as that vendor, with no second vendor pick.

Why a new table, and no parent

Not the purchase cart's cart_items

The purchase cart fits structurally (one carts row per buyer and seller), but the code treats every cart_items row as a facility reserving a listing to buy it. A vendor's cart row would be read the same way:

WhereWhat would go wrong
MarketPlace.cartItem is @HasOneThe model allows one cart row per listing. Five vendors can cart the same listing, so listing reads would pick one at random.
notInCartSql; front-end takesOffer and sendOfferDisabledAny cart row blocks offers. One vendor's cart would stop every other vendor from offering on that listing.
The purchase cart's "already in another cart" checkFacility buyers would be blocked by a vendor's cart.
Divest→auction job, two cron jobs, sales dashboard, header countAll read cart rows as purchases, so they would make wrong auction decisions and inflate counts.

A type column would need a filter in every one of the ~40 API files that read these tables, and missing one fails silently. Reuse also gains nothing: everything on the carts row (shipping quote, PO, bill of sale, self-shipping) is checkout state an offer never uses.

No parent table

Add a parent only if something per cart or per facility needs storing, such as a draft price, several named carts per vendor, or a cart status. That is an additive migration later.

Database (dbMigrations), one migration

CREATE TABLE marketplace_offer_cart_items (
  id             SERIAL PRIMARY KEY,
  vendor_id      INTEGER NOT NULL REFERENCES accounts(id)    ON DELETE CASCADE,
  marketplace_id INTEGER NOT NULL REFERENCES marketplace(id) ON DELETE CASCADE,
  created_by     INTEGER NOT NULL REFERENCES users(id),
  created_at     TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at     TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  CONSTRAINT uq_marketplace_offer_cart_items_vendor_listing UNIQUE (vendor_id, marketplace_id)
);
CREATE INDEX idx_marketplace_offer_cart_items_marketplace_id ON marketplace_offer_cart_items (marketplace_id);

API (backendApi, primary role)

New files

Limits

LimitValue
Items per facilityThe grouped-offer limit, so a full card can always be sent as one offer. The API says 200, the front end says 50; see Still to confirm.
Items per vendor500 (suggested)
Ids per add or remove request200 (@ArrayMaxSize)

Whose cart a request touches

One helper, with the same rule as Browse Market's offerVendorId():

Endpoints

EndpointBehaviour
POST /offer-cart
{ vendorId?, marketplaceIds[] }
One INSERT … SELECT. A listing is added only if it passes offerableListingSql and notInCartSql and isn't already in the cart. The new items count toward both limits, per facility and per vendor. ON CONFLICT DO NOTHING drops duplicates, and a per-vendor advisory lock stops two adds overshooting a limit. Returns { added, skipped }.
DELETE /offer-cart
?vendorId&marketplaceIds=1,2,3 or &facilityId=
Removes one item, the ticked ones, or a whole facility's items ("Remove All Items"). The ids go in the query string because some clients drop a DELETE body.
GET /offer-cart
?vendorIds&facilityIds&offset&limit
A vendor gets its own cart; an admin gets nothing until a vendor is selected. Returns { count, rows: [{ vendor, carts: [{ facility, itemCount, offerableCount, items[] }] }] }, the same nesting as the purchase cart.

How GET builds a page (always limited to the selected vendors)

  1. One page of facility cards. Each card is one vendor and facility pair, ordered by vendor name then facility name. The grouping and counting run in SQL, and count is the number of cards.
  2. The items on those cards only. Each item has its listing and inventory (through the existing getInventoryInclude), the same childCount the purchase cart computes, and an offerable flag. fairMarketValue is included only when checkNotVendorAndVendorLevel(reqUser) passes, the same check as Browse Market's list. offerableCount is computed here, for these cards only.
  3. The vendor and facility accounts on the page, through getAccountInclude: name, default address, primary user and contact.

The service then nests the rows into that shape.

Changes to existing code

WhereChange
vendor-offers.service.ts: sendOffer and sendGroupedOfferInside the existing transaction, delete that vendor's cart rows for the offered listings. This covers every way an offer is sent, and if the send fails, the delete is rolled back with it.
notification.service.ts: cartItemsCountReturns the vendor's offer-cart count instead of 0. This drives the header badge, so no new count plumbing is needed.
marketplace.base.query.ts, marketplace-finder.service.tsAdd a vendor-only inOfferCart flag, an EXISTS lookup on the unique key, beside the existing cartItem include.
An existing daily job in import/modules/cron/Delete cart rows whose listing has been deleted, or off sale, for 30 or more days. Nothing in the repo schedules itself, so this goes into a job that already runs.

The cart page: marketplace/offer-cart

It lives at modules/market-place/offer-cart/. Each area maps to the current purchase cart (modules/cart):

AreaCurrent purchase cartOffer cart
FiltersGroup / Region / Facility (non-facility users)Admins only: Vendor and Facility (app-async-filter; both types exist). Until a vendor is chosen, the page shows "Please select a vendor to view carts", like the purchase cart's "select facility" message.
Results line"Showing results X–Y of N"Same; N counts facility cards.
Account headerBuyer: name, city/state, email and phone with copyVendor, admins only, sticky. A vendor sees only its own cart, so it has no header.
Card status and stepperShipping, PO and quote noticesNone
Card top—"Select all (N)" checkbox
Item row, leftPhoto with a Divestiture or Auction badge, model number, condition, Multi, model, category, capex id, manufacturer, SN, useful life, manufacture date, quantity, componentsIdentical, plus a checkbox, disabled when the item can't take an offer
Item row, right"Removed from marketplace", FMV price, remove, Request FMV"No longer available" when it can't take an offer, FMV for admins only, remove; no Request FMV
Summary panelItems, subtotal, tax, shipping, total, PO and shipping actionsItems · Selected · Available for offer, and the primary button "Send offer · all N" or "Send offer · K selected", with the note "One price covers every item sent together"
Card footer"Sold by" facility and contact; Continue Shopping; Remove All ItemsSame "Sold by" block; Continue Browsing to Browse Market; Remove All Items asks for confirmation
Paginationapp-table-paginator, 5/10/25Same
Empty state"No items in the cart!" with a link to the Internal MarketplaceSame, with Go to Browse Market

Dark mode and phone widths, like the existing page.

Shared pieces, so it really matches

The trade-off: the purchase cart's template changes structure but not output. The alternative is copying about 300 lines of markup that would drift apart.

Sending from a card

Other front-end changes

WhereChange
offer-cart-actions.ts (new, small)One "add to offer cart" helper used by the detail view, Browse Market and My Listing. It handles the admin's vendor pick (VendorSelectDialogComponent), the added/skipped message, and the header count refresh.
Detail view (details-modal)Add to Cart next to Send Offer, shown only where Send Offer would be usable. A vendor toggles Add/Remove using inOfferCart; an admin picks the vendor, then adds.
Browse Market (market-place/list)The bulk button becomes "Add to cart", and the single-facility restriction is dropped. "Select all N listings" still needs one facility selected, and adds up to the per-facility limit.
My Listing (listing/list)The bulk "Send offer" becomes "Add to cart".
HeaderThe cart icon, hidden for vendors today, shows for vendors with the offer-cart count and links to the page.
Marketplace tabsAn "Offer Cart" tab for admins, since their header icon already opens the purchase cart.
Routes, messages, clientURL.OFFER_CART; a child route under MarketPlaceLayoutComponent with the same feature check as the Offers tab; the new messages in messages.ts; regenerate swagger and restart the front end.

Edge cases

CaseBehaviour
A listing is sold, in a PA, auctioned, in a facility's purchase cart, or deleted (soft delete)Shows as "No longer available" and can't be ticked. The vendor can remove it; the daily job deletes it after 30 days.
The vendor offers on a carted listing from another screenRemoved from the cart in that send's transaction.
Two users of the same vendor add the same itemThe unique key ignores the second add.
A facility or the vendor is at its limitThe extra items come back as skipped, with a message.
An admin hasn't selected a vendorNothing is queried; the page asks for a vendor.
A listing changes between adding and sendingThe send API already re-checks every listing under row locks.

Scale: 1k vendors, 1M rows

Every query is scoped to the selected vendor through the unique key's first column, so a vendor's speed doesn't depend on the table's total size. With the plan's fixes in place:

OperationWhat it readsExpected at 1M rows
Storage1M small rows plus two indexesAbout 70–100 MB
Header badge count, on every notifications pollIndex-only count of one vendor's rows (500 at most)About a millisecond
inOfferCart on listing rowsOne unique-key lookup per row on screenFine at any size
Add / remove / remove-after-sendThe ids sent, plus that vendor's rows for the limitsFine
Cart page (vendor, or admin with a vendor selected)That vendor's rows, and an offerable check on the cards shownTens of ms
Hard-deleting a listingThe marketplace_id indexAn index lookup, not a full scan
Rows for gone listingsDeleted after 30 daysThe table stays within the vendor limits

Without the fixes, an admin opening the page with no vendor selected would group all 1M rows and run the offerable check a million times, taking seconds per load. Listing deletes would also scan the whole table. These figures are estimates from the query shapes; Checks lists how to measure them.

Build order (the app works after every step)

  1. Migration, entity and the three endpoints, plus a spec for the cart SQL.
  2. Remove-after-send, cartItemsCount, inOfferCart, and the clean-up job.
  3. Regenerate swagger and restart the front end.
  4. Move app-cart-item-details and app-cart-party out, and switch the purchase cart to them with no visible change.
  5. The offer cart page, including sending from a card.
  6. offer-cart-actions.ts and Add to Cart in the detail view.
  7. Browse Market and My Listing: the bulk button becomes Add to cart.
  8. The vendor header icon and the admin Offer Cart tab.

Commits go on CAP-4002-dev: one in dbMigrations, and one or two each in backendApi and capExpertApp.

Checks

Still to confirm

#PointSuggested
1How many items one vendor's cart may holdDecision 500
2The grouped-offer limit doesn't match: the API allows 200 listings (bundle-listings.ts), the front end blocks above 50 (list.models.ts), and that constant's comment says it mirrors the backend. The cart's per-facility limit follows whichever is right.Decision pick one number and use it in both places
3How long rows for gone listings stay before the daily clean-up deletes themDecision 30 days
4Measure the 1M-row figures before relying on themBefore merge needs database access