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:
parent
2cb7c3a3ad
commit
23cc4657ee
7 changed files with 98 additions and 25 deletions
|
|
@ -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`.
|
||||||
|
|
|
||||||
9
deps.edn
9
deps.edn
|
|
@ -4,9 +4,14 @@
|
||||||
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
|
||||||
:build {:deps {io.github.clojure/tools.build {:mvn/version "0.9.6"}}
|
:build {:deps {io.github.clojure/tools.build {:mvn/version "0.9.6"}}
|
||||||
:ns-default build}}}
|
:ns-default build}}}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
29
resources/example-paychex-pay-item-details-2026.csv
Normal file
29
resources/example-paychex-pay-item-details-2026.csv
Normal 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,,,
|
||||||
|
48
src/core.clj
48
src/core.clj
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue