Stock Transfer — move on-hand quantity between two branches/stores
A stock transfer reduces to one idea: each transfer line writes two
Stockledger rows in the same transaction — anOUTat the source branch and anINat 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
Stockrows (kind = StockTransfer):OUT @ fromBranchId,IN @ toBranchId. - Posts two balancing inventory
AccountTransactionlegs (DECREASE + INCREASE) against theACCOUNT_NAME.INVENTORYaccount, 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/conversionFactoron the input is rejected (assertBaseUomOnly). Convert before calling. - No status enum.
StockTransferTypesexists with a single memberQUANTITYand is registered for GraphQL but is never stored on the header or used for state. - No partial fulfilment / reservation. That belongs to the
fulfillmentmodule, 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.orderItemIdto point at the transfer / transfer-item. Both legs share the sameorderItemId, 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 intransfer/item/item.resolver.ts), and it returns aStockTransferItemPageResult. It is unrelated to the adjustment module.
Resolve-fields (StockTransfer): fromStore and toStore → BranchService.findById(fromBranchId/toBranchId). Resolve-field (StockTransferItem): item → ItemService.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")):
assertBaseUomOnly(model)— throwBadRequestException("Stock transfer quantities must be entered in base UOM")ifuomId,uomQuantity, orconversionFactoris present.- Availability guard —
availableQty = stockSvc.totalQuantity({ branchId: model.branchId, itemId }); ifavailableQty < model.quantity→ throwError("Insufficient stock"). ⚠️ See §9:totalQuantityhere sums all rows'grossQuantity(default arg), it is not a signedIN − OUTbalance. - Persist the line (
transferItemRepo.create), then load the parenttransferand theitem(for cost).amount = Math.abs(quantity) × (item.cost || 0). - LEG 1 — OUT @ source:
updateTransferStock({ ...created, branchId: transfer.fromBranchId }, OUT, "add"). - LEG 2 — IN @ destination:
updateTransferStock({ ...created, branchId: transfer.toBranchId }, IN, "add"). - GL leg 1 — DECREASE inventory:
updateInventoryAccountTransaction(created, transfer, amount, OUT, "add"). - GL leg 2 — INCREASE inventory:
updateInventoryAccountTransaction(created, transfer, amount, IN, "add"). - 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)(loopscreate()per line, injectingtransferIdanddocumentDate) → return header hydrated with its lines. - update: update header →
transferItemSvc.updateItems(id, items): each item with_id→update()(re-writes both legs in place via the"update"action and updates the matching GL transactions); each item without_id→create()(adds new legs). Lines removed from the array are not auto-deleted byupdateItems— removal happens via the explicitdeleteStockTransferItemmutation. - 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 ofbalance(). - 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)
- Admin opens Stock Transfer → New Transfer (
page.tsx), fills theCreateTransferOrdermodal (components/create.tsx): From Store, To Store, Date, and ≥1 item line with a base-UOM quantity. - 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). saveStockTransfer(payload)→createStockTransfermutation →StockTransferResolver.create→StockTransferService.create.- Service opens
create_stock_transfertransaction: persist header →createMany2loops each line throughStockTransferItemService.create. - Per line: base-UOM assert → availability guard on
fromBranchId→ persist line → write OUT@from + IN@toStockrows → post DECREASE + INCREASE inventory GL legs. validateBalancedpasses → commit. Resolver returns the header with hydrated items; context appends it to the list and toasts success.
6.2 Update a transfer
- Edit modal pre-fills from the existing transfer. Submit →
updateStockTransfer. - Header updated;
updateItemsre-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 arecreate()d (full two-leg + GL). - 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 stock →
Error("Insufficient stock")thrown in the line service; transaction aborts, nothing is written, error surfaces viatoastSvc.graphQlError. - Multi-UOM input →
BadRequestException("Stock transfer quantities must be entered in base UOM"). - Same store (admin only) → blocked client-side before the mutation fires.
- GL imbalance →
validateBalancedthrows, 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"),ApDurationPickerfor date range,ApDownloadButton2(PDF + XLSX viastock/transfer/download), "New Transfer" button (gated bycreate-stock-transfer),ApSearchInput, andStockTransferTable. Refetches onfilterchange viafetchStockTransfer.components/table.tsx(StockTransferTable) — columns: Ref, From Store, To Store, Items (count), Date (documentDate), Quantity (sum of line quantities), Created At. Row actions gated bycanUpdate/canDelete; bulk delete viaBulkActionToolbar; view-detail link gated byview-stock-transfer-details.components/create.tsx(CreateTransferOrder) — Formik form,TransferSchema(Yup):fromStore,toStorerequired (ApSelectInputAsyncloading stores viastorePage);documentDaterequired;items[].item._idrequired,items[].quantitypositive & required,items[].inStockrequired.- The line grid (
ItemsList) shows In Stock (Base UOM) (read-only, pulled fromitem.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._id→fromBranchId/toBranchIdand each row →{ _id, itemId, quantity }. The helper text reminds users quantities are in base UOM.
context.tsx(useStockTransferState) — the only consumer ofuseStockTransferQuery(). ExposesfetchStockTransfer,saveStockTransfer(create-or-update by_id),deleteStockTransfer,deleteStockTransferItem,deleteManyStockTransfers, plus list/selection/modal/filter state.gql/query.tsx—stockTransferPage,createStockTransfer,updateStockTransfer,deleteStockTransfer,deleteManyStockTransfers,deleteStockTransferItem,findOneStockTransfer.
8. Dependencies & integrations
stock(StockService) — the single ledger writer; transfer callscreate(per leg),update,findOne,deleteMany, andtotalQuantity(availability guard).branch(BranchService) — resolvesfromStore/toStoreand validates branch ids.item(ItemService) — resolves the lineitemand readsitem.costfor the GL amount.finance/account(AccountService) — locates theINVENTORYaccount and runsvalidateBalanced.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 summinggrossQuantityacross all rows for that branch+item — it is notΣ IN − Σ OUT. The intended "available on-hand" isbalance(). Treat this as a known quirk: the guard may over- or under-report true on-hand depending on the row mix. (stock.repository.tstotalQuantityvsbalance.) - 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
avgCoststamping.StockService.createonly back-fillsavgCostfrom the item whenkind !== StockTransfer; transfer legs carry no per-row avg cost (cost only flows into the GL amount). noteis effectively required (GraphQLnote: String!on the common input) despite the schema prop beingrequired: false.stockAdjustmentItemPageis 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/branchIdand usemongoose-delete; soft-deleted rows drop out ofbalance()automatically. See../../platform/multi-tenancy.md.