feat(server): wip sale invoice tax rates

This commit is contained in:
Ahmed Bouhuolia
2023-08-29 19:12:19 +02:00
parent 09d73db20f
commit 6535424d0f
15 changed files with 219 additions and 49 deletions

View File

@@ -764,6 +764,11 @@ export default class SaleInvoicesController extends BaseController {
errors: [{ type: 'ITEM_ENTRY_TAX_RATE_CODE_NOT_FOUND', code: 5000 }], errors: [{ type: 'ITEM_ENTRY_TAX_RATE_CODE_NOT_FOUND', code: 5000 }],
}); });
} }
if (error.errorType === 'ITEM_ENTRY_TAX_RATE_ID_NOT_FOUND') {
return res.boom.badRequest(null, {
errors: [{ type: 'ITEM_ENTRY_TAX_RATE_ID_NOT_FOUND', code: 5100 }],
});
}
} }
next(error); next(error);
} }

View File

@@ -12,6 +12,11 @@ exports.up = (knex) => {
}) })
.table('items_entries', (table) => { .table('items_entries', (table) => {
table.boolean('is_tax_exclusive'); table.boolean('is_tax_exclusive');
table
.integer('tax_rate_id')
.unsigned()
.references('id')
.inTable('tax_rates');
table.string('tax_code'); table.string('tax_code');
table.decimal('tax_rate'); table.decimal('tax_rate');
}) })
@@ -21,13 +26,13 @@ exports.up = (knex) => {
}) })
.createTable('tax_rate_transactions', (table) => { .createTable('tax_rate_transactions', (table) => {
table.increments('id'); table.increments('id');
table
table.string('tax_name'); .integer('tax_rate_id')
table.string('tax_code'); .unsigned()
.references('id')
.inTable('tax_rates');
table.string('reference_type'); table.string('reference_type');
table.integer('reference_id'); table.integer('reference_id');
table.decimal('tax_amount'); table.decimal('tax_amount');
table.integer('tax_account_id').unsigned(); table.integer('tax_account_id').unsigned();
}); });

View File

@@ -32,6 +32,7 @@ export interface IItemEntry {
projectRefType?: ProjectLinkRefType; projectRefType?: ProjectLinkRefType;
projectRefInvoicedAmount?: number; projectRefInvoicedAmount?: number;
taxRateId: number | null;
taxCode: string; taxCode: string;
taxRate: number; taxRate: number;
taxAmount: number; taxAmount: number;
@@ -51,8 +52,9 @@ export interface IItemEntryDTO {
projectRefType?: ProjectLinkRefType; projectRefType?: ProjectLinkRefType;
projectRefInvoicedAmount?: number; projectRefInvoicedAmount?: number;
taxCode: string; taxCodeId?: number;
taxRate: number; taxCode?: string;
taxRate?: number;
} }
export enum ProjectLinkRefType { export enum ProjectLinkRefType {

View File

@@ -48,9 +48,11 @@ export interface ITaxRateDeletedPayload {
trx: Knex.Transaction; trx: Knex.Transaction;
} }
export interface ITaxTransaction { export interface ITaxTransaction {
id?: number;
taxRateId: number;
referenceType: string;
referenceId: number;
taxAmount: number; taxAmount: number;
taxName: string; taxAccountId: number;
taxCode: string; }
}

View File

@@ -37,6 +37,20 @@ export default class TaxRateTransaction extends mixin(TenantModel, [
* Relationship mapping. * Relationship mapping.
*/ */
static get relationMappings() { static get relationMappings() {
return {}; const TaxRate = require('models/TaxRate');
return {
/**
* Belongs to the tax rate.
*/
taxRate: {
relation: Model.BelongsToOneRelation,
modelClass: TaxRate.default,
join: {
from: 'tax_rate_transactions.taxRateId',
to: 'tax_rates.id',
},
},
};
} }
} }

View File

@@ -75,6 +75,8 @@ export class CommandSaleInvoiceDTOTransformer {
...entry, ...entry,
})); }));
const entries = await composeAsync( const entries = await composeAsync(
// Associate tax rate id from tax code to entries.
this.taxDTOTransformer.assocTaxRateIdFromCodeToEntries(tenantId),
// Sets default cost and sell account to invoice items entries. // Sets default cost and sell account to invoice items entries.
this.itemsEntriesService.setItemsEntriesDefaultAccounts(tenantId) this.itemsEntriesService.setItemsEntriesDefaultAccounts(tenantId)
)(initialEntries); )(initialEntries);

View File

@@ -33,7 +33,8 @@ export class GetSaleInvoice {
.findById(saleInvoiceId) .findById(saleInvoiceId)
.withGraphFetched('entries.item') .withGraphFetched('entries.item')
.withGraphFetched('customer') .withGraphFetched('customer')
.withGraphFetched('branch'); .withGraphFetched('branch')
.withGraphFetched('taxes.taxRate');
// Validates the given sale invoice existance. // Validates the given sale invoice existance.
this.validators.validateInvoiceExistance(saleInvoice); this.validators.validateInvoiceExistance(saleInvoice);

View File

@@ -0,0 +1,46 @@
import { Transformer } from '@/lib/Transformer/Transformer';
export class SaleInvoiceTaxEntryTransformer extends Transformer {
/**
* Included attributes.
* @returns {Array}
*/
public includeAttributes = (): string[] => {
return ['name', 'taxRateCode', 'raxRate', 'taxRateId'];
};
/**
* Exclude attributes.
* @returns {string[]}
*/
public excludeAttributes = (): string[] => {
return ['*'];
};
/**
* Retrieve tax rate code.
* @param taxEntry
* @returns {string}
*/
protected taxRateCode = (taxEntry) => {
return taxEntry.taxRate.code;
};
/**
* Retrieve tax rate id.
* @param taxEntry
* @returns {number}
*/
protected raxRate = (taxEntry) => {
return taxEntry.taxAmount || taxEntry.taxRate.rate;
};
/**
* Retrieve tax rate name.
* @param taxEntry
* @returns {string}
*/
protected name = (taxEntry) => {
return taxEntry.taxRate.name;
};
}

View File

@@ -1,5 +1,6 @@
import { Transformer } from '@/lib/Transformer/Transformer'; import { Transformer } from '@/lib/Transformer/Transformer';
import { formatNumber } from 'utils'; import { formatNumber } from 'utils';
import { SaleInvoiceTaxEntryTransformer } from './SaleInvoiceTaxEntryTransformer';
export class SaleInvoiceTransformer extends Transformer { export class SaleInvoiceTransformer extends Transformer {
/** /**
@@ -15,6 +16,7 @@ export class SaleInvoiceTransformer extends Transformer {
'formattedPaymentAmount', 'formattedPaymentAmount',
'formattedBalanceAmount', 'formattedBalanceAmount',
'formattedExchangeRate', 'formattedExchangeRate',
'taxes',
]; ];
}; };
@@ -88,4 +90,12 @@ export class SaleInvoiceTransformer extends Transformer {
protected formattedExchangeRate = (invoice): string => { protected formattedExchangeRate = (invoice): string => {
return formatNumber(invoice.exchangeRate, { money: false }); return formatNumber(invoice.exchangeRate, { money: false });
}; };
/**
* Retrieve the taxes lines of sale invoice.
* @param {ISaleInvoice} invoice
*/
protected taxes = (invoice) => {
return this.item(invoice.taxes, new SaleInvoiceTaxEntryTransformer());
};
} }

View File

@@ -45,9 +45,13 @@ export class CommandTaxRatesValidators {
itemEntriesDTO: IItemEntryDTO[] itemEntriesDTO: IItemEntryDTO[]
) { ) {
const { TaxRate } = this.tenancy.models(tenantId); const { TaxRate } = this.tenancy.models(tenantId);
const filteredTaxEntries = itemEntriesDTO.filter((e) => e.taxCode); const filteredTaxEntries = itemEntriesDTO.filter((e) => e.taxCode);
const taxCodes = filteredTaxEntries.map((e) => e.taxCode); const taxCodes = filteredTaxEntries.map((e) => e.taxCode);
// Can't validate if there is no tax codes.
if (taxCodes.length === 0) return;
const foundTaxCodes = await TaxRate.query().whereIn('code', taxCodes); const foundTaxCodes = await TaxRate.query().whereIn('code', taxCodes);
const foundCodes = foundTaxCodes.map((tax) => tax.code); const foundCodes = foundTaxCodes.map((tax) => tax.code);
@@ -57,4 +61,31 @@ export class CommandTaxRatesValidators {
throw new ServiceError(ERRORS.ITEM_ENTRY_TAX_RATE_CODE_NOT_FOUND); throw new ServiceError(ERRORS.ITEM_ENTRY_TAX_RATE_CODE_NOT_FOUND);
} }
} }
/**
* Validates the tax rate id of the given item entries DTO.
* @param {number} tenantId
* @param {IItemEntryDTO[]} itemEntriesDTO
* @throws {ServiceError}
*/
public async validateItemEntriesTaxCodeId(
tenantId: number,
itemEntriesDTO: IItemEntryDTO[]
) {
const filteredTaxEntries = itemEntriesDTO.filter((e) => e.taxCodeId);
const taxCodes = filteredTaxEntries.map((e) => e.taxCodeId);
// Can't validate if there is no tax codes.
if (taxCodes.length === 0) return;
const { TaxRate } = this.tenancy.models(tenantId);
const foundTaxCodes = await TaxRate.query().whereIn('id', taxCodes);
const foundCodes = foundTaxCodes.map((tax) => tax.id);
const notFoundTaxCodes = difference(taxCodes, foundCodes);
if (notFoundTaxCodes.length > 0) {
throw new ServiceError(ERRORS.ITEM_ENTRY_TAX_RATE_ID_NOT_FOUND);
}
}
} }

View File

@@ -1,14 +1,18 @@
import { ItemEntry } from "@/models"; import { Inject, Service } from 'typedi';
import { sumBy } from "lodash"; import { keyBy, sumBy } from 'lodash';
import { Service } from "typedi"; import * as R from 'ramda';
import { ItemEntry } from '@/models';
import HasTenancyService from '../Tenancy/TenancyService';
@Service() @Service()
export class ItemEntriesTaxTransactions { export class ItemEntriesTaxTransactions {
@Inject()
private tenancy: HasTenancyService;
/** /**
* * Associates tax amount withheld to the model.
* @param model * @param model
* @returns * @returns
*/ */
public assocTaxAmountWithheldFromEntries(model: any) { public assocTaxAmountWithheldFromEntries(model: any) {
const entries = model.entries.map((entry) => ItemEntry.fromJson(entry)); const entries = model.entries.map((entry) => ItemEntry.fromJson(entry));
@@ -19,4 +23,27 @@ export class ItemEntriesTaxTransactions {
} }
return model; return model;
} }
}
/**
* Associates tax rate id from tax code to entries.
* @param {number} tenantId
* @param {} model
*/
public assocTaxRateIdFromCodeToEntries =
(tenantId: number) => async (entries: any) => {
const entriesWithCode = entries.filter((entry) => entry.taxCode);
const taxCodes = entriesWithCode.map((entry) => entry.taxCode);
const { TaxRate } = this.tenancy.models(tenantId);
const foundTaxCodes = await TaxRate.query().whereIn('code', taxCodes);
const taxCodesMap = keyBy(foundTaxCodes, 'code');
return entries.map((entry) => {
if (entry.taxCode) {
entry.taxRateId = taxCodesMap[entry.taxCode]?.id;
}
return entry;
});
};
}

View File

@@ -1,5 +1,5 @@
import { sumBy, chain } from 'lodash'; import { sumBy, chain, keyBy } from 'lodash';
import { IItemEntry } from '@/interfaces'; import { IItemEntry, ITaxTransaction } from '@/interfaces';
import HasTenancyService from '../Tenancy/TenancyService'; import HasTenancyService from '../Tenancy/TenancyService';
import { Inject, Service } from 'typedi'; import { Inject, Service } from 'typedi';
@@ -17,49 +17,54 @@ export class WriteTaxTransactionsItemEntries {
tenantId: number, tenantId: number,
itemEntries: IItemEntry[] itemEntries: IItemEntry[]
) { ) {
const { TaxRateTransaction } = this.tenancy.models(tenantId); const { TaxRateTransaction, TaxRate } = this.tenancy.models(tenantId);
const aggregatedEntries = this.aggregateItemEntriesByTaxCode(itemEntries); const aggregatedEntries = this.aggregateItemEntriesByTaxCode(itemEntries);
const entriesTaxRateIds = aggregatedEntries.map((entry) => entry.taxRateId);
const taxRates = await TaxRate.query().whereIn('id', entriesTaxRateIds);
const taxRatesById = keyBy(taxRates, 'id');
const taxTransactions = aggregatedEntries.map((entry) => ({ const taxTransactions = aggregatedEntries.map((entry) => ({
taxName: 'TAX NAME', taxRateId: entry.taxRateId,
taxCode: 'TAG_CODE',
referenceType: entry.referenceType, referenceType: entry.referenceType,
referenceId: entry.referenceId, referenceId: entry.referenceId,
taxAmount: entry.taxAmount, taxAmount: entry.taxRate || taxRatesById[entry.taxRateId]?.rate,
})); })) as ITaxTransaction[];
await TaxRateTransaction.query().upsertGraph(taxTransactions); await TaxRateTransaction.query().upsertGraph(taxTransactions);
} }
/** /**
* * Aggregates by tax code id and sums the amount.
* @param {IItemEntry[]} itemEntries * @param {IItemEntry[]} itemEntries
* @returns {} * @returns {IItemEntry[]}
*/ */
private aggregateItemEntriesByTaxCode(itemEntries: IItemEntry[]) { private aggregateItemEntriesByTaxCode = (
return chain(itemEntries.filter((item) => item.taxCode)) itemEntries: IItemEntry[]
.groupBy((item) => item.taxCode) ): IItemEntry[] => {
return chain(itemEntries.filter((item) => item.taxRateId))
.groupBy((item) => item.taxRateId)
.values() .values()
.map((group) => ({ ...group[0], amount: sumBy(group, 'amount') })) .map((group) => ({ ...group[0], amount: sumBy(group, 'amount') }))
.value(); .value();
} };
/**
*
* @param itemEntries
*/
private aggregateItemEntriesByReferenceTypeId(itemEntries: IItemEntry) {}
/** /**
* Removes the tax transactions from the given item entries. * Removes the tax transactions from the given item entries.
* @param tenantId * @param {number} tenantId - Tenant id.
* @param itemEntries * @param {string} referenceType - Reference type.
* @param {number} referenceId - Reference id.
*/ */
public removeTaxTransactionsFromItemEntries( public async removeTaxTransactionsFromItemEntries(
tenantId: number, tenantId: number,
itemEntries: IItemEntry[] referenceId: number,
referenceType: string
) { ) {
const { TaxRateTransaction } = this.tenancy.models(tenantId); const { TaxRateTransaction } = this.tenancy.models(tenantId);
const filteredEntries = itemEntries.filter((item) => item.taxCode); await TaxRateTransaction.query()
.where({ referenceType, referenceId })
.delete();
} }
} }

View File

@@ -2,4 +2,5 @@ export const ERRORS = {
TAX_RATE_NOT_FOUND: 'TAX_RATE_NOT_FOUND', TAX_RATE_NOT_FOUND: 'TAX_RATE_NOT_FOUND',
TAX_CODE_NOT_UNIQUE: 'TAX_CODE_NOT_UNIQUE', TAX_CODE_NOT_UNIQUE: 'TAX_CODE_NOT_UNIQUE',
ITEM_ENTRY_TAX_RATE_CODE_NOT_FOUND: 'ITEM_ENTRY_TAX_RATE_CODE_NOT_FOUND', ITEM_ENTRY_TAX_RATE_CODE_NOT_FOUND: 'ITEM_ENTRY_TAX_RATE_CODE_NOT_FOUND',
ITEM_ENTRY_TAX_RATE_ID_NOT_FOUND: 'ITEM_ENTRY_TAX_RATE_ID_NOT_FOUND',
}; };

View File

@@ -19,6 +19,10 @@ export class SaleInvoiceTaxRateValidateSubscriber {
events.saleInvoice.onCreating, events.saleInvoice.onCreating,
this.validateSaleInvoiceEntriesTaxCodeExistanceOnCreating this.validateSaleInvoiceEntriesTaxCodeExistanceOnCreating
); );
bus.subscribe(
events.saleInvoice.onCreating,
this.validateSaleInvoiceEntriesTaxIdExistanceOnCreating
);
bus.subscribe( bus.subscribe(
events.saleInvoice.onEditing, events.saleInvoice.onEditing,
this.validateSaleInvoiceEntriesTaxCodeExistanceOnEditing this.validateSaleInvoiceEntriesTaxCodeExistanceOnEditing
@@ -27,7 +31,7 @@ export class SaleInvoiceTaxRateValidateSubscriber {
} }
/** /**
* Validate invoice entries tax rate code existance. * Validate invoice entries tax rate code existance when creating.
* @param {ISaleInvoiceCreatingPaylaod} * @param {ISaleInvoiceCreatingPaylaod}
*/ */
private validateSaleInvoiceEntriesTaxCodeExistanceOnCreating = async ({ private validateSaleInvoiceEntriesTaxCodeExistanceOnCreating = async ({
@@ -41,7 +45,21 @@ export class SaleInvoiceTaxRateValidateSubscriber {
}; };
/** /**
* * Validate the tax rate id existance when creating.
* @param {ISaleInvoiceCreatingPaylaod}
*/
private validateSaleInvoiceEntriesTaxIdExistanceOnCreating = async ({
saleInvoiceDTO,
tenantId,
}: ISaleInvoiceCreatingPaylaod) => {
await this.taxRateDTOValidator.validateItemEntriesTaxCodeId(
tenantId,
saleInvoiceDTO.entries
);
};
/**
* Validate invoice entries tax rate code existance when editing.
* @param {ISaleInvoiceEditingPayload} * @param {ISaleInvoiceEditingPayload}
*/ */
private validateSaleInvoiceEntriesTaxCodeExistanceOnEditing = async ({ private validateSaleInvoiceEntriesTaxCodeExistanceOnEditing = async ({

View File

@@ -20,7 +20,7 @@ export class WriteInvoiceTaxTransactionsSubscriber {
this.writeInvoiceTaxTransactionsOnCreated this.writeInvoiceTaxTransactionsOnCreated
); );
bus.subscribe( bus.subscribe(
events.saleInvoice.onDeleted, events.saleInvoice.onDelete,
this.removeInvoiceTaxTransactionsOnDeleted this.removeInvoiceTaxTransactionsOnDeleted
); );
return bus; return bus;
@@ -50,7 +50,8 @@ export class WriteInvoiceTaxTransactionsSubscriber {
}: ISaleInvoiceDeletedPayload) => { }: ISaleInvoiceDeletedPayload) => {
await this.writeTaxTransactions.removeTaxTransactionsFromItemEntries( await this.writeTaxTransactions.removeTaxTransactionsFromItemEntries(
tenantId, tenantId,
oldSaleInvoice.entries oldSaleInvoice.id,
'SaleInvoice'
); );
}; };
} }