diff --git a/server/src/api/controllers/Contacts/Customers.ts b/server/src/api/controllers/Contacts/Customers.ts index 14798b95d..382bf5982 100644 --- a/server/src/api/controllers/Contacts/Customers.ts +++ b/server/src/api/controllers/Contacts/Customers.ts @@ -32,6 +32,17 @@ export default class CustomersController extends ContactsController { asyncMiddleware(this.newCustomer.bind(this)), this.handlerServiceErrors ); + router.post( + '/:id/opening_balance', + [ + ...this.specificContactSchema, + check('opening_balance').exists().isNumeric().toFloat(), + check('opening_balance_at').optional().isISO8601(), + ], + this.validationResult, + asyncMiddleware(this.editOpeningBalanceCustomer.bind(this)), + this.handlerServiceErrors, + ); router.post('/:id', [ ...this.contactDTOSchema, ...this.contactEditDTOSchema, @@ -160,6 +171,36 @@ export default class CustomersController extends ContactsController { } } + /** + * Changes the opening balance of the given customer. + * @param {Request} req - + * @param {Response} res - + * @param {NextFunction} next - + */ + async editOpeningBalanceCustomer(req: Request, res: Response, next: NextFunction) { + const { tenantId } = req; + const { id: customerId } = req.params; + const { + openingBalance, + openingBalanceAt, + } = this.matchedBodyData(req); + + try { + await this.customersService.changeOpeningBalance( + tenantId, + customerId, + openingBalance, + openingBalanceAt, + ); + return res.status(200).send({ + id: customerId, + message: 'The opening balance of the given customer has been changed successfully.', + }); + } catch (error) { + next(error); + } + } + /** * Deletes the given customer from the storage. * @param {Request} req @@ -283,6 +324,11 @@ export default class CustomersController extends ContactsController { errors: [{ type: 'CUSTOMER.HAS.SALES_INVOICES', code: 400 }], }); } + if (error.errorType === 'OPENING_BALANCE_DATE_REQUIRED') { + return res.boom.badRequest(null, { + errors: [{ type: 'OPENING_BALANCE_DATE_REQUIRED', code: 500 }], + }); + } } next(error); } diff --git a/server/src/api/controllers/Contacts/Vendors.ts b/server/src/api/controllers/Contacts/Vendors.ts index 162ac1c22..d8c80dee2 100644 --- a/server/src/api/controllers/Contacts/Vendors.ts +++ b/server/src/api/controllers/Contacts/Vendors.ts @@ -28,6 +28,17 @@ export default class VendorsController extends ContactsController { asyncMiddleware(this.newVendor.bind(this)), this.handlerServiceErrors, ); + router.post( + '/:id/opening_balance', + [ + ...this.specificContactSchema, + check('opening_balance').exists().isNumeric().toFloat(), + check('opening_balance_at').optional().isISO8601(), + ], + this.validationResult, + asyncMiddleware(this.editOpeningBalanceVendor.bind(this)), + this.handlerServiceErrors, + ); router.post('/:id', [ ...this.contactDTOSchema, ...this.contactEditDTOSchema, @@ -144,6 +155,36 @@ export default class VendorsController extends ContactsController { } } + /** + * Changes the opening balance of the given vendor. + * @param {Request} req - + * @param {Response} res - + * @param {NextFunction} next - + */ + async editOpeningBalanceVendor(req: Request, res: Response, next: NextFunction) { + const { tenantId } = req; + const { id: vendorId } = req.params; + const { + openingBalance, + openingBalanceAt, + } = this.matchedBodyData(req); + + try { + await this.vendorsService.changeOpeningBalance( + tenantId, + vendorId, + openingBalance, + openingBalanceAt, + ); + return res.status(200).send({ + id: vendorId, + message: 'The opening balance of the given vendor has been changed successfully.', + }); + } catch (error) { + next(error); + } + } + /** * Deletes the given vendor from the storage. * @param {Request} req @@ -261,6 +302,11 @@ export default class VendorsController extends ContactsController { errors: [{ type: 'VENDOR.HAS.BILLS', code: 400 }], }); } + if (error.errorType === 'OPENING_BALANCE_DATE_REQUIRED') { + return res.boom.badRequest(null, { + errors: [{ type: 'OPENING_BALANCE_DATE_REQUIRED', code: 500 }], + }); + } } next(error); } diff --git a/server/src/api/controllers/Expenses.ts b/server/src/api/controllers/Expenses.ts index 46c343cb2..03789ea25 100644 --- a/server/src/api/controllers/Expenses.ts +++ b/server/src/api/controllers/Expenses.ts @@ -104,6 +104,7 @@ export default class ExpensesController extends BaseController { check('currency_code').optional().isString().isLength({ max: 3 }), check('exchange_rate').optional({ nullable: true }).isNumeric().toFloat(), check('publish').optional().isBoolean().toBoolean(), + check('payee_id').optional({ nullable: true }).isNumeric().toInt(), check('categories').exists().isArray({ min: 1 }), check('categories.*.index') @@ -392,6 +393,11 @@ export default class ExpensesController extends BaseController { errors: [{ type: 'EXPENSE_ALREADY_PUBLISHED', code: 700 }], }); } + if (error.errorType === 'contact_not_found') { + return res.boom.badRequest(null, { + errors: [{ type: 'CONTACT_NOT_FOUND', code: 800 }], + }); + } } next(error); } diff --git a/server/src/interfaces/Expenses.ts b/server/src/interfaces/Expenses.ts index 4a3263f0f..8a7ebac76 100644 --- a/server/src/interfaces/Expenses.ts +++ b/server/src/interfaces/Expenses.ts @@ -22,7 +22,7 @@ export interface IExpense { publishedAt: Date|null, userId: number, paymentDate: Date, - + payeeId: number, categories: IExpenseCategory[], } @@ -43,7 +43,7 @@ export interface IExpenseDTO { publish: boolean, userId: number, paymentDate: Date, - + payeeId: number, categories: IExpenseCategoryDTO[], } diff --git a/server/src/repositories/EntityRepository.ts b/server/src/repositories/EntityRepository.ts index 24898fe49..04fe9f421 100644 --- a/server/src/repositories/EntityRepository.ts +++ b/server/src/repositories/EntityRepository.ts @@ -205,14 +205,15 @@ export default class EntityRepository { } /** - * + * Arbitrary relation graphs can be upserted (insert + update + delete) + * using the upsertGraph method. * @param graph * @param options */ upsertGraph(graph, options) { // Keep the input grpah immutable const graphCloned = cloneDeep(graph); - return this.model.upsertGraph(graphCloned) + return this.model.query().upsertGraph(graphCloned, options) } /** diff --git a/server/src/repositories/ExpenseRepository.ts b/server/src/repositories/ExpenseRepository.ts index a93ad7e31..92ea4e8fc 100644 --- a/server/src/repositories/ExpenseRepository.ts +++ b/server/src/repositories/ExpenseRepository.ts @@ -1,6 +1,7 @@ import TenantRepository from "./TenantRepository"; import moment from "moment"; import { Expense } from 'models'; + export default class ExpenseRepository extends TenantRepository { /** * Constructor method. diff --git a/server/src/services/Contacts/ContactsService.ts b/server/src/services/Contacts/ContactsService.ts index 97500d39a..6fc245f73 100644 --- a/server/src/services/Contacts/ContactsService.ts +++ b/server/src/services/Contacts/ContactsService.ts @@ -1,5 +1,6 @@ import { Inject, Service } from 'typedi'; import { difference, upperFirst, omit } from 'lodash'; +import moment from 'moment'; import { ServiceError } from "exceptions"; import TenancyService from 'services/Tenancy/TenancyService'; import { @@ -11,6 +12,10 @@ import JournalPoster from '../Accounting/JournalPoster'; type TContactService = 'customer' | 'vendor'; +const ERRORS = { + OPENING_BALANCE_DATE_REQUIRED: 'OPENING_BALANCE_DATE_REQUIRED', +}; + @Service() export default class ContactsService { @Inject() @@ -26,13 +31,17 @@ export default class ContactsService { * @param {TContactService} contactService * @return {Promise} */ - public async getContactByIdOrThrowError(tenantId: number, contactId: number, contactService: TContactService) { + public async getContactByIdOrThrowError( + tenantId: number, + contactId: number, + contactService?: TContactService + ) { const { contactRepository } = this.tenancy.repositories(tenantId); this.logger.info('[contact] trying to validate contact existance.', { tenantId, contactId }); const contact = await contactRepository.findOne({ id: contactId, - contactService: contactService, + ...(contactService) && ({ contactService }), }); if (!contact) { @@ -185,4 +194,40 @@ export default class ContactsService { journal.deleteEntries(), ]); } + + /** + * Chanages the opening balance of the given contact. + * @param {number} tenantId + * @param {number} contactId + * @param {ICustomerChangeOpeningBalanceDTO} changeOpeningBalance + * @return {Promise} + */ + public async changeOpeningBalance( + tenantId: number, + contactId: number, + contactService: string, + openingBalance: number, + openingBalanceAt?: Date|string, + ): Promise { + const { contactRepository } = this.tenancy.repositories(tenantId); + + // Retrieve the given contact details or throw not found service error. + const contact = await this.getContactByIdOrThrowError(tenantId, contactId, contactService); + + // Should the opening balance date be required. + if (!contact.openingBalanceAt && !openingBalanceAt) { + throw new ServiceError(ERRORS.OPENING_BALANCE_DATE_REQUIRED); + }; + // Changes the customer the opening balance and opening balance date. + await contactRepository.update({ + openingBalance: openingBalance, + + ...(openingBalanceAt) && ({ + openingBalanceAt: moment(openingBalanceAt).toMySqlDateTime(), + }), + }, { + id: contactId, + contactService, + }); + } } \ No newline at end of file diff --git a/server/src/services/Contacts/CustomersService.ts b/server/src/services/Contacts/CustomersService.ts index 9ee8b30ed..43c2dd867 100644 --- a/server/src/services/Contacts/CustomersService.ts +++ b/server/src/services/Contacts/CustomersService.ts @@ -16,14 +16,13 @@ import { IContactNewDTO, IContactEditDTO, IContact, - ISaleInvoice + ISaleInvoice, } from 'interfaces'; import { ServiceError } from 'exceptions'; import TenancyService from 'services/Tenancy/TenancyService'; import DynamicListingService from 'services/DynamicListing/DynamicListService'; import events from 'subscribers/events'; import moment from 'moment'; -import SaleInvoiceRepository from 'repositories/SaleInvoiceRepository'; @Service() export default class CustomersService { @@ -298,4 +297,31 @@ export default class CustomersService { throw new ServiceError('some_customers_have_invoices'); } } + + /** + * Changes the opening balance of the given customer. + * @param {number} tenantId + * @param {number} customerId + * @param {number} openingBalance + * @param {string|Date} openingBalanceAt + */ + public async changeOpeningBalance( + tenantId: number, + customerId: number, + openingBalance: number, + openingBalanceAt: Date|string, + ) { + + await this.contactService.changeOpeningBalance( + tenantId, + customerId, + 'customer', + openingBalance, + openingBalanceAt, + ); + // Triggers `onOpeingBalanceChanged` event. + await this.eventDispatcher.dispatch(events.customers.onOpeningBalanceChanged, { + tenantId, customerId, openingBalance, openingBalanceAt + }); + } } diff --git a/server/src/services/Contacts/VendorsService.ts b/server/src/services/Contacts/VendorsService.ts index 16982d750..9c34ad725 100644 --- a/server/src/services/Contacts/VendorsService.ts +++ b/server/src/services/Contacts/VendorsService.ts @@ -251,4 +251,31 @@ export default class VendorsService { filterMeta: dynamicFilter.getResponseMeta(), }; } + + /** + * Changes the opeing balance of the given vendor. + * @param {number} tenantId + * @param {number} vendorId + * @param {number} openingBalance + * @param {Date|string} openingBalanceAt + */ + public async changeOpeningBalance( + tenantId: number, + vendorId: number, + openingBalance: number, + openingBalanceAt: Date|string, + ): Promise { + + await this.contactService.changeOpeningBalance( + tenantId, + vendorId, + 'vendor', + openingBalance, + openingBalanceAt, + ); + // Triggers `onOpeingBalanceChanged` event. + await this.eventDispatcher.dispatch(events.vendors.onOpeningBalanceChanged, { + tenantId, vendorId, openingBalance, openingBalanceAt + }); + } } diff --git a/server/src/services/Expenses/ExpensesService.ts b/server/src/services/Expenses/ExpensesService.ts index 27634afb5..c348bf227 100644 --- a/server/src/services/Expenses/ExpensesService.ts +++ b/server/src/services/Expenses/ExpensesService.ts @@ -12,6 +12,7 @@ import JournalCommands from 'services/Accounting/JournalCommands'; import { IExpense, IExpensesFilter, IAccount, IExpenseDTO, IExpensesService, ISystemUser, IPaginationMeta } from 'interfaces'; import DynamicListingService from 'services/DynamicListing/DynamicListService'; import events from 'subscribers/events'; +import ContactsService from "services/Contacts/ContactsService"; const ERRORS = { EXPENSE_NOT_FOUND: 'expense_not_found', @@ -38,6 +39,9 @@ export default class ExpensesService implements IExpensesService { @EventDispatcher() eventDispatcher: EventDispatcherInterface; + @Inject() + contactsService: ContactsService; + /** * Retrieve the payment account details or returns not found server error in case the * given account not found on the storage. @@ -323,6 +327,13 @@ export default class ExpensesService implements IExpensesService { // - Validate expenses accounts type. await this.validateExpensesAccountsType(tenantId, expensesAccounts); + // - Validate the expense payee contact id existance on storage. + if (expenseDTO.payeeId) { + await this.contactsService.getContactByIdOrThrowError( + tenantId, + expenseDTO.payeeId, + ) + } // - Validate the given expense categories not equal zero. this.validateCategoriesNotEqualZero(expenseDTO); @@ -346,8 +357,9 @@ export default class ExpensesService implements IExpensesService { * 2. Validate expense accounts exist on the storage. * 3. Validate payment account type. * 4. Validate expenses accounts type. - * 5. Validate the given expense categories not equal zero. - * 6. Stores the expense to the storage. + * 5. Validate the expense payee contact id existance on storage. + * 6. Validate the given expense categories not equal zero. + * 7. Stores the expense to the storage. * --------- * @param {number} tenantId * @param {IExpenseDTO} expenseDTO @@ -359,26 +371,33 @@ export default class ExpensesService implements IExpensesService { ): Promise { const { expenseRepository } = this.tenancy.repositories(tenantId); - // 1. Validate payment account existance on the storage. + // - Validate payment account existance on the storage. const paymentAccount = await this.getPaymentAccountOrThrowError( tenantId, expenseDTO.paymentAccountId, ); - // 2. Validate expense accounts exist on the storage. + // - Validate expense accounts exist on the storage. const expensesAccounts = await this.getExpensesAccountsOrThrowError( tenantId, this.mapExpensesAccountsIdsFromDTO(expenseDTO), ); - // 3. Validate payment account type. + // - Validate payment account type. await this.validatePaymentAccountType(tenantId, paymentAccount); - // 4. Validate expenses accounts type. + // - Validate expenses accounts type. await this.validateExpensesAccountsType(tenantId, expensesAccounts); - // 5. Validate the given expense categories not equal zero. + // - Validate the expense payee contact id existance on storage. + if (expenseDTO.payeeId) { + await this.contactsService.getContactByIdOrThrowError( + tenantId, + expenseDTO.payeeId, + ) + } + // - Validate the given expense categories not equal zero. this.validateCategoriesNotEqualZero(expenseDTO); - // 6. Save the expense to the storage. + // - Save the expense to the storage. const expenseObj = this.expenseDTOToModel(expenseDTO, authorizedUser); const expenseModel = await expenseRepository.upsertGraph(expenseObj); diff --git a/server/src/services/Items/ItemsService.ts b/server/src/services/Items/ItemsService.ts index b729e0d83..4e8394579 100644 --- a/server/src/services/Items/ItemsService.ts +++ b/server/src/services/Items/ItemsService.ts @@ -19,7 +19,7 @@ const ERRORS = { ITEMS_HAVE_ASSOCIATED_TRANSACTIONS: 'ITEMS_HAVE_ASSOCIATED_TRANSACTIONS', ITEM_HAS_ASSOCIATED_TRANSACTINS: 'ITEM_HAS_ASSOCIATED_TRANSACTINS' -} +}; @Service() export default class ItemsService implements IItemsService { diff --git a/server/src/subscribers/events.ts b/server/src/subscribers/events.ts index 0bd55d059..10aa2f529 100644 --- a/server/src/subscribers/events.ts +++ b/server/src/subscribers/events.ts @@ -146,6 +146,7 @@ export default { onEdited: 'onCustomerEdited', onDeleted: 'onCustomerDeleted', onBulkDeleted: 'onBulkDeleted', + onOpeningBalanceChanged: 'onOpeingBalanceChanged', }, /** @@ -156,6 +157,7 @@ export default { onEdited: 'onVendorEdited', onDeleted: 'onVendorDeleted', onBulkDeleted: 'onVendorBulkDeleted', + onOpeningBalanceChanged: 'onOpeingBalanceChanged', }, items: {