diff --git a/src/database/schemas/finance/invoice.schema.js b/src/database/schemas/finance/invoice.schema.js new file mode 100644 index 0000000..90627ee --- /dev/null +++ b/src/database/schemas/finance/invoice.schema.js @@ -0,0 +1,98 @@ +import mongoose from 'mongoose'; +import { generateId } from '../../utils.js'; +const { Schema } = mongoose; +import { aggregateRollups, aggregateRollupsHistory } from '../../database.js'; + +const invoiceSchema = new Schema( + { + _reference: { type: String, default: () => generateId()() }, + totalAmount: { type: Number, required: true, default: 0 }, + totalAmountWithTax: { type: Number, required: true, default: 0 }, + shippingAmount: { type: Number, required: true, default: 0 }, + shippingAmountWithTax: { type: Number, required: true, default: 0 }, + grandTotalAmount: { type: Number, required: true, default: 0 }, + totalTaxAmount: { type: Number, required: true, default: 0 }, + timestamp: { type: Date, default: Date.now }, + invoiceDate: { type: Date, required: false }, + dueDate: { type: Date, required: false }, + vendor: { type: Schema.Types.ObjectId, ref: 'vendor', required: false }, + customer: { type: Schema.Types.ObjectId, ref: 'customer', required: false }, + invoiceType: { type: String, required: true, default: 'sales', enum: ['sales', 'purchase'] }, + relatedOrderType: { type: String, required: false }, + relatedOrder: { type: Schema.Types.ObjectId, refPath: 'relatedOrderType', required: false }, + state: { + type: { type: String, required: true, default: 'draft' }, + }, + sentAt: { type: Date, required: false }, + paidAt: { type: Date, required: false }, + cancelledAt: { type: Date, required: false }, + overdueAt: { type: Date, required: false }, + }, + { timestamps: true } +); + +const rollupConfigs = [ + { + name: 'draft', + filter: { 'state.type': 'draft' }, + rollups: [{ name: 'draft', property: 'state.type', operation: 'count' }], + }, + { + name: 'sent', + filter: { 'state.type': 'sent' }, + rollups: [{ name: 'sent', property: 'state.type', operation: 'count' }], + }, + { + name: 'partiallyPaid', + filter: { 'state.type': 'partiallyPaid' }, + rollups: [{ name: 'partiallyPaid', property: 'state.type', operation: 'count' }], + }, + { + name: 'paid', + filter: { 'state.type': 'paid' }, + rollups: [{ name: 'paid', property: 'state.type', operation: 'count' }], + }, + { + name: 'overdue', + filter: { 'state.type': 'overdue' }, + rollups: [{ name: 'overdue', property: 'state.type', operation: 'count' }], + }, + { + name: 'cancelled', + filter: { 'state.type': 'cancelled' }, + rollups: [{ name: 'cancelled', property: 'state.type', operation: 'count' }], + }, +]; + +invoiceSchema.statics.stats = async function () { + const results = await aggregateRollups({ + model: this, + rollupConfigs: rollupConfigs, + }); + + // Transform the results to match the expected format + return results; +}; + +invoiceSchema.statics.history = async function (from, to) { + const results = await aggregateRollupsHistory({ + model: this, + startDate: from, + endDate: to, + rollupConfigs: rollupConfigs, + }); + + // Return time-series data array + return results; +}; + +// Add virtual id getter +invoiceSchema.virtual('id').get(function () { + return this._id; +}); + +// Configure JSON serialization to include virtuals +invoiceSchema.set('toJSON', { virtuals: true }); + +// Create and export the model +export const invoiceModel = mongoose.model('invoice', invoiceSchema); diff --git a/src/index.js b/src/index.js index 32e2ff4..5e34145 100644 --- a/src/index.js +++ b/src/index.js @@ -37,6 +37,7 @@ import { courierServiceRoutes, taxRateRoutes, taxRecordRoutes, + invoiceRoutes, } from './routes/index.js'; import path from 'path'; import * as fs from 'fs'; @@ -139,6 +140,7 @@ app.use('/couriers', courierRoutes); app.use('/courierservices', courierServiceRoutes); app.use('/taxrates', taxRateRoutes); app.use('/taxrecords', taxRecordRoutes); +app.use('/invoices', invoiceRoutes); app.use('/notes', noteRoutes); // Start the application diff --git a/src/routes/finance/invoices.js b/src/routes/finance/invoices.js new file mode 100644 index 0000000..9edd3cc --- /dev/null +++ b/src/routes/finance/invoices.js @@ -0,0 +1,99 @@ +import express from 'express'; +import { isAuthenticated } from '../../keycloak.js'; +import { getFilter, convertPropertiesString } from '../../utils.js'; + +const router = express.Router(); +import { + listInvoicesRouteHandler, + getInvoiceRouteHandler, + editInvoiceRouteHandler, + editMultipleInvoicesRouteHandler, + newInvoiceRouteHandler, + deleteInvoiceRouteHandler, + listInvoicesByPropertiesRouteHandler, + getInvoiceStatsRouteHandler, + getInvoiceHistoryRouteHandler, + sendInvoiceRouteHandler, + markInvoicePaidRouteHandler, + cancelInvoiceRouteHandler, +} from '../../services/finance/invoices.js'; + +// list of invoices +router.get('/', isAuthenticated, (req, res) => { + const { page, limit, property, search, sort, order } = req.query; + const allowedFilters = [ + 'vendor', + 'customer', + 'invoiceType', + 'state', + 'value', + 'vendor._id', + 'customer._id', + ]; + const filter = getFilter(req.query, allowedFilters); + listInvoicesRouteHandler(req, res, page, limit, property, filter, search, sort, order); +}); + +router.get('/properties', isAuthenticated, (req, res) => { + let properties = convertPropertiesString(req.query.properties); + const allowedFilters = [ + 'vendor', + 'customer', + 'invoiceType', + 'state.type', + 'value', + 'vendor._id', + 'customer._id', + ]; + const filter = getFilter(req.query, allowedFilters, false); + var masterFilter = {}; + if (req.query.masterFilter) { + masterFilter = JSON.parse(req.query.masterFilter); + } + listInvoicesByPropertiesRouteHandler(req, res, properties, filter, masterFilter); +}); + +router.post('/', isAuthenticated, (req, res) => { + newInvoiceRouteHandler(req, res); +}); + +// get invoice stats +router.get('/stats', isAuthenticated, (req, res) => { + getInvoiceStatsRouteHandler(req, res); +}); + +// get invoice history +router.get('/history', isAuthenticated, (req, res) => { + getInvoiceHistoryRouteHandler(req, res); +}); + +router.get('/:id', isAuthenticated, (req, res) => { + getInvoiceRouteHandler(req, res); +}); + +// update multiple invoices +router.put('/', isAuthenticated, async (req, res) => { + editMultipleInvoicesRouteHandler(req, res); +}); + +router.put('/:id', isAuthenticated, async (req, res) => { + editInvoiceRouteHandler(req, res); +}); + +router.delete('/:id', isAuthenticated, async (req, res) => { + deleteInvoiceRouteHandler(req, res); +}); + +router.post('/:id/send', isAuthenticated, async (req, res) => { + sendInvoiceRouteHandler(req, res); +}); + +router.post('/:id/markpaid', isAuthenticated, async (req, res) => { + markInvoicePaidRouteHandler(req, res); +}); + +router.post('/:id/cancel', isAuthenticated, async (req, res) => { + cancelInvoiceRouteHandler(req, res); +}); + +export default router; diff --git a/src/routes/index.js b/src/routes/index.js index a589ca3..ed2da69 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -29,6 +29,7 @@ import courierRoutes from './management/courier.js'; import courierServiceRoutes from './management/courierservice.js'; import taxRateRoutes from './management/taxrates.js'; import taxRecordRoutes from './management/taxrecords.js'; +import invoiceRoutes from './finance/invoices.js'; import noteRoutes from './misc/notes.js'; export { @@ -64,4 +65,5 @@ export { courierServiceRoutes, taxRateRoutes, taxRecordRoutes, + invoiceRoutes, }; diff --git a/src/services/finance/invoices.js b/src/services/finance/invoices.js new file mode 100644 index 0000000..22e5897 --- /dev/null +++ b/src/services/finance/invoices.js @@ -0,0 +1,382 @@ +import config from '../../config.js'; +import { invoiceModel } from '../../database/schemas/finance/invoice.schema.js'; +import log4js from 'log4js'; +import mongoose from 'mongoose'; +import { + deleteObject, + listObjects, + getObject, + editObject, + editObjects, + newObject, + listObjectsByProperties, + getModelStats, + getModelHistory, + checkStates, +} from '../../database/database.js'; + +const logger = log4js.getLogger('Invoices'); +logger.level = config.server.logLevel; + +export const listInvoicesRouteHandler = async ( + req, + res, + page = 1, + limit = 25, + property = '', + filter = {}, + search = '', + sort = '', + order = 'ascend' +) => { + const populateFields = ['vendor', 'customer', 'relatedOrder']; + const result = await listObjects({ + model: invoiceModel, + page, + limit, + property, + filter, + search, + sort, + order, + populate: populateFields, + }); + + if (result?.error) { + logger.error('Error listing invoices.'); + res.status(result.code).send(result); + return; + } + + logger.debug(`List of invoices (Page ${page}, Limit ${limit}). Count: ${result.length}`); + res.send(result); +}; + +export const listInvoicesByPropertiesRouteHandler = async ( + req, + res, + properties = '', + filter = {}, + masterFilter = {} +) => { + const populateFields = ['vendor', 'customer', 'relatedOrder']; + const result = await listObjectsByProperties({ + model: invoiceModel, + properties, + filter, + populate: populateFields, + masterFilter, + }); + + if (result?.error) { + logger.error('Error listing invoices.'); + res.status(result.code).send(result); + return; + } + + logger.debug(`List of invoices. Count: ${result.length}`); + res.send(result); +}; + +export const getInvoiceRouteHandler = async (req, res) => { + const id = req.params.id; + const populateFields = ['vendor', 'customer', 'relatedOrder']; + const result = await getObject({ + model: invoiceModel, + id, + populate: populateFields, + }); + if (result?.error) { + logger.warn(`Invoice not found with supplied id.`); + return res.status(result.code).send(result); + } + logger.debug(`Retrieved invoice with ID: ${id}`); + res.send(result); +}; + +export const editInvoiceRouteHandler = async (req, res) => { + // Get ID from params + const id = new mongoose.Types.ObjectId(req.params.id); + + logger.trace(`Invoice with ID: ${id}`); + + const checkStatesResult = await checkStates({ model: invoiceModel, id, states: ['draft'] }); + + if (checkStatesResult.error) { + logger.error('Error checking invoice states:', checkStatesResult.error); + res.status(checkStatesResult.code).send(checkStatesResult); + return; + } + + if (checkStatesResult === false) { + logger.error('Invoice is not in draft state.'); + res.status(400).send({ error: 'Invoice is not in draft state.', code: 400 }); + return; + } + + const updateData = { + updatedAt: new Date(), + vendor: req.body.vendor, + customer: req.body.customer, + invoiceType: req.body.invoiceType, + invoiceDate: req.body.invoiceDate, + dueDate: req.body.dueDate, + relatedOrderType: req.body.relatedOrderType, + relatedOrder: req.body.relatedOrder, + }; + // Create audit log before updating + const result = await editObject({ + model: invoiceModel, + id, + updateData, + user: req.user, + }); + + if (result.error) { + logger.error('Error editing invoice:', result.error); + res.status(result).send(result); + return; + } + + logger.debug(`Edited invoice with ID: ${id}`); + + res.send(result); +}; + +export const editMultipleInvoicesRouteHandler = async (req, res) => { + const updates = req.body.map((update) => ({ + _id: update._id, + vendor: update.vendor, + customer: update.customer, + invoiceType: update.invoiceType, + })); + + if (!Array.isArray(updates)) { + return res.status(400).send({ error: 'Body must be an array of updates.', code: 400 }); + } + + const result = await editObjects({ + model: invoiceModel, + updates, + user: req.user, + }); + + if (result.error) { + logger.error('Error editing invoices:', result.error); + res.status(result.code || 500).send(result); + return; + } + + logger.debug(`Edited ${updates.length} invoices`); + + res.send(result); +}; + +export const newInvoiceRouteHandler = async (req, res) => { + const newData = { + updatedAt: new Date(), + vendor: req.body.vendor, + customer: req.body.customer, + invoiceType: req.body.invoiceType || 'sales', + invoiceDate: req.body.invoiceDate || new Date(), + dueDate: req.body.dueDate, + relatedOrderType: req.body.relatedOrderType, + relatedOrder: req.body.relatedOrder, + totalAmount: 0, + totalAmountWithTax: 0, + totalTaxAmount: 0, + grandTotalAmount: 0, + shippingAmount: 0, + shippingAmountWithTax: 0, + }; + const result = await newObject({ + model: invoiceModel, + newData, + user: req.user, + }); + if (result.error) { + logger.error('No invoice created:', result.error); + return res.status(result.code).send(result); + } + + logger.debug(`New invoice with ID: ${result._id}`); + + res.send(result); +}; + +export const deleteInvoiceRouteHandler = async (req, res) => { + // Get ID from params + const id = new mongoose.Types.ObjectId(req.params.id); + + logger.trace(`Invoice with ID: ${id}`); + + const result = await deleteObject({ + model: invoiceModel, + id, + user: req.user, + }); + if (result.error) { + logger.error('No invoice deleted:', result.error); + return res.status(result.code).send(result); + } + + logger.debug(`Deleted invoice with ID: ${result._id}`); + + res.send(result); +}; + +export const getInvoiceStatsRouteHandler = async (req, res) => { + const result = await getModelStats({ model: invoiceModel }); + if (result?.error) { + logger.error('Error fetching invoice stats:', result.error); + return res.status(result.code).send(result); + } + logger.trace('Invoice stats:', result); + res.send(result); +}; + +export const getInvoiceHistoryRouteHandler = async (req, res) => { + const from = req.query.from; + const to = req.query.to; + const result = await getModelHistory({ model: invoiceModel, from, to }); + if (result?.error) { + logger.error('Error fetching invoice history:', result.error); + return res.status(result.code).send(result); + } + logger.trace('Invoice history:', result); + res.send(result); +}; + +export const sendInvoiceRouteHandler = async (req, res) => { + const id = new mongoose.Types.ObjectId(req.params.id); + + logger.trace(`Invoice with ID: ${id}`); + + const checkStatesResult = await checkStates({ model: invoiceModel, id, states: ['draft'] }); + + if (checkStatesResult.error) { + logger.error('Error checking invoice states:', checkStatesResult.error); + res.status(checkStatesResult.code).send(checkStatesResult); + return; + } + + if (checkStatesResult === false) { + logger.error('Invoice is not in draft state.'); + res.status(400).send({ error: 'Invoice is not in draft state.', code: 400 }); + return; + } + + const updateData = { + updatedAt: new Date(), + state: { type: 'sent' }, + sentAt: new Date(), + }; + const result = await editObject({ + model: invoiceModel, + id, + updateData, + user: req.user, + }); + + if (result.error) { + logger.error('Error sending invoice:', result.error); + res.status(result.code).send(result); + return; + } + + logger.debug(`Sent invoice with ID: ${id}`); + res.send(result); +}; + +export const markInvoicePaidRouteHandler = async (req, res) => { + const id = new mongoose.Types.ObjectId(req.params.id); + + logger.trace(`Invoice with ID: ${id}`); + + const checkStatesResult = await checkStates({ + model: invoiceModel, + id, + states: ['sent', 'partiallyPaid'], + }); + + if (checkStatesResult.error) { + logger.error('Error checking invoice states:', checkStatesResult.error); + res.status(checkStatesResult.code).send(checkStatesResult); + return; + } + + if (checkStatesResult === false) { + logger.error('Invoice is not in sent or partially paid state.'); + res.status(400).send({ error: 'Invoice is not in sent or partially paid state.', code: 400 }); + return; + } + + const updateData = { + updatedAt: new Date(), + state: { type: 'paid' }, + paidAt: new Date(), + }; + const result = await editObject({ + model: invoiceModel, + id, + updateData, + user: req.user, + }); + + if (result.error) { + logger.error('Error marking invoice as paid:', result.error); + res.status(result.code).send(result); + return; + } + + logger.debug(`Marked invoice as paid with ID: ${id}`); + res.send(result); +}; + +export const cancelInvoiceRouteHandler = async (req, res) => { + const id = new mongoose.Types.ObjectId(req.params.id); + + logger.trace(`Invoice with ID: ${id}`); + + const checkStatesResult = await checkStates({ + model: invoiceModel, + id, + states: ['draft', 'sent'], + }); + + if (checkStatesResult.error) { + logger.error('Error checking invoice states:', checkStatesResult.error); + res.status(checkStatesResult.code).send(checkStatesResult); + return; + } + + if (checkStatesResult === false) { + logger.error('Invoice is not in a cancellable state.'); + res.status(400).send({ + error: 'Invoice is not in a cancellable state (must be draft or sent).', + code: 400, + }); + return; + } + + const updateData = { + updatedAt: new Date(), + state: { type: 'cancelled' }, + cancelledAt: new Date(), + }; + const result = await editObject({ + model: invoiceModel, + id, + updateData, + user: req.user, + }); + + if (result.error) { + logger.error('Error cancelling invoice:', result.error); + res.status(result.code).send(result); + return; + } + + logger.debug(`Cancelled invoice with ID: ${id}`); + res.send(result); +};