Sales — quotation/order/invoice, stock OUT & revenue recognition

The whole sales side reduces to: a SalesInvoice is an Order(kind=SalesInvoice) that, on checkout, writes one Stock(type=OUT) row per inventory line and posts the GL legs CREDIT Revenue / DEBIT Accounts-Receivable, plus DEBIT COGS / CREDIT Inventory for the cost, plus output tax. Sales Quotations (SQ) and Sales Orders (SO) are planning documents in the same Order collection that move no stock and post no GL until drawn down into an invoice.

Source: BE src/modules/inventory/sales (+ shared inventory/order engine) · Admin src/modules/inventory/sales, inventory/cart, inventory/order, inventory/returns

Related: purchases.md (the shared Order engine lives there — §2) · stock ledger · pricing & costing · domain overview · BE reference


1. Purpose & scope

Covers the sales half of the inventory movement engine:

  • Sales lifecycle documents — Sales Quotation (SQ), Sales Order (SO), Sales Invoice (SIV), Sales Return (SRT) — all rows in the shared polymorphic orders / order_items collections, discriminated by kind.
  • Stock OUT posting — only SalesInvoice ITEM lines for inventory items write Stock(type=OUT).
  • GL side effects — Accounts-Receivable (the "bill"), Sales Revenue, Cost-of-Sales (COGS), Inventory relief, output tax.
  • Pricing & cost-of-sale resolution — price-level lookup; STANDARD/AVERAGE/FIFO/LIFO costing.
  • ReturnsSalesReturn reversing stock + GL.
  • POS-style cart — the admin checkout flow (inventory/cart).

The shared Order engine — header/line schema, the OrderKindTypes/OrderStatusTypes/OrderLineType enums, the OrderService.create() kind-dispatch, totals/tax math (getAmountWithTax, lineNetBase, computeLineTaxes), status machine, transactionality, the order/return resolvers and progressive-invoicing — is documented once in purchases.md §2–§6. This doc covers only what is sales-specific.

What this does not do: catalog/item definition (item.md); UOM/price-level definition (categories-uom.md, pricing.md); the stock-ledger aggregation math (stock.md).


2. Data model (sales-specific)

SalesInvoice (sales/sales.schema.ts) is OrderEntity (same header as purchases.md §2.1) plus:

Field Type Description
customerId ObjectId The buyer (mirrored into userId on save).
orderDate number Document date.
exchangeRate number FX (default 1).
orderId? string When checking out from an existing draft/order.
items SalesInvoiceItem[] Lines (order_items, kind=SalesInvoiceItem).
ignoreTransactions? boolean Skip GL (special imports).
kind OrderKindTypes SalesInvoice (persists into shared orders).

SalesInvoiceItem is the shared OrderItem (purchases.md §2.2) with kind=SalesInvoiceItem; cost here is the cost of sale (COGS) for the line, not the purchase rate.

ISalesGroupSummary (reporting projection, not a collection): {netQuantity, totalCost, totalAmount, profit, margin, itemId, customerId, categoryId, employeeId} — backs the dashboard "top customers/items/categories/employees" queries.

Enums are the shared order enums — see purchases.md §2.3.


3. API surface (sales)

SalesInvoiceResolver (sales/sales.resolver.ts) is @ApGqlAuthorize() + @AuditMeta(...). (The shared order/* and return mutations are in purchases.md §3.)

Operation Type Input Returns Notes
createSalesInvoice mutation CreateSalesInvoiceInput (extends CommonOrderInput) SalesInvoice salesSvc.create (draft create, writes items).
updateSalesInvoice mutation id, UpdateSalesInvoiceInput SalesInvoice
checkoutSalesInvoice mutation id?, SalesInvoiceCheckoutInput Order Main entry@ApBranchAuth({branchIdRequired:true}). Routes a PurchaseInvoice kind to purchaseSvc.checkout; else salesSvc.checkout.
updateSalesInvoiceStatus mutation id, status Boolean
salesInvoicePage / findSalesInvoice / findOneSalesInvoice query page/query SalesInvoice(s) page scoped to user.branchId.
salesInvoiceSummary query SalesInvoiceQueryInput SalesInvoiceSummary thisWeek/lastWeek/thisMonth.
salesInvoiceAnalysis query SalesInvoiceAnalysis today / MTD / YTD.
salesInvoiceTopCustomers / …TopProfitableItems / …TopProfitableCategories / …TopSoldItems / …TopSoldCategories / …TopEmployee query [SalesInvoiceGroupSummary] totalAmountByGrouping.
uploadSalesReceipt mutation OrderReceiptInput [FileUpload]

SalesInvoice resolve-fields: customer, paymentBalance, payments, paymentStatus, canUpdate/canDelete/canAddPayment/canAddItem, receipts, totalAmount = getAmountWithTax(args) − discountAmount, totalCost = Σ items.cost, totalMargin = getMargin, orderDate. SalesInvoiceGroupSummary resolves customer/item/category/employee.


4. Business rules & calculations

4.1 Sales checkout (Stock OUT + GL) — sales/sales.service.tscheckout()

Runs inside withRetryTransaction("checkout_sales_invoice"):

  1. orderSvc.fixPaymentAmount(model) computes header total; documentDate = orderDate.
  2. validateStockAvailability(items) (see §4.5 — currently a soft guard).
  3. If order.orderIdupdate(orderId, order); else create(order) which persists the header then addOrderItems loops itemsSvc.addInvoiceItem(...).
  4. orderTransactionSvc.addBill(order, relationId) posts the AR "bill" leg + output-tax legs.
  5. If paymentType === CASHorderTransactionSvc.updateOrderPayment() (cash/bank legs).
  6. Status: CASH + POSTED{status: POSTED, paymentStatus: PAID}, else {status}.
  7. accountSvc.validateBalanced(ref); if POSTEDorderSvc.postInvoice(id); emits NEW_ORDER.

4.2 Per-line stock OUT — sales/item/item.service.tsaddInvoiceItem()createNormalizedItem()addItemStock()

// addItemStock — Stock OUT
if (!(itm?.type?.isInventoryItem)) return;          // ← non-inventory items move NO stock
const unitCost = toUnitCost(model.cost, model.netQuantity, 0);
const newStock = await stockSvc.create({
  ...model, branchId: model.branchId || order.branchId,
  avgCost: unitCost, cost: model.cost,
  kind: StockKindTypes.SalesInvoice, orderItemId: model._id,
  type: StockTypes.OUT                                // ← OUTBOUND, −qty
});
await itemRepo.update(model._id, { stockId: newStock._id }); // back-link OrderItem → Stock

Sales adds two pre-write concerns purchase doesn't:

  1. Inventory-item check!item.type.isInventoryItem ⇒ no stock row (and COGS/inventory GL legs skipped); only the revenue + AR legs post (e.g. a service/labour line).
  2. AvailabilityvalidateStockAvailability (§4.5).

Multi-UOM: UOM fallback model.uomId || item.salesUomId || item.baseUomId || item.uomId; non-base converts via uomConversionSvc.toBaseQuantity (netQuantity → base units). For soldBy=ITEM items, qty/gross/waste are taken from the chosen serialized Stock row. model.rate = model.amount / model.netQuantity.

4.3 GL legs (double-entry) — sales/item/item.service.tsupdateItemTransactions()

A posted Sales Invoice produces, per line (AccountTransactionKind.SalesInvoice):

Leg Account Type Amount Notes
Sales Revenue item salesAccountId CREDIT lineNetBase(amount, inclusive, taxes, taxAmount) (ex-additive-tax) always
Cost of Sales (COGS) item costOfSalesAccountId DEBIT model.cost inventory items only
Inventory relief item inventoryAccountId CREDIT model.cost inventory items only
Accounts Receivable (the "bill") customer user-account DEBIT getAmountWithTax(order) orderTransactionSvc.addBill
Output tax tax accountId CREDIT (additive) / flipped for WHT/deductive leg amount updateOrderTax (TaxEntry)
Cash/Bank payment (if CASH) cash/bank account CREDIT/DEBIT mirror tendered updateOrderPayment
CATEGORY/DESCRIPTION line line accountId CREDIT line net base updateNonItemTransaction

Mnemonic for an inventory SIV: DR Accounts-Receivable, DR COGS, CR Revenue, CR Inventory, CR Output-Tax. The two cost legs (DR COGS / CR Inventory) net the asset off the balance sheet against expense; revenue + AR record the sale. Sales-base tax type is CREDIT; deductive (withholding) flips to DEBIT.

4.4 Pricing resolution — addInvoiceItem

If the line is not fixed and the order has a customerId, ItemPriceLevelService.resolvePrice(itemId, customer.priceLevelId) resolves a per-base-unit price: customer price level → item default level → null (null ⇒ keep the entered amount). When resolved: amount = resolvedPrice × netQuantity, rate = resolvedPrice × conversionFactor, and priceLevelId is stamped for audit. See pricing.md §4.

4.5 Cost of sale (COGS) — getCostPrice()ItemCostingService.getCostForSale()

Per item.costingMethod (default STANDARD):

  • STANDARD: cost = item.cost × netQuantity.
  • AVERAGE (AVCO): weighted average over PurchaseInvoiceItem lots with documentDate ≤ saleDate (and same branch): unitCost = Σ amount / Σ netQuantity (uses line amount, not cost, since non-fixed cost=0); COGS = unitCost × netQuantity.
  • FIFO / LIFO: lot consumption — FIFO via CostingService.postIssue (returns totalCost, consumes oldest layers); LIFO/getLotCost("desc"). On qty edit/delete, the previously consumed qty is returned to the FIFO pool via postReceipt (reversal) before re-issuing. See pricing.md §4 and stock.md.

Availability guard (validateStockAvailability): the soldByItem/soldByWeight balance checks are present but commented out in the current code, so checkout does not hard-block a negative on-hand. The real enforcement that exists is in the import path (OrderService.evaluateStockForRow flags/skips out-of-stock sales rows; see purchases.md §6.4) and the create-sale UI (stock-item.tsx lists available stock). Treat checkout-time hard availability enforcement as a known TODO.

4.6 Totals & status

Totals/tax math (getAmountWithTax, lineNetBase, computeLineTaxes, getMargin, payment validation) and the SAVED⇄POSTED state machine + guards are the shared engine — purchases.md §4.4–§4.6. totalAmount = getAmountWithTax − discountAmount; totalMargin = Σ(amount − cost).


5. Sales Return (SRT) — order/order.return.ts

Mirror of the purchase return (purchases.md §5.3). Only SalesInvoice can be sales-returned. Per requested line (qty validated ≤ remaining returnable; amount/tax/cost prorated by ratio):

  • Stock: writes Stock(type=IN, kind=OrderReturn) at the branch — returned goods come back into inventory; FIFO receipt via costingSvc.postReceipt at the unit cost.
  • GL (sales return): DEBIT Revenue (rawAmount), DEBIT Output-tax (TaxEntry); for inventory items DEBIT Inventory (cost) + CREDIT COGS (cost) to reverse the cost; settlement leg CREDIT the payment/customer account for rawAmount + tax.
  • syncSourceReturnState rolls up returnedQuantity/returnedAmount and sets source returnStatus (NOT/PARTIALLY/FULLY_RETURNED).

Create/update/delete semantics (re-write/remove ledger rows; on-hand self-corrects) are the shared engine — purchases.md §5.


6. Flows

6.1 Create & checkout a Sales Invoice (Stock OUT)

  1. Admin sales/cart screen → checkoutSalesInvoice(id?, checkout) (branchId required).
  2. salesSvc.checkout (transaction): fixPaymentAmountvalidateStockAvailability → create/update header → per line addInvoiceItem.
  3. Each inventory ITEM line: resolve price (if not fixed) → resolve COGS → UOM→base → Stock(OUT)CR Revenue / DR COGS / CR Inventory legs.
  4. addBill posts DR Accounts-Receivable + output-tax; if CASH, payment legs.
  5. validateBalanced; if POSTED, postInvoice flips ledger to POSTED. On-hand for item+branch decreases by Σ netQuantity(OUT).
  • Unhappy paths: non-inventory line ⇒ revenue+AR only (no stock/COGS); unbalanced ledger ⇒ rollback; approval required & not approved ⇒ FORBIDDEN on post; out-of-stock enforced on import path (not hard-enforced at checkout — §4.5).

6.2 SQ → SO → SIV (progressive invoicing)

Same shared mechanism as purchase (purchases.md §6.2) with PurchaseOrder→SalesOrder, PurchaseInvoice→SalesInvoice: post the SO, eligibleInvoiceOrders(kind=SalesInvoice), then createProgressiveInvoice({orderId, kind: SalesInvoice, items}) creates child SIV(s) and updates SO invoicingStatus. Each child SIV checkout writes Stock OUT + GL.

6.3 Sales Return

returnOrder({orderId, items[{itemId, quantity}], returnDate, paymentAccountId}) → §5: creates a SalesReturn doc, Stock IN, reverses revenue/COGS/inventory/tax, settlement leg, updates source returnStatus.


7. Admin UI

  • Pages (zerp-admin/src/pages/sales/): index.tsx, new.tsx, sell.tsx, buy.tsx, [_id], order, quotation, invoice, plus report pages category.tsx, employee.tsx, item.tsx. Shared order pages order/*; returns under finance/return-orders.
  • Cart / POS checkout (modules/inventory/cart/): cart.tsx, checkout.tsx, components/item.tsx, context.tsx, model.tsx — the POS-style flow that builds line items and calls checkout.
  • Create-sale module (modules/inventory/sales/create/): page.tsx, components/{menu, sale-item, stock-item, status}.tsxstock-item.tsx surfaces available stock per item for selection.
  • Context (modules/inventory/sales/context.tsx): useSalesOrderState() exposes salesInvoicePage, findOneSalesOrder, createSalesOrder (→ createSalesInvoice), updateSalesOrder, checkoutSalesOrder (→ checkoutSalesInvoice), updateSalesOrderStatus, addSalesOrderItem, the summary/analysis loaders (salesInvoiceSummary, salesInvoiceAnalysisSummary) and the dashboard "top" loaders (salesInvoiceTopCustomers, …TopProfitableItems, …TopSoldItems, …TopProfitableCategories, …TopSoldCategories, …TopEmployee), viewInvoice/downloadInvoice. State: salesInvoice(s), salesInvoiceSum, salesInvoiceAnalysis, the six *GroupSummary[] arrays, totalRecords. Components consume useSalesOrderState() only.
  • Summary dashboard (modules/inventory/sales/summary/): analysis, sales-analysis, sales-employee, sales-order, top-customers, top-(profitable|selling)-(items|categories).
  • The same shared order module (modules/inventory/order/*) supplies the line table, add/update-item modal, import, copy, order→invoice, progressive-invoice, return modal, and print templates (see purchases.md §7).
  • Validation: Formik + Yup in order/validation/*.
  • Notable UX: POS cart checkout with split tender (cash/bank), inline price-level pricing, per-item available-stock display, print/PDF invoice, sales analytics dashboards.

8. Dependencies & integrations

Same set as purchases.md §8, with the sales-specific finance kinds: AccountTransactionKind.{SalesInvoice, TaxEntry, OrderPayment, SalesReturnOrder}. Key sales-only collaborators: ItemPriceLevelService (price resolution), ItemCostingService (COGS), CostingService (FIFO/LIFO lots), CustomerService, ItemCategoryService (group summaries).


9. Gotchas & project-specific rules

  • Only SalesInvoice moves stock/GL. SQ/SO are rows-only planning docs.
  • Non-inventory items skip stock and the COGS/inventory legs — only Revenue + AR post. The isInventoryItem flag is the single switch (_overview.md).
  • Checkout does not hard-enforce availabilityvalidateStockAvailability is commented out; out-of-stock is enforced on the import path and surfaced (not blocked) in the create-sale UI. Known TODO (§4.5).
  • COGS depends on costing method — STANDARD uses item.cost; AVERAGE recomputes from purchase lots ≤ sale date; FIFO/LIFO consume layers (and reverse on edit/delete/return). A sale with no prior purchase lots can yield COGS 0 / wrong margin — hence the import out-of-stock guard.
  • customerId vs userIdpre("save") mirrors customerId into userId; AR account is resolved from userId.
  • fixed lines freeze price — skip price-level resolution; cost taken as-is.
  • Quantity is never stored — on-hand is Σ IN − Σ OUT over the ledger; deleting an SIV line re-writes the ledger and self-corrects (stock.md).
  • Idempotent GL + FX repair — re-posting/editing updates existing revenue/COGS/inventory/AR/tax legs in place (repairSalesItemTransactions for exchangeRate ≠ 1); no duplicates.
  • validateBalanced at every flow boundary aborts an unbalanced transaction.