WIP: Allocate landed cost.

This commit is contained in:
a.bouhuolia
2021-07-24 03:10:32 +02:00
parent 70aea9bf2d
commit cf2ebe9597
30 changed files with 602 additions and 218 deletions

View File

@@ -39,7 +39,7 @@ export default class ExpensesController extends BaseController {
);
router.post(
'/:id',
[...this.expenseDTOSchema, ...this.expenseParamSchema],
[...this.editExpenseDTOSchema, ...this.expenseParamSchema],
this.validationResult,
asyncMiddleware(this.editExpense.bind(this)),
this.catchServiceErrors
@@ -116,12 +116,62 @@ export default class ExpensesController extends BaseController {
}
/**
* Expense param schema.
* Edit expense validation schema.
*/
get editExpenseDTOSchema() {
return [
check('reference_no')
.optional({ nullable: true })
.trim()
.escape()
.isLength({ max: DATATYPES_LENGTH.STRING }),
check('payment_date').exists().isISO8601(),
check('payment_account_id')
.exists()
.isInt({ max: DATATYPES_LENGTH.INT_10 })
.toInt(),
check('description')
.optional({ nullable: true })
.isString()
.isLength({ max: DATATYPES_LENGTH.TEXT }),
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.*.id').optional().isNumeric().toInt(),
check('categories.*.index')
.exists()
.isInt({ max: DATATYPES_LENGTH.INT_10 })
.toInt(),
check('categories.*.expense_account_id')
.exists()
.isInt({ max: DATATYPES_LENGTH.INT_10 })
.toInt(),
check('categories.*.amount')
.optional({ nullable: true })
.isFloat({ max: DATATYPES_LENGTH.DECIMAL_13_3 }) // 13, 3
.toFloat(),
check('categories.*.description')
.optional()
.trim()
.escape()
.isLength({ max: DATATYPES_LENGTH.STRING }),
check('categories.*.landed_cost').optional().isBoolean().toBoolean(),
];
}
/**
* Expense param validation schema.
*/
get expenseParamSchema() {
return [param('id').exists().isNumeric().toInt()];
}
/**
* Expenses list validation schema.
*/
get expensesListSchema() {
return [
query('custom_view_id').optional().isNumeric().toInt(),
@@ -291,7 +341,7 @@ export default class ExpensesController extends BaseController {
* @param {Response} res
* @param {ServiceError} error
*/
catchServiceErrors(
private catchServiceErrors(
error: Error,
req: Request,
res: Response,
@@ -348,6 +398,25 @@ export default class ExpensesController extends BaseController {
errors: [{ type: 'EXPENSE_HAS_ASSOCIATED_LANDED_COST', code: 900 }],
});
}
if (error.errorType === 'ENTRIES_ALLOCATED_COST_COULD_NOT_DELETED') {
return res.status(400).send({
errors: [
{ type: 'ENTRIES_ALLOCATED_COST_COULD_NOT_DELETED', code: 1000 },
],
});
}
if (
error.errorType === 'LOCATED_COST_ENTRIES_SHOULD_BIGGE_THAN_NEW_ENTRIES'
) {
return res.status(400).send({
errors: [
{
type: 'LOCATED_COST_ENTRIES_SHOULD_BIGGE_THAN_NEW_ENTRIES',
code: 1100,
},
],
});
}
}
next(error);
}

View File

@@ -406,7 +406,7 @@ export default class ItemsController extends BaseController {
* @param {Response} res
* @param {NextFunction} next
*/
handlerServiceErrors(
private handlerServiceErrors(
error: Error,
req: Request,
res: Response,

View File

@@ -145,7 +145,7 @@ export default class BillsController extends BaseController {
.optional({ nullable: true })
.trim()
.escape(),
check('entries.*.landedCost')
check('entries.*.landed_cost')
.optional({ nullable: true })
.isBoolean()
.toBoolean(),
@@ -347,7 +347,7 @@ export default class BillsController extends BaseController {
* @param {Response} res
* @param {NextFunction} next
*/
handleServiceError(
private handleServiceError(
error: Error,
req: Request,
res: Response,
@@ -422,6 +422,40 @@ export default class BillsController extends BaseController {
],
});
}
if (error.errorType === 'BILL_HAS_ASSOCIATED_LANDED_COSTS') {
return res.status(400).send({
errors: [
{
type: 'BILL_HAS_ASSOCIATED_LANDED_COSTS',
message:
'Cannot delete bill that has associated landed cost transactions.',
code: 1300,
},
],
});
}
if (error.errorType === 'ENTRIES_ALLOCATED_COST_COULD_NOT_DELETED') {
return res.status(400).send({
errors: [
{
type: 'ENTRIES_ALLOCATED_COST_COULD_NOT_DELETED',
code: 1400,
message:
'Bill entries that have landed cost type can not be deleted.',
},
],
});
}
if (error.errorType === 'LOCATED_COST_ENTRIES_SHOULD_BIGGE_THAN_NEW_ENTRIES') {
return res.status(400).send({
errors: [
{
type: 'LOCATED_COST_ENTRIES_SHOULD_BIGGE_THAN_NEW_ENTRIES',
code: 1500,
},
],
});
}
}
next(error);
}

View File

@@ -64,9 +64,9 @@ export default class BillAllocateLandedCost extends BaseController {
/**
* Retrieve the landed cost transactions of the given query.
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
* @param {Request} req - Request
* @param {Response} res - Response.
* @param {NextFunction} next - Next function.
*/
private async getLandedCostTransactions(
req: Request,
@@ -192,10 +192,7 @@ export default class BillAllocateLandedCost extends BaseController {
billId
);
return res.status(200).send({
billId,
transactions,
});
return res.status(200).send({ billId, transactions });
} catch (error) {
next(error);
}

View File

@@ -40,7 +40,6 @@ import Ping from 'api/controllers/Ping';
import Subscription from 'api/controllers/Subscription';
import Licenses from 'api/controllers/Subscription/Licenses';
import InventoryAdjustments from 'api/controllers/Inventory/InventoryAdjustments';
import Setup from 'api/controllers/Setup';
export default () => {

View File

@@ -28,7 +28,8 @@ exports.up = function (knex) {
.inTable('accounts');
table.boolean('landed_cost').defaultTo(false);
table.decimal('allocated_cost_amount', 13, 3);
table.decimal('allocated_cost_amount', 13, 3).defaultTo(0);
table.timestamps();
});
};

View File

@@ -0,0 +1,18 @@
export interface ICommonEntry {
id: number;
amount: number;
}
export interface ICommonLandedCostEntry extends ICommonEntry {
landedCost: boolean;
allocatedCostAmount: number;
}
export interface ICommonEntryDTO {
id?: number;
amount: number;
}
export interface ICommonLandedCostEntryDTO extends ICommonEntryDTO {
landedCost?: boolean;
}

View File

@@ -40,6 +40,9 @@ export interface IExpenseCategory {
description: string;
expenseId: number;
amount: number;
allocatedCostAmount: number;
unallocatedCostAmount: number;
landedCost: boolean;
}
@@ -57,8 +60,10 @@ export interface IExpenseDTO {
}
export interface IExpenseCategoryDTO {
id?: number;
expenseAccountId: number;
index: number;
amount: number;
description?: string;
expenseId: number;
landedCost?: boolean;

View File

@@ -1,26 +1,29 @@
export type IItemEntryTransactionType = 'SaleInvoice' | 'Bill' | 'SaleReceipt';
export interface IItemEntry {
id?: number,
id?: number;
referenceType: string,
referenceId: number,
referenceType: string;
referenceId: number;
index: number,
index: number;
itemId: number,
description: string,
discount: number,
quantity: number,
rate: number,
itemId: number;
description: string;
discount: number;
quantity: number;
rate: number;
amount: number;
sellAccountId: number,
costAccountId: number,
landedCost: number;
allocatedCostAmount: number;
unallocatedCostAmount: number;
landedCost?: boolean,
sellAccountId: number;
costAccountId: number;
}
export interface IItemEntryDTO {
landedCost?: boolean
}
id?: number,
landedCost?: boolean;
}

View File

@@ -64,7 +64,10 @@ export interface ILandedCostTransactionEntry {
name: string;
code: string;
amount: number;
unallocatedCostAmount: number;
allocatedCostAmount: number;
description: string;
costAccountId: number;
}
interface ILandedCostEntry {
@@ -83,7 +86,7 @@ export interface IBillLandedCostTransaction {
costAccountId: number,
description: string;
allocatedEntries?: IBillLandedCostTransactionEntry[],
allocateEntries?: IBillLandedCostTransactionEntry[],
};
export interface IBillLandedCostTransactionEntry {

View File

@@ -54,6 +54,7 @@ export * from './Ledger';
export * from './CashFlow';
export * from './InventoryDetails';
export * from './LandedCost';
export * from './Entry';
export interface I18nService {
__: (input: string) => string;

View File

@@ -26,4 +26,6 @@ import 'subscribers/vendors';
import 'subscribers/paymentMades';
import 'subscribers/paymentReceives';
import 'subscribers/saleEstimates';
import 'subscribers/items';
import 'subscribers/items';
import 'subscribers/LandedCost';

View File

@@ -1,4 +1,5 @@
import { Model } from 'objection';
import { lowerCase } from 'lodash';
import TenantModel from 'models/TenantModel';
export default class BillLandedCost extends TenantModel {
@@ -16,6 +17,25 @@ export default class BillLandedCost extends TenantModel {
return ['createdAt', 'updatedAt'];
}
/**
* Virtual attributes.
*/
static get virtualAttributes() {
return ['allocationMethodFormatted'];
}
/**
* Allocation method formatted.
*/
get allocationMethodFormatted() {
const allocationMethod = lowerCase(this.allocationMethod);
const keyLabelsPairs = {
value: 'Value',
quantity: 'Quantity',
};
return keyLabelsPairs[allocationMethod] || '';
}
/**
* Relationship mapping.
*/

View File

@@ -1,3 +1,4 @@
import { Model } from 'objection';
import TenantModel from 'models/TenantModel';
export default class BillLandedCostEntry extends TenantModel {
@@ -7,4 +8,25 @@ export default class BillLandedCostEntry extends TenantModel {
static get tableName() {
return 'bill_located_cost_entries';
}
/**
* Relationship mapping.
*/
static get relationMappings() {
const ItemEntry = require('models/ItemEntry');
return {
itemEntry: {
relation: Model.BelongsToOneRelation,
modelClass: ItemEntry.default,
join: {
from: 'bill_located_cost_entries.entryId',
to: 'items_entries.referenceId',
},
filter(builder) {
builder.where('reference_type', 'Bill');
},
},
};
}
}

View File

@@ -39,14 +39,18 @@ export default class Expense extends TenantModel {
}
static get virtualAttributes() {
return ['isPublished', 'unallocatedLandedCost'];
return ['isPublished', 'unallocatedCostAmount'];
}
isPublished() {
return Boolean(this.publishedAt);
}
unallocatedLandedCost() {
/**
* Retrieve the unallocated cost amount.
* @return {number}
*/
get unallocatedCostAmount() {
return Math.max(this.amount - this.allocatedCostAmount, 0);
}

View File

@@ -13,14 +13,14 @@ export default class ExpenseCategory extends TenantModel {
* Virtual attributes.
*/
static get virtualAttributes() {
return ['unallocatedLandedCost'];
return ['unallocatedCostAmount'];
}
/**
* Remain unallocated landed cost.
* @return {number}
*/
get unallocatedLandedCost() {
get unallocatedCostAmount() {
return Math.max(this.amount - this.allocatedCostAmount, 0);
}

View File

@@ -21,8 +21,8 @@ export default class ItemEntry extends TenantModel {
return ['amount'];
}
static amount() {
return this.calcAmount(this);
get amount() {
return ItemEntry.calcAmount(this);
}
static calcAmount(itemEntry) {
@@ -34,6 +34,7 @@ export default class ItemEntry extends TenantModel {
static get relationMappings() {
const Item = require('models/Item');
const BillLandedCostEntry = require('models/BillLandedCostEntry');
return {
item: {
@@ -44,6 +45,14 @@ export default class ItemEntry extends TenantModel {
to: 'items.id',
},
},
allocatedCostEntries: {
relation: Model.HasManyRelation,
modelClass: BillLandedCostEntry.default,
join: {
from: 'items_entries.referenceId',
to: 'bill_located_cost_entries.entryId',
},
},
};
}
}

View File

@@ -1,9 +1,11 @@
import moment from 'moment';
import { sumBy } from 'lodash';
import {
IBill,
IManualJournalEntry,
ISaleReceipt,
ISystemUser,
IAccount,
} from 'interfaces';
import JournalPoster from './JournalPoster';
import JournalEntry from './JournalEntry';
@@ -17,7 +19,6 @@ import {
IItemEntry,
} from 'interfaces';
import { increment } from 'utils';
export default class JournalCommands {
journal: JournalPoster;
models: any;
@@ -37,45 +38,20 @@ export default class JournalCommands {
/**
* Records the bill journal entries.
* @param {IBill} bill
* @param {boolean} override - Override the old bill entries.
* @param {IAccount} payableAccount -
*/
async bill(bill: IBill, override: boolean = false): Promise<void> {
const { transactionsRepository, accountRepository } = this.repositories;
const { Item, ItemEntry } = this.models;
const entriesItemsIds = bill.entries.map((entry) => entry.itemId);
// Retrieve the bill transaction items.
const storedItems = await Item.query().whereIn('id', entriesItemsIds);
const storedItemsMap = new Map(storedItems.map((item) => [item.id, item]));
const payableAccount = await accountRepository.findOne({
slug: 'accounts-payable',
});
const formattedDate = moment(bill.billDate).format('YYYY-MM-DD');
bill(bill: IBill, payableAccount: IAccount): void {
const commonJournalMeta = {
debit: 0,
credit: 0,
referenceId: bill.id,
referenceType: 'Bill',
date: formattedDate,
date: moment(bill.billDate).format('YYYY-MM-DD'),
userId: bill.userId,
referenceNumber: bill.referenceNo,
transactionNumber: bill.billNumber,
createdAt: bill.createdAt,
};
// Overrides the old bill entries.
if (override) {
const entries = await transactionsRepository.journal({
referenceType: ['Bill'],
referenceId: [bill.id],
});
this.journal.fromTransactions(entries);
this.journal.removeEntries();
}
const payableEntry = new JournalEntry({
...commonJournalMeta,
credit: bill.amount,
@@ -86,15 +62,15 @@ export default class JournalCommands {
this.journal.credit(payableEntry);
bill.entries.forEach((entry, index) => {
const item: IItem = storedItemsMap.get(entry.itemId);
const amount = ItemEntry.calcAmount(entry);
const landedCostAmount = sumBy(entry.allocatedCostEntries, 'cost');
// Inventory or cost entry.
const debitEntry = new JournalEntry({
...commonJournalMeta,
debit: amount,
debit: entry.amount + landedCostAmount,
account:
['inventory'].indexOf(item.type) !== -1
? item.inventoryAccountId
['inventory'].indexOf(entry.item.type) !== -1
? entry.item.inventoryAccountId
: entry.costAccountId,
index: index + 2,
itemId: entry.itemId,
@@ -102,6 +78,16 @@ export default class JournalCommands {
});
this.journal.debit(debitEntry);
});
// Allocate cost entries journal entries.
bill.locatedLandedCosts.forEach((landedCost) => {
const creditEntry = new JournalEntry({
...commonJournalMeta,
credit: landedCost.amount,
account: landedCost.costAccountId,
});
this.journal.credit(creditEntry);
});
}
/**

View File

@@ -0,0 +1,78 @@
import { Service } from 'typedi';
import { ServiceError } from 'exceptions';
import { transformToMap } from 'utils';
import {
ICommonLandedCostEntry,
ICommonLandedCostEntryDTO
} from 'interfaces';
const ERRORS = {
ENTRIES_ALLOCATED_COST_COULD_NOT_DELETED:
'ENTRIES_ALLOCATED_COST_COULD_NOT_DELETED',
LOCATED_COST_ENTRIES_SHOULD_BIGGE_THAN_NEW_ENTRIES:
'LOCATED_COST_ENTRIES_SHOULD_BIGGE_THAN_NEW_ENTRIES',
};
@Service()
export default class EntriesService {
/**
* Validates bill entries that has allocated landed cost amount not deleted.
* @param {IItemEntry[]} oldBillEntries -
* @param {IItemEntry[]} newBillEntries -
*/
public getLandedCostEntriesDeleted(
oldBillEntries: ICommonLandedCostEntry[],
newBillEntriesDTO: ICommonLandedCostEntryDTO[]
): ICommonLandedCostEntry[] {
const newBillEntriesById = transformToMap(newBillEntriesDTO, 'id');
return oldBillEntries.filter((entry) => {
const newEntry = newBillEntriesById.get(entry.id);
if (entry.allocatedCostAmount > 0 && typeof newEntry === 'undefined') {
return true;
}
return false;
});
}
/**
* Validates the bill entries that have located cost amount should not be deleted.
* @param {IItemEntry[]} oldBillEntries - Old bill entries.
* @param {IItemEntryDTO[]} newBillEntries - New DTO bill entries.
*/
public validateLandedCostEntriesNotDeleted(
oldBillEntries: ICommonLandedCostEntry[],
newBillEntriesDTO: ICommonLandedCostEntryDTO[]
): void {
const entriesDeleted = this.getLandedCostEntriesDeleted(
oldBillEntries,
newBillEntriesDTO
);
if (entriesDeleted.length > 0) {
throw new ServiceError(ERRORS.ENTRIES_ALLOCATED_COST_COULD_NOT_DELETED);
}
}
/**
* Validate allocated cost amount entries should be smaller than new entries amount.
* @param {IItemEntry[]} oldBillEntries - Old bill entries.
* @param {IItemEntryDTO[]} newBillEntries - New DTO bill entries.
*/
public validateLocatedCostEntriesSmallerThanNewEntries(
oldBillEntries: ICommonLandedCostEntry[],
newBillEntriesDTO: ICommonLandedCostEntryDTO[]
): void {
const oldBillEntriesById = transformToMap(oldBillEntries, 'id');
newBillEntriesDTO.forEach((entry) => {
const oldEntry = oldBillEntriesById.get(entry.id);
if (oldEntry && oldEntry.allocatedCostAmount > entry.amount) {
throw new ServiceError(
ERRORS.LOCATED_COST_ENTRIES_SHOULD_BIGGE_THAN_NEW_ENTRIES
);
}
});
}
}

View File

@@ -23,6 +23,7 @@ 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 EntriesService from 'services/Entries';
const ERRORS = {
EXPENSE_NOT_FOUND: 'expense_not_found',
@@ -53,6 +54,9 @@ export default class ExpensesService implements IExpensesService {
@Inject()
contactsService: ContactsService;
@Inject()
entriesService: EntriesService;
/**
* Retrieve the payment account details or returns not found server error in case the
* given account not found on the storage.
@@ -251,14 +255,16 @@ export default class ExpensesService implements IExpensesService {
* @returns {IExpense|ServiceError}
*/
private async getExpenseOrThrowError(tenantId: number, expenseId: number) {
const { expenseRepository } = this.tenancy.repositories(tenantId);
const { Expense } = this.tenancy.models(tenantId);
this.logger.info('[expense] trying to get the given expense.', {
tenantId,
expenseId,
});
// Retrieve the given expense by id.
const expense = await expenseRepository.findOneById(expenseId);
const expense = await Expense.query()
.findById(expenseId)
.withGraphFetched('categories');
if (!expense) {
this.logger.info('[expense] the given expense not found.', {
@@ -459,36 +465,47 @@ export default class ExpensesService implements IExpensesService {
const { expenseRepository } = this.tenancy.repositories(tenantId);
const oldExpense = await this.getExpenseOrThrowError(tenantId, expenseId);
// - Validate payment account existance on the storage.
// Validate payment account existance on the storage.
const paymentAccount = await this.getPaymentAccountOrThrowError(
tenantId,
expenseDTO.paymentAccountId
);
// - Validate expense accounts exist on the storage.
// Validate expense accounts exist on the storage.
const expensesAccounts = await this.getExpensesAccountsOrThrowError(
tenantId,
this.mapExpensesAccountsIdsFromDTO(expenseDTO)
);
// - Validate payment account type.
// Validate payment account type.
await this.validatePaymentAccountType(tenantId, paymentAccount);
// - Validate expenses accounts type.
// Validate expenses accounts type.
await this.validateExpensesAccountsType(tenantId, expensesAccounts);
// - Validate the expense payee contact id existance on storage.
// 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.
// Validate the given expense categories not equal zero.
this.validateCategoriesNotEqualZero(expenseDTO);
// - Update the expense on the storage.
// Update the expense on the storage.
const expenseObj = this.expenseDTOToModel(expenseDTO);
// - Upsert the expense object with expense entries.
// Validate expense entries that have allocated landed cost cannot be deleted.
this.entriesService.validateLandedCostEntriesNotDeleted(
oldExpense.categories,
expenseDTO.categories,
);
// Validate expense entries that have allocated cost amount should be bigger than amount.
this.entriesService.validateLocatedCostEntriesSmallerThanNewEntries(
oldExpense.categories,
expenseDTO.categories,
);
// Upsert the expense object with expense entries.
const expense = await expenseRepository.upsertGraph({
id: expenseId,
...expenseObj,

View File

@@ -1,4 +1,4 @@
import { omit, sumBy } from 'lodash';
import { omit, runInContext, sumBy } from 'lodash';
import moment from 'moment';
import { Inject, Service } from 'typedi';
import composeAsync from 'async/compose';
@@ -24,6 +24,7 @@ import {
IBillsFilter,
IBillsService,
IItemEntry,
IItemEntryDTO,
} from 'interfaces';
import { ServiceError } from 'exceptions';
import ItemsService from 'services/Items/ItemsService';
@@ -32,6 +33,7 @@ import JournalCommands from 'services/Accounting/JournalCommands';
import JournalPosterService from 'services/Sales/JournalPosterService';
import VendorsService from 'services/Contacts/VendorsService';
import { ERRORS } from './constants';
import EntriesService from 'services/Entries';
/**
* Vendor bills services.
@@ -72,6 +74,9 @@ export default class BillsService
@Inject()
vendorsService: VendorsService;
@Inject()
entriesService: EntriesService;
/**
* Validates whether the vendor is exist.
* @async
@@ -166,16 +171,33 @@ export default class BillsService
* Validate the bill number require.
* @param {string} billNo -
*/
validateBillNoRequire(billNo: string) {
private validateBillNoRequire(billNo: string) {
if (!billNo) {
throw new ServiceError(ERRORS.BILL_NO_IS_REQUIRED);
}
}
/**
* Validate bill transaction has no associated allocated landed cost transactions.
* @param {number} tenantId
* @param {number} billId
*/
private async validateBillHasNoLandedCost(tenantId: number, billId: number) {
const { BillLandedCost } = this.tenancy.models(tenantId);
const billLandedCosts = await BillLandedCost.query().where(
'billId',
billId
);
if (billLandedCosts.length > 0) {
throw new ServiceError(ERRORS.BILL_HAS_ASSOCIATED_LANDED_COSTS);
}
}
/**
* Sets the default cost account to the bill entries.
*/
setBillEntriesDefaultAccounts(tenantId: number) {
private setBillEntriesDefaultAccounts(tenantId: number) {
return async (entries: IItemEntry[]) => {
const { Item } = this.tenancy.models(tenantId);
@@ -246,6 +268,7 @@ export default class BillsService
billDTO.vendorId
);
const initialEntries = billDTO.entries.map((entry) => ({
amount: ItemEntry.calcAmount(entry),
reference_type: 'Bill',
...omit(entry, ['amount']),
}));
@@ -397,6 +420,16 @@ export default class BillsService
authorizedUser,
oldBill
);
// Validate landed cost entries that have allocated cost could not be deleted.
await this.entriesService.validateLandedCostEntriesNotDeleted(
oldBill.entries,
billObj.entries,
);
// Validate new landed cost entries should be bigger than new entries.
await this.entriesService.validateLocatedCostEntriesSmallerThanNewEntries(
oldBill.entries,
billObj.entries
);
// Update the bill transaction.
const bill = await billRepository.upsertGraph({
id: billId,
@@ -429,6 +462,9 @@ export default class BillsService
// Retrieve the given bill or throw not found error.
const oldBill = await this.getBillOrThrowError(tenantId, billId);
// Validate the givne bill has no associated landed cost transactions.
await this.validateBillHasNoLandedCost(tenantId, billId);
// Validate the purchase bill has no assocaited payments transactions.
await this.validateBillHasNoEntries(tenantId, billId);
@@ -561,9 +597,16 @@ export default class BillsService
*/
public async recordInventoryTransactions(
tenantId: number,
bill: IBill,
billId: number,
override?: boolean
): Promise<void> {
const { Bill } = this.tenancy.models(tenantId);
// Retireve bill with assocaited entries and allocated cost entries.
const bill = await Bill.query()
.findById(billId)
.withGraphFetched('entries.allocatedCostEntries');
// Loads the inventory items entries of the given sale invoice.
const inventoryEntries =
await this.itemsEntriesService.filterInventoryEntries(
@@ -573,7 +616,6 @@ export default class BillsService
const transaction = {
transactionId: bill.id,
transactionType: 'Bill',
date: bill.billDate,
direction: 'IN',
entries: inventoryEntries,
@@ -609,13 +651,30 @@ export default class BillsService
*/
public async recordJournalTransactions(
tenantId: number,
bill: IBill,
billId: number,
override: boolean = false
) {
const { Bill, Account } = this.tenancy.models(tenantId);
const journal = new JournalPoster(tenantId);
const journalCommands = new JournalCommands(journal);
await journalCommands.bill(bill, override);
const bill = await Bill.query()
.findById(billId)
.withGraphFetched('entries.item')
.withGraphFetched('entries.allocatedCostEntries')
.withGraphFetched('locatedLandedCosts.allocateEntries');
const payableAccount = await Account.query().findOne({
slug: 'accounts-payable',
});
// Overrides the bill journal entries.
if (override) {
await journalCommands.revertJournalEntries(billId, 'Bill');
}
// Writes the bill journal entries.
journalCommands.bill(bill, payableAccount);
return Promise.all([
journal.deleteEntries(),

View File

@@ -12,8 +12,8 @@ import {
export default class BillLandedCost {
/**
* Retrieve the landed cost transaction from the given bill transaction.
* @param {IBill} bill
* @returns {ILandedCostTransaction}
* @param {IBill} bill - Bill transaction.
* @returns {ILandedCostTransaction} - Landed cost transaction.
*/
public transformToLandedCost = (bill: IBill): ILandedCostTransaction => {
const number = bill.billNumber || bill.referenceNo;
@@ -49,7 +49,10 @@ export default class BillLandedCost {
name: billEntry.item.name,
code: billEntry.item.code,
amount: billEntry.amount,
unallocatedCostAmount: billEntry.unallocatedCostAmount,
allocatedCostAmount: billEntry.allocatedCostAmount,
description: billEntry.description,
costAccountId: billEntry.costAccountId || billEntry.item.costAccountId,
};
}
}

View File

@@ -48,6 +48,9 @@ export default class ExpenseLandedCost {
code: expenseEntry.expenseAccount.code,
amount: expenseEntry.amount,
description: expenseEntry.description,
allocatedCostAmount: expenseEntry.allocatedCostAmount,
unallocatedCostAmount: expenseEntry.unallocatedCostAmount,
costAccountId: expenseEntry.expenseAccount.id,
};
};
}

View File

@@ -1,11 +1,12 @@
import { Inject, Service } from 'typedi';
import * as R from 'ramda';
import { IBill, IExpense, ILandedCostTransaction } from 'interfaces';
import { Model } from 'objection';
import { IBill, IExpense, ILandedCostTransaction, ILandedCostTransactionEntry } from 'interfaces';
import { ServiceError } from 'exceptions';
import BillLandedCost from './BillLandedCost';
import ExpenseLandedCost from './ExpenseLandedCost';
import HasTenancyService from 'services/Tenancy/TenancyService';
import { ERRORS } from './constants';
import { ERRORS } from './utils';
@Service()
export default class TransactionLandedCost {
@@ -27,7 +28,7 @@ export default class TransactionLandedCost {
public getModel = (
tenantId: number,
transactionType: string
): IBill | IExpense => {
): Model => {
const Models = this.tenancy.models(tenantId);
const Model = Models[transactionType];
@@ -58,4 +59,26 @@ export default class TransactionLandedCost {
),
)(transaction);
}
/**
* Transformes the given expense or bill entry to landed cost transaction entry.
* @param {string} transactionType
* @param {} transactionEntry
* @returns {ILandedCostTransactionEntry}
*/
public transformToLandedCostEntry = (
transactionType: 'Bill' | 'Expense',
transactionEntry,
): ILandedCostTransactionEntry => {
return R.compose(
R.when(
R.always(transactionType === 'Bill'),
this.billLandedCost.transformToLandedCostEntry,
),
R.when(
R.always(transactionType === 'Expense'),
this.expenseLandedCost.transformToLandedCostEntry,
),
)(transactionEntry);
}
}

View File

@@ -1,15 +0,0 @@
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'
};

View File

@@ -1,5 +1,9 @@
import { Inject, Service } from 'typedi';
import { difference, sumBy } from 'lodash';
import {
EventDispatcher,
EventDispatcherInterface,
} from 'decorators/eventDispatcher';
import BillsService from '../Bills';
import { ServiceError } from 'exceptions';
import {
@@ -9,15 +13,14 @@ import {
ILandedCostItemDTO,
ILandedCostDTO,
IBillLandedCostTransaction,
IBillLandedCostTransactionEntry,
ILandedCostTransaction,
ILandedCostTransactionEntry,
} from 'interfaces';
import events from 'subscribers/events';
import InventoryService from 'services/Inventory/Inventory';
import HasTenancyService from 'services/Tenancy/TenancyService';
import { ERRORS } from './constants';
import { transformToMap } from 'utils';
import JournalPoster from 'services/Accounting/JournalPoster';
import JournalEntry from 'services/Accounting/JournalEntry';
import TransactionLandedCost from './TransctionLandedCost';
import { ERRORS, mergeLocatedWithBillEntries } from './utils';
const CONFIG = {
COST_TYPES: {
@@ -47,6 +50,9 @@ export default class AllocateLandedCostService {
@Inject()
public transactionLandedCost: TransactionLandedCost;
@EventDispatcher()
eventDispatcher: EventDispatcherInterface;
/**
* Validates allocate cost items association with the purchase invoice entries.
* @param {IItemEntry[]} purchaseInvoiceEntries
@@ -72,23 +78,23 @@ export default class AllocateLandedCostService {
};
/**
* Saves the bill landed cost model.
* @param {number} tenantId
* @param {ILandedCostDTO} landedCostDTO
* @param {number} purchaseInvoiceId
* @returns {Promise<void>}
* Transformes DTO to bill landed cost model object.
* @param landedCostDTO
* @param bill
* @param costTransaction
* @param costTransactionEntry
* @returns
*/
private saveBillLandedCostModel = (
tenantId: number,
private transformToBillLandedCost(
landedCostDTO: ILandedCostDTO,
purchaseInvoiceId: number
): Promise<IBillLandedCost> => {
const { BillLandedCost } = this.tenancy.models(tenantId);
bill: IBill,
costTransaction: ILandedCostTransaction,
costTransactionEntry: ILandedCostTransactionEntry
) {
const amount = sumBy(landedCostDTO.items, 'cost');
// Inserts the bill landed cost to the storage.
return BillLandedCost.query().insertGraph({
billId: purchaseInvoiceId,
return {
billId: bill.id,
fromTransactionType: landedCostDTO.transactionType,
fromTransactionId: landedCostDTO.transactionId,
fromTransactionEntryId: landedCostDTO.transactionEntryId,
@@ -96,8 +102,9 @@ export default class AllocateLandedCostService {
allocationMethod: landedCostDTO.allocationMethod,
description: landedCostDTO.description,
allocateEntries: landedCostDTO.items,
});
};
costAccountId: costTransactionEntry.costAccountId,
};
}
/**
* Allocate the landed cost amount to cost transactions.
@@ -147,7 +154,6 @@ export default class AllocateLandedCostService {
tenantId,
transactionType
);
// Decrement the allocate cost amount of cost transaction.
return Model.query()
.where('id', transactionId)
@@ -202,12 +208,22 @@ export default class AllocateLandedCostService {
const entry = await Model.relatedQuery(relation)
.for(transactionId)
.findOne('id', transactionEntryId)
.where('landedCost', true);
.where('landedCost', true)
.onBuild((q) => {
if (transactionType === 'Bill') {
q.withGraphFetched('item');
} else if (transactionType === 'Expense') {
q.withGraphFetched('expenseAccount');
}
});
if (!entry) {
throw new ServiceError(ERRORS.LANDED_COST_ENTRY_NOT_FOUND);
}
return entry;
return this.transactionLandedCost.transformToLandedCostEntry(
transactionType,
entry
);
};
/**
@@ -230,31 +246,11 @@ export default class AllocateLandedCostService {
unallocatedCost: number,
amount: number
): void => {
console.log(unallocatedCost, amount, '123');
if (unallocatedCost < amount) {
throw new ServiceError(ERRORS.COST_AMOUNT_BIGGER_THAN_UNALLOCATED_AMOUNT);
}
};
/**
* Merges item entry to bill located landed cost entry.
* @param {IBillLandedCostTransactionEntry[]} locatedEntries -
* @param {IItemEntry[]} billEntries -
* @returns {(IBillLandedCostTransactionEntry & { entry: IItemEntry })[]}
*/
private mergeLocatedWithBillEntries = (
locatedEntries: IBillLandedCostTransactionEntry[],
billEntries: IItemEntry[]
): (IBillLandedCostTransactionEntry & { entry: IItemEntry })[] => {
const billEntriesByEntryId = transformToMap(billEntries, 'id');
return locatedEntries.map((entry) => ({
...entry,
entry: billEntriesByEntryId.get(entry.entryId),
}));
};
/**
* Records inventory transactions.
* @param {number} tenantId
@@ -266,7 +262,7 @@ export default class AllocateLandedCostService {
bill: IBill
) => {
// Retrieve the merged allocated entries with bill entries.
const allocateEntries = this.mergeLocatedWithBillEntries(
const allocateEntries = mergeLocatedWithBillEntries(
billLandedCost.allocateEntries,
bill.entries
);
@@ -304,22 +300,24 @@ export default class AllocateLandedCostService {
*
* @param {ILandedCostDTO} landedCostDTO - Landed cost DTO.
* @param {number} tenantId - Tenant id.
* @param {number} purchaseInvoiceId - Purchase invoice id.
* @param {number} billId - Purchase invoice id.
*/
public allocateLandedCost = async (
tenantId: number,
allocateCostDTO: ILandedCostDTO,
purchaseInvoiceId: number
billId: number
): Promise<{
billLandedCost: IBillLandedCost;
}> => {
const { BillLandedCost } = this.tenancy.models(tenantId);
// 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(
const bill = await this.billsService.getBillOrThrowError(
tenantId,
purchaseInvoiceId
billId
);
// Retrieve landed cost transaction or throw not found service error.
const landedCostTransaction = await this.getLandedCostOrThrowError(
@@ -336,25 +334,36 @@ export default class AllocateLandedCostService {
);
// Validates allocate cost items association with the purchase invoice entries.
this.validateAllocateCostItems(
purchaseInvoice.entries,
bill.entries,
allocateCostDTO.items
);
// Validate the amount of cost with unallocated landed cost.
this.validateLandedCostEntryAmount(
landedCostEntry.unallocatedLandedCost,
landedCostEntry.unallocatedCostAmount,
amount
);
// Save the bill landed cost model.
const billLandedCost = await this.saveBillLandedCostModel(
tenantId,
// Transformes DTO to bill landed cost model object.
const billLandedCostObj = this.transformToBillLandedCost(
allocateCostDTO,
purchaseInvoiceId
bill,
landedCostTransaction,
landedCostEntry
);
// Save the bill landed cost model.
const billLandedCost = await BillLandedCost.query().insertGraph(
billLandedCostObj
);
// Triggers the event `onBillLandedCostCreated`.
await this.eventDispatcher.dispatch(events.billLandedCost.onCreated, {
tenantId,
billId,
billLandedCostId: billLandedCost.id,
});
// Records the inventory transactions.
await this.recordInventoryTransactions(
tenantId,
billLandedCost,
purchaseInvoice
bill
);
// Increment landed cost amount on transaction and entry.
await this.incrementLandedCostAmount(
@@ -364,55 +373,9 @@ export default class AllocateLandedCostService {
allocateCostDTO.transactionEntryId,
amount
);
// Write the landed cost journal entries.
// await this.writeJournalEntry(tenantId, billLandedCost, purchaseInvoice);
return { billLandedCost };
};
/**
* Write journal entries of the given purchase invoice landed cost.
* @param tenantId
* @param purchaseInvoice
* @param landedCost
*/
private writeJournalEntry = async (
tenantId: number,
landedCostEntry: any,
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.
@@ -422,7 +385,7 @@ export default class AllocateLandedCostService {
public getBillLandedCostOrThrowError = async (
tenantId: number,
landedCostId: number
): Promise<IBillLandedCost> => {
): Promise<IBillLandedCostTransaction> => {
const { BillLandedCost } = this.tenancy.models(tenantId);
// Retrieve the bill landed cost model.
@@ -462,7 +425,7 @@ export default class AllocateLandedCostService {
* - 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<void>}
@@ -481,6 +444,12 @@ export default class AllocateLandedCostService {
// Delete landed cost transaction with assocaited locate entries.
await this.deleteLandedCost(tenantId, landedCostId);
// Triggers the event `onBillLandedCostCreated`.
await this.eventDispatcher.dispatch(events.billLandedCost.onDeleted, {
tenantId,
billLandedCostId: oldBillLandedCost.id,
billId: oldBillLandedCost.billId,
});
// Removes the inventory transactions.
await this.removeInventoryTransactions(tenantId, landedCostId);

View File

@@ -0,0 +1,34 @@
import { IItemEntry, IBillLandedCostTransactionEntry } from 'interfaces';
import { transformToMap } from 'utils';
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',
};
/**
* Merges item entry to bill located landed cost entry.
* @param {IBillLandedCostTransactionEntry[]} locatedEntries -
* @param {IItemEntry[]} billEntries -
* @returns {(IBillLandedCostTransactionEntry & { entry: IItemEntry })[]}
*/
export const mergeLocatedWithBillEntries = (
locatedEntries: IBillLandedCostTransactionEntry[],
billEntries: IItemEntry[]
): (IBillLandedCostTransactionEntry & { entry: IItemEntry })[] => {
const billEntriesByEntryId = transformToMap(billEntries, 'id');
return locatedEntries.map((entry) => ({
...entry,
entry: billEntriesByEntryId.get(entry.entryId),
}));
};

View File

@@ -9,5 +9,8 @@ export const ERRORS = {
BILL_ALREADY_OPEN: 'BILL_ALREADY_OPEN',
BILL_NO_IS_REQUIRED: 'BILL_NO_IS_REQUIRED',
BILL_HAS_ASSOCIATED_PAYMENT_ENTRIES: 'BILL_HAS_ASSOCIATED_PAYMENT_ENTRIES',
VENDOR_HAS_BILLS: 'VENDOR_HAS_BILLS'
VENDOR_HAS_BILLS: 'VENDOR_HAS_BILLS',
BILL_HAS_ASSOCIATED_LANDED_COSTS: 'BILL_HAS_ASSOCIATED_LANDED_COSTS',
BILL_ENTRIES_ALLOCATED_COST_COULD_DELETED: 'BILL_ENTRIES_ALLOCATED_COST_COULD_DELETED',
LOCATED_COST_ENTRIES_SHOULD_BIGGE_THAN_NEW_ENTRIES: 'LOCATED_COST_ENTRIES_SHOULD_BIGGE_THAN_NEW_ENTRIES'
};

View File

@@ -23,20 +23,20 @@ export default class BillSubscriber {
* Handles writing journal entries once bill created.
*/
@On(events.bill.onCreated)
async handlerWriteJournalEntriesOnCreate({ tenantId, bill }) {
async handlerWriteJournalEntriesOnCreate({ tenantId, billId }) {
// Writes the journal entries for the given bill transaction.
this.logger.info('[bill] writing bill journal entries.', { tenantId });
await this.billsService.recordJournalTransactions(tenantId, bill);
await this.billsService.recordJournalTransactions(tenantId, billId);
}
/**
* Handles the overwriting journal entries once bill edited.
*/
@On(events.bill.onEdited)
async handleOverwriteJournalEntriesOnEdit({ tenantId, bill }) {
async handleOverwriteJournalEntriesOnEdit({ tenantId, billId }) {
// Overwrite the journal entries for the given bill transaction.
this.logger.info('[bill] overwriting bill journal entries.', { tenantId });
await this.billsService.recordJournalTransactions(tenantId, bill, true);
await this.billsService.recordJournalTransactions(tenantId, billId, true);
}
/**

View File

@@ -0,0 +1,37 @@
import { Container } from 'typedi';
import { On, EventSubscriber } from 'event-dispatch';
import events from 'subscribers/events';
import TenancyService from 'services/Tenancy/TenancyService';
import BillsService from 'services/Purchases/Bills';
@EventSubscriber()
export default class BillLandedCostSubscriber {
logger: any;
tenancy: TenancyService;
billsService: BillsService;
/**
* Constructor method.
*/
constructor() {
this.logger = Container.get('logger');
this.tenancy = Container.get(TenancyService);
this.billsService = Container.get(BillsService);
}
/**
* Marks the rewrite bill journal entries once the landed cost transaction
* be deleted or created.
*/
@On(events.billLandedCost.onCreated)
@On(events.billLandedCost.onDeleted)
public async handleRewriteBillJournalEntries({
tenantId,
billId,
bilLandedCostId,
}) {
// Overwrite the journal entries for the given bill transaction.
this.logger.info('[bill] overwriting bill journal entries.', { tenantId });
await this.billsService.recordJournalTransactions(tenantId, billId, true);
}
}