mirror of
https://github.com/bigcapitalhq/bigcapital.git
synced 2026-02-19 22:30:31 +00:00
refactor: manual journal.
This commit is contained in:
@@ -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 });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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') });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
377
server/src/api/controllers/ManualJournals.ts
Normal file
377
server/src/api/controllers/ManualJournals.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ import ItemCategories from 'api/controllers/ItemCategories';
|
|||||||
import Accounts from 'api/controllers/Accounts';
|
import Accounts from 'api/controllers/Accounts';
|
||||||
import AccountTypes from 'api/controllers/AccountTypes';
|
import AccountTypes from 'api/controllers/AccountTypes';
|
||||||
import Views from 'api/controllers/Views';
|
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 FinancialStatements from 'api/controllers/FinancialStatements';
|
||||||
import Expenses from 'api/controllers/Expenses';
|
import Expenses from 'api/controllers/Expenses';
|
||||||
import Settings from 'api/controllers/Settings';
|
import Settings from 'api/controllers/Settings';
|
||||||
@@ -63,7 +63,8 @@ export default () => {
|
|||||||
dashboard.use('/currencies', Currencies.router());
|
dashboard.use('/currencies', Currencies.router());
|
||||||
dashboard.use('/accounts', Container.get(Accounts).router());
|
dashboard.use('/accounts', Container.get(Accounts).router());
|
||||||
dashboard.use('/account_types', Container.get(AccountTypes).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('/views', Views.router());
|
||||||
dashboard.use('/items', Container.get(Items).router());
|
dashboard.use('/items', Container.get(Items).router());
|
||||||
dashboard.use('/item_categories', Container.get(ItemCategories).router());
|
dashboard.use('/item_categories', Container.get(ItemCategories).router());
|
||||||
|
|||||||
@@ -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');
|
|
||||||
};
|
|
||||||
53
server/src/interfaces/ManualJournal.ts
Normal file
53
server/src/interfaces/ManualJournal.ts
Normal file
@@ -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<void>;
|
||||||
|
deleteManualJournals(tenantId: number, manualJournalsIds: number[]): Promise<void>;
|
||||||
|
publishManualJournals(tenantId: number, manualJournalsIds: number[]): Promise<void>;
|
||||||
|
publishManualJournal(tenantId: number, manualJournalId: number): Promise<void>;
|
||||||
|
getManualJournals(tenantId: number, filter: IManualJournalsFilter): Promise<void>;
|
||||||
|
}
|
||||||
@@ -20,3 +20,4 @@ export * from './Contact';
|
|||||||
export * from './Expenses';
|
export * from './Expenses';
|
||||||
export * from './Tenancy';
|
export * from './Tenancy';
|
||||||
export * from './View';
|
export * from './View';
|
||||||
|
export * from './ManualJournal';
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
// Here we import all events.
|
// Here we import all events.
|
||||||
import 'subscribers/authentication';
|
import 'subscribers/authentication';
|
||||||
import 'subscribers/organization';
|
import 'subscribers/organization';
|
||||||
|
import 'subscribers/manualJournals';
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import CustomerRepository from 'repositories/CustomerRepository';
|
|||||||
import ExpenseRepository from 'repositories/ExpenseRepository';
|
import ExpenseRepository from 'repositories/ExpenseRepository';
|
||||||
import ViewRepository from 'repositories/ViewRepository';
|
import ViewRepository from 'repositories/ViewRepository';
|
||||||
import ViewRoleRepository from 'repositories/ViewRoleRepository';
|
import ViewRoleRepository from 'repositories/ViewRoleRepository';
|
||||||
|
import ContactRepository from 'repositories/ContactRepository';
|
||||||
|
|
||||||
export default (tenantId: number) => {
|
export default (tenantId: number) => {
|
||||||
return {
|
return {
|
||||||
@@ -12,6 +13,7 @@ export default (tenantId: number) => {
|
|||||||
accountTypeRepository: new AccountTypeRepository(tenantId),
|
accountTypeRepository: new AccountTypeRepository(tenantId),
|
||||||
customerRepository: new CustomerRepository(tenantId),
|
customerRepository: new CustomerRepository(tenantId),
|
||||||
vendorRepository: new VendorRepository(tenantId),
|
vendorRepository: new VendorRepository(tenantId),
|
||||||
|
contactRepository: new ContactRepository(tenantId),
|
||||||
expenseRepository: new ExpenseRepository(tenantId),
|
expenseRepository: new ExpenseRepository(tenantId),
|
||||||
viewRepository: new ViewRepository(tenantId),
|
viewRepository: new ViewRepository(tenantId),
|
||||||
viewRoleRepository: new ViewRoleRepository(tenantId),
|
viewRoleRepository: new ViewRoleRepository(tenantId),
|
||||||
|
|||||||
@@ -71,7 +71,6 @@ export default class Expense extends TenantModel {
|
|||||||
*/
|
*/
|
||||||
static get relationMappings() {
|
static get relationMappings() {
|
||||||
const Account = require('models/Account');
|
const Account = require('models/Account');
|
||||||
const User = require('models/TenantUser');
|
|
||||||
const ExpenseCategory = require('models/ExpenseCategory');
|
const ExpenseCategory = require('models/ExpenseCategory');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -91,14 +90,6 @@ export default class Expense extends TenantModel {
|
|||||||
to: 'expense_transaction_categories.expenseId',
|
to: 'expense_transaction_categories.expenseId',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
user: {
|
|
||||||
relation: Model.BelongsToOneRelation,
|
|
||||||
modelClass: this.relationBindKnex(User.default),
|
|
||||||
join: {
|
|
||||||
from: 'expenses_transactions.userId',
|
|
||||||
to: 'users.id',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Model } from 'objection';
|
import { Model } from 'objection';
|
||||||
import TenantModel from 'models/TenantModel';
|
import TenantModel from 'models/TenantModel';
|
||||||
|
import { AccountTransaction } from 'models';
|
||||||
|
|
||||||
export default class ManualJournal extends TenantModel {
|
export default class ManualJournal extends TenantModel {
|
||||||
/**
|
/**
|
||||||
@@ -21,8 +22,20 @@ export default class ManualJournal extends TenantModel {
|
|||||||
*/
|
*/
|
||||||
static get relationMappings() {
|
static get relationMappings() {
|
||||||
const Media = require('models/Media');
|
const Media = require('models/Media');
|
||||||
|
const AccountTransaction = require('models/AccountTransaction');
|
||||||
|
|
||||||
return {
|
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: {
|
media: {
|
||||||
relation: Model.ManyToManyRelation,
|
relation: Model.ManyToManyRelation,
|
||||||
modelClass: this.relationBindKnex(Media.default),
|
modelClass: this.relationBindKnex(Media.default),
|
||||||
|
|||||||
@@ -6,6 +6,15 @@ import DateSession from 'models/DateSession';
|
|||||||
|
|
||||||
export default class ModelBase extends mixin(Model, [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() {
|
get timestamps() {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|||||||
37
server/src/repositories/ContactRepository.ts
Normal file
37
server/src/repositories/ContactRepository.ts
Normal file
@@ -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) {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { sumBy, chain } from 'lodash';
|
|||||||
import JournalPoster from "./JournalPoster";
|
import JournalPoster from "./JournalPoster";
|
||||||
import JournalEntry from "./JournalEntry";
|
import JournalEntry from "./JournalEntry";
|
||||||
import { AccountTransaction } from 'models';
|
import { AccountTransaction } from 'models';
|
||||||
import { IInventoryTransaction } from 'interfaces';
|
import { IInventoryTransaction, IManualJournal } from 'interfaces';
|
||||||
import AccountsService from '../Accounts/AccountsService';
|
import AccountsService from '../Accounts/AccountsService';
|
||||||
import { IInventoryTransaction, IInventoryTransaction } from '../../interfaces';
|
import { IInventoryTransaction, IInventoryTransaction } from '../../interfaces';
|
||||||
|
|
||||||
@@ -120,6 +120,11 @@ export default class JournalCommands{
|
|||||||
this.journal.credit(creditEntry);
|
this.journal.credit(creditEntry);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {number|number[]} referenceId
|
||||||
|
* @param {string} referenceType
|
||||||
|
*/
|
||||||
async revertJournalEntries(
|
async revertJournalEntries(
|
||||||
referenceId: number|number[],
|
referenceId: number|number[],
|
||||||
referenceType: string
|
referenceType: string
|
||||||
@@ -135,6 +140,36 @@ export default class JournalCommands{
|
|||||||
this.journal.removeEntries();
|
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
|
* Removes and revert accounts balance journal entries that associated
|
||||||
* to the given inventory transactions.
|
* to the given inventory transactions.
|
||||||
|
|||||||
460
server/src/services/ManualJournals/ManualJournalsService.ts
Normal file
460
server/src/services/ManualJournals/ManualJournalsService.ts
Normal file
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void>}
|
||||||
|
*/
|
||||||
|
public async deleteManualJournal(tenantId: number, manualJournalId: number): Promise<void> {
|
||||||
|
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<void>}
|
||||||
|
*/
|
||||||
|
public async deleteManualJournals(tenantId: number, manualJournalsIds: number[]): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,5 +23,14 @@ export default {
|
|||||||
databaseCreated: 'onDatabaseCreated',
|
databaseCreated: 'onDatabaseCreated',
|
||||||
tenantMigrated: 'onTenantMigrated',
|
tenantMigrated: 'onTenantMigrated',
|
||||||
tenantSeeded: 'onTenantSeeded',
|
tenantSeeded: 'onTenantSeeded',
|
||||||
|
},
|
||||||
|
|
||||||
|
manualJournals: {
|
||||||
|
onCreated: 'onManualJournalCreated',
|
||||||
|
onEdited: 'onManualJournalEdited',
|
||||||
|
onDeleted: 'onManualJournalDeleted',
|
||||||
|
onDeletedBulk: 'onManualJournalCreatedBulk',
|
||||||
|
onPublished: 'onManualJournalPublished',
|
||||||
|
onPublishedBulk: 'onManualJournalPublishedBulk',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
43
server/src/subscribers/manualJournals.ts
Normal file
43
server/src/subscribers/manualJournals.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user