(ns auto-ap.graphql.checks (:require [amazonica.aws.s3 :as s3] [auto-ap.datomic :refer [audit-transact remove-nils conn]] [auto-ap.datomic.accounts :as a] [auto-ap.datomic.bank-accounts :as d-bank-accounts] [auto-ap.datomic.checks :as d-checks] [auto-ap.datomic.clients :as d-clients] [auto-ap.datomic.invoices :as d-invoices] [auto-ap.datomic.transactions :as d-transactions] [auto-ap.datomic.vendors :as d-vendors] [auto-ap.graphql.utils :refer [->graphql <-graphql assert-admin assert-can-see-client enum->keyword assert-not-locked assert-none-locked]] [auto-ap.numeric :refer [num->words]] [auto-ap.time :refer [iso-date local-now parse]] [auto-ap.utils :refer [by dollars-0?]] [clj-pdf.core :as pdf] [clj-time.coerce :as c] [clj-time.core :as time] [clj-time.format :as f] [clojure.edn :as edn] [clojure.java.io :as io] [clojure.set :as set] [clojure.string :as str] [clojure.tools.logging :as log] [com.walmartlabs.lacinia.util :refer [attach-resolvers]] [config.core :refer [env]] [datomic.api :as d] [digest]) (:import (java.io ByteArrayOutputStream) (java.text DecimalFormat) (java.util UUID))) (def parser (f/formatter "MM/dd/YYYY")) (defn date->str [t] (f/unparse parser t)) (defn distribute [nums] (let [sum (reduce + 0 nums)] (map #(* 100 (/ % sum)) nums))) (defn split-memo [memo] (str/join "\n" (reverse (take 6 (concat (reduce (fn [[line & rest ] word] (let [line (or line "")] (if (> (+ (count line) (count word)) 50) (concat [word line] rest) (concat [(str line " " word)] rest)))) [] (str/split memo #" ")) (repeat "")))))) (defn make-check-pdf [check] (let [output-stream (ByteArrayOutputStream.)] (pdf/pdf [{:left-margin 25 :right-margin 0 :top-margin 0 :bottom-margin 0 :size :letter} (let [{:keys [bank-account paid-to client check date amount memo] {print-as :vendor/print-as vendor-name :vendor/name :as vendor} :vendor} check df (DecimalFormat. "#,###.00") word-amount (num->words amount) amount (str "--" (.format df amount) "--")] [:table {:num-cols 12 :border false :leading 11 :widths (distribute [2 3 3 3 3 3 3 3 3 2 2 2])} [(let [{:keys [:client/name] {:keys [:address/street1 :address/city :address/state :address/zip]} :client/address} client] [:cell {:colspan 4 } [:paragraph {:leading 14} name "\n" street1 "\n" (str city ", " state " " zip)] ]) (let [{:keys [:bank-account/bank-name :bank-account/bank-code] } bank-account] [:cell {:colspan 6 :align :center} [:paragraph {:style :bold} bank-name] [:paragraph {:size 8 :leading 8} bank-code]]) [:cell {:colspan 2 :size 13} check]] [[:cell {:colspan 9}] [:cell {:colspan 3 :leading -10} date]] [[:cell {:colspan 12 :size 14}] ] [[:cell {:size 13 :leading 13} "PAY"] [:cell {:size 8 :leading 8 } "TO THE ORDER OF"] [:cell {:colspan 7} (if (seq print-as) print-as vendor-name)] [:cell {:colspan 3} amount]] [[:cell {}] [:cell {:colspan 8} (str " -- " word-amount " " (str/join "" (take (max 2 (- 95 (count word-amount))) (repeat "-")))) [:line {:line-width 0.15 :color [50 50 50]}]] [:cell {:colspan 3}]] [[:cell {:size 9 :leading 11.5} "\n\n\n\n\nMEMO"] [:cell {:colspan 5 :leading 11.5} (split-memo memo) [:line {:line-width 0.15 :color [50 50 50]}]] [:cell {:colspan 6 } (if (:client/signature-file client) [:image { :top-margin 90 :xscale 0.30 :yscale 0.30 :align :center} (:client/signature-file client)] [:spacer])]] #_[ #_[:cell {:colspan 5} #_memo ] #_[:cell {:colspan 6}]] [[:cell {:colspan 2}] [:cell {:colspan 10 :leading 30} [:phrase {:size 18 :ttf-name "public/micrenc.ttf"} (str "c" check "c a" (:bank-account/routing bank-account) "a " (:bank-account/number bank-account) "c")]]] [[:cell {:colspan 12 :leading 18} [:spacer]]] [[:cell] (into [:cell {:colspan 9}] (let [{:keys [:client/name] {:keys [:address/street1 :address/street2 :address/city :address/state :address/zip ]} :client/address} client] (filter identity (list [:paragraph " " name] [:paragraph " " street1] (when (not (str/blank? street2)) [:paragraph " " street2]) [:paragraph " " city ", " state " " zip])))) [:cell {:colspan 2 :size 13} check]] [[:cell {:colspan 12 :leading 74} [:spacer]]] [[:cell] [:cell {:colspan 5} [:paragraph " " vendor-name "\n" " " (:address/street1 (:vendor/address vendor)) "\n" (when (not (str/blank? (:address/street2 (:vendor/address vendor)))) (str " " (:address/street2 (:vendor/address vendor)) "\n") ) " " (:address/city (:vendor/address vendor)) ", " (:address/state (:vendor/address vendor)) " " (:address/zip (:vendor/address vendor))]] [:cell {:align :right} "Paid to:\n" "Amount:\n" "Date:\n"] [:cell {:colspan 5} [:paragraph paid-to] [:paragraph amount] [:paragraph date]]] [[:cell {:colspan 3} "Memo:"] [:cell {:colspan 9} memo]] [[:cell {:colspan 12} [:spacer]]] [[:cell {:colspan 12} [:spacer]]] [[:cell {:colspan 12} [:spacer]]] [[:cell {:colspan 12} [:spacer]]] [[:cell {:colspan 5}] [:cell {:align :right :colspan 2} "Check:\n" "Vendor:\n" "Company:\n" "Bank Account:\n" "Paid To:\n" "Amount:\n" "Date:\n"] [:cell {:colspan 5} [:paragraph check] [:paragraph vendor-name] [:paragraph (:client/name client)] [:paragraph (:bank-account/bank-name bank-account)] [:paragraph paid-to] [:paragraph amount] [:paragraph date]]] [[:cell {:colspan 3} "Memo:"] [:cell {:colspan 9} memo]] ])] output-stream) (.toByteArray output-stream))) (defn make-pdfs [checks] (loop [[check & checks] checks] (when check (s3/put-object :bucket-name (:data-bucket env) :key (:payment/s3-key check) :input-stream (-> check :payment/pdf-data (edn/read-string) make-check-pdf (io/make-input-stream {})) :metadata {:content-type "application/pdf"}) (recur checks)))) (defn merge-pdfs [keys] (let [merged-pdf-stream (java.io.ByteArrayOutputStream.) uuid (str (UUID/randomUUID))] (apply pdf/collate (concat [{:size :letter} merged-pdf-stream] (->> keys (map #(s3/get-object (:data-bucket env) %)) (map :input-stream)))) (s3/put-object :bucket-name (:data-bucket env) :key (str "merged-checks/" uuid ".pdf") :input-stream (io/make-input-stream (.toByteArray merged-pdf-stream) {}) :metadata {:content-length (count (.toByteArray merged-pdf-stream)) :content-type "application/pdf"}) (str "http://" (:data-bucket env) ".s3-website-us-east-1.amazonaws.com/merged-checks/" uuid ".pdf"))) #_{:clj-kondo/ignore [:unused-binding]} (defmulti invoices->entities (fn [invoices vendor-id client bank-account type index invoice-amounts] type)) (defn invoice-payments [invoices invoice-amounts] (->> (for [invoice invoices :let [invoice-amount (invoice-amounts (:db/id invoice))]] [{:invoice-payment/payment (-> invoice :invoice/vendor :db/id str) :invoice-payment/amount invoice-amount :invoice-payment/invoice (:db/id invoice)} [:pay (:db/id invoice) invoice-amount]]) (reduce into []))) (defn base-payment [invoices vendor client bank-account _ _ invoice-amounts] {:db/id (str (:db/id vendor)) :payment/bank-account (:db/id bank-account) :payment/amount (reduce + 0 (map (comp invoice-amounts :db/id) invoices)) :payment/vendor (:db/id vendor) :payment/client (:db/id client) :payment/date (c/to-date (time/now)) :payment/invoices (map :db/id invoices)}) (defmethod invoices->entities :payment-type/check [invoices vendor client bank-account type index invoice-amounts] (when (<= (->> invoices (map (comp invoice-amounts :db/id)) (reduce + 0.0)) 0.001) (throw (ex-info "The selected invoices do not have an outstanding balance." {:validation-error "The selected invoices do not have an outstanding balance."}))) (let [uuid (str (UUID/randomUUID)) memo (str "Invoice #'s: " (str/join ", " (map (fn [i] (str (:invoice/invoice-number i) "(" (invoice-amounts (:db/id i)) ")")) invoices))) base-payment (base-payment invoices vendor client bank-account type index invoice-amounts) payment (remove-nils (assoc base-payment :payment/s3-uuid (when (> (:payment/amount base-payment) 0) uuid) :payment/s3-key (when (> (:payment/amount base-payment) 0) (str "checks/" uuid ".pdf")) :payment/s3-url (when (> (:payment/amount base-payment) 0) (str "http://" (:data-bucket env) ".s3-website-us-east-1.amazonaws.com/checks/" uuid ".pdf")) :payment/check-number (+ index (:bank-account/check-number bank-account)) :payment/type :payment-type/check :payment/memo memo :payment/status :payment-status/pending :payment/pdf-data (pr-str {:vendor vendor :paid-to (or (:vendor/paid-to vendor) (:vendor/name vendor)) :amount (reduce + 0 (map (comp invoice-amounts :db/id) invoices)) :check (str (+ index (:bank-account/check-number bank-account))) :memo memo :date (date->str (local-now)) :client (dissoc client :client/bank-accounts :client/locked-until) :bank-account (dissoc bank-account :bank-account/start-date) #_#_:client {:name (:name client) :address (:address client) :signature-file (:signature-file client) :bank {:name (:bank-account/bank-name bank-account) :acct (:bank-account/bank-code bank-account) :routing (:bank-account/routing bank-account) :acct-number (:bank-account/number bank-account)}}})))] (-> [] (conj payment) (into (invoice-payments invoices invoice-amounts))))) (defmethod invoices->entities :payment-type/debit [invoices vendor client bank-account type index invoice-amounts] (when (<= (->> invoices (map (comp invoice-amounts :db/id)) (reduce + 0.0)) 0.001) (throw (ex-info "The selected invoices do not have an outstanding balance." {:validation-error "The selected invoices do not have an outstanding balance."}))) (let [payment (assoc (base-payment invoices vendor client bank-account type index invoice-amounts) :payment/type :payment-type/debit :payment/memo (str "Debit Invoice #'s: " (str/join ", " (map (fn [i] (str (:invoice/invoice-number i) "(" (invoice-amounts (:db/id i)) ")")) invoices))) :payment/status :payment-status/pending)] (-> [] (conj payment) (into (invoice-payments invoices invoice-amounts))))) (defmethod invoices->entities :payment-type/credit [invoices vendor client bank-account type index invoice-amounts] (when (>= (->> invoices (map (comp invoice-amounts :db/id)) (reduce + 0.0)) 0.001) (throw (ex-info "The selected invoices do not have an outstanding balance." {:validation-error "The selected invoices do not have an outstanding balance."}))) (let [payment (assoc (base-payment invoices vendor client bank-account type index invoice-amounts) :payment/type :payment-type/credit :payment/memo (str "Debit Invoice #'s: " (str/join ", " (map (fn [i] (str (:invoice/invoice-number i) "(" (invoice-amounts (:db/id i)) ")")) invoices))) :payment/status :payment-status/pending)] (-> [] (conj payment) (into (invoice-payments invoices invoice-amounts))))) (defmethod invoices->entities :payment-type/cash [invoices vendor client bank-account type index invoice-amounts] (when (<= (->> invoices (map (comp invoice-amounts :db/id)) (reduce + 0.0)) 0.001) (throw (ex-info "The selected invoices do not have an outstanding balance." {:validation-error "The selected invoices do not have an outstanding balance."}))) (let [base-payment (base-payment invoices vendor client bank-account type index invoice-amounts) transaction-id (str (UUID/randomUUID)) memo (str "Cash Invoice #'s: " (str/join ", " (map (fn [i] (str (:invoice/invoice-number i) "(" (invoice-amounts (:db/id i)) ")")) invoices))) payment (assoc base-payment :payment/type :payment-type/cash :payment/memo memo :payment/status :payment-status/cleared) transaction {:db/id (str "transaction-" (:db/id vendor)) :transaction/amount (- (:payment/amount base-payment)) :transaction/payment (str (:db/id vendor)) :transaction/client (:db/id client) :transaction/status "POSTED" :transaction/bank-account (:db/id bank-account) :transaction/id #_{:clj-kondo/ignore [:unresolved-var]} (digest/sha-256 transaction-id) :transaction/raw-id transaction-id :transaction/vendor (:db/id vendor) :transaction/description-original memo :transaction/date (:payment/date base-payment) :transaction/approval-status :transaction-approval-status/approved :transaction/accounts [{:transaction-account/account (:db/id (a/get-account-by-numeric-code-and-sets 21000 ["default"])) :transaction-account/location "A" :transaction-account/amount (Math/abs (:payment/amount base-payment))}]}] (-> [] (conj payment) (conj transaction) (into (invoice-payments invoices invoice-amounts))))) (defn validate-belonging [client-id invoices bank-account] (when-not (apply = client-id (map (comp :db/id :invoice/client) invoices )) (throw (ex-info "You can't pay for that invoice from this bank account." {:validation-error "You can't pay for that invoice from this bank account." :client-id client-id :invoices (map :invoice/invoice-number invoices)})) ) (when-not (= client-id (:db/id (:client/_bank-accounts bank-account))) (throw (ex-info "The selected bank doesn't belong to this client" {:validation-error "The selected bank doesn't belong to this client" :client-id client-id :invoices (map :invoice/invoice-number invoices)})))) (defn print-checks-internal [invoice-payments client-id bank-account-id type id] (let [type (keyword "payment-type" (name type)) invoices (d-invoices/get-multi (map :invoice-id invoice-payments)) client (d-clients/get-by-id client-id) vendors (by :db/id (d-vendors/get-graphql {})) invoice-amounts (by :invoice-id :amount invoice-payments) invoices-grouped-by-vendor (group-by (comp :db/id :invoice/vendor) invoices) bank-account (d-bank-accounts/get-by-id bank-account-id) _ (validate-belonging client-id invoices bank-account) _ (when (and (nil? (:bank-account/check-number bank-account)) (= type :payment-type/check)) (let [message (str "The bank account " (:bank-account/name bank-account) " does not have a starting check number. Please ask the integreat staff to initialize it.")] (throw (ex-info message {:validation-error message})))) checks (->> (for [[[vendor-id invoices] index] (map vector invoices-grouped-by-vendor (range))] (invoices->entities invoices (vendors vendor-id) client bank-account type index invoice-amounts)) (reduce into []) doall) checks (if (= type :payment-type/check) (conj checks [:inc (:db/id bank-account) :bank-account/check-number (count invoices-grouped-by-vendor)]) checks)] (when (= type :payment-type/check) (make-pdfs (filter #(and (= :payment-type/check (:payment/type %)) (> (:payment/amount %) 0.0)) checks))) (audit-transact checks id) {:invoices (d-invoices/get-multi (map :invoice-id invoice-payments)) :pdf-url (if (= type :payment-type/check) (merge-pdfs (filter identity (map :payment/s3-key checks))) nil)})) (defn get-payment-page [context args _] (let [args (assoc args :id (:id context)) [payments checks-count] (d-checks/get-graphql (-> args :filters (<-graphql) (update :payment-type enum->keyword "payment-type") (update :status enum->keyword "payment-status")))] [{:payments (->> payments (map (fn [payment] (if (seq (:transaction/_payment payment)) (-> payment (set/rename-keys {:transaction/_payment :transaction}) (update :transaction first)) payment))) (map ->graphql )) :total checks-count :count (count payments) :start (-> args :filters (:start 0) ) :end (+ (-> args :filters (:start 0) ) (count payments))}])) (defn get-potential-payments [context args _] (let [transaction (d-transactions/get-by-id (:transaction_id args)) _ (assert-can-see-client (:id context) (:transaction/client transaction)) [payments _] (d-checks/get-graphql {:client-id (:db/id (:transaction/client transaction)) :bank-account-id (:db/id (:transaction/bank-account transaction)) :amount (- (:transaction/amount transaction)) :status :payment-status/pending})] (map ->graphql payments))) (defn add-handwritten-check [context args _] (let [invoices (d-invoices/get-multi (map :invoice_id (:invoice_payments args))) bank-account-id (:bank_account_id args) bank-account (d-bank-accounts/get-by-id bank-account-id) _ (doseq [invoice invoices] (assert-can-see-client (:id context) (:invoice/client invoice))) client-id (:db/id (:invoice/client (first invoices))) _ (validate-belonging (:db/id (:client/_bank-accounts bank-account)) invoices bank-account) _ (assert-not-locked client-id (:date args)) invoice-payment-lookup (by :invoice_id :amount (:invoice_payments args)) base-payment (base-payment invoices (:invoice/vendor (first invoices)) (:invoice/client (first invoices)) bank-account :payment-type/check 0 invoice-payment-lookup)] (audit-transact (into [(assoc base-payment :payment/type :payment-type/check :payment/status :payment-status/pending :payment/check-number (:check_number args) :payment/date (c/to-date (parse (:date args) iso-date)))] (invoice-payments invoices invoice-payment-lookup)) (:id context)) (->graphql {:s3-url nil :invoices (d-invoices/get-multi (map :invoice_id (:invoice_payments args)))}))) (defn void-payment [context {id :payment_id} _] (let [check (d-checks/get-by-id id)] (assert (or (= :payment-status/pending (:payment/status check)) (#{:payment-type/cash :payment-type/debit} (:payment/type check)))) (assert-can-see-client (:id context) (:db/id (:payment/client check))) (assert-not-locked (:db/id (:payment/client check)) (:payment/date check)) (let [removing-payments (mapcat (fn [x] (let [invoice (:invoice-payment/invoice x) new-balance (+ (:invoice/outstanding-balance invoice) (:invoice-payment/amount x))] [[:db.fn/retractEntity (:db/id x)] {:db/id (:db/id invoice) :invoice/outstanding-balance new-balance :invoice/status (if (dollars-0? new-balance) (:invoice/status invoice) :invoice-status/unpaid)}])) (:payment/invoices check)) updated-payment {:db/id id :payment/amount 0.0 :payment/status :payment-status/voided}] (audit-transact (conj removing-payments updated-payment) (:id context))) (-> (d-checks/get-by-id id) (->graphql)))) (defn void-payments [context args _] (assert-admin (:id context)) (let [args (assoc args :id (:id context)) ids (some-> args :filters (assoc :id (:id context)) (<-graphql) (update :payment-type enum->keyword "payment-type") (update :status enum->keyword "payment-status") (assoc :per-page Integer/MAX_VALUE) d-checks/raw-graphql-ids :ids) specific-ids (d-checks/filter-ids (:ids args)) all-ids (into (set ids) specific-ids)] (log/info "Voiding " (count all-ids) args) (audit-transact (->> all-ids (d/q '[:find [(pull ?p [:db/id {:invoice-payment/_payment [:invoice-payment/amount :db/id {:invoice-payment/invoice [:db/id :invoice/outstanding-balance]}]}]) ...] :in $ [?p ...] :where (not [_ :transaction/payment ?p]) (not [?p :payment/status :payment-status/voided]) [?p :payment/client ?c] [(get-else $ ?c :client/locked-until #inst "2000-01-01") ?lu] [?p :payment/date ?d] [(>= ?d ?lu)] ] (d/db conn)) (mapcat (fn [{:keys [:db/id] invoices :invoice-payment/_payment}] (into [{:db/id id :payment/amount 0.0 :payment/status :payment-status/voided}] (->> invoices (mapcat (fn [{:keys [:invoice-payment/invoice :db/id :invoice-payment/amount]}] (let [new-balance (+ (:invoice/outstanding-balance invoice) amount)] [[:db.fn/retractEntity id] {:db/id (:db/id invoice) :invoice/outstanding-balance new-balance :invoice/status (if (dollars-0? new-balance) (:invoice/status invoice) :invoice-status/unpaid)}])))))))) (:id context)) {:message (str "Succesfully voided " (count all-ids))})) (defn get-all-payments [context args _] (assert-admin (:id context)) (map ->graphql (first (d-checks/get-graphql (assoc (<-graphql args) :count Integer/MAX_VALUE))))) (defn print-checks [context args _] (assert-can-see-client (:id context) (:client_id args)) (->graphql (print-checks-internal (map (fn [i] {:invoice-id (:invoice_id i) :amount (:amount i)}) (:invoice_payments args)) (:client_id args) (:bank_account_id args) (:type args) (:id context)))) (def objects {:payment {:fields {:id {:type :id} :type {:type :payment_type} :original_id {:type 'Int} :amount {:type 'String} :vendor {:type :vendor} :client {:type :client} :date {:type 'String} :bank_account {:type :bank_account} :memo {:type 'String} :s3_url {:type 'String} :check_number {:type 'Int} :status {:type :ident} :transaction {:type :transaction} :invoices {:type '(list :invoice_payment)}}} :invoice_payment {:fields {:id {:type :id} :amount {:type 'String} :invoice_id {:type 'String} :payment_id {:type 'String} :payment {:type :payment} :invoice {:type :invoice}}} :payment_page {:fields {:payments {:type '(list :payment)} :count {:type 'Int} :total {:type 'Int} :start {:type 'Int} :end {:type 'Int}}}}) (def queries {:all_payments {:type '(list :payment) :args {:client_id {:type :id} :client_code {:type 'String} :original_id {:type 'Int} :statuses {:type '(list String)}} :resolve :get-all-payments} :payment_page {:type '(list :payment_page) :args {:filters {:type :payment_filters}} :resolve :get-payment-page} :potential_payment_matches {:type '(list :payment) :args {:transaction_id {:type :id}} :resolve :get-potential-payments}}) (def mutations {:print_checks {:type :check_result :args {:invoice_payments {:type '(list :invoice_payment_amount)} :bank_account_id {:type :id} :type {:type :payment_type} :client_id {:type :id}} :resolve :mutation/print-checks} :add_handwritten_check {:type :check_result :args {:invoice_payments {:type '(list :invoice_payment_amount)} :date {:type 'String} :check_number {:type 'Int} :bank_account_id {:type :id}} :resolve :mutation/add-handwritten-check} :void_payment {:type :payment :args {:payment_id {:type :id}} :resolve :mutation/void-payment} :void_payments {:type :message :args {:filters {:type :payment_filters} :ids {:type '(list :id)}} :resolve :mutation/void-payments}}) (def input-objects {:invoice_payment_amount {:fields {:invoice_id {:type :id} :amount {:type :money}}} :payment_filters {:fields {:client_id {:type :id} :vendor_id {:type :id} :payment_type {:type :payment_type} :status {:type :payment_status} :exact_match_id {:type :id} :date_range {:type :date_range} :amount_lte {:type :money} :amount_gte {:type :money} :check_number_like {:type 'String} :invoice_number {:type 'String} :start {:type 'Int} :per_page {:type 'Int} :sort {:type '(list :sort_item)}}}}) (def enums {:payment_type {:values [{:enum-value :check} {:enum-value :cash} {:enum-value :debit} {:enum-value :credit}]} :payment_status {:values [{:enum-value :voided} {:enum-value :pending} {:enum-value :cleared}]}}) (def resolvers {:get-potential-payments get-potential-payments :get-payment-page get-payment-page :get-all-payments get-all-payments :mutation/void-payment void-payment :mutation/void-payments void-payments :mutation/print-checks print-checks :mutation/add-handwritten-check add-handwritten-check }) (defn attach [schema] (-> (merge-with merge schema {:objects objects :queries queries :mutations mutations :input-objects input-objects :enums enums}) (attach-resolvers resolvers)))