A pasted row with both amount columns empty carries no accounting
information, but it still became a line item: it tripped the "Line item
amount 0.0 must be greater than 0." warning, which demoted the entire
journal entry to "ignored" -- so the good rows around it were dropped
and any existing entry with that external id was retracted.
Drop those rows in table->entries before add-errors runs, so they never
reach the balance check, :amount, or :line-items. An entry whose rows are
all blank disappears completely: not imported, not retracted, not
counted. Only genuinely empty values qualify (nil or a whitespace-only
string, since form input decodes "" to nil); an explicit 0 still warns as
before.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both external import review grids (ledger and transaction) can run to
hundreds of rows, so finding the handful that failed validation meant
scrolling the whole table. Add an "Only show errors" checkbox next to
"Show table" that hides every clean row.
Done with native Alpine: the wrapper's x-data carries errorsOnly, the
checkbox is x-model bound, and each row is rendered server-side knowing
whether it has errors -- flagged rows get no directive, clean rows get
x-show="!errorsOnly". The grid container becomes
x-show="showTable || errorsOnly" so ticking the filter reveals the table
in one click.
Submitting is unaffected: x-show only toggles display, so hidden rows
keep their inputs in the DOM and still post. Verified in the browser that
the full row set serializes while the filter is on.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Bonanza Produce invoice template captured the bill-to name and street
with [A-Z\s] / [A-Z0-9\s] classes, so any store name or address containing
a lowercase letter or punctuation failed to match and came back nil. On the
McCarran invoice that meant both :customer-identifier and :account-number
were empty, leaving the import with nothing to look a client up by.
Anchor instead on the B/I/L/L letters printed down the left margin at the
start of a line (the ship-to block on the right reuses the same letters
mid-line) and take the whole column up to the next column gap, without
restricting the character set. The leading \d on the account-number capture
is what selects the street L line over the name L line.
Every value the template already extracted is unchanged; only the nils
moved. Adds regression tests for both Reno locations, covering the address
that used to drop out and the sibling one that already worked.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sorting a grid before opening a bulk wizard 500'd the submit with
"No reader function for tag object".
parse-sort returned the grid's whole :matching-header map, which carries a
:render fn. That sort rides along in :query-params, which the bulk wizards
copy verbatim into their form snapshot (bulk_code.clj:88, invoices.clj:1437).
The snapshot is serialized with pr-str into a hidden field and read back with
clojure.edn/read-string on submit; a fn pr-strs as #object[...], which edn has
no reader for, so wrap-decode-multi-form-state threw before the handler ran.
:matching-header was only ever read inside parse-sort itself - nothing
downstream consumes it, and apply-toggle-sort in this same namespace already
builds entries without it. Keep it as a local binding to derive :name and to
drop unknown columns, and leave it out of the result.
Fixes transaction bulk-code (11 production 500s over 2026-08-10/11) and the
same latent bug in invoice bulk-edit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The grid export button rendered white-on-white: a-button's :secondary-light
asked for bg-white-200, which tailwind never generated (no white scale in the
config), then fell through to the generic color branch that layers text-white
on top. Give the variant a real light-blue fill and stop the fallthrough, and
label the button "CSV" so it isn't a lone download glyph.
The ledger register CSV named the account but not its number, so exports had
to be joined back by name. Add an Account Number column beside it, falling
back to the bank account's numeric code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The single typeahead had no visible way to unset a selection — clearing
was only possible via the undiscoverable backspace keybinding.
Adds an x icon on the right side of the field, shown only when a value is
selected. It reuses the same `value = {value: '', label: ''}` assignment
the existing backspace handler uses, so it rides the already-wired clear
path: the hidden input's $watch dispatches `change` (driving htmx filter
refetches) and `x-modelable "value.value"` propagates the empty value to
any parent scope bound via x-model (driving dependent fetches such as the
transaction-rule Account -> Location and Client -> Bank Account chains).
The icon is a plain div with tabindex="-1" and aria-hidden, so it stays
out of the tab order; @click.prevent.stop keeps the click from bubbling
to the wrapping anchor and popping the dropdown. Matches the clear
affordance multi-typeahead already had.
Rebuilds output.css for the new dark:hover:text-gray-200 utility.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The SSR ledger reports could generate and download a PDF but had lost the
SPA's ability to hand the report off to the client's email contacts. This
restores it for Profit and Loss, Balance Sheet and Cash Flows.
The server still sends nothing: the modal offers a mailto: link, pre-filled
with the client's email contacts, subject and body, that opens in the user's
own mail client so they can review before sending.
Extracts the modal the three reports duplicated into a shared
auto-ap.ssr.ledger.export-modal namespace, and tightens the SPA's rules
along the way:
- admin-only, uniformly (the SPA's Cash Flows page skipped this check)
- single client only, uniformly (the SPA's Balance Sheet page did not check,
so a multi-client report could be mailed to one client's contacts)
- recipients joined with "," per RFC 6068 rather than Outlook's ";"
- subject percent-encoded, like the body already was
- body links built from :base-url and bidi routes, fixing the dead
/reports/ link (the page now lives at /company/reports) and the
hardcoded prod domain in the requires-feedback link
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The external ledger import review grid is one row per line item, so a
journal entry spans two or more rows. When one entry fails validation the
whole paste is rejected, and the only way forward was to re-paste without
it.
Each row now carries its entry id (client-source-externalId, the same key
table->entries groups on) and the last column gets a trash button that
drops every row sharing that id. The rows are the form inputs, so removing
them from the DOM removes them from the next import post -- which also
works for rows that fail schema validation. Index gaps left behind are
compacted by coerce-vector on the way back in.
Also drops two leftover pprint calls that dumped form-errors to stdout on
every render.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Recategorizes 131 Sysco line-item descriptions per the client-reviewed
"Product Category Verification with codes" sheet: 96 existing rows re-coded
and 35 new rows appended (ids 1796-1830).
Root cause this addresses: get-line-account matches on exact description
string and silently defaults anything unmapped to 50000 Food Costs. Only 147
of the sheet's 321 reviewed rows coded the way the client expected. Note the
sheet's "change" column understates the work -- 20 of its 53 change rows are
no-ops (Paper -> Paper, confirming the gloves/liners/hairnets) and 3 were
already fixed in 38575aa5 / 7a0e256f, while 214,398 lines of movement come
from rows the client ticked as correct against a suggestion that already
differed from production.
Moves, replayed over all 1,022,732 DET lines in sysco-poller:
50000 -> 51500 Dry Goods 90,481 ln $5,215,011.82 105 clients
50000 -> 51450 Dressing & Sauce 58,191 ln $4,445,726.56 98
50000 -> 51400 Bread and Bun 37,705 ln $3,964,462.24 96
50000 -> 52000 Soft Beverage 36,244 ln $1,033,497.52 94
50000 -> 51200 Produce 6,519 ln $460,629.32 98
55000 -> 51500 Dry Goods 6,516 ln $259,750.60 97
50000 -> 74100 Cleaning Supplies 5,630 ln $205,233.19 98
55000 -> 74100 Cleaning Supplies 5,222 ln $125,946.32 99
50000 -> 51120 Chicken/Poultry 265 ln $42,482.50 8
50000 -> 51300 Dairy 36 ln $5,941.69 6
50000 -> 55000 Paperware 54 ln $1,751.22 18
54400 -> 51450 Dressing & Sauce 24 ln $1,413.26 1
total 246,887 ln $15,761,846.24
Only three source accounts are touched: 50000 and 55000 (the two silent
defaults) plus the single intended 54400 -> 51450 vinaigrette row. Nothing
else leaves a deliberately assigned account.
The 7 Misc Charges descriptions are deliberately left alone per Bryce,
including PICKLE CHIP KOSH 1/4 KK, which therefore stays at the 50000
default rather than moving to Produce as the sheet originally suggested.
Also corrects the PAPER & DISP fallback comment in sysco.clj: 440 of its 455
mapped descriptions point at 55000, not all 454. The 15 exceptions (foil
pans -> 51500, scour pads -> 74100) are mapped explicitly, so the
description map still wins ahead of the fallback. No logic changed.
Affects only clients with the code-sysco-items feature flag, and only at
import time -- already-imported invoices keep their existing splits.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Sysco importer codes each line item by exact description match against
resources/sysco_line_item_mapping.csv, silently defaulting to GL 50000 (Food
Costs) when the description is absent. Every new or renamed Sysco SKU
therefore leaks into Food Costs until someone hand-patches the CSV, which is
what 38575aa5 did for 34 descriptions.
Add a category-level fallback consulted after the description map and before
the 50000 default, enabled for PAPER & DISP only. The description mapping
still wins wherever it exists, so nothing already mapped changes.
PAPER & DISP is safe to generalize: all 454 mapped PAPER & DISP rows point at
55000, with no exceptions. Of the 852 distinct descriptions ever invoiced
under that category, only 3 resolved elsewhere, each because a row with a
different category shared the description and won the later-wins (into {}).
One of those, DESSERT CUP, was simply mis-categorized -- it is paper, and its
own lid (id 1782 LID DOME DESSERT CUP) was already 55000 -- so correct id 1772
to PAPER & DISP / 55000. The remaining two stay at 50000 on purpose, since
they are not paper: PAD SCRUB S-S 35 GRAM 1.25 OZ (SUPP & EQUIP) and TEST
STRIP SANITIZER QUAT (CHEMICAL/JANTRL).
Verified by replaying both changes over all 1,022,732 DET lines in the 56,010
CSVs under sysco-poller/: every resulting transition is 50000 -> 55000 (10,994
lines, $728,106.62). No line that already resolved to a non-default account
moved.
Note this only affects clients carrying the code-sysco-items feature flag, and
only on import -- already-imported invoices need a separate recode.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The statement no longer prints "Reel Produce" as text (only
orders@reelproduce.com), switched to MM/DD/YYYY dates, and moved the
invoice number into an "INV #..." transaction description, so no
template matched and the file fell through to the glimpse2 fallback.
Adds a QuickBooks-statement-style template (same shape as Suncrest /
Ocean Queen) keyed on reelproduce.com + Statement, placed after the
existing Reel Produce statement template so the old layout still wins.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The rotation in 111eca41 updated the Plaid secret-key only in prod.edn,
leaving prod-background-worker.edn, prod-cloud-background-worker.edn, and
prod-cloud.edn on the old (now-invalidated) secret. Background worker jobs
loading those files failed with INVALID_API_KEYS.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Exports one row per sales summary item so each category is individually
auditable, rather than one row per daily summary. Uses the grid helper's
page->csv-entities hook, the same seam the ledger export uses to fan a
journal entry out into its line items.
Includes the GL account code alongside the name so rows tie back to the
chart of accounts, and formats amounts to cents to keep float noise
(36.900000000000006) out of the export. Accounts are resolved in a single
batched pull and clientized per summary so name overrides are respected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
::transaction-routes/all-page does not exist -- the transactions index
is registered as ::transaction-routes/page ("/transaction2"). bidi's
path-for returns nil for an unknown handler, so (hu/url nil {...})
produced the relative "?exact-match-id=123". Clicking a Transaction
link in the ledger paperclip tooltip therefore stayed on the ledger and
re-filtered it by a transaction entity id, matching nothing.
Repoint all five call sites (ledger, payments and invoice paperclip
menus, the insights breadcrumb, and the expected-deposit row button) at
::transaction-routes/page.
Also add the missing conj in the ledger :invoice/source-url cond->
branch. The bare map made cond-> invoke it as a function against the
accumulated vector, yielding nil, so any journal entry whose original
entity was an invoice with a file rendered no paperclip at all.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Untyped (ex-info ... {:validation-error ...}) throws escaped the
wrap-form-4xx-2 middleware, surfacing as a 500 / unexpectedError box
instead of an inline form error. Route them through
utils/form-validation-error, which throws with :type :form-validation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Indexed form fields like periods[0]..periods[13] parse into a
string-keyed map; coerce-vector rebuilt the vector with a plain
lexicographic sort, so "10".."13" sorted before "2". Reports with
10+ periods (e.g. the 13/14-period option) rendered columns in the
order 0,1,10,11,12,13,2..9. Sort index keys numerically instead.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- P&L period dropdown: replace "Calendar year (YYYY)" with
"Previous Calendar Year (YYYY-1)" pulling the full prior year
- External register: title now reads "External Register"; add
Source, External Id, and Location filters
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replicate the master CLJS "delete external ledger" feature on the SSR
external ledger page: an admin-only bulk delete that retracts the
selected journal entries, skipping any in a client's locked period and
capping at 1000 per request.
Return the result via modal-response (retargets the persistent
#modal-content shell) and target #modal-content from the button so the
request never relies on the outerHTML swap inherited from the data-grid
card, which previously replaced #modal-holder and broke the next click.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Shows a "Clear filters" button in the transactions action bar whenever a
non-date filter is active. It's a boosted link back to the transactions
page that preserves the date range (and any implied status), so the
sidebar filters and table both reset.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Disable Code, Delete, and Suppress until at least one row is checked or
"select all" is active, matching the existing selection-aware UI.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bulk coding left the checked items selected after the table refreshed.
Add a dedicated reset-selection event that the grid's Alpine state
listens for, and fire it alongside refreshTable on bulk-code submit.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The unresolved/potential-duplicates query-param decoders fell through to
(boolean %) for unrecognized strings. A round-tripped "false" (pushed into the
URL, re-read via HX-Current-URL) decoded to true since any non-nil string is
truthy, so navigating pages silently turned on the "Unresolved only" filter.
Handle "false" and already-boolean values symmetrically.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Account coding lived in the always-applied base map of edit-form-schema, so
every action (including the link/apply-rule/unlink actions) required a valid
transaction-account/account. The edit modal always submits the Manual tab's
(usually blank) account row, so link submits failed validation before reaching
their save-handler and silently no-op'd. Move account validation into the
:manual branch of the action :multi so link actions validate without it.
Also surface whole-form validation errors in the wizard footer error bar:
default-step-footer only handled top-level/sequential error shapes, so nested
field-error maps (e.g. a hidden tab's account error) produced an empty bar and
a silent failure. Add flatten-form-errors to flatten the humanized error tree.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The bulk-code "Requires Feedback" option submitted "requires_feedback"
(underscore), which decoded to an enum keyword not present in the
schema (idents use a hyphen), so selecting it failed validation. Use
the hyphenated value and relabel the option, the reconciliation report
header to "Client Review" to unify with the sidebar terminology.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The profit-and-loss report always passed :warning as a [:div ...] hiccup
vector, which is truthy even when empty. The shared report table renders
its red warning box with (when warning ...), so a clean report with no
warning and no unresolved entries still showed an empty red error box.
Only build the warning div when there is actual warning text or sample
links, matching how the balance-sheet and cash-flows reports pass nil.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The save-handler re-rendered the edited row via row* without passing
:request, so the Client column's :hide? predicate received a nil request
and never hid the column. Pass :request request like table* does.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The bank-account filter rendered "Please select a client" even when a
client was set on the rule. Two causes:
- Inside (fc/with-field :transaction-rule/bank-account ...) the cursor is
rebound to the bank-account field, so (:transaction-rule/client
(fc/field-value)) read the nil bank-account value and the server
rendered the placeholder. The clientId watcher only fires on change, so
when editing (client preset, unchanged) the htmx swap never corrected
it. Read the client from the form root before entering the field.
- The clientId-change swap used innerHTML, nesting a fresh typeahead
inside the stale one and breaking its Alpine refs. Use outerHTML so the
typeahead is replaced in place.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The top bar grew vertically on narrower viewports when the environment
badge and company-selector labels wrapped, pushing content under the
fixed navbar (which the layout offsets with a fixed pt-16).
Rework the navbar into a fixed h-16 row with a priority-based responsive
layout:
- search fills the middle (flex-1) and shrinks first when space is tight
- company selector holds its size and truncates long names
- environment badge degrades full pill -> compact letter badge -> hidden
- harmonize control heights (40px controls, 32px badge/avatar accents) so
the search no longer renders as a cramped thin strip
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The error-details <pre> lived inside a <span x-data="{e:false}"> that was
itself inside a <p>. Since <pre> is block content, the HTML parser closed
the <p> and reparented the <pre> out of the span, so Alpine evaluated
x-show="e" with e no longer in scope ("e is not defined"). Use a <div>
wrapper instead of <p> so the pre stays within the e scope.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Location field hx-target "find *" resolved to the <label> (first child),
so changing an account swapped the reloaded <select> over the label and
left a duplicate dropdown. Target "find select" instead (simple + advanced).
- edit-wizard-toggle-mode-handler read mode only from step-params, but the
hidden "mode" field is a top-level form param, so current-mode always
defaulted to "simple" and the toggle could never return from advanced.
Read it from form-params too, matching edit-vendor-changed-handler.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>