diff --git a/src/core.clj b/src/core.clj index 07fda4c..fc33717 100644 --- a/src/core.clj +++ b/src/core.clj @@ -21,6 +21,8 @@ :default 0M] [nil "--pay-receipt-no REFERENCE" "Payroll receipt number, eg. \"rt:111/222\"" :default "TODO-PAY-RECEIPT"] + [nil "--pay-invoice-no REFERENCE" "Payroll invoice number, eg. \"rt:111/222\"" + :default "TODO-PAY-INVOICE"] [nil "--fees-receipt-no REFERENCE" "Paychex fees receipt number, eg. \"rt:111/222\"" :default "TODO-FEES-RECEIPT"] [nil "--fees-invoice-no REFERENCE" "Paychex fees invoice number, eg. \"rt:111/222\"" @@ -44,7 +46,7 @@ (defn -main [& args] (let [{:keys [options errors summary]} (parse-opts args cli-options) - {:keys [date period pay-receipt-no total-fees fees-receipt-no fees-invoice-no retirement-receipt-no retirement-invoice-no project]} options] + {:keys [date period pay-receipt-no pay-invoice-no total-fees fees-receipt-no fees-invoice-no retirement-receipt-no retirement-invoice-no project]} options] (when (:help options) (println summary) (System/exit 0)) @@ -54,10 +56,13 @@ (str/join \newline errors))) (System/exit 1)) (let [data (import/read-csv (:csv options)) - imported (concat (import/payroll date period pay-receipt-no project data) + ;; TODO: Renamed receipt meta to invoice + imported (concat (import/net-pay date period pay-receipt-no project data) (import/individual-taxes date period pay-receipt-no retirement-invoice-no project data) (import/employer-taxes date period pay-receipt-no project data) - (import/payroll-fees date period fees-receipt-no fees-invoice-no total-fees project data) + (import/net-pay-ach-debit date period pay-receipt-no pay-invoice-no {} data) + (import/taxes-ach-debit date period pay-receipt-no pay-invoice-no {} data) + (import/fees date period fees-receipt-no fees-invoice-no total-fees project data) (import/retirement date period retirement-receipt-no retirement-invoice-no data)) unmatched (unmatched-employees data project)] (when-not (empty? unmatched) @@ -77,29 +82,19 @@ ;; These examples are not included with the code for privacy reasons. (require '[examples]) - (def data (import/read-csv "/home/ben/Downloads/2023-12-27_Pay-Item-Details_2023-12-2.csv")) + (def data (import/read-csv "/home/ben/Downloads/2024-01-29_Pay-Item-Details_2024-01.csv")) (def imported - (concat (import/payroll "2023-12-29" "December 2023" "rt:19462/674660" {"Sharp-Sage-A" "Outreachy"} data) - (import/individual-taxes "2023-12-29" "December 2023" "rt:19462/674660" "rt:19403/675431" {"Sharp-Sage-A" "Outreachy"} data) - (import/employer-taxes "2023-12-29" "December 2023" "rt:19462/674660" {"Sharp-Sage-A" "Outreachy"} data) - (import/payroll-fees "2023-12-29" "December 2023" "rt:19459/675387" "rt:19459/674887" 206.50M {"Sharp-Sage-A" "Outreachy"} data) - (import/retirement "2024-01-02" "December 2023" "rt:19403/676724" "rt:19403/675431" data))) + (concat (import/net-pay "2024-01-31" "January 2024" "rt:19462/685751" {} data) + (import/individual-taxes "2024-01-31" "January 2024" "rt:19462/685751" "rt:19403/685602" {} data) + (import/employer-taxes "2024-01-31" "January 2024" "rt:19462/685751" {} data) + (import/net-pay-ach-debit "2024-01-31" "January 2024" "TODO-PAY-RECEIPT" "TODO-PAY-INVOICE" {} data) + (import/taxes-ach-debit "2024-01-31" "January 2024" "TODO-PAY-RECEIPT" "TODO-PAY-INVOICE" {} data) + (import/fees "2024-01-31" "January 2024" "rt:19459/675387" "rt:19459/674887" 206.50M {} data) + (import/retirement "2024-01-31" "January 2024" "rt:19403/685929" "rt:19403/685602" data))) ;; Compare hand-written transactions with imported (ignoring ordering). (dd/pretty-print (dd/diff - (sort-postings (parse examples/dec-2023)) - (sort-postings imported))) - - (def data (import/read-csv "/home/ben/Downloads/2024-01-29_Pay-Item-Details_2024-01.csv")) - (def imported - (concat (import/payroll "2024-01-31" "January 2024" "rt:19462/685751" {"Sharp-Sage-A" "Outreachy"} data) - (import/individual-taxes "2024-01-31" "January 2024" "rt:19462/685751" "rt:19403/685602" {"Sharp-Sage-A" "Outreachy"} data) - (import/employer-taxes "2024-01-31" "January 2024" "rt:19462/685751" {"Sharp-Sage-A" "Outreachy"} data) - (import/payroll-fees "2024-01-31" "January 2024" "rt:19459/675387" "rt:19459/674887" 206.50M {"Sharp-Sage-A" "Outreachy"} data) - (import/retirement "2024-01-31" "January 2024" "rt:19403/685929" "rt:19403/685602" data))) - (dd/pretty-print - (dd/diff - (sort-postings (parse examples/jan-2024)) + (sort-postings (parse examples/jan-2024-with-liabilities)) (sort-postings imported))) ;; Print out text transactions diff --git a/src/import.clj b/src/import.clj index f88ba0d..e38e23c 100644 --- a/src/import.clj +++ b/src/import.clj @@ -65,6 +65,15 @@ (assoc m :project (get projects name)) m)) +(defn total [records] + (->> records (map :amount) (reduce + 0M))) + +(defn total-by-type [type records] + (->> records + (filter #(= (:type %) type)) + (map :amount) + (reduce + 0M))) + (defn- split-fee "Share a total fee into n groups allocating the remainder as evenly as possible. (split-fee 36.02 4) => (9.01M 9.01M 9.00M 9.00M)" @@ -119,58 +128,61 @@ ;; ;; (All functions return a sequence of transactions so we can concatenate them.) -(defn payroll +(defn net-pay "Return a net pay transaction." [date period receipt-no projects data] - (let [postings (for [[name records] (group-by :name data)] - (let [total-net-pay (->> records - (filter #(= (:type %) "Net Pay")) - (map :amount) - (reduce + 0M)) - total-reimbursement (->> records - (filter #(= (:type %) "Reimbursement")) - (map :amount) - (reduce + 0M)) - actual-total-net-pay (- total-net-pay total-reimbursement)] - [{:account "Expenses:Payroll:Salary" - :amount actual-total-net-pay - :currency "USD" - :meta (assoc-project projects name {:entity name})} - {:account "Assets:FR:Check2721" - :amount (- actual-total-net-pay) - :currency "USD" - :meta {:entity name}} - {:account "Expenses:Hosting" - :amount total-reimbursement - :currency "USD" - :meta (assoc-project projects name {:entity name :payroll-type "US:Reimbursement"})} - {:account "Assets:FR:Check2721" - :amount (- total-reimbursement) - :currency "USD" - :meta (assoc-project projects name {:entity name :tax-implication "Reimbursement"})}]))] - [{:date date :desc (format "Monthly Payroll - %s - Net Pay" period) - :meta {:program "Conservancy:Payroll" - :project "Conservancy" - :receipt receipt-no - :approval "Financial/Employment-Records/memo-re-board-approval-of-payroll.txt" - :tax-implication "W2" - :payroll-type "US:General"} - :postings (apply concat postings)}])) + (let [template {:date date :desc (format "Monthly Payroll - %s - Net Pay" period) + :meta {:program "Conservancy:Payroll" + :project "Conservancy" + :invoice receipt-no + :approval "Financial/Employment-Records/memo-re-board-approval-of-payroll.txt" + :tax-implication "W2" + :payroll-type "US:General"} + :postings []} + postings (flatten + (for [[name records] (group-by :name data)] + (let [total-net-pay (total-by-type "Net Pay" records) + total-reimbursement (total-by-type "Reimbursement" records) + actual-total-net-pay (- total-net-pay total-reimbursement)] + [{:account "Expenses:Payroll:Salary" + :amount actual-total-net-pay + :currency "USD" + :meta (assoc-project projects name {:entity name})} + {:account "Liabilities:Payable:Accounts" + :amount (- actual-total-net-pay) + :currency "USD" + :meta {:entity name}} + {:account "Expenses:Hosting" + :amount total-reimbursement + :currency "USD" + :meta (assoc-project projects name {:entity name :payroll-type "US:Reimbursement"})} + {:account "Liabilities:Payable:Accounts" + :amount (- total-reimbursement) + :currency "USD" + :meta (assoc-project projects name {:entity name :tax-implication "Reimbursement"})}])))] + [(assoc template :postings postings)])) (defn individual-taxes "Return a transaction of expenses/witholding for each employee." [date period receipt-no invoice-no projects data] (for [[name records] (group-by :name data)] - (let [retirement-lines (filter #(= (:type %) "Retirement") records) + (let [template {:date date :desc (format "Monthly Payroll - %s - TAXES - %s" period name) + :meta (assoc-project + projects name + {:project "Conservancy" + :program "Conservancy:Payroll" + :entity name + :invoice receipt-no + :approval "Financial/Employment-Records/memo-re-board-approval-of-payroll.txt"}) + :postings []} + retirement-lines (filter #(= (:type %) "Retirement") records) witholding-lines (filter #(= (:type %) "Withholding") records) ;; We add these extra disability insurance/asset postings for NY only ;; as discussed in beancount/doc/Payroll.md. insurance-lines (filter (fn [{:keys [category type]}] (and (= type "Withholding") (str/starts-with? category "NY Disability"))) records) - total-retirement (->> retirement-lines - (map :amount) - (reduce + 0M)) + total-retirement (total retirement-lines) retirement-postings (for [{:keys [category amount]} retirement-lines] (if (= category "403b ER match") {:account "Expenses:Payroll:Salary" @@ -192,8 +204,8 @@ :amount amount :currency "USD" :meta {:payroll-type (cat->payroll-type category)}}) - withholding-asset-postings [{:account "Assets:FR:Check2721" - :amount (- (reduce + 0M (map :amount witholding-lines))) + withholding-asset-postings [{:account "Liabilities:Payable:Accounts" + :amount (- (total witholding-lines)) :currency "USD" :meta {:tax-implication "W2"}}] insurance-postings (for [{:keys [category amount]} insurance-lines] @@ -201,27 +213,30 @@ :amount (- amount) :currency "USD" :meta {:payroll-type (cat->payroll-type category)}}) - insurance-asset-postings [{:account "Assets:FR:Check2721" - :amount (reduce + 0M (map :amount insurance-lines)) - :currency "USD"}]] - {:date date :desc (format "Monthly Payroll - %s - TAXES - %s" period name) - :meta (assoc-project projects name {:project "Conservancy" - :program "Conservancy:Payroll" - :entity name - :receipt receipt-no - :approval "Financial/Employment-Records/memo-re-board-approval-of-payroll.txt"}) - :postings (concat - retirement-postings - liability-postings - withholding-postings - withholding-asset-postings - insurance-postings - insurance-asset-postings)}))) + insurance-asset-postings [{:account "Liabilities:Payable:Accounts" + :amount (total insurance-lines) + :currency "USD"}] + all-postings (concat + retirement-postings + liability-postings + withholding-postings + withholding-asset-postings + insurance-postings + insurance-asset-postings)] + (assoc template :postings all-postings)))) + +;; TODO: Rename data to records? (defn employer-taxes "Return an employer taxes transaction." [date period receipt-no projects data] - (let [liability-lines (filter (fn [{:keys [category type]}] + (let [template {:date date :desc (format "Monthly Payroll - %s - TAXES - Employer" period) + :meta {:program "Conservancy:Payroll" + :project "Conservancy" + :invoice receipt-no + :approval "Financial/Employment-Records/memo-re-board-approval-of-payroll.txt"} + :postings []} + liability-lines (filter (fn [{:keys [category type]}] (and (= type "Liability") (not (str/includes? category "Unemploy")))) data) liability-postings (for [{:keys [amount name category]} liability-lines] @@ -230,11 +245,13 @@ :currency "USD" ;; Use eg. "US:Medicare", not "US:Tax:Medicare" as ;; in individual-taxes as confirmed by Rosanne. - :meta (assoc-project projects - name - {:entity name - :payroll-type (str/replace (cat->payroll-type category) "Tax:" "")})}) - total-liabilities (->> liability-postings (map :amount) (reduce + 0M)) + :meta (assoc-project + projects + name + {:entity name + :payroll-type (str/replace (cat->payroll-type category) "Tax:" "")})}) + ;; Can this use total liability-lines? + total-liabilities (total liability-postings) unemploy-lines (filter (fn [{:keys [category type]}] (and (= type "Liability") (str/includes? category "Unemploy"))) data) @@ -245,25 +262,30 @@ :meta (assoc-project projects name {:entity (first (str/split category #" ")) - :memo name ; distinguishes multiple employees in one state + :memo name ; distinguishes multiple employees in one state :payroll-type (str "US:" (cat->payroll-type category))})}) - total-unemploy (->> unemploy-postings (map :amount) (reduce + 0M)) - asset-postings [{:account "Assets:FR:Check2721" + ;; Can this use total unemploy-lines? + total-unemploy (total unemploy-postings) + asset-postings [{:account "Liabilities:Payable:Accounts" :amount (- (+ total-liabilities total-unemploy)) :currency "USD" :meta {:entity "Paychex" - :tax-implication "Tax-Payment"}}]] - [{:date date :desc (format "Monthly Payroll - %s - TAXES - Employer" period) - :meta {:program "Conservancy:Payroll" - :project "Conservancy" - :receipt receipt-no - :approval "Financial/Employment-Records/memo-re-board-approval-of-payroll.txt"} - :postings (concat liability-postings unemploy-postings asset-postings)}])) + :tax-implication "Tax-Payment"}}] + all-postings (concat liability-postings unemploy-postings asset-postings)] + [(assoc template :postings all-postings)])) -(defn payroll-fees +(defn fees "Return a payroll fees transaction." [date period receipt-no invoice-no total-fees projects data] - (let [employees (distinct (map :name data)) + (let [template {: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 []} + employees (distinct (map :name data)) exact-fee-allocation (split-fee total-fees (count employees)) employee-fees (map vector employees exact-fee-allocation) expense-postings (for [[name fee] employee-fees] @@ -273,37 +295,113 @@ :meta (assoc-project projects name {:entity name})}) asset-postings [{:account "Assets:FR:Check2721" :amount (- total-fees) - :currency "USD"}]] - [{: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 (concat expense-postings asset-postings)}])) + :currency "USD"}] + all-postings (concat expense-postings asset-postings)] + [(assoc template :postings all-postings)])) (defn retirement "Return a retirement transaction." [date period receipt-no invoice-no data] - (let [liability-postings (for [[name records] (group-by :name data)] - (let [total-retirement (->> records - (filter #(= (:type %) "Retirement")) - (map :amount) - (reduce + 0M))] - {:account "Liabilities:Payable:Accounts", - :amount total-retirement, - :currency "USD", + (let [template {: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 []} + liability-postings (for [[name records] (group-by :name data)] + (let [total-retirement (total-by-type "Retirement" records)] + {:account "Liabilities:Payable:Accounts" + :amount total-retirement + :currency "USD" :meta {:entity name}})) - total-liabilities (->> liability-postings (map :amount) (reduce + 0M)) + total-liabilities (total liability-postings) asset-postings [{:account "Assets:FR:Check1345" :amount (- total-liabilities) - :currency "USD"}]] - [{: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 (concat liability-postings asset-postings)}])) + :currency "USD"}] + all-postings (concat liability-postings asset-postings)] + [(assoc template :postings all-postings)])) + + +(defn net-pay-ach-debit + ;; TODO: Docstring + "Return a net pay transaction." + [date period receipt-no invoice-no projects data] + (let [template {:date date :desc (format "Monthly Payroll - %s - Net Pay - ACH debit" 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 "W2" + :payroll-type "US:General"} + :postings []} + employee-postings (flatten + (for [[name records] (group-by :name data)] + (let [net-pay (total-by-type "Net Pay" records) + reimbursements (total-by-type "Reimbursement" records)] + [{:account "Liabilities:Payable:Accounts" + :amount (- net-pay reimbursements) + :currency "USD" + :meta (assoc-project projects name {:entity name})} + {:account "Liabilities:Payable:Accounts" + :amount reimbursements + :currency "USD" + :meta (assoc-project projects name {:entity name})}]))) + total-net-pay (total-by-type "Net Pay" data) + total-reimbursements (total-by-type "Reimbursement" data) + total-net-pay-posting {:account "Assets:FR:Check2721" + :amount (- (- total-net-pay total-reimbursements)) + :currency "USD" + :meta {:entity "Paychex" :tax-implication "W2"}} + total-reimbursements-posting {:account "Assets:FR:Check2721" + :amount (- total-reimbursements) + :currency "USD" + :meta {:entity "Paychex" :tax-implication "Reimbursement"}} + all-postings (concat employee-postings [total-net-pay-posting total-reimbursements-posting])] + [(assoc template :postings all-postings)])) + +(defn taxes-ach-debit + ;; TODO: Docstring + "Return an employer taxes transaction." + [date period receipt-no invoice-no projects data] + (let [template {:date date :desc (format "Monthly Payroll - %s - TAXES - ACH debit" period) + :meta {:program "Conservancy:Payroll" + :project "Conservancy" + :receipt receipt-no + :invoice invoice-no + :approval "Financial/Employment-Records/memo-re-board-approval-of-payroll.txt"} + :postings []} + liability-lines (filter (fn [{:keys [category type]}] + (and (= type "Liability") + (not (str/includes? category "Unemploy")))) data) + total-liabilities (total liability-lines) + unemploy-lines (filter (fn [{:keys [category type]}] + (and (= type "Liability") + (str/includes? category "Unemploy"))) data) + total-unemploy (total unemploy-lines) + liability-postings [{:account "Liabilities:Payable:Accounts" + :amount (+ total-liabilities total-unemploy) + :currency "USD" + :meta {:entity "Paychex"}}] + withholding-liability-postings (flatten + (for [[name records] (group-by :name data)] + (let [witholding-lines (filter #(= (:type %) "Withholding") records) + insurance-lines (filter (fn [{:keys [category type]}] + (and (= type "Withholding") + (str/starts-with? category "NY Disability"))) records)] + [{:account "Liabilities:Payable:Accounts" + :amount (total witholding-lines) + :currency "USD" + :meta (assoc-project projects name {:entity name})} + {:account "Liabilities:Payable:Accounts" + :amount (- (total insurance-lines)) + :currency "USD" + :meta (assoc-project projects name {:entity name})}]))) + asset-postings [{:account "Assets:FR:Check2721" + :amount (- (+ total-liabilities total-unemploy (total withholding-liability-postings))) + :currency "USD" + :meta {:entity "Paychex" :tax-implication "Tax-Payment"}}] + all-postings (concat withholding-liability-postings liability-postings asset-postings)] + [(assoc template :postings all-postings)])) diff --git a/test/import_test.clj b/test/import_test.clj index 387d292..3cfea1d 100644 --- a/test/import_test.clj +++ b/test/import_test.clj @@ -7,9 +7,9 @@ [clojure.string :as str] [clojure.test :as t :refer [deftest is]])) -(deftest payroll +(deftest net-pay (let [data (import/read-csv (clojure.java.io/resource "example-paychex-pay-item-details.csv")) - actual (import/payroll "DATE" "PERIOD" "TODO-PAY-RECEIPT" {} data) + actual (import/net-pay "DATE" "PERIOD" "TODO-PAY-RECEIPT" {} data) expected '[{:date "DATE" :desc "Monthly Payroll - PERIOD - Net Pay" :meta @@ -221,9 +221,9 @@ :meta {:entity "Paychex" :tax-implication "Tax-Payment"}})}]] (is (= actual expected)))) -(deftest payroll-fees +(deftest fees (let [data (import/read-csv (clojure.java.io/resource "example-paychex-pay-item-details.csv")) - actual (import/payroll-fees "DATE" "PERIOD" "TODO-FEES-RECEIPT" "TODO-FEES-INVOICE" 206.51 {} data) + actual (import/fees "DATE" "PERIOD" "TODO-FEES-RECEIPT" "TODO-FEES-INVOICE" 206.51 {} data) expected '[{:date "DATE" :payee "Paychex" :desc "Monthly Payroll - PERIOD - Fee"