feat(server): wip tax rate on sale invoice service

This commit is contained in:
Ahmed Bouhuolia
2023-08-14 14:59:10 +02:00
parent a7644e6481
commit d1121f0b81
18 changed files with 514 additions and 74 deletions

View File

@@ -11,14 +11,25 @@ exports.up = (knex) => {
table.timestamps();
})
.table('items_entries', (table) => {
table.boolean(['is_tax_exclusive']);
table.boolean('is_tax_exclusive');
table.string('tax_code');
table.decimal('tax_rate');
table.decimal('tax_amount_withheld')
})
.table('sales_invoices', (table) => {
table.boolean(['is_tax_exclusive']);
table.decimal('tax_amount_withheld')
table.boolean('is_tax_exclusive');
table.decimal('tax_amount_withheld');
})
.createTable('tax_rate_transactions', (table) => {
table.increments('id');
table.string('tax_name');
table.string('tax_code');
table.string('reference_type');
table.integer('reference_id');
table.decimal('tax_amount');
table.integer('tax_account_id').unsigned();
});
};

View File

@@ -1,3 +1,13 @@
export const TaxPayableAccount = {
name: 'Tax Payable',
slug: 'tax-payable',
account_type: 'other-current-liability',
code: '20006',
description: '',
active: 1,
index: 1,
predefined: 1,
};
export default [
{
@@ -81,7 +91,8 @@ export default [
parent_account_id: null,
index: 1,
active: 1,
description:'An account that holds valuation of products or goods that availiable for sale.',
description:
'An account that holds valuation of products or goods that availiable for sale.',
},
// Libilities
@@ -122,7 +133,8 @@ export default [
slug: 'opening-balance-liabilities',
account_type: 'other-current-liability',
code: '20004',
description:'This account will hold the difference in the debits and credits entered during the opening balance..',
description:
'This account will hold the difference in the debits and credits entered during the opening balance..',
active: 1,
index: 1,
predefined: 0,
@@ -138,16 +150,7 @@ export default [
index: 1,
predefined: 0,
},
{
name:'Sales Tax Payable',
slug: 'owner-drawings',
account_type: 'other-current-liability',
code: '20006',
description: '',
active: 1,
index: 1,
predefined: 1,
},
TaxPayableAccount,
// Equity
{
@@ -155,7 +158,8 @@ export default [
slug: 'retained-earnings',
account_type: 'equity',
code: '30001',
description:'Retained earnings tracks net income from previous fiscal years.',
description:
'Retained earnings tracks net income from previous fiscal years.',
active: 1,
index: 1,
predefined: 1,
@@ -165,7 +169,8 @@ export default [
slug: 'opening-balance-equity',
account_type: 'equity',
code: '30002',
description:'When you enter opening balances to the accounts, the amounts enter in Opening balance equity. This ensures that you have a correct trial balance sheet for your company, without even specific the second credit or debit entry.',
description:
'When you enter opening balances to the accounts, the amounts enter in Opening balance equity. This ensures that you have a correct trial balance sheet for your company, without even specific the second credit or debit entry.',
active: 1,
index: 1,
predefined: 1,
@@ -185,7 +190,8 @@ export default [
slug: 'drawings',
account_type: 'equity',
code: '30003',
description:'Goods purchased with the intention of selling these to customers',
description:
'Goods purchased with the intention of selling these to customers',
active: 1,
index: 1,
predefined: 1,
@@ -253,7 +259,8 @@ export default [
account_type: 'expense',
parent_account_id: null,
code: '40006',
description: 'Any bank fees levied is recorded into the bank fees and charges account. A bank account maintenance fee, transaction charges, a late payment fee are some examples.',
description:
'Any bank fees levied is recorded into the bank fees and charges account. A bank account maintenance fee, transaction charges, a late payment fee are some examples.',
active: 1,
index: 1,
predefined: 0,
@@ -310,9 +317,10 @@ export default [
account_type: 'other-income',
parent_account_id: null,
code: '50004',
description:'The income activities are not associated to the core business.',
description:
'The income activities are not associated to the core business.',
active: 1,
index: 1,
predefined: 0,
}
},
];

View File

@@ -54,6 +54,14 @@ export interface IItemDTO {
sellDescription: string;
purchaseDescription: string;
// Used as an override if the default Tax Code for the selected `costAccountId` is not correct
purchaseTaxCode: string;
purchaseTaxId: string;
// Used as an override if the default Tax Code for the selected `sellAccountId` is not correct
saleTaxCode: string;
saleTaxId: string;
quantityOnHand: number;
note: string;

View File

@@ -34,6 +34,7 @@ export interface IItemEntry {
taxCode: string;
taxRate: number;
taxAmount: number;
item?: IItem;

View File

@@ -1,5 +1,5 @@
import { Knex } from 'knex';
import { IItemEntry } from './ItemEntry';
import { IItemEntry, IItemEntryDTO } from './ItemEntry';
import { IDynamicListFilterDTO } from '@/interfaces/DynamicFilter';
export interface ISaleEstimate {
@@ -29,7 +29,7 @@ export interface ISaleEstimateDTO {
estimateDate?: Date;
reference?: string;
estimateNumber?: string;
entries: IItemEntry[];
entries: IItemEntryDTO[];
note: string;
termsConditions: string;
sendToEmail: string;

View File

@@ -1,5 +1,5 @@
import { Knex } from 'knex';
import { ISystemUser, IAccount } from '@/interfaces';
import { ISystemUser, IAccount, ITaxTransaction } from '@/interfaces';
import { IDynamicListFilter } from '@/interfaces/DynamicFilter';
import { IItemEntry, IItemEntryDTO } from './ItemEntry';
@@ -33,6 +33,9 @@ export interface ISaleInvoice {
writtenoffExpenseAccountId?: number;
writtenoffExpenseAccount?: IAccount;
taxAmountWithheld: number;
taxes: ITaxTransaction[]
}
export interface ISaleInvoiceDTO {

View File

@@ -47,3 +47,10 @@ export interface ITaxRateDeletedPayload {
tenantId: number;
trx: Knex.Transaction;
}
export interface ITaxTransaction {
taxAmount: number;
taxName: string;
taxCode: string;
}

View File

@@ -81,6 +81,9 @@ import { ProjectBillableExpensesSubscriber } from '@/services/Projects/Projects/
import { ProjectBillableBillSubscriber } from '@/services/Projects/Projects/ProjectBillableBillSubscriber';
import { SyncActualTimeTaskSubscriber } from '@/services/Projects/Times/SyncActualTimeTaskSubscriber';
import { SaleInvoiceTaxRateValidateSubscriber } from '@/services/TaxRates/subscribers/SaleInvoiceTaxRateValidateSubscriber';
import { SaleEstimateTaxRateValidateSubscriber } from '@/services/TaxRates/subscribers/SaleEstimateTaxRateValidateSubscriber';
import { SaleReceiptTaxRateValidateSubscriber } from '@/services/TaxRates/subscribers/SaleReceiptTaxRateValidateSubscriber';
import { WriteInvoiceTaxTransactionsSubscriber } from '@/services/TaxRates/subscribers/WriteInvoiceTaxTransactionsSubscriber';
export default () => {
return new EventPublisher();
@@ -188,6 +191,11 @@ export const susbcribers = () => {
ProjectBillableTasksSubscriber,
ProjectBillableExpensesSubscriber,
ProjectBillableBillSubscriber,
SaleInvoiceTaxRateValidateSubscriber
// Tax Rates
SaleInvoiceTaxRateValidateSubscriber,
SaleEstimateTaxRateValidateSubscriber,
SaleReceiptTaxRateValidateSubscriber,
WriteInvoiceTaxTransactionsSubscriber
];
};

View File

@@ -59,6 +59,7 @@ import Project from 'models/Project';
import Time from 'models/Time';
import Task from 'models/Task';
import TaxRate from 'models/TaxRate';
import TaxRateTransaction from 'models/TaxRateTransaction';
export default (knex) => {
const models = {
@@ -121,6 +122,7 @@ export default (knex) => {
Time,
Task,
TaxRate,
TaxRateTransaction,
};
return mapValues(models, (model) => model.bindKnex(knex));
};

View File

@@ -2,6 +2,8 @@ import { Model } from 'objection';
import TenantModel from 'models/TenantModel';
export default class ItemEntry extends TenantModel {
public taxRate: number;
/**
* Table name.
*/
@@ -17,7 +19,7 @@ export default class ItemEntry extends TenantModel {
}
static get virtualAttributes() {
return ['amount'];
return ['amount', 'taxAmount'];
}
get amount() {
@@ -31,6 +33,22 @@ export default class ItemEntry extends TenantModel {
return discount ? total - total * discount * 0.01 : total;
}
/**
* Tag rate fraction.
* @returns {number}
*/
get tagRateFraction() {
return this.taxRate / 100;
}
/**
* Tax amount withheld.
* @returns {number}
*/
get taxAmount() {
return this.amount * this.tagRateFraction;
}
static get relationMappings() {
const Item = require('models/Item');
const BillLandedCostEntry = require('models/BillLandedCostEntry');

View File

@@ -13,6 +13,11 @@ export default class SaleInvoice extends mixin(TenantModel, [
CustomViewBaseModel,
ModelSearchable,
]) {
taxAmountWithheld: number;
balance: number;
paymentAmount: number;
exchangeRate: number;
/**
* Table name
*/
@@ -51,12 +56,115 @@ export default class SaleInvoice extends mixin(TenantModel, [
];
}
/**
* Invoice total FCY.
* @returns {number}
*/
get totalFcy() {
return this.amountFcy + this.taxAmountWithheldFcy;
}
/**
* Invoice total BCY.
* @returns {number}
*/
get totalBcy() {
return this.amountBcy + this.taxAmountWithheldBcy;
}
/**
* Tax amount withheld FCY.
* @returns {number}
*/
get taxAmountWithheldFcy() {
return this.taxAmountWithheld;
}
/**
* Tax amount withheld BCY.
* @returns {number}
*/
get taxAmountWithheldBcy() {
return this.taxAmountWithheld;
}
/**
* Subtotal FCY.
* @returns {number}
*/
get subtotalFcy() {
return this.amountFcy;
}
/**
* Subtotal BCY.
* @returns {number}
*/
get subtotalBcy() {
return this.amountBcy;
}
/**
* Invoice due amount FCY.
* @returns {number}
*/
get dueAmountFcy() {
return this.amountFcy - this.paymentAmountFcy;
}
/**
* Invoice due amount BCY.
* @returns {number}
*/
get dueAmountBcy() {
return this.amountBcy - this.paymentAmountBcy;
}
/**
* Invoice amount FCY.
* @returns {number}
*/
get amountFcy() {
return this.balance;
}
/**
* Invoice amount BCY.
* @returns {number}
*/
get amountBcy() {
return this.balance * this.exchangeRate;
}
/**
* Invoice payment amount FCY.
* @returns {number}
*/
get paymentAmountFcy() {
return this.paymentAmount;
}
/**
* Invoice payment amount BCY.
* @returns {number}
*/
get paymentAmountBcy() {
return this.paymentAmount * this.exchangeRate;
}
/**
*
*/
get total() {
return this.balance + this.taxAmountWithheld;
}
/**
* Invoice amount in local currency.
* @returns {number}
*/
get localAmount() {
return this.balance * this.exchangeRate;
return this.total * this.exchangeRate;
}
/**
@@ -333,6 +441,7 @@ export default class SaleInvoice extends mixin(TenantModel, [
const PaymentReceiveEntry = require('models/PaymentReceiveEntry');
const Branch = require('models/Branch');
const Account = require('models/Account');
const TaxRateTransaction = require('models/TaxRateTransaction');
return {
/**
@@ -428,6 +537,21 @@ export default class SaleInvoice extends mixin(TenantModel, [
to: 'accounts.id',
},
},
/**
*
*/
taxes: {
relation: Model.HasManyRelation,
modelClass: TaxRateTransaction.default,
join: {
from: 'sales_invoices.id',
to: 'tax_rate_transactions.referenceId',
},
filter(builder) {
builder.where('reference_type', 'SaleInvoice');
},
},
};
}

View File

@@ -0,0 +1,42 @@
import { mixin, Model, raw } from 'objection';
import TenantModel from 'models/TenantModel';
import ModelSearchable from './ModelSearchable';
export default class TaxRateTransaction extends mixin(TenantModel, [
ModelSearchable,
]) {
/**
* Table name
*/
static get tableName() {
return 'tax_rate_transactions';
}
/**
* Timestamps columns.
*/
get timestamps() {
return [];
}
/**
* Virtual attributes.
*/
static get virtualAttributes() {
return [];
}
/**
* Model modifiers.
*/
static get modifiers() {
return {};
}
/**
* Relationship mapping.
*/
static get relationMappings() {
return {};
}
}

View File

@@ -2,6 +2,7 @@ import { Account } from 'models';
import TenantRepository from '@/repositories/TenantRepository';
import { IAccount } from '@/interfaces';
import { Knex } from 'knex';
import { TaxPayableAccount } from '@/database/seeds/data/accounts';
export default class AccountRepository extends TenantRepository {
/**
@@ -116,7 +117,7 @@ export default class AccountRepository extends TenantRepository {
if (!result) {
result = await this.model.query(trx).insertAndFetch({
name: this.i18n.__('account.accounts_receivable.currency', {
currency: currencyCode
currency: currencyCode,
}),
accountType: 'accounts-receivable',
currencyCode,
@@ -127,6 +128,29 @@ export default class AccountRepository extends TenantRepository {
return result;
};
/**
* Find or create tax payable account.
* @param {Record<string, string>}extraAttrs
* @param {Knex.Transaction} trx
* @returns
*/
async findOrCreateTaxPayable(
extraAttrs: Record<string, string> = {},
trx?: Knex.Transaction
) {
let result = await this.model
.query(trx)
.findOne({ slug: TaxPayableAccount.slug, ...extraAttrs });
if (!result) {
result = await this.model.query(trx).insertAndFetch({
...TaxPayableAccount,
...extraAttrs,
});
}
return result;
}
findOrCreateAccountsPayable = async (
currencyCode: string = '',
extraAttrs = {},

View File

@@ -17,6 +17,7 @@ import HasTenancyService from '@/services/Tenancy/TenancyService';
import { CommandSaleInvoiceValidators } from './CommandSaleInvoiceValidators';
import { SaleInvoiceIncrement } from './SaleInvoiceIncrement';
import { formatDateFields } from 'utils';
import { ItemEntriesTaxTransactions } from '@/services/TaxRates/ItemEntriesTaxTransactions';
@Service()
export class CommandSaleInvoiceDTOTransformer {
@@ -38,6 +39,9 @@ export class CommandSaleInvoiceDTOTransformer {
@Inject()
private invoiceIncrement: SaleInvoiceIncrement;
@Inject()
private taxDTOTransformer: ItemEntriesTaxTransactions;
/**
* Transformes the create DTO to invoice object model.
* @param {ISaleInvoiceCreateDTO} saleInvoiceDTO - Sale invoice DTO.
@@ -96,6 +100,7 @@ export class CommandSaleInvoiceDTOTransformer {
} as ISaleInvoice;
return R.compose(
this.taxDTOTransformer.assocTaxAmountWithheldFromEntries,
this.branchDTOTransform.transformDTO<ISaleInvoice>(tenantId),
this.warehouseDTOTransform.transformDTO<ISaleInvoice>(tenantId)
)(initialDTO);

View File

@@ -1,12 +1,13 @@
import * as R from 'ramda';
import { Knex } from 'knex';
import {
ISaleInvoice,
IItemEntry,
ILedgerEntry,
AccountNormal,
ILedger,
ITaxTransaction,
} from '@/interfaces';
import { Knex } from 'knex';
import { Service, Inject } from 'typedi';
import Ledger from '@/services/Accounting/Ledger';
import LedgerStorageService from '@/services/Accounting/LedgerStorageService';
@@ -22,8 +23,8 @@ export class SaleInvoiceGLEntries {
/**
* Writes a sale invoice GL entries.
* @param {number} tenantId
* @param {number} saleInvoiceId
* @param {number} tenantId - Tenant id.
* @param {number} saleInvoiceId - Sale invoice id.
* @param {Knex.Transaction} trx
*/
public writeInvoiceGLEntries = async (
@@ -42,9 +43,17 @@ export class SaleInvoiceGLEntries {
const ARAccount = await accountRepository.findOrCreateAccountReceivable(
saleInvoice.currencyCode
);
// Find or create tax payable account.
const taxPayableAccount = await accountRepository.findOrCreateTaxPayable(
{},
trx
);
// Retrieves the ledger of the invoice.
const ledger = this.getInvoiceGLedger(saleInvoice, ARAccount.id);
const ledger = this.getInvoiceGLedger(
saleInvoice,
ARAccount.id,
taxPayableAccount.id
);
// Commits the ledger entries to the storage as UOW.
await this.ledegrRepository.commit(tenantId, ledger, trx);
};
@@ -94,10 +103,14 @@ export class SaleInvoiceGLEntries {
*/
public getInvoiceGLedger = (
saleInvoice: ISaleInvoice,
ARAccountId: number
ARAccountId: number,
taxPayableAccountId: number
): ILedger => {
const entries = this.getInvoiceGLEntries(saleInvoice, ARAccountId);
const entries = this.getInvoiceGLEntries(
saleInvoice,
ARAccountId,
taxPayableAccountId
);
return new Ledger(entries);
};
@@ -143,7 +156,7 @@ export class SaleInvoiceGLEntries {
return {
...commonEntry,
debit: saleInvoice.localAmount,
debit: saleInvoice.totalBcy,
accountId: ARAccountId,
contactId: saleInvoice.customerId,
accountNormal: AccountNormal.DEBIT,
@@ -176,7 +189,27 @@ export class SaleInvoiceGLEntries {
itemId: entry.itemId,
itemQuantity: entry.quantity,
accountNormal: AccountNormal.CREDIT,
projectId: entry.projectId || saleInvoice.projectId
projectId: entry.projectId || saleInvoice.projectId,
};
}
);
/**
* Retreives the GL entry of tax payable.
* @param {ISaleInvoice} saleInvoice -
* @param {number} taxPayableAccountId -
* @returns {ILedgerEntry}
*/
private getInvoiceTaxEntry = R.curry(
(saleInvoice: ISaleInvoice, taxPayableAccountId: number): ILedgerEntry => {
const commonEntry = this.getInvoiceGLCommonEntry(saleInvoice);
return {
...commonEntry,
credit: saleInvoice.taxAmountWithheld,
accountId: taxPayableAccountId,
index: saleInvoice.entries.length + 3,
accountNormal: AccountNormal.CREDIT,
};
}
);
@@ -189,15 +222,18 @@ export class SaleInvoiceGLEntries {
*/
public getInvoiceGLEntries = (
saleInvoice: ISaleInvoice,
ARAccountId: number
ARAccountId: number,
taxPayableAccountId: number
): ILedgerEntry[] => {
const receivableEntry = this.getInvoiceReceivableEntry(
saleInvoice,
ARAccountId
);
const transformItemEntry = this.getInvoiceItemEntry(saleInvoice);
const creditEntries = saleInvoice.entries.map(transformItemEntry);
return [receivableEntry, ...creditEntries];
const creditEntries = saleInvoice.entries.map(transformItemEntry);
const taxEntry = this.getInvoiceTaxEntry(saleInvoice, taxPayableAccountId);
return [receivableEntry, ...creditEntries, taxEntry];
};
}

View File

@@ -0,0 +1,22 @@
import { ItemEntry } from "@/models";
import { sumBy } from "lodash";
import { Service } from "typedi";
@Service()
export class ItemEntriesTaxTransactions {
/**
*
* @param model
* @returns
*/
public assocTaxAmountWithheldFromEntries(model: any) {
const entries = model.entries.map((entry) => ItemEntry.fromJson(entry));
const taxAmountWithheld = sumBy(entries, 'taxAmount');
if (taxAmountWithheld) {
model.taxAmountWithheld = taxAmountWithheld;
}
return model;
}
}

View File

@@ -0,0 +1,65 @@
import { sumBy, chain } from 'lodash';
import { IItemEntry } from '@/interfaces';
import HasTenancyService from '../Tenancy/TenancyService';
import { Inject, Service } from 'typedi';
@Service()
export class WriteTaxTransactionsItemEntries {
@Inject()
private tenancy: HasTenancyService;
/**
* Writes the tax transactions from the given item entries.
* @param {number} tenantId
* @param {IItemEntry[]} itemEntries
*/
public async writeTaxTransactionsFromItemEntries(
tenantId: number,
itemEntries: IItemEntry[]
) {
const { TaxRateTransaction } = this.tenancy.models(tenantId);
const aggregatedEntries = this.aggregateItemEntriesByTaxCode(itemEntries);
const taxTransactions = aggregatedEntries.map((entry) => ({
taxName: 'TAX NAME',
taxCode: 'TAG_CODE',
referenceType: entry.referenceType,
referenceId: entry.referenceId,
taxAmount: entry.taxAmount,
}));
await TaxRateTransaction.query().upsertGraph(taxTransactions);
}
/**
*
* @param {IItemEntry[]} itemEntries
* @returns {}
*/
private aggregateItemEntriesByTaxCode(itemEntries: IItemEntry[]) {
return chain(itemEntries.filter((item) => item.taxCode))
.groupBy((item) => item.taxCode)
.values()
.map((group) => ({ ...group[0], amount: sumBy(group, 'amount') }))
.value();
}
/**
*
* @param itemEntries
*/
private aggregateItemEntriesByReferenceTypeId(itemEntries: IItemEntry) {}
/**
* Removes the tax transactions from the given item entries.
* @param tenantId
* @param itemEntries
*/
public removeTaxTransactionsFromItemEntries(
tenantId: number,
itemEntries: IItemEntry[]
) {
const { TaxRateTransaction } = this.tenancy.models(tenantId);
const filteredEntries = itemEntries.filter((item) => item.taxCode);
}
}

View File

@@ -0,0 +1,56 @@
import { Inject, Service } from 'typedi';
import {
ISaleInvoiceCreatedPayload,
ISaleInvoiceDeletedPayload,
} from '@/interfaces';
import events from '@/subscribers/events';
import { WriteTaxTransactionsItemEntries } from '../WriteTaxTransactionsItemEntries';
@Service()
export class WriteInvoiceTaxTransactionsSubscriber {
@Inject()
private writeTaxTransactions: WriteTaxTransactionsItemEntries;
/**
* Attaches events with handlers.
*/
public attach(bus) {
bus.subscribe(
events.saleInvoice.onCreated,
this.writeInvoiceTaxTransactionsOnCreated
);
bus.subscribe(
events.saleInvoice.onDeleted,
this.removeInvoiceTaxTransactionsOnDeleted
);
return bus;
}
/**
* Validate receipt entries tax rate code existance.
* @param {ISaleInvoiceCreatingPaylaod}
*/
private writeInvoiceTaxTransactionsOnCreated = async ({
tenantId,
saleInvoice,
}: ISaleInvoiceCreatedPayload) => {
await this.writeTaxTransactions.writeTaxTransactionsFromItemEntries(
tenantId,
saleInvoice.entries
);
};
/**
* Removes the invoice tax transactions on invoice deleted.
* @param {ISaleInvoiceEditingPayload}
*/
private removeInvoiceTaxTransactionsOnDeleted = async ({
tenantId,
oldSaleInvoice,
}: ISaleInvoiceDeletedPayload) => {
await this.writeTaxTransactions.removeTaxTransactionsFromItemEntries(
tenantId,
oldSaleInvoice.entries
);
};
}