Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/config/db.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,6 @@ db.VersionInfo = require('../models/versioninfo.model.js')(sequelize, Sequelize)
db.PurchaseOrderVendor = require('../models/purchaseordervendor.model.js')(sequelize, Sequelize);
db.PurchaseOrderHeader = require('../models/purchaseorderheader.model.js')(sequelize, Sequelize);
db.PurchaseOrderLine = require('../models/purchaseorderline.model.js')(sequelize, Sequelize);
db.InventoryTransaction = require('../models/inventorytransaction.model.js')(sequelize, Sequelize);

module.exports = db;
32 changes: 32 additions & 0 deletions app/config/openapi.js
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,17 @@ const versionInfoSchema = {
},
};

const inventoryTransactionSchema = {
type: 'object',
properties: {
invtId: { type: 'integer', readOnly: true },
invtCompanyId: { type: 'integer' },
invtDirection: { type: 'integer', enum: [0, 1], description: '0 = inbound (received), 1 = outbound (consumed)' },
invtInitId: { type: 'integer', description: 'Inventory item this transaction affects (FK → InventoryItem.invitId)' },
invtArch: { type: 'boolean', readOnly: true },
},
};

const purchaseOrderHeaderSchema = {
type: 'object',
properties: {
Expand Down Expand Up @@ -272,6 +283,7 @@ const spec = {
PurchaseOrderVendor: purchaseOrderVendorSchema,
PurchaseOrderHeader: purchaseOrderHeaderSchema,
PurchaseOrderLine: purchaseOrderLineSchema,
InventoryTransaction: inventoryTransactionSchema,
Error: errorResponse,
},
},
Expand Down Expand Up @@ -781,6 +793,26 @@ const spec = {
responses: { 200: { description: 'OK' }, 400: { description: 'Invalid header id' }, 403: { description: 'Auth failure' } },
},
},
'/v1/inventorytransaction': {
post: { summary: 'Create an inventory transaction', security: [{ authKey: [] }], requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/InventoryTransaction' } } } }, responses: { 201: { description: 'Created' }, 400: { description: 'Bad request' }, 403: { description: 'Auth failure' } } },
},
'/v1/inventorytransaction/{id}': {
get: { summary: 'Get one inventory transaction', security: [{ authKey: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], responses: { 200: { description: 'Found' }, 404: { description: 'Not found' }, 403: { description: 'Auth failure' } } },
patch: { summary: 'Partial update of an inventory transaction (unusual — reversing entries are the production pattern)', security: [{ authKey: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], requestBody: { content: { 'application/json': { schema: { $ref: '#/components/schemas/InventoryTransaction' } } } }, responses: { 200: { description: 'Updated' }, 400: { description: 'No updatable fields supplied' }, 404: { description: 'Not found' }, 403: { description: 'Auth failure' } } },
delete: { summary: 'Soft-delete an inventory transaction', security: [{ authKey: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], responses: { 200: { description: 'Archived' }, 404: { description: 'Not found' }, 403: { description: 'Auth failure' } } },
},
'/v1/inventorytransaction/bycompany/{id}': {
get: {
summary: 'List inventory transactions in a company (paginated, newest first)',
security: [{ authKey: [] }],
parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } },
{ name: 'limit', in: 'query', schema: { type: 'integer', default: 100, maximum: 500 } },
{ name: 'offset', in: 'query', schema: { type: 'integer', default: 0 } },
],
responses: { 200: { description: 'OK' }, 400: { description: 'Invalid company id' }, 403: { description: 'Auth failure' } },
},
},
},
};

Expand Down
220 changes: 220 additions & 0 deletions app/controllers/inventorytransactioncontroller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Aaron K. Clark
"use strict";

/**
* InventoryTransaction controller — direct compId scoping via
* invtCompanyId. Note that PATCH/DELETE on a movement log is unusual
* (production accounting systems prefer reversing entries); we expose
* them for surface parity, but operators may want to disable them at
* the reverse-proxy layer for audit-grade deployments.
*/

const db = require('../config/db.config.js');
const log = require('../config/logger.js');
const auth = require('../middleware/auth.js');
const InventoryTransaction = db.InventoryTransaction;

const IsMaster = auth.isMaster;
const GetCompanyId = auth.getCompanyId;

const ALLOWED_FIELDS_CREATE = ['invtDirection', 'invtInitId', 'invtCompanyId'];
const ALLOWED_FIELDS_UPDATE = ['invtDirection', 'invtInitId'];

exports.create = async (req, res) => {
const authKey = req.get('authKey');
if (!authKey) {
return res.status(403).json({ message: "Authorization key not sent." });
}

let isAuthKeyMasterKey;
try {
isAuthKeyMasterKey = await IsMaster(authKey);
} catch (error) {
log.error({ err: error }, 'IsMaster failed');
return res.status(500).json({ message: "Error!", error: String(error) });
}

const body = req.body || {};
const payload = {};
for (const f of ALLOWED_FIELDS_CREATE) {
if (body[f] !== undefined) payload[f] = body[f];
}

if (!isAuthKeyMasterKey) {
const authKeyCompanyId = await GetCompanyId(authKey);
if (authKeyCompanyId === -1) {
return res.status(403).json({ message: "Invalid Authorization Key." });
}
if (payload.invtCompanyId !== undefined && Number(payload.invtCompanyId) !== authKeyCompanyId) {
return res.status(403).json({
message: "Cannot create an inventory transaction for a company you do not belong to.",
});
}
payload.invtCompanyId = authKeyCompanyId;
} else {
if (payload.invtCompanyId === undefined || Number(payload.invtCompanyId) <= 0) {
return res.status(400).json({
message: "Master-key requests must specify invtCompanyId.",
});
}
}

payload.invtArch = false;

try {
const created = await InventoryTransaction.create(payload);
return res.status(201).json({ message: "Inventory transaction created.", inventoryTransaction: created });
} catch (error) {
log.error({ err: error }, 'InventoryTransaction.create failed');
return res.status(500).json({ message: "Error!", error: String(error) });
}
};

exports.getById = async (req, res) => {
const authKey = req.get('authKey');
if (!authKey) {
return res.status(403).json({ message: "Authorization key not sent." });
}

let txn;
try {
txn = await InventoryTransaction.findByPk(req.params.id);
} catch (error) {
log.error({ err: error }, 'InventoryTransaction.findByPk failed');
return res.status(500).json({ message: "Error!", error: String(error) });
}
if (!txn || txn.invtArch) {
return res.status(404).json({ message: "Not found." });
}

const isMaster = await IsMaster(authKey);
if (!isMaster) {
const companyId = await GetCompanyId(authKey);
if (companyId === -1 || txn.invtCompanyId !== companyId) {
return res.status(403).json({ message: "Invalid Authorization Key." });
}
}
return res.status(200).json({ message: "Found.", inventoryTransaction: txn });
};

exports.listByCompany = async (req, res) => {
const authKey = req.get('authKey');
if (!authKey) {
return res.status(403).json({ message: "Authorization key not sent." });
}

const targetCompanyId = Number(req.params.id);
if (!Number.isInteger(targetCompanyId) || targetCompanyId <= 0) {
return res.status(400).json({ message: "Invalid company id." });
}

const isMaster = await IsMaster(authKey);
if (!isMaster) {
const companyId = await GetCompanyId(authKey);
if (companyId === -1 || companyId !== targetCompanyId) {
return res.status(403).json({ message: "Invalid Authorization Key." });
}
}

const requestedLimit = parseInt(req.query.limit, 10);
const limit = Number.isInteger(requestedLimit) && requestedLimit > 0
? Math.min(requestedLimit, 500) : 100;
const requestedOffset = parseInt(req.query.offset, 10);
const offset = Number.isInteger(requestedOffset) && requestedOffset >= 0
? requestedOffset : 0;

try {
const { count, rows } = await InventoryTransaction.findAndCountAll({
where: { invtCompanyId: targetCompanyId, invtArch: false },
limit, offset,
order: [['invtId', 'DESC']],
});
return res.status(200).json({
message: "Successfully retrieved inventory transactions with CompanyId " + targetCompanyId,
count, limit, offset, inventoryTransactions: rows,
});
} catch (error) {
log.error({ err: error }, 'InventoryTransaction.findAndCountAll failed');
return res.status(500).json({ message: "Error!", error: String(error) });
}
};

exports.update = async (req, res) => {
const authKey = req.get('authKey');
if (!authKey) {
return res.status(403).json({ message: "Authorization key not sent." });
}

let txn;
try {
txn = await InventoryTransaction.findByPk(req.params.id);
} catch (error) {
log.error({ err: error }, 'InventoryTransaction.findByPk failed');
return res.status(500).json({ message: "Error!", error: String(error) });
}
if (!txn || txn.invtArch) {
return res.status(404).json({ message: "Not found." });
}

const isMaster = await IsMaster(authKey);
if (!isMaster) {
const companyId = await GetCompanyId(authKey);
if (companyId === -1 || txn.invtCompanyId !== companyId) {
return res.status(403).json({ message: "Invalid Authorization Key." });
}
}

const body = req.body || {};
const updates = {};
for (const f of ALLOWED_FIELDS_UPDATE) {
if (body[f] !== undefined) updates[f] = body[f];
}
if (Object.keys(updates).length === 0) {
return res.status(400).json({ message: "No updatable fields supplied." });
}

try {
await txn.update(updates);
return res.status(200).json({ message: "Updated.", inventoryTransaction: txn });
} catch (error) {
log.error({ err: error }, 'InventoryTransaction.update failed');
return res.status(500).json({ message: "Error!", error: String(error) });
}
};

exports.remove = async (req, res) => {
const authKey = req.get('authKey');
if (!authKey) {
return res.status(403).json({ message: "Authorization key not sent." });
}

let txn;
try {
txn = await InventoryTransaction.findByPk(req.params.id);
} catch (error) {
log.error({ err: error }, 'InventoryTransaction.findByPk failed');
return res.status(500).json({ message: "Error!", error: String(error) });
}
if (!txn || txn.invtArch) {
return res.status(404).json({ message: "Not found." });
}

const isMaster = await IsMaster(authKey);
if (!isMaster) {
const companyId = await GetCompanyId(authKey);
if (companyId === -1 || txn.invtCompanyId !== companyId) {
return res.status(403).json({ message: "Invalid Authorization Key." });
}
}

try {
await txn.update({ invtArch: true });
return res.status(200).json({ message: "Archived.", id: txn.invtId });
} catch (error) {
log.error({ err: error }, 'InventoryTransaction archive failed');
return res.status(500).json({ message: "Error!", error: String(error) });
}
};

exports._internals = { IsMaster, GetCompanyId };
33 changes: 33 additions & 0 deletions app/models/inventorytransaction.model.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Aaron K. Clark
"use strict";

/**
* InventoryTransaction — movement log entry against an InventoryItem.
* `invtDirection`: 0 = inbound (received), 1 = outbound (consumed /
* sold). Direct company scoping via `invtCompanyId`.
*
* Note on column naming: the FK to InventoryItem.invitId is stored
* as `invtInitId` in the migration (BACPAC's "init" prefix carried
* through). We match what the migration creates rather than
* renaming.
*/
module.exports = (sequelize, Sequelize) => {
const InventoryTransaction = sequelize.define('InventoryTransaction', {
invtId: {
field: 'invtId',
type: Sequelize.INTEGER,
autoIncrement: true,
primaryKey: true,
},
invtCompanyId: { field: 'invtCompanyId', type: Sequelize.INTEGER, allowNull: false },
invtDirection: { field: 'invtDirection', type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
invtArch: { field: 'invtArch', type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false },
invtInitId: { field: 'invtInitId', type: Sequelize.INTEGER, allowNull: false },
}, {
tableName: 'InventoryTransactions',
timestamps: false,
});

return InventoryTransaction;
};
31 changes: 31 additions & 0 deletions app/routers/router.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const versionInfo = require('../controllers/versioninfocontroller.js');
const purchaseOrderVendor = require('../controllers/purchaseordervendorcontroller.js');
const purchaseOrderHeader = require('../controllers/purchaseorderheadercontroller.js');
const purchaseOrderLine = require('../controllers/purchaseorderlinecontroller.js');
const inventoryTransaction = require('../controllers/inventorytransactioncontroller.js');
const openapiSpec = require('../config/openapi.js');
const v = require('../middleware/validate.js');
const customerSchemas = require('../schemas/customer.schema.js');
Expand All @@ -38,6 +39,7 @@ const versionInfoSchemas = require('../schemas/versioninfo.schema.js');
const purchaseOrderVendorSchemas = require('../schemas/purchaseordervendor.schema.js');
const purchaseOrderHeaderSchemas = require('../schemas/purchaseorderheader.schema.js');
const purchaseOrderLineSchemas = require('../schemas/purchaseorderline.schema.js');
const inventoryTransactionSchemas = require('../schemas/inventorytransaction.schema.js');

// Health / readiness probe. No auth required — only exposes liveness
// of the API process and reachability of the database.
Expand Down Expand Up @@ -475,4 +477,33 @@ router.delete(
purchaseOrderLine.remove,
);

// v1 inventorytransaction routes. Direct compId scoping via invtCompanyId.
router.post(
'/v1/inventorytransaction',
v.body(inventoryTransactionSchemas.createBody),
inventoryTransaction.create,
);
router.get(
'/v1/inventorytransaction/bycompany/:id',
v.params(inventoryTransactionSchemas.intIdParam),
v.query(inventoryTransactionSchemas.listByCompanyQuery),
inventoryTransaction.listByCompany,
);
router.get(
'/v1/inventorytransaction/:id',
v.params(inventoryTransactionSchemas.intIdParam),
inventoryTransaction.getById,
);
router.patch(
'/v1/inventorytransaction/:id',
v.params(inventoryTransactionSchemas.intIdParam),
v.body(inventoryTransactionSchemas.updateBody),
inventoryTransaction.update,
);
router.delete(
'/v1/inventorytransaction/:id',
v.params(inventoryTransactionSchemas.intIdParam),
inventoryTransaction.remove,
);

module.exports = router;
Loading
Loading