Purchases — procurement documents, goods receipt & stock IN

The whole purchase side reduces to: a PurchaseInvoice is an Order(kind=PurchaseInvoice) that, on checkout, writes one Stock(type=IN) row per inventory line and posts the GL legs DEBIT Inventory / CREDIT Accounts-Payable (plus tax). Purchase Requisitions and Purchase Orders are planning documents in the same Order collection 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_items collections, discriminated by kind.
  • Stock IN posting — only PurchaseInvoice lines write Stock(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.
  • ReturnsPurchaseReturn reversing 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 } // C1

2.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 createOrderpurchaseSvc.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.tscheckOutPurchase()

Runs inside withRetryTransaction("create_purchase_order"):

  1. orderSvc.fixPaymentAmount(model) computes header totalAmount (see §4.4) and balanceAmount.
  2. purchaseRepo.create(order) persists the Order(kind=PurchaseInvoice) header.
  3. For each line → purchaseItemSvc.addInvoiceItem(...) (UOM-convert, write OrderItem, write Stock(IN), write inventory GL leg, write tax legs — §4.2/§4.3).
  4. orderTransactionSvc.addBill(order, relationId) posts the AP "bill" leg + tax legs (§4.3).
  5. If paymentType === CASHorderTransactionSvc.updateOrderPayment() posts the cash/bank payment legs.
  6. Status set: if CASH + status=POSTED{status: POSTED, paymentStatus: PAID}, else just {status}.
  7. accountSvc.validateBalanced(ref) asserts the ledger nets to zero.
  8. If POSTEDorderSvc.postInvoice(id) flips all transactions to POSTED status.
  9. 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.tsaddInvoiceItem()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; netQuantity becomes 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 to item.cost; FIFO pushes a cost layer via CostingService.postReceipt. See pricing.md §4.
  • Average cost (OrderBaseService.getPurchaseAvg / PurchaseInvoiceService.getAvg): newAvg = (availableStock>1 && prevQty>1) ? (rate×netQty + prevAmount) / (netQty + prevQty) : rate. Persisted as an ItemPrice history 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.addBilladdCashBilling
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. If inclusive, baseAmount = amount / (1 + additiveRate/100), else baseAmount = amount. Each tax amt = 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 at amount; 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 resolver totalAmount).
  • Margin (getMargin) = Σ (amount − cost) over lines with cost > 0.
  • Payment validation (validatePaymentAmount): cashAmount + bankAmount + discountAmount must equal getAmountWithTax(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, syncSourceReturnStatevalidateBalanced. On-hand self-corrects because stock rows are gone from the balance() aggregation.

5.3 Purchase Return (PRT) — order/order.return.tsreturnOrder()

Only PurchaseInvoice/SalesInvoice can be returned. Per requested line:

  • Validates requestedDisplayQty ≤ remaining returnable qty; baseQty = displayQty × conversionFactor; amount/tax/cost prorated by ratio = displayQty / sourceDisplayQty.
  • For inventory purchase returns, asserts source branch has enough on-hand (stockSvc.balance ≥ baseQty) else "Insufficient stock to return…".
  • Creates a PurchaseReturn order + lines.
  • Stock: writes Stock(type=OUT, kind=OrderReturn) at the branch — returning purchased goods removes them; FIFO issue via costingSvc.postIssue supplies the cost.
  • GL (purchase return): CREDIT Inventory (rawAmount), CREDIT Input-tax (TaxEntry), and a settlement leg DEBIT the payment/user account for rawAmount + tax.
  • syncSourceReturnState rolls up returnedQuantity/returnedAmount and sets source returnStatus. (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)

  1. Admin Purchase screen → createOrder mutation with kind=PurchaseInvoice, supplierId, lines.
  2. OrderService.createpurchaseSvc.checkoutcheckOutPurchase (transaction).
  3. Header persisted; each ITEM line: UOM→base, Stock(IN) written, DR Inventory leg.
  4. addBill posts CR Accounts-Payable + input-tax legs; if CASH, payment legs.
  5. validateBalanced; if POSTED, postInvoice flips 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)

  1. Create PurchaseRequisition / PurchaseOrder (rows only, no stock/GL), POST it.
  2. eligibleInvoiceOrders(kind=PurchaseInvoice) lists posted POs not FULLY_INVOICED.
  3. createProgressiveInvoice({orderId, kind: PurchaseInvoice, items:[{itemId, quantity, rate?}]}): validates each qty ≤ remaining (getRemainingQuantities), creates a child PIV (parentId = PO) with orderDate = now, then syncParentInvoicingStatus sets PO invoicingStatus to 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.

  1. createGoodsReceipt({poId, items}): PO must be kind=PurchaseOrder and POSTED. Per item, already + received ≤ ordered (else BAD_REQUEST).
  2. Persists a GoodsReceipt (collection goods_receipts: poId, supplierId = po.customerId, status=RECEIVED, deliveryRef, note, receivedDate, items[{poItemId,itemId,orderedQuantity,receivedQuantity,uomId}], actorId).
  3. Rolls up OrderItem.grnReceivedQuantity and recomputes PO receiptStatus (NOT/PARTIALLY/FULLY_RECEIVED).
  4. cancelGoodsReceipt reverses the rollups (idempotent) and sets status=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 pages order/index.tsx, order/[_id].tsx, order/edit. Returns live under finance/return-orders/index.tsx.
  • The purchase page is the shared order page parameterised by kind: purchase/page.tsx renders <OrdersPage kind="PurchaseInvoice" title="Purchase" .../> from modules/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() exposes purchaseInvoicePage, findOnePurchaseOrder, createPurchaseOrder (→ createOrder), updatePurchaseOrder (→ updateOrder), updatePurchaseOrderStatus, addPurchaseOrderItem, purchaseInvoiceSummary, viewInvoice/downloadInvoice. State: purchaseInvoice, purchaseInvoices, filter, purchaseInvoiceSum, totalRecords. Components consume usePurchaseOrderState() 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).
  • FinanceAccountService (user accounts, validateBalanced), AccountTransactionService (AccountTransactionKind.{PurchaseInvoice, TaxEntry, OrderPayment, PurchaseReturnOrder}), TaxationService, FiscalPeriodService (date validation), OrderPaymentService.
  • Workflow / approvalsWorkflowEngine, WorkflowTaskService, ApprovalThresholdService (see workflow-approval-engine).
  • Cross-cuttingCompanyService (default workflows, goodsReceiptEnabled), MasterService (cost-center/class/analysis-code), RedisService (onOrderEvent), FileUploadService (receipts), XlsxUtils (import).
  • Audit — every mutation @AuditMeta(...) (modules order/purchase/goods-receipt).

9. Gotchas & project-specific rules

  • Only PurchaseInvoice moves stock/GL. PR/PO/quotations/SOs are rows-only planning docs.
  • Purchase party stored on customerId in places — GRN reads po.customerId as the supplier; buildOrderQuery maps a supplierId filter to customerId. pre("save") mirrors customerId||supplierId into userId.
  • Quantity is never stored as a balance — on-hand is always Σ IN − Σ OUT over 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 GLaddBill/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 when exchangeRate ≠ 1.
  • fixed lines freeze price/cost — skip price-level resolution and average-cost write-back.
  • validateBalanced runs at every flow boundary; an unbalanced ledger aborts the whole transaction.
  • Approval gate on post — if a matching approval-threshold policy exists, postInvoice throws FORBIDDEN until the workflow is APPROVED (no-op for companies without the flag/policy).