diff --git a/packages/server/src/api/controllers/Banking/BankTransactionsMatchingController.ts b/packages/server/src/api/controllers/Banking/BankTransactionsMatchingController.ts new file mode 100644 index 000000000..98e000cb8 --- /dev/null +++ b/packages/server/src/api/controllers/Banking/BankTransactionsMatchingController.ts @@ -0,0 +1,129 @@ +import { Inject, Service } from 'typedi'; +import { NextFunction, Request, Response, Router } from 'express'; +import BaseController from '@/api/controllers/BaseController'; +import { MatchBankTransactionsApplication } from '@/services/Banking/Matching/MatchBankTransactionsApplication'; +import { param } from 'express-validator'; +import { + GetMatchedTransactionsFilter, + IMatchTransactionDTO, +} from '@/services/Banking/Matching/types'; + +@Service() +export class BankTransactionsMatchingController extends BaseController { + @Inject() + private bankTransactionsMatchingApp: MatchBankTransactionsApplication; + + /** + * Router constructor. + */ + public router() { + const router = Router(); + + router.post( + '/:transactionId', + [param('transactionId').exists()], + this.validationResult, + this.matchBankTransaction.bind(this) + ); + router.post( + '/unmatch/:transactionId', + [param('transactionId').exists()], + this.validationResult, + this.unmatchMatchedBankTransaction.bind(this) + ); + router.get('/', this.getMatchedTransactions.bind(this)); + + return router; + } + + /** + * Matches the given bank transaction. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + * @returns + */ + private async matchBankTransaction( + req: Request, + res: Response, + next: NextFunction + ) { + const { tenantId } = req; + const { transactionId } = req.params; + const matchTransactionDTO = this.matchedBodyData( + req + ) as IMatchTransactionDTO; + + try { + await this.bankTransactionsMatchingApp.matchTransaction( + tenantId, + transactionId, + matchTransactionDTO + ); + + return res.status(200).send({ + message: 'The bank transaction has been matched.', + }); + } catch (error) { + next(error); + } + } + + /** + * + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + * @returns + */ + private async unmatchMatchedBankTransaction( + req: Request, + res: Response, + next: NextFunction + ) { + const { tenantId } = req; + const transactionId = req.params?.transactionId; + + try { + await this.bankTransactionsMatchingApp.unmatchMatchedTransaction( + tenantId, + transactionId + ); + + return res.status(200).send({ + message: 'The bank matched transaction has been unmatched.', + }); + } catch (error) { + next(error); + } + } + + /** + * Retrieves the matched transactions. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + private async getMatchedTransactions( + req: Request, + res: Response, + next: NextFunction + ) { + const { tenantId } = req; + const filter = this.matchedQueryData(req) as GetMatchedTransactionsFilter; + + console.log('test'); + + try { + const matchedTransactions = + await this.bankTransactionsMatchingApp.getMatchedTransactions( + tenantId, + filter + ); + + return res.status(200).send({ data: matchedTransactions }); + } catch (error) { + next(error); + } + } +} diff --git a/packages/server/src/api/controllers/Banking/BankingController.ts b/packages/server/src/api/controllers/Banking/BankingController.ts index 109371311..679073661 100644 --- a/packages/server/src/api/controllers/Banking/BankingController.ts +++ b/packages/server/src/api/controllers/Banking/BankingController.ts @@ -3,6 +3,7 @@ import { Router } from 'express'; import BaseController from '@/api/controllers/BaseController'; import { PlaidBankingController } from './PlaidBankingController'; import { BankingRulesController } from './BankingRulesController'; +import { BankTransactionsMatchingController } from './BankTransactionsMatchingController'; @Service() export class BankingController extends BaseController { @@ -14,6 +15,10 @@ export class BankingController extends BaseController { router.use('/plaid', Container.get(PlaidBankingController).router()); router.use('/rules', Container.get(BankingRulesController).router()); + router.use( + '/matches', + Container.get(BankTransactionsMatchingController).router() + ); return router; } diff --git a/packages/server/src/database/migrations/20240619133733_create_matched_bank_transactions_table.js b/packages/server/src/database/migrations/20240619133733_create_matched_bank_transactions_table.js new file mode 100644 index 000000000..1ed36e10c --- /dev/null +++ b/packages/server/src/database/migrations/20240619133733_create_matched_bank_transactions_table.js @@ -0,0 +1,14 @@ +exports.up = function (knex) { + return knex.schema.createTable('matched_bank_transactions', (table) => { + table.increments('id'); + table.integer('uncategorized_transaction_id').unsigned(); + table.string('reference_type'); + table.integer('reference_id'); + table.decimal('amount'); + table.timestamps(); + }); +}; + +exports.down = function (knex) { + return knex.schema.dropTableIfExists('matched_bank_transactions'); +}; diff --git a/packages/server/src/loaders/tenantModels.ts b/packages/server/src/loaders/tenantModels.ts index f119da226..02877491a 100644 --- a/packages/server/src/loaders/tenantModels.ts +++ b/packages/server/src/loaders/tenantModels.ts @@ -67,6 +67,7 @@ import DocumentLink from '@/models/DocumentLink'; import { BankRule } from '@/models/BankRule'; import { BankRuleCondition } from '@/models/BankRuleCondition'; import { RecognizedBankTransaction } from '@/models/RecognizedBankTransaction'; +import { MatchedBankTransaction } from '@/models/MatchedBankTransaction'; export default (knex) => { const models = { @@ -137,6 +138,7 @@ export default (knex) => { BankRule, BankRuleCondition, RecognizedBankTransaction, + MatchedBankTransaction, }; return mapValues(models, (model) => model.bindKnex(knex)); }; diff --git a/packages/server/src/models/MatchedBankTransaction.ts b/packages/server/src/models/MatchedBankTransaction.ts new file mode 100644 index 000000000..5d025cbbb --- /dev/null +++ b/packages/server/src/models/MatchedBankTransaction.ts @@ -0,0 +1,24 @@ +import TenantModel from 'models/TenantModel'; + +export class MatchedBankTransaction extends TenantModel { + /** + * Table name. + */ + static get tableName() { + return 'matched_bank_transactions'; + } + + /** + * Timestamps columns. + */ + get timestamps() { + return []; + } + + /** + * Virtual attributes. + */ + static get virtualAttributes() { + return []; + } +} diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactionBillsTransformer.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactionBillsTransformer.ts new file mode 100644 index 000000000..fdc2a38ed --- /dev/null +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionBillsTransformer.ts @@ -0,0 +1,40 @@ +import { Transformer } from '@/lib/Transformer/Transformer'; + +export class GetMatchedTransactionBillsTransformer extends Transformer { + /** + * Include these attributes to sale credit note object. + * @returns {Array} + */ + public includeAttributes = (): string[] => { + return ['referenceNo']; + }; + + public excludeAttributes = (): string[] => { + return ['*']; + }; + + protected referenceNo(invoice) { + return invoice.referenceNo; + } + + amount(invoice) { + return 1; + } + amountFormatted() { + + } + date() { + + } + dateFromatted() { + + } + transactionId(invoice) { + return invoice.id; + } + transactionNo() { + + } + transactionType() {} + transsactionTypeFormatted() {} +} diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactionExpensesTransformer.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactionExpensesTransformer.ts new file mode 100644 index 000000000..b8f16ae7a --- /dev/null +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionExpensesTransformer.ts @@ -0,0 +1,32 @@ +import { Transformer } from '@/lib/Transformer/Transformer'; + +export class GetMatchedTransactionExpensesTransformer extends Transformer { + /** + * Include these attributes to sale credit note object. + * @returns {Array} + */ + public includeAttributes = (): string[] => { + return ['referenceNo']; + }; + + public excludeAttributes = (): string[] => { + return ['*']; + }; + + protected referenceNo(invoice) { + return invoice.referenceNo; + } + + amount(invoice) { + return 1; + } + amountFormatted() {} + date() {} + dateFromatted() {} + transactionId(invoice) { + return invoice.id; + } + transactionNo() {} + transactionType() {} + transsactionTypeFormatted() {} +} diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactionInvoicesTransformer.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactionInvoicesTransformer.ts new file mode 100644 index 000000000..e3326a873 --- /dev/null +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionInvoicesTransformer.ts @@ -0,0 +1,23 @@ +import { Transformer } from '@/lib/Transformer/Transformer'; + +export class GetMatchedTransactionInvoicesTransformer extends Transformer { + /** + * Include these attributes to sale credit note object. + * @returns {Array} + */ + public includeAttributes = (): string[] => { + return ['referenceNo', 'transactionNo']; + }; + + public excludeAttributes = (): string[] => { + return ['*']; + }; + + protected referenceNo(invoice) { + return invoice.referenceNo; + } + + protected transactionNo(invoice) { + return invoice.invoiceNo; + } +} diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactionManualJournalsTransformer.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactionManualJournalsTransformer.ts new file mode 100644 index 000000000..530dfbfa7 --- /dev/null +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionManualJournalsTransformer.ts @@ -0,0 +1,32 @@ +import { Transformer } from '@/lib/Transformer/Transformer'; + +export class GetMatchedTransactionManualJournalsTransformer extends Transformer { + /** + * Include these attributes to sale credit note object. + * @returns {Array} + */ + public includeAttributes = (): string[] => { + return ['referenceNo']; + }; + + public excludeAttributes = (): string[] => { + return ['*']; + }; + + protected referenceNo(invoice) { + return invoice.referenceNo; + } + + amount(invoice) { + return 1; + } + amountFormatted() {} + date() {} + dateFromatted() {} + transactionId(invoice) { + return invoice.id; + } + transactionNo() {} + transactionType() {} + transsactionTypeFormatted() {} +} diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactions.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactions.ts new file mode 100644 index 000000000..b98d2cef1 --- /dev/null +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactions.ts @@ -0,0 +1,144 @@ +import { Inject, Service } from 'typedi'; +import * as R from 'ramda'; +import { PromisePool } from '@supercharge/promise-pool'; +import { GetMatchedTransactionsFilter } from './types'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; +import { GetMatchedTransactionInvoicesTransformer } from './GetMatchedTransactionInvoicesTransformer'; +import { GetMatchedTransactionBillsTransformer } from './GetMatchedTransactionBillsTransformer'; +import { GetMatchedTransactionExpensesTransformer } from './GetMatchedTransactionExpensesTransformer'; +import { GetMatchedTransactionManualJournalsTransformer } from './GetMatchedTransactionManualJournalsTransformer'; + +@Service() +export class GetMatchedTransactions { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private transformer: TransformerInjectable; + + /** + * Retrieves the matched transactions. + * @param {number} tenantId - + * @param {GetMatchedTransactionsFilter} filter - + */ + public async getMatchedTransactions( + tenantId: number, + filter: GetMatchedTransactionsFilter + ) { + const registered = [ + { + type: 'SaleInvoice', + callback: this.getSaleInvoicesMatchedTransactions.bind(this), + }, + { + type: 'Bill', + callback: this.getBillsMatchedTransactions.bind(this), + }, + { + type: 'Expense', + callback: this.getExpensesMatchedTransactions.bind(this), + }, + { + type: 'ManualJournal', + callback: this.getManualJournalsMatchedTransactions.bind(this), + }, + ]; + const filtered = filter.transactionType + ? registered.filter((item) => item.type === filter.transactionType) + : registered; + + const matchedTransactions = await PromisePool.withConcurrency(2) + .for(filtered) + .process(async ({ type, callback }) => { + return callback(tenantId, filter); + }); + return R.compose(R.flatten)(matchedTransactions?.results); + } + + /** + * + * @param {number} tenantId - + * @param {GetMatchedTransactionsFilter} filter - + */ + async getSaleInvoicesMatchedTransactions( + tenantId: number, + filter: GetMatchedTransactionsFilter + ) { + const { SaleInvoice } = this.tenancy.models(tenantId); + + const invoices = await SaleInvoice.query(); + + return this.transformer.transform( + tenantId, + invoices, + new GetMatchedTransactionInvoicesTransformer() + ); + } + + /** + * + * @param {number} tenantId - + * @param {GetMatchedTransactionsFilter} filter - + */ + async getBillsMatchedTransactions( + tenantId: number, + filter: GetMatchedTransactionsFilter + ) { + const { Bill } = this.tenancy.models(tenantId); + + const bills = await Bill.query(); + + return this.transformer.transform( + tenantId, + bills, + new GetMatchedTransactionBillsTransformer() + ); + } + + /** + * + * @param {number} tenantId + * @param {GetMatchedTransactionsFilter} filter + * @returns + */ + async getExpensesMatchedTransactions( + tenantId: number, + filter: GetMatchedTransactionsFilter + ) { + const { Expense } = this.tenancy.models(tenantId); + + const expenses = await Expense.query(); + + return this.transformer.transform( + tenantId, + expenses, + new GetMatchedTransactionManualJournalsTransformer() + ); + } + + async getManualJournalsMatchedTransactions( + tenantId: number, + filter: GetMatchedTransactionsFilter + ) { + const { ManualJournal } = this.tenancy.models(tenantId); + + const manualJournals = await ManualJournal.query(); + + return this.transformer.transform( + tenantId, + manualJournals, + new GetMatchedTransactionManualJournalsTransformer() + ); + } +} + +interface MatchedTransaction { + amount: number; + amountFormatted: string; + date: string; + dateFormatted: string; + referenceNo: string; + transactionNo: string; + transactionId: number; +} diff --git a/packages/server/src/services/Banking/Matching/MatchBankTransactionsApplication.ts b/packages/server/src/services/Banking/Matching/MatchBankTransactionsApplication.ts new file mode 100644 index 000000000..b065d2bc3 --- /dev/null +++ b/packages/server/src/services/Banking/Matching/MatchBankTransactionsApplication.ts @@ -0,0 +1,68 @@ +import { Inject, Service } from 'typedi'; +import { GetMatchedTransactions } from './GetMatchedTransactions'; +import { MatchBankTransactions } from './MatchTransactions'; +import { UnmatchMatchedBankTransaction } from './UnmatchMatchedTransaction'; +import { GetMatchedTransactionsFilter, IMatchTransactionDTO } from './types'; + +@Service() +export class MatchBankTransactionsApplication { + @Inject() + private getMatchedTransactionsService: GetMatchedTransactions; + + @Inject() + private matchTransactionService: MatchBankTransactions; + + @Inject() + private unmatchMatchedTransactionService: UnmatchMatchedBankTransaction; + + /** + * Retrieves the matched transactions. + * @param {number} tenantId - + * @param {GetMatchedTransactionsFilter} filter - + * @returns + */ + public getMatchedTransactions( + tenantId: number, + filter: GetMatchedTransactionsFilter + ) { + return this.getMatchedTransactionsService.getMatchedTransactions( + tenantId, + filter + ); + } + + /** + * Matches the given uncategorized transaction with the given system transaction. + * @param {number} tenantId + * @param {number} uncategorizedTransactionId + * @param {IMatchTransactionDTO} matchTransactionsDTO + * @returns {Promise} + */ + public matchTransaction( + tenantId: number, + uncategorizedTransactionId: number, + matchTransactionsDTO: IMatchTransactionDTO + ): Promise { + return this.matchTransactionService.matchTransaction( + tenantId, + uncategorizedTransactionId, + matchTransactionsDTO + ); + } + + /** + * Unmatch the given matched transaction. + * @param {number} tenantId + * @param {number} uncategorizedTransactionId + * @returns {Promise} + */ + public unmatchMatchedTransaction( + tenantId: number, + uncategorizedTransactionId: number + ) { + return this.unmatchMatchedTransactionService.unmatchMatchedTransaction( + tenantId, + uncategorizedTransactionId + ); + } +} diff --git a/packages/server/src/services/Banking/Matching/MatchTransactions.ts b/packages/server/src/services/Banking/Matching/MatchTransactions.ts new file mode 100644 index 000000000..496ba204e --- /dev/null +++ b/packages/server/src/services/Banking/Matching/MatchTransactions.ts @@ -0,0 +1,69 @@ +import { PromisePool } from '@supercharge/promise-pool'; +import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import UnitOfWork from '@/services/UnitOfWork'; +import events from '@/subscribers/events'; +import { Knex } from 'knex'; +import { Inject, Service } from 'typedi'; +import { + IBankTransactionMatchedEventPayload, + IBankTransactionMatchingEventPayload, + IMatchTransactionDTO, +} from './types'; + +@Service() +export class MatchBankTransactions { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private uow: UnitOfWork; + + @Inject() + private eventPublisher: EventPublisher; + + /** + * Matches the given uncategorized transaction to the given references. + * @param {number} tenantId + * @param {number} uncategorizedTransactionId + */ + public matchTransaction( + tenantId: number, + uncategorizedTransactionId: number, + matchTransactionsDTO: IMatchTransactionDTO + ) { + const { matchedTransactions } = matchTransactionsDTO; + const { MatchBankTransaction } = this.tenancy.models(tenantId); + + // + return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => { + // Triggers the event `onSaleInvoiceCreated`. + await this.eventPublisher.emitAsync(events.bankMatch.onMatching, { + tenantId, + uncategorizedTransactionId, + matchTransactionsDTO, + trx, + } as IBankTransactionMatchingEventPayload); + + // + await PromisePool.withConcurrency(10) + .for(matchedTransactions) + .process(async (matchedTransaction) => { + await MatchBankTransaction.query(trx).insert({ + uncategorizedTransactionId, + referenceType: matchedTransaction.referenceType, + referenceId: matchedTransaction.referenceId, + amount: matchedTransaction.amount, + }); + }); + + // Triggers the event `onSaleInvoiceCreated`. + await this.eventPublisher.emitAsync(events.bankMatch.onMatched, { + tenantId, + uncategorizedTransactionId, + matchTransactionsDTO, + trx, + } as IBankTransactionMatchedEventPayload); + }); + } +} diff --git a/packages/server/src/services/Banking/Matching/UnmatchMatchedTransaction.ts b/packages/server/src/services/Banking/Matching/UnmatchMatchedTransaction.ts new file mode 100644 index 000000000..98c86d6e6 --- /dev/null +++ b/packages/server/src/services/Banking/Matching/UnmatchMatchedTransaction.ts @@ -0,0 +1,41 @@ +import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import UnitOfWork from '@/services/UnitOfWork'; +import events from '@/subscribers/events'; +import { Inject, Service } from 'typedi'; +import { IBankTransactionUnmatchingEventPayload } from './types'; + +@Service() +export class UnmatchMatchedBankTransaction { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private uow: UnitOfWork; + + @Inject() + private eventPublisher: EventPublisher; + + public unmatchMatchedTransaction( + tenantId: number, + uncategorizedTransactionId: number + ) { + const { MatchedBankTransaction } = this.tenancy.models(tenantId); + + return this.uow.withTransaction(tenantId, async (trx) => { + await this.eventPublisher.emitAsync(events.bankMatch.onUnmatching, { + tenantId, + trx, + } as IBankTransactionUnmatchingEventPayload); + + await MatchedBankTransaction.query(trx) + .where('uncategorizedTransactionId', uncategorizedTransactionId) + .delete(); + + await this.eventPublisher.emitAsync(events.bankMatch.onUnmatched, { + tenantId, + trx, + } as IBankTransactionUnmatchingEventPayload); + }); + } +} diff --git a/packages/server/src/services/Banking/Matching/types.ts b/packages/server/src/services/Banking/Matching/types.ts new file mode 100644 index 000000000..c26e273ec --- /dev/null +++ b/packages/server/src/services/Banking/Matching/types.ts @@ -0,0 +1,39 @@ +import { Knex } from 'knex'; + +export interface IBankTransactionMatchingEventPayload { + tenantId: number; + uncategorizedTransactionId: number; + matchTransactionsDTO: IMatchTransactionDTO; + trx?: Knex.Transaction; +} + +export interface IBankTransactionMatchedEventPayload { + tenantId: number; + uncategorizedTransactionId: number; + matchTransactionsDTO: IMatchTransactionDTO; + trx?: Knex.Transaction; +} + +export interface IBankTransactionUnmatchingEventPayload { + tenantId: number; +} + +export interface IBankTransactionUnmatchedEventPayload { + tenantId: number; +} + +export interface IMatchTransactionDTO { + matchedTransactions: Array<{ + referenceType: string; + referenceId: number; + amount: number; + }>; +} + +export interface GetMatchedTransactionsFilter { + fromDate: string; + toDate: string; + minAmount: number; + maxAmount: number; + transactionType: string; +} diff --git a/packages/server/src/subscribers/events.ts b/packages/server/src/subscribers/events.ts index c57941f1e..e139b2b41 100644 --- a/packages/server/src/subscribers/events.ts +++ b/packages/server/src/subscribers/events.ts @@ -618,6 +618,7 @@ export default { onItemCreated: 'onPlaidItemCreated', }, + // Bank rules. bankRules: { onCreating: 'onBankRuleCreating', onCreated: 'onBankRuleCreated', @@ -628,4 +629,13 @@ export default { onDeleting: 'onBankRuleDeleting', onDeleted: 'onBankRuleDeleted', }, + + // Bank matching. + bankMatch: { + onMatching: 'onBankTransactionMatching', + onMatched: 'onBankTransactionMatched', + + onUnmatching: 'onBankTransactionUnmathcing', + onUnmatched: 'onBankTransactionUnmathced', + } };