diff --git a/.github/workflows/finances.yml b/.github/workflows/finances.yml index 7ee86c5..aeef16d 100644 --- a/.github/workflows/finances.yml +++ b/.github/workflows/finances.yml @@ -1,6 +1,7 @@ -name: Finances CI +name: finances on: + workflow_dispatch: push: branches: [main] paths: diff --git a/finances/document_utils.ts b/finances/document_utils.ts new file mode 100644 index 0000000..9b6a3f7 --- /dev/null +++ b/finances/document_utils.ts @@ -0,0 +1,64 @@ +import { isDocumentParseError, type DocumentParseError } from "./document.ts"; +import { parseDateToYYYYMMDD, isParseDateError } from "./date.ts"; + +interface StringFromLinesOptions { + resultPrefix: string; +} + +export function stringFromLines( + lines: readonly string[], + regex: RegExp, + options?: Partial, +): string | DocumentParseError { + for (const line of lines) { + const trimmedLine = line.trim(); + const match = trimmedLine.match(regex); + if (!match) { + continue; + } + + const matchingString = match[1]; + if (typeof matchingString === "undefined") { + throw new Error( + `internal error stdp7xw6rb: regex should have matched line; ` + + `regex=${regex.source}, line=${trimmedLine}`, + ); + } + + const resultPrefix = options?.resultPrefix; + if (typeof resultPrefix === "undefined") { + return matchingString; + } else { + return resultPrefix + matchingString; + } + } + + return { + type: "DocumentParseError", + message: `line not found matching regex: ${regex.source}`, + }; +} + +export function yyyymmddDateFromLines( + lines: readonly string[], + regex: RegExp, + dateFormat: string, +): string | DocumentParseError { + const dateStr = stringFromLines(lines, regex); + if (isDocumentParseError(dateStr)) { + return dateStr; + } + + const date = parseDateToYYYYMMDD(dateFormat, dateStr); + if (isParseDateError(date)) { + const { message } = date; + return { + type: "DocumentParseError", + message: + `unable to parse date "${dateStr}" ` + + `using format "${dateFormat}": ${message}`, + }; + } + + return date; +} diff --git a/finances/documents.ts b/finances/documents.ts index 8625d7b..8fc0b18 100644 --- a/finances/documents.ts +++ b/finances/documents.ts @@ -1,5 +1,7 @@ import { rogersBill } from "./documents/rogers.ts"; import { publicMobileStatement } from "./documents/public_mobile.ts"; +import { kitchenerUtilitiesBill } from "./documents/kitchener_utilities.ts"; +import { morganStanleyRelease } from "./documents/morgan_stanley.ts"; import { questradeMarginStatement, questradeRESPStatement, @@ -7,11 +9,13 @@ import { } from "./documents/questrade.ts"; export const allDocuments = Object.freeze([ + kitchenerUtilitiesBill, + morganStanleyRelease, publicMobileStatement, - rogersBill, questradeMarginStatement, questradeRESPStatement, questradeRRSPStatement, + rogersBill, ]); export type Documents = Exclude<(typeof allDocuments)[number], undefined>; diff --git a/finances/documents/kitchener_utilities.ts b/finances/documents/kitchener_utilities.ts new file mode 100644 index 0000000..179453e --- /dev/null +++ b/finances/documents/kitchener_utilities.ts @@ -0,0 +1,56 @@ +import { + isDocumentParseError, + type Document, + type DocumentParseError, +} from "../document.ts"; +import { stringFromLines, yyyymmddDateFromLines } from "../document_utils.ts"; + +export interface ParsedKitchenerUtilitiesBill { + type: "KitchenerUtilitiesBill"; + statementDate: string; + amountDue: string; +} + +class KitchenerUtilitiesBill implements Document< + ParsedKitchenerUtilitiesBill, + "KitchenerUtilitiesBill" +> { + readonly type = "KitchenerUtilitiesBill" as const; + + identify(lines: readonly string[]): boolean { + return lines.some((line) => line.includes("UTILITIES@KITCHENER.CA")); + } + + calculateFileName(pdf: Readonly): string { + const { statementDate, amountDue } = pdf; + return `${statementDate} Kitchener Utilities Bill ${amountDue}.pdf`; + } + + parse( + lines: readonly string[], + ): ParsedKitchenerUtilitiesBill | DocumentParseError { + const statementDate = yyyymmddDateFromLines( + lines, + /^Statement Date:\s*(\w+\s+\d+\s+\d+)\s+/i, + "MMM D YYYY", + ); + if (isDocumentParseError(statementDate)) { + return statementDate; + } + + const amountDue = stringFromLines( + lines, + /^Pre-authorized Withdrawal:\s*(\d+\.\d+)$/i, + { resultPrefix: "$" }, + ); + if (isDocumentParseError(amountDue)) { + return amountDue; + } + + return { type: "KitchenerUtilitiesBill", statementDate, amountDue }; + } +} + +export const kitchenerUtilitiesBill: Readonly< + Document +> = Object.freeze(new KitchenerUtilitiesBill()); diff --git a/finances/documents/morgan_stanley.ts b/finances/documents/morgan_stanley.ts new file mode 100644 index 0000000..70425c8 --- /dev/null +++ b/finances/documents/morgan_stanley.ts @@ -0,0 +1,108 @@ +import { + isDocumentParseError, + type Document, + type DocumentParseError, +} from "../document.ts"; +import { stringFromLines, yyyymmddDateFromLines } from "../document_utils.ts"; + +export interface ParsedMorganStanleyRelease { + type: "MorganStanleyRelease"; + awardId: string; + settlementDate: string; + vestedValue: string; + saleAmount: string; + sharesSold: string; + salePrice: string; +} + +class MorganStanleyRelease implements Document< + ParsedMorganStanleyRelease, + "MorganStanleyRelease" +> { + readonly type = "MorganStanleyRelease" as const; + + identify(lines: readonly string[]): boolean { + return lines.some((line) => line.toLowerCase() === "release confirmation"); + } + + calculateFileName(pdf: Readonly): string { + const { + awardId, + settlementDate, + vestedValue, + saleAmount, + sharesSold, + salePrice, + } = pdf; + return ( + `${settlementDate} Morgan Stanley Release Confirmation ` + + `${awardId} ${sharesSold} shares vested for ` + + `${vestedValue} sold for ${saleAmount} ` + + `(${salePrice} per share).pdf` + ); + } + + parse( + lines: readonly string[], + ): ParsedMorganStanleyRelease | DocumentParseError { + const awardId = stringFromLines(lines, /^Award ID:\s+([\w\d]+)$/i); + if (isDocumentParseError(awardId)) { + return awardId; + } + + const settlementDate = yyyymmddDateFromLines( + lines, + /^Settlement Date:\s*(\d+-\w+-\d+)$/i, + "DD-MMM-YYYY", + ); + if (isDocumentParseError(settlementDate)) { + return settlementDate; + } + + const vestedValue = stringFromLines( + lines, + /^Total Gain.*:\s*(\$[\d,.]+)$/i, + ); + if (isDocumentParseError(vestedValue)) { + return vestedValue; + } + + const saleAmount = stringFromLines( + lines, + /^Sale PricexQuantity Sold:\s*\((\$[\d,.]+)\)$/i, + ); + if (isDocumentParseError(saleAmount)) { + return saleAmount; + } + + const sharesSold = stringFromLines( + lines, + /^Quantity Sold:\s*\((\d+\.\d{3})0*\)$/i, + ); + if (isDocumentParseError(sharesSold)) { + return sharesSold; + } + + const salePrice = stringFromLines( + lines, + /shares at (\$\d+\.\d{4})0* per share/i, + ); + if (isDocumentParseError(salePrice)) { + return salePrice; + } + + return { + type: "MorganStanleyRelease", + awardId, + settlementDate, + vestedValue, + saleAmount, + sharesSold, + salePrice, + }; + } +} + +export const morganStanleyRelease: Readonly< + Document +> = Object.freeze(new MorganStanleyRelease()); diff --git a/finances/gasbill.ts b/finances/gasbill.ts deleted file mode 100644 index 30083a7..0000000 --- a/finances/gasbill.ts +++ /dev/null @@ -1,173 +0,0 @@ -import * as fs from "fs"; -import * as path from "path"; -import { Command } from "commander"; -import { PDFParse } from "pdf-parse"; - -const program = new Command(); - -async function parsePdfAndPrint(filePath: string): Promise { - if (!fs.existsSync(filePath)) { - console.error(`Error: File not found at path "${filePath}"`); - process.exit(1); - } - - const dataBuffer = fs.readFileSync(filePath); - const parser = new PDFParse({ data: dataBuffer }); - const result = await parser.getText(); - console.log(result.text); - await parser.destroy(); -} - -const MONTH_MAP: Record = { - jan: "01", - feb: "02", - mar: "03", - apr: "04", - may: "05", - jun: "06", - jul: "07", - aug: "08", - sep: "09", - oct: "10", - nov: "11", - dec: "12", -}; - -function formatDate( - monthStrRaw: string, - dayStr: string, - yearStr: string, -): string { - const day = dayStr.padStart(2, "0"); - const monthKey = monthStrRaw.toLowerCase().substring(0, 3); - const month = MONTH_MAP[monthKey]; - if (!month) { - throw new Error(`Invalid month name in date: "${monthStrRaw}"`); - } - return `${yearStr}-${month}-${day}`; -} - -interface ParsedGasBill { - preAuthorizedWithdrawal: string; - issueDate: string; -} - -async function extractInfoFromPdf(filePath: string): Promise { - const dataBuffer = fs.readFileSync(filePath); - const parser = new PDFParse({ data: dataBuffer }); - const result = await parser.getText(); - await parser.destroy(); - - const withdrawalMatch = result.text.match( - /Pre-authorized Withdrawal:\s*([\d.]+)/i, - ); - const issueDateMatch = result.text.match( - /Issue Date:\s*([A-Za-z]+)\s*(\d{1,2})\s*(\d{4})/i, - ); - - if (withdrawalMatch && issueDateMatch) { - const formattedDate = formatDate( - issueDateMatch[1]!, - issueDateMatch[2]!, - issueDateMatch[3]!, - ); - return { - preAuthorizedWithdrawal: `$${parseFloat(withdrawalMatch[1]!).toFixed(2)}`, - issueDate: formattedDate, - }; - } else { - const missingFields: string[] = []; - if (!withdrawalMatch) missingFields.push("Pre-authorized Withdrawal"); - if (!issueDateMatch) missingFields.push("Issue Date"); - throw new Error( - `Failed to parse PDF. Missing fields: ${missingFields.join(", ")}`, - ); - } -} - -async function parsePdfAndExtractInfo(filePath: string): Promise { - if (!fs.existsSync(filePath)) { - console.error(`Error: File not found at path "${filePath}"`); - process.exit(1); - } - - const parsed = await extractInfoFromPdf(filePath); - - console.log(`Pre-authorized Withdrawal: ${parsed.preAuthorizedWithdrawal}`); - console.log(`Issue Date: ${parsed.issueDate}`); -} - -function getNewFilename(parsed: ParsedGasBill): string { - return `${parsed.issueDate} Kitchener Utilities Bill ${parsed.preAuthorizedWithdrawal}`; -} - -async function generateFilenames(filePaths: string[]): Promise { - for (const filePath of filePaths) { - if (!fs.existsSync(filePath)) { - console.error(`Error: File not found at path "${filePath}"`); - process.exit(1); - } - - const parsed = await extractInfoFromPdf(filePath); - const filename = getNewFilename(parsed); - console.log(filename); - } -} - -async function renameFiles(filePaths: string[]): Promise { - for (const filePath of filePaths) { - if (!fs.existsSync(filePath)) { - console.error(`Error: File not found at path "${filePath}"`); - process.exit(1); - } - - const parsed = await extractInfoFromPdf(filePath); - const baseNewName = getNewFilename(parsed); - - const dir = path.dirname(filePath); - const ext = path.extname(filePath); - const newFilename = `${baseNewName}${ext}`; - const newPath = path.join(dir, newFilename); - - console.log(`Renaming ${filePath} to ${newFilename}`); - fs.renameSync(filePath, newPath); - } -} - -program - .name("gasbill") - .description( - "Reads a Kitchener Utilities gas bill PDF and extracts " + - "information from it relevant for financial record keeping", - ) - .version("1.0.0"); - -program - .command("print") - .description("load a PDF and print its text to stdout") - .argument("", "path to the PDF file") - .action(parsePdfAndPrint); - -program - .command("parse") - .description( - "extract financial record information from the PDF file and print it", - ) - .argument("", "path to the PDF file") - .action(parsePdfAndExtractInfo); - -program - .command("filename") - .description("generate standardized filenames from the PDF data") - .argument("", "paths to the PDF files") - .action(generateFilenames); - -program - .command("rename") - .description( - "rename the PDF files to standardized filenames based on their content", - ) - .argument("", "paths to the PDF files") - .action(renameFiles); - -program.parse(process.argv); diff --git a/finances/mspdf.ts b/finances/mspdf.ts deleted file mode 100644 index 2b8910f..0000000 --- a/finances/mspdf.ts +++ /dev/null @@ -1,210 +0,0 @@ -import * as fs from "fs"; -import * as path from "path"; -import { Command } from "commander"; -import { PDFParse } from "pdf-parse"; - -const program = new Command(); - -async function parsePdfAndPrint(filePath: string): Promise { - if (!fs.existsSync(filePath)) { - console.error(`Error: File not found at path "${filePath}"`); - process.exit(1); - } - - const dataBuffer = fs.readFileSync(filePath); - const parser = new PDFParse({ data: dataBuffer }); - const result = await parser.getText(); - console.log(result.text); - await parser.destroy(); -} - -const MONTH_MAP: Record = { - jan: "01", - feb: "02", - mar: "03", - apr: "04", - may: "05", - jun: "06", - jul: "07", - aug: "08", - sep: "09", - oct: "10", - nov: "11", - dec: "12", -}; - -function formatDate(dateStr: string): string { - const parts = dateStr.split("-"); - if (parts.length !== 3) { - throw new Error(`Invalid date format (expected DD-MMM-YYYY): "${dateStr}"`); - } - const [dayStr, monthStrRaw, yearStr] = parts as [string, string, string]; - const day = dayStr.padStart(2, "0"); - const monthStr = monthStrRaw.toLowerCase(); - const year = yearStr; - - const month = MONTH_MAP[monthStr]; - if (!month) { - throw new Error(`Invalid month name in date: "${dateStr}"`); - } - - return `${year}-${month}-${day}`; -} - -interface ParsedPdf { - awardId: string; - settlementDate: string; - vestedValue: string; - saleAmount: string; - sharesSold: string; - salePrice: string; -} - -async function extractInfoFromPdf(filePath: string): Promise { - const dataBuffer = fs.readFileSync(filePath); - const parser = new PDFParse({ data: dataBuffer }); - const result = await parser.getText(); - await parser.destroy(); - - const awardIdMatch = result.text.match(/Award ID:\s*([^\r\n]+)/i); - const settlementDateMatch = result.text.match( - /Settlement Date:\s*([^\r\n]+)/i, - ); - const vestedValueMatch = result.text.match( - /Total Gain \(FMV x Quantity Released\):\s*([^\r\n]+)/i, - ); - const saleAmountMatch = result.text.match( - /Sale PricexQuantity Sold:\s*\(?([^)\r\n]+)\)?/i, - ); - const sharesSoldMatch = result.text.match(/Quantity Sold:\s*\(?([\d.]+)\)?/i); - const salePriceMatch = result.text.match(/shares at \$([\d.]+) per share/i); - - if ( - awardIdMatch && - settlementDateMatch && - vestedValueMatch && - saleAmountMatch && - sharesSoldMatch && - salePriceMatch - ) { - const rawSettlementDate = settlementDateMatch[1]!.trim(); - const formattedSettlementDate = formatDate(rawSettlementDate); - const formattedSalePrice = parseFloat(salePriceMatch[1]!).toFixed(4); - - return { - awardId: awardIdMatch[1]!.trim(), - settlementDate: formattedSettlementDate, - vestedValue: vestedValueMatch[1]!.trim(), - saleAmount: saleAmountMatch[1]!.trim(), - sharesSold: sharesSoldMatch[1]!.trim(), - salePrice: `$${formattedSalePrice}`, - }; - } else { - const missingFields: string[] = []; - if (!awardIdMatch) missingFields.push("Award ID"); - if (!settlementDateMatch) missingFields.push("Settlement Date"); - if (!vestedValueMatch) missingFields.push("Vested Value"); - if (!saleAmountMatch) missingFields.push("Sale Amount"); - if (!sharesSoldMatch) missingFields.push("Shares Sold"); - if (!salePriceMatch) missingFields.push("Sale Price"); - throw new Error( - `Failed to parse PDF. Missing fields: ${missingFields.join(", ")}`, - ); - } -} - -async function parsePdfAndExtractInfo(filePath: string): Promise { - if (!fs.existsSync(filePath)) { - console.error(`Error: File not found at path "${filePath}"`); - process.exit(1); - } - - const parsed = await extractInfoFromPdf(filePath); - - console.log(`Award ID: ${parsed.awardId}`); - console.log(`Settlement Date: ${parsed.settlementDate}`); - console.log(`Vested Value: ${parsed.vestedValue}`); - console.log(`Sale Amount: ${parsed.saleAmount}`); - console.log(`Shares Sold: ${parsed.sharesSold}`); - console.log(`Sale Price: ${parsed.salePrice}`); -} - -function getNewFilename(parsed: ParsedPdf): string { - return ( - `${parsed.settlementDate} Morgan Stanley Release Confirmation ` + - `${parsed.awardId} ${parsed.sharesSold} shares vested for ` + - `${parsed.vestedValue} sold for ${parsed.saleAmount} ` + - `(${parsed.salePrice} per share)` - ); -} - -async function generateFilenames(filePaths: string[]): Promise { - for (const filePath of filePaths) { - if (!fs.existsSync(filePath)) { - console.error(`Error: File not found at path "${filePath}"`); - process.exit(1); - } - - const parsed = await extractInfoFromPdf(filePath); - const filename = getNewFilename(parsed); - console.log(filename); - } -} - -async function renameFiles(filePaths: string[]): Promise { - for (const filePath of filePaths) { - if (!fs.existsSync(filePath)) { - console.error(`Error: File not found at path "${filePath}"`); - process.exit(1); - } - - const parsed = await extractInfoFromPdf(filePath); - const baseNewName = getNewFilename(parsed); - - const dir = path.dirname(filePath); - const ext = path.extname(filePath); - const newFilename = `${baseNewName}${ext}`; - const newPath = path.join(dir, newFilename); - - console.log(`Renaming ${filePath} to ${newFilename}`); - fs.renameSync(filePath, newPath); - } -} - -program - .name("mspdf") - .description( - 'Reads a "Release Confirmation" PDF from Morgan Stanley and extracts ' + - "information from it relevant for income tax reporting", - ) - .version("1.0.0"); - -program - .command("print") - .description("load a PDF and print its text to stdout") - .argument("", "path to the PDF file") - .action(parsePdfAndPrint); - -program - .command("parse") - .description( - "extract tax reporting information from the PDF file and print it", - ) - .argument("", "path to the PDF file") - .action(parsePdfAndExtractInfo); - -program - .command("filename") - .description("generate standardized filenames from the PDF data") - .argument("", "paths to the PDF files") - .action(generateFilenames); - -program - .command("rename") - .description( - "rename the PDF files to standardized filenames based on their content", - ) - .argument("", "paths to the PDF files") - .action(renameFiles); - -program.parse(process.argv);