(ns auto-ap.graphql.clients (:require [auto-ap.datomic :refer [audit-transact conn remove-nils]] [auto-ap.datomic.clients :as d-clients] [auto-ap.graphql.utils :refer [->graphql assert-admin can-see-client? is-admin?]] [auto-ap.utils :refer [by]] [auto-ap.yodlee.core :refer [in-memory-cache]] [clj-time.coerce :as coerce] [config.core :refer [env]] [clojure.string :as str] [unilog.context :as lc] [clojure.tools.logging :as log] [datomic.api :as d] [clojure.java.io :as io] [amazonica.aws.s3 :as s3] [yang.scheduler :as scheduler] [mount.core :as mount]) (:import [org.apache.commons.codec.binary Base64] java.util.UUID)) (defn assert-client-code-is-unique [code] (when (seq (d/query {:query {:find '[?id] :in ['$ '?code] :where ['[?id :client/code ?code]]} :args [(d/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"} :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 (d/with (d/db conn) txes))] (when (seq (->> (d/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 [[src 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 new_bank_accounts] :as args} value] (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)) _ (when client (audit-transact (into (mapv (fn [lm] [:db/retractEntity (:db/id lm)]) (:client/location-matches client)) (mapv (fn [m] [:db/retract (:db/id client) :client/matches m]) (:client/matches client))) (:id context))) reverts (-> [] (into (->> (:bank_accounts edit_client) (filter #(nil? (:yodlee_account_id %))) (filter #(:bank-account/yodlee-account-id (d/entity (d/db conn) (:id %)))) (map (fn [ba] [:db/retract (:id ba) :bank-account/yodlee-account-id (:bank-account/yodlee-account-id (d/entity (d/db conn) (:id ba)))])))) (into (->> (:bank_accounts edit_client) (filter #(nil? (:yodlee_account %))) (filter #(:bank-account/yodlee-account (d/entity (d/db conn) (:id %)))) (map (fn [ba] [:db/retract (:id ba) :bank-account/yodlee-account (:db/id (:bank-account/yodlee-account (d/entity (d/db conn) (:id ba))))])))) (into (->> (:bank_accounts edit_client) (filter #(nil? (:intuit_bank_account %))) (filter #(:bank-account/intuit-bank-account (d/entity (d/db conn) (:id %)))) (map (fn [ba] [:db/retract (:id ba) :bank-account/intuit-bank-account (:db/id (:bank-account/intuit-bank-account (d/entity (d/db conn) (:id ba))))])))) (into (->> (:bank_accounts edit_client) (filter #(nil? (:plaid_account %))) (filter #(:bank-account/plaid-account (d/entity (d/db conn) (:id %)))) (map (fn [ba] [:db/retract (:id ba) :bank-account/plaid-account (:db/id (:bank-account/plaid-account (d/entity (d/db conn) (:id ba))))]))))) client-code (if (str/blank? (:client/code client)) (:code edit_client) (:client/code client)) transactions (into [(remove-nils {: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/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/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] {:location-match/location (:location lm) :location-match/matches [(:match lm)]}))) :client/address (remove-nils { :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 #(remove-nils (cond-> {:db/id (:id %) :bank-account/code (:code %) :bank-account/bank-name (:bank_name %) :bank-account/bank-code (:bank_code %) :bank-account/start-date (-> (:start_date %) (coerce/to-date)) :bank-account/routing (:routing %) :bank-account/include-in-reports (:include_in_reports %) :bank-account/name (:name %) :bank-account/visible (:visible %) :bank-account/number (:number %) :bank-account/check-number (:check_number %) :bank-account/numeric-code (:numeric_code %) :bank-account/sort-order (:sort_order %) :bank-account/locations (:locations %) :bank-account/yodlee-account-id (:yodlee_account_id %) :bank-account/type (keyword "bank-account-type" (name (:type %)))} (:yodlee_account %) (assoc :bank-account/yodlee-account [:yodlee-account/id (:yodlee_account %)]) (:plaid_account %) (assoc :bank-account/plaid-account (:plaid_account %)) (:intuit_bank_account %) (assoc :bank-account/intuit-bank-account (:intuit_bank_account %)))) (:bank_accounts edit_client)) }) [:reset id :client/forecasted-transactions (map #(remove-nils {:db/id (:id %) :forecasted-transaction/day-of-month (:day_of_month %) :forecasted-transaction/identifier (:identifier %) :forecasted-transaction/amount (:amount %)} ) (:forecasted_transactions edit_client))]] reverts) _ (assert-no-shared-transaction-sources client-code transactions) _ (log/info "upserting client" transactions) result (audit-transact transactions (:id context))] (-> result :tempids (get id) (or id) d-clients/get-by-id (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 (d/query {: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 [(d/db conn) bank-account-id]}) debits (->> all-transactions (map (fn [[e debit credit]] debit)) (reduce + 0.0)) credits (->> all-transactions (map (fn [[e debit credit]] credit)) (reduce + 0.0)) current-balance (if (= :bank-account-type/check (:bank-account/type (d/entity (d/db conn) bank-account-id))) (- debits credits) (- credits debits))] @(d/transact conn [{:db/id bank-account-id :bank-account/current-balance current-balance}]))) (defn bank-accounts-needing-refresh [] (let [last-refreshed (->> (d/query {:query {:find ['?ba '?tx] :in ['$ ] :where ['[?ba :bank-account/current-balance _ ?tx true]]} :args [(d/history (d/db conn))]}) (group-by first) (map (fn [[ba balance-txes]] [ba (->> balance-txes (map second) (sort-by #(- %)) first)]))) has-newer-transaction (->> (d/query {:query {:find ['?ba] :in '[$ [[?ba ?last-refreshed] ...] ] :where ['[_ :journal-entry-line/account ?ba ?tx] '[(>= ?tx ?last-refreshed)]]} :args [(d/history (d/db conn)) last-refreshed]}) (map first) (set)) no-current-balance (->> (d/query {:query {:find ['?ba] :in '[$] :where ['[?ba :bank-account/code] '(not [?ba :bank-account/current-balance]) ]} :args [(d/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" (-> (d/db conn) (d/entity bank-account) :bank-account/code)) (try (refresh-bank-account-current-balance bank-account) (catch Exception e (log/error "Can't refresh current balance" e))))) (defn refresh-all-current-balance [] (lc/with-context {:source "current-balance-cache"} (build-current-balance (->> (d/query {:query {:find ['?ba] :in '[$] :where ['[?ba :bank-account/code]]} :args [(d/db conn)]}) (map first))))) (defn refresh-current-balance [] (lc/with-context {:source "current-balance-cache"} (try (log/info "Refreshing running balance cache") (build-current-balance (bank-accounts-needing-refresh)) (catch Exception e (log/error e))))) (mount/defstate current-balance-worker :start (scheduler/every (* 17 60 1000) refresh-current-balance) :stop (scheduler/stop current-balance-worker)) (defn get-client [context args value] (->graphql (->> (d-clients/get-all) (filter #(can-see-client? (:id context) %)) (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] ;; TODO remove when new yodlee replaces (assoc ba :bank-account/yodlee-balance-old (get-in (by :id (mapcat :accounts @in-memory-cache) ) [(:bank-account/yodlee-account-id ba) :balance :amount]))) bank-accounts))))))))