diff --git a/README.md b/README.md index 6133f54..019085c 100644 --- a/README.md +++ b/README.md @@ -18,13 +18,13 @@ Run a demo with two example employees, Jack and Jill Citizen: 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 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: - 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: @@ -64,7 +64,7 @@ Run tests with: 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 file and run `cider-jack-in`. diff --git a/deps.edn b/deps.edn index dd8a5cc..3b832ed 100644 --- a/deps.edn +++ b/deps.edn @@ -4,9 +4,14 @@ org.clojure/data.csv {:mvn/version "1.0.1"} org.clojure/tools.cli {:mvn/version "1.1.230"}} :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"}} :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"}} :ns-default build}}} diff --git a/manifest.scm b/manifest.scm index e440576..30ab8a8 100644 --- a/manifest.scm +++ b/manifest.scm @@ -5,8 +5,7 @@ (specifications->manifest (list ;; No issues running this OpenJDK 21 program on Debian Stable (OpenJDK 17). - "openjdk@21" - ;; Works fine with clojure-tools from Guix. + "openjdk@21:jdk" "clojure-tools" "clj-kondo" "beancount" diff --git a/resources/example-paychex-pay-item-details-2026.csv b/resources/example-paychex-pay-item-details-2026.csv new file mode 100644 index 0000000..2bc8bca --- /dev/null +++ b/resources/example-paychex-pay-item-details-2026.csv @@ -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,,, diff --git a/src/core.clj b/src/core.clj index 7742cb7..bbc82ec 100644 --- a/src/core.clj +++ b/src/core.clj @@ -6,17 +6,15 @@ (:require [clojure.java.io :as io] [clojure.set :as set] [clojure.string :as str] - [clojure.tools.cli :refer [parse-opts]] + [clojure.tools.cli :as cli] [import :as import]) (:gen-class)) (def cli-options - [[nil "--csv FILE" "Pay Item Details CSV report" - :validate [#(-> % io/file .exists) "File does not exist"]] - [nil "--date DATE" "Date used for the transactions (YYYY-MM-DD)" + [[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"] :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"] [nil "--total-fees NUM" "Total fee charged by Paychex, eg. \"206.50\"" :parse-fn bigdec @@ -33,13 +31,13 @@ :default "TODO-RETIREMENT-RECEIPT"] [nil "--retirement-invoice-no REFERENCE" "Retirement receipt number, eg. \"rt:111/222\"" :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 :validate [#(= 2 (count %)) "Must be of the form \"name:project\""] :parse-fn #(str/split % #":") :default {} :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"]]) (defn unmatched-employees @@ -67,32 +65,49 @@ (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/retirement date period retirement-receipt-no retirement-invoice-no records)) - unmatched (unmatched-employees records project)] - [imported unmatched])) + unmatched (unmatched-employees records project) + 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 "Run the CLI interface." [& args] - (let [{:keys [options errors summary]} (parse-opts args cli-options) - no-csv-or-demo? (not (or (contains? options :csv) (contains? options :demo))) + (let [{:keys [options arguments errors summary]} (cli/parse-opts args cli-options) + 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 - 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) - (println summary) + (println (usage summary)) (System/exit 0)) (when errors (println (str "The following errors occurred:\n\n" (str/join \newline errors))) (System/exit 1)) - (let [[imported unmatched] (run options)] + (let [[imported unmatched warnings] (run options)] (when (seq unmatched) (println (str "Could not find these employees in the payroll:\n\n" (str/join ", " unmatched))) (System/exit 1)) (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 ;; Examples to exercise the importer during development. @@ -103,7 +118,8 @@ ;; These examples are not included with the code for privacy reasons. (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 (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) diff --git a/src/import.clj b/src/import.clj index 4170090..65c009b 100644 --- a/src/import.clj +++ b/src/import.clj @@ -27,7 +27,19 @@ {:name (employee-name->entity-tag name) :category category :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 "Map the CSV withholding categories to Beancount payroll-type tags." diff --git a/test/import_test.clj b/test/import_test.clj index 5999611..2b951f5 100644 --- a/test/import_test.clj +++ b/test/import_test.clj @@ -16,6 +16,17 @@ (def paychex-csv-2025 (->> "example-paychex-pay-item-details-2025.csv" clojure.java.io/resource 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 (let [transaction '{:date "DATE" @@ -102,7 +113,8 @@ DATE txn \"Paychex\" \"Monthly Payroll - PERIOD - Fee\" :meta {:entity "Citizen-Jill" :tax-implication "Reimbursement"}})}]] (are [records] (= expected (import/net-pay "DATE" "PERIOD" "TODO-PAY-INVOICE" {} records)) paychex-csv-2024 - paychex-csv-2025))) + paychex-csv-2025 + paychex-csv-2026))) (deftest individual-taxes (let [expected '({:date "DATE"