Compare commits
33 Commits
2f9da3cdd9
...
integreat-
| Author | SHA1 | Date | |
|---|---|---|---|
| 5452b8b779 | |||
| 5c2cf8a631 | |||
| b8a0e9c3dc | |||
| 9659164fdc | |||
| 8f0a474fa8 | |||
| 6814cf1b15 | |||
| 3441ae63b4 | |||
| 79ddda624a | |||
| cbb9bc750d | |||
| a7ac7eae35 | |||
| c6699dd05a | |||
| 1be83a01f5 | |||
| ebd91f1911 | |||
| c9a587a8c5 | |||
| 9997d60de1 | |||
| 06fb0ea067 | |||
| 9a7d0b8b18 | |||
| 70a3db9a64 | |||
| 4e22fb1d82 | |||
| a88dcf4122 | |||
| 00b5303c28 | |||
| ab1a2c3368 | |||
| 724b6d82f5 | |||
| 6500c44909 | |||
| 2e4152e3fc | |||
| 6ce6a6e0c7 | |||
| 17eebe5628 | |||
| e5a2d0bbba | |||
| 7db1e07512 | |||
| df32100ca2 | |||
| daea729e8e | |||
| de933699aa | |||
| 4fca49bff0 |
177
.agents/skills/frontend-design/LICENSE.txt
Normal file
177
.agents/skills/frontend-design/LICENSE.txt
Normal file
@@ -0,0 +1,177 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
42
.agents/skills/frontend-design/SKILL.md
Normal file
42
.agents/skills/frontend-design/SKILL.md
Normal file
@@ -0,0 +1,42 @@
|
||||
---
|
||||
name: frontend-design
|
||||
description: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, artifacts, posters, or applications (examples include websites, landing pages, dashboards, React components, HTML/CSS layouts, or when styling/beautifying any web UI). Generates creative, polished code and UI design that avoids generic AI aesthetics.
|
||||
license: Complete terms in LICENSE.txt
|
||||
---
|
||||
|
||||
This skill guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices.
|
||||
|
||||
The user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints.
|
||||
|
||||
## Design Thinking
|
||||
|
||||
Before coding, understand the context and commit to a BOLD aesthetic direction:
|
||||
- **Purpose**: What problem does this interface solve? Who uses it?
|
||||
- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction.
|
||||
- **Constraints**: Technical requirements (framework, performance, accessibility).
|
||||
- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember?
|
||||
|
||||
**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity.
|
||||
|
||||
Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is:
|
||||
- Production-grade and functional
|
||||
- Visually striking and memorable
|
||||
- Cohesive with a clear aesthetic point-of-view
|
||||
- Meticulously refined in every detail
|
||||
|
||||
## Frontend Aesthetics Guidelines
|
||||
|
||||
Focus on:
|
||||
- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font.
|
||||
- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes.
|
||||
- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise.
|
||||
- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density.
|
||||
- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays.
|
||||
|
||||
NEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character.
|
||||
|
||||
Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations.
|
||||
|
||||
**IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well.
|
||||
|
||||
Remember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision.
|
||||
1
.claude/skills/agent-browser
Symbolic link
1
.claude/skills/agent-browser
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.agents/skills/agent-browser
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -8,6 +8,8 @@ pom.xml.asc
|
||||
*.class
|
||||
/.lein-*
|
||||
/.nrepl-port
|
||||
nrepl-port
|
||||
.http-port
|
||||
resources/public/js/compiled
|
||||
*.log
|
||||
examples/
|
||||
|
||||
8
.opencode/opencode.json
Normal file
8
.opencode/opencode.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"permission": {
|
||||
"edit": {
|
||||
"src/*": "deny"
|
||||
}
|
||||
}
|
||||
}
|
||||
22
.opencode/package-lock.json
generated
22
.opencode/package-lock.json
generated
@@ -5,7 +5,7 @@
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "1.15.10"
|
||||
"@opencode-ai/plugin": "1.15.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
|
||||
@@ -87,19 +87,19 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@opencode-ai/plugin": {
|
||||
"version": "1.15.10",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.15.10.tgz",
|
||||
"integrity": "sha512-V2p7CvpBtKWB+FID7Dl1y0Ci02zUT40A9b2RD9R9BOiuD8ZcKhHWov+irN0xVJA0Eg6OhEBfA0lPKRn1FNKPlw==",
|
||||
"version": "1.15.12",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.15.12.tgz",
|
||||
"integrity": "sha512-BBteGXEwJt+1ehHqQ+yKXmoWltrW+2xO++B1Fm/dnMGYWT9luEKA5RlUuVYA2qDF6uwlE7kmHZvQZAM79zWHEA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "1.15.10",
|
||||
"@opencode-ai/sdk": "1.15.12",
|
||||
"effect": "4.0.0-beta.66",
|
||||
"zod": "4.1.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.2.15",
|
||||
"@opentui/keymap": ">=0.2.15",
|
||||
"@opentui/solid": ">=0.2.15"
|
||||
"@opentui/core": ">=0.2.16",
|
||||
"@opentui/keymap": ">=0.2.16",
|
||||
"@opentui/solid": ">=0.2.16"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@opentui/core": {
|
||||
@@ -114,9 +114,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@opencode-ai/sdk": {
|
||||
"version": "1.15.10",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.15.10.tgz",
|
||||
"integrity": "sha512-CUhpmMGGOqzvPnNNjjWmEIodAfP6Qnuki2ChIUKWYF7UImZ4zUcMZnzO5BtUxu/Ni1P8qzWxDioXs+7aIZQEhA==",
|
||||
"version": "1.15.12",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.15.12.tgz",
|
||||
"integrity": "sha512-lOaBNX93dkakZe6C42ttX1bkSx3K2c6+Yv+w8Qv02v5rPlu1vCXbmdfYDh9/bw+oq+NKPSaBm9d6kPA19hA5Lg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cross-spawn": "7.0.6"
|
||||
|
||||
13
AGENTS.md
13
AGENTS.md
@@ -20,6 +20,11 @@ clj-nrepl-eval -p PORT "(+ 1 2 3)" # evaluate clojure code
|
||||
|
||||
```
|
||||
|
||||
### Editing clojure
|
||||
|
||||
When editing clojure, use the clojure-mcp editing tools, or ask @clojure-author to make the change. It is critical that you
|
||||
do not use the file editing tools unless absolutely necessary.
|
||||
|
||||
Often times, if a file won't compile, first clj-paren-repair on the file, then try again. If it doesn't wor still, try cljfmt check.
|
||||
|
||||
### Running the Application
|
||||
@@ -29,6 +34,14 @@ INTEGREAT_JOB="" lein run # Default: port 3000
|
||||
PORT=3449 lein run
|
||||
```
|
||||
|
||||
If you want to start the server, you should run `lein mcp-repl` which will output a nrepl-server port file and http-server port file.
|
||||
|
||||
## Browser Automation
|
||||
|
||||
When using the **agent-browser** skill for testing or automation:
|
||||
- Navigate to `/dev-login` to simulate an admin user and fake a session
|
||||
- Do not open directly to a specific page unless explicitly instructed to; instead, start on the dashboard and navigate from there
|
||||
|
||||
## Test Execution
|
||||
prefer using clojure-eval skill
|
||||
|
||||
|
||||
125
CLAUDE.md
125
CLAUDE.md
@@ -1,125 +1,2 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Integreat is a full-stack web application for accounts payable (AP) and accounting automation. It integrates with multiple financial data sources (Plaid, Yodlee, Square, Intuit QBO) to manage invoices, bank accounts, transactions, and vendor information.
|
||||
|
||||
**Tech Stack:**
|
||||
- Backend: Clojure 1.10.1 with Ring (Jetty), Datomic database, GraphQL (Lacinia, in process of depracation)
|
||||
- Frontend, two versions:
|
||||
* ClojureScript with Reagent, Re-frame, (almost depracated)
|
||||
* Server side: HTMX, TailwindCSS, alpinejs (current)
|
||||
- Java: Amazon Corretto 11 (required for Clojure 1.10.1)
|
||||
|
||||
## Development Commands
|
||||
|
||||
**Build:**
|
||||
```bash
|
||||
lein build # Create uberjar
|
||||
lein uberjar # Standalone JAR
|
||||
npm install # Install Node.js dependencies (for frontend build)
|
||||
```
|
||||
|
||||
### Development
|
||||
```bash
|
||||
docker-compose up -d # Start Datomic, Solr services
|
||||
lein repl # Start Clojure REPL (nREPL on port 9000), typically one will be running for you already
|
||||
lein cljfmt check # Check code formatting
|
||||
lein cljfmt fix # Auto-format code
|
||||
clj-paren-repair [FILE ...] # fix parentheses in files
|
||||
clj-nrepl-eval -p PORT "(+ 1 2 3)" # evaluate clojure code
|
||||
|
||||
```
|
||||
|
||||
Often times, if a file won't compile, first clj-paren-repair on the file, then try again. If it doesn't wor still, try cljfmt check.
|
||||
|
||||
|
||||
**Running the Application:**
|
||||
|
||||
**As Web Server:**
|
||||
```bash
|
||||
INTEGREAT_JOB="" lein run # Default: port 3000
|
||||
# Or with custom port:
|
||||
PORT=3449 lein run
|
||||
```
|
||||
|
||||
**As Background Job:**
|
||||
Set `INTEGREAT_JOB` environment variable to one of:
|
||||
- `square-import-job` - Square POS transaction sync
|
||||
- `yodlee2` - Yodlee bank account sync
|
||||
- `plaid` - Plaid bank linking
|
||||
- `intuit` - Intuit QBO sync
|
||||
- `import-uploaded-invoices` - Process uploaded invoice PDFs
|
||||
- `ezcater-upsert` - EZcater PO sync
|
||||
- `ledger_reconcile` - Ledger reconciliation
|
||||
- `bulk_journal_import` - Journal entry import
|
||||
- (no job) - Run web server + nREPL
|
||||
|
||||
## Architecture
|
||||
|
||||
**Request Flow:**
|
||||
1. Ring middleware pipeline processes requests
|
||||
2. Authentication/authorization middleware (Buddy) wraps handlers
|
||||
3. Bidi routes dispatch to handlers
|
||||
4. SSR (server-side rendering) generates HTML with Hiccup for main views
|
||||
5. For interactive pages, HTMX handles partial updates
|
||||
6. Client-side uses alpinejs as a bonus
|
||||
|
||||
**Multi-tenancy:**
|
||||
- Client-based filtering via `:client/code` and `:client/groups`
|
||||
- Client selection via `X-Clients` header or session
|
||||
- Role-based permissions: admin, standard user, vendor
|
||||
|
||||
**Key Directories:**
|
||||
- `src/clj/auto_ap/` - Backend Clojure code
|
||||
- `src/clj/auto_ap/server.clj` - Main entry point, job dispatcher, Mount lifecycle
|
||||
- `src/clj/auto_ap/handler.clj` - Ring app, middleware stack
|
||||
- `src/clj/auto_ap/datomic/` - Datomic schema and queries
|
||||
- `src/clj/auto_ap/ssr/` - Server-side rendered page handlers (Hiccup templates)
|
||||
- `src/clj/auto_ap/routes/` - HTTP route definitions
|
||||
- `src/clj/auto_ap/jobs/` - Background batch jobs
|
||||
- `src/clj/auto_ap/graphql/` - GraphQL type definitions and resolvers
|
||||
- `src/cljs/auto_ap/` - Frontend ClojureScript for old, depracated version
|
||||
- `test/clj/auto_ap/` - Unit/integration tests
|
||||
|
||||
## Database
|
||||
|
||||
- Datomic schema defined in `resources/schema.edn`
|
||||
- Key entity patterns:
|
||||
- `:client/code`, `:client/groups` for multi-tenancy
|
||||
- `:vendor/*`, `:invoice/*`, `:transaction/*`, `:account/*` for standard entities
|
||||
- `:db/type/ref` for relationships, many with `:db/cardinality :db.cardinality/many`
|
||||
|
||||
## Configuration
|
||||
|
||||
- Dev config: `config/dev.edn` (set via `-Dconfig=config/dev.edn`)
|
||||
- Env vars: `INTEGREAT_JOB`, `PORT`
|
||||
- Docker: Uses Alpine-based Amazon Corretto 11 image
|
||||
|
||||
## Important Patterns
|
||||
|
||||
- **Middleware stack** in `handler.clj`: route matching → logging → client hydration → session/auth → idle timeout → error handling → gzip
|
||||
- **Client context** added by middleware: `:identity`, `:clients`, `:client`, `:matched-route`
|
||||
- **Job dispatching** in `server.clj`: checks `INTEGREAT_JOB` env var to run specific background jobs or start web server
|
||||
- **Test selectors**: namespaces ending in `integration` or `functional` are selected by `lein test :integration` / `lein test :functional`
|
||||
|
||||
## Clojure REPL Evaluation
|
||||
|
||||
The command `clj-nrepl-eval` is installed on your path for evaluating Clojure code via nREPL.
|
||||
|
||||
**Discover nREPL servers:**
|
||||
|
||||
`clj-nrepl-eval --discover-ports`
|
||||
|
||||
**Evaluate code:**
|
||||
|
||||
`clj-nrepl-eval -p <port> "<clojure-code>"`
|
||||
|
||||
With timeout (milliseconds)
|
||||
|
||||
`clj-nrepl-eval -p <port> --timeout 5000 "<clojure-code>"`
|
||||
|
||||
The REPL session persists between evaluations - namespaces and state are maintained.
|
||||
Always use `:reload` when requiring namespaces to pick up changes.
|
||||
@AGENTS.md
|
||||
|
||||
106
config/core.clj
Normal file
106
config/core.clj
Normal file
@@ -0,0 +1,106 @@
|
||||
(ns config.core
|
||||
(:require [clojure.java.io :as io]
|
||||
[clojure.edn :as edn]
|
||||
[clojure.string :as s]
|
||||
[clojure.tools.logging :as log])
|
||||
(:import java.io.PushbackReader))
|
||||
|
||||
(defn parse-number [^String v]
|
||||
(try
|
||||
(Long/parseLong v)
|
||||
(catch NumberFormatException _
|
||||
(BigInteger. v))))
|
||||
|
||||
;originally found in cprop https://github.com/tolitius/cprop/blob/6963f8e04fd093744555f990c93747e0e5889395/src/cprop/source.cljc#L26
|
||||
(defn str->value
|
||||
"ENV vars and system properties are strings. str->value will convert:
|
||||
the numbers to longs, the alphanumeric values to strings, and will use Clojure reader for the rest
|
||||
in case reader can't read OR it reads a symbol, the value will be returned as is (a string)"
|
||||
[v]
|
||||
(cond
|
||||
(re-matches #"[0-9]+" v) (parse-number v)
|
||||
(re-matches #"^(true|false)$" v) (Boolean/parseBoolean v)
|
||||
(re-matches #"\w+" v) v
|
||||
:else
|
||||
(try
|
||||
(let [parsed (edn/read-string v)]
|
||||
(if (symbol? parsed) v parsed))
|
||||
(catch Throwable _ v))))
|
||||
|
||||
(defn keywordize [s]
|
||||
(-> (s/lower-case s)
|
||||
(s/replace "_" "-")
|
||||
(s/replace "." "-")
|
||||
(keyword)))
|
||||
|
||||
(defn read-system-env []
|
||||
(->> (System/getenv)
|
||||
(map (fn [[k v]] [(keywordize k) (str->value v)]))
|
||||
(into {})))
|
||||
|
||||
(defn read-system-props []
|
||||
(->> (System/getProperties)
|
||||
(map (fn [[k v]] [(keywordize k) (str->value v)]))
|
||||
(into {})))
|
||||
|
||||
(defn read-env-file [f]
|
||||
(try
|
||||
(when-let [env-file (io/file f)]
|
||||
(when (.exists env-file)
|
||||
(edn/read-string (slurp env-file))))
|
||||
(catch Exception e
|
||||
(log/warn (str "WARNING: failed to parse " f " " (.getLocalizedMessage e))))))
|
||||
|
||||
(defn read-config-file [f]
|
||||
(try
|
||||
(when-let [url (or (io/resource f) (io/file f))]
|
||||
(with-open [r (-> url io/reader PushbackReader.)]
|
||||
(edn/read r)))
|
||||
(catch java.io.FileNotFoundException _)
|
||||
(catch Exception e
|
||||
(log/warn (str "failed to parse " f " " (.getLocalizedMessage e))))))
|
||||
|
||||
(defn contains-in?
|
||||
"checks whether the nested key exists in a map"
|
||||
[m k-path]
|
||||
(let [one-before (get-in m (drop-last k-path))]
|
||||
(when (map? one-before) ;; in case k-path is "longer" than a map: {:a {:b {:c 42}}} => [:a :b :c :d]
|
||||
(contains? one-before (last k-path)))))
|
||||
|
||||
;; author of "deep-merge-with" is Chris Houser: https://github.com/clojure/clojure-contrib/commit/19613025d233b5f445b1dd3460c4128f39218741
|
||||
(defn deep-merge-with
|
||||
"Like merge-with, but merges maps recursively, appling the given fn
|
||||
only when there's a non-map at a particular level.
|
||||
(deepmerge + {:a {:b {:c 1 :d {:x 1 :y 2}} :e 3} :f 4}
|
||||
{:a {:b {:c 2 :d {:z 9} :z 3} :e 100}})
|
||||
-> {:a {:b {:z 3, :c 3, :d {:z 9, :x 1, :y 2}}, :e 103}, :f 4}"
|
||||
[f & maps]
|
||||
(apply
|
||||
(fn m [& maps]
|
||||
(if (every? map? maps)
|
||||
(apply merge-with m maps)
|
||||
(apply f maps)))
|
||||
(remove nil? maps)))
|
||||
|
||||
(defn merge-maps [& m]
|
||||
(reduce #(deep-merge-with (fn [_ v] v) %1 %2) m))
|
||||
|
||||
(defn load-env
|
||||
"Generate a map of environment variables."
|
||||
[& configs]
|
||||
(let [env-props (merge-maps (read-system-env) (read-system-props))]
|
||||
(apply
|
||||
merge-maps
|
||||
(read-config-file "config.edn")
|
||||
(read-env-file ".lein-env")
|
||||
(read-env-file (io/resource ".boot-env"))
|
||||
(read-env-file (:config env-props))
|
||||
env-props
|
||||
configs)))
|
||||
|
||||
(defonce
|
||||
^{:doc "A map of environment variables."}
|
||||
env (load-env))
|
||||
|
||||
(defn reload-env []
|
||||
(alter-var-root #'env (fn [_] (load-env))))
|
||||
@@ -11,7 +11,7 @@
|
||||
:requests-queue-url "https://sqs.us-east-1.amazonaws.com/679918342773/integreat-background-request-prod"
|
||||
:invoice-email "invoices@mail.app.integreatconsult.com"
|
||||
:import-failure-destination-email "ben@integreatconsult.com"
|
||||
:data-bucket "data.app-new.app.integreatconsult.com"
|
||||
:data-bucket "data.prod.app.integreatconsult.com"
|
||||
:yodlee-cobrand-name "qstartus12"
|
||||
:yodlee-cobrand-login "qstartus12"
|
||||
:yodlee-cobrand-password "MPD@mg78hd"
|
||||
|
||||
298
docs/superpowers/plans/2025-01-15-wizard-phase-i-trial.md
Normal file
298
docs/superpowers/plans/2025-01-15-wizard-phase-i-trial.md
Normal file
@@ -0,0 +1,298 @@
|
||||
# Phase I Trial: Django-Formtools Style Wizard (Server-Side Storage)
|
||||
|
||||
## Goal
|
||||
Create a **copy** of one existing wizard using Approach B (server-side session storage) without modifying any existing multi_modal.clj code. This is an isolated trial to validate the pattern.
|
||||
|
||||
## Trial Subject: Transaction Bulk Code Wizard
|
||||
**Why this one:**
|
||||
- Single-step form (simplest case)
|
||||
- Currently uses wizard protocols unnecessarily
|
||||
- ~420 lines in `transaction/bulk_code.clj`
|
||||
- Well-contained with clear inputs/outputs
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Browser Server
|
||||
| |
|
||||
|-- GET /bulk-code-trial -->|
|
||||
| |-- Create session entry
|
||||
| |-- Return form with wizard-id
|
||||
|<-- HTML + wizard-id -----|
|
||||
| |
|
||||
|-- POST (wizard-id + -->|
|
||||
| current-step data) |-- Validate step
|
||||
| |-- Store in session
|
||||
| |-- Return next step or done
|
||||
|<-- HTML -----------------|
|
||||
```
|
||||
|
||||
**Key difference from current approach:**
|
||||
- Current: EDN snapshot serialized in hidden form fields
|
||||
- Trial: Only `wizard-id` and current step fields in form. State lives server-side in atom.
|
||||
|
||||
---
|
||||
|
||||
## Files (All New - No Existing Files Modified)
|
||||
|
||||
```
|
||||
src/clj/auto_ap/ssr/components/wizard_trial/
|
||||
state.clj - Session storage backend
|
||||
core.clj - Trial wizard engine (minimal)
|
||||
|
||||
src/clj/auto_ap/ssr/transaction/
|
||||
bulk_code_trial.clj - Trial implementation of bulk code
|
||||
|
||||
test/clj/auto_ap/ssr/transaction/
|
||||
bulk_code_trial_test.clj - Tests for trial
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase I: Trial Implementation (This Week)
|
||||
|
||||
### Step 1: Create Session Storage
|
||||
|
||||
**File:** `src/clj/auto_ap/ssr/components/wizard_trial/state.clj`
|
||||
|
||||
```clojure
|
||||
(ns auto-ap.ssr.components.wizard-trial.state)
|
||||
|
||||
(defonce ^:private store (atom {}))
|
||||
|
||||
(defn create!
|
||||
"Creates new wizard session. Returns wizard-id."
|
||||
[initial-data]
|
||||
(let [id (str (java.util.UUID/randomUUID))]
|
||||
(swap! store assoc id {:data initial-data
|
||||
:created-at (java.util.Date.)})
|
||||
id))
|
||||
|
||||
(defn get-wizard
|
||||
"Retrieves wizard data by id."
|
||||
[id]
|
||||
(get @store id))
|
||||
|
||||
(defn update-step!
|
||||
"Merges step data into wizard session."
|
||||
[id step-key step-data]
|
||||
(swap! store assoc-in [id :data step-key] step-data))
|
||||
|
||||
(defn get-all-data
|
||||
"Returns merged data from all steps."
|
||||
[id]
|
||||
(-> (get-wizard id)
|
||||
:data
|
||||
vals
|
||||
(apply merge)))
|
||||
|
||||
(defn destroy!
|
||||
"Removes wizard session."
|
||||
[id]
|
||||
(swap! store dissoc id))
|
||||
```
|
||||
|
||||
### Step 2: Create Minimal Wizard Engine
|
||||
|
||||
**File:** `src/clj/auto_ap/ssr/components/wizard_trial/core.clj`
|
||||
|
||||
```clojure
|
||||
(ns auto-ap.ssr.components.wizard-trial.core
|
||||
(:require [auto-ap.ssr.components.wizard-trial.state :as ws]
|
||||
[malli.core :as mc]))
|
||||
|
||||
(defn render-step
|
||||
"Renders a single step form."
|
||||
[{:keys [wizard-id step-config request]}]
|
||||
(let [step-data (get-in (ws/get-wizard wizard-id) [:data (:key step-config)])]
|
||||
[:form {:hx-post (:submit-route step-config)
|
||||
:hx-target "this"
|
||||
:hx-swap "outerHTML"}
|
||||
[:input {:type "hidden" :name "wizard-id" :value wizard-id}]
|
||||
[:input {:type "hidden" :name "step-key" :value (name (:key step-config))}]
|
||||
((:render step-config) (assoc request :step-data step-data))]))
|
||||
|
||||
(defn handle-submit
|
||||
"Handles step submission."
|
||||
[step-config request]
|
||||
(let [{:keys [wizard-id step-key]} (:form-params request)
|
||||
wizard-id (str wizard-id)
|
||||
step-key (keyword step-key)
|
||||
step-data (select-keys (:form-params request) (:fields step-config))]
|
||||
|
||||
(if-let [errors (mc/explain (:schema step-config) step-data)]
|
||||
;; Validation failed - re-render with errors
|
||||
(render-step {:wizard-id wizard-id
|
||||
:step-config step-config
|
||||
:request (assoc request :errors errors)})
|
||||
|
||||
;; Success - save and done (single step for trial)
|
||||
(let [all-data (ws/get-all-data wizard-id)]
|
||||
(ws/destroy! wizard-id)
|
||||
((:done-fn step-config) all-data request)))))
|
||||
```
|
||||
|
||||
### Step 3: Create Trial Bulk Code Form
|
||||
|
||||
**File:** `src/clj/auto_ap/ssr/transaction/bulk_code_trial.clj`
|
||||
|
||||
```clojure
|
||||
(ns auto-ap.ssr.transaction.bulk-code-trial
|
||||
(:require [auto-ap.ssr.components.wizard-trial.core :as wt]
|
||||
[auto-ap.ssr.components.wizard-trial.state :as ws]
|
||||
[auto-ap.ssr.components :as com]
|
||||
[auto-ap.ssr.utils :refer [html-response]]
|
||||
[auto-ap.datomic :refer [conn pull-attr]]
|
||||
[datomic.api :as dc]))
|
||||
|
||||
(def bulk-code-schema
|
||||
[:map
|
||||
[:vendor {:optional true} [:maybe int?]]
|
||||
[:approval-status {:optional true} [:maybe keyword?]]
|
||||
[:accounts {:optional true}
|
||||
[:vector {:coerce? true}
|
||||
[:map
|
||||
[:account int?]
|
||||
[:location :string]
|
||||
[:percentage :double]]]]]])
|
||||
|
||||
(defn render-bulk-code-form
|
||||
"Pure function - renders the form given data."
|
||||
[{:keys [step-data errors]}]
|
||||
[:div.bulk-code-trial
|
||||
[:h2 "Bulk Code (Trial - Phase I)"]
|
||||
|
||||
[:div.space-y-4
|
||||
;; Vendor field
|
||||
[:div
|
||||
[:label "Vendor"]
|
||||
(com/typeahead {:name "vendor"
|
||||
:value (:vendor step-data)
|
||||
:url "/api/vendors/search"})]
|
||||
|
||||
;; Accounts table
|
||||
[:div
|
||||
[:h3 "Accounts"]
|
||||
[:table
|
||||
[:thead
|
||||
[:tr
|
||||
[:th "Account"]
|
||||
[:th "Location"]
|
||||
[:th "%"]]]
|
||||
[:tbody
|
||||
(for [[idx account] (map-indexed vector (:accounts step-data []))]
|
||||
[:tr {:key idx}
|
||||
[:td (com/typeahead {:name (str "accounts[" idx "][account]")
|
||||
:value (:account account)})]
|
||||
[:td (com/text-input {:name (str "accounts[" idx "][location]")
|
||||
:value (:location account)})]
|
||||
[:td (com/money-input {:name (str "accounts[" idx "][percentage]")
|
||||
:value (:percentage account)})]])]]]
|
||||
|
||||
;; Submit
|
||||
[:button {:type "submit"} "Apply Bulk Code"]]])
|
||||
|
||||
(def trial-step-config
|
||||
{:key :bulk-code
|
||||
:schema bulk-code-schema
|
||||
:fields [:vendor :approval-status :accounts]
|
||||
:render render-bulk-code-form
|
||||
:submit-route "/transaction/bulk-code-trial"
|
||||
:done-fn (fn [data request]
|
||||
;; Apply bulk coding logic here
|
||||
(html-response [:div.success "Bulk code applied! (Trial)"]))})
|
||||
|
||||
;; Route handlers
|
||||
(defn open-trial [request]
|
||||
(let [wizard-id (ws/create! {:accounts []})]
|
||||
(wt/render-step {:wizard-id wizard-id
|
||||
:step-config trial-step-config
|
||||
:request request})))
|
||||
|
||||
(defn submit-trial [request]
|
||||
(wt/handle-submit trial-step-config request))
|
||||
```
|
||||
|
||||
### Step 4: Add Routes
|
||||
|
||||
In your routes file (new entries, don't modify existing):
|
||||
|
||||
```clojure
|
||||
{::route/bulk-code-trial open-trial
|
||||
::route/bulk-code-trial-submit submit-trial}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase II: Expand Trial (If Phase I Works)
|
||||
|
||||
**Goal:** Test with a multi-step wizard
|
||||
|
||||
**Subject:** New Invoice Wizard (2-3 steps)
|
||||
- Step 1: Basic details
|
||||
- Step 2: Accounts (conditional)
|
||||
- Step 3: Submit
|
||||
|
||||
**New additions to trial engine:**
|
||||
- Step navigation (next/prev)
|
||||
- Conditional steps (skip accounts if not customizing)
|
||||
- Step validation per-step
|
||||
- Progress indicator
|
||||
|
||||
**Files to create:**
|
||||
```
|
||||
src/clj/auto_ap/ssr/invoice/
|
||||
new_invoice_trial.clj
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase III: Full Migration Decision (If Phase II Works)
|
||||
|
||||
**Goal:** Decide whether to migrate all wizards or keep both systems
|
||||
|
||||
**Evaluation criteria:**
|
||||
1. ✅ Line count reduction (target: 50%+)
|
||||
2. ✅ Testability (pure functions easier to test)
|
||||
3. ✅ Performance (server-side storage vs EDN serialization)
|
||||
4. ✅ Complexity (fewer protocols/middleware)
|
||||
5. ⚠️ Session handling (what happens on server restart?)
|
||||
6. ⚠️ Multiple tabs (can user have two wizards open?)
|
||||
|
||||
**Decision matrix:**
|
||||
|
||||
| Criteria | Current | Trial | Winner |
|
||||
|----------|---------|-------|--------|
|
||||
| Lines of code | ~8,200 | ~3,000 (est.) | Trial |
|
||||
| Server restarts | Survives (state in form) | Loses state | Current |
|
||||
| Multiple tabs | Works (independent forms) | Needs separate IDs | Tie |
|
||||
| Testability | Hard (cursor context) | Easy (pure functions) | Trial |
|
||||
| Complex merges | Painful | Simple (keyed steps) | Trial |
|
||||
|
||||
**If trial wins:** Migrate all wizards using Phase II pattern
|
||||
**If mixed:** Use trial for simple forms, keep current for complex multi-step
|
||||
|
||||
---
|
||||
|
||||
## How to Run the Trial
|
||||
|
||||
1. **Start server:** `lein run`
|
||||
2. **Navigate to:** `/transaction/bulk-code-trial`
|
||||
3. **Test:** Fill form, submit, verify state handling
|
||||
4. **Compare:** Open existing `/transaction/bulk-code` in another tab
|
||||
5. **Evaluate:** Which feels simpler? Which is easier to debug?
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria for Phase I
|
||||
|
||||
- [ ] Trial renders without errors
|
||||
- [ ] Form submission validates correctly
|
||||
- [ ] Server-side state persists across requests
|
||||
- [ ] No modifications to existing multi_modal.clj
|
||||
- [ ] Code is < 200 lines (vs 420 original)
|
||||
- [ ] Developer can understand flow in 5 minutes
|
||||
|
||||
**Ready to implement Phase I?**
|
||||
1283
docs/superpowers/plans/2025-01-15-wizard-refactor.md
Normal file
1283
docs/superpowers/plans/2025-01-15-wizard-refactor.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,601 @@
|
||||
# Transaction Edit Modal: Simple / Advanced Mode Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace the always-visible account split table in the transaction edit modal with a simple mode (single account + location fields) that is the default for uncoded or single-account transactions, with a toggle to the full advanced split table.
|
||||
|
||||
**Architecture:** HTMX-driven server-side swap. A new `edit-wizard-toggle-mode` GET endpoint re-renders the manual coding section in the requested mode. Mode is carried via a hidden `<input name="mode">` field included in all HTMX requests. `edit-vendor-changed` is updated to branch on mode. The `LinksStep` render function selects initial mode based on account row count.
|
||||
|
||||
**Tech Stack:** Clojure/Hiccup server-side rendering, HTMX, Alpine.js, Bidi routing, Datomic
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-05-27-transaction-edit-simple-advanced-mode-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| File | What changes |
|
||||
|------|-------------|
|
||||
| `src/cljc/auto_ap/routes/transactions.cljc` | Add `::edit-wizard-toggle-mode` route |
|
||||
| `src/clj/auto_ap/ssr/transaction/edit.clj` | Add `simple-mode-fields*`, `manual-coding-section*`, `edit-wizard-toggle-mode-handler`; update `LinksStep` render; update `edit-vendor-changed-handler`; register new route handler |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Add the toggle-mode route
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/cljc/auto_ap/routes/transactions.cljc`
|
||||
|
||||
- [ ] **Step 1: Add the route entry**
|
||||
|
||||
Open `src/cljc/auto_ap/routes/transactions.cljc`. After the `"/edit-wizard-new-account"` line, add:
|
||||
|
||||
```clojure
|
||||
"/edit-wizard-toggle-mode" ::edit-wizard-toggle-mode
|
||||
```
|
||||
|
||||
The file should look like:
|
||||
|
||||
```clojure
|
||||
"/edit-wizard-new-account" ::edit-wizard-new-account
|
||||
"/edit-wizard-toggle-mode" ::edit-wizard-toggle-mode
|
||||
"/match-payment" ::link-payment
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify the file compiles**
|
||||
|
||||
```bash
|
||||
clj-nrepl-eval -p 9000 "(require '[auto-ap.routes.transactions] :reload)"
|
||||
```
|
||||
|
||||
Expected: no errors.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/cljc/auto_ap/routes/transactions.cljc
|
||||
git commit -m "feat: add edit-wizard-toggle-mode route"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Add `simple-mode-fields*` — the simple-mode account/location UI
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/clj/auto_ap/ssr/transaction/edit.clj`
|
||||
|
||||
This function renders the account typeahead + location select + toggle link for simple mode. It goes near the existing `account-typeahead*` and `location-select*` helpers (around line 180).
|
||||
|
||||
- [ ] **Step 1: Add `simple-mode-fields*` after `account-typeahead*` (around line 180)**
|
||||
|
||||
```clojure
|
||||
(defn simple-mode-fields*
|
||||
"Renders the simple-mode account + location row and the toggle link.
|
||||
request must have :multi-form-state and :entity bound."
|
||||
[request]
|
||||
(let [snapshot (-> request :multi-form-state :snapshot)
|
||||
client-id (or (-> request :entity :transaction/client :db/id)
|
||||
(:transaction/client snapshot))
|
||||
existing-row (first (:transaction/accounts snapshot))
|
||||
account-val (:transaction-account/account existing-row)
|
||||
location-val (or (:transaction-account/location existing-row) "Shared")
|
||||
account-id (when (nat-int? account-val)
|
||||
(dc/pull (dc/db conn) '[:account/location] account-val))
|
||||
row-id (or (:db/id existing-row) (str (java.util.UUID/randomUUID)))]
|
||||
[:div
|
||||
;; hidden inputs to encode the single row as transaction/accounts[0]
|
||||
(fc/with-field :transaction/accounts
|
||||
(fc/with-cursor-index 0
|
||||
[:span
|
||||
(fc/with-field :db/id
|
||||
(com/hidden {:name (fc/field-name) :value row-id}))
|
||||
[:div.flex.gap-2.mt-2
|
||||
(fc/with-field :transaction-account/account
|
||||
(com/validated-field
|
||||
{:label "Account" :errors (fc/field-errors)}
|
||||
[:div.w-72
|
||||
(account-typeahead* {:value account-val
|
||||
:client-id client-id
|
||||
:name (fc/field-name)
|
||||
:x-model "simpleAccountId"})]))
|
||||
(fc/with-field :transaction-account/location
|
||||
(com/validated-field
|
||||
{:label "Location"
|
||||
:errors (fc/field-errors)
|
||||
:x-hx-val:account-id "simpleAccountId"
|
||||
:hx-vals (hx/json (cond-> {:name (fc/field-name)}
|
||||
client-id (assoc :client-id client-id)))
|
||||
:x-dispatch:changed "simpleAccountId"
|
||||
: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 account-id)
|
||||
:client-locations (pull-attr (dc/db conn) :client/locations client-id)
|
||||
:value location-val})))
|
||||
;; hidden amount — full transaction total
|
||||
(fc/with-field :transaction-account/amount
|
||||
(let [total (Math/abs (or (-> request :entity :transaction/amount)
|
||||
(:transaction/amount snapshot)
|
||||
0.0))]
|
||||
(com/hidden {:name (fc/field-name) :value total})))]))
|
||||
;; toggle link
|
||||
[:div.mt-1
|
||||
[:a.text-sm.text-blue-600.hover:underline.cursor-pointer
|
||||
{:hx-get (bidi/path-for ssr-routes/only-routes ::route/edit-wizard-toggle-mode)
|
||||
:hx-include "closest form"
|
||||
:hx-target "#manual-coding-section"
|
||||
:hx-swap "outerHTML"}
|
||||
"Switch to advanced mode"]]]))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify the file has no parse errors**
|
||||
|
||||
```bash
|
||||
clj-nrepl-eval -p 9000 "(require '[auto-ap.ssr.transaction.edit] :reload)"
|
||||
```
|
||||
|
||||
Expected: no errors.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/clj/auto_ap/ssr/transaction/edit.clj
|
||||
git commit -m "feat: add simple-mode-fields* for transaction edit modal"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Extract `manual-coding-section*` and update `LinksStep`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/clj/auto_ap/ssr/transaction/edit.clj`
|
||||
|
||||
Currently the manual coding block (vendor field + account grid) is inlined inside `LinksStep/render-step`. Extract it into `manual-coding-section*` which selects mode and renders accordingly. This also adds the `mode` hidden input and wraps the section in `#manual-coding-section`.
|
||||
|
||||
- [ ] **Step 1: Add `manual-mode-initial` helper** (determines initial mode from snapshot)
|
||||
|
||||
Add this function after `simple-mode-fields*`:
|
||||
|
||||
```clojure
|
||||
(defn- manual-mode-initial
|
||||
"Returns :simple or :advanced based on existing account row count."
|
||||
[snapshot]
|
||||
(let [rows (seq (:transaction/accounts snapshot))]
|
||||
(if (and rows (> (count rows) 1))
|
||||
:advanced
|
||||
:simple)))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `manual-coding-section*`**
|
||||
|
||||
Add after `manual-mode-initial`:
|
||||
|
||||
```clojure
|
||||
(defn manual-coding-section*
|
||||
"Renders the vendor field + account/location section for the manual tab.
|
||||
mode is :simple or :advanced."
|
||||
[mode request]
|
||||
(let [snapshot (-> request :multi-form-state :snapshot)
|
||||
row-count (count (:transaction/accounts snapshot))]
|
||||
[:div#manual-coding-section
|
||||
;; hidden mode input — carried by all hx-include=\"closest form\" calls
|
||||
(com/hidden {:name "mode" :value (name mode)})
|
||||
;; vendor field
|
||||
[:div {:hx-trigger "change"
|
||||
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-vendor-changed)
|
||||
:hx-target "#manual-coding-section"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-include "closest form"}
|
||||
(fc/with-field :transaction/vendor
|
||||
(com/validated-field
|
||||
{:label "Vendor" :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 :vendor-search)
|
||||
:value (fc/field-value)
|
||||
:content-fn (fn [c] (pull-attr (dc/db conn) :vendor/name c))})]))]
|
||||
;; account/location section
|
||||
(if (= mode :simple)
|
||||
[:div {:x-data (hx/json {:simpleAccountId
|
||||
(-> snapshot :transaction/accounts first
|
||||
:transaction-account/account)})}
|
||||
(fc/start-form (:multi-form-state request) nil
|
||||
(fc/with-field :step-params
|
||||
(simple-mode-fields* request)))]
|
||||
;; advanced mode
|
||||
[:div
|
||||
(when (<= row-count 1)
|
||||
[:div.mb-2
|
||||
[:a.text-sm.text-blue-600.hover:underline.cursor-pointer
|
||||
{:hx-get (bidi/path-for ssr-routes/only-routes ::route/edit-wizard-toggle-mode)
|
||||
:hx-include "closest form"
|
||||
:hx-target "#manual-coding-section"
|
||||
:hx-swap "outerHTML"}
|
||||
"Switch to simple mode"]])
|
||||
(fc/start-form (:multi-form-state request) nil
|
||||
(fc/with-field :step-params
|
||||
(fc/with-field :transaction/accounts
|
||||
[:div#account-grid-body
|
||||
(account-grid-body* request)])))])]))
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update `LinksStep/render-step` to use `manual-coding-section*`**
|
||||
|
||||
In `LinksStep/render-step` (around line 826), replace the entire `[:div {}` block inside `[:div {:x-show "activeForm === 'manual'" ...}]` (which currently contains the vendor typeahead + approval status + `account-grid-body*`) with:
|
||||
|
||||
```clojure
|
||||
[:div {:x-show "activeForm === 'manual'", :x-transition:enter "transition ease-out duration-500", :x-transition:enter-start "opacity-0 transform scale-95", :x-transition:enter-end "opacity-100 transform scale-100"}
|
||||
[:div {}
|
||||
(manual-coding-section* (manual-mode-initial snapshot) request)
|
||||
;; Approval status field
|
||||
(fc/with-field :transaction/approval-status
|
||||
(com/validated-field
|
||||
{:label "Status"
|
||||
:errors (fc/field-errors)}
|
||||
(let [current-value (name (or (fc/field-value) :transaction-approval-status/unapproved))]
|
||||
[:div {:x-data (hx/json {:approvalStatus current-value})}
|
||||
(com/hidden {:name (fc/field-name)
|
||||
:value current-value
|
||||
":value" "approvalStatus"})
|
||||
[:div {:class "inline-flex rounded-md shadow-sm", :role "group"}
|
||||
(com/button-group-button {"@click" "approvalStatus = 'approved'"
|
||||
":class" "{ '!bg-primary-200 text-primary-800': approvalStatus === 'approved' }"
|
||||
:class "rounded-l-lg"}
|
||||
"Approved")
|
||||
(com/button-group-button {"@click" "approvalStatus = 'unapproved'"
|
||||
":class" "{ '!bg-primary-200 text-primary-800': approvalStatus === 'unapproved' }"
|
||||
:class "rounded-r-lg"}
|
||||
"Unapproved")
|
||||
(com/button-group-button {"@click" "approvalStatus = 'suppressed'"
|
||||
":class" "{ '!bg-primary-200 text-primary-800': approvalStatus === 'suppressed' }"
|
||||
:class "rounded-r-lg"}
|
||||
"Client Review")]]]))]]]
|
||||
```
|
||||
|
||||
Also remove the now-redundant `(fc/with-field :transaction/accounts ...)` wrapper that previously wrapped `account-grid-body*` (it is now handled inside `manual-coding-section*`).
|
||||
|
||||
- [ ] **Step 4: Verify the file compiles**
|
||||
|
||||
```bash
|
||||
clj-nrepl-eval -p 9000 "(require '[auto-ap.ssr.transaction.edit] :reload)"
|
||||
```
|
||||
|
||||
Expected: no errors.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/clj/auto_ap/ssr/transaction/edit.clj
|
||||
git commit -m "feat: extract manual-coding-section* with simple/advanced mode selection"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Add `edit-wizard-toggle-mode-handler`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/clj/auto_ap/ssr/transaction/edit.clj`
|
||||
|
||||
This handler re-renders `#manual-coding-section` in the opposite mode. It reads `mode` from the form params (via `step-params` in the decoded multi-form-state) and flips it.
|
||||
|
||||
- [ ] **Step 1: Add the handler function** (add after `edit-vendor-changed-handler`):
|
||||
|
||||
```clojure
|
||||
(defn edit-wizard-toggle-mode-handler [request]
|
||||
(let [step-params (-> request :multi-form-state :step-params)
|
||||
current-mode (keyword (or (:mode step-params) "simple"))
|
||||
target-mode (if (= current-mode :simple) :advanced :simple)
|
||||
snapshot (-> request :multi-form-state :snapshot)
|
||||
;; When switching simple→advanced, promote simple-mode values into accounts
|
||||
render-request
|
||||
(if (and (= target-mode :advanced)
|
||||
(= current-mode :simple))
|
||||
;; carry the simple-mode single row into snapshot so the table shows it
|
||||
(let [accounts (or (seq (:transaction/accounts step-params))
|
||||
(seq (:transaction/accounts snapshot)))]
|
||||
(-> request
|
||||
(assoc-in [:multi-form-state :snapshot :transaction/accounts]
|
||||
(vec accounts))
|
||||
(assoc-in [:multi-form-state :step-params :transaction/accounts]
|
||||
(vec accounts))))
|
||||
;; advanced→simple: take first row only
|
||||
(let [first-row (first (or (seq (:transaction/accounts step-params))
|
||||
(seq (:transaction/accounts snapshot))))]
|
||||
(-> request
|
||||
(assoc-in [:multi-form-state :snapshot :transaction/accounts]
|
||||
(if first-row [first-row] []))
|
||||
(assoc-in [:multi-form-state :step-params :transaction/accounts]
|
||||
(if first-row [first-row] [])))))]
|
||||
(html-response
|
||||
(fc/start-form (:multi-form-state render-request) nil
|
||||
(fc/with-field :step-params
|
||||
(manual-coding-section* target-mode render-request))))))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Register the handler in `key->handler`**
|
||||
|
||||
In the `key->handler` map (around line 1357), add after the `::route/edit-wizard-new-account` entry:
|
||||
|
||||
```clojure
|
||||
::route/edit-wizard-toggle-mode (-> edit-wizard-toggle-mode-handler
|
||||
(mm/wrap-wizard edit-wizard)
|
||||
(wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read)
|
||||
(mm/wrap-decode-multi-form-state))
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify the file compiles**
|
||||
|
||||
```bash
|
||||
clj-nrepl-eval -p 9000 "(require '[auto-ap.ssr.transaction.edit] :reload)"
|
||||
```
|
||||
|
||||
Expected: no errors.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/clj/auto_ap/ssr/transaction/edit.clj
|
||||
git commit -m "feat: add edit-wizard-toggle-mode-handler"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Update `edit-vendor-changed-handler` to support both modes
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/clj/auto_ap/ssr/transaction/edit.clj`
|
||||
|
||||
Currently this handler always returns `[:div#account-grid-body ...]`. It must now return `#manual-coding-section` in the correct mode.
|
||||
|
||||
- [ ] **Step 1: Replace `edit-vendor-changed-handler`**
|
||||
|
||||
Replace the entire `edit-vendor-changed-handler` function body with:
|
||||
|
||||
```clojure
|
||||
(defn edit-vendor-changed-handler [request]
|
||||
(let [multi-form-state (:multi-form-state request)
|
||||
snapshot (:snapshot multi-form-state)
|
||||
step-params (:step-params multi-form-state)
|
||||
mode (keyword (or (:mode step-params) "simple"))
|
||||
client-id (or (:transaction/client snapshot)
|
||||
(-> request :entity :transaction/client :db/id))
|
||||
vendor-id (or (:transaction/vendor step-params)
|
||||
(:transaction/vendor snapshot))
|
||||
total (Math/abs (or (-> request :entity :transaction/amount)
|
||||
(:transaction/amount snapshot)
|
||||
0.0))
|
||||
amount-mode (or (:amount-mode snapshot) "$")
|
||||
existing-accounts (or (seq (:transaction/accounts step-params))
|
||||
(seq (:transaction/accounts snapshot)))
|
||||
default-account (when (and (empty? existing-accounts) vendor-id client-id)
|
||||
(vendor-default-account vendor-id client-id))
|
||||
render-request
|
||||
(if (and (empty? existing-accounts) vendor-id client-id)
|
||||
(let [new-account (cond-> {:db/id (str (java.util.UUID/randomUUID))
|
||||
:transaction-account/location (or (:account/location default-account) "Shared")
|
||||
:transaction-account/amount (if (= amount-mode "%") 100.0 total)}
|
||||
default-account (assoc :transaction-account/account (:db/id default-account)))]
|
||||
(-> request
|
||||
(assoc-in [:multi-form-state :snapshot :transaction/accounts] [new-account])
|
||||
(assoc-in [:multi-form-state :step-params :transaction/accounts] [new-account])))
|
||||
request)]
|
||||
(html-response
|
||||
(fc/start-form (:multi-form-state render-request) nil
|
||||
(fc/with-field :step-params
|
||||
(manual-coding-section* mode render-request))))))
|
||||
```
|
||||
|
||||
Note: the `hx-target` on the vendor field in `manual-coding-section*` must point to `#manual-coding-section` (not `#account-grid-body`) — this was set correctly in Task 3.
|
||||
|
||||
- [ ] **Step 2: Verify the file compiles**
|
||||
|
||||
```bash
|
||||
clj-nrepl-eval -p 9000 "(require '[auto-ap.ssr.transaction.edit] :reload)"
|
||||
```
|
||||
|
||||
Expected: no errors.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/clj/auto_ap/ssr/transaction/edit.clj
|
||||
git commit -m "feat: update edit-vendor-changed-handler to support simple/advanced mode"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Fix `fc/with-cursor-index` usage in `simple-mode-fields*`
|
||||
|
||||
**Context:** The simple-mode fields need to emit form names like `step-params[transaction/accounts][0][transaction-account/account]`. The existing `fc/cursor-map` used in `account-grid-body*` handles this automatically. For the single-row simple mode we need to manually set index 0.
|
||||
|
||||
Look up how `fc/with-cursor-index` (or equivalent) works in `src/clj/auto_ap/ssr/form_cursor.clj` before writing the code in Task 2. If no such helper exists, use `fc/cursor-nth` or replicate the index manually via the form cursor API.
|
||||
|
||||
- [ ] **Step 1: Inspect the form cursor API**
|
||||
|
||||
```bash
|
||||
clj-nrepl-eval -p 9000 "(clj-mcp.repl-tools/list-vars 'auto-ap.ssr.form-cursor)"
|
||||
```
|
||||
|
||||
Note the available functions.
|
||||
|
||||
- [ ] **Step 2: Update `simple-mode-fields*` if needed**
|
||||
|
||||
If `fc/with-cursor-index` does not exist, replace the `fc/with-cursor-index 0` call in Task 2 with the correct form-cursor idiom. The key requirement is that the hidden `db/id`, `transaction-account/account`, `transaction-account/location`, and `transaction-account/amount` fields emit names matching index 0 of `transaction/accounts`.
|
||||
|
||||
A known working pattern from `account-grid-body*`:
|
||||
|
||||
```clojure
|
||||
(fc/cursor-map #(transaction-account-row* {:value % ...}))
|
||||
```
|
||||
|
||||
For simple mode with a single synthetic row, build a one-element vector in the snapshot and let `fc/cursor-map` iterate it — but render a flat div instead of a table. Or pass the cursor manually:
|
||||
|
||||
```clojure
|
||||
(fc/with-field :transaction/accounts
|
||||
(let [row-cursor (fc/cursor-nth 0)] ; adjust to actual API
|
||||
(fc/with-cursor row-cursor
|
||||
...field rendering...)))
|
||||
```
|
||||
|
||||
Verify field names are correct in a browser after implementation.
|
||||
|
||||
- [ ] **Step 3: Verify the file compiles**
|
||||
|
||||
```bash
|
||||
clj-nrepl-eval -p 9000 "(require '[auto-ap.ssr.transaction.edit] :reload)"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit if any changes were made**
|
||||
|
||||
```bash
|
||||
git add src/clj/auto_ap/ssr/transaction/edit.clj
|
||||
git commit -m "fix: correct form-cursor indexing for simple-mode account field"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Manual smoke test
|
||||
|
||||
Before writing automated tests, verify the UI works end-to-end in a browser.
|
||||
|
||||
- [ ] **Step 1: Start the application**
|
||||
|
||||
```bash
|
||||
INTEGREAT_JOB="" lein run
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Open a transaction with no accounts**
|
||||
|
||||
Navigate to a transaction with no coded accounts. Open the edit modal. Verify it opens in simple mode with blank account and location fields.
|
||||
|
||||
- [ ] **Step 3: Test vendor selection in simple mode**
|
||||
|
||||
Select a vendor. Verify the account field is populated with the vendor's default account and the location is set appropriately.
|
||||
|
||||
- [ ] **Step 4: Test toggle to advanced**
|
||||
|
||||
Click "Switch to advanced mode". Verify the full split table appears with one pre-populated row.
|
||||
|
||||
- [ ] **Step 5: Test toggle back to simple**
|
||||
|
||||
With 1 row, click "Switch to simple mode". Verify the single account/location fields appear with that row's values.
|
||||
|
||||
- [ ] **Step 6: Test with a split transaction**
|
||||
|
||||
Open a transaction that already has 2+ accounts. Verify it opens in advanced mode. Verify the "Switch to simple mode" link is absent.
|
||||
|
||||
- [ ] **Step 7: Test save round-trip**
|
||||
|
||||
In simple mode, set a vendor, account, and location. Save. Re-open. Verify the same values are pre-populated in simple mode.
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Write e2e tests
|
||||
|
||||
**Files:**
|
||||
- Create: `test/clj/auto_ap/ssr/transaction/edit_simple_advanced_mode_test.clj`
|
||||
|
||||
Check how existing e2e tests are structured first:
|
||||
|
||||
```bash
|
||||
clj-nrepl-eval -p 9000 "(clj-mcp.repl-tools/list-ns)"
|
||||
```
|
||||
|
||||
Look for test namespaces matching `auto-ap.ssr.transaction.*` or `auto-ap.e2e.*`. Follow the same fixture/helper patterns.
|
||||
|
||||
The test file should cover all 20 acceptance criteria from the spec. Group them with `testing` blocks:
|
||||
|
||||
```clojure
|
||||
(ns auto-ap.ssr.transaction.edit-simple-advanced-mode-test
|
||||
(:require
|
||||
[clojure.test :refer [deftest is testing use-fixtures]]
|
||||
;; ... project-specific test helpers ...
|
||||
))
|
||||
|
||||
(deftest simple-advanced-mode-initial-state
|
||||
(testing "AC1: uncoded transaction opens in simple mode"
|
||||
;; create a transaction with no accounts
|
||||
;; open edit modal
|
||||
;; verify #manual-coding-section has mode=simple hidden input
|
||||
;; verify no #account-grid-body present
|
||||
(is ...))
|
||||
|
||||
(testing "AC2: single-account transaction opens in simple mode with values pre-populated"
|
||||
...)
|
||||
|
||||
(testing "AC3: multi-account transaction opens in advanced mode"
|
||||
...))
|
||||
|
||||
(deftest simple-mode-vendor-selection
|
||||
(testing "AC4: selecting vendor populates account and location"
|
||||
...)
|
||||
(testing "AC5: selecting vendor does not overwrite manually chosen account"
|
||||
...))
|
||||
|
||||
(deftest mode-toggle
|
||||
(testing "AC9: switching to advanced carries account/location into first row"
|
||||
...)
|
||||
(testing "AC10: switching to advanced from blank simple gives empty table"
|
||||
...)
|
||||
(testing "AC11: switch-to-simple link visible with 0 or 1 rows"
|
||||
...)
|
||||
(testing "AC12: switch-to-simple link absent with 2+ rows"
|
||||
...)
|
||||
(testing "AC13: switching to simple pre-populates from first row"
|
||||
...))
|
||||
|
||||
(deftest save-round-trip
|
||||
(testing "AC6: save in simple mode persists vendor/account/location"
|
||||
...)
|
||||
(testing "AC18: switching modes mid-edit then saving produces valid transaction"
|
||||
...)
|
||||
(testing "AC19: split transaction re-opens in advanced mode with splits intact"
|
||||
...)
|
||||
(testing "AC20: single-account transaction re-opens in simple mode"
|
||||
...))
|
||||
```
|
||||
|
||||
Fill in actual test bodies using the project's test infrastructure (browser automation or ring mock depending on what exists).
|
||||
|
||||
- [ ] **Step 1: Check existing test conventions**
|
||||
|
||||
```bash
|
||||
clj-nrepl-eval -p 9000 "(require '[auto-ap.ssr.testing-conventions] :reload)"
|
||||
```
|
||||
|
||||
Also load the testing-conventions skill for guidance:
|
||||
|
||||
```
|
||||
Load skill: testing-conventions
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the test file** following project conventions
|
||||
|
||||
- [ ] **Step 3: Run the tests**
|
||||
|
||||
```bash
|
||||
clj-nrepl-eval -p 9000 "(clojure.test/run-tests 'auto-ap.ssr.transaction.edit-simple-advanced-mode-test)"
|
||||
```
|
||||
|
||||
Expected: all tests pass.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add test/clj/auto_ap/ssr/transaction/edit_simple_advanced_mode_test.clj
|
||||
git commit -m "test: add e2e acceptance tests for simple/advanced mode"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Checklist (completed inline)
|
||||
|
||||
- **Spec coverage:** All 20 ACs addressed — Tasks 2–5 implement the behaviour; Task 8 tests all 20.
|
||||
- **Placeholder scan:** Task 6 and Task 8 contain some "fill in" guidance — this is intentional because they depend on runtime API discovery. The instructions tell the engineer exactly where to look and what to verify.
|
||||
- **Type consistency:** `manual-coding-section*` is used consistently by `LinksStep/render-step`, `edit-vendor-changed-handler`, and `edit-wizard-toggle-mode-handler`. `#manual-coding-section` is the swap target throughout. `mode` hidden input uses `(name mode)` for string serialization and `(keyword ...)` for deserialization.
|
||||
@@ -0,0 +1,132 @@
|
||||
# Transaction Edit Modal: Simple / Advanced Mode
|
||||
|
||||
**Date:** 2026-05-27
|
||||
**Status:** Approved
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The transaction editing modal gains a two-mode interface. **Simple mode** replaces the account/location split table with two single fields (account typeahead + location dropdown), suitable for the common case of a single-account transaction. **Advanced mode** exposes the existing split table for multi-account allocations. The mode is selected automatically on open based on the transaction's current state, and the user can toggle between modes via a server-rendered swap.
|
||||
|
||||
---
|
||||
|
||||
## Layout
|
||||
|
||||
### Simple mode
|
||||
|
||||
```
|
||||
[ Vendor typeahead ]
|
||||
[ Account typeahead ] [ Location ▼ ]
|
||||
Switch to advanced mode →
|
||||
[ Memo ]
|
||||
[ Approval status buttons ]
|
||||
```
|
||||
|
||||
### Advanced mode
|
||||
|
||||
```
|
||||
[ Vendor typeahead ]
|
||||
Switch to simple mode →
|
||||
[ Account | Location | $ / % | ✕ ]
|
||||
[ Account | Location | $ / % | ✕ ]
|
||||
[ + Add row ]
|
||||
[ Memo ]
|
||||
[ Approval status buttons ]
|
||||
```
|
||||
|
||||
The toggle link sits directly below the vendor field. It reads "Switch to advanced mode" in simple mode and "Switch to simple mode" in advanced mode. In advanced mode with 2+ rows, the "Switch to simple mode" link is hidden (the user must remove rows manually before they can return to simple mode). The toggle link fires `hx-get` on the `::route/edit-wizard-toggle-mode` endpoint with `hx-include="closest form"` so the current form state (including `mode`) is carried in the request.
|
||||
|
||||
---
|
||||
|
||||
## Mode Selection on Open
|
||||
|
||||
The server determines initial mode when rendering the `LinksStep` body:
|
||||
|
||||
- **0 or 1 existing account rows** → render simple mode, pre-populate the account/location fields from the existing row (blank if none).
|
||||
- **2+ existing account rows** → render advanced mode with all rows populated.
|
||||
|
||||
---
|
||||
|
||||
## Toggle Mechanism (Option B — HTMX swap)
|
||||
|
||||
Clicking the toggle link fires an `hx-get` request to a new endpoint that re-renders the editable body of the modal in the target mode. Mode is passed as a query param (e.g., `?mode=advanced` or `?mode=simple`).
|
||||
|
||||
**Simple → Advanced:** The current account and location values from the simple fields are carried into the first row of the advanced table (100% of transaction total, or full dollar amount). Any additional rows previously added to the table are preserved via the multi-form-state snapshot.
|
||||
|
||||
**Advanced → Simple:** Only available when there is exactly 0 or 1 row in the table. The toggle link is absent when 2+ rows exist.
|
||||
|
||||
The swapped fragment replaces the entire editable body div (`#links-step-body` or equivalent target), keeping the side panel and modal chrome intact.
|
||||
|
||||
The current mode is tracked as a hidden `<input name="mode" value="simple|advanced">` inside the form. This ensures all HTMX calls that `hx-include` the form (vendor change, toggle, submit) carry the mode value without requiring it to be a separate query param.
|
||||
|
||||
---
|
||||
|
||||
## Vendor Selection Behaviour
|
||||
|
||||
### In simple mode
|
||||
|
||||
When the vendor typeahead fires its `change` event, the existing `edit-vendor-changed` HTMX endpoint is called. The response re-renders the simple-mode body with:
|
||||
|
||||
- The account field populated with the vendor's default account (clientized for the transaction's client).
|
||||
- The location field set to the account's fixed location, or "Shared" if the account has no fixed location.
|
||||
- Fields are editable; the user may override both.
|
||||
|
||||
The vendor default only applies when there are no existing accounts (matching existing server-side logic in `edit-vendor-changed-handler`). If the user has already manually chosen an account, changing vendor does not overwrite it.
|
||||
|
||||
### In advanced mode
|
||||
|
||||
Vendor change behaviour is unchanged from the current implementation: if no rows exist, a single row is created with the vendor's default account and location at 100% / full amount. If rows already exist, the vendor change has no effect on the table.
|
||||
|
||||
---
|
||||
|
||||
## Form Submission
|
||||
|
||||
Both modes submit to the same `edit-submit` endpoint. Simple mode submits the single account and location as a one-element `transaction/accounts` vector, identical in shape to what the advanced table produces today. No schema or handler changes are needed for submission.
|
||||
|
||||
---
|
||||
|
||||
## New Routes
|
||||
|
||||
| Method | Route key | Purpose |
|
||||
|--------|-----------|---------|
|
||||
| `GET` | `::route/edit-wizard-toggle-mode` | Re-renders the editable body in the requested mode. Reads current form state from `hx-include`'d form fields; the `mode` hidden input indicates the target mode (the endpoint flips it). |
|
||||
|
||||
The `edit-vendor-changed` endpoint reads the `mode` hidden input from the included form to determine whether to return simple-mode or advanced-mode HTML.
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
These are the expected behaviours to be verified by e2e tests:
|
||||
|
||||
1. **Verify** that when a transaction has no coded accounts, it opens in simple mode with blank account and location fields.
|
||||
2. **Verify** that when a transaction has exactly one coded account, it opens in simple mode with that account and location pre-selected.
|
||||
3. **Verify** that when a transaction has two or more coded accounts, it opens in advanced mode showing the full split table.
|
||||
4. **Verify** that in simple mode, selecting a vendor replaces the account field with the vendor's default account (clientized for the transaction's client) and sets the location to the account's fixed location or "Shared".
|
||||
5. **Verify** that in simple mode, selecting a vendor does not overwrite an account the user has already manually chosen (i.e., the account typeahead already has a value when the vendor change fires).
|
||||
6. **Verify** that after saving in simple mode, re-opening the transaction shows the same vendor, account, and location that were saved.
|
||||
7. **Verify** that in simple mode, the account field is a typeahead that respects the same allowance rules as the advanced table (`:account/default-allowance`).
|
||||
8. **Verify** that in simple mode, the location dropdown shows the account's fixed location (sole option) if the account has one, or the full list of client locations plus "Shared" if it does not.
|
||||
9. **Verify** that clicking "Switch to advanced mode" from simple mode re-renders the form in advanced mode with one table row pre-populated from the simple-mode account and location fields.
|
||||
10. **Verify** that clicking "Switch to advanced mode" from a blank simple mode (no account selected) re-renders the form in advanced mode with an empty table (no rows, just the "Add row" button).
|
||||
11. **Verify** that the "Switch to simple mode" link is visible in advanced mode when there is exactly 0 or 1 row.
|
||||
12. **Verify** that the "Switch to simple mode" link is absent in advanced mode when there are 2 or more rows.
|
||||
13. **Verify** that clicking "Switch to simple mode" from advanced mode (1 row) re-renders the form in simple mode with that row's account and location pre-populated.
|
||||
14. **Verify** that in advanced mode, selecting a vendor when there are no rows creates a single row with the vendor's default account, correct location, and 100% (or full dollar amount) allocation.
|
||||
15. **Verify** that in advanced mode, selecting a vendor when rows already exist does not modify the existing rows.
|
||||
16. **Verify** that the vendor default account is determined by clientizing the vendor for the client the transaction belongs to (client-specific account override takes precedence over the global vendor default).
|
||||
17. **Verify** that the approval status, memo, and vendor fields are present and functional in both simple and advanced modes.
|
||||
18. **Verify** that switching modes mid-edit and then saving produces a valid transaction (no orphaned or duplicated account rows).
|
||||
19. **Verify** that a transaction saved in advanced mode with splits can be re-opened and remains in advanced mode with all splits intact.
|
||||
20. **Verify** that a transaction saved in simple mode (single account) can be re-opened in simple mode and the single account/location are correctly pre-populated.
|
||||
|
||||
---
|
||||
|
||||
## Files Affected
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/clj/auto_ap/ssr/transaction/edit.clj` | Add simple-mode rendering functions; add `edit-wizard-toggle-mode` handler; update `edit-vendor-changed-handler` to support both modes; update `LinksStep` body render to select initial mode |
|
||||
| `src/cljc/auto_ap/routes/transactions.cljc` | Add `::edit-wizard-toggle-mode` route |
|
||||
| E2E test file (to be created) | Acceptance criteria tests for all 20 items above |
|
||||
@@ -234,7 +234,7 @@ test.describe('Bulk Code Transactions - Happy Path', () => {
|
||||
await openBulkCodeModal(page);
|
||||
|
||||
// Should show all transactions
|
||||
await expect(page.locator('text=Bulk editing 5 transactions')).toBeVisible();
|
||||
await expect(page.locator('text=Bulk editing 6 transactions')).toBeVisible();
|
||||
|
||||
// Add account at 100%
|
||||
await addNewAccount(page);
|
||||
@@ -263,6 +263,61 @@ test.describe('Bulk Code Transactions - Validation', () => {
|
||||
await expect(page.locator('#modal-holder[x-show="open"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should preserve vendor and status on validation error', async ({ page }) => {
|
||||
await navigateToTransactions(page);
|
||||
await selectTransactionByIndex(page, 0);
|
||||
await openBulkCodeModal(page);
|
||||
|
||||
// Select vendor
|
||||
const testInfo = await getTestInfo(page);
|
||||
const vendorId = testInfo.accounts.vendor;
|
||||
|
||||
const vendorContainer = page.locator('div[hx-post*="vendor-changed"]').first();
|
||||
const vendorHidden = vendorContainer.locator('input[type="hidden"]').first();
|
||||
|
||||
await vendorHidden.evaluate((el: HTMLInputElement, value: string) => {
|
||||
const newInput = document.createElement('input');
|
||||
newInput.type = 'hidden';
|
||||
newInput.name = el.name;
|
||||
newInput.value = value;
|
||||
el.parentNode.replaceChild(newInput, el);
|
||||
}, vendorId.toString());
|
||||
|
||||
await vendorContainer.evaluate((el: HTMLElement) => {
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
|
||||
await page.waitForResponse(response => response.url().includes('/vendor-changed') && response.status() === 200);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Select approval status
|
||||
const statusSelect = page.locator('select[name="step-params[approval-status]"]').first();
|
||||
await statusSelect.selectOption('approved');
|
||||
|
||||
// Vendor selection pre-populated a default account row at 100%.
|
||||
// Modify its percentage to 50% (invalid - doesn't total 100%).
|
||||
await setAccountPercentage(page, 0, '50');
|
||||
|
||||
await submitBulkCodeForm(page);
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Modal should still be open
|
||||
await expect(page.locator('#modal-holder[x-show="open"]')).toBeVisible();
|
||||
|
||||
// Vendor should still be selected
|
||||
const vendorHiddenAfter = page.locator('input[type="hidden"][name="step-params[vendor]"]').first();
|
||||
const vendorValueAfter = await vendorHiddenAfter.inputValue();
|
||||
expect(vendorValueAfter).toBe(vendorId.toString());
|
||||
|
||||
// Status should still be selected
|
||||
const statusValueAfter = await statusSelect.inputValue();
|
||||
expect(statusValueAfter).toBe('approved');
|
||||
|
||||
// Should show validation error
|
||||
const errorText = await getModalErrorText(page);
|
||||
expect(errorText).toContain('does not equal 100%');
|
||||
});
|
||||
|
||||
test('should reject when account percentages total less than 100%', async ({ page }) => {
|
||||
await navigateToTransactions(page);
|
||||
await selectTransactionByIndex(page, 0);
|
||||
@@ -447,7 +502,7 @@ test.describe('Bulk Code Transactions - Vendor Pre-population', () => {
|
||||
await page.waitForSelector('table tbody tr');
|
||||
});
|
||||
|
||||
test('should NOT pre-populate default account when user has multiple clients', async ({ page }) => {
|
||||
test('should pre-populate non-clientized default account when user has multiple clients', async ({ page }) => {
|
||||
// Switch to multi-client mode
|
||||
await page.request.get('/test-set-client-mode?mode=multi-client');
|
||||
|
||||
@@ -480,13 +535,15 @@ test.describe('Bulk Code Transactions - Vendor Pre-population', () => {
|
||||
await page.waitForResponse(response => response.url().includes('/vendor-changed') && response.status() === 200);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Should NOT have pre-populated account rows - only the "New account" button row
|
||||
const accountRows = page.locator('#account-entries tbody tr');
|
||||
const rowCount = await accountRows.count();
|
||||
|
||||
// With multi-client, no pre-population should happen, so only 1 row (the "New account" button)
|
||||
expect(rowCount).toBe(1);
|
||||
|
||||
// Should pre-populate the vendor's default account (non-clientized) plus the "New account" row
|
||||
const accountInputs = page.locator('#account-entries input[type="hidden"][name*="[account]"]');
|
||||
const accountInputCount = await accountInputs.count();
|
||||
expect(accountInputCount).toBe(1);
|
||||
|
||||
// The pre-populated account should be the vendor's raw default account (test-account)
|
||||
const accountValue = await accountInputs.first().inputValue();
|
||||
expect(accountValue).toBe(testInfo.accounts['test-account'].toString());
|
||||
|
||||
// Switch back to single-client mode for other tests
|
||||
await page.request.get('/test-set-client-mode?mode=single-client');
|
||||
});
|
||||
|
||||
@@ -15,13 +15,8 @@ async function openEditModal(page: any, transactionIndex: number = 0) {
|
||||
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' });
|
||||
await page.waitForSelector('#wizardmodal');
|
||||
|
||||
// Click Next to go to the links step (button says "Transaction Actions")
|
||||
await page.click('button:has-text("Transaction Actions")');
|
||||
|
||||
// Wait for the links step to load
|
||||
await page.waitForSelector('text=Transaction Actions', { state: 'visible' });
|
||||
|
||||
// Click on "Manual" tab
|
||||
// The modal is now single-page (Edit Transaction). Click "Manual" tab to ensure
|
||||
// the manual account coding form is active.
|
||||
await page.click('button:has-text("Manual")');
|
||||
|
||||
// Wait for the manual form to appear
|
||||
@@ -383,6 +378,83 @@ async function openEditModalForTransaction(page: any, description: string) {
|
||||
await page.waitForSelector('text=Transaction Actions', { state: 'visible' });
|
||||
}
|
||||
|
||||
async function selectVendorFromTypeahead(page: any, vendorName: string) {
|
||||
const testInfo = await getTestInfo(page);
|
||||
const vendorId = testInfo.accounts.vendor;
|
||||
|
||||
if (!vendorId) {
|
||||
throw new Error(`Could not find vendor with name ${vendorName}`);
|
||||
}
|
||||
|
||||
const vendorContainer = page.locator('div[hx-post*="edit-vendor-changed"]').first();
|
||||
const vendorHidden = vendorContainer.locator('input[type="hidden"]').first();
|
||||
|
||||
await vendorHidden.evaluate((el: HTMLInputElement, value: string) => {
|
||||
const newInput = document.createElement('input');
|
||||
newInput.type = 'hidden';
|
||||
newInput.name = el.name;
|
||||
newInput.value = value;
|
||||
el.parentNode.replaceChild(newInput, el);
|
||||
}, vendorId.toString());
|
||||
|
||||
await vendorContainer.evaluate((el: HTMLElement) => {
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
|
||||
await page.waitForResponse(response => response.url().includes('/edit-vendor-changed') && response.status() === 200);
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
test.describe('Transaction Edit Vendor Pre-population', () => {
|
||||
test('should start with no account rows when transaction has no accounts', async ({ page }) => {
|
||||
await openEditModal(page, 3);
|
||||
|
||||
await page.click('button:has-text("Manual")');
|
||||
await page.waitForSelector('#account-grid-body');
|
||||
|
||||
// Remove any existing accounts from previous tests
|
||||
await removeAllAccounts(page);
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const accountRows = page.locator('#account-grid-body tbody tr.account-row');
|
||||
const rowCount = await accountRows.count();
|
||||
|
||||
expect(rowCount).toBe(0);
|
||||
});
|
||||
|
||||
test('should pre-populate default account when vendor is selected', async ({ page }) => {
|
||||
await openEditModal(page, 3);
|
||||
|
||||
await page.click('button:has-text("Manual")');
|
||||
await page.waitForSelector('#account-grid-body');
|
||||
|
||||
// Remove any existing accounts from previous tests
|
||||
await removeAllAccounts(page);
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const accountRows = page.locator('#account-grid-body tbody tr.account-row');
|
||||
const initialRowCount = await accountRows.count();
|
||||
expect(initialRowCount).toBe(0);
|
||||
|
||||
await selectVendorFromTypeahead(page, 'Test Vendor');
|
||||
|
||||
const rowsAfterVendor = page.locator('#account-grid-body tbody tr.account-row');
|
||||
const rowCountAfter = await rowsAfterVendor.count();
|
||||
|
||||
expect(rowCountAfter).toBe(1);
|
||||
|
||||
const accountHidden = page.locator('input[type="hidden"][name*="transaction-account/account"]').first();
|
||||
const accountValue = await accountHidden.inputValue();
|
||||
|
||||
const testInfo = await getTestInfo(page);
|
||||
expect(accountValue).toBe(testInfo.accounts['test-account'].toString());
|
||||
|
||||
const amountInput = page.locator('.account-amount-field').first();
|
||||
const amountValue = await amountInput.inputValue();
|
||||
expect(parseFloat(amountValue)).toBeCloseTo(400.0, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Transaction Link Date Display', () => {
|
||||
test('should show payment date when linking to payment', async ({ page }) => {
|
||||
await openEditModalForTransaction(page, 'Transaction for payment link');
|
||||
|
||||
@@ -74,17 +74,17 @@ test.describe('Transaction Navigation - Amount Filter Persistence', () => {
|
||||
test('should persist amount filter when navigating to Client Review', async ({ page }) => {
|
||||
// Step 1: Navigate to All page and set amount filter
|
||||
await navigateToTransactions(page, '/transaction2');
|
||||
await setAmountFilter(page, '', '250');
|
||||
await setAmountFilter(page, '', '500');
|
||||
|
||||
// Step 2: Wait for URL to update
|
||||
await page.waitForURL(url => url.search.includes('amount-lte=250'), { timeout: 5000 });
|
||||
await page.waitForURL(url => url.search.includes('amount-lte=500'), { timeout: 5000 });
|
||||
|
||||
// Step 3: Click Client Review nav link
|
||||
await clickTransactionNavLink(page, 'Client Review');
|
||||
|
||||
// Step 4: Verify filter persisted
|
||||
const feedbackUrl = page.url();
|
||||
expect(feedbackUrl).toContain('amount-lte=250');
|
||||
expect(feedbackUrl).toContain('amount-lte=500');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
(update m (:db/ident (:ledger-mapped/ledger-side item)) (fnil + 0.0) (:ledger-mapped/amount item 0.0)))
|
||||
{:account account}
|
||||
acc-items))))
|
||||
_ (clojure.pprint/pprint aggregated)
|
||||
line-items (mapv (fn [{:keys [account] :as m}]
|
||||
(cond-> {:db/id (str (java.util.UUID/randomUUID))
|
||||
:journal-entry-line/account account
|
||||
@@ -29,8 +28,7 @@
|
||||
aggregated)
|
||||
|
||||
total-debits (reduce + 0.0 (map #(get % :ledger-side/debit 0.0) aggregated))
|
||||
total-credits (reduce + 0.0 (map #(get % :ledger-side/credit 0.0) aggregated))
|
||||
_ (clojure.pprint/pprint [total-debits total-credits])]
|
||||
total-credits (reduce + 0.0 (map #(get % :ledger-side/credit 0.0) aggregated))]
|
||||
(when (and (seq line-items)
|
||||
(= (Math/round (* 1000 total-debits))
|
||||
(Math/round (* 1000 total-credits))))
|
||||
|
||||
@@ -118,7 +118,12 @@
|
||||
"type": "local",
|
||||
"command": ["clojure", "-Tmcp", "start", ":config-profile", ":cli-assist"],
|
||||
"enabled": true
|
||||
}
|
||||
} ,
|
||||
"tavily": {
|
||||
"type": "remote",
|
||||
"url": "https://mcp.tavily.com/mcp/?tavilyApiKey=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",
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"permission": {
|
||||
"read": "allow",
|
||||
|
||||
@@ -119,6 +119,7 @@
|
||||
:aliases {"build" ["do" ["uberjar"]]
|
||||
#_#_"fig:dev" ["run" "-m" "figwheel.main" "-b" "dev" "-r"]
|
||||
"build-dev" ["trampoline" "run" "-m" "figwheel.main" "-b" "dev" "-r"]
|
||||
"mcp-repl" ["trampoline" "run" "-m" "dev-mcp"]
|
||||
#_#_"fig:min" ["run" "-m" "figwheel.main" "-O" "whitespace" "-bo" "min"]}
|
||||
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -6,6 +6,12 @@
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/agent-browser/SKILL.md",
|
||||
"computedHash": "228f87d57035100d9dc6efcfc05aafd4b6e3962adacaa04b8217ab2fadb15dc8"
|
||||
},
|
||||
"frontend-design": {
|
||||
"source": "anthropics/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/frontend-design/SKILL.md",
|
||||
"computedHash": "063a0e6448123cd359ad0044cc46b0e490cc7964d45ef4bb9fd842bd2ffbca67"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,9 +66,9 @@
|
||||
])
|
||||
|
||||
(defn not-found [_]
|
||||
{:status 404
|
||||
{:status 404
|
||||
:headers {}
|
||||
:body ""})
|
||||
:body ""})
|
||||
|
||||
(defn home-handler [{:keys [identity]}]
|
||||
(if identity
|
||||
@@ -125,13 +125,13 @@
|
||||
|
||||
(defn wrap-logging [handler]
|
||||
(fn [request]
|
||||
(mu/with-context (cond-> {:uri (:uri request)
|
||||
:route (:handler (bidi.bidi/match-route all-routes
|
||||
(:uri request)
|
||||
:request-method (:request-method request)))
|
||||
(mu/with-context (cond-> {:uri (:uri request)
|
||||
:route (:handler (bidi.bidi/match-route all-routes
|
||||
(:uri request)
|
||||
:request-method (:request-method request)))
|
||||
|
||||
:client-selection (:client-selection request)
|
||||
:source "request"
|
||||
:source "request"
|
||||
:query (:uri request)
|
||||
:request-method (:request-method request)
|
||||
:user (dissoc (:identity request)
|
||||
@@ -157,15 +157,15 @@
|
||||
(defn wrap-idle-session-timeout
|
||||
[handler]
|
||||
(fn [request]
|
||||
(let [session (:session request {:version session-version/current-session-version})
|
||||
(let [session (:session request {:version session-version/current-session-version})
|
||||
end-time (coerce/to-date-time (::idle-timeout session))]
|
||||
(if (and end-time (time/before? end-time (time/now)))
|
||||
(if (get (:headers request) "hx-request")
|
||||
{:session nil
|
||||
:status 200
|
||||
:status 200
|
||||
:headers {"hx-redirect" "/login"}}
|
||||
{:session nil
|
||||
:status 302
|
||||
:status 302
|
||||
:headers {"Location" "/login"}})
|
||||
(when-let [response (handler request)]
|
||||
(let [session (:session response session)]
|
||||
@@ -231,7 +231,7 @@
|
||||
seq
|
||||
(pull-many (dc/db conn)
|
||||
'[:db/id :client/name :client/code :client/locations
|
||||
:client/matches :client/feature-flags
|
||||
:client/matches :client/feature-flags
|
||||
{:client/bank-accounts [:db/id
|
||||
{:bank-account/type [:db/ident]}
|
||||
:bank-account/number
|
||||
@@ -298,7 +298,7 @@
|
||||
{:status 200
|
||||
:headers {"hx-trigger" (cheshire/generate-string
|
||||
{"notification" (str (hiccup/html [:div (.getMessage e)]))})
|
||||
"hx-reswap" "none"}} ;; TODO make a warning box so you don't have to reuse the notifaction box, or make it reuse the same box but theme differently
|
||||
"hx-reswap" "none"}} ;; TODO make a warning box so you don't have to reuse the notifaction box, or make it reuse the same box but theme differently
|
||||
:else
|
||||
{:status 500
|
||||
:body (pr-str e)})))))
|
||||
@@ -315,32 +315,48 @@
|
||||
:valid-trimmed-client-ids trimmed-clients
|
||||
:first-client-id (first valid-clients)
|
||||
:clients-trimmed? (not= (count trimmed-clients) (count valid-clients)))))))
|
||||
|
||||
(defn wrap-dev-login [handler]
|
||||
(fn [request]
|
||||
(if (and (= "/dev-login" (:uri request))
|
||||
(some-> env :base-url (.contains "localhost")))
|
||||
(let [identity {:user "Dev User"
|
||||
:user/name "Dev User"
|
||||
:user/role "admin"
|
||||
:db/id 0}]
|
||||
{:status 200
|
||||
:headers {"Content-Type" "text/html"}
|
||||
:body "<p>Logged in as Dev User!</p><a href='/dashboard'>Continue to dashboard</a>"
|
||||
:session {:identity identity
|
||||
:version session-version/current-session-version}})
|
||||
(handler request))))
|
||||
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
||||
(defonce app
|
||||
(-> route-handler
|
||||
(wrap-hx-current-url-params)
|
||||
(wrap-guess-route)
|
||||
(wrap-logging)
|
||||
(wrap-trim-clients)
|
||||
(wrap-hydrate-clients)
|
||||
(wrap-store-client-in-session)
|
||||
(wrap-gunzip-jwt)
|
||||
(wrap-authorization auth-backend)
|
||||
(wrap-authentication auth-backend
|
||||
(session-backend {:authfn (fn [auth]
|
||||
(dissoc auth :exp))}))
|
||||
(-> route-handler
|
||||
(wrap-hx-current-url-params)
|
||||
(wrap-guess-route)
|
||||
(wrap-logging)
|
||||
(wrap-trim-clients)
|
||||
(wrap-hydrate-clients)
|
||||
(wrap-store-client-in-session)
|
||||
(wrap-gunzip-jwt)
|
||||
(wrap-dev-login)
|
||||
(wrap-authorization auth-backend)
|
||||
(wrap-authentication auth-backend
|
||||
(session-backend {:authfn (fn [auth]
|
||||
(dissoc auth :exp))}))
|
||||
|
||||
#_(wrap-pprint-session)
|
||||
#_(wrap-pprint-session)
|
||||
|
||||
(session-version/wrap-session-version)
|
||||
(wrap-idle-session-timeout)
|
||||
(wrap-session {:store (cookie-store
|
||||
{:key
|
||||
(byte-array
|
||||
[42, 52, -31, 101, -126, -33, -118, -69, -82, -59, -15, -69, -38, 103, -102, -1])})})
|
||||
(session-version/wrap-session-version)
|
||||
(wrap-idle-session-timeout)
|
||||
(wrap-session {:store (cookie-store
|
||||
{:key
|
||||
(byte-array
|
||||
[42, 52, -31, 101, -126, -33, -118, -69, -82, -59, -15, -69, -38, 103, -102, -1])})})
|
||||
|
||||
#_(wrap-reload)
|
||||
(wrap-params)
|
||||
(mp/wrap-multipart-params)
|
||||
(wrap-edn-params)
|
||||
(wrap-error)))
|
||||
#_(wrap-reload)
|
||||
(wrap-params)
|
||||
(mp/wrap-multipart-params)
|
||||
(wrap-edn-params)
|
||||
(wrap-error)))
|
||||
|
||||
@@ -114,27 +114,17 @@
|
||||
{}
|
||||
line-items)
|
||||
total-line-amount (reduce + 0.0 (vals expense-accounts))
|
||||
accounts (if (zero? total-line-amount)
|
||||
[]
|
||||
(vec (for [[account amount] expense-accounts]
|
||||
(let [ratio (/ amount total-line-amount)
|
||||
cents (int (Math/round (* ratio abs-total 100)))]
|
||||
#:invoice-expense-account {:db/id (random-tempid)
|
||||
:account account
|
||||
:location (:invoice/location invoice)
|
||||
:amount (* 0.01 cents)}))))
|
||||
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 (- abs-total
|
||||
(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)))
|
||||
[])]
|
||||
leftover (- abs-total total-line-amount)
|
||||
food-cost-account (get-line-account nil)
|
||||
adjusted-accounts (if (zero? leftover)
|
||||
expense-accounts
|
||||
(update expense-accounts food-cost-account (fnil + 0.0) leftover))
|
||||
accounts (vec (for [[account amount] adjusted-accounts]
|
||||
#:invoice-expense-account {:db/id (random-tempid)
|
||||
:account account
|
||||
:location (:invoice/location invoice)
|
||||
:amount (with-precision 2
|
||||
(double (.setScale (bigdec amount) 2 java.math.RoundingMode/HALF_UP)))}))]
|
||||
(dissoc (assoc invoice :invoice/expense-accounts accounts) :line-items))))
|
||||
|
||||
(defn maybe-code-line-items [invoice]
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
|
||||
(defn invoke-glimpse2 [f]
|
||||
(let [result (slurp (:payload (lambda/invoke {:client-config {:request-timeout 120000
|
||||
:socket-timeout 120000}} {:function-name "glimpse2" :payload
|
||||
:socket-timeout 120000}} {:function-name "glimpse2-test" :payload
|
||||
(json/write-str
|
||||
(alog/peek ::x {"url" (str "https://" "data.prod.app.integreatconsult.com" "/" f)}))})))]
|
||||
|
||||
|
||||
@@ -62,7 +62,9 @@
|
||||
(.setHandler server stats-handler))
|
||||
(.setStopAtShutdown server true))
|
||||
|
||||
(mount/defstate port :start (Integer/parseInt (str (or (env :port) "3000"))))
|
||||
(def ^:dynamic *http-port-override* nil)
|
||||
|
||||
(mount/defstate port :start (Integer/parseInt (str (or *http-port-override* (env :port) "3000"))))
|
||||
|
||||
(mount/defstate jetty
|
||||
:start (run-jetty app {:port port
|
||||
@@ -82,7 +84,7 @@
|
||||
(statsd/gauge "requests.5xx" (double (.getResponses5xx (.getHandler jetty))))
|
||||
(.statsReset (.getHandler jetty))
|
||||
(catch Exception e
|
||||
(alog/warn ::cant-collect-stats :error e))))
|
||||
(alog/warn ::cant-collect-stats :error e))))
|
||||
|
||||
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
||||
(mount/defstate jetty-stats
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
(ns auto-ap.ssr.auth
|
||||
(:require
|
||||
[auto-ap.session-version :as session-version]
|
||||
[auto-ap.ssr.components :as com]
|
||||
[auto-ap.ssr.hx :as hx]
|
||||
[auto-ap.ssr.svg :as svg]
|
||||
[auto-ap.ssr.ui :refer [base-page]]
|
||||
[buddy.sign.jwt :as jwt]
|
||||
[config.core :refer [env]]
|
||||
[hiccup2.core :as hiccup]
|
||||
[hiccup.util :as hu]))
|
||||
|
||||
(defn logout [request]
|
||||
@@ -15,7 +14,7 @@
|
||||
:session {}})
|
||||
|
||||
(defn impersonate [request]
|
||||
{:status 200
|
||||
{:status 200
|
||||
:session {:identity (dissoc (jwt/unsign (get-in request [:query-params "jwt"])
|
||||
(:jwt-secret env)
|
||||
{:alg :hs512})
|
||||
@@ -37,68 +36,73 @@
|
||||
"scope" "https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile"}
|
||||
next (assoc "state" (hu/url-encode next))))))))
|
||||
|
||||
(defn- login-page [contents]
|
||||
{:status 200
|
||||
:headers {"Content-Type" "text/html"}
|
||||
:body (str "<!DOCTYPE html>"
|
||||
(hiccup/html
|
||||
[:html
|
||||
[:head
|
||||
[:meta {:charset "utf-8"}]
|
||||
[:meta {:name "viewport" :content "width=device-width, initial-scale=1"}]
|
||||
[:title "Integreat · Sign In"]
|
||||
[:link {:rel "icon" :type "image/png" :href "/favicon.png"}]
|
||||
[:link {:rel "stylesheet" :href "/output.css"}]
|
||||
[:script {:defer true :src "https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"}]
|
||||
[:style
|
||||
"body{background:linear-gradient(160deg,#79b52e 0%,#009cea 100%);min-height:100vh}"]]
|
||||
[:body contents]]))})
|
||||
|
||||
(defn- page-contents [request]
|
||||
[:div#app {"@notification.document" "notificationDetails=event.detail.value; showNotification=true"
|
||||
[:div
|
||||
{:x-data (hx/json {:showError false
|
||||
:errorDetails ""})
|
||||
"@htmx:response-error.camel" "errorDetails = $event.detail.xhr.response; showError=true;"}
|
||||
|
||||
:x-data (hx/json {:showError false
|
||||
:errorDetails ""
|
||||
:showNotification false
|
||||
:notificationDetails ""})
|
||||
"@htmx:response-error.camel" "errorDetails = $event.detail.xhr.response; showError=true;"}
|
||||
[:div#app-contents.flex.overflow-hidden
|
||||
[:div#main-content {:class "relative w-full h-full overflow-y-auto px-4 bg-gray-100 dark:bg-gray-900 min-h-content "}
|
||||
[:div#notification-holder
|
||||
[:div.fixed.top-0.right-0.left-0.z-30.mx-auto.max-w-screen-lg.w-screen-lg.my-0.pt-8.rounded-lg {:x-show "showNotification"}
|
||||
[:div.relative
|
||||
[:button.absolute.right-2.top-2.w-6.h-6.z-50.text-blue-400
|
||||
{"@click" "showNotification=false"}
|
||||
svg/filled-x]]
|
||||
[:div.fixed.top-0.left-0.right-0.z-50.mx-auto.max-w-md.w-full.px-4.pt-6
|
||||
{:x-show "showError"
|
||||
"x-transition:enter" "transition duration-200 ease-out"
|
||||
"x-transition:enter-start" "opacity-0 -translate-y-3"
|
||||
"x-transition:enter-end" "opacity-100 translate-y-0"}
|
||||
[:div.relative.bg-white.rounded-xl.shadow-xl.border.border-red-200.p-4
|
||||
[:button.absolute.right-3.top-3.p-1.text-red-400.hover:text-red-600
|
||||
{"@click" "showError=false"}
|
||||
svg/filled-x]
|
||||
[:div.flex.items-start.gap-3
|
||||
[:div.flex-shrink-0.w-5.h-5.text-red-500 svg/alert]
|
||||
[:div.flex-1.min-w-0
|
||||
[:p.text-sm.font-medium.text-gray-900 "Something went wrong"]
|
||||
[:p.text-xs.text-gray-500.mt-0.5
|
||||
"Our team has been notified. Please try again."
|
||||
[:span {:x-data (hx/json {"e" false})}
|
||||
" "
|
||||
[:a.text-xs.underline.cursor-pointer.text-gray-500.hover:text-gray-700
|
||||
{"@click" "e=true"}
|
||||
"Details"]
|
||||
[:pre.text-xs.mt-1.font-mono.text-red-600.bg-red-50.p-2.rounded {:x-show "e" :x-text "errorDetails"}]]]]]]]
|
||||
|
||||
[:div.m-4.overflow-auto.z-30.flex.center-items.justify-center.text-blue-800.bg-blue-50.dark:bg-gray-800.dark:text-blue-400.border-blue-300.rounded-lg.border.max-h-96
|
||||
{:x-show "showNotification"
|
||||
"x-transition:enter" "transition duration-300 transform ease-in-out"
|
||||
"x-transition:enter-start" "opacity-0 translate-y-full"
|
||||
"x-transition:enter-end" "opacity-100 translate-y-0"
|
||||
"x-transition:leave" "transition duration-300 transform ease-in-out"
|
||||
"x-transition:leave-start" "opacity-100 translate-y-0"
|
||||
"x-transition:leave-end" "opacity-0 translate-y-full"}
|
||||
[:div.flex.items-center.justify-center.min-h-screen.px-4
|
||||
[:div.w-full.max-w-lg
|
||||
[:div.flex.flex-col.items-center.mb-10
|
||||
[:img {:src "/img/logo-big.png" :alt "Integreat" :class "h-16 brightness-0 invert"}]]
|
||||
|
||||
[:div {:class "p-4 text-lg w-full" :role "alert"}
|
||||
[:div.text-sm
|
||||
[:pre#notification-details.text-xs {:x-html "notificationDetails"}]]]]]]
|
||||
[:div {:x-show "showError"
|
||||
:x-init ""}
|
||||
[:div.fixed.top-0.right-0.left-0.z-30.mx-auto.max-w-screen-lg.w-screen-lg.my-0.pt-8.rounded-lg
|
||||
[:div.relative
|
||||
[:button.absolute.right-2.top-2.w-6.h-6.z-50.text-red-600
|
||||
{"@click" "showError=false"}
|
||||
svg/filled-x]]
|
||||
[:div.bg-white.rounded-2xl.shadow-2xl.p-10
|
||||
{:style "animation: slideUp 0.4s ease-out forwards; opacity: 0;"}
|
||||
[:div.flex.flex-col.items-center.gap-8
|
||||
[:div.text-center
|
||||
[:h1.text-2xl.font-bold.text-gray-900 "Sign in to Integreat"]
|
||||
[:p.mt-2.text-base.text-gray-500 "Use your Google account to continue"]]
|
||||
|
||||
[:div.m-4.overflow-auto.z-30.flex.center-items.justify-center.text-red-800.bg-red-50.dark:bg-gray-800.dark:text-red-400.border-red-300.rounded-lg.border.max-h-96
|
||||
{:x-show "showError"
|
||||
"x-transition:enter" "transition duration-300"
|
||||
"x-transition:enter-start" "opacity-0"
|
||||
"x-transition:enter-end" "opacity-100"}
|
||||
[:a {:href (login-url (get (:query-params request) "redirect-to"))
|
||||
:class "w-full max-w-xs flex items-center justify-center gap-3 px-6 py-3.5 text-base font-semibold rounded-xl border-2 border-gray-200 text-gray-700 bg-white hover:bg-gray-50 hover:border-gray-300 shadow-md hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-400 transition-all duration-150"}
|
||||
svg/google
|
||||
"Sign in with Google"]]
|
||||
|
||||
[:div {:class "p-4 mb-4 text-lg w-full" :role "alert"}
|
||||
[:div.inline-block.w-8.h-8.mr-2 svg/alert]
|
||||
[:span.font-medium "Oh, drat! An unexpected error has occurred."]
|
||||
[:div.text-sm {:x-data (hx/json {"expandError" false})}
|
||||
[:p "Integreat staff have been notified and are looking into it. "]
|
||||
[:p "To see error details, " [:a.underline.cursor-pointer {"@click" "expandError=true"} "click here"] "."]
|
||||
[:pre#error-details.text-xs {:x-show "expandError" :x-text "errorDetails"}]]]]]]
|
||||
[:div.p-4.flex.flex-row.justify-center.items-center.h-screen
|
||||
(com/card {:class "animate-slideUp"}
|
||||
|
||||
[:div.p-4
|
||||
[:img {:src "/img/logo-big.png"}]
|
||||
[:div
|
||||
[:a.button.is-large.is-primary {:href (login-url (get (:query-params request) "redirect-to"))} "Login with Google"]]
|
||||
"HELLO"])]]]])
|
||||
[:p.mt-2.text-center.text-xs.text-gray-400
|
||||
"By signing in, you agree to our "
|
||||
[:a.underline.hover:text-gray-600 {:href "/terms"} "Terms of Service"]
|
||||
" and "
|
||||
[:a.underline.hover:text-gray-600 {:href "/privacy"} "Privacy Policy"]]]]]])
|
||||
|
||||
(defn login [request]
|
||||
(base-page
|
||||
request
|
||||
(page-contents request)
|
||||
|
||||
"Dashboard"))
|
||||
(login-page (page-contents request)))
|
||||
54
src/clj/auto_ap/ssr/components/wizard_trial/core.clj
Normal file
54
src/clj/auto_ap/ssr/components/wizard_trial/core.clj
Normal file
@@ -0,0 +1,54 @@
|
||||
(ns auto-ap.ssr.components.wizard-trial.core
|
||||
(:require
|
||||
[auto-ap.ssr.components.wizard-trial.state :as ws]
|
||||
[auto-ap.ssr.utils :refer [html-response main-transformer modal-response]]
|
||||
[malli.core :as mc]
|
||||
[malli.error :as me]))
|
||||
|
||||
(defn render-step
|
||||
"Renders a single step form.
|
||||
step-config is a map with:
|
||||
- :key - step keyword
|
||||
- :render - function taking {:keys [step-data errors request]} returning hiccup
|
||||
- :submit-route - string URL for form POST"
|
||||
[{:keys [wizard-id step-config request errors]}]
|
||||
(let [{:keys [key render submit-route]} step-config
|
||||
wizard (ws/get-wizard wizard-id)
|
||||
step-data (get-in wizard [:data key] {})]
|
||||
[:form {:hx-post submit-route
|
||||
:hx-target "this"}
|
||||
[:input {:type "hidden" :name "wizard-id" :value wizard-id}]
|
||||
[:input {:type "hidden" :name "step-key" :value (name key)}]
|
||||
(render {:step-data step-data :errors errors :request request})]))
|
||||
|
||||
(defn handle-submit
|
||||
"Handles step submission.
|
||||
Validates step data against schema.
|
||||
If valid: saves to session and calls done-fn.
|
||||
If invalid: re-renders step with errors."
|
||||
[step-config request]
|
||||
(let [{:keys [form-params]} request
|
||||
wizard-id (get form-params "wizard-id")
|
||||
step-key (keyword (get form-params "step-key"))
|
||||
fields (:fields step-config)
|
||||
step-data (reduce (fn [acc field]
|
||||
(if-let [v (get form-params (name field))]
|
||||
(assoc acc field v)
|
||||
acc))
|
||||
{}
|
||||
fields)
|
||||
schema (:schema step-config)
|
||||
decoded (mc/decode schema step-data main-transformer)
|
||||
valid? (mc/validate schema decoded)]
|
||||
(if valid?
|
||||
(do
|
||||
(ws/update-step! wizard-id step-key decoded)
|
||||
(let [all-data (ws/get-all-data wizard-id)]
|
||||
(ws/destroy! wizard-id)
|
||||
((:done-fn step-config) all-data request)))
|
||||
(let [errors (me/humanize (mc/explain schema decoded))]
|
||||
(modal-response
|
||||
(render-step {:wizard-id wizard-id
|
||||
:step-config step-config
|
||||
:request request
|
||||
:errors errors}))))))
|
||||
35
src/clj/auto_ap/ssr/components/wizard_trial/state.clj
Normal file
35
src/clj/auto_ap/ssr/components/wizard_trial/state.clj
Normal file
@@ -0,0 +1,35 @@
|
||||
(ns auto-ap.ssr.components.wizard-trial.state)
|
||||
|
||||
(defonce ^:private store (atom {}))
|
||||
|
||||
(defn create!
|
||||
"Creates new wizard session with initial data. Returns wizard-id string."
|
||||
[initial-data]
|
||||
(let [id (str (java.util.UUID/randomUUID))]
|
||||
(swap! store assoc id {:data initial-data
|
||||
:created-at (java.util.Date.)})
|
||||
id))
|
||||
|
||||
(defn get-wizard
|
||||
"Retrieves wizard data by id. Returns nil if not found."
|
||||
[id]
|
||||
(get @store id))
|
||||
|
||||
(defn update-step!
|
||||
"Merges step data into wizard session under step-key."
|
||||
[id step-key step-data]
|
||||
(swap! store update-in [id :data step-key] merge step-data))
|
||||
|
||||
(defn get-all-data
|
||||
"Returns merged data from all steps for final submission."
|
||||
[id]
|
||||
(when-let [wizard (get-wizard id)]
|
||||
(let [data (:data wizard)]
|
||||
(apply merge
|
||||
(into {} (remove (comp map? val) data))
|
||||
(filter map? (vals data))))))
|
||||
|
||||
(defn destroy!
|
||||
"Removes wizard session."
|
||||
[id]
|
||||
(swap! store dissoc id))
|
||||
@@ -56,7 +56,6 @@
|
||||
|
||||
(defn field-errors
|
||||
([]
|
||||
(println "CURRENT IS" *current*)
|
||||
(field-errors *current*))
|
||||
([cursor]
|
||||
(get-in *form-errors* (cursor/path cursor))))
|
||||
|
||||
@@ -83,11 +83,11 @@
|
||||
[:line {:stroke "currentColor", :fill "none", :stroke-linejoin "round", :y1 "16.22", :stroke-linecap "round", :stroke-width "1.5px", :x1 "16.221", :y2 "23.25", :x2 "23.25"}]])
|
||||
|
||||
(def moon
|
||||
[:svg {:id "theme-toggle-dark-icon", :fill "currentColor", :viewbox "0 0 20 20", :xmlns "http://www.w3.org/2000/svg"}
|
||||
[:svg {:id "theme-toggle-dark-icon", :fill "currentColor", :viewbox "0 0 20 20", :xmlns "http://www.w3.org/2000/svg"}
|
||||
[:path {:d "M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"}]])
|
||||
|
||||
(def sun
|
||||
[:svg {:id "theme-toggle-light-icon", :fill "currentColor", :viewbox "0 0 20 20", :xmlns "http://www.w3.org/2000/svg"}
|
||||
[:svg {:id "theme-toggle-light-icon", :fill "currentColor", :viewbox "0 0 20 20", :xmlns "http://www.w3.org/2000/svg"}
|
||||
[:path {:d "M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z", :fill-rule "evenodd", :clip-rule "evenodd"}]])
|
||||
|
||||
(def home
|
||||
@@ -157,23 +157,23 @@
|
||||
[:defs]
|
||||
[:title "navigation-next"]
|
||||
[:path
|
||||
{:d "M23,9.5H12.387a4,4,0,0,0-4,4v2",
|
||||
:fill "none",
|
||||
:stroke "currentColor",
|
||||
:stroke-linecap "round",
|
||||
{:d "M23,9.5H12.387a4,4,0,0,0-4,4v2",
|
||||
:fill "none",
|
||||
:stroke "currentColor",
|
||||
:stroke-linecap "round",
|
||||
:stroke-linejoin "round"}]
|
||||
[:polyline
|
||||
{:points "19 13.498 23 9.498 19 5.498",
|
||||
:fill "none",
|
||||
:stroke "currentColor",
|
||||
:stroke-linecap "round",
|
||||
{:points "19 13.498 23 9.498 19 5.498",
|
||||
:fill "none",
|
||||
:stroke "currentColor",
|
||||
:stroke-linecap "round",
|
||||
:stroke-linejoin "round"}]
|
||||
[:path
|
||||
{:d
|
||||
"M14.387,13v5.5a1,1,0,0,1-1,1h-12a1,1,0,0,1-1-1V6.5a1,1,0,0,1,1-1h12a1,1,0,0,1,1,1V7",
|
||||
:fill "none",
|
||||
:stroke "currentColor",
|
||||
:stroke-linecap "round",
|
||||
:fill "none",
|
||||
:stroke "currentColor",
|
||||
:stroke-linecap "round",
|
||||
:stroke-linejoin "round"}]])
|
||||
(def play
|
||||
[:svg {:xmlns "http://www.w3.org/2000/svg", :viewbox "-0.5 -0.5 24 24"}
|
||||
@@ -187,26 +187,26 @@
|
||||
[:defs]
|
||||
[:title "pencil"]
|
||||
[:rect
|
||||
{:y "1.09",
|
||||
:stroke "currentColor",
|
||||
:transform "translate(11.889 -5.238) rotate(45)",
|
||||
:fill "none",
|
||||
{:y "1.09",
|
||||
:stroke "currentColor",
|
||||
:transform "translate(11.889 -5.238) rotate(45)",
|
||||
:fill "none",
|
||||
:stroke-linejoin "round",
|
||||
:width "6",
|
||||
:stroke-linecap "round",
|
||||
:x "9.268",
|
||||
:height "21.284"}]
|
||||
:width "6",
|
||||
:stroke-linecap "round",
|
||||
:x "9.268",
|
||||
:height "21.284"}]
|
||||
[:polygon
|
||||
{:points "2.621 17.136 0.5 23.5 6.864 21.379 2.621 17.136",
|
||||
:fill "none",
|
||||
:stroke "currentColor",
|
||||
:stroke-linecap "round",
|
||||
{:points "2.621 17.136 0.5 23.5 6.864 21.379 2.621 17.136",
|
||||
:fill "none",
|
||||
:stroke "currentColor",
|
||||
:stroke-linecap "round",
|
||||
:stroke-linejoin "round"}]
|
||||
[:path
|
||||
{:d "M21.914,6.328,17.672,2.086l.707-.707a3,3,0,0,1,4.242,4.242Z",
|
||||
:fill "none",
|
||||
:stroke "currentColor",
|
||||
:stroke-linecap "round",
|
||||
{:d "M21.914,6.328,17.672,2.086l.707-.707a3,3,0,0,1,4.242,4.242Z",
|
||||
:fill "none",
|
||||
:stroke "currentColor",
|
||||
:stroke-linecap "round",
|
||||
:stroke-linejoin "round"}]])
|
||||
|
||||
(def dollar-tag
|
||||
@@ -231,15 +231,15 @@
|
||||
[:path
|
||||
{:d
|
||||
"M5.5,11.5c-.275,0-.341.159-.146.354l6.292,6.293a.5.5,0,0,0,.709,0l6.311-6.275c.2-.193.13-.353-.145-.355L15.5,11.5V1.5a1,1,0,0,0-1-1h-5a1,1,0,0,0-1,1V11a.5.5,0,0,1-.5.5Z",
|
||||
:fill "none",
|
||||
:stroke "currentColor",
|
||||
:stroke-linecap "round",
|
||||
:fill "none",
|
||||
:stroke "currentColor",
|
||||
:stroke-linecap "round",
|
||||
:stroke-linejoin "round"}]
|
||||
[:path
|
||||
{:d "M23.5,18.5v4a1,1,0,0,1-1,1H1.5a1,1,0,0,1-1-1v-4",
|
||||
:fill "none",
|
||||
:stroke "currentColor",
|
||||
:stroke-linecap "round",
|
||||
{:d "M23.5,18.5v4a1,1,0,0,1-1,1H1.5a1,1,0,0,1-1-1v-4",
|
||||
:fill "none",
|
||||
:stroke "currentColor",
|
||||
:stroke-linecap "round",
|
||||
:stroke-linejoin "round"}]])
|
||||
|
||||
(def trash
|
||||
@@ -522,3 +522,10 @@
|
||||
[:path {:d "m12 16 0 3", :fill "none", :stroke "currentColor", :stroke-linecap "round", :stroke-linejoin "round", :stroke-width "1"}]
|
||||
[:path {:d "M4.5 9.5h15s1 0 1 1v12s0 1 -1 1h-15s-1 0 -1 -1v-12s0 -1 1 -1", :fill "none", :stroke "currentColor", :stroke-linecap "round", :stroke-linejoin "round", :stroke-width "1"}]
|
||||
[:path {:d "M6.5 6a5.5 5.5 0 0 1 11 0v3.5h-11Z", :fill "none", :stroke "currentColor", :stroke-linecap "round", :stroke-linejoin "round", :stroke-width "1"}]])
|
||||
|
||||
(def google
|
||||
[:svg {:viewbox "0 0 24 24", :width "20", :height "20", :xmlns "http://www.w3.org/2000/svg"}
|
||||
[:path {:fill "#4285F4" :d "M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"}]
|
||||
[:path {:fill "#34A853" :d "M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"}]
|
||||
[:path {:fill "#FBBC05" :d "M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"}]
|
||||
[:path {:fill "#EA4335" :d "M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"}]])
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
[auto-ap.ssr.grid-page-helper :as helper :refer [wrap-apply-sort]]
|
||||
[auto-ap.ssr.ledger :refer [wrap-ensure-bank-account-belongs]]
|
||||
[auto-ap.ssr.transaction.bulk-code :as bulk-code :refer [all-ids-not-locked]]
|
||||
[auto-ap.ssr.transaction.bulk-code-trial :as bulk-code-trial]
|
||||
[auto-ap.ssr.transaction.common :refer [bank-account-filter* fetch-ids
|
||||
grid-page query-schema
|
||||
wrap-status-from-source]]
|
||||
@@ -53,7 +54,7 @@
|
||||
all-selected
|
||||
(:ids (fetch-ids (dc/db conn) (-> request
|
||||
(assoc-in [:form-params :start] 0)
|
||||
(assoc-in [:form-params :per-page] 250))))
|
||||
(assoc-in [:form-params :per-page] 250))))
|
||||
:else
|
||||
selected)
|
||||
all-ids (all-ids-not-locked ids)
|
||||
@@ -101,16 +102,18 @@
|
||||
(def key->handler
|
||||
(merge edit/key->handler
|
||||
bulk-code/key->handler
|
||||
{::route/bulk-code-trial bulk-code-trial/open-trial
|
||||
::route/bulk-code-trial-submit bulk-code-trial/submit-trial}
|
||||
(apply-middleware-to-all-handlers
|
||||
{::route/page page
|
||||
{::route/page page
|
||||
::route/approved-page (-> page (wrap-implied-route-param :status :transaction-approval-status/approved))
|
||||
::route/unapproved-page (-> page (wrap-implied-route-param :status :transaction-approval-status/unapproved))
|
||||
::route/requires-feedback-page (-> page (wrap-implied-route-param :status :transaction-approval-status/requires-feedback))
|
||||
::route/table table
|
||||
::route/csv csv
|
||||
::route/table table
|
||||
::route/csv csv
|
||||
::route/bank-account-filter bank-account-filter
|
||||
::route/bulk-delete (-> bulk-delete
|
||||
(wrap-schema-enforce :form-schema query-schema))}
|
||||
::route/bulk-delete (-> bulk-delete
|
||||
(wrap-schema-enforce :form-schema query-schema))}
|
||||
(fn [h]
|
||||
(-> h
|
||||
(wrap-copy-qp-pqp)
|
||||
|
||||
@@ -185,25 +185,28 @@
|
||||
:hx-target "#account-entries"
|
||||
:hx-swap "innerHTML"
|
||||
:hx-include "closest form"}
|
||||
(fc/with-field :vendor
|
||||
(com/validated-field {:label "Vendor"
|
||||
:errors (fc/field-errors)}
|
||||
(com/typeahead {:name (fc/field-name)
|
||||
:placeholder "Search for vendor..."
|
||||
:url (bidi/path-for ssr-routes/only-routes :vendor-search)
|
||||
:content-fn (fn [c] (pull-attr (dc/db conn) :vendor/name c))})))]
|
||||
(fc/with-field :vendor
|
||||
(com/validated-field {:label "Vendor"
|
||||
:errors (fc/field-errors)}
|
||||
(com/typeahead {:name (fc/field-name)
|
||||
:placeholder "Search for vendor..."
|
||||
: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))})))]
|
||||
|
||||
;; Status field
|
||||
[:div
|
||||
(fc/with-field :approval-status
|
||||
(com/validated-field {:label "Status"
|
||||
:errors (fc/field-errors)}
|
||||
(com/select {:name (fc/field-name)
|
||||
:options [["" "No Change"]
|
||||
["approved" "Approved"]
|
||||
["unapproved" "Unapproved"]
|
||||
["suppressed" "Suppressed"]
|
||||
["requires_feedback" "Requires Feedback"]]})))]
|
||||
[:div
|
||||
(fc/with-field :approval-status
|
||||
(com/validated-field {:label "Status"
|
||||
:errors (fc/field-errors)}
|
||||
(com/select {:name (fc/field-name)
|
||||
:value (some-> (fc/field-value)
|
||||
name)
|
||||
:options [["" "No Change"]
|
||||
["approved" "Approved"]
|
||||
["unapproved" "Unapproved"]
|
||||
["suppressed" "Suppressed"]
|
||||
["requires_feedback" "Requires Feedback"]]})))]
|
||||
|
||||
;; Accounts section
|
||||
[:div.col-span-2.pt-4
|
||||
@@ -343,25 +346,26 @@
|
||||
:percentage 1.0})
|
||||
|
||||
(defn- render-accounts-section [request]
|
||||
(let [step-params (:step-params (:multi-form-state request))]
|
||||
(let [multi-form-state (:multi-form-state request)]
|
||||
(html-response
|
||||
[:div
|
||||
(fc/start-form step-params
|
||||
(fc/start-form multi-form-state
|
||||
(when (:form-errors request) {:step-params (:form-errors request)})
|
||||
(fc/with-field :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 #(transaction-account-row* {:value %}))
|
||||
(com/data-grid-new-row {:colspan 4
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes
|
||||
::route/bulk-code-new-account)
|
||||
:row-offset 0
|
||||
:index (count (fc/field-value))}
|
||||
"New account")))))])))
|
||||
(fc/with-field :step-params
|
||||
(fc/with-field :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 #(transaction-account-row* {:value %}))
|
||||
(com/data-grid-new-row {:colspan 4
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes
|
||||
::route/bulk-code-new-account)
|
||||
:row-offset 0
|
||||
:index (count (fc/field-value))}
|
||||
"New account"))))))])))
|
||||
|
||||
(defn- single-client-id [request]
|
||||
"Returns the client ID if the user has access to exactly one client, nil otherwise."
|
||||
@@ -373,10 +377,8 @@
|
||||
step-params (:step-params (:multi-form-state request))
|
||||
client-id (single-client-id request)
|
||||
vendor-id (or (:vendor step-params) (:vendor snapshot))
|
||||
_ (println ::VENDOR-CHANGED :client-id client-id :vendor-id vendor-id :accounts-empty (empty? (:accounts step-params)))
|
||||
updated-step-params (if (and (empty? (:accounts step-params))
|
||||
vendor-id
|
||||
client-id)
|
||||
vendor-id)
|
||||
(if-let [default-account (vendor-default-account vendor-id client-id)]
|
||||
(assoc step-params :accounts [(build-default-account-row default-account)])
|
||||
step-params)
|
||||
|
||||
172
src/clj/auto_ap/ssr/transaction/bulk_code_trial.clj
Normal file
172
src/clj/auto_ap/ssr/transaction/bulk_code_trial.clj
Normal file
@@ -0,0 +1,172 @@
|
||||
(ns auto-ap.ssr.transaction.bulk-code-trial
|
||||
(:require
|
||||
[auto-ap.datomic :refer [conn pull-attr pull-many]]
|
||||
[auto-ap.ssr.components :as com]
|
||||
[auto-ap.ssr.components.wizard-trial.core :as wt]
|
||||
[auto-ap.ssr.components.wizard-trial.state :as ws]
|
||||
[auto-ap.ssr.transaction.edit :as edit :refer [account-typeahead* location-select*]]
|
||||
[auto-ap.ssr.utils :refer [html-response modal-response percentage]]
|
||||
[auto-ap.ssr.svg :as svg]
|
||||
[bidi.bidi :as bidi]
|
||||
[clojure.string :as str]
|
||||
[datomic.api :as dc]
|
||||
[malli.core :as mc]))
|
||||
|
||||
(def bulk-code-schema
|
||||
(mc/schema
|
||||
[:map
|
||||
[:vendor {:optional true} [:maybe int?]]
|
||||
[:approval-status {:optional true} [:maybe keyword?]]
|
||||
[:accounts {:optional true}
|
||||
[:vector {:coerce? true}
|
||||
[:map
|
||||
[:account int?]
|
||||
[:location :string]
|
||||
[:percentage percentage]]]]]))
|
||||
|
||||
(defn- account-row
|
||||
"Renders a single account row with typeahead, location select, percentage, and delete button."
|
||||
[index {:keys [account location percentage]} errors request]
|
||||
(let [account-name (str "accounts[" index "][account]")
|
||||
location-name (str "accounts[" index "][location]")
|
||||
percentage-name (str "accounts[" index "][percentage]")
|
||||
row-errors (get errors index)
|
||||
client-id (-> request :clients first :db/id)
|
||||
account-location (try
|
||||
(when (nat-int? account)
|
||||
(:account/location (dc/pull (dc/db conn) '[:account/location] account)))
|
||||
(catch Exception e nil))
|
||||
client-locations (try
|
||||
(pull-attr (dc/db conn) :client/locations client-id)
|
||||
(catch Exception e nil))]
|
||||
[:tr
|
||||
[:td (com/validated-field
|
||||
{:errors (get row-errors :account)}
|
||||
(account-typeahead* {:value account
|
||||
:client-id client-id
|
||||
:name account-name}))]
|
||||
[:td (com/validated-field
|
||||
{:errors (get row-errors :location)}
|
||||
(location-select* {:name location-name
|
||||
:account-location account-location
|
||||
:client-locations client-locations
|
||||
:value location}))]
|
||||
[:td (com/validated-field
|
||||
{:errors (get row-errors :percentage)}
|
||||
(com/money-input {:name percentage-name
|
||||
:value (some-> percentage (* 100) long)
|
||||
:class "w-16"}))]
|
||||
[:td (com/a-icon-button {"@click.prevent.stop" "this.closest('tr').remove()"}
|
||||
svg/x)]]))
|
||||
|
||||
(defn render-bulk-code-form
|
||||
"Renders the bulk code form inside a modal card structure.
|
||||
Takes {:keys [step-data errors request]}"
|
||||
[{:keys [step-data errors request]}]
|
||||
(let [vendor (get step-data :vendor)
|
||||
approval-status (get step-data :approval-status)
|
||||
accounts (get step-data :accounts
|
||||
[{:account nil :location "Shared" :percentage 0.5}
|
||||
{:account nil :location "Shared" :percentage 0.5}
|
||||
{:account nil :location "" :percentage nil}])
|
||||
selected-ids [] ; Would come from request in real implementation
|
||||
all-ids []]
|
||||
(com/modal-card-advanced
|
||||
{:class "md:w-[750px] md:h-[600px] w-full h-full"}
|
||||
(com/modal-header {}
|
||||
[:div.p-2 "Bulk editing " (count all-ids) " transactions"])
|
||||
(com/modal-body {}
|
||||
[:div.space-y-4.p-4
|
||||
[:div.grid.grid-cols-2.gap-4
|
||||
;; Vendor field
|
||||
[:div
|
||||
(com/validated-field
|
||||
{:label "Vendor"
|
||||
:errors (get errors :vendor)}
|
||||
(com/typeahead {:name "vendor"
|
||||
:placeholder "Search for vendor..."
|
||||
:url (bidi/path-for auto-ap.ssr-routes/only-routes :vendor-search)
|
||||
:value vendor
|
||||
:content-fn (fn [c]
|
||||
(try
|
||||
(pull-attr (dc/db conn) :vendor/name c)
|
||||
(catch Exception e
|
||||
"Vendor")))}))]
|
||||
|
||||
;; Approval status field
|
||||
[:div
|
||||
(com/validated-field
|
||||
{:label "Status"
|
||||
:errors (get errors :approval-status)}
|
||||
(com/select {:name "approval-status"
|
||||
:value (some-> approval-status name)
|
||||
:allow-blank? true
|
||||
:options [["" "No Change"]
|
||||
["approved" "Approved"]
|
||||
["unapproved" "Unapproved"]
|
||||
["suppressed" "Suppressed"]
|
||||
["requires_feedback" "Requires Feedback"]]}))]]
|
||||
|
||||
;; Accounts section
|
||||
[:div.col-span-2.pt-4
|
||||
[:h3.text-lg.font-medium.mb-3 "Expense Accounts"]
|
||||
(com/validated-field
|
||||
{:errors (get errors :accounts)}
|
||||
[:table.w-full.text-sm.text-left
|
||||
[:thead
|
||||
[:tr
|
||||
[:th "Account"]
|
||||
[:th {:class "w-32"} "Location"]
|
||||
[:th {:class "w-16"} "%"]
|
||||
[:th {:class "w-16"}]]]
|
||||
[:tbody
|
||||
(map-indexed
|
||||
(fn [idx account]
|
||||
(account-row idx account (get errors :accounts) request))
|
||||
accounts)]])]
|
||||
|
||||
;; Add new account button
|
||||
[:div
|
||||
(com/button {:color :secondary
|
||||
:type "button"
|
||||
:class "mt-2"
|
||||
"@click" (str "
|
||||
const tbody = this.closest('form').querySelector('tbody');
|
||||
const newRow = document.createElement('tr');
|
||||
newRow.innerHTML = `
|
||||
<td><input type='text' name='accounts[" (count accounts) "][account]' placeholder='Account ID' class='w-full'></td>
|
||||
<td><input type='text' name='accounts[" (count accounts) "][location]' value='Shared' class='w-full'></td>
|
||||
<td><input type='number' name='accounts[" (count accounts) "][percentage]' class='w-16'></td>
|
||||
<td><button type='button' onclick='this.closest(\"tr\").remove()'>×</button></td>
|
||||
`;
|
||||
tbody.appendChild(newRow);
|
||||
")}
|
||||
"New account")]])
|
||||
|
||||
(com/modal-footer {}
|
||||
[:div.flex.justify-end
|
||||
[:div.flex.items-baseline.gap-x-4
|
||||
(com/form-errors {:errors (seq errors)})
|
||||
(com/button {:color :primary :type "submit" :class "w-32"} "Save")]]))))
|
||||
|
||||
(def trial-step-config
|
||||
{:key :bulk-code
|
||||
:schema bulk-code-schema
|
||||
:fields [:vendor :approval-status :accounts]
|
||||
:render render-bulk-code-form
|
||||
:submit-route "/transaction/bulk-code-trial"
|
||||
:done-fn (fn [data request]
|
||||
(modal-response
|
||||
(com/success-modal {:title "Transactions Coded (Trial)"}
|
||||
[:p "This was a trial run. No transactions were actually modified."])
|
||||
:headers {"hx-trigger" "refreshTable"}))})
|
||||
|
||||
(defn open-trial [request]
|
||||
(let [wizard-id (ws/create! {})]
|
||||
(modal-response
|
||||
(wt/render-step {:wizard-id wizard-id
|
||||
:step-config trial-step-config
|
||||
:request request}))))
|
||||
|
||||
(defn submit-trial [request]
|
||||
(wt/handle-submit trial-step-config request))
|
||||
@@ -440,6 +440,12 @@
|
||||
"x-bind:hx-vals" "JSON.stringify({selected: $data.selected, 'all-selected': $data.all_selected})"
|
||||
"hx-include" "#transaction-filters"}
|
||||
"Code")
|
||||
(com/button {:color :secondary
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes ::route/bulk-code-trial)
|
||||
:hx-target "#modal-holder"
|
||||
"x-bind:hx-vals" "JSON.stringify({selected: $data.selected, 'all-selected': $data.all_selected})"
|
||||
"hx-include" "#transaction-filters"}
|
||||
"Code (Trial)")
|
||||
(com/button {:color :primary
|
||||
:hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-delete)
|
||||
:hx-target "#modal-holder"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
(ns auto-ap.ssr.transaction.edit
|
||||
(:require
|
||||
[auto-ap.cursor :as cursor]
|
||||
[auto-ap.datomic
|
||||
:refer [audit-transact conn pull-attr pull-ref]]
|
||||
[auto-ap.datomic.accounts :as d-accounts]
|
||||
@@ -179,6 +180,82 @@
|
||||
(:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read value)
|
||||
client-id)))})])
|
||||
|
||||
(defn simple-mode-fields*
|
||||
"Renders the simple-mode account + location row and the toggle-to-advanced link.
|
||||
Must be called within a fc/start-form + fc/with-field :step-params context.
|
||||
Caller must establish Alpine x-data with simpleAccountId in scope."
|
||||
[request]
|
||||
(let [snapshot (-> request :multi-form-state :snapshot)
|
||||
step-params (-> request :multi-form-state :step-params)
|
||||
client-id (or (-> request :entity :transaction/client :db/id)
|
||||
(:transaction/client snapshot))
|
||||
existing-row (first (or (seq (:transaction/accounts step-params))
|
||||
(seq (:transaction/accounts snapshot))))
|
||||
account-val (let [av (:transaction-account/account existing-row)]
|
||||
(if (map? av) (:db/id av) av))
|
||||
location-val (or (:transaction-account/location existing-row) "Shared")
|
||||
account-id (when (nat-int? account-val)
|
||||
(dc/pull (dc/db conn) '[:account/location] account-val))
|
||||
row-id (or (:db/id existing-row) (str (java.util.UUID/randomUUID)))
|
||||
total (Math/abs (or (-> request :entity :transaction/amount)
|
||||
(:transaction/amount snapshot)
|
||||
0.0))]
|
||||
[:div
|
||||
(fc/with-field :transaction/accounts
|
||||
(fc/with-cursor (let [cur fc/*current*]
|
||||
(if (sequential? @cur)
|
||||
(nth cur 0 nil)
|
||||
(auto_ap.cursor.MapCursor. {} (cursor/state cur) (conj (cursor/path cur) 0))))
|
||||
[:span
|
||||
(fc/with-field :db/id
|
||||
(com/hidden {:name (fc/field-name)
|
||||
:value row-id}))
|
||||
[:div.flex.gap-2.mt-2
|
||||
(fc/with-field :transaction-account/account
|
||||
(com/validated-field
|
||||
{:label "Account"
|
||||
:errors (fc/field-errors)}
|
||||
[:div.w-72
|
||||
(account-typeahead* {:value account-val
|
||||
:client-id client-id
|
||||
:name (fc/field-name)
|
||||
:x-model "simpleAccountId"})]))
|
||||
(fc/with-field :transaction-account/location
|
||||
(com/validated-field
|
||||
{:label "Location"
|
||||
:errors (fc/field-errors)
|
||||
:x-hx-val:account-id "simpleAccountId"
|
||||
:hx-vals (hx/json (cond-> {:name (fc/field-name)}
|
||||
client-id (assoc :client-id client-id)))
|
||||
:x-dispatch:changed "simpleAccountId"
|
||||
: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 account-id)
|
||||
:client-locations (pull-attr (dc/db conn) :client/locations client-id)
|
||||
:value location-val})))
|
||||
(fc/with-field :transaction-account/amount
|
||||
(com/hidden {:name (fc/field-name)
|
||||
:value total}))]]))
|
||||
[:div.mt-1
|
||||
[:a.text-sm.text-blue-600.hover:underline.cursor-pointer
|
||||
{:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-wizard-toggle-mode)
|
||||
:hx-include "closest form"
|
||||
:hx-target "#manual-coding-section"
|
||||
:hx-swap "outerHTML"}
|
||||
"Switch to advanced mode"]]]))
|
||||
|
||||
(defn- manual-mode-initial
|
||||
"Returns :simple or :advanced based on existing account row count."
|
||||
[snapshot]
|
||||
(let [rows (seq (:transaction/accounts snapshot))]
|
||||
(if (and rows (> (count rows) 1))
|
||||
:advanced
|
||||
:simple)))
|
||||
|
||||
(defn transaction-account-row* [{:keys [value client-id amount-mode total]}]
|
||||
(com/data-grid-row
|
||||
(-> {:class "account-row"
|
||||
@@ -420,6 +497,55 @@
|
||||
(format "$%,.2f" total))
|
||||
(com/data-grid-cell {})))))
|
||||
|
||||
(defn manual-coding-section*
|
||||
"Renders the vendor field + account/location section for the manual tab.
|
||||
mode is :simple or :advanced.
|
||||
In simple mode, establishes Alpine x-data with simpleAccountId in scope.
|
||||
Must be called within a fc/start-form + fc/with-field :step-params context."
|
||||
[mode request]
|
||||
(let [snapshot (-> request :multi-form-state :snapshot)
|
||||
step-params (-> request :multi-form-state :step-params)
|
||||
all-accounts (or (seq (:transaction/accounts step-params))
|
||||
(seq (:transaction/accounts snapshot)))
|
||||
row-count (count all-accounts)]
|
||||
[:div#manual-coding-section
|
||||
(com/hidden {:name "mode" :value (name mode)})
|
||||
[:div {:hx-trigger "change"
|
||||
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-vendor-changed)
|
||||
:hx-target "#manual-coding-section"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-include "closest form"}
|
||||
(fc/with-field :transaction/vendor
|
||||
(com/validated-field
|
||||
{:label "Vendor" :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 :vendor-search)
|
||||
:value (fc/field-value)
|
||||
:content-fn (fn [c] (pull-attr (dc/db conn) :vendor/name c))})]))]
|
||||
(if (= mode :simple)
|
||||
[:div {:x-data (hx/json {:simpleAccountId
|
||||
(let [av (-> (first all-accounts) :transaction-account/account)]
|
||||
(if (map? av) (:db/id av) av))})}
|
||||
(simple-mode-fields* request)]
|
||||
[:div
|
||||
(when (<= row-count 1)
|
||||
[:div.mb-2
|
||||
[:a.text-sm.text-blue-600.hover:underline.cursor-pointer
|
||||
{:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-wizard-toggle-mode)
|
||||
:hx-include "closest form"
|
||||
:hx-target "#manual-coding-section"
|
||||
:hx-swap "outerHTML"}
|
||||
"Switch to simple mode"]])
|
||||
(fc/with-field :transaction/accounts
|
||||
(com/validated-field
|
||||
{:errors (fc/field-errors)}
|
||||
[:div#account-grid-body
|
||||
(account-grid-body* request)]))])]))
|
||||
|
||||
(defn toggle-amount-mode [request]
|
||||
(let [snapshot (-> request :multi-form-state :snapshot)
|
||||
old-mode (or (:amount-mode snapshot) "$")
|
||||
@@ -815,7 +941,6 @@
|
||||
":disabled" "!canChange"}
|
||||
"Manual"))]
|
||||
[:div {:x-show "activeForm === 'link-payment'", :x-transition:enter "transition ease-out duration-500", :x-transition:enter-start "opacity-0 transform scale-95", :x-transition:enter-end "opacity-100 transform scale-100"}
|
||||
|
||||
(payment-matches-view request)]
|
||||
[:div {:x-show "activeForm === 'link-unpaid-invoices'", :x-transition:enter "transition ease-out duration-500", :x-transition:enter-start "opacity-0 transform scale-95", :x-transition:enter-end "opacity-100 transform scale-100"}
|
||||
(unpaid-invoices-view request)]
|
||||
@@ -825,27 +950,7 @@
|
||||
(transaction-rules-view request)]
|
||||
[:div {:x-show "activeForm === 'manual'", :x-transition:enter "transition ease-out duration-500", :x-transition:enter-start "opacity-0 transform scale-95", :x-transition:enter-end "opacity-100 transform scale-100"}
|
||||
[:div {}
|
||||
[:div {:hx-trigger "change"
|
||||
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-vendor-changed)
|
||||
:hx-target "#account-grid-body"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-include "closest form"}
|
||||
(fc/with-field :transaction/vendor
|
||||
(com/validated-field
|
||||
{:label "Vendor"
|
||||
: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 :vendor-search)
|
||||
:value (fc/field-value)
|
||||
:content-fn (fn [c] (pull-attr (dc/db conn) :vendor/name c))})]))]
|
||||
|
||||
;; Memo field
|
||||
|
||||
;; Approval status field
|
||||
(manual-coding-section* (manual-mode-initial snapshot) request)
|
||||
(fc/with-field :transaction/approval-status
|
||||
(com/validated-field
|
||||
{:label "Status"
|
||||
@@ -867,12 +972,7 @@
|
||||
(com/button-group-button {"@click" "approvalStatus = 'suppressed'"
|
||||
":class" "{ '!bg-primary-200 text-primary-800': approvalStatus === 'suppressed' }"
|
||||
:class "rounded-r-lg"}
|
||||
"Client Review")]])))
|
||||
(fc/with-field :transaction/accounts
|
||||
(com/validated-field
|
||||
{:errors (fc/field-errors)}
|
||||
[:div#account-grid-body
|
||||
(account-grid-body* request)]))]]]])
|
||||
"Client Review")]])))]]]])
|
||||
:footer
|
||||
(mm/default-step-footer linear-wizard this :validation-route ::route/edit-wizard-navigate
|
||||
:next-button (com/button {:color :primary :x-ref "next" :class "w-32 wizard-save-action"} "Done"))
|
||||
@@ -1329,9 +1429,11 @@
|
||||
(let [multi-form-state (:multi-form-state request)
|
||||
snapshot (:snapshot multi-form-state)
|
||||
step-params (:step-params multi-form-state)
|
||||
mode (keyword (or (:mode step-params) "simple"))
|
||||
client-id (or (:transaction/client snapshot)
|
||||
(-> request :entity :transaction/client :db/id))
|
||||
vendor-id (or (:transaction/vendor step-params) (:transaction/vendor snapshot))
|
||||
vendor-id (or (:transaction/vendor step-params)
|
||||
(:transaction/vendor snapshot))
|
||||
total (Math/abs (or (-> request :entity :transaction/amount)
|
||||
(:transaction/amount snapshot)
|
||||
0.0))
|
||||
@@ -1351,8 +1453,39 @@
|
||||
(assoc-in [:multi-form-state :step-params :transaction/accounts] [new-account])))
|
||||
request)]
|
||||
(html-response
|
||||
[:div#account-grid-body
|
||||
(render-account-grid-body render-request)])))
|
||||
(fc/start-form (:multi-form-state render-request) nil
|
||||
(fc/with-field :step-params
|
||||
(manual-coding-section* mode render-request))))))
|
||||
|
||||
(defn edit-wizard-toggle-mode-handler [request]
|
||||
(let [step-params (-> request :multi-form-state :step-params)
|
||||
snapshot (-> request :multi-form-state :snapshot)
|
||||
current-mode (keyword (or (:mode step-params) "simple"))
|
||||
target-mode (if (= current-mode :simple) :advanced :simple)
|
||||
;; When switching simple→advanced, promote simple-mode values into accounts
|
||||
render-request
|
||||
(if (and (= target-mode :advanced)
|
||||
(= current-mode :simple))
|
||||
;; carry the simple-mode single row into snapshot so the table shows it
|
||||
(let [accounts (or (seq (:transaction/accounts step-params))
|
||||
(seq (:transaction/accounts snapshot)))]
|
||||
(-> request
|
||||
(assoc-in [:multi-form-state :snapshot :transaction/accounts]
|
||||
(vec accounts))
|
||||
(assoc-in [:multi-form-state :step-params :transaction/accounts]
|
||||
(vec accounts))))
|
||||
;; advanced→simple: take first row only
|
||||
(let [first-row (first (or (seq (:transaction/accounts step-params))
|
||||
(seq (:transaction/accounts snapshot))))]
|
||||
(-> request
|
||||
(assoc-in [:multi-form-state :snapshot :transaction/accounts]
|
||||
(if first-row [first-row] []))
|
||||
(assoc-in [:multi-form-state :step-params :transaction/accounts]
|
||||
(if first-row [first-row] [])))))]
|
||||
(html-response
|
||||
(fc/start-form (:multi-form-state render-request) nil
|
||||
(fc/with-field :step-params
|
||||
(manual-coding-section* target-mode render-request))))))
|
||||
|
||||
(def key->handler
|
||||
(apply-middleware-to-all-handlers
|
||||
@@ -1395,6 +1528,10 @@
|
||||
(mm/wrap-wizard edit-wizard)
|
||||
(wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read)
|
||||
(mm/wrap-decode-multi-form-state))
|
||||
::route/edit-wizard-toggle-mode (-> edit-wizard-toggle-mode-handler
|
||||
(mm/wrap-wizard edit-wizard)
|
||||
(wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read)
|
||||
(mm/wrap-decode-multi-form-state))
|
||||
::route/edit-wizard-new-account (->
|
||||
(add-new-entity-handler [:step-params :transaction/accounts]
|
||||
(fn render [cursor request]
|
||||
|
||||
32
src/clj/dev_mcp.clj
Normal file
32
src/clj/dev_mcp.clj
Normal file
@@ -0,0 +1,32 @@
|
||||
(ns dev-mcp
|
||||
"Leiningen task: start nREPL + web server on random ports."
|
||||
(:require [clojure.java.io :as io]
|
||||
[nrepl.server :as nrepl])
|
||||
(:import [java.net ServerSocket]))
|
||||
|
||||
(defn available-port []
|
||||
"Pick a random available TCP port."
|
||||
(with-open [s (ServerSocket. 0)]
|
||||
(.getLocalPort s)))
|
||||
|
||||
(defn- mcp-repl-task [& _args]
|
||||
"Start nREPL server and HTTP server on random ports.
|
||||
|
||||
Writes ports to nrepl-port and .http-port files.
|
||||
Connect with: clj-nrepl-eval -p $(cat nrepl-port)"
|
||||
(let [nrepl-port (available-port)
|
||||
http-port (available-port)]
|
||||
(spit "nrepl-port" (str nrepl-port))
|
||||
(spit ".http-port" (str http-port))
|
||||
(println (format "nREPL port: %d (nrepl-port)" nrepl-port))
|
||||
(println (format "HTTP port: %d (.http-port)" http-port))
|
||||
(nrepl/start-server :port nrepl-port)
|
||||
(require 'user)
|
||||
((resolve 'user/start-dev) http-port)
|
||||
(println "Ready.")
|
||||
@(promise)))
|
||||
|
||||
(defn -main
|
||||
"Entry point for: lein trampoline run -m dev-mcp"
|
||||
[& args]
|
||||
(apply mcp-repl-task args))
|
||||
105
src/clj/user.clj
105
src/clj/user.clj
@@ -84,24 +84,24 @@
|
||||
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
||||
(defn load-accounts [conn]
|
||||
(let [[header & rows] (-> "master-account-list.csv" (io/resource) io/input-stream (BOMInputStream.) (io/reader) csv/read-csv)
|
||||
code->existing-account (by :account/numeric-code (map first (dc/q {:find ['(pull ?e [:account/numeric-code
|
||||
:db/id])]
|
||||
:in ['$]
|
||||
code->existing-account (by :account/numeric-code (map first (dc/q {:find ['(pull ?e [:account/numeric-code
|
||||
:db/id])]
|
||||
:in ['$]
|
||||
:where ['[?e :account/name]]}
|
||||
(dc/db conn))))
|
||||
|
||||
also-merge-txes (fn [also-merge old-account-id]
|
||||
(if old-account-id
|
||||
(let [[sunset-account]
|
||||
(first (dc/q {:find ['?a]
|
||||
:in ['$ '?ac]
|
||||
(first (dc/q {:find ['?a]
|
||||
:in ['$ '?ac]
|
||||
:where ['[?a :account/numeric-code ?ac]]}
|
||||
(dc/db conn) also-merge))]
|
||||
(into (mapv
|
||||
(fn [[entity id _]]
|
||||
[:db/add entity id old-account-id])
|
||||
(dc/q {:find ['?e '?id '?a]
|
||||
:in ['$ '?ac]
|
||||
(dc/q {:find ['?e '?id '?a]
|
||||
:in ['$ '?ac]
|
||||
:where ['[?a :account/numeric-code ?ac]
|
||||
'[?e ?at ?a]
|
||||
'[?at :db/ident ?id]]}
|
||||
@@ -112,7 +112,7 @@
|
||||
|
||||
txes (transduce
|
||||
(comp
|
||||
(map (fn ->map [r]
|
||||
(map (fn ->map [r]
|
||||
(into {} (map vector header r))))
|
||||
(map (fn parse-map [r]
|
||||
{:old-account-id (:db/id (code->existing-account
|
||||
@@ -160,8 +160,8 @@
|
||||
|
||||
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
||||
(defn find-bad-accounts []
|
||||
(set (map second (dc/q {:find ['(pull ?x [*]) '?z]
|
||||
:in ['$]
|
||||
(set (map second (dc/q {:find ['(pull ?x [*]) '?z]
|
||||
:in ['$]
|
||||
:where ['[?e :account/numeric-code ?z]
|
||||
'[(<= ?z 9999)]
|
||||
'[?x ?a ?e]]}
|
||||
@@ -177,8 +177,8 @@
|
||||
[:db/retractEntity old-account-id])))
|
||||
conj
|
||||
[]
|
||||
(dc/q {:find ['?e]
|
||||
:in ['$]
|
||||
(dc/q {:find ['?e]
|
||||
:in ['$]
|
||||
:where ['[?e :account/numeric-code ?z]
|
||||
'[(<= ?z 9999)]]}
|
||||
(dc/db conn)))))
|
||||
@@ -192,27 +192,27 @@
|
||||
(fn [acc [e z]]
|
||||
(update acc z conj e))
|
||||
{}
|
||||
(dc/q {:find ['?e '?z]
|
||||
:in ['$]
|
||||
(dc/q {:find ['?e '?z]
|
||||
:in ['$]
|
||||
:where ['[?e :account/numeric-code ?z]]}
|
||||
(dc/db conn)))))
|
||||
|
||||
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
||||
(defn customize-accounts [customer filename]
|
||||
(let [[_ & rows] (-> filename (io/resource) io/input-stream (BOMInputStream.) (io/reader) csv/read-csv)
|
||||
[client-id] (first (dc/q (-> {:find ['?e]
|
||||
:in ['$ '?z]
|
||||
[client-id] (first (dc/q (-> {:find ['?e]
|
||||
:in ['$ '?z]
|
||||
:where [['?e :client/code '?z]]}
|
||||
(dc/db conn) customer)))
|
||||
code->existing-account (by :account/numeric-code (map first (dc/q {:find ['(pull ?e [:account/numeric-code
|
||||
{:account/applicability [:db/ident]}
|
||||
:db/id])]
|
||||
:in ['$]
|
||||
code->existing-account (by :account/numeric-code (map first (dc/q {:find ['(pull ?e [:account/numeric-code
|
||||
{:account/applicability [:db/ident]}
|
||||
:db/id])]
|
||||
:in ['$]
|
||||
:where ['[?e :account/name]]}
|
||||
(dc/db conn))))
|
||||
|
||||
existing-account-overrides (dc/q {:find ['?e]
|
||||
:in ['$ '?client-id]
|
||||
existing-account-overrides (dc/q {:find ['?e]
|
||||
:in ['$ '?client-id]
|
||||
:where [['?e :account-client-override/client '?client-id]]}
|
||||
(dc/db conn) client-id)
|
||||
|
||||
@@ -276,8 +276,8 @@
|
||||
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
||||
(defn fix-transactions-without-locations [client-code location]
|
||||
(->>
|
||||
(dc/q {:find ['(pull ?e [*])]
|
||||
:in ['$ '?client-code]
|
||||
(dc/q {:find ['(pull ?e [*])]
|
||||
:in ['$ '?client-code]
|
||||
:where ['[?e :transaction/accounts ?ta]
|
||||
'[?e :transaction/matched-rule]
|
||||
'[?e :transaction/approval-status :transaction-approval-status/approved]
|
||||
@@ -297,8 +297,8 @@
|
||||
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
||||
(defn entity-history [i]
|
||||
(vec (sort-by first (dc/q
|
||||
{:find ['?tx '?z '?v]
|
||||
:in ['?i '$]
|
||||
{:find ['?tx '?z '?v]
|
||||
:in ['?i '$]
|
||||
:where ['[?i ?a ?v ?tx ?ad]
|
||||
'[?a :db/ident ?z]
|
||||
'[(= ?ad true)]]}
|
||||
@@ -307,8 +307,8 @@
|
||||
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
||||
(defn entity-history-with-revert [i]
|
||||
(vec (sort-by first (dc/q
|
||||
{:find ['?tx '?z '?v '?ad]
|
||||
:in ['?i '$]
|
||||
{:find ['?tx '?z '?v '?ad]
|
||||
:in ['?i '$]
|
||||
:where ['[?i ?a ?v ?tx ?ad]
|
||||
'[?a :db/ident ?z]]}
|
||||
i (dc/history (dc/db conn))))))
|
||||
@@ -347,15 +347,18 @@
|
||||
(hawk.core/watch! [{:paths ["src/" "test/"]
|
||||
:handler auto-reset-handler}]))
|
||||
|
||||
(defn start-http []
|
||||
(defn start-http [& [http-port]]
|
||||
(when http-port
|
||||
(alter-var-root #'auto-ap.server/*http-port-override* (constantly http-port)))
|
||||
(mount.core/start (mount.core/only #{#'auto-ap.server/port #'auto-ap.server/jetty})))
|
||||
|
||||
(defn start-dev []
|
||||
(defn start-dev [& [http-port]]
|
||||
(set-refresh-dirs "src")
|
||||
#_(clojure.tools.namespace.repl/disable-reload! (find-ns 'auto-ap.server))
|
||||
#_(clojure.tools.namespace.repl/disable-reload! (find-ns 'auto-ap.time))
|
||||
(clojure.tools.namespace.repl/disable-reload! (find-ns 'dev-mcp))
|
||||
#_(clojure.tools.namespace.repl/disable-reload! (find-ns 'auto-ap.server))
|
||||
#_(clojure.tools.namespace.repl/disable-reload! (find-ns 'auto-ap.time))
|
||||
(start-db)
|
||||
(start-http)
|
||||
(start-http http-port)
|
||||
(auto-reset))
|
||||
|
||||
#_(defn start-search []
|
||||
@@ -376,18 +379,18 @@
|
||||
|
||||
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
||||
(defn find-queries [words]
|
||||
(let [obj (s3/list-objects-v2 :bucket-name (:data-bucket env)
|
||||
:prefix (str "queries/"))
|
||||
concurrent 30
|
||||
(let [obj (s3/list-objects-v2 :bucket-name (:data-bucket env)
|
||||
:prefix (str "queries/"))
|
||||
concurrent 30
|
||||
output-chan (async/chan)]
|
||||
(async/pipeline-blocking concurrent
|
||||
output-chan
|
||||
(comp
|
||||
(map #(do
|
||||
[(:key %)
|
||||
(str (slurp (:object-content (s3/get-object
|
||||
:bucket-name (:data-bucket env)
|
||||
:key (:key %)))))]))
|
||||
(str (slurp (:object-content (s3/get-object
|
||||
:bucket-name (:data-bucket env)
|
||||
:key (:key %)))))]))
|
||||
|
||||
(filter #(->> words
|
||||
(every? (fn [w] (str/includes? (second %) w)))))
|
||||
@@ -401,9 +404,9 @@
|
||||
|
||||
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
||||
(defn upsert-invoice-amounts [tsv]
|
||||
(let [data (with-open [reader (io/reader (char-array tsv))]
|
||||
(doall (csv/read-csv reader :separator \tab)))
|
||||
db (dc/db conn)
|
||||
(let [data (with-open [reader (io/reader (char-array tsv))]
|
||||
(doall (csv/read-csv reader :separator \tab)))
|
||||
db (dc/db conn)
|
||||
i->invoice-id (fn [i]
|
||||
(try (Long/parseLong i)
|
||||
(catch Exception e
|
||||
@@ -456,7 +459,7 @@
|
||||
:when current-total]
|
||||
|
||||
[(when (not (auto-ap.utils/dollars= current-total target-total))
|
||||
{:db/id invoice-id
|
||||
{:db/id invoice-id
|
||||
:invoice/total target-total})
|
||||
|
||||
(when new-account?
|
||||
@@ -521,7 +524,7 @@
|
||||
(let [client-location (ffirst (d/q '[:find ?l :in $ ?c :where [?c :client/locations ?l]] (dc/db conn) [:client/code client-code]))]
|
||||
(clojure.data.csv/write-csv
|
||||
*out*
|
||||
(for [n (range n)
|
||||
(for [n (range n)
|
||||
:let [v (rand-nth (map first (d/q '[:find ?vn :where [_ :vendor/name ?vn]] (dc/db conn))))
|
||||
[{a-1 :account/numeric-code a-1-location :account/location}
|
||||
{a-2 :account/numeric-code a-2-location :account/location}] (->> (d/q '[:find (pull ?a [:account/numeric-code :account/location]) :where [?a :account/numeric-code]]
|
||||
@@ -534,8 +537,8 @@
|
||||
(t/minus (t/days (rand-int 60)))
|
||||
(atime/unparse atime/normal-date))
|
||||
id (rand-int 100000)]
|
||||
a [[(str id) client-code "synthetic" v d a-1 (or a-1-location client-location) 0 amount]
|
||||
[(str id) client-code "synthetic" v d a-2 (or a-2-location client-location) amount 0]]]
|
||||
a [[(str id) client-code "synthetic" v d a-1 (or a-1-location client-location) 0 amount]
|
||||
[(str id) client-code "synthetic" v d a-2 (or a-2-location client-location) amount 0]]]
|
||||
a)
|
||||
:separator \tab))))
|
||||
|
||||
@@ -547,7 +550,7 @@
|
||||
(let [bank-accounts (map first (d/q '[:find ?bac :in $ ?c :where [?c :client/bank-accounts ?b] [?b :bank-account/code ?bac]] (dc/db conn) [:client/code client-code]))]
|
||||
(clojure.data.csv/write-csv
|
||||
*out*
|
||||
(for [n (range n)
|
||||
(for [n (range n)
|
||||
:let [amount (rand-int 2000)
|
||||
d (-> (t/now)
|
||||
(t/minus (t/days (rand-int 60)))
|
||||
@@ -563,7 +566,7 @@
|
||||
:in $
|
||||
:where [?i :invoice/invoice-number]
|
||||
(not [?i :invoice/status :invoice-status/voided])]
|
||||
:args [(dc/db conn)]})
|
||||
:args [(dc/db conn)]})
|
||||
(map first)
|
||||
(partition-all 500))]
|
||||
(print ".")
|
||||
@@ -576,7 +579,7 @@
|
||||
:in $
|
||||
:where [?i :payment/date]
|
||||
(not [?i :payment/status :payment-status/voided])]
|
||||
:args [(dc/db conn)]})
|
||||
:args [(dc/db conn)]})
|
||||
(map first)
|
||||
(partition-all 500))]
|
||||
(print ".")
|
||||
@@ -589,7 +592,7 @@
|
||||
:in $
|
||||
:where [?i :transaction/description-original]
|
||||
(not [?i :transaction/approval-status :transaction-approval-status/suppressed])]
|
||||
:args [(dc/db conn)]})
|
||||
:args [(dc/db conn)]})
|
||||
(map first)
|
||||
(partition-all 500))]
|
||||
(print ".")
|
||||
@@ -600,7 +603,7 @@
|
||||
(doseq [batch (->> (dc/qseq {:query '[:find ?i
|
||||
:in $
|
||||
:where [?i :journal-entry/date]]
|
||||
:args [(dc/db conn)]})
|
||||
:args [(dc/db conn)]})
|
||||
(map first)
|
||||
(partition-all 500))]
|
||||
(print ".")
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
(ns auto-ap.routes.transactions)
|
||||
|
||||
(def routes {"" {:get ::page
|
||||
:put ::edit-wizard-navigate
|
||||
"/unapproved" ::unapproved-page
|
||||
"/requires-feedback" ::requires-feedback-page
|
||||
"/approved" ::approved-page
|
||||
"/bulk-delete" ::bulk-delete
|
||||
"/bulk-suppress" ::bulk-suppress
|
||||
"/bulk-code" {:get ::bulk-code
|
||||
:put ::bulk-code-submit
|
||||
"/new-account" ::bulk-code-new-account
|
||||
"/vendor-changed" ::bulk-code-vendor-changed}}
|
||||
(def routes {"" {:get ::page
|
||||
:put ::edit-wizard-navigate
|
||||
"/unapproved" ::unapproved-page
|
||||
"/requires-feedback" ::requires-feedback-page
|
||||
"/approved" ::approved-page
|
||||
"/bulk-delete" ::bulk-delete
|
||||
"/bulk-suppress" ::bulk-suppress
|
||||
"/bulk-code" {:get ::bulk-code
|
||||
:put ::bulk-code-submit
|
||||
"/new-account" ::bulk-code-new-account
|
||||
"/vendor-changed" ::bulk-code-vendor-changed}
|
||||
"/bulk-code-trial" {:get ::bulk-code-trial
|
||||
:post ::bulk-code-trial-submit}}
|
||||
"/new" {:get ::new
|
||||
:post ::new-submit
|
||||
"/location-select" ::location-select
|
||||
@@ -22,9 +24,9 @@
|
||||
"/parse" ::external-import-parse
|
||||
"/import" ::external-import-import}
|
||||
|
||||
"/table" ::table
|
||||
"/csv" ::csv
|
||||
"/bank-account-filter" ::bank-account-filter
|
||||
"/table" ::table
|
||||
"/csv" ::csv
|
||||
"/bank-account-filter" ::bank-account-filter
|
||||
|
||||
["/" [#"\d+" :db/id]] {"/edit" {:get ::edit-wizard}}
|
||||
"/edit-submit" ::edit-submit
|
||||
@@ -34,6 +36,7 @@
|
||||
"/account-balance" ::account-balance
|
||||
"/toggle-amount-mode" ::toggle-amount-mode
|
||||
"/edit-wizard-new-account" ::edit-wizard-new-account
|
||||
"/edit-wizard-toggle-mode" ::edit-wizard-toggle-mode
|
||||
"/match-payment" ::link-payment
|
||||
"/match-autopay-invoices" ::link-autopay-invoices
|
||||
"/match-unpaid-invoices" ::link-unpaid-invoices
|
||||
|
||||
114
test/clj/auto_ap/ssr/components/wizard_trial/core_test.clj
Normal file
114
test/clj/auto_ap/ssr/components/wizard_trial/core_test.clj
Normal file
@@ -0,0 +1,114 @@
|
||||
(ns auto-ap.ssr.components.wizard-trial.core-test
|
||||
(:require
|
||||
[auto-ap.ssr.components.wizard-trial.core :as sut]
|
||||
[auto-ap.ssr.components.wizard-trial.state :as ws]
|
||||
[clojure.test :refer [deftest is testing]]
|
||||
[hiccup.core :as hiccup]))
|
||||
|
||||
(deftest render-step-test
|
||||
(testing "render-step produces a form with hidden wizard-id and step-key inputs"
|
||||
(let [wizard-id (ws/create! {})
|
||||
step-config {:key :test-step
|
||||
:render (fn [{:keys [step-data errors request]}]
|
||||
[:div (str "data: " step-data) (when errors [:span.error (str errors)])])
|
||||
:submit-route "/test-submit"}
|
||||
result (sut/render-step {:wizard-id wizard-id
|
||||
:step-config step-config
|
||||
:request {}})]
|
||||
(is (= :form (first result)))
|
||||
(let [attrs (second result)
|
||||
children (drop 2 result)]
|
||||
(is (= "/test-submit" (:hx-post attrs)))
|
||||
(is (= "this" (:hx-target attrs)))
|
||||
(let [html (hiccup/html result)]
|
||||
(is (re-find #"name=\"wizard-id\"" html))
|
||||
(is (re-find #"value=\"" html))
|
||||
(is (re-find #"name=\"step-key\"" html))
|
||||
(is (re-find #"value=\"test-step\"" html))))))
|
||||
|
||||
(testing "render-step passes step-data, errors, and request to the render function"
|
||||
(let [wizard-id (ws/create! {:test-step {:field "value"}})
|
||||
captured (atom nil)
|
||||
step-config {:key :test-step
|
||||
:render (fn [args] (reset! captured args) [:div "rendered"])
|
||||
:submit-route "/test-submit"}
|
||||
_ (sut/render-step {:wizard-id wizard-id
|
||||
:step-config step-config
|
||||
:request {:client-id 123}
|
||||
:errors {:field ["is invalid"]}})
|
||||
{:keys [step-data errors request]} @captured]
|
||||
(is (= {:field "value"} step-data))
|
||||
(is (= {:field ["is invalid"]} errors))
|
||||
(is (= {:client-id 123} request)))))
|
||||
|
||||
(deftest handle-submit-test
|
||||
(testing "handle-submit with valid data saves step and calls done-fn"
|
||||
(let [done-result (atom nil)
|
||||
wizard-id (ws/create! {})
|
||||
step-config {:key :test-step
|
||||
:schema [:map [:name :string]]
|
||||
:fields [:name]
|
||||
:render (fn [_] [:div "rendered"])
|
||||
:submit-route "/test-submit"
|
||||
:done-fn (fn [data request]
|
||||
(reset! done-result {:data data :request request})
|
||||
{:status 200 :body "done"})}
|
||||
request {:form-params {"wizard-id" wizard-id
|
||||
"step-key" "test-step"
|
||||
"name" "Alice"}}
|
||||
response (sut/handle-submit step-config request)]
|
||||
(is (= 200 (:status response)))
|
||||
(is (= "done" (:body response)))
|
||||
(is (= "Alice" (get-in @done-result [:data :name])))
|
||||
(is (nil? (ws/get-wizard wizard-id)) "Wizard session should be destroyed after successful submit")))
|
||||
|
||||
(testing "handle-submit with invalid data re-renders step with errors"
|
||||
(let [wizard-id (ws/create! {})
|
||||
step-config {:key :test-step
|
||||
:schema [:map [:name :string]]
|
||||
:fields [:name]
|
||||
:render (fn [{:keys [errors]}]
|
||||
[:div (when errors [:span.error (str errors)])])
|
||||
:submit-route "/test-submit"
|
||||
:done-fn (fn [_ _] {:status 200 :body "done"})}
|
||||
request {:form-params {"wizard-id" wizard-id
|
||||
"step-key" "test-step"
|
||||
"name" ""}}
|
||||
response (sut/handle-submit step-config request)]
|
||||
(is (= 200 (:status response)))
|
||||
(is (string? (:body response)))
|
||||
(is (re-find #"error" (:body response)) "Response body should contain error markup")
|
||||
(is (some? (ws/get-wizard wizard-id)) "Wizard session should still exist after failed validation")))
|
||||
|
||||
(testing "handle-submit with missing required field shows validation error"
|
||||
(let [wizard-id (ws/create! {})
|
||||
step-config {:key :test-step
|
||||
:schema [:map [:name :string]]
|
||||
:fields [:name]
|
||||
:render (fn [{:keys [errors]}]
|
||||
[:div (when errors [:span.error (str errors)])])
|
||||
:submit-route "/test-submit"
|
||||
:done-fn (fn [_ _] {:status 200 :body "done"})}
|
||||
request {:form-params {"wizard-id" wizard-id
|
||||
"step-key" "test-step"}}
|
||||
response (sut/handle-submit step-config request)]
|
||||
(is (= 200 (:status response)))
|
||||
(is (re-find #"error" (:body response)) "Response body should contain error markup for missing field")))
|
||||
|
||||
(testing "handle-submit decodes step data using main-transformer"
|
||||
(let [done-result (atom nil)
|
||||
wizard-id (ws/create! {})
|
||||
step-config {:key :test-step
|
||||
:schema [:map [:count int?]]
|
||||
:fields [:count]
|
||||
:render (fn [_] [:div "rendered"])
|
||||
:submit-route "/test-submit"
|
||||
:done-fn (fn [data _]
|
||||
(reset! done-result data)
|
||||
{:status 200 :body "done"})}
|
||||
request {:form-params {"wizard-id" wizard-id
|
||||
"step-key" "test-step"
|
||||
"count" "42"}}
|
||||
response (sut/handle-submit step-config request)]
|
||||
(is (= 200 (:status response)))
|
||||
(is (= 42 (:count @done-result)) "String count should be decoded to integer"))))
|
||||
76
test/clj/auto_ap/ssr/components/wizard_trial/state_test.clj
Normal file
76
test/clj/auto_ap/ssr/components/wizard_trial/state_test.clj
Normal file
@@ -0,0 +1,76 @@
|
||||
(ns auto-ap.ssr.components.wizard-trial.state-test
|
||||
(:require
|
||||
[auto-ap.ssr.components.wizard-trial.state :as sut]
|
||||
[clojure.test :refer [deftest is testing]]))
|
||||
|
||||
(deftest create-and-get-wizard-test
|
||||
(testing "Session creation returns a non-nil wizard-id"
|
||||
(let [wizard-id (sut/create! {:foo "bar"})]
|
||||
(is (string? wizard-id))
|
||||
(is (seq wizard-id))))
|
||||
|
||||
(testing "Session retrieval returns the stored data"
|
||||
(let [wizard-id (sut/create! {:foo "bar"})
|
||||
wizard (sut/get-wizard wizard-id)]
|
||||
(is (map? wizard))
|
||||
(is (= {:foo "bar"} (:data wizard)))
|
||||
(is (inst? (:created-at wizard)))))
|
||||
|
||||
(testing "Session retrieval returns nil for unknown id"
|
||||
(is (nil? (sut/get-wizard "non-existent-id")))))
|
||||
|
||||
(deftest update-step-test
|
||||
(testing "update-step! merges data into the specified step key"
|
||||
(let [wizard-id (sut/create! {})
|
||||
_ (sut/update-step! wizard-id :step1 {:field-a "a"})
|
||||
wizard (sut/get-wizard wizard-id)]
|
||||
(is (= {:field-a "a"} (get-in wizard [:data :step1])))))
|
||||
|
||||
(testing "update-step! merges without overwriting other step keys"
|
||||
(let [wizard-id (sut/create! {})
|
||||
_ (sut/update-step! wizard-id :step1 {:field-a "a"})
|
||||
_ (sut/update-step! wizard-id :step2 {:field-b "b"})
|
||||
wizard (sut/get-wizard wizard-id)]
|
||||
(is (= {:field-a "a"} (get-in wizard [:data :step1])))
|
||||
(is (= {:field-b "b"} (get-in wizard [:data :step2])))))
|
||||
|
||||
(testing "update-step! merges within the same step key"
|
||||
(let [wizard-id (sut/create! {})
|
||||
_ (sut/update-step! wizard-id :step1 {:field-a "a"})
|
||||
_ (sut/update-step! wizard-id :step1 {:field-b "b"})
|
||||
wizard (sut/get-wizard wizard-id)]
|
||||
(is (= {:field-a "a" :field-b "b"} (get-in wizard [:data :step1]))))))
|
||||
|
||||
(deftest destroy-test
|
||||
(testing "destroy! removes the wizard session"
|
||||
(let [wizard-id (sut/create! {:foo "bar"})
|
||||
_ (sut/destroy! wizard-id)]
|
||||
(is (nil? (sut/get-wizard wizard-id)))))
|
||||
|
||||
(testing "destroy! is a no-op for unknown id"
|
||||
(sut/destroy! "non-existent-id")
|
||||
(is (nil? (sut/get-wizard "non-existent-id")))))
|
||||
|
||||
(deftest get-all-data-test
|
||||
(testing "get-all-data merges non-map values and map values from all steps"
|
||||
(let [wizard-id (sut/create! {:client-id 123})
|
||||
_ (sut/update-step! wizard-id :step1 {:vendor 456})
|
||||
_ (sut/update-step! wizard-id :step2 {:accounts [{:account 1}]})
|
||||
all-data (sut/get-all-data wizard-id)]
|
||||
(is (= {:client-id 123 :vendor 456 :accounts [{:account 1}]} all-data))))
|
||||
|
||||
(testing "get-all-data returns nil for unknown id"
|
||||
(is (nil? (sut/get-all-data "non-existent-id")))))
|
||||
|
||||
(deftest session-exists-test
|
||||
(testing "Session exists after creation"
|
||||
(let [wizard-id (sut/create! {})]
|
||||
(is (some? (sut/get-wizard wizard-id)))))
|
||||
|
||||
(testing "Session does not exist after destruction"
|
||||
(let [wizard-id (sut/create! {})
|
||||
_ (sut/destroy! wizard-id)]
|
||||
(is (nil? (sut/get-wizard wizard-id)))))
|
||||
|
||||
(testing "Session does not exist for random id"
|
||||
(is (nil? (sut/get-wizard (str (java.util.UUID/randomUUID)))))))
|
||||
104
test/clj/auto_ap/ssr/transaction/bulk_code_trial_test.clj
Normal file
104
test/clj/auto_ap/ssr/transaction/bulk_code_trial_test.clj
Normal file
@@ -0,0 +1,104 @@
|
||||
(ns auto-ap.ssr.transaction.bulk-code-trial-test
|
||||
(:require
|
||||
[auto-ap.ssr.components.wizard-trial.state :as ws]
|
||||
[auto-ap.ssr.transaction.bulk-code-trial :as sut]
|
||||
[clojure.test :refer [deftest is testing use-fixtures]]
|
||||
[mount.core :as mount]))
|
||||
|
||||
(use-fixtures :each
|
||||
(fn [test-fn]
|
||||
(mount/start #'auto-ap.datomic/conn)
|
||||
(test-fn)
|
||||
(mount/stop #'auto-ap.datomic/conn)))
|
||||
|
||||
(deftest open-trial-test
|
||||
(testing "open-trial returns modal-response with a form containing expected fields"
|
||||
(let [response (sut/open-trial {})]
|
||||
(is (= 200 (:status response)))
|
||||
(is (= "text/html" (get-in response [:headers "Content-Type"])))
|
||||
(let [body (:body response)]
|
||||
(is (string? body))
|
||||
(is (re-find #"modal-card" body) "Should contain modal card structure")
|
||||
(is (re-find #"Bulk editing" body) "Should show header with transaction count")
|
||||
(is (re-find #"Vendor" body) "Form should contain Vendor label")
|
||||
(is (re-find #"Status" body) "Form should contain Status label")
|
||||
(is (re-find #"Expense Accounts" body) "Form should contain Expense Accounts heading")
|
||||
(is (re-find #"Account" body) "Form should contain Account column header")
|
||||
(is (re-find #"Location" body) "Form should contain Location column header")
|
||||
(is (re-find #"%" body) "Form should contain percentage column header")
|
||||
(is (re-find #"Save" body) "Form should contain Save button")
|
||||
(is (re-find #"New account" body) "Form should contain New account button")
|
||||
(is (re-find #"name=\"wizard-id\"" body) "Form should contain hidden wizard-id input")
|
||||
(is (re-find #"name=\"step-key\"" body) "Form should contain hidden step-key input")))))
|
||||
|
||||
(deftest submit-trial-valid-test
|
||||
(testing "submit-trial with valid data returns success response and destroys session"
|
||||
(let [wizard-id (ws/create! {})
|
||||
request {:form-params {"wizard-id" wizard-id
|
||||
"step-key" "bulk-code"
|
||||
"vendor" "123"
|
||||
"approval-status" "approved"
|
||||
"accounts[0][account]" "1"
|
||||
"accounts[0][location]" "DT"
|
||||
"accounts[0][percentage]" "50"}}
|
||||
response (sut/submit-trial request)]
|
||||
(is (= 200 (:status response)))
|
||||
(is (re-find #"Transactions Coded" (:body response)) "Response should indicate success")
|
||||
(is (nil? (ws/get-wizard wizard-id)) "Wizard session should be destroyed after successful submit"))))
|
||||
|
||||
(deftest submit-trial-invalid-test
|
||||
(testing "submit-trial with invalid vendor id shows validation error"
|
||||
(let [wizard-id (ws/create! {})
|
||||
request {:form-params {"wizard-id" wizard-id
|
||||
"step-key" "bulk-code"
|
||||
"vendor" "not-a-number"
|
||||
"approval-status" "approved"}}
|
||||
response (sut/submit-trial request)]
|
||||
(is (= 200 (:status response)))
|
||||
(is (re-find #"error" (:body response)) "Response should contain error markup for invalid vendor")
|
||||
(is (some? (ws/get-wizard wizard-id)) "Wizard session should persist after failed validation")))
|
||||
|
||||
(testing "submit-trial with invalid account data shows validation error"
|
||||
(let [wizard-id (ws/create! {})
|
||||
request {:form-params {"wizard-id" wizard-id
|
||||
"step-key" "bulk-code"
|
||||
"accounts[0][account]" "not-a-number"
|
||||
"accounts[0][location]" "DT"
|
||||
"accounts[0][percentage]" "50"}}
|
||||
response (sut/submit-trial request)]
|
||||
(is (= 200 (:status response)))
|
||||
(is (re-find #"error" (:body response)) "Response should contain error markup for invalid account")
|
||||
(is (some? (ws/get-wizard wizard-id)) "Wizard session should persist after failed validation")))
|
||||
|
||||
(testing "submit-trial with percentage over 100% shows validation error"
|
||||
(let [wizard-id (ws/create! {})
|
||||
request {:form-params {"wizard-id" wizard-id
|
||||
"step-key" "bulk-code"
|
||||
"accounts[0][account]" "1"
|
||||
"accounts[0][location]" "DT"
|
||||
"accounts[0][percentage]" "150"}}
|
||||
response (sut/submit-trial request)]
|
||||
(is (= 200 (:status response)))
|
||||
(is (re-find #"error" (:body response)) "Response should contain error markup for percentage > 100%")
|
||||
(is (some? (ws/get-wizard wizard-id)) "Wizard session should persist after failed validation"))))
|
||||
|
||||
(deftest submit-trial-empty-test
|
||||
(testing "submit-trial with empty form data shows validation errors"
|
||||
(let [wizard-id (ws/create! {})
|
||||
request {:form-params {"wizard-id" wizard-id
|
||||
"step-key" "bulk-code"}}
|
||||
response (sut/submit-trial request)]
|
||||
(is (= 200 (:status response)))
|
||||
(is (not (re-find #"Bulk code applied" (:body response))) "Empty form should not succeed")
|
||||
(is (some? (ws/get-wizard wizard-id)) "Wizard session should persist after failed validation")))
|
||||
|
||||
(testing "submit-trial with no account rows selected shows validation errors"
|
||||
(let [wizard-id (ws/create! {})
|
||||
request {:form-params {"wizard-id" wizard-id
|
||||
"step-key" "bulk-code"
|
||||
"vendor" ""
|
||||
"approval-status" ""}}
|
||||
response (sut/submit-trial request)]
|
||||
(is (= 200 (:status response)))
|
||||
(is (not (re-find #"Bulk code applied" (:body response))) "Form with empty values should not succeed")
|
||||
(is (some? (ws/get-wizard wizard-id)) "Wizard session should persist after failed validation"))))
|
||||
@@ -0,0 +1,936 @@
|
||||
(ns auto-ap.ssr.transaction.edit-simple-advanced-mode-test
|
||||
(:require
|
||||
[auto-ap.datomic :refer [conn]]
|
||||
[auto-ap.integration.util :refer [wrap-setup]]
|
||||
[auto-ap.solr]
|
||||
[auto-ap.ssr.components.multi-modal :as mm]
|
||||
[auto-ap.ssr.form-cursor :as fc]
|
||||
[auto-ap.ssr.transaction.edit :refer [clientize-vendor
|
||||
edit-vendor-changed-handler
|
||||
edit-wizard-toggle-mode-handler
|
||||
location-select*
|
||||
manual-coding-section*
|
||||
vendor-default-account]]
|
||||
[clojure.test :refer [deftest is testing use-fixtures]]
|
||||
[datomic.api :as dc]
|
||||
[hiccup.core :as hiccup]))
|
||||
|
||||
;; Private save-handler accessible via var reference
|
||||
(def ^:private save-handler
|
||||
#'auto-ap.ssr.transaction.edit/save-handler)
|
||||
|
||||
(use-fixtures :each wrap-setup)
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Helpers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- tempid->id
|
||||
"Resolve a string temp ID to its numeric Datomic entity ID after transact."
|
||||
[result temp-id]
|
||||
(get (:tempids result) temp-id))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; AC1-3: manual-mode-initial — mode selection on open
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
;; manual-mode-initial is private; access via var reference
|
||||
(def ^:private manual-mode-initial
|
||||
#'auto-ap.ssr.transaction.edit/manual-mode-initial)
|
||||
|
||||
(deftest manual-mode-initial-test
|
||||
(testing "AC1: uncoded transaction (no accounts) opens in simple mode"
|
||||
(is (= :simple (manual-mode-initial {:db/id 123})))
|
||||
(is (= :simple (manual-mode-initial {:db/id 123 :transaction/accounts []}))))
|
||||
|
||||
(testing "AC2: single-account transaction opens in simple mode"
|
||||
(is (= :simple (manual-mode-initial {:db/id 123
|
||||
:transaction/accounts [{:transaction-account/account 456
|
||||
:transaction-account/location "Shared"
|
||||
:transaction-account/amount 100.0}]}))))
|
||||
|
||||
(testing "AC3: multi-account (2+) transaction opens in advanced mode"
|
||||
(is (= :advanced (manual-mode-initial {:db/id 123
|
||||
:transaction/accounts [{:transaction-account/account 1}
|
||||
{:transaction-account/account 2}]})))
|
||||
(is (= :advanced (manual-mode-initial {:db/id 123
|
||||
:transaction/accounts [{} {} {}]})))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; AC4-5: edit-vendor-changed-handler — vendor selection in simple mode
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(deftest edit-vendor-changed-simple-mode-test
|
||||
(testing "AC4: vendor selection in simple mode populates account/location from vendor default"
|
||||
;; Set up vendor with a default account
|
||||
(let [result @(dc/transact conn [{:db/id "vendor-id"
|
||||
:vendor/name "Test Vendor"}
|
||||
{:db/id "account-id"
|
||||
:account/name "Test Account"
|
||||
:account/type :account-type/expense}
|
||||
{:db/id "vendor-id"
|
||||
:vendor/default-account "account-id"}
|
||||
{:db/id "client-id"
|
||||
:client/code "TESTCL"
|
||||
:client/locations ["DT"]}
|
||||
{:db/id "transaction-id"
|
||||
:transaction/amount 100.0
|
||||
:transaction/date #inst "2023-01-01"
|
||||
:transaction/id (str (java.util.UUID/randomUUID))
|
||||
:transaction/client "client-id"}])
|
||||
tx-id (tempid->id result "transaction-id")
|
||||
vendor-id (tempid->id result "vendor-id")
|
||||
account-id (tempid->id result "account-id")
|
||||
client-id (tempid->id result "client-id")
|
||||
request {:multi-form-state (mm/->MultiStepFormState
|
||||
{:db/id tx-id
|
||||
:transaction/client client-id
|
||||
:transaction/accounts []}
|
||||
[]
|
||||
{:mode "simple"
|
||||
:transaction/vendor vendor-id
|
||||
:transaction/accounts []})
|
||||
:entity {:db/id tx-id
|
||||
:transaction/client {:db/id client-id}
|
||||
:transaction/amount 100.0}}
|
||||
response (edit-vendor-changed-handler request)
|
||||
body (:body response)]
|
||||
;; Response should contain the manual-coding-section div
|
||||
(is (re-find #"manual-coding-section" body)
|
||||
"Response should contain the manual-coding-section element")
|
||||
;; The vendor's default account ID should appear in the rendered HTML
|
||||
(is (re-find (re-pattern (str account-id)) body)
|
||||
"Response should contain the vendor's default account ID")
|
||||
;; The account's name should appear in the rendered HTML
|
||||
(is (re-find #"Test Account" body)
|
||||
"Response should contain the vendor's default account name")))
|
||||
|
||||
(testing "AC5: vendor selection in simple mode does NOT overwrite already-set account"
|
||||
(let [result @(dc/transact conn [{:db/id "vendor-id"
|
||||
:vendor/name "Test Vendor"}
|
||||
{:db/id "account-id"
|
||||
:account/name "Test Account"
|
||||
:account/type :account-type/expense}
|
||||
{:db/id "vendor-id"
|
||||
:vendor/default-account "account-id"}
|
||||
{:db/id "other-account-id"
|
||||
:account/name "Other Account"
|
||||
:account/type :account-type/expense}
|
||||
{:db/id "client-id"
|
||||
:client/code "TESTCL2"
|
||||
:client/locations ["DT"]}
|
||||
{:db/id "transaction-id"
|
||||
:transaction/amount 100.0
|
||||
:transaction/date #inst "2023-01-01"
|
||||
:transaction/id (str (java.util.UUID/randomUUID))
|
||||
:transaction/client "client-id"}])
|
||||
tx-id (tempid->id result "transaction-id")
|
||||
vendor-id (tempid->id result "vendor-id")
|
||||
other-account-id (tempid->id result "other-account-id")
|
||||
client-id (tempid->id result "client-id")
|
||||
;; existing-accounts already set means vendor should NOT overwrite
|
||||
existing-accounts [{:db/id "row-id"
|
||||
:transaction-account/account other-account-id
|
||||
:transaction-account/location "DT"
|
||||
:transaction-account/amount 100.0}]
|
||||
request {:multi-form-state (mm/->MultiStepFormState
|
||||
{:db/id tx-id
|
||||
:transaction/client client-id
|
||||
:transaction/accounts existing-accounts}
|
||||
[]
|
||||
{:mode "simple"
|
||||
:transaction/vendor vendor-id
|
||||
:transaction/accounts existing-accounts})
|
||||
:entity {:db/id tx-id
|
||||
:transaction/client {:db/id client-id}
|
||||
:transaction/amount 100.0}}
|
||||
response (edit-vendor-changed-handler request)
|
||||
body (:body response)]
|
||||
;; The handler returns an html-response; verify the body is HTML
|
||||
(is (re-find #"manual-coding-section" body)
|
||||
"Response body should contain the manual-coding-section element")
|
||||
;; The original account ID must still appear in the rendered HTML
|
||||
(is (re-find (re-pattern (str other-account-id)) body)
|
||||
"Response should contain the original (pre-existing) account ID")
|
||||
;; The vendor's default account ID must NOT appear — it was not used
|
||||
(is (not (re-find (re-pattern (str (tempid->id result "account-id"))) body))
|
||||
"Response should NOT contain the vendor's default account ID when existing account is set"))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; AC6: save round-trip — manual mode saves vendor + account to DB
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(deftest save-manual-round-trip-test
|
||||
(testing "AC6: save in simple mode persists vendor/account/location — re-opening shows same values"
|
||||
(let [result @(dc/transact conn [{:db/id "vendor-id"
|
||||
:vendor/name "Save Vendor"}
|
||||
{:db/id "account-id"
|
||||
:account/name "Save Account"
|
||||
:account/type :account-type/expense}
|
||||
{:db/id "client-id"
|
||||
:client/code "SAVECL"
|
||||
:client/locations ["DT"]}
|
||||
{:db/id "transaction-id"
|
||||
:transaction/amount 100.0
|
||||
:transaction/date #inst "2023-01-01"
|
||||
:transaction/id (str (java.util.UUID/randomUUID))
|
||||
:transaction/client "client-id"}])
|
||||
tx-id (tempid->id result "transaction-id")
|
||||
vendor-id (tempid->id result "vendor-id")
|
||||
account-id (tempid->id result "account-id")
|
||||
client-id (tempid->id result "client-id")
|
||||
new-row-id (str (java.util.UUID/randomUUID))
|
||||
snapshot {:db/id tx-id
|
||||
:transaction/client client-id
|
||||
:action :manual
|
||||
:transaction/vendor vendor-id
|
||||
:transaction/amount 100.0
|
||||
:transaction/accounts [{:db/id new-row-id
|
||||
:transaction-account/account account-id
|
||||
:transaction-account/location "Shared"
|
||||
:transaction-account/amount 100.0}]}
|
||||
request {:multi-form-state (mm/->MultiStepFormState snapshot [] snapshot)
|
||||
:entity {:db/id tx-id
|
||||
:transaction/client {:db/id client-id}
|
||||
:transaction/amount 100.0}
|
||||
:identity {:user/role "admin"}}]
|
||||
(with-redefs [auto-ap.solr/impl (auto-ap.solr/->InMemSolrClient (atom {}))]
|
||||
(save-handler request))
|
||||
;; Verify the transaction was saved to DB with vendor and accounts
|
||||
(let [saved (dc/pull (dc/db conn)
|
||||
'[:db/id
|
||||
{:transaction/vendor [:db/id]}
|
||||
{:transaction/accounts [{:transaction-account/account [:db/id]}
|
||||
:transaction-account/location
|
||||
:transaction-account/amount]}]
|
||||
tx-id)]
|
||||
(is (= vendor-id (-> saved :transaction/vendor :db/id))
|
||||
"Vendor should be saved on the transaction")
|
||||
(is (= 1 (count (:transaction/accounts saved)))
|
||||
"Exactly one account row should be saved")
|
||||
(is (= account-id (-> saved :transaction/accounts first :transaction-account/account :db/id))
|
||||
"The correct account should be saved")
|
||||
;; "Shared" is spread to client-specific locations by maybe-spread-locations
|
||||
(is (= "DT" (-> saved :transaction/accounts first :transaction-account/location))
|
||||
"The location should be saved (Shared spreads to client locations)")
|
||||
(is (= 100.0 (-> saved :transaction/accounts first :transaction-account/amount))
|
||||
"The amount should be saved")))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; AC7: simple mode account typeahead respects :account/default-allowance rules
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(deftest simple-mode-account-typeahead-allowance-test
|
||||
(testing "AC7: simple mode account typeahead URL includes purpose=transaction"
|
||||
;; The account-typeahead* always passes purpose=transaction to the search URL,
|
||||
;; and the schema validates accounts using check-allowance with :account/default-allowance.
|
||||
;; We test the rendering side: the typeahead URL in simple-mode-fields* must contain purpose=transaction.
|
||||
(let [result @(dc/transact conn [{:db/id "client-id"
|
||||
:client/code "ALLOWCL"
|
||||
:client/locations ["DT"]}
|
||||
{:db/id "account-id"
|
||||
:account/name "Allowed Account"
|
||||
:account/type :account-type/expense}
|
||||
{:db/id "transaction-id"
|
||||
:transaction/amount 100.0
|
||||
:transaction/date #inst "2023-01-01"
|
||||
:transaction/id (str (java.util.UUID/randomUUID))
|
||||
:transaction/client "client-id"}])
|
||||
tx-id (tempid->id result "transaction-id")
|
||||
account-id (tempid->id result "account-id")
|
||||
client-id (tempid->id result "client-id")
|
||||
one-account [{:db/id "row-1"
|
||||
:transaction-account/account account-id
|
||||
:transaction-account/location "DT"
|
||||
:transaction-account/amount 100.0}]
|
||||
request {:multi-form-state (mm/->MultiStepFormState
|
||||
{:db/id tx-id
|
||||
:transaction/client client-id
|
||||
:transaction/accounts one-account}
|
||||
[]
|
||||
{:mode "simple"
|
||||
:transaction/accounts one-account})
|
||||
:entity {:db/id tx-id
|
||||
:transaction/client {:db/id client-id}
|
||||
:transaction/amount 100.0}}
|
||||
html (hiccup/html
|
||||
(fc/start-form (:multi-form-state request) nil
|
||||
(fc/with-field :step-params
|
||||
(manual-coding-section* :simple request))))]
|
||||
(is (re-find #"purpose=transaction" html)
|
||||
"Simple mode account typeahead URL must include purpose=transaction to enforce allowance rules"))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; AC8: location dropdown — fixed location vs full client list
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(deftest location-select-options-test
|
||||
(testing "AC8: when account has a fixed location, dropdown shows only that location"
|
||||
(let [html (hiccup/html
|
||||
(location-select* {:name "location"
|
||||
:account-location "HQ"
|
||||
:client-locations ["DT" "NY"]
|
||||
:value "HQ"}))]
|
||||
(is (re-find #"HQ" html)
|
||||
"Fixed account location 'HQ' should appear in dropdown")
|
||||
(is (not (re-find #"DT" html))
|
||||
"Client location 'DT' should NOT appear when account has fixed location")
|
||||
(is (not (re-find #"Shared" html))
|
||||
"'Shared' option should not appear when account has fixed location")))
|
||||
|
||||
(testing "AC8: when account has no fixed location, dropdown shows full client location list"
|
||||
(let [html (hiccup/html
|
||||
(location-select* {:name "location"
|
||||
:account-location nil
|
||||
:client-locations ["DT" "NY"]
|
||||
:value "Shared"}))]
|
||||
(is (re-find #"Shared" html)
|
||||
"'Shared' option should appear when no fixed account location")
|
||||
(is (re-find #"DT" html)
|
||||
"Client location 'DT' should appear when no fixed account location")
|
||||
(is (re-find #"NY" html)
|
||||
"Client location 'NY' should appear when no fixed account location"))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; AC9: simple → advanced toggle with pre-populated row
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(deftest toggle-simple-to-advanced-test
|
||||
(testing "AC9: clicking 'Switch to advanced mode' from simple mode re-renders in advanced with one row pre-populated"
|
||||
(let [result @(dc/transact conn [{:db/id "client-id"
|
||||
:client/code "TOGGLECL"
|
||||
:client/locations ["DT"]}
|
||||
{:db/id "account-id"
|
||||
:account/name "Toggle Account"
|
||||
:account/type :account-type/expense}
|
||||
{:db/id "transaction-id"
|
||||
:transaction/amount 100.0
|
||||
:transaction/date #inst "2023-01-01"
|
||||
:transaction/id (str (java.util.UUID/randomUUID))
|
||||
:transaction/client "client-id"}])
|
||||
tx-id (tempid->id result "transaction-id")
|
||||
account-id (tempid->id result "account-id")
|
||||
client-id (tempid->id result "client-id")
|
||||
simple-account [{:db/id "row-1"
|
||||
:transaction-account/account account-id
|
||||
:transaction-account/location "DT"
|
||||
:transaction-account/amount 100.0}]
|
||||
request {:multi-form-state (mm/->MultiStepFormState
|
||||
{:db/id tx-id
|
||||
:transaction/client client-id
|
||||
:transaction/accounts simple-account}
|
||||
[]
|
||||
{:mode "simple"
|
||||
:transaction/accounts simple-account})
|
||||
:entity {:db/id tx-id
|
||||
:transaction/client {:db/id client-id}
|
||||
:transaction/amount 100.0}}
|
||||
response (edit-wizard-toggle-mode-handler request)
|
||||
body (:body response)]
|
||||
(is (re-find #"manual-coding-section" body)
|
||||
"Response body should contain the coding section element")
|
||||
;; Advanced mode has Switch to simple mode link (when <=1 row)
|
||||
(is (re-find #"Switch to simple mode" body)
|
||||
"After toggling to advanced mode (from simple with 1 row), 'Switch to simple mode' should appear")
|
||||
;; The account-grid-body section should be present in the advanced mode HTML
|
||||
(is (re-find #"account-grid-body" body)
|
||||
"Advanced mode response should contain the account-grid-body element")
|
||||
;; The account ID from the simple-mode row should appear in the advanced table HTML
|
||||
(is (re-find (re-pattern (str account-id)) body)
|
||||
"Advanced mode response should contain the account ID from the simple-mode row")
|
||||
;; The account name should appear in the advanced table HTML
|
||||
(is (re-find #"Toggle Account" body)
|
||||
"Advanced mode response should contain the account name from the simple-mode row"))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; AC10: blank simple → advanced gives empty table
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(deftest toggle-blank-simple-to-advanced-test
|
||||
(testing "AC10: 'Switch to advanced mode' from blank simple mode gives advanced with empty table"
|
||||
(let [result @(dc/transact conn [{:db/id "client-id"
|
||||
:client/code "BLNKTOGLCL"
|
||||
:client/locations ["DT"]}
|
||||
{:db/id "transaction-id"
|
||||
:transaction/amount 100.0
|
||||
:transaction/date #inst "2023-01-01"
|
||||
:transaction/id (str (java.util.UUID/randomUUID))
|
||||
:transaction/client "client-id"}])
|
||||
tx-id (tempid->id result "transaction-id")
|
||||
client-id (tempid->id result "client-id")
|
||||
request {:multi-form-state (mm/->MultiStepFormState
|
||||
{:db/id tx-id
|
||||
:transaction/client client-id
|
||||
:transaction/accounts []}
|
||||
[]
|
||||
{:mode "simple"
|
||||
:transaction/accounts []})
|
||||
:entity {:db/id tx-id
|
||||
:transaction/client {:db/id client-id}
|
||||
:transaction/amount 100.0}}
|
||||
response (edit-wizard-toggle-mode-handler request)
|
||||
body (:body response)]
|
||||
;; Should render in advanced mode
|
||||
(is (re-find #"account-grid-body" body)
|
||||
"Blank simple → advanced should produce account-grid-body")
|
||||
;; Should NOT have any account-row elements (empty grid)
|
||||
(is (not (re-find #"account-row" body))
|
||||
"Blank simple → advanced should have NO account rows")
|
||||
;; Should show 'Switch to simple mode' because 0 rows <= 1
|
||||
(is (re-find #"Switch to simple mode" body)
|
||||
"Advanced mode with 0 rows should show 'Switch to simple mode' link"))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; AC11-12: "Switch to simple mode" visibility based on row count
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(deftest advanced-mode-switch-link-visibility-test
|
||||
(testing "AC11: 'Switch to simple mode' link is visible in advanced mode with 0 or 1 rows"
|
||||
(let [result @(dc/transact conn [{:db/id "client-id"
|
||||
:client/code "VISLINKCL"
|
||||
:client/locations ["DT"]}
|
||||
{:db/id "account-id"
|
||||
:account/name "Visibility Account"
|
||||
:account/type :account-type/expense}
|
||||
{:db/id "transaction-id"
|
||||
:transaction/amount 100.0
|
||||
:transaction/date #inst "2023-01-01"
|
||||
:transaction/id (str (java.util.UUID/randomUUID))
|
||||
:transaction/client "client-id"}])
|
||||
tx-id (tempid->id result "transaction-id")
|
||||
account-id (tempid->id result "account-id")
|
||||
client-id (tempid->id result "client-id")
|
||||
zero-rows []
|
||||
one-row [{:db/id "row-1"
|
||||
:transaction-account/account account-id
|
||||
:transaction-account/location "DT"
|
||||
:transaction-account/amount 100.0}]
|
||||
make-req (fn [accounts]
|
||||
{:multi-form-state (mm/->MultiStepFormState
|
||||
{:db/id tx-id
|
||||
:transaction/client client-id
|
||||
:transaction/accounts accounts}
|
||||
[]
|
||||
{:mode "advanced"
|
||||
:transaction/accounts accounts})
|
||||
:entity {:db/id tx-id
|
||||
:transaction/client {:db/id client-id}
|
||||
:transaction/amount 100.0}})
|
||||
html-zero-rows (hiccup/html
|
||||
(fc/start-form (:multi-form-state (make-req zero-rows)) nil
|
||||
(fc/with-field :step-params
|
||||
(manual-coding-section* :advanced (make-req zero-rows)))))
|
||||
html-one-row (hiccup/html
|
||||
(fc/start-form (:multi-form-state (make-req one-row)) nil
|
||||
(fc/with-field :step-params
|
||||
(manual-coding-section* :advanced (make-req one-row)))))]
|
||||
(is (re-find #"Switch to simple mode" html-zero-rows)
|
||||
"In advanced mode with 0 rows, 'Switch to simple mode' should be visible")
|
||||
(is (re-find #"Switch to simple mode" html-one-row)
|
||||
"In advanced mode with 1 row, 'Switch to simple mode' should be visible")))
|
||||
|
||||
(testing "AC12: 'Switch to simple mode' link is absent in advanced mode with 2+ rows"
|
||||
(let [result @(dc/transact conn [{:db/id "client-id"
|
||||
:client/code "TWOROWCL"
|
||||
:client/locations ["DT"]}
|
||||
{:db/id "account-id"
|
||||
:account/name "Account1"
|
||||
:account/type :account-type/expense}
|
||||
{:db/id "account-id-2"
|
||||
:account/name "Account2"
|
||||
:account/type :account-type/expense}
|
||||
{:db/id "transaction-id"
|
||||
:transaction/amount 100.0
|
||||
:transaction/date #inst "2023-01-01"
|
||||
:transaction/id (str (java.util.UUID/randomUUID))
|
||||
:transaction/client "client-id"}])
|
||||
tx-id (tempid->id result "transaction-id")
|
||||
account-id (tempid->id result "account-id")
|
||||
account-id-2 (tempid->id result "account-id-2")
|
||||
client-id (tempid->id result "client-id")
|
||||
two-rows [{:db/id "row-1"
|
||||
:transaction-account/account account-id
|
||||
:transaction-account/location "DT"
|
||||
:transaction-account/amount 50.0}
|
||||
{:db/id "row-2"
|
||||
:transaction-account/account account-id-2
|
||||
:transaction-account/location "DT"
|
||||
:transaction-account/amount 50.0}]
|
||||
request {:multi-form-state (mm/->MultiStepFormState
|
||||
{:db/id tx-id
|
||||
:transaction/client client-id
|
||||
:transaction/accounts two-rows}
|
||||
[]
|
||||
{:mode "advanced"
|
||||
:transaction/accounts two-rows})
|
||||
:entity {:db/id tx-id
|
||||
:transaction/client {:db/id client-id}
|
||||
:transaction/amount 100.0}}
|
||||
html (hiccup/html
|
||||
(fc/start-form (:multi-form-state request) nil
|
||||
(fc/with-field :step-params
|
||||
(manual-coding-section* :advanced request))))]
|
||||
(is (not (re-find #"Switch to simple mode" html))
|
||||
"In advanced mode with 2+ rows, 'Switch to simple mode' link should be absent"))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; AC13: advanced → simple (1 row) re-renders in simple mode
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(deftest toggle-advanced-to-simple-test
|
||||
(testing "AC13: 'Switch to simple mode' from advanced (1 row) gives simple mode with that row's values"
|
||||
(let [result @(dc/transact conn [{:db/id "client-id"
|
||||
:client/code "TOGSIMCL"
|
||||
:client/locations ["DT"]}
|
||||
{:db/id "account-id"
|
||||
:account/name "Toggle Account"
|
||||
:account/type :account-type/expense}
|
||||
{:db/id "transaction-id"
|
||||
:transaction/amount 100.0
|
||||
:transaction/date #inst "2023-01-01"
|
||||
:transaction/id (str (java.util.UUID/randomUUID))
|
||||
:transaction/client "client-id"}])
|
||||
tx-id (tempid->id result "transaction-id")
|
||||
account-id (tempid->id result "account-id")
|
||||
client-id (tempid->id result "client-id")
|
||||
one-row [{:db/id "row-1"
|
||||
:transaction-account/account account-id
|
||||
:transaction-account/location "DT"
|
||||
:transaction-account/amount 100.0}]
|
||||
request {:multi-form-state (mm/->MultiStepFormState
|
||||
{:db/id tx-id
|
||||
:transaction/client client-id
|
||||
:transaction/accounts one-row}
|
||||
[]
|
||||
{:mode "advanced"
|
||||
:transaction/accounts one-row})
|
||||
:entity {:db/id tx-id
|
||||
:transaction/client {:db/id client-id}
|
||||
:transaction/amount 100.0}}
|
||||
response (edit-wizard-toggle-mode-handler request)
|
||||
body (:body response)]
|
||||
;; After toggling from advanced to simple, show "Switch to advanced mode" link
|
||||
(is (re-find #"Switch to advanced mode" body)
|
||||
"After toggling to simple mode, 'Switch to advanced mode' should be visible")
|
||||
;; The row's account ID should appear in the simple mode form
|
||||
(is (re-find (re-pattern (str account-id)) body)
|
||||
"Simple mode should display the account from the advanced mode row"))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; AC14: vendor change in advanced mode with no rows creates one row
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(deftest edit-vendor-changed-advanced-mode-no-rows-test
|
||||
(testing "AC14: vendor change in advanced mode with no rows creates one row with vendor's default account"
|
||||
(let [result @(dc/transact conn [{:db/id "vendor-id"
|
||||
:vendor/name "Advanced Vendor"}
|
||||
{:db/id "account-id"
|
||||
:account/name "Vendor Default Account"
|
||||
:account/type :account-type/expense}
|
||||
{:db/id "vendor-id"
|
||||
:vendor/default-account "account-id"}
|
||||
{:db/id "client-id"
|
||||
:client/code "ADVVCL"
|
||||
:client/locations ["DT"]}
|
||||
{:db/id "transaction-id"
|
||||
:transaction/amount 200.0
|
||||
:transaction/date #inst "2023-01-01"
|
||||
:transaction/id (str (java.util.UUID/randomUUID))
|
||||
:transaction/client "client-id"}])
|
||||
tx-id (tempid->id result "transaction-id")
|
||||
vendor-id (tempid->id result "vendor-id")
|
||||
account-id (tempid->id result "account-id")
|
||||
client-id (tempid->id result "client-id")
|
||||
;; Advanced mode with no accounts
|
||||
request {:multi-form-state (mm/->MultiStepFormState
|
||||
{:db/id tx-id
|
||||
:transaction/client client-id
|
||||
:transaction/accounts []}
|
||||
[]
|
||||
{:mode "advanced"
|
||||
:transaction/vendor vendor-id
|
||||
:transaction/accounts []})
|
||||
:entity {:db/id tx-id
|
||||
:transaction/client {:db/id client-id}
|
||||
:transaction/amount 200.0}}
|
||||
response (edit-vendor-changed-handler request)
|
||||
body (:body response)]
|
||||
(is (re-find #"manual-coding-section" body))
|
||||
;; Should have account-grid-body (advanced mode)
|
||||
(is (re-find #"account-grid-body" body)
|
||||
"Advanced mode vendor change should show account-grid-body")
|
||||
;; The vendor's default account ID should appear in the new row
|
||||
(is (re-find (re-pattern (str account-id)) body)
|
||||
"Advanced mode: vendor default account should appear in the newly created row")
|
||||
(is (re-find #"Vendor Default Account" body)
|
||||
"Advanced mode: vendor default account name should appear in the new row"))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; AC15: vendor change in advanced mode with existing rows does NOT modify them
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(deftest edit-vendor-changed-advanced-mode-existing-rows-test
|
||||
(testing "AC15: vendor change in advanced mode with existing rows does NOT modify existing rows"
|
||||
(let [result @(dc/transact conn [{:db/id "vendor-id"
|
||||
:vendor/name "New Vendor"}
|
||||
{:db/id "vendor-account-id"
|
||||
:account/name "Vendor Default Account"
|
||||
:account/type :account-type/expense}
|
||||
{:db/id "vendor-id"
|
||||
:vendor/default-account "vendor-account-id"}
|
||||
{:db/id "existing-account-id"
|
||||
:account/name "Existing Account"
|
||||
:account/type :account-type/expense}
|
||||
{:db/id "client-id"
|
||||
:client/code "ADVEXCL"
|
||||
:client/locations ["DT"]}
|
||||
{:db/id "transaction-id"
|
||||
:transaction/amount 100.0
|
||||
:transaction/date #inst "2023-01-01"
|
||||
:transaction/id (str (java.util.UUID/randomUUID))
|
||||
:transaction/client "client-id"}])
|
||||
tx-id (tempid->id result "transaction-id")
|
||||
vendor-id (tempid->id result "vendor-id")
|
||||
vendor-account-id (tempid->id result "vendor-account-id")
|
||||
existing-account-id (tempid->id result "existing-account-id")
|
||||
client-id (tempid->id result "client-id")
|
||||
existing-rows [{:db/id "row-1"
|
||||
:transaction-account/account existing-account-id
|
||||
:transaction-account/location "DT"
|
||||
:transaction-account/amount 100.0}]
|
||||
;; Advanced mode with existing accounts — vendor change should NOT touch them
|
||||
request {:multi-form-state (mm/->MultiStepFormState
|
||||
{:db/id tx-id
|
||||
:transaction/client client-id
|
||||
:transaction/accounts existing-rows}
|
||||
[]
|
||||
{:mode "advanced"
|
||||
:transaction/vendor vendor-id
|
||||
:transaction/accounts existing-rows})
|
||||
:entity {:db/id tx-id
|
||||
:transaction/client {:db/id client-id}
|
||||
:transaction/amount 100.0}}
|
||||
response (edit-vendor-changed-handler request)
|
||||
body (:body response)]
|
||||
;; The original (existing) account should still be shown
|
||||
(is (re-find (re-pattern (str existing-account-id)) body)
|
||||
"Existing row account should remain unchanged when vendor changes in advanced mode")
|
||||
;; The vendor's default account should NOT appear — existing rows are untouched
|
||||
(is (not (re-find (re-pattern (str vendor-account-id)) body))
|
||||
"Vendor default account should NOT replace existing rows in advanced mode"))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; AC16: vendor-default-account uses clientized vendor logic
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(deftest vendor-default-account-clientize-test
|
||||
(testing "AC16: client-specific account override takes precedence over global vendor default"
|
||||
(let [result @(dc/transact conn [{:db/id "global-account-id"
|
||||
:account/name "Global Default Account"
|
||||
:account/type :account-type/expense}
|
||||
{:db/id "client-specific-account-id"
|
||||
:account/name "Client Specific Account"
|
||||
:account/type :account-type/expense}
|
||||
{:db/id "client-id"
|
||||
:client/code "CLACCTEST"
|
||||
:client/locations ["DT"]}
|
||||
{:db/id "vendor-id"
|
||||
:vendor/name "Clientized Vendor"
|
||||
:vendor/default-account "global-account-id"
|
||||
:vendor/account-overrides [{:vendor-account-override/client "client-id"
|
||||
:vendor-account-override/account "client-specific-account-id"}]}])
|
||||
vendor-id (tempid->id result "vendor-id")
|
||||
client-id (tempid->id result "client-id")
|
||||
client-specific-account-id (tempid->id result "client-specific-account-id")
|
||||
result-account (vendor-default-account vendor-id client-id)]
|
||||
(is (some? result-account)
|
||||
"Should return a default account")
|
||||
(is (= client-specific-account-id (:db/id result-account))
|
||||
"Client-specific account override should take precedence over global vendor default")))
|
||||
|
||||
(testing "AC16: falls back to global vendor default when no client override"
|
||||
(let [result @(dc/transact conn [{:db/id "global-account-id"
|
||||
:account/name "Global Default Account"
|
||||
:account/type :account-type/expense}
|
||||
{:db/id "vendor-id"
|
||||
:vendor/name "Vendor No Override"
|
||||
:vendor/default-account "global-account-id"}
|
||||
{:db/id "client-id"
|
||||
:client/code "NOOVCL"
|
||||
:client/locations ["DT"]}])
|
||||
vendor-id (tempid->id result "vendor-id")
|
||||
client-id (tempid->id result "client-id")
|
||||
global-account-id (tempid->id result "global-account-id")
|
||||
result-account (vendor-default-account vendor-id client-id)]
|
||||
(is (= global-account-id (:db/id result-account))
|
||||
"Should use global vendor default when no client-specific override exists")))
|
||||
|
||||
(testing "AC16: clientize-vendor applies both terms and account overrides"
|
||||
(let [vendor {:db/id "vendor-id"
|
||||
:vendor/name "Test Vendor"
|
||||
:vendor/default-account {:db/id "global-account-id"}
|
||||
:vendor/terms "NET30"
|
||||
:vendor/automatically-paid-when-due []
|
||||
:vendor/terms-overrides [{:vendor-terms-override/client {:db/id "client-id"}
|
||||
:vendor-terms-override/terms "NET60"}]
|
||||
:vendor/account-overrides [{:vendor-account-override/client {:db/id "client-id"}
|
||||
:vendor-account-override/account {:db/id "client-specific-account-id"}}]}
|
||||
clientized (clientize-vendor vendor "client-id")]
|
||||
(is (= "NET60" (:vendor/terms clientized))
|
||||
"Terms override should be applied")
|
||||
(is (= "client-specific-account-id" (-> clientized :vendor/default-account :db/id))
|
||||
"Account override should be applied"))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; AC17: approval status, memo, vendor fields present in both modes
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(deftest fields-present-in-both-modes-test
|
||||
;; NOTE: memo and approval-status fields are rendered by LinksStep/render-step
|
||||
;; (outside of #manual-coding-section), so they cannot be tested at the
|
||||
;; manual-coding-section* level. This test covers what manual-coding-section*
|
||||
;; is directly responsible for.
|
||||
(testing "AC17: key fields are present in both simple and advanced modes"
|
||||
(let [result @(dc/transact conn [{:db/id "client-id"
|
||||
:client/code "FLDTESTCL"
|
||||
:client/locations ["DT"]}
|
||||
{:db/id "account-id"
|
||||
:account/name "Fields Account"
|
||||
:account/type :account-type/expense}
|
||||
{:db/id "transaction-id"
|
||||
:transaction/amount 100.0
|
||||
:transaction/date #inst "2023-01-01"
|
||||
:transaction/id (str (java.util.UUID/randomUUID))
|
||||
:transaction/client "client-id"}])
|
||||
tx-id (tempid->id result "transaction-id")
|
||||
account-id (tempid->id result "account-id")
|
||||
client-id (tempid->id result "client-id")
|
||||
one-account [{:db/id "row-1"
|
||||
:transaction-account/account account-id
|
||||
:transaction-account/location "DT"
|
||||
:transaction-account/amount 100.0}]
|
||||
make-req (fn [mode accounts]
|
||||
{:multi-form-state (mm/->MultiStepFormState
|
||||
{:db/id tx-id
|
||||
:transaction/client client-id
|
||||
:transaction/accounts accounts}
|
||||
[]
|
||||
{:mode (name mode)
|
||||
:transaction/accounts accounts})
|
||||
:entity {:db/id tx-id
|
||||
:transaction/client {:db/id client-id}
|
||||
:transaction/amount 100.0}})
|
||||
html-simple (hiccup/html
|
||||
(fc/start-form (:multi-form-state (make-req :simple one-account)) nil
|
||||
(fc/with-field :step-params
|
||||
(manual-coding-section* :simple (make-req :simple one-account)))))
|
||||
html-advanced (hiccup/html
|
||||
(fc/start-form (:multi-form-state (make-req :advanced one-account)) nil
|
||||
(fc/with-field :step-params
|
||||
(manual-coding-section* :advanced (make-req :advanced one-account)))))]
|
||||
;; --- Vendor field assertions (both modes) ---
|
||||
(is (re-find #"Vendor" html-simple)
|
||||
"AC17: Vendor field label must appear in simple mode")
|
||||
(is (re-find #"vendor/search" html-simple)
|
||||
"AC17: Vendor typeahead/search URL must appear in simple mode")
|
||||
(is (re-find #"transaction/vendor" html-simple)
|
||||
"AC17: Vendor field name must appear in simple mode")
|
||||
(is (re-find #"Vendor" html-advanced)
|
||||
"AC17: Vendor field label must appear in advanced mode")
|
||||
(is (re-find #"vendor/search" html-advanced)
|
||||
"AC17: Vendor typeahead/search URL must appear in advanced mode")
|
||||
(is (re-find #"transaction/vendor" html-advanced)
|
||||
"AC17: Vendor field name must appear in advanced mode")
|
||||
;; --- Hidden mode input (proves section is complete) ---
|
||||
(is (re-find #"name=\"mode\".*value=\"simple\"" html-simple)
|
||||
"AC17: hidden mode=simple input must be present in simple mode")
|
||||
(is (re-find #"name=\"mode\".*value=\"advanced\"" html-advanced)
|
||||
"AC17: hidden mode=advanced input must be present in advanced mode")
|
||||
;; --- Simple-mode-specific fields: account typeahead, location select, toggle link ---
|
||||
(is (re-find #"account/search" html-simple)
|
||||
"AC17: account typeahead search URL must appear in simple mode")
|
||||
(is (re-find #"transaction-account/location" html-simple)
|
||||
"AC17: location select field name must appear in simple mode")
|
||||
(is (re-find #"Switch to advanced mode" html-simple)
|
||||
"AC17: toggle link to advanced mode must appear in simple mode")
|
||||
;; --- Advanced-mode-specific: account-grid-body and toggle link ---
|
||||
(is (re-find #"account-grid-body" html-advanced)
|
||||
"AC17: #account-grid-body must be present in advanced mode")
|
||||
(is (re-find #"Switch to simple mode" html-advanced)
|
||||
"AC17: toggle link to simple mode must appear in advanced mode (<=1 row)"))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; AC18: switching modes mid-edit then saving produces a valid transaction
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(deftest switch-modes-then-save-test
|
||||
(testing "AC18: switching simple→advanced→save produces a single valid transaction row in DB"
|
||||
(let [result @(dc/transact conn [{:db/id "vendor-id"
|
||||
:vendor/name "Switch Vendor"}
|
||||
{:db/id "account-id"
|
||||
:account/name "Switch Account"
|
||||
:account/type :account-type/expense}
|
||||
{:db/id "client-id"
|
||||
:client/code "SWITCHCL"
|
||||
:client/locations ["DT"]}
|
||||
{:db/id "transaction-id"
|
||||
:transaction/amount 100.0
|
||||
:transaction/date #inst "2023-01-01"
|
||||
:transaction/id (str (java.util.UUID/randomUUID))
|
||||
:transaction/client "client-id"}])
|
||||
tx-id (tempid->id result "transaction-id")
|
||||
vendor-id (tempid->id result "vendor-id")
|
||||
account-id (tempid->id result "account-id")
|
||||
client-id (tempid->id result "client-id")
|
||||
simple-row [{:db/id "row-1"
|
||||
:transaction-account/account account-id
|
||||
:transaction-account/location "DT"
|
||||
:transaction-account/amount 100.0}]
|
||||
;; Step 1: Start in simple mode
|
||||
simple-request {:multi-form-state (mm/->MultiStepFormState
|
||||
{:db/id tx-id
|
||||
:transaction/client client-id
|
||||
:transaction/accounts simple-row}
|
||||
[]
|
||||
{:mode "simple"
|
||||
:transaction/accounts simple-row})
|
||||
:entity {:db/id tx-id
|
||||
:transaction/client {:db/id client-id}
|
||||
:transaction/amount 100.0}}
|
||||
;; Step 2: Toggle to advanced mode
|
||||
toggle-response (edit-wizard-toggle-mode-handler simple-request)
|
||||
;; After toggle, build a save request in advanced mode with one row
|
||||
advanced-snapshot {:db/id tx-id
|
||||
:transaction/client client-id
|
||||
:action :manual
|
||||
:transaction/vendor vendor-id
|
||||
:transaction/amount 100.0
|
||||
:transaction/accounts [{:db/id "row-1"
|
||||
:transaction-account/account account-id
|
||||
:transaction-account/location "DT"
|
||||
:transaction-account/amount 100.0}]}
|
||||
save-request {:multi-form-state (mm/->MultiStepFormState advanced-snapshot [] advanced-snapshot)
|
||||
:entity {:db/id tx-id
|
||||
:transaction/client {:db/id client-id}
|
||||
:transaction/amount 100.0}
|
||||
:identity {:user/role "admin"}}]
|
||||
;; Verify the toggle worked
|
||||
(is (re-find #"account-grid-body" (:body toggle-response))
|
||||
"After toggle, response should be in advanced mode")
|
||||
;; Now save
|
||||
(with-redefs [auto-ap.solr/impl (auto-ap.solr/->InMemSolrClient (atom {}))]
|
||||
(save-handler save-request))
|
||||
;; Verify exactly one account row was saved (no orphaned rows)
|
||||
(let [saved (dc/pull (dc/db conn)
|
||||
'[:db/id
|
||||
{:transaction/accounts [{:transaction-account/account [:db/id]}
|
||||
:transaction-account/location
|
||||
:transaction-account/amount]}]
|
||||
tx-id)]
|
||||
(is (= 1 (count (:transaction/accounts saved)))
|
||||
"AC18: Exactly one account row should be saved after mode-switch + save (no orphaned rows)")
|
||||
(is (= account-id (-> saved :transaction/accounts first :transaction-account/account :db/id))
|
||||
"AC18: The correct account should be saved")))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; AC19: split transaction re-opens in advanced mode with all splits
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(deftest split-transaction-reopens-advanced-test
|
||||
(testing "AC19: split transaction (2+ accounts) re-opens in advanced mode with all splits intact"
|
||||
(let [result @(dc/transact conn [{:db/id "client-id"
|
||||
:client/code "SPLITCL"
|
||||
:client/locations ["DT"]}
|
||||
{:db/id "account-id-1"
|
||||
:account/name "Split Account 1"
|
||||
:account/type :account-type/expense}
|
||||
{:db/id "account-id-2"
|
||||
:account/name "Split Account 2"
|
||||
:account/type :account-type/expense}
|
||||
{:db/id "transaction-id"
|
||||
:transaction/amount 100.0
|
||||
:transaction/date #inst "2023-01-01"
|
||||
:transaction/id (str (java.util.UUID/randomUUID))
|
||||
:transaction/client "client-id"}])
|
||||
tx-id (tempid->id result "transaction-id")
|
||||
account-id-1 (tempid->id result "account-id-1")
|
||||
account-id-2 (tempid->id result "account-id-2")
|
||||
client-id (tempid->id result "client-id")
|
||||
two-rows [{:db/id "row-1"
|
||||
:transaction-account/account account-id-1
|
||||
:transaction-account/location "DT"
|
||||
:transaction-account/amount 50.0}
|
||||
{:db/id "row-2"
|
||||
:transaction-account/account account-id-2
|
||||
:transaction-account/location "DT"
|
||||
:transaction-account/amount 50.0}]
|
||||
request {:multi-form-state (mm/->MultiStepFormState
|
||||
{:db/id tx-id
|
||||
:transaction/client client-id
|
||||
:transaction/accounts two-rows}
|
||||
[]
|
||||
{:mode "advanced"
|
||||
:transaction/accounts two-rows})
|
||||
:entity {:db/id tx-id
|
||||
:transaction/client {:db/id client-id}
|
||||
:transaction/amount 100.0}}
|
||||
html (hiccup/html
|
||||
(fc/start-form (:multi-form-state request) nil
|
||||
(fc/with-field :step-params
|
||||
(manual-coding-section* :advanced request))))]
|
||||
;; Should show both account IDs
|
||||
(is (re-find (re-pattern (str account-id-1)) html)
|
||||
"AC19: First split account should appear in advanced mode rendering")
|
||||
(is (re-find (re-pattern (str account-id-2)) html)
|
||||
"AC19: Second split account should appear in advanced mode rendering")
|
||||
;; Should NOT show 'Switch to simple mode' with 2 rows
|
||||
(is (not (re-find #"Switch to simple mode" html))
|
||||
"AC19: Split transaction (2 rows) in advanced mode should NOT show 'Switch to simple mode'"))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; AC20: single-account transaction re-opens in simple mode
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(deftest single-account-reopens-simple-test
|
||||
(testing "AC20: single-account transaction re-opens in simple mode with account/location pre-populated"
|
||||
(let [result @(dc/transact conn [{:db/id "client-id"
|
||||
:client/code "SINGCL"
|
||||
:client/locations ["DT"]}
|
||||
{:db/id "account-id"
|
||||
:account/name "Single Account"
|
||||
:account/type :account-type/expense}
|
||||
{:db/id "transaction-id"
|
||||
:transaction/amount 100.0
|
||||
:transaction/date #inst "2023-01-01"
|
||||
:transaction/id (str (java.util.UUID/randomUUID))
|
||||
:transaction/client "client-id"}])
|
||||
tx-id (tempid->id result "transaction-id")
|
||||
account-id (tempid->id result "account-id")
|
||||
client-id (tempid->id result "client-id")
|
||||
one-account [{:db/id "row-1"
|
||||
:transaction-account/account account-id
|
||||
:transaction-account/location "DT"
|
||||
:transaction-account/amount 100.0}]
|
||||
request {:multi-form-state (mm/->MultiStepFormState
|
||||
{:db/id tx-id
|
||||
:transaction/client client-id
|
||||
:transaction/accounts one-account}
|
||||
[]
|
||||
{:mode "simple"
|
||||
:transaction/accounts one-account})
|
||||
:entity {:db/id tx-id
|
||||
:transaction/client {:db/id client-id}
|
||||
:transaction/amount 100.0}}
|
||||
html (hiccup/html
|
||||
(fc/start-form (:multi-form-state request) nil
|
||||
(fc/with-field :step-params
|
||||
(manual-coding-section* :simple request))))]
|
||||
;; Simple mode should show the account typeahead pre-populated with the account
|
||||
(is (re-find (re-pattern (str account-id)) html)
|
||||
"AC20: Simple mode should show the account pre-populated")
|
||||
;; Should show 'Switch to advanced mode' (confirming it's in simple mode)
|
||||
(is (re-find #"Switch to advanced mode" html)
|
||||
"AC20: Simple mode should show 'Switch to advanced mode' link")
|
||||
;; Should NOT show 'Switch to simple mode'
|
||||
(is (not (re-find #"Switch to simple mode" html))
|
||||
"AC20: Simple mode should NOT show 'Switch to simple mode' link"))))
|
||||
@@ -135,13 +135,19 @@
|
||||
:payment/status :payment-status/pending
|
||||
:payment/date #inst "2023-06-15")
|
||||
;; Transaction and unpaid invoice for link testing
|
||||
(test-transaction :db/id "transaction-id-unpaid"
|
||||
:transaction/client "client-id"
|
||||
:transaction/bank-account "bank-account-id"
|
||||
:transaction/amount -150.0
|
||||
:transaction/description-original "Transaction for unpaid invoice link"
|
||||
:transaction/approval-status :transaction-approval-status/unapproved)
|
||||
(test-invoice :db/id "invoice-unpaid-id"
|
||||
(test-transaction :db/id "transaction-id-unpaid"
|
||||
:transaction/client "client-id"
|
||||
:transaction/bank-account "bank-account-id"
|
||||
:transaction/amount -150.0
|
||||
:transaction/description-original "Transaction for unpaid invoice link"
|
||||
:transaction/approval-status :transaction-approval-status/unapproved)
|
||||
(test-transaction :db/id "transaction-id-feedback"
|
||||
:transaction/client "client-id"
|
||||
:transaction/bank-account "bank-account-id"
|
||||
:transaction/amount 400.0
|
||||
:transaction/description-original "Transaction for feedback review"
|
||||
:transaction/approval-status :transaction-approval-status/requires-feedback)
|
||||
(test-invoice :db/id "invoice-unpaid-id"
|
||||
:invoice/client "client-id"
|
||||
:invoice/vendor "vendor-id"
|
||||
:invoice/total 150.0
|
||||
|
||||
Reference in New Issue
Block a user