mirror of
https://github.com/bigcapitalhq/bigcapital.git
synced 2026-02-16 04:40:32 +00:00
feat: remove path alias.
feat: remove Webpack and depend on nodemon. feat: refactoring expenses. feat: optimize system users with caching. feat: architecture tenant optimize.
This commit is contained in:
31
server/src/api/controllers/AccountTypes.ts
Normal file
31
server/src/api/controllers/AccountTypes.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Service } from 'typedi';
|
||||
import { Request, Response, Router } from 'express';
|
||||
import asyncMiddleware from 'api/middleware/asyncMiddleware';
|
||||
import BaseController from 'api/controllers/BaseController';
|
||||
|
||||
@Service()
|
||||
export default class AccountsTypesController extends BaseController{
|
||||
/**
|
||||
* Router constructor.
|
||||
*/
|
||||
router() {
|
||||
const router = Router();
|
||||
|
||||
router.get('/',
|
||||
asyncMiddleware(this.getAccountTypesList));
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve accounts types list.
|
||||
*/
|
||||
async getAccountTypesList(req: Request, res: Response) {
|
||||
const { AccountType } = req.models;
|
||||
const accountTypes = await AccountType.query();
|
||||
|
||||
return res.status(200).send({
|
||||
account_types: accountTypes,
|
||||
});
|
||||
}
|
||||
};
|
||||
984
server/src/api/controllers/Accounting.js
Normal file
984
server/src/api/controllers/Accounting.js
Normal file
@@ -0,0 +1,984 @@
|
||||
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 });
|
||||
},
|
||||
},
|
||||
};
|
||||
542
server/src/api/controllers/Accounts.ts
Normal file
542
server/src/api/controllers/Accounts.ts
Normal file
@@ -0,0 +1,542 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { check, validationResult, param, query } from 'express-validator';
|
||||
import { difference } from 'lodash';
|
||||
import asyncMiddleware from 'api/middleware/asyncMiddleware';
|
||||
import JournalPoster from 'services/Accounting/JournalPoster';
|
||||
import {
|
||||
mapViewRolesToConditionals,
|
||||
mapFilterRolesToDynamicFilter,
|
||||
} from 'lib/ViewRolesBuilder';
|
||||
import {
|
||||
DynamicFilter,
|
||||
DynamicFilterSortBy,
|
||||
DynamicFilterViews,
|
||||
DynamicFilterFilterRoles,
|
||||
} from 'lib/DynamicFilter';
|
||||
import BaseController from './BaseController';
|
||||
import { IAccountDTO, IAccount } from 'interfaces';
|
||||
import { ServiceError } from 'exceptions';
|
||||
import AccountsService from 'services/Accounts/AccountsService';
|
||||
import { Service, Inject } from 'typedi';
|
||||
|
||||
@Service()
|
||||
export default class AccountsController extends BaseController{
|
||||
|
||||
@Inject()
|
||||
accountsService: AccountsService;
|
||||
|
||||
/**
|
||||
* Router constructor method.
|
||||
*/
|
||||
router() {
|
||||
const router = Router();
|
||||
|
||||
router.post(
|
||||
'/bulk/:type(activate|inactivate)',
|
||||
asyncMiddleware(this.bulkToggleActivateAccounts.bind(this))
|
||||
);
|
||||
router.post(
|
||||
'/:id/activate', [
|
||||
...this.accountParamSchema,
|
||||
],
|
||||
asyncMiddleware(this.activateAccount.bind(this))
|
||||
);
|
||||
router.post(
|
||||
'/:id/inactivate', [
|
||||
...this.accountParamSchema,
|
||||
],
|
||||
asyncMiddleware(this.inactivateAccount.bind(this))
|
||||
);
|
||||
router.post(
|
||||
'/:id', [
|
||||
...this.accountDTOSchema,
|
||||
...this.accountParamSchema,
|
||||
],
|
||||
this.validationResult,
|
||||
asyncMiddleware(this.editAccount.bind(this))
|
||||
);
|
||||
router.post(
|
||||
'/', [
|
||||
...this.accountDTOSchema,
|
||||
],
|
||||
this.validationResult,
|
||||
asyncMiddleware(this.newAccount.bind(this))
|
||||
);
|
||||
router.get(
|
||||
'/:id', [
|
||||
...this.accountParamSchema,
|
||||
],
|
||||
this.validationResult,
|
||||
asyncMiddleware(this.getAccount.bind(this))
|
||||
);
|
||||
// // router.get(
|
||||
// // '/', [
|
||||
// // ...this.accountsListSchema
|
||||
// // ],
|
||||
// // asyncMiddleware(this.getAccountsList.handler)
|
||||
// // );
|
||||
|
||||
router.delete(
|
||||
'/:id', [
|
||||
...this.accountParamSchema
|
||||
],
|
||||
this.validationResult,
|
||||
asyncMiddleware(this.deleteAccount.bind(this))
|
||||
);
|
||||
router.delete(
|
||||
'/',
|
||||
this.bulkDeleteSchema,
|
||||
asyncMiddleware(this.deleteBulkAccounts.bind(this))
|
||||
);
|
||||
// router.post(
|
||||
// '/:id/transfer_account/:toAccount',
|
||||
// this.transferToAnotherAccount.validation,
|
||||
// asyncMiddleware(this.transferToAnotherAccount.handler)
|
||||
// );
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
/**
|
||||
* Account DTO Schema validation.
|
||||
*/
|
||||
get accountDTOSchema() {
|
||||
return [
|
||||
check('name')
|
||||
.exists()
|
||||
.isLength({ min: 3, max: 255 })
|
||||
.trim()
|
||||
.escape(),
|
||||
check('code')
|
||||
.optional({ nullable: true })
|
||||
.isLength({ min: 3, max: 6 })
|
||||
.trim()
|
||||
.escape(),
|
||||
check('account_type_id')
|
||||
.exists()
|
||||
.isNumeric()
|
||||
.toInt(),
|
||||
check('description')
|
||||
.optional({ nullable: true })
|
||||
.isLength({ max: 512 })
|
||||
.trim()
|
||||
.escape(),
|
||||
check('parent_account_id')
|
||||
.optional({ nullable: true })
|
||||
.isNumeric()
|
||||
.toInt(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Account param schema validation.
|
||||
*/
|
||||
get accountParamSchema() {
|
||||
return [
|
||||
param('id').exists().isNumeric().toInt()
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Accounts list schema validation.
|
||||
*/
|
||||
get accountsListSchema() {
|
||||
return [
|
||||
query('display_type').optional().isIn(['tree', 'flat']),
|
||||
query('account_types').optional().isArray(),
|
||||
query('account_types.*').optional().isNumeric().toInt(),
|
||||
query('custom_view_id').optional().isNumeric().toInt(),
|
||||
|
||||
query('stringified_filter_roles').optional().isJSON(),
|
||||
|
||||
query('column_sort_by').optional(),
|
||||
query('sort_order').optional().isIn(['desc', 'asc']),
|
||||
];
|
||||
}
|
||||
|
||||
get bulkDeleteSchema() {
|
||||
return [
|
||||
query('ids').isArray({ min: 2 }),
|
||||
query('ids.*').isNumeric().toInt(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new account.
|
||||
* @param {Request} req -
|
||||
* @param {Response} res -
|
||||
* @param {NextFunction} next -
|
||||
*/
|
||||
async newAccount(req: Request, res: Response, next: NextFunction) {
|
||||
const { tenantId } = req;
|
||||
const accountDTO: IAccountDTO = this.matchedBodyData(req);
|
||||
|
||||
try {
|
||||
const account = await this.accountsService.newAccount(tenantId, accountDTO);
|
||||
|
||||
return res.status(200).send({ id: account.id });
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceError) {
|
||||
this.transformServiceErrorToResponse(res, error);
|
||||
}
|
||||
next();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit account details.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @return {Response}
|
||||
*/
|
||||
async editAccount(req: Request, res: Response, next: NextFunction) {
|
||||
const { tenantId } = req;
|
||||
const { id: accountId } = req.params;
|
||||
const accountDTO: IAccountDTO = this.matchedBodyData(req);
|
||||
|
||||
try {
|
||||
const account = await this.accountsService.editAccount(tenantId, accountId, accountDTO);
|
||||
return res.status(200).send({ id: account.id });
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceError) {
|
||||
this.transformServiceErrorToResponse(res, error);
|
||||
}
|
||||
next();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get details of the given account.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @return {Response}
|
||||
*/
|
||||
async getAccount(req: Request, res: Response, next: NextFunction) {
|
||||
const { tenantId } = req;
|
||||
const { id: accountId } = req.params;
|
||||
|
||||
try {
|
||||
const account = await this.accountsService.getAccount(tenantId, accountId);
|
||||
return res.status(200).send({ account });
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceError) {
|
||||
this.transformServiceErrorToResponse(res, error);
|
||||
}
|
||||
next();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the given account.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @return {Response}
|
||||
*/
|
||||
async deleteAccount(req: Request, res: Response, next: NextFunction) {
|
||||
const { id: accountId } = req.params;
|
||||
const { tenantId } = req;
|
||||
|
||||
try {
|
||||
await this.accountsService.deleteAccount(tenantId, accountId);
|
||||
return res.status(200).send({ id: accountId });
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceError) {
|
||||
this.transformServiceErrorToResponse(res, error);
|
||||
|
||||
|
||||
}
|
||||
next();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate the given account.
|
||||
* @param {Response} res -
|
||||
* @param {Request} req -
|
||||
* @return {Response}
|
||||
*/
|
||||
async activateAccount(req: Request, res: Response, next: Function){
|
||||
const { id: accountId } = req.params;
|
||||
const { tenantId } = req;
|
||||
|
||||
try {
|
||||
await this.accountsService.activateAccount(tenantId, accountId, true);
|
||||
return res.status(200).send({ id: accountId });
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceError) {
|
||||
this.transformServiceErrorToResponse(res, error);
|
||||
}
|
||||
next();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inactive the given account.
|
||||
* @param {Response} res -
|
||||
* @param {Request} req -
|
||||
* @return {Response}
|
||||
*/
|
||||
async inactivateAccount(req: Request, res: Response, next: Function){
|
||||
const { id: accountId } = req.params;
|
||||
const { tenantId } = req;
|
||||
|
||||
try {
|
||||
await this.accountsService.activateAccount(tenantId, accountId, false);
|
||||
return res.status(200).send({ id: accountId });
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceError) {
|
||||
this.transformServiceErrorToResponse(res, error);
|
||||
}
|
||||
next();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk activate/inactivate accounts.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {NextFunction} next
|
||||
*/
|
||||
async bulkToggleActivateAccounts(req: Request, res: Response, next: Function) {
|
||||
const { type } = req.params;
|
||||
const { tenantId } = req;
|
||||
const { ids: accountsIds } = req.query;
|
||||
|
||||
try {
|
||||
const isActive = (type === 'activate' ? 1 : 0);
|
||||
await this.accountsService.activateAccounts(tenantId, accountsIds, isActive)
|
||||
return res.status(200).send({ ids: accountsIds });
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceError) {
|
||||
this.transformServiceErrorToResponse(res, error);
|
||||
}
|
||||
next();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes accounts in bulk.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {NextFunction} next
|
||||
*/
|
||||
async deleteBulkAccounts(req: Request, res: Response, next: NextFunction) {
|
||||
const { ids: accountsIds } = req.query;
|
||||
const { tenantId } = req;
|
||||
|
||||
try {
|
||||
await this.accountsService.deleteAccounts(tenantId, accountsIds);
|
||||
return res.status(200).send({ ids: accountsIds });
|
||||
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
|
||||
if (error instanceof ServiceError) {
|
||||
this.transformServiceErrorToResponse(res, error);
|
||||
}
|
||||
next();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms service errors to response.
|
||||
* @param {Response} res
|
||||
* @param {ServiceError} error
|
||||
*/
|
||||
transformServiceErrorToResponse(res: Response, error: ServiceError) {
|
||||
console.log(error.errorType);
|
||||
if (error.errorType === 'account_not_found') {
|
||||
return res.boom.notFound(
|
||||
'The given account not found.', {
|
||||
errors: [{ type: 'ACCOUNT.NOT.FOUND', code: 100 }] }
|
||||
);
|
||||
}
|
||||
if (error.errorType === 'account_name_not_unqiue') {
|
||||
return res.boom.badRequest(
|
||||
'The given account not unique.',
|
||||
{ errors: [{ type: 'ACCOUNT.NAME.NOT.UNIQUE', code: 150 }], }
|
||||
);
|
||||
}
|
||||
if (error.errorType === 'account_type_not_found') {
|
||||
return res.boom.badRequest(
|
||||
'The given account type not found.', {
|
||||
errors: [{ type: 'ACCOUNT_TYPE_NOT_FOUND', code: 200 }] }
|
||||
);
|
||||
}
|
||||
if (error.errorType === 'account_type_not_allowed_to_changed') {
|
||||
return res.boom.badRequest(
|
||||
'Not allowed to change account type of the account.',
|
||||
{ errors: [{ type: 'NOT.ALLOWED.TO.CHANGE.ACCOUNT.TYPE', code: 300 }] }
|
||||
);
|
||||
}
|
||||
if (error.errorType === 'parent_account_not_found') {
|
||||
return res.boom.badRequest(
|
||||
'The parent account not found.',
|
||||
{ errors: [{ type: 'PARENT_ACCOUNT_NOT_FOUND', code: 400 }] },
|
||||
);
|
||||
}
|
||||
if (error.errorType === 'parent_has_different_type') {
|
||||
return res.boom.badRequest(
|
||||
'The parent account has different type.',
|
||||
{ errors: [{ type: 'PARENT.ACCOUNT.HAS.DIFFERENT.ACCOUNT.TYPE', code: 500 }] }
|
||||
);
|
||||
}
|
||||
if (error.errorType === 'account_code_not_unique') {
|
||||
return res.boom.badRequest(
|
||||
'The given account code is not unique.',
|
||||
{ errors: [{ type: 'NOT_UNIQUE_CODE', code: 600 }] }
|
||||
);
|
||||
}
|
||||
if (error.errorType === 'account_has_children') {
|
||||
return res.boom.badRequest(
|
||||
'You could not delete account has children.',
|
||||
{ errors: [{ type: 'ACCOUNT.HAS.CHILD.ACCOUNTS', code: 700 }] }
|
||||
);
|
||||
}
|
||||
if (error.errorType === 'account_has_associated_transactions') {
|
||||
return res.boom.badRequest(
|
||||
'You could not delete account has associated transactions.',
|
||||
{ errors: [{ type: 'ACCOUNT.HAS.ASSOCIATED.TRANSACTIONS', code: 800 }] }
|
||||
);
|
||||
}
|
||||
if (error.errorType === 'account_predefined') {
|
||||
return res.boom.badRequest(
|
||||
'You could not delete predefined account',
|
||||
{ errors: [{ type: 'ACCOUNT.PREDEFINED', code: 900 }] }
|
||||
);
|
||||
}
|
||||
if (error.errorType === 'accounts_not_found') {
|
||||
return res.boom.notFound(
|
||||
'Some of the given accounts not found.',
|
||||
{ errors: [{ type: 'SOME.ACCOUNTS.NOT_FOUND', code: 1000 }] },
|
||||
);
|
||||
}
|
||||
if (error.errorType === 'predefined_accounts') {
|
||||
return res.boom.badRequest(
|
||||
'Some of the given accounts are predefined.',
|
||||
{ errors: [{ type: 'ACCOUNTS_PREDEFINED', code: 1100 }] }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// /**
|
||||
// * Retrieve accounts list.
|
||||
// */
|
||||
// getAccountsList(req, res) {
|
||||
// const validationErrors = validationResult(req);
|
||||
|
||||
// if (!validationErrors.isEmpty()) {
|
||||
// return res.boom.badData(null, {
|
||||
// code: 'validation_error',
|
||||
// ...validationErrors,
|
||||
// });
|
||||
// }
|
||||
// const filter = {
|
||||
// display_type: 'flat',
|
||||
// account_types: [],
|
||||
// filter_roles: [],
|
||||
// sort_order: 'asc',
|
||||
// ...req.query,
|
||||
// };
|
||||
// if (filter.stringified_filter_roles) {
|
||||
// filter.filter_roles = JSON.parse(filter.stringified_filter_roles);
|
||||
// }
|
||||
// const { Resource, Account, View } = req.models;
|
||||
// const errorReasons = [];
|
||||
|
||||
// const accountsResource = await Resource.query()
|
||||
// .remember()
|
||||
// .where('name', 'accounts')
|
||||
// .withGraphFetched('fields')
|
||||
// .first();
|
||||
|
||||
// if (!accountsResource) {
|
||||
// return res.status(400).send({
|
||||
// errors: [{ type: 'ACCOUNTS_RESOURCE_NOT_FOUND', code: 200 }],
|
||||
// });
|
||||
// }
|
||||
// const resourceFieldsKeys = accountsResource.fields.map((c) => c.key);
|
||||
|
||||
// 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', accountsResource.id);
|
||||
// builder.withGraphFetched('roles.field');
|
||||
// builder.withGraphFetched('columns');
|
||||
// builder.first();
|
||||
|
||||
// builder.remember();
|
||||
// });
|
||||
// const dynamicFilter = new DynamicFilter(Account.tableName);
|
||||
|
||||
// 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);
|
||||
// }
|
||||
// // 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);
|
||||
// }
|
||||
// // Filter roles.
|
||||
// if (filter.filter_roles.length > 0) {
|
||||
// // Validate the accounts resource fields.
|
||||
// const filterRoles = new DynamicFilterFilterRoles(
|
||||
// mapFilterRolesToDynamicFilter(filter.filter_roles),
|
||||
// accountsResource.fields
|
||||
// );
|
||||
// dynamicFilter.setFilter(filterRoles);
|
||||
|
||||
// if (filterRoles.validateFilterRoles().length > 0) {
|
||||
// errorReasons.push({
|
||||
// type: 'ACCOUNTS.RESOURCE.HAS.NO.GIVEN.FIELDS',
|
||||
// code: 500,
|
||||
// });
|
||||
// }
|
||||
// }
|
||||
// if (errorReasons.length > 0) {
|
||||
// return res.status(400).send({ errors: errorReasons });
|
||||
// }
|
||||
|
||||
// const accounts = await Account.query().onBuild((builder) => {
|
||||
// builder.modify('filterAccountTypes', filter.account_types);
|
||||
// builder.withGraphFetched('type');
|
||||
// builder.withGraphFetched('balance');
|
||||
|
||||
// dynamicFilter.buildQuery()(builder);
|
||||
// });
|
||||
// return res.status(200).send({
|
||||
// accounts:
|
||||
// filter.display_type === 'tree'
|
||||
// ? Account.toNestedArray(accounts)
|
||||
// : accounts,
|
||||
// ...(view
|
||||
// ? {
|
||||
// customViewId: view.id,
|
||||
// }
|
||||
// : {}),
|
||||
// });
|
||||
// }
|
||||
};
|
||||
25
server/src/api/controllers/Agendash.ts
Normal file
25
server/src/api/controllers/Agendash.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
|
||||
import { Router } from 'express'
|
||||
import basicAuth from 'express-basic-auth';
|
||||
import agendash from 'agendash'
|
||||
import { Container } from 'typedi'
|
||||
import config from 'config'
|
||||
|
||||
export default class AgendashController {
|
||||
|
||||
static router() {
|
||||
const router = Router();
|
||||
const agendaInstance = Container.get('agenda')
|
||||
|
||||
router.use('/dash',
|
||||
basicAuth({
|
||||
users: {
|
||||
[config.agendash.user]: config.agendash.password,
|
||||
},
|
||||
challenge: true,
|
||||
}),
|
||||
agendash(agendaInstance)
|
||||
);
|
||||
return router;
|
||||
}
|
||||
}
|
||||
221
server/src/api/controllers/Authentication.ts
Normal file
221
server/src/api/controllers/Authentication.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { Request, Response, Router } from 'express';
|
||||
import { check, ValidationChain } from 'express-validator';
|
||||
import { Service, Inject } from 'typedi';
|
||||
import BaseController from 'api/controllers/BaseController';
|
||||
import validateMiddleware from 'api/middleware/validateMiddleware';
|
||||
import asyncMiddleware from 'api/middleware/asyncMiddleware';
|
||||
import AuthenticationService from 'services/Authentication';
|
||||
import { IUserOTD, ISystemUser, IRegisterOTD } from 'interfaces';
|
||||
import { ServiceError, ServiceErrors } from "exceptions";
|
||||
|
||||
@Service()
|
||||
export default class AuthenticationController extends BaseController{
|
||||
@Inject()
|
||||
authService: AuthenticationService;
|
||||
|
||||
/**
|
||||
* Constructor method.
|
||||
*/
|
||||
router() {
|
||||
const router = Router();
|
||||
|
||||
router.post(
|
||||
'/login',
|
||||
this.loginSchema,
|
||||
this.validationResult,
|
||||
asyncMiddleware(this.login.bind(this))
|
||||
);
|
||||
router.post(
|
||||
'/register',
|
||||
this.registerSchema,
|
||||
this.validationResult,
|
||||
asyncMiddleware(this.register.bind(this))
|
||||
);
|
||||
router.post(
|
||||
'/send_reset_password',
|
||||
this.sendResetPasswordSchema,
|
||||
this.validationResult,
|
||||
asyncMiddleware(this.sendResetPassword.bind(this))
|
||||
);
|
||||
router.post(
|
||||
'/reset/:token',
|
||||
this.resetPasswordSchema,
|
||||
this.validationResult,
|
||||
asyncMiddleware(this.resetPassword.bind(this))
|
||||
);
|
||||
return router;
|
||||
}
|
||||
|
||||
/**
|
||||
* Login schema.
|
||||
*/
|
||||
get loginSchema(): ValidationChain[] {
|
||||
return [
|
||||
check('crediential').exists().isEmail(),
|
||||
check('password').exists().isLength({ min: 5 }),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Register schema.
|
||||
*/
|
||||
get registerSchema(): ValidationChain[] {
|
||||
return [
|
||||
check('organization_name').exists().trim().escape(),
|
||||
check('first_name').exists().trim().escape(),
|
||||
check('last_name').exists().trim().escape(),
|
||||
check('email').exists().isEmail().trim().escape(),
|
||||
check('phone_number').exists().trim().escape(),
|
||||
check('password').exists().trim().escape(),
|
||||
check('country').exists().trim().escape(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset password schema.
|
||||
*/
|
||||
get resetPasswordSchema(): ValidationChain[] {
|
||||
return [
|
||||
check('password').exists().isLength({ min: 5 })
|
||||
.custom((value, { req }) => {
|
||||
if (value !== req.body.confirm_password) {
|
||||
throw new Error("Passwords don't match");
|
||||
} else {
|
||||
return value;
|
||||
}
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Send reset password validation schema.
|
||||
*/
|
||||
get sendResetPasswordSchema(): ValidationChain[] {
|
||||
return [
|
||||
check('email').exists().isEmail().trim().escape(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle user login.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async login(req: Request, res: Response, next: Function): Response {
|
||||
const userDTO: IUserOTD = this.matchedBodyData(req);
|
||||
|
||||
try {
|
||||
const { token, user, tenant } = await this.authService.signIn(
|
||||
userDTO.crediential,
|
||||
userDTO.password
|
||||
);
|
||||
return res.status(200).send({ token, user, tenant });
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceError) {
|
||||
if (['invalid_details', 'invalid_password'].indexOf(error.errorType) !== -1) {
|
||||
return res.boom.badRequest(null, {
|
||||
errors: [{ type: 'INVALID_DETAILS', code: 100 }],
|
||||
});
|
||||
}
|
||||
if (error.errorType === 'user_inactive') {
|
||||
return res.boom.badRequest(null, {
|
||||
errors: [{ type: 'INVALID_DETAILS', code: 200 }],
|
||||
});
|
||||
}
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Organization register handler.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async register(req: Request, res: Response, next: Function) {
|
||||
const registerDTO: IRegisterOTD = this.matchedBodyData(req);
|
||||
|
||||
try {
|
||||
const registeredUser: ISystemUser = await this.authService.register(registerDTO);
|
||||
|
||||
return res.status(200).send({
|
||||
type: 'success',
|
||||
code: 'REGISTER.SUCCESS',
|
||||
message: 'Register organization has been success.',
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceErrors) {
|
||||
const errorReasons = [];
|
||||
|
||||
if (error.hasType('phone_number_exists')) {
|
||||
errorReasons.push({ type: 'PHONE_NUMBER_EXISTS', code: 100 });
|
||||
}
|
||||
if (error.hasType('email_exists')) {
|
||||
errorReasons.push({ type: 'EMAIL.EXISTS', code: 200 });
|
||||
}
|
||||
if (errorReasons.length > 0) {
|
||||
return res.boom.badRequest(null, { errors: errorReasons });
|
||||
}
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send reset password handler
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async sendResetPassword(req: Request, res: Response, next: Function) {
|
||||
const { email } = this.matchedBodyData(req);
|
||||
|
||||
try {
|
||||
await this.authService.sendResetPassword(email);
|
||||
|
||||
return res.status(200).send({
|
||||
code: 'SEND_RESET_PASSWORD_SUCCESS',
|
||||
});
|
||||
} catch(error) {
|
||||
if (error instanceof ServiceError) {
|
||||
if (error.errorType === 'email_not_found') {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'EMAIL.NOT.REGISTERED', code: 200 }],
|
||||
});
|
||||
}
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset password handler
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async resetPassword(req: Request, res: Response, next: Function) {
|
||||
const { token } = req.params;
|
||||
const { password } = req.body;
|
||||
|
||||
try {
|
||||
await this.authService.resetPassword(token, password);
|
||||
|
||||
return res.status(200).send({
|
||||
type: 'RESET_PASSWORD_SUCCESS',
|
||||
})
|
||||
} catch(error) {
|
||||
if (error instanceof ServiceError) {
|
||||
if (error.errorType === 'token_invalid' || error.errorType === 'token_expired') {
|
||||
return res.boom.badRequest(null, {
|
||||
errors: [{ type: 'TOKEN_INVALID', code: 100 }],
|
||||
});
|
||||
}
|
||||
if (error.errorType === 'user_not_found') {
|
||||
return res.boom.badRequest(null, {
|
||||
errors: [{ type: 'USER_NOT_FOUND', code: 120 }],
|
||||
});
|
||||
}
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
28
server/src/api/controllers/BaseController.ts
Normal file
28
server/src/api/controllers/BaseController.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Response, Request } from 'express';
|
||||
import { matchedData, validationResult } from "express-validator";
|
||||
import { camelCase, omit } from "lodash";
|
||||
import { mapKeysDeep } from 'utils'
|
||||
|
||||
export default class BaseController {
|
||||
|
||||
matchedBodyData(req: Request, options: any = {}) {
|
||||
const data = matchedData(req, {
|
||||
locations: ['body'],
|
||||
includeOptionals: true,
|
||||
...omit(options, ['locations']), // override any propery except locations.
|
||||
});
|
||||
return mapKeysDeep(data, (v, k) => camelCase(k));
|
||||
}
|
||||
|
||||
validationResult(req: Request, res: Response, next: NextFunction) {
|
||||
const validationErrors = validationResult(req);
|
||||
|
||||
if (!validationErrors.isEmpty()) {
|
||||
return res.boom.badData(null, {
|
||||
code: 'validation_error',
|
||||
...validationErrors,
|
||||
});
|
||||
}
|
||||
next();
|
||||
}
|
||||
}
|
||||
70
server/src/api/controllers/Contacts/Contacts.ts
Normal file
70
server/src/api/controllers/Contacts/Contacts.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { check, param, query } from 'express-validator';
|
||||
import BaseController from "api/controllers/BaseController";
|
||||
|
||||
export default class ContactsController extends BaseController {
|
||||
|
||||
/**
|
||||
* Contact DTO schema.
|
||||
*/
|
||||
get contactDTOSchema() {
|
||||
return [
|
||||
check('first_name').optional().trim().escape(),
|
||||
check('last_name').optional().trim().escape(),
|
||||
|
||||
check('company_name').optional().trim().escape(),
|
||||
check('display_name').exists().trim().escape(),
|
||||
|
||||
check('email').optional().isEmail().trim().escape(),
|
||||
check('work_phone').optional().trim().escape(),
|
||||
check('personal_phone').optional().trim().escape(),
|
||||
|
||||
check('billing_address_city').optional().trim().escape(),
|
||||
check('billing_address_country').optional().trim().escape(),
|
||||
check('billing_address_email').optional().isEmail().trim().escape(),
|
||||
check('billing_address_zipcode').optional().trim().escape(),
|
||||
check('billing_address_phone').optional().trim().escape(),
|
||||
check('billing_address_state').optional().trim().escape(),
|
||||
|
||||
check('shipping_address_city').optional().trim().escape(),
|
||||
check('shipping_address_country').optional().trim().escape(),
|
||||
check('shipping_address_email').optional().isEmail().trim().escape(),
|
||||
check('shipping_address_zip_code').optional().trim().escape(),
|
||||
check('shipping_address_phone').optional().trim().escape(),
|
||||
check('shipping_address_state').optional().trim().escape(),
|
||||
|
||||
check('note').optional().trim().escape(),
|
||||
check('active').optional().isBoolean().toBoolean(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Contact new DTO schema.
|
||||
*/
|
||||
get contactNewDTOSchema() {
|
||||
return [
|
||||
check('balance').optional().isNumeric().toInt(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Contact edit DTO schema.
|
||||
*/
|
||||
get contactEditDTOSchema() {
|
||||
return [
|
||||
|
||||
]
|
||||
}
|
||||
|
||||
get specificContactSchema() {
|
||||
return [
|
||||
param('id').exists().isNumeric().toInt(),
|
||||
];
|
||||
}
|
||||
|
||||
get bulkContactsSchema() {
|
||||
return [
|
||||
query('ids').isArray({ min: 2 }),
|
||||
query('ids.*').isNumeric().toInt(),
|
||||
]
|
||||
}
|
||||
}
|
||||
196
server/src/api/controllers/Contacts/Customers.ts
Normal file
196
server/src/api/controllers/Contacts/Customers.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { Request, Response, Router, NextFunction } from 'express';
|
||||
import { Service, Inject } from 'typedi';
|
||||
import { check } from 'express-validator';
|
||||
import ContactsController from 'api/controllers/Contacts/Contacts';
|
||||
import CustomersService from 'services/Contacts/CustomersService';
|
||||
import { ServiceError } from 'exceptions';
|
||||
import { ICustomerNewDTO, ICustomerEditDTO } from 'interfaces';
|
||||
import asyncMiddleware from 'api/middleware/asyncMiddleware';
|
||||
|
||||
@Service()
|
||||
export default class CustomersController extends ContactsController {
|
||||
@Inject()
|
||||
customersService: CustomersService;
|
||||
|
||||
/**
|
||||
* Express router.
|
||||
*/
|
||||
router() {
|
||||
const router = Router();
|
||||
|
||||
router.post('/', [
|
||||
...this.contactDTOSchema,
|
||||
...this.contactNewDTOSchema,
|
||||
...this.customerDTOSchema,
|
||||
],
|
||||
this.validationResult,
|
||||
asyncMiddleware(this.newCustomer.bind(this))
|
||||
);
|
||||
router.post('/:id', [
|
||||
...this.contactDTOSchema,
|
||||
...this.contactEditDTOSchema,
|
||||
...this.customerDTOSchema,
|
||||
],
|
||||
this.validationResult,
|
||||
asyncMiddleware(this.editCustomer.bind(this))
|
||||
);
|
||||
router.delete('/:id', [
|
||||
...this.specificContactSchema,
|
||||
],
|
||||
this.validationResult,
|
||||
asyncMiddleware(this.deleteCustomer.bind(this))
|
||||
);
|
||||
router.delete('/', [
|
||||
...this.bulkContactsSchema,
|
||||
],
|
||||
this.validationResult,
|
||||
asyncMiddleware(this.deleteBulkCustomers.bind(this))
|
||||
);
|
||||
router.get('/:id', [
|
||||
...this.specificContactSchema,
|
||||
],
|
||||
this.validationResult,
|
||||
asyncMiddleware(this.getCustomer.bind(this))
|
||||
);
|
||||
return router;
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer DTO schema.
|
||||
*/
|
||||
get customerDTOSchema() {
|
||||
return [
|
||||
check('customer_type').exists().trim().escape(),
|
||||
check('opening_balance').optional().isNumeric().toInt(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new customer.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {NextFunction} next
|
||||
*/
|
||||
async newCustomer(req: Request, res: Response, next: NextFunction) {
|
||||
const contactDTO: ICustomerNewDTO = this.matchedBodyData(req);
|
||||
const { tenantId } = req;
|
||||
|
||||
try {
|
||||
const contact = await this.customersService.newCustomer(tenantId, contactDTO);
|
||||
return res.status(200).send({ id: contact.id });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Edits the given customer details.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {NextFunction} next
|
||||
*/
|
||||
async editCustomer(req: Request, res: Response, next: NextFunction) {
|
||||
const contactDTO: ICustomerEditDTO = this.matchedBodyData(req);
|
||||
const { tenantId } = req;
|
||||
const { id: contactId } = req.params;
|
||||
|
||||
try {
|
||||
await this.customersService.editCustomer(tenantId, contactId, contactDTO);
|
||||
return res.status(200).send({ id: contactId });
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceError) {
|
||||
if (error.errorType === 'contact_not_found') {
|
||||
return res.boom.badRequest(null, {
|
||||
errors: [{ type: 'CUSTOMER.NOT.FOUND', code: 100 }],
|
||||
});
|
||||
}
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the given customer from the storage.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {NextFunction} next
|
||||
*/
|
||||
async deleteCustomer(req: Request, res: Response, next: NextFunction) {
|
||||
const { tenantId } = req;
|
||||
const { id: contactId } = req.params;
|
||||
|
||||
try {
|
||||
await this.customersService.deleteCustomer(tenantId, contactId)
|
||||
return res.status(200).send({ id: contactId });
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceError) {
|
||||
if (error.errorType === 'contact_not_found') {
|
||||
return res.boom.badRequest(null, {
|
||||
errors: [{ type: 'CUSTOMER.NOT.FOUND', code: 100 }],
|
||||
});
|
||||
}
|
||||
if (error.errorType === 'customer_has_invoices') {
|
||||
return res.boom.badRequest(null, {
|
||||
errors: [{ type: 'CUSTOMER.HAS.SALES_INVOICES', code: 200 }],
|
||||
});
|
||||
}
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve details of the given customer id.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {NextFunction} next
|
||||
*/
|
||||
async getCustomer(req: Request, res: Response, next: NextFunction) {
|
||||
const { tenantId } = req;
|
||||
const { id: contactId } = req.params;
|
||||
|
||||
try {
|
||||
const contact = await this.customersService.getCustomer(tenantId, contactId)
|
||||
return res.status(200).send({ contact });
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceError) {
|
||||
if (error.errorType === 'contact_not_found') {
|
||||
return res.boom.badRequest(null, {
|
||||
errors: [{ type: 'CONTACT.NOT.FOUND', code: 100 }],
|
||||
});
|
||||
}
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes customers in bulk.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {NextFunction} next
|
||||
*/
|
||||
async deleteBulkCustomers(req: Request, res: Response, next: NextFunction) {
|
||||
const { ids: contactsIds } = req.query;
|
||||
const { tenantId } = req;
|
||||
|
||||
try {
|
||||
await this.customersService.deleteBulkCustomers(tenantId, contactsIds)
|
||||
return res.status(200).send({ ids: contactsIds });
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceError) {
|
||||
if (error.errorType === 'contacts_not_found') {
|
||||
return res.boom.badRequest(null, {
|
||||
errors: [{ type: 'CUSTOMERS.NOT.FOUND', code: 100 }],
|
||||
});
|
||||
}
|
||||
if (error.errorType === 'some_customers_have_invoices') {
|
||||
return res.boom.badRequest(null, {
|
||||
errors: [{ type: 'SOME.CUSTOMERS.HAVE.SALES_INVOICES', code: 200 }],
|
||||
});
|
||||
}
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
195
server/src/api/controllers/Contacts/Vendors.ts
Normal file
195
server/src/api/controllers/Contacts/Vendors.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { Request, Response, Router, NextFunction } from 'express';
|
||||
import { Service, Inject } from 'typedi';
|
||||
import { check } from 'express-validator';
|
||||
import ContactsController from 'api/controllers/Contacts/Contacts';
|
||||
import VendorsService from 'services/Contacts/VendorsService';
|
||||
import { ServiceError } from 'exceptions';
|
||||
import { IVendorNewDTO, IVendorEditDTO } from 'interfaces';
|
||||
import asyncMiddleware from 'api/middleware/asyncMiddleware';
|
||||
|
||||
@Service()
|
||||
export default class VendorsController extends ContactsController {
|
||||
@Inject()
|
||||
vendorsService: VendorsService;
|
||||
|
||||
/**
|
||||
* Express router.
|
||||
*/
|
||||
router() {
|
||||
const router = Router();
|
||||
|
||||
router.post('/', [
|
||||
...this.contactDTOSchema,
|
||||
...this.contactNewDTOSchema,
|
||||
...this.vendorDTOSchema,
|
||||
],
|
||||
this.validationResult,
|
||||
asyncMiddleware(this.newVendor.bind(this))
|
||||
);
|
||||
router.post('/:id', [
|
||||
...this.contactDTOSchema,
|
||||
...this.contactEditDTOSchema,
|
||||
...this.vendorDTOSchema,
|
||||
],
|
||||
this.validationResult,
|
||||
asyncMiddleware(this.editVendor.bind(this))
|
||||
);
|
||||
router.delete('/:id', [
|
||||
...this.specificContactSchema,
|
||||
],
|
||||
this.validationResult,
|
||||
asyncMiddleware(this.deleteVendor.bind(this))
|
||||
);
|
||||
router.delete('/', [
|
||||
...this.bulkContactsSchema,
|
||||
],
|
||||
this.validationResult,
|
||||
asyncMiddleware(this.deleteBulkVendors.bind(this))
|
||||
);
|
||||
router.get('/:id', [
|
||||
...this.specificContactSchema,
|
||||
],
|
||||
this.validationResult,
|
||||
asyncMiddleware(this.getVendor.bind(this))
|
||||
);
|
||||
return router;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vendor DTO schema.
|
||||
*/
|
||||
get vendorDTOSchema() {
|
||||
return [
|
||||
check('opening_balance').optional().isNumeric().toInt(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new vendor.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {NextFunction} next
|
||||
*/
|
||||
async newVendor(req: Request, res: Response, next: NextFunction) {
|
||||
const contactDTO: IVendorNewDTO = this.matchedBodyData(req);
|
||||
const { tenantId } = req;
|
||||
|
||||
try {
|
||||
const contact = await this.vendorsService.newVendor(tenantId, contactDTO);
|
||||
return res.status(200).send({ id: contact.id });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Edits the given vendor details.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {NextFunction} next
|
||||
*/
|
||||
async editVendor(req: Request, res: Response, next: NextFunction) {
|
||||
const contactDTO: IVendorEditDTO = this.matchedBodyData(req);
|
||||
const { tenantId } = req;
|
||||
const { id: contactId } = req.params;
|
||||
|
||||
try {
|
||||
await this.vendorsService.editVendor(tenantId, contactId, contactDTO);
|
||||
return res.status(200).send({ id: contactId });
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceError) {
|
||||
if (error.errorType === 'contact_not_found') {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'VENDOR.NOT.FOUND', code: 100 }],
|
||||
});
|
||||
}
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the given vendor from the storage.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {NextFunction} next
|
||||
*/
|
||||
async deleteVendor(req: Request, res: Response, next: NextFunction) {
|
||||
const { tenantId } = req;
|
||||
const { id: contactId } = req.params;
|
||||
|
||||
try {
|
||||
await this.vendorsService.deleteVendor(tenantId, contactId)
|
||||
return res.status(200).send({ id: contactId });
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceError) {
|
||||
if (error.errorType === 'contact_not_found') {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'VENDOR.NOT.FOUND', code: 100 }],
|
||||
});
|
||||
}
|
||||
if (error.errorType === 'vendor_has_bills') {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'VENDOR.HAS.BILLS', code: 200 }],
|
||||
});
|
||||
}
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve details of the given vendor id.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {NextFunction} next
|
||||
*/
|
||||
async getVendor(req: Request, res: Response, next: NextFunction) {
|
||||
const { tenantId } = req;
|
||||
const { id: vendorId } = req.params;
|
||||
|
||||
try {
|
||||
const vendor = await this.vendorsService.getVendor(tenantId, vendorId)
|
||||
return res.status(200).send({ vendor });
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceError) {
|
||||
if (error.errorType === 'contact_not_found') {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'VENDOR.NOT.FOUND', code: 100 }],
|
||||
});
|
||||
}
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes vendors in bulk.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {NextFunction} next
|
||||
*/
|
||||
async deleteBulkVendors(req: Request, res: Response, next: NextFunction) {
|
||||
const { ids: contactsIds } = req.query;
|
||||
const { tenantId } = req;
|
||||
|
||||
try {
|
||||
await this.vendorsService.deleteBulkVendors(tenantId, contactsIds)
|
||||
return res.status(200).send({ ids: contactsIds });
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceError) {
|
||||
if (error.errorType === 'contacts_not_found') {
|
||||
return res.boom.badRequest(null, {
|
||||
errors: [{ type: 'VENDORS.NOT.FOUND', code: 100 }],
|
||||
});
|
||||
}
|
||||
if (error.errorType === 'some_vendors_have_bills') {
|
||||
return res.boom.badRequest(null, {
|
||||
errors: [{ type: 'SOME.VENDORS.HAVE.BILLS', code: 200 }],
|
||||
});
|
||||
}
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
135
server/src/api/controllers/Currencies.js
Normal file
135
server/src/api/controllers/Currencies.js
Normal file
@@ -0,0 +1,135 @@
|
||||
import express from 'express';
|
||||
import { check, param, validationResult } from 'express-validator';
|
||||
import asyncMiddleware from 'api/middleware/asyncMiddleware';
|
||||
|
||||
export default {
|
||||
/**
|
||||
* Router constructor.
|
||||
*/
|
||||
router() {
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/',
|
||||
this.all.validation,
|
||||
asyncMiddleware(this.all.handler));
|
||||
|
||||
router.post('/',
|
||||
this.newCurrency.validation,
|
||||
asyncMiddleware(this.newCurrency.handler));
|
||||
|
||||
router.post('/:id',
|
||||
this.editCurrency.validation,
|
||||
asyncMiddleware(this.editCurrency.handler));
|
||||
|
||||
router.delete('/:currency_code',
|
||||
this.deleteCurrecy.validation,
|
||||
asyncMiddleware(this.deleteCurrecy.handler));
|
||||
|
||||
return router;
|
||||
},
|
||||
|
||||
/**
|
||||
* Retrieve all registered currency details.
|
||||
*/
|
||||
all: {
|
||||
validation: [],
|
||||
async handler(req, res) {
|
||||
const { Currency } = req.models;
|
||||
const currencies = await Currency.query();
|
||||
|
||||
return res.status(200).send({
|
||||
currencies: [
|
||||
...currencies,
|
||||
],
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
newCurrency: {
|
||||
validation: [
|
||||
check('currency_name').exists().trim().escape(),
|
||||
check('currency_code').exists().trim().escape(),
|
||||
],
|
||||
async handler(req, res) {
|
||||
const validationErrors = validationResult(req);
|
||||
|
||||
if (!validationErrors.isEmpty()) {
|
||||
return res.boom.badData(null, {
|
||||
code: 'validation_error', ...validationErrors,
|
||||
});
|
||||
}
|
||||
const form = { ...req.body };
|
||||
const { Currency } = req.models;
|
||||
|
||||
const foundCurrency = await Currency.query()
|
||||
.where('currency_code', form.currency_code);
|
||||
|
||||
if (foundCurrency.length > 0) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'CURRENCY.CODE.ALREADY.EXISTS', code: 100 }],
|
||||
});
|
||||
}
|
||||
await Currency.query()
|
||||
.insert({ ...form });
|
||||
|
||||
return res.status(200).send({
|
||||
currency: { ...form },
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
deleteCurrecy: {
|
||||
validation: [
|
||||
param('currency_code').exists().trim().escape(),
|
||||
],
|
||||
async handler(req, res) {
|
||||
const validationErrors = validationResult(req);
|
||||
|
||||
if (!validationErrors.isEmpty()) {
|
||||
return res.boom.badData(null, {
|
||||
code: 'validation_error', ...validationErrors,
|
||||
});
|
||||
}
|
||||
const { Currency } = req.models;
|
||||
const { currency_code: currencyCode } = req.params;
|
||||
|
||||
await Currency.query()
|
||||
.where('currency_code', currencyCode)
|
||||
.delete();
|
||||
|
||||
return res.status(200).send({ currency_code: currencyCode });
|
||||
},
|
||||
},
|
||||
|
||||
editCurrency: {
|
||||
validation: [
|
||||
param('id').exists().isNumeric().toInt(),
|
||||
check('currency_name').exists().trim().escape(),
|
||||
check('currency_code').exists().trim().escape(),
|
||||
],
|
||||
async handler(req, res) {
|
||||
const validationErrors = validationResult(req);
|
||||
|
||||
if (!validationErrors.isEmpty()) {
|
||||
return res.boom.badData(null, {
|
||||
code: 'validation_error', ...validationErrors,
|
||||
});
|
||||
}
|
||||
const form = { ...req.body };
|
||||
const { id } = req.params;
|
||||
const { Currency } = req.models;
|
||||
|
||||
const foundCurrency = await Currency.query()
|
||||
.where('currency_code', form.currency_code).whereNot('id', id);
|
||||
|
||||
if (foundCurrency.length > 0) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'CURRENCY.CODE.ALREADY.EXISTS', code: 100 }],
|
||||
});
|
||||
}
|
||||
await Currency.query().where('id', id).update({ ...form });
|
||||
|
||||
return res.status(200).send({ currency: { ...form } });
|
||||
},
|
||||
},
|
||||
};
|
||||
209
server/src/api/controllers/ExchangeRates.js
Normal file
209
server/src/api/controllers/ExchangeRates.js
Normal file
@@ -0,0 +1,209 @@
|
||||
import express from 'express';
|
||||
import {
|
||||
check,
|
||||
param,
|
||||
query,
|
||||
validationResult,
|
||||
} from 'express-validator';
|
||||
import moment from 'moment';
|
||||
import { difference } from 'lodash';
|
||||
import asyncMiddleware from 'api/middleware/asyncMiddleware';
|
||||
|
||||
export default {
|
||||
/**
|
||||
* Constructor method.
|
||||
*/
|
||||
router() {
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/',
|
||||
this.exchangeRates.validation,
|
||||
asyncMiddleware(this.exchangeRates.handler));
|
||||
|
||||
router.post('/',
|
||||
this.addExchangeRate.validation,
|
||||
asyncMiddleware(this.addExchangeRate.handler));
|
||||
|
||||
router.post('/:id',
|
||||
this.editExchangeRate.validation,
|
||||
asyncMiddleware(this.editExchangeRate.handler));
|
||||
|
||||
router.delete('/bulk',
|
||||
this.bulkDeleteExchangeRates.validation,
|
||||
asyncMiddleware(this.bulkDeleteExchangeRates.handler));
|
||||
|
||||
router.delete('/:id',
|
||||
this.deleteExchangeRate.validation,
|
||||
asyncMiddleware(this.deleteExchangeRate.handler));
|
||||
|
||||
return router;
|
||||
},
|
||||
|
||||
/**
|
||||
* Retrieve exchange rates.
|
||||
*/
|
||||
exchangeRates: {
|
||||
validation: [
|
||||
query('page').optional().isNumeric().toInt(),
|
||||
query('page_size').optional().isNumeric().toInt(),
|
||||
],
|
||||
async handler(req, res) {
|
||||
const validationErrors = validationResult(req);
|
||||
|
||||
if (!validationErrors.isEmpty()) {
|
||||
return res.boom.badData(null, {
|
||||
code: 'validation_error', ...validationErrors,
|
||||
});
|
||||
}
|
||||
const filter = {
|
||||
page: 1,
|
||||
page_size: 10,
|
||||
...req.query,
|
||||
};
|
||||
const { ExchangeRate } = req.models;
|
||||
const exchangeRates = await ExchangeRate.query()
|
||||
.pagination(filter.page - 1, filter.page_size);
|
||||
|
||||
return res.status(200).send({ exchange_rates: exchangeRates });
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Adds a new exchange rate on the given date.
|
||||
*/
|
||||
addExchangeRate: {
|
||||
validation: [
|
||||
check('exchange_rate').exists().isNumeric().toFloat(),
|
||||
check('currency_code').exists().trim().escape(),
|
||||
check('date').exists().isISO8601(),
|
||||
],
|
||||
async handler(req, res) {
|
||||
const validationErrors = validationResult(req);
|
||||
|
||||
if (!validationErrors.isEmpty()) {
|
||||
return res.boom.badData(null, {
|
||||
code: 'validation_error', ...validationErrors,
|
||||
});
|
||||
}
|
||||
const { ExchangeRate } = req.models;
|
||||
const form = { ...req.body };
|
||||
const foundExchangeRate = await ExchangeRate.query()
|
||||
.where('currency_code', form.currency_code)
|
||||
.where('date', form.date);
|
||||
|
||||
if (foundExchangeRate.length > 0) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'EXCHANGE.RATE.DATE.PERIOD.DEFINED', code: 200 }],
|
||||
});
|
||||
}
|
||||
await ExchangeRate.query().insert({
|
||||
...form,
|
||||
date: moment(form.date).format('YYYY-MM-DD'),
|
||||
});
|
||||
|
||||
return res.status(200).send();
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
/**
|
||||
* Edit the given exchange rate.
|
||||
*/
|
||||
editExchangeRate: {
|
||||
validation: [
|
||||
param('id').exists().isNumeric().toInt(),
|
||||
check('exchange_rate').exists().isNumeric().toFloat(),
|
||||
],
|
||||
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 form = { ...req.body };
|
||||
const { ExchangeRate } = req.models;
|
||||
|
||||
const foundExchangeRate = await ExchangeRate.query()
|
||||
.where('id', id);
|
||||
|
||||
if (!foundExchangeRate.length) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'EXCHANGE.RATE.NOT.FOUND', code: 200 }],
|
||||
});
|
||||
}
|
||||
await ExchangeRate.query()
|
||||
.where('id', id)
|
||||
.update({ ...form });
|
||||
|
||||
return res.status(200).send({ id });
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete the given exchange rate from the storage.
|
||||
*/
|
||||
deleteExchangeRate: {
|
||||
validation: [
|
||||
param('id').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 { ExchangeRate } = req.models;
|
||||
const foundExchangeRate = await ExchangeRate.query().where('id', id);
|
||||
|
||||
if (!foundExchangeRate.length) {
|
||||
return res.status(404).send({
|
||||
errors: [{ type: 'EXCHANGE.RATE.NOT.FOUND', code: 200 }],
|
||||
});
|
||||
}
|
||||
await ExchangeRate.query().where('id', id).delete();
|
||||
|
||||
return res.status(200).send({ id });
|
||||
},
|
||||
},
|
||||
|
||||
bulkDeleteExchangeRates: {
|
||||
validation: [
|
||||
query('ids').isArray({ min: 2 }),
|
||||
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 = {
|
||||
ids: [],
|
||||
...req.query,
|
||||
};
|
||||
const { ExchangeRate } = req.models;
|
||||
|
||||
const exchangeRates = await ExchangeRate.query().whereIn('id', filter.ids);
|
||||
const exchangeRatesIds = exchangeRates.map((category) => category.id);
|
||||
const notFoundExRates = difference(filter.ids, exchangeRatesIds);
|
||||
|
||||
if (notFoundExRates.length > 0) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'EXCHANGE.RATES.IS.NOT.FOUND', code: 200, ids: notFoundExRates }],
|
||||
});
|
||||
}
|
||||
await ExchangeRate.query().whereIn('id', exchangeRatesIds).delete();
|
||||
|
||||
return res.status(200).send({ ids: exchangeRatesIds });
|
||||
},
|
||||
},
|
||||
}
|
||||
265
server/src/api/controllers/Expenses.ts
Normal file
265
server/src/api/controllers/Expenses.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
import { Inject, Service } from "typedi";
|
||||
import { check, param, query } from 'express-validator';
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import asyncMiddleware from 'api/middleware/asyncMiddleware';
|
||||
import BaseController from "api/controllers/BaseController";
|
||||
import ExpensesService from "services/Expenses/ExpensesService";
|
||||
import { IExpenseDTO } from 'interfaces';
|
||||
import { ServiceError } from "exceptions";
|
||||
|
||||
@Service()
|
||||
export default class ExpensesController extends BaseController {
|
||||
@Inject()
|
||||
expensesService: ExpensesService;
|
||||
|
||||
/**
|
||||
* Express router.
|
||||
*/
|
||||
router() {
|
||||
const router = Router();
|
||||
|
||||
router.post(
|
||||
'/', [
|
||||
...this.expenseDTOSchema,
|
||||
],
|
||||
this.validationResult,
|
||||
asyncMiddleware(this.newExpense.bind(this))
|
||||
);
|
||||
router.post('/publish', [
|
||||
...this.bulkSelectSchema,
|
||||
],
|
||||
this.bulkPublishExpenses.bind(this)
|
||||
);
|
||||
router.post(
|
||||
'/:id/publish', [
|
||||
...this.expenseParamSchema,
|
||||
],
|
||||
this.validationResult,
|
||||
asyncMiddleware(this.publishExpense.bind(this))
|
||||
);
|
||||
router.post(
|
||||
'/:id', [
|
||||
...this.expenseDTOSchema,
|
||||
...this.expenseParamSchema,
|
||||
],
|
||||
this.validationResult,
|
||||
asyncMiddleware(this.editExpense.bind(this)),
|
||||
);
|
||||
router.delete(
|
||||
'/:id', [
|
||||
...this.expenseParamSchema,
|
||||
],
|
||||
this.validationResult,
|
||||
asyncMiddleware(this.deleteExpense.bind(this))
|
||||
);
|
||||
router.delete('/', [
|
||||
...this.bulkSelectSchema,
|
||||
],
|
||||
this.validationResult,
|
||||
asyncMiddleware(this.bulkDeleteExpenses.bind(this))
|
||||
);
|
||||
return router;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expense DTO schema.
|
||||
*/
|
||||
get expenseDTOSchema() {
|
||||
return [
|
||||
check('reference_no').optional().trim().escape().isLength({ max: 255 }),
|
||||
check('payment_date').exists().isISO8601(),
|
||||
check('payment_account_id').exists().isNumeric().toInt(),
|
||||
check('description').optional(),
|
||||
check('currency_code').optional(),
|
||||
check('exchange_rate').optional().isNumeric().toFloat(),
|
||||
check('publish').optional().isBoolean().toBoolean(),
|
||||
check('categories').exists().isArray({ min: 1 }),
|
||||
check('categories.*.index').exists().isNumeric().toInt(),
|
||||
check('categories.*.expense_account_id').exists().isNumeric().toInt(),
|
||||
check('categories.*.amount')
|
||||
.optional({ nullable: true })
|
||||
.isNumeric()
|
||||
.isDecimal()
|
||||
.isFloat({ max: 9999999999.999 }) // 13, 3
|
||||
.toFloat(),
|
||||
check('categories.*.description').optional().trim().escape().isLength({
|
||||
max: 255,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Expense param schema.
|
||||
*/
|
||||
get expenseParamSchema() {
|
||||
return [
|
||||
param('id').exists().isNumeric().toInt(),
|
||||
];
|
||||
}
|
||||
|
||||
get bulkSelectSchema() {
|
||||
return [
|
||||
query('ids').isArray({ min: 1 }),
|
||||
query('ids.*').isNumeric().toInt(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new expense on
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {NextFunction} next
|
||||
*/
|
||||
async newExpense(req: Request, res: Response, next: NextFunction) {
|
||||
const expenseDTO: IExpenseDTO = this.matchedBodyData(req);
|
||||
const { tenantId, user } = req;
|
||||
|
||||
try {
|
||||
const expense = await this.expensesService.newExpense(tenantId, expenseDTO, user);
|
||||
return res.status(200).send({ id: expense.id });
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceError) {
|
||||
this.serviceErrorsTransformer(res, error);
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Edits details of the given expense.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {NextFunction} next
|
||||
*/
|
||||
async editExpense(req: Request, res: Response, next: NextFunction) {
|
||||
const { id: expenseId } = req.params;
|
||||
const expenseDTO: IExpenseDTO = this.matchedBodyData(req);
|
||||
const { tenantId, user } = req;
|
||||
|
||||
try {
|
||||
await this.expensesService.editExpense(tenantId, expenseId, expenseDTO, user);
|
||||
return res.status(200).send({ id: expenseId });
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceError) {
|
||||
this.serviceErrorsTransformer(res, error);
|
||||
}
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the given expense.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {NextFunction} next
|
||||
*/
|
||||
async deleteExpense(req: Request, res: Response, next: NextFunction) {
|
||||
const { tenantId } = req;
|
||||
const { id: expenseId } = req.params;
|
||||
|
||||
try {
|
||||
await this.expensesService.deleteExpense(tenantId, expenseId)
|
||||
return res.status(200).send({ id: expenseId });
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceError) {
|
||||
this.serviceErrorsTransformer(res, error);
|
||||
}
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishs the given expense.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {NextFunction} next
|
||||
*/
|
||||
async publishExpense(req: Request, res: Response, next: NextFunction) {
|
||||
const { tenantId } = req;
|
||||
const { id: expenseId } = req.params;
|
||||
|
||||
try {
|
||||
await this.expensesService.publishExpense(tenantId, expenseId)
|
||||
return res.status(200).send({ });
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceError) {
|
||||
this.serviceErrorsTransformer(req, error);
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the expenses in bulk.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {NextFunction} next
|
||||
*/
|
||||
async bulkDeleteExpenses(req: Request, res: Response, next: NextFunction) {
|
||||
const { tenantId } = req;
|
||||
const { ids: expensesIds } = req.params;
|
||||
|
||||
try {
|
||||
await this.expensesService.deleteBulkExpenses(tenantId, expensesIds);
|
||||
return res.status(200).send({ ids: expensesIds });
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceError) {
|
||||
this.serviceErrorsTransformer(req, error);
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async bulkPublishExpenses(req: Request, res: Response, next: NextFunction) {
|
||||
const { tenantId } = req;
|
||||
|
||||
try {
|
||||
await this.expensesService.publishBulkExpenses(tenantId,);
|
||||
return res.status(200).send({});
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceError) {
|
||||
this.serviceErrorsTransformer(req, error);
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform service errors to api response errors.
|
||||
* @param {Response} res
|
||||
* @param {ServiceError} error
|
||||
*/
|
||||
serviceErrorsTransformer(res, error: ServiceError) {
|
||||
if (error.errorType === 'expense_not_found') {
|
||||
return res.boom.badRequest(null, {
|
||||
errors: [{ type: 'EXPENSE_NOT_FOUND' }],
|
||||
});
|
||||
}
|
||||
if (error.errorType === 'total_amount_equals_zero') {
|
||||
return res.boom.badRequest(null, {
|
||||
errors: [{ type: 'TOTAL.AMOUNT.EQUALS.ZERO' }],
|
||||
});
|
||||
}
|
||||
if (error.errorType === 'payment_account_not_found') {
|
||||
return res.boom.badRequest(null, {
|
||||
errors: [{ type: 'PAYMENT.ACCOUNT.NOT.FOUND', }],
|
||||
});
|
||||
}
|
||||
if (error.errorType === 'some_expenses_not_found') {
|
||||
return res.boom.badRequest(null, {
|
||||
errors: [{ type: 'SOME.EXPENSE.ACCOUNTS.NOT.FOUND', code: 200 }]
|
||||
})
|
||||
}
|
||||
if (error.errorType === 'payment_account_has_invalid_type') {
|
||||
return res.boom.badRequest(null, {
|
||||
errors: [{ type: 'PAYMENT.ACCOUNT.HAS.INVALID.TYPE' }],
|
||||
});
|
||||
}
|
||||
if (error.errorType === 'expenses_account_has_invalid_type') {
|
||||
return res.boom.badRequest(null, {
|
||||
errors: [{ type: 'EXPENSES.ACCOUNT.HAS.INVALID.TYPE' }]
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
234
server/src/api/controllers/Fields.js
Normal file
234
server/src/api/controllers/Fields.js
Normal file
@@ -0,0 +1,234 @@
|
||||
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') });
|
||||
},
|
||||
},
|
||||
};
|
||||
28
server/src/api/controllers/FinancialStatements.js
Normal file
28
server/src/api/controllers/FinancialStatements.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import express from 'express';
|
||||
|
||||
import BalanceSheetController from './FinancialStatements/BalanceSheet';
|
||||
import TrialBalanceSheetController from './FinancialStatements/TrialBalanceSheet';
|
||||
import GeneralLedgerController from './FinancialStatements/generalLedger';
|
||||
import JournalSheetController from './FinancialStatements/JournalSheet';
|
||||
import ProfitLossController from './FinancialStatements/ProfitLossSheet';
|
||||
import ReceivableAgingSummary from './FinancialStatements/ReceivableAgingSummary';
|
||||
import PayableAgingSummary from './FinancialStatements/PayableAgingSummary';
|
||||
|
||||
export default {
|
||||
/**
|
||||
* Router constructor.
|
||||
*/
|
||||
router() {
|
||||
const router = express.Router();
|
||||
|
||||
router.use('/balance_sheet', BalanceSheetController.router());
|
||||
router.use('/profit_loss_sheet', ProfitLossController.router());
|
||||
router.use('/general_ledger', GeneralLedgerController.router());
|
||||
router.use('/trial_balance_sheet', TrialBalanceSheetController.router());
|
||||
router.use('/journal', JournalSheetController.router());
|
||||
router.use('/receivable_aging_summary', ReceivableAgingSummary.router());
|
||||
router.use('/payable_aging_summary', PayableAgingSummary.router());
|
||||
|
||||
return router;
|
||||
},
|
||||
};
|
||||
106
server/src/api/controllers/FinancialStatements/AgingReport.js
Normal file
106
server/src/api/controllers/FinancialStatements/AgingReport.js
Normal file
@@ -0,0 +1,106 @@
|
||||
import moment from 'moment';
|
||||
import { validationResult } from 'express-validator';
|
||||
import { omit, reverse } from 'lodash';
|
||||
import BaseController from 'api/controllers/BaseController';
|
||||
|
||||
export default class AgingReport extends BaseController{
|
||||
|
||||
/**
|
||||
* Express validator middleware.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {Function} next
|
||||
*/
|
||||
static validateResults(req, res, next) {
|
||||
const validationErrors = validationResult(req);
|
||||
|
||||
if (!validationErrors.isEmpty()) {
|
||||
return res.boom.badData(null, {
|
||||
code: 'validation_error', ...validationErrors,
|
||||
});
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Array} agingPeriods
|
||||
* @param {Numeric} customerBalance
|
||||
*/
|
||||
static contactAgingBalance(agingPeriods, receivableTotalCredit) {
|
||||
let prevAging = 0;
|
||||
let receivableCredit = receivableTotalCredit;
|
||||
let diff = receivableCredit;
|
||||
|
||||
const periods = reverse(agingPeriods).map((agingPeriod) => {
|
||||
const agingAmount = (agingPeriod.closingBalance - prevAging);
|
||||
const subtract = Math.min(diff, agingAmount);
|
||||
diff -= Math.min(agingAmount, diff);
|
||||
|
||||
const total = Math.max(agingAmount - subtract, 0);
|
||||
|
||||
const output = {
|
||||
...omit(agingPeriod, ['closingBalance']),
|
||||
total,
|
||||
};
|
||||
prevAging = agingPeriod.closingBalance;
|
||||
return output;
|
||||
});
|
||||
return reverse(periods);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {*} asDay
|
||||
* @param {*} agingDaysBefore
|
||||
* @param {*} agingPeriodsFreq
|
||||
*/
|
||||
static agingRangePeriods(asDay, agingDaysBefore, agingPeriodsFreq) {
|
||||
const totalAgingDays = agingDaysBefore * agingPeriodsFreq;
|
||||
const startAging = moment(asDay).startOf('day');
|
||||
const endAging = startAging.clone().subtract('days', totalAgingDays).endOf('day');
|
||||
|
||||
const agingPeriods = [];
|
||||
const startingAging = startAging.clone();
|
||||
|
||||
let beforeDays = 1;
|
||||
let toDays = 0;
|
||||
|
||||
while (startingAging > endAging) {
|
||||
const currentAging = startingAging.clone();
|
||||
startingAging.subtract('days', agingDaysBefore).endOf('day');
|
||||
toDays += agingDaysBefore;
|
||||
|
||||
agingPeriods.push({
|
||||
from_period: moment(currentAging).toDate(),
|
||||
to_period: moment(startingAging).toDate(),
|
||||
before_days: beforeDays === 1 ? 0 : beforeDays,
|
||||
to_days: toDays,
|
||||
...(startingAging.valueOf() === endAging.valueOf()) ? {
|
||||
to_period: null,
|
||||
to_days: null,
|
||||
} : {},
|
||||
});
|
||||
beforeDays += agingDaysBefore;
|
||||
}
|
||||
return agingPeriods;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {*} filter
|
||||
*/
|
||||
static formatNumberClosure(filter) {
|
||||
return (balance) => {
|
||||
let formattedBalance = parseFloat(balance);
|
||||
|
||||
if (filter.no_cents) {
|
||||
formattedBalance = parseInt(formattedBalance, 10);
|
||||
}
|
||||
if (filter.divide_1000) {
|
||||
formattedBalance /= 1000;
|
||||
}
|
||||
return formattedBalance;
|
||||
};
|
||||
}
|
||||
}
|
||||
237
server/src/api/controllers/FinancialStatements/BalanceSheet.js
Normal file
237
server/src/api/controllers/FinancialStatements/BalanceSheet.js
Normal file
@@ -0,0 +1,237 @@
|
||||
import express from 'express';
|
||||
import { query, validationResult } from 'express-validator';
|
||||
import moment from 'moment';
|
||||
import { pick, omit, sumBy } from 'lodash';
|
||||
import JournalPoster from 'services/Accounting/JournalPoster';
|
||||
import { dateRangeCollection, itemsStartWith, getTotalDeep } from 'utils';
|
||||
import asyncMiddleware from 'api/middleware/asyncMiddleware';
|
||||
import { formatNumberClosure } from './FinancialStatementMixin';
|
||||
import BalanceSheetStructure from 'data/BalanceSheetStructure';
|
||||
|
||||
export default {
|
||||
/**
|
||||
* Router constructor.
|
||||
*/
|
||||
router() {
|
||||
const router = express.Router();
|
||||
|
||||
router.get(
|
||||
'/',
|
||||
this.balanceSheet.validation,
|
||||
asyncMiddleware(this.balanceSheet.handler)
|
||||
);
|
||||
|
||||
return router;
|
||||
},
|
||||
|
||||
/**
|
||||
* Retrieve the balance sheet.
|
||||
*/
|
||||
balanceSheet: {
|
||||
validation: [
|
||||
query('accounting_method').optional().isIn(['cash', 'accural']),
|
||||
query('from_date').optional(),
|
||||
query('to_date').optional(),
|
||||
query('display_columns_type').optional().isIn(['date_periods', 'total']),
|
||||
query('display_columns_by')
|
||||
.optional({ nullable: true, checkFalsy: true })
|
||||
.isIn(['year', 'month', 'week', 'day', 'quarter']),
|
||||
query('number_format.no_cents').optional().isBoolean().toBoolean(),
|
||||
query('number_format.divide_1000').optional().isBoolean().toBoolean(),
|
||||
query('account_ids').isArray().optional(),
|
||||
query('account_ids.*').isNumeric().toInt(),
|
||||
query('none_zero').optional().isBoolean().toBoolean(),
|
||||
],
|
||||
async handler(req, res) {
|
||||
const validationErrors = validationResult(req);
|
||||
|
||||
if (!validationErrors.isEmpty()) {
|
||||
return res.boom.badData(null, {
|
||||
code: 'validation_error',
|
||||
...validationErrors,
|
||||
});
|
||||
}
|
||||
const { Account, AccountType } = req.models;
|
||||
|
||||
const filter = {
|
||||
display_columns_type: 'total',
|
||||
display_columns_by: '',
|
||||
from_date: moment().startOf('year').format('YYYY-MM-DD'),
|
||||
to_date: moment().endOf('year').format('YYYY-MM-DD'),
|
||||
number_format: {
|
||||
no_cents: false,
|
||||
divide_1000: false,
|
||||
},
|
||||
none_zero: false,
|
||||
basis: 'cash',
|
||||
account_ids: [],
|
||||
...req.query,
|
||||
};
|
||||
if (!Array.isArray(filter.account_ids)) {
|
||||
filter.account_ids = [filter.account_ids];
|
||||
}
|
||||
// Account balance formmatter based on the given query.
|
||||
const amountFormatter = formatNumberClosure(filter.number_format);
|
||||
const comparatorDateType =
|
||||
filter.display_columns_type === 'total'
|
||||
? 'day'
|
||||
: filter.display_columns_by;
|
||||
|
||||
const balanceSheetTypes = await AccountType.query().where(
|
||||
'balance_sheet',
|
||||
true
|
||||
);
|
||||
// Fetch all balance sheet accounts from the storage.
|
||||
const accounts = await Account.query()
|
||||
.whereIn(
|
||||
'account_type_id',
|
||||
balanceSheetTypes.map((a) => a.id)
|
||||
)
|
||||
.modify('filterAccounts', filter.account_ids)
|
||||
.withGraphFetched('type')
|
||||
.withGraphFetched('transactions')
|
||||
.modifyGraph('transactions', (builder) => {
|
||||
builder.modify('filterDateRange', null, filter.to_date);
|
||||
});
|
||||
// Accounts dependency graph.
|
||||
const accountsGraph = Account.toDependencyGraph(accounts);
|
||||
// Load all entries that associated to the given accounts.
|
||||
const journalEntriesCollected = Account.collectJournalEntries(accounts);
|
||||
const journalEntries = new JournalPoster(accountsGraph);
|
||||
journalEntries.loadEntries(journalEntriesCollected);
|
||||
|
||||
// Date range collection.
|
||||
const dateRangeSet =
|
||||
filter.display_columns_type === 'date_periods'
|
||||
? dateRangeCollection(
|
||||
filter.from_date,
|
||||
filter.to_date,
|
||||
comparatorDateType
|
||||
)
|
||||
: [];
|
||||
// Gets the date range set from start to end date.
|
||||
const getAccountTotalPeriods = (account) => ({
|
||||
total_periods: dateRangeSet.map((date) => {
|
||||
const amount = journalEntries.getAccountBalance(
|
||||
account.id,
|
||||
date,
|
||||
comparatorDateType
|
||||
);
|
||||
return {
|
||||
amount,
|
||||
date,
|
||||
formatted_amount: amountFormatter(amount),
|
||||
};
|
||||
}),
|
||||
});
|
||||
// Retrieve accounts total periods.
|
||||
const getAccountsTotalPeriods = (_accounts) =>
|
||||
Object.values(
|
||||
dateRangeSet.reduce((acc, date, index) => {
|
||||
const amount = sumBy(_accounts, `total_periods[${index}].amount`);
|
||||
acc[date] = {
|
||||
date,
|
||||
amount,
|
||||
formatted_amount: amountFormatter(amount),
|
||||
};
|
||||
return acc;
|
||||
}, {})
|
||||
);
|
||||
// Retrieve account total and total periods with account meta.
|
||||
const getAccountTotal = (account) => {
|
||||
const closingBalance = journalEntries.getAccountBalance(
|
||||
account.id,
|
||||
filter.to_date
|
||||
);
|
||||
const totalPeriods =
|
||||
(filter.display_columns_type === 'date_periods' &&
|
||||
getAccountTotalPeriods(account)) ||
|
||||
null;
|
||||
|
||||
return {
|
||||
...pick(account, ['id', 'index', 'name', 'code', 'parentAccountId']),
|
||||
...(totalPeriods && { totalPeriods }),
|
||||
total: {
|
||||
amount: closingBalance,
|
||||
formatted_amount: amountFormatter(closingBalance),
|
||||
date: filter.to_date,
|
||||
},
|
||||
};
|
||||
};
|
||||
// Get accounts total of the given structure section
|
||||
const getAccountsSectionTotal = (_accounts) => {
|
||||
const total = getTotalDeep(_accounts, 'children', 'total.amount');
|
||||
return {
|
||||
total: {
|
||||
total,
|
||||
formatted_amount: amountFormatter(total),
|
||||
},
|
||||
};
|
||||
};
|
||||
// Strcuture accounts related mapper.
|
||||
const structureAccountsRelatedMapper = (accountsTypes) => {
|
||||
const filteredAccounts = accounts
|
||||
// Filter accounts that have no transaction when `none_zero` is on.
|
||||
.filter(
|
||||
(account) => account.transactions.length > 0 || !filter.none_zero
|
||||
)
|
||||
// Filter accounts that associated to the section accounts types.
|
||||
.filter(
|
||||
(account) => accountsTypes.indexOf(account.type.childType) !== -1
|
||||
)
|
||||
.map(getAccountTotal);
|
||||
// Gets total amount of the given accounts.
|
||||
const totalAmount = sumBy(filteredAccounts, 'total.amount');
|
||||
|
||||
return {
|
||||
children: Account.toNestedArray(filteredAccounts),
|
||||
total: {
|
||||
amount: totalAmount,
|
||||
formatted_amount: amountFormatter(totalAmount),
|
||||
},
|
||||
...(filter.display_columns_type === 'date_periods'
|
||||
? {
|
||||
total_periods: getAccountsTotalPeriods(filteredAccounts),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
// Structure section mapper.
|
||||
const structureSectionMapper = (structure) => {
|
||||
const result = {
|
||||
...omit(structure, itemsStartWith(Object.keys(structure), '_')),
|
||||
...(structure.children
|
||||
? {
|
||||
children: balanceSheetWalker(structure.children),
|
||||
}
|
||||
: {}),
|
||||
...(structure._accounts_types_related
|
||||
? {
|
||||
...structureAccountsRelatedMapper(
|
||||
structure._accounts_types_related
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
return {
|
||||
...result,
|
||||
...(!structure._accounts_types_related
|
||||
? getAccountsSectionTotal(result.children)
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
const balanceSheetWalker = (reportStructure) =>
|
||||
reportStructure.map(structureSectionMapper).filter(
|
||||
// Filter the structure sections that have no children.
|
||||
(structure) => structure.children.length > 0 || structure._forceShow
|
||||
);
|
||||
|
||||
// Response.
|
||||
return res.status(200).send({
|
||||
query: { ...filter },
|
||||
columns: { ...dateRangeSet },
|
||||
balance_sheet: [...balanceSheetWalker(BalanceSheetStructure)],
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
|
||||
|
||||
export const formatNumberClosure = (filter) => (balance) => {
|
||||
let formattedBalance = parseFloat(balance);
|
||||
|
||||
if (filter.no_cents) {
|
||||
formattedBalance = parseInt(formattedBalance, 10);
|
||||
}
|
||||
if (filter.divide_1000) {
|
||||
formattedBalance /= 1000;
|
||||
}
|
||||
return formattedBalance;
|
||||
};
|
||||
165
server/src/api/controllers/FinancialStatements/GeneralLedger.js
Normal file
165
server/src/api/controllers/FinancialStatements/GeneralLedger.js
Normal file
@@ -0,0 +1,165 @@
|
||||
import express from 'express';
|
||||
import { query, validationResult } from 'express-validator';
|
||||
import moment from 'moment';
|
||||
import { pick, difference } from 'lodash';
|
||||
import JournalPoster from 'services/Accounting/JournalPoster';
|
||||
import { formatNumberClosure } from './FinancialStatementMixin';
|
||||
import asyncMiddleware from 'api/middleware/asyncMiddleware';
|
||||
import DependencyGraph from 'lib/DependencyGraph';
|
||||
|
||||
export default {
|
||||
/**
|
||||
* Router constructor.
|
||||
*/
|
||||
router() {
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/',
|
||||
this.generalLedger.validation,
|
||||
asyncMiddleware(this.generalLedger.handler));
|
||||
|
||||
return router;
|
||||
},
|
||||
|
||||
/**
|
||||
* Retrieve the general ledger financial statement.
|
||||
*/
|
||||
generalLedger: {
|
||||
validation: [
|
||||
query('from_date').optional().isISO8601(),
|
||||
query('to_date').optional().isISO8601(),
|
||||
query('basis').optional(),
|
||||
query('number_format.no_cents').optional().isBoolean().toBoolean(),
|
||||
query('number_format.divide_1000').optional().isBoolean().toBoolean(),
|
||||
query('none_zero').optional().isBoolean().toBoolean(),
|
||||
query('accounts_ids').optional(),
|
||||
query('accounts_ids.*').isNumeric().toInt(),
|
||||
query('orderBy').optional().isIn(['created_at', 'name', 'code']),
|
||||
query('order').optional().isIn(['desc', 'asc']),
|
||||
],
|
||||
async handler(req, res) {
|
||||
const validationErrors = validationResult(req);
|
||||
|
||||
if (!validationErrors.isEmpty()) {
|
||||
return res.boom.badData(null, {
|
||||
code: 'validation_error', ...validationErrors,
|
||||
});
|
||||
}
|
||||
const { AccountTransaction, Account } = req.models;
|
||||
|
||||
const filter = {
|
||||
from_date: moment().startOf('year').format('YYYY-MM-DD'),
|
||||
to_date: moment().endOf('year').format('YYYY-MM-DD'),
|
||||
basis: 'cash',
|
||||
number_format: {
|
||||
no_cents: false,
|
||||
divide_1000: false,
|
||||
},
|
||||
none_zero: false,
|
||||
accounts_ids: [],
|
||||
...req.query,
|
||||
};
|
||||
if (!Array.isArray(filter.accounts_ids)) {
|
||||
filter.accounts_ids = [filter.accounts_ids];
|
||||
}
|
||||
filter.accounts_ids = filter.accounts_ids.map((id) => parseInt(id, 10));
|
||||
|
||||
const errorReasons = [];
|
||||
|
||||
if (filter.accounts_ids.length > 0) {
|
||||
const accounts = await Account.query().whereIn('id', filter.accounts_ids);
|
||||
const accountsIds = accounts.map((a) => a.id);
|
||||
|
||||
if (difference(filter.accounts_ids, accountsIds).length > 0) {
|
||||
errorReasons.push({ type: 'FILTER.ACCOUNTS.IDS.NOT.FOUND', code: 200 });
|
||||
}
|
||||
}
|
||||
if (errorReasons.length > 0) {
|
||||
return res.status(400).send({ error: errorReasons });
|
||||
}
|
||||
const accounts = await Account.query()
|
||||
// .remember('general_ledger_accounts')
|
||||
.orderBy('index', 'DESC')
|
||||
.modify('filterAccounts', filter.accounts_ids)
|
||||
.withGraphFetched('type')
|
||||
.withGraphFetched('transactions')
|
||||
.modifyGraph('transactions', (builder) => {
|
||||
builder.modify('filterDateRange', filter.from_date, filter.to_date);
|
||||
});
|
||||
|
||||
// Accounts dependency graph.
|
||||
const accountsGraph = DependencyGraph.fromArray(
|
||||
accounts, { itemId: 'id', parentItemId: 'parentAccountId' }
|
||||
);
|
||||
|
||||
const openingBalanceTransactions = await AccountTransaction.query()
|
||||
// .remember()
|
||||
.modify('filterDateRange', null, filter.from_date)
|
||||
.modify('sumationCreditDebit')
|
||||
.withGraphFetched('account.type');
|
||||
|
||||
const closingBalanceTransactions = await AccountTransaction.query()
|
||||
// .remember()
|
||||
.modify('filterDateRange', null, filter.to_date)
|
||||
.modify('sumationCreditDebit')
|
||||
.withGraphFetched('account.type');
|
||||
|
||||
const opeingBalanceCollection = new JournalPoster(accountsGraph);
|
||||
const closingBalanceCollection = new JournalPoster(accountsGraph);
|
||||
|
||||
opeingBalanceCollection.loadEntries(openingBalanceTransactions);
|
||||
closingBalanceCollection.loadEntries(closingBalanceTransactions);
|
||||
|
||||
// Transaction amount formatter based on the given query.
|
||||
const formatNumber = formatNumberClosure(filter.number_format);
|
||||
|
||||
const accountsResponse = accounts
|
||||
.filter((account) => (
|
||||
account.transactions.length > 0 || !filter.none_zero
|
||||
))
|
||||
.map((account) => ({
|
||||
...pick(account, ['id', 'name', 'code', 'index', 'parentAccountId']),
|
||||
transactions: [
|
||||
...account.transactions.map((transaction) => {
|
||||
let amount = 0;
|
||||
|
||||
if (account.type.normal === 'credit') {
|
||||
amount += transaction.credit - transaction.debit;
|
||||
} else if (account.type.normal === 'debit') {
|
||||
amount += transaction.debit - transaction.credit;
|
||||
}
|
||||
return {
|
||||
...pick(transaction, ['id', 'note', 'transactionType', 'referenceType',
|
||||
'referenceId', 'date', 'createdAt']),
|
||||
amount,
|
||||
formatted_amount: formatNumber(amount),
|
||||
};
|
||||
}),
|
||||
],
|
||||
opening: (() => {
|
||||
const openingAmount = opeingBalanceCollection.getAccountBalance(account.id);
|
||||
|
||||
return {
|
||||
date: filter.from_date,
|
||||
amount: openingAmount,
|
||||
formatted_amount: formatNumber(openingAmount),
|
||||
}
|
||||
})(),
|
||||
closing: (() => {
|
||||
const closingAmount = closingBalanceCollection.getAccountBalance(account.id);
|
||||
|
||||
return {
|
||||
date: filter.to_date,
|
||||
amount: closingAmount,
|
||||
formatted_amount: formatNumber(closingAmount),
|
||||
}
|
||||
})(),
|
||||
}));
|
||||
|
||||
return res.status(200).send({
|
||||
query: { ...filter },
|
||||
accounts: Account.toNestedArray(accountsResponse),
|
||||
});
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
|
||||
|
||||
export default class InventoryValuationSummary {
|
||||
|
||||
static router() {
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/inventory_valuation_summary',
|
||||
asyncMiddleware(this.inventoryValuationSummary),
|
||||
);
|
||||
return router;
|
||||
}
|
||||
|
||||
static inventoryValuationSummary(req, res) {
|
||||
|
||||
}
|
||||
}
|
||||
120
server/src/api/controllers/FinancialStatements/JournalSheet.js
Normal file
120
server/src/api/controllers/FinancialStatements/JournalSheet.js
Normal file
@@ -0,0 +1,120 @@
|
||||
import express from 'express';
|
||||
import { query, oneOf, validationResult } from 'express-validator';
|
||||
import moment from 'moment';
|
||||
import { groupBy } from 'lodash';
|
||||
import JournalPoster from 'services/Accounting/JournalPoster';
|
||||
import asyncMiddleware from 'api/middleware/asyncMiddleware';
|
||||
import { formatNumberClosure } from './FinancialStatementMixin';
|
||||
|
||||
|
||||
export default {
|
||||
/**
|
||||
* Router constructor.
|
||||
*/
|
||||
router() {
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/',
|
||||
this.journal.validation,
|
||||
asyncMiddleware(this.journal.handler));
|
||||
|
||||
return router;
|
||||
},
|
||||
|
||||
/**
|
||||
* Retrieve the ledger report of the given account.
|
||||
*/
|
||||
journal: {
|
||||
validation: [
|
||||
query('from_date').optional().isISO8601(),
|
||||
query('to_date').optional().isISO8601(),
|
||||
oneOf([
|
||||
query('transaction_types').optional().isArray({ min: 1 }),
|
||||
query('transaction_types.*').optional().isNumeric().toInt(),
|
||||
], [
|
||||
query('transaction_types').optional().trim().escape(),
|
||||
]),
|
||||
oneOf([
|
||||
query('account_ids').optional().isArray({ min: 1 }),
|
||||
query('account_ids.*').optional().isNumeric().toInt(),
|
||||
], [
|
||||
query('account_ids').optional().isNumeric().toInt(),
|
||||
]),
|
||||
query('from_range').optional().isNumeric().toInt(),
|
||||
query('to_range').optional().isNumeric().toInt(),
|
||||
query('number_format.no_cents').optional().isBoolean().toBoolean(),
|
||||
query('number_format.divide_1000').optional().isBoolean().toBoolean(),
|
||||
],
|
||||
async handler(req, res) {
|
||||
const validationErrors = validationResult(req);
|
||||
|
||||
if (!validationErrors.isEmpty()) {
|
||||
return res.boom.badData(null, {
|
||||
code: 'validation_error', ...validationErrors,
|
||||
});
|
||||
}
|
||||
const { AccountTransaction } = req.models;
|
||||
|
||||
const filter = {
|
||||
from_date: moment().startOf('year').format('YYYY-MM-DD'),
|
||||
to_date: moment().endOf('year').format('YYYY-MM-DD'),
|
||||
from_range: null,
|
||||
to_range: null,
|
||||
account_ids: [],
|
||||
transaction_types: [],
|
||||
number_format: {
|
||||
no_cents: false,
|
||||
divide_1000: false,
|
||||
},
|
||||
...req.query,
|
||||
};
|
||||
if (!Array.isArray(filter.transaction_types)) {
|
||||
filter.transaction_types = [filter.transaction_types];
|
||||
}
|
||||
if (!Array.isArray(filter.account_ids)) {
|
||||
filter.account_ids = [filter.account_ids];
|
||||
}
|
||||
filter.account_ids = filter.account_ids.map((id) => parseInt(id, 10));
|
||||
|
||||
const accountsJournalEntries = await AccountTransaction.query()
|
||||
// .remember()
|
||||
.modify('filterDateRange', filter.from_date, filter.to_date)
|
||||
.modify('filterAccounts', filter.account_ids)
|
||||
.modify('filterTransactionTypes', filter.transaction_types)
|
||||
.modify('filterAmountRange', filter.from_range, filter.to_range)
|
||||
.withGraphFetched('account.type');
|
||||
|
||||
const formatNumber = formatNumberClosure(filter.number_format);
|
||||
|
||||
const journalGrouped = groupBy(accountsJournalEntries,
|
||||
(entry) => `${entry.referenceId}-${entry.referenceType}`);
|
||||
|
||||
const journal = Object.keys(journalGrouped).map((key) => {
|
||||
const transactionsGroup = journalGrouped[key];
|
||||
|
||||
const journalPoster = new JournalPoster();
|
||||
journalPoster.loadEntries(transactionsGroup);
|
||||
|
||||
const trialBalance = journalPoster.getTrialBalance();
|
||||
|
||||
return {
|
||||
id: key,
|
||||
entries: transactionsGroup,
|
||||
|
||||
credit: trialBalance.credit,
|
||||
debit: trialBalance.debit,
|
||||
|
||||
formatted_credit: formatNumber(trialBalance.credit),
|
||||
formatted_debit: formatNumber(trialBalance.debit),
|
||||
};
|
||||
});
|
||||
|
||||
return res.status(200).send({
|
||||
query: { ...filter },
|
||||
journal,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import express from 'express';
|
||||
import { query } from 'express-validator';
|
||||
import { difference } from 'lodash';
|
||||
import JournalPoster from 'services/Accounting/JournalPoster';
|
||||
import asyncMiddleware from 'api/middleware/asyncMiddleware';
|
||||
import AgingReport from 'api/controllers/FinancialStatements/AgingReport';
|
||||
import moment from 'moment';
|
||||
|
||||
export default class PayableAgingSummary extends AgingReport {
|
||||
/**
|
||||
* Router constructor.
|
||||
*/
|
||||
static router() {
|
||||
const router = express.Router();
|
||||
|
||||
router.get(
|
||||
'/',
|
||||
this.payableAgingSummaryRoles(),
|
||||
this.validateResults,
|
||||
asyncMiddleware(this.validateVendorsIds.bind(this)),
|
||||
asyncMiddleware(this.payableAgingSummary.bind(this))
|
||||
);
|
||||
return router;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the report vendors ids query.
|
||||
*/
|
||||
static async validateVendorsIds(req, res, next) {
|
||||
const { Vendor } = req.models;
|
||||
|
||||
const filter = {
|
||||
vendors_ids: [],
|
||||
...req.query,
|
||||
};
|
||||
if (!Array.isArray(filter.vendors_ids)) {
|
||||
filter.vendors_ids = [filter.vendors_ids];
|
||||
}
|
||||
if (filter.vendors_ids.length > 0) {
|
||||
const storedCustomers = await Vendor.query().whereIn(
|
||||
'id',
|
||||
filter.vendors_ids
|
||||
);
|
||||
const storedCustomersIds = storedCustomers.map((c) => c.id);
|
||||
const notStoredCustomersIds = difference(
|
||||
storedCustomersIds,
|
||||
filter,
|
||||
vendors_ids
|
||||
);
|
||||
if (notStoredCustomersIds.length) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'VENDORS.IDS.NOT.FOUND', code: 300 }],
|
||||
});
|
||||
}
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Receivable aging summary validation roles.
|
||||
*/
|
||||
static payableAgingSummaryRoles() {
|
||||
return [
|
||||
query('as_date').optional().isISO8601(),
|
||||
query('aging_days_before').optional().isNumeric().toInt(),
|
||||
query('aging_periods').optional().isNumeric().toInt(),
|
||||
query('number_format.no_cents').optional().isBoolean().toBoolean(),
|
||||
query('number_format.1000_divide').optional().isBoolean().toBoolean(),
|
||||
query('vendors_ids.*').isNumeric().toInt(),
|
||||
query('none_zero').optional().isBoolean().toBoolean(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve payable aging summary report.
|
||||
*/
|
||||
static async payableAgingSummary(req, res) {
|
||||
const { Customer, Account, AccountTransaction, AccountType } = req.models;
|
||||
const storedVendors = await Customer.query();
|
||||
|
||||
const filter = {
|
||||
as_date: moment().format('YYYY-MM-DD'),
|
||||
aging_days_before: 30,
|
||||
aging_periods: 3,
|
||||
number_format: {
|
||||
no_cents: false,
|
||||
divide_1000: false,
|
||||
},
|
||||
...req.query,
|
||||
};
|
||||
const accountsReceivableType = await AccountType.query()
|
||||
.where('key', 'accounts_payable')
|
||||
.first();
|
||||
|
||||
const accountsReceivable = await Account.query()
|
||||
.where('account_type_id', accountsReceivableType.id)
|
||||
.remember()
|
||||
.first();
|
||||
|
||||
const transactions = await AccountTransaction.query()
|
||||
.modify('filterDateRange', null, filter.as_date)
|
||||
.where('account_id', accountsReceivable.id)
|
||||
.remember();
|
||||
|
||||
const journalPoster = new JournalPoster();
|
||||
journalPoster.loadEntries(transactions);
|
||||
|
||||
const agingPeriods = this.agingRangePeriods(
|
||||
filter.as_date,
|
||||
filter.aging_days_before,
|
||||
filter.aging_periods
|
||||
);
|
||||
// Total amount formmatter based on the given query.
|
||||
const totalFormatter = formatNumberClosure(filter.number_format);
|
||||
|
||||
const vendors = storedVendors.map((vendor) => {
|
||||
// Calculate the trial balance total of the given vendor.
|
||||
const vendorBalance = journalPoster.getContactTrialBalance(
|
||||
accountsReceivable.id,
|
||||
vendor.id,
|
||||
'vendor'
|
||||
);
|
||||
const agingClosingBalance = agingPeriods.map((agingPeriod) => {
|
||||
// Calculate the trial balance between the given date period.
|
||||
const agingTrialBalance = journalPoster.getContactTrialBalance(
|
||||
accountsReceivable.id,
|
||||
vendor.id,
|
||||
'vendor',
|
||||
agingPeriod.from_period
|
||||
);
|
||||
return {
|
||||
...agingPeriod,
|
||||
closingBalance: agingTrialBalance.debit,
|
||||
};
|
||||
});
|
||||
const aging = this.contactAgingBalance(
|
||||
agingClosingBalance,
|
||||
vendorBalance.credit
|
||||
);
|
||||
return {
|
||||
vendor_name: vendor.displayName,
|
||||
aging: aging.map((item) => ({
|
||||
...item,
|
||||
formatted_total: totalFormatter(item.total),
|
||||
})),
|
||||
total: vendorBalance.balance,
|
||||
formatted_total: totalFormatted(vendorBalance.balance),
|
||||
};
|
||||
});
|
||||
|
||||
const agingClosingBalance = agingPeriods.map((agingPeriod) => {
|
||||
const closingTrialBalance = journalPoster.getContactTrialBalance(
|
||||
accountsReceivable.id,
|
||||
null,
|
||||
'vendor',
|
||||
agingPeriod.from_period
|
||||
);
|
||||
return {
|
||||
...agingPeriod,
|
||||
closingBalance: closingTrialBalance.balance,
|
||||
};
|
||||
});
|
||||
|
||||
const totalClosingBalance = journalPoster.getContactTrialBalance(
|
||||
accountsReceivable.id,
|
||||
null,
|
||||
'vendor'
|
||||
);
|
||||
const agingTotal = this.contactAgingBalance(
|
||||
agingClosingBalance,
|
||||
totalClosingBalance.credit
|
||||
);
|
||||
|
||||
return res.status(200).send({
|
||||
columns: [ ...agingPeriods ],
|
||||
aging: {
|
||||
vendors,
|
||||
total: [
|
||||
...agingTotal.map((item) => ({
|
||||
...item,
|
||||
formatted_total: totalFormatter(item.total),
|
||||
})),
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import express from 'express';
|
||||
import { query, oneOf, validationResult } from 'express-validator';
|
||||
import moment from 'moment';
|
||||
import { pick, sumBy } from 'lodash';
|
||||
import JournalPoster from 'services/Accounting/JournalPoster';
|
||||
import { dateRangeCollection } from 'utils';
|
||||
import asyncMiddleware from 'api/middleware/asyncMiddleware';
|
||||
import { formatNumberClosure } from './FinancialStatementMixin';
|
||||
|
||||
export default {
|
||||
/**
|
||||
* Router constructor.
|
||||
*/
|
||||
router() {
|
||||
const router = express.Router();
|
||||
|
||||
router.get(
|
||||
'/',
|
||||
this.profitLossSheet.validation,
|
||||
asyncMiddleware(this.profitLossSheet.handler)
|
||||
);
|
||||
return router;
|
||||
},
|
||||
|
||||
/**
|
||||
* Retrieve profit/loss financial statement.
|
||||
*/
|
||||
profitLossSheet: {
|
||||
validation: [
|
||||
query('basis').optional(),
|
||||
query('from_date').optional().isISO8601(),
|
||||
query('to_date').optional().isISO8601(),
|
||||
query('number_format.no_cents').optional().isBoolean(),
|
||||
query('number_format.divide_1000').optional().isBoolean(),
|
||||
query('basis').optional(),
|
||||
query('none_zero').optional().isBoolean().toBoolean(),
|
||||
query('account_ids').isArray().optional(),
|
||||
query('account_ids.*').isNumeric().toInt(),
|
||||
query('display_columns_type').optional().isIn(['total', 'date_periods']),
|
||||
query('display_columns_by')
|
||||
.optional({ nullable: true, checkFalsy: true })
|
||||
.isIn(['year', 'month', 'week', 'day', 'quarter']),
|
||||
],
|
||||
async handler(req, res) {
|
||||
const validationErrors = validationResult(req);
|
||||
|
||||
if (!validationErrors.isEmpty()) {
|
||||
return res.boom.badData(null, {
|
||||
code: 'validation_error',
|
||||
...validationErrors,
|
||||
});
|
||||
}
|
||||
const { Account, AccountType } = req.models;
|
||||
const filter = {
|
||||
from_date: moment().startOf('year').format('YYYY-MM-DD'),
|
||||
to_date: moment().endOf('year').format('YYYY-MM-DD'),
|
||||
number_format: {
|
||||
no_cents: false,
|
||||
divide_1000: false,
|
||||
},
|
||||
basis: 'accural',
|
||||
none_zero: false,
|
||||
display_columns_type: 'total',
|
||||
display_columns_by: 'month',
|
||||
account_ids: [],
|
||||
...req.query,
|
||||
};
|
||||
if (!Array.isArray(filter.account_ids)) {
|
||||
filter.account_ids = [filter.account_ids];
|
||||
}
|
||||
const incomeStatementTypes = await AccountType.query().where(
|
||||
'income_sheet',
|
||||
true
|
||||
);
|
||||
// Fetch all income accounts from storage.
|
||||
const accounts = await Account.query()
|
||||
// .remember('profit_loss_accounts')
|
||||
.modify('filterAccounts', filter.account_ids)
|
||||
.whereIn(
|
||||
'account_type_id',
|
||||
incomeStatementTypes.map((t) => t.id)
|
||||
)
|
||||
.withGraphFetched('type')
|
||||
.withGraphFetched('transactions');
|
||||
|
||||
// Accounts dependency graph.
|
||||
const accountsGraph = Account.toDependencyGraph(accounts);
|
||||
|
||||
// Filter all none zero accounts if it was enabled.
|
||||
const filteredAccounts = accounts.filter(
|
||||
(account) => account.transactions.length > 0 || !filter.none_zero
|
||||
);
|
||||
const journalEntriesCollected = Account.collectJournalEntries(accounts);
|
||||
const journalEntries = new JournalPoster(accountsGraph);
|
||||
journalEntries.loadEntries(journalEntriesCollected);
|
||||
|
||||
// Account balance formmatter based on the given query.
|
||||
const numberFormatter = formatNumberClosure(filter.number_format);
|
||||
const comparatorDateType =
|
||||
filter.display_columns_type === 'total'
|
||||
? 'day'
|
||||
: filter.display_columns_by;
|
||||
// Gets the date range set from start to end date.
|
||||
const dateRangeSet = dateRangeCollection(
|
||||
filter.from_date,
|
||||
filter.to_date,
|
||||
comparatorDateType
|
||||
);
|
||||
const accountsMapper = (incomeExpenseAccounts) =>
|
||||
incomeExpenseAccounts.map((account) => ({
|
||||
...pick(account, ['id', 'index', 'name', 'code', 'parentAccountId']),
|
||||
// Total closing balance of the account.
|
||||
...(filter.display_columns_type === 'total' && {
|
||||
total: (() => {
|
||||
const amount = journalEntries.getAccountBalance(
|
||||
account.id,
|
||||
filter.to_date
|
||||
);
|
||||
return {
|
||||
amount,
|
||||
date: filter.to_date,
|
||||
formatted_amount: numberFormatter(amount),
|
||||
};
|
||||
})(),
|
||||
}),
|
||||
// Date periods when display columns type `periods`.
|
||||
...(filter.display_columns_type === 'date_periods' && {
|
||||
periods: dateRangeSet.map((date) => {
|
||||
const type = comparatorDateType;
|
||||
const amount = journalEntries.getAccountBalance(
|
||||
account.id,
|
||||
date,
|
||||
type
|
||||
);
|
||||
return {
|
||||
date,
|
||||
amount,
|
||||
formatted_amount: numberFormatter(amount),
|
||||
};
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
const accountsIncome = Account.toNestedArray(
|
||||
accountsMapper(
|
||||
filteredAccounts.filter((account) => account.type.normal === 'credit')
|
||||
)
|
||||
);
|
||||
const accountsExpenses = Account.toNestedArray(
|
||||
accountsMapper(
|
||||
filteredAccounts.filter((account) => account.type.normal === 'debit')
|
||||
)
|
||||
);
|
||||
const totalPeriodsMapper = (incomeExpenseAccounts) =>
|
||||
Object.values(
|
||||
dateRangeSet.reduce((acc, date, index) => {
|
||||
let amount = sumBy(
|
||||
incomeExpenseAccounts,
|
||||
`periods[${index}].amount`
|
||||
);
|
||||
acc[date] = {
|
||||
date,
|
||||
amount,
|
||||
formatted_amount: numberFormatter(amount),
|
||||
};
|
||||
return acc;
|
||||
}, {})
|
||||
);
|
||||
|
||||
// Total income - Total expenses = Net income
|
||||
const netIncomePeriodsMapper = (
|
||||
totalIncomeAcocunts,
|
||||
totalExpenseAccounts
|
||||
) =>
|
||||
dateRangeSet.map((date, index) => {
|
||||
const totalIncome = totalIncomeAcocunts[index];
|
||||
const totalExpenses = totalExpenseAccounts[index];
|
||||
|
||||
let amount = totalIncome.amount || 0;
|
||||
amount -= totalExpenses.amount || 0;
|
||||
return { date, amount, formatted_amount: numberFormatter(amount) };
|
||||
});
|
||||
|
||||
// @return {Object}
|
||||
const netIncomeTotal = (totalIncome, totalExpenses) => {
|
||||
const netIncomeAmount = totalIncome.amount - totalExpenses.amount;
|
||||
return {
|
||||
amount: netIncomeAmount,
|
||||
formatted_amount: netIncomeAmount,
|
||||
date: filter.to_date,
|
||||
};
|
||||
};
|
||||
|
||||
const incomeResponse = {
|
||||
entry_normal: 'credit',
|
||||
accounts: accountsIncome,
|
||||
...(filter.display_columns_type === 'total' &&
|
||||
(() => {
|
||||
const totalIncomeAccounts = sumBy(accountsIncome, 'total.amount');
|
||||
return {
|
||||
total: {
|
||||
amount: totalIncomeAccounts,
|
||||
date: filter.to_date,
|
||||
formatted_amount: numberFormatter(totalIncomeAccounts),
|
||||
},
|
||||
};
|
||||
})()),
|
||||
...(filter.display_columns_type === 'date_periods' && {
|
||||
total_periods: [...totalPeriodsMapper(accountsIncome)],
|
||||
}),
|
||||
};
|
||||
const expenseResponse = {
|
||||
entry_normal: 'debit',
|
||||
accounts: accountsExpenses,
|
||||
...(filter.display_columns_type === 'total' &&
|
||||
(() => {
|
||||
const totalExpensesAccounts = sumBy(
|
||||
accountsExpenses,
|
||||
'total.amount'
|
||||
);
|
||||
return {
|
||||
total: {
|
||||
amount: totalExpensesAccounts,
|
||||
date: filter.to_date,
|
||||
formatted_amount: numberFormatter(totalExpensesAccounts),
|
||||
},
|
||||
};
|
||||
})()),
|
||||
...(filter.display_columns_type === 'date_periods' && {
|
||||
total_periods: [...totalPeriodsMapper(accountsExpenses)],
|
||||
}),
|
||||
};
|
||||
const netIncomeResponse = {
|
||||
...(filter.display_columns_type === 'total' && {
|
||||
total: {
|
||||
...netIncomeTotal(incomeResponse.total, expenseResponse.total),
|
||||
},
|
||||
}),
|
||||
...(filter.display_columns_type === 'date_periods' && {
|
||||
total_periods: [
|
||||
...netIncomePeriodsMapper(
|
||||
incomeResponse.total_periods,
|
||||
expenseResponse.total_periods
|
||||
),
|
||||
],
|
||||
}),
|
||||
};
|
||||
return res.status(200).send({
|
||||
query: { ...filter },
|
||||
columns: [...dateRangeSet],
|
||||
profitLoss: {
|
||||
income: incomeResponse,
|
||||
expenses: expenseResponse,
|
||||
net_income: netIncomeResponse,
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,218 @@
|
||||
import express from 'express';
|
||||
import { query, oneOf } from 'express-validator';
|
||||
import { difference } from 'lodash';
|
||||
import JournalPoster from 'services/Accounting/JournalPoster';
|
||||
import asyncMiddleware from 'api/middleware/asyncMiddleware';
|
||||
import AgingReport from 'api/controllers/FinancialStatements/AgingReport';
|
||||
import moment from 'moment';
|
||||
|
||||
export default class ReceivableAgingSummary extends AgingReport {
|
||||
/**
|
||||
* Router constructor.
|
||||
*/
|
||||
static router() {
|
||||
const router = express.Router();
|
||||
|
||||
router.get(
|
||||
'/',
|
||||
this.receivableAgingSummaryRoles,
|
||||
this.validateResults,
|
||||
asyncMiddleware(this.validateCustomersIds.bind(this)),
|
||||
asyncMiddleware(this.receivableAgingSummary.bind(this))
|
||||
);
|
||||
return router;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the report customers ids query.
|
||||
*/
|
||||
static async validateCustomersIds(req, res, next) {
|
||||
const { Customer } = req.models;
|
||||
|
||||
const filter = {
|
||||
customer_ids: [],
|
||||
...req.query,
|
||||
};
|
||||
if (!Array.isArray(filter.customer_ids)) {
|
||||
filter.customer_ids = [filter.customer_ids];
|
||||
}
|
||||
if (filter.customer_ids.length > 0) {
|
||||
const storedCustomers = await Customer.query().whereIn(
|
||||
'id',
|
||||
filter.customer_ids
|
||||
);
|
||||
const storedCustomersIds = storedCustomers.map((c) => parseInt(c.id, 10));
|
||||
const notStoredCustomersIds = difference(
|
||||
filter.customer_ids.map(a => parseInt(a, 10)),
|
||||
storedCustomersIds
|
||||
);
|
||||
|
||||
if (notStoredCustomersIds.length) {
|
||||
return res.status(400).send({
|
||||
errors: [
|
||||
{
|
||||
type: 'CUSTOMERS.IDS.NOT.FOUND',
|
||||
code: 300,
|
||||
ids: notStoredCustomersIds,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Receivable aging summary validation roles.
|
||||
*/
|
||||
static get receivableAgingSummaryRoles() {
|
||||
return [
|
||||
query('as_date').optional().isISO8601(),
|
||||
query('aging_days_before').optional().isNumeric().toInt(),
|
||||
query('aging_periods').optional().isNumeric().toInt(),
|
||||
query('number_format.no_cents').optional().isBoolean().toBoolean(),
|
||||
query('number_format.1000_divide').optional().isBoolean().toBoolean(),
|
||||
oneOf(
|
||||
[
|
||||
query('customer_ids').optional().isArray({ min: 1 }),
|
||||
query('customer_ids.*').isNumeric().toInt(),
|
||||
],
|
||||
[query('customer_ids').optional().isNumeric().toInt()]
|
||||
),
|
||||
query('none_zero').optional().isBoolean().toBoolean(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve receivable aging summary report.
|
||||
*/
|
||||
static async receivableAgingSummary(req, res) {
|
||||
const { Customer, Account, AccountTransaction, AccountType } = req.models;
|
||||
|
||||
const filter = {
|
||||
as_date: moment().format('YYYY-MM-DD'),
|
||||
aging_days_before: 30,
|
||||
aging_periods: 3,
|
||||
number_format: {
|
||||
no_cents: false,
|
||||
divide_1000: false,
|
||||
},
|
||||
customer_ids: [],
|
||||
none_zero: false,
|
||||
...req.query,
|
||||
};
|
||||
if (!Array.isArray(filter.customer_ids)) {
|
||||
filter.customer_ids = [filter.customer_ids];
|
||||
}
|
||||
|
||||
const storedCustomers = await Customer.query().onBuild((builder) => {
|
||||
if (filter.customer_ids.length > 0) {
|
||||
builder.modify('filterCustomerIds', filter.customer_ids);
|
||||
}
|
||||
return builder;
|
||||
});
|
||||
|
||||
const accountsReceivableType = await AccountType.query()
|
||||
.where('key', 'accounts_receivable')
|
||||
.first();
|
||||
|
||||
const accountsReceivable = await Account.query()
|
||||
.where('account_type_id', accountsReceivableType.id)
|
||||
.remember()
|
||||
.first();
|
||||
|
||||
const transactions = await AccountTransaction.query().onBuild((query) => {
|
||||
query.modify('filterDateRange', null, filter.as_date)
|
||||
query.where('account_id', accountsReceivable.id)
|
||||
query.modify('filterContactType', 'customer');
|
||||
|
||||
if (filter.customer_ids.length> 0) {
|
||||
query.modify('filterContactIds', filter.customer_ids)
|
||||
}
|
||||
query.remember();
|
||||
return query;
|
||||
});
|
||||
|
||||
const journalPoster = new JournalPoster();
|
||||
journalPoster.loadEntries(transactions);
|
||||
|
||||
const agingPeriods = this.agingRangePeriods(
|
||||
filter.as_date,
|
||||
filter.aging_days_before,
|
||||
filter.aging_periods
|
||||
);
|
||||
// Total amount formmatter based on the given query.
|
||||
const totalFormatter = this.formatNumberClosure(filter.number_format);
|
||||
|
||||
const customers = storedCustomers.map((customer) => {
|
||||
// Calculate the trial balance total of the given customer.
|
||||
const customerBalance = journalPoster.getContactTrialBalance(
|
||||
accountsReceivable.id,
|
||||
customer.id,
|
||||
'customer'
|
||||
);
|
||||
const agingClosingBalance = agingPeriods.map((agingPeriod) => {
|
||||
// Calculate the trial balance between the given date period.
|
||||
const agingTrialBalance = journalPoster.getContactTrialBalance(
|
||||
accountsReceivable.id,
|
||||
customer.id,
|
||||
'customer',
|
||||
agingPeriod.from_period
|
||||
);
|
||||
return {
|
||||
...agingPeriod,
|
||||
closingBalance: agingTrialBalance.debit,
|
||||
};
|
||||
});
|
||||
const aging = this.contactAgingBalance(
|
||||
agingClosingBalance,
|
||||
customerBalance.credit
|
||||
);
|
||||
return {
|
||||
customer_name: customer.displayName,
|
||||
aging: aging.map((item) => ({
|
||||
...item,
|
||||
formatted_total: totalFormatter(item.total),
|
||||
})),
|
||||
total: customerBalance.balance,
|
||||
formatted_total: totalFormatter(customerBalance.balance),
|
||||
};
|
||||
});
|
||||
|
||||
const agingClosingBalance = agingPeriods.map((agingPeriod) => {
|
||||
const closingTrialBalance = journalPoster.getContactTrialBalance(
|
||||
accountsReceivable.id,
|
||||
null,
|
||||
'customer',
|
||||
agingPeriod.from_period
|
||||
);
|
||||
return {
|
||||
...agingPeriod,
|
||||
closingBalance: closingTrialBalance.balance,
|
||||
};
|
||||
});
|
||||
|
||||
const totalClosingBalance = journalPoster.getContactTrialBalance(
|
||||
accountsReceivable.id,
|
||||
null,
|
||||
'customer'
|
||||
);
|
||||
const agingTotal = this.contactAgingBalance(
|
||||
agingClosingBalance,
|
||||
totalClosingBalance.credit
|
||||
);
|
||||
|
||||
return res.status(200).send({
|
||||
columns: [...agingPeriods],
|
||||
aging: {
|
||||
customers,
|
||||
total: [
|
||||
...agingTotal.map((item) => ({
|
||||
...item,
|
||||
formatted_total: totalFormatter(item.total),
|
||||
})),
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import express from 'express';
|
||||
import { query, validationResult } from 'express-validator';
|
||||
import moment from 'moment';
|
||||
import JournalPoster from 'services/Accounting/JournalPoster';
|
||||
import asyncMiddleware from 'api/middleware/asyncMiddleware';
|
||||
import DependencyGraph from 'lib/DependencyGraph';
|
||||
import { formatNumberClosure }from './FinancialStatementMixin';
|
||||
|
||||
export default {
|
||||
/**
|
||||
* Router constructor.
|
||||
*/
|
||||
router() {
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/',
|
||||
this.trialBalanceSheet.validation,
|
||||
asyncMiddleware(this.trialBalanceSheet.handler));
|
||||
|
||||
return router;
|
||||
},
|
||||
|
||||
/**
|
||||
* Retrieve the trial balance sheet.
|
||||
*/
|
||||
trialBalanceSheet: {
|
||||
validation: [
|
||||
query('basis').optional(),
|
||||
query('from_date').optional().isISO8601(),
|
||||
query('to_date').optional().isISO8601(),
|
||||
query('number_format.no_cents').optional().isBoolean().toBoolean(),
|
||||
query('number_format.1000_divide').optional().isBoolean().toBoolean(),
|
||||
query('account_ids').isArray().optional(),
|
||||
query('account_ids.*').isNumeric().toInt(),
|
||||
query('basis').optional(),
|
||||
query('none_zero').optional().isBoolean().toBoolean(),
|
||||
],
|
||||
async handler(req, res) {
|
||||
const validationErrors = validationResult(req);
|
||||
|
||||
if (!validationErrors.isEmpty()) {
|
||||
return res.boom.badData(null, {
|
||||
code: 'validation_error', ...validationErrors,
|
||||
});
|
||||
}
|
||||
const { Account } = req.models;
|
||||
const filter = {
|
||||
from_date: moment().startOf('year').format('YYYY-MM-DD'),
|
||||
to_date: moment().endOf('year').format('YYYY-MM-DD'),
|
||||
number_format: {
|
||||
no_cents: false,
|
||||
divide_1000: false,
|
||||
},
|
||||
basis: 'accural',
|
||||
none_zero: false,
|
||||
account_ids: [],
|
||||
...req.query,
|
||||
};
|
||||
if (!Array.isArray(filter.account_ids)) {
|
||||
filter.account_ids = [filter.account_ids];
|
||||
}
|
||||
|
||||
const accounts = await Account.query()
|
||||
// .remember('trial_balance_accounts')
|
||||
.modify('filterAccounts', filter.account_ids)
|
||||
.withGraphFetched('type')
|
||||
.withGraphFetched('transactions')
|
||||
.modifyGraph('transactions', (builder) => {
|
||||
builder.modify('sumationCreditDebit');
|
||||
builder.modify('filterDateRange', filter.from_date, filter.to_date);
|
||||
});
|
||||
|
||||
// Accounts dependency graph.
|
||||
const accountsGraph = DependencyGraph.fromArray(
|
||||
accounts, { itemId: 'id', parentItemId: 'parentAccountId' }
|
||||
);
|
||||
|
||||
const journalEntriesCollect = Account.collectJournalEntries(accounts);
|
||||
const journalEntries = new JournalPoster(accountsGraph);
|
||||
journalEntries.loadEntries(journalEntriesCollect);
|
||||
|
||||
// Account balance formmatter based on the given query.
|
||||
const balanceFormatter = formatNumberClosure(filter.number_format);
|
||||
|
||||
const accountsResponse = accounts
|
||||
.filter((account) => (
|
||||
account.transactions.length > 0 || !filter.none_zero
|
||||
))
|
||||
.map((account) => {
|
||||
const trial = journalEntries.getTrialBalanceWithDepands(account.id);
|
||||
|
||||
return {
|
||||
id: account.id,
|
||||
parentAccountId: account.parentAccountId,
|
||||
name: account.name,
|
||||
code: account.code,
|
||||
accountNormal: account.type.normal,
|
||||
|
||||
credit: trial.credit,
|
||||
debit: trial.debit,
|
||||
balance: trial.balance,
|
||||
|
||||
formatted_credit: balanceFormatter(trial.credit),
|
||||
formatted_debit: balanceFormatter(trial.debit),
|
||||
formatted_balance: balanceFormatter(trial.balance),
|
||||
};
|
||||
});
|
||||
|
||||
return res.status(200).send({
|
||||
query: { ...filter },
|
||||
accounts: [...Account.toNestedArray(accountsResponse) ],
|
||||
});
|
||||
},
|
||||
},
|
||||
}
|
||||
167
server/src/api/controllers/InviteUsers.ts
Normal file
167
server/src/api/controllers/InviteUsers.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { Service, Inject } from 'typedi';
|
||||
import { Router, Request, Response } from 'express';
|
||||
import {
|
||||
check,
|
||||
body,
|
||||
param,
|
||||
} from 'express-validator';
|
||||
import asyncMiddleware from 'api/middleware/asyncMiddleware';
|
||||
import InviteUserService from 'services/InviteUsers';
|
||||
import { ServiceErrors, ServiceError } from 'exceptions';
|
||||
import BaseController from './BaseController';
|
||||
|
||||
@Service()
|
||||
export default class InviteUsersController extends BaseController {
|
||||
@Inject()
|
||||
inviteUsersService: InviteUserService;
|
||||
|
||||
/**
|
||||
* Routes that require authentication.
|
||||
*/
|
||||
authRouter() {
|
||||
const router = Router();
|
||||
|
||||
router.post('/send', [
|
||||
body('email').exists().trim().escape(),
|
||||
],
|
||||
this.validationResult,
|
||||
asyncMiddleware(this.sendInvite.bind(this)),
|
||||
);
|
||||
return router;
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes that non-required authentication.
|
||||
*/
|
||||
nonAuthRouter() {
|
||||
const router = Router();
|
||||
|
||||
router.post('/accept/:token', [
|
||||
...this.inviteUserDTO,
|
||||
],
|
||||
this.validationResult,
|
||||
asyncMiddleware(this.accept.bind(this))
|
||||
);
|
||||
router.get('/invited/:token', [
|
||||
param('token').exists().trim().escape(),
|
||||
],
|
||||
this.validationResult,
|
||||
asyncMiddleware(this.invited.bind(this))
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invite DTO schema validation.
|
||||
*/
|
||||
get inviteUserDTO() {
|
||||
return [
|
||||
check('first_name').exists().trim().escape(),
|
||||
check('last_name').exists().trim().escape(),
|
||||
check('phone_number').exists().trim().escape(),
|
||||
check('password').exists().trim().escape(),
|
||||
param('token').exists().trim().escape(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Invite a user to the authorized user organization.
|
||||
* @param {Request} req -
|
||||
* @param {Response} res -
|
||||
* @param {NextFunction} next -
|
||||
*/
|
||||
async sendInvite(req: Request, res: Response, next: Function) {
|
||||
const { email } = req.body;
|
||||
const { tenantId } = req;
|
||||
const { user } = req;
|
||||
|
||||
try {
|
||||
await this.inviteUsersService.sendInvite(tenantId, email, user);
|
||||
return res.status(200).send({
|
||||
type: 'success',
|
||||
code: 'INVITE.SENT.SUCCESSFULLY',
|
||||
message: 'The invite has been sent to the given email.',
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceError) {
|
||||
if (error.errorType === 'email_already_invited') {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'EMAIL.ALREADY.INVITED' }],
|
||||
});
|
||||
}
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
return res.status(200).send();
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept the inviation.
|
||||
* @param {Request} req -
|
||||
* @param {Response} res -
|
||||
* @param {NextFunction} next -
|
||||
*/
|
||||
async accept(req: Request, res: Response, next: Function) {
|
||||
const inviteUserInput: IInviteUserInput = this.matchedBodyData(req, {
|
||||
locations: ['body'],
|
||||
includeOptionals: true,
|
||||
});
|
||||
const { token } = req.params;
|
||||
|
||||
try {
|
||||
await this.inviteUsersService.acceptInvite(token, inviteUserInput);
|
||||
return res.status(200).send({
|
||||
type: 'success',
|
||||
code: 'USER.INVITE.ACCEPTED',
|
||||
message: 'User invite has been accepted successfully.',
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
if (error instanceof ServiceError) {
|
||||
|
||||
if (error.errorType === 'phone_number_exists') {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'PHONE_NUMBER.EXISTS' }],
|
||||
});
|
||||
}
|
||||
if (error.errorType === 'invite_token_invalid') {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'INVITE.TOKEN.INVALID' }],
|
||||
});
|
||||
}
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the invite token is valid.
|
||||
* @param {Request} req -
|
||||
* @param {Response} res -
|
||||
* @param {NextFunction} next -
|
||||
*/
|
||||
async invited(req: Request, res: Response, next: Function) {
|
||||
const { token } = req.params;
|
||||
|
||||
try {
|
||||
const { inviteToken, orgName } = await this.inviteUsersService.checkInvite(token);
|
||||
|
||||
return res.status(200).send({
|
||||
inviteToken: inviteToken.token,
|
||||
email: inviteToken.email,
|
||||
organizationName: orgName?.value,
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
if (error instanceof ServiceError) {
|
||||
if (error.errorType === 'invite_token_invalid') {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'INVITE.TOKEN.INVALID' }],
|
||||
});
|
||||
}
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
431
server/src/api/controllers/ItemCategories.ts
Normal file
431
server/src/api/controllers/ItemCategories.ts
Normal file
@@ -0,0 +1,431 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import {
|
||||
check,
|
||||
param,
|
||||
query,
|
||||
} from 'express-validator';
|
||||
import { difference } from 'lodash';
|
||||
import { Service } from 'typedi';
|
||||
import asyncMiddleware from 'api/middleware/asyncMiddleware';
|
||||
import validateMiddleware from 'api/middleware/validateMiddleware';
|
||||
import {
|
||||
DynamicFilter,
|
||||
DynamicFilterSortBy,
|
||||
DynamicFilterFilterRoles,
|
||||
} from 'lib/DynamicFilter';
|
||||
import {
|
||||
mapFilterRolesToDynamicFilter,
|
||||
} from 'lib/ViewRolesBuilder';
|
||||
import { IItemCategory, IItemCategoryOTD } from 'interfaces';
|
||||
import BaseController from 'api/controllers/BaseController';
|
||||
|
||||
@Service()
|
||||
export default class ItemsCategoriesController extends BaseController {
|
||||
/**
|
||||
* Router constructor method.
|
||||
*/
|
||||
router() {
|
||||
const router = Router();
|
||||
|
||||
router.post('/:id', [
|
||||
...this.categoryValidationSchema,
|
||||
...this.specificCategoryValidationSchema,
|
||||
],
|
||||
validateMiddleware,
|
||||
asyncMiddleware(this.validateParentCategoryExistance),
|
||||
asyncMiddleware(this.validateSellAccountExistance),
|
||||
asyncMiddleware(this.validateCostAccountExistance),
|
||||
asyncMiddleware(this.validateInventoryAccountExistance),
|
||||
asyncMiddleware(this.editCategory)
|
||||
);
|
||||
router.post('/',
|
||||
this.categoryValidationSchema,
|
||||
validateMiddleware,
|
||||
asyncMiddleware(this.validateParentCategoryExistance),
|
||||
asyncMiddleware(this.validateSellAccountExistance),
|
||||
asyncMiddleware(this.validateCostAccountExistance),
|
||||
asyncMiddleware(this.validateInventoryAccountExistance),
|
||||
asyncMiddleware(this.newCategory),
|
||||
);
|
||||
router.delete('/bulk',
|
||||
this.categoriesBulkValidationSchema,
|
||||
validateMiddleware,
|
||||
asyncMiddleware(this.validateCategoriesIdsExistance),
|
||||
asyncMiddleware(this.bulkDeleteCategories),
|
||||
);
|
||||
router.delete('/:id',
|
||||
this.specificCategoryValidationSchema,
|
||||
validateMiddleware,
|
||||
asyncMiddleware(this.validateItemCategoryExistance),
|
||||
asyncMiddleware(this.deleteItem),
|
||||
);
|
||||
router.get('/:id',
|
||||
this.specificCategoryValidationSchema,
|
||||
validateMiddleware,
|
||||
asyncMiddleware(this.validateItemCategoryExistance),
|
||||
asyncMiddleware(this.getCategory)
|
||||
);
|
||||
router.get('/',
|
||||
this.categoriesListValidationSchema,
|
||||
validateMiddleware,
|
||||
asyncMiddleware(this.getList)
|
||||
);
|
||||
return router;
|
||||
}
|
||||
|
||||
/**
|
||||
* Item category validation schema.
|
||||
*/
|
||||
get categoryValidationSchema() {
|
||||
return [
|
||||
check('name').exists().trim().escape(),
|
||||
check('parent_category_id')
|
||||
.optional({ nullable: true, checkFalsy: true })
|
||||
.isNumeric()
|
||||
.toInt(),
|
||||
check('description')
|
||||
.optional()
|
||||
.trim()
|
||||
.escape(),
|
||||
check('sell_account_id')
|
||||
.optional({ nullable: true, checkFalsy: true })
|
||||
.isNumeric()
|
||||
.toInt(),
|
||||
check('cost_account_id')
|
||||
.optional()
|
||||
.isNumeric()
|
||||
.toInt(),
|
||||
check('inventory_account_id')
|
||||
.optional()
|
||||
.isNumeric()
|
||||
.toInt(),
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate items categories bulk actions.
|
||||
*/
|
||||
get categoriesBulkValidationSchema() {
|
||||
return [
|
||||
query('ids').isArray({ min: 2 }),
|
||||
query('ids.*').isNumeric().toInt(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate items categories schema.
|
||||
*/
|
||||
get categoriesListValidationSchema() {
|
||||
return [
|
||||
query('column_sort_order').optional().trim().escape(),
|
||||
query('sort_order').optional().trim().escape().isIn(['desc', 'asc']),
|
||||
query('stringified_filter_roles').optional().isJSON(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate specific item category schema.
|
||||
*/
|
||||
get specificCategoryValidationSchema() {
|
||||
return [
|
||||
param('id').exists().toInt(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the item category existance.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async validateItemCategoryExistance(req: Request, res: Response, next: Function) {
|
||||
const categoryId: number = req.params.id;
|
||||
const { ItemCategory } = req.models;
|
||||
const category = await ItemCategory.query().findById(categoryId);
|
||||
|
||||
if (!category) {
|
||||
return res.boom.notFound(null, {
|
||||
errors: [{ type: 'ITEM_CATEGORY_NOT_FOUND', code: 100 }],
|
||||
});
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate wether the given cost account exists on the storage.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {Function} next
|
||||
*/
|
||||
async validateCostAccountExistance(req: Request, res: Response, next: Function) {
|
||||
const { Account, AccountType } = req.models;
|
||||
const category: IItemCategoryOTD = this.matchedBodyData(req);
|
||||
|
||||
if (category.costAccountId) {
|
||||
const COGSType = await AccountType.query().findOne('key', 'cost_of_goods_sold');
|
||||
const foundAccount = await Account.query().findById(category.costAccountId)
|
||||
|
||||
if (!foundAccount) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'COST.ACCOUNT.NOT.FOUND', code: 120 }],
|
||||
});
|
||||
} else if (foundAccount.accountTypeId !== COGSType.id) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'COST.ACCOUNT.NOT.COGS.TYPE', code: 220 }],
|
||||
});
|
||||
}
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate wether the given sell account exists on the storage.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {NextFunction} next
|
||||
*/
|
||||
async validateSellAccountExistance(req: Request, res: Response, next: Function) {
|
||||
const { Account, AccountType } = req.models;
|
||||
const category: IItemCategoryOTD = this.matchedBodyData(req);
|
||||
|
||||
if (category.sellAccountId) {
|
||||
const incomeType = await AccountType.query().findOne('key', 'income');
|
||||
const foundAccount = await Account.query().findById(category.sellAccountId);
|
||||
|
||||
if (!foundAccount) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'SELL.ACCOUNT.NOT.FOUND', code: 130 }],
|
||||
});
|
||||
} else if (foundAccount.accountTypeId !== incomeType.id) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'SELL.ACCOUNT.NOT.INCOME.TYPE', code: 230 }],
|
||||
})
|
||||
}
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates wether the given inventory account exists on the storage.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {NextFunction} next
|
||||
*/
|
||||
async validateInventoryAccountExistance(req: Request, res: Response, next: Function) {
|
||||
const { Account, AccountType } = req.models;
|
||||
const category: IItemCategoryOTD = this.matchedBodyData(req);
|
||||
|
||||
if (category.inventoryAccountId) {
|
||||
const otherAsset = await AccountType.query().findOne('key', 'other_asset');
|
||||
const foundAccount = await Account.query().findById(category.inventoryAccountId);
|
||||
|
||||
if (!foundAccount) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'INVENTORY.ACCOUNT.NOT.FOUND', code: 200}],
|
||||
});
|
||||
} else if (otherAsset.id !== foundAccount.accountTypeId) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'INVENTORY.ACCOUNT.NOT.CURRENT.ASSET', code: 300 }],
|
||||
});
|
||||
}
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the item category parent category whether exists on the storage.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {Function} next
|
||||
*/
|
||||
async validateParentCategoryExistance(req: Request, res: Response, next: Function) {
|
||||
const category: IItemCategory = this.matchedBodyData(req);
|
||||
const { ItemCategory } = req.models;
|
||||
|
||||
if (category.parentCategoryId) {
|
||||
const foundParentCategory = await ItemCategory.query()
|
||||
.where('id', category.parentCategoryId)
|
||||
.first();
|
||||
|
||||
if (!foundParentCategory) {
|
||||
return res.boom.notFound('The parent category ID is not found.', {
|
||||
errors: [{ type: 'PARENT_CATEGORY_NOT_FOUND', code: 100 }],
|
||||
});
|
||||
}
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate item categories ids existance.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {Function} next
|
||||
*/
|
||||
async validateCategoriesIdsExistance(req: Request, res: Response, next: Function) {
|
||||
const ids: number[] = (req.query?.ids || []);
|
||||
const { ItemCategory } = req.models;
|
||||
|
||||
const itemCategories = await ItemCategory.query().whereIn('id', ids);
|
||||
const itemCategoriesIds = itemCategories.map((category: IItemCategory) => category.id);
|
||||
const notFoundCategories = difference(ids, itemCategoriesIds);
|
||||
|
||||
if (notFoundCategories.length > 0) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'ITEM.CATEGORIES.IDS.NOT.FOUND', code: 200 }],
|
||||
});
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new item category.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async newCategory(req: Request, res: Response) {
|
||||
const { user } = req;
|
||||
const category: IItemCategory = this.matchedBodyData(req);
|
||||
const { ItemCategory } = req.models;
|
||||
|
||||
const storedCategory = await ItemCategory.query().insert({
|
||||
...category,
|
||||
user_id: user.id,
|
||||
});
|
||||
return res.status(200).send({ category: storedCategory });
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit details of the given category item.
|
||||
* @param {Request} req -
|
||||
* @param {Response} res -
|
||||
* @return {Response}
|
||||
*/
|
||||
async editCategory(req: Request, res: Response) {
|
||||
const { id } = req.params;
|
||||
const category: IItemCategory = this.matchedBodyData(req);
|
||||
const { ItemCategory } = req.models;
|
||||
|
||||
const updateItemCategory = await ItemCategory.query()
|
||||
.where('id', id)
|
||||
.update({ ...category });
|
||||
|
||||
return res.status(200).send({ id });
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the give item category.
|
||||
* @param {Request} req -
|
||||
* @param {Response} res -
|
||||
* @return {Response}
|
||||
*/
|
||||
async deleteItem(req: Request, res: Response) {
|
||||
const { id } = req.params;
|
||||
const { ItemCategory } = req.models;
|
||||
|
||||
await ItemCategory.query()
|
||||
.where('id', id)
|
||||
.delete();
|
||||
|
||||
return res.status(200).send({ id });
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the list of items.
|
||||
* @param {Request} req -
|
||||
* @param {Response} res -
|
||||
* @return {Response}
|
||||
*/
|
||||
async getList(req: Request, res: Response) {
|
||||
const { Resource, ItemCategory } = req.models;
|
||||
const categoriesResource = await Resource.query()
|
||||
.where('name', 'items_categories')
|
||||
.withGraphFetched('fields')
|
||||
.first();
|
||||
|
||||
if (!categoriesResource) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'ITEMS.CATEGORIES.RESOURCE.NOT.FOUND', code: 200 }],
|
||||
});
|
||||
}
|
||||
const filter = {
|
||||
column_sort_order: '',
|
||||
sort_order: '',
|
||||
filter_roles: [],
|
||||
...req.query,
|
||||
};
|
||||
if (filter.stringified_filter_roles) {
|
||||
filter.filter_roles = JSON.parse(filter.stringified_filter_roles);
|
||||
}
|
||||
const errorReasons = [];
|
||||
const resourceFieldsKeys = categoriesResource.fields.map((c) => c.key);
|
||||
const dynamicFilter = new DynamicFilter(ItemCategory.tableName);
|
||||
|
||||
// Dynamic filter with filter roles.
|
||||
if (filter.filter_roles.length > 0) {
|
||||
// Validate the accounts resource fields.
|
||||
const filterRoles = new DynamicFilterFilterRoles(
|
||||
mapFilterRolesToDynamicFilter(filter.filter_roles),
|
||||
categoriesResource.fields,
|
||||
);
|
||||
categoriesResource.setFilter(filterRoles);
|
||||
|
||||
if (filterRoles.validateFilterRoles().length > 0) {
|
||||
errorReasons.push({ type: 'ITEMS.RESOURCE.HAS.NO.FIELDS', code: 500 });
|
||||
}
|
||||
}
|
||||
// Dynamic filter with column sort order.
|
||||
if (filter.column_sort_order) {
|
||||
if (resourceFieldsKeys.indexOf(filter.column_sort_order) === -1) {
|
||||
errorReasons.push({ type: 'COLUMN.SORT.ORDER.NOT.FOUND', code: 300 });
|
||||
}
|
||||
const sortByFilter = new DynamicFilterSortBy(
|
||||
filter.column_sort_order,
|
||||
filter.sort_order,
|
||||
);
|
||||
dynamicFilter.setFilter(sortByFilter);
|
||||
}
|
||||
if (errorReasons.length > 0) {
|
||||
return res.status(400).send({ errors: errorReasons });
|
||||
}
|
||||
const categories = await ItemCategory.query().onBuild((builder) => {
|
||||
dynamicFilter.buildQuery()(builder);
|
||||
|
||||
builder.select([
|
||||
'*',
|
||||
ItemCategory.relatedQuery('items').count().as('count'),
|
||||
]);
|
||||
});
|
||||
|
||||
return res.status(200).send({ categories });
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve details of the given category.
|
||||
* @param {Request} req -
|
||||
* @param {Response} res -
|
||||
* @return {Response}
|
||||
*/
|
||||
async getCategory(req: Request, res: Response) {
|
||||
const itemCategoryId: number = req.params.id;
|
||||
const { ItemCategory } = req.models;
|
||||
|
||||
const itemCategory = await ItemCategory.query().findById(itemCategoryId);
|
||||
|
||||
return res.status(200).send({ category: itemCategory });
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk delete the given item categories.
|
||||
* @param {Request} req -
|
||||
* @param {Response} res -
|
||||
* @return {Response}
|
||||
*/
|
||||
async bulkDeleteCategories(req: Request, res: Response) {
|
||||
const ids = req.query.ids;
|
||||
const { ItemCategory } = req.models;
|
||||
|
||||
await ItemCategory.query().whereIn('id', ids).delete();
|
||||
|
||||
return res.status(200).send({ ids: filter.ids });
|
||||
}
|
||||
};
|
||||
431
server/src/api/controllers/Items.ts
Normal file
431
server/src/api/controllers/Items.ts
Normal file
@@ -0,0 +1,431 @@
|
||||
import { Inject, Service } from 'typedi';
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { check, param, query, ValidationChain, matchedData } from 'express-validator';
|
||||
import asyncMiddleware from 'api/middleware/asyncMiddleware';
|
||||
import validateMiddleware from 'api/middleware/validateMiddleware';
|
||||
import ItemsService from 'services/Items/ItemsService';
|
||||
import DynamicListing from 'services/DynamicListing/DynamicListing';
|
||||
import DynamicListingBuilder from 'services/DynamicListing/DynamicListingBuilder';
|
||||
import { dynamicListingErrorsToResponse } from 'services/DynamicListing/hasDynamicListing';
|
||||
|
||||
@Service()
|
||||
export default class ItemsController {
|
||||
@Inject()
|
||||
itemsService: ItemsService;
|
||||
|
||||
/**
|
||||
* Router constructor.
|
||||
*/
|
||||
router() {
|
||||
const router = Router();
|
||||
|
||||
router.post(
|
||||
'/',
|
||||
this.validateItemSchema,
|
||||
validateMiddleware,
|
||||
asyncMiddleware(this.validateCategoryExistance.bind(this)),
|
||||
asyncMiddleware(this.validateCostAccountExistance.bind(this)),
|
||||
asyncMiddleware(this.validateSellAccountExistance.bind(this)),
|
||||
asyncMiddleware(this.validateInventoryAccountExistance.bind(this)),
|
||||
asyncMiddleware(this.validateItemNameExistance.bind(this)),
|
||||
asyncMiddleware(this.newItem.bind(this)),
|
||||
);
|
||||
router.post(
|
||||
'/:id', [
|
||||
...this.validateItemSchema,
|
||||
...this.validateSpecificItemSchema,
|
||||
],
|
||||
validateMiddleware,
|
||||
asyncMiddleware(this.validateItemExistance.bind(this)),
|
||||
asyncMiddleware(this.validateCategoryExistance.bind(this)),
|
||||
asyncMiddleware(this.validateCostAccountExistance.bind(this)),
|
||||
asyncMiddleware(this.validateSellAccountExistance.bind(this)),
|
||||
asyncMiddleware(this.validateInventoryAccountExistance.bind(this)),
|
||||
asyncMiddleware(this.validateItemNameExistance.bind(this)),
|
||||
asyncMiddleware(this.editItem.bind(this)),
|
||||
);
|
||||
router.delete(
|
||||
'/:id',
|
||||
this.validateSpecificItemSchema,
|
||||
validateMiddleware,
|
||||
asyncMiddleware(this.validateItemExistance.bind(this)),
|
||||
asyncMiddleware(this.deleteItem.bind(this)),
|
||||
);
|
||||
router.get(
|
||||
'/:id',
|
||||
this.validateSpecificItemSchema,
|
||||
validateMiddleware,
|
||||
asyncMiddleware(this.validateItemExistance.bind(this)),
|
||||
asyncMiddleware(this.getItem.bind(this)),
|
||||
);
|
||||
router.get(
|
||||
'/',
|
||||
this.validateListQuerySchema,
|
||||
validateMiddleware,
|
||||
asyncMiddleware(this.listItems.bind(this)),
|
||||
);
|
||||
return router;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate item schema.
|
||||
*/
|
||||
get validateItemSchema(): ValidationChain[] {
|
||||
return [
|
||||
check('name').exists(),
|
||||
check('type').exists().trim().escape()
|
||||
.isIn(['service', 'non-inventory', 'inventory']),
|
||||
check('sku').optional({ nullable: true }).trim().escape(),
|
||||
// Purchase attributes.
|
||||
check('purchasable').optional().isBoolean().toBoolean(),
|
||||
check('cost_price')
|
||||
.if(check('purchasable').equals('true'))
|
||||
.exists()
|
||||
.isNumeric()
|
||||
.toFloat(),
|
||||
check('cost_account_id')
|
||||
.if(check('purchasable').equals('true'))
|
||||
.exists()
|
||||
.isInt()
|
||||
.toInt(),
|
||||
// Sell attributes.
|
||||
check('sellable').optional().isBoolean().toBoolean(),
|
||||
check('sell_price')
|
||||
.if(check('sellable').equals('true'))
|
||||
.exists()
|
||||
.isNumeric()
|
||||
.toFloat(),
|
||||
check('sell_account_id')
|
||||
.if(check('sellable').equals('true'))
|
||||
.exists()
|
||||
.isInt()
|
||||
.toInt(),
|
||||
check('inventory_account_id')
|
||||
.if(check('type').equals('inventory'))
|
||||
.exists()
|
||||
.isInt()
|
||||
.toInt(),
|
||||
check('sell_description').optional({ nullable: true }).trim().escape(),
|
||||
check('cost_description').optional({ nullable: true }).trim().escape(),
|
||||
|
||||
check('category_id').optional({ nullable: true }).isInt().toInt(),
|
||||
check('note').optional(),
|
||||
|
||||
check('media_ids').optional().isArray(),
|
||||
check('media_ids.*').exists().isNumeric().toInt(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate specific item params schema.
|
||||
*/
|
||||
get validateSpecificItemSchema(): ValidationChain[] {
|
||||
return [
|
||||
param('id').exists().isNumeric().toInt(),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Validate list query schema
|
||||
*/
|
||||
get validateListQuerySchema() {
|
||||
return [
|
||||
query('column_sort_order').optional().isIn(['created_at', 'name', 'amount', 'sku']),
|
||||
query('sort_order').optional().isIn(['desc', 'asc']),
|
||||
query('page').optional().isNumeric().toInt(),
|
||||
query('page_size').optional().isNumeric().toInt(),
|
||||
query('custom_view_id').optional().isNumeric().toInt(),
|
||||
query('stringified_filter_roles').optional().isJSON(),
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the given item existance on the storage.
|
||||
* @param {Request} req -
|
||||
* @param {Response} res -
|
||||
* @param {NextFunction} next -
|
||||
*/
|
||||
async validateItemExistance(req: Request, res: Response, next: Function) {
|
||||
const { Item } = req.models;
|
||||
const itemId: number = req.params.id;
|
||||
|
||||
const foundItem = await Item.query().findById(itemId);
|
||||
|
||||
if (!foundItem) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'ITEM.NOT.FOUND', code: 100 }],
|
||||
});
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate wether the given item name already exists on the storage.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {NextFunction} next
|
||||
*/
|
||||
async validateItemNameExistance(req: Request, res: Response, next: Function) {
|
||||
const { Item } = req.models;
|
||||
const item = req.body;
|
||||
const itemId: number = req.params.id;
|
||||
|
||||
const foundItems: [] = await Item.query().onBuild((builder: any) => {
|
||||
builder.where('name', item.name);
|
||||
if (itemId) {
|
||||
builder.whereNot('id', itemId);
|
||||
}
|
||||
});
|
||||
if (foundItems.length > 0) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'ITEM.NAME.ALREADY.EXISTS', code: 210 }],
|
||||
});
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate wether the given category existance on the storage.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {Function} next
|
||||
*/
|
||||
async validateCategoryExistance(req: Request, res: Response, next: Function) {
|
||||
const { ItemCategory } = req.models;
|
||||
const item = req.body;
|
||||
|
||||
if (item.category_id) {
|
||||
const foundCategory = await ItemCategory.query().findById(item.category_id);
|
||||
|
||||
if (!foundCategory) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'ITEM_CATEGORY.NOT.FOUND', code: 140 }],
|
||||
});
|
||||
}
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate wether the given cost account exists on the storage.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {Function} next
|
||||
*/
|
||||
async validateCostAccountExistance(req: Request, res: Response, next: Function) {
|
||||
const { Account, AccountType } = req.models;
|
||||
const item = req.body;
|
||||
|
||||
if (item.cost_account_id) {
|
||||
const COGSType = await AccountType.query().findOne('key', 'cost_of_goods_sold');
|
||||
const foundAccount = await Account.query().findById(item.cost_account_id)
|
||||
|
||||
if (!foundAccount) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'COST.ACCOUNT.NOT.FOUND', code: 120 }],
|
||||
});
|
||||
} else if (foundAccount.accountTypeId !== COGSType.id) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'COST.ACCOUNT.NOT.COGS.TYPE', code: 220 }],
|
||||
});
|
||||
}
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate wether the given sell account exists on the storage.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {NextFunction} next
|
||||
*/
|
||||
async validateSellAccountExistance(req: Request, res: Response, next: Function) {
|
||||
const { Account, AccountType } = req.models;
|
||||
const item = req.body;
|
||||
|
||||
if (item.sell_account_id) {
|
||||
const incomeType = await AccountType.query().findOne('key', 'income');
|
||||
const foundAccount = await Account.query().findById(item.sell_account_id);
|
||||
|
||||
if (!foundAccount) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'SELL.ACCOUNT.NOT.FOUND', code: 130 }],
|
||||
});
|
||||
} else if (foundAccount.accountTypeId !== incomeType.id) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'SELL.ACCOUNT.NOT.INCOME.TYPE', code: 230 }],
|
||||
})
|
||||
}
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates wether the given inventory account exists on the storage.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {NextFunction} next
|
||||
*/
|
||||
async validateInventoryAccountExistance(req: Request, res: Response, next: Function) {
|
||||
const { Account, AccountType } = req.models;
|
||||
const item = req.body;
|
||||
|
||||
if (item.inventory_account_id) {
|
||||
const otherAsset = await AccountType.query().findOne('key', 'other_asset');
|
||||
const foundAccount = await Account.query().findById(item.inventory_account_id);
|
||||
|
||||
if (!foundAccount) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'INVENTORY.ACCOUNT.NOT.FOUND', code: 200}],
|
||||
});
|
||||
} else if (otherAsset.id !== foundAccount.accountTypeId) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'INVENTORY.ACCOUNT.NOT.CURRENT.ASSET', code: 300 }],
|
||||
});
|
||||
}
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the given item details to the storage.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async newItem(req: Request, res: Response,) {
|
||||
const { tenantId } = req;
|
||||
|
||||
const item = matchedData(req, {
|
||||
locations: ['body'],
|
||||
includeOptionals: true
|
||||
});
|
||||
const storedItem = await this.itemsService.newItem(tenantId, item);
|
||||
|
||||
return res.status(200).send({ id: storedItem.id });
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the given item details on the storage.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async editItem(req: Request, res: Response) {
|
||||
const { tenantId } = req;
|
||||
|
||||
const itemId: number = req.params.id;
|
||||
const item = matchedData(req, {
|
||||
locations: ['body'],
|
||||
includeOptionals: true
|
||||
});
|
||||
const updatedItem = await this.itemsService.editItem(tenantId, item, itemId);
|
||||
|
||||
return res.status(200).send({ id: itemId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the given item from the storage.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async deleteItem(req: Request, res: Response) {
|
||||
const itemId: number = req.params.id;
|
||||
const { tenantId } = req;
|
||||
|
||||
await this.itemsService.deleteItem(tenantId, itemId);
|
||||
|
||||
return res.status(200).send({ id: itemId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve details the given item id.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @return {Response}
|
||||
*/
|
||||
async getItem(req: Request, res: Response) {
|
||||
const itemId: number = req.params.id;
|
||||
const { tenantId } = req;
|
||||
|
||||
const storedItem = await this.itemsService.getItemWithMetadata(tenantId, itemId);
|
||||
|
||||
return res.status(200).send({ item: storedItem });
|
||||
}
|
||||
|
||||
/**
|
||||
* Listing items with pagination metadata.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async listItems(req: Request, res: Response) {
|
||||
const filter = {
|
||||
filter_roles: [],
|
||||
sort_order: 'asc',
|
||||
page: 1,
|
||||
page_size: 10,
|
||||
...req.query,
|
||||
};
|
||||
if (filter.stringified_filter_roles) {
|
||||
filter.filter_roles = JSON.parse(filter.stringified_filter_roles);
|
||||
}
|
||||
const { Resource, Item, View } = req.models;
|
||||
const resource = await Resource.query()
|
||||
.remember()
|
||||
.where('name', 'items')
|
||||
.withGraphFetched('fields')
|
||||
.first();
|
||||
|
||||
if (!resource) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'ITEMS.RESOURCE.NOT_FOUND', code: 200 }],
|
||||
});
|
||||
}
|
||||
const viewMeta = await View.query()
|
||||
.modify('allMetadata')
|
||||
.modify('specificOrFavourite', filter.custom_view_id)
|
||||
.where('resource_id', resource.id)
|
||||
.first();
|
||||
|
||||
const listingBuilder = new DynamicListingBuilder();
|
||||
const errorReasons = [];
|
||||
|
||||
listingBuilder.addModelClass(Item);
|
||||
listingBuilder.addCustomViewId(filter.custom_view_id);
|
||||
listingBuilder.addFilterRoles(filter.filter_roles);
|
||||
listingBuilder.addSortBy(filter.sort_by, filter.sort_order);
|
||||
listingBuilder.addView(viewMeta);
|
||||
|
||||
const dynamicListing = new DynamicListing(listingBuilder);
|
||||
|
||||
if (dynamicListing instanceof Error) {
|
||||
const errors = dynamicListingErrorsToResponse(dynamicListing);
|
||||
errorReasons.push(...errors);
|
||||
}
|
||||
if (errorReasons.length > 0) {
|
||||
return res.status(400).send({ errors: errorReasons });
|
||||
}
|
||||
const items = await Item.query().onBuild((builder: any) => {
|
||||
builder.withGraphFetched('costAccount');
|
||||
builder.withGraphFetched('sellAccount');
|
||||
builder.withGraphFetched('inventoryAccount');
|
||||
builder.withGraphFetched('category');
|
||||
|
||||
dynamicListing.buildQuery()(builder);
|
||||
return builder;
|
||||
}).pagination(filter.page - 1, filter.page_size);
|
||||
|
||||
return res.status(200).send({
|
||||
items: {
|
||||
...items,
|
||||
...(viewMeta
|
||||
? {
|
||||
viewMeta: {
|
||||
custom_view_id: viewMeta.id,
|
||||
view_columns: viewMeta.columns,
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
163
server/src/api/controllers/Media.js
Normal file
163
server/src/api/controllers/Media.js
Normal file
@@ -0,0 +1,163 @@
|
||||
|
||||
import express from 'express';
|
||||
import {
|
||||
param,
|
||||
query,
|
||||
validationResult,
|
||||
} from 'express-validator';
|
||||
import Container from 'typedi';
|
||||
import fs from 'fs';
|
||||
import { difference } from 'lodash';
|
||||
import asyncMiddleware from 'api/middleware/asyncMiddleware';
|
||||
|
||||
const fsPromises = fs.promises;
|
||||
|
||||
export default {
|
||||
/**
|
||||
* Router constructor.
|
||||
*/
|
||||
router() {
|
||||
const router = express.Router();
|
||||
|
||||
router.post('/upload',
|
||||
this.upload.validation,
|
||||
asyncMiddleware(this.upload.handler));
|
||||
|
||||
router.delete('/',
|
||||
this.delete.validation,
|
||||
asyncMiddleware(this.delete.handler));
|
||||
|
||||
router.get('/',
|
||||
this.get.validation,
|
||||
asyncMiddleware(this.get.handler));
|
||||
|
||||
return router;
|
||||
},
|
||||
|
||||
/**
|
||||
* Retrieve all or the given attachment ids.
|
||||
*/
|
||||
get: {
|
||||
validation: [
|
||||
query('ids'),
|
||||
],
|
||||
async handler(req, res) {
|
||||
const validationErrors = validationResult(req);
|
||||
|
||||
if (!validationErrors.isEmpty()) {
|
||||
return res.boom.badData(null, {
|
||||
code: 'validation_error', ...validationErrors,
|
||||
});
|
||||
}
|
||||
const { Media } = req.models;
|
||||
const media = await Media.query().onBuild((builder) => {
|
||||
|
||||
if (req.query.ids) {
|
||||
const ids = Array.isArray(req.query.ids) ? req.query.ids : [req.query.ids];
|
||||
builder.whereIn('id', ids);
|
||||
}
|
||||
});
|
||||
|
||||
return res.status(200).send({ media });
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Uploads the given attachment file.
|
||||
*/
|
||||
upload: {
|
||||
validation: [
|
||||
// check('attachment').exists(),
|
||||
],
|
||||
async handler(req, res) {
|
||||
const Logger = Container.get('logger');
|
||||
|
||||
if (!req.files.attachment) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'ATTACHMENT.NOT.FOUND', code: 200 }],
|
||||
});
|
||||
}
|
||||
const publicPath = 'storage/app/public/';
|
||||
const attachmentsMimes = ['image/png', 'image/jpeg'];
|
||||
const { attachment } = req.files;
|
||||
const { Media } = req.models;
|
||||
|
||||
const errorReasons = [];
|
||||
|
||||
// Validate the attachment.
|
||||
if (attachment && attachmentsMimes.indexOf(attachment.mimetype) === -1) {
|
||||
errorReasons.push({ type: 'ATTACHMENT.MINETYPE.NOT.SUPPORTED', code: 160 });
|
||||
}
|
||||
// Catch all error reasons to response 400.
|
||||
if (errorReasons.length > 0) {
|
||||
return res.status(400).send({ errors: errorReasons });
|
||||
}
|
||||
|
||||
try {
|
||||
await attachment.mv(`${publicPath}${req.organizationId}/${attachment.md5}.png`);
|
||||
Logger.info('[attachment] uploaded successfully');
|
||||
} catch (error) {
|
||||
Logger.info('[attachment] uploading failed.', { error });
|
||||
}
|
||||
|
||||
const media = await Media.query().insert({
|
||||
attachment_file: `${attachment.md5}.png`,
|
||||
});
|
||||
return res.status(200).send({ media });
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Deletes the given attachment ids from file system and database.
|
||||
*/
|
||||
delete: {
|
||||
validation: [
|
||||
query('ids').exists().isArray(),
|
||||
query('ids.*').exists().isNumeric().toInt(),
|
||||
],
|
||||
async handler(req, res) {
|
||||
const Logger = Container.get('logger');
|
||||
const validationErrors = validationResult(req);
|
||||
|
||||
if (!validationErrors.isEmpty()) {
|
||||
return res.boom.badData(null, {
|
||||
code: 'validation_error', ...validationErrors,
|
||||
});
|
||||
}
|
||||
const { Media, MediaLink } = req.models;
|
||||
const ids = Array.isArray(req.query.ids) ? req.query.ids : [req.query.ids];
|
||||
const media = await Media.query().whereIn('id', ids);
|
||||
const mediaIds = media.map((m) => m.id);
|
||||
const notFoundMedia = difference(ids, mediaIds);
|
||||
|
||||
if (notFoundMedia.length) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'MEDIA.IDS.NOT.FOUND', code: 200, ids: notFoundMedia }],
|
||||
});
|
||||
}
|
||||
const publicPath = 'storage/app/public/';
|
||||
const tenantPath = `${publicPath}${req.organizationId}`;
|
||||
const unlinkOpers = [];
|
||||
|
||||
media.forEach((mediaModel) => {
|
||||
const oper = fsPromises.unlink(`${tenantPath}/${mediaModel.attachmentFile}`);
|
||||
unlinkOpers.push(oper);
|
||||
});
|
||||
await Promise.all(unlinkOpers).then((resolved) => {
|
||||
resolved.forEach(() => {
|
||||
Logger.info('[attachment] file has been deleted.');
|
||||
});
|
||||
})
|
||||
.catch((errors) => {
|
||||
errors.forEach((error) => {
|
||||
Logger.info('[attachment] Delete item attachment file delete failed.', { error });
|
||||
})
|
||||
});
|
||||
|
||||
await MediaLink.query().whereIn('media_id', mediaIds).delete();
|
||||
await Media.query().whereIn('id', mediaIds).delete();
|
||||
|
||||
return res.status(200).send();
|
||||
},
|
||||
},
|
||||
};
|
||||
114
server/src/api/controllers/Organization.ts
Normal file
114
server/src/api/controllers/Organization.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { Inject, Service } from 'typedi';
|
||||
import { Router, Request, Response } from 'express';
|
||||
import asyncMiddleware from "api/middleware/asyncMiddleware";
|
||||
import JWTAuth from 'api/middleware/jwtAuth';
|
||||
import TenancyMiddleware from 'api/middleware/TenancyMiddleware';
|
||||
import SubscriptionMiddleware from 'api/middleware/SubscriptionMiddleware';
|
||||
import AttachCurrentTenantUser from 'api/middleware/AttachCurrentTenantUser';
|
||||
import OrganizationService from 'services/Organization';
|
||||
import { ServiceError } from 'exceptions';
|
||||
import BaseController from 'api/controllers/BaseController';
|
||||
|
||||
@Service()
|
||||
export default class OrganizationController extends BaseController{
|
||||
@Inject()
|
||||
organizationService: OrganizationService;
|
||||
|
||||
/**
|
||||
* Router constructor.
|
||||
*/
|
||||
router() {
|
||||
const router = Router();
|
||||
|
||||
// Should before build tenant database the user be authorized and
|
||||
// most important than that, should be subscribed to any plan.
|
||||
router.use(JWTAuth);
|
||||
router.use(AttachCurrentTenantUser);
|
||||
router.use(TenancyMiddleware);
|
||||
router.use(SubscriptionMiddleware('main'));
|
||||
|
||||
router.post(
|
||||
'/build',
|
||||
asyncMiddleware(this.build.bind(this))
|
||||
);
|
||||
router.post(
|
||||
'/seed',
|
||||
asyncMiddleware(this.seed.bind(this)),
|
||||
);
|
||||
return router;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds tenant database and migrate database schema.
|
||||
* @param {Request} req - Express request.
|
||||
* @param {Response} res - Express response.
|
||||
* @param {NextFunction} next
|
||||
*/
|
||||
async build(req: Request, res: Response, next: Function) {
|
||||
const { organizationId } = req.tenant;
|
||||
|
||||
try {
|
||||
await this.organizationService.build(organizationId);
|
||||
|
||||
return res.status(200).send({
|
||||
type: 'success',
|
||||
code: 'ORGANIZATION.DATABASE.INITIALIZED',
|
||||
message: 'The organization database has been initialized.'
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceError) {
|
||||
if (error.errorType === 'tenant_not_found') {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'TENANT.NOT.FOUND', code: 100 }],
|
||||
});
|
||||
}
|
||||
if (error.errorType === 'tenant_already_initialized') {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'TENANT.DATABASE.ALREADY.BUILT', code: 200 }],
|
||||
});
|
||||
}
|
||||
}
|
||||
console.log(error);
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeds initial data to tenant database.
|
||||
* @param req
|
||||
* @param res
|
||||
* @param next
|
||||
*/
|
||||
async seed(req: Request, res: Response, next: Function) {
|
||||
const { organizationId } = req.tenant;
|
||||
|
||||
try {
|
||||
await this.organizationService.seed(organizationId);
|
||||
|
||||
return res.status(200).send({
|
||||
type: 'success',
|
||||
code: 'ORGANIZATION.DATABASE.SEED',
|
||||
message: 'The organization database has been seeded.'
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceError) {
|
||||
if (error.errorType === 'tenant_not_found') {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'TENANT.NOT.FOUND', code: 100 }],
|
||||
});
|
||||
}
|
||||
if (error.errorType === 'tenant_seeded') {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'TENANT.DATABASE.ALREADY.SEEDED', code: 200 }],
|
||||
});
|
||||
}
|
||||
if (error.errorType === 'tenant_db_not_built') {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'TENANT.DATABASE.NOT.BUILT', code: 300 }],
|
||||
});
|
||||
}
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
30
server/src/api/controllers/Ping.ts
Normal file
30
server/src/api/controllers/Ping.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import MomentFormat from 'lib/MomentFormats';
|
||||
import moment from 'moment';
|
||||
|
||||
export default class Ping {
|
||||
/**
|
||||
* Router constur
|
||||
*/
|
||||
router() {
|
||||
const router = Router();
|
||||
|
||||
router.get(
|
||||
'/',
|
||||
this.ping,
|
||||
);
|
||||
return router;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the ping request.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async ping(req: Request, res: Response)
|
||||
{
|
||||
return res.status(200).send({
|
||||
server: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
400
server/src/api/controllers/Purchases/Bills.ts
Normal file
400
server/src/api/controllers/Purchases/Bills.ts
Normal file
@@ -0,0 +1,400 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { check, param, query, matchedData } from 'express-validator';
|
||||
import { Service, Inject } from 'typedi';
|
||||
import { difference } from 'lodash';
|
||||
import { BillOTD } from 'interfaces';
|
||||
import validateMiddleware from 'api/middleware/validateMiddleware';
|
||||
import asyncMiddleware from 'api/middleware/asyncMiddleware';
|
||||
import BillsService from 'services/Purchases/Bills';
|
||||
import BaseController from 'api/controllers/BaseController';
|
||||
import ItemsService from 'services/Items/ItemsService';
|
||||
import TenancyService from 'services/Tenancy/TenancyService';
|
||||
import DynamicListingBuilder from 'services/DynamicListing/DynamicListingBuilder';
|
||||
import DynamicListing from 'services/DynamicListing/DynamicListing';
|
||||
import { dynamicListingErrorsToResponse } from 'services/DynamicListing/HasDynamicListing';
|
||||
|
||||
@Service()
|
||||
export default class BillsController extends BaseController {
|
||||
@Inject()
|
||||
itemsService: ItemsService;
|
||||
|
||||
@Inject()
|
||||
billsService: BillsService;
|
||||
|
||||
@Inject()
|
||||
tenancy: TenancyService;
|
||||
|
||||
/**
|
||||
* Router constructor.
|
||||
*/
|
||||
router() {
|
||||
const router = Router();
|
||||
|
||||
router.post(
|
||||
'/',
|
||||
[...this.billValidationSchema],
|
||||
validateMiddleware,
|
||||
asyncMiddleware(this.validateVendorExistance.bind(this)),
|
||||
asyncMiddleware(this.validateItemsIds.bind(this)),
|
||||
asyncMiddleware(this.validateBillNumberExists.bind(this)),
|
||||
asyncMiddleware(this.validateNonPurchasableEntriesItems.bind(this)),
|
||||
asyncMiddleware(this.newBill.bind(this))
|
||||
);
|
||||
router.post(
|
||||
'/:id',
|
||||
[...this.billValidationSchema, ...this.specificBillValidationSchema],
|
||||
validateMiddleware,
|
||||
asyncMiddleware(this.validateBillExistance.bind(this)),
|
||||
asyncMiddleware(this.validateVendorExistance.bind(this)),
|
||||
asyncMiddleware(this.validateItemsIds.bind(this)),
|
||||
asyncMiddleware(this.validateEntriesIdsExistance.bind(this)),
|
||||
asyncMiddleware(this.validateNonPurchasableEntriesItems.bind(this)),
|
||||
asyncMiddleware(this.editBill.bind(this))
|
||||
);
|
||||
router.get(
|
||||
'/:id',
|
||||
[...this.specificBillValidationSchema],
|
||||
validateMiddleware,
|
||||
asyncMiddleware(this.validateBillExistance.bind(this)),
|
||||
asyncMiddleware(this.getBill.bind(this))
|
||||
);
|
||||
router.get(
|
||||
'/',
|
||||
[...this.billsListingValidationSchema],
|
||||
validateMiddleware,
|
||||
asyncMiddleware(this.listingBills.bind(this))
|
||||
);
|
||||
router.delete(
|
||||
'/:id',
|
||||
[...this.specificBillValidationSchema],
|
||||
validateMiddleware,
|
||||
asyncMiddleware(this.validateBillExistance.bind(this)),
|
||||
asyncMiddleware(this.deleteBill.bind(this))
|
||||
);
|
||||
return router;
|
||||
}
|
||||
|
||||
/**
|
||||
* Common validation schema.
|
||||
*/
|
||||
get billValidationSchema() {
|
||||
return [
|
||||
check('bill_number').exists().trim().escape(),
|
||||
check('bill_date').exists().isISO8601(),
|
||||
check('due_date').optional().isISO8601(),
|
||||
check('vendor_id').exists().isNumeric().toInt(),
|
||||
check('note').optional().trim().escape(),
|
||||
check('entries').isArray({ min: 1 }),
|
||||
|
||||
check('entries.*.id').optional().isNumeric().toInt(),
|
||||
check('entries.*.index').exists().isNumeric().toInt(),
|
||||
check('entries.*.item_id').exists().isNumeric().toInt(),
|
||||
check('entries.*.rate').exists().isNumeric().toFloat(),
|
||||
check('entries.*.quantity').exists().isNumeric().toFloat(),
|
||||
check('entries.*.discount').optional().isNumeric().toFloat(),
|
||||
check('entries.*.description').optional().trim().escape(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Bill validation schema.
|
||||
*/
|
||||
get specificBillValidationSchema() {
|
||||
return [param('id').exists().isNumeric().toInt()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Bills list validation schema.
|
||||
*/
|
||||
get billsListingValidationSchema() {
|
||||
return [
|
||||
query('custom_view_id').optional().isNumeric().toInt(),
|
||||
query('stringified_filter_roles').optional().isJSON(),
|
||||
query('page').optional().isNumeric().toInt(),
|
||||
query('page_size').optional().isNumeric().toInt(),
|
||||
query('column_sort_by').optional(),
|
||||
query('sort_order').optional().isIn(['desc', 'asc']),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates whether the vendor is exist.
|
||||
* @async
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {Function} next
|
||||
*/
|
||||
async validateVendorExistance(req: Request, res: Response, next: Function) {
|
||||
const { tenantId } = req;
|
||||
const { Vendor } = req.models;
|
||||
|
||||
const isVendorExists = await Vendor.query().findById(req.body.vendor_id);
|
||||
|
||||
if (!isVendorExists) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'VENDOR.ID.NOT.FOUND', code: 300 }],
|
||||
});
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the given bill existance.
|
||||
* @async
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {Function} next
|
||||
*/
|
||||
async validateBillExistance(req: Request, res: Response, next: Function) {
|
||||
const billId: number = req.params.id;
|
||||
const { tenantId } = req;
|
||||
|
||||
const isBillExists = await this.billsService.isBillExists(tenantId, billId);
|
||||
|
||||
if (!isBillExists) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'BILL.NOT.FOUND', code: 200 }],
|
||||
});
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the entries items ids.
|
||||
* @async
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {Function} next
|
||||
*/
|
||||
async validateItemsIds(req: Request, res: Response, next: Function) {
|
||||
const { tenantId } = req;
|
||||
const itemsIds = req.body.entries.map((e) => e.item_id);
|
||||
|
||||
const notFoundItemsIds = await this.itemsService.isItemsIdsExists(tenantId, itemsIds);
|
||||
|
||||
if (notFoundItemsIds.length > 0) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'ITEMS.IDS.NOT.FOUND', code: 400 }],
|
||||
});
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the bill number existance.
|
||||
* @async
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {Function} next
|
||||
*/
|
||||
async validateBillNumberExists(req: Request, res: Response, next: Function) {
|
||||
const { tenantId } = req;
|
||||
|
||||
const isBillNoExists = await this.billsService.isBillNoExists(
|
||||
tenantId, req.body.bill_number,
|
||||
);
|
||||
if (isBillNoExists) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'BILL.NUMBER.EXISTS', code: 500 }],
|
||||
});
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the entries ids existance on the storage.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {Function} next
|
||||
*/
|
||||
async validateEntriesIdsExistance(req: Request, res: Response, next: Function) {
|
||||
const { id: billId } = req.params;
|
||||
const bill = { ...req.body };
|
||||
const { ItemEntry } = req.models;
|
||||
|
||||
const entriesIds = bill.entries.filter((e) => e.id).map((e) => e.id);
|
||||
|
||||
const storedEntries = await ItemEntry.query()
|
||||
.whereIn('reference_id', [billId])
|
||||
.whereIn('reference_type', ['Bill']);
|
||||
|
||||
const storedEntriesIds = storedEntries.map((entry) => entry.id);
|
||||
const notFoundEntriesIds = difference(entriesIds, storedEntriesIds);
|
||||
|
||||
if (notFoundEntriesIds.length > 0) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'BILL.ENTRIES.IDS.NOT.FOUND', code: 600 }],
|
||||
});
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the entries items that not purchase-able.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {Function} next
|
||||
*/
|
||||
async validateNonPurchasableEntriesItems(req: Request, res: Response, next: Function) {
|
||||
const { Item } = req.models;
|
||||
const bill = { ...req.body };
|
||||
const itemsIds = bill.entries.map(e => e.item_id);
|
||||
|
||||
const purchasbleItems = await Item.query()
|
||||
.where('purchasable', true)
|
||||
.whereIn('id', itemsIds);
|
||||
|
||||
const purchasbleItemsIds = purchasbleItems.map((item) => item.id);
|
||||
const notPurchasableItems = difference(itemsIds, purchasbleItemsIds);
|
||||
|
||||
if (notPurchasableItems.length > 0) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'NOT.PURCHASE.ABLE.ITEMS', code: 600 }],
|
||||
});
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new bill and records journal transactions.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {Function} next
|
||||
*/
|
||||
async newBill(req: Request, res: Response, next: Function) {
|
||||
const { tenantId } = req;
|
||||
const { ItemEntry } = req.models;
|
||||
|
||||
const billOTD: BillOTD = matchedData(req, {
|
||||
locations: ['body'],
|
||||
includeOptionals: true
|
||||
});
|
||||
const storedBill = await this.billsService.createBill(tenantId, billOTD);
|
||||
|
||||
return res.status(200).send({ id: storedBill.id });
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit bill details with associated entries and rewrites journal transactions.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async editBill(req: Request, res: Response) {
|
||||
const { id: billId } = req.params;
|
||||
const { ItemEntry } = req.models;
|
||||
const { tenantId } = req;
|
||||
|
||||
const billOTD: BillOTD = matchedData(req, {
|
||||
locations: ['body'],
|
||||
includeOptionals: true
|
||||
});
|
||||
const editedBill = await this.billsService.editBill(tenantId, billId, billOTD);
|
||||
|
||||
return res.status(200).send({ id: billId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the given bill details with associated item entries.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @return {Response}
|
||||
*/
|
||||
async getBill(req: Request, res: Response) {
|
||||
const { tenantId } = req;
|
||||
const { id: billId } = req.params;
|
||||
|
||||
const bill = await this.billsService.getBillWithMetadata(tenantId, billId);
|
||||
|
||||
return res.status(200).send({ bill });
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the given bill with associated entries and journal transactions.
|
||||
* @param {Request} req -
|
||||
* @param {Response} res -
|
||||
* @return {Response}
|
||||
*/
|
||||
async deleteBill(req: Request, res: Response) {
|
||||
const billId = req.params.id;
|
||||
const { tenantId } = req;
|
||||
|
||||
await this.billsService.deleteBill(tenantId, billId);
|
||||
|
||||
return res.status(200).send({ id: billId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Listing bills with pagination meta.
|
||||
* @param {Request} req -
|
||||
* @param {Response} res -
|
||||
* @return {Response}
|
||||
*/
|
||||
async listingBills(req: Request, res: Response) {
|
||||
const filter = {
|
||||
filter_roles: [],
|
||||
sort_order: 'asc',
|
||||
page: 1,
|
||||
page_size: 10,
|
||||
...req.query,
|
||||
};
|
||||
if (filter.stringified_filter_roles) {
|
||||
filter.filter_roles = JSON.parse(filter.stringified_filter_roles);
|
||||
}
|
||||
const { Bill, View, Resource } = req.models;
|
||||
const resource = await Resource.query()
|
||||
.remember()
|
||||
.where('name', 'bills')
|
||||
.withGraphFetched('fields')
|
||||
.first();
|
||||
|
||||
if (!resource) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'BILLS_RESOURCE_NOT_FOUND', code: 200 }],
|
||||
});
|
||||
}
|
||||
const viewMeta = await View.query()
|
||||
.modify('allMetadata')
|
||||
.modify('specificOrFavourite', filter.custom_view_id)
|
||||
.where('resource_id', resource.id)
|
||||
.first();
|
||||
|
||||
const listingBuilder = new DynamicListingBuilder();
|
||||
const errorReasons = [];
|
||||
|
||||
listingBuilder.addModelClass(Bill);
|
||||
listingBuilder.addCustomViewId(filter.custom_view_id);
|
||||
listingBuilder.addFilterRoles(filter.filter_roles);
|
||||
listingBuilder.addSortBy(filter.sort_by, filter.sort_order);
|
||||
listingBuilder.addView(viewMeta);
|
||||
|
||||
const dynamicListing = new DynamicListing(listingBuilder);
|
||||
|
||||
if (dynamicListing instanceof Error) {
|
||||
const errors = dynamicListingErrorsToResponse(dynamicListing);
|
||||
errorReasons.push(...errors);
|
||||
}
|
||||
if (errorReasons.length > 0) {
|
||||
return res.status(400).send({ errors: errorReasons });
|
||||
}
|
||||
const bills = await Bill.query()
|
||||
.onBuild((builder) => {
|
||||
dynamicListing.buildQuery()(builder);
|
||||
builder.withGraphFetched('vendor');
|
||||
return builder;
|
||||
})
|
||||
.pagination(filter.page - 1, filter.page_size);
|
||||
|
||||
return res.status(200).send({
|
||||
bills: {
|
||||
...bills,
|
||||
...(viewMeta
|
||||
? {
|
||||
view_meta: {
|
||||
customViewId: viewMeta.id,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
453
server/src/api/controllers/Purchases/BillsPayments.ts
Normal file
453
server/src/api/controllers/Purchases/BillsPayments.ts
Normal file
@@ -0,0 +1,453 @@
|
||||
|
||||
import { Router } from 'express';
|
||||
import { Service, Inject } from 'typedi';
|
||||
import { check, param, query, ValidationChain, matchedData } from 'express-validator';
|
||||
import { difference } from 'lodash';
|
||||
import asyncMiddleware from 'api/middleware/asyncMiddleware';
|
||||
import validateMiddleware from 'api/middleware/validateMiddleware';
|
||||
import BaseController from 'api/controllers/BaseController';
|
||||
import BillPaymentsService from 'services/Purchases/BillPayments';
|
||||
import AccountsService from 'services/Accounts/AccountsService';
|
||||
import DynamicListingBuilder from 'services/DynamicListing/DynamicListingBuilder';
|
||||
import DynamicListing from 'services/DynamicListing/DynamicListing';
|
||||
import { dynamicListingErrorsToResponse } from 'services/DynamicListing/hasDynamicListing';
|
||||
|
||||
/**
|
||||
* Bills payments controller.
|
||||
* @service
|
||||
*/
|
||||
@Service()
|
||||
export default class BillsPayments extends BaseController {
|
||||
@Inject()
|
||||
billPaymentService: BillPaymentsService;
|
||||
|
||||
@Inject()
|
||||
accountsService: AccountsService;
|
||||
|
||||
/**
|
||||
* Router constructor.
|
||||
*/
|
||||
router() {
|
||||
const router = Router();
|
||||
|
||||
router.post('/', [
|
||||
...this.billPaymentSchemaValidation,
|
||||
],
|
||||
validateMiddleware,
|
||||
asyncMiddleware(this.validateBillPaymentVendorExistance.bind(this)),
|
||||
asyncMiddleware(this.validatePaymentAccount.bind(this)),
|
||||
asyncMiddleware(this.validatePaymentNumber.bind(this)),
|
||||
asyncMiddleware(this.validateEntriesBillsExistance.bind(this)),
|
||||
asyncMiddleware(this.validateVendorsDueAmount.bind(this)),
|
||||
asyncMiddleware(this.createBillPayment.bind(this)),
|
||||
);
|
||||
router.post('/:id', [
|
||||
...this.billPaymentSchemaValidation,
|
||||
...this.specificBillPaymentValidateSchema,
|
||||
],
|
||||
validateMiddleware,
|
||||
asyncMiddleware(this.validateBillPaymentVendorExistance.bind(this)),
|
||||
asyncMiddleware(this.validatePaymentAccount.bind(this)),
|
||||
asyncMiddleware(this.validatePaymentNumber.bind(this)),
|
||||
asyncMiddleware(this.validateEntriesIdsExistance.bind(this)),
|
||||
asyncMiddleware(this.validateEntriesBillsExistance.bind(this)),
|
||||
asyncMiddleware(this.validateVendorsDueAmount.bind(this)),
|
||||
asyncMiddleware(this.editBillPayment.bind(this)),
|
||||
)
|
||||
router.delete('/:id',
|
||||
this.specificBillPaymentValidateSchema,
|
||||
validateMiddleware,
|
||||
asyncMiddleware(this.validateBillPaymentExistance.bind(this)),
|
||||
asyncMiddleware(this.deleteBillPayment.bind(this)),
|
||||
);
|
||||
router.get('/:id',
|
||||
this.specificBillPaymentValidateSchema,
|
||||
validateMiddleware,
|
||||
asyncMiddleware(this.validateBillPaymentExistance.bind(this)),
|
||||
asyncMiddleware(this.getBillPayment.bind(this)),
|
||||
);
|
||||
router.get('/',
|
||||
this.listingValidationSchema,
|
||||
validateMiddleware,
|
||||
asyncMiddleware(this.getBillsPayments.bind(this))
|
||||
);
|
||||
return router;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bill payments schema validation.
|
||||
*/
|
||||
get billPaymentSchemaValidation(): ValidationChain[] {
|
||||
return [
|
||||
check('vendor_id').exists().isNumeric().toInt(),
|
||||
check('payment_account_id').exists().isNumeric().toInt(),
|
||||
check('payment_number').exists().trim().escape(),
|
||||
check('payment_date').exists(),
|
||||
check('description').optional().trim().escape(),
|
||||
check('reference').optional().trim().escape(),
|
||||
|
||||
check('entries').exists().isArray({ min: 1 }),
|
||||
check('entries.*.bill_id').exists().isNumeric().toInt(),
|
||||
check('entries.*.payment_amount').exists().isNumeric().toInt(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Specific bill payment schema validation.
|
||||
*/
|
||||
get specificBillPaymentValidateSchema(): ValidationChain[] {
|
||||
return [
|
||||
param('id').exists().isNumeric().toInt(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Bills payment list validation schema.
|
||||
*/
|
||||
get listingValidationSchema(): ValidationChain[] {
|
||||
return [
|
||||
query('custom_view_id').optional().isNumeric().toInt(),
|
||||
query('stringified_filter_roles').optional().isJSON(),
|
||||
query('column_sort_by').optional(),
|
||||
query('sort_order').optional().isIn(['desc', 'asc']),
|
||||
query('page').optional().isNumeric().toInt(),
|
||||
query('page_size').optional().isNumeric().toInt(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate whether the bill payment vendor exists on the storage.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {Function} next
|
||||
*/
|
||||
async validateBillPaymentVendorExistance(req: Request, res: Response, next: any ) {
|
||||
const billPayment = req.body;
|
||||
const { Vendor } = req.models;
|
||||
const isVendorExists = await Vendor.query().findById(billPayment.vendor_id);
|
||||
|
||||
if (!isVendorExists) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'BILL.PAYMENT.VENDOR.NOT.FOUND', code: 500 }],
|
||||
});
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the bill payment existance.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {Function} next
|
||||
*/
|
||||
async validateBillPaymentExistance(req: Request, res: Response, next: any ) {
|
||||
const { id: billPaymentId } = req.params;
|
||||
const { BillPayment } = req.models;
|
||||
const foundBillPayment = await BillPayment.query().findById(billPaymentId);
|
||||
|
||||
if (!foundBillPayment) {
|
||||
return res.status(404).send({
|
||||
errors: [{ type: 'BILL.PAYMENT.NOT.FOUND', code: 100 }],
|
||||
});
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the payment account.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {Function} next
|
||||
*/
|
||||
async validatePaymentAccount(req: Request, res: Response, next: any) {
|
||||
const { tenantId } = req;
|
||||
const billPayment = { ...req.body };
|
||||
|
||||
const isAccountExists = await this.accountsService.isAccountExists(
|
||||
tenantId,
|
||||
billPayment.payment_account_id
|
||||
);
|
||||
|
||||
if (!isAccountExists) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'PAYMENT.ACCOUNT.NOT.FOUND', code: 200 }],
|
||||
});
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the payment number uniqness.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {Function} res
|
||||
*/
|
||||
async validatePaymentNumber(req: Request, res: Response, next: any) {
|
||||
const billPayment = { ...req.body };
|
||||
const { id: billPaymentId } = req.params;
|
||||
const { BillPayment } = req.models;
|
||||
|
||||
const foundBillPayment = await BillPayment.query()
|
||||
.onBuild((builder: any) => {
|
||||
builder.where('payment_number', billPayment.payment_number)
|
||||
if (billPaymentId) {
|
||||
builder.whereNot('id', billPaymentId);
|
||||
}
|
||||
})
|
||||
.first();
|
||||
|
||||
if (foundBillPayment) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'PAYMENT.NUMBER.NOT.UNIQUE', code: 300 }],
|
||||
});
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate whether the entries bills ids exist on the storage.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {NextFunction} next
|
||||
*/
|
||||
async validateEntriesBillsExistance(req: Request, res: Response, next: any) {
|
||||
const { Bill } = req.models;
|
||||
const billPayment = { ...req.body };
|
||||
const entriesBillsIds = billPayment.entries.map((e: any) => e.bill_id);
|
||||
|
||||
// Retrieve not found bills that associated to the given vendor id.
|
||||
const notFoundBillsIds = await Bill.getNotFoundBills(
|
||||
entriesBillsIds,
|
||||
billPayment.vendor_id,
|
||||
);
|
||||
if (notFoundBillsIds.length > 0) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'BILLS.IDS.NOT.EXISTS', code: 600 }],
|
||||
});
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate wether the payment amount bigger than the payable amount.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {NextFunction} next
|
||||
* @return {void}
|
||||
*/
|
||||
async validateVendorsDueAmount(req: Request, res: Response, next: Function) {
|
||||
const { Bill } = req.models;
|
||||
const billsIds = req.body.entries.map((entry: any) => entry.bill_id);
|
||||
const storedBills = await Bill.query()
|
||||
.whereIn('id', billsIds);
|
||||
|
||||
const storedBillsMap = new Map(
|
||||
storedBills.map((bill: any) => [bill.id, bill]),
|
||||
);
|
||||
interface invalidPaymentAmountError{
|
||||
index: number,
|
||||
due_amount: number
|
||||
};
|
||||
const hasWrongPaymentAmount: invalidPaymentAmountError[] = [];
|
||||
const { entries } = req.body;
|
||||
|
||||
entries.forEach((entry: any, index: number) => {
|
||||
const entryBill = storedBillsMap.get(entry.bill_id);
|
||||
const { dueAmount } = entryBill;
|
||||
|
||||
if (dueAmount < entry.payment_amount) {
|
||||
hasWrongPaymentAmount.push({ index, due_amount: dueAmount });
|
||||
}
|
||||
});
|
||||
if (hasWrongPaymentAmount.length > 0) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'INVALID.BILL.PAYMENT.AMOUNT', code: 400, indexes: hasWrongPaymentAmount }]
|
||||
});
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the payment receive entries IDs existance.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @return {Response}
|
||||
*/
|
||||
async validateEntriesIdsExistance(req: Request, res: Response, next: Function) {
|
||||
const { BillPaymentEntry } = req.models;
|
||||
|
||||
const billPayment = { id: req.params.id, ...req.body };
|
||||
const entriesIds = billPayment.entries
|
||||
.filter((entry: any) => entry.id)
|
||||
.map((entry: any) => entry.id);
|
||||
|
||||
const storedEntries = await BillPaymentEntry.query()
|
||||
.where('bill_payment_id', billPayment.id);
|
||||
|
||||
const storedEntriesIds = storedEntries.map((entry: any) => entry.id);
|
||||
const notFoundEntriesIds = difference(entriesIds, storedEntriesIds);
|
||||
|
||||
if (notFoundEntriesIds.length > 0) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'ENTEIES.IDS.NOT.FOUND', code: 800 }],
|
||||
});
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a bill payment.
|
||||
* @async
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {Response} res
|
||||
*/
|
||||
async createBillPayment(req: Request, res: Response) {
|
||||
const { tenantId } = req;
|
||||
|
||||
const billPayment = matchedData(req, {
|
||||
locations: ['body'],
|
||||
includeOptionals: true,
|
||||
});
|
||||
const storedPayment = await this.billPaymentService
|
||||
.createBillPayment(tenantId, billPayment);
|
||||
|
||||
return res.status(200).send({ id: storedPayment.id });
|
||||
}
|
||||
|
||||
/**
|
||||
* Edits the given bill payment details.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async editBillPayment(req: Request, res: Response) {
|
||||
const { tenantId } = req;
|
||||
|
||||
const billPayment = matchedData(req, {
|
||||
locations: ['body'],
|
||||
includeOptionals: true,
|
||||
});
|
||||
const { id: billPaymentId } = req.params;
|
||||
const { BillPayment } = req.models;
|
||||
|
||||
const oldBillPayment = await BillPayment.query()
|
||||
.where('id', billPaymentId)
|
||||
.withGraphFetched('entries')
|
||||
.first();
|
||||
|
||||
await this.billPaymentService.editBillPayment(
|
||||
tenantId,
|
||||
billPaymentId,
|
||||
billPayment,
|
||||
oldBillPayment,
|
||||
);
|
||||
return res.status(200).send({ id: 1 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the bill payment and revert the journal
|
||||
* transactions with accounts balance.
|
||||
* @param {Request} req -
|
||||
* @param {Response} res -
|
||||
* @return {Response} res -
|
||||
*/
|
||||
async deleteBillPayment(req: Request, res: Response) {
|
||||
const { tenantId } = req;
|
||||
|
||||
const { id: billPaymentId } = req.params;
|
||||
const billPayment = req.body;
|
||||
|
||||
await this.billPaymentService.deleteBillPayment(tenantId, billPaymentId);
|
||||
|
||||
return res.status(200).send({ id: billPaymentId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the bill payment.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async getBillPayment(req: Request, res: Response) {
|
||||
const { tenantId } = req;
|
||||
const { id: billPaymentId } = req.params;
|
||||
|
||||
const billPayment = await this.billPaymentService
|
||||
.getBillPaymentWithMetadata(tenantId, billPaymentId);
|
||||
|
||||
return res.status(200).send({ bill_payment: { ...billPayment } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve bills payments listing with pagination metadata.
|
||||
* @param {Request} req -
|
||||
* @param {Response} res -
|
||||
* @return {Response}
|
||||
*/
|
||||
async getBillsPayments(req: Request, res: Response) {
|
||||
const filter = {
|
||||
filter_roles: [],
|
||||
sort_order: 'asc',
|
||||
page: 1,
|
||||
page_size: 10,
|
||||
...req.query,
|
||||
};
|
||||
if (filter.stringified_filter_roles) {
|
||||
filter.filter_roles = JSON.parse(filter.stringified_filter_roles);
|
||||
}
|
||||
const { BillPayment, View, Resource } = req.models;
|
||||
|
||||
const resource = await Resource.query()
|
||||
.where('name', 'bill_payments')
|
||||
.withGraphFetched('fields')
|
||||
.first();
|
||||
|
||||
if (!resource) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'BILL.PAYMENTS.RESOURCE.NOT_FOUND', code: 200 }],
|
||||
});
|
||||
}
|
||||
const viewMeta = await View.query()
|
||||
.modify('allMetadata')
|
||||
.modify('specificOrFavourite', filter.custom_view_id)
|
||||
.where('resource_id', resource.id)
|
||||
.first();
|
||||
|
||||
const listingBuilder = new DynamicListingBuilder();
|
||||
const errorReasons = [];
|
||||
|
||||
listingBuilder.addModelClass(BillPayment);
|
||||
listingBuilder.addCustomViewId(filter.custom_view_id);
|
||||
listingBuilder.addFilterRoles(filter.filter_roles);
|
||||
listingBuilder.addSortBy(filter.sort_by, filter.sort_order);
|
||||
listingBuilder.addView(viewMeta);
|
||||
|
||||
const dynamicListing = new DynamicListing(listingBuilder);
|
||||
|
||||
if (dynamicListing instanceof Error) {
|
||||
const errors = dynamicListingErrorsToResponse(dynamicListing);
|
||||
errorReasons.push(...errors);
|
||||
}
|
||||
if (errorReasons.length > 0) {
|
||||
return res.status(400).send({ errors: errorReasons });
|
||||
}
|
||||
const billPayments = await BillPayment.query().onBuild((builder) => {
|
||||
dynamicListing.buildQuery()(builder);
|
||||
builder.withGraphFetched('vendor');
|
||||
builder.withGraphFetched('paymentAccount');
|
||||
return builder;
|
||||
}).pagination(filter.page - 1, filter.page_size);
|
||||
|
||||
return res.status(200).send({
|
||||
bill_payments: {
|
||||
...billPayments,
|
||||
...(viewMeta
|
||||
? {
|
||||
view_meta: {
|
||||
customViewId: viewMeta.id,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
16
server/src/api/controllers/Purchases/index.ts
Normal file
16
server/src/api/controllers/Purchases/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import express from 'express';
|
||||
import { Container } from 'typedi';
|
||||
import Bills from 'api/controllers/Purchases/Bills'
|
||||
import BillPayments from 'api/controllers/Purchases/BillsPayments';
|
||||
|
||||
export default {
|
||||
|
||||
router() {
|
||||
const router = express.Router();
|
||||
|
||||
router.use('/bills', Container.get(Bills).router());
|
||||
router.use('/bill_payments', Container.get(BillPayments).router());
|
||||
|
||||
return router;
|
||||
}
|
||||
}
|
||||
115
server/src/api/controllers/Resources.js
Normal file
115
server/src/api/controllers/Resources.js
Normal file
@@ -0,0 +1,115 @@
|
||||
import express from 'express';
|
||||
import {
|
||||
param,
|
||||
query,
|
||||
} from 'express-validator';
|
||||
import asyncMiddleware from 'api/middleware/asyncMiddleware';
|
||||
|
||||
export default {
|
||||
/**
|
||||
* Router constructor.
|
||||
*/
|
||||
router() {
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/:resource_slug/data',
|
||||
this.resourceData.validation,
|
||||
asyncMiddleware(this.resourceData.handler));
|
||||
|
||||
router.get('/:resource_slug/columns',
|
||||
this.resourceColumns.validation,
|
||||
asyncMiddleware(this.resourceColumns.handler));
|
||||
|
||||
router.get('/:resource_slug/fields',
|
||||
this.resourceFields.validation,
|
||||
asyncMiddleware(this.resourceFields.handler));
|
||||
|
||||
return router;
|
||||
},
|
||||
|
||||
/**
|
||||
* Retrieve resource data of the given resource key/slug.
|
||||
*/
|
||||
resourceData: {
|
||||
validation: [
|
||||
param('resource_slug').trim().escape().exists(),
|
||||
],
|
||||
async handler(req, res) {
|
||||
const { AccountType } = req.models;
|
||||
const { resource_slug: resourceSlug } = req.params;
|
||||
|
||||
const data = await AccountType.query();
|
||||
|
||||
return res.status(200).send({
|
||||
data,
|
||||
resource_slug: resourceSlug,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Retrieve resource columns of the given resource.
|
||||
*/
|
||||
resourceColumns: {
|
||||
validation: [
|
||||
param('resource_slug').trim().escape().exists(),
|
||||
],
|
||||
async handler(req, res) {
|
||||
const { resource_slug: resourceSlug } = req.params;
|
||||
const { Resource } = req.models;
|
||||
|
||||
const resource = await Resource.query()
|
||||
.where('name', resourceSlug)
|
||||
.withGraphFetched('fields')
|
||||
.first();
|
||||
|
||||
if (!resource) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'RESOURCE.SLUG.NOT.FOUND', code: 200 }],
|
||||
});
|
||||
}
|
||||
const resourceFields = resource.fields
|
||||
.filter((field) => field.columnable)
|
||||
.map((field) => ({
|
||||
id: field.id,
|
||||
label: field.labelName,
|
||||
key: field.key,
|
||||
}));
|
||||
|
||||
return res.status(200).send({
|
||||
resource_columns: resourceFields,
|
||||
resource_slug: resourceSlug,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Retrieve resource fields of the given resource.
|
||||
*/
|
||||
resourceFields: {
|
||||
validation: [
|
||||
param('resource_slug').trim().escape().exists(),
|
||||
query('predefined').optional().isBoolean().toBoolean(),
|
||||
query('builtin').optional().isBoolean().toBoolean(),
|
||||
],
|
||||
async handler(req, res) {
|
||||
const { resource_slug: resourceSlug } = req.params;
|
||||
const { Resource } = req.models;
|
||||
|
||||
const resource = await Resource.query()
|
||||
.where('name', resourceSlug)
|
||||
.withGraphFetched('fields')
|
||||
.first();
|
||||
|
||||
if (!resource) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'RESOURCE.SLUG.NOT.FOUND', code: 200 }],
|
||||
});
|
||||
}
|
||||
return res.status(200).send({
|
||||
resource_fields: resource.fields,
|
||||
resource_slug: resourceSlug,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
476
server/src/api/controllers/Sales/PaymentReceives.ts
Normal file
476
server/src/api/controllers/Sales/PaymentReceives.ts
Normal file
@@ -0,0 +1,476 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { check, param, query, ValidationChain, matchedData } from 'express-validator';
|
||||
import { difference } from 'lodash';
|
||||
import { Inject, Service } from 'typedi';
|
||||
import { IPaymentReceive, IPaymentReceiveOTD } from 'interfaces';
|
||||
import BaseController from 'api/controllers/BaseController';
|
||||
import validateMiddleware from 'api/middleware/validateMiddleware';
|
||||
import asyncMiddleware from 'api/middleware/asyncMiddleware';
|
||||
import PaymentReceiveService from 'services/Sales/PaymentsReceives';
|
||||
import SaleInvoiceService from 'services/Sales/SalesInvoices';
|
||||
import AccountsService from 'services/Accounts/AccountsService';
|
||||
import DynamicListing from 'services/DynamicListing/DynamicListing';
|
||||
import DynamicListingBuilder from 'services/DynamicListing/DynamicListingBuilder';
|
||||
import { dynamicListingErrorsToResponse } from 'services/DynamicListing/hasDynamicListing';
|
||||
|
||||
/**
|
||||
* Payments receives controller.
|
||||
* @service
|
||||
*/
|
||||
@Service()
|
||||
export default class PaymentReceivesController extends BaseController {
|
||||
@Inject()
|
||||
paymentReceiveService: PaymentReceiveService;
|
||||
|
||||
@Inject()
|
||||
accountsService: AccountsService;
|
||||
|
||||
@Inject()
|
||||
saleInvoiceService: SaleInvoiceService;
|
||||
|
||||
/**
|
||||
* Router constructor.
|
||||
*/
|
||||
router() {
|
||||
const router = Router();
|
||||
|
||||
router.post(
|
||||
'/:id',
|
||||
this.editPaymentReceiveValidation,
|
||||
validateMiddleware,
|
||||
asyncMiddleware(this.validatePaymentReceiveExistance.bind(this)),
|
||||
asyncMiddleware(this.validatePaymentReceiveNoExistance.bind(this)),
|
||||
asyncMiddleware(this.validateCustomerExistance.bind(this)),
|
||||
asyncMiddleware(this.validateDepositAccount.bind(this)),
|
||||
asyncMiddleware(this.validateInvoicesIDs.bind(this)),
|
||||
asyncMiddleware(this.validateEntriesIdsExistance.bind(this)),
|
||||
asyncMiddleware(this.validateInvoicesPaymentsAmount.bind(this)),
|
||||
asyncMiddleware(this.editPaymentReceive.bind(this)),
|
||||
);
|
||||
router.post(
|
||||
'/',
|
||||
this.newPaymentReceiveValidation,
|
||||
validateMiddleware,
|
||||
asyncMiddleware(this.validatePaymentReceiveNoExistance.bind(this)),
|
||||
asyncMiddleware(this.validateCustomerExistance.bind(this)),
|
||||
asyncMiddleware(this.validateDepositAccount.bind(this)),
|
||||
asyncMiddleware(this.validateInvoicesIDs.bind(this)),
|
||||
asyncMiddleware(this.validateInvoicesPaymentsAmount.bind(this)),
|
||||
asyncMiddleware(this.newPaymentReceive.bind(this)),
|
||||
);
|
||||
router.get(
|
||||
'/:id',
|
||||
this.paymentReceiveValidation,
|
||||
validateMiddleware,
|
||||
asyncMiddleware(this.validatePaymentReceiveExistance.bind(this)),
|
||||
asyncMiddleware(this.getPaymentReceive.bind(this))
|
||||
);
|
||||
router.get(
|
||||
'/',
|
||||
this.validatePaymentReceiveList,
|
||||
validateMiddleware,
|
||||
asyncMiddleware(this.getPaymentReceiveList.bind(this)),
|
||||
);
|
||||
router.delete(
|
||||
'/:id',
|
||||
this.paymentReceiveValidation,
|
||||
validateMiddleware,
|
||||
asyncMiddleware(this.validatePaymentReceiveExistance.bind(this)),
|
||||
asyncMiddleware(this.deletePaymentReceive.bind(this)),
|
||||
);
|
||||
return router;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payment receive schema.
|
||||
* @return {Array}
|
||||
*/
|
||||
get paymentReceiveSchema(): ValidationChain[] {
|
||||
return [
|
||||
check('customer_id').exists().isNumeric().toInt(),
|
||||
check('payment_date').exists(),
|
||||
check('reference_no').optional(),
|
||||
check('deposit_account_id').exists().isNumeric().toInt(),
|
||||
check('payment_receive_no').exists().trim().escape(),
|
||||
check('statement').optional().trim().escape(),
|
||||
|
||||
check('entries').isArray({ min: 1 }),
|
||||
|
||||
check('entries.*.invoice_id').exists().isNumeric().toInt(),
|
||||
check('entries.*.payment_amount').exists().isNumeric().toInt(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Payment receive list validation schema.
|
||||
*/
|
||||
get validatePaymentReceiveList(): ValidationChain[] {
|
||||
return [
|
||||
query('custom_view_id').optional().isNumeric().toInt(),
|
||||
query('stringified_filter_roles').optional().isJSON(),
|
||||
query('column_sort_by').optional(),
|
||||
query('sort_order').optional().isIn(['desc', 'asc']),
|
||||
query('page').optional().isNumeric().toInt(),
|
||||
query('page_size').optional().isNumeric().toInt(),
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate payment receive parameters.
|
||||
*/
|
||||
get paymentReceiveValidation() {
|
||||
return [param('id').exists().isNumeric().toInt()];
|
||||
}
|
||||
|
||||
/**
|
||||
* New payment receive validation schema.
|
||||
* @return {Array}
|
||||
*/
|
||||
get newPaymentReceiveValidation() {
|
||||
return [...this.paymentReceiveSchema];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the payment receive number existance.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {Function} next
|
||||
*/
|
||||
async validatePaymentReceiveNoExistance(req: Request, res: Response, next: Function) {
|
||||
const tenantId = req.tenantId;
|
||||
const isPaymentNoExists = await this.paymentReceiveService.isPaymentReceiveNoExists(
|
||||
tenantId,
|
||||
req.body.payment_receive_no,
|
||||
req.params.id,
|
||||
);
|
||||
if (isPaymentNoExists) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'PAYMENT.RECEIVE.NUMBER.EXISTS', code: 400 }],
|
||||
});
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the payment receive existance.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {Function} next
|
||||
*/
|
||||
async validatePaymentReceiveExistance(req: Request, res: Response, next: Function) {
|
||||
const tenantId = req.tenantId;
|
||||
const isPaymentNoExists = await this.paymentReceiveService
|
||||
.isPaymentReceiveExists(
|
||||
tenantId,
|
||||
req.params.id
|
||||
);
|
||||
if (!isPaymentNoExists) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'PAYMENT.RECEIVE.NOT.EXISTS', code: 600 }],
|
||||
});
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the deposit account id existance.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {Function} next
|
||||
*/
|
||||
async validateDepositAccount(req: Request, res: Response, next: Function) {
|
||||
const tenantId = req.tenantId;
|
||||
const isDepositAccExists = await this.accountsService.isAccountExists(
|
||||
tenantId,
|
||||
req.body.deposit_account_id
|
||||
);
|
||||
if (!isDepositAccExists) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'DEPOSIT.ACCOUNT.NOT.EXISTS', code: 300 }],
|
||||
});
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the `customer_id` existance.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {Function} next
|
||||
*/
|
||||
async validateCustomerExistance(req: Request, res: Response, next: Function) {
|
||||
const { Customer } = req.models;
|
||||
|
||||
const isCustomerExists = await Customer.query().findById(req.body.customer_id);
|
||||
|
||||
if (!isCustomerExists) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'CUSTOMER.ID.NOT.EXISTS', code: 200 }],
|
||||
});
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the invoices IDs existance.
|
||||
* @param {Request} req -
|
||||
* @param {Response} res -
|
||||
* @param {Function} next -
|
||||
*/
|
||||
async validateInvoicesIDs(req: Request, res: Response, next: Function) {
|
||||
const paymentReceive = { ...req.body };
|
||||
const { tenantId } = req;
|
||||
const invoicesIds = paymentReceive.entries
|
||||
.map((e) => e.invoice_id);
|
||||
|
||||
const notFoundInvoicesIDs = await this.saleInvoiceService.isInvoicesExist(
|
||||
tenantId,
|
||||
invoicesIds,
|
||||
paymentReceive.customer_id,
|
||||
);
|
||||
if (notFoundInvoicesIDs.length > 0) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'INVOICES.IDS.NOT.FOUND', code: 500 }],
|
||||
});
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates entries invoice payment amount.
|
||||
* @param {Request} req -
|
||||
* @param {Response} res -
|
||||
* @param {Function} next -
|
||||
*/
|
||||
async validateInvoicesPaymentsAmount(req: Request, res: Response, next: Function) {
|
||||
const { SaleInvoice } = req.models;
|
||||
const invoicesIds = req.body.entries.map((e) => e.invoice_id);
|
||||
|
||||
const storedInvoices = await SaleInvoice.query()
|
||||
.whereIn('id', invoicesIds);
|
||||
|
||||
const storedInvoicesMap = new Map(
|
||||
storedInvoices.map((invoice) => [invoice.id, invoice])
|
||||
);
|
||||
const hasWrongPaymentAmount: any[] = [];
|
||||
|
||||
req.body.entries.forEach((entry, index: number) => {
|
||||
const entryInvoice = storedInvoicesMap.get(entry.invoice_id);
|
||||
const { dueAmount } = entryInvoice;
|
||||
|
||||
if (dueAmount < entry.payment_amount) {
|
||||
hasWrongPaymentAmount.push({ index, due_amount: dueAmount });
|
||||
}
|
||||
});
|
||||
if (hasWrongPaymentAmount.length > 0) {
|
||||
return res.status(400).send({
|
||||
errors: [
|
||||
{
|
||||
type: 'INVOICE.PAYMENT.AMOUNT',
|
||||
code: 200,
|
||||
indexes: hasWrongPaymentAmount,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the payment receive entries IDs existance.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @return {Response}
|
||||
*/
|
||||
async validateEntriesIdsExistance(req: Request, res: Response, next: Function) {
|
||||
const paymentReceive = { id: req.params.id, ...req.body };
|
||||
const entriesIds = paymentReceive.entries
|
||||
.filter(entry => entry.id)
|
||||
.map(entry => entry.id);
|
||||
|
||||
const { PaymentReceiveEntry } = req.models;
|
||||
|
||||
const storedEntries = await PaymentReceiveEntry.query()
|
||||
.where('payment_receive_id', paymentReceive.id);
|
||||
|
||||
const storedEntriesIds = storedEntries.map((entry) => entry.id);
|
||||
const notFoundEntriesIds = difference(entriesIds, storedEntriesIds);
|
||||
|
||||
if (notFoundEntriesIds.length > 0) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'ENTEIES.IDS.NOT.FOUND', code: 800 }],
|
||||
});
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Records payment receive to the given customer with associated invoices.
|
||||
*/
|
||||
async newPaymentReceive(req: Request, res: Response) {
|
||||
const { tenantId } = req;
|
||||
const paymentReceive: IPaymentReceiveOTD = matchedData(req, {
|
||||
locations: ['body'],
|
||||
includeOptionals: true,
|
||||
});
|
||||
|
||||
const storedPaymentReceive = await this.paymentReceiveService
|
||||
.createPaymentReceive(
|
||||
tenantId,
|
||||
paymentReceive,
|
||||
);
|
||||
return res.status(200).send({ id: storedPaymentReceive.id });
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit payment receive validation.
|
||||
*/
|
||||
get editPaymentReceiveValidation() {
|
||||
return [
|
||||
param('id').exists().isNumeric().toInt(),
|
||||
...this.paymentReceiveSchema,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit the given payment receive.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @return {Response}
|
||||
*/
|
||||
async editPaymentReceive(req: Request, res: Response) {
|
||||
const { tenantId } = req;
|
||||
const { id: paymentReceiveId } = req.params;
|
||||
const { PaymentReceive } = req.models;
|
||||
|
||||
const paymentReceive: IPaymentReceiveOTD = matchedData(req, {
|
||||
locations: ['body'],
|
||||
});
|
||||
|
||||
// Retrieve the payment receive before updating.
|
||||
const oldPaymentReceive: IPaymentReceive = await PaymentReceive.query()
|
||||
.where('id', paymentReceiveId)
|
||||
.withGraphFetched('entries')
|
||||
.first();
|
||||
|
||||
await this.paymentReceiveService.editPaymentReceive(
|
||||
tenantId,
|
||||
paymentReceiveId,
|
||||
paymentReceive,
|
||||
oldPaymentReceive,
|
||||
);
|
||||
return res.status(200).send({ id: paymentReceiveId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Delets the given payment receive id.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async deletePaymentReceive(req: Request, res: Response) {
|
||||
const { tenantId } = req;
|
||||
const { id: paymentReceiveId } = req.params;
|
||||
const { PaymentReceive } = req.models;
|
||||
|
||||
const storedPaymentReceive = await PaymentReceive.query()
|
||||
.where('id', paymentReceiveId)
|
||||
.withGraphFetched('entries')
|
||||
.first();
|
||||
|
||||
await this.paymentReceiveService.deletePaymentReceive(
|
||||
tenantId,
|
||||
paymentReceiveId,
|
||||
storedPaymentReceive
|
||||
);
|
||||
return res.status(200).send({ id: paymentReceiveId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the given payment receive details.
|
||||
* @asycn
|
||||
* @param {Request} req -
|
||||
* @param {Response} res -
|
||||
*/
|
||||
async getPaymentReceive(req: Request, res: Response) {
|
||||
const { id: paymentReceiveId } = req.params;
|
||||
const paymentReceive = await PaymentReceiveService.getPaymentReceive(
|
||||
paymentReceiveId
|
||||
);
|
||||
return res.status(200).send({ paymentReceive });
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve payment receive list with pagination metadata.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @return {Response}
|
||||
*/
|
||||
async getPaymentReceiveList(req: Request, res: Response) {
|
||||
const filter = {
|
||||
filter_roles: [],
|
||||
sort_order: 'asc',
|
||||
page: 1,
|
||||
page_size: 10,
|
||||
...req.query,
|
||||
};
|
||||
if (filter.stringified_filter_roles) {
|
||||
filter.filter_roles = JSON.parse(filter.stringified_filter_roles);
|
||||
}
|
||||
const { Resource, PaymentReceive, View, Bill } = req.models;
|
||||
const resource = await Resource.query()
|
||||
.remember()
|
||||
.where('name', 'payment_receives')
|
||||
.withGraphFetched('fields')
|
||||
.first();
|
||||
|
||||
if (!resource) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'PAYMENT_RECEIVES_RESOURCE_NOT_FOUND', code: 200 }],
|
||||
});
|
||||
}
|
||||
const viewMeta = await View.query()
|
||||
.modify('allMetadata')
|
||||
.modify('specificOrFavourite', filter.custom_view_id)
|
||||
.where('resource_id', resource.id)
|
||||
.first();
|
||||
|
||||
const listingBuilder = new DynamicListingBuilder();
|
||||
const errorReasons = [];
|
||||
|
||||
listingBuilder.addModelClass(Bill);
|
||||
listingBuilder.addCustomViewId(filter.custom_view_id);
|
||||
listingBuilder.addFilterRoles(filter.filter_roles);
|
||||
listingBuilder.addSortBy(filter.sort_by, filter.sort_order);
|
||||
listingBuilder.addView(viewMeta);
|
||||
|
||||
const dynamicListing = new DynamicListing(listingBuilder);
|
||||
|
||||
if (dynamicListing instanceof Error) {
|
||||
const errors = dynamicListingErrorsToResponse(dynamicListing);
|
||||
errorReasons.push(...errors);
|
||||
}
|
||||
if (errorReasons.length > 0) {
|
||||
return res.status(400).send({ errors: errorReasons });
|
||||
}
|
||||
const paymentReceives = await PaymentReceive.query().onBuild((builder) => {
|
||||
builder.withGraphFetched('customer');
|
||||
builder.withGraphFetched('depositAccount');
|
||||
|
||||
dynamicListing.buildQuery()(builder);
|
||||
return builder;
|
||||
}).pagination(filter.page - 1, filter.page_size);
|
||||
|
||||
return res.status(200).send({
|
||||
payment_receives: {
|
||||
...paymentReceives,
|
||||
...(viewMeta
|
||||
? {
|
||||
viewMeta: {
|
||||
customViewId: viewMeta.id,
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
369
server/src/api/controllers/Sales/SalesEstimates.ts
Normal file
369
server/src/api/controllers/Sales/SalesEstimates.ts
Normal file
@@ -0,0 +1,369 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { check, param, query, matchedData } from 'express-validator';
|
||||
import { Inject, Service } from 'typedi';
|
||||
import { ISaleEstimate, ISaleEstimateOTD } from 'interfaces';
|
||||
import BaseController from 'api/controllers/BaseController'
|
||||
import validateMiddleware from 'api/middleware/validateMiddleware';
|
||||
import asyncMiddleware from 'api/middleware/asyncMiddleware';
|
||||
import SaleEstimateService from 'services/Sales/SalesEstimate';
|
||||
import ItemsService from 'services/Items/ItemsService';
|
||||
import DynamicListingBuilder from 'services/DynamicListing/DynamicListingBuilder';
|
||||
import DynamicListing from 'services/DynamicListing/DynamicListing';
|
||||
|
||||
@Service()
|
||||
export default class SalesEstimatesController extends BaseController {
|
||||
@Inject()
|
||||
saleEstimateService: SaleEstimateService;
|
||||
|
||||
@Inject()
|
||||
itemsService: ItemsService;
|
||||
|
||||
/**
|
||||
* Router constructor.
|
||||
*/
|
||||
router() {
|
||||
const router = Router();
|
||||
|
||||
router.post(
|
||||
'/',
|
||||
this.estimateValidationSchema,
|
||||
validateMiddleware,
|
||||
asyncMiddleware(this.validateEstimateCustomerExistance.bind(this)),
|
||||
asyncMiddleware(this.validateEstimateNumberExistance.bind(this)),
|
||||
asyncMiddleware(this.validateEstimateEntriesItemsExistance.bind(this)),
|
||||
asyncMiddleware(this.newEstimate.bind(this))
|
||||
);
|
||||
router.post(
|
||||
'/:id', [
|
||||
...this.validateSpecificEstimateSchema,
|
||||
...this.estimateValidationSchema,
|
||||
],
|
||||
validateMiddleware,
|
||||
asyncMiddleware(this.validateEstimateIdExistance.bind(this)),
|
||||
asyncMiddleware(this.validateEstimateCustomerExistance.bind(this)),
|
||||
asyncMiddleware(this.validateEstimateNumberExistance.bind(this)),
|
||||
asyncMiddleware(this.validateEstimateEntriesItemsExistance.bind(this)),
|
||||
asyncMiddleware(this.valdiateInvoiceEntriesIdsExistance.bind(this)),
|
||||
asyncMiddleware(this.editEstimate.bind(this))
|
||||
);
|
||||
router.delete(
|
||||
'/:id', [
|
||||
this.validateSpecificEstimateSchema,
|
||||
],
|
||||
validateMiddleware,
|
||||
asyncMiddleware(this.validateEstimateIdExistance.bind(this)),
|
||||
asyncMiddleware(this.deleteEstimate.bind(this))
|
||||
);
|
||||
router.get(
|
||||
'/:id',
|
||||
this.validateSpecificEstimateSchema,
|
||||
validateMiddleware,
|
||||
asyncMiddleware(this.validateEstimateIdExistance.bind(this)),
|
||||
asyncMiddleware(this.getEstimate.bind(this))
|
||||
);
|
||||
router.get(
|
||||
'/',
|
||||
this.validateEstimateListSchema,
|
||||
validateMiddleware,
|
||||
asyncMiddleware(this.getEstimates.bind(this))
|
||||
);
|
||||
return router;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate validation schema.
|
||||
*/
|
||||
get estimateValidationSchema() {
|
||||
return [
|
||||
check('customer_id').exists().isNumeric().toInt(),
|
||||
check('estimate_date').exists().isISO8601(),
|
||||
check('expiration_date').optional().isISO8601(),
|
||||
check('reference').optional(),
|
||||
check('estimate_number').exists().trim().escape(),
|
||||
|
||||
check('entries').exists().isArray({ min: 1 }),
|
||||
check('entries.*.index').exists().isNumeric().toInt(),
|
||||
check('entries.*.item_id').exists().isNumeric().toInt(),
|
||||
check('entries.*.description').optional().trim().escape(),
|
||||
check('entries.*.quantity').exists().isNumeric().toInt(),
|
||||
check('entries.*.rate').exists().isNumeric().toFloat(),
|
||||
check('entries.*.discount').optional().isNumeric().toFloat(),
|
||||
|
||||
check('note').optional().trim().escape(),
|
||||
check('terms_conditions').optional().trim().escape(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Specific sale estimate validation schema.
|
||||
*/
|
||||
get validateSpecificEstimateSchema() {
|
||||
return [
|
||||
param('id').exists().isNumeric().toInt(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sales estimates list validation schema.
|
||||
*/
|
||||
get validateEstimateListSchema() {
|
||||
return [
|
||||
query('custom_view_id').optional().isNumeric().toInt(),
|
||||
query('stringified_filter_roles').optional().isJSON(),
|
||||
query('column_sort_by').optional(),
|
||||
query('sort_order').optional().isIn(['desc', 'asc']),
|
||||
query('page').optional().isNumeric().toInt(),
|
||||
query('page_size').optional().isNumeric().toInt(),
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate whether the estimate customer exists on the storage.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {Function} next
|
||||
*/
|
||||
async validateEstimateCustomerExistance(req: Request, res: Response, next: Function) {
|
||||
const estimate = { ...req.body };
|
||||
const { Customer } = req.models
|
||||
|
||||
const foundCustomer = await Customer.query().findById(estimate.customer_id);
|
||||
|
||||
if (!foundCustomer) {
|
||||
return res.status(404).send({
|
||||
errors: [{ type: 'CUSTOMER.ID.NOT.FOUND', code: 200 }],
|
||||
});
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the estimate number unique on the storage.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {Function} next
|
||||
*/
|
||||
async validateEstimateNumberExistance(req: Request, res: Response, next: Function) {
|
||||
const estimate = { ...req.body };
|
||||
const { tenantId } = req;
|
||||
|
||||
const isEstNumberUnqiue = await this.saleEstimateService.isEstimateNumberUnique(
|
||||
tenantId,
|
||||
estimate.estimate_number,
|
||||
req.params.id,
|
||||
);
|
||||
if (isEstNumberUnqiue) {
|
||||
return res.boom.badRequest(null, {
|
||||
errors: [{ type: 'ESTIMATE.NUMBER.IS.NOT.UNQIUE', code: 300 }],
|
||||
});
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the estimate entries items ids existance on the storage.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {Function} next
|
||||
*/
|
||||
async validateEstimateEntriesItemsExistance(req: Request, res: Response, next: Function) {
|
||||
const tenantId = req.tenantId;
|
||||
const estimate = { ...req.body };
|
||||
const estimateItemsIds = estimate.entries.map(e => e.item_id);
|
||||
|
||||
// Validate items ids in estimate entries exists.
|
||||
const notFoundItemsIds = await this.itemsService.isItemsIdsExists(tenantId, estimateItemsIds);
|
||||
|
||||
if (notFoundItemsIds.length > 0) {
|
||||
return res.boom.badRequest(null, {
|
||||
errors: [{ type: 'ITEMS.IDS.NOT.EXISTS', code: 400 }],
|
||||
});
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate whether the sale estimate id exists on the storage.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {Function} next
|
||||
*/
|
||||
async validateEstimateIdExistance(req: Request, res: Response, next: Function) {
|
||||
const { id: estimateId } = req.params;
|
||||
const { tenantId } = req;
|
||||
|
||||
const storedEstimate = await this.saleEstimateService
|
||||
.getEstimate(tenantId, estimateId);
|
||||
|
||||
if (!storedEstimate) {
|
||||
return res.status(404).send({
|
||||
errors: [{ type: 'SALE.ESTIMATE.ID.NOT.FOUND', code: 200 }],
|
||||
});
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate sale invoice entries ids existance on the storage.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {Function} next
|
||||
*/
|
||||
async valdiateInvoiceEntriesIdsExistance(req: Request, res: Response, next: Function) {
|
||||
const { ItemEntry } = req.models;
|
||||
|
||||
const { id: saleInvoiceId } = req.params;
|
||||
const saleInvoice = { ...req.body };
|
||||
const entriesIds = saleInvoice.entries
|
||||
.filter(e => e.id)
|
||||
.map((e) => e.id);
|
||||
|
||||
const foundEntries = await ItemEntry.query()
|
||||
.whereIn('id', entriesIds)
|
||||
.where('reference_type', 'SaleInvoice')
|
||||
.where('reference_id', saleInvoiceId);
|
||||
|
||||
if (foundEntries.length > 0) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'ENTRIES.IDS.NOT.EXISTS', code: 300 }],
|
||||
});
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle create a new estimate with associated entries.
|
||||
* @param {Request} req -
|
||||
* @param {Response} res -
|
||||
* @return {Response} res -
|
||||
*/
|
||||
async newEstimate(req: Request, res: Response) {
|
||||
const { tenantId } = req;
|
||||
const estimateOTD: ISaleEstimateOTD = matchedData(req, {
|
||||
locations: ['body'],
|
||||
includeOptionals: true,
|
||||
});
|
||||
const storedEstimate = await this.saleEstimateService
|
||||
.createEstimate(tenantId, estimateOTD);
|
||||
|
||||
return res.status(200).send({ id: storedEstimate.id });
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle update estimate details with associated entries.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async editEstimate(req: Request, res: Response) {
|
||||
const { id: estimateId } = req.params;
|
||||
const { tenantId } = req;
|
||||
|
||||
const estimateOTD: ISaleEstimateOTD = matchedData(req, {
|
||||
locations: ['body'],
|
||||
includeOptionals: true,
|
||||
});
|
||||
// Update estimate with associated estimate entries.
|
||||
await this.saleEstimateService.editEstimate(tenantId, estimateId, estimateOTD);
|
||||
|
||||
return res.status(200).send({ id: estimateId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the given estimate with associated entries.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async deleteEstimate(req: Request, res: Response) {
|
||||
const { id: estimateId } = req.params;
|
||||
const { tenantId } = req;
|
||||
|
||||
await this.saleEstimateService.deleteEstimate(tenantId, estimateId);
|
||||
|
||||
return res.status(200).send({ id: estimateId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the given estimate with associated entries.
|
||||
*/
|
||||
async getEstimate(req: Request, res: Response) {
|
||||
const { id: estimateId } = req.params;
|
||||
const { tenantId } = req;
|
||||
|
||||
const estimate = await this.saleEstimateService
|
||||
.getEstimateWithEntries(tenantId, estimateId);
|
||||
|
||||
return res.status(200).send({ estimate });
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve estimates with pagination metadata.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async getEstimates(req: Request, res: Response) {
|
||||
const filter = {
|
||||
filter_roles: [],
|
||||
sort_order: 'asc',
|
||||
page: 1,
|
||||
page_size: 10,
|
||||
...req.query,
|
||||
};
|
||||
if (filter.stringified_filter_roles) {
|
||||
filter.filter_roles = JSON.parse(filter.stringified_filter_roles);
|
||||
}
|
||||
const { SaleEstimate, Resource, View } = req.models;
|
||||
const resource = await Resource.query()
|
||||
.remember()
|
||||
.where('name', 'sales_estimates')
|
||||
.withGraphFetched('fields')
|
||||
.first();
|
||||
|
||||
if (!resource) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'RESOURCE.NOT.FOUND', code: 200, }],
|
||||
});
|
||||
}
|
||||
const viewMeta = await View.query()
|
||||
.modify('allMetadata')
|
||||
.modify('specificOrFavourite', filter.custom_view_id)
|
||||
.where('resource_id', resource.id)
|
||||
.first();
|
||||
|
||||
const listingBuilder = new DynamicListingBuilder();
|
||||
const errorReasons = [];
|
||||
|
||||
listingBuilder.addView(viewMeta);
|
||||
listingBuilder.addModelClass(SaleEstimate);
|
||||
listingBuilder.addCustomViewId(filter.custom_view_id);
|
||||
listingBuilder.addFilterRoles(filter.filter_roles);
|
||||
listingBuilder.addSortBy(filter.sort_by, filter.sort_order);
|
||||
|
||||
const dynamicListing = new DynamicListing(listingBuilder);
|
||||
|
||||
if (dynamicListing instanceof Error) {
|
||||
const errors = dynamicListingErrorsToResponse(dynamicListing);
|
||||
errorReasons.push(...errors);
|
||||
}
|
||||
if (errorReasons.length > 0) {
|
||||
return res.status(400).send({ errors: errorReasons });
|
||||
}
|
||||
|
||||
const salesEstimates = await SaleEstimate.query().onBuild((builder) => {
|
||||
dynamicListing.buildQuery()(builder);
|
||||
builder.withGraphFetched('customer');
|
||||
return builder;
|
||||
}).pagination(filter.page - 1, filter.page_size);
|
||||
|
||||
return res.status(200).send({
|
||||
sales_estimates: {
|
||||
...salesEstimates,
|
||||
...(viewMeta ? {
|
||||
viewMeta: {
|
||||
custom_view_id: viewMeta.id,
|
||||
},
|
||||
} : {}),
|
||||
},
|
||||
|
||||
});
|
||||
}
|
||||
};
|
||||
483
server/src/api/controllers/Sales/SalesInvoices.ts
Normal file
483
server/src/api/controllers/Sales/SalesInvoices.ts
Normal file
@@ -0,0 +1,483 @@
|
||||
import express from 'express';
|
||||
import { check, param, query, matchedData } from 'express-validator';
|
||||
import { difference } from 'lodash';
|
||||
import { raw } from 'objection';
|
||||
import { Service, Inject } from 'typedi';
|
||||
import validateMiddleware from 'api/middleware/validateMiddleware';
|
||||
import asyncMiddleware from 'api/middleware/asyncMiddleware';
|
||||
import SaleInvoiceService from 'services/Sales/SalesInvoices';
|
||||
import ItemsService from 'services/Items/ItemsService';
|
||||
import DynamicListing from 'services/DynamicListing/DynamicListing';
|
||||
import DynamicListingBuilder from 'services/DynamicListing/DynamicListingBuilder';
|
||||
import { dynamicListingErrorsToResponse } from 'services/DynamicListing/hasDynamicListing';
|
||||
import { ISaleInvoiceOTD } from 'interfaces';
|
||||
|
||||
@Service()
|
||||
export default class SaleInvoicesController {
|
||||
@Inject()
|
||||
itemsService: ItemsService;
|
||||
|
||||
@Inject()
|
||||
saleInvoiceService: SaleInvoiceService;
|
||||
|
||||
/**
|
||||
* Router constructor.
|
||||
*/
|
||||
router() {
|
||||
const router = express.Router();
|
||||
|
||||
router.post(
|
||||
'/',
|
||||
this.saleInvoiceValidationSchema,
|
||||
validateMiddleware,
|
||||
asyncMiddleware(this.validateInvoiceCustomerExistance.bind(this)),
|
||||
asyncMiddleware(this.validateInvoiceNumberUnique.bind(this)),
|
||||
asyncMiddleware(this.validateInvoiceItemsIdsExistance.bind(this)),
|
||||
asyncMiddleware(this.validateNonSellableEntriesItems.bind(this)),
|
||||
asyncMiddleware(this.newSaleInvoice.bind(this))
|
||||
);
|
||||
router.post(
|
||||
'/:id',
|
||||
[
|
||||
...this.saleInvoiceValidationSchema,
|
||||
...this.specificSaleInvoiceValidation,
|
||||
],
|
||||
validateMiddleware,
|
||||
asyncMiddleware(this.validateInvoiceExistance.bind(this)),
|
||||
asyncMiddleware(this.validateInvoiceCustomerExistance.bind(this)),
|
||||
asyncMiddleware(this.validateInvoiceNumberUnique.bind(this)),
|
||||
asyncMiddleware(this.validateInvoiceItemsIdsExistance.bind(this)),
|
||||
asyncMiddleware(this.valdiateInvoiceEntriesIdsExistance.bind(this)),
|
||||
asyncMiddleware(this.validateEntriesIdsExistance.bind(this)),
|
||||
asyncMiddleware(this.validateNonSellableEntriesItems.bind(this)),
|
||||
asyncMiddleware(this.editSaleInvoice.bind(this))
|
||||
);
|
||||
router.delete(
|
||||
'/:id',
|
||||
this.specificSaleInvoiceValidation,
|
||||
validateMiddleware,
|
||||
asyncMiddleware(this.validateInvoiceExistance.bind(this)),
|
||||
asyncMiddleware(this.deleteSaleInvoice.bind(this))
|
||||
);
|
||||
router.get(
|
||||
'/due_invoices',
|
||||
this.dueSalesInvoicesListValidationSchema,
|
||||
asyncMiddleware(this.getDueSalesInvoice.bind(this)),
|
||||
);
|
||||
router.get(
|
||||
'/:id',
|
||||
this.specificSaleInvoiceValidation,
|
||||
validateMiddleware,
|
||||
asyncMiddleware(this.validateInvoiceExistance.bind(this)),
|
||||
asyncMiddleware(this.getSaleInvoice.bind(this))
|
||||
);
|
||||
router.get(
|
||||
'/',
|
||||
this.saleInvoiceListValidationSchema,
|
||||
asyncMiddleware(this.getSalesInvoices.bind(this))
|
||||
)
|
||||
return router;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sale invoice validation schema.
|
||||
*/
|
||||
get saleInvoiceValidationSchema() {
|
||||
return [
|
||||
check('customer_id').exists().isNumeric().toInt(),
|
||||
check('invoice_date').exists().isISO8601(),
|
||||
check('due_date').exists().isISO8601(),
|
||||
check('invoice_no').exists().trim().escape(),
|
||||
check('reference_no').optional().trim().escape(),
|
||||
check('status').exists().trim().escape(),
|
||||
|
||||
check('invoice_message').optional().trim().escape(),
|
||||
check('terms_conditions').optional().trim().escape(),
|
||||
|
||||
check('entries').exists().isArray({ min: 1 }),
|
||||
|
||||
check('entries.*.index').exists().isNumeric().toInt(),
|
||||
check('entries.*.item_id').exists().isNumeric().toInt(),
|
||||
check('entries.*.rate').exists().isNumeric().toFloat(),
|
||||
check('entries.*.quantity').exists().isNumeric().toFloat(),
|
||||
check('entries.*.discount').optional().isNumeric().toFloat(),
|
||||
check('entries.*.description').optional().trim().escape(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Specific sale invoice validation schema.
|
||||
*/
|
||||
get specificSaleInvoiceValidation() {
|
||||
return [param('id').exists().isNumeric().toInt()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sales invoices list validation schema.
|
||||
*/
|
||||
get saleInvoiceListValidationSchema() {
|
||||
return [
|
||||
query('custom_view_id').optional().isNumeric().toInt(),
|
||||
query('stringified_filter_roles').optional().isJSON(),
|
||||
query('column_sort_by').optional(),
|
||||
query('sort_order').optional().isIn(['desc', 'asc']),
|
||||
query('page').optional().isNumeric().toInt(),
|
||||
query('page_size').optional().isNumeric().toInt(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Due sale invoice list validation schema.
|
||||
*/
|
||||
get dueSalesInvoicesListValidationSchema() {
|
||||
return [
|
||||
query('customer_id').optional().isNumeric().toInt(),
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate whether sale invoice customer exists on the storage.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {Function} next
|
||||
*/
|
||||
async validateInvoiceCustomerExistance(req: Request, res: Response, next: Function) {
|
||||
const saleInvoice = { ...req.body };
|
||||
const { Customer } = req.models;
|
||||
|
||||
const isCustomerIDExists = await Customer.query().findById(saleInvoice.customer_id);
|
||||
|
||||
if (!isCustomerIDExists) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'CUSTOMER.ID.NOT.EXISTS', code: 200 }],
|
||||
});
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate whether sale invoice items ids esits on the storage.
|
||||
* @param {Request} req -
|
||||
* @param {Response} res -
|
||||
* @param {Function} next -
|
||||
*/
|
||||
async validateInvoiceItemsIdsExistance(req: Request, res: Response, next: Function) {
|
||||
const { tenantId } = req;
|
||||
const saleInvoice = { ...req.body };
|
||||
const entriesItemsIds = saleInvoice.entries.map((e) => e.item_id);
|
||||
|
||||
const isItemsIdsExists = await this.itemsService.isItemsIdsExists(
|
||||
tenantId, entriesItemsIds,
|
||||
);
|
||||
if (isItemsIdsExists.length > 0) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'ITEMS.IDS.NOT.EXISTS', code: 300 }],
|
||||
});
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Validate whether sale invoice number unqiue on the storage.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {Function} next
|
||||
*/
|
||||
async validateInvoiceNumberUnique(req: Request, res: Response, next: Function) {
|
||||
const { tenantId } = req;
|
||||
const saleInvoice = { ...req.body };
|
||||
|
||||
const isInvoiceNoExists = await this.saleInvoiceService.isSaleInvoiceNumberExists(
|
||||
tenantId,
|
||||
saleInvoice.invoice_no,
|
||||
req.params.id
|
||||
);
|
||||
if (isInvoiceNoExists) {
|
||||
return res
|
||||
.status(400)
|
||||
.send({
|
||||
errors: [{ type: 'SALE.INVOICE.NUMBER.IS.EXISTS', code: 200 }],
|
||||
});
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate whether sale invoice exists on the storage.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {Function} next
|
||||
*/
|
||||
async validateInvoiceExistance(req: Request, res: Response, next: Function) {
|
||||
const { id: saleInvoiceId } = req.params;
|
||||
const { tenantId } = req;
|
||||
|
||||
const isSaleInvoiceExists = await this.saleInvoiceService.isSaleInvoiceExists(
|
||||
tenantId, saleInvoiceId,
|
||||
);
|
||||
if (!isSaleInvoiceExists) {
|
||||
return res
|
||||
.status(404)
|
||||
.send({ errors: [{ type: 'SALE.INVOICE.NOT.FOUND', code: 200 }] });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate sale invoice entries ids existance on the storage.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {Function} next
|
||||
*/
|
||||
async valdiateInvoiceEntriesIdsExistance(req: Request, res: Response, next: Function) {
|
||||
const { tenantId } = req;
|
||||
const saleInvoice = { ...req.body };
|
||||
const entriesItemsIds = saleInvoice.entries.map((e) => e.item_id);
|
||||
|
||||
const isItemsIdsExists = await this.itemsService.isItemsIdsExists(
|
||||
tenantId, entriesItemsIds,
|
||||
);
|
||||
if (isItemsIdsExists.length > 0) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'ITEMS.IDS.NOT.EXISTS', code: 300 }],
|
||||
});
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate whether the sale estimate entries IDs exist on the storage.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {Function} next
|
||||
*/
|
||||
async validateEntriesIdsExistance(req: Request, res: Response, next: Function) {
|
||||
const { ItemEntry } = req.models;
|
||||
const { id: saleInvoiceId } = req.params;
|
||||
const saleInvoice = { ...req.body };
|
||||
|
||||
const entriesIds = saleInvoice.entries
|
||||
.filter(e => e.id)
|
||||
.map(e => e.id);
|
||||
|
||||
const storedEntries = await ItemEntry.query()
|
||||
.whereIn('reference_id', [saleInvoiceId])
|
||||
.whereIn('reference_type', ['SaleInvoice']);
|
||||
|
||||
const storedEntriesIds = storedEntries.map((entry) => entry.id);
|
||||
const notFoundEntriesIds = difference(
|
||||
entriesIds,
|
||||
storedEntriesIds,
|
||||
);
|
||||
if (notFoundEntriesIds.length > 0) {
|
||||
return res.boom.badRequest(null, {
|
||||
errors: [{ type: 'SALE.INVOICE.ENTRIES.IDS.NOT.FOUND', code: 500 }],
|
||||
});
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the entries items that not sellable.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {Function} next
|
||||
*/
|
||||
async validateNonSellableEntriesItems(req: Request, res: Response, next: Function) {
|
||||
const { Item } = req.models;
|
||||
const saleInvoice = { ...req.body };
|
||||
const itemsIds = saleInvoice.entries.map(e => e.item_id);
|
||||
|
||||
const sellableItems = await Item.query()
|
||||
.where('sellable', true)
|
||||
.whereIn('id', itemsIds);
|
||||
|
||||
const sellableItemsIds = sellableItems.map((item) => item.id);
|
||||
const notSellableItems = difference(itemsIds, sellableItemsIds);
|
||||
|
||||
if (notSellableItems.length > 0) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'NOT.SELLABLE.ITEMS', code: 600 }],
|
||||
});
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new sale invoice.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {Function} next
|
||||
*/
|
||||
async newSaleInvoice(req: Request, res: Response) {
|
||||
const { tenantId } = req;
|
||||
const saleInvoiceOTD: ISaleInvoiceOTD = matchedData(req, {
|
||||
locations: ['body'],
|
||||
includeOptionals: true
|
||||
});
|
||||
|
||||
// Creates a new sale invoice with associated entries.
|
||||
const storedSaleInvoice = await this.saleInvoiceService.createSaleInvoice(
|
||||
tenantId, saleInvoiceOTD,
|
||||
);
|
||||
return res.status(200).send({ id: storedSaleInvoice.id });
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit sale invoice details.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {Function} next
|
||||
*/
|
||||
async editSaleInvoice(req: Request, res: Response) {
|
||||
const { tenantId } = req;
|
||||
const { id: saleInvoiceId } = req.params;
|
||||
|
||||
const saleInvoiceOTD: ISaleInvoiceOTD = matchedData(req, {
|
||||
locations: ['body'],
|
||||
includeOptionals: true
|
||||
});
|
||||
// Update the given sale invoice details.
|
||||
await this.saleInvoiceService.editSaleInvoice(tenantId, saleInvoiceId, saleInvoiceOTD);
|
||||
|
||||
return res.status(200).send({ id: saleInvoiceId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the sale invoice with associated entries and journal transactions.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {Function} next
|
||||
*/
|
||||
async deleteSaleInvoice(req: Request, res: Response) {
|
||||
const { id: saleInvoiceId } = req.params;
|
||||
const { tenantId } = req;
|
||||
|
||||
// Deletes the sale invoice with associated entries and journal transaction.
|
||||
await this.saleInvoiceService.deleteSaleInvoice(tenantId, saleInvoiceId);
|
||||
|
||||
return res.status(200).send({ id: saleInvoiceId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the sale invoice with associated entries.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async getSaleInvoice(req: Request, res: Response) {
|
||||
const { id: saleInvoiceId } = req.params;
|
||||
const { tenantId } = req;
|
||||
|
||||
const saleInvoice = await this.saleInvoiceService.getSaleInvoiceWithEntries(
|
||||
tenantId, saleInvoiceId,
|
||||
);
|
||||
return res.status(200).send({ sale_invoice: saleInvoice });
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the due sales invoices for the given customer.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async getDueSalesInvoice(req: Request, res: Response) {
|
||||
const { Customer, SaleInvoice } = req.models;
|
||||
const { tenantId } = req;
|
||||
|
||||
const filter = {
|
||||
customer_id: null,
|
||||
...req.query,
|
||||
};
|
||||
if (filter.customer_id) {
|
||||
const foundCustomer = await Customer.query().findById(filter.customer_id);
|
||||
|
||||
if (!foundCustomer) {
|
||||
return res.status(200).send({
|
||||
errors: [{ type: 'CUSTOMER.NOT.FOUND', code: 200 }],
|
||||
});
|
||||
}
|
||||
}
|
||||
const dueSalesInvoices = await SaleInvoice.query().onBuild((query) => {
|
||||
query.where(raw('BALANCE - PAYMENT_AMOUNT > 0'));
|
||||
if (filter.customer_id) {
|
||||
query.where('customer_id', filter.customer_id);
|
||||
}
|
||||
});
|
||||
return res.status(200).send({
|
||||
due_sales_invoices: dueSalesInvoices,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve paginated sales invoices with custom view metadata.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {Function} next
|
||||
*/
|
||||
async getSalesInvoices(req, res) {
|
||||
const filter = {
|
||||
filter_roles: [],
|
||||
sort_order: 'asc',
|
||||
page: 1,
|
||||
page_size: 10,
|
||||
...req.query,
|
||||
};
|
||||
if (filter.stringified_filter_roles) {
|
||||
filter.filter_roles = JSON.parse(filter.stringified_filter_roles);
|
||||
}
|
||||
const { SaleInvoice, View, Resource } = req.models;
|
||||
const resource = await Resource.query()
|
||||
.remember()
|
||||
.where('name', 'sales_invoices')
|
||||
.withGraphFetched('fields')
|
||||
.first();
|
||||
|
||||
if (!resource) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'SALES_INVOICES_RESOURCE_NOT_FOUND', code: 200 }],
|
||||
});
|
||||
}
|
||||
const viewMeta = await View.query()
|
||||
.modify('allMetadata')
|
||||
.modify('specificOrFavourite', filter.custom_view_id)
|
||||
.where('resource_id', resource.id)
|
||||
.first();
|
||||
|
||||
const listingBuilder = new DynamicListingBuilder();
|
||||
const errorReasons = [];
|
||||
|
||||
listingBuilder.addModelClass(SaleInvoice);
|
||||
listingBuilder.addCustomViewId(filter.custom_view_id);
|
||||
listingBuilder.addFilterRoles(filter.filter_roles);
|
||||
listingBuilder.addSortBy(filter.sort_by, filter.sort_order);
|
||||
listingBuilder.addView(viewMeta);
|
||||
|
||||
const dynamicListing = new DynamicListing(listingBuilder);
|
||||
|
||||
if (dynamicListing instanceof Error) {
|
||||
const errors = dynamicListingErrorsToResponse(dynamicListing);
|
||||
errorReasons.push(...errors);
|
||||
}
|
||||
if (errorReasons.length > 0) {
|
||||
return res.status(400).send({ errors: errorReasons });
|
||||
}
|
||||
const salesInvoices = await SaleInvoice.query().onBuild((builder) => {
|
||||
builder.withGraphFetched('entries');
|
||||
builder.withGraphFetched('customer');
|
||||
dynamicListing.buildQuery()(builder);
|
||||
}).pagination(filter.page - 1, filter.page_size);
|
||||
|
||||
return res.status(200).send({
|
||||
sales_invoices: {
|
||||
...salesInvoices,
|
||||
...(viewMeta
|
||||
? {
|
||||
view_meta: {
|
||||
customViewId: viewMeta.id,
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
366
server/src/api/controllers/Sales/SalesReceipts.ts
Normal file
366
server/src/api/controllers/Sales/SalesReceipts.ts
Normal file
@@ -0,0 +1,366 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { check, param, query, matchedData } from 'express-validator';
|
||||
import { Inject, Service } from 'typedi';
|
||||
import validateMiddleware from 'api/middleware/validateMiddleware';
|
||||
import asyncMiddleware from 'api/middleware/asyncMiddleware';
|
||||
import AccountsService from 'services/Accounts/AccountsService';
|
||||
import ItemsService from 'services/Items/ItemsService';
|
||||
import SaleReceiptService from 'services/Sales/SalesReceipts';
|
||||
import DynamicListingBuilder from 'services/DynamicListing/DynamicListingBuilder';
|
||||
import DynamicListing from 'services/DynamicListing/DynamicListing';
|
||||
import {
|
||||
dynamicListingErrorsToResponse
|
||||
} from 'services/DynamicListing/HasDynamicListing';
|
||||
|
||||
@Service()
|
||||
export default class SalesReceiptsController {
|
||||
@Inject()
|
||||
saleReceiptService: SaleReceiptService;
|
||||
|
||||
@Inject()
|
||||
accountsService: AccountsService;
|
||||
|
||||
@Inject()
|
||||
itemsService: ItemsService;
|
||||
|
||||
/**
|
||||
* Router constructor.
|
||||
*/
|
||||
router() {
|
||||
const router = Router();
|
||||
|
||||
router.post(
|
||||
'/:id', [
|
||||
...this.specificReceiptValidationSchema,
|
||||
...this.salesReceiptsValidationSchema,
|
||||
],
|
||||
validateMiddleware,
|
||||
asyncMiddleware(this.validateSaleReceiptExistance.bind(this)),
|
||||
asyncMiddleware(this.validateReceiptCustomerExistance.bind(this)),
|
||||
asyncMiddleware(this.validateReceiptDepositAccountExistance.bind(this)),
|
||||
asyncMiddleware(this.validateReceiptItemsIdsExistance.bind(this)),
|
||||
asyncMiddleware(this.validateReceiptEntriesIds.bind(this)),
|
||||
asyncMiddleware(this.editSaleReceipt.bind(this))
|
||||
);
|
||||
router.post(
|
||||
'/',
|
||||
this.salesReceiptsValidationSchema,
|
||||
validateMiddleware,
|
||||
asyncMiddleware(this.validateReceiptCustomerExistance.bind(this)),
|
||||
asyncMiddleware(this.validateReceiptDepositAccountExistance.bind(this)),
|
||||
asyncMiddleware(this.validateReceiptItemsIdsExistance.bind(this)),
|
||||
asyncMiddleware(this.newSaleReceipt.bind(this))
|
||||
);
|
||||
router.delete(
|
||||
'/:id',
|
||||
this.specificReceiptValidationSchema,
|
||||
validateMiddleware,
|
||||
asyncMiddleware(this.validateSaleReceiptExistance.bind(this)),
|
||||
asyncMiddleware(this.deleteSaleReceipt.bind(this))
|
||||
);
|
||||
router.get(
|
||||
'/',
|
||||
this.listSalesReceiptsValidationSchema,
|
||||
validateMiddleware,
|
||||
asyncMiddleware(this.listingSalesReceipts.bind(this))
|
||||
);
|
||||
return router;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sales receipt validation schema.
|
||||
* @return {Array}
|
||||
*/
|
||||
get salesReceiptsValidationSchema() {
|
||||
return [
|
||||
check('customer_id').exists().isNumeric().toInt(),
|
||||
check('deposit_account_id').exists().isNumeric().toInt(),
|
||||
check('receipt_date').exists().isISO8601(),
|
||||
check('send_to_email').optional().isEmail(),
|
||||
check('reference_no').optional().trim().escape(),
|
||||
|
||||
check('entries').exists().isArray({ min: 1 }),
|
||||
|
||||
check('entries.*.id').optional({ nullable: true }).isNumeric().toInt(),
|
||||
check('entries.*.index').exists().isNumeric().toInt(),
|
||||
check('entries.*.item_id').exists().isNumeric().toInt(),
|
||||
check('entries.*.description').optional().trim().escape(),
|
||||
check('entries.*.quantity').exists().isNumeric().toInt(),
|
||||
check('entries.*.rate').exists().isNumeric().toInt(),
|
||||
check('entries.*.discount').optional().isNumeric().toInt(),
|
||||
|
||||
check('receipt_message').optional().trim().escape(),
|
||||
check('statement').optional().trim().escape(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Specific sale receipt validation schema.
|
||||
*/
|
||||
get specificReceiptValidationSchema() {
|
||||
return [
|
||||
param('id').exists().isNumeric().toInt()
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* List sales receipts validation schema.
|
||||
*/
|
||||
get listSalesReceiptsValidationSchema() {
|
||||
return [
|
||||
query('custom_view_id').optional().isNumeric().toInt(),
|
||||
query('stringified_filter_roles').optional().isJSON(),
|
||||
query('column_sort_by').optional(),
|
||||
query('sort_order').optional().isIn(['desc', 'asc']),
|
||||
query('page').optional().isNumeric().toInt(),
|
||||
query('page_size').optional().isNumeric().toInt(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate whether sale receipt exists on the storage.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async validateSaleReceiptExistance(req: Request, res: Response, next: Function) {
|
||||
const { tenantId } = req;
|
||||
const { id: saleReceiptId } = req.params;
|
||||
|
||||
const isSaleReceiptExists = await this.saleReceiptService
|
||||
.isSaleReceiptExists(
|
||||
tenantId,
|
||||
saleReceiptId,
|
||||
);
|
||||
if (!isSaleReceiptExists) {
|
||||
return res.status(404).send({
|
||||
errors: [{ type: 'SALE.RECEIPT.NOT.FOUND', code: 200 }],
|
||||
});
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate whether sale receipt customer exists on the storage.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {Function} next
|
||||
*/
|
||||
async validateReceiptCustomerExistance(req: Request, res: Response, next: Function) {
|
||||
const saleReceipt = { ...req.body };
|
||||
const { Customer } = req.models;
|
||||
|
||||
const foundCustomer = await Customer.query().findById(saleReceipt.customer_id);
|
||||
|
||||
if (!foundCustomer) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'CUSTOMER.ID.NOT.EXISTS', code: 200 }],
|
||||
});
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate whether sale receipt deposit account exists on the storage.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {Function} next
|
||||
*/
|
||||
async validateReceiptDepositAccountExistance(req: Request, res: Response, next: Function) {
|
||||
const { tenantId } = req;
|
||||
|
||||
const saleReceipt = { ...req.body };
|
||||
const isDepositAccountExists = await this.accountsService.isAccountExists(
|
||||
tenantId,
|
||||
saleReceipt.deposit_account_id
|
||||
);
|
||||
if (!isDepositAccountExists) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'DEPOSIT.ACCOUNT.NOT.EXISTS', code: 300 }],
|
||||
});
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate whether receipt items ids exist on the storage.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {Function} next
|
||||
*/
|
||||
async validateReceiptItemsIdsExistance(req: Request, res: Response, next: Function) {
|
||||
const { tenantId } = req;
|
||||
|
||||
const saleReceipt = { ...req.body };
|
||||
const estimateItemsIds = saleReceipt.entries.map((e) => e.item_id);
|
||||
|
||||
const notFoundItemsIds = await this.itemsService.isItemsIdsExists(
|
||||
tenantId,
|
||||
estimateItemsIds
|
||||
);
|
||||
if (notFoundItemsIds.length > 0) {
|
||||
return res.status(400).send({ errors: [{ type: 'ITEMS.IDS.NOT.EXISTS', code: 400 }] });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate receipt entries ids existance on the storage.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {Function} next
|
||||
*/
|
||||
async validateReceiptEntriesIds(req: Request, res: Response, next: Function) {
|
||||
const { tenantId } = req;
|
||||
|
||||
const saleReceipt = { ...req.body };
|
||||
const { id: saleReceiptId } = req.params;
|
||||
|
||||
// Validate the entries IDs that not stored or associated to the sale receipt.
|
||||
const notExistsEntriesIds = await this.saleReceiptService
|
||||
.isSaleReceiptEntriesIDsExists(
|
||||
tenantId,
|
||||
saleReceiptId,
|
||||
saleReceipt,
|
||||
);
|
||||
if (notExistsEntriesIds.length > 0) {
|
||||
return res.status(400).send({ errors: [{
|
||||
type: 'ENTRIES.IDS.NOT.FOUND',
|
||||
code: 500,
|
||||
}]
|
||||
});
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new receipt.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async newSaleReceipt(req: Request, res: Response) {
|
||||
const { tenantId } = req;
|
||||
|
||||
const saleReceipt = matchedData(req, {
|
||||
locations: ['body'],
|
||||
includeOptionals: true,
|
||||
});
|
||||
// Store the given sale receipt details with associated entries.
|
||||
const storedSaleReceipt = await this.saleReceiptService
|
||||
.createSaleReceipt(
|
||||
tenantId,
|
||||
saleReceipt,
|
||||
);
|
||||
return res.status(200).send({ id: storedSaleReceipt.id });
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the sale receipt with associated entries and journal transactions.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async deleteSaleReceipt(req: Request, res: Response) {
|
||||
const { tenantId } = req;
|
||||
const { id: saleReceiptId } = req.params;
|
||||
|
||||
// Deletes the sale receipt.
|
||||
await this.saleReceiptService.deleteSaleReceipt(tenantId, saleReceiptId);
|
||||
|
||||
return res.status(200).send({ id: saleReceiptId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit the sale receipt details with associated entries and re-write
|
||||
* journal transaction on the same date.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async editSaleReceipt(req: Request, res: Response) {
|
||||
const { tenantId } = req;
|
||||
|
||||
const { id: saleReceiptId } = req.params;
|
||||
const saleReceipt = { ...req.body };
|
||||
|
||||
const errorReasons = [];
|
||||
|
||||
// Handle all errors with reasons messages.
|
||||
if (errorReasons.length > 0) {
|
||||
return res.boom.badRequest(null, { errors: errorReasons });
|
||||
}
|
||||
// Update the given sale receipt details.
|
||||
await this.saleReceiptService.editSaleReceipt(
|
||||
tenantId,
|
||||
saleReceiptId,
|
||||
saleReceipt,
|
||||
);
|
||||
|
||||
return res.status(200).send();
|
||||
}
|
||||
|
||||
/**
|
||||
* Listing sales receipts.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async listingSalesReceipts(req: Request, res: Response) {
|
||||
const filter = {
|
||||
filter_roles: [],
|
||||
sort_order: 'asc',
|
||||
page: 1,
|
||||
page_size: 10,
|
||||
...req.query,
|
||||
};
|
||||
if (filter.stringified_filter_roles) {
|
||||
filter.filter_roles = JSON.parse(filter.stringified_filter_roles);
|
||||
}
|
||||
const { SaleReceipt, Resource, View } = req.models;
|
||||
const resource = await Resource.query()
|
||||
.remember()
|
||||
.where('name', 'sales_receipts')
|
||||
.withGraphFetched('fields')
|
||||
.first();
|
||||
|
||||
if (!resource) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'RESOURCE.NOT.FOUND', code: 200, }],
|
||||
});
|
||||
}
|
||||
const viewMeta = await View.query()
|
||||
.modify('allMetadata')
|
||||
.modify('specificOrFavourite', filter.custom_view_id)
|
||||
.where('resource_id', resource.id)
|
||||
.first();
|
||||
|
||||
const listingBuilder = new DynamicListingBuilder();
|
||||
const errorReasons = [];
|
||||
|
||||
listingBuilder.addView(viewMeta);
|
||||
listingBuilder.addModelClass(SaleReceipt);
|
||||
listingBuilder.addCustomViewId(filter.custom_view_id);
|
||||
listingBuilder.addFilterRoles(filter.filter_roles);
|
||||
listingBuilder.addSortBy(filter.sort_by, filter.sort_order);
|
||||
|
||||
const dynamicListing = new DynamicListing(listingBuilder);
|
||||
|
||||
if (dynamicListing instanceof Error) {
|
||||
const errors = dynamicListingErrorsToResponse(dynamicListing);
|
||||
errorReasons.push(...errors);
|
||||
}
|
||||
const salesReceipts = await SaleReceipt.query().onBuild((builder) => {
|
||||
builder.withGraphFetched('customer');
|
||||
builder.withGraphFetched('depositAccount');
|
||||
builder.withGraphFetched('entries');
|
||||
dynamicListing.buildQuery()(builder);
|
||||
}).pagination(filter.page - 1, filter.page_size);
|
||||
|
||||
return res.status(200).send({
|
||||
sales_receipts: {
|
||||
...salesReceipts,
|
||||
...(viewMeta ? {
|
||||
view_meta: {
|
||||
customViewId: viewMeta.id,
|
||||
}
|
||||
} : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
22
server/src/api/controllers/Sales/index.ts
Normal file
22
server/src/api/controllers/Sales/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import express from 'express';
|
||||
import { Container } from 'typedi';
|
||||
import SalesEstimates from './SalesEstimates';
|
||||
import SalesReceipts from './SalesReceipts';
|
||||
import SalesInvoices from './SalesInvoices'
|
||||
import PaymentReceives from './PaymentReceives';
|
||||
|
||||
export default {
|
||||
/**
|
||||
* Router constructor.
|
||||
*/
|
||||
router() {
|
||||
const router = express.Router();
|
||||
|
||||
router.use('/invoices', Container.get(SalesInvoices).router());
|
||||
router.use('/estimates', Container.get(SalesEstimates).router());
|
||||
router.use('/receipts', Container.get(SalesReceipts).router());
|
||||
router.use('/payment_receives', Container.get(PaymentReceives).router());
|
||||
|
||||
return router;
|
||||
}
|
||||
}
|
||||
95
server/src/api/controllers/Settings.ts
Normal file
95
server/src/api/controllers/Settings.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { body, query, validationResult } from 'express-validator';
|
||||
import { pick } from 'lodash';
|
||||
import { IOptionDTO, IOptionsDTO } from 'interfaces';
|
||||
import BaseController from 'api/controllers/BaseController';
|
||||
import asyncMiddleware from 'api/middleware/asyncMiddleware';
|
||||
|
||||
export default class SettingsController extends BaseController{
|
||||
/**
|
||||
* Router constructor.
|
||||
*/
|
||||
router() {
|
||||
const router = Router();
|
||||
|
||||
router.post('/',
|
||||
this.saveSettingsValidationSchema,
|
||||
asyncMiddleware(this.saveSettings.bind(this)));
|
||||
|
||||
router.get('/',
|
||||
this.getSettingsSchema,
|
||||
asyncMiddleware(this.getSettings.bind(this)));
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save settings validation schema.
|
||||
*/
|
||||
get saveSettingsValidationSchema() {
|
||||
return [
|
||||
body('options').isArray({ min: 1 }),
|
||||
body('options.*.key').exists(),
|
||||
body('options.*.value').exists(),
|
||||
body('options.*.group').exists(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the application options from the storage.
|
||||
*/
|
||||
get getSettingsSchema() {
|
||||
return [
|
||||
query('key').optional(),
|
||||
query('group').optional(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the given options to the storage.
|
||||
* @param {Request} req -
|
||||
* @param {Response} res -
|
||||
*/
|
||||
saveSettings(req: Request, res: Response) {
|
||||
const { Option } = req.models;
|
||||
const optionsDTO: IOptionsDTO = this.matchedBodyData(req);
|
||||
const { settings } = req;
|
||||
|
||||
const errorReasons: { type: string, code: number, keys: [] }[] = [];
|
||||
const notDefinedOptions = Option.validateDefined(optionsDTO.options);
|
||||
|
||||
if (notDefinedOptions.length) {
|
||||
errorReasons.push({
|
||||
type: 'OPTIONS.KEY.NOT.DEFINED',
|
||||
code: 200,
|
||||
keys: notDefinedOptions.map((o) => ({
|
||||
...pick(o, ['key', 'group'])
|
||||
})),
|
||||
});
|
||||
}
|
||||
if (errorReasons.length) {
|
||||
return res.status(400).send({ errors: errorReasons });
|
||||
}
|
||||
optionsDTO.options.forEach((option: IOptionDTO) => {
|
||||
settings.set({ ...option });
|
||||
});
|
||||
|
||||
return res.status(200).send({
|
||||
type: 'success',
|
||||
code: 'OPTIONS.SAVED.SUCCESSFULLY',
|
||||
message: 'Options have been saved successfully.',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve settings.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
getSettings(req: Request, res: Response) {
|
||||
const { settings } = req;
|
||||
const allSettings = settings.all();
|
||||
|
||||
return res.status(200).send({ settings: allSettings });
|
||||
}
|
||||
};
|
||||
267
server/src/api/controllers/Subscription/Licenses.ts
Normal file
267
server/src/api/controllers/Subscription/Licenses.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import { Service, Inject } from 'typedi';
|
||||
import { Router, Request, Response } from 'express'
|
||||
import { check, oneOf, ValidationChain } from 'express-validator';
|
||||
import basicAuth from 'express-basic-auth';
|
||||
import config from 'config';
|
||||
import { License, Plan } from 'system/models';
|
||||
import BaseController from 'api/controllers/BaseController';
|
||||
import LicenseService from 'services/Payment/License';
|
||||
import validateMiddleware from 'api/middleware/validateMiddleware';
|
||||
import asyncMiddleware from 'api/middleware/asyncMiddleware';
|
||||
import { ILicensesFilter } from 'interfaces';
|
||||
|
||||
@Service()
|
||||
export default class LicensesController extends BaseController {
|
||||
@Inject()
|
||||
licenseService: LicenseService;
|
||||
|
||||
/**
|
||||
* Router constructor.
|
||||
*/
|
||||
router() {
|
||||
const router = Router();
|
||||
|
||||
router.use(basicAuth({
|
||||
users: {
|
||||
[config.licensesAuth.user]: config.licensesAuth.password,
|
||||
},
|
||||
challenge: true,
|
||||
}));
|
||||
|
||||
router.post(
|
||||
'/generate',
|
||||
this.generateLicenseSchema,
|
||||
validateMiddleware,
|
||||
asyncMiddleware(this.validatePlanExistance.bind(this)),
|
||||
asyncMiddleware(this.generateLicense.bind(this)),
|
||||
);
|
||||
router.post(
|
||||
'/disable/:licenseId',
|
||||
validateMiddleware,
|
||||
asyncMiddleware(this.validateLicenseExistance.bind(this)),
|
||||
asyncMiddleware(this.validateNotDisabledLicense.bind(this)),
|
||||
asyncMiddleware(this.disableLicense.bind(this)),
|
||||
);
|
||||
router.post(
|
||||
'/send',
|
||||
this.sendLicenseSchemaValidation,
|
||||
validateMiddleware,
|
||||
asyncMiddleware(this.sendLicense.bind(this)),
|
||||
);
|
||||
router.delete(
|
||||
'/:licenseId',
|
||||
asyncMiddleware(this.validateLicenseExistance.bind(this)),
|
||||
asyncMiddleware(this.deleteLicense.bind(this)),
|
||||
);
|
||||
router.get(
|
||||
'/',
|
||||
asyncMiddleware(this.listLicenses.bind(this)),
|
||||
);
|
||||
return router;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate license validation schema.
|
||||
*/
|
||||
get generateLicenseSchema(): ValidationChain[] {
|
||||
return [
|
||||
check('loop').exists().isNumeric().toInt(),
|
||||
check('period').exists().isNumeric().toInt(),
|
||||
check('period_interval').exists().isIn([
|
||||
'month', 'months', 'year', 'years', 'day', 'days'
|
||||
]),
|
||||
check('plan_id').exists().isNumeric().toInt(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Specific license validation schema.
|
||||
*/
|
||||
get specificLicenseSchema(): ValidationChain[] {
|
||||
return [
|
||||
oneOf([
|
||||
check('license_id').exists().isNumeric().toInt(),
|
||||
], [
|
||||
check('license_code').exists().isNumeric().toInt(),
|
||||
])
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Send license validation schema.
|
||||
*/
|
||||
get sendLicenseSchemaValidation(): ValidationChain[] {
|
||||
return [
|
||||
check('period').exists().isNumeric(),
|
||||
check('period_interval').exists().trim().escape(),
|
||||
check('plan_id').exists().isNumeric().toInt(),
|
||||
oneOf([
|
||||
check('phone_number').exists().trim().escape(),
|
||||
check('email').exists().trim().escape(),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the plan existance on the storage.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {Function} next
|
||||
*/
|
||||
async validatePlanExistance(req: Request, res: Response, next: Function) {
|
||||
const body = this.matchedBodyData(req);
|
||||
const planId: number = body.planId || req.params.planId;
|
||||
const foundPlan = await Plan.query().findById(planId);
|
||||
|
||||
if (!foundPlan) {
|
||||
return res.status(400).send({
|
||||
erorrs: [{ type: 'PLAN.NOT.FOUND', code: 100 }],
|
||||
});
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Valdiate the license existance on the storage.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {Function}
|
||||
*/
|
||||
async validateLicenseExistance(req: Request, res: Response, next: Function) {
|
||||
const body = this.matchedBodyData(req);
|
||||
|
||||
const licenseId = body.licenseId || req.params.licenseId;
|
||||
const foundLicense = await License.query().findById(licenseId);
|
||||
|
||||
if (!foundLicense) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'LICENSE.NOT.FOUND', code: 200 }],
|
||||
});
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates whether the license id is disabled.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {Function} next
|
||||
*/
|
||||
async validateNotDisabledLicense(req: Request, res: Response, next: Function) {
|
||||
const licenseId = req.params.licenseId || req.query.licenseId;
|
||||
const foundLicense = await License.query().findById(licenseId);
|
||||
|
||||
if (foundLicense.disabled) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'LICENSE.ALREADY.DISABLED', code: 200 }],
|
||||
});
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate licenses codes with given period in bulk.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @return {Response}
|
||||
*/
|
||||
async generateLicense(req: Request, res: Response, next: Function) {
|
||||
const { loop = 10, period, periodInterval, planId } = this.matchedBodyData(req);
|
||||
|
||||
try {
|
||||
await this.licenseService.generateLicenses(
|
||||
loop, period, periodInterval, planId,
|
||||
);
|
||||
return res.status(200).send({
|
||||
code: 100,
|
||||
type: 'LICENSEES.GENERATED.SUCCESSFULLY',
|
||||
message: 'The licenses have been generated successfully.'
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable the given license on the storage.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @return {Response}
|
||||
*/
|
||||
async disableLicense(req: Request, res: Response) {
|
||||
const { licenseId } = req.params;
|
||||
|
||||
await this.licenseService.disableLicense(licenseId);
|
||||
|
||||
return res.status(200).send({ license_id: licenseId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the given license code on the storage.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @return {Response}
|
||||
*/
|
||||
async deleteLicense(req: Request, res: Response) {
|
||||
const { licenseId } = req.params;
|
||||
|
||||
await this.licenseService.deleteLicense(licenseId);
|
||||
|
||||
return res.status(200).send({ license_id: licenseId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Send license code in the given period to the customer via email or phone number
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @return {Response}
|
||||
*/
|
||||
async sendLicense(req: Request, res: Response) {
|
||||
const { phoneNumber, email, period, periodInterval, planId } = this.matchedBodyData(req);
|
||||
|
||||
const license = await License.query()
|
||||
.modify('filterActiveLicense')
|
||||
.where('license_period', period)
|
||||
.where('period_interval', periodInterval)
|
||||
.where('plan_id', planId)
|
||||
.first();
|
||||
|
||||
if (!license) {
|
||||
return res.status(400).send({
|
||||
status: 110,
|
||||
message: 'There is no licenses availiable right now with the given period and plan.',
|
||||
code: 'NO.AVALIABLE.LICENSE.CODE',
|
||||
});
|
||||
}
|
||||
await this.licenseService.sendLicenseToCustomer(
|
||||
license.licenseCode, phoneNumber, email,
|
||||
);
|
||||
return res.status(200).send({
|
||||
status: 100,
|
||||
code: 'LICENSE.CODE.SENT',
|
||||
message: 'The license has been sent to the given customer.',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Listing licenses.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async listLicenses(req: Request, res: Response) {
|
||||
const filter: ILicensesFilter = {
|
||||
disabled: false,
|
||||
used: false,
|
||||
sent: false,
|
||||
active: false,
|
||||
...req.query,
|
||||
};
|
||||
const licenses = await License.query()
|
||||
.onBuild((builder) => {
|
||||
builder.modify('filter', filter);
|
||||
builder.orderBy('createdAt', 'ASC');
|
||||
});
|
||||
return res.status(200).send({ licenses });
|
||||
}
|
||||
}
|
||||
30
server/src/api/controllers/Subscription/PaymentMethod.ts
Normal file
30
server/src/api/controllers/Subscription/PaymentMethod.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Inject } from 'typedi';
|
||||
import { Plan } from 'system/models';
|
||||
import BaseController from 'api/controllers/BaseController';
|
||||
import SubscriptionService from 'services/Subscription/SubscriptionService';
|
||||
|
||||
export default class PaymentMethodController extends BaseController {
|
||||
@Inject()
|
||||
subscriptionService: SubscriptionService;
|
||||
|
||||
/**
|
||||
* Validate the given plan slug exists on the storage.
|
||||
*
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {NextFunction} next
|
||||
*
|
||||
* @return {Response|void}
|
||||
*/
|
||||
async validatePlanSlugExistance(req: Request, res: Response, next: Function) {
|
||||
const { planSlug } = this.matchedBodyData(req);
|
||||
const foundPlan = await Plan.query().where('slug', planSlug).first();
|
||||
|
||||
if (!foundPlan) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'PLAN.SLUG.NOT.EXISTS', code: 110 }],
|
||||
});
|
||||
}
|
||||
next();
|
||||
}
|
||||
}
|
||||
99
server/src/api/controllers/Subscription/PaymentViaLicense.ts
Normal file
99
server/src/api/controllers/Subscription/PaymentViaLicense.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { Inject, Service } from 'typedi';
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { check } from 'express-validator';
|
||||
import validateMiddleware from 'api/middleware/validateMiddleware';
|
||||
import asyncMiddleware from 'api/middleware/asyncMiddleware';
|
||||
import PaymentMethodController from 'api/controllers/Subscription/PaymentMethod';
|
||||
import {
|
||||
NotAllowedChangeSubscriptionPlan,
|
||||
NoPaymentModelWithPricedPlan,
|
||||
PaymentAmountInvalidWithPlan,
|
||||
PaymentInputInvalid,
|
||||
} from 'exceptions';
|
||||
import { ILicensePaymentModel } from 'interfaces';
|
||||
|
||||
@Service()
|
||||
export default class PaymentViaLicenseController extends PaymentMethodController {
|
||||
@Inject('logger')
|
||||
logger: any;
|
||||
|
||||
/**
|
||||
* Router constructor.
|
||||
*/
|
||||
router() {
|
||||
const router = Router();
|
||||
|
||||
router.post(
|
||||
'/payment',
|
||||
this.paymentViaLicenseSchema,
|
||||
validateMiddleware,
|
||||
asyncMiddleware(this.validatePlanSlugExistance.bind(this)),
|
||||
asyncMiddleware(this.paymentViaLicense.bind(this)),
|
||||
);
|
||||
return router;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payment via license validation schema.
|
||||
*/
|
||||
get paymentViaLicenseSchema() {
|
||||
return [
|
||||
check('plan_slug').exists().trim().escape(),
|
||||
check('license_code').optional().trim().escape(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the subscription payment via license code.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @return {Response}
|
||||
*/
|
||||
async paymentViaLicense(req: Request, res: Response, next: Function) {
|
||||
const { planSlug, licenseCode } = this.matchedBodyData(req);
|
||||
const { tenant } = req;
|
||||
|
||||
try {
|
||||
const licenseModel: ILicensePaymentModel|null = licenseCode
|
||||
? { licenseCode } : null;
|
||||
|
||||
await this.subscriptionService
|
||||
.subscriptionViaLicense(tenant.id, planSlug, licenseModel);
|
||||
|
||||
return res.status(200).send({
|
||||
type: 'success',
|
||||
code: 'PAYMENT.SUCCESSFULLY.MADE',
|
||||
message: 'Payment via license has been made successfully.',
|
||||
});
|
||||
} catch (exception) {
|
||||
const errorReasons = [];
|
||||
|
||||
if (exception instanceof NoPaymentModelWithPricedPlan) {
|
||||
errorReasons.push({
|
||||
type: 'NO_PAYMENT_WITH_PRICED_PLAN',
|
||||
code: 140,
|
||||
});
|
||||
}
|
||||
if (exception instanceof NotAllowedChangeSubscriptionPlan) {
|
||||
errorReasons.push({
|
||||
type: 'NOT.ALLOWED.RENEW.SUBSCRIPTION.WHILE.ACTIVE',
|
||||
code: 120,
|
||||
});
|
||||
}
|
||||
if (errorReasons.length > 0) {
|
||||
return res.status(400).send({ errors: errorReasons });
|
||||
}
|
||||
if (exception instanceof PaymentInputInvalid) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'LICENSE.CODE.IS.INVALID', code: 120 }],
|
||||
});
|
||||
}
|
||||
if (exception instanceof PaymentAmountInvalidWithPlan) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'LICENSE.NOT.FOR.GIVEN.PLAN' }],
|
||||
});
|
||||
}
|
||||
next(exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
24
server/src/api/controllers/Subscription/index.ts
Normal file
24
server/src/api/controllers/Subscription/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Router } from 'express'
|
||||
import { Container, Service } from 'typedi';
|
||||
import JWTAuth from 'api/middleware/jwtAuth';
|
||||
import TenancyMiddleware from 'api/middleware/TenancyMiddleware';
|
||||
import AttachCurrentTenantUser from 'api/middleware/AttachCurrentTenantUser';
|
||||
import PaymentViaLicenseController from 'api/controllers/Subscription/PaymentViaLicense';
|
||||
|
||||
@Service()
|
||||
export default class SubscriptionController {
|
||||
/**
|
||||
* Router constructor.
|
||||
*/
|
||||
router() {
|
||||
const router = Router();
|
||||
|
||||
router.use(JWTAuth);
|
||||
router.use(AttachCurrentTenantUser);
|
||||
router.use(TenancyMiddleware);
|
||||
|
||||
router.use('/license', Container.get(PaymentViaLicenseController).router());
|
||||
|
||||
return router;
|
||||
}
|
||||
}
|
||||
254
server/src/api/controllers/Users.ts
Normal file
254
server/src/api/controllers/Users.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { Service, Inject } from 'typedi';
|
||||
import {
|
||||
check,
|
||||
query,
|
||||
param,
|
||||
} from 'express-validator';
|
||||
import asyncMiddleware from 'api/middleware/asyncMiddleware';
|
||||
import BaseController from 'api/controllers/BaseController';
|
||||
import UsersService from 'services/Users/UsersService';
|
||||
import { ServiceError, ServiceErrors } from 'exceptions';
|
||||
import { ISystemUserDTO } from 'interfaces';
|
||||
|
||||
@Service()
|
||||
export default class UsersController extends BaseController{
|
||||
@Inject()
|
||||
usersService: UsersService;
|
||||
|
||||
/**
|
||||
* Router constructor.
|
||||
*/
|
||||
router() {
|
||||
const router = Router();
|
||||
|
||||
router.put('/:id/inactivate', [
|
||||
...this.specificUserSchema,
|
||||
],
|
||||
this.validationResult,
|
||||
asyncMiddleware(this.inactivateUser.bind(this))
|
||||
);
|
||||
router.put('/:id/activate', [
|
||||
...this.specificUserSchema
|
||||
],
|
||||
this.validationResult,
|
||||
asyncMiddleware(this.activateUser.bind(this))
|
||||
);
|
||||
router.post('/:id', [
|
||||
...this.userDTOSchema,
|
||||
...this.specificUserSchema,
|
||||
],
|
||||
this.validationResult,
|
||||
asyncMiddleware(this.editUser.bind(this))
|
||||
);
|
||||
router.get('/',
|
||||
this.listUsersSchema,
|
||||
this.validationResult,
|
||||
asyncMiddleware(this.listUsers.bind(this))
|
||||
);
|
||||
router.get('/:id', [
|
||||
...this.specificUserSchema,
|
||||
],
|
||||
this.validationResult,
|
||||
asyncMiddleware(this.getUser.bind(this))
|
||||
);
|
||||
router.delete('/:id', [
|
||||
...this.specificUserSchema
|
||||
],
|
||||
this.validationResult,
|
||||
asyncMiddleware(this.deleteUser.bind(this))
|
||||
);
|
||||
return router;
|
||||
}
|
||||
|
||||
/**
|
||||
* User DTO Schema.
|
||||
*/
|
||||
get userDTOSchema() {
|
||||
return [
|
||||
check('first_name').exists(),
|
||||
check('last_name').exists(),
|
||||
check('email').exists().isEmail(),
|
||||
check('phone_number').optional().isMobilePhone(),
|
||||
]
|
||||
}
|
||||
|
||||
get specificUserSchema() {
|
||||
return [
|
||||
param('id').exists().isNumeric().toInt(),
|
||||
];
|
||||
}
|
||||
|
||||
get listUsersSchema() {
|
||||
return [
|
||||
query('page_size').optional().isNumeric().toInt(),
|
||||
query('page').optional().isNumeric().toInt(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit details of the given user.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @return {Response|void}
|
||||
*/
|
||||
async editUser(req: Request, res: Response, next: NextFunction) {
|
||||
const userDTO: ISystemUserDTO = this.matchedBodyData(req);
|
||||
const { tenantId } = req;
|
||||
const { id: userId } = req.params;
|
||||
|
||||
try {
|
||||
await this.usersService.editUser(tenantId, userId, userDTO);
|
||||
return res.status(200).send({ id: userId });
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceErrors) {
|
||||
const errorReasons = [];
|
||||
|
||||
if (error.errorType === 'email_already_exists') {
|
||||
errorReasons.push({ type: 'EMAIL_ALREADY_EXIST', code: 100 });
|
||||
}
|
||||
if (error.errorType === 'phone_number_already_exist') {
|
||||
errorReasons.push({ type: 'PHONE_NUMBER_ALREADY_EXIST', code: 200 });
|
||||
}
|
||||
if (errorReasons.length > 0) {
|
||||
return res.status(400).send({ errors: errorReasons });
|
||||
}
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft deleting the given user.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @return {Response|void}
|
||||
*/
|
||||
async deleteUser(req: Request, res: Response, next: Function) {
|
||||
const { id } = req.params;
|
||||
const { tenantId } = req;
|
||||
|
||||
debugger;
|
||||
|
||||
try {
|
||||
await this.usersService.deleteUser(tenantId, id);
|
||||
return res.status(200).send({ id });
|
||||
} catch (error) {
|
||||
|
||||
if (error instanceof ServiceError) {
|
||||
if (error.errorType === 'user_not_found') {
|
||||
return res.boom.notFound(null, {
|
||||
errors: [{ type: 'USER_NOT_FOUND', code: 100 }],
|
||||
});
|
||||
}
|
||||
}
|
||||
next();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve user details of the given user id.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @return {Response|void}
|
||||
*/
|
||||
async getUser(req: Request, res: Response, next: NextFunction) {
|
||||
const { id: userId } = req.params;
|
||||
const { tenantId } = req;
|
||||
|
||||
try {
|
||||
const user = await this.usersService.getUser(tenantId, userId);
|
||||
return res.status(200).send({ user });
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
|
||||
if (error instanceof ServiceError) {
|
||||
if (error.errorType === 'user_not_found') {
|
||||
return res.boom.notFound(null, {
|
||||
errors: [{ type: 'USER_NOT_FOUND', code: 100 }],
|
||||
});
|
||||
}
|
||||
}
|
||||
next();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the list of users.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @return {Response|void}
|
||||
*/
|
||||
async listUsers(req: Request, res: Response, next: NextFunction) {
|
||||
const { tenantId } = req;
|
||||
try {
|
||||
const users = await this.usersService.getList(tenantId);
|
||||
|
||||
return res.status(200).send({ users });
|
||||
} catch (error) {
|
||||
next();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate the given user.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @param {NextFunction} next
|
||||
*/
|
||||
async activateUser(req: Request, res: Response, next: NextFunction) {
|
||||
const { tenantId } = req;
|
||||
const { id: userId } = req.params;
|
||||
|
||||
try {
|
||||
await this.usersService.activateUser(tenantId, userId);
|
||||
return res.status(200).send({ id: userId });
|
||||
} catch(error) {
|
||||
console.log(error);
|
||||
|
||||
if (error instanceof ServiceError) {
|
||||
if (error.errorType === 'user_not_found') {
|
||||
return res.status(404).send({
|
||||
errors: [{ type: 'USER.NOT.FOUND', code: 100 }],
|
||||
});
|
||||
}
|
||||
if (error.errorType === 'user_already_active') {
|
||||
return res.status(404).send({
|
||||
errors: [{ type: 'USER.ALREADY.ACTIVE', code: 200 }],
|
||||
});
|
||||
}
|
||||
}
|
||||
next();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inactivate the given user.
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
* @return {Response|void}
|
||||
*/
|
||||
async inactivateUser(req: Request, res: Response, next: NextFunction) {
|
||||
const { tenantId, user } = req;
|
||||
const { id: userId } = req.params;
|
||||
|
||||
try {
|
||||
await this.usersService.inactivateUser(tenantId, userId);
|
||||
return res.status(200).send({ id: userId });
|
||||
} catch(error) {
|
||||
if (error instanceof ServiceError) {
|
||||
if (error.errorType === 'user_not_found') {
|
||||
return res.status(404).send({
|
||||
errors: [{ type: 'USER.NOT.FOUND', code: 100 }],
|
||||
});
|
||||
}
|
||||
if (error.errorType === 'user_already_inactive') {
|
||||
return res.status(404).send({
|
||||
errors: [{ type: 'USER.ALREADY.INACTIVE', code: 200 }],
|
||||
});
|
||||
}
|
||||
}
|
||||
next();
|
||||
}
|
||||
}
|
||||
};
|
||||
473
server/src/api/controllers/Views.js
Normal file
473
server/src/api/controllers/Views.js
Normal file
@@ -0,0 +1,473 @@
|
||||
import { difference, pick } from 'lodash';
|
||||
import express from 'express';
|
||||
import {
|
||||
check,
|
||||
query,
|
||||
param,
|
||||
oneOf,
|
||||
validationResult,
|
||||
} from 'express-validator';
|
||||
import asyncMiddleware from 'api/middleware/asyncMiddleware';
|
||||
import {
|
||||
validateViewRoles,
|
||||
} from 'lib/ViewRolesBuilder';
|
||||
|
||||
export default {
|
||||
resource: 'items',
|
||||
|
||||
/**
|
||||
* Router constructor.
|
||||
*/
|
||||
router() {
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/',
|
||||
this.listViews.validation,
|
||||
asyncMiddleware(this.listViews.handler));
|
||||
|
||||
router.post('/',
|
||||
this.createView.validation,
|
||||
asyncMiddleware(this.createView.handler));
|
||||
|
||||
router.post('/:view_id',
|
||||
this.editView.validation,
|
||||
asyncMiddleware(this.editView.handler));
|
||||
|
||||
router.delete('/:view_id',
|
||||
this.deleteView.validation,
|
||||
asyncMiddleware(this.deleteView.handler));
|
||||
|
||||
router.get('/:view_id',
|
||||
asyncMiddleware(this.getView.handler));
|
||||
|
||||
router.get('/:view_id/resource',
|
||||
this.getViewResource.validation,
|
||||
asyncMiddleware(this.getViewResource.handler));
|
||||
|
||||
return router;
|
||||
},
|
||||
|
||||
/**
|
||||
* List all views that associated with the given resource.
|
||||
*/
|
||||
listViews: {
|
||||
validation: [
|
||||
oneOf([
|
||||
query('resource_name').exists().trim().escape(),
|
||||
], [
|
||||
query('resource_id').exists().isNumeric().toInt(),
|
||||
]),
|
||||
],
|
||||
async handler(req, res) {
|
||||
const { Resource, View } = req.models;
|
||||
const filter = { ...req.query };
|
||||
|
||||
const resource = await Resource.query().onBuild((builder) => {
|
||||
if (filter.resource_id) {
|
||||
builder.where('id', filter.resource_id);
|
||||
}
|
||||
if (filter.resource_name) {
|
||||
builder.where('name', filter.resource_name);
|
||||
}
|
||||
builder.first();
|
||||
});
|
||||
|
||||
const views = await View.query().where('resource_id', resource.id);
|
||||
|
||||
return res.status(200).send({ views });
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Retrieve view details of the given view id.
|
||||
*/
|
||||
getView: {
|
||||
validation: [
|
||||
param('view_id').exists().isNumeric().toInt(),
|
||||
],
|
||||
async handler(req, res) {
|
||||
const { view_id: viewId } = req.params;
|
||||
const { View } = req.models;
|
||||
|
||||
const view = await View.query()
|
||||
.where('id', viewId)
|
||||
.withGraphFetched('resource')
|
||||
.withGraphFetched('columns')
|
||||
.withGraphFetched('roles.field')
|
||||
.first();
|
||||
|
||||
if (!view) {
|
||||
return res.boom.notFound(null, {
|
||||
errors: [{ type: 'VIEW_NOT_FOUND', code: 100 }],
|
||||
});
|
||||
}
|
||||
return res.status(200).send({ view: view.toJSON() });
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete the given view of the resource.
|
||||
*/
|
||||
deleteView: {
|
||||
validation: [
|
||||
param('view_id').exists().isNumeric().toInt(),
|
||||
],
|
||||
async handler(req, res) {
|
||||
const { View } = req.models;
|
||||
const { view_id: viewId } = req.params;
|
||||
const view = await View.query().findById(viewId);
|
||||
|
||||
if (!view) {
|
||||
return res.boom.notFound(null, {
|
||||
errors: [{ type: 'VIEW_NOT_FOUND', code: 100 }],
|
||||
});
|
||||
}
|
||||
if (view.predefined) {
|
||||
return res.boom.badRequest(null, {
|
||||
errors: [{ type: 'PREDEFINED_VIEW', code: 200 }],
|
||||
});
|
||||
}
|
||||
await Promise.all([
|
||||
view.$relatedQuery('roles').delete(),
|
||||
view.$relatedQuery('columns').delete(),
|
||||
]);
|
||||
await View.query().where('id', view.id).delete();
|
||||
|
||||
return res.status(200).send({ id: view.id });
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Creates a new view.
|
||||
*/
|
||||
createView: {
|
||||
validation: [
|
||||
check('resource_name').exists().escape().trim(),
|
||||
check('name').exists().escape().trim(),
|
||||
check('logic_expression').exists().trim().escape(),
|
||||
check('roles').isArray({ min: 1 }),
|
||||
check('roles.*.field_key').exists().escape().trim(),
|
||||
check('roles.*.comparator').exists(),
|
||||
check('roles.*.value').exists(),
|
||||
check('roles.*.index').exists().isNumeric().toInt(),
|
||||
check('columns').exists().isArray({ min: 1 }),
|
||||
check('columns.*.key').exists().escape().trim(),
|
||||
check('columns.*.index').exists().isNumeric().toInt(),
|
||||
],
|
||||
async handler(req, res) {
|
||||
const validationErrors = validationResult(req);
|
||||
|
||||
if (!validationErrors.isEmpty()) {
|
||||
return res.boom.badData(null, {
|
||||
code: 'validation_error', ...validationErrors,
|
||||
});
|
||||
}
|
||||
const {
|
||||
Resource,
|
||||
View,
|
||||
ViewColumn,
|
||||
ViewRole,
|
||||
} = req.models;
|
||||
const form = { roles: [], ...req.body };
|
||||
const resource = await Resource.query().where('name', form.resource_name).first();
|
||||
|
||||
if (!resource) {
|
||||
return res.boom.notFound(null, {
|
||||
errors: [{ type: 'RESOURCE_NOT_FOUND', code: 100 }],
|
||||
});
|
||||
}
|
||||
const errorReasons = [];
|
||||
const fieldsSlugs = form.roles.map((role) => role.field_key);
|
||||
|
||||
const resourceFields = await resource.$relatedQuery('fields');
|
||||
const resourceFieldsKeys = resourceFields.map((f) => f.key);
|
||||
const resourceFieldsKeysMap = new Map(resourceFields.map((field) => [field.key, field]));
|
||||
const columnsKeys = form.columns.map((c) => c.key);
|
||||
|
||||
// The difference between the stored resource fields and submit fields keys.
|
||||
const notFoundFields = difference(fieldsSlugs, resourceFieldsKeys);
|
||||
|
||||
if (notFoundFields.length > 0) {
|
||||
errorReasons.push({ type: 'RESOURCE_FIELDS_NOT_EXIST', code: 100, fields: notFoundFields });
|
||||
}
|
||||
// The difference between the stored resource fields and the submit columns keys.
|
||||
const notFoundColumns = difference(columnsKeys, resourceFieldsKeys);
|
||||
|
||||
if (notFoundColumns.length > 0) {
|
||||
errorReasons.push({ type: 'COLUMNS_NOT_EXIST', code: 200, columns: notFoundColumns });
|
||||
}
|
||||
// Validates the view conditional logic expression.
|
||||
if (!validateViewRoles(form.roles, form.logic_expression)) {
|
||||
errorReasons.push({ type: 'VIEW.ROLES.LOGIC.EXPRESSION.INVALID', code: 400 });
|
||||
}
|
||||
if (errorReasons.length > 0) {
|
||||
return res.boom.badRequest(null, { errors: errorReasons });
|
||||
}
|
||||
|
||||
// Save view details.
|
||||
const view = await View.query().insert({
|
||||
name: form.name,
|
||||
predefined: false,
|
||||
resource_id: resource.id,
|
||||
roles_logic_expression: form.logic_expression,
|
||||
});
|
||||
// Save view roles async operations.
|
||||
const saveViewRolesOpers = [];
|
||||
|
||||
form.roles.forEach((role) => {
|
||||
const fieldModel = resourceFieldsKeysMap.get(role.field_key);
|
||||
|
||||
const saveViewRoleOper = ViewRole.query().insert({
|
||||
...pick(role, ['comparator', 'value', 'index']),
|
||||
field_id: fieldModel.id,
|
||||
view_id: view.id,
|
||||
});
|
||||
saveViewRolesOpers.push(saveViewRoleOper);
|
||||
});
|
||||
|
||||
form.columns.forEach((column) => {
|
||||
const fieldModel = resourceFieldsKeysMap.get(column.key);
|
||||
|
||||
const saveViewColumnOper = ViewColumn.query().insert({
|
||||
field_id: fieldModel.id,
|
||||
view_id: view.id,
|
||||
index: column.index,
|
||||
});
|
||||
saveViewRolesOpers.push(saveViewColumnOper);
|
||||
});
|
||||
await Promise.all(saveViewRolesOpers);
|
||||
|
||||
return res.status(200).send({ id: view.id });
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Edit the given custom view metadata.
|
||||
*/
|
||||
editView: {
|
||||
validation: [
|
||||
param('view_id').exists().isNumeric().toInt(),
|
||||
check('name').exists().escape().trim(),
|
||||
check('logic_expression').exists().trim().escape(),
|
||||
|
||||
check('columns').exists().isArray({ min: 1 }),
|
||||
|
||||
check('columns.*.id').optional().isNumeric().toInt(),
|
||||
check('columns.*.key').exists().escape().trim(),
|
||||
check('columns.*.index').exists().isNumeric().toInt(),
|
||||
|
||||
check('roles').isArray(),
|
||||
check('roles.*.id').optional().isNumeric().toInt(),
|
||||
check('roles.*.field_key').exists().escape().trim(),
|
||||
check('roles.*.comparator').exists(),
|
||||
check('roles.*.value').exists(),
|
||||
check('roles.*.index').exists().isNumeric().toInt(),
|
||||
],
|
||||
async handler(req, res) {
|
||||
const { view_id: viewId } = req.params;
|
||||
const validationErrors = validationResult(req);
|
||||
|
||||
if (!validationErrors.isEmpty()) {
|
||||
return res.boom.badData(null, {
|
||||
code: 'validation_error', ...validationErrors,
|
||||
});
|
||||
}
|
||||
const {
|
||||
View, ViewRole, ViewColumn, Resource,
|
||||
} = req.models;
|
||||
const view = await View.query().where('id', viewId)
|
||||
.withGraphFetched('roles.field')
|
||||
.withGraphFetched('columns')
|
||||
.first();
|
||||
|
||||
if (!view) {
|
||||
return res.boom.notFound(null, {
|
||||
errors: [{ type: 'ROLE_NOT_FOUND', code: 100 }],
|
||||
});
|
||||
}
|
||||
const form = { ...req.body };
|
||||
const resource = await Resource.query()
|
||||
.where('id', view.resourceId)
|
||||
.withGraphFetched('fields')
|
||||
.withGraphFetched('views')
|
||||
.first();
|
||||
|
||||
const errorReasons = [];
|
||||
const fieldsSlugs = form.roles.map((role) => role.field_key);
|
||||
const resourceFieldsKeys = resource.fields.map((f) => f.key);
|
||||
const resourceFieldsKeysMap = new Map(resource.fields.map((field) => [field.key, field]));
|
||||
const columnsKeys = form.columns.map((c) => c.key);
|
||||
|
||||
// The difference between the stored resource fields and submit fields keys.
|
||||
const notFoundFields = difference(fieldsSlugs, resourceFieldsKeys);
|
||||
|
||||
// Validate not found resource fields keys.
|
||||
if (notFoundFields.length > 0) {
|
||||
errorReasons.push({
|
||||
type: 'RESOURCE_FIELDS_NOT_EXIST', code: 100, fields: notFoundFields,
|
||||
});
|
||||
}
|
||||
// The difference between the stored resource fields and the submit columns keys.
|
||||
const notFoundColumns = difference(columnsKeys, resourceFieldsKeys);
|
||||
|
||||
// Validate not found view columns.
|
||||
if (notFoundColumns.length > 0) {
|
||||
errorReasons.push({ type: 'RESOURCE_COLUMNS_NOT_EXIST', code: 200, columns: notFoundColumns });
|
||||
}
|
||||
// Validates the view conditional logic expression.
|
||||
if (!validateViewRoles(form.roles, form.logic_expression)) {
|
||||
errorReasons.push({ type: 'VIEW.ROLES.LOGIC.EXPRESSION.INVALID', code: 400 });
|
||||
}
|
||||
|
||||
const viewRolesIds = view.roles.map((r) => r.id);
|
||||
const viewColumnsIds = view.columns.map((c) => c.id);
|
||||
|
||||
const formUpdatedRoles = form.roles.filter((r) => r.id);
|
||||
const formInsertRoles = form.roles.filter((r) => !r.id);
|
||||
|
||||
const formRolesIds = formUpdatedRoles.map((r) => r.id);
|
||||
|
||||
const formUpdatedColumns = form.columns.filter((r) => r.id);
|
||||
const formInsertedColumns = form.columns.filter((r) => !r.id);
|
||||
const formColumnsIds = formUpdatedColumns.map((r) => r.id);
|
||||
|
||||
const rolesIdsShouldDeleted = difference(viewRolesIds, formRolesIds);
|
||||
const columnsIdsShouldDelete = difference(viewColumnsIds, formColumnsIds);
|
||||
|
||||
const notFoundViewRolesIds = difference(formRolesIds, viewRolesIds);
|
||||
const notFoundViewColumnsIds = difference(viewColumnsIds, viewColumnsIds);
|
||||
|
||||
// Validate the not found view roles ids.
|
||||
if (notFoundViewRolesIds.length) {
|
||||
errorReasons.push({ type: 'VIEW.ROLES.IDS.NOT.FOUND', code: 500, ids: notFoundViewRolesIds });
|
||||
}
|
||||
// Validate the not found view columns ids.
|
||||
if (notFoundViewColumnsIds.length) {
|
||||
errorReasons.push({ type: 'VIEW.COLUMNS.IDS.NOT.FOUND', code: 600, ids: notFoundViewColumnsIds });
|
||||
}
|
||||
if (errorReasons.length > 0) {
|
||||
return res.status(400).send({ errors: errorReasons });
|
||||
}
|
||||
const asyncOpers = [];
|
||||
|
||||
// Save view details.
|
||||
await View.query()
|
||||
.where('id', view.id)
|
||||
.patch({
|
||||
name: form.name,
|
||||
roles_logic_expression: form.logic_expression,
|
||||
});
|
||||
|
||||
// Update view roles.
|
||||
if (formUpdatedRoles.length > 0) {
|
||||
formUpdatedRoles.forEach((role) => {
|
||||
const fieldModel = resourceFieldsKeysMap.get(role.field_key);
|
||||
const updateOper = ViewRole.query()
|
||||
.where('id', role.id)
|
||||
.update({
|
||||
...pick(role, ['comparator', 'value', 'index']),
|
||||
field_id: fieldModel.id,
|
||||
});
|
||||
asyncOpers.push(updateOper);
|
||||
});
|
||||
}
|
||||
// Insert a new view roles.
|
||||
if (formInsertRoles.length > 0) {
|
||||
formInsertRoles.forEach((role) => {
|
||||
const fieldModel = resourceFieldsKeysMap.get(role.field_key);
|
||||
const insertOper = ViewRole.query()
|
||||
.insert({
|
||||
...pick(role, ['comparator', 'value', 'index']),
|
||||
field_id: fieldModel.id,
|
||||
view_id: view.id,
|
||||
});
|
||||
asyncOpers.push(insertOper);
|
||||
});
|
||||
}
|
||||
// Delete view roles.
|
||||
if (rolesIdsShouldDeleted.length > 0) {
|
||||
const deleteOper = ViewRole.query()
|
||||
.whereIn('id', rolesIdsShouldDeleted)
|
||||
.delete();
|
||||
asyncOpers.push(deleteOper);
|
||||
}
|
||||
// Insert a new view columns to the storage.
|
||||
if (formInsertedColumns.length > 0) {
|
||||
formInsertedColumns.forEach((column) => {
|
||||
const fieldModel = resourceFieldsKeysMap.get(column.key);
|
||||
const insertOper = ViewColumn.query()
|
||||
.insert({
|
||||
field_id: fieldModel.id,
|
||||
index: column.index,
|
||||
view_id: view.id,
|
||||
});
|
||||
asyncOpers.push(insertOper);
|
||||
});
|
||||
}
|
||||
// Update the view columns on the storage.
|
||||
if (formUpdatedColumns.length > 0) {
|
||||
formUpdatedColumns.forEach((column) => {
|
||||
const updateOper = ViewColumn.query()
|
||||
.where('id', column.id)
|
||||
.update({
|
||||
index: column.index,
|
||||
});
|
||||
asyncOpers.push(updateOper);
|
||||
});
|
||||
}
|
||||
// Delete the view columns from the storage.
|
||||
if (columnsIdsShouldDelete.length > 0) {
|
||||
const deleteOper = ViewColumn.query()
|
||||
.whereIn('id', columnsIdsShouldDelete)
|
||||
.delete();
|
||||
asyncOpers.push(deleteOper);
|
||||
}
|
||||
await Promise.all(asyncOpers);
|
||||
|
||||
return res.status(200).send();
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Retrieve resource columns that associated to the given custom view.
|
||||
*/
|
||||
getViewResource: {
|
||||
validation: [
|
||||
param('view_id').exists().isNumeric().toInt(),
|
||||
],
|
||||
async handler(req, res) {
|
||||
const { view_id: viewId } = req.params;
|
||||
const { View } = req.models;
|
||||
|
||||
const view = await View.query()
|
||||
.where('id', viewId)
|
||||
.withGraphFetched('resource.fields')
|
||||
.first();
|
||||
|
||||
if (!view) {
|
||||
return res.boom.notFound(null, {
|
||||
errors: [{ type: 'VIEW.NOT.FOUND', code: 100 }],
|
||||
});
|
||||
}
|
||||
if (!view.resource) {
|
||||
return res.boom.badData(null, {
|
||||
errors: [{ type: 'VIEW.HAS.NOT.ASSOCIATED.RESOURCE', code: 200 }],
|
||||
});
|
||||
}
|
||||
|
||||
const resourceColumns = view.resource.fields
|
||||
.filter((field) => field.columnable)
|
||||
.map((field) => ({
|
||||
id: field.id,
|
||||
label: field.labelName,
|
||||
key: field.key,
|
||||
}));
|
||||
|
||||
return res.status(200).send({
|
||||
resource_slug: view.resource.name,
|
||||
resource_columns: resourceColumns,
|
||||
resource_fields: view.resource.fields,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
760
server/src/api/controllers/_Expenses.js
Normal file
760
server/src/api/controllers/_Expenses.js
Normal file
@@ -0,0 +1,760 @@
|
||||
import express from 'express';
|
||||
import { check, param, query, validationResult } from 'express-validator';
|
||||
import moment from 'moment';
|
||||
import { difference, sumBy, omit } from 'lodash';
|
||||
import asyncMiddleware from 'api/middleware/asyncMiddleware';
|
||||
import JournalPoster from 'services/Accounting/JournalPoster';
|
||||
import JournalEntry from 'services/Accounting/JournalEntry';
|
||||
import JWTAuth from 'api/middleware/jwtAuth';
|
||||
import { mapViewRolesToConditionals } from 'lib/ViewRolesBuilder';
|
||||
import {
|
||||
DynamicFilter,
|
||||
DynamicFilterSortBy,
|
||||
DynamicFilterViews,
|
||||
DynamicFilterFilterRoles,
|
||||
} from 'lib/DynamicFilter';
|
||||
|
||||
export default {
|
||||
/**
|
||||
* Router constructor.
|
||||
*/
|
||||
router() {
|
||||
const router = express.Router();
|
||||
|
||||
router.post(
|
||||
'/',
|
||||
this.newExpense.validation,
|
||||
asyncMiddleware(this.newExpense.handler)
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:id/publish',
|
||||
this.publishExpense.validation,
|
||||
asyncMiddleware(this.publishExpense.handler)
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/:id',
|
||||
this.deleteExpense.validation,
|
||||
asyncMiddleware(this.deleteExpense.handler)
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/',
|
||||
this.deleteBulkExpenses.validation,
|
||||
asyncMiddleware(this.deleteBulkExpenses.handler)
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:id',
|
||||
this.updateExpense.validation,
|
||||
asyncMiddleware(this.updateExpense.handler)
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/',
|
||||
this.listExpenses.validation,
|
||||
asyncMiddleware(this.listExpenses.handler)
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:id',
|
||||
this.getExpense.validation,
|
||||
asyncMiddleware(this.getExpense.handler)
|
||||
);
|
||||
|
||||
return router;
|
||||
},
|
||||
|
||||
/**
|
||||
* Saves a new expense.
|
||||
*/
|
||||
newExpense: {
|
||||
validation: [
|
||||
check('reference_no').optional().trim().escape().isLength({
|
||||
max: 255,
|
||||
}),
|
||||
check('payment_date').isISO8601().optional(),
|
||||
check('payment_account_id').exists().isNumeric().toInt(),
|
||||
check('description').optional(),
|
||||
check('currency_code').optional(),
|
||||
check('exchange_rate').optional().isNumeric().toFloat(),
|
||||
check('publish').optional().isBoolean().toBoolean(),
|
||||
check('categories').exists().isArray({ min: 1 }),
|
||||
check('categories.*.index').exists().isNumeric().toInt(),
|
||||
check('categories.*.expense_account_id').exists().isNumeric().toInt(),
|
||||
check('categories.*.amount')
|
||||
.optional({ nullable: true })
|
||||
.isNumeric()
|
||||
.isDecimal()
|
||||
.isFloat({ max: 9999999999.999 }) // 13, 3
|
||||
.toFloat(),
|
||||
check('categories.*.description').optional().trim().escape().isLength({
|
||||
max: 255,
|
||||
}),
|
||||
check('custom_fields').optional().isArray({ min: 0 }),
|
||||
check('custom_fields.*.key').exists().trim().escape(),
|
||||
check('custom_fields.*.value').exists(),
|
||||
],
|
||||
async handler(req, res) {
|
||||
const validationErrors = validationResult(req);
|
||||
|
||||
if (!validationErrors.isEmpty()) {
|
||||
return res.boom.badData(null, {
|
||||
code: 'validation_error',
|
||||
...validationErrors,
|
||||
});
|
||||
}
|
||||
const { user } = req;
|
||||
const { Expense, ExpenseCategory, Account } = req.models;
|
||||
|
||||
const form = {
|
||||
date: new Date(),
|
||||
published: false,
|
||||
custom_fields: [],
|
||||
categories: [],
|
||||
...req.body,
|
||||
};
|
||||
const totalAmount = sumBy(form.categories, 'amount');
|
||||
const expenseAccountsIds = form.categories.map(
|
||||
(account) => account.expense_account_id
|
||||
);
|
||||
|
||||
const storedExpenseAccounts = await Account.query().whereIn(
|
||||
'id',
|
||||
expenseAccountsIds
|
||||
);
|
||||
const storedExpenseAccountsIds = storedExpenseAccounts.map((a) => a.id);
|
||||
|
||||
const notStoredExpensesAccountsIds = difference(
|
||||
expenseAccountsIds,
|
||||
storedExpenseAccountsIds
|
||||
);
|
||||
const errorReasons = [];
|
||||
|
||||
const paymentAccount = await Account.query()
|
||||
.where('id', form.payment_account_id)
|
||||
.first();
|
||||
|
||||
if (!paymentAccount) {
|
||||
errorReasons.push({
|
||||
type: 'PAYMENT.ACCOUNT.NOT.FOUND',
|
||||
code: 500,
|
||||
});
|
||||
}
|
||||
if (notStoredExpensesAccountsIds.length > 0) {
|
||||
errorReasons.push({
|
||||
type: 'EXPENSE.ACCOUNTS.IDS.NOT.STORED',
|
||||
code: 400,
|
||||
ids: notStoredExpensesAccountsIds,
|
||||
});
|
||||
}
|
||||
if (totalAmount <= 0) {
|
||||
errorReasons.push({ type: 'TOTAL.AMOUNT.EQUALS.ZERO', code: 300 });
|
||||
}
|
||||
if (errorReasons.length > 0) {
|
||||
return res.status(400).send({ errors: errorReasons });
|
||||
}
|
||||
|
||||
const expenseTransaction = await Expense.query().insert({
|
||||
total_amount: totalAmount,
|
||||
payment_account_id: form.payment_account_id,
|
||||
reference_no: form.reference_no,
|
||||
description: form.description,
|
||||
payment_date: moment(form.payment_date).format('YYYY-MM-DD'),
|
||||
user_id: user.id,
|
||||
});
|
||||
const storeExpenseCategoriesOper = [];
|
||||
|
||||
form.categories.forEach((category) => {
|
||||
const oper = ExpenseCategory.query().insert({
|
||||
expense_id: expenseTransaction.id,
|
||||
...category,
|
||||
});
|
||||
storeExpenseCategoriesOper.push(oper);
|
||||
});
|
||||
|
||||
const accountsDepGraph = await Account.depGraph().query();
|
||||
const journalPoster = new JournalPoster(accountsDepGraph);
|
||||
|
||||
const mixinEntry = {
|
||||
referenceType: 'Expense',
|
||||
referenceId: expenseTransaction.id,
|
||||
date: moment(form.payment_date).format('YYYY-MM-DD'),
|
||||
userId: user.id,
|
||||
draft: !form.publish,
|
||||
};
|
||||
const paymentJournalEntry = new JournalEntry({
|
||||
credit: totalAmount,
|
||||
account: paymentAccount.id,
|
||||
...mixinEntry,
|
||||
});
|
||||
journalPoster.credit(paymentJournalEntry);
|
||||
|
||||
form.categories.forEach((category) => {
|
||||
const expenseJournalEntry = new JournalEntry({
|
||||
account: category.expense_account_id,
|
||||
debit: category.amount,
|
||||
note: category.description,
|
||||
...mixinEntry,
|
||||
});
|
||||
journalPoster.debit(expenseJournalEntry);
|
||||
});
|
||||
await Promise.all([
|
||||
...storeExpenseCategoriesOper,
|
||||
journalPoster.saveEntries(),
|
||||
form.status && journalPoster.saveBalance(),
|
||||
]);
|
||||
|
||||
return res.status(200).send({ id: expenseTransaction.id });
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Publish the given expense id.
|
||||
*/
|
||||
publishExpense: {
|
||||
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 { Expense, Account, AccountTransaction } = req.models;
|
||||
const expense = await Expense.query().findById(id);
|
||||
const errorReasons = [];
|
||||
|
||||
if (!expense) {
|
||||
errorReasons.push({ type: 'EXPENSE.NOT.FOUND', code: 100 });
|
||||
return res.status(400).send({ errors: errorReasons });
|
||||
}
|
||||
if (expense.published) {
|
||||
errorReasons.push({ type: 'EXPENSE.ALREADY.PUBLISHED', code: 200 });
|
||||
}
|
||||
if (errorReasons.length > 0) {
|
||||
return res.status(400).send({ errors: errorReasons });
|
||||
}
|
||||
const transactions = await AccountTransaction.query()
|
||||
.whereIn('reference_type', ['Expense'])
|
||||
.where('reference_id', expense.id)
|
||||
.withGraphFetched('account.type');
|
||||
|
||||
const accountsDepGraph = await Account.depGraph().query().remember();
|
||||
const journal = new JournalPoster(accountsDepGraph);
|
||||
|
||||
journal.loadEntries(transactions);
|
||||
journal.calculateEntriesBalanceChange();
|
||||
|
||||
const updateAccTransactionsOper = AccountTransaction.query()
|
||||
.where('reference_id', expense.id)
|
||||
.where('reference_type', 'Expense')
|
||||
.patch({
|
||||
draft: false,
|
||||
});
|
||||
|
||||
const updateExpenseOper = Expense.query()
|
||||
.where('id', expense.id)
|
||||
.update({ published: true });
|
||||
|
||||
await Promise.all([
|
||||
updateAccTransactionsOper,
|
||||
updateExpenseOper,
|
||||
journal.saveBalance(),
|
||||
]);
|
||||
return res.status(200).send();
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Retrieve paginated expenses list.
|
||||
*/
|
||||
listExpenses: {
|
||||
validation: [
|
||||
query('page').optional().isNumeric().toInt(),
|
||||
query('page_size').optional().isNumeric().toInt(),
|
||||
|
||||
query('custom_view_id').optional().isNumeric().toInt(),
|
||||
query('stringified_filter_roles').optional().isJSON(),
|
||||
|
||||
query('column_sort_by').optional(),
|
||||
query('sort_order').optional().isIn(['desc', 'asc']),
|
||||
],
|
||||
async handler(req, res) {
|
||||
const validationErrors = validationResult(req);
|
||||
|
||||
if (!validationErrors.isEmpty()) {
|
||||
return res.boom.badData(null, {
|
||||
code: 'validation_error',
|
||||
...validationErrors,
|
||||
});
|
||||
}
|
||||
|
||||
const filter = {
|
||||
sort_order: 'asc',
|
||||
filter_roles: [],
|
||||
page_size: 15,
|
||||
page: 1,
|
||||
...req.query,
|
||||
};
|
||||
const errorReasons = [];
|
||||
const { Resource, Expense, View } = req.models;
|
||||
|
||||
const expensesResource = await Resource.query()
|
||||
.remember()
|
||||
.where('name', 'expenses')
|
||||
.withGraphFetched('fields')
|
||||
.first();
|
||||
|
||||
const expensesResourceFields = expensesResource.fields.map((f) => f.key);
|
||||
|
||||
if (!expensesResource) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'EXPENSES.RESOURCE.NOT.FOUND', code: 200 }],
|
||||
});
|
||||
}
|
||||
const view = await View.query().onBuild((builder) => {
|
||||
if (filter.csutom_view_id) {
|
||||
builder.where('id', filter.csutom_view_id);
|
||||
} else {
|
||||
builder.where('favourite', true);
|
||||
}
|
||||
builder.withGraphFetched('roles.field');
|
||||
builder.withGraphFetched('columns');
|
||||
builder.first();
|
||||
});
|
||||
const dynamicFilter = new DynamicFilter(Expense.tableName);
|
||||
|
||||
// Column sorting.
|
||||
if (filter.column_sort_by) {
|
||||
if (expensesResourceFields.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);
|
||||
}
|
||||
// Custom 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);
|
||||
}
|
||||
// Filter roles.
|
||||
if (filter.filter_roles.length > 0) {
|
||||
const filterRoles = new DynamicFilterFilterRoles(
|
||||
mapFilterRolesToDynamicFilter(filter.filter_roles),
|
||||
expensesResource.fields
|
||||
);
|
||||
if (filterRoles.validateFilterRoles().length > 0) {
|
||||
errorReasons.push({
|
||||
type: 'ACCOUNTS.RESOURCE.HAS.NO.GIVEN.FIELDS',
|
||||
code: 500,
|
||||
});
|
||||
}
|
||||
dynamicFilter.setFilter(filterRoles);
|
||||
}
|
||||
if (errorReasons.length > 0) {
|
||||
return res.status(400).send({ errors: errorReasons });
|
||||
}
|
||||
const expenses = await Expense.query()
|
||||
.onBuild((builder) => {
|
||||
builder.withGraphFetched('paymentAccount');
|
||||
builder.withGraphFetched('categories.expenseAccount');
|
||||
builder.withGraphFetched('user');
|
||||
dynamicFilter.buildQuery()(builder);
|
||||
})
|
||||
.pagination(filter.page - 1, filter.page_size);
|
||||
|
||||
return res.status(200).send({
|
||||
expenses: {
|
||||
...expenses,
|
||||
...(view
|
||||
? {
|
||||
viewMeta: {
|
||||
viewColumns: view.columns,
|
||||
customViewId: view.id,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete the given expense transaction.
|
||||
*/
|
||||
deleteExpense: {
|
||||
validation: [param('id').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 {
|
||||
Expense,
|
||||
ExpenseCategory,
|
||||
AccountTransaction,
|
||||
Account,
|
||||
} = req.models;
|
||||
|
||||
const expense = await Expense.query().where('id', id).first();
|
||||
|
||||
if (!expense) {
|
||||
return res.status(404).send({
|
||||
errors: [
|
||||
{
|
||||
type: 'EXPENSE.NOT.FOUND',
|
||||
code: 200,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
await ExpenseCategory.query().where('expense_id', id).delete();
|
||||
|
||||
const deleteExpenseOper = Expense.query().where('id', id).delete();
|
||||
const expenseTransactions = await AccountTransaction.query()
|
||||
.where('reference_type', 'Expense')
|
||||
.where('reference_id', expense.id);
|
||||
|
||||
const accountsDepGraph = await Account.depGraph().query().remember();
|
||||
const journalEntries = new JournalPoster(accountsDepGraph);
|
||||
|
||||
journalEntries.loadEntries(expenseTransactions);
|
||||
journalEntries.removeEntries();
|
||||
|
||||
await Promise.all([
|
||||
deleteExpenseOper,
|
||||
journalEntries.deleteEntries(),
|
||||
journalEntries.saveBalance(),
|
||||
]);
|
||||
return res.status(200).send();
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Update details of the given account.
|
||||
*/
|
||||
updateExpense: {
|
||||
validation: [
|
||||
param('id').isNumeric().toInt(),
|
||||
check('reference_no').optional().trim().escape(),
|
||||
check('payment_date').isISO8601().optional(),
|
||||
check('payment_account_id').exists().isNumeric().toInt(),
|
||||
check('description').optional(),
|
||||
check('currency_code').optional(),
|
||||
check('exchange_rate').optional().isNumeric().toFloat(),
|
||||
check('publish').optional().isBoolean().toBoolean(),
|
||||
|
||||
check('categories').exists().isArray({ min: 1 }),
|
||||
check('categories.*.id').optional().isNumeric().toInt(),
|
||||
check('categories.*.index').exists().isNumeric().toInt(),
|
||||
check('categories.*.expense_account_id').exists().isNumeric().toInt(),
|
||||
check('categories.*.amount').optional().isNumeric().toFloat(),
|
||||
check('categories.*.description').optional().trim().escape(),
|
||||
],
|
||||
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 { user } = req;
|
||||
const {
|
||||
Account,
|
||||
Expense,
|
||||
ExpenseCategory,
|
||||
AccountTransaction,
|
||||
} = req.models;
|
||||
|
||||
const form = {
|
||||
categories: [],
|
||||
...req.body,
|
||||
};
|
||||
if (!Array.isArray(form.categories)) {
|
||||
form.categories = [form.categories];
|
||||
}
|
||||
const expense = await Expense.query()
|
||||
.where('id', id)
|
||||
.withGraphFetched('categories')
|
||||
.first();
|
||||
|
||||
if (!expense) {
|
||||
return res.status(404).send({
|
||||
errors: [{ type: 'EXPENSE.NOT.FOUND', code: 200 }],
|
||||
});
|
||||
}
|
||||
const errorReasons = [];
|
||||
const paymentAccount = await Account.query()
|
||||
.where('id', form.payment_account_id)
|
||||
.first();
|
||||
|
||||
if (!paymentAccount) {
|
||||
errorReasons.push({ type: 'PAYMENT.ACCOUNT.NOT.FOUND', code: 400 });
|
||||
}
|
||||
const categoriesHasNoId = form.categories.filter((c) => !c.id);
|
||||
const categoriesHasId = form.categories.filter((c) => c.id);
|
||||
|
||||
const expenseCategoriesIds = expense.categories.map((c) => c.id);
|
||||
const formExpenseCategoriesIds = categoriesHasId.map((c) => c.id);
|
||||
|
||||
const categoriesIdsDeleted = difference(
|
||||
formExpenseCategoriesIds,
|
||||
expenseCategoriesIds
|
||||
);
|
||||
const categoriesShouldDelete = difference(
|
||||
expenseCategoriesIds,
|
||||
formExpenseCategoriesIds
|
||||
);
|
||||
|
||||
const formExpensesAccountsIds = form.categories.map(
|
||||
(c) => c.expense_account_id
|
||||
);
|
||||
const storedExpenseAccounts = await Account.query().whereIn(
|
||||
'id',
|
||||
formExpensesAccountsIds
|
||||
);
|
||||
const storedExpenseAccountsIds = storedExpenseAccounts.map((a) => a.id);
|
||||
|
||||
const expenseAccountsIdsNotFound = difference(
|
||||
formExpensesAccountsIds,
|
||||
storedExpenseAccountsIds
|
||||
);
|
||||
const totalAmount = sumBy(form.categories, 'amount');
|
||||
|
||||
if (expenseAccountsIdsNotFound.length > 0) {
|
||||
errorReasons.push({
|
||||
type: 'EXPENSE.ACCOUNTS.IDS.NOT.FOUND',
|
||||
code: 600,
|
||||
ids: expenseAccountsIdsNotFound,
|
||||
});
|
||||
}
|
||||
|
||||
if (categoriesIdsDeleted.length > 0) {
|
||||
errorReasons.push({
|
||||
type: 'EXPENSE.CATEGORIES.IDS.NOT.FOUND',
|
||||
code: 300,
|
||||
});
|
||||
}
|
||||
if (totalAmount <= 0) {
|
||||
errorReasons.push({ type: 'TOTAL.AMOUNT.EQUALS.ZERO', code: 500 });
|
||||
}
|
||||
// Handle all error reasons.
|
||||
if (errorReasons.length > 0) {
|
||||
return res.status(400).send({ errors: errorReasons });
|
||||
}
|
||||
const expenseCategoriesMap = new Map(
|
||||
expense.categories.map((category) => [category.id, category])
|
||||
);
|
||||
|
||||
const categoriesInsertOpers = [];
|
||||
const categoriesUpdateOpers = [];
|
||||
|
||||
categoriesHasNoId.forEach((category) => {
|
||||
const oper = ExpenseCategory.query().insert({
|
||||
...category,
|
||||
expense_id: expense.id,
|
||||
});
|
||||
categoriesInsertOpers.push(oper);
|
||||
});
|
||||
|
||||
categoriesHasId.forEach((category) => {
|
||||
const oper = ExpenseCategory.query()
|
||||
.where('id', category.id)
|
||||
.patch({
|
||||
...omit(category, ['id']),
|
||||
});
|
||||
categoriesUpdateOpers.push(oper);
|
||||
});
|
||||
|
||||
const updateExpenseOper = Expense.query()
|
||||
.where('id', id)
|
||||
.update({
|
||||
payment_date: moment(form.payment_date).format('YYYY-MM-DD'),
|
||||
total_amount: totalAmount,
|
||||
description: form.description,
|
||||
payment_account_id: form.payment_account_id,
|
||||
reference_no: form.reference_no,
|
||||
});
|
||||
|
||||
const deleteCategoriesOper =
|
||||
categoriesShouldDelete.length > 0
|
||||
? ExpenseCategory.query()
|
||||
.whereIn('id', categoriesShouldDelete)
|
||||
.delete()
|
||||
: Promise.resolve();
|
||||
|
||||
// Update the journal entries.
|
||||
const transactions = await AccountTransaction.query()
|
||||
.whereIn('reference_type', ['Expense'])
|
||||
.where('reference_id', expense.id)
|
||||
.withGraphFetched('account.type');
|
||||
|
||||
const accountsDepGraph = await Account.depGraph().query().remember();
|
||||
const journal = new JournalPoster(accountsDepGraph);
|
||||
|
||||
journal.loadEntries(transactions);
|
||||
journal.removeEntries();
|
||||
|
||||
const mixinEntry = {
|
||||
referenceType: 'Expense',
|
||||
referenceId: expense.id,
|
||||
userId: user.id,
|
||||
draft: !form.publish,
|
||||
};
|
||||
const paymentJournalEntry = new JournalEntry({
|
||||
credit: totalAmount,
|
||||
account: paymentAccount.id,
|
||||
...mixinEntry,
|
||||
});
|
||||
journal.credit(paymentJournalEntry);
|
||||
|
||||
form.categories.forEach((category) => {
|
||||
const entry = new JournalEntry({
|
||||
account: category.expense_account_id,
|
||||
debit: category.amount,
|
||||
note: category.description,
|
||||
...mixinEntry,
|
||||
});
|
||||
journal.debit(entry);
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
...categoriesInsertOpers,
|
||||
...categoriesUpdateOpers,
|
||||
updateExpenseOper,
|
||||
deleteCategoriesOper,
|
||||
|
||||
journal.saveEntries(),
|
||||
form.status && journal.saveBalance(),
|
||||
]);
|
||||
return res.status(200).send({ id });
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Retrieve details of the given expense id.
|
||||
*/
|
||||
getExpense: {
|
||||
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 { Expense, AccountTransaction } = req.models;
|
||||
|
||||
const expense = await Expense.query()
|
||||
.where('id', id)
|
||||
.withGraphFetched('categories')
|
||||
.withGraphFetched('paymentAccount')
|
||||
.withGraphFetched('user')
|
||||
.first();
|
||||
|
||||
if (!expense) {
|
||||
return res.status(404).send({
|
||||
errors: [{ type: 'EXPENSE.NOT.FOUND', code: 200 }],
|
||||
});
|
||||
}
|
||||
|
||||
const journalEntries = await AccountTransaction.query()
|
||||
.where('reference_id', expense.id)
|
||||
.where('reference_type', 'Expense');
|
||||
|
||||
return res.status(200).send({
|
||||
expense: {
|
||||
...expense.toJSON(),
|
||||
journalEntries,
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Deletes bulk expenses.
|
||||
*/
|
||||
deleteBulkExpenses: {
|
||||
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 { Expense, AccountTransaction, Account, MediaLink } = req.models;
|
||||
|
||||
const expenses = await Expense.query().whereIn('id', filter.ids);
|
||||
|
||||
const storedExpensesIds = expenses.map((e) => e.id);
|
||||
const notFoundExpenses = difference(filter.ids, storedExpensesIds);
|
||||
|
||||
if (notFoundExpenses.length > 0) {
|
||||
return res.status(404).send({
|
||||
errors: [{ type: 'EXPENSES.NOT.FOUND', code: 200 }],
|
||||
});
|
||||
}
|
||||
|
||||
const deleteExpensesOper = Expense.query()
|
||||
.whereIn('id', storedExpensesIds)
|
||||
.delete();
|
||||
|
||||
const transactions = await AccountTransaction.query()
|
||||
.whereIn('reference_type', ['Expense'])
|
||||
.whereIn('reference_id', filter.ids);
|
||||
|
||||
const accountsDepGraph = await Account.depGraph().query().remember();
|
||||
const journal = new JournalPoster(accountsDepGraph);
|
||||
|
||||
journal.loadEntries(transactions);
|
||||
journal.removeEntries();
|
||||
|
||||
await MediaLink.query()
|
||||
.where('model_name', 'Expense')
|
||||
.whereIn('model_id', filter.ids)
|
||||
.delete();
|
||||
|
||||
await Promise.all([
|
||||
deleteExpensesOper,
|
||||
journal.deleteEntries(),
|
||||
journal.saveBalance(),
|
||||
]);
|
||||
return res.status(200).send({ ids: filter.ids });
|
||||
},
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user