;; Copyright 2024 Ben Sturmfels ;; License: GPLv3-or-later ;; ;; Full copyright and licensing details can be found at toplevel file ;; LICENSE.txt in the repository. (ns import (:require [clojure.data.csv :as csv] [clojure.java.io :as io] [clojure.string :as str])) (defn- employee-name->entity-tag "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-grouped-csv "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 (group-by :name (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)))}))))) (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" "Fed Unemploy" "US:Unemployment" "IL Unemploy" "IL:Unemployment" "NY Unemploy" "NY:Unemployment" "OR Unemploy" "OR:Unemployment" "NY Re-empl Svc" "US:NY:Reempt" cat)) (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") (assoc m :project "Outreachy") m)) (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))) (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))))))))) (defn payroll "Return a net pay transaction." [date period receipt-no groups] (let [postings (for [[name records] groups] (let [total-net-pay (->> records (filter #(= (:type %) "Net Pay")) (map :amount) (apply +)) 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" :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" :meta (assoc-project name {:entity name :payroll-type "US:Reimbursement"})} {:account "Assets:FR:Check2721" :amount (- total-reimbursement) :currency "USD" :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 groups] (for [[name records] groups] (let [retirement-lines (filter #(= (:type %) "Retirement") records) witholding-lines (filter #(= (:type %) "Withholding") records) ;; TODO: We seem to add these extra insurance/asset postings for ;; Karen (NY) only, but not for say Pono/Sage/Bradley (OR). Confirm ;; with Rosanne. insurance-lines (filter #(and (= (:type %) "Withholding") (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" :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" :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) :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)}))) (defn employer-taxes "Return an employer taxes transaction." [date period receipt-no groups] (let [ungrouped (concat (vals groups)) ; Grouping by employee not useful here liability-lines (filter #(and (= (:type %) "Liability") (not (str/includes? (:category %) "Unemploy"))) ungrouped) liability-postings (for [{:keys [category amount]} 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")) ungrouped) unemploy-postings (for [{:keys [amount category]} unemploy-lines] {:account "Expenses:Payroll:Tax" :amount amount :currency "USD" :meta (assoc-project name {:entity (first (str/split category #" ")) :memo name :payroll-type (str "US:" (cat->payroll-type category))})}) total-unemploy (->> unemploy-postings (map :amount) (reduce +)) asset-postings [{:account "Assets:FR:Check2721" :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)}])) (defn payroll-fees "Return a payroll fees transaction." [date period receipt-no invoice-no total-fees groups] (let [employees (keys groups) 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" :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 groups] (let [liability-postings (for [[name records] groups] (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)}]))