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 variance diff = newQuantity − quantity becomes a single Stock row — IN if you found more, OUT if 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 − quantity and writes one Stock row (kind = StockAdjustment): IN when diff > 0, OUT when diff < 0, of magnitude |diff|.
  • Posts two balancing AccountTransaction legs valuing the variance at cost × 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 resulting Stock row are scoped to that store.

What it explicitly does NOT do:

  • No multi-UOM. Quantities are the item's base UOM only; uomId / uomQuantity / conversionFactor on 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.md for the approval-threshold module.)
  • No reason enum. The only "reason" capture is the free-text description per line + note on the header. StockAdjustmentTypes has a single member QUANTITY (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 newQuantity against the system quantity. StockKindTypes.StockCount exists in the ledger enum but this module always writes kind = 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.orderItemId to point at the adjustment / adjustment-item. On update the matching row is found by { orderItemId }; on delete deleteMany({ orderItemId }). Edge case: when diff === 0, type is OUT (the > 0 test is false) and netQuantity = 0 — a zero-qty OUT row, which is a no-op on balance().


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 StockAdjustmentResolver has @ApGqlAuthorize() applied per-method (not at class level), unlike the transfer resolver which is class-gated.

Resolve-field (StockAdjustment): accountAccountService.findById(accountId). Resolve-field (StockAdjustmentItem): itemItemService.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 Stock row uses type = diff > 0 ? IN : OUT and netQuantity = |diff| (so on-hand moves toward the counted figure), and stores cost = the signed cost × diff.

Worked example (one line, store A):

  • System quantity = 100, counted newQuantity = 92, unitCost = 5.
  • diff = 92 − 100 = −8 → write Stock(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:

  1. Inventory leg — updateItemTypeAccount() against accountSvc.findOne({ category: ACCOUNT_NAME.INVENTORY }):
    • type = getTransactionType(inventoryAccountId, item.diff < 0 ? "DECREASE" : "INCREASE") (getTransactionType maps 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.
  2. Offset leg — updateAccountTransaction() against the operator-chosen adjustment.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 branchId from context → persist header → adjustmentItemSvc.createMany2(header, items) (loops create() per line) → return header hydrated with lines.
  • update: update header → updateMany2(header, items): each item with _idupdate() (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 leave balance()).
  • 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)

  1. Admin opens Stock Adjustments → New Adjustment (page.tsx) → CreateStockAdjustment modal (components/create.tsx). Header: Adjustment Type (only QUANTITY), Adjustment Account, Date.
  2. Per item row: pick a Store (ApStoreSelectionInput) → pick an Item (ApItemSelection filtered by that store's storeId). On item select the form seeds quantity = item.stockBalance (the system on-hand for that store), cost = item.cost, newQuantity = 0, diff = 0.
  3. The operator enters the counted figure either by editing New Quantity (which recomputes diff = newQuantity − quantity) or by editing Adjustment diff directly (which recomputes newQuantity = quantity + diff). The two fields are kept in sync.
  4. Submit → saveStockAdjustment maps each row to { itemId, branchId: store._id, cost, quantity, newQuantity, description }createStockAdjustmentStockAdjustmentService.create.
  5. Per line: base-UOM assert → default store → persist line → compute diff/cost → write the single Stock(IN|OUT) row at that store → post inventory + offset GL legs.
  6. validateBalanced passes → commit. Context prepends the new adjustment, toasts success.

6.2 Update an adjustment

  1. Edit modal pre-fills from the saved adjustment (re-seeding quantity from current item.stockBalance).
  2. updateStockAdjustment → header update → existing lines re-write their stock row + both GL legs in place ("update"), new lines create(). Removing a line uses deleteStockAdjustmentItem(_id) (soft-delete line + remove its stock row + GL legs).

6.3 Unhappy paths

  • Multi-UOM inputBadRequestException("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 itemId and accountId.
  • GL imbalancevalidateBalanced throws, 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 a StockAdjustmentTypes value), account (required, ApAccountSelection), documentDate (required).
    • Items array (.min(1)): each row requires store ({_id} required, nullable object), item (_id required), quantity (required), newQuantity (required, min 0), diff (required), cost (required, min 0); description optional (max 200).
    • Line grid columns: Store | Item | Description | In Stock (Base UOM) (read-only, = system quantity) | New Quantity (Base UOM) (editable; recomputes diff) | Adjustment (Base UOM) (editable diff; recomputes newQuantity) | 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).
  • context.tsx (useStockAdjustmentState) — only consumer of useStockAdjustmentQuery(). Exposes fetchStockAdjustment, saveStockAdjustment (create-or-update by _id), deleteAdjustment, deleteAdjustmentItem, deleteManyAdjustment, plus list/selection/modal/filter state.
  • model.tsIStockAdjustment, IStockAdjustmentItem (includes branchId? + store?: IStore from the per-row-store plan), IStockAdjustmentInput, StockAdjustmentTypes enum (QUANTITY only).
  • gql/query.tsxstockAdjustmentPage, create/update/delete, deleteManyStockAdjustment, deleteStockAdjustmentItem, findOneStockAdjustment.

8. Dependencies & integrations

  • stock (StockService) — writes/updates/deletes the single variance Stock row per line.
  • item (ItemService) — resolves the line item; UI reads item.stockBalance (system qty) and item.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; runs validateBalanced.
  • finance/transaction (AccountTransactionService) — posts the two GL legs (kind = StockAdjustment), and getTransactionType maps INCREASE/DECREASE → DEBIT/CREDIT by account side.
  • No cron / external services / events.

9. Gotchas & project-specific rules

  • diff drives everything. newQuantity − quantity decides direction (IN/OUT), magnitude (|diff|), and GL value (cost × diff). If diff === 0 the line still writes a zero-qty OUT row (the > 0 test is false) — harmless to balance() 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 branchId is a fallback; the actual stock movement uses each line's branchId. One document can reconcile many stores. The UI requires a store per row and resets the row when the store changes.
  • quantity is a snapshot, not live. It is the system on-hand captured at entry (from item.stockBalance). If on-hand changes between entry and save, the recorded diff is 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.StockCount is unused by this module (it always writes kind = StockAdjustment).
  • Multi-tenant / soft-delete — header + lines carry companyId/branchId, use mongoose-delete; soft-deleted rows drop out of balance(). See ../../platform/multi-tenancy.md.