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.
On this page
- What is still needed
- Decisions needed
- How a grouped offer works
- What's included
- Database
- Plan vs what was built
- Validation
- QA checklist
- Branch and files
What is still needed
| # | Item | When | Why it matters | Where |
|---|---|---|---|---|
| 1 | Run the end-to-end checks on the local DB | Before merge | No 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 |
| 2 | Lock the listings when an offer is accepted | Before merge | Send 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 |
| 3 | Bring the out-of-date spec up to date | Before merge or own ticket | 9 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 |
| 4 | How the total is split across listings | Decision | The split is currently equal. See Decisions needed. | bundle-listings.ts · splitEqually |
| 5 | Follow-up migration | Decision | The offer row → offer link is still nullable, and the old marketplace_id columns are still there. See Decisions needed. | dbMigrations |
| 6 | Completion task sends the offer id | Later | The 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 |
| 7 | Tests across every read path | Later | The 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 |
| 8 | Try the 200-listing limit | Later | The 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 listing | Notes |
|---|---|---|
| Equal split (today) | $5,000 + $5,000 | Simple, but reports show the cheaper listing selling for far above its value. |
| In proportion to FMV | $9,000 + $1,000 | Lines 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:
marketplace_counter_offers.marketplace_offer_association_idis still nullable. Once the new API is live everywhere, any offer rows the old API wrote in between should be backfilled, and then the column made NOT NULL.- While the old columns exist, new code can still read through them by mistake. A grouped offer would then silently go missing, with no error.
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.
| Step | Who | What happens |
|---|---|---|
| Send | Vendor (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. |
| Counter | Either side | Adds one offer row for the whole offer and moves the thread's latest-offer pointers. Every listing's offer status is recalculated. |
| Accept | Whoever did not make the latest offer | The offer row moves to Awaiting Signature. One PA is created that lists every listing and its components, at the total price. |
| Reject / cancel | Either side / vendor or admin | One 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 uploaded | DocuSign / admin | One 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 elsewhere | Cart, offline sale, bulk action, divest cron | Every 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 status rollup, PA locks and the close-on-sale helpers
- completion, and the DocuSign callback and its cron
- the divest-to-auction cron
- action feeds (seed, reconcile and counts) and vendor cards
- the dashboards, the CaptAIn marketplace prompts and the chatbot tool
- the marketplace and transaction exports
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.
| Table | Change |
|---|---|
marketplace_offer_items | New. One row per listing in an offer. The (offer, listing) pair is unique, and rows are indexed by listing. |
marketplace_offer_associations | New item_count column (default 1, must be ≥ 1) and an index on vendor. marketplace_id becomes nullable. |
marketplace_counter_offers | New marketplace_offer_association_id column (nullable, indexed). marketplace_id becomes nullable. |
last_touchpoints | New marketplace_offer_association_id column, for notes on a grouped offer. |
docusign_requests, marketplace | Indexes: PA requests by offer row, and live component listings by parent (including FMV). |
email_templates | The 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 item | Status | Where it stands |
|---|---|---|---|
| M1 | Additive migration: items table, item_count, offer row → thread link, backfill | Built | Also adds the touchpoints link, extra indexes and the logistics email template. |
| M2 | Make the link required and drop the three marketplace_id columns | Not done | The design changed so the columns stay, now nullable. See decision 5. |
| A1 | Shared helpers for both API roles | Built | In src/shared/modules/vendor-offers/, with specs. |
| A2 | Send route for one or more listings | Built differently | Extends the existing send-offer/:marketplaceId with marketplaceIds[] and an anchor listing. |
| A3 | Counter, accept, reject, cancel, resend and history work on the thread | Built | The routes are unchanged; each listing's actions resolve to its thread. |
| A4 | Accept locks the listing rows and re-checks them | Partial | Send locks them; accept does not. See item 2. |
| A5 | PA document lists every item | Built | The document lists every listing and its components; the email gives the count. |
| A6 | Completion receives the thread id and writes one line per item | Built differently | One line per listing at an equal share. The payload is still listing + vendor; see items 4 and 6. |
| A7 | A listing sold elsewhere closes every offer holding it and voids their PAs | Built | Only those offers' envelopes are voided. |
| A8 | One email or notification per offer step | Built | New notification type, carried onto development's job queue. |
| I1–I3 | Import role: DocuSign callback and cron, divest cron, action feeds | Built | — |
| G1–G13 | 13 groups of reads move onto items | Built | All areas changed. They are not each tested with a grouped offer; see item 7. |
| V2 | Individual / Grouped tabs on Vendor Offers and My Offers | Built | Also on the vendor's My Offers, which settles open question Q8. |
| F1–F2 | Grouped-offer dialog, and selecting listings in Browse Market | Built | — |
| F3–F5 | Offer reply modal, badges, DocuSign list | Built | — |
| P1 | Unit tests | Partial | The 9 CAP-4002 suites pass. One older spec fails; see item 3. |
| P3–P5 | End to end, a single-listing regression run, swagger diff | Not done | See item 1. |
Validation (14 Oct, 18:00, after the development merge)
| Check | Result | Evidence |
|---|---|---|
| Layering, worker-path and notification-event gates | Pass | layering ok, worker paths ok, notification events ok: 11 event class(es) |
| API primary :8080 | Pass | Found 0 errors, then Nest application successfully started |
| API import :8081 | Pass | Found 0 errors, then Nest application successfully started |
| Swagger (primary) | Pass | Has /grouped-offers, /grouped-offers/{threadId}/items, and marketplaceIds on CreateSendOfferDto |
| Front end :4200 | Pass | Swagger client regenerated from both roles; Application bundle generation complete with no errors |
| CAP-4002 unit specs (9 suites) | Pass | 105 / 105 |
| Vendor event handler spec | Fail | 9 / 28 fail. The cause comes from development; see item 3. |
| End to end on the local DB | Not run | See the QA checklist |
QA checklist
Run on the local DB with the stack up. Each line is one case and what to expect.
| # | Case | Expect |
|---|---|---|
| 1 | Vendor selects 3 listings from one facility and sends one offer | One row on the Grouped tab, with 3 items. The facility gets one notification. |
| 2 | Select listings from two facilities, or a component listing | Send is refused with a clear message. |
| 3 | Send a grouped offer that includes a listing already in this vendor's live grouped offer | Refused with the "already in a grouped offer" message. |
| 4 | Facility counters, vendor counters back, facility accepts | One offer row per step. Each listing shows the current status. |
| 5 | Open the PA | One envelope, listing all 3 listings and their components at the total price. |
| 6 | Complete the PA (signed or offline upload) | One transaction with 3 priced lines plus component lines, adding up to the total. All 3 listings sold. |
| 7 | Another vendor has an individual offer on one of the 3 listings | It closes when the grouped offer wins, and links to it with "Selected in a grouped offer". |
| 8 | One listing of an open grouped offer is sold through the cart | The whole grouped offer closes, its PA is voided, and the other 2 listings are free again. |
| 9 | Vendor cancels a grouped offer, then sends it again | Works, and reuses the same thread. |
| 10 | A one-listing offer from send to sold | Behaves exactly as before the branch (regression). |
| 11 | Open a grouped-offer email link | Opens the Grouped tab with that offer pinned and highlighted. |
| 12 | Grouped tab filters: status, vendor, last updated, asset search on a component's name | Each filter narrows the list, and the tab counts follow. |
Branch and files
| Repo | Changes | vs development |
|---|---|---|
| backendApi (capExpertAPI) | 117 files, +5,384 / −2,079 | Up to date (development merged 17:31). Pushed. |
| capExpertApp | 56 files, +4,329 / −1,522 | Up to date (development merged 17:32). Pushed. |
| dbMigrations | 1 migration, 1 email template | 3 commits behind (unrelated CAP-20 sticker indexes). Pushed. |
Main new files:
- API —
src/shared/modules/vendor-offers/grouped-offers/(query service, rules, schema) - API —
src/primary/modules/vendor-offers/grouped-offers/(controller, DTO) - API —
src/shared/entity/marketplace-offer-items.entity.ts - Front end —
modules/offers/grouped-offers/(table, items, components, filters, menu) - Front end —
modules/offers/grouped-offer-actions/ - Front end —
modules/market-place/list/grouped-offer-modal/andlist.grouped-offer.ts - Front end —
modules/market-place/offer-view-tabs/