Compare commits
127 Commits
5df1d63f80
...
staging
| Author | SHA1 | Date | |
|---|---|---|---|
| 85aaf7b759 | |||
| 3641846f70 | |||
| d360316590 | |||
| 8215e6376d | |||
| 3759258ebe | |||
| 0e02c489e0 | |||
| 917b7f3857 | |||
| a8d8a8d111 | |||
| 360847fa58 | |||
| 55650c2dab | |||
| 19186097d5 | |||
| 1f6395382d | |||
| d52159637e | |||
| 3648597031 | |||
| 901d9eb508 | |||
| 569e52d1c1 | |||
| 9cc3418b1b | |||
| a1098b28f8 | |||
| b6649a3d1d | |||
| 38ae6f460f | |||
| e156d8bfd8 | |||
| 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 | |||
| 2f9da3cdd9 | |||
| 78bd1d92e0 | |||
| 99dd88329e | |||
| de73233a08 | |||
| 11cc887671 | |||
| a4d7ac5982 | |||
| f42d937691 | |||
| 200056098f | |||
| 712b2c0cb8 | |||
| 85652a7ce7 | |||
| ae0788e6dd | |||
| f239b114c3 | |||
| 8e3aa13f4d | |||
| 5b2aba561c | |||
| 3715910029 | |||
| 03bfca35cb | |||
| ba87805d4c | |||
| 8bd0cee1b1 | |||
| 76c6eaddb9 | |||
| ddf11a7cb3 | |||
| adb6ecb1ff | |||
| 4221d6a0d6 | |||
| 918ddd14ff | |||
| acd4184ef0 | |||
| 857a1536ef | |||
| 535ef4d113 | |||
| 351659f8eb | |||
| 4739769297 | |||
| 567db50a66 | |||
| dbfa04c766 | |||
| 0692089e39 | |||
| 8189a7648b | |||
| dd4d1a6d4f | |||
| 4f32527732 | |||
| 0811771ae6 | |||
| c6b55ce567 | |||
| 1f9a7080e1 | |||
| 6f7f1c7815 | |||
| 065d1182d7 | |||
| b42e2a6a44 | |||
| e8979738ab | |||
| 08b948c24b | |||
| aae1d2168b | |||
| 83a739ac5b | |||
| 021a2f14f7 | |||
| 2c8985203e | |||
| 64506705e7 | |||
| 66b0b611e4 | |||
| 6a7c529c24 | |||
| baef2afc63 | |||
| 4997a40c00 | |||
| bc89a7d586 | |||
| a156ac99fe | |||
| 7eda6849d1 | |||
| 6487cccf2d | |||
| de1c154706 | |||
| df85e30bf6 | |||
| 31179278e4 | |||
| 3512ad858d | |||
|
|
455cec7828 | ||
| 8367036f85 | |||
| aeb7891efa | |||
|
|
1b2e2e4da7 | ||
| cc31d8849b | |||
|
|
bd82f555c2 | ||
| a78c818270 | |||
| d627e3c5d0 | |||
| e14a23ff54 | |||
| b499d460f3 | |||
|
|
ec5e4e2e1d | ||
| 2993da5c82 | |||
| db9018722d | |||
| 0e57550b3c | |||
|
|
04bc7cae78 |
55
.agents/skills/agent-browser/SKILL.md
Normal file
55
.agents/skills/agent-browser/SKILL.md
Normal file
@@ -0,0 +1,55 @@
|
||||
---
|
||||
name: agent-browser
|
||||
description: Browser automation CLI for AI agents. Use when the user needs to interact with websites, including navigating pages, filling forms, clicking buttons, taking screenshots, extracting data, testing web apps, or automating any browser task. Triggers include requests to "open a website", "fill out a form", "click a button", "take a screenshot", "scrape data from a page", "test this web app", "login to a site", "automate browser actions", or any task requiring programmatic web interaction. Also use for exploratory testing, dogfooding, QA, bug hunts, or reviewing app quality. Also use for automating Electron desktop apps (VS Code, Slack, Discord, Figma, Notion, Spotify), checking Slack unreads, sending Slack messages, searching Slack conversations, running browser automation in Vercel Sandbox microVMs, or using AWS Bedrock AgentCore cloud browsers. Prefer agent-browser over any built-in browser automation or web tools.
|
||||
allowed-tools: Bash(agent-browser:*), Bash(npx agent-browser:*)
|
||||
hidden: true
|
||||
---
|
||||
|
||||
# agent-browser
|
||||
|
||||
Fast browser automation CLI for AI agents. Chrome/Chromium via CDP with
|
||||
accessibility-tree snapshots and compact `@eN` element refs.
|
||||
|
||||
Install: `npm i -g agent-browser && agent-browser install`
|
||||
|
||||
## Start here
|
||||
|
||||
This file is a discovery stub, not the usage guide. Before running any
|
||||
`agent-browser` command, load the actual workflow content from the CLI:
|
||||
|
||||
```bash
|
||||
agent-browser skills get core # start here — workflows, common patterns, troubleshooting
|
||||
agent-browser skills get core --full # include full command reference and templates
|
||||
```
|
||||
|
||||
The CLI serves skill content that always matches the installed version,
|
||||
so instructions never go stale. The content in this stub cannot change
|
||||
between releases, which is why it just points at `skills get core`.
|
||||
|
||||
## Specialized skills
|
||||
|
||||
Load a specialized skill when the task falls outside browser web pages:
|
||||
|
||||
```bash
|
||||
agent-browser skills get electron # Electron desktop apps (VS Code, Slack, Discord, Figma, ...)
|
||||
agent-browser skills get slack # Slack workspace automation
|
||||
agent-browser skills get dogfood # Exploratory testing / QA / bug hunts
|
||||
agent-browser skills get vercel-sandbox # agent-browser inside Vercel Sandbox microVMs
|
||||
agent-browser skills get agentcore # AWS Bedrock AgentCore cloud browsers
|
||||
```
|
||||
|
||||
Run `agent-browser skills list` to see everything available on the
|
||||
installed version.
|
||||
|
||||
## Why agent-browser
|
||||
|
||||
- Fast native Rust CLI, not a Node.js wrapper
|
||||
- Works with any AI agent (Cursor, Claude Code, Codex, Continue, Windsurf, etc.)
|
||||
- Chrome/Chromium via CDP with no Playwright or Puppeteer dependency
|
||||
- Accessibility-tree snapshots with element refs for reliable interaction
|
||||
- Sessions, authentication vault, state persistence, video recording
|
||||
- Specialized skills for Electron apps, Slack, exploratory testing, cloud providers
|
||||
|
||||
## Observability Dashboard
|
||||
|
||||
The dashboard runs independently of browser sessions on port 4848 and can also be opened through a proxied or forwarded URL such as `https://dashboard.agent-browser.localhost`. Agents should stay on the dashboard origin: session tabs, status, and stream traffic are proxied internally, so session ports do not need to be exposed.
|
||||
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
|
||||
1
.env
Normal file
1
.env
Normal file
@@ -0,0 +1 @@
|
||||
OPENROUTER_API_KEY=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
|
||||
2
.envrc
Normal file
2
.envrc
Normal file
@@ -0,0 +1,2 @@
|
||||
export OPENROUTER_API_KEY=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
|
||||
export AWS_PROFILE=integreat
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -8,6 +8,8 @@ pom.xml.asc
|
||||
*.class
|
||||
/.lein-*
|
||||
/.nrepl-port
|
||||
nrepl-port
|
||||
.http-port
|
||||
resources/public/js/compiled
|
||||
*.log
|
||||
examples/
|
||||
@@ -46,3 +48,9 @@ data/solr/logs
|
||||
.vscode/**
|
||||
sysco-poller/**/*.csv
|
||||
.aider*
|
||||
.tmp/**
|
||||
playwright-report/**
|
||||
test-results/**
|
||||
# Scratch dir for temp files (screenshots, logs, etc.); keep the dir, ignore contents
|
||||
/tmp/*
|
||||
!/tmp/.gitkeep
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
56
.opencode/package-lock.json
generated
56
.opencode/package-lock.json
generated
@@ -5,7 +5,7 @@
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "1.14.25"
|
||||
"@opencode-ai/plugin": "1.15.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
|
||||
@@ -87,32 +87,36 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@opencode-ai/plugin": {
|
||||
"version": "1.14.25",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.14.25.tgz",
|
||||
"integrity": "sha512-Mvx8R9MFLFSJBHkcleAdRcdCW+ADiOKgzF89h80ywgsnRgwQEjVQjkCL+veyXjyiyvsai2S/qLNg6etlQRhfUw==",
|
||||
"version": "1.15.10",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.15.10.tgz",
|
||||
"integrity": "sha512-V2p7CvpBtKWB+FID7Dl1y0Ci02zUT40A9b2RD9R9BOiuD8ZcKhHWov+irN0xVJA0Eg6OhEBfA0lPKRn1FNKPlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "1.14.25",
|
||||
"effect": "4.0.0-beta.48",
|
||||
"@opencode-ai/sdk": "1.15.10",
|
||||
"effect": "4.0.0-beta.66",
|
||||
"zod": "4.1.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.1.103",
|
||||
"@opentui/solid": ">=0.1.103"
|
||||
"@opentui/core": ">=0.2.15",
|
||||
"@opentui/keymap": ">=0.2.15",
|
||||
"@opentui/solid": ">=0.2.15"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@opentui/core": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentui/keymap": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentui/solid": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@opencode-ai/sdk": {
|
||||
"version": "1.14.25",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.14.25.tgz",
|
||||
"integrity": "sha512-YSb+77OgIylwnk9aLoIyfr7FOx3MDD1ASEy2cUbOGTnhNqcR7S2sZAWLgkNWBBOkIH4tG6ouOckcG8lg12a3Tg==",
|
||||
"version": "1.15.10",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.15.10.tgz",
|
||||
"integrity": "sha512-CUhpmMGGOqzvPnNNjjWmEIodAfP6Qnuki2ChIUKWYF7UImZ4zUcMZnzO5BtUxu/Ni1P8qzWxDioXs+7aIZQEhA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cross-spawn": "7.0.6"
|
||||
@@ -149,9 +153,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/effect": {
|
||||
"version": "4.0.0-beta.48",
|
||||
"resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.48.tgz",
|
||||
"integrity": "sha512-MMAM/ZabuNdNmgXiin+BAanQXK7qM8mlt7nfXDoJ/Gn9V8i89JlCq+2N0AiWmqFLXjGLA0u3FjiOjSOYQk5uMw==",
|
||||
"version": "4.0.0-beta.66",
|
||||
"resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.66.tgz",
|
||||
"integrity": "sha512-4arEr62cziFa8BBVDUwJCJJmaVepXf/kRg7KtC0h8+bufngscrHbwWFhr9c+HonwOF+31U3iD3xUJmw9KzX7Dw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
@@ -167,9 +171,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/fast-check": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.7.0.tgz",
|
||||
"integrity": "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ==",
|
||||
"version": "4.8.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.8.0.tgz",
|
||||
"integrity": "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -216,9 +220,9 @@
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/msgpackr": {
|
||||
"version": "1.11.10",
|
||||
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.10.tgz",
|
||||
"integrity": "sha512-iCZNq+HszvF+fC3anCm4nBmWEnbeIAfpDs6IStAEKhQ2YSgkjzVG2FF9XJqwwQh5bH3N9OUTUt4QwVN6MLMLtA==",
|
||||
"version": "1.11.12",
|
||||
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.12.tgz",
|
||||
"integrity": "sha512-RBdJ1Un7yGlXWajrkxcSa93nvQ0w4zBf60c0yYv7YtBelP8H2FA7XsfBbMHtXKXUMUxH7zV3Zuozh+kUQWhHvg==",
|
||||
"license": "MIT",
|
||||
"optionalDependencies": {
|
||||
"msgpackr-extract": "^3.0.2"
|
||||
@@ -323,9 +327,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "13.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
|
||||
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
|
||||
"version": "13.0.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz",
|
||||
"integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
@@ -351,9 +355,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.3",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
|
||||
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
|
||||
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
|
||||
127
.opencode/skills/gitea-tea/SKILL.md
Normal file
127
.opencode/skills/gitea-tea/SKILL.md
Normal file
@@ -0,0 +1,127 @@
|
||||
---
|
||||
name: gitea-tea
|
||||
description: Use tea CLI to create, manage, and checkout Gitea pull requests. Use this when opening a PR, managing PRs, or checking out PRs on the gitea remote (gitea.story-basking.ts.net).
|
||||
---
|
||||
|
||||
# Gitea Tea CLI Skill
|
||||
|
||||
This skill covers using `tea` (Gitea's official CLI) for pull request workflows in this project.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
Use this skill when you need to:
|
||||
- Create a PR from a working branch to master on the gitea remote
|
||||
- Open, list, or view PRs
|
||||
- Checkout a PR locally for review or iteration
|
||||
- Manage PR state (close, reopen, merge)
|
||||
|
||||
## Project Setup
|
||||
|
||||
The gitea remote is `gitea.story-basking.ts.net` with repo slug `notid/integreat`. The default push remote is **gitea**, NOT origin and NOT deploy.
|
||||
|
||||
In this project's environment:
|
||||
- Gitea login is pre-configured for `gitea.story-basking.ts.net`
|
||||
- Repo slug: `notid/integreat`
|
||||
- Target branch for PRs: `master`
|
||||
- The git remote named `gitea` points to this instance
|
||||
|
||||
## Creating a PR
|
||||
|
||||
Use `tea pulls create` to open a PR from the current branch to master. Always specify `-r notid/integreat -b master`:
|
||||
|
||||
```bash
|
||||
tea pulls create -r notid/integreat -b master --title "Title" --description "Body"
|
||||
```
|
||||
|
||||
Common flags:
|
||||
- `-t, --title` - PR title
|
||||
- `-d, --description` - PR body/description (use heredoc or file for long descriptions)
|
||||
- `-a, --assignees` - Comma-separated usernames to assign
|
||||
- `-L, --labels` - Comma-separated labels to apply
|
||||
- `-m, --milestone` - Milestone to assign
|
||||
|
||||
**Writing a multiline description:**
|
||||
|
||||
```bash
|
||||
tea pulls create -r notid/integreat -b master \
|
||||
-t "feat: add feature" \
|
||||
-d "$(cat <<'EOF'
|
||||
## Summary
|
||||
- Bullet point one
|
||||
- Bullet point two
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
Or write the body to a temp file first and reference it.
|
||||
|
||||
## Listing PRs
|
||||
|
||||
```bash
|
||||
tea pulls list -r notid/integreat # List open PRs
|
||||
tea pulls list -r notid/integreat --state all # All PRs
|
||||
tea pulls list -r notid/integreat --limit 10 -o simple # Limit output, simple format
|
||||
```
|
||||
|
||||
## Opening a PR in Browser
|
||||
|
||||
```bash
|
||||
tea open pr <number> -r notid/integreat
|
||||
tea open pr create -r notid/integreat # Open web UI to create a PR
|
||||
```
|
||||
|
||||
## Checking Out a PR Locally
|
||||
|
||||
```bash
|
||||
tea pulls checkout <number> -r notid/integreat
|
||||
```
|
||||
|
||||
This fetches and checks out the PR branch locally.
|
||||
|
||||
## Managing PR State
|
||||
|
||||
**Close a PR:**
|
||||
```bash
|
||||
tea pulls close <number> -r notid/integreat --confirm
|
||||
```
|
||||
|
||||
**Reopen a closed PR:**
|
||||
```bash
|
||||
tea pulls reopen <number> -r notid/integreat --confirm
|
||||
```
|
||||
|
||||
**Merge a PR:**
|
||||
```bash
|
||||
tea pulls merge <number> -r notid/integreat --confirm
|
||||
```
|
||||
|
||||
**Edit a PR (title, description, etc.):**
|
||||
```bash
|
||||
tea pulls edit <number> -r notid/integreat --title "New title" --description "New body"
|
||||
```
|
||||
|
||||
## Full PR Creation Workflow
|
||||
|
||||
1. Ensure the branch is pushed to gitea:
|
||||
```bash
|
||||
git push gitea <branch-name>
|
||||
```
|
||||
|
||||
2. Create the PR with tea:
|
||||
```bash
|
||||
tea pulls create -r notid/integreat -b master \
|
||||
--title "feat: description of change" \
|
||||
--description "Detailed PR body here"
|
||||
```
|
||||
|
||||
3. Open the PR in browser to verify:
|
||||
```bash
|
||||
tea open pr <number> -r notid/integreat
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
- Always use `-r notid/integreat` to specify the repo explicitly
|
||||
- Use `-b master` to set the target branch (default may differ)
|
||||
- The `--confirm` flag is required for destructive actions (close, merge)
|
||||
- Use `-o simple`, `-o json`, `-o table`, etc. to control output format
|
||||
25
AGENTS.md
25
AGENTS.md
@@ -1,5 +1,9 @@
|
||||
# Integreat Development Guide
|
||||
|
||||
## Temporary Files
|
||||
|
||||
Write any temporary files (screenshots, scratch logs, generated artifacts, etc.) to the `./tmp/` directory at the repo root. Its contents are gitignored (only `.gitkeep` is tracked), so nothing there will be accidentally committed. Do not scatter temp files elsewhere in the repo or in the system `/tmp`.
|
||||
|
||||
## Build & Run Commands
|
||||
|
||||
### Build
|
||||
@@ -20,6 +24,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,9 +38,25 @@ 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
|
||||
|
||||
## Pull Requests on Gitea
|
||||
|
||||
This project uses **gitea story-basking.ts net** as the primary remote for PRs. Use `tea` (the Gitea CLI) to create and manage pull requests. The gitea remote is the one you push to, NOT origin and NOT deploy.
|
||||
|
||||
**When opening a PR**, load and follow the **gitea-tea** skill. In short:
|
||||
- Target branch is always `master`
|
||||
- Use `tea pulls create -r notid/integreat -b master --title "..." --description "..."`
|
||||
|
||||
### Run All Tests
|
||||
```bash
|
||||
lein test # WORST CASE
|
||||
|
||||
144
AUTOMATION_NOTES.md
Normal file
144
AUTOMATION_NOTES.md
Normal file
@@ -0,0 +1,144 @@
|
||||
# Automation Notes
|
||||
|
||||
Findings from investigating intermittent dialog-open failures on `/pos/summaries` (and likely other grid pages) when driven by `agent-browser`. Most of these apply equally to any browser automation — Playwright, Selenium, manual rapid-click testing.
|
||||
|
||||
## TL;DR
|
||||
|
||||
The reported "sometimes the dialog opens, sometimes it doesn't" was a server-side bug: `icon-button-` rendered as `<button>` with the HTML default `type="submit"`. Inside a `<form>` (every row in `grid_page_helper`), the click raced HTMX. If form submission won, the browser navigated to `/pos/summaries?id=…` and the modal request was canceled.
|
||||
|
||||
Fix is in `src/clj/auto_ap/ssr/components/buttons.clj` — `icon-button-` now defaults `:type "button"`. Verified with 30/30 rapid open/close cycles with random close delays spanning the entire 300 ms transition window.
|
||||
|
||||
## Modal lifecycle (for reference)
|
||||
|
||||
1. User clicks pencil → htmx `GET /pos/summaries/:id` (the edit-wizard route).
|
||||
2. Server returns response with headers `hx-trigger: modalopen`, `hx-retarget: #modal-content`, `hx-reswap: innerHTML`. See `modal-response` in `src/clj/auto_ap/ssr/utils.clj:41`.
|
||||
3. htmx swaps innerHTML of `#modal-content`, then dispatches a `modalopen` document event.
|
||||
4. Alpine handler on `#modal-holder` (`src/clj/auto_ap/ssr/ui.clj:84`) sets `open=true`.
|
||||
5. `x-show="open"` triggers a 300 ms enter transition on two nested divs (backdrop + content).
|
||||
6. Closing dispatches `modalclose`, sets `open=false`, runs the 300 ms leave transition.
|
||||
|
||||
## Root cause of the reported flakiness
|
||||
|
||||
`grid_page_helper.clj:58-61` wraps each row's action buttons in a `<form>` with a hidden `id` field:
|
||||
|
||||
```clojure
|
||||
(com/data-grid-right-stack-cell {}
|
||||
(into [:form.flex.space-x-2
|
||||
[:input {:type :hidden :name "id" :value ((:id-fn gridspec) entity)}]]
|
||||
((:row-buttons gridspec) request entity)))
|
||||
```
|
||||
|
||||
The buttons in `:row-buttons` come from `icon-button-`, which rendered `<button>` with no explicit type. HTML default: `type="submit"`. When the pencil is clicked:
|
||||
|
||||
- htmx normally intercepts via `hx-get` and calls `preventDefault()`.
|
||||
- If anything (large DOM, htmx still initializing other elements, agent-browser issuing the click in a busy frame) delays htmx's listener relative to the form's submit handler, the form submits.
|
||||
- Form submission triggers a same-page navigation to `/pos/summaries?id=<value>`, which cancels the in-flight XHR. The modal request never lands.
|
||||
|
||||
The race is non-deterministic, which is why it was intermittent. Browser automation makes it more visible because clicks fire faster than a human's, hitting moments when htmx might not yet have fully registered.
|
||||
|
||||
**Fix:** `icon-button-` now does `(merge {:type "button"} params)`. Same fix should be applied prophylactically to any other button helper used inside a row form: `button-`, `a-button-` (less relevant, `<a>` doesn't submit), `navigation-button-`. `group-button-` already sets `type="button"`. `validated-save-button-` correctly stays `submit`.
|
||||
|
||||
## Other findings (cosmetic — not causing failures)
|
||||
|
||||
### Duplicate `x-trap` directive
|
||||
|
||||
`src/clj/auto_ap/ssr/ui.clj:99-100`:
|
||||
|
||||
```clojure
|
||||
"x-trap.inert.noscroll" "open"
|
||||
"x-trap.inert" "open"
|
||||
```
|
||||
|
||||
Both bound to the same expression. Alpine de-duplicates by directive name, so this is dead code. Drop the second line.
|
||||
|
||||
### Mixed `bg-opacity` and `opacity` in inner-modal transitions
|
||||
|
||||
`src/clj/auto_ap/ssr/ui.clj:103-107`:
|
||||
|
||||
```clojure
|
||||
"x-transition:enter-start" "!bg-opacity-0 !translate-y-32"
|
||||
"x-transition:enter-end" "!bg-opacity-100 !translate-y-0"
|
||||
"x-transition:leave-start" "!opacity-100 !translate-y-0"
|
||||
"x-transition:leave-end" "!opacity-0 !translate-y-32"
|
||||
```
|
||||
|
||||
The inner div has no background color, so `bg-opacity-*` does nothing during enter. The leave correctly animates `opacity`. Net effect: enter is a translate-only animation while leave is translate-plus-fade. Asymmetric but works. Should be `opacity` on both sides for consistency.
|
||||
|
||||
### `_x_hidePromise` lingering flag
|
||||
|
||||
After rapid close→open cycles, Alpine's internal `_x_hidePromise` property remains truthy on the inner div even when the element is fully visible. It looks alarming when inspecting state but does not block subsequent transitions. Verified empirically with 30 trials.
|
||||
|
||||
## Browser-automation specifics
|
||||
|
||||
Things that aren't bugs but bite agent-browser scripts:
|
||||
|
||||
### Refs go stale on every HTMX swap
|
||||
|
||||
The `@eN` refs from a `snapshot` are valid only until the page changes. HTMX swaps innerHTML of `#modal-content` on every modal open, so any ref pointing inside the modal — or even refs pointing to row elements after the grid refreshes — silently breaks. Re-snapshot before each interaction; the docs explicitly warn about this.
|
||||
|
||||
### Default click is too fast for a busy frame
|
||||
|
||||
`agent-browser click @eN` dispatches a synthetic click via CDP without waiting for the page to settle. For htmx-driven interactions, the safe pattern is:
|
||||
|
||||
1. Click.
|
||||
2. Wait for the observable side-effect, not for time.
|
||||
|
||||
For modal opens specifically:
|
||||
|
||||
```bash
|
||||
agent-browser click @e_pencil
|
||||
agent-browser wait --fn "document.querySelector('#modal-holder')?._x_dataStack?.[0]?.open && document.querySelector('#modal-content').children.length > 0"
|
||||
agent-browser snapshot -i
|
||||
```
|
||||
|
||||
For modal closes:
|
||||
|
||||
```bash
|
||||
# After clicking a Save button that returns hx-trigger: modalclose
|
||||
agent-browser wait --fn "!document.querySelector('#modal-holder')?._x_dataStack?.[0]?.open"
|
||||
```
|
||||
|
||||
For grid refreshes after filter changes:
|
||||
|
||||
```bash
|
||||
agent-browser wait --fn "!document.querySelector('.htmx-request')"
|
||||
```
|
||||
|
||||
(htmx adds the `.htmx-request` class to elements during in-flight requests.)
|
||||
|
||||
### CDP screenshot timeouts
|
||||
|
||||
`agent-browser screenshot` occasionally returns `CDP command timed out: Page.captureScreenshot`. This is a Chromium/CDP issue, not application code. Workarounds:
|
||||
|
||||
- Don't rely on screenshots for state verification. Read state via `agent-browser eval` directly.
|
||||
- If you need an image, retry once after a small wait.
|
||||
|
||||
### Reading Alpine state for diagnostics
|
||||
|
||||
Useful one-liners when debugging modal state:
|
||||
|
||||
```bash
|
||||
agent-browser eval --stdin <<'EOF'
|
||||
(()=>{
|
||||
const h = document.querySelector('#modal-holder');
|
||||
const c = document.querySelector('#modal-content');
|
||||
const inner = c?.parentElement;
|
||||
return {
|
||||
open: h?._x_dataStack?.[0]?.open,
|
||||
unexpectedError: h?._x_dataStack?.[0]?.unexpectedError,
|
||||
contentChildren: c?.children.length,
|
||||
innerDisplay: inner ? getComputedStyle(inner).display : null,
|
||||
innerOpacity: inner ? getComputedStyle(inner).opacity : null,
|
||||
hxRequest: !!document.querySelector('.htmx-request')
|
||||
};
|
||||
})()
|
||||
EOF
|
||||
```
|
||||
|
||||
## Patterns that improve reliability
|
||||
|
||||
When adding new interactive components:
|
||||
|
||||
- **Every `<button>` inside a form must declare `:type`**. Default to `"button"` for icon/utility buttons; only the actual submit needs `"submit"`. Either the component helper sets it or the call site does — never rely on the HTML default inside a form.
|
||||
- **Don't dispatch `modalclose` and `modalopen` in the same tick.** They share `open` state and the result depends on order. If a flow needs to swap modals, use `modal-replace-response` (which sets `hx-trigger: modalswap` — see `src/clj/auto_ap/ssr/utils.clj:51`) so the swap goes through the `@modalswap.document` handler that explicitly sequences with `$nextTick`.
|
||||
- **Prefer waiting on observable DOM/state over fixed delays.** `wait --fn` with an Alpine state check is faster and more reliable than `wait 500`.
|
||||
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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM amazoncorretto:11-alpine
|
||||
FROM 679918342773.dkr.ecr.us-east-1.amazonaws.com/corretto:11-alpine
|
||||
RUN apk add --no-cache poppler-utils
|
||||
COPY target/auto-ap.jar /usr/local/
|
||||
COPY config /usr/local/config/
|
||||
|
||||
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"
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
---
|
||||
date: 2026-06-01
|
||||
topic: manual-transaction-import-ssr
|
||||
focus: Port the master-branch "manual import transactions" feature to the SSR/alpine/htmx stack, modeled on the SSR ledger import, preserving all validations, starting from a failing e2e test, with minimal core-component change.
|
||||
mode: repo-grounded
|
||||
---
|
||||
|
||||
# Ideation: Porting Manual Bank-Transaction Import to SSR
|
||||
|
||||
## Grounding Context (Codebase)
|
||||
|
||||
Three reference points were read in full:
|
||||
|
||||
**1. The master feature (what we must reproduce).**
|
||||
- UI: `src/cljs/auto_ap/views/pages/transactions/manual.cljs` — a re-frame modal titled "Import Transactions" with a single `<textarea>` ("Yodlee manual import table"). User pastes tab-separated Yodlee data, clicks "Import". POSTs `(:data)` as EDN to `/api/transactions/batch-upload`.
|
||||
- Route/handler: `src/clj/auto_ap/routes/invoices.clj:241` `batch-upload-transactions` → `assert-admin`, then `manual/import-batch (manual/tabulate-data data)`.
|
||||
- Parsing: `src/clj/auto_ap/import/manual.clj` — `tabulate-data` reads CSV with `\tab` separator, drops the header row, and maps **fixed positional columns**: `[:status :raw-date :description-original :high-level-category nil nil :amount nil nil nil nil nil :bank-account-code :client-code]`.
|
||||
- Per-row mapping/validation: `manual->transaction` uses `import.manual.common/assoc-or-error` to accumulate errors for: **client lookup** (by bank-account-code → client), **bank-account lookup** (by code), **date parse** (`parse-date`, requires `MM/dd/yyyy`), **amount parse** (`parse-amount`).
|
||||
- Engine: `import.manual/import-batch` builds lookups, calls `t/start-import-batch :import-source/manual`, applies `t/apply-synthetic-ids` (dedupe key), imports only rows with no `:errors`, returns stats `{:import-batch/imported ... :failed-validation N :sample-error "..."}`.
|
||||
- Deeper validations live in `src/clj/auto_ap/import/transactions.clj` `categorize-transaction`: status must be `"POSTED"`, transaction date must be after `bank-account/start-date` and after `client/locked-until`, duplicate detection via `:transaction/id` (extant cache), missing client/bank-account/id → `:error`, plus suppression. Synthetic-id (`apply-synthetic-ids`/`synthetic-key`) gives idempotent re-import.
|
||||
|
||||
**2. The SSR ledger import (the pattern to emulate).**
|
||||
- `src/clj/auto_ap/ssr/ledger.clj` implements a **dedicated two-stage page**, not a modal:
|
||||
- `external-import-text-form*` (~L246): alpine `x-data {clipboard}`, a hidden `text-area` bound `x-model`, an `hx-post` to `::route/external-import-parse` triggered on a `"pasted"` event; a "Load from clipboard" button reads `navigator.clipboard`.
|
||||
- `external-import-parse` (~L373): re-renders `external-import-form*` with `:just-parsed? true`.
|
||||
- `external-import-table-form*` (~L117): renders parsed rows into an **editable** `com/data-grid-card` — each cell is a `fc/with-field` text/money input (form-cursor round-trips values + errors); per-row error/warning badge with tooltip; "Import" `hx-post`s to `::route/external-import-import`.
|
||||
- Validation: malli `parse-form-schema` (~L341) with `:decode/string tsv->import-data` + `:decode/arbitrary` (vector→map) enforces shape (min-length, `clj-date-schema`, `money`, location max 2). Business validation in `add-errors`/`table->entries` (~L380–504) accumulates `[message status]` pairs (`:error`/`:warn`) at entry and line-item level; `flatten-errors` maps them to form-cursor field paths `[[:table idx] message status]`.
|
||||
- `import-ledger` (~L554): splits good/ignored(warn-only)/bad entries; `throw+` `:field-validation` with `:form-errors` if any bad; upserts hidden vendors; retracts+re-inserts good entries (idempotent via `:journal-entry/external-id`); touches solr; returns `{:successful :ignored :form-errors}`. `external-import-import` wraps it in an `html-response` with an `hx-trigger` notification.
|
||||
|
||||
**3. THE KEY DISCOVERY — scaffolding already exists, handlers do not.**
|
||||
- `src/cljc/auto_ap/routes/transactions.cljc` **already declares** the route names, mirroring ledger exactly:
|
||||
```
|
||||
"/external-new" ::external-page
|
||||
"/external-import-new" {"" ::external-import-page
|
||||
"/parse" ::external-import-parse
|
||||
"/import" ::external-import-import}
|
||||
```
|
||||
- But `src/clj/auto_ap/ssr/transaction.clj` `key->handler` (L101) wires **only** `page/table/csv/bank-account-filter/bulk-delete` (+ `edit/` + `bulk-code/`). **No handler exists for any `external-import-*` route.** The aside nav (`ssr/components/aside.clj`) wires the *ledger* import nav (`::ledger-routes/external-import-page`) but there is no transaction-import nav entry. So the routes are declared dead ends; the gap is handlers + UI + validation + nav + tests.
|
||||
|
||||
**Test conventions.** `test/clj/auto_ap/ssr/ledger_test.clj` is the template: pure-fn tests (`tsv->import-data`, `trim-header`, `line->id`, `flatten-errors`), validation tests (`add-errors` per error type), tx-building tests (`entry->tx`), and end-to-end import tests (`import-ledger` against Datomic via `wrap-setup` + `setup-test-data` + `admin-token`). `test/clj/auto_ap/test-server.clj` is a Playwright/browser e2e harness with `wrap-test-auth` and seeded `setup-test-data`. Existing engine unit tests: `test/clj/auto_ap/import/transactions_test.clj` already covers `categorize-transaction`.
|
||||
|
||||
## Topic Axes
|
||||
|
||||
- **Input/paste fidelity** — keeping the exact Yodlee positional-column paste payload (req #1)
|
||||
- **UI flow & surface** — modal vs. dedicated two-stage paste→review→import page (req #2)
|
||||
- **Validation architecture** — where shape vs. business rules live, and how errors surface (req #3)
|
||||
- **Core/backend reuse** — how much of `import.transactions` / `import.manual` is reused vs. reimplemented (req #5)
|
||||
- **Test strategy** — failing-first e2e, then unit coverage (req #4)
|
||||
|
||||
## The Central Design Fork
|
||||
|
||||
Requirements #1 ("paste the exact same content") and #2 ("follow ledger patterns") pull in slightly different directions, but resolve cleanly:
|
||||
|
||||
- **Input format stays identical** — the user still copies the same Yodlee table and pastes the same tab-separated positional columns. We reuse `manual/columns` + `manual/tabulate-data` for the *paste shape*; we do **not** switch to ledger's named-header columns.
|
||||
- **Everything downstream follows ledger** — dedicated page, clipboard paste, editable review grid, per-row error/warning badges, re-validate loop, notification on import.
|
||||
|
||||
The one decision genuinely worth the user's input is **how much of the ledger UX to adopt**: the full editable review grid (idea #5, higher value, more work) vs. a lighter paste→validate→summary page closer to master (still SSR, less scope). Both are presented below as ranked survivors; this is the seed question for brainstorming.
|
||||
|
||||
## Ranked Ideas
|
||||
|
||||
### 1. Wire the pre-scaffolded routes into a dedicated two-stage SSR import page
|
||||
**Description:** Implement `external-import-page` / `external-import-parse` / `external-import-import` handlers in a new `src/clj/auto_ap/ssr/transaction/import.clj`, add them to `ssr/transaction.clj` `key->handler` (with the same middleware stack: `wrap-must {:activity :view :subject :transaction}`, `wrap-client-redirect-unauthenticated`, etc.), and add a transaction "Import" nav entry in `aside.clj` parallel to the ledger one. The page structure mirrors `ledger/external-import-page`: breadcrumb → clipboard script → paste form → review table.
|
||||
**Axis:** UI flow & surface
|
||||
**Basis:** `direct:` `routes/transactions.cljc` already declares `::external-import-page/parse/import` but `ssr/transaction.clj:101 key->handler` wires none of them; `ssr/ledger.clj:686 key->handler` shows the exact wiring shape to copy.
|
||||
**Rationale:** The routing contract already exists and is unused — the cheapest, lowest-risk foundation, and it's what makes req #2 ("like the ledger import") literally true at the routing/page level.
|
||||
**Downsides:** Adds a new namespace; needs a nav entry and middleware parity to avoid auth gaps.
|
||||
**Confidence:** 95%
|
||||
**Complexity:** Low
|
||||
**Status:** Unexplored
|
||||
|
||||
### 2. Preserve the exact Yodlee positional paste format (req #1)
|
||||
**Description:** Reuse `import.manual/columns` + `tabulate-data` (or a malli `:decode/string` wrapper around them) for parsing the pasted TSV, instead of ledger's named-header `tsv->import-data`. Keep the same column positions (`:status :raw-date :description-original ... :amount ... :bank-account-code :client-code`) so users paste identical content.
|
||||
**Axis:** Input/paste fidelity
|
||||
**Basis:** `direct:` Requirement #1 ("Paste the exact same type of content as was in the master branch version") + `import/manual.clj:10` fixed `columns` vector; the master textarea label is literally "Yodlee manual import table".
|
||||
**Rationale:** Users' upstream copy/paste habit (and the Yodlee export shape) is the contract that must not break; the ledger named-header approach would silently change what content is valid.
|
||||
**Downsides:** Positional columns are brittle if Yodlee changes column order — but that's already the status quo, not a regression.
|
||||
**Confidence:** 90%
|
||||
**Complexity:** Low
|
||||
**Status:** Unexplored
|
||||
|
||||
### 3. Reuse the `import.transactions` engine as the backend (req #5)
|
||||
**Description:** The import handler maps parsed rows → `:transaction/*` maps via the existing `manual->transaction` shape, then drives `import.transactions/start-import-batch :import-source/manual` + `import-transaction!` + `finish!` + `get-stats` — exactly as `import.manual/import-batch` does today. The SSR layer is presentation + pre-validation only; the categorization, rule-matching, payment/deposit clearing, synthetic-id dedupe, and audit-transact paths are untouched.
|
||||
**Axis:** Core/backend reuse
|
||||
**Basis:** `direct:` Requirement #5 ("Minimally, if at all, change any core components") + `import/manual.clj:32 import-batch` already encapsulates the whole engine call; `import/transactions.clj` is covered by `transactions_test.clj`.
|
||||
**Rationale:** This is the battle-tested core. Re-implementing it ledger-style would risk dropping validations (`categorize-transaction`) and duplicate a large, audited code path. Wrapping it keeps core change near zero.
|
||||
**Downsides:** `import-batch` returns summary stats, not per-row form-cursor errors — so the pre-validation layer (idea #4) must surface row errors *before* the engine runs.
|
||||
**Confidence:** 90%
|
||||
**Complexity:** Medium
|
||||
**Status:** Unexplored
|
||||
|
||||
### 4. Two-tier validation: malli shape-parse + a transaction `add-errors` business layer
|
||||
**Description:** Mirror ledger's split. Tier 1: a malli `parse-form-schema` decodes the pasted TSV and coerces/validates *shape* (date parses as `MM/dd/yyyy`, amount parses, required fields present) — reusing `manual.common/parse-date`/`parse-amount` as `:decode`/predicate fns. Tier 2: a transaction-specific `add-errors`/`table->entries` adds *business* errors/warnings by reusing the predicates already encoded in `categorize-transaction`: client-not-found (`:error`), bank-account-not-found (`:error`), date before `bank-account/start-date` (`:warn`/`:not-ready`), client locked-until (`:warn`), status not `POSTED` (`:not-ready`), already-imported/extant (`:warn`). Errors are `[message status]` pairs surfaced via `flatten-errors` → form-cursor field paths.
|
||||
**Axis:** Validation architecture
|
||||
**Basis:** `direct:` Requirement #3 ("every validation maintained… but doesn't have to follow the same structure — make it like the ledger import"). Maps master validations (`manual.clj:23-30`, `transactions.clj:191-225 categorize-transaction`) onto ledger's `add-errors`/`flatten-errors` shape (`ledger.clj:380-519`).
|
||||
**Rationale:** Gives the ledger-style inline error UX while guaranteeing 1:1 validation parity — each master check becomes an explicit `add-errors` clause, which is also directly unit-testable (one test per error type, like `ledger_test/add-errors-test`).
|
||||
**Downsides:** Some checks (extant/duplicate, start-date, locked-until) need a DB read at validation time that master does lazily inside the engine — must decide whether to pre-check or let the engine's stats report them. Risk of double-validation drift if engine and pre-validator disagree.
|
||||
**Confidence:** 80%
|
||||
**Complexity:** High
|
||||
**Status:** Unexplored
|
||||
|
||||
### 5. Editable review grid with per-row error/warning badges and a re-validate loop
|
||||
**Description:** After paste+parse, render rows into a `com/data-grid-card` where each field (date, amount, description, bank-account-code, client-code) is an editable `fc/with-field` input, with `com/validated-field` error display and a per-row alert badge+tooltip — exactly like `ledger/external-import-table-form*`. The user can fix a wrong client/bank-account code or date inline and re-submit; only clean rows import, warn-only rows are skipped, error rows block. A "Show table" toggle keeps the default view compact.
|
||||
**Axis:** UI flow & surface / validation surfacing
|
||||
**Basis:** `direct:` Requirement #2 ("follow slightly better design patterns, like how the ledger import works"). `ledger.clj:117-244` is the editable-grid implementation to copy; master has no inline correction at all (fire-and-forget modal + summary stats).
|
||||
**Rationale:** This is the concrete UX upgrade over master and the main reason to model on ledger — turning "paste, pray, read a stats blob" into "paste, see exactly which rows are wrong and why, fix them, import."
|
||||
**Downsides:** Highest-effort idea; form-cursor round-tripping of an editable grid is the trickiest part of the ledger code. If scope must shrink, a read-only review table + summary (lighter survivor) is the fallback.
|
||||
**Confidence:** 75%
|
||||
**Complexity:** High
|
||||
**Status:** Unexplored
|
||||
|
||||
### 6. Preserve idempotent re-import via synthetic-id duplicate detection, surfaced as "already imported"
|
||||
**Description:** Keep `apply-synthetic-ids`/`synthetic-key` so re-pasting the same export is idempotent (the engine categorizes extant rows as `:extant` and skips them). Surface this in the review grid as a `:warn`-level "already imported" badge rather than silently dropping it, so the user understands why a row didn't import.
|
||||
**Axis:** Validation architecture
|
||||
**Basis:** `direct:` `import/transactions.clj:405-421 apply-synthetic-ids` + `categorize-transaction:192-225` extant handling. This is an existing master behavior that req #3 requires us to maintain.
|
||||
**Rationale:** Duplicate-safety is an easy validation to lose in a port; making it visible (vs. master's opaque stats) is a small, high-trust UX win that costs almost nothing on top of idea #5.
|
||||
**Downsides:** Requires a DB read of existing `:transaction/id`s at validation time (or reading it back from engine stats post-import).
|
||||
**Confidence:** 80%
|
||||
**Complexity:** Low
|
||||
**Status:** Unexplored
|
||||
|
||||
### 7. Start with a failing Playwright e2e, then backfill ledger_test-style unit coverage (req #4)
|
||||
**Description:** First commit: a failing e2e (against `test-server`) that dev-logs in, navigates dashboard → transactions → Import, pastes a known-good Yodlee TSV into the paste box, asserts parsed rows render, clicks Import, and asserts the transactions appear / a "N imported" notification fires. It fails initially (no handler/nav). Then make it pass incrementally: route+page (idea #1) → parse (#2/#4 tier 1) → review grid (#5) → import via engine (#3) → business validation (#4 tier 2). Backstop with unit tests mirroring `ledger_test.clj`: `tabulate-data`/parse, each `add-errors` validation clause, and an end-to-end `import` test against Datomic. Reuse the existing `transactions_test.clj` `categorize-transaction` coverage as the validation-parity oracle.
|
||||
**Axis:** Test strategy
|
||||
**Basis:** `direct:` Requirement #4 ("Write detailed acceptance criteria, and start with a failing e2e test, making it pass over time"). `test-server.clj` already provides the browser harness + test auth; `ledger_test.clj` provides the unit-test template.
|
||||
**Rationale:** A red e2e pins down the acceptance contract before any handler exists and gives an unambiguous "done" signal; the unit layer locks in validation parity clause-by-clause so req #3 can't silently regress.
|
||||
**Downsides:** e2e clipboard paste may need a direct `type`-into-textarea path (or a test seam) since `navigator.clipboard.read()` is awkward to drive headless — plan a paste fallback the test can use.
|
||||
**Confidence:** 85%
|
||||
**Complexity:** Medium
|
||||
**Status:** Unexplored
|
||||
|
||||
## Draft Acceptance Criteria (seed for brainstorm/plan, per req #4)
|
||||
|
||||
**Routing & access**
|
||||
- [ ] `GET /transactions/external-import-new` renders an import page (admin-gated, same middleware as other transaction routes); 401/redirect for unauthenticated.
|
||||
- [ ] A "Import" nav entry appears under the transactions section, active on the import route.
|
||||
|
||||
**Paste & parse (req #1)**
|
||||
- [ ] Pasting the exact master Yodlee TSV (same positional columns) parses into the same field set as `manual/tabulate-data`.
|
||||
- [ ] Header row is dropped; blank rows ignored.
|
||||
- [ ] `POST …/parse` re-renders the page with a "N rows found" banner and the review table.
|
||||
|
||||
**Validation parity (req #3)** — each must produce a visible, row-attributed message:
|
||||
- [ ] Client not found for bank-account-code → error.
|
||||
- [ ] Bank account not found by code → error.
|
||||
- [ ] Date not `MM/dd/yyyy` / unparseable → error.
|
||||
- [ ] Amount unparseable → error.
|
||||
- [ ] Status ≠ `POSTED` → not-imported (warn/not-ready).
|
||||
- [ ] Date before `bank-account/start-date` → not-imported (not-ready).
|
||||
- [ ] Date on/before `client/locked-until` → not-imported (not-ready).
|
||||
- [ ] Already-imported (synthetic-id extant) row → skipped, surfaced as warn.
|
||||
- [ ] Missing client / bank-account / id → error.
|
||||
|
||||
**Import (req #5, minimal core change)**
|
||||
- [ ] `POST …/import` runs the existing `import.transactions` engine via the `:import-source/manual` batch path; no change to `categorize-transaction`/`import-transaction!`/`apply-synthetic-ids`.
|
||||
- [ ] Only clean rows import; warn-only rows skipped; any error blocks (or imports clean rows + reports errors — match master's "import valid, report failed-validation").
|
||||
- [ ] Success notification reports counts (imported / skipped / errors), mirroring master's stats.
|
||||
- [ ] Re-importing the same paste is idempotent (no duplicates).
|
||||
|
||||
**Tests (req #4)**
|
||||
- [ ] A Playwright e2e covering paste → review → import → assert, committed red first, green at the end.
|
||||
- [ ] Unit tests per validation clause + a Datomic-backed end-to-end import test, modeled on `ledger_test.clj`.
|
||||
|
||||
## Failing-First e2e: concrete starting point
|
||||
|
||||
Add `test/clj/auto_ap/ssr/transaction/import_test.clj` (unit) and a Playwright spec driven through `test-server`. The e2e is the first artifact and is expected to fail because no `external-import` handler is wired in `ssr/transaction.clj`. Make it green by walking ideas #1 → #2 → #5 → #3 → #4 in that order; the unit suite grows alongside #4.
|
||||
|
||||
## Rejection Summary
|
||||
|
||||
| # | Idea | Reason Rejected |
|
||||
|---|------|-----------------|
|
||||
| 1 | Switch paste format to ledger's named-header columns | Violates req #1 — users paste the exact Yodlee positional export; changing valid input is a silent regression |
|
||||
| 2 | Keep it a re-frame/CLJS modal | Branch eliminated the CLJS app; contradicts the whole port. (An SSR htmx *modal* was considered but rejected vs. the dedicated page — ledger uses a page and the editable review grid needs the room) |
|
||||
| 3 | Reimplement validation entirely ledger-style, ignoring `import.transactions` | Duplicates audited `categorize-transaction` logic, risks dropping validations (req #3), and churns core (violates req #5) |
|
||||
| 4 | Async/streaming import for large pastes | Scope overrun — master is synchronous; YAGNI for the manual paste workflow |
|
||||
| 5 | Add CSV file-upload alongside paste | Scope overrun — not part of the master manual-import feature |
|
||||
| 6 | Replace `import-batch` stats with a bespoke result type | Unnecessary core change; the existing stats map already carries imported/failed/sample-error |
|
||||
@@ -0,0 +1,219 @@
|
||||
---
|
||||
title: Complete Automatic Sales Summary Calculations and Ledger Posting
|
||||
type: feat
|
||||
status: completed
|
||||
date: 2026-04-24
|
||||
---
|
||||
|
||||
# Complete Automatic Sales Summary Calculations and Ledger Posting
|
||||
|
||||
## What's Incomplete
|
||||
- **Automatic Totals**: Aggregate attributes (e.g., `:sales-summary/total-card-payments`) are not calculated/stored by the job.
|
||||
- **Data Persistence**: Automatic recalculations risk overwriting manual user adjustments.
|
||||
- **Automation Gap**: Ledger entries are currently imported from external Excel files rather than generated automatically from the summaries.
|
||||
- **UI Polish**: "Clientization" and HTMX context (`client-id`) TODOs remain in the admin interface.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
...
|
||||
|
||||
|
||||
This plan completes the implementation of automatic sales summary calculations and ensures they are correctly posted to the ledger. Currently, the `sales-summaries-v2` job calculates detailed daily summaries, but it doesn't store aggregate totals, preserve manual adjustments, or trigger the creation of actual ledger entries. Additionally, the admin UI has several unresolved TODOs.
|
||||
|
||||
---
|
||||
|
||||
## Problem Frame
|
||||
|
||||
The system currently aggregates raw sales data into a `sales-summary` entity, but the final step—creating balanced journal entries for the general ledger—is a manual process involving external Excel calculations and subsequent imports. This creates a dependency on external tools and increases the risk of data entry errors. The goal is to automate this pipeline entirely within the product.
|
||||
|
||||
---
|
||||
|
||||
## Requirements Trace
|
||||
|
||||
- R1. Calculate and store aggregate totals (e.g., `:sales-summary/total-card-payments`) on the `sales-summary` entity.
|
||||
- R2. Preserve user-made manual adjustments (`:sales-summary-item/manual? true`) during automatic recalculations.
|
||||
- R3. Aggregate detailed `sales-summary-item`s into balanced `journal-entry` lines by account and location.
|
||||
- R4. Automate the posting of these aggregated totals to the ledger.
|
||||
- R5. Resolve UI TODOs in the Sales Summaries admin page, specifically regarding client-scoping ("clientize") and HTMX context (`client-id`).
|
||||
|
||||
---
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
- **In-Scope**:
|
||||
- Enhancements to the `sales-summaries-v2` job.
|
||||
- Implementation of the summary-to-ledger aggregation and posting logic.
|
||||
- Cleanup of the Sales Summaries admin UI.
|
||||
- **Out-of-Scope**:
|
||||
- Changing the fundamental calculation logic for sales orders/refunds.
|
||||
- Creating new ledger accounts (assume existing account mapping is sufficient).
|
||||
- Changing the naming of refunds/returns (user requested to keep as is).
|
||||
|
||||
---
|
||||
|
||||
## Context & Research
|
||||
|
||||
### Relevant Code and Patterns
|
||||
|
||||
- **Jobs**: `src/clj/auto_ap/jobs/sales_summaries.clj` contains the main calculation logic.
|
||||
- **UI**: `src/clj/auto_ap/ssr/admin/sales_summaries.clj` implements the admin interface.
|
||||
- **Ledger Posting**: `src/clj/auto_ap/ledger.clj` and `iol_ion/src/iol_ion/tx/upsert_ledger.clj` handle journal entry creation.
|
||||
- **Reconciliation Pattern**: `reconcile-ledger` in `src/clj/auto_ap/ledger.clj` shows how to find missing ledger entries and trigger their creation.
|
||||
|
||||
### Institutional Learnings
|
||||
|
||||
- No existing documented patterns for sales summary posting were found in `docs/solutions/`. This implementation will establish the pattern.
|
||||
|
||||
---
|
||||
|
||||
## Key Technical Decisions
|
||||
|
||||
- **Detailed Summary $\to$ Aggregated Ledger**: The `sales-summary` will maintain granular detail (line items, specific fee types), but the ledger posting will aggregate these items by account and location to create balanced `journal-entry` lines.
|
||||
- **Automatic Posting**: Posting to the ledger will be integrated into the reconciliation process, similar to how invoices and transactions are handled in `reconcile-ledger`.
|
||||
- **Location Handling**: Since `sales-summary-item`s don't have a location, a default location for the client will be used for ledger posting.
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
### Resolved During Planning
|
||||
|
||||
- **Architectural Decision**: Use a detailed summary that aggregates into the ledger.
|
||||
- **Renaming**: Keep "Refunds/Returns" as is.
|
||||
|
||||
### Deferred to Implementation
|
||||
|
||||
- **Default Location Logic**: Exactly how the "default location" for a client is retrieved or defined.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Units
|
||||
|
||||
- U1. **Enhance `sales-summaries-v2` Job**
|
||||
|
||||
**Goal:** Ensure the job stores aggregate totals and preserves manual adjustments.
|
||||
|
||||
**Requirements:** R1, R2
|
||||
|
||||
**Dependencies:** None
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/clj/auto_ap/jobs/sales_summaries.clj`
|
||||
|
||||
**Approach:**
|
||||
- Update `sales-summaries-v2` to calculate totals for attributes like `:sales-summary/total-card-payments`, `:sales-summary/total-cash-payments`, etc., based on the generated items.
|
||||
- Implement a merge strategy: when updating a summary, keep any items where `:sales-summary-item/manual?` is true, and only replace the automatically calculated items.
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: Running the job for a client with sales and refunds results in a `sales-summary` with correct `:sales-summary/total-*` attributes.
|
||||
- Edge case: Running the job on a summary that already has a manual item ensures the manual item is not overwritten.
|
||||
|
||||
**Verification:**
|
||||
- Datomic query shows `sales-summary` entities have populated total attributes and preserved manual items.
|
||||
|
||||
---
|
||||
|
||||
- U2. **Implement Summary-to-Ledger Aggregation**
|
||||
|
||||
**Goal:** Create a function to transform detailed summary items into balanced ledger lines.
|
||||
|
||||
**Requirements:** R3
|
||||
|
||||
**Dependencies:** U1
|
||||
|
||||
**Files:**
|
||||
- Create: `src/clj/auto_ap/ledger/sales_summaries.clj` (or add to `src/clj/auto_ap/ledger.clj`)
|
||||
- Test: `test/clj/auto_ap/ledger_test.clj`
|
||||
|
||||
**Approach:**
|
||||
- Create a function `aggregate-summary-items` that:
|
||||
1. Groups `sales-summary-item`s by `:ledger-mapped/account`.
|
||||
2. Sums the `:ledger-mapped/amount` based on `:ledger-mapped/ledger-side` (debit vs credit).
|
||||
3. Assigns a location (default client location).
|
||||
4. Returns a list of `journal-entry-line` maps.
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: A set of items with mixed accounts and sides aggregates into the correct number of ledger lines with summed amounts.
|
||||
- Edge case: Items with `nil` accounts are handled gracefully (e.g., mapped to an "Unknown" account or logged as error).
|
||||
|
||||
**Verification:**
|
||||
- Unit tests verify that a list of `sales-summary-item`s is correctly transformed into `journal-entry-line`s.
|
||||
|
||||
---
|
||||
|
||||
- U3. **Implement Automatic Ledger Posting for Summaries**
|
||||
|
||||
**Goal:** Ensure sales summaries trigger the creation of ledger entries.
|
||||
|
||||
**Requirements:** R4
|
||||
|
||||
**Dependencies:** U2
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/clj/auto_ap/ledger.clj`
|
||||
- Modify: `src/clj/auto_ap/jobs/sales_summaries.clj`
|
||||
|
||||
**Approach:**
|
||||
- Implement a `:upsert-sales-summary-ledger` transaction or function that takes a `sales-summary` and uses the aggregation logic from U2 to post to the ledger.
|
||||
- Integrate this into the `reconcile-ledger` function in `src/clj/auto_ap/ledger.clj` to find summaries missing ledger entries and post them.
|
||||
|
||||
**Test scenarios:**
|
||||
- Integration: Running `reconcile-ledger` identifies a `sales-summary` missing a `journal-entry` and creates a balanced `journal-entry` for it.
|
||||
- Happy path: The created `journal-entry` has the correct total amount and matches the summary totals.
|
||||
|
||||
**Verification:**
|
||||
- A `sales-summary` entity is linked to a `journal-entry` via `:journal-entry/original-entity`.
|
||||
|
||||
---
|
||||
|
||||
- U4. **Resolve UI TODOs in Sales Summaries Admin**
|
||||
|
||||
**Goal:** Fix client-scoping and HTMX context in the admin UI.
|
||||
|
||||
**Requirements:** R5
|
||||
|
||||
**Dependencies:** None
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/clj/auto_ap/ssr/admin/sales_summaries.clj`
|
||||
|
||||
**Approach:**
|
||||
- Resolve "clientize" TODOs: Ensure the data pulled for the table and edit wizard is correctly scoped and transformed using client-specific context.
|
||||
- Fix HTMX `client-id` passing: Update the `new-summary-item` trigger to correctly pass the `client-id` via `hx-vals` from the form state.
|
||||
- Clean up any remaining schema TODOs in the SSR file.
|
||||
|
||||
**Test scenarios:**
|
||||
- Integration: Adding a new summary item in the UI correctly sends the `client-id` and the item is created for the correct client.
|
||||
- Happy path: The summary table displays correctly and "missing account" warnings appear only for items without a mapped account.
|
||||
|
||||
**Verification:**
|
||||
- Manual verification in the browser: New items are added correctly, and the UI is free of "missing account" red pills for mapped items.
|
||||
|
||||
---
|
||||
|
||||
## System-Wide Impact
|
||||
|
||||
- **Interaction graph**: The `sales-summaries-v2` job now feeds into the ledger system via `reconcile-ledger`.
|
||||
- **Error propagation**: Failures in the aggregation logic will prevent the `journal-entry` from being created, which will be surfaced by `reconcile-ledger` as a missing entry.
|
||||
- **State lifecycle risks**: Ensuring that `manual?` items are not overwritten during automatic recalculation is critical to avoid losing user adjustments.
|
||||
- **Integration coverage**: Integration tests must cover the full flow: `sales-orders` $\to$ `sales-summary` $\to$ `journal-entry`.
|
||||
|
||||
---
|
||||
|
||||
## Risks & Dependencies
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| Overwriting manual adjustments | Implement explicit merge logic based on the `:sales-summary-item/manual?` flag. |
|
||||
| Unbalanced ledger entries | Use a strict aggregation function that ensures debits = credits for every posted summary. |
|
||||
| Missing location data | Implement a robust fallback to a default client location. |
|
||||
|
||||
---
|
||||
|
||||
## Sources & References
|
||||
|
||||
- Related code: `src/clj/auto_ap/jobs/sales_summaries.clj`
|
||||
- Related code: `src/clj/auto_ap/ssr/admin/sales_summaries.clj`
|
||||
- Related code: `src/clj/auto_ap/ledger.clj`
|
||||
- Related code: `iol_ion/src/iol_ion/tx/upsert_ledger.clj`
|
||||
@@ -0,0 +1,275 @@
|
||||
---
|
||||
date: 2026-06-01
|
||||
type: feat
|
||||
status: active
|
||||
plan_id: 2026-06-01-001
|
||||
title: "feat: Port manual bank-transaction import to SSR (alpine/htmx)"
|
||||
depth: standard
|
||||
origin: docs/ideation/2026-06-01-manual-transaction-import-ssr-ideation.md
|
||||
---
|
||||
|
||||
# feat: Port Manual Bank-Transaction Import to SSR
|
||||
|
||||
## Summary
|
||||
|
||||
Port the master-branch "manual import transactions" feature into the SSR/alpinejs/htmx stack by implementing the `external-import` handlers that `src/cljc/auto_ap/routes/transactions.cljc` already declares but that no handler currently serves. The feature is a dedicated two-stage page — paste the same Yodlee positional-column TSV → an editable review grid with per-row error/warning badges → import — modeled directly on the SSR ledger import (`src/clj/auto_ap/ssr/ledger.clj`). Validation follows the ledger's `add-errors` shape but preserves every master validation, and the actual write reuses the existing `auto-ap.import.transactions` engine unchanged.
|
||||
|
||||
---
|
||||
|
||||
## Problem Frame
|
||||
|
||||
On `master`, admins import bank transactions by pasting a tab-separated Yodlee export into a re-frame modal (`src/cljs/auto_ap/views/pages/transactions/manual.cljs`) that POSTs EDN to `/api/transactions/batch-upload`. This branch removed the ClojureScript React app and re-implemented the transactions surface server-side, but the manual-import feature was never ported — so admins on this branch cannot manually import transactions at all.
|
||||
|
||||
The route names are already scaffolded (`::external-page`, `::external-import-page`, `::external-import-parse`, `::external-import-import` in `routes/transactions.cljc`) but `src/clj/auto_ap/ssr/transaction.clj` wires no handlers for them — they are declared dead ends. The work is to fill that gap with handlers + UI + validation + nav + tests, mirroring the already-shipped ledger import.
|
||||
|
||||
---
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
**In scope**
|
||||
- A dedicated SSR import page at `/transaction2/external-import-new` (+ `/parse`, `/import` sub-routes), admin-gated with the same middleware posture as other transaction routes and the ledger import.
|
||||
- Reuse of the exact Yodlee positional-column paste format (no named-header columns).
|
||||
- An editable review grid with inline per-field editing and per-row error/warning badges (ledger-style, form-cursor driven).
|
||||
- Two-tier validation preserving every master validation, with the agreed severity split.
|
||||
- Import via the existing `auto-ap.import.transactions` engine, block-whole-batch on hard errors.
|
||||
- A transactions-section "Import" nav entry and an import-result notification.
|
||||
- A Playwright e2e (committed failing first) plus unit/integration tests modeled on `test/clj/auto_ap/ssr/ledger_test.clj`.
|
||||
|
||||
### Deferred to Follow-Up Work
|
||||
- CSV file upload as an alternative to paste.
|
||||
- Asynchronous/streaming import for very large pastes.
|
||||
- Any change to `categorize-transaction` or engine internals.
|
||||
|
||||
**Outside this change**
|
||||
- Named-header column format (rejected — would silently change valid input).
|
||||
- A bespoke import-result type replacing the engine's stats map.
|
||||
|
||||
---
|
||||
|
||||
## Key Technical Decisions
|
||||
|
||||
1. **Full editable review grid, block-whole-batch on hard error** (from brainstorm). Any remaining hard error blocks the entire import (ledger behavior: `throw+ {:type :field-validation ...}`, re-render the grid with errors highlighted); warn-level rows skip just that row and the rest import. Rationale: with an editable grid, the user can fix fixable problems inline, so "nothing imports until clean-or-skippable" is the coherent contract.
|
||||
|
||||
2. **Severity split between fixable errors and inherent warnings.**
|
||||
- **Hard errors (block, must fix inline):** unparseable/invalid date (must match `MM/dd/yyyy`), unparseable amount, unknown client code (no client for the bank-account-code), unknown bank-account code, missing required fields.
|
||||
- **Warnings (skip that row, import the rest):** status ≠ `"POSTED"`, transaction date before `bank-account/start-date`, date on/before `client/locked-until`, already-imported (synthetic-id `extant`).
|
||||
Rationale: fixable problems are correctable by editing a cell; inherent skip-conditions are facts about the data/account that editing cannot change, so they should not block the batch — this also reproduces master's "import valid, report the rest" outcome for those rows.
|
||||
|
||||
3. **Reuse the exact Yodlee positional paste format** (req #1). Parse with the master positional `columns` mapping (`auto-ap.import.manual/columns` + `tabulate-data` shape), not ledger's named-header `tsv->import-data`. Rationale: admins paste an unchanged Yodlee export; changing valid input is a silent regression.
|
||||
|
||||
4. **Reuse the `import.transactions` engine unchanged** (req #5). The import handler maps reviewed rows → `:transaction/*` maps (the `auto-ap.import.manual/manual->transaction` shape) and drives `start-import-batch :import-source/manual` → `import-transaction!` → `finish!` → `get-stats`, with `apply-synthetic-ids` for dedupe — exactly as `auto-ap.import.manual/import-batch` does today. The SSR layer is presentation + pre-validation only.
|
||||
|
||||
5. **Preview/engine parity via shared predicates** (the key design tension). The warn-level conditions shown in the grid before import (`not-ready` from start-date/locked-until, `extant`/already-imported, non-`POSTED`) and the engine's write-time `categorize-transaction` decisions must not drift. Decision: the pre-validation layer computes warn conditions by calling the **same** predicate functions the engine uses (`auto-ap.import.transactions/categorize-transaction` and its inputs — `get-existing` for extant, the bank-account `start-date`/`locked-until` checks), rather than re-deriving parallel logic. The grid is advisory display; the engine remains authoritative at write time, and because both read the same functions they agree. Hard-error (fixable) validations have no engine equivalent and live only in the pre-validation layer / malli schema.
|
||||
|
||||
6. **Testable paste path.** The ledger import populates a hidden textarea from `navigator.clipboard` via an alpine `@click`/`paste` handler, which is awkward to drive in headless Playwright. Decision: keep the "Load from clipboard" affordance, but ensure the paste textarea is fillable and that a `pasted`/`change` trigger fires the parse `hx-post`, so the e2e can set the value and dispatch the event without the clipboard API. (Implementation detail of how the trigger is wired is deferred to execution.)
|
||||
|
||||
---
|
||||
|
||||
## High-Level Technical Design
|
||||
|
||||
Two-stage flow mirroring `ssr/ledger.clj`, on the transactions surface:
|
||||
|
||||
```
|
||||
GET /transaction2/external-import-new -> external-import-page (paste form + empty review area)
|
||||
POST /transaction2/external-import-new/parse -> external-import-parse (decode TSV -> validate -> render editable grid)
|
||||
POST /transaction2/external-import-new/import -> external-import-import (re-validate -> if any hard error: re-render grid (blocked);
|
||||
else run import.transactions engine on clean rows,
|
||||
skip warn rows, return notification with stats)
|
||||
```
|
||||
|
||||
*This illustrates the intended approach and is directional guidance for review, not implementation specification. The implementing agent should treat it as context, not code to reproduce.*
|
||||
|
||||
Validation is two-tier:
|
||||
- **Tier 1 (shape, hard errors):** a malli parse-form-schema decodes the pasted TSV positionally (reusing the master column order) and coerces/flags shape problems — date parses as `MM/dd/yyyy`, amount parses, required fields present.
|
||||
- **Tier 2 (business):** a transaction `add-errors`/`table->entries` pass attaches `[message status]` pairs (`:error` / `:warn`) per row, with the hard/warn split from Decision 2, computing warn conditions from the shared engine predicates (Decision 5). `flatten-errors` maps them onto form-cursor field paths for the editable grid.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Units
|
||||
|
||||
Build order is **failing-e2e-first** (req #4): U1 lands the red acceptance test, then U2–U7 turn it green incrementally. Each feature-bearing unit also grows the unit-test suite in `test/clj/auto_ap/ssr/transaction/import_test.clj`.
|
||||
|
||||
### U1. Failing e2e acceptance test + deterministic import seed
|
||||
|
||||
**Goal:** Commit the end-to-end acceptance test (expected to fail) that defines "done", plus the deterministic test fixture it needs.
|
||||
**Requirements:** req #4; advances acceptance criteria AC-1, AC-2, AC-9, AC-10.
|
||||
**Dependencies:** none.
|
||||
**Files:**
|
||||
- `e2e/transaction-import.spec.ts` (new)
|
||||
- `test/clj/auto_ap/test_server.clj` (modify `seed-test-data` to give the seeded bank account a **fixed** `:bank-account/code`, e.g. `"TEST-CHK"`, since `test-bank-account` otherwise assigns a random code)
|
||||
**Approach:** Mirror `e2e/transaction-navigation.spec.ts` conventions (`x-clients: "mine"` header, `page.goto`, locators). The spec navigates to `/transaction2/external-import-new`, fills the paste box with a known-good Yodlee TSV whose bank-account-code/client-code match the seed (`TEST` client, `TEST-CHK` bank account), triggers parse, asserts parsed rows render in the review grid, clicks Import, and asserts a success notification with an imported count and that the imported transaction is visible on `/transaction2`. Include a second scenario pasting a row with an unknown client code and asserting a blocking error badge + that nothing imports. Drive paste by filling the textarea and dispatching the parse trigger (Decision 6), not the clipboard API.
|
||||
**Patterns to follow:** `e2e/transaction-navigation.spec.ts`, `e2e/bulk-code-transactions.spec.ts`; seed shape in `test/clj/auto_ap/test_server.clj` `seed-test-data`.
|
||||
**Test scenarios:**
|
||||
- Covers AE/AC-1, AC-2: paste valid TSV → rows render → import → "N imported" notification → transaction appears on the list page.
|
||||
- Covers AC-9: paste TSV with an unknown client code → row shows a blocking error badge, Import is blocked, no transaction created.
|
||||
- Edge: empty paste → no rows / friendly empty state (assert no crash).
|
||||
**Verification:** `npx playwright test e2e/transaction-import.spec.ts` runs and **fails** at this unit (no handler yet); the seed change does not break existing e2e specs (`npx playwright test` green except the new file).
|
||||
**Execution note:** Start red. This is the acceptance contract; do not weaken it to pass — make U2–U7 satisfy it.
|
||||
|
||||
### U2. Wire routes and render the import page shell
|
||||
|
||||
**Goal:** Make `/transaction2/external-import-new` serve a real page with the correct admin middleware; wire `parse`/`import` routes to placeholder handlers.
|
||||
**Requirements:** AC-1, AC-12 (auth); req #2.
|
||||
**Dependencies:** U1.
|
||||
**Files:**
|
||||
- `src/clj/auto_ap/ssr/transaction/import.clj` (new — namespace for the import handlers)
|
||||
- `src/clj/auto_ap/ssr/transaction.clj` (merge the new `key->handler` entries into the existing map at the `key->handler` def)
|
||||
**Approach:** Create `external-import-page` returning a `base-page` + `com/page` with breadcrumb ("Transactions" → "Import"), the clipboard helper script, and a forms container (initially just the paste form placeholder). Wire `::route/external-import-page`, `::route/external-import-parse`, `::route/external-import-import` into the transaction `key->handler` with the same middleware chain ledger uses for its import routes (`wrap-schema-enforce`/`wrap-form-4xx-2`/`wrap-schema-decode`/`wrap-nested-form-params` on parse/import) under the transaction page middleware (`wrap-must {:activity :import :subject :transaction}` analogous to ledger's `:subject :ledger`, `wrap-client-redirect-unauthenticated`). Confirm the correct `:activity`/`:subject` against the permissions model.
|
||||
**Patterns to follow:** `src/clj/auto_ap/ssr/ledger.clj` `external-import-page` and `key->handler` (~lines 276–318, 686–718); `src/clj/auto_ap/ssr/transaction.clj` existing `key->handler` (~line 101).
|
||||
**Test scenarios:**
|
||||
- Happy path: `GET` the page as admin → 200, renders the paste form container.
|
||||
- Error/auth: unauthenticated request → redirect/401 per `wrap-client-redirect-unauthenticated`.
|
||||
**Verification:** Page loads at the route in the running app and in `test_server`; the e2e gets past navigation (still fails later in the flow).
|
||||
|
||||
### U3. Paste + parse using the master positional column format
|
||||
|
||||
**Goal:** Parse the pasted Yodlee TSV (exact master columns) into rows and render them; wire the paste form's `pasted`-triggered `hx-post` to the parse handler.
|
||||
**Requirements:** req #1, req #2; AC-1, AC-3, AC-4.
|
||||
**Dependencies:** U2.
|
||||
**Files:**
|
||||
- `src/clj/auto_ap/ssr/transaction/import.clj` (add `external-import-text-form*`, `external-import-parse`, the parse malli schema, and a positional `tsv->rows` decode)
|
||||
- `test/clj/auto_ap/ssr/transaction/import_test.clj` (new)
|
||||
**Approach:** Reuse the master column order from `auto-ap.import.manual/columns` and `tabulate-data` (CSV read with `\tab`, drop header) to map positional columns. Define a malli `parse-form-schema` (ledger-style) whose `:table` field uses a `:decode/string` that runs the positional parse and a per-row `:decode/arbitrary` to build row maps; encode Tier-1 shape constraints (date `MM/dd/yyyy`, amount parses, required fields) reusing `auto-ap.import.manual.common/parse-date`/`parse-amount` semantics. `external-import-parse` re-renders the forms fragment with `:just-parsed? true`. Keep the paste textarea fillable and fire the parse trigger on a `pasted`/`change` event (Decision 6).
|
||||
**Patterns to follow:** `ssr/ledger.clj` `external-import-text-form*`, `external-import-parse`, `tsv->import-data`, `parse-form-schema` (~lines 246–375); `import/manual.clj` `columns`/`tabulate-data`; `import/manual/common.clj` `parse-date`/`parse-amount`.
|
||||
**Test scenarios:**
|
||||
- Happy path: a known Yodlee TSV string decodes to the expected row count with the expected field keys/values (positional mapping correct).
|
||||
- Header handling: first row dropped; blank rows ignored.
|
||||
- Edge: amount with currency formatting parses; amount unparseable flagged at Tier 1.
|
||||
- Edge: date not `MM/dd/yyyy` flagged at Tier 1; valid date parses.
|
||||
- Covers AC-3: pasting the exact master column layout yields the same field set master's `tabulate-data` produced.
|
||||
**Verification:** After paste, the parsed rows render (read-only at this unit is acceptable); parse unit tests green.
|
||||
|
||||
### U4. Editable review grid with per-row error/warning badges
|
||||
|
||||
**Goal:** Render parsed rows into an editable `data-grid` where each field is editable and per-row error/warning badges show, with a "Show table" toggle and an Import button.
|
||||
**Requirements:** req #2; AC-5, AC-6.
|
||||
**Dependencies:** U3.
|
||||
**Files:**
|
||||
- `src/clj/auto_ap/ssr/transaction/import.clj` (add `external-import-table-form*`, `external-import-form*`)
|
||||
**Approach:** Mirror `ledger/external-import-table-form*` using form-cursor (`fc/start-form`, `fc/with-field`, `fc/cursor-map`, `fc/field-value`/`field-name`/`field-errors`) and `com/data-grid-card` / `com/validated-field` / `com/text-input` / `com/money-input`. Columns reflect the transaction row shape (date, description, amount, bank-account-code, client-code, status). Per-row badge summarizes that row's error/warn state with a tooltip listing messages (red for `:error`, yellow for `:warn`). A parsed-summary banner shows row count + error/warning pill counts. Values round-trip on re-submit so inline edits persist.
|
||||
**Patterns to follow:** `ssr/ledger.clj` `external-import-table-form*` and `external-import-form*` (~lines 117–274).
|
||||
**Test scenarios:**
|
||||
- Test expectation: none for pure rendering structure beyond what U5 exercises — but include: rows with no errors render without a badge; rows with errors render a red badge; rows with only warnings render a yellow badge (assert via the rendered hiccup/markup in a handler-level test once U5 attaches errors).
|
||||
**Verification:** Parsed grid is visibly editable; badges appear once U5 attaches errors; e2e can see rows.
|
||||
|
||||
### U5. Two-tier validation preserving every master validation
|
||||
|
||||
**Goal:** Attach hard-error and warning statuses to rows per the severity split, reusing the engine's predicates for the warn conditions so the preview matches the engine.
|
||||
**Requirements:** req #3, req #5 (Decision 5); AC-7, AC-8, AC-9.
|
||||
**Dependencies:** U3 (Tier 1 shape errors), U4 (badges to display them).
|
||||
**Files:**
|
||||
- `src/clj/auto_ap/ssr/transaction/import.clj` (add `add-errors`, `table->entries`, `flatten-errors`, `entry-error-types` analogues)
|
||||
- `test/clj/auto_ap/ssr/transaction/import_test.clj` (extend)
|
||||
**Approach:** Build a transaction `add-errors` that, given lookups (client-by-bank-account-code, bank-account-by-code, bank-account `start-date`/`locked-until`, existing transaction ids), assigns:
|
||||
- **Hard errors:** unknown client code, unknown bank-account code, missing required fields. (Tier-1 date/amount errors already present from U3.)
|
||||
- **Warnings:** status ≠ `POSTED`; date before `bank-account/start-date`; date on/before `client/locked-until`; already-imported (synthetic-id present in existing ids).
|
||||
Compute the warn conditions by calling the same functions the engine uses — `auto-ap.import.transactions/categorize-transaction` (and its inputs `get-existing`, the `apply-synthetic-ids` key) — rather than parallel logic (Decision 5). `flatten-errors` maps `[message status]` onto form-cursor field paths so badges render against the right rows. Map every master validation explicitly (see `import/manual.clj manual->transaction` and `import/transactions.clj categorize-transaction`).
|
||||
**Patterns to follow:** `ssr/ledger.clj` `add-errors`/`table->entries`/`flatten-errors`/`entry-error-types` (~lines 380–523); `import/transactions.clj` `categorize-transaction`/`get-existing`/`apply-synthetic-ids`.
|
||||
**Test scenarios (one per validation, modeled on `ledger_test/add-errors-test`):**
|
||||
- Hard error: unknown client code → `:error` with a clear message.
|
||||
- Hard error: unknown bank-account code → `:error`.
|
||||
- Hard error: missing required field → `:error`.
|
||||
- (Tier 1) invalid date / unparseable amount → `:error`.
|
||||
- Warning: status ≠ `POSTED` → `:warn`, row skipped.
|
||||
- Warning: date before `bank-account/start-date` → `:warn`.
|
||||
- Warning: date on/before `client/locked-until` → `:warn`.
|
||||
- Warning: already-imported (extant synthetic id) → `:warn`.
|
||||
- Parity: a row the grid marks clean is categorized `:import` by `categorize-transaction`; a row marked warn-skip is categorized to the matching non-`:import` action (assert grid preview agrees with engine).
|
||||
- Pass-through: a fully valid row has no errors/warnings.
|
||||
**Verification:** Validation unit tests green; badges reflect the correct severities in the grid.
|
||||
|
||||
### U6. Import via the existing engine, block-on-error, with notification
|
||||
|
||||
**Goal:** Implement `external-import-import`: block the whole batch if any hard error remains; otherwise run the `import.transactions` engine on clean rows (skipping warn rows) and return a result notification.
|
||||
**Requirements:** req #5, Decisions 1 & 4; AC-2, AC-9, AC-10, AC-11.
|
||||
**Dependencies:** U5.
|
||||
**Files:**
|
||||
- `src/clj/auto_ap/ssr/transaction/import.clj` (add `rows->transactions`, `import-transactions`, `external-import-import`)
|
||||
**Approach:** Re-validate submitted (possibly edited) rows via U5. If any `:error` rows remain, `throw+ {:type :field-validation :form-errors ... :form-params ...}` and re-render the grid with errors (the `wrap-form-4xx-2` middleware handles re-render) — nothing imports. Otherwise map clean rows → `:transaction/*` maps using the `auto-ap.import.manual/manual->transaction` shape, apply `apply-synthetic-ids`, then drive `start-import-batch :import-source/manual` → `import-transaction!` (per row) → `finish!` → `get-stats`. Warn-only rows are excluded from the engine input (skipped). Return `html-response` re-rendering the form with an `hx-trigger` notification reporting counts from the engine stats (imported / skipped / not-ready / extant), mirroring master's stats surface.
|
||||
**Patterns to follow:** `ssr/ledger.clj` `import-ledger` + `external-import-import` (~lines 554–684); `import/manual.clj` `import-batch` (engine driving); `import/manual.clj` `manual->transaction`.
|
||||
**Test scenarios (modeled on `ledger_test` `import-ledger-*` tests, against Datomic via `wrap-setup`/`setup-test-data`/`admin-token`):**
|
||||
- Happy path: all-clean batch → engine imports all rows; stats report the imported count; transactions exist in the DB afterward.
|
||||
- Block-on-error: a batch with one hard-error row → throws `:field-validation`; **no** transactions are created (assert DB unchanged).
|
||||
- Warning skip: a batch with one warn-only row (e.g., non-`POSTED`) and clean rows → clean rows import, warn row skipped, stats reflect the skip.
|
||||
- Idempotency: importing the same paste twice → second run imports 0 new (extant/synthetic-id dedupe); no duplicates.
|
||||
- Integration: imported transaction carries `:import-source/manual` and is categorized/coded by the engine as it would be for any import (engine unchanged).
|
||||
**Verification:** Import unit/integration tests green; the e2e's import step succeeds and the transaction appears on the list page.
|
||||
|
||||
### U7. Transactions "Import" nav entry + final polish
|
||||
|
||||
**Goal:** Add an "Import" entry to the transactions section nav (parallel to the ledger import nav) and finish the parsed-summary banner / notification copy.
|
||||
**Requirements:** req #2; AC-1, AC-11.
|
||||
**Dependencies:** U2 (route exists), U6 (notification exists).
|
||||
**Files:**
|
||||
- `src/clj/auto_ap/ssr/components/aside.clj` (add a transactions "Import" nav button + mark active on `::transaction-routes/external-import-page`)
|
||||
**Approach:** Mirror the ledger import nav entry in `aside.clj` — add a sub-menu button under the transactions section linking to `::transaction-routes/external-import-page`, active-highlighted on that matched route. Confirm the banner shows row counts + error/warning pills (from U4) and the success notification copy matches the engine stats.
|
||||
**Patterns to follow:** `ssr/components/aside.clj` ledger import nav (~lines 360–366) and the transactions sub-menu (~lines 285–298).
|
||||
**Test scenarios:**
|
||||
- Test expectation: none (navigation markup) — covered indirectly by the e2e navigating via the nav link; optionally assert the nav button renders with the correct href on the import route.
|
||||
**Verification:** Full `e2e/transaction-import.spec.ts` passes; nav link is present and active on the import page.
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
**Routing & access**
|
||||
- **AC-1.** `GET /transaction2/external-import-new` renders the import page for an admin; an "Import" nav entry under the transactions section links to it and is active there.
|
||||
- **AC-12.** Unauthenticated access redirects/401s per the standard transaction-route middleware.
|
||||
|
||||
**Paste & parse (req #1)**
|
||||
- **AC-3.** Pasting the exact master Yodlee positional TSV parses into the same field set as `auto-ap.import.manual/tabulate-data`; the header row is dropped and blank rows ignored.
|
||||
- **AC-4.** `POST .../parse` re-renders the page with a "N rows found" banner and the review grid.
|
||||
|
||||
**Review grid (req #2)**
|
||||
- **AC-5.** Parsed rows render in an editable grid; each field is editable and inline edits persist across re-submit.
|
||||
- **AC-6.** Each row shows an error badge (red) when it has a hard error, a warning badge (yellow) when it has only warnings, and no badge when clean; badges list messages on hover.
|
||||
|
||||
**Validation parity (req #3)** — each produces a visible, row-attributed message:
|
||||
- **AC-7.** Hard errors block: client not found, bank account not found, date not `MM/dd/yyyy`, amount unparseable, missing required field.
|
||||
- **AC-8.** Warnings skip just that row: status ≠ `POSTED`, date before `bank-account/start-date`, date on/before `client/locked-until`, already-imported.
|
||||
- **AC-9.** With any remaining hard error, clicking Import blocks the whole batch (nothing imports) and re-renders the grid with errors highlighted.
|
||||
|
||||
**Import (req #5)**
|
||||
- **AC-2.** `POST .../import` imports the clean rows via the existing `import.transactions` engine on the `:import-source/manual` batch path; the success notification reports counts (imported / skipped / not-ready / extant).
|
||||
- **AC-10.** Re-importing the same paste is idempotent — no duplicate transactions (synthetic-id dedupe preserved).
|
||||
- **AC-11.** `categorize-transaction` and the engine internals are unchanged by this work.
|
||||
|
||||
**Tests (req #4)**
|
||||
- The Playwright e2e `e2e/transaction-import.spec.ts` exists, was committed failing first, and passes at the end.
|
||||
- Unit/integration tests in `test/clj/auto_ap/ssr/transaction/import_test.clj` cover each validation clause and the end-to-end import flow against Datomic.
|
||||
|
||||
---
|
||||
|
||||
## Test Strategy
|
||||
|
||||
- **e2e (Playwright):** `e2e/transaction-import.spec.ts`, driven through `test/clj/auto_ap/test_server.clj` (real routes, injected test auth). Committed red in U1, green by U7.
|
||||
- **Unit/integration (clojure.test):** `test/clj/auto_ap/ssr/transaction/import_test.clj`, modeled on `test/clj/auto_ap/ssr/ledger_test.clj` — pure parse/format tests, one validation test per clause, and Datomic-backed import tests via `wrap-setup` / `setup-test-data` / `admin-token`. Run with `clj-nrepl-eval -p PORT "(clojure.test/run-tests 'auto-ap.ssr.transaction.import-test)"` per AGENTS.md (preferred over `lein test`).
|
||||
- **Validation-parity oracle:** existing `test/clj/auto_ap/import/transactions_test.clj` (`categorize-transaction`) backs Decision 5 — the warn-condition predicates the grid reuses are already under test.
|
||||
|
||||
---
|
||||
|
||||
## System-Wide Impact
|
||||
|
||||
- **Editing discipline (AGENTS.md):** all Clojure edits go through the clojure-mcp editing tools (or `@clojure-author`), not raw file edits; use `clojure-eval`/`clj-nrepl-eval` to compile-check and run tests. Run `lein cljfmt fix` before committing; `clj-paren-repair` on any file that won't compile.
|
||||
- **Shared component reuse:** the feature composes existing `ssr/components` (`data-grid-card`, `validated-field`, `text-input`, `money-input`, `button`, `checkbox`, `errors`, `pill`, `form-errors`) and `ssr/form-cursor` — no core-component changes expected (req #5). If a component genuinely needs a new option, prefer an additive, backward-compatible change and flag it.
|
||||
- **Test fixture change:** giving the seeded bank account a deterministic `:bank-account/code` in `test_server.clj` could affect other e2e specs that assume the random code; U1 verifies the existing suite stays green.
|
||||
- **Permissions:** confirm the `wrap-must` `:activity`/`:subject` for the import routes matches the permission model (ledger uses `{:activity :import :subject :ledger}`); use the transaction equivalent.
|
||||
|
||||
---
|
||||
|
||||
## Risks & Mitigations
|
||||
|
||||
- **Preview/engine drift (highest risk).** Mitigated by Decision 5 — share the engine's predicates for warn conditions; the parity test in U5 asserts the grid and `categorize-transaction` agree.
|
||||
- **Headless clipboard paste.** Mitigated by Decision 6 — fillable textarea + explicit parse trigger so the e2e never needs `navigator.clipboard`.
|
||||
- **form-cursor round-tripping of an editable grid** is the trickiest ledger mechanic to copy; mitigate by mirroring `external-import-table-form*` closely and testing edit-persist-on-resubmit early (U4).
|
||||
- **Positional column brittleness** is inherited from master (Yodlee column order); not a regression, and out of scope to fix here.
|
||||
|
||||
---
|
||||
|
||||
## Deferred Implementation Notes (execution-time unknowns)
|
||||
|
||||
- Exact helper/function names in the new `import.clj` namespace.
|
||||
- The precise malli `:decode` wiring for positional parsing (reuse vs. thin wrapper around `tabulate-data`).
|
||||
- The exact `:activity`/`:subject` keyword for the import-route `wrap-must` (verify against the permissions model).
|
||||
- The seeded bank-account code value and whether any existing e2e needs adjustment after making it deterministic.
|
||||
- Final notification/banner copy.
|
||||
@@ -0,0 +1,777 @@
|
||||
# SSR Form & Wizard Simplification — Migration Plan
|
||||
|
||||
> **Status:** Planning / for execution by an agent or engineer.
|
||||
> **Owner:** Bryce
|
||||
> **Type:** Refactor (no user-facing behavior change; parity required).
|
||||
|
||||
This plan describes a series of low-risk migrations that make the server-side
|
||||
rendered (SSR) forms and wizards substantially simpler. It is self-contained:
|
||||
every concept needed to execute is stated here, illustrated with code snippets.
|
||||
The work is sequenced so each migration is small, reversible, and *teaches a
|
||||
skill* that makes the next migration cheaper.
|
||||
|
||||
---
|
||||
|
||||
## 1. Goals
|
||||
|
||||
1. **Render forms by re-rendering the whole form** (or a precise, isolated
|
||||
fragment) over HTMX, using hx-select to choose elements, instead of mutating
|
||||
the DOM in place. This removes the class of bugs around stale state, lost
|
||||
focus/caret, and out-of-band patching.
|
||||
2. **Root cursors at the top; never fake their position.** Cursors are fine and
|
||||
stay — a render function may take an explicit data map *or* a cursor. What we
|
||||
remove is the practice of **faking a cursor to start deeper** in the tree to
|
||||
satisfy a partial render, and the duplicate `*-no-cursor*` variants that
|
||||
fakery forces. The target: a cursor always begins at the top level of what the
|
||||
form consumes and walks down naturally from there. (Because the whole form is
|
||||
re-rendered each time, there is no longer any reason to fake a deep starting
|
||||
position.)
|
||||
3. **Stop forcing single-step forms through wizard machinery.** Most "wizards"
|
||||
are single-step; they become plain forms. Genuine multi-step flows use a
|
||||
small data-driven engine instead of protocols + middleware stacking, and
|
||||
**store each step's data in the session** (combined only at the end) instead
|
||||
of round-tripping and merging an EDN snapshot — the Django `formtools` model.
|
||||
4. **Render HTML with Selmer templates** (Jinja-style) instead of Hiccup for the
|
||||
interactive, attribute-heavy components, so Alpine/HTMX attributes are
|
||||
first-class HTML rather than a mix of Clojure keywords and strings.
|
||||
5. **Capture the migration method in a skill** that is created after the first
|
||||
successful migration and extended by every migration thereafter.
|
||||
|
||||
Net effect target: large reduction in lines of code, route count, and branching
|
||||
complexity, with measurably more reuse across similar forms.
|
||||
|
||||
---
|
||||
|
||||
## 2. Why — the current pain (rationale)
|
||||
|
||||
### 2.1 In-place DOM mutation is fragile
|
||||
Re-rendering only fragments and patching the rest (via morph or out-of-band
|
||||
swaps) means the server and the DOM can disagree. Keeping a focused input alive
|
||||
through a patch requires keying tricks and guards. Re-rendering the **whole
|
||||
form** and letting the typed value ride along in the form is simpler and
|
||||
correct, *provided the input the user is typing in is never inside the region
|
||||
being swapped*.
|
||||
|
||||
### 2.2 Faking cursor positions forces duplicate functions
|
||||
A "form cursor" itself is fine. The pain comes from **faking the cursor's
|
||||
starting position** — rebinding the dynamic root deeper in the tree so a deeply
|
||||
nested render function can run against a fragment. That fakery is fragile and
|
||||
hard to follow, and it has spawned duplicate render functions: one that reads the
|
||||
faked cursor and one that takes plain params for the cases where the fake can't
|
||||
be set up.
|
||||
|
||||
```clojure
|
||||
;; SMELL: this render fn assumes the cursor was faked to start deep at an account,
|
||||
;; so it only works when *current*/*prefix* were rebound to point there first.
|
||||
(defn account-row* [{:keys [value client-id]}]
|
||||
(com/data-grid-row
|
||||
(fc/with-field :transaction-account/account
|
||||
(com/data-grid-cell
|
||||
(account-typeahead* {:value (fc/field-value) :name (fc/field-name)})))
|
||||
...))
|
||||
|
||||
;; SMELL: a second copy of the same markup, just to avoid the faked-deep cursor
|
||||
(defn account-row-no-cursor* [{:keys [account index client-id]}]
|
||||
...)
|
||||
```
|
||||
|
||||
**Target:** the cursor starts at the top of the form's data and walks down
|
||||
naturally; a row render either takes explicit row data or receives a cursor the
|
||||
caller advanced step-by-step from the root — never one teleported to a deep node.
|
||||
|
||||
### 2.3 Single-step forms wear wizard costumes
|
||||
Several forms implement a multi-step wizard protocol (5 protocols, 15+ methods),
|
||||
serialize an EDN snapshot with custom readers into hidden fields, and register
|
||||
10–20 routes with stacked middleware — all for a single-step form. That is pure
|
||||
overhead.
|
||||
|
||||
### 2.4 Multi-step wizards round-trip and merge a snapshot
|
||||
The genuine multi-step wizards carry the whole accumulating form state as an EDN
|
||||
snapshot in hidden fields, then rebuild it each request by merging the posted
|
||||
pieces back into the snapshot. The serialization needs custom readers, the merge
|
||||
logic is error-prone, and the page payload grows with every step. The fix is to
|
||||
**store each step's data in the session under its own key and combine only at the
|
||||
end** — the Django `formtools` model (§3.3) — so no snapshot is built or merged.
|
||||
|
||||
### 2.5 Hiccup makes Alpine/HTMX attributes ambiguous
|
||||
The same attribute is sometimes a keyword and sometimes a string in the same
|
||||
file, and event handlers must be strings while structural Alpine attrs are
|
||||
keywords. There is no rule a reader (or an LLM) can rely on:
|
||||
|
||||
```clojure
|
||||
;; Both of these appear in one component file today:
|
||||
:x-ref "input" ; keyword key
|
||||
"x-ref" "hidden" ; string key
|
||||
:x-model "value.value"
|
||||
"x-model" "search"
|
||||
"@keydown.down.prevent.stop" "tippy.show();" ; handlers must be strings
|
||||
:x-init "..." ; structural attrs are keywords
|
||||
```
|
||||
|
||||
In a Selmer template the same markup is unambiguous plain HTML:
|
||||
|
||||
```html
|
||||
<input x-ref="input" x-model="value.value"
|
||||
@keydown.down.prevent.stop="tippy?.show()" />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Target state (the patterns, with snippets)
|
||||
|
||||
These four patterns are what every migration moves code *toward*. The skill
|
||||
(§5) holds the canonical, growing version of each.
|
||||
|
||||
### 3.1 Whole-form HTMX swap doctrine
|
||||
|
||||
Decide per interactive control, in this priority order:
|
||||
|
||||
1. **No request** when the field affects nothing else. Its value rides along in
|
||||
the form and is read on submit.
|
||||
```html
|
||||
<!-- a memo / free-text field that influences nothing -->
|
||||
<input name="memo" /> <!-- no hx-* at all -->
|
||||
```
|
||||
2. **Targeted swap of a single isolated cell** when a field's effect is purely
|
||||
local. Give the cell a stable id and keep it out of the typed input's subtree.
|
||||
```html
|
||||
<!-- selecting an account only changes the valid Location options -->
|
||||
<select name="accounts[0][account]"
|
||||
hx-post="/transaction/edit-form-changed"
|
||||
hx-target="#account-location-0"
|
||||
hx-select="#account-location-0"
|
||||
hx-swap="outerHTML" hx-trigger="changed">
|
||||
</select>
|
||||
<div id="account-location-0"> ...location options... </div>
|
||||
```
|
||||
3. **Whole-form swap** when the change touches interdependent state (vendor,
|
||||
add/remove row, mode toggle, $/% radio). The form's hidden state rides along,
|
||||
so one swap keeps everything consistent — **no out-of-band swaps**.
|
||||
```html
|
||||
<form id="wizard-form"
|
||||
hx-post="/transaction/edit-form-changed"
|
||||
hx-target="#wizard-form" hx-select="#wizard-form" hx-swap="outerHTML">
|
||||
...
|
||||
</form>
|
||||
```
|
||||
4. **Out-of-band (OOB) swap only for genuinely disjoint DOM regions** — a global
|
||||
flash/toast, a nav badge, a modal mounted at the document root. If you are
|
||||
tempted to OOB something *inside the same feature*, that is a signal to
|
||||
**restructure the DOM so the dependent element shares a common ancestor** with
|
||||
the trigger, and use an ordinary swap. Example: put running totals in a
|
||||
sibling `<tbody>` so an amount edit can swap totals without replacing the
|
||||
amount input:
|
||||
```clojure
|
||||
;; totals live in their own tbody, a sibling of the input rows
|
||||
(com/data-grid- {:rows ...
|
||||
:footer-tbody [:tbody {:id "account-totals"} ...]})
|
||||
|
||||
;; the amount input swaps ONLY the totals tbody (never itself)
|
||||
[:input {:name "accounts[0][amount]"
|
||||
:hx-post "/transaction/edit-form-changed"
|
||||
:hx-target "#account-totals" :hx-select "#account-totals"
|
||||
:hx-swap "outerHTML" :hx-trigger "keyup changed delay:300ms"}]
|
||||
```
|
||||
|
||||
**Focus invariant (must always hold):** the input the user is typing in is never
|
||||
inside the region its own request swaps.
|
||||
|
||||
**Alpine components must survive swaps.** Null-guard every reference that depends
|
||||
on Alpine/tippy being initialised, and key a component by its server-provided
|
||||
value so a server-driven change re-initialises it instead of preserving stale
|
||||
state:
|
||||
```clojure
|
||||
;; null-guard:
|
||||
"@keydown.enter.prevent.stop" "$refs.input?.__x_tippy?.hide(); ..."
|
||||
;; key by current value so morph/replace re-inits on server change:
|
||||
(assoc attrs :key (str id "--" current-value))
|
||||
```
|
||||
|
||||
**Selector strategy for targeted swaps (a consideration, not a mandate).**
|
||||
Rules 2 and 4 above need a stable `hx-target`/`hx-select`. The obvious approach
|
||||
— a unique `id` on every swappable element — gets noisy in repeated structures
|
||||
(e.g. a table of financial accounts where choosing an account must swap *that
|
||||
row's* dropdown). When you reach those advanced cases, consider a more
|
||||
consistent scheme instead of hand-minting ids everywhere:
|
||||
|
||||
- **Semantic markup + data-attributes** to craft a fine-grained selector without
|
||||
per-element ids. For example, mark rows/cells with their identity and target
|
||||
by attribute:
|
||||
```html
|
||||
<tr data-row="account" data-index="0">
|
||||
<td data-cell="account">
|
||||
<select hx-post="/transaction/edit-form-changed"
|
||||
hx-target="[data-row='account'][data-index='0'] [data-cell='location']"
|
||||
hx-select="[data-row='account'][data-index='0'] [data-cell='location']"
|
||||
hx-swap="outerHTML" hx-trigger="changed">…</select>
|
||||
</td>
|
||||
<td data-cell="location">…</td>
|
||||
</tr>
|
||||
```
|
||||
- **A `form-path -> id` (or `-> selector`) function**, derived the same way a
|
||||
cursor path is, so the server and the markup agree on the target by
|
||||
construction rather than by convention. A render fn at form-path
|
||||
`[:accounts 0 :location]` would compute its own stable selector (id or
|
||||
data-attribute query) from that path, mirroring §3.2's top-rooted cursor.
|
||||
|
||||
The aim is *consistency and predictability* of swap targets in repeated/nested
|
||||
structures — pick whichever keeps targets unambiguous and easy to generate. Note
|
||||
this in `reference/swap-doctrine.md` and let the first modal that hits nested
|
||||
repeated swaps (Phase 5 / the wizards) settle on a convention for the cookbook.
|
||||
|
||||
### 3.2 Render functions: explicit data, or a top-rooted cursor
|
||||
|
||||
One function, data in, markup out. The data can arrive as a plain map or via a
|
||||
cursor — **as long as the cursor was rooted at the top of the form and walked
|
||||
down to here**, never faked to start at this depth.
|
||||
|
||||
```clojure
|
||||
;; GOOD: pure, works everywhere, testable without setup
|
||||
(defn account-row [{:keys [account index client-id amount-mode]}]
|
||||
(com/data-grid-row
|
||||
(com/hidden {:name (str "accounts[" index "][db/id]")
|
||||
:value (or (:db/id account) "")})
|
||||
(com/data-grid-cell
|
||||
(account-typeahead* {:value (:transaction-account/account account)
|
||||
:name (str "accounts[" index "][account]")
|
||||
:client-id client-id}))
|
||||
...))
|
||||
```
|
||||
|
||||
```clojure
|
||||
;; ALSO FINE: a cursor that started at the form root and was advanced naturally.
|
||||
;; The top-level render walks the cursor; the row fn receives the dereferenced
|
||||
;; row (or the advanced cursor) — no rebinding of *current*/*prefix* to fake depth.
|
||||
(defn account-rows [accounts-cursor]
|
||||
(for [row-cursor (fc/each accounts-cursor)] ; advanced from the root, not faked
|
||||
(account-row {:account @row-cursor :index (fc/index row-cursor) ...})))
|
||||
```
|
||||
|
||||
The rule is about *where the cursor starts*, not whether you use one. If a caller
|
||||
already holds a top-rooted cursor, advance it and hand the row data (or the
|
||||
advanced cursor) to one render function. Never rebind the cursor to teleport to a
|
||||
deep node, and never keep a second `*-no-cursor*` copy of the markup.
|
||||
|
||||
### 3.3 Forms vs. wizards (and the data-driven wizard engine)
|
||||
|
||||
- **Single-step → plain form.** Two routes: `GET` (render) and `POST` (validate
|
||||
+ save). State is plain form fields + an entity id. No snapshot, no server
|
||||
state, no protocol.
|
||||
```clojure
|
||||
{::route/edit (fn [req] (html-response (render-edit-form {:entity (get-entity req)})))
|
||||
::route/edit-submit (fn [req] (validate-and-save req))}
|
||||
```
|
||||
|
||||
- **Genuinely multi-step → data-driven engine with session-stored step state.**
|
||||
|
||||
> **Inspiration — Django `formtools` `WizardView`.** Django's wizard does *not*
|
||||
> round-trip a serialized blob of the whole form through the page. Each step's
|
||||
> validated (cleaned) data is written to a **storage backend (the user session
|
||||
> by default)** under that step's key, and the steps are combined only at the
|
||||
> very end via `get_all_cleaned_data()`. We adopt the same model: **replace the
|
||||
> EDN snapshot + piecewise merging with per-step form state stored in the
|
||||
> session.** A step writes its own data under its own key; nothing is merged
|
||||
> into a snapshot and nothing about other steps rides through the form.
|
||||
> Refs: `formtools.wizard.views.WizardView`, its `storage` backends
|
||||
> (`SessionStorage`), and `get_all_cleaned_data()`
|
||||
> (https://django-formtools.readthedocs.io/en/latest/wizard.html).
|
||||
|
||||
A wizard is *data*:
|
||||
```clojure
|
||||
(def vendor-wizard-config
|
||||
{:steps [{:key :info :schema info-schema :fields [...] :render render-info-step
|
||||
:next (fn [data] :terms)}
|
||||
{:key :terms :schema terms-schema :fields [...] :render render-terms-step
|
||||
:next (fn [data] :done)}]
|
||||
:init-fn (fn [req] {...})
|
||||
:submit-route "/admin/vendor/wizard/submit"
|
||||
:done-fn (fn [all-data req] (save! all-data) (html-response "Saved"))})
|
||||
```
|
||||
with a tiny engine (no protocols) whose state lives **in the session**, keyed
|
||||
by a wizard instance id, with each step's data stored under its own step key —
|
||||
the formtools `SessionStorage` model. No snapshot, no custom EDN readers, no
|
||||
merge-into-snapshot:
|
||||
```clojure
|
||||
;; Storage backed by the Ring session (replaces the hidden EDN snapshot).
|
||||
;; Path in session: [:wizards <wizard-id> :step-data <step-key>]
|
||||
(defn create-wizard! [session config]
|
||||
(let [id (str (java.util.UUID/randomUUID))]
|
||||
[id (assoc-in session [:wizards id]
|
||||
{:current-step (-> config :steps first :key) :step-data {}})]))
|
||||
|
||||
(defn put-step [session id k data] (assoc-in session [:wizards id :step-data k] data)) ; replace, not merge
|
||||
(defn set-step [session id k] (assoc-in session [:wizards id :current-step] k))
|
||||
(defn get-all [session id] (->> (get-in session [:wizards id :step-data]) vals (apply merge)))
|
||||
(defn forget [session id] (update session :wizards dissoc id))
|
||||
|
||||
(defn render-wizard [{:keys [wizard-id config session request]}]
|
||||
(let [{:keys [current-step step-data]} (get-in session [:wizards wizard-id])
|
||||
step (first (filter #(= (:key %) current-step) (:steps config)))]
|
||||
[:form#wizard-form {:hx-post (:submit-route config)
|
||||
:hx-target "#wizard-form" :hx-select "#wizard-form" :hx-swap "outerHTML"}
|
||||
;; only a reference token rides in the form -- not the form's state
|
||||
(com/hidden {:name "wizard-id" :value wizard-id})
|
||||
(com/hidden {:name "current-step" :value (name current-step)})
|
||||
((:render step) (assoc request :step-data (get step-data current-step {})))]))
|
||||
|
||||
;; Handlers thread the (possibly updated) session back into the Ring response.
|
||||
(defn handle-step-submit [config {:keys [session] :as request}]
|
||||
(let [{:strs [wizard-id current-step]} (:form-params request)
|
||||
step (first (filter #(= (:key %) (keyword current-step)) (:steps config)))
|
||||
data (select-keys (:form-params request) (map name (:fields step)))]
|
||||
(if-let [errors (mc/explain (:schema step) data)]
|
||||
(-> (render-wizard {:wizard-id wizard-id :config config :session session
|
||||
:request (assoc request :errors errors)})
|
||||
html-response)
|
||||
(let [session' (put-step session wizard-id (keyword current-step) data)
|
||||
nxt ((:next step) data)]
|
||||
(if (= nxt :done)
|
||||
(-> ((:done-fn config) (get-all session' wizard-id) request) ; combine only at the end
|
||||
(assoc :session (forget session' wizard-id)))
|
||||
(let [session'' (set-step session' wizard-id nxt)]
|
||||
(-> (html-response (render-wizard {:wizard-id wizard-id :config config
|
||||
:session session'' :request request}))
|
||||
(assoc :session session''))))))))
|
||||
```
|
||||
Two routes per wizard: open (`partial open-wizard config`) and submit
|
||||
(`partial handle-step-submit config`). State is namespaced by `wizard-id` inside
|
||||
the session, so multiple in-flight wizards (and tabs) don't collide, and it is
|
||||
discarded on completion (`forget`). See Open decision 1 for the storage-backend
|
||||
choice (Ring session store vs. a durable store for long-lived wizards).
|
||||
|
||||
### 3.4 Selmer templates
|
||||
|
||||
Interactive components render from Selmer templates with plain-HTML attributes.
|
||||
Selmer composes via `{% include %}` and `{% block %}`; an interop bridge lets a
|
||||
Selmer template embed Hiccup output (and vice versa) during the transition.
|
||||
|
||||
```html
|
||||
{# templates/components/typeahead.html #}
|
||||
<div class="relative" x-data="{{ x_data|safe }}" x-model="{{ x_model }}">
|
||||
<a class="{{ classes }}" x-ref="input" tabindex="0"
|
||||
@keydown.down.prevent.stop="tippy?.show()"
|
||||
@keydown.backspace="tippy?.hide(); value = {value:'', label:''}">
|
||||
<span x-text="value.label"></span>
|
||||
</a>
|
||||
...
|
||||
</div>
|
||||
```
|
||||
|
||||
```clojure
|
||||
;; render helper + interop bridge
|
||||
(defn render [tpl ctx] (selmer/render-file tpl ctx))
|
||||
(defn hiccup->html [h] (hiccup/html h)) ; embed hiccup inside selmer via {{ frag|safe }}
|
||||
;; selmer fragment inside hiccup: [:div (hiccup/raw (render "..." ctx))]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Principles
|
||||
|
||||
1. **Strangler, not big-bang.** New engine, Selmer renderer, and the swap
|
||||
doctrine live alongside the old code. Migrate one modal at a time behind its
|
||||
own route. Old machinery is deleted only when its last caller is gone.
|
||||
2. **Simplest first.** Each migration is small and reversible (one commit).
|
||||
Start with the already-proven modal, then the smallest fresh ones, and leave
|
||||
the largest/most complex for last — by which point the skill is mature.
|
||||
3. **Skill-driven and self-reinforcing.** After the first successful migration,
|
||||
distil the method into a skill (§5). Every subsequent migration *reads* the
|
||||
skill first and *extends* it last.
|
||||
4. **Quality must measurably improve.** Each migration records a scorecard (§6);
|
||||
no metric may regress for the touched modal.
|
||||
5. **Behavior parity is proven by tests, not by reading** (§7). The full e2e
|
||||
suite must stay green after every migration.
|
||||
|
||||
---
|
||||
|
||||
## 5. The skill: `ssr-form-migration`
|
||||
|
||||
**When it is created:** in **Phase 1**, immediately after — and distilled from —
|
||||
the first successful modal migration (the transaction-edit modal, whose
|
||||
whole-form swap implementation already exists and serves as the reference). The
|
||||
skill is *not* written speculatively; it encodes a method that already worked.
|
||||
|
||||
**Where:** `.claude/skills/ssr-form-migration/` (matches the existing project
|
||||
convention, e.g. `.claude/skills/testing-conventions/SKILL.md`).
|
||||
|
||||
**Structure:**
|
||||
```
|
||||
.claude/skills/ssr-form-migration/
|
||||
SKILL.md # the playbook (§8): classify → migrate → verify → record
|
||||
reference/
|
||||
swap-doctrine.md # §3.1 rules, focus invariant, OOB-vs-hoist, Alpine hardening,
|
||||
# target-selector strategy (semantic/data-attr/form-path->id)
|
||||
render-functions.md # §3.2 explicit-data or top-rooted cursor; no faked positions
|
||||
form-vs-wizard.md # §3.3 classification + the data-driven engine
|
||||
selmer-conventions.md # §3.4 attr style, interop bridge, include/block patterns
|
||||
component-cookbook.md # GROWS: typeahead, account-row, totals, money-input, mode-toggle…
|
||||
gotchas.md # GROWS: stale $refs, key-by-value, wizard-id GC, coercion…
|
||||
test-recipes.md # GROWS: how to e2e a swap; assert a Selmer render; fixture a wizard-id
|
||||
scorecard.md # the §6 heuristics + a running table of every migration's numbers
|
||||
```
|
||||
|
||||
**Growth contract — the last task of every migration:**
|
||||
- Converted a component? → add its before/after to `component-cookbook.md`.
|
||||
- Hit a surprise? → one entry in `gotchas.md`.
|
||||
- Found a test pattern? → `test-recipes.md`.
|
||||
- Playbook step missing/wrong? → fix `SKILL.md`.
|
||||
- Measured the scorecard? → append the row to `scorecard.md`.
|
||||
|
||||
**Success signal:** each migration should reuse more cookbook entries and start
|
||||
from a better scorecard baseline than the previous one. If migration N+1 is not
|
||||
easier than N, the skill-update step is being skipped — treat that as a bug.
|
||||
|
||||
---
|
||||
|
||||
## 6. Quality scorecard (the ratchet)
|
||||
|
||||
Cheap to measure (`grep -c`, `wc -l`, `clj-kondo`), recorded before/after each
|
||||
migration in the commit message and `scorecard.md`. **No metric may regress for
|
||||
the touched modal.**
|
||||
|
||||
| # | Heuristic | Measure | Target |
|
||||
|---|-----------|---------|--------|
|
||||
| 1 | Faked cursor positions (not cursors themselves) | count cursor-root rebinds (`binding` of `*current*`/`*prefix*`/`*form-data*`, or `with-field`/`with-*` used to *re-root* deeper) + `grep -c '\-no-cursor'` | → 0 (top-rooted cursors are fine) |
|
||||
| 2 | Implicit state merges (snapshot/cursor) | count merge sites | → 0 (forms); explicit `update-step!` only (wizards) |
|
||||
| 3 | Branching complexity | `clj-kondo`, or count `cond`/`condp`/`case`/nested `if` + max depth | net ↓ |
|
||||
| 4 | Lines of code | `wc -l` on the modal's file(s) | net ↓ |
|
||||
| 5 | Reuse / cross-form similarity | cookbook components reused; duplicated-block count | reuse ↑, dup ↓ |
|
||||
| 6 | Route count | count routes for the modal | → 2 (+1 for add-row) |
|
||||
| 7 | OOB swaps | `grep -c hx-swap-oob` | → 0 unless a justified disjoint-region case is documented |
|
||||
| 8 | Attribute consistency | mixed `:x-`/`"x-"` encodings in migrated template | → 0 |
|
||||
|
||||
These are directional evidence, not targets to game. Pair them with the e2e
|
||||
parity gate (§7) so "simpler" can never mean "broken."
|
||||
|
||||
---
|
||||
|
||||
## 7. Testing strategy
|
||||
|
||||
Consistent with the project's `testing-conventions` skill (test user-observable
|
||||
behavior; assert DB state directly; don't test the means).
|
||||
|
||||
1. **Characterization e2e first.** Before changing a modal, write/confirm a
|
||||
Playwright spec capturing its current behavior — focus/caret survival across
|
||||
swaps, the field round-trip, validation errors, and the actual save. This
|
||||
spec is the parity contract the refactor must keep green.
|
||||
2. **Pure-function checks via REPL.** Once render fns are pure, exercise the
|
||||
data-prep functions with `clojure-eval` / `clj-nrepl-eval`. Assert on returned
|
||||
data; for markup use string matches (`(re-find #"accounts\[0\]\[account\]" (str html))`)
|
||||
— this style survives the Selmer switch. Avoid brittle structural assertions.
|
||||
3. **DB-state assertions for mutations.** If a submit writes Datomic, verify by
|
||||
querying the DB, not by asserting on markup.
|
||||
|
||||
**Regression gate:** the full e2e suite must stay green after every migration.
|
||||
Record the current pass/fail baseline in `test-recipes.md` at the first
|
||||
migration and never drop below it.
|
||||
|
||||
---
|
||||
|
||||
## 8. Per-migration playbook (the repeatable loop)
|
||||
|
||||
This is the canonical loop each modal phase follows; it lives in `SKILL.md`.
|
||||
Modal phases below list only what is *specific* to that modal plus this loop.
|
||||
|
||||
1. [ ] **Read the skill.** Note applicable cookbook entries and gotchas.
|
||||
2. [ ] **Classify.** Single-step → plain form (no server state). Multi-step →
|
||||
wizard (engine + server state). When in doubt, it's a form.
|
||||
3. [ ] **Baseline the scorecard (§6).** Record before-numbers.
|
||||
4. [ ] **Characterize behavior (test-first).** Write/confirm the e2e spec.
|
||||
5. [ ] **Consolidate render functions** so they take explicit data or a
|
||||
top-rooted cursor — remove faked cursor positions and `*-no-cursor*`
|
||||
duplicates (heuristics 1, 2). Using a cursor is fine; faking its start is not.
|
||||
6. [ ] **Templatize in Selmer**; reuse cookbook bits, add new ones back
|
||||
(heuristics 5, 8).
|
||||
7. [ ] **Wire HTMX per the swap doctrine** (§3.1); focus invariant intact; OOB
|
||||
only for disjoint regions (heuristic 7).
|
||||
8. [ ] **Collapse routes** to 2 (+1 for add-row) (heuristic 6).
|
||||
9. [ ] **Verify:** modal e2e + full suite green; assert DB mutations; REPL-check
|
||||
pure fns. Re-measure scorecard — no regressions.
|
||||
10. [ ] **Commit** one reversible feature commit; message includes the scorecard
|
||||
delta and reused/new cookbook entries.
|
||||
11. [ ] **Feed the skill** (cookbook / gotchas / test-recipes / scorecard /
|
||||
SKILL.md). *Not optional.*
|
||||
|
||||
---
|
||||
|
||||
## 9. Phases & tasks
|
||||
|
||||
> Migration target inventory (verify line counts at execution time):
|
||||
|
||||
| Modal | File | Steps | Target | Phase |
|
||||
|-------|------|-------|--------|-------|
|
||||
| Transaction Edit | `transaction/edit.clj` | 1 (mode toggle) | form | 2 (skill trial) |
|
||||
| Transaction Bulk Code | `transaction/bulk_code.clj` | 1 | form | 3 |
|
||||
| Sales Summary Edit | `pos/sales_summaries.clj` | 1 | form | 4 |
|
||||
| Invoice Bulk Edit | `invoices.clj` | 1 | form | 5 |
|
||||
| Transaction Rule | `admin/transaction_rules.clj` | 2 | wizard | 6 |
|
||||
| Invoice Pay | `invoices.clj` | 2 | wizard | 7 |
|
||||
| New Invoice | `invoice/new_invoice_wizard.clj` | 3 | wizard | 8 |
|
||||
| Vendor | `admin/vendors.clj` | 5 | wizard | 9 |
|
||||
| Client | `admin/clients.clj` | 7 | wizard | 10 |
|
||||
|
||||
---
|
||||
|
||||
### Phase 1 — Distil the skill (no app code changes)
|
||||
|
||||
**Rationale:** the transaction-edit modal has already been migrated to the
|
||||
whole-form swap approach successfully. Capture that working method as a skill
|
||||
*now*, so every later migration is cheaper and consistent. (If the reference
|
||||
implementation is not yet on the working branch, merge it first — that is an
|
||||
acceptable prerequisite.)
|
||||
|
||||
- [ ] Create `.claude/skills/ssr-form-migration/SKILL.md` with the playbook (§8).
|
||||
- [ ] Write `reference/swap-doctrine.md` from §3.1 (the four rules, focus
|
||||
invariant, OOB-vs-hoist, Alpine hardening), using the real transaction-edit
|
||||
swaps as worked examples.
|
||||
- [ ] Write `reference/render-functions.md` from §3.2 (explicit data or a
|
||||
top-rooted cursor; remove faked positions and `*-no-cursor*` duplicates).
|
||||
- [ ] Write `reference/form-vs-wizard.md` from §3.3 (classification + engine).
|
||||
- [ ] Stub `reference/selmer-conventions.md` from §3.4, marked "validated in
|
||||
Phase 2."
|
||||
- [ ] Seed `component-cookbook.md` with whatever transaction-edit already proved
|
||||
(e.g. the hardened typeahead, the totals-in-sibling-`<tbody>` pattern).
|
||||
- [ ] Seed `gotchas.md` (stale `$refs`, key-by-value).
|
||||
- [ ] Seed `test-recipes.md`; record the **current full e2e pass/fail baseline**.
|
||||
- [ ] Create `scorecard.md` with the §6 table and an empty results table.
|
||||
- [ ] **Exit criteria:** an agent can read `SKILL.md` and the references and
|
||||
understand the whole method without this plan.
|
||||
|
||||
---
|
||||
|
||||
### Phase 2 — Trial the skill on Transaction Edit (first test subject)
|
||||
|
||||
**Rationale:** validate the freshly written skill against the one modal whose
|
||||
"correct" outcome we already know. This is also where Selmer + pure functions
|
||||
are completed for this modal and the Selmer conventions get written from a real,
|
||||
verified example. Target type: **plain form** (single step with a mode toggle —
|
||||
the toggle is just a `GET` with a `?mode=` query param that re-renders the form).
|
||||
|
||||
**Foundation (do once, here):**
|
||||
- [ ] Add the `selmer` dependency to `project.clj`.
|
||||
- [ ] Build the render helper (`selmer/render-file`) and the **interop bridge**
|
||||
(Hiccup→string for embedding in Selmer, and Selmer fragment inside Hiccup).
|
||||
- [ ] Prove interop: a throwaway Selmer page renders inside the existing layout,
|
||||
and a Hiccup component renders inside a Selmer template.
|
||||
|
||||
**Modal migration (run the §8 loop), specifics:**
|
||||
- [ ] Confirm/author the characterization e2e spec covering: typing in memo keeps
|
||||
focus; selecting an account updates only its Location options; changing vendor
|
||||
/ adding / removing a row / toggling mode / toggling $-vs-% re-renders the
|
||||
whole form correctly; amount edits update totals without losing the amount
|
||||
caret; save round-trips.
|
||||
- [ ] Extract pure render fns: `render-simple-fields`, `render-advanced-fields`,
|
||||
`account-row`, `account-totals` (remove any `*-no-cursor*` duplicates).
|
||||
- [ ] Convert those render fns to Selmer templates; record each as a cookbook
|
||||
entry; finalize `selmer-conventions.md`.
|
||||
- [ ] Verify the swaps match the doctrine (whole-form for structural changes,
|
||||
targeted cell for account→location, sibling-`<tbody>` for totals, no request
|
||||
for memo); confirm `grep -c hx-swap-oob` is 0.
|
||||
- [ ] Collapse routes: `GET /transaction/edit` (with `?mode=`), `POST
|
||||
/transaction/edit`, plus the single `edit-form-changed` re-render endpoint.
|
||||
- [ ] Verify (modal e2e + full suite green; DB save asserted).
|
||||
- [ ] **Feed the skill:** refine `SKILL.md` and references from anything the
|
||||
trial revealed; append the scorecard row (this is the baseline others beat).
|
||||
- [ ] **Exit criteria:** skill-driven migration reproduces the known-good
|
||||
behavior; Selmer conventions are validated; cookbook has ≥3 reusable entries.
|
||||
|
||||
---
|
||||
|
||||
### Phase 3 — Transaction Bulk Code (plain form)
|
||||
|
||||
**Rationale:** the smallest *fresh* modal — first real test of "read the skill,
|
||||
apply it cold." Single-step form currently wearing a wizard costume.
|
||||
|
||||
- [ ] Run the §8 loop.
|
||||
- [ ] Classify as plain form; delete the wizard protocol/record and snapshot.
|
||||
- [ ] Extract `render-bulk-code-fields`; reuse cookbook typeahead/money-input.
|
||||
- [ ] Search params preserved as plain hidden fields (no EDN snapshot).
|
||||
- [ ] Collapse 4 wizard routes → 2 (`GET` open, `POST` submit).
|
||||
- [ ] Verify bulk-code applies correctly (assert DB) + full suite green.
|
||||
- [ ] Feed the skill; append scorecard row.
|
||||
- [ ] **Exit criteria:** ≥2 cookbook entries reused; LOC, route-count, and
|
||||
faked-cursor count all down vs. baseline.
|
||||
|
||||
---
|
||||
|
||||
### Phase 4 — Sales Summary Edit (plain form)
|
||||
|
||||
**Rationale:** another single-step form; reinforces the cold-apply loop.
|
||||
|
||||
- [ ] Run the §8 loop.
|
||||
- [ ] Classify as plain form; remove wizard record + `wrap-init-multi-form-state`.
|
||||
- [ ] Extract `render-sales-summary-fields` (pure); reuse cookbook entries.
|
||||
- [ ] Collapse 3 wizard routes → 2.
|
||||
- [ ] Verify edit saves (assert DB) + full suite green.
|
||||
- [ ] Feed the skill; append scorecard row.
|
||||
|
||||
---
|
||||
|
||||
### Phase 5 — Invoice Bulk Edit (plain form with rows + totals)
|
||||
|
||||
**Rationale:** first single-step form with dynamic account rows and live totals
|
||||
— exercises the add-row endpoint and the totals-in-sibling-`<tbody>` swap
|
||||
(instead of OOB).
|
||||
|
||||
- [ ] Run the §8 loop.
|
||||
- [ ] Extract `bulk-edit-account-row` (pure); reuse the `account-row`/`totals`
|
||||
cookbook entries from Phase 2.
|
||||
- [ ] Add-row: a `POST` that appends a fresh row; totals re-render via the
|
||||
sibling-`<tbody>` swap, **not** OOB.
|
||||
- [ ] **Settle a target-selector convention** for repeated/nested rows (§3.1
|
||||
"Selector strategy"): semantic data-attributes and/or a `form-path -> selector`
|
||||
helper, rather than hand-minted ids per element. Record the chosen convention
|
||||
in `reference/swap-doctrine.md` + `component-cookbook.md` so later wizards reuse it.
|
||||
- [ ] Collapse 4 wizard routes → 3 (open, submit, add-row).
|
||||
- [ ] Verify add/remove rows + totals + apply (assert DB) + full suite green.
|
||||
- [ ] Feed the skill; append scorecard row.
|
||||
- [ ] **Exit criteria:** `grep -c hx-swap-oob` is 0; row/totals patterns are
|
||||
confirmed reusable across two modals now.
|
||||
|
||||
---
|
||||
|
||||
### Phase 6 — Build the wizard engine + migrate Transaction Rule (2-step wizard)
|
||||
|
||||
**Rationale:** the first genuinely multi-step modal, and the simplest one — the
|
||||
right place to introduce the data-driven engine (§3.3) and **session-stored
|
||||
per-step state** (the Django `formtools` model), replacing the EDN snapshot +
|
||||
merge.
|
||||
|
||||
**Engine (do once, here):**
|
||||
- [ ] Create `components/wizard_state.clj` backed by the **Ring session**:
|
||||
`create-wizard!`, `put-step` (replace step data, do **not** merge into a
|
||||
snapshot), `set-step`, `get-all` (combine only at the end), `forget`. State is
|
||||
namespaced by `wizard-id` inside the session (`[:wizards <id> ...]`) so tabs
|
||||
and concurrent wizards don't collide. Each fn returns the updated session for
|
||||
the handler to thread into the Ring response. Test the lifecycle via REPL.
|
||||
- [ ] Create `components/wizard2.clj` (`render-wizard`, `handle-step-submit`,
|
||||
`open-wizard`) — engine threads session through and only `wizard-id` rides in
|
||||
the form. Test render + step navigation + that no snapshot is emitted.
|
||||
- [ ] Document the engine usage and the formtools inspiration in
|
||||
`reference/form-vs-wizard.md`.
|
||||
|
||||
**Modal migration (run the §8 loop), specifics:**
|
||||
- [ ] Extract `render-edit-step` and `render-test-step` (the test step shows a
|
||||
results table); keep `validate-transaction-rule` as the step `:schema`/custom check.
|
||||
- [ ] Define `transaction-rule-wizard-config` with both steps + `:done-fn`.
|
||||
- [ ] Collapse routes → 2 (open, submit).
|
||||
- [ ] Verify create / edit / run-test (assert DB) + full suite green.
|
||||
- [ ] Feed the skill; append scorecard row.
|
||||
- [ ] **Exit criteria:** engine proven on a real 2-step flow; state TTL works.
|
||||
|
||||
---
|
||||
|
||||
### Phase 7 — Invoice Pay (2-step wizard)
|
||||
|
||||
**Rationale:** 2 steps with conditional rendering by payment method (e.g.,
|
||||
handwrite-check fields) — exercises the engine's `:next`/conditional branching.
|
||||
|
||||
- [ ] Run the §8 loop.
|
||||
- [ ] Extract `render-choose-method-step` and `render-payment-details-step`.
|
||||
- [ ] Build `pay-wizard-config`; move setup logic into `:init-fn` (e.g. the
|
||||
`invoice-by-id` lookup); branch `:next` on payment method.
|
||||
- [ ] Collapse routes → 2.
|
||||
- [ ] Verify each payment method path (assert DB) + full suite green.
|
||||
- [ ] Feed the skill; append scorecard row.
|
||||
|
||||
---
|
||||
|
||||
### Phase 8 — New Invoice (3-step wizard)
|
||||
|
||||
**Rationale:** a true 3-step wizard with a conditional accounts step — the
|
||||
reference multi-step shape.
|
||||
|
||||
- [ ] Run the §8 loop.
|
||||
- [ ] Extract `render-basic-details-step`, `render-accounts-step`,
|
||||
`render-submit-step`; reuse the expense-account row cookbook entry.
|
||||
- [ ] Define step schemas separately; `:next` from basic-details skips accounts
|
||||
when not customizing.
|
||||
- [ ] `:init-fn` sets defaults (e.g. date = now).
|
||||
- [ ] Add-row for expense accounts via the sibling-`<tbody>` totals pattern.
|
||||
- [ ] Collapse routes → 2 (+1 add-row).
|
||||
- [ ] Verify create with/without custom accounts (assert DB) + full suite green.
|
||||
- [ ] Feed the skill; append scorecard row.
|
||||
|
||||
---
|
||||
|
||||
### Phase 9 — Vendor (5-step wizard)
|
||||
|
||||
**Rationale:** larger multi-step; by now the engine and cookbook are mature.
|
||||
|
||||
- [ ] Run the §8 loop.
|
||||
- [ ] Extract the 5 step render fns: `render-info-step`, `render-terms-step`,
|
||||
`render-account-step`, `render-address-step`, `render-legal-step`.
|
||||
- [ ] Build `vendor-wizard-config`; handle `:new` vs `:edit` via `:init-fn`
|
||||
(empty vs. loaded entity).
|
||||
- [ ] Replace the conditional `hx-post`/`hx-put` logic with the engine's submit.
|
||||
- [ ] Collapse routes → 2.
|
||||
- [ ] Verify create + edit across all steps (assert DB) + full suite green.
|
||||
- [ ] Feed the skill; append scorecard row.
|
||||
|
||||
---
|
||||
|
||||
### Phase 10 — Client (7-step wizard) — largest, last
|
||||
|
||||
**Rationale:** the biggest, most complex modal (nested bank accounts, location
|
||||
matches, emails, contact methods). Deliberately last, when the skill is richest.
|
||||
|
||||
- [ ] Run the §8 loop; split extraction into sub-tasks per step.
|
||||
- [ ] Extract the 7 step render fns (`:info`, `:matches`, `:contact`,
|
||||
`:bank-accounts`, `:integrations`, `:cash-flow`, `:other-settings`).
|
||||
- [ ] Convert each `add-new-entity-handler` (bank accounts, location matches,
|
||||
emails, contact methods) to an add-row `POST` using the cookbook row pattern;
|
||||
drop `fc/with-field-default` nesting.
|
||||
- [ ] Build `client-wizard-config`; `:new` vs `:edit` via `:init-fn`.
|
||||
- [ ] Collapse routes → 2 (+ add-row endpoints as needed).
|
||||
- [ ] Verify create + edit across all 7 steps thoroughly (assert DB) + full
|
||||
suite green.
|
||||
- [ ] Feed the skill; append scorecard row.
|
||||
|
||||
---
|
||||
|
||||
### Phase 11 — Cleanup
|
||||
|
||||
**Rationale:** remove the now-dead old machinery.
|
||||
|
||||
- [ ] Delete the legacy wizard module (protocols + middleware) once no caller
|
||||
remains; remove any v1→v2 shim.
|
||||
- [ ] Remove the Alpine morph dependency/extension if unreferenced.
|
||||
- [ ] Decide (Open decision 3) whether to extend Selmer to the remaining static
|
||||
Hiccup, now that the skill makes it cheap.
|
||||
- [ ] Promote recurring cookbook entries into shared Selmer partials/components.
|
||||
- [ ] Final scorecard review: confirm the suite-wide LOC/route/complexity drop.
|
||||
|
||||
---
|
||||
|
||||
## 10. Risks & mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| In-flight wizard state lost (restart / session expiry) | State lives in the session (formtools model), scoped to true multi-step wizards; plain forms hold none. Lifetime follows the session; for long-lived wizards choose a durable session backend or store (Open decision 1). `forget` on completion prevents session bloat. |
|
||||
| Mixed Hiccup/Selmer interop gets messy | Build + prove the interop bridge in Phase 2 before broad use; strangler keeps both valid. |
|
||||
| Selmer loses Hiccup's structural testability | Lean on e2e + DB assertions; unit-test the data-prep functions, not markup. |
|
||||
| Large files hide behavior (`clients.clj`, `edit.clj`) | They go last, after the skill is rich; characterization e2e first; split per step. |
|
||||
| Alpine components break across swaps | Codify hardening (null-guarded `tippy?`/`$refs`, key-by-value) as a cookbook entry applied everywhere. |
|
||||
| Heuristics get gamed (LOC golfing, fake route counts) | Directional evidence only; always paired with the e2e parity gate; review the trend, not single numbers. |
|
||||
| Skill-update step skipped under pressure | Required commit-message line (scorecard delta + reused/new entries); if N+1 isn't easier, flag it. |
|
||||
| Quality regresses silently | Ratchet rule: no metric may regress for a touched modal without a written exception in `gotchas.md`. |
|
||||
|
||||
---
|
||||
|
||||
## 11. Open decisions
|
||||
|
||||
1. **Wizard state storage** — store multi-step state in the **Ring session**
|
||||
(Django `formtools` `SessionStorage` model), keyed by `wizard-id`, none for
|
||||
plain forms? Confirm the session backend in use (in-memory vs. durable) is
|
||||
acceptable for in-flight wizard lifetime, or pick a durable store for
|
||||
long-lived flows. *(recommended: session storage, scoped to multi-step
|
||||
wizards only)*
|
||||
2. **Selmer scope** — convert only interactive/attribute-heavy components first
|
||||
(hybrid), or all SSR files (full sweep)? *(recommended: hybrid, revisit in
|
||||
Phase 11)*
|
||||
3. **Whole-form vs. targeted granularity defaults** — confirm the §3.1 priority
|
||||
order (no-request → targeted cell → whole-form → OOB-only-if-disjoint) as the
|
||||
project default. *(recommended: yes)*
|
||||
4. **First step** — start by distilling the skill (Phase 1) with the reference
|
||||
implementation merged as a prerequisite, rather than treating the merge
|
||||
itself as step one. *(recommended: yes)*
|
||||
613
docs/superpowers/plans/2026-05-18-inline-account-editing.md
Normal file
613
docs/superpowers/plans/2026-05-18-inline-account-editing.md
Normal file
@@ -0,0 +1,613 @@
|
||||
# Inline Account Editing 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 sales summary wizard's flat data-grid with a two-column debit/credit layout matching the embedded grid, and add HTMX-based inline account editing (click pencil → typeahead → confirm → swap back to display mode).
|
||||
|
||||
**Architecture:** Each item's account cell renders in "display mode" (account name + hidden input + pencil icon). Clicking the pencil fires an HTMX GET that swaps in a typeahead + confirm/cancel buttons. Confirm fires an HTMX PUT that swaps back to display mode with an updated hidden input. No DB writes until the wizard form is submitted.
|
||||
|
||||
**Tech Stack:** Clojure, Hiccup, HTMX, Alpine.js (for typeahead), form-cursor, multi-modal wizard middleware, Datomic.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add route keys to route definitions
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/cljc/auto_ap/routes/pos/sales_summaries.cljc`
|
||||
|
||||
- [ ] **Step 1: Add three new route keys**
|
||||
|
||||
Add these routes inside the existing `routes` map, alongside `"/edit/sales-summary-item"`:
|
||||
|
||||
```clojure
|
||||
"/edit/item-account" ::edit-item-account
|
||||
"/edit/save-item-account" ::save-item-account
|
||||
"/edit/cancel-item-account" ::cancel-item-account
|
||||
```
|
||||
|
||||
The full routes map should become:
|
||||
|
||||
```clojure
|
||||
(def routes {"" {:get ::page
|
||||
:put ::edit-wizard-submit}
|
||||
"/table" ::table
|
||||
["/" [#"\d+" :db/id]] {:get ::edit-wizard}
|
||||
"/edit/navigate" ::edit-wizard-navigate
|
||||
"/edit/sales-summary-item" ::new-summary-item
|
||||
"/edit/item-account" ::edit-item-account
|
||||
"/edit/save-item-account" ::save-item-account
|
||||
"/edit/cancel-item-account" ::cancel-item-account})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify the route file parses**
|
||||
|
||||
Run: `clj -M:check` or similar. If no checker is available, move on — the Clojure compiler will catch errors at load time.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Add account display cell helper and account edit cell helper
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/clj/auto_ap/ssr/pos/sales_summaries.clj`
|
||||
|
||||
These are pure rendering functions — no routes, no handlers, just hiccup.
|
||||
|
||||
- [ ] **Step 1: Make `account-typeahead*` public**
|
||||
|
||||
Change `defn-` to `defn` for `account-typeahead*` so it can be used from the new handlers:
|
||||
|
||||
```clojure
|
||||
(defn account-typeahead*
|
||||
[{:keys [name value client-id]}]
|
||||
[:div.flex.flex-col
|
||||
(com/typeahead {:name name
|
||||
:placeholder "Search..."
|
||||
:url (hu/url (bidi/path-for ssr-routes/only-routes :account-search)
|
||||
{:client-id client-id
|
||||
:purpose "invoice"})
|
||||
:value value
|
||||
:content-fn (fn [value]
|
||||
(:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read value)
|
||||
client-id)))})])
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `account-display-cell` function**
|
||||
|
||||
This renders the display-mode account cell: account name (or "Missing acct" pill), hidden input, and pencil icon. Insert after the `truncate` defn:
|
||||
|
||||
```clojure
|
||||
(defn account-display-cell [{:keys [item field-name-prefix client-id]}]
|
||||
(let [account-id (:ledger-mapped/account item)
|
||||
account-name (when account-id
|
||||
(:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read account-id)
|
||||
client-id)))]
|
||||
[:div.flex.items-center.gap-2
|
||||
(com/hidden {:name (str field-name-prefix "[ledger-mapped/account]")
|
||||
:value (or account-id "")})
|
||||
(if account-id
|
||||
[:span.text-sm account-name]
|
||||
(com/pill {:color :red} "Missing acct"))
|
||||
(com/a-icon-button {:hx-get (bidi/path-for ssr-routes/only-routes ::route/edit-item-account)
|
||||
:hx-target "closest td"
|
||||
:hx-swap "innerHTML"
|
||||
:hx-vals (hx/json {:item-index (or (:item-index item) 0)
|
||||
:client-id client-id
|
||||
:current-account-id (or account-id "")})}
|
||||
svg/pencil)]))
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add `account-edit-cell` function**
|
||||
|
||||
This renders the edit-mode account cell: typeahead + confirm/cancel buttons. This is what `::route/edit-item-account` returns:
|
||||
|
||||
```clojure
|
||||
(defn account-edit-cell [{:keys [field-name-prefix client-id current-account-id]}]
|
||||
(let [account-input-name (str field-name-prefix "[ledger-mapped/account]")]
|
||||
[:div.flex.flex-col.gap-2
|
||||
(account-typeahead* {:name account-input-name
|
||||
:value current-account-id
|
||||
:client-id client-id})
|
||||
[:div.flex.gap-1
|
||||
(com/a-icon-button {:hx-put (bidi/path-for ssr-routes/only-routes ::route/save-item-account)
|
||||
:hx-target "closest td"
|
||||
:hx-swap "innerHTML"
|
||||
:hx-include "closest td"
|
||||
:hx-vals (hx/json {:field-name-prefix field-name-prefix
|
||||
:client-id client-id})}
|
||||
svg/check)
|
||||
(com/a-icon-button {:hx-get (bidi/path-for ssr-routes/only-routes ::route/cancel-item-account)
|
||||
:hx-target "closest td"
|
||||
:hx-swap "innerHTML"
|
||||
:hx-vals (hx/json {:field-name-prefix field-name-prefix
|
||||
:client-id client-id
|
||||
:current-account-id (or current-account-id "")})}
|
||||
svg/x)]]))
|
||||
```
|
||||
|
||||
**Note:** We construct the input name directly from `field-name-prefix` + `[ledger-mapped/account]` instead of using form-cursor, because the HTMX handler doesn't have access to the wizard's form state. The typeahead component accepts a `:name` string directly.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Verify svg/check exists
|
||||
|
||||
**Files:**
|
||||
- Check: `src/clj/auto_ap/ssr/svg.clj`
|
||||
|
||||
- [ ] **Step 1: Search for check icon**
|
||||
|
||||
Run: `rg "def.*check" src/clj/auto_ap/ssr/svg.clj`
|
||||
|
||||
If `svg/check` does not exist, look for alternatives like `svg/tick`, `svg/confirm`, or `svg/save`. If none exist, use `svg/pencil` with a different label, or use a simple `[:span "✓"]` instead.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Rewrite MainStep render-step to use two-column layout
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/clj/auto_ap/ssr/pos/sales_summaries.clj`
|
||||
|
||||
This is the core UI change. Replace the flat data-grid in `render-step` with a two-column layout matching the embedded grid.
|
||||
|
||||
- [ ] **Step 1: Replace the MainStep record's render-step body**
|
||||
|
||||
Replace the existing `render-step` implementation in the `defrecord MainStep` with:
|
||||
|
||||
```clojure
|
||||
(render-step
|
||||
[this {:keys [multi-form-state] :as request}]
|
||||
(let [client-id (:db/id (:sales-summary/client (:snapshot multi-form-state)))
|
||||
items (sort-items (:sales-summary/items (:step-params multi-form-state)))
|
||||
debit-items (filter #(= :ledger-side/debit (:ledger-mapped/ledger-side %)) items)
|
||||
credit-items (filter #(= :ledger-side/credit (:ledger-mapped/ledger-side %)) items)
|
||||
max-rows (max (count debit-items) (count credit-items))
|
||||
padded-debits (concat debit-items (repeat (- max-rows (count debit-items)) nil))
|
||||
padded-credits (concat credit-items (repeat (- max-rows (count credit-items)) nil))]
|
||||
(mm/default-render-step
|
||||
linear-wizard this
|
||||
:head [:div.p-2 "Edit Summary"]
|
||||
:body (mm/default-step-body
|
||||
{}
|
||||
[:div
|
||||
(fc/with-field :db/id
|
||||
(com/hidden {:name (fc/field-name)
|
||||
:value (fc/field-value)}))
|
||||
[:div.grid.grid-cols-2.gap-4
|
||||
[:div
|
||||
[:div.font-semibold.text-sm.mb-2 "Debits"]
|
||||
[:div.space-y-1
|
||||
(for [[idx item] (map-indexed vector padded-debits)]
|
||||
(if item
|
||||
(let [manual? (:sales-summary-item/manual? item)]
|
||||
[:div.flex.items-center.gap-2.text-sm
|
||||
(com/hidden {:name (str "step-params[sales-summary/items][" idx "][db/id]")
|
||||
:value (:db/id item)})
|
||||
(com/hidden {:name (str "step-params[sales-summary/items][" idx "][sales-summary-item/category]")
|
||||
:value (:sales-summary-item/category item)})
|
||||
(when manual?
|
||||
(com/hidden {:name (str "step-params[sales-summary/items][" idx "][sales-summary-item/manual?]")
|
||||
:value "true"}))
|
||||
[:span.text-gray-500 (truncate (:sales-summary-item/category item) 30)]
|
||||
(account-display-cell {:item (assoc item :item-index idx)
|
||||
:field-name-prefix (str "step-params[sales-summary/items][" idx "]")
|
||||
:client-id client-id})
|
||||
[:span.font-mono (format "$%,.2f" (:ledger-mapped/amount item))]])
|
||||
[:div.h-6]))]
|
||||
(summary-total-row* request)
|
||||
(unbalanced-row* request)]
|
||||
[:div
|
||||
[:div.font-semibold.text-sm.mb-2 "Credits"]
|
||||
[:div.space-y-1
|
||||
(for [[idx item] (map-indexed vector padded-credits)]
|
||||
(if item
|
||||
(let [actual-idx (+ (count debit-items) idx)
|
||||
manual? (:sales-summary-item/manual? item)]
|
||||
[:div.flex.items-center.gap-2.text-sm
|
||||
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][db/id]")
|
||||
:value (:db/id item)})
|
||||
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][sales-summary-item/category]")
|
||||
:value (:sales-summary-item/category item)})
|
||||
(when manual?
|
||||
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][sales-summary-item/manual?]")
|
||||
:value "true"}))
|
||||
[:span.text-gray-500 (truncate (:sales-summary-item/category item) 30)]
|
||||
(account-display-cell {:item (assoc item :item-index actual-idx)
|
||||
:field-name-prefix (str "step-params[sales-summary/items][" actual-idx "]")
|
||||
:client-id client-id})
|
||||
[:span.font-mono (format "$%,.2f" (:ledger-mapped/amount item))]])
|
||||
[:div.h-6]))]
|
||||
(summary-total-row* request)
|
||||
(unbalanced-row* request)]]
|
||||
[:div.mt-4
|
||||
(fc/with-field :sales-summary/items
|
||||
(com/data-grid-new-row {:colspan 2
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes ::route/new-summary-item)
|
||||
:row-offset 0
|
||||
:index (count (fc/field-value))
|
||||
:tr-params {:hx-vals (hx/json {:client-id client-id})}}
|
||||
"New Summary Item"))]])
|
||||
|
||||
:footer
|
||||
(mm/default-step-footer linear-wizard this :validation-route ::route/edit-wizard-navigate)
|
||||
:validation-route ::route/edit-wizard-navigate
|
||||
:width-height-class "lg:w-[900px] lg:h-[600px]")))
|
||||
```
|
||||
|
||||
**Important note on item indexing:** The padded lists are for display alignment only. The hidden inputs must use the *actual* index in the `:sales-summary/items` vector, not the display index. Debit items keep their original indices; credit items' indices start after all debit items. This is a simplification — if items are interspersed (debit, credit, debit), this approach breaks. We need to compute actual indices from the sorted list, not from the filtered sublists. See Step 2.
|
||||
|
||||
- [ ] **Step 2: Fix index calculation to use actual sorted position**
|
||||
|
||||
The approach in Step 1 has an indexing bug. Items in the form are stored as a vector and submitted by index. We must preserve the actual vector index for each item. Replace the layout logic with:
|
||||
|
||||
```clojure
|
||||
(render-step
|
||||
[this {:keys [multi-form-state] :as request}]
|
||||
(let [client-id (:db/id (:sales-summary/client (:snapshot multi-form-state)))
|
||||
items (sort-items (:sales-summary/items (:step-params multi-form-state)))
|
||||
indexed-items (map-indexed vector items)
|
||||
debit-items (filter #(= :ledger-side/debit (:ledger-mapped/ledger-side (second %))) indexed-items)
|
||||
credit-items (filter #(= :ledger-side/credit (:ledger-mapped/ledger-side (second %))) indexed-items)
|
||||
max-rows (max (count debit-items) (count credit-items))
|
||||
padded-debits (concat debit-items (repeat (- max-rows (count debit-items)) nil))
|
||||
padded-credits (concat credit-items (repeat (- max-rows (count credit-items)) nil))]
|
||||
(mm/default-render-step
|
||||
linear-wizard this
|
||||
:head [:div.p-2 "Edit Summary"]
|
||||
:body (mm/default-step-body
|
||||
{}
|
||||
[:div
|
||||
(fc/with-field :db/id
|
||||
(com/hidden {:name (fc/field-name)
|
||||
:value (fc/field-value)}))
|
||||
[:div.grid.grid-cols-2.gap-4
|
||||
[:div
|
||||
[:div.font-semibold.text-sm.mb-2 "Debits"]
|
||||
[:div.space-y-1
|
||||
(for [[actual-idx item] padded-debits]
|
||||
(if item
|
||||
(let [manual? (:sales-summary-item/manual? item)]
|
||||
[:div.flex.items-center.gap-2.text-sm
|
||||
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][db/id]")
|
||||
:value (:db/id item)})
|
||||
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][sales-summary-item/category]")
|
||||
:value (:sales-summary-item/category item)})
|
||||
(when manual?
|
||||
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][sales-summary-item/manual?]")
|
||||
:value "true"}))
|
||||
[:span.text-gray-500 (truncate (:sales-summary-item/category item) 30)]
|
||||
(account-display-cell {:item (assoc item :item-index actual-idx)
|
||||
:field-name-prefix (str "step-params[sales-summary/items][" actual-idx "]")
|
||||
:client-id client-id})
|
||||
[:span.font-mono (format "$%,.2f" (:ledger-mapped/amount item))]])
|
||||
[:div.h-6]))]
|
||||
[:div
|
||||
[:div.font-semibold.text-sm.mb-2 "Credits"]
|
||||
[:div.space-y-1
|
||||
(for [[actual-idx item] padded-credits]
|
||||
(if item
|
||||
(let [manual? (:sales-summary-item/manual? item)]
|
||||
[:div.flex.items-center.gap-2.text-sm
|
||||
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][db/id]")
|
||||
:value (:db/id item)})
|
||||
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][sales-summary-item/category]")
|
||||
:value (:sales-summary-item/category item)})
|
||||
(when manual?
|
||||
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][sales-summary-item/manual?]")
|
||||
:value "true"}))
|
||||
[:span.text-gray-500 (truncate (:sales-summary-item/category item) 30)]
|
||||
(account-display-cell {:item (assoc item :item-index actual-idx)
|
||||
:field-name-prefix (str "step-params[sales-summary/items][" actual-idx "]")
|
||||
:client-id client-id})
|
||||
[:span.font-mono (format "$%,.2f" (:ledger-mapped/amount item))]])
|
||||
[:div.h-6]))]]]
|
||||
[:div.mt-4
|
||||
(fc/with-field :sales-summary/items
|
||||
(com/data-grid-new-row {:colspan 2
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes ::route/new-summary-item)
|
||||
:row-offset 0
|
||||
:index (count (fc/field-value))
|
||||
:tr-params {:hx-vals (hx/json {:client-id client-id})}}
|
||||
"New Summary Item"))]])
|
||||
|
||||
:footer
|
||||
(mm/default-step-footer linear-wizard this :validation-route ::route/edit-wizard-navigate)
|
||||
:validation-route ::route/edit-wizard-navigate
|
||||
:width-height-class "lg:w-[900px] lg:h-[600px]")))
|
||||
```
|
||||
|
||||
**Key design decisions:**
|
||||
- `map-indexed vector items` preserves actual vector position for hidden input names
|
||||
- `padded-debits` / `padded-credits` are sequences of `[actual-idx item]` or `nil` for padding rows
|
||||
- Padding rows render as empty `[:div.h-6]` to maintain alignment
|
||||
- Total/unbalanced rows are not repeated per column — they go below the two-column grid, shared
|
||||
|
||||
- [ ] **Step 3: Move total/unbalanced rows outside the two-column grid**
|
||||
|
||||
The `summary-total-row*` and `unbalanced-row*` functions currently render as `<tr>` elements inside a data-grid. In the new layout, these should be simple flex rows below the grid, not table rows. For now, keep them as-is but render them in a single section below both columns (remove the duplicate from the credit column). Adjust the `:body` content:
|
||||
|
||||
After the `[:div.grid.grid-cols-2.gap-4 ...]` block, add:
|
||||
|
||||
```clojure
|
||||
[:div.mt-2.border-t.pt-2
|
||||
(summary-total-row* request)
|
||||
(unbalanced-row* request)]
|
||||
```
|
||||
|
||||
But since `summary-total-row*` and `unbalanced-row*` currently return `<tr>` elements, they won't render correctly outside a table. For the initial implementation, replace them with inline hiccup that renders the same info in a flex layout. See Task 5.
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Rewrite total and unbalanced display as non-table hiccup
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/clj/auto_ap/ssr/pos/sales_summaries.clj`
|
||||
|
||||
The existing `summary-total-row*` and `unbalanced-row*` return `<tr>` / `<td>` elements for the data-grid. The new layout is not a table, so these need to be simple div-based layouts.
|
||||
|
||||
- [ ] **Step 1: Add `summary-total-display` function**
|
||||
|
||||
Insert after `unbalanced-row*`:
|
||||
|
||||
```clojure
|
||||
(defn summary-total-display [request]
|
||||
(let [total-credits (-> request
|
||||
:multi-form-state
|
||||
:step-params
|
||||
:sales-summary/items
|
||||
(total-credits))
|
||||
total-debits (-> request
|
||||
:multi-form-state
|
||||
:step-params
|
||||
:sales-summary/items
|
||||
(total-debits))]
|
||||
[:div.flex.justify-between.text-sm.py-1
|
||||
[:span.font-semibold "Total"]
|
||||
[:div.flex.gap-8
|
||||
[:span.font-mono (format "$%,.2f" total-debits)]
|
||||
[:span.font-mono (format "$%,.2f" total-credits)]]]))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `unbalanced-display` function**
|
||||
|
||||
```clojure
|
||||
(defn unbalanced-display [request]
|
||||
(let [total-credits (-> request
|
||||
:multi-form-state
|
||||
:step-params
|
||||
:sales-summary/items
|
||||
(total-credits))
|
||||
total-debits (-> request
|
||||
:multi-form-state
|
||||
:step-params
|
||||
:sales-summary/items
|
||||
(total-debits))
|
||||
delta (- total-debits total-credits)]
|
||||
(when-not (dollars-0? delta)
|
||||
[:div.flex.justify-between.text-sm.py-1
|
||||
[:span.font-semibold {:class (if (pos? delta) "text-red-600" "text-green-600")} "Unbalanced"]
|
||||
[:div.flex.gap-8
|
||||
[:span.font-mono (when (pos? delta) (format "$%,.2f" delta))]
|
||||
[:span.font-mono (when (neg? delta) (format "$%,.2f" (Math/abs delta)))]]]])))
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Use these in the render-step body**
|
||||
|
||||
In Task 4's render-step, replace the total/unbalanced section at the bottom with:
|
||||
|
||||
```clojure
|
||||
[:div.mt-2.border-t.pt-2
|
||||
(summary-total-display request)
|
||||
(unbalanced-display request)]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Add `edit-item-account` handler
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/clj/auto_ap/ssr/pos/sales_summaries.clj`
|
||||
|
||||
- [ ] **Step 1: Write the handler**
|
||||
|
||||
Insert before `key->handler`:
|
||||
|
||||
```clojure
|
||||
(defn edit-item-account [request]
|
||||
(let [{:keys [item-index client-id current-account-id]} (:query-params request)
|
||||
item-index (if (string? item-index) (Integer/parseInt item-index) item-index)
|
||||
field-name-prefix (str "step-params[sales-summary/items][" item-index "]")]
|
||||
(html-response
|
||||
(account-edit-cell {:field-name-prefix field-name-prefix
|
||||
:client-id (if (string? client-id) (Long/parseLong client-id) client-id)
|
||||
:current-account-id (when (and current-account-id
|
||||
(not= current-account-id ""))
|
||||
(if (string? current-account-id)
|
||||
(Long/parseLong current-account-id)
|
||||
current-account-id))}))))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add it to key->handler**
|
||||
|
||||
Add to the handler map inside `key->handler`:
|
||||
|
||||
```clojure
|
||||
::route/edit-item-account (-> edit-item-account
|
||||
(wrap-schema-enforce :query-schema [:map
|
||||
[:item-index nat-int?]
|
||||
[:client-id {:optional true} [:maybe entity-id]]
|
||||
[:current-account-id {:optional true} [:maybe :string]]]))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Add `save-item-account` handler
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/clj/auto_ap/ssr/pos/sales_summaries.clj`
|
||||
|
||||
- [ ] **Step 1: Write the handler**
|
||||
|
||||
This handler receives the typeahead's selected value via `hx-include "closest td"`, which includes the hidden input from the typeahead. The typeahead's hidden input name will be something like `step-params[sales-summary/items][2][ledger-mapped/account]`. We need to extract the selected account ID from the form params and return a display cell with the updated value.
|
||||
|
||||
```clojure
|
||||
(defn save-item-account [request]
|
||||
(let [{:keys [field-name-prefix client-id]} (some-> request :query-params)
|
||||
account-input-name (str field-name-prefix "[ledger-mapped/account]")
|
||||
account-id-str (get-in request [:form-params account-input-name])
|
||||
account-id (when (and account-id-str (not= account-id-str ""))
|
||||
(Long/parseLong account-id-str))
|
||||
item {:ledger-mapped/account account-id
|
||||
:item-index (second (re-find #"\[(\d+)\]" field-name-prefix))}]
|
||||
(html-response
|
||||
(account-display-cell {:item item
|
||||
:field-name-prefix field-name-prefix
|
||||
:client-id (if (string? client-id) (Long/parseLong client-id) client-id)}))))
|
||||
```
|
||||
|
||||
**Note:** `field-name-prefix` comes from `hx-vals` in the confirm button. `account-input-name` is constructed by appending `[ledger-mapped/account]` to the prefix. The typeahead's hidden input will have this name.
|
||||
|
||||
- [ ] **Step 2: Add it to key->handler**
|
||||
|
||||
```clojure
|
||||
::route/save-item-account (-> save-item-account
|
||||
(mm/wrap-wizard edit-wizard)
|
||||
(mm/wrap-decode-multi-form-state))
|
||||
```
|
||||
|
||||
Wait — we said no DB writes until wizard submit. So we should NOT wrap with `wrap-wizard` and `wrap-decode-multi-form-state`. The handler just returns HTML. It doesn't need the wizard state. The form params contain the typeahead value, and the query params contain the field name prefix and client-id. Simple.
|
||||
|
||||
```clojure
|
||||
::route/save-item-account save-item-account
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Add `cancel-item-account` handler
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/clj/auto_ap/ssr/pos/sales_summaries.clj`
|
||||
|
||||
- [ ] **Step 1: Write the handler**
|
||||
|
||||
```clojure
|
||||
(defn cancel-item-account [request]
|
||||
(let [{:keys [field-name-prefix client-id current-account-id]} (:query-params request)
|
||||
account-id (when (and current-account-id (not= current-account-id ""))
|
||||
(if (string? current-account-id)
|
||||
(Long/parseLong current-account-id)
|
||||
current-account-id))
|
||||
item {:ledger-mapped/account account-id
|
||||
:item-index (second (re-find #"\[(\d+)\]" field-name-prefix))}]
|
||||
(html-response
|
||||
(account-display-cell {:item item
|
||||
:field-name-prefix field-name-prefix
|
||||
:client-id (if (string? client-id) (Long/parseLong client-id) client-id)}))))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add it to key->handler**
|
||||
|
||||
```clojure
|
||||
::route/cancel-item-account cancel-item-account
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Wire routes in key->handler
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/clj/auto_ap/ssr/pos/sales_summaries.clj`
|
||||
|
||||
- [ ] **Step 1: Add all three new handlers to key->handler**
|
||||
|
||||
The final additions to the handler map (before the closing `}`):
|
||||
|
||||
```clojure
|
||||
::route/edit-item-account (-> edit-item-account
|
||||
(wrap-schema-enforce :query-schema [:map
|
||||
[:item-index nat-int?]
|
||||
[:client-id {:optional true} [:maybe entity-id]]
|
||||
[:current-account-id {:optional true} [:maybe :string]]]))
|
||||
::route/save-item-account save-item-account
|
||||
::route/cancel-item-account cancel-item-account
|
||||
```
|
||||
|
||||
These get the same middleware applied via `apply-middleware-to-all-handlers` at the bottom of `key->handler`.
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Handle manual items in the two-column layout
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/clj/auto_ap/ssr/pos/sales_summaries.clj`
|
||||
|
||||
Manual items have editable category text inputs and debit/credit money inputs. In the two-column layout, manual items need to stay in "edit mode" with their inputs visible.
|
||||
|
||||
- [ ] **Step 1: Add manual item rendering in the debit/credit columns**
|
||||
|
||||
In the render-step `for` loop, when `manual?` is true, render the editable fields instead of display-mode:
|
||||
|
||||
For a debit manual item:
|
||||
|
||||
```clojure
|
||||
[:div.flex.items-center.gap-2.text-sm
|
||||
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][db/id]")
|
||||
:value (:db/id item)})
|
||||
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][sales-summary-item/manual?]")
|
||||
:value "true"})
|
||||
(fc/start-form-with-prefix [(str "step-params[sales-summary/items][" actual-idx "]")]
|
||||
item []
|
||||
(fc/with-field :sales-summary-item/category
|
||||
(com/text-input {:placeholder "Category/Explanation"
|
||||
:name (fc/field-name)
|
||||
:value (fc/field-value)
|
||||
:class "w-32 text-sm"}))
|
||||
(account-display-cell {:item (assoc item :item-index actual-idx)
|
||||
:field-name-prefix (str "step-params[sales-summary/items][" actual-idx "]")
|
||||
:client-id client-id}))
|
||||
(fc/start-form-with-prefix [(str "step-params[sales-summary/items][" actual-idx "]")]
|
||||
item []
|
||||
(fc/with-field :debit
|
||||
(com/money-input {:class "w-24 text-sm"
|
||||
:name (fc/field-name)
|
||||
:value (fc/field-value)})))]
|
||||
```
|
||||
|
||||
For a credit manual item, replace `:debit` with `:credit`.
|
||||
|
||||
---
|
||||
|
||||
### Task 11: Test the complete flow end-to-end
|
||||
|
||||
**Files:**
|
||||
- Manual testing
|
||||
|
||||
- [ ] **Step 1: Open a sales summary row in the wizard**
|
||||
|
||||
Verify the two-column layout renders correctly with debits on the left, credits on the right.
|
||||
|
||||
- [ ] **Step 2: Verify display-mode account cells**
|
||||
|
||||
Each item should show the account name (or "Missing acct" pill) + hidden input + pencil icon.
|
||||
|
||||
- [ ] **Step 3: Click a pencil icon**
|
||||
|
||||
The cell should swap to show a typeahead search + confirm (check) and cancel (X) buttons.
|
||||
|
||||
- [ ] **Step 4: Search and select an account in the typeahead**
|
||||
|
||||
After selecting, click confirm. The cell should swap back to display mode with the updated account name and hidden input value.
|
||||
|
||||
- [ ] **Step 5: Click cancel**
|
||||
|
||||
The cell should swap back to the original display mode.
|
||||
|
||||
- [ ] **Step 6: Submit the wizard**
|
||||
|
||||
All hidden inputs (including the updated account) should be submitted. Verify the transaction updates the correct accounts.
|
||||
|
||||
- [ ] **Step 7: Test manual items**
|
||||
|
||||
Add a new summary item. Verify it renders with editable category + money inputs. Verify the account cell still uses the pencil-to-typeahead pattern.
|
||||
|
||||
- [ ] **Step 8: Test total/unbalanced display**
|
||||
|
||||
Verify totals and unbalanced indicators update correctly (if the `expense-account-total` route is fixed — out of scope for this plan but note if broken).
|
||||
@@ -0,0 +1,203 @@
|
||||
# Memo and Description Filters 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:** Add a new memo filter and enhance the existing description filter to use case-insensitive regex matching on the transaction page.
|
||||
|
||||
**Architecture:** Modify the existing query schema, filter UI, and query logic in `src/clj/auto_ap/ssr/transaction/common.clj`. Both filters convert user input to regex patterns with `(?i)` flag and use Datomic's `re-find`.
|
||||
|
||||
**Tech Stack:** Clojure, Datomic, Hiccup, Malli schema validation
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- **Modify:** `src/clj/auto_ap/ssr/transaction/common.clj` — add memo to query schema, add memo filter UI, update description filter to use regex, add memo filter to query logic
|
||||
- **Test:** `test/clj/auto_ap/ssr/transaction/common_test.clj` — create new test file for filter logic (or add to existing transaction tests)
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Update Query Schema
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/clj/auto_ap/ssr/transaction/common.clj:42`
|
||||
|
||||
- [ ] **Step 1: Add `:memo` to query-schema**
|
||||
|
||||
Add after the `:description` field in the query-schema map:
|
||||
|
||||
```clojure
|
||||
[:memo {:optional true} [:maybe [:string {:decode/string strip}]]]
|
||||
```
|
||||
|
||||
It should be placed after `:description` (line 42) and before `:vendor` (line 43).
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Update Description Filter Query Logic
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/clj/auto_ap/ssr/transaction/common.clj:140-145`
|
||||
|
||||
- [ ] **Step 1: Change description filter from `.contains` to `re-find`**
|
||||
|
||||
Replace the description filter block (lines 140-145):
|
||||
|
||||
```clojure
|
||||
(seq (:description args))
|
||||
(merge-query {:query {:in ['?description]
|
||||
:where ['[?e :transaction/description-original ?do]
|
||||
'[(clojure.string/lower-case ?do) ?do2]
|
||||
'[(.contains ?do2 ?description)]]}
|
||||
:args [(str/lower-case (:description args))]})
|
||||
```
|
||||
|
||||
With:
|
||||
|
||||
```clojure
|
||||
(seq (:description args))
|
||||
(merge-query {:query {:in ['?description-regex]
|
||||
:where ['[?e :transaction/description-original ?do]
|
||||
'[(re-find ?description-regex ?do)]]}
|
||||
:args [(re-pattern (str "(?i).*" (str/lower-case (:description args)) ".*"))]})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Add Memo Filter Query Logic
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/clj/auto_ap/ssr/transaction/common.clj` (after description filter block)
|
||||
|
||||
- [ ] **Step 1: Add memo filter condition in fetch-ids cond-> chain**
|
||||
|
||||
Add after the description filter block (around line 145) and before the amount-gte filter:
|
||||
|
||||
```clojure
|
||||
(seq (:memo args))
|
||||
(merge-query {:query {:in ['?memo-regex]
|
||||
:where ['[?e :transaction/memo ?memo]
|
||||
'[(re-find ?memo-regex ?memo)]]}
|
||||
:args [(re-pattern (str "(?i).*" (str/lower-case (:memo args)) ".*"))]})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Add Memo Filter UI
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/clj/auto_ap/ssr/transaction/common.clj:340-355` (around the description filter UI)
|
||||
|
||||
- [ ] **Step 1: Add memo filter input in the filters function**
|
||||
|
||||
Add after the description filter input (lines 340-346) and before the location filter (lines 348-354):
|
||||
|
||||
```clojure
|
||||
(com/field {:label "Memo"}
|
||||
(com/text-input {:name "memo"
|
||||
:id "memo"
|
||||
:class "hot-filter"
|
||||
:value (:memo (:query-params request))
|
||||
:placeholder "e.g., Rent"
|
||||
:size :small}))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Write Tests
|
||||
|
||||
**Files:**
|
||||
- Create: `test/clj/auto_ap/ssr/transaction/common_test.clj`
|
||||
|
||||
- [ ] **Step 1: Create test file for filter logic**
|
||||
|
||||
```clojure
|
||||
(ns auto-ap.ssr.transaction.common-test
|
||||
(:require
|
||||
[auto-ap.ssr.transaction.common :as sut]
|
||||
[clojure.test :as t :refer [deftest is testing use-fixtures]]))
|
||||
|
||||
(deftest description-filter-regex-pattern
|
||||
(testing "Description filter creates correct regex pattern"
|
||||
(let [pattern (re-pattern (str "(?i).*" "Groceries" ".*"))]
|
||||
(is (re-find pattern "My Groceries Store"))
|
||||
(is (re-find pattern "GROCERIES"))
|
||||
(is (re-find pattern "groceries shop"))
|
||||
(is (not (re-find pattern "Restaurant"))))))
|
||||
|
||||
(deftest memo-filter-regex-pattern
|
||||
(testing "Memo filter creates correct regex pattern"
|
||||
(let [pattern (re-pattern (str "(?i).*" "Rent" ".*"))]
|
||||
(is (re-find pattern "Monthly Rent"))
|
||||
(is (re-find pattern "RENT"))
|
||||
(is (re-find pattern "rent payment"))
|
||||
(is (not (re-find pattern "Utilities"))))))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they pass**
|
||||
|
||||
Run: `clj-nrepl-eval -p PORT "(clojure.test/run-tests 'auto-ap.ssr.transaction.common-test)"`
|
||||
|
||||
Expected: All tests pass
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Verify Changes
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/clj/auto_ap/ssr/transaction/common.clj`
|
||||
|
||||
- [ ] **Step 1: Check that both filters work correctly**
|
||||
|
||||
1. Start the application: `INTEGREAT_JOB="" lein run`
|
||||
2. Navigate to the transaction page
|
||||
3. Test description filter:
|
||||
- Enter "gro" in description filter
|
||||
- Should show transactions with descriptions containing "gro" (case-insensitive)
|
||||
4. Test memo filter:
|
||||
- Enter "rent" in memo filter
|
||||
- Should show transactions with memos containing "rent" (case-insensitive)
|
||||
5. Test combined filters:
|
||||
- Use both description and memo filters together
|
||||
- Should show only transactions matching both criteria
|
||||
|
||||
- [ ] **Step 2: Commit changes**
|
||||
|
||||
```bash
|
||||
git add src/clj/auto_ap/ssr/transaction/common.clj
|
||||
git add test/clj/auto_ap/ssr/transaction/common_test.clj
|
||||
git commit -m "feat: add memo filter and enhance description filter with regex matching"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage check:**
|
||||
- ✅ New memo filter added to query schema (Task 1)
|
||||
- ✅ Memo filter uses `re-find` with `(?i)` flag (Task 3)
|
||||
- ✅ Description filter enhanced to use `re-find` (Task 2)
|
||||
- ✅ Both filters wrap input with `.*` on both ends
|
||||
- ✅ Memo filter placed after description filter in query logic (Task 3)
|
||||
- ✅ Memo filter UI added to filter sidebar (Task 4)
|
||||
- ✅ Tests written for regex patterns (Task 5)
|
||||
|
||||
**Placeholder scan:**
|
||||
- No TBDs, TODOs, or placeholder text found
|
||||
- All code blocks contain actual implementation code
|
||||
|
||||
**Type consistency:**
|
||||
- `:memo` field uses same schema structure as `:description`
|
||||
- Both filters use `re-pattern` with `(?i)` flag consistently
|
||||
|
||||
## Execution Handoff
|
||||
|
||||
Plan complete and saved to `docs/superpowers/plans/2026-05-26-memo-description-filter-plan.md`.
|
||||
|
||||
**Two execution options:**
|
||||
|
||||
**1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration
|
||||
|
||||
**2. Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints
|
||||
|
||||
Which approach?
|
||||
@@ -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,145 @@
|
||||
# Inline Account Editing in Sales Summary Wizard
|
||||
|
||||
## Problem
|
||||
|
||||
The current edit wizard for sales summaries renders every item in a flat data-grid with a full typeahead component per row for account assignment. This requires heavy scrolling and makes it hard to see the debit/credit structure at a glance.
|
||||
|
||||
## Solution
|
||||
|
||||
Redesign the wizard's MainStep to mirror the embedded grid's two-column layout (debits / credits), and replace the always-visible typeahead with a click-to-swap inline editing pattern powered by HTMX.
|
||||
|
||||
## Current Flow
|
||||
|
||||
1. Click pencil icon on a grid row → opens full modal wizard
|
||||
2. Wizard renders a data-grid where every row has: category (hidden or text input), account (typeahead), debit, credit
|
||||
3. Every row initializes a typeahead component, even if the user only needs to edit one account
|
||||
4. Heavy scrolling due to tall rows
|
||||
|
||||
## New Flow
|
||||
|
||||
1. Click pencil icon on a grid row → opens modal wizard showing two columns (debits / credits) matching the embedded grid layout
|
||||
2. Each item's account cell renders in **display mode**: account name text + hidden input holding the account ID + pencil icon. If no account is assigned, shows a red "Missing acct" pill + pencil icon.
|
||||
3. Click pencil on an account cell → `hx-get` to `::route/edit-item-account` → server returns **edit mode** (typeahead + confirm/cancel buttons), replacing just that cell via `hx-swap "innerHTML"`
|
||||
4. User selects an account in the typeahead → clicks confirm → `hx-put` to `::route/save-item-account` → server returns **display mode** (updated account name text + updated hidden input + pencil icon)
|
||||
5. Click cancel → `hx-get` to `::route/cancel-item-account` → server returns original display mode
|
||||
6. When the user submits the entire wizard form, all hidden inputs (including updated account IDs) are collected by the existing multi-form-state decode and saved in a single DB transaction
|
||||
|
||||
### Key Constraint
|
||||
|
||||
HTMX routes only manage interactivity (swapping cells). No DB writes happen until the wizard form is submitted via the existing submit handler.
|
||||
|
||||
## New Routes
|
||||
|
||||
| Route Key | Method | Purpose |
|
||||
|---|---|---|
|
||||
| `::route/edit-item-account` | GET | Returns typeahead + confirm/cancel for one account cell |
|
||||
| `::route/save-item-account` | PUT | Returns display mode with updated hidden input value |
|
||||
| `::route/cancel-item-account` | GET | Returns display mode with original hidden input value |
|
||||
|
||||
### Route Parameters
|
||||
|
||||
All three routes receive:
|
||||
- `item-index` — the index of the sales-summary/item in the vector (to construct the correct field name prefix)
|
||||
- `client-id` — for the typeahead search URL
|
||||
- The form-cursor field name prefix is derived from `item-index` so the returned hidden input has the correct `name` attribute (e.g. `step-params[sales-summary/items][2][ledger-mapped/account]`)
|
||||
|
||||
Additionally:
|
||||
- `edit-item-account` and `cancel-item-account` receive the `current-account-id` as a query param so cancel can restore the original value
|
||||
- `save-item-account` receives the selected account ID from the typeahead's form submission in the request body
|
||||
|
||||
## Wizard MainStep Changes
|
||||
|
||||
### Layout
|
||||
|
||||
Replace the current flat data-grid with a two-column layout mirroring the embedded grid:
|
||||
|
||||
```
|
||||
+-------------------------------------------+
|
||||
| Debits | Credits |
|
||||
|-------------------------------------------|
|
||||
| Category Acct Amt | Category Acct Amt |
|
||||
| ... | ... |
|
||||
|-------------------------------------------|
|
||||
| Total: $X,XXX.XX | Total: $X,XXX.XX |
|
||||
| Delta: $XX.XX | Delta: $XX.XX |
|
||||
+-------------------------------------------+
|
||||
| [+ New Summary Item] |
|
||||
+-------------------------------------------+
|
||||
```
|
||||
|
||||
### Item Rendering (Display Mode)
|
||||
|
||||
For each item (non-manual):
|
||||
- **Category**: text label + hidden input
|
||||
- **Account**: account name text (or "Missing acct" pill) + hidden input with account ID + pencil icon with `hx-get`
|
||||
- **Amount**: formatted dollar amount (debit or credit column)
|
||||
|
||||
### Item Rendering (Edit Mode — account cell only)
|
||||
|
||||
When the pencil is clicked, only the account cell swaps to:
|
||||
- Typeahead component (same `account-typeahead*` as current)
|
||||
- Confirm button (small check icon) with `hx-put`
|
||||
- Cancel button (small X icon) with `hx-get`
|
||||
|
||||
### Manual Items
|
||||
|
||||
Same as current: category text input, account typeahead, debit/credit money inputs, delete button. The "New Summary Item" button remains. Manual items are always in "edit mode" since they have editable fields beyond just account.
|
||||
|
||||
### Hidden Inputs
|
||||
|
||||
Every item row must include hidden inputs for:
|
||||
- `db/id`
|
||||
- `sales-summary-item/category` (for non-manual items)
|
||||
- `sales-summary-item/manual?` (for manual items)
|
||||
- `ledger-mapped/account` — this is the key one that gets updated by the inline edit flow
|
||||
|
||||
When the typeahead swaps in (edit mode), the old hidden input for `ledger-mapped/account` is replaced by the typeahead's own hidden input. On confirm, the server returns the updated hidden input. On cancel, the server returns the original hidden input.
|
||||
|
||||
### Total / Unbalanced Rows
|
||||
|
||||
Same as current: `summary-total-row*` and `unbalanced-row*` with live recalculation via `hx-put` to `::route/expense-account-total`.
|
||||
|
||||
## Handler Implementation
|
||||
|
||||
### `edit-item-account` handler
|
||||
|
||||
1. Parse query params: item index, client-id, current field name prefix
|
||||
2. Render the typeahead + confirm/cancel buttons
|
||||
3. The typeahead uses the same `account-typeahead*` pattern
|
||||
4. Confirm button: `hx-put` to `::route/save-item-account`, `hx-target "closest td"`, `hx-swap "innerHTML"`
|
||||
5. Cancel button: `hx-get` to `::route/cancel-item-account`, `hx-target "closest td"`, `hx-swap "innerHTML"`
|
||||
|
||||
### `save-item-account` handler
|
||||
|
||||
1. Parse form body: selected account ID, item index, client-id, field name prefix
|
||||
2. Resolve account name from DB using `d-accounts/clientize`
|
||||
3. Return display mode HTML: account name text + hidden input (with new account ID) + pencil icon
|
||||
|
||||
### `cancel-item-account` handler
|
||||
|
||||
1. Parse query params: item index, client-id, current field name prefix, original account ID
|
||||
2. Resolve account name from DB (if account ID exists)
|
||||
3. Return display mode HTML: account name text (or "Missing acct" pill) + hidden input (with original account ID) + pencil icon
|
||||
|
||||
## Route Definitions
|
||||
|
||||
Add to `routes.cljc`:
|
||||
|
||||
```clojure
|
||||
"/edit/item-account" ::edit-item-account
|
||||
"/edit/save-item-account" ::save-item-account
|
||||
"/edit/cancel-item-account" ::cancel-item-account
|
||||
```
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `src/cljc/auto_ap/routes/pos/sales_summaries.cljc` | Add 3 new route keys |
|
||||
| `src/clj/auto_ap/ssr/pos/sales_summaries.clj` | Rewrite MainStep render-step, add 3 handlers, add helper fns for account display/edit cells |
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Changes to the embedded grid table (already redesigned)
|
||||
- Changes to how the wizard submit handler works
|
||||
- Adding the missing `::route/expense-account-total` route (pre-existing bug, separate fix)
|
||||
@@ -0,0 +1,152 @@
|
||||
# Transaction Account Amount Mode Toggle - Design Spec
|
||||
|
||||
**Date:** 2026-05-20
|
||||
**Feature:** Global $/% Toggle for Transaction Accounts (Manual Action)
|
||||
|
||||
## Overview
|
||||
|
||||
In the transaction edit modal's "manual" action view, replace the static "$" column header with a sliding toggle that allows users to switch between viewing amounts as dollar values or percentages. When toggled, the entire account grid re-renders via HTMX with converted values. Percentages are multiplied by 100 (e.g., $200 on a $200 transaction → 100%). When switching back to dollars, use `spread-cents` to ensure accurate cent distribution.
|
||||
|
||||
## Motivation
|
||||
|
||||
The cljs master version supports per-row $/% toggles. Users want this capability in the SSR version, but with a single global toggle in the table header for simplicity and consistency with the bulk coding interface.
|
||||
|
||||
## Schema Changes
|
||||
|
||||
### Form State
|
||||
|
||||
Add `amount-mode` to the edit form's step params:
|
||||
|
||||
```clojure
|
||||
[:amount-mode [:enum "$" "%"] {:default "$"}]
|
||||
```
|
||||
|
||||
Stored in `multi-form-state` alongside existing transaction data. Not persisted to Datomic—purely a UI preference.
|
||||
|
||||
## UI Design
|
||||
|
||||
### Table Header
|
||||
|
||||
Replace the static `"$"` header cell (line ~739 in `edit.clj`) with a radio toggle:
|
||||
|
||||
```clojure
|
||||
(com/radio-card {:options [{:value "$" :content "$"}
|
||||
{:value "%" :content "%"}]
|
||||
:value (or amount-mode "$")
|
||||
:name "step-params[amount-mode]"
|
||||
:hx-post (bidi/path-for ssr-routes/only-routes ::route/toggle-amount-mode)
|
||||
:hx-target "#account-grid-body"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-include "closest form"})
|
||||
```
|
||||
|
||||
### Grid Body
|
||||
|
||||
Wrap the account grid rows in a container with id `account-grid-body`:
|
||||
|
||||
```clojure
|
||||
[:div#account-grid-body
|
||||
(fc/cursor-map #(transaction-account-row* ...))
|
||||
...total/balance rows...]
|
||||
```
|
||||
|
||||
When toggled, only this container re-renders. All other form fields (vendor, memo, approval status) are preserved.
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Toggle Request (HTMX)
|
||||
|
||||
1. User clicks toggle
|
||||
2. HTMX serializes entire form via `hx-include "closest form"`
|
||||
3. POST to `::route/toggle-amount-mode`
|
||||
4. Server:
|
||||
- Merges form params into existing `multi-form-state`
|
||||
- Extracts old mode and new mode
|
||||
- Converts all `:transaction-account/amount` values:
|
||||
- If old="$" new="%": multiply by 100/total
|
||||
- If old="%" new="$": use `percentages->dollars` (see Conversion Logic)
|
||||
- Updates `amount-mode` in state
|
||||
- Re-renders `#account-grid-body`
|
||||
5. Client swaps grid body. Tab order preserved.
|
||||
|
||||
### Conversion Logic
|
||||
|
||||
**$ → %:**
|
||||
```clojure
|
||||
(defn ->percentage [amount total]
|
||||
(when (and amount total (not= total 0))
|
||||
(* 100.0 (/ amount total))))
|
||||
```
|
||||
|
||||
**% → $ (using spread-cents):**
|
||||
```clojure
|
||||
(defn percentages->dollars [percentages total]
|
||||
(let [total-cents (int (* 100 (Math/abs total)))
|
||||
pct-sum (reduce + 0 percentages)
|
||||
;; Normalize percentages to sum to 100
|
||||
normalized-pcts (if (zero? pct-sum)
|
||||
(repeat (count percentages) 0)
|
||||
(map #(* (/ % pct-sum) 100) percentages))
|
||||
;; Convert each pct to its share of cents
|
||||
individual-cents (map #(int (* total-cents (/ % 100))) normalized-pcts)
|
||||
short-by (- total-cents (reduce + 0 individual-cents))
|
||||
;; Distribute remainder using spread-cents pattern
|
||||
adjustments (concat (take short-by (repeat 1)) (repeat 0))
|
||||
final-cents (map + individual-cents adjustments)]
|
||||
(map #(* 0.01 %) final-cents)))
|
||||
```
|
||||
|
||||
Example: One account at 100% of $200.00 → `total-cents=20000`, `individual-cents=[20000]`, result: `[200.00]`
|
||||
|
||||
### Save Handling
|
||||
|
||||
Before validation in `save-handler :manual`:
|
||||
|
||||
```clojure
|
||||
(let [snapshot (:snapshot multi-form-state)
|
||||
accounts (:transaction/accounts snapshot)
|
||||
total (Math/abs (:transaction/amount existing-tx))
|
||||
mode (:amount-mode snapshot "$")
|
||||
;; If in % mode, convert back to $ before saving
|
||||
accounts' (if (= "%" mode)
|
||||
(let [percentages (map :transaction-account/amount accounts)
|
||||
dollar-amounts (percentages->dollars percentages total)]
|
||||
(map #(assoc %1 :transaction-account/amount %2) accounts dollar-amounts))
|
||||
accounts)]
|
||||
...)
|
||||
```
|
||||
|
||||
## Form Preservation
|
||||
|
||||
The HTMX toggle is designed to preserve:
|
||||
- **Tab order:** All inputs remain in DOM with same `tabindex` attributes
|
||||
- **Other form fields:** Vendor, memo, approval status are outside `#account-grid-body`
|
||||
- **Alpine.js state:** `x-data` on rows uses `data-key="show"` for animation—this is re-established on re-render
|
||||
- **Field names:** Account/location/amount field names follow `step-params[transaction/accounts][N][...]` pattern
|
||||
|
||||
## Error Handling
|
||||
|
||||
- **Zero transaction amount:** If total is $0, percentages are all 0%. Toggle is disabled or shows error.
|
||||
- **Percentage sum ≠ 100:** After editing in % mode, if percentages don't sum to 100, normalize proportionally before converting back to $.
|
||||
- **Invalid input:** If user types non-numeric in % mode, existing form validation catches it on submit.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
1. **Toggle $→%:** 200/200 transaction shows 100.0
|
||||
2. **Toggle %→$:** 100% on 200 transaction shows 200.00
|
||||
3. **Multiple accounts:** 50/50 split on 200 → 100.00/100.00 after conversion
|
||||
4. **Cent distribution:** 33.33/33.33/33.34% on $100 → uses spread-cents for accurate distribution
|
||||
5. **Form preservation:** Toggle doesn't lose vendor/memo data
|
||||
6. **Save in % mode:** Correctly converts back to $ before Datomic transaction
|
||||
|
||||
## Files to Modify
|
||||
|
||||
- `src/clj/auto_ap/ssr/transaction/edit.clj` — main implementation
|
||||
- `src/clj/auto_ap/routes/transactions.clj` — add `::route/toggle-amount-mode`
|
||||
- `src/clj/auto_ap/ssr/transaction/edit.clj` routes map — register handler
|
||||
|
||||
## Future Considerations
|
||||
|
||||
- This pattern could be extracted for reuse in invoice expense accounts
|
||||
- Consider persisting user's last-used mode preference in localStorage
|
||||
- Could add visual indicator when percentages don't sum to 100%
|
||||
@@ -0,0 +1,85 @@
|
||||
# Bulk Coding Transactions - Requirements Document
|
||||
|
||||
Based on analysis of the master cljs implementation (`src/cljs/auto_ap/views/pages/transactions/bulk_updates.cljs`) and GraphQL resolver (`src/clj/auto_ap/graphql/transactions.clj`).
|
||||
|
||||
## Feature Overview
|
||||
|
||||
Bulk coding allows admin users to apply vendor, approval status, and expense account allocations to multiple transactions simultaneously from the transactions grid page.
|
||||
|
||||
## Functional Requirements
|
||||
|
||||
### 1. Access Control
|
||||
- **FR-1.1**: Bulk coding must be restricted to admin users only
|
||||
- **FR-1.2**: The bulk code button should only be visible/enabled when transactions are selected
|
||||
|
||||
### 2. Transaction Selection
|
||||
- **FR-2.1**: Users can select specific transactions via checkboxes in the grid
|
||||
- **FR-2.2**: Users can select all visible transactions via a header checkbox
|
||||
- **FR-2.3**: The system must filter out locked transactions (where client's `locked-until` date is after transaction date)
|
||||
- **FR-2.4**: The modal must display the count of transactions that will actually be coded (after filtering locked ones)
|
||||
|
||||
### 3. Bulk Code Form Fields
|
||||
- **FR-3.1**: **Vendor** (optional): Searchable typeahead to select a vendor
|
||||
- **FR-3.2**: **Approval Status** (optional): Select from:
|
||||
- No Change (empty)
|
||||
- Approved
|
||||
- Unapproved
|
||||
- Suppressed
|
||||
- Requires Feedback
|
||||
- **FR-3.3**: **Expense Accounts** (optional): One or more account allocations with:
|
||||
- Account: Searchable typeahead for expense accounts
|
||||
- Location: Dropdown with "Shared" and client-specific locations
|
||||
- Percentage: Numeric input (0-100), must total exactly 100% across all accounts
|
||||
|
||||
### 4. Account Location Validation
|
||||
- **FR-4.1**: If an account has a fixed location configured, the selected location MUST match it
|
||||
- **FR-4.2**: If an account has no fixed location, the selected location must be either "Shared" or one of the client's locations
|
||||
- **FR-4.3**: Invalid locations must be rejected with a clear error message
|
||||
|
||||
### 5. Percentage Validation
|
||||
- **FR-5.1**: When accounts are provided, the sum of all percentages must equal exactly 100%
|
||||
- **FR-5.2**: Values must be between 0 and 100
|
||||
- **FR-5.3**: Invalid totals must be rejected with a clear error message showing the actual total
|
||||
|
||||
### 6. Amount Distribution
|
||||
- **FR-6.1**: Percentages are converted to dollar amounts per transaction based on each transaction's amount
|
||||
- **FR-6.2**: For "Shared" location, amounts are distributed evenly across all client locations (with proper cent handling)
|
||||
- **FR-6.3**: Rounding errors are absorbed by the last account row
|
||||
- **FR-6.4**: Each transaction gets its own set of transaction-account entities
|
||||
|
||||
### 7. Submission Behavior
|
||||
- **FR-7.1**: Submitting with no accounts, no vendor, and no status should be a no-op (or rejected)
|
||||
- **FR-7.2**: On success, all selected non-locked transactions are updated
|
||||
- **FR-7.3**: Success response triggers a table refresh
|
||||
- **FR-7.4**: Modal closes on success
|
||||
|
||||
## UI/UX Requirements (from Master)
|
||||
|
||||
### SSR-Specific Adaptations
|
||||
- The SSR version uses a modal wizard with HTMX instead of a re-frame modal
|
||||
- Form state is managed server-side via `multi-form-state`
|
||||
- Percentage inputs display as whole numbers (50 for 50%) but are stored as decimals (0.5)
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
### Happy Path
|
||||
1. Select single transaction, code with 100% to one account
|
||||
2. Select multiple transactions, code with vendor + status + accounts
|
||||
3. Select all visible transactions via header checkbox
|
||||
|
||||
### Validation
|
||||
4. Submit without any changes (no vendor, no status, no accounts)
|
||||
5. Submit with accounts totaling < 100%
|
||||
6. Submit with accounts totaling > 100%
|
||||
7. Submit with invalid location for account
|
||||
8. Submit with location not belonging to client
|
||||
|
||||
### Edge Cases
|
||||
9. All selected transactions are locked (count should be 0)
|
||||
10. Mix of locked and unlocked transactions (only unlocked should be coded)
|
||||
11. "Shared" location distributes across multiple client locations
|
||||
|
||||
## Known Issues to Verify
|
||||
|
||||
1. **Missing location validation**: The SSR version (`bulk_code.clj`) does not validate account locations against client locations or account fixed locations (present in GraphQL version)
|
||||
2. **Approval status options**: Verify "excluded" vs "suppressed" naming consistency
|
||||
@@ -0,0 +1,66 @@
|
||||
# Design: Memo and Description Filters for Transaction Page
|
||||
|
||||
## Overview
|
||||
|
||||
Add a new **Memo** filter to the transaction page and enhance the existing **Description** filter to support wildcard matching. Both filters should be case-insensitive.
|
||||
|
||||
## Changes
|
||||
|
||||
### 1. New Memo Filter
|
||||
|
||||
- Add a text input field in the filter sidebar
|
||||
- Search against `:transaction/memo` attribute
|
||||
- Convert user input to regex pattern `.*input.*` with `(?i)` flag
|
||||
- Use Datomic `re-find` for matching
|
||||
- **Place this filter towards the end of the filter list** since regex matching is expensive
|
||||
|
||||
### 2. Enhanced Description Filter
|
||||
|
||||
- Change from `.contains` substring matching to `re-find` with `(?i)` flag
|
||||
- Wrap user input with `.*` on both ends: `.*input.*`
|
||||
- Maintains existing UI placement
|
||||
|
||||
## Files Modified
|
||||
|
||||
- `src/clj/auto_ap/ssr/transaction/common.clj`
|
||||
|
||||
### Query Schema Changes
|
||||
|
||||
Add `:memo` key to the `query-schema` map:
|
||||
```clojure
|
||||
[:memo {:optional true} [:maybe [:string {:decode/string strip}]]]
|
||||
```
|
||||
|
||||
### Filter UI Changes
|
||||
|
||||
Add memo filter input in the `filters` function, placed **after** the Amount filter and **before** the Linking filter.
|
||||
|
||||
### Query Logic Changes
|
||||
|
||||
In `fetch-ids`, add memo filter condition in the `cond->` chain (placed after other cheaper filters like description).
|
||||
|
||||
### Description Filter Update
|
||||
|
||||
Change the description filter from:
|
||||
```clojure
|
||||
'[(clojure.string/lower-case ?do) ?do2]
|
||||
'[(.contains ?do2 ?description)]]
|
||||
```
|
||||
|
||||
To:
|
||||
```clojure
|
||||
'[(re-find ?description-regex ?do)]]
|
||||
```
|
||||
with args: `[(re-pattern (str "(?i).*" description ".*"))]`
|
||||
|
||||
## Behavior
|
||||
|
||||
- Both filters are optional (only applied when user enters text)
|
||||
- Both are case-insensitive
|
||||
- Both support substring matching (e.g., "rent" matches "Monthly Rent Payment")
|
||||
- Empty or whitespace-only input is ignored
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- Memo filter is placed towards the end of the filter chain since regex operations are more expensive than exact matches
|
||||
- Description filter also uses regex, but since it's an existing filter being enhanced, it stays in its current position in the query
|
||||
@@ -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 |
|
||||
185
docs/testing/README.md
Normal file
185
docs/testing/README.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# Integreat Test Strategy & Behavior Documentation
|
||||
|
||||
**Last Updated:** 2026-05-04
|
||||
|
||||
## Purpose
|
||||
|
||||
This document defines the comprehensive testing strategy for Integreat. The goal is to cover all user-visible behaviors with tests that provide confidence in the system's correctness. We are preserving valuable existing tests and filling gaps with new behavior documentation.
|
||||
|
||||
## Test Types
|
||||
|
||||
### 1. Unit Tests
|
||||
- **Scope:** Pure functions, transformations, validations, business logic
|
||||
- **Characteristics:** No database, no external services, deterministic
|
||||
- **Location:** `test/clj/auto_ap/<namespace>_test.clj`
|
||||
- **Examples:** `new_invoice_wizard_test.clj` (location spreading logic)
|
||||
|
||||
### 2. Integration Tests
|
||||
- **Scope:** Database interactions, GraphQL resolvers, route handlers, cross-system behavior
|
||||
- **Characteristics:** Uses Datomic in-memory database (`datomic:mem://test`), schema created per test, data cleaned up after
|
||||
- **Location:** `test/clj/auto_ap/integration/`
|
||||
- **Fixtures:** `wrap-setup` creates schema + functions, runs test, deletes DB
|
||||
- **Helpers:** `test/clj/auto_ap/integration/util.clj` provides entity creation helpers
|
||||
|
||||
### 3. UI / Functional Tests (Playwright)
|
||||
- **Scope:** End-to-end user flows, page navigation, form interactions, HTMX behaviors
|
||||
- **Characteristics:** Runs against running application, exercises real HTTP routes
|
||||
- **Location:** TBD (likely `test/functional/` or similar)
|
||||
- **Scope Limitation:** Only SSR pages (HTMX-based) get UI tests. Legacy SPA pages get behavior docs only.
|
||||
|
||||
## Existing Test Inventory
|
||||
|
||||
### Unit Tests
|
||||
| File | Coverage | Status |
|
||||
|------|----------|--------|
|
||||
| `auto_ap/ezcater_test.clj` | EzCater integration logic | Keep |
|
||||
| `auto_ap/import/plaid_test.clj` | Plaid import | Keep |
|
||||
| `auto_ap/import/transactions_test.clj` | Transaction import | Keep |
|
||||
| `auto_ap/import/yodlee_test.clj` | Yodlee import | Keep |
|
||||
| `auto_ap/import/manual_test.clj` | Manual import | Keep |
|
||||
| `auto_ap/ledger_test.clj` | Ledger calculations | Keep |
|
||||
| `auto_ap/parse/templates_test.clj` | PDF template parsing | Keep |
|
||||
| `auto_ap/ssr/invoice/new_invoice_wizard_test.clj` | Location spreading logic | Keep |
|
||||
|
||||
### Integration Tests
|
||||
| File | Coverage | Status |
|
||||
|------|----------|--------|
|
||||
| `auto_ap/integration/graphql.clj` | Transaction page, invoice page, ledger page, vendors, transaction rules | Keep |
|
||||
| `auto_ap/integration/graphql/accounts.clj` | Account GraphQL | Keep |
|
||||
| `auto_ap/integration/graphql/checks.clj` | Check GraphQL | Keep |
|
||||
| `auto_ap/integration/graphql/invoices.clj` | Invoice GraphQL | Keep |
|
||||
| `auto_ap/integration/graphql/ledger/running_balance.clj` | Ledger balance | Keep |
|
||||
| `auto_ap/integration/graphql/transaction_rules.clj` | Transaction rules | Keep |
|
||||
| `auto_ap/integration/graphql/transactions.clj` | Transaction GraphQL | Keep |
|
||||
| `auto_ap/integration/graphql/users.clj` | User GraphQL | Keep |
|
||||
| `auto_ap/integration/graphql/vendors.clj` | Vendor GraphQL | Keep |
|
||||
| `auto_ap/integration/routes/invoice_test.clj` | Invoice import routes | Keep |
|
||||
| `auto_ap/integration/routes/ezcater_xls.clj` | EzCater XLS routes | Keep |
|
||||
| `auto_ap/integration/rule_matching.clj` | Rule matching logic | Keep |
|
||||
| `auto_ap/integration/jobs/ntg.clj` | NTG background job | Keep |
|
||||
|
||||
## Page/Subsystem Coverage Map
|
||||
|
||||
### SSR Pages (HTMX - Eligible for UI Tests)
|
||||
1. **Dashboard** - Main dashboard with cards (expenses, tasks, bank accounts, sales, P&L)
|
||||
2. **Invoices** - List, detail, new wizard, pay wizard, bulk edit, bulk delete, import, glimpse (OCR)
|
||||
3. **Payments** - List, detail, bulk operations
|
||||
4. **Transactions** - List, detail, new, external import, coding workflow
|
||||
5. **Ledger** - Entries, P&L, Balance Sheet, Cash Flows, investigation
|
||||
6. **Company** - Profile, signature, 1099s, reports, bank accounts, Plaid/Yodlee linking
|
||||
7. **POS** - Sales, expected deposits, tenders, refunds, cash drawer shifts
|
||||
8. **Outgoing Invoices** - Create outgoing invoices
|
||||
9. **Admin** - Clients, accounts, vendors, transaction rules, background jobs, history, import batches, Excel invoices, sales summaries
|
||||
10. **Search** - Global search dialog
|
||||
11. **Indicators** - Business indicators
|
||||
|
||||
### Legacy SPA Pages (Behavior Docs Only)
|
||||
1. **Home** - Legacy dashboard
|
||||
2. **Login** - Authentication
|
||||
3. **Transactions** - Unapproved, approved, requires feedback, excluded
|
||||
4. **Ledger** - P&L, Balance Sheet, Cash Flows, external, external import
|
||||
5. **Payments** - Legacy payments list
|
||||
6. **Reports** - Reports page
|
||||
7. **Admin Vendors** - Vendor management (legacy)
|
||||
8. **New Vendor** - Vendor creation
|
||||
|
||||
## Behavior Documentation Structure
|
||||
|
||||
Each subsystem gets a markdown file in `docs/testing/behaviors/` with the following structure:
|
||||
|
||||
```markdown
|
||||
# <Subsystem> Behaviors
|
||||
|
||||
## Overview
|
||||
Brief description of what this subsystem does and its user-visible purpose.
|
||||
|
||||
## Pages / Routes
|
||||
List of all routes and their purposes.
|
||||
|
||||
## Behaviors by Type
|
||||
|
||||
### Unit Test Behaviors
|
||||
- Pure function behaviors, edge cases
|
||||
|
||||
### Integration Test Behaviors
|
||||
- Database interactions, API behaviors, cross-system flows
|
||||
|
||||
### UI Test Behaviors (SSR only)
|
||||
- End-to-end happy paths
|
||||
- User interaction flows
|
||||
- Navigation between pages
|
||||
|
||||
## Edge Cases
|
||||
- Error states
|
||||
- Empty states
|
||||
- Permission boundaries
|
||||
- Concurrent operations
|
||||
- Large data volumes
|
||||
|
||||
## Test Data Requirements
|
||||
What entities need to exist for meaningful tests.
|
||||
|
||||
## Dependencies
|
||||
What other subsystems this depends on.
|
||||
```
|
||||
|
||||
## Test Data Patterns
|
||||
|
||||
The integration test utilities in `test/clj/auto_ap/integration/util.clj` provide:
|
||||
- `test-client` - Creates a test client entity
|
||||
- `test-vendor` - Creates a test vendor entity
|
||||
- `test-bank-account` - Creates a test bank account
|
||||
- `test-transaction` - Creates a test transaction
|
||||
- `test-payment` - Creates a test payment
|
||||
- `test-invoice` - Creates a test invoice
|
||||
- `test-account` - Creates a test GL account
|
||||
- `test-transaction-rule` - Creates a test transaction rule
|
||||
- `setup-test-data` - Convenience function to create standard test data set
|
||||
- `admin-token` / `user-token` - JWT identity helpers
|
||||
|
||||
## Mocks & External Services
|
||||
|
||||
- **Solr:** Mocked for search functionality
|
||||
- **AWS Services:** (Textract, S3, SES, SQS) - Should be mocked in tests
|
||||
- **Plaid/Yodlee/Intuit:** External financial APIs - Mocked
|
||||
- **Square/EzCater:** POS integrations - Mocked
|
||||
|
||||
## Priorities
|
||||
|
||||
1. **Critical:** Invoice pages (core business function)
|
||||
2. **Critical:** Payment pages (money movement)
|
||||
3. **High:** Dashboard (first thing users see)
|
||||
4. **High:** Transaction pages (data entry/coding)
|
||||
5. **High:** Ledger reports (financial reporting)
|
||||
6. **Medium:** Admin pages (operations)
|
||||
7. **Medium:** Company settings (configuration)
|
||||
8. **Low:** POS pages (ancillary)
|
||||
9. **Low:** Legacy SPA pages (behavior docs only, no UI tests)
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
# All tests
|
||||
lein test
|
||||
|
||||
# Integration tests only
|
||||
lein test :integration
|
||||
|
||||
# Functional tests only
|
||||
lein test :functional
|
||||
```
|
||||
|
||||
## Files
|
||||
|
||||
- [Dashboard Behaviors](behaviors/dashboard.md)
|
||||
- [Invoice Behaviors](behaviors/invoice.md)
|
||||
- [Payment Behaviors](behaviors/payment.md)
|
||||
- [Transaction Behaviors](behaviors/transaction.md)
|
||||
- [Ledger Behaviors](behaviors/ledger.md)
|
||||
- [Company Behaviors](behaviors/company.md)
|
||||
- [POS Behaviors](behaviors/pos.md)
|
||||
- [Outgoing Invoice Behaviors](behaviors/outgoing-invoice.md)
|
||||
- [Admin Behaviors](behaviors/admin.md)
|
||||
- [Search & Indicators Behaviors](behaviors/search-indicators.md)
|
||||
- [Auth Behaviors](behaviors/auth.md)
|
||||
- [Legacy SPA Behaviors](behaviors/legacy-spa.md)
|
||||
494
docs/testing/behaviors/admin.md
Normal file
494
docs/testing/behaviors/admin.md
Normal file
@@ -0,0 +1,494 @@
|
||||
# Admin Behaviors
|
||||
|
||||
## Overview
|
||||
|
||||
The Admin section is a server-side rendered (SSR) HTMX interface for superuser operations in Integreat. All admin pages require the `admin` user role. Non-admin users are redirected away. The admin area includes dashboards, entity management grids, background job orchestration, audit history, and bulk import tools.
|
||||
|
||||
**Testing Philosophy**
|
||||
- Prefer unit tests for pure business logic (calculations, validations, transformations)
|
||||
- Use integration tests for database interactions and cross-system flows
|
||||
- Use UI tests only for end-to-end happy paths that touch multiple pages
|
||||
- Every behavior must be user-visible; no tests for implementation details
|
||||
|
||||
---
|
||||
|
||||
## Testing Patterns
|
||||
|
||||
### Pattern: Grid Page Behaviors
|
||||
Most list pages in Integreat follow the same pattern:
|
||||
1. Fetch IDs via Datomic query with filters
|
||||
2. Hydrate results via `pull-many`
|
||||
3. Render table with sortable columns
|
||||
4. Support selection (individual / all / all-filtered)
|
||||
5. Action buttons appear conditionally based on permissions and selection state
|
||||
|
||||
**Test implications:** Validate the query generation, not the rendering. UI tests only need to verify one filter and one sort work end-to-end.
|
||||
|
||||
### Pattern: Wizard Behaviors
|
||||
Wizards are multi-step forms with HTMX-driven navigation:
|
||||
1. Each step is a GET that renders a form fragment
|
||||
2. Form submissions are POST/PUT with validation
|
||||
3. Navigation between steps updates the wizard state
|
||||
4. Final submit creates/updates the entity
|
||||
|
||||
**Test implications:** Unit test validation logic and state transitions. Integration test the full wizard flow once. UI test only the happy path.
|
||||
|
||||
### Pattern: Admin Permission Gates
|
||||
Every admin operation checks:
|
||||
1. `wrap-client-redirect-unauthenticated` — redirects unauthenticated users to login
|
||||
2. `wrap-admin` — blocks non-admin authenticated users
|
||||
3. `assert-can-see-client` — when impersonating, user has access to the client
|
||||
|
||||
**Test implications:** Integration test each gate independently. UI tests only verify the happy path with an admin user.
|
||||
|
||||
---
|
||||
|
||||
## Dashboard
|
||||
|
||||
### Display Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 1.1 | It should display a bar chart showing client count at 2 years ago, 1 year ago, and today | UI | [ ] |
|
||||
| 1.2 | It should display a line chart showing Datomic transaction counts per hour over the last 24 hours | UI | [ ] |
|
||||
| 1.3 | It should render the standard admin page layout with the admin-aside-nav sidebar | UI | [ ] |
|
||||
|
||||
### Access Control Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 2.1 | It should redirect unauthenticated users to the login page | Integration | [ ] |
|
||||
| 2.2 | It should show an authorization failure for authenticated non-admin users | Integration | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Clients
|
||||
|
||||
### Grid Display Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 3.1 | It should display a table with columns: Name, Code, Locations, Emails, Status | UI | [ ] |
|
||||
| 3.2 | It should show location values as pills in the Locations column | UI | [ ] |
|
||||
| 3.3 | It should show email values as pills in the Emails column | UI | [ ] |
|
||||
| 3.4 | It should show lock status as "Locked <date>" with green color when less than 90 days old | UI | [ ] |
|
||||
| 3.5 | It should show lock status with yellow color when between 90 days and 1 year old | UI | [ ] |
|
||||
| 3.6 | It should show lock status with red color when more than 1 year old | UI | [ ] |
|
||||
| 3.7 | It should show "Not locked" in red when no lock date is set | UI | [ ] |
|
||||
| 3.8 | It should show bank account integration status pills in red for failed or unauthorized accounts | UI | [ ] |
|
||||
| 3.9 | It should show a Biweekly Sales PowerQuery button on each row | UI | [ ] |
|
||||
| 3.10 | It should show an Edit button (pencil icon) on each row | UI | [ ] |
|
||||
| 3.11 | It should show a "New Client" button that opens the client wizard | UI | [ ] |
|
||||
|
||||
### Filtering Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 4.1 | It should filter clients by name using case-insensitive substring match | Integration | [ ] |
|
||||
| 4.2 | It should filter clients by code using exact match on upper-cased code | Integration | [ ] |
|
||||
| 4.3 | It should filter clients by group using exact match on upper-cased group | Integration | [ ] |
|
||||
| 4.4 | It should support an "All" or "Only mine" filter to show only clients assigned to the current user | Integration | [ ] |
|
||||
| 4.5 | It should trigger HTMX requests with 500ms debounce on filter change and 1000ms debounce on keyup | Integration | [ ] |
|
||||
|
||||
### Sorting Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 5.1 | It should sort clients by name ascending/descending | Integration | [ ] |
|
||||
| 5.2 | It should sort clients by code ascending/descending | Integration | [ ] |
|
||||
| 5.3 | It should paginate results with 25 clients per page by default | Integration | [ ] |
|
||||
|
||||
### Client Wizard Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 6.1 | It should show a multi-step linear wizard with steps: Info, Matches, Contact, Bank Accounts, Integrations, Cash Flow, Other Settings | UI | [ ] |
|
||||
| 6.2 | It should require Name on the Info step | Integration | [ ] |
|
||||
| 6.3 | It should prevent editing the Code field when editing an existing client | UI | [ ] |
|
||||
| 6.4 | It should allow setting a Locked Until date on the Info step | UI | [ ] |
|
||||
| 6.5 | It should show a dynamic grid for adding and removing locations on the Info step | UI | [ ] |
|
||||
| 6.6 | It should allow configuring string match patterns and location match patterns on the Matches step | UI | [ ] |
|
||||
| 6.7 | It should allow entering address fields and email contacts on the Contact step | UI | [ ] |
|
||||
| 6.8 | It should show a sortable card list of existing bank accounts on the Bank Accounts step | UI | [ ] |
|
||||
| 6.9 | It should allow adding cash accounts with nickname, code, financial code, start date, include-in-reports, and visible-for-payment fields | UI | [ ] |
|
||||
| 6.10 | It should allow adding credit card accounts with bank name, account number, and Plaid/Yodlee/Intuit integration selectors | UI | [ ] |
|
||||
| 6.11 | It should allow adding checking accounts with routing number, bank code, and check number fields | UI | [ ] |
|
||||
| 6.12 | It should require a financial code when "Include in Reports" is enabled for a bank account | Unit + Integration | [ ] |
|
||||
| 6.13 | It should allow entering a Square auth token and mapping Square locations to client locations on the Integrations step | UI | [ ] |
|
||||
| 6.14 | It should show "No locations found" when the Square location refresh times out after 2 seconds | Integration | [ ] |
|
||||
| 6.15 | It should allow entering Week A/B credits and debits on the Cash Flow step | UI | [ ] |
|
||||
| 6.16 | It should allow selecting feature flags and entering groups on the Other Settings step | UI | [ ] |
|
||||
| 6.17 | It should validate that the client code is unique when creating a new client | Unit + Integration | [ ] |
|
||||
| 6.18 | It should upper-case group values on save | Unit | [ ] |
|
||||
| 6.19 | It should flash the updated row in the grid and close the modal after a successful save | UI | [ ] |
|
||||
| 6.20 | It should reindex the client in Solr after a successful save | Integration | [ ] |
|
||||
|
||||
### Biweekly Sales PowerQuery Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 7.1 | It should generate 6 saved queries per client (sales summary, category, expected deposits, tenders, refunds, cash drawer shifts) | Integration | [ ] |
|
||||
| 7.2 | It should open a modal with copy-to-clipboard buttons for Excel Power Query M-code | UI | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Accounts
|
||||
|
||||
### Grid Display Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 8.1 | It should display a table with columns: Code, Name, Type, Location | UI | [ ] |
|
||||
| 8.2 | It should show the account type as a colored pill | UI | [ ] |
|
||||
| 8.3 | It should show an Edit button on each row | UI | [ ] |
|
||||
| 8.4 | It should show a "New Account" button | UI | [ ] |
|
||||
|
||||
### Filtering Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 9.1 | It should filter accounts by name using case-insensitive substring match on upper-cased name | Integration | [ ] |
|
||||
| 9.2 | It should filter accounts by code using exact numeric match | Integration | [ ] |
|
||||
| 9.3 | It should filter accounts by type: All, Dividend, Asset, Equity, Liability, Expense, Revenue, or None | Integration | [ ] |
|
||||
|
||||
### Sorting Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 10.1 | It should sort accounts by code, name, type, or location ascending/descending | Integration | [ ] |
|
||||
| 10.2 | It should default sort by upper-cased numeric code | Integration | [ ] |
|
||||
|
||||
### Account Dialog Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 11.1 | It should show a modal dialog with a live-updating header displaying the numeric code and name | UI | [ ] |
|
||||
| 11.2 | It should require a numeric code when creating a new account | Integration | [ ] |
|
||||
| 11.3 | It should hide the numeric code field when editing an existing account | UI | [ ] |
|
||||
| 11.4 | It should require a name and account type | Integration | [ ] |
|
||||
| 11.5 | It should allow setting Invoice Allowance, Vendor Allowance, and Applicability as dropdown enums | UI | [ ] |
|
||||
| 11.6 | It should show a Client Overrides grid with client typeahead and override name | UI | [ ] |
|
||||
| 11.7 | It should validate that no client appears more than once in the Client Overrides grid | Unit + Integration | [ ] |
|
||||
| 11.8 | It should validate that the numeric code is unique when creating a new account | Unit + Integration | [ ] |
|
||||
| 11.9 | It should reindex the account and all client overrides in Solr after a successful save | Integration | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Vendors
|
||||
|
||||
### Grid Display Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 12.1 | It should display a table with columns: Name, Email, Default Account | UI | [ ] |
|
||||
| 12.2 | It should show an "Unused" pill in red when a vendor has 0 clients | UI | [ ] |
|
||||
| 12.3 | It should show a "Used by N clients" pill in primary color when a vendor is assigned to clients | UI | [ ] |
|
||||
| 12.4 | It should show a "Used N times" pill in secondary color when a vendor has transaction usage | UI | [ ] |
|
||||
| 12.5 | It should show an Edit button on each row that opens the vendor wizard | UI | [ ] |
|
||||
| 12.6 | It should show a "Merge" button for merging vendors | UI | [ ] |
|
||||
| 12.7 | It should show a "New Vendor" button | UI | [ ] |
|
||||
|
||||
### Filtering Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 13.1 | It should filter vendors by name using case-insensitive substring match on upper-cased name | Integration | [ ] |
|
||||
| 13.2 | It should filter vendors by visibility: All, Only hidden, or Only global | Integration | [ ] |
|
||||
|
||||
### Vendor Wizard Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 14.1 | It should show a multi-step wizard with steps: Info, Terms, Account, Address, Legal | UI | [ ] |
|
||||
| 14.2 | It should require a name of at least 3 characters on the Info step | Unit + Integration | [ ] |
|
||||
| 14.3 | It should allow toggling a "Print As" alias on the Info step | UI | [ ] |
|
||||
| 14.4 | It should show a "Hidden" checkbox on the Info step visible only to admins | UI | [ ] |
|
||||
| 14.5 | It should allow setting terms in days and a grid of client-specific terms overrides on the Terms step | UI | [ ] |
|
||||
| 14.6 | It should allow configuring a list of clients for automatically paid when due on the Terms step | UI | [ ] |
|
||||
| 14.7 | It should allow selecting a default account via typeahead on the Account step | UI | [ ] |
|
||||
| 14.8 | It should show an Account Overrides grid where account typeahead is scoped by selected client | Integration | [ ] |
|
||||
| 14.9 | It should allow entering address fields with a 2-character state and 5-character zip on the Address step | UI | [ ] |
|
||||
| 14.10 | It should allow entering a legal entity name OR first/middle/last name, TIN, TIN type, and 1099 type on the Legal step | UI | [ ] |
|
||||
| 14.11 | It should validate that terms override clients are unique with no duplicates | Unit + Integration | [ ] |
|
||||
| 14.12 | It should reindex the vendor name and hidden flag in Solr after a successful save | Integration | [ ] |
|
||||
|
||||
### Vendor Merge Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 15.1 | It should open a modal with Source Vendor and Target Vendor selectors | UI | [ ] |
|
||||
| 15.2 | It should validate that the source and target vendors are different | Unit + Integration | [ ] |
|
||||
| 15.3 | It should retract all references to the source vendor and assert them as the target vendor on merge | Integration | [ ] |
|
||||
| 15.4 | It should show a success notification after a successful merge | UI | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Rules
|
||||
|
||||
### Grid Display Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 16.1 | It should display a table with columns: Client, Bank Account, Description, Amount, Note | UI | [ ] |
|
||||
| 16.2 | It should show a group pill in the Client column when the rule applies to a client group | UI | [ ] |
|
||||
| 16.3 | It should show amount gte/lte filters as pills in the Amount column | UI | [ ] |
|
||||
| 16.4 | It should show Delete, Execute, and Edit row action buttons | UI | [ ] |
|
||||
| 16.5 | It should show a "New Transaction Rule" button | UI | [ ] |
|
||||
|
||||
### Filtering Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 17.1 | It should filter rules by vendor using an entity typeahead | Integration | [ ] |
|
||||
| 17.2 | It should filter rules by note using case-insensitive regex match | Integration | [ ] |
|
||||
| 17.3 | It should filter rules by description using case-insensitive substring match | Integration | [ ] |
|
||||
| 17.4 | It should filter rules by client group using exact upper-cased match | Integration | [ ] |
|
||||
|
||||
### Transaction Rule Wizard Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 18.1 | It should show a two-step wizard: Edit then Test | UI | [ ] |
|
||||
| 18.2 | It should require a description regex pattern of at least 3 characters on the Edit step | Unit + Integration | [ ] |
|
||||
| 18.3 | It should allow toggling optional filters for Client, Client Group, Bank Account, Amount range, and Day of Month range | UI | [ ] |
|
||||
| 18.4 | It should scope the bank account selector to the selected client | Integration | [ ] |
|
||||
| 18.5 | It should allow assigning a vendor, configuring account grids, and setting approval status as outcomes | UI | [ ] |
|
||||
| 18.6 | It should derive account location from the account's fixed location, client locations, or "Shared" | Unit | [ ] |
|
||||
| 18.7 | It should validate that account percentages sum to exactly 100% | Unit + Integration | [ ] |
|
||||
| 18.8 | It should validate that the selected bank account belongs to the selected client | Unit + Integration | [ ] |
|
||||
| 18.9 | It should validate that the rule location matches the account's fixed location when one is set | Unit + Integration | [ ] |
|
||||
| 18.10 | It should show up to 15 matching transactions on the Test step with client, bank, date, and description | Integration | [ ] |
|
||||
| 18.11 | It should display a badge showing the total match count with "99+" when 99 or more transactions match | UI | [ ] |
|
||||
|
||||
### Rule Execution Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 19.1 | It should open a dialog with checkbox-selectable transactions that match the rule and are unapproved | UI | [ ] |
|
||||
| 19.2 | It should include only transactions on or after the client's locked-until date | Integration | [ ] |
|
||||
| 19.3 | It should allow selecting all matching transactions or individual transactions | UI | [ ] |
|
||||
| 19.4 | It should apply rule coding to each selected transaction | Integration | [ ] |
|
||||
| 19.5 | It should update the Solr index after rule execution | Integration | [ ] |
|
||||
| 19.6 | It should show a notification reading "Successfully coded X of Y transactions!" after execution | UI | [ ] |
|
||||
|
||||
### Rule Deletion Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 20.1 | It should show a confirmation dialog before deleting a rule | UI | [ ] |
|
||||
| 20.2 | It should retract the rule entity from the database on confirmation | Integration | [ ] |
|
||||
| 20.3 | It should fade out the row with a "live-removed" animation after deletion | UI | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Jobs
|
||||
|
||||
### Grid Display Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 21.1 | It should display a table with columns: Start time, End time, Duration, Name, Status | UI | [ ] |
|
||||
| 21.2 | It should show status values as running, pending, succeeded, or failed | UI | [ ] |
|
||||
| 21.3 | It should display ECS tasks filtered by the INTEGREAT_JOB environment variable | Integration | [ ] |
|
||||
| 21.4 | It should show a "Run job" button | UI | [ ] |
|
||||
|
||||
### Job Start Dialog Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 22.1 | It should show a job type dropdown with options: Yodlee Import, Yodlee Account Import, Intuit Import, Plaid Import, Bulk Journal Import, Square Import, Register Invoice Import, Upsert recent ezcater orders, Load Historical Square Sales, Export Backup | UI | [ ] |
|
||||
| 22.2 | It should show a dynamic subform with an S3 URL path input for Bulk Journal Import and Register Invoice Import | UI | [ ] |
|
||||
| 22.3 | It should show a client typeahead and days input (1-120) for Load Historical Square Sales | UI | [ ] |
|
||||
| 22.4 | It should prevent starting a job that is already running | Integration | [ ] |
|
||||
| 22.5 | It should launch an ECS Fargate Spot task on submit | Integration | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## History
|
||||
|
||||
### Search Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 23.1 | It should allow searching for an entity by numeric Datomic entity ID | UI | [ ] |
|
||||
| 23.2 | It should show an error notification when the entity ID cannot be parsed as a Long | Integration | [ ] |
|
||||
|
||||
### Display Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 24.1 | It should display a history table with columns: Date, User, Field, From value, To value | UI | [ ] |
|
||||
| 24.2 | It should format date values in local format | Unit | [ ] |
|
||||
| 24.3 | It should display large integers greater than 1 million as clickable links to that entity's history | UI | [ ] |
|
||||
| 24.4 | It should display nil values as "(none)" | Unit | [ ] |
|
||||
| 24.5 | It should allow clicking an entity ID to load that entity's history inline | Integration | [ ] |
|
||||
| 24.6 | It should show a Snapshot link that opens an inspector displaying all entity attributes | UI | [ ] |
|
||||
| 24.7 | It should show all history rows without pagination | Integration | [ ] |
|
||||
|
||||
### Inspector Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 25.1 | It should display a card showing all attributes of an entity at the current database value | UI | [ ] |
|
||||
| 25.2 | It should allow clicking entity IDs within the inspector to recurse into that entity's history | Integration | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Imports
|
||||
|
||||
### Grid Display Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 26.1 | It should display a table with columns: Date, Source, Status, User, Imported, Pre-existing, Suppressed | UI | [ ] |
|
||||
| 26.2 | It should show an external link on each row to transactions filtered by import batch | UI | [ ] |
|
||||
| 26.3 | It should show a "New Import" button | UI | [ ] |
|
||||
|
||||
### Filtering Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 27.1 | It should filter import batches by date range | Integration | [ ] |
|
||||
| 27.2 | It should filter import batches by source | Integration | [ ] |
|
||||
|
||||
### Sorting Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 28.1 | It should sort import batches by date, source, status, or user | Integration | [ ] |
|
||||
| 28.2 | It should paginate results with 25 import batches per page by default | Integration | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Excel Invoices
|
||||
|
||||
### Display Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 29.1 | It should display a single-page form with a large textarea for tab-separated invoice data | UI | [ ] |
|
||||
| 29.2 | It should show sample data as a placeholder in the textarea | UI | [ ] |
|
||||
|
||||
### Import Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 30.1 | It should parse tab-separated rows with columns: raw-date, vendor-name, check, location, invoice-number, amount, client-name, bill-entered, bill-rejected, added-on, exported-on, account-numeric-code | Unit | [ ] |
|
||||
| 30.2 | It should resolve the client by code or name | Integration | [ ] |
|
||||
| 30.3 | It should resolve the vendor by exact case-sensitive name match | Integration | [ ] |
|
||||
| 30.4 | It should resolve the account by numeric code | Integration | [ ] |
|
||||
| 30.5 | It should group rows into new, existing, and error categories | Unit | [ ] |
|
||||
| 30.6 | It should create a paid invoice with zero outstanding balance and a cash transaction when the check type is "Cash" | Integration | [ ] |
|
||||
| 30.7 | It should create an unpaid invoice with full outstanding balance when the check type is not "Cash" | Integration | [ ] |
|
||||
| 30.8 | It should display results as pills showing imported count, extant count, and vendors not found with hover tooltip | UI | [ ] |
|
||||
| 30.9 | It should display an error grid for rows that failed validation | UI | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Sales Summaries
|
||||
|
||||
### Grid Display Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 31.1 | It should display a table with columns: Client, Date, Debits, Credits | UI | [ ] |
|
||||
| 31.2 | It should hide the Client column when only one client is selected | UI | [ ] |
|
||||
| 31.3 | It should show each debit/credit item with category, amount, and a red "missing account" pill when no account is mapped | UI | [ ] |
|
||||
| 31.4 | It should show a total row with a green pill when debits equal credits, or a red pill when unbalanced | UI | [ ] |
|
||||
| 31.5 | It should show an Edit button on each row | UI | [ ] |
|
||||
|
||||
### Filtering Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 32.1 | It should filter sales summaries by date range | Integration | [ ] |
|
||||
| 32.2 | It should scope results to the user's valid clients | Integration | [ ] |
|
||||
|
||||
### Edit Wizard Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 33.1 | It should display a grid of sales summary items with Category, Account, Debits, and Credits columns | UI | [ ] |
|
||||
| 33.2 | It should allow editing the category, account, and debit/credit amounts for manual items | UI | [ ] |
|
||||
| 33.3 | It should allow removing manual items from the grid | UI | [ ] |
|
||||
| 33.4 | It should display auto-generated items with read-only category and amount but editable account | UI | [ ] |
|
||||
| 33.5 | It should scope the account typeahead to the client and filter for invoice-purpose accounts | Integration | [ ] |
|
||||
| 33.6 | It should update the live total row and unbalanced row when amounts change | UI | [ ] |
|
||||
| 33.7 | It should validate that each item has exactly one of credit or debit, not both | Unit + Integration | [ ] |
|
||||
| 33.8 | It should validate that total debits equal total credits before saving | Unit + Integration | [ ] |
|
||||
| 33.9 | It should update ledger-mapped account assignments and flag manual items on save | Integration | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Cross-Cutting Behaviors
|
||||
|
||||
### Admin-Only Access Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 34.1 | It should redirect unauthenticated users to the login page on all admin routes | Integration | [ ] |
|
||||
| 34.2 | It should show an authorization failure for authenticated non-admin users on all admin routes | Integration | [ ] |
|
||||
| 34.3 | It should require admin role for all mutating admin handlers | Integration | [ ] |
|
||||
|
||||
### Audit History Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 35.1 | It should record the admin user who performed each mutating operation via the `:audit/user` attribute | Integration | [ ] |
|
||||
| 35.2 | It should write all mutating operations through `audit-transact` or `audit-transact-batch` | Integration | [ ] |
|
||||
| 35.3 | It should allow querying all changes to an entity from Datomic's history database on the History page | Integration | [ ] |
|
||||
|
||||
### Impersonation Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 36.1 | It should allow admin users to select a client from the global client selector to filter admin grids | UI | [ ] |
|
||||
| 36.2 | It should respect the selected client when filtering the Clients, Transaction Rules, and Sales Summaries grids | Integration | [ ] |
|
||||
|
||||
### Form Validation Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 37.1 | It should enforce form structure via Malli schemas | Unit | [ ] |
|
||||
| 37.2 | It should validate query params, route params, and form params via `wrap-schema-enforce` | Integration | [ ] |
|
||||
| 37.3 | It should re-render dialogs with field-level validation errors on 400 responses | Integration | [ ] |
|
||||
|
||||
### Solr Indexing Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 38.1 | It should reindex Solr documents after creating or updating a client | Integration | [ ] |
|
||||
| 38.2 | It should reindex Solr documents after creating or updating a vendor or account | Integration | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Test Data Requirements
|
||||
|
||||
| Entity | Requirements |
|
||||
|--------|-------------|
|
||||
| **Users** | Admin user with `:user/role :user-role/admin`; non-admin user with `:user/role :user-role/user` |
|
||||
| **Clients** | Client with name, code, and locations; client with locked-until in past and future; client with bank accounts (cash, credit, checking); client with Square auth token; client with feature flags and groups |
|
||||
| **Accounts** | Account with unique numeric code, name, and type; account with fixed location; account with client overrides |
|
||||
| **Vendors** | Vendor with name and default account; vendor with terms overrides and account overrides; hidden and non-hidden vendors; vendor with 1099 legal entity info |
|
||||
| **Transaction Rules** | Rule with description pattern, client, bank account, amount gte/lte; rule with account assignments summing to 100%; unapproved transactions matching rule criteria; transactions before and after client's locked-until date |
|
||||
| **Background Jobs** | ECS cluster with task definitions containing `INTEGREAT_JOB` env var, or mock ECS responses |
|
||||
| **Import Batches** | Import batch entities with date, source, and status |
|
||||
| **Sales Summaries** | Sales summary with ledger-mapped items; both manual and auto-generated items |
|
||||
|
||||
## Existing Tests to Preserve
|
||||
|
||||
- `test/clj/auto_ap/integration/graphql/users.clj` — User role and permission tests
|
||||
- `test/clj/auto_ap/integration/graphql/accounts.clj` — Account search and override tests
|
||||
- `test/clj/auto_ap/integration/graphql/vendors.clj` — Vendor management tests
|
||||
- `test/clj/auto_ap/integration/graphql/transaction_rules.clj` — Rule matching and execution tests
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Datomic (primary store, history, pull API)
|
||||
- HTMX (frontend interactivity)
|
||||
- Alpine.js (modal state, conditional visibility)
|
||||
- Chartist (dashboard charts)
|
||||
- Solr (search indexing)
|
||||
- ECS API (background jobs)
|
||||
- Malli (schema validation)
|
||||
- Bidi (routing)
|
||||
184
docs/testing/behaviors/auth.md
Normal file
184
docs/testing/behaviors/auth.md
Normal file
@@ -0,0 +1,184 @@
|
||||
# Authentication Behaviors
|
||||
|
||||
## Overview
|
||||
|
||||
Authentication in Integreat uses Google OAuth 2.0 as the primary identity provider. Users authenticate via Google, receive a JWT token, and establish a server-side session. The system supports role-based access control (`admin`, `user`, `read-only`), client-scoped permissions, session versioning for SSR rollout, and admin impersonation via signed JWT tokens.
|
||||
|
||||
**Testing Philosophy**
|
||||
- Prefer unit tests for pure business logic (JWT generation, compression, validation)
|
||||
- Use integration tests for database interactions and cross-system flows (OAuth callback, session management)
|
||||
- Use UI tests only for end-to-end happy paths that touch multiple pages
|
||||
- Every behavior must be user-visible; no tests for implementation details
|
||||
|
||||
---
|
||||
|
||||
## Testing Patterns
|
||||
|
||||
### Pattern: OAuth Login Flow
|
||||
The standard authentication flow follows these steps:
|
||||
1. Unauthenticated user navigates to a protected page
|
||||
2. User is redirected to `/login`
|
||||
3. User clicks "Sign in with Google"
|
||||
4. Browser redirects to Google OAuth consent screen
|
||||
5. User consents and Google redirects to `/api/oauth?code=<code>&state=<state>`
|
||||
6. Server exchanges code for token, fetches profile, finds/creates user
|
||||
7. Server redirects to original page (or `/`) with `?jwt=<token>`
|
||||
8. Client reads JWT and establishes session
|
||||
|
||||
**Test implications:** Integration test the OAuth callback handler. UI test only the happy path once.
|
||||
|
||||
### Pattern: Middleware Stack
|
||||
Every protected route passes through authentication middleware:
|
||||
1. `wrap-session-version` — validates session version, redirects to login if outdated
|
||||
2. `wrap-secure` — checks for authenticated session, redirects to login if missing
|
||||
3. `wrap-admin` — checks for admin role, redirects to login if not admin
|
||||
4. `wrap-client-redirect-unauthenticated` — converts 401 responses to HTMX redirects
|
||||
|
||||
**Test implications:** Integration test each middleware independently. UI tests only verify redirect behavior.
|
||||
|
||||
### Pattern: JWT Claims
|
||||
The JWT token contains user identity and permissions:
|
||||
1. `:user`, `:exp`, `:db/id`, `:user/role`, `:user/name` for all users
|
||||
2. `:gz-clients` (compressed) for admin and read-only users
|
||||
3. `:user/clients` (plain) for regular users
|
||||
|
||||
**Test implications:** Unit test JWT generation for each role type.
|
||||
|
||||
---
|
||||
|
||||
## Login
|
||||
|
||||
### Display Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 1.1 | It should display a "Sign in with Google" button on the login page | UI | [ ] |
|
||||
|
||||
### OAuth Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 1.2 | It should redirect to Google OAuth when the user clicks "Sign in with Google" | UI | [ ] |
|
||||
| 1.3 | It should exchange the authorization code for an access token on callback | Integration | [ ] |
|
||||
| 1.4 | It should fetch the user's Google profile using the access token | Integration | [ ] |
|
||||
| 1.5 | It should create a new user account when the user logs in for the first time | Integration | [ ] |
|
||||
| 1.6 | It should find the existing user account on subsequent logins | Integration | [ ] |
|
||||
| 1.7 | It should redirect to the original page after successful OAuth | Integration | [ ] |
|
||||
| 1.8 | It should redirect to the root page when no return URL is provided | Integration | [ ] |
|
||||
| 1.9 | It should establish a server-side session with user identity and version | Integration | [ ] |
|
||||
| 1.10 | It should pass the JWT token in the query string after successful OAuth | Integration | [ ] |
|
||||
| 1.11 | It should display the user's clients and data after successful login | UI | [ ] |
|
||||
| 1.12 | It should handle users without email via Google provider ID | Integration | [ ] |
|
||||
| 1.13 | It should return 401 with error message when the OAuth code is missing | Integration | [ ] |
|
||||
| 1.14 | It should return 401 when the OAuth code is invalid or expired | Integration | [ ] |
|
||||
| 1.15 | It should return 401 and log a warning when the Google network request fails | Integration | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Logout
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 2.1 | It should clear the session when the user navigates to `/logout` | Integration | [ ] |
|
||||
| 2.2 | It should redirect to the login page after logout | Integration | [ ] |
|
||||
| 2.3 | It should remain idempotent when logging out without an active session | Integration | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Impersonation
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 3.1 | It should allow admin users to assume another user's identity via signed JWT | Integration | [ ] |
|
||||
| 3.2 | It should validate the impersonation JWT signature with `:jwt-secret` and `:hs512` | Integration | [ ] |
|
||||
| 3.3 | It should reject expired impersonation JWTs | Integration | [ ] |
|
||||
| 3.4 | It should block non-admin users from accessing `/impersonate` | Integration | [ ] |
|
||||
| 3.5 | It should block unauthenticated users from accessing `/impersonate` | Integration | [ ] |
|
||||
| 3.6 | It should replace the admin's session with the impersonated user's session | Integration | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Session Management
|
||||
|
||||
### Authentication Gate Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 4.1 | It should allow authenticated requests to proceed to protected routes | Integration | [ ] |
|
||||
| 4.2 | It should redirect unauthenticated users to `/login` with a `redirect-to` parameter | Integration | [ ] |
|
||||
| 4.3 | It should return `hx-redirect: /login` for unauthenticated HTMX requests | Integration | [ ] |
|
||||
|
||||
### Admin Gate Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 5.1 | It should allow admin requests to proceed to admin-only routes | Integration | [ ] |
|
||||
| 5.2 | It should redirect non-admin users to `/login` when accessing admin routes | Integration | [ ] |
|
||||
|
||||
### Session Version Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 6.1 | It should invalidate sessions with outdated version numbers | Integration | [ ] |
|
||||
| 6.2 | It should redirect to `/login` when an outdated session accesses normal routes | Integration | [ ] |
|
||||
| 6.3 | It should return `hx-redirect: /login` for outdated sessions on HTMX routes | Integration | [ ] |
|
||||
| 6.4 | It should return 401 for outdated sessions on GraphQL routes | Integration | [ ] |
|
||||
| 6.5 | It should treat sessions without a version as outdated | Integration | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Cross-Cutting Behaviors
|
||||
|
||||
### JWT Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 7.1 | It should generate a JWT containing the user's role and client access on login | Unit | [ ] |
|
||||
| 7.2 | It should compress the client list for admin users to fit in the JWT | Unit | [ ] |
|
||||
| 7.3 | It should compress the client list for read-only users to fit in the JWT | Unit | [ ] |
|
||||
| 7.4 | It should include a plain client list for regular users in the JWT | Unit | [ ] |
|
||||
| 7.5 | It should create API tokens with admin role and 1000-day expiration | Unit | [ ] |
|
||||
|
||||
### Middleware Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 8.1 | It should convert 401 responses to HTMX redirects for unauthenticated users | Integration | [ ] |
|
||||
|
||||
### Role-Based Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 9.1 | It should allow admin users to access all clients | Integration | [ ] |
|
||||
| 9.2 | It should allow regular users to access only their assigned clients | Integration | [ ] |
|
||||
| 9.3 | It should allow read-only users to access all clients with view-only permissions | Integration | [ ] |
|
||||
| 9.4 | It should handle admin users with no clients by providing an empty compressed list | Unit | [ ] |
|
||||
| 9.5 | It should handle regular users with no clients by providing an empty client vector | Unit | [ ] |
|
||||
|
||||
### Security Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 10.1 | It should reject tampered JWTs during impersonation | Integration | [ ] |
|
||||
| 10.2 | It should treat sessions with nil identity as unauthenticated | Integration | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Test Data Requirements
|
||||
|
||||
| Entity | Requirements |
|
||||
|--------|-------------|
|
||||
| **Users** | Admin user with multiple clients; regular user with subset of clients; read-only user with multiple clients; new user (not in database) with Google provider details; existing user with Google provider details |
|
||||
| **Clients** | Multiple clients with `:client/code`, `:client/name`, `:client/locations`; client associations on users |
|
||||
| **OAuth Mock** | Mock Google token endpoint responses (success and failure); mock Google userinfo endpoint responses |
|
||||
|
||||
## Existing Tests to Preserve
|
||||
|
||||
- None identified
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Google OAuth 2.0 (authorization code exchange and userinfo retrieval)
|
||||
- Buddy JWT (token signing/unsigning with HS512)
|
||||
- Datomic (user lookup and creation)
|
||||
- Legacy SPA (login and needs-activation pages, no SSR tests)
|
||||
320
docs/testing/behaviors/company.md
Normal file
320
docs/testing/behaviors/company.md
Normal file
@@ -0,0 +1,320 @@
|
||||
# Company Behaviors
|
||||
|
||||
## Overview
|
||||
|
||||
The Company section provides company-level settings and reporting for Integreat users. It is implemented as SSR pages using HTMX and is accessed via the left navigation sidebar under "My Company". All pages require authentication and enforce client-level access controls. Most pages react to client selection changes by refreshing their content via HTMX.
|
||||
|
||||
**Testing Philosophy**
|
||||
- Prefer unit tests for pure business logic (calculations, validations, transformations)
|
||||
- Use integration tests for database interactions and cross-system flows
|
||||
- Use UI tests only for end-to-end happy paths that touch multiple pages
|
||||
- Every behavior must be user-visible; no tests for implementation details
|
||||
|
||||
---
|
||||
|
||||
## Testing Patterns
|
||||
|
||||
### Pattern: Grid Page Behaviors
|
||||
Most list pages in Integreat follow the same pattern:
|
||||
1. Fetch IDs via Datomic query with filters
|
||||
2. Hydrate results via `pull-many`
|
||||
3. Render table with sortable columns
|
||||
4. Support selection (individual / all / all-filtered)
|
||||
5. Action buttons appear conditionally based on permissions and selection state
|
||||
|
||||
**Test implications:** Validate the query generation, not the rendering. UI tests only need to verify one filter and one sort work end-to-end.
|
||||
|
||||
### Pattern: Permission Gates
|
||||
Every mutating operation checks:
|
||||
1. `assert-can-see-client` — user has access to the client
|
||||
2. `can?` — user has the specific permission for the activity
|
||||
3. Admin role checks for destructive operations
|
||||
|
||||
**Test implications:** Integration test each gate independently. UI tests only verify the happy path with a permitted user.
|
||||
|
||||
### Pattern: HTMX Refresh on Client Switch
|
||||
All company pages listen for `clientSelected from:body` event and refresh `#app-contents` with a 300ms swap animation.
|
||||
|
||||
**Test implications:** Integration test the event handling and route params. UI test only one page to verify the swap animation.
|
||||
|
||||
---
|
||||
|
||||
## Company Profile
|
||||
|
||||
### Display Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 1.1 | It should display a "Please select a company" placeholder card when no client is selected | UI | [ ] |
|
||||
| 1.2 | It should display the company name as a heading when a client is selected | UI | [ ] |
|
||||
| 1.3 | It should display the company address (street, city, state, zip) when address data exists | UI | [ ] |
|
||||
| 1.4 | It should omit missing address fields without showing error placeholders | UI | [ ] |
|
||||
| 1.5 | It should show a "Download vendor list" button | UI | [ ] |
|
||||
| 1.6 | It should download a CSV/Excel export when the download button is clicked | Integration | [ ] |
|
||||
|
||||
### Signature Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 2.1 | It should show the signature section only when the user has signature edit permission | Integration | [ ] |
|
||||
| 2.2 | It should display the saved signature image when one exists | UI | [ ] |
|
||||
| 2.3 | It should show a "New signature" button that enables drawing mode on a canvas | UI | [ ] |
|
||||
| 2.4 | It should show a "Clear" button that clears the canvas while in drawing mode | UI | [ ] |
|
||||
| 2.5 | It should show an "Accept" button that submits the drawn signature | UI | [ ] |
|
||||
| 2.6 | It should reject invalid signature image data with a validation error | Unit + Integration | [ ] |
|
||||
| 2.7 | It should provide a drag-and-drop zone for uploading JPEG signature files | UI | [ ] |
|
||||
| 2.8 | It should change the drop zone background color on hover | UI | [ ] |
|
||||
| 2.9 | It should refresh the signature section with the uploaded image on successful upload | Integration | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## 1099 Reports
|
||||
|
||||
### Display Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 3.1 | It should display vendors who received $600 or more in check payments during the current tax year | Integration | [ ] |
|
||||
| 3.2 | It should show grid columns: Client, Vendor Name, TIN, Expense Account, Address, Paid | UI | [ ] |
|
||||
| 3.3 | It should display the vendor's legal entity name as a subtitle under the vendor name | UI | [ ] |
|
||||
| 3.4 | It should show a 1099 type pill badge when a 1099 type is set | UI | [ ] |
|
||||
| 3.5 | It should display the TIN with a TIN type pill (EIN or SSN) | UI | [ ] |
|
||||
| 3.6 | It should show "No address" placeholder when the vendor has no address | UI | [ ] |
|
||||
| 3.7 | It should display the total paid amount as a pill badge rounded to the nearest dollar | UI | [ ] |
|
||||
| 3.8 | It should show an edit icon button on each row | UI | [ ] |
|
||||
| 3.9 | It should show vendors shared across multiple clients in each client's context | Integration | [ ] |
|
||||
| 3.10 | It should show an empty grid when no vendors received $600+ in checks during the tax year | UI | [ ] |
|
||||
|
||||
### Filtering & Sorting Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 4.1 | It should support standard grid query params (sort, pagination, search) | Integration | [ ] |
|
||||
| 4.2 | It should default sort by client code then amount | Integration | [ ] |
|
||||
|
||||
### Edit Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 5.1 | It should open a vendor edit dialog in a modal when the edit icon is clicked | UI | [ ] |
|
||||
| 5.2 | It should display address fields (Street 1, Street 2, City, State, ZIP) in the dialog | UI | [ ] |
|
||||
| 5.3 | It should validate the ZIP code as 5 digits or empty | Unit + Integration | [ ] |
|
||||
| 5.4 | It should allow entering either a legal entity name or first/middle/last name | UI | [ ] |
|
||||
| 5.5 | It should allow entering a TIN and selecting TIN type (EIN or SSN) | UI | [ ] |
|
||||
| 5.6 | It should allow selecting a 1099 type from a dropdown | UI | [ ] |
|
||||
| 5.7 | It should close the modal and refresh the row with a flash highlight on successful save | Integration | [ ] |
|
||||
| 5.8 | It should null the address if all address fields are empty and no existing address | Integration | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Expense Reports
|
||||
|
||||
### Chart Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 6.1 | It should display a bar chart of expenses grouped by top 20 expense accounts over the last 8 weeks | UI | [ ] |
|
||||
| 6.2 | It should show week ranges (Monday-Sunday) formatted as dates on the X-axis | UI | [ ] |
|
||||
| 6.3 | It should provide a vendor typeahead to filter expenses to a specific vendor | Integration | [ ] |
|
||||
| 6.4 | It should provide an expense account typeahead to filter to a specific account | Integration | [ ] |
|
||||
| 6.5 | It should refresh the chart when filters change | Integration | [ ] |
|
||||
| 6.6 | It should default to last 65 days of data but display last 8 weeks | Integration | [ ] |
|
||||
|
||||
### Invoice Totals Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 7.1 | It should display a grid of total invoice amounts per vendor per company | UI | [ ] |
|
||||
| 7.2 | It should provide start and end date range filters | UI | [ ] |
|
||||
| 7.3 | It should default the date range to the last 30 days | Integration | [ ] |
|
||||
| 7.4 | It should show the vendor name in a sticky left column | UI | [ ] |
|
||||
| 7.5 | It should show "-" for zero amounts | UI | [ ] |
|
||||
| 7.6 | It should push filter changes to browser history | Integration | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Reconciliation Reports
|
||||
|
||||
### Access Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 8.1 | It should show the reconciliation navigation link only when the user has reconciliation report permission | Integration | [ ] |
|
||||
| 8.2 | It should require start and end dates to be submitted via a "Run" button | UI | [ ] |
|
||||
| 8.3 | It should show a "Please choose a time range to run the report" message when no dates are selected | UI | [ ] |
|
||||
|
||||
### Display Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 8.4 | It should display a grid with columns: Bank Account, Source Count, Synced Count, Approved, Unapproved, Requires Feedback, Missing | UI | [ ] |
|
||||
| 8.5 | It should highlight rows with green background when external count equals synced count | UI | [ ] |
|
||||
| 8.6 | It should highlight rows with red background when counts mismatch | UI | [ ] |
|
||||
| 8.7 | It should display a missing transactions count with a tooltip button | UI | [ ] |
|
||||
| 8.8 | It should show a popup table of missing transaction dates and amounts when the tooltip is clicked | UI | [ ] |
|
||||
| 8.9 | It should hide the missing transactions tooltip when the count is zero | UI | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Plaid Bank Linking
|
||||
|
||||
### Account Grid Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 9.1 | It should display a grid of Plaid-linked accounts with columns: Plaid Item, Integreat Status, Plaid Bank Status, Accounts | UI | [ ] |
|
||||
| 9.2 | It should show a red pill with error message tooltip when any linked bank account has failed or unauthorized status | UI | [ ] |
|
||||
| 9.3 | It should show a green "Success" pill when all accounts are healthy | UI | [ ] |
|
||||
| 9.4 | It should display linked accounts with name, masked number, last synced date, and identicon | UI | [ ] |
|
||||
| 9.5 | It should support sorting by external ID and Plaid bank status | Integration | [ ] |
|
||||
| 9.6 | It should show an empty grid when no bank accounts are linked | UI | [ ] |
|
||||
|
||||
### Link Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 10.1 | It should show a "Link account" button when a client is selected | UI | [ ] |
|
||||
| 10.2 | It should hide the link button when no client is selected | UI | [ ] |
|
||||
| 10.3 | It should open a Plaid Link modal when the link button is clicked | UI | [ ] |
|
||||
| 10.4 | It should create the Plaid item and accounts in the system after successful linking | Integration | [ ] |
|
||||
| 10.5 | It should redirect back to the Plaid page after successful account linking | Integration | [ ] |
|
||||
|
||||
### Re-authenticate Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 11.1 | It should show a "Reauthenticate" button on each row | UI | [ ] |
|
||||
| 11.2 | It should open Plaid Link in update mode when reauthenticate is clicked | UI | [ ] |
|
||||
| 11.3 | It should refresh the row after successful reauthentication | Integration | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Yodlee Bank Linking
|
||||
|
||||
### Account Grid Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 12.1 | It should display a grid of Yodlee provider accounts with columns: Client, Provider Account, Status, Detailed Status, Last Updated, Accounts | UI | [ ] |
|
||||
| 12.2 | It should hide the Client column when the user has only one client | UI | [ ] |
|
||||
| 12.3 | It should show a green pill for success status and a yellow pill for other statuses | UI | [ ] |
|
||||
| 12.4 | It should display linked accounts with name and number | UI | [ ] |
|
||||
| 12.5 | It should support sorting by status, client, provider account, and last updated | Integration | [ ] |
|
||||
| 12.6 | It should show an empty grid when no bank accounts are linked | UI | [ ] |
|
||||
|
||||
### Link Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 13.1 | It should show a "Link new account" button | UI | [ ] |
|
||||
| 13.2 | It should disable the link button and show helper text when no client is selected | UI | [ ] |
|
||||
| 13.3 | It should open a Yodlee Fastlink modal when the link button is clicked | UI | [ ] |
|
||||
| 13.4 | It should display an error notification and close the modal after 3 seconds when Yodlee returns an error | Integration | [ ] |
|
||||
|
||||
### Re-authenticate Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 14.1 | It should show a "Reauthenticate" button per row | UI | [ ] |
|
||||
| 14.2 | It should open Fastlink in edit mode when reauthenticate is clicked | UI | [ ] |
|
||||
| 14.3 | It should refresh the row after successful reauthentication | Integration | [ ] |
|
||||
|
||||
### Admin Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 15.1 | It should show a refresh button on each row for admin users | Integration | [ ] |
|
||||
| 15.2 | It should trigger a Yodlee account refresh when the refresh button is clicked | Integration | [ ] |
|
||||
| 15.3 | It should refresh the row after successful Yodlee refresh | Integration | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Generated Reports List
|
||||
|
||||
### Display Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 16.1 | It should display a grid of previously generated reports with columns: Name, Created by, Created | UI | [ ] |
|
||||
| 16.2 | It should show the creator name as a pill badge | UI | [ ] |
|
||||
| 16.3 | It should show an empty grid when no reports have been generated | UI | [ ] |
|
||||
|
||||
### Row Action Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 17.1 | It should provide a download link to the report file on each row | UI | [ ] |
|
||||
| 17.2 | It should show a delete button on each row for admin users | Integration | [ ] |
|
||||
| 17.3 | It should delete the report and its file when the delete button is clicked | Integration | [ ] |
|
||||
|
||||
### Filtering & Sorting Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 18.1 | It should support filtering by date range and client | Integration | [ ] |
|
||||
| 18.2 | It should support sorting by client, created date, creator, and name | Integration | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Cross-Cutting Behaviors
|
||||
|
||||
### Client Switching Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 19.1 | It should refresh page content with a 300ms swap animation when the user switches clients | Integration | [ ] |
|
||||
| 19.2 | It should show appropriate placeholder states when no client is selected on pages that require one | UI | [ ] |
|
||||
| 19.3 | It should operate 1099 and reports grids across all visible clients when no single client is selected | Integration | [ ] |
|
||||
|
||||
### Permission Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 20.1 | It should block access to company pages for unauthenticated users | Integration | [ ] |
|
||||
| 20.2 | It should block access to company pages for users without client access | Integration | [ ] |
|
||||
| 20.3 | It should hide the signature section from users without signature edit permission | Integration | [ ] |
|
||||
| 20.4 | It should hide the reconciliation report navigation link from users without reconciliation report permission | Integration | [ ] |
|
||||
| 20.5 | It should hide the delete report button from non-admin users | Integration | [ ] |
|
||||
| 20.6 | It should hide the Yodlee refresh button from non-admin users | Integration | [ ] |
|
||||
|
||||
### Bank Account Search Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 21.1 | It should provide a bank account typeahead for searching accounts belonging to a specific client | Integration | [ ] |
|
||||
| 21.2 | It should show "Please select a client" message when no client is selected in the bank account typeahead | UI | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Test Data Requirements
|
||||
|
||||
| Entity | Requirements |
|
||||
|--------|-------------|
|
||||
| **Clients** | Multiple clients with different codes, names, and addresses; one with complete address, one with no address; one with existing signature file, one without |
|
||||
| **Vendors** | With 1099 data (legal entity name, TIN, TIN type, 1099 type, address); with and without addresses; who received $600+ in check payments in 2025; who received less than $600; shared across multiple clients |
|
||||
| **Payments** | Check payments dated within 2025 for 1099 testing; payments of different types (check, ACH) — only checks count toward 1099; payments to vendors across multiple clients |
|
||||
| **Invoices** | Non-voided invoices with expense account allocations for expense report testing; with different vendors and expense accounts; dated across multiple weeks for 8-week breakdown |
|
||||
| **Bank Accounts** | Plaid-linked accounts with various statuses (success, error, unauthorized); Yodlee-linked accounts with various statuses |
|
||||
| **Reports** | At least one generated report with name, creator, created date, and file URL; reports belonging to different clients |
|
||||
| **Users** | Admin user (full access); user with signature edit permission; user with reconciliation report permission; user with single client access; user with multiple client access; read-only user (should not see company nav) |
|
||||
|
||||
## Existing Tests to Preserve
|
||||
|
||||
- No existing company-specific behavior tests were identified at the time of writing.
|
||||
- Any future company behavior tests should be added under `test/clj/auto_ap/company/` or similar.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Datomic (primary store for all entities and queries)
|
||||
- Amazonica S3 (signature image storage and report file storage)
|
||||
- Plaid API (bank account linking)
|
||||
- Yodlee API (bank account linking)
|
||||
- Intuit/QuickBooks API (reconciliation report data)
|
||||
- Solr (client name search)
|
||||
- HTMX (server-rendered interactions)
|
||||
- Alpine.js (signature canvas state, drag-and-drop, modal state, chart initialization)
|
||||
- Chart.js (expense breakdown bar chart)
|
||||
- SignaturePad library (canvas signature drawing)
|
||||
- Plaid Link SDK (Plaid account linking)
|
||||
- Yodlee Fastlink SDK (Yodlee account linking)
|
||||
- Jdenticon (account identicons in Plaid grid)
|
||||
250
docs/testing/behaviors/dashboard.md
Normal file
250
docs/testing/behaviors/dashboard.md
Normal file
@@ -0,0 +1,250 @@
|
||||
# Dashboard Behaviors
|
||||
|
||||
## Overview
|
||||
|
||||
The Dashboard is an admin-only, SSR-based overview page that displays financial summary cards across selected clients. It provides at-a-glance visibility into bank account balances, outstanding tasks, recent sales trends, expense breakdowns, and profit/loss summaries. Cards are loaded lazily via HTMX for progressive rendering.
|
||||
|
||||
**Testing Philosophy**
|
||||
- Prefer unit tests for pure business logic (calculations, formatting, client trimming)
|
||||
- Use integration tests for database interactions and cross-system flows (Datomic queries, GraphQL calls)
|
||||
- Use UI tests only for end-to-end happy paths and visual states
|
||||
- Every behavior must be user-visible; no tests for implementation details
|
||||
|
||||
---
|
||||
|
||||
## Testing Patterns
|
||||
|
||||
### Pattern: Progressive Card Loading
|
||||
Dashboard cards follow a progressive loading pattern:
|
||||
1. SSR renders stub cards with loading spinners
|
||||
2. Each stub triggers an independent HTMX request on load
|
||||
3. Card endpoints return HTML fragments that replace the stub content
|
||||
4. Cards load independently without blocking each other
|
||||
|
||||
**Test implications:** Unit test the card query logic. Integration test each card endpoint returns valid HTML. UI test only needs to verify the page loads and cards appear.
|
||||
|
||||
### Pattern: Client Context Propagation
|
||||
All dashboard operations depend on selected clients:
|
||||
1. Client selection triggers a page-wide refresh via HTMX
|
||||
2. `wrap-trim-clients` middleware limits to 20 clients before queries execute
|
||||
3. All card endpoints receive the same trimmed client set
|
||||
|
||||
**Test implications:** Integration test that changing clients updates all cards. Unit test the trimming logic.
|
||||
|
||||
### Pattern: Admin Permission Gating
|
||||
The dashboard is restricted to admin users:
|
||||
1. `wrap-admin` middleware checks user role before any data access
|
||||
2. Non-admin users are redirected before reaching handlers
|
||||
3. Middleware is applied consistently to all card endpoints
|
||||
|
||||
**Test implications:** Integration test each endpoint independently for permission enforcement.
|
||||
|
||||
---
|
||||
|
||||
## Dashboard Page
|
||||
|
||||
### Page Loading Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 1.1 | It should render the main dashboard page with navigation, client selector, and "Dashboard" breadcrumb for admin users | UI | [ ] |
|
||||
| 1.2 | It should display six stub cards with loading spinners for progressive rendering | UI | [ ] |
|
||||
| 1.3 | It should trigger independent HTMX requests to load each card's content on page load | Integration | [ ] |
|
||||
| 1.4 | It should progressively replace stub cards with actual data as responses arrive | UI | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Bank Accounts Card
|
||||
|
||||
### Display Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 2.1 | It should display each client's name, account name, ledger balance, and last sync time | UI | [ ] |
|
||||
| 2.2 | It should exclude bank accounts with cash type from the display | Integration | [ ] |
|
||||
| 2.3 | It should format ledger balances as currency ($X,XXX.XX) | Unit + UI | [ ] |
|
||||
| 2.4 | It should display the last sync timestamp in standard time format when present | Unit + UI | [ ] |
|
||||
| 2.5 | It should display Intuit balance and sync time for Intuit-linked accounts | UI | [ ] |
|
||||
| 2.6 | It should display Yodlee available balance, sync time, and pending balance for Yodlee-linked accounts | UI | [ ] |
|
||||
| 2.7 | It should display Plaid balance and sync time for Plaid-linked accounts | UI | [ ] |
|
||||
| 2.8 | It should display $0.00 for missing or null balances | Unit + UI | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Sales Card
|
||||
|
||||
### Display Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 3.1 | It should display a bar chart of gross sales for the last 14 days | UI | [ ] |
|
||||
| 3.2 | It should render an empty bar chart when no sales orders exist in the date range | UI | [ ] |
|
||||
|
||||
### Data Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 3.3 | It should query and sum sales order totals by date for the selected clients | Integration | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Expense Card
|
||||
|
||||
### Display Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 4.1 | It should display a pie chart of the top 5 expense accounts for the last month | UI | [ ] |
|
||||
| 4.2 | It should render an empty pie chart when no invoices with expense accounts exist in the date range | UI | [ ] |
|
||||
|
||||
### Data Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 4.3 | It should sum expense amounts by account name for the selected clients | Integration | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Profit & Loss Card
|
||||
|
||||
### Display Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 5.1 | It should display income and expenses aggregated by category (sales, COGS, payroll, controllable, fixed overhead, ownership controllable) | UI | [ ] |
|
||||
| 5.2 | It should show $0.00 for both income and expenses when no data exists for the period | UI | [ ] |
|
||||
|
||||
### Data Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 5.3 | It should query P&L data via GraphQL for the selected clients and last month | Integration | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Tasks Card
|
||||
|
||||
### Display Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 6.1 | It should display the count of unpaid invoices when the count is non-zero | UI | [ ] |
|
||||
| 6.2 | It should display the count of uncategorized transactions requiring feedback when the count is non-zero | UI | [ ] |
|
||||
| 6.3 | It should provide a "Pay now" link for unpaid invoices linking to the unpaid invoices page with year date range | UI | [ ] |
|
||||
| 6.4 | It should provide a "Review now" link for uncategorized transactions linking to the requires-feedback page | UI | [ ] |
|
||||
| 6.5 | It should hide task sections entirely when their respective counts are zero | Integration | [ ] |
|
||||
|
||||
### Data Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 6.6 | It should query Datomic for invoices with unpaid status for the selected clients | Integration | [ ] |
|
||||
| 6.7 | It should query Datomic for transactions with requires-feedback approval status for the selected clients | Integration | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Expense Breakdown Card
|
||||
|
||||
### Display Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 7.1 | It should display a bar chart breaking down expenses by account | UI | [ ] |
|
||||
| 7.2 | It should render an empty chart when no expense data exists | UI | [ ] |
|
||||
| 7.3 | It should provide Vendor and Account typeahead filters | UI | [ ] |
|
||||
|
||||
### Data Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 7.4 | It should reload the chart with filtered data when filter selections change | Integration | [ ] |
|
||||
| 7.5 | It should update the URL with filter query parameters via hx-push-url | Integration | [ ] |
|
||||
| 7.6 | It should exclude voided invoices from the breakdown | Integration | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Filtering Behaviors
|
||||
|
||||
### Expense Breakdown Filters
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 8.1 | It should filter the expense breakdown chart by vendor selection | Integration | [ ] |
|
||||
| 8.2 | It should filter the expense breakdown chart by expense account selection | Integration | [ ] |
|
||||
| 8.3 | It should trigger an HTMX request to reload the chart when any filter changes | Integration | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Client Selection Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 9.1 | It should update the dashboard content when the user selects different clients from the dropdown | UI | [ ] |
|
||||
| 9.2 | It should trigger a clientSelected event on the body when client selection changes | Integration | [ ] |
|
||||
| 9.3 | It should swap the dashboard content area with fresh content for the newly selected clients | Integration | [ ] |
|
||||
| 9.4 | It should re-fetch all card data with the new client context | Integration | [ ] |
|
||||
| 9.5 | It should limit reports to the first 20 selected clients from the valid set | Unit + Integration | [ ] |
|
||||
| 9.6 | It should display a yellow warning banner when more than 20 clients are selected | UI | [ ] |
|
||||
| 9.7 | It should persist the warning banner across client selection changes until fewer than 21 clients are selected | UI | [ ] |
|
||||
| 9.8 | It should trim the client set before executing any card data queries | Integration | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Error Handling Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 10.1 | It should load each card independently via separate HTMX requests | Integration | [ ] |
|
||||
| 10.2 | It should not prevent other cards from loading when one card endpoint fails | Integration | [ ] |
|
||||
| 10.3 | It should display a loading spinner on stub cards until data loads or a timeout occurs | UI | [ ] |
|
||||
| 10.4 | It should return appropriate HTTP status codes for card endpoint errors without breaking the page layout | Integration | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Cross-Cutting Behaviors
|
||||
|
||||
### Permission Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 11.1 | It should allow only admin users to access the dashboard page and card endpoints | Integration | [ ] |
|
||||
| 11.2 | It should redirect non-admin authenticated users to /login with a 302 status | Integration | [ ] |
|
||||
| 11.3 | It should redirect unauthenticated users to /login with a redirect-to parameter | Integration | [ ] |
|
||||
| 11.4 | It should verify admin role via middleware before executing any data queries | Integration | [ ] |
|
||||
|
||||
### Empty State Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 12.1 | It should render the dashboard page when no clients are selected, with all cards showing empty states | UI | [ ] |
|
||||
| 12.2 | It should display an empty bank accounts list when no clients are selected | UI | [ ] |
|
||||
| 12.3 | It should display an empty sales chart when no clients are selected | UI | [ ] |
|
||||
| 12.4 | It should display an empty expense pie chart when no clients are selected | UI | [ ] |
|
||||
| 12.5 | It should show $0.00 income and expenses in the P&L card when no clients are selected | UI | [ ] |
|
||||
| 12.6 | It should hide all task sections when no clients are selected | UI | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Test Data Requirements
|
||||
|
||||
| Entity | Requirements |
|
||||
|--------|-------------|
|
||||
| **Users** | Admin user with access to multiple clients; non-admin user for permission denial |
|
||||
| **Clients** | Minimum 2, ideally 25+; mix with/without bank accounts |
|
||||
| **Bank Accounts** | Various types (checking, savings, cash); some linked to Intuit, Yodlee, Plaid; with/without balances and sync timestamps |
|
||||
| **Sales Orders** | Orders within and outside the 14-day window with totals |
|
||||
| **Invoices** | With expense accounts and unpaid status; voided invoices to test exclusion |
|
||||
| **Transactions** | With requires-feedback approval status |
|
||||
| **Chart of Accounts** | Categories: sales, COGS, payroll, controllable, fixed overhead, ownership controllable |
|
||||
|
||||
## Existing Tests to Preserve
|
||||
|
||||
- No existing dashboard-specific tests have been identified in the current test suite. Any tests covering dashboard routes or card handlers should be preserved during refactoring.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Datomic (primary data store for all card queries)
|
||||
- GraphQL/Lacinia (P&L data via get-profit-and-loss-raw)
|
||||
- HTMX (progressive card loading via hx-get/hx-trigger/hx-swap)
|
||||
- Chart.js (canvas-based charts)
|
||||
- Alpine.js (chart data binding)
|
||||
403
docs/testing/behaviors/invoice.md
Normal file
403
docs/testing/behaviors/invoice.md
Normal file
@@ -0,0 +1,403 @@
|
||||
# Invoice Behaviors
|
||||
|
||||
## Overview
|
||||
|
||||
Invoices are the core entity of Integreat. This document catalogs every observable behavior with its recommended test strategy and implementation status.
|
||||
|
||||
**Testing Philosophy**
|
||||
- Prefer unit tests for pure business logic (calculations, validations, transformations)
|
||||
- Use integration tests for database interactions and cross-system flows
|
||||
- Use UI tests only for end-to-end happy paths that touch multiple pages
|
||||
- Every behavior must be user-visible; no tests for implementation details
|
||||
|
||||
---
|
||||
|
||||
## Testing Patterns
|
||||
|
||||
### Pattern: Grid Page Behaviors
|
||||
Most list pages in Integreat follow the same pattern:
|
||||
1. Fetch IDs via Datomic query with filters
|
||||
2. Hydrate results via `pull-many`
|
||||
3. Render table with sortable columns
|
||||
4. Support selection (individual / all / all-filtered)
|
||||
5. Action buttons appear conditionally based on permissions and selection state
|
||||
|
||||
**Test implications:** Validate the query generation, not the rendering. UI tests only need to verify one filter and one sort work end-to-end.
|
||||
|
||||
### Pattern: Wizard Behaviors
|
||||
Wizards are multi-step forms with HTMX-driven navigation:
|
||||
1. Each step is a GET that renders a form fragment
|
||||
2. Form submissions are POST/PUT with validation
|
||||
3. Navigation between steps updates the wizard state
|
||||
4. Final submit creates/updates the entity
|
||||
|
||||
**Test implications:** Unit test validation logic and state transitions. Integration test the full wizard flow once. UI test only the happy path.
|
||||
|
||||
### Pattern: Permission Gates
|
||||
Every mutating operation checks:
|
||||
1. `assert-can-see-client` — user has access to the client
|
||||
2. `assert-not-locked` — invoice date >= client locked-until
|
||||
3. `can?` — user has the specific permission for the activity
|
||||
|
||||
**Test implications:** Integration test each gate independently. UI tests only verify the happy path with a permitted user.
|
||||
|
||||
---
|
||||
|
||||
## Invoice List Page
|
||||
|
||||
### Display Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 1.1 | It should display a table with columns: Client, Vendor, Invoice #, Date, Due, Status, Account, Outstanding, Links | UI | [ ] |
|
||||
| 1.2 | It should show the Client column only when multiple clients OR multiple locations are selected | Integration | [ ] |
|
||||
| 1.3 | It should show "Paid" status as a primary-colored pill | UI | [ ] |
|
||||
| 1.4 | It should show "Voided" status as a red pill | UI | [ ] |
|
||||
| 1.5 | It should show "Scheduled" status as a yellow pill when a scheduled payment exists | UI | [ ] |
|
||||
| 1.6 | It should show "Unpaid" status as a secondary-colored pill | UI | [ ] |
|
||||
| 1.7 | It should display due dates relative to today: "today", "in X days", or "X days ago" with appropriate color coding | Unit + UI | [ ] |
|
||||
| 1.8 | It should show a partial payment indicator "of $X.XX" when outstanding balance differs from total | UI | [ ] |
|
||||
| 1.9 | It should display a links dropdown showing payments, transactions, ledger entries, and source files for each invoice | UI | [ ] |
|
||||
| 1.10 | It should group table rows by vendor name when sorted by vendor | Integration | [ ] |
|
||||
|
||||
### Filtering Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 2.1 | It should filter invoices by vendor typeahead selection | Integration | [ ] |
|
||||
| 2.2 | It should filter invoices by expense account typeahead selection | Integration | [ ] |
|
||||
| 2.3 | It should filter invoices by date range (invoice date) | Integration | [ ] |
|
||||
| 2.4 | It should filter invoices by due date range | Integration | [ ] |
|
||||
| 2.5 | It should filter invoices by amount range (min/max total) | Integration | [ ] |
|
||||
| 2.6 | It should filter invoices by invoice number partial match | Integration | [ ] |
|
||||
| 2.7 | It should filter invoices by check number | Integration | [ ] |
|
||||
| 2.8 | It should filter invoices by status via route (all/unpaid/paid/voided) | Integration | [ ] |
|
||||
| 2.9 | It should filter invoices by import status (pending/imported) | Integration | [ ] |
|
||||
| 2.10 | It should support exact-match navigation to a specific invoice by ID, bypassing other filters | Integration | [ ] |
|
||||
| 2.11 | It should filter to invoices with scheduled payments | Integration | [ ] |
|
||||
| 2.12 | It should filter to unresolved invoices (missing or unassigned expense accounts) | Integration | [ ] |
|
||||
| 2.13 | It should filter by expense account location | Integration | [ ] |
|
||||
| 2.14 | Given multiple filters are applied, when the user changes one filter, then the table should refresh with the combined filter set | Integration | [ ] |
|
||||
|
||||
### Sorting Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 3.1 | It should sort by client name ascending/descending | Integration | [ ] |
|
||||
| 3.2 | It should sort by vendor name ascending/descending | Integration | [ ] |
|
||||
| 3.3 | It should sort by description original ascending/descending | Integration | [ ] |
|
||||
| 3.4 | It should sort by expense account location ascending/descending | Integration | [ ] |
|
||||
| 3.5 | It should sort by invoice date ascending/descending | Integration | [ ] |
|
||||
| 3.6 | It should sort by due date ascending/descending, with nulls last | Integration | [ ] |
|
||||
| 3.7 | It should sort by invoice number ascending/descending | Integration | [ ] |
|
||||
| 3.8 | It should sort by total amount ascending/descending | Integration | [ ] |
|
||||
| 3.9 | It should sort by outstanding balance ascending/descending | Integration | [ ] |
|
||||
| 3.10 | Given the user clicks a column header twice, then the sort direction should toggle | Integration | [ ] |
|
||||
|
||||
### Pagination Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 4.1 | It should display 25 invoices per page by default | Integration | [ ] |
|
||||
| 4.2 | It should allow changing the per-page count | Integration | [ ] |
|
||||
| 4.3 | It should calculate the total outstanding balance and total amount across ALL matching invoices, not just the current page | Unit | [ ] |
|
||||
|
||||
### Selection Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 5.1 | It should allow selecting individual invoices via checkboxes | UI | [ ] |
|
||||
| 5.2 | It should allow selecting all visible invoices via a header checkbox | UI | [ ] |
|
||||
| 5.3 | It should allow selecting all filtered invoices (up to 250) for bulk operations | Integration | [ ] |
|
||||
| 5.4 | Given invoices are selected, when the user applies a filter, then the selection should be cleared | Integration | [ ] |
|
||||
|
||||
### Row Action Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 6.1 | It should show a void button for unpaid invoices when the user has delete permission | UI | [ ] |
|
||||
| 6.2 | It should show an edit button for unpaid and paid invoices when the user has edit permission | UI | [ ] |
|
||||
| 6.3 | It should show an unvoid button for voided invoices when the user has edit permission | UI | [ ] |
|
||||
| 6.4 | It should show an undo-autopay button for paid invoices with scheduled payments and no linked payments, when the user has edit permission | UI | [ ] |
|
||||
| 6.5 | Given a paid invoice with linked non-voided payments, when the user attempts to void it, then it should be blocked with a message to void payments first | Integration | [ ] |
|
||||
|
||||
### Pay Button Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 7.1 | It should display the pay button disabled when no invoices are selected | UI | [ ] |
|
||||
| 7.2 | It should display the pay button disabled when invoices from multiple clients are selected | UI | [ ] |
|
||||
| 7.3 | It should display the pay button disabled when selected invoices have mixed positive and negative vendor totals | UI | [ ] |
|
||||
| 7.4 | It should display "Pay N invoices ($X.XX)" when valid invoices are selected | UI | [ ] |
|
||||
| 7.5 | It should display "Pay invoices using credit" when selected invoices for a single vendor have a net zero balance | UI | [ ] |
|
||||
| 7.6 | It should show a tooltip explaining why the pay button is disabled | UI | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## New Invoice Wizard
|
||||
|
||||
### Basic Details Step
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 8.1 | It should require client, vendor, date, invoice number, and total | Integration | [ ] |
|
||||
| 8.2 | It should auto-calculate the due date from vendor terms when client, date, and vendor are selected | Unit | [ ] |
|
||||
| 8.3 | It should auto-calculate the scheduled payment date from vendor autopay settings | Unit | [ ] |
|
||||
| 8.4 | It should suggest the vendor's default expense account | Unit | [ ] |
|
||||
| 8.5 | It should prevent duplicate invoice numbers for the same vendor and client | Unit + Integration | [ ] |
|
||||
| 8.6 | It should allow editing all fields when creating a new invoice | UI | [ ] |
|
||||
|
||||
### Expense Accounts Step
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 9.1 | It should allow adding multiple expense account rows | UI | [ ] |
|
||||
| 9.2 | It should allow selecting an account, location, and amount per row | UI | [ ] |
|
||||
| 9.3 | It should auto-populate the location from the account's configured location, or default to "Shared" | Unit | [ ] |
|
||||
| 9.4 | It should validate that expense account amounts sum to the invoice total | Unit + Integration | [ ] |
|
||||
| 9.5 | Given a "Shared" location, when the invoice is saved, then the amount should be spread equally across all client locations | Unit | [ ] |
|
||||
| 9.6 | Given a "Shared" location with an odd total, when spread across N locations, then the remainder should be distributed 1 cent at a time to the first locations | Unit | [x] |
|
||||
| 9.7 | Given a negative total, when spread across locations, then negative amounts should be distributed correctly | Unit | [x] |
|
||||
| 9.8 | It should allow removing individual account rows | UI | [ ] |
|
||||
| 9.9 | It should update the total and balance dynamically when amounts change | UI | [ ] |
|
||||
|
||||
### Next Steps
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 10.1 | Given a new invoice is saved successfully, then the wizard should show "Next Steps" with Pay now, Add another, and Close options | UI | [ ] |
|
||||
| 10.2 | Given the user clicks "Pay now", then the pay wizard should open for the newly created invoice | UI | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Edit Invoice
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 11.1 | It should allow editing unpaid and paid invoices | Integration | [ ] |
|
||||
| 11.2 | It should disable the vendor field when editing | UI | [ ] |
|
||||
| 11.3 | It should allow modifying expense account amounts, adding/removing accounts | Integration | [ ] |
|
||||
| 11.4 | It should validate that modified amounts still sum to the total | Unit + Integration | [ ] |
|
||||
| 11.5 | Given the user saves changes, then the invoice row should update in place without a full page reload | UI | [ ] |
|
||||
| 11.6 | It should block editing invoices with dates before the client's locked-until date | Integration | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Pay Wizard
|
||||
|
||||
### Payment Method Selection
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 12.1 | It should show bank account cards with type-appropriate icons (cash=green, check=blue, credit=purple) | UI | [ ] |
|
||||
| 12.2 | It should allow selecting "Print check" for check-type bank accounts | UI | [ ] |
|
||||
| 12.3 | It should allow selecting "With cash" for cash-type bank accounts | UI | [ ] |
|
||||
| 12.4 | It should allow selecting "Debit" for any bank account | UI | [ ] |
|
||||
| 12.5 | It should allow selecting "Handwrite check" when a single vendor is selected with positive balance | UI | [ ] |
|
||||
|
||||
### Payment Details
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 13.1 | It should display a grid of selected invoices with vendor, number, total, and pay amount | UI | [ ] |
|
||||
| 13.2 | It should default to "Pay in full" mode, paying the outstanding balance of each invoice | Integration | [ ] |
|
||||
| 13.3 | It should allow switching to "Customize payments" mode to set individual pay amounts | UI | [ ] |
|
||||
| 13.4 | It should validate that custom payment amounts do not exceed the outstanding balance | Unit + Integration | [ ] |
|
||||
| 13.5 | It should require a check number for handwritten checks | Integration | [ ] |
|
||||
| 13.6 | It should block payment if the invoice date is before the client's locked-until date | Integration | [ ] |
|
||||
| 13.7 | Given the user submits a check payment, when successful, then a PDF download link should be provided | Integration | [ ] |
|
||||
|
||||
### Credit Payment
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 14.1 | Given selected invoices for a single vendor with a net zero balance, when the user clicks pay, then a credit payment should be created offsetting credit invoices against payment invoices | Integration | [ ] |
|
||||
| 14.2 | It should block credit payment when multiple vendors are selected | Integration | [ ] |
|
||||
| 14.3 | It should block credit payment when the net balance is positive | Integration | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Bulk Edit
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 15.1 | It should allow selecting multiple invoices and opening the bulk edit wizard | UI | [ ] |
|
||||
| 15.2 | It should allow adding expense account rows with account, location, and percentage | UI | [ ] |
|
||||
| 15.3 | It should validate that percentages sum to 100% | Unit + Integration | [ ] |
|
||||
| 15.4 | Given valid percentages, when submitted, then all selected invoices should be coded with the new expense accounts | Integration | [ ] |
|
||||
| 15.5 | It should exclude invoices with dates before the client's locked-until date | Integration | [ ] |
|
||||
| 15.6 | It should spread "Shared" locations across all client locations, rounding cents correctly | Unit | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Bulk Void
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 16.1 | It should show a confirmation modal with the count of invoices to void | UI | [ ] |
|
||||
| 16.2 | It should require admin permission for bulk void operations | Integration | [ ] |
|
||||
| 16.3 | Given confirmed, when voiding, then linked cash payments should be voided automatically | Integration | [ ] |
|
||||
| 16.4 | Given confirmed, when voiding, then each invoice's total, outstanding balance, and expense account amounts should be set to 0 | Integration | [ ] |
|
||||
| 16.5 | It should exclude invoices with linked non-cash payments | Integration | [ ] |
|
||||
| 16.6 | It should exclude invoices with dates before the client's locked-until date | Integration | [ ] |
|
||||
| 16.7 | Given successful voiding, then the table should refresh with a success notification | UI | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Single Void
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 17.1 | Given an unpaid invoice with no linked payments, when the user voids it, then the invoice status should change to voided with zero amounts | Integration | [ ] |
|
||||
| 17.2 | Given a paid invoice with linked payments, when the user attempts to void it, then it should be blocked with an error message | Integration | [ ] |
|
||||
| 17.3 | It should block voiding invoices with dates before the client's locked-until date | Integration | [ ] |
|
||||
| 17.4 | Given successful voiding, then the row should update in place with a "live-removed" animation | UI | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Unvoid
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 18.1 | Given a voided invoice, when the user unvoids it, then it should restore the original status, total, outstanding balance, and expense accounts from Datomic history | Integration | [ ] |
|
||||
| 18.2 | It should require edit permission and client access | Integration | [ ] |
|
||||
| 18.3 | It should block unvoiding invoices with dates before the client's locked-until date | Integration | [ ] |
|
||||
| 18.4 | Given successful unvoiding, then the row should update in place with a flash animation | UI | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Undo Autopay
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 19.1 | Given a paid invoice with a scheduled payment and no linked payments, when the user undoes autopay, then the status should reset to unpaid and outstanding should equal total | Integration | [ ] |
|
||||
| 19.2 | It should block undoing autopay for invoices without scheduled payments | Unit + Integration | [ ] |
|
||||
| 19.3 | It should block undoing autopay for invoices with linked payments | Unit + Integration | [ ] |
|
||||
| 19.4 | It should block undoing autopay for invoices that are not paid | Unit + Integration | [ ] |
|
||||
| 19.5 | It should block undoing autopay for invoices with dates before the client's locked-until date | Integration | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Import Page
|
||||
|
||||
### Upload Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 20.1 | It should allow uploading CSV and PDF files via drag-and-drop | UI | [ ] |
|
||||
| 20.2 | It should parse CSV files directly | Integration | [ ] |
|
||||
| 20.3 | It should send PDF files to AWS Textract for OCR parsing when enabled | Integration | [ ] |
|
||||
| 20.4 | It should create invoices with pending import status | Integration | [ ] |
|
||||
| 20.5 | It should display results with success/failure per file | UI | [ ] |
|
||||
| 20.6 | It should allow force-overriding client, vendor, location, and ChatGPT parsing mode | UI | [ ] |
|
||||
|
||||
### Validation Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 21.1 | It should reject uploads missing required fields (client, vendor, date, total) | Integration | [ ] |
|
||||
| 21.2 | It should reject uploads where the user has no access to the client | Integration | [ ] |
|
||||
| 21.3 | It should reject uploads with unmatchable vendors, showing a search hint | Integration | [ ] |
|
||||
|
||||
### Approve/Disapprove Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 22.1 | Given a pending imported invoice, when approved, then its status should change to imported | Integration | [ ] |
|
||||
| 22.2 | Given a pending imported invoice, when disapproved, then it should be deleted | Integration | [ ] |
|
||||
| 22.3 | It should support bulk approve/disapprove with selection | Integration | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Glimpse (OCR Import)
|
||||
|
||||
### Upload Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 23.1 | It should allow uploading PDF files | UI | [ ] |
|
||||
| 23.2 | It should upload the file to S3 and start an AWS Textract job | Integration | [ ] |
|
||||
| 23.3 | It should poll every 5 seconds while the Textract job is in progress | Integration | [ ] |
|
||||
| 23.4 | Given a successful Textract job, then it should display extracted fields with confidence scores | UI | [ ] |
|
||||
|
||||
### Field Extraction Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 24.1 | It should extract total from AMOUNT_DUE or TOTAL fields | Unit | [ ] |
|
||||
| 24.2 | It should extract customer from CUSTOMER_NUMBER or RECEIVER_NAME, falling back to Solr search | Unit + Integration | [ ] |
|
||||
| 24.3 | It should extract vendor from VENDOR_NAME, falling back to Solr search | Unit + Integration | [ ] |
|
||||
| 24.4 | It should extract date from INVOICE_RECEIPT_DATE, ORDER_DATE, or DELIVERY_DATE | Unit | [ ] |
|
||||
| 24.5 | It should extract invoice number from INVOICE_RECEIPT_ID or PO_NUMBER | Unit | [ ] |
|
||||
|
||||
### Form Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 25.1 | It should show a side-by-side layout with PDF preview and form | UI | [ ] |
|
||||
| 25.2 | It should display alternative values as clickable pills for each field | UI | [ ] |
|
||||
| 25.3 | It should require selecting client and vendor from alternatives (fields disabled until selected) | UI | [ ] |
|
||||
| 25.4 | Given the user saves, then it should create an invoice linked to the textract job | Integration | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Cross-Cutting Behaviors
|
||||
|
||||
### Permission Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 26.1 | It should block invoice creation for users without `:create` permission | Integration | [ ] |
|
||||
| 26.2 | It should block invoice editing for users without `:edit` permission | Integration | [ ] |
|
||||
| 26.3 | It should block invoice voiding for users without `:delete` permission | Integration | [ ] |
|
||||
| 26.4 | It should block invoice payment for users without `:pay` permission | Integration | [ ] |
|
||||
| 26.5 | It should block bulk delete for non-admin users | Integration | [ ] |
|
||||
| 26.6 | It should block bulk edit for users without `:bulk-edit` permission | Integration | [ ] |
|
||||
| 26.7 | It should block import for users without `:import` permission | Integration | [ ] |
|
||||
| 26.8 | It should verify the user has access to the invoice's client before any mutation | Integration | [ ] |
|
||||
|
||||
### Lock Date Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 27.1 | It should block editing invoices dated before the client's locked-until date | Integration | [ ] |
|
||||
| 27.2 | It should block paying invoices dated before the client's locked-until date | Integration | [ ] |
|
||||
| 27.3 | It should block voiding invoices dated before the client's locked-until date | Integration | [ ] |
|
||||
| 27.4 | It should block importing invoices dated before the client's locked-until date | Integration | [ ] |
|
||||
| 27.5 | It should block approving imported invoices dated before the client's locked-until date | Integration | [ ] |
|
||||
| 27.6 | It should filter out locked invoices from bulk operations | Integration | [ ] |
|
||||
| 27.7 | It should show a warning when some selected invoices are locked | UI | [ ] |
|
||||
|
||||
### Legacy Route Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 28.1 | It should redirect old SPA routes (`/invoices`, `/invoices/unpaid`, etc.) to the new SSR routes | Integration | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Test Data Requirements
|
||||
|
||||
| Entity | Requirements |
|
||||
|--------|-------------|
|
||||
| **Clients** | Multiple clients with different locations; some with locked-until dates |
|
||||
| **Vendors** | With/without default accounts; with/without terms and autopay settings |
|
||||
| **Accounts** | Expense accounts with/without invoice allowance; different locations |
|
||||
| **Bank Accounts** | Check, cash, and credit types |
|
||||
| **Invoices** | Various statuses (unpaid, paid, voided, scheduled), dates, amounts |
|
||||
| **Payments** | Linked to invoices; cash and check types |
|
||||
| **Files** | Valid CSV, PDF with text, PDF requiring OCR |
|
||||
|
||||
## Existing Tests to Preserve
|
||||
|
||||
- `test/clj/auto_ap/ssr/invoice/new_invoice_wizard_test.clj` — Location spreading logic
|
||||
- `test/clj/auto_ap/integration/routes/invoice_test.clj` — Import routes
|
||||
- `test/clj/auto_ap/integration/graphql/invoices.clj` — GraphQL invoice operations
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Datomic (primary store, history for unvoid)
|
||||
- AWS S3 (file storage)
|
||||
- AWS Textract (OCR)
|
||||
- Solr (search for Glimpse matching)
|
||||
- HTMX/Alpine.js (frontend interactivity)
|
||||
519
docs/testing/behaviors/ledger.md
Normal file
519
docs/testing/behaviors/ledger.md
Normal file
@@ -0,0 +1,519 @@
|
||||
# Ledger Behaviors
|
||||
|
||||
## Overview
|
||||
|
||||
The Ledger module is the core accounting interface of Integreat. It provides server-side rendered (HTMX) pages for viewing journal entries, creating manual journal entries, and generating financial reports (Profit & Loss, Balance Sheet, Cash Flows). All ledger pages are permission-gated and client-scoped.
|
||||
|
||||
**Testing Philosophy**
|
||||
- Prefer unit tests for pure business logic (calculations, validations, transformations)
|
||||
- Use integration tests for database interactions and cross-system flows
|
||||
- Use UI tests only for end-to-end happy paths that touch multiple pages
|
||||
- Every behavior must be user-visible; no tests for implementation details
|
||||
|
||||
---
|
||||
|
||||
## Testing Patterns
|
||||
|
||||
### Pattern: Grid Page Behaviors
|
||||
Most list pages in Integreat follow the same pattern:
|
||||
1. Fetch IDs via Datomic query with filters
|
||||
2. Hydrate results via `pull-many`
|
||||
3. Render table with sortable columns
|
||||
4. Support selection (individual / all / all-filtered)
|
||||
5. Action buttons appear conditionally based on permissions and selection state
|
||||
|
||||
**Test implications:** Validate the query generation, not the rendering. UI tests only need to verify one filter and one sort work end-to-end.
|
||||
|
||||
### Pattern: Modal Form Behaviors
|
||||
Modal forms are HTMX-driven dialogs:
|
||||
1. Opened via GET request that renders a form fragment
|
||||
2. Form submissions are POST/PUT with validation
|
||||
3. On success, the modal closes and the table updates in place
|
||||
4. On validation failure, the modal shows error messages
|
||||
|
||||
**Test implications:** Unit test validation logic. Integration test the full modal flow once. UI test only the happy path.
|
||||
|
||||
### Pattern: Report Behaviors
|
||||
Financial reports follow a consistent pattern:
|
||||
1. Form with client multi-select, date/period selectors, and toggles
|
||||
2. Run button triggers HTMX request to generate report
|
||||
3. Report data is computed from account snapshots and running balances
|
||||
4. Export button generates PDF and returns a download modal
|
||||
|
||||
**Test implications:** Unit test calculation logic. Integration test report generation with various filter combinations. UI test only one report end-to-end.
|
||||
|
||||
### Pattern: Permission Gates
|
||||
Every mutating operation checks:
|
||||
1. `assert-can-see-client` — user has access to the client
|
||||
2. `assert-not-locked` — entry date >= client locked-until
|
||||
3. `can?` — user has the specific permission for the activity
|
||||
|
||||
**Test implications:** Integration test each gate independently. UI tests only verify the happy path with a permitted user.
|
||||
|
||||
---
|
||||
|
||||
## Ledger Entries List
|
||||
|
||||
### Display Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 1.1 | It should display a paginated, sortable data grid of journal entries | UI | [ ] |
|
||||
| 1.2 | It should show the Client column only when multiple clients OR multiple locations are selected | Integration | [ ] |
|
||||
| 1.3 | It should display the Vendor column, falling back to `alternate-description` when no vendor is present | Integration | [ ] |
|
||||
| 1.4 | It should hide the Source column on the internal ledger page | UI | [ ] |
|
||||
| 1.5 | It should hide the External ID column on the internal ledger page | UI | [ ] |
|
||||
| 1.6 | It should truncate the External ID column to a max-width when displayed | UI | [ ] |
|
||||
| 1.7 | It should display the Date column with formatted dates | UI | [ ] |
|
||||
| 1.8 | It should display the Amount column formatted as currency | UI | [ ] |
|
||||
| 1.9 | It should display Debit lines with account, location, and amount per line item | UI | [ ] |
|
||||
| 1.10 | It should display Credit lines with account, location, and amount per line item | UI | [ ] |
|
||||
| 1.11 | It should display a Links dropdown with links to original invoice, source file, transaction, or memo | UI | [ ] |
|
||||
| 1.12 | It should show the page title reflecting the status filter, e.g. "Unpaid Register" or "Paid Register" | UI | [ ] |
|
||||
| 1.13 | It should show an "Add journal entry" button on the internal ledger page | UI | [ ] |
|
||||
| 1.14 | It should hide the "Add journal entry" button on the external ledger page | UI | [ ] |
|
||||
| 1.15 | It should show a client selection sidebar when the user has access to multiple clients | UI | [ ] |
|
||||
|
||||
### Filtering Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 2.1 | It should filter entries by vendor typeahead selection | Integration | [ ] |
|
||||
| 2.2 | It should filter entries by account typeahead selection | Integration | [ ] |
|
||||
| 2.3 | It should filter entries by bank account via radio filter | Integration | [ ] |
|
||||
| 2.4 | It should refresh the bank account filter when the client selection changes | Integration | [ ] |
|
||||
| 2.5 | It should filter entries by date range | Integration | [ ] |
|
||||
| 2.6 | It should filter entries by invoice number text search | Integration | [ ] |
|
||||
| 2.7 | It should filter entries by account code range (gte/lte inputs) | Integration | [ ] |
|
||||
| 2.8 | It should filter entries by amount range (gte/lte inputs) | Integration | [ ] |
|
||||
| 2.9 | It should filter to unbalanced entries when "Show unbalanced" is checked | Integration | [ ] |
|
||||
| 2.10 | It should support exact-match navigation to a specific entry by ID, bypassing other filters | Integration | [ ] |
|
||||
| 2.11 | It should clear the exact match ID pill when clicked | UI | [ ] |
|
||||
| 2.12 | Given multiple filters are applied, when the user changes one filter, then the table should refresh with the combined filter set | Integration | [ ] |
|
||||
|
||||
### Sorting Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 3.1 | It should sort by Client ascending/descending | Integration | [ ] |
|
||||
| 3.2 | It should sort by Vendor ascending/descending | Integration | [ ] |
|
||||
| 3.3 | It should sort by Source ascending/descending | Integration | [ ] |
|
||||
| 3.4 | It should sort by External ID ascending/descending | Integration | [ ] |
|
||||
| 3.5 | It should sort by Date ascending/descending | Integration | [ ] |
|
||||
| 3.6 | It should sort by Amount ascending/descending | Integration | [ ] |
|
||||
| 3.7 | It should sort by Account ascending/descending | Integration | [ ] |
|
||||
| 3.8 | It should default to Date ascending | Integration | [ ] |
|
||||
| 3.9 | Given sorting by Vendor, then rows should be grouped with break headers | Integration | [ ] |
|
||||
| 3.10 | Given sorting by Source, then rows should be grouped with break headers | Integration | [ ] |
|
||||
| 3.11 | Given the user clicks a column header twice, then the sort direction should toggle | Integration | [ ] |
|
||||
|
||||
### Pagination Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 4.1 | It should display 25 entries per page by default | Integration | [ ] |
|
||||
| 4.2 | It should allow changing the per-page count | Integration | [ ] |
|
||||
| 4.3 | It should show pagination controls at the bottom of the table | UI | [ ] |
|
||||
|
||||
### Row Action Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 5.1 | It should show a void button for unpaid invoices when the user has `:delete :invoice` permission | UI | [ ] |
|
||||
| 5.2 | It should show an edit button for unpaid and paid invoices when the user has `:edit :invoice` permission | UI | [ ] |
|
||||
| 5.3 | It should show an unvoid button for voided invoices when the user has `:edit :invoice` permission | UI | [ ] |
|
||||
| 5.4 | It should show a trash icon with confirmation for delete operations | UI | [ ] |
|
||||
|
||||
### CSV Export Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 6.1 | It should export all matching entries with line-item-level rows | Integration | [ ] |
|
||||
| 6.2 | It should include columns: ID, Client, Vendor, Source, External ID, Date, Amount, Account, Debit, Credit | Integration | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## New Journal Entry
|
||||
|
||||
### Modal Form Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 7.1 | It should open as a modal dialog 750px wide | UI | [ ] |
|
||||
| 7.2 | It should show a client typeahead pre-filled if a client is already selected on the parent page | UI | [ ] |
|
||||
| 7.3 | It should show a date input defaulting to today in MM/DD/YYYY format | UI | [ ] |
|
||||
| 7.4 | It should show a vendor typeahead disabled when editing an existing entry | UI | [ ] |
|
||||
| 7.5 | It should show a total amount input requiring a value of at least $0.01 | Unit + Integration | [ ] |
|
||||
| 7.6 | It should show an optional memo text input | UI | [ ] |
|
||||
| 7.7 | It should display a line items grid with Account, Location, Debit, and Credit columns | UI | [ ] |
|
||||
|
||||
### Line Item Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 8.1 | It should allow account typeahead search scoped to the selected client | Integration | [ ] |
|
||||
| 8.2 | It should update the location dropdown based on the selected account's required location | Integration | [ ] |
|
||||
| 8.3 | It should lock the location dropdown to a fixed location when the account requires it | Integration | [ ] |
|
||||
| 8.4 | It should show all client locations when the account has no location restriction | Integration | [ ] |
|
||||
| 8.5 | It should add new line item rows via HTMX request | UI | [ ] |
|
||||
| 8.6 | It should allow removing line item rows with an X button | UI | [ ] |
|
||||
|
||||
### Validation Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 9.1 | It should require a client | Unit + Integration | [ ] |
|
||||
| 9.2 | It should require a valid date | Unit + Integration | [ ] |
|
||||
| 9.3 | It should require a vendor | Unit + Integration | [ ] |
|
||||
| 9.4 | It should require an amount of at least $0.01 | Unit + Integration | [ ] |
|
||||
| 9.5 | It should require each line item to have an allowed account | Unit + Integration | [ ] |
|
||||
| 9.6 | It should require each line item to have a location belonging to the account | Unit + Integration | [ ] |
|
||||
| 9.7 | It should validate that debits sum to the total amount | Unit + Integration | [ ] |
|
||||
| 9.8 | It should validate that credits sum to the total amount | Unit + Integration | [ ] |
|
||||
| 9.9 | It should validate that debits and credits each equal the journal entry amount | Unit + Integration | [ ] |
|
||||
| 9.10 | It should block saving when the entry date is on or before the client's `locked-until` date | Integration | [ ] |
|
||||
|
||||
### Save Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 10.1 | It should generate an external ID in the format `manual-<uuid>` | Unit | [ ] |
|
||||
| 10.2 | It should update the client's `ledger-last-change` timestamp | Integration | [ ] |
|
||||
| 10.3 | Given a new entry is saved successfully, then it should prepend the new row to the table and close the modal | UI | [ ] |
|
||||
| 10.4 | Given an existing entry is saved successfully, then it should replace the existing row in the table and close the modal | UI | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## External Import
|
||||
|
||||
### Clipboard Paste Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 11.1 | It should allow clicking a "Load from clipboard" button | UI | [ ] |
|
||||
| 11.2 | It should read TSV data from the browser clipboard | UI | [ ] |
|
||||
| 11.3 | It should parse tab-separated values with columns: Id, Client, Source, Vendor, Date, Account Code, Location, Debit, Credit | Integration | [ ] |
|
||||
|
||||
### Parse Validation Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 12.1 | It should validate that all rows have required fields | Integration | [ ] |
|
||||
| 12.2 | It should validate that dates are parseable | Unit + Integration | [ ] |
|
||||
| 12.3 | It should validate that account codes are numeric or bank account strings | Unit + Integration | [ ] |
|
||||
| 12.4 | It should validate that locations are 1-2 characters | Unit + Integration | [ ] |
|
||||
| 12.5 | It should validate that debits and credits are valid money amounts | Unit + Integration | [ ] |
|
||||
|
||||
### Import Validation Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 13.1 | It should validate that the client code exists | Integration | [ ] |
|
||||
| 13.2 | It should validate that the vendor exists, creating a hidden vendor if missing | Integration | [ ] |
|
||||
| 13.3 | It should block entries for dates when the client is locked | Integration | [ ] |
|
||||
| 13.4 | It should validate that debits and credits balance per entry | Unit + Integration | [ ] |
|
||||
| 13.5 | It should warn when an entry totals $0.00 | Unit + Integration | [ ] |
|
||||
| 13.6 | It should validate that the location belongs to the client | Integration | [ ] |
|
||||
| 13.7 | It should validate that the account code exists | Integration | [ ] |
|
||||
| 13.8 | It should validate that bank account codes belong to the client | Integration | [ ] |
|
||||
| 13.9 | It should validate that account location requirements are satisfied | Integration | [ ] |
|
||||
|
||||
### Import Result Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 14.1 | It should import successful entries | Integration | [ ] |
|
||||
| 14.2 | It should ignore entries with warnings, removing them if they previously existed | Integration | [ ] |
|
||||
| 14.3 | It should block import and show error counts when entries have errors | Integration | [ ] |
|
||||
| 14.4 | It should retract existing entries by external ID before importing | Integration | [ ] |
|
||||
| 14.5 | It should index imported entries in Solr asynchronously | Integration | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## P&L Report
|
||||
|
||||
### Form Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 15.1 | It should show a customer multi-select typeahead with a max of 20 selections | UI | [ ] |
|
||||
| 15.2 | It should default to the first 5 customers when "all" is selected | Integration | [ ] |
|
||||
| 15.3 | It should show a periods dropdown defaulting to year-to-date | UI | [ ] |
|
||||
| 15.4 | It should show a "Column per location" toggle | UI | [ ] |
|
||||
| 15.5 | It should show an "Include deltas" toggle | UI | [ ] |
|
||||
| 15.6 | It should trigger report generation via HTMX PUT on the Run button | UI | [ ] |
|
||||
| 15.7 | It should show an Export PDF button | UI | [ ] |
|
||||
|
||||
### Report Generation Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 16.1 | It should compute running balances before generating the report | Integration | [ ] |
|
||||
| 16.2 | It should query detailed account snapshots for each client and period end date | Integration | [ ] |
|
||||
| 16.3 | It should calculate amounts as debits minus credits for assets, dividends, and expenses | Unit | [ ] |
|
||||
| 16.4 | It should calculate amounts as credits minus debits for liabilities, equity, and revenue | Unit | [ ] |
|
||||
| 16.5 | It should group data by client, location, and period | Integration | [ ] |
|
||||
|
||||
### Report Output Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 17.1 | It should display a summary table with Sales, COGS, Payroll, Gross Profits, Overhead, and Net Income | UI | [ ] |
|
||||
| 17.2 | It should display a detail table with account-level breakdown within each category | UI | [ ] |
|
||||
| 17.3 | It should calculate percent of sales for each row | Unit | [ ] |
|
||||
| 17.4 | It should show deltas between periods when enabled | UI | [ ] |
|
||||
| 17.5 | It should show each location as separate columns when column-per-location mode is enabled | UI | [ ] |
|
||||
|
||||
### Warning Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 18.1 | It should warn when more than 20 clients are selected and truncate to 20 | Integration | [ ] |
|
||||
| 18.2 | It should warn about unresolved ledger entries with missing numeric codes | Integration | [ ] |
|
||||
| 18.3 | It should show sample links to admin history for invalid entries when the user has `:view :history` permission | Integration | [ ] |
|
||||
|
||||
### PDF Export Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 19.1 | It should generate a PDF with Calibri Light font at 6pt | Integration | [ ] |
|
||||
| 19.2 | It should upload the PDF to S3 at `reports/profit-and-loss/<uuid>/<name>.pdf` | Integration | [ ] |
|
||||
| 19.3 | It should persist a report record in Datomic | Integration | [ ] |
|
||||
| 19.4 | It should return a modal with a download link | UI | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Balance Sheet
|
||||
|
||||
### Form Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 20.1 | It should show a customer multi-select typeahead with a max of 20 selections | UI | [ ] |
|
||||
| 20.2 | It should default to the first 5 customers when "all" is selected | Integration | [ ] |
|
||||
| 20.3 | It should show a date dropdown defaulting to today | UI | [ ] |
|
||||
| 20.4 | It should show an "Include deltas" toggle | UI | [ ] |
|
||||
| 20.5 | It should trigger report generation via HTMX GET on the Run button | UI | [ ] |
|
||||
| 20.6 | It should show an Export PDF button | UI | [ ] |
|
||||
|
||||
### Report Generation Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 21.1 | It should compute running balances before generating the report | Integration | [ ] |
|
||||
| 21.2 | It should query account snapshots as of each selected date | Integration | [ ] |
|
||||
| 21.3 | It should group accounts into Assets, Liabilities, and Owner's Equity | Integration | [ ] |
|
||||
| 21.4 | It should include Retained Earnings as net income across all P&L categories | Unit | [ ] |
|
||||
|
||||
### Report Output Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 22.1 | It should display an Assets section with account detail and subtotal | UI | [ ] |
|
||||
| 22.2 | It should display a Liabilities section with account detail and subtotal | UI | [ ] |
|
||||
| 22.3 | It should display an Owner's Equity section with account detail and subtotal | UI | [ ] |
|
||||
| 22.4 | It should display a Retained Earnings line | UI | [ ] |
|
||||
| 22.5 | It should show delta columns between periods when enabled and multiple dates are selected | UI | [ ] |
|
||||
|
||||
### Warning Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 23.1 | It should warn when more than 20 clients are selected | Integration | [ ] |
|
||||
| 23.2 | It should warn about unresolved ledger entries | Integration | [ ] |
|
||||
|
||||
### PDF Export Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 24.1 | It should generate a PDF and upload to S3 at `reports/balance-sheet/<uuid>/<name>.pdf` | Integration | [ ] |
|
||||
| 24.2 | It should persist a report record in Datomic | Integration | [ ] |
|
||||
| 24.3 | It should return a modal with a download link | UI | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Cash Flows
|
||||
|
||||
### Form Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 25.1 | It should show a customer multi-select typeahead with a max of 20 selections | UI | [ ] |
|
||||
| 25.2 | It should default to the first 5 customers when "all" is selected | Integration | [ ] |
|
||||
| 25.3 | It should show a periods dropdown defaulting to year-to-date | UI | [ ] |
|
||||
| 25.4 | It should trigger report generation via HTMX PUT on the Run button | UI | [ ] |
|
||||
| 25.5 | It should show an Export PDF button | UI | [ ] |
|
||||
|
||||
### Report Generation Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 26.1 | It should query account snapshots as of period end plus one day | Integration | [ ] |
|
||||
| 26.2 | It should group accounts into Operating Activities, Investment Activities, Financing Activities, and Cash | Integration | [ ] |
|
||||
| 26.3 | It should calculate cash flow effect by adding or subtracting based on account code ranges | Unit | [ ] |
|
||||
|
||||
### Report Output Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 27.1 | It should display Net Income as the starting point | UI | [ ] |
|
||||
| 27.2 | It should display Operating Activities detail with increases, decreases, and cash impact | UI | [ ] |
|
||||
| 27.3 | It should display Investment Activities detail | UI | [ ] |
|
||||
| 27.4 | It should display Financing Activities detail | UI | [ ] |
|
||||
| 27.5 | It should display Change in Cash and Cash Equivalents total | UI | [ ] |
|
||||
| 27.6 | It should display Bank Accounts / Cash detail | UI | [ ] |
|
||||
|
||||
### Warning Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 28.1 | It should warn when more than 20 clients are selected | Integration | [ ] |
|
||||
| 28.2 | It should warn about unresolved ledger entries | Integration | [ ] |
|
||||
|
||||
### PDF Export Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 29.1 | It should generate a PDF and upload to S3 at `reports/cash-flows/<uuid>/<name>.pdf` | Integration | [ ] |
|
||||
| 29.2 | It should persist a report record in Datomic | Integration | [ ] |
|
||||
| 29.3 | It should return a modal with a download link | UI | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Investigation
|
||||
|
||||
### Modal Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 30.1 | It should open as a modal dialog from report table cell clicks | UI | [ ] |
|
||||
| 30.2 | It should filter ledger entries by the clicked cell's filters: account code range, client, location, and date range | Integration | [ ] |
|
||||
| 30.3 | It should display a raw table without checkboxes | UI | [ ] |
|
||||
| 30.4 | It should constrain the modal to a max height of 600px with scrollable content | UI | [ ] |
|
||||
|
||||
### Table Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 31.1 | It should use the same query schema as the main ledger list | Integration | [ ] |
|
||||
| 31.2 | It should support sorting and pagination | Integration | [ ] |
|
||||
| 31.3 | It should not push URL state on filter or sort changes | Integration | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Cross-Cutting Behaviors
|
||||
|
||||
### Report Generation Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 32.1 | It should call `upsert-running-balance` before querying to ensure cached balances are current | Integration | [ ] |
|
||||
| 32.2 | It should use `detailed-account-snapshot` Datomic query for raw report data | Integration | [ ] |
|
||||
| 32.3 | It should build account lookups per-client via `build-account-lookup` | Integration | [ ] |
|
||||
| 32.4 | It should skip entries without numeric codes and warn about unresolved entries | Integration | [ ] |
|
||||
|
||||
### Export Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 33.1 | It should support PDF export via `clj-pdf` for all three reports | Integration | [ ] |
|
||||
| 33.2 | It should use Calibri Light 6pt font on letter size for all PDF exports | Integration | [ ] |
|
||||
| 33.3 | It should upload PDFs to the S3 data bucket with a UUID-based key | Integration | [ ] |
|
||||
| 33.4 | It should persist report metadata to Datomic with name, client, key, URL, creator, and created timestamp | Integration | [ ] |
|
||||
| 33.5 | It should return a modal with an S3 download link after export | UI | [ ] |
|
||||
|
||||
### Filtering and Sorting Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 34.1 | It should apply ledger list filters via HTMX on change with a 500ms debounce | Integration | [ ] |
|
||||
| 34.2 | It should apply hot filters via HTMX on keyup with a 1000ms debounce | Integration | [ ] |
|
||||
| 34.3 | It should refresh the bank account filter when client selection changes | Integration | [ ] |
|
||||
| 34.4 | It should support multiple sort keys with ascending and descending direction | Integration | [ ] |
|
||||
| 34.5 | It should default to date ascending sort | Integration | [ ] |
|
||||
| 34.6 | It should bypass all other filters when an exact match ID filter is active | Integration | [ ] |
|
||||
|
||||
### Permission Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 35.1 | It should require an authenticated user for all ledger pages | Integration | [ ] |
|
||||
| 35.2 | It should require `:read :ledger` permission for the main ledger page | Integration | [ ] |
|
||||
| 35.3 | It should require `:edit :ledger` permission for new and edit journal entry | Integration | [ ] |
|
||||
| 35.4 | It should require `:import :ledger` permission plus admin assertion for external import | Integration | [ ] |
|
||||
| 35.5 | It should require `:read :profit-and-loss` permission for the P&L report | Integration | [ ] |
|
||||
| 35.6 | It should require `:read :balance-sheet` permission for the balance sheet | Integration | [ ] |
|
||||
| 35.7 | It should require `:read :cash-flows` permission for the cash flows report | Integration | [ ] |
|
||||
| 35.8 | It should restrict users to clients they have permission for via `assert-can-see-client` | Integration | [ ] |
|
||||
| 35.9 | It should require `:delete :invoice` permission for invoice void actions | Integration | [ ] |
|
||||
| 35.10 | It should require `:edit :invoice` permission for invoice edit and unvoid actions | Integration | [ ] |
|
||||
|
||||
### Empty State Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 36.1 | It should show an empty table with pagination showing 0 results when no entries exist | UI | [ ] |
|
||||
| 36.2 | It should show an empty table with filter pills remaining when filters match nothing | UI | [ ] |
|
||||
| 36.3 | It should show an empty report form with no table rendered when report data is empty | UI | [ ] |
|
||||
|
||||
### Data Locking Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 37.1 | It should block creating journal entries for dates on or before the client's `locked-until` date | Integration | [ ] |
|
||||
| 37.2 | It should reject external import entries for locked dates | Integration | [ ] |
|
||||
|
||||
### Unbalanced Entry Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 38.1 | It should compute debit and credit sums per entry for the "Show unbalanced" filter | Unit | [ ] |
|
||||
| 38.2 | It should display unbalanced entries in the normal view without filtering | UI | [ ] |
|
||||
|
||||
### Account Location Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 39.1 | It should reject locations other than the fixed location for accounts with fixed locations | Integration | [ ] |
|
||||
| 39.2 | It should reject "A" (all) location for accounts without location restrictions | Integration | [ ] |
|
||||
| 39.3 | It should validate account location requirements on both the frontend location select and backend schema | Integration | [ ] |
|
||||
|
||||
### Running Balance Cache Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 40.1 | It should recompute balances for dirty line items via `refresh-running-balance-cache` | Integration | [ ] |
|
||||
| 40.2 | It should mark a changed entry's line items and subsequent entries as dirty | Integration | [ ] |
|
||||
| 40.3 | It should skip recomputation for non-dirty entries | Integration | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Test Data Requirements
|
||||
|
||||
| Entity | Requirements |
|
||||
|--------|-------------|
|
||||
| **Clients** | At least 2 clients with different locations; at least 1 client with a `locked-until` date in the past |
|
||||
| **Accounts** | Asset accounts (11000-11999); Liability accounts (20000-28999); Equity accounts (30000-39999); Revenue accounts (40000-49999); Expense accounts (50000-98999); accounts with fixed locations; accounts without location restrictions |
|
||||
| **Vendors** | Existing vendors; hidden vendors (auto-created on import) |
|
||||
| **Journal Entries** | Balanced entries (debits = credits); unbalanced entries (debits ≠ credits); entries with multiple line items; entries linked to invoices; entries with external IDs; entries across multiple dates and locations |
|
||||
| **Bank Accounts** | Bank accounts linked to clients |
|
||||
|
||||
## Existing Tests to Preserve
|
||||
|
||||
- `test/clj/auto_ap/ssr/ledger/ledger_test.clj` — Ledger page rendering and grid behaviors
|
||||
- `test/clj/auto_ap/integration/routes/ledger_test.clj` — Ledger routes and mutations
|
||||
- `test/clj/auto_ap/ledger/reports_test.clj` — Report generation and calculation logic
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `auto-ap.ssr.ledger.common` — Shared grid page config, query schema, filtering
|
||||
- `auto-ap.ledger.reports` — Report aggregation and formatting logic
|
||||
- `auto-ap.ledger` — Running balance cache, account lookups
|
||||
- `auto-ap.datomic.accounts` — Account querying and clientization
|
||||
- `auto-ap.permissions` — Permission checks and middleware
|
||||
- `auto-ap.ssr.grid-page-helper` — Generic data grid behaviors
|
||||
- `auto-ap.ssr.components` — UI components (typeahead, date inputs, buttons)
|
||||
- `auto-ap.ssr.form-cursor` — Form state management
|
||||
- `clj-pdf` — PDF generation
|
||||
- `amazonica.aws.s3` — S3 upload for exports
|
||||
- `datomic.api` — Database queries and transactions
|
||||
340
docs/testing/behaviors/legacy-spa.md
Normal file
340
docs/testing/behaviors/legacy-spa.md
Normal file
@@ -0,0 +1,340 @@
|
||||
# Legacy SPA Behaviors
|
||||
|
||||
## Overview
|
||||
|
||||
These pages are rendered client-side via Reagent/Re-frame and use GraphQL for data fetching. They are being migrated to HTMX SSR. **No UI tests should be written for these pages until migrated.**
|
||||
|
||||
**Testing Philosophy**
|
||||
- Prefer unit tests for pure business logic (calculations, validations, transformations)
|
||||
- Use integration tests for GraphQL queries, mutations, and data flows
|
||||
- Use UI tests only after migration to SSR; until then, mark as "UI (when migrated)"
|
||||
- Every behavior must be user-visible; no tests for implementation details
|
||||
|
||||
---
|
||||
|
||||
## Testing Patterns
|
||||
|
||||
### Pattern: GraphQL Data Fetching
|
||||
All data fetching uses re-frame `graphql` effect with JWT token from `:user` in app-db:
|
||||
1. Queries use `owns-state` to track loading status per page
|
||||
2. Results flow through `::data-page/received` event which stores data and syncs URL query params
|
||||
3. Filter changes are debounced (800ms) via `dispatch-debounce`
|
||||
|
||||
**Test implications:** Integration test the GraphQL query resolution and response shape. Do not test the re-frame effect machinery.
|
||||
|
||||
### Pattern: Re-frame State Management
|
||||
- **Data pages**: `data-page` namespace provides reusable pagination/filtering state
|
||||
- `::data-page/params` — merged filters + table params + query params
|
||||
- `::data-page/data` — GraphQL response data
|
||||
- `::data-page/checked` — selected rows for bulk operations
|
||||
- **Forms**: `forms` namespace manages edit dialogs with `start-form`, `change-handler`, `save-succeeded`
|
||||
- **Status**: `status` namespace tracks async operation states (`:loading`, `:complete`, `:error`)
|
||||
|
||||
**Test implications:** Test via integration tests that verify the correct data appears after state transitions. Do not test subscription internals.
|
||||
|
||||
### Pattern: Client-Side Routing
|
||||
- Navbar uses Bidi `path-for` with `routes/routes` for legacy SPA links
|
||||
- Some navbar items (Payments, POS, Invoices) now link to `ssr-routes/only-routes` instead
|
||||
- Page components dispatch `::mounted` on mount and `::unmounted` on unmount
|
||||
|
||||
**Test implications:** After migration, integration test route redirects from old SPA routes to new SSR routes.
|
||||
|
||||
---
|
||||
|
||||
## Home
|
||||
|
||||
### Dashboard Display
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 1.1 | It should display a dashboard with three chart sections: top expense categories, upcoming bills, and cash flow projection | UI (when migrated) | [ ] |
|
||||
| 1.2 | It should load data for the currently selected client on page load | Integration | [ ] |
|
||||
| 1.3 | It should display a note: "these reports are for [client]. Please choose a specific customer for their report." when the user has access to multiple clients and has not selected a specific one | UI (when migrated) | [ ] |
|
||||
| 1.4 | It should display an interactive bar chart for cash flow projection | UI (when migrated) | [ ] |
|
||||
| 1.5 | It should display a table below the cash flow chart showing invoices, upcoming debits, and upcoming credits with days-until due | UI (when migrated) | [ ] |
|
||||
| 1.6 | It should allow switching the cash flow range between 7, 30, 60, 90, 120, 150, and 180 days | UI (when migrated) | [ ] |
|
||||
| 1.7 | Given the user switches the cash flow range, then the chart and table should update to reflect the selected range | Integration | [ ] |
|
||||
| 1.8 | Given the user clicks a cash flow bar, then it should redirect to the unpaid invoices page for that date | UI (when migrated) | [ ] |
|
||||
| 1.9 | It should show empty charts gracefully when there is no data for the selected client | UI (when migrated) | [ ] |
|
||||
| 1.10 | It should show a loading state while GraphQL data is being fetched | UI (when migrated) | [ ] |
|
||||
| 1.11 | It should handle GraphQL errors by showing the loading state appropriately | UI (when migrated) | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Login
|
||||
|
||||
### Authentication
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 2.1 | It should display a "Login with Google" button on the login page | UI (when migrated) | [ ] |
|
||||
| 2.2 | It should link the login button to Google OAuth | UI (when migrated) | [ ] |
|
||||
| 2.3 | It should preserve the `redirect-to` query parameter in the Google OAuth URL | Integration | [ ] |
|
||||
| 2.4 | It should display a warning notification with the logout reason when the `logout-reason` query parameter is set | UI (when migrated) | [ ] |
|
||||
| 2.5 | It should redirect an already authenticated user away from the login page | Integration | [ ] |
|
||||
|
||||
### Needs Activation
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 2.6 | It should display the message: "Sorry, your user is not activated yet. Please have Ben Skinner enable your account." when the user's account is inactive | UI (when migrated) | [ ] |
|
||||
| 2.7 | It should provide a "here" link that clears user state and redirects to the login page | Integration | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Transactions
|
||||
|
||||
### List Display
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 3.1 | It should display a table of transactions with columns for amount, memo, location, approval status, vendor, accounts, date, and description | UI (when migrated) | [ ] |
|
||||
| 3.2 | It should load the transaction list with a default date filter of the last 1 month | Integration | [ ] |
|
||||
| 3.3 | It should allow filtering by vendor, account, bank account, date range, amount range, location, import batch, description, and linked status via a sidebar | UI (when migrated) | [ ] |
|
||||
| 3.4 | It should debounce sidebar filter changes by 800ms before refreshing data | Integration | [ ] |
|
||||
| 3.5 | It should support pagination with start and per-page parameters | Integration | [ ] |
|
||||
| 3.6 | It should display checkboxes for row selection when the user is an admin | UI (when migrated) | [ ] |
|
||||
| 3.7 | It should not display checkboxes or bulk action buttons for non-admin users | UI (when migrated) | [ ] |
|
||||
|
||||
### Transaction Edit
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 3.8 | It should open an edit sidebar when the user clicks a transaction row | UI (when migrated) | [ ] |
|
||||
| 3.9 | It should allow updating the vendor, approval status, memo, and expense accounts | UI (when migrated) | [ ] |
|
||||
| 3.10 | It should validate that the sum of expense account amounts equals the transaction amount | Unit + Integration | [ ] |
|
||||
| 3.11 | It should validate that locations are valid for the selected accounts | Unit + Integration | [ ] |
|
||||
| 3.12 | It should block editing locked transactions | Integration | [ ] |
|
||||
| 3.13 | It should display validation errors inline in the edit form | UI (when migrated) | [ ] |
|
||||
|
||||
### Bulk Operations (Admin)
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 3.14 | It should allow deleting selected transactions or all visible transactions | Integration | [ ] |
|
||||
| 3.15 | It should allow suppressing selected transactions instead of deleting them | Integration | [ ] |
|
||||
| 3.16 | It should allow bulk coding by applying vendor, account, approval status, and account rules to multiple transactions | Integration | [ ] |
|
||||
| 3.17 | It should allow importing transactions via a manual Yodlee import dialog | Integration | [ ] |
|
||||
|
||||
### Route Variants
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 3.18 | It should apply the correct default approval status filter for each route variant: all, unapproved, approved, requires-feedback, excluded | Integration | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Ledger
|
||||
|
||||
### General Ledger
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 4.1 | It should display a table of journal entries with expandable line items | UI (when migrated) | [ ] |
|
||||
| 4.2 | It should load the ledger with a default date filter of the last 1 month | Integration | [ ] |
|
||||
| 4.3 | It should allow filtering by vendor, account, bank account, date range, amount range, and location via a sidebar | UI (when migrated) | [ ] |
|
||||
| 4.4 | It should support virtual pagination controls | Integration | [ ] |
|
||||
| 4.5 | It should allow admin users to export filtered results as a CSV download | Integration | [ ] |
|
||||
| 4.6 | It should display "Not authorized" for users with the manager role | UI (when migrated) | [ ] |
|
||||
|
||||
### Profit and Loss
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 4.7 | It should generate a profit and loss report for a single client with a default period | Integration | [ ] |
|
||||
| 4.8 | It should allow selecting multiple companies via a multi-select typeahead and generating a combined report | UI (when migrated) | [ ] |
|
||||
| 4.9 | It should provide period preset buttons: 13 periods, 12 months, last week, week-to-date, last month, month-to-date, year-to-date, last calendar year, and full year | UI (when migrated) | [ ] |
|
||||
| 4.10 | It should populate the correct date ranges when a period preset is selected | Unit | [ ] |
|
||||
| 4.11 | It should allow custom period selection via start and end date pickers in advanced mode | UI (when migrated) | [ ] |
|
||||
| 4.12 | It should optionally include period-over-period deltas | UI (when migrated) | [ ] |
|
||||
| 4.13 | It should optionally break out the report by location, which is mutually exclusive with including deltas | Integration | [ ] |
|
||||
| 4.14 | It should allow admin users to generate and download a PDF export | Integration | [ ] |
|
||||
| 4.15 | It should provide an email composition link for single-client PDF exports | UI (when migrated) | [ ] |
|
||||
| 4.16 | It should open a ledger detail sidebar when the user clicks a report cell | UI (when migrated) | [ ] |
|
||||
| 4.17 | It should display "Not authorized" for users with the manager role | UI (when migrated) | [ ] |
|
||||
|
||||
### Cash Flows
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 4.18 | It should generate a cash flows statement report | Integration | [ ] |
|
||||
| 4.19 | It should use the same company, period, delta, and location column controls as the profit and loss report | Integration | [ ] |
|
||||
| 4.20 | It should allow admin users to export the report to PDF | Integration | [ ] |
|
||||
| 4.21 | It should open a ledger detail sidebar when the user clicks a report cell | UI (when migrated) | [ ] |
|
||||
| 4.22 | It should display "Not authorized" for users with the manager role | UI (when migrated) | [ ] |
|
||||
|
||||
### Profit and Loss Detail
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 4.23 | It should generate a detailed journal entry report with a default 2-week date range | Integration | [ ] |
|
||||
| 4.24 | It should group journal entries by category: sales, COGS, payroll, controllable, fixed overhead, and ownership controllable | Integration | [ ] |
|
||||
| 4.25 | It should display Gross Profit, Overhead, and Net Profit summaries | UI (when migrated) | [ ] |
|
||||
| 4.26 | It should filter journal entries by the selected start and end dates | Integration | [ ] |
|
||||
| 4.27 | It should allow admin users to export the report to PDF with an email link for single client | Integration | [ ] |
|
||||
| 4.28 | It should display "Not authorized" for users with the manager role | UI (when migrated) | [ ] |
|
||||
|
||||
### Balance Sheet
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 4.29 | It should generate a balance sheet report as of a specific date | Integration | [ ] |
|
||||
| 4.30 | It should allow selecting multiple companies and combining them into a single report | Integration | [ ] |
|
||||
| 4.31 | It should optionally include a prior-year comparison with a side-by-side view | UI (when migrated) | [ ] |
|
||||
| 4.32 | It should allow admin users to export the report to PDF with an email composition link for single client | Integration | [ ] |
|
||||
| 4.33 | It should open ledger entries filtered by account and date range when the user clicks a cell | UI (when migrated) | [ ] |
|
||||
| 4.34 | It should display "Not authorized" for users with the manager role | UI (when migrated) | [ ] |
|
||||
|
||||
### External Ledger
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 4.35 | It should display only externally-imported journal entries with an external_id | Integration | [ ] |
|
||||
| 4.36 | It should allow admin users to delete selected entries, with a maximum of 1000 at once | Integration | [ ] |
|
||||
| 4.37 | It should allow admin users to export to CSV | Integration | [ ] |
|
||||
| 4.38 | It should display "Not authorized" for non-admin users | UI (when migrated) | [ ] |
|
||||
|
||||
### External Import
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 4.39 | It should allow admin users to paste tab-separated data into a textarea | UI (when migrated) | [ ] |
|
||||
| 4.40 | It should provide a checkbox to indicate whether the first row is a header | UI (when migrated) | [ ] |
|
||||
| 4.41 | It should parse the pasted data into a table with columns: Id, Client, Source, Vendor, Date, Account, Location, Debit, Credit, Note, and Cleared against | Integration | [ ] |
|
||||
| 4.42 | It should validate that the client code exists | Unit + Integration | [ ] |
|
||||
| 4.43 | It should validate that the vendor exists | Unit + Integration | [ ] |
|
||||
| 4.44 | It should validate that the date is in MM/dd/yyyy format | Unit + Integration | [ ] |
|
||||
| 4.45 | It should validate that total debits equal total credits | Unit + Integration | [ ] |
|
||||
| 4.46 | It should validate that all amounts are greater than 0 | Unit + Integration | [ ] |
|
||||
| 4.47 | It should validate that entries are dated after the client's locked-until date | Integration | [ ] |
|
||||
| 4.48 | It should validate that the location belongs to the client or is "A" | Unit + Integration | [ ] |
|
||||
| 4.49 | It should validate that the account exists and the location matches the account's required location | Unit + Integration | [ ] |
|
||||
| 4.50 | It should display errors per row with a dropdown explanation | UI (when migrated) | [ ] |
|
||||
| 4.51 | It should show status icons indicating success, ignored, or existing per row after import | UI (when migrated) | [ ] |
|
||||
| 4.52 | It should display the total success count, ignored count, and error count after import | UI (when migrated) | [ ] |
|
||||
| 4.53 | It should provide an "Only show errors" filter | UI (when migrated) | [ ] |
|
||||
| 4.54 | It should display "Not authorized" for non-admin users | UI (when migrated) | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Payments
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 5.1 | It should be fully migrated to SSR at `/payment/`; the legacy client route exists only for navbar highlighting | N/A | [x] |
|
||||
|
||||
---
|
||||
|
||||
## Reports
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 6.1 | It should be fully migrated to SSR at `/company/reports`; the legacy client route exists only for navbar highlighting | N/A | [x] |
|
||||
|
||||
---
|
||||
|
||||
## Vendors
|
||||
|
||||
### Admin Vendor Management
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 7.1 | It should be fully migrated to SSR at `/admin/vendor`; the legacy client route exists only for navbar highlighting | N/A | [x] |
|
||||
|
||||
### New Vendor Dialog
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 7.2 | It should load the home dashboard with the vendor creation dialog pre-opened when navigating to `/vendor/new` | UI (when migrated) | [ ] |
|
||||
| 7.3 | It should only open the vendor dialog if the user has `:vendor :create` permission | Integration | [ ] |
|
||||
| 7.4 | It should allow creating a new vendor with name, terms, address, contacts, and default account | UI (when migrated) | [ ] |
|
||||
| 7.5 | It should validate that only one terms override exists per client | Unit + Integration | [ ] |
|
||||
| 7.6 | It should validate that only one schedule payment DOM override exists per client | Unit + Integration | [ ] |
|
||||
| 7.7 | It should validate that only one account override exists per client | Unit + Integration | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Cross-Cutting Behaviors
|
||||
|
||||
### GraphQL Query Patterns
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 8.1 | It should include the JWT token from the `:user` app-db state in all GraphQL requests | Integration | [ ] |
|
||||
| 8.2 | It should track loading status per page using `owns-state` | Integration | [ ] |
|
||||
| 8.3 | It should store GraphQL response data and sync URL query params via the `::data-page/received` event | Integration | [ ] |
|
||||
| 8.4 | It should debounce filter changes by 800ms before dispatching GraphQL requests | Integration | [ ] |
|
||||
|
||||
### Re-frame State Management
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 8.5 | It should merge filters, table params, and query params into `::data-page/params` | Integration | [ ] |
|
||||
| 8.6 | It should store GraphQL response data in `::data-page/data` | Integration | [ ] |
|
||||
| 8.7 | It should track selected rows for bulk operations in `::data-page/checked` | Integration | [ ] |
|
||||
| 8.8 | It should manage edit dialog state with `start-form`, `change-handler`, and `save-succeeded` events | Integration | [ ] |
|
||||
| 8.9 | It should track async operation states as `:loading`, `:complete`, or `:error` | Integration | [ ] |
|
||||
|
||||
### Navigation
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 8.10 | It should redirect old SPA routes to new SSR routes after migration | Integration | [ ] |
|
||||
| 8.11 | It should set up data subscriptions, forward event listeners, and parameter change watchers on page mount | Integration | [ ] |
|
||||
| 8.12 | It should tear down data subscriptions, forward event listeners, and parameter change watchers on page unmount | Integration | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Test Data Requirements
|
||||
|
||||
| Entity | Requirements |
|
||||
|--------|-------------|
|
||||
| **Users** | Admin, power-user, manager, user, and read-only roles |
|
||||
| **Clients** | Multiple clients with different locations; some with locked-until dates |
|
||||
| **Vendors** | With/without default accounts, terms, and autopay settings |
|
||||
| **Accounts** | Expense accounts with/without invoice allowance; different locations |
|
||||
| **Bank Accounts** | Check, cash, and credit types |
|
||||
| **Transactions** | Various approval statuses, dates, amounts, locked/unlocked states |
|
||||
| **Journal Entries** | With/without external_id; various categories and accounts |
|
||||
| **Invoices** | Various statuses and due dates for cash flow projections |
|
||||
|
||||
## Existing Tests to Preserve
|
||||
|
||||
- Test GraphQL query resolution for `HomeDashboard`, `TransactionPage`, `LedgerPage`, `ProfitAndLoss`, `CashFlows`, `JournalDetailReport`, `BalanceSheet`, and `ExternalLedger`
|
||||
- Test re-frame event handlers for data page state transitions
|
||||
- Test form validation logic for transaction editing and external ledger import
|
||||
|
||||
## Dependencies
|
||||
|
||||
- GraphQL API (data fetching)
|
||||
- Re-frame/Reagent (client-side state and rendering)
|
||||
- Bidi (client-side routing)
|
||||
- Recharts (chart rendering on Home page)
|
||||
- Server-side PDF generation (P&L, Cash Flows, Balance Sheet, P&L Detail)
|
||||
|
||||
## Migration Notes
|
||||
|
||||
### Already Migrated to SSR
|
||||
- **Payments** (`/payments/`) - Fully migrated to `/payment/` SSR routes
|
||||
- **Reports** (`/reports/`) - Fully migrated to `/company/reports` SSR routes
|
||||
- **Admin Vendors** (`/admin/vendors`) - Fully migrated to `/admin/vendor` SSR routes
|
||||
|
||||
### Still Legacy SPA (prioritized by complexity)
|
||||
1. **Transactions** - High complexity (filters, edit form, bulk operations, manual import)
|
||||
2. **Home/Dashboard** - Medium complexity (charts, cash flow calculations)
|
||||
3. **Ledger** - Medium complexity (filters, CSV export)
|
||||
4. **External Ledger** - Low-medium complexity (subset of ledger + delete)
|
||||
5. **External Import** - Medium complexity (TSV parsing, validation, batch import)
|
||||
6. **Profit and Loss** - High complexity (multi-company, periods, PDF export)
|
||||
7. **Cash Flows** - High complexity (shares P&L infrastructure)
|
||||
8. **Profit and Loss Detail** - Medium complexity (category filtering, running balances)
|
||||
9. **Balance Sheet** - Medium complexity (comparison mode, drill-down)
|
||||
10. **Login** - Low complexity (static page)
|
||||
11. **Needs Activation** - Low complexity (static page)
|
||||
12. **New Vendor** - Low complexity (dialog on home page)
|
||||
|
||||
### Migration Risks
|
||||
- **Chart libraries**: Home page uses Recharts (React). Replacement needed for SSR.
|
||||
- **PDF generation**: P&L, Cash Flows, Balance Sheet, P&L Detail all support PDF export via server-side generation.
|
||||
- **Bulk operations**: Transactions page has complex bulk coding/deletion with validation.
|
||||
- **External import**: TSV parsing and validation logic lives entirely in SPA; server-side equivalent needed.
|
||||
192
docs/testing/behaviors/outgoing-invoice.md
Normal file
192
docs/testing/behaviors/outgoing-invoice.md
Normal file
@@ -0,0 +1,192 @@
|
||||
# Outgoing Invoice Behaviors
|
||||
|
||||
## Overview
|
||||
|
||||
The Outgoing Invoice subsystem allows users to create and generate PDF invoices to send to customers. It provides a form-based workflow for specifying the client (sender), recipient details, invoice metadata (date, number, tax rate), and line items. Upon submission, the system calculates subtotals, applies tax, and invokes an AWS Lambda function (`genpdf`) to generate a downloadable PDF.
|
||||
|
||||
**Testing Philosophy**
|
||||
- Prefer unit tests for pure business logic (calculations, validations, transformations)
|
||||
- Use integration tests for database interactions and cross-system flows
|
||||
- Use UI tests only for end-to-end happy paths that touch multiple pages
|
||||
- Every behavior must be user-visible; no tests for implementation details
|
||||
|
||||
---
|
||||
|
||||
## Testing Patterns
|
||||
|
||||
### Pattern: Form Submission with HTMX
|
||||
Outgoing invoice forms use HTMX for asynchronous submission and partial page updates:
|
||||
1. Form fields are rendered server-side with validation state
|
||||
2. HTMX handles POST submission and swaps the response into the page
|
||||
3. Success responses trigger modal display with PDF download link
|
||||
4. Error responses re-render the form with validation errors
|
||||
|
||||
**Test implications:** Unit test validation logic and calculation functions. Integration test the full POST flow. UI test only the happy path.
|
||||
|
||||
### Pattern: Dynamic Line Items
|
||||
Line items are added and removed dynamically without page reload:
|
||||
1. "Add line" button fetches a new row via HTMX
|
||||
2. Each row has description, quantity, unit price inputs, and a delete button
|
||||
3. Delete uses Alpine.js to fade out and remove the row
|
||||
4. Empty line items are filtered out on submission
|
||||
|
||||
**Test implications:** Unit test the filtering and calculation logic. Integration test HTMX endpoints. UI test the add/remove interactions.
|
||||
|
||||
---
|
||||
|
||||
## Create Invoice
|
||||
|
||||
### Form Display Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 1.1 | It should render a new invoice form with breadcrumbs: Invoices > Outgoing > New | UI | [ ] |
|
||||
| 1.2 | It should display a client typeahead field labeled "From (client)" | UI | [ ] |
|
||||
| 1.3 | It should display an invoice number input field | UI | [ ] |
|
||||
| 1.4 | It should display a date picker pre-filled with today's date in `normal-date` format | UI | [ ] |
|
||||
| 1.5 | It should display recipient name "To" field | UI | [ ] |
|
||||
| 1.6 | It should display recipient address fields: street1, street2, city, state, zip | UI | [ ] |
|
||||
| 1.7 | It should display a line items grid with one default empty row | UI | [ ] |
|
||||
| 1.8 | It should display a tax percentage input with default value 10.0 | UI | [ ] |
|
||||
| 1.9 | It should display a "Generate" button to submit the form | UI | [ ] |
|
||||
| 1.10 | It should display an "Add line" button to add more line items | UI | [ ] |
|
||||
|
||||
### Form Validation Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 2.1 | It should require client selection | Integration | [ ] |
|
||||
| 2.2 | It should require invoice date | Integration | [ ] |
|
||||
| 2.3 | It should require recipient name in "To" field | Integration | [ ] |
|
||||
| 2.4 | It should require invoice number | Integration | [ ] |
|
||||
| 2.5 | It should require at least one line item with description, quantity, and unit price | Integration | [ ] |
|
||||
| 2.6 | It should make recipient address street2 optional | Unit | [ ] |
|
||||
| 2.7 | It should strip whitespace from street2 and treat empty as nil | Unit | [ ] |
|
||||
| 2.8 | It should coerce line items from nested form parameters into a vector | Unit | [ ] |
|
||||
| 2.9 | It should display validation errors next to the offending fields | UI | [ ] |
|
||||
| 2.10 | It should redisplay the form with entered data preserved when validation fails | Integration | [ ] |
|
||||
|
||||
### Submission Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 3.1 | It should filter out line items with empty descriptions before calculation | Unit | [ ] |
|
||||
| 3.2 | It should calculate each line item total as `unit-price * quantity` | Unit | [ ] |
|
||||
| 3.3 | It should calculate subtotal as the sum of all line item totals | Unit | [ ] |
|
||||
| 3.4 | It should calculate tax as `subtotal * (tax-rate / 100)` | Unit | [ ] |
|
||||
| 3.5 | It should calculate total as `subtotal + tax` | Unit | [ ] |
|
||||
| 3.6 | It should format monetary values as `$X,XXX.XX` strings before sending to Lambda | Unit | [ ] |
|
||||
| 3.7 | It should format the invoice date as `normal-date` string before sending to Lambda | Unit | [ ] |
|
||||
| 3.8 | It should invoke the `genpdf` Lambda function with a JSON payload | Integration | [ ] |
|
||||
| 3.9 | It should extract the S3 URL from the Lambda response | Integration | [ ] |
|
||||
| 3.10 | It should display a modal with "Download your invoice" and a link to the S3 URL | UI | [ ] |
|
||||
| 3.11 | Given the Lambda invocation fails, then it should display an error without showing a modal | Integration | [ ] |
|
||||
| 3.12 | Given all line items are empty, then subtotal should be `0.0`, tax should be `0.0`, and total should be `0.0` | Unit | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Line Items
|
||||
|
||||
### Add Line Item Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 4.1 | It should fetch a new empty line item row via HTMX when "Add line" is clicked | Integration | [ ] |
|
||||
| 4.2 | It should append the new row to the line items grid | UI | [ ] |
|
||||
| 4.3 | It should render each row with hidden db/id, description input, quantity money-input, unit-price money-input, and delete button | UI | [ ] |
|
||||
| 4.4 | It should allow adding multiple line items | UI | [ ] |
|
||||
|
||||
### Remove Line Item Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 5.1 | It should fade out a line item row over 500ms when the delete button is clicked | UI | [ ] |
|
||||
| 5.2 | It should remove the line item row from the DOM after the fade animation | UI | [ ] |
|
||||
| 5.3 | It should preserve data in remaining line items after deletion | UI | [ ] |
|
||||
| 5.4 | It should allow deleting all line items, leaving the grid empty | UI | [ ] |
|
||||
|
||||
### Calculation Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 6.1 | It should handle negative quantities in line item calculations | Unit | [ ] |
|
||||
| 6.2 | It should show `$0.00` for line items with zero unit price | Unit | [ ] |
|
||||
| 6.3 | It should format large monetary values with comma separators (e.g., `$1,234.56`) | Unit | [ ] |
|
||||
| 6.4 | It should format nil monetary values as `$0.00` | Unit | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## PDF Generation
|
||||
|
||||
### Lambda Integration Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 7.1 | It should invoke `genpdf` Lambda with a JSON payload containing invoice data | Integration | [ ] |
|
||||
| 7.2 | It should include formatted monetary strings in the Lambda payload | Unit | [ ] |
|
||||
| 7.3 | It should include the invoice date as a `normal-date` string in the Lambda payload | Unit | [ ] |
|
||||
| 7.4 | It should extract the S3 URL from a successful Lambda response | Integration | [ ] |
|
||||
| 7.5 | It should present the S3 URL as a clickable download link in the modal | UI | [ ] |
|
||||
|
||||
### Error Handling Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 8.1 | Given the Lambda returns invalid JSON, then it should propagate an error | Integration | [ ] |
|
||||
| 8.2 | Given the S3 URL is inaccessible, then the link should still be presented but may fail on click | UI | [ ] |
|
||||
| 8.3 | Given a very large invoice payload, then Lambda payload size limits may apply | Integration | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Cross-Cutting Behaviors
|
||||
|
||||
### Authentication Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 9.1 | It should redirect unauthenticated users to `/login` | Integration | [ ] |
|
||||
| 9.2 | It should redirect unauthenticated users back to `/outgoing-invoice/new` after login | Integration | [ ] |
|
||||
| 9.3 | It should apply `wrap-secure` middleware to all routes | Integration | [ ] |
|
||||
| 9.4 | It should apply `wrap-trim-client-ids` middleware to requests | Integration | [ ] |
|
||||
|
||||
### Client Selection Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 10.1 | It should populate the client typeahead from the `:company-search` endpoint | Integration | [ ] |
|
||||
| 10.2 | It should only show clients the authenticated user has access to | Integration | [ ] |
|
||||
|
||||
### Tax Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 11.1 | It should treat a whole number tax (e.g., 10) as 10% | Unit | [ ] |
|
||||
| 11.2 | It should treat a decimal tax (e.g., 8.25) as 8.25% | Unit | [ ] |
|
||||
| 11.3 | It should allow tax rates over 100% | Unit | [ ] |
|
||||
| 11.4 | It should calculate total equal to subtotal when tax is zero | Unit | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Test Data Requirements
|
||||
|
||||
| Entity | Requirements |
|
||||
|--------|-------------|
|
||||
| **Users** | Authenticated user with access to at least one client |
|
||||
| **Clients** | Multiple clients with complete profiles including address (name, street, city, state, zip) |
|
||||
| **Form Data** | Valid invoice number strings; valid dates in `normal-date` format; recipient names and addresses |
|
||||
| **Line Items** | Descriptions, quantities (numeric), unit prices (monetary) |
|
||||
| **Tax Rates** | Percentage values (e.g., 10.0 for 10%) |
|
||||
| **AWS Lambda** | Mock `genpdf` Lambda returning valid S3 URL; mock `genpdf` Lambda returning error |
|
||||
|
||||
## Existing Tests to Preserve
|
||||
|
||||
- `test/clj/auto_ap/ssr/outgoing_invoice_test.clj` — Outgoing invoice form rendering, submission, and PDF generation
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Datomic (client lookup for typeahead, address data)
|
||||
- AWS Lambda (`genpdf` function generates PDF from invoice data)
|
||||
- AWS S3 (generated PDFs stored at `data.prod.app.integreatconsult.com/<path>`)
|
||||
- HTMX (form submission, line item row fetching)
|
||||
- Alpine.js (line item row removal animation)
|
||||
- Form cursor (`auto-ap.ssr.form-cursor`) — field state management, error binding
|
||||
260
docs/testing/behaviors/payment.md
Normal file
260
docs/testing/behaviors/payment.md
Normal file
@@ -0,0 +1,260 @@
|
||||
# Payment Behaviors
|
||||
|
||||
## Overview
|
||||
|
||||
Payments represent disbursements to vendors for invoices. The payment system supports multiple payment types (check, cash, debit, balance credit) and tracks payments through statuses (pending, cleared, voided). Payments are created through the invoice payment flow and managed through a searchable grid interface.
|
||||
|
||||
**Testing Philosophy**
|
||||
- Prefer unit tests for pure business logic (calculations, validations, transformations)
|
||||
- Use integration tests for database interactions and cross-system flows
|
||||
- Use UI tests only for end-to-end happy paths that touch multiple pages
|
||||
- Every behavior must be user-visible; no tests for implementation details
|
||||
|
||||
---
|
||||
|
||||
## Testing Patterns
|
||||
|
||||
### Pattern: Grid Page Behaviors
|
||||
Most list pages in Integreat follow the same pattern:
|
||||
1. Fetch IDs via Datomic query with filters
|
||||
2. Hydrate results via `pull-many`
|
||||
3. Render table with sortable columns
|
||||
4. Support selection (individual / all / all-filtered)
|
||||
5. Action buttons appear conditionally based on permissions and selection state
|
||||
|
||||
**Test implications:** Validate the query generation, not the rendering. UI tests only need to verify one filter and one sort work end-to-end.
|
||||
|
||||
### Pattern: Permission Gates
|
||||
Every mutating operation checks:
|
||||
1. `assert-can-see-client` — user has access to the client
|
||||
2. `assert-not-locked` — payment date >= client locked-until
|
||||
3. `can?` — user has the specific permission for the activity
|
||||
|
||||
**Test implications:** Integration test each gate independently. UI tests only verify the happy path with a permitted user.
|
||||
|
||||
### Pattern: Check Generation Behaviors
|
||||
Check printing involves:
|
||||
1. Validation of invoices and bank account
|
||||
2. Sequential check number assignment
|
||||
3. PDF generation with MICR encoding
|
||||
4. S3 upload and storage
|
||||
5. Transaction creation (for cash payments)
|
||||
|
||||
**Test implications:** Integration test the full flow once. Unit test validation logic and PDF content generation.
|
||||
|
||||
---
|
||||
|
||||
## Payment List Page
|
||||
|
||||
### Display Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 1.1 | It should display a table with columns: Client, Vendor, Bank Account, Check #, Status, Date, Amount, Links | UI | [ ] |
|
||||
| 1.2 | It should show the Client column only when viewing payments for multiple clients | Integration | [ ] |
|
||||
| 1.3 | It should hide the Bank Account and Date columns on smaller viewports | UI | [ ] |
|
||||
| 1.4 | It should show "Cleared" status as a primary-colored pill | UI | [ ] |
|
||||
| 1.5 | It should show "Pending" status as a secondary-colored pill | UI | [ ] |
|
||||
| 1.6 | It should show "Voided" status as a red-colored pill | UI | [ ] |
|
||||
| 1.7 | It should render check numbers as links to S3 PDFs when an s3-url exists | UI | [ ] |
|
||||
| 1.8 | It should show plain check number text for payments without an s3-url | UI | [ ] |
|
||||
| 1.9 | It should display a links dropdown showing associated invoices and transactions | UI | [ ] |
|
||||
| 1.10 | It should display checkboxes for bulk selection on each row | UI | [ ] |
|
||||
| 1.11 | It should show no check number for non-check payment types | UI | [ ] |
|
||||
|
||||
### Filtering Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 2.1 | It should filter payments by vendor typeahead selection | Integration | [ ] |
|
||||
| 2.2 | It should filter payments by date range | Integration | [ ] |
|
||||
| 2.3 | It should filter payments by check number (exact match or partial text) | Integration | [ ] |
|
||||
| 2.4 | It should filter payments by invoice number (exact match) | Integration | [ ] |
|
||||
| 2.5 | It should filter payments by amount range (min/max) | Integration | [ ] |
|
||||
| 2.6 | It should filter payments by payment type via radio cards (All, Cash, Check, Debit) | Integration | [ ] |
|
||||
| 2.7 | It should support exact-match navigation to a specific payment by ID, bypassing other filters | Integration | [ ] |
|
||||
| 2.8 | It should filter payments by status via route (`/payments/pending`, `/payments/cleared`, `/payments/voided`) | Integration | [ ] |
|
||||
| 2.9 | It should apply all filters via HTMX with debounced triggers | Integration | [ ] |
|
||||
| 2.10 | It should combine all filters with AND logic | Integration | [ ] |
|
||||
| 2.11 | It should use efficient time-bounded queries for date range filtering | Integration | [ ] |
|
||||
| 2.12 | It should parse check number search as Long when possible, falling back to exact string match | Unit + Integration | [ ] |
|
||||
| 2.13 | Given multiple filters are applied, when the user changes one filter, then the table should refresh with the combined filter set | Integration | [ ] |
|
||||
| 2.14 | It should bypass all other filters when exact-match ID is provided | Integration | [ ] |
|
||||
|
||||
### Sorting Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 3.1 | It should sort by client name ascending/descending | Integration | [ ] |
|
||||
| 3.2 | It should sort by vendor name ascending/descending | Integration | [ ] |
|
||||
| 3.3 | It should sort by bank account ascending/descending | Integration | [ ] |
|
||||
| 3.4 | It should sort by check number ascending/descending | Integration | [ ] |
|
||||
| 3.5 | It should sort by date ascending/descending | Integration | [ ] |
|
||||
| 3.6 | It should sort by amount ascending/descending | Integration | [ ] |
|
||||
| 3.7 | It should sort by status ascending/descending | Integration | [ ] |
|
||||
| 3.8 | Given the user clicks a column header twice, then the sort direction should toggle | Integration | [ ] |
|
||||
|
||||
### Pagination Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 4.1 | It should display 25 payments per page by default | Integration | [ ] |
|
||||
| 4.2 | It should allow changing the per-page count | Integration | [ ] |
|
||||
| 4.3 | It should calculate the total visible float and total float across all matching payments, not just the current page | Unit | [ ] |
|
||||
|
||||
### Selection Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 5.1 | It should allow selecting individual payments via checkboxes | UI | [ ] |
|
||||
| 5.2 | It should allow selecting all visible payments via a header checkbox | UI | [ ] |
|
||||
| 5.3 | It should allow selecting all filtered payments (up to 250) for bulk operations | Integration | [ ] |
|
||||
| 5.4 | Given payments are selected, when the user applies a filter, then the selection should be cleared | Integration | [ ] |
|
||||
|
||||
### Row Action Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 6.1 | It should show a trash icon on each row unless the payment status is already voided | UI | [ ] |
|
||||
| 6.2 | It should prompt for confirmation when clicking the trash icon ("Are you sure you want to void this payment?") | UI | [ ] |
|
||||
| 6.3 | Given confirmation, when voiding a payment, then the row should be removed from the table with animation | UI | [ ] |
|
||||
| 6.4 | It should block voiding cleared check payments | Integration | [ ] |
|
||||
|
||||
### Float Display Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 7.1 | It should display a "Visible in float" pill showing the sum of pending payment amounts in the current filter view | Unit | [ ] |
|
||||
| 7.2 | It should display a "Total in float" pill showing the sum of all pending payments for the selected client(s) | Unit | [ ] |
|
||||
| 7.3 | It should exclude voided payments from float calculations | Unit | [ ] |
|
||||
| 7.4 | It should include only pending status payments in float calculations | Unit | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Bulk Void
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 8.1 | It should show a confirmation modal with warning icon and count of payments to be voided | UI | [ ] |
|
||||
| 8.2 | It should support "Selected only" mode to void only checkboxed payments | UI | [ ] |
|
||||
| 8.3 | It should support "All selected" mode to void all payments matching current filters (up to 250) | Integration | [ ] |
|
||||
| 8.4 | It should require admin permission for bulk void operations | Integration | [ ] |
|
||||
| 8.5 | Given confirmation, when voiding, then the modal should close and a notification should show "Successfully voided X of Y payments" | Integration | [ ] |
|
||||
| 8.6 | It should skip payments that already have transactions and skip already-voided payments | Integration | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Check Printing
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 9.1 | It should generate physical check PDFs with MICR encoding at the bottom | Integration | [ ] |
|
||||
| 9.2 | It should include payee, amount in numbers and words, date, memo, bank info, and client signature image | Integration | [ ] |
|
||||
| 9.3 | It should generate voucher copies with full invoice details below the check | Integration | [ ] |
|
||||
| 9.4 | It should store check PDFs in S3 under `checks/{uuid}.pdf` | Integration | [ ] |
|
||||
| 9.5 | It should assign check numbers sequentially from the bank account's check number | Integration | [ ] |
|
||||
| 9.6 | It should increment the bank account's check number by the number of vendors paid | Integration | [ ] |
|
||||
| 9.7 | It should validate that the bank account has a starting check number | Integration | [ ] |
|
||||
| 9.8 | It should merge multiple checks into a single PDF at `merged-checks/{uuid}.pdf` | Integration | [ ] |
|
||||
| 9.9 | It should group invoices by vendor, creating one check per vendor per batch | Integration | [ ] |
|
||||
| 9.10 | It should validate that all invoices belong to the same client and the selected bank account belongs to the same client | Integration | [ ] |
|
||||
| 9.11 | It should reject check creation if the total amount is <= $0.00 | Integration | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## ACH Payments
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 10.1 | It should create pending payments with `payment-type/debit` | Integration | [ ] |
|
||||
| 10.2 | It should not generate check PDFs for ACH payments | Integration | [ ] |
|
||||
| 10.3 | It should not create transactions for ACH payments | Integration | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Balance Credit Payments
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 11.1 | It should allow paying invoices from existing vendor credit with `payment-type/balance-credit` | Integration | [ ] |
|
||||
| 11.2 | It should block balance credit payments when multiple vendors are selected | Integration | [ ] |
|
||||
| 11.3 | It should offset positive-balance invoices against negative-balance invoices | Integration | [ ] |
|
||||
| 11.4 | It should create a single cleared payment for the net amount, consuming credit invoices first-in | Integration | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Cash Payments
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 12.1 | It should create payments with `payment-type/cash` automatically marked as cleared | Integration | [ ] |
|
||||
| 12.2 | It should create an associated transaction with POSTED status | Integration | [ ] |
|
||||
| 12.3 | It should use the account with numeric code 21000 for cash payment transactions | Integration | [ ] |
|
||||
| 12.4 | It should set the payment date to the latest invoice date | Integration | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Cross-Cutting Behaviors
|
||||
|
||||
### Voiding Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 13.1 | It should allow voiding pending payments | Integration | [ ] |
|
||||
| 13.2 | It should allow voiding cash, debit, and balance-credit payments even when cleared | Integration | [ ] |
|
||||
| 13.3 | It should block voiding cleared check payments | Integration | [ ] |
|
||||
| 13.4 | It should set the payment amount to 0.0 when voided | Integration | [ ] |
|
||||
| 13.5 | It should set the payment status to voided | Integration | [ ] |
|
||||
| 13.6 | It should remove all invoice-payment links when voiding | Integration | [ ] |
|
||||
| 13.7 | It should restore invoice outstanding balances by adding back the invoice-payment amount | Integration | [ ] |
|
||||
| 13.8 | It should revert invoice status to unpaid when restored balance becomes non-zero | Integration | [ ] |
|
||||
| 13.9 | It should unlink associated transactions when voiding | Integration | [ ] |
|
||||
|
||||
### Permission Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 14.1 | It should require client visibility for viewing payments | Integration | [ ] |
|
||||
| 14.2 | It should require client visibility for voiding individual payments | Integration | [ ] |
|
||||
| 14.3 | It should require admin permission for bulk voiding payments | Integration | [ ] |
|
||||
| 14.4 | It should allow viewing S3 check PDFs to all users who can see the payment | Integration | [ ] |
|
||||
|
||||
### Lock Date Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 15.1 | It should block voiding payments dated before the client's locked-until date | Integration | [ ] |
|
||||
| 15.2 | It should check lock dates on individual void operations | Integration | [ ] |
|
||||
| 15.3 | It should check lock dates on bulk void operations | Integration | [ ] |
|
||||
| 15.4 | It should exclude locked payments from bulk void results | Integration | [ ] |
|
||||
| 15.5 | It should show a warning when some selected payments are locked | UI | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Test Data Requirements
|
||||
|
||||
| Entity | Requirements |
|
||||
|--------|-------------|
|
||||
| **Clients** | Multiple clients with different locations; some with locked-until dates |
|
||||
| **Vendors** | With/without default accounts; with/without terms and autopay settings |
|
||||
| **Bank Accounts** | Check, cash, and credit types; with/without starting check numbers |
|
||||
| **Invoices** | Various statuses, dates, amounts; positive and negative balances |
|
||||
| **Payments** | Check, cash, debit, and balance-credit types; pending, cleared, and voided statuses |
|
||||
| **Transactions** | Linked to cash payments with POSTED status |
|
||||
| **Files** | Check PDFs stored in S3 |
|
||||
|
||||
## Existing Tests to Preserve
|
||||
|
||||
- `test/clj/auto_ap/integration/graphql/checks.clj` — Check/payment GraphQL operations
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Datomic for payment/invoice/transaction persistence
|
||||
- S3 for check PDF storage and retrieval
|
||||
- Solr for index updates after payment mutations
|
||||
- HTMX + Alpine.js for interactive grid behavior
|
||||
- `clj-pdf` for check PDF generation
|
||||
- `clj-time` for date parsing and coercion
|
||||
- `auto-ap.datomic/scan-payments` for efficient date-range queries
|
||||
- `auto-ap.permissions/can?` for permission checks
|
||||
- `auto-ap.datomic/audit-transact` for all mutations
|
||||
405
docs/testing/behaviors/pos.md
Normal file
405
docs/testing/behaviors/pos.md
Normal file
@@ -0,0 +1,405 @@
|
||||
# POS Behaviors
|
||||
|
||||
## Overview
|
||||
|
||||
The POS (Point of Sale) module provides SSR (HTMX-driven) grid pages for viewing sales data imported from payment processors. All pages share a common grid layout with filters, sortable columns, and pagination. Data is read-only except for the admin Sales Summaries edit wizard.
|
||||
|
||||
**Testing Philosophy**
|
||||
- Prefer unit tests for pure business logic (calculations, validations, transformations)
|
||||
- Use integration tests for database interactions and cross-system flows
|
||||
- Use UI tests only for end-to-end happy paths that touch multiple pages
|
||||
- Every behavior must be user-visible; no tests for implementation details
|
||||
|
||||
---
|
||||
|
||||
## Testing Patterns
|
||||
|
||||
### Pattern: Grid Page Behaviors
|
||||
Most list pages in Integreat follow the same pattern:
|
||||
1. Fetch IDs via Datomic query with filters
|
||||
2. Hydrate results via `pull-many`
|
||||
3. Render table with sortable columns
|
||||
4. Support selection (individual / all / all-filtered)
|
||||
5. Action buttons appear conditionally based on permissions and selection state
|
||||
|
||||
**Test implications:** Validate the query generation, not the rendering. UI tests only need to verify one filter and one sort work end-to-end.
|
||||
|
||||
### Pattern: Wizard Behaviors
|
||||
Wizards are multi-step forms with HTMX-driven navigation:
|
||||
1. Each step is a GET that renders a form fragment
|
||||
2. Form submissions are POST/PUT with validation
|
||||
3. Navigation between steps updates the wizard state
|
||||
4. Final submit creates/updates the entity
|
||||
|
||||
**Test implications:** Unit test validation logic and state transitions. Integration test the full wizard flow once. UI test only the happy path.
|
||||
|
||||
### Pattern: Permission Gates
|
||||
Every mutating operation checks:
|
||||
1. `assert-can-see-client` — user has access to the client
|
||||
2. `assert-not-locked` — invoice date >= client locked-until
|
||||
3. `can?` — user has the specific permission for the activity
|
||||
|
||||
**Test implications:** Integration test each gate independently. UI tests only verify the happy path with a permitted user.
|
||||
|
||||
---
|
||||
|
||||
## Sales Orders
|
||||
|
||||
### Display Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 1.1 | It should display a table with columns: Client, Date, Source, Total, Tax, Tip, Payment Methods | UI | [ ] |
|
||||
| 1.2 | It should show the Client column only when multiple clients are in scope | Integration | [ ] |
|
||||
| 1.3 | It should render the Source column as a pill badge | UI | [ ] |
|
||||
| 1.4 | It should render each unique payment method as a pill in the Payment Methods column (cash, card, gift card, other) | UI | [ ] |
|
||||
| 1.5 | It should display action buttons above the grid showing Total $ and Tax $ pills summarizing the currently filtered result set | UI | [ ] |
|
||||
| 1.6 | It should show an external link icon row button when the sales order has a reference link | UI | [ ] |
|
||||
|
||||
### Filtering Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 2.1 | It should filter sales orders by date range (start date / end date) | Integration | [ ] |
|
||||
| 2.2 | It should filter sales orders by total amount range (min / max) | Integration | [ ] |
|
||||
| 2.3 | It should filter sales orders by payment method via radio cards: All, Cash, Card, Gift Card, Other | Integration | [ ] |
|
||||
| 2.4 | It should filter sales orders by processor via radio cards: All, Square, Doordash, Uber Eats, Grubhub, Koala, EZCater, No Processor | Integration | [ ] |
|
||||
| 2.5 | It should filter sales orders by category text input matching order line item category | Integration | [ ] |
|
||||
| 2.6 | Given multiple filters are applied, when the user changes one filter, then the table should refresh with the combined filter set | Integration | [ ] |
|
||||
|
||||
### Sorting Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 3.1 | It should sort by client name ascending/descending | Integration | [ ] |
|
||||
| 3.2 | It should sort by date ascending/descending | Integration | [ ] |
|
||||
| 3.3 | It should sort by total amount ascending/descending | Integration | [ ] |
|
||||
| 3.4 | It should sort by tax amount ascending/descending | Integration | [ ] |
|
||||
| 3.5 | It should sort by tip amount ascending/descending | Integration | [ ] |
|
||||
| 3.6 | It should sort by source ascending/descending | Integration | [ ] |
|
||||
| 3.7 | It should sort by processor ascending/descending | Integration | [ ] |
|
||||
| 3.8 | Given the user clicks a column header twice, then the sort direction should toggle | Integration | [ ] |
|
||||
|
||||
### Pagination Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 4.1 | It should display 25 sales orders per page by default | Integration | [ ] |
|
||||
| 4.2 | It should allow changing the per-page count | Integration | [ ] |
|
||||
| 4.3 | It should calculate the total amount and tax across ALL matching sales orders, not just the current page | Unit | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Expected Deposits
|
||||
|
||||
### Display Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 5.1 | It should display a table with columns: Client, Date, Sales Date, Total, Fee | UI | [ ] |
|
||||
| 5.2 | It should show the Client column only when multiple clients are in scope | Integration | [ ] |
|
||||
| 5.3 | It should show a totals breakdown per expected deposit, aggregating charges by sales date with count and amount | UI | [ ] |
|
||||
| 5.4 | It should show an external link icon row button when the expected deposit has a reference link | UI | [ ] |
|
||||
| 5.5 | It should show a "Transaction" button linking to the associated transaction when one exists | UI | [ ] |
|
||||
|
||||
### Filtering Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 6.1 | It should filter expected deposits by date range | Integration | [ ] |
|
||||
| 6.2 | It should support exact match ID to jump to a specific record, showing a removable pill when active | Integration | [ ] |
|
||||
| 6.3 | Given multiple filters are applied, when the user changes one filter, then the table should refresh with the combined filter set | Integration | [ ] |
|
||||
|
||||
### Sorting Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 7.1 | It should sort by client name ascending/descending | Integration | [ ] |
|
||||
| 7.2 | It should sort by location ascending/descending | Integration | [ ] |
|
||||
| 7.3 | It should sort by date ascending/descending | Integration | [ ] |
|
||||
| 7.4 | It should sort by total amount ascending/descending | Integration | [ ] |
|
||||
| 7.5 | It should sort by fee amount ascending/descending | Integration | [ ] |
|
||||
| 7.6 | Given the user clicks a column header twice, then the sort direction should toggle | Integration | [ ] |
|
||||
|
||||
### Pagination Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 8.1 | It should display 25 expected deposits per page by default | Integration | [ ] |
|
||||
| 8.2 | It should allow changing the per-page count | Integration | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Tenders
|
||||
|
||||
### Display Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 9.1 | It should display a table with columns: Client, Date, Total, Processor, Tip, Links | UI | [ ] |
|
||||
| 9.2 | It should show the Client column only when multiple clients are in scope | Integration | [ ] |
|
||||
| 9.3 | It should render the Processor column as a pill badge | UI | [ ] |
|
||||
| 9.4 | It should show an external link icon row button when the tender has a reference link | UI | [ ] |
|
||||
| 9.5 | It should show an "expected deposit" pill in the Links column when an associated expected deposit exists | UI | [ ] |
|
||||
|
||||
### Filtering Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 10.1 | It should filter tenders by date range | Integration | [ ] |
|
||||
| 10.2 | It should filter tenders by processor via radio cards: All, Square, Doordash, Uber Eats, Grubhub, Koala, EZCater, No Processor | Integration | [ ] |
|
||||
| 10.3 | It should filter tenders by total amount range (min / max) | Integration | [ ] |
|
||||
| 10.4 | Given multiple filters are applied, when the user changes one filter, then the table should refresh with the combined filter set | Integration | [ ] |
|
||||
|
||||
### Sorting Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 11.1 | It should sort by client name ascending/descending | Integration | [ ] |
|
||||
| 11.2 | It should sort by date ascending/descending | Integration | [ ] |
|
||||
| 11.3 | It should sort by total amount ascending/descending | Integration | [ ] |
|
||||
| 11.4 | It should sort by tip amount ascending/descending | Integration | [ ] |
|
||||
| 11.5 | It should sort by processor ascending/descending | Integration | [ ] |
|
||||
| 11.6 | Given the user clicks a column header twice, then the sort direction should toggle | Integration | [ ] |
|
||||
|
||||
### Pagination Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 12.1 | It should display 25 tenders per page by default | Integration | [ ] |
|
||||
| 12.2 | It should allow changing the per-page count | Integration | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Refunds
|
||||
|
||||
### Display Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 13.1 | It should display a table with columns: Client, Date, Total, Type, Fee | UI | [ ] |
|
||||
| 13.2 | It should show the Client column only when multiple clients are in scope | Integration | [ ] |
|
||||
|
||||
### Filtering Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 14.1 | It should filter refunds by date range | Integration | [ ] |
|
||||
| 14.2 | It should filter refunds by total amount range (min / max) | Integration | [ ] |
|
||||
| 14.3 | Given multiple filters are applied, when the user changes one filter, then the table should refresh with the combined filter set | Integration | [ ] |
|
||||
|
||||
### Sorting Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 15.1 | It should sort by client name ascending/descending | Integration | [ ] |
|
||||
| 15.2 | It should sort by date ascending/descending | Integration | [ ] |
|
||||
| 15.3 | It should sort by total amount ascending/descending | Integration | [ ] |
|
||||
| 15.4 | It should sort by fee amount ascending/descending | Integration | [ ] |
|
||||
| 15.5 | It should sort by type ascending/descending | Integration | [ ] |
|
||||
| 15.6 | Given the user clicks a column header twice, then the sort direction should toggle | Integration | [ ] |
|
||||
|
||||
### Pagination Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 16.1 | It should display 25 refunds per page by default | Integration | [ ] |
|
||||
| 16.2 | It should allow changing the per-page count | Integration | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Cash Drawer Shifts
|
||||
|
||||
### Display Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 17.1 | It should display a table with columns: Client, Date, Paid in, Paid out, Expected cash, Opened cash | UI | [ ] |
|
||||
| 17.2 | It should show the Client column only when multiple clients are in scope | Integration | [ ] |
|
||||
|
||||
### Filtering Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 18.1 | It should filter cash drawer shifts by date range | Integration | [ ] |
|
||||
| 18.2 | It should filter cash drawer shifts by total amount range (min / max) | Integration | [ ] |
|
||||
| 18.3 | Given multiple filters are applied, when the user changes one filter, then the table should refresh with the combined filter set | Integration | [ ] |
|
||||
|
||||
### Sorting Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 19.1 | It should sort by client name ascending/descending | Integration | [ ] |
|
||||
| 19.2 | It should sort by date ascending/descending | Integration | [ ] |
|
||||
| 19.3 | It should sort by paid-in amount ascending/descending | Integration | [ ] |
|
||||
| 19.4 | It should sort by paid-out amount ascending/descending | Integration | [ ] |
|
||||
| 19.5 | It should sort by expected-cash amount ascending/descending | Integration | [ ] |
|
||||
| 19.6 | It should sort by opened-cash amount ascending/descending | Integration | [ ] |
|
||||
| 19.7 | Given the user clicks a column header twice, then the sort direction should toggle | Integration | [ ] |
|
||||
|
||||
### Pagination Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 20.1 | It should display 25 cash drawer shifts per page by default | Integration | [ ] |
|
||||
| 20.2 | It should allow changing the per-page count | Integration | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Sales Summaries
|
||||
|
||||
### Display Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 21.1 | It should display a table with columns: Client, Date, Debits, Credits | UI | [ ] |
|
||||
| 21.2 | It should show the Client column only when multiple clients are in scope | Integration | [ ] |
|
||||
| 21.3 | It should display debit-line items with category and amount | UI | [ ] |
|
||||
| 21.4 | It should display credit-line items with category and amount | UI | [ ] |
|
||||
| 21.5 | It should show a red "missing account" warning pill for unmapped items | UI | [ ] |
|
||||
| 21.6 | It should show a green "Total" pill for balanced summaries (debits equal credits) | UI | [ ] |
|
||||
| 21.7 | It should show a red "Total" pill for unbalanced summaries (debits do not equal credits) | UI | [ ] |
|
||||
| 21.8 | It should show an edit (pencil) icon row button opening the edit wizard modal | UI | [ ] |
|
||||
|
||||
### Filtering Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 22.1 | It should filter sales summaries by date range | Integration | [ ] |
|
||||
| 22.2 | Given multiple filters are applied, when the user changes one filter, then the table should refresh with the combined filter set | Integration | [ ] |
|
||||
|
||||
### Sorting Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 23.1 | It should sort by client name ascending/descending | Integration | [ ] |
|
||||
| 23.2 | It should sort by date ascending/descending | Integration | [ ] |
|
||||
| 23.3 | It should sort by debits ascending/descending | Integration | [ ] |
|
||||
| 23.4 | It should sort by credits ascending/descending | Integration | [ ] |
|
||||
| 23.5 | Given the user clicks a column header twice, then the sort direction should toggle | Integration | [ ] |
|
||||
|
||||
### Pagination Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 24.1 | It should display 25 sales summaries per page by default | Integration | [ ] |
|
||||
| 24.2 | It should allow changing the per-page count | Integration | [ ] |
|
||||
|
||||
### Edit Wizard Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 25.1 | It should open a modal dialog when the edit button is clicked | UI | [ ] |
|
||||
| 25.2 | It should display a data grid of summary items with columns: Category, Account, Debits, Credits | UI | [ ] |
|
||||
| 25.3 | It should render auto items (non-manual) with read-only Category and amount, editable Account via typeahead | UI | [ ] |
|
||||
| 25.4 | It should render manual items with editable Category (text input), Account (typeahead), Debit amount, and Credit amount | UI | [ ] |
|
||||
| 25.5 | It should allow adding new manual items via a "New Summary Item" row | UI | [ ] |
|
||||
| 25.6 | It should allow removing manual items via an X button | UI | [ ] |
|
||||
| 25.7 | It should validate that an item cannot have both credit and debit amounts | Unit + Integration | [ ] |
|
||||
| 25.8 | It should display a total row with running totals for debits and credits | UI | [ ] |
|
||||
| 25.9 | It should display an unbalanced row showing the difference when debits do not equal credits | UI | [ ] |
|
||||
| 25.10 | It should search accounts scoped to the client with purpose "invoice" in the account typeahead | Integration | [ ] |
|
||||
| 25.11 | It should flash the row and close the modal after successful save | UI | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Cross-Cutting Behaviors
|
||||
|
||||
### HTMX Live Filtering Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 26.1 | It should trigger table refresh on filter form change with a 500ms debounce | Integration | [ ] |
|
||||
| 26.2 | It should trigger table refresh on hot-filter keyup with a 1000ms debounce | Integration | [ ] |
|
||||
| 26.3 | It should POST to the table route and swap the grid contents | Integration | [ ] |
|
||||
| 26.4 | It should update the browser URL via hx-push-url when filters change | Integration | [ ] |
|
||||
|
||||
### Date Range Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 27.1 | It should support start-date and end-date query params on all pages | Integration | [ ] |
|
||||
| 27.2 | It should render the date range filter consistently across all pages | UI | [ ] |
|
||||
| 27.3 | Given a date range with no start or end date, then the query should use scan functions with nil boundaries | Integration | [ ] |
|
||||
|
||||
### Total Range Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 28.1 | It should support total-gte and total-lte query params on pages with amount filters | Integration | [ ] |
|
||||
| 28.2 | It should render money inputs for the total range filter | UI | [ ] |
|
||||
|
||||
### Exact Match ID Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 29.1 | It should support exact-match-id to jump to a specific record on applicable pages | Integration | [ ] |
|
||||
| 29.2 | It should show a removable pill when exact-match-id is active | UI | [ ] |
|
||||
|
||||
### Sorting Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 30.1 | It should toggle ascending/descending sort when a sortable column header is clicked | Integration | [ ] |
|
||||
| 30.2 | It should support multi-sort with active sorts appearing as removable pills above the grid | Integration | [ ] |
|
||||
| 30.3 | It should remove a sort when the X on its pill is clicked | Integration | [ ] |
|
||||
| 30.4 | It should default to sort by date descending for most pages | Integration | [ ] |
|
||||
|
||||
### Pagination Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 31.1 | It should display first/previous/next/last pagination controls | UI | [ ] |
|
||||
| 31.2 | It should display the total count above the grid | UI | [ ] |
|
||||
|
||||
### Client Scoping Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 32.1 | It should scope all queries to the user's accessible clients (trimmed to max 20) | Integration | [ ] |
|
||||
| 32.2 | It should hide the Client column when only one client is in scope | Integration | [ ] |
|
||||
| 32.3 | It should support client-id and client-code URL params | Integration | [ ] |
|
||||
|
||||
### Permission Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 33.1 | It should require `(can? identity {:subject :sales :activity :read})` to access POS pages | Integration | [ ] |
|
||||
| 33.2 | It should require admin access (`wrap-admin`) to access Sales Summaries | Integration | [ ] |
|
||||
| 33.3 | It should redirect unauthenticated users | Integration | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Test Data Requirements
|
||||
|
||||
| Entity | Requirements |
|
||||
|--------|-------------|
|
||||
| **Clients** | Multiple clients with `db/id`, `client/name`, `client/code`; some with locked-until dates |
|
||||
| **Sales Orders** | With `:sales-order/date`, `:sales-order/total`, `:sales-order/tax`, `:sales-order/tip`, `:sales-order/source`, `:sales-order/charges` (with `charge/type-name`, `charge/processor`), `:sales-order/line-items` (with `order-line-item/category`) |
|
||||
| **Expected Deposits** | With `:expected-deposit/date`, `:expected-deposit/total`, `:expected-deposit/fee`, `:expected-deposit/client`, optional `transaction/_expected-deposit` |
|
||||
| **Tenders (Charges)** | With `:charge/date`, `:charge/total`, `:charge/tip`, `:charge/processor`, optional `expected-deposit/_charges` |
|
||||
| **Refunds** | With `:sales-refund/date`, `:sales-refund/total`, `:sales-refund/fee`, `:sales-refund/type` |
|
||||
| **Cash Drawer Shifts** | With `:cash-drawer-shift/date`, `:cash-drawer-shift/paid-in`, `:cash-drawer-shift/paid-out`, `:cash-drawer-shift/expected-cash`, `:cash-drawer-shift/opened-cash` |
|
||||
| **Sales Summaries** | With `:sales-summary/date`, `:sales-summary/client`, `:sales-summary/items` (with `ledger-mapped/ledger-side`, `ledger-mapped/amount`, `sales-summary-item/category`, optional `ledger-mapped/account`) |
|
||||
| **Accounts** | With purpose "invoice", scoped to clients, for Sales Summaries edit wizard |
|
||||
|
||||
## Existing Tests to Preserve
|
||||
|
||||
- `test/clj/auto_ap/ssr/pos/sales_orders_test.clj` — Sales orders grid behaviors
|
||||
- `test/clj/auto_ap/ssr/pos/expected_deposits_test.clj` — Expected deposits grid behaviors
|
||||
- `test/clj/auto_ap/ssr/pos/tenders_test.clj` — Tenders grid behaviors
|
||||
- `test/clj/auto_ap/ssr/pos/refunds_test.clj` — Refunds grid behaviors
|
||||
- `test/clj/auto_ap/ssr/pos/cash_drawer_shifts_test.clj` — Cash drawer shifts grid behaviors
|
||||
- `test/clj/auto_ap/ssr/admin/sales_summaries_test.clj` — Sales summaries admin behaviors
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Datomic (primary store)
|
||||
- HTMX/Alpine.js (frontend interactivity)
|
||||
- Grid system: `auto-ap.ssr.grid-page-helper` provides `build`, `page-route`, `table-route`, `row*`, `table*`
|
||||
- Components: `auto-ap.ssr.components` for data grids, pills, buttons, inputs
|
||||
- Querying: `auto-ap.datomic` for `query2`, `pull-many`, `apply-pagination`, `apply-sort-3`
|
||||
- Ions: `iol-ion.query/scan-sales-orders`, `scan-expected-deposits`, `scan-charges`, `scan-sales-refunds`, `scan-cash-drawer-shifts`
|
||||
- Permissions: `auto-ap.permissions/can?`
|
||||
- Time: `auto-ap.time` for date formatting and localization
|
||||
- Schema validation: Malli schemas enforce query params on every request
|
||||
167
docs/testing/behaviors/search-indicators.md
Normal file
167
docs/testing/behaviors/search-indicators.md
Normal file
@@ -0,0 +1,167 @@
|
||||
# Search & Indicators Behaviors
|
||||
|
||||
## Overview
|
||||
|
||||
The Search subsystem provides a global full-text search dialog accessible from the main navigation bar. It queries a Solr index of invoices, payments, transactions, and journal entries, returning results filtered by the user's client permissions. The Indicators subsystem provides small UI utilities like relative date badges (e.g., "5 days ago") used across the application.
|
||||
|
||||
**Testing Philosophy**
|
||||
- Prefer unit tests for pure business logic (query parsing, date formatting, number formatting)
|
||||
- Use integration tests for database interactions and cross-system flows
|
||||
- Use UI tests only for end-to-end happy paths that touch multiple pages
|
||||
- Every behavior must be user-visible; no tests for implementation details
|
||||
|
||||
---
|
||||
|
||||
## Testing Patterns
|
||||
|
||||
### Pattern: Search Query Parsing
|
||||
Search queries are transformed before being sent to Solr:
|
||||
1. Bare words become text search clauses joined with `AND`
|
||||
2. Quoted phrases are preserved as exact tokens
|
||||
3. Type keywords (`invoice`, `payment`, `transaction`, `journal-entry`) filter by document type
|
||||
4. Dates in normal or ISO format are converted to Solr date format
|
||||
5. Decimal numbers are formatted to 2 decimal places
|
||||
6. Unparseable tokens pass through unchanged
|
||||
|
||||
**Test implications:** Unit test each transformation rule. Integration test the full query pipeline once.
|
||||
|
||||
### Pattern: Permission Filtering
|
||||
All search results are filtered by the user's client access permissions.
|
||||
|
||||
**Test implications:** Integration test with users having different client access levels.
|
||||
|
||||
---
|
||||
|
||||
## Search
|
||||
|
||||
### Modal Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 1.1 | It should open a search modal when the user clicks the search icon in the navbar | UI | [ ] |
|
||||
| 1.2 | It should display an autofocused search input with placeholder text in the modal | UI | [ ] |
|
||||
| 1.3 | It should trigger a search after 300ms debounce when the user types in the search input | Integration | [ ] |
|
||||
| 1.4 | It should display the search modal without results when no query is provided | Integration | [ ] |
|
||||
|
||||
### Query Parsing Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 2.1 | It should convert bare words in the search query to text search clauses joined with `AND` | Unit | [ ] |
|
||||
| 2.2 | It should preserve quoted phrases in the search query as exact text matches | Unit | [ ] |
|
||||
| 2.3 | It should filter by document type when the user includes a type keyword (`invoice`, `payment`, `transaction`, `journal-entry`) | Unit | [ ] |
|
||||
| 2.4 | It should convert dates in normal format (e.g., `5/5/2034`) to Solr-compatible date format | Unit | [ ] |
|
||||
| 2.5 | It should convert dates in ISO format to Solr-compatible date format | Unit | [ ] |
|
||||
| 2.6 | It should pass through unparseable dates unchanged | Unit | [ ] |
|
||||
| 2.7 | It should format decimal numbers to exactly 2 decimal places with `HALF_UP` rounding | Unit | [ ] |
|
||||
| 2.8 | It should pass through integer numbers unchanged | Unit | [ ] |
|
||||
| 2.9 | It should handle mixed type keywords and text tokens in the search query | Unit | [ ] |
|
||||
| 2.10 | It should handle multiple type keywords in the search query | Unit | [ ] |
|
||||
|
||||
### Results Display Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 3.1 | It should display search results as cards below the search input | UI | [ ] |
|
||||
| 3.2 | It should show a type icon on each result card | UI | [ ] |
|
||||
| 3.3 | It should show the document type name on each result card | UI | [ ] |
|
||||
| 3.4 | It should show the client code as a pill on each result card | UI | [ ] |
|
||||
| 3.5 | It should show the amount as a pill on each result card | UI | [ ] |
|
||||
| 3.6 | It should show the vendor name as a pill when present on each result card | UI | [ ] |
|
||||
| 3.7 | It should show the date on each result card | UI | [ ] |
|
||||
| 3.8 | It should show the description or number on each result card | UI | [ ] |
|
||||
| 3.9 | It should link each result card to the appropriate detail page with an `exact-match-id` parameter | Integration | [ ] |
|
||||
| 3.10 | It should open the detail page in a new tab when the user clicks the external link icon | UI | [ ] |
|
||||
| 3.11 | It should filter results to only show documents from clients the user can access | Integration | [ ] |
|
||||
|
||||
### Empty Results Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 4.1 | It should display "No results found." when the query matches no documents | UI | [ ] |
|
||||
| 4.2 | It should return an empty results list when the user has no accessible clients | Integration | [ ] |
|
||||
| 4.3 | It should return an empty results list when Solr is unavailable | Integration | [ ] |
|
||||
|
||||
### Type Filter Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 5.1 | It should filter results to only show payment documents when the user types "payment" | Integration | [ ] |
|
||||
| 5.2 | It should filter results to only show invoice documents when the user types "invoice" | Integration | [ ] |
|
||||
| 5.3 | It should show the payment icon and link to the payments detail page for payment results | UI | [ ] |
|
||||
|
||||
### Date Search Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 6.1 | It should return documents matching a date in normal format (e.g., `5/5/2034`) | Integration | [ ] |
|
||||
| 6.2 | It should return documents matching a date in ISO format | Integration | [ ] |
|
||||
| 6.3 | It should accept future dates in the search query | Integration | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Indicators
|
||||
|
||||
### Days-Ago Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 7.1 | It should display a relative date badge showing "N days ago" for past dates | UI | [ ] |
|
||||
| 7.2 | It should display a relative date badge showing "N days from now" for future dates | UI | [ ] |
|
||||
| 7.3 | It should show the days-ago badge in primary color for dates less than 30 days old | Unit + UI | [ ] |
|
||||
| 7.4 | It should show the days-ago badge in secondary color for dates 30-59 days old | Unit + UI | [ ] |
|
||||
| 7.5 | It should show the days-ago badge in yellow color for dates 60-89 days old | Unit + UI | [ ] |
|
||||
| 7.6 | It should show the days-ago badge in red color for dates 90 or more days old | Unit + UI | [ ] |
|
||||
| 7.7 | It should show "0 days ago" with primary color for same-day dates | Unit + UI | [ ] |
|
||||
| 7.8 | It should render an empty indicator when the date is nil | Unit + UI | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Cross-Cutting Behaviors
|
||||
|
||||
### Permission Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 8.1 | It should require authentication to access the search endpoint | Integration | [ ] |
|
||||
| 8.2 | It should require authentication to access the days-ago endpoint | Integration | [ ] |
|
||||
| 8.3 | It should redirect unauthenticated users to the login page when accessing search or days-ago | Integration | [ ] |
|
||||
|
||||
### Error Handling Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 9.1 | It should reject invalid date formats for the days-ago endpoint via schema validation | Integration | [ ] |
|
||||
| 9.2 | It should handle special characters in the search query without errors | Integration | [ ] |
|
||||
| 9.3 | It should handle very long search queries without UI breakage | UI | [ ] |
|
||||
| 9.4 | It should handle numeric tokens with commas or currency symbols as literal text search | Unit | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Test Data Requirements
|
||||
|
||||
| Entity | Requirements |
|
||||
|--------|-------------|
|
||||
| **Solr Index** | Indexed documents of all four types: `invoice`, `payment`, `transaction`, `journal-entry`; spanning multiple clients; with varying dates, amounts, descriptions, numbers, and vendor names |
|
||||
| **Users** | Authenticated user with access to subset of clients; authenticated user with access to all clients; admin user |
|
||||
| **Clients** | Multiple clients with `:client/code` and `:client/name` |
|
||||
| **Invoices** | With `:invoice/invoice-number`, `:invoice/total`, `:invoice/date`, `:invoice/client`, `:invoice/vendor` |
|
||||
| **Payments** | With `:payment/check-number`, `:payment/amount`, `:payment/date`, `:payment/client`, `:payment/vendor` |
|
||||
| **Transactions** | With `:transaction/description-original`, `:transaction/amount`, `:transaction/date`, `:transaction/client`, `:transaction/vendor` |
|
||||
| **Journal Entries** | With `:journal-entry/amount`, `:journal-entry/date`, `:journal-entry/client`, `:journal-entry/vendor`, `:journal-entry/line-items` |
|
||||
|
||||
## Existing Tests to Preserve
|
||||
|
||||
No existing test files specified for Search & Indicators.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Solr**: Full-text search index (`auto-ap.solr`). Uses `MockSolrClient` in test environments without Solr configured
|
||||
- **Datomic**: Client visibility checks pull user/client associations
|
||||
- **HTMX**: Modal loading, search debounce (`keyup changed delay:300ms`), indicator spinner
|
||||
- **Alpine.js**: Modal card structure
|
||||
- **Middleware**: `wrap-secure` requires authentication; `wrap-client-redirect-unauthenticated` redirects unauthenticated to `/login`; `wrap-schema-enforce` validates `date` query parameter for `/days-ago`
|
||||
- **Invoices**: Search results link to invoice detail pages
|
||||
- **Payments**: Search results link to payment detail pages
|
||||
- **Transactions**: Search results link to transaction detail pages
|
||||
- **Ledger**: Search results link to ledger for journal entries
|
||||
310
docs/testing/behaviors/transaction.md
Normal file
310
docs/testing/behaviors/transaction.md
Normal file
@@ -0,0 +1,310 @@
|
||||
# Transaction Behaviors
|
||||
|
||||
## Overview
|
||||
|
||||
Transactions represent bank account activity imported from external sources (Plaid, Yodlee, Intuit). The SSR transaction pages provide an HTMX-based grid view for browsing, filtering, and exporting transactions, plus an admin insights page for AI-assisted vendor/account coding.
|
||||
|
||||
**Testing Philosophy**
|
||||
- Prefer unit tests for pure business logic (calculations, validations, transformations)
|
||||
- Use integration tests for database interactions and cross-system flows
|
||||
- Use UI tests only for end-to-end happy paths that touch multiple pages
|
||||
- Every behavior must be user-visible; no tests for implementation details
|
||||
|
||||
---
|
||||
|
||||
## Testing Patterns
|
||||
|
||||
### Pattern: Grid Page Behaviors
|
||||
Most list pages in Integreat follow the same pattern:
|
||||
1. Fetch IDs via Datomic query with filters
|
||||
2. Hydrate results via `pull-many`
|
||||
3. Render table with sortable columns
|
||||
4. Support selection (individual / all / all-filtered)
|
||||
5. Action buttons appear conditionally based on permissions and selection state
|
||||
|
||||
**Test implications:** Validate the query generation, not the rendering. UI tests only need to verify one filter and one sort work end-to-end.
|
||||
|
||||
### Pattern: Admin Insights Behaviors
|
||||
Insights pages display AI recommendations for coding transactions:
|
||||
1. Fetch unapproved transactions with `outcome-recommendation` data
|
||||
2. Display recommendation buttons sorted by frequency
|
||||
3. Allow approving (coding) or rejecting recommendations inline
|
||||
4. Use infinite scroll instead of pagination
|
||||
|
||||
**Test implications:** Unit test the recommendation sorting and filtering logic. Integration test the approve/reject endpoints. UI test the infinite scroll and animation behaviors.
|
||||
|
||||
### Pattern: Permission Gates
|
||||
Every mutating operation checks:
|
||||
1. `assert-can-see-client` — user has access to the client
|
||||
2. `assert-not-locked` — transaction date >= client locked-until or bank account start-date
|
||||
3. `can?` — user has the specific permission for the activity
|
||||
|
||||
**Test implications:** Integration test each gate independently. UI tests only verify the happy path with a permitted user.
|
||||
|
||||
---
|
||||
|
||||
## Transaction List Page
|
||||
|
||||
### Display Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 1.1 | It should display a table with columns: Client, Vendor, Description, Date, Amount, Links | UI | [ ] |
|
||||
| 1.2 | It should hide the Client column when only one client with one location is selected | Integration | [ ] |
|
||||
| 1.3 | It should display the description from `description-original` in the Description column | UI | [ ] |
|
||||
| 1.4 | It should fall back to `description-simple` in italics in the Vendor column when no vendor is assigned | UI | [ ] |
|
||||
| 1.5 | It should render dates in `MM/DD/YYYY` format | UI | [ ] |
|
||||
| 1.6 | It should right-align amounts and format them as `$X,XXX.XX` | UI | [ ] |
|
||||
| 1.7 | It should display a links dropdown with links to associated Payment page or Client Overrides | UI | [ ] |
|
||||
| 1.8 | It should show checkboxes for bulk selection on each row | UI | [ ] |
|
||||
| 1.9 | It should group table rows by vendor name (or "No vendor") when sorted by Vendor | Integration | [ ] |
|
||||
| 1.10 | It should show the grid title "Transaction" and entity name "register" | UI | [ ] |
|
||||
| 1.11 | It should display a breadcrumb showing "Transactions" linking to the list page | UI | [ ] |
|
||||
|
||||
### Filtering Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 2.1 | It should filter transactions by vendor typeahead selection | Integration | [ ] |
|
||||
| 2.2 | It should filter transactions by bank account via radio card selector with "All" plus client's bank accounts | Integration | [ ] |
|
||||
| 2.3 | It should dynamically reload the bank account filter when the `clientSelected` event fires | Integration | [ ] |
|
||||
| 2.4 | It should filter transactions by date range (start/end dates) | Integration | [ ] |
|
||||
| 2.5 | It should filter transactions by description with 1000ms debounced search | Integration | [ ] |
|
||||
| 2.6 | It should filter transactions by amount range (min/max) | Integration | [ ] |
|
||||
| 2.7 | It should support exact-match navigation to a specific transaction by ID, bypassing other filters | Integration | [ ] |
|
||||
| 2.8 | It should render the exact-match ID as a removable pill when present in query params | UI | [ ] |
|
||||
| 2.9 | Given multiple filters are applied, when the user changes one filter, then the table should refresh with the combined filter set | Integration | [ ] |
|
||||
|
||||
### Sorting Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 3.1 | It should sort by client name ascending/descending | Integration | [ ] |
|
||||
| 3.2 | It should sort by vendor name ascending/descending, handling missing vendors by grounding empty string | Integration | [ ] |
|
||||
| 3.3 | It should sort by description ascending/descending | Integration | [ ] |
|
||||
| 3.4 | It should sort by date ascending/descending | Integration | [ ] |
|
||||
| 3.5 | It should sort by amount ascending/descending | Integration | [ ] |
|
||||
| 3.6 | Given the user clicks a column header twice, then the sort direction should toggle | Integration | [ ] |
|
||||
| 3.7 | It should default to ascending sort on the implicit `sort-default` field | Integration | [ ] |
|
||||
|
||||
### Pagination Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 4.1 | It should display 25 transactions per page by default | Integration | [ ] |
|
||||
| 4.2 | It should display the total matching count and sum of all matching amounts | Integration | [ ] |
|
||||
| 4.3 | It should render pagination controls via the grid helper | UI | [ ] |
|
||||
|
||||
### Action Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 5.1 | It should display an "Add Transaction" button (primary color) linking to the new transaction route | UI | [ ] |
|
||||
|
||||
### CSV Export Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 6.1 | It should export all filtered results (not just the current page) as CSV | Integration | [ ] |
|
||||
| 6.2 | It should include CSV headers: Id, Client, Vendor, Description, Date, Amount | Integration | [ ] |
|
||||
| 6.3 | It should use raw data values instead of formatted display values in the CSV | Integration | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## New Transaction
|
||||
|
||||
> **Note:** Routes are defined in `auto-ap.routes.transactions` but no handlers are wired in `auto-ap.ssr.transaction`. The "Add Transaction" button in the grid links to this route, which would currently 404. Legacy client-side new transaction functionality exists in the SPA.
|
||||
|
||||
### Basic Details
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 7.1 | It should display a new transaction form at `GET /transaction2/new` | UI | [ ] |
|
||||
| 7.2 | It should allow selecting a location via location selector sub-component | UI | [ ] |
|
||||
| 7.3 | It should allow selecting an account via account typeahead sub-component | UI | [ ] |
|
||||
| 7.4 | It should allow adding line items via line item sub-component | UI | [ ] |
|
||||
| 7.5 | It should submit the new transaction via `POST /transaction2/new` | Integration | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## External Import
|
||||
|
||||
> **Note:** Routes are defined but handlers are not wired in the SSR transaction namespace. The ledger namespace (`auto-ap.ssr.ledger`) has a fully implemented external import flow that these routes may mirror.
|
||||
|
||||
### External Transaction Entry
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 8.1 | It should display an external transaction entry page at `GET /transaction2/external-new` | UI | [ ] |
|
||||
|
||||
### CSV/Text Import
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 8.2 | It should display an import form for CSV/text paste at `GET /transaction2/external-import-new` | UI | [ ] |
|
||||
| 8.3 | It should parse pasted data via `POST /transaction2/external-import-new/parse` | Integration | [ ] |
|
||||
| 8.4 | It should execute the import via `POST /transaction2/external-import-new/import` | Integration | [ ] |
|
||||
| 8.5 | It should deduplicate transactions via SHA-256 synthetic keys during import | Unit | [ ] |
|
||||
| 8.6 | It should auto-match imported transactions to existing pending payments by check number or amount | Integration | [ ] |
|
||||
| 8.7 | It should auto-match imported transactions to expected deposits | Integration | [ ] |
|
||||
| 8.8 | It should auto-code imported transactions via transaction rules | Integration | [ ] |
|
||||
| 8.9 | It should categorize imports as `:import`, `:extant`, `:suppressed`, `:error`, or `:not-ready` | Integration | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Admin Insights / Coding
|
||||
|
||||
### Insights Page Display
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 9.1 | It should display the insights page at `/transaction/insights` for admin users | UI | [ ] |
|
||||
| 9.2 | It should show the title "Transaction Insights" and breadcrumbs: Transactions > Insights | UI | [ ] |
|
||||
| 9.3 | It should display a data grid with headers: Client, Account, Date, Description, Amount, Actions | UI | [ ] |
|
||||
| 9.4 | It should show up to 50 recommendations at a time with no pagination | Integration | [ ] |
|
||||
| 9.5 | It should implement infinite scroll via `hx-trigger="intersect once"` on the last row | UI | [ ] |
|
||||
| 9.6 | It should display "That's the last of 'em!" when no more recommendations are available | UI | [ ] |
|
||||
| 9.7 | It should show an empty grid when no unapproved transactions have recommendations | UI | [ ] |
|
||||
|
||||
### Recommendation Rows
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 10.1 | It should show unapproved transactions from the last 300 days that have `outcome-recommendation` data | Integration | [ ] |
|
||||
| 10.2 | It should display each row with: client code, bank account code, date, description, amount | UI | [ ] |
|
||||
| 10.3 | It should display the amount as a rounded dollar tag (green for positive, red for negative) | UI | [ ] |
|
||||
| 10.4 | It should show up to 3 recommendation buttons per row, sorted by frequency (highest count first) | Integration | [ ] |
|
||||
| 10.5 | It should display each recommendation button as `Vendor Name | Account Name` with a count badge | UI | [ ] |
|
||||
| 10.6 | It should render recommendation buttons in `:primary` color if seen by client, `:secondary` otherwise | UI | [ ] |
|
||||
|
||||
### Coding Actions
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 11.1 | It should approve and code a transaction via `POST /transaction/insights/code/:id` | Integration | [ ] |
|
||||
| 11.2 | It should set the approval status to `approved` and assign vendor and account when coding | Integration | [ ] |
|
||||
| 11.3 | It should distribute the amount across valid locations using `spread-cents` when coding | Unit | [ ] |
|
||||
| 11.4 | It should re-render the row with `live-added` class and Alpine.js disappear animation after coding | UI | [ ] |
|
||||
| 11.5 | It should reject a recommendation via `DELETE /transaction/insights/disapprove/:id` | Integration | [ ] |
|
||||
| 11.6 | It should clear `outcome-recommendation` on the transaction when rejecting | Integration | [ ] |
|
||||
| 11.7 | It should re-render the row with `live-removed` class and disappear animation after rejecting | UI | [ ] |
|
||||
| 11.8 | It should open an explain modal via `GET /transaction/insights/explain/:id` | UI | [ ] |
|
||||
| 11.9 | It should display similar transactions from Pinecone vector search in the explain modal | Integration | [ ] |
|
||||
| 11.10 | It should display Date, Description, Amount, Vendor, Account, and Similarity Score for similar transactions | UI | [ ] |
|
||||
| 11.11 | It should filter similar transactions to scores > 0.95 | Unit | [ ] |
|
||||
| 11.12 | It should show the top 10 similar transactions plus the target transaction highlighted | UI | [ ] |
|
||||
| 11.13 | It should show only the target transaction with no similar matches if Pinecone API fails | Integration | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Cross-Cutting Behaviors
|
||||
|
||||
### Approval Workflow Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 12.1 | It should set transactions to `unapproved` status on import | Integration | [ ] |
|
||||
| 12.2 | It should exclude `suppressed` transactions from all list queries including GraphQL | Integration | [ ] |
|
||||
| 12.3 | It should show `requires-feedback` transactions in the dashboard tasks card | Integration | [ ] |
|
||||
| 12.4 | It should allow admin-only bulk status changes via GraphQL mutation `bulk_change_transaction_status` | Integration | [ ] |
|
||||
| 12.5 | It should block modifying locked transactions (before `client/locked-until` or `bank-account/start-date`) | Integration | [ ] |
|
||||
|
||||
### Coding Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 13.1 | It should allow coding transactions with one or more expense accounts | Integration | [ ] |
|
||||
| 13.2 | It should validate that account totals equal 100% of the transaction amount server-side | Unit + Integration | [ ] |
|
||||
| 13.3 | It should require the location to match the account's fixed location if one is set | Integration | [ ] |
|
||||
| 13.4 | It should distribute amounts proportionally across all client locations when location is "Shared" | Unit | [ ] |
|
||||
| 13.5 | It should reserve location "A" for liabilities/equities/assets | Integration | [ ] |
|
||||
| 13.6 | It should allow admin-only bulk coding via GraphQL mutation `bulk_code_transactions` | Integration | [ ] |
|
||||
|
||||
### Bank Account Filtering Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 14.1 | It should show the bank account filter only when a client is selected | UI | [ ] |
|
||||
| 14.2 | It should dynamically update the bank account filter via HTMX when `clientSelected` event fires | Integration | [ ] |
|
||||
| 14.3 | It should validate that the selected bank account belongs to the current client | Integration | [ ] |
|
||||
| 14.4 | It should default to "All" if the selected account doesn't belong to the current client | Integration | [ ] |
|
||||
|
||||
### CSV Export Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 15.1 | It should use the same filters for CSV export as the table view | Integration | [ ] |
|
||||
| 15.2 | It should export all matching rows bypassing pagination | Integration | [ ] |
|
||||
| 15.3 | It should include the ID column in CSV but not in the HTML view | Integration | [ ] |
|
||||
|
||||
### Payments & Linking Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 16.1 | It should auto-match transactions to payments by check number or amount on import | Integration | [ ] |
|
||||
| 16.2 | It should create a cleared payment and set the transaction to `approved` with Accounts Payable account when linking | Integration | [ ] |
|
||||
| 16.3 | It should revert the transaction to `unapproved` and clear payment/accounts when unlinking | Integration | [ ] |
|
||||
| 16.4 | It should allow a transaction to pay multiple autopay invoices, creating a payment that clears them all | Integration | [ ] |
|
||||
| 16.5 | It should allow a transaction to pay multiple unpaid invoices for outstanding balances | Integration | [ ] |
|
||||
|
||||
### Permission Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 17.1 | It should require `:activity :view :subject :transaction` permission to view transactions | Integration | [ ] |
|
||||
| 17.2 | It should require `:activity :insights :subject :transaction` permission to access the insights page | Integration | [ ] |
|
||||
| 17.3 | It should restrict bulk status changes to admin only | Integration | [ ] |
|
||||
| 17.4 | It should restrict bulk coding to admin only | Integration | [ ] |
|
||||
| 17.5 | It should require power user role with client visibility check to edit transactions | Integration | [ ] |
|
||||
| 17.6 | It should require power user role to match/unlink transactions | Integration | [ ] |
|
||||
| 17.7 | It should redirect unauthenticated users to `/login` for all SSR routes | Integration | [ ] |
|
||||
|
||||
### Import Processing Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 18.1 | It should assign client and bank account during import | Integration | [ ] |
|
||||
| 18.2 | It should set initial status to `unapproved` on import | Integration | [ ] |
|
||||
| 18.3 | It should extract check number from description if present during import | Unit | [ ] |
|
||||
| 18.4 | It should attempt auto-match to pending payment during import | Integration | [ ] |
|
||||
| 18.5 | It should attempt auto-match to expected deposit during import | Integration | [ ] |
|
||||
| 18.6 | It should apply transaction rules for auto-coding during import | Integration | [ ] |
|
||||
| 18.7 | It should apply default vendor if set during import | Integration | [ ] |
|
||||
| 18.8 | It should deduplicate via SHA-256 of `date-bank-account-description-amount-index-client` | Unit | [ ] |
|
||||
| 18.9 | It should skip suppressed transactions on re-import | Integration | [ ] |
|
||||
|
||||
### Empty State Behaviors
|
||||
|
||||
| # | Behavior | Test Strategy | Status |
|
||||
|---|----------|---------------|--------|
|
||||
| 19.1 | It should render an empty state when no transactions match filters | UI | [ ] |
|
||||
| 19.2 | It should show `$0.00` for sum amount when no transactions match | UI | [ ] |
|
||||
| 19.3 | It should render pagination controls showing 0 results when no transactions match | UI | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Test Data Requirements
|
||||
|
||||
| Entity | Requirements |
|
||||
|--------|-------------|
|
||||
| **Users** | Admin user with `:user/role "admin"`; power user with access to specific clients; regular user with client visibility restrictions; unauthenticated user |
|
||||
| **Clients** | Minimum 2 clients with different locations; client with multiple bank accounts; client with single location (to test client column hiding) |
|
||||
| **Bank Accounts** | Bank account with `bank-account/name` and `bank-account/numeric-code`; bank account with `bank-account/start-date` (to test locked transactions); multiple bank accounts under same client |
|
||||
| **Transactions** | Transactions with all approval statuses (`unapproved`, `approved`, `requires-feedback`, `excluded`, `suppressed`); transactions with and without vendors; transactions with positive and negative amounts; transactions linked to payments; transactions with `outcome-recommendation` (for insights); transactions with `exact-match-id` parameter; transactions dated before and after `client/locked-until` |
|
||||
| **Vendors** | Vendor with name for typeahead matching; vendor linked to transactions |
|
||||
| **Accounts** | Account with fixed location; account without fixed location; account with numeric code (for insights display) |
|
||||
| **Payments** | Pending payment with matching check number and amount; cleared payment linked to transaction |
|
||||
|
||||
## Existing Tests to Preserve
|
||||
|
||||
- `test/clj/auto_ap/ssr/transaction_test.clj` — Transaction SSR routes and behaviors
|
||||
- `test/clj/auto_ap/integration/routes/transaction_test.clj` — Transaction import routes
|
||||
- `test/clj/auto_ap/integration/graphql/transactions.clj` — GraphQL transaction operations
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Datomic (primary store, history for unvoid)
|
||||
- Pinecone (vector similarity search for transaction insights explain feature)
|
||||
- Solr (index updated on transaction changes via `solr/touch-with-ledger`)
|
||||
- HTMX (all SSR interactions: filtering, sorting, pagination, insights coding)
|
||||
- Alpine.js (filter state for exact match pill, row disappear animations)
|
||||
550
e2e/bulk-code-transactions.spec.ts
Normal file
550
e2e/bulk-code-transactions.spec.ts
Normal file
@@ -0,0 +1,550 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
let testInfoCache: any = null;
|
||||
|
||||
async function getTestInfo(page: any) {
|
||||
const response = await page.request.get('/test-info');
|
||||
testInfoCache = await response.json();
|
||||
return testInfoCache;
|
||||
}
|
||||
|
||||
async function navigateToTransactions(page: any, clientMode: string = 'mine') {
|
||||
await page.setExtraHTTPHeaders({
|
||||
'x-clients': clientMode === 'all' ? '"all"' : '"mine"'
|
||||
});
|
||||
await page.goto('/transaction2');
|
||||
await page.waitForSelector('table tbody tr');
|
||||
}
|
||||
|
||||
async function selectTransactionByIndex(page: any, index: number) {
|
||||
const rows = page.locator('table tbody tr');
|
||||
const row = rows.nth(index);
|
||||
const checkbox = row.locator('input[type="checkbox"][name="id"]').first();
|
||||
await checkbox.click();
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
|
||||
async function selectAllTransactions(page: any) {
|
||||
const headerCheckbox = page.locator('input#checkbox-all').first();
|
||||
await headerCheckbox.click();
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
|
||||
async function openBulkCodeModal(page: any) {
|
||||
const codeButton = page.locator('button:has-text("Code")').first();
|
||||
await codeButton.click();
|
||||
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' });
|
||||
await page.waitForSelector('#wizardmodal');
|
||||
}
|
||||
|
||||
async function closeBulkCodeModal(page: any) {
|
||||
// The success response swaps the modal content, but the modal holder stays open
|
||||
// Wait for the success message to appear
|
||||
await page.waitForSelector('text=Successfully coded', { timeout: 10000 });
|
||||
}
|
||||
|
||||
async function selectAccountFromTypeahead(page: any, rowIndex: number, accountKey: string) {
|
||||
const testInfo = await getTestInfo(page);
|
||||
const accountId = testInfo.accounts[accountKey];
|
||||
|
||||
if (!accountId) {
|
||||
throw new Error(`Could not find account with key ${accountKey}`);
|
||||
}
|
||||
|
||||
const allRows = page.locator('#account-entries tbody tr');
|
||||
const rowCount = await allRows.count();
|
||||
|
||||
let accountRow = null;
|
||||
let accountRowIndex = 0;
|
||||
for (let i = 0; i < rowCount; i++) {
|
||||
const row = allRows.nth(i);
|
||||
const hasAccountInput = await row.locator('input[name*="account"]').count() > 0;
|
||||
if (hasAccountInput) {
|
||||
if (accountRowIndex === rowIndex) {
|
||||
accountRow = row;
|
||||
break;
|
||||
}
|
||||
accountRowIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
if (!accountRow) {
|
||||
throw new Error(`Could not find account row at index ${rowIndex}`);
|
||||
}
|
||||
|
||||
const hiddenInput = accountRow.locator('input[type="hidden"][name*="[account]"]').first();
|
||||
|
||||
await hiddenInput.evaluate((el: HTMLInputElement, value: string) => {
|
||||
// Replace the Alpine-managed hidden input with a plain one
|
||||
const newInput = document.createElement('input');
|
||||
newInput.type = 'hidden';
|
||||
newInput.name = el.name;
|
||||
newInput.value = value;
|
||||
|
||||
el.parentNode.replaceChild(newInput, el);
|
||||
}, accountId.toString());
|
||||
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Trigger the location select reload by dispatching 'changed' event
|
||||
const locationContainer = accountRow.locator('[x-dispatch\\:changed]').first();
|
||||
if (await locationContainer.count() > 0) {
|
||||
await locationContainer.evaluate((el: HTMLElement) => {
|
||||
el.dispatchEvent(new CustomEvent('changed', { bubbles: true }));
|
||||
});
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
}
|
||||
|
||||
async function findAccountRow(page: any, rowIndex: number) {
|
||||
const allRows = page.locator('#account-entries tbody tr');
|
||||
const rowCount = await allRows.count();
|
||||
|
||||
let accountRowIndex = 0;
|
||||
for (let i = 0; i < rowCount; i++) {
|
||||
const row = allRows.nth(i);
|
||||
const hasAccountInput = await row.locator('input[name*="account"]').count() > 0;
|
||||
if (hasAccountInput) {
|
||||
if (accountRowIndex === rowIndex) {
|
||||
return row;
|
||||
}
|
||||
accountRowIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Could not find account row at index ${rowIndex}`);
|
||||
}
|
||||
|
||||
async function setAccountPercentage(page: any, rowIndex: number, percentage: string) {
|
||||
const row = await findAccountRow(page, rowIndex);
|
||||
const percentageInput = row.locator('input[name*="percentage"]').first();
|
||||
await percentageInput.fill(percentage);
|
||||
await percentageInput.dispatchEvent('change');
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
async function setAccountLocation(page: any, rowIndex: number, location: string) {
|
||||
const row = await findAccountRow(page, rowIndex);
|
||||
const locationSelect = row.locator('select[name*="location"]').first();
|
||||
|
||||
// If the option doesn't exist, add it (for testing invalid locations)
|
||||
const optionExists = await locationSelect.locator(`option[value="${location}"]`).count() > 0;
|
||||
if (!optionExists) {
|
||||
await locationSelect.evaluate((el: HTMLSelectElement, value: string) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = value;
|
||||
option.textContent = value;
|
||||
el.appendChild(option);
|
||||
}, location);
|
||||
}
|
||||
|
||||
await locationSelect.selectOption(location);
|
||||
await locationSelect.dispatchEvent('change');
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
async function addNewAccount(page: any) {
|
||||
const newAccountButton = page.locator('a:has-text("New account")').first();
|
||||
await newAccountButton.click();
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
async function submitBulkCodeForm(page: any) {
|
||||
const form = page.locator('#wizard-form');
|
||||
await form.evaluate((el: HTMLFormElement) => {
|
||||
el.dispatchEvent(new Event('submit', { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
async function getModalErrorText(page: any) {
|
||||
const errorElement = page.locator('#form-errors .error-content');
|
||||
try {
|
||||
await errorElement.waitFor({ state: 'visible', timeout: 3000 });
|
||||
return await errorElement.textContent();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.describe('Bulk Code Transactions - Happy Path', () => {
|
||||
test('should bulk code a single transaction with vendor, status, and 100% account', async ({ page }) => {
|
||||
await navigateToTransactions(page);
|
||||
await selectTransactionByIndex(page, 0);
|
||||
await openBulkCodeModal(page);
|
||||
|
||||
// Verify modal shows correct count
|
||||
await expect(page.locator('text=Bulk editing 1 transactions')).toBeVisible();
|
||||
|
||||
// Select vendor
|
||||
const vendorHidden = page.locator('input[type="hidden"][name="step-params[vendor]"]').first();
|
||||
const testInfo = await getTestInfo(page);
|
||||
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);
|
||||
}, testInfo.accounts.vendor.toString());
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Select approval status
|
||||
const statusSelect = page.locator('select[name="step-params[approval-status]"]').first();
|
||||
await statusSelect.selectOption('approved');
|
||||
|
||||
// Add account
|
||||
await addNewAccount(page);
|
||||
await selectAccountFromTypeahead(page, 0, 'test-account');
|
||||
await setAccountLocation(page, 0, 'Shared');
|
||||
await setAccountPercentage(page, 0, '100');
|
||||
|
||||
// Submit
|
||||
await submitBulkCodeForm(page);
|
||||
await closeBulkCodeModal(page);
|
||||
|
||||
// Verify success by checking table refreshed
|
||||
await page.waitForSelector('table tbody tr');
|
||||
});
|
||||
|
||||
test('should bulk code multiple selected transactions', async ({ page }) => {
|
||||
await navigateToTransactions(page);
|
||||
await selectTransactionByIndex(page, 0);
|
||||
await selectTransactionByIndex(page, 1);
|
||||
await openBulkCodeModal(page);
|
||||
|
||||
// Should show count of selected transactions
|
||||
await expect(page.locator('text=Bulk editing 2 transactions')).toBeVisible();
|
||||
|
||||
// Add account at 100%
|
||||
await addNewAccount(page);
|
||||
await selectAccountFromTypeahead(page, 0, 'test-account');
|
||||
await setAccountLocation(page, 0, 'Shared');
|
||||
await setAccountPercentage(page, 0, '100');
|
||||
|
||||
await submitBulkCodeForm(page);
|
||||
await closeBulkCodeModal(page);
|
||||
|
||||
await page.waitForSelector('table tbody tr');
|
||||
});
|
||||
|
||||
test('should bulk code all visible transactions via header checkbox', async ({ page }) => {
|
||||
await navigateToTransactions(page);
|
||||
await selectAllTransactions(page);
|
||||
await openBulkCodeModal(page);
|
||||
|
||||
// Should show all transactions
|
||||
await expect(page.locator('text=Bulk editing 6 transactions')).toBeVisible();
|
||||
|
||||
// Add account at 100%
|
||||
await addNewAccount(page);
|
||||
await selectAccountFromTypeahead(page, 0, 'test-account');
|
||||
await setAccountLocation(page, 0, 'Shared');
|
||||
await setAccountPercentage(page, 0, '100');
|
||||
|
||||
await submitBulkCodeForm(page);
|
||||
await closeBulkCodeModal(page);
|
||||
|
||||
await page.waitForSelector('table tbody tr');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Bulk Code Transactions - Validation', () => {
|
||||
test('should reject when no vendor, status, or accounts provided', async ({ page }) => {
|
||||
await navigateToTransactions(page);
|
||||
await selectTransactionByIndex(page, 0);
|
||||
await openBulkCodeModal(page);
|
||||
|
||||
// Submit without any changes
|
||||
await submitBulkCodeForm(page);
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Modal should still be open
|
||||
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);
|
||||
await openBulkCodeModal(page);
|
||||
|
||||
await addNewAccount(page);
|
||||
await selectAccountFromTypeahead(page, 0, 'test-account');
|
||||
await setAccountLocation(page, 0, 'Shared');
|
||||
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();
|
||||
|
||||
// Should show validation error
|
||||
const errorText = await getModalErrorText(page);
|
||||
expect(errorText).toContain('does not equal 100%');
|
||||
});
|
||||
|
||||
test('should reject when account percentages total more than 100%', async ({ page }) => {
|
||||
await navigateToTransactions(page);
|
||||
await selectTransactionByIndex(page, 0);
|
||||
await openBulkCodeModal(page);
|
||||
|
||||
await addNewAccount(page);
|
||||
await selectAccountFromTypeahead(page, 0, 'test-account');
|
||||
await setAccountLocation(page, 0, 'Shared');
|
||||
await setAccountPercentage(page, 0, '150');
|
||||
|
||||
await submitBulkCodeForm(page);
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Modal should still be open
|
||||
await expect(page.locator('#modal-holder[x-show="open"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should reject invalid location for account with fixed location', async ({ page }) => {
|
||||
await navigateToTransactions(page);
|
||||
await selectTransactionByIndex(page, 0);
|
||||
await openBulkCodeModal(page);
|
||||
|
||||
await addNewAccount(page);
|
||||
// Use the fixed-location account
|
||||
await selectAccountFromTypeahead(page, 0, 'fixed-location-account');
|
||||
// Try to set wrong location (account is fixed to "DT", try "INVALID")
|
||||
await setAccountLocation(page, 0, 'INVALID');
|
||||
await setAccountPercentage(page, 0, '100');
|
||||
|
||||
await submitBulkCodeForm(page);
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Modal should still be open
|
||||
await expect(page.locator('#modal-holder[x-show="open"]')).toBeVisible();
|
||||
|
||||
// Should show validation error about location mismatch
|
||||
const errorText = await getModalErrorText(page);
|
||||
expect(errorText).toContain('location');
|
||||
});
|
||||
|
||||
test('should reject location not belonging to client', async ({ page }) => {
|
||||
await navigateToTransactions(page);
|
||||
await selectTransactionByIndex(page, 0);
|
||||
await openBulkCodeModal(page);
|
||||
|
||||
await addNewAccount(page);
|
||||
await selectAccountFromTypeahead(page, 0, 'test-account');
|
||||
// Client only has "DT" location, try "GR"
|
||||
await setAccountLocation(page, 0, 'GR');
|
||||
await setAccountPercentage(page, 0, '100');
|
||||
|
||||
await submitBulkCodeForm(page);
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Modal should still be open
|
||||
await expect(page.locator('#modal-holder[x-show="open"]')).toBeVisible();
|
||||
|
||||
// Should show validation error
|
||||
const errorText = await getModalErrorText(page);
|
||||
expect(errorText).toContain('location');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Bulk Code Transactions - Account Distribution', () => {
|
||||
test('should split 50/50 between two accounts', async ({ page }) => {
|
||||
await navigateToTransactions(page);
|
||||
await selectTransactionByIndex(page, 0);
|
||||
await openBulkCodeModal(page);
|
||||
|
||||
// First account at 50%
|
||||
await addNewAccount(page);
|
||||
await selectAccountFromTypeahead(page, 0, 'test-account');
|
||||
await setAccountLocation(page, 0, 'DT');
|
||||
await setAccountPercentage(page, 0, '50');
|
||||
|
||||
// Second account at 50%
|
||||
await addNewAccount(page);
|
||||
await selectAccountFromTypeahead(page, 1, 'second-account');
|
||||
await setAccountLocation(page, 1, 'DT');
|
||||
await setAccountPercentage(page, 1, '50');
|
||||
|
||||
await submitBulkCodeForm(page);
|
||||
await closeBulkCodeModal(page);
|
||||
|
||||
await page.waitForSelector('table tbody tr');
|
||||
});
|
||||
|
||||
test('should allow Shared location for account without fixed location', async ({ page }) => {
|
||||
await navigateToTransactions(page);
|
||||
await selectTransactionByIndex(page, 0);
|
||||
await openBulkCodeModal(page);
|
||||
|
||||
await addNewAccount(page);
|
||||
await selectAccountFromTypeahead(page, 0, 'test-account');
|
||||
await setAccountLocation(page, 0, 'Shared');
|
||||
await setAccountPercentage(page, 0, '100');
|
||||
|
||||
await submitBulkCodeForm(page);
|
||||
await closeBulkCodeModal(page);
|
||||
|
||||
await page.waitForSelector('table tbody tr');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Bulk Code Transactions - Vendor Pre-population', () => {
|
||||
test('should pre-populate default account when vendor is selected', async ({ page }) => {
|
||||
// Ensure single-client mode
|
||||
await page.request.get('/test-set-client-mode?mode=single-client');
|
||||
|
||||
await navigateToTransactions(page);
|
||||
await selectTransactionByIndex(page, 0);
|
||||
await openBulkCodeModal(page);
|
||||
|
||||
// Select vendor (test vendor has default-account set to test-account)
|
||||
const testInfo = await getTestInfo(page);
|
||||
const vendorId = testInfo.accounts.vendor;
|
||||
|
||||
// The vendor typeahead dispatches change from its parent div
|
||||
// We need to set the hidden input and dispatch change on the container
|
||||
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());
|
||||
|
||||
// Dispatch change on the container to trigger HTMX
|
||||
await vendorContainer.evaluate((el: HTMLElement) => {
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
|
||||
// Wait for HTMX response
|
||||
await page.waitForResponse(response => response.url().includes('/vendor-changed') && response.status() === 200);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Account should be pre-populated - check for account row
|
||||
const accountRows = page.locator('#account-entries tbody tr');
|
||||
const rowCount = await accountRows.count();
|
||||
|
||||
// Should have at least 1 account row (the default account) plus the new-row button
|
||||
expect(rowCount).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// The account should have a hidden input with the test-account ID
|
||||
const accountHidden = page.locator('input[type="hidden"][name*="[account]"]').first();
|
||||
const accountValue = await accountHidden.inputValue();
|
||||
expect(accountValue).toBe(testInfo.accounts['test-account'].toString());
|
||||
|
||||
// Percentage should be 100
|
||||
const percentageInput = page.locator('input[name*="percentage"]').first();
|
||||
const percentageValue = await percentageInput.inputValue();
|
||||
expect(percentageValue).toBe('100');
|
||||
|
||||
// Submit should succeed
|
||||
await submitBulkCodeForm(page);
|
||||
await closeBulkCodeModal(page);
|
||||
|
||||
await page.waitForSelector('table tbody tr');
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
// Use 'all' to see all clients in the database
|
||||
await navigateToTransactions(page, 'all');
|
||||
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());
|
||||
|
||||
// Dispatch change on the container to trigger HTMX
|
||||
await vendorContainer.evaluate((el: HTMLElement) => {
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
|
||||
// Wait for HTMX response
|
||||
await page.waitForResponse(response => response.url().includes('/vendor-changed') && response.status() === 200);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// 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');
|
||||
});
|
||||
});
|
||||
570
e2e/transaction-edit.spec.ts
Normal file
570
e2e/transaction-edit.spec.ts
Normal file
@@ -0,0 +1,570 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
async function openEditModal(page: any, transactionIndex: number = 0) {
|
||||
// Navigate to transactions page
|
||||
await page.goto('/transaction2');
|
||||
|
||||
// Wait for the table to load
|
||||
await page.waitForSelector('table tbody tr');
|
||||
|
||||
// Find and click the edit button for the specified transaction
|
||||
const editButton = page.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]').nth(transactionIndex);
|
||||
await editButton.click();
|
||||
|
||||
// Wait for the modal to open
|
||||
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' });
|
||||
await page.waitForSelector('#wizardmodal');
|
||||
|
||||
// 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
|
||||
await page.waitForSelector('#account-grid-body');
|
||||
}
|
||||
|
||||
let testInfoCache: any = null;
|
||||
|
||||
async function getTestInfo(page: any) {
|
||||
// Always fetch fresh to handle server restarts
|
||||
const response = await page.request.get('/test-info');
|
||||
testInfoCache = await response.json();
|
||||
return testInfoCache;
|
||||
}
|
||||
|
||||
async function selectAccountFromTypeahead(page: any, rowIndex: number, accountName: string) {
|
||||
// The account search uses Solr which isn't available in tests.
|
||||
// Instead, we directly set the hidden input value via JavaScript.
|
||||
|
||||
// Get all rows except the new-row, total, balance, and transaction total rows
|
||||
const allRows = page.locator('#account-grid-body tbody tr');
|
||||
const rowCount = await allRows.count();
|
||||
|
||||
// Find the row that has a hidden input for account (actual account rows)
|
||||
let accountRow = null;
|
||||
let accountRowIndex = 0;
|
||||
for (let i = 0; i < rowCount; i++) {
|
||||
const row = allRows.nth(i);
|
||||
const hasAccountInput = await row.locator('input[name*="transaction-account/account"]').count() > 0;
|
||||
if (hasAccountInput) {
|
||||
if (accountRowIndex === rowIndex) {
|
||||
accountRow = row;
|
||||
break;
|
||||
}
|
||||
accountRowIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
if (!accountRow) {
|
||||
throw new Error(`Could not find account row at index ${rowIndex}`);
|
||||
}
|
||||
|
||||
// Find the hidden input for the account
|
||||
const hiddenInput = accountRow.locator('input[type="hidden"][name*="transaction-account/account"]').first();
|
||||
|
||||
// Get account IDs from test-info endpoint
|
||||
const testInfo = await getTestInfo(page);
|
||||
const accountKey = accountName === 'Test' ? 'test-account' : 'second-account';
|
||||
const accountId = testInfo.accounts[accountKey];
|
||||
|
||||
if (!accountId) {
|
||||
throw new Error(`Could not find account with name ${accountName}`);
|
||||
}
|
||||
|
||||
// Set the hidden input value and trigger change
|
||||
// Also update Alpine.js data to prevent it from overwriting our value
|
||||
await hiddenInput.evaluate((el: HTMLInputElement, value: string) => {
|
||||
// Set the DOM value
|
||||
el.value = value;
|
||||
|
||||
// Update Alpine.js component data
|
||||
const alpineEl = el.closest('[x-data]');
|
||||
if (alpineEl && (alpineEl as any).__x) {
|
||||
(alpineEl as any).__x.$data.value.value = parseInt(value);
|
||||
(alpineEl as any).__x.$data.value.label = 'Selected Account';
|
||||
}
|
||||
|
||||
// Also update any parent Alpine model (accountId)
|
||||
const rowEl = el.closest('tr[x-data]');
|
||||
if (rowEl && (rowEl as any).__x) {
|
||||
(rowEl as any).__x.$data.accountId = parseInt(value);
|
||||
}
|
||||
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}, accountId.toString());
|
||||
|
||||
// Wait for any HTMX updates
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
async function findAccountRow(page: any, rowIndex: number) {
|
||||
const accountRows = page.locator('#account-grid-body tbody tr.account-row');
|
||||
const rowCount = await accountRows.count();
|
||||
|
||||
if (rowIndex >= rowCount) {
|
||||
throw new Error(`Could not find account row at index ${rowIndex}. Only ${rowCount} account rows found.`);
|
||||
}
|
||||
|
||||
return accountRows.nth(rowIndex);
|
||||
}
|
||||
|
||||
async function setAccountAmount(page: any, rowIndex: number, amount: string) {
|
||||
const row = await findAccountRow(page, rowIndex);
|
||||
const amountInput = row.locator('.account-amount-field');
|
||||
await amountInput.fill(amount);
|
||||
await amountInput.dispatchEvent('change');
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
async function addNewAccount(page: any) {
|
||||
// Click the "New account" button
|
||||
await page.click('text=New account');
|
||||
|
||||
// Wait for the new row to be added
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
async function setAccountLocation(page: any, rowIndex: number, location: string) {
|
||||
const row = await findAccountRow(page, rowIndex);
|
||||
const locationSelect = row.locator('select[name*="location"]').first();
|
||||
|
||||
// If the option doesn't exist, add it (for testing invalid locations)
|
||||
const optionExists = await locationSelect.locator(`option[value="${location}"]`).count() > 0;
|
||||
if (!optionExists) {
|
||||
await locationSelect.evaluate((el: HTMLSelectElement, value: string) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = value;
|
||||
option.textContent = value;
|
||||
el.appendChild(option);
|
||||
}, location);
|
||||
}
|
||||
|
||||
await locationSelect.selectOption(location);
|
||||
await locationSelect.dispatchEvent('change');
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
async function getAccountLocation(page: any, rowIndex: number): Promise<string> {
|
||||
const row = await findAccountRow(page, rowIndex);
|
||||
const locationSelect = row.locator('select[name*="location"]').first();
|
||||
return await locationSelect.inputValue();
|
||||
}
|
||||
|
||||
async function removeAllAccounts(page: any) {
|
||||
const accountRows = page.locator('#account-grid-body tbody tr.account-row');
|
||||
const rowCount = await accountRows.count();
|
||||
|
||||
for (let i = rowCount - 1; i >= 0; i--) {
|
||||
const row = accountRows.nth(i);
|
||||
const removeButton = row.locator('.account-remove-action');
|
||||
await removeButton.click();
|
||||
// Wait for the Alpine.js removal animation (500ms + buffer)
|
||||
await page.waitForTimeout(700);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTransaction(page: any) {
|
||||
// Click the save button to submit the form via HTMX
|
||||
await page.locator('.wizard-save-action').click();
|
||||
|
||||
// Wait for the modal to close (longer timeout for parallel test load)
|
||||
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'hidden', timeout: 20000 });
|
||||
}
|
||||
|
||||
async function toggleToPercentMode(page: any) {
|
||||
const percentRadio = page.locator('input[name="step-params[amount-mode]"][value="%"]');
|
||||
await percentRadio.click();
|
||||
|
||||
// Wait for HTMX to swap the grid body
|
||||
await page.waitForResponse(response =>
|
||||
response.url().includes('/toggle-amount-mode') && response.status() === 200
|
||||
);
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
|
||||
async function toggleToDollarMode(page: any) {
|
||||
const dollarRadio = page.locator('input[name="step-params[amount-mode]"][value="$"]');
|
||||
await dollarRadio.click();
|
||||
|
||||
// Wait for HTMX to swap the grid body
|
||||
await page.waitForResponse(response =>
|
||||
response.url().includes('/toggle-amount-mode') && response.status() === 200
|
||||
);
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.describe('Transaction Edit Shared Location', () => {
|
||||
test('should spread Shared location to client locations on save and display correctly on reopen', async ({ page }) => {
|
||||
// Use the second transaction to avoid interfering with other tests
|
||||
const transactionIndex = 1;
|
||||
|
||||
// Step 1: Open edit modal and add an account with Shared location
|
||||
await openEditModal(page, transactionIndex);
|
||||
|
||||
// Remove any existing accounts from previous tests
|
||||
await removeAllAccounts(page);
|
||||
|
||||
// Add a new account row
|
||||
await addNewAccount(page);
|
||||
|
||||
// Select the account
|
||||
await selectAccountFromTypeahead(page, 0, 'Test');
|
||||
|
||||
// Set location to Shared
|
||||
await setAccountLocation(page, 0, 'Shared');
|
||||
|
||||
// Set amount to $200 (the full transaction amount for the second transaction)
|
||||
await setAccountAmount(page, 0, '200');
|
||||
|
||||
// Save the transaction
|
||||
await saveTransaction(page);
|
||||
|
||||
// Step 2: Re-open and verify location is not "Shared" but the actual client location
|
||||
await openEditModal(page, transactionIndex);
|
||||
|
||||
// Wait for accounts to load
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Get the location of the first account
|
||||
const location = await getAccountLocation(page, 0);
|
||||
|
||||
// The location should be the actual client location ("DT" in test data), not "Shared"
|
||||
expect(location).not.toBe('Shared');
|
||||
expect(location).toBe('DT');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Transaction Edit Full Workflow', () => {
|
||||
test('should code transaction with vendor using percentage, then split 50/50, then switch to dollars', async ({ page }) => {
|
||||
// Step 1: Open edit modal and code with 100% to one account
|
||||
await openEditModal(page);
|
||||
|
||||
// Switch to percentage mode first (this re-renders the grid from server state)
|
||||
await toggleToPercentMode(page);
|
||||
|
||||
// Check if there's already an account from previous tests
|
||||
const allRows = page.locator('#account-grid-body tbody tr');
|
||||
const hasExistingAccount = await allRows.locator('input[name*="transaction-account/account"]').count() > 0;
|
||||
|
||||
if (!hasExistingAccount) {
|
||||
// Add a new account row if none exist
|
||||
await addNewAccount(page);
|
||||
}
|
||||
|
||||
// Select the account
|
||||
await selectAccountFromTypeahead(page, 0, 'Test');
|
||||
|
||||
// Set amount to 100%
|
||||
await setAccountAmount(page, 0, '100');
|
||||
|
||||
// Save the transaction
|
||||
await saveTransaction(page);
|
||||
|
||||
// Step 2: Re-open and split 50/50 with two accounts
|
||||
await openEditModal(page);
|
||||
|
||||
// Note: amount-mode is UI-only state, so it resets to $ when re-opening
|
||||
// Switch back to percentage mode
|
||||
await toggleToPercentMode(page);
|
||||
|
||||
// The existing account from step 1 should already be there
|
||||
// Change its amount from 100% to 50%
|
||||
await setAccountAmount(page, 0, '50');
|
||||
|
||||
// Add a second account at 50%
|
||||
await addNewAccount(page);
|
||||
await page.waitForTimeout(1000);
|
||||
await selectAccountFromTypeahead(page, 1, 'Second');
|
||||
await setAccountAmount(page, 1, '50');
|
||||
|
||||
// Save
|
||||
await saveTransaction(page);
|
||||
|
||||
// Step 3: Re-open and verify dollar amounts
|
||||
await openEditModal(page);
|
||||
|
||||
// The accounts should be persisted from the previous save
|
||||
// Wait for accounts to load
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Verify we're in dollar mode (default)
|
||||
const dollarRadio = page.locator('input[name="step-params[amount-mode]"][value="$"]');
|
||||
await expect(dollarRadio).toBeChecked();
|
||||
|
||||
// Verify amounts are in dollars (converted from percentages on save)
|
||||
const row0 = await findAccountRow(page, 0);
|
||||
const row1 = await findAccountRow(page, 1);
|
||||
|
||||
const amount0 = row0.locator('.account-amount-field');
|
||||
const amount1 = row1.locator('.account-amount-field');
|
||||
|
||||
// Each should be $50.00 (or close to it)
|
||||
const val0 = await amount0.inputValue();
|
||||
const val1 = await amount1.inputValue();
|
||||
|
||||
expect(parseFloat(val0)).toBeCloseTo(50.0, 1);
|
||||
expect(parseFloat(val1)).toBeCloseTo(50.0, 1);
|
||||
|
||||
// Save
|
||||
await saveTransaction(page);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Transaction Edit Validation', () => {
|
||||
test('should show validation error when account totals do not match transaction amount', async ({ page }) => {
|
||||
// Use the third transaction to avoid interference from other tests
|
||||
await openEditModal(page, 2);
|
||||
|
||||
// Stay in dollar mode (default)
|
||||
// Remove any existing accounts from previous tests
|
||||
await removeAllAccounts(page);
|
||||
await page.waitForTimeout(2000);
|
||||
// Add an account
|
||||
await addNewAccount(page);
|
||||
await selectAccountFromTypeahead(page, 0, 'Test');
|
||||
|
||||
// Set amount to $50 (which doesn't match the $300 transaction)
|
||||
await setAccountAmount(page, 0, '50');
|
||||
|
||||
// Try to save - this should fail because $50 != $300
|
||||
// Click the save button to submit the form via HTMX
|
||||
await page.locator('.wizard-save-action').click();
|
||||
|
||||
// Wait for the response (longer timeout for parallel test load)
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Modal should still be open (save failed)
|
||||
await expect(page.locator('#modal-holder[x-show="open"]')).toBeVisible();
|
||||
|
||||
// The form should still be present
|
||||
const form = page.locator('#wizard-form');
|
||||
await expect(form).toBeVisible();
|
||||
|
||||
// Verify the account row is still there with our $50 value
|
||||
const amountInput = page.locator('.account-amount-field').first();
|
||||
const value = await amountInput.inputValue();
|
||||
expect(parseFloat(value)).toBeCloseTo(50.0, 1);
|
||||
|
||||
// Verify the user-friendly error message is displayed
|
||||
const errorElement = page.locator('#form-errors .error-content');
|
||||
await expect(errorElement).toBeVisible();
|
||||
const errorText = await errorElement.textContent();
|
||||
expect(errorText).toContain('The total of your expense accounts ($50.00) must equal the transaction amount ($300.00)');
|
||||
});
|
||||
});
|
||||
|
||||
async function openEditModalForTransaction(page: any, description: string) {
|
||||
// Navigate to transactions page
|
||||
await page.goto('/transaction2');
|
||||
|
||||
// Wait for the table to load
|
||||
await page.waitForSelector('table tbody tr');
|
||||
|
||||
// Find the row with the specific description and click its edit button
|
||||
const row = page.locator('table tbody tr', { hasText: description }).first();
|
||||
const editButton = row.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]').first();
|
||||
await editButton.click();
|
||||
|
||||
// Wait for the modal to open
|
||||
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' });
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
// Drives the *real* vendor typeahead the way a user does: open the dropdown,
|
||||
// click a rendered result. The vendor search is backed by Solr (unavailable in
|
||||
// tests), so the result option is injected into the typeahead's Alpine
|
||||
// `elements` instead of being fetched. Everything else -- the dropdown's own
|
||||
// search input firing a native `change` on blur, the `value = element` click
|
||||
// handler, the Alpine reactivity, and the HTMX round-trip to
|
||||
// `edit-vendor-changed` -- runs exactly as in production. This is the flow that
|
||||
// regressed: a stale native `change` from the search input used to win the race
|
||||
// and revert the vendor to its previous value.
|
||||
async function selectVendorViaDropdown(page: any, vendorId: number, vendorName: string) {
|
||||
const wrapper = page.locator('div[hx-post*="edit-vendor-changed"]').first();
|
||||
const typeahead = wrapper.locator('div.relative[x-data]').first();
|
||||
|
||||
// Open the dropdown (tippy renders the popper into [data-tippy-root]).
|
||||
await typeahead.locator('a[x-ref="input"]').click();
|
||||
|
||||
const search = page.locator('[data-tippy-root] input[x-model="search"]').first();
|
||||
await search.waitFor({ state: 'visible' });
|
||||
|
||||
// Type under the 3-char search threshold so no Solr request fires and clears
|
||||
// our injected option, while still dirtying the input so it fires a native
|
||||
// `change` on blur -- the event that used to clobber the selection.
|
||||
await search.fill('te');
|
||||
|
||||
// Inject a clickable result into the typeahead's Alpine state.
|
||||
await typeahead.evaluate(
|
||||
(el: HTMLElement, opt: { id: number; label: string }) => {
|
||||
(window as any).Alpine.$data(el).elements = [{ value: opt.id, label: opt.label }];
|
||||
},
|
||||
{ id: vendorId, label: vendorName }
|
||||
);
|
||||
|
||||
// Click the rendered option: fires the search input's native change (stale
|
||||
// value) AND the synthetic change carrying the new value, then HTMX swaps.
|
||||
await page.locator('[data-tippy-root] a', { hasText: vendorName }).first().click();
|
||||
|
||||
await page.waitForResponse(
|
||||
(response: any) =>
|
||||
response.url().includes('/edit-vendor-changed') && response.status() === 200
|
||||
);
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
// Opens the edit modal and activates the Manual tab, waiting on the vendor
|
||||
// typeahead rather than the account grid (which only exists in advanced mode).
|
||||
async function openManualVendorSection(page: any, transactionIndex: number) {
|
||||
await page.goto('/transaction2');
|
||||
await page.waitForSelector('table tbody tr');
|
||||
|
||||
const editButton = page
|
||||
.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]')
|
||||
.nth(transactionIndex);
|
||||
await editButton.click();
|
||||
|
||||
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' });
|
||||
await page.waitForSelector('#wizardmodal');
|
||||
await page.click('button:has-text("Manual")');
|
||||
await page.waitForSelector('div[hx-post*="edit-vendor-changed"]');
|
||||
}
|
||||
|
||||
test.describe('Transaction Edit Vendor Selection', () => {
|
||||
test('selecting a vendor from the dropdown updates the displayed vendor', async ({ page }) => {
|
||||
await openManualVendorSection(page, 3);
|
||||
|
||||
const testInfo = await getTestInfo(page);
|
||||
const vendorId: number = testInfo.accounts.vendor;
|
||||
|
||||
await selectVendorViaDropdown(page, vendorId, 'Test Vendor');
|
||||
|
||||
// The displayed vendor label must reflect the selection after the HTMX
|
||||
// round-trip. Before the fix this reverted to blank because a stale
|
||||
// `change` event submitted the previous vendor and its response won.
|
||||
const label = page
|
||||
.locator('div[hx-post*="edit-vendor-changed"] span[x-text="value.label"]')
|
||||
.first();
|
||||
await expect(label).toHaveText('Test Vendor');
|
||||
|
||||
// The server-rendered hidden input must carry the newly selected vendor id.
|
||||
const hidden = page
|
||||
.locator(
|
||||
'div[hx-post*="edit-vendor-changed"] input[type="hidden"][name="step-params[transaction/vendor]"]'
|
||||
)
|
||||
.first();
|
||||
await expect(hidden).toHaveValue(vendorId.toString());
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Transaction Link Date Display', () => {
|
||||
test('should show payment date when linking to payment', async ({ page }) => {
|
||||
await openEditModalForTransaction(page, 'Transaction for payment link');
|
||||
|
||||
// Click on "Link to payment" tab
|
||||
await page.click('button:has-text("Link to payment")');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Verify the payment option shows the date
|
||||
await expect(page.locator('#payment-matches')).toContainText('Available Payments');
|
||||
await expect(page.locator('#payment-matches')).toContainText('06/14/2023');
|
||||
});
|
||||
|
||||
test('should show invoice date when linking to unpaid invoice', async ({ page }) => {
|
||||
await openEditModalForTransaction(page, 'Transaction for unpaid invoice link');
|
||||
|
||||
// Click on "Link to unpaid invoices" tab
|
||||
await page.click('button:has-text("Link to unpaid invoices")');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Verify the invoice option shows the date
|
||||
await expect(page.locator('text=Available Unpaid Invoices')).toBeVisible();
|
||||
await expect(page.locator('text=UNPAID-001')).toBeVisible();
|
||||
await expect(page.locator('text=07/19/2023')).toBeVisible();
|
||||
});
|
||||
});
|
||||
125
e2e/transaction-import.spec.ts
Normal file
125
e2e/transaction-import.spec.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// The SSR manual transaction import accepts the exact Yodlee positional-column
|
||||
// TSV format from the master branch. Column order (14 columns), per
|
||||
// auto-ap.import.manual/columns:
|
||||
// 0:status 1:raw-date 2:description-original 3:high-level-category
|
||||
// 4,5:(unused) 6:amount 7..11:(unused) 12:bank-account-code 13:client-code
|
||||
//
|
||||
// The test server (auto-ap.test-server) seeds client "TEST" with a bank
|
||||
// account whose code is the deterministic "TEST-CHK" (see seed-test-data).
|
||||
|
||||
const IMPORT_PATH = '/transaction2/external-import-new';
|
||||
|
||||
function yodleeRow(opts: {
|
||||
status?: string;
|
||||
date?: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
amount?: string;
|
||||
bankAccountCode?: string;
|
||||
clientCode?: string;
|
||||
}): string {
|
||||
const cols = new Array(14).fill('');
|
||||
cols[0] = opts.status ?? 'POSTED';
|
||||
cols[1] = opts.date ?? '';
|
||||
cols[2] = opts.description ?? '';
|
||||
cols[3] = opts.category ?? '';
|
||||
cols[6] = opts.amount ?? '';
|
||||
cols[12] = opts.bankAccountCode ?? '';
|
||||
cols[13] = opts.clientCode ?? '';
|
||||
return cols.join('\t');
|
||||
}
|
||||
|
||||
function yodleeTsv(rows: string[]): string {
|
||||
// First line is a header that the importer drops.
|
||||
const header = new Array(14).fill('');
|
||||
header[0] = 'Status';
|
||||
header[1] = 'Date';
|
||||
header[2] = 'Description';
|
||||
header[6] = 'Amount';
|
||||
header[12] = 'Bank Account';
|
||||
header[13] = 'Client';
|
||||
return [header.join('\t'), ...rows].join('\n');
|
||||
}
|
||||
|
||||
async function gotoImport(page: any) {
|
||||
await page.setExtraHTTPHeaders({ 'x-clients': '"mine"' });
|
||||
await page.goto(IMPORT_PATH);
|
||||
}
|
||||
|
||||
async function pasteAndParse(page: any, tsv: string) {
|
||||
const textarea = page.locator('#parse-form textarea').first();
|
||||
await textarea.fill(tsv);
|
||||
// A visible "Parse" button submits the paste form (htmx swaps in the grid).
|
||||
await page.getByRole('button', { name: /parse/i }).click();
|
||||
await page.waitForTimeout(800);
|
||||
}
|
||||
|
||||
test.describe('Manual Transaction Import (SSR)', () => {
|
||||
test('renders the import page with a paste box', async ({ page }) => {
|
||||
await gotoImport(page);
|
||||
await expect(page.locator('#parse-form textarea').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('paste -> parse -> review grid -> import a valid transaction', async ({ page }) => {
|
||||
await gotoImport(page);
|
||||
|
||||
const description = 'E2E Imported Coffee';
|
||||
const tsv = yodleeTsv([
|
||||
yodleeRow({
|
||||
date: '01/15/2024',
|
||||
description,
|
||||
category: 'Food',
|
||||
amount: '12.50',
|
||||
bankAccountCode: 'TEST-CHK',
|
||||
clientCode: 'TEST',
|
||||
}),
|
||||
]);
|
||||
|
||||
await pasteAndParse(page, tsv);
|
||||
|
||||
// The review grid renders the parsed row as editable inputs (the
|
||||
// description lives in an input value, so assert on the input, not text).
|
||||
await expect(page.locator('input[value="TEST-CHK"]').first()).toBeVisible();
|
||||
await expect(page.locator(`input[value="${description}"]`).first()).toBeVisible();
|
||||
|
||||
// Import the clean batch.
|
||||
await page.getByRole('button', { name: /^import$/i }).click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// The imported transaction shows up on the transactions list.
|
||||
await page.goto('/transaction2?date-range=all');
|
||||
await page.waitForSelector('table tbody tr');
|
||||
await expect(page.getByText(description)).toBeVisible();
|
||||
});
|
||||
|
||||
test('blocks the whole batch when a row has an unknown bank-account code', async ({ page }) => {
|
||||
await gotoImport(page);
|
||||
|
||||
const description = 'E2E Blocked Row';
|
||||
const tsv = yodleeTsv([
|
||||
yodleeRow({
|
||||
date: '01/16/2024',
|
||||
description,
|
||||
amount: '20.00',
|
||||
bankAccountCode: 'NOPE-DOES-NOT-EXIST',
|
||||
clientCode: 'TEST',
|
||||
}),
|
||||
]);
|
||||
|
||||
await pasteAndParse(page, tsv);
|
||||
|
||||
// The grid surfaces a blocking error for the bad row. The importer reuses
|
||||
// the master-branch message wording ("Cannot find bank account by code …").
|
||||
await expect(page.getByText(/cannot find bank account/i).first()).toBeVisible();
|
||||
|
||||
// Importing does not create the transaction (batch blocked).
|
||||
await page.getByRole('button', { name: /^import$/i }).click();
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
await page.goto('/transaction2?date-range=all');
|
||||
await page.waitForSelector('table tbody tr');
|
||||
await expect(page.getByText(description)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
209
e2e/transaction-navigation.spec.ts
Normal file
209
e2e/transaction-navigation.spec.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
async function navigateToTransactions(page: any, path: string = '/transaction2') {
|
||||
await page.setExtraHTTPHeaders({
|
||||
'x-clients': '"mine"'
|
||||
});
|
||||
await page.goto(path);
|
||||
await page.waitForSelector('table tbody tr');
|
||||
}
|
||||
|
||||
async function setAmountFilter(page: any, gte: string, lte: string) {
|
||||
const amountGte = page.locator('input[name="amount-gte"]').first();
|
||||
const amountLte = page.locator('input[name="amount-lte"]').first();
|
||||
|
||||
await amountGte.fill(gte);
|
||||
await amountLte.fill(lte);
|
||||
|
||||
// Trigger the filter form submission via change event
|
||||
await amountLte.dispatchEvent('change');
|
||||
|
||||
// Wait for HTMX to update the table and push URL
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
async function getTableRowCount(page: any): Promise<number> {
|
||||
const rows = page.locator('table tbody tr');
|
||||
return await rows.count();
|
||||
}
|
||||
|
||||
async function clickTransactionNavLink(page: any, linkText: string) {
|
||||
const link = page.locator(`a:has-text("${linkText}")`).first();
|
||||
await link.click({ force: true });
|
||||
|
||||
// Wait for navigation and table to load
|
||||
await page.waitForTimeout(1000);
|
||||
await page.waitForSelector('table tbody tr', { timeout: 10000 });
|
||||
}
|
||||
|
||||
test.describe('Transaction Navigation - Amount Filter Persistence', () => {
|
||||
test('should persist amount filter when navigating from All to Unapproved', async ({ page }) => {
|
||||
// Step 1: Navigate to All transactions page
|
||||
await navigateToTransactions(page, '/transaction2');
|
||||
|
||||
// Step 2: Set amount filter
|
||||
await setAmountFilter(page, '250', '');
|
||||
|
||||
// Step 3: Verify the URL updated with the filter
|
||||
await page.waitForURL(url => url.search.includes('amount-gte=250'), { timeout: 5000 });
|
||||
|
||||
// Step 4: Click the Unapproved nav link
|
||||
await clickTransactionNavLink(page, 'Unapproved');
|
||||
|
||||
// Step 5: Verify amount filter is preserved in URL after navigation
|
||||
const unapprovedUrl = page.url();
|
||||
expect(unapprovedUrl).toContain('amount-gte=250');
|
||||
|
||||
// Step 6: Verify filter still applies (only 1 row - the 300 transaction)
|
||||
const filteredCount = await getTableRowCount(page);
|
||||
expect(filteredCount).toBe(1);
|
||||
});
|
||||
|
||||
test('should persist amount filter when navigating from Unapproved to All', async ({ page }) => {
|
||||
// Step 1: Navigate to Unapproved page with amount filter already in URL
|
||||
await navigateToTransactions(page, '/transaction2/unapproved?amount-gte=200');
|
||||
|
||||
// Step 2: Click All nav link
|
||||
await clickTransactionNavLink(page, 'All');
|
||||
|
||||
// Step 3: Verify amount filter is preserved
|
||||
const allUrl = page.url();
|
||||
expect(allUrl).toContain('amount-gte=200');
|
||||
});
|
||||
|
||||
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, '', '500');
|
||||
|
||||
// Step 2: Wait for URL to update
|
||||
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=500');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Transaction Navigation - Date Filter Persistence', () => {
|
||||
test('should persist date-range preset when navigating between pages', async ({ page }) => {
|
||||
// Step 1: Navigate with date-range=all (includes 2022 test data)
|
||||
await navigateToTransactions(page, '/transaction2?date-range=all');
|
||||
|
||||
// Step 2: Click Unapproved nav link
|
||||
await clickTransactionNavLink(page, 'Unapproved');
|
||||
|
||||
// Step 3: Verify date-range persisted
|
||||
const unapprovedUrl = page.url();
|
||||
expect(unapprovedUrl).toContain('date-range=all');
|
||||
});
|
||||
});
|
||||
|
||||
async function setTextFilter(page: any, name: string, value: string) {
|
||||
const input = page.locator(`input[name="${name}"]`).first();
|
||||
await input.fill(value);
|
||||
await input.dispatchEvent('change');
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
test.describe('Transaction Filters - Description and Memo', () => {
|
||||
test('should filter by description with case-insensitive wildcard matching', async ({ page }) => {
|
||||
await navigateToTransactions(page, '/transaction2');
|
||||
|
||||
// Filter by "second" (lowercase) - should match "Second transaction"
|
||||
await setTextFilter(page, 'description', 'second');
|
||||
|
||||
// Wait for URL to update
|
||||
await page.waitForURL(url => url.search.includes('description=second'), { timeout: 5000 });
|
||||
|
||||
// Should show only 1 row (the "Second transaction")
|
||||
const rowCount = await getTableRowCount(page);
|
||||
expect(rowCount).toBe(1);
|
||||
|
||||
// Verify the row contains "Second transaction"
|
||||
const rowText = await page.locator('table tbody tr').first().textContent();
|
||||
expect(rowText).toContain('Second transaction');
|
||||
});
|
||||
|
||||
test('should filter by memo with case-insensitive wildcard matching', async ({ page }) => {
|
||||
await navigateToTransactions(page, '/transaction2');
|
||||
|
||||
// Filter by "rent" (lowercase) - should match "Monthly rent payment"
|
||||
await setTextFilter(page, 'memo', 'rent');
|
||||
|
||||
// Wait for URL to update
|
||||
await page.waitForURL(url => url.search.includes('memo=rent'), { timeout: 5000 });
|
||||
|
||||
// Should show only 1 row (the transaction with "Monthly rent payment" memo)
|
||||
const rowCount = await getTableRowCount(page);
|
||||
expect(rowCount).toBe(1);
|
||||
|
||||
// Verify the row contains "Test transaction" (the one with the rent memo)
|
||||
const rowText = await page.locator('table tbody tr').first().textContent();
|
||||
expect(rowText).toContain('Test transaction');
|
||||
});
|
||||
|
||||
test('should filter by description and memo together', async ({ page }) => {
|
||||
await navigateToTransactions(page, '/transaction2');
|
||||
|
||||
// Set both filters - should match "Test transaction" which has memo "Monthly rent payment"
|
||||
await setTextFilter(page, 'description', 'test');
|
||||
await setTextFilter(page, 'memo', 'rent');
|
||||
|
||||
// Wait for URL to update
|
||||
await page.waitForURL(url => url.search.includes('description=test') && url.search.includes('memo=rent'), { timeout: 5000 });
|
||||
|
||||
// Should show only 1 row
|
||||
const rowCount = await getTableRowCount(page);
|
||||
expect(rowCount).toBe(1);
|
||||
|
||||
// Verify it's the "Test transaction" row
|
||||
const rowText = await page.locator('table tbody tr').first().textContent();
|
||||
expect(rowText).toContain('Test transaction');
|
||||
});
|
||||
|
||||
test('should show no results when filter does not match', async ({ page }) => {
|
||||
await navigateToTransactions(page, '/transaction2');
|
||||
|
||||
// Filter by something that doesn't exist
|
||||
await setTextFilter(page, 'description', 'nonexistent');
|
||||
|
||||
// Wait for URL to update
|
||||
await page.waitForURL(url => url.search.includes('description=nonexistent'), { timeout: 5000 });
|
||||
|
||||
// Should show no rows
|
||||
const rowCount = await getTableRowCount(page);
|
||||
expect(rowCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Transaction Sort - Default Newest First', () => {
|
||||
test('should show transactions sorted by date descending by default', async ({ page }) => {
|
||||
await navigateToTransactions(page, '/transaction2');
|
||||
|
||||
// Verify no explicit sort in URL initially
|
||||
expect(page.url()).not.toContain('sort=');
|
||||
|
||||
// The default sort should be newest first (descending by date)
|
||||
// We can verify this by checking that clicking Date header toggles to asc
|
||||
const dateHeader = page.locator('th').filter({ hasText: 'Date' }).first();
|
||||
await dateHeader.click();
|
||||
|
||||
// Wait for HTMX to update
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
// The URL should now have an explicit sort parameter (ascending because we toggled from default desc)
|
||||
const currentUrl = page.url();
|
||||
expect(currentUrl).toContain('sort=date%3Aasc');
|
||||
|
||||
// Click again to toggle to descending
|
||||
await dateHeader.click();
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
const toggledUrl = page.url();
|
||||
expect(toggledUrl).toContain('sort=date%3Adesc');
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
||||
(defn dollars= [amt1 amt2]
|
||||
(dollars-0? (- amt1 amt2) ))
|
||||
(dollars-0? (- amt1 amt2)))
|
||||
|
||||
(defn localize [d]
|
||||
(time/to-time-zone d (time/time-zone-for-id "America/Los_Angeles")))
|
||||
@@ -22,7 +22,6 @@
|
||||
(defn local-now []
|
||||
(localize (time/now)))
|
||||
|
||||
|
||||
(defn recent-date
|
||||
([]
|
||||
(recent-date 90))
|
||||
@@ -32,16 +31,16 @@
|
||||
(def excel-formatter (f/with-zone (f/formatter "MM/dd/yyyy") (time/time-zone-for-id "America/Los_Angeles")))
|
||||
(defn excel-date [d]
|
||||
(->> d
|
||||
(coerce/to-date-time)
|
||||
localize
|
||||
(f/unparse excel-formatter )))
|
||||
(coerce/to-date-time)
|
||||
localize
|
||||
(f/unparse excel-formatter)))
|
||||
|
||||
(def iso-formatter (f/with-zone (f/formatter "yyyy-MM-dd") (time/time-zone-for-id "America/Los_Angeles")))
|
||||
(defn iso-date [d]
|
||||
(->> d
|
||||
(coerce/to-date-time)
|
||||
localize
|
||||
(f/unparse iso-formatter )))
|
||||
(coerce/to-date-time)
|
||||
localize
|
||||
(f/unparse iso-formatter)))
|
||||
|
||||
(defn sales-orders-in-range [db client start end]
|
||||
(let [end (or end #inst "2050-01-01")]
|
||||
@@ -53,9 +52,6 @@
|
||||
[client start]
|
||||
[client end]))))
|
||||
|
||||
|
||||
|
||||
|
||||
(defn can-see-client? [identity client]
|
||||
(when (not client)
|
||||
(println "WARNING - permission checking for null client"))
|
||||
@@ -63,11 +59,9 @@
|
||||
((set (map :db/id (:user/clients identity))) (:db/id client))
|
||||
((set (map :db/id (:user/clients identity))) client)))
|
||||
|
||||
|
||||
(defn ->pattern [x]
|
||||
(. java.util.regex.Pattern (compile x java.util.regex.Pattern/CASE_INSENSITIVE)))
|
||||
|
||||
|
||||
(defn dom [^java.util.Date x]
|
||||
(-> x
|
||||
(.toInstant)
|
||||
@@ -85,8 +79,8 @@
|
||||
:let [c (entid db c)]
|
||||
r (seq (dc/index-range db
|
||||
:sales-order/client+date
|
||||
[c (or start #inst "2001-01-01T08:00:00.000-00:00") ]
|
||||
[c (or (next-day end) #inst "2030-03-05T08:00:00.000-00:00") ]))]
|
||||
[c (or start #inst "2001-01-01T08:00:00.000-00:00")]
|
||||
[c (or (next-day end) #inst "2030-03-05T08:00:00.000-00:00")]))]
|
||||
[(:e r) (first (:v r)) (second (:v r))]))
|
||||
|
||||
(defn scan-charges [db clients start end]
|
||||
@@ -94,8 +88,8 @@
|
||||
:let [c (entid db c)]
|
||||
r (seq (dc/index-range db
|
||||
:charge/client+date
|
||||
[c (or start #inst "2001-01-01T08:00:00.000-00:00") ]
|
||||
[c (or (next-day end) #inst "2030-03-05T08:00:00.000-00:00") ]))]
|
||||
[c (or start #inst "2001-01-01T08:00:00.000-00:00")]
|
||||
[c (or (next-day end) #inst "2030-03-05T08:00:00.000-00:00")]))]
|
||||
[(:e r) (first (:v r)) (second (:v r))]))
|
||||
|
||||
(defn scan-sales-refunds [db clients start end]
|
||||
@@ -103,8 +97,8 @@
|
||||
:let [c (entid db c)]
|
||||
r (seq (dc/index-range db
|
||||
:sales-refund/client+date
|
||||
[c (or start #inst "2001-01-01T08:00:00.000-00:00") ]
|
||||
[c (or (next-day end) #inst "2030-03-05T08:00:00.000-00:00") ]))]
|
||||
[c (or start #inst "2001-01-01T08:00:00.000-00:00")]
|
||||
[c (or (next-day end) #inst "2030-03-05T08:00:00.000-00:00")]))]
|
||||
[(:e r) (first (:v r)) (second (:v r))]))
|
||||
|
||||
(defn scan-expected-deposits [db clients start end]
|
||||
@@ -112,8 +106,8 @@
|
||||
:let [c (entid db c)]
|
||||
r (seq (dc/index-range db
|
||||
:expected-deposit/client+date
|
||||
[c (or start #inst "2001-01-01T08:00:00.000-00:00") ]
|
||||
[c (or (next-day end) #inst "2030-03-05T08:00:00.000-00:00") ]))]
|
||||
[c (or start #inst "2001-01-01T08:00:00.000-00:00")]
|
||||
[c (or (next-day end) #inst "2030-03-05T08:00:00.000-00:00")]))]
|
||||
[(:e r) (first (:v r)) (second (:v r))]))
|
||||
|
||||
(defn scan-cash-drawer-shifts [db clients start end]
|
||||
@@ -121,8 +115,8 @@
|
||||
:let [c (entid db c)]
|
||||
r (seq (dc/index-range db
|
||||
:cash-drawer-shift/client+date
|
||||
[c (or start #inst "2001-01-01T08:00:00.000-00:00") ]
|
||||
[c (or (next-day end) #inst "2030-03-05T08:00:00.000-00:00") ]))]
|
||||
[c (or start #inst "2001-01-01T08:00:00.000-00:00")]
|
||||
[c (or (next-day end) #inst "2030-03-05T08:00:00.000-00:00")]))]
|
||||
[(:e r) (first (:v r)) (second (:v r))]))
|
||||
|
||||
(defn scan-invoices [db clients start end]
|
||||
@@ -130,17 +124,17 @@
|
||||
:let [c (entid db c)]
|
||||
r (seq (dc/index-range db
|
||||
:invoice/client+date
|
||||
[c (or start #inst "2001-01-01T08:00:00.000-00:00") ]
|
||||
[c (or (next-day end) #inst "2030-03-05T08:00:00.000-00:00") ]))]
|
||||
[c (or start #inst "2001-01-01T08:00:00.000-00:00")]
|
||||
[c (or (next-day end) #inst "2030-03-05T08:00:00.000-00:00")]))]
|
||||
[(:e r) (first (:v r)) (second (:v r))]))
|
||||
|
||||
(defn scan-transactions [db clients start end]
|
||||
(for [c clients
|
||||
:let [c (entid db c)]
|
||||
r (seq (dc/index-range db
|
||||
:transaction/client+date
|
||||
[c (or start #inst "2001-01-01T08:00:00.000-00:00") ]
|
||||
[c (or (next-day end) #inst "2030-03-05T08:00:00.000-00:00") ]))]
|
||||
:transaction/client+date
|
||||
[c (or start #inst "2001-01-01T08:00:00.000-00:00")]
|
||||
[c (or (next-day end) #inst "2030-03-05T08:00:00.000-00:00")]))]
|
||||
[(:e r) (first (:v r)) (second (:v r))]))
|
||||
|
||||
(defn scan-ledger [db clients start end]
|
||||
@@ -148,8 +142,8 @@
|
||||
:let [c (entid db c)]
|
||||
r (seq (dc/index-range db
|
||||
:journal-entry/client+date
|
||||
[c (or start #inst "2001-01-01T08:00:00.000-00:00") ]
|
||||
[c (or (next-day end) #inst "2030-03-05T08:00:00.000-00:00") ]))]
|
||||
[c (or start #inst "2001-01-01T08:00:00.000-00:00")]
|
||||
[c (or (next-day end) #inst "2030-03-05T08:00:00.000-00:00")]))]
|
||||
[(:e r) (first (:v r)) (second (:v r))]))
|
||||
|
||||
(defn scan-payments [db clients start end]
|
||||
@@ -157,15 +151,14 @@
|
||||
:let [c (entid db c)]
|
||||
r (seq (dc/index-range db
|
||||
:payment/client+date
|
||||
[c (or start #inst "2001-01-01T08:00:00.000-00:00") ]
|
||||
[c (or (next-day end) #inst "2030-03-05T08:00:00.000-00:00") ]))]
|
||||
[c (or start #inst "2001-01-01T08:00:00.000-00:00")]
|
||||
[c (or (next-day end) #inst "2030-03-05T08:00:00.000-00:00")]))]
|
||||
[(:e r) (first (:v r)) (second (:v r))]))
|
||||
|
||||
(defn ident [x]
|
||||
(:db/ident x))
|
||||
|
||||
(deftype Line [^Long id ^Long client-id ^Long account-id ^String location ^java.util.Date date ^Double debit ^Double credit ^Double running-balance]
|
||||
)
|
||||
(deftype Line [^Long id ^Long client-id ^Long account-id ^String location ^java.util.Date date ^Double debit ^Double credit ^Double running-balance])
|
||||
|
||||
(defmethod print-method Line [entity writer]
|
||||
(.write writer (format "Line %d: client:%d account:%d location:%s date:%s"
|
||||
@@ -175,18 +168,16 @@
|
||||
(.-location entity)
|
||||
(iso-date (.-date entity)))))
|
||||
|
||||
|
||||
(defn ->line [{[current-client current-account current-location current-date debit credit running-balance]
|
||||
:v
|
||||
id :e}]
|
||||
(Line. id current-client current-account current-location current-date debit credit running-balance)
|
||||
)
|
||||
(Line. id current-client current-account current-location current-date debit credit running-balance))
|
||||
|
||||
(defn compare-account [^Line l1 ^Line l2]
|
||||
|
||||
(defn compare-account [^Line l1 ^Line l2]
|
||||
|
||||
(let [a (compare (.-date l1) (.-date l2))]
|
||||
(if (not= 0 a)
|
||||
a
|
||||
a
|
||||
(compare (.-id l1) (.-id l2)))))
|
||||
|
||||
(defn account-sets [db client-id]
|
||||
@@ -194,7 +185,7 @@
|
||||
(seq)
|
||||
(map ->line)
|
||||
(partition-by (fn set-partition [^Line l]
|
||||
[(.-account-id l) (.-location l)]))) ]
|
||||
[(.-account-id l) (.-location l)])))]
|
||||
(->> running-balance-set
|
||||
(sort compare-account))))
|
||||
|
||||
@@ -205,35 +196,35 @@
|
||||
(take-while (fn until-date [^Line l]
|
||||
(let [^java.util.Date d (.-date l)]
|
||||
(<= (.compareTo ^java.util.Date d end) 0))))
|
||||
last) ]
|
||||
last)]
|
||||
:when (and z (.-id z))]
|
||||
[(.-client-id z) (.-account-id z) (.-location z) (.-date z) (.-running-balance z)]))
|
||||
|
||||
#_(doseq [[ n] (dc/q '[:find ?cd :where [?c :client/code ?cd] [?c :client/groups "NTG"]] (dc/db auto-ap.datomic/conn))]
|
||||
(println n)
|
||||
(dc/q '[:find ?code ?name ?afc ?an ?l ?d2 ?balance
|
||||
:in $ ?end ?group
|
||||
:where
|
||||
[(clj-time.coerce/to-date-time ?end) ?end2]
|
||||
[(iol-ion.query/localize ?end2) ?end3]
|
||||
[(clj-time.coerce/to-date ?end3) ?end4]
|
||||
(or
|
||||
[?c :client/groups ?group]
|
||||
[?c :client/code ?group])
|
||||
[?c :client/name ?name]
|
||||
[?c :client/code ?code]
|
||||
[?c :client/bank-accounts ?b]
|
||||
[(iol-ion.query/account-snapshot $ ?c ?end4) [?x ...]]
|
||||
[(untuple ?x) [_ ?a ?l ?date ?balance]]
|
||||
[(not= nil ?a)]
|
||||
[(iol-ion.query/excel-date ?date) ?d2]
|
||||
(or-join [?a ?afc ?an]
|
||||
(and [?a :account/name ?an]
|
||||
[?a :account/numeric-code ?afc])
|
||||
(and [?a :bank-account/name ?an]
|
||||
[?a :bank-account/numeric-code ?afc]))]
|
||||
(dc/db auto-ap.datomic/conn)
|
||||
#inst "2024-10-10" n))
|
||||
#_(doseq [[n] (dc/q '[:find ?cd :where [?c :client/code ?cd] [?c :client/groups "NTG"]] (dc/db auto-ap.datomic/conn))]
|
||||
(println n)
|
||||
(dc/q '[:find ?code ?name ?afc ?an ?l ?d2 ?balance
|
||||
:in $ ?end ?group
|
||||
:where
|
||||
[(clj-time.coerce/to-date-time ?end) ?end2]
|
||||
[(iol-ion.query/localize ?end2) ?end3]
|
||||
[(clj-time.coerce/to-date ?end3) ?end4]
|
||||
(or
|
||||
[?c :client/groups ?group]
|
||||
[?c :client/code ?group])
|
||||
[?c :client/name ?name]
|
||||
[?c :client/code ?code]
|
||||
[?c :client/bank-accounts ?b]
|
||||
[(iol-ion.query/account-snapshot $ ?c ?end4) [?x ...]]
|
||||
[(untuple ?x) [_ ?a ?l ?date ?balance]]
|
||||
[(not= nil ?a)]
|
||||
[(iol-ion.query/excel-date ?date) ?d2]
|
||||
(or-join [?a ?afc ?an]
|
||||
(and [?a :account/name ?an]
|
||||
[?a :account/numeric-code ?afc])
|
||||
(and [?a :bank-account/name ?an]
|
||||
[?a :bank-account/numeric-code ?afc]))]
|
||||
(dc/db auto-ap.datomic/conn)
|
||||
#inst "2024-10-10" n))
|
||||
|
||||
(defn detailed-account-snapshot
|
||||
([db client-id ^java.util.Date end]
|
||||
@@ -266,12 +257,11 @@
|
||||
:credits 0.0
|
||||
:current-balance 0.0})))]
|
||||
:when client-id]
|
||||
(do
|
||||
(do
|
||||
[client-id account-id location debits credits current-balance count sample]))))
|
||||
|
||||
|
||||
(comment
|
||||
(->>
|
||||
(comment
|
||||
(->>
|
||||
(detailed-account-snapshot (dc/db auto-ap.datomic/conn)
|
||||
(auto-ap.datomic/pull-id (dc/db auto-ap.datomic/conn)
|
||||
[:client/code "NGOP"])
|
||||
@@ -280,65 +270,65 @@
|
||||
(into #{})
|
||||
seq)
|
||||
|
||||
(account-snapshot (dc/db auto-ap.datomic/conn)
|
||||
(auto-ap.datomic/pull-id (dc/db auto-ap.datomic/conn)
|
||||
[:client/code "NGOP"])
|
||||
#inst "2022-01-01")
|
||||
(account-snapshot (dc/db auto-ap.datomic/conn)
|
||||
(auto-ap.datomic/pull-id (dc/db auto-ap.datomic/conn)
|
||||
[:client/code "NGOP"])
|
||||
#inst "2022-01-01")
|
||||
|
||||
(def orig (->> [:client/code "NGOP"]
|
||||
(auto-ap.datomic/pull-id (dc/db auto-ap.datomic/conn))
|
||||
(account-sets (dc/db auto-ap.datomic/conn))
|
||||
(mapcat (fn [ls]
|
||||
ls))
|
||||
(filter (fn [l] (nil? (.-location l))))
|
||||
(into #{})))
|
||||
(def orig (->> [:client/code "NGOP"]
|
||||
(auto-ap.datomic/pull-id (dc/db auto-ap.datomic/conn))
|
||||
(account-sets (dc/db auto-ap.datomic/conn))
|
||||
(mapcat (fn [ls]
|
||||
ls))
|
||||
(filter (fn [l] (nil? (.-location l))))
|
||||
(into #{})))
|
||||
|
||||
(.-location orig)
|
||||
(.-location orig)
|
||||
|
||||
(def orig (into [] (take 5000 (mapcat (fn [ls]
|
||||
(map #(.-id %) ls)) (account-sets (dc/db auto-ap.datomic/conn)
|
||||
(auto-ap.datomic/pull-id (dc/db auto-ap.datomic/conn)
|
||||
[:client/code "NGOP"]))))))
|
||||
(def orig (into [] (take 5000 (mapcat (fn [ls]
|
||||
(map #(.-id %) ls)) (account-sets (dc/db auto-ap.datomic/conn)
|
||||
(auto-ap.datomic/pull-id (dc/db auto-ap.datomic/conn)
|
||||
[:client/code "NGOP"]))))))
|
||||
|
||||
(def n (into [] (take 5000 (mapcat (fn [ls]
|
||||
(map #(.-id %) ls)) (account-sets (dc/db auto-ap.datomic/conn)
|
||||
(auto-ap.datomic/pull-id (dc/db auto-ap.datomic/conn)
|
||||
[:client/code "NGOP"]))))))
|
||||
|
||||
(def n (into [] (take 5000 (mapcat (fn [ls]
|
||||
(map #(.-id %) ls)) (account-sets (dc/db auto-ap.datomic/conn)
|
||||
(auto-ap.datomic/pull-id (dc/db auto-ap.datomic/conn)
|
||||
[:client/code "NGOP"]))))))
|
||||
|
||||
(= orig n)
|
||||
|
||||
#_(seq (dc/q '[:find ?c ?a ?l ?date ?balance
|
||||
:in $
|
||||
:where [?c :client/code "NGOP"]
|
||||
[(iol-ion.query/account-snapshot $ ?c #inst "2023-01-01") [?x ...]]
|
||||
[(untuple ?x) [_ ?a ?l ?date ?balance]]]
|
||||
(dc/db auto-ap.datomic/conn)))
|
||||
#_(seq (dc/q '[:find ?c ?a ?l ?date ?balance
|
||||
:in $
|
||||
:where [?c :client/code "NGOP"]
|
||||
[(iol-ion.query/account-snapshot $ ?c #inst "2023-01-01") [?x ...]]
|
||||
[(untuple ?x) [_ ?a ?l ?date ?balance]]]
|
||||
(dc/db auto-ap.datomic/conn)))
|
||||
|
||||
#_(->> (seq (dc/q '[:find ?code ?name ?afc ?an ?l ?d2 ?balance ?end4
|
||||
:in $ ?end ?group
|
||||
:where
|
||||
[(clj-time.coerce/to-date-time ?end) ?end2]
|
||||
[(iol-ion.query/localize ?end2) ?end3]
|
||||
[(clj-time.coerce/to-date ?end3) ?end4]
|
||||
(or
|
||||
[?c :client/groups ?group]
|
||||
[?c :client/code ?group])
|
||||
[?c :client/name ?name]
|
||||
[?c :client/code ?code]
|
||||
[?c :client/bank-accounts ?b]
|
||||
[(iol-ion.query/account-snapshot $ ?c ?end4) [?x ...]]
|
||||
[(untuple ?x) [_ ?a ?l ?date ?balance]]
|
||||
[(iol-ion.query/excel-date ?date) ?d2]
|
||||
[(not= nil ?a)]
|
||||
(or-join [?a ?afc ?an]
|
||||
(and [?a :account/name ?an]
|
||||
[?a :account/numeric-code ?afc])
|
||||
(and [?a :bank-account/name ?an]
|
||||
[?a :bank-account/numeric-code ?afc]))]
|
||||
(dc/db auto-ap.datomic/conn)
|
||||
#inst "2024-09-23"
|
||||
"NGKG"))
|
||||
(filter (fn [[_ _ afc]]
|
||||
(= 12990 afc)))
|
||||
(map (fn [[_ _ _ _ _ _ a]]
|
||||
(Math/round a)))))
|
||||
#_(->> (seq (dc/q '[:find ?code ?name ?afc ?an ?l ?d2 ?balance ?end4
|
||||
:in $ ?end ?group
|
||||
:where
|
||||
[(clj-time.coerce/to-date-time ?end) ?end2]
|
||||
[(iol-ion.query/localize ?end2) ?end3]
|
||||
[(clj-time.coerce/to-date ?end3) ?end4]
|
||||
(or
|
||||
[?c :client/groups ?group]
|
||||
[?c :client/code ?group])
|
||||
[?c :client/name ?name]
|
||||
[?c :client/code ?code]
|
||||
[?c :client/bank-accounts ?b]
|
||||
[(iol-ion.query/account-snapshot $ ?c ?end4) [?x ...]]
|
||||
[(untuple ?x) [_ ?a ?l ?date ?balance]]
|
||||
[(iol-ion.query/excel-date ?date) ?d2]
|
||||
[(not= nil ?a)]
|
||||
(or-join [?a ?afc ?an]
|
||||
(and [?a :account/name ?an]
|
||||
[?a :account/numeric-code ?afc])
|
||||
(and [?a :bank-account/name ?an]
|
||||
[?a :bank-account/numeric-code ?afc]))]
|
||||
(dc/db auto-ap.datomic/conn)
|
||||
#inst "2024-09-23"
|
||||
"NGKG"))
|
||||
(filter (fn [[_ _ afc]]
|
||||
(= 12990 afc)))
|
||||
(map (fn [[_ _ _ _ _ _ a]]
|
||||
(Math/round a)))))
|
||||
@@ -11,7 +11,6 @@
|
||||
(def pull-many iol-ion.utils/pull-many)
|
||||
(def remove-nils iol-ion.utils/remove-nils)
|
||||
|
||||
|
||||
;; TODO expected-deposit ledger entry
|
||||
#_(defmethod entity-change->ledger :expected-deposit
|
||||
[db [type id]]
|
||||
@@ -33,9 +32,6 @@
|
||||
:location "A"
|
||||
:account :account/ccp}]}))
|
||||
|
||||
|
||||
|
||||
|
||||
(defn regenerate-literals []
|
||||
(require 'com.github.ivarref.gen-fn)
|
||||
(spit
|
||||
@@ -49,9 +45,9 @@
|
||||
(datomic-fn :upsert-entity #'iol-ion.tx.upsert-entity/upsert-entity)
|
||||
(datomic-fn :upsert-invoice #'iol-ion.tx.upsert-invoice/upsert-invoice)
|
||||
(datomic-fn :upsert-ledger #'iol-ion.tx.upsert-ledger/upsert-ledger)
|
||||
(datomic-fn :upsert-transaction #'iol-ion.tx.upsert-transaction/upsert-transaction)])))
|
||||
(datomic-fn :upsert-transaction #'iol-ion.tx.upsert-transaction/upsert-transaction)
|
||||
(datomic-fn :upsert-sales-summary #'iol-ion.tx.upsert-sales-summary-ledger/upsert-sales-summary)])))
|
||||
|
||||
(comment
|
||||
(regenerate-literals)
|
||||
|
||||
(auto-ap.datomic/install-functions))
|
||||
(auto-ap.datomic/install-functions))
|
||||
|
||||
@@ -13,11 +13,11 @@
|
||||
(:invoice/invoice-number invoice)
|
||||
(:invoice/client invoice)
|
||||
(:invoice/vendor invoice))))
|
||||
[ locked-until] (first (dc/q '[:find ?locked-until
|
||||
:in $ ?c
|
||||
:where [?c :client/locked-until ?locked-until]]
|
||||
db
|
||||
(:invoice/client invoice)))
|
||||
[locked-until] (first (dc/q '[:find ?locked-until
|
||||
:in $ ?c
|
||||
:where [?c :client/locked-until ?locked-until]]
|
||||
db
|
||||
(:invoice/client invoice)))
|
||||
is-locked? (cond
|
||||
(not locked-until) false
|
||||
(not (:invoice/date invoice)) true
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
(defn reset-rels [db e a vs]
|
||||
(assert (every? :db/id vs) (format "In order to reset attribute %s, every value must have :db/id" a))
|
||||
(let [ids (when-not (string? e)
|
||||
(->> (dc/q '[:find ?z
|
||||
:in $ ?e ?a
|
||||
:where [?e ?a ?z]]
|
||||
db e a)
|
||||
(map first)))
|
||||
(->> (dc/q '[:find ?z
|
||||
:in $ ?e ?a
|
||||
:where [?e ?a ?z]]
|
||||
db e a)
|
||||
(map first)))
|
||||
new-id-set (set (map :db/id vs))
|
||||
retract-ids (filter (complement new-id-set) ids)
|
||||
{is-component? :db/isComponent} (dc/pull db [:db/isComponent] a)
|
||||
@@ -16,6 +16,6 @@
|
||||
(-> []
|
||||
(into (map (fn [i] (if is-component?
|
||||
[:db/retractEntity i]
|
||||
[:db/retract e a i ])) retract-ids))
|
||||
[:db/retract e a i])) retract-ids))
|
||||
(into (map (fn [i] [:db/add e a i]) new-rels))
|
||||
(into (map (fn [i] [:upsert-entity i]) vs)))))
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
(:require [datomic.api :as dc]))
|
||||
|
||||
(defn reset-scalars [db e a vs]
|
||||
|
||||
|
||||
(let [extant (when-not (string? e)
|
||||
(->> (dc/q '[:find ?z
|
||||
:in $ ?e ?a
|
||||
@@ -12,5 +12,5 @@
|
||||
retracts (filter (complement (set vs)) extant)
|
||||
new (filter (complement (set extant)) vs)]
|
||||
(-> []
|
||||
(into (map (fn [i] [:db/retract e a i ]) retracts))
|
||||
(into (map (fn [i] [:db/retract e a i]) retracts))
|
||||
(into (map (fn [i] [:db/add e a i]) new)))))
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
)
|
||||
(:import [java.util UUID]))
|
||||
|
||||
|
||||
(defn -random-tempid []
|
||||
(str (UUID/randomUUID)))
|
||||
|
||||
@@ -36,7 +35,6 @@
|
||||
;; :else
|
||||
;; v))
|
||||
|
||||
|
||||
(defn upsert-entity [db entity]
|
||||
(when-not (or (:db/id entity)
|
||||
(:db/ident entity))
|
||||
@@ -90,7 +88,7 @@
|
||||
ops
|
||||
|
||||
;; reset relationships if it's refs, and not a lookup (i.e., seq of maps, or empty seq)
|
||||
|
||||
|
||||
(and (sequential? v) (= :db.type/tuple (ident->value-type a)) (not (= :db.cardinality/many (ident->cardinality a))))
|
||||
(conj ops [:db/add e a v])
|
||||
|
||||
|
||||
@@ -4,13 +4,12 @@
|
||||
|
||||
(defn -remove-nils [m]
|
||||
(let [result (reduce-kv
|
||||
(fn [m k v]
|
||||
(if (not (nil? v))
|
||||
(assoc m k v)
|
||||
m
|
||||
))
|
||||
{}
|
||||
m)]
|
||||
(fn [m k v]
|
||||
(if (not (nil? v))
|
||||
(assoc m k v)
|
||||
m))
|
||||
{}
|
||||
m)]
|
||||
(if (seq result)
|
||||
result
|
||||
nil)))
|
||||
@@ -33,41 +32,38 @@
|
||||
invoice-id)
|
||||
credit-invoice? (< (:invoice/total entity 0.0) 0.0)]
|
||||
(when-not (or
|
||||
(not (:invoice/total entity))
|
||||
(= true (:invoice/exclude-from-ledger entity))
|
||||
(= :import-status/pending (:db/ident (:invoice/import-status entity)))
|
||||
(= :invoice-status/voided (:db/ident (:invoice/status entity)))
|
||||
(< -0.001 (:invoice/total entity) 0.001))
|
||||
|
||||
(-remove-nils
|
||||
{:journal-entry/source "invoice"
|
||||
:journal-entry/client (:db/id (:invoice/client entity))
|
||||
:journal-entry/date (:invoice/date entity)
|
||||
:journal-entry/original-entity raw-invoice-id
|
||||
:journal-entry/vendor (:db/id (:invoice/vendor entity))
|
||||
:journal-entry/amount (Math/abs (:invoice/total entity))
|
||||
(not (:invoice/total entity))
|
||||
(= true (:invoice/exclude-from-ledger entity))
|
||||
(= :import-status/pending (:db/ident (:invoice/import-status entity)))
|
||||
(= :invoice-status/voided (:db/ident (:invoice/status entity)))
|
||||
(< -0.001 (:invoice/total entity) 0.001))
|
||||
|
||||
:journal-entry/line-items (into [(cond-> {:db/id (str raw-invoice-id "-" 0)
|
||||
:journal-entry-line/account :account/accounts-payable
|
||||
:journal-entry-line/location "A"
|
||||
}
|
||||
credit-invoice? (assoc :journal-entry-line/debit (Math/abs (:invoice/total entity)))
|
||||
(not credit-invoice?) (assoc :journal-entry-line/credit (Math/abs (:invoice/total entity))))]
|
||||
(map-indexed (fn [i ea]
|
||||
(cond->
|
||||
{:db/id (str raw-invoice-id "-" (inc i))
|
||||
:journal-entry-line/account (:db/id (:invoice-expense-account/account ea))
|
||||
:journal-entry-line/location (or (:invoice-expense-account/location ea) "HQ")
|
||||
}
|
||||
credit-invoice? (assoc :journal-entry-line/credit (Math/abs (:invoice-expense-account/amount ea)))
|
||||
(not credit-invoice?) (assoc :journal-entry-line/debit (Math/abs (:invoice-expense-account/amount ea)))))
|
||||
(:invoice/expense-accounts entity)))
|
||||
:journal-entry/cleared (and (< (:invoice/outstanding-balance entity) 0.01)
|
||||
(every? #(= :payment-status/cleared (:payment/status %)) (:invoice/payments entity))
|
||||
)}))))
|
||||
(-remove-nils
|
||||
{:journal-entry/source "invoice"
|
||||
:journal-entry/client (:db/id (:invoice/client entity))
|
||||
:journal-entry/date (:invoice/date entity)
|
||||
:journal-entry/original-entity raw-invoice-id
|
||||
:journal-entry/vendor (:db/id (:invoice/vendor entity))
|
||||
:journal-entry/amount (Math/abs (:invoice/total entity))
|
||||
|
||||
:journal-entry/line-items (into [(cond-> {:db/id (str raw-invoice-id "-" 0)
|
||||
:journal-entry-line/account :account/accounts-payable
|
||||
:journal-entry-line/location "A"}
|
||||
credit-invoice? (assoc :journal-entry-line/debit (Math/abs (:invoice/total entity)))
|
||||
(not credit-invoice?) (assoc :journal-entry-line/credit (Math/abs (:invoice/total entity))))]
|
||||
(map-indexed (fn [i ea]
|
||||
(cond->
|
||||
{:db/id (str raw-invoice-id "-" (inc i))
|
||||
:journal-entry-line/account (:db/id (:invoice-expense-account/account ea))
|
||||
:journal-entry-line/location (or (:invoice-expense-account/location ea) "HQ")}
|
||||
credit-invoice? (assoc :journal-entry-line/credit (Math/abs (:invoice-expense-account/amount ea)))
|
||||
(not credit-invoice?) (assoc :journal-entry-line/debit (Math/abs (:invoice-expense-account/amount ea)))))
|
||||
(:invoice/expense-accounts entity)))
|
||||
:journal-entry/cleared (and (< (:invoice/outstanding-balance entity) 0.01)
|
||||
(every? #(= :payment-status/cleared (:payment/status %)) (:invoice/payments entity)))}))))
|
||||
|
||||
(defn current-date [db]
|
||||
(let [ last-tx (dc/t->tx (dc/basis-t db))
|
||||
(let [last-tx (dc/t->tx (dc/basis-t db))
|
||||
[[date]] (seq (dc/q '[:find ?ti :in $ ?tx
|
||||
:where [?tx :db/txInstant ?ti]]
|
||||
db
|
||||
@@ -80,15 +76,15 @@
|
||||
invoice-id (or (-> with-invoice :tempids (get (:db/id invoice)))
|
||||
(:db/id invoice))
|
||||
journal-entry (invoice->journal-entry (:db-after with-invoice)
|
||||
invoice-id
|
||||
(:db/id invoice))
|
||||
client-id (-> (dc/pull (:db-after with-invoice)
|
||||
[{:invoice/client [:db/id]}]
|
||||
invoice-id
|
||||
(:db/id invoice))
|
||||
client-id (-> (dc/pull (:db-after with-invoice)
|
||||
[{:invoice/client [:db/id]}]
|
||||
invoice-id)
|
||||
:invoice/client
|
||||
:invoice/client
|
||||
:db/id)]
|
||||
(into upserted-entity
|
||||
(if journal-entry
|
||||
(if journal-entry
|
||||
[[:upsert-ledger journal-entry]]
|
||||
[[:db/retractEntity [:journal-entry/original-entity (:db/id invoice)]]
|
||||
{:db/id client-id
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
next-jel (->> (dc/index-pull db {:index :avet
|
||||
:selector [:db/id :journal-entry-line/client+account+location+date]
|
||||
:start [:journal-entry-line/client+account+location+date
|
||||
(:journal-entry-line/client+account+location+date jel)
|
||||
(:journal-entry-line/client+account+location+date jel)
|
||||
(:db/id jel)]})
|
||||
(take-while (fn line-must-match-client-account-location [result]
|
||||
(and
|
||||
@@ -24,9 +24,8 @@
|
||||
|
||||
(def extant-read '[:db/id :journal-entry/date :journal-entry/client {:journal-entry/line-items [:journal-entry-line/account :journal-entry-line/location :db/id :journal-entry-line/client+account+location+date]}])
|
||||
|
||||
|
||||
(defn current-date [db]
|
||||
(let [ last-tx (dc/t->tx (dc/basis-t db))
|
||||
(let [last-tx (dc/t->tx (dc/basis-t db))
|
||||
[[date]] (seq (dc/q '[:find ?ti :in $ ?tx
|
||||
:where [?tx :db/txInstant ?ti]]
|
||||
db
|
||||
@@ -51,7 +50,7 @@
|
||||
(let [extant-entry (or (when-let [original-entity (:journal-entry/original-entity ledger-entry)]
|
||||
(dc/pull db extant-read [:journal-entry/original-entity original-entity]))
|
||||
(when-let [external-id (:journal-entry/external-id ledger-entry)]
|
||||
(dc/pull db extant-read [:journal-entry/external-id external-id]))) ]
|
||||
(dc/pull db extant-read [:journal-entry/external-id external-id])))]
|
||||
|
||||
(cond->
|
||||
[[:upsert-entity (into (-> ledger-entry
|
||||
@@ -59,11 +58,11 @@
|
||||
(:db/id ledger-entry)
|
||||
(:db/id extant-entry)
|
||||
(-random-tempid)))
|
||||
(update :journal-entry/line-items
|
||||
(update :journal-entry/line-items
|
||||
(fn [lis]
|
||||
(mapv #(-> %
|
||||
(assoc :journal-entry-line/date (:journal-entry/date ledger-entry))
|
||||
(assoc :journal-entry-line/client (:journal-entry/client ledger-entry)))
|
||||
(assoc :journal-entry-line/client (:journal-entry/client ledger-entry)))
|
||||
lis)))))]
|
||||
{:db/id (:journal-entry/client ledger-entry)
|
||||
:client/ledger-last-change (current-date db)}])))
|
||||
|
||||
66
iol_ion/src/iol_ion/tx/upsert_sales_summary_ledger.clj
Normal file
66
iol_ion/src/iol_ion/tx/upsert_sales_summary_ledger.clj
Normal file
@@ -0,0 +1,66 @@
|
||||
(ns iol-ion.tx.upsert-sales-summary-ledger
|
||||
(:require [datomic.api :as dc]))
|
||||
|
||||
(defn summary->journal-entry [db summary-id]
|
||||
(let [summary (dc/pull db '[:sales-summary/client
|
||||
:sales-summary/date
|
||||
{:sales-summary/items [:sales-summary-item/category
|
||||
:ledger-mapped/account
|
||||
:ledger-mapped/amount
|
||||
{:ledger-mapped/ledger-side [:db/ident]}]}]
|
||||
summary-id)
|
||||
items (:sales-summary/items summary)
|
||||
aggregated (->> items
|
||||
(filter :ledger-mapped/account)
|
||||
(group-by :ledger-mapped/account)
|
||||
(map (fn [[account acc-items]]
|
||||
(reduce
|
||||
(fn [m item]
|
||||
(update m (:db/ident (:ledger-mapped/ledger-side item)) (fnil + 0.0) (:ledger-mapped/amount item 0.0)))
|
||||
{:account account}
|
||||
acc-items))))
|
||||
line-items (mapv (fn [{:keys [account] :as m}]
|
||||
(cond-> {:db/id (str (java.util.UUID/randomUUID))
|
||||
:journal-entry-line/account account
|
||||
:journal-entry-line/location "A"}
|
||||
(get m :ledger-side/debit) (assoc :journal-entry-line/debit (get m :ledger-side/debit))
|
||||
(get m :ledger-side/credit) (assoc :journal-entry-line/credit (get m :ledger-side/credit))))
|
||||
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))]
|
||||
(when (and (seq line-items)
|
||||
(= (Math/round (* 1000 total-debits))
|
||||
(Math/round (* 1000 total-credits))))
|
||||
{:journal-entry/source "sales-summary"
|
||||
:journal-entry/client (:db/id (:sales-summary/client summary))
|
||||
:journal-entry/date (:sales-summary/date summary)
|
||||
:journal-entry/original-entity summary-id
|
||||
:journal-entry/amount total-debits
|
||||
:journal-entry/line-items line-items})))
|
||||
|
||||
(defn current-date [db]
|
||||
(let [last-tx (dc/t->tx (dc/basis-t db))
|
||||
[[date]] (seq (dc/q '[:find ?ti :in $ ?tx
|
||||
:where [?tx :db/txInstant ?ti]]
|
||||
db
|
||||
last-tx))]
|
||||
date))
|
||||
|
||||
(defn upsert-sales-summary [db summary]
|
||||
(let [upserted-summary [[:upsert-entity summary]]
|
||||
db-after (-> (dc/with db upserted-summary) :db-after)
|
||||
summary-id (:db/id summary)
|
||||
client-id (-> (dc/pull db-after [{:sales-summary/client [:db/id]}] summary-id)
|
||||
:sales-summary/client
|
||||
:db/id)
|
||||
journal-entry (summary->journal-entry db-after summary-id)]
|
||||
upserted-summary
|
||||
#_(into upserted-summary
|
||||
(if journal-entry
|
||||
[[:upsert-ledger journal-entry]]
|
||||
(concat
|
||||
[[:db/retractEntity [:journal-entry/original-entity (:db/id summary)]]]
|
||||
|
||||
(when client-id [{:db/id client-id
|
||||
:client/ledger-last-change (current-date db)}]))))))
|
||||
@@ -81,73 +81,70 @@
|
||||
[[:upsert-ledger journal-entry]]
|
||||
[[:db/retractEntity [:journal-entry/original-entity (:db/id transaction)]]]))))
|
||||
|
||||
|
||||
#_(comment
|
||||
|
||||
;; If transactions are failing, it is likely that there are multiple bank accounts linked
|
||||
;; to yodlee or plaid. here is how i debugged
|
||||
(upsert-transaction (dc/db auto-ap.datomic/conn) {:transaction/matched-rule 17592233159891,
|
||||
:db/id "34411061-4656-4e77-8cc0-2f2769b4324c",
|
||||
:transaction/status "POSTED",
|
||||
:transaction/description-original "Rotten Robbie #03",
|
||||
:transaction/approval-status {:db/id 17592231963877,
|
||||
:db/ident :transaction-approval-status/approved},
|
||||
:transaction/plaid-merchant {:db/id "223ceae4-d9e7-4e7f-92be-4fb00676088b",
|
||||
:plaid-merchant/name "Rotten Robbie"},
|
||||
:transaction/bank-account 17592232681223,
|
||||
:transaction/vendor 17592232627053,
|
||||
:transaction/date #inst "2024-02-24T08:00:00Z",
|
||||
:transaction/client 17592232577980,
|
||||
:transaction/id "11a4a13e713d63f476009027e9a53e217e13d0192a37df8ab96c0eed4bdbe996",
|
||||
:transaction/amount -84.43,
|
||||
:transaction/accounts [{:db/id "cad8463f-2dfe-47dc-ab17-831e87a633d5",
|
||||
:transaction-account/account 17592231963549,
|
||||
:transaction-account/location "CB",
|
||||
:transaction-account/amount 84.43}],
|
||||
:transaction/raw-id "gQypbv5946F08op74wZmidDg8qD8Q1fM6gEBP"})
|
||||
(upsert-transaction (dc/db auto-ap.datomic/conn) {:transaction/matched-rule 17592233159891,
|
||||
:db/id "34411061-4656-4e77-8cc0-2f2769b4324c",
|
||||
:transaction/status "POSTED",
|
||||
:transaction/description-original "Rotten Robbie #03",
|
||||
:transaction/approval-status {:db/id 17592231963877,
|
||||
:db/ident :transaction-approval-status/approved},
|
||||
:transaction/plaid-merchant {:db/id "223ceae4-d9e7-4e7f-92be-4fb00676088b",
|
||||
:plaid-merchant/name "Rotten Robbie"},
|
||||
:transaction/bank-account 17592232681223,
|
||||
:transaction/vendor 17592232627053,
|
||||
:transaction/date #inst "2024-02-24T08:00:00Z",
|
||||
:transaction/client 17592232577980,
|
||||
:transaction/id "11a4a13e713d63f476009027e9a53e217e13d0192a37df8ab96c0eed4bdbe996",
|
||||
:transaction/amount -84.43,
|
||||
:transaction/accounts [{:db/id "cad8463f-2dfe-47dc-ab17-831e87a633d5",
|
||||
:transaction-account/account 17592231963549,
|
||||
:transaction-account/location "CB",
|
||||
:transaction-account/amount 84.43}],
|
||||
:transaction/raw-id "gQypbv5946F08op74wZmidDg8qD8Q1fM6gEBP"})
|
||||
|
||||
["upsert-transaction"]
|
||||
(user/init-repl)
|
||||
["upsert-transaction"]
|
||||
(user/init-repl)
|
||||
|
||||
(def my-transaction {:transaction/bank-account 17592232681223,
|
||||
:transaction/date #inst "2024-02-24T08:00:00.000-00:00",
|
||||
:transaction/matched-rule 17592233159891,
|
||||
:transaction/client 17592232577980,
|
||||
:transaction/status "POSTED",
|
||||
:transaction/plaid-merchant
|
||||
{:plaid-merchant/name "Rotten Robbie", :db/id "b2776792-9e2b-46e8-a9c8-bf80abea359e"},
|
||||
:db/id "ac2efd80-bb03-48b2-b0d0-6b47a5c119dc",
|
||||
:transaction/id "11a4a13e713d63f476009027e9a53e217e13d0192a37df8ab96c0eed4bdbe996",
|
||||
:transaction/description-original "Rotten Robbie #03",
|
||||
:transaction/approval-status {:db/id 17592231963877, :db/ident :transaction-approval-status/approved}, :transaction/amount -84.43,
|
||||
:transaction/accounts [{:db/id "c402c7b3-c11b-484b-b670-bd48f79a3e5f", :transaction-account/account 17592231963549, :transaction-account/amount 84.43, :transaction-account/location "CB"}],
|
||||
:transaction/raw-id "gQypbv5946F08op74wZmidDg8qD8Q1fM6gEBP",
|
||||
:transaction/vendor 17592232627053})
|
||||
(def my-transaction {:transaction/bank-account 17592232681223,
|
||||
:transaction/date #inst "2024-02-24T08:00:00.000-00:00",
|
||||
:transaction/matched-rule 17592233159891,
|
||||
:transaction/client 17592232577980,
|
||||
:transaction/status "POSTED",
|
||||
:transaction/plaid-merchant
|
||||
{:plaid-merchant/name "Rotten Robbie", :db/id "b2776792-9e2b-46e8-a9c8-bf80abea359e"},
|
||||
:db/id "ac2efd80-bb03-48b2-b0d0-6b47a5c119dc",
|
||||
:transaction/id "11a4a13e713d63f476009027e9a53e217e13d0192a37df8ab96c0eed4bdbe996",
|
||||
:transaction/description-original "Rotten Robbie #03",
|
||||
:transaction/approval-status {:db/id 17592231963877, :db/ident :transaction-approval-status/approved}, :transaction/amount -84.43,
|
||||
:transaction/accounts [{:db/id "c402c7b3-c11b-484b-b670-bd48f79a3e5f", :transaction-account/account 17592231963549, :transaction-account/amount 84.43, :transaction-account/location "CB"}],
|
||||
:transaction/raw-id "gQypbv5946F08op74wZmidDg8qD8Q1fM6gEBP",
|
||||
:transaction/vendor 17592232627053})
|
||||
|
||||
(def my-journal {:journal-entry/alternate-description "Rotten Robbie #03",
|
||||
:journal-entry/date #inst "2024-02-24T08:00:00.000-00:00",
|
||||
:journal-entry/original-entity "ac2efd80-bb03-48b2-b0d0-6b47a5c119dc",
|
||||
:journal-entry/client 17592232577980,
|
||||
:journal-entry/line-items [{:journal-entry-line/credit 84.43, :journal-entry-line/account 17592232681223, :db/id "ac2efd80-bb03-48b2-b0d0-6b47a5c119dc-0", :journal-entry-line/location "A"}
|
||||
{:journal-entry-line/account 17592231963549, :db/id "ac2efd80-bb03-48b2-b0d0-6b47a5c119dc-1", :journal-entry-line/debit 84.43, :journal-entry-line/location "CB"}
|
||||
{:journal-entry-line/account 17592231963549, :db/id "ac2efd80-bb03-48b2-b0d0-6b47a5c119dc-2", :journal-entry-line/debit 84.43, :journal-entry-line/location "CB"}],
|
||||
:journal-entry/source "transaction",
|
||||
:journal-entry/cleared true,
|
||||
:journal-entry/amount 84.43,
|
||||
:journal-entry/vendor 17592232627053})
|
||||
(dc/pull (dc/db auto-ap.datomic/conn) '[*] [:transaction/id "11a4a13e713d63f476009027e9a53e217e13d0192a37df8ab96c0eed4bdbe996"])
|
||||
wl
|
||||
(user/init-repl)
|
||||
(def my-journal {:journal-entry/alternate-description "Rotten Robbie #03",
|
||||
:journal-entry/date #inst "2024-02-24T08:00:00.000-00:00",
|
||||
:journal-entry/original-entity "ac2efd80-bb03-48b2-b0d0-6b47a5c119dc",
|
||||
:journal-entry/client 17592232577980,
|
||||
:journal-entry/line-items [{:journal-entry-line/credit 84.43, :journal-entry-line/account 17592232681223, :db/id "ac2efd80-bb03-48b2-b0d0-6b47a5c119dc-0", :journal-entry-line/location "A"}
|
||||
{:journal-entry-line/account 17592231963549, :db/id "ac2efd80-bb03-48b2-b0d0-6b47a5c119dc-1", :journal-entry-line/debit 84.43, :journal-entry-line/location "CB"}
|
||||
{:journal-entry-line/account 17592231963549, :db/id "ac2efd80-bb03-48b2-b0d0-6b47a5c119dc-2", :journal-entry-line/debit 84.43, :journal-entry-line/location "CB"}],
|
||||
:journal-entry/source "transaction",
|
||||
:journal-entry/cleared true,
|
||||
:journal-entry/amount 84.43,
|
||||
:journal-entry/vendor 17592232627053})
|
||||
(dc/pull (dc/db auto-ap.datomic/conn) '[*] [:transaction/id "11a4a13e713d63f476009027e9a53e217e13d0192a37df8ab96c0eed4bdbe996"])
|
||||
wl
|
||||
(user/init-repl)
|
||||
|
||||
(or (when-let [original-entity (:journal-entry/original-entity my-journal)]
|
||||
(dc/pull (dc/db auto-ap.datomic/conn) iol-ion.tx.upsert-ledger/extant-read [:journal-entry/original-entity original-entity]))
|
||||
(when-let [external-id (:journal-entry/external-id my-journal)]
|
||||
(dc/pull (dc/db auto-ap.datomic/conn) iol-ion.tx.upsert-ledger/extant-read [:journal-entry/external-id external-id])))
|
||||
(or (when-let [original-entity (:journal-entry/original-entity my-journal)]
|
||||
(dc/pull (dc/db auto-ap.datomic/conn) iol-ion.tx.upsert-ledger/extant-read [:journal-entry/original-entity original-entity]))
|
||||
(when-let [external-id (:journal-entry/external-id my-journal)]
|
||||
(dc/pull (dc/db auto-ap.datomic/conn) iol-ion.tx.upsert-ledger/extant-read [:journal-entry/external-id external-id])))
|
||||
|
||||
@(dc/transact auto-ap.datomic/conn [[:upsert-entity my-transaction]
|
||||
[:upsert-ledger my-journal]])
|
||||
@(dc/transact auto-ap.datomic/conn [[:upsert-entity my-transaction]
|
||||
[:upsert-ledger my-journal]])
|
||||
|
||||
(auto-ap.datomic/pull-attr (dc/db auto-ap.datomic/conn) :bank-account/code 17592232681223)
|
||||
(auto-ap.datomic/pull-attr (dc/db auto-ap.datomic/conn) :bank-account/code 17592232681228)
|
||||
|
||||
)
|
||||
(auto-ap.datomic/pull-attr (dc/db auto-ap.datomic/conn) :bank-account/code 17592232681223)
|
||||
(auto-ap.datomic/pull-attr (dc/db auto-ap.datomic/conn) :bank-account/code 17592232681228))
|
||||
@@ -10,11 +10,11 @@
|
||||
(by f identity xs))
|
||||
([f fv xs]
|
||||
(reduce
|
||||
#(assoc %1 (f %2) (fv %2))
|
||||
{}
|
||||
xs)))
|
||||
#(assoc %1 (f %2) (fv %2))
|
||||
{}
|
||||
xs)))
|
||||
|
||||
(defn pull-many [db read ids ]
|
||||
(defn pull-many [db read ids]
|
||||
(->> (dc/q '[:find (pull ?e r)
|
||||
:in $ [?e ...] r]
|
||||
db
|
||||
@@ -24,13 +24,12 @@
|
||||
|
||||
(defn remove-nils [m]
|
||||
(let [result (reduce-kv
|
||||
(fn [m k v]
|
||||
(if (not (nil? v))
|
||||
(assoc m k v)
|
||||
m
|
||||
))
|
||||
{}
|
||||
m)]
|
||||
(fn [m k v]
|
||||
(if (not (nil? v))
|
||||
(assoc m k v)
|
||||
m))
|
||||
{}
|
||||
m)]
|
||||
(if (seq result)
|
||||
result
|
||||
nil)))
|
||||
|
||||
256
opencode.json
Normal file
256
opencode.json
Normal file
File diff suppressed because one or more lines are too long
98
package-lock.json
generated
98
package-lock.json
generated
@@ -26,6 +26,7 @@
|
||||
"recharts": "^2.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.60.0",
|
||||
"@tailwindcss/forms": "^0.5.3",
|
||||
"webpack": "^5.75.0",
|
||||
"webpack-cli": "^5.0.1"
|
||||
@@ -189,6 +190,22 @@
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
|
||||
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.60.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@popperjs/core": {
|
||||
"version": "2.11.8",
|
||||
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
||||
@@ -1916,6 +1933,53 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
|
||||
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.60.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
|
||||
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.4.35",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz",
|
||||
@@ -3335,6 +3399,15 @@
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"@playwright/test": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
|
||||
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"playwright": "1.60.0"
|
||||
}
|
||||
},
|
||||
"@popperjs/core": {
|
||||
"version": "2.11.8",
|
||||
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
||||
@@ -4655,6 +4728,31 @@
|
||||
"find-up": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"playwright": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
|
||||
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"fsevents": "2.3.2",
|
||||
"playwright-core": "1.60.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"playwright-core": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
|
||||
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
|
||||
"dev": true
|
||||
},
|
||||
"postcss": {
|
||||
"version": "8.4.35",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz",
|
||||
|
||||
@@ -24,12 +24,16 @@
|
||||
"recharts": "^2.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.60.0",
|
||||
"@tailwindcss/forms": "^0.5.3",
|
||||
"webpack": "^5.75.0",
|
||||
"webpack-cli": "^5.0.1"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:server": "clojure -M:test -m auto-ap.test-server"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
26
playwright.config.ts
Normal file
26
playwright.config.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: 'html',
|
||||
use: {
|
||||
baseURL: 'http://localhost:3333',
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
webServer: {
|
||||
command: 'lein run -m auto-ap.test-server',
|
||||
url: 'http://localhost:3333/test-info',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120000,
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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"]}
|
||||
|
||||
|
||||
|
||||
652
resources/InvoicesList (45).csv
Normal file
652
resources/InvoicesList (45).csv
Normal file
@@ -0,0 +1,652 @@
|
||||
Invoice,Amount Due,Original Amount,Invoice Date,Invoice Due Date,Invoice Status,Ship To Name,Ship To Number,Bill To Name,Bill To Number,Line Item,Item Description,Original Quantity,Current Quantity,Unit Price,Tax Amount,Total Amount,Unit of Measure,Weight,Split Item Indicator
|
||||
"850081745","$4,631.61","$4,631.61","2026-04-02","2026-05-29","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7661388","LID PLAS FLAT F/12-22 OZ","1","1","25.41","0","25.41","N","0.0",
|
||||
"850081745","$4,631.61","$4,631.61","2026-04-02","2026-05-29","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7452585","NAPKIN 2PLY INTR FOLD 6.3X8.26","1","1","22.32","0","22.32","N","0.0",
|
||||
"850081745","$4,631.61","$4,631.61","2026-04-02","2026-05-29","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","4613026","CUP PORTION PLAS CLR 1.50 OZ","1","1","26.15","0","26.15","N","0.0",
|
||||
"850081745","$4,631.61","$4,631.61","2026-04-02","2026-05-29","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7408008","BOWL PLASTIC COATING 42 OZ","1","1","43.99","0","43.99","N","0.0",
|
||||
"850081745","$4,631.61","$4,631.61","2026-04-02","2026-05-29","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7408215","LID CLEAR PET 42 OZ","1","1","41.25","0","41.25","N","0.0",
|
||||
"850081745","$4,631.61","$4,631.61","2026-04-02","2026-05-29","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7354127","CUP PAPER COLD 22 OZ LOGO NTG","1","1","47.6","0","47.6","N","0.0",
|
||||
"850081745","$4,631.61","$4,631.61","2026-04-02","2026-05-29","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7354120","CONTAINER PAPER 1/30 OZ NTG","1","1","30.6","0","30.6","N","0.0",
|
||||
"850081745","$4,631.61","$4,631.61","2026-04-02","2026-05-29","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7354119","CONTAINER PAPER 4/110OZ NTG","1","1","27.6","0","27.6","N","0.0",
|
||||
"850081745","$4,631.61","$4,631.61","2026-04-02","2026-05-29","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7250678","CONTAINER PAPER MLD FBR 9X6","3","3","37.6","0","112.8","N","0.0",
|
||||
"850081745","$4,631.61","$4,631.61","2026-04-02","2026-05-29","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7417242","BAG PAPER 250 CT","2","2","46.07","0","92.14","N","0.0",
|
||||
"850081745","$4,631.61","$4,631.61","2026-04-02","2026-05-29","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","5723808","WRAP DELI WHT 12X12 GRS RESIST","1","0","24.46","0","24.46","N","0.0","S"
|
||||
"850081745","$4,631.61","$4,631.61","2026-04-02","2026-05-29","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","8492330","WATER PURIFIED BTL PET LSE DW","1","1","20.56","0","20.56","N","0.0",
|
||||
"850081745","$4,631.61","$4,631.61","2026-04-02","2026-05-29","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7189422","SODA CHERRY VISSINADA GRK PLAS","1","1","14.84","0","14.84","N","0.0",
|
||||
"850081745","$4,631.61","$4,631.61","2026-04-02","2026-05-29","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","9910355","SODA LEMON LEMONADA GREEK","1","1","14.93","0","14.93","N","0.0",
|
||||
"850081745","$4,631.61","$4,631.61","2026-04-02","2026-05-29","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7206974","JUICE CONC STRAWB DRAGON","1","1","172.37","0","172.37","N","0.0",
|
||||
"850081745","$4,631.61","$4,631.61","2026-04-02","2026-05-29","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7459969","SYRUP DR PPR DIET BIB","1","1","62.48","0","62.48","N","0.0",
|
||||
"850081745","$4,631.61","$4,631.61","2026-04-02","2026-05-29","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","4273553","SYRUP DR PEPPER BIB","1","1","117.3","0","117.3","N","0.0",
|
||||
"850081745","$4,631.61","$4,631.61","2026-04-02","2026-05-29","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","9911236","SPICE OREGANO LEAF RUBBED","1","1","79.83","0","79.83","N","0.0",
|
||||
"850081745","$4,631.61","$4,631.61","2026-04-02","2026-05-29","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7296407","GLOVE NITRILE BLK PEDRFREE LRG","1","1","39.41","3.85","39.41","N","0.0",
|
||||
"850081745","$4,631.61","$4,631.61","2026-04-02","2026-05-29","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7301949","RICE MIX NICKS","1","1","31.07","0","31.07","N","0.0",
|
||||
"850081745","$4,631.61","$4,631.61","2026-04-02","2026-05-29","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7053293","RICE BASMATI PABROIL SELA CS","2","2","54.84","0","109.68","N","0.0",
|
||||
"850081745","$4,631.61","$4,631.61","2026-04-02","2026-05-29","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","2039220","POTATO KENNEBEC FRESH","5","5","23.65","0","118.25","N","0.0",
|
||||
"850081745","$4,631.61","$4,631.61","2026-04-02","2026-05-29","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7108399","DRESSING VINAIGRETTE LOGO","1","1","75.49","0","75.49","N","0.0",
|
||||
"850081745","$4,631.61","$4,631.61","2026-04-02","2026-05-29","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7360056","DIP GARLIC TOUM","1","1","29.4","0","29.4","N","0.0",
|
||||
"850081745","$4,631.61","$4,631.61","2026-04-02","2026-05-29","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","4823761","OIL CORN","1","1","38.85","0","38.85","N","0.0",
|
||||
"850081745","$4,631.61","$4,631.61","2026-04-02","2026-05-29","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","5223334","BREAD PITA GYRO PRE-OILED 7","8","8","26.36","0","210.88","N","0.0",
|
||||
"850081745","$4,631.61","$4,631.61","2026-04-02","2026-05-29","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7278619","SPREAD HUMMUS TRADITIONAL","2","2","56.72","0","113.44","N","0.0",
|
||||
"850081745","$4,631.61","$4,631.61","2026-04-02","2026-05-29","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7213639","SAUCE TZATZIKI","2","2","95.98","0","191.96","N","0.0",
|
||||
"850081745","$4,631.61","$4,631.61","2026-04-02","2026-05-29","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7792187","CHICKEN CVP THIGH BNLS SKLS","3","3","89.32","0","267.96","N","0.0",
|
||||
"850081745","$4,631.61","$4,631.61","2026-04-02","2026-05-29","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7302646","YOGURT FRZN NF NICK THE GREEK","2","1","54.82","0","109.64","N","0.0",
|
||||
"850081745","$4,631.61","$4,631.61","2026-04-02","2026-05-29","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7455027","SPANAKOPITA SPINACH COOKED","2","2","69.37","0","138.74","N","0.0",
|
||||
"850081745","$4,631.61","$4,631.61","2026-04-02","2026-05-29","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","0932867","BEEF SHLDR TERES MAJOR SEL","1","1","7.556","0","404.25","Y","53.5",
|
||||
"850081745","$4,631.61","$4,631.61","2026-04-02","2026-05-29","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7211838","PORK SLI GYRO CONE","1","1","87.76","0","87.76","N","0.0",
|
||||
"850081745","$4,631.61","$4,631.61","2026-04-02","2026-05-29","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7124188","GYRO CHICKEN SHAWARMA CONE","9","9","86.97","0","782.73","N","0.0",
|
||||
"850081745","$4,631.61","$4,631.61","2026-04-02","2026-05-29","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","9906087","MEAT GYRO BEEF CONE NTG","7","7","92.53","0","647.71","N","0.0",
|
||||
"850081745","$4,631.61","$4,631.61","2026-04-02","2026-05-29","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7187055","BAKLAVA CLASSIC 2X24","2","2","49.53","0","99.06","N","0.0",
|
||||
"850081745","$4,631.61","$4,631.61","2026-04-02","2026-05-29","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7212299","DESSERT MINI PLAIN BEIGNET","1","1","57.66","0","57.66","N","0.0",
|
||||
"850081745","$4,631.61","$4,631.61","2026-04-02","2026-05-29","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7274591","APTZR VEG FALAFEL PUCK HALAL","1","1","97.94","0","97.94","N","0.0",
|
||||
"850081745","$4,631.61","$4,631.61","2026-04-02","2026-05-29","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","9477498","ALLOWANCE FOR DROP SIZE","1","1","-7.32","0","-7.32","N","0.0",
|
||||
"850081745","$4,631.61","$4,631.61","2026-04-02","2026-05-29","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","6592893","CHGS FOR FUEL SURCHARGE","1","1","4.17","0","4.17","N","0.0",
|
||||
"850087188","$434.66","$434.66","2026-04-04","2026-05-29","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7417242","BAG PAPER 250 CT","1","1","46.07","0","46.07","N","0.0",
|
||||
"850087188","$434.66","$434.66","2026-04-04","2026-05-29","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7408215","LID CLEAR PET 42 OZ","1","1","41.25","0","41.25","N","0.0",
|
||||
"850087188","$434.66","$434.66","2026-04-04","2026-05-29","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","8936379","SODA PEPSI COLA","1","1","34.01","0","34.01","N","0.0",
|
||||
"850087188","$434.66","$434.66","2026-04-04","2026-05-29","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","0635005","SODA COLA PEPSI ZERO SUGAR","1","1","34.01","0","34.01","N","0.0",
|
||||
"850087188","$434.66","$434.66","2026-04-04","2026-05-29","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","1336403","SODA DR PPR REG","1","1","34.01","0","34.01","N","0.0",
|
||||
"850087188","$434.66","$434.66","2026-04-04","2026-05-29","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7278619","SPREAD HUMMUS TRADITIONAL","1","1","56.72","0","56.72","N","0.0",
|
||||
"850087188","$434.66","$434.66","2026-04-04","2026-05-29","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7171302","DRESSING MARINADE SOUVLAKI","1","1","72.44","0","72.44","N","0.0",
|
||||
"850087188","$434.66","$434.66","2026-04-04","2026-05-29","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7213703","SAUCE SPICY YOGURT LOGO","1","1","95.98","0","95.98","N","0.0",
|
||||
"850087188","$434.66","$434.66","2026-04-04","2026-05-29","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","1706813","FORK PLAS PP X-HVY BLK","1","1","16.03","0","16.03","N","0.0",
|
||||
"850087188","$434.66","$434.66","2026-04-04","2026-05-29","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","6592893","CHGS FOR FUEL SURCHARGE","1","1","0.54","0","0.54","N","0.0",
|
||||
"850091209","$3,608.82","$3,608.82","2026-04-06","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7452585","NAPKIN 2PLY INTR FOLD 6.3X8.26","1","1","22.32","0","22.32","N","0.0",
|
||||
"850091209","$3,608.82","$3,608.82","2026-04-06","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","0191567","STRAW PLAS TRANS JMB WRPD 7.75","1","0","5.09","0","5.09","N","0.0","S"
|
||||
"850091209","$3,608.82","$3,608.82","2026-04-06","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7408008","BOWL PLASTIC COATING 42 OZ","1","1","43.99","0","43.99","N","0.0",
|
||||
"850091209","$3,608.82","$3,608.82","2026-04-06","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7131355","CUP PLAS CLR RPET 12-14 OZ","0","0","28.25","0","0","N","0.0",
|
||||
"850091209","$3,608.82","$3,608.82","2026-04-06","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7354120","CONTAINER PAPER 1/30 OZ NTG","1","1","30.6","0","30.6","N","0.0",
|
||||
"850091209","$3,608.82","$3,608.82","2026-04-06","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7354119","CONTAINER PAPER 4/110OZ NTG","1","1","27.6","0","27.6","N","0.0",
|
||||
"850091209","$3,608.82","$3,608.82","2026-04-06","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7250678","CONTAINER PAPER MLD FBR 9X6","2","2","37.6","0","75.2","N","0.0",
|
||||
"850091209","$3,608.82","$3,608.82","2026-04-06","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7417242","BAG PAPER 250 CT","1","1","46.07","0","46.07","N","0.0",
|
||||
"850091209","$3,608.82","$3,608.82","2026-04-06","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","1428869","TEA ICED UNSWEET PURELEAF","1","1","21.84","0","21.84","N","0.0",
|
||||
"850091209","$3,608.82","$3,608.82","2026-04-06","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","8492330","WATER PURIFIED BTL PET LSE DW","1","1","20.56","0","20.56","N","0.0",
|
||||
"850091209","$3,608.82","$3,608.82","2026-04-06","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7153421","SYRUP COLA PEPSI ZERO BIB","0","0","71.94","0","0","N","0.0",
|
||||
"850091209","$3,608.82","$3,608.82","2026-04-06","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","9495920","SYRUP LEMONADE PNK BIB","1","1","115.95","0","115.95","N","0.0",
|
||||
"850091209","$3,608.82","$3,608.82","2026-04-06","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","6937767","FOIL ALMN ROLL HVY WGT 500 FT","1","1","39.46","3.85","39.46","N","0.0",
|
||||
"850091209","$3,608.82","$3,608.82","2026-04-06","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7053293","RICE BASMATI PABROIL SELA CS","1","1","54.84","0","54.84","N","0.0",
|
||||
"850091209","$3,608.82","$3,608.82","2026-04-06","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7301949","RICE MIX NICKS","1","1","31.07","0","31.07","N","0.0",
|
||||
"850091209","$3,608.82","$3,608.82","2026-04-06","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","2039220","POTATO KENNEBEC FRESH","4","4","23.65","0","94.6","N","0.0",
|
||||
"850091209","$3,608.82","$3,608.82","2026-04-06","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7108399","DRESSING VINAIGRETTE LOGO","1","1","75.49","0","75.49","N","0.0",
|
||||
"850091209","$3,608.82","$3,608.82","2026-04-06","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","5934302","OIL OLIVE BLEND 80/20","1","1","78.45","0","78.45","N","0.0",
|
||||
"850091209","$3,608.82","$3,608.82","2026-04-06","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","4062337","BEAN GARBANZO FCY NO SULFITE","1","1","31.8","0","31.8","N","0.0",
|
||||
"850091209","$3,608.82","$3,608.82","2026-04-06","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","4372932","PEPPER BANANA MILD RING","1","1","40.85","0","40.85","N","0.0",
|
||||
"850091209","$3,608.82","$3,608.82","2026-04-06","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7360056","DIP GARLIC TOUM","1","1","29.4","0","29.4","N","0.0",
|
||||
"850091209","$3,608.82","$3,608.82","2026-04-06","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","4693715","PASTE HERB HARISSA MOROCCAN","1","1","28.05","0","28.05","N","0.0",
|
||||
"850091209","$3,608.82","$3,608.82","2026-04-06","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","4823761","OIL CORN","2","2","38.85","0","77.7","N","0.0",
|
||||
"850091209","$3,608.82","$3,608.82","2026-04-06","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7171302","DRESSING MARINADE SOUVLAKI","1","1","72.44","0","72.44","N","0.0",
|
||||
"850091209","$3,608.82","$3,608.82","2026-04-06","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","5223334","BREAD PITA GYRO PRE-OILED 7","4","4","26.36","0","105.44","N","0.0",
|
||||
"850091209","$3,608.82","$3,608.82","2026-04-06","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7278619","SPREAD HUMMUS TRADITIONAL","1","1","56.72","0","56.72","N","0.0",
|
||||
"850091209","$3,608.82","$3,608.82","2026-04-06","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7253487","CHEESE FETA RW","1","1","2.428","0","93.14","Y","38.36",
|
||||
"850091209","$3,608.82","$3,608.82","2026-04-06","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7213639","SAUCE TZATZIKI","1","1","95.98","0","95.98","N","0.0",
|
||||
"850091209","$3,608.82","$3,608.82","2026-04-06","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7792187","CHICKEN CVP THIGH BNLS SKLS","3","3","89.32","0","267.96","N","0.0",
|
||||
"850091209","$3,608.82","$3,608.82","2026-04-06","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7302646","YOGURT FRZN NF NICK THE GREEK","1","1","54.82","0","54.82","N","0.0",
|
||||
"850091209","$3,608.82","$3,608.82","2026-04-06","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","8142366","BEEF GRND CHUCK FINE 80/20FRSH","1","1","5.245","0","319.95","Y","61.0",
|
||||
"850091209","$3,608.82","$3,608.82","2026-04-06","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","0932867","BEEF SHLDR TERES MAJOR SEL","1","1","7.556","0","405.76","Y","53.7",
|
||||
"850091209","$3,608.82","$3,608.82","2026-04-06","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7211838","PORK SLI GYRO CONE","1","1","87.76","0","87.76","N","0.0",
|
||||
"850091209","$3,608.82","$3,608.82","2026-04-06","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7124188","GYRO CHICKEN SHAWARMA CONE","5","5","86.97","0","434.85","N","0.0",
|
||||
"850091209","$3,608.82","$3,608.82","2026-04-06","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","9906087","MEAT GYRO BEEF CONE NTG","5","5","92.53","0","462.65","N","0.0",
|
||||
"850091209","$3,608.82","$3,608.82","2026-04-06","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7324922","PASTRY BEIGNET MN FLD CHOCCRML","1","1","53.82","0","53.82","N","0.0",
|
||||
"850091209","$3,608.82","$3,608.82","2026-04-06","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7274591","APTZR VEG FALAFEL PUCK HALAL","1","1","97.94","0","97.94","N","0.0",
|
||||
"850091209","$3,608.82","$3,608.82","2026-04-06","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","6592893","CHGS FOR FUEL SURCHARGE","1","1","3.01","0","3.01","N","0.0",
|
||||
"850098462","$4,627.94","$4,627.94","2026-04-09","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","1706813","FORK PLAS PP X-HVY BLK","1","1","16.03","0","16.03","N","0.0",
|
||||
"850098462","$4,627.94","$4,627.94","2026-04-09","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7790795","LID PLAS CLR F/1.5-2.5OZ PRTN","1","1","30.05","0","30.05","N","0.0",
|
||||
"850098462","$4,627.94","$4,627.94","2026-04-09","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7408008","BOWL PLASTIC COATING 42 OZ","2","2","43.99","0","87.98","N","0.0",
|
||||
"850098462","$4,627.94","$4,627.94","2026-04-09","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7408215","LID CLEAR PET 42 OZ","2","2","41.25","0","82.5","N","0.0",
|
||||
"850098462","$4,627.94","$4,627.94","2026-04-09","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7131355","CUP PLAS CLR RPET 12-14 OZ","1","1","28.25","0","28.25","N","0.0",
|
||||
"850098462","$4,627.94","$4,627.94","2026-04-09","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7354120","CONTAINER PAPER 1/30 OZ NTG","1","1","30.6","0","30.6","N","0.0",
|
||||
"850098462","$4,627.94","$4,627.94","2026-04-09","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7250678","CONTAINER PAPER MLD FBR 9X6","2","2","37.6","0","75.2","N","0.0",
|
||||
"850098462","$4,627.94","$4,627.94","2026-04-09","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7417242","BAG PAPER 250 CT","2","2","46.07","0","92.14","N","0.0",
|
||||
"850098462","$4,627.94","$4,627.94","2026-04-09","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","0635005","SODA COLA PEPSI ZERO SUGAR","1","1","34.01","0","34.01","N","0.0",
|
||||
"850098462","$4,627.94","$4,627.94","2026-04-09","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7153421","SYRUP COLA PEPSI ZERO BIB","1","1","71.94","0","71.94","N","0.0",
|
||||
"850098462","$4,627.94","$4,627.94","2026-04-09","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","5167711","SODA ORANGE CRSH","1","1","34.01","0","34.01","N","0.0",
|
||||
"850098462","$4,627.94","$4,627.94","2026-04-09","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","8492330","WATER PURIFIED BTL PET LSE DW","1","1","20.56","0","20.56","N","0.0",
|
||||
"850098462","$4,627.94","$4,627.94","2026-04-09","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","9911228","SODA ORANGE PORTOKALADA GREEK","1","1","14.93","0","14.93","N","0.0",
|
||||
"850098462","$4,627.94","$4,627.94","2026-04-09","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7189422","SODA CHERRY VISSINADA GRK PLAS","1","1","14.84","0","14.84","N","0.0",
|
||||
"850098462","$4,627.94","$4,627.94","2026-04-09","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","9911229","WATER MINERAL CARNONATED GREEK","1","1","26.57","0","26.57","N","0.0",
|
||||
"850098462","$4,627.94","$4,627.94","2026-04-09","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7228761","DRINK ENERGY TROPICAL VIBE","1","1","24.48","0","24.48","N","0.0",
|
||||
"850098462","$4,627.94","$4,627.94","2026-04-09","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7228477","DRINK ENERGY ORANGE SPRKLNG","1","1","24.48","0","24.48","N","0.0",
|
||||
"850098462","$4,627.94","$4,627.94","2026-04-09","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7228764","DRINK ENERGY PEACH VIBE SPRKLG","1","1","24.48","0","24.48","N","0.0",
|
||||
"850098462","$4,627.94","$4,627.94","2026-04-09","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","8847824","SYRUP COLA PEPSI BIB","1","1","115.95","0","115.95","N","0.0",
|
||||
"850098462","$4,627.94","$4,627.94","2026-04-09","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","2102479","SKEWER BAMBOO 10IN","1","0","7.82","0","7.82","N","0.0","S"
|
||||
"850098462","$4,627.94","$4,627.94","2026-04-09","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7053293","RICE BASMATI PABROIL SELA CS","3","3","54.84","0","164.52","N","0.0",
|
||||
"850098462","$4,627.94","$4,627.94","2026-04-09","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","2039220","POTATO KENNEBEC FRESH","5","5","23.65","0","118.25","N","0.0",
|
||||
"850098462","$4,627.94","$4,627.94","2026-04-09","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7108399","DRESSING VINAIGRETTE LOGO","1","1","75.49","0","75.49","N","0.0",
|
||||
"850098462","$4,627.94","$4,627.94","2026-04-09","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7360056","DIP GARLIC TOUM","2","2","29.4","0","58.8","N","0.0",
|
||||
"850098462","$4,627.94","$4,627.94","2026-04-09","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","4823761","OIL CORN","3","3","38.85","0","116.55","N","0.0",
|
||||
"850098462","$4,627.94","$4,627.94","2026-04-09","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","5223334","BREAD PITA GYRO PRE-OILED 7","6","6","26.36","0","158.16","N","0.0",
|
||||
"850098462","$4,627.94","$4,627.94","2026-04-09","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7485170","BUTTER SOLID USDA AA UNSLTD","1","1","68.55","0","68.55","N","0.0",
|
||||
"850098462","$4,627.94","$4,627.94","2026-04-09","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7278619","SPREAD HUMMUS TRADITIONAL","2","2","56.72","0","113.44","N","0.0",
|
||||
"850098462","$4,627.94","$4,627.94","2026-04-09","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7253487","CHEESE FETA RW","1","1","2.429","0","95.92","Y","39.49",
|
||||
"850098462","$4,627.94","$4,627.94","2026-04-09","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7213639","SAUCE TZATZIKI","2","2","95.98","0","191.96","N","0.0",
|
||||
"850098462","$4,627.94","$4,627.94","2026-04-09","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7213703","SAUCE SPICY YOGURT LOGO","1","1","95.98","0","95.98","N","0.0",
|
||||
"850098462","$4,627.94","$4,627.94","2026-04-09","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7792187","CHICKEN CVP THIGH BNLS SKLS","3","3","89.32","0","267.96","N","0.0",
|
||||
"850098462","$4,627.94","$4,627.94","2026-04-09","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7302646","YOGURT FRZN NF NICK THE GREEK","3","3","54.82","0","164.46","N","0.0",
|
||||
"850098462","$4,627.94","$4,627.94","2026-04-09","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7007041","BUN BRIOCHE HOMESTYLE 4.25","1","1","30.83","0","30.83","N","0.0",
|
||||
"850098462","$4,627.94","$4,627.94","2026-04-09","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7455027","SPANAKOPITA SPINACH COOKED","1","1","69.37","0","69.37","N","0.0",
|
||||
"850098462","$4,627.94","$4,627.94","2026-04-09","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","0932867","BEEF SHLDR TERES MAJOR SEL","1","1","7.556","0","368.73","Y","48.8",
|
||||
"850098462","$4,627.94","$4,627.94","2026-04-09","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7124188","GYRO CHICKEN SHAWARMA CONE","9","9","86.97","0","782.73","N","0.0",
|
||||
"850098462","$4,627.94","$4,627.94","2026-04-09","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","9906087","MEAT GYRO BEEF CONE NTG","8","8","92.53","0","740.24","N","0.0",
|
||||
"850098462","$4,627.94","$4,627.94","2026-04-09","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7187055","BAKLAVA CLASSIC 2X24","1","1","49.53","0","49.53","N","0.0",
|
||||
"850098462","$4,627.94","$4,627.94","2026-04-09","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","8936379","SODA PEPSI COLA","1","1","34.01","0","34.01","N","0.0",
|
||||
"850098462","$4,627.94","$4,627.94","2026-04-09","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","9477498","ALLOWANCE FOR DROP SIZE","1","1","-7.81","0","-7.81","N","0.0",
|
||||
"850098462","$4,627.94","$4,627.94","2026-04-09","2026-06-05","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","6592893","CHGS FOR FUEL SURCHARGE","1","1","4.45","0","4.45","N","0.0",
|
||||
"850108204","$3,103.61","$3,103.61","2026-04-13","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7452585","NAPKIN 2PLY INTR FOLD 6.3X8.26","1","1","22.32","0","22.32","N","0.0",
|
||||
"850108204","$3,103.61","$3,103.61","2026-04-13","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","0191567","STRAW PLAS TRANS JMB WRPD 7.75","1","0","5.09","0","5.09","N","0.0","S"
|
||||
"850108204","$3,103.61","$3,103.61","2026-04-13","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","1706813","FORK PLAS PP X-HVY BLK","1","1","16.03","0","16.03","N","0.0",
|
||||
"850108204","$3,103.61","$3,103.61","2026-04-13","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","4613026","CUP PORTION PLAS CLR 1.50 OZ","1","1","26.15","0","26.15","N","0.0",
|
||||
"850108204","$3,103.61","$3,103.61","2026-04-13","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7408008","BOWL PLASTIC COATING 42 OZ","1","1","43.99","0","43.99","N","0.0",
|
||||
"850108204","$3,103.61","$3,103.61","2026-04-13","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7354119","CONTAINER PAPER 4/110OZ NTG","1","1","27.6","0","27.6","N","0.0",
|
||||
"850108204","$3,103.61","$3,103.61","2026-04-13","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7250678","CONTAINER PAPER MLD FBR 9X6","1","1","37.6","0","37.6","N","0.0",
|
||||
"850108204","$3,103.61","$3,103.61","2026-04-13","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7417242","BAG PAPER 250 CT","1","1","46.07","0","46.07","N","0.0",
|
||||
"850108204","$3,103.61","$3,103.61","2026-04-13","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7365006","WRAP PAPER 14X14 LOGO VER2","1","1","91.04","0","91.04","N","0.0",
|
||||
"850108204","$3,103.61","$3,103.61","2026-04-13","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","1763853","LINER REPRO 40X46 1.5 ML BLK","1","1","39.47","3.85","39.47","N","0.0",
|
||||
"850108204","$3,103.61","$3,103.61","2026-04-13","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","1336403","SODA DR PPR REG","1","1","34.01","0","34.01","N","0.0",
|
||||
"850108204","$3,103.61","$3,103.61","2026-04-13","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7301949","RICE MIX NICKS","1","1","31.07","0","31.07","N","0.0",
|
||||
"850108204","$3,103.61","$3,103.61","2026-04-13","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7053293","RICE BASMATI PABROIL SELA CS","1","1","54.84","0","54.84","N","0.0",
|
||||
"850108204","$3,103.61","$3,103.61","2026-04-13","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","4062337","BEAN GARBANZO FCY NO SULFITE","1","1","31.8","0","31.8","N","0.0",
|
||||
"850108204","$3,103.61","$3,103.61","2026-04-13","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","4823761","OIL CORN","1","1","38.85","0","38.85","N","0.0",
|
||||
"850108204","$3,103.61","$3,103.61","2026-04-13","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","4136768","KETCHUP PACKET FCY","1","1","34","0","34","N","0.0",
|
||||
"850108204","$3,103.61","$3,103.61","2026-04-13","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7171302","DRESSING MARINADE SOUVLAKI","1","1","72.44","0","72.44","N","0.0",
|
||||
"850108204","$3,103.61","$3,103.61","2026-04-13","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7377725","PASTE TAHINI DRESSING","1","1","37.51","0","37.51","N","0.0",
|
||||
"850108204","$3,103.61","$3,103.61","2026-04-13","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","5223334","BREAD PITA GYRO PRE-OILED 7","4","4","26.36","0","105.44","N","0.0",
|
||||
"850108204","$3,103.61","$3,103.61","2026-04-13","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7278619","SPREAD HUMMUS TRADITIONAL","1","1","56.72","0","56.72","N","0.0",
|
||||
"850108204","$3,103.61","$3,103.61","2026-04-13","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","9910846","OLIVE KALAMATA PTD BRNE 22 LB","1","1","79.42","0","79.42","N","0.0",
|
||||
"850108204","$3,103.61","$3,103.61","2026-04-13","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7213639","SAUCE TZATZIKI","1","1","95.98","0","95.98","N","0.0",
|
||||
"850108204","$3,103.61","$3,103.61","2026-04-13","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7792187","CHICKEN CVP THIGH BNLS SKLS","3","3","89.32","0","267.96","N","0.0",
|
||||
"850108204","$3,103.61","$3,103.61","2026-04-13","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7455027","SPANAKOPITA SPINACH COOKED","1","1","69.37","0","69.37","N","0.0",
|
||||
"850108204","$3,103.61","$3,103.61","2026-04-13","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","0932867","BEEF SHLDR TERES MAJOR SEL","1","1","7.556","0","438.25","Y","58.0",
|
||||
"850108204","$3,103.61","$3,103.61","2026-04-13","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7211838","PORK SLI GYRO CONE","1","1","87.76","0","87.76","N","0.0",
|
||||
"850108204","$3,103.61","$3,103.61","2026-04-13","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7124188","GYRO CHICKEN SHAWARMA CONE","6","6","86.97","0","521.82","N","0.0",
|
||||
"850108204","$3,103.61","$3,103.61","2026-04-13","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","9906087","MEAT GYRO BEEF CONE NTG","4","4","92.76","0","371.04","N","0.0",
|
||||
"850108204","$3,103.61","$3,103.61","2026-04-13","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7187055","BAKLAVA CLASSIC 2X24","2","2","49.53","0","99.06","N","0.0",
|
||||
"850108204","$3,103.61","$3,103.61","2026-04-13","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7212299","DESSERT MINI PLAIN BEIGNET","2","2","57.66","0","115.32","N","0.0",
|
||||
"850108204","$3,103.61","$3,103.61","2026-04-13","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7274591","APTZR VEG FALAFEL PUCK HALAL","1","1","97.94","0","97.94","N","0.0",
|
||||
"850108204","$3,103.61","$3,103.61","2026-04-13","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","6592893","CHGS FOR FUEL SURCHARGE","1","1","2.6","0","2.6","N","0.0",
|
||||
"850111098","$0.00","-$54.72","2026-04-15","2026-04-15","Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7302646","YOGURT FRZN NF NICK THE GREEK","-1","-1","54.82","0","-54.82","N","0.0",
|
||||
"850111098","$0.00","-$54.72","2026-04-15","2026-04-15","Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","9477498","ALLOWANCE FOR DROP SIZE","1","1","0.1","0","0.1","N","0.0",
|
||||
"850113429","$4,115.36","$4,115.36","2026-04-16","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","1706946","SPOON PLAS TEA PP X-HVY BLK","1","1","18.01","0","18.01","N","0.0",
|
||||
"850113429","$4,115.36","$4,115.36","2026-04-16","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7408008","BOWL PLASTIC COATING 42 OZ","2","2","43.99","0","87.98","N","0.0",
|
||||
"850113429","$4,115.36","$4,115.36","2026-04-16","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7408215","LID CLEAR PET 42 OZ","2","2","41.25","0","82.5","N","0.0",
|
||||
"850113429","$4,115.36","$4,115.36","2026-04-16","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7354120","CONTAINER PAPER 1/30 OZ NTG","1","1","30.6","0","30.6","N","0.0",
|
||||
"850113429","$4,115.36","$4,115.36","2026-04-16","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7354119","CONTAINER PAPER 4/110OZ NTG","1","1","27.6","0","27.6","N","0.0",
|
||||
"850113429","$4,115.36","$4,115.36","2026-04-16","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7250678","CONTAINER PAPER MLD FBR 9X6","2","2","37.6","0","75.2","N","0.0",
|
||||
"850113429","$4,115.36","$4,115.36","2026-04-16","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7417242","BAG PAPER 250 CT","1","1","46.07","0","46.07","N","0.0",
|
||||
"850113429","$4,115.36","$4,115.36","2026-04-16","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7321470","CANDY MILK CHOC SHELLS","1","1","133.28","0","133.28","N","0.0",
|
||||
"850113429","$4,115.36","$4,115.36","2026-04-16","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","1428869","TEA ICED UNSWEET PURELEAF","1","1","21.84","0","21.84","N","0.0",
|
||||
"850113429","$4,115.36","$4,115.36","2026-04-16","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","8492330","WATER PURIFIED BTL PET LSE DW","1","1","20.56","0","20.56","N","0.0",
|
||||
"850113429","$4,115.36","$4,115.36","2026-04-16","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","9910355","SODA LEMON LEMONADA GREEK","1","1","14.93","0","14.93","N","0.0",
|
||||
"850113429","$4,115.36","$4,115.36","2026-04-16","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","9911228","SODA ORANGE PORTOKALADA GREEK","1","1","14.93","0","14.93","N","0.0",
|
||||
"850113429","$4,115.36","$4,115.36","2026-04-16","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7044106","JUICE CONC MANDARIN CARDAMOM","1","1","172.37","0","172.37","N","0.0",
|
||||
"850113429","$4,115.36","$4,115.36","2026-04-16","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7459969","SYRUP DR PPR DIET BIB","1","1","62.48","0","62.48","N","0.0",
|
||||
"850113429","$4,115.36","$4,115.36","2026-04-16","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7296407","GLOVE NITRILE BLK PEDRFREE LRG","1","1","39.41","3.84","39.41","N","0.0",
|
||||
"850113429","$4,115.36","$4,115.36","2026-04-16","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7435266","FILM PVC 18X2000 ROLL","1","1","21.82","2.14","21.82","N","0.0",
|
||||
"850113429","$4,115.36","$4,115.36","2026-04-16","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7053293","RICE BASMATI PABROIL SELA CS","2","2","54.84","0","109.68","N","0.0",
|
||||
"850113429","$4,115.36","$4,115.36","2026-04-16","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","2039220","POTATO KENNEBEC FRESH","7","7","23.65","0","165.55","N","0.0",
|
||||
"850113429","$4,115.36","$4,115.36","2026-04-16","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7108399","DRESSING VINAIGRETTE LOGO","2","2","75.49","0","150.98","N","0.0",
|
||||
"850113429","$4,115.36","$4,115.36","2026-04-16","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","4113049","VINEGAR DISTILLED WHITE 5%","1","1","16.48","0","16.48","N","0.0",
|
||||
"850113429","$4,115.36","$4,115.36","2026-04-16","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7360056","DIP GARLIC TOUM","1","1","29.4","0","29.4","N","0.0",
|
||||
"850113429","$4,115.36","$4,115.36","2026-04-16","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","4823761","OIL CORN","2","2","38.85","0","77.7","N","0.0",
|
||||
"850113429","$4,115.36","$4,115.36","2026-04-16","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7171302","DRESSING MARINADE SOUVLAKI","1","1","72.44","0","72.44","N","0.0",
|
||||
"850113429","$4,115.36","$4,115.36","2026-04-16","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7108544","SAUCE MUSTARD","1","1","81.42","0","81.42","N","0.0",
|
||||
"850113429","$4,115.36","$4,115.36","2026-04-16","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","5223334","BREAD PITA GYRO PRE-OILED 7","5","5","26.36","0","131.8","N","0.0",
|
||||
"850113429","$4,115.36","$4,115.36","2026-04-16","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7278619","SPREAD HUMMUS TRADITIONAL","2","2","56.72","0","113.44","N","0.0",
|
||||
"850113429","$4,115.36","$4,115.36","2026-04-16","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7213639","SAUCE TZATZIKI","2","2","95.98","0","191.96","N","0.0",
|
||||
"850113429","$4,115.36","$4,115.36","2026-04-16","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7213703","SAUCE SPICY YOGURT LOGO","1","1","95.98","0","95.98","N","0.0",
|
||||
"850113429","$4,115.36","$4,115.36","2026-04-16","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7792187","CHICKEN CVP THIGH BNLS SKLS","3","3","89.32","0","267.96","N","0.0",
|
||||
"850113429","$4,115.36","$4,115.36","2026-04-16","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7302646","YOGURT FRZN NF NICK THE GREEK","3","3","54.98","0","164.94","N","0.0",
|
||||
"850113429","$4,115.36","$4,115.36","2026-04-16","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7455027","SPANAKOPITA SPINACH COOKED","1","1","69.37","0","69.37","N","0.0",
|
||||
"850113429","$4,115.36","$4,115.36","2026-04-16","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","0932867","BEEF SHLDR TERES MAJOR SEL","1","1","7.556","0","371.76","Y","49.2",
|
||||
"850113429","$4,115.36","$4,115.36","2026-04-16","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7187055","BAKLAVA CLASSIC 2X24","1","1","49.53","0","49.53","N","0.0",
|
||||
"850113429","$4,115.36","$4,115.36","2026-04-16","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7124188","GYRO CHICKEN SHAWARMA CONE","7","7","86.97","0","608.79","N","0.0",
|
||||
"850113429","$4,115.36","$4,115.36","2026-04-16","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","9906087","MEAT GYRO BEEF CONE NTG","5","5","92.76","0","463.8","N","0.0",
|
||||
"850113429","$4,115.36","$4,115.36","2026-04-16","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","6592893","CHGS FOR FUEL SURCHARGE","1","1","3.82","0","3.82","N","0.0",
|
||||
"850121152","$1,242.02","$1,242.02","2026-04-18","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7417242","BAG PAPER 250 CT","1","1","46.07","0","46.07","N","0.0",
|
||||
"850121152","$1,242.02","$1,242.02","2026-04-18","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7455080","CHOCOLATE DUBAI PISTCHO KUNFEH","1","0","119.09","0","119.09","N","0.0",
|
||||
"850121152","$1,242.02","$1,242.02","2026-04-18","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7206974","JUICE CONC STRAWB DRAGON","1","1","172.37","0","172.37","N","0.0",
|
||||
"850121152","$1,242.02","$1,242.02","2026-04-18","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","5223334","BREAD PITA GYRO PRE-OILED 7","2","2","26.36","0","52.72","N","0.0",
|
||||
"850121152","$1,242.02","$1,242.02","2026-04-18","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7792187","CHICKEN CVP THIGH BNLS SKLS","1","1","89.32","0","89.32","N","0.0",
|
||||
"850121152","$1,242.02","$1,242.02","2026-04-18","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7124188","GYRO CHICKEN SHAWARMA CONE","1","1","86.97","0","86.97","N","0.0",
|
||||
"850121152","$1,242.02","$1,242.02","2026-04-18","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","9906087","MEAT GYRO BEEF CONE NTG","2","2","92.76","0","185.52","N","0.0",
|
||||
"850121152","$1,242.02","$1,242.02","2026-04-18","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7274591","APTZR VEG FALAFEL PUCK HALAL","1","1","97.94","0","97.94","N","0.0",
|
||||
"850121152","$1,242.02","$1,242.02","2026-04-18","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7212299","DESSERT MINI PLAIN BEIGNET","1","1","57.66","0","57.66","N","0.0",
|
||||
"850121152","$1,242.02","$1,242.02","2026-04-18","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7324922","PASTRY BEIGNET MN FLD CHOCCRML","1","1","53.82","0","53.82","N","0.0",
|
||||
"850121152","$1,242.02","$1,242.02","2026-04-18","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7053293","RICE BASMATI PABROIL SELA CS","1","1","54.84","0","54.84","N","0.0",
|
||||
"850121152","$1,242.02","$1,242.02","2026-04-18","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7301949","RICE MIX NICKS","1","1","31.07","0","31.07","N","0.0",
|
||||
"850121152","$1,242.02","$1,242.02","2026-04-18","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7321835","BOX CATERING 21X13X4.25 LOGO","1","1","68.25","0","68.25","N","0.0",
|
||||
"850121152","$1,242.02","$1,242.02","2026-04-18","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7360056","DIP GARLIC TOUM","1","1","29.4","0","29.4","N","0.0",
|
||||
"850121152","$1,242.02","$1,242.02","2026-04-18","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7213639","SAUCE TZATZIKI","1","1","95.98","0","95.98","N","0.0",
|
||||
"850121152","$1,242.02","$1,242.02","2026-04-18","2026-06-12","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","6592893","CHGS FOR FUEL SURCHARGE","1","1","1","0","1","N","0.0",
|
||||
"850122686","$2,636.43","$2,636.43","2026-04-20","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7455080","CHOCOLATE DUBAI PISTCHO KUNFEH","1","1","119.09","0","119.09","N","0.0",
|
||||
"850122686","$2,636.43","$2,636.43","2026-04-20","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7452585","NAPKIN 2PLY INTR FOLD 6.3X8.26","1","1","22.32","0","22.32","N","0.0",
|
||||
"850122686","$2,636.43","$2,636.43","2026-04-20","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","1706813","FORK PLAS PP X-HVY BLK","1","1","16.03","0","16.03","N","0.0",
|
||||
"850122686","$2,636.43","$2,636.43","2026-04-20","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","1706914","KNIFE PLAS PP X-HVY BLK","1","1","17.66","0","17.66","N","0.0",
|
||||
"850122686","$2,636.43","$2,636.43","2026-04-20","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7064580","CUP PLAS TRANS HIPS 12 OZ","1","1","42.93","0","42.93","N","0.0",
|
||||
"850122686","$2,636.43","$2,636.43","2026-04-20","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7250678","CONTAINER PAPER MLD FBR 9X6","2","2","37.6","0","75.2","N","0.0",
|
||||
"850122686","$2,636.43","$2,636.43","2026-04-20","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7354142","TRAY PAPER FOOD 2LB LOGO NTG","1","1","24.6","0","24.6","N","0.0",
|
||||
"850122686","$2,636.43","$2,636.43","2026-04-20","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7417242","BAG PAPER 250 CT","1","1","46.07","0","46.07","N","0.0",
|
||||
"850122686","$2,636.43","$2,636.43","2026-04-20","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7293283","PAN FOIL STM TBL FULL DP 3-3/8","0","0","50.01","0","0","N","0.0",
|
||||
"850122686","$2,636.43","$2,636.43","2026-04-20","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","8936379","SODA PEPSI COLA","1","1","34.01","0","34.01","N","0.0",
|
||||
"850122686","$2,636.43","$2,636.43","2026-04-20","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","0635005","SODA COLA PEPSI ZERO SUGAR","1","1","34.01","0","34.01","N","0.0",
|
||||
"850122686","$2,636.43","$2,636.43","2026-04-20","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","5167711","SODA ORANGE CRSH","1","1","34.01","0","34.01","N","0.0",
|
||||
"850122686","$2,636.43","$2,636.43","2026-04-20","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","8492330","WATER PURIFIED BTL PET LSE DW","1","1","20.56","0","20.56","N","0.0",
|
||||
"850122686","$2,636.43","$2,636.43","2026-04-20","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","2102479","SKEWER BAMBOO 10IN","1","0","7.82","0","7.82","N","0.0","S"
|
||||
"850122686","$2,636.43","$2,636.43","2026-04-20","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","5254743","SPICE PAPRIKA GROUND","1","0","45.84","0","45.84","N","0.0","S"
|
||||
"850122686","$2,636.43","$2,636.43","2026-04-20","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7053293","RICE BASMATI PABROIL SELA CS","1","1","54.84","0","54.84","N","0.0",
|
||||
"850122686","$2,636.43","$2,636.43","2026-04-20","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","2039220","POTATO KENNEBEC FRESH","1","1","23.65","0","23.65","N","0.0",
|
||||
"850122686","$2,636.43","$2,636.43","2026-04-20","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7108399","DRESSING VINAIGRETTE LOGO","1","1","75.49","0","75.49","N","0.0",
|
||||
"850122686","$2,636.43","$2,636.43","2026-04-20","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","5934302","OIL OLIVE BLEND 80/20","1","1","78.45","0","78.45","N","0.0",
|
||||
"850122686","$2,636.43","$2,636.43","2026-04-20","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","4062337","BEAN GARBANZO FCY NO SULFITE","1","1","31.8","0","31.8","N","0.0",
|
||||
"850122686","$2,636.43","$2,636.43","2026-04-20","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","4372932","PEPPER BANANA MILD RING","1","1","40.85","0","40.85","N","0.0",
|
||||
"850122686","$2,636.43","$2,636.43","2026-04-20","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","4693715","PASTE HERB HARISSA MOROCCAN","1","1","28.05","0","28.05","N","0.0",
|
||||
"850122686","$2,636.43","$2,636.43","2026-04-20","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","4823761","OIL CORN","1","1","38.85","0","38.85","N","0.0",
|
||||
"850122686","$2,636.43","$2,636.43","2026-04-20","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","5223334","BREAD PITA GYRO PRE-OILED 7","4","4","26.36","0","105.44","N","0.0",
|
||||
"850122686","$2,636.43","$2,636.43","2026-04-20","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7278619","SPREAD HUMMUS TRADITIONAL","1","1","56.72","0","56.72","N","0.0",
|
||||
"850122686","$2,636.43","$2,636.43","2026-04-20","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7253487","CHEESE FETA RW","1","1","2.513","0","88.66","Y","35.28",
|
||||
"850122686","$2,636.43","$2,636.43","2026-04-20","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7213639","SAUCE TZATZIKI","1","1","95.98","0","95.98","N","0.0",
|
||||
"850122686","$2,636.43","$2,636.43","2026-04-20","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7213703","SAUCE SPICY YOGURT LOGO","1","1","95.98","0","95.98","N","0.0",
|
||||
"850122686","$2,636.43","$2,636.43","2026-04-20","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7792187","CHICKEN CVP THIGH BNLS SKLS","1","1","89.32","0","89.32","N","0.0",
|
||||
"850122686","$2,636.43","$2,636.43","2026-04-20","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7302646","YOGURT FRZN NF NICK THE GREEK","1","1","54.93","0","54.93","N","0.0",
|
||||
"850122686","$2,636.43","$2,636.43","2026-04-20","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7211838","PORK SLI GYRO CONE","1","1","87.99","0","87.99","N","0.0",
|
||||
"850122686","$2,636.43","$2,636.43","2026-04-20","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7124188","GYRO CHICKEN SHAWARMA CONE","5","5","87.04","0","435.2","N","0.0",
|
||||
"850122686","$2,636.43","$2,636.43","2026-04-20","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","9906087","MEAT GYRO BEEF CONE NTG","6","6","92.86","0","557.16","N","0.0",
|
||||
"850122686","$2,636.43","$2,636.43","2026-04-20","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7187055","BAKLAVA CLASSIC 2X24","1","1","49.53","0","49.53","N","0.0",
|
||||
"850122686","$2,636.43","$2,636.43","2026-04-20","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","6592893","CHGS FOR FUEL SURCHARGE","1","1","2.59","0","2.59","N","0.0",
|
||||
"850125010","$0.00","-$119.09","2026-04-21","2026-04-21","Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7455080","CHOCOLATE DUBAI PISTCHO KUNFEH","-1","-1","119.09","0","-119.09","N","0.0",
|
||||
"850130314","$4,393.39","$4,393.39","2026-04-23","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","0191567","STRAW PLAS TRANS JMB WRPD 7.75","1","0","5.09","0","5.09","N","0.0","S"
|
||||
"850130314","$4,393.39","$4,393.39","2026-04-23","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7790795","LID PLAS CLR F/1.5-2.5OZ PRTN","1","1","30.05","0","30.05","N","0.0",
|
||||
"850130314","$4,393.39","$4,393.39","2026-04-23","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7408008","BOWL PLASTIC COATING 42 OZ","2","2","43.99","0","87.98","N","0.0",
|
||||
"850130314","$4,393.39","$4,393.39","2026-04-23","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7408215","LID CLEAR PET 42 OZ","2","2","41.25","0","82.5","N","0.0",
|
||||
"850130314","$4,393.39","$4,393.39","2026-04-23","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7354127","CUP PAPER COLD 22 OZ LOGO NTG","1","1","47.6","0","47.6","N","0.0",
|
||||
"850130314","$4,393.39","$4,393.39","2026-04-23","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7661388","LID PLAS FLAT F/12-22 OZ","1","1","25.41","0","25.41","N","0.0",
|
||||
"850130314","$4,393.39","$4,393.39","2026-04-23","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7354120","CONTAINER PAPER 1/30 OZ NTG","1","1","30.6","0","30.6","N","0.0",
|
||||
"850130314","$4,393.39","$4,393.39","2026-04-23","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7354119","CONTAINER PAPER 4/110OZ NTG","1","1","27.6","0","27.6","N","0.0",
|
||||
"850130314","$4,393.39","$4,393.39","2026-04-23","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7250678","CONTAINER PAPER MLD FBR 9X6","3","3","37.6","0","112.8","N","0.0",
|
||||
"850130314","$4,393.39","$4,393.39","2026-04-23","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7417242","BAG PAPER 250 CT","2","2","46.07","0","92.14","N","0.0",
|
||||
"850130314","$4,393.39","$4,393.39","2026-04-23","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7293283","PAN FOIL STM TBL FULL DP 3-3/8","1","1","50.01","4.88","50.01","N","0.0",
|
||||
"850130314","$4,393.39","$4,393.39","2026-04-23","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7465969","PAN FOIL STM TBL DEEPXH 2-9/16","1","1","44.75","4.36","44.75","N","0.0",
|
||||
"850130314","$4,393.39","$4,393.39","2026-04-23","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","5056098","LINER TRASH 40X48 13 MC NAT","1","1","56.06","5.46","56.06","N","0.0",
|
||||
"850130314","$4,393.39","$4,393.39","2026-04-23","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","1336403","SODA DR PPR REG","1","1","34.01","0","34.01","N","0.0",
|
||||
"850130314","$4,393.39","$4,393.39","2026-04-23","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","8492330","WATER PURIFIED BTL PET LSE DW","1","1","20.56","0","20.56","N","0.0",
|
||||
"850130314","$4,393.39","$4,393.39","2026-04-23","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","9911226","SODA CHERRY VISSINADA GREEK","1","1","14.93","0","14.93","N","0.0",
|
||||
"850130314","$4,393.39","$4,393.39","2026-04-23","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","9910355","SODA LEMON LEMONADA GREEK","1","1","14.93","0","14.93","N","0.0",
|
||||
"850130314","$4,393.39","$4,393.39","2026-04-23","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","9911229","WATER MINERAL CARNONATED GREEK","1","1","26.57","0","26.57","N","0.0",
|
||||
"850130314","$4,393.39","$4,393.39","2026-04-23","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7153421","SYRUP COLA PEPSI ZERO BIB","1","1","71.94","0","71.94","N","0.0",
|
||||
"850130314","$4,393.39","$4,393.39","2026-04-23","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7296407","GLOVE NITRILE BLK PEDRFREE LRG","1","1","39.41","3.85","39.41","N","0.0",
|
||||
"850130314","$4,393.39","$4,393.39","2026-04-23","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7301949","RICE MIX NICKS","1","1","31.07","0","31.07","N","0.0",
|
||||
"850130314","$4,393.39","$4,393.39","2026-04-23","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7053293","RICE BASMATI PABROIL SELA CS","1","1","54.84","0","54.84","N","0.0",
|
||||
"850130314","$4,393.39","$4,393.39","2026-04-23","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","2039220","POTATO KENNEBEC FRESH","5","5","23.65","0","118.25","N","0.0",
|
||||
"850130314","$4,393.39","$4,393.39","2026-04-23","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7360056","DIP GARLIC TOUM","2","2","29.4","0","58.8","N","0.0",
|
||||
"850130314","$4,393.39","$4,393.39","2026-04-23","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","4823761","OIL CORN","3","3","38.85","0","116.55","N","0.0",
|
||||
"850130314","$4,393.39","$4,393.39","2026-04-23","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7171302","DRESSING MARINADE SOUVLAKI","1","1","72.44","0","72.44","N","0.0",
|
||||
"850130314","$4,393.39","$4,393.39","2026-04-23","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","5223334","BREAD PITA GYRO PRE-OILED 7","7","7","26.36","0","184.52","N","0.0",
|
||||
"850130314","$4,393.39","$4,393.39","2026-04-23","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7278619","SPREAD HUMMUS TRADITIONAL","2","2","56.72","0","113.44","N","0.0",
|
||||
"850130314","$4,393.39","$4,393.39","2026-04-23","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7213639","SAUCE TZATZIKI","2","2","95.98","0","191.96","N","0.0",
|
||||
"850130314","$4,393.39","$4,393.39","2026-04-23","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7792187","CHICKEN CVP THIGH BNLS SKLS","4","4","89.32","0","357.28","N","0.0",
|
||||
"850130314","$4,393.39","$4,393.39","2026-04-23","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7302646","YOGURT FRZN NF NICK THE GREEK","2","2","54.93","0","109.86","N","0.0",
|
||||
"850130314","$4,393.39","$4,393.39","2026-04-23","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7007041","BUN BRIOCHE HOMESTYLE 4.25","1","1","30.93","0","30.93","N","0.0",
|
||||
"850130314","$4,393.39","$4,393.39","2026-04-23","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7455027","SPANAKOPITA SPINACH COOKED","1","1","69.37","0","69.37","N","0.0",
|
||||
"850130314","$4,393.39","$4,393.39","2026-04-23","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","0932867","BEEF SHLDR TERES MAJOR SEL","1","1","7.556","0","356.64","Y","47.2",
|
||||
"850130314","$4,393.39","$4,393.39","2026-04-23","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7211838","PORK SLI GYRO CONE","1","1","87.99","0","87.99","N","0.0",
|
||||
"850130314","$4,393.39","$4,393.39","2026-04-23","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7124188","GYRO CHICKEN SHAWARMA CONE","7","7","87.04","0","609.28","N","0.0",
|
||||
"850130314","$4,393.39","$4,393.39","2026-04-23","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","9906087","MEAT GYRO BEEF CONE NTG","8","8","92.86","0","742.88","N","0.0",
|
||||
"850130314","$4,393.39","$4,393.39","2026-04-23","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7274591","APTZR VEG FALAFEL PUCK HALAL","1","1","98.4","0","98.4","N","0.0",
|
||||
"850130314","$4,393.39","$4,393.39","2026-04-23","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7324922","PASTRY BEIGNET MN FLD CHOCCRML","1","1","53.82","0","53.82","N","0.0",
|
||||
"850130314","$4,393.39","$4,393.39","2026-04-23","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","9477498","ALLOWANCE FOR DROP SIZE","1","1","-7.5","0","-7.5","N","0.0",
|
||||
"850130314","$4,393.39","$4,393.39","2026-04-23","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","6592893","CHGS FOR FUEL SURCHARGE","1","1","4.27","0.01","4.27","N","0.0",
|
||||
"850137898","$104.49","$104.49","2026-04-25","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7187055","BAKLAVA CLASSIC 2X24","1","1","49.53","0","49.53","N","0.0",
|
||||
"850137898","$104.49","$104.49","2026-04-25","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7053293","RICE BASMATI PABROIL SELA CS","1","1","54.84","0","54.84","N","0.0",
|
||||
"850137898","$104.49","$104.49","2026-04-25","2026-06-19","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","6592893","CHGS FOR FUEL SURCHARGE","1","1","0.12","0","0.12","N","0.0",
|
||||
"850139122","$3,461.13","$3,461.13","2026-04-27","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7452585","NAPKIN 2PLY INTR FOLD 6.3X8.26","1","1","22.32","0","22.32","N","0.0",
|
||||
"850139122","$3,461.13","$3,461.13","2026-04-27","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","1706813","FORK PLAS PP X-HVY BLK","0","0","16.03","0","0","N","0.0",
|
||||
"850139122","$3,461.13","$3,461.13","2026-04-27","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7408008","BOWL PLASTIC COATING 42 OZ","1","1","43.99","0","43.99","N","0.0",
|
||||
"850139122","$3,461.13","$3,461.13","2026-04-27","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7408215","LID CLEAR PET 42 OZ","1","1","41.25","0","41.25","N","0.0",
|
||||
"850139122","$3,461.13","$3,461.13","2026-04-27","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7354119","CONTAINER PAPER 4/110OZ NTG","1","1","27.6","0","27.6","N","0.0",
|
||||
"850139122","$3,461.13","$3,461.13","2026-04-27","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7250678","CONTAINER PAPER MLD FBR 9X6","2","2","37.6","0","75.2","N","0.0",
|
||||
"850139122","$3,461.13","$3,461.13","2026-04-27","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7417242","BAG PAPER 250 CT","2","2","46.07","0","92.14","N","0.0",
|
||||
"850139122","$3,461.13","$3,461.13","2026-04-27","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7293257","LID FOIL F/FULL STM TBL PAN","1","1","54.17","5.27","54.17","N","0.0",
|
||||
"850139122","$3,461.13","$3,461.13","2026-04-27","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","6938211","LID FOIL F/ HALF STMTBL PAN","1","1","29.53","2.89","29.53","N","0.0",
|
||||
"850139122","$3,461.13","$3,461.13","2026-04-27","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","8492330","WATER PURIFIED BTL PET LSE DW","1","1","20.56","0","20.56","N","0.0",
|
||||
"850139122","$3,461.13","$3,461.13","2026-04-27","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","9911228","SODA ORANGE PORTOKALADA GREEK","1","1","14.93","0","14.93","N","0.0",
|
||||
"850139122","$3,461.13","$3,461.13","2026-04-27","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","2102479","SKEWER BAMBOO 10IN","1","0","7.82","0","7.82","N","0.0","S"
|
||||
"850139122","$3,461.13","$3,461.13","2026-04-27","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7053293","RICE BASMATI PABROIL SELA CS","1","1","54.84","0","54.84","N","0.0",
|
||||
"850139122","$3,461.13","$3,461.13","2026-04-27","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","2039220","POTATO KENNEBEC FRESH","2","2","23.65","0","47.3","N","0.0",
|
||||
"850139122","$3,461.13","$3,461.13","2026-04-27","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7108399","DRESSING VINAIGRETTE LOGO","1","1","75.49","0","75.49","N","0.0",
|
||||
"850139122","$3,461.13","$3,461.13","2026-04-27","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","4823761","OIL CORN","1","1","38.85","0","38.85","N","0.0",
|
||||
"850139122","$3,461.13","$3,461.13","2026-04-27","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7171302","DRESSING MARINADE SOUVLAKI","1","1","72.44","0","72.44","N","0.0",
|
||||
"850139122","$3,461.13","$3,461.13","2026-04-27","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","5223334","BREAD PITA GYRO PRE-OILED 7","5","5","26.36","0","131.8","N","0.0",
|
||||
"850139122","$3,461.13","$3,461.13","2026-04-27","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","1376805","PAD SCOUR GRN 6X9IN ANTIMICRO","1","1","11.79","1.16","11.79","N","0.0",
|
||||
"850139122","$3,461.13","$3,461.13","2026-04-27","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7485170","BUTTER SOLID USDA AA UNSLTD","1","1","74.83","0","74.83","N","0.0",
|
||||
"850139122","$3,461.13","$3,461.13","2026-04-27","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7278619","SPREAD HUMMUS TRADITIONAL","1","1","56.72","0","56.72","N","0.0",
|
||||
"850139122","$3,461.13","$3,461.13","2026-04-27","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7253487","CHEESE FETA RW","1","1","2.513","0","88.18","Y","35.09",
|
||||
"850139122","$3,461.13","$3,461.13","2026-04-27","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7213639","SAUCE TZATZIKI","2","2","95.98","0","191.96","N","0.0",
|
||||
"850139122","$3,461.13","$3,461.13","2026-04-27","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7213703","SAUCE SPICY YOGURT LOGO","1","1","95.98","0","95.98","N","0.0",
|
||||
"850139122","$3,461.13","$3,461.13","2026-04-27","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7792187","CHICKEN CVP THIGH BNLS SKLS","3","3","89.32","0","267.96","N","0.0",
|
||||
"850139122","$3,461.13","$3,461.13","2026-04-27","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7302646","YOGURT FRZN NF NICK THE GREEK","1","1","54.98","0","54.98","N","0.0",
|
||||
"850139122","$3,461.13","$3,461.13","2026-04-27","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7455027","SPANAKOPITA SPINACH COOKED","1","1","69.37","0","69.37","N","0.0",
|
||||
"850139122","$3,461.13","$3,461.13","2026-04-27","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","0932867","BEEF SHLDR TERES MAJOR SEL","1","1","7.556","0","396.69","Y","52.5",
|
||||
"850139122","$3,461.13","$3,461.13","2026-04-27","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7211838","PORK SLI GYRO CONE","1","1","87.99","0","87.99","N","0.0",
|
||||
"850139122","$3,461.13","$3,461.13","2026-04-27","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7124188","GYRO CHICKEN SHAWARMA CONE","6","6","87.04","0","522.24","N","0.0",
|
||||
"850139122","$3,461.13","$3,461.13","2026-04-27","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","9906087","MEAT GYRO BEEF CONE NTG","6","6","92.86","0","557.16","N","0.0",
|
||||
"850139122","$3,461.13","$3,461.13","2026-04-27","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7274591","APTZR VEG FALAFEL PUCK HALAL","1","1","98.4","0","98.4","N","0.0",
|
||||
"850139122","$3,461.13","$3,461.13","2026-04-27","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","2520551","FORK PLAS PP HVY BLK FULL LENG","1","1","22.59","0","22.59","N","0.0",
|
||||
"850139122","$3,461.13","$3,461.13","2026-04-27","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","6592893","CHGS FOR FUEL SURCHARGE","1","1","2.94","0","2.94","N","0.0",
|
||||
"850146532","$4,054.54","$4,054.54","2026-04-30","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","1706813","FORK PLAS PP X-HVY BLK","1","1","16.03","0","16.03","N","0.0",
|
||||
"850146532","$4,054.54","$4,054.54","2026-04-30","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","4613026","CUP PORTION PLAS CLR 1.50 OZ","1","1","26.15","0","26.15","N","0.0",
|
||||
"850146532","$4,054.54","$4,054.54","2026-04-30","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7373744","DESSERT CUP","1","1","72.22","0","72.22","N","0.0",
|
||||
"850146532","$4,054.54","$4,054.54","2026-04-30","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7373626","LID DOME DESSERT CUP","1","1","53.59","0","53.59","N","0.0",
|
||||
"850146532","$4,054.54","$4,054.54","2026-04-30","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7408008","BOWL PLASTIC COATING 42 OZ","1","1","43.99","0","43.99","N","0.0",
|
||||
"850146532","$4,054.54","$4,054.54","2026-04-30","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7408215","LID CLEAR PET 42 OZ","1","1","41.25","0","41.25","N","0.0",
|
||||
"850146532","$4,054.54","$4,054.54","2026-04-30","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7354120","CONTAINER PAPER 1/30 OZ NTG","1","1","30.6","0","30.6","N","0.0",
|
||||
"850146532","$4,054.54","$4,054.54","2026-04-30","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7354119","CONTAINER PAPER 4/110OZ NTG","1","1","27.6","0","27.6","N","0.0",
|
||||
"850146532","$4,054.54","$4,054.54","2026-04-30","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7250678","CONTAINER PAPER MLD FBR 9X6","3","3","37.6","0","112.8","N","0.0",
|
||||
"850146532","$4,054.54","$4,054.54","2026-04-30","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7417242","BAG PAPER 250 CT","2","2","46.07","0","92.14","N","0.0",
|
||||
"850146532","$4,054.54","$4,054.54","2026-04-30","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7365006","WRAP PAPER 14X14 LOGO VER2","1","1","91.04","0","91.04","N","0.0",
|
||||
"850146532","$4,054.54","$4,054.54","2026-04-30","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","8936379","SODA PEPSI COLA","1","1","34.01","0","34.01","N","0.0",
|
||||
"850146532","$4,054.54","$4,054.54","2026-04-30","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","0635005","SODA COLA PEPSI ZERO SUGAR","1","1","34.01","0","34.01","N","0.0",
|
||||
"850146532","$4,054.54","$4,054.54","2026-04-30","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","1336403","SODA DR PPR REG","1","1","34.01","0","34.01","N","0.0",
|
||||
"850146532","$4,054.54","$4,054.54","2026-04-30","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","9911226","SODA CHERRY VISSINADA GREEK","1","1","14.93","0","14.93","N","0.0",
|
||||
"850146532","$4,054.54","$4,054.54","2026-04-30","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7459969","SYRUP DR PPR DIET BIB","1","1","62.48","0","62.48","N","0.0",
|
||||
"850146532","$4,054.54","$4,054.54","2026-04-30","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7296407","GLOVE NITRILE BLK PEDRFREE LRG","1","1","39.41","3.85","39.41","N","0.0",
|
||||
"850146532","$4,054.54","$4,054.54","2026-04-30","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7301949","RICE MIX NICKS","1","1","31.07","0","31.07","N","0.0",
|
||||
"850146532","$4,054.54","$4,054.54","2026-04-30","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7053293","RICE BASMATI PABROIL SELA CS","2","2","54.84","0","109.68","N","0.0",
|
||||
"850146532","$4,054.54","$4,054.54","2026-04-30","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","2039220","POTATO KENNEBEC FRESH","5","5","23.65","0","118.25","N","0.0",
|
||||
"850146532","$4,054.54","$4,054.54","2026-04-30","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7108399","DRESSING VINAIGRETTE LOGO","1","1","75.49","0","75.49","N","0.0",
|
||||
"850146532","$4,054.54","$4,054.54","2026-04-30","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","4062337","BEAN GARBANZO FCY NO SULFITE","1","1","31.8","0","31.8","N","0.0",
|
||||
"850146532","$4,054.54","$4,054.54","2026-04-30","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7360056","DIP GARLIC TOUM","2","2","29.4","0","58.8","N","0.0",
|
||||
"850146532","$4,054.54","$4,054.54","2026-04-30","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","4823761","OIL CORN","1","1","38.85","0","38.85","N","0.0",
|
||||
"850146532","$4,054.54","$4,054.54","2026-04-30","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","4136768","KETCHUP PACKET FCY","1","1","34","0","34","N","0.0",
|
||||
"850146532","$4,054.54","$4,054.54","2026-04-30","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7171302","DRESSING MARINADE SOUVLAKI","1","1","72.44","0","72.44","N","0.0",
|
||||
"850146532","$4,054.54","$4,054.54","2026-04-30","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","5223334","BREAD PITA GYRO PRE-OILED 7","7","7","26.36","0","184.52","N","0.0",
|
||||
"850146532","$4,054.54","$4,054.54","2026-04-30","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7278619","SPREAD HUMMUS TRADITIONAL","2","2","56.72","0","113.44","N","0.0",
|
||||
"850146532","$4,054.54","$4,054.54","2026-04-30","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7253487","CHEESE FETA RW","1","1","2.513","0","97.86","Y","38.94",
|
||||
"850146532","$4,054.54","$4,054.54","2026-04-30","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7213639","SAUCE TZATZIKI","2","2","95.98","0","191.96","N","0.0",
|
||||
"850146532","$4,054.54","$4,054.54","2026-04-30","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7792187","CHICKEN CVP THIGH BNLS SKLS","3","3","89.32","0","267.96","N","0.0",
|
||||
"850146532","$4,054.54","$4,054.54","2026-04-30","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7302646","YOGURT FRZN NF NICK THE GREEK","2","2","54.98","0","109.96","N","0.0",
|
||||
"850146532","$4,054.54","$4,054.54","2026-04-30","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7455027","SPANAKOPITA SPINACH COOKED","1","1","69.37","0","69.37","N","0.0",
|
||||
"850146532","$4,054.54","$4,054.54","2026-04-30","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","0932867","BEEF SHLDR TERES MAJOR SEL","1","1","7.556","0","378.56","Y","50.1",
|
||||
"850146532","$4,054.54","$4,054.54","2026-04-30","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7124188","GYRO CHICKEN SHAWARMA CONE","6","6","87.04","0","522.24","N","0.0",
|
||||
"850146532","$4,054.54","$4,054.54","2026-04-30","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","9906087","MEAT GYRO BEEF CONE NTG","6","6","92.86","0","557.16","N","0.0",
|
||||
"850146532","$4,054.54","$4,054.54","2026-04-30","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7187055","BAKLAVA CLASSIC 2X24","2","2","49.53","0","99.06","N","0.0",
|
||||
"850146532","$4,054.54","$4,054.54","2026-04-30","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7324922","PASTRY BEIGNET MN FLD CHOCCRML","1","1","53.82","0","53.82","N","0.0",
|
||||
"850146532","$4,054.54","$4,054.54","2026-04-30","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7212299","DESSERT MINI PLAIN BEIGNET","0","0","57.66","0","0","N","0.0",
|
||||
"850146532","$4,054.54","$4,054.54","2026-04-30","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","6592893","CHGS FOR FUEL SURCHARGE","1","1","3.94","0","3.94","N","0.0",
|
||||
"850157242","$434.43","$434.43","2026-05-02","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","9906087","MEAT GYRO BEEF CONE NTG","2","2","92.86","0","185.72","N","0.0",
|
||||
"850157242","$434.43","$434.43","2026-05-02","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7124188","GYRO CHICKEN SHAWARMA CONE","1","1","87.04","0","87.04","N","0.0",
|
||||
"850157242","$434.43","$434.43","2026-05-02","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","8492330","WATER PURIFIED BTL PET LSE DW","1","1","20.56","0","20.56","N","0.0",
|
||||
"850157242","$434.43","$434.43","2026-05-02","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7213639","SAUCE TZATZIKI","1","1","95.98","0","95.98","N","0.0",
|
||||
"850157242","$434.43","$434.43","2026-05-02","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7408008","BOWL PLASTIC COATING 42 OZ","1","1","43.58","0","43.58","N","0.0",
|
||||
"850157242","$434.43","$434.43","2026-05-02","2026-06-26","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","6592893","CHGS FOR FUEL SURCHARGE","1","1","0.35","0","0.35","N","0.0",
|
||||
"850158779","$4,144.09","$4,144.09","2026-05-04","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","5001858","CHICKEN CVP THIGH B/S HALAL JM","4","4","99.56","0","398.24","N","0.0",
|
||||
"850158779","$4,144.09","$4,144.09","2026-05-04","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7321835","BOX CATERING 21X13X4.25 LOGO","1","1","68.25","0","68.25","N","0.0",
|
||||
"850158779","$4,144.09","$4,144.09","2026-05-04","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7452585","NAPKIN 2PLY INTR FOLD 6.3X8.26","1","1","22.32","0","22.32","N","0.0",
|
||||
"850158779","$4,144.09","$4,144.09","2026-05-04","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","0191567","STRAW PLAS TRANS JMB WRPD 7.75","1","0","5.09","0","5.09","N","0.0","S"
|
||||
"850158779","$4,144.09","$4,144.09","2026-05-04","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","1706946","SPOON PLAS TEA PP X-HVY BLK","1","1","18.01","0","18.01","N","0.0",
|
||||
"850158779","$4,144.09","$4,144.09","2026-05-04","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","1706813","FORK PLAS PP X-HVY BLK","1","1","16.03","0","16.03","N","0.0",
|
||||
"850158779","$4,144.09","$4,144.09","2026-05-04","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7408008","BOWL PLASTIC COATING 42 OZ","1","1","43.58","0","43.58","N","0.0",
|
||||
"850158779","$4,144.09","$4,144.09","2026-05-04","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7408215","LID CLEAR PET 42 OZ","1","1","40.99","0","40.99","N","0.0",
|
||||
"850158779","$4,144.09","$4,144.09","2026-05-04","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7250678","CONTAINER PAPER MLD FBR 9X6","2","2","37.6","0","75.2","N","0.0",
|
||||
"850158779","$4,144.09","$4,144.09","2026-05-04","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7354120","CONTAINER PAPER 1/30 OZ NTG","1","1","30.6","0","30.6","N","0.0",
|
||||
"850158779","$4,144.09","$4,144.09","2026-05-04","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7417242","BAG PAPER 250 CT","2","2","46.28","0","92.56","N","0.0",
|
||||
"850158779","$4,144.09","$4,144.09","2026-05-04","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","5167711","SODA ORANGE CRSH","1","1","34.01","0","34.01","N","0.0",
|
||||
"850158779","$4,144.09","$4,144.09","2026-05-04","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","9911228","SODA ORANGE PORTOKALADA GREEK","1","1","14.93","0","14.93","N","0.0",
|
||||
"850158779","$4,144.09","$4,144.09","2026-05-04","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7053293","RICE BASMATI PABROIL SELA CS","2","2","54.84","0","109.68","N","0.0",
|
||||
"850158779","$4,144.09","$4,144.09","2026-05-04","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","2039220","POTATO KENNEBEC FRESH","5","5","23.65","0","118.25","N","0.0",
|
||||
"850158779","$4,144.09","$4,144.09","2026-05-04","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7171301","DRESSING SALAD PRASINI","1","1","74.46","0","74.46","N","0.0",
|
||||
"850158779","$4,144.09","$4,144.09","2026-05-04","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7108399","DRESSING VINAIGRETTE LOGO","1","1","75.93","0","75.93","N","0.0",
|
||||
"850158779","$4,144.09","$4,144.09","2026-05-04","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","5934302","OIL OLIVE BLEND 80/20","1","1","82.96","0","82.96","N","0.0",
|
||||
"850158779","$4,144.09","$4,144.09","2026-05-04","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","9910846","OLIVE KALAMATA PTD BRNE 22 LB","1","1","79.42","0","79.42","N","0.0",
|
||||
"850158779","$4,144.09","$4,144.09","2026-05-04","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","4693715","PASTE HERB HARISSA MOROCCAN","1","1","28.05","0","28.05","N","0.0",
|
||||
"850158779","$4,144.09","$4,144.09","2026-05-04","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","4823761","OIL CORN","1","1","38.85","0","38.85","N","0.0",
|
||||
"850158779","$4,144.09","$4,144.09","2026-05-04","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","4361432","HONEY PURE CLOVER GR A TSC JUG","1","1","118.35","0","118.35","N","0.0",
|
||||
"850158779","$4,144.09","$4,144.09","2026-05-04","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7171302","DRESSING MARINADE SOUVLAKI","1","1","73.28","0","73.28","N","0.0",
|
||||
"850158779","$4,144.09","$4,144.09","2026-05-04","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","5223334","BREAD PITA GYRO PRE-OILED 7","7","7","26.36","0","184.52","N","0.0",
|
||||
"850158779","$4,144.09","$4,144.09","2026-05-04","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7278619","SPREAD HUMMUS TRADITIONAL","2","2","56.72","0","113.44","N","0.0",
|
||||
"850158779","$4,144.09","$4,144.09","2026-05-04","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7213639","SAUCE TZATZIKI","1","1","95.98","0","95.98","N","0.0",
|
||||
"850158779","$4,144.09","$4,144.09","2026-05-04","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7213703","SAUCE SPICY YOGURT LOGO","1","1","95.98","0","95.98","N","0.0",
|
||||
"850158779","$4,144.09","$4,144.09","2026-05-04","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7302646","YOGURT FRZN NF NICK THE GREEK","2","2","54.93","0","109.86","N","0.0",
|
||||
"850158779","$4,144.09","$4,144.09","2026-05-04","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","8142366","BEEF GRND CHUCK FINE 80/20FRSH","1","1","5.14","0","314.05","Y","61.1",
|
||||
"850158779","$4,144.09","$4,144.09","2026-05-04","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","0932867","BEEF SHLDR TERES MAJOR SEL","1","1","7.556","0","337.75","Y","44.7",
|
||||
"850158779","$4,144.09","$4,144.09","2026-05-04","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7211838","PORK SLI GYRO CONE","1","1","87.99","0","87.99","N","0.0",
|
||||
"850158779","$4,144.09","$4,144.09","2026-05-04","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7124188","GYRO CHICKEN SHAWARMA CONE","5","5","87.02","0","435.1","N","0.0",
|
||||
"850158779","$4,144.09","$4,144.09","2026-05-04","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","9906087","MEAT GYRO BEEF CONE NTG","6","6","92.86","0","557.16","N","0.0",
|
||||
"850158779","$4,144.09","$4,144.09","2026-05-04","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7187055","BAKLAVA CLASSIC 2X24","1","1","49.53","0","49.53","N","0.0",
|
||||
"850158779","$4,144.09","$4,144.09","2026-05-04","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7274591","APTZR VEG FALAFEL PUCK HALAL","1","1","98.4","0","98.4","N","0.0",
|
||||
"850158779","$4,144.09","$4,144.09","2026-05-04","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","6592893","CHGS FOR FUEL SURCHARGE","1","1","3.49","0","3.49","N","0.0",
|
||||
"850166198","$3,999.80","$3,999.80","2026-05-07","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","0191567","STRAW PLAS TRANS JMB WRPD 7.75","1","0","5.09","0","5.09","N","0.0","S"
|
||||
"850166198","$3,999.80","$3,999.80","2026-05-07","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7790795","LID PLAS CLR F/1.5-2.5OZ PRTN","1","1","30.05","0","30.05","N","0.0",
|
||||
"850166198","$3,999.80","$3,999.80","2026-05-07","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7408008","BOWL PLASTIC COATING 42 OZ","1","1","43.58","0","43.58","N","0.0",
|
||||
"850166198","$3,999.80","$3,999.80","2026-05-07","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7408215","LID CLEAR PET 42 OZ","1","1","40.99","0","40.99","N","0.0",
|
||||
"850166198","$3,999.80","$3,999.80","2026-05-07","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7354119","CONTAINER PAPER 4/110OZ NTG","1","1","27.6","0","27.6","N","0.0",
|
||||
"850166198","$3,999.80","$3,999.80","2026-05-07","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7250678","CONTAINER PAPER MLD FBR 9X6","2","2","37.6","0","75.2","N","0.0",
|
||||
"850166198","$3,999.80","$3,999.80","2026-05-07","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7417242","BAG PAPER 250 CT","1","1","46.28","0","46.28","N","0.0",
|
||||
"850166198","$3,999.80","$3,999.80","2026-05-07","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","5723808","WRAP DELI WHT 12X12 GRS RESIST","1","0","24.46","0","24.46","N","0.0","S"
|
||||
"850166198","$3,999.80","$3,999.80","2026-05-07","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","8936379","SODA PEPSI COLA","1","1","34.01","0","34.01","N","0.0",
|
||||
"850166198","$3,999.80","$3,999.80","2026-05-07","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","1336403","SODA DR PPR REG","1","1","34.01","0","34.01","N","0.0",
|
||||
"850166198","$3,999.80","$3,999.80","2026-05-07","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","1428869","TEA ICED UNSWEET PURELEAF","1","1","21.84","0","21.84","N","0.0",
|
||||
"850166198","$3,999.80","$3,999.80","2026-05-07","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","8492330","WATER PURIFIED BTL PET LSE DW","1","1","20.56","0","20.56","N","0.0",
|
||||
"850166198","$3,999.80","$3,999.80","2026-05-07","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7206974","JUICE CONC STRAWB DRAGON","1","1","172.37","0","172.37","N","0.0",
|
||||
"850166198","$3,999.80","$3,999.80","2026-05-07","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7153421","SYRUP COLA PEPSI ZERO BIB","1","1","71.94","0","71.94","N","0.0",
|
||||
"850166198","$3,999.80","$3,999.80","2026-05-07","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7234905","SYRUP LEMON LIME BIB","1","1","115.95","0","115.95","N","0.0",
|
||||
"850166198","$3,999.80","$3,999.80","2026-05-07","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","2102479","SKEWER BAMBOO 10IN","1","0","7.82","0","7.82","N","0.0","S"
|
||||
"850166198","$3,999.80","$3,999.80","2026-05-07","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7296407","GLOVE NITRILE BLK PEDRFREE LRG","1","1","39.41","3.85","39.41","N","0.0",
|
||||
"850166198","$3,999.80","$3,999.80","2026-05-07","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7301949","RICE MIX NICKS","1","1","32.33","0","32.33","N","0.0",
|
||||
"850166198","$3,999.80","$3,999.80","2026-05-07","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7053293","RICE BASMATI PABROIL SELA CS","1","1","54.84","0","54.84","N","0.0",
|
||||
"850166198","$3,999.80","$3,999.80","2026-05-07","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","2039220","POTATO KENNEBEC FRESH","4","4","23.65","0","94.6","N","0.0",
|
||||
"850166198","$3,999.80","$3,999.80","2026-05-07","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7108399","DRESSING VINAIGRETTE LOGO","1","1","75.93","0","75.93","N","0.0",
|
||||
"850166198","$3,999.80","$3,999.80","2026-05-07","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","4062337","BEAN GARBANZO FCY NO SULFITE","1","1","31.8","0","31.8","N","0.0",
|
||||
"850166198","$3,999.80","$3,999.80","2026-05-07","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","4372932","PEPPER BANANA MILD RING","1","1","40.88","0","40.88","N","0.0",
|
||||
"850166198","$3,999.80","$3,999.80","2026-05-07","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7360056","DIP GARLIC TOUM","2","2","29.78","0","59.56","N","0.0",
|
||||
"850166198","$3,999.80","$3,999.80","2026-05-07","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","4823761","OIL CORN","3","3","38.85","0","116.55","N","0.0",
|
||||
"850166198","$3,999.80","$3,999.80","2026-05-07","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7171302","DRESSING MARINADE SOUVLAKI","1","1","73.28","0","73.28","N","0.0",
|
||||
"850166198","$3,999.80","$3,999.80","2026-05-07","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7108544","SAUCE MUSTARD","1","1","81.66","0","81.66","N","0.0",
|
||||
"850166198","$3,999.80","$3,999.80","2026-05-07","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","5223334","BREAD PITA GYRO PRE-OILED 7","5","5","26.36","0","131.8","N","0.0",
|
||||
"850166198","$3,999.80","$3,999.80","2026-05-07","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7278619","SPREAD HUMMUS TRADITIONAL","2","2","56.72","0","113.44","N","0.0",
|
||||
"850166198","$3,999.80","$3,999.80","2026-05-07","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7253487","CHEESE FETA RW","1","1","2.513","0","90.12","Y","35.86",
|
||||
"850166198","$3,999.80","$3,999.80","2026-05-07","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7213639","SAUCE TZATZIKI","2","2","95.98","0","191.96","N","0.0",
|
||||
"850166198","$3,999.80","$3,999.80","2026-05-07","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7792187","CHICKEN CVP THIGH BNLS SKLS","2","2","89.32","0","178.64","N","0.0",
|
||||
"850166198","$3,999.80","$3,999.80","2026-05-07","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7302646","YOGURT FRZN NF NICK THE GREEK","2","2","54.93","0","109.86","N","0.0",
|
||||
"850166198","$3,999.80","$3,999.80","2026-05-07","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7007041","BUN BRIOCHE HOMESTYLE 4.25","1","1","30.93","0","30.93","N","0.0",
|
||||
"850166198","$3,999.80","$3,999.80","2026-05-07","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7455027","SPANAKOPITA SPINACH COOKED","1","1","69.37","0","69.37","N","0.0",
|
||||
"850166198","$3,999.80","$3,999.80","2026-05-07","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","0932867","BEEF SHLDR TERES MAJOR SEL","1","1","7.556","0","377.8","Y","50.0",
|
||||
"850166198","$3,999.80","$3,999.80","2026-05-07","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7124188","GYRO CHICKEN SHAWARMA CONE","6","6","87.02","0","522.12","N","0.0",
|
||||
"850166198","$3,999.80","$3,999.80","2026-05-07","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","9906087","MEAT GYRO BEEF CONE NTG","7","7","92.86","0","650.02","N","0.0",
|
||||
"850166198","$3,999.80","$3,999.80","2026-05-07","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7187055","BAKLAVA CLASSIC 2X24","1","1","49.53","0","49.53","N","0.0",
|
||||
"850166198","$3,999.80","$3,999.80","2026-05-07","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","6592893","CHGS FOR FUEL SURCHARGE","1","1","3.57","0","3.57","N","0.0",
|
||||
"850173770","$432.27","$432.27","2026-05-09","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7417242","BAG PAPER 250 CT","1","1","46.28","0","46.28","N","0.0",
|
||||
"850173770","$432.27","$432.27","2026-05-09","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7124188","GYRO CHICKEN SHAWARMA CONE","1","1","87.02","0","87.02","N","0.0",
|
||||
"850173770","$432.27","$432.27","2026-05-09","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","9906087","MEAT GYRO BEEF CONE NTG","1","1","92.86","0","92.86","N","0.0",
|
||||
"850173770","$432.27","$432.27","2026-05-09","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7053293","RICE BASMATI PABROIL SELA CS","1","1","54.84","0","54.84","N","0.0",
|
||||
"850173770","$432.27","$432.27","2026-05-09","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7213639","SAUCE TZATZIKI","1","1","95.98","0","95.98","N","0.0",
|
||||
"850173770","$432.27","$432.27","2026-05-09","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7302646","YOGURT FRZN NF NICK THE GREEK","1","1","54.93","0","54.93","N","0.0",
|
||||
"850173770","$432.27","$432.27","2026-05-09","2026-07-03","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","6592893","CHGS FOR FUEL SURCHARGE","1","1","0.36","0","0.36","N","0.0",
|
||||
"850175232","$3,182.09","$3,182.09","2026-05-11","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7452585","NAPKIN 2PLY INTR FOLD 6.3X8.26","1","1","22.32","0","22.32","N","0.0",
|
||||
"850175232","$3,182.09","$3,182.09","2026-05-11","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","1706813","FORK PLAS PP X-HVY BLK","1","1","16.03","0","16.03","N","0.0",
|
||||
"850175232","$3,182.09","$3,182.09","2026-05-11","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7408008","BOWL PLASTIC COATING 42 OZ","1","1","43.58","0","43.58","N","0.0",
|
||||
"850175232","$3,182.09","$3,182.09","2026-05-11","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7408215","LID CLEAR PET 42 OZ","1","1","40.99","0","40.99","N","0.0",
|
||||
"850175232","$3,182.09","$3,182.09","2026-05-11","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7250678","CONTAINER PAPER MLD FBR 9X6","2","2","37.6","0","75.2","N","0.0",
|
||||
"850175232","$3,182.09","$3,182.09","2026-05-11","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7354120","CONTAINER PAPER 1/30 OZ NTG","1","1","30.6","0","30.6","N","0.0",
|
||||
"850175232","$3,182.09","$3,182.09","2026-05-11","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7417242","BAG PAPER 250 CT","1","1","46.28","0","46.28","N","0.0",
|
||||
"850175232","$3,182.09","$3,182.09","2026-05-11","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","2451417","SAUCE CHILI HOT SRIRACHA","1","1","41.26","0","41.26","N","0.0",
|
||||
"850175232","$3,182.09","$3,182.09","2026-05-11","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","0635005","SODA COLA PEPSI ZERO SUGAR","1","1","34.01","0","34.01","N","0.0",
|
||||
"850175232","$3,182.09","$3,182.09","2026-05-11","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","9910355","SODA LEMON LEMONADA GREEK","1","1","14.93","0","14.93","N","0.0",
|
||||
"850175232","$3,182.09","$3,182.09","2026-05-11","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","8847824","SYRUP COLA PEPSI BIB","1","1","115.95","0","115.95","N","0.0",
|
||||
"850175232","$3,182.09","$3,182.09","2026-05-11","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7053293","RICE BASMATI PABROIL SELA CS","1","1","54.84","0","54.84","N","0.0",
|
||||
"850175232","$3,182.09","$3,182.09","2026-05-11","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","2039220","POTATO KENNEBEC FRESH","6","6","23.65","0","141.9","N","0.0",
|
||||
"850175232","$3,182.09","$3,182.09","2026-05-11","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","4113049","VINEGAR DISTILLED WHITE 5%","1","1","16.48","0","16.48","N","0.0",
|
||||
"850175232","$3,182.09","$3,182.09","2026-05-11","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","4136768","KETCHUP PACKET FCY","1","1","34","0","34","N","0.0",
|
||||
"850175232","$3,182.09","$3,182.09","2026-05-11","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7171302","DRESSING MARINADE SOUVLAKI","1","1","73.28","0","73.28","N","0.0",
|
||||
"850175232","$3,182.09","$3,182.09","2026-05-11","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","5223334","BREAD PITA GYRO PRE-OILED 7","6","6","26.36","0","158.16","N","0.0",
|
||||
"850175232","$3,182.09","$3,182.09","2026-05-11","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7485170","BUTTER SOLID USDA AA UNSLTD","1","1","74.83","0","74.83","N","0.0",
|
||||
"850175232","$3,182.09","$3,182.09","2026-05-11","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7278619","SPREAD HUMMUS TRADITIONAL","1","1","56.72","0","56.72","N","0.0",
|
||||
"850175232","$3,182.09","$3,182.09","2026-05-11","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7253487","CHEESE FETA RW","1","1","2.525","0","91.41","Y","36.2",
|
||||
"850175232","$3,182.09","$3,182.09","2026-05-11","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7213639","SAUCE TZATZIKI","1","1","99.62","0","99.62","N","0.0",
|
||||
"850175232","$3,182.09","$3,182.09","2026-05-11","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7213703","SAUCE SPICY YOGURT LOGO","1","1","99.62","0","99.62","N","0.0",
|
||||
"850175232","$3,182.09","$3,182.09","2026-05-11","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7792187","CHICKEN CVP THIGH BNLS SKLS","1","1","89.32","0","89.32","N","0.0",
|
||||
"850175232","$3,182.09","$3,182.09","2026-05-11","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7302646","YOGURT FRZN NF NICK THE GREEK","2","2","54.93","0","109.86","N","0.0",
|
||||
"850175232","$3,182.09","$3,182.09","2026-05-11","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7455027","SPANAKOPITA SPINACH COOKED","1","1","69.37","0","69.37","N","0.0",
|
||||
"850175232","$3,182.09","$3,182.09","2026-05-11","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","0932867","BEEF SHLDR TERES MAJOR SEL","1","1","7.556","0","298.46","Y","39.5",
|
||||
"850175232","$3,182.09","$3,182.09","2026-05-11","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7211838","PORK SLI GYRO CONE","1","1","87.99","0","87.99","N","0.0",
|
||||
"850175232","$3,182.09","$3,182.09","2026-05-11","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7124188","GYRO CHICKEN SHAWARMA CONE","5","5","87.04","0","435.2","N","0.0",
|
||||
"850175232","$3,182.09","$3,182.09","2026-05-11","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","9906087","MEAT GYRO BEEF CONE NTG","6","6","92.86","0","557.16","N","0.0",
|
||||
"850175232","$3,182.09","$3,182.09","2026-05-11","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7187055","BAKLAVA CLASSIC 2X24","1","1","49.53","0","49.53","N","0.0",
|
||||
"850175232","$3,182.09","$3,182.09","2026-05-11","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7274591","APTZR VEG FALAFEL PUCK HALAL","1","1","98.4","0","98.4","N","0.0",
|
||||
"850175232","$3,182.09","$3,182.09","2026-05-11","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","6592893","CHGS FOR FUEL SURCHARGE","1","1","2.99","0","2.99","N","0.0",
|
||||
"850180287","$655.69","$655.69","2026-05-13","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7187055","BAKLAVA CLASSIC 2X24","1","1","49.53","0","49.53","N","0.0",
|
||||
"850180287","$655.69","$655.69","2026-05-13","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7408008","BOWL PLASTIC COATING 42 OZ","1","1","43.58","0","43.58","N","0.0",
|
||||
"850180287","$655.69","$655.69","2026-05-13","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7408215","LID CLEAR PET 42 OZ","1","1","40.99","0","40.99","N","0.0",
|
||||
"850180287","$655.69","$655.69","2026-05-13","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7124188","GYRO CHICKEN SHAWARMA CONE","2","2","87.04","0","174.08","N","0.0",
|
||||
"850180287","$655.69","$655.69","2026-05-13","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","9906087","MEAT GYRO BEEF CONE NTG","1","1","92.86","0","92.86","N","0.0",
|
||||
"850180287","$655.69","$655.69","2026-05-13","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7792187","CHICKEN CVP THIGH BNLS SKLS","1","1","89.32","0","89.32","N","0.0",
|
||||
"850180287","$655.69","$655.69","2026-05-13","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7213639","SAUCE TZATZIKI","1","1","99.62","0","99.62","N","0.0",
|
||||
"850180287","$655.69","$655.69","2026-05-13","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","4613026","CUP PORTION PLAS CLR 1.50 OZ","1","1","22.19","0","22.19","N","0.0",
|
||||
"850180287","$655.69","$655.69","2026-05-13","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7064580","CUP PLAS TRANS HIPS 12 OZ","1","1","42.93","0","42.93","N","0.0",
|
||||
"850180287","$655.69","$655.69","2026-05-13","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","6592893","CHGS FOR FUEL SURCHARGE","1","1","0.59","0","0.59","N","0.0",
|
||||
"850182453","$3,956.95","$3,956.95","2026-05-14","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","1706813","FORK PLAS PP X-HVY BLK","1","1","16.03","0","16.03","N","0.0",
|
||||
"850182453","$3,956.95","$3,956.95","2026-05-14","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7408008","BOWL PLASTIC COATING 42 OZ","1","1","43.58","0","43.58","N","0.0",
|
||||
"850182453","$3,956.95","$3,956.95","2026-05-14","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7250678","CONTAINER PAPER MLD FBR 9X6","3","3","37.6","0","112.8","N","0.0",
|
||||
"850182453","$3,956.95","$3,956.95","2026-05-14","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7354120","CONTAINER PAPER 1/30 OZ NTG","1","1","30.6","0","30.6","N","0.0",
|
||||
"850182453","$3,956.95","$3,956.95","2026-05-14","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7354119","CONTAINER PAPER 4/110OZ NTG","1","1","27.6","0","27.6","N","0.0",
|
||||
"850182453","$3,956.95","$3,956.95","2026-05-14","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7354142","TRAY PAPER FOOD 2LB LOGO NTG","1","1","24.6","0","24.6","N","0.0",
|
||||
"850182453","$3,956.95","$3,956.95","2026-05-14","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7417242","BAG PAPER 250 CT","2","2","46.28","0","92.56","N","0.0",
|
||||
"850182453","$3,956.95","$3,956.95","2026-05-14","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7365006","WRAP PAPER 14X14 LOGO VER2","1","1","91.04","0","91.04","N","0.0",
|
||||
"850182453","$3,956.95","$3,956.95","2026-05-14","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","1336403","SODA DR PPR REG","1","1","34.01","0","34.01","N","0.0",
|
||||
"850182453","$3,956.95","$3,956.95","2026-05-14","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","8492330","WATER PURIFIED BTL PET LSE DW","2","2","20.56","0","41.12","N","0.0",
|
||||
"850182453","$3,956.95","$3,956.95","2026-05-14","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","9911229","WATER MINERAL CARNONATED GREEK","1","1","26.57","0","26.57","N","0.0",
|
||||
"850182453","$3,956.95","$3,956.95","2026-05-14","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7228477","DRINK ENERGY ORANGE SPRKLNG","1","1","24.48","0","24.48","N","0.0",
|
||||
"850182453","$3,956.95","$3,956.95","2026-05-14","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","2102479","SKEWER BAMBOO 10IN","1","0","7.82","0","7.82","N","0.0","S"
|
||||
"850182453","$3,956.95","$3,956.95","2026-05-14","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","5793963","GRILL BRICK 3.5IN THICK","1","1","35.22","3.41","35.22","N","0.0",
|
||||
"850182453","$3,956.95","$3,956.95","2026-05-14","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","6937767","FOIL ALMN ROLL HVY WGT 500 FT","1","1","39.46","3.87","39.46","N","0.0",
|
||||
"850182453","$3,956.95","$3,956.95","2026-05-14","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7435266","FILM PVC 18X2000 ROLL","1","1","21.82","2.13","21.82","N","0.0",
|
||||
"850182453","$3,956.95","$3,956.95","2026-05-14","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7301949","RICE MIX NICKS","1","1","32.33","0","32.33","N","0.0",
|
||||
"850182453","$3,956.95","$3,956.95","2026-05-14","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7053293","RICE BASMATI PABROIL SELA CS","2","2","54.84","0","109.68","N","0.0",
|
||||
"850182453","$3,956.95","$3,956.95","2026-05-14","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","2039220","POTATO KENNEBEC FRESH","4","4","23.65","0","94.6","N","0.0",
|
||||
"850182453","$3,956.95","$3,956.95","2026-05-14","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7108399","DRESSING VINAIGRETTE LOGO","2","2","75.93","0","151.86","N","0.0",
|
||||
"850182453","$3,956.95","$3,956.95","2026-05-14","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","5934302","OIL OLIVE BLEND 80/20","1","1","82.96","0","82.96","N","0.0",
|
||||
"850182453","$3,956.95","$3,956.95","2026-05-14","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","4062337","BEAN GARBANZO FCY NO SULFITE","1","1","31.8","0","31.8","N","0.0",
|
||||
"850182453","$3,956.95","$3,956.95","2026-05-14","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7360056","DIP GARLIC TOUM","2","2","29.78","0","59.56","N","0.0",
|
||||
"850182453","$3,956.95","$3,956.95","2026-05-14","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","4823761","OIL CORN","2","2","38.85","0","77.7","N","0.0",
|
||||
"850182453","$3,956.95","$3,956.95","2026-05-14","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7377725","PASTE TAHINI DRESSING","1","1","38.05","0","38.05","N","0.0",
|
||||
"850182453","$3,956.95","$3,956.95","2026-05-14","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","5223334","BREAD PITA GYRO PRE-OILED 7","8","8","26.36","0","210.88","N","0.0",
|
||||
"850182453","$3,956.95","$3,956.95","2026-05-14","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7278619","SPREAD HUMMUS TRADITIONAL","3","3","56.72","0","170.16","N","0.0",
|
||||
"850182453","$3,956.95","$3,956.95","2026-05-14","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7253487","CHEESE FETA RW","1","1","2.525","0","89.28","Y","35.36",
|
||||
"850182453","$3,956.95","$3,956.95","2026-05-14","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7213639","SAUCE TZATZIKI","1","1","99.62","0","99.62","N","0.0",
|
||||
"850182453","$3,956.95","$3,956.95","2026-05-14","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7792187","CHICKEN CVP THIGH BNLS SKLS","3","3","89.32","0","267.96","N","0.0",
|
||||
"850182453","$3,956.95","$3,956.95","2026-05-14","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7302646","YOGURT FRZN NF NICK THE GREEK","2","2","54.93","0","109.86","N","0.0",
|
||||
"850182453","$3,956.95","$3,956.95","2026-05-14","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","0932867","BEEF SHLDR TERES MAJOR SEL","1","1","7.556","0","314.33","Y","41.6",
|
||||
"850182453","$3,956.95","$3,956.95","2026-05-14","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7211838","PORK SLI GYRO CONE","1","1","87.99","0","87.99","N","0.0",
|
||||
"850182453","$3,956.95","$3,956.95","2026-05-14","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7124188","GYRO CHICKEN SHAWARMA CONE","5","5","87.04","0","435.2","N","0.0",
|
||||
"850182453","$3,956.95","$3,956.95","2026-05-14","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","9906087","MEAT GYRO BEEF CONE NTG","6","6","92.86","0","557.16","N","0.0",
|
||||
"850182453","$3,956.95","$3,956.95","2026-05-14","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7187055","BAKLAVA CLASSIC 2X24","1","1","49.53","0","49.53","N","0.0",
|
||||
"850182453","$3,956.95","$3,956.95","2026-05-14","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7212299","DESSERT MINI PLAIN BEIGNET","0","0","55.66","0","0","N","0.0",
|
||||
"850182453","$3,956.95","$3,956.95","2026-05-14","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7324922","PASTRY BEIGNET MN FLD CHOCCRML","1","1","53.82","0","53.82","N","0.0",
|
||||
"850182453","$3,956.95","$3,956.95","2026-05-14","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7274591","APTZR VEG FALAFEL PUCK HALAL","1","1","98.4","0","98.4","N","0.0",
|
||||
"850182453","$3,956.95","$3,956.95","2026-05-14","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","1498411","DOUGH PASTRY HNY PUFF","1","1","53.1","0","53.1","N","0.0",
|
||||
"850182453","$3,956.95","$3,956.95","2026-05-14","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","9477498","ALLOWANCE FOR DROP SIZE","1","1","-7.01","0","-7.01","N","0.0",
|
||||
"850182453","$3,956.95","$3,956.95","2026-05-14","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","6592893","CHGS FOR FUEL SURCHARGE","1","1","4","0.01","4","N","0.0",
|
||||
"850190221","$1,234.46","$1,234.46","2026-05-16","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7354127","CUP PAPER COLD 22 OZ LOGO NTG","1","1","47.6","0","47.6","N","0.0",
|
||||
"850190221","$1,234.46","$1,234.46","2026-05-16","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7228764","DRINK ENERGY PEACH VIBE SPRKLG","1","1","24.48","0","24.48","N","0.0",
|
||||
"850190221","$1,234.46","$1,234.46","2026-05-16","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7417242","BAG PAPER 250 CT","1","1","46.28","0","46.28","N","0.0",
|
||||
"850190221","$1,234.46","$1,234.46","2026-05-16","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7124188","GYRO CHICKEN SHAWARMA CONE","2","2","87.04","0","174.08","N","0.0",
|
||||
"850190221","$1,234.46","$1,234.46","2026-05-16","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","9906087","MEAT GYRO BEEF CONE NTG","1","1","92.86","0","92.86","N","0.0",
|
||||
"850190221","$1,234.46","$1,234.46","2026-05-16","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7213639","SAUCE TZATZIKI","1","1","99.62","0","99.62","N","0.0",
|
||||
"850190221","$1,234.46","$1,234.46","2026-05-16","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7213703","SAUCE SPICY YOGURT LOGO","1","1","99.62","0","99.62","N","0.0",
|
||||
"850190221","$1,234.46","$1,234.46","2026-05-16","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7053293","RICE BASMATI PABROIL SELA CS","1","1","54.84","0","54.84","N","0.0",
|
||||
"850190221","$1,234.46","$1,234.46","2026-05-16","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7408215","LID CLEAR PET 42 OZ","1","1","40.99","0","40.99","N","0.0",
|
||||
"850190221","$1,234.46","$1,234.46","2026-05-16","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","0932867","BEEF SHLDR TERES MAJOR SEL","1","1","7.556","0","383.09","Y","50.7",
|
||||
"850190221","$1,234.46","$1,234.46","2026-05-16","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7792187","CHICKEN CVP THIGH BNLS SKLS","1","1","89.32","0","89.32","N","0.0",
|
||||
"850190221","$1,234.46","$1,234.46","2026-05-16","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7661388","LID PLAS FLAT F/12-22 OZ","1","1","25.41","0","25.41","N","0.0",
|
||||
"850190221","$1,234.46","$1,234.46","2026-05-16","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7053293","RICE BASMATI PABROIL SELA CS","1","1","54.84","0","54.84","N","0.0",
|
||||
"850190221","$1,234.46","$1,234.46","2026-05-16","2026-07-10","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","6592893","CHGS FOR FUEL SURCHARGE","1","1","0.83","0","0.83","N","0.0",
|
||||
"850191803","$2,403.25","$2,403.25","2026-05-18","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7452585","NAPKIN 2PLY INTR FOLD 6.3X8.26","1","1","22.32","0","22.32","N","0.0",
|
||||
"850191803","$2,403.25","$2,403.25","2026-05-18","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","0191567","STRAW PLAS TRANS JMB WRPD 7.75","1","0","5.09","0","5.09","N","0.0","S"
|
||||
"850191803","$2,403.25","$2,403.25","2026-05-18","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","1706813","FORK PLAS PP X-HVY BLK","1","1","16.03","0","16.03","N","0.0",
|
||||
"850191803","$2,403.25","$2,403.25","2026-05-18","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7408008","BOWL PLASTIC COATING 42 OZ","1","1","43.58","0","43.58","N","0.0",
|
||||
"850191803","$2,403.25","$2,403.25","2026-05-18","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7408215","LID CLEAR PET 42 OZ","1","1","40.99","0","40.99","N","0.0",
|
||||
"850191803","$2,403.25","$2,403.25","2026-05-18","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7354119","CONTAINER PAPER 4/110OZ NTG","1","1","27.6","0","27.6","N","0.0",
|
||||
"850191803","$2,403.25","$2,403.25","2026-05-18","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7354120","CONTAINER PAPER 1/30 OZ NTG","1","1","30.6","0","30.6","N","0.0",
|
||||
"850191803","$2,403.25","$2,403.25","2026-05-18","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7250678","CONTAINER PAPER MLD FBR 9X6","2","2","37.6","0","75.2","N","0.0",
|
||||
"850191803","$2,403.25","$2,403.25","2026-05-18","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7417242","BAG PAPER 250 CT","1","1","46.28","0","46.28","N","0.0",
|
||||
"850191803","$2,403.25","$2,403.25","2026-05-18","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","8367823","SAUCE HOT BOTTLE","1","1","24.47","0","24.47","N","0.0",
|
||||
"850191803","$2,403.25","$2,403.25","2026-05-18","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","5056098","LINER TRASH 40X48 13 MC NAT","1","1","58.01","5.66","58.01","N","0.0",
|
||||
"850191803","$2,403.25","$2,403.25","2026-05-18","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","8936379","SODA PEPSI COLA","1","1","34.01","0","34.01","N","0.0",
|
||||
"850191803","$2,403.25","$2,403.25","2026-05-18","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","6937767","FOIL ALMN ROLL HVY WGT 500 FT","1","1","39.46","3.85","39.46","N","0.0",
|
||||
"850191803","$2,403.25","$2,403.25","2026-05-18","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7301949","RICE MIX NICKS","1","1","32.33","0","32.33","N","0.0",
|
||||
"850191803","$2,403.25","$2,403.25","2026-05-18","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7053293","RICE BASMATI PABROIL SELA CS","1","1","54.84","0","54.84","N","0.0",
|
||||
"850191803","$2,403.25","$2,403.25","2026-05-18","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","2039220","POTATO KENNEBEC FRESH","3","3","23.65","0","70.95","N","0.0",
|
||||
"850191803","$2,403.25","$2,403.25","2026-05-18","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","4693715","PASTE HERB HARISSA MOROCCAN","1","1","28.05","0","28.05","N","0.0",
|
||||
"850191803","$2,403.25","$2,403.25","2026-05-18","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","4823761","OIL CORN","1","1","42.5","0","42.5","N","0.0",
|
||||
"850191803","$2,403.25","$2,403.25","2026-05-18","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7171302","DRESSING MARINADE SOUVLAKI","1","1","73.28","0","73.28","N","0.0",
|
||||
"850191803","$2,403.25","$2,403.25","2026-05-18","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","5223334","BREAD PITA GYRO PRE-OILED 7","4","4","26.36","0","105.44","N","0.0",
|
||||
"850191803","$2,403.25","$2,403.25","2026-05-18","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7278619","SPREAD HUMMUS TRADITIONAL","1","1","56.72","0","56.72","N","0.0",
|
||||
"850191803","$2,403.25","$2,403.25","2026-05-18","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7213639","SAUCE TZATZIKI","2","2","99.62","0","199.24","N","0.0",
|
||||
"850191803","$2,403.25","$2,403.25","2026-05-18","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7792187","CHICKEN CVP THIGH BNLS SKLS","2","2","89.32","0","178.64","N","0.0",
|
||||
"850191803","$2,403.25","$2,403.25","2026-05-18","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7302646","YOGURT FRZN NF NICK THE GREEK","2","2","54.88","0","109.76","N","0.0",
|
||||
"850191803","$2,403.25","$2,403.25","2026-05-18","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7455027","SPANAKOPITA SPINACH COOKED","1","1","69.37","0","69.37","N","0.0",
|
||||
"850191803","$2,403.25","$2,403.25","2026-05-18","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7124188","GYRO CHICKEN SHAWARMA CONE","4","4","87.04","0","348.16","N","0.0",
|
||||
"850191803","$2,403.25","$2,403.25","2026-05-18","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","9906087","MEAT GYRO BEEF CONE NTG","6","6","92.86","0","557.16","N","0.0",
|
||||
"850191803","$2,403.25","$2,403.25","2026-05-18","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","6592893","CHGS FOR FUEL SURCHARGE","1","1","2.46","0","2.46","N","0.0",
|
||||
"850195877","$359.28","$359.28","2026-05-19","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","9495920","SYRUP LEMONADE PNK BIB","1","1","115.95","0","115.95","N","0.0",
|
||||
"850195877","$359.28","$359.28","2026-05-19","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7324922","PASTRY BEIGNET MN FLD CHOCCRML","1","1","53.82","0","53.82","N","0.0",
|
||||
"850195877","$359.28","$359.28","2026-05-19","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","1336403","SODA DR PPR REG","1","1","34.01","0","34.01","N","0.0",
|
||||
"850195877","$359.28","$359.28","2026-05-19","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","5167711","SODA ORANGE CRSH","1","1","34.01","0","34.01","N","0.0",
|
||||
"850195877","$359.28","$359.28","2026-05-19","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7455080","CHOCOLATE DUBAI PISTCHO KUNFEH","1","1","119.09","0","119.09","N","0.0",
|
||||
"850195880","$59.56","$59.56","2026-05-19","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7360056","DIP GARLIC TOUM","2","2","29.78","0","59.56","N","0.0",
|
||||
"850199082","$5,324.76","$5,324.76","2026-05-21","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7452585","NAPKIN 2PLY INTR FOLD 6.3X8.26","1","1","22.32","0","22.32","N","0.0",
|
||||
"850199082","$5,324.76","$5,324.76","2026-05-21","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","1706946","SPOON PLAS TEA PP X-HVY BLK","1","1","18.01","0","18.01","N","0.0",
|
||||
"850199082","$5,324.76","$5,324.76","2026-05-21","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7790795","LID PLAS CLR F/1.5-2.5OZ PRTN","1","1","30.05","0","30.05","N","0.0",
|
||||
"850199082","$5,324.76","$5,324.76","2026-05-21","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7408008","BOWL PLASTIC COATING 42 OZ","2","2","43.58","0","87.16","N","0.0",
|
||||
"850199082","$5,324.76","$5,324.76","2026-05-21","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7408215","LID CLEAR PET 42 OZ","1","1","40.99","0","40.99","N","0.0",
|
||||
"850199082","$5,324.76","$5,324.76","2026-05-21","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7354119","CONTAINER PAPER 4/110OZ NTG","1","1","27.6","0","27.6","N","0.0",
|
||||
"850199082","$5,324.76","$5,324.76","2026-05-21","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7250678","CONTAINER PAPER MLD FBR 9X6","3","3","37.6","0","112.8","N","0.0",
|
||||
"850199082","$5,324.76","$5,324.76","2026-05-21","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7417242","BAG PAPER 250 CT","2","2","46.28","0","92.56","N","0.0",
|
||||
"850199082","$5,324.76","$5,324.76","2026-05-21","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7321470","CANDY MILK CHOC SHELLS","1","1","133.28","0","133.28","N","0.0",
|
||||
"850199082","$5,324.76","$5,324.76","2026-05-21","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","1428869","TEA ICED UNSWEET PURELEAF","1","1","21.84","0","21.84","N","0.0",
|
||||
"850199082","$5,324.76","$5,324.76","2026-05-21","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","8492330","WATER PURIFIED BTL PET LSE DW","1","1","20.56","0","20.56","N","0.0",
|
||||
"850199082","$5,324.76","$5,324.76","2026-05-21","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","9911226","SODA CHERRY VISSINADA GREEK","1","1","14.93","0","14.93","N","0.0",
|
||||
"850199082","$5,324.76","$5,324.76","2026-05-21","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","9911228","SODA ORANGE PORTOKALADA GREEK","1","1","14.93","0","14.93","N","0.0",
|
||||
"850199082","$5,324.76","$5,324.76","2026-05-21","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","9910355","SODA LEMON LEMONADA GREEK","1","1","14.93","0","14.93","N","0.0",
|
||||
"850199082","$5,324.76","$5,324.76","2026-05-21","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7228761","DRINK ENERGY TROPICAL VIBE","1","1","24.48","0","24.48","N","0.0",
|
||||
"850199082","$5,324.76","$5,324.76","2026-05-21","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7206974","JUICE CONC STRAWB DRAGON","1","1","160.37","0","160.37","N","0.0",
|
||||
"850199082","$5,324.76","$5,324.76","2026-05-21","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7153421","SYRUP COLA PEPSI ZERO BIB","1","1","71.94","0","71.94","N","0.0",
|
||||
"850199082","$5,324.76","$5,324.76","2026-05-21","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","8847741","SYRUP MOUNTAIN DEW BIB","1","1","115.95","0","115.95","N","0.0",
|
||||
"850199082","$5,324.76","$5,324.76","2026-05-21","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7459969","SYRUP DR PPR DIET BIB","1","1","62.48","0","62.48","N","0.0",
|
||||
"850199082","$5,324.76","$5,324.76","2026-05-21","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","2102479","SKEWER BAMBOO 10IN","1","0","7.82","0","7.82","N","0.0","S"
|
||||
"850199082","$5,324.76","$5,324.76","2026-05-21","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7296407","GLOVE NITRILE BLK PEDRFREE LRG","1","1","39.41","3.85","39.41","N","0.0",
|
||||
"850199082","$5,324.76","$5,324.76","2026-05-21","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7053293","RICE BASMATI PABROIL SELA CS","1","1","54.84","0","54.84","N","0.0",
|
||||
"850199082","$5,324.76","$5,324.76","2026-05-21","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","2039220","POTATO KENNEBEC FRESH","5","5","23.65","0","118.25","N","0.0",
|
||||
"850199082","$5,324.76","$5,324.76","2026-05-21","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7108399","DRESSING VINAIGRETTE LOGO","1","1","75.93","0","75.93","N","0.0",
|
||||
"850199082","$5,324.76","$5,324.76","2026-05-21","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","4062337","BEAN GARBANZO FCY NO SULFITE","1","1","31.8","0","31.8","N","0.0",
|
||||
"850199082","$5,324.76","$5,324.76","2026-05-21","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","4372932","PEPPER BANANA MILD RING","1","1","40.88","0","40.88","N","0.0",
|
||||
"850199082","$5,324.76","$5,324.76","2026-05-21","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","4823761","OIL CORN","2","2","42.5","0","85","N","0.0",
|
||||
"850199082","$5,324.76","$5,324.76","2026-05-21","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7171302","DRESSING MARINADE SOUVLAKI","1","1","73.28","0","73.28","N","0.0",
|
||||
"850199082","$5,324.76","$5,324.76","2026-05-21","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","5223334","BREAD PITA GYRO PRE-OILED 7","8","8","26.36","0","210.88","N","0.0",
|
||||
"850199082","$5,324.76","$5,324.76","2026-05-21","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7278619","SPREAD HUMMUS TRADITIONAL","3","3","56.72","0","170.16","N","0.0",
|
||||
"850199082","$5,324.76","$5,324.76","2026-05-21","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7253487","CHEESE FETA RW","1","1","2.525","0","92.16","Y","36.5",
|
||||
"850199082","$5,324.76","$5,324.76","2026-05-21","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7213639","SAUCE TZATZIKI","2","2","99.62","0","199.24","N","0.0",
|
||||
"850199082","$5,324.76","$5,324.76","2026-05-21","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7213703","SAUCE SPICY YOGURT LOGO","1","1","99.62","0","99.62","N","0.0",
|
||||
"850199082","$5,324.76","$5,324.76","2026-05-21","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7792187","CHICKEN CVP THIGH BNLS SKLS","3","3","89.32","0","267.96","N","0.0",
|
||||
"850199082","$5,324.76","$5,324.76","2026-05-21","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7302646","YOGURT FRZN NF NICK THE GREEK","3","3","54.88","0","164.64","N","0.0",
|
||||
"850199082","$5,324.76","$5,324.76","2026-05-21","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7455027","SPANAKOPITA SPINACH COOKED","2","2","69.37","0","138.74","N","0.0",
|
||||
"850199082","$5,324.76","$5,324.76","2026-05-21","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","0932867","BEEF SHLDR TERES MAJOR SEL","2","2","7.556","0","817.56","Y","108.2",
|
||||
"850199082","$5,324.76","$5,324.76","2026-05-21","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7211838","PORK SLI GYRO CONE","1","1","87.99","0","87.99","N","0.0",
|
||||
"850199082","$5,324.76","$5,324.76","2026-05-21","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7124188","GYRO CHICKEN SHAWARMA CONE","7","7","87.04","0","609.28","N","0.0",
|
||||
"850199082","$5,324.76","$5,324.76","2026-05-21","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","9906087","MEAT GYRO BEEF CONE NTG","7","7","92.86","0","650.02","N","0.0",
|
||||
"850199082","$5,324.76","$5,324.76","2026-05-21","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7187055","BAKLAVA CLASSIC 2X24","2","2","49.53","0","99.06","N","0.0",
|
||||
"850199082","$5,324.76","$5,324.76","2026-05-21","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7274591","APTZR VEG FALAFEL PUCK HALAL","1","1","98.4","0","98.4","N","0.0",
|
||||
"850199082","$5,324.76","$5,324.76","2026-05-21","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","9477498","ALLOWANCE FOR DROP SIZE","1","1","-7.91","0","-7.91","N","0.0",
|
||||
"850199082","$5,324.76","$5,324.76","2026-05-21","2026-07-17","Not Paid","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","6592893","CHGS FOR FUEL SURCHARGE","1","1","4.52","0","4.52","N","0.0",
|
||||
"850200939","-$2.00","-$2.00","2026-05-21","2026-05-21","Payment Processing","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7212299","DESSERT MINI PLAIN BEIGNET","-1","-1","57.66","0","-57.66","N","0.0",
|
||||
"850200939","-$2.00","-$2.00","2026-05-21","2026-05-21","Payment Processing","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7212299","DESSERT MINI PLAIN BEIGNET","1","1","55.66","0","55.66","N","0.0",
|
||||
"850201144","-$2.00","-$2.00","2026-05-21","2026-05-21","Payment Processing","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7212299","DESSERT MINI PLAIN BEIGNET","-1","-1","57.66","0","-57.66","N","0.0",
|
||||
"850201144","-$2.00","-$2.00","2026-05-21","2026-05-21","Payment Processing","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7212299","DESSERT MINI PLAIN BEIGNET","1","1","55.66","0","55.66","N","0.0",
|
||||
"850200964","-$2.00","-$2.00","2026-05-21","2026-05-21","Payment Processing","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7212299","DESSERT MINI PLAIN BEIGNET","-1","-1","57.66","0","-57.66","N","0.0",
|
||||
"850200964","-$2.00","-$2.00","2026-05-21","2026-05-21","Payment Processing","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7212299","DESSERT MINI PLAIN BEIGNET","1","1","55.66","0","55.66","N","0.0",
|
||||
"850201060","-$2.00","-$2.00","2026-05-21","2026-05-21","Payment Processing","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7212299","DESSERT MINI PLAIN BEIGNET","-1","-1","57.66","0","-57.66","N","0.0",
|
||||
"850201060","-$2.00","-$2.00","2026-05-21","2026-05-21","Payment Processing","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7212299","DESSERT MINI PLAIN BEIGNET","1","1","55.66","0","55.66","N","0.0",
|
||||
"850200828","-$2.00","-$2.00","2026-05-21","2026-05-21","Payment Processing","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7212299","DESSERT MINI PLAIN BEIGNET","-1","-1","57.66","0","-57.66","N","0.0",
|
||||
"850200828","-$2.00","-$2.00","2026-05-21","2026-05-21","Payment Processing","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7212299","DESSERT MINI PLAIN BEIGNET","1","1","55.66","0","55.66","N","0.0",
|
||||
"850201089","-$2.00","-$2.00","2026-05-21","2026-05-21","Payment Processing","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7212299","DESSERT MINI PLAIN BEIGNET","-1","-1","57.66","0","-57.66","N","0.0",
|
||||
"850201089","-$2.00","-$2.00","2026-05-21","2026-05-21","Payment Processing","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7212299","DESSERT MINI PLAIN BEIGNET","1","1","55.66","0","55.66","N","0.0",
|
||||
"850201127","-$4.00","-$4.00","2026-05-21","2026-05-21","Payment Processing","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7212299","DESSERT MINI PLAIN BEIGNET","-2","-2","57.66","0","-115.32","N","0.0",
|
||||
"850201127","-$4.00","-$4.00","2026-05-21","2026-05-21","Payment Processing","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7212299","DESSERT MINI PLAIN BEIGNET","2","2","55.66","0","111.32","N","0.0",
|
||||
"850200909","-$2.00","-$2.00","2026-05-21","2026-05-21","Payment Processing","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7212299","DESSERT MINI PLAIN BEIGNET","-1","-1","57.66","0","-57.66","N","0.0",
|
||||
"850200909","-$2.00","-$2.00","2026-05-21","2026-05-21","Payment Processing","NICK THE GREEK CONCORD","175469","CKC CONCORD INC","175469","7212299","DESSERT MINI PLAIN BEIGNET","1","1","55.66","0","55.66","N","0.0",
|
||||
|
File diff suppressed because one or more lines are too long
5342
resources/public/js/htmx.js
Normal file
5342
resources/public/js/htmx.js
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -1940,12 +1940,12 @@
|
||||
:db/unique :db.unique/identity
|
||||
:db/index true}
|
||||
{:db/ident :sales-summary/client+dirty
|
||||
:db/valueType :db.type/tuple
|
||||
:db/tupleAttrs [:sales-summary/client :sales-summary/dirty]
|
||||
:db/cardinality :db.cardinality/one
|
||||
:db/index true}
|
||||
:db/valueType :db.type/tuple
|
||||
:db/tupleAttrs [:sales-summary/client :sales-summary/dirty]
|
||||
:db/cardinality :db.cardinality/one
|
||||
:db/index true}
|
||||
|
||||
{:db/ident :sales-summary-item/category
|
||||
{:db/ident :sales-summary-item/category
|
||||
:db/valueType :db.type/string
|
||||
:db/cardinality :db.cardinality/one}
|
||||
{:db/ident :sales-summary-item/sort-order
|
||||
|
||||
@@ -1759,4 +1759,38 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
1758,MEATS,PORK BELLY SKIN ON P12 COV,Beef/Pork Costs,51110,
|
||||
1759,MEATS,PORK SHANK BONE KUROBUTA PR12,Beef/Pork Costs,51110,
|
||||
1760,CANNED AND DRY,SEASONING ITALIAN WHL,Food Costs,50000,
|
||||
1761,PRODUCE,MUSHROOM PORTABELLA CAP 4-5,Produce Costs,51200,
|
||||
1761,PRODUCE,MUSHROOM PORTABELLA CAP 4-5,Produce Costs,51200,
|
||||
1762,PAPER & DISP,BAG PAPER 250 CT,Paper Costs,55000,
|
||||
1763,MEATS,BEEF SHLDR TERES MAJOR SEL,Beef/Pork Costs,51110,
|
||||
1764,PAPER & DISP,BOWL PLASTIC COATING 42 OZ,Paper Costs,55000,
|
||||
1765,PAPER & DISP,BOX CATERING 21X13X4.25 LOGO,Paper Costs,55000,
|
||||
1766,CANNED AND DRY,CANDY MILK CHOC SHELLS,Food Costs,50000,
|
||||
1767,CANNED AND DRY,CHOCOLATE DUBAI PISTCHO KUNFEH,Food Costs,50000,
|
||||
1768,PAPER & DISP,CONTAINER PAPER 1/30 OZ NTG,Paper Costs,55000,
|
||||
1769,PAPER & DISP,CONTAINER PAPER 4/110OZ NTG,Paper Costs,55000,
|
||||
1770,PAPER & DISP,CUP PAPER COLD 22 OZ LOGO NTG,Paper Costs,55000,
|
||||
1771,PAPER & DISP,CUP PORTION PLAS CLR 1.50 OZ,Paper Costs,55000,
|
||||
1772,CANNED AND DRY,DESSERT CUP,Food Costs,50000,
|
||||
1773,FROZEN,DESSERT MINI PLAIN BEIGNET,Food Costs,50000,
|
||||
1774,CANNED AND DRY,DIP GARLIC TOUM,Food Costs,50000,
|
||||
1775,CANNED AND DRY,DRINK ENERGY ORANGE SPRKLNG,Soft Beverage Costs,52000,
|
||||
1776,CANNED AND DRY,DRINK ENERGY PEACH VIBE SPRKLG,Soft Beverage Costs,52000,
|
||||
1777,CANNED AND DRY,DRINK ENERGY TROPICAL VIBE,Soft Beverage Costs,52000,
|
||||
1778,PAPER & DISP,FILM PVC 18X2000 ROLL,Paper Costs,55000,
|
||||
1779,CANNED AND DRY,JUICE CONC MANDARIN CARDAMOM,Food Costs,50000,
|
||||
1780,CANNED AND DRY,JUICE CONC STRAWB DRAGON,Food Costs,50000,
|
||||
1781,PAPER & DISP,LID CLEAR PET 42 OZ,Paper Costs,55000,
|
||||
1782,PAPER & DISP,LID DOME DESSERT CUP,Paper Costs,55000,
|
||||
1783,PAPER & DISP,NAPKIN 2PLY INTR FOLD 6.3X8.26,Paper Costs,55000,
|
||||
1784,CANNED AND DRY,PASTE HERB HARISSA MOROCCAN,Food Costs,50000,
|
||||
1785,CANNED AND DRY,PASTE TAHINI DRESSING,Food Costs,50000,
|
||||
1786,FROZEN,PASTRY BEIGNET MN FLD CHOCCRML,Food Costs,50000,
|
||||
1787,CANNED AND DRY,PEPPER BANANA MILD RING,Food Costs,50000,
|
||||
1788,CANNED AND DRY,RICE MIX NICKS,Food Costs,50000,
|
||||
1789,CANNED AND DRY,SODA CHERRY VISSINADA GREEK,Soft Beverage Costs,52000,
|
||||
1790,CANNED AND DRY,SODA COLA PEPSI ZERO SUGAR,Soft Beverage Costs,52000,
|
||||
1791,CANNED AND DRY,SODA PEPSI COLA,Soft Beverage Costs,52000,
|
||||
1792,FROZEN,SPANAKOPITA SPINACH COOKED,Food Costs,50000,
|
||||
1793,PAPER & DISP,SPOON PLAS TEA PP X-HVY BLK,Paper Costs,55000,
|
||||
1794,PAPER & DISP,WRAP PAPER 14X14 LOGO VER2,Paper Costs,55000,
|
||||
1795,DAIRY PRODUCTS,YOGURT FRZN NF NICK THE GREEK,Dairy Costs,51300,
|
||||
|
||||
|
17
skills-lock.json
Normal file
17
skills-lock.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"version": 1,
|
||||
"skills": {
|
||||
"agent-browser": {
|
||||
"source": "vercel-labs/agent-browser",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
(ns amazonica.aws.textract
|
||||
(:require [amazonica.core :as amz])
|
||||
(:import [com.amazonaws.services.textract AmazonTextractClient ]))
|
||||
(:import [com.amazonaws.services.textract AmazonTextractClient]))
|
||||
|
||||
#_
|
||||
(import '[com.amazonaws.services.textract AmazonTextractClient ])
|
||||
#_(import '[com.amazonaws.services.textract.model S3Object ])
|
||||
#_(import '[com.amazonaws.services.textract.model StartExpenseAnalysisRequest ])
|
||||
#_(import '[com.amazonaws.services.textract.model GetExpenseAnalysisRequest ])
|
||||
#_(import '[com.amazonaws.services.textract AmazonTextractClient])
|
||||
#_(import '[com.amazonaws.services.textract.model S3Object])
|
||||
#_(import '[com.amazonaws.services.textract.model StartExpenseAnalysisRequest])
|
||||
#_(import '[com.amazonaws.services.textract.model GetExpenseAnalysisRequest])
|
||||
|
||||
#_(import '[com.amazonaws.services.textract.model DocumentLocation])
|
||||
(amz/set-client AmazonTextractClient *ns*)
|
||||
|
||||
@@ -28,7 +28,6 @@
|
||||
[(str "container:" (:DockerId container-data))
|
||||
(str "ip:" (-> container-data :Networks first :IPv4Addresses first))])
|
||||
|
||||
|
||||
(mount/defstate container-tags
|
||||
:start (get-container-tags)
|
||||
:stop nil)
|
||||
|
||||
@@ -5,14 +5,11 @@
|
||||
(path [cursor])
|
||||
(state [cursor]))
|
||||
|
||||
|
||||
(defprotocol ITransact
|
||||
(-transact! [cursor f]))
|
||||
|
||||
|
||||
(declare to-cursor cursor?)
|
||||
|
||||
|
||||
(deftype ValCursor [value state path]
|
||||
IDeref
|
||||
(deref [_]
|
||||
@@ -23,9 +20,8 @@
|
||||
ITransact
|
||||
(-transact! [_ f]
|
||||
(get-in
|
||||
(swap! state (if (empty? path) f #(update-in % path f)))
|
||||
path)))
|
||||
|
||||
(swap! state (if (empty? path) f #(update-in % path f)))
|
||||
path)))
|
||||
|
||||
(deftype MapCursor [value state path]
|
||||
Counted
|
||||
@@ -53,14 +49,13 @@
|
||||
ITransact
|
||||
(-transact! [cursor f]
|
||||
(get-in
|
||||
(swap! state (if (empty? path) f #(update-in % path f)))
|
||||
path))
|
||||
(swap! state (if (empty? path) f #(update-in % path f)))
|
||||
path))
|
||||
Seqable
|
||||
(seq [this]
|
||||
(for [[k v] @this]
|
||||
[k (to-cursor v state (conj path k) nil)])))
|
||||
|
||||
|
||||
(deftype VecCursor [value state path]
|
||||
Counted
|
||||
(count [_]
|
||||
@@ -91,29 +86,25 @@
|
||||
ITransact
|
||||
(-transact! [cursor f]
|
||||
(get-in
|
||||
(swap! state (if (empty? path) f #(update-in % path f)))
|
||||
path))
|
||||
(swap! state (if (empty? path) f #(update-in % path f)))
|
||||
path))
|
||||
Seqable
|
||||
(seq [this]
|
||||
(for [[v i] (map vector @this (range))]
|
||||
(to-cursor v state (conj path i) nil))))
|
||||
|
||||
|
||||
(defn- to-cursor
|
||||
([v state path value]
|
||||
(cond
|
||||
(cursor? v) v
|
||||
(map? v) (MapCursor. value state path)
|
||||
(vector? v) (VecCursor. value state path)
|
||||
:else (ValCursor. value state path)
|
||||
)))
|
||||
|
||||
:else (ValCursor. value state path))))
|
||||
|
||||
(defn cursor? [c]
|
||||
"Returns true if c is a cursor."
|
||||
(satisfies? ICursor c))
|
||||
|
||||
|
||||
(defn cursor [v]
|
||||
"Creates cursor from supplied value v. If v is an ordinary
|
||||
data structure, it is wrapped into atom. If v is an atom,
|
||||
@@ -123,7 +114,6 @@
|
||||
(if (instance? Atom v) v (atom v))
|
||||
[] nil))
|
||||
|
||||
|
||||
(defn synthetic-cursor [v prefix]
|
||||
(let [internal-cursor (cursor v)]
|
||||
(reify ICursor
|
||||
@@ -132,14 +122,12 @@
|
||||
(state [this]
|
||||
(state internal-cursor)))))
|
||||
|
||||
|
||||
(defn transact! [cursor f]
|
||||
"Changes value beneath cursor by passing it to a single-argument
|
||||
function f. Old value will be passed as function argument. Function
|
||||
result will be the new value."
|
||||
(-transact! cursor f))
|
||||
|
||||
|
||||
(defn update! [cursor v]
|
||||
"Replaces value supplied by cursor with value v."
|
||||
(-transact! cursor (constantly v)))
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
[iol-ion.tx.upsert-invoice]
|
||||
[iol-ion.tx.upsert-ledger]
|
||||
[iol-ion.tx.upsert-transaction]
|
||||
[iol-ion.tx.upsert-sales-summary-ledger]
|
||||
[com.github.ivarref.gen-fn :refer [gen-fn! datomic-fn]]
|
||||
[auto-ap.utils :refer [default-pagination-size by]]
|
||||
[clojure.edn :as edn]
|
||||
@@ -26,8 +27,8 @@
|
||||
(def uri (:datomic-url env))
|
||||
|
||||
#_(mount/defstate client
|
||||
:start (dc/client (:client-config env))
|
||||
:stop nil)
|
||||
:start (dc/client (:client-config env))
|
||||
:stop nil)
|
||||
|
||||
(mount/defstate conn
|
||||
:start (dc/connect uri)
|
||||
@@ -37,21 +38,20 @@
|
||||
|
||||
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
||||
#_(defn create-database []
|
||||
(d/create-database uri))
|
||||
(d/create-database uri))
|
||||
|
||||
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
||||
#_(defn drop-database []
|
||||
(d/delete-database uri))
|
||||
(d/delete-database uri))
|
||||
|
||||
(defn remove-nils [m]
|
||||
(let [result (reduce-kv
|
||||
(fn [m k v]
|
||||
(if (not (nil? v))
|
||||
(assoc m k v)
|
||||
m
|
||||
))
|
||||
{}
|
||||
m)]
|
||||
(fn [m k v]
|
||||
(if (not (nil? v))
|
||||
(assoc m k v)
|
||||
m))
|
||||
{}
|
||||
m)]
|
||||
(if (seq result)
|
||||
result
|
||||
nil)))
|
||||
@@ -79,7 +79,7 @@
|
||||
:db/valueType :db.type/string
|
||||
:db/cardinality :db.cardinality/one
|
||||
:db/doc "A vendor's email address"}
|
||||
|
||||
|
||||
{:db/ident :vendor/phone
|
||||
:db/valueType :db.type/string
|
||||
:db/cardinality :db.cardinality/one
|
||||
@@ -101,14 +101,13 @@
|
||||
:db/valueType :db.type/ref
|
||||
:db/isComponent true
|
||||
:db/cardinality :db.cardinality/one
|
||||
:db/doc "The vendor's secondary contact"}
|
||||
:db/doc "The vendor's secondary contact"}
|
||||
{:db/ident :vendor/address
|
||||
:db/valueType :db.type/ref
|
||||
:db/cardinality :db.cardinality/one
|
||||
:db/isComponent true
|
||||
:db.install/_attribute :db.part/db
|
||||
:db/doc "The vendor's address"}
|
||||
])
|
||||
:db/doc "The vendor's address"}])
|
||||
|
||||
(def client-schema
|
||||
[{:db/ident :client/original-id
|
||||
@@ -150,8 +149,7 @@
|
||||
:db/doc "Bank accounts for the client"}])
|
||||
|
||||
(def address-schema
|
||||
[
|
||||
{:db/ident :address/street1
|
||||
[{:db/ident :address/street1
|
||||
:db/valueType :db.type/string
|
||||
:db/cardinality :db.cardinality/one
|
||||
:db/doc "123 main st"}
|
||||
@@ -173,8 +171,7 @@
|
||||
:db/doc "95014"}])
|
||||
|
||||
(def contact-schema
|
||||
[
|
||||
{:db/ident :contact/name
|
||||
[{:db/ident :contact/name
|
||||
:db/valueType :db.type/string
|
||||
:db/cardinality :db.cardinality/one
|
||||
:db/doc "John Smith"}
|
||||
@@ -187,8 +184,6 @@
|
||||
:db/cardinality :db.cardinality/one
|
||||
:db/doc "hello@example.com"}])
|
||||
|
||||
|
||||
|
||||
(def bank-account-schema
|
||||
[{:db/ident :bank-account/external-id
|
||||
:db/valueType :db.type/long
|
||||
@@ -295,7 +290,6 @@
|
||||
:db/cardinality :db.cardinality/many
|
||||
:db/isComponent true
|
||||
:db/doc "The expense account categories for this invoice"}
|
||||
|
||||
|
||||
{:db/ident :invoice-status/paid}
|
||||
{:db/ident :invoice-status/unpaid}
|
||||
@@ -311,17 +305,15 @@
|
||||
:db/valueType :db.type/long
|
||||
:db/cardinality :db.cardinality/one
|
||||
:db/doc "The code for the expense account"}
|
||||
{:db/ident :invoice-expense-account/location
|
||||
{:db/ident :invoice-expense-account/location
|
||||
:db/valueType :db.type/string
|
||||
:db/cardinality :db.cardinality/one
|
||||
:db/doc "Location for this expense account"}
|
||||
:db/doc "Location for this expense account"}
|
||||
{:db/ident :invoice-expense-account/amount
|
||||
:db/valueType :db.type/double
|
||||
:db/cardinality :db.cardinality/one
|
||||
:db/doc "The amount that this contributes to"}])
|
||||
|
||||
|
||||
|
||||
(def payment-schema
|
||||
[{:db/ident :payment/original-id
|
||||
:db/valueType :db.type/long
|
||||
@@ -372,9 +364,8 @@
|
||||
:db/valueType :db.type/string
|
||||
:db/cardinality :db.cardinality/one
|
||||
:db/doc "raw data used to generate check pdf"}
|
||||
|
||||
|
||||
;; relations
|
||||
;; relations
|
||||
{:db/ident :payment/vendor
|
||||
:db/valueType :db.type/ref
|
||||
:db/cardinality :db.cardinality/one
|
||||
@@ -399,8 +390,7 @@
|
||||
|
||||
{:db/ident :payment-type/cash}
|
||||
{:db/ident :payment-type/check}
|
||||
{:db/ident :payment-type/debit}
|
||||
])
|
||||
{:db/ident :payment-type/debit}])
|
||||
|
||||
(def invoice-payment-schema
|
||||
[{:db/ident :invoice-payment/original-id
|
||||
@@ -413,7 +403,7 @@
|
||||
:db/valueType :db.type/double
|
||||
:db/cardinality :db.cardinality/one
|
||||
:db/doc "The amount that was paid to this invoice"}
|
||||
|
||||
|
||||
;; relations
|
||||
{:db/ident :invoice-payment/invoice
|
||||
:db/valueType :db.type/ref
|
||||
@@ -480,8 +470,7 @@
|
||||
:db/cardinality :db.cardinality/one
|
||||
:db/doc "The check number that was parsed from the description"}
|
||||
|
||||
|
||||
;; relations
|
||||
;; relations
|
||||
{:db/ident :transaction/vendor
|
||||
:db/valueType :db.type/ref
|
||||
:db/cardinality :db.cardinality/one
|
||||
@@ -497,8 +486,7 @@
|
||||
{:db/ident :transaction/payment
|
||||
:db/valueType :db.type/ref
|
||||
:db/cardinality :db.cardinality/one
|
||||
:db/doc "The payment that this transaction matched to"}
|
||||
])
|
||||
:db/doc "The payment that this transaction matched to"}])
|
||||
|
||||
(def user-schema
|
||||
[{:db/ident :user/original-id
|
||||
@@ -530,12 +518,10 @@
|
||||
;;enums
|
||||
{:db/ident :user-role/admin}
|
||||
{:db/ident :user-role/user}
|
||||
{:db/ident :user-role/none}
|
||||
])
|
||||
{:db/ident :user-role/none}])
|
||||
|
||||
(def base-schema
|
||||
[ address-schema contact-schema vendor-schema client-schema bank-account-schema invoice-schema invoice-expense-account-schema payment-schema invoice-payment-schema transaction-schema user-schema])
|
||||
|
||||
[address-schema contact-schema vendor-schema client-schema bank-account-schema invoice-schema invoice-expense-account-schema payment-schema invoice-payment-schema transaction-schema user-schema])
|
||||
|
||||
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
||||
(defn migrate-vendors [_]
|
||||
@@ -589,19 +575,19 @@
|
||||
|
||||
(defn add-sorter-fields-2 [q sort-map args]
|
||||
(reduce
|
||||
(fn [q {:keys [sort-key]}]
|
||||
(merge-query q
|
||||
{:query {:find [(last (last (sort-map
|
||||
sort-key
|
||||
(println "Warning, trying to sort by unsupported field" sort-key))))]
|
||||
:where (sort-map
|
||||
sort-key
|
||||
(println "Warning, trying to sort by unsupported field" sort-key))}}))
|
||||
q
|
||||
(:sort args)))
|
||||
(fn [q {:keys [sort-key]}]
|
||||
(merge-query q
|
||||
{:query {:find [(last (last (sort-map
|
||||
sort-key
|
||||
(println "Warning, trying to sort by unsupported field" sort-key))))]
|
||||
:where (sort-map
|
||||
sort-key
|
||||
(println "Warning, trying to sort by unsupported field" sort-key))}}))
|
||||
q
|
||||
(:sort args)))
|
||||
|
||||
(defn apply-sort-3 [args results]
|
||||
|
||||
|
||||
(let [sort-bys (conj (into [] (:sort args))
|
||||
{:sort-key "default" :asc (if (contains? args :default-asc?)
|
||||
(:default-asc? args)
|
||||
@@ -610,7 +596,7 @@
|
||||
comparator (fn [xs ys]
|
||||
(reduce
|
||||
(fn [_ i]
|
||||
|
||||
|
||||
(let [comparison (if (:asc (nth sort-bys i))
|
||||
(compare (nth xs i) (nth ys i))
|
||||
(compare (nth ys i) (nth xs i)))]
|
||||
@@ -624,18 +610,18 @@
|
||||
|
||||
;; TODO replace COULD JUST BE SORT-3
|
||||
(defn apply-sort-4 [args results]
|
||||
|
||||
|
||||
(let [sort-bys (-> []
|
||||
(into (:sort args))
|
||||
(conj {:sort-key "default" :asc (if (contains? args :default-asc?)
|
||||
(:default-asc? args)
|
||||
true)})
|
||||
(:default-asc? args)
|
||||
true)})
|
||||
(conj {:sort-key "e" :asc true}))
|
||||
length (count sort-bys)
|
||||
comparator (fn [xs ys]
|
||||
(reduce
|
||||
(fn [_ i]
|
||||
|
||||
|
||||
(let [comparison (if (:asc (nth sort-bys i))
|
||||
(compare (nth xs i) (nth ys i))
|
||||
(compare (nth ys i) (nth xs i)))]
|
||||
@@ -656,7 +642,7 @@
|
||||
(defn apply-pagination [args results]
|
||||
{:ids (->> results
|
||||
(drop (or (:start args) 0))
|
||||
(take (or (:count args )
|
||||
(take (or (:count args)
|
||||
(:per-page args)
|
||||
default-pagination-size))
|
||||
(map last))
|
||||
@@ -668,8 +654,8 @@
|
||||
(reduce
|
||||
(fn [full-tx batch]
|
||||
(let [batch (conj (vec batch) {:db/id "datomic.tx"
|
||||
:audit/user (str (:user/role id) "-" (:user/name id))
|
||||
:audit/batch batch-id})
|
||||
:audit/user (str (:user/role id) "-" (:user/name id))
|
||||
:audit/batch batch-id})
|
||||
_ (mu/log ::transacting-batch
|
||||
:batch batch-id
|
||||
:count (count batch))
|
||||
@@ -686,18 +672,17 @@
|
||||
(partition-all 200 txes))))
|
||||
|
||||
(defn audit-transact [txes id]
|
||||
(try
|
||||
(try
|
||||
@(dc/transact-async conn (conj txes {:db/id "datomic.tx"
|
||||
:audit/user (str (:user/role id) "-" (:user/name id))}))
|
||||
:audit/user (str (:user/role id) "-" (:user/name id))}))
|
||||
(catch Exception e
|
||||
(mu/log ::transaction-error
|
||||
:exception e
|
||||
:level :error
|
||||
:tx txes)
|
||||
(throw e)
|
||||
)))
|
||||
(throw e))))
|
||||
|
||||
(defn pull-many [db read ids ]
|
||||
(defn pull-many [db read ids]
|
||||
(->> (dc/q '[:find (pull ?e r)
|
||||
:in $ [?e ...] r]
|
||||
db
|
||||
@@ -705,22 +690,22 @@
|
||||
read)
|
||||
(map first)))
|
||||
|
||||
(defn pull-many-by-id [db read ids ]
|
||||
(defn pull-many-by-id [db read ids]
|
||||
(into {}
|
||||
(map (fn [[e]]
|
||||
[(:db/id e) e]))
|
||||
(dc/q '[:find (pull ?e r)
|
||||
:in $ [?e ...] r]
|
||||
db
|
||||
ids
|
||||
read)))
|
||||
:in $ [?e ...] r]
|
||||
db
|
||||
ids
|
||||
read)))
|
||||
|
||||
(defn random-tempid []
|
||||
(str (UUID/randomUUID)))
|
||||
|
||||
(defn pull-id [db id]
|
||||
(if (sequential? id)
|
||||
(ffirst (dc/q '[:find ?i
|
||||
(ffirst (dc/q '[:find ?i
|
||||
:in $ [?a ?v]
|
||||
:where [?i ?a ?v]]
|
||||
db
|
||||
@@ -733,170 +718,163 @@
|
||||
(defn pull-ref [db k id]
|
||||
(:db/id (pull-attr db k id)))
|
||||
|
||||
#_(comment
|
||||
(dc/pull (dc/db conn) '[*] 175921860633685)
|
||||
|
||||
(upsert-entity (dc/db conn) {:db/id 175921860633685 :invoice/invoice-number nil :invoice/date #inst "2021-01-01" :invoice/expense-accounts [:reset-rels [{:db/id "new" :invoice-expense-account/amount 1}]]})
|
||||
|
||||
(upsert-entity (dc/db conn) {:invoice/client #:db{:id 79164837221949},
|
||||
:invoice/status #:db{:id 101155069755470, :ident :invoice-status/paid},
|
||||
:invoice/due #inst "2020-12-23T08:00:00.000-00:00",
|
||||
:invoice/invoice-number "12648",
|
||||
:invoice/import-status
|
||||
:import-status/imported,
|
||||
:invoice/vendor nil,
|
||||
:invoice/date #inst "2020-12-16T08:00:00.000-00:00",
|
||||
:entity/migration-key 17592234924273,
|
||||
:db/id 175921860633685,
|
||||
:invoice/outstanding-balance 0.0,
|
||||
:invoice/expense-accounts
|
||||
[{:entity/migration-key 17592234924274,
|
||||
:invoice-expense-account/location nil
|
||||
:invoice-expense-account/amount 360.0,
|
||||
:invoice-expense-account/account #:db{:id 92358976759248}}]})
|
||||
|
||||
#_(comment
|
||||
(dc/pull (dc/db conn) '[*] 175921860633685)
|
||||
|
||||
(upsert-entity (dc/db conn) {:db/id 175921860633685 :invoice/invoice-number nil :invoice/date #inst "2021-01-01" :invoice/expense-accounts [:reset-rels [{:db/id "new" :invoice-expense-account/amount 1}]]})
|
||||
|
||||
(upsert-entity (dc/db conn) {:invoice/client #:db{:id 79164837221949},
|
||||
:invoice/status #:db{:id 101155069755470, :ident :invoice-status/paid},
|
||||
:invoice/due #inst "2020-12-23T08:00:00.000-00:00",
|
||||
:invoice/invoice-number "12648",
|
||||
:invoice/import-status
|
||||
:import-status/imported,
|
||||
:invoice/vendor nil,
|
||||
:invoice/date #inst "2020-12-16T08:00:00.000-00:00",
|
||||
:entity/migration-key 17592234924273,
|
||||
:db/id 175921860633685,
|
||||
:invoice/outstanding-balance 0.0,
|
||||
:invoice/expense-accounts
|
||||
[{:entity/migration-key 17592234924274,
|
||||
:invoice-expense-account/location nil
|
||||
:invoice-expense-account/amount 360.0,
|
||||
:invoice-expense-account/account #:db{:id 92358976759248}}],})
|
||||
|
||||
|
||||
|
||||
#_(dc/pull (dc/db conn) auto-ap.datomic.clients 79164837221904)
|
||||
(upsert-entity (dc/db conn) {:client/name "20Twenty - WG Development LLC",
|
||||
:client/square-locations
|
||||
[{:db/id 83562883711605,
|
||||
:entity/migration-key 17592258901782,
|
||||
:square-location/square-id "L2579ATQ0X1ET",
|
||||
:square-location/name "20Twenty",
|
||||
:square-location/client-location "WG"}],
|
||||
:client/square-auth-token
|
||||
"EAAAEEr749Ea6AdPTdngsmUPwIM3ETbPwcx3QQl_NS0KWuIL-JNzAg4f3W9DGQhb",
|
||||
:client/bank-accounts
|
||||
[{:bank-account/sort-order 2,
|
||||
:bank-account/include-in-reports true,
|
||||
:bank-account/number "3467",
|
||||
:bank-account/code "20TY-WFCC3467",
|
||||
:bank-account/locations ["WG"],
|
||||
:entity/migration-key 17592245102834,
|
||||
:bank-account/current-balance 11160.289999999979,
|
||||
:bank-account/name "Wells Fargo CC - 3467",
|
||||
:db/id 83562883732805,
|
||||
:bank-account/start-date #inst "2021-12-01T08:00:00.000-00:00",
|
||||
:bank-account/visible true,
|
||||
:bank-account/type
|
||||
#:db{:id 101155069755504, :ident :bank-account-type/credit},
|
||||
:bank-account/intuit-bank-account #:db{:id 105553116286744},
|
||||
:bank-account/integration-status
|
||||
{:db/id 74766790691480,
|
||||
:entity/migration-key 17592267080690,
|
||||
:integration-status/last-updated #inst "2022-08-23T03:47:44.892-00:00",
|
||||
:integration-status/last-attempt #inst "2022-08-23T03:47:44.892-00:00",
|
||||
#_(dc/pull (dc/db conn) auto-ap.datomic.clients 79164837221904)
|
||||
(upsert-entity (dc/db conn) {:client/name "20Twenty - WG Development LLC",
|
||||
:client/square-locations
|
||||
[{:db/id 83562883711605,
|
||||
:entity/migration-key 17592258901782,
|
||||
:square-location/square-id "L2579ATQ0X1ET",
|
||||
:square-location/name "20Twenty",
|
||||
:square-location/client-location "WG"}],
|
||||
:client/square-auth-token
|
||||
"EAAAEEr749Ea6AdPTdngsmUPwIM3ETbPwcx3QQl_NS0KWuIL-JNzAg4f3W9DGQhb",
|
||||
:client/bank-accounts
|
||||
[{:bank-account/sort-order 2,
|
||||
:bank-account/include-in-reports true,
|
||||
:bank-account/number "3467",
|
||||
:bank-account/code "20TY-WFCC3467",
|
||||
:bank-account/locations ["WG"],
|
||||
:entity/migration-key 17592245102834,
|
||||
:bank-account/current-balance 11160.289999999979,
|
||||
:bank-account/name "Wells Fargo CC - 3467",
|
||||
:db/id 83562883732805,
|
||||
:bank-account/start-date #inst "2021-12-01T08:00:00.000-00:00",
|
||||
:bank-account/visible true,
|
||||
:bank-account/type
|
||||
#:db{:id 101155069755504, :ident :bank-account-type/credit},
|
||||
:bank-account/intuit-bank-account #:db{:id 105553116286744},
|
||||
:bank-account/integration-status
|
||||
{:db/id 74766790691480,
|
||||
:entity/migration-key 17592267080690,
|
||||
:integration-status/last-updated #inst "2022-08-23T03:47:44.892-00:00",
|
||||
:integration-status/last-attempt #inst "2022-08-23T03:47:44.892-00:00",
|
||||
:integration-status/state
|
||||
#:db{:id 101155069755529, :ident :integration-state/success}},
|
||||
:bank-account/bank-name "Wells Fargo"}
|
||||
{:bank-account/sort-order 0,
|
||||
:bank-account/include-in-reports true,
|
||||
:bank-account/numeric-code 11301,
|
||||
:bank-account/check-number 301,
|
||||
:bank-account/number "1734742859",
|
||||
:bank-account/code "20TY-WF2882",
|
||||
:bank-account/locations ["WG"],
|
||||
:bank-account/bank-code "11-4288/1210 4285",
|
||||
:entity/migration-key 17592241193004,
|
||||
:bank-account/current-balance -47342.54000000085,
|
||||
:bank-account/name "Wells Fargo Main - 2859",
|
||||
:db/id 83562883732846,
|
||||
:bank-account/start-date #inst "2021-12-01T08:00:00.000-00:00",
|
||||
:bank-account/visible true,
|
||||
:bank-account/type
|
||||
#:db{:id 101155069755468, :ident :bank-account-type/check},
|
||||
:bank-account/intuit-bank-account #:db{:id 105553116286745},
|
||||
:bank-account/routing "121042882",
|
||||
:bank-account/integration-status
|
||||
{:db/id 74766790691458,
|
||||
:entity/migration-key 17592267080255,
|
||||
:integration-status/last-updated #inst "2022-08-23T03:46:45.879-00:00",
|
||||
:integration-status/last-attempt #inst "2022-08-23T03:46:45.879-00:00",
|
||||
:integration-status/state
|
||||
#:db{:id 101155069755529, :ident :integration-state/success}},
|
||||
:bank-account/bank-name "Wells Fargo"}
|
||||
{:bank-account/sort-order 1,
|
||||
:bank-account/include-in-reports true,
|
||||
:bank-account/numeric-code 20101,
|
||||
:bank-account/yodlee-account-id 27345526,
|
||||
:bank-account/number "41006",
|
||||
:bank-account/code "20TY-Amex41006",
|
||||
:bank-account/locations ["WG"],
|
||||
:entity/migration-key 17592241193006,
|
||||
:bank-account/current-balance 9674.069999999963,
|
||||
:bank-account/name "Amex - 41006",
|
||||
:db/id 83562883732847,
|
||||
:bank-account/visible true,
|
||||
:bank-account/type
|
||||
#:db{:id 101155069755504, :ident :bank-account-type/credit},
|
||||
:bank-account/bank-name "American Express"}
|
||||
{:bank-account/sort-order 3,
|
||||
:bank-account/include-in-reports true,
|
||||
:bank-account/numeric-code 11101,
|
||||
:bank-account/code "20TY-0",
|
||||
:bank-account/locations ["WG"],
|
||||
:entity/migration-key 17592241193005,
|
||||
:bank-account/current-balance 0.0,
|
||||
:bank-account/name "CASH",
|
||||
:db/id 83562883732848,
|
||||
:bank-account/visible true,
|
||||
:bank-account/type
|
||||
#:db{:id 101155069755469, :ident :bank-account-type/cash}}],
|
||||
:entity/migration-key 17592241193003,
|
||||
:db/id 79164837221904,
|
||||
:client/address
|
||||
{:db/id 105553116285906,
|
||||
:entity/migration-key 17592250661126,
|
||||
:address/street1 "1389 Lincoln Ave",
|
||||
:address/city "San Jose",
|
||||
:address/state "CA",
|
||||
:address/zip "95125"},
|
||||
:client/code "NY",
|
||||
:client/locations ["WE" "NG"],
|
||||
:client/square-integration-status
|
||||
{:db/id 74766790691447,
|
||||
:entity/migration-key 17592267072653,
|
||||
:integration-status/last-updated #inst "2022-08-23T13:09:16.082-00:00",
|
||||
:integration-status/last-attempt #inst "2022-08-23T13:08:47.018-00:00",
|
||||
:integration-status/state
|
||||
#:db{:id 101155069755529, :ident :integration-state/success}},
|
||||
:bank-account/bank-name "Wells Fargo"}
|
||||
{:bank-account/sort-order 0,
|
||||
:bank-account/include-in-reports true,
|
||||
:bank-account/numeric-code 11301,
|
||||
:bank-account/check-number 301,
|
||||
:bank-account/number "1734742859",
|
||||
:bank-account/code "20TY-WF2882",
|
||||
:bank-account/locations ["WG"],
|
||||
:bank-account/bank-code "11-4288/1210 4285",
|
||||
:entity/migration-key 17592241193004,
|
||||
:bank-account/current-balance -47342.54000000085,
|
||||
:bank-account/name "Wells Fargo Main - 2859",
|
||||
:db/id 83562883732846,
|
||||
:bank-account/start-date #inst "2021-12-01T08:00:00.000-00:00",
|
||||
:bank-account/visible true,
|
||||
:bank-account/type
|
||||
#:db{:id 101155069755468, :ident :bank-account-type/check},
|
||||
:bank-account/intuit-bank-account #:db{:id 105553116286745},
|
||||
:bank-account/routing "121042882",
|
||||
:bank-account/integration-status
|
||||
{:db/id 74766790691458,
|
||||
:entity/migration-key 17592267080255,
|
||||
:integration-status/last-updated #inst "2022-08-23T03:46:45.879-00:00",
|
||||
:integration-status/last-attempt #inst "2022-08-23T03:46:45.879-00:00",
|
||||
:integration-status/state
|
||||
#:db{:id 101155069755529, :ident :integration-state/success}},
|
||||
:bank-account/bank-name "Wells Fargo"}
|
||||
{:bank-account/sort-order 1,
|
||||
:bank-account/include-in-reports true,
|
||||
:bank-account/numeric-code 20101,
|
||||
:bank-account/yodlee-account-id 27345526,
|
||||
:bank-account/number "41006",
|
||||
:bank-account/code "20TY-Amex41006",
|
||||
:bank-account/locations ["WG"],
|
||||
:entity/migration-key 17592241193006,
|
||||
:bank-account/current-balance 9674.069999999963,
|
||||
:bank-account/name "Amex - 41006",
|
||||
:db/id 83562883732847,
|
||||
:bank-account/visible true,
|
||||
:bank-account/type
|
||||
#:db{:id 101155069755504, :ident :bank-account-type/credit},
|
||||
:bank-account/bank-name "American Express"}
|
||||
{:bank-account/sort-order 3,
|
||||
:bank-account/include-in-reports true,
|
||||
:bank-account/numeric-code 11101,
|
||||
:bank-account/code "20TY-0",
|
||||
:bank-account/locations ["WG"],
|
||||
:entity/migration-key 17592241193005,
|
||||
:bank-account/current-balance 0.0,
|
||||
:bank-account/name "CASH",
|
||||
:db/id 83562883732848,
|
||||
:bank-account/visible true,
|
||||
:bank-account/type
|
||||
#:db{:id 101155069755469, :ident :bank-account-type/cash}}],
|
||||
:entity/migration-key 17592241193003,
|
||||
:db/id 79164837221904,
|
||||
:client/address
|
||||
{:db/id 105553116285906,
|
||||
:entity/migration-key 17592250661126,
|
||||
:address/street1 "1389 Lincoln Ave",
|
||||
:address/city "San Jose",
|
||||
:address/state "CA",
|
||||
:address/zip "95125"},
|
||||
:client/code "NY",
|
||||
:client/locations ["WE" "NG"],
|
||||
:client/square-integration-status
|
||||
{:db/id 74766790691447,
|
||||
:entity/migration-key 17592267072653,
|
||||
:integration-status/last-updated #inst "2022-08-23T13:09:16.082-00:00",
|
||||
:integration-status/last-attempt #inst "2022-08-23T13:08:47.018-00:00",
|
||||
:integration-status/state
|
||||
#:db{:id 101155069755529, :ident :integration-state/success}}})
|
||||
|
||||
)
|
||||
#:db{:id 101155069755529, :ident :integration-state/success}}}))
|
||||
|
||||
(defn install-functions []
|
||||
@(dc/transact conn
|
||||
(edn/read-string {:readers {'db/id id-literal
|
||||
'db/fn construct}} (slurp (io/resource "functions.edn")))))
|
||||
(edn/read-string {:readers {'db/id id-literal
|
||||
'db/fn construct}} (slurp (io/resource "functions.edn")))))
|
||||
(defn all-schema []
|
||||
(edn/read-string (slurp (io/resource "schema.edn"))))
|
||||
|
||||
(defn transact-schema [conn]
|
||||
@(dc/transact conn
|
||||
(edn/read-string (slurp (io/resource "schema.edn"))))
|
||||
(edn/read-string (slurp (io/resource "schema.edn"))))
|
||||
|
||||
;; this is temporary for any new stuff that needs to be asserted for cloud migration.
|
||||
@(dc/transact conn
|
||||
(edn/read-string (slurp (io/resource "cloud-migration-schema.edn")))))
|
||||
(edn/read-string (slurp (io/resource "cloud-migration-schema.edn")))))
|
||||
|
||||
(defn backoff [n]
|
||||
(let [base-timeout 500
|
||||
(let [base-timeout 500
|
||||
max-timeout 300000 ; 5 minutes
|
||||
max-retries 10
|
||||
backoff-time (* base-timeout (Math/pow 2 (min n max-retries)))]
|
||||
(min (+ backoff-time (rand-int base-timeout)) max-timeout)))
|
||||
|
||||
(defn transact-with-backoff
|
||||
([tx ] (transact-with-backoff tx 0))
|
||||
([tx] (transact-with-backoff tx 0))
|
||||
([tx attempt]
|
||||
(try
|
||||
(try
|
||||
@(dc/transact conn tx)
|
||||
(catch Exception e
|
||||
(if (< attempt 10)
|
||||
(do
|
||||
(do
|
||||
(Thread/sleep (backoff attempt))
|
||||
(mu/log ::transact-failed
|
||||
:exception e
|
||||
@@ -922,7 +900,6 @@
|
||||
(into #{}
|
||||
(map :db/id (:user/clients id [])))))
|
||||
|
||||
|
||||
(defn query2 [query]
|
||||
(apply dc/q (:query query) (:args query)))
|
||||
|
||||
@@ -932,14 +909,14 @@
|
||||
(defn observable-query [query]
|
||||
(mu/with-context {:query (pr-str (:query query))
|
||||
:args (pr-str (:args query))}
|
||||
(mu/trace ::query
|
||||
[]
|
||||
(let [query-results (dc/query {:query (:query query)
|
||||
:args (:args query)
|
||||
:query-stats true
|
||||
:io-context ::hello})]
|
||||
(alog/info ::query-stats
|
||||
:io-stats (pr-str (:io-stats query-results))
|
||||
:query-stats (pr-str (:query-stats query-results)))
|
||||
(:ret query-results)))))
|
||||
(mu/trace ::query
|
||||
[]
|
||||
(let [query-results (dc/query {:query (:query query)
|
||||
:args (:args query)
|
||||
:query-stats true
|
||||
:io-context ::hello})]
|
||||
(alog/info ::query-stats
|
||||
:io-stats (pr-str (:io-stats query-results))
|
||||
:query-stats (pr-str (:query-stats query-results)))
|
||||
(:ret query-results)))))
|
||||
|
||||
|
||||
@@ -12,18 +12,18 @@
|
||||
[datomic.api :as dc]))
|
||||
|
||||
(defn <-datomic [a]
|
||||
(-> a
|
||||
(-> a
|
||||
(update :account/applicability :db/ident)
|
||||
(update :account/invoice-allowance :db/ident)
|
||||
(update :account/vendor-allowance :db/ident)))
|
||||
|
||||
(def default-read ['* {:account/type [:db/ident :db/id]
|
||||
:account/applicability [:db/ident :db/id]
|
||||
:account/applicability [:db/ident :db/id]
|
||||
:account/invoice-allowance [:db/ident :db/id]
|
||||
:account/vendor-allowance [:db/ident :db/id]
|
||||
:account/client-overrides [:db/id
|
||||
:account-client-override/name
|
||||
{:account-client-override/client [:db/id :client/name]}]}])
|
||||
:account/client-overrides [:db/id
|
||||
:account-client-override/name
|
||||
{:account-client-override/client [:db/id :client/name]}]}])
|
||||
|
||||
(defn clientize [a client]
|
||||
(if-let [override-name (->> a
|
||||
@@ -52,44 +52,44 @@
|
||||
(map <-datomic)))))
|
||||
|
||||
(defn get-for-vendor [vendor-id client-id]
|
||||
(if client-id
|
||||
(if client-id
|
||||
(->>
|
||||
(dc/q '[:find (pull ?e r)
|
||||
:in $ ?v ?c r
|
||||
:where (or-join [?v ?c ?e]
|
||||
(and [?v :vendor/account-overrides ?ao]
|
||||
[?ao :vendor-account-override/client ?c]
|
||||
[?ao :vendor-account-override/account ?e])
|
||||
(and [?v :vendor/account-overrides ?ao]
|
||||
(not [?ao :vendor-account-override/client ?c])
|
||||
[?v :vendor/default-account ?e])
|
||||
(and (not [?v :vendor/account-overrides])
|
||||
[?v :vendor/default-account ?e]))]
|
||||
(dc/q '[:find (pull ?e r)
|
||||
:in $ ?v ?c r
|
||||
:where (or-join [?v ?c ?e]
|
||||
(and [?v :vendor/account-overrides ?ao]
|
||||
[?ao :vendor-account-override/client ?c]
|
||||
[?ao :vendor-account-override/account ?e])
|
||||
(and [?v :vendor/account-overrides ?ao]
|
||||
(not [?ao :vendor-account-override/client ?c])
|
||||
[?v :vendor/default-account ?e])
|
||||
(and (not [?v :vendor/account-overrides])
|
||||
[?v :vendor/default-account ?e]))]
|
||||
|
||||
(dc/db conn )
|
||||
(dc/db conn)
|
||||
vendor-id
|
||||
client-id
|
||||
default-read)
|
||||
(map first)
|
||||
(map <-datomic)
|
||||
first)
|
||||
(map first)
|
||||
(map <-datomic)
|
||||
first)
|
||||
(<-datomic (dc/q '[:find (pull ?e r)
|
||||
:in $ ?v r
|
||||
:where [?v :vendor/default-account ?e]]
|
||||
|
||||
(dc/db conn )
|
||||
(dc/db conn)
|
||||
vendor-id
|
||||
default-read))))
|
||||
|
||||
(defn get-account-by-numeric-code-and-sets [numeric-code _]
|
||||
(->>
|
||||
(dc/q {:find ['(pull ?e [* {:account/type [:db/ident :db/id]}])]
|
||||
:in ['$ '?numeric-code]
|
||||
:where ['[?e :account/numeric-code ?numeric-code]]}
|
||||
(dc/db conn) numeric-code)
|
||||
(map first)
|
||||
(map <-datomic)
|
||||
(first)))
|
||||
(dc/q {:find ['(pull ?e [* {:account/type [:db/ident :db/id]}])]
|
||||
:in ['$ '?numeric-code]
|
||||
:where ['[?e :account/numeric-code ?numeric-code]]}
|
||||
(dc/db conn) numeric-code)
|
||||
(map first)
|
||||
(map <-datomic)
|
||||
(first)))
|
||||
|
||||
(defn raw-graphql-ids [db args]
|
||||
(let [query (cond-> {:query {:find []
|
||||
@@ -111,23 +111,21 @@
|
||||
:args [(re-pattern (str "(?i)" (:name-like args)))]})
|
||||
|
||||
true
|
||||
(merge-query {:query {:find ['?sort-default '?e ]
|
||||
(merge-query {:query {:find ['?sort-default '?e]
|
||||
:where ['[?e :account/name]
|
||||
'[?e :account/numeric-code ?sort-default]]}}))]
|
||||
|
||||
|
||||
(cond->> (query2 query)
|
||||
true (apply-sort-3 args)
|
||||
true (apply-pagination args))))
|
||||
|
||||
|
||||
(defn graphql-results [ids db _]
|
||||
(let [results (->> (pull-many db default-read ids)
|
||||
(group-by :db/id))
|
||||
accounts (->> ids
|
||||
(map results)
|
||||
(map first)
|
||||
(map <-datomic))]
|
||||
(map results)
|
||||
(map first)
|
||||
(map <-datomic))]
|
||||
accounts))
|
||||
|
||||
(defn get-graphql [args]
|
||||
|
||||
@@ -13,15 +13,12 @@
|
||||
(def default-read '[* {:client/_bank-accounts [:db/id]}])
|
||||
|
||||
(defn <-datomic [x]
|
||||
(->> x
|
||||
(map #(update % :bank-account/type :db/ident))
|
||||
))
|
||||
|
||||
|
||||
(->> x
|
||||
(map #(update % :bank-account/type :db/ident))))
|
||||
|
||||
(defn get-by-id [id]
|
||||
(->> [(dc/pull (dc/db conn ) default-read id)]
|
||||
(<-datomic)
|
||||
(->> [(dc/pull (dc/db conn) default-read id)]
|
||||
(<-datomic)
|
||||
(first)))
|
||||
|
||||
|
||||
|
||||
@@ -16,22 +16,22 @@
|
||||
|
||||
(defn <-datomic [result]
|
||||
(-> result
|
||||
(update :payment/date c/from-date)
|
||||
(update :payment/status :db/ident)
|
||||
(update :payment/type :db/ident)
|
||||
(update :transaction/_payment (fn [transactions]
|
||||
(mapv (fn [transaction]
|
||||
(update transaction :transaction/date c/from-date))
|
||||
transactions)))
|
||||
(rename-keys {:invoice-payment/_payment :payment/invoices})))
|
||||
(update :payment/date c/from-date)
|
||||
(update :payment/status :db/ident)
|
||||
(update :payment/type :db/ident)
|
||||
(update :transaction/_payment (fn [transactions]
|
||||
(mapv (fn [transaction]
|
||||
(update transaction :transaction/date c/from-date))
|
||||
transactions)))
|
||||
(rename-keys {:invoice-payment/_payment :payment/invoices})))
|
||||
|
||||
(def default-read '[*
|
||||
{:invoice-payment/_payment [* {:invoice-payment/invoice [*]}]}
|
||||
{:payment/client [:client/name :db/id :client/code]}
|
||||
{:payment/bank-account [*]}
|
||||
{:payment/vendor [:vendor/name {:vendor/default-account
|
||||
[:account/name :account/numeric-code :db/id]} :db/id {:vendor/primary-contact [*]} {:vendor/address [*]}]}
|
||||
{:payment/status [:db/ident]}
|
||||
{:invoice-payment/_payment [* {:invoice-payment/invoice [*]}]}
|
||||
{:payment/client [:client/name :db/id :client/code]}
|
||||
{:payment/bank-account [*]}
|
||||
{:payment/vendor [:vendor/name {:vendor/default-account
|
||||
[:account/name :account/numeric-code :db/id]} :db/id {:vendor/primary-contact [*]} {:vendor/address [*]}]}
|
||||
{:payment/status [:db/ident]}
|
||||
{:payment/type [:db/ident]}
|
||||
{:transaction/_payment [:db/id :transaction/date]}])
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
(:sort args) (add-sorter-fields {"client" ['[?e :payment/client ?c]
|
||||
'[?c :client/name ?sort-client]]
|
||||
"vendor" ['[?e :payment/vendor ?v]
|
||||
'[?v :vendor/name ?sort-vendor]]
|
||||
'[?v :vendor/name ?sort-vendor]]
|
||||
"bank-account" ['[?e :payment/bank-account ?ba]
|
||||
'[?ba :bank-account/name ?sort-bank-account]]
|
||||
"check-number" ['[(get-else $ ?e :payment/check-number 0) ?sort-check-number]]
|
||||
@@ -73,7 +73,7 @@
|
||||
:where []}
|
||||
:args [(:exact-match-id args)]})
|
||||
|
||||
(:vendor-id args)
|
||||
(:vendor-id args)
|
||||
(merge-query {:query {:in ['?vendor-id]
|
||||
:where ['[?e :payment/vendor ?vendor-id]]}
|
||||
:args [(:vendor-id args)]})
|
||||
@@ -100,13 +100,13 @@
|
||||
:where ['[?e :payment/bank-account ?bank-account-id]]}
|
||||
:args [(:bank-account-id args)]})
|
||||
|
||||
(:amount-gte args)
|
||||
(:amount-gte args)
|
||||
(merge-query {:query {:in ['?amount-gte]
|
||||
:where ['[?e :payment/amount ?a]
|
||||
'[(>= ?a ?amount-gte)]]}
|
||||
:args [(:amount-gte args)]})
|
||||
|
||||
(:amount-lte args)
|
||||
(:amount-lte args)
|
||||
(merge-query {:query {:in ['?amount-lte]
|
||||
:where ['[?e :payment/amount ?a]
|
||||
'[(<= ?a ?amount-lte)]]}
|
||||
@@ -118,7 +118,6 @@
|
||||
'[(iol-ion.query/dollars= ?transaction-amount ?amount)]]}
|
||||
:args [(:amount args)]})
|
||||
|
||||
|
||||
(:status args)
|
||||
(merge-query {:query {:in ['?status]
|
||||
:where ['[?e :payment/status ?status]]}
|
||||
@@ -137,7 +136,6 @@
|
||||
true
|
||||
(merge-query {:query {:find ['?sort-default '?e]}})))]
|
||||
|
||||
|
||||
(cond->> (observable-query query)
|
||||
true (apply-sort-3 (assoc args :default-asc? false))
|
||||
true (apply-pagination args)))))
|
||||
@@ -146,9 +144,9 @@
|
||||
(let [results (->> (pull-many db default-read ids)
|
||||
(group-by :db/id))
|
||||
payments (->> ids
|
||||
(map results)
|
||||
(map first)
|
||||
(mapv <-datomic))]
|
||||
(map results)
|
||||
(map first)
|
||||
(mapv <-datomic))]
|
||||
payments))
|
||||
|
||||
(defn get-graphql [args]
|
||||
@@ -169,7 +167,7 @@
|
||||
[]))
|
||||
|
||||
(defn get-by-id [id]
|
||||
(->>
|
||||
(->>
|
||||
(dc/pull (dc/db conn) default-read id)
|
||||
(<-datomic)))
|
||||
|
||||
|
||||
@@ -122,8 +122,6 @@
|
||||
Long/parseLong
|
||||
(#(hash-map :db/id %)))))
|
||||
|
||||
|
||||
|
||||
(defn exact-match [identifier]
|
||||
(when (and identifier (not-empty identifier))
|
||||
(some-> (solr/query solr/impl "clients"
|
||||
@@ -170,7 +168,6 @@
|
||||
matching-ids)
|
||||
(set (map :db/id (:clients args))))
|
||||
|
||||
|
||||
query (cond-> {:query {:find []
|
||||
:in ['$]
|
||||
:where []}
|
||||
@@ -179,7 +176,6 @@
|
||||
(merge-query {:query {:in ['[?e ...]]}
|
||||
:args [(set valid-ids)]})
|
||||
|
||||
|
||||
(:sort args) (add-sorter-fields {"name" ['[?e :client/name ?sort-name]]}
|
||||
args)
|
||||
|
||||
@@ -195,7 +191,6 @@
|
||||
(map cleanse))]
|
||||
results))
|
||||
|
||||
|
||||
(defn get-graphql-page [args]
|
||||
(let [db (dc/db conn)
|
||||
{ids-to-retrieve :ids matching-count :count} (raw-graphql-ids db args)]
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"date" ['[?e :expected-deposit/date ?sort-date]]
|
||||
"total" ['[?e :expected-deposit/total ?sort-total]]
|
||||
"fee" ['[?e :expected-deposit/fee ?sort-fee]]}
|
||||
args)
|
||||
args)
|
||||
|
||||
true
|
||||
(merge-query {:query {:in ['[?xx ...]]
|
||||
@@ -58,13 +58,13 @@
|
||||
'[?client-id :client/code ?client-code]]}
|
||||
:args [(:client-code args)]})
|
||||
|
||||
(:total-gte args)
|
||||
(:total-gte args)
|
||||
(merge-query {:query {:in ['?total-gte]
|
||||
:where ['[?e :expected-deposit/total ?a]
|
||||
'[(>= ?a ?total-gte)]]}
|
||||
:args [(:total-gte args)]})
|
||||
|
||||
(:total-lte args)
|
||||
(:total-lte args)
|
||||
(merge-query {:query {:in ['?total-lte]
|
||||
:where ['[?e :expected-deposit/total ?a]
|
||||
'[(<= ?a ?total-lte)]]}
|
||||
@@ -76,7 +76,6 @@
|
||||
'[(iol-ion.query/dollars= ?expected-deposit-total ?total)]]}
|
||||
:args [(:total args)]})
|
||||
|
||||
|
||||
(:start (:date-range args))
|
||||
(merge-query {:query {:in '[?start-date]
|
||||
:where ['[?e :expected-deposit/date ?date]
|
||||
@@ -92,7 +91,7 @@
|
||||
true
|
||||
(merge-query {:query {:find ['?sort-default '?e]
|
||||
:where ['[?e :expected-deposit/date ?sort-default]]}}))]
|
||||
|
||||
|
||||
(cond->> (query2 query)
|
||||
true (apply-sort-3 args)
|
||||
true (apply-pagination args))))
|
||||
@@ -101,26 +100,26 @@
|
||||
(let [results (->> (pull-many db default-read ids)
|
||||
(group-by :db/id))
|
||||
payments (->> ids
|
||||
(map results)
|
||||
(map first)
|
||||
(mapv <-datomic)
|
||||
(map (fn get-totals [ed]
|
||||
(assoc ed :totals
|
||||
(->> (dc/q '[:find ?d4 (count ?c) (sum ?a)
|
||||
:in $ ?ed
|
||||
:where [?ed :expected-deposit/charges ?c]
|
||||
[?c :charge/total ?a]
|
||||
[?o :sales-order/charges ?c]
|
||||
[?o :sales-order/date ?d]
|
||||
[(clj-time.coerce/from-date ?d) ?d2]
|
||||
[(auto-ap.time/localize ?d2) ?d3]
|
||||
[(clj-time.coerce/to-local-date ?d3) ?d4]]
|
||||
(dc/db conn)
|
||||
(:db/id ed))
|
||||
(map (fn [[date count amount]]
|
||||
{:date (c/to-date-time date)
|
||||
:count count
|
||||
:amount amount})))))))]
|
||||
(map results)
|
||||
(map first)
|
||||
(mapv <-datomic)
|
||||
(map (fn get-totals [ed]
|
||||
(assoc ed :totals
|
||||
(->> (dc/q '[:find ?d4 (count ?c) (sum ?a)
|
||||
:in $ ?ed
|
||||
:where [?ed :expected-deposit/charges ?c]
|
||||
[?c :charge/total ?a]
|
||||
[?o :sales-order/charges ?c]
|
||||
[?o :sales-order/date ?d]
|
||||
[(clj-time.coerce/from-date ?d) ?d2]
|
||||
[(auto-ap.time/localize ?d2) ?d3]
|
||||
[(clj-time.coerce/to-local-date ?d3) ?d4]]
|
||||
(dc/db conn)
|
||||
(:db/id ed))
|
||||
(map (fn [[date count amount]]
|
||||
{:date (c/to-date-time date)
|
||||
:count count
|
||||
:amount amount})))))))]
|
||||
payments))
|
||||
|
||||
(defn get-graphql [args]
|
||||
|
||||
@@ -34,16 +34,15 @@
|
||||
|
||||
(defn <-datomic [x]
|
||||
(-> x
|
||||
(update :invoice/date coerce/from-date)
|
||||
(update :invoice/due coerce/from-date)
|
||||
(update :invoice/scheduled-payment coerce/from-date)
|
||||
(update :invoice/status :db/ident)
|
||||
(update :invoice/expense-accounts (fn [eas]
|
||||
(map
|
||||
#(update % :invoice-expense-account/account d-accounts/clientize (:db/id (:invoice/client x)))
|
||||
eas)))
|
||||
(rename-keys {:invoice-payment/_invoice :invoice/payments})))
|
||||
|
||||
(update :invoice/date coerce/from-date)
|
||||
(update :invoice/due coerce/from-date)
|
||||
(update :invoice/scheduled-payment coerce/from-date)
|
||||
(update :invoice/status :db/ident)
|
||||
(update :invoice/expense-accounts (fn [eas]
|
||||
(map
|
||||
#(update % :invoice-expense-account/account d-accounts/clientize (:db/id (:invoice/client x)))
|
||||
eas)))
|
||||
(rename-keys {:invoice-payment/_invoice :invoice/payments})))
|
||||
|
||||
(defn raw-graphql-ids
|
||||
([args]
|
||||
@@ -63,37 +62,29 @@
|
||||
valid-clients]}
|
||||
(cond-> {:query {:find []
|
||||
:in '[$ [?clients ?start ?end]]
|
||||
:where '[
|
||||
[(iol-ion.query/scan-invoices $ ?clients ?start ?end) [[?e _ ?sort-default] ...]]
|
||||
]}
|
||||
:where '[[(iol-ion.query/scan-invoices $ ?clients ?start ?end) [[?e _ ?sort-default] ...]]]}
|
||||
:args [db
|
||||
[valid-clients
|
||||
(some-> (:start (:date-range args)) coerce/to-date)
|
||||
(some-> (:end (:date-range args)) coerce/to-date)]]}
|
||||
|
||||
|
||||
(:client-id args)
|
||||
(merge-query {:query {:in ['?client-id]
|
||||
:where ['[?e :invoice/client ?client-id]]}
|
||||
:args [ (:client-id args)]})
|
||||
:args [(:client-id args)]})
|
||||
|
||||
(:client-code args)
|
||||
(merge-query {:query {:in ['?client-code]
|
||||
:where ['[?e :invoice/client ?client-id]
|
||||
'[?client-id :client/code ?client-code]]}
|
||||
:args [ (:client-code args)]})
|
||||
:args [(:client-code args)]})
|
||||
|
||||
(:original-id args)
|
||||
(merge-query {:query {:in ['?original-id]
|
||||
:where [
|
||||
'[?e :invoice/client ?c]
|
||||
:where ['[?e :invoice/client ?c]
|
||||
'[?c :client/original-id ?original-id]]}
|
||||
:args [ (cond-> (:original-id args)
|
||||
(string? (:original-id args)) Long/parseLong )]})
|
||||
|
||||
|
||||
|
||||
|
||||
:args [(cond-> (:original-id args)
|
||||
(string? (:original-id args)) Long/parseLong)]})
|
||||
|
||||
(:start (:due-range args)) (merge-query {:query {:in '[?start-due]
|
||||
:where ['[?e :invoice/due ?due]
|
||||
@@ -104,34 +95,33 @@
|
||||
:where ['[?e :invoice/due ?due]
|
||||
'[(<= ?due ?end-due)]]}
|
||||
:args [(coerce/to-date (:end (:due-range args)))]})
|
||||
|
||||
|
||||
(:import-status args)
|
||||
(merge-query {:query {:in ['?import-status]
|
||||
:where ['[?e :invoice/import-status ?import-status]]}
|
||||
:args [ (keyword "import-status" (:import-status args))]})
|
||||
:args [(keyword "import-status" (:import-status args))]})
|
||||
(:status args)
|
||||
(merge-query {:query {:in ['?status]
|
||||
:where ['[?e :invoice/status ?status]]}
|
||||
:args [ (:status args)]})
|
||||
:args [(:status args)]})
|
||||
(:vendor-id args)
|
||||
(merge-query {:query {:in ['?vendor-id]
|
||||
:where ['[?e :invoice/vendor ?vendor-id]]}
|
||||
:args [ (:vendor-id args)]})
|
||||
:args [(:vendor-id args)]})
|
||||
|
||||
(:account-id args)
|
||||
(merge-query {:query {:in ['?account-id]
|
||||
:where ['[?e :invoice/expense-accounts ?iea ?]
|
||||
'[?iea :invoice-expense-account/account ?account-id]]}
|
||||
:args [ (:account-id args)]})
|
||||
:args [(:account-id args)]})
|
||||
|
||||
(:amount-gte args)
|
||||
(:amount-gte args)
|
||||
(merge-query {:query {:in ['?amount-gte]
|
||||
:where ['[?e :invoice/total ?total-filter]
|
||||
'[(>= ?total-filter ?amount-gte)]]}
|
||||
:args [(:amount-gte args)]})
|
||||
|
||||
(:amount-lte args)
|
||||
(:amount-lte args)
|
||||
(merge-query {:query {:in ['?amount-lte]
|
||||
:where ['[?e :invoice/total ?total-filter]
|
||||
'[(<= ?total-filter ?amount-lte)]]}
|
||||
@@ -151,7 +141,7 @@
|
||||
(:unresolved args)
|
||||
(merge-query {:query {:in []
|
||||
:where ['(or-join [?e]
|
||||
(not [?e :invoice/expense-accounts ])
|
||||
(not [?e :invoice/expense-accounts])
|
||||
(and [?e :invoice/expense-accounts ?ea]
|
||||
(not [?ea :invoice-expense-account/account])))]}
|
||||
:args []})
|
||||
@@ -165,7 +155,7 @@
|
||||
(:sort args) (add-sorter-fields {"client" ['[?e :invoice/client ?c]
|
||||
'[?c :client/name ?sort-client]]
|
||||
"vendor" ['[?e :invoice/vendor ?v]
|
||||
'[?v :vendor/name ?sort-vendor]]
|
||||
'[?v :vendor/name ?sort-vendor]]
|
||||
"description-original" ['[?e :transaction/description-original ?sort-description-original]]
|
||||
"location" ['[?e :invoice/expense-accounts ?iea]
|
||||
'[?iea :invoice-expense-account/location ?sort-location]]
|
||||
@@ -176,16 +166,15 @@
|
||||
"outstanding-balance" ['[?e :invoice/outstanding-balance ?sort-outstanding-balance]]}
|
||||
args)
|
||||
true
|
||||
(merge-query {:query {:find ['?sort-default '?e ]}}) ))]
|
||||
(merge-query {:query {:find ['?sort-default '?e]}})))]
|
||||
(->> (observable-query query)
|
||||
(apply-sort-3 args)
|
||||
(apply-pagination args)))))
|
||||
|
||||
(apply-sort-3 args)
|
||||
(apply-pagination args)))))
|
||||
|
||||
(defn graphql-results [ids db _]
|
||||
(let [results (->> (pull-many db default-read ids)
|
||||
(group-by :db/id))
|
||||
|
||||
|
||||
invoices (->> ids
|
||||
(map results)
|
||||
(map first)
|
||||
@@ -193,40 +182,38 @@
|
||||
invoices))
|
||||
|
||||
(defn sum-outstanding [ids]
|
||||
|
||||
(->>
|
||||
(dc/q {:find ['?id '?o]
|
||||
:in ['$ '[?id ...]]
|
||||
:where ['[?id :invoice/outstanding-balance ?o]]}
|
||||
(dc/db conn)
|
||||
ids)
|
||||
|
||||
(->>
|
||||
(dc/q {:find ['?id '?o]
|
||||
:in ['$ '[?id ...]]
|
||||
:where ['[?id :invoice/outstanding-balance ?o]]}
|
||||
(dc/db conn)
|
||||
ids)
|
||||
(map last)
|
||||
(reduce
|
||||
+
|
||||
0.0)))
|
||||
|
||||
(defn sum-total-amount [ids]
|
||||
|
||||
(->>
|
||||
(dc/q {:find ['?id '?o]
|
||||
:in ['$ '[?id ...]]
|
||||
:where ['[?id :invoice/total ?o]]
|
||||
}
|
||||
(dc/db conn)
|
||||
ids)
|
||||
(map last)
|
||||
(reduce
|
||||
+
|
||||
0.0)))
|
||||
|
||||
(->>
|
||||
(dc/q {:find ['?id '?o]
|
||||
:in ['$ '[?id ...]]
|
||||
:where ['[?id :invoice/total ?o]]}
|
||||
(dc/db conn)
|
||||
ids)
|
||||
(map last)
|
||||
(reduce
|
||||
+
|
||||
0.0)))
|
||||
|
||||
(defn get-graphql [args]
|
||||
|
||||
|
||||
(let [db (dc/db conn)
|
||||
{ids-to-retrieve :ids matching-count :count} (raw-graphql-ids db args)
|
||||
outstanding (sum-outstanding ids-to-retrieve)
|
||||
total-amount (sum-total-amount ids-to-retrieve)]
|
||||
|
||||
|
||||
[(->> (graphql-results ids-to-retrieve db args))
|
||||
matching-count
|
||||
outstanding
|
||||
@@ -239,56 +226,51 @@
|
||||
|
||||
(defn get-multi [ids]
|
||||
(map <-datomic
|
||||
(pull-many (dc/db conn) default-read ids )))
|
||||
|
||||
|
||||
(pull-many (dc/db conn) default-read ids)))
|
||||
|
||||
(defn find-conflicting [{:keys [:invoice/invoice-number :invoice/vendor :invoice/client :db/id]}]
|
||||
|
||||
|
||||
(->> (dc/q
|
||||
{:find [(list 'pull '?e default-read)]
|
||||
:in ['$ '?invoice-number '?vendor '?client '?invoice-id]
|
||||
:where '[[?e :invoice/invoice-number ?invoice-number]
|
||||
[?e :invoice/vendor ?vendor]
|
||||
[?e :invoice/client ?client]
|
||||
(not [?e :invoice/status :invoice-status/voided])
|
||||
[(not= ?e ?invoice-id)]]}
|
||||
(dc/db conn) invoice-number vendor client (or id 0))
|
||||
{:find [(list 'pull '?e default-read)]
|
||||
:in ['$ '?invoice-number '?vendor '?client '?invoice-id]
|
||||
:where '[[?e :invoice/invoice-number ?invoice-number]
|
||||
[?e :invoice/vendor ?vendor]
|
||||
[?e :invoice/client ?client]
|
||||
(not [?e :invoice/status :invoice-status/voided])
|
||||
[(not= ?e ?invoice-id)]]}
|
||||
(dc/db conn) invoice-number vendor client (or id 0))
|
||||
(map first)
|
||||
(map <-datomic)))
|
||||
|
||||
|
||||
(defn get-existing-set []
|
||||
(let [vendored-results (set (dc/q {:find ['?vendor '?client '?invoice-number]
|
||||
:in ['$]
|
||||
:where '[[?e :invoice/invoice-number ?invoice-number]
|
||||
[?e :invoice/vendor ?vendor]
|
||||
[?e :invoice/client ?client]
|
||||
(not [?e :invoice/status :invoice-status/voided])
|
||||
]}
|
||||
(not [?e :invoice/status :invoice-status/voided])]}
|
||||
(dc/db conn)))
|
||||
vendorless-results (->> (dc/q {:find ['?client '?invoice-number]
|
||||
:in ['$]
|
||||
:where '[[?e :invoice/invoice-number ?invoice-number]
|
||||
(not [?e :invoice/vendor])
|
||||
[?e :invoice/client ?client]
|
||||
(not [?e :invoice/status :invoice-status/voided])
|
||||
]}
|
||||
(not [?e :invoice/status :invoice-status/voided])]}
|
||||
(dc/db conn))
|
||||
(mapv (fn [[client invoice-number]]
|
||||
[nil client invoice-number]) )
|
||||
[nil client invoice-number]))
|
||||
set)]
|
||||
(into vendored-results vendorless-results)))
|
||||
|
||||
|
||||
(defn filter-ids [ids]
|
||||
(if ids
|
||||
(->>
|
||||
(dc/q {:find ['?e]
|
||||
:in ['$ '[?e ...]]
|
||||
:where ['[?e :invoice/date]]}
|
||||
(dc/db conn) ids)
|
||||
(map first)
|
||||
vec)
|
||||
(if ids
|
||||
(->>
|
||||
(dc/q {:find ['?e]
|
||||
:in ['$ '[?e ...]]
|
||||
:where ['[?e :invoice/date]]}
|
||||
(dc/db conn) ids)
|
||||
(map first)
|
||||
vec)
|
||||
[]))
|
||||
|
||||
(defn code-invoice
|
||||
@@ -317,7 +299,7 @@
|
||||
client-id))))
|
||||
[schedule-payment-dom] (map first (dc/q '[:find ?dom
|
||||
:in $ ?v ?c
|
||||
:where [?v :vendor/schedule-payment-dom ?sp ]
|
||||
:where [?v :vendor/schedule-payment-dom ?sp]
|
||||
[?sp :vendor-schedule-payment-dom/client ?c]
|
||||
[?sp :vendor-schedule-payment-dom/dom ?dom]]
|
||||
db
|
||||
|
||||
@@ -34,8 +34,8 @@
|
||||
(some-> (:start (:date-range args)) coerce/to-date)
|
||||
(some-> (:end (:date-range args)) coerce/to-date)]]}
|
||||
|
||||
(:only-external args)
|
||||
(merge-query {:query {:where ['(not [?e :journal-entry/original-entity ])]}})
|
||||
(:only-external args)
|
||||
(merge-query {:query {:where ['(not [?e :journal-entry/original-entity])]}})
|
||||
|
||||
(seq (:external-id-like args))
|
||||
(merge-query {:query {:in ['?external-id-like]
|
||||
@@ -48,12 +48,11 @@
|
||||
:where ['[?e :journal-entry/source ?source]]}
|
||||
:args [(:source args)]})
|
||||
|
||||
(:vendor-id args)
|
||||
(:vendor-id args)
|
||||
(merge-query {:query {:in ['?vendor-id]
|
||||
:where ['[?e :journal-entry/vendor ?vendor-id]]}
|
||||
:args [(:vendor-id args)]})
|
||||
|
||||
|
||||
(or (seq (:numeric-code args))
|
||||
(:bank-account-id args)
|
||||
(not-empty (:location args)))
|
||||
@@ -70,36 +69,35 @@
|
||||
:args [(vec (for [{:keys [from to]} (:numeric-code args)]
|
||||
[(or from 0) (or to 99999)]))]})
|
||||
|
||||
|
||||
(:amount-gte args)
|
||||
(:amount-gte args)
|
||||
(merge-query {:query {:in ['?amount-gte]
|
||||
:where ['[?e :journal-entry/amount ?a]
|
||||
'[(>= ?a ?amount-gte)]]}
|
||||
:args [(:amount-gte args)]})
|
||||
|
||||
(:amount-lte args)
|
||||
(:amount-lte args)
|
||||
(merge-query {:query {:in ['?amount-lte]
|
||||
:where ['[?e :journal-entry/amount ?a]
|
||||
'[(<= ?a ?amount-lte)]]}
|
||||
:args [(:amount-lte args)]})
|
||||
|
||||
(:bank-account-id args)
|
||||
(:bank-account-id args)
|
||||
(merge-query {:query {:in ['?a]
|
||||
:where ['[?li :journal-entry-line/account ?a]]}
|
||||
:args [(:bank-account-id args)]})
|
||||
|
||||
(:account-id args)
|
||||
(:account-id args)
|
||||
(merge-query {:query {:in ['?a2]
|
||||
:where ['[?e :journal-entry/line-items ?li2]
|
||||
'[?li2 :journal-entry-line/account ?a2]]}
|
||||
:args [(:account-id args)]})
|
||||
|
||||
(not-empty (:location args))
|
||||
(not-empty (:location args))
|
||||
(merge-query {:query {:in ['?location]
|
||||
:where ['[?li :journal-entry-line/location ?location]]}
|
||||
:args [(:location args)]})
|
||||
|
||||
(not-empty (:locations args))
|
||||
(not-empty (:locations args))
|
||||
(merge-query {:query {:in ['[?location ...]]
|
||||
:where ['[?li :journal-entry-line/location ?location]]}
|
||||
:args [(:locations args)]})
|
||||
@@ -118,7 +116,7 @@
|
||||
(merge-query {:query {:find ['?sort-default '?e]}})))]
|
||||
(->> (observable-query query)
|
||||
(apply-sort-4 (assoc args :default-asc? true))
|
||||
(apply-pagination args))))
|
||||
(apply-pagination args))))
|
||||
|
||||
(defn graphql-results [ids db _]
|
||||
(let [results (->> (pull-many db '[* {:journal-entry/client [:client/name :client/code :db/id]
|
||||
@@ -134,15 +132,15 @@
|
||||
(update je :journal-entry/line-items
|
||||
(fn [jels]
|
||||
(map
|
||||
#(update % :journal-entry-line/account d-accounts/clientize (:db/id (:journal-entry/client je)))
|
||||
jels)))))
|
||||
#(update % :journal-entry-line/account d-accounts/clientize (:db/id (:journal-entry/client je)))
|
||||
jels)))))
|
||||
(filter (fn [je]
|
||||
(every?
|
||||
(fn [jel]
|
||||
(let [include-in-reports (-> jel :journal-entry-line/account :bank-account/include-in-reports)]
|
||||
(or (nil? include-in-reports)
|
||||
(true? include-in-reports))))
|
||||
(:journal-entry/line-items je))))
|
||||
(fn [jel]
|
||||
(let [include-in-reports (-> jel :journal-entry-line/account :bank-account/include-in-reports)]
|
||||
(or (nil? include-in-reports)
|
||||
(true? include-in-reports))))
|
||||
(:journal-entry/line-items je))))
|
||||
(group-by :db/id))]
|
||||
(->> ids
|
||||
(map results)
|
||||
@@ -156,7 +154,7 @@
|
||||
matching-count]))
|
||||
|
||||
(defn filter-ids [ids]
|
||||
(if ids
|
||||
(if ids
|
||||
(->> (dc/q {:find ['?e]
|
||||
:in ['$ '[?e ...]]
|
||||
:where ['[?e :journal-entry/date]]}
|
||||
|
||||
@@ -23,9 +23,9 @@
|
||||
(update :sales-order/charges (fn [cs]
|
||||
(map (fn [c]
|
||||
(-> c
|
||||
(update :charge/processor :db/ident)
|
||||
(set/rename-keys {:expected-deposit/_charges :expected-deposit})
|
||||
(update :expected-deposit first)))
|
||||
(update :charge/processor :db/ident)
|
||||
(set/rename-keys {:expected-deposit/_charges :expected-deposit})
|
||||
(update :expected-deposit first)))
|
||||
cs)))))
|
||||
|
||||
(def default-read '[:db/id
|
||||
@@ -43,8 +43,7 @@
|
||||
:sales-order/source,
|
||||
:sales-order/reference-link,
|
||||
{:sales-order/client [:client/name :db/id :client/code]
|
||||
:sales-order/charges [
|
||||
:charge/type-name,
|
||||
:sales-order/charges [:charge/type-name,
|
||||
:charge/total,
|
||||
:charge/tax,
|
||||
:charge/tip,
|
||||
@@ -63,7 +62,6 @@
|
||||
(set/intersection #{(:client-id args)}
|
||||
visible-clients)
|
||||
|
||||
|
||||
(:client-code args)
|
||||
(set/intersection #{(pull-id db [:client/code (:client-code args)])}
|
||||
visible-clients)
|
||||
@@ -79,7 +77,7 @@
|
||||
:where '[[(iol-ion.query/scan-sales-orders $ ?clients ?start-date ?end-date) [[?e _ ?sort-default] ...]]]}
|
||||
:args [db [selected-clients
|
||||
(some-> (:start (:date-range args)) c/to-date)
|
||||
(some-> (:end (:date-range args)) c/to-date )]]}
|
||||
(some-> (:end (:date-range args)) c/to-date)]]}
|
||||
|
||||
(:sort args) (add-sorter-fields-2 {"client" ['[?e :sales-order/client ?c]
|
||||
'[?c :client/name ?sort-client]]
|
||||
@@ -108,13 +106,13 @@
|
||||
'[?chg :charge/type-name ?type-name]]}
|
||||
:args [(:type-name args)]})
|
||||
|
||||
(:total-gte args)
|
||||
(:total-gte args)
|
||||
(merge-query {:query {:in ['?total-gte]
|
||||
:where ['[?e :sales-order/total ?a]
|
||||
'[(>= ?a ?total-gte)]]}
|
||||
:args [(:total-gte args)]})
|
||||
|
||||
(:total-lte args)
|
||||
(:total-lte args)
|
||||
(merge-query {:query {:in ['?total-lte]
|
||||
:where ['[?e :sales-order/total ?a]
|
||||
'[(<= ?a ?total-lte)]]}
|
||||
@@ -136,7 +134,7 @@
|
||||
|
||||
(defn graphql-results [ids db _]
|
||||
(let [results (->> (pull-many db default-read ids)
|
||||
(group-by :db/id))
|
||||
(group-by :db/id))
|
||||
payments (->> ids
|
||||
(map results)
|
||||
(map first)
|
||||
@@ -146,14 +144,14 @@
|
||||
(defn summarize-orders [ids]
|
||||
|
||||
(let [[total tax] (->>
|
||||
(dc/q {:find ['(sum ?t) '(sum ?tax)]
|
||||
:with ['?id]
|
||||
:in ['$ '[?id ...]]
|
||||
:where ['[?id :sales-order/total ?t]
|
||||
'[?id :sales-order/tax ?tax]]}
|
||||
(dc/db conn)
|
||||
ids)
|
||||
first)]
|
||||
(dc/q {:find ['(sum ?t) '(sum ?tax)]
|
||||
:with ['?id]
|
||||
:in ['$ '[?id ...]]
|
||||
:where ['[?id :sales-order/total ?t]
|
||||
'[?id :sales-order/tax ?tax]]}
|
||||
(dc/db conn)
|
||||
ids)
|
||||
first)]
|
||||
{:total total
|
||||
:tax tax}))
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
"note" ['[?e :transaction-rule/note ?sort-note]]
|
||||
"amount-lte" ['[?e :transaction-rule/amount-lte ?sort-amount-lte]]
|
||||
"amount-gte" ['[?e :transaction-rule/amount-gte ?sort-amount-gte]]}
|
||||
args)
|
||||
args)
|
||||
|
||||
(seq (:clients args))
|
||||
(merge-query {:query {:in ['[?xx ...]]
|
||||
@@ -78,7 +78,6 @@
|
||||
(merge-query {:query {:find ['?e]
|
||||
:where ['[?e :transaction-rule/transaction-approval-status]]}}))]
|
||||
|
||||
|
||||
(cond->> (query2 query)
|
||||
true (apply-sort-3 args)
|
||||
true (apply-pagination args))))
|
||||
@@ -99,13 +98,13 @@
|
||||
matching-count]))
|
||||
|
||||
(defn get-by-id [id]
|
||||
(->>
|
||||
(->>
|
||||
(dc/pull (dc/db conn) default-read id)
|
||||
(<-datomic)))
|
||||
|
||||
(defn get-all []
|
||||
(mapv first
|
||||
(dc/q {:find [(list 'pull '?e default-read )]
|
||||
(dc/q {:find [(list 'pull '?e default-read)]
|
||||
:in ['$]
|
||||
:where ['[?e :transaction-rule/transaction-approval-status]]}
|
||||
(dc/db conn))))
|
||||
|
||||
@@ -37,7 +37,6 @@
|
||||
(map first)
|
||||
set)))
|
||||
|
||||
|
||||
(defn raw-graphql-ids
|
||||
([args] (raw-graphql-ids (dc/db conn) args))
|
||||
([db args]
|
||||
@@ -87,7 +86,6 @@
|
||||
:where ['[?e :transaction/vendor ?vendor-id]]}
|
||||
:args [(:vendor-id args)]})
|
||||
|
||||
|
||||
(:amount-gte args)
|
||||
(merge-query {:query {:in ['?amount-gte]
|
||||
:where ['[?e :transaction/amount ?a]
|
||||
|
||||
@@ -4,15 +4,15 @@
|
||||
[datomic.api :as dc]
|
||||
[datomic.api :as d]))
|
||||
|
||||
(defn find-or-insert! [{:keys [:user/provider :user/provider-id ] :as new-user}]
|
||||
(defn find-or-insert! [{:keys [:user/provider :user/provider-id] :as new-user}]
|
||||
(let [is-first-user? (not (seq (dc/q [:find '?e
|
||||
:in '$
|
||||
:where '[?e :user/provider]]
|
||||
(dc/db conn))))
|
||||
user-id (ffirst (dc/q '[:find ?e
|
||||
:in $ ?provider ?provider-id
|
||||
:where [?e :user/provider ?provider]
|
||||
[?e :user/provider-id ?provider-id]]
|
||||
:in $ ?provider ?provider-id
|
||||
:where [?e :user/provider ?provider]
|
||||
[?e :user/provider-id ?provider-id]]
|
||||
(dc/db conn) provider provider-id))
|
||||
result @(dc/transact conn [[:upsert-entity (cond-> (assoc new-user :db/id (or user-id "user")
|
||||
:user/last-login (java.util.Date.))
|
||||
|
||||
@@ -18,29 +18,29 @@
|
||||
(:vendor/legal-entity-tin-type a) (update :vendor/legal-entity-tin-type :db/ident)
|
||||
(:vendor/legal-entity-1099-type a) (update :vendor/legal-entity-1099-type :db/ident)
|
||||
true (assoc :usage (:vendor-usage/_vendor a))
|
||||
true (dissoc :vendor-usage/_vendor )))
|
||||
true (dissoc :vendor-usage/_vendor)))
|
||||
|
||||
(defn cleanse [id vendor]
|
||||
(let [clients (if-let [clients (limited-clients id)]
|
||||
(set (map :db/id clients))
|
||||
nil)]
|
||||
(if clients
|
||||
(-> vendor
|
||||
(-> vendor
|
||||
(update :vendor/account-overrides (fn [aos]
|
||||
(->> aos
|
||||
(filter #(clients (:db/id (:vendor-account-override/client %))))
|
||||
(map #(update % :vendor-account-override/account d-accounts/clientize (:db/id (:vendor-account-override/client %)))))))
|
||||
(update :vendor/terms-overrides (fn [to] (filter #(clients (:db/id (:vendor-terms-override/client %))) to)))
|
||||
(update :vendor/schedule-payment-dom (fn [to] (filter #(clients (:db/id (:vendor-schedule-payment-dom/client %))) to))))
|
||||
(-> vendor
|
||||
(-> vendor
|
||||
(update :vendor/account-overrides (fn [aos]
|
||||
(->> aos
|
||||
(map #(update % :vendor-account-override/account d-accounts/clientize (:db/id (:vendor-account-override/client %)))))))))))
|
||||
|
||||
(def default-read
|
||||
'[* {:vendor/account-overrides [* {:vendor-account-override/client [:client/name :db/id]
|
||||
:vendor-account-override/account [:account/name :account/numeric-code :db/id
|
||||
{:account/client-overrides [:account-client-override/client :account-client-override/name]}]}]
|
||||
:vendor-account-override/account [:account/name :account/numeric-code :db/id
|
||||
{:account/client-overrides [:account-client-override/client :account-client-override/name]}]}]
|
||||
:vendor/terms-overrides [* {:vendor-terms-override/client [:client/name :client/code :db/id]}]
|
||||
:vendor/schedule-payment-dom [* {:vendor-schedule-payment-dom/client [:client/name :client/code :db/id]}]
|
||||
:vendor/automatically-paid-when-due [:db/id :client/name]
|
||||
@@ -50,14 +50,13 @@
|
||||
:vendor/plaid-merchant [:db/id :plaid-merchant/name]
|
||||
:vendor-usage/_vendor [:vendor-usage/client :vendor-usage/count]}])
|
||||
|
||||
|
||||
(defn raw-graphql-ids [db args]
|
||||
(let [query (cond-> {:query {:find []
|
||||
:in ['$]
|
||||
:where []}
|
||||
:args [db]}
|
||||
(:sort args) (add-sorter-fields {"name" ['[?e :vendor/name ?sort-name]]}
|
||||
args)
|
||||
args)
|
||||
|
||||
(not (str/blank? (:name-like args)))
|
||||
(merge-query {:query {:in ['?name-like]
|
||||
@@ -70,25 +69,22 @@
|
||||
(merge-query {:query {:find ['?e]
|
||||
:where ['[?e :vendor/name]]}}))]
|
||||
|
||||
|
||||
(cond->> (query2 query)
|
||||
true (apply-sort-3 args)
|
||||
true (apply-pagination args))))
|
||||
|
||||
(defn trim-usage [v limited-clients]
|
||||
(->> (if limited-clients
|
||||
(update v :usage (fn [usages]
|
||||
(->> usages
|
||||
(filter (comp (set (map :db/id limited-clients)) :db/id :vendor-usage/client))
|
||||
(map (fn [u] {:client-id (:db/id (:vendor-usage/client u))
|
||||
:count (:vendor-usage/count u)})))))
|
||||
(update v :usage (fn [usages]
|
||||
(->> usages
|
||||
(filter (comp (set (map :db/id limited-clients)) :db/id :vendor-usage/client))
|
||||
(map (fn [u] {:client-id (:db/id (:vendor-usage/client u))
|
||||
:count (:vendor-usage/count u)})))))
|
||||
|
||||
(update v :usage (fn [usages]
|
||||
(->> usages
|
||||
(map (fn [u] {:client-id (:db/id (:vendor-usage/client u))
|
||||
:count (:vendor-usage/count u)}))))))
|
||||
|
||||
))
|
||||
(update v :usage (fn [usages]
|
||||
(->> usages
|
||||
(map (fn [u] {:client-id (:db/id (:vendor-usage/client u))
|
||||
:count (:vendor-usage/count u)}))))))))
|
||||
|
||||
(defn graphql-results [ids db args]
|
||||
(let [results (->> (pull-many db default-read ids)
|
||||
@@ -104,9 +100,7 @@
|
||||
(let [db (dc/db conn)
|
||||
{ids-to-retrieve :ids matching-count :count} (raw-graphql-ids db args)]
|
||||
[(->> (graphql-results ids-to-retrieve db args))
|
||||
matching-count])
|
||||
|
||||
)
|
||||
matching-count]))
|
||||
|
||||
(defn get-graphql-by-id [args id]
|
||||
(->> (dc/q {:find [(list 'pull '?e default-read)]
|
||||
@@ -120,29 +114,28 @@
|
||||
first))
|
||||
|
||||
(defn get-by-id [id]
|
||||
|
||||
|
||||
(->> (dc/q '[:find (pull ?e [*
|
||||
{:vendor/default-account [:account/name :db/id :account/location]
|
||||
:vendor/legal-entity-tin-type [:db/ident :db/id]
|
||||
:vendor/legal-entity-1099-type [:db/ident :db/id]
|
||||
:vendor/plaid-merchant [:db/id :plaid-merchant/name]
|
||||
:vendor/account-overrides [* {:vendor-account-override/client [:client/name :db/id]
|
||||
:vendor-account-override/account [:account/name :account/numeric-code :db/id]}]
|
||||
:vendor/terms-overrides [* {:vendor-terms-override/client [:client/name :db/id]}]
|
||||
:vendor/schedule-payment-dom [* {:vendor-schedule-payment-dom/client [:client/name :db/id]}]
|
||||
:vendor/automatically-paid-when-due [:db/id :client/name]}])
|
||||
:in $ ?e
|
||||
:where [?e]]
|
||||
(dc/db conn)
|
||||
id)
|
||||
{:vendor/default-account [:account/name :db/id :account/location]
|
||||
:vendor/legal-entity-tin-type [:db/ident :db/id]
|
||||
:vendor/legal-entity-1099-type [:db/ident :db/id]
|
||||
:vendor/plaid-merchant [:db/id :plaid-merchant/name]
|
||||
:vendor/account-overrides [* {:vendor-account-override/client [:client/name :db/id]
|
||||
:vendor-account-override/account [:account/name :account/numeric-code :db/id]}]
|
||||
:vendor/terms-overrides [* {:vendor-terms-override/client [:client/name :db/id]}]
|
||||
:vendor/schedule-payment-dom [* {:vendor-schedule-payment-dom/client [:client/name :db/id]}]
|
||||
:vendor/automatically-paid-when-due [:db/id :client/name]}])
|
||||
:in $ ?e
|
||||
:where [?e]]
|
||||
(dc/db conn)
|
||||
id)
|
||||
(map first)
|
||||
(map <-datomic)
|
||||
(first)))
|
||||
|
||||
|
||||
(defn terms-for-client-id [vendor client-id]
|
||||
(or
|
||||
(->>
|
||||
(->>
|
||||
(filter
|
||||
(fn [to]
|
||||
(= (:db/id (:vendor-terms-override/client to))
|
||||
@@ -153,8 +146,8 @@
|
||||
(:vendor/terms vendor)))
|
||||
|
||||
(defn account-for-client-id [vendor client-id]
|
||||
(or
|
||||
(->>
|
||||
(or
|
||||
(->>
|
||||
(filter
|
||||
(fn [to]
|
||||
(= (:db/id (:vendor-account-override/client to))
|
||||
@@ -165,7 +158,7 @@
|
||||
(:vendor/default-account vendor)))
|
||||
|
||||
(defn automatically-paid-for-client-id? [vendor client-id]
|
||||
(->>
|
||||
(->>
|
||||
(:vendor/automatically-paid-when-due vendor)
|
||||
(filter
|
||||
(fn [client]
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
(defn get-merchants [_]
|
||||
;; TODO admin?
|
||||
(->>
|
||||
(dc/q {:find ['(pull ?e [:yodlee-merchant/name :yodlee-merchant/yodlee-id :db/id])]
|
||||
:in ['$]
|
||||
:where [['?e :yodlee-merchant/name]]}
|
||||
(dc/db conn))
|
||||
(mapv first)))
|
||||
(dc/q {:find ['(pull ?e [:yodlee-merchant/name :yodlee-merchant/yodlee-id :db/id])]
|
||||
:in ['$]
|
||||
:where [['?e :yodlee-merchant/name]]}
|
||||
(dc/db conn))
|
||||
(mapv first)))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
(ns auto-ap.ezcater.core
|
||||
(:require
|
||||
[auto-ap.datomic :refer [conn random-tempid]]
|
||||
[auto-ap.datomic :refer [conn random-tempid]]
|
||||
[datomic.api :as dc]
|
||||
[clj-http.client :as client]
|
||||
[venia.core :as v]
|
||||
@@ -20,42 +20,41 @@
|
||||
:body (json/write-str {"query" (v/graphql-query q)})
|
||||
:as :json})
|
||||
:body
|
||||
:data
|
||||
))
|
||||
:data))
|
||||
|
||||
(defn get-caterers [integration]
|
||||
(:caterers (query integration {:venia/queries [{:query/data
|
||||
[:caterers [:name :uuid [:address [:name :street]]]]}]} )))
|
||||
[:caterers [:name :uuid [:address [:name :street]]]]}]})))
|
||||
|
||||
(defn get-subscriptions [integration]
|
||||
(->> (query integration {:venia/queries [{:query/data
|
||||
[:subscribers [:id [:subscriptions [:parentId :parentEntity :eventEntity :eventKey]] ]]}]} )
|
||||
[:subscribers [:id [:subscriptions [:parentId :parentEntity :eventEntity :eventKey]]]]}]})
|
||||
:subscribers
|
||||
first
|
||||
:subscriptions))
|
||||
|
||||
(defn get-integrations []
|
||||
(map first (dc/q '[:find (pull ?i [:ezcater-integration/api-key
|
||||
:ezcater-integration/subscriber-uuid
|
||||
:db/id
|
||||
:ezcater-integration/integration-status [:db/id]])
|
||||
:in $
|
||||
:where [?i :ezcater-integration/api-key]]
|
||||
(dc/db conn))))
|
||||
:ezcater-integration/subscriber-uuid
|
||||
:db/id
|
||||
:ezcater-integration/integration-status [:db/id]])
|
||||
:in $
|
||||
:where [?i :ezcater-integration/api-key]]
|
||||
(dc/db conn))))
|
||||
|
||||
(defn mark-integration-status [integration integration-status]
|
||||
@(dc/transact conn
|
||||
[{:db/id (:db/id integration)
|
||||
:ezcater-integration/integration-status (assoc integration-status
|
||||
:db/id (or (-> integration :ezcater-integration/integration-status :db/id)
|
||||
(random-tempid)))}]))
|
||||
[{:db/id (:db/id integration)
|
||||
:ezcater-integration/integration-status (assoc integration-status
|
||||
:db/id (or (-> integration :ezcater-integration/integration-status :db/id)
|
||||
(random-tempid)))}]))
|
||||
|
||||
(defn upsert-caterers
|
||||
([integration]
|
||||
@(dc/transact
|
||||
conn
|
||||
(for [caterer (get-caterers integration)]
|
||||
{:db/id (:db/id integration)
|
||||
{:db/id (:db/id integration)
|
||||
:ezcater-integration/caterers [{:ezcater-caterer/name (str (:name caterer) " (" (:street (:address caterer)) ")")
|
||||
:ezcater-caterer/search-terms (str (:name caterer) " " (:street (:address caterer)))
|
||||
:ezcater-caterer/uuid (:uuid caterer)}]}))))
|
||||
@@ -64,14 +63,14 @@
|
||||
([integration]
|
||||
(let [extant (get-subscriptions integration)
|
||||
to-ensure (set (map first (dc/q '[:find ?cu
|
||||
:in $
|
||||
:where [_ :client/ezcater-locations ?el]
|
||||
[?el :ezcater-location/caterer ?c]
|
||||
[?c :ezcater-caterer/uuid ?cu]]
|
||||
(dc/db conn))))
|
||||
:in $
|
||||
:where [_ :client/ezcater-locations ?el]
|
||||
[?el :ezcater-location/caterer ?c]
|
||||
[?c :ezcater-caterer/uuid ?cu]]
|
||||
(dc/db conn))))
|
||||
to-create (set/difference
|
||||
to-ensure
|
||||
(set (map :parentId extant)))]
|
||||
to-ensure
|
||||
(set (map :parentId extant)))]
|
||||
(doseq [parentId to-create]
|
||||
(query integration
|
||||
{:venia/operation {:operation/type :mutation
|
||||
@@ -94,7 +93,6 @@
|
||||
:eventKey 'cancelled}}
|
||||
[[:subscription [:parentId :parentEntity :eventEntity :eventKey]]]]]})))))
|
||||
|
||||
|
||||
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
||||
(defn upsert-ezcater
|
||||
([] (upsert-ezcater (get-integrations)))
|
||||
@@ -115,12 +113,11 @@
|
||||
|
||||
(defn get-caterer [caterer-uuid]
|
||||
(dc/pull (dc/db conn)
|
||||
'[:ezcater-caterer/name
|
||||
{:ezcater-integration/_caterers [:ezcater-integration/api-key]}
|
||||
{:ezcater-location/_caterer [:ezcater-location/location
|
||||
{:client/_ezcater-locations [:client/code]}]}]
|
||||
[:ezcater-caterer/uuid caterer-uuid]))
|
||||
|
||||
'[:ezcater-caterer/name
|
||||
{:ezcater-integration/_caterers [:ezcater-integration/api-key]}
|
||||
{:ezcater-location/_caterer [:ezcater-location/location
|
||||
{:client/_ezcater-locations [:client/code]}]}]
|
||||
[:ezcater-caterer/uuid caterer-uuid]))
|
||||
|
||||
(defn round-carry-cents [f]
|
||||
(with-precision 2 (double (.setScale (bigdec f) 2 java.math.RoundingMode/HALF_UP))))
|
||||
@@ -135,126 +132,118 @@
|
||||
0.15M
|
||||
:else
|
||||
0.07M)]
|
||||
(round-carry-cents
|
||||
(* commision%
|
||||
0.01M
|
||||
(+
|
||||
(-> order :totals :subTotal :subunits )
|
||||
(reduce +
|
||||
0
|
||||
(map (comp :subunits :cost) (:feesAndDiscounts (:catererCart order)))))))))
|
||||
|
||||
(defn ccp-fee [order]
|
||||
(round-carry-cents
|
||||
(* 0.000299M
|
||||
(+
|
||||
(-> order :totals :subTotal :subunits )
|
||||
(-> order :totals :salesTax :subunits )
|
||||
(round-carry-cents
|
||||
(* commision%
|
||||
0.01M
|
||||
(+
|
||||
(-> order :totals :subTotal :subunits)
|
||||
(reduce +
|
||||
0
|
||||
(map (comp :subunits :cost) (:feesAndDiscounts (:catererCart order))))))))
|
||||
(map (comp :subunits :cost) (:feesAndDiscounts (:catererCart order)))))))))
|
||||
|
||||
(defn ccp-fee [order]
|
||||
(round-carry-cents
|
||||
(* 0.000299M
|
||||
(+
|
||||
(-> order :totals :subTotal :subunits)
|
||||
(-> order :totals :salesTax :subunits)
|
||||
(reduce +
|
||||
0
|
||||
(map (comp :subunits :cost) (:feesAndDiscounts (:catererCart order))))))))
|
||||
|
||||
(defn order->sales-order [{{:keys [timestamp]} :event {:keys [orderItems]} :catererCart :keys [client-code client-location uuid] :as order}]
|
||||
(let [adjustment (round-carry-cents (- (+ (-> order :totals :subTotal :subunits (* 0.01))
|
||||
(-> order :totals :salesTax :subunits (* 0.01)))
|
||||
(-> order :catererCart :totals :catererTotalDue )
|
||||
(-> order :catererCart :totals :catererTotalDue)
|
||||
(commision order)
|
||||
(ccp-fee order)))
|
||||
service-charge (+ (commision order) (ccp-fee order))
|
||||
tax (-> order :totals :salesTax :subunits (* 0.01))
|
||||
tip (-> order :totals :tip :subunits (* 0.01))]
|
||||
#:sales-order
|
||||
{:date (atime/localize (coerce/to-date-time timestamp))
|
||||
:external-id (str "ezcater/order/" client-code "-" client-location "-" uuid)
|
||||
:client [:client/code client-code]
|
||||
:location client-location
|
||||
:reference-link (str (url/url "https://ezmanage.ezcater.com/orders/" uuid ))
|
||||
:line-items [#:order-line-item
|
||||
{:external-id (str "ezcater/order/" client-code "-" client-location "-" uuid "-" 0)
|
||||
:item-name "EZCater Catering"
|
||||
:category "EZCater Catering"
|
||||
:discount adjustment
|
||||
:tax tax
|
||||
:total (+ (-> order :totals :subTotal :subunits (* 0.01))
|
||||
tax
|
||||
tip)}]
|
||||
:charges [#:charge
|
||||
{:type-name "CARD"
|
||||
:date (atime/localize (coerce/to-date-time timestamp))
|
||||
:client [:client/code client-code]
|
||||
:location client-location
|
||||
:external-id (str "ezcater/charge/" uuid)
|
||||
:processor :ccp-processor/ezcater
|
||||
:total (+ (-> order :totals :subTotal :subunits (* 0.01))
|
||||
tax
|
||||
tip)
|
||||
:tip tip}]
|
||||
|
||||
:total (+ (-> order :totals :subTotal :subunits (* 0.01))
|
||||
tax
|
||||
tip)
|
||||
:discount adjustment
|
||||
:service-charge service-charge
|
||||
:tax tax
|
||||
:tip tip
|
||||
:returns 0.0
|
||||
:vendor :vendor/ccp-ezcater}))
|
||||
{:date (atime/localize (coerce/to-date-time timestamp))
|
||||
:external-id (str "ezcater/order/" client-code "-" client-location "-" uuid)
|
||||
:client [:client/code client-code]
|
||||
:location client-location
|
||||
:reference-link (str (url/url "https://ezmanage.ezcater.com/orders/" uuid))
|
||||
:line-items [#:order-line-item
|
||||
{:external-id (str "ezcater/order/" client-code "-" client-location "-" uuid "-" 0)
|
||||
:item-name "EZCater Catering"
|
||||
:category "EZCater Catering"
|
||||
:discount adjustment
|
||||
:tax tax
|
||||
:total (+ (-> order :totals :subTotal :subunits (* 0.01))
|
||||
tax
|
||||
tip)}]
|
||||
:charges [#:charge
|
||||
{:type-name "CARD"
|
||||
:date (atime/localize (coerce/to-date-time timestamp))
|
||||
:client [:client/code client-code]
|
||||
:location client-location
|
||||
:external-id (str "ezcater/charge/" uuid)
|
||||
:processor :ccp-processor/ezcater
|
||||
:total (+ (-> order :totals :subTotal :subunits (* 0.01))
|
||||
tax
|
||||
tip)
|
||||
:tip tip}]
|
||||
|
||||
:total (+ (-> order :totals :subTotal :subunits (* 0.01))
|
||||
tax
|
||||
tip)
|
||||
:discount adjustment
|
||||
:service-charge service-charge
|
||||
:tax tax
|
||||
:tip tip
|
||||
:returns 0.0
|
||||
:vendor :vendor/ccp-ezcater}))
|
||||
|
||||
(defn get-by-id [integration id]
|
||||
(query
|
||||
integration
|
||||
{:venia/queries [[:order {:id id}
|
||||
[:uuid
|
||||
:orderNumber
|
||||
:orderSourceType
|
||||
[:caterer
|
||||
[:name
|
||||
:uuid
|
||||
[:address [:street]]]]
|
||||
[:event
|
||||
[:timestamp
|
||||
:catererHandoffFoodTime
|
||||
:orderType]]
|
||||
[:catererCart [[:orderItems
|
||||
[:name
|
||||
:quantity
|
||||
:posItemId
|
||||
[:totalInSubunits
|
||||
[:currency
|
||||
:subunits]]]]
|
||||
[:totals
|
||||
[:catererTotalDue]]
|
||||
[:feesAndDiscounts
|
||||
{:type 'DELIVERY_FEE}
|
||||
[[:cost
|
||||
[:currency
|
||||
:subunits]]]]]]
|
||||
[:totals [[:customerTotalDue
|
||||
[
|
||||
:currency
|
||||
:subunits
|
||||
]]
|
||||
[:pointOfSaleIntegrationFee
|
||||
[
|
||||
:currency
|
||||
:subunits
|
||||
]]
|
||||
[:tip
|
||||
[:currency
|
||||
:subunits]]
|
||||
[:salesTax
|
||||
[
|
||||
:currency
|
||||
:subunits
|
||||
]]
|
||||
[:salesTaxRemittance
|
||||
[:currency
|
||||
:subunits
|
||||
]]
|
||||
[:subTotal
|
||||
[:currency
|
||||
:subunits]]]]]]]}))
|
||||
(query
|
||||
integration
|
||||
{:venia/queries [[:order {:id id}
|
||||
[:uuid
|
||||
:orderNumber
|
||||
:orderSourceType
|
||||
[:caterer
|
||||
[:name
|
||||
:uuid
|
||||
[:address [:street]]]]
|
||||
[:event
|
||||
[:timestamp
|
||||
:catererHandoffFoodTime
|
||||
:orderType]]
|
||||
[:catererCart [[:orderItems
|
||||
[:name
|
||||
:quantity
|
||||
:posItemId
|
||||
[:totalInSubunits
|
||||
[:currency
|
||||
:subunits]]]]
|
||||
[:totals
|
||||
[:catererTotalDue]]
|
||||
[:feesAndDiscounts
|
||||
{:type 'DELIVERY_FEE}
|
||||
[[:cost
|
||||
[:currency
|
||||
:subunits]]]]]]
|
||||
[:totals [[:customerTotalDue
|
||||
[:currency
|
||||
:subunits]]
|
||||
[:pointOfSaleIntegrationFee
|
||||
[:currency
|
||||
:subunits]]
|
||||
[:tip
|
||||
[:currency
|
||||
:subunits]]
|
||||
[:salesTax
|
||||
[:currency
|
||||
:subunits]]
|
||||
[:salesTaxRemittance
|
||||
[:currency
|
||||
:subunits]]
|
||||
[:subTotal
|
||||
[:currency
|
||||
:subunits]]]]]]]}))
|
||||
|
||||
(defn lookup-order [json]
|
||||
(let [caterer (get-caterer (get json "parent_id"))
|
||||
@@ -262,25 +251,25 @@
|
||||
client (-> caterer :ezcater-location/_caterer first :client/_ezcater-locations :client/code)
|
||||
location (-> caterer :ezcater-location/_caterer first :ezcater-location/location)]
|
||||
(if (and client location)
|
||||
(doto
|
||||
(-> (get-by-id integration (get json "entity_id"))
|
||||
(:order)
|
||||
(assoc :client-code client
|
||||
:client-location location))
|
||||
(doto
|
||||
(-> (get-by-id integration (get json "entity_id"))
|
||||
(:order)
|
||||
(assoc :client-code client
|
||||
:client-location location))
|
||||
(#(alog/info ::order-details :detail %)))
|
||||
(alog/warn ::caterer-no-longer-has-location :json json))))
|
||||
|
||||
(defn import-order [json]
|
||||
;; {"id" "bf3dcf5c-a68f-42d9-9084-049133e03d3d", "parent_type" "Caterer", "parent_id" "91541331-d7ae-4634-9e8b-ccbbcfb2ce70", "entity_type" "Order", "entity_id" "9ab05fee-a9c5-483b-a7f2-14debde4b7a8", "key" "accepted", "occurred_at" "2022-07-21T19:21:07.549Z"}
|
||||
(alog/info
|
||||
::try-import-order
|
||||
:json json)
|
||||
::try-import-order
|
||||
:json json)
|
||||
@(dc/transact conn (filter identity
|
||||
[(some-> json
|
||||
(lookup-order)
|
||||
(order->sales-order)
|
||||
(update :sales-order/date coerce/to-date)
|
||||
(update-in [:sales-order/charges 0 :charge/date] coerce/to-date))])))
|
||||
[(some-> json
|
||||
(lookup-order)
|
||||
(order->sales-order)
|
||||
(update :sales-order/date coerce/to-date)
|
||||
(update-in [:sales-order/charges 0 :charge/date] coerce/to-date))])))
|
||||
|
||||
(defn upsert-recent []
|
||||
(upsert-ezcater)
|
||||
@@ -289,17 +278,17 @@
|
||||
(filter #(= 7 (time/day-of-week %)))))
|
||||
(time/days 1)))
|
||||
orders-to-update (doall (for [[order uuid] (dc/q '[:find ?eid ?uuid
|
||||
:in $ ?start
|
||||
:where [?e :sales-order/vendor :vendor/ccp-ezcater]
|
||||
[?e :sales-order/date ?d]
|
||||
[(>= ?d ?start)]
|
||||
[?e :sales-order/external-id ?eid]
|
||||
[?e :sales-order/client ?c]
|
||||
[?c :client/ezcater-locations ?l]
|
||||
[?l :ezcater-location/caterer ?c2]
|
||||
[?c2 :ezcater-caterer/uuid ?uuid]]
|
||||
(dc/db conn)
|
||||
last-sunday)
|
||||
:in $ ?start
|
||||
:where [?e :sales-order/vendor :vendor/ccp-ezcater]
|
||||
[?e :sales-order/date ?d]
|
||||
[(>= ?d ?start)]
|
||||
[?e :sales-order/external-id ?eid]
|
||||
[?e :sales-order/client ?c]
|
||||
[?c :client/ezcater-locations ?l]
|
||||
[?l :ezcater-location/caterer ?c2]
|
||||
[?c2 :ezcater-caterer/uuid ?uuid]]
|
||||
(dc/db conn)
|
||||
last-sunday)
|
||||
:let [_ (alog/info ::considering
|
||||
:order order)
|
||||
id (last (str/split order #"/"))
|
||||
@@ -313,29 +302,29 @@
|
||||
"occurred_at" "2022-07-21T19:21:07.549Z"}
|
||||
ezcater-order (lookup-order lookup-map)
|
||||
extant-order (dc/pull (dc/db conn) '[:sales-order/total
|
||||
:sales-order/tax
|
||||
:sales-order/tip
|
||||
:sales-order/discount
|
||||
:sales-order/external-id
|
||||
{:sales-order/charges [:charge/tax
|
||||
:charge/tip
|
||||
:charge/total
|
||||
:charge/external-id]
|
||||
:sales-order/line-items [:order-line-item/external-id
|
||||
:order-line-item/total
|
||||
:order-line-item/tax
|
||||
:order-line-item/discount]}]
|
||||
[:sales-order/external-id order])
|
||||
:sales-order/tax
|
||||
:sales-order/tip
|
||||
:sales-order/discount
|
||||
:sales-order/external-id
|
||||
{:sales-order/charges [:charge/tax
|
||||
:charge/tip
|
||||
:charge/total
|
||||
:charge/external-id]
|
||||
:sales-order/line-items [:order-line-item/external-id
|
||||
:order-line-item/total
|
||||
:order-line-item/tax
|
||||
:order-line-item/discount]}]
|
||||
[:sales-order/external-id order])
|
||||
|
||||
updated-order (-> (order->sales-order ezcater-order)
|
||||
(select-keys
|
||||
#{:sales-order/total
|
||||
:sales-order/tax
|
||||
:sales-order/tip
|
||||
:sales-order/discount
|
||||
:sales-order/charges
|
||||
:sales-order/external-id
|
||||
:sales-order/line-items})
|
||||
#{:sales-order/total
|
||||
:sales-order/tax
|
||||
:sales-order/tip
|
||||
:sales-order/discount
|
||||
:sales-order/charges
|
||||
:sales-order/external-id
|
||||
:sales-order/line-items})
|
||||
(update :sales-order/line-items
|
||||
(fn [c]
|
||||
(map #(select-keys % #{:order-line-item/external-id
|
||||
|
||||
@@ -34,15 +34,14 @@
|
||||
(clojure.lang IPersistentMap)))
|
||||
|
||||
(def integreat-schema
|
||||
{
|
||||
:scalars {:id {:parse #(cond (number? %)
|
||||
{:scalars {:id {:parse #(cond (number? %)
|
||||
%
|
||||
|
||||
%
|
||||
(Long/parseLong %))
|
||||
|
||||
|
||||
:serialize #(.toString %)}
|
||||
:ident {:parse (fn [x] {:db/ident x})
|
||||
:ident {:parse (fn [x] {:db/ident x})
|
||||
:serialize #(or (:ident %) (:db/ident %) %)}
|
||||
:iso_date {:parse #(time/parse % time/iso-date)
|
||||
:serialize #(time/unparse % time/iso-date)}
|
||||
@@ -65,29 +64,28 @@
|
||||
:else
|
||||
%)
|
||||
:serialize #(cond (double? %)
|
||||
(str %)
|
||||
|
||||
(int? %)
|
||||
(str %)
|
||||
|
||||
:else
|
||||
%)}
|
||||
|
||||
:percentage {:parse #(cond (and (string? %)
|
||||
(not (str/blank? %)))
|
||||
(Double/parseDouble %)
|
||||
(str %)
|
||||
|
||||
(int? %)
|
||||
(double %)
|
||||
(str %)
|
||||
|
||||
:else
|
||||
%)
|
||||
%)}
|
||||
|
||||
:percentage {:parse #(cond (and (string? %)
|
||||
(not (str/blank? %)))
|
||||
(Double/parseDouble %)
|
||||
|
||||
(int? %)
|
||||
(double %)
|
||||
|
||||
:else
|
||||
%)
|
||||
:serialize #(if (double? %)
|
||||
(str %)
|
||||
%)}}
|
||||
:objects
|
||||
{
|
||||
:message
|
||||
{:message
|
||||
{:fields {:message {:type 'String}}}
|
||||
|
||||
:search_result
|
||||
@@ -128,8 +126,7 @@
|
||||
:email {:type 'String}
|
||||
:phone {:type 'String}}}
|
||||
|
||||
|
||||
:address
|
||||
:address
|
||||
{:fields {:id {:type :id}
|
||||
:street1 {:type 'String}
|
||||
:street2 {:type 'String}
|
||||
@@ -184,7 +181,6 @@
|
||||
:legal_entity_tin_type {:type :tin_type}
|
||||
:legal_entity_1099_type {:type :type_1099}}}
|
||||
|
||||
|
||||
:reminder
|
||||
{:fields {:id {:type 'Int}
|
||||
:email {:type 'String}
|
||||
@@ -193,13 +189,13 @@
|
||||
:scheduled {:type 'String}
|
||||
:sent {:type 'String}
|
||||
:vendor {:type :vendor}}}
|
||||
|
||||
|
||||
:yodlee_merchant {:fields {:id {:type :id}
|
||||
:yodlee_id {:type 'String}
|
||||
:name {:type 'String}}}
|
||||
|
||||
:plaid_merchant {:fields {:id {:type :id}
|
||||
:name {:type 'String}}}
|
||||
:name {:type 'String}}}
|
||||
|
||||
:intuit_bank_account {:fields {:id {:type :id}
|
||||
:external_id {:type 'String}
|
||||
@@ -222,8 +218,6 @@
|
||||
:accounts {:type '(list :percentage_account)}
|
||||
:transaction_approval_status {:type :transaction_approval_status}}}
|
||||
|
||||
|
||||
|
||||
:user
|
||||
{:fields {:id {:type :id}
|
||||
:name {:type 'String}
|
||||
@@ -264,18 +258,12 @@
|
||||
:start {:type 'Int}
|
||||
:end {:type 'Int}}}
|
||||
|
||||
|
||||
|
||||
:transaction_rule_page {:fields {:transaction_rules {:type '(list :transaction_rule)}
|
||||
:transaction_rule_page {:fields {:transaction_rules {:type '(list :transaction_rule)}
|
||||
:count {:type 'Int}
|
||||
:total {:type 'Int}
|
||||
:start {:type 'Int}
|
||||
:end {:type 'Int}}}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
:vendor_page {:fields {:vendors {:type '(list :vendor)}
|
||||
:count {:type 'Int}
|
||||
:total {:type 'Int}
|
||||
@@ -283,10 +271,10 @@
|
||||
:end {:type 'Int}}}
|
||||
|
||||
:account_page {:fields {:accounts {:type '(list :account)}
|
||||
:count {:type 'Int}
|
||||
:total {:type 'Int}
|
||||
:start {:type 'Int}
|
||||
:end {:type 'Int}}}
|
||||
:count {:type 'Int}
|
||||
:total {:type 'Int}
|
||||
:start {:type 'Int}
|
||||
:end {:type 'Int}}}
|
||||
|
||||
:reminder_page {:fields {:reminders {:type '(list :reminder)}
|
||||
:count {:type 'Int}
|
||||
@@ -303,7 +291,7 @@
|
||||
:paid {:type 'String}
|
||||
:unpaid {:type 'String}}}
|
||||
|
||||
:upcoming_transaction {:fields {:amount {:type :money}
|
||||
:upcoming_transaction {:fields {:amount {:type :money}
|
||||
:identifier {:type 'String}
|
||||
:date {:type :iso_date}}}
|
||||
|
||||
@@ -320,14 +308,13 @@
|
||||
:potential_transaction_rule_matches {:type '(list :transaction_rule)
|
||||
:args {:transaction_id {:type :id}}
|
||||
:resolve :get-transaction-rule-matches}
|
||||
|
||||
|
||||
:test_transaction_rule {:type '(list :transaction)
|
||||
:args {:transaction_rule {:type :edit_transaction_rule}}
|
||||
:resolve :test-transaction-rule}
|
||||
:args {:transaction_rule {:type :edit_transaction_rule}}
|
||||
:resolve :test-transaction-rule}
|
||||
|
||||
:run_transaction_rule {:type '(list :transaction)
|
||||
:args {:transaction_rule_id {:type :id}}
|
||||
:args {:transaction_rule_id {:type :id}}
|
||||
:resolve :run-transaction-rule}
|
||||
|
||||
:invoice_stats {:type '(list :invoice_stat)
|
||||
@@ -337,7 +324,6 @@
|
||||
:cash_flow {:type :cash_flow_result
|
||||
:args {:client_id {:type :id}}
|
||||
:resolve :get-cash-flow}
|
||||
|
||||
|
||||
:all_accounts {:type '(list :account)
|
||||
:args {}
|
||||
@@ -351,11 +337,7 @@
|
||||
:allowance {:type :account_allowance}
|
||||
:client_id {:type :id}
|
||||
:vendor_id {:type :id}}
|
||||
:resolve :search-account}
|
||||
|
||||
|
||||
|
||||
|
||||
:resolve :search-account}
|
||||
|
||||
:yodlee_merchants {:type '(list :yodlee_merchant)
|
||||
:args {}
|
||||
@@ -376,16 +358,13 @@
|
||||
:description {:type 'String}}
|
||||
:resolve :get-transaction-rule-page}
|
||||
|
||||
|
||||
|
||||
|
||||
:vendor {:type :vendor_page
|
||||
:args {:name_like {:type 'String}
|
||||
:start {:type 'Int}
|
||||
:per_page {:type 'Int}
|
||||
:sort {:type '(list :sort_item)}}
|
||||
:resolve :get-vendor}
|
||||
|
||||
|
||||
:vendor_by_id {:type :vendor
|
||||
:args {:id {:type :id}}
|
||||
:resolve :vendor-by-id}
|
||||
@@ -395,8 +374,7 @@
|
||||
:resolve :account-for-vendor}}
|
||||
|
||||
:input-objects
|
||||
{
|
||||
:sort_item
|
||||
{:sort_item
|
||||
{:fields {:sort_key {:type 'String}
|
||||
:sort_name {:type 'String}
|
||||
:asc {:type 'Boolean}}}
|
||||
@@ -495,8 +473,7 @@
|
||||
:name {:type 'String}
|
||||
:client_overrides {:type '(list :edit_account_client_override)}}}}
|
||||
|
||||
:enums {
|
||||
:processor {:values [{:enum-value :na}
|
||||
:enums {:processor {:values [{:enum-value :na}
|
||||
{:enum-value :doordash}
|
||||
{:enum-value :koala}
|
||||
{:enum-value :ezcater}
|
||||
@@ -533,9 +510,7 @@
|
||||
{:enum-value :equity}
|
||||
{:enum-value :revenue}]}}
|
||||
:mutations
|
||||
{
|
||||
|
||||
:delete_transaction_rule
|
||||
{:delete_transaction_rule
|
||||
{:type :id
|
||||
:args {:transaction_rule_id {:type :id}}
|
||||
:resolve :mutation/delete-transaction-rule}
|
||||
@@ -553,9 +528,7 @@
|
||||
:upsert_transaction_rule
|
||||
{:type :transaction_rule
|
||||
:args {:transaction_rule {:type :edit_transaction_rule}}
|
||||
:resolve :mutation/upsert-transaction-rule}
|
||||
}})
|
||||
|
||||
:resolve :mutation/upsert-transaction-rule}}})
|
||||
|
||||
(defn snake->kebab [s]
|
||||
(str/replace s #"_" "-"))
|
||||
@@ -571,65 +544,64 @@
|
||||
|
||||
(defn ->graphql [m]
|
||||
(walk/postwalk
|
||||
(fn [node]
|
||||
(cond
|
||||
(fn [node]
|
||||
(cond
|
||||
|
||||
(keyword? node)
|
||||
(snake node)
|
||||
(keyword? node)
|
||||
(snake node)
|
||||
|
||||
:else
|
||||
node))
|
||||
m))
|
||||
:else
|
||||
node))
|
||||
m))
|
||||
|
||||
|
||||
(defn get-expense-account-stats [_ {:keys [client_id] } _]
|
||||
(defn get-expense-account-stats [_ {:keys [client_id]} _]
|
||||
(let [query (cond-> {:query {:find ['?account '?account-name '(sum ?amount)]
|
||||
:in ['$]
|
||||
:where []}
|
||||
:args [(dc/db conn) client_id]}
|
||||
client_id (merge-query {:query {:in ['?c]}
|
||||
|
||||
:args [client_id]})
|
||||
(not client_id) (merge-query {:query {:where ['[?c :client/name]]}})
|
||||
:in ['$]
|
||||
:where []}
|
||||
:args [(dc/db conn) client_id]}
|
||||
client_id (merge-query {:query {:in ['?c]}
|
||||
|
||||
true (merge-query {:query {:where ['[?i :invoice/client ?c]
|
||||
'[?i :invoice/expense-accounts ?expense-account]
|
||||
'[?expense-account :invoice-expense-account/account ?account]
|
||||
'[?account :account/name ?account-name]
|
||||
'[?expense-account :invoice-expense-account/amount ?amount]]}}))
|
||||
:args [client_id]})
|
||||
(not client_id) (merge-query {:query {:where ['[?c :client/name]]}})
|
||||
|
||||
true (merge-query {:query {:where ['[?i :invoice/client ?c]
|
||||
'[?i :invoice/expense-accounts ?expense-account]
|
||||
'[?expense-account :invoice-expense-account/account ?account]
|
||||
'[?account :account/name ?account-name]
|
||||
'[?expense-account :invoice-expense-account/amount ?amount]]}}))
|
||||
result (query2 query)]
|
||||
(for [[account-id account-name total] result]
|
||||
{:account {:id account-id :name account-name} :total total})))
|
||||
|
||||
(defn get-invoice-stats [_ {:keys [client_id] } _]
|
||||
(defn get-invoice-stats [_ {:keys [client_id]} _]
|
||||
(let [query (cond-> {:query {:find ['?name '(sum ?outstanding-balance) '(sum ?total)]
|
||||
:in ['$]
|
||||
:where []}
|
||||
:args [(dc/db conn) client_id]}
|
||||
client_id (merge-query {:query {:in ['?c]}
|
||||
:args [client_id]})
|
||||
(not client_id) (merge-query {:query {:where ['[?c :client/name]]}})
|
||||
:in ['$]
|
||||
:where []}
|
||||
:args [(dc/db conn) client_id]}
|
||||
client_id (merge-query {:query {:in ['?c]}
|
||||
:args [client_id]})
|
||||
(not client_id) (merge-query {:query {:where ['[?c :client/name]]}})
|
||||
|
||||
true (merge-query {:query {:where ['[?i :invoice/client ?c]
|
||||
'[?i :invoice/outstanding-balance ?outstanding-balance]
|
||||
'[?i :invoice/total ?total]
|
||||
'[?i :invoice/due ?date]
|
||||
'[(.toInstant ^java.util.Date ?date) ?d2]
|
||||
'[(.between java.time.temporal.ChronoUnit/DAYS (java.time.Instant/now) ?d2 ) ?d3]
|
||||
'(or-join [?d3 ?name]
|
||||
(and [(<= ?d3 0)]
|
||||
[(ground :due) ?name])
|
||||
(and [(<= ?d3 30)]
|
||||
[(ground :due-30) ?name])
|
||||
(and [(<= ?d3 60)]
|
||||
[(ground :due-30) ?name])
|
||||
(and [(> ?d3 60)]
|
||||
[(ground :due-later) ?name]))]}}))
|
||||
true (merge-query {:query {:where ['[?i :invoice/client ?c]
|
||||
'[?i :invoice/outstanding-balance ?outstanding-balance]
|
||||
'[?i :invoice/total ?total]
|
||||
'[?i :invoice/due ?date]
|
||||
'[(.toInstant ^java.util.Date ?date) ?d2]
|
||||
'[(.between java.time.temporal.ChronoUnit/DAYS (java.time.Instant/now) ?d2) ?d3]
|
||||
'(or-join [?d3 ?name]
|
||||
(and [(<= ?d3 0)]
|
||||
[(ground :due) ?name])
|
||||
(and [(<= ?d3 30)]
|
||||
[(ground :due-30) ?name])
|
||||
(and [(<= ?d3 60)]
|
||||
[(ground :due-30) ?name])
|
||||
(and [(> ?d3 60)]
|
||||
[(ground :due-later) ?name]))]}}))
|
||||
result (->> (query2 query)
|
||||
(group-by first))]
|
||||
|
||||
|
||||
(for [[id name] [[:due "Due"] [:due-30 "0-30 days"] [:due-60 "31-60 days"] [:due-later ">60 days"]]
|
||||
:let [[[_ outstanding-balance total] ] (id result nil)
|
||||
:let [[[_ outstanding-balance total]] (id result nil)
|
||||
outstanding-balance (or outstanding-balance 0)
|
||||
total (or total 0)]]
|
||||
{:name name :unpaid outstanding-balance :paid (if (= :due id)
|
||||
@@ -637,7 +609,7 @@
|
||||
(- total outstanding-balance))})))
|
||||
|
||||
(defn has-fulfilled? [id date recent-fulfillments]
|
||||
|
||||
|
||||
(seq (transduce
|
||||
(filter (fn [[potential-id potential-date]]
|
||||
(let [date (coerce/to-date-time date)
|
||||
@@ -652,7 +624,7 @@
|
||||
|
||||
(defn get-cash-flow [_ {:keys [client_id]} _]
|
||||
(when client_id
|
||||
(let [{:client/keys [week-a-credits week-a-debits week-b-credits week-b-debits forecasted-transactions ]} (dc/pull (dc/db conn) '[*] client_id)
|
||||
(let [{:client/keys [week-a-credits week-a-debits week-b-credits week-b-debits forecasted-transactions]} (dc/pull (dc/db conn) '[*] client_id)
|
||||
total-cash (reduce
|
||||
(fn [total [credit debit]]
|
||||
(- (+ total credit)
|
||||
@@ -685,9 +657,9 @@
|
||||
:where ['[?p :payment/client ?client]
|
||||
'[?p :payment/status :payment-status/pending]
|
||||
'[?p :payment/amount ?amount]
|
||||
'(or
|
||||
[?p :payment/type :payment-type/debit]
|
||||
[?p :payment/type :payment-type/check])]}
|
||||
'(or
|
||||
[?p :payment/type :payment-type/debit]
|
||||
[?p :payment/type :payment-type/check])]}
|
||||
(dc/db conn) client_id (coerce/to-date (t/plus (time/local-now) (t/days 180))))))
|
||||
recent-fulfillments (dc/q {:find '[?f ?d]
|
||||
:in '[$ ?client ?min-date]
|
||||
@@ -710,7 +682,7 @@
|
||||
:date (coerce/to-date-time next)})
|
||||
is-week-a? (fn [d]
|
||||
(= 0 (mod (t/in-weeks (t/interval first-week-a d)) 2)))]
|
||||
|
||||
|
||||
{:beginning_balance total-cash
|
||||
:outstanding_payments outstanding-checks
|
||||
:invoices_due_soon (mapv (fn [[due outstanding invoice-number vendor-id vendor-name]]
|
||||
@@ -735,31 +707,29 @@
|
||||
:date (coerce/to-date-time date)})
|
||||
(take (* 7 4) (time/day-of-week-seq 1)))
|
||||
(filter #(< (:amount %) 0) forecasted-transactions))})))
|
||||
|
||||
|
||||
(def schema
|
||||
(-> integreat-schema
|
||||
(attach-tracing-resolvers
|
||||
{
|
||||
:get-all-accounts gq-accounts/get-all-graphql
|
||||
:get-transaction-rule-page gq-transaction-rules/get-transaction-rule-page
|
||||
:get-transaction-rule-matches gq-transaction-rules/get-transaction-rule-matches
|
||||
:get-expense-account-stats get-expense-account-stats
|
||||
:get-invoice-stats get-invoice-stats
|
||||
:get-cash-flow get-cash-flow
|
||||
:get-yodlee-merchants ym/get-yodlee-merchants
|
||||
:get-intuit-bank-accounts gq-intuit-bank-accounts/get-intuit-bank-accounts
|
||||
:vendor-by-id gq-vendors/get-by-id
|
||||
:account-for-vendor gq-accounts/default-for-vendor
|
||||
:mutation/delete-transaction-rule gq-transaction-rules/delete-transaction-rule
|
||||
:mutation/upsert-transaction-rule gq-transaction-rules/upsert-transaction-rule
|
||||
:test-transaction-rule gq-transaction-rules/test-transaction-rule
|
||||
:run-transaction-rule gq-transaction-rules/run-transaction-rule
|
||||
:mutation/upsert-vendor gq-vendors/upsert-vendor
|
||||
:mutation/merge-vendors gq-vendors/merge-vendors
|
||||
:get-vendor gq-vendors/get-graphql
|
||||
:search-vendor gq-vendors/search
|
||||
:search-account gq-accounts/search})
|
||||
{:get-all-accounts gq-accounts/get-all-graphql
|
||||
:get-transaction-rule-page gq-transaction-rules/get-transaction-rule-page
|
||||
:get-transaction-rule-matches gq-transaction-rules/get-transaction-rule-matches
|
||||
:get-expense-account-stats get-expense-account-stats
|
||||
:get-invoice-stats get-invoice-stats
|
||||
:get-cash-flow get-cash-flow
|
||||
:get-yodlee-merchants ym/get-yodlee-merchants
|
||||
:get-intuit-bank-accounts gq-intuit-bank-accounts/get-intuit-bank-accounts
|
||||
:vendor-by-id gq-vendors/get-by-id
|
||||
:account-for-vendor gq-accounts/default-for-vendor
|
||||
:mutation/delete-transaction-rule gq-transaction-rules/delete-transaction-rule
|
||||
:mutation/upsert-transaction-rule gq-transaction-rules/upsert-transaction-rule
|
||||
:test-transaction-rule gq-transaction-rules/test-transaction-rule
|
||||
:run-transaction-rule gq-transaction-rules/run-transaction-rule
|
||||
:mutation/upsert-vendor gq-vendors/upsert-vendor
|
||||
:mutation/merge-vendors gq-vendors/merge-vendors
|
||||
:get-vendor gq-vendors/get-graphql
|
||||
:search-vendor gq-vendors/search
|
||||
:search-account gq-accounts/search})
|
||||
gq-checks/attach
|
||||
gq-ledger/attach
|
||||
gq-plaid/attach
|
||||
@@ -772,30 +742,28 @@
|
||||
gq-sales-orders/attach
|
||||
schema/compile))
|
||||
|
||||
|
||||
|
||||
(defn simplify
|
||||
"Converts all ordered maps nested within the map into standard hash maps, and
|
||||
sequences into vectors, which makes for easier constants in the tests, and eliminates ordering problems."
|
||||
[m]
|
||||
(walk/postwalk
|
||||
(fn [node]
|
||||
(cond
|
||||
(instance? IPersistentMap node)
|
||||
(into {} node)
|
||||
(fn [node]
|
||||
(cond
|
||||
(instance? IPersistentMap node)
|
||||
(into {} node)
|
||||
|
||||
(seq? node)
|
||||
(vec node)
|
||||
(seq? node)
|
||||
(vec node)
|
||||
|
||||
(keyword? node)
|
||||
(kebab node)
|
||||
(keyword? node)
|
||||
(kebab node)
|
||||
|
||||
:else
|
||||
node))
|
||||
m))
|
||||
:else
|
||||
node))
|
||||
m))
|
||||
|
||||
(defn query-name [q]
|
||||
(try
|
||||
(try
|
||||
(str/join "__" (map name (:operations (p/operations (p/parse-query schema q)))))
|
||||
(catch Exception _
|
||||
"unknown query")))
|
||||
@@ -805,32 +773,32 @@
|
||||
(query id q nil))
|
||||
([id q v]
|
||||
(statsd/increment "query.graphql.count" {:tags #{(str "query:" (query-name q))}})
|
||||
(statsd/time! [(str "query.graphql.time" ) {:tags #{(str "query:" (query-name q))}}]
|
||||
(mu/with-context {:query-name (query-name q) :user id :query q}
|
||||
(mu/trace ::executing-query
|
||||
[]
|
||||
(try
|
||||
(let [[result time] (time-it (simplify (execute schema q (dissoc v
|
||||
:clients) {:id id
|
||||
:clients (:clients v)
|
||||
:log-context (or (mu/local-context) {})})))]
|
||||
|
||||
(when (seq (:errors result))
|
||||
(throw (ex-info "GraphQL error" {:result result})))
|
||||
result)
|
||||
(statsd/time! [(str "query.graphql.time") {:tags #{(str "query:" (query-name q))}}]
|
||||
(mu/with-context {:query-name (query-name q) :user id :query q}
|
||||
(mu/trace ::executing-query
|
||||
[]
|
||||
(try
|
||||
(let [[result time] (time-it (simplify (execute schema q (dissoc v
|
||||
:clients) {:id id
|
||||
:clients (:clients v)
|
||||
:log-context (or (mu/local-context) {})})))]
|
||||
|
||||
(catch Exception e
|
||||
(if-let [v (or (:validation-error (ex-data e))
|
||||
(:validation-error (ex-data (.getCause e))))]
|
||||
|
||||
(do
|
||||
(alog/warn ::query-validation
|
||||
:exception e)
|
||||
(throw e)
|
||||
#_{:errors [{:message v}]})
|
||||
(do
|
||||
(alog/error ::query-error
|
||||
:exception e)
|
||||
(when (seq (:errors result))
|
||||
(throw (ex-info "GraphQL error" {:result result})))
|
||||
result)
|
||||
|
||||
(throw e))))))))))
|
||||
(catch Exception e
|
||||
(if-let [v (or (:validation-error (ex-data e))
|
||||
(:validation-error (ex-data (.getCause e))))]
|
||||
|
||||
(do
|
||||
(alog/warn ::query-validation
|
||||
:exception e)
|
||||
(throw e)
|
||||
#_{:errors [{:message v}]})
|
||||
(do
|
||||
(alog/error ::query-error
|
||||
:exception e)
|
||||
|
||||
(throw e))))))))))
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
[iol-ion.tx :refer [random-tempid]]
|
||||
[com.brunobonacci.mulog :as mu]))
|
||||
|
||||
|
||||
(defn get-all-graphql [context args _]
|
||||
(assert-admin (:id context))
|
||||
(let [args (assoc args :id (:id context))
|
||||
|
||||
@@ -97,7 +97,6 @@
|
||||
[:line {:line-width 0.15 :color [50 50 50]}]]
|
||||
[:cell {:colspan 3}]]
|
||||
|
||||
|
||||
[[:cell {:size 9 :leading 11.5} "\n\n\n\n\nMEMO"]
|
||||
[:cell {:colspan 5 :leading 11.5} (split-memo memo)
|
||||
[:line {:line-width 0.15 :color [50 50 50]}]]
|
||||
@@ -186,8 +185,6 @@
|
||||
:payment/pdf-data
|
||||
(edn/read-string)
|
||||
|
||||
|
||||
|
||||
make-check-pdf)]
|
||||
(s3/put-object :bucket-name (:data-bucket env)
|
||||
:key (:payment/s3-key check)
|
||||
@@ -277,7 +274,6 @@
|
||||
(conj payment)
|
||||
(into (invoice-payments invoices invoice-amounts)))))
|
||||
|
||||
|
||||
(defmethod invoices->entities :payment-type/debit [invoices vendor client bank-account type index invoice-amounts date]
|
||||
(when (<= (->> invoices
|
||||
(map (comp invoice-amounts :db/id))
|
||||
@@ -297,7 +293,6 @@
|
||||
(conj payment)
|
||||
(into (invoice-payments invoices invoice-amounts)))))
|
||||
|
||||
|
||||
(defmethod invoices->entities :payment-type/balance-credit [invoices invoice-amounts]
|
||||
(when (<= (->> invoices
|
||||
(map (comp invoice-amounts :db/id))
|
||||
@@ -488,7 +483,6 @@
|
||||
{:s3-url nil
|
||||
:invoices (d-invoices/get-multi (map :invoice_id (:invoice_payments args)))})))
|
||||
|
||||
|
||||
(defn void-payment [context {id :payment_id} _]
|
||||
(let [check (d-checks/get-by-id id)]
|
||||
(assert (or (= :payment-status/pending (:payment/status check))
|
||||
@@ -549,7 +543,6 @@
|
||||
:invoice-status/unpaid)}]]))))))))
|
||||
id))
|
||||
|
||||
|
||||
(defn void-payments [context args _]
|
||||
(assert-admin (:id context))
|
||||
(let [args (assoc args :clients (:clients context))
|
||||
@@ -607,7 +600,6 @@
|
||||
0.001))
|
||||
invoices)
|
||||
|
||||
|
||||
total-to-pay (reduce + 0 (map :invoice/outstanding-balance invoices-to-be-paid))
|
||||
_ (when (<= total-to-pay 0.001)
|
||||
(assert-failure "You must select invoices that need to be paid."))
|
||||
@@ -637,8 +629,6 @@
|
||||
[total-to-pay []])))
|
||||
(into {}))
|
||||
|
||||
|
||||
|
||||
vendor-id (:db/id (:invoice/vendor (first invoices)))
|
||||
payment {:db/id (str vendor-id)
|
||||
:payment/amount total-to-pay
|
||||
@@ -751,7 +741,6 @@
|
||||
{:enum-value :pending}
|
||||
{:enum-value :cleared}]}})
|
||||
|
||||
|
||||
(def resolvers
|
||||
{:get-potential-payments get-potential-payments
|
||||
:get-payment-page get-payment-page
|
||||
|
||||
@@ -19,9 +19,9 @@
|
||||
(defn get-admin-client [context {:keys [id]} _]
|
||||
(assert-admin (:id context))
|
||||
(->graphql
|
||||
(-> (d-clients/get-by-id id)
|
||||
(update :client/bank-accounts (fn [bas]
|
||||
(map #(set/rename-keys % {:bank-account/use-date-instead-of-post-date? :use-date-instead-of-post-date}) bas))))))
|
||||
(-> (d-clients/get-by-id id)
|
||||
(update :client/bank-accounts (fn [bas]
|
||||
(map #(set/rename-keys % {:bank-account/use-date-instead-of-post-date? :use-date-instead-of-post-date}) bas))))))
|
||||
|
||||
(defn get-client-page [context args _]
|
||||
(assert-admin (:id context))
|
||||
@@ -29,7 +29,7 @@
|
||||
[clients clients-count] (d-clients/get-graphql-page (assoc (<-graphql (:filters args))
|
||||
:clients (:clients context)))
|
||||
clients (->> clients
|
||||
|
||||
|
||||
(map (fn [c]
|
||||
(update c :client/bank-accounts (fn [bas]
|
||||
(map #(set/rename-keys % {:bank-account/use-date-instead-of-post-date? :use-date-instead-of-post-date}) bas)))))
|
||||
@@ -47,13 +47,6 @@
|
||||
bank-accounts))))))]
|
||||
(result->page clients clients-count :clients (:filters args))))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
(def objects
|
||||
{:location_match
|
||||
{:fields {:location {:type 'String}
|
||||
@@ -102,15 +95,13 @@
|
||||
:yodlee_provider_accounts {:type '(list :yodlee_provider_account)}
|
||||
:plaid_items {:type '(list :plaid_item)}}}
|
||||
|
||||
:client_page
|
||||
:client_page
|
||||
{:fields {:clients {:type '(list :client)}
|
||||
:count {:type 'Int}
|
||||
:total {:type 'Int}
|
||||
:start {:type 'Int}
|
||||
:end {:type 'Int}}}
|
||||
|
||||
|
||||
|
||||
:bank_account
|
||||
{:fields {:id {:type :id}
|
||||
:integration_status {:type :integration_status}
|
||||
@@ -139,9 +130,7 @@
|
||||
:forecasted_transaction {:fields {:identifier {:type 'String}
|
||||
:id {:type :id}
|
||||
:day_of_month {:type 'Int}
|
||||
:amount {:type :money}}}
|
||||
|
||||
})
|
||||
:amount {:type :money}}}})
|
||||
|
||||
(def queries
|
||||
{:client {:type '(list :client)
|
||||
@@ -158,12 +147,12 @@
|
||||
{})
|
||||
|
||||
(def input-objects
|
||||
{ :client_filters
|
||||
{:client_filters
|
||||
{:fields {:code {:type 'String}
|
||||
:name_like {:type 'String}
|
||||
:start {:type 'Int}
|
||||
:per_page {:type 'Int}
|
||||
:sort {:type '(list :sort_item)}}} })
|
||||
:sort {:type '(list :sort_item)}}}})
|
||||
|
||||
(def enums
|
||||
{:bank_account_type {:values [{:enum-value :check}
|
||||
@@ -173,11 +162,10 @@
|
||||
(def resolvers
|
||||
{:get-client get-client
|
||||
:get-admin-client get-admin-client
|
||||
:get-client-page get-client-page })
|
||||
|
||||
:get-client-page get-client-page})
|
||||
|
||||
(defn attach [schema]
|
||||
(->
|
||||
(->
|
||||
(merge-with merge schema
|
||||
{:objects objects
|
||||
:queries queries
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
(defn get-all-expected-deposits [context args _]
|
||||
(assert-admin (:id context))
|
||||
(map
|
||||
(comp ->graphql status->graphql)
|
||||
(comp ->graphql status->graphql)
|
||||
(first (d-expected-deposit/get-graphql (assoc (<-graphql args) :count Integer/MAX_VALUE)))))
|
||||
|
||||
(defn get-expected-deposit-page [context args _]
|
||||
|
||||
@@ -17,15 +17,14 @@
|
||||
{:name name
|
||||
:id id}))
|
||||
|
||||
|
||||
(def objects
|
||||
{:ezcater_caterer {:fields {:name {:type 'String}
|
||||
:id {:type :id}}}})
|
||||
|
||||
(def queries
|
||||
{:search_ezcater_caterer {:type '(list :search_result)
|
||||
:args {:query {:type 'String}}
|
||||
:resolve :search-ezcater-caterer}})
|
||||
:args {:query {:type 'String}}
|
||||
:resolve :search-ezcater-caterer}})
|
||||
|
||||
(def enums
|
||||
{})
|
||||
|
||||
@@ -40,7 +40,6 @@
|
||||
(merge-query {:query {:find ['?e]
|
||||
:where ['[?e :import-batch/date]]}}))]
|
||||
|
||||
|
||||
(cond->> (query2 query)
|
||||
true (apply-sort-3 args)
|
||||
true (apply-pagination args))))
|
||||
@@ -66,9 +65,8 @@
|
||||
(map #(update % :import-batch/date coerce/to-date-time)))
|
||||
matching-count :data args)))
|
||||
|
||||
|
||||
(defn attach [schema]
|
||||
(->
|
||||
(->
|
||||
(merge-with merge schema
|
||||
{:objects {:import_batch {:fields {:user_name {:type 'String}
|
||||
:id {:type :id}
|
||||
@@ -83,12 +81,10 @@
|
||||
:count {:type 'Int}
|
||||
:total {:type 'Int}
|
||||
:start {:type 'Int}
|
||||
:end {:type 'Int}}}
|
||||
|
||||
}
|
||||
:end {:type 'Int}}}}
|
||||
:queries {:import_batch_page {:type :import_batch_page
|
||||
:args {:filters {:type :import_batch_filters}}
|
||||
|
||||
|
||||
:resolve :get-import-batch-page}}
|
||||
:mutations {}
|
||||
:input-objects {:import_batch_filters {:fields {:start {:type 'Int}
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
(defn get-intuit-bank-accounts [context _ _]
|
||||
(assert-admin (:id context))
|
||||
(->graphql (map first (dc/q '[:find (pull ?e [*])
|
||||
:in $
|
||||
:where [?e :intuit-bank-account/external-id]]
|
||||
(dc/db conn)))))
|
||||
:in $
|
||||
:where [?e :intuit-bank-account/external-id]]
|
||||
(dc/db conn)))))
|
||||
|
||||
@@ -174,8 +174,6 @@
|
||||
(let [error (str "Expense account total (" expense-account-total ") does not equal invoice total (" total ")")]
|
||||
(throw (ex-info error {:validation-error error}))))))
|
||||
|
||||
|
||||
|
||||
(defn add-invoice [context {{:keys [expense_accounts client_id vendor_id] :as in} :invoice} _]
|
||||
(assert-no-conflicting in)
|
||||
(assert-can-see-client (:id context) client_id)
|
||||
@@ -193,8 +191,6 @@
|
||||
(when-not ((set (map :db/id (:client/bank-accounts (d-clients/get-by-id client-id)))) bank-account-id)
|
||||
(throw (ex-info (str "Bank account does not belong to client") {:validation-error "Bank account does not belong to client."}))))
|
||||
|
||||
|
||||
|
||||
(defn add-and-print-invoice [context {{:keys [total client_id vendor_id] :as in} :invoice bank-account-id :bank_account_id type :type} _]
|
||||
(mu/trace ::validating-invoice [:invoice in]
|
||||
(do
|
||||
@@ -261,7 +257,6 @@
|
||||
|
||||
(-> (d-invoices/get-by-id id) (->graphql (:id context)))))
|
||||
|
||||
|
||||
(defn get-ids-matching-filters [args]
|
||||
(let [ids (some-> args
|
||||
:filters
|
||||
@@ -448,8 +443,6 @@
|
||||
[])]
|
||||
accounts)))
|
||||
|
||||
|
||||
|
||||
(defn bulk-change-invoices [context args _]
|
||||
(assert-admin (:id context))
|
||||
(when-not (:client_id args)
|
||||
|
||||
@@ -38,9 +38,9 @@
|
||||
_ (when (:client_id (:filters args))
|
||||
(assert-can-see-client (:id context) (:client_id (:filters args))))
|
||||
clients (or (and (:client_id (:filters args))
|
||||
[{:db/id (:client_id (:filters args))}])
|
||||
(:clients context))
|
||||
|
||||
[{:db/id (:client_id (:filters args))}])
|
||||
(:clients context))
|
||||
|
||||
[journal-entries journal-entries-count] (l/get-graphql (assoc (<-graphql (:filters args))
|
||||
:clients clients))
|
||||
|
||||
@@ -55,12 +55,10 @@
|
||||
(let [args (assoc args :id (:id context))
|
||||
[journal-entries journal-entries-count] (l/get-graphql (assoc (<-graphql (:filters args))
|
||||
:per-page Integer/MAX_VALUE
|
||||
:clients (:clients context)))
|
||||
:clients (:clients context)))]
|
||||
|
||||
|
||||
]
|
||||
{:csv_content_b64 (Base64/encodeBase64String
|
||||
(.getBytes
|
||||
(.getBytes
|
||||
(with-open [w (java.io.StringWriter.)]
|
||||
(csv/write-csv w
|
||||
(into [["Client" "Vendor" "Date" "Journal Entry" "Journal Entry Line" "Account Code" "Account Name" "Account Type" "Debit" "Credit" "Net"]]
|
||||
@@ -83,22 +81,19 @@
|
||||
(-> li :journal-entry-line/account :bank-account/numeric-code))
|
||||
(or (-> li :journal-entry-line/account :account/name)
|
||||
(-> li :journal-entry-line/account :bank-account/name))
|
||||
(some-> account-type name )
|
||||
(some-> account-type name)
|
||||
(-> li :journal-entry-line/debit)
|
||||
(-> li :journal-entry-line/credit)
|
||||
(if (#{:account-type/asset
|
||||
:account-type/dividend
|
||||
:account-type/expense} account-type)
|
||||
(- (or (-> li :journal-entry-line/debit) 0.0) (or (-> li :journal-entry-line/credit) 0.0))
|
||||
(- (or (-> li :journal-entry-line/credit) 0.0) (or (-> li :journal-entry-line/debit) 0.0)))
|
||||
|
||||
]))
|
||||
(:journal-entry/line-items j))
|
||||
))))
|
||||
(- (or (-> li :journal-entry-line/credit) 0.0) (or (-> li :journal-entry-line/debit) 0.0)))]))
|
||||
|
||||
(:journal-entry/line-items j))))))
|
||||
:quote? (constantly true))
|
||||
(.toString w))))}))
|
||||
|
||||
|
||||
(defn roll-up-until
|
||||
([lookup-account all-ledger-entries end-date]
|
||||
(roll-up-until lookup-account all-ledger-entries end-date nil))
|
||||
@@ -107,57 +102,56 @@
|
||||
(filter (fn [[d]]
|
||||
(if start-date
|
||||
(and
|
||||
(>= (compare d start-date) 0)
|
||||
(<= (compare d end-date) 0))
|
||||
(>= (compare d start-date) 0)
|
||||
(<= (compare d end-date) 0))
|
||||
(<= (compare d end-date) 0))))
|
||||
(reduce
|
||||
(fn [acc [_ _ account location debit credit]]
|
||||
(-> acc
|
||||
(update-in [[location account] :debit] (fnil + 0.0) debit)
|
||||
(update-in [[location account] :credit] (fnil + 0.0) credit)
|
||||
(update-in [[location account] :count] (fnil + 0) 1))
|
||||
)
|
||||
{})
|
||||
(fn [acc [_ _ account location debit credit]]
|
||||
(-> acc
|
||||
(update-in [[location account] :debit] (fnil + 0.0) debit)
|
||||
(update-in [[location account] :credit] (fnil + 0.0) credit)
|
||||
(update-in [[location account] :count] (fnil + 0) 1)))
|
||||
{})
|
||||
(reduce-kv
|
||||
(fn [acc [location account-id] {:keys [debit credit count]}]
|
||||
(let [account (lookup-account account-id)
|
||||
account-type (:account_type account)]
|
||||
|
||||
(conj acc (merge {:id (str account-id "-" location)
|
||||
:location (or location "")
|
||||
:count count
|
||||
:debits debit
|
||||
:credits credit
|
||||
:amount (if account-type (if (#{:account-type/asset
|
||||
:account-type/dividend
|
||||
:account-type/expense} account-type)
|
||||
(- debit credit)
|
||||
(- credit debit))
|
||||
0.0)}
|
||||
account))))
|
||||
[]))))
|
||||
(fn [acc [location account-id] {:keys [debit credit count]}]
|
||||
(let [account (lookup-account account-id)
|
||||
account-type (:account_type account)]
|
||||
|
||||
(conj acc (merge {:id (str account-id "-" location)
|
||||
:location (or location "")
|
||||
:count count
|
||||
:debits debit
|
||||
:credits credit
|
||||
:amount (if account-type (if (#{:account-type/asset
|
||||
:account-type/dividend
|
||||
:account-type/expense} account-type)
|
||||
(- debit credit)
|
||||
(- credit debit))
|
||||
0.0)}
|
||||
account))))
|
||||
[]))))
|
||||
|
||||
(defn full-ledger-for-client [client-id]
|
||||
(->> (dc/q
|
||||
{:find ['?d '?jel '?account '?location '?debit '?credit]
|
||||
:in ['$ '?client-id]
|
||||
:where '[[?e :journal-entry/client ?client-id]
|
||||
[?e :journal-entry/date ?d]
|
||||
[?e :journal-entry/line-items ?jel]
|
||||
(or-join [?e]
|
||||
(and [?e :journal-entry/original-entity ?i]
|
||||
(or-join [?e ?i]
|
||||
(and
|
||||
[?i :transaction/bank-account ?b]
|
||||
(or [?b :bank-account/include-in-reports true]
|
||||
(not [?b :bank-account/include-in-reports])))
|
||||
(not [?i :transaction/bank-account])))
|
||||
(not [?e :journal-entry/original-entity ]))
|
||||
[(get-else $ ?jel :journal-entry-line/account :account/unknown) ?account]
|
||||
[(get-else $ ?jel :journal-entry-line/debit 0.0) ?debit ]
|
||||
[(get-else $ ?jel :journal-entry-line/credit 0.0) ?credit]
|
||||
[(get-else $ ?jel :journal-entry-line/location "") ?location]]}
|
||||
(dc/db conn) client-id)
|
||||
(->> (dc/q
|
||||
{:find ['?d '?jel '?account '?location '?debit '?credit]
|
||||
:in ['$ '?client-id]
|
||||
:where '[[?e :journal-entry/client ?client-id]
|
||||
[?e :journal-entry/date ?d]
|
||||
[?e :journal-entry/line-items ?jel]
|
||||
(or-join [?e]
|
||||
(and [?e :journal-entry/original-entity ?i]
|
||||
(or-join [?e ?i]
|
||||
(and
|
||||
[?i :transaction/bank-account ?b]
|
||||
(or [?b :bank-account/include-in-reports true]
|
||||
(not [?b :bank-account/include-in-reports])))
|
||||
(not [?i :transaction/bank-account])))
|
||||
(not [?e :journal-entry/original-entity]))
|
||||
[(get-else $ ?jel :journal-entry-line/account :account/unknown) ?account]
|
||||
[(get-else $ ?jel :journal-entry-line/debit 0.0) ?debit]
|
||||
[(get-else $ ?jel :journal-entry-line/credit 0.0) ?credit]
|
||||
[(get-else $ ?jel :journal-entry-line/location "") ?location]]}
|
||||
(dc/db conn) client-id)
|
||||
(sort-by first)))
|
||||
|
||||
(defn get-balance-sheet [context args _]
|
||||
@@ -180,30 +174,29 @@
|
||||
[client-id (build-account-lookup client-id)]))
|
||||
(into {}))]
|
||||
(alog/info ::balance-sheet :params args)
|
||||
|
||||
|
||||
(cond-> {:balance-sheet-accounts (mapcat
|
||||
#(roll-up-until (lookup-account %) (all-ledger-entries %) end-date )
|
||||
client-ids)
|
||||
}
|
||||
#(roll-up-until (lookup-account %) (all-ledger-entries %) end-date)
|
||||
client-ids)}
|
||||
(:include_comparison args) (assoc :comparable-balance-sheet-accounts (mapcat
|
||||
#(roll-up-until (lookup-account %) (all-ledger-entries %) comparable-date )
|
||||
client-ids))
|
||||
#(roll-up-until (lookup-account %) (all-ledger-entries %) comparable-date)
|
||||
client-ids))
|
||||
true ->graphql)))
|
||||
|
||||
(defn get-profit-and-loss-raw [client-ids periods]
|
||||
(let [ all-ledger-entries (->> client-ids
|
||||
(map (fn [client-id]
|
||||
[client-id (full-ledger-for-client client-id)]))
|
||||
(into {}))
|
||||
(let [all-ledger-entries (->> client-ids
|
||||
(map (fn [client-id]
|
||||
[client-id (full-ledger-for-client client-id)]))
|
||||
(into {}))
|
||||
lookup-account (->> client-ids
|
||||
(map (fn [client-id]
|
||||
[client-id (build-account-lookup client-id)]))
|
||||
(into {}))]
|
||||
(->graphql {:periods
|
||||
(->graphql {:periods
|
||||
(->> periods
|
||||
(mapv (fn [{:keys [start end]}]
|
||||
{:accounts (mapcat
|
||||
#(roll-up-until (lookup-account %) (all-ledger-entries %) (coerce/to-date end) (coerce/to-date start) )
|
||||
#(roll-up-until (lookup-account %) (all-ledger-entries %) (coerce/to-date end) (coerce/to-date start))
|
||||
client-ids)})))})))
|
||||
|
||||
(defn get-profit-and-loss [context args _]
|
||||
@@ -216,12 +209,9 @@
|
||||
(assert-can-see-client (:id context) client-id))
|
||||
_ (when (and (:include_deltas args)
|
||||
(:column_per_location args))
|
||||
(throw (ex-info "Please select one of 'Include deltas' or 'Column per location'" {:validation-error "Please select one of 'Include deltas' or 'Column per location'"}))) ]
|
||||
(throw (ex-info "Please select one of 'Include deltas' or 'Column per location'" {:validation-error "Please select one of 'Include deltas' or 'Column per location'"})))]
|
||||
(get-profit-and-loss-raw client-ids (:periods args))))
|
||||
|
||||
|
||||
|
||||
|
||||
;; profit and loss based off of index
|
||||
#_(defn get-profit-and-loss [context args _]
|
||||
(let [client-id (:client_id args)
|
||||
@@ -239,17 +229,17 @@
|
||||
:in $ [?c ...]
|
||||
:where
|
||||
(or-join [?c ?a ?l]
|
||||
(and
|
||||
[?a :account/numeric-code]
|
||||
(not [?a :account/location])
|
||||
[?c :client/locations ?l])
|
||||
(and
|
||||
[?a :account/numeric-code]
|
||||
[?a :account/location ?l]
|
||||
[?c :client/locations ?l])
|
||||
[?a :account/numeric-code]
|
||||
(not [?a :account/location])
|
||||
[?c :client/locations ?l])
|
||||
(and
|
||||
[?c :client/bank-accounts ?a]
|
||||
[(ground "A") ?l]))]
|
||||
[?a :account/numeric-code]
|
||||
[?a :account/location ?l]
|
||||
[?c :client/locations ?l])
|
||||
(and
|
||||
[?c :client/bank-accounts ?a]
|
||||
[(ground "A") ?l]))]
|
||||
(dc/db conn)
|
||||
client-ids)
|
||||
lookup-account (->> client-ids
|
||||
@@ -257,49 +247,48 @@
|
||||
[client-id (build-account-lookup client-id)]))
|
||||
(into {}))]
|
||||
(->graphql
|
||||
{:periods
|
||||
(->> (:periods args)
|
||||
(mapv (fn [{:keys [start end]}]
|
||||
(let [start (coerce/to-date start)
|
||||
end (coerce/to-date end)]
|
||||
{:accounts (mapcat
|
||||
(fn [[c a l]]
|
||||
(let [start-point (->> (dc/index-pull db
|
||||
{:index :avet
|
||||
:selector [:db/id :journal-entry-line/running-balance :journal-entry-line/client+account+location+date]
|
||||
:start [:journal-entry-line/client+account+location+date [c a l start]]
|
||||
:reverse true
|
||||
:limit 1})
|
||||
(take-while (fn [result]
|
||||
(= [c a l]
|
||||
(take 3 (:journal-entry-line/client+account+location+date result)))))
|
||||
(drop-while (fn [{[_ _ _ date] :journal-entry-line/client+account+location+date}]
|
||||
(>= (compare date start) 0)))
|
||||
first)
|
||||
end-point (->> (dc/index-pull db
|
||||
{:index :avet
|
||||
:selector [:db/id :journal-entry-line/running-balance :journal-entry-line/client+account+location+date]
|
||||
:start [:journal-entry-line/client+account+location+date [c a l end]]
|
||||
:reverse true
|
||||
:limit 1})
|
||||
(take-while (fn [result]
|
||||
(= [c a l]
|
||||
(take 3 (:journal-entry-line/client+account+location+date result)))))
|
||||
(take 1)
|
||||
(drop-while (fn [{[_ _ _ date] :journal-entry-line/client+account+location+date}]
|
||||
(>= (compare date end) 0)))
|
||||
first)]
|
||||
(when end-point
|
||||
[(merge {:id (str a "-" l)
|
||||
:location (or l "")
|
||||
:count 0
|
||||
:debits 0
|
||||
:credits 0
|
||||
:amount (- (or (:journal-entry-line/running-balance end-point) 0.0)
|
||||
(or (:journal-entry-line/running-balance start-point) 0.0))
|
||||
}
|
||||
((lookup-account c) a))])))
|
||||
all-used-account-locations)}))))})))
|
||||
{:periods
|
||||
(->> (:periods args)
|
||||
(mapv (fn [{:keys [start end]}]
|
||||
(let [start (coerce/to-date start)
|
||||
end (coerce/to-date end)]
|
||||
{:accounts (mapcat
|
||||
(fn [[c a l]]
|
||||
(let [start-point (->> (dc/index-pull db
|
||||
{:index :avet
|
||||
:selector [:db/id :journal-entry-line/running-balance :journal-entry-line/client+account+location+date]
|
||||
:start [:journal-entry-line/client+account+location+date [c a l start]]
|
||||
:reverse true
|
||||
:limit 1})
|
||||
(take-while (fn [result]
|
||||
(= [c a l]
|
||||
(take 3 (:journal-entry-line/client+account+location+date result)))))
|
||||
(drop-while (fn [{[_ _ _ date] :journal-entry-line/client+account+location+date}]
|
||||
(>= (compare date start) 0)))
|
||||
first)
|
||||
end-point (->> (dc/index-pull db
|
||||
{:index :avet
|
||||
:selector [:db/id :journal-entry-line/running-balance :journal-entry-line/client+account+location+date]
|
||||
:start [:journal-entry-line/client+account+location+date [c a l end]]
|
||||
:reverse true
|
||||
:limit 1})
|
||||
(take-while (fn [result]
|
||||
(= [c a l]
|
||||
(take 3 (:journal-entry-line/client+account+location+date result)))))
|
||||
(take 1)
|
||||
(drop-while (fn [{[_ _ _ date] :journal-entry-line/client+account+location+date}]
|
||||
(>= (compare date end) 0)))
|
||||
first)]
|
||||
(when end-point
|
||||
[(merge {:id (str a "-" l)
|
||||
:location (or l "")
|
||||
:count 0
|
||||
:debits 0
|
||||
:credits 0
|
||||
:amount (- (or (:journal-entry-line/running-balance end-point) 0.0)
|
||||
(or (:journal-entry-line/running-balance start-point) 0.0))}
|
||||
((lookup-account c) a))])))
|
||||
all-used-account-locations)}))))})))
|
||||
|
||||
(defn profit-and-loss-pdf [context args value]
|
||||
(let [data (get-profit-and-loss context args value)
|
||||
@@ -320,10 +309,9 @@
|
||||
|
||||
(->graphql result)))
|
||||
|
||||
|
||||
(defn assoc-error [f]
|
||||
(fn [entry]
|
||||
(try
|
||||
(try
|
||||
(f entry)
|
||||
(catch Exception e
|
||||
(assoc entry :error (.getMessage e)
|
||||
@@ -333,13 +321,13 @@
|
||||
(defn all-ids-not-locked [all-ids]
|
||||
(->> all-ids
|
||||
(dc/q '[:find ?t
|
||||
:in $ [?t ...]
|
||||
:where
|
||||
[?t :journal-entry/client ?c]
|
||||
[(get-else $ ?c :client/locked-until #inst "2000-01-01") ?lu]
|
||||
[?t :journal-entry/date ?d]
|
||||
[(>= ?d ?lu)]]
|
||||
(dc/db conn))
|
||||
:in $ [?t ...]
|
||||
:where
|
||||
[?t :journal-entry/client ?c]
|
||||
[(get-else $ ?c :client/locked-until #inst "2000-01-01") ?lu]
|
||||
[?t :journal-entry/date ?d]
|
||||
[(>= ?d ?lu)]]
|
||||
(dc/db conn))
|
||||
(map first)))
|
||||
|
||||
(defn delete-external-ledger [context args _]
|
||||
@@ -353,8 +341,8 @@
|
||||
(#(l/raw-graphql-ids (dc/db conn) %))
|
||||
:ids)
|
||||
_ (alog/info ::trying-to-delete
|
||||
:count (count ids)
|
||||
:sample (take 3 ids))
|
||||
:count (count ids)
|
||||
:sample (take 3 ids))
|
||||
specific-ids (l/filter-ids (:ids args))
|
||||
all-ids (all-ids-not-locked (into (set ids) specific-ids))]
|
||||
(if (> (count all-ids) 1000)
|
||||
@@ -364,7 +352,7 @@
|
||||
(audit-transact-batch
|
||||
(map (fn [i]
|
||||
[:db/retractEntity i])
|
||||
all-ids)
|
||||
all-ids)
|
||||
(:id context))
|
||||
{:message (str "Succesfully deleted " (count all-ids) " ledger entries.")}))))
|
||||
|
||||
@@ -372,15 +360,15 @@
|
||||
(assert-admin (:id context))
|
||||
(let [used-vendor-names (set (map :vendor_name (:entries args)))
|
||||
all-vendors (mu/trace ::get-all-vendors
|
||||
[]
|
||||
(->> (dc/q '[:find ?e
|
||||
:in $ [?name ...]
|
||||
:where [?e :vendor/name ?name]]
|
||||
(dc/db conn)
|
||||
used-vendor-names)
|
||||
(map first)
|
||||
(pull-many (dc/db conn) [:db/id :vendor/name])
|
||||
(by :vendor/name)))
|
||||
[]
|
||||
(->> (dc/q '[:find ?e
|
||||
:in $ [?name ...]
|
||||
:where [?e :vendor/name ?name]]
|
||||
(dc/db conn)
|
||||
used-vendor-names)
|
||||
(map first)
|
||||
(pull-many (dc/db conn) [:db/id :vendor/name])
|
||||
(by :vendor/name)))
|
||||
client-locked-lookup (mu/trace ::get-all-clients []
|
||||
(->> (dc/q '[:find ?code ?locked-until
|
||||
:in $
|
||||
@@ -389,18 +377,18 @@
|
||||
(dc/db conn))
|
||||
(into {})))
|
||||
all-client-bank-accounts (mu/trace ::get-all-client-bank-accounts
|
||||
[]
|
||||
(->> (dc/q '[:find ?code ?ba-code
|
||||
:in $
|
||||
:where [?c :client/code ?code]
|
||||
[?c :client/bank-accounts ?ba]
|
||||
[?ba :bank-account/code ?ba-code]]
|
||||
(dc/db conn))
|
||||
(reduce
|
||||
(fn [acc [code ba-code]]
|
||||
(update acc code (fnil conj #{}) ba-code))
|
||||
{})))
|
||||
|
||||
[]
|
||||
(->> (dc/q '[:find ?code ?ba-code
|
||||
:in $
|
||||
:where [?c :client/code ?code]
|
||||
[?c :client/bank-accounts ?ba]
|
||||
[?ba :bank-account/code ?ba-code]]
|
||||
(dc/db conn))
|
||||
(reduce
|
||||
(fn [acc [code ba-code]]
|
||||
(update acc code (fnil conj #{}) ba-code))
|
||||
{})))
|
||||
|
||||
all-client-locations (mu/trace ::get-all-client-locations
|
||||
[]
|
||||
(->> (dc/q '[:find ?code ?location
|
||||
@@ -409,160 +397,158 @@
|
||||
[?c :client/locations ?location]]
|
||||
(dc/db conn))
|
||||
(reduce
|
||||
(fn [acc [code ba-code]]
|
||||
(update acc code (fnil conj #{"HQ" "A"}) ba-code))
|
||||
{})))
|
||||
|
||||
(fn [acc [code ba-code]]
|
||||
(update acc code (fnil conj #{"HQ" "A"}) ba-code))
|
||||
{})))
|
||||
|
||||
new-hidden-vendors (reduce
|
||||
(fn [new-vendors {:keys [vendor_name]}]
|
||||
(if (or (all-vendors vendor_name)
|
||||
(new-vendors vendor_name))
|
||||
new-vendors
|
||||
(assoc new-vendors vendor_name
|
||||
{:vendor/name vendor_name
|
||||
:vendor/hidden true
|
||||
:db/id vendor_name})))
|
||||
{}
|
||||
(:entries args))
|
||||
(fn [new-vendors {:keys [vendor_name]}]
|
||||
(if (or (all-vendors vendor_name)
|
||||
(new-vendors vendor_name))
|
||||
new-vendors
|
||||
(assoc new-vendors vendor_name
|
||||
{:vendor/name vendor_name
|
||||
:vendor/hidden true
|
||||
:db/id vendor_name})))
|
||||
{}
|
||||
(:entries args))
|
||||
_ (mu/trace ::upsert-new-vendors
|
||||
[]
|
||||
(audit-transact-batch (vec (vals new-hidden-vendors)) (:id context)))
|
||||
[]
|
||||
(audit-transact-batch (vec (vals new-hidden-vendors)) (:id context)))
|
||||
all-vendors (->> (dc/q '[:find ?e
|
||||
:in $ [?name ...]
|
||||
:where [?e :vendor/name ?name]]
|
||||
(dc/db conn)
|
||||
used-vendor-names)
|
||||
:in $ [?name ...]
|
||||
:where [?e :vendor/name ?name]]
|
||||
(dc/db conn)
|
||||
used-vendor-names)
|
||||
(map first)
|
||||
(pull-many (dc/db conn) [:db/id :vendor/name])
|
||||
(by :vendor/name))
|
||||
all-accounts (mu/trace ::get-all-accounts []
|
||||
(transduce (map (comp str :account/numeric-code)) conj #{} (a/get-accounts)))
|
||||
transaction (mu/trace ::build-transaction
|
||||
[:count (count (:entries args))]
|
||||
(doall (map
|
||||
(assoc-error (fn [entry]
|
||||
(let [vendor (all-vendors (:vendor_name entry))]
|
||||
(when-not (client-locked-lookup (:client_code entry))
|
||||
(throw (ex-info (str "Client '" (:client_code entry )"' not found.") {:status :error}) ))
|
||||
(when-not vendor
|
||||
(throw (ex-info (str "Vendor '" (:vendor_name entry) "' not found.") {:status :error})))
|
||||
(when-not (re-find #"\d{1,2}/\d{1,2}/\d{4}" (:date entry))
|
||||
(throw (ex-info (str "Date must be MM/dd/yyyy") {:status :error})))
|
||||
(when-let [locked-until (client-locked-lookup (:client_code entry))]
|
||||
(when (and (not (t/after? (coerce/to-date-time (coerce/to-date (parse/parse-value :clj-time "MM/dd/yyyy" (:date entry))))
|
||||
(coerce/to-date-time locked-until)))
|
||||
(not (t/equal? (coerce/to-date-time (coerce/to-date (parse/parse-value :clj-time "MM/dd/yyyy" (:date entry))))
|
||||
(coerce/to-date-time locked-until))))
|
||||
(throw (ex-info (str "Client's data is locked until " locked-until) {:status :error}))))
|
||||
|
||||
(when-not (dollars= (reduce (fnil + 0.0 0.0) 0.0 (map :debit (:line_items entry)))
|
||||
(reduce (fnil + 0.0 0.0) 0.0 (map :credit (:line_items entry))))
|
||||
(throw (ex-info (str "Debits '"
|
||||
(reduce (fnil + 0.0 0.0) 0 (map :debit (:line_items entry)))
|
||||
"' and credits '"
|
||||
(reduce (fnil + 0.0 0.0) 0 (map :credit (:line_items entry)))
|
||||
"' do not add up.")
|
||||
{:status :error})))
|
||||
(when (dollars= (reduce (fnil + 0.0 0.0) 0.0 (map :debit (:line_items entry)))
|
||||
0.0)
|
||||
(throw (ex-info (str "Cannot have ledger entries that total $0.00")
|
||||
{:status :ignored})))
|
||||
(assoc entry
|
||||
:status :success
|
||||
:tx
|
||||
[:upsert-ledger
|
||||
(remove-nils
|
||||
{:journal-entry/source (:source entry)
|
||||
:journal-entry/client [:client/code (:client_code entry)]
|
||||
:journal-entry/date (coerce/to-date (parse/parse-value :clj-time "MM/dd/yyyy" (:date entry)))
|
||||
:journal-entry/external-id (:external_id entry)
|
||||
:journal-entry/vendor (:db/id (all-vendors (:vendor_name entry)))
|
||||
:journal-entry/amount (:amount entry)
|
||||
:journal-entry/note (:note entry)
|
||||
:journal-entry/cleared-against (:cleared_against entry)
|
||||
[:count (count (:entries args))]
|
||||
(doall (map
|
||||
(assoc-error (fn [entry]
|
||||
(let [vendor (all-vendors (:vendor_name entry))]
|
||||
(when-not (client-locked-lookup (:client_code entry))
|
||||
(throw (ex-info (str "Client '" (:client_code entry) "' not found.") {:status :error})))
|
||||
(when-not vendor
|
||||
(throw (ex-info (str "Vendor '" (:vendor_name entry) "' not found.") {:status :error})))
|
||||
(when-not (re-find #"\d{1,2}/\d{1,2}/\d{4}" (:date entry))
|
||||
(throw (ex-info (str "Date must be MM/dd/yyyy") {:status :error})))
|
||||
(when-let [locked-until (client-locked-lookup (:client_code entry))]
|
||||
(when (and (not (t/after? (coerce/to-date-time (coerce/to-date (parse/parse-value :clj-time "MM/dd/yyyy" (:date entry))))
|
||||
(coerce/to-date-time locked-until)))
|
||||
(not (t/equal? (coerce/to-date-time (coerce/to-date (parse/parse-value :clj-time "MM/dd/yyyy" (:date entry))))
|
||||
(coerce/to-date-time locked-until))))
|
||||
(throw (ex-info (str "Client's data is locked until " locked-until) {:status :error}))))
|
||||
|
||||
:journal-entry/line-items
|
||||
(mapv (fn [ea]
|
||||
(let [debit (or (:debit ea) 0.0)
|
||||
credit (or (:credit ea) 0.0)]
|
||||
(when (and (not (get
|
||||
(get all-client-locations (:client_code entry))
|
||||
(:location ea)))
|
||||
(not= "A" (:location ea)))
|
||||
(throw (ex-info (str "Location '" (:location ea) "' not found.")
|
||||
{:status :error})))
|
||||
(when (and (<= debit 0.0)
|
||||
(<= credit 0.0))
|
||||
(throw (ex-info (str "Line item amount " (or debit credit) " must be greater than 0.")
|
||||
{:status :error})))
|
||||
(when (and (not (all-accounts (:account_identifier ea)))
|
||||
(not (get
|
||||
(get all-client-bank-accounts (:client_code entry))
|
||||
(:account_identifier ea))))
|
||||
(throw (ex-info (str "Account '" (:account_identifier ea) "' not found.")
|
||||
{:status :error})))
|
||||
(let [matching-account (when (re-matches #"^[0-9]+$" (:account_identifier ea))
|
||||
(a/get-account-by-numeric-code-and-sets (Integer/parseInt (:account_identifier ea)) ["default"]))]
|
||||
(when (and matching-account
|
||||
(:account/location matching-account)
|
||||
(not= (:account/location matching-account)
|
||||
(:location ea)))
|
||||
(throw (ex-info (str "Account '"
|
||||
(:account/numeric-code matching-account)
|
||||
"' requires location '"
|
||||
(:account/location matching-account)
|
||||
"' but got '"
|
||||
(:location ea)
|
||||
"'")
|
||||
{:status :error})))
|
||||
(when (and matching-account
|
||||
(not (:account/location matching-account))
|
||||
(= "A" (:location ea)))
|
||||
(throw (ex-info (str "Account '"
|
||||
(:account/numeric-code matching-account)
|
||||
"' cannot use location '"
|
||||
(:location ea)
|
||||
"'")
|
||||
{:status :error})))
|
||||
(remove-nils (cond-> {:db/id (random-tempid)
|
||||
:journal-entry-line/location (:location ea)
|
||||
:journal-entry-line/debit (when (> debit 0)
|
||||
debit)
|
||||
:journal-entry-line/credit (when (> credit 0)
|
||||
credit)}
|
||||
matching-account (assoc :journal-entry-line/account (:db/id matching-account))
|
||||
(not matching-account) (assoc :journal-entry-line/account [:bank-account/code (:account_identifier ea)]))))))
|
||||
(:line_items entry))
|
||||
|
||||
:journal-entry/cleared true})]))))
|
||||
(:entries args))))
|
||||
(when-not (dollars= (reduce (fnil + 0.0 0.0) 0.0 (map :debit (:line_items entry)))
|
||||
(reduce (fnil + 0.0 0.0) 0.0 (map :credit (:line_items entry))))
|
||||
(throw (ex-info (str "Debits '"
|
||||
(reduce (fnil + 0.0 0.0) 0 (map :debit (:line_items entry)))
|
||||
"' and credits '"
|
||||
(reduce (fnil + 0.0 0.0) 0 (map :credit (:line_items entry)))
|
||||
"' do not add up.")
|
||||
{:status :error})))
|
||||
(when (dollars= (reduce (fnil + 0.0 0.0) 0.0 (map :debit (:line_items entry)))
|
||||
0.0)
|
||||
(throw (ex-info (str "Cannot have ledger entries that total $0.00")
|
||||
{:status :ignored})))
|
||||
(assoc entry
|
||||
:status :success
|
||||
:tx
|
||||
[:upsert-ledger
|
||||
(remove-nils
|
||||
{:journal-entry/source (:source entry)
|
||||
:journal-entry/client [:client/code (:client_code entry)]
|
||||
:journal-entry/date (coerce/to-date (parse/parse-value :clj-time "MM/dd/yyyy" (:date entry)))
|
||||
:journal-entry/external-id (:external_id entry)
|
||||
:journal-entry/vendor (:db/id (all-vendors (:vendor_name entry)))
|
||||
:journal-entry/amount (:amount entry)
|
||||
:journal-entry/note (:note entry)
|
||||
:journal-entry/cleared-against (:cleared_against entry)
|
||||
|
||||
:journal-entry/line-items
|
||||
(mapv (fn [ea]
|
||||
(let [debit (or (:debit ea) 0.0)
|
||||
credit (or (:credit ea) 0.0)]
|
||||
(when (and (not (get
|
||||
(get all-client-locations (:client_code entry))
|
||||
(:location ea)))
|
||||
(not= "A" (:location ea)))
|
||||
(throw (ex-info (str "Location '" (:location ea) "' not found.")
|
||||
{:status :error})))
|
||||
(when (and (<= debit 0.0)
|
||||
(<= credit 0.0))
|
||||
(throw (ex-info (str "Line item amount " (or debit credit) " must be greater than 0.")
|
||||
{:status :error})))
|
||||
(when (and (not (all-accounts (:account_identifier ea)))
|
||||
(not (get
|
||||
(get all-client-bank-accounts (:client_code entry))
|
||||
(:account_identifier ea))))
|
||||
(throw (ex-info (str "Account '" (:account_identifier ea) "' not found.")
|
||||
{:status :error})))
|
||||
(let [matching-account (when (re-matches #"^[0-9]+$" (:account_identifier ea))
|
||||
(a/get-account-by-numeric-code-and-sets (Integer/parseInt (:account_identifier ea)) ["default"]))]
|
||||
(when (and matching-account
|
||||
(:account/location matching-account)
|
||||
(not= (:account/location matching-account)
|
||||
(:location ea)))
|
||||
(throw (ex-info (str "Account '"
|
||||
(:account/numeric-code matching-account)
|
||||
"' requires location '"
|
||||
(:account/location matching-account)
|
||||
"' but got '"
|
||||
(:location ea)
|
||||
"'")
|
||||
{:status :error})))
|
||||
(when (and matching-account
|
||||
(not (:account/location matching-account))
|
||||
(= "A" (:location ea)))
|
||||
(throw (ex-info (str "Account '"
|
||||
(:account/numeric-code matching-account)
|
||||
"' cannot use location '"
|
||||
(:location ea)
|
||||
"'")
|
||||
{:status :error})))
|
||||
(remove-nils (cond-> {:db/id (random-tempid)
|
||||
:journal-entry-line/location (:location ea)
|
||||
:journal-entry-line/debit (when (> debit 0)
|
||||
debit)
|
||||
:journal-entry-line/credit (when (> credit 0)
|
||||
credit)}
|
||||
matching-account (assoc :journal-entry-line/account (:db/id matching-account))
|
||||
(not matching-account) (assoc :journal-entry-line/account [:bank-account/code (:account_identifier ea)]))))))
|
||||
(:line_items entry))
|
||||
|
||||
:journal-entry/cleared true})]))))
|
||||
(:entries args))))
|
||||
errors (filter #(= (:status %) :error) transaction)
|
||||
ignored (filter #(= (:status %) :ignored) transaction)
|
||||
success (filter #(= (:status %) :success) transaction)
|
||||
retraction (mapv (fn [x] [:db/retractEntity [:journal-entry/external-id (:external_id x)]])
|
||||
success)
|
||||
ignore-retraction (->> ignored
|
||||
(map :external_id )
|
||||
(map :external_id)
|
||||
(dc/q '[:find ?je
|
||||
:in $ [?ei ...]
|
||||
:where [?je :journal-entry/external-id ?ei]]
|
||||
(dc/db conn)
|
||||
)
|
||||
:in $ [?ei ...]
|
||||
:where [?je :journal-entry/external-id ?ei]]
|
||||
(dc/db conn))
|
||||
(map first)
|
||||
(map (fn [je] [:db/retractEntity je])))]
|
||||
(alog/info ::manual-import
|
||||
:errors (count errors)
|
||||
:sample (take 3 errors))
|
||||
|
||||
|
||||
(mu/trace ::retraction-tx
|
||||
[:count (count retraction)]
|
||||
(audit-transact-batch retraction (:id context)))
|
||||
[:count (count retraction)]
|
||||
(audit-transact-batch retraction (:id context)))
|
||||
(mu/trace ::ignore-retraction-tx
|
||||
[:count (count ignore-retraction)]
|
||||
(when (seq ignore-retraction)
|
||||
(audit-transact-batch ignore-retraction (:id context))))
|
||||
(let [invalidated
|
||||
[:count (count ignore-retraction)]
|
||||
(when (seq ignore-retraction)
|
||||
(audit-transact-batch ignore-retraction (:id context))))
|
||||
(let [invalidated
|
||||
(mu/trace ::success-tx
|
||||
[:count (count success)]
|
||||
(for [[_ n] (:tempids (audit-transact-batch (map :tx success) (:id context)))]
|
||||
@@ -573,7 +559,7 @@
|
||||
[:count (count invalidated)]
|
||||
(doseq [n invalidated]
|
||||
(solr/touch n)))))
|
||||
|
||||
|
||||
{:successful (map (fn [x] {:external_id (:external_id x)}) success)
|
||||
:ignored (map (fn [x]
|
||||
{:external_id (:external_id x)})
|
||||
@@ -582,7 +568,6 @@
|
||||
:errors (map (fn [x] {:external_id (:external_id x)
|
||||
:error (:error x)}) errors)}))
|
||||
|
||||
|
||||
(defn get-journal-detail-report [context input _]
|
||||
(let [category-totals (atom {})
|
||||
base-categories (into []
|
||||
@@ -597,20 +582,19 @@
|
||||
:clients [{:db/id client-id}])
|
||||
{:filters {:location location
|
||||
:date_range (:date_range input)
|
||||
:from_numeric_code (l-reports/min-numeric-code category )
|
||||
:to_numeric_code (l-reports/max-numeric-code category )
|
||||
:from_numeric_code (l-reports/min-numeric-code category)
|
||||
:to_numeric_code (l-reports/max-numeric-code category)
|
||||
:per_page Integer/MAX_VALUE}}
|
||||
nil)
|
||||
:journal_entries
|
||||
(mapcat (fn [je]
|
||||
(->> (je :line_items)
|
||||
(filter (fn [jel]
|
||||
(when-let [account (account-lookup (:id (:account jel)))]
|
||||
(and
|
||||
(l-reports/account-belongs-in-category? (:numeric_code account) category)
|
||||
(= location (:location jel)))))
|
||||
)
|
||||
(map (fn [jel ]
|
||||
(when-let [account (account-lookup (:id (:account jel)))]
|
||||
(and
|
||||
(l-reports/account-belongs-in-category? (:numeric_code account) category)
|
||||
(= location (:location jel))))))
|
||||
(map (fn [jel]
|
||||
{:date (:date je)
|
||||
:debit (:debit jel)
|
||||
:credit (:credit jel)
|
||||
@@ -621,18 +605,18 @@
|
||||
(into []))
|
||||
_ (swap! category-totals assoc-in [client-id location category]
|
||||
(- (or (reduce + 0.0 (map #(or (:credit %) 0.0) all-journal-entries)) 0.0)
|
||||
(or (reduce + 0.0 (map #(or (:debit %) 0.0) all-journal-entries)) 0.0)) )
|
||||
(or (reduce + 0.0 (map #(or (:debit %) 0.0) all-journal-entries)) 0.0)))
|
||||
journal-entries-by-account (group-by #(account-lookup (get-in % [:account :id])) all-journal-entries)]
|
||||
[account journal-entries] (conj (vec journal-entries-by-account) [nil all-journal-entries])
|
||||
:let [journal-entries (first (reduce
|
||||
(fn [[acc last-je] je]
|
||||
(let [next-je (assoc je :running_balance
|
||||
(- (+ (or (:running_balance last-je 0.0) 0.0)
|
||||
(or (:credit je 0.0) 0.0))
|
||||
(or (:debit je 0.0) 0.0)))]
|
||||
[(conj acc next-je) next-je]))
|
||||
[]
|
||||
(sort-by :date journal-entries)))]]
|
||||
(fn [[acc last-je] je]
|
||||
(let [next-je (assoc je :running_balance
|
||||
(- (+ (or (:running_balance last-je 0.0) 0.0)
|
||||
(or (:credit je 0.0) 0.0))
|
||||
(or (:debit je 0.0) 0.0)))]
|
||||
[(conj acc next-je) next-je]))
|
||||
[]
|
||||
(sort-by :date journal-entries)))]]
|
||||
{:category (->graphql category)
|
||||
:client_id client-id
|
||||
:location location
|
||||
@@ -641,7 +625,7 @@
|
||||
:journal_entries (when account journal-entries)
|
||||
:total (- (or (reduce + 0.0 (map #(or (:credit %) 0.0) journal-entries)) 0.0)
|
||||
(or (reduce + 0.0 (map #(or (:debit %) 0.0) journal-entries)) 0.0))}))
|
||||
result {:categories
|
||||
result {:categories
|
||||
(into base-categories
|
||||
(for [client-id (:client_ids input)
|
||||
:let [_ (assert-can-see-client (:id context) client-id)
|
||||
@@ -675,15 +659,12 @@
|
||||
line))}]
|
||||
result))
|
||||
|
||||
|
||||
|
||||
(defn journal-detail-report-pdf [context args value]
|
||||
(let [data (get-journal-detail-report context args value)
|
||||
result (print-journal-detail-report (:id context) args data)]
|
||||
|
||||
(->graphql result)))
|
||||
|
||||
|
||||
(def objects
|
||||
{:balance_sheet_account
|
||||
{:fields {:id {:type 'String}
|
||||
@@ -847,7 +828,7 @@
|
||||
(def input-objects
|
||||
{:numeric_code_range
|
||||
{:fields {:from {:type 'Int}
|
||||
:to {:type 'Int}}}
|
||||
:to {:type 'Int}}}
|
||||
:ledger_filters
|
||||
{:fields {:client_id {:type :id}
|
||||
:vendor_id {:type :id}
|
||||
@@ -874,25 +855,23 @@
|
||||
:credit {:type :money}}}
|
||||
:import_ledger_entry
|
||||
{:fields {:source {:type 'String}
|
||||
:external_id {:type 'String}
|
||||
:external_id {:type 'String}
|
||||
:client_code {:type 'String}
|
||||
:date {:type 'String}
|
||||
:vendor_name {:type 'String}
|
||||
:amount {:type :money}
|
||||
:note {:type 'String}
|
||||
:cleared_against {:type 'String}
|
||||
:line_items {:type '(list :import_ledger_line_item)}}}
|
||||
})
|
||||
:line_items {:type '(list :import_ledger_line_item)}}}})
|
||||
|
||||
(def enums
|
||||
{:ledger_category {:values [{:enum-value :sales}
|
||||
{:enum-value :cogs}
|
||||
{:enum-value :payroll}
|
||||
{:enum-value :cogs}
|
||||
{:enum-value :payroll}
|
||||
{:enum-value :controllable}
|
||||
{:enum-value :fixed_overhead}
|
||||
{:enum-value :ownership_controllable}]}})
|
||||
|
||||
|
||||
(def resolvers
|
||||
{:get-ledger-page get-ledger-page
|
||||
:get-balance-sheet get-balance-sheet
|
||||
|
||||
@@ -21,13 +21,11 @@
|
||||
:name (first name)}))
|
||||
[]))
|
||||
|
||||
|
||||
|
||||
(defn attach [schema]
|
||||
(->
|
||||
(->
|
||||
(merge-with merge schema
|
||||
{:objects {:plaid_link_result
|
||||
{:fields {:token {:type 'String}} }
|
||||
{:fields {:token {:type 'String}}}
|
||||
|
||||
:plaid_item
|
||||
{:fields {:external_id {:type 'String}
|
||||
@@ -50,7 +48,7 @@
|
||||
:name {:type 'String}
|
||||
:number {:type 'String}}}}
|
||||
:queries {:search_plaid_merchants {:type '(list :plaid_merchant)
|
||||
:args {:query {:type 'String}}
|
||||
:resolve :search-plaid-merchants}}})
|
||||
:args {:query {:type 'String}}
|
||||
:resolve :search-plaid-merchants}}})
|
||||
(attach-tracing-resolvers {:search-plaid-merchants search-merchants})))
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
(ns auto-ap.graphql.sales-orders
|
||||
(:require [auto-ap.datomic.sales-orders :as d-sales-orders2]
|
||||
[auto-ap.graphql.utils :refer [->graphql <-graphql result->page assert-admin] ]
|
||||
[auto-ap.graphql.utils :refer [->graphql <-graphql result->page assert-admin]]
|
||||
[com.walmartlabs.lacinia.util :refer [attach-resolvers]]
|
||||
[auto-ap.graphql.utils :refer [attach-tracing-resolvers]]))
|
||||
|
||||
@@ -14,19 +14,18 @@
|
||||
(defn get-all-sales-orders [context args _]
|
||||
(assert-admin (:id context))
|
||||
(map
|
||||
->graphql
|
||||
(first (d-sales-orders2/get-graphql (assoc (<-graphql args) :count Integer/MAX_VALUE)))))
|
||||
|
||||
->graphql
|
||||
(first (d-sales-orders2/get-graphql (assoc (<-graphql args) :count Integer/MAX_VALUE)))))
|
||||
|
||||
(def objects
|
||||
{:sales_order_page
|
||||
{:fields {:sales_orders {:type '(list :sales_order)}
|
||||
:count {:type 'Int}
|
||||
:total {:type 'Int}
|
||||
:start {:type 'Int}
|
||||
:end {:type 'Int}
|
||||
:sales_order_total {:type :money}
|
||||
:sales_order_tax {:type :money}}}
|
||||
:count {:type 'Int}
|
||||
:total {:type 'Int}
|
||||
:start {:type 'Int}
|
||||
:end {:type 'Int}
|
||||
:sales_order_total {:type :money}
|
||||
:sales_order_tax {:type :money}}}
|
||||
|
||||
:sales_order
|
||||
{:fields {:id {:type :id}
|
||||
@@ -93,8 +92,7 @@
|
||||
|
||||
(def resolvers
|
||||
{:get-all-sales-orders get-all-sales-orders
|
||||
:get-sales-order-page get-sales-orders-page
|
||||
})
|
||||
:get-sales-order-page get-sales-orders-page})
|
||||
|
||||
(defn attach [schema]
|
||||
(->
|
||||
|
||||
@@ -29,9 +29,8 @@
|
||||
(defn get-transaction-rule-matches [context args _]
|
||||
(if (= "admin" (:user/role (:id context)))
|
||||
(let [transaction (update (d-transactions/get-by-id (:transaction_id args)) :transaction/date c/to-date)
|
||||
all-rules (tr/get-all-for-client (:db/id (:transaction/client transaction)))
|
||||
all-rules (tr/get-all-for-client (:db/id (:transaction/client transaction)))]
|
||||
|
||||
]
|
||||
(mu/log ::counted
|
||||
:count (count all-rules))
|
||||
(doto (map ->graphql (rm/get-matching-rules transaction all-rules)) (#(println (count %)))))
|
||||
@@ -43,7 +42,7 @@
|
||||
:account account_id
|
||||
:location location})
|
||||
|
||||
(defn delete-transaction-rule [context {:keys [transaction_rule_id ]} _]
|
||||
(defn delete-transaction-rule [context {:keys [transaction_rule_id]} _]
|
||||
(assert-admin (:id context))
|
||||
(let [existing-transaction-rule (tr/get-by-id transaction_rule_id)]
|
||||
(when-not (:transaction-rule/description existing-transaction-rule)
|
||||
@@ -59,62 +58,59 @@
|
||||
(. java.util.regex.Pattern (compile description java.util.regex.Pattern/CASE_INSENSITIVE))
|
||||
(catch Exception e
|
||||
(throw (ex-info (ex-message e) {:validation-error (ex-message e)}))))
|
||||
_ (when-not (dollars= 1.0 account-total)
|
||||
_ (when-not (dollars= 1.0 account-total)
|
||||
(let [error (str "Account total (" account-total ") does not reach 100%")]
|
||||
(throw (ex-info error {:validation-error error}))))
|
||||
_ (when (and (str/blank? description)
|
||||
(nil? yodlee_merchant_id))
|
||||
(nil? yodlee_merchant_id))
|
||||
(let [error (str "You must provide a description or a yodlee merchant")]
|
||||
(throw (ex-info error {:validation-error error}))))
|
||||
_ (doseq [a accounts
|
||||
:let [{:keys [:account/location :account/name]} (dc/pull (dc/db conn) [:account/location :account/name] (:account_id a))
|
||||
client (dc/pull (dc/db conn) [:client/locations] client_id)
|
||||
]]
|
||||
client (dc/pull (dc/db conn) [:client/locations] client_id)]]
|
||||
(when (and location (not= location (:location a)))
|
||||
(let [err (str "Account " name " uses location " (:location a) ", but is supposed to be " location)]
|
||||
(throw (ex-info err {:validation-error err}) )))
|
||||
|
||||
(throw (ex-info err {:validation-error err}))))
|
||||
|
||||
(when (and (not location)
|
||||
(not (get (into #{"Shared"} (:client/locations client))
|
||||
(:location a))))
|
||||
(let [err (str "Account " name " uses location " (:location a) ", but doesn't belong to the client.")]
|
||||
(throw (ex-info err {:validation-error err}) ))))
|
||||
(throw (ex-info err {:validation-error err})))))
|
||||
rule-id (if id
|
||||
id
|
||||
"transaction-rule")
|
||||
transaction [[:upsert-entity #:transaction-rule {:db/id (or rule-id (random-tempid))
|
||||
:description description
|
||||
:note note
|
||||
:client client_id
|
||||
:bank-account bank_account_id
|
||||
:yodlee-merchant yodlee_merchant_id
|
||||
:dom-lte dom_lte
|
||||
:dom-gte dom_gte
|
||||
:amount-lte amount_lte
|
||||
:amount-gte amount_gte
|
||||
:vendor vendor_id
|
||||
:transaction-approval-status
|
||||
(some->> transaction_approval_status
|
||||
name
|
||||
snake->kebab
|
||||
(keyword "transaction-approval-status"))
|
||||
:transaction-rule/accounts (map transaction-rule-account->entity accounts)}]]
|
||||
|
||||
:description description
|
||||
:note note
|
||||
:client client_id
|
||||
:bank-account bank_account_id
|
||||
:yodlee-merchant yodlee_merchant_id
|
||||
:dom-lte dom_lte
|
||||
:dom-gte dom_gte
|
||||
:amount-lte amount_lte
|
||||
:amount-gte amount_gte
|
||||
:vendor vendor_id
|
||||
:transaction-approval-status
|
||||
(some->> transaction_approval_status
|
||||
name
|
||||
snake->kebab
|
||||
(keyword "transaction-approval-status"))
|
||||
:transaction-rule/accounts (map transaction-rule-account->entity accounts)}]]
|
||||
|
||||
transaction-result (audit-transact transaction (:id context))]
|
||||
(-> (tr/get-by-id (or (-> transaction-result :tempids (get "transaction-rule"))
|
||||
id))
|
||||
id))
|
||||
((ident->enum-f :transaction-rule/transaction-approval-status))
|
||||
(->graphql))))
|
||||
|
||||
(defn -test-transaction-rule [id {:keys [:transaction-rule/description :transaction-rule/client :transaction-rule/bank-account :transaction-rule/amount-lte :transaction-rule/amount-gte :transaction-rule/dom-lte :transaction-rule/dom-gte :transaction-rule/yodlee-merchant]} include-coded? count]
|
||||
(let [query (cond-> {:query {:find ['(pull ?e [* {:transaction/client [:client/name]
|
||||
:transaction/bank-account [:bank-account/name]
|
||||
:transaction/payment [:db/id]}
|
||||
])]
|
||||
:in ['$ ]
|
||||
:transaction/payment [:db/id]}])]
|
||||
:in ['$]
|
||||
:where []}
|
||||
:args [(dc/db conn)]}
|
||||
:args [(dc/db conn)]}
|
||||
description
|
||||
(merge-query {:query {:in ['?descr]
|
||||
:where ['[(iol-ion.query/->pattern ?descr) ?description-regex]]}
|
||||
@@ -170,23 +166,22 @@
|
||||
:where ['[?e :transaction/client ?client-id]]}
|
||||
:args [(:db/id client)]})
|
||||
|
||||
|
||||
(not include-coded?)
|
||||
(merge-query {:query {:where ['[or [?e :transaction/approval-status :transaction-approval-status/unapproved]
|
||||
[(missing? $ ?e :transaction/approval-status)]]]}})
|
||||
|
||||
true
|
||||
(merge-query {:query {:where ['[?e :transaction/id]]}}))]
|
||||
(->>
|
||||
(query2 query)
|
||||
(transduce (comp
|
||||
(take (or count 15))
|
||||
(map first)
|
||||
(map #(dissoc % :transaction/id))
|
||||
(map (fn [x]
|
||||
(update x :transaction/date c/from-date)))
|
||||
(map ->graphql))
|
||||
conj []))))
|
||||
(->>
|
||||
(query2 query)
|
||||
(transduce (comp
|
||||
(take (or count 15))
|
||||
(map first)
|
||||
(map #(dissoc % :transaction/id))
|
||||
(map (fn [x]
|
||||
(update x :transaction/date c/from-date)))
|
||||
(map ->graphql))
|
||||
conj []))))
|
||||
|
||||
(defn test-transaction-rule [{:keys [id]} {{:keys [description client_id bank_account_id amount_lte amount_gte dom_lte dom_gte yodlee_merchant_id]} :transaction_rule} _]
|
||||
(assert-admin id)
|
||||
@@ -200,7 +195,6 @@
|
||||
:yodlee-merchant (when yodlee_merchant_id {:db/id yodlee_merchant_id})}
|
||||
true 15))
|
||||
|
||||
|
||||
(defn run-transaction-rule [{:keys [id]} {:keys [transaction_rule_id count]} _]
|
||||
(assert-admin id)
|
||||
(-test-transaction-rule id (tr/get-by-id transaction_rule_id) false count))
|
||||
|
||||
@@ -66,14 +66,14 @@
|
||||
|
||||
(defn get-ids-matching-filters [args]
|
||||
(alog/info ::getting-ids-matching-filters
|
||||
:args args)
|
||||
:args args)
|
||||
(let [ids (some-> (:filters args)
|
||||
(assoc :clients (:clients args))
|
||||
(assoc :id (:id args))
|
||||
(<-graphql)
|
||||
(update :approval-status enum->keyword "transaction-approval-status")
|
||||
(assoc :per-page Integer/MAX_VALUE)
|
||||
(d-transactions/raw-graphql-ids )
|
||||
(d-transactions/raw-graphql-ids)
|
||||
:ids)
|
||||
specific-ids (d-transactions/filter-ids (seq (:ids args)))]
|
||||
(if (seq (:ids args))
|
||||
@@ -83,13 +83,13 @@
|
||||
(defn all-ids-not-locked [all-ids]
|
||||
(->> all-ids
|
||||
(dc/q '[:find ?t
|
||||
:in $ [?t ...]
|
||||
:where
|
||||
[?t :transaction/client ?c]
|
||||
[(get-else $ ?c :client/locked-until #inst "2000-01-01") ?lu]
|
||||
[?t :transaction/date ?d]
|
||||
[(>= ?d ?lu)]]
|
||||
(dc/db conn))
|
||||
:in $ [?t ...]
|
||||
:where
|
||||
[?t :transaction/client ?c]
|
||||
[(get-else $ ?c :client/locked-until #inst "2000-01-01") ?lu]
|
||||
[?t :transaction/date ?d]
|
||||
[(>= ?d ?lu)]]
|
||||
(dc/db conn))
|
||||
(map first)))
|
||||
(defn bulk-change-status [context args _]
|
||||
(let [_ (assert-admin (:id context))
|
||||
@@ -98,47 +98,46 @@
|
||||
all-ids-not-locked)]
|
||||
|
||||
(alog/info ::bulk-change-status
|
||||
:count (count all-ids)
|
||||
:sample (take 3 all-ids)
|
||||
:status (:status args)
|
||||
)
|
||||
:count (count all-ids)
|
||||
:sample (take 3 all-ids)
|
||||
:status (:status args))
|
||||
(audit-transact-batch
|
||||
(->> all-ids
|
||||
(mapv (fn [t]
|
||||
[:upsert-transaction {:db/id t
|
||||
:transaction/approval-status (enum->keyword (:status args) "transaction-approval-status")}])))
|
||||
|
||||
|
||||
(:id context))
|
||||
{:message (str "Succesfully changed " (count all-ids) " transactions to be " (name (:status args) ) ".")}))
|
||||
{:message (str "Succesfully changed " (count all-ids) " transactions to be " (name (:status args)) ".")}))
|
||||
|
||||
;; TODO very similar to rule-matching
|
||||
(defn maybe-code-accounts [transaction account-rules valid-locations]
|
||||
(with-precision 2
|
||||
(let [accounts (vec (mapcat
|
||||
(fn [ar]
|
||||
(let [cents-to-distribute (int (Math/round (Math/abs (* (:percentage ar)
|
||||
(:transaction/amount transaction)
|
||||
100))))]
|
||||
(if (= "Shared" (:location ar))
|
||||
(->> valid-locations
|
||||
(map
|
||||
(fn [cents location]
|
||||
{:db/id (random-tempid)
|
||||
:transaction-account/account (:account_id ar)
|
||||
:transaction-account/amount (* 0.01 cents)
|
||||
:transaction-account/location location})
|
||||
(rm/spread-cents cents-to-distribute (count valid-locations))))
|
||||
[(cond-> {:db/id (random-tempid)
|
||||
:transaction-account/account (:account_id ar)
|
||||
:transaction-account/amount (* 0.01 cents-to-distribute)}
|
||||
(:location ar) (assoc :transaction-account/location (:location ar)))])))
|
||||
account-rules))
|
||||
(fn [ar]
|
||||
(let [cents-to-distribute (int (Math/round (Math/abs (* (:percentage ar)
|
||||
(:transaction/amount transaction)
|
||||
100))))]
|
||||
(if (= "Shared" (:location ar))
|
||||
(->> valid-locations
|
||||
(map
|
||||
(fn [cents location]
|
||||
{:db/id (random-tempid)
|
||||
:transaction-account/account (:account_id ar)
|
||||
:transaction-account/amount (* 0.01 cents)
|
||||
:transaction-account/location location})
|
||||
(rm/spread-cents cents-to-distribute (count valid-locations))))
|
||||
[(cond-> {:db/id (random-tempid)
|
||||
:transaction-account/account (:account_id ar)
|
||||
:transaction-account/amount (* 0.01 cents-to-distribute)}
|
||||
(:location ar) (assoc :transaction-account/location (:location ar)))])))
|
||||
account-rules))
|
||||
accounts (mapv
|
||||
(fn [a]
|
||||
(update a :transaction-account/amount
|
||||
#(with-precision 2
|
||||
(double (.setScale (bigdec %) 2 java.math.RoundingMode/HALF_UP)))))
|
||||
accounts)
|
||||
(fn [a]
|
||||
(update a :transaction-account/amount
|
||||
#(with-precision 2
|
||||
(double (.setScale (bigdec %) 2 java.math.RoundingMode/HALF_UP)))))
|
||||
accounts)
|
||||
leftover (with-precision 2 (.round (bigdec (- (Math/abs (:transaction/amount transaction))
|
||||
(Math/abs (reduce + 0.0 (map #(:transaction-account/amount %) accounts)))))
|
||||
*math-context*))
|
||||
@@ -152,13 +151,13 @@
|
||||
(when-not (seq (:clients context))
|
||||
(throw (ex-info "Client is required"
|
||||
{:validation-error "Client is required"})))
|
||||
(let [args (assoc args :clients (:clients context) :id (:id context))
|
||||
(let [args (assoc args :clients (:clients context) :id (:id context))
|
||||
client->locations (->> (:clients context)
|
||||
(map :db/id )
|
||||
(map :db/id)
|
||||
(dc/q
|
||||
'[:find (pull ?e [:db/id :client/locations])
|
||||
:in $ [?e ...]]
|
||||
(dc/db conn))
|
||||
'[:find (pull ?e [:db/id :client/locations])
|
||||
:in $ [?e ...]]
|
||||
(dc/db conn))
|
||||
(map (fn [[client]]
|
||||
[(:db/id client) (:client/locations client)]))
|
||||
(into {}))
|
||||
@@ -166,41 +165,40 @@
|
||||
transactions (pull-many (dc/db conn) [:db/id :transaction/amount {:transaction/client [:db/id]}] (vec all-ids))
|
||||
account-total (reduce + 0 (map (fn [x] (:percentage x)) (:accounts args)))]
|
||||
(alog/info ::bulk-coding-transactions
|
||||
:count (count transactions)
|
||||
:sample (take 3 transactions))
|
||||
:count (count transactions)
|
||||
:sample (take 3 transactions))
|
||||
(when
|
||||
(and
|
||||
(seq (:accounts args))
|
||||
(not (dollars= 1.0 account-total)))
|
||||
(let [error (str "Account total (" account-total ") does not reach 100%")]
|
||||
(throw (ex-info error {:validation-error error}))))
|
||||
(and
|
||||
(seq (:accounts args))
|
||||
(not (dollars= 1.0 account-total)))
|
||||
(let [error (str "Account total (" account-total ") does not reach 100%")]
|
||||
(throw (ex-info error {:validation-error error}))))
|
||||
(doseq [a (:accounts args)
|
||||
:let [{:keys [:account/location :account/name]} (dc/pull (dc/db conn)
|
||||
[:account/location :account/name]
|
||||
(:account_id a))]]
|
||||
(when (and location (not= location (:location a)))
|
||||
(let [err (str "Account " name " uses location " (:location a) ", but is supposed to be " location)]
|
||||
(throw (ex-info err {:validation-error err}) )))
|
||||
(throw (ex-info err {:validation-error err}))))
|
||||
(doseq [[_ locations] client->locations]
|
||||
(when (and (not location)
|
||||
(not (get (into #{"Shared"} locations)
|
||||
(:location a))))
|
||||
(let [err (str "Account " name " uses location " (:location a) ", but doesn't belong to the client.")]
|
||||
(throw (ex-info err {:validation-error err}) )))))
|
||||
(throw (ex-info err {:validation-error err}))))))
|
||||
(audit-transact-batch
|
||||
(map (fn [t]
|
||||
(let [locations (client->locations (-> t :transaction/client :db/id))]
|
||||
(doto
|
||||
[:upsert-transaction (cond-> t
|
||||
(:approval_status args) (assoc :transaction/approval-status (enum->keyword (:approval_status args) "transaction-approval-status"))
|
||||
(:vendor args) (assoc :transaction/vendor (:vendor args))
|
||||
(seq (:accounts args)) (assoc :transaction/accounts (maybe-code-accounts t (:accounts args) locations)))]
|
||||
clojure.pprint/pprint)))
|
||||
transactions)
|
||||
(:id context))
|
||||
(map (fn [t]
|
||||
(let [locations (client->locations (-> t :transaction/client :db/id))]
|
||||
(doto
|
||||
[:upsert-transaction (cond-> t
|
||||
(:approval_status args) (assoc :transaction/approval-status (enum->keyword (:approval_status args) "transaction-approval-status"))
|
||||
(:vendor args) (assoc :transaction/vendor (:vendor args))
|
||||
(seq (:accounts args)) (assoc :transaction/accounts (maybe-code-accounts t (:accounts args) locations)))]
|
||||
clojure.pprint/pprint)))
|
||||
transactions)
|
||||
(:id context))
|
||||
{:message (str "Successfully coded " (count all-ids) " transactions.")}))
|
||||
|
||||
|
||||
(defn delete-transactions [context args _]
|
||||
(let [_ (assert-admin (:id context))
|
||||
args (assoc args :clients (:clients context))
|
||||
@@ -208,24 +206,24 @@
|
||||
db (dc/db conn)]
|
||||
|
||||
(alog/info ::bulk-delete-transactions
|
||||
:count (count all-ids)
|
||||
:sample (take 3 all-ids))
|
||||
:count (count all-ids)
|
||||
:sample (take 3 all-ids))
|
||||
(audit-transact-batch
|
||||
(mapcat (fn [i]
|
||||
(let [transaction (dc/pull db [:transaction/payment
|
||||
:transaction/expected-deposit
|
||||
:db/id] i)
|
||||
payment-id (-> transaction :transaction/payment :db/id)
|
||||
expected-deposit-id (-> transaction :transaction/expected-deposit :db/id)]
|
||||
(cond->> [[:db/retractEntity [:journal-entry/original-entity i]]]
|
||||
payment-id (into [{:db/id payment-id
|
||||
:payment/status :payment-status/pending}
|
||||
[:db/retract (:db/id transaction) :transaction/payment payment-id]])
|
||||
expected-deposit-id (into [{:db/id expected-deposit-id
|
||||
:expected-deposit/status :expected-deposit-status/pending}
|
||||
[:db/retract (:db/id transaction) :transaction/expected-deposit expected-deposit-id]]))))
|
||||
all-ids)
|
||||
(:id context))
|
||||
(mapcat (fn [i]
|
||||
(let [transaction (dc/pull db [:transaction/payment
|
||||
:transaction/expected-deposit
|
||||
:db/id] i)
|
||||
payment-id (-> transaction :transaction/payment :db/id)
|
||||
expected-deposit-id (-> transaction :transaction/expected-deposit :db/id)]
|
||||
(cond->> [[:db/retractEntity [:journal-entry/original-entity i]]]
|
||||
payment-id (into [{:db/id payment-id
|
||||
:payment/status :payment-status/pending}
|
||||
[:db/retract (:db/id transaction) :transaction/payment payment-id]])
|
||||
expected-deposit-id (into [{:db/id expected-deposit-id
|
||||
:expected-deposit/status :expected-deposit-status/pending}
|
||||
[:db/retract (:db/id transaction) :transaction/expected-deposit expected-deposit-id]]))))
|
||||
all-ids)
|
||||
(:id context))
|
||||
(audit-transact-batch
|
||||
(mapcat (fn [i]
|
||||
(let [transaction-tx (if (:suppress args)
|
||||
@@ -242,21 +240,21 @@
|
||||
(assert-power-user (:id context))
|
||||
|
||||
(let [transaction (d-transactions/get-by-id (:transaction_id args))
|
||||
_ (assert-can-see-client (:id context) (:transaction/client transaction) )
|
||||
_ (assert-can-see-client (:id context) (:transaction/client transaction))
|
||||
matches-set (i-transactions/match-transaction-to-unfulfilled-autopayments (:transaction/amount transaction)
|
||||
(:db/id (:transaction/client transaction)))]
|
||||
(->graphql (for [matches matches-set]
|
||||
(for [[_ invoice-id ] matches]
|
||||
(for [[_ invoice-id] matches]
|
||||
(d-invoices/get-by-id invoice-id))))))
|
||||
|
||||
(defn get-potential-unpaid-invoices-matches [context args _]
|
||||
(assert-power-user (:id context))
|
||||
(let [transaction (d-transactions/get-by-id (:transaction_id args))
|
||||
_ (assert-can-see-client (:id context) (:transaction/client transaction) )
|
||||
_ (assert-can-see-client (:id context) (:transaction/client transaction))
|
||||
matches-set (i-transactions/match-transaction-to-unpaid-invoices (:transaction/amount transaction)
|
||||
(:db/id (:transaction/client transaction)))]
|
||||
(->graphql (for [matches matches-set]
|
||||
(for [[_ invoice-id ] matches]
|
||||
(for [[_ invoice-id] matches]
|
||||
(d-invoices/get-by-id invoice-id))))))
|
||||
|
||||
(defn unlink-transaction [context args _]
|
||||
@@ -264,20 +262,20 @@
|
||||
args (assoc args :id (:id context))
|
||||
transaction-id (:transaction_id args)
|
||||
transaction (dc/pull (dc/db conn)
|
||||
[:transaction/approval-status
|
||||
:transaction/status
|
||||
:transaction/date
|
||||
:transaction/location
|
||||
:transaction/vendor
|
||||
:transaction/accounts
|
||||
:transaction/client [:db/id]
|
||||
{:transaction/payment [:payment/date {:payment/status [:db/ident]} :db/id]} ]
|
||||
transaction-id)
|
||||
_ (assert-can-see-client (:id context) (:transaction/client transaction) )
|
||||
[:transaction/approval-status
|
||||
:transaction/status
|
||||
:transaction/date
|
||||
:transaction/location
|
||||
:transaction/vendor
|
||||
:transaction/accounts
|
||||
:transaction/client [:db/id]
|
||||
{:transaction/payment [:payment/date {:payment/status [:db/ident]} :db/id]}]
|
||||
transaction-id)
|
||||
_ (assert-can-see-client (:id context) (:transaction/client transaction))
|
||||
_ (assert-not-locked (:db/id (:transaction/client transaction)) (:transaction/date transaction))
|
||||
_ (when (:transaction/payment transaction)
|
||||
(assert-not-locked (:db/id (:transaction/client transaction)) (-> transaction :transaction/payment :payment/date)))
|
||||
payment (-> transaction :transaction/payment )
|
||||
payment (-> transaction :transaction/payment)
|
||||
is-autopay-payment? (some->> (dc/q {:find ['?sp]
|
||||
:in ['$ '?payment]
|
||||
:where ['[?ip :invoice-payment/payment ?payment]
|
||||
@@ -286,8 +284,7 @@
|
||||
(dc/db conn) (:db/id payment))
|
||||
seq
|
||||
(map first)
|
||||
(every? #(instance? java.util.Date %)))
|
||||
]
|
||||
(every? #(instance? java.util.Date %)))]
|
||||
|
||||
(alog/info ::unlinking :transaction (pr-str transaction) :autopay is-autopay-payment? :payment (pr-str payment))
|
||||
|
||||
@@ -295,49 +292,47 @@
|
||||
(throw (ex-info "Payment can't be undone because it isn't cleared." {:validation-error "Payment can't be undone because it isn't cleared."})))
|
||||
(if is-autopay-payment?
|
||||
(audit-transact
|
||||
(-> [{:db/id (:db/id payment)
|
||||
:payment/status :payment-status/pending}
|
||||
[:upsert-transaction
|
||||
{:db/id transaction-id
|
||||
:transaction/approval-status :transaction-approval-status/unapproved
|
||||
:transaction/payment nil
|
||||
:transaction/vendor nil
|
||||
:transaction/location nil
|
||||
:transaction/accounts nil}]
|
||||
(-> [{:db/id (:db/id payment)
|
||||
:payment/status :payment-status/pending}
|
||||
[:upsert-transaction
|
||||
{:db/id transaction-id
|
||||
:transaction/approval-status :transaction-approval-status/unapproved
|
||||
:transaction/payment nil
|
||||
:transaction/vendor nil
|
||||
:transaction/location nil
|
||||
:transaction/accounts nil}]
|
||||
|
||||
[:db/retractEntity (:db/id payment) ]]
|
||||
[:db/retractEntity (:db/id payment)]]
|
||||
|
||||
(into (map (fn [[invoice-payment]]
|
||||
[:db/retractEntity invoice-payment])
|
||||
(dc/q {:find ['?ip]
|
||||
:in ['$ '?p]
|
||||
:where ['[?ip :invoice-payment/payment ?p]]}
|
||||
(dc/db conn)
|
||||
(:db/id payment) ))))
|
||||
(into (map (fn [[invoice-payment]]
|
||||
[:db/retractEntity invoice-payment])
|
||||
(dc/q {:find ['?ip]
|
||||
:in ['$ '?p]
|
||||
:where ['[?ip :invoice-payment/payment ?p]]}
|
||||
(dc/db conn)
|
||||
(:db/id payment)))))
|
||||
(:id context))
|
||||
(audit-transact
|
||||
[{:db/id (:db/id payment)
|
||||
:payment/status :payment-status/pending}
|
||||
[:upsert-transaction
|
||||
{:db/id transaction-id
|
||||
:transaction/approval-status :transaction-approval-status/unapproved
|
||||
:transaction/payment nil
|
||||
:transaction/vendor nil
|
||||
:transaction/location nil
|
||||
:transaction/accounts nil}]]
|
||||
[{:db/id (:db/id payment)
|
||||
:payment/status :payment-status/pending}
|
||||
[:upsert-transaction
|
||||
{:db/id transaction-id
|
||||
:transaction/approval-status :transaction-approval-status/unapproved
|
||||
:transaction/payment nil
|
||||
:transaction/vendor nil
|
||||
:transaction/location nil
|
||||
:transaction/accounts nil}]]
|
||||
(:id context)))
|
||||
(-> (d-transactions/get-by-id transaction-id)
|
||||
approval-status->graphql
|
||||
->graphql)))
|
||||
|
||||
|
||||
(defn transaction-account->entity [{:keys [id account_id amount location]}]
|
||||
#:transaction-account {:amount amount
|
||||
:db/id (or id (random-tempid))
|
||||
:account account_id
|
||||
:location location})
|
||||
|
||||
|
||||
(defn assert-valid-expense-accounts [accounts]
|
||||
(doseq [trans-account accounts
|
||||
:let [account (dc/pull (dc/db conn)
|
||||
@@ -351,7 +346,7 @@
|
||||
(:account/location account)))
|
||||
(let [err (str "Account uses location '" (:location trans-account) "' but expects '" (:account/location account) "'")]
|
||||
(throw (ex-info err
|
||||
{:validation-error err}))))
|
||||
{:validation-error err}))))
|
||||
|
||||
(when (and (empty? (:account/location account))
|
||||
(= "A" (:location trans-account)))
|
||||
@@ -359,13 +354,12 @@
|
||||
(throw (ex-info err
|
||||
{:validation-error err}))))
|
||||
|
||||
|
||||
(when (nil? (:account_id trans-account))
|
||||
(throw (ex-info "Account is missing account" {:validation-error "Account is missing account"})))))
|
||||
|
||||
(defn edit-transaction [context {{:keys [id accounts vendor_id approval_status memo forecast_match]} :transaction} _]
|
||||
(let [existing-transaction (d-transactions/get-by-id id)
|
||||
_ (assert-can-see-client (:id context) (:transaction/client existing-transaction) )
|
||||
_ (assert-can-see-client (:id context) (:transaction/client existing-transaction))
|
||||
_ (assert-valid-expense-accounts accounts)
|
||||
_ (assert-not-locked (:db/id (:transaction/client existing-transaction)) (:transaction/date existing-transaction))
|
||||
account-total (reduce + 0 (map (fn [x] (:amount x)) accounts))
|
||||
@@ -378,17 +372,17 @@
|
||||
set
|
||||
(conj "A")
|
||||
(conj "HQ"))))]
|
||||
|
||||
|
||||
(when (and (not (dollars= (Math/abs (:transaction/amount existing-transaction)) account-total))
|
||||
(or
|
||||
(and (= approval_status :unapproved)
|
||||
(> (count accounts) 0))
|
||||
(not= approval_status :unapproved)))
|
||||
(not= approval_status :unapproved)))
|
||||
(let [error (str "Expense account total (" account-total ") does not equal transaction total (" (Math/abs (:transaction/amount existing-transaction)) ")")]
|
||||
(throw (ex-info error {:validation-error error}))))
|
||||
(throw (ex-info error {:validation-error error}))))
|
||||
(when missing-locations
|
||||
(throw (ex-info (str "Location '" (str/join ", " missing-locations) "' not found on client.") {})) )
|
||||
|
||||
(throw (ex-info (str "Location '" (str/join ", " missing-locations) "' not found on client.") {})))
|
||||
|
||||
(audit-transact (cond-> [[:upsert-transaction {:db/id id
|
||||
:transaction/vendor vendor_id
|
||||
:transaction/memo memo
|
||||
@@ -413,8 +407,8 @@
|
||||
(defn match-transaction [context {:keys [transaction_id payment_id]} _]
|
||||
(let [transaction (d-transactions/get-by-id transaction_id)
|
||||
payment (d-checks/get-by-id payment_id)
|
||||
_ (assert-can-see-client (:id context) (:transaction/client transaction) )
|
||||
_ (assert-can-see-client (:id context) (:payment/client payment) )
|
||||
_ (assert-can-see-client (:id context) (:transaction/client transaction))
|
||||
_ (assert-can-see-client (:id context) (:payment/client payment))
|
||||
_ (assert-not-locked (:db/id (:transaction/client transaction)) (:transaction/date transaction))]
|
||||
(when (not= (:db/id (:transaction/client transaction))
|
||||
(:db/id (:payment/client payment)))
|
||||
@@ -423,7 +417,7 @@
|
||||
(when-not (dollars= (- (:transaction/amount transaction))
|
||||
(:payment/amount payment))
|
||||
(throw (ex-info "Amounts don't match" {:validation-error "Amounts don't match"})))
|
||||
(audit-transact (into
|
||||
(audit-transact (into
|
||||
[{:db/id (:db/id payment)
|
||||
:payment/status :payment-status/cleared
|
||||
:payment/date (coerce/to-date (first (sort [(:payment/date payment)
|
||||
@@ -431,14 +425,14 @@
|
||||
|
||||
[:upsert-transaction
|
||||
{:db/id (:db/id transaction)
|
||||
:transaction/payment (:db/id payment)
|
||||
:transaction/vendor (:db/id (:payment/vendor payment))
|
||||
:transaction/location "A"
|
||||
:transaction/approval-status :transaction-approval-status/approved
|
||||
:transaction/accounts [{:db/id (random-tempid)
|
||||
:transaction-account/account (:db/id (a/get-account-by-numeric-code-and-sets 21000 ["default"]))
|
||||
:transaction-account/location "A"
|
||||
:transaction-account/amount (Math/abs (:transaction/amount transaction))}]}]])
|
||||
:transaction/payment (:db/id payment)
|
||||
:transaction/vendor (:db/id (:payment/vendor payment))
|
||||
:transaction/location "A"
|
||||
:transaction/approval-status :transaction-approval-status/approved
|
||||
:transaction/accounts [{:db/id (random-tempid)
|
||||
:transaction-account/account (:db/id (a/get-account-by-numeric-code-and-sets 21000 ["default"]))
|
||||
:transaction-account/location "A"
|
||||
:transaction-account/amount (Math/abs (:transaction/amount transaction))}]}]])
|
||||
(:id context)))
|
||||
(solr/touch-with-ledger transaction_id)
|
||||
(-> (d-transactions/get-by-id transaction_id)
|
||||
@@ -448,7 +442,7 @@
|
||||
(defn match-transaction-autopay-invoices [context {:keys [transaction_id autopay_invoice_ids]} _]
|
||||
(let [_ (assert-power-user (:id context))
|
||||
transaction (d-transactions/get-by-id transaction_id)
|
||||
_ (assert-can-see-client (:id context) (:transaction/client transaction) )
|
||||
_ (assert-can-see-client (:id context) (:transaction/client transaction))
|
||||
db (dc/db conn)
|
||||
invoice-clients (set (map #(pull-ref db :invoice/client %) autopay_invoice_ids))
|
||||
invoice-amount (reduce + 0.0 (map #(pull-attr db :invoice/total %) autopay_invoice_ids))
|
||||
@@ -474,9 +468,9 @@
|
||||
(:db/id (:transaction/bank-account transaction))
|
||||
(:db/id (:transaction/client transaction)))]
|
||||
(alog/info ::adding-payment-from-autopay-invoice
|
||||
:payment (pr-str payment-tx))
|
||||
:payment (pr-str payment-tx))
|
||||
(audit-transact payment-tx (:id context)))
|
||||
(solr/touch-with-ledger transaction_id)
|
||||
(solr/touch-with-ledger transaction_id)
|
||||
(-> (d-transactions/get-by-id transaction_id)
|
||||
approval-status->graphql
|
||||
->graphql)))
|
||||
@@ -485,8 +479,8 @@
|
||||
(defn match-transaction-unpaid-invoices [context {:keys [transaction_id unpaid_invoice_ids]} _]
|
||||
(let [_ (assert-power-user (:id context))
|
||||
transaction (d-transactions/get-by-id transaction_id)
|
||||
|
||||
_ (assert-can-see-client (:id context) (:transaction/client transaction) )
|
||||
|
||||
_ (assert-can-see-client (:id context) (:transaction/client transaction))
|
||||
_ (assert-not-locked (:db/id (:transaction/client transaction)) (:transaction/date transaction))
|
||||
db (dc/db conn)
|
||||
invoice-clients (set (map #(pull-ref db :invoice/client %) unpaid_invoice_ids))
|
||||
@@ -502,17 +496,17 @@
|
||||
(throw (ex-info "Amounts don't match" {:validation-error "Amounts don't match"})))
|
||||
(when (:transaction/payment transaction)
|
||||
(throw (ex-info "Transaction already linked" {:validation-error "Transaction already linked"})))
|
||||
|
||||
|
||||
(let [payment-tx (i-transactions/add-new-payment (dc/pull db [:transaction/amount :transaction/date :db/id] transaction_id)
|
||||
(map (fn [id]
|
||||
(let [entity (dc/pull db [:invoice/vendor :db/id :invoice/total] id)]
|
||||
[(or (-> entity :invoice/vendor :db/id)
|
||||
(-> entity :invoice/vendor))
|
||||
(-> entity :db/id)
|
||||
(-> entity :invoice/total)]))
|
||||
unpaid_invoice_ids)
|
||||
(:db/id (:transaction/bank-account transaction))
|
||||
(:db/id (:transaction/client transaction)))]
|
||||
(map (fn [id]
|
||||
(let [entity (dc/pull db [:invoice/vendor :db/id :invoice/total] id)]
|
||||
[(or (-> entity :invoice/vendor :db/id)
|
||||
(-> entity :invoice/vendor))
|
||||
(-> entity :db/id)
|
||||
(-> entity :invoice/total)]))
|
||||
unpaid_invoice_ids)
|
||||
(:db/id (:transaction/bank-account transaction))
|
||||
(:db/id (:transaction/client transaction)))]
|
||||
(audit-transact payment-tx (:id context)))
|
||||
(solr/touch-with-ledger transaction_id)
|
||||
|
||||
@@ -527,9 +521,8 @@
|
||||
:count Integer/MAX_VALUE} nil)
|
||||
|
||||
(filter #(not (:payment %)))
|
||||
(map :id ))
|
||||
(map :id))
|
||||
|
||||
|
||||
transaction_ids)
|
||||
_ (mu/log ::here :txids transaction_ids)
|
||||
transaction_ids (all-ids-not-locked transaction_ids)
|
||||
@@ -553,17 +546,16 @@
|
||||
(audit-transact (mapv (fn [t]
|
||||
[:upsert-transaction
|
||||
(remove-nils (rm/apply-rule {:db/id (:db/id t)
|
||||
:transaction/amount (:transaction/amount t)}
|
||||
transaction-rule
|
||||
:transaction/amount (:transaction/amount t)}
|
||||
transaction-rule
|
||||
|
||||
(or (-> t :transaction/bank-account :bank-account/locations)
|
||||
(-> t :transaction/client :client/locations))))])
|
||||
(or (-> t :transaction/bank-account :bank-account/locations)
|
||||
(-> t :transaction/client :client/locations))))])
|
||||
transactions)
|
||||
(:id context))
|
||||
|
||||
(doseq [n transactions]
|
||||
(solr/touch-with-ledger (:db/id n)))
|
||||
)
|
||||
(solr/touch-with-ledger (:db/id n))))
|
||||
(transduce
|
||||
(comp
|
||||
(map d-transactions/get-by-id)
|
||||
@@ -571,12 +563,12 @@
|
||||
(map ->graphql))
|
||||
conj
|
||||
[]
|
||||
transaction_ids ))
|
||||
transaction_ids))
|
||||
|
||||
(def objects
|
||||
{:transaction {:fields {:id {:type :id}
|
||||
:amount {:type 'String}
|
||||
:memo {:type 'String}
|
||||
:memo {:type 'String}
|
||||
:is_locked {:type 'Boolean}
|
||||
:description_original {:type 'String}
|
||||
:description_simple {:type 'String}
|
||||
@@ -628,8 +620,8 @@
|
||||
:resolve :mutation/bulk-code-transactions}
|
||||
:delete_transactions {:type :message
|
||||
:args {:filters {:type :transaction_filters}
|
||||
:ids {:type '(list :id)}
|
||||
:suppress {:type 'Boolean}}
|
||||
:ids {:type '(list :id)}
|
||||
:suppress {:type 'Boolean}}
|
||||
:resolve :mutation/delete-transactions}
|
||||
:edit_transaction {:type :transaction
|
||||
:args {:transaction {:type :edit_transaction}}
|
||||
@@ -711,9 +703,8 @@
|
||||
:mutation/match-transaction-unpaid-invoices match-transaction-unpaid-invoices
|
||||
:mutation/match-transaction-rules match-transaction-rules})
|
||||
|
||||
|
||||
(defn attach [schema]
|
||||
(->
|
||||
(->
|
||||
(merge-with merge schema
|
||||
{:objects objects
|
||||
:queries queries
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user