diff --git a/server/src/api/controllers/Expenses.ts b/server/src/api/controllers/Expenses.ts index c30dca97e..a207b6029 100644 --- a/server/src/api/controllers/Expenses.ts +++ b/server/src/api/controllers/Expenses.ts @@ -111,6 +111,7 @@ export default class ExpensesController extends BaseController { .trim() .escape() .isLength({ max: DATATYPES_LENGTH.STRING }), + check('categories.*.landed_cost').optional().isBoolean().toBoolean(), ]; } @@ -251,11 +252,8 @@ export default class ExpensesController extends BaseController { } try { - const { - expenses, - pagination, - filterMeta, - } = await this.expensesService.getExpensesList(tenantId, filter); + const { expenses, pagination, filterMeta } = + await this.expensesService.getExpensesList(tenantId, filter); return res.status(200).send({ expenses, @@ -345,6 +343,11 @@ export default class ExpensesController extends BaseController { errors: [{ type: 'CONTACT_NOT_FOUND', code: 800 }], }); } + if (error.errorType === 'EXPENSE_HAS_ASSOCIATED_LANDED_COST') { + return res.status(400).send({ + errors: [{ type: 'EXPENSE_HAS_ASSOCIATED_LANDED_COST', code: 900 }], + }); + } } next(error); } diff --git a/server/src/api/controllers/Purchases/Bills.ts b/server/src/api/controllers/Purchases/Bills.ts index d8f8affde..7100bd3ad 100644 --- a/server/src/api/controllers/Purchases/Bills.ts +++ b/server/src/api/controllers/Purchases/Bills.ts @@ -110,6 +110,10 @@ export default class BillsController extends BaseController { .optional({ nullable: true }) .trim() .escape(), + check('entries.*.landed_cost') + .optional({ nullable: true }) + .isBoolean() + .toBoolean(), ]; } @@ -141,6 +145,10 @@ export default class BillsController extends BaseController { .optional({ nullable: true }) .trim() .escape(), + check('entries.*.landedCost') + .optional({ nullable: true }) + .isBoolean() + .toBoolean(), ]; } @@ -301,11 +309,8 @@ export default class BillsController extends BaseController { filter.filterRoles = JSON.parse(filter.stringifiedFilterRoles); } try { - const { - bills, - pagination, - filterMeta, - } = await this.billsService.getBills(tenantId, filter); + const { bills, pagination, filterMeta } = + await this.billsService.getBills(tenantId, filter); return res.status(200).send({ bills, @@ -397,17 +402,24 @@ export default class BillsController extends BaseController { if (error.errorType === 'contact_not_found') { return res.boom.badRequest(null, { errors: [ - { type: 'VENDOR_NOT_FOUND', message: 'Vendor not found.', code: 1200 }, + { + type: 'VENDOR_NOT_FOUND', + message: 'Vendor not found.', + code: 1200, + }, ], }); } if (error.errorType === 'BILL_HAS_ASSOCIATED_PAYMENT_ENTRIES') { return res.status(400).send({ - errors: [{ - type: 'BILL_HAS_ASSOCIATED_PAYMENT_ENTRIES', - message: 'Cannot delete bill that has associated payment transactions.', - code: 1200 - }], + errors: [ + { + type: 'BILL_HAS_ASSOCIATED_PAYMENT_ENTRIES', + message: + 'Cannot delete bill that has associated payment transactions.', + code: 1200, + }, + ], }); } } diff --git a/server/src/api/controllers/Purchases/LandedCost.ts b/server/src/api/controllers/Purchases/LandedCost.ts new file mode 100644 index 000000000..da32f2b8b --- /dev/null +++ b/server/src/api/controllers/Purchases/LandedCost.ts @@ -0,0 +1,289 @@ +import { Router, Request, Response, NextFunction } from 'express'; +import { check, param, query } from 'express-validator'; +import { Service, Inject } from 'typedi'; +import { ServiceError } from 'exceptions'; +import AllocateLandedCostService from 'services/Purchases/LandedCost'; +import LandedCostListing from 'services/Purchases/LandedCost/LandedCostListing'; +import BaseController from '../BaseController'; +import { ResultSetDependencies } from 'mathjs'; + +@Service() +export default class BillAllocateLandedCost extends BaseController { + @Inject() + allocateLandedCost: AllocateLandedCostService; + + @Inject() + landedCostListing: LandedCostListing; + + /** + * Router constructor. + */ + public router() { + const router = Router(); + + router.post( + '/bills/:billId/allocate', + [ + check('transaction_id').exists().isInt(), + check('transaction_type').exists().isIn(['Expense', 'Bill']), + check('transaction_entry_id').exists().isInt(), + + check('allocation_method').exists().isIn(['value', 'quantity']), + check('description').optional({ nullable: true }), + + check('items').isArray({ min: 1 }), + check('items.*.entry_id').isInt(), + check('items.*.cost').isDecimal(), + ], + this.validationResult, + this.calculateLandedCost.bind(this), + this.handleServiceErrors + ); + router.delete( + '/:allocatedLandedCostId', + [param('allocatedLandedCostId').exists().isInt()], + this.validationResult, + this.deleteAllocatedLandedCost.bind(this), + this.handleServiceErrors + ); + router.get( + '/transactions', + [query('transaction_type').exists().isIn(['Expense', 'Bill'])], + this.validationResult, + this.getLandedCostTransactions.bind(this), + this.handleServiceErrors + ); + router.get( + '/bills/:billId/transactions', + [param('billId').exists()], + this.validationResult, + this.getBillLandedCostTransactions.bind(this), + this.handleServiceErrors + ); + return router; + } + + /** + * Retrieve the landed cost transactions of the given query. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + private async getLandedCostTransactions( + req: Request, + res: Response, + next: NextFunction + ) { + const { tenantId } = req; + const query = this.matchedQueryData(req); + + try { + const transactions = + await this.landedCostListing.getLandedCostTransactions(tenantId, query); + return res.status(200).send({ transactions }); + } catch (error) { + next(error); + } + } + + /** + * Allocate landed cost. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + * @returns {Response} + */ + public async calculateLandedCost( + req: Request, + res: Response, + next: NextFunction + ) { + const { tenantId } = req; + const { billId: purchaseInvoiceId } = req.params; + const landedCostDTO = this.matchedBodyData(req); + + try { + const { billLandedCost } = + await this.allocateLandedCost.allocateLandedCost( + tenantId, + landedCostDTO, + purchaseInvoiceId + ); + + return res.status(200).send({ + id: billLandedCost.id, + message: 'The items cost are located successfully.', + }); + } catch (error) { + next(error); + } + } + + /** + * Deletes the allocated landed cost. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + * @returns {Response} + */ + public async deleteAllocatedLandedCost( + req: Request, + res: Response, + next: NextFunction + ): Promise { + const { tenantId } = req; + const { allocatedLandedCostId } = req.params; + + try { + await this.allocateLandedCost.deleteAllocatedLandedCost( + tenantId, + allocatedLandedCostId + ); + + return res.status(200).send({ + id: allocatedLandedCostId, + message: 'The allocated landed cost are delete successfully.', + }); + } catch (error) { + next(error); + } + } + + /** + * Retrieve the list unlocated landed costs. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + public async listLandedCosts( + req: Request, + res: Response, + next: NextFunction + ) { + const query = this.matchedQueryData(req); + const { tenantId } = req; + + try { + const transactions = + await this.landedCostListing.getLandedCostTransactions(tenantId, query); + return res.status(200).send({ transactions }); + } catch (error) { + next(error); + } + } + + /** + * Retrieve the bill landed cost transactions. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + public async getBillLandedCostTransactions( + req: Request, + res: Response, + next: NextFunction + ): Promise { + const { tenantId } = req; + const { billId } = req.params; + + try { + const transactions = + await this.landedCostListing.getBillLandedCostTransactions( + tenantId, + billId + ); + + return res.status(200).send({ + billId, + transactions, + }); + } catch (error) { + next(error); + } + } + + /** + * Handle service errors. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + * @param {Error} error + */ + public handleServiceErrors( + error: Error, + req: Request, + res: Response, + next: NextFunction + ) { + if (error instanceof ServiceError) { + if (error.errorType === 'BILL_NOT_FOUND') { + return res.status(400).send({ + errors: [ + { + type: 'BILL_NOT_FOUND', + code: 400, + message: 'The give bill id not found.', + }, + ], + }); + } + if (error.errorType === 'LANDED_COST_TRANSACTION_NOT_FOUND') { + return res.status(400).send({ + errors: [ + { + type: 'LANDED_COST_TRANSACTION_NOT_FOUND', + code: 200, + message: 'The given landed cost transaction id not found.', + }, + ], + }); + } + if (error.errorType === 'LANDED_COST_ENTRY_NOT_FOUND') { + return res.status(400).send({ + errors: [ + { + type: 'LANDED_COST_ENTRY_NOT_FOUND', + code: 300, + message: 'The given landed cost tranasction entry id not found.', + }, + ], + }); + } + if (error.errorType === 'COST_AMOUNT_BIGGER_THAN_UNALLOCATED_AMOUNT') { + return res.status(400).send({ + errors: [ + { type: 'COST_AMOUNT_BIGGER_THAN_UNALLOCATED_AMOUNT', code: 300 }, + ], + }); + } + if (error.errorType === 'LANDED_COST_ITEMS_IDS_NOT_FOUND') { + return res.status(400).send({ + errors: [ + { + type: 'LANDED_COST_ITEMS_IDS_NOT_FOUND', + code: 200, + message: 'The given entries ids of purchase invoice not found.', + }, + ], + }); + } + if (error.errorType === 'BILL_LANDED_COST_NOT_FOUND') { + return res.status(400).send({ + errors: [ + { + type: 'BILL_LANDED_COST_NOT_FOUND', + code: 200, + message: 'The given bill located landed cost not found.', + }, + ], + }); + } + if (error.errorType === 'COST_TRASNACTION_NOT_FOUND') { + return res.status(400).send({ + errors: [{ type: 'COST_TRASNACTION_NOT_FOUND', code: 500 }], + }); + } + } + next(error); + } +} diff --git a/server/src/api/controllers/Purchases/index.ts b/server/src/api/controllers/Purchases/index.ts index a56ac1261..2ee3686f2 100644 --- a/server/src/api/controllers/Purchases/index.ts +++ b/server/src/api/controllers/Purchases/index.ts @@ -2,6 +2,7 @@ import { Router } from 'express'; import { Container, Service } from 'typedi'; import Bills from 'api/controllers/Purchases/Bills' import BillPayments from 'api/controllers/Purchases/BillsPayments'; +import BillAllocateLandedCost from './LandedCost'; @Service() export default class PurchasesController { @@ -11,6 +12,7 @@ export default class PurchasesController { router.use('/bills', Container.get(Bills).router()); router.use('/bill_payments', Container.get(BillPayments).router()); + router.use('/landed-cost', Container.get(BillAllocateLandedCost).router()); return router; } diff --git a/server/src/api/controllers/Sales/SalesInvoices.ts b/server/src/api/controllers/Sales/SalesInvoices.ts index c2fbc8aa1..7b8641c36 100644 --- a/server/src/api/controllers/Sales/SalesInvoices.ts +++ b/server/src/api/controllers/Sales/SalesInvoices.ts @@ -347,7 +347,7 @@ export default class SaleInvoicesController extends BaseController { * @param {Response} res * @param {NextFunction} next */ - handleServiceErrors( + private handleServiceErrors( error: Error, req: Request, res: Response, diff --git a/server/src/database/migrations/20190822214306_create_items_table.js b/server/src/database/migrations/20190822214306_create_items_table.js index 7d0100506..16ac1ed66 100644 --- a/server/src/database/migrations/20190822214306_create_items_table.js +++ b/server/src/database/migrations/20190822214306_create_items_table.js @@ -17,6 +17,7 @@ exports.up = function (knex) { table.text('sell_description').nullable(); table.text('purchase_description').nullable(); table.integer('quantity_on_hand'); + table.boolean('landed_cost').nullable(); table.text('note').nullable(); table.boolean('active'); diff --git a/server/src/database/migrations/20200105014405_create_expenses_table.js b/server/src/database/migrations/20200105014405_create_expenses_table.js index 5f4382e35..169856f33 100644 --- a/server/src/database/migrations/20200105014405_create_expenses_table.js +++ b/server/src/database/migrations/20200105014405_create_expenses_table.js @@ -1,20 +1,29 @@ +exports.up = function (knex) { + return knex.schema + .createTable('expenses_transactions', (table) => { + table.increments(); + table.string('currency_code', 3); + table.text('description'); + table + .integer('payment_account_id') + .unsigned() + .references('id') + .inTable('accounts'); + table.integer('payee_id').unsigned().references('id').inTable('contacts'); + table.string('reference_no'); -exports.up = function(knex) { - return knex.schema.createTable('expenses_transactions', (table) => { - table.increments(); - table.decimal('total_amount', 13, 3); - table.string('currency_code', 3); - table.text('description'); - table.integer('payment_account_id').unsigned().references('id').inTable('accounts'); - table.integer('payee_id').unsigned().references('id').inTable('contacts');; - table.string('reference_no'); - table.date('published_at').index(); - table.integer('user_id').unsigned().index(); - table.date('payment_date').index(); - table.timestamps(); - }).raw('ALTER TABLE `EXPENSES_TRANSACTIONS` AUTO_INCREMENT = 1000'); + table.decimal('total_amount', 13, 3); + table.decimal('landed_cost_amount', 13, 3).defaultTo(0); + table.decimal('allocated_cost_amount', 13, 3).defaultTo(0); + + table.date('published_at').index(); + table.integer('user_id').unsigned().index(); + table.date('payment_date').index(); + table.timestamps(); + }) + .raw('ALTER TABLE `EXPENSES_TRANSACTIONS` AUTO_INCREMENT = 1000'); }; -exports.down = function(knex) { +exports.down = function (knex) { return knex.schema.dropTableIfExists('expenses'); }; diff --git a/server/src/database/migrations/20200606113848_create_expense_transactions_categories_table.js b/server/src/database/migrations/20200606113848_create_expense_transactions_categories_table.js index b383bd668..a1bc88052 100644 --- a/server/src/database/migrations/20200606113848_create_expense_transactions_categories_table.js +++ b/server/src/database/migrations/20200606113848_create_expense_transactions_categories_table.js @@ -1,16 +1,29 @@ - -exports.up = function(knex) { - return knex.schema.createTable('expense_transaction_categories', table => { - table.increments(); - table.integer('expense_account_id').unsigned().index().references('id').inTable('accounts'); - table.integer('index').unsigned(); - table.text('description'); - table.decimal('amount', 13, 3); - table.integer('expense_id').unsigned().index().references('id').inTable('expenses_transactions'); - table.timestamps(); - }).raw('ALTER TABLE `EXPENSE_TRANSACTION_CATEGORIES` AUTO_INCREMENT = 1000');; +exports.up = function (knex) { + return knex.schema + .createTable('expense_transaction_categories', (table) => { + table.increments(); + table + .integer('expense_account_id') + .unsigned() + .index() + .references('id') + .inTable('accounts'); + table.integer('index').unsigned(); + table.text('description'); + table.decimal('amount', 13, 3); + table.decimal('allocated_cost_amount', 13, 3).defaultTo(0); + table.boolean('landed_cost').defaultTo(false); + table + .integer('expense_id') + .unsigned() + .index() + .references('id') + .inTable('expenses_transactions'); + table.timestamps(); + }) + .raw('ALTER TABLE `EXPENSE_TRANSACTION_CATEGORIES` AUTO_INCREMENT = 1000'); }; -exports.down = function(knex) { +exports.down = function (knex) { return knex.schema.dropTableIfExists('expense_transaction_categories'); }; diff --git a/server/src/database/migrations/20200719152005_create_bills_table.js b/server/src/database/migrations/20200719152005_create_bills_table.js index c8c432a32..34cb845ef 100644 --- a/server/src/database/migrations/20200719152005_create_bills_table.js +++ b/server/src/database/migrations/20200719152005_create_bills_table.js @@ -1,8 +1,12 @@ - -exports.up = function(knex) { +exports.up = function (knex) { return knex.schema.createTable('bills', (table) => { table.increments(); - table.integer('vendor_id').unsigned().index().references('id').inTable('contacts'); + table + .integer('vendor_id') + .unsigned() + .index() + .references('id') + .inTable('contacts'); table.string('bill_number'); table.date('bill_date').index(); table.date('due_date').index(); @@ -12,6 +16,8 @@ exports.up = function(knex) { table.decimal('amount', 13, 3).defaultTo(0); table.string('currency_code'); table.decimal('payment_amount', 13, 3).defaultTo(0); + table.decimal('landed_cost_amount', 13, 3).defaultTo(0); + table.decimal('allocated_cost_amount', 13, 3).defaultTo(0); table.string('inv_lot_number').index(); table.date('opened_at').index(); table.integer('user_id').unsigned(); @@ -19,6 +25,6 @@ exports.up = function(knex) { }); }; -exports.down = function(knex) { +exports.down = function (knex) { return knex.schema.dropTableIfExists('bills'); }; diff --git a/server/src/database/migrations/20200722164252_create_landed_cost_table.js b/server/src/database/migrations/20200722164252_create_landed_cost_table.js new file mode 100644 index 000000000..f315e1bde --- /dev/null +++ b/server/src/database/migrations/20200722164252_create_landed_cost_table.js @@ -0,0 +1,21 @@ +exports.up = function (knex) { + return knex.schema.createTable('bill_located_costs', (table) => { + table.increments(); + + table.decimal('amount', 13, 3).unsigned(); + + table.integer('fromTransactionId').unsigned(); + table.string('fromTransactionType'); + table.integer('fromTransactionEntryId').unsigned(); + + table.string('allocationMethod'); + table.integer('costAccountId').unsigned(); + table.text('description'); + + table.integer('billId').unsigned(); + + table.timestamps(); + }); +}; + +exports.down = function (knex) {}; diff --git a/server/src/database/migrations/20200722164253_create_landed_cost_entries_table.js b/server/src/database/migrations/20200722164253_create_landed_cost_entries_table.js new file mode 100644 index 000000000..96cdc5d77 --- /dev/null +++ b/server/src/database/migrations/20200722164253_create_landed_cost_entries_table.js @@ -0,0 +1,11 @@ +exports.up = function (knex) { + return knex.schema.createTable('bill_located_cost_entries', (table) => { + table.increments(); + + table.decimal('cost', 13, 3).unsigned(); + table.integer('entry_id').unsigned(); + table.integer('bill_located_cost_id').unsigned(); + }); +}; + +exports.down = function (knex) {}; diff --git a/server/src/database/migrations/20200722173423_create_items_entries_table.js b/server/src/database/migrations/20200722173423_create_items_entries_table.js index b6313eaba..a4c809cec 100644 --- a/server/src/database/migrations/20200722173423_create_items_entries_table.js +++ b/server/src/database/migrations/20200722173423_create_items_entries_table.js @@ -15,6 +15,8 @@ exports.up = function(knex) { table.integer('sell_account_id').unsigned().references('id').inTable('accounts'); table.integer('cost_account_id').unsigned().references('id').inTable('accounts'); + table.boolean('landed_cost').defaultTo(false); + table.decimal('allocated_cost_amount', 13, 3); table.timestamps(); }); }; diff --git a/server/src/interfaces/Bill.ts b/server/src/interfaces/Bill.ts index 0598dde6a..ea68100c3 100644 --- a/server/src/interfaces/Bill.ts +++ b/server/src/interfaces/Bill.ts @@ -1,64 +1,69 @@ -import { IDynamicListFilterDTO } from "./DynamicFilter"; -import { IItemEntry, IItemEntryDTO } from "./ItemEntry"; +import { IDynamicListFilterDTO } from './DynamicFilter'; +import { IItemEntry, IItemEntryDTO } from './ItemEntry'; export interface IBillDTO { - vendorId: number, - billNumber: string, - billDate: Date, - dueDate: Date, - referenceNo: string, - status: string, - note: string, - amount: number, - paymentAmount: number, - open: boolean, - entries: IItemEntryDTO[], -}; + vendorId: number; + billNumber: string; + billDate: Date; + dueDate: Date; + referenceNo: string; + status: string; + note: string; + amount: number; + paymentAmount: number; + open: boolean; + entries: IItemEntryDTO[]; +} export interface IBillEditDTO { - vendorId: number, - billNumber: string, - billDate: Date, - dueDate: Date, - referenceNo: string, - status: string, - note: string, - amount: number, - paymentAmount: number, - open: boolean, - entries: IItemEntryDTO[], -}; + vendorId: number; + billNumber: string; + billDate: Date; + dueDate: Date; + referenceNo: string; + status: string; + note: string; + amount: number; + paymentAmount: number; + open: boolean; + entries: IItemEntryDTO[]; +} export interface IBill { - id?: number, + id?: number; - vendorId: number, - billNumber: string, - billDate: Date, - dueDate: Date, - referenceNo: string, - status: string, - note: string, - amount: number, - paymentAmount: number, - currencyCode: string, + vendorId: number; + billNumber: string; + billDate: Date; + dueDate: Date; + referenceNo: string; + status: string; + note: string; - dueAmount: number, - overdueDays: number, + amount: number; + allocatedCostAmount: number; + landedCostAmount: number; + unallocatedCostAmount: number; - openedAt: Date | string, + paymentAmount: number; + currencyCode: string; - entries: IItemEntry[], - userId: number, + dueAmount: number; + overdueDays: number; - createdAt: Date, - updateAt: Date, -}; + openedAt: Date | string; -export interface IBillsFilter extends IDynamicListFilterDTO { - stringifiedFilterRoles?: string, + entries: IItemEntry[]; + userId: number; + + createdAt: Date; + updateAt: Date; +} + +export interface IBillsFilter extends IDynamicListFilterDTO { + stringifiedFilterRoles?: string; } export interface IBillsService { validateVendorHasNoBills(tenantId: number, vendorId: number): Promise; -} \ No newline at end of file +} diff --git a/server/src/interfaces/Expenses.ts b/server/src/interfaces/Expenses.ts index 0d1a11a1f..a1bea49ac 100644 --- a/server/src/interfaces/Expenses.ts +++ b/server/src/interfaces/Expenses.ts @@ -27,15 +27,20 @@ export interface IExpense { userId: number; paymentDate: Date; payeeId: number; + landedCostAmount: number; + allocatedCostAmount: number; + unallocatedCostAmount: number; categories: IExpenseCategory[]; } export interface IExpenseCategory { + id?: number; expenseAccountId: number; index: number; description: string; expenseId: number; amount: number; + landedCost: boolean; } export interface IExpenseDTO { @@ -56,6 +61,7 @@ export interface IExpenseCategoryDTO { index: number; description?: string; expenseId: number; + landedCost?: boolean; } export interface IExpensesService { diff --git a/server/src/interfaces/ItemEntry.ts b/server/src/interfaces/ItemEntry.ts index 3e082c716..f9df14934 100644 --- a/server/src/interfaces/ItemEntry.ts +++ b/server/src/interfaces/ItemEntry.ts @@ -17,8 +17,10 @@ export interface IItemEntry { sellAccountId: number, costAccountId: number, + + landedCost?: boolean, } export interface IItemEntryDTO { - + landedCost?: boolean } \ No newline at end of file diff --git a/server/src/interfaces/LandedCost.ts b/server/src/interfaces/LandedCost.ts new file mode 100644 index 000000000..a719b31c2 --- /dev/null +++ b/server/src/interfaces/LandedCost.ts @@ -0,0 +1,85 @@ +export interface IBillLandedCost { + fromTransactionId: number; + fromTransactionType: string; + amount: number; + BillId: number; +} + +export interface IBillLandedCostEntry { + id?: number, + cost: number, + entryId: number, + billLocatedCostId: number, +} + +export interface ILandedCostItemDTO { + entryId: number, + cost: number; +} +export type ILandedCostType = 'Expense' | 'Bill'; + +export interface ILandedCostDTO { + transactionType: ILandedCostType; + transactionId: number; + transactionEntryId: number, + allocationMethod: string; + description: string; + items: ILandedCostItemDTO[]; +} + +export interface ILandedCostQueryDTO { + vendorId: number; + fromDate: Date; + toDate: Date; +} + +export interface IUnallocatedListCost { + costNumber: string; + costAmount: number; + unallocatedAmount: number; +} + +export interface ILandedCostTransactionsQueryDTO { + transactionType: string, + date: Date, +} + +export interface ILandedCostEntriesQueryDTO { + transactionType: string, + transactionId: number, +} + +export interface ILandedCostTransaction { + id: number; + name: string; + amount: number; + allocatedCostAmount: number; + unallocatedCostAmount: number; + transactionType: string; + entries?: ILandedCostTransactionEntry[]; +} + +export interface ILandedCostTransactionEntry { + id: number; + name: string; + code: string; + amount: number; + description: string; +} + +interface ILandedCostEntry { + id: number; + landedCost?: boolean; +} + +export interface IBillLandedCostTransaction { + id: number, + fromTranscationId: number, + fromTransactionType: string; + fromTransactionEntryId: number; + + billId: number, + allocationMethod: string; + costAccountId: number, + description: string; +}; \ No newline at end of file diff --git a/server/src/interfaces/index.ts b/server/src/interfaces/index.ts index 388668041..0ce5f414c 100644 --- a/server/src/interfaces/index.ts +++ b/server/src/interfaces/index.ts @@ -53,6 +53,7 @@ export * from './Table'; export * from './Ledger'; export * from './CashFlow'; export * from './InventoryDetails'; +export * from './LandedCost'; export interface I18nService { __: (input: string) => string; diff --git a/server/src/loaders/tenantModels.ts b/server/src/loaders/tenantModels.ts index 86adacdab..c71242b02 100644 --- a/server/src/loaders/tenantModels.ts +++ b/server/src/loaders/tenantModels.ts @@ -36,6 +36,8 @@ import Media from 'models/Media'; import MediaLink from 'models/MediaLink'; import InventoryAdjustment from 'models/InventoryAdjustment'; import InventoryAdjustmentEntry from 'models/InventoryAdjustmentEntry'; +import BillLandedCost from 'models/BillLandedCost'; +import BillLandedCostEntry from 'models/BillLandedCostEntry'; export default (knex) => { const models = { @@ -75,6 +77,8 @@ export default (knex) => { Contact, InventoryAdjustment, InventoryAdjustmentEntry, + BillLandedCost, + BillLandedCostEntry }; return mapValues(models, (model) => model.bindKnex(knex)); } \ No newline at end of file diff --git a/server/src/models/Bill.js b/server/src/models/Bill.js index 9b69d0c3e..f7e050eb2 100644 --- a/server/src/models/Bill.js +++ b/server/src/models/Bill.js @@ -103,6 +103,7 @@ export default class Bill extends TenantModel { 'remainingDays', 'overdueDays', 'isOverdue', + 'unallocatedCostAmount' ]; } @@ -178,6 +179,14 @@ export default class Bill extends TenantModel { return this.overdueDays > 0; } + /** + * Retrieve the unallocated cost amount. + * @return {number} + */ + get unallocatedCostAmount() { + return Math.max(this.landedCostAmount - this.allocatedCostAmount, 0); + } + getOverdueDays(asDate = moment().format('YYYY-MM-DD')) { // Can't continue in case due date not defined. if (!this.dueDate) { @@ -195,6 +204,7 @@ export default class Bill extends TenantModel { static get relationMappings() { const Contact = require('models/Contact'); const ItemEntry = require('models/ItemEntry'); + const BillLandedCost = require('models/BillLandedCost'); return { vendor: { @@ -220,6 +230,15 @@ export default class Bill extends TenantModel { builder.where('reference_type', 'Bill'); }, }, + + locatedLandedCosts: { + relation: Model.HasManyRelation, + modelClass: BillLandedCost.default, + join: { + from: 'bills.id', + to: 'bill_located_costs.billId', + }, + }, }; } diff --git a/server/src/models/BillLandedCost.js b/server/src/models/BillLandedCost.js new file mode 100644 index 000000000..c7a0624fc --- /dev/null +++ b/server/src/models/BillLandedCost.js @@ -0,0 +1,36 @@ +import { Model } from 'objection'; +import TenantModel from 'models/TenantModel'; + +export default class BillLandedCost extends TenantModel { + /** + * Table name + */ + static get tableName() { + return 'bill_located_costs'; + } + + /** + * Model timestamps. + */ + get timestamps() { + return ['createdAt', 'updatedAt']; + } + + /** + * Relationship mapping. + */ + static get relationMappings() { + const BillLandedCostEntry = require('models/BillLandedCostEntry'); + + return { + allocateEntries: { + relation: Model.HasManyRelation, + modelClass: BillLandedCostEntry.default, + join: { + from: 'bill_located_costs.id', + to: 'bill_located_cost_entries.billLocatedCostId', + }, + }, + }; + } +} diff --git a/server/src/models/BillLandedCostEntry.js b/server/src/models/BillLandedCostEntry.js new file mode 100644 index 000000000..aca0a87b7 --- /dev/null +++ b/server/src/models/BillLandedCostEntry.js @@ -0,0 +1,10 @@ +import TenantModel from 'models/TenantModel'; + +export default class BillLandedCostEntry extends TenantModel { + /** + * Table name + */ + static get tableName() { + return 'bill_located_cost_entries'; + } +} diff --git a/server/src/models/Expense.js b/server/src/models/Expense.js index c95d9e57f..e8bc02545 100644 --- a/server/src/models/Expense.js +++ b/server/src/models/Expense.js @@ -1,27 +1,27 @@ -import { Model } from "objection"; -import TenantModel from "models/TenantModel"; -import { viewRolesBuilder } from "lib/ViewRolesBuilder"; +import { Model } from 'objection'; +import TenantModel from 'models/TenantModel'; +import { viewRolesBuilder } from 'lib/ViewRolesBuilder'; export default class Expense extends TenantModel { /** * Table name */ static get tableName() { - return "expenses_transactions"; + return 'expenses_transactions'; } /** * Account transaction reference type. */ static get referenceType() { - return "Expense"; + return 'Expense'; } /** * Model timestamps. */ get timestamps() { - return ["createdAt", "updatedAt"]; + return ['createdAt', 'updatedAt']; } /** @@ -37,14 +37,19 @@ export default class Expense extends TenantModel { static get media() { return true; } - + static get virtualAttributes() { - return ["isPublished"]; + return ['isPublished', 'unallocatedLandedCost']; } + isPublished() { return Boolean(this.publishedAt); } + unallocatedLandedCost() { + return Math.max(this.amount - this.allocatedCostAmount, 0); + } + /** * Model modifiers. */ @@ -52,28 +57,28 @@ export default class Expense extends TenantModel { return { filterByDateRange(query, startDate, endDate) { if (startDate) { - query.where("date", ">=", startDate); + query.where('date', '>=', startDate); } if (endDate) { - query.where("date", "<=", endDate); + query.where('date', '<=', endDate); } }, filterByAmountRange(query, from, to) { if (from) { - query.where("amount", ">=", from); + query.where('amount', '>=', from); } if (to) { - query.where("amount", "<=", to); + query.where('amount', '<=', to); } }, filterByExpenseAccount(query, accountId) { if (accountId) { - query.where("expense_account_id", accountId); + query.where('expense_account_id', accountId); } }, filterByPaymentAccount(query, accountId) { if (accountId) { - query.where("payment_account_id", accountId); + query.where('payment_account_id', accountId); } }, viewRolesBuilder(query, conditionals, expression) { @@ -94,40 +99,40 @@ export default class Expense extends TenantModel { * Relationship mapping. */ static get relationMappings() { - const Account = require("models/Account"); - const ExpenseCategory = require("models/ExpenseCategory"); - const Media = require("models/Media"); + const Account = require('models/Account'); + const ExpenseCategory = require('models/ExpenseCategory'); + const Media = require('models/Media'); return { paymentAccount: { relation: Model.BelongsToOneRelation, modelClass: Account.default, join: { - from: "expenses_transactions.paymentAccountId", - to: "accounts.id", + from: 'expenses_transactions.paymentAccountId', + to: 'accounts.id', }, }, categories: { relation: Model.HasManyRelation, modelClass: ExpenseCategory.default, join: { - from: "expenses_transactions.id", - to: "expense_transaction_categories.expenseId", + from: 'expenses_transactions.id', + to: 'expense_transaction_categories.expenseId', }, }, media: { relation: Model.ManyToManyRelation, modelClass: Media.default, join: { - from: "expenses_transactions.id", + from: 'expenses_transactions.id', through: { - from: "media_links.model_id", - to: "media_links.media_id", + from: 'media_links.model_id', + to: 'media_links.media_id', }, - to: "media.id", + to: 'media.id', }, filter(query) { - query.where("model_name", "Expense"); + query.where('model_name', 'Expense'); }, }, }; @@ -139,39 +144,39 @@ export default class Expense extends TenantModel { static get fields() { return { payment_date: { - label: "Payment date", - column: "payment_date", - columnType: "date", + label: 'Payment date', + column: 'payment_date', + columnType: 'date', }, payment_account: { - label: "Payment account", - column: "payment_account_id", - relation: "accounts.id", - optionsResource: "account", + label: 'Payment account', + column: 'payment_account_id', + relation: 'accounts.id', + optionsResource: 'account', }, amount: { - label: "Amount", - column: "total_amount", - columnType: "number", + label: 'Amount', + column: 'total_amount', + columnType: 'number', }, currency_code: { - label: "Currency", - column: "currency_code", - optionsResource: "currency", + label: 'Currency', + column: 'currency_code', + optionsResource: 'currency', }, reference_no: { - label: "Reference No.", - column: "reference_no", - columnType: "string", + label: 'Reference No.', + column: 'reference_no', + columnType: 'string', }, description: { - label: "Description", - column: "description", - columnType: "string", + label: 'Description', + column: 'description', + columnType: 'string', }, published: { - label: "Published", - column: "published_at", + label: 'Published', + column: 'published_at', }, status: { label: 'Status', @@ -194,9 +199,9 @@ export default class Expense extends TenantModel { }, }, created_at: { - label: "Created at", - column: "created_at", - columnType: "date", + label: 'Created at', + column: 'created_at', + columnType: 'date', }, }; } diff --git a/server/src/models/ExpenseCategory.js b/server/src/models/ExpenseCategory.js index 80b89e89f..a761acb23 100644 --- a/server/src/models/ExpenseCategory.js +++ b/server/src/models/ExpenseCategory.js @@ -9,6 +9,21 @@ export default class ExpenseCategory extends TenantModel { return 'expense_transaction_categories'; } + /** + * Virtual attributes. + */ + static get virtualAttributes() { + return ['unallocatedLandedCost']; + } + + /** + * Remain unallocated landed cost. + * @return {number} + */ + get unallocatedLandedCost() { + return Math.max(this.amount - this.allocatedCostAmount, 0); + } + /** * Relationship mapping. */ diff --git a/server/src/services/Expenses/ExpensesService.ts b/server/src/services/Expenses/ExpensesService.ts index 1e3670e08..1ac1623b0 100644 --- a/server/src/services/Expenses/ExpensesService.ts +++ b/server/src/services/Expenses/ExpensesService.ts @@ -17,11 +17,12 @@ import { IExpensesService, ISystemUser, IPaginationMeta, + IExpenseCategory, } from 'interfaces'; import DynamicListingService from 'services/DynamicListing/DynamicListService'; import events from 'subscribers/events'; import ContactsService from 'services/Contacts/ContactsService'; -import { ACCOUNT_PARENT_TYPE, ACCOUNT_ROOT_TYPE } from 'data/AccountTypes' +import { ACCOUNT_PARENT_TYPE, ACCOUNT_ROOT_TYPE } from 'data/AccountTypes'; const ERRORS = { EXPENSE_NOT_FOUND: 'expense_not_found', @@ -32,6 +33,7 @@ const ERRORS = { PAYMENT_ACCOUNT_HAS_INVALID_TYPE: 'payment_account_has_invalid_type', EXPENSES_ACCOUNT_HAS_INVALID_TYPE: 'expenses_account_has_invalid_type', EXPENSE_ALREADY_PUBLISHED: 'expense_already_published', + EXPENSE_HAS_ASSOCIATED_LANDED_COST: 'EXPENSE_HAS_ASSOCIATED_LANDED_COST', }; @Service() @@ -308,6 +310,27 @@ export default class ExpensesService implements IExpensesService { } } + /** + * Retrieve the expense landed cost amount. + * @param {IExpenseDTO} expenseDTO + * @return {number} + */ + private getExpenseLandedCostAmount(expenseDTO: IExpenseDTO): number { + const landedCostEntries = expenseDTO.categories.filter((entry) => { + return entry.landedCost === true; + }); + return this.getExpenseCategoriesTotal(landedCostEntries); + } + + /** + * Retrieve the given expense categories total. + * @param {IExpenseCategory} categories + * @returns {number} + */ + private getExpenseCategoriesTotal(categories): number { + return sumBy(categories, 'amount'); + } + /** * Mapping expense DTO to model. * @param {IExpenseDTO} expenseDTO @@ -315,12 +338,14 @@ export default class ExpensesService implements IExpensesService { * @return {IExpense} */ private expenseDTOToModel(expenseDTO: IExpenseDTO, user?: ISystemUser) { - const totalAmount = sumBy(expenseDTO.categories, 'amount'); + const landedCostAmount = this.getExpenseLandedCostAmount(expenseDTO); + const totalAmount = this.getExpenseCategoriesTotal(expenseDTO.categories); return { categories: [], ...omit(expenseDTO, ['publish']), totalAmount, + landedCostAmount, paymentDate: moment(expenseDTO.paymentDate).toMySqlDateTime(), ...(user ? { @@ -340,7 +365,7 @@ export default class ExpensesService implements IExpensesService { * @param {IExpenseDTO} expenseDTO * @return {number[]} */ - mapExpensesAccountsIdsFromDTO(expenseDTO: IExpenseDTO) { + private mapExpensesAccountsIdsFromDTO(expenseDTO: IExpenseDTO) { return expenseDTO.categories.map((category) => category.expenseAccountId); } @@ -544,15 +569,16 @@ export default class ExpensesService implements IExpensesService { authorizedUser: ISystemUser ): Promise { const oldExpense = await this.getExpenseOrThrowError(tenantId, expenseId); - const { - expenseRepository, - expenseEntryRepository, - } = this.tenancy.repositories(tenantId); + const { expenseRepository, expenseEntryRepository } = + this.tenancy.repositories(tenantId); this.logger.info('[expense] trying to delete the expense.', { tenantId, expenseId, }); + // Validates the expense has no associated landed cost. + await this.validateNoAssociatedLandedCost(tenantId, expenseId); + await expenseEntryRepository.deleteBy({ expenseId }); await expenseRepository.deleteById(expenseId); @@ -572,7 +598,7 @@ export default class ExpensesService implements IExpensesService { /** * Filters the not published expenses. - * @param {IExpense[]} expenses - + * @param {IExpense[]} expenses - */ public getNonePublishedExpenses(expenses: IExpense[]): IExpense[] { return expenses.filter((expense) => !expense.publishedAt); @@ -648,4 +674,25 @@ export default class ExpensesService implements IExpensesService { } return expense; } + + /** + * Validates the expense has not associated landed cost + * references to the given expense. + * @param {number} tenantId + * @param {number} expenseId + */ + public async validateNoAssociatedLandedCost( + tenantId: number, + expenseId: number + ) { + const { BillLandedCost } = this.tenancy.models(tenantId); + + const associatedLandedCosts = await BillLandedCost.query() + .where('fromTransactionType', 'Expense') + .where('fromTransactionId', expenseId); + + if (associatedLandedCosts.length > 0) { + throw new ServiceError(ERRORS.EXPENSE_HAS_ASSOCIATED_LANDED_COST); + } + } } diff --git a/server/src/services/Purchases/Bills.ts b/server/src/services/Purchases/Bills.ts index b35284e40..12a63ad67 100644 --- a/server/src/services/Purchases/Bills.ts +++ b/server/src/services/Purchases/Bills.ts @@ -40,7 +40,8 @@ import { ERRORS } from './constants'; @Service('Bills') export default class BillsService extends SalesInvoicesCost - implements IBillsService { + implements IBillsService +{ @Inject() inventoryService: InventoryService; @@ -100,7 +101,7 @@ export default class BillsService * @param {number} tenantId - * @param {number} billId - */ - private async getBillOrThrowError(tenantId: number, billId: number) { + public async getBillOrThrowError(tenantId: number, billId: number) { const { Bill } = this.tenancy.models(tenantId); this.logger.info('[bill] trying to get bill.', { tenantId, billId }); @@ -194,6 +195,28 @@ export default class BillsService }; } + /** + * Retrieve the bill entries total. + * @param {IItemEntry[]} entries + * @returns {number} + */ + private getBillEntriesTotal(tenantId: number, entries: IItemEntry[]): number { + const { ItemEntry } = this.tenancy.models(tenantId); + + return sumBy(entries, (e) => ItemEntry.calcAmount(e)); + } + + /** + * Retrieve the bill landed cost amount. + * @param {IBillDTO} billDTO + * @returns {number} + */ + private getBillLandedCostAmount(tenantId: number, billDTO: IBillDTO): number { + const costEntries = billDTO.entries.filter((entry) => entry.landedCost); + + return this.getBillEntriesTotal(tenantId, costEntries); + } + /** * Converts create bill DTO to model. * @param {number} tenantId @@ -211,6 +234,9 @@ export default class BillsService const amount = sumBy(billDTO.entries, (e) => ItemEntry.calcAmount(e)); + // Retrieve the landed cost amount from landed cost entries. + const landedCostAmount = this.getBillLandedCostAmount(tenantId, billDTO); + // Bill number from DTO or from auto-increment. const billNumber = billDTO.billNumber || oldBill?.billNumber; @@ -234,6 +260,7 @@ export default class BillsService 'dueDate', ]), amount, + landedCostAmount, currencyCode: vendor.currencyCode, billNumber, entries, @@ -498,7 +525,7 @@ export default class BillsService const bill = await Bill.query() .findById(billId) .withGraphFetched('vendor') - .withGraphFetched('entries'); + .withGraphFetched('entries.item'); if (!bill) { throw new ServiceError(ERRORS.BILL_NOT_FOUND); @@ -538,10 +565,11 @@ export default class BillsService override?: boolean ): Promise { // Loads the inventory items entries of the given sale invoice. - const inventoryEntries = await this.itemsEntriesService.filterInventoryEntries( - tenantId, - bill.entries - ); + const inventoryEntries = + await this.itemsEntriesService.filterInventoryEntries( + tenantId, + bill.entries + ); const transaction = { transactionId: bill.id, transactionType: 'Bill', diff --git a/server/src/services/Purchases/LandedCost/BillLandedCost.ts b/server/src/services/Purchases/LandedCost/BillLandedCost.ts new file mode 100644 index 000000000..0aacc5ecf --- /dev/null +++ b/server/src/services/Purchases/LandedCost/BillLandedCost.ts @@ -0,0 +1,55 @@ +import { Service } from 'typedi'; +import { isEmpty } from 'lodash'; +import { + IBill, + IItem, + ILandedCostTransactionEntry, + ILandedCostTransaction, + IItemEntry, +} from 'interfaces'; + +@Service() +export default class BillLandedCost { + /** + * Retrieve the landed cost transaction from the given bill transaction. + * @param {IBill} bill + * @returns {ILandedCostTransaction} + */ + public transformToLandedCost = (bill: IBill): ILandedCostTransaction => { + const number = bill.billNumber || bill.referenceNo; + const name = [ + number, + bill.currencyCode + ' ' + bill.unallocatedCostAmount, + ].join(' - '); + + return { + id: bill.id, + name, + allocatedCostAmount: bill.allocatedCostAmount, + amount: bill.landedCostAmount, + unallocatedCostAmount: bill.unallocatedCostAmount, + transactionType: 'Bill', + + ...(!isEmpty(bill.entries)) && { + entries: bill.entries.map(this.transformToLandedCostEntry), + }, + }; + }; + + /** + * Transformes bill entry to landed cost entry. + * @param {IItemEntry} billEntry - Bill entry. + * @return {ILandedCostTransactionEntry} + */ + public transformToLandedCostEntry( + billEntry: IItemEntry & { item: IItem } + ): ILandedCostTransactionEntry { + return { + id: billEntry.id, + name: billEntry.item.name, + code: billEntry.item.code, + amount: billEntry.amount, + description: billEntry.description, + }; + } +} diff --git a/server/src/services/Purchases/LandedCost/ExpenseLandedCost.ts b/server/src/services/Purchases/LandedCost/ExpenseLandedCost.ts new file mode 100644 index 000000000..8931e500c --- /dev/null +++ b/server/src/services/Purchases/LandedCost/ExpenseLandedCost.ts @@ -0,0 +1,53 @@ +import { Service } from 'typedi'; +import { isEmpty } from 'lodash'; +import { + IExpense, + ILandedCostTransactionEntry, + IExpenseCategory, + IAccount, + ILandedCostTransaction, +} from 'interfaces'; + +@Service() +export default class ExpenseLandedCost { + /** + * Retrieve the landed cost transaction from the given expense transaction. + * @param {IExpense} expense + * @returns {ILandedCostTransaction} + */ + public transformToLandedCost = ( + expense: IExpense + ): ILandedCostTransaction => { + const name = [expense.currencyCode + ' ' + expense.totalAmount].join(' - '); + + return { + id: expense.id, + name, + allocatedCostAmount: expense.allocatedCostAmount, + amount: expense.landedCostAmount, + unallocatedCostAmount: expense.unallocatedCostAmount, + transactionType: 'Expense', + + ...(!isEmpty(expense.categories) && { + entries: expense.categories.map(this.transformToLandedCostEntry), + }), + }; + }; + + /** + * Transformes expense entry to landed cost entry. + * @param {IExpenseCategory & { expenseAccount: IAccount }} expenseEntry - + * @return {ILandedCostTransactionEntry} + */ + public transformToLandedCostEntry = ( + expenseEntry: IExpenseCategory & { expenseAccount: IAccount } + ): ILandedCostTransactionEntry => { + return { + id: expenseEntry.id, + name: expenseEntry.expenseAccount.name, + code: expenseEntry.expenseAccount.code, + amount: expenseEntry.amount, + description: expenseEntry.description, + }; + }; +} diff --git a/server/src/services/Purchases/LandedCost/LandedCostListing.ts b/server/src/services/Purchases/LandedCost/LandedCostListing.ts new file mode 100644 index 000000000..e476b9f93 --- /dev/null +++ b/server/src/services/Purchases/LandedCost/LandedCostListing.ts @@ -0,0 +1,78 @@ +import { Inject, Service } from 'typedi'; +import { ref } from 'objection'; +import { + ILandedCostTransactionsQueryDTO, + ILandedCostTransaction, + IBillLandedCostTransaction, +} from 'interfaces'; +import TransactionLandedCost from './TransctionLandedCost'; +import BillsService from '../Bills'; +import HasTenancyService from 'services/Tenancy/TenancyService'; + +@Service() +export default class LandedCostListing { + @Inject() + transactionLandedCost: TransactionLandedCost; + + @Inject() + billsService: BillsService; + + @Inject() + tenancy: HasTenancyService; + + /** + * Retrieve the landed costs based on the given query. + * @param {number} tenantId + * @param {ILandedCostTransactionsQueryDTO} query + * @returns {Promise} + */ + public getLandedCostTransactions = async ( + tenantId: number, + query: ILandedCostTransactionsQueryDTO + ): Promise => { + const { transactionType } = query; + const Model = this.transactionLandedCost.getModel( + tenantId, + query.transactionType + ); + + // Retrieve the model entities. + const transactions = await Model.query().onBuild((q) => { + q.where('allocated_cost_amount', '<', ref('landed_cost_amount')); + + if (query.transactionType === 'Bill') { + q.withGraphFetched('entries.item'); + } else if (query.transactionType === 'Expense') { + q.withGraphFetched('categories.expenseAccount'); + } + }); + return transactions.map((transaction) => ({ + ...this.transactionLandedCost.transformToLandedCost( + transactionType, + transaction + ), + })); + }; + + /** + * Retrieve the bill associated landed cost transactions. + * @param {number} tenantId - Tenant id. + * @param {number} billId - Bill id. + * @return {Promise} + */ + public getBillLandedCostTransactions = async ( + tenantId: number, + billId: number + ): Promise => { + const { BillLandedCost } = this.tenancy.models(tenantId); + + // Retrieve the given bill id or throw not found service error. + const bill = await this.billsService.getBillOrThrowError(tenantId, billId); + + const landedCostTransactions = await BillLandedCost.query() + .where('bill_id', billId) + .withGraphFetched('allocateEntries'); + + return landedCostTransactions; + }; +} diff --git a/server/src/services/Purchases/LandedCost/TransctionLandedCost.ts b/server/src/services/Purchases/LandedCost/TransctionLandedCost.ts new file mode 100644 index 000000000..57c8e13c6 --- /dev/null +++ b/server/src/services/Purchases/LandedCost/TransctionLandedCost.ts @@ -0,0 +1,61 @@ +import { Inject, Service } from 'typedi'; +import * as R from 'ramda'; +import { IBill, IExpense, ILandedCostTransaction } from 'interfaces'; +import { ServiceError } from 'exceptions'; +import BillLandedCost from './BillLandedCost'; +import ExpenseLandedCost from './ExpenseLandedCost'; +import HasTenancyService from 'services/Tenancy/TenancyService'; +import { ERRORS } from './constants'; + +@Service() +export default class TransactionLandedCost { + @Inject() + billLandedCost: BillLandedCost; + + @Inject() + expenseLandedCost: ExpenseLandedCost; + + @Inject() + tenancy: HasTenancyService; + + /** + * Retrieve the cost transaction code model. + * @param {number} tenantId - Tenant id. + * @param {string} transactionType - Transaction type. + * @returns + */ + public getModel = ( + tenantId: number, + transactionType: string + ): IBill | IExpense => { + const Models = this.tenancy.models(tenantId); + const Model = Models[transactionType]; + + if (!Model) { + throw new ServiceError(ERRORS.COST_TYPE_UNDEFINED); + } + return Model; + } + + /** + * Mappes the given expense or bill transaction to landed cost transaction. + * @param {string} transactionType - Transaction type. + * @param {IBill|IExpense} transaction - Expense or bill transaction. + * @returns {ILandedCostTransaction} + */ + public transformToLandedCost = ( + transactionType: string, + transaction: IBill | IExpense + ): ILandedCostTransaction => { + return R.compose( + R.when( + R.always(transactionType === 'Bill'), + this.billLandedCost.transformToLandedCost, + ), + R.when( + R.always(transactionType === 'Expense'), + this.expenseLandedCost.transformToLandedCost, + ), + )(transaction); + } +} diff --git a/server/src/services/Purchases/LandedCost/constants.ts b/server/src/services/Purchases/LandedCost/constants.ts new file mode 100644 index 000000000..be247f943 --- /dev/null +++ b/server/src/services/Purchases/LandedCost/constants.ts @@ -0,0 +1,15 @@ + + + +export const ERRORS = { + COST_TYPE_UNDEFINED: 'COST_TYPE_UNDEFINED', + LANDED_COST_ITEMS_IDS_NOT_FOUND: 'LANDED_COST_ITEMS_IDS_NOT_FOUND', + COST_TRANSACTION_HAS_NO_ENOUGH_TO_LOCATE: + 'COST_TRANSACTION_HAS_NO_ENOUGH_TO_LOCATE', + BILL_LANDED_COST_NOT_FOUND: 'BILL_LANDED_COST_NOT_FOUND', + COST_ENTRY_ID_NOT_FOUND: 'COST_ENTRY_ID_NOT_FOUND', + LANDED_COST_TRANSACTION_NOT_FOUND: 'LANDED_COST_TRANSACTION_NOT_FOUND', + LANDED_COST_ENTRY_NOT_FOUND: 'LANDED_COST_ENTRY_NOT_FOUND', + COST_AMOUNT_BIGGER_THAN_UNALLOCATED_AMOUNT: 'COST_AMOUNT_BIGGER_THAN_UNALLOCATED_AMOUNT', + ALLOCATE_COST_SHOULD_NOT_BE_BILL: 'ALLOCATE_COST_SHOULD_NOT_BE_BILL' +}; diff --git a/server/src/services/Purchases/LandedCost/index.ts b/server/src/services/Purchases/LandedCost/index.ts new file mode 100644 index 000000000..604ddc3e9 --- /dev/null +++ b/server/src/services/Purchases/LandedCost/index.ts @@ -0,0 +1,504 @@ +import { Inject, Service } from 'typedi'; +import { difference, sumBy } from 'lodash'; +import BillsService from '../Bills'; +import { ServiceError } from 'exceptions'; +import { + IItemEntry, + IBill, + IBillLandedCost, + ILandedCostItemDTO, + ILandedCostDTO, +} from 'interfaces'; +import InventoryService from 'services/Inventory/Inventory'; +import HasTenancyService from 'services/Tenancy/TenancyService'; +import { ERRORS } from './constants'; +import { mergeObjectsBykey } from 'utils'; +import JournalPoster from 'services/Accounting/JournalPoster'; +import JournalEntry from 'services/Accounting/JournalEntry'; +import TransactionLandedCost from './TransctionLandedCost'; + +const CONFIG = { + COST_TYPES: { + Expense: { + entries: 'categories', + }, + Bill: { + entries: 'entries', + }, + }, +}; + +@Service() +export default class AllocateLandedCostService { + @Inject() + public billsService: BillsService; + + @Inject() + public inventoryService: InventoryService; + + @Inject() + public tenancy: HasTenancyService; + + @Inject('logger') + public logger: any; + + @Inject() + public transactionLandedCost: TransactionLandedCost; + + /** + * Validates allocate cost items association with the purchase invoice entries. + * @param {IItemEntry[]} purchaseInvoiceEntries + * @param {ILandedCostItemDTO[]} landedCostItems + */ + private validateAllocateCostItems = ( + purchaseInvoiceEntries: IItemEntry[], + landedCostItems: ILandedCostItemDTO[] + ): void => { + // Purchase invoice entries items ids. + const purchaseInvoiceItems = purchaseInvoiceEntries.map((e) => e.id); + const landedCostItemsIds = landedCostItems.map((item) => item.entryId); + + // Not found items ids. + const notFoundItemsIds = difference( + purchaseInvoiceItems, + landedCostItemsIds + ); + // Throw items ids not found service error. + if (notFoundItemsIds.length > 0) { + throw new ServiceError(ERRORS.LANDED_COST_ITEMS_IDS_NOT_FOUND); + } + }; + + /** + * Saves the bill landed cost model. + * @param {number} tenantId + * @param {ILandedCostDTO} landedCostDTO + * @param {number} purchaseInvoiceId + * @returns {Promise} + */ + private saveBillLandedCostModel = ( + tenantId: number, + landedCostDTO: ILandedCostDTO, + purchaseInvoiceId: number + ): Promise => { + const { BillLandedCost } = this.tenancy.models(tenantId); + const amount = sumBy(landedCostDTO.items, 'cost'); + + // Inserts the bill landed cost to the storage. + return BillLandedCost.query().insertGraph({ + billId: purchaseInvoiceId, + fromTransactionType: landedCostDTO.transactionType, + fromTransactionId: landedCostDTO.transactionId, + fromTransactionEntryId: landedCostDTO.transactionEntryId, + amount, + allocationMethod: landedCostDTO.allocationMethod, + description: landedCostDTO.description, + allocateEntries: landedCostDTO.items, + }); + }; + + /** + * Allocate the landed cost amount to cost transactions. + * @param {number} tenantId - + * @param {string} transactionType + * @param {number} transactionId + */ + private incrementLandedCostAmount = async ( + tenantId: number, + transactionType: string, + transactionId: number, + transactionEntryId: number, + amount: number + ): Promise => { + const Model = this.transactionLandedCost.getModel( + tenantId, + transactionType + ); + const relation = CONFIG.COST_TYPES[transactionType].entries; + + // Increment the landed cost transaction amount. + await Model.query() + .where('id', transactionId) + .increment('allocatedCostAmount', amount); + + // Increment the landed cost entry. + await Model.relatedQuery(relation) + .for(transactionId) + .where('id', transactionEntryId) + .increment('allocatedCostAmount', amount); + }; + + /** + * Reverts the landed cost amount to cost transaction. + * @param {number} tenantId - Tenant id. + * @param {string} transactionType - Transaction type. + * @param {number} transactionId - Transaction id. + * @param {number} amount - Amount + */ + private revertLandedCostAmount = ( + tenantId: number, + transactionType: string, + transactionId: number, + amount: number + ) => { + const Model = this.transactionLandedCost.getModel(tenantId, transactionType); + + // Decrement the allocate cost amount of cost transaction. + return Model.query() + .where('id', transactionId) + .decrement('allocatedCostAmount', amount); + }; + + /** + * Retrieve the cost transaction or throw not found error. + * @param {number} tenantId + * @param {transactionType} transactionType - + * @param {transactionId} transactionId - + */ + public getLandedCostOrThrowError = async ( + tenantId: number, + transactionType: string, + transactionId: number + ) => { + const Model = this.transactionLandedCost.getModel( + tenantId, + transactionType + ); + const model = await Model.query().findById(transactionId); + + if (!model) { + throw new ServiceError(ERRORS.LANDED_COST_TRANSACTION_NOT_FOUND); + } + return this.transactionLandedCost.transformToLandedCost( + transactionType, + model + ); + }; + + /** + * Retrieve the landed cost entries. + * @param {number} tenantId + * @param {string} transactionType + * @param {number} transactionId + * @returns + */ + public getLandedCostEntry = async ( + tenantId: number, + transactionType: string, + transactionId: number, + transactionEntryId: number + ): Promise => { + const Model = this.transactionLandedCost.getModel( + tenantId, + transactionType + ); + const relation = CONFIG.COST_TYPES[transactionType].entries; + + const entry = await Model.relatedQuery(relation) + .for(transactionId) + .findOne('id', transactionEntryId) + .where('landedCost', true); + + if (!entry) { + throw new ServiceError(ERRORS.LANDED_COST_ENTRY_NOT_FOUND); + } + return entry; + }; + + /** + * Retrieve allocate items cost total. + * @param {ILandedCostDTO} landedCostDTO + * @returns {number} + */ + private getAllocateItemsCostTotal = ( + landedCostDTO: ILandedCostDTO + ): number => { + return sumBy(landedCostDTO.items, 'cost'); + }; + + /** + * Validate allocate cost transaction should not be bill transaction. + * @param {number} purchaseInvoiceId + * @param {string} transactionType + * @param {number} transactionId + */ + private validateAllocateCostNotSameBill = ( + purchaseInvoiceId: number, + transactionType: string, + transactionId: number + ): void => { + if (transactionType === 'Bill' && transactionId === purchaseInvoiceId) { + throw new ServiceError(ERRORS.ALLOCATE_COST_SHOULD_NOT_BE_BILL); + } + }; + + /** + * Validates the landed cost entry amount. + * @param {number} unallocatedCost - + * @param {number} amount - + */ + private validateLandedCostEntryAmount = ( + unallocatedCost: number, + amount: number + ): void => { + console.log(unallocatedCost, amount, '123'); + + if (unallocatedCost < amount) { + throw new ServiceError(ERRORS.COST_AMOUNT_BIGGER_THAN_UNALLOCATED_AMOUNT); + } + }; + + /** + * Records inventory transactions. + * @param {number} tenantId + * @param {} allocateEntries + */ + private recordInventoryTransactions = async ( + tenantId: number, + allocateEntries, + purchaseInvoice: IBill, + landedCostId: number + ) => { + const costEntries = mergeObjectsBykey( + purchaseInvoice.entries, + allocateEntries.map((e) => ({ ...e, id: e.itemId })), + 'id' + ); + // Inventory transaction. + const inventoryTransactions = costEntries.map((entry) => ({ + date: purchaseInvoice.billDate, + itemId: entry.itemId, + direction: 'IN', + quantity: 0, + rate: entry.cost, + transactionType: 'LandedCost', + transactionId: landedCostId, + entryId: entry.id, + })); + + return this.inventoryService.recordInventoryTransactions( + tenantId, + inventoryTransactions + ); + }; + + /** + * ================================= + * Allocate landed cost. + * ================================= + * - Validates the allocate cost not the same purchase invoice id. + * - Get the given bill (purchase invoice) or throw not found error. + * - Get the given landed cost transaction or throw not found error. + * - Validate landed cost transaction has enough unallocated cost amount. + * - Validate landed cost transaction entry has enough unallocated cost amount. + * - Validate allocate entries existance and associated with cost bill transaction. + * - Writes inventory landed cost transaction. + * - Increment the allocated landed cost transaction. + * - Increment the allocated landed cost transaction entry. + * + * @param {ILandedCostDTO} landedCostDTO - Landed cost DTO. + * @param {number} tenantId - Tenant id. + * @param {number} purchaseInvoiceId - Purchase invoice id. + */ + public allocateLandedCost = async ( + tenantId: number, + allocateCostDTO: ILandedCostDTO, + purchaseInvoiceId: number + ): Promise<{ + billLandedCost: IBillLandedCost; + }> => { + // Retrieve total cost of allocated items. + const amount = this.getAllocateItemsCostTotal(allocateCostDTO); + + // Retrieve the purchase invoice or throw not found error. + const purchaseInvoice = await this.billsService.getBillOrThrowError( + tenantId, + purchaseInvoiceId + ); + // Retrieve landed cost transaction or throw not found service error. + const landedCostTransaction = await this.getLandedCostOrThrowError( + tenantId, + allocateCostDTO.transactionType, + allocateCostDTO.transactionId + ); + // Retrieve landed cost transaction entries. + const landedCostEntry = await this.getLandedCostEntry( + tenantId, + allocateCostDTO.transactionType, + allocateCostDTO.transactionId, + allocateCostDTO.transactionEntryId + ); + // Validates allocate cost items association with the purchase invoice entries. + this.validateAllocateCostItems( + purchaseInvoice.entries, + allocateCostDTO.items + ); + // Validate the amount of cost with unallocated landed cost. + this.validateLandedCostEntryAmount( + landedCostEntry.unallocatedLandedCost, + amount + ); + // Save the bill landed cost model. + const billLandedCost = await this.saveBillLandedCostModel( + tenantId, + allocateCostDTO, + purchaseInvoiceId + ); + // Records the inventory transactions. + // await this.recordInventoryTransactions( + // tenantId, + // allocateCostDTO.items, + // purchaseInvoice, + // landedCostTransaction.id + // ); + // Increment landed cost amount on transaction and entry. + await this.incrementLandedCostAmount( + tenantId, + allocateCostDTO.transactionType, + allocateCostDTO.transactionId, + allocateCostDTO.transactionEntryId, + amount + ); + // Write the landed cost journal entries. + // await this.writeJournalEntry(tenantId, purchaseInvoice, billLandedCost); + + return { billLandedCost }; + }; + + /** + * Write journal entries of the given purchase invoice landed cost. + * @param tenantId + * @param purchaseInvoice + * @param landedCost + */ + private writeJournalEntry = async ( + tenantId: number, + purchaseInvoice: IBill, + landedCost: IBillLandedCost + ) => { + const journal = new JournalPoster(tenantId); + const billEntriesById = purchaseInvoice.entries; + + const commonEntry = { + referenceType: 'Bill', + referenceId: purchaseInvoice.id, + date: purchaseInvoice.billDate, + indexGroup: 300, + }; + const costEntry = new JournalEntry({ + ...commonEntry, + credit: landedCost.amount, + account: landedCost.costAccountId, + index: 1, + }); + journal.credit(costEntry); + + landedCost.allocateEntries.forEach((entry, index) => { + const billEntry = billEntriesById[entry.entryId]; + + const inventoryEntry = new JournalEntry({ + ...commonEntry, + debit: entry.cost, + account: billEntry.item.inventoryAccountId, + index: 1 + index, + }); + journal.debit(inventoryEntry); + }); + return journal; + }; + + /** + * Retrieve the give bill landed cost or throw not found service error. + * @param {number} tenantId - Tenant id. + * @param {number} landedCostId - Landed cost id. + * @returns {Promise} + */ + public getBillLandedCostOrThrowError = async ( + tenantId: number, + landedCostId: number + ): Promise => { + const { BillLandedCost } = this.tenancy.models(tenantId); + + // Retrieve the bill landed cost model. + const billLandedCost = await BillLandedCost.query().findById(landedCostId); + + if (!billLandedCost) { + throw new ServiceError(ERRORS.BILL_LANDED_COST_NOT_FOUND); + } + return billLandedCost; + }; + + /** + * Deletes the landed cost transaction with assocaited allocate entries. + * @param {number} tenantId + * @param {number} landedCostId + */ + public deleteLandedCost = async ( + tenantId: number, + landedCostId: number + ): Promise => { + const { BillLandedCost, BillLandedCostEntry } = + this.tenancy.models(tenantId); + + // Deletes the bill landed cost allocated entries associated to landed cost. + await BillLandedCostEntry.query() + .where('bill_located_cost_id', landedCostId) + .delete(); + + // Delete the bill landed cost from the storage. + await BillLandedCost.query().where('id', landedCostId).delete(); + }; + + /** + * Deletes the allocated landed cost. + * ================================== + * - Delete bill landed cost transaction with associated allocate entries. + * - Delete the associated inventory transactions. + * - Decrement allocated amount of landed cost transaction and entry. + * - Revert journal entries. + * + * @param {number} tenantId - Tenant id. + * @param {number} landedCostId - Landed cost id. + * @return {Promise} + */ + public deleteAllocatedLandedCost = async ( + tenantId: number, + landedCostId: number + ): Promise<{ + landedCostId: number; + }> => { + // Retrieves the bill landed cost. + const oldBillLandedCost = await this.getBillLandedCostOrThrowError( + tenantId, + landedCostId + ); + // Delete landed cost transaction with assocaited locate entries. + await this.deleteLandedCost(tenantId, landedCostId); + + // Removes the inventory transactions. + await this.removeInventoryTransactions(tenantId, landedCostId); + + // Reverts the landed cost amount to the cost transaction. + await this.revertLandedCostAmount( + tenantId, + oldBillLandedCost.fromTransactionType, + oldBillLandedCost.fromTransactionId, + oldBillLandedCost.amount + ); + return { landedCostId }; + }; + + /** + * Deletes the inventory transaction. + * @param {number} tenantId + * @param {number} landedCostId + * @returns + */ + private removeInventoryTransactions = (tenantId, landedCostId: number) => { + return this.inventoryService.deleteInventoryTransactions( + tenantId, + landedCostId, + 'LandedCost' + ); + }; +} diff --git a/server/src/subscribers/events.ts b/server/src/subscribers/events.ts index 48f8836b5..fe6a74f0f 100644 --- a/server/src/subscribers/events.ts +++ b/server/src/subscribers/events.ts @@ -203,5 +203,15 @@ export default { onQuickCreated: 'onInventoryAdjustmentQuickCreated', onDeleted: 'onInventoryAdjustmentDeleted', onPublished: 'onInventoryAdjustmentPublished', + }, + + /** + * Bill landed cost. + */ + billLandedCost: { + onCreate: 'onBillLandedCostCreate', + onCreated: 'onBillLandedCostCreated', + onDelete: 'onBillLandedCostDelete', + onDeleted: 'onBillLandedCostDeleted' } } diff --git a/server/src/utils/index.ts b/server/src/utils/index.ts index a5f894765..a18bc93e2 100644 --- a/server/src/utils/index.ts +++ b/server/src/utils/index.ts @@ -373,6 +373,11 @@ const accumSum = (data, callback) => { }, 0) } +const mergeObjectsBykey = (object1, object2, key) => { + var merged = _.merge(_.keyBy(object1, key), _.keyBy(object2, key)); + return _.values(merged); +} + export { accumSum, increment, @@ -400,5 +405,6 @@ export { transactionIncrement, transformToMapBy, dateRangeFromToCollection, - transformToMapKeyValue + transformToMapKeyValue, + mergeObjectsBykey };