Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions finances/document_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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;
}
Expand All @@ -38,8 +38,9 @@ export function yyyymmddDateFromPdf(
pdf: string,
regex: RegExp,
dateFormat: string,
options?: Partial<StringFromLinesOptions>,
): string | DocumentParseError {
const dateStr = stringFromPdf(pdf, regex);
const dateStr = stringFromPdf(pdf, regex, options);
if (isDocumentParseError(dateStr)) {
return dateStr;
}
Expand Down
2 changes: 2 additions & 0 deletions finances/documents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -23,6 +24,7 @@ export const allDocuments = Object.freeze([
payStubMeal,
payStubRegularPay,
payStubShuttle,
pcMastercardStatement,
publicMobileStatement,
questradeMarginStatement,
questradeRESPStatement,
Expand Down
2 changes: 1 addition & 1 deletion finances/documents/kitchener_utilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
109 changes: 109 additions & 0 deletions finances/documents/pcmc.ts
Original file line number Diff line number Diff line change
@@ -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<ParsedPCMastercardStatement>): 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<Readonly<[keyof typeof monthNumberByName, string]>>
>;
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);
}
Comment on lines +54 to +105

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The current implementation of transformMonthNameToNumber is fragile and represents a regression in robustness compared to the previous implementation in finances/pcmc.ts.

Specifically:

  1. It relies on exact substring matching of hardcoded month names (some with dots like 'Jan.', others without like 'May').
  2. If a PDF statement uses a different abbreviation (e.g., 'Sep.' instead of 'Sept.', or 'Jul.' instead of 'July'), or omits/adds a period, the parsing will fail with a DocumentParseError.
  3. The previous implementation was much more robust: it extracted the month name, took the first 3 characters, lowercased them, and mapped them.

We can restore and improve upon that robustness by matching the leading month word with a regular expression, extracting its 3-letter prefix, and replacing it safely using string slicing.

const monthNumberByPrefix: Record<string, string> = {
  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 transformMonthNameToNumber(s: string): string | DocumentParseError {
  const match = s.match(/^([A-Za-z]+)(\\.?)/);
  if (!match) {
    return {
      type: 'DocumentParseError',
      message: 'unrecognized date format: ' + s,
    };
  }

  const [fullMatch, monthStr] = match;
  const prefix = monthStr.toLowerCase().substring(0, 3);
  const monthNumber = monthNumberByPrefix[prefix];
  if (!monthNumber) {
    return {
      type: 'DocumentParseError',
      message:
        'unrecognized month name in date: ' + s + ' ' +
        '(recognized month prefixes are: ' +
        Object.keys(monthNumberByPrefix).join(', ') +
        ')',
    };
  }

  return monthNumber + s.slice(fullMatch.length);
}


export const pcMastercardStatement: Readonly<
Document<ParsedPCMastercardStatement, "PCMastercardStatement">
> = Object.freeze(new PCMastercardStatement());
176 changes: 0 additions & 176 deletions finances/pcmc.ts

This file was deleted.

Loading