(ns auto-ap.ssr.company.plaid (:require [auto-ap.datomic :refer [add-sorter-fields apply-pagination apply-sort-3 conn merge-query pull-attr pull-many-by-id query2]] [auto-ap.graphql.utils :refer [assert-can-see-client]] [auto-ap.logging :as alog] [auto-ap.plaid.core :as p] [auto-ap.ssr-routes :as ssr-routes] [auto-ap.ssr.components :as com] [auto-ap.ssr.grid-page-helper :as helper] [auto-ap.ssr.hx :as hx] [auto-ap.ssr.svg :as svg] [auto-ap.ssr.utils :refer [html-response]] [auto-ap.time :as atime] [bidi.bidi :as bidi] [clj-time.coerce :as coerce] [clj-time.core :as time] [clojure.string :as str] [datomic.api :as dc] [hiccup2.core :as hiccup])) (def default-read '[:db/id :plaid-item/external-id :plaid-item/access-token :plaid-item/last-updated :plaid-item/status {:plaid-item/accounts [:db/id {:bank-account/_plaid-account [{:bank-account/integration-status [{ [ :integration-status/state :xform iol-ion.query/ident] [:db/ident]} :integration-status/message :integration-status/last-attempt :integration-status/last-updated]}]} :plaid-account/external-id :plaid-account/number :plaid-account/balance :plaid-account/name]}]) (defn fetch-ids [db request] (let [query-params (:parsed-query-params request) query (cond-> {:query {:find [] :in ['$ '[?xx ...]] :where ['[?e :plaid-item/client ?xx]]} :args [db (:trimmed-clients request)]} (:sort query-params) (add-sorter-fields {"external-id" ['[?e :plaid-item/external-id ?sort-external-id]] "status" ['[?e :plaid-item/status ?sort-status]]} query-params) true (merge-query {:query {:find ['?e] :where ['[?e :plaid-item/external-id]]}}))] (clojure.pprint/pprint query-params) (cond->> (query2 query) true (apply-sort-3 query-params) true (apply-pagination query-params)))) (defn hydrate-results [ids db _] (let [results (pull-many-by-id db default-read ids)] (->> ids (map results)))) (defn fetch-page [request] (let [db (dc/db conn) {ids-to-retrieve :ids matching-count :count} (fetch-ids db request)] [(hydrate-results ids-to-retrieve db request) matching-count])) (defn plaid-link-script [token] (format "window.plaid = Plaid.create( { token: \"%s\", onSuccess: function (x) { htmx.trigger(\"#link-account\", \"linked\", {\"public_token\": x})} })", token)) (defn link [{{client-code "client_code" public-token "public_token"} :form-params :keys [identity] :as request}] (alog/info ::linking :request request) (when-not client-code (throw (ex-info "Client not provided" {:validation-error "Client not provided."}))) (when-not public-token (throw (ex-info "Public token not provided" {:validation-error "public token not provided"}))) (alog/info ::linking-plaid :id identity :client-code client-code) (assert-can-see-client identity (pull-attr (dc/db conn) :db/id [:client/code client-code])) (let [access-token (:access_token (p/exchange-public-token public-token client-code)) account-result (p/get-accounts access-token ) item {:plaid-item/client [:client/code client-code] :plaid-item/external-id (-> account-result :item :item_id ) :plaid-item/access-token access-token :plaid-item/status (or (some-> account-result :item :error) "SUCCESS") :plaid-item/last-updated (coerce/to-date (time/now)) :db/id "plaid-item"}] @(dc/transact conn (->> (:accounts account-result) (map (fn [a] (let [balance (some-> a :balances :current (* 0.01))] (cond-> {:plaid-account/external-id (:account_id a) :plaid-account/number (:mask a) :plaid-account/name (str (:name a) " " (:mask a)) :plaid-item/_accounts "plaid-item"} balance (assoc :plaid-account/balance balance))))) (into [item]))) (alog/info ::access-token-was :token access-token) {:headers {"Hx-redirect" (bidi/path-for ssr-routes/only-routes :company-plaid)}})) (defn relink [{{:strs [plaid-item-id]} :query-params :keys [identity]}] (let [pi (dc/pull (dc/db conn) [:plaid-item/access-token {:plaid-item/client [:client/code]}] (Long/parseLong plaid-item-id))] (assert-can-see-client identity (pull-attr (dc/db conn) :db/id [:client/code (-> pi :plaid-item/client :client/code)])) (html-response [:div [:script (hiccup/raw (plaid-link-script (p/get-relink-token (-> pi :plaid-item/client :client/code) (-> pi :plaid-item/access-token))))] [:script (hiccup/raw "window.plaid.open()")] #_(com/button {:color :primary :id "link-account" :onClick "window.plaid.open()"} (com/button-icon {} svg/refresh) "Start relink")]))) (def grid-page (helper/build {:id "plaid-table" :nav com/company-aside-nav :fetch-page fetch-page :breadcrumbs [[:a {:href (bidi/path-for ssr-routes/only-routes :company)} "My Company"] [:a {:href (bidi/path-for ssr-routes/only-routes :company-plaid)} "Plaid"]] :title "Plaid Accounts" :entity-name "Plaid accounts" :route :company-plaid-table :action-buttons (fn [request] (when-let [client-code (:client/code (:client request))] [[:div {:hx-post (str (bidi/path-for ssr-routes/only-routes :company-plaid-link :request-method :post)) :hx-vals (hiccup/raw (format "js:{client_code: \"%s\", public_token: event.detail.public_token}", client-code)) :hx-trigger "linked"} [:script (hiccup/raw (plaid-link-script (p/get-link-token client-code)))] (com/button {:color :primary :id "link-account" :onClick "window.plaid.open()"} (com/button-icon {} svg/refresh) (format "Link %s account" client-code))]])) :row-buttons (fn [request e] [[:div (com/button {:hx-put (str (bidi/path-for ssr-routes/only-routes :company-plaid-relink) "?plaid-item-id=" (:db/id e)) :color :primary :hx-target "closest div"} "Reauthenticate")]]) :headers [{:key "plaid-item" :name "Plaid Item" :sort-key "external-id" :render :plaid-item/external-id} {:key "integreat-plaid-status" :name "Integreat ↔ Plaid status" :sort-key "integreat-plaid-status" :render (fn [e] (let [bad-integration (->> (:plaid-item/accounts e) (map (comp first :bank-account/_plaid-account)) (filter (comp #{:integration-state/failed :integration-state/unauthorized} :integration-status/state :bank-account/integration-status)) first :bank-account/integration-status)] [:div (when bad-integration {:x-popper (hx/json {:source "$refs.button" :tooltip "$refs.tooltip"}) :x-data (hx/json {}) }) [:div.cursor-pointer (com/pill {:color (if bad-integration :red :primary) :x-ref "button"} [:div.inline-flex.gap-2 (or (some-> bad-integration :integration-status/state name str/capitalize) "Success") (when bad-integration " (detail)") (when bad-integration (com/tooltip {:x-ref "tooltip"} [:div.text-red-700 (:integration-status/message bad-integration)]))])] [:div.grid.grid-cols-2.gap-1.auto-cols-min.grid-flow-row.shrink [:div "Attempted: "] [:div (atime/unparse-local (coerce/to-date-time (:integration-status/last-attempt e)) atime/normal-date)] [:div "Last Updated: "] [:div (atime/unparse-local (coerce/to-date-time (:integration-status/last-updated e)) atime/normal-date)]]]))} {:key "plaid-bank-status" :name "Plaid ↔ Bank Status" :sort-key "plaid-bank-status" :render (fn [e] (when-let [status (:plaid-item/status e)] [:div [:div (com/pill {:color :primary} status)] [:div (atime/unparse-local (coerce/to-date-time (:plaid-item/last-updated e)) atime/normal-date)]]))} {:key "accounts" :name "Accounts" :show-starting "md" :render (fn [e] [:ul (for [a (:plaid-item/accounts e)] [:li (:plaid-account/name a) " - " (:plaid-account/number a)])])}]})) (def page (helper/page-route grid-page)) (def table (helper/table-route grid-page))