(ns auto-ap.graphql.clients (:require [amazonica.aws.s3 :as s3] [auto-ap.datomic :refer [audit-transact conn]] [auto-ap.datomic.clients :as d-clients] [auto-ap.graphql.utils :refer [->graphql assert-admin attach-tracing-resolvers can-see-client? <-graphql result->page is-admin?]] [auto-ap.routes.queries :as q] [auto-ap.square.core :as square] [auto-ap.utils :refer [heartbeat]] [clj-time.coerce :as coerce] [clojure.java.io :as io] [clojure.set :as set] [clojure.string :as str] [clojure.tools.logging :as log] [com.brunobonacci.mulog :as mu] [datomic.api :as dc] [iol-ion.tx :refer [random-tempid]] [mount.core :as mount] [unilog.context :as lc] [yang.scheduler :as scheduler] [auto-ap.solr :as solr]) (:import (java.util UUID) (org.apache.commons.codec.binary Base64))) (defn assert-client-code-is-unique [code] (when (seq (dc/q {:find '[?id] :in ['$ '?code] :where ['[?id :client/code ?code]]} (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/db conn) 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 (cond-> {:db/id id :client/code client-code :client/name (:name edit_client) :client/matches (:matches edit_client) :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 (when (seq (filter identity (vals (:address edit_client)))) {: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 (or (:id ba) (random-tempid)) :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))} signature-file (assoc :client/signature-file signature-file)) _ (mu/log ::upserting :up updated-entity) _ (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))) (let [updated-client (-> result :tempids (get id) (or id) d-clients/get-by-id)] (when (:client/name updated-client) (solr/index-documents-raw solr/impl "clients" [{"id" (:db/id updated-client) "name" (conj (or (:client/matches updated-client) []) (:client/name updated-client)) "code" (:client/code updated-client) "exact" (map str/upper-case (conj (or (:client/matches updated-client) []) (:client/name updated-client)))}])) (-> updated-client (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-all-current-balance [] (lc/with-context {:source "current-balance-refresh"} (let [db (dc/db conn) clients (dc/q '[:find (pull ?c [:db/id :client/code {:client/bank-accounts [:db/id :bank-account/code]}]) :where [?c :client/code]] db )] (doseq [[{client :db/id code :client/code bank-accounts :client/bank-accounts}] clients {bank-account :db/id bac :bank-account/code} bank-accounts] @(dc/transact conn [{:db/id bank-account :bank-account/current-balance (or (->> (dc/index-pull db {:index :avet :selector [:db/id :journal-entry-line/location :journal-entry-line/account :journal-entry-line/running-balance :journal-entry-line/client+account+location+date {:journal-entry/_line-items [:journal-entry/date :journal-entry/client]}] :start [:journal-entry-line/client+account+location+date [client bank-account "A" #inst "2030-01-01"]] :reverse true }) (filter (fn [{[c b] :journal-entry-line/client+account+location+date}] (and (= c client) (= b bank-account)))) (map :journal-entry-line/running-balance) (first)) 0.0)}]))))) (defn get-client [context _ _] (->graphql (->> (d-clients/get-minimal) (filter #(can-see-client? (:id context) %))))) (defn get-admin-client [context {:keys [id]} _] (assert-admin (:id context)) (->graphql (-> (d-clients/get-by-id 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)))))) (defn get-client-page [context args _] (assert-admin (:id context)) (let [args (assoc args :id (:id context)) [clients clients-count] (d-clients/get-graphql-page (assoc (<-graphql (:filters args)) :id (:id context))) clients (->> clients (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))))))] (result->page clients clients-count :clients (:filters args)))) (def sales-summary-query "[:find ?d4 (sum ?total) (sum ?tax) (sum ?tip) (sum ?service-charge) (sum ?discount) (sum ?returns) :with ?s :in $ :where [(ground (iol-ion.query/recent-date)) ?min-d] [(ground #inst \"2040-01-01\") ?max-d] [?c :client/code \"%s\"] [(iol-ion.query/sales-orders-in-range $ ?c ?min-d ?max-d) [?s ...]] [?s :sales-order/date ?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] [(iol-ion.query/excel-date ?d) ?d4] ]") (def sales-category-query "[:find ?d4 ?n ?n2 (sum ?total) (sum ?tax) (sum ?discount) :with ?s ?li :in $ :where [(ground (iol-ion.query/recent-date)) ?min-d] [(ground #inst \"2040-01-01\") ?max-d] [?c :client/code \"%s\"] [(iol-ion.query/sales-orders-in-range $ ?c ?min-d ?max-d) [?s ...]] [?s :sales-order/date ?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] [(iol-ion.query/excel-date ?d) ?d4]]") (def expected-deposits-query "[:find ?d4 ?t ?f :in $ :where [(ground (iol-ion.query/recent-date)) ?min-d] [?c :client/code \"%s\"] [?s :expected-deposit/client ?c] [?s :expected-deposit/sales-date ?date] [(>= ?date ?min-d)] [?s :expected-deposit/total ?t] [?s :expected-deposit/fee ?f] [(iol-ion.query/excel-date ?date) ?d4] ]") (def tenders-query "[:find ?d4 ?type ?p2 (sum ?total) (sum ?tip) :with ?charge :in $ :where [(ground (iol-ion.query/recent-date)) ?min-d] [?c :client/code \"%s\"] [?s :sales-order/client ?c] [?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] [(iol-ion.query/excel-date ?date) ?d4] ]") (def tenders2-query "[:find ?d4 ?type ?p2 (sum ?total) (sum ?tip) :with ?charge :in $ :where [(ground (iol-ion.query/recent-date)) ?min-d] [?charge :charge/date ?date] [(>= ?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] [(iol-ion.query/excel-date ?date) ?d4]] " ) (def refunds-query "[:find ?d4 ?t (sum ?total) (sum ?fee) :with ?r :in $ :where [(ground (iol-ion.query/recent-date)) ?min-d] [?r :sales-refund/client [:client/code \"%s\"]] [?r :sales-refund/date ?date] [(>= ?date ?min-d)] [?r :sales-refund/total ?total] [?r :sales-refund/fee ?fee] [?r :sales-refund/type ?t] [(iol-ion.query/excel-date ?date) ?d4] ]") (def cash-drawer-shift-query "[:find ?d4 (sum ?paid-in) (sum ?paid-out) (sum ?expected-cash) (sum ?opened-cash) :with ?cds :in $ :where [?cds :cash-drawer-shift/date ?date] [(ground (iol-ion.query/recent-date)) ?min-d] [(>= ?date ?min-d)] [?cds :cash-drawer-shift/client [:client/code \"%s\"]] [?cds :cash-drawer-shift/paid-in ?paid-in] [?cds :cash-drawer-shift/paid-out ?paid-out] [?cds :cash-drawer-shift/expected-cash ?expected-cash] [?cds :cash-drawer-shift/opened-cash ?opened-cash] [(iol-ion.query/excel-date ?date) ?d4]]") (defn setup-sales-queries-impl [client-id] (let [{client-code :client/code feature-flags :client/feature-flags} (dc/pull (dc/db conn) '[:client/code :client/feature-flags] client-id) 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]) (q/put-query (str (UUID/randomUUID)) (format cash-drawer-shift-query client-code) (str "cash drawer shift query for " client-code) (str client-code "-cash-drawer-shift") [: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")])) cash-drawer-shift-id (:saved-query/guid (dc/pull (dc/db conn) [:saved-query/guid] [:saved-query/lookup-key (str client-code "-cash-drawer-shift")]))] {: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") (str "Cash Drawer Shift: " "https://app.integreatconsult.com/api/queries/" cash-drawer-shift-id "/results/json")])}))) (defn setup-sales-queries [context args _] (assert-admin (:id context)) (setup-sales-queries-impl (:client_id args))) (defn reset-all-queries [] (doseq [[c] (dc/q '[:find ?c :where [?c :client/code]] (dc/db conn))] (setup-sales-queries-impl c))) (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)}}} :client_page {:fields {:clients {:type '(list :client)} :count {:type 'Int} :total {:type 'Int} :start {:type 'Int} :end {:type 'Int}}} :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} :admin_client {:type :client :args {:id {:type :id}} :resolve :get-admin-client} :client_page {:type :client_page :args {:filters {:type :client_filters}} :resolve :get-client-page}}) (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}}} :client_filters {:fields {:code {:type 'String} :name_like {:type 'String} :start {:type 'Int} :per_page {:type 'Int} :sort {:type '(list :sort_item)}}} :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 :get-admin-client get-admin-client :get-client-page get-client-page :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)))