From d40ff90e8504989b1a7c9bb7852c0cb5145cf12d Mon Sep 17 00:00:00 2001 From: Bryce Covert Date: Tue, 13 Dec 2022 07:45:55 -0800 Subject: [PATCH] Makes it possible to bulk change invoices --- src/clj/auto_ap/datomic/invoices.clj | 6 + src/clj/auto_ap/graphql/invoices.clj | 113 ++++++++++++++++-- .../auto_ap/views/components/buttons.cljs | 5 + .../components/expense_accounts_field.cljs | 2 +- .../views/components/invoice_table.cljs | 1 + .../views/components/invoices/side_bar.cljs | 15 ++- .../views/pages/invoices/bulk_change.cljs | 97 +++++++++++++++ .../auto_ap/views/pages/transactions.cljs | 5 +- .../pages/transactions/bulk_updates.cljs | 4 +- .../auto_ap/views/pages/unpaid_invoices.cljs | 30 +++-- 10 files changed, 255 insertions(+), 23 deletions(-) create mode 100644 src/cljs/auto_ap/views/pages/invoices/bulk_change.cljs diff --git a/src/clj/auto_ap/datomic/invoices.clj b/src/clj/auto_ap/datomic/invoices.clj index 6cb46493..a4dcd339 100644 --- a/src/clj/auto_ap/datomic/invoices.clj +++ b/src/clj/auto_ap/datomic/invoices.clj @@ -104,6 +104,12 @@ :where ['[?e :invoice/vendor ?vendor-id]]} :args [ (:vendor-id args)]}) + (:account-id args) + (merge-query {:query {:in ['?account-id] + :where ['[?e :invoice/expense-accounts ?iea ?] + '[?iea :invoice-expense-account/account ?account-id]]} + :args [ (:account-id args)]}) + (:amount-gte args) (merge-query {:query {:in ['?amount-gte] :where ['[?e :invoice/total ?total-filter] diff --git a/src/clj/auto_ap/graphql/invoices.clj b/src/clj/auto_ap/graphql/invoices.clj index a292c4df..c306a636 100644 --- a/src/clj/auto_ap/graphql/invoices.clj +++ b/src/clj/auto_ap/graphql/invoices.clj @@ -2,10 +2,11 @@ (:require [auto-ap.datomic :refer [conn remove-nils uri]] - [auto-ap.ledger :refer [transact-with-ledger]] + [auto-ap.ledger :refer [transact-with-ledger transact-batch-with-ledger]] [auto-ap.datomic.clients :as d-clients] [auto-ap.datomic.invoices :as d-invoices] [auto-ap.datomic.vendors :as d-vendors] + [auto-ap.rule-matching :as rm] [auto-ap.graphql.checks :as gq-checks] [auto-ap.graphql.utils :as u @@ -239,19 +240,23 @@ (-> (d-invoices/get-by-id id) (->graphql (:id context))))) -(defn void-invoices [context args _] - (let [_ (assert-admin (:id context)) - args (assoc args :id (:id context)) - ids (some-> args + +(defn get-ids-matching-filters [args] + (let [ids (some-> args :filters - (assoc :id (:id context)) + (assoc :id (:id args)) (<-graphql) (update :status enum->keyword "invoice-status") (assoc :per-page Integer/MAX_VALUE) d-invoices/raw-graphql-ids :ids) - specific-ids (d-invoices/filter-ids (:ids args)) - all-ids (into (set ids) specific-ids)] + specific-ids (d-invoices/filter-ids (:ids args))] + (into (set ids) specific-ids))) + +(defn void-invoices [context args _] + (let [_ (assert-admin (:id context)) + args (assoc args :id (:id context)) + all-ids (all-ids-not-locked (get-ids-matching-filters args))] (log/info "Voiding " (count all-ids) args) (transact-with-ledger @@ -341,6 +346,87 @@ (d-invoices/get-by-id (:invoice_id args)) (:id context)))) +;; TODO - multiple versions of this now exist. fix in datomic migration? +(defn maybe-code-accounts [invoice account-rules valid-locations] + (with-precision 2 + (let [accounts (vec (mapcat + (fn [ar] + (let [cents-to-distribute (int (Math/round (Math/abs (* (:percentage ar) + (:invoice/total invoice) + 100))))] + (if (= "Shared" (:location ar)) + (do + (->> valid-locations + (map + (fn [cents location] + {:invoice-expense-account/account (:account_id ar) + :invoice-expense-account/amount (* 0.01 cents) + :invoice-expense-account/location location}) + (rm/spread-cents cents-to-distribute (count valid-locations))))) + [(cond-> {:invoice-expense-account/account (:account_id ar) + :invoice-expense-account/amount (* 0.01 cents-to-distribute)} + (:location ar) (assoc :invoice-expense-account/location (:location ar)))]))) + account-rules)) + accounts (mapv + (fn [a] + (update a :invoice-expense-account/amount + #(with-precision 2 + (double (.setScale (bigdec %) 2 java.math.RoundingMode/HALF_UP))))) + accounts) + leftover (with-precision 2 (.round (bigdec (- (Math/abs (:invoice/total invoice)) + (Math/abs (reduce + 0.0 (map #(:invoice-expense-account/amount %) accounts))))) + *math-context*)) + accounts (if (seq accounts) + (update-in accounts [(dec (count accounts)) :invoice-expense-account/amount] #(+ % (double leftover))) + [])] + [:reset (:db/id invoice) :invoice/expense-accounts accounts]))) + +(defn all-ids-not-locked [all-ids] + (->> all-ids + (d/q '[:find [?i ...] + :in $ [?i ...] + :where + [?i :invoice/client ?c] + [(get-else $ ?c :client/locked-until #inst "2000-01-01") ?lu] + [?i :invoice/date ?d] + [(>= ?d ?lu)]] + (d/db conn)))) + +(defn bulk-change-invoices [context args _] + (assert-admin (:id context)) + (when-not (:client_id args) + (throw (ex-info "Client is required" + {:validation-error "client is required"}))) + (let [args (assoc args :id (:id context)) + locations (:client/locations (d/pull (d/db conn) + [:client/locations] + (:client_id args))) + all-ids (all-ids-not-locked (get-ids-matching-filters args)) + invoices (d/pull-many (d/db conn) '[:db/id :invoice/total] (vec all-ids)) + account-total (reduce + 0 (map (fn [x] (:percentage x)) (:accounts args)))] + (log/info "client is" locations) + (when + (not (dollars= 1.0 account-total)) + (let [error (str "Account total (" account-total ") does not reach 100%")] + (throw (ex-info error {:validation-error error})))) + (doseq [a (:accounts args) + :let [{:keys [:account/location :account/name]} (d/entity (d/db conn) (:account_id a))]] + (when (and location (not= location (:location a))) + (let [err (str "Account " name " uses location " (:location a) ", but is supposed to be " location)] + (throw (ex-info err {:validation-error err}) ))) + (when (and (not location) + (not (get (into #{"Shared"} locations) + (:location a)))) + (let [err (str "Account " name " uses location " (:location a) ", but doesn't belong to the client.")] + (throw (ex-info err {:validation-error err}) )))) + (log/info "Bulk coding " (count all-ids) args) + (transact-batch-with-ledger + (map (fn [i] + (maybe-code-accounts i (:accounts args) locations)) + invoices) + (:id context)) + {:message (str "Successfully coded " (count all-ids) " invoices.")})) + (def objects {:invoice {:fields {:id {:type :id} @@ -423,7 +509,14 @@ :edit_expense_accounts {:type :invoice :args {:invoice_id {:type :id} :expense_accounts {:type '(list :edit_expense_account)}} - :resolve :mutation/edit-expense-accounts}}) + :resolve :mutation/edit-expense-accounts} + + :bulk_change_invoices {:type :message + :args {:filters {:type :invoice_filters} + :client_id {:type :id} + :accounts {:type '(list :edit_percentage_account)} + :ids {:type '(list :id)}} + :resolve :mutation/bulk-change-invoices}}) (def input-objects {:add_invoice @@ -462,6 +555,7 @@ :scheduled_payments {:type 'Boolean} :client_id {:type :id} :vendor_id {:type :id} + :account_id {:type :id} :amount_lte {:type :money} :amount_gte {:type :money} :invoice_number_like {:type 'String} @@ -485,6 +579,7 @@ :mutation/edit-invoice edit-invoice :mutation/void-invoice void-invoice :mutation/void-invoices void-invoices + :mutation/bulk-change-invoices bulk-change-invoices :mutation/unvoid-invoice unvoid-invoice :mutation/unautopay-invoice unautopay-invoice :mutation/edit-expense-accounts edit-expense-accounts}) diff --git a/src/cljs/auto_ap/views/components/buttons.cljs b/src/cljs/auto_ap/views/components/buttons.cljs index 6cb5fb2c..a01b7b38 100644 --- a/src/cljs/auto_ap/views/components/buttons.cljs +++ b/src/cljs/auto_ap/views/components/buttons.cljs @@ -28,6 +28,11 @@ [:span.icon [:i.fa.fa-plus]] [:span name]]) +(defn event-button [{:keys [event name class ]}] + [:a.button.is-outlined {:class class + :on-click (dispatch-event event)} + [:span name]]) + (defn dropdown [{:keys [event on-click] :as params}] [:a.button (cond-> params true (dissoc :event :icon) diff --git a/src/cljs/auto_ap/views/components/expense_accounts_field.cljs b/src/cljs/auto_ap/views/components/expense_accounts_field.cljs index f18c4427..16a4ffa9 100644 --- a/src/cljs/auto_ap/views/components/expense_accounts_field.cljs +++ b/src/cljs/auto_ap/views/components/expense_accounts_field.cljs @@ -96,7 +96,7 @@ (on-change (into [] updated-expense-accounts))))} [:div [:div.tags - (when max-value + (when (and max-value (not percentage-only?)) [:div.tag "To Allocate: " (->$ max-value)]) (when-not percentage-only? diff --git a/src/cljs/auto_ap/views/components/invoice_table.cljs b/src/cljs/auto_ap/views/components/invoice_table.cljs index 384319ce..69585bb3 100644 --- a/src/cljs/auto_ap/views/components/invoice_table.cljs +++ b/src/cljs/auto_ap/views/components/invoice_table.cljs @@ -29,6 +29,7 @@ :per-page (:per-page params) :vendor-id (:id (:vendor params)) + :account-id (:id (:account params)) :date-range (:date-range params) :due-range (:due-range params) :amount-gte (:amount-gte (:amount-range 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 c3923802..5cc363a1 100644 --- a/src/cljs/auto_ap/views/components/invoices/side_bar.cljs +++ b/src/cljs/auto_ap/views/components/invoices/side_bar.cljs @@ -21,7 +21,8 @@ (defn invoices-side-bar [{:keys [data-page] :as params}] (let [ap @(re-frame/subscribe [::subs/active-page]) - user @(re-frame/subscribe [::subs/user])] + user @(re-frame/subscribe [::subs/user]) + client @(re-frame/subscribe [::subs/client])] [:div [:div [:p.menu-label "Type"] [:ul.menu-list @@ -111,6 +112,18 @@ [:div [invoice-number-filter params]] + [:p.menu-label "Financial Account"] + [:div + [search-backed-typeahead {:search-query (fn [i] + [:search_account + {:query i + :client-id (:id client)} + [:name :id]]) + :entity->text (fn [x ] (:name x)) + :type "typeahead-v3" + :on-change #(re-frame/dispatch [::data-page/filter-changed data-page :account (some-> % (select-keys [:name :id :numeric-code]))]) + :value @(re-frame/subscribe [::data-page/filter data-page :account])}]] + (when-let [exact-match-id @(re-frame/subscribe [::data-page/filter data-page :exact-match-id])] [:div [:p.menu-label "Specific Invoice"] diff --git a/src/cljs/auto_ap/views/pages/invoices/bulk_change.cljs b/src/cljs/auto_ap/views/pages/invoices/bulk_change.cljs new file mode 100644 index 00000000..292e52cd --- /dev/null +++ b/src/cljs/auto_ap/views/pages/invoices/bulk_change.cljs @@ -0,0 +1,97 @@ +(ns auto-ap.views.pages.invoices.bulk-change + (:require + [auto-ap.forms :as forms] + [auto-ap.status :as status] + [auto-ap.subs :as subs] + [auto-ap.views.components.expense-accounts-field + :as expense-accounts-field + :refer [expense-accounts-field-v2]] + [auto-ap.views.components.modal :as modal] + [auto-ap.views.pages.data-page :as data-page] + [auto-ap.views.components.invoice-table + :refer [data-params->query-params]] + [auto-ap.views.utils :refer [dispatch-event with-user]] + [clojure.string :as str] + [re-frame.core :as re-frame] + [reagent.core :as r] + [vimsical.re-frame.fx.track :as track] + [auto-ap.events :as events] + [vimsical.re-frame.cofx.inject :as inject] + [auto-ap.forms.builder :as form-builder] + [auto-ap.views.components :as com])) + +(re-frame/reg-event-fx + ::coded + (fn [_ [_ _ _]] + {:dispatch-n [[::modal/modal-closed] + [:auto-ap.views.pages.unpaid-invoices/params-change + @(re-frame/subscribe [::data-page/params :auto-ap.views.pages.unpaid-invoices/invoices])]]})) + +(re-frame/reg-event-fx + ::code-selected + [with-user (forms/in-form ::form) (re-frame/inject-cofx ::inject/sub [::subs/client])] + (fn [{:keys [user db] ::subs/keys [client]} [_ checked]] + (let [checked-params (get checked "header") + specific-invoices (map :id (vals (dissoc checked "header"))) + data (:data db)] + {:graphql + {:token user + :owns-state {:single ::form} + :query-obj {:venia/operation {:operation/type :mutation + :operation/name "BulkChangeInvoices"} + :venia/queries [[:bulk-change-invoices + {:filters (some-> checked-params data-params->query-params) + :client_id (:id client) + :ids specific-invoices + :accounts (map + #(-> % + (update :id (fn [i] (if (some-> i (str/starts-with? "new-")) + nil + i))) + (assoc :percentage (/ (get-in % [:amount-percentage]) 100 )) + (assoc :account-id (get-in % [:account :id])) + (select-keys [:percentage :id :location :account-id])) + (:accounts data))} + [:message] + ]]} + :on-success (fn [result] + [::coded + (:message result) + checked-params + ])}}))) + +(defn form-content [_] + (let [{:keys [data]} @(re-frame/subscribe [::forms/form ::form])] + [form-builder/builder {:submit-event [::code-selected] + :id ::form} + + [form-builder/raw-field-v2 {:field :accounts} + [expense-accounts-field-v2 {:descriptor "account asssignment" + :percentage-only? true + :client (:client data) + :locations (into ["Shared"] @(re-frame/subscribe [::subs/locations-for-client (:id (:client data))])) + :max 100}]]])) +(defn form [_] + (r/create-class + {:display-name "invoice-bulk-change-form" + :reagent-render (fn [p] + [form-content p])})) + +(re-frame/reg-event-fx + ::bulk-change-requested + (fn [{:keys [db]} [_ checked params total-visible]] + (let [to-change (if (get checked "header") + [:b.strong.has-text-danger "all " total-visible " visible invoices"] + (str (count checked) " invoices"))] + {:dispatch [::modal/modal-requested {:title "Confirmation" + :body [:div "Please fill in the details on how to code " to-change ":" + [form ]] + :cancel? true + :confirm {:value "Code" + :class "is-danger" + :status-from [::status/single ::form] + :on-click (dispatch-event [::code-selected checked] )} + :close-event [::status/completed ::code-selected]}] + :db (-> db + (forms/start-form ::form {:accounts [] + :client @(re-frame/subscribe [::subs/client (:client-id params)])}))}))) diff --git a/src/cljs/auto_ap/views/pages/transactions.cljs b/src/cljs/auto_ap/views/pages/transactions.cljs index 97efa205..795045f0 100644 --- a/src/cljs/auto_ap/views/pages/transactions.cljs +++ b/src/cljs/auto_ap/views/pages/transactions.cljs @@ -132,7 +132,8 @@ (defn action-buttons [] (let [is-admin? @(re-frame/subscribe [::subs/is-admin?]) params @(re-frame/subscribe [::data-page/params ::page]) - checked @(re-frame/subscribe [::data-page/checked ::page])] + checked @(re-frame/subscribe [::data-page/checked ::page]) + {:keys [total]} @(re-frame/subscribe [::data-page/data ::page])] (when is-admin? [:<> [:div.level-item @@ -154,7 +155,7 @@ [:button.button.is-outlined.is-primary {:on-click (dispatch-event [::manual/opening])} "Manual Yodlee Import"] (when (:client-id params) - [:button.button.is-warning {:on-click (dispatch-event [::bulk/code-requested checked params]) + [:button.button.is-warning {:on-click (dispatch-event [::bulk/code-requested checked params total]) :disabled (not (seq checked))} "Code"]) [:button.button.is-danger {:on-click (dispatch-event [::delete-selected-requested params false]) diff --git a/src/cljs/auto_ap/views/pages/transactions/bulk_updates.cljs b/src/cljs/auto_ap/views/pages/transactions/bulk_updates.cljs index b2e59e28..9ad2114f 100644 --- a/src/cljs/auto_ap/views/pages/transactions/bulk_updates.cljs +++ b/src/cljs/auto_ap/views/pages/transactions/bulk_updates.cljs @@ -138,9 +138,9 @@ (re-frame/reg-event-fx ::code-requested - (fn [{:keys [db]} [_ checked params]] + (fn [{:keys [db]} [_ checked params total-visible]] (let [to-delete (if (get checked "header") - [:b.strong.has-text-danger "all visible transactions"] + [:b.strong.has-text-danger "all " total-visible " visible transactions"] (str (count checked) " transactions"))] {:dispatch [::modal/modal-requested {:title "Confirmation" :body [:div "Please fill in the details on how to code " to-delete ":" diff --git a/src/cljs/auto_ap/views/pages/unpaid_invoices.cljs b/src/cljs/auto_ap/views/pages/unpaid_invoices.cljs index ea5fb11e..65c12f0a 100644 --- a/src/cljs/auto_ap/views/pages/unpaid_invoices.cljs +++ b/src/cljs/auto_ap/views/pages/unpaid_invoices.cljs @@ -6,6 +6,7 @@ [auto-ap.status :as status] [auto-ap.subs :as subs] [auto-ap.views.components.buttons :as buttons] + [auto-ap.views.pages.invoices.bulk-change :as bulk-change] [auto-ap.views.components.dropdown :refer [drop-down]] [auto-ap.views.components.expense-accounts-dialog :as expense-accounts-dialog] @@ -208,18 +209,32 @@ "Void"]))) -(defn pay-button [] +(defn pay-button [status] (let [current-client @(re-frame/subscribe [::subs/client]) checked-invoices @(re-frame/subscribe [::data-page/checked :invoices]) - print-checks-status @(re-frame/subscribe [::status/single ::print-checks])] + is-admin? @(re-frame/subscribe [::subs/is-admin?]) + print-checks-status @(re-frame/subscribe [::status/single ::print-checks]) + params @(re-frame/subscribe [::data-page/params :invoices]) + {:keys [total]} @(re-frame/subscribe [::data-page/data :invoices]) + ] [:div.buttons - [void-selected-button] + (when (and (= :unpaid status) + is-admin?) + [void-selected-button]) [buttons/new-button {:event [::new-invoice-clicked] :name "Invoice" :class "is-primary"}] - - (when current-client + + (when (and is-admin? + current-client) + [buttons/event-button {:event [::bulk-change/bulk-change-requested checked-invoices params total] + :name "Bulk Edit" + :class "is-secondary" + :disabled (not (seq checked-invoices))}]) + + (when (and current-client + (= :unpaid status)) (let [balance (->> checked-invoices vals (map (comp js/parseFloat :outstanding-balance)) @@ -284,11 +299,10 @@ [table/invoice-table {:id (:id page) :data-page :invoices - :check-boxes (= status :unpaid) + :check-boxes true :checkable-fn (fn [i] (not (:scheduled-payment i))) :actions #{:edit :void :expense-accounts} - :action-buttons (when (= status :unpaid) - [pay-button])}]])) + :action-buttons [pay-button status]}]])) (defn layout [params] (let [{invoice-bar-active? :active?} @(re-frame/subscribe [::forms/form ::form/form])]