From 0f14f0f52c3dc1494c3c0cef04d10d322a282cdd Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Sun, 28 Dec 2025 02:11:16 +0000 Subject: [PATCH] Add payment management functionality - Introduced a new payment schema to handle payment records, including fields for amount, vendor, client, invoice, and state. - Implemented route handlers for CRUD operations on payments, including listing, creating, editing, and deleting payments. - Updated application routes to include payment routes for better organization and access. - Added statistics and history retrieval methods for payments to enhance reporting capabilities. --- .../schemas/finance/payment.schema.js | 105 +++++ src/index.js | 2 + src/routes/finance/payments.js | 96 +++++ src/routes/index.js | 2 + src/services/finance/payments.js | 373 ++++++++++++++++++ 5 files changed, 578 insertions(+) create mode 100644 src/database/schemas/finance/payment.schema.js create mode 100644 src/routes/finance/payments.js create mode 100644 src/services/finance/payments.js diff --git a/src/database/schemas/finance/payment.schema.js b/src/database/schemas/finance/payment.schema.js new file mode 100644 index 0000000..437583d --- /dev/null +++ b/src/database/schemas/finance/payment.schema.js @@ -0,0 +1,105 @@ +import mongoose from 'mongoose'; +import { generateId } from '../../utils.js'; +const { Schema } = mongoose; +import { aggregateRollups, aggregateRollupsHistory, editObject } from '../../database.js'; + +const paymentSchema = new Schema( + { + _reference: { type: String, default: () => generateId()() }, + amount: { type: Number, required: true, default: 0 }, + vendor: { type: Schema.Types.ObjectId, ref: 'vendor', required: false }, + client: { type: Schema.Types.ObjectId, ref: 'client', required: false }, + invoice: { type: Schema.Types.ObjectId, ref: 'invoice', required: true }, + state: { + type: { type: String, required: true, default: 'draft' }, + }, + paymentDate: { type: Date, required: false }, + postedAt: { type: Date, required: false }, + cancelledAt: { type: Date, required: false }, + paymentMethod: { type: String, required: false }, + notes: { type: String, required: false }, + }, + { timestamps: true } +); + +const rollupConfigs = [ + { + name: 'draft', + filter: { 'state.type': 'draft' }, + rollups: [ + { name: 'draftCount', property: 'state.type', operation: 'count' }, + { name: 'draftAmount', property: 'amount', operation: 'sum' }, + ], + }, + { + name: 'posted', + filter: { 'state.type': 'posted' }, + rollups: [ + { name: 'postedCount', property: 'state.type', operation: 'count' }, + { name: 'postedAmount', property: 'amount', operation: 'sum' }, + ], + }, + { + name: 'cancelled', + filter: { 'state.type': 'cancelled' }, + rollups: [ + { name: 'cancelledCount', property: 'state.type', operation: 'count' }, + { name: 'cancelledAmount', property: 'amount', operation: 'sum' }, + ], + }, +]; + +paymentSchema.statics.stats = async function () { + const results = await aggregateRollups({ + model: this, + rollupConfigs: rollupConfigs, + }); + + // Transform the results to match the expected format + return results; +}; + +paymentSchema.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; +}; + +paymentSchema.statics.recalculate = async function (payment, user) { + const paymentId = payment._id || payment; + if (!paymentId) { + return; + } + + // For payments, the amount is set directly + const amount = payment.amount || 0; + + const updateData = { + amount: parseFloat(amount).toFixed(2), + }; + + await editObject({ + model: this, + id: paymentId, + updateData, + user, + recalculate: false, + }); +}; + +// Add virtual id getter +paymentSchema.virtual('id').get(function () { + return this._id; +}); + +// Configure JSON serialization to include virtuals +paymentSchema.set('toJSON', { virtuals: true }); + +// Create and export the model +export const paymentModel = mongoose.model('payment', paymentSchema); diff --git a/src/index.js b/src/index.js index 90d8aa0..f6edc79 100644 --- a/src/index.js +++ b/src/index.js @@ -38,6 +38,7 @@ import { taxRateRoutes, taxRecordRoutes, invoiceRoutes, + paymentRoutes, clientRoutes, salesOrderRoutes, } from './routes/index.js'; @@ -143,6 +144,7 @@ app.use('/courierservices', courierServiceRoutes); app.use('/taxrates', taxRateRoutes); app.use('/taxrecords', taxRecordRoutes); app.use('/invoices', invoiceRoutes); +app.use('/payments', paymentRoutes); app.use('/clients', clientRoutes); app.use('/salesorders', salesOrderRoutes); app.use('/notes', noteRoutes); diff --git a/src/routes/finance/payments.js b/src/routes/finance/payments.js new file mode 100644 index 0000000..61cbc32 --- /dev/null +++ b/src/routes/finance/payments.js @@ -0,0 +1,96 @@ +import express from 'express'; +import { isAuthenticated } from '../../keycloak.js'; +import { getFilter, convertPropertiesString } from '../../utils.js'; + +const router = express.Router(); +import { + listPaymentsRouteHandler, + getPaymentRouteHandler, + editPaymentRouteHandler, + editMultiplePaymentsRouteHandler, + newPaymentRouteHandler, + deletePaymentRouteHandler, + listPaymentsByPropertiesRouteHandler, + getPaymentStatsRouteHandler, + getPaymentHistoryRouteHandler, + postPaymentRouteHandler, + cancelPaymentRouteHandler, +} from '../../services/finance/payments.js'; + +// list of payments +router.get('/', isAuthenticated, (req, res) => { + const { page, limit, property, search, sort, order } = req.query; + const allowedFilters = [ + 'vendor', + 'client', + 'state', + 'vendor._id', + 'client._id', + 'invoice', + 'invoice._id', + ]; + const filter = getFilter(req.query, allowedFilters); + listPaymentsRouteHandler(req, res, page, limit, property, filter, search, sort, order); +}); + +router.get('/properties', isAuthenticated, (req, res) => { + let properties = convertPropertiesString(req.query.properties); + const allowedFilters = [ + 'vendor', + 'client', + 'invoice', + 'state.type', + 'value', + 'vendor._id', + 'client._id', + 'invoice._id', + ]; + const filter = getFilter(req.query, allowedFilters, false); + var masterFilter = {}; + if (req.query.masterFilter) { + masterFilter = JSON.parse(req.query.masterFilter); + } + listPaymentsByPropertiesRouteHandler(req, res, properties, filter, masterFilter); +}); + +router.post('/', isAuthenticated, (req, res) => { + newPaymentRouteHandler(req, res); +}); + +// get payment stats +router.get('/stats', isAuthenticated, (req, res) => { + getPaymentStatsRouteHandler(req, res); +}); + +// get payment history +router.get('/history', isAuthenticated, (req, res) => { + getPaymentHistoryRouteHandler(req, res); +}); + +router.get('/:id', isAuthenticated, (req, res) => { + getPaymentRouteHandler(req, res); +}); + +// update multiple payments +router.put('/', isAuthenticated, async (req, res) => { + editMultiplePaymentsRouteHandler(req, res); +}); + +router.put('/:id', isAuthenticated, async (req, res) => { + editPaymentRouteHandler(req, res); +}); + +router.delete('/:id', isAuthenticated, async (req, res) => { + deletePaymentRouteHandler(req, res); +}); + +router.post('/:id/post', isAuthenticated, async (req, res) => { + postPaymentRouteHandler(req, res); +}); + +router.post('/:id/cancel', isAuthenticated, async (req, res) => { + cancelPaymentRouteHandler(req, res); +}); + +export default router; + diff --git a/src/routes/index.js b/src/routes/index.js index b7a65ba..c7fb0f9 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -30,6 +30,7 @@ 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 paymentRoutes from './finance/payments.js'; import clientRoutes from './sales/clients.js'; import salesOrderRoutes from './sales/salesorders.js'; import noteRoutes from './misc/notes.js'; @@ -68,6 +69,7 @@ export { taxRateRoutes, taxRecordRoutes, invoiceRoutes, + paymentRoutes, clientRoutes, salesOrderRoutes, }; diff --git a/src/services/finance/payments.js b/src/services/finance/payments.js new file mode 100644 index 0000000..09ac5aa --- /dev/null +++ b/src/services/finance/payments.js @@ -0,0 +1,373 @@ +import config from '../../config.js'; +import { paymentModel } from '../../database/schemas/finance/payment.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'; +import { invoiceModel } from '../../database/schemas/finance/invoice.schema.js'; + +const logger = log4js.getLogger('Payments'); +logger.level = config.server.logLevel; + +export const listPaymentsRouteHandler = async ( + req, + res, + page = 1, + limit = 25, + property = '', + filter = {}, + search = '', + sort = '', + order = 'ascend' +) => { + const populateFields = ['vendor', 'client', 'invoice']; + const result = await listObjects({ + model: paymentModel, + page, + limit, + property, + filter, + search, + sort, + order, + populate: populateFields, + }); + + if (result?.error) { + logger.error('Error listing payments.'); + res.status(result.code).send(result); + return; + } + + logger.debug(`List of payments (Page ${page}, Limit ${limit}). Count: ${result.length}`); + res.send(result); +}; + +export const listPaymentsByPropertiesRouteHandler = async ( + req, + res, + properties = '', + filter = {}, + masterFilter = {} +) => { + const populateFields = ['vendor', 'client', 'invoice']; + const result = await listObjectsByProperties({ + model: paymentModel, + properties, + filter, + populate: populateFields, + masterFilter, + }); + + if (result?.error) { + logger.error('Error listing payments.'); + res.status(result.code).send(result); + return; + } + + logger.debug(`List of payments. Count: ${result.length}`); + res.send(result); +}; + +export const getPaymentRouteHandler = async (req, res) => { + const id = req.params.id; + const populateFields = [ + { path: 'vendor' }, + { path: 'client' }, + { path: 'invoice' }, + ]; + const result = await getObject({ + model: paymentModel, + id, + populate: populateFields, + }); + if (result?.error) { + logger.warn(`Payment not found with supplied id.`); + return res.status(result.code).send(result); + } + logger.debug(`Retrieved payment with ID: ${id}`); + res.send(result); +}; + +export const editPaymentRouteHandler = async (req, res) => { + // Get ID from params + const id = new mongoose.Types.ObjectId(req.params.id); + + logger.trace(`Payment with ID: ${id}`); + + const checkStatesResult = await checkStates({ model: paymentModel, id, states: ['draft'] }); + + if (checkStatesResult.error) { + logger.error('Error checking payment states:', checkStatesResult.error); + res.status(checkStatesResult.code).send(checkStatesResult); + return; + } + + if (checkStatesResult === false) { + logger.error('Payment is not in draft state.'); + res.status(400).send({ error: 'Payment is not in draft state.', code: 400 }); + return; + } + + const updateData = { + updatedAt: new Date(), + vendor: req.body.vendor, + client: req.body.client, + invoice: req.body.invoice, + amount: req.body.amount, + paymentDate: req.body.paymentDate, + paymentMethod: req.body.paymentMethod, + notes: req.body.notes, + }; + // Create audit log before updating + const result = await editObject({ + model: paymentModel, + id, + updateData, + user: req.user, + }); + + if (result.error) { + logger.error('Error editing payment:', result.error); + res.status(result).send(result); + return; + } + + logger.debug(`Edited payment with ID: ${id}`); + + res.send(result); +}; + +export const editMultiplePaymentsRouteHandler = async (req, res) => { + const updates = req.body.map((update) => ({ + _id: update._id, + vendor: update.vendor, + client: update.client, + invoice: update.invoice, + })); + + if (!Array.isArray(updates)) { + return res.status(400).send({ error: 'Body must be an array of updates.', code: 400 }); + } + + const result = await editObjects({ + model: paymentModel, + updates, + user: req.user, + }); + + if (result.error) { + logger.error('Error editing payments:', result.error); + res.status(result.code || 500).send(result); + return; + } + + logger.debug(`Edited ${updates.length} payments`); + + res.send(result); +}; + +export const newPaymentRouteHandler = async (req, res) => { + // Get invoice to populate vendor/client + const invoice = await getObject({ + model: invoiceModel, + id: req.body.invoice, + populate: [{ path: 'vendor' }, { path: 'client' }], + }); + + if (invoice.error) { + logger.error('Error getting invoice:', invoice.error); + return res.status(invoice.code).send(invoice); + } + + const newData = { + updatedAt: new Date(), + vendor: invoice.vendor?._id || req.body.vendor, + client: invoice.client?._id || req.body.client, + invoice: req.body.invoice, + amount: req.body.amount || 0, + paymentDate: req.body.paymentDate || new Date(), + paymentMethod: req.body.paymentMethod, + notes: req.body.notes, + }; + const result = await newObject({ + model: paymentModel, + newData, + user: req.user, + }); + if (result.error) { + logger.error('No payment created:', result.error); + return res.status(result.code).send(result); + } + + logger.debug(`New payment with ID: ${result._id}`); + + res.send(result); +}; + +export const deletePaymentRouteHandler = async (req, res) => { + // Get ID from params + const id = new mongoose.Types.ObjectId(req.params.id); + + logger.trace(`Payment with ID: ${id}`); + + const result = await deleteObject({ + model: paymentModel, + id, + user: req.user, + }); + if (result.error) { + logger.error('No payment deleted:', result.error); + return res.status(result.code).send(result); + } + + logger.debug(`Deleted payment with ID: ${result._id}`); + + res.send(result); +}; + +export const getPaymentStatsRouteHandler = async (req, res) => { + const result = await getModelStats({ model: paymentModel }); + if (result?.error) { + logger.error('Error fetching payment stats:', result.error); + return res.status(result.code).send(result); + } + logger.trace('Payment stats:', result); + res.send(result); +}; + +export const getPaymentHistoryRouteHandler = async (req, res) => { + const from = req.query.from; + const to = req.query.to; + const result = await getModelHistory({ model: paymentModel, from, to }); + if (result?.error) { + logger.error('Error fetching payment history:', result.error); + return res.status(result.code).send(result); + } + logger.trace('Payment history:', result); + res.send(result); +}; + +export const postPaymentRouteHandler = async (req, res) => { + const id = new mongoose.Types.ObjectId(req.params.id); + + logger.trace(`Payment with ID: ${id}`); + + const checkStatesResult = await checkStates({ model: paymentModel, id, states: ['draft'] }); + + if (checkStatesResult.error) { + logger.error('Error checking payment states:', checkStatesResult.error); + res.status(checkStatesResult.code).send(checkStatesResult); + return; + } + + if (checkStatesResult === false) { + logger.error('Payment is not in draft state.'); + res.status(400).send({ error: 'Payment is not in draft state.', code: 400 }); + return; + } + + const payment = await getObject({ + model: paymentModel, + id, + populate: [{ path: 'invoice' }], + }); + + if (payment.error) { + logger.error('Error getting payment:', payment.error); + return res.status(payment.code).send(payment); + } + + // Update invoice paid amounts if needed + if (payment.invoice) { + const invoice = await getObject({ + model: invoiceModel, + id: payment.invoice._id || payment.invoice, + }); + + if (!invoice.error) { + // You can add logic here to update invoice paid amounts + // This is a simplified version - adjust based on your business logic + } + } + + const updateData = { + updatedAt: new Date(), + state: { type: 'posted' }, + postedAt: new Date(), + }; + const result = await editObject({ + model: paymentModel, + id, + updateData, + user: req.user, + }); + + if (result.error) { + logger.error('Error posting payment:', result.error); + res.status(result.code).send(result); + return; + } + + logger.debug(`Posted payment with ID: ${id}`); + res.send(result); +}; + +export const cancelPaymentRouteHandler = async (req, res) => { + const id = new mongoose.Types.ObjectId(req.params.id); + + logger.trace(`Payment with ID: ${id}`); + + const checkStatesResult = await checkStates({ + model: paymentModel, + id, + states: ['draft', 'posted'], + }); + + if (checkStatesResult.error) { + logger.error('Error checking payment states:', checkStatesResult.error); + res.status(checkStatesResult.code).send(checkStatesResult); + return; + } + + if (checkStatesResult === false) { + logger.error('Payment is not in a cancellable state.'); + res.status(400).send({ + error: 'Payment is not in a cancellable state (must be draft or posted).', + code: 400, + }); + return; + } + + const updateData = { + updatedAt: new Date(), + state: { type: 'cancelled' }, + cancelledAt: new Date(), + }; + const result = await editObject({ + model: paymentModel, + id, + updateData, + user: req.user, + }); + + if (result.error) { + logger.error('Error cancelling payment:', result.error); + res.status(result.code).send(result); + return; + } + + logger.debug(`Cancelled payment with ID: ${id}`); + res.send(result); +}; +