Make CSV file an argument, handle blank amounts and add non-zero PFML warning

Was producing an error due to new PFML field with blank amounts.
This commit is contained in:
Ben Sturmfels 2026-02-13 13:36:15 +11:00
parent 2cb7c3a3ad
commit 23cc4657ee
Signed by: bsturmfels
GPG key ID: 023C05E2C9C068F0
7 changed files with 98 additions and 25 deletions

View file

@ -18,13 +18,13 @@ Run a demo with two example employees, Jack and Jill Citizen:
Provide your own payroll data with: Provide your own payroll data with:
java -jar payroll-importer-x.x.x-standalone.jar --csv resources/example-paychex-pay-item-details.csv --total-fees 206.50 java -jar payroll-importer-x.x.x-standalone.jar --total-fees 206.50 resources/example-paychex-pay-item-details.csv
In the above, various values such as the date, time period covered and In the above, various values such as the date, time period covered and
receipt/invoice values show "TODO" placeholders that you are expected to fill in receipt/invoice values show "TODO" placeholders that you are expected to fill in
later. If you prefer, you can provide any/all of these explicitly: later. If you prefer, you can provide any/all of these explicitly:
java -jar payroll-importer-x.x.x-standalone.jar --csv resources/example-paychex-pay-item-details.csv --date 2023-12-29 --period 'December 2023' --total-fees 206.50 --pay-receipt-no rt:19462/674660 --pay-invoice-no rt:19403/675431 --fees-receipt-no rt:19459/675387 --fees-invoice-no rt:19459/674887 --retirement-receipt-no rt:19403/676724 --retirement-invoice-no rt:19403/675431 java -jar payroll-importer-x.x.x-standalone.jar --date 2023-12-29 --period 'December 2023' --total-fees 206.50 --pay-receipt-no rt:19462/674660 --pay-invoice-no rt:19403/675431 --fees-receipt-no rt:19459/675387 --fees-invoice-no rt:19459/674887 --retirement-receipt-no rt:19403/676724 --retirement-invoice-no rt:19403/675431 resources/example-paychex-pay-item-details.csv
You can test the output in Beancount by adding the following header entries to define the accounts: You can test the output in Beancount by adding the following header entries to define the accounts:
@ -64,7 +64,7 @@ Run tests with:
You can run without building using: You can run without building using:
bin/dev --csv resources/example-paychex-pay-item-details.csv --total-fees 206.50 bin/dev --total-fees 206.50 resources/example-paychex-pay-item-details.csv
The project is set up for development in Emacs and CIDER-mode. Open a source The project is set up for development in Emacs and CIDER-mode. Open a source
file and run `cider-jack-in`. file and run `cider-jack-in`.

View file

@ -4,7 +4,12 @@
org.clojure/data.csv {:mvn/version "1.0.1"} org.clojure/data.csv {:mvn/version "1.0.1"}
org.clojure/tools.cli {:mvn/version "1.1.230"}} org.clojure/tools.cli {:mvn/version "1.1.230"}}
:aliases :aliases
{:dev {:extra-deps {lambdaisland/deep-diff2 {:mvn/version "2.10.211"}}} {:dev {:extra-deps {lambdaisland/deep-diff2 {:mvn/version "2.10.211"}}
:main-opts ["-m" "core"]
;; Saves ~ 1 second of startup time - 1.5 sec on my laptop. After
;; building an uberjar and running with these options, it drops to about
;; 750ms.
:jvm-opts ["-XX:TieredStopAtLevel=1" "-XX:+TieredCompilation"]}
:test {:extra-deps {lambdaisland/kaocha {:mvn/version "1.87.1366"}} :test {:extra-deps {lambdaisland/kaocha {:mvn/version "1.87.1366"}}
:main-opts ["-m" "kaocha.runner"]} :main-opts ["-m" "kaocha.runner"]}
;; Run with clj -T:build function-in-build ;; Run with clj -T:build function-in-build

View file

@ -5,8 +5,7 @@
(specifications->manifest (specifications->manifest
(list (list
;; No issues running this OpenJDK 21 program on Debian Stable (OpenJDK 17). ;; No issues running this OpenJDK 21 program on Debian Stable (OpenJDK 17).
"openjdk@21" "openjdk@21:jdk"
;; Works fine with clojure-tools from Guix.
"clojure-tools" "clojure-tools"
"clj-kondo" "clj-kondo"
"beancount" "beancount"

View file

@ -0,0 +1,29 @@
Example Co Inc,"Citizen, Jack A",1,403b EE Pretax,Retirement,,,,,,1000,,,,,
Example Co Inc,"Citizen, Jack A",1,Exp Reimb Non Tax,Reimbursement,,,,,50,,,,,,
Example Co Inc,"Citizen, Jack A",1,Fed Income Tax,Withholding,,,,,,,,470.22,,,
Example Co Inc,"Citizen, Jack A",1,Fed Unemploy,Liability Expense,,,,,,,,,0,,
Example Co Inc,"Citizen, Jack A",1,Medicare,Withholding,,,,,,,,88,,,
Example Co Inc,"Citizen, Jack A",1,Medicare,Liability Expense,,,,,,,,,88,,
Example Co Inc,"Citizen, Jack A",1,Net Pay,Net Pay,,,,,,,,,,,4184.49
Example Co Inc,"Citizen, Jack A",1,Salary,Earnings,6068.99,,0,,,,,,,,
Example Co Inc,"Citizen, Jack A",1,Social Security,Withholding,,,,,,,,376.28,,,
Example Co Inc,"Citizen, Jack A",1,Social Security,Liability Expense,,,,,,,,,376.28,,
Example Co Inc,"Citizen, Jack A",1,TN Unemploy,Liability Expense,,,,,,,,,0,,
Example Co Inc,"Citizen, Jill B",2,403b EE Pretax,Retirement,,,,,,820,,,,,
Example Co Inc,"Citizen, Jill B",2,Exp Reimb Non Tax,Reimbursement,,,,,50,,,,,,
Example Co Inc,"Citizen, Jill B",2,Fed Income Tax,Withholding,,,,,,,,681.01,,,
Example Co Inc,"Citizen, Jill B",2,Fed Unemploy,Liability Expense,,,,,,,,,0,,
Example Co Inc,"Citizen, Jill B",2,Medicare,Liability Expense,,,,,,,,,99.28,,
Example Co Inc,"Citizen, Jill B",2,Medicare,Withholding,,,,,,,,99.29,,,
Example Co Inc,"Citizen, Jill B",2,Net Pay,Net Pay,,,,,,,,,,,4397.39
Example Co Inc,"Citizen, Jill B",2,OR Disability PFL,Withholding,,,,,,,,41.08,,,
Example Co Inc,"Citizen, Jill B",2,OR Disability PFL,Liability Expense,,,,,,,,,0,,
Example Co Inc,"Citizen, Jill B",2,OR EE Work Bene,Withholding,,,,,,,,0,,,
Example Co Inc,"Citizen, Jill B",2,OR ER Work Bene,Liability Expense,,,,,,,,,0,,
Example Co Inc,"Citizen, Jill B",2,OR Income Tax,Withholding,,,,,,,,427.8,,,
Example Co Inc,"Citizen, Jill B",2,OR TRANS STT,Withholding,,,,,,,,6.03,,,
Example Co Inc,"Citizen, Jill B",2,OR Unemploy,Liability Expense,,,,,,,,,0,,
Example Co Inc,"Citizen, Jill B",2,PFML ER PU,Fringe Benefits,,,,,,,,,,,
Example Co Inc,"Citizen, Jill B",2,Salary,Earnings,6847.12,,0,,,,,,,,
Example Co Inc,"Citizen, Jill B",2,Social Security,Liability Expense,,,,,,,,,424.52,,
Example Co Inc,"Citizen, Jill B",2,Social Security,Withholding,,,,,,,,424.52,,,
1 Example Co Inc Citizen, Jack A 1 403b EE Pretax Retirement 1000
2 Example Co Inc Citizen, Jack A 1 Exp Reimb Non Tax Reimbursement 50
3 Example Co Inc Citizen, Jack A 1 Fed Income Tax Withholding 470.22
4 Example Co Inc Citizen, Jack A 1 Fed Unemploy Liability Expense 0
5 Example Co Inc Citizen, Jack A 1 Medicare Withholding 88
6 Example Co Inc Citizen, Jack A 1 Medicare Liability Expense 88
7 Example Co Inc Citizen, Jack A 1 Net Pay Net Pay 4184.49
8 Example Co Inc Citizen, Jack A 1 Salary Earnings 6068.99 0
9 Example Co Inc Citizen, Jack A 1 Social Security Withholding 376.28
10 Example Co Inc Citizen, Jack A 1 Social Security Liability Expense 376.28
11 Example Co Inc Citizen, Jack A 1 TN Unemploy Liability Expense 0
12 Example Co Inc Citizen, Jill B 2 403b EE Pretax Retirement 820
13 Example Co Inc Citizen, Jill B 2 Exp Reimb Non Tax Reimbursement 50
14 Example Co Inc Citizen, Jill B 2 Fed Income Tax Withholding 681.01
15 Example Co Inc Citizen, Jill B 2 Fed Unemploy Liability Expense 0
16 Example Co Inc Citizen, Jill B 2 Medicare Liability Expense 99.28
17 Example Co Inc Citizen, Jill B 2 Medicare Withholding 99.29
18 Example Co Inc Citizen, Jill B 2 Net Pay Net Pay 4397.39
19 Example Co Inc Citizen, Jill B 2 OR Disability PFL Withholding 41.08
20 Example Co Inc Citizen, Jill B 2 OR Disability PFL Liability Expense 0
21 Example Co Inc Citizen, Jill B 2 OR EE Work Bene Withholding 0
22 Example Co Inc Citizen, Jill B 2 OR ER Work Bene Liability Expense 0
23 Example Co Inc Citizen, Jill B 2 OR Income Tax Withholding 427.8
24 Example Co Inc Citizen, Jill B 2 OR TRANS STT Withholding 6.03
25 Example Co Inc Citizen, Jill B 2 OR Unemploy Liability Expense 0
26 Example Co Inc Citizen, Jill B 2 PFML ER PU Fringe Benefits
27 Example Co Inc Citizen, Jill B 2 Salary Earnings 6847.12 0
28 Example Co Inc Citizen, Jill B 2 Social Security Liability Expense 424.52
29 Example Co Inc Citizen, Jill B 2 Social Security Withholding 424.52

View file

@ -6,17 +6,15 @@
(:require [clojure.java.io :as io] (:require [clojure.java.io :as io]
[clojure.set :as set] [clojure.set :as set]
[clojure.string :as str] [clojure.string :as str]
[clojure.tools.cli :refer [parse-opts]] [clojure.tools.cli :as cli]
[import :as import]) [import :as import])
(:gen-class)) (:gen-class))
(def cli-options (def cli-options
[[nil "--csv FILE" "Pay Item Details CSV report" [[nil "--date DATE" "Date used for the transactions (YYYY-MM-DD)"
:validate [#(-> % io/file .exists) "File does not exist"]]
[nil "--date DATE" "Date used for the transactions (YYYY-MM-DD)"
:validate [#(re-matches #"\d{4}-\d{2}-\d{2}" %) "Must be of format YYYY-MM-DD"] :validate [#(re-matches #"\d{4}-\d{2}-\d{2}" %) "Must be of format YYYY-MM-DD"]
:default "TODO-DATE"] :default "TODO-DATE"]
[nil "--period PERIOD" "Month/year covered by the pay run eg. \"December 2023\"" [nil "--period PERIOD" "Month/year of the pay run eg. \"December 2023\""
:default "TODO-PERIOD"] :default "TODO-PERIOD"]
[nil "--total-fees NUM" "Total fee charged by Paychex, eg. \"206.50\"" [nil "--total-fees NUM" "Total fee charged by Paychex, eg. \"206.50\""
:parse-fn bigdec :parse-fn bigdec
@ -33,13 +31,13 @@
:default "TODO-RETIREMENT-RECEIPT"] :default "TODO-RETIREMENT-RECEIPT"]
[nil "--retirement-invoice-no REFERENCE" "Retirement receipt number, eg. \"rt:111/222\"" [nil "--retirement-invoice-no REFERENCE" "Retirement receipt number, eg. \"rt:111/222\""
:default "TODO-RETIREMENT-INVOICE"] :default "TODO-RETIREMENT-INVOICE"]
[nil "--project EMPLOYEE:PROJECT" "Allocate an employee to a specific project, eg. \"Doe-Jane:Outreachy\". Use once for each employee." [nil "--project EMPLOYEE:PROJECT" "Allocate employee to project, eg. \"Doe-Jane:Outreachy\""
:multi true :multi true
:validate [#(= 2 (count %)) "Must be of the form \"name:project\""] :validate [#(= 2 (count %)) "Must be of the form \"name:project\""]
:parse-fn #(str/split % #":") :parse-fn #(str/split % #":")
:default {} :default {}
:assoc-fn (fn [m k [name proj]] (assoc-in m [k name] proj))] :assoc-fn (fn [m k [name proj]] (assoc-in m [k name] proj))]
[nil "--demo" "Produce demo output based made-up payroll data. Useful for documentation."] [nil "--demo" "Produce demo output based made-up payroll data"]
["-h" "--help"]]) ["-h" "--help"]])
(defn unmatched-employees (defn unmatched-employees
@ -67,32 +65,49 @@
(import/taxes-ach-debit date period pay-receipt-no pay-invoice-no project records) (import/taxes-ach-debit date period pay-receipt-no pay-invoice-no project records)
(import/fees date period fees-receipt-no fees-invoice-no total-fees project records) (import/fees date period fees-receipt-no fees-invoice-no total-fees project records)
(import/retirement date period retirement-receipt-no retirement-invoice-no records)) (import/retirement date period retirement-receipt-no retirement-invoice-no records))
unmatched (unmatched-employees records project)] unmatched (unmatched-employees records project)
[imported unmatched])) warnings (import/warnings records)]
[imported unmatched warnings]))
(defn usage [summary]
(str
"Usage: java -jar payroll-importer.jar [OPTIONS] [CSV]\n\n"
"Options include:\n\n"
summary
"\n\n"
"Use --project once for each employee."))
(defn -main (defn -main
"Run the CLI interface." "Run the CLI interface."
[& args] [& args]
(let [{:keys [options errors summary]} (parse-opts args cli-options) (let [{:keys [options arguments errors summary]} (cli/parse-opts args cli-options)
no-csv-or-demo? (not (or (contains? options :csv) (contains? options :demo))) csv (first arguments)
options (assoc options :csv csv)
demo? (contains? options :demo)
neither-csv-or-demo? (and (not csv) (not demo?))
csv-doesnt-exist? (and (not demo?) csv (not (-> csv io/file .exists)))
errors (cond-> errors errors (cond-> errors
no-csv-or-demo? (conj "Please provide a CSV file with \"--csv FILE\" or try \"--demo\""))] neither-csv-or-demo? (conj "Please provide a CSV file argument or try \"--demo\"")
csv-doesnt-exist? (conj (str "CSV file \"" csv "\" does not exist")))]
(when (:help options) (when (:help options)
(println summary) (println (usage summary))
(System/exit 0)) (System/exit 0))
(when errors (when errors
(println (println
(str "The following errors occurred:\n\n" (str "The following errors occurred:\n\n"
(str/join \newline errors))) (str/join \newline errors)))
(System/exit 1)) (System/exit 1))
(let [[imported unmatched] (run options)] (let [[imported unmatched warnings] (run options)]
(when (seq unmatched) (when (seq unmatched)
(println (println
(str "Could not find these employees in the payroll:\n\n" (str "Could not find these employees in the payroll:\n\n"
(str/join ", " unmatched))) (str/join ", " unmatched)))
(System/exit 1)) (System/exit 1))
(doseq [i imported] (doseq [i imported]
(println (import/render-transaction i)))))) (println (import/render-transaction i)))
(when (seq warnings)
(.println System/err "WARNINGS:\n")
(.println System/err (str/join "/n" warnings))))))
(comment (comment
;; Examples to exercise the importer during development. ;; Examples to exercise the importer during development.
@ -103,7 +118,8 @@
;; These examples are not included with the code for privacy reasons. ;; These examples are not included with the code for privacy reasons.
(require '[examples]) (require '[examples])
(def records (import/read-csv "/home/ben/Downloads/2024-01-29_Pay-Item-Details_2024-01.csv")) (def records (import/read-csv "/home/ben/downloads/2026-01-28_Pay-Item-Details_2026-01.csv"))
(def imported (def imported
(concat (import/net-pay "2024-01-31" "January 2024" "rt:19462/685751" {} records) (concat (import/net-pay "2024-01-31" "January 2024" "rt:19462/685751" {} records)
(import/individual-taxes "2024-01-31" "January 2024" "rt:19462/685751" "rt:19403/685602" {} records) (import/individual-taxes "2024-01-31" "January 2024" "rt:19462/685751" "rt:19403/685602" {} records)

View file

@ -27,7 +27,19 @@
{:name (employee-name->entity-tag name) {:name (employee-name->entity-tag name)
:category category :category category
:type type :type type
:amount (apply max (map bigdec (remove str/blank? totals)))})))) :amount (reduce max 0 (map bigdec (remove str/blank? totals)))}))))
(defn non-zero-pfml? [record]
"PFML is a new field as of 2026, but it's only an issue if there's a non-zero
amount listed. In that case we'll need to figure out how to record this in the
books."
(and (= (:category record) "PFML ER PU") (not (zero? (:amount record)))))
(defn warnings [records]
(->> records
(filter non-zero-pfml?)
(map :name)
(map #(str "Non-zero \"PFML ER PU\" record found for " %))))
(defn- cat->payroll-type (defn- cat->payroll-type
"Map the CSV withholding categories to Beancount payroll-type tags." "Map the CSV withholding categories to Beancount payroll-type tags."

View file

@ -16,6 +16,17 @@
(def paychex-csv-2025 (->> "example-paychex-pay-item-details-2025.csv" (def paychex-csv-2025 (->> "example-paychex-pay-item-details-2025.csv"
clojure.java.io/resource clojure.java.io/resource
import/read-csv)) import/read-csv))
(def paychex-csv-2026 (->> "example-paychex-pay-item-details-2026.csv"
clojure.java.io/resource
import/read-csv))
(deftest warns-about-non-zero-pfml-records
(let [records [{:name "Doe-Jane"
:category "PFML ER PU"
:type "Fringe Benefits"
:amount 1M}]
warnings (import/warnings records)]
(is (= (count warnings) 1) "Expected a non-zero PFML warning")))
(deftest render-transaction (deftest render-transaction
(let [transaction '{:date "DATE" (let [transaction '{:date "DATE"
@ -102,7 +113,8 @@ DATE txn \"Paychex\" \"Monthly Payroll - PERIOD - Fee\"
:meta {:entity "Citizen-Jill" :tax-implication "Reimbursement"}})}]] :meta {:entity "Citizen-Jill" :tax-implication "Reimbursement"}})}]]
(are [records] (= expected (import/net-pay "DATE" "PERIOD" "TODO-PAY-INVOICE" {} records)) (are [records] (= expected (import/net-pay "DATE" "PERIOD" "TODO-PAY-INVOICE" {} records))
paychex-csv-2024 paychex-csv-2024
paychex-csv-2025))) paychex-csv-2025
paychex-csv-2026)))
(deftest individual-taxes (deftest individual-taxes
(let [expected '({:date "DATE" (let [expected '({:date "DATE"