diff --git a/.gitignore b/.gitignore index b2d59d1..c3e0bb2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /node_modules -/dist \ No newline at end of file +/dist +*.log \ No newline at end of file diff --git a/data/DodgyTransactions2015.csv b/data/DodgyTransactions2015.csv new file mode 100644 index 0000000..e443ddf --- /dev/null +++ b/data/DodgyTransactions2015.csv @@ -0,0 +1,52 @@ +Date,From,To,Narrative,Amount +01/01/2015,Laura B,Sarah T,Misc Morale,10.54 +04/01/2015,Stephen S,Gergana I,Lunch,3.22 +07/01/2015,Ben B,Jon A,Fantasy Football,9.93 +11/01/2015,Ben B,Todd,Lunch,7.33 +12/01/2015,Laura B,Ben B,Beers,8.94 +15/01/2015,Todd,Rob S,Beers,10.19 +16/01/2015,Laura B,Jon A,Lego Assistance,0.69 +18/01/2015,Jon A,Sarah T,Coffee,10.73 +22/01/2015,Gergana I,Todd,Coffee,0.8 +24/01/2015,Gergana I,Chris W,Coffee,8.49 +27/01/2015,Laura B,Tim L,Jenkins Fees,0.52 +29/01/2015,Sam N,Chris W,Lunch,9.01 +01/02/2015,Stephen S,Laura B,Coffee,7.53 +02/02/2015,Stephen S,Rob S,Fantasy Football,7.43 +04/02/2015,Rob S,Stephen S,Fantasy Football,5.78 +06/02/2015,Dan W,Gergana I,Automated Testing Services,10.29 +07/02/2015,Jon A,Chris W,Rails Consultancy,7.37 +10/02/2015,Sam N,Gergana I,Sandbox Help,9.84 +11/02/2015,Chris W,Stephen S,Misc Morale,0.68 +13/02/2015,Laura B,Gergana I,Beers,6.56 +16/02/2015,Chris W,Dan W,Jenkins Fees,1.28 +17/02/2015,Sarah T,Dan W,Sandbox Help,11.48 +18/02/2015,Stephen S,Rob S,Lego Assistance,4.83 +21/02/2015,Dan W,Ben B,Pokemon Training,0.5 +24/02/2015,Todd,Gergana I,Coffee,9.08 +26/02/2015,Stephen S,Jon A,Pokemon Training,4.23 +01/03/2015,Ben B,Sam N,Lunch,One Cheeseburger +02/03/2015,Todd,Chris W,Sandbox Help,1.92 +04/03/2015,Laura B,Jon A,Sandcastle Help,9.09 +06/03/2015,Stephen S,Gergana I,Lego Assistance,9.4 +08/03/2015,Ben B,Sam N,Pokemon Training,11.32 +11/03/2015,Stephen S,Jon A,Sandcastle Help,11.57 +15/03/2015,Todd,Sam N,Pokemon Training,2.29 +19/03/2015,Laura B,Chris W,Coffee,9.96 +21/03/2015,Jon A,Gergana I,Beers,7.54 +25/03/2015,Gergana I,Ben B,Automated Testing Services,2.39 +28/03/2015,Stephen S,Tim L,Lunch,5.83 +29/03/2015,Gergana I,Ben B,Audit and Other Financial Services,10.85 +30/03/2015,Tim L,Todd,Pokemon Training,8.6 +02/04/2015,Stephen S,Chris W,Pokemon Training,1.11 +05/04/2015,Chris W,Ben B,Lunch,0.96 +09/04/2015,Chris W,Rob S,Audit and Other Financial Services,11.79 +12/04/2015,Gergana I,Laura B,Stationary Items,8.37 +13/04/2015,Laura B,Tim L,Services Rendered,1.27 +15/04/2015,Ben B,Sam N,Lego Assistance,8.5 +19/04/2015,Stephen S,Chris W,White Water Rafting,8.91 +23/04/2015,Tim L,Todd,Misc Morale,8.1 +24/04/2015,Tim L,Sarah T,Arcade Social,6.38 +26/04/2015,Jon A,Todd,Stationary Items,8.44 +27/04/2015,Ben B,Jon A,Pokemon Training,10.91 +Last Thursday,Sarah T,Dan W,Beers,5.42 diff --git a/import/csvService.ts b/import/csvService.ts new file mode 100644 index 0000000..1d5baf0 --- /dev/null +++ b/import/csvService.ts @@ -0,0 +1,22 @@ +import { readFileSync } from "fs"; +import { parse } from "csv-parse/sync"; +import type { TransactionRecord } from "../types/transactionRecord"; +import type { FileDataRecord } from "../types/fileDataRecord"; +import { getParsedObjectsToTransactionRecords } from "../utils/parseObjectToTransactionRecord"; + +const HEADERS = ["Date", "From", "To", "Narrative", "Amount"]; + +export const csvService = (filePath: string): TransactionRecord[] => { + const fileContent = readFileSync(filePath, { encoding: "utf-8" }); + + const records = parse(fileContent, { + delimiter: ",", + columns: HEADERS, + from_line: 2, + }); + + let transactionRecords: TransactionRecord[] = + getParsedObjectsToTransactionRecords(records as FileDataRecord[]); + + return transactionRecords; +}; diff --git a/interface/menu.ts b/interface/menu.ts index 02582ea..06dc467 100644 --- a/interface/menu.ts +++ b/interface/menu.ts @@ -1,5 +1,5 @@ import { AccountManager } from "../account/accountManager"; -import { getTransactionData } from "../utils/getTransactionData"; +import { csvService } from "../import/csvService"; import { convertPenceToPounds } from "../utils/penceConverter"; import { getUserInput } from "../utils/getUserInput"; import type { TransactionRecord } from "../types/transactionRecord"; @@ -83,8 +83,11 @@ export class Menu { }; #loadTransactionRecords = () => { - const transactionRecords = getTransactionData( - "./data/Transactions2014.csv", + // const transactionRecords = getTransactionData( + // "./data/Transactions2014.csv", + // ); + const transactionRecords = csvService( + "./data/DodgyTransactions2015.csv", ); transactionRecords.forEach((record) => { this.accountManager.addTransactionRecord(record); diff --git a/package-lock.json b/package-lock.json index 56296b4..ca255a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "csv-parse": "^6.1.0", "date-fns": "^4.1.0", "lodash": "^4.17.21", + "log4js": "^6.9.1", "ts-node": "^10.9.2" }, "devDependencies": { @@ -593,6 +594,32 @@ "url": "https://github.com/sponsors/kossnocorp" } }, + "node_modules/date-format": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/date-format/-/date-format-4.0.14.tgz", + "integrity": "sha512-39BOQLs9ZjKh0/patS9nrT8wc3ioX3/eA/zgbKNopnF2wCqJEoxywwwElATYvRsXdnOxA/OQeQoFZ3rFjVajhg==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/diff": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", @@ -644,18 +671,75 @@ "@esbuild/win32-x64": "0.25.9" } }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "license": "ISC" + }, + "node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "license": "MIT" }, + "node_modules/log4js": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/log4js/-/log4js-6.9.1.tgz", + "integrity": "sha512-1somDdy9sChrr9/f4UlzhdaGfDR2c/SaD2a4T7qEkG4jTS57/B3qmnjLYePwQ8cqWnUHZI0iAKxMBpCZICiZ2g==", + "license": "Apache-2.0", + "dependencies": { + "date-format": "^4.0.14", + "debug": "^4.3.4", + "flatted": "^3.2.7", + "rfdc": "^1.3.0", + "streamroller": "^3.1.5" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/make-error": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", "license": "ISC" }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/prettier": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", @@ -672,6 +756,26 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/streamroller": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/streamroller/-/streamroller-3.1.5.tgz", + "integrity": "sha512-KFxaM7XT+irxvdqSP1LGLgNWbYN7ay5owZ3r/8t77p+EtSUAfUgtl7be3xtqtOmGUl9K9YPO2ca8133RlTjvKw==", + "license": "MIT", + "dependencies": { + "date-format": "^4.0.14", + "debug": "^4.3.4", + "fs-extra": "^8.1.0" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/ts-node": { "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", @@ -734,6 +838,15 @@ "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==", "license": "MIT" }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", diff --git a/package.json b/package.json index 0714127..ec3c3cd 100644 --- a/package.json +++ b/package.json @@ -12,10 +12,10 @@ }, "license": "ISC", "author": "", - "type": "module", + "type": "commonjs", "main": "index.ts", "scripts": { - "start": "tsc && esbuild index.ts --bundle --format=esm --platform=node --outfile=dist/bundle.js && node dist/bundle.js", + "start": "tsc && esbuild index.ts --bundle --format=cjs --platform=node --outfile=dist/bundle.js && node dist/bundle.js", "test": "echo \"Error: no test specified\" && exit 1", "format": "prettier . --write" }, @@ -29,6 +29,7 @@ "dependencies": { "csv-parse": "^6.1.0", "date-fns": "^4.1.0", + "log4js": "^6.9.1", "lodash": "^4.17.21", "ts-node": "^10.9.2" } diff --git a/types/fileDataRecord.ts b/types/fileDataRecord.ts new file mode 100644 index 0000000..eea5aca --- /dev/null +++ b/types/fileDataRecord.ts @@ -0,0 +1,7 @@ +export interface FileDataRecord { + Date: string; + From: string; + To: string; + Narrative: string; + Amount: string; +} diff --git a/utils/getTransactionData.ts b/utils/getTransactionData.ts deleted file mode 100644 index e040c05..0000000 --- a/utils/getTransactionData.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { readFileSync } from "fs"; -import { parse } from "csv-parse/sync"; -import type { TransactionRecord } from "../types/transactionRecord"; -import { parse as dateParseFns } from "date-fns"; -import { convertPoundsToPence } from "./penceConverter"; - -const HEADERS = ["Date", "From", "To", "Narrative", "Amount"]; - -interface CsvRow { - Date: string; - From: string; - To: string; - Narrative: string; - Amount: string; -} - -function parseDate(dateStr: string): Date { - return dateParseFns(dateStr, "d/M/y", new Date()); -} - -const setTransactionRecordsFromCsvRows = ( - transactionRecords: TransactionRecord[], - csvRows: CsvRow[], -) => { - csvRows.forEach((row: CsvRow) => { - const amountInPence = convertPoundsToPence(parseFloat(row.Amount)); - const record: TransactionRecord = { - Date: parseDate(row.Date), - From: row.From, - To: row.To, - Narrative: row.Narrative, - Amount: amountInPence, - }; - transactionRecords.push(record); - }); -}; - -export const getTransactionData = (filePath: string): TransactionRecord[] => { - let transactionRecords: TransactionRecord[] = []; - - const fileContent = readFileSync(filePath, { encoding: "utf-8" }); - - const records = parse(fileContent, { - delimiter: ",", - columns: HEADERS, - from_line: 2, - }); - - setTransactionRecordsFromCsvRows(transactionRecords, records as CsvRow[]); - - return transactionRecords; -}; diff --git a/utils/logger.ts b/utils/logger.ts new file mode 100644 index 0000000..96e2a71 --- /dev/null +++ b/utils/logger.ts @@ -0,0 +1,18 @@ +import { configure } from "log4js"; + +var loggerConfiguration = configure({ + appenders: { + file: { type: "file", filename: "logs.log" }, + out: { type: "stdout" }, + }, + categories: { + default: { appenders: ["file", "out"], level: "error" }, + }, +}) + +export function getLogger(filename: string): ReturnType +{ + const logger = loggerConfiguration.getLogger(filename); + logger.level = "debug"; + return logger; +} \ No newline at end of file diff --git a/utils/parseObjectToTransactionRecord.ts b/utils/parseObjectToTransactionRecord.ts new file mode 100644 index 0000000..cfa36bb --- /dev/null +++ b/utils/parseObjectToTransactionRecord.ts @@ -0,0 +1,111 @@ +import type { TransactionRecord } from "../types/transactionRecord"; +import type { FileDataRecord } from "../types/fileDataRecord"; +import * as datefns from "date-fns" +import { convertPoundsToPence, convertPenceToPounds } from "./penceConverter"; +import { getLogger } from "./logger"; + +const logger = getLogger("parseObjectToTransactionRecord"); +logger.level = "debug"; + +const EXPECTED_MONETARY_REGEX = /^\d+\.\d\d$/; + +function getTransactionRecordValidityWarnings( + transactionRecord: TransactionRecord, +): string[] { + const warningMessages: string[] = []; + + if (!datefns.isValid(transactionRecord.Date)) { + warningMessages.push( + `Invalid Date: ${transactionRecord.Date} is not valid.`, + ); + } + + if (transactionRecord.From.trim() === "") { + warningMessages.push(`From - Field must not be empty`); + } + + if (transactionRecord.To.trim() === "") { + warningMessages.push(`To - Field must not be empty`); + } + + if (transactionRecord.Narrative.trim() === "") { + warningMessages.push(`Narrative - Field must not be empty`); + } + + return warningMessages; +} + +function parseDate(dateStr: string): Date { + return datefns.parse(dateStr, "d/M/y", new Date()); +} + +function parseDataRecordToTransactionRecords( + record: FileDataRecord, + lineIndexNumber: number, +): TransactionRecord { + const parsedDate = parseDate(record.Date); + + const amountInPence = convertPoundsToPence(parseFloat(record.Amount)); + + const newTransactionRecord: TransactionRecord = { + Date: parsedDate, + From: record.From, + To: record.To, + Narrative: record.Narrative, + Amount: amountInPence, + }; + + const warnings = getTransactionRecordValidityWarnings( + newTransactionRecord, + ); + + if(EXPECTED_MONETARY_REGEX.test(record.Amount) === false){ + warnings.push(`Amount - ${record.Amount} is not in the expected format of a decimal number with two decimal places (e.g., 123.45)`); + } + + if (warnings.length === 0) return newTransactionRecord; + + logger.warn(`Warnings present on line ${lineIndexNumber + 1}`); + warnings.forEach((warning) => { + logger.warn(warning); + }); + + throw new Error("Invalid record found"); +} + +export function getParsedObjectsToTransactionRecords( + dataRecords: FileDataRecord[], +) { + const parsedDataRecords: TransactionRecord[] = []; + + let isAllValid = true; + + for (let i = 0; i < dataRecords.length; i++) { + const row = dataRecords[i]; + if (row == null || row === undefined) { + logger.warn(`No record found on line ${i + 1}`); + isAllValid = false; + continue; + } + try{ + const parsedRecord = parseDataRecordToTransactionRecords(row, i); + parsedDataRecords.push(parsedRecord); + } + catch (e){ + isAllValid = false; + continue; + } + } + + if (!isAllValid) { + logger.warn( + "The above warnings were found while parsing the records. Please fix the issues and try again.", + ); + process.exit(1); + } + + logger.info( + `Successfully parsed ${parsedDataRecords.length} valid records out of ${dataRecords.length} total records.`, + ); + return parsedDataRecords; +}