Files
integreat/src/clj/auto_ap/graphql/clients.clj

600 lines
29 KiB
Clojure

(ns auto-ap.graphql.clients
(:require
[amazonica.aws.s3 :as s3]
[auto-ap.datomic :refer [audit-transact conn upsert-entity random-tempid]]
[auto-ap.datomic.clients :as d-clients]
[auto-ap.square.core :as square]
[auto-ap.graphql.utils
:refer [->graphql assert-admin can-see-client? is-admin? attach-tracing-resolvers]]
[auto-ap.routes.queries :as q]
[clj-time.coerce :as coerce]
[clojure.java.io :as io]
[clojure.set :as set]
[clojure.string :as str]
[clojure.tools.logging :as log]
[datomic.client.api :as dc]
[unilog.context :as lc]
[com.brunobonacci.mulog :as mu]
[datomic.client.api :as dc])
(:import
(java.util UUID)
(org.apache.commons.codec.binary Base64)))
(defn assert-client-code-is-unique [code]
(when (seq (dc/q {:query {:find '[?id]
:in ['$ '?code]
:where ['[?id :client/code ?code]]}
:args [(dc/db conn) code]}))
(throw (ex-info "Client is not unique" {:validation-error (str "Client code '" code "' is not unique.")}))))
(defn upload-signature-data [signature-data]
(let [prefix "data:image/jpeg;base64,"]
(when signature-data
(when-not (str/starts-with? signature-data prefix)
(throw (ex-info "Invalid signature image" {:validation-error (str "Invalid signature image.")})))
(let [signature-id (str (UUID/randomUUID))
raw-bytes (Base64/decodeBase64 (subs signature-data (count prefix)))]
(s3/put-object :bucket-name "integreat-signature-images" #_(:data-bucket env)
:key (str signature-id ".jpg")
:input-stream (io/make-input-stream raw-bytes {})
:metadata {:content-type "image/jpeg"
:content-length (count raw-bytes)}
:canned-acl "public-read")
(str "https://integreat-signature-images.s3.amazonaws.com/" signature-id ".jpg")))))
(defn assert-no-shared-transaction-sources [client-code txes]
(let [new-db (:db-after (dc/with (dc/with-db conn)
{:tx-data txes}))]
(when (seq (->> (dc/q '[:find ?src (count ?ba)
:in $ ?c
:where [?c :client/bank-accounts ?ba]
(or
[?ba :bank-account/intuit-bank-account ?src]
[?ba :bank-account/plaid-account ?src]
[?ba :bank-account/yodlee-account-id ?src])]
new-db [:client/code client-code])
(filter (fn [[_ cnt]]
(> cnt 1)))))
(throw (ex-info "Cannot reuse yodlee/plaid/intuit account" {:validation-error (str "Cannot reuse yodlee/plaid/intuit account")})))))
(defn edit-client [context {:keys [edit_client]} _]
(assert-admin (:id context))
(when-not (:id edit_client)
(assert-client-code-is-unique (:code edit_client)))
(let [client (when (:id edit_client) (d-clients/get-by-id (:id edit_client)))
id (or (:db/id client) "new-client")
signature-file (upload-signature-data (:signature_data edit_client))
client-code (if (str/blank? (:client/code client))
(:code edit_client)
(:client/code client))
updated-entity {:db/id id
:client/code client-code
:client/name (:name edit_client)
:client/matches (:matches edit_client)
:client/signature-file signature-file
:client/email (:email edit_client)
:client/locked-until (some-> (:locked_until edit_client) (coerce/to-date))
:client/locations (filter identity (:locations edit_client))
:client/week-a-debits (:week_a_debits edit_client)
:client/week-a-credits (:week_a_credits edit_client)
:client/week-b-debits (:week_b_debits edit_client)
:client/square-auth-token (:square_auth_token edit_client)
:client/square-locations (map
(fn [sl]
{:db/id (or (:id sl) (random-tempid))
:square-location/client-location (:client_location sl)})
(:square_locations edit_client))
:client/emails (map (fn [e]
{:db/id (or (:id e)
(random-tempid))
:email-contact/email (:email e)
:email-contact/description (:description e)})
(:emails edit_client))
:client/feature-flags (:feature_flags edit_client)
:client/ezcater-locations (map
(fn [el]
{:db/id (or (:id el) (random-tempid))
:ezcater-location/location (:location el)
:ezcater-location/caterer (:caterer el)})
(:ezcater_locations edit_client))
:client/week-b-credits (:week_b_credits edit_client)
:client/location-matches (->> (:location_matches edit_client)
(filter (fn [lm] (and (:location lm) (:match lm))))
(map (fn [lm] {:db/id (or (:id lm ) (random-tempid))
:location-match/location (:location lm)
:location-match/matches [(:match lm)]})))
:client/address {:db/id (or (:id (:address edit_client)) (random-tempid))
:address/street1 (:street1 (:address edit_client))
:address/street2 (:street2 (:address edit_client))
:address/city (:city (:address edit_client))
:address/state (:state (:address edit_client))
:address/zip (:zip (:address edit_client))}
:client/bank-accounts (map (fn [ba]
{:db/id (:id ba)
:bank-account/code (:code ba)
:bank-account/bank-name (:bank_name ba)
:bank-account/bank-code (:bank_code ba)
:bank-account/start-date (-> (:start_date ba) (coerce/to-date))
:bank-account/routing (:routing ba)
:bank-account/include-in-reports (:include_in_reports ba)
:bank-account/name (:name ba)
:bank-account/visible (:visible ba)
:bank-account/number (:number ba)
:bank-account/check-number (:check_number ba)
:bank-account/numeric-code (:numeric_code ba)
:bank-account/sort-order (:sort_order ba)
:bank-account/locations (:locations ba)
:bank-account/use-date-instead-of-post-date? (boolean (:use_date_instead_of_post_date ba))
:bank-account/yodlee-account-id (:yodlee_account_id ba)
:bank-account/type (keyword "bank-account-type" (name (:type ba)))
:bank-account/yodlee-account (when (:yodlee_account ba)
[:yodlee-account/id (:yodlee_account ba)])
:bank-account/plaid-account (:plaid_account ba)
:bank-account/intuit-bank-account (:intuit_bank_account ba)})
(:bank_accounts edit_client))
}
_ (assert-no-shared-transaction-sources client-code [`(upsert-entity ~updated-entity)])
_ (log/info "upserting client" updated-entity)
result (audit-transact [`(upsert-entity ~updated-entity)] (:id context))]
(when (:square_auth_token edit_client)
(square/upsert-locations (-> result :tempids (get id) (or id) d-clients/get-by-id)))
(-> (-> result :tempids (get id) (or id) d-clients/get-by-id)
(update :client/bank-accounts
(fn [bas]
(map #(set/rename-keys % {:bank-account/use-date-instead-of-post-date? :use-date-instead-of-post-date}) bas)))
(update :client/location-matches
(fn [lms]
(mapcat (fn [lm]
(map (fn [m]
{:location-match/match m
:location-match/location (:location-match/location lm)})
(:location-match/matches lm)))
lms)))
->graphql)))
(defn refresh-bank-account-current-balance [bank-account-id]
(let [all-transactions (dc/q
{:query {:find ['?e '?debit '?credit]
:in ['$ '?bank-account-id]
:where '[[?e :journal-entry-line/account ?bank-account-id]
[(get-else $ ?e :journal-entry-line/debit 0.0) ?debit]
[(get-else $ ?e :journal-entry-line/credit 0.0) ?credit]]}
:args [(dc/db conn) bank-account-id]})
debits (->> all-transactions
(map (fn [[_ debit _]]
debit))
(reduce + 0.0))
credits (->> all-transactions
(map (fn [[_ _ credit]]
credit))
(reduce + 0.0))
current-balance (if (= :bank-account-type/check (:db/ident (:bank-account/type (dc/pull (dc/db conn) [:bank-account/type] bank-account-id))))
(- debits credits)
(- credits debits))]
(dc/transact conn {:tx-data [{:db/id bank-account-id
:bank-account/current-balance current-balance}]})))
(defn bank-accounts-needing-refresh []
(let [last-refreshed (->> (dc/q {:query {:find ['?ba '?tx]
:in ['$ ]
:where ['[?ba :bank-account/current-balance _ ?tx true]]}
:args [(dc/history (dc/db conn))]})
(group-by first)
(map (fn [[ba balance-txes]]
[ba (->> balance-txes
(map second)
(sort-by #(- %))
first)])))
has-newer-transaction (->> (dc/q
{:query {:find ['?ba]
:in '[$ [[?ba ?last-refreshed] ...] ]
:where ['[_ :journal-entry-line/account ?ba ?tx]
'[(>= ?tx ?last-refreshed)]]}
:args [(dc/history (dc/db conn))
last-refreshed]})
(map first)
(set))
no-current-balance (->> (dc/q {:query {:find ['?ba]
:in '[$]
:where ['[?ba :bank-account/code]
'(not [?ba :bank-account/current-balance])
]}
:args [(dc/db conn)]})
(map first))]
(into has-newer-transaction no-current-balance)))
(defn build-current-balance [bank-accounts]
(doseq [bank-account bank-accounts]
(log/info "Refreshing bank account" (-> (dc/db conn) (dc/pull [:bank-account/code] bank-account)))
(try
(refresh-bank-account-current-balance bank-account)
(catch Exception e
(log/error "Can't refresh current balance" e)))))
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
(defn refresh-all-current-balance []
(lc/with-context {:source "current-balance-cache"}
(build-current-balance (->> (dc/q {:query {:find ['?ba]
:in '[$]
:where ['[?ba :bank-account/code]]}
:args [(dc/db conn)]})
(map first)))))
(defn refresh-current-balance []
(build-current-balance (bank-accounts-needing-refresh)))
(defn get-client [context _ _]
(->graphql
(->> (d-clients/get-all)
(filter #(can-see-client? (:id context) %))
(map (fn [c]
(update c :client/bank-accounts (fn [bas]
(map #(set/rename-keys % {:bank-account/use-date-instead-of-post-date? :use-date-instead-of-post-date}) bas)))))
(map (fn [c]
(if (is-admin? (:id context))
c
(-> c
(dissoc :client/yodlee-provider-accounts)
(dissoc :client/plaid-items)))))
(map (fn [c]
(update c :client/bank-accounts
(fn [bank-accounts]
(mapv (fn [ba]
(assoc ba :bank-account/yodlee-balance-old nil))
bank-accounts))))))))
(def sales-summary-query
"[:find ?d4 (sum ?total) (sum ?tax) (sum ?tip) (sum ?service-charge) (sum ?discount) (sum ?returns)
:with ?s
:in $
:where
[?s :sales-order/client [:client/code \"%s\"]]
[(ground (clj-time.coerce/to-date (clj-time.core/minus (auto-ap.time/local-now) (clj-time.core/days 90)))) ?min-d]
[?s :sales-order/date ?d]
[(>= ?d ?min-d)]
[?s :sales-order/total ?total]
[?s :sales-order/tax ?tax]
[?s :sales-order/tip ?tip]
[?s :sales-order/service-charge ?service-charge]
[?s :sales-order/returns ?returns]
[?s :sales-order/discount ?discount]
[(clj-time.coerce/to-date-time ?d) ?d2]
[(auto-ap.time/localize ?d2) ?d3]
[(auto-ap.time/unparse-local ?d3 auto-ap.time/normal-date) ?d4]
]")
(def sales-category-query
"[:find ?d4 ?n ?n2 (sum ?total) (sum ?tax) (sum ?discount)
:with ?s ?li
:in $
:where
[?s :sales-order/client [:client/code \"%s\"]]
[(ground (clj-time.coerce/to-date (clj-time.core/minus (auto-ap.time/local-now) (clj-time.core/days 90)))) ?min-d]
[?s :sales-order/date ?d]
[(>= ?d ?min-d)]
[?s :sales-order/line-items ?li]
[?li :order-line-item/category ?n]
[(get-else $ ?li :order-line-item/item-name \"\") ?n2]
[?li :order-line-item/total ?total]
[?li :order-line-item/tax ?tax]
[?li :order-line-item/discount ?discount]
[(clj-time.coerce/to-date-time ?d) ?d2]
[(auto-ap.time/localize ?d2) ?d3]
[(auto-ap.time/unparse-local ?d3 auto-ap.time/normal-date) ?d4]
]")
(def expected-deposits-query
"[:find ?d4 ?t ?f
:in $
:where
[?c :client/code \"%s\"]
[?s :expected-deposit/client ?c]
[?s :expected-deposit/total ?t]
[?s :expected-deposit/fee ?f]
[?s :expected-deposit/sales-date ?date]
[(clj-time.coerce/to-date-time ?date) ?d2]
[(auto-ap.time/localize ?d2) ?d3]
[(auto-ap.time/unparse-local ?d3 auto-ap.time/normal-date) ?d4]
]")
(def tenders-query
"[:find ?d4 ?type ?p2 (sum ?total) (sum ?tip)
:with ?charge
:in $
:where
[?c :client/code \"%s\"]
[?s :sales-order/client ?c]
[(ground (clj-time.coerce/to-date (clj-time.core/minus (auto-ap.time/local-now) (clj-time.core/days 90)))) ?min-d]
[?s :sales-order/date ?date]
[(>= ?date ?min-d)]
[?s :sales-order/charges ?charge]
[?charge :charge/type-name ?type]
[?charge :charge/total ?total]
[?charge :charge/tip ?tip]
[(get-else $ ?charge :charge/processor :na) ?ccp]
[(get-else $ ?ccp :db/ident :na) ?p]
[(name ?p) ?p2]
[(clj-time.coerce/to-date-time ?date) ?d2]
[(auto-ap.time/localize ?d2) ?d3]
[(auto-ap.time/unparse-local ?d3 auto-ap.time/normal-date) ?d4]
]")
(def tenders2-query
"[:find ?d4 ?type ?p2 (sum ?total) (sum ?tip)
:with ?charge
:in $
:where
[?charge :charge/date ?date]
[(ground (clj-time.coerce/to-date (clj-time.core/minus (auto-ap.time/local-now) (clj-time.core/days 90)))) ?min-d]
[(>= ?date ?min-d)]
[?charge :charge/client ?c]
[?c :client/code \"%s\"]
[?charge :charge/type-name ?type]
[?charge :charge/total ?total]
[?charge :charge/tip ?tip]
(or
(and [_ :expected-deposit/charges ?charge ]
[(ground :settlement) ?ccp]
[(ground :settlement) ?p])
(and
(not [_ :expected-deposit/charges ?charge])
[(get-else $ ?charge :charge/processor :na) ?ccp]
[(get-else $ ?ccp :db/ident :na) ?p]
))
[(name ?p) ?p2]
[(clj-time.coerce/to-date-time ?date) ?d2]
[(auto-ap.time/localize ?d2) ?d3]
[(auto-ap.time/unparse-local ?d3 auto-ap.time/normal-date) ?d4]]" )
(def refunds-query
"[:find ?d4 ?t (sum ?total) (sum ?fee)
:with ?r
:in $
:where
[?r :sales-refund/client [:client/code \"%s\"]]
[?r :sales-refund/total ?total]
[?r :sales-refund/fee ?fee]
[?r :sales-refund/date ?date]
[?r :sales-refund/type ?t]
[(clj-time.coerce/to-date-time ?date) ?d2]
[(auto-ap.time/localize ?d2) ?d3]
[(auto-ap.time/unparse-local ?d3 auto-ap.time/normal-date) ?d4]]")
(defn setup-sales-queries [context args _]
(assert-admin (:id context))
(let [{client-code :client/code feature-flags :client/feature-flags} (dc/pull (dc/db conn) '[:client/code :client/feature-flags] (:client_id args))
is-new-square? ((set feature-flags) "new-square")]
(q/put-query (str (UUID/randomUUID))
(format sales-summary-query client-code)
(str "sales query for " client-code)
(str client-code "-sales-summary")
[:client/code client-code]
)
(q/put-query (str (UUID/randomUUID))
(format sales-category-query client-code)
(str "sales category query for " client-code)
(str client-code "-sales-category")
[:client/code client-code]
)
(q/put-query (str (UUID/randomUUID))
(format expected-deposits-query client-code)
(str "expected deposit query for " client-code)
(str client-code "-expected-deposit")
[:client/code client-code]
)
(q/put-query (str (UUID/randomUUID))
(format (if is-new-square? tenders2-query tenders-query) client-code)
(str "tender query for " client-code)
(str client-code "-tender")
[:client/code client-code]
)
(q/put-query (str (UUID/randomUUID))
(format refunds-query client-code)
(str "refunds query for " client-code)
(str client-code "-refund")
[:client/code client-code])
(let [sales-summary-id (:saved-query/guid (dc/pull (dc/db conn) [:saved-query/guid] [:saved-query/lookup-key (str client-code "-sales-summary")]))
sales-category-id (:saved-query/guid (dc/pull (dc/db conn) [:saved-query/guid] [:saved-query/lookup-key (str client-code "-sales-category")]))
expected-deposit-id (:saved-query/guid (dc/pull (dc/db conn) [:saved-query/guid] [:saved-query/lookup-key (str client-code "-expected-deposit")]))
tender-id (:saved-query/guid (dc/pull (dc/db conn) [:saved-query/guid] [:saved-query/lookup-key (str client-code "-tender")]))
refund-id (:saved-query/guid (dc/pull (dc/db conn) [:saved-query/guid] [:saved-query/lookup-key (str client-code "-refund")]))]
{:message (str/join "\n"
[
(str "For " client-code ":")
(str "Sales: " "https://app.integreatconsult.com/api/queries/" sales-summary-id "/results/json")
(str "Sales Category: " "https://app.integreatconsult.com/api/queries/" sales-category-id "/results/json")
(str "Expected Deposits: " "https://app.integreatconsult.com/api/queries/" expected-deposit-id "/results/json")
(str "Tenders: " "https://app.integreatconsult.com/api/queries/" tender-id "/results/json")
(str "Refund: " "https://app.integreatconsult.com/api/queries/" refund-id "/results/json")])})))
(def objects
{:location_match
{:fields {:location {:type 'String}
:match {:type 'String}
:id {:type :id}}}
:square_location
{:fields {:client_location {:type 'String}
:name {:type 'String}
:square_id {:type 'String}
:id {:type :id}}}
:ezcater_location
{:fields {:location {:type 'String}
:caterer {:type :ezcater_caterer}
:id {:type :id}}}
:email_contact {:fields {:id {:type :id}
:email {:type 'String}
:description {:type 'String}}}
:client
{:fields {:id {:type :id}
:name {:type 'String}
:locked_until {:type :iso_date}
:code {:type 'String}
:feature_flags {:type '(list String)}
:square_auth_token {:type 'String}
:signature_file {:type 'String}
:square_integration_status {:type :integration_status}
:week_a_debits {:type :money}
:week_a_credits {:type :money}
:week_b_debits {:type :money}
:week_b_credits {:type :money}
:email {:type 'String}
:emails {:type '(list :email_contact)}
:address {:type :address}
:location_matches {:type '(list :location_match)}
:locations {:type '(list String)}
:matches {:type '(list String)}
:bank_accounts {:type '(list :bank_account)}
:square_locations {:type '(list :square_location)}
:ezcater_locations {:type '(list :ezcater_location)}
:forecasted_transactions {:type '(list :forecasted_transaction)}
:yodlee_provider_accounts {:type '(list :yodlee_provider_account)}
:plaid_items {:type '(list :plaid_item)}}}
:bank_account
{:fields {:id {:type :id}
:integration_status {:type :integration_status}
:type {:type :ident}
:start_date {:type :iso_date}
:number {:type 'String}
:numeric_code {:type 'Int}
:sort_order {:type 'Int}
:visible {:type 'Boolean}
:include_in_reports {:type 'Boolean}
:routing {:type 'String}
:code {:type 'String}
:check_number {:type 'Int}
:name {:type 'String}
:bank_code {:type 'String}
:bank_name {:type 'String}
:current_balance {:type :money}
:yodlee_balance_old {:type :money}
:yodlee_account_id {:type 'Int}
:yodlee_account {:type :yodlee_account}
:plaid_account {:type :plaid_account}
:intuit_bank_account {:type :intuit_bank_account}
:use_date_instead_of_post_date {:type 'Boolean}
:locations {:type '(list String)}}}
:forecasted_transaction {:fields {:identifier {:type 'String}
:id {:type :id}
:day_of_month {:type 'Int}
:amount {:type :money}}}
})
(def queries
{:client {:type '(list :client)
:resolve :get-client}})
(def mutations
{:edit_client {:type :client
:args {:edit_client {:type :edit_client}}
:resolve :mutation/edit-client}
:setup_sales_queries {:type :message
:args {:client_id {:type :id}}
:resolve :mutation/setup-sales-queries}})
(def input-objects
{:edit_location_match {:fields {:location {:type 'String}
:match {:type 'String}
:id {:type :id}}}
:edit_square_location {:fields {:client_location {:type 'String}
:id {:type :id}}}
:edit_ezcater_location {:fields {:location {:type 'String}
:caterer {:type :id}
:id {:type :id}}}
:edit_forecasted_transaction {:fields {:identifier {:type 'String}
:id {:type :id}
:day_of_month {:type 'Int}
:amount {:type :money}}}
:edit_email_contact {:fields {:id {:type :id}
:email {:type 'String}
:description {:type 'String}}}
:edit_client {:fields {:id {:type :id}
:name {:type 'String}
:locked_until {:type :iso_date}
:code {:type 'String}
:square_auth_token {:type 'String}
:feature_flags {:type '(list String)}
:signature_data {:type 'String}
:email {:type 'String}
:emails {:type '(list :edit_email_contact)}
:week_a_credits {:type :money}
:week_a_debits {:type :money}
:week_b_credits {:type :money}
:week_b_debits {:type :money}
:address {:type :add_address}
:locations {:type '(list String)}
:matches {:type '(list String)}
:location_matches {:type '(list :edit_location_match)}
:square_locations {:type '(list :edit_square_location)}
:ezcater_locations {:type '(list :edit_ezcater_location)}
:bank_accounts {:type '(list :edit_bank_account)}
:forecasted_transactions {:type '(list :edit_forecasted_transaction)}}}
:edit_bank_account
{:fields {:id {:type :id}
:code {:type 'String}
:type {:type :bank_account_type}
:start_date {:type :iso_date}
:number {:type 'String}
:check_number {:type 'Int}
:numeric_code {:type 'Int}
:visible {:type 'Boolean}
:include_in_reports {:type 'Boolean}
:sort_order {:type 'Int}
:name {:type 'String}
:bank_code {:type 'String}
:routing {:type 'String}
:bank_name {:type 'String}
:locations {:type '(list String)}
:yodlee_account_id {:type 'Int}
:use_date_instead_of_post_date {:type 'Boolean}
:intuit_bank_account {:type :id}
:plaid_account {:type :id}
:yodlee_account {:type 'Int}}}})
(def enums
{:bank_account_type {:values [{:enum-value :check}
{:enum-value :credit}
{:enum-value :cash}]}})
(def resolvers
{:get-client get-client
:mutation/edit-client edit-client
:mutation/setup-sales-queries setup-sales-queries})
(defn attach [schema]
(->
(merge-with merge schema
{:objects objects
:queries queries
:mutations mutations
:input-objects input-objects
:enums enums})
(attach-tracing-resolvers resolvers)))