Purchases — procurement documents, goods receipt & stock IN
The whole purchase side reduces to: a
PurchaseInvoiceis anOrder(kind=PurchaseInvoice)that, on checkout, writes oneStock(type=IN)row per inventory line and posts the GL legsDEBIT Inventory / CREDIT Accounts-Payable(plus tax). Purchase Requisitions and Purchase Orders are planning documents in the sameOrdercollection that move no stock and post no GL until they are drawn down into an invoice. This doc also describes the shared Order engine once — sales.md references it rather than repeating it.
Source: BE src/modules/inventory/order (shared engine), inventory/purchase, inventory/goods-receipt · Admin src/modules/inventory/order, inventory/purchase, inventory/returns
Related: stock ledger · domain overview · BE reference · sales
1. Purpose & scope
Covers the purchase half of the inventory movement engine:
- Procurement lifecycle documents — Purchase Requisition (PR), Purchase Order (PO), Purchase Invoice (PIV), Purchase Return (PRT) — all rows in the single polymorphic
orders/order_itemscollections, discriminated bykind. - Stock IN posting — only
PurchaseInvoicelines writeStock(type=IN). - GL side effects — Accounts-Payable (the "bill" leg), Inventory asset, tax (input VAT / withholding) entries.
- Goods Receipt (GRN) — an operational-only receipt document against a posted PO (no stock/GL in the current safe slice).
- Progressive invoicing — drawing partial invoices from a posted PO.
- Returns —
PurchaseReturnreversing stock + GL.
The shared Order engine (§2–§5) is documented here and reused by sales. What this module does not do: catalog/item definition (item.md), UOM/price-level definition (pricing.md, categories-uom.md), inter-branch transfer (stock.md), and the actual stock-ledger aggregation math (stock.md).
2. The shared Order engine
Purchase and Sales are not separate document shapes — they share one Order header schema and one OrderItem line schema, discriminated by a kind enum. (Transfer is the exception — it has its own collections; see stock.md.)
2.1 Order (collection orders) — order/order.schema.ts
@ApSchema({ discriminatorKey: "kind", collection: "orders", timestamps: false }). Order extends OrderEntity extends BaseSchema. Soft-deleted via mongoose-delete.
| Field | Type | Req | Description |
|---|---|---|---|
ref |
string | ✅ | Generated document number (prefix per kind — see §2.3). |
kind |
OrderKindTypes |
✅ | Discriminator. PR/PO/PIV/PRT/SQ/SO/SIV/SRT. |
status |
OrderStatusTypes |
SAVED (default) | POSTED. |
|
parentId |
ObjectId | Source order this was drawn/copied/returned from. | |
sourceInvoiceId / sourceOrderId |
ObjectId | Lineage refs (returns / progressive invoicing). | |
userId |
ObjectId | Counterparty mirror — set from customerId/supplierId in a pre("save") hook. |
|
customerId |
ObjectId | Set for sales kinds. Purchase party is also stored on customerId in some paths (GRN reads po.customerId as supplier; see §6.3). |
|
supplierId |
ObjectId | Set for purchase kinds. | |
branchId |
ObjectId (BaseSchema) | Warehouse/store — flows down to every Stock row. |
|
orderDate |
number (unix) | Document date; copied to documentDate in pre("save"). |
|
paymentType |
OrderPaymentType |
CASH | CREDIT. |
|
paymentMethod |
OrderPaymentMethodTypes |
CASH (default) | BANK | STOCK. |
|
totalAmount / totalAmountPaid |
number | Denormalized; recomputed (see §4 totals). | |
cashAmount / bankAmount |
number | Split tender. | |
stockPaymentAmount / stockPaymentItemId |
number/string | Barter / pay-in-stock (gold heritage). | |
discountValueType |
OrderDiscountValueType |
AMOUNT | PERCENTAGE. |
|
discountValue / discountAmount |
number | Header-level discount. | |
paymentStatus |
OrderPaymentStatusTypes |
PENDING (default) | PAID. |
|
invoicingStatus |
OrderInvoicingStatusTypes |
Draw-down rollup on parent PO/SO. | |
fulfillmentStatus |
OrderFulfillmentStatus |
C1 operational only — no stock/GL. Default NONE. |
|
receiptStatus |
OrderReceiptStatus |
C2 GRN rollup — operational only. Default NOT_RECEIVED. |
|
returnStatus |
OrderReturnStatusTypes |
NOT_RETURNED (default) / PARTIALLY_RETURNED / FULLY_RETURNED. |
|
returnedAmount / returnedQuantity |
number | Return rollup. | |
returnEntryId |
ObjectId | Last return doc raised against this invoice. | |
receiptIds |
ObjectId[] | Uploaded receipt files. | |
exchangeRate |
number | Default 1; multi-currency. | |
workflowId |
ObjectId | Approval workflow binding. | |
completedAt/completedBy, confirmedAt/confirmedBy |
Lifecycle stamps. | ||
fixed |
boolean | true ⇒ price/cost is frozen on the line (skip price-level/avg-cost resolution). |
|
items |
OrderItem[] (virtual) |
Child lines (lookup on order_items.orderId). |
Indexes: {branchId,documentDate}, {customerId,documentDate}, {supplierId,documentDate}, {status,documentDate}, {companyId}.
PurchaseInvoice (purchase/purchase.schema.ts) is just OrderEntity + required supplierId + required orderDate + items: PurchaseInvoiceItem[]. No discriminator subclass of its own — it persists into the same orders collection.
2.2 OrderItem (collection order_items) — order/item/item.schema.ts
@ApSchema({ discriminatorKey: "kind", collection: "order_items", timestamps: true }). Soft-deleted.
| Field | Type | Description |
|---|---|---|
ref |
string (unique) | Line number. |
orderId |
ObjectId ✅ | Back-ref to parent Order. |
kind |
OrderItemKindTypes ✅ |
Mirrors parent (PurchaseInvoiceItem, …) via OrderKindTypeToItemKindType. |
lineType |
OrderLineType |
ITEM (default — moves stock) | CATEGORY (GL charge line) | DESCRIPTION (text/optional GL). |
itemId |
ObjectId | The product (ITEM lines only). |
stockId |
ObjectId | FK to the Stock row this line generated (set after stock written). |
description |
string | For CATEGORY/DESCRIPTION lines. |
accountId |
ObjectId | GL account for CATEGORY (required) / mapped DESCRIPTION lines. |
grossQuantity / netQuantity / wasteQuantity |
number | In base UOM. netQuantity is what becomes Stock.netQuantity. waste = gross − net. |
uomId / uomQuantity / conversionFactor |
Multi-UOM: line entered in e.g. CTN; netQuantity = uomQuantity × conversionFactor. |
|
priceLevelId |
ObjectId | Which price level applied (audit). |
rate |
number | Unit price (per base unit, after re-derivation). |
cost |
number | Unit/total cost (purchase: rate; sales: COGS). |
amount |
number | Line total. |
margin |
number | amount − cost when fixed. |
makingCharge |
number | Gold heritage. |
exchangeRate |
number | Per-line FX snapshot (default 1). |
taxId / taxAmount / taxInclusive |
Legacy single-tax mirror of the primary tax. | |
taxes |
OrderLineTax[] |
Multi-tax breakdown: {taxId, name, percentage, direction (ADDITIVE|DEDUCTIVE), amount, accountId}. |
costCenterId / classId / analysisCodeId |
ObjectId | Reporting dimensions. |
returnedQuantity |
number | Rollup of returned qty against this line. |
sourceItemId / sourceInvoiceId |
ObjectId | Lineage (returns / progressive). |
pickedQuantity/packedQuantity/fulfilledQuantity |
number | C1 fulfillment (operational only). |
grnReceivedQuantity |
number | C2 GRN rollup (operational only). |
fixed |
boolean | Freeze price/cost on this line. |
2.3 Enums
// order/order.constants.ts
export enum OrderKindTypes {
PurchaseRequisition = "PurchaseRequisition", // PR — planning, no stock/GL
PurchaseOrder = "PurchaseOrder", // PO — planning, no stock/GL
PurchaseInvoice = "PurchaseInvoice", // PIV — ✅ Stock IN + GL
PurchaseReturn = "PurchaseReturn", // PRT — reverses stock + GL
SalesQuotation = "SalesQuotation", // SQ — planning
SalesOrder = "SalesOrder", // SO — planning
SalesInvoice = "SalesInvoice", // SIV — ✅ Stock OUT + GL
SalesReturn = "SalesReturn" // SRT
}
export enum OrderItemKindTypes { PurchaseRequisitionItem, PurchaseOrderItem, PurchaseInvoiceItem, PurchaseReturnItem, SalesQuotationItem, SalesOrderItem, SalesInvoiceItem, SalesReturnItem } // (string values mirror names)
export enum OrderLineType { ITEM = "ITEM", CATEGORY = "CATEGORY", DESCRIPTION = "DESCRIPTION" }
// document-number prefix (getOrderIdPrefix): PR, PO, PIV, PRT, SQ, SO, SIV, SRT
export const OrderKindTypeToItemKindType = { /* PurchaseInvoice → PurchaseInvoiceItem, etc. */ };// inventory.constant.ts
export enum OrderStatusTypes { SAVED = "SAVED", POSTED = "POSTED" }
export enum OrderPaymentStatusTypes { PENDING, PAID }
export enum OrderPaymentType { CASH, CREDIT }
export enum OrderPaymentMethodTypes { CASH, BANK, STOCK }
export enum OrderDiscountValueType { AMOUNT, PERCENTAGE }
export enum OrderInvoicingStatusTypes { NOT_INVOICED, PARTIALLY_INVOICED, FULLY_INVOICED }
export enum OrderReturnStatusTypes { NOT_RETURNED, PARTIALLY_RETURNED, FULLY_RETURNED }
export enum OrderReceiptStatus { NOT_RECEIVED, PARTIALLY_RECEIVED, FULLY_RECEIVED } // GRN rollup
export enum GoodsReceiptStatus { RECEIVED, CANCELLED }
export enum OrderFulfillmentStatus { NONE, RESERVED, PICKING, PICKED, PACKED, SHIPPED, DELIVERED, CANCELLED } // C12.4 The dispatch — OrderService.create() / .update()
OrderService extends OrderBaseService extends AbstractBaseService<Order>. The single entry point routes by kind:
// order/order.service.ts → create()
if (data.kind === OrderKindTypes.PurchaseInvoice) return this.purchaseSvc.checkout(data);
if (data.kind === OrderKindTypes.SalesInvoice) return this.salesSvc.checkout(data);
// every other kind (PR/PO/PRT/SQ/SO/SRT): persist Order + OrderItems only, NO stock, NO GL
return this.withRetryTransaction("create_order", async () => {
const order = await super.create(data);
if (data.items?.length) await this.orderItemsSvc.createMany(
data.items.map(i => ({ ...i, orderId: order._id, kind: OrderKindTypeToItemKindType[order.kind] })));
return order;
});After a non-invoice create, maybeRequireApproval() (approval-threshold) and optional maybeAutoSubmitDefaultWorkflow() run — neither blocks creation (errors swallowed).
OrderService.create(data)
│
┌──────────────────────┼──────────────────────┐
kind==PurchaseInvoice kind==SalesInvoice else (PR/PO/SQ/SO/PRT/SRT)
│ │ │
PurchaseService SalesService super.create + orderItemsSvc.createMany
.checkout() .checkout() (rows only — no Stock, no GL)
▼ ▼
PurchaseItemSvc SalesItemSvc
.addInvoiceItem .addInvoiceItem
▼ ▼
Stock(IN) + GL Stock(OUT) + GL
└──────────┬───────────┘
▼
StockService.create() ← single shared stock-ledger writer
Takeaways for a port: one stock writer; purchase/sales reuse Order/OrderItem and only add flow-specific item handling; everything commits inside a single retry transaction (withRetryTransaction) so order + items + stock + GL legs are atomic.
3. API surface (shared + purchase)
All resolvers are @ApGqlAuthorize() (JWT + access-group RBAC) and carry @AuditMeta(...). See permissions, audit-trail.
Shared Order resolver — order/order.resolver.ts
| Operation | Type | Input | Returns | Notes |
|---|---|---|---|---|
createOrder |
mutation | CreateOrderInput |
Order |
Routes by kind (§2.4). autoSubmitWorkflow? is transient. |
updateOrder |
mutation | id, UpdateOrderInput |
Order |
|
updateOrderStatus |
mutation | id, OrderStatusInput |
Boolean | |
deleteOrder / deleteManyOrder |
mutation | id / {orderIds} |
Boolean | Reverses stock + GL (§5). |
postInvoice / postManyOrder / saveManyOrder |
mutation | id / {orderIds} |
Boolean | SAVED⇄POSTED. |
copyOrder |
mutation | CopyOrderInput {orderId, kind} |
String (new id) | Clone to another kind (e.g. PO→PIV). |
returnOrder |
mutation | ReturnOrderInput |
Order |
§5.3. |
createProgressiveInvoice |
mutation | CreateProgressiveInvoiceInput |
String | Partial draw-down (§6.2). |
getRemainingQuantities |
query | orderId | [RemainingQuantityItem] |
Ordered − invoiced per line. |
getOrderInvoices |
query | orderId | [Order] |
Child invoices of a PO/SO. |
eligibleInvoiceOrders |
query | EligibleInvoiceOrdersInput |
OrderPageResult |
Posted, not-fully-invoiced POs/SOs. |
orderPage / findOrder / findOneOrder / findInvoices |
query | page/query | Orders | |
orderInvoice |
query | id | OrderInvoice |
Print model. |
runOrderWorkflow |
mutation | RunOrderWorkflowInput |
Order |
|
importInvoice / confirmInvoiceImport |
mutation | file / ConfirmInvoiceImportInput |
preview / summary | Two-step XLSX import (§6.4). |
onOrderEvent |
subscription | — | OrderEvent |
Redis pub/sub on NEW_ORDER. |
orderSummary |
query | OrderQueryInput |
OrderSummary2 |
thisWeek/lastWeek/thisMonth totals. |
Order resolve-fields: paymentBalance, paymentStatus, canAddPayment/canUpdate/canDelete/canPost/canCopy/canAddItem/canReturn (guards from OrderBaseService), customer, currency, items, payments, receipts, totalAmount, totalCost, totalMargin, orderDate, workFlow.
Purchase resolver — purchase/purchase.resolver.ts
| Operation | Type | Input | Returns |
|---|---|---|---|
purchaseInvoicePage |
query | PurchaseInvoicePageInput |
PurchaseInvoicePageResult |
findPurchaseInvoice / findOnePurchaseInvoice |
query | PurchaseInvoiceQueryInput |
PurchaseInvoice(s) |
purchaseInvoiceSummary |
query | PurchaseInvoiceQueryInput |
PurchaseInvoiceSummary |
updatePurchaseInvoice |
mutation | id, UpdatePurchaseInvoiceInput |
PurchaseInvoice |
updatePurchaseInvoiceStatus |
mutation | id, status | Boolean |
uploadPurchaseReceipt |
mutation | OrderReceiptInput |
[FileUpload] |
Purchase resolve-field totalAmount = getAmountWithTax(order) − discountAmount. (Purchase creation goes through the shared createOrder → purchaseSvc.checkout, not a dedicated createPurchaseInvoice mutation. The sales resolver's checkoutSalesInvoice also routes a PurchaseInvoice kind to purchaseSvc.checkout — see sales.md §3.)
Goods Receipt resolver — goods-receipt/goods-receipt.resolver.ts
| Operation | Type | Input | Returns |
|---|---|---|---|
createGoodsReceipt |
mutation | CreateGoodsReceiptInput {poId, deliveryRef?, note?, receivedDate?, items[{poItemId, receivedQuantity}]} |
GoodsReceipt |
updateGoodsReceipt |
mutation | _id, {deliveryRef?, note?} |
GoodsReceipt |
cancelGoodsReceipt |
mutation | _id | GoodsReceipt |
findOneGoodsReceipt / goodsReceiptPage |
query | query / page | GoodsReceipt(s) |
purchaseOrderReceiptSummary |
query | poId | PurchaseOrderReceiptSummary |
4. Business rules & calculations
4.1 Purchase checkout (Stock IN + GL) — purchase/purchase.service.ts → checkOutPurchase()
Runs inside withRetryTransaction("create_purchase_order"):
orderSvc.fixPaymentAmount(model)computes headertotalAmount(see §4.4) andbalanceAmount.purchaseRepo.create(order)persists theOrder(kind=PurchaseInvoice)header.- For each line →
purchaseItemSvc.addInvoiceItem(...)(UOM-convert, writeOrderItem, writeStock(IN), write inventory GL leg, write tax legs — §4.2/§4.3). orderTransactionSvc.addBill(order, relationId)posts the AP "bill" leg + tax legs (§4.3).- If
paymentType === CASH→orderTransactionSvc.updateOrderPayment()posts the cash/bank payment legs. - Status set: if
CASH+status=POSTED→{status: POSTED, paymentStatus: PAID}, else just{status}. accountSvc.validateBalanced(ref)asserts the ledger nets to zero.- If
POSTED→orderSvc.postInvoice(id)flips all transactions toPOSTEDstatus. - Emits
NEW_ORDER.
checkout() wraps checkOutPurchase, defaulting per line cost = rate, grossQuantity = netQuantity + wasteQuantity. If model._id exists it routes to update() instead.
4.2 Per-line stock IN — purchase/item/item.service.ts → addInvoiceItem() → addItemStock()
// addItemStock — Stock IN
const newAvg = await orderSvc.getPurchaseAvg({ grossQuantity, netQuantity, itemId, rate, documentDate, orderId });
stock = await stockSvc.create({
...model, branchId: model.branchId || order.branchId,
avgCost: newAvg, orderItemId: model._id, soldBy: prodItm.soldBy,
kind: StockKindTypes.PurchaseInvoice, type: StockTypes.IN // ← INBOUND, +qty
});
// costing: AVERAGE → item.cost = newAvg; FIFO → fifoSvc.postReceipt(item, netQty, rate, ref, branch, stockId, orderId)- Multi-UOM: UOM fallback
model.uomId || item.purchaseUomId || item.baseUomId || item.uomId. If not base UOM,uomConversionSvc.toBaseQuantity()converts;netQuantitybecomes base units,grossQuantity = displayGross × conversionFactor. See categories-uom.md. model.rate = model.amount / model.netQuantity(per-base-unit rate re-derived from amount).wasteQuantity = grossQuantity − netQuantity.- Costing methods (
item.costingMethod, default STANDARD): AVERAGE writes the new weighted-average back toitem.cost; FIFO pushes a cost layer viaCostingService.postReceipt. See pricing.md §4. - Average cost (
OrderBaseService.getPurchaseAvg/PurchaseInvoiceService.getAvg):newAvg = (availableStock>1 && prevQty>1) ? (rate×netQty + prevAmount) / (netQty + prevQty) : rate. Persisted as anItemPricehistory row when it changes.
A non-inventory item is rejected on purchase: updateItemInventoryTransaction throws "Item is not configured as an inventory item. Purchase orders require inventory items only." if the item has no inventoryAccountId. (Non-ITEM lines — CATEGORY/DESCRIPTION — bypass stock and post a single GL charge leg instead.)
4.3 GL legs (double-entry)
A posted Purchase Invoice produces, per the AccountTransaction ledger (finance/transaction):
| Leg | Account | Type | Amount | Written by |
|---|---|---|---|---|
| Inventory (asset) | item inventoryAccountId |
DEBIT | line net base amount (ex-tax) | purchaseItemSvc.updateItemInventoryTransaction (per ITEM line) |
| Accounts Payable (the "bill") | supplier user-account | CREDIT | getAmountWithTax(order) |
orderTransactionSvc.addBill → addCashBilling |
| Input tax | tax accountId |
DEBIT (additive) / flipped for WHT/deductive | leg amount |
orderTransactionSvc.updateOrderTax (TaxEntry) |
| Cash/Bank payment (if CASH) | cash/bank account | DEBIT/CREDIT mirror | tendered amount | orderTransactionSvc.updateOrderPayment |
| CATEGORY/DESCRIPTION line | line accountId |
DEBIT | line net base | purchaseItemSvc.updateNonItemTransaction |
Mnemonic for an inventory PIV: DR Inventory, DR Input-Tax, CR Accounts-Payable. The bill leg uses getUserOrderTransactionType to pick direction by account category (CREDITOR ⇒ INCREASE on purchase). Tax direction: purchase base type is DEBIT; deductive (withholding) flips to CREDIT (buildTaxEntry). Idempotency guards mean re-posting/editing updates the existing bill/tax legs in place rather than duplicating.
4.4 Totals & tax math
- Line tax (
finance/taxation/taxation.model.ts → computeLineTaxes): additive rate = Σ additive percentages. Ifinclusive,baseAmount = amount / (1 + additiveRate/100), elsebaseAmount = amount. Each taxamt = baseAmount × pct/100;lineTotal = inclusive ? amount − totalDeductive : baseAmount + totalAdditive − totalDeductive. lineNetBase(amount, inclusive, taxes, legacyTaxAmount)= the ex-additive-tax base used for inventory/revenue GL legs:inclusive ? amount − additiveTax : amount.- Order total with tax (
OrderBaseService.getAmountWithTax): for each billable line, start atamount; for each tax — DEDUCTIVE always subtracts; ADDITIVE adds only when not inclusive. (isBillableLine= not a DESCRIPTION line without an account.) - Header
totalAmount=getAmountWithTax(order) − discountAmount(purchase/sales resolvertotalAmount). - Margin (
getMargin) = Σ(amount − cost)over lines withcost > 0. - Payment validation (
validatePaymentAmount):cashAmount + bankAmount + discountAmountmust equalgetAmountWithTax(order)or it throws "not equal".
4.5 Status / state machine
(PR / PO planning docs)
createOrder ─▶ SAVED ──postInvoice──▶ POSTED
▲ │ updateOrder │
│ └──saveOrder───────────┘ (POSTED → SAVED demotion via saveOrder/saveManyOrder)
│
PIV: createOrder ─▶ checkout writes Stock IN + GL; status SAVED or POSTED in one step
PIV POSTED ──returnOrder──▶ PurchaseReturn doc (reverses stock + GL); source.returnStatus → PARTIALLY/FULLY_RETURNED
PO POSTED ──createProgressiveInvoice──▶ child PIV(s); parent.invoicingStatus → PARTIALLY/FULLY_INVOICED
PO POSTED ──createGoodsReceipt──▶ GRN (operational); parent.receiptStatus → PARTIALLY/FULLY_RECEIVED
Guards (OrderBaseService): canUpdate/canDelete block a POSTED non-invoice order; invoices stay editable. canPost only for invoice kinds not already POSTED. canReturn only for invoice kinds not FULLY_RETURNED. canCopy blocks invoices, and blocks POs/SOs with no remaining-to-invoice qty. Posting an invoice that requires approval (approval-threshold policy matched) throws "This order requires approval before it can be posted" unless the workflow status includes APPROVED.
4.6 Transactionality
Each flow runs in one Mongo session via withRetryTransaction(name, fn); setSession fans the session into stock/item/account/transaction/payment/workflow sub-services so order + items + stock + GL legs commit atomically. The outermost session asserts accountSvc.validateBalanced(...); nested checkouts skip their own assertion (checked by sessionName).
5. Mutations: create / update / delete / return
5.1 Update — OrderService.update()
Routes by kind: PurchaseInvoice → purchaseSvc.update, SalesInvoice → salesSvc.update, else updateNonInvoiceOrder. Purchase update re-runs addInvoiceItem for new lines and updateInvoiceItem for existing (re-converting UOM, re-deriving rate, re-writing the stock row via updateItemStock, repairing FIFO layers, refreshing inventory/tax/bill legs), then fixPaymentAmount.
5.2 Delete — OrderService.delete()
In one transaction: delete order transactions (if invoice/return) → orderItemsSvc.deleteByOrder (each line deletes its stock + GL legs; FIFO layers zeroed first) → stockSvc.deleteMany({orderId}) → delete order → delete workflow tasks → if invoice with parent, syncParentInvoicingStatus; if return with parent, syncSourceReturnState → validateBalanced. On-hand self-corrects because stock rows are gone from the balance() aggregation.
5.3 Purchase Return (PRT) — order/order.return.ts → returnOrder()
Only PurchaseInvoice/SalesInvoice can be returned. Per requested line:
- Validates
requestedDisplayQty ≤ remaining returnable qty;baseQty = displayQty × conversionFactor; amount/tax/cost prorated byratio = displayQty / sourceDisplayQty. - For inventory purchase returns, asserts source branch has enough on-hand (
stockSvc.balance≥ baseQty) else "Insufficient stock to return…". - Creates a
PurchaseReturnorder + lines. - Stock: writes
Stock(type=OUT, kind=OrderReturn)at the branch — returning purchased goods removes them; FIFO issue viacostingSvc.postIssuesupplies the cost. - GL (purchase return): CREDIT Inventory (
rawAmount), CREDIT Input-tax (TaxEntry), and a settlement leg DEBIT the payment/user account forrawAmount + tax. syncSourceReturnStaterolls upreturnedQuantity/returnedAmountand sets sourcereturnStatus. (Sales return is the mirror — DR revenue, DR inventory + CR COGS, CR settlement; Stock IN. See sales.md §5.)
6. Flows
6.1 Create a Purchase Invoice (Stock IN)
- Admin Purchase screen →
createOrdermutation withkind=PurchaseInvoice,supplierId, lines. OrderService.create→purchaseSvc.checkout→checkOutPurchase(transaction).- Header persisted; each ITEM line: UOM→base,
Stock(IN)written, DR Inventory leg. addBillposts CR Accounts-Payable + input-tax legs; if CASH, payment legs.validateBalanced; ifPOSTED,postInvoiceflips ledger to POSTED. On-hand for the item+branch increases byΣ netQuantity(IN).
- Unhappy paths: non-inventory item ⇒ BAD_REQUEST; unbalanced ledger ⇒ throws and rolls back; approval required & not approved ⇒ FORBIDDEN on post.
6.2 PR → PO → PIV (progressive invoicing)
- Create
PurchaseRequisition/PurchaseOrder(rows only, no stock/GL), POST it. eligibleInvoiceOrders(kind=PurchaseInvoice)lists posted POs notFULLY_INVOICED.createProgressiveInvoice({orderId, kind: PurchaseInvoice, items:[{itemId, quantity, rate?}]}): validates each qty ≤ remaining (getRemainingQuantities), creates a childPIV(parentId = PO) withorderDate = now, thensyncParentInvoicingStatussets POinvoicingStatusto PARTIALLY/FULLY_INVOICED. The child PIV checkout writes Stock IN + GL normally.
6.3 Goods Receipt (GRN) — operational only — goods-receipt/goods-receipt.service.ts
Flag-gated (company.goodsReceiptEnabled; else FORBIDDEN). Safe slice = NO stock movement, NO GL.
createGoodsReceipt({poId, items}): PO must bekind=PurchaseOrderandPOSTED. Per item,already + received ≤ ordered(else BAD_REQUEST).- Persists a
GoodsReceipt(collectiongoods_receipts:poId,supplierId = po.customerId,status=RECEIVED,deliveryRef,note,receivedDate,items[{poItemId,itemId,orderedQuantity,receivedQuantity,uomId}],actorId). - Rolls up
OrderItem.grnReceivedQuantityand recomputes POreceiptStatus(NOT/PARTIALLY/FULLY_RECEIVED). cancelGoodsReceiptreverses the rollups (idempotent) and setsstatus=CANCELLED.
GRN does not post stock or GRNI. Stock IN still arrives only via a Purchase Invoice. GRN is the document of record for physical receipt against a PO.
6.4 Invoice import (XLSX, bulk) — OrderService.importInvoice / confirmInvoiceImport
Two-step: importInvoice(file, kind?, branchId?) parses rows (resolving customer/supplier/item/cost-center/class/analysis-code by name; for sales kinds it flags out-of-stock rows cumulatively). confirmInvoiceImport({kind, branchId, transactions}) validates dates against the fiscal period, skips duplicates (same ref+item, in-file and pre-existing) and out-of-stock sales rows, then creates one order per transaction via create() (so purchase imports add stock, sales imports draw it). Returns {importedInvoices, importedItems, skippedCount, skipped[]}. validateBalanced at the end.
7. Admin UI
- Pages (
zerp-admin/src/pages/):purchase/index.tsx,purchase/new.tsx,purchase/[_id],purchase/order,purchase/requisition,purchase/invoice; shared order pagesorder/index.tsx,order/[_id].tsx,order/edit. Returns live underfinance/return-orders/index.tsx. - The purchase page is the shared order page parameterised by kind:
purchase/page.tsxrenders<OrdersPage kind="PurchaseInvoice" title="Purchase" .../>frommodules/inventory/order/page.tsx. The same order module (order/components/*) provides the line table (orderItemTable.tsx), add/update item modal (add-or-update-item.tsx), import (import.tsx/confirmImport.tsx), copy (copy.tsx), order→invoice (order-to-invoice-modal.tsx), progressive invoice (progressive-invoice.tsx), returns (returnItem.tsx), and print templates (components/template/*). - Context (
modules/inventory/purchase/context.tsx):usePurchaseOrderState()exposespurchaseInvoicePage,findOnePurchaseOrder,createPurchaseOrder(→createOrder),updatePurchaseOrder(→updateOrder),updatePurchaseOrderStatus,addPurchaseOrderItem,purchaseInvoiceSummary,viewInvoice/downloadInvoice. State:purchaseInvoice,purchaseInvoices,filter,purchaseInvoiceSum,totalRecords. Components consumeusePurchaseOrderState()only. - Returns context (
modules/inventory/returns/context.tsx):useReturnOrderState()→returnOrderPage,createReturnOrder,updateReturnOrder. - Validation: Formik + Yup schemas in
order/validation/*(createInvoiceSchema,add-update-itemschema,importFormSchema,returnItemFormSchema). - Notable UX: inline line editing, XLSX import preview with duplicate/out-of-stock flags, copy-to-kind, partial (progressive) invoicing modal, print/PDF invoice templates, receipt-file upload.
8. Dependencies & integrations
- Stock ledger (stock.md) —
StockService.create/balance/deleteMany;CostingService(FIFO layers). - Item catalog (item.md) —
ItemService.getAccounts(inventory/sales/COGS accounts),costingMethod,soldBy;ItemUOMConversionService(categories-uom.md);ItemPriceService/ItemPriceLevelService(pricing.md). - Finance —
AccountService(user accounts,validateBalanced),AccountTransactionService(AccountTransactionKind.{PurchaseInvoice, TaxEntry, OrderPayment, PurchaseReturnOrder}),TaxationService,FiscalPeriodService(date validation),OrderPaymentService. - Workflow / approvals —
WorkflowEngine,WorkflowTaskService,ApprovalThresholdService(see workflow-approval-engine). - Cross-cutting —
CompanyService(default workflows,goodsReceiptEnabled),MasterService(cost-center/class/analysis-code),RedisService(onOrderEvent),FileUploadService(receipts),XlsxUtils(import). - Audit — every mutation
@AuditMeta(...)(modulesorder/purchase/goods-receipt).
9. Gotchas & project-specific rules
- Only
PurchaseInvoicemoves stock/GL. PR/PO/quotations/SOs are rows-only planning docs. - Purchase party stored on
customerIdin places — GRN readspo.customerIdas the supplier;buildOrderQuerymaps asupplierIdfilter tocustomerId.pre("save")mirrorscustomerId||supplierIdintouserId. - Quantity is never stored as a balance — on-hand is always
Σ IN − Σ OUTover the ledger (stock.md). Editing/deleting a line just re-writes/removes ledger rows. - GRN is operational-only in the current safe slice (no stock, no GRNI). Stock IN comes solely from the invoice.
- Idempotent GL —
addBill/tax-entry logic deletes/updates existing legs before recreating, so editing or re-posting an invoice never duplicates the AP or tax legs (a past bug). FX repair (repairInventoryTransactions) fixes legs whenexchangeRate ≠ 1. fixedlines freeze price/cost — skip price-level resolution and average-cost write-back.validateBalancedruns at every flow boundary; an unbalanced ledger aborts the whole transaction.- Approval gate on post — if a matching approval-threshold policy exists,
postInvoicethrows FORBIDDEN until the workflow is APPROVED (no-op for companies without the flag/policy).