From 882f01a1789891185acfb1bf377d4e9c21afd729 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Sat, 19 Sep 2020 21:14:09 +0200 Subject: [PATCH] refactor: manual journal. --- server/src/api/controllers/Accounting.js | 984 ------------------ server/src/api/controllers/Fields.js | 234 ----- server/src/api/controllers/ManualJournals.ts | 377 +++++++ server/src/api/index.ts | 5 +- .../20200608192614_create_vendors_table.js | 46 - server/src/interfaces/ManualJournal.ts | 53 + server/src/interfaces/index.ts | 1 + server/src/loaders/events.ts | 1 + server/src/loaders/tenantRepositories.ts | 2 + server/src/models/Expense.js | 9 - server/src/models/ManualJournal.js | 13 + server/src/models/Model.js | 9 + server/src/repositories/ContactRepository.ts | 37 + .../services/Accounting/JournalCommands.ts | 37 +- .../ManualJournals/ManualJournalsService.ts | 460 ++++++++ server/src/subscribers/events.ts | 9 + server/src/subscribers/manualJournals.ts | 43 + 17 files changed, 1044 insertions(+), 1276 deletions(-) delete mode 100644 server/src/api/controllers/Accounting.js delete mode 100644 server/src/api/controllers/Fields.js create mode 100644 server/src/api/controllers/ManualJournals.ts delete mode 100644 server/src/database/migrations/20200608192614_create_vendors_table.js create mode 100644 server/src/interfaces/ManualJournal.ts create mode 100644 server/src/repositories/ContactRepository.ts create mode 100644 server/src/services/ManualJournals/ManualJournalsService.ts create mode 100644 server/src/subscribers/manualJournals.ts diff --git a/server/src/api/controllers/Accounting.js b/server/src/api/controllers/Accounting.js deleted file mode 100644 index 0314e4318..000000000 --- a/server/src/api/controllers/Accounting.js +++ /dev/null @@ -1,984 +0,0 @@ -import { check, query, validationResult, param } from 'express-validator'; -import express from 'express'; -import { difference } from 'lodash'; -import moment from 'moment'; -import asyncMiddleware from 'api/middleware/asyncMiddleware'; -import JournalPoster from 'services/Accounting/JournalPoster'; -import JournalEntry from 'services/Accounting/JournalEntry'; -import { - mapViewRolesToConditionals, - mapFilterRolesToDynamicFilter, -} from 'lib/ViewRolesBuilder'; -import { - DynamicFilter, - DynamicFilterSortBy, - DynamicFilterViews, - DynamicFilterFilterRoles, -} from 'lib/DynamicFilter'; - -export default { - /** - * Router constructor. - */ - router() { - const router = express.Router(); - - router.get( - '/manual-journals/:id', - this.getManualJournal.validation, - asyncMiddleware(this.getManualJournal.handler) - ); - router.get( - '/manual-journals', - this.manualJournals.validation, - asyncMiddleware(this.manualJournals.handler) - ); - router.post( - '/make-journal-entries', - this.validateMediaIds, - this.validateContactEntries, - this.makeJournalEntries.validation, - asyncMiddleware(this.makeJournalEntries.handler) - ); - router.post( - '/manual-journals/:id/publish', - this.publishManualJournal.validation, - asyncMiddleware(this.publishManualJournal.handler) - ); - router.post( - '/manual-journals/:id', - this.validateMediaIds, - this.validateContactEntries, - this.editManualJournal.validation, - asyncMiddleware(this.editManualJournal.handler) - ); - router.delete( - '/manual-journals/:id', - this.deleteManualJournal.validation, - asyncMiddleware(this.deleteManualJournal.handler) - ); - router.delete( - '/manual-journals', - this.deleteBulkManualJournals.validation, - asyncMiddleware(this.deleteBulkManualJournals.handler) - ); - router.post( - '/recurring-journal-entries', - this.recurringJournalEntries.validation, - asyncMiddleware(this.recurringJournalEntries.handler) - ); - return router; - }, - - /** - * Retrieve manual journals, - */ - manualJournals: { - validation: [ - query('page').optional().isNumeric().toInt(), - query('page_size').optional().isNumeric().toInt(), - query('custom_view_id').optional().isNumeric().toInt(), - - query('column_sort_by').optional().trim().escape(), - query('sort_order').optional().isIn(['desc', 'asc']), - - query('stringified_filter_roles').optional().isJSON(), - ], - async handler(req, res) { - const validationErrors = validationResult(req); - - if (!validationErrors.isEmpty()) { - return res.boom.badData(null, { - code: 'validation_error', - ...validationErrors, - }); - } - const filter = { - filter_roles: [], - page: 1, - page_size: 999, - sort_order: 'asc', - ...req.query, - }; - if (filter.stringified_filter_roles) { - filter.filter_roles = JSON.parse(filter.stringified_filter_roles); - } - const { Resource, View, ManualJournal } = req.models; - - const errorReasons = []; - const manualJournalsResource = await Resource.query() - .where('name', 'manual_journals') - .withGraphFetched('fields') - .first(); - - if (!manualJournalsResource) { - return res.status(400).send({ - errors: [{ type: 'MANUAL_JOURNALS.RESOURCE.NOT.FOUND', code: 200 }], - }); - } - - const view = await View.query().onBuild((builder) => { - if (filter.custom_view_id) { - builder.where('id', filter.custom_view_id); - } else { - builder.where('favourite', true); - } - builder.where('resource_id', manualJournalsResource.id); - builder.withGraphFetched('roles.field'); - builder.withGraphFetched('columns'); - builder.first(); - }); - - const resourceFieldsKeys = manualJournalsResource.fields.map( - (c) => c.key - ); - const dynamicFilter = new DynamicFilter(ManualJournal.tableName); - - // Dynamic filter with view roles. - if (view && view.roles.length > 0) { - const viewFilter = new DynamicFilterViews( - mapViewRolesToConditionals(view.roles), - view.rolesLogicExpression - ); - if (!viewFilter.validateFilterRoles()) { - errorReasons.push({ - type: 'VIEW.LOGIC.EXPRESSION.INVALID', - code: 400, - }); - } - dynamicFilter.setFilter(viewFilter); - } - // Dynamic filter with filter roles. - if (filter.filter_roles.length > 0) { - // Validate the accounts resource fields. - const filterRoles = new DynamicFilterFilterRoles( - mapFilterRolesToDynamicFilter(filter.filter_roles), - manualJournalsResource.fields - ); - dynamicFilter.setFilter(filterRoles); - - if (filterRoles.validateFilterRoles().length > 0) { - errorReasons.push({ - type: 'MANUAL.JOURNAL.HAS.NO.FIELDS', - code: 500, - }); - } - } - // Dynamic filter with column sort order. - if (filter.column_sort_by) { - if (resourceFieldsKeys.indexOf(filter.column_sort_by) === -1) { - errorReasons.push({ type: 'COLUMN.SORT.ORDER.NOT.FOUND', code: 300 }); - } - const sortByFilter = new DynamicFilterSortBy( - filter.column_sort_by, - filter.sort_order - ); - dynamicFilter.setFilter(sortByFilter); - } - if (errorReasons.length > 0) { - return res.status(400).send({ errors: errorReasons }); - } - // Manual journals. - const manualJournals = await ManualJournal.query() - .onBuild((builder) => { - dynamicFilter.buildQuery()(builder); - }) - .pagination(filter.page - 1, filter.page_size); - - return res.status(200).send({ - manualJournals: { - ...manualJournals, - ...(view - ? { - viewMeta: { - customViewId: view.id, - }, - } - : {}), - }, - }); - }, - }, - - /** - * Validate media ids. - * @param {Request} req - - * @param {Response} res - - * @param {Function} next - - */ - async validateMediaIds(req, res, next) { - const form = { media_ids: [], ...req.body }; - const { Media } = req.models; - const errorReasons = []; - - // Validate if media ids was not already exists on the storage. - if (form.media_ids.length > 0) { - const storedMedia = await Media.query().whereIn('id', form.media_ids); - const notFoundMedia = difference( - form.media_ids, - storedMedia.map((m) => m.id) - ); - - if (notFoundMedia.length > 0) { - errorReasons.push({ - type: 'MEDIA.IDS.NOT.FOUND', - code: 400, - ids: notFoundMedia, - }); - } - } - req.errorReasons = - Array.isArray(req.errorReasons) && req.errorReasons.length - ? req.errorReasons.push(...errorReasons) - : errorReasons; - next(); - }, - - /** - * Validate form entries with contact customers and vendors. - * - * - Validate the entries that with receivable has no customer contact. - * - Validate the entries that with payable has no vendor contact. - * - Validate the entries with customers contacts that not found on the storage. - * - Validate the entries with vendors contacts that not found on the storage. - * - * @param {Request} req - * @param {Response} res - * @param {Function} next - */ - async validateContactEntries(req, res, next) { - const form = { entries: [], ...req.body }; - const { Account, AccountType, Vendor, Customer } = req.models; - const errorReasons = []; - - // Validate the entries contact type and ids. - const formEntriesCustomersIds = form.entries.filter( - (e) => e.contact_type === 'customer' - ); - const formEntriesVendorsIds = form.entries.filter( - (e) => e.contact_type === 'vendor' - ); - - const accountsTypes = await AccountType.query(); - - const payableAccountsType = accountsTypes.find( - (t) => t.key === 'accounts_payable' - ); - const receivableAccountsType = accountsTypes.find( - (t) => t.key === 'accounts_receivable' - ); - - const receivableAccountOper = Account.query() - .where('account_type_id', receivableAccountsType.id) - .first(); - const payableAccountOper = Account.query() - .where('account_type_id', payableAccountsType.id) - .first(); - - const [receivableAccount, payableAccount] = await Promise.all([ - receivableAccountOper, - payableAccountOper, - ]); - - const entriesHasNoReceivableAccount = form.entries.filter( - (e) => - e.account_id === receivableAccount.id && - (!e.contact_id || e.contact_type !== 'customer') - ); - - if (entriesHasNoReceivableAccount.length > 0) { - errorReasons.push({ - type: 'RECEIVABLE.ENTRIES.HAS.NO.CUSTOMERS', - code: 900, - indexes: entriesHasNoReceivableAccount.map((e) => e.index), - }); - } - - const entriesHasNoVendorContact = form.entries.filter( - (e) => - e.account_id === payableAccount.id && - (!e.contact_id || e.contact_type !== 'contact') - ); - - if (entriesHasNoVendorContact.length > 0) { - errorReasons.push({ - type: 'PAYABLE.ENTRIES.HAS.NO.VENDORS', - code: 1000, - indexes: entriesHasNoVendorContact.map((e) => e.index), - }); - } - - // Validate customers contacts. - if (formEntriesCustomersIds.length > 0) { - const customersContactsIds = formEntriesCustomersIds.map( - (c) => c.contact_id - ); - const storedContacts = await Customer.query().whereIn( - 'id', - customersContactsIds - ); - - const storedContactsIds = storedContacts.map((c) => c.id); - - const notFoundContactsIds = difference( - formEntriesCustomersIds.map((c) => c.contact_id), - storedContactsIds - ); - if (notFoundContactsIds.length > 0) { - errorReasons.push({ - type: 'CUSTOMERS.CONTACTS.NOT.FOUND', - code: 500, - ids: notFoundContactsIds, - }); - } - - const notReceivableAccounts = formEntriesCustomersIds.filter( - (c) => c.account_id !== receivableAccount.id - ); - if (notReceivableAccounts.length > 0) { - errorReasons.push({ - type: 'CUSTOMERS.NOT.WITH.RECEIVABLE.ACCOUNT', - code: 700, - indexes: notReceivableAccounts.map((a) => a.index), - }); - } - } - - // Validate vendors contacts. - if (formEntriesVendorsIds.length > 0) { - const vendorsContactsIds = formEntriesVendorsIds.map((c) => c.contact_id); - const storedContacts = await Vendor.query().where( - 'id', - vendorsContactsIds - ); - - const storedContactsIds = storedContacts.map((c) => c.id); - - const notFoundContactsIds = difference( - formEntriesVendorsIds.map((v) => v.contact_id), - storedContactsIds - ); - if (notFoundContactsIds.length > 0) { - errorReasons.push({ - type: 'VENDORS.CONTACTS.NOT.FOUND', - code: 600, - ids: notFoundContactsIds, - }); - } - const notPayableAccounts = formEntriesVendorsIds.filter( - (v) => v.contact_id === payableAccount.id - ); - if (notPayableAccounts.length > 0) { - errorReasons.push({ - type: 'VENDORS.NOT.WITH.PAYABLE.ACCOUNT', - code: 800, - indexes: notPayableAccounts.map((a) => a.index), - }); - } - } - - req.errorReasons = - Array.isArray(req.errorReasons) && req.errorReasons.length - ? req.errorReasons.push(...errorReasons) - : errorReasons; - - next(); - }, - - /** - * Make journal entrires. - */ - makeJournalEntries: { - validation: [ - check('date').exists().isISO8601(), - check('journal_number').exists().trim().escape(), - check('journal_type').optional({ nullable: true }).trim().escape(), - check('reference').optional({ nullable: true }), - check('description').optional().trim().escape(), - check('status').optional().isBoolean().toBoolean(), - check('entries').isArray({ min: 2 }), - check('entries.*.index').exists().isNumeric().toInt(), - check('entries.*.credit') - .optional({ nullable: true }) - .isNumeric() - .isDecimal() - .isFloat({ max: 9999999999.999 }) // 13, 3 - .toFloat(), - check('entries.*.debit') - .optional({ nullable: true }) - .isNumeric() - .isDecimal() - .isFloat({ max: 9999999999.999 }) // 13, 3 - .toFloat(), - check('entries.*.account_id').isNumeric().toInt(), - check('entries.*.note').optional(), - check('entries.*.contact_id') - .optional({ nullable: true }) - .isNumeric() - .toInt(), - check('entries.*.contact_type').optional().isIn(['vendor', 'customer']), - check('media_ids').optional().isArray(), - check('media_ids.*').exists().isNumeric().toInt(), - ], - async handler(req, res) { - const validationErrors = validationResult(req); - - if (!validationErrors.isEmpty()) { - return res.boom.badData(null, { - code: 'validation_error', - ...validationErrors, - }); - } - const form = { - date: new Date(), - journal_type: 'journal', - reference: '', - media_ids: [], - ...req.body, - }; - const { ManualJournal, Account, MediaLink } = req.models; - const { tenantId } = req; - - let totalCredit = 0; - let totalDebit = 0; - - const { user } = req; - const errorReasons = [...(req.errorReasons || [])]; - const entries = form.entries.filter( - (entry) => entry.credit || entry.debit - ); - const formattedDate = moment(form.date).format('YYYY-MM-DD'); - - entries.forEach((entry) => { - if (entry.credit > 0) { - totalCredit += entry.credit; - } - if (entry.debit > 0) { - totalDebit += entry.debit; - } - }); - if (totalCredit <= 0 || totalDebit <= 0) { - errorReasons.push({ - type: 'CREDIT.DEBIT.SUMATION.SHOULD.NOT.EQUAL.ZERO', - code: 400, - }); - } - if (totalCredit !== totalDebit) { - errorReasons.push({ type: 'CREDIT.DEBIT.NOT.EQUALS', code: 100 }); - } - const formEntriesAccountsIds = entries.map((entry) => entry.account_id); - - const accounts = await Account.query() - .whereIn('id', formEntriesAccountsIds) - .withGraphFetched('type'); - - const storedAccountsIds = accounts.map((account) => account.id); - - if (difference(formEntriesAccountsIds, storedAccountsIds).length > 0) { - errorReasons.push({ type: 'ACCOUNTS.IDS.NOT.FOUND', code: 200 }); - } - - const journalNumber = await ManualJournal.query().where( - 'journal_number', - form.journal_number - ); - - if (journalNumber.length > 0) { - errorReasons.push({ type: 'JOURNAL.NUMBER.ALREADY.EXISTS', code: 300 }); - } - - if (errorReasons.length > 0) { - return res.status(400).send({ errors: errorReasons }); - } - // Save manual journal tansaction. - const manualJournal = await ManualJournal.query().insert({ - reference: form.reference, - journal_type: form.journal_type, - journal_number: form.journal_number, - amount: totalCredit, - date: formattedDate, - description: form.description, - status: form.status, - user_id: user.id, - }); - const journalPoster = new JournalPoster(tenantId); - - entries.forEach((entry) => { - const jouranlEntry = new JournalEntry({ - debit: entry.debit, - credit: entry.credit, - account: entry.account_id, - referenceType: 'Journal', - referenceId: manualJournal.id, - contactType: entry.contact_type, - contactId: entry.contact_id, - note: entry.note, - date: formattedDate, - userId: user.id, - draft: !form.status, - index: entry.index, - }); - if (entry.debit) { - journalPoster.debit(jouranlEntry); - } else { - journalPoster.credit(jouranlEntry); - } - }); - - // Save linked media to the journal model. - const bulkSaveMediaLink = []; - - form.media_ids.forEach((mediaId) => { - const oper = MediaLink.query().insert({ - model_name: 'Journal', - model_id: manualJournal.id, - media_id: mediaId, - }); - bulkSaveMediaLink.push(oper); - }); - - // Saves the journal entries and accounts balance changes. - await Promise.all([ - ...bulkSaveMediaLink, - journalPoster.saveEntries(), - form.status && journalPoster.saveBalance(), - ]); - return res.status(200).send({ id: manualJournal.id }); - }, - }, - - /** - * Saves recurring journal entries template. - */ - recurringJournalEntries: { - validation: [ - check('template_name').exists(), - check('recurrence').exists(), - check('active').optional().isBoolean().toBoolean(), - check('entries').isArray({ min: 1 }), - check('entries.*.credit').isNumeric().toInt(), - check('entries.*.debit').isNumeric().toInt(), - check('entries.*.account_id').isNumeric().toInt(), - check('entries.*.note').optional(), - ], - async handler(req, res) { - const validationErrors = validationResult(req); - - if (!validationErrors.isEmpty()) { - return res.boom.badData(null, { - code: 'validation_error', - ...validationErrors, - }); - } - }, - }, - - /** - * Edit the given manual journal. - */ - editManualJournal: { - validation: [ - param('id').exists().isNumeric().toInt(), - check('date').exists().isISO8601(), - check('journal_number').exists().trim().escape(), - check('journal_type').optional({ nullable: true }).trim().escape(), - check('reference').optional({ nullable: true }), - check('description').optional().trim().escape(), - check('entries').isArray({ min: 2 }), - // check('entries.*.index').exists().isNumeric().toInt(), - check('entries.*.credit') - .optional({ nullable: true }) - .isNumeric() - .toFloat(), - check('entries.*.debit') - .optional({ nullable: true }) - .isNumeric() - .toFloat(), - check('entries.*.account_id').isNumeric().toInt(), - check('entries.*.contact_id') - .optional({ nullable: true }) - .isNumeric() - .toInt(), - check('entries.*.contact_type') - .optional() - .isIn(['vendor', 'customer']) - .isNumeric() - .toInt(), - check('entries.*.note').optional(), - check('media_ids').optional().isArray(), - check('media_ids.*').isNumeric().toInt(), - ], - async handler(req, res) { - const validationErrors = validationResult(req); - - if (!validationErrors.isEmpty()) { - return res.boom.badData(null, { - code: 'validation_error', - ...validationErrors, - }); - } - const form = { - date: new Date(), - journal_type: 'Journal', - reference: '', - media_ids: [], - ...req.body, - }; - const { id } = req.params; - const { - ManualJournal, - AccountTransaction, - Account, - Media, - MediaLink, - } = req.models; - - const manualJournal = await ManualJournal.query() - .where('id', id) - .withGraphFetched('media') - .first(); - - if (!manualJournal) { - return res.status(4040).send({ - errors: [{ type: 'MANUAL.JOURNAL.NOT.FOUND', code: 100 }], - }); - } - let totalCredit = 0; - let totalDebit = 0; - - const { user } = req; - const { tenantId } = req; - const errorReasons = [...(req.errorReasons || [])]; - const entries = form.entries.filter( - (entry) => entry.credit || entry.debit - ); - const formattedDate = moment(form.date).format('YYYY-MM-DD'); - - entries.forEach((entry) => { - if (entry.credit > 0) { - totalCredit += entry.credit; - } - if (entry.debit > 0) { - totalDebit += entry.debit; - } - }); - if (totalCredit <= 0 || totalDebit <= 0) { - errorReasons.push({ - type: 'CREDIT.DEBIT.SUMATION.SHOULD.NOT.EQUAL.ZERO', - code: 400, - }); - } - if (totalCredit !== totalDebit) { - errorReasons.push({ type: 'CREDIT.DEBIT.NOT.EQUALS', code: 100 }); - } - const journalNumber = await ManualJournal.query() - .where('journal_number', form.journal_number) - .whereNot('id', id) - .first(); - - if (journalNumber) { - errorReasons.push({ type: 'JOURNAL.NUMBER.ALREADY.EXISTS', code: 300 }); - } - const accountsIds = entries.map((entry) => entry.account_id); - const accounts = await Account.query() - .whereIn('id', accountsIds) - .withGraphFetched('type'); - - const storedAccountsIds = accounts.map((account) => account.id); - - if (difference(accountsIds, storedAccountsIds).length > 0) { - errorReasons.push({ type: 'ACCOUNTS.IDS.NOT.FOUND', code: 200 }); - } - if (errorReasons.length > 0) { - return res.status(400).send({ errors: errorReasons }); - } - - await ManualJournal.query().where('id', manualJournal.id).update({ - reference: form.reference, - journal_type: form.journal_type, - journalNumber: form.journal_number, - amount: totalCredit, - date: formattedDate, - description: form.description, - }); - - const transactions = await AccountTransaction.query() - .whereIn('reference_type', ['Journal']) - .where('reference_id', manualJournal.id) - .withGraphFetched('account.type'); - - const journal = new JournalPoster(tenantId); - - journal.loadEntries(transactions); - journal.removeEntries(); - - entries.forEach((entry) => { - const jouranlEntry = new JournalEntry({ - debit: entry.debit, - credit: entry.credit, - account: entry.account_id, - referenceType: 'Journal', - referenceId: manualJournal.id, - note: entry.note, - date: formattedDate, - userId: user.id, - }); - if (entry.debit) { - journal.debit(jouranlEntry); - } else { - journal.credit(jouranlEntry); - } - }); - - // Save links of new inserted media that associated to the journal model. - const journalMediaIds = manualJournal.media.map((m) => m.id); - const newInsertedMedia = difference(form.media_ids, journalMediaIds); - const bulkSaveMediaLink = []; - - newInsertedMedia.forEach((mediaId) => { - const oper = MediaLink.query().insert({ - model_name: 'Journal', - model_id: manualJournal.id, - media_id: mediaId, - }); - bulkSaveMediaLink.push(oper); - }); - - await Promise.all([ - ...bulkSaveMediaLink, - journal.deleteEntries(), - journal.saveEntries(), - journal.saveBalance(), - ]); - - return res.status(200).send({}); - }, - }, - - publishManualJournal: { - validation: [param('id').exists().isNumeric().toInt()], - async handler(req, res) { - const validationErrors = validationResult(req); - - if (!validationErrors.isEmpty()) { - return res.boom.badData(null, { - code: 'validation_error', - ...validationErrors, - }); - } - const { ManualJournal, AccountTransaction, Account } = req.models; - - const { id } = req.params; - const { tenantId } = req; - const manualJournal = await ManualJournal.query().where('id', id).first(); - - if (!manualJournal) { - return res.status(404).send({ - errors: [{ type: 'MANUAL.JOURNAL.NOT.FOUND', code: 100 }], - }); - } - if (manualJournal.status) { - return res.status(400).send({ - errors: [{ type: 'MANUAL.JOURNAL.PUBLISHED.ALREADY', code: 200 }], - }); - } - const updateJournalTransactionOper = ManualJournal.query() - .where('id', manualJournal.id) - .update({ status: 1 }); - - const transactions = await AccountTransaction.query() - .whereIn('reference_type', ['Journal', 'ManualJournal']) - .where('reference_id', manualJournal.id) - .withGraphFetched('account.type'); - - const journal = new JournalPoster(tenantId); - - journal.loadEntries(transactions); - journal.calculateEntriesBalanceChange(); - - const updateAccountsTransactionsOper = AccountTransaction.query() - .whereIn( - 'id', - transactions.map((t) => t.id) - ) - .update({ draft: 0 }); - - await Promise.all([ - updateJournalTransactionOper, - updateAccountsTransactionsOper, - journal.saveBalance(), - ]); - return res.status(200).send({ id }); - }, - }, - - getManualJournal: { - validation: [param('id').exists().isNumeric().toInt()], - async handler(req, res) { - const validationErrors = validationResult(req); - - if (!validationErrors.isEmpty()) { - return res.boom.badData(null, { - code: 'validation_error', - ...validationErrors, - }); - } - const { ManualJournal, AccountTransaction } = req.models; - - const { id } = req.params; - const manualJournal = await ManualJournal.query() - .where('id', id) - .withGraphFetched('media') - .first(); - - if (!manualJournal) { - return res.status(404).send({ - errors: [{ type: 'MANUAL.JOURNAL.NOT.FOUND', code: 100 }], - }); - } - const transactions = await AccountTransaction.query() - .whereIn('reference_type', ['Journal', 'ManualJournal']) - .where('reference_id', manualJournal.id); - - return res.status(200).send({ - manual_journal: { - ...manualJournal.toJSON(), - entries: [...transactions], - }, - }); - }, - }, - - /** - * Deletes manual journal transactions and associated - * accounts transactions. - */ - deleteManualJournal: { - validation: [param('id').exists().isNumeric().toInt()], - async handler(req, res) { - const validationErrors = validationResult(req); - - if (!validationErrors.isEmpty()) { - return res.boom.badData(null, { - code: 'validation_error', - ...validationErrors, - }); - } - const { id } = req.params; - const { tenantId } = req; - const { - ManualJournal, - AccountTransaction, - MediaLink, - Account, - } = req.models; - - const manualJournal = await ManualJournal.query().where('id', id).first(); - - if (!manualJournal) { - return res.status(404).send({ - errors: [{ type: 'MANUAL.JOURNAL.NOT.FOUND', code: 100 }], - }); - } - const transactions = await AccountTransaction.query() - .whereIn('reference_type', ['Journal', 'ManualJournal']) - .where('reference_id', manualJournal.id) - .withGraphFetched('account.type'); - - const journal = new JournalPoster(tenantId); - - journal.loadEntries(transactions); - journal.removeEntries(); - - await MediaLink.query() - .where('model_name', 'Journal') - .where('model_id', manualJournal.id) - .delete(); - - await ManualJournal.query().where('id', manualJournal.id).delete(); - - await Promise.all([journal.deleteEntries(), journal.saveBalance()]); - return res.status(200).send({ id }); - }, - }, - - recurringJournalsList: { - validation: [ - query('page').optional().isNumeric().toInt(), - query('page_size').optional().isNumeric().toInt(), - query('template_name').optional(), - ], - async handler(req, res) { - const validationErrors = validationResult(req); - - if (!validationErrors.isEmpty()) { - return res.boom.badData(null, { - code: 'validation_error', - ...validationErrors, - }); - } - }, - }, - - /** - * Deletes bulk manual journals. - */ - deleteBulkManualJournals: { - validation: [ - query('ids').isArray({ min: 1 }), - query('ids.*').isNumeric().toInt(), - ], - async handler(req, res) { - const validationErrors = validationResult(req); - - if (!validationErrors.isEmpty()) { - return res.boom.badData(null, { - code: 'validation_error', - ...validationErrors, - }); - } - const filter = { ...req.query }; - const { tenantId } = req; - const { - ManualJournal, - AccountTransaction, - Account, - MediaLink, - } = req.models; - - const manualJournals = await ManualJournal.query().whereIn( - 'id', - filter.ids - ); - - const notFoundManualJournals = difference( - filter.ids, - manualJournals.map((m) => m.id) - ); - - if (notFoundManualJournals.length > 0) { - return res.status(404).send({ - errors: [{ type: 'MANUAL.JOURNAL.NOT.FOUND', code: 200 }], - }); - } - const transactions = await AccountTransaction.query() - .whereIn('reference_type', ['Journal', 'ManualJournal']) - .whereIn('reference_id', filter.ids); - - const journal = new JournalPoster(tenantId); - - journal.loadEntries(transactions); - journal.removeEntries(); - - await MediaLink.query() - .where('model_name', 'Journal') - .whereIn('model_id', filter.ids) - .delete(); - - await ManualJournal.query().whereIn('id', filter.ids).delete(); - - await Promise.all([journal.deleteEntries(), journal.saveBalance()]); - return res.status(200).send({ ids: filter.ids }); - }, - }, -}; diff --git a/server/src/api/controllers/Fields.js b/server/src/api/controllers/Fields.js deleted file mode 100644 index 442fb074f..000000000 --- a/server/src/api/controllers/Fields.js +++ /dev/null @@ -1,234 +0,0 @@ -import express from 'express'; -import { check, param, validationResult } from 'express-validator'; -import ResourceField from 'models/ResourceField'; -import Resource from 'models/Resource'; -import asyncMiddleware from '../middleware/asyncMiddleware'; - -/** - * Types of the custom fields. - */ -const TYPES = ['text', 'email', 'number', 'url', 'percentage', 'checkbox', 'radio', 'textarea']; - -export default { - /** - * Router constructor method. - */ - router() { - const router = express.Router(); - - router.post('/resource/:resource_name', - this.addNewField.validation, - asyncMiddleware(this.addNewField.handler)); - - router.post('/:field_id', - this.editField.validation, - asyncMiddleware(this.editField.handler)); - - router.post('/status/:field_id', - this.changeStatus.validation, - asyncMiddleware(this.changeStatus.handler)); - - // router.get('/:field_id', - // asyncMiddleware(this.getField.handler)); - - // router.delete('/:field_id', - // asyncMiddleware(this.deleteField.handler)); - - return router; - }, - - /** - * Adds a new field control to the given resource. - * @param {Request} req - - * @param {Response} res - - */ - addNewField: { - validation: [ - param('resource_name').exists().trim().escape(), - check('label').exists().escape().trim(), - check('data_type').exists().isIn(TYPES), - check('help_text').optional(), - check('default').optional(), - check('options').optional().isArray(), - check('options.*.key').exists().isNumeric().toInt(), - check('options.*.value').exists(), - ], - async handler(req, res) { - const { resource_name: resourceName } = req.params; - const validationErrors = validationResult(req); - - if (!validationErrors.isEmpty()) { - return res.boom.badData(null, { - code: 'validation_error', ...validationErrors, - }); - } - const resource = await Resource.query().where('name', resourceName).first(); - - if (!resource) { - return res.boom.notFound(null, { - errors: [{ type: 'RESOURCE_NOT_FOUND', code: 100 }], - }); - } - const form = { options: [], ...req.body }; - const choices = form.options.map((option) => ({ key: option.key, value: option.value })); - - const storedResource = await ResourceField.query().insertAndFetch({ - data_type: form.data_type, - label_name: form.label, - help_text: form.help_text, - default: form.default, - resource_id: resource.id, - options: choices, - index: -1, - }); - return res.status(200).send({ id: storedResource.id }); - }, - }, - - /** - * Edit details of the given field. - */ - editField: { - validation: [ - param('field_id').exists().isNumeric().toInt(), - check('label').exists().escape().trim(), - check('data_type').exists().isIn(TYPES), - check('help_text').optional(), - check('default').optional(), - check('options').optional().isArray(), - check('options.*.key').exists().isNumeric().toInt(), - check('options.*.value').exists(), - ], - async handler(req, res) { - const { field_id: fieldId } = req.params; - const validationErrors = validationResult(req); - - if (!validationErrors.isEmpty()) { - return res.boom.badData(null, { - code: 'validation_error', ...validationErrors, - }); - } - const field = await ResourceField.query().findById(fieldId); - - if (!field) { - return res.boom.notFound(null, { - errors: [{ type: 'FIELD_NOT_FOUND', code: 100 }], - }); - } - // Sets the default value of optional fields. - const form = { options: [], ...req.body }; - const choices = form.options.map((option) => ({ key: option.key, value: option.value })); - - await ResourceField.query().findById(field.id).update({ - data_type: form.data_type, - label_name: form.label, - help_text: form.help_text, - default: form.default, - options: choices, - }); - return res.status(200).send({ id: field.id }); - }, - }, - - /** - * Retrieve the fields list of the given resource. - * @param {Request} req - - * @param {Response} res - - */ - fieldsList: { - validation: [ - param('resource_name').toInt(), - ], - async handler(req, res) { - const validationErrors = validationResult(req); - - if (!validationErrors.isEmpty()) { - return res.boom.badData(null, { - code: 'validation_error', ...validationErrors, - }); - } - const { resource_name: resourceName } = req.params; - const resource = await Resource.query().where('name', resourceName).first(); - - if (!resource) { - return res.boom.notFound(null, { - errors: [{ type: 'RESOURCE_NOT_FOUND', code: 100 }], - }); - } - const fields = await ResourceField.where('resource_id', resource.id).fetchAll(); - - return res.status(200).send({ fields: fields.toJSON() }); - }, - }, - - /** - * Change status of the given field. - */ - changeStatus: { - validation: [ - param('field_id').toInt(), - check('active').isBoolean().toBoolean(), - ], - async handler(req, res) { - const { field_id: fieldId } = req.params; - const field = await ResourceField.query().findById(fieldId); - - if (!field) { - return res.boom.notFound(null, { - errors: [{ type: 'NOT_FOUND_FIELD', code: 100 }], - }); - } - - const { active } = req.body; - await ResourceField.query().findById(field.id).patch({ active }); - - return res.status(200).send({ id: field.id }); - }, - }, - - /** - * Retrieve details of the given field. - */ - getField: { - validation: [ - param('field_id').toInt(), - ], - async handler(req, res) { - const { field_id: id } = req.params; - const field = await ResourceField.where('id', id).fetch(); - - if (!field) { - return res.boom.notFound(); - } - - return res.status(200).send({ - field: field.toJSON(), - }); - }, - }, - - /** - * Delete the given field. - */ - deleteField: { - validation: [ - param('field_id').toInt(), - ], - async handler(req, res) { - const { field_id: id } = req.params; - const field = await ResourceField.where('id', id).fetch(); - - if (!field) { - return res.boom.notFound(); - } - if (field.attributes.predefined) { - return res.boom.badRequest(null, { - errors: [{ type: 'PREDEFINED_FIELD', code: 100 }], - }); - } - await field.destroy(); - - return res.status(200).send({ id: field.get('id') }); - }, - }, -}; diff --git a/server/src/api/controllers/ManualJournals.ts b/server/src/api/controllers/ManualJournals.ts new file mode 100644 index 000000000..486d681f8 --- /dev/null +++ b/server/src/api/controllers/ManualJournals.ts @@ -0,0 +1,377 @@ +import { Request, Response, Router, NextFunction } from 'express'; +import { check, param, query } from 'express-validator'; +import BaseController from 'api/controllers/BaseController'; +import asyncMiddleware from 'api/middleware/asyncMiddleware'; +import ManualJournalsService from 'services/ManualJournals/ManualJournalsService'; +import { Inject, Service } from "typedi"; +import { ServiceError } from 'exceptions'; +import DynamicListingService from 'services/DynamicListing/DynamicListService'; + +@Service() +export default class ManualJournalsController extends BaseController { + + @Inject() + manualJournalsService: ManualJournalsService; + + @Inject() + dynamicListService: DynamicListingService; + + /** + * Router constructor. + */ + router() { + const router = Router(); + + router.get( + '/', [ + ...this.manualJournalsListSchema, + ], + this.validationResult, + asyncMiddleware(this.getManualJournalsList.bind(this)), + this.dynamicListService.handlerErrorsToResponse, + this.catchServiceErrors, + ); + router.get( + '/:id', + asyncMiddleware(this.getManualJournal.bind(this)), + this.catchServiceErrors, + ); + router.post( + '/publish', [ + ...this.manualJournalIdsSchema, + ], + this.validationResult, + asyncMiddleware(this.publishManualJournals.bind(this)), + this.catchServiceErrors, + ); + router.post( + '/:id/publish', [ + ...this.manualJournalParamSchema, + ], + this.validationResult, + asyncMiddleware(this.publishManualJournal.bind(this)), + this.catchServiceErrors, + ); + router.post( + '/:id', [ + ...this.manualJournalValidationSchema, + ...this.manualJournalParamSchema, + ], + this.validationResult, + asyncMiddleware(this.editManualJournal.bind(this)), + this.catchServiceErrors, + ); + router.delete( + '/:id', [ + ...this.manualJournalParamSchema, + ], + this.validationResult, + asyncMiddleware(this.deleteManualJournal.bind(this)), + this.catchServiceErrors, + ); + router.delete( + '/', [ + ...this.manualJournalIdsSchema, + ], + this.validationResult, + asyncMiddleware(this.deleteBulkManualJournals.bind(this)), + this.catchServiceErrors, + ); + router.post( + '/', [ + ...this.manualJournalValidationSchema, + ], + this.validationResult, + asyncMiddleware(this.makeJournalEntries.bind(this)), + this.catchServiceErrors, + ); + return router; + } + + /** + * Specific manual journal id param validation schema. + */ + get manualJournalParamSchema() { + return [ + param('id').exists().isNumeric().toInt() + ]; + } + + /** + * Manual journal bulk ids validation schema. + */ + get manualJournalIdsSchema() { + return [ + query('ids').isArray({ min: 1 }), + query('ids.*').isNumeric().toInt(), + ] + } + + /** + * Manual journal DTO schema. + */ + get manualJournalValidationSchema() { + return [ + check('date').exists().isISO8601(), + check('journal_number').exists().trim().escape(), + check('journal_type').optional({ nullable: true }).trim().escape(), + check('reference').optional({ nullable: true }), + check('description').optional().trim().escape(), + check('status').optional().isBoolean().toBoolean(), + check('entries').isArray({ min: 2 }), + check('entries.*.index').exists().isNumeric().toInt(), + check('entries.*.credit') + .optional({ nullable: true }) + .isNumeric() + .isDecimal() + .isFloat({ max: 9999999999.999 }) // 13, 3 + .toFloat(), + check('entries.*.debit') + .optional({ nullable: true }) + .isNumeric() + .isDecimal() + .isFloat({ max: 9999999999.999 }) // 13, 3 + .toFloat(), + check('entries.*.account_id').isNumeric().toInt(), + check('entries.*.note').optional(), + check('entries.*.contact_id') + .optional({ nullable: true }) + .isNumeric() + .toInt(), + check('entries.*.contact_type').optional().isIn(['vendor', 'customer']), + ] + } + + /** + * Manual journals list validation schema. + */ + get manualJournalsListSchema() { + return [ + query('page').optional().isNumeric().toInt(), + query('page_size').optional().isNumeric().toInt(), + query('custom_view_id').optional().isNumeric().toInt(), + + query('column_sort_by').optional().trim().escape(), + query('sort_order').optional().isIn(['desc', 'asc']), + + query('stringified_filter_roles').optional().isJSON(), + ]; + } + + async getManualJournal(req: Request, res: Response, next: NextFunction) { + const { tenantId } = req; + const { id: manualJournalId } = req.params; + + try { + const manualJournal = await this.manualJournalsService.getManualJournal(tenantId, manualJournalId); + return res.status(200).send({ manualJournal }); + } catch (error) { + next(error); + }; + } + + /** + * Publish the given manual journal. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + async publishManualJournal(req: Request, res: Response, next: NextFunction) { + const { tenantId } = req; + const { id: manualJournalId } = req.params; + + try { + await this.manualJournalsService.publishManualJournal(tenantId, manualJournalId); + + return res.status(200).send(); + } catch (error) { + next(error); + } + } + + /** + * Publish the given manual journals in bulk. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + async publishManualJournals(req: Request, res: Response, next: NextFunction) { + const { tenantId } = req; + const { ids: manualJournalsIds } = req.query; + + try { + await this.manualJournalsService.publishManualJournals(tenantId, manualJournalsIds); + + return res.status(200).send(); + } catch (error) { + next(error); + } + } + + /** + * Delete the given manual journal. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + async deleteManualJournal(req: Request, res: Response, next: NextFunction) { + const { tenantId, user } = req; + const { id: manualJournalId } = req.params; + + try { + await this.manualJournalsService.deleteManualJournal(tenantId, manualJournalId); + return res.status(200).send(); + } catch (error) { + next(error); + } + } + + /** + * Deletes manual journals in bulk. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + async deleteBulkManualJournals(req: Request, res: Response, next: NextFunction) { + const { tenantId } = req; + const { ids: manualJournalsIds } = req.query; + + try { + await this.manualJournalsService.deleteManualJournals(tenantId, manualJournalsIds); + return res.status(200).send(); + } catch (error) { + next(error); + } + } + + /** + * Make manual journal. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + async makeJournalEntries(req: Request, res: Response, next: NextFunction) { + const { tenantId, user } = req; + const manualJournalDTO = this.matchedBodyData(req); + + try { + const { manualJournal } = await this.manualJournalsService.makeJournalEntries(tenantId, manualJournalDTO, user); + + return res.status(200).send({ id: manualJournal.id }); + } catch (error) { + next(error); + } + } + + /** + * Edit the given manual journal. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + async editManualJournal(req: Request, res: Response, next: NextFunction) { + const { tenantId, user } = req; + const { id: manualJournalId } = req.params; + const manualJournalDTO = this.matchedBodyData(req); + + try { + const { manualJournal } = await this.manualJournalsService.editJournalEntries( + tenantId, + manualJournalId, + manualJournalDTO, + user, + ); + return res.status(200).send({ id: manualJournal.id }); + } catch (error) { + next(error); + } + } + + /** + * Retrieve manual journals list. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + async getManualJournalsList(req: Request, res: Response, next: NextFunction) { + const { tenantId } = req; + const filter = { + sortOrder: 'asc', + columnSortBy: 'created_at', + filterRoles: [], + ...this.matchedQueryData(req), + } + if (filter.stringifiedFilterRoles) { + filter.filterRoles = JSON.parse(filter.stringifiedFilterRoles); + } + try { + const manualJournals = await this.manualJournalsService.getManualJournals(tenantId, filter); + return res.status(200).send({ manualJournals }); + } catch (error) { + console.log(error); + + next(error); + } + } + + /** + * Catches all service errors. + * @param error + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + catchServiceErrors(error, req: Request, res: Response, next: NextFunction) { + if (error instanceof ServiceError) { + if (error.errorType === 'manual_journal_not_found') { + res.boom.badRequest( + 'Manual journal not found.', + { errors: [{ type: 'MANUAL.JOURNAL.NOT.FOUND', code: 100 }], } + ) + } + if (error.errorType === 'credit_debit_not_equal_zero') { + return res.boom.badRequest( + 'Credit and debit should not be equal zero.', + { errors: [{ type: 'CREDIT.DEBIT.SUMATION.SHOULD.NOT.EQUAL.ZERO', code: 400, }] } + ) + } + if (error.errorType === 'credit_debit_not_equal') { + return res.boom.badRequest( + 'Credit and debit should be equal.', + { errors: [{ type: 'CREDIT.DEBIT.NOT.EQUALS', code: 100 }] } + ) + } + if (error.errorType === 'acccounts_ids_not_found') { + return res.boom.badRequest( + 'Journal entries some of accounts ids not exists.', + { errors: [{ type: 'ACCOUNTS.IDS.NOT.FOUND', code: 200 }] } + ) + } + if (error.errorType === 'journal_number_exists') { + return res.boom.badRequest( + 'Journal number should be unique.', + { errors: [{ type: 'JOURNAL.NUMBER.ALREADY.EXISTS', code: 300 }] }, + ); + } + if (error.errorType === 'payabel_entries_have_no_vendors') { + return res.boom.badRequest( + '', + { errors: [{ type: '' }] }, + ); + } + if (error.errorType === 'receivable_entries_have_no_customers') { + return res.boom.badRequest( + '', + { errors: [{ type: '' }] }, + ); + } + if (error.errorType === 'contacts_not_found') { + return res.boom.badRequest( + '', + { errors: [{ type: '' }] }, + ); + } + } + next(error); + } +} \ No newline at end of file diff --git a/server/src/api/index.ts b/server/src/api/index.ts index 6895803ce..ca728a57e 100644 --- a/server/src/api/index.ts +++ b/server/src/api/index.ts @@ -20,7 +20,7 @@ import ItemCategories from 'api/controllers/ItemCategories'; import Accounts from 'api/controllers/Accounts'; import AccountTypes from 'api/controllers/AccountTypes'; import Views from 'api/controllers/Views'; -import Accounting from 'api/controllers/Accounting'; +import ManualJournals from 'api/controllers/ManualJournals'; import FinancialStatements from 'api/controllers/FinancialStatements'; import Expenses from 'api/controllers/Expenses'; import Settings from 'api/controllers/Settings'; @@ -63,7 +63,8 @@ export default () => { dashboard.use('/currencies', Currencies.router()); dashboard.use('/accounts', Container.get(Accounts).router()); dashboard.use('/account_types', Container.get(AccountTypes).router()); - dashboard.use('/accounting', Accounting.router()); + // dashboard.use('/accounting', Accounting.router()); + dashboard.use('/manual-journals', Container.get(ManualJournals).router()); dashboard.use('/views', Views.router()); dashboard.use('/items', Container.get(Items).router()); dashboard.use('/item_categories', Container.get(ItemCategories).router()); diff --git a/server/src/database/migrations/20200608192614_create_vendors_table.js b/server/src/database/migrations/20200608192614_create_vendors_table.js deleted file mode 100644 index 3b1f56160..000000000 --- a/server/src/database/migrations/20200608192614_create_vendors_table.js +++ /dev/null @@ -1,46 +0,0 @@ - -exports.up = function(knex) { - return knex.schema.createTable('vendors', table => { - table.increments(); - - table.string('customer_type'); - table.decimal('balance', 13, 3).defaultTo(0); - - table.string('first_name').nullable(); - table.string('last_name').nullable(); - table.string('company_name').nullable(); - - table.string('display_name'); - - table.string('email').nullable(); - table.string('work_phone').nullable(); - table.string('personal_phone').nullable(); - - table.string('billing_address_1').nullable(); - table.string('billing_address_2').nullable(); - table.string('billing_address_city').nullable(); - table.string('billing_address_country').nullable(); - table.string('billing_address_email').nullable(); - table.string('billing_address_zipcode').nullable(); - table.string('billing_address_phone').nullable(); - table.string('billing_address_state').nullable(), - - table.string('shipping_address_1').nullable(); - table.string('shipping_address_2').nullable(); - table.string('shipping_address_city').nullable(); - table.string('shipping_address_country').nullable(); - table.string('shipping_address_email').nullable(); - table.string('shipping_address_zipcode').nullable(); - table.string('shipping_address_phone').nullable(); - table.string('shipping_address_state').nullable(); - - table.text('note'); - table.boolean('active').defaultTo(true); - - table.timestamps(); - }); -}; - -exports.down = function(knex) { - return knex.schema.dropTableIfExists('vendors'); -}; diff --git a/server/src/interfaces/ManualJournal.ts b/server/src/interfaces/ManualJournal.ts new file mode 100644 index 000000000..74a36ba65 --- /dev/null +++ b/server/src/interfaces/ManualJournal.ts @@ -0,0 +1,53 @@ +import { IDynamicListFilterDTO } from "./DynamicFilter"; +import { IJournalEntry } from "./Journal"; +import { ISystemUser } from "./User"; + + +export interface IManualJournal { + id: number, + date: Date|string, + journalNumber: number, + journalType: string, + amount: number, + status: boolean, + description: string, + userId: number, + entries: IJournalEntry[], +} + +export interface IManualJournalEntryDTO { + index: number, + credit: number, + debit: number, + accountId: number, + note?: string, + contactId?: number, + contactType?: string, +} + +export interface IManualJournalDTO { + date: Date, + journalNumber: number, + journalType: string, + reference?: string, + description?: string, + status?: string, + entries: IManualJournalEntryDTO[], + mediaIds: number[], +} + +export interface IManualJournalsFilter extends IDynamicListFilterDTO { + stringifiedFilterRoles?: string, + page?: number, + pageSize?: number, +} + +export interface IManuaLJournalsService { + makeJournalEntries(tenantId: number, manualJournalDTO: IManualJournalDTO, authorizedUser: ISystemUser): Promise<{ manualJournal: IManualJournal }>; + editJournalEntries(tenantId: number, manualJournalId: number, manualJournalDTO: IManualJournalDTO, authorizedUser): Promise<{ manualJournal: IManualJournal }>; + deleteManualJournal(tenantId: number, manualJournalId: number): Promise; + deleteManualJournals(tenantId: number, manualJournalsIds: number[]): Promise; + publishManualJournals(tenantId: number, manualJournalsIds: number[]): Promise; + publishManualJournal(tenantId: number, manualJournalId: number): Promise; + getManualJournals(tenantId: number, filter: IManualJournalsFilter): Promise; +} \ No newline at end of file diff --git a/server/src/interfaces/index.ts b/server/src/interfaces/index.ts index 20f8db9f1..7738cfa11 100644 --- a/server/src/interfaces/index.ts +++ b/server/src/interfaces/index.ts @@ -20,3 +20,4 @@ export * from './Contact'; export * from './Expenses'; export * from './Tenancy'; export * from './View'; +export * from './ManualJournal'; \ No newline at end of file diff --git a/server/src/loaders/events.ts b/server/src/loaders/events.ts index e46ce2d8e..fed18f4b3 100644 --- a/server/src/loaders/events.ts +++ b/server/src/loaders/events.ts @@ -1,3 +1,4 @@ // Here we import all events. import 'subscribers/authentication'; import 'subscribers/organization'; +import 'subscribers/manualJournals'; diff --git a/server/src/loaders/tenantRepositories.ts b/server/src/loaders/tenantRepositories.ts index 628832103..28b2b30c1 100644 --- a/server/src/loaders/tenantRepositories.ts +++ b/server/src/loaders/tenantRepositories.ts @@ -5,6 +5,7 @@ import CustomerRepository from 'repositories/CustomerRepository'; import ExpenseRepository from 'repositories/ExpenseRepository'; import ViewRepository from 'repositories/ViewRepository'; import ViewRoleRepository from 'repositories/ViewRoleRepository'; +import ContactRepository from 'repositories/ContactRepository'; export default (tenantId: number) => { return { @@ -12,6 +13,7 @@ export default (tenantId: number) => { accountTypeRepository: new AccountTypeRepository(tenantId), customerRepository: new CustomerRepository(tenantId), vendorRepository: new VendorRepository(tenantId), + contactRepository: new ContactRepository(tenantId), expenseRepository: new ExpenseRepository(tenantId), viewRepository: new ViewRepository(tenantId), viewRoleRepository: new ViewRoleRepository(tenantId), diff --git a/server/src/models/Expense.js b/server/src/models/Expense.js index a5b0b4e41..5ea5710ef 100644 --- a/server/src/models/Expense.js +++ b/server/src/models/Expense.js @@ -71,7 +71,6 @@ export default class Expense extends TenantModel { */ static get relationMappings() { const Account = require('models/Account'); - const User = require('models/TenantUser'); const ExpenseCategory = require('models/ExpenseCategory'); return { @@ -91,14 +90,6 @@ export default class Expense extends TenantModel { to: 'expense_transaction_categories.expenseId', }, }, - user: { - relation: Model.BelongsToOneRelation, - modelClass: this.relationBindKnex(User.default), - join: { - from: 'expenses_transactions.userId', - to: 'users.id', - }, - }, }; } } diff --git a/server/src/models/ManualJournal.js b/server/src/models/ManualJournal.js index b798dd7a9..648275eb9 100644 --- a/server/src/models/ManualJournal.js +++ b/server/src/models/ManualJournal.js @@ -1,5 +1,6 @@ import { Model } from 'objection'; import TenantModel from 'models/TenantModel'; +import { AccountTransaction } from 'models'; export default class ManualJournal extends TenantModel { /** @@ -21,8 +22,20 @@ export default class ManualJournal extends TenantModel { */ static get relationMappings() { const Media = require('models/Media'); + const AccountTransaction = require('models/AccountTransaction'); return { + entries: { + relation: Model.HasManyRelation, + modelClass: this.relationBindKnex(AccountTransaction.default), + join: { + from: 'manual_journals.id', + to: 'accounts_transactions.referenceId', + }, + filter: (query) => { + query.where('referenceType', 'Journal'); + }, + }, media: { relation: Model.ManyToManyRelation, modelClass: this.relationBindKnex(Media.default), diff --git a/server/src/models/Model.js b/server/src/models/Model.js index c67fb9684..fdd8dd9b0 100644 --- a/server/src/models/Model.js +++ b/server/src/models/Model.js @@ -6,6 +6,15 @@ import DateSession from 'models/DateSession'; export default class ModelBase extends mixin(Model, [DateSession]) { + + static query(...args) { + return super.query(...args).onBuildKnex(knexQueryBuilder => { + knexQueryBuilder.on('query', queryData => { + console.log(queryData); + }); + }); + } + get timestamps() { return []; } diff --git a/server/src/repositories/ContactRepository.ts b/server/src/repositories/ContactRepository.ts new file mode 100644 index 000000000..b25942ec4 --- /dev/null +++ b/server/src/repositories/ContactRepository.ts @@ -0,0 +1,37 @@ +import TenantRepository from 'repositories/TenantRepository'; + +export default class ContactRepository extends TenantRepository { + cache: any; + models: any; + + /** + * Constructor method. + * @param {number} tenantId - The given tenant id. + */ + constructor( + tenantId: number, + ) { + super(tenantId); + + this.models = this.tenancy.models(tenantId); + this.cache = this.tenancy.cache(tenantId); + } + + findById(contactId: number) { + const { Contact } = this.models; + return this.cache.get(`contact.id.${contactId}`, () => { + return Contact.query().findById(contactId); + }) + } + + findByIds(contactIds: number[]) { + const { Contact } = this.models; + return this.cache.get(`contact.ids.${contactIds.join(',')}`, () => { + return Contact.query().whereIn('id', contactIds); + }); + } + + insert(contact) { + + } +} \ No newline at end of file diff --git a/server/src/services/Accounting/JournalCommands.ts b/server/src/services/Accounting/JournalCommands.ts index 8f7f893df..9e07d306b 100644 --- a/server/src/services/Accounting/JournalCommands.ts +++ b/server/src/services/Accounting/JournalCommands.ts @@ -2,7 +2,7 @@ import { sumBy, chain } from 'lodash'; import JournalPoster from "./JournalPoster"; import JournalEntry from "./JournalEntry"; import { AccountTransaction } from 'models'; -import { IInventoryTransaction } from 'interfaces'; +import { IInventoryTransaction, IManualJournal } from 'interfaces'; import AccountsService from '../Accounts/AccountsService'; import { IInventoryTransaction, IInventoryTransaction } from '../../interfaces'; @@ -120,6 +120,11 @@ export default class JournalCommands{ this.journal.credit(creditEntry); } + /** + * + * @param {number|number[]} referenceId + * @param {string} referenceType + */ async revertJournalEntries( referenceId: number|number[], referenceType: string @@ -135,6 +140,36 @@ export default class JournalCommands{ this.journal.removeEntries(); } + + /** + * Writes journal entries from manual journal model object. + * @param {IManualJournal} manualJournalObj + * @param {number} manualJournalId + */ + async manualJournal(manualJournalObj: IManualJournal, manualJournalId: number) { + manualJournalObj.entries.forEach((entry) => { + const jouranlEntry = new JournalEntry({ + debit: entry.debit, + credit: entry.credit, + account: entry.account, + referenceType: 'Journal', + referenceId: manualJournalId, + contactType: entry.contactType, + contactId: entry.contactId, + note: entry.note, + date: manualJournalObj.date, + userId: manualJournalObj.userId, + draft: !manualJournalObj.status, + index: entry.index, + }); + if (entry.debit) { + this.journal.debit(jouranlEntry); + } else { + this.journal.credit(jouranlEntry); + } + }); + } + /** * Removes and revert accounts balance journal entries that associated * to the given inventory transactions. diff --git a/server/src/services/ManualJournals/ManualJournalsService.ts b/server/src/services/ManualJournals/ManualJournalsService.ts new file mode 100644 index 000000000..614107497 --- /dev/null +++ b/server/src/services/ManualJournals/ManualJournalsService.ts @@ -0,0 +1,460 @@ +import { difference, sumBy, omit } from 'lodash'; +import { Service, Inject } from "typedi"; +import moment from 'moment'; +import { ServiceError } from "exceptions"; +import { + IManualJournalDTO, + IManuaLJournalsService, + IManualJournalsFilter, + ISystemUser, + IManualJournal, + IManualJournalEntryDTO, +} from 'interfaces'; +import TenancyService from 'services/Tenancy/TenancyService'; +import DynamicListingService from 'services/DynamicListing/DynamicListService'; +import events from 'subscribers/events'; +import { + EventDispatcher, + EventDispatcherInterface, +} from 'decorators/eventDispatcher'; +import JournalPoster from 'services/Accounting/JournalPoster'; +import JournalCommands from 'services/Accounting/JournalCommands'; + +const ERRORS = { + NOT_FOUND: 'manual_journal_not_found', + CREDIT_DEBIT_NOT_EQUAL_ZERO: 'credit_debit_not_equal_zero', + CREDIT_DEBIT_NOT_EQUAL: 'credit_debit_not_equal', + ACCCOUNTS_IDS_NOT_FOUND: 'acccounts_ids_not_found', + JOURNAL_NUMBER_EXISTS: 'journal_number_exists', + RECEIVABLE_ENTRIES_NO_CUSTOMERS: 'receivable_entries_have_no_customers', + PAYABLE_ENTRIES_NO_VENDORS: 'payabel_entries_have_no_vendors', + CONTACTS_NOT_FOUND: 'contacts_not_found', +}; + +@Service() +export default class ManualJournalsService implements IManuaLJournalsService { + @Inject() + tenancy: TenancyService; + + @Inject() + dynamicListService: DynamicListingService; + + @Inject('logger') + logger: any; + + @EventDispatcher() + eventDispatcher: EventDispatcherInterface; + + /** + * Validates the manual journal existance. + * @param {number} tenantId + * @param {number} manualJournalId + */ + private async validateManualJournalExistance(tenantId: number, manualJournalId: number) { + const { ManualJournal } = this.tenancy.models(tenantId); + + this.logger.info('[manual_journal] trying to validate existance.', { tenantId, manualJournalId }); + const manualJournal = await ManualJournal.query().findById(manualJournalId); + + if (!manualJournal) { + this.logger.warn('[manual_journal] not exists on the storage.', { tenantId, manualJournalId }); + throw new ServiceError(ERRORS.NOT_FOUND); + } + } + + /** + * Validate manual journals existance. + * @param {number} tenantId + * @param {number[]} manualJournalsIds + * @throws {ServiceError} + */ + private async validateManualJournalsExistance(tenantId: number, manualJournalsIds: number[]) { + const { ManualJournal } = this.tenancy.models(tenantId); + + const manualJournals = await ManualJournal.query().whereIn('id', manualJournalsIds); + + const notFoundManualJournals = difference( + manualJournalsIds, + manualJournals.map((m) => m.id) + ); + if (notFoundManualJournals.length > 0) { + throw new ServiceError(ERRORS.NOT_FOUND); + } + } + + /** + * Validate manual journal credit and debit should be equal. + * @param {IManualJournalDTO} manualJournalDTO + */ + private valdiateCreditDebitTotalEquals(manualJournalDTO: IManualJournalDTO) { + let totalCredit = 0; + let totalDebit = 0; + + manualJournalDTO.entries.forEach((entry) => { + if (entry.credit > 0) { + totalCredit += entry.credit; + } + if (entry.debit > 0) { + totalDebit += entry.debit; + } + }); + if (totalCredit <= 0 || totalDebit <= 0) { + this.logger.info('[manual_journal] the total credit and debit equals zero.'); + throw new ServiceError(ERRORS.CREDIT_DEBIT_NOT_EQUAL_ZERO); + } + if (totalCredit !== totalDebit) { + this.logger.info('[manual_journal] the total credit not equals total debit.'); + throw new ServiceError(ERRORS.CREDIT_DEBIT_NOT_EQUAL); + } + } + + /** + * Validate manual entries accounts existance on the storage. + * @param {number} tenantId + * @param {IManualJournalDTO} manualJournalDTO + */ + private async validateAccountsExistance(tenantId: number, manualJournalDTO: IManualJournalDTO) { + const { Account } = this.tenancy.models(tenantId); + const manualAccountsIds = manualJournalDTO.entries.map(e => e.accountId); + + const accounts = await Account.query() + .whereIn('id', manualAccountsIds) + .withGraphFetched('type'); + + const storedAccountsIds = accounts.map((account) => account.id); + + if (difference(manualAccountsIds, storedAccountsIds).length > 0) { + this.logger.info('[manual_journal] some entries accounts not exist.', { tenantId, manualAccountsIds }); + throw new ServiceError(ERRORS.ACCCOUNTS_IDS_NOT_FOUND); + } + } + + /** + * Validate manual journal number unique. + * @param {number} tenantId + * @param {IManualJournalDTO} manualJournalDTO + */ + private async validateManualJournalNoUnique(tenantId: number, manualJournalDTO: IManualJournalDTO, notId?: numebr) { + const { ManualJournal } = this.tenancy.models(tenantId); + const journalNumber = await ManualJournal.query().where( + 'journal_number', + manualJournalDTO.journalNumber, + ).onBuild((builder) => { + if (notId) { + builder.whereNot('id', notId); + } + }); + if (journalNumber.length > 0) { + throw new ServiceError(ERRORS.JOURNAL_NUMBER_EXISTS); + } + } + + /** + * Validate entries that have receivable account should have customer type. + * @param {number} tenantId - + * @param {IManualJournalDTO} manualJournalDTO + */ + private async validateReceivableEntries(tenantId: number, manualJournalDTO: IManualJournalDTO): Promise { + const { accountRepository } = this.tenancy.repositories(tenantId); + const receivableAccount = await accountRepository.getBySlug('accounts-receivable'); + + const entriesHasNoReceivableAccount = manualJournalDTO.entries.filter( + (e) => (e.accountId === receivableAccount.id) && + (!e.contactId || e.contactType !== 'customer') + ); + if (entriesHasNoReceivableAccount.length > 0) { + throw new ServiceError(ERRORS.RECEIVABLE_ENTRIES_NO_CUSTOMERS); + } + } + + /** + * Validates payable entries should have vendor type. + * @param {number} tenantId - + * @param {IManualJournalDTO} manualJournalDTO + */ + private async validatePayableEntries(tenantId: number, manualJournalDTO: IManualJournalDTO): Promise { + const { accountRepository } = this.tenancy.repositories(tenantId); + const payableAccount = await accountRepository.getBySlug('accounts-payable'); + + const entriesHasNoVendorContact = manualJournalDTO.entries.filter( + (e) => (e.accountId === payableAccount.id) && + (!e.contactId || e.contactType !== 'vendor') + ); + if (entriesHasNoVendorContact.length > 0) { + throw new ServiceError(ERRORS.PAYABLE_ENTRIES_NO_VENDORS); + } + } + + /** + * Vaplidate entries contacts existance. + * @param {number} tenantId - + * @param {IManualJournalDTO} manualJournalDTO + */ + private async validateContactsExistance(tenantId: number, manualJournalDTO: IManualJournalDTO) { + const { contactRepository } = this.tenancy.repositories(tenantId); + const manualJCotactsIds = manualJournalDTO.entries + .filter((entry) => entry.contactId) + .map((entry) => entry.contactId); + + if (manualJCotactsIds.length > 0) { + const storedContacts = await contactRepository.findByIds(manualJCotactsIds); + const storedContactsIds = storedContacts.map((c) => c.id); + + const notFoundContactsIds = difference(manualJCotactsIds, storedContactsIds); + + if (notFoundContactsIds.length > 0) { + throw new ServiceError(ERRORS.CONTACTS_NOT_FOUND); + } + } + } + + /** + * Transform manual journal DTO to graphed model to save it. + * @param {IManualJournalDTO} manualJournalDTO + * @param {ISystemUser} authorizedUser + */ + private transformDTOToModel(manualJournalDTO: IManualJournalDTO, user: ISystemUser): IManualJournal { + const amount = sumBy(manualJournalDTO.entries, 'credit') || 0; + const date = moment(manualJournalDTO.date).format('YYYY-MM-DD'); + + return { + ...manualJournalDTO, + amount, + date, + userId: user.id, + entries: this.transformDTOToEntriesModel(manualJournalDTO.entries), + }; + } + + /** + * + * @param {IManualJournalEntryDTO[]} entries + */ + private transformDTOToEntriesModel(entries: IManualJournalEntryDTO[]) { + return entries.map((entry: IManualJournalEntryDTO) => ({ + ...omit(entry, ['accountId']), + account: entry.accountId, + })) + } + + /** + * Make journal entries. + * @param {number} tenantId + * @param {IManualJournalDTO} manualJournalDTO + * @param {ISystemUser} authorizedUser + */ + public async makeJournalEntries( + tenantId: number, + manualJournalDTO: IManualJournalDTO, + authorizedUser: ISystemUser + ): Promise<{ manualJournal: IManualJournal }> { + const { ManualJournal } = this.tenancy.models(tenantId); + + this.valdiateCreditDebitTotalEquals(manualJournalDTO); + + await this.validateReceivableEntries(tenantId, manualJournalDTO); + await this.validatePayableEntries(tenantId, manualJournalDTO); + await this.validateContactsExistance(tenantId, manualJournalDTO); + + await this.validateAccountsExistance(tenantId, manualJournalDTO); + await this.validateManualJournalNoUnique(tenantId, manualJournalDTO); + + this.logger.info('[manual_journal] trying to save manual journal to the storage.', { tenantId, manualJournalDTO }); + const manualJournalObj = this.transformDTOToModel(manualJournalDTO, authorizedUser); + + const storedManualJournal = await ManualJournal.query().insert({ + ...omit(manualJournalObj, ['entries']), + }); + const manualJournal: IManualJournal = { ...manualJournalObj, id: storedManualJournal.id }; + + // Triggers `onManualJournalCreated` event. + this.eventDispatcher.dispatch(events.manualJournals.onCreated, { + tenantId, manualJournal, + }); + this.logger.info('[manual_journal] the manual journal inserted successfully.', { tenantId }); + + return { manualJournal }; + } + + /** + * Edits jouranl entries. + * @param {number} tenantId + * @param {number} manualJournalId + * @param {IMakeJournalDTO} manualJournalDTO + * @param {ISystemUser} authorizedUser + */ + public async editJournalEntries( + tenantId: number, + manualJournalId: number, + manualJournalDTO: IManualJournalDTO, + authorizedUser: ISystemUser + ): Promise<{ manualJournal: IManualJournal }> { + const { ManualJournal } = this.tenancy.models(tenantId); + + await this.validateManualJournalExistance(tenantId, manualJournalId); + + this.valdiateCreditDebitTotalEquals(manualJournalDTO); + + await this.validateAccountsExistance(tenantId, manualJournalDTO); + await this.validateManualJournalNoUnique(tenantId, manualJournalDTO, manualJournalId); + + const manualJournalObj = this.transformDTOToModel(manualJournalDTO, authorizedUser); + + const storedManualJournal = await ManualJournal.query().where('id', manualJournalId) + .patch({ + ...omit(manualJournalObj, ['entries']), + }); + const manualJournal: IManualJournal = { ...manualJournalObj, id: manualJournalId }; + + // Triggers `onManualJournalEdited` event. + this.eventDispatcher.dispatch(events.manualJournals.onEdited, { + tenantId, manualJournal, + }); + return { manualJournal }; + } + + /** + * Deletes the given manual journal + * @param {number} tenantId + * @param {number} manualJournalId + * @return {Promise} + */ + public async deleteManualJournal(tenantId: number, manualJournalId: number): Promise { + const { ManualJournal } = this.tenancy.models(tenantId); + await this.validateManualJournalExistance(tenantId, manualJournalId); + + this.logger.info('[manual_journal] trying to delete the manual journal.', { tenantId, manualJournalId }); + await ManualJournal.query().findById(manualJournalId).delete(); + + // Triggers `onManualJournalDeleted` event. + this.eventDispatcher.dispatch(events.manualJournals.onDeleted, { + tenantId, manualJournalId, + }); + this.logger.info('[manual_journal] the given manual journal deleted successfully.', { tenantId, manualJournalId }); + } + + /** + * Deletes the given manual journals. + * @param {number} tenantId + * @param {number[]} manualJournalsIds + * @return {Promise} + */ + public async deleteManualJournals(tenantId: number, manualJournalsIds: number[]): Promise { + const { ManualJournal } = this.tenancy.models(tenantId); + await this.validateManualJournalsExistance(tenantId, manualJournalsIds); + + this.logger.info('[manual_journal] trying to delete the manual journals.', { tenantId, manualJournalsIds }); + await ManualJournal.query().where('id', manualJournalsIds).delete(); + + // Triggers `onManualJournalDeletedBulk` event. + this.eventDispatcher.dispatch(events.manualJournals.onDeletedBulk, { + tenantId, manualJournalsIds, + }); + this.logger.info('[manual_journal] the given manual journals deleted successfully.', { tenantId, manualJournalsIds }); + } + + /** + * Publish the given manual journals. + * @param {number} tenantId + * @param {number[]} manualJournalsIds + */ + public async publishManualJournals(tenantId: number, manualJournalsIds: number[]): Promise { + const { ManualJournal } = this.tenancy.models(tenantId); + await this.validateManualJournalsExistance(tenantId, manualJournalsIds); + + this.logger.info('[manual_journal] trying to publish the manual journal.', { tenantId, manualJournalsIds }); + await ManualJournal.query().whereIn('id', manualJournalsIds).patch({ status: 1, }); + + // Triggers `onManualJournalPublishedBulk` event. + this.eventDispatcher.dispatch(events.manualJournals.onPublishedBulk, { + tenantId, manualJournalsIds, + }); + this.logger.info('[manual_journal] the given manula journal published successfully.', { tenantId, manualJournalId }); + } + + /** + * Publish the given manual journal. + * @param {number} tenantId + * @param {number} manualJournalId + */ + public async publishManualJournal(tenantId: number, manualJournalId: number): Promise { + const { ManualJournal } = this.tenancy.models(tenantId); + await this.validateManualJournalExistance(tenantId, manualJournalId); + + this.logger.info('[manual_journal] trying to publish the manual journal.', { tenantId, manualJournalId }); + await ManualJournal.query().findById(manualJournalId).patch({ status: 1, }); + + // Triggers `onManualJournalPublishedBulk` event. + this.eventDispatcher.dispatch(events.manualJournals.onPublished, { + tenantId, manualJournalId, + }); + this.logger.info('[manual_journal] the given manula journal published successfully.', { tenantId, manualJournalId }); + } + + /** + * Retrieve manual journals datatable list. + * @param {number} tenantId + * @param {IManualJournalsFilter} filter + */ + public async getManualJournals(tenantId: number, filter: IManualJournalsFilter) { + const { ManualJournal } = this.tenancy.models(tenantId); + + const dynamicList = await this.dynamicListService.dynamicList(tenantId, ManualJournal, filter); + + this.logger.info('[manual_journals] trying to get manual journals list.', { tenantId, filter }); + const manualJournal = await ManualJournal.query().onBuild((builder) => { + dynamicList.buildQuery()(builder); + }); + return manualJournal; + } + + /** + * Retrieve manual journal details with assocaited journal transactions. + * @param {number} tenantId + * @param {number} manualJournalId + */ + public async getManualJournal(tenantId: number, manualJournalId: number) { + const { ManualJournal } = this.tenancy.models(tenantId); + + await this.validateManualJournalExistance(tenantId, manualJournalId); + + this.logger.info('[manual_journals] trying to get specific manual journal.', { tenantId, manualJournalId }); + const manualJournal = await ManualJournal.query() + .findById(manualJournalId) + .withGraphFetched('entries'); + + return manualJournal; + } + + /** + * Write manual journal entries. + * @param {number} tenantId + * @param {number} manualJournalId + * @param {IManualJournal|null} manualJournalObj + * @param {boolean} override + */ + public async writeJournalEntries( + tenantId: number, + manualJournalId: number, + manualJournalObj?: IManualJournal|null, + override?: Boolean, + ) { + const journal = new JournalPoster(tenantId); + const journalCommands = new JournalCommands(journal); + + if (override) { + this.logger.info('[manual_journal] trying to revert journal entries.', { tenantId, manualJournalId }); + await journalCommands.revertJournalEntries(manualJournalId, 'Journal'); + } + if (manualJournalObj) { + journalCommands.manualJournal(manualJournalObj, manualJournalId); + } + this.logger.info('[manual_journal] trying to save journal entries.', { tenantId, manualJournalId }); + await Promise.all([ + journal.saveBalance(), + journal.deleteEntries(), + journal.saveEntries(), + ]); + this.logger.info('[manual_journal] the journal entries saved successfully.', { tenantId, manualJournalId }); + } +} \ No newline at end of file diff --git a/server/src/subscribers/events.ts b/server/src/subscribers/events.ts index 2b82f1f05..0d5150c0a 100644 --- a/server/src/subscribers/events.ts +++ b/server/src/subscribers/events.ts @@ -23,5 +23,14 @@ export default { databaseCreated: 'onDatabaseCreated', tenantMigrated: 'onTenantMigrated', tenantSeeded: 'onTenantSeeded', + }, + + manualJournals: { + onCreated: 'onManualJournalCreated', + onEdited: 'onManualJournalEdited', + onDeleted: 'onManualJournalDeleted', + onDeletedBulk: 'onManualJournalCreatedBulk', + onPublished: 'onManualJournalPublished', + onPublishedBulk: 'onManualJournalPublishedBulk', } } diff --git a/server/src/subscribers/manualJournals.ts b/server/src/subscribers/manualJournals.ts new file mode 100644 index 000000000..caea25a18 --- /dev/null +++ b/server/src/subscribers/manualJournals.ts @@ -0,0 +1,43 @@ +import { Inject, Container } from 'typedi'; +import { On, EventSubscriber } from "event-dispatch"; +import events from 'subscribers/events'; +import ManualJournalsService from 'services/ManualJournals/ManualJournalsService'; + +@EventSubscriber() +export class ManualJournalSubscriber { + /** + * Handle manual journal created event. + * @param {{ tenantId: number, manualJournal: IManualJournal }} + */ + @On(events.manualJournals.onCreated) + public async onManualJournalCreated({ tenantId, manualJournal }) { + const manualJournalsService = Container.get(ManualJournalsService); + + await manualJournalsService + .writeJournalEntries(tenantId, manualJournal.id, manualJournal); + } + + /** + * Handle manual journal edited event. + * @param {{ tenantId: number, manualJournal: IManualJournal }} + */ + @On(events.manualJournals.onEdited) + public async onManualJournalEdited({ tenantId, manualJournal }) { + const manualJournalsService = Container.get(ManualJournalsService); + + await manualJournalsService + .writeJournalEntries(tenantId, manualJournal.id, manualJournal, true); + } + + /** + * Handle manual journal deleted event. + * @param {{ tenantId: number, manualJournalId: number }} + */ + @On(events.manualJournals.onDeleted) + public async onManualJournalDeleted({ tenantId, manualJournalId, }) { + const manualJournalsService = Container.get(ManualJournalsService); + + await manualJournalsService + .writeJournalEntries(tenantId, manualJournalId, null, true); + } +} \ No newline at end of file