Stock Transfer — move on-hand quantity between two branches/stores

A stock transfer reduces to one idea: each transfer line writes two Stock ledger rows in the same transaction — an OUT at the source branch and an IN at the destination branch. Net company quantity is unchanged; per-branch on-hand shifts. There is no in-transit / dispatch-then-receive state — both legs are written the moment the line is created.

Source: BE src/modules/inventory/transfer (+ transfer/item) · Admin src/modules/inventory/transfer

See the canonical engine reference zerp-be/docs/inventory-stock-flow.md §4 and the ledger core in ./stock.md. The stock-movement contract used here is shared with purchase/sales; this doc covers the transfer-specific header/line collections and the two-leg posting + GL legs.


1. Purpose & scope

The transfer module relocates on-hand inventory quantity from one branch (fromBranchId) to another (toBranchId). It is the only stock-movement flow that does not reuse the polymorphic Order / OrderItem schema — it has its own StockTransfer / StockTransferItem collections.

What it does:

  • Persists a transfer header + lines (stock_transfers, stock_transfer_items).
  • For each line, writes two Stock rows (kind = StockTransfer): OUT @ fromBranchId, IN @ toBranchId.
  • Posts two balancing inventory AccountTransaction legs (DECREASE + INCREASE) against the ACCOUNT_NAME.INVENTORY account, so the GL stays balanced.
  • Guards the source branch's available quantity before writing.

What it explicitly does NOT do:

  • No in-transit / received state. Both legs commit immediately; there is no draft → dispatched → received lifecycle (transfer.service.ts, transfer/item/item.service.ts). This is called out as an extension point in the engine reference.
  • No multi-UOM. Transfers operate in the item's base UOM only; any uomId / uomQuantity / conversionFactor on the input is rejected (assertBaseUomOnly). Convert before calling.
  • No status enum. StockTransferTypes exists with a single member QUANTITY and is registered for GraphQL but is never stored on the header or used for state.
  • No partial fulfilment / reservation. That belongs to the fulfillment module, which is independent of transfers.

2. Data model

2.1 stock_transfers — transfer header

transfer/transfer.schema.ts (class StockTransfer extends BaseSchema, soft-deleted via mongoose-delete).

field type required description
ref string yes Generated document number (generateRef() in the base repo — sequential uniqueId, optional prefix).
fromBranchId ObjectId yes Source warehouse/store. Receives the OUT legs. Coerced via BaseSchema.toObjectId.
toBranchId ObjectId yes Destination warehouse/store. Receives the IN legs.
note string no Free text. (Note: the GraphQL CommonStockTransferInput.note is nullable: false, so the admin form requires a value in practice.)
items StockTransferItem[] Virtual — populated by $lookup on stock_transfer_items.transferId, not stored on the header.

Plus inherited BaseSchema fields: _id, key, companyId, branchId, documentCode, documentDate, createdAt/By, updatedAt/By, and the resolve-flags canDelete/canUpdate/canView/canPost. documentDate (unix ts) is the business date used for filtering and carried onto every Stock row and GL leg.

// transfer/transfer.schema.ts
export enum StockTransferTypes { QUANTITY = "QUANTITY" }   // registered, never stored

@ApSchema({ collection: "stock_transfers", timestamps: true })
export class StockTransfer extends BaseSchema {
  @Prop({ required: true }) ref: string;
  @Prop({ required: true, set: v => BaseSchema.toObjectId(v) }) fromBranchId: Types.ObjectId;
  @Prop({ required: true, set: v => BaseSchema.toObjectId(v) }) toBranchId: Types.ObjectId;
  @Prop({ required: false }) note: string;
  items: StockTransferItem[];   // virtual ($lookup)
}

// header find/findOne/page always hydrate items via this lookup:
export const $lookupTransferItem = [{
  $lookup: { from: "stock_transfer_items", localField: "_id", foreignField: "transferId", as: "items" }
}];

2.2 stock_transfer_items — transfer line

transfer/item/item.schema.ts (class StockTransferItem extends BaseSchema, soft-deleted).

field type required description
ref string yes Generated line reference.
transferId ObjectId yes Back-ref to the parent stock_transfers._id.
itemId ObjectId yes The product (items._id).
quantity number yes Base UOM only. Drives both stock legs (Math.abs(quantity)) and the GL amount.
branchId ObjectId (set per-leg) Not a stored input — the service swaps it to from/to per leg during posting.
// transfer/item/item.schema.ts
@ApSchema({ collection: "stock_transfer_items", timestamps: true })
export class StockTransferItem extends BaseSchema {
  @Prop({ required: true }) ref: string;
  @Prop({ required: true, set: v => BaseSchema.toObjectId(v) }) transferId: Types.ObjectId;
  @Prop({ required: true, set: v => BaseSchema.toObjectId(v) }) itemId: Types.ObjectId;
  @Prop({ required: true }) quantity: number;   // BASE UOM ONLY
  branchId: Types.ObjectId;                      // assigned per-leg at post time
}

2.3 What a line writes into the Stock ledger

Each line produces two rows in stocks (see ./stock.md for the full Stock shape). They are identical except type and branchId:

Stock field OUT leg (source) IN leg (destination)
kind StockKindTypes.StockTransfer StockKindTypes.StockTransfer
type StockTypes.OUT StockTypes.IN
branchId transfer.fromBranchId transfer.toBranchId
itemId line itemId line itemId
orderId line transferId (reused field) line transferId
orderItemId line _id (reused field) line _id
grossQuantity / netQuantity Math.abs(quantity) Math.abs(quantity)
documentDate line documentDate line documentDate

The transfer reuses Stock.orderId / Stock.orderItemId to point at the transfer / transfer-item. Both legs share the same orderItemId, so deletion (deleteMany({ orderItemId })) removes both at once, and on update the matching leg is found by { orderItemId, type }.

Cost note: when kind === StockTransfer, StockService.create does not stamp avgCost from the item (the guard model.kind !== StockKindTypes.StockTransfer skips it). Cost is carried only into the GL amount (Math.abs(quantity) × item.cost), identical on both legs, so the inventory account nets to zero.


3. API surface

GraphQL — transfer/transfer.resolver.ts (header) + transfer/item/item.resolver.ts (line)

Operation Type Input Returns Permission
createStockTransfer mutation transfer: CreateStockTransferInput StockTransfer @ApGqlAuthorize + audit CREATE
updateStockTransfer mutation _id: String, transfer: UpdateStockTransferInput StockTransfer @ApGqlAuthorize + audit UPDATE
deleteStockTransfer mutation _id: String Boolean @ApGqlAuthorize + audit DELETE
deleteManyStockTransfers mutation ids: [String] Boolean @ApGqlAuthorize + audit DELETE
deleteStockTransferItem mutation _id: String Boolean @ApGqlAuthorize + audit DELETE
findStockTransfer query query: QueryStockTransferInput [StockTransfer] @ApGqlAuthorize
findOneStockTransfer query query: QueryStockTransferInput StockTransfer @ApGqlAuthorize
stockTransferPage query page: StockTransferPageInput StockTransferPageResult @ApGqlAuthorize
findOneStockTransferItem query query: QueryStockTransferItemInput StockTransferItem @ApGqlAuthorize
stockAdjustmentItemPage query page: StockTransferItemPageInput StockTransferItemPageResult @ApGqlAuthorize

⚠️ Naming gotcha: the transfer-item page query is registered as stockAdjustmentItemPage (copy-paste name in transfer/item/item.resolver.ts), and it returns a StockTransferItemPageResult. It is unrelated to the adjustment module.

Resolve-fields (StockTransfer): fromStore and toStoreBranchService.findById(fromBranchId/toBranchId). Resolve-field (StockTransferItem): itemItemService.findById(itemId).

Input shapes (transfer/transfer.dto.ts, transfer/item/item.dto.ts):

input CreateStockTransferInput {
  _id: ID
  fromBranchId: ID!
  toBranchId: ID!
  note: String!
  createdAt: Float
  documentDate: Float
  items: [CreateStockTransferItemInput!]!
}
input CreateStockTransferItemInput { transferId: ID  itemId: ID!  quantity: Float! }
input UpdateStockTransferInput { _id  fromBranchId  toBranchId  note  createdAt  documentDate  items: [UpdateStockTransferItemInput!] }
input UpdateStockTransferItemInput { transferId  itemId  quantity  _id: ID }
input StockTransferPageInput { skip: Float!  take: Float!  branchId: ID  stockId: ID }
input QueryStockTransferInput { _id  fromBranchId  toBranchId  note  createdAt  documentDate }

REST — transfer/transfer.controller.ts

Method Route Query Response
GET api/stock/transfer/download fromDate, toDate, keyword, downloadType=XLSX XLSX report (Ref, From/To Store, Items count, total Quantity, Document Date, Created At). Guarded by @ApiAuthorize.

4. Business rules & calculations

4.1 The two-leg write — StockTransferItemService.create()

The core algorithm, run inside a retry transaction (withRetryTransaction("create_transfer_item")):

  1. assertBaseUomOnly(model) — throw BadRequestException("Stock transfer quantities must be entered in base UOM") if uomId, uomQuantity, or conversionFactor is present.
  2. Availability guardavailableQty = stockSvc.totalQuantity({ branchId: model.branchId, itemId }); if availableQty < model.quantity → throw Error("Insufficient stock"). ⚠️ See §9: totalQuantity here sums all rows' grossQuantity (default arg), it is not a signed IN − OUT balance.
  3. Persist the line (transferItemRepo.create), then load the parent transfer and the item (for cost). amount = Math.abs(quantity) × (item.cost || 0).
  4. LEG 1 — OUT @ source: updateTransferStock({ ...created, branchId: transfer.fromBranchId }, OUT, "add").
  5. LEG 2 — IN @ destination: updateTransferStock({ ...created, branchId: transfer.toBranchId }, IN, "add").
  6. GL leg 1 — DECREASE inventory: updateInventoryAccountTransaction(created, transfer, amount, OUT, "add").
  7. GL leg 2 — INCREASE inventory: updateInventoryAccountTransaction(created, transfer, amount, IN, "add").
  8. If the outer session is the transfer-item session, assert GL balanced (accountSvc.validateBalanced).
// transfer/item/item.service.ts  (trimmed)
public async create(model: StockTransferItem) {
  return this.withRetryTransaction("create_transfer_item", async () => {
    this.assertBaseUomOnly(model);
    const availableQty = await this.stockSvc.totalQuantity({ branchId: model.branchId, itemId: model.itemId });
    if (availableQty < model.quantity) throw new Error("Insufficient stock");

    const created  = await this.transferItemRepo.create(model);
    const transfer = await this.stockTransferSvc.findById(model.transferId?.toString());
    const item     = await this.itemSvc.findById(model.itemId?.toString());
    const amount   = Math.abs(model.quantity) * (item?.cost || 0);

    await this.updateTransferStock({ ...created, branchId: transfer.fromBranchId }, StockTypes.OUT, "add"); // -qty @from
    await this.updateTransferStock({ ...created, branchId: transfer.toBranchId   }, StockTypes.IN,  "add"); // +qty @to
    await this.updateInventoryAccountTransaction(created, transfer, amount, StockTypes.OUT, "add");          // GL DECREASE
    await this.updateInventoryAccountTransaction(created, transfer, amount, StockTypes.IN,  "add");          // GL INCREASE
    return created;
  });
}

updateTransferStock(model, stockType, action) builds the Stock payload (see §2.3 table) and either stockSvc.create(payload) (action add) or finds the existing leg by { orderItemId, type } and stockSvc.update(...) (action update).

4.2 GL side effects — updateInventoryAccountTransaction()

Each leg posts one balancing AccountTransaction against the single inventory account (accountSvc.findOne({ category: ACCOUNT_NAME.INVENTORY })):

  • type = transactionSvc.getTransactionType(inventoryAccountId, stockType === IN ? "INCREASE" : "DECREASE").
  • amount = the line cost amount (same on both legs).
  • refId = transfer._id, ref2Id = transferItem._id, itemId, accountId, documentDate.
  • remark = "Stock transfer <ref> (<IN|OUT>)", status = POSTED, kind = AccountTransactionKind.StockTransfer.

Because both legs hit the same inventory account with equal-and-opposite movements, the GL nets to zero — this is purely a relocation, not a P&L event. validateBalanced enforces this.

4.3 Create / update / delete (header) — StockTransferService

All header operations wrap a retry transaction and assert validateBalanced at the end.

  • create: persist header → transferItemSvc.createMany2(header, items) (loops create() per line, injecting transferId and documentDate) → return header hydrated with its lines.
  • update: update header → transferItemSvc.updateItems(id, items): each item with _idupdate() (re-writes both legs in place via the "update" action and updates the matching GL transactions); each item without _idcreate() (adds new legs). Lines removed from the array are not auto-deleted by updateItems — removal happens via the explicit deleteStockTransferItem mutation.
  • delete (header): transferItemSvc.deleteItems({ transferId }) deletes each line (soft-delete) and, per line, stockSvc.deleteMany({ orderItemId }) (removes both legs) + transactionSvc.deleteMany({ ref2Id }) (removes both GL legs) → then soft-delete the header. On-hand self-corrects because soft-deleted ledger rows drop out of balance().
  • deleteManyStockTransfers: loops delete() per id (each in its own transaction).

4.4 Status / state machine

None. A transfer has no lifecycle states. It is created fully posted (both legs + both GL legs exist immediately) or it does not exist. The StockTransferTypes.QUANTITY enum is vestigial. If a dispatch-then-receive workflow is ever needed, the extension point is to split the two updateTransferStock calls across two events (write OUT on dispatch, IN on receipt) — see engine reference §4.

4.5 Transactionality

Header create/update/delete and each line create/update/delete each run in their own withRetryTransaction. setSession propagates the Mongo session down to stockSvc, accountSvc, transactionSvc, stockTransferSvc, and itemSvc, so a line's two stock rows + two GL legs commit atomically with the line. On any throw the outer service abortSession()s and rethrows.


5. Permissions

Resolvers are class-gated by @ApGqlAuthorize() (JWT + access-group RBAC — see ../../platform/permissions-access.md). The transfer action keys seeded in permission/action/action.service.ts and referenced by the admin under USER_ACCESS.INVENTORY are:

Action key Used for
view-stock-transfers list page access
create-stock-transfer "New Transfer" button (and update)
update-stock-transfer edit
delete-stock-transfer delete / bulk delete
view-stock-transfer-details detail view button

All five live under the inventory permission module (USER_ACCESS.INVENTORY.MODULE = 'inventory'). Every transfer mutation carries @AuditMeta({ module: 'stock-transfer', collection: 'stock_transfers' }) writing snapshots to the audit trail.


6. Flows

6.1 Create a transfer (happy path)

  1. Admin opens Stock Transfer → New Transfer (page.tsx), fills the CreateTransferOrder modal (components/create.tsx): From Store, To Store, Date, and ≥1 item line with a base-UOM quantity.
  2. Client-side guard: if fromBranchId === toBranchId → toast "You can't transfer to the same store" and abort (this is enforced only in the admin; the BE does not re-check same-store).
  3. saveStockTransfer(payload)createStockTransfer mutation → StockTransferResolver.createStockTransferService.create.
  4. Service opens create_stock_transfer transaction: persist header → createMany2 loops each line through StockTransferItemService.create.
  5. Per line: base-UOM assert → availability guard on fromBranchId → persist line → write OUT@from + IN@to Stock rows → post DECREASE + INCREASE inventory GL legs.
  6. validateBalanced passes → commit. Resolver returns the header with hydrated items; context appends it to the list and toasts success.

6.2 Update a transfer

  1. Edit modal pre-fills from the existing transfer. Submit → updateStockTransfer.
  2. Header updated; updateItems re-writes existing lines' two legs in place ("update" action finds each leg by { orderItemId, type }) and updates the two matching GL transactions; brand-new lines are create()d (full two-leg + GL).
  3. To remove a line, the UI calls deleteStockTransferItem(_id)StockTransferItemService.delete → soft-delete line + deleteMany({ orderItemId }) (both stock legs) + deleteMany({ ref2Id }) (both GL legs).

6.3 Unhappy paths

  • Insufficient source stockError("Insufficient stock") thrown in the line service; transaction aborts, nothing is written, error surfaces via toastSvc.graphQlError.
  • Multi-UOM inputBadRequestException("Stock transfer quantities must be entered in base UOM").
  • Same store (admin only) → blocked client-side before the mutation fires.
  • GL imbalancevalidateBalanced throws, the whole transaction rolls back.

7. Admin UI

Route: /stock/transfer (list) and /stock/transfer/[id] (detail). Module: src/modules/inventory/transfer.

  • page.tsx (TransferOrdersPage)ApPageHeader ("Stock Transfer"), ApDurationPicker for date range, ApDownloadButton2 (PDF + XLSX via stock/transfer/download), "New Transfer" button (gated by create-stock-transfer), ApSearchInput, and StockTransferTable. Refetches on filter change via fetchStockTransfer.
  • components/table.tsx (StockTransferTable) — columns: Ref, From Store, To Store, Items (count), Date (documentDate), Quantity (sum of line quantities), Created At. Row actions gated by canUpdate / canDelete; bulk delete via BulkActionToolbar; view-detail link gated by view-stock-transfer-details.
  • components/create.tsx (CreateTransferOrder) — Formik form, TransferSchema (Yup):
    • fromStore, toStore required (ApSelectInputAsync loading stores via storePage); documentDate required; items[].item._id required, items[].quantity positive & required, items[].inStock required.
    • The line grid (ItemsList) shows In Stock (Base UOM) (read-only, pulled from item.stockBalance) next to the editable Quantity (Base UOM), with an "Add Item" row button and a TOTAL footer row.
    • On submit, maps fromStore._id/toStore._idfromBranchId/toBranchId and each row → { _id, itemId, quantity }. The helper text reminds users quantities are in base UOM.
  • context.tsx (useStockTransferState) — the only consumer of useStockTransferQuery(). Exposes fetchStockTransfer, saveStockTransfer (create-or-update by _id), deleteStockTransfer, deleteStockTransferItem, deleteManyStockTransfers, plus list/selection/modal/filter state.
  • gql/query.tsxstockTransferPage, createStockTransfer, updateStockTransfer, deleteStockTransfer, deleteManyStockTransfers, deleteStockTransferItem, findOneStockTransfer.

8. Dependencies & integrations

  • stock (StockService) — the single ledger writer; transfer calls create (per leg), update, findOne, deleteMany, and totalQuantity (availability guard).
  • branch (BranchService) — resolves fromStore / toStore and validates branch ids.
  • item (ItemService) — resolves the line item and reads item.cost for the GL amount.
  • finance/account (AccountService) — locates the INVENTORY account and runs validateBalanced.
  • finance/transaction (AccountTransactionService) — posts/updates/deletes the two inventory GL legs (kind = StockTransfer).
  • No cron, no external services, no events emitted.

9. Gotchas & project-specific rules

  • Availability guard uses grossQuantity, not signed balance. stockSvc.totalQuantity({ branchId, itemId }) defaults to summing grossQuantity across all rows for that branch+item — it is not Σ IN − Σ OUT. The intended "available on-hand" is balance(). Treat this as a known quirk: the guard may over- or under-report true on-hand depending on the row mix. (stock.repository.ts totalQuantity vs balance.)
  • Same-store transfer is only blocked in the admin. The BE will happily write OUT and IN on the same branch (netting to zero) if called directly.
  • Both legs share orderItemId. Deletion and update find both legs by it; this is why both must always be written together.
  • Transfers skip avgCost stamping. StockService.create only back-fills avgCost from the item when kind !== StockTransfer; transfer legs carry no per-row avg cost (cost only flows into the GL amount).
  • note is effectively required (GraphQL note: String! on the common input) despite the schema prop being required: false.
  • stockAdjustmentItemPage is the (mis-named) transfer-item page query — do not confuse with the adjustment module.
  • No in-transit state — see §4.4. This is the single biggest difference from a textbook transfer module.
  • Multi-tenant / soft-delete — header and lines carry companyId/branchId and use mongoose-delete; soft-deleted rows drop out of balance() automatically. See ../../platform/multi-tenancy.md.