cancel (purchaseOrder)
cancel aborts a purchase order that hasn't finished. It's allowed from Draft, Pending, and InProgress โ i.e. any state before the order is Approved/Rejected/terminal. On cancel it releases the budget reservation (totalEncumbered -= amount, remainingBudget += amount), sets status: "Cancelled", stamps completedAt, and pushes a cancelled history entry with an optional reason. Only Manager/Admin, or UnitHead with the canRegisterPurchaseOrder feature, can cancel.
Package import
The tutorial source imports the framework as "lesan" โ in this repo that alias maps to the local framework source (deno.json โ ../../src/mod.ts). In your own app import from @hemedani/lesan (npm/Bun) or jsr:@hemedani/lesan (Deno). @lib and @model are the tutorial's aliases for utils/ and models/.
The validator (cancel.val.ts)โ
The PO _id plus an optional reason string. Everything else is derived from the stored document.
import { object, objectIdValidation, optional, string } from "lesan";
import { selectStruct } from "../../../mod.ts";
import { activeRoleMixin } from "@lib";
export const cancelValidator = () => {
return object({
set: object({
...activeRoleMixin,
_id: objectIdValidation,
reason: optional(string()),
}),
get: selectStruct("purchaseOrder", 1),
});
};
The implementation (cancel.fn.ts)โ
- Loads the PO (
_id,status,estimatedAmount,budgetLine._id). Missing โpurchase order not found. - Status guard: the cancellable set is
["Draft", "Pending", "InProgress"]. Anything else โthis purchase order cannot be cancelled. - Releases the encumbrance โ if a
budgetLineis linked,$incstotalEncumbered: -amountandremainingBudget: +amount. - Pushes a
cancelledhistory entry, addingdetails: { reason }only when areasonwas provided. - Sets
status: "Cancelled"andcompletedAt: new Date(), returning thegetprojection.
import { type ActFn, ObjectId } from "lesan";
import { budgetLine, coreApp, purchaseOrder } from "../../../mod.ts";
import { throwError } from "@lib";
import type { MyContext } from "@lib";
export const cancelFn: ActFn = async (body) => {
const {
set: { _id, reason },
get,
} = body.details;
const { user }: MyContext = coreApp.contextFns.getContextModel() as MyContext;
const poId = new ObjectId(_id as string);
const po = await purchaseOrder.findOne({
filters: { _id: poId },
projection: {
_id: 1,
status: 1,
estimatedAmount: 1,
"budgetLine._id": 1,
},
});
!po && throwError("purchase order not found");
const cancellable = ["Draft", "Pending", "InProgress"];
if (!cancellable.includes(po!.status)) {
throwError("this purchase order cannot be cancelled");
}
if ((po as any)?.budgetLine?._id) {
await budgetLine.findOneAndUpdate({
filter: { _id: (po as any).budgetLine._id },
update: {
$inc: {
totalEncumbered: -(po!.estimatedAmount || 0),
remainingBudget: po!.estimatedAmount || 0,
},
},
projection: { _id: 1 },
});
}
const performerName = `${(user as any).first_name ?? ""} ${(user as any).last_name ?? ""}`.trim();
return await purchaseOrder.findOneAndUpdate({
filter: { _id: poId },
update: {
$set: {
status: "Cancelled",
completedAt: new Date(),
},
$push: {
history: {
action: "cancelled",
performed: {
by: user._id.toString(),
name: performerName,
at: new Date(),
role: { id: "", name: "" },
},
...(reason && { details: { reason } }),
},
},
},
projection: get,
});
};
Why cancel restores the budgetโ
Cancel is the mirror image of submit:
- Submit:
totalEncumbered += amount,remainingBudget -= amount(money reserved). - Cancel:
totalEncumbered -= amount,remainingBudget += amount(money released back to the pool).
Unlike finalize โ which converts the reservation into permanent spend โ cancel puts the money back where it can be used by a future order. In the seed test, after cancelling a 5M order, remainingBudget climbs back and totalEncumbered returns to 0.
In the workflowโ
Cancel is the graceful exit for anything not yet finalized:
Draft / Pending / InProgress โโcancelโโโถ Cancelled
Because the guard allows InProgress, an order can be cancelled while approvals are still pending โ the outstanding stepApproval docs remain in the DB, but the PO status is terminal so submitDecision will refuse them with the purchase order is not in progress. Once a PO is Approved, cancelling is no longer allowed (the step approvals are done); it must go through finalize instead.
Links: overview, purchaseOrder model, budgetLine model, po-submit, po-finalize.
Run itโ
curl -X POST http://localhost:1380/lesan \
-H "Content-Type: application/json" \
-H "token: $TOKEN" \
-d '{
"model": "purchaseOrder",
"act": "cancel",
"details": {
"set": {
"activeRoleId": "ghost-role",
"_id": "<poId>",
"reason": "Budget freeze"
},
"get": {
"_id": 1,
"status": 1
}
}
}'
Expect { "body": { "status": "Cancelled" }, "success": true }. Omit reason to skip the details on the history entry.
Errors & fixesโ
| Error | Meaning | Fix |
|---|---|---|
purchase order not found | No PO with that _id | Verify the id via gets |
this purchase order cannot be cancelled | PO status is Approved/Rejected/Completed/Cancelled | Only Draft, Pending, InProgress can be cancelled; an Approved PO must be finalized instead |
Shared auth errors apply, and Missing feature: canRegisterPurchaseOrder appears for a UnitHead without the feature. A PO without a budgetLine is still cancelled fine โ the release step is skipped.