Order cancel and return · the task

Order cancellations and returns: the plan

Plan v3, 14 Oct 2026. Nothing is built yet. Part A is what we are building, for everyone; Part B is the technical design for whoever builds it.

Simple cancelStatus and money only. Order, request or listing, all bids or the winning offer, and a sold marketplace item become Order Cancelled. Fee and sales value come off. No CoPilot inventory is touched.
Relaunch CoPilot / List on marketplaceEverything Simple does, then the bought inventory goes back to how it was before the order, then a new request or listing starts. Relaunch goes out to the same vendors automatically.

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

  1. On an order row, an admin picks Cancel or return.
  2. 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").
  3. 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.
  4. 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).
  5. 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

  1. Journal before you touch. Every cancel, return, and undo writes one row to a new transaction_closure_events table 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.ts all set Sold / Completed with no previous-status stamp).
  2. Never do anything irreversible. No DocuSign voids (do not call removeMarketplaceOffers, which voids by default; do not fire cerVoidEvent). No hard deletes. Every removal is a soft delete on a paranoid entity so restore() works.
  3. Original amounts stay; effective amounts are derived and stored. Reports read effective.
  4. One predicate, one expression. Counts use transactionIsLiveSql(alias); sums use effectiveMarginSql(alias) etc., both in shared/sql/canonical-predicates.sql.ts.
  5. 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.
  6. 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.
  7. New statuses, never reused ones. Every record a closure touches gets Order Canceled / Order Returned (A2a). Reusing Canceled, RFP Canceled, removed_from_marketplace, or the offer Canceled would make a cancelled order indistinguishable from a bidding-stage cancel or a pulled listing in every list, count, and vendor view. The cancellation JSON 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 by marketplace_counter_offers.status, .previous_status, and marketplace.counter_offer_status) + 'Order Cancelled', 'Order Returned'. Launch, proposal, parent-request, inventory sell_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_*, all effective_*, history_notes length.
  • Each targeted transaction_equipment_details row: closure_status, closed_quantity, closed_inventory_ids, closure_event_id.
  • CoPilot / Denovo / service contract: copilot_requests id, status, cancellation, additional_details; every copilot_launches and copilot_launch_proposals row of the request in Proposal Accepted or CoPilot Lost, id, status, previous_status, parent_status; copilot_parent_requests id, status; the budget_planning rows pointing at the request.
  • Marketplace / FMV: marketplace rows (parent + children by parent_id) id, status, transaction_id, counter_offer_status, docusign_request_id; the winning marketplace_counter_offers thread id, status, previous_status; seller camp_inventory_items (parent + children) id, sell_status.

Relaunch / Relist adds

  • Seller camp_inventory_items (parent + children by inventory_parent_id): marketplace_id, transaction_id, deleted_at; inventory_inspections for the listing id, status.
  • Buyer or created rows from the line's equipment_details.buyingInventoryIds (unit rows first, then component rows): each row's deleted_at, sell_status, marketplace_id, service_contract_id, warranty_id, quantity, purchase_price, plus their inventory_stickers, inventory_sticker_details, and asset_registries.inventory_id links.
  • warranties and their document_manager rows reached through warranty_id: id, deleted_at.
  • If the equipment is listed now: that listing's rows, threads, carts, and wishlists (B7.6).
  • side_effects records 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 against TransactionOrderStatusEnum, reject when closure_status IN ('cancelled','returned'). It stays lifecycle-only.
  • PUT /transaction/update-margin/:id: add AdminGuard (missing today). On update, recompute effective_margin_amount too.
  • POST /transaction/shipment-tracking: DTO gains optional direction (default outbound).
  • DELETE /copilot/:copilotParentId (copilot-crud.service.ts remove()): 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:

  1. Line columns: closure_status, closed_quantity = quantity, closure_event_id; closed_inventory_ids stays NULL.
  2. Transaction columns and effective amounts (B6); closure_status = partial while any line is open, else cancelled / returned.
  3. The type's status writes:
    • CoPilot, Denovo, service contract: copilot_requests.status = 'Order Canceled' (or 'Order Returned') with the cancellation JSON in the shape cancelCloseCoPilot writes ({reason, cancelled_by, cancelled_at, previous_status:'Completed', type:'order_cancel'|'order_return', notes}). The winning launch and its proposal get previous_status = status then the same new value; the winning launch's parent_status gets the matching copilotParentLaunchesStatus value. 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 stay NO_PARTICIPATION. updateLaunchStatus (group-wide recompute from proposals) must rank the new values ahead of COPILOT_LOST or the next recompute flips the winner's launch to Lost; CoPilotLostVendorEmailJob finds no Lost launches left and also skips requests in a new status. Append to copilot_parent_requests.action_logs; recompute the parent via changeCoPilotParentStatusAfterComplete with a new rule (Order Canceled when every line is, otherwise unchanged). unlinkBudgetOnCoPilotClose so the budget line is free again (journaled). Do not call cancelCloseCoPilot: it skips COMPLETED lines, excludes the winner and losers, and fires cerVoidEvent. Pre-order items, PO requests, and CER rows are left as they are.
    • Vendor marketplace and FMV: marketplace rows (parent + children) status = 'order_cancelled' (or 'order_returned'). transaction_id and docusign_request_id are kept, so the listing still records its order and the signed agreement stays reachable; Relist clears them (B7.1). Not removed_from_marketplace: that is what a pulled listing gets. Only the winning thread (LOGISTICS_REQUIRED / SOLD with the winning vendor_id) gets previous_status = status, status = 'Order Cancelled'; the other threads stay Sold ("Not Selected"). Do not call removeMarketplaceOffers (rewrites every thread, Promise.all race). Then updateMarketplaceCounterOfferStatus so marketplace.counter_offer_status rolls up, with the new values ranked ahead of SOLD in both priority maps. Never write NULL: the facility "My Offers" view hides NULL threads. The seller's inventory (parent + children) gets sell_status = 'order_cancelled' (or 'order_returned') and keeps its marketplace_id; how the ~120 sell_status readers treat the new value is Pending Q30b (B8.5). inventory_inspections untouched. FMV has no threads: listing and seller item only.
  4. 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:

  1. Seller inventory (parent + listed children): sell_status = NULL, marketplace_id = NULL, transaction_id = NULL (what commonDestroyMarketPlace writes for a removed listing). The old listing rows: transaction_id = NULL, docusign_request_id = NULL; status stays order_cancelled. inventory_inspections in Sold → Canceled.
  2. New listing: MarketplaceCrudHelperService.create / handleParentInventories with 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 the isAlreadySent lockout on the old thread. Record newMarketplaceIds in side_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:

  1. The created rows (equipment_details.buyingInventoryIds: unit rows then component rows) per the state rules (B7.6): forceDestroyByIds; warranties reached via warranty_id and their document_manager rows soft-deleted explicitly (no helper does it; a warranty shared by other surviving rows through warrantyGroupMap is kept).
  2. 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 and copilotCrudService.create, in our own service:
    • strip status, id, requestId, groupId, cancellation, and additionalDetails (the create copies it verbatim and it carries lostEmailSent); 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; record clonedCopilotParentId in side_effects;
    • the new request is created and launched in the same call to the original vendor list, winner included, reusing the CaptAIn createAndLaunchOneFacility pattern (copilotCrudService.create then copilotLaunchService.launchCoPilot); children with no matching vendor are dropped by checkVendorMatch, 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.

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 by isCopilotSold, which tests copilot_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_price handled, then the project's own launchCopilot. 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 as putBack:false; undo leaves it deleted.
  • listed: commonDestroyMarketPlace semantics first (the preview shows the open-offer count, decided Q5), then as in_place.
  • moved_on: Relaunch / Relist is not offered for that line; the preview names the later order.
  • in_use: as in_place, with the warning (Pending Q7).
  • not_traceable: CoPilot → not offered; marketplace → offered, the seller item is always known from the line's inventory_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's lines JSON. Timestamps are not used: camp_inventory_items.updated_at is 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 flags hasOrderCanceledStatus, transactionId attached to cancelled vendor cards (today won only), performedAt CASEs. 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 launch parent_status rollup, copilot-crud.service.ts statusRules, the counter-offer priority maps.
  • Counts and metrics: summary canceled count (a separate Order cancelled count, decided Q27), per-parent and account analytics counts, totalLaunch, closeDateColumn, liquidation summary buckets, chatbot status buckets.
  • Labels: ModifiedCopilotStatusEnum and VendorCopilotStatus export labels, chatbot statusLabel, getCounterOfferStatus passthrough.
  • Clone / reopen: the requestFor=clone filter and reOpenCount must accept the new value, since Relaunch clones an order-cancelled request.
  • Frontend: regenerated swagger enums; list.types.ts filter options; kanban.service.ts showStatusPill, STATUS_PILL_VARIANT_TW, the vendor status collapse at 412-416, the Reopen action; CopilotLaunchesStatusForView label; preflight list filter and colours; Denovo bid row flags; marketplace OFFER_REQUEST_LIST, getOfferStatusFilter, the four offer badge maps, the reply-modal banner and showCancelBtn, the listing details-modal @switch and the four showVendor*/showAddToCart gates, 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 rule marketplace_won_offline_cleanup; emit the marketplace upsert anyway so the instant path covers what it can.
  • Emails: backgroundJobService.emailTask('OrderCancelledEmail' | 'OrderReturnedEmail' | 'OrderClosureUndoneEmail', ...); add to the EmailQueue union and emailHandlers in primary/modules/task-queue/task-queue.controller.ts; one mail service in primary/queue/mail/transaction/. Recipients: vendor account users and facility account users on the order, plus admin CC.
  • transactions.history_notes append {status: 'Cancelled'|'Returned'|'Undo', notes, createdAt, createdBy} so the existing notes UI shows it.
  • copilot_parent_requests.action_logs append 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 the order_shipment_tracking card 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.ts generateMenu, shown when permission.hasAdminAccess && (pageType === 'Transactions' || routeParams.fromTracking) and the order type is not Transfer. Handle in both dispatchers: list.component.ts and order-list-table/order-list-table.component.ts.
  • Dialog: new modules/transaction/closure/transaction-closure-dialog on TwDialogService. 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 from GET closure-preview. Return step shows carrier + tracking fields reusing tracking-form.
  • Badges: closure badge next to the lifecycle status in list.component.html and order-list-table.component.html; line-level badge in equipment-list-table. Filter Closure: Live / Partial / Cancelled / Returned next to the existing status filter. Keep the lifecycle dropdown; disable it when the order is fully closed.
  • Detail page (modules/transaction/details): closure timeline from GET 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-list shows a Return badge and groups by direction; add-tracking-dialog takes direction.
  • 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: OrderStatus in list.interface.ts gains Delivered (BE already has it) and a new ClosureStatus; core/utils/enum.ts gains 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

  1. Duplicate-purchase guard ignores closure (B7.2).
  2. Stale offers lock vendors out (isAlreadySent); sidestepped because Relist creates a new listing row (B7.1).
  3. Won cards regenerate (B8.2).
  4. remove() on CoPilot can hard-delete a completed or closed order (B5).
  5. update-margin has no admin check (B5).
  6. order-status accepts any string and is open to buyer and seller (B5).
  7. removeMarketplaceOffers race: findAll without transaction, two updates in Promise.all. Not called by this feature; noted so nobody reaches for it.
  8. cancelCloseCoPilot pre-order lookup uses the unfiltered id list; not touched by this feature but noted so nobody reuses it for order cancels.
  9. cancelCloseCoPilot proposal updates run in a Promise.all(map) that returns nothing, so they are not awaited.
  10. po_requests.status entity enum may not accept the Cancel value the existing cancel writes; the failure is only logged. Verify before reusing the value.
  11. removeMarketplaceOffers defaults voidEnvelope=true and fires the void un-awaited before the caller commits; the offline marketplace path relies on that default.
  12. copilot_pre_order_items reset to Planning by the existing cancel reappear in the pre-order lists (they filter on status and transaction_id only). This feature leaves them Completed, which keeps them out of those lists.
  13. removeMarketplaceOffers rewrites every thread on a listing, so it cannot mark one vendor's offer; this feature writes the winning thread directly.
  14. copilot-bids.service.ts:859 lists [COMPLETED, CANCELED, CANCELED]; CLOSED was meant.
  15. copilot-requests.entity.ts declares status as STRING while the column is the Postgres enum enum_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:

  1. Create an order through the real path.
  2. Simple cancel → only the B4 "Simple" rows change (statuses, amounts); every inventory, shipment, pre-order, and contract row is byte-identical; reports exclude it.
  3. Undo → diff every B4 row against the snapshot: zero differences.
  4. 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 cancelled and the parent request as Order Canceled.
  5. 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.
  6. Return with tracking → direction='return', poller picks it, no delivered banner, no reopen of "Confirm delivery".
  7. 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.
  8. Relaunch → the new request has no status, cancellation, or lostEmailSent copied over, is in New under a new parent with relaunched_from_parent_id set, 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.
  9. 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 updateLaunchStatus run 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

  1. 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).
  2. 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.
  3. Reporting call sites (B8.2) and exports.
  4. Frontend action, dialog with the line picker, badges, filter, detail timeline, swagger regen.
  5. 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.
  6. Return shipments: direction column, DTO, the ~12 outbound filters, FE shipment UI.
  7. Emails and templates.
  8. 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.