Sales — quotation/order/invoice, stock OUT & revenue recognition
The whole sales side reduces to: a
SalesInvoiceis anOrder(kind=SalesInvoice)that, on checkout, writes oneStock(type=OUT)row per inventory line and posts the GL legsCREDIT Revenue / DEBIT Accounts-Receivable, plusDEBIT COGS / CREDIT Inventoryfor the cost, plus output tax. Sales Quotations (SQ) and Sales Orders (SO) are planning documents in the sameOrdercollection 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_itemscollections, discriminated bykind. - Stock OUT posting — only
SalesInvoiceITEM lines for inventory items writeStock(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.
- Returns —
SalesReturnreversing stock + GL. - POS-style cart — the admin checkout flow (
inventory/cart).
The shared Order engine — header/line schema, the
OrderKindTypes/OrderStatusTypes/OrderLineTypeenums, theOrderService.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.ts → checkout()
Runs inside withRetryTransaction("checkout_sales_invoice"):
orderSvc.fixPaymentAmount(model)computes header total;documentDate = orderDate.validateStockAvailability(items)(see §4.5 — currently a soft guard).- If
order.orderId→update(orderId, order); elsecreate(order)which persists the header thenaddOrderItemsloopsitemsSvc.addInvoiceItem(...). orderTransactionSvc.addBill(order, relationId)posts the AR "bill" leg + output-tax legs.- If
paymentType === CASH→orderTransactionSvc.updateOrderPayment()(cash/bank legs). - Status:
CASH+POSTED→{status: POSTED, paymentStatus: PAID}, else{status}. accountSvc.validateBalanced(ref); ifPOSTED→orderSvc.postInvoice(id); emitsNEW_ORDER.
4.2 Per-line stock OUT — sales/item/item.service.ts → addInvoiceItem() → 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 → StockSales adds two pre-write concerns purchase doesn't:
- 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). - Availability —
validateStockAvailability(§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.ts → updateItemTransactions()
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
PurchaseInvoiceItemlots withdocumentDate ≤ saleDate(and same branch):unitCost = Σ amount / Σ netQuantity(uses lineamount, notcost, since non-fixedcost=0);COGS = unitCost × netQuantity. - FIFO / LIFO: lot consumption — FIFO via
CostingService.postIssue(returnstotalCost, consumes oldest layers); LIFO/getLotCost("desc"). On qty edit/delete, the previously consumed qty is returned to the FIFO pool viapostReceipt(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.evaluateStockForRowflags/skips out-of-stock sales rows; see purchases.md §6.4) and the create-sale UI (stock-item.tsxlists 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 viacostingSvc.postReceiptat 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 forrawAmount + tax. syncSourceReturnStaterolls upreturnedQuantity/returnedAmountand sets sourcereturnStatus(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)
- Admin sales/cart screen →
checkoutSalesInvoice(id?, checkout)(branchIdrequired). salesSvc.checkout(transaction):fixPaymentAmount→validateStockAvailability→ create/update header → per lineaddInvoiceItem.- Each inventory ITEM line: resolve price (if not fixed) → resolve COGS → UOM→base →
Stock(OUT)→ CR Revenue / DR COGS / CR Inventory legs. addBillposts DR Accounts-Receivable + output-tax; if CASH, payment legs.validateBalanced; ifPOSTED,postInvoiceflips 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 pagescategory.tsx,employee.tsx,item.tsx. Shared order pagesorder/*; returns underfinance/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}.tsx—stock-item.tsxsurfaces available stock per item for selection. - Context (
modules/inventory/sales/context.tsx):useSalesOrderState()exposessalesInvoicePage,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 consumeuseSalesOrderState()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
SalesInvoicemoves stock/GL. SQ/SO are rows-only planning docs. - Non-inventory items skip stock and the COGS/inventory legs — only Revenue + AR post. The
isInventoryItemflag is the single switch (_overview.md). - Checkout does not hard-enforce availability —
validateStockAvailabilityis 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. customerIdvsuserId—pre("save")mirrorscustomerIdintouserId; AR account is resolved fromuserId.fixedlines freeze price — skip price-level resolution;costtaken as-is.- Quantity is never stored — on-hand is
Σ IN − Σ OUTover 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 (
repairSalesItemTransactionsforexchangeRate ≠ 1); no duplicates. validateBalancedat every flow boundary aborts an unbalanced transaction.