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.
This commit is contained in:
Ray Miller 2024-07-13 16:55:52 +01:00
parent ef791b6be6
commit 98910d3306

View file

@ -1,11 +1,17 @@
#!/usr/bin/env -S guile -e main -s #!/usr/bin/env -S guile -e main -s
!# !#
(use-modules (ice-9 match) ;; Script for updating current account and credit card statements
(ice-9 getopt-long) ;; 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)) (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-input-format "%d %b %Y")
(define date-output-format "%Y-%m-%d") (define date-output-format "%Y-%m-%d")
@ -13,31 +19,84 @@
(strftime date-output-format (strftime date-output-format
(car (strptime date-input-format d)))) (car (strptime date-input-format d))))
(define (read-statement path) ;; Characters we expect to see in a numeric amount field. The
(call-with-input-file path ;; Nationwide statements contain a non-ASCII currency character
(lambda (port) ;; that we want to delete.
(dsv->scm port #:format 'rfc4180))))
(define currency-charset (string->char-set "0123456789.-")) (define currency-charset (string->char-set "0123456789.-"))
(define (format-amount s) (define (format-amount s)
(string-filter currency-charset s)) (string-filter currency-charset s))
(define (process-row row) ;; Profiles for the different statemnets.
(match-let (((date description location paid-out paid-in) row)) ;; skip: the number of leading rows to skip
(list (format-date date) ;; header: boolean indicating whether or not the first unskipped
description ;; row is a header
location ;; date-cols: list of columns containing dates
(format-amount paid-out) ;; amount-cols: list columns containing amounts
(format-amount paid-in)))) (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) ;; Predicate for validating the profile option.
(match-let (((_ _ _ _ header . data) (read-statement input-path))) (define (valid-profile? p)
(let ((updated (cons header (map process-row data)))) (if (assoc p profiles) #t #f))
(call-with-output-file output-path
(lambda (port)
(scm->dsv updated port #:format 'rfc4180))))))
;; 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) (define* (usage #:optional errmsg)
(with-output-to-port (current-error-port) (with-output-to-port (current-error-port)
(lambda () (lambda ()
@ -51,22 +110,28 @@ Usage: fix-credit-card-statement [options]
-i, --input=FILENAME Input file path. -i, --input=FILENAME Input file path.
-o, --output=FILENAME Output file path. Required unless --overwrite is given. -o, --output=FILENAME Output file path. Required unless --overwrite is given.
-w, --overwrite Overwrite the input file with the updated data. -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))))) (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) (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)) (input (single-char #\i) (value #t))
(output (single-char #\o) (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)) (options (getopt-long args option-spec))
(help-wanted (option-ref options 'help #f)) (help-wanted (option-ref options 'help #f))
(profile (option-ref options 'profile #f))
(input (option-ref options 'input #f)) (input (option-ref options 'input #f))
(output (option-ref options 'output #f)) (output (option-ref options 'output #f))
(overwrite (option-ref options 'overwrite #f))) (overwrite (option-ref options 'overwrite #f)))
(cond (cond
(help-wanted (usage)) (help-wanted (usage))
((not profile) (usage "profile is required"))
((not input) (usage "input filename is required")) ((not input) (usage "input filename is required"))
((and overwrite output) (usage "output filename cannot be given with --overwrite")) ((and overwrite output) (usage "output filename cannot be given with --overwrite"))
((not (or overwrite output)) (usage "output filename is required without --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))))