Plan only. Nothing is built yet. v1 was written 2026-10-07 from a code review. v2 (2026-10-13) folded in the stakeholder answers: partial cancels per equipment request (per quantity is out of scope, see A5), "relaunch or relist" options, return tracking, admin fee removed in full, and the ability to undo a cancellation. v3 (2026-10-14) corrects facts checked against the code and the dev data (dashboard storage, reminder cards, equipment traceability, the relaunch path) and moves every open point into Questions. Same day, two rules were settled: Simple cancel is status and money only and never touches equipment, and every record a cancel touches gets a new "Order Cancelled" status instead of reusing an existing one. The product owner answered the open questions on 2026-10-14 (answer sheet in Questions); the ones still needing stakeholder confirmation are in To confirm. Where this plan depends on one of those it says Pending Qn and states the assumed answer.
Part A is for everyone. Part B is the technical design for whoever builds it.
Part A — What we are building
A1. The problem
A customer cancels or returns an order after it was placed. The platform never finds out. We learn it from the vendor at the monthly admin-fee check. Until then:
- Sales, margin, and savings totals on every dashboard include the order.
- The fee check is done by hand from the vendor's list, not from our data.
- The equipment stays marked Sold or delivered, and nothing on the platform says the deal is off.
The Order Tracking page has a status dropdown today (Created, Shipped, Completed). It changes a label only. It does not touch reports or inventory, and anyone with access to the page can use it.
A2. Decisions
| Topic | Decision |
|---|---|
| Who | Admin only. |
| Where | Order Tracking page and Payments > Transactions page. Same action, same dialog. |
| Statuses | Cancelled (before delivery) and Returned (after delivery). An order can also be Partially cancelled or returned. |
| Statuses on the records | Every record the cancel touches (request, winning bid, listing, winning offer) gets a new status, "Order Cancelled" or "Order Returned". We never reuse the status a bidding-stage cancel or a removed listing uses, so the two can always be told apart (A2a). |
| Partial | Per equipment request (whole lines of an order). Cancelling some units of one line (per quantity) is out of scope for now; the data model leaves room for it (see A5). |
| Admin fee | Removed in full for the cancelled or returned part. Sales value for that part goes to zero. |
| The two choices | Simple cancel: status and money only. The order, the request or listing, and the winning bid or offer are marked Order Cancelled; the fee and sales value come off the reports; no equipment is touched. Relaunch CoPilot / List on marketplace: everything Simple does, then the equipment is put back the way it was before the order, and a fresh request or listing is started. |
| Return tracking | Yes. Admin can add the return shipment's carrier and tracking number. It is tracked like any other shipment and shown separately. |
| Undo | Yes. A cancellation or return set by mistake can be undone, and the order and everything it touched go back to exactly how they were. |
| Order record | Always kept. Original amounts stay on it for history. Reports use the "effective" amounts. |
A2a. The new statuses, and where they show
A request cancelled during bidding and an order cancelled after checkout are different things, and the platform must never show them under one label. So neither choice puts anything back into an existing status. Both write a new one:
| Record | Today after a bidding-stage cancel | After an order cancel (new) | After an order return (new) |
|---|---|---|---|
| CoPilot request | Canceled | Order Canceled | Order Returned |
| Winning vendor's bid (launch and proposal) | RFP Canceled | Order Canceled | Order Returned |
| Other vendors' bids (Lost) | RFP Canceled | Order Canceled, so they stop counting as Lost | Order Returned |
| Parent request (all lines) | Canceled | Order Canceled when every line is; stays Completed while any line is | same |
| Marketplace listing | removed_from_marketplace | order_cancelled | order_returned |
| Winning vendor's offer | Canceled ("Canceled by Vendor" on screen) | Order Cancelled | Order Returned |
| Other vendors' offers | Removed From Marketplace | unchanged (Not Selected) | unchanged |
| Facility's sold item (marketplace) | — | order_cancelled (no longer Sold) | order_returned |
| The order itself | — | Cancelled / Partial (closure badge) | Returned / Partial |
The facility's own item in a marketplace sale gets the new status too, so it no longer reads as Sold (whether it counts as fleet again is Pending Q30b). Inventory that a CoPilot or Denovo order created carries no new status and is not touched by Simple cancel. Under Relaunch / Relist the inventory goes back to the state it had before the order (unlisted, or removed).
Where the new status shows: the Cancelled column on the facility and vendor kanban, with its own "Order Canceled" pill and its own filter option; the vendor's offers list; every status filter; a separate "Order cancelled" count next to "Canceled" on the summary cards; exports; and the chatbot.
The cost: about 200 places in the code switch on the old statuses. Roughly half only need the new value added next to the old one; the other half need their own line (a column, a filter option, a badge, a count, a sort rank). The full list is in B8.5. Three database enums need new values (B2.7).
A3. What the admin sees
- On an order row, an admin picks Cancel or return.
- A dialog opens with three steps:
- What: Cancel or Return. Which equipment lines (all by default). The reason, typed in a free-text box (no fixed list).
- Then what: Simple cancel, or Relaunch CoPilot / List on marketplace. The second is offered only when the equipment can still be put back (A5a). For a return, optionally the return carrier and tracking number.
- Preview: how much sales value and admin fee come off. For Relaunch / Relist also which equipment moves where, with any warnings (for example "listed with 2 open offers").
- On confirm, the platform makes every change in one go, records a journal entry of what it changed, and emails the vendor and the facility.
- The row now shows a Cancelled, Returned, or Partial badge. The order detail page shows a timeline of these events, each with an Undo button (any event can be undone on its own, since cancelled lines never overlap).
- Undo puts everything back and records that too. Undoing a Simple cancel is always possible. Undoing a Relaunch / Relist shows what it would discard and asks the admin to confirm; it refuses only when a newer order exists for the same equipment (see A6; Pending Q16).
A4. What each choice does, by order type
| Order type | What the sale did | Simple cancel (always the same) | Relaunch / List on marketplace adds |
|---|---|---|---|
| Vendor marketplace (facility sells used equipment to a vendor) | Facility's equipment and listing marked Sold. Other vendors' offers closed. | Listing marked Order Cancelled. Winning vendor's offer marked Order Cancelled. Other vendors' offers untouched. The facility's item marked Order Cancelled instead of Sold. | The item back in the facility's inventory, unlisted, then a fresh listing with the same details (price must be confirmed by the admin for sales entered offline, see A6). |
| Internal marketplace (one facility buys from another) | Seller's equipment and listing marked Sold. A copy created in the buyer's inventory. | Listing marked Order Cancelled. Seller's item marked Order Cancelled; buyer's copy stays. | Seller's item back, unlisted. Buyer's copy removed, following A5a. Then a fresh listing. |
| CoPilot (facility buys new from a vendor after bids) | New equipment, warranties, and stickers created in the facility's inventory. Request marked Completed. | Request and winning bid marked Order Cancelled (or Order Returned) with the reason. The equipment, warranties, and stickers stay in the facility's inventory. | That equipment, with its warranties and stickers, removed (A5a). A brand-new request copied from the old one, launched automatically to the same vendors, winner included. Old and new requests are linked. |
| Service contract | New contract created. On a renewal the old one was archived and its equipment moved over. | Request marked Order Cancelled. The contract itself is untouched; the admin archives it and reactivates the old one by hand (Pending Q29). Only Cancel applies. | No relaunch in this version. |
| Denovo (new facility project) | The project's planning rows were updated to Purchased and extra unit rows cloned, one per unit, each placed in a room. | Same as CoPilot: request, winning bid, and losing bids marked Order Cancelled (or Order Returned). The project rows stay as they are. If units go back to the vendor, the facility uses the existing per-row Return to vendor action, which must keep working for these rows (B7.4). | Relaunch is a later phase: rows back to Planned, then the project's own launch. |
| Transfers | Equipment moved between facilities. No fee. | Not part of this. | — |
A5. Partial cancels and returns
Partial means some equipment lines of an order, not others. One line is one equipment request with its winning bid, its quantity, and the inventory rows created for it. An order for 3 monitors and 2 beds has two lines; the bed line can be cancelled while the monitor line stays.
- Cancelling a line is the same thing as cancelling that request after completion: the request and its winning bid are marked Order Cancelled with the reason, and the order's money drops by that line's amount. With Relaunch, that line's inventory rows are removed as well.
- The order keeps its original amounts and gains effective amounts: original minus the cancelled lines. Dashboards and the fee check use the effective amounts.
- Marketplace lines are one asset each, so partial there is also "some lines, not others".
- Sales entered offline by an admin and vendor-marketplace purchase-agreement sales carry no per-line prices, so for those two the whole order is cancelled or returned at once.
- Each partial is its own journal event with its own date and amount, so one line cancelled today and another returned next month are two rows in the fee check, undone one at a time.
Per quantity (out of scope for now). Cancelling 1 of the 3 monitors on one line. Not built, but the line columns and the journal are shaped so it can be added later with no schema change. What it would add: an asset picker in the dialog, a rule for components quoted per line, a way to know which units a shipment carried, and a relaunch with reduced quantity.
A5a. When Relaunch / Relist can be offered
Simple cancel is always available and always does the same thing. Relaunch / Relist also has to put the equipment back, which is only possible when the equipment is still where the order put it. The preview works that out per line and the dialog shows or hides the second choice accordingly.
| Where the equipment is now | Relaunch / Relist |
|---|---|
| Still at the buyer, unlisted | Offered. CoPilot: the created rows are removed. Marketplace: the seller's item goes back to unlisted. |
| Deleted by the facility since | Offered. Nothing to remove; the preview says "already removed by the facility". Undo leaves it deleted. |
| Listed on the marketplace now | Offered. The listing is taken down first and its open offers closed; the preview shows the offer count (decided Q5). |
| Sold on or transferred away since | Hidden. Simple cancel only; the preview names the later order. A full unwind means cancelling that later order first (a transfer cannot be cancelled in this version, see A4). |
| In use (stickers, asset registry, service contract) | Offered with a warning; stickers and registry links go with it (Pending Q7). |
| Cannot be traced to the order (older orders without recorded inventory) | Hidden. Only newer orders are supported for Relaunch / Relist; older ones get Simple cancel. |
On the dev data this is not rare: of about 1,070 items that CoPilot orders created, 89 were later deleted by the facility, 30 were sold on or transferred, and 4 are listed right now.
A6. Limits to be aware of
- Undo after something has happened since (Pending Q16). Three tiers. Nothing happened: plain undo. Newer activity but no newer order (new offers on the relisted item, vendor proposals on the relaunched request, the restored item listed again): undo shows what it will discard and the admin confirms; the discard reuses the existing unlist and cancel-request flows. A newer order exists for the same equipment (sold again, or the relaunched request checked out): undo refuses and names the order to cancel first.
- Documents and emails already sent stay. Invoice PDFs, confirmation emails, and signed purchase agreements are not recalled. A completed DocuSign agreement cannot be voided anyway, and we never try. One timing gap: the "not selected" email to losing vendors is sent later by a background job, so it must skip cancelled orders.
- Some orders cannot be traced to their equipment (mostly older internal-marketplace purchases; the preview decides per line from the data, never from a date). Simple cancel is unaffected. Relaunch / Relist is supported only for newer orders that recorded their inventory and is hidden otherwise (A5a).
- Offline (admin-entered) marketplace sales overwrote the item's details (price, condition, quantity, model) and set the listing price to zero. Simple cancel does not touch the item. Relist puts it back with the admin-entered details and creates the new listing at zero price, and the dialog says so (Pending Q9). The other offline variant, a vendor sale closed with an uploaded agreement, is harmless: only the sold flag changes.
- Denovo planning rows had quantity, price, and warranty overwritten at purchase and the extra unit rows cannot be merged back. Simple cancel leaves them alone. The later Relaunch phase would leave N rows of quantity 1 marked Planned with the purchase price still on them, unless the purchase starts saving the planning values, which is not part of this version.
- Dashboard history months (Pending Q1). Each stored month holds all-time running totals as of that month, for facility accounts only; the current month is always computed live. Assumed rule: a cancellation is booked in the month it happens. Past months stay as they were, the current month and the fee figures drop, and nothing stored is rewritten. The vendor's own dashboard counts won deals by their current status, so there the cancelled deal simply disappears from the month it was won.
- Dismissed reminder cards stay dismissed. The "order won, ship it" card is hidden per user by the request or offer it belongs to, with no expiry, so after an undo it does not come back (decided Q18: the won status and count do come back on undo; only a hand-dismissed card stays hidden).
A7. Reporting and the monthly fee check
- Every total that includes orders (sales dashboard, attainment, kanban revenue, savings, public stats, transaction list totals, exports, Denovo room totals, bid analytics) switches to the effective amounts and skips fully cancelled or returned orders in counts.
- The Marketplace Transactions report and the transaction export gain status, margin, and effective margin columns.
- No new report in this version (decided 2026-10-14). Cancels and returns show on the order list, in its export, and in the existing reports through the new status and effective margin columns. An Order Adjustments report and a per-vendor fee statement are needed later; see Questions Q3 and B13 "Later".
A8. What does not change
- Mobile app. It is the Capture field tool and does not read orders. When Relaunch / Relist removes equipment, it disappears from the fleet like any other deletion.
- Accounting sync. QuickBooks is not called by live orders today. Nothing to undo.
- Transfers and parts purchase orders. Out of scope.
A9. Decided and open
Decided on 2026-10-13 and 2026-10-14: admin only, from Order Tracking and Payments > Transactions; two choices in the dialog; Simple cancel is status and money only and never touches equipment; every touched record gets a new Order Cancelled / Order Returned status; partial per equipment request; fee removed in full; undo required; Denovo rows are never deleted; relaunch is a new request, not a reopened one, launched automatically to the same vendors with the trade-in and budget carried over; losing bids get Order Cancelled; the marketplace item gets Order Cancelled; the reason is free text; order cancellations get their own pill, filter, and count; any event can be undone on its own; no restocking fee (none exists in the data); no new report in this version.
Everything still open is in To confirm, with its example and assumed answer; this plan marks each as "Pending Qn". The ones that change the build most: Q1 (which month a cancellation lands in), Q16 (undo after new activity), Q25 (a 10-day window), Q29 (service contracts), and Q30b (whether an Order Cancelled item is back in the fleet).
Part B — Technical design
Paths are relative to capExpertApi/src unless stated. Line numbers were checked on 2026-10-14
and will drift.
B1. Design principles
- Journal before you touch. Every cancel, return, and undo writes one row to a new
transaction_closure_eventstable containing a before-snapshot of every record it is about to change. Undo replays the snapshot. This is required because order creation overwrites prior state and saves nothing (transaction-helper.service.ts,vendor-offer-transaction.service.ts,copilot-pre-order-checkout.service.tsall set Sold / Completed with no previous-status stamp). - Never do anything irreversible. No DocuSign voids (do not call
removeMarketplaceOffers, which voids by default; do not firecerVoidEvent). No hard deletes. Every removal is a soft delete on a paranoid entity sorestore()works. - Original amounts stay; effective amounts are derived and stored. Reports read effective.
- One predicate, one expression. Counts use
transactionIsLiveSql(alias); sums useeffectiveMarginSql(alias)etc., both inshared/sql/canonical-predicates.sql.ts. - Per-type handlers behind one orchestrator. The orchestrator owns the DB transaction, the journal, amounts, emails, and action-feed upserts. Handlers know only their order type.
- Undo works per line, not per order (decided Q17). Closed lines never overlap, so undo restores from the event's own line snapshots and recomputes the order-level columns and amounts instead of replaying the order-level image. The order-level image stays in the journal for audit. An event already undone cannot be undone again.
- New statuses, never reused ones. Every record a closure touches gets
Order Canceled/Order Returned(A2a). ReusingCanceled,RFP Canceled,removed_from_marketplace, or the offerCanceledwould make a cancelled order indistinguishable from a bidding-stage cancel or a pulled listing in every list, count, and vendor view. ThecancellationJSON and the journal carry the reason; the status carries the kind.
B2. Schema changes (dbMigrations)
All migrations follow the existing style: YYYYMMDDHHMMSS-description.js, 'use strict',
@type {import('sequelize-cli').Migration}, explicit transaction. Template for the table:
20261006084411-create-capture-project-files-table.js. Template for columns:
20260522110517-add-white-glove-shipping-charge-to-transactions.js.
B2.1 transactions — new columns
| Column | Type | Meaning |
|---|---|---|
closure_status |
STRING(20) NULL | NULL = live. partial, cancelled, returned. |
closed_at, closed_by |
TIMESTAMPTZ, INT | Last closure event. Cleared on full undo. |
closure_reason, closure_note |
STRING, TEXT | Last event's reason and note. |
effective_inventory_amount, effective_components_amount, effective_service_components_amount, effective_shipping_charge, effective_sales_tax, effective_transaction_fee, effective_total_amount, effective_margin_amount |
FLOAT(12,2) NULL | NULL = same as original. Set by every closure and undo. |
order_status stays a plain string for the lifecycle (Created, Shipped, Delivered, Completed).
Closure is orthogonal so a partially cancelled order can still ship its remainder. No DB enum.
Partial index: (closure_status) WHERE closure_status IS NOT NULL.
B2.2 transaction_equipment_details — new columns
| Column | Type | Meaning |
|---|---|---|
closure_status |
STRING(20) NULL | cancelled, returned. (partial reserved for per-quantity.) |
closed_quantity |
INT NOT NULL DEFAULT 0 | Reserved for per-quantity. Per-request closure sets it to quantity. |
closed_inventory_ids |
JSONB NULL | Rows removed by Relaunch / Relist, from equipment_details.buyingInventoryIds. NULL after a Simple cancel. |
closure_event_id |
INT NULL FK | Last event touching the line. |
The two reserved columns cost nothing now and mean per-quantity later is a dialog, eligibility, and amounts change only, with no migration.
B2.3 New table transaction_closure_events (the journal)
| Column | Type | Notes |
|---|---|---|
id |
BIGSERIAL PK | |
transaction_id |
INT NOT NULL FK → transactions | index |
kind |
STRING(10) | cancel, return, undo |
scope |
STRING(10) | full, partial |
outcome |
STRING(10) NULL | simple, relist, relaunch |
reason, note |
STRING, TEXT | |
lines |
JSONB | [{lineId, quantity, inventoryIds[], equipmentState, putBack, actions[]}]: what was requested and, for Relaunch / Relist, where each line's equipment was and what was done with it (B7.6). Simple writes equipmentState: null, putBack: false. |
before |
JSONB | Snapshot, see B4. The source of truth for undo. |
after |
JSONB | What was written, for display and audit. |
side_effects |
JSONB | {newMarketplaceIds[], clonedCopilotParentId, returnShipmentId, emailsSent[]} |
amount_delta |
JSONB | {inventory, total, margin, ...} removed (negative) or restored |
undone_event_id |
BIGINT NULL FK self | For kind='undo': which event it reversed |
undone_at, undone_by |
TIMESTAMPTZ, INT | Set on the reversed event |
created_by, created_at |
INT, TIMESTAMPTZ |
Partial index (transaction_id) WHERE undone_at IS NULL AND kind <> 'undo' for "open events".
B2.4 shipment_trackings — direction
direction STRING(10) NOT NULL DEFAULT 'outbound' (outbound | return), plus
closure_event_id BIGINT NULL. Template: 20260730101916-add-is-delivered-to-shipment_trackings.js.
B2.5 copilot_parent_requests — lineage
relaunched_from_parent_id INT NULL so a relaunched request links to the cancelled one. No lineage
field exists today (the UI "Reopen Request" is a clone with no link).
B2.6 Email templates
One migration using dbMigrations/database/helpers/common-email-template-migration.js (template:
20260806073540-add-report-ready-email-template.js) with three HTML files in
dbMigrations/email_templates/: order_cancelled_email.html, order_returned_email.html,
order_closure_undone_email.html.
B2.7 Status values
Code enums (shared/utils/enum/index.ts unless stated), each gaining ORDER_CANCELED and
ORDER_RETURNED:
copilotStatusEnum ('Order Canceled' / 'Order Returned'), copilotParentStatusEnum,
copilotLaunchesStatus (used by launches and proposals), copilotParentLaunchesStatus (~174, the
launch parent_status rollup, easy to miss), SELL_STATUS ('order_cancelled' / 'order_returned'),
MarketplaceCounterOfferStatusEnum ('Order Cancelled' / 'Order Returned') and its ...ForDTO
duplicate in shared/utils/constant/index.ts, and the DataType.ENUM(...) list on
shared/entity/market-places.entity.ts:167. InspectionStatusEnum is not extended: inspections
reuse Canceled (string column, nothing switches on it that matters).
Database. Three columns are Postgres enums, so each needs ALTER TYPE ... ADD VALUE, in its own
migration (ADD VALUE cannot be used in the transaction that adds it):
enum_ezrfp_requests_status(copilot_requests.status; the entity says STRING, the column is the enum) + 'Order Canceled', 'Order Returned'.enum_marketplace_status(marketplace.status) + 'order_cancelled', 'order_returned'.enum_marketplace_counter_offers_status(shared bymarketplace_counter_offers.status,.previous_status, andmarketplace.counter_offer_status) + 'Order Cancelled', 'Order Returned'. Launch, proposal, parent-request, inventorysell_status, and inspection columns are plain strings.
Two SQL functions test the old terminal lists and must be redefined in a migration with the new
values added: update_inventory_lookup_trade_in (status NOT IN ('Canceled','Completed','Closed'),
otherwise a cancelled order's trade-in stays locked) and recompute_sc_copilot_parent_id
(migration 20260615143529, same list).
B3. Backend module layout
New folder primary/modules/transaction/closure/:
transaction-closure.controller.ts admin routes (B5)
transaction-closure.service.ts orchestrator: preview, cancel, return, undo
closure-journal.service.ts snapshot(), restore(), openEvents()
closure-amounts.service.ts effective amounts per type (B6)
closure-eligibility.service.ts per-line checks, equipment state, whether Relaunch / Relist may be offered (B5, B7.6)
handlers/
closure-handler.interface.ts snapshot(ctx) / apply(ctx) / undo(ctx) / canUndo(ctx)
marketplace-closure.handler.ts Vendor marketplace (PA and offline)
fmv-closure.handler.ts Internal marketplace (cart)
copilot-closure.handler.ts CoPilot + Denovo
service-contract-closure.handler.ts
transfer-closure.handler.ts rejects (out of scope)
(no dashboard-snapshot service: Pending Q1, assumed answer needs none, see B8.3)
Shared pieces go in shared/ (layering rule: shared/ must not import primary/):
shared/sql/canonical-predicates.sql.ts:transactionIsLiveSql,effective*Sql.shared/entity/transaction-closure-events.entity.ts.shared/modules/transaction/dto/closure.dto.ts(request and response DTOs).shared/modules/events/event-types.ts:ORDER_CANCELLED,ORDER_RETURNED,ORDER_CLOSURE_UNDONE.
The import role needs the entity and predicates only (cron copies of the aggregates, exports).
B4. The snapshot (what before must contain)
Captured inside the DB transaction, before any write, by the orchestrator plus the type handler.
Simple cancel (every type)
transactions:order_status,closure_*, alleffective_*,history_noteslength.- Each targeted
transaction_equipment_detailsrow:closure_status,closed_quantity,closed_inventory_ids,closure_event_id. - CoPilot / Denovo / service contract:
copilot_requestsid, status, cancellation, additional_details; everycopilot_launchesandcopilot_launch_proposalsrow of the request in Proposal Accepted or CoPilot Lost,id, status, previous_status, parent_status;copilot_parent_requestsid, status; thebudget_planningrows pointing at the request. - Marketplace / FMV:
marketplacerows (parent + children byparent_id)id, status, transaction_id, counter_offer_status, docusign_request_id; the winningmarketplace_counter_offersthreadid, status, previous_status; sellercamp_inventory_items(parent + children)id, sell_status.
Relaunch / Relist adds
- Seller
camp_inventory_items(parent + children byinventory_parent_id):marketplace_id, transaction_id, deleted_at;inventory_inspectionsfor the listingid, status. - Buyer or created rows from the line's
equipment_details.buyingInventoryIds(unit rows first, then component rows): each row'sdeleted_at, sell_status, marketplace_id, service_contract_id, warranty_id, quantity, purchase_price, plus theirinventory_stickers,inventory_sticker_details, andasset_registries.inventory_idlinks. warrantiesand theirdocument_managerrows reached throughwarranty_id:id, deleted_at.- If the equipment is listed now: that listing's rows, threads, carts, and wishlists (B7.6).
side_effectsrecords what was created:newMarketplaceIds[],clonedCopilotParentId,returnShipmentId,emailsSent[].
Not journaled because neither choice touches them: pre-order items, PO requests, CER rows,
outbound shipments, Denovo rows, service-contract rows. after holds the same keys as before
with the written values.
B5. Endpoints
All on TransactionClosureController (@Controller('transaction')), guarded by
@UseGuards(AdminGuard) and @RequireAnyFeature(FEATURES.PAYMENT.TRANSACTIONS.ROOT, FEATURES.ORDER_TRACKING.ROOT) (precedent: transaction.controller.ts confirm-margin and the
combined-transactions route). No new feature key.
| Method | Route | Body / result |
|---|---|---|
| GET | /transaction/:id/closure-preview?kind=cancel|return |
Lines with request name, quantity, inventoryIds, per-line eligibility (delivered, equipmentState: in_place / deleted_by_facility / listed / moved_on / in_use / not_traceable, usedSince[], lineClosable, putsBack), allowed outcomes (Simple always; Relaunch / Relist only when every picked line's equipment can be put back, B7.6), amount delta per line and for the whole order, warnings (listed with open offers, in use, moved on with the later order named, not traceable, offline price zero). |
| POST | /transaction/:id/cancel |
{lineIds:number[], reason, note?, outcome:'simple'|'relist'|'relaunch'} → event id, new badges, amount delta. Simple never touches equipment; for Relaunch / Relist the eligibility service decides per line what can be put back and records it in the event. (Per-quantity would add quantity / inventoryIds per line; not accepted now.) |
| POST | /transaction/:id/return |
Same plus returnShipment?:{shippingCompanyId, trackingNumber?, manualShippingDetails?}. |
| POST | /transaction/:id/closure-events/:eventId/undo |
{note?} → new undo event. 409 with a reason list when blocked. |
| GET | /transaction/:id/closure-events |
Timeline for the detail page. |
Existing routes to tighten in the same change:
PUT /transaction/order-status: validate againstTransactionOrderStatusEnum, reject whenclosure_status IN ('cancelled','returned'). It stays lifecycle-only.PUT /transaction/update-margin/:id: addAdminGuard(missing today). On update, recomputeeffective_margin_amounttoo.POST /transaction/shipment-tracking: DTO gains optionaldirection(defaultoutbound).DELETE /copilot/:copilotParentId(copilot-crud.service.tsremove()): refuse when any linked transaction exists (completed or closed). Today it soft-deletes the transaction, its lines, and the buy and sell orders with no status check.
B6. Effective amounts (closure-amounts.service.ts)
Closure is per whole line, so each closed line removes its full value. (Per-quantity later would
introduce a fraction f = closedQuantity / quantity; the formulas below are written so that
f = 1 today.)
| Type | Closed inventory value per line | Shipping, tax, fee |
|---|---|---|
| CoPilot / Denovo (checkout) | total_price + components_total_price + components.optionalItemsPrice + service_components_total_price + serviceComponents optional |
The line's shipping, white glove, and installation come off with it (they live on copilot_launch_proposals per line). Tax recomputed on the new subtotal with the stored rate; fee with the stored percentage. |
| CoPilot offline | same | The offline path stores order-level sums only: prorate shipping and tax by the line's share of inventory amount. |
| FMV (cart) | line total_price (= FMV) |
Shipping kept unless the whole order closes. No tax or fee. |
| Vendor PA, offline marketplace | Whole order only: lines carry 0 / NULL. effective_* = 0. |
— |
| Service contract | line total_price (= contract grand total) |
— |
| Transfer | rejected | — |
effective_inventory_amount = inventory_amount − Σ closed;
effective_margin_amount = effective_inventory_amount × capexpert_margin_percentage / 100
(same formula as creation in denovo-transaction.helper.service.ts and buy-sell-order.service.ts);
effective_total_amount rebuilt from the effective parts. Savings amounts
(copilot_presented_amount / copilot_captured_amount) are not changed: the kanban and
dashboard fall back to copilot_requests.presented_saving when both are 0. They are excluded by
transactionIsLiveSql instead, and prorated at read time for partials only in the kanban revenue card.
Undo recomputes from the remaining open lines, so a second cancel after an undo stays correct.
B7. Per-type playbooks
Each is executed inside one Sequelize transaction. Order: snapshot → writes → journal row → commit → after-commit jobs (action-feed upserts, emails).
B7.0 Simple cancel, every type
Status and money only. Per closed line:
- Line columns:
closure_status,closed_quantity = quantity,closure_event_id;closed_inventory_idsstays NULL. - Transaction columns and effective amounts (B6);
closure_status=partialwhile any line is open, elsecancelled/returned. - The type's status writes:
- CoPilot, Denovo, service contract:
copilot_requests.status = 'Order Canceled'(or'Order Returned') with thecancellationJSON in the shapecancelCloseCoPilotwrites ({reason, cancelled_by, cancelled_at, previous_status:'Completed', type:'order_cancel'|'order_return', notes}). The winning launch and its proposal getprevious_status = statusthen the same new value; the winning launch'sparent_statusgets the matchingcopilotParentLaunchesStatusvalue. Losing proposals and launches (COPILOT_LOST) get the same treatment, so they leave the vendor's lost count; restoring their pre-checkout status instead would put them back in the vendors' Under review column on a dead request. Non-bidders stayNO_PARTICIPATION.updateLaunchStatus(group-wide recompute from proposals) must rank the new values ahead ofCOPILOT_LOSTor the next recompute flips the winner's launch to Lost;CoPilotLostVendorEmailJobfinds no Lost launches left and also skips requests in a new status. Append tocopilot_parent_requests.action_logs; recompute the parent viachangeCoPilotParentStatusAfterCompletewith a new rule (Order Canceledwhen every line is, otherwise unchanged).unlinkBudgetOnCoPilotCloseso the budget line is free again (journaled). Do not callcancelCloseCoPilot: it skips COMPLETED lines, excludes the winner and losers, and firescerVoidEvent. Pre-order items, PO requests, and CER rows are left as they are. - Vendor marketplace and FMV:
marketplacerows (parent + children)status = 'order_cancelled'(or'order_returned').transaction_idanddocusign_request_idare kept, so the listing still records its order and the signed agreement stays reachable; Relist clears them (B7.1). Notremoved_from_marketplace: that is what a pulled listing gets. Only the winning thread (LOGISTICS_REQUIRED/SOLDwith the winningvendor_id) getsprevious_status = status, status = 'Order Cancelled'; the other threads staySold("Not Selected"). Do not callremoveMarketplaceOffers(rewrites every thread,Promise.allrace). ThenupdateMarketplaceCounterOfferStatussomarketplace.counter_offer_statusrolls up, with the new values ranked ahead ofSOLDin both priority maps. Never write NULL: the facility "My Offers" view hides NULL threads. The seller's inventory (parent + children) getssell_status = 'order_cancelled'(or'order_returned') and keeps itsmarketplace_id; how the ~120sell_statusreaders treat the new value is Pending Q30b (B8.5).inventory_inspectionsuntouched. FMV has no threads: listing and seller item only.
- CoPilot, Denovo, service contract:
- Journal row, commit, after-commit:
actionFeedUpsert('copilot' | 'marketplace', ids)and the emails (B9). No inventory event, nothing physical changed.
Undo of a Simple cancel: restore the status columns from the journal, recompute amounts from the remaining open lines, re-link the budget. Always allowed.
B7.1 Vendor marketplace: Relist
B7.0, then:
- Seller inventory (parent + listed children):
sell_status = NULL, marketplace_id = NULL, transaction_id = NULL(whatcommonDestroyMarketPlacewrites for a removed listing). The old listing rows:transaction_id = NULL, docusign_request_id = NULL; status staysorder_cancelled.inventory_inspectionsinSold→Canceled. - New listing:
MarketplaceCrudHelperService.create/handleParentInventorieswith the old listing's fields (price, FMV, condition, flag, comment). This is what every relist does today; no path reactivates an old row, and a new row sidesteps theisAlreadySentlockout on the old thread. RecordnewMarketplaceIdsinside_effects. For offline sales the old price is 0; the preview warns and the dialog requires a price.
Undo: B7.0 undo, then restore the inventory fields and inspections from the journal, and deal
with the new listing (Pending Q16, assumed): Sold or with a signed PA → refuse and name the order;
with offers, carts, or wishlists → the preview lists them and, on confirm, commonDestroyMarketPlace
semantics minus the wishlist hard delete (a narrow variant) take it down; otherwise take it down
silently. actionFeedUpsert('inventory', ids).
B7.2 Internal marketplace (FMV): Relist
B7.1, plus the buyer's copy (additional_details.buyingInventoryIds; for orders without them the
copy is not_traceable, B7.6): per the state rules, forceDestroyByIds(ids, reqUser, tx)
(soft-deletes inventory, stickers, details; unlinks asset_registries.inventory_id, so journal
those links). Do not reuse destroyAll's eligibility as a silent skip: its "ineligible" is exactly
our moved_on, which the preview must show. Duplicate-purchase guard in createTransactionForCart
/ createTransactionForVendorMarketplace (transaction-helper.service.ts): add
closure_status IS NULL OR closure_status = 'partial' (and for partial, exclude closed line ids),
or the same buyer can never re-buy.
Undo: B7.1 undo, then restore() the buyer rows, stickers, details, and asset-registry links.
B7.3 CoPilot: Relaunch
B7.0, then per closed line:
- The created rows (
equipment_details.buyingInventoryIds: unit rows then component rows) per the state rules (B7.6):forceDestroyByIds; warranties reached viawarranty_idand theirdocument_managerrows soft-deleted explicitly (no helper does it; a warranty shared by other surviving rows throughwarrantyGroupMapis kept). - A server-side clone, not a redirect into the UI form. The UI "Reopen Request" is a facility
action that pre-fills the create form from
GET /copilot/:id?requestFor=clone(which must accept the new status) and submits a plain create; it always makes a new parent in New and never launches. Reuse its read andcopilotCrudService.create, in our own service:- strip
status,id,requestId,groupId,cancellation, andadditionalDetails(the create copies it verbatim and it carrieslostEmailSent); clear past target dates; - carry the uploaded attachments only, not the quote that would mark the request un-approved and re-run AI extraction;
- carry the trade-in when that item is still unlisted; re-link the budget row to the new request (decided Q12);
accountId= the facility,createdBy= the admin,approvedByAdmin = true;- set
relaunched_from_parent_id; recordclonedCopilotParentIdinside_effects; - the new request is created and launched in the same call to the original vendor list, winner
included, reusing the CaptAIn
createAndLaunchOneFacilitypattern (copilotCrudService.createthencopilotLaunchService.launchCoPilot); children with no matching vendor are dropped bycheckVendorMatch, so the preview lists the vendors that will be invited. A new parent is the only safe shape: appending to the old parent is unsupported (update()on a Completed parent destroys the Completed siblings). There is no duplicate guard on equipment, so a second request for the same inventory is allowed. Not available for Denovo or service contracts.
- strip
Undo: B7.0 undo, then restore() inventory, stickers, details, warranties, documents, asset
registry links; budget link from the journal; the clone (Pending Q16, assumed): checked out →
refuse and name the order; vendor responses (launch bid_submit_date, is_opt_out, proposals,
quotes, chat) → the preview lists them and, on confirm, the clone is closed through the existing
in-progress cancel plus our own vendor notice; no responses → soft-delete it through remove().
actionFeedUpsert('copilot') and ('inventory').
B7.4 Denovo
Simple cancel only (B7.0). The project rows stay exactly as they are: no status flip, no clones touched, no warranty change. Two things to keep working:
- The existing per-row Return to vendor action (
updateDenovoStatus, guarded byisCopilotSold, which testscopilot_requests.status = COMPLETED) must also accept the new statuses, or the facility cannot send the units back after we mark the order. - Denovo pre-orders are keyed by project and vendor; they are not touched here.
Relaunch is a later phase: rows back to Planned (write the status directly; the transition table
has no purchased → planned edge), clones and
purchase_pricehandled, then the project's ownlaunchCopilot. Saving the planning values at purchase time (not in this version) is what would make that exact.
B7.5 Service contract
Simple cancel only (B7.0): the request is marked Order Canceled; the new service_contracts row,
the archived previous one, and the moved inventory are untouched (Pending Q29). If the admin
archives the new contract by hand, note that the default scope hides archived rows and an
un-archive needs ServiceContract.scope('anyLifecycleStatus'). No return, no relaunch.
B7.6 Equipment state rules (Relaunch / Relist only)
The eligibility service classifies each picked line's equipment and the preview shows it:
in_place, deleted_by_facility, listed, moved_on (a later order line or a transfer points
at it, or a later row was created from it), in_use (stickers, asset registry, service contract),
not_traceable. Rules:
in_place: remove (CoPilot, FMV buyer copy) or put back unlisted (seller item).deleted_by_facility: nothing to remove; recorded asputBack:false; undo leaves it deleted.listed:commonDestroyMarketPlacesemantics first (the preview shows the open-offer count, decided Q5), then asin_place.moved_on: Relaunch / Relist is not offered for that line; the preview names the later order.in_use: asin_place, with the warning (Pending Q7).not_traceable: CoPilot → not offered; marketplace → offered, the seller item is always known from the line'sinventory_id/marketplace_id, and the preview warns the buyer's copy stays. The state and the action taken are recorded per line in the event'slinesJSON. Timestamps are not used:camp_inventory_items.updated_atis skipped by triggers and silent updates.
B8. Reporting
B8.1 Predicates
In shared/sql/canonical-predicates.sql.ts, following activeFleetSql:
export const transactionIsLiveSql = (t: string): string =>
`(${t}.closure_status IS NULL OR ${t}.closure_status = 'partial')`;
export const effectiveMarginSql = (t: string): string =>
`COALESCE(${t}.effective_margin_amount, ${t}.capexpert_margin_amount)`;
// same for inventory_amount, total_amount, transaction_fee
Plus Sequelize where/literal helpers for findAll callers.
B8.2 Call sites
| Site | Change |
|---|---|
primary/modules/dashboard/sales/transactional-details.service.ts (CoPilot include, marketplace/FMV, ROI, raw SQL at the end) |
live predicate on counts, effective sums |
primary/modules/dashboard/sales/attainment.service.ts |
same |
primary/modules/copilot/crud/copilot-kanban.service.ts revenue card |
same (raw SQL) |
primary/modules/dashboard/dashboard.service.ts savings and liquidation |
same; liquidation also stops counting listings whose order is closed |
primary/modules/dashboard/performance-tracking.service.ts |
same |
primary/modules/dashboard/sales/sales-dashboard.service.ts first-time/returning |
live predicate in the EXISTS |
primary/modules/public-stats/public-stats.service.ts |
same |
primary/modules/transaction/transaction.base.query.ts list totals (two copies) and import/.../transaction.base.query.ts |
effective sums; new closureStatus filter |
primary/modules/copilot/copilot-helper/copilot-helper.service.ts active-order counts |
closed orders are neither active nor in progress (today a cancelled-but-kept order counts as "in progress") |
copilot-kanban.service.ts Completed-column summary (queryTransactionAggregates without the equipment filter), vendor won tab, bidding-finder.service.ts order link on the vendor bid detail |
live predicate; a partial closure must not count the closed line |
Vendor orders, invoices, and shipments lists (transactions by seller_account_id), addShipmentTrackings |
show the closure badge; refuse new outbound tracking on a closed order |
fleet-planning/budget-report.service.ts procurement spend (total_amount where not deleted) |
effective total, live predicate |
primary/modules/action-feeds/action-feed-won-cards.service.ts and import/modules/cron/action-feeds/seed-action-feeds.service.ts won builders |
live predicate (partial CoPilot closures leave the request Completed) |
primary/modules/action-feeds/action-feed-shipment-cards.service.ts, shipment-tracking-event-handler.service.ts, won covering-shipment SQL, reconciler rules marketplace_won_delivered_reopen, marketplace_won_attach_hide, shipment_other_delivered_auto_resolve, shipment_deleted, seed descriptors, copilot-event-handler.service.ts, order has-shipment filter, export |
direction='outbound' |
primary/modules/denovo-projects/.../denovo-room-view.service.ts totals, copilot-bids-analytics.service.ts, copilot-bids-vendor.service.ts |
effective sums / live predicate |
primary/modules/chatbot/tools/procurement.tools.ts |
same |
shared/modules/reports/definitions/marketplace/marketplace-transactions/*.report.ts |
status, margin, effective margin columns; default filter live |
import/modules/transaction/transaction.service.ts export, import/modules/thread-helper/copilot-export.ts |
status + margin columns |
import/modules/cron/dashboard-analytics/transactional-details/*.service.ts |
effective sums going forward |
B8.3 Past months on the dashboard (Pending Q1)
How dashboard_analytics really works (checked 2026-10-14): one row per facility account and
year, eight JSONB columns keyed by month index "0"–"11"; the cron writes only the current
UTC month, and each month key holds all-time cumulative totals as of that run, with no date
filter and no status filter. Vendors and management groups have no rows. On read, the selected
month is computed live when it is the current month and from the snapshot otherwise; growth is
computed at read time as month M against M−1 on average order value. CoPilot past-month GMV and
margin are recomputed live from transactions.created_at <= end of month and never read the
snapshot.
Consequences: a delta on "the order's month" alone would break the next month's growth figure, and the current month never needs a delta.
- Assumed answer (book the cancellation in the month it happens): no stored month is ever
edited. The live current-month queries and the cron get the live predicate and effective sums
(B8.1, B8.2). The live past-month CoPilot queries get a time-aware variant,
transactionIsLiveAsOfSql(t, asOf)=closure_status IS NULL OR closed_at > asOf, so a month that ended before the cancel still counts the order, matching the snapshot semantics. The fee figures show the cancellation in the event month. - Alternative (restate history): subtract the delta from every stored key from the order's month to the latest stored key, on the buyer's and the seller's facility rows, for the marketplace and ROI JSON columns. More code, rewrites reported history, and still leaves the vendor dashboard inconsistent (it counts won deals by current status).
B8.4 Order Adjustments report (later, out of scope now)
Parked on 2026-10-14. When picked up: a definition under
shared/modules/reports/definitions/transaction/order-adjustments/, one row per closure event
(order id, type, buyer, seller, order date, event date, kind, scope, reason, amount delta, margin
delta, outcome, by, undone), filters on event month, order month, and account, CSV/XLSX like the
Marketplace Transactions report. The journal table (B2.3) already holds every column it needs, so
nothing in this version has to change for it.
B8.5 The status sweep
Every site that switches on Canceled / RFP Canceled / removed_from_marketplace / offer
Canceled was classified on 2026-10-14. Counts below are code sites, not lines.
Add the new value next to the old one (treated as terminal; no new behaviour): CoPilot 74
backend + 20 frontend, marketplace 12 backend + 13 frontend. The ones that silently misbehave
without it: copilot-event-handler.service.ts computeTarget (returns skip and orphans the
won card), copilot-helper.service.ts calculatedStatus (would show Ready for Review) and
pendingSavings (keeps counting), the terminal lists in copilot-launch-event-handler.service.ts
and seed-action-feeds.service.ts, the vendor guards acceptProposalRfp and
submitBidCollectively, closeAllArchived, the SC and trade-in and room-asset locks
(service-contract.constant.ts, service-contract-eligibility.util.ts,
inventory-locations.service.ts), marketplace-event-handler.service.ts computeTarget, the
marketplace_removed reconciler rule, canceledVendorOffer (or the vendor can turn Order
Cancelled into Canceled), vendorRemove, marketplace.crud-helper.service.ts:104, the two
priority maps, and the sort CASE lists in market-place.service.ts and vendor-offers.service.ts.
Needs its own line (a column, filter option, badge, count, sort rank, label): CoPilot 47 backend + 21 frontend, marketplace 10 backend + 7 frontend. By area:
- Kanban: facility CANCELLED tab (
copilot-kanban.service.ts:563-577) and vendor CANCELLED tab (:2465-2470),VENDOR_TAB_STATUS_MAP,statusTypeMapping(two copies), card flagshasOrderCanceledStatus,transactionIdattached to cancelled vendor cards (today won only),performedAtCASEs. Today a request or launch in a brand-new status lands in no column on either board. - Filters and sorts: status filter cases in
copilot-helper.service.ts,copilot-kanban.service.ts,dashboard-helper.service.ts,copilot-bids.service.ts, the import-role filter and sort constants, the launch-list sort ranks, the facility "removed" listing toggle and the admin Vendor Offers literal (primary and import copies). - Rollups:
changeCoPilotParentStatusAfterComplete(parent status), the launchparent_statusrollup,copilot-crud.service.tsstatusRules, the counter-offer priority maps. - Counts and metrics: summary
canceledcount (a separate Order cancelled count, decided Q27), per-parent and account analytics counts,totalLaunch,closeDateColumn, liquidation summary buckets, chatbot status buckets. - Labels:
ModifiedCopilotStatusEnumandVendorCopilotStatusexport labels, chatbotstatusLabel,getCounterOfferStatuspassthrough. - Clone / reopen: the
requestFor=clonefilter andreOpenCountmust accept the new value, since Relaunch clones an order-cancelled request. - Frontend: regenerated swagger enums;
list.types.tsfilter options;kanban.service.tsshowStatusPill,STATUS_PILL_VARIANT_TW, the vendor status collapse at 412-416, the Reopen action;CopilotLaunchesStatusForViewlabel; preflight list filter and colours; Denovo bid row flags; marketplaceOFFER_REQUEST_LIST,getOfferStatusFilter, the four offer badge maps, the reply-modal banner andshowCancelBtn, the listing details-modal@switchand the fourshowVendor*/showAddToCartgates, the listing row badges, Order Details links.
No change (gated by a positive status, or unrelated): CoPilot 45, marketplace 98.
Inventory sell_status (about 120 sites): classified as no change while Simple left the item
Sold. With order_cancelled on the item (Q30) every one must be rechecked, because some read
"not Sold or Transfer" as fleet and others read "blank or Sell" as fleet. The answer to Q30b
decides which side the new value joins.
B9. Side effects after commit
actionFeedUpsert('marketplace', ids)/('copilot', ids)after every closure;('inventory', ids)only after Relaunch / Relist, since Simple changes no inventory. Offline-marketplace won cards are cleaned only by the cron rulemarketplace_won_offline_cleanup; emit the marketplace upsert anyway so the instant path covers what it can.- Emails:
backgroundJobService.emailTask('OrderCancelledEmail' | 'OrderReturnedEmail' | 'OrderClosureUndoneEmail', ...); add to theEmailQueueunion andemailHandlersinprimary/modules/task-queue/task-queue.controller.ts; one mail service inprimary/queue/mail/transaction/. Recipients: vendor account users and facility account users on the order, plus admin CC. transactions.history_notesappend{status: 'Cancelled'|'Returned'|'Undo', notes, createdAt, createdBy}so the existing notes UI shows it.copilot_parent_requests.action_logsappend for CoPilot types.module_activities: none. That table feeds adoption metrics, not audit.- Background jobs that today ignore order status and must skip closed orders:
CoPilotLostVendorEmailJob(sends "not selected" up to 8 days after checkout),DenovoPreOrderCompletionJob(a retry would regenerate invoices and resend the won email), and theorder_shipment_trackingcard that adopted the won card once a shipment existed (it must not ask anyone to confirm delivery of a closed order). The shipping-tracking poller keeps tracking outbound shipments of a closed order; that is real freight and is left alone.
B10. Frontend (capExpertApp)
- Action: one item in
modules/transaction/list/transaction-helper.service.tsgenerateMenu, shown whenpermission.hasAdminAccess && (pageType === 'Transactions' || routeParams.fromTracking)and the order type is not Transfer. Handle in both dispatchers:list.component.tsandorder-list-table/order-list-table.component.ts. - Dialog: new
modules/transaction/closure/transaction-closure-dialogonTwDialogService. Three steps as in A3; the "Then what" step offers Simple always and Relaunch / Relist only when the preview says every picked line can be put back. The reason is one required textarea; no reason list. Line picker: one checkbox per equipment line showing request name, quantity, amount, and eligibility; all checked by default; closed lines shown disabled with their badge. No quantity control (per-quantity is out of scope). Preview panel fromGET closure-preview. Return step shows carrier + tracking fields reusingtracking-form. - Badges: closure badge next to the lifecycle status in
list.component.htmlandorder-list-table.component.html; line-level badge inequipment-list-table. FilterClosure: Live / Partial / Cancelled / Returnednext to the existing status filter. Keep the lifecycle dropdown; disable it when the order is fully closed. - Detail page (
modules/transaction/details): closure timeline fromGET closure-events; Undo button on every open event for admins (decided Q17); a confirm step listing what the undo will discard; the 409 reasons when refused. - Relaunch: after a cancel with the Relaunch outcome the dialog shows a link to the new request, which is already launched to the listed vendors.
- Shipments:
shipment-tracking-listshows a Return badge and groups by direction;add-tracking-dialogtakesdirection. - Status pills and filters (B8.5): "Order Canceled" / "Order Returned" pills on the facility and vendor kanban cards and the Denovo bid rows, a filter option on both boards, and the vendor offers list badge, filter, reply-modal banner, and disabled "Cancel Offer"; a separate "Order cancelled" count card. No new board column.
- Enums:
OrderStatusinlist.interface.tsgainsDelivered(BE already has it) and a newClosureStatus;core/utils/enum.tsgains the closure enum; the CoPilot and marketplace status enums come from the regenerated swagger client. - Swagger regen after the BE lands (both roles'
/swagger-json).
B11. Guards and existing bugs fixed in the same change
- Duplicate-purchase guard ignores closure (B7.2).
- Stale offers lock vendors out (
isAlreadySent); sidestepped because Relist creates a new listing row (B7.1). - Won cards regenerate (B8.2).
remove()on CoPilot can hard-delete a completed or closed order (B5).update-marginhas no admin check (B5).order-statusaccepts any string and is open to buyer and seller (B5).removeMarketplaceOffersrace:findAllwithouttransaction, two updates inPromise.all. Not called by this feature; noted so nobody reaches for it.cancelCloseCoPilotpre-order lookup uses the unfiltered id list; not touched by this feature but noted so nobody reuses it for order cancels.cancelCloseCoPilotproposal updates run in aPromise.all(map)that returns nothing, so they are not awaited.po_requests.statusentity enum may not accept theCancelvalue the existing cancel writes; the failure is only logged. Verify before reusing the value.removeMarketplaceOffersdefaultsvoidEnvelope=trueand fires the void un-awaited before the caller commits; the offline marketplace path relies on that default.copilot_pre_order_itemsreset to Planning by the existing cancel reappear in the pre-order lists (they filter on status andtransaction_idonly). This feature leaves them Completed, which keeps them out of those lists.removeMarketplaceOffersrewrites every thread on a listing, so it cannot mark one vendor's offer; this feature writes the winning thread directly.copilot-bids.service.ts:859lists[COMPLETED, CANCELED, CANCELED];CLOSEDwas meant.copilot-requests.entity.tsdeclaresstatusas STRING while the column is the Postgres enumenum_ezrfp_requests_status; any new value needs the ALTER TYPE (B2.7).
B12. Test plan
Unit (*.spec.ts beside each file)
closure-amounts.service: each type's formula, partial fractions, consumables, undo recompute.closure-journal.service: snapshot → restore round trip is byte-identical per handler.closure-eligibility.service: delivered line blocks cancel; equipment state per line (in place, deleted by facility, listed, moved on, in use, not traceable) and whether Relaunch / Relist may be offered; outcomes per type.
Integration (dev DB, read old/primaryApi/.env for psql per the standing permission; writes
need a revert script)
For each of: PA marketplace, offline marketplace, FMV, CoPilot checkout, CoPilot offline, Denovo,
service-contract renewal:
- Create an order through the real path.
- Simple cancel → only the B4 "Simple" rows change (statuses, amounts); every inventory, shipment, pre-order, and contract row is byte-identical; reports exclude it.
- Undo → diff every B4 row against the snapshot: zero differences.
- Partial (one line of a two-line order) → that request Order Canceled, the other untouched,
effective amounts equal the remaining line; cancelling the second line ends the order as
cancelledand the parent request as Order Canceled. - Relist / Relaunch → equipment put back per B7.6 (removed, unlisted, or left with the recorded state), new listing / new request exists and is linked, the vendor can offer again and the buyer can re-buy; undo confirms after an offer or vendor response, refuses after a new order, succeeds silently before.
- Return with tracking →
direction='return', poller picks it, no delivered banner, no reopen of "Confirm delivery". - Reporting period (Pending Q1): a June order cancelled in October stays in June's dashboard figures and leaves October's and the fee figures; the vendor dashboard drops the won deal.
- Relaunch → the new request has no
status,cancellation, orlostEmailSentcopied over, is in New under a new parent withrelaunched_from_parent_idset, keeps the trade-in and budget link, and is already launched to the original vendors. Undo with no vendor response removes it; with a response it confirms and closes it; after checkout it refuses. - Statuses → after a cancel the request shows in the facility Cancelled column with the "Order
Canceled" pill and is filterable as such; the winning vendor's card shows "Order cancelled" with
the order link; losers show Order Canceled and their lost count drops; the listing shows in the facility removed view and the
vendor's offers list as "Order Cancelled" with "Cancel Offer" disabled; the trade-in item and
any service-contract lock are released; no won card is orphaned; a group-wide
updateLaunchStatusrun does not change the winner's launch.
Gates (from capExpertApi/CLAUDE.md): swagger diff per role shows only the new routes and
fields; both roles boot; layering check passes; entity ↔ migration conformance.
B13. Build order
- Migrations (B2, including the three enum ADD VALUE migrations and the two function redefinitions in B2.7) and entities. Code enums. The "add alongside" status sweep (B8.5), which is mechanical and safe to land first. Predicates (B8.1).
- Journal + orchestrator + amounts + the Simple cancel for every type (B7.0): statuses and money only, per line from the start. Undo. Guards 2, 4, 5, 6.
- Reporting call sites (B8.2) and exports.
- Frontend action, dialog with the line picker, badges, filter, detail timeline, swagger regen.
- Relist and Relaunch: eligibility and the equipment state rules (B7.6), the put-back per type, the new listing / server-side clone, lineage column, the confirm-and-discard undo. Guards 1, 7.
- Return shipments:
directioncolumn, DTO, the ~12 outbound filters, FE shipment UI. - Emails and templates.
- Later: Order Adjustments report and per-vendor fee statement (B8.4, admin side first, then vendor-facing), per-quantity closure (asset picker, component rule, shipment quantities, reduced-quantity relaunch), Denovo relaunch, restocking fee kept by the vendor, vendor self-service return reporting, re-ordering a cancelled CoPilot request without a relaunch.
Steps 1 to 3 ship together: a cancel without undo and without report changes would leave the platform worse than today.