From 156e0d5537b005bc25c8a83532f383a20ad7d1ae Mon Sep 17 00:00:00 2001 From: denver Date: Tue, 23 Jun 2026 10:16:45 -0400 Subject: [PATCH] pcmc.ts converted to new format --- finances/document_utils.ts | 11 +- finances/documents.ts | 2 + finances/documents/kitchener_utilities.ts | 2 +- finances/documents/pcmc.ts | 109 ++++++++++++++ finances/pcmc.ts | 176 ---------------------- 5 files changed, 118 insertions(+), 182 deletions(-) create mode 100644 finances/documents/pcmc.ts delete mode 100644 finances/pcmc.ts diff --git a/finances/document_utils.ts b/finances/document_utils.ts index 3e58e65..13753a4 100644 --- a/finances/document_utils.ts +++ b/finances/document_utils.ts @@ -2,7 +2,7 @@ import { isDocumentParseError, type DocumentParseError } from "./document.ts"; import { parseDateToYYYYMMDD, isParseDateError } from "./date.ts"; interface StringFromLinesOptions { - resultPrefix: string; + resultTransform: (matchingString: string) => string | DocumentParseError; } export function stringFromPdf( @@ -26,9 +26,9 @@ export function stringFromPdf( ); } - const resultPrefix = options?.resultPrefix; - if (typeof resultPrefix !== "undefined") { - return resultPrefix + matchingString; + const resultTransform = options?.resultTransform; + if (typeof resultTransform !== "undefined") { + return resultTransform(matchingString); } else { return matchingString; } @@ -38,8 +38,9 @@ export function yyyymmddDateFromPdf( pdf: string, regex: RegExp, dateFormat: string, + options?: Partial, ): string | DocumentParseError { - const dateStr = stringFromPdf(pdf, regex); + const dateStr = stringFromPdf(pdf, regex, options); if (isDocumentParseError(dateStr)) { return dateStr; } diff --git a/finances/documents.ts b/finances/documents.ts index af33b3e..f54f1dd 100644 --- a/finances/documents.ts +++ b/finances/documents.ts @@ -8,6 +8,7 @@ import { payStubRegularPay, payStubShuttle, } from "./documents/paystub.ts"; +import { pcMastercardStatement } from "./documents/pcmc.ts"; import { morganStanleyRelease } from "./documents/morgan_stanley.ts"; import { questradeMarginStatement, @@ -23,6 +24,7 @@ export const allDocuments = Object.freeze([ payStubMeal, payStubRegularPay, payStubShuttle, + pcMastercardStatement, publicMobileStatement, questradeMarginStatement, questradeRESPStatement, diff --git a/finances/documents/kitchener_utilities.ts b/finances/documents/kitchener_utilities.ts index aa0e22e..48becc4 100644 --- a/finances/documents/kitchener_utilities.ts +++ b/finances/documents/kitchener_utilities.ts @@ -37,7 +37,7 @@ class KitchenerUtilitiesBill implements Document< const amountDue = stringFromPdf( pdf, /^Pre-authorized Withdrawal:\s*(\d+\.\d+)$/im, - { resultPrefix: "$" }, + { resultTransform: (s) => `$${s}` }, ); if (isDocumentParseError(amountDue)) { return amountDue; diff --git a/finances/documents/pcmc.ts b/finances/documents/pcmc.ts new file mode 100644 index 0000000..968bf6d --- /dev/null +++ b/finances/documents/pcmc.ts @@ -0,0 +1,109 @@ +import type { Document, DocumentParseError } from "../document.ts"; +import { isDocumentParseError } from "../document.ts"; +import { stringFromPdf, yyyymmddDateFromPdf } from "../document_utils.ts"; + +export interface ParsedPCMastercardStatement { + type: "PCMastercardStatement"; + statementDate: string; + purchases: string; +} + +class PCMastercardStatement implements Document< + ParsedPCMastercardStatement, + "PCMastercardStatement" +> { + readonly type = "PCMastercardStatement" as const; + + identify(pdf: string): boolean { + const regex = /^President's Choice Financial Mastercard\W/im; + return regex.test(pdf); + } + + calculateFileName(pdf: Readonly): string { + const { statementDate, purchases } = pdf; + return `${statementDate} PC MasterCard Statement ${purchases}.pdf`; + } + + parse(pdf: string): ParsedPCMastercardStatement | DocumentParseError { + const statementDate = yyyymmddDateFromPdf( + pdf, + /^Statement date:\s*(\w+\.?\s+\d+,\s+\d+)$/im, + "MM D, YYYY", + { resultTransform: transformMonthNameToNumber }, + ); + if (isDocumentParseError(statementDate)) { + return statementDate; + } + + const purchases = stringFromPdf( + pdf, + /^\+\s*Purchases\s+(\$[\d,]+\.\d+)$/im, + ); + if (isDocumentParseError(purchases)) { + return purchases; + } + + return { + type: "PCMastercardStatement", + statementDate, + purchases, + }; + } +} + +const monthNumberByName = Object.freeze({ + "Jan.": "01", + "Feb.": "02", + "Mar.": "03", + "Apr.": "04", + May: "05", + June: "06", + July: "07", + "Aug.": "08", + "Sept.": "09", + "Oct.": "10", + "Nov.": "11", + "Dec.": "12", +} as const); + +function transformMonthNameToNumber(s: string): string | DocumentParseError { + const replacements: Array<{ monthName: string; monthNumber: string }> = []; + + const months = Object.entries(monthNumberByName) as Readonly< + Array> + >; + for (const [monthName, monthNumber] of months) { + if (s.includes(monthName)) { + replacements.push({ monthName, monthNumber }); + } + } + + const replacement = replacements[0]; + if (!replacement) { + return { + type: "DocumentParseError", + message: + `unrecognized month name in date: ${s} ` + + `(recognized month names are: ` + + Object.getOwnPropertyNames(monthNumberByName).join(", ") + + `)`, + }; + } else if (replacements.length > 1) { + return { + type: "DocumentParseError", + message: + `${replacements.length} month names recognized in date, ` + + `but expected exactly 1: ${s} ` + + `(recognized month names: ` + + replacements.map((entry) => entry.monthName).join(", ") + + `)`, + }; + } + + const { monthName, monthNumber } = replacement; + return s.replace(monthName, monthNumber); +} + +export const pcMastercardStatement: Readonly< + Document +> = Object.freeze(new PCMastercardStatement()); diff --git a/finances/pcmc.ts b/finances/pcmc.ts deleted file mode 100644 index 39c8e38..0000000 --- a/finances/pcmc.ts +++ /dev/null @@ -1,176 +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 ParsedPcmc { - statementDate: string; - purchases: 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 statementDateMatch = result.text.match( - /Statement date:\s*([A-Za-z]+)\.?\s*(\d{1,2}),\s*(\d{4})/i, - ); - const purchasesMatch = result.text.match(/^\+\s*Purchases\s+\$?([\d,.]+)/im); - - if (statementDateMatch && purchasesMatch) { - const formattedDate = formatDate( - statementDateMatch[1]!, - statementDateMatch[2]!, - statementDateMatch[3]!, - ); - return { - statementDate: formattedDate, - purchases: `$${purchasesMatch[1]!.trim()}`, - }; - } else { - const missingFields: string[] = []; - if (!statementDateMatch) missingFields.push("Statement Date"); - if (!purchasesMatch) missingFields.push("Purchases"); - 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); - } - - try { - const parsed = await extractInfoFromPdf(filePath); - console.log(`Statement Date: ${parsed.statementDate}`); - console.log(`Purchases: ${parsed.purchases}`); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error(`Error: ${message}`); - process.exit(1); - } -} - -function getNewFilename(parsed: ParsedPcmc): string { - return `${parsed.statementDate} PC MasterCard Statement ${parsed.purchases}`; -} - -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("pcmc") - .description( - "Reads a PC MasterCard statement 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 PC MasterCard statement 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);