From 5502f4c4a2783a9ccbaa2729ec1462a4d493da09 Mon Sep 17 00:00:00 2001 From: Bryce Date: Thu, 25 Jun 2026 22:05:01 -0700 Subject: [PATCH] =?UTF-8?q?refactor(ssr):=20Phase=208=20=E2=80=94=20migrat?= =?UTF-8?q?e=20New/Edit=20Invoice=20onto=20the=20engine=20(conditional=20:?= =?UTF-8?q?next)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hardest modal in the app: one wizard that both creates and edits invoices, with a conditional middle step (basic-details → [accounts] → next-steps, where the expense-accounts step is skipped on the default-accounts path). Migrated off mm/* + form-cursor + the EDN snapshot onto the session-backed engine (wizard2). Finding: the OLD basic-details "Save" was broken. It hx-puts /invoice/new/navigate, whose `[:to {:optional true} …]` query-schema 500s on empty query-params — Ring's wrap-params yields {} for a no-query PUT, and main-transformer's parse-empty-as-nil decodes {} → nil, which the bare [:map] rejects. Production uses the identical wrap-params, so it was broken there too. So e2e/invoice-new.spec.ts is an ACCEPTANCE gate (red on the old code, green on the engine, whose submit is a POST with no query-schema): the migration fixes a latent bug. Create semantics (default → vendor default account, location-spread; customize → posted grid; edit → prefill + updated row) were pinned at the REPL. What changed: - defrecord 4 → 0 (NewWizard2 / BasicDetailsStep / AccountsStep / NextSteps), mm/ 0, fc/ cursor refs 0, step-params[…] field names 0. - Conditional `:next` `(if (= :customize …) :accounts :done)` replaces mm/CustomNext + the broken 308-to-submit. Dual-purpose new+edit = one :init-fn branching on a route :db/id; create-wizard! seeds :init-data as per-step step-data so edit opens populated. - The broken new-wizard-navigate route is deleted; the genuine async helpers (account-prediction, due/scheduled-payment-date, location-select, expense total/balance, add-row) remain but read the posted flat form (+ ws/get-all for the cross-step total). - next-steps becomes the done-fn's returned modal (Pay now / Add another / Close). - Dates ride as java.util.Date (#inst) in step-data so it's EDN-safe across the non-terminal step (clj-time DateTimes break the cookie store). Verification: full e2e suite 61/61 (58 prior + 3 new); maybe-spread-locations unit test 6/6; create semantics + edit prefill confirmed at the REPL. Skill fed (scorecard Phase 8, gotchas {}→nil 500 + #inst dates, form-vs-wizard conditional :next + dual-purpose). Co-Authored-By: Claude Opus 4.8 --- .../reference/form-vs-wizard.md | 40 + .../ssr-form-migration/reference/gotchas.md | 32 + .../ssr-form-migration/reference/scorecard.md | 40 + e2e/invoice-new.spec.ts | 101 ++ .../ssr/invoice/new_invoice_wizard.clj | 1231 ++++++++--------- src/cljc/auto_ap/routes/invoice.cljc | 1 - 6 files changed, 820 insertions(+), 625 deletions(-) create mode 100644 e2e/invoice-new.spec.ts diff --git a/.claude/skills/ssr-form-migration/reference/form-vs-wizard.md b/.claude/skills/ssr-form-migration/reference/form-vs-wizard.md index 1d34d4af..e039c77e 100644 --- a/.claude/skills/ssr-form-migration/reference/form-vs-wizard.md +++ b/.claude/skills/ssr-form-migration/reference/form-vs-wizard.md @@ -189,3 +189,43 @@ entity*, not a true multi-data-step flow — so it exercises the engine's render preview path (`:all-data` feeds the test table) but not the cross-step *merge*. The merge (`get-all` combining independent steps) gets its real workout in Phase 7+ (Invoice Pay, New Invoice, Vendor, Client), where steps collect genuinely different fields. + +## Conditional `:next` + dual-purpose (new+edit) — New Invoice (Phase 8) + +A step's `:next` is just `(fn [data] -> next-step-key | :done)`, so **branching the flow is a +one-liner** — no `CustomNext` protocol, no 308-redirect-to-submit hack: + +```clojure +{:key :basic-details + :next (fn [data] (if (= :customize (:customize-accounts data)) :accounts :done))} +``` + +`:default` skips the expense-accounts step entirely (the done-fn uses the vendor's default +account); `:customize` routes through the grid. The old wizard expressed this with +`mm/CustomNext` returning either `navigate-handler{:to :accounts}` or a 308 to the submit +route — and the 308 path was broken (see `gotchas.md`, the `{}`→nil 500). The engine's +conditional `:next` is both simpler and correct. + +**Dual-purpose (create *and* edit) = one config, one `:init-fn` that branches on a route id:** + +```clojure +(defn new-init-fn [request] + (if-let [id (->db-id (get-in request [:route-params :db/id]))] + {:init-data {:basic-details (… entity prefilled, :customize-accounts :customize) + :accounts {:invoice/expense-accounts (… existing rows)}}} ; edit + {:init-data {:basic-details {:invoice/date (coerce/to-date (time/now)) ; new + :customize-accounts :default}}})) +``` + +`create-wizard!` stores `:init-data` **as the per-step `:step-data` map directly**, so seeding +`{:basic-details … :accounts …}` opens both steps populated — the edit case repopulates the +grid without a separate hydrate. Two open routes (`new-wizard`, `edit-wizard`) both reduce to +`(partial wizard2/open-wizard config)`; the done-fn branches on `(:db/id all-data)` to return +the next-steps modal (create) vs the swapped table row (edit). + +**Async step fragments read the posted form, not multi-form-state.** The basic-details +fragments (account-prediction radio, due-date / scheduled-payment suggestions) and the +accounts totals all post the whole `#wizard-form`; in the engine that form carries the flat +`invoice/*` fields + the opaque `wizard-id`, so a fragment decodes what it needs straight from +`form-params` (and, for a cross-step value like the invoice total on the accounts step, reads +`ws/get-all` via the posted `wizard-id`). No `mm/wrap-decode-multi-form-state` stack survives. diff --git a/.claude/skills/ssr-form-migration/reference/gotchas.md b/.claude/skills/ssr-form-migration/reference/gotchas.md index 6312cd87..5f05a4ed 100644 --- a/.claude/skills/ssr-form-migration/reference/gotchas.md +++ b/.claude/skills/ssr-form-migration/reference/gotchas.md @@ -333,3 +333,35 @@ Rules of thumb: `clojure.edn/read-string {:readers clj-time.coerce/data-readers}` (see `multi_modal.clj`) — the cookie store has no such readers. (A durable/typed session backend would remove this constraint; until then, EDN-safe is the rule. See `form-vs-wizard.md` open question.) + +## A bare `[:map …]` query-schema 500s on empty query-params (the `{}`→nil trap) + +`auto-ap.ssr.utils/main-transformer` includes `parse-empty-as-nil`, whose **`:map` decoder +turns any map with no truthy values into `nil`** (`(if (seq (filter identity (vals m))) m nil)`). +So `(mc/coerce [:map [:k {:optional true} …]] {} main-transformer)` decodes `{}` → `nil`, +then validates `nil` against `[:map …]` → `:malli.core/invalid-type` → **500**. + +Ring's `wrap-params` sets `:query-params` to `{}` (not nil) for a request with no query +string. So **any handler wrapped with `wrap-schema-enforce :query-schema [:map …]` 500s on a +PUT/POST that carries no `?query`** — `(and query-schema query-params)` is truthy for `{}`, +so the coercion runs and blows up. This is exactly why the pre-migration New Invoice +basic-details "Save" was broken: its button `hx-put`s `/invoice/new/navigate` (no `?to`), and +`mm/next-handler`'s `[:to {:optional true} …]` query-schema 500d every time (the +`CustomNext`/308-to-submit logic never even ran). + +- A `[:maybe [:map …]]` query-schema survives (`nil` is valid) — that's why the *grid* + query-schema, hit by the same empty POST, doesn't throw. +- **The engine sidesteps this entirely**: `handle-step-submit` is a POST with **no** + query-schema, so empty query-params never reach a `[:map]` coercion. Migrating a wizard + off the `mm` navigate route *removes* the bug; you don't need to fix the old route. + +## Keep wizard dates as `#inst`, not clj-time, in step-data + +Reinforcing the EDN-safety rule above: a new+edit wizard that stores dates across a +non-terminal step (New Invoice: `basic-details` holds `:invoice/date` while you visit +`accounts`) must keep them **EDN-safe**. Decode them to `java.util.Date` (`coerce/to-date`) +before they land in step-data, and coerce back to clj-time only for display +(`coerce/from-date` → `atime/unparse-local`). A helper that maps over the date keys +(`->edn-safe-dates`) right after `mc/decode` is the clean seam — both the step `:decode` and +the edit `:init-fn` run the posted/persisted map through it. Datomic's upsert wants +`java.util.Date` anyway, so the done-fn needs no extra conversion. diff --git a/.claude/skills/ssr-form-migration/reference/scorecard.md b/.claude/skills/ssr-form-migration/reference/scorecard.md index ef4d6dc1..f1504f8e 100644 --- a/.claude/skills/ssr-form-migration/reference/scorecard.md +++ b/.claude/skills/ssr-form-migration/reference/scorecard.md @@ -220,3 +220,43 @@ Each migration appends one row (after-numbers), referencing the before in the di > invoice list in `:context`, which is fine at gate scale (1 invoice) but is the session-bloat > risk the reviewer named; a leaner context (ids + amounts, re-query in render) is the > follow-up if real payments carry many invoices. + +--- + +## Phase 8 — New / Edit Invoice (the conditional-`:next`, dual-purpose wizard) + +`auto-ap.ssr.invoice.new-invoice-wizard` — the hardest modal in the app: one wizard that +both **creates and edits** invoices, with a **conditional middle step** (basic-details → +*[accounts]* → next-steps, where accounts is skipped on the default-accounts path), Solr +typeaheads for client+vendor, an async account-prediction fragment, and live expense-account +totals. + +**Finding: the OLD basic-details "Save" was broken.** It `hx-put`s `/invoice/new/navigate`, +whose `[:to {:optional true} …]` query-schema 500s on empty query-params (the `{}`→nil +`main-transformer` quirk — see `gotchas.md`). Production uses the identical `wrap-params`, so +it was broken there too; the underlying create only worked when POSTed straight to +`new-invoice-submit`. So the Phase 8 gate (`e2e/invoice-new.spec.ts`) is an **acceptance** +gate, not a green→green characterization: red on the old code, green on the engine (whose +submit is a POST with no query-schema). The migration *fixes* a latent bug. The create +*semantics* (default → vendor default account, location-spread; customize → the posted grid; +edit → prefilled + updated row) were pinned via REPL before/around the migration. + +**Conditional `:next` is a one-liner** (`(if (= :customize …) :accounts :done)`) replacing the +`mm/CustomNext` protocol + the broken 308-to-submit. **Dual-purpose = one `:init-fn` branching +on a route `:db/id`**; `create-wizard!` seeds `:init-data` as per-step step-data so edit opens +both steps populated. See `form-vs-wizard.md`. + +**Coupling outcome (the review's lens).** The whole wizard collapses to *config + render + +fragments*: `defrecord` **4 → 0** (NewWizard2 / BasicDetailsStep / AccountsStep / NextSteps all +gone), `mm/` **0**, `fc/` cursor refs **0**, `step-params[…]` field names **0**. The broken +`new-wizard-navigate` route is **deleted** (3 wizard-nav routes → the engine's open + submit); +the genuine async helpers (account-prediction, due-date, scheduled-payment-date, +location-select, expense-account total/balance, add-row) remain but were **de-coupled from +multi-form-state** — each now reads the posted flat form (+ `ws/get-all` for the one cross-step +value). `next-steps` stops being a wizard step and becomes the done-fn's returned modal (Pay +now / Add another / Close), matching the Phase 7 pay-success shape. + +**Verification:** full e2e suite **61/61** (58 prior + 3 new: basic-details renders; +default-path create → next-steps; customize-path → accounts grid → create → next-steps); the +`maybe-spread-locations` unit test still 6/6; create semantics + edit prefill confirmed at the +REPL; dates ride as `#inst` so step-data is EDN-safe across the non-terminal step. diff --git a/e2e/invoice-new.spec.ts b/e2e/invoice-new.spec.ts new file mode 100644 index 00000000..f72c8095 --- /dev/null +++ b/e2e/invoice-new.spec.ts @@ -0,0 +1,101 @@ +import { test, expect } from '@playwright/test'; + +// Acceptance spec for the New Invoice wizard (basic-details -> [accounts] -> next-steps), +// the dual-purpose new+edit wizard with a CONDITIONAL middle step: basic-details creates +// straight away when customize-accounts = :default (the vendor's default expense account), +// and routes through the expense-accounts grid when :customize. +// +// NOTE: the pre-migration `mm` flow's basic-details "Save" was broken in this harness (and +// prod): the button PUT /invoice/new/navigate, whose `:to` query-schema 500s on empty +// query-params (the {}->nil main-transformer quirk). So this is an ACCEPTANCE gate -- red on +// the old code, green on the engine (whose submit is a POST with no query-schema). The seed +// exposes client TEST + vendor "Test Vendor" (default account "Test Account") via /test-info. +test.beforeEach(async ({ request }) => { await request.post('/test-reset'); }); + +async function seedIds(page: any): Promise<{ client: number; vendor: number }> { + const info = await (await page.request.get('/test-info')).json(); + return { client: info.clientIds.test, vendor: info.accounts.vendor }; +} + +// Open the wizard from the invoice list (so htmx/alpine are present -- opening the modal +// fragment directly would submit natively). +async function openNewWizard(page: any) { + await page.setExtraHTTPHeaders({ 'x-clients': '"mine"' }); + await page.goto('/invoice'); + await page.waitForSelector('#entity-table tbody tr'); + await page.locator('button:has-text("New invoice")').first().click(); + await page.waitForSelector('#wizard-form'); + await page.waitForTimeout(400); +} + +// The vendor field is a typeahead whose hidden input posts invoice/vendor; set it the way a +// dropdown pick would land (the value the form submits). +async function setVendor(page: any, vendorId: number) { + await page.evaluate((id: number) => { + const hidden = document.querySelector('input[name="invoice/vendor"]') as HTMLInputElement; + hidden.value = String(id); + hidden.dispatchEvent(new Event('change', { bubbles: true })); + }, vendorId); +} + +// The customize-accounts radio lives in an async fragment loaded on the "bryce" event (fired +// when the Alpine vendorId changes). Trigger that htmx load explicitly after setting vendor. +async function loadPrediction(page: any) { + await page.evaluate(() => { + const el = document.querySelector('#expense-account-prediction [hx-put], #expense-account-prediction[hx-put]'); + // @ts-ignore + if (el && window.htmx) window.htmx.trigger(el, 'bryce'); + }); + await page.waitForTimeout(600); +} + +const save = (page: any) => page.locator('#wizard-form button:has-text("Save")').first().click(); + +test.describe.configure({ mode: 'serial' }); + +test.describe('New Invoice wizard (acceptance)', () => { + test('basic-details renders the invoice fields', async ({ page }) => { + await openNewWizard(page); + const form = page.locator('#wizard-form'); + await expect(form).toContainText('New invoice'); + await expect(form).toContainText('Vendor'); + await expect(form).toContainText('Date'); + await expect(form).toContainText('Invoice Number'); + await expect(form).toContainText('Total'); + await expect(form.locator('input[name="invoice/total"]')).toBeVisible(); + }); + + test('default-accounts path creates the invoice and offers to pay it now', async ({ page }) => { + const { vendor } = await seedIds(page); + await openNewWizard(page); + await setVendor(page, vendor); + await page.locator('input[name="invoice/invoice-number"]').fill('NEW-1001'); + await page.locator('input[name="invoice/total"]').fill('212.44'); + await page.waitForTimeout(200); + await save(page); + await page.waitForTimeout(1200); + // the next-steps modal (done-fn output) -- no accounts step on the default path + await expect(page.locator('body')).toContainText('Would you like to pay this invoice now?'); + }); + + test('customize-accounts path routes through the expense-accounts grid then creates', async ({ page }) => { + const { vendor } = await seedIds(page); + await openNewWizard(page); + await setVendor(page, vendor); + await page.locator('input[name="invoice/invoice-number"]').fill('NEW-1002'); + await page.locator('input[name="invoice/total"]').fill('300.00'); + await loadPrediction(page); + // pick "Customize accounts" (the radio in the async fragment) + await page.locator('input[name="customize-accounts"][value="customize"]').first().check(); + await page.waitForTimeout(150); + await save(page); + await page.waitForTimeout(1000); + // the expense-accounts step: a grid prefilled with the vendor's default account + total + const form = page.locator('#wizard-form'); + await expect(form).toContainText('Invoice accounts'); + await expect(form).toContainText('INVOICE TOTAL'); + await save(page); // accounts -> done + await page.waitForTimeout(1200); + await expect(page.locator('body')).toContainText('Would you like to pay this invoice now?'); + }); +}); diff --git a/src/clj/auto_ap/ssr/invoice/new_invoice_wizard.clj b/src/clj/auto_ap/ssr/invoice/new_invoice_wizard.clj index d7ed7829..30b21248 100644 --- a/src/clj/auto_ap/ssr/invoice/new_invoice_wizard.clj +++ b/src/clj/auto_ap/ssr/invoice/new_invoice_wizard.clj @@ -1,39 +1,59 @@ (ns auto-ap.ssr.invoice.new-invoice-wizard + "New / Edit Invoice wizard, migrated onto the session-backed engine (wizard2). + + A dual-purpose wizard (create + edit) with a CONDITIONAL middle step: + basic-details --(customize-accounts = :default)--> :done (default vendor account) + \\-(customize-accounts = :customize)-> accounts --> :done + + Per-step data lives in the Ring session (wizard-state); the engine's get-all merges + them for the done-fn (`create-invoice!`). Only an opaque wizard-id + current-step ride + in the form -- no EDN snapshot. + + The pre-migration `mm` flow routed basic-details \"Save\" through a PUT /navigate whose + `:to` query-schema 500s on empty query-params (the {}->nil main-transformer quirk); the + engine's submit is a POST with no query-schema, so that latent bug is gone. + + Dates in step-data are kept as java.util.Date (#inst) -- EDN-safe for the cookie session + store, unlike clj-time DateTimes (which have no reader); they are coerced to clj-time only + for display." (:require - [auto-ap.datomic - :refer [audit-transact conn pull-attr]] + [auto-ap.datomic :refer [audit-transact conn pull-attr]] [auto-ap.datomic.accounts :as d-accounts] [auto-ap.datomic.invoices :as d-invoices] [auto-ap.graphql.utils :refer [assert-can-see-client assert-not-locked - exception->4xx]] + exception->4xx exception->notification]] [auto-ap.logging :as alog] [auto-ap.routes.invoice :as route] - [auto-ap.routes.utils - :refer [wrap-client-redirect-unauthenticated]] + [auto-ap.routes.utils :refer [wrap-client-redirect-unauthenticated]] [auto-ap.solr :as solr] [auto-ap.ssr-routes :as ssr-routes] - [auto-ap.ssr.common-handlers :refer [add-new-entity-handler]] [auto-ap.ssr.components :as com] - [auto-ap.ssr.components.multi-modal :as mm] - [auto-ap.ssr.form-cursor :as fc] + [auto-ap.ssr.components.wizard-state :as ws] + [auto-ap.ssr.components.wizard2 :as wizard2] [auto-ap.ssr.hx :as hx] [auto-ap.ssr.invoice.common :refer [default-read]] - [auto-ap.ssr.nested-form-params :refer [wrap-nested-form-params]] + [auto-ap.ssr.nested-form-params :as nfp] [auto-ap.ssr.svg :as svg] [auto-ap.ssr.utils - :refer [->db-id apply-middleware-to-all-handlers check-allowance - check-location-belongs clj-date-schema entity-id - form-validation-error html-response money strip + :refer [->db-id apply-middleware-to-all-handlers assert-schema check-allowance + check-location-belongs clj-date-schema entity-id form-validation-error + html-response main-transformer modal-response money path->name2 strip wrap-schema-enforce]] [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] [hiccup.util :as hu] [iol-ion.query :refer [dollars=]] [malli.core :as mc] - [malli.util :as mut])) + [malli.util :as mut] + [slingshot.slingshot :refer [try+]])) + +;; --------------------------------------------------------------------------- +;; Domain helpers (unchanged from the pre-migration wizard). +;; --------------------------------------------------------------------------- (defn get-vendor [vendor-id] (dc/pull @@ -49,7 +69,7 @@ vendor-id)) (defn check-vendor-default-account [vendor-id] - (some? (:vendor/default-account (get-vendor vendor-id)))) + (some? (:vendor/default-account (get-vendor vendor-id)))) (def new-form-schema [:map @@ -84,13 +104,6 @@ (check-location-belongs (:invoice-expense-account/location iea) (:invoice-expense-account/account iea)))]]]]]) -(defn wrap-schema [s] - [:and s - [:fn (fn [{:keys [:db/id :invoice/invoice-number :invoice/vendor :invoice/client] :as z}] - (if id - true - (and invoice-number vendor client)))]]) - (defn clientize-vendor [{:vendor/keys [terms-overrides automatically-paid-when-due default-account account-overrides] :as vendor} client-id] (if (nil? vendor) nil @@ -122,209 +135,6 @@ true (dissoc :vendor/account-overrides :vendor/terms-overrides))] vendor))) -(defn account-prediction* [{:keys [multi-form-state]}] - (let [vendor (clientize-vendor (get-vendor (:invoice/vendor (:step-params multi-form-state))) - (->db-id (:invoice/client (:step-params multi-form-state)))) - account-name (:account/name (:vendor/default-account vendor)) - value (mm/get-mfs-field multi-form-state :customize-accounts)] - (when vendor - (com/radio-list {:name "step-params[customize-accounts]" - - :value (name value) - :options (filter identity - [(when account-name {:value (name :default) - :content (com/pill {:color :primary} account-name)}) - {:value (name :customize) - :content [:div "Customize accounts"]}])})))) - -(defrecord BasicDetailsStep [linear-wizard] - mm/ModalWizardStep - (step-name [_] - "Basic Details") - (step-key [_] - :basic-details) - - (edit-path [_ _] - []) - - (step-schema [_] - (wrap-schema (mut/select-keys (mm/form-schema linear-wizard) #{:invoice/client :invoice/vendor :invoice/date :invoice/due :invoice/scheduled-payment :invoice/total :invoice/invoice-number :db/id :customize-due-and-scheduled? :customize-accounts}))) - - (render-step - [this {:keys [multi-form-state] :as request}] - (let [extant? (mm/get-mfs-field multi-form-state :db/id)] - (mm/default-render-step - linear-wizard this - :head [:div.p-2 (if extant? - "Edit invoice" - "New invoice")] - :body (mm/default-step-body - {} - [:div {:x-data (hx/json {:clientId (or (fc/field-value (:invoice/client fc/*current*)) - (:db/id (:client request))) - :vendorId (fc/field-value (:invoice/vendor fc/*current*)) - :date (-> (fc/field-value (:invoice/date fc/*current*)) - (atime/unparse-local atime/normal-date)) - :due (some-> (fc/field-value (:invoice/due fc/*current*)) - (atime/unparse-local atime/normal-date)) - :scheduledPayment (some-> (fc/field-value (:invoice/scheduled-payment fc/*current*)) - (atime/unparse-local atime/normal-date)) - :customizeDueAndScheduled (fc/field-value (:customize-due-and-scheduled? fc/*current*))})} - (fc/with-field :db/id - (when extant? - (com/hidden {:name (fc/field-name) - :value (fc/field-value)}))) - - (fc/with-field :customize-due-and-scheduled? - (com/hidden {:name (fc/field-name) - :value (fc/field-value) - :x-model "customizeDueAndScheduled"})) - (fc/with-field :invoice/client - (if (or (:client request) extant?) - (com/hidden {:name (fc/field-name) - :value (or (mm/get-mfs-field multi-form-state :invoice/client) - (:db/id (:client request)))}) - (com/validated-field - {:label "Client" - :errors (fc/field-errors)} - [:div.w-96 - (com/typeahead {:name (fc/field-name) - :error? (fc/error?) - :class "w-96" - :placeholder "Search..." - :url (bidi/path-for ssr-routes/only-routes :company-search) - :value (fc/field-value) - :content-fn (fn [c] (pull-attr (dc/db conn) :client/name c)) - :x-model "clientId"})]))) - (fc/with-field :invoice/vendor - (com/validated-field - {:label "Vendor" - :errors (fc/field-errors)} - [:div.w-96 - (com/typeahead {:name (fc/field-name) - :error? (fc/error?) - :disabled (boolean (-> request :multi-form-state :snapshot :db/id)) - :class "w-96" - :placeholder "Search..." - :url (bidi/path-for ssr-routes/only-routes :vendor-search) - :value (fc/field-value) - :content-fn (fn [c] (pull-attr (dc/db conn) :vendor/name c)) - :x-model "vendorId"})])) - [:div.mb-4 - ;; TODO DO NOT MERGE UNTIL THIS IS FIXED - #_[:span.text-sm.text-gray-500 "Can't find the vendor? " - (com/link {:href ... - :target "new"} - "Add new vendor") - " in a new window, then return here."]] - - [:div.flex.items-center.gap-2 - (fc/with-field :invoice/date - (com/validated-field - {:label "Date" - :errors (fc/field-errors)} - [:div {:class "w-24"} - (com/date-input {:value (some-> (fc/field-value) - (atime/unparse-local atime/normal-date)) - :name (fc/field-name) - :error? (fc/field-errors) - :x-model "date" - :placeholder "1/1/2024"})])) - [:div {:x-show "!customizeDueAndScheduled"} - (com/link {"@click" "customizeDueAndScheduled=true" - :x-show "!due && !scheduledPayment"} - "Add due / scheduled payment date") - (com/link {"@click" "customizeDueAndScheduled=true" - :x-show "due || scheduledPayment"} - "Change due / scheduled payment date")]] - (fc/with-field :invoice/due - (com/validated-field - (hx/alpine-appear {:label "Due (optional)" - :errors (fc/field-errors) - :x-show "customizeDueAndScheduled"}) - [:div {:class "w-24" - :hx-put (bidi.bidi/path-for ssr-routes/only-routes ::route/due-date) - :x-dispatch:changed "[clientId, vendorId, date]" - :hx-trigger "changed" - :hx-target "this" - :hx-swap "innerHTML"} - (com/date-input {:value (some-> (fc/field-value) - (atime/unparse-local atime/normal-date)) - :name (fc/field-name) - :x-model "due" - - :error? (fc/field-errors) - :placeholder "1/1/2024"})])) - (fc/with-field :invoice/scheduled-payment - (com/validated-field - (hx/alpine-appear {:label "Scheduled payment (optional)" - :errors (fc/field-errors) - :x-show "customizeDueAndScheduled"}) - [:div {:class "w-24"} - [:div {:class "w-24" - - :hx-put (bidi.bidi/path-for ssr-routes/only-routes ::route/scheduled-payment-date) - :x-dispatch:changed "[clientId, vendorId, due]" - :hx-trigger "changed" - :hx-target "this" - :hx-swap "innerHTML"} - (com/date-input {:value (some-> (fc/field-value) - (atime/unparse-local atime/normal-date)) - :name (fc/field-name) - :error? (fc/field-errors) - :placeholder "1/1/2024"})]])) - - (fc/with-field :invoice/invoice-number - (com/validated-field - {:label "Invoice Number" - :errors (fc/field-errors)} - [:div {:class "w-24"} - (com/text-input {:value (-> (fc/field-value)) - #_#_:disabled (boolean (-> request :multi-form-state :snapshot :db/id)) - :name (fc/field-name) - :error? (fc/field-errors) - :placeholder "HA-123"})])) - (fc/with-field :invoice/total - (com/validated-field - {:label "Total" - :errors (fc/field-errors)} - [:div {:class "w-16"} - (com/money-input {:value (-> (fc/field-value)) - :name (fc/field-name) - :class "w-24" - :error? (fc/field-errors) - :placeholder "212.44"})])) - - [:div#expense-account-prediction - (hx/alpine-appear - {:x-dispatch:bryce "[vendorId]" - :hx-trigger "bryce" - :hx-put (bidi.bidi/path-for ssr-routes/only-routes ::route/account-prediction) - :hx-target "this" - :hx-swap "innerHTML"}) - (account-prediction* request)]]) - - :footer - (mm/default-step-footer linear-wizard this :validation-route ::route/new-wizard-navigate - :next-button (com/button {:color :primary :x-ref "next" :class "w-32" - :hx-put (bidi.bidi/path-for ssr-routes/only-routes - ::route/new-wizard-navigate)} "Save")) - :validation-route ::route/new-wizard-navigate))) - - mm/CustomNext - (custom-next-handler - [_ request] - (if (= (get-in request [:multi-form-state :step-params :customize-accounts]) - :customize) - (mm/navigate-handler {:request request - :to-step :accounts}) - - (html-response [:div] - :headers {"location" (bidi.bidi/path-for ssr-routes/only-routes ::route/new-invoice-submit)} - :status 308) - #_(mm/navigate-handler {:request request - :to-step :next-steps})))) - (defn location-select* [{:keys [name account-location client-locations value]}] (let [options (into (cond account-location @@ -338,7 +148,7 @@ [["Shared" "Shared"]]))] (com/select {:options options :name name - :value (ffirst options) + :value (or value (ffirst options)) :class "w-full"}))) (defn account-typeahead* @@ -356,210 +166,6 @@ (:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read value) client-id)))})]) -(defn- invoice-expense-account-row* [{:keys [value client-id]}] - (com/data-grid-row - (-> {:x-data (hx/json {:show (boolean (not (fc/field-value (:new? value)))) - :accountId (fc/field-value (:invoice-expense-account/account value))}) - :data-key "show" - :x-ref "p"} - hx/alpine-mount-then-appear) - (fc/with-field :db/id - (com/hidden {:name (fc/field-name) - :value (fc/field-value)})) - (fc/with-field :invoice-expense-account/account - (com/data-grid-cell - {} - (com/validated-field - {:errors (fc/field-errors)} - (account-typeahead* {:value (fc/field-value) - :client-id client-id - :name (fc/field-name) - :x-model "accountId"})))) - (fc/with-field :invoice-expense-account/location - (com/data-grid-cell - {} - (com/validated-field - {:errors (fc/field-errors) - :x-hx-val:account-id "accountId" - :hx-vals (hx/json {:name (fc/field-name) - :client-id client-id}) - :x-dispatch:changed "accountId" - :hx-trigger "changed" - :hx-get (bidi/path-for ssr-routes/only-routes ::route/location-select) - :hx-target "find *" - :hx-swap "outerHTML"} - (location-select* {:name (fc/field-name) - :account-location (:account/location (cond->> (:invoice-expense-account/account @value) - (nat-int? (:invoice-expense-account/account @value)) (dc/pull (dc/db conn) - '[:account/location]))) - :client-locations (pull-attr (dc/db conn) :client/locations client-id) - :value (fc/field-value)})))) - (fc/with-field :invoice-expense-account/amount - (com/data-grid-cell - {} - (com/validated-field - {:errors (fc/field-errors)} - (com/money-input {:name (fc/field-name) - :class "w-16 amount-field" - :value (fc/field-value)})))) - (com/data-grid-cell {:class "align-top"} - (com/a-icon-button {"@click.prevent.stop" "show=false; setTimeout(() => $refs.p.remove(), 500)"} svg/x)))) - -(defn invoice-expense-account-total* [request] - (let [total (->> (-> request - :multi-form-state - :step-params - :invoice/expense-accounts) - (map (fnil :invoice-expense-account/amount 0.0)) - (filter number?) - (reduce + 0.0))] - (format "$%,.2f" total))) - -(defn invoice-expense-account-balance* [request] - (let [total (->> (-> request - :multi-form-state - :step-params - :invoice/expense-accounts) - (map (fnil :invoice-expense-account/amount 0.0)) - (filter number?) - (reduce + 0.0)) - balance (- - (-> request :multi-form-state :snapshot :invoice/total) - total)] - [:span {:class (when-not (dollars= 0.0 balance) - "text-red-300")} - (format "$%,.2f" balance)])) - -(defn invoice-expense-account-total [request] - (html-response (invoice-expense-account-total* request))) - -(defn invoice-expense-account-balance [request] - (html-response (invoice-expense-account-balance* request))) - -(defrecord AccountsStep [linear-wizard] - mm/ModalWizardStep - (step-name [_] - "Expense Accounts") - (step-key [_] - :accounts) - - (edit-path [_ _] - []) - - (step-schema [_] - (mut/select-keys (mm/form-schema linear-wizard) #{:invoice/expense-accounts})) - - (render-step [this {{:keys [snapshot] :as multi-form-state} :multi-form-state :as request}] - (mm/default-render-step - linear-wizard this - :head [:div.p-2 "Invoice accounts "] - :body (mm/default-step-body - {} - [:div {} - (pull-attr (dc/db conn) :client/name (:invoice/client snapshot)) - (fc/with-field :invoice/expense-accounts - (com/validated-field - {:errors (fc/field-errors)} - (com/data-grid {:headers [(com/data-grid-header {} "Account") - (com/data-grid-header {:class "w-32"} "Location") - (com/data-grid-header {:class "w-16"} "$") - (com/data-grid-header {:class "w-16"})]} - (fc/cursor-map #(invoice-expense-account-row* {:value % - :client-id (:invoice/client snapshot)})) - - (com/data-grid-new-row {:colspan 4 - :hx-get (bidi/path-for ssr-routes/only-routes - ::route/new-wizard-new-account) - :row-offset 0 - :index (count (fc/field-value)) - :tr-params {:hx-vals (hx/json {:client-id (:invoice/client snapshot)})}} - "New account") - (com/data-grid-row {} - (com/data-grid-cell {}) - (com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "TOTAL"]) - (com/data-grid-cell {:id "total" - :class "text-right" - :hx-trigger "change from:closest form target:.amount-field" - :hx-put (bidi.bidi/path-for ssr-routes/only-routes ::route/expense-account-total) - :hx-target "this" - :hx-swap "innerHTML"} - (invoice-expense-account-total* request)) - (com/data-grid-cell {})) - - (com/data-grid-row {} - (com/data-grid-cell {}) - (com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "BALANCE"]) - (com/data-grid-cell {:id "total" - :class "text-right" - :hx-trigger "change from:closest form target:.amount-field" - :hx-put (bidi.bidi/path-for ssr-routes/only-routes ::route/expense-account-balance) - :hx-target "this" - :hx-swap "innerHTML"} - (invoice-expense-account-balance* request)) - (com/data-grid-cell {})) - - (com/data-grid-row {} - - (com/data-grid-cell {}) - (com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "INVOICE TOTAL"]) - (com/data-grid-cell {:class "text-right"} - (format "$%,.2f" (:invoice/total snapshot))) - (com/data-grid-cell {})))))]) - :footer - (mm/default-step-footer linear-wizard this :validation-route ::route/new-wizard-navigate - :next-button (com/button {:color :primary :x-ref "next" :class "w-32"} "Save")) - :validation-route ::route/new-wizard-navigate)) - mm/Initializable - (init-step-params - [_ current request] - (if (not (seq (:invoice/expense-accounts (:step-params current)))) - (assoc (:step-params current) :invoice/expense-accounts [{:db/id "123" - :invoice-expense-account/location "Shared" - :invoice-expense-account/account (:db/id (:vendor/default-account (clientize-vendor (get-vendor (->db-id (:invoice/vendor (:snapshot current)))) - (->db-id (:invoice/client (:snapshot current)))))) - :invoice-expense-account/amount (:invoice/total (:step-params current))}]) - (:step-params current)))) - -(defrecord NextSteps [linear-wizard] - mm/ModalWizardStep - (step-name [_] - "Next Steps") - (step-key [_] - :next-steps) - - (edit-path [_ _] - []) - - (step-schema [_] - (mut/select-keys (mm/form-schema linear-wizard) #{})) - - (render-step [this {{:keys [snapshot] :as multi-form-state} :multi-form-state :as request}] - - (mm/default-render-step - linear-wizard this - :head [:div.p-2 "Invoice accounts "] - :body (mm/default-step-body - {} - [:p.text-lg "Would you like to pay this invoice now?"] - - (com/navigation-button-list {} - (com/navigation-button (-> {:class "w-48" - :hx-get (hu/url (bidi.bidi/path-for ssr-routes/only-routes ::route/pay-wizard) - {:selected (:db/id snapshot) - :replace-modal true})} - hx/trigger-click-or-enter) "Pay now") - (com/navigation-button (-> {:class "w-48" - :hx-get (hu/url (bidi.bidi/path-for ssr-routes/only-routes ::route/new-wizard) - {:replace-modal true})} - hx/trigger-click-or-enter) "Add another") - (com/navigation-button {:class "w-48" :next-arrow? false - "@click" "$dispatch('modalclose') " - "@keyup.enter.stop" "$dispatch('modalclose')"} - "Close"))) - :footer - nil - :validation-route ::route/new-wizard-navigate))) - (defn assert-no-conflicting [{:invoice/keys [invoice-number client vendor] :db/keys [id]}] (when (seq (d-invoices/find-conflicting {:invoice/invoice-number invoice-number :invoice/vendor (->db-id vendor) @@ -629,228 +235,605 @@ (apply-total-delta-to-account ($->cents (:invoice/total invoice))) (map (fn [ea] (update ea :invoice-expense-account/amount cents->$)))))))) -(defrecord NewWizard2 [_ current-step] - mm/LinearModalWizard - (hydrate-from-request - [this request] - this) - (navigate [this step-key] - (assoc this :current-step step-key)) - (get-current-step [this] - (if current-step - (mm/get-step this current-step) - (mm/get-step this :basic-details))) - (render-wizard [this {:keys [multi-form-state] :as request}] - (mm/default-render-wizard - this request - :form-params - (-> mm/default-form-props - (assoc :hx-post - (str (bidi/path-for ssr-routes/only-routes ::route/new-invoice-submit)))) - :render-timeline? false)) - (steps [_] - [:basic-details - :accounts - :next-steps]) - (get-step [this step-key] - (let [step-key-result (mc/parse mm/step-key-schema step-key) - [step-key-type step-key] step-key-result] - (get {:basic-details (->BasicDetailsStep this) - :accounts (->AccountsStep this) - :next-steps (->NextSteps this)} - step-key))) - (form-schema [_] - new-form-schema) - (submit [this {:keys [multi-form-state request-method identity] :as request}] - (let [invoice (:snapshot multi-form-state) +;; --------------------------------------------------------------------------- +;; De-cursored field names + per-render errors. +;; --------------------------------------------------------------------------- - _ (alog/peek invoice) - extant? (:db/id invoice) - client-id (->db-id (:invoice/client invoice)) - vendor-id (->db-id (:invoice/vendor invoice)) - paid-amount (if-let [outstanding-balance - (and extant? - (- - (pull-attr (dc/db conn) - :invoice/total - (:db/id invoice)) - (pull-attr (dc/db conn) - :invoice/outstanding-balance - (:db/id invoice))))] - outstanding-balance - 0.0) - outstanding-balance (- (or - (:invoice/total (:step-params multi-form-state)) - (:invoice/total (:snapshot multi-form-state))) - paid-amount) +(def ^:dynamic *errors* + "Humanized form errors for the current step render, keyed by schema paths. + Bound by each render fn from the engine ctx :errors." + {}) - transaction [:upsert-invoice (-> multi-form-state - :snapshot - (assoc :db/id (or (:db/id invoice) "invoice")) - (dissoc :customize-due-and-scheduled? :invoice/journal-entry :invoice/payments :customize-accounts) - (assoc :invoice/expense-accounts (if (= :customize (:customize-accounts invoice)) - (-> multi-form-state :step-params :invoice/expense-accounts) - [{:db/id "123" - :invoice-expense-account/location "Shared" - :invoice-expense-account/account (:db/id (:vendor/default-account (clientize-vendor (get-vendor vendor-id) - client-id))) - :invoice-expense-account/amount (or (:invoice/total (:step-params multi-form-state)) - (:invoice/total (:snapshot multi-form-state)))}])) - (assoc - :invoice/outstanding-balance outstanding-balance - :invoice/import-status :import-status/imported - :invoice/status (if (dollars= 0.0 outstanding-balance) - :invoice-status/paid - :invoice-status/unpaid)) - (maybe-spread-locations) - (update :invoice/date coerce/to-date) - (update :invoice/due coerce/to-date) - (update :invoice/scheduled-payment coerce/to-date))]] - (assert-invoice-amounts-add-up (second transaction)) - (assert-no-conflicting invoice) - (exception->4xx #(assert-can-see-client (:identity request) client-id)) +(defn- ferr [& path] (get-in *errors* (vec path))) +(defn- err? [& path] (boolean (seq (apply ferr path)))) +(defn- ea-name [index field] (path->name2 :invoice/expense-accounts index field)) +(defn- ea-errors [index field] (ferr :invoice/expense-accounts index field)) - (exception->4xx #(assert-not-locked client-id (:invoice/date invoice))) - (let [transaction-result (audit-transact [transaction] (:identity request))] - (try - (solr/touch-with-ledger (get-in transaction-result [:tempids "invoice"])) - (catch Exception e - (alog/error ::cant-save-solr - :error e))) +(defn- fmt-date + "java.util.Date (#inst, EDN-safe) -> the MM/DD/YYYY display string." + [d] + (some-> d coerce/from-date (atime/unparse-local atime/normal-date))) - (if extant? +;; --------------------------------------------------------------------------- +;; Schemas + decode (per step). +;; --------------------------------------------------------------------------- - (html-response - (@(resolve 'auto-ap.ssr.invoices/row*) identity (dc/pull (dc/db conn) default-read (:db/id invoice)) {:flash? true - :request request}) - :headers (cond-> {"hx-trigger" "modalclose" - "hx-retarget" (format "#entity-table tr[data-id=\"%d\"]" (:db/id invoice)) - "hx-reswap" "outerHTML"})) +(def ^:private basic-details-schema + (mc/schema + (mut/select-keys new-form-schema + #{:invoice/client :invoice/vendor :invoice/date :invoice/due + :invoice/scheduled-payment :invoice/total :invoice/invoice-number + :db/id :customize-due-and-scheduled? :customize-accounts}))) - (assoc-in (mm/navigate-handler {:request (assoc-in request [:multi-form-state :snapshot :db/id] (get-in transaction-result [:tempids "invoice"])) - :to-step :next-steps}) - [:headers "hx-trigger"] "invalidated")))))) +(def ^:private accounts-schema + (mc/schema (mut/select-keys new-form-schema #{:invoice/expense-accounts}))) -(def new-wizard (->NewWizard2 nil nil)) +(defn- ->edn-safe-dates + "clj-time DateTimes -> java.util.Date so step-data round-trips through the cookie store." + [m] + (reduce (fn [acc k] (cond-> acc (get acc k) (update k coerce/to-date))) + m + [:invoice/date :invoice/due :invoice/scheduled-payment])) -(defn initial-new-wizard-state [request] - (mm/->MultiStepFormState {:invoice/date (time/now) - :customize-accounts :default} - [] - {:invoice/date (time/now) - :customize-accounts :default})) +(defn- decode-basic-details + "Step 1 posts flat invoice/* fields (the engine already stripped its nav fields)." + [request] + (let [nested (:form-params (nfp/nested-params-request request {}))] + (-> (mc/decode basic-details-schema nested main-transformer) + ->edn-safe-dates))) -(defn initial-edit-wizard-state [request] - (let [entity (dc/pull (dc/db conn) default-read (:db/id (:route-params request))) - entity (select-keys entity (mut/keys new-form-schema))] +(defn- decode-accounts + "Step 2: nested invoice/expense-accounts[i][...] rows." + [request] + (let [nested (:form-params (nfp/nested-params-request request {}))] + (mc/decode accounts-schema nested main-transformer))) - (mm/->MultiStepFormState (assoc entity - :customize-accounts :customize) - [] - (assoc entity - :customize-accounts :customize)))) +(defn- validate-basic-details + "Mirror the old wrap-schema :fn: a new invoice needs client + vendor + invoice-number; + date + total are always required; a chosen vendor must have a default expense account." + [data _request] + (let [new? (not (:db/id data)) + errs (cond-> {} + (nil? (:invoice/date data)) (assoc :invoice/date ["required"]) + (nil? (:invoice/total data)) (assoc :invoice/total ["required"]) + (and new? (nil? (:invoice/client data))) (assoc :invoice/client ["required"]) + (and new? (nil? (:invoice/vendor data))) (assoc :invoice/vendor ["required"]) + (and new? (str/blank? (:invoice/invoice-number data))) (assoc :invoice/invoice-number ["required"]) + (and (:invoice/vendor data) + (not (check-vendor-default-account (->db-id (:invoice/vendor data))))) + (assoc :invoice/vendor ["Vendor is missing default expense account"]))] + (when (seq errs) errs))) -(defn location-select [{{:keys [name account-id client-id value] :as qp} :query-params}] - (html-response (location-select* {:name name - :value value +;; --------------------------------------------------------------------------- +;; Renders (de-cursored: explicit data + path->name2 + *errors*). +;; --------------------------------------------------------------------------- + +(defn account-prediction-radio + "The customize-accounts radio (default vendor account vs customize), computed from the + posted/known vendor + client. Lives in an async fragment so it appears once a vendor is + chosen." + [{:keys [vendor-id client-id value]}] + (let [vendor (clientize-vendor (get-vendor vendor-id) (->db-id client-id)) + account-name (:account/name (:vendor/default-account vendor))] + (when vendor + (com/radio-list {:name "customize-accounts" + :value (name (or value :default)) + :options (filter identity + [(when account-name {:value (name :default) + :content (com/pill {:color :primary} account-name)}) + {:value (name :customize) + :content [:div "Customize accounts"]}])})))) + +(defn render-basic-details + [{:keys [step-data request errors]}] + (binding [*errors* (or errors {})] + (let [data (or step-data {}) + extant? (:db/id data) + client-from-req (:db/id (:client request)) + client-val (or (:invoice/client data) client-from-req)] + (com/modal-card-advanced + {"@keydown.enter.prevent.stop" "if ($refs.next) {$refs.next.click()}" + :class " md:w-[750px] md:h-[600px] w-full h-full" + "x-data" ""} + (com/modal-header {} [:div.p-2 (if extant? "Edit invoice" "New invoice")]) + (com/modal-body + {} + [:div {:x-data (hx/json {:clientId client-val + :vendorId (:invoice/vendor data) + :date (fmt-date (:invoice/date data)) + :due (fmt-date (:invoice/due data)) + :scheduledPayment (fmt-date (:invoice/scheduled-payment data)) + :customizeDueAndScheduled (boolean (:customize-due-and-scheduled? data))})} + (when extant? + (com/hidden {:name "db/id" :value extant?})) + (com/hidden {:name "customize-due-and-scheduled?" + :value (boolean (:customize-due-and-scheduled? data)) + :x-model "customizeDueAndScheduled"}) + (if (or (:client request) extant?) + (com/hidden {:name "invoice/client" :value client-val}) + (com/validated-field + {:label "Client" :errors (ferr :invoice/client)} + [:div.w-96 + (com/typeahead {:name "invoice/client" + :error? (err? :invoice/client) + :class "w-96" + :placeholder "Search..." + :url (bidi/path-for ssr-routes/only-routes :company-search) + :value (:invoice/client data) + :content-fn (fn [c] (pull-attr (dc/db conn) :client/name c)) + :x-model "clientId"})])) + (com/validated-field + {:label "Vendor" :errors (ferr :invoice/vendor)} + [:div.w-96 + (com/typeahead {:name "invoice/vendor" + :error? (err? :invoice/vendor) + :disabled (boolean extant?) + :class "w-96" + :placeholder "Search..." + :url (bidi/path-for ssr-routes/only-routes :vendor-search) + :value (:invoice/vendor data) + :content-fn (fn [c] (pull-attr (dc/db conn) :vendor/name c)) + :x-model "vendorId"})]) + [:div.flex.items-center.gap-2 + (com/validated-field + {:label "Date" :errors (ferr :invoice/date)} + [:div {:class "w-24"} + (com/date-input {:value (fmt-date (:invoice/date data)) + :name "invoice/date" + :error? (err? :invoice/date) + :x-model "date" + :placeholder "1/1/2024"})]) + [:div {:x-show "!customizeDueAndScheduled"} + (com/link {"@click" "customizeDueAndScheduled=true" + :x-show "!due && !scheduledPayment"} + "Add due / scheduled payment date") + (com/link {"@click" "customizeDueAndScheduled=true" + :x-show "due || scheduledPayment"} + "Change due / scheduled payment date")]] + (com/validated-field + (hx/alpine-appear {:label "Due (optional)" + :errors (ferr :invoice/due) + :x-show "customizeDueAndScheduled"}) + [:div {:class "w-24" + :hx-put (bidi/path-for ssr-routes/only-routes ::route/due-date) + :x-dispatch:changed "[clientId, vendorId, date]" + :hx-trigger "changed" + :hx-target "this" + :hx-swap "innerHTML"} + (com/date-input {:value (fmt-date (:invoice/due data)) + :name "invoice/due" + :x-model "due" + :error? (err? :invoice/due) + :placeholder "1/1/2024"})]) + (com/validated-field + (hx/alpine-appear {:label "Scheduled payment (optional)" + :errors (ferr :invoice/scheduled-payment) + :x-show "customizeDueAndScheduled"}) + [:div {:class "w-24" + :hx-put (bidi/path-for ssr-routes/only-routes ::route/scheduled-payment-date) + :x-dispatch:changed "[clientId, vendorId, due]" + :hx-trigger "changed" + :hx-target "this" + :hx-swap "innerHTML"} + (com/date-input {:value (fmt-date (:invoice/scheduled-payment data)) + :name "invoice/scheduled-payment" + :error? (err? :invoice/scheduled-payment) + :placeholder "1/1/2024"})]) + (com/validated-field + {:label "Invoice Number" :errors (ferr :invoice/invoice-number)} + [:div {:class "w-24"} + (com/text-input {:value (:invoice/invoice-number data) + :name "invoice/invoice-number" + :error? (err? :invoice/invoice-number) + :placeholder "HA-123"})]) + (com/validated-field + {:label "Total" :errors (ferr :invoice/total)} + [:div {:class "w-16"} + (com/money-input {:value (:invoice/total data) + :name "invoice/total" + :class "w-24" + :error? (err? :invoice/total) + :placeholder "212.44"})]) + [:div#expense-account-prediction + (hx/alpine-appear + {:x-dispatch:bryce "[vendorId]" + :hx-trigger "bryce" + :hx-put (bidi/path-for ssr-routes/only-routes ::route/account-prediction) + :hx-target "this" + :hx-swap "innerHTML"}) + (account-prediction-radio {:vendor-id (->db-id (:invoice/vendor data)) + :client-id client-val + :value (:customize-accounts data)})]]) + (com/modal-footer {} (wizard2/nav-footer {:save? true :save-label "Save"})))))) + +(defn- invoice-expense-account-row* + "One expense-account row, de-cursored: a plain account map + its index." + [{:keys [account index client-id client-locations]}] + (let [acct (:invoice-expense-account/account account) + acct-id (if (map? acct) (:db/id acct) acct) + aname (ea-name index :invoice-expense-account/account) + lname (ea-name index :invoice-expense-account/location)] + (com/data-grid-row + (-> {:x-data (hx/json {:show (boolean (not (:new? account))) + :accountId acct-id}) + :data-key "show" + :x-ref "p"} + hx/alpine-mount-then-appear) + (com/hidden {:name (ea-name index :db/id) + :value (:db/id account)}) + (com/data-grid-cell + {} + (com/validated-field + {:errors (ea-errors index :invoice-expense-account/account)} + (account-typeahead* {:value acct-id + :client-id client-id + :name aname + :x-model "accountId"}))) + (com/data-grid-cell + {} + (com/validated-field + {:errors (ea-errors index :invoice-expense-account/location) + :x-hx-val:account-id "accountId" + :hx-vals (hx/json {:name lname :client-id client-id}) + :x-dispatch:changed "accountId" + :hx-trigger "changed" + :hx-get (bidi/path-for ssr-routes/only-routes ::route/location-select) + :hx-target "find *" + :hx-swap "outerHTML"} + (location-select* {:name lname + :account-location (:account/location (when (nat-int? acct-id) + (dc/pull (dc/db conn) '[:account/location] acct-id))) + :client-locations client-locations + :value (:invoice-expense-account/location account)}))) + (com/data-grid-cell + {} + (com/validated-field + {:errors (ea-errors index :invoice-expense-account/amount)} + (com/money-input {:name (ea-name index :invoice-expense-account/amount) + :class "w-16 amount-field" + :value (:invoice-expense-account/amount account)}))) + (com/data-grid-cell {:class "align-top"} + (com/a-icon-button {"@click.prevent.stop" "show=false; setTimeout(() => $refs.p.remove(), 500)"} svg/x))))) + +(defn- expense-accounts-or-default + "The accounts grid's rows: the posted rows, or -- on first landing -- a single row + prefilled with the vendor's default account + the invoice total (the old Initializable)." + [{:keys [step-data all-data]}] + (let [rows (:invoice/expense-accounts step-data)] + (if (seq rows) + (vec rows) + [{:db/id "123" + :invoice-expense-account/location "Shared" + :invoice-expense-account/account + (:db/id (:vendor/default-account + (clientize-vendor (get-vendor (->db-id (:invoice/vendor all-data))) + (->db-id (:invoice/client all-data))))) + :invoice-expense-account/amount (:invoice/total all-data)}]))) + +(defn- expense-accounts-total* [rows] + (->> rows + (map (fnil :invoice-expense-account/amount 0.0)) + (filter number?) + (reduce + 0.0))) + +(defn render-accounts + [{:keys [all-data errors] :as ctx}] + (binding [*errors* (or errors {})] + (let [client-id (->db-id (:invoice/client all-data)) + client-locations (pull-attr (dc/db conn) :client/locations client-id) + rows (expense-accounts-or-default ctx) + invoice-total (:invoice/total all-data) + total (expense-accounts-total* rows)] + (com/modal-card-advanced + {"@keydown.enter.prevent.stop" "if ($refs.next) {$refs.next.click()}" + :class " md:w-[750px] md:h-[600px] w-full h-full" + "x-data" ""} + (com/modal-header {} [:div.p-2 "Invoice accounts "]) + (com/modal-body + {} + [:div {} + (pull-attr (dc/db conn) :client/name client-id) + (com/validated-field + {:errors (ferr :invoice/expense-accounts)} + (com/data-grid {:headers [(com/data-grid-header {} "Account") + (com/data-grid-header {:class "w-32"} "Location") + (com/data-grid-header {:class "w-16"} "$") + (com/data-grid-header {:class "w-16"})]} + (map-indexed (fn [i a] (invoice-expense-account-row* {:account a + :index i + :client-id client-id + :client-locations client-locations})) + rows) + + (com/data-grid-new-row {:colspan 4 + :hx-get (bidi/path-for ssr-routes/only-routes ::route/new-wizard-new-account) + :index (count rows) + :tr-params {:hx-vals (hx/json {:client-id client-id})}} + "New account") + (com/data-grid-row {} + (com/data-grid-cell {}) + (com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "TOTAL"]) + (com/data-grid-cell {:id "total" + :class "text-right" + :hx-trigger "change from:closest form target:.amount-field" + :hx-put (bidi/path-for ssr-routes/only-routes ::route/expense-account-total) + :hx-target "this" + :hx-swap "innerHTML"} + (format "$%,.2f" total)) + (com/data-grid-cell {})) + (com/data-grid-row {} + (com/data-grid-cell {}) + (com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "BALANCE"]) + (com/data-grid-cell {:id "balance" + :class "text-right" + :hx-trigger "change from:closest form target:.amount-field" + :hx-put (bidi/path-for ssr-routes/only-routes ::route/expense-account-balance) + :hx-target "this" + :hx-swap "innerHTML"} + [:span {:class (when-not (dollars= 0.0 (- invoice-total total)) "text-red-300")} + (format "$%,.2f" (- invoice-total total))]) + (com/data-grid-cell {})) + (com/data-grid-row {} + (com/data-grid-cell {}) + (com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "INVOICE TOTAL"]) + (com/data-grid-cell {:class "text-right"} (format "$%,.2f" invoice-total)) + (com/data-grid-cell {}))))]) + (com/modal-footer {} (wizard2/nav-footer {:back? true :save? true :save-label "Save"})))))) + +;; --------------------------------------------------------------------------- +;; Async step fragments (read from posted form / session -- no multi-form-state). +;; --------------------------------------------------------------------------- + +(defn- form-vendor-client + "Decode just the vendor + client + dates a fragment needs from the posted basic-details + form (flat invoice/* fields)." + [request] + (let [nested (:form-params (nfp/nested-params-request request {}))] + (mc/decode basic-details-schema nested main-transformer))) + +(defn account-prediction [request] + (let [{:invoice/keys [vendor client] :keys [customize-accounts]} (form-vendor-client request)] + (html-response (account-prediction-radio {:vendor-id (->db-id vendor) + :client-id client + :value customize-accounts})))) + +(defn due-date [request] + (let [{:invoice/keys [vendor client date due]} (form-vendor-client request) + vendor (clientize-vendor (get-vendor (->db-id vendor)) (->db-id client)) + date (some-> date coerce/from-date) + good-date (or (when (and date (:vendor/terms vendor)) + (time/plus date (time/days (:vendor/terms vendor)))) + (some-> due coerce/from-date))] + (html-response + (com/date-input {:value (some-> good-date (atime/unparse-local atime/normal-date)) + :name "invoice/due" + :x-init (format "due='%s'" (or (some-> good-date (atime/unparse-local atime/normal-date)) "")) + :x-model "due" + :error? false + :placeholder "1/1/2024"})))) + +(defn scheduled-payment-date [request] + (let [{:invoice/keys [vendor client due]} (form-vendor-client request) + vendor (clientize-vendor (get-vendor (->db-id vendor)) (->db-id client)) + good-date (when (and due (:vendor/automatically-paid-when-due vendor)) + (some-> due coerce/from-date))] + (html-response + (com/date-input {:value (some-> good-date (atime/unparse-local atime/normal-date)) + :name "invoice/scheduled-payment" + :error? false + :placeholder "1/1/2024"})))) + +(defn location-select [{{:keys [name account-id client-id value]} :query-params}] + (html-response (location-select* {:name name + :value value :account-location (some->> account-id (pull-attr (dc/db conn) :account/location)) :client-locations (some->> client-id (pull-attr (dc/db conn) :client/locations))}))) -(defn due-date [{:keys [multi-form-state]}] - (let [vendor (clientize-vendor (get-vendor (:invoice/vendor (:step-params multi-form-state))) - (->db-id (:invoice/client (:step-params multi-form-state)))) - good-date - (or (when (and (:invoice/date (:step-params multi-form-state)) - (-> vendor :vendor/terms)) - (time/plus (:invoice/date (:step-params multi-form-state)) - (time/days (-> vendor :vendor/terms)))) - (:invoice/due (:step-params multi-form-state)))] +(defn- posted-amount-total + "Sum the amounts in the posted accounts form." + [request] + (let [nested (:form-params (nfp/nested-params-request request {})) + decoded (mc/decode accounts-schema nested main-transformer)] + (expense-accounts-total* (:invoice/expense-accounts decoded)))) +(defn- session-invoice-total + "The invoice total stored in basic-details step-data (the accounts form doesn't carry it)." + [request] + (let [wid (get-in request [:form-params "wizard-id"])] + (or (:invoice/total (ws/get-all (:session request) wid)) 0.0))) + +(defn expense-account-total [request] + (html-response (format "$%,.2f" (posted-amount-total request)))) + +(defn expense-account-balance [request] + (let [balance (- (session-invoice-total request) (posted-amount-total request))] + (html-response [:span {:class (when-not (dollars= 0.0 balance) "text-red-300")} + (format "$%,.2f" balance)]))) + +(defn new-account + "Add-row: render one fresh expense-account row at the posted index." + [request] + (let [idx (-> request :query-params :index) + idx (if (string? idx) (Integer/parseInt idx) idx) + client-id (-> request :query-params :client-id) + client-locations (some->> client-id (pull-attr (dc/db conn) :client/locations))] (html-response - (com/date-input {:value (some-> good-date - (atime/unparse-local atime/normal-date)) - :name "step-params[invoice/due]" - :x-init (format "due='%s'" (some-> good-date - (atime/unparse-local atime/normal-date))) - :x-model "due" - :error? false - :placeholder "1/1/2024"})))) + (invoice-expense-account-row* {:account (wizard2/blank-row :invoice-expense-account/location "Shared") + :index idx + :client-id client-id + :client-locations client-locations})))) -(defn scheduled-payment-date [{:keys [multi-form-state]}] - (let [vendor (clientize-vendor (get-vendor (:invoice/vendor (:step-params multi-form-state))) - (->db-id (:invoice/client (:step-params multi-form-state)))) - good-date - (when (and (:invoice/due (:step-params multi-form-state)) - (:vendor/automatically-paid-when-due vendor)) +;; --------------------------------------------------------------------------- +;; done-fn: create / update the invoice, then show next-steps (or the updated row). +;; --------------------------------------------------------------------------- - (:invoice/due (:step-params multi-form-state)))] +(defn- next-steps-modal + "After a NEW invoice is created: offer to pay it now, add another, or close." + [invoice-id] + (modal-response + (com/modal {} + (com/modal-card-advanced + {:class "transition duration-300 ease-in-out scale-100 translate-x-0 opacity-100"} + (com/modal-header {} [:div.p-2 "Invoice accounts "]) + (com/modal-body + {} + [:p.text-lg "Would you like to pay this invoice now?"] + (com/navigation-button-list + {} + (com/navigation-button (-> {:class "w-48" + :hx-get (hu/url (bidi/path-for ssr-routes/only-routes ::route/pay-wizard) + {:selected invoice-id :replace-modal true})} + hx/trigger-click-or-enter) "Pay now") + (com/navigation-button (-> {:class "w-48" + :hx-get (hu/url (bidi/path-for ssr-routes/only-routes ::route/new-wizard) + {:replace-modal true})} + hx/trigger-click-or-enter) "Add another") + (com/navigation-button {:class "w-48" :next-arrow? false + "@click" "$dispatch('modalclose') " + "@keyup.enter.stop" "$dispatch('modalclose')"} + "Close"))))) + :headers {"hx-trigger" "invalidated"})) - (html-response - (com/date-input {:value (some-> good-date - (atime/unparse-local atime/normal-date)) - :name "step-params[invoice/scheduled-payment]" - :error? false - :placeholder "1/1/2024"})))) - -(defn account-prediction [{:keys [multi-form-state form-errors] :as request}] +(defn- updated-row-response + "After an EDIT: swap the table row in place (resolve invoices/row* to avoid a cycle)." + [identity invoice-id request] (html-response - (account-prediction* request))) + (@(resolve 'auto-ap.ssr.invoices/row*) identity (dc/pull (dc/db conn) default-read invoice-id) {:flash? true + :request request}) + :headers {"hx-trigger" "modalclose" + "hx-retarget" (format "#entity-table tr[data-id=\"%d\"]" invoice-id) + "hx-reswap" "outerHTML"})) + +(defn create-invoice! + "Engine done-fn: merge the steps into the invoice, validate, upsert, and respond. Default + accounts use the vendor's default account; :customize uses the posted grid." + [all-data {:keys [identity] :as request}] + (let [invoice all-data + extant? (:db/id invoice) + client-id (->db-id (:invoice/client invoice)) + vendor-id (->db-id (:invoice/vendor invoice)) + paid-amount (if-let [outstanding-balance + (and extant? + (- (pull-attr (dc/db conn) :invoice/total (:db/id invoice)) + (pull-attr (dc/db conn) :invoice/outstanding-balance (:db/id invoice))))] + outstanding-balance + 0.0) + outstanding-balance (- (:invoice/total invoice) paid-amount) + entity (-> invoice + (assoc :db/id (or (:db/id invoice) "invoice")) + (dissoc :customize-due-and-scheduled? :customize-accounts) + (assoc :invoice/expense-accounts + (if (= :customize (:customize-accounts invoice)) + (:invoice/expense-accounts invoice) + [{:db/id "123" + :invoice-expense-account/location "Shared" + :invoice-expense-account/account + (:db/id (:vendor/default-account (clientize-vendor (get-vendor vendor-id) client-id))) + :invoice-expense-account/amount (:invoice/total invoice)}])) + (assoc :invoice/outstanding-balance outstanding-balance + :invoice/import-status :import-status/imported + :invoice/status (if (dollars= 0.0 outstanding-balance) + :invoice-status/paid + :invoice-status/unpaid)) + (maybe-spread-locations) + (update :invoice/date coerce/to-date) + (update :invoice/due coerce/to-date) + (update :invoice/scheduled-payment coerce/to-date))] + (assert-invoice-amounts-add-up entity) + (assert-no-conflicting invoice) + (exception->4xx #(assert-can-see-client identity client-id)) + (exception->4xx #(assert-not-locked client-id (:invoice/date invoice))) + (let [transaction-result (audit-transact [[:upsert-invoice entity]] identity)] + (try + (solr/touch-with-ledger (or (:db/id invoice) (get-in transaction-result [:tempids "invoice"]))) + (catch Exception e + (alog/error ::cant-save-solr :error e))) + (if extant? + (updated-row-response identity (:db/id invoice) request) + (next-steps-modal (get-in transaction-result [:tempids "invoice"])))))) + +;; --------------------------------------------------------------------------- +;; Engine config + open / submit handlers. +;; --------------------------------------------------------------------------- + +(defn new-init-fn + "Engine :init-fn -- new vs edit branch on a route db/id. Dates ride as java.util.Date + (EDN-safe). For new, the date defaults to today; for edit, the persisted invoice is read + and customize-accounts is forced to :customize so its accounts are editable." + [request] + (if-let [id (->db-id (get-in request [:route-params :db/id]))] + (let [entity (-> (dc/pull (dc/db conn) default-read id) + (select-keys (mut/keys new-form-schema)) + ->edn-safe-dates + (assoc :customize-accounts :customize))] + {:init-data {:basic-details (dissoc entity :invoice/expense-accounts) + :accounts (select-keys entity [:invoice/expense-accounts])}}) + {:init-data {:basic-details {:invoice/date (coerce/to-date (time/now)) + :customize-accounts :default}}})) + +(def new-invoice-config + {:name :new-invoice + :form-id "wizard-form" + :submit-route (bidi/path-for ssr-routes/only-routes ::route/new-invoice-submit) + :form-attrs {:hx-ext "response-targets" + :hx-target-400 "#form-errors"} + :open-response (fn [form] (modal-response [:div#transitioner.flex-1 form])) + :init-fn new-init-fn + :steps [{:key :basic-details + :decode decode-basic-details + :validate validate-basic-details + :render render-basic-details + :next (fn [data] (if (= :customize (:customize-accounts data)) :accounts :done))} + {:key :accounts + :decode decode-accounts + :render render-accounts + :next (fn [_] :done)}] + :done-fn create-invoice!}) + +(defn open-wizard + "GET open (new or edit): build the wizard in its modal shell." + [request] + (exception->notification + #(wizard2/open-wizard new-invoice-config request))) + +(defn new-invoice-step + "POST handler for every transition: basic-details Save, accounts Save/Back. Surface the + create-time validation (conflict, amounts, locks) as a 4xx into #form-errors." + [request] + (try+ + (wizard2/handle-step-submit new-invoice-config request) + (catch #(#{:form-validation :schema-validation :field-validation :notification} (:type %)) e + (html-response + [:span.error-content.text-red-500 + (or (some->> (:form-validation-errors e) (str/join " ")) + (:message e) + "Could not save the invoice.")] + :status 400)))) (def key->handler (apply-middleware-to-all-handlers - {::route/new-wizard (-> mm/open-wizard-handler - (mm/wrap-wizard new-wizard) - (mm/wrap-init-multi-form-state initial-new-wizard-state)) - ::route/edit-wizard (-> mm/open-wizard-handler - (mm/wrap-wizard new-wizard) - (mm/wrap-init-multi-form-state initial-edit-wizard-state) + {::route/new-wizard (partial open-wizard) + ::route/edit-wizard (-> (partial open-wizard) (wrap-schema-enforce :route-schema [:map [:db/id entity-id]])) - ::route/due-date (-> due-date - (mm/wrap-wizard new-wizard) - (mm/wrap-decode-multi-form-state) - (wrap-nested-form-params)) - ::route/account-prediction (-> account-prediction - (mm/wrap-wizard new-wizard) - (mm/wrap-decode-multi-form-state) - (wrap-nested-form-params)) - ::route/scheduled-payment-date (-> scheduled-payment-date - (mm/wrap-wizard new-wizard) - (mm/wrap-decode-multi-form-state) - (wrap-nested-form-params)) - ::route/expense-account-total (-> invoice-expense-account-total - (mm/wrap-wizard new-wizard) - (mm/wrap-decode-multi-form-state)) - ::route/expense-account-balance (-> invoice-expense-account-balance - (mm/wrap-wizard new-wizard) - (mm/wrap-decode-multi-form-state)) + ::route/new-invoice-submit new-invoice-step - ::route/location-select (-> location-select - (wrap-schema-enforce :query-schema [:map - [:name :string] - [:client-id {:optional true} - [:maybe entity-id]] - [:account-id {:optional true} - [:maybe entity-id]]])) + ::route/account-prediction account-prediction + ::route/due-date due-date + ::route/scheduled-payment-date scheduled-payment-date + ::route/expense-account-total expense-account-total + ::route/expense-account-balance expense-account-balance - ::route/new-invoice-submit (-> mm/submit-handler - (mm/wrap-wizard new-wizard) - (mm/wrap-decode-multi-form-state)) - ::route/new-wizard-navigate (-> mm/next-handler - (mm/wrap-wizard new-wizard) - (mm/wrap-decode-multi-form-state)) - ::route/new-wizard-new-account (-> - (add-new-entity-handler [:step-params :invoice/expense-accounts] - (fn render [cursor request] - (invoice-expense-account-row* - {:value cursor - :client-id (:client-id (:query-params request))})) - (fn build-new-row [base _] - (assoc base :invoice-expense-account/location "Shared"))) - (wrap-schema-enforce :query-schema [:map - [:client-id {:optional true} - [:maybe entity-id]]]))} + ::route/location-select (-> location-select + (wrap-schema-enforce :query-schema [:map + [:name :string] + [:client-id {:optional true} [:maybe entity-id]] + [:account-id {:optional true} [:maybe entity-id]]])) + ::route/new-wizard-new-account (-> new-account + (wrap-schema-enforce :query-schema [:map + [:index {:optional true} [:maybe nat-int?]] + [:client-id {:optional true} [:maybe entity-id]]]))} (fn [h] (-> h - (wrap-client-redirect-unauthenticated))))) \ No newline at end of file + (wrap-client-redirect-unauthenticated))))) diff --git a/src/cljc/auto_ap/routes/invoice.cljc b/src/cljc/auto_ap/routes/invoice.cljc index c732782a..3d7852f1 100644 --- a/src/cljc/auto_ap/routes/invoice.cljc +++ b/src/cljc/auto_ap/routes/invoice.cljc @@ -15,7 +15,6 @@ :put ::new-invoice-submit "/due-date" ::due-date "/scheduled-payment-date" ::scheduled-payment-date - "/navigate" ::new-wizard-navigate "/account/new" ::new-wizard-new-account "/account/location-select" ::location-select "/account/prediction" ::account-prediction