Grouped Offers (CAP-4002)

Branch CAP-4002-dev vs origin/development · backendApi, capExpertApp, dbMigrations · last checked 14 Oct 2026, 18:00

Status: built, compiles, not ready to merge. The feature is in place in all three repos. Both API roles and the front end build with no errors on the running stack.

Before merge, three things are left: end-to-end testing, a row lock on accept, and one out-of-date spec. Two product decisions are also open: how the total is split across listings, and the follow-up migration.

0 errorsAPI primary, API import and front end, after the development merge
105 / 105tests pass in the 9 CAP-4002 suites; 1 older spec fails (a development issue)
13 of 17plan items built (2 of them a different way); 2 partial, 2 not done
8 open3 before merge · 2 decisions · 3 later

On this page

What is still needed

#ItemWhenWhy it mattersWhere
1Run the end-to-end checks on the local DBBefore mergeNo grouped offer has been taken from send to sold yet. This is the only proof that the PA, completion and closing the other offers work together. See the QA checklist.local DB + running stack
2Lock the listings when an offer is acceptedBefore mergeSend locks the listing rows, but accept does not. If a grouped offer and an individual offer on the same listing are accepted at the same moment, both can win. The fix is to lock the offer's listings inside the accept transaction and re-check that they are still offerable.counter-offers.service.ts · acceptRejectOffer
3Bring the out-of-date spec up to dateBefore merge or own ticket9 of 28 tests fail, and the cause is in development, not this branch. The base reconciler now asks the import role to recompute counts through jobService.crossServerDispatch, instead of writing rows. The spec still expects rows and never provides jobService. The fix is a jobService stub plus assertions on the dispatch.marketplace-vendor-event-handler.service.spec.ts
4How the total is split across listingsDecisionThe split is currently equal. See Decisions needed.bundle-listings.ts · splitEqually
5Follow-up migrationDecisionThe offer row → offer link is still nullable, and the old marketplace_id columns are still there. See Decisions needed.dbMigrations
6Completion task sends the offer idLaterThe task sends listing + vendor. Completion uses those to find the offer that holds that listing, from that vendor, and is awaiting signature. Only one offer should match, so the risk is low. Sending the offer's own id would remove any doubt, but the old payload must still be accepted for one release.vendor-offer-transaction.service.ts, import role DocuSign sync
7Tests across every read pathLaterThe SQL builders have specs. The 13 areas that now read through the items table (rollup, PA locks, close-on-sale, feeds, crons and so on) are not each tested with a grouped offer.specs
8Try the 200-listing limitLaterThe PA document lists every listing and its components. A grouped offer at the limit has not been rendered, sent to DocuSign or completed.send, PA, completion

Decisions needed

4. How the total is split across listings

When a grouped offer completes, every listing gets one transaction line. Right now each line gets an equal share of the total, and the leftover cents go on the last line. That share flows into the transaction lines, the sales dashboards and the exports.

Option$10,000 for a $9,000 and a $1,000 listingNotes
Equal split (today)$5,000 + $5,000Simple, but reports show the cheaper listing selling for far above its value.
In proportion to FMV$9,000 + $1,000Lines match each listing's value. A rule is needed for when FMV is missing; equal split is the obvious fallback.

Suggested: split in proportion to FMV, and fall back to an equal split when any listing has no FMV.

5. Follow-up migration

The plan's second migration would have dropped marketplace_id from the offer thread, the offer rows and the PA rows. The branch keeps those columns instead and makes them nullable, and a grouped offer leaves them empty. Two loose ends remain:

Suggested: after release, run one migration that does the backfill and adds NOT NULL. Keep the old columns nullable for now, and drop them later in their own change.

How a grouped offer works

A vendor makes one offer, at one total price, for several listings from the same facility. That is a Grouped offer. An offer for one listing is an Individual offer, as before, and is stored as a grouped offer with one listing. Both go through the same flow, and a grouped offer is always handled as a whole, never item by item.

StepWhoWhat happens
SendVendor (or admin)Creates one thread with item_count = N, one item per listing, and one offer row with the total. The listing rows are locked while each one is checked.
CounterEither sideAdds one offer row for the whole offer and moves the thread's latest-offer pointers. Every listing's offer status is recalculated.
AcceptWhoever did not make the latest offerThe offer row moves to Awaiting Signature. One PA is created that lists every listing and its components, at the total price.
Reject / cancelEither side / vendor or adminOne row update, then every listing's offer status is recalculated. If a vendor sends again after canceling a grouped offer, the same thread is reused.
PA signed, or offline PA uploadedDocuSign / adminOne transaction, with one line per listing at its share of the total and each listing's components as unpriced lines. All listings and their inventory are marked sold, and competing offers on those listings are closed.
A listing goes elsewhereCart, offline sale, bulk action, divest cronEvery offer holding that listing closes as a whole, and only those offers' PAs are voided. The grouped offer's other listings go back on offer.

What's included

Sending

Selecting listings in Browse Market — Vendors can select listings in the list view, or tick them on the cards. A bulk bar offers to send an offer for the selection, and there is a "select all remaining" option. The grouped-offer dialog lists the chosen listings and takes one total price.

Rules at send — Every listing must belong to the same facility, be a parent listing (not a component), and be open for offers. A grouped offer can hold up to 200 listings. A listing that is already in a live grouped offer from this vendor is refused with its own message.

The API — There is no new send route. POST /vendor-offer/send-offer/:marketplaceId takes an optional marketplaceIds[]. The listing in the URL is the "anchor" that links and notifications point to.

Screens

Individual and Grouped tabs — The admin Vendor Offers page and My Offers (facility, group and vendor) get an Individual / Grouped switch, and each tab shows a count. The choice is kept in ?offerView=grouped, which grouped-offer emails use in their links.

Grouped offers table — One row per grouped offer, which expands to show its listings and their components. It has an actions menu (counter, accept, reject, cancel, PA). The filters cover status, vendor, group scope, last-updated range, and an asset search that also matches components. Sellers see the offer compared with the combined FMV of the listings; vendors do not.

Individual offers that lose — When a grouped offer wins a listing, the other individual offers on it show their own outcome, plus a "Selected in a grouped offer" link to the winner.

Last touchpoints per grouped offer — Notes can be attached to a grouped offer. Deleting one soft-deletes it.

Smaller changes — The tabs component now uses signals, and data tables get a row-flash helper. The marketplace tour covers the new tabs. The DocuSign list shows a grouped PA's listing count, and the details-modal vendor dialog also handles grouped offers.

Emails and notifications

There is a new GroupedOfferMarketplace notification for the vendor and the facility, sent through development's new notification job queue. Offer emails show the price only when it applies. The PA email for a grouped offer gives the number of items and refers to the attached PA for the details. The logistics-required email words its request differently for an individual and a grouped offer. Email links carry selectedId, which pins and highlights that offer in the list.

Reads that now go through the items table

A grouped thread has no single listing, so every read that went from a listing to its offers now goes through marketplace_offer_items. That covers:

The new shared builders live in src/shared/modules/vendor-offers/: offer-listing.sql, offer-bundle.sql, offer-pending.sql, offerable-listing.sql, offer-status and bundle-listings. There are two new endpoints: GET /grouped-offers and GET /grouped-offers/:threadId/items.

Database

One migration, 20261013113910-add-marketplace-offer-bundles.js, runs in a single transaction. It only adds, and no existing value changes.

TableChange
marketplace_offer_itemsNew. One row per listing in an offer. The (offer, listing) pair is unique, and rows are indexed by listing.
marketplace_offer_associationsNew item_count column (default 1, must be ≥ 1) and an index on vendor. marketplace_id becomes nullable.
marketplace_counter_offersNew marketplace_offer_association_id column (nullable, indexed). marketplace_id becomes nullable.
last_touchpointsNew marketplace_offer_association_id column, for notes on a grouped offer.
docusign_requests, marketplaceIndexes: PA requests by offer row, and live component listings by parent (including FMV).
email_templatesThe logistics-required email's fixed sentence becomes {DESCRIPTION}.

Backfill: every existing offer becomes a grouped offer of one listing, and every offer row is linked to its thread. Rollback: the down step refuses to run while any grouped offer with more than one listing exists.

Plan vs what was built

The plan from 29 Sep (marketplace-bundle-offers), checked against the branch.

Built as planned   Built differently done another way   Partial part of it   Not done nothing yet

#Plan itemStatusWhere it stands
M1Additive migration: items table, item_count, offer row → thread link, backfillBuiltAlso adds the touchpoints link, extra indexes and the logistics email template.
M2Make the link required and drop the three marketplace_id columnsNot doneThe design changed so the columns stay, now nullable. See decision 5.
A1Shared helpers for both API rolesBuiltIn src/shared/modules/vendor-offers/, with specs.
A2Send route for one or more listingsBuilt differentlyExtends the existing send-offer/:marketplaceId with marketplaceIds[] and an anchor listing.
A3Counter, accept, reject, cancel, resend and history work on the threadBuiltThe routes are unchanged; each listing's actions resolve to its thread.
A4Accept locks the listing rows and re-checks themPartialSend locks them; accept does not. See item 2.
A5PA document lists every itemBuiltThe document lists every listing and its components; the email gives the count.
A6Completion receives the thread id and writes one line per itemBuilt differentlyOne line per listing at an equal share. The payload is still listing + vendor; see items 4 and 6.
A7A listing sold elsewhere closes every offer holding it and voids their PAsBuiltOnly those offers' envelopes are voided.
A8One email or notification per offer stepBuiltNew notification type, carried onto development's job queue.
I1–I3Import role: DocuSign callback and cron, divest cron, action feedsBuilt—
G1–G1313 groups of reads move onto itemsBuiltAll areas changed. They are not each tested with a grouped offer; see item 7.
V2Individual / Grouped tabs on Vendor Offers and My OffersBuiltAlso on the vendor's My Offers, which settles open question Q8.
F1–F2Grouped-offer dialog, and selecting listings in Browse MarketBuilt—
F3–F5Offer reply modal, badges, DocuSign listBuilt—
P1Unit testsPartialThe 9 CAP-4002 suites pass. One older spec fails; see item 3.
P3–P5End to end, a single-listing regression run, swagger diffNot doneSee item 1.

Validation (14 Oct, 18:00, after the development merge)

CheckResultEvidence
Layering, worker-path and notification-event gatesPasslayering ok, worker paths ok, notification events ok: 11 event class(es)
API primary :8080PassFound 0 errors, then Nest application successfully started
API import :8081PassFound 0 errors, then Nest application successfully started
Swagger (primary)PassHas /grouped-offers, /grouped-offers/{threadId}/items, and marketplaceIds on CreateSendOfferDto
Front end :4200PassSwagger client regenerated from both roles; Application bundle generation complete with no errors
CAP-4002 unit specs (9 suites)Pass105 / 105
Vendor event handler specFail9 / 28 fail. The cause comes from development; see item 3.
End to end on the local DBNot runSee the QA checklist

QA checklist

Run on the local DB with the stack up. Each line is one case and what to expect.

#CaseExpect
1Vendor selects 3 listings from one facility and sends one offerOne row on the Grouped tab, with 3 items. The facility gets one notification.
2Select listings from two facilities, or a component listingSend is refused with a clear message.
3Send a grouped offer that includes a listing already in this vendor's live grouped offerRefused with the "already in a grouped offer" message.
4Facility counters, vendor counters back, facility acceptsOne offer row per step. Each listing shows the current status.
5Open the PAOne envelope, listing all 3 listings and their components at the total price.
6Complete the PA (signed or offline upload)One transaction with 3 priced lines plus component lines, adding up to the total. All 3 listings sold.
7Another vendor has an individual offer on one of the 3 listingsIt closes when the grouped offer wins, and links to it with "Selected in a grouped offer".
8One listing of an open grouped offer is sold through the cartThe whole grouped offer closes, its PA is voided, and the other 2 listings are free again.
9Vendor cancels a grouped offer, then sends it againWorks, and reuses the same thread.
10A one-listing offer from send to soldBehaves exactly as before the branch (regression).
11Open a grouped-offer email linkOpens the Grouped tab with that offer pinned and highlighted.
12Grouped tab filters: status, vendor, last updated, asset search on a component's nameEach filter narrows the list, and the tab counts follow.

Branch and files

RepoChangesvs development
backendApi (capExpertAPI)117 files, +5,384 / −2,079Up to date (development merged 17:31). Pushed.
capExpertApp56 files, +4,329 / −1,522Up to date (development merged 17:32). Pushed.
dbMigrations1 migration, 1 email template3 commits behind (unrelated CAP-20 sticker indexes). Pushed.

Main new files: