Stock Adjustment — reconcile counted stock to the ledger (counts & variances)
A stock adjustment reduces to one idea: for each line you record the system quantity (
quantity) and the counted/new quantity (newQuantity); the variancediff = newQuantity − quantitybecomes a singleStockrow —INif you found more,OUTif you found less — and a balanced pair of GL legs valuing the variance at the item's cost. Each line targets its own store (branchId), so one document can reconcile multiple branches at once.
Source: BE src/modules/inventory/adjustment (+ adjustment/item) · Admin src/modules/inventory/adjustment
Related plan: zerp-be/docs/superpowers/plans/2026-05-13-stock-adjustment-store-per-row.md (added the per-row store selector). Ledger core: ./stock.md.
1. Purpose & scope
The adjustment module is how operators reconcile the derived on-hand ledger to a physical stock count (and how they record write-offs, shrinkage, found stock, damage, etc.). It is the manual counterpart to the automatic purchase/sales/transfer movements.
What it does:
- Persists an adjustment header + lines (
stock_adjustments,stock_adjustment_items). - For each line, computes the variance
diff = newQuantity − quantityand writes oneStockrow (kind = StockAdjustment):INwhendiff > 0,OUTwhendiff < 0, of magnitude|diff|. - Posts two balancing
AccountTransactionlegs valuing the variance atcost × diff: one against the inventory account, one against the operator-chosen adjustment account (e.g. shrinkage / write-off expense), keeping the GL balanced. - Supports per-row store selection — each line carries its own
branchId, so the "In Stock" baseline and the resultingStockrow are scoped to that store.
What it explicitly does NOT do:
- No multi-UOM. Quantities are the item's base UOM only;
uomId/uomQuantity/conversionFactoron a line are rejected (assertBaseUomOnly). Convert before calling. - No approval workflow / threshold gating. Adjustments post immediately on save. (Value-band approvals are a separate, generic concept — see
fulfillment.mdfor the approval-threshold module.) - No reason enum. The only "reason" capture is the free-text
descriptionper line +noteon the header.StockAdjustmentTypeshas a single memberQUANTITY(the document is always a quantity adjustment); there is no DAMAGE / SHRINKAGE / COUNT enum. - No separate "stock count" document. A physical count is an adjustment: you enter counted figures as
newQuantityagainst the systemquantity.StockKindTypes.StockCountexists in the ledger enum but this module always writeskind = StockAdjustment.
2. Data model
2.1 stock_adjustments — adjustment header
adjustment/adjustment.schema.ts (class StockAdjustment extends BaseSchema, soft-deleted).
| field | type | required | description |
|---|---|---|---|
ref |
string | yes | Generated document number (generateRef()). |
branchId |
ObjectId | yes (schema) | Header branch. Defaults to contextSvc.user?.branchId if not sent. Per-line branchId overrides this for the actual stock movement (see §2.2). |
accountId |
ObjectId | yes | The adjustment/offset account chosen by the operator (the non-inventory GL leg — e.g. an expense/write-off account). |
type |
StockAdjustmentTypes |
— | Enum, default QUANTITY; only that value exists. |
note |
string | no | Free text. |
documentDate |
number | no | Business date (unix ts); carried onto every line, Stock row, and GL leg. |
items |
StockAdjustmentItem[] |
— | Virtual — $lookup on stock_adjustment_items.adjustmentId, not stored. |
// adjustment/adjustment.schema.ts
export enum StockAdjustmentTypes { QUANTITY = "QUANTITY" } // only value
@ApSchema({ collection: "stock_adjustments", timestamps: true })
export class StockAdjustment extends BaseSchema {
@Prop({ required: true }) ref: string;
@Prop({ required: true, set: v => BaseSchema.toObjectId(v) }) branchId: Types.ObjectId;
@Prop({ required: true, set: v => BaseSchema.toObjectId(v) }) accountId: Types.ObjectId; // adjustment account
@Prop({ type: String, enum: StockAdjustmentTypes, default: StockAdjustmentTypes.QUANTITY }) type;
@Prop({ required: false }) note: string;
@Prop({ required: false }) documentDate: number;
items: StockAdjustmentItem[]; // virtual ($lookup)
}2.2 stock_adjustment_items — adjustment line
adjustment/item/item.schema.ts (class StockAdjustmentItem extends BaseSchema, soft-deleted).
| field | type | required | description |
|---|---|---|---|
ref |
string | yes | Generated line reference. |
adjustmentId |
ObjectId | yes | Back-ref to stock_adjustments._id. |
itemId |
ObjectId | yes | The product. |
branchId |
ObjectId | yes | Per-row store — where this variance lands. Defaults to contextSvc.user?.branchId. |
cost |
number | yes | Unit cost used to value the variance (GL amount). UI seeds it from item.cost. |
quantity |
number | yes | System on-hand at entry time (base UOM). UI seeds it from item.stockBalance for that store. |
newQuantity |
number | no (schema) / yes (GraphQL+UI) | The counted / target quantity (base UOM). |
diff |
number | no | Stored variance newQuantity − quantity (also recomputed in the service). |
description |
string | no | Free-text per-line reason. |
// adjustment/item/item.schema.ts
@ApSchema({ collection: "stock_adjustment_items", timestamps: true })
export class StockAdjustmentItem extends BaseSchema {
@Prop({ required: true }) ref: string;
@Prop({ required: true, set: v => BaseSchema.toObjectId(v) }) adjustmentId: Types.ObjectId;
@Prop({ required: true, set: v => BaseSchema.toObjectId(v) }) itemId: Types.ObjectId;
@Prop({ required: true, set: v => BaseSchema.toObjectId(v) }) branchId: Types.ObjectId; // per-row store
@Prop({ required: true }) cost: number; // unit cost (variance valuation)
@Prop({ required: true }) quantity: number; // SYSTEM qty (base UOM)
@Prop({ required: false }) newQuantity: number; // COUNTED qty (base UOM)
@Prop({ required: false }) description: string;
@Prop({ required: false }) diff: number; // newQuantity - quantity
}2.3 What a line writes into the Stock ledger
One Stock row per line (updateAdjustmentStock):
| Stock field | value |
|---|---|
kind |
StockKindTypes.StockAdjustment |
type |
diff > 0 ? StockTypes.IN : StockTypes.OUT |
branchId |
the line's per-row branchId |
itemId |
line itemId |
orderId |
line adjustmentId (reused field) |
orderItemId |
line _id (reused field) |
grossQuantity / netQuantity |
Math.abs(diff) |
cost |
cost × diff (the variance valuation; see §4.1) |
documentDate |
line documentDate |
The line reuses
Stock.orderId/Stock.orderItemIdto point at the adjustment / adjustment-item. On update the matching row is found by{ orderItemId }; on deletedeleteMany({ orderItemId }). Edge case: whendiff === 0,typeisOUT(the> 0test is false) andnetQuantity = 0— a zero-qty OUT row, which is a no-op onbalance().
3. API surface
GraphQL — adjustment/adjustment.resolver.ts (header) + adjustment/item (line)
| Operation | Type | Input | Returns | Permission |
|---|---|---|---|---|
createStockAdjustment |
mutation | adjustment: CreateStockAdjustmentInput |
StockAdjustment |
@ApGqlAuthorize + audit CREATE |
updateStockAdjustment |
mutation | _id: String, adjustment: UpdateStockAdjustmentInput |
StockAdjustment |
@ApGqlAuthorize + audit UPDATE |
deleteStockAdjustment |
mutation | _id: String |
Boolean |
@ApGqlAuthorize + audit DELETE |
deleteManyStockAdjustment |
mutation | input: DeleteManyStockAdjustmentInput |
Boolean |
@ApGqlAuthorize + audit DELETE |
deleteStockAdjustmentItem |
mutation | _id: String |
Boolean |
(item resolver) |
findStockAdjustment |
query | query: QueryStockAdjustmentInput |
[StockAdjustment] |
@ApGqlAuthorize |
findOneStockAdjustment |
query | query: QueryStockAdjustmentInput |
StockAdjustment (nullable) |
@ApGqlAuthorize |
stockAdjustmentPage |
query | page: StockAdjustmentPageInput |
StockAdjustmentPageResult |
@ApGqlAuthorize |
findOneStockAdjustmentItem |
query | query: QueryStockAdjustmentItemInput |
StockAdjustmentItem |
(item resolver) |
Note: the header
StockAdjustmentResolverhas@ApGqlAuthorize()applied per-method (not at class level), unlike the transfer resolver which is class-gated.
Resolve-field (StockAdjustment): account → AccountService.findById(accountId). Resolve-field (StockAdjustmentItem): item → ItemService.findById(itemId).
Input shapes (adjustment/adjustment.dto.ts, adjustment/item/item.dto.ts):
input CreateStockAdjustmentInput {
_id: ID accountId: ID! note: String type: StockAdjustmentTypes! documentDate: Float
items: [CreateStockAdjustmentItemInput!]
}
input CreateStockAdjustmentItemInput {
adjustmentId: ID itemId: ID! cost: Float! quantity: Float! newQuantity: Float
description: String branchId: String # per-row store
}
input UpdateStockAdjustmentInput { _id accountId note type documentDate items: [UpdateStockAdjustmentItemInput!] }
input DeleteManyStockAdjustmentInput { ids: [ID!]! }
input StockAdjustmentPageInput { skip: Float! take: Float! fromDate toDate branchId: ID stockId: ID keyword }REST — adjustment/adjustment.controller.ts
An XLSX/report download controller exists (mirrors the transfer controller's pattern). No write endpoints.
4. Business rules & calculations
4.1 The variance math — mapCostAndDiff() + updateAdjustmentStock()
The entire numeric core, per line:
// adjustment/item/item.service.ts
private mapCostAndDiff(model: StockAdjustmentItem) {
const diff = model.newQuantity - model.quantity; // + = found more, - = found less
const cost = model.cost * diff; // signed variance value
return { cost, diff };
}diff = newQuantity − quantity— the signed variance in base units.cost = unitCost × diff— the signed value of the variance (negative if stock decreased).- The
Stockrow usestype = diff > 0 ? IN : OUTandnetQuantity = |diff|(so on-hand moves toward the counted figure), and storescost= the signedcost × diff.
Worked example (one line, store A):
- System
quantity = 100, countednewQuantity = 92,unitCost = 5. diff = 92 − 100 = −8→ writeStock(type=OUT, netQuantity=8, kind=StockAdjustment, branchId=A).- On-hand at store A drops by 8 (
Σ IN − Σ OUT), landing at the counted 92. - Variance value =
5 × −8 = −40→ GL legs value the write-off at 40 (see §4.2).
4.2 GL side effects — two balancing legs
Each line posts two AccountTransactions so the books balance:
- Inventory leg —
updateItemTypeAccount()againstaccountSvc.findOne({ category: ACCOUNT_NAME.INVENTORY }):type = getTransactionType(inventoryAccountId, item.diff < 0 ? "DECREASE" : "INCREASE")(getTransactionTypemaps INCREASE/DECREASE to DEBIT/CREDIT based on the account's normal side —accountType.debit === type ? DEBIT : CREDIT,transaction.service.ts).amount = cost(the variance value),refId = adjustment._id,ref2Id = item._id,itemId,documentDate,remark = "Stock adjustment <ref>",status = POSTED,kind = StockAdjustment.
- Offset leg —
updateAccountTransaction()against the operator-chosenadjustment.accountId:type =the opposite of the inventory leg's type (type === CREDIT ? DEBIT : CREDIT), so the two legs net to zero.- Same
amount,refId,ref2Id,documentDate,remark,kind = StockAdjustment.
After posting, accountSvc.validateBalanced(...) asserts the GL is balanced (only when the line service owns the session — i.e. not when invoked as a child of the header transaction).
4.3 Create / update / delete (header) — StockAdjustmentService
All wrapped in a retry transaction, asserting validateBalanced at the end.
- create: default
branchIdfrom context → persist header →adjustmentItemSvc.createMany2(header, items)(loopscreate()per line) → return header hydrated with lines. - update: update header →
updateMany2(header, items): each item with_id→update()(re-writes the stock row + both GL legs in place); each new item →create(). - delete (header):
deleteItems({ adjustmentId })deletes each line, and per line: soft-delete line +stockSvc.deleteMany({ orderItemId })(the stock row) +transactionSvc.deleteMany({ ref2Id })(the GL legs) → then soft-delete the header. On-hand self-corrects (soft-deleted rows leavebalance()). - deleteManyAdjustments: loops
delete()per id.
4.4 Line create — StockAdjustmentItemService.create() (step by step)
public async create(model) {
return this.withRetryTransaction("create_adjustment_item", async () => {
this.assertBaseUomOnly(model); // base UOM only
model.branchId = model.branchId || this.contextSvc.user?.branchId; // per-row store
const created = await this.adjItemRepo.create(model);
const { cost, diff } = this.mapCostAndDiff(created); // diff = new - sys; cost = unit*diff
await this.updateAdjustmentStock({ ...created, cost, diff }, "add"); // 1 Stock row (IN/OUT)
const { type } = await this.updateItemTypeAccount(created, cost, "add"); // inventory GL leg
await this.updateAccountTransaction(created, cost, opposite(type), "add"); // offset GL leg
// validateBalanced when this service owns the session
return created;
});
}4.5 Status / state machine
None. An adjustment is created fully posted. The StockAdjustmentTypes.QUANTITY enum does not drive state. No draft/approved/posted lifecycle, no reversal status — deletion is the only "undo".
4.6 Transactionality
Header and per-line operations each run in withRetryTransaction; setSession propagates the Mongo session to stockSvc, accountSvc, transactionSvc, and stockAdjustmentSvc, so a line's stock row + two GL legs commit atomically. On throw the service aborts and rethrows.
5. Permissions
Resolvers gated by @ApGqlAuthorize() (per-method on the header resolver). Action keys (seeded in permission/action/action.service.ts, referenced under USER_ACCESS.INVENTORY in the admin):
| Action key | Used for |
|---|---|
view-stock-adjustments |
list page |
create-stock-adjustment |
new / edit |
view-stock-adjustment-details |
detail view |
(Delete reuses the inventory module's access via @ApGqlAuthorize.) All adjustment mutations carry @AuditMeta({ module: 'stock-adjustment', collection: 'stock_adjustments' }) → audit trail. See ../../platform/permissions-access.md.
6. Flows
6.1 Create an adjustment / record a count (happy path)
- Admin opens Stock Adjustments → New Adjustment (
page.tsx) →CreateStockAdjustmentmodal (components/create.tsx). Header: Adjustment Type (onlyQUANTITY), Adjustment Account, Date. - Per item row: pick a Store (
ApStoreSelectionInput) → pick an Item (ApItemSelectionfiltered by that store'sstoreId). On item select the form seedsquantity = item.stockBalance(the system on-hand for that store),cost = item.cost,newQuantity = 0,diff = 0. - The operator enters the counted figure either by editing New Quantity (which recomputes
diff = newQuantity − quantity) or by editing Adjustmentdiffdirectly (which recomputesnewQuantity = quantity + diff). The two fields are kept in sync. - Submit →
saveStockAdjustmentmaps each row to{ itemId, branchId: store._id, cost, quantity, newQuantity, description }→createStockAdjustment→StockAdjustmentService.create. - Per line: base-UOM assert → default store → persist line → compute
diff/cost→ write the singleStock(IN|OUT)row at that store → post inventory + offset GL legs. validateBalancedpasses → commit. Context prepends the new adjustment, toasts success.
6.2 Update an adjustment
- Edit modal pre-fills from the saved adjustment (re-seeding
quantityfrom currentitem.stockBalance). updateStockAdjustment→ header update → existing lines re-write their stock row + both GL legs in place ("update"), new linescreate(). Removing a line usesdeleteStockAdjustmentItem(_id)(soft-delete line + remove its stock row + GL legs).
6.3 Unhappy paths
- Multi-UOM input →
BadRequestException("Stock adjustment quantities must be entered in base UOM"). - Missing store or item on a row → blocked client-side (toast "All items must have a store and item selected.") before the mutation; GraphQL also requires
itemIdandaccountId. - GL imbalance →
validateBalancedthrows, the whole transaction rolls back.
7. Admin UI
Route: /stock/adjustments (list) + detail. Module: src/modules/inventory/adjustment.
page.tsx— page header, duration/date filter, search, download, "New Adjustment" button, table.components/create.tsx(CreateStockAdjustment+ItemsList) — Formik form,FormSchema(Yup):- Header:
type(select, must be aStockAdjustmentTypesvalue),account(required,ApAccountSelection),documentDate(required). - Items array (
.min(1)): each row requiresstore({_id}required, nullable object),item(_idrequired),quantity(required),newQuantity(required,min 0),diff(required),cost(required,min 0);descriptionoptional (max 200). - Line grid columns: Store | Item | Description | In Stock (Base UOM) (read-only, = system
quantity) | New Quantity (Base UOM) (editable; recomputesdiff) | Adjustment (Base UOM) (editablediff; recomputesnewQuantity) | Cost | Total (cost × newQuantity) | delete. A TOTAL footer row sums quantity / newQuantity / diff / cost. Add-row button and a base-UOM helper note. - On store change, the row's item/quantity/cost/newQuantity/diff reset (forces re-pick scoped to the new store).
- Header:
context.tsx(useStockAdjustmentState) — only consumer ofuseStockAdjustmentQuery(). ExposesfetchStockAdjustment,saveStockAdjustment(create-or-update by_id),deleteAdjustment,deleteAdjustmentItem,deleteManyAdjustment, plus list/selection/modal/filter state.model.ts—IStockAdjustment,IStockAdjustmentItem(includesbranchId?+store?: IStorefrom the per-row-store plan),IStockAdjustmentInput,StockAdjustmentTypesenum (QUANTITYonly).gql/query.tsx—stockAdjustmentPage, create/update/delete,deleteManyStockAdjustment,deleteStockAdjustmentItem,findOneStockAdjustment.
8. Dependencies & integrations
stock(StockService) — writes/updates/deletes the single varianceStockrow per line.item(ItemService) — resolves the lineitem; UI readsitem.stockBalance(system qty) anditem.cost(variance valuation seed).store/branch— per-row store selector (ApStoreSelectionInput) scopes items + balances.finance/account(AccountService) — resolves the inventory account + the operator-chosen adjustment account; runsvalidateBalanced.finance/transaction(AccountTransactionService) — posts the two GL legs (kind = StockAdjustment), andgetTransactionTypemaps INCREASE/DECREASE → DEBIT/CREDIT by account side.- No cron / external services / events.
9. Gotchas & project-specific rules
diffdrives everything.newQuantity − quantitydecides direction (IN/OUT), magnitude (|diff|), and GL value (cost × diff). Ifdiff === 0the line still writes a zero-qtyOUTrow (the> 0test is false) — harmless tobalance()but worth knowing.- Two accounts, two legs. Unlike a transfer (both legs hit one inventory account), an adjustment hits the inventory account and a user-chosen offset account, with opposite DR/CR — that's how the variance becomes a real P&L/expense entry.
- Per-row store is authoritative. The header
branchIdis a fallback; the actual stock movement uses each line'sbranchId. One document can reconcile many stores. The UI requires a store per row and resets the row when the store changes. quantityis a snapshot, not live. It is the system on-hand captured at entry (fromitem.stockBalance). If on-hand changes between entry and save, the recordeddiffis relative to the snapshot, not to live stock.- Base UOM only — convert before calling; alternate-UOM fields are rejected.
- No reason taxonomy — only free-text
description/note. There is no enum for shrinkage/damage/found. - Stock count = adjustment. There is no distinct count document;
StockKindTypes.StockCountis unused by this module (it always writeskind = StockAdjustment). - Multi-tenant / soft-delete — header + lines carry
companyId/branchId, usemongoose-delete; soft-deleted rows drop out ofbalance(). See../../platform/multi-tenancy.md.