payroll-import/src/import.clj

303 lines
15 KiB
Clojure
Raw Normal View History

2024-02-21 23:11:07 +00:00
;; Copyright 2024 Ben Sturmfels
;; License: GPLv3-or-later
(ns import
(:require [clojure.data.csv :as csv]
[clojure.java.io :as io]
[clojure.string :as str]))
2024-02-22 01:43:32 +00:00
(defn- employee-name->entity-tag
2024-02-21 23:11:07 +00:00
"Convert the name from the Pay Item Details report into an entity slug for Beancount."
[name]
;; Should potentially be a lookup table in config.
(case name
"Sharp, Sage A" "Sharp-Sage-A"
(-> name
(str/replace " Jr." "")
(str/replace ", " "-")
(str/replace #" \w$" ""))))
(defn read-csv
2024-02-21 23:11:07 +00:00
"Read in CSV and return a vector of maps with only the fields we want.
Merges the various number fields into a single \"amount\" field."
[filename]
(with-open [reader (io/reader filename)]
(doall
(for [[_ name _ category type & totals] (csv/read-csv reader)]
{:name (employee-name->entity-tag name)
:category category
:type type
:amount (apply max (map bigdec (remove str/blank? totals)))}))))
2024-02-21 23:11:07 +00:00
(defn- cat->payroll-type
"Map the CSV withholding categories to Beancount payroll-type tags."
[cat]
;; May need to become config, similar to names. Should prompt you if no
;; mapping exists to avoid mistakes.
(case cat
"Fed Income Tax" "US:Tax:Income"
"Medicare" "US:Tax:Medicare"
"IL Income Tax" "US:IL:Tax:Income"
"NY Income Tax" "US:NY:Tax:Income"
"NYC Income Tax" "US:NY:Tax:NYC"
"OR Income Tax" "US:OR:Tax:Income"
"OH Income Tax" "US:OH:Tax:Income"
"PNTSD Income Tax" "US:OH:Tax:PNTSD"
"COLMB Income Tax" "US:OH:Tax:COLUMB"
"Social Security" "US:Tax:SocialSecurity"
"NY Disability" "US:NY:Disability"
"OR Disability PFL" "US:OR:Disability:PFL"
"NY Disability PFL" "US:NY:Disability:PFL"
"OR TRANS STT" "US:OR:Tax:STT"
2024-02-20 07:26:45 +00:00
"Fed Unemploy" "US:Unemployment"
"IL Unemploy" "IL:Unemployment"
"NY Unemploy" "NY:Unemployment"
"OR Unemploy" "OR:Unemployment"
"NY Re-empl Svc" "US:NY:Reempt"
cat))
2024-02-21 23:11:07 +00:00
(defn- assoc-project
"Conditionally adds a specific project tag to metadata.
Some employees need to be tagged with a particular project to override the
default \"Conservancy\" project."
[name m]
(if (= name "Sharp-Sage-A")
2024-02-21 14:45:15 +00:00
(assoc m :project "Outreachy")
m))
2024-02-21 23:11:07 +00:00
(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)"
[total n]
(let [total (bigdec total)
fee-share (.setScale (bigdec (/ (double total) n)) 2 java.math.RoundingMode/FLOOR)
extra-cents (* 100M (- total (* fee-share n)))
base-fee-allocation (repeat fee-share)
cents-allocation (take n (concat (repeat extra-cents 0.01M) (repeat 0)))]
(map + base-fee-allocation cents-allocation)))
2024-02-21 23:11:07 +00:00
(defn render-transaction
"Turn a Beancount transaction data structure into text."
[{:keys [date payee desc meta postings]}]
(str/join (concat
;; Transaction date/description
[(if payee
(format "%s txn \"%s\" \"%s\"\n" date payee desc)
(format "%s txn \"%s\"\n" date desc))]
;; Transaction metadata
(for [[k v] meta]
(format " %s: \"%s\"\n" (name k) v))
;; Postings and posting metadata
(for [{:keys [amount account currency meta]} postings]
(when (not (zero? amount))
(format " %-40s %10.2f %s\n%s" account amount currency
(str/join (for [[k v] meta]
(format " %s: \"%s\"\n" (name k) v)))))))))
;; Each of the below functions returns one of the five sections of the required
;; bookkeeping data:
;;
;; * net pay (single transaction)
;; * individual taxes (one transaction for each employee)
;; * employer taxes (single transaction)
;; * fees (single transaction)
;; * retirement (single trasaction)
;;
;; These functions take the input CSV data, pre-formatted and grouped by
;; employee.
;;
;; The output is an intermediate data structure that can then be run through
;; `render-transaction` to produce Beancount transaction text. The data
;; structures can also be programatically compared to a parsed version of the
;; hand-written Beancount transactions for development and testing (see
;; parse.clj).
;;
;; (All functions return a sequence of transactions so we can concatenate them.)
(defn payroll
"Return a net pay transaction."
[date period receipt-no data]
(let [postings (for [[name records] (group-by :name data)]
(let [total-net-pay (->> records
2024-02-20 07:26:45 +00:00
(filter #(= (:type %) "Net Pay"))
(map :amount)
(apply +))
2024-02-20 07:26:45 +00:00
total-reimbursement (->> records
(filter #(= (:type %) "Reimbursement"))
(map :amount)
(apply +))
actual-total-net-pay (- total-net-pay total-reimbursement)]
[{:account "Expenses:Payroll:Salary"
:amount actual-total-net-pay
:currency "USD"
2024-02-21 14:45:15 +00:00
:meta (assoc-project 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"
2024-02-21 14:45:15 +00:00
:meta (assoc-project name {:entity name :payroll-type "US:Reimbursement"})}
{:account "Assets:FR:Check2721"
:amount (- total-reimbursement)
:currency "USD"
2024-02-21 14:45:15 +00:00
:meta (assoc-project 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)}]))
(defn individual-taxes
"Return a transaction of expenses/witholding for each employee."
[date period receipt-no invoice-no data]
(for [[name records] (group-by :name data)]
(let [retirement-lines (filter #(= (:type %) "Retirement") records)
witholding-lines (filter #(= (:type %) "Withholding") records)
2024-02-22 01:43:32 +00:00
;; TODO: We seem to add these extra insurance/asset postings for
2024-02-21 23:11:07 +00:00
;; Karen (NY) only, but not for say Pono/Sage/Bradley (OR). Confirm
;; with Rosanne.
insurance-lines (filter #(and (= (:type %) "Withholding")
2024-02-21 23:11:07 +00:00
(str/starts-with? (:category %) "NY Disability")) records)
total-retirement (->> retirement-lines
(map :amount)
(apply +))
retirement-postings (for [{:keys [category amount]} retirement-lines]
(if (= category "403b ER match")
{:account "Expenses:Payroll:Salary"
:amount amount
:currency "USD"
:meta {:payroll-type "US:403b:Match"
:invoice invoice-no}}
{:account "Expenses:Payroll:Salary"
:amount amount
:currency "USD"
:meta {:payroll-type "US:403b:Employee"
:invoice invoice-no}}))
liability-postings [{:account "Liabilities:Payable:Accounts"
:amount (- total-retirement)
:currency "USD"
:meta {:invoice invoice-no}}]
withholding-postings (for [{:keys [category amount]} witholding-lines]
{:account "Expenses:Payroll:Salary"
:amount amount
:currency "USD"
2024-02-21 23:11:07 +00:00
:meta {:payroll-type (cat->payroll-type category)}})
withholding-asset-postings [{:account "Assets:FR:Check2721"
:amount (- (reduce + (map :amount witholding-lines)))
:currency "USD"
:meta {:tax-implication "W2"}}]
insurance-postings (for [{:keys [category amount]} insurance-lines]
{:account "Expenses:Insurance"
:amount (- amount)
:currency "USD"
2024-02-21 23:11:07 +00:00
:meta {:payroll-type (cat->payroll-type category)}})
insurance-asset-postings [{:account "Assets:FR:Check2721"
:amount (reduce + (map :amount insurance-lines))
:currency "USD"}]]
{:date date :desc (format "Monthly Payroll - %s - TAXES - %s" period name)
2024-02-21 14:45:15 +00:00
:meta (assoc-project 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)})))
2024-02-20 07:26:45 +00:00
(defn employer-taxes
"Return an employer taxes transaction."
[date period receipt-no data]
(let [liability-lines (filter #(and (= (:type %) "Liability")
(not (str/includes? (:category %) "Unemploy"))) data)
2024-02-22 03:18:21 +00:00
liability-postings (for [{:keys [amount name category]} liability-lines]
{:account "Expenses:Payroll:Tax"
:amount amount
:currency "USD"
;; TODO: Check lack of ":Tax:" with Rosanne.
:meta (assoc-project
name
{:entity name
:payroll-type (str/replace (cat->payroll-type category) "Tax:" "")})})
total-liabilities (->> liability-postings (map :amount) (reduce +))
unemploy-lines (filter #(and (= (:type %) "Liability")
(str/includes? (:category %) "Unemploy")) data)
2024-02-22 03:18:21 +00:00
unemploy-postings (for [{:keys [amount name category]} unemploy-lines]
{:account "Expenses:Payroll:Tax"
:amount amount
:currency "USD"
:meta (assoc-project
name
{:entity (first (str/split category #" "))
2024-02-22 01:43:32 +00:00
:memo name
:payroll-type (str "US:" (cat->payroll-type category))})})
2024-02-21 14:45:15 +00:00
total-unemploy (->> unemploy-postings (map :amount) (reduce +))
asset-postings [{:account "Assets:FR:Check2721"
2024-02-21 14:45:15 +00:00
: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"}
2024-02-21 14:45:15 +00:00
:postings (concat liability-postings unemploy-postings asset-postings)}]))
(defn payroll-fees
"Return a payroll fees transaction."
[date period receipt-no invoice-no total-fees data]
(let [employees (distinct (map :name data))
_ (println employees)
exact-fee-allocation (split-fee total-fees (count employees))
employee-fees (map vector employees exact-fee-allocation)
expense-postings (for [[name fee] employee-fees]
{:account "Expenses:Payroll:Fees"
:amount fee
:currency "USD"
2024-02-21 14:45:15 +00:00
:meta (assoc-project 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)}]))
(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 +))]
{:account "Liabilities:Payable:Accounts",
:amount total-retirement,
:currency "USD",
:meta {:entity name}}))
total-liabilities (->> liability-postings (map :amount) (reduce +))
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)}]))