From 98910d33069ed65888a301de6f5e0e7c2144a114 Mon Sep 17 00:00:00 2001 From: Ray Miller Date: Sat, 13 Jul 2024 16:55:52 +0100 Subject: [PATCH] Make Nationwide statement processing data-driven. Instead of different scripts for credit card and current account statements, define a profile for each that specifies the date and amount columns, and have this control the processing. --- guile/fix-nationwide-statement.scm | 113 +++++++++++++++++++++++------ 1 file changed, 89 insertions(+), 24 deletions(-) diff --git a/guile/fix-nationwide-statement.scm b/guile/fix-nationwide-statement.scm index 60be247..cc45410 100755 --- a/guile/fix-nationwide-statement.scm +++ b/guile/fix-nationwide-statement.scm @@ -1,11 +1,17 @@ #!/usr/bin/env -S guile -e main -s !# -(use-modules (ice-9 match) - (ice-9 getopt-long) +;; Script for updating current account and credit card statements +;; downloaded from Nationwide, who use a date and currency format +;; that Gnucash does not support. + +(use-modules (ice-9 getopt-long) + ((srfi srfi-1) #:select (drop)) (dsv)) - +;; Date appears in Nationwide statements in the format +;; "10 Jan 2024", but this is not understood by Gnucash +;; so we convert it to YYYY-MM-DD format. (define date-input-format "%d %b %Y") (define date-output-format "%Y-%m-%d") @@ -13,31 +19,84 @@ (strftime date-output-format (car (strptime date-input-format d)))) -(define (read-statement path) - (call-with-input-file path - (lambda (port) - (dsv->scm port #:format 'rfc4180)))) - +;; Characters we expect to see in a numeric amount field. The +;; Nationwide statements contain a non-ASCII currency character +;; that we want to delete. (define currency-charset (string->char-set "0123456789.-")) (define (format-amount s) (string-filter currency-charset s)) -(define (process-row row) - (match-let (((date description location paid-out paid-in) row)) - (list (format-date date) - description - location - (format-amount paid-out) - (format-amount paid-in)))) +;; Profiles for the different statemnets. +;; skip: the number of leading rows to skip +;; header: boolean indicating whether or not the first unskipped +;; row is a header +;; date-cols: list of columns containing dates +;; amount-cols: list columns containing amounts +(define profiles + '(("credit-card" . ((skip . 4) + (header . #t) + (date-cols . (0)) + (amount-cols . (3 4)))) + ("current-account" . ((skip . 4) + (header . #t) + (date-cols . (0)) + (amount-cols . (3 4 5)))))) -(define (process-statement input-path output-path) - (match-let (((_ _ _ _ header . data) (read-statement input-path))) - (let ((updated (cons header (map process-row data)))) - (call-with-output-file output-path - (lambda (port) - (scm->dsv updated port #:format 'rfc4180)))))) +;; Predicate for validating the profile option. +(define (valid-profile? p) + (if (assoc p profiles) #t #f)) +;; Update a list by applying the given function to each of the +;; listed columns. +(define (update-list lst cols f) + (for-each (lambda (k) + (let ((v (list-ref lst k))) + (list-set! lst k (f v)))) + cols)) + +;; Given a spec listing the date and amount columns, return a +;; function that will apply the corresponding formats to a row. +(define (process-row spec) + (let ((date-cols (assq-ref spec 'date-cols)) + (amount-cols (assq-ref spec 'amount-cols))) + (lambda (row) + (when date-cols + (update-list row date-cols format-date)) + (when amount-cols + (update-list row amount-cols format-amount))))) + +;; Read a CSV from the given path. +(define (read-statement path) + (call-with-input-file path + (lambda (port) + (dsv->scm port #:format 'rfc4180)))) + +;; Write data to the given path in CSV format. +(define (write-statement data path) + (call-with-output-file path + (lambda (port) + (scm->dsv data port #:format 'rfc4180)))) + + +(define (update-data spec data) + (let* ((data (drop data (or (assq-ref spec 'skip) 0))) + (header (if (assq-ref spec 'header) (car data) #f)) + (data (if header (cdr data) data))) + (for-each (process-row spec) data) + (if header + (cons header data) + data))) + +;; Apply the updates defined in `spec` to the statement read +;; from input-path and write the updated data to output-path. +(define (process-statement spec input-path output-path) + (let ((data (read-statement input-path))) + (write-statement (update-data spec data) output-path))) + +;; Display a usage message and (optional) error message to STDERR +;; and exit. If an error message is given the exit code will be +;; non-zero. (define* (usage #:optional errmsg) (with-output-to-port (current-error-port) (lambda () @@ -51,22 +110,28 @@ Usage: fix-credit-card-statement [options] -i, --input=FILENAME Input file path. -o, --output=FILENAME Output file path. Required unless --overwrite is given. -w, --overwrite Overwrite the input file with the updated data. + -p, --profile=PROFILE Profile name [credit-card|current-account]. ") (exit (if errmsg EXIT_FAILURE EXIT_SUCCESS))))) +;; Process command-line arguments and validate options. +;; If valid, run process-statement with the given options. (define (main args) - (let* ((option-spec '((help (single-char #\h) (value #f)) + (let* ((option-spec `((help (single-char #\h) (value #f)) (input (single-char #\i) (value #t)) (output (single-char #\o) (value #t)) - (overwrite (single-char #\w) (value #f)))) + (overwrite (single-char #\w) (value #f)) + (profile (single-char #\p) (value #t) (predicate ,valid-profile?)))) (options (getopt-long args option-spec)) (help-wanted (option-ref options 'help #f)) + (profile (option-ref options 'profile #f)) (input (option-ref options 'input #f)) (output (option-ref options 'output #f)) (overwrite (option-ref options 'overwrite #f))) (cond (help-wanted (usage)) + ((not profile) (usage "profile is required")) ((not input) (usage "input filename is required")) ((and overwrite output) (usage "output filename cannot be given with --overwrite")) ((not (or overwrite output)) (usage "output filename is required without --overwrite"))) - (process-statement input (or output input)))) + (process-statement (assoc-ref profiles profile) input (or output input))))