diff --git a/.gitignore b/.gitignore index c70084ab..5c0bbc0f 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,7 @@ private node_modules **/*.swp **/.DS_Store +.clj-kondo/** +.calva/** +.lsp/** + diff --git a/get-prod-ips.sh b/get-prod-ips.sh new file mode 100755 index 00000000..14c8eaaf --- /dev/null +++ b/get-prod-ips.sh @@ -0,0 +1,2 @@ +#!/bin/bash +aws ecs describe-tasks --tasks `aws ecs list-tasks --service-name "integreat_app_prod" | jq -r '.taskArns|join(" ")'` | jq -r '.tasks|.[]|.attachments|.[]|.details|.[]|select( .name == "privateIPv4Address")|.value' diff --git a/project.clj b/project.clj index 251219c8..ef9f85c4 100644 --- a/project.clj +++ b/project.clj @@ -44,7 +44,7 @@ com.fasterxml.jackson.core/jackson-core]] [nrepl "0.8.3" :exclusions [org.clojure/tools.logging]] - [cheshire "5.10.0"] + [cheshire "5.9.0"] [org.clojure/tools.logging "1.1.0"] [ch.qos.logback/logback-classic "1.2.3" ] [ch.qos.logback/logback-core "1.2.3" ] diff --git a/src/clj/auto_ap/datomic.clj b/src/clj/auto_ap/datomic.clj index 76ac3f4e..d1b89b6f 100644 --- a/src/clj/auto_ap/datomic.clj +++ b/src/clj/auto_ap/datomic.clj @@ -793,7 +793,6 @@ (let [sort-bys (conj (:sort args) {:sort-key "default" :asc true}) - _ (log/info sort-bys) length (count sort-bys) comparator (fn [xs ys] (reduce diff --git a/src/clj/auto_ap/datomic/checks.clj b/src/clj/auto_ap/datomic/checks.clj index 8db16463..1c78a903 100644 --- a/src/clj/auto_ap/datomic/checks.clj +++ b/src/clj/auto_ap/datomic/checks.clj @@ -130,7 +130,6 @@ :where ['[?e :payment/date ?sort-default]]}}))] - (log/info "query" query) (cond->> query true (d/query) true (apply-sort-3 args) diff --git a/src/clj/auto_ap/datomic/invoices.clj b/src/clj/auto_ap/datomic/invoices.clj index f4514efe..a34e1399 100644 --- a/src/clj/auto_ap/datomic/invoices.clj +++ b/src/clj/auto_ap/datomic/invoices.clj @@ -105,6 +105,11 @@ '[(.contains ^String ?invoice-number ?invoice-number-like)]]} :args [(:invoice-number-like args)]}) + (:scheduled-payments args) + (merge-query {:query {:in [] + :where ['[?e :invoice/scheduled-payment]]} + :args []}) + (:unresolved args) (merge-query {:query {:in [] :where ['(or-join [?e] diff --git a/src/clj/auto_ap/graphql.clj b/src/clj/auto_ap/graphql.clj index 9260e53a..f7185d87 100644 --- a/src/clj/auto_ap/graphql.clj +++ b/src/clj/auto_ap/graphql.clj @@ -532,6 +532,7 @@ :due_range {:type :date_range} :status {:type :invoice_status} :unresolved {:type 'Boolean} + :scheduled_payments {:type 'Boolean} :client_id {:type :id} :vendor_id {:type :id} :amount_lte {:type :money} diff --git a/src/clj/auto_ap/graphql/transactions.clj b/src/clj/auto_ap/graphql/transactions.clj index 78e66295..56ca81e2 100644 --- a/src/clj/auto_ap/graphql/transactions.clj +++ b/src/clj/auto_ap/graphql/transactions.clj @@ -95,24 +95,57 @@ {:transaction/payment [{:payment/status [:db/ident]} :db/id]} ] transaction-id) payment (-> transaction :transaction/payment ) + is-autopay-payment? (some->> (doto (d/query {:query {:find ['?sp] + :in ['$ '?payment] + :where ['[?ip :invoice-payment/payment ?payment] + '[?ip :invoice-payment/invoice ?i] + '[(get-else $ ?i :invoice/scheduled-payment "N/A") ?sp]]} + :args [(d/db conn) (:db/id payment)]}) + log/info) + seq + (map first) + (every? #(instance? java.util.Date %))) ] - (log/info "Unlinking " transaction-id " from payment " payment) + (log/info (:db/id payment)) + + (log/info "Unlinking " transaction-id " from payment " payment " - Deleting the payment due to autopay? " is-autopay-payment?) (when (not= :payment-status/cleared (-> payment :payment/status :db/ident)) (throw (ex-info "Payment can't be undone because it isn't cleared." {:validation-error "Payment can't be undone because it isn't cleared."}))) - (audit-transact - (into [{:db/id (:db/id payment) - :payment/status :payment-status/pending} - {:db/id transaction-id - :transaction/approval-status :transaction-approval-status/unapproved} - [:db/retract transaction-id :transaction/payment (:db/id payment)] - [:db/retract transaction-id :transaction/vendor (:db/id (:transaction/vendor transaction))] - [:db/retract transaction-id :transaction/location (:transaction/location transaction)]] + (if is-autopay-payment? + (audit-transact + (->> [{:db/id (:db/id payment) + :payment/status :payment-status/pending} + {:db/id transaction-id + :transaction/approval-status :transaction-approval-status/unapproved} - (map (fn [a] - [:db/retract transaction-id :transaction/accounts (:db/id a)]) - (:transaction/accounts transaction))) - (:id context)) + [:db/retractEntity (:db/id payment) ] + [:db/retract transaction-id :transaction/payment (:db/id payment)] + [:db/retract transaction-id :transaction/vendor (:db/id (:transaction/vendor transaction))] + [:db/retract transaction-id :transaction/location (:transaction/location transaction)]] + (into (map (fn [a] + [:db/retract transaction-id :transaction/accounts (:db/id a)]) + (:transaction/accounts transaction))) + (into (map (fn [[invoice-payment]] + [:db/retractEntity invoice-payment]) + (d/query {:query {:find ['?ip] + :in ['$] + :where ['[?ip :invoice-payment/payment ?p]]} + :args [(d/db conn) (:db/id payment)]} )))) + (:id context)) + (audit-transact + (into [{:db/id (:db/id payment) + :payment/status :payment-status/pending} + {:db/id transaction-id + :transaction/approval-status :transaction-approval-status/unapproved} + [:db/retract transaction-id :transaction/payment (:db/id payment)] + [:db/retract transaction-id :transaction/vendor (:db/id (:transaction/vendor transaction))] + [:db/retract transaction-id :transaction/location (:transaction/location transaction)]] + + (map (fn [a] + [:db/retract transaction-id :transaction/accounts (:db/id a)]) + (:transaction/accounts transaction))) + (:id context))) (-> (d-transactions/get-by-id transaction-id) approval-status->graphql ->graphql))) diff --git a/src/clj/auto_ap/yodlee/import.clj b/src/clj/auto_ap/yodlee/import.clj index c29f8526..26f652fd 100644 --- a/src/clj/auto_ap/yodlee/import.clj +++ b/src/clj/auto_ap/yodlee/import.clj @@ -1,25 +1,24 @@ (ns auto-ap.yodlee.import - (:require [auto-ap.yodlee.core :as client] - [auto-ap.utils :refer [by]] - [datomic.api :as d] - [auto-ap.datomic :refer [uri remove-nils]] + (:require [auto-ap.datomic :refer [audit-transact conn remove-nils uri]] [auto-ap.datomic.accounts :as a] - [clj-time.coerce :as coerce] - [digest :refer [sha-256]] [auto-ap.datomic.checks :as d-checks] - [auto-ap.datomic.transactions :as d-transactions] [auto-ap.datomic.clients :as d-clients] - [auto-ap.time :as time] [auto-ap.datomic.transaction-rules :as tr] + [auto-ap.datomic.transactions :as d-transactions] [auto-ap.rule-matching :as rm] - [clojure.string :as str] - [unilog.context :as lc] - [clojure.tools.logging :as log] + [auto-ap.time :as time] + [auto-ap.utils :refer [by dollars=]] + [auto-ap.yodlee.core :as client] + [clj-time.coerce :as coerce] [clj-time.core :as t] + [clojure.string :as str] + [clojure.tools.logging :as log] + [datomic.api :as d] + [digest :refer [sha-256]] [mount.core :as mount] + [unilog.context :as lc] [yang.scheduler :as scheduler])) - (defn rough-match [client-id bank-account-id amount] (if (and client-id bank-account-id amount) (let [[matching-checks] (d-checks/get-graphql {:client-id client-id @@ -32,7 +31,7 @@ nil)) -(defn transaction->payment [_ check-number client-id bank-account-id amount id] +(defn transaction->existing-payment [_ check-number client-id bank-account-id amount id] (log/info "Searching for a matching check for " {:client-id client-id :check-number check-number @@ -57,6 +56,61 @@ :else (rough-match client-id bank-account-id amount))) + + +(defn match-transaction-to-unfulfilled-autopayments [amount client-id] + (log/info "trying to find uncleared autopay invoices") + (let [candidate-invoices-vendor-groups (->> (d/query {:query {:find ['?vendor-id '?e '?total '?sd] + :in ['$ '?client-id] + :where ['[?e :invoice/client ?client-id] + '[?e :invoice/scheduled-payment ?sd] + '[?e :invoice/status :invoice-status/paid] + '(not [_ :invoice-payment/invoice ?e]) + '[?e :invoice/vendor ?vendor-id] + '[?e :invoice/total ?total]]} + :args [(d/db conn) client-id]}) + (sort-by last) ;; sort by scheduled payment date + (group-by first) ;; group by vendors + vals) + considerations (for [candidate-invoices candidate-invoices-vendor-groups + invoice-count (range 1 3) + consideration (partition invoice-count 1 candidate-invoices) + :when (dollars= (reduce (fn [acc [_ _ amount]] + (+ acc amount)) 0.0 consideration) + (- amount))] + consideration)] + (log/info "Found " (count considerations) "considerations for transaction of" amount) + (if (= 1 (count considerations)) + (first considerations) + []))) + +(defn add-new-payment [[transaction :as tx] [[vendor] :as invoice-payments] bank-account-id client-id] + (log/info "Adding a new payment for transaction " (:transaction/id transaction) " and invoices " invoice-payments) + (let [payment-id (d/tempid :db.part/user)] + (-> tx + (conj {:payment/bank-account bank-account-id + :payment/client client-id + :payment/amount (- (:transaction/amount transaction)) + :payment/vendor vendor + :payment/date (:transaction/date transaction) + :payment/type :payment-type/debit + :payment/status :payment-status/cleared + :db/id payment-id}) + (into (map (fn [[vendor invoice-id invoice-amount]] + {:invoice-payment/invoice invoice-id + :invoice-payment/payment payment-id + :invoice-payment/amount invoice-amount}) + invoice-payments)) + (update 0 assoc + :transaction/payment payment-id + :transaction/approval-status :transaction-approval-status/approved + :transaction/vendor vendor + :transaction/location "A" + :transaction/accounts [#:transaction-account + {:account (:db/id (a/get-account-by-numeric-code-and-sets 21000 ["default"])) + :location "A" + :amount (Math/abs (:transaction/amount transaction))}])))) + (defn extract-check-number [{{description-original :original} :description}] (if-let [[_ _ check-number] (re-find #"(?i)check(card|[^0-9]+([0-9]*))" description-original)] @@ -67,6 +121,7 @@ nil)) (defn transactions->txs [transactions transaction->bank-account apply-rules existing] + (into [] (for [transaction transactions @@ -101,39 +156,40 @@ (or (not (:start-date bank-account)) (t/after? date (:start-date bank-account))) )] - (let [check (transaction->payment transaction check-number client-id bank-account-id amount id)] - (-> - #:transaction - {:post-date (coerce/to-date (time/parse post-date "YYYY-MM-dd")) - :id (sha-256 (str id)) - :account-id account-id - :date (coerce/to-date date) - :amount (double amount) - :description-original (some-> description-original (str/replace #"\s+" " ")) - :description-simple (some-> description-simple (str/replace #"\s+" " ")) - :approval-status (if check - :transaction-approval-status/approved - :transaction-approval-status/unapproved) - :type type - :status status - :client client-id - :check-number check-number - :bank-account bank-account-id - :payment (when check - {:db/id (:db/id check) - :payment/status :payment-status/cleared}) + (let [existing-check (transaction->existing-payment transaction check-number client-id bank-account-id amount id) + invoices-matches (when-not existing-check + (match-transaction-to-unfulfilled-autopayments amount client-id ))] + (cond-> + [#:transaction + {:post-date (coerce/to-date (time/parse post-date "YYYY-MM-dd")) + :id (sha-256 (str id)) + :account-id account-id + :date (coerce/to-date date) + :amount (double amount) + :description-original (some-> description-original (str/replace #"\s+" " ")) + :description-simple (some-> description-simple (str/replace #"\s+" " ")) + :approval-status :transaction-approval-status/unapproved + :type type + :status status + :client client-id + :check-number check-number + :bank-account bank-account-id}] + existing-check (update 0 #(assoc % :transaction/approval-status :transaction-approval-status/approved + :transaction/payment {:db/id (:db/id existing-check) + :payment/status :payment-status/cleared} + :transaction/vendor (:db/id (:payment/vendor existing-check)) + :transaction/location "A" + :transaction/accounts [#:transaction-account + {:account (:db/id (a/get-account-by-numeric-code-and-sets 21000 ["default"])) + :location "A" + :amount (Math/abs (double amount))}])) - :vendor (when check - (:db/id (:payment/vendor check))) - :location (when check - "A") - :accounts (when check - [#:transaction-account {:account (:db/id (a/get-account-by-numeric-code-and-sets 21000 ["default"])) - :location "A" - :amount (Math/abs (double amount))}])} + (and (not existing-check) + (seq invoices-matches)) (add-new-payment invoices-matches bank-account-id client-id) + - (apply-rules valid-locations) - remove-nils))))) + true (update 0 #(apply-rules % valid-locations)) + true (update 0 remove-nils)))))) (defn batch-transact [transactions] @@ -203,7 +259,27 @@ (let [all-bank-accounts (get-all-bank-accounts) transaction->bank-account (comp (by :bank-account/yodlee-account-id all-bank-accounts) :accountId) all-rules (tr/get-all)] - (batch-transact (transactions->txs transactions transaction->bank-account (rm/rule-applying-fn all-rules) (get-existing)))))))) + (doseq [tx (transactions->txs transactions transaction->bank-account (rm/rule-applying-fn all-rules) (get-existing))] + (audit-transact tx {:user/name "Yodlee import" + :user/role ":admin"}))))))) + +(defn do-import2 + ([] + (do-import2 (client2/get-transactions "AFH"))) + ([transactions] + (lc/with-context {:source "Import yodlee transactions"} + + (do + (log/info "importing from yodlee2") + (let [all-bank-accounts (get-all-bank-accounts) + transaction->bank-account (comp (by (comp :yodlee-account/id :bank-account/yodlee-account) all-bank-accounts) :accountId) + all-rules (tr/get-all)] + + (log/info "COUNT" (count (transactions->txs transactions transaction->bank-account (rm/rule-applying-fn all-rules) (get-existing)))) + (doseq [tx (transactions->txs transactions transaction->bank-account (rm/rule-applying-fn all-rules) (get-existing))] + (log/info "transacting" tx) + (audit-transact tx {:user/name "Yodlee import" + :user/role ":admin"}))))))) diff --git a/src/cljs/auto_ap/views/components/invoice_table.cljs b/src/cljs/auto_ap/views/components/invoice_table.cljs index 1ab7d47b..afb4dfa7 100644 --- a/src/cljs/auto_ap/views/components/invoice_table.cljs +++ b/src/cljs/auto_ap/views/components/invoice_table.cljs @@ -33,6 +33,7 @@ :amount-lte (:amount-lte (:amount-range params)) :location (:location params) :unresolved (:unresolved params) + :scheduled-payments (:scheduled-payments params) :invoice-number-like (:invoice-number-like params) :client-id (:id @(re-frame/subscribe [::subs/client])) :import-status (:import-status params) diff --git a/src/cljs/auto_ap/views/components/invoices/side_bar.cljs b/src/cljs/auto_ap/views/components/invoices/side_bar.cljs index 0b6352dd..d74aa193 100644 --- a/src/cljs/auto_ap/views/components/invoices/side_bar.cljs +++ b/src/cljs/auto_ap/views/components/invoices/side_bar.cljs @@ -76,6 +76,19 @@ {:on-change-event [::data-page/filter-changed data-page :due-range] :value @(re-frame/subscribe [::data-page/filter data-page :due-range])}]] + [:p.menu-label ""] + [:div + [switch-field {:id "unresolved-only" + :checked (boolean @(re-frame/subscribe [::data-page/filter data-page :scheduled-payments])) + + :on-change (fn [e] + (re-frame/dispatch [::data-page/filter-changed data-page :scheduled-payments (.-checked (.-target e))]) + + + ) + :label "Scheduled payments" + :type "checkbox"}]] + [:p.menu-label "Amount"] [:div [number-filter diff --git a/test/clj/auto_ap/integration/yodlee/import.clj b/test/clj/auto_ap/integration/yodlee/import.clj index 539f7ef9..17899374 100644 --- a/test/clj/auto_ap/integration/yodlee/import.clj +++ b/test/clj/auto_ap/integration/yodlee/import.clj @@ -5,83 +5,228 @@ [auto-ap.datomic :refer [uri]] [auto-ap.rule-matching :as rm] [auto-ap.datomic.migrate :as m] - [clojure.test :as t])) + [clojure.test :as t] + [clojure.tools.logging :as log] + [clojure.set :as set])) (defn wrap-setup [f] (with-redefs [auto-ap.datomic/uri "datomic:mem://datomic-transactor:4334/invoice"] (d/create-database uri) - (m/-main false) - (f) - (d/release (d/connect uri)) - (d/delete-database uri))) + (with-redefs [auto-ap.datomic/conn (d/connect uri)] + (m/migrate auto-ap.datomic/conn) + (f) + (d/release auto-ap.datomic/conn) + (d/delete-database uri)))) (t/use-fixtures :each wrap-setup) (defn noop-rule [transaction locations] transaction) +(def base-transaction {:postDate "2014-01-04" + :accountId 1234 + :date "2014-01-02" + :id 1 + :amount {:amount 12.0} + :description {:original "original-description" + :simple "simple-description"} + :merchant {:id "123" + :name "456"} + :baseType "DEBIT" + :status "POSTED" + + :bank-account {:db/id 456 + :client/_bank-accounts {:db/id 123 + :client/locations ["Z" "E"]}}}) + (t/deftest do-import - (let [base-transaction {:postDate "2014-01-04" - :accountId 1234 - :date "2014-01-02" - :id 1 - :amount {:amount 12.0} - :description {:original "original-description" - :simple "simple-description"} - :merchant {:id "123" - :name "456"} - :baseType "DEBIT" - :status "POSTED" - - :bank-account {:db/id 456 - :client/_bank-accounts {:db/id 123 - :client/locations ["Z" "E"]}}}] - (t/testing "Should import single transaction" - (let [result (sut/transactions->txs [base-transaction] + (t/testing "Should import single transaction" + (let [[result] (sut/transactions->txs [base-transaction] :bank-account noop-rule #{})] - (t/is (= [#:transaction {:amount -12.0 - :date #inst "2014-01-02T08:00:00.000-00:00" - :bank-account 456 - :client 123 - :post-date #inst "2014-01-04T08:00:00.000-00:00" - :account-id 1234 - :description-original "original-description" - :status "POSTED" - :id "6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b" - :approval-status :transaction-approval-status/unapproved - :description-simple "simple-description"}] - result)))) + (t/is (= [#:transaction {:amount -12.0 + :date #inst "2014-01-02T08:00:00.000-00:00" + :bank-account 456 + :client 123 + :post-date #inst "2014-01-04T08:00:00.000-00:00" + :account-id 1234 + :description-original "original-description" + :status "POSTED" + :id "6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b" + :approval-status :transaction-approval-status/unapproved + :description-simple "simple-description"}] + result)))) - (t/testing "Should exclude a transaction before start date" - (let [result (sut/transactions->txs [(assoc-in base-transaction - [:bank-account :start-date] - (clj-time.coerce/to-date-time #inst "2020-01-01"))] - :bank-account - noop-rule - #{})] - (t/is (= [] - result)))) + (t/testing "Should exclude a transaction before start date" + (let [result (sut/transactions->txs [(assoc-in base-transaction + [:bank-account :start-date] + (clj-time.coerce/to-date-time #inst "2020-01-01"))] + :bank-account + noop-rule + #{})] + (t/is (= [] + result)))) - (t/testing "Should not reimport an existing transaction" - (let [result (sut/transactions->txs [base-transaction] - :bank-account - noop-rule - #{"6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b"})] - (t/is (= [] - result)))) + (t/testing "Should not reimport an existing transaction" + (let [result (sut/transactions->txs [base-transaction] + :bank-account + noop-rule + #{"6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b"})] + (t/is (= [] + result)))) - (t/testing "Should skip transaction if no client is found" - (let [result (sut/transactions->txs [(assoc base-transaction :bank-account nil)] - :bank-account - noop-rule - #{})] - (t/is (= [] result)))) + (t/testing "Should skip transaction if no client is found" + (let [result (sut/transactions->txs [(assoc base-transaction :bank-account nil)] + :bank-account + noop-rule + #{})] + (t/is (= [] result)))) - (t/testing "Should match an uncleared check" + (t/testing "Should match an uncleared check" + (let [{:strs [bank-account-id client-id payment-id]} (->> [#:payment {:status :payment-status/pending + :date #inst "2019-01-01" + :bank-account "bank-account-id" + :client "client-id" + :check-number 10001 + :amount 30.0 + :db/id "payment-id"} + #:bank-account {:name "Bank account" + :db/id "bank-account-id"} + #:client {:name "Client" + :db/id "client-id" + :bank-accounts ["bank-account-id"]}] + (d/transact (d/connect uri)) + deref + :tempids)] + + + (let [[[transaction-result]] (sut/transactions->txs [(assoc base-transaction + :description {:original "CHECK 10001" + :simple ""} + :amount {:amount 30.0} + :bank-account {:db/id bank-account-id + :client/_bank-accounts {:db/id client-id + :client/locations ["A"]}})] + :bank-account + noop-rule + #{})] + + (t/is (= {:db/id payment-id + :payment/status :payment-status/cleared} + (:transaction/payment transaction-result)))) + + (t/testing "Should not match an already matched check" + @(d/transact (d/connect uri) [{:db/id payment-id :payment/status :payment-status/cleared}]) + (let [[result] (sut/transactions->txs [(assoc base-transaction + :description {:original "CHECK 10001" + :simple ""} + :amount {:amount 30.0} + :id 789 + :bank-account {:db/id bank-account-id + :client/_bank-accounts {:db/id client-id + :client/locations ["A"]}})] + :bank-account + noop-rule + #{})] + + (t/is (= nil + (:transaction/payment result))))))) + + (t/testing "Auto-pay Invoices" + (t/testing "Should match paid invoice that doesn't have a payment yet" + (let [{:strs [bank-account-id client-id invoice1-id invoice2-id vendor-id]} (->> [#:invoice {:status :invoice-status/paid + :vendor "vendor-id" + :scheduled-payment #inst "2019-01-04" + :date #inst "2019-01-01" + :client "client-id" + :total 20.0 + :db/id "invoice1-id"} + #:invoice {:status :invoice-status/paid + :vendor "vendor-id" + :scheduled-payment #inst "2019-01-04" + :date #inst "2019-01-01" + :client "client-id" + :total 10.0 + :db/id "invoice2-id"} + #:vendor {:name "Autopay vendor" + :db/id "vendor-id"} + #:bank-account {:name "Bank account" + :db/id "bank-account-id"} + #:client {:name "Client" + :db/id "client-id" + :bank-accounts ["bank-account-id"]}] + (d/transact (d/connect uri)) + deref + :tempids) + [[transaction-tx payment-tx invoice-payments1-tx invoice-payments2-tx]] (sut/transactions->txs [(assoc base-transaction + :amount {:amount 30.0} + :bank-account {:db/id bank-account-id + :client/_bank-accounts {:db/id client-id + :client/locations ["A"]}})] + :bank-account + noop-rule + #{})] + + (t/is (= :transaction-approval-status/approved + (:transaction/approval-status transaction-tx)) + (str "Should have approved transaction " transaction-tx)) + (t/is (= #:payment{:status :payment-status/cleared + :client client-id + :bank-account bank-account-id + :vendor vendor-id + :amount 30.0} + + (dissoc payment-tx :db/id)) + (str "Should have created payment " payment-tx)) + (t/is (= #:invoice-payment{:invoice invoice1-id + :amount 20.0 + :payment (:db/id payment-tx)} + + (dissoc invoice-payments1-tx :db/id)) + (str "Should have paid invoice 1" invoice-payments1-tx)) + (t/is (= #:invoice-payment{:invoice invoice2-id + :amount 10.0 + :payment (:db/id payment-tx)} + + (dissoc invoice-payments2-tx :db/id)) + (str "Should have paid invoice 2" invoice-payments2-tx)))) + + (t/testing "Should not match paid invoice that isn't a scheduled payment" + (let [{:strs [bank-account-id client-id invoice-id]} (->> [#:invoice {:status :invoice-status/paid + :vendor "vendor-id" + :date #inst "2019-01-01" + :client "client-id" + :total 30.0 + :db/id "invoice-id"} + #:vendor {:name "Autopay vendor" + :db/id "vendor-id"} + #:bank-account {:name "Bank account" + :db/id "bank-account-id"} + #:client {:name "Client" + :db/id "client-id" + :bank-accounts ["bank-account-id"]}] + (d/transact (d/connect uri)) + deref + :tempids) + [[transaction-tx payment-tx]] (sut/transactions->txs [(assoc base-transaction + :amount {:amount 30.0} + :bank-account {:db/id bank-account-id + :client/_bank-accounts {:db/id client-id + :client/locations ["A"]}})] + :bank-account + noop-rule + #{})] + + (t/is (= :transaction-approval-status/unapproved + (:transaction/approval-status transaction-tx))) + (t/is (nil? (:transaction/payment transaction-tx)))))) + + + (t/testing "Rules" + (t/testing "Should apply rules to imported transaction" (let [{:strs [bank-account-id client-id payment-id]} (->> [#:payment {:status :payment-status/pending :date #inst "2019-01-01" :bank-account "bank-account-id" @@ -96,251 +241,371 @@ :bank-accounts ["bank-account-id"]}] (d/transact (d/connect uri)) deref - :tempids)] + :tempids) + [[transaction-tx]] (sut/transactions->txs [(assoc base-transaction + :description {:original "Hello XXX039" + :simple ""} + :amount {:amount 31.0} + :id 789 + :bank-account {:db/id bank-account-id + :client/_bank-accounts {:db/id client-id + :client/locations ["A"]}})] + :bank-account + (rm/rule-applying-fn [{:transaction-rule/description "XXX039" + :transaction-rule/transaction-approval-status :transaction-approval-status/approved}]) + #{})] + + (t/is (= :transaction-approval-status/approved + (:transaction/approval-status transaction-tx))))) + (t/testing "Should apply vendor and approval status" + (let [apply-rules (rm/rule-applying-fn [{:db/id 1 + :transaction-rule/description "XXX039" + :transaction-rule/transaction-approval-status :transaction-approval-status/approved + :transaction-rule/vendor {:db/id 123} + :transaction-rule/accounts [{:transaction-rule-account/account {:db/id 9} + :transaction-rule-account/location "Shared" + :transaction-rule-account/percentage 1.0}]} + {:db/id 2 + :transaction-rule/description "OtherMatch" + :transaction-rule/transaction-approval-status :transaction-approval-status/requires-feedback + :transaction-rule/vendor {:db/id 456} + :transaction-rule/accounts [{:transaction-rule-account/account {:db/id 9} + :transaction-rule-account/location "Z" + :transaction-rule-account/percentage 1.0}]}])] + (t/is (= {:transaction/description-original "Hello XXX039", + :transaction/vendor 123 + :transaction/approval-status :transaction-approval-status/approved + :transaction/accounts [{:transaction-account/account 9 + :transaction-account/amount 30.0 + :transaction-account/location "Z"}] + :transaction/matched-rule 1 + :transaction/amount 30.0} + (-> {:transaction/description-original "Hello XXX039" + :transaction/amount 30.0} + (apply-rules ["Z"])))) - (let [[result] (sut/transactions->txs [(assoc base-transaction - :description {:original "CHECK 10001" - :simple ""} - :amount {:amount 30.0} - :bank-account {:db/id bank-account-id - :client/_bank-accounts {:db/id client-id - :client/locations ["A"]}})] - :bank-account - noop-rule - #{})] - - (t/is (= {:db/id payment-id - :payment/status :payment-status/cleared} - (:transaction/payment result)))) + (t/is (= {:transaction/description-original "OtherMatch", + :transaction/approval-status :transaction-approval-status/requires-feedback + :transaction/vendor 456 + :transaction/amount 30.0 + :transaction/matched-rule 2 + :transaction/accounts [{:transaction-account/account 9 + :transaction-account/amount 30.0 + :transaction-account/location "Z"}]} + (-> {:transaction/description-original "OtherMatch" + :transaction/amount 30.0} + (apply-rules ["Z"])))) - (t/testing "Should not match an already matched check" - @(d/transact (d/connect uri) [{:db/id payment-id :payment/status :payment-status/cleared}]) - (let [[result] (sut/transactions->txs [(assoc base-transaction - :description {:original "CHECK 10001" - :simple ""} - :amount {:amount 30.0} - :id 789 - :bank-account {:db/id bank-account-id - :client/_bank-accounts {:db/id client-id - :client/locations ["A"]}})] - :bank-account - noop-rule - #{})] - - (t/is (= nil - (:transaction/payment result))))))) + (t/is (= {:transaction/description-original "Hello Not match"} + (-> {:transaction/description-original "Hello Not match"} + (apply-rules [])))))) - - (t/testing "Rules" - (t/testing "Should apply rules to imported transaction" - (let [{:strs [bank-account-id client-id payment-id]} (->> [#:payment {:status :payment-status/pending - :date #inst "2019-01-01" - :bank-account "bank-account-id" - :client "client-id" - :check-number 10001 - :amount 30.0 - :db/id "payment-id"} - #:bank-account {:name "Bank account" - :db/id "bank-account-id"} - #:client {:name "Client" - :db/id "client-id" - :bank-accounts ["bank-account-id"]}] - (d/transact (d/connect uri)) - deref - :tempids) - [result] (sut/transactions->txs [(assoc base-transaction - :description {:original "Hello XXX039" - :simple ""} - :amount {:amount 31.0} - :id 789 - :bank-account {:db/id bank-account-id - :client/_bank-accounts {:db/id client-id - :client/locations ["A"]}})] - :bank-account - (rm/rule-applying-fn [{:transaction-rule/description "XXX039" - :transaction-rule/transaction-approval-status :transaction-approval-status/approved}]) - #{})] - - (t/is (= :transaction-approval-status/approved - (:transaction/approval-status result))))) - - (t/testing "Should apply vendor and approval status" - (let [apply-rules (rm/rule-applying-fn [{:db/id 1 - :transaction-rule/description "XXX039" - :transaction-rule/transaction-approval-status :transaction-approval-status/approved - :transaction-rule/vendor {:db/id 123} - :transaction-rule/accounts [{:transaction-rule-account/account {:db/id 9} - :transaction-rule-account/location "Shared" - :transaction-rule-account/percentage 1.0}]} - {:db/id 2 - :transaction-rule/description "OtherMatch" - :transaction-rule/transaction-approval-status :transaction-approval-status/requires-feedback - :transaction-rule/vendor {:db/id 456} - :transaction-rule/accounts [{:transaction-rule-account/account {:db/id 9} - :transaction-rule-account/location "Z" - :transaction-rule-account/percentage 1.0}]}])] - (t/is (= {:transaction/description-original "Hello XXX039", - :transaction/vendor 123 - :transaction/approval-status :transaction-approval-status/approved - :transaction/accounts [{:transaction-account/account 9 - :transaction-account/amount 30.0 - :transaction-account/location "Z"}] - :transaction/matched-rule 1 - :transaction/amount 30.0} - (-> {:transaction/description-original "Hello XXX039" - :transaction/amount 30.0} - (apply-rules ["Z"])))) - - (t/is (= {:transaction/description-original "OtherMatch", - :transaction/approval-status :transaction-approval-status/requires-feedback - :transaction/vendor 456 - :transaction/amount 30.0 - :transaction/matched-rule 2 - :transaction/accounts [{:transaction-account/account 9 - :transaction-account/amount 30.0 - :transaction-account/location "Z"}]} - (-> {:transaction/description-original "OtherMatch" - :transaction/amount 30.0} - (apply-rules ["Z"])))) - - (t/is (= {:transaction/description-original "Hello Not match"} - (-> {:transaction/description-original "Hello Not match"} - (apply-rules [])))))) - - (t/testing "Should match if day of month matches" - (let [apply-rules (rm/rule-applying-fn [{:db/id 123 - :transaction-rule/dom-gte 3 - :transaction-rule/dom-lte 9 - :transaction-rule/transaction-approval-status :transaction-approval-status/approved - :transaction-rule/vendor {:db/id 123}}])] - (t/is (= 123 - (-> {:transaction/date #inst "2019-01-04T00:00:00.000-08:00" - :transaction/amount 1.0} - (apply-rules []) - :transaction/matched-rule))) - (t/is (= 123 - (-> {:transaction/date #inst "2019-01-03T00:00:00.000-08:00" - :transaction/amount 1.0} - (apply-rules []) - :transaction/matched-rule))) - (t/is (= 123 - (-> {:transaction/date #inst "2019-01-09T00:00:00.000-08:00" - :transaction/amount 1.0} - (apply-rules []) - :transaction/matched-rule))) - (t/is (nil? - (-> {:transaction/date #inst "2019-01-01T00:00:00.000-08:00" + (t/testing "Should match if day of month matches" + (let [apply-rules (rm/rule-applying-fn [{:db/id 123 + :transaction-rule/dom-gte 3 + :transaction-rule/dom-lte 9 + :transaction-rule/transaction-approval-status :transaction-approval-status/approved + :transaction-rule/vendor {:db/id 123}}])] + (t/is (= 123 + (-> {:transaction/date #inst "2019-01-04T00:00:00.000-08:00" :transaction/amount 1.0} (apply-rules []) :transaction/matched-rule))) - (t/is (nil? - (-> {:transaction/date #inst "2019-01-10T00:00:00.000-08:00" + (t/is (= 123 + (-> {:transaction/date #inst "2019-01-03T00:00:00.000-08:00" :transaction/amount 1.0} (apply-rules []) - :transaction/matched-rule))))) - - (t/testing "Should match if amount matches" - (let [apply-rules (rm/rule-applying-fn [{:db/id 123 - :transaction-rule/amount-gte 3.0 - :transaction-rule/amount-lte 9.0 - :transaction-rule/transaction-approval-status :transaction-approval-status/approved - :transaction-rule/accounts [#:transaction-rule-account {:percentage 1.0 - :locatoin "HQ"}] - :transaction-rule/vendor {:db/id 123}}])] - (t/is (= 123 - (-> {:transaction/amount 4.0} - (apply-rules []) - :transaction/matched-rule))) - (t/is (= 123 - (-> {:transaction/amount 3.0} - (apply-rules []) - :transaction/matched-rule))) - (t/is (= 123 - (-> {:transaction/amount 9.0} - (apply-rules []) - :transaction/matched-rule))) - (t/is (nil? - (-> {:transaction/amount 9.01} - (apply-rules []) :transaction/matched-rule))) - (t/is (nil? - (-> {:transaction/amount 2.99} - (apply-rules []) - :transaction/matched-rule))))) - - (t/testing "Should match if client matches" - (let [apply-rules (rm/rule-applying-fn [{:db/id 123 - :transaction-rule/client {:db/id 456}}])] - (t/is (= 123 - (-> {:transaction/client 456 - :transaction/amount 1.0} - (apply-rules []) - :transaction/matched-rule))) - (t/is (nil? - (-> {:transaction/client 89 + (t/is (= 123 + (-> {:transaction/date #inst "2019-01-09T00:00:00.000-08:00" :transaction/amount 1.0} (apply-rules []) - :transaction/matched-rule))))) - (t/testing "Should match if bank-account matches" - (let [apply-rules (rm/rule-applying-fn [{:db/id 123 - :transaction-rule/bank-account {:db/id 456}}])] - (t/is (= 123 - (-> {:transaction/bank-account 456 - :transaction/amount 1.0} - (apply-rules []) - :transaction/matched-rule))) - (t/is (nil? - (-> {:transaction/bank-account 89 + :transaction/matched-rule))) + (t/is (nil? + (-> {:transaction/date #inst "2019-01-01T00:00:00.000-08:00" + :transaction/amount 1.0} + (apply-rules []) + :transaction/matched-rule))) + (t/is (nil? + (-> {:transaction/date #inst "2019-01-10T00:00:00.000-08:00" + :transaction/amount 1.0} + (apply-rules []) + :transaction/matched-rule))))) + + (t/testing "Should match if amount matches" + (let [apply-rules (rm/rule-applying-fn [{:db/id 123 + :transaction-rule/amount-gte 3.0 + :transaction-rule/amount-lte 9.0 + :transaction-rule/transaction-approval-status :transaction-approval-status/approved + :transaction-rule/accounts [#:transaction-rule-account {:percentage 1.0 + :locatoin "HQ"}] + :transaction-rule/vendor {:db/id 123}}])] + (t/is (= 123 + (-> {:transaction/amount 4.0} + (apply-rules []) + :transaction/matched-rule))) + (t/is (= 123 + (-> {:transaction/amount 3.0} + (apply-rules []) + :transaction/matched-rule))) + (t/is (= 123 + (-> {:transaction/amount 9.0} + (apply-rules []) + :transaction/matched-rule))) + (t/is (nil? + (-> {:transaction/amount 9.01} + (apply-rules []) + :transaction/matched-rule))) + (t/is (nil? + (-> {:transaction/amount 2.99} + (apply-rules []) + :transaction/matched-rule))))) + + (t/testing "Should match if client matches" + (let [apply-rules (rm/rule-applying-fn [{:db/id 123 + :transaction-rule/client {:db/id 456}}])] + (t/is (= 123 + (-> {:transaction/client 456 :transaction/amount 1.0} (apply-rules []) - :transaction/matched-rule))))) + :transaction/matched-rule))) + (t/is (nil? + (-> {:transaction/client 89 + :transaction/amount 1.0} + (apply-rules []) + :transaction/matched-rule))))) + (t/testing "Should match if bank-account matches" + (let [apply-rules (rm/rule-applying-fn [{:db/id 123 + :transaction-rule/bank-account {:db/id 456}}])] + (t/is (= 123 + (-> {:transaction/bank-account 456 + :transaction/amount 1.0} + (apply-rules []) + :transaction/matched-rule))) + (t/is (nil? + (-> {:transaction/bank-account 89 + :transaction/amount 1.0} + (apply-rules []) + :transaction/matched-rule))))) - (t/testing "Should prioritize rules" - (let [apply-rules (rm/rule-applying-fn (shuffle [{:db/id 2 - :transaction-rule/description "Hello" - :transaction-rule/amount-gte 5.0} - {:db/id 1 - :transaction-rule/description "Hello"} - {:db/id 0 - :transaction-rule/description "Hello"} - {:db/id 3 - :transaction-rule/description "Hello" - :transaction-rule/client {:db/id 789}} - {:db/id 4 - :transaction-rule/description "Hello" - :transaction-rule/client {:db/id 789} - :transaction-rule/bank-account {:db/id 456}}]))] + (t/testing "Should prioritize rules" + (let [apply-rules (rm/rule-applying-fn (shuffle [{:db/id 2 + :transaction-rule/description "Hello" + :transaction-rule/amount-gte 5.0} + {:db/id 1 + :transaction-rule/description "Hello"} + {:db/id 0 + :transaction-rule/description "Hello"} + {:db/id 3 + :transaction-rule/description "Hello" + :transaction-rule/client {:db/id 789}} + {:db/id 4 + :transaction-rule/description "Hello" + :transaction-rule/client {:db/id 789} + :transaction-rule/bank-account {:db/id 456}}]))] - (t/is (= 4 - (-> {:transaction/bank-account 456 - :transaction/client 789 - :transaction/description-original "Hello" - :transaction/amount 6.0} - (apply-rules []) - :transaction/matched-rule))) - (t/is (= 3 - (-> {:transaction/bank-account 457 - :transaction/client 789 - :transaction/amount 6.0 - :transaction/description-original "Hello"} - (apply-rules []) - :transaction/matched-rule))) - (t/is (= 3 - (-> {:transaction/bank-account 457 - :transaction/client 789 - :transaction/amount 6.0 - :transaction/description-original "Hello"} - (apply-rules []) - :transaction/matched-rule))) + (t/is (= 4 + (-> {:transaction/bank-account 456 + :transaction/client 789 + :transaction/description-original "Hello" + :transaction/amount 6.0} + (apply-rules []) + :transaction/matched-rule))) + (t/is (= 3 + (-> {:transaction/bank-account 457 + :transaction/client 789 + :transaction/amount 6.0 + :transaction/description-original "Hello"} + (apply-rules []) + :transaction/matched-rule))) + (t/is (= 3 + (-> {:transaction/bank-account 457 + :transaction/client 789 + :transaction/amount 6.0 + :transaction/description-original "Hello"} + (apply-rules []) + :transaction/matched-rule))) - (t/testing "Should only apply if there is a single rule at that specificity level" - (t/is (nil? - (-> {:transaction/bank-account 3 - :transaction/client 1 - :transaction/description-original "Hello" - :transaction/amount 0.0} - (apply-rules []) - :transaction/matched-rule))))))))) + (t/testing "Should only apply if there is a single rule at that specificity level" + (t/is (nil? + (-> {:transaction/bank-account 3 + :transaction/client 1 + :transaction/description-original "Hello" + :transaction/amount 0.0} + (apply-rules []) + :transaction/matched-rule)))))))) +(t/deftest match-transaction-to-unfulfilled-payments + (t/testing "Auto-pay Invoices" + (let [{:strs [vendor1-id vendor2-id]} (->> [#:vendor {:name "Autopay vendor 1" + :db/id "vendor1-id"} + #:vendor {:name "Autopay vendor 2" + :db/id "vendor2-id"}] + (d/transact (d/connect uri)) + deref + :tempids)] + (t/testing "Should find a single invoice that matches exactly" + (let [{:strs [client-id invoice-id]} (->> [#:invoice {:status :invoice-status/paid + :vendor vendor1-id + :scheduled-payment #inst "2019-01-04" + :date #inst "2019-01-01" + :client "client-id" + :total 30.0 + :db/id "invoice-id"} + #:client {:name "Client" :db/id "client-id"}] + (d/transact (d/connect uri)) + deref + :tempids) + invoices-matches (sut/match-transaction-to-unfulfilled-autopayments -30.0 client-id)] + (t/is (= 1 (count invoices-matches))) + )) + + (t/testing "Should not match paid invoice that isn't a scheduled payment" + (let [{:strs [client-id invoice-id]} (->> [#:invoice {:status :invoice-status/paid + :vendor vendor1-id + :date #inst "2019-01-01" + :client "client-id" + :total 30.0 + :db/id "invoice-id"} + #:client {:name "Client" :db/id "client-id"}] + (d/transact (d/connect uri)) + deref + :tempids) + invoices-matches (sut/match-transaction-to-unfulfilled-autopayments -30.0 client-id)] + + (t/is (= [] invoices-matches)))) + + (t/testing "Should not match unpaid invoice" + (let [{:strs [client-id invoice-id]} (->> [#:invoice {:status :invoice-status/unpaid + :scheduled-payment #inst "2019-01-04" + :vendor vendor1-id + :date #inst "2019-01-01" + :client "client-id" + :total 30.0 + :db/id "invoice-id"} + #:client {:name "Client" :db/id "client-id"}] + (d/transact (d/connect uri)) + deref + :tempids) + invoices-matches (sut/match-transaction-to-unfulfilled-autopayments -30.0 client-id)] + + (t/is (= [] invoices-matches)))) + + (t/testing "Should not match invoice that already has a payment" + (let [{:strs [client-id invoice-id]} (->> [#:invoice {:status :invoice-status/paid + :scheduled-payment #inst "2019-01-04" + :vendor vendor1-id + :date #inst "2019-01-01" + :client "client-id" + :total 30.0 + :db/id "invoice-id"} + {:invoice-payment/amount 30.0 + :invoice-payment/invoice "invoice-id"} + #:client {:name "Client" + :db/id "client-id"}] + (d/transact (d/connect uri)) + deref + :tempids) + invoices-matches (sut/match-transaction-to-unfulfilled-autopayments -30.0 + client-id)] + (t/is (= [] invoices-matches)))) + (t/testing "Should match multiple invoices for same vendor that total to transaction amount" + (let [{:strs [client-id invoice1-id invoice2-id]} (->> [#:invoice {:status :invoice-status/paid + :vendor vendor1-id + :scheduled-payment #inst "2019-01-04" + :date #inst "2019-01-01" + :client "client-id" + :total 15.0 + :db/id "invoice1-id"} + #:invoice {:status :invoice-status/paid + :vendor vendor1-id + :scheduled-payment #inst "2019-01-04" + :date #inst "2019-01-01" + :client "client-id" + :total 15.0 + :db/id "invoice2-id"} + #:client {:name "Client" :db/id "client-id"}] + (d/transact (d/connect uri)) + deref + :tempids) + invoices-matches (sut/match-transaction-to-unfulfilled-autopayments -30.0 client-id)] + (t/is (= 2 (count invoices-matches)) + (str "Expected " (vec invoices-matches) " to have a singular match of two invoices.")))) + (t/testing "Should not match if there are multiple candidate matches" + (let [{:strs [client-id invoice1-id invoice2-id]} (->> [#:invoice {:status :invoice-status/paid + :vendor vendor1-id + :scheduled-payment #inst "2019-01-04" + :date #inst "2019-01-01" + :client "client-id" + :total 30.0 + :db/id "invoice1-id"} + #:invoice {:status :invoice-status/paid + :vendor vendor1-id + :scheduled-payment #inst "2019-01-04" + :date #inst "2019-01-01" + :client "client-id" + :total 30.0 + :db/id "invoice2-id"} + #:client {:name "Client" :db/id "client-id"}] + (d/transact (d/connect uri)) + deref + :tempids) + invoices-matches (sut/match-transaction-to-unfulfilled-autopayments -30.0 client-id)] + (t/is (= 0 (count invoices-matches)) + (str "Expected " (vec invoices-matches) " to not match due to multiple possibilities.")))) + + (t/testing "Should not match if invoices are for different vendors" + (let [{:strs [client-id invoice1-id invoice2-id]} (->> [#:invoice {:status :invoice-status/paid + :vendor vendor1-id + :scheduled-payment #inst "2019-01-04" + :date #inst "2019-01-01" + :client "client-id" + :total 10.0 + :db/id "invoice1-id"} + #:invoice {:status :invoice-status/paid + :vendor vendor2-id + :scheduled-payment #inst "2019-01-04" + :date #inst "2019-01-01" + :client "client-id" + :total 20.0 + :db/id "invoice2-id"} + #:client {:name "Client" :db/id "client-id"}] + (d/transact (d/connect uri)) + deref + :tempids) + invoices-matches (sut/match-transaction-to-unfulfilled-autopayments -30.0 client-id)] + (t/is (= 0 (count invoices-matches)) + (str "Expected " (vec invoices-matches) " to only consider invoices for the same vendor.")))) + + (t/testing "Should only consider invoices chronologically" + (let [{:strs [client-id invoice1-id invoice2-id]} (->> [#:invoice {:status :invoice-status/paid + :vendor vendor1-id + :scheduled-payment #inst "2019-01-04" + :date #inst "2019-01-01" + :client "client-id" + :total 10.0 + :db/id "invoice1-id"} + #:invoice {:status :invoice-status/paid + :vendor vendor1-id + :scheduled-payment #inst "2019-01-06" + :date #inst "2019-01-01" + :client "client-id" + :total 21.0 + :db/id "invoice2-id"} + #:invoice {:status :invoice-status/paid + :vendor vendor1-id + :scheduled-payment #inst "2019-01-05" + :date #inst "2019-01-01" + :client "client-id" + :total 30.0 + :db/id "invoice3-id"} + #:client {:name "Client" :db/id "client-id"}] + (d/transact (d/connect uri)) + deref + :tempids)] + (t/is (= 2 (count (sut/match-transaction-to-unfulfilled-autopayments -40.0 client-id))) + (str "Expected to match with the chronologically adjacent invoice-1 and invoice-3.")) + (t/is (= [] (sut/match-transaction-to-unfulfilled-autopayments -31.0 client-id)) + (str "Expected to not match, because there is invoice-3 is between invoice-1 and invoice-2.")))))))