submit (purchaseOrder)
submit is the act that starts the procurement workflow. It takes a Draft (or Pending) purchase order and:
- resolves the process it will flow through (explicit link, or auto-resolve via resolveProcessForPO),
- encumbers the budget โ moves
estimatedAmountout ofremainingBudgetand intototalEncumberedon the linkedbudgetLine, - creates the first step's approvals โ one
stepApprovalper unit assigned to step 1, - transitions the PO to
InProgresswithcurrentStep: 1, and pushes asubmittedhistory entry.
After this call the order is live: it shows up in getPendingByUnit for the approvers and waits for their submitDecision. Only Manager/Admin, or UnitHead/Employee with the canRegisterPurchaseOrder feature, can submit.
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 (submit.val.ts)โ
Remarkably small for what it does โ just the PO _id and a depth-1 projection.
import { object, objectIdValidation } from "lesan";
import { selectStruct } from "../../../mod.ts";
import { activeRoleMixin } from "@lib";
export const submitValidator = () => {
return object({
set: object({
...activeRoleMixin,
_id: objectIdValidation,
}),
get: selectStruct("purchaseOrder", 1),
});
};
The implementation (submit.fn.ts)โ
The fn performs these steps in order:
- Loads the PO with a focused projection (
_id,status,currentStep,estimatedAmount,title, and the_ids oforganization,requestingUnit,product,process,budgetLine). Throwspurchase order not foundif it doesn't exist. - Checks the status โ only
DraftandPendingmay be submitted; anything else throwsonly draft orders can be submitted. - Resolves the process โ uses the PO's embedded
process._idif present; otherwise callsresolveProcessForPOusing the embedded organization/unit/product snapshots. If still nothing โno active process found for this purchase order. - Verifies the process is active โ loads the process and requires
status === "Active", elseprocess is not active. - Loads the process steps โ queries
processStepfor the process sorted byorder: 1. Empty โprocess has no steps. - Creates the first step's approvals โ takes the first step's
assigneeGroups, flattens allunitIds, deduplicates, and inserts onestepApprovalwithstatus: "pending"per unique unit, linked to the PO, the step, and the unit (each with back-references). - Builds the
submittedhistory entry with the current user anddetails: { processId, stepOrder: 1 }. - Prepares the updates โ
status: "InProgress",currentStep: 1. - Back-fills the process relation โ if the PO had no
processlink yet, it callspurchaseOrder.addRelationwithreplace: trueto attach the resolved process. - Encumbers the budget โ if a
budgetLineis linked, it$incstotalEncumbered: +estimatedAmountandremainingBudget: -estimatedAmount(see the math box below). - Commits โ
findOneAndUpdatesets status/currentStep and$pushes the history entry, returning thegetprojection.
import { type ActFn, ObjectId } from "lesan";
import { budgetLine, coreApp, process, processStep, purchaseOrder, stepApproval } from "../../../mod.ts";
import { resolveProcessForPO, throwError } from "@lib";
import type { MyContext } from "@lib";
export const submitFn: ActFn = async (body) => {
const {
set: { _id },
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,
currentStep: 1,
estimatedAmount: 1,
title: 1,
"organization._id": 1,
"requestingUnit._id": 1,
"product._id": 1,
"process._id": 1,
"budgetLine._id": 1,
},
});
!po && throwError("purchase order not found");
if (po!.status !== "Draft" && po!.status !== "Pending") {
throwError("only draft orders can be submitted");
}
let processId = (po as any)?.process?._id as string | undefined;
if (!processId && (po as any)?.organization?._id) {
processId = await resolveProcessForPO({
organizationId: (po as any).organization._id.toString(),
...((po as any)?.requestingUnit?._id && {
requestingUnitId: (po as any).requestingUnit._id.toString(),
}),
productId: (po as any).product._id.toString(),
});
}
!processId && throwError("no active process found for this purchase order");
const processDoc = await process.findOne({
filters: { _id: new ObjectId(processId) },
projection: { _id: 1, status: 1 },
});
!processDoc || processDoc!.status !== "Active" &&
throwError("process is not active");
const steps = await processStep
.aggregation({
pipeline: [
{ $match: { "process._id": new ObjectId(processId as string) } },
{ $sort: { order: 1 } },
],
projection: {
_id: 1,
name: 1,
order: 1,
stepType: 1,
groupsOperator: 1,
assigneeGroups: 1,
},
})
.toArray();
if (!steps || steps.length === 0) {
throwError("process has no steps");
}
const firstStep = steps[0];
const firstStepUnits = (firstStep!.assigneeGroups as {
unitIds: string[];
}[]).flatMap((g) => g.unitIds);
const uniqueUnitIds = [...new Set(firstStepUnits.map((u) => u.toString()))];
for (const unitId of uniqueUnitIds) {
await stepApproval.insertOne({
doc: { status: "pending" },
relations: {
purchaseOrder: {
_ids: poId,
relatedRelations: { stepApprovals: true },
},
processStep: {
_ids: new ObjectId(firstStep!._id as string),
relatedRelations: { approvals: true },
},
unit: {
_ids: new ObjectId(unitId),
relatedRelations: { stepApprovals: true },
},
},
projection: { _id: 1 },
});
}
const performerName = `${(user as any).first_name ?? ""} ${(user as any).last_name ?? ""}`.trim();
const performedBy = user._id.toString();
const historyEntry = {
action: "submitted",
performed: {
by: performedBy,
name: performerName,
at: new Date(),
role: {
id: "",
name: "",
},
},
details: { processId, stepOrder: 1 },
};
const updates: Record<string, unknown> = {
status: "InProgress",
currentStep: 1,
};
if (!(po as any).process?._id) {
await purchaseOrder.addRelation({
filters: { _id: poId },
relations: {
process: {
_ids: new ObjectId(processId as string),
relatedRelations: { purchaseOrders: true },
},
},
projection: { _id: 1 },
replace: true,
});
}
if ((po as any)?.budgetLine?._id) {
const budgetLineId = (po as any).budgetLine._id;
await budgetLine.findOneAndUpdate({
filter: { _id: budgetLineId },
update: {
$inc: {
totalEncumbered: po!.estimatedAmount || 0,
remainingBudget: -(po!.estimatedAmount || 0),
},
},
projection: { _id: 1 },
});
}
return await purchaseOrder.findOneAndUpdate({
filter: { _id: poId },
update: {
$set: updates,
$push: { history: historyEntry },
},
projection: get,
});
};
Why the budget math worksโ
The budgetLine tracks three numbers that must always sum correctly:
totalAllocated = totalSpent + totalEncumbered + remainingBudget
- Submit (this page):
totalEncumbered += amountandremainingBudget -= amount. The money is reserved but not yet spent โ it can't be used for another order while the PO is in flight. - Finalize (po-finalize):
totalEncumbered -= amountandtotalSpent += amount. The reservation converts into actual spend. - Cancel (po-cancel):
totalEncumbered -= amountandremainingBudget += amount. The reservation is released back to the pool.
So after submit, remainingBudget drops and totalEncumbered rises โ the seed test checks exactly that (totalEncumbered == 25000000 after submitting a 25M order). Since estimatedAmount defaults to 0, a PO without an amount encumbers nothing (the || 0 guard).
Why firstStepUnits dedupesโ
A step's assigneeGroups can repeat a unit across groups ({operator: "OR", unitIds: ["unitA"]}, {operator: "AND", unitIds: ["unitA"]}). Creating two stepApproval docs for the same unit would double their vote. [...new Set(...)] guarantees exactly one pending approval per unit โ which is also what evaluateStepStatus expects when it looks up one approval per unitId.
In the workflowโ
submit is the hinge of the whole chapter โ it's where a passive Draft becomes a live workflow:
add โโถ Draft โโsubmitโโโถ InProgress (currentStep: 1, budget encumbered)
โ
submitDecision (per unit) โโโถ advance to step 2...
โ
Approved โโfinalizeโโโถ Completed
One approval task is created per unit of step 1; each of those is decided via submitDecision, which uses evaluateStepStatus to decide whether to advance, approve, or reject the whole PO.
Links: overview, purchaseOrder model, process model, processStep model, stepApproval model, budgetLine model, resolveProcess, po-add, submit-decision.
Run itโ
curl -X POST http://localhost:1380/lesan \
-H "Content-Type: application/json" \
-H "token: $TOKEN" \
-d '{
"model": "purchaseOrder",
"act": "submit",
"details": {
"set": {
"activeRoleId": "ghost-role",
"_id": "<poId>"
},
"get": {
"_id": 1,
"title": 1,
"status": 1,
"currentStep": 1,
"history": 1
}
}
}'
Expect { "body": { "status": "InProgress", "currentStep": 1, "history": [..., { "action": "submitted", ... }] }, "success": true }. Then check the budget with the budgetLine act getBudgetLineBreakdown to see totalEncumbered bumped and remainingBudget dropped, and call getStepApprovals to see the pending approvals.
Errors & fixesโ
| Error | Meaning | Fix |
|---|---|---|
purchase order not found | No PO with that _id | Verify the id via gets |
only draft orders can be submitted | PO status isn't Draft/Pending | You can't re-submit; the order is already in flight or terminal |
no active process found for this purchase order | No process link and resolveProcessForPO found nothing | Create + activate a process, or attach one via po-update-relations |
process is not active | The linked process isn't Active | Run activateProcess |
process has no steps | The process has zero processStep docs | Add steps with addProcessStep |
No active process found for this organization. Please create and activate a process first. | Raised by resolveProcessForPO | Same as no active process... above |
Shared auth errors apply, and Missing feature: canRegisterPurchaseOrder appears for UnitHead/Employee without the feature.