From 18eb076df04359eaff74a3086ef5ed354cb44e71 Mon Sep 17 00:00:00 2001 From: Ben Sturmfels Date: Wed, 21 Feb 2024 18:08:49 +1100 Subject: [PATCH] Add CLI, build script, fees importer and retirement importer --- deps.edn | 7 +++- src/core.clj | 67 ++++++++++++++++++++++++++-------- src/import.clj | 97 +++++++++++++++++++++++++++++++++++++------------- src/parse.clj | 4 ++- 4 files changed, 133 insertions(+), 42 deletions(-) diff --git a/deps.edn b/deps.edn index 159f011..82db182 100644 --- a/deps.edn +++ b/deps.edn @@ -1,4 +1,9 @@ {:deps { org.clojure/clojure {:mvn/version "1.11.1"} org.clojure/data.csv {:mvn/version "1.0.1"} - lambdaisland/deep-diff2 {:mvn/version "2.10.211"}}} + org.clojure/tools.cli {:mvn/version "1.1.230"} + lambdaisland/deep-diff2 {:mvn/version "2.10.211"}} + :aliases + {;; 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/src/core.clj b/src/core.clj index 9a8ee74..dab49eb 100644 --- a/src/core.clj +++ b/src/core.clj @@ -1,26 +1,68 @@ (ns core - (:require [import :as import] + (:require [clojure.tools.cli :refer [parse-opts]] + [import :as import] [parse :refer [parse]] - [lambdaisland.deep-diff2 :as dd])) + [lambdaisland.deep-diff2 :as dd]) + (:gen-class)) ;; TODO: Need some tests now it's working. -;; TODO: Where do the $25.81 fees come from? - -;; TODO: Need a CLI to run it on a CSV file. - (defn sort-postings [transactions] (for [t transactions] (update t :postings (fn [ps] (sort-by (juxt #(get-in % [:meta :entity]) :account :amount) (filter #(not (zero? (:amount %))) ps)))))) + +(def cli-options + [[nil "--csv FILE" "Pay Item Details CSV report"] + [nil "--pay-date DATE" "Date for the payroll transactions (YYYY-MM-DD)" + :validate [#(re-matches #"\d{4}-\d{2}-\d{2}" %) "Must be of format YYYY-MM-DD"] + :default "FIXME"] + [nil "--period PERIOD" "Month/year covered by the pay run eg. \"December 2023\"" + :default "FIXME"] + [nil "--pay-receipt-no REFERENCE" "Payroll receipt number, eg. \"rt:111/222\"" + :default "FIXME"] + [nil "--pay-invoice-no REFERENCE" "Payroll invoice number, eg. \"rt:111/222\"" + :default "FIXME"] + [nil "--total-fees NUM" "Total fee charged by Paychex, eg. \"206.50\"" + :default (bigdec 0)] + [nil "--fees-receipt-no REFERENCE" "Paychex fees receipt number, eg. \"rt:111/222\"" + :default "FIXME"] + [nil "--fees-invoice-no REFERENCE" "Paychex fees invoice number, eg. \"rt:111/222\"" + :default "FIXME"] + [nil "--retirement-date DATE" "Date for the retirement transactions (YYYY-MM-DD)" + :validate [#(re-matches #"\d{4}-\d{2}-\d{2}" %) "Must be of format YYYY-MM-DD"] + :default "FIXME"] + [nil "--retirement-receipt-no REFERENCE" "Retirement receipt number, eg. \"rt:111/222\"" + :default "FIXME"] + [nil "--retirement-invoice-no REFERENCE" "Retirement receipt number, eg. \"rt:111/222\"" + :default "FIXME"] + ["-h" "--help"]]) + +(defn -main [& args] + (let [{:keys [options _arguments _errors summary]} (parse-opts args cli-options) + {:keys [pay-date period pay-receipt-no pay-invoice-no total-fees fees-receipt-no fees-invoice-no retirement-date retirement-receipt-no retirement-invoice-no]} options] + (when (:help options) + (println summary) + (System/exit 0)) + (let [grouped-data (import/read-grouped-csv (:csv options)) + imported (concat [(import/import-monthly-payroll pay-date period pay-receipt-no grouped-data)] + (import/import-individual-taxes pay-date period pay-receipt-no pay-invoice-no grouped-data) + [(import/import-employer-taxes pay-date period pay-receipt-no grouped-data)] + [(import/payroll-fees pay-date period fees-receipt-no fees-invoice-no total-fees grouped-data)] + [(import/import-retirement retirement-date period retirement-receipt-no retirement-invoice-no grouped-data)])] + (doseq [i imported] + (println (import/render-transaction i)))))) + (comment (require '[examples :as examples]) - (def data (import/read-grouped-csv "/home/ben/Downloads/2023-12-27_Pay-Item-Details_2023-12-2.csv")) + (def grouped-data (import/read-grouped-csv "/home/ben/Downloads/2023-12-27_Pay-Item-Details_2023-12-2.csv")) (def imported - (concat [(import/import-monthly-payroll data)] - (import/import-individual-taxes data) - [(import/import-employer-taxes data)])) + (concat [(import/import-monthly-payroll "2023-12-29" "December 2023" "rt:19462/674660" grouped-data)] + (import/import-individual-taxes "2023-12-29" "December 2023" "rt:19462/674660" "rt:19403/675431" grouped-data) + [(import/import-employer-taxes "2023-12-29" "December 2023" "rt:19462/674660" grouped-data)] + [(import/payroll-fees "2023-12-29" "December 2023" "rt:19459/675387" "rt:19459/674887" (bigdec 206.50) grouped-data)] + [(import/import-retirement "2024-01-02" "December 2023" "rt:19403/676724" "rt:19403/675431" grouped-data)])) (dd/pretty-print (dd/diff @@ -30,9 +72,4 @@ (doseq [i imported] (println (import/render-transaction i))) - (dd/pretty-print - (dd/diff - (sort-postings (parse examples/employer-taxes)) - (sort-postings [(import/import-employer-taxes data)]))) - ) diff --git a/src/import.clj b/src/import.clj index fdae9ea..d50f8ff 100644 --- a/src/import.clj +++ b/src/import.clj @@ -13,13 +13,11 @@ (reduce #(update %1 %2 f) m ks)) (defn bigdec-or-zero [s] - (if (str/blank? s) (bigdec 0) (bigdec s))) - -(defn read-grouped-csv [filename] - (with-open [reader (io/reader filename)] - (doall - (group-by :name (import/prep-csv-data (csv/read-csv reader)))))) + (if (str/blank? s) + (bigdec 0) + (bigdec s))) +;; TODO: Consider merging into a single amount column. (defn prep-csv-data [records] (let [headers [:org :name :id :category :type :t-earnings :_ :t-earnings-match :_ :t-reimbursement :t-retirement :_ :t-withholding :t-liability :_ :t-net-pay] map-records (map zipmap (repeat headers) records) @@ -36,6 +34,11 @@ bigdec-or-zero))))] cleaned-records)) +(defn read-grouped-csv [filename] + (with-open [reader (io/reader filename)] + (doall + (group-by :name (import/prep-csv-data (csv/read-csv reader)))))) + (defn format-name [name] (case name "Sharp, Sage A" "Sharp-Sage-A" @@ -80,13 +83,11 @@ ;; TODO: How do we know we used all the relevant data from the report? Didn't ;; miss anything? -;; TODO: Split out the match amount into a separate transaction entry? - -(defn import-monthly-payroll [groups] - {:date "2023-12-29" :desc "Monthly Payroll - December 2023 - Net Pay" +(defn import-monthly-payroll [date period receipt-no groups] + {:date date :desc (format "Monthly Payroll - %s - Net Pay" period) :meta {:program "Conservancy:Payroll" :project "Conservancy" - :receipt "rt:19462/674660" + :receipt receipt-no :approval "Financial/Employment-Records/memo-re-board-approval-of-payroll.txt" :tax-implication "W2" :payroll-type "US:General"} @@ -135,14 +136,14 @@ :tax-implication "Reimbursement"})}]] (concat pay-exp-trans reimbursement-exp-trans))))}) -(defn import-individual-taxes [groups] +(defn import-individual-taxes [date period receipt-no invoice-no groups] ;; Print the individual taxes blocks (for [[name records] groups] - {:date "2023-12-29" :desc (format "Monthly Payroll - December 2023 - TAXES - %s" (format-name name)) + {:date date :desc (format "Monthly Payroll - %s - TAXES - %s" period (format-name name)) :meta {:project (if (= (format-name name) "Sharp-Sage-A") "Outreachy" "Conservancy") :program "Conservancy:Payroll" :entity (format-name name) - :receipt "rt:19462/674660" + :receipt receipt-no :approval "Financial/Employment-Records/memo-re-board-approval-of-payroll.txt"} :postings (let [super-lines (filter #(str/starts-with? (:category %) "403b") records) @@ -161,16 +162,16 @@ :amount (:t-retirement x) :currency "USD" :meta {:payroll-type "US:403b:Match" - :invoice "rt:19403/675431"}} + :invoice invoice-no}} {:account "Expenses:Payroll:Salary" :amount (:t-retirement x) :currency "USD" :meta {:payroll-type "US:403b:Employee" - :invoice "rt:19403/675431"}})) + :invoice invoice-no}})) [{:account "Liabilities:Payable:Accounts" :amount (- total-super) :currency "USD" - :meta {:invoice "rt:19403/675431"}}] + :meta {:invoice invoice-no}}] (conj (vec (for [x witholding-lines] {:account "Expenses:Payroll:Salary" @@ -181,7 +182,7 @@ :amount (- (reduce + (map :t-withholding witholding-lines))) :currency "USD" :meta {:tax-implication "W2"}}) - ;; We seem to add these extra insurance lines for Karen (only). Confirm with Rosanne. + ;; TODO: We seem to add these extra insurance lines for Karen (only). Confirm with Rosanne. (conj (vec (for [x insurance-lines] {:account "Expenses:Insurance" @@ -192,11 +193,11 @@ :amount (bigdec (reduce + (map :t-withholding insurance-lines))) :currency "USD"})))})) -(defn import-employer-taxes [groups] - {:date "2023-12-29" :desc "Monthly Payroll - December 2023 - TAXES - Employer" +(defn import-employer-taxes [date period receipt-no groups] + {:date date :desc (format "Monthly Payroll - %s - TAXES - Employer" period) :meta {:program "Conservancy:Payroll" :project "Conservancy" - :receipt "rt:19462/674660" + :receipt receipt-no :approval "Financial/Employment-Records/memo-re-board-approval-of-payroll.txt"} :postings (let [total-emp-tax (reduce + (map :t-liability (apply concat (vals groups))))] @@ -218,9 +219,55 @@ :amount (- total-emp-tax) :currency "USD" :meta {:entity "Paychex" - :tax-implication "Tax-Payment"} - }))}) + :tax-implication "Tax-Payment"}}))}) -;; TODO: Fee +(defn payroll-fees [date period receipt-no invoice-no total-fees groups] + (let [employees (map import/format-name (keys groups)) + num-employees (count employees) + fee-share (.setScale (/ total-fees num-employees) 2 java.math.RoundingMode/FLOOR) + extra-cents (* 100 (- total-fees (* fee-share num-employees))) + base-fee-allocation (repeat fee-share) + cents-allocation (concat (repeat extra-cents (bigdec 0.01)) (repeat 0)) + exact-fee-allocation (reverse (take num-employees (map + base-fee-allocation cents-allocation))) + expense-postings (for [[name fee] (map vector employees exact-fee-allocation)] + {:account "Expenses:Payroll:Fees" + :amount fee + :currency "USD" + :meta {:entity name}}) + asset-posting {:account "Assets:FR:Check2721" + :amount (- total-fees) + :currency "USD"} + all-postings (conj (vec expense-postings) asset-posting)] + {:date date :payee "Paychex" :desc (format "Monthly Payroll - %s - Fee" period) + :meta {:program "Conservancy:Payroll" + :project "Conservancy" + :receipt receipt-no + :invoice invoice-no + :approval "Financial/Employment-Records/memo-re-board-approval-of-payroll.txt" + :tax-implication "USA-Corporation"} + :postings all-postings})) -;; TODO: Vanguard 403(b) +(defn import-retirement [date period receipt-no invoice-no groups] + {:date date :desc (format "ASCENSUS TRUST RET PLAN - ACH DEBIT - Vanguard 403(b) - %s" period) + :meta {:program "Conservancy:Payroll" + :project "Conservancy" + :receipt receipt-no + :approval "Financial/Employment-Records/memo-re-board-approval-of-payroll.txt" + :tax-implication "Retirement-Pretax" + :invoice invoice-no} + :postings + (let [total-retirement (reduce + (map :t-retirement (apply concat (vals groups))))] + (conj (vec (for [[name records] groups] + (let [name (format-name name) + total-retirement (->> records + (filter #(= (:type %) "Retirement")) + (map :t-retirement) + (apply +) + bigdec)] + {:account "Liabilities:Payable:Accounts", + :amount total-retirement, + :currency "USD", + :meta {:entity name}}))) + {:account "Assets:FR:Check1345" + :amount (- total-retirement) + :currency "USD"}))}) diff --git a/src/parse.clj b/src/parse.clj index 7637b69..930cee6 100644 --- a/src/parse.clj +++ b/src/parse.clj @@ -8,7 +8,7 @@ (s/def ::quoted-token (s/cat :_ #{\"} - :token (s/+ (set "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01234567890-:./% ")) + :token (s/+ (set "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01234567890-:./% ()")) :_ #{\"})) (s/def ::whitespace (s/+ #{\space})) @@ -37,6 +37,8 @@ :_ ::whitespace :_ ::token :_ ::whitespace + :payee (s/? ::quoted-token) + :_ (s/? ::whitespace) :desc ::quoted-token :_ #{\newline} :meta (s/* ::meta)