Skip to content

Commit

Permalink
feat: auto-categorization
Browse files Browse the repository at this point in the history
  • Loading branch information
joshcanhelp committed Feb 26, 2024
1 parent 17d141f commit 9489849
Show file tree
Hide file tree
Showing 5 changed files with 256 additions and 10 deletions.
19 changes: 16 additions & 3 deletions src/scripts/import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { getTranslator } from "../translators/index.js";
import { DB } from "../utils/storage.js";
import { Configuration } from "../utils/config.js";
import {
autoCategorize,
mapTransaction,
printTransaction,
TransactionComplete,
Expand Down Expand Up @@ -112,15 +113,27 @@ export const run = async (
continue;
}

// Output all values from the imported transaction for inspection
printTransaction(importedTransaction);

// If we're importing a complete transaction, save and on to the next
if (useTranslator.importCompleted) {
db.saveRow(importedTransaction as TransactionComplete);
continue;
}

const autoCategorization = autoCategorize(importedTransaction, config);
if (autoCategorization !== null) {
const mappedTransaction = mapTransaction(
importedTransaction,
autoCategorization
);
printTransaction(mappedTransaction);
if (await promptConfirm("Save this transaction and continue?")) {
db.saveRow(mappedTransaction);
continue;
}
}

// Output all values from the imported transaction for inspection
printTransaction(importedTransaction);
const transactionPrompt = await promptTransaction();

// Force a skipped transaction, no record created in the output file
Expand Down
19 changes: 18 additions & 1 deletion src/utils/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import path from "path";
import { readFileSync } from "fs";
import { CommandArgs } from "../cli.js";
import { getReportYear } from "./date.js";
import { TransactionComplete } from "./transaction.js";

////
/// Data
Expand Down Expand Up @@ -47,13 +48,30 @@ export interface Allowance {
};
}

export interface AutoCategorization {
description?: string;
amount?: {
gt?: number;
gte?: number;
lt?: number;
lte?: number;
};
categorization: {
category: TransactionComplete["category"];
subCategory: TransactionComplete["subCategory"];
expenseType: TransactionComplete["expenseType"];
notes?: string;
};
}

export interface Configuration {
outputFile: string | OutputFiles;
subCategories: SubCategories;
getOutputFile: (args?: CommandArgs) => string;
expenseTypeMapping: { [key: string]: "need" | "want" };
moveFilesAfterImport: { [key: string]: string };
defaultImportDir?: string;
autoCategorization?: AutoCategorization[];
expenseAllowance?: {
[key: string]: Allowance;
};
Expand All @@ -68,7 +86,6 @@ export const getConfiguration = (): Configuration => {
try {
userConfig = readFileSync(configPath, { encoding: "utf8" });
} catch (error: unknown) {
console.log(`🤖 No configuration file found at ${configPath}.`);
return {
...defaultConfig,
getOutputFile: () => defaultConfig.outputFile,
Expand Down
2 changes: 1 addition & 1 deletion src/utils/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export interface TransactionPrompt {
category: TransactionComplete["category"];
subCategory: TransactionComplete["subCategory"];
expenseType: TransactionComplete["expenseType"];
notes: string;
notes?: string;
}

export interface FixPrompt {
Expand Down
177 changes: 177 additions & 0 deletions src/utils/transaction.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { AutoCategorization, Configuration, getConfiguration } from "./config";
import { TransactionImported, autoCategorize } from "./transaction";

describe("Function: autoCategorize", () => {
let mockTransaction: TransactionImported;
let mockConfig: Configuration;
let mockAutoCategorization: AutoCategorization;
beforeAll(() => {
mockTransaction = {
id: "ABC123",
account: "Big Bank",
datePosted: "2024-02-24",
amount: 123.45,
description: "TRANSACTION 123456",
comments: "Comments",
checkNumber: 1234,
};
mockConfig = getConfiguration();
mockAutoCategorization = {
categorization: {
category: "expense",
subCategory: "family",
expenseType: "need",
notes: "__TEST_NOTES__",
},
};
});

it("returns the transaction unchanged if no categorization is found in config", () => {
mockConfig.autoCategorization = undefined;
const result = autoCategorize(mockTransaction, mockConfig);
expect(result).toBeNull();
});

it("auto-categorizes when the description is matched", () => {
mockTransaction.description = "__TEST_DESCRIPTION__";
mockConfig.autoCategorization = [{ ...mockAutoCategorization }];
mockConfig.autoCategorization[0].description = "TEST_DESCRIPTION";

expect(autoCategorize(mockTransaction, mockConfig)).toEqual(
mockConfig.autoCategorization[0].categorization
);
});

it("does not auto-categorize when the description is NOT matched", () => {
mockTransaction.description = "__TEST_DESCRIPTION__";

mockConfig.autoCategorization = [{ ...mockAutoCategorization }];
mockConfig.autoCategorization[0].description = "NOT_MATCHING";

expect(autoCategorize(mockTransaction, mockConfig)).toBeNull();
});

it("auto-categorizes when the amount is matched on greater than", () => {
mockTransaction.amount = 150;

mockConfig.autoCategorization = [{ ...mockAutoCategorization }];
mockConfig.autoCategorization[0].amount = { gt: 100 };

expect(autoCategorize(mockTransaction, mockConfig)).toEqual(
mockConfig.autoCategorization[0].categorization
);
});

it("does not auto-categorize when the amount is NOT matched on greater than", () => {
mockTransaction.amount = 150;

mockConfig.autoCategorization = [{ ...mockAutoCategorization }];
mockConfig.autoCategorization[0].amount = { gt: 200 };

expect(autoCategorize(mockTransaction, mockConfig)).toBeNull();
});

it("auto-categorizes when the amount is matched on greater than or equals", () => {
mockTransaction.amount = 150;

mockConfig.autoCategorization = [{ ...mockAutoCategorization }];
mockConfig.autoCategorization[0].amount = { gte: 150 };

expect(autoCategorize(mockTransaction, mockConfig)).toEqual(
mockConfig.autoCategorization[0].categorization
);
});

it("does not auto-categorize when the amount is NOT matched on greater than or equals", () => {
mockTransaction.amount = 150;

mockConfig.autoCategorization = [{ ...mockAutoCategorization }];
mockConfig.autoCategorization[0].amount = { gte: 150.01 };

expect(autoCategorize(mockTransaction, mockConfig)).toBeNull();
});

it("auto-categorizes when the amount is matched on less than", () => {
mockTransaction.amount = 150;

mockConfig.autoCategorization = [{ ...mockAutoCategorization }];
mockConfig.autoCategorization[0].amount = { lt: 200 };

expect(autoCategorize(mockTransaction, mockConfig)).toEqual(
mockConfig.autoCategorization[0].categorization
);
});

it("does not auto-categorize when the amount is NOT matched on less than", () => {
mockTransaction.amount = 150;

mockConfig.autoCategorization = [{ ...mockAutoCategorization }];
mockConfig.autoCategorization[0].amount = { lt: 150 };

expect(autoCategorize(mockTransaction, mockConfig)).toBeNull();
});

it("auto-categorizes when the amount is matched on less than or equals", () => {
mockTransaction.amount = 150;

mockConfig.autoCategorization = [{ ...mockAutoCategorization }];
mockConfig.autoCategorization[0].amount = { lte: 150 };

expect(autoCategorize(mockTransaction, mockConfig)).toEqual(
mockConfig.autoCategorization[0].categorization
);
});

it("does not auto-categorize when the amount is NOT matched on less than or equals", () => {
mockTransaction.amount = 150;

mockConfig.autoCategorization = [{ ...mockAutoCategorization }];
mockConfig.autoCategorization[0].amount = { lte: 149 };

expect(autoCategorize(mockTransaction, mockConfig)).toBeNull();
});

it("auto-categorizes when description and amount are matched", () => {
mockTransaction.description = "__TEST_DESCRIPTION__";
mockTransaction.amount = 150;

mockConfig.autoCategorization = [{ ...mockAutoCategorization }];
mockConfig.autoCategorization[0].description = "TEST_DESCRIPTION";
mockConfig.autoCategorization[0].amount = {
gt: 100,
lt: 200,
};

expect(autoCategorize(mockTransaction, mockConfig)).toEqual(
mockConfig.autoCategorization[0].categorization
);
});

it("does not auto-categorize when amount matches but description does not", () => {
mockTransaction.description = "__TEST_DESCRIPTION__";
mockTransaction.amount = 150;

mockConfig.autoCategorization = [{ ...mockAutoCategorization }];
mockConfig.autoCategorization[0].description = "NOT_MATCHED";
mockConfig.autoCategorization[0].amount = {
gt: 100,
lt: 200,
};

expect(autoCategorize(mockTransaction, mockConfig)).toBeNull();
});

it("does not auto-categorize when description matches but amount does not", () => {
mockTransaction.description = "__TEST_DESCRIPTION__";
mockTransaction.amount = 250;

mockConfig.autoCategorization = [{ ...mockAutoCategorization }];
mockConfig.autoCategorization[0].description = "TEST_DESCRIPTION";
mockConfig.autoCategorization[0].amount = {
gt: 100,
lt: 200,
};

expect(autoCategorize(mockTransaction, mockConfig)).toBeNull();
});
});
49 changes: 44 additions & 5 deletions src/utils/transaction.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Configuration } from "./config.js";
import { getFormattedDate } from "./date.js";
import { print } from "./index.js";
import { TransactionPrompt } from "./prompt.js";
Expand Down Expand Up @@ -97,10 +98,10 @@ export interface TransactionHeader {

export interface TransactionComplete extends TransactionImported {
dateImported: string;
splitId: number;
category: "expense" | "income" | "omit" | "split" | "skip";
subCategory: string;
expenseType: "need" | "want" | "";
splitId: number;
notes?: string;
}

Expand Down Expand Up @@ -136,7 +137,7 @@ export const mapTransaction = (
return {
...imported,
dateImported: getFormattedDate(),
notes: prompt.notes,
notes: prompt.notes || "",
splitId,
category,
subCategory,
Expand All @@ -155,15 +156,53 @@ export const sortTransactionsByDate = (a: string[], b: string[]): number => {
};

export const printTransaction = (
transaction: TransactionImported | TransactionComplete
transaction: TransactionImported | TransactionComplete | TransactionPrompt
) => {
for (const transProp in transaction) {
const label = transactionHeaders.find(
(header) => header.key === transProp
)?.header;
const value = transaction[transProp as keyof TransactionImported];
const value = transaction[transProp as keyof typeof transaction];
if (value) {
print(`${label || "<unknown>"}: ${value || "<none>"}`);
print(`${label || "<unknown>"}: ${value ? value : "<none>"}`);
}
}
};

export const autoCategorize = (
transaction: TransactionImported,
config: Configuration
): TransactionPrompt | null => {
if (!config.autoCategorization) {
return null;
}

for (const matchCriteria of config.autoCategorization) {
const { description, amount, categorization } = matchCriteria;

let matchedDescription = true;
if (description) {
matchedDescription = transaction.description.includes(description);
}

let matchedAmount = true;
if (typeof amount?.gt === "number") {
matchedAmount = transaction.amount > amount.gt;
}
if (matchedAmount && typeof amount?.gte === "number") {
matchedAmount = transaction.amount >= amount.gte;
}
if (matchedAmount && typeof amount?.lt === "number") {
matchedAmount = transaction.amount < amount.lt;
}
if (matchedAmount && typeof amount?.lte === "number") {
matchedAmount = transaction.amount <= amount.lte;
}

if (matchedDescription && matchedAmount) {
return categorization;
}
}

return null;
};

0 comments on commit 9489849

Please sign in to comment.