diff --git a/packages/server/src/database/migrations/20240618100137_create_bank_rules_table.js b/packages/server/src/database/migrations/20240618100137_create_bank_rules_table.js index ec0136117..c11ce89f2 100644 --- a/packages/server/src/database/migrations/20240618100137_create_bank_rules_table.js +++ b/packages/server/src/database/migrations/20240618100137_create_bank_rules_table.js @@ -5,11 +5,19 @@ exports.up = function (knex) { table.string('name'); table.integer('order').unsigned(); - table.integer('apply_if_account_id').unsigned(); + table + .integer('apply_if_account_id') + .unsigned() + .references('id') + .inTable('accounts'); table.string('apply_if_transaction_type'); table.string('assign_category'); - table.integer('assign_account_id').unsigned(); + table + .integer('assign_account_id') + .unsigned() + .references('id') + .inTable('accounts'); table.string('assign_payee'); table.string('assign_memo'); @@ -19,7 +27,11 @@ exports.up = function (knex) { }) .createTable('bank_rule_conditions', (table) => { table.increments('id').primary(); - table.integer('rule_id').unsigned(); + table + .integer('rule_id') + .unsigned() + .references('id') + .inTable('bank_rules'); table.string('field'); table.string('comparator'); table.string('value'); diff --git a/packages/server/src/database/migrations/20240618171553_create_recognized_bank_transactions_table.js b/packages/server/src/database/migrations/20240618171553_create_recognized_bank_transactions_table.js index 6fab718a1..223ff403e 100644 --- a/packages/server/src/database/migrations/20240618171553_create_recognized_bank_transactions_table.js +++ b/packages/server/src/database/migrations/20240618171553_create_recognized_bank_transactions_table.js @@ -1,11 +1,23 @@ exports.up = function (knex) { return knex.schema.createTable('recognized_bank_transactions', (table) => { table.increments('id'); - table.integer('cashflow_transaction_id').unsigned(); - table.inteegr('bank_rule_id').unsigned(); + table + .integer('cashflow_transaction_id') + .unsigned() + .references('id') + .inTable('uncategorized_cashflow_transactions'); + table + .integer('bank_rule_id') + .unsigned() + .references('id') + .inTable('bank_rules'); table.string('assigned_category'); - table.integer('assigned_account_id').unsigned(); + table + .integer('assigned_account_id') + .unsigned() + .references('id') + .inTable('accounts'); table.string('assigned_payee'); table.string('assigned_memo'); diff --git a/packages/server/src/loaders/tenantModels.ts b/packages/server/src/loaders/tenantModels.ts index 0debb29b5..f119da226 100644 --- a/packages/server/src/loaders/tenantModels.ts +++ b/packages/server/src/loaders/tenantModels.ts @@ -66,6 +66,7 @@ import Document from '@/models/Document'; import DocumentLink from '@/models/DocumentLink'; import { BankRule } from '@/models/BankRule'; import { BankRuleCondition } from '@/models/BankRuleCondition'; +import { RecognizedBankTransaction } from '@/models/RecognizedBankTransaction'; export default (knex) => { const models = { @@ -135,6 +136,7 @@ export default (knex) => { UncategorizedCashflowTransaction, BankRule, BankRuleCondition, + RecognizedBankTransaction, }; return mapValues(models, (model) => model.bindKnex(knex)); }; diff --git a/packages/server/src/models/RecognizedBankTransaction.ts b/packages/server/src/models/RecognizedBankTransaction.ts new file mode 100644 index 000000000..4ddf47c06 --- /dev/null +++ b/packages/server/src/models/RecognizedBankTransaction.ts @@ -0,0 +1,24 @@ +import TenantModel from 'models/TenantModel'; + +export class RecognizedBankTransaction extends TenantModel { + /** + * Table name. + */ + static get tableName() { + return 'recognized_bank_transactions'; + } + + /** + * Timestamps columns. + */ + get timestamps() { + return []; + } + + /** + * Virtual attributes. + */ + static get virtualAttributes() { + return []; + } +} diff --git a/packages/server/src/models/UncategorizedCashflowTransaction.ts b/packages/server/src/models/UncategorizedCashflowTransaction.ts index bcd4a2770..272458892 100644 --- a/packages/server/src/models/UncategorizedCashflowTransaction.ts +++ b/packages/server/src/models/UncategorizedCashflowTransaction.ts @@ -11,9 +11,15 @@ export default class UncategorizedCashflowTransaction extends mixin( [ModelSettings] ) { id!: number; + date!: Date | string; amount!: number; categorized!: boolean; accountId!: number; + referenceNo!: string; + payee!: string; + description!: string; + plaidTransactionId!: string; + recognizedTransactionId!: number; /** * Table name. @@ -75,11 +81,21 @@ export default class UncategorizedCashflowTransaction extends mixin( return 0 < this.withdrawal; } + /** + * Detarmines whether the transaction is recognized. + */ + public get isRecognized(): boolean { + return !!this.recognizedTransactionId; + } + /** * Relationship mapping. */ static get relationMappings() { const Account = require('models/Account'); + const { + RecognizedBankTransaction, + } = require('models/RecognizedBankTransaction'); return { /** @@ -93,6 +109,18 @@ export default class UncategorizedCashflowTransaction extends mixin( to: 'accounts.id', }, }, + + /** + * Transaction may has association to recognized transaction. + */ + recognizedTransaction: { + relation: Model.HasOneRelation, + modelClass: RecognizedBankTransaction, + join: { + from: 'uncategorized_cashflow_transactions.recognizedTransactionId', + to: 'recognized_bank_transactions.id', + }, + }, }; } diff --git a/packages/server/src/services/Banking/RegonizeTranasctions/RecognizeTranasctionsService.ts b/packages/server/src/services/Banking/RegonizeTranasctions/RecognizeTranasctionsService.ts index 1e0bb8c58..74832fbbb 100644 --- a/packages/server/src/services/Banking/RegonizeTranasctions/RecognizeTranasctionsService.ts +++ b/packages/server/src/services/Banking/RegonizeTranasctions/RecognizeTranasctionsService.ts @@ -1,37 +1,90 @@ +import { Knex } from 'knex'; import UncategorizedCashflowTransaction from '@/models/UncategorizedCashflowTransaction'; import HasTenancyService from '@/services/Tenancy/TenancyService'; import { transformToMapBy } from '@/utils'; import { Inject, Service } from 'typedi'; import { PromisePool } from '@supercharge/promise-pool'; +import { BankRule } from '@/models/BankRule'; +import { bankRulesMatchTransaction } from './_utils'; @Service() -export class RecognizeedTranasctionsService { +export class RecognizeTranasctionsService { @Inject() private tenancy: HasTenancyService; /** - * Regonized the uncategorized transactions. - * @param {number} tenantId + * Marks the uncategorized transaction as recognized from the given bank rule. + * @param {number} tenantId - + * @param {BankRule} bankRule - + * @param {UncategorizedCashflowTransaction} transaction - + * @param {Knex.Transaction} trx - */ - public async recognizeTransactions(tenantId: number) { + private markBankRuleAsRecognized = async ( + tenantId: number, + bankRule: BankRule, + transaction: UncategorizedCashflowTransaction, + trx?: Knex.Transaction + ) => { + const { RecognizedBankTransaction, UncategorizedCashflowTransaction } = + this.tenancy.models(tenantId); + + const recognizedTransaction = await RecognizedBankTransaction.query( + trx + ).insert({ + bankRuleId: bankRule.id, + cashflowTransactionId: transaction.id, + assignedCategory: bankRule.assignCategory, + assignedAccountId: bankRule.assignAccountId, + assignedPayee: bankRule.assignPayee, + assignedMemo: bankRule.assignMemo, + }); + await UncategorizedCashflowTransaction.query(trx) + .findById(transaction.id) + .patch({ + recognizedTransactionId: recognizedTransaction.id, + }); + }; + + /** + * Regonized the uncategorized transactions. + * @param {number} tenantId - + * @param {Knex.Transaction} trx - + */ + public async recognizeTransactions(tenantId: number, trx?: Knex.Transaction) { const { UncategorizedCashflowTransaction, BankRule } = this.tenancy.models(tenantId); const uncategorizedTranasctions = - await UncategorizedCashflowTransaction.query().where( - 'regonized_transaction_id', - null - ); + await UncategorizedCashflowTransaction.query() + .where('recognized_transaction_id', null) + .where('categorized', false); - const bankRules = await BankRule.query(); - const bankRulesByAccountId = transformToMapBy(bankRules, 'accountId'); - - console.log(bankRulesByAccountId); - - const regonizeTransaction = ( + const bankRules = await BankRule.query().withGraphFetched('conditions'); + const bankRulesByAccountId = transformToMapBy( + bankRules, + 'applyIfAccountId' + ); + // Try to recognize the transaction. + const regonizeTransaction = async ( transaction: UncategorizedCashflowTransaction - ) => {}; - + ) => { + const allAccountsBankRules = bankRulesByAccountId.get(`null`); + const accountBankRules = bankRulesByAccountId.get( + `${transaction.accountId}` + ); + const recognizedBankRule = bankRulesMatchTransaction( + transaction, + accountBankRules + ); + if (recognizedBankRule) { + await this.markBankRuleAsRecognized( + tenantId, + recognizedBankRule, + transaction, + trx + ); + } + }; await PromisePool.withConcurrency(MIGRATION_CONCURRENCY) .for(uncategorizedTranasctions) .process((transaction: UncategorizedCashflowTransaction, index, pool) => { @@ -39,6 +92,10 @@ export class RecognizeedTranasctionsService { }); } + /** + * + * @param {number} uncategorizedTransaction + */ public async regonizeTransaction( uncategorizedTransaction: UncategorizedCashflowTransaction ) {} diff --git a/packages/server/src/services/Banking/RegonizeTranasctions/RecognizeTransactionsJob.ts b/packages/server/src/services/Banking/RegonizeTranasctions/RecognizeTransactionsJob.ts index 2278b6505..2eee22f53 100644 --- a/packages/server/src/services/Banking/RegonizeTranasctions/RecognizeTransactionsJob.ts +++ b/packages/server/src/services/Banking/RegonizeTranasctions/RecognizeTransactionsJob.ts @@ -1,5 +1,5 @@ import Container, { Service } from 'typedi'; -import { RegonizeTranasctionsService } from './RecognizeTranasctionsService'; +import { RecognizeTranasctionsService } from './RecognizeTranasctionsService'; @Service() export class RegonizeTransactionsJob { @@ -8,7 +8,7 @@ export class RegonizeTransactionsJob { */ constructor(agenda) { agenda.define( - 'regonize-uncategorized-transactions-job', + 'recognize-uncategorized-transactions-job', { priority: 'high', concurrency: 2 }, this.handler ); @@ -19,10 +19,10 @@ export class RegonizeTransactionsJob { */ private handler = async (job, done: Function) => { const { tenantId } = job.attrs.data; - const regonizeTransactions = Container.get(RegonizeTranasctionsService); + const regonizeTransactions = Container.get(RecognizeTranasctionsService); try { - await regonizeTransactions.regonizeTransactions(tenantId); + await regonizeTransactions.recognizeTransactions(tenantId); done(); } catch (error) { console.log(error); diff --git a/packages/server/src/services/Banking/RegonizeTranasctions/_utils.ts b/packages/server/src/services/Banking/RegonizeTranasctions/_utils.ts new file mode 100644 index 000000000..2a030b799 --- /dev/null +++ b/packages/server/src/services/Banking/RegonizeTranasctions/_utils.ts @@ -0,0 +1,104 @@ +import UncategorizedCashflowTransaction from '@/models/UncategorizedCashflowTransaction'; +import { + BankRuleApplyIfTransactionType, + BankRuleConditionComparator, + BankRuleConditionType, + IBankRule, + IBankRuleCondition, +} from '../Rules/types'; +import { BankRule } from '@/models/BankRule'; + +const conditionsMatch = ( + transaction: UncategorizedCashflowTransaction, + conditions: IBankRuleCondition[], + conditionsType: BankRuleConditionType = BankRuleConditionType.And +) => { + const method = + conditionsType === BankRuleConditionType.And ? 'every' : 'some'; + + return conditions[method]((condition) => { + switch (determineFieldType(condition.field)) { + case 'number': + return matchNumberCondition(transaction, condition); + case 'text': + return matchTextCondition(transaction, condition); + default: + return false; + } + }); +}; + +const matchNumberCondition = ( + transaction: UncategorizedCashflowTransaction, + condition: IBankRuleCondition +) => { + switch (condition.comparator) { + case BankRuleConditionComparator.Equals: + return transaction[condition.field] === condition.value; + case BankRuleConditionComparator.Contains: + return transaction[condition.field] + ?.toString() + .includes(condition.value.toString()); + case BankRuleConditionComparator.NotContain: + return !transaction[condition.field] + ?.toString() + .includes(condition.value.toString()); + default: + return false; + } +}; + +const matchTextCondition = ( + transaction: UncategorizedCashflowTransaction, + condition: IBankRuleCondition +) => { + switch (condition.comparator) { + case BankRuleConditionComparator.Equals: + return transaction[condition.field] === condition.value; + case BankRuleConditionComparator.Contains: + return transaction[condition.field]?.includes(condition.value.toString()); + case BankRuleConditionComparator.NotContain: + return !transaction[condition.field]?.includes( + condition.value.toString() + ); + default: + return false; + } +}; + +const matchTransactionType = ( + bankRule: BankRule, + transaction: UncategorizedCashflowTransaction +): boolean => { + return ( + (transaction.isDepositTransaction && + bankRule.applyIfTransactionType === + BankRuleApplyIfTransactionType.Deposit) || + (transaction.isWithdrawalTransaction && + bankRule.applyIfTransactionType === + BankRuleApplyIfTransactionType.Withdrawal) + ); +}; + +export const bankRulesMatchTransaction = ( + transaction: UncategorizedCashflowTransaction, + bankRules: IBankRule[] +) => { + return bankRules.find((rule) => { + return ( + matchTransactionType(rule, transaction) && + conditionsMatch(transaction, rule.conditions, rule.conditionsType) + ); + }); +}; + +const determineFieldType = (field: string): string => { + switch (field) { + case 'amount': + return 'number'; + case 'description': + return 'text'; + default: + return 'unknown'; + } +}; diff --git a/packages/server/src/services/Banking/Rules/types.ts b/packages/server/src/services/Banking/Rules/types.ts index ae1151254..0701345f0 100644 --- a/packages/server/src/services/Banking/Rules/types.ts +++ b/packages/server/src/services/Banking/Rules/types.ts @@ -1,5 +1,48 @@ import { Knex } from 'knex'; +export enum BankRuleConditionField { + Amount = 'Amount', + Description = 'Description', +} + +export enum BankRuleConditionComparator { + Contains = 'contains', + Equals = 'equals', + NotContain = 'not_contain'; +} + +export interface IBankRuleCondition { + id?: number; + field: BankRuleConditionField; + comparator: BankRuleConditionComparator; + value: number; +} + +export enum BankRuleConditionType { + Or = 'or', + And = 'and' +} + +export enum BankRuleApplyIfTransactionType { + Deposit = 'deposit', + Withdrawal = 'withdrawal', +} + +export interface IBankRule { + name: string; + order?: number; + applyIfAccountId: number; + applyIfTransactionType: BankRuleApplyIfTransactionType; + + conditionsType: BankRuleConditionType; + conditions: IBankRuleCondition[]; + + assignCategory: BankRuleAssignCategory; + assignAccountId: number; + assignPayee?: string; + assignMemo?: string; +} + export enum BankRuleAssignCategory { InterestIncome = 'InterestIncome', OtherIncome = 'OtherIncome',