ERP software handles money, stock, and approvals, so small bugs get expensive fast. A stock count that drifts by a few units or a rounding error in an invoice can take days to trace.
Off-the-shelf ERPs cover the general case well, but many companies eventually need a custom module for a workflow the big platforms handle awkwardly.
The MERN stack works well for this. You get flexible schemas for evolving business rules, one language across the whole stack, and real multi-document transactions in MongoDB.
This guide walks through a small purchasing and inventory module and focuses on 3 things: data modeling, consistent writes, and audit trails.
The Example Module
The module has suppliers, products, purchase orders (POs) with line items, and stock levels per warehouse.
The critical operation is receiving goods: the system must update PO line quantities, change the PO status, and increase stock, all at once. If only half of that happens, the numbers stop matching reality.
Data Modeling: Embed What Belongs Together
A simple rule works well for ERP data. Embed things that live and die with the parent, like PO line items.
Reference things with their own lifecycle, like suppliers and products.
const { Schema } = require('mongoose');
const lineItemSchema = new Schema({
product: { type: Schema.Types.ObjectId, ref: 'Product', required: true },
sku: { type: String, required: true }, // snapshot at order time
unitPriceCents: { type: Number, required: true, min: 0 },
quantityOrdered: { type: Number, required: true, min: 1 },
quantityReceived: { type: Number, default: 0, min: 0 },
});
const purchaseOrderSchema = new Schema({
number: { type: String, required: true, unique: true },
supplier: { type: Schema.Types.ObjectId, ref: 'Supplier', required: true },
warehouse: { type: Schema.Types.ObjectId, ref: 'Warehouse', required: true },
status: {
type: String,
enum: ['draft', 'approved', 'partially_received', 'received', 'cancelled'],
default: 'draft',
},
lines: [lineItemSchema],
}, { timestamps: true, optimisticConcurrency: true });
purchaseOrderSchema.index({ supplier: 1, status: 1, createdAt: -1 });
const stockLevelSchema = new Schema({
product: { type: Schema.Types.ObjectId, ref: 'Product', required: true },
warehouse: { type: Schema.Types.ObjectId, ref: 'Warehouse', required: true },
onHand: { type: Number, default: 0, min: 0 },
});
stockLevelSchema.index({ product: 1, warehouse: 1 }, { unique: true });
A few choices here are worth copying:
- Snapshot fixed values. SKU and price are copied into each line, so old orders keep what was actually agreed even if the product changes later.
-
Store money as integers. JavaScript floats can't represent 0.1 exactly. Cents avoid a whole category of rounding bugs. If you need fractional cents, use
Decimal128. - Turn on optimistic concurrency. If 2 people edit the same PO at once, the second save fails instead of silently overwriting the first.
- Use a unique compound index on stock. One row per product per warehouse, guaranteed by the database.
It also pays to design indexes around the questions people will actually ask.
Procurement teams usually want "open orders from supplier X, newest first", and the compound index on supplier, status, and createdAt answers that without a collection scan.
Look at the reports and list screens in your mockups before writing schemas, and add indexes for them from day one.
Transactions: Keep Related Writes Consistent
Receiving goods touches 2 collections. Without a transaction, a crash between the 2 writes leaves you with phantom inventory.
MongoDB transactions need a replica set; locally, a single-node one (mongod --replSet rs0) is enough.
The easiest API is session.withTransaction(), which commits for you and retries on transient errors:
async function receiveGoods({ poId, receipts }) {
const session = await mongoose.startSession();
try {
await session.withTransaction(async () => {
const po = await PurchaseOrder.findById(poId).session(session);
if (!po || !['approved', 'partially_received'].includes(po.status)) {
throw new Error('Order cannot receive goods');
}
for (const { lineId, quantity } of receipts) {
const line = po.lines.id(lineId);
if (line.quantityReceived + quantity > line.quantityOrdered) {
throw new Error(`Over-receipt on ${line.sku}`);
}
line.quantityReceived += quantity;
await StockLevel.updateOne(
{ product: line.product, warehouse: po.warehouse },
{ $inc: { onHand: quantity } },
{ upsert: true, session }
);
}
const done = po.lines.every(l => l.quantityReceived === l.quantityOrdered);
po.status = done ? 'received' : 'partially_received';
await po.save({ session });
});
} finally {
await session.endSession();
}
}
3 rules keep this safe.
1) Pass the session to every read and write.
2) Keep side effects like emails or payment calls out of the callback, since withTransaction may run it more than once.
3) Keep transactions short to avoid lock contention.
For side effects that must happen, the outbox pattern works nicely. Inside the transaction, insert a record like { type: 'po.received', poId } into an outbox collection.
A background worker picks these up after the commit and sends the email or notifies the accounting system.
If the transaction rolls back, the outbox record disappears too, so nothing gets sent for a change that never happened.
Retry semantics and write conflicts rarely show up in tutorials, which is why companies building finance-adjacent modules often look for MERN stack developers who have already debugged these issues under real load.
Warehouse networks are also flaky, so give each receipt an idempotency key, store it under a unique index inside the same transaction, and duplicate requests will fail harmlessly.
Audit Trails: Who Changed What, and When
Auditors will ask "who approved this order?" and "why did the price change?". A Mongoose plugin combined with AsyncLocalStorage answers these automatically:
const { AsyncLocalStorage } = require('node:async_hooks');
const get = require('lodash/get');
const auditContext = new AsyncLocalStorage();
// Express middleware, placed after authentication
function withAuditContext(req, res, next) {
auditContext.run({ userId: req.user.id, requestId: req.id }, next);
}
function auditPlugin(schema, { entity }) {
schema.post('init', function () {
this.$locals.before = this.toObject({ depopulate: true });
});
schema.pre('save', async function () {
const ctx = auditContext.getStore() || {};
const changes = Object.fromEntries(this.directModifiedPaths().map(p => [
p, { from: get(this.$locals.before, p), to: this.get(p) },
]));
await AuditLog.create([{
entity,
entityId: this._id,
action: this.isNew ? 'create' : 'update',
changes,
userId: ctx.userId,
requestId: ctx.requestId,
}], { session: this.$session() });
});
}
purchaseOrderSchema.plugin(auditPlugin, { entity: 'PurchaseOrder' });
The key detail is this.$session(). The audit entry joins the same transaction as the change it describes, so a rollback removes both.
Small details like this are easy to miss, and a review from experienced MERN developers at this stage usually costs far less than cleaning up inconsistent audit data after a compliance check.
2 more things to handle. Query-level updates like the $inc on stock skip save hooks, so for inventory use a StockMovement ledger with one row per movement and treat onHand as a cached total.
The ledger also lets you rebuild stock for any date, which accountants love at month-end. And make the log append-only by giving the app's database user only insert and find rights on the audit collection.
On the React side, a simple timeline under each record showing who changed which field and when goes a long way.
Once users can see the history themselves, many "who changed this?" support tickets simply stop coming.
Testing the Hard Parts
mongodb-memory-server includes MongoMemoryReplSet, so transactions work in CI without a real cluster. Focus your integration tests on the failure paths:
- 2 concurrent receipts on the same PO, where one should fail on the version check.
- A thrown error halfway through
receiveGoods, after which stock, PO and audit rows should all be unchanged. - The same request sent twice with one idempotency key, which should change stock only once.
Wrapping Up
Embed data that belongs to its parent, store money as integers, and index for the reports people actually use.
Wrap related writes in withTransaction, move side effects to an outbox and write audit entries inside the same transaction.
Get these right, and the rest of the module becomes ordinary CRUD work, which is exactly how ERP code should feel.
Top comments (1)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.