(ns auto-ap.graphql.invoices (:require [auto-ap.datomic :refer [conn pull-attr pull-many pull-ref random-tempid audit-transact audit-transact-batch]] [auto-ap.datomic.clients :as d-clients] [auto-ap.datomic.invoices :as d-invoices] [auto-ap.datomic.vendors :as d-vendors] [auto-ap.graphql.checks :as gq-checks] [auto-ap.graphql.utils :as u :refer [<-graphql assert-admin assert-can-see-client assert-failure assert-not-locked assert-power-user attach-tracing-resolvers enum->keyword]] [auto-ap.rule-matching :as rm] [auto-ap.utils :refer [dollars=]] [clj-time.coerce :as coerce] [clj-time.core :as time] [clojure.tools.logging :as log] [com.brunobonacci.mulog :as mu] [datomic.api :as dc] [auto-ap.solr :as solr])) (defn ->graphql [invoice user ] (if (= "admin" (:user/role user)) (u/->graphql invoice) (u/->graphql (if (:invoice/source-url-admin-only invoice) (dissoc invoice :invoice/source-url) invoice)))) (defn get-invoice-page [context args _] (let [args (assoc args :id (:id context)) [invoices invoice-count outstanding total-amount] (-> args :filters (assoc :clients (:clients context)) (<-graphql) (update :status enum->keyword "invoice-status") (d-invoices/get-graphql))] [{:invoices (mapv #(->graphql % (:id context)) invoices) :outstanding outstanding :total_amount total-amount :total invoice-count :count (count invoices) :start (-> args :filters (:start 0)) :end (+ (-> args :filters (:start 0)) (count invoices))}])) (defn get-all-invoices [context args _] (assert-admin (:id context)) (map u/->graphql (first (d-invoices/get-graphql (assoc (<-graphql args) :count Integer/MAX_VALUE))))) (defn reject-invoices [context {:keys [invoices]} _] (assert-power-user (:id context)) (doseq [i invoices] (assert-can-see-client (:id context) (:db/id (:invoice/client (dc/pull (dc/db conn) [{:invoice/client [:db/id]}] i))))) (let [transactions (mapcat (fn [i] [[:db/retractEntity i]]) invoices)] (audit-transact transactions (:id context)) invoices)) (defn approve-invoices [context {:keys [invoices]} _] (assert-power-user (:id context)) (doseq [i invoices :let [invoice (dc/pull (dc/db conn) [{:invoice/client [:db/id]} :invoice/date] i)]] (assert-can-see-client (:id context) (-> invoice :invoice/client :db/id)) (assert-not-locked (-> invoice :invoice/client :db/id) (-> invoice :invoice/date))) (let [transactions (map (fn [i] [:upsert-invoice {:db/id i :invoice/import-status :import-status/imported}]) invoices)] (audit-transact transactions (:id context)) invoices)) (defn assert-no-conflicting [{:keys [invoice_number client_id vendor_id]}] (when-not vendor_id (assert-failure "Please specify a vendor.")) (when-not invoice_number (assert-failure "Please specify an invoice.")) (when (seq (d-invoices/find-conflicting {:invoice/invoice-number invoice_number :invoice/vendor vendor_id :invoice/client client_id})) (throw (ex-info (str "Invoice '" invoice_number "' already exists.") {:invoice-number invoice_number :validation-error (str "Invoice '" invoice_number "' already exists.")})))) (defn expense-account->entity [{:keys [id account_id amount location]}] #:invoice-expense-account {:amount amount :db/id (or id (random-tempid)) :account account_id :location location}) (defn add-invoice-transaction [{:keys [total invoice_number scheduled_payment client_id vendor_id date due expense_accounts]}] (let [vendor (d-vendors/get-by-id vendor_id) account (:vendor/default-account vendor) due (or (and (:vendor/terms vendor) (coerce/to-date (time/plus date (time/days (d-vendors/terms-for-client-id vendor client_id))))) due nil) scheduled_payment (or scheduled_payment (and (d-vendors/automatically-paid-for-client-id? vendor client_id) due) nil) _ (when-not (:db/id account) (throw (ex-info (str "Vendor '" (:vendor/name vendor) "' does not have a default expense acount.") {:vendor-id vendor_id})))] [:upsert-invoice {:db/id "invoice" :invoice/invoice-number invoice_number :invoice/client client_id :invoice/vendor vendor_id :invoice/import-status :import-status/imported :invoice/total total :invoice/outstanding-balance total :invoice/status :invoice-status/unpaid :invoice/date (coerce/to-date date) :invoice/expense-accounts (map expense-account->entity expense_accounts) :invoice/due (coerce/to-date due) :invoice/scheduled-payment (coerce/to-date scheduled_payment)}])) (defn assert-valid-expense-accounts [expense_accounts vendor_id] (doseq [expense-account expense_accounts :let [account (dc/pull (dc/db conn) [:db/id :account/location {:account/invoice-allowance [:db/ident]}] (:account_id expense-account))]] (when (empty? (:location expense-account)) (throw (ex-info "Expense account is missing location" {:validation-error "Expense account is missing location"}))) (when (and (seq (:account/location account)) (not= (:location expense-account) (:account/location account))) (let [err (str "Account uses location '" (:location expense-account) "' but expects '" (:account/location account) "'")] (throw (ex-info err {:validation-error err})))) (when (and (= :allowance/denied (:db/ident (:account/invoice-allowance account))) (not= (pull-ref (dc/db conn) :vendor/default-account vendor_id ) (:db/id account))) (let [err (str "Account isn't allowed for invoice use.")] (throw (ex-info err {:validation-error err})))) (when (and (empty? (:account/location account)) (= "A" (:location expense-account))) (let [err (str "Account uses location '" (:location expense-account) "', which is reserved for liabilities, equities, and assets.")] (throw (ex-info err {:validation-error err})))) (when (nil? (:account_id expense-account)) (throw (ex-info "Expense account is missing account" {:validation-error "Expense account is missing account"}))) (when (nil? (:amount expense-account)) (throw (ex-info "Expense account does not have an amount specified." {:validation-error "Expense account does not have an amount specified."}))))) (defn assert-invoice-amounts-add-up [{:keys [expense_accounts total]}] (let [expense-account-total (reduce + 0 (map (fn [x] (:amount x)) expense_accounts))] (when-not (dollars= total expense-account-total) (let [error (str "Expense account total (" expense-account-total ") does not equal invoice total (" total ")")] (throw (ex-info error {:validation-error error})))))) (defn add-invoice [context {{:keys [expense_accounts client_id vendor_id] :as in} :invoice} _] (assert-no-conflicting in) (assert-can-see-client (:id context) client_id) (assert-not-locked client_id (:date in)) (assert-valid-expense-accounts expense_accounts vendor_id) (assert-invoice-amounts-add-up in) (let [transaction-result (audit-transact [(add-invoice-transaction in)] (:id context))] (solr/touch-with-ledger (get-in transaction-result [:tempids "invoice"])) (-> (d-invoices/get-by-id (get-in transaction-result [:tempids "invoice"])) (->graphql (:id context))))) (defn assert-bank-account-belongs [client-id bank-account-id] (when-not ((set (map :db/id (:client/bank-accounts (d-clients/get-by-id client-id)))) bank-account-id) (throw (ex-info (str "Bank account does not belong to client") {:validation-error "Bank account does not belong to client."})))) (defn add-and-print-invoice [context {{:keys [total client_id vendor_id] :as in} :invoice bank-account-id :bank_account_id type :type} _] (mu/trace ::validating-invoice [:invoice in] (do (assert-no-conflicting in) (assert-can-see-client (:id context) client_id) (assert-bank-account-belongs client_id bank-account-id) (assert-not-locked client_id (:date in)) (assert-valid-expense-accounts (:expense_accounts in) vendor_id) (assert-invoice-amounts-add-up in))) (let [transaction-result (audit-transact [(add-invoice-transaction in)] (:id context))] (mu/trace ::printing-checks [] (-> (gq-checks/print-checks-internal [{:invoice-id (get-in transaction-result [:tempids "invoice"]) :amount total}] client_id bank-account-id type (:id context)) u/->graphql)))) (defn edit-invoice [context {{:keys [id due invoice_number vendor_id total date expense_accounts scheduled_payment] :as in} :invoice} _] (let [invoice (d-invoices/get-by-id id) _ (when (seq (d-invoices/find-conflicting {:db/id id :invoice/invoice-number invoice_number :invoice/vendor (:db/id (:invoice/vendor invoice)) :invoice/client (:db/id (:invoice/client invoice))})) (throw (ex-info (str "Invoice '" invoice_number "' already exists.") {:invoice-number invoice_number}))) paid-amount (- (:invoice/total invoice) (:invoice/outstanding-balance invoice)) _ (assert-can-see-client (:id context) (:db/id (:invoice/client invoice))) _ (assert-not-locked (:db/id (:invoice/client invoice)) (:date in)) _ (assert-valid-expense-accounts expense_accounts vendor_id) _ (assert-invoice-amounts-add-up in) updated-invoice {:db/id id :invoice/invoice-number invoice_number :invoice/date (coerce/to-date date) :invoice/total total :invoice/outstanding-balance (- total paid-amount) :invoice/expense-accounts (map expense-account->entity expense_accounts) :invoice/due (coerce/to-date due) :invoice/scheduled-payment (coerce/to-date scheduled_payment)}] (audit-transact [[:upsert-invoice updated-invoice]] (:id context)) (solr/touch-with-ledger id) (-> (d-invoices/get-by-id id) (->graphql (:id context))))) (defn void-invoice [context {id :invoice_id} _] (let [invoice (d-invoices/get-by-id id)] (assert-can-see-client (:id context) (:db/id (:invoice/client invoice))) (assert-not-locked (:db/id (:invoice/client invoice)) (:invoice/date invoice)) (audit-transact [[:upsert-invoice {:db/id id :invoice/total 0.0 :invoice/outstanding-balance 0.0 :invoice/status :invoice-status/voided :invoice/expense-accounts (map (fn [ea] {:db/id (:db/id ea) :invoice-expense-account/amount 0.0}) (:invoice/expense-accounts invoice))}]] (:id context)) (-> (d-invoices/get-by-id id) (->graphql (:id context))))) (defn get-ids-matching-filters [args] (let [ids (some-> args :filters (<-graphql) (assoc :clients (:clients args)) (update :status enum->keyword "invoice-status") (assoc :per-page Integer/MAX_VALUE) d-invoices/raw-graphql-ids :ids) specific-ids (d-invoices/filter-ids (:ids args))] (into (set ids) specific-ids))) (defn all-ids-not-locked [all-ids] (->> all-ids (dc/q '[:find ?i :in $ [?i ...] :where [?i :invoice/client ?c] [(get-else $ ?c :client/locked-until #inst "2000-01-01") ?lu] [?i :invoice/date ?d] [(>= ?d ?lu)]] (dc/db conn)) (map first))) (defn void-invoices [context args _] (let [_ (assert-admin (:id context)) args (assoc args :clients (:clients context)) all-ids (all-ids-not-locked (get-ids-matching-filters args)) voidable-cash-payments (->> (dc/q '[:find ?p :in $ [?i ...] :where [?ip :invoice-payment/invoice ?i] [?ip :invoice-payment/payment ?p] [?p :payment/type :payment-type/cash] [?i :invoice/client ?c] [(get-else $ ?c :client/locked-until #inst "2000-01-01") ?lu] [?i :invoice/date ?d] [(>= ?d ?lu)]] (dc/db conn) all-ids) (map first)) ] (log/info "Voiding " (count voidable-cash-payments) "cash payments first") (gq-checks/void-payments-internal voidable-cash-payments (:id context)) (log/info "Voiding " (count all-ids) args) (audit-transact (->> all-ids (dc/q '[:find (pull ?i [:db/id :invoice/date {:invoice/expense-accounts [:db/id]}]) :in $ [?i ...] :where (not [_ :invoice-payment/invoice ?i]) [?i :invoice/client ?c] [(get-else $ ?c :client/locked-until #inst "2000-01-01") ?lu] [?i :invoice/date ?d] [(>= ?d ?lu)]] (dc/db conn)) (map (fn [[i]] [:upsert-invoice {:db/id (:db/id i) :invoice/total 0.0 :invoice/outstanding-balance 0.0 :invoice/status :invoice-status/voided :invoice/expense-accounts (mapv (fn [iea] {:db/id (:db/id iea) :invoice-expense-account/amount 0.0}) (:invoice/expense-accounts i))}]))) (:id context)) {:message (str "Succesfully voided " (count all-ids))})) (defn unvoid-invoice [context {id :invoice_id} _] (let [invoice (d-invoices/get-by-id id) _ (assert-can-see-client (:id context) (:db/id (:invoice/client invoice))) _ (assert-not-locked (:db/id (:invoice/client invoice)) (:invoice/date invoice)) history (dc/history (dc/db conn)) txs (dc/q {:find ['?tx '?e '?original-status '?original-outstanding '?total '?ea '?ea-amount] :where ['[?e :invoice/status :invoice-status/voided ?tx true] '[?e :invoice/status ?original-status ?tx false] '[?e :invoice/outstanding-balance ?original-outstanding ?tx false] '[?e :invoice/total ?total ?tx false] '[?ea :invoice-expense-account/amount ?ea-amount ?tx false]] :in ['$ '?e]} history id) [last-transaction] (->> txs (sort-by first) (last))] (audit-transact [[:upsert-invoice (->> txs (filter (fn [[tx]] (= tx last-transaction))) (reduce (fn [new-transaction [_ entity original-status original-outstanding total expense-account expense-account-amount]] (-> new-transaction (assoc :db/id entity :invoice/total total :invoice/status original-status :invoice/outstanding-balance original-outstanding) (update :invoice/expense-accounts (fnil conj []) {:db/id expense-account :invoice-expense-account/amount expense-account-amount}))) {}))]] (:id context)) (-> (d-invoices/get-by-id id) (->graphql (:id context))))) (defn unautopay-invoice [context {id :invoice_id} _] (let [invoice (dc/pull (dc/db conn) [{:invoice/client [:db/id]} :invoice-payment/_invoice :invoice/total :invoice/scheduled-payment :invoice/date] id)] (assert (:invoice/client invoice)) (assert-can-see-client (:id context) (:db/id (:invoice/client invoice))) (assert-not-locked (:db/id (:invoice/client invoice)) (:invoice/date invoice)) (assert (not (seq (:invoice-payment/_invoice invoice)))) (audit-transact [[:upsert-invoice {:db/id id :invoice/status :invoice-status/unpaid :invoice/outstanding-balance (:invoice/total invoice) :invoice/scheduled-payment nil}]] (:id context)) (-> (d-invoices/get-by-id id) (->graphql (:id context))))) (defn edit-expense-accounts [context args _] (assert-can-see-client (:id context) (:db/id (:invoice/client (d-invoices/get-by-id (:invoice_id args))))) (let [invoice-id (:invoice_id args) invoice (d-invoices/get-by-id invoice-id) _ (assert-not-locked (:db/id (:invoice/client invoice)) (:invoice/date invoice)) _ (assert-valid-expense-accounts (:expense_accounts args) (:db/id (:invoice/vendor invoice )))] (audit-transact [[:upsert-invoice {:db/id invoice-id :invoice/expense-accounts (map expense-account->entity (:expense_accounts args))}]] (:id context)) (->graphql (d-invoices/get-by-id (:invoice_id args)) (:id context)))) ;; TODO - multiple versions of this now exist. fix in datomic migration? ;; Approach could be something like this: (thanks chatgpt) ;; (defn distribute-cents [amount categories] ;; (let [dollars (quot amount 1) ;; cents (rem amount 1) ;; cents-per-category (quot cents (count categories)) ;; extra-cents (rem cents (count categories))] ;; (zipmap categories (repeat cents-per-category)) ;; (reduce-kv (fn [acc i category] ;; (update acc category + (if (< i extra-cents) 1 0))) ;; acc ;; categories))) ;; convert to/from decimal first ;; for categories, pass in the accounts, and return a tuple of [account amount] ;; after return, just use (assoc account :invoice-expnese-account/amount amount) (defn maybe-code-accounts [invoice account-rules valid-locations] (with-precision 2 (let [accounts (vec (mapcat (fn [ar] (let [cents-to-distribute (int (Math/round (Math/abs (* (:percentage ar) (:invoice/total invoice) 100))))] (if (= "Shared" (:location ar)) (do (->> valid-locations (map (fn [cents location] {:db/id (random-tempid) :invoice-expense-account/account (:account_id ar) :invoice-expense-account/amount (* 0.01 cents) :invoice-expense-account/location location}) (rm/spread-cents cents-to-distribute (count valid-locations))))) [(cond-> {:db/id (random-tempid) :invoice-expense-account/account (:account_id ar) :invoice-expense-account/amount (* 0.01 cents-to-distribute)} (:location ar) (assoc :invoice-expense-account/location (:location ar)))]))) account-rules)) accounts (mapv (fn [a] (update a :invoice-expense-account/amount #(with-precision 2 (double (.setScale (bigdec %) 2 java.math.RoundingMode/HALF_UP))))) accounts) leftover (with-precision 2 (.round (bigdec (- (Math/abs (:invoice/total invoice)) (Math/abs (reduce + 0.0 (map #(:invoice-expense-account/amount %) accounts))))) *math-context*)) accounts (if (seq accounts) (update-in accounts [(dec (count accounts)) :invoice-expense-account/amount] #(+ % (double leftover))) [])] accounts))) (defn bulk-change-invoices [context args _] (assert-admin (:id context)) (when-not (:client_id args) (throw (ex-info "Client is required" {:validation-error "client is required"}))) (let [args (assoc args :clients [{:db/id (:client_id args)}]) locations (pull-attr (dc/db conn) :client/locations (:client_id args)) all-ids (all-ids-not-locked (get-ids-matching-filters args)) invoices (pull-many (dc/db conn) '[:db/id :invoice/total] (vec all-ids)) account-total (reduce + 0 (map (fn [x] (:percentage x)) (:accounts args)))] (log/info "client is" locations) (when (not (dollars= 1.0 account-total)) (let [error (str "Account total (" account-total ") does not reach 100%")] (throw (ex-info error {:validation-error error})))) (doseq [a (:accounts args) :let [{:keys [:account/location :account/name]} (dc/pull (dc/db conn) [:account/location :account/name] (:account_id a))]] (when (and location (not= location (:location a))) (let [err (str "Account " name " uses location " (:location a) ", but is supposed to be " location)] (throw (ex-info err {:validation-error err}) ))) (when (and (not location) (not (get (into #{"Shared"} locations) (:location a)))) (let [err (str "Account " name " uses location " (:location a) ", but doesn't belong to the client.")] (throw (ex-info err {:validation-error err}) )))) (log/info "Bulk coding " (count all-ids) args) (audit-transact-batch (map (fn [i] [:upsert-invoice {:db/id (:db/id i) :invoice/expense-accounts (maybe-code-accounts i (:accounts args) locations)}]) invoices) (:id context)) {:message (str "Successfully coded " (count all-ids) " invoices.")})) (def objects {:invoice {:fields {:id {:type :id} :original_id {:type 'Int} :client_identifier {:type 'String} :total {:type 'String} :source_url {:type 'String} :outstanding_balance {:type 'String} :invoice_number {:type 'String} :status {:type 'String} :expense_accounts {:type '(list :invoices_expense_accounts)} :similarity {:type 'Float} :date {:type :iso_date} :due {:type :iso_date} :client_id {:type 'Int} :payments {:type '(list :invoice_payment)} :vendor {:type :vendor} :client {:type :client} :scheduled_payment {:type :iso_date}}} :invoices_expense_accounts {:fields {:id {:type :id} :invoice_id {:type 'String} :account {:type :account} :location {:type 'String} :amount {:type 'String}}} :invoice_page {:fields {:invoices {:type '(list :invoice)} :outstanding {:type :money} :total_amount {:type :money} :count {:type 'Int} :total {:type 'Int} :start {:type 'Int} :end {:type 'Int}}}}) (def queries {:invoice_page {:type '(list :invoice_page) :args {:filters {:type :invoice_filters}} :resolve :get-invoice-page} :all_invoices {:type '(list :invoice) :args {:client_id {:type :id} :client_code {:type 'String} :original_id {:type 'Int} :statuses {:type '(list String)}} :resolve :get-all-invoices}}) (def mutations {:edit_invoice {:type :invoice :args {:invoice {:type :edit_invoice}} :resolve :mutation/edit-invoice} :add_invoice {:type :invoice :args {:invoice {:type :add_invoice}} :resolve :mutation/add-invoice} :void_invoice {:type :invoice :args {:invoice_id {:type :id}} :resolve :mutation/void-invoice} :void_invoices {:type :message :args {:filters {:type :invoice_filters} :ids {:type '(list :id)}} :resolve :mutation/void-invoices} :unvoid_invoice {:type :invoice :args {:invoice_id {:type :id}} :resolve :mutation/unvoid-invoice} :unautopay_invoice {:type :invoice :args {:invoice_id {:type :id}} :resolve :mutation/unautopay-invoice} :reject_invoices {:type '(list :id) :args {:invoices {:type '(list :id)}} :resolve :mutation/reject-invoices} :approve_invoices {:type '(list :id) :args {:invoices {:type '(list :id)}} :resolve :mutation/approve-invoices} :add_and_print_invoice {:type :check_result :args {:invoice {:type :add_invoice} :bank_account_id {:type :id} :type {:type :payment_type}} :resolve :mutation/add-and-print-invoice} :edit_expense_accounts {:type :invoice :args {:invoice_id {:type :id} :expense_accounts {:type '(list :edit_expense_account)}} :resolve :mutation/edit-expense-accounts} :bulk_change_invoices {:type :message :args {:filters {:type :invoice_filters} :client_id {:type :id} :accounts {:type '(list :edit_percentage_account)} :ids {:type '(list :id)}} :resolve :mutation/bulk-change-invoices}}) (def input-objects {:add_invoice {:fields {:id {:type :id} :invoice_number {:type 'String} :expense_accounts {:type '(list :edit_expense_account)} :location {:type 'String} :scheduled_payment {:type :iso_date} :date {:type :iso_date} :due {:type :iso_date} :client_id {:type :id} :vendor_id {:type :id} :vendor_name {:type 'String} :total {:type :money}}} :edit_invoice {:fields {:id {:type :id} :invoice_number {:type 'String} :expense_accounts {:type '(list :edit_expense_account)} :date {:type :iso_date} :scheduled_payment {:type :iso_date} :due {:type :iso_date} :total {:type :money}}} :edit_expense_account {:fields {:id {:type :id} :account_id {:type :id} :location {:type 'String} :amount {:type :money}}} :invoice_filters {:fields {:import_status {:type 'String} :exact_match_id {:type :id} :date_range {:type :date_range} :due_range {:type :date_range} :status {:type :invoice_status} :unresolved {:type 'Boolean} :scheduled_payments {:type 'Boolean} :vendor_id {:type :id} :account_id {:type :id} :amount_lte {:type :money} :amount_gte {:type :money} :invoice_number_like {:type 'String} :location {:type 'String} :start {:type 'Int} :per_page {:type 'Int} :sort {:type '(list :sort_item)}}}}) (def enums {:invoice_status {:values [{:enum-value :paid} {:enum-value :unpaid} {:enum-value :voided}]}}) (def resolvers {:get-invoice-page get-invoice-page :get-all-invoices get-all-invoices :mutation/reject-invoices reject-invoices :mutation/approve-invoices approve-invoices :mutation/add-invoice add-invoice :mutation/add-and-print-invoice add-and-print-invoice :mutation/edit-invoice edit-invoice :mutation/void-invoice void-invoice :mutation/void-invoices void-invoices :mutation/bulk-change-invoices bulk-change-invoices :mutation/unvoid-invoice unvoid-invoice :mutation/unautopay-invoice unautopay-invoice :mutation/edit-expense-accounts edit-expense-accounts}) (defn attach [schema] (-> (merge-with merge schema {:objects objects :queries queries :mutations mutations :input-objects input-objects :enums enums}) (attach-tracing-resolvers resolvers)))