feat: auto recognize uncategorized transactions

This commit is contained in:
Ahmed Bouhuolia
2024-06-19 13:49:12 +02:00
parent 0b5cee070a
commit 6c4b0cdac5
9 changed files with 308 additions and 26 deletions

View File

@@ -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');

View File

@@ -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');

View File

@@ -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));
};

View File

@@ -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 [];
}
}

View File

@@ -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',
},
},
};
}

View File

@@ -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
) {}

View File

@@ -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);

View File

@@ -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';
}
};

View File

@@ -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',