Compare commits
40 Commits
feat/sales
...
sales-summ
| Author | SHA1 | Date | |
|---|---|---|---|
| 79a4b457b0 | |||
| 1558851c18 | |||
| 4a1817711d | |||
| 967e77e443 | |||
| 10d0d01b82 | |||
| 57a84dae11 | |||
| 943bc18842 | |||
| 8af9bfdee2 | |||
| 95d0537c69 | |||
| 5126f3799d | |||
| 7a0e256f07 | |||
| 2b5fbaca00 | |||
| 473556a45d | |||
| 604d1ee1cf | |||
| e095cb94e4 | |||
| fa25620b7a | |||
| 111eca413e | |||
| fc54b92ddb | |||
| 019a1b4cd8 | |||
| 38575aa5bd | |||
| 08b948c24b | |||
| 83a739ac5b | |||
| 2c8985203e | |||
| 66b0b611e4 | |||
| baef2afc63 | |||
| a156ac99fe | |||
| de1c154706 | |||
| 31179278e4 | |||
|
|
455cec7828 | ||
| aeb7891efa | |||
|
|
1b2e2e4da7 | ||
| cc31d8849b | |||
|
|
bd82f555c2 | ||
| a78c818270 | |||
| d627e3c5d0 | |||
| e14a23ff54 | |||
| b499d460f3 | |||
|
|
ec5e4e2e1d | ||
| 2993da5c82 | |||
|
|
04bc7cae78 |
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
|
||||
376
.opencode/package-lock.json
generated
Normal file
376
.opencode/package-lock.json
generated
Normal file
@@ -0,0 +1,376 @@
|
||||
{
|
||||
"name": ".opencode",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "1.14.31"
|
||||
}
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz",
|
||||
"integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz",
|
||||
"integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz",
|
||||
"integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz",
|
||||
"integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz",
|
||||
"integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz",
|
||||
"integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@opencode-ai/plugin": {
|
||||
"version": "1.14.31",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.14.31.tgz",
|
||||
"integrity": "sha512-ZF7UoNKtZDtgW/2KrcFw5I7R2HRj/NigBuRwKPonvSZS36LnghZ7PYcXYZFGCjEgBmLUMMrLVgxccKLyxsgB0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "1.14.31",
|
||||
"effect": "4.0.0-beta.57",
|
||||
"zod": "4.1.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.2.0",
|
||||
"@opentui/solid": ">=0.2.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@opentui/core": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentui/solid": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@opencode-ai/sdk": {
|
||||
"version": "1.14.31",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.14.31.tgz",
|
||||
"integrity": "sha512-QaV+ti3NYUITmgIDqtNMqGIYBXJOx2zheN1g+7w4HC8QQsbaW1c7glxXExQHRbdUzcQPP2vUQhnXOcEsTw5CcQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cross-spawn": "7.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-key": "^3.1.0",
|
||||
"shebang-command": "^2.0.0",
|
||||
"which": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/effect": {
|
||||
"version": "4.0.0-beta.57",
|
||||
"resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.57.tgz",
|
||||
"integrity": "sha512-rg32VgXnLKaPRs9tbRDaZ5jxmzNY7ojXt85gSHGUTwdlbWH5Ik+OCUY2q14TXliygPGoHwCAvNWS4bQJOqf00g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"fast-check": "^4.6.0",
|
||||
"find-my-way-ts": "^0.1.6",
|
||||
"ini": "^6.0.0",
|
||||
"kubernetes-types": "^1.30.0",
|
||||
"msgpackr": "^1.11.9",
|
||||
"multipasta": "^0.2.7",
|
||||
"toml": "^4.1.1",
|
||||
"uuid": "^13.0.0",
|
||||
"yaml": "^2.8.3"
|
||||
}
|
||||
},
|
||||
"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==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/dubzzz"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fast-check"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pure-rand": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.17.0"
|
||||
}
|
||||
},
|
||||
"node_modules/find-my-way-ts": {
|
||||
"version": "0.1.6",
|
||||
"resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz",
|
||||
"integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ini": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz",
|
||||
"integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/kubernetes-types": {
|
||||
"version": "1.30.0",
|
||||
"resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz",
|
||||
"integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==",
|
||||
"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==",
|
||||
"license": "MIT",
|
||||
"optionalDependencies": {
|
||||
"msgpackr-extract": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/msgpackr-extract": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz",
|
||||
"integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"node-gyp-build-optional-packages": "5.2.2"
|
||||
},
|
||||
"bin": {
|
||||
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/multipasta": {
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz",
|
||||
"integrity": "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-gyp-build-optional-packages": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
|
||||
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"node-gyp-build-optional-packages": "bin.js",
|
||||
"node-gyp-build-optional-packages-optional": "optional.js",
|
||||
"node-gyp-build-optional-packages-test": "build-test.js"
|
||||
}
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pure-rand": {
|
||||
"version": "8.4.0",
|
||||
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz",
|
||||
"integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/dubzzz"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fast-check"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"shebang-regex": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-regex": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/toml": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/toml/-/toml-4.1.1.tgz",
|
||||
"integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "13.0.1",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.1.tgz",
|
||||
"integrity": "sha512-9ezox2roIft6ExBVTVqibSd5dc5/47Sw/uY6b4SjQUT2TzQ0tltNquWA46y4xPQmdZYqvnio22SgWd41M86+jw==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist-node/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"node-which": "bin/node-which"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.3",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
|
||||
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.1.8",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -173,4 +173,4 @@
|
||||
:token "EAAAEO2xSqesDutZz71hz3eulKmrlKTiEqG3uZ4j25x5GYlOluQ2cj2JxNUXqXD7"}}
|
||||
:plaid {:base-url "https://production.plaid.com"
|
||||
:client-id "61bfab05f7e762001b323f79"
|
||||
:secret-key "2be026ca5e7f7e9f23f2fb4d7c914d"}}
|
||||
:secret-key "44a05fbe9f33a2975b3b3ac06b0b62"}}
|
||||
|
||||
@@ -31,5 +31,5 @@
|
||||
:yodlee2-proxy-port 8888
|
||||
:plaid {:base-url "https://production.plaid.com"
|
||||
:client-id "61bfab05f7e762001b323f79"
|
||||
:secret-key "2be026ca5e7f7e9f23f2fb4d7c914d"}
|
||||
:secret-key "44a05fbe9f33a2975b3b3ac06b0b62"}
|
||||
}
|
||||
|
||||
@@ -34,5 +34,5 @@
|
||||
:yodlee2-proxy-port 8888
|
||||
:plaid {:base-url "https://production.plaid.com"
|
||||
:client-id "61bfab05f7e762001b323f79"
|
||||
:secret-key "2be026ca5e7f7e9f23f2fb4d7c914d"}
|
||||
:secret-key "44a05fbe9f33a2975b3b3ac06b0b62"}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
:scheme "https"
|
||||
:dd-env "prod"
|
||||
:dd-service "integreat-app"
|
||||
:jwt-secret "auto ap invoices are awesome"
|
||||
:jwt-secret "rotated secrets are the best"
|
||||
:invoice-import-queue-url "https://sqs.us-east-1.amazonaws.com/679918342773/integreat-mail-prod"
|
||||
:requests-queue-url "https://sqs.us-east-1.amazonaws.com/679918342773/integreat-background-request-prod"
|
||||
:invoice-email "invoices@mail.app.integreatconsult.com"
|
||||
@@ -25,13 +25,13 @@
|
||||
:run-background? false
|
||||
:run-web? true
|
||||
:yodlee2-integreat-user "integreat-main"
|
||||
:yodlee2-client-id "3AATcwfPsWP1rP9oDoo4HvZhtaroGVcA"
|
||||
:yodlee2-client-secret "cXTBmKbGfkaBFIpM"
|
||||
:yodlee2-client-id "lNgzSdjkVXSkXyOBlResXUsCpCpBBDlG"
|
||||
:yodlee2-client-secret "U3qjZP2gErZfbuTPud+LNJF9jHbNRzCWCZbEi6dDiHsziCNwI5yNNNrBAUsnjcu7VFsUVNmkxNKSW85Qf1YMOITC7q0kdv7MGv/ZqRLfQV5odkiPbLHgOrE7UE6//MtjU0jTznGA70WTPS+wwmugg8ArNnx+4QCHrrBrkRfFVOE="
|
||||
:yodlee2-base-url "https://production.api.yodlee.com/ysl"
|
||||
:yodlee2-fastlink "https://fl4.prod.yodlee.com/authenticate/USDevexProd2-319/fastlink/?channelAppName=usdevexprod2"
|
||||
:yodlee2-proxy-host "172.31.10.83"
|
||||
:yodlee2-proxy-port 8888
|
||||
:plaid {:base-url "https://production.plaid.com"
|
||||
:client-id "61bfab05f7e762001b323f79"
|
||||
:secret-key "2be026ca5e7f7e9f23f2fb4d7c914d"}
|
||||
:secret-key "44a05fbe9f33a2975b3b3ac06b0b62"}
|
||||
}
|
||||
|
||||
BIN
dev-resources/Statement1_from_REEL_Produce_Inc.28676.pdf
Normal file
BIN
dev-resources/Statement1_from_REEL_Produce_Inc.28676.pdf
Normal file
Binary file not shown.
115
docs/2026-08-15-remove-voided-orders-risk.md
Normal file
115
docs/2026-08-15-remove-voided-orders-risk.md
Normal file
@@ -0,0 +1,115 @@
|
||||
---
|
||||
title: remove-voided-orders can delete another client's payments
|
||||
type: risk
|
||||
date: 2026-08-15
|
||||
status: open — decide before merging the re-key
|
||||
---
|
||||
|
||||
# `remove-voided-orders` can delete another client's payments
|
||||
|
||||
Measured on the restored backup, 2026-08-15. This risk is **pre-existing** — nothing in the
|
||||
sales-summary work created it — but it is live right now, and the re-key work touches the same
|
||||
data, so it should be understood before merging.
|
||||
|
||||
## The mechanism, in four steps
|
||||
|
||||
**1. Charges are component entities of an order.**
|
||||
|
||||
```clojure
|
||||
;; resources/schema.edn
|
||||
{:db/ident :sales-order/charges
|
||||
:db/valueType :db.type/ref
|
||||
:db/isComponent true ;; <- this is the load-bearing bit
|
||||
:db/cardinality :db.cardinality/many}
|
||||
```
|
||||
|
||||
`:db/isComponent true` tells Datomic the charges *belong to* the order. It is what lets you
|
||||
transact an order with its tenders nested inside, and it means the charges have no independent
|
||||
existence as far as Datomic is concerned.
|
||||
|
||||
**2. `retractEntity` on a component parent deletes the children too.**
|
||||
|
||||
That is the documented behaviour of `:db/retractEntity`: it recursively retracts component
|
||||
values. `square.core3/remove-voided-orders` ends with exactly that:
|
||||
|
||||
```clojure
|
||||
(s/map (fn [[o]]
|
||||
[[:db/retractEntity [:sales-order/external-id (:sales-order/external-id o)]]]))
|
||||
```
|
||||
|
||||
It asks Square for the last 10 days of orders, keeps the ones that should *not* be imported —
|
||||
voided and cancelled orders — and retracts any of those we already stored. That is correct and
|
||||
desirable on its own: a voided order should not sit in the books.
|
||||
|
||||
**3. But one charge can be shared by two orders.**
|
||||
|
||||
When two clients are configured on the same Square location, both import the same Square data.
|
||||
Order keys embed the client, so each client gets its own order entity. Charge keys did **not**
|
||||
embed the client, and `:charge/external-id` is `:db.unique/identity`, so both clients' orders
|
||||
resolved to *the same charge entity*:
|
||||
|
||||
```
|
||||
NGCD order 17592395490523 ──┐
|
||||
├──> charge 17592490524 ← one entity, two parents
|
||||
NGCC order 17592395511722 ──┘
|
||||
```
|
||||
|
||||
**4. So retracting one order deletes a charge the other order still points at.**
|
||||
|
||||
Datomic sees a component and removes it. The surviving order keeps its line items — its sales —
|
||||
but its tender is gone. The day then shows revenue with no payment against it, the summary goes
|
||||
out of balance, and the payment is gone from the current database value. (History retains it, so
|
||||
it is recoverable by someone who knows to look, but nothing in the app will show it again.)
|
||||
|
||||
## How exposed are we
|
||||
|
||||
Measured over the 10 contended clients across 2026-07-13 → 08-14:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Charges examined | 56,829 |
|
||||
| **Referenced by more than one order** | **35,870 (63%)** |
|
||||
|
||||
So this is not a theoretical corner. Roughly two thirds of the charges in that population have
|
||||
two parents, and any voided order among them takes a charge down with it.
|
||||
|
||||
The exposure window for *new* damage is the rolling 10 days `remove-voided-orders` searches, but
|
||||
the shared charges themselves span the whole period the locations were double-configured.
|
||||
|
||||
## What changes after Phase 0 and the re-key, and what doesn't
|
||||
|
||||
- **Phase 0 (done on the restore)** stops new sharing: only one client per location imports now,
|
||||
so no new order pairs form.
|
||||
- **The re-key (done for refunds and the contended clients' charges)** makes sharing structurally
|
||||
impossible going forward, because a charge key now contains the client code.
|
||||
- **Neither retroactively splits the 35,870 charges that are already shared.** They still have two
|
||||
parents. Until they are split, `remove-voided-orders` remains capable of deleting a payment
|
||||
belonging to the other client.
|
||||
|
||||
This is why plan §3.3 forbids retracting anything — including any historical cleanup of the
|
||||
duplicate clients' data — until a verification query shows zero charges with more than one parent.
|
||||
|
||||
## Options, roughly in order of preference
|
||||
|
||||
1. **Split the shared charges, then let removal run normally.** Re-import the affected window now
|
||||
that keys are client-scoped, so each client creates its own charge entity. This reuses the
|
||||
import path rather than hand-constructing component entities. Verify with a query for charges
|
||||
having more than one referencing order; it must reach zero.
|
||||
|
||||
2. **Guard the retraction.** Before retracting an order, check whether any of its charges are
|
||||
referenced by another order; detach those (retract the `:sales-order/charges` ref rather than
|
||||
the charge) and retract the rest. Small, contained change, and it makes the operation safe
|
||||
regardless of what shape the data is in — worth doing on its own merits even after a split.
|
||||
|
||||
3. **Do nothing and accept it.** Only defensible once every location has a single client *and*
|
||||
the historical shared charges are gone. Not true today.
|
||||
|
||||
## What I did about it during the validation run
|
||||
|
||||
I ran the import on the restore with `remove-voided-orders` **skipped**, and ran the other steps
|
||||
(`upsert-locations`, `upsert`, `upsert-payouts`, `upsert-refunds`) normally. That kept the
|
||||
validation faithful to how the import behaves without risking silent payment loss in the data
|
||||
the measurements were about to be taken from.
|
||||
|
||||
**Nothing in the production system has been changed.** This note is about a risk that already
|
||||
exists there.
|
||||
760
docs/2026-08-15-sales-summary-reconciliation.html
Normal file
760
docs/2026-08-15-sales-summary-reconciliation.html
Normal file
@@ -0,0 +1,760 @@
|
||||
<title>Ninety-Day Reconciliation</title>
|
||||
<style>
|
||||
:root {
|
||||
--paper: #F6F8F7; --card: #FFFFFF; --ink: #141F1D; --ink-soft: #4A5C58;
|
||||
--ink-faint: #7C8D89; --rule: #DCE4E1; --accent: #0E5B57; --accent-soft: #E3EFED;
|
||||
--good: #1A6B49; --bad: #A03B26; --warn: #8A6410;
|
||||
--shadow: 0 1px 2px rgba(20,31,29,.06), 0 8px 24px rgba(20,31,29,.05);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--paper: #0E1615; --card: #151F1E; --ink: #E8EFED; --ink-soft: #A3B3AF;
|
||||
--ink-faint: #74847F; --rule: #26332F; --accent: #5FBDB4; --accent-soft: #16302E;
|
||||
--good: #5FBE8C; --bad: #E08A72; --warn: #D6AC55;
|
||||
--shadow: 0 1px 2px rgba(0,0,0,.4), 0 8px 24px rgba(0,0,0,.3);
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
--paper: #0E1615; --card: #151F1E; --ink: #E8EFED; --ink-soft: #A3B3AF;
|
||||
--ink-faint: #74847F; --rule: #26332F; --accent: #5FBDB4; --accent-soft: #16302E;
|
||||
--good: #5FBE8C; --bad: #E08A72; --warn: #D6AC55;
|
||||
--shadow: 0 1px 2px rgba(0,0,0,.4), 0 8px 24px rgba(0,0,0,.3);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { background: var(--paper); color: var(--ink);
|
||||
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
font-size: 16px; line-height: 1.6; margin: 0; padding: 0 20px 96px; }
|
||||
.wrap { max-width: 940px; margin: 0 auto; }
|
||||
.measure { max-width: 66ch; }
|
||||
.num { font-variant-numeric: tabular-nums; }
|
||||
.mono { font-family: ui-monospace, "SF Mono", "Cascadia Code", monospace; font-variant-numeric: tabular-nums; }
|
||||
|
||||
header.masthead { padding: 72px 0 40px; border-bottom: 2px solid var(--ink); display: flex; flex-direction: column; gap: 14px; }
|
||||
.eyebrow { font-size: 12px; letter-spacing: .14em; text-transform: uppercase; color: var(--accent); font-weight: 600; }
|
||||
h1 { font-family: Georgia, "Iowan Old Style", serif; font-size: clamp(34px, 5.4vw, 54px);
|
||||
line-height: 1.08; font-weight: 600; letter-spacing: -.015em; margin: 0; text-wrap: balance; }
|
||||
.standfirst { font-size: 19px; color: var(--ink-soft); margin: 0; max-width: 62ch; }
|
||||
.meta { display: flex; flex-wrap: wrap; gap: 10px 28px; font-size: 13px; color: var(--ink-faint); padding-top: 6px; }
|
||||
.meta b { color: var(--ink-soft); font-weight: 600; }
|
||||
|
||||
section { padding-top: 56px; display: flex; flex-direction: column; gap: 20px; }
|
||||
h2 { font-family: Georgia, "Iowan Old Style", serif; font-size: 27px; font-weight: 600; letter-spacing: -.01em; margin: 0; text-wrap: balance; }
|
||||
h3 { font-size: 14px; letter-spacing: .08em; text-transform: uppercase; color: var(--ink-soft); font-weight: 700; margin: 0; }
|
||||
h4 { font-size: 18px; font-weight: 650; margin: 0; letter-spacing: -.01em; }
|
||||
p { margin: 0; }
|
||||
.measure p + p { margin-top: 14px; }
|
||||
|
||||
.ledger { display: grid; grid-template-columns: 1fr auto 1fr; border: 1px solid var(--rule);
|
||||
border-radius: 4px; background: var(--card); box-shadow: var(--shadow); overflow: hidden; }
|
||||
.ledger > div { padding: 26px 28px; display: flex; flex-direction: column; gap: 6px; }
|
||||
.ledger .arrow { justify-content: center; align-items: center; border-left: 1px solid var(--rule);
|
||||
border-right: 1px solid var(--rule); color: var(--ink-faint); font-size: 22px; background: var(--accent-soft); }
|
||||
.side-label { font-size: 12px; letter-spacing: .12em; text-transform: uppercase; color: var(--ink-faint); font-weight: 600; }
|
||||
.figure { font-size: clamp(28px, 4.4vw, 40px); font-weight: 650; line-height: 1.05; letter-spacing: -.02em; }
|
||||
.figure.after { color: var(--good); }
|
||||
.subfig { font-size: 14px; color: var(--ink-soft); }
|
||||
|
||||
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 14px; }
|
||||
.stat { background: var(--card); border: 1px solid var(--rule); border-radius: 4px; padding: 18px 20px; display: flex; flex-direction: column; gap: 4px; }
|
||||
.stat .k { font-size: 30px; font-weight: 650; letter-spacing: -.02em; line-height: 1; }
|
||||
.stat .l { font-size: 13px; color: var(--ink-soft); }
|
||||
.stat.zero .k { color: var(--good); }
|
||||
|
||||
.problem { border-left: 3px solid var(--accent); padding-left: 24px; display: flex; flex-direction: column; gap: 14px; }
|
||||
.problem.two { border-left-color: var(--warn); }
|
||||
.problem.three { border-left-color: var(--bad); }
|
||||
|
||||
.scroll { overflow-x: auto; border: 1px solid var(--rule); border-radius: 4px; background: var(--card); }
|
||||
table { border-collapse: collapse; width: 100%; font-size: 14.5px; }
|
||||
th, td { padding: 11px 16px; text-align: left; border-bottom: 1px solid var(--rule); white-space: nowrap; }
|
||||
thead th { font-size: 11.5px; letter-spacing: .09em; text-transform: uppercase; color: var(--ink-faint); font-weight: 700; background: var(--accent-soft); }
|
||||
tbody tr:last-child td { border-bottom: none; }
|
||||
td.n, th.n { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
tr.total td { font-weight: 650; background: var(--accent-soft); }
|
||||
.good { color: var(--good); font-weight: 650; }
|
||||
.bad { color: var(--bad); font-weight: 650; }
|
||||
.dim { color: var(--ink-faint); }
|
||||
|
||||
.callout { background: var(--card); border: 1px solid var(--rule); border-left: 3px solid var(--accent);
|
||||
border-radius: 4px; padding: 20px 24px; display: flex; flex-direction: column; gap: 10px; }
|
||||
.callout.warn { border-left-color: var(--warn); }
|
||||
.callout .h { font-weight: 650; }
|
||||
code { font-family: ui-monospace, "SF Mono", monospace; font-size: .9em; background: var(--accent-soft); padding: 1px 5px; border-radius: 3px; }
|
||||
pre { margin: 0; padding: 20px; font-size: 13px; line-height: 1.7; white-space: pre; font-family: ui-monospace, "SF Mono", monospace; }
|
||||
footer { margin-top: 72px; padding-top: 24px; border-top: 1px solid var(--rule); font-size: 13px; color: var(--ink-faint); display: flex; flex-direction: column; gap: 8px; }
|
||||
ul { margin: 0; padding-left: 20px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.tech { font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: var(--accent);
|
||||
font-weight: 700; border: 1px solid var(--accent); border-radius: 3px; padding: 2px 7px; display: inline-block; }
|
||||
</style>
|
||||
|
||||
<div class="wrap">
|
||||
|
||||
<header class="masthead">
|
||||
<div class="eyebrow">Sales summaries · measured on a restored production backup</div>
|
||||
<h1>Ninety-Day Reconciliation</h1>
|
||||
<p class="standfirst">Three faults were leaving restaurant days out of balance — one in the data, two in the arithmetic — and a fourth, found late, that is a missing-data problem wearing a balancing problem's clothes. This is what they were, what they cost, and what fixing them is worth, measured by running the real job over ninety days of real trading, twice: once with the fixes off and once with them on.</p>
|
||||
<div class="meta">
|
||||
<span><b>Window</b> 2026-05-10 → 2026-08-07</span>
|
||||
<span><b>Client-days</b> <span class="num">18,900</span></span>
|
||||
<span><b>Clients</b> <span class="num">210</span></span>
|
||||
<span><b>Nothing in production was changed</b></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section>
|
||||
<div class="ledger">
|
||||
<div>
|
||||
<span class="side-label">Today's calculation, ninety days re-run</span>
|
||||
<span class="figure num">$70,276.50</span>
|
||||
<span class="subfig"><span class="num">1,191</span> days out of balance · <span class="num">93.70%</span> clean</span>
|
||||
</div>
|
||||
<div class="arrow" aria-hidden="true">→</div>
|
||||
<div>
|
||||
<span class="side-label">The same ninety days, fixes on</span>
|
||||
<span class="figure after num">$2,379.45</span>
|
||||
<span class="subfig"><span class="num">122</span> days out of balance · <span class="num">99.35%</span> clean</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat"><span class="k num">1,069</span><span class="l">client-days brought into balance</span></div>
|
||||
<div class="stat zero"><span class="k num">0</span><span class="l">days knocked out of balance</span></div>
|
||||
<div class="stat"><span class="k num">96.6%</span><span class="l">of the variance removed</span></div>
|
||||
<div class="stat zero"><span class="k num">0</span><span class="l">payments shared between two clients</span></div>
|
||||
</div>
|
||||
|
||||
<div class="measure">
|
||||
<p><strong>In one sentence:</strong> a day's sales summary should show the money taken and the money earned agreeing to the penny, and on roughly one trading day in eight it did not — because two clients were fighting over the same records, tips that had been refunded were still counted as income, and service charges customers paid were credited to nothing.</p>
|
||||
<p><strong>How the two figures above were produced.</strong> Both are the real nightly job, run over the same ninety days against the same restored database, writing real summaries each time — the first pass with the fixes switched off, the second with them on. Comparing a re-run against a re-run rather than against production's stored summaries is the stricter test: production's figures are in places months stale, and crediting the fixes with repairing ordinary staleness would flatter them. On that fairer footing the fixes are worth <strong>1,069 days and $67,897.05</strong>, not the larger number a stale baseline would have shown. Both passes ran with the duplicate client records left active, which is how this will actually be deployed.</p>
|
||||
<p><strong>Most of what is left is not a balancing fault at all</strong>, and the section on the fourth problem explains why deliberately leaving it unbalanced is the right call.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>The four problems</h2>
|
||||
|
||||
<div class="problem">
|
||||
<h4>1. Two client records sharing one Square location</h4>
|
||||
<div class="measure">
|
||||
<p><strong>For the business:</strong> ten restaurant locations were set up twice in the system, as two separate clients. Both were importing from Square. Because the two records competed for the same payments and refunds, a refund would belong to one client for twenty minutes, then the other — so a day's books could gain or lose a refund depending on nothing but timing. On 2026-07-23 one client's summary was missing a $71.94 refund entirely, and was out of balance by exactly that amount.</p>
|
||||
<p><span class="tech">technical</span> Sales orders scoped their identifier by client (<code>square/order/<code>-<loc>-<id></code>), but refunds, card charges, payouts and cash-drawer shifts did not — they used the bare Square id. Those attributes are <code>:db.unique/identity</code>, so both clients' imports resolved to a single entity and the last writer won.</p>
|
||||
<p>Reading ownership out of the database's own history, this had actually happened to <strong>3,387 refunds, 4,069 payouts and 2,628 cash-drawer shifts</strong>. And it has involved <strong>19 client pairs, of which only 10 are visible in today's configuration</strong> — nine more contended in the past and the configuration has since changed, so no point-in-time check would find them.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="problem two">
|
||||
<h4>2. One payment record owned by two orders</h4>
|
||||
<div class="measure">
|
||||
<p><strong>For the business:</strong> the same collision meant a single card payment could be attached to both clients' copies of an order. That is worse than untidy. The nightly import removes orders Square reports as voided, and removing an order also removes its payments — so cancelling one client's order could silently delete the <em>other</em> client's payment, leaving a day showing sales with no money against them.</p>
|
||||
<p><span class="tech">technical</span> <code>:sales-order/charges</code> is declared <code>:db/isComponent true</code>, so <code>[:db/retractEntity <order>]</code> cascades into the charges. In a 20,000-order sample of the affected clients, <strong>11,469 charges had two parent orders</strong>. This is why <code>remove-voided-orders</code> was left switched off during testing.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="problem three">
|
||||
<h4>3. Tips refunded, and service charges credited nowhere</h4>
|
||||
<div class="measure">
|
||||
<p><strong>For the business:</strong> two arithmetic faults, both of which overstated or understated a day.</p>
|
||||
<ul>
|
||||
<li><strong>Refunded tips stayed on the books.</strong> When a guest was refunded, the tip came back too — but the summary still counted the original tip as income. On one NGLK day the books credited $482.94 of tips beside a $60.00 refund of that very tip.</li>
|
||||
<li><strong>Service charges were collected but never earned.</strong> A catering or auto-gratuity charge is inside the card payment the customer makes, so it arrived as money taken — but no line recorded it as money earned. One NTPT order carried $427.10 that was credited to nothing at all; the largest single instance was <strong>$1,344.86</strong> in one day.</li>
|
||||
</ul>
|
||||
<p><span class="tech">technical</span> <code>get-tip</code> summed tips by joining through <code>:sales-order/charges</code>, so a return-only order — which has no tender to join through — contributed nothing, while its reversal sat unread on <code>:sales-order/tip</code>. Nothing at all read <code>:sales-order/service-charge</code>.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="problem">
|
||||
<h4>4. Refunds on records whose sales were never imported <span class="tech">not fixed — deliberately</span></h4>
|
||||
<div class="measure">
|
||||
<p><strong>This is why the duplicated restaurants looked so much worse than everyone else.</strong> Of the days still failing after the first three fixes, <strong>155 of the 423 on shared-location records had no sales orders at all</strong> — the summary consisted of nothing but refunds and their fees, with no sales for them to reduce.</p>
|
||||
<p>The obvious reading is that the refund simply settled on a closed day. It is the wrong one. Checking each of those days against the date its client first recorded <em>any</em> order shows <strong>140 of 171 fall before that client had a single order in the system</strong> — for seven of the nine records affected, every single one does. These are not quiet days. They are periods where the sales were never imported at all.</p>
|
||||
<p><strong>Where the refunds came from.</strong> Reading the database's own ownership history settles it. A $35.35 refund dated 26 February belonged to <span class="mono">NGDG</span> that same day, and was taken over by <span class="mono">NGDU</span> on 12 August. Others flip between the two records several times a day across 12–15 August. <span class="mono">NGDU</span>'s first order is 2 August; it holds 94 refunds dated before it existed as a trading record. It never made them — it inherited seven months of the other record's refunds, because the refund key carried no client and whichever import ran last took ownership. That is fault 1, seen from the other end.</p>
|
||||
<p>Across the nine records, <strong>660 refunds worth $15,237.02 sit on a record dated before that record's first order.</strong> Nothing is lost and nothing is double-counted — the money is real and the surviving record has its own copy — but it is filed against a set of books that has no sales to put it against.</p>
|
||||
<p><strong>Why it is deliberately left out of balance.</strong> The day can be closed in one line: book a return equal to the day's refunds whenever the client recorded no sales. It is safe by construction — no trading day could be touched — and on this data it closes 16 of the 122 remaining days and $1,227.65. It was built, measured, and then removed, because it is the wrong thing to do. An unbalanced day is the only visible signal that a restaurant's sales are not being imported. Making the arithmetic agree would remove the alarm and leave the fire.</p>
|
||||
<p><span class="tech">technical</span> <code>get-returns</code> sums <code>:sales-order/returns</code> over orders scanned for the date. With no orders the sum is nil and no <code>Returns</code> line is written, while <code>get-refund-items</code> still credits <code>Card Refunds</code> from the <code>sales-refund</code> records. The imbalance is the correct output for the input; the input is what is wrong. A test now pins this behaviour in place so it is not "fixed" by someone reading only the arithmetic.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>What the fixes actually are</h2>
|
||||
<div class="measure">
|
||||
<p>Five changes. The first three stop two clients from sharing a record; the last two record
|
||||
money that was being collected but not booked. Each is small — the difficulty was knowing
|
||||
which line to change, not writing it. A sixth was written and then removed; it is described
|
||||
at the end because the reasoning matters more than the code did.</p>
|
||||
</div>
|
||||
|
||||
<h3>1 · Put the client in the record's name</h3>
|
||||
<div class="measure">
|
||||
<p>Every imported record has an identifier the importer uses to decide "have I seen this
|
||||
before?". Sales orders already included the client; refunds, card payments, payouts and
|
||||
cash-drawer shifts did not, which is precisely why two clients could land on one record.</p>
|
||||
</div>
|
||||
<div class="scroll">
|
||||
<pre><span class="dim">;; before — the bare Square id, identical for both clients</span>
|
||||
(str "square/refund/" (:id r)) <span class="dim">;; square/refund/NOkQOTIiJULWN6…</span>
|
||||
|
||||
<span class="dim">;; after</span>
|
||||
(scoped-key "square/refund/" client location (:id r))
|
||||
<span class="dim">;; square/refund/NGCD-CD-NOkQOTIiJULWN6…</span>
|
||||
|
||||
(defn scoped-key [prefix client location id]
|
||||
(str prefix (:client/code client) "-"
|
||||
(:square-location/client-location location) "-" id))</pre>
|
||||
</div>
|
||||
<div class="measure">
|
||||
<p>Applied at five places in the Square importer: order payments, refunds, payouts (twice —
|
||||
the record itself and the lookup that finds it) and cash-drawer shifts. ezCater orders
|
||||
already did this and needed no change.</p>
|
||||
</div>
|
||||
|
||||
<h3>2 · Find the existing record before writing, under either name</h3>
|
||||
<div class="measure">
|
||||
<p>This is the one that makes the change safe to deploy. The identifiers are unique keys, so
|
||||
the importer relies on "same id, same record". Rename them and the next import matches
|
||||
nothing — and would quietly create a <em>second</em> copy of every refund and payment in the
|
||||
system, leaving the originals orphaned. So the importer looks up the record explicitly,
|
||||
new name first, old name second, and writes to whichever it finds.</p>
|
||||
</div>
|
||||
<div class="scroll">
|
||||
<pre>(defn existing-id [db attr prefix client location id]
|
||||
(when id
|
||||
(or (dc/entid db [attr (scoped-key prefix client location id)]) <span class="dim">;; new scheme</span>
|
||||
(dc/entid db [attr (str prefix id)])))) <span class="dim">;; legacy scheme</span></pre>
|
||||
</div>
|
||||
<div class="measure">
|
||||
<p>The result is pinned as the record's id on the way in, so the write lands on the existing
|
||||
row regardless of which name it currently carries. The proof this worked is a count that did
|
||||
not move. Every one of the 265,965 refunds, payouts and cash-drawer shifts in the database was
|
||||
re-named, and afterwards there were still exactly <strong>50,986 refunds, 144,688 payouts and
|
||||
69,291 cash-drawer shifts</strong> — the same three figures as at the restore point.
|
||||
Had the fallback lookup been missing, each of these would have doubled instead.</p>
|
||||
<p><strong>The fallback also has to refuse.</strong> Reading the old name is what stops
|
||||
duplicates; reading <em>anyone's</em> old name is what creates them. Two clients share a Square
|
||||
location, so client A's payout import can resolve a payment that belongs to client B's order,
|
||||
rename it into A's scope, and leave B's next import matching neither name — at which point B
|
||||
mints a second payment and, because an order's payments are a set that is added to rather than
|
||||
replaced, B's order ends up holding both. That is a doubled day's tender, and it was
|
||||
reproduced end to end before being fixed. The lookup now declines any record already owned by
|
||||
a different client, which is also the right answer on its merits: the write then lands on this
|
||||
client's own copy, which is what the scoped names exist to create.</p>
|
||||
<p>This is transitional. Once no legacy names remain, the fallback and the refusal are deleted
|
||||
together and the guarantee stops depending on either.</p>
|
||||
</div>
|
||||
|
||||
<h3>3 · Give every order its own payment record</h3>
|
||||
<div class="measure">
|
||||
<p>Renaming stops <em>new</em> collisions but does not undo old ones: a payment already shared
|
||||
by two orders is still one row with two owners. The migration walks each order's payments and,
|
||||
where another order has already claimed one, makes that order its own copy with the same
|
||||
amounts and points the order at the copy.</p>
|
||||
</div>
|
||||
<div class="scroll">
|
||||
<pre><span class="dim">;; for each order, for each of its payments:</span>
|
||||
:keep <span class="dim">→</span> first order to claim it; rename in place
|
||||
:clone <span class="dim">→</span> copy type, total, tip, tax, date, processor, note, receipt link
|
||||
set the copy's client and location to this order's
|
||||
retract this order's link to the shared payment
|
||||
link it to the copy instead</pre>
|
||||
</div>
|
||||
<div class="measure">
|
||||
<p>Run over the whole database that was <strong>16,236,839 renamed and 500,438 copied</strong>,
|
||||
and payments owned by two orders went from 11,469 in a 20,000-order sample to zero across
|
||||
every order of the last year. The record count rose by about 500,438 — the number of copies it
|
||||
reported making, which is the check that it created what it meant to and nothing else.</p>
|
||||
<p>One subtlety worth recording, because it bit us: the Square id has to be recovered from the
|
||||
record's current owner rather than by trimming a fixed prefix. Client codes contain dashes —
|
||||
<span class="mono">N-30003</span> — so a pattern cannot tell where the client name ends and the
|
||||
Square id begins. Getting this wrong scoped some records twice and doubled their tender.</p>
|
||||
</div>
|
||||
|
||||
<h3>4 · Count tips that were handed back</h3>
|
||||
<div class="measure">
|
||||
<p>Tips were summed by walking from the order to its payments. A refund-only order has no
|
||||
payment attached, so its negative tip was invisible. The fix adds those tips rather than
|
||||
replacing the calculation.</p>
|
||||
</div>
|
||||
<div class="scroll">
|
||||
<pre><span class="dim">;; before</span>
|
||||
:ledger-mapped/amount (tendered-tip c date)
|
||||
|
||||
<span class="dim">;; after</span>
|
||||
:ledger-mapped/amount (+ (tendered-tip c date)
|
||||
(untendered-tip c date))
|
||||
|
||||
<span class="dim">;; untendered-tip — tips on orders with no payment attached</span>
|
||||
[?e :sales-order/tip ?tip]
|
||||
(not [?e :sales-order/charges])</pre>
|
||||
</div>
|
||||
<div class="measure">
|
||||
<p><strong>Adding rather than replacing is deliberate.</strong> Where an order does have a
|
||||
payment, the payment is the correct source: real orders exist whose payment carries a tip the
|
||||
order does not — an auto-gratuity recorded as a service charge, or a wallet tip missing from
|
||||
the order totals. Reading the order instead would have dropped those. Three tests hold this
|
||||
in place: the refund case must change, and the tendered and ordinary cases must not.</p>
|
||||
</div>
|
||||
|
||||
<h3>5 · Credit Square service charges, both signs</h3>
|
||||
<div class="measure">
|
||||
<p>Nothing read the service-charge field at all. A new line credits it, for Square orders only
|
||||
and for negative amounts as well as positive.</p>
|
||||
</div>
|
||||
<div class="scroll">
|
||||
<pre>[?e :sales-order/service-charge ?service-charge]
|
||||
(or-join [?e]
|
||||
[?e :sales-order/vendor :vendor/ccp-square]
|
||||
(and (not [?e :sales-order/vendor])
|
||||
[?e :sales-order/external-id ?external-id]
|
||||
[(clojure.string/starts-with? ?external-id "square/order/")]))</pre>
|
||||
</div>
|
||||
<div class="measure">
|
||||
<p><strong>Why the vendor test has two branches.</strong> ezCater service charges are commission
|
||||
the platform deducts from the restaurant, not money the diner hands over, so crediting them
|
||||
would make a day worse rather than better — hence the Square-only condition. But whole eras of
|
||||
Square orders carry no vendor field at all, and a test on vendor alone would silently credit
|
||||
nothing. The second branch falls back to the order's own identifier.</p>
|
||||
<p><strong>Why negatives matter.</strong> A returned catering fee arrives as a negative service
|
||||
charge and is already deducted from the day's returns; dropping negatives would lose the
|
||||
reversal. The line sits behind a per-client switch, off by default, so it can be turned on a
|
||||
few restaurants at a time.</p>
|
||||
</div>
|
||||
|
||||
<h3>6 · The change that was written, measured, and then taken out</h3>
|
||||
<div class="measure">
|
||||
<p>Worth recording, because the arithmetic case for it is good and someone will propose it
|
||||
again. Where a day has refunds and no sales orders whatsoever, book a <code>Returns</code>
|
||||
debit equal to that day's refunds:</p>
|
||||
</div>
|
||||
<div class="scroll">
|
||||
<pre>(defn- refund-only-returns [c date]
|
||||
(when-not (traded? c date)
|
||||
(let [amount (refunded-total c date)]
|
||||
(when-not (zero? amount) amount))))</pre>
|
||||
</div>
|
||||
<div class="measure">
|
||||
<p>It works. Measured over the same ninety days it closed <strong>171 days and $5,795.18</strong>,
|
||||
knocked nothing out of balance, and altered no already-balanced day — the guard makes it
|
||||
incapable of touching a day that traded.</p>
|
||||
<p>It was removed anyway. Those days are not quiet days; they are days whose sales were never
|
||||
imported, and closing them removes the only visible sign of that. What is left in the code is
|
||||
a comment saying so and a test asserting the day <em>stays</em> out of balance, so the next
|
||||
person to notice the arithmetic finds the reasoning before they find the fix.</p>
|
||||
</div>
|
||||
|
||||
<h3>Supporting changes</h3>
|
||||
<div class="scroll">
|
||||
<table>
|
||||
<thead><tr><th>Change</th><th>Why</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Log each day's imbalance and its suspect lines</td><td>an out-of-balance day was only visible by opening the screen; now it can be queried</td></tr>
|
||||
<tr><td>Stop the dirty-summary scan at the client boundary</td><td>it read every later client's summaries too — 1,321 ms to 5.6 ms per client</td></tr>
|
||||
<tr><td>Split the recompute driver into a per-client function</td><td>lets a backfill spread clients across threads instead of grinding one at a time</td></tr>
|
||||
<tr><td>Install schema attributes before the tuples that compose them</td><td>the test suite could not build an empty database at all, so no test could run</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="measure">
|
||||
<p>That last one is worth a sentence for engineers: <code>transact-schema</code> installed
|
||||
schema.edn then cloud-migration-schema.edn, but a composite tuple in the first file is built
|
||||
from an attribute in the second. Datomic will not create a tuple before its members exist, so
|
||||
every test fixture died in setup. It is very likely why sales summaries had no tests before
|
||||
this work.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>What each fix is worth</h2>
|
||||
<div class="measure">
|
||||
<p>The job was run over the same ninety days at each stage, writing real summaries every time, so these are measured outcomes rather than estimates. All 18,900 client-day summaries in the window are included, whether or not the restaurant traded that day.</p>
|
||||
</div>
|
||||
<div class="scroll">
|
||||
<table>
|
||||
<thead><tr><th>Stage</th><th class="n">Days out of balance</th><th class="n">Clean</th><th class="n">Total variance</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Today's calculation, ninety days re-run</td><td class="n">1,191</td><td class="n">93.70%</td><td class="n">$70,276.50</td></tr>
|
||||
<tr><td>+ refunded tips</td><td class="n">890</td><td class="n">95.29%</td><td class="n">$67,032.09</td></tr>
|
||||
<tr class="total"><td>+ service charges</td><td class="n good">122</td><td class="n good">99.35%</td><td class="n good">$2,379.45</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="measure">
|
||||
<p><strong>Deduplication is not a row in this table, and that is deliberate.</strong> Separating the shared records is a change to the data, not to the arithmetic, and it had already been carried out before either pass ran — so both the baseline and the result above are computed on repaired data, and neither is credited with it. Its effect is shown structurally instead, further down: payments owned by two clients went to zero and stayed there. The consequence for reading this table is that <strong>$66,589.75 is what the three arithmetic fixes are worth on their own</strong>, with the deduplication's contribution already banked in the starting figure rather than added to the improvement.</p>
|
||||
</div>
|
||||
|
||||
<h3>Day-by-day effect of each change</h3>
|
||||
<div class="scroll">
|
||||
<table>
|
||||
<thead><tr><th>Change</th><th class="n">Unchanged</th><th class="n">Into balance</th><th class="n">Out of balance</th><th class="n">Balanced days altered</th><th class="n">Money moved</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Refunded tips</td><td class="n">18,571</td><td class="n good">301</td><td class="n good">0</td><td class="n good">0</td><td class="n">$4,027.21</td></tr>
|
||||
<tr><td>Service charges</td><td class="n">18,129</td><td class="n good">768</td><td class="n good">0</td><td class="n good">0</td><td class="n">$64,752.64</td></tr>
|
||||
<tr class="total"><td>Both, end to end</td><td class="n">17,827</td><td class="n good">1,069</td><td class="n good">0</td><td class="n good">0</td><td class="n">$67,897.05</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="measure">
|
||||
<p><strong>Neither fix touched a day that was already correct.</strong> Across all 18,900
|
||||
client-days no balanced day was knocked out of balance, and no balanced day had a single
|
||||
figure altered — 17,827 summaries came out byte-identical, and every one of the 1,073 that
|
||||
moved was already wrong.</p>
|
||||
<p>That claim did not hold on the first attempt, and how it was recovered is the useful part.
|
||||
Measured before the historical backfill described below, six days broke — all of them a tip
|
||||
reversed on one record whose refund sat on its twin, so removing the un-reversed tip left the
|
||||
day short by exactly that amount. Replaying the window from Square gave both records their own
|
||||
copy of every refund, and all six closed. The fix was never wrong; it was reading half a
|
||||
transaction.</p>
|
||||
<p>Service charges are by far the larger of the two fixes, moving $58,923.85 against the tip fix's $3,777.67.</p>
|
||||
<p>That claim is stronger than a balance check, and it is the one worth insisting on: a day can stay balanced while its individual lines move, which would still be a change to the books. Every line of every summary was compared — category, debit or credit side, amount to the cent, and account — not just the day's bottom line.</p>
|
||||
<p><strong>The two fixes account for every day they moved, exactly.</strong> Adding up the untendered-tip and service-charge amounts across all 984 changed days leaves a residue of <span class="mono">0.0000000002</span>. Nothing else moved those days; there is no unexplained remainder hiding a third effect, and the six that broke are accounted for by the same arithmetic as the 915 that healed.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>What it looks like on the page</h2>
|
||||
<div class="measure">
|
||||
<p>Both arithmetic fixes add exactly one credit line. Nothing else in a summary moves — no sales figure, no payment, no tax.</p>
|
||||
</div>
|
||||
|
||||
<h3>A refunded tip — NGLK, 2026-08-04</h3>
|
||||
<div class="scroll">
|
||||
<table>
|
||||
<thead><tr><th>Line</th><th class="n">Before</th><th class="n">After</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><strong>Tip</strong></td><td class="n">482.94</td><td class="n">422.94</td></tr>
|
||||
<tr><td class="dim">Card Refunds</td><td class="n dim">60.00</td><td class="n dim">60.00</td></tr>
|
||||
<tr><td>Total money taken</td><td class="n">10,094.81</td><td class="n">10,094.81</td></tr>
|
||||
<tr><td>Total money earned</td><td class="n">10,154.81</td><td class="n">10,094.81</td></tr>
|
||||
<tr class="total"><td>Out of balance by</td><td class="n bad">−60.00</td><td class="n good">0.00</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="measure">
|
||||
<p>The day already carried a $60.00 card refund — the guest was given their money back, tip included — while the tip line still credited the full $482.94. The corrected figure matches the refund to the penny. The order behind it is <span class="mono">square/order/NGLK-SM-OxSX9gpXJV394qqT8mnBGypUwKNZY</span>: a tip of −60.00 on an order with no payment attached at all.</p>
|
||||
</div>
|
||||
|
||||
<h3>A service charge — NTPT, 2026-08-06</h3>
|
||||
<div class="scroll">
|
||||
<table>
|
||||
<thead><tr><th>Line</th><th class="n">Before</th><th class="n">After</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><strong>Service Charges</strong></td><td class="n bad">not shown</td><td class="n">427.10</td></tr>
|
||||
<tr><td class="dim">Card Payments</td><td class="n dim">4,975.89</td><td class="n dim">4,975.89</td></tr>
|
||||
<tr><td>Total money taken</td><td class="n">7,777.20</td><td class="n">7,777.20</td></tr>
|
||||
<tr><td>Total money earned</td><td class="n">7,350.10</td><td class="n">7,777.20</td></tr>
|
||||
<tr class="total"><td>Out of balance by</td><td class="n bad">+427.10</td><td class="n good">0.00</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3>The largest repairs of each kind</h3>
|
||||
<div class="scroll">
|
||||
<table>
|
||||
<thead><tr><th>Client</th><th>Date</th><th>Line</th><th class="n">Before</th><th class="n">After</th><th class="n">Day closed</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="mono">NGPA</td><td>2026-06-04</td><td>Service Charges</td><td class="n bad">not shown</td><td class="n">1,344.86</td><td class="n good">+1,344.86 → 0</td></tr>
|
||||
<tr><td class="mono">NTPT</td><td>2026-08-06</td><td>Service Charges</td><td class="n bad">not shown</td><td class="n">427.10</td><td class="n good">+427.10 → 0</td></tr>
|
||||
<tr><td class="mono">N-30003</td><td>2026-05-27</td><td>Service Charges</td><td class="n bad">not shown</td><td class="n">405.83</td><td class="n good">+405.83 → 0</td></tr>
|
||||
<tr><td class="mono">NGFL</td><td>2026-05-19</td><td>Tip</td><td class="n">238.46</td><td class="n">70.42</td><td class="n good">−168.04 → 0</td></tr>
|
||||
<tr><td class="mono">NGMI</td><td>2026-07-09</td><td>Tip</td><td class="n">230.01</td><td class="n">80.01</td><td class="n good">−150.00 → 0</td></tr>
|
||||
<tr><td class="mono">NGVA</td><td>2026-07-03</td><td>Tip</td><td class="n">152.66</td><td class="n">40.12</td><td class="n good">−112.54 → 0</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="measure">
|
||||
<p>In every case the correction equals the imbalance exactly, which is what you would expect if the fix is recording something real that was recorded nowhere. On NGNP 2026-06-25 both fixes land on one day and pull opposite ways — $301.40 credited, $1.80 removed, $299.60 closed — a useful check that they are independent.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>What was done to the data, and how it was checked</h2>
|
||||
<div class="measure">
|
||||
<p>Every step below was performed against a restored copy of the production database. Production itself was never touched.</p>
|
||||
</div>
|
||||
<div class="scroll">
|
||||
<table>
|
||||
<thead><tr><th>Step</th><th>Result</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Walk every order in the database, newest month first</td><td class="n">19,040,785 orders · 38 minutes</td></tr>
|
||||
<tr><td>Give every order its own payment record</td><td class="n">16,236,839 re-keyed · 500,438 copied</td></tr>
|
||||
<tr><td><strong>Payments owned by two orders</strong></td><td class="n good">0 <span class="dim">across every order of the last year — 5,158,470</span></td></tr>
|
||||
<tr><td>Client-scope refunds, payouts and cash-drawer shifts</td><td class="n good">counts unchanged · 0 collisions</td></tr>
|
||||
<tr><td>Live Square import afterwards</td><td class="n good">0 orders with duplicated payment · 0 shared payments</td></tr>
|
||||
<tr><td>Ownership changes after the change</td><td class="n good">0 refunds · 0 payouts · 0 shifts</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="measure">
|
||||
<p>The count checks are the ones that matter. If re-keying had gone wrong it would have created a second copy of every record rather than updating the existing one, and the totals would have doubled. They did not move. The payment-copy step is the exception and is meant to add records — it added 500,438, matching the number of copies it reported making. (Close, not exact: the counter increments while the transaction is being assembled, so two copies that resolve onto one entity are counted twice. It is a good check, not a proof.)</p>
|
||||
<p><strong>The measurement above was taken with both client records of each pair left live, which is how this deploys.</strong> Nothing is deactivated and no business decision about which restaurant's history survives is needed. The risk that opens is narrow and specific: while any record still carries a legacy key, a second client can resolve onto it. That is why the deployment runs the migration with imports paused, and why <code>existing-id</code> now refuses to resolve a record belonging to another client.</p>
|
||||
</div>
|
||||
|
||||
<div class="callout">
|
||||
<span class="h">The whole analysis was run again from nothing, and landed in the same place</span>
|
||||
<p>Everything above was rebuilt from a fresh restore of the production backup, several times over, each time from the backup point itself rather than from a database an earlier run had touched: restore, re-key and split across all nineteen million orders, then two full ninety-day recomputes. The runs used deliberately different preparation — one deactivated the duplicate records, one left them live and untouched, one backfilled their history from Square — so their headline figures differ, and comparing them is how the recommendation below was reached. What did <strong>not</strong> move is the part that should not: for the 190 clients that do not share a Square location the residue is 119 days and $1,730.61 in every run, with the same five restaurants accounting for it. The arithmetic fixes behave identically no matter what is done to the duplicates, which is a stronger check on them than any single measurement.</p>
|
||||
</div>
|
||||
|
||||
<div class="callout warn">
|
||||
<span class="h">A bug in this work, found by measuring rather than reading</span>
|
||||
<p>The first attempt at copying shared payments derived each payment's Square identifier by stripping a fixed prefix. That is right the first time a payment is seen, but once it has been re-keyed to one client, a second order meeting it later read the already-scoped key as the identifier and scoped it twice — <span class="mono">NGCD-CD-NGCC-CC-<id></span>. The importer then created a fresh payment, doubling the tender on five clients by $3,000–$7,000 each. It was caught because the totals were absurd, not because the code looked wrong. The fix recovers the scope from the record itself; client codes contain dashes, so it cannot be done by pattern. A test now runs the step one order at a time, which is the arrangement that exposes it.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>What is still out of balance</h2>
|
||||
<div class="measure">
|
||||
<p>122 client-days out of 18,900, totalling <strong>$2,379.45</strong> — and only 32 of
|
||||
those are above ten cents.</p>
|
||||
</div>
|
||||
<div class="scroll">
|
||||
<table>
|
||||
<thead><tr><th>Where the remainder sits</th><th class="n">Days</th><th class="n">Variance</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>The twenty records that share a Square location</td><td class="n good">3</td><td class="n good">$648.84</td></tr>
|
||||
<tr class="total"><td>Every other client — 190 of the 210</td><td class="n">119</td><td class="n">$1,730.61</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="measure">
|
||||
<p><strong>The shared-location records are now the clean part of the book.</strong> Three days
|
||||
between all twenty of them: NGBK and NGBR at $299.42 each on 2026-08-06, which is the Square
|
||||
tender-versus-order-total gap described below and not an attribution fault, and NGDA at $50.00,
|
||||
an auto-gratuity booked as a service charge. Before the backfill those same records carried
|
||||
423 days and $18,508.39.</p>
|
||||
<p><strong>The other 119 days have not moved across any run of this analysis.</strong> Four
|
||||
separate rebuilds — different databases, different preparation, one with the duplicates
|
||||
deactivated and one without — all land on 119 days and $1,730.61, with the same five
|
||||
restaurants accounting for almost all of it:</p>
|
||||
</div>
|
||||
<div class="scroll">
|
||||
<table>
|
||||
<thead><tr><th>Client</th><th class="n">Days</th><th class="n">Variance</th><th>What it is</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="mono">NG4S</td><td class="n">10</td><td class="n">$1,066.61</td><td>refunds arriving for a record with no sales imported — the fourth problem</td></tr>
|
||||
<tr><td class="mono">NGMV</td><td class="n">5</td><td class="n">$259.38</td><td>late May, undiagnosed</td></tr>
|
||||
<tr><td class="mono">NGEB</td><td class="n">4</td><td class="n">$199.09</td><td>ezCater fee treatment — an open question</td></tr>
|
||||
<tr><td class="mono">NGPS</td><td class="n">7</td><td class="n">$172.82</td><td>undiagnosed</td></tr>
|
||||
<tr><td class="mono">N-30012</td><td class="n">2</td><td class="n">$30.31</td><td>late May, undiagnosed</td></tr>
|
||||
<tr class="total"><td class="dim">everyone else</td><td class="n dim">91</td><td class="n dim">$2.40</td><td class="dim">till rounding — pennies a day</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="measure">
|
||||
<p>Sixteen of the 122 are days a record had no sales imported at all, worth $1,227.65 — the
|
||||
fourth problem, still deliberately visible. The clusters on NGMV, NGPS and NGEB are
|
||||
unexplained and worth a look, though at under $650 across sixteen days they are no longer
|
||||
urgent.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Making the duplicated restaurants match</h2>
|
||||
<div class="measure">
|
||||
<p>Re-keying stops the two records fighting, but on its own it does not make them equal, and
|
||||
the difference is worth stating plainly because it decides whether the books close.</p>
|
||||
<p><strong>Orders were always duplicated; refunds never were.</strong> A sales order's
|
||||
identifier has always carried its client, so each of the two records built its own order
|
||||
history from the start. Refunds, payouts and cash-drawer shifts did not, so only ONE record
|
||||
holds each of them — whichever imported it last. The migration freezes that ownership rather
|
||||
than evening it out. The record left without them shows returns from its own orders and no
|
||||
refunds to set against them, and is out of balance by exactly the amount its twin is holding.</p>
|
||||
<p>On 2026-05-11 both NGBK and NGBR held the same 221 orders. NGBK had no refunds; NGBR had
|
||||
two, worth $2,232.29; and NGBK's books were out by $2,232.29 to the cent. Across the whole
|
||||
database NGBK held <strong>158,535 orders and five refunds</strong>.</p>
|
||||
</div>
|
||||
<div class="callout">
|
||||
<span class="h">The fix is to ask Square again, not to manufacture copies</span>
|
||||
<p>With client-scoped keys in place, every record now creates its own copy of whatever it
|
||||
reads. So replaying the window from Square is all that is needed: each record imports the same
|
||||
refunds independently and the two histories converge, without any code inventing a duplicate
|
||||
and having to be trusted about it. <code>backfill-history</code> does exactly that for a date
|
||||
range, and after it every one of the ten pairs held matching order and refund counts.</p>
|
||||
<p>It closed <strong>420 of the 423 days</strong> the shared records were carrying, and
|
||||
$17,859.55 of the $18,508.39. It is also what recovered the zero-regression guarantee above.</p>
|
||||
</div>
|
||||
<div class="callout warn">
|
||||
<span class="h">One capped read, found by doing this</span>
|
||||
<p>The refunds import asked Square for a location's refunds and read the first page of the
|
||||
answer — no cursor, no date range. Square pages at a hundred, so a location with more than a
|
||||
hundred refunds silently returned a hundred, and the response looked complete. That is why the
|
||||
twins each held almost exactly 100 refunds, and why an earlier import added exactly 1,000
|
||||
across ten locations. Following the cursor is a few lines; the reason it went unnoticed for so
|
||||
long is that a capped list is indistinguishable from a short one.</p>
|
||||
</div>
|
||||
<div class="measure">
|
||||
<p><strong>This turned out to be a better answer than retiring the duplicate records.</strong>
|
||||
An earlier measurement that deactivated one record of each pair left 279 days and $7,790.54.
|
||||
Backfilling instead, with both records live, leaves <strong>122 days and $2,379.45</strong> —
|
||||
and it needs no business decision about which restaurant's history to abandon.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>How complete is this</h2>
|
||||
<div class="measure">
|
||||
<p>The importer understands both the old and new record names, so the change can be deployed
|
||||
before the renaming finishes. That tolerance is a bridge, not a destination — while any
|
||||
record still carries an unscoped name, two clients can land on it and the guarantee rests on
|
||||
a convention rather than on the data. So the renaming was run to completion and measured.</p>
|
||||
</div>
|
||||
<div class="scroll">
|
||||
<table>
|
||||
<thead><tr><th>Record type</th><th class="n">Total</th><th class="n">Client-scoped</th><th class="n">Still to rename</th><th class="n">Cannot be scoped</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Card payments</td><td class="n">17,045,933</td><td class="n good">17,045,933</td><td class="n good">0</td><td class="n good">0</td></tr>
|
||||
<tr><td>Refunds</td><td class="n">50,986</td><td class="n good">50,986</td><td class="n good">0</td><td class="n good">0</td></tr>
|
||||
<tr><td>Payouts</td><td class="n">144,688</td><td class="n good">144,652</td><td class="n good">0</td><td class="n">36</td></tr>
|
||||
<tr><td>Cash-drawer shifts</td><td class="n">69,291</td><td class="n good">69,291</td><td class="n good">0</td><td class="n good">0</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="measure">
|
||||
<p>Every record in the database now carries its owner's name, and the migration proposes no
|
||||
further changes: asked what is left to do, it answers zero on all four record types. The 36
|
||||
payouts are ones with no client or location recorded anywhere, on the record itself or on
|
||||
anything referring to it, so there is nothing to name them after.</p>
|
||||
<p>Renaming had to be driven from orders, because a payment's rightful owner is whichever
|
||||
order refers to it — so completing it meant walking all <strong>19,040,785 orders</strong>, not
|
||||
just the clients that look shared today. Nine client pairs contended in the past without
|
||||
sharing a location now, and a migration scoped to the current configuration would have missed
|
||||
every one of them.</p>
|
||||
<p><strong>A caution about how completeness is counted.</strong> 282,649 card payments carry no
|
||||
client attribute of their own — they are stubs the payout path creates, never referenced by an
|
||||
order. A gate that checks the attribute reports these as "no owner" and looks like a gap. They
|
||||
are not: their names are scoped, recovered from the deposit that holds them. The figures above
|
||||
are counted the harder way, by asking the migration what it would still change, which resolves
|
||||
each record's owner through whatever refers to it. Reading the attribute alone would have
|
||||
understated completeness by a quarter of a million records — and an early draft of this report
|
||||
did exactly that.</p>
|
||||
</div>
|
||||
|
||||
<div class="scroll">
|
||||
<table>
|
||||
<thead><tr><th>Shared payments after the migration</th><th class="n">Count</th><th>Meaning</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><strong>Owned by more than one order</strong></td><td class="n good">0</td><td>whether the orders belong to different clients or the same one</td></tr>
|
||||
<tr><td class="dim">checked across</td><td class="n dim">400,000 orders</td><td class="dim">spread through the whole database</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="measure">
|
||||
<p>The problem this work exists to solve is gone: no payment answers to two orders, so the
|
||||
component relationship means what it says and deleting an order can no longer take another
|
||||
order's money with it.</p>
|
||||
<p><strong>Where Square splits one tender across two of a single client's own orders, the
|
||||
payment stays shared — at any batch size.</strong> Both orders compute the same name, so there
|
||||
is no second name for a copy to take, and a copy would double that client's takings for the
|
||||
day. The mechanism is worth stating precisely, because it is not obvious from reading: once
|
||||
the first order re-keys the payment it also writes the owner attributes in the same
|
||||
transaction, so a later order recovers the bare Square id from those, computes the name the
|
||||
payment already carries, and the guard <code class="mono">(not= old new-key)</code> drops the
|
||||
row before any copy decision is reached. Verified by running the migration at a batch size of
|
||||
one, which forces the two orders into separate batches: no copy is made.</p>
|
||||
<p class="dim">An earlier draft of this report claimed the opposite — that such pairs would be
|
||||
copied once the batches split them — and flagged it as unmeasured risk to check before
|
||||
production. That was wrong, and it is recorded here rather than quietly deleted because it did
|
||||
real damage: an independent reviewer cited this paragraph as evidence and raised a defect that
|
||||
does not exist. A test now pins the behaviour at batch size one.</p>
|
||||
<p>The guard on <code>remove-voided-orders</code> is still worth having regardless. It is
|
||||
cheap, and it makes the safety a property of the deletion rather than of the migration having
|
||||
been run first.</p>
|
||||
</div>
|
||||
|
||||
<div class="callout">
|
||||
<span class="h">Re-running is safe, and that was proved at full scale</span>
|
||||
<p>After the complete pass, asking the migration what it would change next returns
|
||||
<strong>nothing</strong> — 17,045,933 payments examined, none to rename, none unscopable. A
|
||||
record that already carries the right name is left untouched, so the migration can be stopped,
|
||||
resumed, or repeated without consequence.</p>
|
||||
<p>Its speed is worth a note for whoever schedules it: the whole nineteen million orders were
|
||||
walked in about <strong>thirty-eight minutes</strong>, month by month from the current month
|
||||
backwards so that stopping early leaves the recent end done. An earlier attempt appeared to be
|
||||
transactor-bound and was projected at two days, which is why a previous run narrowed it to the
|
||||
analysis window. That diagnosis was wrong. The bottleneck was garbage collection in the process
|
||||
driving the migration — freeing held memory took an unrelated recompute from 17 client-days a
|
||||
minute to 4,515. Nothing about the database or the transactor needed to change.</p>
|
||||
</div>
|
||||
|
||||
<div class="measure">
|
||||
<p><strong>What follows from the gate reading zero.</strong> <code>unscoped-report</code> counts
|
||||
these figures on demand. Now that unscoped is zero across the board, the importer's
|
||||
understanding of the old name form can be removed — at which point two clients sharing a
|
||||
location becomes structurally incapable of producing a shared record, rather than prevented by
|
||||
a convention that a future import could quietly break. That removal is the one remaining step
|
||||
of this piece of work.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Decisions and risks still open</h2>
|
||||
<div class="scroll">
|
||||
<table>
|
||||
<thead><tr><th>Item</th><th>Who decides</th><th>Why it matters</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Which client record survives at each shared location</td><td>the business</td><td>the newer record generally has no history before the split, so keeping it loses years of the location's books</td></tr>
|
||||
<tr><td>Which revenue account service charges post to</td><td>accounting</td><td>currently 49000 Service Income, chosen so the work could be measured; it affects reporting, never whether a day balances</td></tr>
|
||||
<tr><td><strong>659 refunds on records that have no sales for them</strong></td><td>the business, then engineering</td><td>the top open item. $15,225.24 dated before the holding record's own first order. Either the missing sales get imported, or the refunds move to the record that has them — but the books cannot close until one of the two happens</td></tr>
|
||||
<tr><td>Whether to correct records the wrong client already owns</td><td>the business</td><td>the fix stops future mix-ups; it does not retrospectively move records claimed while the configuration was shared</td></tr>
|
||||
<tr><td><code>remove-voided-orders</code></td><td>engineering</td><td>safe once no payment has two parent orders; worth guarding regardless so it detaches rather than deletes</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="callout warn">
|
||||
<span class="h">Two operational findings, unrelated to the summaries</span>
|
||||
<p><strong>The production backup had not written a restore point since 2025-03-10</strong> — roughly seventeen months — even though data files were still uploading daily. A backup you cannot restore from is not a backup. A fresh one was taken on 2026-08-14 and is what this work used.</p>
|
||||
<p><strong>The database server was sized for a toy dataset</strong>: a 2 GB cache against 27 GB of data. Worth checking what production is set to.</p>
|
||||
<p><strong>Slowness here was misdiagnosed twice, in the same direction.</strong> Both a recompute crawling at 17 client-days a minute and a migration projected to take two days turned out to be garbage collection in the client process, not the database or the transactor. Freeing held memory took the recompute to 4,515 client-days a minute — a factor of 265 — and the migration finished in well under an hour. The lesson generalises: before concluding the transactor is the bottleneck, look at the heap of whatever is driving it.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>How to check any of this <span class="tech">technical</span></h2>
|
||||
<div class="scroll">
|
||||
<pre><span class="dim">;; the restored database, untouched production as of 2026-08-14 22:52</span>
|
||||
(def conn (d/connect "datomic:dev://localhost:4337/integreat-prod-restore"))
|
||||
|
||||
<span class="dim">;; the two orders behind the worked examples</span>
|
||||
(d/pull (d/db conn) '[*] [:sales-order/external-id
|
||||
"square/order/NGLK-SM-OxSX9gpXJV394qqT8mnBGypUwKNZY"])
|
||||
(d/pull (d/db conn) '[*] [:sales-order/external-id
|
||||
"square/order/NTPT-PT-KrMZzcon1cpQEJUyetErkBIpcdEZY"])
|
||||
|
||||
<span class="dim">;; the gate: no payment may have two parent orders</span>
|
||||
(rk/charges-with-multiple-parents (d/db conn) orders) <span class="dim">;; => 0</span>
|
||||
|
||||
<span class="dim">;; ownership history — which records ever changed client</span>
|
||||
(->> (d/datoms (d/history (d/db conn)) :aevt :sales-refund/client)
|
||||
(filter :added)
|
||||
(reduce (fn [m d] (update m (:e d) (fnil conj #{}) (:v d))) {})
|
||||
(filter (fn [[_ owners]] (> (count owners) 1)))
|
||||
count)</pre>
|
||||
</div>
|
||||
<div class="measure">
|
||||
<p>The comparison tool is committed as <code>auto-ap.jobs.compare-sales-summaries</code>. Unit tests: <code>lein test auto-ap.jobs.sales-summaries-test auto-ap.square.core3-test auto-ap.jobs.rekey-square-external-ids-test</code>.</p>
|
||||
</div>
|
||||
|
||||
<div class="callout warn">
|
||||
<span class="h">Do not compare summaries with <code>as-of</code> — a correction to how this was measured</span>
|
||||
<p>The obvious way to audit a recompute is to read the database at a point before it and diff:
|
||||
Datomic keeps every past value, so no snapshot is needed. That is what
|
||||
<code>compare-sales-summaries</code> was built to do, and for summary amounts it does not work.
|
||||
<code>:ledger-mapped/amount</code>, <code>:ledger-mapped/ledger-side</code> and
|
||||
<code>:ledger-mapped/account</code> are all declared <code>:db/noHistory true</code>, so
|
||||
superseded values are discarded rather than retained. A historical read of a summary that has
|
||||
since been recomputed can return its lines with the categories intact and the amounts simply
|
||||
absent — which reads as a legitimate all-zero summary, not as an error.</p>
|
||||
<p>Every figure in this report is therefore taken from a live read of the database immediately
|
||||
after each pass, captured and stored outside it, and the before/after comparison is done
|
||||
between those two captures. No historical read is involved anywhere in the numbers above. The
|
||||
tool remains useful for categories and for which days changed; its docstring overstates what it
|
||||
can recover, and that is worth correcting before someone relies on it for amounts.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<span>Measured 2026-08-15 against <span class="mono">integreat-prod-restore</span>, restored fresh from backup point 209608347 — production as of 2026-08-14 22:52. Nothing in production was read or written. Branch <span class="mono">worktree-sales-summary-balance</span>.</span>
|
||||
<span>A day counts as out of balance when money taken minus money earned is half a penny or more. "Material" means ten cents or more, the threshold below which the residual is till rounding. Of the 122 remaining days only 32 are material, and just 3 of them sit on the twenty records that share a Square location.</span>
|
||||
<span>Both the baseline and the result are live captures taken straight after their own recompute, never historical reads — see the note on <code>as-of</code> above.</span>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
419
docs/2026-08-15-sales-summary-rollout-plan.md
Normal file
419
docs/2026-08-15-sales-summary-rollout-plan.md
Normal file
@@ -0,0 +1,419 @@
|
||||
# Sales-summary balancing — rollout plan
|
||||
|
||||
Steps to execute, in order. Every step is either reversible or verifiable before the next one
|
||||
begins. The one behaviour change that alters a client's books is behind a per-client feature flag
|
||||
that is **off by default**, so merging and deploying this branch changes nothing on its own.
|
||||
|
||||
Measured on a restored copy of production (backup point `209608347`), 210 clients over
|
||||
2026-05-10 → 2026-08-07, with the duplicate client records left active exactly as they will be in
|
||||
production: **1,191 client-days out of balance / $70,276.50 → 122 days / $2,379.45**, of which only
|
||||
32 are above ten cents. 1,069 days came into balance, none broke, and no already-balanced day had a
|
||||
figure altered.
|
||||
|
||||
Of the $2,379.45 left, just **$648.84 across 3 days** is on the twenty shared-location records. The
|
||||
other 119 days and $1,730.61 belong to ordinary clients and have not moved across any run of this
|
||||
analysis.
|
||||
|
||||
Getting the shared records there needs step 5 — a historical backfill from Square. Without it they
|
||||
carry 423 days and $18,508.39, because re-keying stops the two records fighting but does not give
|
||||
each its own copy of the refunds.
|
||||
|
||||
---
|
||||
|
||||
## Before you start
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Flag introduced | `summary-service-charges` — off by default |
|
||||
| Migration to run once | `auto-ap.jobs.rekey-square-external-ids/migrate-all!` |
|
||||
| Expected migration runtime | ~38 minutes for 19M orders on a warm cache |
|
||||
| Backfill runtime (step 5) | ~5.9 hours for 90 days across the 20 shared-location records — an overnight job |
|
||||
| Nothing here touches | invoices, payments, the ledger, or any client without the flag set |
|
||||
|
||||
**Client configuration is left exactly as it is.** Ten Square locations are configured against two
|
||||
client records each, and both stay active. The re-key is what resolves them: once every record
|
||||
carries its owner in its key, each client's import resolves only its own records and the two
|
||||
records keep independent, stable histories. No "which record survives" decision is needed, and
|
||||
nothing is deactivated.
|
||||
|
||||
The consequence to be aware of: each Square payment, refund, payout and shift at a shared location
|
||||
becomes **two entities, one per client record** — by design. That is the stable end state, not a
|
||||
duplicate to clean up. If any report or export aggregates across client records, one restaurant's
|
||||
takings would be counted twice at that layer. Nothing in this work changes that either way.
|
||||
|
||||
That holds automatically for everything imported *from now on*, because the keys carry the client.
|
||||
It does **not** hold for history: refunds, payouts and shifts already in the database exist only
|
||||
once, on whichever record imported them last, and re-keying freezes that rather than evening it out.
|
||||
Step 5 is what brings the existing history into the same shape.
|
||||
|
||||
**The only window of risk is between deploying and finishing the migration**, while legacy keys
|
||||
still exist for a client to resolve. Steps 2–6 exist to make that window effectively zero.
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Guard `remove-voided-orders`
|
||||
|
||||
Do this before the migration, not after. `:sales-order/charges` is `:db/isComponent true`, so
|
||||
retracting an order cascades into its payments. Until step 4 finishes there are still payments with
|
||||
two parent orders, and deleting one client's voided order can take the other client's payment with
|
||||
it.
|
||||
|
||||
Either leave `remove-voided-orders` switched off until step 4 verifies clean, or change it to detach
|
||||
a payment that has more than one parent rather than delete it. Detaching is worth doing regardless —
|
||||
it makes the safety a property of the deletion rather than of the migration having been run first.
|
||||
|
||||
See `docs/2026-08-15-remove-voided-orders-risk.md`.
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Pause the Square importer
|
||||
|
||||
**This is what makes the deploy safe, and it is easy to skip.** Steps 2 through 6 should be one
|
||||
maintenance action, not separate days' work.
|
||||
|
||||
While legacy keys exist, `square.core3/existing-id` falls back to them — and at a shared location
|
||||
that is the one code path that can reach across client records. Running the migration with imports
|
||||
paused means no client is resolving keys while the keys are being rewritten, so the window closes
|
||||
entirely rather than merely narrowing.
|
||||
|
||||
The migration itself takes about **38 minutes** for all 19M orders, so the pause is short — and
|
||||
if you need it shorter, see step 6: you can resume imports before it finishes.
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — Deploy the code
|
||||
|
||||
Deploy the branch. The flag is absent from every client, so:
|
||||
|
||||
- tips are calculated exactly as they are today,
|
||||
- no `Service Charges` line is written.
|
||||
|
||||
The only changes that take effect immediately are the safe ones: imbalance logging, the
|
||||
dirty-summary scan bounded to one client (1,321 ms → 5.6 ms per client), the schema-ordering fix,
|
||||
and the importer's new client-scoped keys.
|
||||
|
||||
**The importer reads both key schemes**, so the deploy does not depend on the migration having
|
||||
finished. Two protections cover the interval before it does: imports are paused (step 2), and
|
||||
`existing-id` refuses to resolve a record that already belongs to a different client. Do not remove
|
||||
the legacy lookup yet — see step 10.
|
||||
|
||||
---
|
||||
|
||||
## Step 4 — Run the migration
|
||||
|
||||
Run it immediately after the deploy, while imports are still paused.
|
||||
|
||||
```clojure
|
||||
(require '[auto-ap.jobs.rekey-square-external-ids :as rk])
|
||||
|
||||
;; read-only first — no two entities may want the same key. `plan` does NOT return a
|
||||
;; :collisions key; you have to hand its :new-keys to `collisions` yourself.
|
||||
(rk/collisions (:new-keys (rk/plan (d/db conn) :charge/external-id rk/charge-prefix)))
|
||||
;; => [] (anything else: stop, do not migrate)
|
||||
|
||||
;; then the whole thing
|
||||
(rk/migrate-all! 2000)
|
||||
```
|
||||
|
||||
`migrate-all!` runs this same check itself, on every attribute including charges, and throws
|
||||
rather than transacting if it finds one. Running it by hand first just means finding out before
|
||||
the 38-minute walk rather than partway through it.
|
||||
|
||||
Runs in about thirty-eight minutes over 19M orders. It is **idempotent and resumable** — a record that
|
||||
already carries the right name is skipped, so it can be stopped and re-run without consequence.
|
||||
|
||||
**It is also ordered so that stopping early is survivable.** Refunds, payouts and cash-drawer
|
||||
shifts go first — a quarter of a million records, seconds of work — so an interruption cannot catch
|
||||
them half done. The long part then walks orders **a month at a time, from the current month
|
||||
backwards**, logging `::month-complete` as each finishes:
|
||||
|
||||
```
|
||||
::month-complete :month "2026-08" :rekeyed 118203 :cloned 2244
|
||||
::month-complete :month "2026-07" :rekeyed 241887 :cloned 4611
|
||||
...
|
||||
```
|
||||
|
||||
That ordering is the recovery plan. If it dies, everything from the last logged month forward is
|
||||
fully scoped — and that recent window is what the importer actually reads — so **you can resume
|
||||
imports against a partially migrated database** and finish the older tail later. Walking oldest
|
||||
first would have spent the first several hours on 2019 data no import will touch, leaving exactly
|
||||
the wrong end done.
|
||||
|
||||
If you do resume imports mid-migration, the ownership guard in `existing-id` is what keeps the
|
||||
unmigrated tail safe: a client cannot resolve onto another client's legacy-keyed record.
|
||||
|
||||
If it appears to crawl, the cause is almost certainly garbage collection in the process driving it,
|
||||
not the transactor. That misdiagnosis cost two days of projected runtime during this work. Free
|
||||
retained memory in the REPL and re-measure before changing anything about the database.
|
||||
|
||||
**Verify.** Two checks, doing two different jobs — run both.
|
||||
|
||||
**(a) Completeness, across everything.** `plan` must report nothing left to do, for all four
|
||||
attributes:
|
||||
|
||||
```clojure
|
||||
(dissoc (rk/plan (d/db conn) :charge/external-id rk/charge-prefix) :new-keys)
|
||||
;; => {:total 17045933 :to-migrate 0 :already-scoped 17045933 :unscopable 0}
|
||||
```
|
||||
|
||||
Read `:to-migrate 0` **and** `:unscopable 0`. This is the authoritative signal, and it covers all
|
||||
17M charges.
|
||||
|
||||
`unscoped-report` is useful colour but is not the gate: its `:no-owner` column never reaches zero
|
||||
for charges, because ~283k payout stubs carry no `:charge/client` of their own and it classifies
|
||||
by attribute rather than by resolving ownership. Judge completeness by `plan`.
|
||||
|
||||
**(b) The safety gate for the cascade** — no payment may answer to two orders, or re-enabling
|
||||
`remove-voided-orders` in step 9 can delete a payment another order still needs. Check **every**
|
||||
order in the last year, with no sampling:
|
||||
|
||||
```clojure
|
||||
(let [db (d/db conn)
|
||||
cs (map first (d/q '[:find ?c :where [?c :client/code _]] db))
|
||||
year (java.util.Date. (- (.getTime (java.util.Date.)) (long (* 365 86400000))))]
|
||||
(rk/charges-with-multiple-parents
|
||||
db (map first (iol-ion.query/scan-sales-orders db cs year nil))))
|
||||
;; => 0
|
||||
```
|
||||
|
||||
On the restored copy that is 5,158,470 orders — 27% of the table — via the
|
||||
`:sales-order/client+date` index. A year is chosen deliberately: `remove-voided-orders` only ever
|
||||
deletes orders Square reports as voided, which are recent, so that is where the destructive risk
|
||||
lives. Completeness across all of history is check (a)'s job, not this one.
|
||||
|
||||
> Do **not** sample this with `(take n (rk/all-order-ids db))`. `all-order-ids` streams `:aevt`,
|
||||
> which is ascending entity id, so a `take` returns the *oldest* orders — on the restored copy the
|
||||
> first 400,000 are all from 2019–2021, before any of the contention this gate looks for. It would
|
||||
> report a confident zero having inspected none of the relevant data.
|
||||
|
||||
---
|
||||
|
||||
## Step 5 — Backfill the shared-location clients from Square
|
||||
|
||||
**Skip this and the ten duplicated restaurants stay badly out of balance.** It is the difference
|
||||
between 122 client-days out of balance and 542.
|
||||
|
||||
Sales orders have always been keyed by client, so both records of a pair built their own order
|
||||
history. Refunds, payouts and cash-drawer shifts were not, so only ONE record holds each of them.
|
||||
Re-keying freezes that ownership; it does not even it out. The record left without them shows
|
||||
returns from its own orders and no refunds against them — NGBK held 158,535 orders and five
|
||||
refunds — and is out of balance by exactly what its twin is holding.
|
||||
|
||||
Rather than manufacture copies, ask Square again. Client-scoped keys mean each record now creates
|
||||
its own copy of whatever it reads, so replaying the window makes the two histories converge:
|
||||
|
||||
```clojure
|
||||
(require '[auto-ap.square.core3 :as sq])
|
||||
(require '[clj-time.core :as t])
|
||||
|
||||
@(apply sq/backfill-history
|
||||
(t/date-time 2026 5 10) (t/date-time 2026 8 9)
|
||||
["NGBK" "NGBR" "NGCD" "NGCC" "NGVG" "NGVC" "NGEZ" "NGJS" "NGDG" "NGDU"
|
||||
"NGDV" "NGDS" "NGWC" "NGWN" "NGHY" "NGHA" "NGDA" "NGDL" "NGCL" "NGCT"])
|
||||
```
|
||||
|
||||
**Verify** — every pair should hold matching order and refund counts in the window:
|
||||
|
||||
```clojure
|
||||
;; per pair, per side: window orders and window refunds. The two sides should agree.
|
||||
```
|
||||
|
||||
Measured on the restored copy: all ten pairs matched afterwards, and the shared records went from
|
||||
423 days and $18,508.39 out of balance to 3 days and $648.84.
|
||||
|
||||
**Budget an overnight run.** This took **5.9 hours** for ninety days across the twenty records.
|
||||
Every Square call in the process shares one 25-requests-per-second throttle, refunds and shifts cost
|
||||
one API call per record, and `backfill-history` imports three clients at a time — raise its
|
||||
`s/buffer` if you need it faster. Neither the database nor the transactor is the limit; reads
|
||||
measured at 32 µs.
|
||||
|
||||
It must run **after** the migration. Run before, and it imports against legacy keys and leaves more
|
||||
to migrate.
|
||||
|
||||
---
|
||||
|
||||
## Step 6 — Resume the Square importer
|
||||
|
||||
Normally: once step 4's two checks read clean and step 5's backfill has finished. The maintenance
|
||||
window ends here.
|
||||
|
||||
**If the migration did not finish**, you do not have to wait for it. Resume imports once the
|
||||
`::month-complete` log covers the window your importer reads — the last 75 days for payouts and
|
||||
cash-drawer shifts, and whatever range the order import is configured for. Then re-run
|
||||
`migrate-all!` afterwards to walk the remaining older months; it will skip everything already done.
|
||||
Run the step 4 checks again once it does finish.
|
||||
|
||||
The first cycle after resuming is the one to watch. Compare these against the same counts taken
|
||||
immediately before the deploy — growth should be ordinary daily volume:
|
||||
|
||||
```clojure
|
||||
(count (d/datoms (d/db conn) :aevt :sales-refund/external-id))
|
||||
(count (d/datoms (d/db conn) :aevt :expected-deposit/external-id))
|
||||
(count (d/datoms (d/db conn) :aevt :cash-drawer-shift/external-id))
|
||||
(count (d/datoms (d/db conn) :aevt :charge/external-id))
|
||||
```
|
||||
|
||||
A near-doubling of any of them means records are being created rather than matched — **stop and
|
||||
roll back the deploy.** Charges are included deliberately: they are the one that doubles a client's
|
||||
takings rather than merely duplicating a row.
|
||||
|
||||
---
|
||||
|
||||
## Step 7 — Recompute summaries, flags still off
|
||||
|
||||
```clojure
|
||||
(require '[auto-ap.jobs.sales-summaries :as ss])
|
||||
(ss/refresh-sales-summaries 90)
|
||||
```
|
||||
|
||||
This is the pass that banks the deduplication. **Capture the result before going further** — you
|
||||
will need it as the baseline for step 8, and it cannot be reconstructed afterwards:
|
||||
|
||||
```clojure
|
||||
(require '[auto-ap.tools.compare-sales-summaries :as cmp]) ; test/dev classpath
|
||||
(def before (cmp/summaries-in (d/db conn) start end))
|
||||
(spit "before.edn" (pr-str before))
|
||||
```
|
||||
|
||||
> **Do not use `d/as-of` to compare summary amounts.** `:ledger-mapped/amount`, `ledger-side` and
|
||||
> `account` are `:db/noHistory`, so past values are discarded. A summary that has since been
|
||||
> recomputed reads back through `as-of` with its amounts *absent*, which looks like a legitimate
|
||||
> balanced day. Capture live, before and after, and diff the captures.
|
||||
|
||||
---
|
||||
|
||||
## Step 8 — Turn the flag on, a few restaurants at a time
|
||||
|
||||
Needs accounting sign-off first: `summary-service-charges` posts to **49000 Service Income**, chosen
|
||||
so the work could be measured. It affects reporting, never whether a day balances.
|
||||
|
||||
```clojure
|
||||
@(d/transact conn [{:db/id [:client/code "NGxx"]
|
||||
:client/feature-flags ["summary-service-charges"]}])
|
||||
(ss/refresh-sales-summaries 90)
|
||||
```
|
||||
|
||||
Start with two or three restaurants, confirm, then widen.
|
||||
|
||||
**Verify** against the capture from step 7:
|
||||
|
||||
```clojure
|
||||
(def after (cmp/summaries-in (d/db conn) start end))
|
||||
(cmp/compare-window ...) ; both arguments live database values, never as-of
|
||||
```
|
||||
|
||||
The two numbers that matter — both were zero across all 18,900 client-days in testing:
|
||||
|
||||
- `:balanced->unbalanced` must be **0**
|
||||
- previously-balanced days whose lines changed must be **0**
|
||||
|
||||
If either is non-zero, retract the flag for the affected clients and re-run step 7. The flag is the
|
||||
rollback: removing it restores today's behaviour exactly.
|
||||
|
||||
---
|
||||
|
||||
## Step 9 — Re-enable `remove-voided-orders`
|
||||
|
||||
Safe once step 4's gate reads zero. Keep the detach-rather-than-delete guard from step 1.
|
||||
|
||||
---
|
||||
|
||||
## Step 10 — Remove the legacy key lookup
|
||||
|
||||
**Schedule this; do not leave it open-ended.** Both client records at a shared location stay active
|
||||
permanently, so the legacy fallback in `square.core3/existing-id` is the one code path that can ever
|
||||
reach across them. Deleting it is what turns the guarantee from conventional into structural.
|
||||
|
||||
Once `plan` reports `:to-migrate 0` and has stayed there through several import cycles, drop the
|
||||
legacy branch of `existing-id` — and with it `owned-by-other-client?`, which exists only to make
|
||||
that branch safe while it lives. After this, two clients on one location are structurally incapable
|
||||
of resolving onto each other's records, and no ordering discipline is required to keep it that way.
|
||||
|
||||
Until it is done, the protection is the guard plus the maintenance window, both of which depend on
|
||||
people doing the right thing. That is the reason not to let this drift.
|
||||
|
||||
---
|
||||
|
||||
## Step 11 — Deal with the refunds that have no sales behind them
|
||||
|
||||
**The most important item in this document, and the only one that is not just execution.**
|
||||
|
||||
16 of the 122 remaining days are a record carrying refunds on a day it recorded no sales at all,
|
||||
and all 16 fall before that client's first ever order. Two clients are affected, holding **160
|
||||
refunds worth $4,347.68 dated before their own first order**:
|
||||
|
||||
| Client | First order | Refunds before it | Value | Days out of balance |
|
||||
|---|---|---:|---:|---:|
|
||||
| NG4S | 2026-05-29 | 79 | $2,180.08 | 10 |
|
||||
| NGPS | 2026-05-26 | 81 | $2,167.60 | 7 |
|
||||
|
||||
**Step 5's backfill already resolved the other seven.** Before it, nine records were in this state
|
||||
holding 660 refunds worth $15,237.02 — but seven of them were shared-location twins whose refunds
|
||||
only looked orphaned because their orders had never been imported. Replaying the window gave them
|
||||
their orders, and the refunds stopped predating them.
|
||||
|
||||
NG4S and NGPS are different: neither shares a Square location, so there is no twin holding the other
|
||||
half. Their sales genuinely are not in the system for the period their refunds cover. The database's
|
||||
own ownership history is the evidence to check — for the twins it showed refunds changing hands
|
||||
between the two records; for these two there is no second record to have taken them from.
|
||||
|
||||
Two ways to close it, and the business has to pick:
|
||||
|
||||
1. **Import the missing sales.** Correct if these records are meant to have their own books. Try
|
||||
`backfill-history` for them first, with a window reaching back before their first order — that is
|
||||
exactly what fixed the seven, and it is one command.
|
||||
2. **Move the refunds to the record that has the sales.** Correct only if the refunds were misfiled
|
||||
onto a record that should not have books of its own.
|
||||
|
||||
Start with (1): it is cheap, reversible in the sense that it only adds what Square reports, and it
|
||||
is already proven to work on this exact symptom.
|
||||
|
||||
```clojure
|
||||
;; per client: refunds dated before that client's own first order
|
||||
(let [first-order (->> (d/q '[:find [?d ...] :in $ ?c
|
||||
:where [?o :sales-order/client ?c] [?o :sales-order/date ?d]]
|
||||
(d/db conn) [:client/code "NG4S"])
|
||||
(reduce (fn [a b] (if (.before a b) a b))))]
|
||||
(->> (d/q '[:find [(pull ?r [:sales-refund/date :sales-refund/total]) ...] :in $ ?c
|
||||
:where [?r :sales-refund/client ?c]]
|
||||
(d/db conn) [:client/code "NG4S"])
|
||||
(filter #(.before (:sales-refund/date %) first-order))
|
||||
count))
|
||||
```
|
||||
|
||||
**Until this is resolved those days stay out of balance, on purpose.** A summary change to close
|
||||
them was written and measured — it works, closes 16 days and $1,227.65, and breaks nothing — and it
|
||||
was removed, because an unbalanced day is the only visible signal that a restaurant's sales are not
|
||||
being imported. A test asserts the day stays unbalanced so nobody closes it without reading this.
|
||||
|
||||
---
|
||||
|
||||
## What this will not fix
|
||||
|
||||
122 client-days over ninety days, $2,379.45, of which only 32 are above ten cents.
|
||||
|
||||
| | Days | Variance | |
|
||||
|---|---:|---:|---|
|
||||
| Real trading days with genuine discrepancies | 106 | $1,151.80 | see below |
|
||||
| Refunds on a record with no sales imported | 16 | $1,227.65 | step 11 — deliberately visible |
|
||||
|
||||
Of the 106 trading days, only **3 are on shared-location records** — $648.84 in total, and all three
|
||||
are already diagnosed: NGBK and NGBR at $299.42 each on 2026-08-06, where Square recorded $6,358.99
|
||||
of tender against $6,059.57 of order totals (the gap itself, not a summary fault), and NGDA at
|
||||
$50.00, an auto-gratuity booked as a service charge.
|
||||
|
||||
The other 103 days come to **$502.96 across 190 clients** — a few dollars here and there, mostly
|
||||
till rounding, plus small undiagnosed clusters on NGMV ($259.38 over 5 days) and NGEB ($199.09 over
|
||||
4 days, an ezCater fee-treatment question). Those two are worth a look but are not urgent.
|
||||
|
||||
That 103-day, $502.96 figure has been identical in every run of this analysis — with the duplicates
|
||||
deactivated, with them live, and with them backfilled. It is the floor this work reaches.
|
||||
|
||||
---
|
||||
|
||||
## Two operational findings, unrelated to the summaries
|
||||
|
||||
- **The production backup had not written a restore point since 2025-03-10** — about seventeen
|
||||
months — although data files were still uploading daily. Worth an alert on restore-point age.
|
||||
- **The database server is sized for a much smaller dataset**: a 2 GB cache against 27 GB of data.
|
||||
Worth checking what production is set to.
|
||||
@@ -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`
|
||||
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,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)
|
||||
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)
|
||||
@@ -49,9 +49,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))
|
||||
|
||||
70
iol_ion/src/iol_ion/tx/upsert_sales_summary_ledger.clj
Normal file
70
iol_ion/src/iol_ion/tx/upsert_sales_summary_ledger.clj
Normal file
@@ -0,0 +1,70 @@
|
||||
(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))))
|
||||
_ (clojure.pprint/pprint aggregated)
|
||||
line-items (mapv (fn [{:keys [account] :as m}]
|
||||
(cond-> {:db/id (str (java.util.UUID/randomUUID))
|
||||
:journal-entry-line/account account
|
||||
: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))
|
||||
_ (clojure.pprint/pprint [total-debits total-credits])
|
||||
]
|
||||
(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)}]))))))
|
||||
256
opencode.json
Normal file
256
opencode.json
Normal file
File diff suppressed because one or more lines are too long
1
resources/.~lock.sample-yodlee-manual.tsv#
Normal file
1
resources/.~lock.sample-yodlee-manual.tsv#
Normal file
@@ -0,0 +1 @@
|
||||
,noti,pop-os,01.06.2026 21:02,file:///home/noti/.config/libreoffice/4;
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1949,12 +1949,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
|
||||
|
||||
@@ -6,10 +6,10 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
4,PAPER & DISP,STRAW PLAS TRANS JMB WRPD 7.75,Paper Costs,55000,
|
||||
5,PAPER & DISP,FORK PLAS BLK PLA COMPOSTABLE,Paper Costs,55000,
|
||||
6,PAPER & DISP,BAG PLAS LOGO 3 CLR,Paper Costs,55000,
|
||||
7,FROZEN,BREAD PITA GYRO PRE-OILED 7,Food Costs,50000,
|
||||
7,FROZEN,BREAD PITA GYRO PRE-OILED 7,Bread and Bun Costs,51400,
|
||||
8,DAIRY PRODUCTS,YOGURT FRZN TART,Dairy Costs,51300,
|
||||
9,POULTRY,GYRO CHICKEN SHAWARMA CONE,Chicken/ Poultry Costs,51120,
|
||||
10,FROZEN,BAKLAVA CLASSIC 2X24,Food Costs,50000,
|
||||
10,FROZEN,BAKLAVA CLASSIC 2X24,Dry Goods Costs,51500,
|
||||
11,MEATS,PORK SLI GYRO CONE,Beef/Pork Costs,51110,
|
||||
12,DAIRY PRODUCTS,SAUCE TZATZIKI,Dairy Costs,51300,
|
||||
13,POULTRY,CHICKEN CVP THIGH BNLS SKLS,Chicken/ Poultry Costs,51120,
|
||||
@@ -19,7 +19,7 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
17,CANNED AND DRY,RICE BASMATI STEAMED XTRA LNG,Food Costs,50000,
|
||||
18,CANNED AND DRY,WATER SPARKLING GREEK,Beverages Costs,52000,
|
||||
19,DAIRY PRODUCTS,SAUCE SPICY YOGURT LOGO,Dairy Costs,51300,
|
||||
20,CANNED AND DRY,WATER PURIFIED .5,Food Costs,50000,
|
||||
20,CANNED AND DRY,WATER PURIFIED .5,Soft Beverage Cost,52000,
|
||||
21,FROZEN,DOUGH PASTRY HNY PUFF,Food Costs,50000,
|
||||
22,DAIRY PRODUCTS,YOGURT PLAIN GREEK NON-FAT,Dairy Costs,51300,
|
||||
23,PAPER & DISP,GLOVE NITRILE LARGE,Paper Costs,55000,
|
||||
@@ -43,39 +43,39 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
41,CANNED AND DRY,WATER BOTTLED DRINKING,Food Costs,50000,
|
||||
42,CANNED AND DRY,SODA CHERRY VISSINADA GRK PLAS,Food Costs,50000,
|
||||
43,CANNED AND DRY,SODA LEMON LEMONADA GREEK,Soft Beverage Costs,52000,
|
||||
44,CANNED AND DRY,WATER MINERAL CARNONATED GREEK,Food Costs,50000,
|
||||
44,CANNED AND DRY,WATER MINERAL CARNONATED GREEK,Soft Beverage Cost,52000,
|
||||
45,DISPENSER BEVRG,SYRUP COLA PEPSI BIB,Soft Beverage Costs,52000,
|
||||
46,DISPENSER BEVRG,SYRUP LEMONADE PNK BIB,Soft Beverage Costs,52000,
|
||||
47,CANNED AND DRY,RICE BASMATI PABROIL SELA CS,Food Costs,50000,
|
||||
48,CANNED AND DRY,KETCHUP FANCY,Food Costs,50000,
|
||||
47,CANNED AND DRY,RICE BASMATI PABROIL SELA CS,Dry Goods Costs,51500,
|
||||
48,CANNED AND DRY,KETCHUP FANCY,Dry Goods Costs,51500,
|
||||
49,CANNED AND DRY,TUB & HUMMUS,Food Costs,50000,
|
||||
50,CHEMICAL/JANTRL,SANITIZER MULTI QUAT LIQ,Food Costs,50000,
|
||||
50,CHEMICAL/JANTRL,SANITIZER MULTI QUAT LIQ,Cleaning Supplies,74100,
|
||||
51,DAIRY PRODUCTS,YOGURT PLAIN GRK 5%,Dairy Costs,51300,
|
||||
52,FROZEN,APTZR VEG FALAFEL BALL,Food Costs,50000,
|
||||
52,FROZEN,APTZR VEG FALAFEL BALL,Dry Goods Costs,51500,
|
||||
53,PAPER & DISP,BOWL PAPER FIBER RND 32OZ 8IN,Paper Costs,55000,
|
||||
54,PAPER & DISP,LID PLAS F/BOWL RND 8,Paper Costs,55000,
|
||||
55,MEATS,BEEF GRND CHUCK FINE 80/20FRSH,Beef/Pork Costs,51110,
|
||||
56,CANNED AND DRY,SODA ORANGE CRSH,Food Costs,50000,
|
||||
56,CANNED AND DRY,SODA ORANGE CRSH,Soft Beverage Cost,52000,
|
||||
57,PAPER & DISP,CONTAINER PLAS CLR BAR LK 5 IN,Paper Costs,55000,
|
||||
58,CANNED AND DRY,KETCHUP PACKET FCY,Food Costs,50000,
|
||||
58,CANNED AND DRY,KETCHUP PACKET FCY,Dry Goods Costs,51500,
|
||||
59,PAPER & DISP,BAG PLAS WAVE TOP LOGO 18X16,Paper Costs,55000,
|
||||
60,CANNED AND DRY,DRESSING VINAIGRETTE LOGO,Food Costs,50000,
|
||||
60,CANNED AND DRY,DRESSING VINAIGRETTE LOGO,Dressing & Sauce Cost,51450,
|
||||
61,PAPER & DISP,CONTAINER PAPER MLD FBR 9X6,Paper Costs,55000,
|
||||
62,PAPER & DISP,BOWL PAPER MLD FBR 32OZ NFA,Paper Costs,55000,
|
||||
63,PAPER & DISP,CONTAINER PLAS 120Z SUNDAE,Paper Costs,55000,
|
||||
64,CANNED AND DRY,DRESSING VINAIGRETTE GYRO,Food Costs,50000,
|
||||
65,CANNED AND DRY,VINEGAR WINE RED 5% 50 GRN,Alcohol Costs,54000,
|
||||
66,DAIRY PRODUCTS,EGG SHELL LG WHT AA CA CGFREE,Dairy Costs,51300,
|
||||
67,CANNED AND DRY,DRESSING MARINADE SOUVLAKI,Food Costs,50000,
|
||||
68,CANNED AND DRY,SAUCE MUSTARD,Food Costs,50000,
|
||||
67,CANNED AND DRY,DRESSING MARINADE SOUVLAKI,Dressing & Sauce Cost,51450,
|
||||
68,CANNED AND DRY,SAUCE MUSTARD,Dressing & Sauce Cost,51450,
|
||||
69,CANNED AND DRY,TEA ICED SWEET PURELEAF,Beverages Costs,52000,
|
||||
70,PAPER & DISP,LINER TRASH 40X46 1.1 ML GRY,Paper Costs,55000,
|
||||
71,CANNED AND DRY,SODA COLA,Soft Beverage Costs,52000,
|
||||
72,CANNED AND DRY,HONEY PURE CLOVER GR A TSC JUG,Food Costs,50000,
|
||||
72,CANNED AND DRY,HONEY PURE CLOVER GR A TSC JUG,Dry Goods Costs,51500,
|
||||
73,DAIRY PRODUCTS,CHEESE FETA RW,Dairy Costs,51300,
|
||||
74,CANNED AND DRY,WATER PURIFIED BTL PET LSE DW,Food Costs,50000,
|
||||
74,CANNED AND DRY,WATER PURIFIED BTL PET LSE DW,Soft Beverage Cost,52000,
|
||||
75,PRODUCE,JUICE LEMON FRESH PSTRZD,Produce Costs,51200,
|
||||
76,CANNED AND DRY,SPREAD HUMMUS TRADITIONAL,Food Costs,50000,
|
||||
76,CANNED AND DRY,SPREAD HUMMUS TRADITIONAL,Dressing & Sauce Cost,51450,
|
||||
77,PRODUCE,LETTUCE ROMAINE OF HEART FRSH,Produce Costs,51200,
|
||||
78,PAPER & DISP,CONTAINER PAPER #1/30OZ NTG,Paper Costs,55000,
|
||||
79,CANNED AND DRY,OIL SALAD CANOLA ZTF,Food Costs,50000,
|
||||
@@ -84,7 +84,7 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
82,POULTRY,CHICKEN CVP WHL WOG NAE 3.5-4#,Chicken/ Poultry Costs,51120,
|
||||
83,PAPER & DISP,GLOVE NITRILE FDSRV PF BLU LRG,Paper Costs,55000,
|
||||
84,CANNED AND DRY,RICE BASMATI CHEF SECRT LG GRN,Food Costs,50000,
|
||||
85,FROZEN,APTZR VEG FALAFEL PUCK HALAL,Food Costs,50000,
|
||||
85,FROZEN,APTZR VEG FALAFEL PUCK HALAL,Dry Goods Costs,51500,
|
||||
86,PAPER & DISP,LID PLAS PET FOR 32OZ BOWL,Paper Costs,55000,
|
||||
87,PAPER & DISP,FORK PLAS PP X-HVY BLK,Paper Costs,55000,
|
||||
88,PRODUCE,TOMATO ROMA JUMBO FRESH,Produce Costs,51200,
|
||||
@@ -95,7 +95,7 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
93,PRODUCE,SPINACH BABY FRSH,Produce Costs,51200,
|
||||
94,PRODUCE,DILL BABY FRESH HERB,Produce Costs,51200,
|
||||
95,PRODUCE,CUCUMBER ENGLISH MED SEEDLESS,Produce Costs,51200,
|
||||
96,CANNED AND DRY,SODA ORANGE PORTOKALADA GREEK,Food Costs,50000,
|
||||
96,CANNED AND DRY,SODA ORANGE PORTOKALADA GREEK,Soft Beverage Cost,52000,
|
||||
97,DAIRY PRODUCTS,BUTTER SOLID USDA AA UNSLTD,Dairy Costs,51300,
|
||||
98,DAIRY PRODUCTS,CHEESE MONT JACK SLI INT .75OZ,Dairy Costs,51300,
|
||||
99,DAIRY PRODUCTS,CREAMER HALF & HALF SHF STBL,Dairy Costs,51300,
|
||||
@@ -115,18 +115,18 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
113,CANNED AND DRY,FLOUR ALL PURP H&R BL EN MT,Food Costs,50000,
|
||||
114,CANNED AND DRY,JAM STRAWBERRY CUP,Food Costs,50000,
|
||||
115,CANNED AND DRY,MARMALADE ORANGE CUP,Food Costs,50000,
|
||||
116,CANNED AND DRY,SALT GRANULATED PLAIN,Food Costs,50000,
|
||||
116,CANNED AND DRY,SALT GRANULATED PLAIN,Dry Goods Costs,51500,
|
||||
117,CANNED AND DRY,SAUCE HOT PEPPER CALIFN STYLE,Food Costs,50000,
|
||||
118,CANNED AND DRY,SAUCE STEAK GLASS,Food Costs,50000,
|
||||
119,CANNED AND DRY,SHORTENING PAN & GRILL,Food Costs,50000,
|
||||
120,CANNED AND DRY,SUGAR GRANULATED XFINE CANE,Food Costs,50000,
|
||||
120,CANNED AND DRY,SUGAR GRANULATED XFINE CANE,Dry Goods Costs,51500,
|
||||
121,CANNED AND DRY,SYRUP BREAKFAST CUP,Food Costs,50000,
|
||||
122,CANNED AND DRY,SYRUP PANCAKE & WAFFLE,Food Costs,50000,
|
||||
123,PAPER & DISP,BAG PLAS TSHRT 11.5X6.5X21 TKU,Paper Costs,55000,
|
||||
124,PAPER & DISP,CONTAINER PLAS DELI TRANS W/LD,Paper Costs,55000,
|
||||
125,PAPER & DISP,CONTAINER PLAS HNG WHT 8.5 1C,Paper Costs,55000,
|
||||
126,PAPER & DISP,CUP PLAS PRTN TRANS 2OZ,Paper Costs,55000,
|
||||
127,PAPER & DISP,FOIL ALMN ROLL STD WGT 500FT,Paper Costs,55000,
|
||||
127,PAPER & DISP,FOIL ALMN ROLL STD WGT 500FT,Dry Goods Costs,51500,
|
||||
128,PAPER & DISP,LINER TRASH 40X46 1.6 ML BLK,Paper Costs,55000,
|
||||
129,PAPER & DISP,TOWEL MULTI 9.5X9.12 EARTH+,Paper Costs,55000,
|
||||
130,PRODUCE,ASPARAGUS FRESH LARGE FX,Produce Costs,51200,
|
||||
@@ -155,7 +155,7 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
153,DAIRY PRODUCTS,MILK WHL CORRUGATE,Dairy Costs,51300,
|
||||
154,DAIRY PRODUCTS,BUTTERMILK 1% HG,Dairy Costs,51300,
|
||||
155,DAIRY PRODUCTS,CHEESE CHDR MLD SLI INT .75 YL,Dairy Costs,51300,
|
||||
156,CHEMICAL/JANTRL,BLEACH LIQ GRMCDL ULTRA 6%,Food Costs,50000,
|
||||
156,CHEMICAL/JANTRL,BLEACH LIQ GRMCDL ULTRA 6%,Cleaning Supplies,74100,
|
||||
157,POULTRY,TURKEY BRST NAT BRN PAN SKON,Poultry Costs,51120,
|
||||
158,DAIRY PRODUCTS,CREAM HEAVY 40% FRESH HG,Dairy Costs,51300,
|
||||
159,MEATS,BACON SHINGLE 10/12 HY GF PR12,Beef/Pork Costs,51110,
|
||||
@@ -194,7 +194,7 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
192,PAPER & DISP,BOWL PAPER MOLDED FIBER 32OZ,Paper Costs,55000,
|
||||
193,PAPER & DISP,BOX CORR CATER #1 LOGO 2021,Paper Costs,55000,
|
||||
194,DISPENSER BEVRG,SYRUP LEMONADE BIB,Soft Beverage Costs,52000,
|
||||
195,PAPER & DISP,FOIL ALMN ROLL HVY WGT 500 FT,Paper Costs,55000,
|
||||
195,PAPER & DISP,FOIL ALMN ROLL HVY WGT 500 FT,Dry Goods Costs,51500,
|
||||
196,DAIRY PRODUCTS,CHEESE FETA CHUNKS IN BRNE,Dairy Costs,51300,
|
||||
197,POULTRY,CHICKEN CVP WOG WHL HAL,Chicken/ Poultry Costs,51120,
|
||||
198,PAPER & DISP,CONTAINER MFPP 1C HNG 9X6 WHT,Paper Costs,55000,
|
||||
@@ -203,11 +203,11 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
201,DISPENSER BEVRG,SYRUP LEMON LIME BIB,Soft Beverage Costs,52000,
|
||||
202,DAIRY PRODUCTS,CHEESE FETA PAIL,Dairy Costs,51300,
|
||||
203,CANNED AND DRY,CHGS FOR MINIMUM ORDER,Food Costs,50000,
|
||||
204,CANNED AND DRY,BREAD CRUMB PLAIN MED,Food Costs,50000,
|
||||
205,CANNED AND DRY,DRESSING SALAD PRASINI,Food Costs,50000,
|
||||
206,CANNED AND DRY,OIL CORN,Food Costs,50000,
|
||||
207,CANNED AND DRY,OLIVE KALAMATA PTD BRNE 22 LB,Food Costs,50000,
|
||||
208,CANNED AND DRY,SPICE TURMERIC GROUND,Food Costs,50000,
|
||||
204,CANNED AND DRY,BREAD CRUMB PLAIN MED,Dry Goods Costs,51500,
|
||||
205,CANNED AND DRY,DRESSING SALAD PRASINI,Dressing & Sauce Cost,51450,
|
||||
206,CANNED AND DRY,OIL CORN,Dressing & Sauce Cost,51450,
|
||||
207,CANNED AND DRY,OLIVE KALAMATA PTD BRNE 22 LB,Produce Costs,51200,
|
||||
208,CANNED AND DRY,SPICE TURMERIC GROUND,Dry Goods Costs,51500,
|
||||
209,PRODUCE,SQUASH ZUCCHINI MEDIUM FRESH,Produce Costs,51200,
|
||||
210,PRODUCE,ONION GREEN ICELS ROOTLESS,Produce Costs,51200,
|
||||
211,CANNED AND DRY,SODA COLA PEPSI ZERO,Soft Beverage Costs,52000,
|
||||
@@ -219,7 +219,7 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
217,FROZEN,BAKLAVA GREEK PASTRY,Food Costs,50000,
|
||||
218,DISPENSER BEVRG,SYRUP DR PPR DIET BIB,Soft Beverage Costs,52000,
|
||||
219,DISPENSER BEVRG,SYRUP MOUNTAIN DEW BIB,Soft Beverage Costs,52000,
|
||||
220,CANNED AND DRY,SAUCE CHILI HOT SRIRACHA,Food Costs,50000,
|
||||
220,CANNED AND DRY,SAUCE CHILI HOT SRIRACHA,Dry Goods Costs,51500,
|
||||
221,PAPER & DISP,SKEWER BAMBOO 10IN,Paper Costs,55000,
|
||||
222,CANNED AND DRY,RICE BASMATI,Food Costs,50000,
|
||||
223,PAPER & DISP,WRAP PAPER 14X14 LOGO,Paper Costs,55000,
|
||||
@@ -227,12 +227,12 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
225,MEATS,PORK BUTT BNLS VP PR12,Beef/Pork Costs,51110,
|
||||
226,DAIRY PRODUCTS,YOGURT PLAIN GREEK NONFAT,Dairy Costs,51300,
|
||||
227,PAPER & DISP,TONG PLAS 9 BLK SNAP N SERVE,Paper Costs,55000,
|
||||
228,CANNED AND DRY,SAUCE HOT SRIRACHA,Food Costs,50000,
|
||||
228,CANNED AND DRY,SAUCE HOT SRIRACHA,Dry Goods Costs,51500,
|
||||
229,DISPENSER BEVRG,SYRUP DR PEPPER BIB,Soft Beverage Costs,52000,
|
||||
230,HLTHCAR/HOSPITALITY,BILLING MISC REGULAR,Food Costs,50000,
|
||||
231,PAPER & DISP,FORK PLAS BLK MEDHVY MDLNGTH,Paper Costs,55000,
|
||||
232,DAIRY PRODUCTS,YOGURT PLAIN ORIGINAL FTFR,Dairy Costs,51300,
|
||||
233,SUPP & EQUIP,MOP HEAD BLND LPD ALL PURP LRG,Food Costs,50000,
|
||||
233,SUPP & EQUIP,MOP HEAD BLND LPD ALL PURP LRG,Cleaning Supplies,74100,
|
||||
234,PAPER & DISP,SPOON PLAS WHT MEDHVY MDLNGTH,Paper Costs,55000,
|
||||
235,MEATS,BEEF GROUND BULK NAT 80/20,Beef/Pork Costs,51110,
|
||||
236,PAPER & DISP,CONTAINER PAPER HNG 9X6 PFF,Paper Costs,55000,
|
||||
@@ -245,9 +245,9 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
243,PRODUCE,CUCUMBER ENGLISH FRSH,Produce Costs,51200,
|
||||
244,PAPER & DISP,FORK PLAS WHT MED HVY MDLNGTH,Paper Costs,55000,
|
||||
245,PAPER & DISP,CUP PLAS 12-14OZ CLR STRT WALL,Paper Costs,55000,
|
||||
246,CANNED AND DRY,SAUCE HOT BOTTLE,Food Costs,50000,
|
||||
247,CANNED AND DRY,OIL OLIVE BLEND 80/20,Food Costs,50000,
|
||||
248,CANNED AND DRY,SPICE OREGANO LEAF RUBBED,Food Costs,50000,
|
||||
246,CANNED AND DRY,SAUCE HOT BOTTLE,Dry Goods Costs,51500,
|
||||
247,CANNED AND DRY,OIL OLIVE BLEND 80/20,Dry Goods Costs,51500,
|
||||
248,CANNED AND DRY,SPICE OREGANO LEAF RUBBED,Dry Goods Costs,51500,
|
||||
249,DAIRY PRODUCTS,YOGURT VANILLA GREEK NFAT,Dairy Costs,51300,
|
||||
250,FROZEN,BUN BRIOCHE HOMESTYLE 4,Food Costs,50000,
|
||||
251,CANNED AND DRY,WATER SPRKLG IMPRTD MNERAL GLS,Food Costs,50000,
|
||||
@@ -260,18 +260,18 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
258,CANNED AND DRY,PEPPER GREEN CHILI WHL,Food Costs,50000,
|
||||
259,CANNED AND DRY,PEPPER JALAPENO SLI FIELD RUN,Food Costs,50000,
|
||||
260,CANNED AND DRY,SAUCE MIX HOLLANDAISE GF,Food Costs,50000,
|
||||
261,CANNED AND DRY,VINEGAR DISTILLED WHITE 5%,Food Costs,50000,
|
||||
261,CANNED AND DRY,VINEGAR DISTILLED WHITE 5%,Dry Goods Costs,51500,
|
||||
262,PAPER & DISP,COVER TOILET SEAT,Paper Costs,55000,
|
||||
263,PAPER & DISP,FILM PVC 2000FT ROLL,Paper Costs,55000,
|
||||
264,PAPER & DISP,DOILY PAPER NRMDY LACE 6,Paper Costs,55000,
|
||||
265,PAPER & DISP,KIT CUTLERY MED PP KFS S&P NAP,Paper Costs,55000,
|
||||
266,PAPER & DISP,NAPKIN DNR 2P 15X16.25 1/8F WH,Paper Costs,55000,
|
||||
267,CHEMICAL/JANTRL,DETERGENT POT/PAN LIQ PINK RTU,Food Costs,50000,
|
||||
267,CHEMICAL/JANTRL,DETERGENT POT/PAN LIQ PINK RTU,Cleaning Supplies,74100,
|
||||
268,CHEMICAL/JANTRL,SALT GRANULE SOLAR WATER SOFT,Food Costs,50000,
|
||||
269,PRODUCE,CARROT FRESH JUMBO,Produce Costs,51200,
|
||||
270,PRODUCE,LIME FRESH 200CT,Produce Costs,51200,
|
||||
271,PAPER & DISP,CUP PLAS RPET CLR 16 OZ,Paper Costs,55000,
|
||||
272,CANNED AND DRY,SAUCE HOT,Food Costs,50000,
|
||||
272,CANNED AND DRY,SAUCE HOT,Dry Goods Costs,51500,
|
||||
273,MEATS,BACON SHINGLE 10/12 AW GF PR12,Beef/Pork Costs,51110,
|
||||
274,PAPER & DISP,CRAYON RED BLUE YEL GREEN,Paper Costs,55000,
|
||||
275,DAIRY PRODUCTS,CREAMER HALF AND HALF PC ASEP,Dairy Costs,51300,
|
||||
@@ -282,7 +282,7 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
280,CANNED AND DRY,KETCHUP SQUEEZE UPSIDE DOWN,Food Costs,50000,
|
||||
281,PAPER & DISP,GLOVE NITRILE FDSRV PF BLK LRG,Paper Costs,55000,
|
||||
282,MEATS,BEEF GRND CHUCK 81/19 CHUB FRS,Beef/Pork Costs,51110,
|
||||
283,PAPER & DISP,LID FOIL F/FULL STM TBL PAN,Paper Costs,55000,
|
||||
283,PAPER & DISP,LID FOIL F/FULL STM TBL PAN,Dry Goods Costs,51500,
|
||||
284,PAPER & DISP,FORK WOODEN DISP,Paper Costs,55000,
|
||||
285,PAPER & DISP,SKEWER BAMBOO THIN 8 IN,Paper Costs,55000,
|
||||
286,PAPER & DISP,WRAP DELI WHT 12X12 GRS RESIST,Paper Costs,55000,
|
||||
@@ -292,9 +292,9 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
290,PAPER & DISP,TRAY FOOD PAPER #2 LOGO,Paper Costs,55000,
|
||||
291,PAPER & DISP,LID PLAS CLR F/1.5-2.5OZ PRTN,Paper Costs,55000,
|
||||
292,DISPENSER BEVRG,SYRUP TEA RASP 5X1 BRISK,Soft Beverage Costs,52000,
|
||||
293,PAPER & DISP,PAN FOIL STM TBL FULL DP 3-3/8,Paper Costs,55000,
|
||||
293,PAPER & DISP,PAN FOIL STM TBL FULL DP 3-3/8,Dry Goods Costs,51500,
|
||||
294,DISPENSER BEVRG,SYRUP ROOT BEER BIB,Soft Beverage Costs,52000,
|
||||
295,PAPER & DISP,FOIL ALMN ROLL HVY WGT 1000 FT,Paper Costs,55000,
|
||||
295,PAPER & DISP,FOIL ALMN ROLL HVY WGT 1000 FT,Dry Goods Costs,51500,
|
||||
296,DAIRY PRODUCTS,CHEESE GORGONZOLA WHEEL HALF,Dairy Costs,51300,
|
||||
297,DAIRY PRODUCTS,CHEESE MOZZ WM SHRED GOLD PREM,Dairy Costs,51300,
|
||||
298,DAIRY PRODUCTS,CREAM SOUR CULTRD GRADE A,Dairy Costs,51300,
|
||||
@@ -311,7 +311,7 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
309,SEAFOOD,SCALLOP SEA WTR ADD 10/20 USA,Seafood Costs,51130,
|
||||
310,MEATS,BACON SLAB SLI 13/17 CT PR12,Beef/Pork Costs,51110,
|
||||
311,CANNED AND DRY,DRESSING MIX RANCH,Food Costs,50000,
|
||||
312,FROZEN,BUN BRIOCHE HOMESTYLE 4.25,Food Costs,50000,
|
||||
312,FROZEN,BUN BRIOCHE HOMESTYLE 4.25,Bread and Bun Costs,51400,
|
||||
313,CANNED AND DRY,SODA LEMON LIME,Soft Beverage Costs,52000,
|
||||
314,FROZEN,PUREE ORANGE BLOOD CONCENTRATE,Food Costs,50000,
|
||||
315,CANNED AND DRY,FLOUR HI-GLUTEN BL EN MT AA,Dry Good Costs,51500,
|
||||
@@ -327,23 +327,23 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
325,DAIRY PRODUCTS,CHEESE FETA CHUNKS PAIL PREM,Dairy Costs,51300,
|
||||
326,PRODUCE,PEPPER GREEN BELL LARGE FRESH,Produce Costs,51200,
|
||||
327,PRODUCE,TOMATO ROMA FRSH,Produce Costs,51200,
|
||||
328,CHEMICAL/JANTRL,CLEANER DEGREASER OVEN RTU,Food Costs,50000,
|
||||
328,CHEMICAL/JANTRL,CLEANER DEGREASER OVEN RTU,Cleaning Supplies,74100,
|
||||
329,PAPER & DISP,BOX CORR CATER #4 LOGO 2021,Paper Costs,55000,
|
||||
330,PAPER & DISP,LID PLAS HI DOME DESSRT,Paper Costs,55000,
|
||||
331,MEATS,PORK BUTT BNLS 1/4 6-9#EA,Beef/Pork Costs,51110,
|
||||
332,PAPER & DISP,CUP PAPER HOT WHT TALL 12OZ,Paper Costs,55000,
|
||||
333,PAPER & DISP,SPOON PLAS SOUP BLACK XHEAVY,Paper Costs,55000,
|
||||
334,PAPER & DISP,FOIL ALMN ROLL STD WGT 1000 FT,Paper Costs,55000,
|
||||
335,SUPP & EQUIP,PAD SCRUB STNLS 50GR 1.75OZ,Food Costs,50000,
|
||||
334,PAPER & DISP,FOIL ALMN ROLL STD WGT 1000 FT,Dry Goods Costs,51500,
|
||||
335,SUPP & EQUIP,PAD SCRUB STNLS 50GR 1.75OZ,Cleaning Supplies,74100,
|
||||
336,DISPENSER BEVRG,SYRUP TEA UNSWTD 5X1,Soft Beverage Costs,52000,
|
||||
337,FROZEN,BUN BRIOCHE SPLIT TOP 4IN SLI,Food Costs,50000,
|
||||
337,FROZEN,BUN BRIOCHE SPLIT TOP 4IN SLI,Bread and Bun Costs,51400,
|
||||
338,CANNED AND DRY,WATER MINERAL LIMONATA CAN,Beverages Costs,52000,
|
||||
339,PRODUCE,GARLIC PEELED CHINESE,Produce Costs,51200,
|
||||
340,PAPER & DISP,LID PLAS FLAT F/12-24Z PET CUP,Paper Costs,55000,
|
||||
341,FROZEN,BILLING MISC FROZEN,Food Costs,50000,
|
||||
342,PAPER & DISP,KNIFE PLAS BLK PLA COMPSTABLE,Paper Costs,55000,
|
||||
343,PAPER & DISP,GLOVE NITRILE MED,Paper Costs,55000,
|
||||
344,SUPP & EQUIP,GRILL BRICK 3.5IN THICK,Food Costs,50000,
|
||||
344,SUPP & EQUIP,GRILL BRICK 3.5IN THICK,Cleaning Supplies,74100,
|
||||
345,PRODUCE,CABBAGE SAVOY FRSH,Produce Costs,51200,
|
||||
346,PRODUCE,FLOWER ORCHID MULTI COLORED,Produce Costs,51200,
|
||||
347,CANNED AND DRY,OIL AVOCADO,Food Costs,50000,
|
||||
@@ -393,7 +393,7 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
391,PAPER & DISP,TOWEL MULTIFOLD 9.4X9.2 WHT 1P,Paper Costs,55000,
|
||||
392,PAPER & DISP,TISSUE TOILET WRPD 4X3.8 2PLY,Paper Costs,55000,
|
||||
393,MEATS,BEEF GRND 80/20 BULK,Beef/Pork Costs,51110,
|
||||
394,CANNED AND DRY,MAYONNAISE HEAVY DUTY,Food Costs,50000,
|
||||
394,CANNED AND DRY,MAYONNAISE HEAVY DUTY,Dressing & Sauce Cost,51450,
|
||||
395,PAPER & DISP,CONTAINER PLAS HNG 9X6 WHT,Paper Costs,55000,
|
||||
396,PAPER & DISP,SPOON PLASTIC BLK BAGGED,Paper Costs,55000,
|
||||
397,PAPER & DISP,FORK PLAS BLK HVY FULL LNGTH,Paper Costs,55000,
|
||||
@@ -406,7 +406,7 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
404,CANNED AND DRY,BEAN BLACK,Food Costs,50000,
|
||||
405,CANNED AND DRY,SYRUP CHOCOLATE PLAS JUG,Food Costs,50000,
|
||||
406,CANNED AND DRY,TUNA LIGHT SKIPJACK CHUNK WTR,Food Costs,50000,
|
||||
407,CHEMICAL/JANTRL,DEGREASER HEAVY DUTY RTU,Food Costs,50000,
|
||||
407,CHEMICAL/JANTRL,DEGREASER HEAVY DUTY RTU,Cleaning Supplies,74100,
|
||||
408,CANNED AND DRY,JAM BLACKBERRY CUP,Food Costs,50000,
|
||||
409,CANNED AND DRY,PICKLE WHL DILL KO REF 75/85,Food Costs,50000,
|
||||
410,DAIRY PRODUCTS,CHEESE QUESO FRESCO CASERO,Dairy Costs,51300,
|
||||
@@ -423,7 +423,7 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
421,PAPER & DISP,CONTAINER PAPER HNG 9X6 FIB,Paper Costs,55000,
|
||||
422,PAPER & DISP,LINER REPRO 40X46 1.5 ML BLK,Paper Costs,55000,
|
||||
423,PAPER & DISP,TAPE PAPR REG THERMAL 3-1/8,Paper Costs,55000,
|
||||
424,SUPP & EQUIP,PAD SCOUR GRN 6X9IN ANTIMICRO,Food Costs,50000,
|
||||
424,SUPP & EQUIP,PAD SCOUR GRN 6X9IN ANTIMICRO,Cleaning Supplies,74100,
|
||||
425,PAPER & DISP,GLOVE NITRILE BLUE XL,Paper Costs,55000,
|
||||
426,PRODUCE,CUCUMBER ENGLISH LONG,Produce Costs,51200,
|
||||
427,PRODUCE,TOMATO ROMA MED,Produce Costs,51200,
|
||||
@@ -434,13 +434,13 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
432,POULTRY,CHICKEN CVP THIGH B/S HALAL JM,Chicken/ Poultry Costs,51120,
|
||||
433,CANNED AND DRY,DRESSING HONEY MUSTARD,Food Costs,50000,
|
||||
434,PRODUCE,JUICE LEMON PSTRZD ULTRA PREM,Produce Costs,51200,
|
||||
435,CANNED AND DRY,SPICE PAPRIKA GROUND,Food Costs,50000,
|
||||
435,CANNED AND DRY,SPICE PAPRIKA GROUND,Dry Goods Costs,51500,
|
||||
436,PAPER & DISP,FORK PLAS WHT HVY FULL LENGTH,Paper Costs,55000,
|
||||
437,PAPER & DISP,LID FOIL F/ HALF STMTBL PAN,Paper Costs,55000,
|
||||
437,PAPER & DISP,LID FOIL F/ HALF STMTBL PAN,Dry Goods Costs,51500,
|
||||
438,SUPP & EQUIP,PAN FOIL HALF DEEP 100CT,Food Costs,50000,
|
||||
439,PAPER & DISP,CONTAINER FOAM HNG LRG 1C,Paper Costs,55000,
|
||||
440,PAPER & DISP,LINER TRASH 40X48 13 MC NAT,Paper Costs,55000,
|
||||
441,CANNED AND DRY,KETCHUP FCY,Food Costs,50000,
|
||||
441,CANNED AND DRY,KETCHUP FCY,Dry Goods Costs,51500,
|
||||
442,PAPER & DISP,STRAW PLAS WRPD FLEX WHT 7.625,Paper Costs,55000,
|
||||
443,CANNED AND DRY,WATER SPRKLG CHRY/POMGRNT,Food Costs,50000,
|
||||
444,PAPER & DISP,APRON POLY EMBSD WHT 28X46 ECO,Paper Costs,55000,
|
||||
@@ -457,13 +457,13 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
455,SEAFOOD,CALAMARI TUBE & TNT 5-8 INCH,Seafood Costs,51130,
|
||||
456,SEAFOOD,SHRIMP CKD & PLD BAY 90\150 FZ,Seafood Costs,51130,
|
||||
457,SEAFOOD,SHRIMP WHT GH 16-20,Seafood Costs,51130,
|
||||
458,SUPP & EQUIP,BROOM ANGULAR FLAGGED,Food Costs,50000,
|
||||
458,SUPP & EQUIP,BROOM ANGULAR FLAGGED,Cleaning Supplies,74100,
|
||||
459,CANNED AND DRY,SAUCE CHILI SRIRACHA,Food Costs,50000,
|
||||
460,PAPER & DISP,CONTAINER PAPER #1 TK OUT KRFT,Paper Costs,55000,
|
||||
461,PAPER & DISP,SPOON PLAS BLK MEDHVY MDLNGTH,Paper Costs,55000,
|
||||
462,PAPER & DISP,LID PLAS F/ 12/16/21/24 CUPS,Paper Costs,55000,
|
||||
463,PAPER & DISP,GLOVE NITRILE FDSRV PF BLU XL,Paper Costs,55000,
|
||||
464,CANNED AND DRY,SODA ORANGE,Food Costs,50000,
|
||||
464,CANNED AND DRY,SODA ORANGE,Soft Beverage Cost,52000,
|
||||
465,PRODUCE,SQUASH ZUCCHINI MED FRSH,Produce Costs,51200,
|
||||
466,CANNED AND DRY,SPICE TURMERIC GRND ORGANIC,Food Costs,50000,
|
||||
467,CANNED AND DRY,WATER SPRING IMPORTED GLS,Food Costs,50000,
|
||||
@@ -474,9 +474,9 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
472,PAPER & DISP,GLOVE NITRILE BLK PEDRFREE LRG,Paper Costs,55000,
|
||||
473,PAPER & DISP,TRAY CARRYOUT 4CUP,Paper Costs,55000,
|
||||
474,PAPER & DISP,BAG PLAS T-SHRT THNKYOU12X7X22,Paper Costs,55000,
|
||||
475,PAPER & DISP,GRILL BRICK 3.5IN THICK,Paper Costs,55000,
|
||||
476,PAPER & DISP,PAD SCOUR GRN 6X9IN ANTIMICRO,Paper Costs,55000,
|
||||
477,PAPER & DISP,PAD SCRUB STNLS 50GR 1.75OZ,Paper Costs,55000,
|
||||
475,PAPER & DISP,GRILL BRICK 3.5IN THICK,Cleaning Supplies,74100,
|
||||
476,PAPER & DISP,PAD SCOUR GRN 6X9IN ANTIMICRO,Cleaning Supplies,74100,
|
||||
477,PAPER & DISP,PAD SCRUB STNLS 50GR 1.75OZ,Cleaning Supplies,74100,
|
||||
478,PAPER & DISP,LID TOGO PLAS F/12-16-32 OZ,Paper Costs,55000,
|
||||
479,PRODUCE,PEPPER GREEN BELL FRSH LG,Produce Costs,51200,
|
||||
480,DISPENSER BEVRG,SYRUP COLA WILD CHERRY,Soft Beverage Costs,52000,
|
||||
@@ -486,25 +486,25 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
484,PRODUCE,TOMATO ROMA FRESH,Produce Costs,51200,
|
||||
485,DAIRY PRODUCTS,YOGURT PLAIN GREEK WM 4% FAT,Dairy Costs,51300,
|
||||
486,CHEMICAL/JANTRL,CLEANER OVEN GREASESTRIP+ NP,Food Costs,50000,
|
||||
487,FROZEN,BUN BRIOCHE SLI 4.5,Food Costs,50000,
|
||||
487,FROZEN,BUN BRIOCHE SLI 4.5,Bread and Bun Costs,51400,
|
||||
488,PRODUCE,LEMON FRESH,Produce Costs,51200,
|
||||
489,SUPP & EQUIP,DISH SUPREME GLASS,Food Costs,50000,
|
||||
490,PAPER & DISP,CONTAINER POLYETHELYN,Paper Costs,55000,
|
||||
491,PAPER & DISP,FILTER GREASE CONE 10 IN,Paper Costs,55000,
|
||||
491,PAPER & DISP,FILTER GREASE CONE 10 IN,Cleaning Supplies,74100,
|
||||
492,PRODUCE,SQUASH ZUCCHINI FCY FRESH,Produce Costs,51200,
|
||||
493,CHEMICAL/JANTRL,CLEANER ALL PURPOSE PINE RTU,Food Costs,50000,
|
||||
494,CANNED AND DRY,KETCHUP PACKET FCY FOIL,Food Costs,50000,
|
||||
493,CHEMICAL/JANTRL,CLEANER ALL PURPOSE PINE RTU,Cleaning Supplies,74100,
|
||||
494,CANNED AND DRY,KETCHUP PACKET FCY FOIL,Dry Goods Costs,51500,
|
||||
495,PRODUCE,MUSHROOM PORTABELLA CP LRG FSH,Produce Costs,51200,
|
||||
496,PAPER & DISP,CONTAINER MINERAL 9X6 HNG 1CPT,Paper Costs,55000,
|
||||
497,PAPER & DISP,WRAP DRY WAX DELI HVY 10X10.75,Paper Costs,55000,
|
||||
498,PAPER & DISP,LID PLAS 12/16/22 OZ CUP,Paper Costs,55000,
|
||||
499,CHEMICAL/JANTRL,POLISH S-S SATIN SHINE ARSL,Food Costs,50000,
|
||||
500,CANNED AND DRY,SPICE MARJORAM LVS,Food Costs,50000,
|
||||
501,SUPP & EQUIP,BOTTLE PLASTIC SQUEEZE WIDEMTH,Food Costs,50000,
|
||||
502,PAPER & DISP,PAN FOIL STM TBL DEEPXH 2-9/16,Paper Costs,55000,
|
||||
499,CHEMICAL/JANTRL,POLISH S-S SATIN SHINE ARSL,Cleaning Supplies,74100,
|
||||
500,CANNED AND DRY,SPICE MARJORAM LVS,Dry Goods Costs,51500,
|
||||
501,SUPP & EQUIP,BOTTLE PLASTIC SQUEEZE WIDEMTH,Paperware Cost,55000,
|
||||
502,PAPER & DISP,PAN FOIL STM TBL DEEPXH 2-9/16,Dry Goods Costs,51500,
|
||||
503,PAPER & DISP,TONG PLAS BLK 6.25IN SM SRVING,Paper Costs,55000,
|
||||
504,PAPER & DISP,FORK PLAS WHT P/P,Paper Costs,55000,
|
||||
505,CHEMICAL/JANTRL,CLEANER DEGRSR HGH TMP GRL RTU,Food Costs,50000,
|
||||
505,CHEMICAL/JANTRL,CLEANER DEGRSR HGH TMP GRL RTU,Cleaning Supplies,74100,
|
||||
506,DISPENSER BEVRG,SYRUP BASE ORG CRSH BIB,Soft Beverage Costs,52000,
|
||||
507,PAPER & DISP,CONTAINER PAPER #4 TAKEOUT WHT,Paper Costs,55000,
|
||||
508,CANNED AND DRY,CAPER NONPAREIL IMPORTED,Food Costs,50000,
|
||||
@@ -515,7 +515,7 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
513,PAPER & DISP,FORK PLASTIC WRPD PP XHVY BLK,Paper Costs,55000,
|
||||
514,MEATS,LAMB RIBLET FZN,Meats Costs,51110,
|
||||
515,CANNED AND DRY,WATER BOTTLED,Food Costs,50000,
|
||||
516,CHEMICAL/JANTRL,SOAP HAND LIQ PINK RTU,Food Costs,50000,
|
||||
516,CHEMICAL/JANTRL,SOAP HAND LIQ PINK RTU,Cleaning Supplies,74100,
|
||||
517,PAPER & DISP,LINER TRASH 40X46 1.5 ML BLU,Paper Costs,55000,
|
||||
518,CANNED AND DRY,SYSCO CUSTOMER AGREEMENT,Food Costs,50000,
|
||||
519,CANNED AND DRY,HONEY WILDFLOWER BLOSSOM,Food Costs,50000,
|
||||
@@ -532,12 +532,12 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
530,CANNED AND DRY,JUICE APPLE GLASS FCY,Food Costs,50000,
|
||||
531,DAIRY PRODUCTS,MILK ALMOND BARISTA BLEND,Dairy Costs,51300,
|
||||
532,CANNED AND DRY,SPICE OREGANO LEAF,Food Costs,50000,
|
||||
533,CANNED AND DRY,SAUCE CHILI SRIRACHA CHA,Food Costs,50000,
|
||||
533,CANNED AND DRY,SAUCE CHILI SRIRACHA CHA,Dry Goods Costs,51500,
|
||||
534,CANNED AND DRY,OLIVE KALAMATA PTD PLAS KEG,Food Costs,50000,
|
||||
535,CANNED AND DRY,MUSTARD YELLOW PRPD,Food Costs,50000,
|
||||
536,CHEMICAL/JANTRL,CLEANER DEGRSR GREASELIFT RTU,Food Costs,50000,
|
||||
537,CANNED AND DRY,SALT PKT .6 GM,Food Costs,50000,
|
||||
538,CANNED AND DRY,SPICE PEPPER PACKET .1 GM,Food Costs,50000,
|
||||
538,CANNED AND DRY,SPICE PEPPER PACKET .1 GM,Dry Goods Costs,51500,
|
||||
539,PAPER & DISP,LINER ROLL COMPOST47X60 1ML,Paper Costs,55000,
|
||||
540,CANNED AND DRY,WATER SPRKLG ORG ARANCAT CAN,Food Costs,50000,
|
||||
541,DISPENSER BEVRG,SYRUP COKE DIET 5X1 BIB,Soft Beverage Costs,52000,
|
||||
@@ -564,7 +564,7 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
562,CHEMICAL/JANTRL,BLEACH LIQUID DISINFECT CLENER,Food Costs,50000,
|
||||
563,PAPER & DISP,CONTAINER PAPER FBR 9X6 1CPFF,Paper Costs,55000,
|
||||
564,CANNED AND DRY,SODA COKE MEXICO GLASS NON RET,Soft Beverage Costs,52000,
|
||||
565,CANNED AND DRY,SPICE CINNAMON STICK,Food Costs,50000,
|
||||
565,CANNED AND DRY,SPICE CINNAMON STICK,Dry Goods Costs,51500,
|
||||
566,CANNED AND DRY,WALNUT HALVES AND PCS,Food Costs,50000,
|
||||
567,DISPENSER BEVRG,TEA ICED CONC RASP 5.5+1,Soft Beverage Costs,52000,
|
||||
568,MEATS,BEEF GRND BULK 81/19 CHUB FRS,Beef/Pork Costs,51110,
|
||||
@@ -658,7 +658,7 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
656,PAPER & DISP,CUP PLAS TRANS 16OZ SOFT,Paper Costs,55000,
|
||||
657,PRODUCE,PARSLEY BUNCH FDSVC,Produce Costs,51200,
|
||||
658,PAPER & DISP,LINER TRASH 24X32 .5 ML BLK,Paper Costs,55000,
|
||||
659,CANNED AND DRY,SPICE CINNAMON GRND,Food Costs,50000,
|
||||
659,CANNED AND DRY,SPICE CINNAMON GRND,Dry Goods Costs,51500,
|
||||
660,PAPER & DISP,FORK PLAS HVY STY BLK,Paper Costs,55000,
|
||||
661,PAPER & DISP,SPOON PLAS PP HVY BLK FULL LEN,Paper Costs,55000,
|
||||
662,DAIRY PRODUCTS,EGG SHELL LG PAST CF,Dairy Costs,51300,
|
||||
@@ -797,7 +797,7 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
795,PAPER & DISP,SUPPLY ACCESSORIES SOTF COM,Paper Costs,55000,
|
||||
796,PAPER & DISP,LID PLAS CLR FLT W/SLT 12-24OZ,Paper Costs,55000,
|
||||
797,PAPER & DISP,TRAY PAPER PULP CARRYOUT 4 CUP,Paper Costs,55000,
|
||||
798,CHEMICAL/JANTRL,SANITIZER OASIS 146 MULTI QUAT,Food Costs,50000,
|
||||
798,CHEMICAL/JANTRL,SANITIZER OASIS 146 MULTI QUAT,Cleaning Supplies,74100,
|
||||
799,PAPER & DISP,CUP PLAS CLR TALL 8OZ RIGID,Paper Costs,55000,
|
||||
800,PAPER & DISP,BILLING MISC DISP,Paper Costs,55000,
|
||||
801,PAPER & DISP,LID FOIL F/ HALF STM TBL PAN,Paper Costs,55000,
|
||||
@@ -816,7 +816,7 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
814,CANNED AND DRY,FLOUR SEMOLINA UNBLCH,Food Costs,50000,
|
||||
815,CANNED AND DRY,SAUCE HOT SRIRACHA BLUE AGAVE,Food Costs,50000,
|
||||
816,PAPER & DISP,LINER TRASH 43X48 16 MC NAT,Paper Costs,55000,
|
||||
817,CANNED AND DRY,WALNUT HALF & PCS,Food Costs,50000,
|
||||
817,CANNED AND DRY,WALNUT HALF & PCS,Produce Costs,51200,
|
||||
818,MEATS,BEEF SHORT RIB ASIAN CUT 1/4,Beef/Pork Costs,51110,
|
||||
819,PAPER & DISP,LINER PLAS INSERT/WARMER 18X14,Paper Costs,55000,
|
||||
820,PAPER & DISP,BOX PIZZA 14 W/K B-FLT 1-7/8,Paper Costs,55000,
|
||||
@@ -877,7 +877,7 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
875,DAIRY PRODUCTS,CHEESE CREAM LIGHT CUP,Dairy Costs,51300,
|
||||
876,DAIRY PRODUCTS,YOGURT BLUEBERRY GREEK NON FAT,Dairy Costs,51300,
|
||||
877,HLTHCAR/HOSPITALITY,PERKS MEMBERSHIP FEE,Food Costs,50000,
|
||||
878,CANNED AND DRY,DRESSING RED WINE VINGRT METRO,Wine Costs,54400,
|
||||
878,CANNED AND DRY,DRESSING RED WINE VINGRT METRO,Dressing & Sauce Cost,51450,
|
||||
879,CANNED AND DRY,SPICE GARLIC PWDR,Food Costs,50000,
|
||||
880,CANNED AND DRY,SPICE ONION POWDER,Food Costs,50000,
|
||||
881,CANNED AND DRY,SALT KOSHER FLAKE COARSE,Food Costs,50000,
|
||||
@@ -925,7 +925,7 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
923,CANNED AND DRY,SAUCE WORCESTERSHIRE,Food Costs,50000,
|
||||
924,PAPER & DISP,CONTAINER PLAS 1C HNG 6X6 WHT,Paper Costs,55000,
|
||||
925,PRODUCE,PINEAPPLE FRESH,Produce Costs,51200,
|
||||
926,SUPP & EQUIP,MOP HEAD CTN CUT END VALUE #24,Food Costs,50000,
|
||||
926,SUPP & EQUIP,MOP HEAD CTN CUT END VALUE #24,Cleaning Supplies,74100,
|
||||
927,DAIRY PRODUCTS,CHEESE RICOTTA WMHM SEL,Dairy Costs,51300,
|
||||
928,MEATS,BACON LAYFLAT NT CC 13/17 PR12,Beef/Pork Costs,51110,
|
||||
929,CANNED AND DRY,COOKIE CRUMB OREO MED CRUNCH,Food Costs,50000,
|
||||
@@ -1005,7 +1005,7 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
1004,DAIRY PRODUCTS,CHEESE BLUE STUFFED OLIVES,Dairy Costs,51300,
|
||||
1005,SUPP & EQUIP,HANDLE MOP FIBRGLS QUICK CHNGE,Food Costs,50000,
|
||||
1006,SUPP & EQUIP,SUPPLY SOTF JANSAN,Food Costs,50000,
|
||||
1007,CANNED AND DRY,WALNUT HALVES & PCS,Food Costs,50000,
|
||||
1007,CANNED AND DRY,WALNUT HALVES & PCS,Produce Costs,51200,
|
||||
1008,CANNED AND DRY,WATER SPARKLN ORG PRCKLY PEAR,Beverages Costs,52000,
|
||||
1009,CANNED AND DRY,DRINK NATURAL CLMTN SPRKLG,Food Costs,50000,
|
||||
1010,CANNED AND DRY,DRESSING MIX RNCH BTRMK NO MSG,Food Costs,50000,
|
||||
@@ -1032,7 +1032,7 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
1031,CANNED AND DRY,DRESSING BALSAMIC VINGT GARLIC,Food Costs,50000,
|
||||
1032,CANNED AND DRY,SPREAD CHOC NUTELLA JAR FDSRV,Food Costs,50000,
|
||||
1033,CANNED AND DRY,SUGAR BROWN LIGHT,Food Costs,50000,
|
||||
1034,PAPER & DISP,PAD SCOUR 6X9 HVYDTY ANTIMICRO,Paper Costs,55000,
|
||||
1034,PAPER & DISP,PAD SCOUR 6X9 HVYDTY ANTIMICRO,Cleaning Supplies,74100,
|
||||
1035,CHEMICAL/JANTRL,DETERGENT POT/PAN LIQ GRN RTU,Food Costs,50000,
|
||||
1036,PAPER & DISP,KNIFE PLAS HVY STY BLK,Paper Costs,55000,
|
||||
1037,CHEMICAL/JANTRL,CLEANER DISINFECT PEROX RTU,Food Costs,50000,
|
||||
@@ -1111,7 +1111,7 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
1110,PAPER & DISP,BAG PAPER BRN W/HNDL REGAL 65#,Paper Costs,55000,
|
||||
1111,DAIRY PRODUCTS,EGG SHELL WHT CAGEFREE GR A LG,Dairy Costs,51300,
|
||||
1112,CANNED AND DRY,VINEGAR RICE SEASONED,Food Costs,50000,
|
||||
1113,CANNED AND DRY,VINEGAR WHITE DSTD 5%,Food Costs,50000,
|
||||
1113,CANNED AND DRY,VINEGAR WHITE DSTD 5%,Dry Goods Costs,51500,
|
||||
1114,SEAFOOD,SHRIMP WHT GH 13-15,Seafood Costs,51130,
|
||||
1115,PAPER & DISP,BAG PLAS PRTN 6.5X7 ORG SAT,Paper Costs,55000,
|
||||
1116,CANNED AND DRY,BREAD CRUMB JAP PANKO TOASTED,Food Costs,50000,
|
||||
@@ -1489,7 +1489,7 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
1488,POULTRY,CHICKEN CVP WHL WOG FZ,Chicken/ Poultry Costs,51120,
|
||||
1489,PRODUCE,LEEK BUNCH FRSH ICELS,Produce Costs,51200,
|
||||
1490,CANNED AND DRY,BILLING MISC CANNED/DRY,Food Costs,50000,
|
||||
1491,PAPER & DISP,PAN FOIL STEAM TBL HALF DEEP,Paper Costs,55000,
|
||||
1491,PAPER & DISP,PAN FOIL STEAM TBL HALF DEEP,Dry Goods Costs,51500,
|
||||
1492,PAPER & DISP,TRAY PAPER CARRIER 4 CUP,Paper Costs,55000,
|
||||
1493,PAPER & DISP,KIT CUTLERY FKS/SP/NP HW PP BK,Paper Costs,55000,
|
||||
1494,MEATS,BEEF PATTY 80/20 RND FRSH,Beef/Pork Costs,51110,
|
||||
@@ -1498,7 +1498,7 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
1497,DAIRY PRODUCTS,EGG HARDCOOKED CGFREE HARD PK,Dairy Costs,51300,
|
||||
1498,SEAFOOD,SHRIMP WHT P&D TLOF 26/30,Seafood Costs,51130,
|
||||
1499,PAPER & DISP,CONTAINER PAPER HNG 9X6 1C FBR,Paper Costs,55000,
|
||||
1500,CHEMICAL/JANTRL,DETERGENT POT & PAN LIQUID,Food Costs,50000,
|
||||
1500,CHEMICAL/JANTRL,DETERGENT POT & PAN LIQUID,Cleaning Supplies,74100,
|
||||
1501,FROZEN,ASPARAGUS SPEAR MED IQF P,Produce Costs,51200,
|
||||
1502,PRODUCE,ASPARAGUS FRESH STANDARD,Produce Costs,51200,
|
||||
1503,SUPP & EQUIP,SCREEN GRIDDLE 4X6IN,Food Costs,50000,
|
||||
@@ -1515,7 +1515,7 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
1514,PAPER & DISP,GLOVE SYNTHETIC FDSRV PF MED,Paper Costs,55000,
|
||||
1515,POULTRY,CHICKEN THIGH BNLS SKIN-ON RAW,Chicken/ Poultry Costs,51120,
|
||||
1516,CANNED AND DRY,SPICE SAGE GRND,Food Costs,50000,
|
||||
1517,CANNED AND DRY,KETCHUP SQUEEZE RED UPSIDE DWN,Food Costs,50000,
|
||||
1517,CANNED AND DRY,KETCHUP SQUEEZE RED UPSIDE DWN,Dry Goods Costs,51500,
|
||||
1518,CANNED AND DRY,SPICE NUTMEG WHL,Food Costs,50000,
|
||||
1519,DAIRY PRODUCTS,YOGURT PLAIN FULL FAT,Dairy Costs,51300,
|
||||
1520,CANNED AND DRY,VINEGAR WINE RED ITALY 6% GLS,Wine Costs,54400,
|
||||
@@ -1555,7 +1555,7 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
1554,FROZEN,BAKLAVA WALNT TRIANGLES,Food Costs,50000,
|
||||
1555,PAPER & DISP,DISPENSER NAP XPRSNP STND BLK,Paper Costs,55000,
|
||||
1556,PAPER & DISP,DISPENSER TOWEL MANUL COMP360,Paper Costs,55000,
|
||||
1557,PAPER & DISP,PAN FOIL STM TBL MED 2-3/16,Paper Costs,55000,
|
||||
1557,PAPER & DISP,PAN FOIL STM TBL MED 2-3/16,Dry Goods Costs,51500,
|
||||
1558,PRODUCE,ONION RED JUMBO CTN,Produce Costs,51200,
|
||||
1559,MEATS,BEEF CHUCK SHORTRIB KOREAN1/2,Beef/Pork Costs,51110,
|
||||
1560,FROZEN,RICE MEXICAN STY,Food Costs,50000,
|
||||
@@ -1653,7 +1653,7 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
1652,CANNED AND DRY,SODA COKE CHERRY ZERO CONTOUR,Soft Beverage Costs,52000,
|
||||
1653,FROZEN,ENTREE VEG FALAFEL BALLS VEGAN,Food Costs,50000,
|
||||
1654,SUPP & EQUIP,BRUSH GRILL W/SCRPR 27 IN HNDL,Food Costs,50000,
|
||||
1655,CANNED AND DRY,SAUCE HOT SRIRACHA HUY FONG,Food Costs,50000,
|
||||
1655,CANNED AND DRY,SAUCE HOT SRIRACHA HUY FONG,Dry Goods Costs,51500,
|
||||
1656,PAPER & DISP,TOWEL MULTIFOLD PRM LEAF,Paper Costs,55000,
|
||||
1657,CANNED AND DRY,SODA LEMON LIME 12OZ,Soft Beverage Costs,52000,
|
||||
1658,PAPER & DISP,KNIFE PLAS WRP BLK,Paper Costs,55000,
|
||||
@@ -1709,7 +1709,7 @@ Id,Sysco Category,Sysco Description,Integreat Account,Integreat Account Code,Nic
|
||||
1708,PAPER & DISP,CONTAINER PLAS CLR HNG 8IN,Paper Costs,55000,
|
||||
1709,PAPER & DISP,TISSUE TOILET 2PL ADVC WHT WR,Paper Costs,55000,
|
||||
1710,SUPP & EQUIP,SPATULA RUBBER SILICONE 10.25,Food Costs,50000,
|
||||
1711,CANNED AND DRY,BEAN GARBANZO FCY NO SULFITE,Food Costs,50000,
|
||||
1711,CANNED AND DRY,BEAN GARBANZO FCY NO SULFITE,Dry Goods Costs,51500,
|
||||
1712,MEATS,BACON SLI APLWD 13/17CT PR12,Beef/Pork Costs,51110,
|
||||
1713,POULTRY,SAUSAGE CHICKEN APPLE RAW 1 OZ,Poultry Costs,51120,
|
||||
1714,CANNED AND DRY,TOMATO SUNDRIED JULENNE,Food Costs,50000,
|
||||
@@ -1759,4 +1759,73 @@ 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,Dry Goods Costs,51500,
|
||||
1767,CANNED AND DRY,CHOCOLATE DUBAI PISTCHO KUNFEH,Dry Goods Costs,51500,
|
||||
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,PAPER & DISP,DESSERT CUP,Paper Costs,55000,
|
||||
1773,FROZEN,DESSERT MINI PLAIN BEIGNET,Bread and Bun Costs,51400,
|
||||
1774,CANNED AND DRY,DIP GARLIC TOUM,Dressing & Sauce Cost,51450,
|
||||
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,Soft Beverage Cost,52000,
|
||||
1780,CANNED AND DRY,JUICE CONC STRAWB DRAGON,Soft Beverage Cost,52000,
|
||||
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,Dressing & Sauce Cost,51450,
|
||||
1785,CANNED AND DRY,PASTE TAHINI DRESSING,Dressing & Sauce Cost,51450,
|
||||
1786,FROZEN,PASTRY BEIGNET MN FLD CHOCCRML,Bread and Bun Costs,51400,
|
||||
1787,CANNED AND DRY,PEPPER BANANA MILD RING,Produce Costs,51200,
|
||||
1788,CANNED AND DRY,RICE MIX NICKS,Dry Goods Costs,51500,
|
||||
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,Bread and Bun Costs,51400,
|
||||
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,
|
||||
1796,FROZEN,BALL FALAFEL FRTTR 1 OZ IQF,Dry Goods Costs,51500,
|
||||
1797,SUPP & EQUIP,BASKET PLAS 10.5X7X1.5 BLK,Paperware Cost,55000,
|
||||
1798,CANNED AND DRY,BEAN GARBANZO LOW SODIUM,Dry Goods Costs,51500,
|
||||
1799,FROZEN,BREAD POTATO ROLL 4 INCH,Bread and Bun Costs,51400,
|
||||
1800,FROZEN,BUN HAMBURGER 4IN 1.75 OZ,Bread and Bun Costs,51400,
|
||||
1801,DAIRY PRODUCTS,CHEESE FETA CRUMBLES,Dairy Costs,51300,
|
||||
1802,FROZEN,CHEESE STICK HALLOUMI STYL,Dairy Costs,51300,
|
||||
1803,POULTRY,CHICKEN CVP THGH B/S HALAL,Chicken/ Poultry Costs,51120,
|
||||
1804,CHEMICAL/JANTRL,CLEANER DEGREASER CONCENTR RTU,Cleaning Supplies,74100,
|
||||
1805,CHEMICAL/JANTRL,CLEANER DEGREASER GRSELFT RTU,Cleaning Supplies,74100,
|
||||
1806,,CONTAINER PAPER CUSTOM LOGO8X5,Paperware Cost,55000,
|
||||
1807,,CONTAINER PAPER FBR 9X6 1C WHT,Paperware Cost,55000,
|
||||
1808,,DRESSING RANCH SPICY,Dairy Costs,51300,
|
||||
1809,,FILM PVC ROLL CRYS 2000 FT,Paperware Cost,55000,
|
||||
1810,,FORK PLASTIC SERVING BLK 10IN,Paperware Cost,55000,
|
||||
1811,DAIRY PRODUCTS,ICE CREAM COOKIE&CREAM,Dairy Costs,51300,
|
||||
1812,DAIRY PRODUCTS,ICE CREAM STRAWBERRY,Dairy Costs,51300,
|
||||
1813,DAIRY PRODUCTS,ICE CREAM VAN QUICK BLEND,Dairy Costs,51300,
|
||||
1814,DISPENSER BEVRG,JUICE CONC BERRY PATCH ORG,Soft Beverage Cost,52000,
|
||||
1815,CANNED AND DRY,JUICE LEMON PLAS RTU,Produce Costs,51200,
|
||||
1816,PRODUCE,KALE CHOPPED,Produce Costs,51200,
|
||||
1817,PRODUCE,KALE FRESH,Produce Costs,51200,
|
||||
1818,CANNED AND DRY,KETCHUP FCY POUCH EQUALS 6/10#,Dry Goods Costs,51500,
|
||||
1819,PRODUCE,LEMON FRESH BAGGED,Produce Costs,51200,
|
||||
1820,CANNED AND DRY,OIL OLIVE SOYBEAN BLEND 75/25,Dry Goods Costs,51500,
|
||||
1821,PRODUCE,ONION WHITE JUMBO BAG,Produce Costs,51200,
|
||||
1822,CANNED AND DRY,PEPPER BANANA MILD RING 7-9HUN,Produce Costs,51200,
|
||||
1823,CANNED AND DRY,PEPPER BANANA RING,Produce Costs,51200,
|
||||
1824,FROZEN,POTATO FRY 1/4 SS XLF PHANTM,Produce Costs,51200,
|
||||
1825,,SANITIZER NO RINSE QUORUM,Cleaning Supplies,74100,
|
||||
1826,CANNED AND DRY,SODA ROOT BEER CUBE,Soft Beverage Cost,52000,
|
||||
1827,DISPENSER BEVRG,SYRUP FRUIT PUNCH BIB FLASHIN,Soft Beverage Cost,52000,
|
||||
1828,DISPENSER BEVRG,SYRUP ORANGE 5X1 BIB,Soft Beverage Cost,52000,
|
||||
1829,PRODUCE,TOMATO BULK 5X5 FRSH,Produce Costs,51200,
|
||||
1830,PRODUCE,TOMATO GRAPE FRSH,Produce Costs,51200,
|
||||
|
||||
|
43
resources/sysco_recode/bad.csv
Normal file
43
resources/sysco_recode/bad.csv
Normal file
@@ -0,0 +1,43 @@
|
||||
,,,,,,,,,,,,,,,,,,,,,,,,,d,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,20.56,CA,20.56,Y,0,0,0,1.2,07,48,02,01,CANNED AND DRY,24,20OZ,AQUAFIN,WATER PURIFIED BTL PET LSE DW,30,33,0.75,24,,000000,,0000,,,,29115,47,,8492330,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,14.84,CA,14.84,Y,0,0,0,0.6,07,16,05,01,CANNED AND DRY,12,11.2OZ,LOUX,SODA CHERRY VISSINADA GRK PLAS,9.5,10.5,0.26,12,,000000,,0000,,,3000P,808959,01,,7189422,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,14.93,CA,14.93,Y,0,0,0,0.6,07,16,05,01,CANNED AND DRY,12,8 OZ,LOUX,SODA LEMON LEMONADA GREEK,9,11.5,0.26,12,,000000,,0000,,,3200,808959,01,,9910355,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,79.83,CA,79.83,Y,0,0,0,0,07,35,03,99,CANNED AND DRY,4,5 LB,OTHRYS,SPICE OREGANO LEAF RUBBED,20,22,2.51,4,,000000,,0000,,,62760,808959,01,,9911236,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,31.07,CA,31.07,Y,0,0,0,0,07,35,99,99,CANNED AND DRY,22,4.68OZ,HI WEST,RICE MIX NICKS,6.43,7,0.16,22,,000000,,0000,,,30-5729,345717,03,,7301949,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,2,2,0,54.84,CA,109.68,Y,0,0,0,0,07,33,01,99,CANNED AND DRY,2,20 LB,ROYAL,RICE BASMATI PABROIL SELA CS,40,40.6,1.21,2,,000000,,0000,,,91000244,26992,43,,7053293,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,75.49,CA,75.49,Y,0,0,0,0,07,34,04,99,CANNED AND DRY,4,1 GAL,NICKGRK,DRESSING VINAIGRETTE LOGO,33,35,0.87,4,,000000,,0000,,,1654,853,01,,7108399,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,29.4,CA,29.4,Y,0,0,0,0,07,36,99,99,CANNED AND DRY,8,15 OZ,HAIG'S,DIP GARLIC TOUM,7.25,8.25,0.34,8,,000000,,0000,,,8PGD16,691816,01,,7360056,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,38.85,CA,38.85,Y,0,0,0,0,07,37,02,02,CANNED AND DRY,1,35 LB,BEOCO,OIL CORN,35,36.55,0.85,1,,000000,,0000,,,,9846,02,,4823761,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,2,2,0,56.72,CA,113.44,Y,0,0,0,0,07,36,99,99,CANNED AND DRY,4,4 LB,GRECDEL,SPREAD HUMMUS TRADITIONAL,16,17,0.62,4,,000000,,0000,,,HU000083,1533,19,,7278619,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,-7.22,CA,-7.32,Y,0,0,0,0,07,86,01,99,CANNED AND DRY,1,EA,NONPROD,ALLOWANCE FOR DROP SIZE,0.01,0.01,0.01,1,,000000,,0000,,,,,01,,9477498,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,4.17,CA,4.17,Y,0,0,0,0,07,86,01,99,CANNED AND DRY,1,EA,NONPROD,CHGS FOR FUEL SURCHARGE,1,1,0,1,,000000,,0000,,,,,01,,6592893,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,2,2,0,95.98,CA,191.96,Y,0,0,0,0,02,04,99,99,DAIRY PRODUCTS,1,5 GAL,NICKGRK,SAUCE TZATZIKI,42,43.5,1.2,1,,000000,,0000,,,SA000084,1533,19,,7213639,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,2,2,0,54.82,CA,109.64,Y,0,0,0,0,02,10,01,99,DAIRY PRODUCTS,4,1 GAL,NICKGRK,YOGURT FRZN NF NICK THE GREEK,39.9,39.9,0.97,4,,000000,,0000,,,13101,379887,05,,7302646,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,172.37,CA,172.37,Y,0,0,0,0,12,08,02,03,DISPENSER BEVRG,12,32 OZ,TRACTOR,JUICE CONC STRAWB DRAGON,24,25.5,0.58,12,,000000,,0000,,,6555,693956,01,,7206974,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,62.48,CA,62.48,Y,0,0,0,0,12,08,02,03,DISPENSER BEVRG,1,2.5GAL,DR PEPR,SYRUP DR PPR DIET BIB,20.93,21.82,0.47,1,,000000,,0000,,,12115,376510,09,,7459969,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,117.3,CA,117.3,Y,0,0,0,0,12,08,02,03,DISPENSER BEVRG,1,5GAL,DR PEPR,SYRUP DR PEPPER BIB,40,54.4,0.83,1,,000000,,0000,,,12109,9562,14,,4273553,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,8,8,0,26.36,CA,210.88,Y,0,0,0,0,06,02,45,99,FROZEN,12,10 CT,KONTOS,BREAD PITA GYRO PRE-OILED 7,21,24,1.65,12,,000000,,0000,,,10005,25370,01,,5223334,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,2,2,0,69.37,CA,138.74,Y,0,0,0,0,06,01,70,99,FROZEN,36,6 OZ,HELLAS,SPANAKOPITA SPINACH COOKED,12.4,13.4,0.62,36,,000000,,0000,,,216312,32248,01,,7455027,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,2,2,0,49.53,CA,99.06,Y,0,0,0,0,06,01,60,99,FROZEN,2,24 CT,HELLAS,BAKLAVA CLASSIC 2X24,9.6,10.6,0.46,2,,000000,,0000,,,100224,32248,01,,7187055,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,57.66,CA,57.66,Y,0,0,0,0,06,01,65,99,FROZEN,140,0.7 OZ,CHICPAT,DESSERT MINI PLAIN BEIGNET,6.17,7.5,0.88,140,,000000,,0000,,,540061,1188,53,,7212299,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,97.94,CA,97.94,Y,0,0,0,0,06,02,01,99,FROZEN,4,10 LB,NICKGRK,APTZR VEG FALAFEL PUCK HALAL,40,42,2.03,4,,000000,,0000,,,FA000090,1533,05,,7274591,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,1,53.5,1,53.5,7.556,LB,404.25,Y,0,0,0,0,03,02,01,13,MEATS,5,10.5#,TWORVRS,BEEF SHLDR TERES MAJOR SEL,53,55,1.99,5,,000000,,0000,,,B83003,527004,03,,0932867,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,87.76,CA,87.76,Y,0,0,0,0,03,04,99,99,MEATS,1,20 LB,GRECDEL,PORK SLI GYRO CONE,20,21,0.77,1,,000000,,0000,,,ME000215,1533,05,,7211838,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,7,7,0,92.53,CA,647.71,Y,0,0,0,0,03,02,04,99,MEATS,1,30 LB,NICKGRK,MEAT GYRO BEEF CONE NTG,30,31,0.97,1,,000000,,0000,,,ME000071,1533,05,,9906087,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,25.41,CA,25.41,Y,0,0,0,0,08,42,64,43,PAPER & DISP,20,50 CT,KARAT,LID PLAS FLAT F/12-22 OZ,5.75,7,1.94,20,,000000,,0000,,,C-KCL90,461672,05,,7661388,00000000000000,,,260402,04671945,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,22.32,CA,22.32,Y,0,0,0,0,08,60,99,99,PAPER & DISP,24,250 CT,ELEMEN,NAPKIN 2PLY INTR FOLD 6.3X8.26,16.1,16.8,1.55,24,,000000,,0000,,,11904,613310,01,,7452585,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,26.15,CA,26.15,Y,0,0,0,0,08,21,64,62,PAPER & DISP,50,50CT,KARAT,CUP PORTION PLAS CLR 1.50 OZ,10,10,1.36,50,,000000,,0000,,,FP-P150-PP,461672,05,,4613026,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,43.99,CA,43.99,Y,0,0,0,0,08,75,03,04,PAPER & DISP,6,50 EA,NATZWAY,BOWL PLASTIC COATING 42 OZ,14.55,17.19,3.56,6,,000000,,0000,,,10205,773772,01,,7408008,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,41.25,CA,41.25,Y,0,0,0,0,08,42,99,99,PAPER & DISP,6,50CT,NATZWAY,LID CLEAR PET 42 OZ,8.59,10.47,2.01,6,,000000,,0000,,,10206,773772,01,,7408215,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,47.6,CA,47.6,Y,0,0,0,0,08,21,56,99,PAPER & DISP,1000,22 OZ,NICKGRK,CUP PAPER COLD 22 OZ LOGO NTG,31.96,34.62,3.78,1000,,000000,,0000,,,810161542703,461672,05,,7354127,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,30.6,CA,30.6,Y,0,0,0,0,08,18,56,99,PAPER & DISP,1,450 CT,NICKGRK,CONTAINER PAPER 1/30 OZ NTG,23,25,3.25,1,,000000,,0000,,,810161542673,461672,05,,7354120,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,27.6,CA,27.6,Y,0,0,0,0,08,18,56,99,PAPER & DISP,1,160 CT,NICKGRK,CONTAINER PAPER 4/110OZ NTG,19.6,21.4,3.59,1,,000000,,0000,,,810161542680,461672,05,,7354119,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,3,3,0,37.6,CA,112.8,Y,0,0,0,0,08,18,02,12,PAPER & DISP,2,100CT,NATZWAY,CONTAINER PAPER MLD FBR 9X6,18,18,1.28,2,,000000,,0000,,,10042,773772,01,,7250678,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,2,2,0,46.07,CA,92.14,Y,0,0,0,0,08,09,56,99,PAPER & DISP,1,250BAG,NICKGRK,BAG PAPER 250 CT,14.5,15,2.18,1,,000000,,0000,,,,773772,01,,7417242,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,1,,1,1,0,24.46,EA,24.46,Y,0,0,0,0,08,36,56,79,PAPER & DISP,5,1000,BAGCRFT,WRAP DELI WHT 12X12 GRS RESIST,37,37,1.07,5,,000000,,0000,,,P057012,276,01,,5723808,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,1,1,0,39.41,CA,39.41,Y,3.85,0,0,0,08,06,25,10,PAPER & DISP,10,100CT,DHGPROF,GLOVE NITRILE BLK PEDRFREE LRG,12.21,12.21,0.66,10,,000000,,0000,,,DNGB-L,613310,01,,7296407,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,3,3,0,89.32,CA,267.96,Y,0,0,0,0,05,01,01,07,POULTRY,4,10 LB,SYS CLS,CHICKEN CVP THIGH BNLS SKLS,40,42,1.04,4,,000000,,0000,,,14301,3254,21,,7792187,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,9,9,0,86.97,CA,782.73,Y,0,0,0,0,05,02,01,99,POULTRY,1,20LB,GRECDEL,GYRO CHICKEN SHAWARMA CONE,20,21,0.77,1,,000000,,0000,,,ME000102,1533,05,,7124188,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,DET,,,,,5,5,0,23.65,CA,118.25,Y,0,0,0,0,11,02,23,01,PRODUCE,1,50 LB,PACKER,POTATO KENNEBEC FRESH,50,52,2,1,,000000,,0000,,,,696760,01,,2039220,00000000000000,,,260402,04672959,
|
||||
EEK,,050,00175469,850081745,HDR,,,CKC CONCORD INC,,260402,,,,Rolling 8,,NICK THE GREEK CONCORD,2075 DIAMOND BLVD,STE H-103,CONCORD,CA,94520-582,408593,000000000,,,,,BBNKG,0,050,SYSCO SAN FRANCISCO,5900 STEWART AVENU,,FREMONT,CA,94538,,,,,,,1372486,4024,004,0000000,00000000,20260529,6.25,CRO8
|
||||
EEK,,050,00175469,850081745,SUM,,,40,0,0,74,0,4625.36,6.25,00000463161,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||
|
271
scratch-sessions/sysco_recode_scratch.clj
Normal file
271
scratch-sessions/sysco_recode_scratch.clj
Normal file
@@ -0,0 +1,271 @@
|
||||
;; =====================================================================
|
||||
;; ONE-OFF SCRATCH — re-code already-imported Sysco invoices after fixing
|
||||
;; resources/sysco_line_item_mapping.csv.
|
||||
;;
|
||||
;; Context: the Sysco importer codes each line item by EXACT description
|
||||
;; match against sysco_line_item_mapping.csv, defaulting to GL 50000 when a
|
||||
;; description is missing (auto-ap.jobs.sysco/get-line-account). Missing
|
||||
;; PAPER & DISP (and other) descriptions landed in 50000 (Food Costs)
|
||||
;; instead of their real accounts (e.g. 55000 Paper Costs). The mapping is
|
||||
;; now fixed; this re-derives the correct split from each invoice's source
|
||||
;; CSV and rewrites :invoice/expense-accounts.
|
||||
;;
|
||||
;; Design:
|
||||
;; - Recode EVERY invoice found in the CSV resource (a Sysco file may batch
|
||||
;; several invoices; they're grouped by InvoiceNumber).
|
||||
;; - Build ONE transaction covering every invoice, emitting only the datoms
|
||||
;; that actually change:
|
||||
;; * reuse an existing invoice-expense-account when its account (and
|
||||
;; location) already match, updating just :amount when it differs;
|
||||
;; * add a child for an account that has no row yet;
|
||||
;; * retract a child whose account is no longer in the corrected split;
|
||||
;; * emit nothing for rows already correct.
|
||||
;; - Validate with (dc/with db changes): apply the tx to an in-memory db
|
||||
;; value and assert every affected invoice's expense-account amounts sum
|
||||
;; to its :invoice/total BEFORE committing for real.
|
||||
;; - After committing, touch the ledger for every affected invoice. This is
|
||||
;; a SEPARATE transaction on purpose: :upsert-invoice rebuilds the journal
|
||||
;; entry from the invoice's expense-accounts as seen in db-before, so it
|
||||
;; must run after the recode is committed.
|
||||
;;
|
||||
;; DO NOT load/evaluate this whole file. Step through the (comment ...) forms
|
||||
;; one at a time in a connected REPL; the commit + ledger steps are gated #_.
|
||||
;;
|
||||
;; PRECONDITIONS
|
||||
;; - The deployed artifact ships the fixed sysco_line_item_mapping.csv AND
|
||||
;; the invoice CSV at resources/sysco_recode/<file>.csv (io/resource).
|
||||
;; - You are connected to the DB you intend to mutate (prod conn!).
|
||||
;; =====================================================================
|
||||
|
||||
(comment
|
||||
|
||||
(require '[auto-ap.jobs.sysco :as sysco]
|
||||
'[auto-ap.datomic :refer [conn audit-transact random-tempid]]
|
||||
'[auto-ap.utils :refer [dollars=]]
|
||||
'[auto-ap.time :as t]
|
||||
'[clj-time.coerce :as coerce]
|
||||
'[clojure.data.csv :as csv]
|
||||
'[clojure.java.io :as io]
|
||||
'[datomic.api :as dc])
|
||||
|
||||
;; ------------------------------------------------------------------
|
||||
;; STEP 0 — reload the mapping cache so the corrected CSV is in effect.
|
||||
;; ------------------------------------------------------------------
|
||||
(reset! sysco/sysco-name->line nil)
|
||||
(count (sysco/get-sysco->line))
|
||||
|
||||
;; sanity: a previously-missing paper description now resolves to 55000.
|
||||
(dc/pull (dc/db conn) [:account/numeric-code :account/name]
|
||||
(sysco/get-line-account "BAG PAPER 250 CT")) ; => 55000
|
||||
|
||||
;; ------------------------------------------------------------------
|
||||
;; Helpers
|
||||
;; ------------------------------------------------------------------
|
||||
(defn read-csv-rows
|
||||
"Reads the invoice CSV from the classpath (so it ships with the deploy).
|
||||
`resource-path` is relative to a resources/ root, e.g. \"sysco_recode/bad.csv\"."
|
||||
[resource-path]
|
||||
(with-open [r (io/reader (or (io/resource resource-path)
|
||||
(throw (ex-info "CSV not found on classpath"
|
||||
{:resource-path resource-path}))))]
|
||||
(doall (csv/read-csv r))))
|
||||
|
||||
(defn parse-date
|
||||
"Sysco yyMMdd string -> java.util.Date, the same way the importer stores
|
||||
:invoice/date (auto-ap.jobs.sysco/extract-invoice-details)."
|
||||
[yymmdd]
|
||||
(coerce/to-date (t/parse yymmdd "yyMMdd")))
|
||||
|
||||
(defn group-invoices
|
||||
"Split a (possibly multi-invoice) Sysco CSV into one entry per invoice.
|
||||
Groups DET/HDR/SUM rows by InvoiceNumber (index 4); date comes from the
|
||||
group's HDR row InvoiceDate (index 10)."
|
||||
[rows]
|
||||
(->> rows
|
||||
(filter #(contains? #{"DET" "HDR" "SUM"} (nth % 5 nil)))
|
||||
(group-by #(nth % 4))
|
||||
(mapv (fn [[number grp]]
|
||||
(let [hdr (first (filter #(= "HDR" (nth % 5)) grp))]
|
||||
{:invoice-number number
|
||||
:date-str (some-> hdr (nth 10))
|
||||
:rows grp})))))
|
||||
|
||||
(defn desired-split
|
||||
"Rows of one Sysco invoice -> {account-eid -> amount-double}, using the
|
||||
CURRENT (fixed) mapping. DET rows only (record-type at index 5); tax
|
||||
(SUM row, TotalTaxAmount index 14) added to the same account the
|
||||
importer uses for \"TAX\". Mirrors auto-ap.jobs.sysco/code-individual-items."
|
||||
[rows]
|
||||
(let [det (filter #(= "DET" (nth % 5)) rows)
|
||||
sum-row (first (filter #(= "SUM" (nth % 5)) rows))
|
||||
tax (some-> sum-row (nth 14) Double/parseDouble)
|
||||
by-acct (reduce
|
||||
(fn [acc row]
|
||||
(update acc
|
||||
(sysco/get-line-account (nth row sysco/item-name-index))
|
||||
(fnil + 0.0)
|
||||
(Double/parseDouble (nth row sysco/item-price-index))))
|
||||
{}
|
||||
det)]
|
||||
(cond-> by-acct
|
||||
(and tax (not (zero? tax)))
|
||||
(update (sysco/get-line-account "TAX") (fnil + 0.0) tax))))
|
||||
|
||||
(defn resolve-eid
|
||||
"Match on invoice-number AND date (belt-and-suspenders). Asserts a unique
|
||||
hit so we never recode the wrong invoice."
|
||||
[invoice-number date]
|
||||
(let [ids (mapv first (dc/q '[:find ?i :in $ ?n ?d
|
||||
:where
|
||||
[?i :invoice/invoice-number ?n]
|
||||
[?i :invoice/date ?d]]
|
||||
(dc/db conn) invoice-number date))]
|
||||
(assert (>= 1 (count ids))
|
||||
(str "multiple invoices match " invoice-number " / " date ": " ids))
|
||||
(first ids)))
|
||||
|
||||
(defn invoice-change-datoms
|
||||
"Minimal tx-data to make invoice `eid`'s expense-account split equal
|
||||
`desired` ({account-eid -> amount}). Returns [] when already correct."
|
||||
[db eid desired]
|
||||
(let [existing (:invoice/expense-accounts
|
||||
(dc/pull db [{:invoice/expense-accounts
|
||||
[:db/id :invoice-expense-account/amount
|
||||
:invoice-expense-account/location
|
||||
{:invoice-expense-account/account [:db/id]}]}]
|
||||
eid))
|
||||
loc (or (some :invoice-expense-account/location existing) "HQ")
|
||||
;; one child per account expected; index by account, retract any dupes
|
||||
by-acct (group-by #(get-in % [:invoice-expense-account/account :db/id]) existing)
|
||||
one (into {} (map (fn [[a cs]] [a (first cs)])) by-acct)
|
||||
dupes (mapcat (fn [[_ cs]] (map :db/id (rest cs))) by-acct)
|
||||
wanted (set (keys desired))
|
||||
upserts (keep (fn [[acct amt]]
|
||||
(let [child (get one acct)]
|
||||
(cond
|
||||
;; new account -> accrete a child under the invoice
|
||||
(nil? child)
|
||||
{:db/id eid
|
||||
:invoice/expense-accounts
|
||||
[#:invoice-expense-account{:db/id (random-tempid)
|
||||
:account acct
|
||||
:location loc
|
||||
:amount amt}]}
|
||||
;; right account, wrong value -> reuse, set amount
|
||||
;; (and fix location if it drifted)
|
||||
(or (not (dollars= (:invoice-expense-account/amount child) amt))
|
||||
(not= (:invoice-expense-account/location child) loc))
|
||||
(cond-> {:db/id (:db/id child)
|
||||
:invoice-expense-account/amount amt}
|
||||
(not= (:invoice-expense-account/location child) loc)
|
||||
(assoc :invoice-expense-account/location loc))
|
||||
;; already correct -> nothing
|
||||
:else nil)))
|
||||
desired)
|
||||
retracts (for [[acct child] one :when (not (wanted acct))]
|
||||
[:db/retractEntity (:db/id child)])]
|
||||
(vec (concat upserts
|
||||
retracts
|
||||
(map (fn [id] [:db/retractEntity id]) dupes)))))
|
||||
|
||||
;; ------------------------------------------------------------------
|
||||
;; STEP 1 — point at the CSV. EVERY invoice in this file gets recoded.
|
||||
;; Place the file under resources/ (e.g. resources/sysco_recode/bad.csv)
|
||||
;; and commit it so it's on the classpath of the deployed artifact.
|
||||
;; ------------------------------------------------------------------
|
||||
(def csv-path "sysco_recode/bad.csv")
|
||||
(def rows (read-csv-rows csv-path))
|
||||
|
||||
(def invoices (group-invoices rows))
|
||||
(mapv (juxt :invoice-number :date-str) invoices) ;; what we found in the file
|
||||
|
||||
;; ------------------------------------------------------------------
|
||||
;; STEP 2 — resolve each invoice (number + date) and compute its split.
|
||||
;; ------------------------------------------------------------------
|
||||
(def plan
|
||||
(mapv (fn [{:keys [invoice-number date-str rows]}]
|
||||
(let [date (parse-date date-str)]
|
||||
{:invoice-number invoice-number
|
||||
:date date
|
||||
:eid (resolve-eid invoice-number date)
|
||||
:desired (desired-split rows)}))
|
||||
invoices))
|
||||
|
||||
;; bail if any invoice number didn't resolve
|
||||
(assert (every? :eid plan)
|
||||
(str "unresolved invoices: "
|
||||
(mapv (juxt :invoice-number :date) (remove :eid plan))))
|
||||
|
||||
;; ------------------------------------------------------------------
|
||||
;; STEP 3 — build the SINGLE changes-only transaction across all invoices.
|
||||
;; ------------------------------------------------------------------
|
||||
(def changes
|
||||
(let [db (dc/db conn)]
|
||||
(vec (mapcat (fn [{:keys [eid desired]}] (invoice-change-datoms db eid desired))
|
||||
plan))))
|
||||
|
||||
(count changes) ;; how many datoms we're actually changing
|
||||
changes ;; inspect the full minimal tx
|
||||
|
||||
;; ------------------------------------------------------------------
|
||||
;; STEP 4 — validate with dc/with: apply the tx to an in-memory db value
|
||||
;; and confirm every affected invoice still balances (sum of expense
|
||||
;; account amounts == :invoice/total).
|
||||
;; ------------------------------------------------------------------
|
||||
(def preview (dc/with (dc/db conn) changes))
|
||||
|
||||
(def balance-report
|
||||
(let [db-after (:db-after preview)]
|
||||
(mapv (fn [{:keys [eid invoice-number]}]
|
||||
(let [inv (dc/pull db-after
|
||||
[:invoice/total
|
||||
{:invoice/expense-accounts [:invoice-expense-account/amount]}]
|
||||
eid)
|
||||
s (reduce + 0.0 (map :invoice-expense-account/amount
|
||||
(:invoice/expense-accounts inv)))]
|
||||
{:invoice-number invoice-number
|
||||
:total (:invoice/total inv)
|
||||
:ea-sum s
|
||||
:ok? (dollars= s (:invoice/total inv))}))
|
||||
plan)))
|
||||
balance-report
|
||||
|
||||
;; HARD GATE — do not continue unless every invoice balances post-change.
|
||||
(assert (every? :ok? balance-report)
|
||||
(str "unbalanced after change: "
|
||||
(filterv (complement :ok?) balance-report)))
|
||||
|
||||
;; ------------------------------------------------------------------
|
||||
;; STEP 5 — COMMIT the recode (gated). One transaction, changes only.
|
||||
;; ------------------------------------------------------------------
|
||||
#_(audit-transact changes
|
||||
{:user/name "sysco recode (missing GL mappings fix)"
|
||||
:user/role "admin"})
|
||||
|
||||
;; ------------------------------------------------------------------
|
||||
;; STEP 6 — touch the ledger for every affected invoice (separate tx;
|
||||
;; :upsert-invoice rebuilds the journal entry from the now-committed
|
||||
;; expense-accounts). Gated.
|
||||
;; ------------------------------------------------------------------
|
||||
#_(audit-transact (mapv (fn [{:keys [eid]}] [:upsert-invoice {:db/id eid}]) plan)
|
||||
{:user/name "sysco recode ledger touch"
|
||||
:user/role "admin"})
|
||||
|
||||
;; ------------------------------------------------------------------
|
||||
;; STEP 7 — verify committed result.
|
||||
;; ------------------------------------------------------------------
|
||||
#_(let [db (dc/db conn)]
|
||||
(mapv (fn [{:keys [eid invoice-number]}]
|
||||
{:invoice-number invoice-number
|
||||
:accounts
|
||||
(->> (dc/pull db
|
||||
[{:invoice/expense-accounts
|
||||
[:invoice-expense-account/amount
|
||||
{:invoice-expense-account/account [:account/numeric-code]}]}]
|
||||
eid)
|
||||
:invoice/expense-accounts
|
||||
(map (juxt #(get-in % [:invoice-expense-account/account :account/numeric-code])
|
||||
:invoice-expense-account/amount))
|
||||
(sort-by first)
|
||||
vec)})
|
||||
plan)))
|
||||
@@ -5,10 +5,11 @@
|
||||
[iol-ion.tx.propose-invoice]
|
||||
[iol-ion.tx.reset-rels]
|
||||
[iol-ion.tx.reset-scalars]
|
||||
[iol-ion.tx.upsert-entity]
|
||||
[iol-ion.tx.upsert-invoice]
|
||||
[iol-ion.tx.upsert-ledger]
|
||||
[iol-ion.tx.upsert-transaction]
|
||||
[iol-ion.tx.upsert-entity]
|
||||
[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]
|
||||
@@ -874,13 +875,22 @@
|
||||
(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"))))
|
||||
(defn transact-schema
|
||||
"Installs the schema in two passes: every plain attribute first, then every composite tuple.
|
||||
|
||||
;; 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")))))
|
||||
A tuple can only be created once the attributes it composes already exist, and the pieces are
|
||||
spread across both files — `:journal-entry-line/running-balance-tuple` lives in schema.edn
|
||||
while one of its members, `:journal-entry-line/running-balance`, lives in
|
||||
cloud-migration-schema.edn. Transacting the files in order therefore cannot install that tuple
|
||||
against an empty database. Long-lived databases never hit it because those attributes went in
|
||||
years apart."
|
||||
[conn]
|
||||
(let [schema (concat (edn/read-string (slurp (io/resource "schema.edn")))
|
||||
;; this is temporary for any new stuff that needs to be asserted for cloud migration.
|
||||
(edn/read-string (slurp (io/resource "cloud-migration-schema.edn"))))
|
||||
{tuples true plain false} (group-by #(contains? % :db/tupleAttrs) schema)]
|
||||
(when (seq plain) @(dc/transact conn plain))
|
||||
(when (seq tuples) @(dc/transact conn tuples))))
|
||||
|
||||
(defn backoff [n]
|
||||
(let [base-timeout 500
|
||||
|
||||
62
src/clj/auto_ap/datomic/sales_summaries.clj
Normal file
62
src/clj/auto_ap/datomic/sales_summaries.clj
Normal file
@@ -0,0 +1,62 @@
|
||||
(ns auto-ap.datomic.sales-summaries
|
||||
(:require
|
||||
[iol-ion.query :refer [dollars=]]))
|
||||
|
||||
(defn- ledger-side
|
||||
"The ledger side of an item as a keyword, whether it arrived as a plain keyword (a
|
||||
transaction map, or a pull using `:xform iol-ion.query/ident`) or as the `{:db/ident ...}`
|
||||
map a plain `pull` returns. Resolving both shapes here matters: items whose side does not
|
||||
compare equal are counted on neither side, which would leave a summary looking balanced at
|
||||
zero and therefore silently accepted."
|
||||
[item]
|
||||
(let [side (:ledger-mapped/ledger-side item)]
|
||||
(if (map? side) (:db/ident side) side)))
|
||||
|
||||
(defn- side-total [side items]
|
||||
(->> items
|
||||
(filter #(= side (ledger-side %)))
|
||||
(map #(:ledger-mapped/amount % 0.0))
|
||||
(reduce + 0.0)))
|
||||
|
||||
(defn total-debits [items]
|
||||
(side-total :ledger-side/debit items))
|
||||
|
||||
(defn total-credits [items]
|
||||
(side-total :ledger-side/credit items))
|
||||
|
||||
(defn fully-mapped? [items]
|
||||
(every? :ledger-mapped/account items))
|
||||
|
||||
(defn fully-sided?
|
||||
"Every item says which side of the ledger it belongs on. Guards `accepted?` against
|
||||
reading a collection of sideless items as balanced at zero."
|
||||
[items]
|
||||
(every? #(#{:ledger-side/debit :ledger-side/credit} (ledger-side %)) items))
|
||||
|
||||
(defn balanced? [items]
|
||||
(dollars= (total-debits items) (total-credits items)))
|
||||
|
||||
(defn imbalance
|
||||
"Signed debits minus credits. `balanced?` answers yes or no; this says by how much and in
|
||||
which direction, so a day that does not balance can be logged and queried rather than only
|
||||
rendered red. Positive means the tender side exceeds what revenue accounts for."
|
||||
[items]
|
||||
(- (total-debits items) (total-credits items)))
|
||||
|
||||
(defn accepted?
|
||||
"True once a summary is finished: every line is mapped to an account and debits equal
|
||||
credits. This is the same condition the sales summaries grid renders as \"Balanced\", and
|
||||
the condition the scheduled refresh treats as \"leave this alone\"."
|
||||
[items]
|
||||
(boolean (and (seq items)
|
||||
(fully-mapped? items)
|
||||
(fully-sided? items)
|
||||
(balanced? items))))
|
||||
|
||||
(defn <-pulled-item
|
||||
"Flattens the ref values on a pulled sales summary item back to the scalars a transaction
|
||||
expects. `accepted?` reads either shape, so this is only needed on the write path."
|
||||
[item]
|
||||
(cond-> item
|
||||
(map? (:ledger-mapped/ledger-side item)) (update :ledger-mapped/ledger-side :db/ident)
|
||||
(map? (:ledger-mapped/account item)) (update :ledger-mapped/account :db/id)))
|
||||
375
src/clj/auto_ap/jobs/backfill_olo_processors.clj
Normal file
375
src/clj/auto_ap/jobs/backfill_olo_processors.clj
Normal file
@@ -0,0 +1,375 @@
|
||||
(ns auto-ap.jobs.backfill-olo-processors
|
||||
"One-off backfill for the Olo third-party-source fix (commit fa25620b, \"olo fixes\").
|
||||
|
||||
That commit replaced the exact-match `condp` on `(:name (:source order))` in
|
||||
`square3/tender->charge` with substring matching, so Olo-brokered orders that
|
||||
arrive from Square with source names like \"Olo - DoorDash\" or
|
||||
\"olo-ubereats\" now classify as the real delivery processor instead of
|
||||
falling through to `:ccp-processor/na`.
|
||||
|
||||
Orders imported *before* the fix shipped still carry the old
|
||||
`:charge/processor`, which means `sales-summaries` bucketed them as
|
||||
\"Unknown\" instead of \"Food App Payments\". This job recomputes
|
||||
`:charge/processor` for already-imported Square charges over a date range
|
||||
(default: 2026-07-07 -> today) and marks the affected sales summaries dirty
|
||||
so `auto-ap.jobs.sales-summaries` recalculates them.
|
||||
|
||||
IMPORTANT: this backfill never re-implements the classifier. It feeds the
|
||||
stored shape of each charge back through the real production function
|
||||
(`square3/tender->charge`), so it cannot drift from the importer.
|
||||
|
||||
No Square API calls are made -- every input the classifier needs
|
||||
(`:sales-order/source`, `:charge/note`, `:charge/type-name`) is already in
|
||||
Datomic. See the `(comment ...)` block at the bottom for the API-based
|
||||
fallback if you need to repair charges that have no parent sales order.
|
||||
|
||||
Dry run by default. Pass `:apply? true` to write."
|
||||
(:require
|
||||
[auto-ap.datomic :refer [conn]]
|
||||
[auto-ap.jobs.core :refer [execute]]
|
||||
[auto-ap.jobs.sales-summaries :as summaries]
|
||||
[auto-ap.logging :as alog]
|
||||
[auto-ap.square.core3 :as square3]
|
||||
[auto-ap.time :as atime]
|
||||
[clj-time.coerce :as coerce]
|
||||
[clj-time.core :as time]
|
||||
[clj-time.format :as f]
|
||||
[clj-time.periodic :as per]
|
||||
[clojure.string :as str]
|
||||
[config.core :refer [env]]
|
||||
[datomic.api :as dc]
|
||||
;; the datalog below calls iol-ion.query/scan-charges by fully-qualified
|
||||
;; symbol, so the namespace has to be loaded.
|
||||
[iol-ion.query]))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Date handling
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(def pacific (time/time-zone-for-id "America/Los_Angeles"))
|
||||
|
||||
(def default-start-date
|
||||
"The Olo fix landed 2026-07-22; the user-visible bad data starts 7/7."
|
||||
"2026-07-07")
|
||||
|
||||
(defn parse-day
|
||||
"\"2026-07-07\" -> local (Pacific) midnight DateTime. Passes DateTimes through."
|
||||
[d]
|
||||
(cond
|
||||
(nil? d) nil
|
||||
(string? d) (f/parse (f/with-zone (f/formatter "yyyy-MM-dd") pacific) d)
|
||||
:else (coerce/to-date-time d)))
|
||||
|
||||
(defn local-midnight [dt]
|
||||
(.toDateMidnight (atime/localize dt)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Classification -- delegates to the production importer
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn recompute-processor
|
||||
"Runs the *live* classifier over a charge's stored inputs.
|
||||
|
||||
`tender->charge` only reads `(:name (:source order))` from the order and
|
||||
`:type`/`:note` from the tender when deciding `:charge/processor`, so a
|
||||
synthetic order/tender carrying the stored values yields exactly what a
|
||||
fresh import would produce today. `:created_at` and `:id` are placeholders
|
||||
purely to keep the unrelated `:charge/date` and `:charge/reference-link`
|
||||
branches from blowing up; we only read `:charge/processor` back out.
|
||||
|
||||
Note on nil sources: the importer stores `(or (:name (:source order))
|
||||
\"Square\")`, so a source that was originally nil comes back as \"Square\".
|
||||
Both nil and \"Square\" classify to `:ccp-processor/na`, so the round trip
|
||||
is faithful."
|
||||
[{:keys [source note type-name]}]
|
||||
(:charge/processor
|
||||
(square3/tender->charge {:source {:name source}
|
||||
:created_at "1970-01-01T00:00:00Z"}
|
||||
{} ; client -> only :charge/client
|
||||
{} ; location -> only :charge/location
|
||||
{:id "backfill"
|
||||
:type type-name
|
||||
:note note})))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Scanning
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(def charge-pull
|
||||
[:db/id
|
||||
:charge/external-id
|
||||
:charge/type-name
|
||||
:charge/note
|
||||
:charge/date
|
||||
:charge/total
|
||||
{:charge/processor [:db/ident]}
|
||||
{:sales-order/_charges [:db/id
|
||||
:sales-order/external-id
|
||||
:sales-order/source
|
||||
{:sales-order/vendor [:db/ident]}]}])
|
||||
|
||||
(defn client-charges
|
||||
"All charges for `client-eid` between `start-inst` and `end-inst` (both
|
||||
inclusive by day), via the :charge/client+date index."
|
||||
[db client-eid start-inst end-inst]
|
||||
(->> (dc/q '[:find (pull ?c pull-pattern)
|
||||
:in $ pull-pattern [?clients ?start ?end]
|
||||
:where
|
||||
[(iol-ion.query/scan-charges $ ?clients ?start ?end) [[?c _ _] ...]]]
|
||||
db
|
||||
charge-pull
|
||||
[[client-eid] start-inst end-inst])
|
||||
(map first)))
|
||||
|
||||
(defn parent-order
|
||||
"Reverse refs on component attributes come back as a single map from pull,
|
||||
but tolerate a collection in case that ever changes."
|
||||
[charge]
|
||||
(let [o (:sales-order/_charges charge)]
|
||||
(if (sequential? o) (first o) o)))
|
||||
|
||||
(defn square-charge? [charge]
|
||||
(= :vendor/ccp-square (get-in (parent-order charge) [:sales-order/vendor :db/ident])))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Planning
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn charge->change
|
||||
"nil when the charge is already correct (or isn't ours to touch)."
|
||||
[charge]
|
||||
(when (square-charge? charge)
|
||||
(let [order (parent-order charge)
|
||||
current (get-in charge [:charge/processor :db/ident])
|
||||
next (recompute-processor {:source (:sales-order/source order)
|
||||
:note (:charge/note charge)
|
||||
:type-name (:charge/type-name charge)})]
|
||||
(when (not= current next)
|
||||
{:charge (:db/id charge)
|
||||
:external-id (:charge/external-id charge)
|
||||
:order (:sales-order/external-id order)
|
||||
:date (:charge/date charge)
|
||||
:source (:sales-order/source order)
|
||||
:note (:charge/note charge)
|
||||
:type-name (:charge/type-name charge)
|
||||
:total (:charge/total charge)
|
||||
:from current
|
||||
:to next}))))
|
||||
|
||||
(defn downgrade?
|
||||
"A change that *loses* processor information. The Olo fix can only ever widen
|
||||
matching, so these should not exist -- if they do, something else changed
|
||||
and we'd rather report than clobber."
|
||||
[{:keys [from to]}]
|
||||
(and (= :ccp-processor/na to)
|
||||
(not= :ccp-processor/na from)
|
||||
(some? from)))
|
||||
|
||||
(defn plan-for-client
|
||||
[db {:keys [db/id client/code]} start-inst end-inst allow-downgrade?]
|
||||
(let [charges (client-charges db id start-inst end-inst)
|
||||
square (filter square-charge? charges)
|
||||
orphans (->> charges
|
||||
(remove (comp some? parent-order))
|
||||
(filter #(some-> (:charge/external-id %)
|
||||
(str/starts-with? "square/charge/"))))
|
||||
all (keep charge->change square)
|
||||
[skipped changes] (if allow-downgrade?
|
||||
[[] all]
|
||||
[(filter downgrade? all) (remove downgrade? all)])]
|
||||
{:client id
|
||||
:code code
|
||||
:scanned (count square)
|
||||
:orphan-charges (count orphans)
|
||||
:changes (vec changes)
|
||||
:skipped (vec skipped)}))
|
||||
|
||||
(defn build-plan
|
||||
[db clients start-inst end-inst allow-downgrade?]
|
||||
(->> clients
|
||||
(map #(plan-for-client db % start-inst end-inst allow-downgrade?))
|
||||
(remove #(and (zero? (count (:changes %)))
|
||||
(zero? (count (:skipped %)))
|
||||
(zero? (:orphan-charges %))))
|
||||
vec))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Reporting
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- transitions [changes]
|
||||
(->> changes
|
||||
(group-by (juxt :from :to))
|
||||
(map (fn [[[from to] cs]]
|
||||
[from to (count cs) (reduce + 0.0 (keep :total cs))]))
|
||||
(sort-by #(- (nth % 2)))))
|
||||
|
||||
(defn print-report [plan]
|
||||
(println)
|
||||
(println "=== Olo processor backfill ===")
|
||||
(doseq [{:keys [code scanned changes skipped orphan-charges]} plan]
|
||||
(println)
|
||||
(printf "%-8s scanned %d square charges, %d to update%n"
|
||||
(str code) scanned (count changes))
|
||||
(doseq [[from to n total] (transitions changes)]
|
||||
(printf " %-24s -> %-24s %5d $%.2f%n"
|
||||
(str from) (str to) n total))
|
||||
(when (seq skipped)
|
||||
(printf " !! %d downgrade(s) to :ccp-processor/na SKIPPED (pass :allow-downgrade? true to force)%n"
|
||||
(count skipped))
|
||||
(doseq [{:keys [external-id order source note from]} (take 10 skipped)]
|
||||
(printf " %s (order %s, source %s, note %s) was %s%n"
|
||||
external-id order (pr-str source) (pr-str note) from)))
|
||||
(when (pos? orphan-charges)
|
||||
(printf " note: %d square charge(s) have no parent sales order (custom-amount tenders).%n"
|
||||
orphan-charges)
|
||||
(println " Their order source is not stored, so they cannot be reclassified")
|
||||
(println " locally -- use the re-import fallback in the comment block.")))
|
||||
(let [total (reduce + 0 (map (comp count :changes) plan))]
|
||||
(println)
|
||||
(printf "TOTAL: %d charge(s) across %d client(s)%n" total (count plan))
|
||||
(println)
|
||||
total))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Applying
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn apply-plan!
|
||||
"Transacts the processor corrections in batches. Returns the number written."
|
||||
[plan]
|
||||
(let [tx (for [{:keys [changes]} plan
|
||||
{:keys [charge to]} changes]
|
||||
{:db/id charge :charge/processor to})]
|
||||
(doseq [batch (partition-all 100 tx)]
|
||||
(alog/info ::updating-charges :count (count batch))
|
||||
@(dc/transact-async conn batch))
|
||||
(count tx)))
|
||||
|
||||
(defn mark-summaries-dirty!
|
||||
"Processor drives the Card / Food App / Unknown split in
|
||||
`sales-summaries/get-payment-items`, so every day we touched has to be
|
||||
recalculated. `periodic-seq` is end-exclusive, hence the +1 day."
|
||||
[plan start end]
|
||||
(doseq [{:keys [client code changes]} plan
|
||||
:when (seq changes)]
|
||||
(alog/info ::marking-summaries-dirty :client code)
|
||||
(summaries/mark-dirty client
|
||||
(local-midnight start)
|
||||
(time/plus (local-midnight end) (time/days 1)))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Entry point
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn backfill!
|
||||
"Recompute :charge/processor for imported Square charges.
|
||||
|
||||
Options:
|
||||
:start \"2026-07-07\" (default) or a DateTime -- inclusive
|
||||
:end \"2026-07-29\" or a DateTime -- inclusive, default today
|
||||
:codes seq of client codes; default every Square client
|
||||
:apply? false (default) = dry run, print only
|
||||
:allow-downgrade? false (default) = refuse changes that drop a known
|
||||
processor back to :ccp-processor/na
|
||||
:mark-dirty? true (default when applying) = flag sales summaries for
|
||||
recalculation
|
||||
|
||||
Returns the plan so you can inspect individual charges in the REPL."
|
||||
[& {:keys [start end codes apply? allow-downgrade? mark-dirty?]
|
||||
:or {start default-start-date
|
||||
apply? false
|
||||
allow-downgrade? false}}]
|
||||
(let [start-dt (parse-day start)
|
||||
end-dt (or (parse-day end) (atime/localize (time/now)))
|
||||
start-inst (coerce/to-date (local-midnight start-dt))
|
||||
end-inst (coerce/to-date (local-midnight end-dt))
|
||||
db (dc/db conn)
|
||||
clients (if (seq codes)
|
||||
(apply square3/get-square-clients codes)
|
||||
(square3/get-square-clients))
|
||||
_ (alog/info ::scanning
|
||||
:start start-inst
|
||||
:end end-inst
|
||||
:clients (count clients)
|
||||
:dry-run? (not apply?))
|
||||
plan (build-plan db clients start-inst end-inst allow-downgrade?)
|
||||
total (print-report plan)]
|
||||
(if-not apply?
|
||||
(do (println "DRY RUN -- nothing written. Re-run with :apply? true to commit.")
|
||||
(alog/info ::dry-run-complete :change-count total))
|
||||
(do
|
||||
(alog/info ::applying :change-count total)
|
||||
(apply-plan! plan)
|
||||
(when (not= false mark-dirty?)
|
||||
(mark-summaries-dirty! plan start-dt end-dt)
|
||||
(println "Sales summaries marked dirty. Run auto-ap.jobs.sales-summaries")
|
||||
(println "(or `(auto-ap.jobs.sales-summaries/sales-summaries-v2)`) to rebuild them."))
|
||||
(alog/info ::done :change-count total)))
|
||||
plan))
|
||||
|
||||
(defn -main [& _]
|
||||
(execute "backfill-olo-processors"
|
||||
(fn []
|
||||
(let [{:keys [start end codes apply allow-downgrade mark-dirty]} (:args env)]
|
||||
(backfill! :start (or start default-start-date)
|
||||
:end end
|
||||
:codes (cond-> codes (string? codes)
|
||||
(str/split #","))
|
||||
:apply? (boolean apply)
|
||||
:allow-downgrade? (boolean allow-downgrade)
|
||||
:mark-dirty? (if (nil? mark-dirty) true (boolean mark-dirty)))))))
|
||||
|
||||
(comment
|
||||
;; ---------------------------------------------------------------------
|
||||
;; 1. Dry run everything from 7/7 -- read only, prints what would change.
|
||||
;; ---------------------------------------------------------------------
|
||||
(def plan (backfill!))
|
||||
|
||||
;; One client at a time while you sanity check.
|
||||
(backfill! :codes ["NGCL"])
|
||||
|
||||
;; Eyeball the actual charges behind a transition.
|
||||
(->> plan
|
||||
(mapcat :changes)
|
||||
(filter #(= :ccp-processor/doordash (:to %)))
|
||||
(map (juxt :date :source :note :total :from :to))
|
||||
(take 20))
|
||||
|
||||
;; Every distinct source string we saw reclassified -- confirms the Olo
|
||||
;; variants ("Olo - DoorDash", "olo-ubereats", ...) are what moved.
|
||||
(->> plan (mapcat :changes) (map :source) frequencies)
|
||||
|
||||
;; ---------------------------------------------------------------------
|
||||
;; 2. Commit, then rebuild the summaries.
|
||||
;; ---------------------------------------------------------------------
|
||||
(backfill! :apply? true)
|
||||
(auto-ap.jobs.sales-summaries/sales-summaries-v2)
|
||||
|
||||
;; ---------------------------------------------------------------------
|
||||
;; 3. Verify: no square charge in the window disagrees with the classifier.
|
||||
;; ---------------------------------------------------------------------
|
||||
(->> (backfill!) (mapcat :changes) count) ; => 0
|
||||
|
||||
;; ---------------------------------------------------------------------
|
||||
;; Fallback: charges with no parent sales order (custom-amount tenders,
|
||||
;; see `is-order-only-for-charge?` in square3/order->sales-order) do not
|
||||
;; store the order source, so they cannot be fixed locally. Re-import them
|
||||
;; through the real pipeline instead -- `upsert` is idempotent on
|
||||
;; :charge/external-id / :sales-order/external-id, so re-running a day is
|
||||
;; safe and rewrites the processor from live Square data.
|
||||
;;
|
||||
;; This hits the Square API for every day x location; the existing
|
||||
;; auto-ap.jobs.load-historical-sales job does the same thing by day count.
|
||||
;; ---------------------------------------------------------------------
|
||||
(doseq [client (square3/get-square-clients)
|
||||
location (:client/square-locations client)
|
||||
:when (:square-location/client-location location)
|
||||
d (per/periodic-seq (parse-day "2026-07-07")
|
||||
(time/plus (atime/localize (time/now)) (time/days 1))
|
||||
(time/days 1))]
|
||||
(println (:client/code client) (:square-location/client-location location) (str d))
|
||||
@(square3/upsert client location d (time/plus d (time/days 1))))
|
||||
|
||||
;; ...then mark dirty + rebuild summaries as in step 2.
|
||||
)
|
||||
389
src/clj/auto_ap/jobs/rekey_square_external_ids.clj
Normal file
389
src/clj/auto_ap/jobs/rekey_square_external_ids.clj
Normal file
@@ -0,0 +1,389 @@
|
||||
(ns auto-ap.jobs.rekey-square-external-ids
|
||||
"One-shot migration re-keying Square entities to client-scoped external ids.
|
||||
|
||||
Refunds, charges, Square payouts (expected deposits) and cash drawer shifts all carry keys with
|
||||
no client scoping, so two clients configured on the same Square location share a single entity:
|
||||
its owner flips every time either client imports. Sales orders and ezCater orders already scope
|
||||
their keys by client and location; this brings the rest in line.
|
||||
|
||||
Measured on a restored production backup, ownership had actually changed on 3,387 refunds,
|
||||
4,069 expected deposits and 2,628 cash drawer shifts, across 19 distinct client pairs — nine of
|
||||
which no longer share a location in the current configuration and so are invisible to any
|
||||
point-in-time check.
|
||||
|
||||
Run AFTER the importer knows how to resolve both key schemes (`square.core3/existing-id`).
|
||||
Running it first would be harmless, but the importer would then re-create legacy-keyed
|
||||
entities on its next pass.
|
||||
|
||||
The migration is idempotent: an entity already carrying its scoped key is skipped, so it can
|
||||
be re-run over a partially migrated database."
|
||||
(:require
|
||||
[auto-ap.datomic :refer [conn]]
|
||||
[auto-ap.logging :as alog]
|
||||
[datomic.api :as dc]
|
||||
[iol-ion.query]))
|
||||
|
||||
(def refund-prefix "square/refund/")
|
||||
(def charge-prefix "square/charge/")
|
||||
(def deposit-prefix "square/payout/")
|
||||
(def shift-prefix "square/cash-drawer-shift/")
|
||||
|
||||
(defn- scope-of
|
||||
"`[client-code location]` for an entity, or nil when it cannot be determined.
|
||||
|
||||
Charges are the awkward case: about an eighth of them carry neither `:charge/client` nor
|
||||
`:charge/location`. Those are stubs minted by the payout path, which asserts an external id
|
||||
alone and lets unique-identity upsert bring a bare entity into being, plus older tender
|
||||
records that predate the client attribute. None are orphaned, so the scope is recovered from
|
||||
whatever references them — the sales order first, then the expected deposit."
|
||||
[db attr e]
|
||||
(let [ent (dc/entity db e)
|
||||
pair (fn [code loc] (when (and code loc) [code loc]))]
|
||||
(or (case attr
|
||||
:sales-refund/external-id (pair (:client/code (:sales-refund/client ent))
|
||||
(:sales-refund/location ent))
|
||||
:charge/external-id (pair (:client/code (:charge/client ent))
|
||||
(:charge/location ent))
|
||||
:expected-deposit/external-id (pair (:client/code (:expected-deposit/client ent))
|
||||
(:expected-deposit/location ent))
|
||||
:cash-drawer-shift/external-id (pair (:client/code (:cash-drawer-shift/client ent))
|
||||
(:cash-drawer-shift/location ent)))
|
||||
(when-let [o (:e (first (dc/datoms db :vaet e :sales-order/charges)))]
|
||||
(let [oe (dc/entity db o)]
|
||||
(pair (:client/code (:sales-order/client oe)) (:sales-order/location oe))))
|
||||
(when-let [d (:e (first (dc/datoms db :vaet e :expected-deposit/charges)))]
|
||||
(let [de (dc/entity db d)]
|
||||
(pair (:client/code (:expected-deposit/client de)) (:expected-deposit/location de)))))))
|
||||
|
||||
(defn planned-key
|
||||
"`[eid new-key]` for an entity that still needs re-keying, or nil when it is already scoped or
|
||||
cannot be scoped at all.
|
||||
|
||||
Detection compares against the key this entity *should* have rather than pattern-matching the
|
||||
id, because Square ids may themselves contain dashes and no pattern separates the two schemes
|
||||
reliably. That also makes the migration idempotent."
|
||||
[db attr prefix datom]
|
||||
(let [old (:v datom)]
|
||||
(when-let [[code loc] (scope-of db attr (:e datom))]
|
||||
(let [scoped-prefix (str prefix code "-" loc "-")]
|
||||
(when-not (.startsWith ^String old scoped-prefix)
|
||||
[(:e datom) (str scoped-prefix (subs old (count prefix)))])))))
|
||||
|
||||
(defn plan
|
||||
"Everything the migration would change, plus what it cannot touch. Read-only — run this and
|
||||
check `:collisions` is empty before transacting anything."
|
||||
[db attr prefix]
|
||||
(let [acc (reduce (fn [acc d]
|
||||
(let [acc (update acc :total inc)]
|
||||
(if-let [[e new-key] (planned-key db attr prefix d)]
|
||||
(-> acc
|
||||
(update :to-migrate inc)
|
||||
(update :new-keys conj! [e new-key]))
|
||||
(if (scope-of db attr (:e d))
|
||||
(update acc :already-scoped inc)
|
||||
(update acc :unscopable inc)))))
|
||||
{:total 0 :to-migrate 0 :already-scoped 0 :unscopable 0 :new-keys (transient [])}
|
||||
(dc/datoms db :aevt attr))]
|
||||
(update acc :new-keys persistent!)))
|
||||
|
||||
(defn collisions
|
||||
"Any two entities that would land on the same new key. Must be empty: a collision would merge
|
||||
two entities into one and lose whichever lost."
|
||||
[new-keys]
|
||||
(->> new-keys
|
||||
(group-by second)
|
||||
(keep (fn [[k es]] (when (> (count es) 1) [k (mapv first es)])))
|
||||
vec))
|
||||
|
||||
(defn migrate!
|
||||
"Asserts the new external id on each planned entity. The attribute is cardinality one, so the
|
||||
legacy value is retracted by the same assertion and the entity keeps its identity — nothing is
|
||||
created and nothing is deleted.
|
||||
|
||||
Returns the number of entities re-keyed."
|
||||
[attr new-keys batch-size]
|
||||
(let [total (count new-keys)]
|
||||
(alog/info ::migrating :attr attr :count total)
|
||||
(doseq [[i batch] (map-indexed vector (partition-all batch-size new-keys))]
|
||||
@(dc/transact conn (for [[e new-key] batch] {:db/id e attr new-key}))
|
||||
(when (zero? (mod i 20))
|
||||
(alog/info ::migrated :attr attr :done (* i batch-size) :of total)))
|
||||
total))
|
||||
|
||||
(def charge-copy-attrs
|
||||
"Everything a charge carries in its own right. `:charge/client+date` is a tuple Datomic
|
||||
maintains, and `:charge/external-id` is set separately, so neither is copied."
|
||||
[:charge/type-name :charge/total :charge/tip :charge/tax :charge/date
|
||||
:charge/processor :charge/note :charge/reference-link])
|
||||
|
||||
(defn- raw-square-id
|
||||
"The Square id inside a charge's external id, with any client scoping removed.
|
||||
|
||||
Stripping only the `square/charge/` prefix is not enough. Once a charge has been scoped to one
|
||||
client, a second order processing the same charge would read `NGCC-CC-<id>` as the id and scope
|
||||
it again, producing `square/charge/NGCD-CD-NGCC-CC-<id>`. The importer then computes the
|
||||
correct single-scoped key, fails to find it, and creates a second charge — silently doubling
|
||||
the tender.
|
||||
|
||||
Client codes may themselves contain dashes, so the scope cannot be recognised by pattern. It is
|
||||
recovered from the entity instead: whoever the charge currently belongs to is exactly whose
|
||||
scope its key carries."
|
||||
[db charge old]
|
||||
(let [ent (dc/entity db charge)
|
||||
code (:client/code (:charge/client ent))
|
||||
loc (:charge/location ent)
|
||||
owner-prefix (when (and code loc) (str charge-prefix code "-" loc "-"))]
|
||||
(cond
|
||||
(and owner-prefix (.startsWith ^String old ^String owner-prefix)) (subs old (count owner-prefix))
|
||||
(.startsWith ^String old charge-prefix) (subs old (count charge-prefix))
|
||||
:else old)))
|
||||
|
||||
(defn- charge-plan-for-order
|
||||
"What one order needs doing to its charges.
|
||||
|
||||
`:keep` — no other order has claimed this charge yet, so this order takes it and it is renamed
|
||||
in place. `:clone` — another order already owns it, so this order needs its own copy.
|
||||
|
||||
Splitting is per client, not per order. Where two orders of the SAME client and location refer
|
||||
to one payment — Square splitting a tender across orders, or an amendment — they are left
|
||||
sharing it deliberately. Both would compute the same name, so there is no second name to give
|
||||
a copy, and more importantly a copy would double that client's takings for the day. The
|
||||
component cascade still applies to those, which is why the retraction guard on
|
||||
`remove-voided-orders` is needed regardless of this migration.
|
||||
|
||||
Whether a charge is claimed is read from the charge itself: an unclaimed one still carries the
|
||||
bare `square/charge/<id>` form. Deciding it that way rather than by remembering
|
||||
every charge seen so far is what lets this run across all 16 million of them — the alternative
|
||||
needs a map of the entire table in memory. `batch-seen` covers only the orders inside one
|
||||
transaction, where the database snapshot cannot yet show a claim made moments earlier."
|
||||
[db order-eid batch-seen]
|
||||
(let [oe (dc/entity db order-eid)
|
||||
code (:client/code (:sales-order/client oe))
|
||||
loc (:sales-order/location oe)]
|
||||
(when (and code loc)
|
||||
(for [d (dc/datoms db :eavt order-eid :sales-order/charges)
|
||||
:let [charge (:v d)
|
||||
old (:v (first (dc/datoms db :eavt charge :charge/external-id)))]
|
||||
:when old
|
||||
:let [raw (raw-square-id db charge old)
|
||||
new-key (str charge-prefix code "-" loc "-" raw)
|
||||
claimed (get @batch-seen charge)
|
||||
unclaimed? (and (= old (str charge-prefix raw)) (nil? claimed))]
|
||||
;; nothing to do when the charge already answers to this order's name, or when
|
||||
;; another order in this same batch has just claimed it under that very name —
|
||||
;; that is the same-client case, which stays shared
|
||||
:when (and (not= old new-key) (not= claimed new-key))]
|
||||
{:order order-eid :charge charge :new-key new-key :raw raw
|
||||
:client (:db/id (:sales-order/client oe)) :location loc
|
||||
:action (if unclaimed? :keep :clone)}))))
|
||||
|
||||
(defn split-and-rekey-charges!
|
||||
"Gives every order its own charge entity, keyed by that order's client and location.
|
||||
|
||||
Where two clients were configured on one Square location, both clients' orders resolved to a
|
||||
single charge, because charge keys carried no client. Re-keying alone does not undo that — it
|
||||
hands the one entity to whichever client is looked at first and leaves the other order pointing
|
||||
at a charge it does not own. Since `:sales-order/charges` is a component attribute, that is not
|
||||
merely untidy: retracting either order would delete a charge the other one still needs.
|
||||
|
||||
So a shared charge is cloned. The first order to claim it keeps it, re-keyed to that order's
|
||||
scope; every other order gets a copy carrying the same amounts, scoped to its own client, and
|
||||
has its reference repointed. Afterwards no charge has more than one parent order and the
|
||||
component relationship means what it says.
|
||||
|
||||
`orders` is the collection of order entity ids to process — typically every order belonging to
|
||||
the clients that share, or have ever shared, a Square location."
|
||||
[orders batch-size]
|
||||
(let [cloned (atom 0)
|
||||
rekeyed (atom 0)]
|
||||
(doseq [[i batch] (map-indexed vector (partition-all batch-size orders))]
|
||||
(when (zero? (mod i 200))
|
||||
;; the whole-database run walks 19M orders; without a trail an interrupted run leaves
|
||||
;; no way to tell how far it got short of querying the data by hand
|
||||
(alog/info ::splitting :orders-done (* i batch-size) :rekeyed @rekeyed :cloned @cloned))
|
||||
(let [db (dc/db conn)
|
||||
batch-seen (atom {})
|
||||
tx (doall
|
||||
(for [o batch
|
||||
plan (charge-plan-for-order db o batch-seen)
|
||||
:let [{:keys [charge new-key action client location]} plan]
|
||||
tx-item (if (= :keep action)
|
||||
(do (swap! batch-seen assoc charge new-key)
|
||||
(swap! rekeyed inc)
|
||||
;; record who claimed it: the owner is how a later order
|
||||
;; recovers the Square id from an already-scoped key
|
||||
[{:db/id charge
|
||||
:charge/external-id new-key
|
||||
:charge/client client
|
||||
:charge/location location}])
|
||||
(let [ent (dc/entity db charge)
|
||||
copy (reduce (fn [m a] (if-some [v (get ent a)]
|
||||
(assoc m a (if (map? v) (:db/id v) v))
|
||||
m))
|
||||
{} charge-copy-attrs)]
|
||||
(swap! cloned inc)
|
||||
[(assoc copy
|
||||
:db/id new-key
|
||||
:charge/external-id new-key
|
||||
:charge/client client
|
||||
:charge/location location)
|
||||
[:db/retract o :sales-order/charges charge]
|
||||
{:db/id o :sales-order/charges new-key}]))]
|
||||
tx-item))]
|
||||
(when (seq tx) @(dc/transact conn tx))))
|
||||
(alog/info ::split-charges :rekeyed @rekeyed :cloned @cloned)
|
||||
{:rekeyed @rekeyed :cloned @cloned}))
|
||||
|
||||
(defn charges-with-multiple-parents
|
||||
"The §3.3 gate. Must read zero once the split has run: while any charge has two parent orders,
|
||||
retracting either order deletes the other one's payment."
|
||||
[db orders]
|
||||
(->> orders
|
||||
(mapcat (fn [o] (map :v (dc/datoms db :eavt o :sales-order/charges))))
|
||||
distinct
|
||||
(filter (fn [c] (> (reduce (fn [n _] (inc n)) 0 (dc/datoms db :vaet c :sales-order/charges)) 1)))
|
||||
count))
|
||||
|
||||
(def scoped-attrs
|
||||
"Every Square-imported entity whose key must carry its client, with the prefix and where to
|
||||
read the owner from."
|
||||
[{:attr :sales-refund/external-id :prefix refund-prefix
|
||||
:client :sales-refund/client :location :sales-refund/location}
|
||||
{:attr :charge/external-id :prefix charge-prefix
|
||||
:client :charge/client :location :charge/location}
|
||||
{:attr :expected-deposit/external-id :prefix deposit-prefix
|
||||
:client :expected-deposit/client :location :expected-deposit/location}
|
||||
{:attr :cash-drawer-shift/external-id :prefix shift-prefix
|
||||
:client :cash-drawer-shift/client :location :cash-drawer-shift/location}])
|
||||
|
||||
(defn unscoped-report
|
||||
"Counts, per entity type, how many keys are already client-scoped, how many still carry the
|
||||
legacy unscoped form, and how many have no owner to scope by.
|
||||
|
||||
The importer tolerates both key schemes on purpose, so that the change can be deployed before
|
||||
the migration finishes — but that tolerance is a transition, not a resting place. While
|
||||
`:legacy` is above zero the database is in a mixed state and a stray unscoped record can still
|
||||
be adopted by whichever client imports it first. **`:legacy` reaching zero on every attribute is
|
||||
the done-signal**, and it is what licenses removing the fallback lookup in
|
||||
`square.core3/existing-id`.
|
||||
|
||||
`:no-owner` is NOT part of that signal and never reaches zero for charges. It counts entities
|
||||
whose own `:charge/client`/`:charge/location` are absent — around an eighth of charges, the
|
||||
payout stubs `migrate!` only ever gives an external id — so this report structurally cannot
|
||||
verify them even when their keys are perfectly scoped. It classifies by attribute; `plan`
|
||||
resolves ownership through whatever references the entity. Ask `plan` for the authoritative
|
||||
answer: `:to-migrate 0` with `:unscopable 0` means there is nothing left to do."
|
||||
[db]
|
||||
(into {}
|
||||
(for [{:keys [attr prefix client location]} scoped-attrs]
|
||||
[attr (reduce (fn [acc d]
|
||||
(let [e (dc/entity db (:e d))
|
||||
code (:client/code (client e))
|
||||
loc (location e)
|
||||
scoped (when (and code loc) (str prefix code "-" loc "-"))]
|
||||
(cond
|
||||
(and scoped (.startsWith ^String (:v d) ^String scoped)) (update acc :scoped inc)
|
||||
(nil? scoped) (update acc :no-owner inc)
|
||||
:else (update acc :legacy inc))))
|
||||
{:scoped 0 :legacy 0 :no-owner 0}
|
||||
(dc/datoms db :aevt attr))])))
|
||||
|
||||
(defn all-order-ids
|
||||
"Every sales order in the database, streamed in `:aevt` order — which is ascending entity id,
|
||||
so OLDEST first. Fine for counting; wrong for anything that takes a prefix. `(take n ...)` of
|
||||
this returns the oldest n orders, not a sample: on the production copy the first 400,000 are
|
||||
all from 2019 to 2021. Use `order-months-newest-first` to walk the data in migration order."
|
||||
[db]
|
||||
(map :e (dc/datoms db :aevt :sales-order/external-id)))
|
||||
|
||||
(def ^:private earliest-orders
|
||||
"How far back the month walk goes. Comfortably before the oldest order in the database
|
||||
(2019-12-31 on the production copy); months with no orders cost one index seek per client."
|
||||
#inst "2015-01-01T00:00:00.000-00:00")
|
||||
|
||||
(defn- ->date [^java.time.LocalDate d]
|
||||
(java.util.Date/from (.toInstant (.atStartOfDay d (java.time.ZoneId/systemDefault)))))
|
||||
|
||||
(defn order-months-newest-first
|
||||
"`[start end]` month windows from now back to `earliest`, newest month first.
|
||||
|
||||
The migration walks months in this order on purpose. It is the difference between an
|
||||
interrupted run leaving the data safe to import against and leaving it dangerous: the importer
|
||||
works on recent data, so having the newest months fully scoped is what lets imports resume
|
||||
while the older tail is still unmigrated. Walking oldest-first would spend hours on 2019 before
|
||||
touching anything this month's import will read.
|
||||
|
||||
Windows tile without gaps — each month's end is the day before the next month's start — and
|
||||
because the migration is idempotent an order landing in two windows is a no-op the second
|
||||
time, so boundary precision is not safety-critical."
|
||||
([] (order-months-newest-first earliest-orders))
|
||||
([^java.util.Date earliest]
|
||||
(let [zone (java.time.ZoneId/systemDefault)
|
||||
floor (java.time.YearMonth/from (.toLocalDate (.atZone (.toInstant earliest) zone)))]
|
||||
(->> (iterate (fn [^java.time.YearMonth m] (.minusMonths m 1)) (java.time.YearMonth/now zone))
|
||||
(take-while (fn [^java.time.YearMonth m] (not (.isBefore m floor))))
|
||||
(map (fn [^java.time.YearMonth m]
|
||||
[(->date (.atDay m 1)) (->date (.atEndOfMonth m))]))))))
|
||||
|
||||
(defn orders-in-window
|
||||
"Sales order ids for every client between `start` and `end` inclusive, via the
|
||||
`:sales-order/client+date` index."
|
||||
[db clients start end]
|
||||
(map first (iol-ion.query/scan-sales-orders db clients start end)))
|
||||
|
||||
(defn- all-client-ids [db]
|
||||
(map first (dc/q '[:find ?c :where [?c :client/code _]] db)))
|
||||
|
||||
(defn migrate-all!
|
||||
"The complete migration, over the whole database rather than a chosen subset.
|
||||
|
||||
Splitting is driven from orders, because a payment's rightful owner is whichever order refers
|
||||
to it — so every order has to be walked, not merely the clients that share a location today.
|
||||
Nine client pairs contended in the past and no longer share one; their records are still mixed,
|
||||
and a migration scoped to the current configuration would miss every one of them.
|
||||
|
||||
**Ordered so that an interrupted run is recoverable.** Refunds, payouts and cash-drawer shifts
|
||||
go first: together they are a quarter of a million records and take seconds, so finishing them
|
||||
up front means an interruption cannot leave them half done. The long part — walking every order
|
||||
to split shared charges — then runs a month at a time from the current month backwards, logging
|
||||
each month as it completes. Stop it after any month and the data from that month forward is
|
||||
fully scoped, which is the part the importer reads, so imports can resume against it while the
|
||||
older tail waits. Re-running picks up where it left off because each month's work is idempotent.
|
||||
|
||||
Returns the split counts and the completeness report, which should read zero legacy across the
|
||||
board when this finishes."
|
||||
[batch-size]
|
||||
(doseq [{:keys [attr prefix]} scoped-attrs
|
||||
:when (not= attr :charge/external-id)]
|
||||
(let [p (plan (dc/db conn) attr prefix)]
|
||||
(when-let [c (seq (collisions (:new-keys p)))]
|
||||
(throw (ex-info "two entities would take the same key" {:attr attr :collisions (count c)})))
|
||||
(migrate! attr (:new-keys p) batch-size)))
|
||||
(let [clients (all-client-ids (dc/db conn))
|
||||
split (reduce (fn [acc [start end]]
|
||||
(let [ids (orders-in-window (dc/db conn) clients start end)
|
||||
r (split-and-rekey-charges! ids batch-size)]
|
||||
(alog/info ::month-complete
|
||||
:month (subs (str (.toInstant ^java.util.Date start)) 0 7)
|
||||
:rekeyed (:rekeyed r) :cloned (:cloned r))
|
||||
(merge-with + acc r)))
|
||||
{:rekeyed 0 :cloned 0}
|
||||
(order-months-newest-first))]
|
||||
;; charges no order refers to — payout stubs — are scoped from the deposit that holds them.
|
||||
;; Collision-checked like the others: this is the largest attribute in the database, so it is
|
||||
;; the last one that should discover a clash as a mid-run exception.
|
||||
(let [p (plan (dc/db conn) :charge/external-id charge-prefix)]
|
||||
(when-let [c (seq (collisions (:new-keys p)))]
|
||||
(throw (ex-info "two entities would take the same key"
|
||||
{:attr :charge/external-id :collisions (count c)})))
|
||||
(when (seq (:new-keys p)) (migrate! :charge/external-id (:new-keys p) batch-size)))
|
||||
{:split split :completeness (unscoped-report (dc/db conn))}))
|
||||
|
||||
(defn counts
|
||||
"Entity totals, for the before/after assertion that is this migration's real safety net: if
|
||||
either number moves, the re-key created duplicates instead of updating in place."
|
||||
[db]
|
||||
{:refunds (reduce (fn [n _] (inc n)) 0 (dc/datoms db :aevt :sales-refund/external-id))
|
||||
:charges (reduce (fn [n _] (inc n)) 0 (dc/datoms db :aevt :charge/external-id))
|
||||
:deposits (reduce (fn [n _] (inc n)) 0 (dc/datoms db :aevt :expected-deposit/external-id))
|
||||
:shifts (reduce (fn [n _] (inc n)) 0 (dc/datoms db :aevt :cash-drawer-shift/external-id))})
|
||||
@@ -1,5 +1,6 @@
|
||||
(ns auto-ap.jobs.sales-summaries
|
||||
(:require [auto-ap.datomic :refer [conn]]
|
||||
[auto-ap.datomic.sales-summaries :as d-ss]
|
||||
[auto-ap.jobs.core :refer [execute]]
|
||||
[auto-ap.logging :as alog]
|
||||
[auto-ap.time :as atime]
|
||||
@@ -8,6 +9,7 @@
|
||||
[clj-time.periodic :as per]
|
||||
[clojure.string :as str]
|
||||
[com.brunobonacci.mulog :as mu]
|
||||
[config.core :refer [env]]
|
||||
[datomic.api :as dc]))
|
||||
|
||||
(defn mark-dirty [client start end]
|
||||
@@ -39,26 +41,108 @@
|
||||
(dc/db conn)
|
||||
number)))
|
||||
|
||||
|
||||
(defn delete-all []
|
||||
@(dc/transact-async conn
|
||||
(->>
|
||||
(dc/q '[:find ?ss
|
||||
:where [?ss :sales-summary/date]]
|
||||
(dc/db conn))
|
||||
(map (fn [[ ss]]
|
||||
[:db/retractEntity ss])))))
|
||||
(->>
|
||||
(dc/q '[:find ?ss
|
||||
:where [?ss :sales-summary/date]]
|
||||
(dc/db conn))
|
||||
(map (fn [[ss]]
|
||||
[:db/retractEntity ss])))))
|
||||
|
||||
(def item-read
|
||||
"Enough of a summary item to both evaluate `d-ss/accepted?` and transact the item back
|
||||
unchanged. `:db/id` matters: `:sales-summary/items` is a component attribute upserted via
|
||||
`[:reset-rels ...]`, so an item re-transacted without its id is deleted and recreated."
|
||||
'[:db/id
|
||||
:sales-summary-item/category
|
||||
:sales-summary-item/sort-order
|
||||
:sales-summary-item/manual?
|
||||
:ledger-mapped/amount
|
||||
{:ledger-mapped/ledger-side [:db/ident]}
|
||||
{:ledger-mapped/account [:db/id]}])
|
||||
|
||||
(defn dirty-sales-summaries
|
||||
"The client's dirty summaries, with enough of each item to evaluate and re-transact it.
|
||||
|
||||
(defn dirty-sales-summaries [c]
|
||||
`index-pull` returns a lazy seq running from `:start` to the END of the index, so this must
|
||||
stop at the client boundary rather than filter: `:sales-summary/client+dirty` sorts by client
|
||||
first, so every later client's summaries sit beyond this client's and filtering would walk all
|
||||
of them — for every client — pulling their items on the way. That is quadratic in the number of
|
||||
summaries, and it showed up as a full refresh degrading from ~180 client-days a minute to ~3 as
|
||||
the summary count grew."
|
||||
[c]
|
||||
(let [client-id (dc/entid (dc/db conn) c)]
|
||||
(->> (dc/index-pull (dc/db conn)
|
||||
{:index :avet
|
||||
:selector '[:sales-summary/date :sales-summary/client :db/id]
|
||||
:selector (conj '[:sales-summary/date :sales-summary/client :db/id]
|
||||
{:sales-summary/items item-read})
|
||||
:start [:sales-summary/client+dirty [client-id true]]})
|
||||
(filter (fn [sales-summary]
|
||||
(= client-id (:db/id (:sales-summary/client sales-summary))))))))
|
||||
(take-while (fn [sales-summary]
|
||||
(= client-id (:db/id (:sales-summary/client sales-summary))))))))
|
||||
|
||||
(def default-days
|
||||
"How far back the scheduled refresh looks for summaries that still need recomputing."
|
||||
7)
|
||||
|
||||
(defn trailing-window
|
||||
"`[start end)` covering the last `days` business days, ending with today. `end` is
|
||||
exclusive, matching both `periodic-seq`'s 3-arity and the grid's date filters, so
|
||||
`(trailing-window 7)` is day -6 through today inclusive."
|
||||
[days]
|
||||
[(.toDateMidnight (atime/localize (time/minus (time/now) (time/days (dec days)))))
|
||||
(.toDateMidnight (atime/localize (time/plus (time/now) (time/days 1))))])
|
||||
|
||||
(defn accepted-client+dates
|
||||
"Set of `[client-id date]` pairs in `[start end)` whose summary is already accepted, and
|
||||
so should be left alone rather than re-marked. Accepted means balanced with every line
|
||||
mapped to an account — the condition the grid renders as \"Balanced\"."
|
||||
[db start end]
|
||||
(->> (dc/q '[:find (pull ?ss selector)
|
||||
:in $ ?start ?end selector
|
||||
:where
|
||||
[?ss :sales-summary/date ?d]
|
||||
[(>= ?d ?start)]
|
||||
[(< ?d ?end)]]
|
||||
db
|
||||
(c/to-date start)
|
||||
(c/to-date end)
|
||||
(conj '[:sales-summary/date {:sales-summary/client [:db/id]}]
|
||||
{:sales-summary/items item-read}))
|
||||
(map first)
|
||||
(filter #(d-ss/accepted? (map d-ss/<-pulled-item (:sales-summary/items %))))
|
||||
(map (juxt (comp :db/id :sales-summary/client) :sales-summary/date))
|
||||
set))
|
||||
|
||||
(defn mark-stale-dirty
|
||||
"Marks every client/day in the trailing `days` window dirty so `sales-summaries-v2` will
|
||||
recompute it, skipping days whose summary is already accepted. Because accepted is derived
|
||||
rather than stored, a summary that later falls out of balance is picked up again on the
|
||||
next run. Returns the number of client/days marked."
|
||||
[days]
|
||||
(let [db (dc/db conn)
|
||||
[start end] (trailing-window days)
|
||||
accepted (accepted-client+dates db start end)
|
||||
clients (map first (dc/q '[:find ?c
|
||||
:in $
|
||||
:where [_ :sales-order/client ?c]]
|
||||
db))
|
||||
dates (map c/to-date (per/periodic-seq start end (time/days 1)))
|
||||
tx-data (for [client clients
|
||||
date dates
|
||||
:when (not (accepted [client date]))]
|
||||
{:sales-summary/client client
|
||||
:sales-summary/date date
|
||||
:sales-summary/dirty true
|
||||
:sales-summary/client+date [client date]})]
|
||||
(alog/info ::marking-dirty
|
||||
:days days
|
||||
:client-count (count clients)
|
||||
:accepted-count (count accepted)
|
||||
:marked (count tx-data))
|
||||
(doseq [batch (partition-all 500 tx-data)]
|
||||
@(dc/transact conn batch))
|
||||
(count tx-data)))
|
||||
|
||||
(defn- get-fee [c date]
|
||||
(- (or (ffirst (dc/q '[:find ?f
|
||||
@@ -72,8 +156,18 @@
|
||||
date))
|
||||
0.0)))
|
||||
|
||||
(def service-charges-account
|
||||
"Where a credited Square service charge lands. 49000 is the existing \"Service Income\"
|
||||
revenue account, which is the closest fit for auto-gratuity and catering fees.
|
||||
|
||||
NEEDS ACCOUNTING SIGN-OFF before `service-charges-flag` is enabled for any client: the wrong
|
||||
account misstates revenue, and a category with no account at all keeps a day from ever
|
||||
reaching accepted, since `accepted?` requires every line to be mapped."
|
||||
49000)
|
||||
|
||||
(def name->number
|
||||
{"gyros and pitas" 40111
|
||||
"service charges" service-charges-account
|
||||
"returns" 41300
|
||||
"card payments" 75460
|
||||
"cash payments" 75452
|
||||
@@ -99,53 +193,53 @@
|
||||
"food app refunds" 41400})
|
||||
|
||||
(defn get-payment-items [c date]
|
||||
(->>
|
||||
(dc/q '[:find ?processor ?type-name (sum ?total)
|
||||
:with ?c
|
||||
:in $ [?clients ?start-date ?end-date]
|
||||
:where [(iol-ion.query/scan-sales-orders $ ?clients ?start-date ?end-date) [[?e _ ?sort-default] ...]]
|
||||
[?e :sales-order/charges ?c]
|
||||
[?c :charge/type-name ?type-name]
|
||||
(or-join [?c ?processor]
|
||||
(and [?c :charge/processor ?p]
|
||||
[?p :db/ident ?processor])
|
||||
(and
|
||||
(not [?c :charge/processor])
|
||||
[(ground :ccp-processor/na) ?processor]))
|
||||
[?c :charge/total ?total]]
|
||||
(dc/db conn)
|
||||
[[c] date date])
|
||||
(reduce
|
||||
(fn [acc [processor type-name total]]
|
||||
(update
|
||||
acc
|
||||
(cond (= type-name "CARD")
|
||||
"Card Payments"
|
||||
(= type-name "CASH")
|
||||
"Cash Payments"
|
||||
(#{"SQUARE_GIFT_CARD" "WALLET" "GIFT_CARD"} type-name)
|
||||
"Gift Card Payments"
|
||||
(#{:ccp-processor/toast
|
||||
#_:ccp-processor/ezcater
|
||||
#_:ccp-processor/koala
|
||||
:ccp-processor/doordash
|
||||
:ccp-processor/grubhub
|
||||
:ccp-processor/uber-eats} processor)
|
||||
"Food App Payments"
|
||||
:else
|
||||
"Unknown")
|
||||
(fnil + 0.0)
|
||||
total))
|
||||
{})
|
||||
(map (fn [[k v]]
|
||||
{:db/id (str (java.util.UUID/randomUUID))
|
||||
:sales-summary-item/sort-order 0
|
||||
:sales-summary-item/category k
|
||||
|
||||
:ledger-mapped/amount (if (= "Card Payments" k)
|
||||
(- v (get-fee c date))
|
||||
v)
|
||||
:ledger-mapped/ledger-side :ledger-side/debit}))))
|
||||
(->>
|
||||
(dc/q '[:find ?processor ?type-name (sum ?total)
|
||||
:with ?c
|
||||
:in $ [?clients ?start-date ?end-date]
|
||||
:where [(iol-ion.query/scan-sales-orders $ ?clients ?start-date ?end-date) [[?e _ ?sort-default] ...]]
|
||||
[?e :sales-order/charges ?c]
|
||||
[?c :charge/type-name ?type-name]
|
||||
(or-join [?c ?processor]
|
||||
(and [?c :charge/processor ?p]
|
||||
[?p :db/ident ?processor])
|
||||
(and
|
||||
(not [?c :charge/processor])
|
||||
[(ground :ccp-processor/na) ?processor]))
|
||||
[?c :charge/total ?total]]
|
||||
(dc/db conn)
|
||||
[[c] date date])
|
||||
(reduce
|
||||
(fn [acc [processor type-name total]]
|
||||
(update
|
||||
acc
|
||||
(cond (= type-name "CARD")
|
||||
"Card Payments"
|
||||
(= type-name "CASH")
|
||||
"Cash Payments"
|
||||
(#{"SQUARE_GIFT_CARD" "WALLET" "GIFT_CARD"} type-name)
|
||||
"Gift Card Payments"
|
||||
(#{:ccp-processor/toast
|
||||
#_:ccp-processor/ezcater
|
||||
#_:ccp-processor/koala
|
||||
:ccp-processor/doordash
|
||||
:ccp-processor/grubhub
|
||||
:ccp-processor/uber-eats} processor)
|
||||
"Food App Payments"
|
||||
:else
|
||||
"Unknown")
|
||||
(fnil + 0.0)
|
||||
total))
|
||||
{})
|
||||
(map (fn [[k v]]
|
||||
{:db/id (str (java.util.UUID/randomUUID))
|
||||
:sales-summary-item/sort-order 0
|
||||
:sales-summary-item/category k
|
||||
|
||||
:ledger-mapped/amount (if (= "Card Payments" k)
|
||||
(- v (get-fee c date))
|
||||
v)
|
||||
:ledger-mapped/ledger-side :ledger-side/debit}))))
|
||||
|
||||
(defn get-discounts [c date]
|
||||
(when-let [discount (ffirst (dc/q '[:find (sum ?discount)
|
||||
@@ -162,7 +256,7 @@
|
||||
:ledger-mapped/ledger-side :ledger-side/debit}))
|
||||
|
||||
(defn get-refund-items [c date]
|
||||
(->>
|
||||
(->>
|
||||
(dc/q '[:find ?type-name (sum ?t)
|
||||
:with ?e
|
||||
:in $ [?clients ?start-date ?end-date]
|
||||
@@ -173,26 +267,24 @@
|
||||
(dc/db conn)
|
||||
[[c] date date])
|
||||
(reduce
|
||||
(fn [acc [type-name total]]
|
||||
(update
|
||||
acc
|
||||
(cond (= type-name "CARD")
|
||||
"Card Refunds"
|
||||
(= type-name "CASH")
|
||||
"Cash Refunds"
|
||||
:else
|
||||
"Food App Refunds")
|
||||
(fnil + 0.0)
|
||||
total))
|
||||
{})
|
||||
(fn [acc [type-name total]]
|
||||
(update
|
||||
acc
|
||||
(cond (= type-name "CARD")
|
||||
"Card Refunds"
|
||||
(= type-name "CASH")
|
||||
"Cash Refunds"
|
||||
:else
|
||||
"Food App Refunds")
|
||||
(fnil + 0.0)
|
||||
total))
|
||||
{})
|
||||
(map (fn [[k v]]
|
||||
{:db/id (str (java.util.UUID/randomUUID))
|
||||
:sales-summary-item/sort-order 3
|
||||
:sales-summary-item/category k
|
||||
:ledger-mapped/amount v
|
||||
:ledger-mapped/ledger-side :ledger-side/credit}))))
|
||||
|
||||
|
||||
{:db/id (str (java.util.UUID/randomUUID))
|
||||
:sales-summary-item/sort-order 3
|
||||
:sales-summary-item/category k
|
||||
:ledger-mapped/amount v
|
||||
:ledger-mapped/ledger-side :ledger-side/credit}))))
|
||||
|
||||
(defn get-fees [c date]
|
||||
(when-let [fee (get-fee c date)]
|
||||
@@ -219,21 +311,48 @@
|
||||
[[c] date date]))
|
||||
0.0)})
|
||||
|
||||
(defn- get-tip [c date]
|
||||
(defn- tendered-tip
|
||||
"Tips read off the tenders, which is where a tip actually settles."
|
||||
[c date]
|
||||
(or (ffirst (dc/q '[:find (sum ?tip)
|
||||
:with ?c
|
||||
:in $ [?clients ?start-date ?end-date]
|
||||
:where [(iol-ion.query/scan-sales-orders $ ?clients ?start-date ?end-date) [[?e _ ?sort-default] ...]]
|
||||
[?e :sales-order/charges ?c]
|
||||
[?c :charge/tip ?tip]]
|
||||
(dc/db conn)
|
||||
[[c] date date]))
|
||||
0.0))
|
||||
|
||||
(defn- untendered-tip
|
||||
"Tips on orders that carry no tender at all. A return-only order reverses its tip on
|
||||
`:sales-order/tip` but has no charge to join through, so the reversal is invisible to
|
||||
`tendered-tip` and the day ends up crediting a tip that was handed back."
|
||||
[c date]
|
||||
(or (ffirst (dc/q '[:find (sum ?tip)
|
||||
:with ?e
|
||||
:in $ [?clients ?start-date ?end-date]
|
||||
:where [(iol-ion.query/scan-sales-orders $ ?clients ?start-date ?end-date) [[?e _ ?sort-default] ...]]
|
||||
[?e :sales-order/tip ?tip]
|
||||
(not [?e :sales-order/charges])]
|
||||
(dc/db conn)
|
||||
[[c] date date]))
|
||||
0.0))
|
||||
|
||||
(defn- get-tip
|
||||
"Tendered tips plus the tips on untendered orders. Additive rather than substitutive on
|
||||
purpose: where an order does have a tender, the tender is the correct source, and real
|
||||
orders exist whose tender carries a tip their `:sales-order/tip` does not — auto-gratuity
|
||||
booked as a service charge, and wallet tips absent from the net amounts. Reading the order
|
||||
instead of the tender would drop those."
|
||||
[c date]
|
||||
{:ledger-mapped/ledger-side :ledger-side/credit
|
||||
:sales-summary-item/sort-order 2
|
||||
:db/id (str (java.util.UUID/randomUUID))
|
||||
|
||||
:sales-summary-item/category "Tip"
|
||||
:ledger-mapped/amount (or (ffirst (dc/q '[:find (sum ?tip)
|
||||
:with ?c
|
||||
:in $ [?clients ?start-date ?end-date]
|
||||
:where [(iol-ion.query/scan-sales-orders $ ?clients ?start-date ?end-date) [[?e _ ?sort-default] ...]]
|
||||
[?e :sales-order/charges ?c]
|
||||
[?c :charge/tip ?tip]]
|
||||
(dc/db conn)
|
||||
[[c] date date]))
|
||||
0.0)})
|
||||
:ledger-mapped/amount (+ (tendered-tip c date)
|
||||
(untendered-tip c date))})
|
||||
|
||||
(defn- get-sales [c date]
|
||||
(let [sales (->> (dc/q '[:find ?category (sum ?total) (sum ?tax) (sum ?discount)
|
||||
@@ -259,6 +378,14 @@
|
||||
:ledger-mapped/amount (- (+ total discount) tax)
|
||||
#_#_:ledger-mapped/account nil})))
|
||||
|
||||
;; A day carrying refunds and no sales at all is left out of balance on purpose. It is tempting
|
||||
;; to close it by booking a return against the day's refunds — the arithmetic works, and no
|
||||
;; trading day could be affected. Do not. Those days are overwhelmingly not "a refund settled
|
||||
;; while the restaurant was shut": they are days whose *orders were never imported*, on client
|
||||
;; records that took ownership of another record's refunds through the unscoped keys this branch
|
||||
;; fixes. Balancing them would convert the only signal that a client's sales are missing into
|
||||
;; silence. See `docs/2026-08-15-sales-summary-rollout-plan.md`.
|
||||
|
||||
(defn- get-returns [c date]
|
||||
(when-let [amount (ffirst (dc/q '[:find (sum ?r)
|
||||
:with ?e
|
||||
@@ -276,49 +403,128 @@
|
||||
:ledger-mapped/amount amount
|
||||
:ledger-mapped/ledger-side :ledger-side/debit}))
|
||||
|
||||
(defn sales-summaries-v2 []
|
||||
(doseq [[c client-code] (dc/q '[:find ?c ?client-code
|
||||
:in $
|
||||
:where [?c :client/code ?client-code]]
|
||||
(dc/db conn))
|
||||
{:sales-summary/keys [date] :db/keys [id]} (dirty-sales-summaries c)]
|
||||
(def service-charges-flag
|
||||
"Per-client rollout lever for crediting Square service charges, in the same style as
|
||||
`new-square` and `import-custom-amount`. Absent, the summary behaves exactly as it does
|
||||
today."
|
||||
"summary-service-charges")
|
||||
|
||||
(defn- service-charges-enabled? [c]
|
||||
(contains? (set (:client/feature-flags (dc/pull (dc/db conn) '[:client/feature-flags] c)))
|
||||
service-charges-flag))
|
||||
|
||||
(defn service-charge-total
|
||||
"Square service charges for the day, both signs.
|
||||
|
||||
A service charge is collected inside the card tender but nothing credits it, so every order
|
||||
carrying one leaves the day short by exactly that amount. Both signs matter: a returned
|
||||
catering fee arrives as a negative service charge and is subtracted back out of
|
||||
`:sales-order/returns`, so dropping negatives would lose the reversal.
|
||||
|
||||
The vendor gate is load-bearing — ezCater service charges are commission deducted from the
|
||||
restaurant rather than collected from the diner, and crediting those would make things worse.
|
||||
It matches on `:sales-order/vendor` where that is set and falls back to the external id
|
||||
prefix where it is not, because whole eras of Square orders carry no vendor attribute at all
|
||||
and a gate on vendor alone silently credits nothing.
|
||||
|
||||
Kept separate from the rollout flag so the arithmetic can be measured on its own."
|
||||
[c date]
|
||||
(ffirst (dc/q '[:find (sum ?service-charge)
|
||||
:with ?e
|
||||
:in $ [?clients ?start-date ?end-date]
|
||||
:where [(iol-ion.query/scan-sales-orders $ ?clients ?start-date ?end-date) [[?e _ ?sort-default] ...]]
|
||||
[?e :sales-order/service-charge ?service-charge]
|
||||
(or-join [?e]
|
||||
[?e :sales-order/vendor :vendor/ccp-square]
|
||||
(and (not [?e :sales-order/vendor])
|
||||
[?e :sales-order/external-id ?external-id]
|
||||
[(clojure.string/starts-with? ?external-id "square/order/")]))]
|
||||
(dc/db conn)
|
||||
[[c] date date])))
|
||||
|
||||
(defn- get-service-charges
|
||||
"The day's service charges as a summary item, for clients opted in to the rollout."
|
||||
[c date]
|
||||
(when (service-charges-enabled? c)
|
||||
(when-let [amount (service-charge-total c date)]
|
||||
(when-not (zero? amount)
|
||||
{:db/id (str (java.util.UUID/randomUUID))
|
||||
:sales-summary-item/category "Service Charges"
|
||||
:sales-summary-item/sort-order 2
|
||||
:ledger-mapped/amount amount
|
||||
:ledger-mapped/ledger-side :ledger-side/credit}))))
|
||||
|
||||
(def ^:private suspect-categories
|
||||
"The terms a balancing investigation keeps returning to. Logged beside the imbalance so a
|
||||
day's shape can be read out of the logs without re-running the job."
|
||||
["Tip" "Service Charges" "Returns" "Card Refunds" "Cash Refunds" "Food App Refunds"])
|
||||
|
||||
(defn- suspect-totals
|
||||
"Amounts for `suspect-categories` present on this day, omitting the ones that are zero."
|
||||
[items]
|
||||
(into {}
|
||||
(for [category suspect-categories
|
||||
:let [amount (->> items
|
||||
(filter #(= category (:sales-summary-item/category %)))
|
||||
(map #(:ledger-mapped/amount % 0.0))
|
||||
(reduce + 0.0))]
|
||||
:when (not (zero? amount))]
|
||||
[category amount])))
|
||||
|
||||
(defn refresh-client!
|
||||
"Recomputes every dirty summary for one client.
|
||||
|
||||
Split out of the driver loop so a client's work stands on its own: it can be run for a single
|
||||
client, and a backfill over the whole history can spread clients across threads instead of
|
||||
grinding through the largest ones one day at a time."
|
||||
[c client-code]
|
||||
(doseq [{:sales-summary/keys [date] :db/keys [id] :as existing-summary} (dirty-sales-summaries c)]
|
||||
(mu/with-context {:client-code client-code
|
||||
:date date}
|
||||
(alog/info ::updating)
|
||||
(let [result {:db/id id
|
||||
(let [manual-items (->> existing-summary
|
||||
:sales-summary/items
|
||||
(filter :sales-summary-item/manual?)
|
||||
(map d-ss/<-pulled-item))
|
||||
calculated-items (->>
|
||||
(get-sales c date)
|
||||
(concat (get-payment-items c date))
|
||||
(concat (get-refund-items c date))
|
||||
(cons (get-discounts c date))
|
||||
(cons (get-fees c date))
|
||||
(cons (get-tax c date))
|
||||
(cons (get-tip c date))
|
||||
(cons (get-service-charges c date))
|
||||
(cons (get-returns c date))
|
||||
(filter identity)
|
||||
(map (fn [z]
|
||||
(assoc z :ledger-mapped/account (some-> z :sales-summary-item/category str/lower-case name->number lookup-account)
|
||||
:sales-summary-item/manual? false))))
|
||||
all-items (concat calculated-items manual-items)
|
||||
result {:db/id id
|
||||
:sales-summary/client c
|
||||
:sales-summary/date date
|
||||
:sales-summary/dirty false
|
||||
:sales-summary/client+date [c date]
|
||||
|
||||
:sales-summary/items
|
||||
(->>
|
||||
(get-sales c date)
|
||||
(concat (get-payment-items c date))
|
||||
(concat (get-refund-items c date))
|
||||
(cons (get-discounts c date))
|
||||
(cons (get-fees c date))
|
||||
(cons (get-tax c date))
|
||||
(cons (get-tip c date))
|
||||
(cons (get-returns c date))
|
||||
(filter identity)
|
||||
(map (fn [z]
|
||||
(assoc z :ledger-mapped/account (some-> z :sales-summary-item/category str/lower-case name->number lookup-account)
|
||||
:sales-summary-item/manual? false))
|
||||
)) }]
|
||||
:sales-summary/items all-items}]
|
||||
(if (seq (:sales-summary/items result))
|
||||
(do
|
||||
(alog/info ::upserting-summaries
|
||||
:category-count (count (:sales-summary/items result)))
|
||||
@(dc/transact conn [[:upsert-entity result]]))
|
||||
:category-count (count (:sales-summary/items result))
|
||||
:imbalance (d-ss/imbalance all-items)
|
||||
:balanced? (d-ss/balanced? all-items)
|
||||
:suspect-totals (suspect-totals all-items))
|
||||
@(dc/transact conn [[:upsert-sales-summary result]]))
|
||||
@(dc/transact conn [{:db/id id :sales-summary/dirty false}]))))))
|
||||
|
||||
(let [c (auto-ap.datomic/pull-attr (dc/db conn) :db/id [:client/code "NGCL" ])
|
||||
date #inst "2024-04-14T00:00:00-07:00"]
|
||||
(get-payment-items c date)
|
||||
|
||||
)
|
||||
|
||||
(defn sales-summaries-v2
|
||||
"Recomputes every dirty summary, client by client."
|
||||
[]
|
||||
(doseq [[c client-code] (dc/q '[:find ?c ?client-code
|
||||
:in $
|
||||
:where [?c :client/code ?client-code]]
|
||||
(dc/db conn))]
|
||||
(refresh-client! c client-code)))
|
||||
|
||||
(defn reset-summaries []
|
||||
@(dc/transact conn (->> (dc/q '[:find ?sos
|
||||
@@ -328,35 +534,35 @@
|
||||
(map (fn [[sos]]
|
||||
[:db/retractEntity sos])))))
|
||||
|
||||
|
||||
|
||||
|
||||
(comment
|
||||
(auto-ap.datomic/transact-schema conn)
|
||||
|
||||
@(dc/transact conn [{:db/ident :sales-summary/total-unknown-processor-payments
|
||||
:db/noHistory true,
|
||||
:db/valueType :db.type/double
|
||||
:db/cardinality :db.cardinality/one}])
|
||||
|
||||
(apply mark-dirty [:client/code "NGCL"] (last-n-days 30))
|
||||
|
||||
(apply mark-dirty [:client/code "NGDG"] (last-n-days 30))
|
||||
|
||||
(apply mark-dirty [:client/code "NGPG"] (last-n-days 30))
|
||||
(dirty-sales-summaries [:client/code "NGWH"])
|
||||
|
||||
(mark-all-dirty 50)
|
||||
(apply mark-dirty [:client/code "NGWH"] (last-n-days 5))
|
||||
|
||||
(iol-ion.tx.upsert-sales-summary-ledger/summary->journal-entry (dc/db conn) 17592314245819)
|
||||
|
||||
(iol-ion.tx.upsert-sales-summary-ledger/upsert-sales-summary (dc/db conn) {:db/id 17592314241429})
|
||||
|
||||
(mark-all-dirty 5)
|
||||
(delete-all)
|
||||
|
||||
(sales-summaries-v2)
|
||||
|
||||
1
|
||||
|
||||
(dc/q '[:find (pull ?sos [* {:sales-summary/sales-items [*]}])
|
||||
:in $
|
||||
:where [?sos :sales-summary/client [:client/code "NGHW"]]
|
||||
[?sos :sales-summary/date ?d]
|
||||
[(= ?d #inst "2024-04-10T00:00:00-07:00")]]
|
||||
(dc/db conn))
|
||||
|
||||
|
||||
(dc/q '[:find ?n ?p2 (sum ?total)
|
||||
:with ?c
|
||||
:in $ [?clients ?start-date ?end-date]
|
||||
@@ -369,23 +575,35 @@
|
||||
(dc/db conn)
|
||||
[[(auto-ap.datomic/pull-attr (dc/db conn) :db/id [:client/code "NGHW"])] #inst "2024-04-11T00:00:00-07:00" #inst "2024-04-11T00:00:00-07:00"])
|
||||
|
||||
(dc/q '[:find ?n
|
||||
(dc/q '[:find ?n
|
||||
:in $ [?clients ?start-date ?end-date]
|
||||
:where [(iol-ion.query/scan-sales-orders $ ?clients ?start-date ?end-date) [[?e _ ?sort-default] ...]]
|
||||
[?e :sales-order/line-items ?li]
|
||||
[?li :order-line-item/item-name ?n] ]
|
||||
[?li :order-line-item/item-name ?n]]
|
||||
(dc/db conn)
|
||||
[[(auto-ap.datomic/pull-attr (dc/db conn) :db/id [:client/code "NGCL"])] #inst "2024-04-11T00:00:00-07:00" #inst "2024-04-24T00:00:00-07:00"])
|
||||
|
||||
@(dc/transact conn [{:db/id :sales-summary/total-tax :db/ident :sales-summary/total-tax-legacy}
|
||||
{:db/id :sales-summary/total-tip :db/ident :sales-summary/total-tip-legacy}])
|
||||
|
||||
(auto-ap.datomic/transact-schema conn)
|
||||
@(dc/transact conn [{:db/id :sales-summary/total-tax :db/ident :sales-summary/total-tax-legacy}
|
||||
{:db/id :sales-summary/total-tip :db/ident :sales-summary/total-tip-legacy}])
|
||||
|
||||
)
|
||||
(auto-ap.datomic/transact-schema conn))
|
||||
|
||||
(defn days-arg
|
||||
"Trailing-window size from the job's `args`, e.g. `{:days 30}` set as a container override
|
||||
from the admin Background Jobs page for an ad-hoc wider backfill. Values arrive as EDN but
|
||||
may still be strings, so coerce defensively the way load-historical-sales does."
|
||||
[args]
|
||||
(let [days (:days args)]
|
||||
(cond-> (or days default-days)
|
||||
(string? days) (#(Long/parseLong %)))))
|
||||
|
||||
(defn refresh-sales-summaries
|
||||
"Marks the trailing `days` window dirty, skipping accepted summaries, then recomputes
|
||||
everything left dirty."
|
||||
([] (refresh-sales-summaries default-days))
|
||||
([days]
|
||||
(mark-stale-dirty days)
|
||||
(sales-summaries-v2)))
|
||||
|
||||
(defn -main [& _]
|
||||
(execute "sales-summaries" sales-summaries-v2))
|
||||
|
||||
(execute "sales-summaries" #(refresh-sales-summaries (days-arg (:args env)))))
|
||||
|
||||
@@ -35,20 +35,35 @@
|
||||
(into {}))))))
|
||||
@sysco-name->line)
|
||||
|
||||
(defn get-line-account [item-name]
|
||||
(get (get-sysco->line)
|
||||
item-name
|
||||
(ffirst (dc/q '[:find ?a
|
||||
:in $ ?an
|
||||
:where [?a :account/numeric-code ?an]]
|
||||
(dc/db conn)
|
||||
50000))))
|
||||
(defn get-account-by-code [numeric-code]
|
||||
(ffirst (dc/q '[:find ?a
|
||||
:in $ ?an
|
||||
:where [?a :account/numeric-code ?an]]
|
||||
(dc/db conn)
|
||||
numeric-code)))
|
||||
|
||||
;; Sysco categories whose *default* is unambiguous, so a line item whose
|
||||
;; description is missing from sysco_line_item_mapping.csv still codes
|
||||
;; correctly instead of silently defaulting to Food Costs. Only PAPER & DISP
|
||||
;; qualifies: 440 of its 455 mapped descriptions point at 55000, and the 15
|
||||
;; exceptions (foil pans -> 51500, scour pads -> 74100) are all mapped
|
||||
;; explicitly, so the description mapping wins ahead of this fallback.
|
||||
(def category->numeric-code {"PAPER & DISP" 55000})
|
||||
|
||||
(def default-numeric-code 50000)
|
||||
|
||||
(defn get-line-account
|
||||
([item-name] (get-line-account item-name nil))
|
||||
([item-name sysco-category]
|
||||
(or (get (get-sysco->line) item-name)
|
||||
(some-> (category->numeric-code sysco-category) get-account-by-code)
|
||||
(get-account-by-code default-numeric-code))))
|
||||
|
||||
(def ^:dynamic bucket-name (:data-bucket env))
|
||||
|
||||
(def header-keys ["TransCode" "GroupID" "Company" "CustomerNumber" "InvoiceNumber" "RecordType" "Item" "InvoiceDocument" "AccountName" "AccountDunsNo" "InvoiceDate" "AccountDate" "CustomerPONo" "PaymentTerms" "TermsDescription" "StoreNumber" "CustomerName" "AddressLine1" "AddressLine2" "City1" "State1" "Zip1" "Phone1" "Duns1" "Hin1" "Dea1" "TIDCustomer" "ChainNumber" "BidNumber" "ContractNumber" "CompanyNumber" "BriefName" "Address" "Address2" "City2" "State2" "Zip2" "Phone2" "Duns2" "Hin2" "Dea2" "Tid_OPCO" "ObligationIndicator" "Manifest" "Route" "Stop" "TermsDiscountPercent" "TermsDiscountDueDate" "TermsNetDueDate" "TermsDiscountAmount" "TermsDiscountCode" "OrderDate" "DepartmentCode"])
|
||||
(def item-price-index 15)
|
||||
(def item-category-index 25)
|
||||
(def item-name-index 29)
|
||||
|
||||
(def summary-keys ["TranCode" "GroupID" "Company" "CustomerNumber" "InvoiceNumber" "RecordType" "Item" "InvoiceDocument" "TotalLines" "TotalQtyInvoice" "TotalQty" "TotalQtySplit" "TotalQtyPounds" "TotalExtendedPrice" "TotalTaxAmount" "TotalInvoiceAmount" "AccountDate"])
|
||||
@@ -56,14 +71,13 @@
|
||||
(defn get-sysco-vendor []
|
||||
(let [db (dc/db conn)]
|
||||
(->
|
||||
(dc/q '[:find (pull ?v r)
|
||||
:in $ r
|
||||
:where [?v :vendor/name "Sysco"]]
|
||||
db
|
||||
d-vendors/default-read)
|
||||
first
|
||||
first)))
|
||||
|
||||
(dc/q '[:find (pull ?v r)
|
||||
:in $ r
|
||||
:where [?v :vendor/name "Sysco"]]
|
||||
db
|
||||
d-vendors/default-read)
|
||||
first
|
||||
first)))
|
||||
|
||||
(defn read-sysco-csv [k]
|
||||
(-> (s3/get-object {:bucket-name bucket-name
|
||||
@@ -73,34 +87,33 @@
|
||||
csv/read-csv))
|
||||
|
||||
(defn check-okay-amount? [i]
|
||||
(dollars=
|
||||
(dollars=
|
||||
(:invoice/total i)
|
||||
(reduce + 0.0 (map :invoice-expense-account/amount (:invoice/expense-accounts i)))))
|
||||
|
||||
(defn code-individual-items [invoice csv-rows tax]
|
||||
(let [items (->> csv-rows
|
||||
butlast
|
||||
(reduce
|
||||
(fn [acc row]
|
||||
(update acc (get-line-account (nth row item-name-index))
|
||||
(fnil + 0.0)
|
||||
(Double/parseDouble (nth row item-price-index))
|
||||
)
|
||||
)
|
||||
{})
|
||||
)
|
||||
(reduce
|
||||
(fn [acc row]
|
||||
(update acc (get-line-account (nth row item-name-index)
|
||||
(nth row item-category-index))
|
||||
(fnil + 0.0)
|
||||
(Double/parseDouble (nth row item-price-index))))
|
||||
|
||||
{}))
|
||||
items-with-tax (update items (get-line-account "TAX")
|
||||
(fnil + 0.0)
|
||||
(fnil + 0.0)
|
||||
tax)
|
||||
updated-invoice (assoc invoice :invoice/expense-accounts
|
||||
(for [[account amount] items-with-tax]
|
||||
#:invoice-expense-account {:db/id (random-tempid)
|
||||
:account account
|
||||
:location (:invoice/location invoice)
|
||||
:amount amount}))]
|
||||
updated-invoice (assoc invoice :invoice/expense-accounts
|
||||
(for [[account amount] items-with-tax]
|
||||
#:invoice-expense-account {:db/id (random-tempid)
|
||||
:account account
|
||||
:location (:invoice/location invoice)
|
||||
:amount amount}))]
|
||||
(if (check-okay-amount? updated-invoice)
|
||||
updated-invoice
|
||||
(do (alog/warn ::itemized-expenses-not-adding-up
|
||||
(do (alog/warn ::itemized-expenses-not-adding-up
|
||||
:invoice updated-invoice)
|
||||
invoice))))
|
||||
|
||||
@@ -122,11 +135,11 @@
|
||||
(header-row "AddressLine2")
|
||||
(header-row "City1")
|
||||
(header-row "City2")])
|
||||
|
||||
|
||||
account-number (some-> account-number Long/parseLong str)
|
||||
matching-client (and account-number
|
||||
(d-clients/exact-match account-number))
|
||||
|
||||
|
||||
_ (when-not matching-client
|
||||
(throw (ex-info "cannot find matching client"
|
||||
{:account-number account-number
|
||||
@@ -153,9 +166,9 @@
|
||||
:client/locations]
|
||||
(:db/id matching-client))
|
||||
location-hint
|
||||
location-hint )
|
||||
location-hint)
|
||||
:date (coerce/to-date date)
|
||||
:vendor (:db/id sysco-vendor )
|
||||
:vendor (:db/id sysco-vendor)
|
||||
:client (:db/id matching-client)
|
||||
:import-status :import-status/imported
|
||||
:status :invoice-status/unpaid
|
||||
@@ -180,64 +193,54 @@
|
||||
(s3/delete-object {:bucket-name bucket-name
|
||||
:key k}))
|
||||
|
||||
(defn get-test-invoice-file
|
||||
(defn get-test-invoice-file
|
||||
([] (get-test-invoice-file 999))
|
||||
( [i]
|
||||
([i]
|
||||
(nth (->> (s3/list-objects-v2 {:bucket-name "data.prod.app.integreatconsult.com"
|
||||
:prefix "sysco/imported"})
|
||||
:object-summaries
|
||||
(map :key)
|
||||
)
|
||||
(map :key))
|
||||
i)))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
(comment
|
||||
(with-bindings { #'bucket-name "data.prod.app.integreatconsult.com"}
|
||||
(doall
|
||||
(for [n (range 930 940 )
|
||||
:let [result (-> (get-test-invoice-file n)
|
||||
read-sysco-csv
|
||||
(extract-invoice-details (get-sysco-vendor))
|
||||
)]
|
||||
#_#_:when (not (check-okay-amount? result))]
|
||||
(comment
|
||||
(with-bindings {#'bucket-name "data.prod.app.integreatconsult.com"}
|
||||
(doall
|
||||
(for [n (range 930 940)
|
||||
:let [result (-> (get-test-invoice-file n)
|
||||
read-sysco-csv
|
||||
(extract-invoice-details (get-sysco-vendor)))]
|
||||
#_#_:when (not (check-okay-amount? result))]
|
||||
|
||||
result)))
|
||||
|
||||
(with-bindings { #'bucket-name "data.prod.app.integreatconsult.com"}
|
||||
(let [result (-> "sysco/error/SYSCO050_00175962_20241010122639019.csv"
|
||||
(with-bindings {#'bucket-name "data.prod.app.integreatconsult.com"}
|
||||
(let [result (-> "sysco/error/SYSCO050_00175962_20241010122639019.csv"
|
||||
read-sysco-csv
|
||||
(extract-invoice-details (get-sysco-vendor))
|
||||
)]
|
||||
(extract-invoice-details (get-sysco-vendor)))]
|
||||
|
||||
result))
|
||||
|
||||
)
|
||||
result)))
|
||||
|
||||
(defn import-sysco []
|
||||
(let [sysco-vendor (get-sysco-vendor)
|
||||
keys (->> (s3/list-objects-v2 {:bucket-name bucket-name
|
||||
:prefix "sysco/pending"})
|
||||
:object-summaries
|
||||
(map :key))]
|
||||
|
||||
:object-summaries
|
||||
(map :key))]
|
||||
|
||||
(alog/info ::importing-sysco
|
||||
:count (count keys)
|
||||
:keys (pr-str keys))
|
||||
|
||||
|
||||
(let [transaction (->> keys
|
||||
(mapcat (fn [k]
|
||||
(try
|
||||
(try
|
||||
(let [invoice-key (str "invoice-files/" (UUID/randomUUID) ".csv") ;
|
||||
invoice-url (str "https://" (:data-bucket env) "/" invoice-key)]
|
||||
(s3/copy-object {:source-bucket-name (:data-bucket env)
|
||||
:destination-bucket-name (:data-bucket env)
|
||||
:source-key k
|
||||
:destination-key invoice-key})
|
||||
[[:propose-invoice
|
||||
[[:propose-invoice
|
||||
(-> k
|
||||
read-sysco-csv
|
||||
(extract-invoice-details sysco-vendor)
|
||||
@@ -246,7 +249,7 @@
|
||||
(alog/error ::cant-load-file
|
||||
:file k
|
||||
:error e e)
|
||||
(s3/copy-object {:source-bucket-name (:data-bucket env)
|
||||
(s3/copy-object {:source-bucket-name (:data-bucket env)
|
||||
:destination-bucket-name (:data-bucket env)
|
||||
:source-key k
|
||||
:destination-key (str "sysco/error/"
|
||||
@@ -256,6 +259,5 @@
|
||||
(doseq [k keys]
|
||||
(mark-key k))))
|
||||
|
||||
|
||||
(defn -main [& _]
|
||||
(execute "sysco" import-sysco))
|
||||
|
||||
@@ -35,27 +35,42 @@
|
||||
|
||||
|
||||
invoices-missing-ledger-entries (->> (dc/q {:find ['?t ]
|
||||
:in ['$ '?sd]
|
||||
:where ['[?t :invoice/date ?d]
|
||||
'[(>= ?d ?sd)]
|
||||
'(not [_ :journal-entry/original-entity ?t])
|
||||
'[?t :invoice/total ?amt]
|
||||
'[(not= 0.0 ?amt)]
|
||||
'(not [?t :invoice/status :invoice-status/voided])
|
||||
'(not [?t :invoice/import-status :import-status/pending])
|
||||
'(not [?t :invoice/exclude-from-ledger true])
|
||||
]}
|
||||
(dc/db conn) start-date)
|
||||
:in ['$ '?sd]
|
||||
:where ['[?t :invoice/date ?d]
|
||||
'[(>= ?d ?sd)]
|
||||
'(not [_ :journal-entry/original-entity ?t])
|
||||
'[?t :invoice/total ?amt]
|
||||
'[(not= 0.0 ?amt)]
|
||||
'(not [?t :invoice/status :invoice-status/voided])
|
||||
'(not [?t :invoice/import-status :import-status/pending])
|
||||
'(not [?t :invoice/exclude-from-ledger true])
|
||||
]}
|
||||
(dc/db conn) start-date)
|
||||
(map first)
|
||||
(mapv (fn [i]
|
||||
[:upsert-invoice {:db/id i}])))
|
||||
repairs (vec (concat txes-missing-ledger-entries invoices-missing-ledger-entries))]
|
||||
|
||||
sales-summaries-missing-ledger-entries (->> (dc/q {:find ['?ss ]
|
||||
:in ['$ '?sd]
|
||||
:where ['[?ss :sales-summary/date ?d]
|
||||
'[(>= ?d ?sd)]
|
||||
'(not [_ :journal-entry/original-entity ?ss])
|
||||
'[?ss :sales-summary/items ?item]
|
||||
'[?item :ledger-mapped/account]
|
||||
]}
|
||||
(dc/db conn) start-date)
|
||||
(map first)
|
||||
(mapv (fn [ss]
|
||||
[:upsert-sales-summary {:db/id ss}])))
|
||||
|
||||
repairs (vec (concat txes-missing-ledger-entries invoices-missing-ledger-entries sales-summaries-missing-ledger-entries))]
|
||||
(when (seq repairs)
|
||||
(mu/log ::ledger-repairs-needed
|
||||
:sample (take 3 repairs)
|
||||
:transaction-count (count txes-missing-ledger-entries)
|
||||
:invoice-count (count invoices-missing-ledger-entries))
|
||||
@(dc/transact conn repairs)))))
|
||||
(mu/log ::ledger-repairs-needed
|
||||
:sample (take 3 repairs)
|
||||
:transaction-count (count txes-missing-ledger-entries)
|
||||
:invoice-count (count invoices-missing-ledger-entries)
|
||||
:sales-summary-count (count sales-summaries-missing-ledger-entries))
|
||||
@(dc/transact conn repairs)))))
|
||||
|
||||
|
||||
(defn touch-transaction [e]
|
||||
|
||||
@@ -742,6 +742,19 @@
|
||||
:total [:trim-commas-and-negate nil]}
|
||||
:multi #"\n"
|
||||
:multi-match? #"^\d+"}
|
||||
|
||||
;; REEL PRODUCE STATEMENT (QuickBooks layout -- no "Reel Produce" text on the page)
|
||||
{:vendor "Reel Produce"
|
||||
:keywords [#"reelproduce\.com" #"Statement"]
|
||||
:extract {:date #"^\s*([0-9]+/[0-9]+/[0-9]+)"
|
||||
:customer-identifier #"To:(?:.*?)\n\s*(.*?)\s{2,}"
|
||||
:invoice-number #"INV #(\d+)"
|
||||
:total #"Orig\. Amount \$([\d\-,]+\.\d{2,2})"}
|
||||
:parser {:date [:clj-time "MM/dd/yyyy"]
|
||||
:total [:trim-commas-and-negate nil]}
|
||||
:multi #"\n"
|
||||
:multi-match? #"^\s*[0-9]+/[0-9]+/[0-9]+\s+INV #"}
|
||||
|
||||
{:vendor "Paulino's Bakery"
|
||||
:keywords [#"paulinosbakery"]
|
||||
:extract {:date #"\s*([0-9]+/[0-9]+/[0-9]+)"
|
||||
|
||||
@@ -177,8 +177,9 @@
|
||||
(conj [:paragraph {:color [128 0 0] :size 9} (:warning report)])
|
||||
(conj
|
||||
(table->pdf report
|
||||
(cond-> (into [30 ] (repeat client-count 13))
|
||||
(:include-comparison args) (into (repeat (* 2 client-count) 13))))))
|
||||
(cond-> (into [30 ] (repeat client-count 13))
|
||||
(:include-comparison args) (into (repeat (* 2 client-count) 13))
|
||||
(and (> client-count 1) (not (:include-comparison args))) (conj 13)))))
|
||||
output-stream)
|
||||
(.toByteArray output-stream)))
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
[auto-ap.jobs.load-historical-sales :as job-load-historical-sales]
|
||||
[auto-ap.jobs.plaid :as job-plaid]
|
||||
[auto-ap.jobs.register-invoice-import :as job-register-invoice-import]
|
||||
[auto-ap.jobs.sales-summaries :as job-sales-summaries]
|
||||
[auto-ap.jobs.square :as job-square]
|
||||
[auto-ap.jobs.sysco :as job-sysco]
|
||||
[auto-ap.jobs.vendor-usages :as job-vendor-usages]
|
||||
@@ -33,23 +34,22 @@
|
||||
(.addShutdownHook (Runtime/getRuntime)
|
||||
(Thread. f)))
|
||||
|
||||
|
||||
(defn gzip-handler []
|
||||
(let [gz (GzipHandler.)]
|
||||
(doto gz
|
||||
(.setIncludedMethods (into-array ["GET" "POST" "PUT" "DELETE" "PATCH"]))
|
||||
(.setIncludedMimeTypes (into-array ["text/css"
|
||||
"text/*"
|
||||
"text/plain"
|
||||
"text/javascript"
|
||||
"text/csv"
|
||||
"text/html"
|
||||
"text/html;charset=utf-8"
|
||||
"application/javascript"
|
||||
"application/csv"
|
||||
"application/edn"
|
||||
"application/json"
|
||||
"image/svg+xml"]))
|
||||
(.setIncludedMimeTypes (into-array ["text/css"
|
||||
"text/*"
|
||||
"text/plain"
|
||||
"text/javascript"
|
||||
"text/csv"
|
||||
"text/html"
|
||||
"text/html;charset=utf-8"
|
||||
"application/javascript"
|
||||
"application/csv"
|
||||
"application/edn"
|
||||
"application/json"
|
||||
"image/svg+xml"]))
|
||||
(.setMinGzipSize 1024))
|
||||
gz))
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
(.setHandler server stats-handler))
|
||||
(.setStopAtShutdown server true))
|
||||
|
||||
(mount/defstate port :start (Integer/parseInt (or (env :port) "3000")))
|
||||
(mount/defstate port :start (Integer/parseInt (str (or (env :port) "3000"))))
|
||||
|
||||
(mount/defstate jetty
|
||||
:start (run-jetty app {:port port
|
||||
@@ -126,6 +126,9 @@
|
||||
(= job "close-auto-invoices")
|
||||
(job-close-auto-invoices/-main)
|
||||
|
||||
(= job "sales-summaries")
|
||||
(job-sales-summaries/-main)
|
||||
|
||||
(= job "ezcater-upsert")
|
||||
(job-ezcater-upsert/-main)
|
||||
|
||||
|
||||
@@ -27,11 +27,9 @@
|
||||
"Authorization" (str "Bearer " (:client/square-auth-token client))
|
||||
"Content-Type" "application/json"}))
|
||||
|
||||
|
||||
(defn ->square-date [d]
|
||||
(f/unparse (f/formatter "YYYY-MM-dd'T'HH:mm:ssZZ") d))
|
||||
|
||||
|
||||
(def manifold-api-stream
|
||||
(let [stream (s/stream 100)]
|
||||
(->> stream
|
||||
@@ -42,10 +40,10 @@
|
||||
(de/loop [attempt 0]
|
||||
(-> (de/chain (de/future-with (ex/execute-pool)
|
||||
#_(log/info ::request-started
|
||||
:url (:url request)
|
||||
:attempt attempt
|
||||
:source "Square 3"
|
||||
:background-job "Square 3")
|
||||
:url (:url request)
|
||||
:attempt attempt
|
||||
:source "Square 3"
|
||||
:background-job "Square 3")
|
||||
(try
|
||||
(client/request (assoc request
|
||||
:socket-timeout 10000
|
||||
@@ -104,7 +102,6 @@
|
||||
:exception error))
|
||||
[]))))
|
||||
|
||||
|
||||
(def item-cache (atom {}))
|
||||
|
||||
(defn fetch-catalog [client i v]
|
||||
@@ -124,13 +121,11 @@
|
||||
#(do (swap! item-cache assoc i %)
|
||||
%))))
|
||||
|
||||
|
||||
(defn fetch-catalog-cache [client i version]
|
||||
(if (get @item-cache i)
|
||||
(de/success-deferred (get @item-cache i))
|
||||
(fetch-catalog client i version)))
|
||||
|
||||
|
||||
(defn item->category-name-impl [client item version]
|
||||
(capture-context->lc
|
||||
(cond (:item_id (:item_variation_data item))
|
||||
@@ -161,7 +156,6 @@
|
||||
:item item)
|
||||
"Uncategorized"))))
|
||||
|
||||
|
||||
(defn item-id->category-name [client i version]
|
||||
(capture-context->lc
|
||||
(-> [client i]
|
||||
@@ -226,7 +220,6 @@
|
||||
(concat (:orders result) continued-results))))
|
||||
(:orders result)))))))
|
||||
|
||||
|
||||
(defn search
|
||||
([client location start end]
|
||||
(capture-context->lc
|
||||
@@ -250,11 +243,9 @@
|
||||
(concat (:orders result) continued-results))))
|
||||
(:orders result))))))))
|
||||
|
||||
|
||||
(defn amount->money [amt]
|
||||
(* 0.01 (or (:amount amt) 0.0)))
|
||||
|
||||
|
||||
;; to get totals:
|
||||
(comment
|
||||
(reduce
|
||||
@@ -269,6 +260,66 @@
|
||||
0.0
|
||||
[]))
|
||||
|
||||
(defn scoped-key
|
||||
"Client-scoped external id, in the shape sales order keys already use.
|
||||
|
||||
Without the client and location in the key, two clients configured on the same Square location
|
||||
collide on a single entity: a refund changes owner every time either client imports, and one
|
||||
charge ends up shared between both clients' orders."
|
||||
[prefix client location id]
|
||||
(str prefix (:client/code client) "-" (:square-location/client-location location) "-" id))
|
||||
|
||||
(def ^:private owner-attr
|
||||
"Where each Square-imported entity records the client it belongs to."
|
||||
{:charge/external-id :charge/client
|
||||
:sales-refund/external-id :sales-refund/client
|
||||
:expected-deposit/external-id :expected-deposit/client
|
||||
:cash-drawer-shift/external-id :cash-drawer-shift/client})
|
||||
|
||||
(defn- owned-by-other-client?
|
||||
"Whether `e` already belongs to a client other than `client-eid`.
|
||||
|
||||
Reads the entity's own owner attribute, and for a charge falls back to the client of whichever
|
||||
sales order refers to it — charges predating `:charge/client` still have orders, and those are
|
||||
exactly the ones that can be taken by the wrong client."
|
||||
[db attr e client-eid]
|
||||
(let [ent (dc/entity db e)
|
||||
owner (or (:db/id ((owner-attr attr) ent))
|
||||
(when (= attr :charge/external-id)
|
||||
(some->> (first (dc/datoms db :vaet e :sales-order/charges))
|
||||
:e
|
||||
(dc/entity db)
|
||||
:sales-order/client
|
||||
:db/id)))]
|
||||
(and owner (not= owner client-eid))))
|
||||
|
||||
(defn existing-id
|
||||
"Entity id of the refund or charge this id already refers to, trying the client-scoped key
|
||||
first and the legacy unscoped key second.
|
||||
|
||||
This is what makes re-keying safe. These external ids are `:db.unique/identity`, so the import
|
||||
relies on upsert-by-identity; changing the key format on its own would match nothing and
|
||||
Datomic would create a SECOND entity for every refund and charge, orphaning the original under
|
||||
its legacy key. Pinning the result as `:db/id` makes the write land on the existing entity
|
||||
whichever scheme it currently carries.
|
||||
|
||||
The legacy branch will not take a record that already belongs to a different client. Without
|
||||
that check, two clients on one Square location double money during the window between deploying
|
||||
and finishing the migration: client A's payout import resolves B's charge by its bare key and
|
||||
renames it into A's scope, B's next order import then matches neither scheme and mints a second
|
||||
charge, and because `:sales-order/charges` is cardinality-many nothing retracts the first — so
|
||||
B's order carries two charges for one payment. Declining is also the right answer on its merits:
|
||||
the write then lands on this client's own copy, which is what the scoped keys exist to create.
|
||||
|
||||
Once the migration has run there are no legacy keys left for this branch to find, and both it
|
||||
and the guard can be deleted together."
|
||||
[db attr prefix client location id]
|
||||
(when id
|
||||
(or (dc/entid db [attr (scoped-key prefix client location id)])
|
||||
(when-let [legacy (dc/entid db [attr (str prefix id)])]
|
||||
(when-not (owned-by-other-client? db attr legacy (:db/id client))
|
||||
legacy)))))
|
||||
|
||||
(defn tender->charge [order client location t]
|
||||
(remove-nils
|
||||
#:charge
|
||||
@@ -278,9 +329,10 @@
|
||||
:note (:note t)
|
||||
:location (:square-location/client-location location)
|
||||
:reference-link (str (url/url "https://squareup.com/receipt/preview" (:id t)))
|
||||
:db/id (existing-id (dc/db conn) :charge/external-id "square/charge/" client location (:id t))
|
||||
:external-id (when (:id t)
|
||||
(str "square/charge/" (:id t)))
|
||||
:processor (cond
|
||||
(scoped-key "square/charge/" client location (:id t)))
|
||||
:processor (cond
|
||||
(#{"OTHER" "THIRD_PARTY_CARD"} (:type t))
|
||||
(condp = (some-> (:note t) str/lower-case)
|
||||
"doordash" :ccp-processor/doordash
|
||||
@@ -290,15 +342,15 @@
|
||||
"grubhub" :ccp-processor/grubhub
|
||||
"grub" :ccp-processor/grubhub
|
||||
"gh" :ccp-processor/grubhub
|
||||
(condp = (:name (:source order))
|
||||
"GRUBHUB" :ccp-processor/grubhub
|
||||
"UBEREATS" :ccp-processor/uber-eats
|
||||
"Uber Eats" :ccp-processor/uber-eats
|
||||
"DOORDASH" :ccp-processor/doordash
|
||||
"DoorDash" :ccp-processor/doordash
|
||||
"Koala" :ccp-processor/koala
|
||||
"koala-production" :ccp-processor/koala
|
||||
:ccp-processor/na))
|
||||
(let [src (some-> (:name (:source order)) str/lower-case)]
|
||||
(cond
|
||||
(nil? src) :ccp-processor/na
|
||||
(str/includes? src "doordash") :ccp-processor/doordash
|
||||
(str/includes? src "uber") :ccp-processor/uber-eats
|
||||
(str/includes? src "grubhub") :ccp-processor/grubhub
|
||||
(str/includes? src "postmates") :ccp-processor/uber-eats
|
||||
(str/includes? src "koala") :ccp-processor/koala
|
||||
:else :ccp-processor/na)))
|
||||
(= (:type t) "CARD")
|
||||
:ccp-processor/square
|
||||
|
||||
@@ -353,7 +405,7 @@
|
||||
#:sales-order
|
||||
{:date (if (= "Invoices" (:name (:source order)))
|
||||
(when (:closed_at order)
|
||||
(coerce/to-date (time/to-time-zone (coerce/to-date-time (:closed_at order)) (time/time-zone-for-id "America/Los_Angeles"))))
|
||||
(coerce/to-date (time/to-time-zone (coerce/to-date-time (:closed_at order)) (time/time-zone-for-id "America/Los_Angeles"))))
|
||||
(coerce/to-date (time/to-time-zone (coerce/to-date-time (:created_at order)) (time/time-zone-for-id "America/Los_Angeles"))))
|
||||
:client (:db/id client)
|
||||
:location (:square-location/client-location location)
|
||||
@@ -415,7 +467,6 @@
|
||||
:client client
|
||||
:location location)))))))
|
||||
|
||||
|
||||
(defn get-payment [client p]
|
||||
(de/chain (manifold-api-call
|
||||
{:url (str "https://connect.squareup.com/v2/payments/" p)
|
||||
@@ -424,7 +475,6 @@
|
||||
:body
|
||||
:payment))
|
||||
|
||||
|
||||
(defn continue-payout-entry-list [c l poi cursor]
|
||||
(capture-context->lc lc
|
||||
(de/chain
|
||||
@@ -509,7 +559,7 @@
|
||||
(try
|
||||
(->> (for [payout payouts
|
||||
:let [best-sales-date (some->> (dc/q '[:find ?s4 (count ?s)
|
||||
:in $ ?payout-id
|
||||
:in $ [?payout-id ...]
|
||||
:where
|
||||
[?payout :expected-deposit/external-id ?payout-id]
|
||||
[?payout :expected-deposit/charges ?c]
|
||||
@@ -519,7 +569,8 @@
|
||||
[(auto-ap.time/localize ?s2) ?s3]
|
||||
[(clj-time.coerce/to-local-date ?s3) ?s4]]
|
||||
(dc/db conn)
|
||||
(str "square/payout/" (:id payout)))
|
||||
[(scoped-key "square/payout/" client location (:id payout))
|
||||
(str "square/payout/" (:id payout))])
|
||||
(sort-by last)
|
||||
last
|
||||
first
|
||||
@@ -543,7 +594,10 @@
|
||||
(:db/id client)
|
||||
(amount->money (:amount_money payout))))]
|
||||
:when (not equivalent-already-exists?)]
|
||||
#:expected-deposit {:external-id (str "square/payout/" (:id payout))
|
||||
#:expected-deposit {:db/id (or (existing-id (dc/db conn) :expected-deposit/external-id
|
||||
"square/payout/" client location (:id payout))
|
||||
(str "square/payout/" (:id payout)))
|
||||
:external-id (scoped-key "square/payout/" client location (:id payout))
|
||||
:vendor :vendor/ccp-square
|
||||
:status :expected-deposit-status/pending
|
||||
:total (amount->money (:amount_money payout))
|
||||
@@ -561,22 +615,61 @@
|
||||
(coerce/to-date)))
|
||||
:charges (reverse (->> (:payout_entries payout)
|
||||
(filter (comp :payment_id :type_charge_details))
|
||||
(map (fn [p] {:charge/external-id (str "square/charge/" (:payment_id (:type_charge_details p)))}))))})
|
||||
(map (fn [p]
|
||||
(let [payment-id (:payment_id (:type_charge_details p))]
|
||||
(remove-nils
|
||||
{:charge/external-id (scoped-key "square/charge/" client location payment-id)
|
||||
;; the owner attributes must travel with the key: `raw-square-id` and
|
||||
;; `scope-of` both recover a charge's scope from them, and a key
|
||||
;; scoped to one client while the owner says another is what makes
|
||||
;; the migration write square/charge/B-LB-A-LA-<id>
|
||||
:charge/client (:db/id client)
|
||||
:charge/location (:square-location/client-location location)
|
||||
:db/id (existing-id (dc/db conn) :charge/external-id "square/charge/" client location payment-id)}))))))})
|
||||
(filter :expected-deposit/date)
|
||||
(into []))
|
||||
(catch Throwable e
|
||||
(log/error ::transform-payout-failed
|
||||
:exception e)))))))))
|
||||
|
||||
(defn refunds
|
||||
([client l]
|
||||
(de/chain (manifold-api-call {:url (str "https://connect.squareup.com/v2/refunds?location_id=" (:square-location/square-id l))
|
||||
:method :get
|
||||
(defn- refund-list
|
||||
"Every refund Square has for this location in `[start end]`, following the cursor to the end.
|
||||
|
||||
:headers (client-base-headers client)
|
||||
:as :json})
|
||||
The list endpoint returns one page at a time. Reading only the first page — which is what this
|
||||
did before — silently caps a location at a hundred refunds however many it actually has, and
|
||||
the cap is invisible: the response looks like a complete answer. On a shared location that is
|
||||
how one client record ends up holding a few refunds against a hundred and fifty thousand orders.
|
||||
|
||||
`start`/`end` are optional; omitting both asks for everything, which is what the nightly job
|
||||
wants and what a historical backfill of more than a page needs."
|
||||
([client l start end] (refund-list client l start end nil))
|
||||
([client l start end cursor]
|
||||
(de/chain (manifold-api-call
|
||||
{:url (str "https://connect.squareup.com/v2/refunds"
|
||||
"?"
|
||||
(url/map->query
|
||||
(cond-> {:location_id (:square-location/square-id l)
|
||||
:limit 100}
|
||||
start (assoc :begin_time (->square-date start))
|
||||
end (assoc :end_time (->square-date end))
|
||||
cursor (assoc :cursor cursor))))
|
||||
:method :get
|
||||
:headers (client-base-headers client)
|
||||
:as :json})
|
||||
:body
|
||||
:refunds
|
||||
(fn [result]
|
||||
(log/info ::refunds-page
|
||||
:count (count (:refunds result))
|
||||
:more? (boolean (not-empty (:cursor result))))
|
||||
(if (not-empty (:cursor result))
|
||||
(de/chain (refund-list client l start end (:cursor result))
|
||||
(fn [more] (concat (:refunds result) more)))
|
||||
(:refunds result))))))
|
||||
|
||||
(defn refunds
|
||||
([client l] (refunds client l nil nil))
|
||||
([client l start end]
|
||||
(de/chain (refund-list client l start end)
|
||||
(fn [refunds]
|
||||
(->> refunds
|
||||
(filter (fn [r] (= "COMPLETED" (:status r))))
|
||||
@@ -585,7 +678,8 @@
|
||||
(de/chain
|
||||
(get-payment client (:payment_id r))
|
||||
(fn [payment]
|
||||
#:sales-refund {:external-id (str "square/refund/" (:id r))
|
||||
#:sales-refund {:db/id (existing-id (dc/db conn) :sales-refund/external-id "square/refund/" client l (:id r))
|
||||
:external-id (scoped-key "square/refund/" client l (:id r))
|
||||
:vendor :vendor/ccp-square
|
||||
:total (amount->money (:amount_money r))
|
||||
:fee (transduce
|
||||
@@ -618,7 +712,6 @@
|
||||
:count (count x))
|
||||
@(dc/transact-async conn x))))))))
|
||||
|
||||
|
||||
(defn upsert-payouts
|
||||
([client]
|
||||
(apply de/zip
|
||||
@@ -647,11 +740,12 @@
|
||||
(for [square-location (:client/square-locations client)
|
||||
:when (:square-location/client-location square-location)]
|
||||
(upsert-refunds client square-location))))
|
||||
([client location]
|
||||
([client location] (upsert-refunds client location nil nil))
|
||||
([client location start end]
|
||||
(with-context-as {:source "Square refunds loading"
|
||||
:client (:client/code client)} lc
|
||||
|
||||
(de/chain (refunds client location)
|
||||
(de/chain (refunds client location start end)
|
||||
(fn [refunds]
|
||||
(mu/with-context lc
|
||||
(try
|
||||
@@ -667,7 +761,6 @@
|
||||
|
||||
(log/info ::done-loading-refunds)))))))
|
||||
|
||||
|
||||
(defn get-cash-shift [client id]
|
||||
(de/chain (manifold-api-call {:url (str (url/url "https://connect.squareup.com/v2/cash-drawers/shifts" id))
|
||||
:method :get
|
||||
@@ -703,7 +796,10 @@
|
||||
(de/chain
|
||||
(get-cash-shift client (:id s))
|
||||
(fn [cash-drawer-shift]
|
||||
#:cash-drawer-shift {:external-id (str "square/cash-drawer-shift/" (:id cash-drawer-shift))
|
||||
#:cash-drawer-shift {:db/id (or (existing-id (dc/db conn) :cash-drawer-shift/external-id
|
||||
"square/cash-drawer-shift/" client l (:id cash-drawer-shift))
|
||||
(str "square/cash-drawer-shift/" (:id cash-drawer-shift)))
|
||||
:external-id (scoped-key "square/cash-drawer-shift/" client l (:id cash-drawer-shift))
|
||||
:vendor :vendor/ccp-square
|
||||
:paid-in (amount->money (:cash_paid_in_money cash-drawer-shift))
|
||||
:paid-out (amount->money (:cash_paid_out_money cash-drawer-shift))
|
||||
@@ -723,10 +819,12 @@
|
||||
:when (:square-location/client-location square-location)]
|
||||
(upsert-cash-shifts client square-location))))
|
||||
([client location]
|
||||
(upsert-cash-shifts client location (time/plus (time/now) (time/days -75)) (time/now)))
|
||||
([client location start end]
|
||||
(with-context-as {:source "Square cash shift loading"
|
||||
:client (:client/code client)} lc
|
||||
|
||||
(de/chain (cash-drawer-shifts client location)
|
||||
(de/chain (cash-drawer-shifts client location start end)
|
||||
(fn [cash-shifts]
|
||||
(mu/with-context lc
|
||||
(try
|
||||
@@ -826,8 +924,6 @@
|
||||
d1
|
||||
d2))
|
||||
|
||||
|
||||
|
||||
(defn remove-voided-orders
|
||||
([client]
|
||||
(apply de/zip
|
||||
@@ -854,7 +950,7 @@
|
||||
(:sales-order/external-id o))))))
|
||||
(s/map (fn [[o]]
|
||||
[[:db/retractEntity [:sales-order/external-id (:sales-order/external-id o)]]]))
|
||||
|
||||
|
||||
(s/reduce into [])))
|
||||
|
||||
(fn [results]
|
||||
@@ -863,31 +959,26 @@
|
||||
(log/info ::removing-orders
|
||||
:count (count x))
|
||||
@(dc/transact-async conn x)))))
|
||||
(de/catch (fn [e]
|
||||
(log/warn ::couldnt-remove :error e)
|
||||
nil) ))))))
|
||||
(de/catch (fn [e]
|
||||
(log/warn ::couldnt-remove :error e)
|
||||
nil)))))))
|
||||
|
||||
#_(comment
|
||||
(require 'auto-ap.time-reader)
|
||||
#_(comment
|
||||
(require 'auto-ap.time-reader)
|
||||
|
||||
@(let [[c [l]] (get-square-client-and-location "DBFS") ]
|
||||
(log/peek :x [ c l])
|
||||
(search c l #clj-time/date-time "2026-03-28" #clj-time/date-time "2026-03-29")
|
||||
@(let [[c [l]] (get-square-client-and-location "DBFS")]
|
||||
(log/peek :x [c l])
|
||||
(search c l #clj-time/date-time "2026-03-28" #clj-time/date-time "2026-03-29"))
|
||||
|
||||
)
|
||||
@(let [[c [l]] (get-square-client-and-location "NGAK")]
|
||||
(log/peek :x [c l])
|
||||
|
||||
@(let [[c [l]] (get-square-client-and-location "NGAK") ]
|
||||
(log/peek :x [ c l])
|
||||
|
||||
(remove-voided-orders c l #clj-time/date-time "2024-04-11" #clj-time/date-time "2024-04-15"))
|
||||
(doseq [c (get-square-clients)]
|
||||
(try
|
||||
@(remove-voided-orders c)
|
||||
(catch Exception e
|
||||
nil)))
|
||||
|
||||
|
||||
)
|
||||
(remove-voided-orders c l #clj-time/date-time "2024-04-11" #clj-time/date-time "2024-04-15"))
|
||||
(doseq [c (get-square-clients)]
|
||||
(try
|
||||
@(remove-voided-orders c)
|
||||
(catch Exception e
|
||||
nil))))
|
||||
|
||||
(defn upsert-all [& clients]
|
||||
(capture-context->lc
|
||||
@@ -950,14 +1041,53 @@
|
||||
(s/realize-each)
|
||||
(s/reduce conj []))))
|
||||
|
||||
(defn backfill-history
|
||||
"Re-imports orders, payouts, refunds and cash-drawer shifts for `[start end]`, one client at a
|
||||
time, for every square location the client has.
|
||||
|
||||
This exists for the shared-location case. Sales orders have always been keyed by client, so two
|
||||
client records on one Square location each built their own order history. Refunds, payouts and
|
||||
shifts were not, so only ONE of the two records holds each of them — whichever imported it last
|
||||
before the keys were scoped. Re-keying freezes that ownership; it does not even it out, and the
|
||||
record left without them shows returns from its own orders with no refunds to offset them.
|
||||
|
||||
Rather than manufacture copies, this asks Square again. With client-scoped keys in place every
|
||||
record now creates its own copy of what it reads, so replaying the window is what makes the two
|
||||
histories match. Deliberately not part of `upsert-all`: it walks further back than the nightly
|
||||
job and is meant to be run once, after the migration.
|
||||
|
||||
Run it AFTER `rekey-square-external-ids/migrate-all!`. Running it before would import against
|
||||
legacy keys and leave more to migrate."
|
||||
[start end & client-codes]
|
||||
(with-context-as {:source "Square historical backfill"} lc
|
||||
(->> (apply get-square-clients client-codes)
|
||||
(s/->source)
|
||||
(s/map (fn [client]
|
||||
(with-context-as (merge lc {:client (:client/code client)}) lc
|
||||
(->
|
||||
(apply de/zip
|
||||
(for [l (:client/square-locations client)
|
||||
:when (:square-location/client-location l)]
|
||||
(de/chain
|
||||
(upsert client l start end)
|
||||
(fn [_] (upsert-payouts client l start end))
|
||||
(fn [_] (upsert-refunds client l start end))
|
||||
(fn [_] (upsert-cash-shifts client l start end))
|
||||
(fn [_] (log/info ::backfilled
|
||||
:location (:square-location/client-location l))))))
|
||||
(de/catch (fn [e]
|
||||
(mu/with-context lc
|
||||
(log/info ::backfill-failed :severity :error :exception e))))))))
|
||||
(s/buffer 3)
|
||||
(s/realize-each)
|
||||
(s/reduce conj []))))
|
||||
|
||||
(defn do-upsert-all [& clients]
|
||||
(mu/trace
|
||||
::upsert-all
|
||||
[:clients clients]
|
||||
@(apply upsert-all clients)))
|
||||
|
||||
|
||||
|
||||
(comment
|
||||
(defn refunds-raw-cont
|
||||
([client l cursor so-far]
|
||||
@@ -987,9 +1117,8 @@
|
||||
(->>
|
||||
@(let [[c [l]] (get-square-client-and-location "NGGG")]
|
||||
|
||||
|
||||
(search c l (time/now) (time/plus (time/now) (time/days -1))))
|
||||
|
||||
|
||||
(filter (fn [r]
|
||||
(str/starts-with? (:created_at r) "2024-03-14"))))
|
||||
|
||||
@@ -997,7 +1126,6 @@
|
||||
(->>
|
||||
@(let [[c [l]] (get-square-client-and-location "NGGG")]
|
||||
|
||||
|
||||
(refunds-raw-cont c l nil []))
|
||||
(filter (fn [r]
|
||||
(str/starts-with? (:created_at r) "2024-03-14")))))
|
||||
@@ -1031,13 +1159,8 @@
|
||||
[]))]
|
||||
[(:client/code c) (atime/unparse-local (clj-time.coerce/to-date-time (:sales-order/date bad-row)) atime/normal-date) (:sales-order/total bad-row) (:sales-order/tax bad-row) (:sales-order/tip bad-row) (:db/id bad-row)])
|
||||
:separator \tab)
|
||||
|
||||
|
||||
|
||||
|
||||
;; =>
|
||||
|
||||
|
||||
;; =>
|
||||
|
||||
(require 'auto-ap.time-reader)
|
||||
|
||||
@@ -1046,27 +1169,16 @@
|
||||
(clojure.pprint/pprint (let [[c [l]] (get-square-client-and-location "NGVT")]
|
||||
l
|
||||
|
||||
|
||||
(def z @(search c l #clj-time/date-time "2025-02-23T00:00:00-08:00"
|
||||
#clj-time/date-time "2025-02-28T00:00:00-08:00"))
|
||||
(take 10 (map #(first (deref (order->sales-order c l %))) z)))
|
||||
(take 10 (map #(first (deref (order->sales-order c l %))) z))))
|
||||
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
(->> z
|
||||
(->> z
|
||||
(filter (fn [o]
|
||||
(seq (filter (comp #{"OTHER"} :type) (:tenders o)))))
|
||||
(filter #(not (:name (:source %))))
|
||||
(count)
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
(count))
|
||||
|
||||
(doseq [[code] (seq (dc/q '[:find ?code
|
||||
:in $
|
||||
:where [?o :sales-order/date ?d]
|
||||
@@ -1075,32 +1187,22 @@
|
||||
[?o :sales-order/client ?c]
|
||||
[?c :client/code ?code]]
|
||||
(dc/db conn)))
|
||||
:let [[c [l]] (get-square-client-and-location code)
|
||||
]
|
||||
:let [[c [l]] (get-square-client-and-location code)]
|
||||
order @(search c l #clj-time/date-time "2026-01-01T00:00:00-08:00" (time/now))
|
||||
:when (= "Invoices" (:name (:source order) ))
|
||||
:when (= "Invoices" (:name (:source order)))
|
||||
:let [[sales-order] @(order->sales-order c l order)]]
|
||||
|
||||
|
||||
(when (should-import-order? order)
|
||||
(println "DATE IS" (:sales-order/date sales-order))
|
||||
(when (some-> (:sales-order/date sales-order) coerce/to-date-time (time/after? #clj-time/date-time "2026-2-16T00:00:00-08:00"))
|
||||
(println "WOULD UPDATE" sales-order)
|
||||
@(dc/transact auto-ap.datomic/conn [sales-order])
|
||||
)
|
||||
#_@(dc/transact )
|
||||
(println "DONE"))
|
||||
|
||||
|
||||
)
|
||||
@(dc/transact auto-ap.datomic/conn [sales-order]))
|
||||
#_@(dc/transact)
|
||||
(println "DONE")))
|
||||
|
||||
#_(filter (comp #{"OTHER"} :type) (mapcat :tenders z))
|
||||
|
||||
|
||||
@(let [[c [l]] (get-square-client-and-location "NGRY")]
|
||||
#_(search c l (clj-time.coerce/from-date #inst "2025-02-28") (clj-time.coerce/from-date #inst "2025-03-01"))
|
||||
|
||||
(order->sales-order c l (:order (get-order c l "KdvwntmfMNTKBu8NOocbxatOs18YY" )))
|
||||
|
||||
)
|
||||
|
||||
)
|
||||
(order->sales-order c l (:order (get-order c l "KdvwntmfMNTKBu8NOocbxatOs18YY")))))
|
||||
|
||||
@@ -28,14 +28,13 @@
|
||||
(com.amazonaws.services.ecs.model AssignPublicIp)))
|
||||
|
||||
(defn get-ecs-tasks []
|
||||
(->>
|
||||
(concat (:task-arns (ecs/list-tasks :max-results 50)) (:task-arns (ecs/list-tasks :desired-status "STOPPED" :max-results 50)))
|
||||
(ecs/describe-tasks :include [] :tasks)
|
||||
:tasks
|
||||
(map #(assoc % :task-definition (:task-definition (ecs/describe-task-definition :task-definition (:task-definition-arn %)))))
|
||||
(sort-by :created-at)
|
||||
reverse))
|
||||
|
||||
(->>
|
||||
(concat (:task-arns (ecs/list-tasks :max-results 50)) (:task-arns (ecs/list-tasks :desired-status "STOPPED" :max-results 50)))
|
||||
(ecs/describe-tasks :include [] :tasks)
|
||||
:tasks
|
||||
(map #(assoc % :task-definition (:task-definition (ecs/describe-task-definition :task-definition (:task-definition-arn %)))))
|
||||
(sort-by :created-at)
|
||||
reverse))
|
||||
|
||||
(defn is-background-job?
|
||||
"This function checks whether a given task is a background job.
|
||||
@@ -60,7 +59,7 @@
|
||||
(defn job-exited-successfully? [task]
|
||||
(if (= 0 (->> task
|
||||
:containers
|
||||
(filter (comp #{"integreat-app" } :name))
|
||||
(filter (comp #{"integreat-app"} :name))
|
||||
(first)
|
||||
:exit-code))
|
||||
true
|
||||
@@ -77,7 +76,7 @@
|
||||
:succeeded
|
||||
:failed))
|
||||
:name (task-definition->job-name (:task-definition task))
|
||||
:end-date (some-> (:stopped-at task) coerce/to-date-time (time/to-time-zone (time/time-zone-for-offset 0)))
|
||||
:end-date (some-> (:stopped-at task) coerce/to-date-time (time/to-time-zone (time/time-zone-for-offset 0)))
|
||||
:start-date (some-> (:created-at task) coerce/to-date-time (time/to-time-zone (time/time-zone-for-offset 0)))})
|
||||
|
||||
(defn fetch-page [request]
|
||||
@@ -85,7 +84,7 @@
|
||||
(filter is-background-job?)
|
||||
(map ecs-task->job))]
|
||||
[jobs (count jobs)]))
|
||||
(def query-schema (mc/schema [:map ]))
|
||||
(def query-schema (mc/schema [:map]))
|
||||
|
||||
(def grid-page
|
||||
(helper/build {:id "job-table"
|
||||
@@ -107,8 +106,7 @@
|
||||
:entity-name "Job"
|
||||
:query-schema query-schema
|
||||
:route :admin-job-table
|
||||
:headers [
|
||||
{:key "start"
|
||||
:headers [{:key "start"
|
||||
:name "Start"
|
||||
:render #(some-> % :start-date (atime/unparse-local atime/standard-time))}
|
||||
{:key "end"
|
||||
@@ -119,7 +117,7 @@
|
||||
:render (fn [e]
|
||||
(when (and (:start-date e)
|
||||
(:end-date e))
|
||||
(str (time/in-minutes (time/interval
|
||||
(str (time/in-minutes (time/interval
|
||||
(:start-date e)
|
||||
(:end-date e))) " minutes")))}
|
||||
{:key "name"
|
||||
@@ -150,16 +148,16 @@
|
||||
:network-configuration {:aws-vpc-configuration {:subnets ["subnet-5e675761" "subnet-8519fde2" "subnet-89bab8d4"]
|
||||
:security-groups ["sg-004e5855310c453a3" "sg-02d167406b1082698"]
|
||||
:assign-public-ip AssignPublicIp/ENABLED}}}
|
||||
args (assoc-in [:overrides :container-overrides ] [{:name "integreat-app" :environment [{:name "args" :value (pr-str args)}]}]))))
|
||||
args (assoc-in [:overrides :container-overrides] [{:name "integreat-app" :environment [{:name "args" :value (pr-str args)}]}]))))
|
||||
|
||||
(defn job-start [{:keys [form-params]}]
|
||||
(if (not (get (currently-running-jobs) (:name form-params)))
|
||||
(let [new-job (run-task
|
||||
(-> (:name form-params)
|
||||
(str/replace #"-" "_")
|
||||
(str/replace #":" "")
|
||||
(str "_" (:dd-env env)))
|
||||
(dissoc form-params :name))]
|
||||
(-> (:name form-params)
|
||||
(str/replace #"-" "_")
|
||||
(str/replace #":" "")
|
||||
(str "_" (:dd-env env)))
|
||||
(dissoc form-params :name))]
|
||||
{:message (str "task " (str new-job) " started.")})
|
||||
(form-validation-error "This job is already running"
|
||||
:form-params form-params)))
|
||||
@@ -170,107 +168,109 @@
|
||||
[(fc/with-field :ledger-url
|
||||
(com/validated-field {:label "Url"
|
||||
:errors (fc/field-errors)}
|
||||
[:div.flex.place-items-center.gap-2
|
||||
[:pre.text-xs.mr-1 "s3://data.prod.app.integreatconsult.com/bulk-import/"]
|
||||
(com/text-input {:placeholder "ledger-data.csv"
|
||||
:name (fc/field-name)
|
||||
:value (fc/field-value)} )]))]
|
||||
[:div.flex.place-items-center.gap-2
|
||||
[:pre.text-xs.mr-1 "s3://data.prod.app.integreatconsult.com/bulk-import/"]
|
||||
(com/text-input {:placeholder "ledger-data.csv"
|
||||
:name (fc/field-name)
|
||||
:value (fc/field-value)})]))]
|
||||
(= "register-invoice-import" name)
|
||||
[
|
||||
(fc/with-field :invoice-url
|
||||
(com/validated-field {:label "Url"
|
||||
:errors (fc/field-errors)}
|
||||
[:div.flex.place-items-center.gap-2
|
||||
[:pre.text-xs.mr-1 "s3://data.prod.app.integreatconsult.com/bulk-import/"]
|
||||
(com/text-input {:placeholder "invoice-data.csv"
|
||||
:name (fc/field-name)
|
||||
:value (fc/field-value)} )]))]
|
||||
[(fc/with-field :invoice-url
|
||||
(com/validated-field {:label "Url"
|
||||
:errors (fc/field-errors)}
|
||||
[:div.flex.place-items-center.gap-2
|
||||
[:pre.text-xs.mr-1 "s3://data.prod.app.integreatconsult.com/bulk-import/"]
|
||||
(com/text-input {:placeholder "invoice-data.csv"
|
||||
:name (fc/field-name)
|
||||
:value (fc/field-value)})]))]
|
||||
(= "load-historical-sales" name)
|
||||
[
|
||||
(fc/with-field :client
|
||||
(com/validated-field {:label "Client"
|
||||
:errors (fc/field-errors)}
|
||||
(com/typeahead {:name (fc/field-name)
|
||||
:value (fc/field-value)
|
||||
:placeholder "Search..."
|
||||
:url (bidi/path-for ssr-routes/only-routes
|
||||
:company-search)})))
|
||||
(fc/with-field :days
|
||||
[(fc/with-field :client
|
||||
(com/validated-field {:label "Client"
|
||||
:errors (fc/field-errors)}
|
||||
(com/typeahead {:name (fc/field-name)
|
||||
:value (fc/field-value)
|
||||
:placeholder "Search..."
|
||||
:url (bidi/path-for ssr-routes/only-routes
|
||||
:company-search)})))
|
||||
(fc/with-field :days
|
||||
(com/validated-field {:label "Days to load"
|
||||
:errors (fc/field-errors)}
|
||||
(com/text-input {:placeholder "60"
|
||||
:name (fc/field-name)
|
||||
:value (fc/field-value)} )))]
|
||||
:else nil))
|
||||
:value (fc/field-value)})))]
|
||||
(= "sales-summaries" name)
|
||||
[(fc/with-field :days
|
||||
(com/validated-field {:label "Days to refresh"
|
||||
:errors (fc/field-errors)}
|
||||
(com/text-input {:placeholder "7"
|
||||
:name (fc/field-name)
|
||||
:value (fc/field-value)})))]
|
||||
:else nil)))
|
||||
|
||||
|
||||
)
|
||||
|
||||
(defn subform [{{:keys [name]} :query-params }]
|
||||
(defn subform [{{:keys [name]} :query-params}]
|
||||
(html-response
|
||||
(fc/start-form {} nil
|
||||
(subform* {:name name}))))
|
||||
(fc/start-form {} nil
|
||||
(subform* {:name name}))))
|
||||
|
||||
(defn job-start-dialog [{:keys [form-errors form-params] :as request}]
|
||||
(fc/start-form (or form-params {}) form-errors
|
||||
(modal-response
|
||||
(com/modal ;; TODO we need a cleaner way to have forms that wrap the whole. In this cas
|
||||
{}
|
||||
[:form {:hx-post (bidi/path-for ssr-routes/only-routes :admin-job-start)
|
||||
:class "h-full w-full"}
|
||||
[:fieldset {:class "hx-disable h-full w-full"}
|
||||
(com/modal-card {}
|
||||
[:div.m-2 "New job"]
|
||||
[:div.space-y-6
|
||||
(modal-response
|
||||
(com/modal ;; TODO we need a cleaner way to have forms that wrap the whole. In this cas
|
||||
{}
|
||||
[:form {:hx-post (bidi/path-for ssr-routes/only-routes :admin-job-start)
|
||||
:class "h-full w-full"}
|
||||
[:fieldset {:class "hx-disable h-full w-full"}
|
||||
(com/modal-card {}
|
||||
[:div.m-2 "New job"]
|
||||
[:div.space-y-6
|
||||
|
||||
(fc/with-field :name
|
||||
(com/validated-field {:label "Job"
|
||||
:errors (fc/field-errors)}
|
||||
(com/select {:name (fc/field-name)
|
||||
:value (fc/field-value)
|
||||
:class "w-64"
|
||||
:options [["" ""]
|
||||
["yodlee2" "Yodlee Import"]
|
||||
["yodlee2-accounts" "Yodlee Account Import"]
|
||||
["intuit" "Intuit import"]
|
||||
["plaid" "Plaid import"]
|
||||
["bulk-journal-import" "Bulk Journal Import"]
|
||||
["square-import-job" "Square Import"]
|
||||
["register-invoice-import" "Register Invoice Import "]
|
||||
["ezcater-upsert" "Upsert recent ezcater orders"]
|
||||
["load-historical-sales" "Load Historical Square Sales"]
|
||||
["export-backup" "Export Backup"]]
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes
|
||||
:admin-job-subform)
|
||||
:hx-target "#sub-form"
|
||||
:hx-swap "innerHTML"})))
|
||||
(fc/with-field :name
|
||||
(com/validated-field {:label "Job"
|
||||
:errors (fc/field-errors)}
|
||||
(com/select {:name (fc/field-name)
|
||||
:value (fc/field-value)
|
||||
:class "w-64"
|
||||
:options [["" ""]
|
||||
["yodlee2" "Yodlee Import"]
|
||||
["yodlee2-accounts" "Yodlee Account Import"]
|
||||
["intuit" "Intuit import"]
|
||||
["plaid" "Plaid import"]
|
||||
["bulk-journal-import" "Bulk Journal Import"]
|
||||
["square-import-job" "Square Import"]
|
||||
["register-invoice-import" "Register Invoice Import "]
|
||||
["ezcater-upsert" "Upsert recent ezcater orders"]
|
||||
["load-historical-sales" "Load Historical Square Sales"]
|
||||
["sales-summaries" "Refresh Sales Summaries"]
|
||||
["export-backup" "Export Backup"]]
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes
|
||||
:admin-job-subform)
|
||||
:hx-target "#sub-form"
|
||||
:hx-swap "innerHTML"})))
|
||||
|
||||
[:div#sub-form (subform* {:name (fc/with-field :name (fc/field-value))}) ]]
|
||||
[:div
|
||||
|
||||
(com/form-errors {:errors (:errors fc/*form-errors*)})
|
||||
(com/validated-save-button {:errors form-errors} "Run job")])]]))))
|
||||
[:div#sub-form (subform* {:name (fc/with-field :name (fc/field-value))})]]
|
||||
[:div
|
||||
|
||||
(com/form-errors {:errors (:errors fc/*form-errors*)})
|
||||
(com/validated-save-button {:errors form-errors} "Run job")])]]))))
|
||||
|
||||
(def form-schema (mc/schema [:map
|
||||
[:name [:string {:min 1}]]
|
||||
[:ledger-url {:optional true} [:string {:min 1}]]
|
||||
[:invoice-url {:optional true} [:string {:min 1}]]
|
||||
[:client {:optional true} entity-id]
|
||||
[:days {:optional true} [:int {:min 1 :max 120}]]
|
||||
]))
|
||||
[:days {:optional true} [:int {:min 1 :max 120}]]]))
|
||||
|
||||
(def key->handler
|
||||
(apply-middleware-to-all-handlers
|
||||
(->>
|
||||
{:admin-jobs (helper/page-route grid-page)
|
||||
:admin-job-table (helper/table-route grid-page)
|
||||
:admin-job-subform (-> subform (wrap-schema-enforce :query-schema [:map [:name {:optional true} [:maybe :string]]]))
|
||||
:admin-job-start (-> job-start
|
||||
(wrap-schema-enforce :form-schema form-schema)
|
||||
(wrap-nested-form-params)
|
||||
(wrap-form-4xx-2 job-start-dialog))
|
||||
:admin-job-start-dialog job-start-dialog})
|
||||
(fn [h]
|
||||
(-> h
|
||||
(wrap-admin)
|
||||
(wrap-client-redirect-unauthenticated)))))
|
||||
(apply-middleware-to-all-handlers
|
||||
(->>
|
||||
{:admin-jobs (helper/page-route grid-page)
|
||||
:admin-job-table (helper/table-route grid-page)
|
||||
:admin-job-subform (-> subform (wrap-schema-enforce :query-schema [:map [:name {:optional true} [:maybe :string]]]))
|
||||
:admin-job-start (-> job-start
|
||||
(wrap-schema-enforce :form-schema form-schema)
|
||||
(wrap-nested-form-params)
|
||||
(wrap-form-4xx-2 job-start-dialog))
|
||||
:admin-job-start-dialog job-start-dialog})
|
||||
(fn [h]
|
||||
(-> h
|
||||
(wrap-admin)
|
||||
(wrap-client-redirect-unauthenticated)))))
|
||||
|
||||
@@ -1,560 +0,0 @@
|
||||
(ns auto-ap.ssr.admin.sales-summaries
|
||||
(:require
|
||||
[auto-ap.datomic
|
||||
:refer [apply-pagination apply-sort-3 conn merge-query pull-many
|
||||
query2]]
|
||||
[auto-ap.datomic.accounts :as d-accounts]
|
||||
[auto-ap.graphql.utils :refer [extract-client-ids]]
|
||||
[auto-ap.query-params :refer [wrap-copy-qp-pqp]]
|
||||
[auto-ap.routes.admin.sales-summaries :as route]
|
||||
[auto-ap.routes.utils
|
||||
:refer [wrap-admin wrap-client-redirect-unauthenticated]]
|
||||
[auto-ap.ssr-routes :as ssr-routes]
|
||||
[auto-ap.ssr.common-handlers :refer [add-new-entity-handler]]
|
||||
[auto-ap.ssr.components :as com]
|
||||
[auto-ap.ssr.components.multi-modal :as mm]
|
||||
[auto-ap.ssr.form-cursor :as fc]
|
||||
[auto-ap.ssr.grid-page-helper :as helper :refer [wrap-apply-sort]]
|
||||
[auto-ap.ssr.hx :as hx]
|
||||
[auto-ap.ssr.svg :as svg]
|
||||
[auto-ap.ssr.utils
|
||||
:refer [apply-middleware-to-all-handlers clj-date-schema
|
||||
default-grid-fields-schema entity-id html-response money
|
||||
strip temp-id wrap-merge-prior-hx wrap-schema-enforce]]
|
||||
[auto-ap.time :as atime]
|
||||
[bidi.bidi :as bidi]
|
||||
[clj-time.coerce :as c]
|
||||
[clojure.string :as str]
|
||||
[datomic.api :as dc]
|
||||
[hiccup.util :as hu]
|
||||
[iol-ion.query :refer [dollars=]]
|
||||
[malli.core :as mc]
|
||||
[malli.util :as mut]))
|
||||
|
||||
(def query-schema (mc/schema
|
||||
[:maybe
|
||||
(into [:map {:date-range [:date-range :start-date :end-date]}
|
||||
|
||||
[:start-date {:optional true}
|
||||
[:maybe clj-date-schema]]
|
||||
[:end-date {:optional true}
|
||||
[:maybe clj-date-schema]] ]
|
||||
default-grid-fields-schema)]))
|
||||
|
||||
(defn filters [request]
|
||||
[:form {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||
"hx-get" (bidi/path-for ssr-routes/only-routes
|
||||
::route/table)
|
||||
"hx-target" "#entity-table"
|
||||
"hx-indicator" "#entity-table"}
|
||||
|
||||
#_[:fieldset.space-y-6
|
||||
(date-range-field {:value {:start (:start-date (:query-params request))
|
||||
:end (:end-date (:query-params request))}
|
||||
:id "date-range"})
|
||||
(com/field {:label "Source"}
|
||||
(com/select {:name "source"
|
||||
:class "hot-filter w-full"
|
||||
:value (:source (:query-params request))
|
||||
:placeholder ""
|
||||
:options (ref->select-options "import-source" :allow-nil? true)}))
|
||||
|
||||
#_(com/field {:label "Code"}
|
||||
(com/text-input {:name "code"
|
||||
:id "code"
|
||||
:class "hot-filter"
|
||||
:value (:code (:query-params request))
|
||||
:placeholder "11101"
|
||||
:size :small}))]])
|
||||
|
||||
(def default-read '[:db/id
|
||||
*
|
||||
[:sales-summary/date :xform clj-time.coerce/from-date]
|
||||
{:sales-summary/client [:client/code :client/name :db/id]}
|
||||
{:sales-summary/items [{[:ledger-mapped/ledger-side :xform iol-ion.query/ident] [:db/ident]
|
||||
} ;; TODO clientize
|
||||
:ledger-mapped/account
|
||||
:ledger-mapped/amount
|
||||
:sales-summary-item/category
|
||||
:sales-summary-item/sort-order
|
||||
:db/id
|
||||
:sales-summary-item/manual?]
|
||||
} ]) ;; TODO
|
||||
|
||||
(defn fetch-ids [db request]
|
||||
(let [query-params (:query-params request)
|
||||
valid-clients (extract-client-ids (:clients request)
|
||||
(:client request)
|
||||
(:client-id query-params)
|
||||
(when (:client-code query-params)
|
||||
[:client/code (:client-code query-params)]))
|
||||
query (cond-> {:query {:find []
|
||||
:in '[$ [?client ...]]
|
||||
:where '[[?e :sales-summary/client ?client]]}
|
||||
:args [db valid-clients]}
|
||||
(or (:start-date query-params)
|
||||
(:end-date query-params))
|
||||
(merge-query {:query '{:where [[?e :sales-summary/date ?d]]}})
|
||||
|
||||
(:start-date query-params)
|
||||
(merge-query {:query '{:in [?start-date]
|
||||
:where [[(>= ?d ?start-date)]]}
|
||||
:args [(-> query-params :start-date c/to-date)]})
|
||||
|
||||
(:end-date query-params)
|
||||
(merge-query {:query '{:in [?end-date]
|
||||
:where [[(< ?d ?end-date)]]}
|
||||
:args [(-> query-params :end-date c/to-date)]})
|
||||
|
||||
|
||||
true
|
||||
(merge-query {:query {:find ['?sort-default '?e]
|
||||
:where ['[?e :sales-summary/date ?sort-default]]}}))]
|
||||
(cond->> (query2 query)
|
||||
true (apply-sort-3 query-params)
|
||||
true (apply-pagination query-params))))
|
||||
|
||||
(defn hydrate-results [ids db _]
|
||||
(let [results (->> (pull-many db default-read ids)
|
||||
(group-by :db/id))
|
||||
refunds (->> ids
|
||||
(map results)
|
||||
(map first))]
|
||||
refunds))
|
||||
|
||||
(defn fetch-page [request]
|
||||
(let [db (dc/db conn)
|
||||
{ids-to-retrieve :ids matching-count :count} (fetch-ids db request)]
|
||||
|
||||
[(->> (hydrate-results ids-to-retrieve db request))
|
||||
matching-count]))
|
||||
|
||||
#_(defn get-debits [ss]
|
||||
{:card-payments (+ (:sales-summary/total-card-payments ss 0.0)
|
||||
(:sales-summary/total-card-fees ss 0.0)
|
||||
(- (:sales-summary/total-card-refunds ss 0.0)))
|
||||
:food-app-payments (+ (:sales-summary/total-food-app-payments ss 0.0)
|
||||
(:sales-summary/total-food-app-fees ss 0.0)
|
||||
(- (:sales-summary/total-food-app-refunds ss 0.0)))
|
||||
:gift-card-payments (+ (:sales-summary/total-gift-card-payments ss 0.0)
|
||||
(:sales-summary/total-gift-card-fees ss 0.0)
|
||||
(- (:sales-summary/total-gift-card-refunds ss 0.0)))
|
||||
#_#_:refunds (+ (:sales-summary/total-food-app-refunds ss 0.0)
|
||||
(:sales-summary/total-card-refunds ss 0.0)
|
||||
(:sales-summary/total-cash-refunds ss 0.0))
|
||||
|
||||
:fees (- (:sales-summary/total-card-fees ss 0.0))
|
||||
:cash-payments (+ (:sales-summary/total-cash-payments ss 0.0)
|
||||
(- (:sales-summary/total-cash-refunds ss 0.0)))
|
||||
:total-unknown-processor-payments (:sales-summary/total-unknown-processor-payments ss 0.0)
|
||||
:discounts (+ (:sales-summary/discount ss 0.0))
|
||||
:returns (+ (:sales-summary/total-returns ss 0.0))})
|
||||
(defn sort-items [ss]
|
||||
(sort-by (juxt :ledger-mapped/ledger-side :sales-summary-item/sort-order :sales-summary-item/category) ss))
|
||||
|
||||
|
||||
|
||||
(defn total-debits [items]
|
||||
(->> items
|
||||
(filter #(= :ledger-side/debit (:ledger-mapped/ledger-side %)))
|
||||
(map #(:ledger-mapped/amount % 0.0))
|
||||
(reduce + 0.0)))
|
||||
|
||||
(defn total-credits [items]
|
||||
(->> items
|
||||
(filter #(= :ledger-side/credit (:ledger-mapped/ledger-side %)))
|
||||
(map #(:ledger-mapped/amount % 0.0))
|
||||
(reduce + 0.0)))
|
||||
|
||||
(def grid-page
|
||||
(helper/build {:id "entity-table"
|
||||
:id-fn :db/id
|
||||
:nav com/admin-aside-nav
|
||||
:fetch-page fetch-page
|
||||
:page-specific-nav filters
|
||||
:query-schema query-schema
|
||||
:row-buttons (fn [_ entity]
|
||||
[(com/icon-button {:hx-get (bidi/path-for ssr-routes/only-routes
|
||||
::route/edit-wizard
|
||||
:db/id (:db/id entity))}
|
||||
svg/pencil)])
|
||||
:oob-render
|
||||
(fn [request]
|
||||
[#_(assoc-in (date-range-field {:value {:start (:start-date (:query-params request))
|
||||
:end (:end-date (:query-params request))}
|
||||
:id "date-range"}) [1 :hx-swap-oob] true)]) ;; TODO
|
||||
:breadcrumbs [[:a {:href (bidi/path-for ssr-routes/only-routes
|
||||
:admin)}
|
||||
"Admin"]
|
||||
|
||||
[:a {:href (bidi/path-for ssr-routes/only-routes
|
||||
::route/page)}
|
||||
"Sales Summaries"]]
|
||||
:title "Sales Summaries"
|
||||
:entity-name "Daily Summary"
|
||||
:route ::route/table
|
||||
:headers [{:key "client"
|
||||
:name "Client"
|
||||
:sort-key "client"
|
||||
:hide? (fn [args]
|
||||
(= (count (:clients args)) 1))
|
||||
:render #(-> % :sales-summary/client :client/code)}
|
||||
|
||||
|
||||
{:key "date"
|
||||
:name "Date"
|
||||
:sort-key "date"
|
||||
:render #(some-> % :sales-summary/date (atime/unparse-local atime/normal-date))}
|
||||
|
||||
|
||||
{:key "debits"
|
||||
:name "debits"
|
||||
:sort-key "debits"
|
||||
:render (fn [ss]
|
||||
(let [total-debits (total-debits (:sales-summary/items ss))
|
||||
total-credits (total-credits (:sales-summary/items ss))]
|
||||
[:ul
|
||||
(for [si (sort-items (:sales-summary/items ss))
|
||||
:when (= :ledger-side/debit (:ledger-mapped/ledger-side si))]
|
||||
[:li (:sales-summary-item/category si) ": " (format "$%,.2f" (:ledger-mapped/amount si))
|
||||
(when-not (:ledger-mapped/account si)
|
||||
[:span.pl-4 (com/pill {:color :red}
|
||||
"missing account")])]
|
||||
)
|
||||
[:li (com/pill {:color (if (dollars= total-debits total-credits)
|
||||
:primary
|
||||
:red)} "Total: " (format "$%,.2f" total-debits))]]))}
|
||||
{:key "credits"
|
||||
:name "credits"
|
||||
:sort-key "credits"
|
||||
:render (fn [ss]
|
||||
(let [total-debits (total-debits (:sales-summary/items ss))
|
||||
total-credits (total-credits (:sales-summary/items ss))]
|
||||
[:ul
|
||||
(for [si (sort-items (:sales-summary/items ss))
|
||||
:when (= :ledger-side/credit (:ledger-mapped/ledger-side si))]
|
||||
[:li (:sales-summary-item/category si) ": " (format "$%,.2f" (:ledger-mapped/amount si))
|
||||
(when-not (:ledger-mapped/account si)
|
||||
[:span.pl-4 (com/pill {:color :red}
|
||||
"missing account")])])
|
||||
[:li (com/pill {:color (if (dollars= total-debits total-credits)
|
||||
:primary
|
||||
:red)} "Total: " (format "$%,.2f" total-credits))]]))}]}))
|
||||
|
||||
;; TODO schema cleanup
|
||||
;; Decide on what should be calculated as generating ledger entries, and what should be calculated
|
||||
;; as part of the summary
|
||||
;; default thought here is that the summary has more detail (e.g., line items), fees broken out by type
|
||||
;; and aggregated into the final ledger entry
|
||||
;; that allows customization at any level.
|
||||
;; TODO rename refunds/returns
|
||||
|
||||
(def row* (partial helper/row* grid-page))
|
||||
(def table* (partial helper/table* grid-page))
|
||||
|
||||
(def edit-schema
|
||||
[:map
|
||||
[:db/id entity-id]
|
||||
[:sales-summary/client [:map [:db/id entity-id]]]
|
||||
[:sales-summary/items
|
||||
[:vector {:coerce? true}
|
||||
[:and
|
||||
[:map
|
||||
[:db/id [:or entity-id temp-id]]
|
||||
[:sales-summary-item/category [:string {:decode/string strip}]]
|
||||
[:sales-summary-item/manual? {:default false :decode/arbitrary (fn [x] (cond
|
||||
(boolean? x)
|
||||
x
|
||||
(nil? x)
|
||||
false
|
||||
(str/blank? x)
|
||||
false
|
||||
:else
|
||||
true))} :boolean]
|
||||
[:ledger-mapped/account entity-id]
|
||||
[:credit {:optional true} [:maybe money]]
|
||||
[:debit {:optional true} [:maybe money]]]
|
||||
[:fn {:error/message "Must choose one of credit/debit"
|
||||
:error/path [:credit]}
|
||||
(fn [x]
|
||||
(not (and (:credit x)
|
||||
(:debit x))))]]]] ])
|
||||
|
||||
|
||||
(defn summary-total-row* [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))]
|
||||
|
||||
(com/data-grid-row {:id "total-row"
|
||||
:hx-trigger "change from:closest form target:.amount-field"
|
||||
:hx-put (bidi.bidi/path-for ssr-routes/only-routes ::route/expense-account-total)
|
||||
:hx-target "this"
|
||||
:hx-swap "innerHTML"}
|
||||
(com/data-grid-cell {})
|
||||
(com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "TOTAL"])
|
||||
(com/data-grid-cell {:class "text-right"}
|
||||
(format "$%,.2f" total-debits))
|
||||
(com/data-grid-cell {:class "text-right"}
|
||||
(format "$%,.2f" total-credits)))))
|
||||
|
||||
(defn unbalanced-row* [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))]
|
||||
|
||||
(com/data-grid-row {:id "total-row"
|
||||
:hx-trigger "change from:closest form target:.amount-field"
|
||||
:hx-put (bidi.bidi/path-for ssr-routes/only-routes ::route/expense-account-total)
|
||||
:hx-target "this"
|
||||
:hx-swap "innerHTML"}
|
||||
(com/data-grid-cell {})
|
||||
(com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "UNBALANCED"])
|
||||
(com/data-grid-cell {:class "text-right"}
|
||||
(when (and
|
||||
(not (dollars= total-credits total-debits))
|
||||
(> total-debits total-credits))
|
||||
(format "$%,.2f" (- total-debits total-credits))))
|
||||
(com/data-grid-cell {:class "text-right"}
|
||||
(when
|
||||
(and (not (dollars= total-credits total-debits))
|
||||
(> total-credits total-debits))
|
||||
(format "$%,.2f" (- total-credits total-debits)))))))
|
||||
|
||||
(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)))})])
|
||||
|
||||
(defn sales-summary-item-row* [{:keys [value client-id]}]
|
||||
(let [manual? (fc/field-value (:sales-summary-item/manual? value))]
|
||||
(com/data-grid-row (cond-> {:x-ref "p"
|
||||
:x-data (hx/json {})}
|
||||
(fc/field-value (:new? value)) (hx/htmx-transition-appear ))
|
||||
(fc/with-field :db/id
|
||||
(com/hidden {:name (fc/field-name)
|
||||
:value (fc/field-value)}))
|
||||
(when manual?
|
||||
(fc/with-field :sales-summary-item/manual?
|
||||
(com/hidden {:name (fc/field-name)
|
||||
:value true})))
|
||||
(com/data-grid-cell {}
|
||||
(fc/with-field :sales-summary-item/category
|
||||
(if manual?
|
||||
(com/validated-field {:errors (fc/field-errors)}
|
||||
(com/text-input {:placeholder "Category/Explanation"
|
||||
:name (fc/field-name)
|
||||
:value (fc/field-value)}))
|
||||
|
||||
(list
|
||||
(com/hidden {:name (fc/field-name)
|
||||
:value (fc/field-value)})
|
||||
(fc/field-value (:sales-summary-item/category value))))))
|
||||
(com/data-grid-cell {}
|
||||
(fc/with-field :ledger-mapped/account
|
||||
(com/validated-field {:errors (fc/field-errors)}
|
||||
(account-typeahead* {:value (fc/field-value)
|
||||
:client-id client-id
|
||||
:name (fc/field-name)}))))
|
||||
(com/data-grid-cell {:class "text-right"}
|
||||
|
||||
(if manual?
|
||||
(fc/with-field :debit
|
||||
(com/validated-field {:errors (fc/field-errors)}
|
||||
(com/money-input {:class "w-24"
|
||||
:name (fc/field-name)
|
||||
:value (fc/field-value)})))
|
||||
(when (= (fc/field-value (:ledger-mapped/ledger-side value))
|
||||
:ledger-side/debit)
|
||||
(format "$%,.2f" (fc/field-value (:ledger-mapped/amount value))))))
|
||||
(com/data-grid-cell {:class "text-right"}
|
||||
|
||||
(if manual?
|
||||
(fc/with-field :credit
|
||||
(com/validated-field {:errors (fc/field-errors)}
|
||||
(com/money-input {:class "w-24"
|
||||
:name (fc/field-name)
|
||||
:value (fc/field-value)})))
|
||||
(when (= (fc/field-value (:ledger-mapped/ledger-side value))
|
||||
:ledger-side/credit)
|
||||
(format "$%,.2f" (fc/field-value (:ledger-mapped/amount value))))))
|
||||
(com/data-grid-cell {:class "align-top"}
|
||||
(when manual?
|
||||
(com/a-icon-button {"@click.prevent.stop" "$refs.p.remove()"} svg/x))))))
|
||||
|
||||
(defrecord MainStep [linear-wizard]
|
||||
mm/ModalWizardStep
|
||||
(step-name [_]
|
||||
"Main")
|
||||
(step-key [_]
|
||||
:main)
|
||||
|
||||
(edit-path [_ _]
|
||||
[])
|
||||
|
||||
(step-schema [_]
|
||||
(mut/select-keys (mm/form-schema linear-wizard) #{:db/id :sales-summary/items}))
|
||||
|
||||
(render-step
|
||||
[this {:keys [multi-form-state] :as request}]
|
||||
(mm/default-render-step
|
||||
linear-wizard this
|
||||
:head [:div.p-2 "New invoice"]
|
||||
:body (mm/default-step-body
|
||||
{}
|
||||
[:div
|
||||
(fc/with-field :db/id
|
||||
(com/hidden {:name (fc/field-name)
|
||||
:value (fc/field-value)}))
|
||||
(com/data-grid {:headers
|
||||
[(com/data-grid-header {} "Category")
|
||||
(com/data-grid-header {} "Account")
|
||||
(com/data-grid-header {} "Debits")
|
||||
(com/data-grid-header {} "Credits")
|
||||
(com/data-grid-header {} "")]}
|
||||
(fc/with-field :sales-summary/items
|
||||
(list
|
||||
(fc/cursor-map #(sales-summary-item-row* {:value %
|
||||
:client-id (:db/id (:sales-summary/client (:snapshot multi-form-state))) }))
|
||||
;; TODO
|
||||
(com/data-grid-new-row {:colspan 5
|
||||
: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 (:db/id (:sales-summary/client (:snapshot multi-form-state)))})}} ;; TODO
|
||||
"New Summary Item")))
|
||||
(summary-total-row* request)
|
||||
(unbalanced-row* request)) ])
|
||||
|
||||
: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-[850px] lg:h-[900px]")))
|
||||
|
||||
(defn attach-ledger [i]
|
||||
(cond-> i
|
||||
(:credit i) (assoc :ledger-mapped/ledger-side :ledger-side/credit
|
||||
:ledger-mapped/amount (:credit i))
|
||||
(:debit i) (assoc :ledger-mapped/ledger-side :ledger-side/debit
|
||||
:ledger-mapped/amount (:debit i))
|
||||
true (dissoc :credit :debit)
|
||||
true (assoc :sales-summary-item/manual? true)))
|
||||
|
||||
(defrecord EditWizard [_ current-step]
|
||||
mm/LinearModalWizard
|
||||
(hydrate-from-request
|
||||
[this request]
|
||||
this)
|
||||
(navigate [this step-key]
|
||||
(assoc this :current-step step-key))
|
||||
(get-current-step
|
||||
[this]
|
||||
(mm/get-step this :main))
|
||||
(render-wizard [this {:keys [multi-form-state] :as request}]
|
||||
(mm/default-render-wizard
|
||||
this request
|
||||
:form-params
|
||||
(-> mm/default-form-props
|
||||
(assoc :hx-put
|
||||
(str (bidi/path-for ssr-routes/only-routes ::route/edit-wizard-submit))))
|
||||
:render-timeline? false))
|
||||
(steps [_]
|
||||
[:main])
|
||||
(get-step [this step-key]
|
||||
(let [step-key-result (mc/parse mm/step-key-schema step-key)
|
||||
[step-key-type step-key] step-key-result]
|
||||
(->MainStep this)))
|
||||
(form-schema [_]
|
||||
edit-schema)
|
||||
(submit [this {:keys [multi-form-state request-method identity] :as request}]
|
||||
(let [result (:snapshot multi-form-state )
|
||||
transaction [:upsert-entity {:db/id (:db/id result)
|
||||
:sales-summary/items (map
|
||||
(fn [i]
|
||||
(if (:sales-summary-item/manual? i)
|
||||
(attach-ledger i)
|
||||
{:db/id (:db/id i)
|
||||
:ledger-mapped/account (:ledger-mapped/account i)
|
||||
}))
|
||||
(:sales-summary/items result))}]]
|
||||
(clojure.pprint/pprint (:sales-summary/items result))
|
||||
@(dc/transact conn [ transaction])
|
||||
(html-response
|
||||
(row* identity (dc/pull (dc/db conn) default-read (:db/id result))
|
||||
{:flash? true
|
||||
:request request})
|
||||
:headers (cond-> {"hx-trigger" "modalclose"
|
||||
"hx-retarget" (format "#entity-table tr[data-id=\"%d\"]" (:db/id result))
|
||||
"hx-reswap" "outerHTML"})))))
|
||||
|
||||
(def edit-wizard (->EditWizard nil nil))
|
||||
|
||||
(defn initial-edit-wizard-state [request]
|
||||
(let [entity (dc/pull (dc/db conn) default-read (:db/id (:route-params request)))
|
||||
entity (select-keys entity (mut/keys edit-schema))
|
||||
entity (update entity :sales-summary/items (comp #(map (fn [x]
|
||||
(if (= :ledger-side/debit (:ledger-mapped/ledger-side x))
|
||||
(assoc x :debit (:ledger-mapped/amount x))
|
||||
(assoc x :credit (:ledger-mapped/amount x))))
|
||||
%) sort-items))]
|
||||
|
||||
(mm/->MultiStepFormState entity [] entity)))
|
||||
|
||||
(def key->handler
|
||||
(apply-middleware-to-all-handlers
|
||||
(->>
|
||||
{::route/page (helper/page-route grid-page)
|
||||
::route/table (helper/table-route grid-page)
|
||||
::route/edit-wizard (-> mm/open-wizard-handler
|
||||
(mm/wrap-wizard edit-wizard)
|
||||
(mm/wrap-init-multi-form-state initial-edit-wizard-state)
|
||||
(wrap-schema-enforce :route-schema [:map [:db/id entity-id]]))
|
||||
::route/edit-wizard-navigate (-> mm/next-handler
|
||||
(mm/wrap-wizard edit-wizard)
|
||||
(mm/wrap-decode-multi-form-state))
|
||||
::route/new-summary-item (-> (add-new-entity-handler [:step-params :sales-summary/items]
|
||||
(fn render [cursor request]
|
||||
(sales-summary-item-row*
|
||||
{:value cursor
|
||||
:client-id (:client-id (:query-params request))}))
|
||||
(fn build-new-row [base _]
|
||||
(assoc base :sales-summary-item/manual? true)))
|
||||
(wrap-schema-enforce :query-schema [:map
|
||||
[:client-id {:optional true}
|
||||
[:maybe entity-id]]]))
|
||||
::route/edit-wizard-submit (-> mm/submit-handler
|
||||
(mm/wrap-wizard edit-wizard)
|
||||
(mm/wrap-decode-multi-form-state))})
|
||||
(fn [h]
|
||||
(-> h
|
||||
(wrap-copy-qp-pqp)
|
||||
(wrap-apply-sort grid-page)
|
||||
(wrap-merge-prior-hx)
|
||||
(wrap-schema-enforce :query-schema query-schema)
|
||||
(wrap-schema-enforce :hx-schema query-schema)
|
||||
(wrap-admin)
|
||||
(wrap-client-redirect-unauthenticated)))))
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
[auto-ap.routes.admin.excel-invoices :as ei-routes]
|
||||
[auto-ap.routes.admin.import-batch :as ib-routes]
|
||||
[auto-ap.routes.admin.transaction-rules :as transaction-rules]
|
||||
[auto-ap.routes.admin.vendors :as v-routes]
|
||||
[auto-ap.routes.admin.vendors :as v-routes]
|
||||
[auto-ap.routes.pos.sales-summaries :as ss-routes]
|
||||
[auto-ap.routes.invoice :as invoice-route]
|
||||
[auto-ap.routes.ledger :as ledger-routes]
|
||||
[auto-ap.routes.outgoing-invoice :as oi-routes]
|
||||
@@ -90,8 +91,8 @@
|
||||
(#{::invoice-route/all-page ::invoice-route/unpaid-page ::invoice-route/voided-page ::invoice-route/paid-page ::oi-routes/new ::invoice-route/import-page :invoice-glimpse :invoice-glimpse-textract-invoice} (:matched-route request))
|
||||
"invoices"
|
||||
|
||||
(#{:pos-sales :pos-expected-deposits :pos-tenders :pos-refunds :pos-cash-drawer-shifts} (:matched-route request))
|
||||
"sales"
|
||||
(#{:pos-sales :pos-expected-deposits :pos-tenders :pos-refunds :pos-cash-drawer-shifts ::ss-routes/page} (:matched-route request))
|
||||
"sales"
|
||||
(#{::payment-routes/all-page ::payment-routes/pending-page ::payment-routes/cleared-page ::payment-routes/voided-page} (:matched-route request))
|
||||
"payments"
|
||||
(#{::ledger-routes/all-page ::ledger-routes/external-page ::ledger-routes/external-import-page ::ledger-routes/balance-sheet ::ledger-routes/cash-flows ::ledger-routes/profit-and-loss} (:matched-route request))
|
||||
@@ -207,12 +208,18 @@
|
||||
:hx-boost "true"}
|
||||
|
||||
"Refunds")
|
||||
(menu-button- {:href (str (bidi/path-for ssr-routes/only-routes
|
||||
:pos-cash-drawer-shifts)
|
||||
"?date-range=week")
|
||||
:active? (= :pos-cash-drawer-shifts (:matched-route request))
|
||||
:hx-boost "true"}
|
||||
"Cash drawer shifts")
|
||||
(menu-button- {:href (str (bidi/path-for ssr-routes/only-routes
|
||||
:pos-cash-drawer-shifts)
|
||||
::ss-routes/page)
|
||||
"?date-range=week")
|
||||
:active? (= :pos-cash-drawer-shifts (:matched-route request))
|
||||
:active? (= ::ss-routes/page (:matched-route request))
|
||||
:hx-boost "true"}
|
||||
"Cash drawer shifts"))))
|
||||
"Summaries"))))
|
||||
|
||||
(menu-button- {"@click.prevent" "if (selected == 'payments') {selected = null } else { selected = 'payments'} "
|
||||
:icon svg/payments}
|
||||
|
||||
@@ -144,10 +144,12 @@
|
||||
[:div.htmx-indicator-hidden.inline-flex.gap-2.items-center.justify-center (into [:div.h-4.w-4] children)]]))
|
||||
|
||||
(defn a-icon-button- [params & children]
|
||||
(into
|
||||
[:a (-> params (update :class str " inline-flex items-center justify-center bg-white dark:bg-gray-600 items-center p-3 text-sm font-medium border border-gray-300 dark:border-gray-700 text-center text-gray-500 hover:text-gray-800 rounded-lg dark:text-gray-400 dark:hover:text-gray-100")
|
||||
(update :href #(or % "")))
|
||||
[:div.h-4.w-4 children]]))
|
||||
(let [class-str (:class params "")
|
||||
has-padding? (re-find #"\bp[x y]?-\d+(\.\d+)?\b" class-str)]
|
||||
(into
|
||||
[:a (-> params (update :class str (if has-padding? "" " p-3") " inline-flex items-center justify-center bg-white dark:bg-gray-600 items-center text-sm font-medium border border-gray-300 dark:border-gray-700 text-center text-gray-500 hover:text-gray-800 rounded-lg dark:text-gray-400 dark:hover:text-gray-100")
|
||||
(update :href #(or % "")))
|
||||
[:div.h-4.w-4 children]])))
|
||||
|
||||
(defn save-button- [params & children]
|
||||
[:button {:class "text-white bg-green-500 hover:bg-green-700 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center mr-2 dark:bg-green-600 dark:hover:bg-green-700 dark:focus:ring-green-800 inline-flex items-center hover:scale-105 transition duration-300"}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
[auto-ap.ssr.admin.excel-invoice :as admin-excel-invoices]
|
||||
[auto-ap.ssr.admin.history :as history]
|
||||
[auto-ap.ssr.admin.import-batch :as import-batch]
|
||||
[auto-ap.ssr.admin.sales-summaries :as admin-sales-summaries]
|
||||
[auto-ap.ssr.pos.sales-summaries :as pos-sales-summaries]
|
||||
[auto-ap.ssr.admin.transaction-rules :as admin-rules]
|
||||
[auto-ap.ssr.admin.vendors :as admin-vendors]
|
||||
[auto-ap.ssr.auth :as auth]
|
||||
@@ -85,17 +85,17 @@
|
||||
(into company-1099/key->handler)
|
||||
(into invoice/key->handler)
|
||||
(into import-batch/key->handler)
|
||||
(into pos-sales/key->handler)
|
||||
(into pos-expected-deposits/key->handler)
|
||||
(into pos-tenders/key->handler)
|
||||
(into pos-cash-drawer-shifts/key->handler)
|
||||
(into pos-refunds/key->handler)
|
||||
(into users/key->handler)
|
||||
(into admin-accounts/key->handler)
|
||||
(into admin-excel-invoices/key->handler)
|
||||
(into admin/key->handler)
|
||||
(into admin-jobs/key->handler)
|
||||
(into admin-sales-summaries/key->handler)
|
||||
(into pos-sales/key->handler)
|
||||
(into pos-expected-deposits/key->handler)
|
||||
(into pos-tenders/key->handler)
|
||||
(into pos-cash-drawer-shifts/key->handler)
|
||||
(into pos-refunds/key->handler)
|
||||
(into pos-sales-summaries/key->handler)
|
||||
(into users/key->handler)
|
||||
(into admin-accounts/key->handler)
|
||||
(into admin-excel-invoices/key->handler)
|
||||
(into admin/key->handler)
|
||||
(into admin-jobs/key->handler)
|
||||
(into admin-vendors/key->handler)
|
||||
(into admin-clients/key->handler)
|
||||
(into admin-rules/key->handler)
|
||||
|
||||
@@ -120,7 +120,8 @@
|
||||
(list
|
||||
[:div.text-2xl.font-bold.text-gray-600 (str "Balance Sheet - " (str/join ", " (map :client/name client))) ]
|
||||
(rtable/table {:widths (cond-> (into [30 ] (repeat 13 client-count))
|
||||
(> (count date) 1) (into (repeat 13 (* 2 client-count (dec (count date))))))
|
||||
(> (count date) 1) (into (repeat 13 (* 2 client-count (dec (count date)))))
|
||||
(and (> client-count 1) (= (count date) 1)) (conj 13))
|
||||
:investigate-url (bidi.bidi/path-for ssr-routes/only-routes ::route/investigate)
|
||||
:table report
|
||||
:warning (not-empty (str/join "\n " (filter not-empty [warning (:warning report)])))} ))))])
|
||||
@@ -201,8 +202,9 @@
|
||||
(conj [:paragraph {:color [128 0 0] :size 9} (:warning report)])
|
||||
(conj
|
||||
(table->pdf report
|
||||
(cond-> (into [30 ] (repeat client-count 13))
|
||||
(> (count date) 1) (into (repeat (* 2 client-count (dec (count date))) 13 ))))))
|
||||
(cond-> (into [30 ] (repeat client-count 13))
|
||||
(> (count date) 1) (into (repeat (* 2 client-count (dec (count date))) 13 ))
|
||||
(and (> client-count 1) (= (count date) 1)) (conj 13)))))
|
||||
output-stream)
|
||||
(.toByteArray output-stream)))
|
||||
|
||||
|
||||
779
src/clj/auto_ap/ssr/pos/sales_summaries.clj
Normal file
779
src/clj/auto_ap/ssr/pos/sales_summaries.clj
Normal file
@@ -0,0 +1,779 @@
|
||||
(ns auto-ap.ssr.pos.sales-summaries
|
||||
(:require
|
||||
[auto-ap.datomic
|
||||
:refer [apply-pagination apply-sort-3 conn merge-query pull-many
|
||||
query2]]
|
||||
[auto-ap.datomic.accounts :as d-accounts]
|
||||
[auto-ap.datomic.sales-summaries :refer [total-credits total-debits]]
|
||||
[auto-ap.graphql.utils :refer [extract-client-ids]]
|
||||
[auto-ap.query-params :refer [wrap-copy-qp-pqp]]
|
||||
[auto-ap.client-routes :as client-routes]
|
||||
[auto-ap.routes.pos.sales-summaries :as route]
|
||||
[auto-ap.ssr-routes :as ssr-routes]
|
||||
[auto-ap.ssr.common-handlers :refer [add-new-entity-handler]]
|
||||
[auto-ap.ssr.components :as com]
|
||||
[auto-ap.ssr.components.link-dropdown :refer [link-dropdown]]
|
||||
[auto-ap.ssr.components.multi-modal :as mm]
|
||||
[auto-ap.ssr.form-cursor :as fc]
|
||||
[auto-ap.ssr.grid-page-helper :as helper :refer [wrap-apply-sort]]
|
||||
[auto-ap.ssr.hx :as hx]
|
||||
[auto-ap.ssr.pos.common
|
||||
:refer [date-range-field*]]
|
||||
[auto-ap.ssr.svg :as svg]
|
||||
[auto-ap.ssr.utils
|
||||
:refer [apply-middleware-to-all-handlers clj-date-schema
|
||||
default-grid-fields-schema entity-id html-response money
|
||||
strip temp-id wrap-merge-prior-hx wrap-schema-enforce]]
|
||||
[auto-ap.time :as atime]
|
||||
[bidi.bidi :as bidi]
|
||||
[clj-time.coerce :as c]
|
||||
[clojure.string :as str]
|
||||
[datomic.api :as dc]
|
||||
[hiccup.util :as hu]
|
||||
[iol-ion.query :refer [dollars= dollars-0?]]
|
||||
[malli.core :as mc]
|
||||
[malli.util :as mut]))
|
||||
|
||||
(def query-schema (mc/schema
|
||||
[:maybe
|
||||
(into [:map {:date-range [:date-range :start-date :end-date]}
|
||||
|
||||
[:start-date {:optional true}
|
||||
[:maybe clj-date-schema]]
|
||||
[:end-date {:optional true}
|
||||
[:maybe clj-date-schema]]]
|
||||
default-grid-fields-schema)]))
|
||||
|
||||
(defn filters [request]
|
||||
[:form {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||
"hx-get" (bidi/path-for ssr-routes/only-routes
|
||||
::route/table)
|
||||
"hx-target" "#entity-table"
|
||||
"hx-indicator" "#entity-table"}
|
||||
|
||||
[:fieldset.space-y-6
|
||||
(date-range-field* request)]])
|
||||
|
||||
(def default-read '[:db/id
|
||||
*
|
||||
[:sales-summary/date :xform clj-time.coerce/from-date]
|
||||
{:sales-summary/client [:client/code :client/name :db/id]}
|
||||
{:sales-summary/items [{[:ledger-mapped/ledger-side :xform iol-ion.query/ident] [:db/ident]}
|
||||
:ledger-mapped/account
|
||||
:ledger-mapped/amount
|
||||
:sales-summary-item/category
|
||||
:sales-summary-item/sort-order
|
||||
:db/id
|
||||
:sales-summary-item/manual?]}
|
||||
{:journal-entry/original-entity [:db/id]}])
|
||||
|
||||
(defn fetch-ids [db request]
|
||||
(let [query-params (:query-params request)
|
||||
valid-clients (extract-client-ids (:clients request)
|
||||
(:client request)
|
||||
(:client-id query-params)
|
||||
(when (:client-code query-params)
|
||||
[:client/code (:client-code query-params)]))
|
||||
query (cond-> {:query {:find []
|
||||
:in '[$ [?client ...]]
|
||||
:where '[[?e :sales-summary/client ?client]]}
|
||||
:args [db valid-clients]}
|
||||
(or (:start-date query-params)
|
||||
(:end-date query-params))
|
||||
(merge-query {:query '{:where [[?e :sales-summary/date ?d]]}})
|
||||
|
||||
(:start-date query-params)
|
||||
(merge-query {:query '{:in [?start-date]
|
||||
:where [[(>= ?d ?start-date)]]}
|
||||
:args [(-> query-params :start-date c/to-date)]})
|
||||
|
||||
(:end-date query-params)
|
||||
(merge-query {:query '{:in [?end-date]
|
||||
:where [[(< ?d ?end-date)]]}
|
||||
:args [(-> query-params :end-date c/to-date)]})
|
||||
|
||||
true
|
||||
(merge-query {:query {:find ['?sort-default '?e]
|
||||
:where ['[?e :sales-summary/date ?sort-default]]}}))]
|
||||
(cond->> (query2 query)
|
||||
true (apply-sort-3 query-params)
|
||||
true (apply-pagination query-params))))
|
||||
|
||||
(defn hydrate-results [ids db _]
|
||||
(let [results (->> (pull-many db default-read ids)
|
||||
(group-by :db/id))
|
||||
refunds (->> ids
|
||||
(map results)
|
||||
(map first))]
|
||||
refunds))
|
||||
|
||||
(defn fetch-page [request]
|
||||
(let [db (dc/db conn)
|
||||
{ids-to-retrieve :ids matching-count :count} (fetch-ids db request)]
|
||||
|
||||
[(->> (hydrate-results ids-to-retrieve db request))
|
||||
matching-count]))
|
||||
|
||||
(defn sort-items [ss]
|
||||
(sort-by (juxt :ledger-mapped/ledger-side :sales-summary-item/sort-order :sales-summary-item/category) ss))
|
||||
|
||||
(defn truncate [s max-len]
|
||||
(if (> (count s) max-len)
|
||||
(str (subs s 0 (- max-len 3)) "...")
|
||||
s))
|
||||
|
||||
(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)))})])
|
||||
|
||||
(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.account-cell.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 {:class "p-1"
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes ::route/edit-item-account)
|
||||
:hx-target "closest .account-cell"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-vals (hx/json {:item-index (or (:item-index item) 0)
|
||||
:client-id client-id
|
||||
:current-account-id (or account-id "")})}
|
||||
svg/pencil)]))
|
||||
|
||||
(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.account-cell.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 {:class "p-1"
|
||||
:hx-put (bidi/path-for ssr-routes/only-routes ::route/save-item-account)
|
||||
:hx-target "closest .account-cell"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-include "closest .account-cell"
|
||||
:hx-vals (hx/json {:field-name-prefix field-name-prefix
|
||||
:client-id client-id})}
|
||||
svg/check)
|
||||
(com/a-icon-button {:class "p-1"
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes ::route/cancel-item-account)
|
||||
:hx-target "closest .account-cell"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-vals (hx/json {:field-name-prefix field-name-prefix
|
||||
:client-id client-id
|
||||
:current-account-id (or current-account-id "")})}
|
||||
svg/x)]]))
|
||||
|
||||
(def grid-page
|
||||
(helper/build {:id "entity-table"
|
||||
:id-fn :db/id
|
||||
:nav com/main-aside-nav
|
||||
:fetch-page fetch-page
|
||||
:page-specific-nav filters
|
||||
:query-schema query-schema
|
||||
:row-buttons (fn [_ entity]
|
||||
[(com/icon-button {:hx-get (bidi/path-for ssr-routes/only-routes
|
||||
::route/edit-wizard
|
||||
:db/id (:db/id entity))}
|
||||
svg/pencil)])
|
||||
:oob-render
|
||||
(fn [request]
|
||||
[(assoc-in (date-range-field* request) [1 :hx-swap-oob] true)])
|
||||
:breadcrumbs [[:a {:href (bidi/path-for ssr-routes/only-routes
|
||||
:company)}
|
||||
"POS"]
|
||||
|
||||
[:a {:href (bidi/path-for ssr-routes/only-routes
|
||||
::route/page)}
|
||||
"Sales Summaries"]]
|
||||
:title "Sales Summaries"
|
||||
:entity-name "Daily Summary"
|
||||
:route ::route/table
|
||||
:headers [{:key "client"
|
||||
:name "Client"
|
||||
:sort-key "client"
|
||||
:hide? (fn [args]
|
||||
(= (count (:clients args)) 1))
|
||||
:render #(-> % :sales-summary/client :client/code)}
|
||||
|
||||
{:key "date"
|
||||
:name "Date"
|
||||
:sort-key "date"
|
||||
:render #(some-> % :sales-summary/date (atime/unparse-local atime/normal-date))}
|
||||
|
||||
{:key "debits"
|
||||
:name "Debits"
|
||||
:sort-key "debits"
|
||||
:class "w-72 align-top"
|
||||
:render (fn [ss]
|
||||
(let [items (:sales-summary/items ss)
|
||||
debit-items (filter #(= :ledger-side/debit (:ledger-mapped/ledger-side %)) (sort-items items))
|
||||
credit-count (count (filter #(= :ledger-side/credit (:ledger-mapped/ledger-side %)) items))
|
||||
total-debits (total-debits items)]
|
||||
[:div.flex.flex-col.h-full
|
||||
[:ul.flex-grow
|
||||
(for [si debit-items]
|
||||
[:li.flex.items-baseline.gap-2.py-0.5.text-sm.text-gray-700
|
||||
[:span.flex-1.min-w-0.truncate.text-gray-600
|
||||
(:sales-summary-item/category si)]
|
||||
(when-not (:ledger-mapped/account si)
|
||||
[:span.shrink-0 (com/pill {:color :red} "?")])
|
||||
[:span.shrink-0.font-mono.tabular-nums.text-right.text-gray-900.whitespace-nowrap
|
||||
(format "$%,.2f" (:ledger-mapped/amount si))]])
|
||||
(for [_ (range (max 0 (- credit-count (count debit-items))))]
|
||||
[:li.py-0.5.text-sm " "])]
|
||||
[:div.border-t-2.border-gray-300.mt-1.pt-1.flex.justify-between.items-baseline
|
||||
[:span.text-xs.uppercase.tracking-wider.font-semibold.text-gray-500 "Total"]
|
||||
[:span.font-mono.tabular-nums.font-bold.text-gray-900
|
||||
(format "$%,.2f" total-debits)]]]))}
|
||||
|
||||
{:key "credits"
|
||||
:name "Credits"
|
||||
:sort-key "credits"
|
||||
:class "w-72 align-top"
|
||||
:render (fn [ss]
|
||||
(let [items (:sales-summary/items ss)
|
||||
credit-items (filter #(= :ledger-side/credit (:ledger-mapped/ledger-side %)) (sort-items items))
|
||||
debit-count (count (filter #(= :ledger-side/debit (:ledger-mapped/ledger-side %)) items))
|
||||
total-credits (total-credits items)]
|
||||
[:div.flex.flex-col.h-full
|
||||
[:ul.flex-grow
|
||||
(for [si credit-items]
|
||||
[:li.flex.items-baseline.gap-2.py-0.5.text-sm.text-gray-700
|
||||
[:span.flex-1.min-w-0.truncate.text-gray-600
|
||||
(:sales-summary-item/category si)]
|
||||
(when-not (:ledger-mapped/account si)
|
||||
[:span.shrink-0 (com/pill {:color :red} "?")])
|
||||
[:span.shrink-0.font-mono.tabular-nums.text-right.text-gray-900.whitespace-nowrap
|
||||
(format "$%,.2f" (:ledger-mapped/amount si))]])
|
||||
(for [_ (range (max 0 (- debit-count (count credit-items))))]
|
||||
[:li.py-0.5.text-sm " "])]
|
||||
[:div.border-t-2.border-gray-300.mt-1.pt-1.flex.justify-between.items-baseline
|
||||
[:span.text-xs.uppercase.tracking-wider.font-semibold.text-gray-500 "Total"]
|
||||
[:span.font-mono.tabular-nums.font-bold.text-gray-900
|
||||
(format "$%,.2f" total-credits)]]]))}
|
||||
|
||||
{:key "balance"
|
||||
:name "Status"
|
||||
:sort-key "balance"
|
||||
:class "w-28 align-top"
|
||||
:render (fn [ss]
|
||||
(let [items (:sales-summary/items ss)
|
||||
total-debits (total-debits items)
|
||||
total-credits (total-credits items)
|
||||
delta (- total-debits total-credits)
|
||||
balanced? (dollars= total-debits total-credits)
|
||||
missing-account? (some #(not (:ledger-mapped/account %)) items)]
|
||||
[:div.flex.flex-col.items-center.gap-1.pt-2
|
||||
(when missing-account?
|
||||
[:span.inline-block.text-xs.font-semibold.uppercase.tracking-wider.text-amber-800.bg-amber-100.border.border-amber-300.rounded-sm.px-1.5.py-0.5
|
||||
"Missing acct"])
|
||||
(if balanced?
|
||||
(when-not missing-account?
|
||||
[:span.inline-block.text-xs.font-semibold.uppercase.tracking-wider.text-emerald-800.bg-emerald-100.border.border-emerald-300.rounded-sm.px-1.5.py-0.5
|
||||
"Balanced"])
|
||||
[:div.flex.flex-col.items-center
|
||||
[:span.font-mono.tabular-nums.text-red-700.font-bold.text-sm
|
||||
(format "$%,.2f" (Math/abs delta))]
|
||||
[:span.text-xs.uppercase.tracking-wider.text-red-600.font-medium.mt-0.5
|
||||
(if (> total-debits total-credits) "Debit over" "Credit over")]])]))}
|
||||
|
||||
{:key "links"
|
||||
:name "Links"
|
||||
:show-starting "lg"
|
||||
:class "w-8"
|
||||
:render (fn [ss]
|
||||
(let [ledger-entry (:journal-entry/original-entity ss)]
|
||||
(when (seq ledger-entry)
|
||||
(link-dropdown
|
||||
[{:link (hu/url (bidi/path-for client-routes/routes :ledger)
|
||||
{:exact-match-id (:db/id (first ledger-entry))})
|
||||
:color :yellow
|
||||
:content "Ledger entry"}]))))}]}))
|
||||
|
||||
(def row* (partial helper/row* grid-page))
|
||||
(def table* (partial helper/table* grid-page))
|
||||
|
||||
(def edit-schema
|
||||
[:map
|
||||
[:db/id entity-id]
|
||||
[:sales-summary/client [:map [:db/id entity-id]]]
|
||||
[:sales-summary/items
|
||||
[:vector {:coerce? true}
|
||||
[:and
|
||||
[:map
|
||||
[:db/id [:or entity-id temp-id]]
|
||||
[:sales-summary-item/category [:string {:decode/string strip}]]
|
||||
[:sales-summary-item/manual? {:default false :decode/arbitrary (fn [x] (cond
|
||||
(boolean? x)
|
||||
x
|
||||
(nil? x)
|
||||
false
|
||||
(str/blank? x)
|
||||
false
|
||||
:else
|
||||
true))} :boolean]
|
||||
[:ledger-mapped/account entity-id]
|
||||
[:credit {:optional true} [:maybe money]]
|
||||
[:debit {:optional true} [:maybe money]]]
|
||||
[:fn {:error/message "Must choose one of credit/debit"
|
||||
:error/path [:credit]}
|
||||
(fn [x]
|
||||
(not (and (:credit x)
|
||||
(:debit x))))]]]]])
|
||||
|
||||
(defn summary-total-row* [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))]
|
||||
|
||||
(com/data-grid-row {:id "total-row"
|
||||
:class "bg-slate-50 border-t-2 border-slate-300"}
|
||||
(com/data-grid-cell {})
|
||||
(com/data-grid-cell {:class "text-right"}
|
||||
[:span.text-xs.uppercase.tracking-wider.font-semibold.text-slate-600
|
||||
"Total"])
|
||||
(com/data-grid-cell {:class "text-right"}
|
||||
[:span.font-mono.tabular-nums.font-bold.text-slate-900
|
||||
(format "$%,.2f" total-debits)])
|
||||
(com/data-grid-cell {:class "text-right"}
|
||||
[:span.font-mono.tabular-nums.font-bold.text-slate-900
|
||||
(format "$%,.2f" total-credits)])
|
||||
(com/data-grid-cell {}))))
|
||||
|
||||
(defn unbalanced-row* [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))
|
||||
unbalanced? (not (dollars= total-credits total-debits))
|
||||
debit-over? (and unbalanced? (> total-debits total-credits))
|
||||
credit-over? (and unbalanced? (> total-credits total-debits))]
|
||||
|
||||
(com/data-grid-row {:id "unbalanced-row"
|
||||
:class (when unbalanced? "bg-red-50 border-t border-red-200")}
|
||||
(com/data-grid-cell {})
|
||||
(com/data-grid-cell {:class "text-right"}
|
||||
(when unbalanced?
|
||||
[:span.text-xs.uppercase.tracking-wider.font-semibold.text-red-700
|
||||
"Out of balance"]))
|
||||
(com/data-grid-cell {:class "text-right"}
|
||||
(when debit-over?
|
||||
[:span.font-mono.tabular-nums.font-bold.text-red-700
|
||||
(format "$%,.2f" (- total-debits total-credits))]))
|
||||
(com/data-grid-cell {:class "text-right"}
|
||||
(when credit-over?
|
||||
[:span.font-mono.tabular-nums.font-bold.text-red-700
|
||||
(format "$%,.2f" (- total-credits total-debits))]))
|
||||
(com/data-grid-cell {}))))
|
||||
|
||||
(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.border-t.mt-1
|
||||
{:id "total-display"}]
|
||||
[:span.font-semibold "Total"]
|
||||
[:div.flex.gap-8
|
||||
[:span.font-mono (format "$%,.2f" total-debits)]
|
||||
[:span.font-mono (format "$%,.2f" total-credits)]]))
|
||||
|
||||
(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
|
||||
{:id "unbalanced-display"}
|
||||
[:span.font-semibold.text-red-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)))]]]])))
|
||||
|
||||
(defn sales-summary-item-row* [{:keys [value client-id]}]
|
||||
(let [manual? (fc/field-value (:sales-summary-item/manual? value))]
|
||||
(com/data-grid-row (cond-> {:x-ref "p"
|
||||
:x-data (hx/json {})
|
||||
:class (when manual?
|
||||
"bg-indigo-50/40 border-l-2 border-indigo-300")}
|
||||
(fc/field-value (:new? value)) (hx/htmx-transition-appear))
|
||||
(fc/with-field :db/id
|
||||
(com/hidden {:name (fc/field-name)
|
||||
:value (fc/field-value)}))
|
||||
(when manual?
|
||||
(fc/with-field :sales-summary-item/manual?
|
||||
(com/hidden {:name (fc/field-name)
|
||||
:value true})))
|
||||
(com/data-grid-cell {:class "align-top"}
|
||||
(fc/with-field :sales-summary-item/category
|
||||
(if manual?
|
||||
(com/validated-field {:errors (fc/field-errors)}
|
||||
(com/text-input {:placeholder "Category/Explanation"
|
||||
:name (fc/field-name)
|
||||
:value (fc/field-value)}))
|
||||
|
||||
(list
|
||||
(com/hidden {:name (fc/field-name)
|
||||
:value (fc/field-value)})
|
||||
[:span.text-sm.text-gray-700
|
||||
(fc/field-value (:sales-summary-item/category value))]))))
|
||||
(com/data-grid-cell {:class "align-top"}
|
||||
(fc/with-field :ledger-mapped/account
|
||||
(com/validated-field {:errors (fc/field-errors)}
|
||||
(account-typeahead* {:value (fc/field-value)
|
||||
:client-id client-id
|
||||
:name (fc/field-name)}))))
|
||||
(com/data-grid-cell {:class "text-right align-top"}
|
||||
|
||||
(if manual?
|
||||
(fc/with-field :debit
|
||||
(com/validated-field {:errors (fc/field-errors)}
|
||||
(com/money-input {:class "w-24 text-right font-mono tabular-nums"
|
||||
:name (fc/field-name)
|
||||
:value (fc/field-value)})))
|
||||
(when (= (fc/field-value (:ledger-mapped/ledger-side value))
|
||||
:ledger-side/debit)
|
||||
[:span.font-mono.tabular-nums.text-gray-900.text-sm.whitespace-nowrap
|
||||
(format "$%,.2f" (fc/field-value (:ledger-mapped/amount value)))])))
|
||||
(com/data-grid-cell {:class "text-right align-top"}
|
||||
|
||||
(if manual?
|
||||
(fc/with-field :credit
|
||||
(com/validated-field {:errors (fc/field-errors)}
|
||||
(com/money-input {:class "w-24 text-right font-mono tabular-nums"
|
||||
:name (fc/field-name)
|
||||
:value (fc/field-value)})))
|
||||
(when (= (fc/field-value (:ledger-mapped/ledger-side value))
|
||||
:ledger-side/credit)
|
||||
[:span.font-mono.tabular-nums.text-gray-900.text-sm.whitespace-nowrap
|
||||
(format "$%,.2f" (fc/field-value (:ledger-mapped/amount value)))])))
|
||||
(com/data-grid-cell {:class "align-top"}
|
||||
(when manual?
|
||||
(com/a-icon-button {"@click.prevent.stop" "$refs.p.remove()"} svg/x))))))
|
||||
|
||||
(defrecord MainStep [linear-wizard]
|
||||
mm/ModalWizardStep
|
||||
(step-name [_]
|
||||
"Main")
|
||||
(step-key [_]
|
||||
:main)
|
||||
|
||||
(edit-path [_ _]
|
||||
[])
|
||||
|
||||
(step-schema [_]
|
||||
(mut/select-keys (mm/form-schema linear-wizard) #{:db/id :sales-summary/items}))
|
||||
|
||||
(render-step
|
||||
[this {:keys [multi-form-state] :as request}]
|
||||
(let [client-id (:db/id (:sales-summary/client (:snapshot multi-form-state)))
|
||||
items (:sales-summary/items (:step-params multi-form-state))
|
||||
sorted-items (sort-items items)
|
||||
indexed-items (map-indexed vector sorted-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-6
|
||||
[: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)]
|
||||
(if manual?
|
||||
[:div.flex.items-center.gap-2.text-sm {:x-ref "p" :x-data (hx/json {})}
|
||||
(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"
|
||||
:name (fc/field-name)
|
||||
:value (fc/field-value)
|
||||
:class "w-32 text-sm"})))
|
||||
(account-typeahead* {:name (str "step-params[sales-summary/items][" actual-idx "][ledger-mapped/account]")
|
||||
:value (:ledger-mapped/account item)
|
||||
: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-right font-mono tabular-nums"
|
||||
:name (fc/field-name)
|
||||
:value (fc/field-value)})))
|
||||
(com/a-icon-button {"@click.prevent.stop" "$refs.p.remove()"} svg/x)]
|
||||
[: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)})
|
||||
[: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.ml-auto.font-mono.tabular-nums.text-gray-900 (format "$%,.2f" (:ledger-mapped/amount item))]]))
|
||||
[:div.h-6]))]
|
||||
[:div.mt-2.border-t.pt-1
|
||||
(summary-total-display request)
|
||||
(unbalanced-display request)]]
|
||||
[: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)]
|
||||
(if manual?
|
||||
[:div.flex.items-center.gap-2.text-sm {:x-ref "p" :x-data (hx/json {})}
|
||||
(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"
|
||||
:name (fc/field-name)
|
||||
:value (fc/field-value)
|
||||
:class "w-32 text-sm"})))
|
||||
(account-typeahead* {:name (str "step-params[sales-summary/items][" actual-idx "][ledger-mapped/account]")
|
||||
:value (:ledger-mapped/account item)
|
||||
:client-id client-id})
|
||||
(fc/start-form-with-prefix [(str "step-params[sales-summary/items][" actual-idx "]")]
|
||||
item []
|
||||
(fc/with-field :credit
|
||||
(com/money-input {:class "w-24 text-right font-mono tabular-nums"
|
||||
:name (fc/field-name)
|
||||
:value (fc/field-value)})))
|
||||
(com/a-icon-button {"@click.prevent.stop" "$refs.p.remove()"} svg/x)]
|
||||
[: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)})
|
||||
[: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.ml-auto.font-mono.tabular-nums.text-gray-900 (format "$%,.2f" (:ledger-mapped/amount item))]]))
|
||||
[:div.h-6]))]
|
||||
[:div.mt-2.border-t.pt-1
|
||||
(summary-total-display request)
|
||||
(unbalanced-display request)]]]
|
||||
[:div.mt-4.border-t.pt-2
|
||||
(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]"))))
|
||||
|
||||
(defn attach-ledger [i]
|
||||
(cond-> i
|
||||
(:credit i) (assoc :ledger-mapped/ledger-side :ledger-side/credit
|
||||
:ledger-mapped/amount (:credit i))
|
||||
(:debit i) (assoc :ledger-mapped/ledger-side :ledger-side/debit
|
||||
:ledger-mapped/amount (:debit i))
|
||||
true (dissoc :credit :debit)
|
||||
true (assoc :sales-summary-item/manual? true)))
|
||||
|
||||
(defrecord EditWizard [_ current-step]
|
||||
mm/LinearModalWizard
|
||||
(hydrate-from-request
|
||||
[this request]
|
||||
this)
|
||||
(navigate [this step-key]
|
||||
(assoc this :current-step step-key))
|
||||
(get-current-step
|
||||
[this]
|
||||
(mm/get-step this :main))
|
||||
(render-wizard [this {:keys [multi-form-state] :as request}]
|
||||
(mm/default-render-wizard
|
||||
this request
|
||||
:form-params
|
||||
(-> mm/default-form-props
|
||||
(assoc :hx-put
|
||||
(str (bidi/path-for ssr-routes/only-routes ::route/edit-wizard-submit))))
|
||||
:render-timeline? false))
|
||||
(steps [_]
|
||||
[:main])
|
||||
(get-step [this step-key]
|
||||
(let [step-key-result (mc/parse mm/step-key-schema step-key)
|
||||
[step-key-type step-key] step-key-result]
|
||||
(->MainStep this)))
|
||||
(form-schema [_]
|
||||
edit-schema)
|
||||
(submit [this {:keys [multi-form-state request-method identity] :as request}]
|
||||
(let [result (:snapshot multi-form-state)
|
||||
transaction [:upsert-sales-summary {:db/id (:db/id result)
|
||||
:sales-summary/items (map
|
||||
(fn [i]
|
||||
(if (:sales-summary-item/manual? i)
|
||||
(attach-ledger i)
|
||||
{:db/id (:db/id i)
|
||||
:ledger-mapped/account (:ledger-mapped/account i)}))
|
||||
(:sales-summary/items result))}]]
|
||||
@(dc/transact conn [transaction])
|
||||
(html-response
|
||||
(row* identity (dc/pull (dc/db conn) default-read (:db/id result))
|
||||
{:flash? true
|
||||
:request request})
|
||||
:headers (cond-> {"hx-trigger" "modalclose"
|
||||
"hx-retarget" (format "#entity-table tr[data-id=\"%d\"]" (:db/id result))
|
||||
"hx-reswap" "outerHTML"})))))
|
||||
|
||||
(def edit-wizard (->EditWizard nil nil))
|
||||
|
||||
(defn initial-edit-wizard-state [request]
|
||||
(let [entity (dc/pull (dc/db conn) default-read (:db/id (:route-params request)))
|
||||
entity (select-keys entity (mut/keys edit-schema))
|
||||
entity (update entity :sales-summary/items (comp #(map (fn [x]
|
||||
(if (= :ledger-side/debit (:ledger-mapped/ledger-side x))
|
||||
(assoc x :debit (:ledger-mapped/amount x))
|
||||
(assoc x :credit (:ledger-mapped/amount x))))
|
||||
%) sort-items))]
|
||||
|
||||
(mm/->MultiStepFormState entity [] entity)))
|
||||
|
||||
(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 "]")
|
||||
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))
|
||||
client-id (if (string? client-id) (Long/parseLong client-id) client-id)]
|
||||
(html-response
|
||||
(account-edit-cell {:field-name-prefix field-name-prefix
|
||||
:client-id client-id
|
||||
:current-account-id current-account-id}))))
|
||||
|
||||
(defn save-item-account [request]
|
||||
(let [field-name-prefix (get-in request [:params "field-name-prefix"])
|
||||
client-id (get-in request [:params "client-id"])
|
||||
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+)\]" (or field-name-prefix "")))}
|
||||
client-id (if (string? client-id) (Long/parseLong client-id) client-id)]
|
||||
(html-response
|
||||
(account-display-cell {:item item
|
||||
:field-name-prefix field-name-prefix
|
||||
:client-id client-id}))))
|
||||
|
||||
(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+)\]" (or field-name-prefix "")))}
|
||||
client-id (if (string? client-id) (Long/parseLong client-id) client-id)]
|
||||
(html-response
|
||||
(account-display-cell {:item item
|
||||
:field-name-prefix field-name-prefix
|
||||
:client-id client-id}))))
|
||||
|
||||
(def key->handler
|
||||
(apply-middleware-to-all-handlers
|
||||
(->>
|
||||
{::route/page (helper/page-route grid-page)
|
||||
::route/table (helper/table-route grid-page)
|
||||
::route/edit-wizard (-> mm/open-wizard-handler
|
||||
(mm/wrap-wizard edit-wizard)
|
||||
(mm/wrap-init-multi-form-state initial-edit-wizard-state)
|
||||
(wrap-schema-enforce :route-schema [:map [:db/id entity-id]]))
|
||||
::route/edit-wizard-navigate (-> mm/next-handler
|
||||
(mm/wrap-wizard edit-wizard)
|
||||
(mm/wrap-decode-multi-form-state))
|
||||
::route/new-summary-item (-> (add-new-entity-handler [:step-params :sales-summary/items]
|
||||
(fn render [cursor request]
|
||||
(sales-summary-item-row*
|
||||
{:value cursor
|
||||
:client-id (:client-id (:query-params request))}))
|
||||
(fn build-new-row [base _]
|
||||
(assoc base :sales-summary-item/manual? true)))
|
||||
(wrap-schema-enforce :query-schema [:map
|
||||
[:client-id {:optional true}
|
||||
[:maybe entity-id]]]))
|
||||
::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
|
||||
::route/edit-wizard-submit (-> mm/submit-handler
|
||||
(mm/wrap-wizard edit-wizard)
|
||||
(mm/wrap-decode-multi-form-state))})
|
||||
(fn [h]
|
||||
(-> h
|
||||
(wrap-copy-qp-pqp)
|
||||
(wrap-apply-sort grid-page)
|
||||
(wrap-merge-prior-hx)
|
||||
(wrap-schema-enforce :query-schema query-schema)
|
||||
(wrap-schema-enforce :hx-schema query-schema)))))
|
||||
@@ -798,30 +798,34 @@
|
||||
|
||||
|
||||
(defn balance-sheet-headers [pnl-data]
|
||||
(let [period-count (count (:periods (:args pnl-data)))]
|
||||
(let [period-count (count (:periods (:args pnl-data)))
|
||||
client-ids (set (map :client-id (:data pnl-data)))
|
||||
client-count (count client-ids)
|
||||
show-total? (and (> client-count 1) (= 1 period-count))]
|
||||
(cond-> []
|
||||
(> (count (set (map :client-id (:data pnl-data)))) 1)
|
||||
(conj (into [{:value "Client"}]
|
||||
(> client-count 1)
|
||||
(conj (cond-> (into [{:value "Client"}]
|
||||
(mapcat identity
|
||||
(for [client client-ids]
|
||||
(cond-> [{:value (str (-> pnl-data :client-codes (get client)))}]
|
||||
(> period-count 1)
|
||||
(into (apply concat (repeat (dec period-count) ["" ""])))))))
|
||||
show-total? (conj {:value "Total" :bold true :border [:left]})))
|
||||
|
||||
(mapcat identity
|
||||
(for [client (set (map :client-id (:data pnl-data))) ]
|
||||
(cond-> [{:value (str (-> pnl-data :client-codes (get client)))}]
|
||||
|
||||
(> period-count 1)
|
||||
(into (apply concat (repeat (dec period-count) ["" ""]))))))))
|
||||
true
|
||||
(conj (into [{:value "Period Ending"}]
|
||||
(for [client (set (map :client-id (:data pnl-data)))
|
||||
(conj (cond-> (into [{:value "Period Ending"}]
|
||||
(for [client client-ids
|
||||
[index p] (map vector (range) (:periods (:args pnl-data)))
|
||||
:let [is-first? (= 0 index)
|
||||
period-date (date->str p)
|
||||
period-headers (if (or is-first?
|
||||
(not (:include-deltas (:args pnl-data))))
|
||||
[{:value period-date}]
|
||||
[{:value period-date}
|
||||
{:value "+/-"}])]
|
||||
[{:value period-date}]
|
||||
[{:value period-date}
|
||||
{:value "+/-"}])]
|
||||
header period-headers]
|
||||
header))))))
|
||||
header))
|
||||
show-total? (conj {:value (date->str (first (:periods (:args pnl-data)))) :border [:left]}))))))
|
||||
|
||||
(defn append-deltas [table]
|
||||
(->> table
|
||||
@@ -890,12 +894,33 @@
|
||||
:rows table})))
|
||||
)
|
||||
|
||||
(defn add-total-border [rows]
|
||||
(map (fn [row]
|
||||
(let [last-idx (dec (count row))]
|
||||
(map-indexed
|
||||
(fn [i cell]
|
||||
(if (= i last-idx)
|
||||
(let [borders (or (:border cell) [])]
|
||||
(assoc cell :border (conj borders :left)))
|
||||
cell))
|
||||
row)))
|
||||
rows))
|
||||
|
||||
(defn summarize-balance-sheet [pnl-data]
|
||||
(let [pnl-datas (for [client-id (set (map :client-id (:data pnl-data)))
|
||||
p (:periods (:args pnl-data))]
|
||||
(-> pnl-data
|
||||
(filter-client client-id)
|
||||
(filter-period p)))]
|
||||
(let [client-ids (set (map :client-id (:data pnl-data)))
|
||||
client-count (count client-ids)
|
||||
period-count (count (:periods (:args pnl-data)))
|
||||
show-total? (and (> client-count 1) (= 1 period-count))
|
||||
pnl-datas (for [client-id client-ids
|
||||
p (:periods (:args pnl-data))]
|
||||
(-> pnl-data
|
||||
(filter-client client-id)
|
||||
(filter-period p)))
|
||||
total-data (when show-total?
|
||||
(-> pnl-data
|
||||
(filter-period (first (:periods (:args pnl-data))))
|
||||
(assoc :cell-args {:bold true})))
|
||||
pnl-datas (concat pnl-datas (when total-data [total-data]))]
|
||||
(let [table (-> []
|
||||
(into (detail-rows pnl-datas
|
||||
:assets
|
||||
@@ -912,10 +937,11 @@
|
||||
(negate #{:cogs :payroll :controllable :fixed-overhead :ownership-controllable}))
|
||||
pnl-datas)
|
||||
"Retained Earnings")))
|
||||
table (if (and (> (count (:periods (:args pnl-data))) 1)
|
||||
table (if (and (> period-count 1)
|
||||
(:include-deltas (:args pnl-data)))
|
||||
(append-deltas table)
|
||||
table)]
|
||||
(append-deltas table)
|
||||
table)
|
||||
table (if show-total? (add-total-border table) table)]
|
||||
{:warning (warning-message pnl-data)
|
||||
:header (balance-sheet-headers pnl-data)
|
||||
:rows table}))
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
(ns auto-ap.routes.admin.sales-summaries)
|
||||
(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})
|
||||
10
src/cljc/auto_ap/routes/pos/sales_summaries.cljc
Normal file
10
src/cljc/auto_ap/routes/pos/sales_summaries.cljc
Normal file
@@ -0,0 +1,10 @@
|
||||
(ns auto-ap.routes.pos.sales-summaries)
|
||||
(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})
|
||||
@@ -12,7 +12,7 @@
|
||||
[auto-ap.routes.transactions :as t-routes]
|
||||
|
||||
[auto-ap.routes.admin.clients :as ac-routes]
|
||||
[auto-ap.routes.admin.sales-summaries :as ss-routes]
|
||||
[auto-ap.routes.pos.sales-summaries :as ss-routes]
|
||||
[auto-ap.routes.admin.transaction-rules :as tr-routes]))
|
||||
|
||||
(def routes {"impersonate" :impersonate
|
||||
|
||||
@@ -265,7 +265,8 @@ NOTE: Please review the transactions we may have question for you here: https://
|
||||
[:div.notification.is-warning.is-light
|
||||
(:warning report)])
|
||||
[rtable/table {:widths (cond-> (into [30 ] (repeat 13 client-count))
|
||||
(:include-comparison args) (into (repeat 13 (* 2 client-count))))
|
||||
(:include-comparison args) (into (repeat 13 (* 2 client-count)))
|
||||
(and (> client-count 1) (not (:include-comparison args))) (conj 13))
|
||||
:click-event ::investigate-clicked
|
||||
:table report}]]))
|
||||
|
||||
|
||||
@@ -1,5 +1,2 @@
|
||||
#!/bin/bash
|
||||
sudo docker run --rm -ti -v ~/dev/integreat/data/solr:/var/solr --network=bridge -p 8983:8983 bryce-solr
|
||||
#sudo podman container run --user 1000 --privileged --volume /home/notid/dev/integreat/data/solr:/var/solr -p 8983:8983 bryce-solr
|
||||
|
||||
|
||||
sudo docker run --rm -ti -v ~/dev/integreat/data/solr:/var/solr --network=bridge -p 8983:8983 679918342773.dkr.ecr.us-east-1.amazonaws.com/integreat-solr
|
||||
|
||||
@@ -386,6 +386,20 @@ module "close_auto_invoices_job" {
|
||||
cpu = 512
|
||||
}
|
||||
|
||||
module "sales_summaries_job" {
|
||||
count = var.enable_schedules ? 1 : 0
|
||||
source = "./background-job/"
|
||||
ecs_cluster = var.ecs_cluster
|
||||
task_role_arn = var.task_role_arn
|
||||
stage = var.stage
|
||||
schedule = "rate(1 day)"
|
||||
job_name = "sales-summaries"
|
||||
execution_role_arn = var.execution_role_arn
|
||||
use_schedule = true
|
||||
memory = 4096
|
||||
cpu = 2048
|
||||
}
|
||||
|
||||
module "yodlee2_accounts_job" {
|
||||
count = var.enable_schedules ? 1 : 0
|
||||
source = "./background-job/"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
aws_access_key_id="AKIAINHACMVQJ6NYD26A"
|
||||
aws_secret_access_key="FwdL4TbIC/5H/4mwhQy4iSI/eSewyPgfS1EEt6tL"
|
||||
aws_access_key_id="AKIAZ4TSKSJ27WXFCOWK"
|
||||
aws_secret_access_key="NY1divQYUBELhsNvCeprd4r9MvOXhlNMECnsg7TL"
|
||||
domain="app.integreatconsult.com"
|
||||
invoice_address="invoices@mail.app.integreatconsult.com"
|
||||
base_url="https://app.integreatconsult.com"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"version": 4,
|
||||
"terraform_version": "1.9.2",
|
||||
"serial": 718,
|
||||
"terraform_version": "1.15.1",
|
||||
"serial": 722,
|
||||
"lineage": "9b630886-8cee-a57d-c7a2-4f19f13f9c51",
|
||||
"outputs": {
|
||||
"aws_access_key_id": {
|
||||
@@ -115,7 +115,8 @@
|
||||
"usage_operation": "RunInstances",
|
||||
"virtualization_type": "hvm"
|
||||
},
|
||||
"sensitive_attributes": []
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -133,7 +134,8 @@
|
||||
"id": "679918342773",
|
||||
"user_id": "AIDAJPUJFTOKO4IRADMV4"
|
||||
},
|
||||
"sensitive_attributes": []
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -181,7 +183,8 @@
|
||||
],
|
||||
"version": "2012-10-17"
|
||||
},
|
||||
"sensitive_attributes": []
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -246,6 +249,7 @@
|
||||
}
|
||||
]
|
||||
],
|
||||
"identity_schema_version": 0,
|
||||
"private": "bnVsbA=="
|
||||
}
|
||||
]
|
||||
@@ -311,6 +315,7 @@
|
||||
}
|
||||
]
|
||||
],
|
||||
"identity_schema_version": 0,
|
||||
"private": "bnVsbA=="
|
||||
}
|
||||
]
|
||||
@@ -346,6 +351,7 @@
|
||||
"type": "gp2"
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJlMmJmYjczMC1lY2FhLTExZTYtOGY4OC0zNDM2M2JjN2M0YzAiOnsiY3JlYXRlIjozMDAwMDAwMDAwMDAsImRlbGV0ZSI6MzAwMDAwMDAwMDAwLCJ1cGRhdGUiOjMwMDAwMDAwMDAwMH19",
|
||||
"dependencies": [
|
||||
"aws_instance.solr_ec2",
|
||||
@@ -445,6 +451,7 @@
|
||||
"wait_for_steady_state": true
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJlMmJmYjczMC1lY2FhLTExZTYtOGY4OC0zNDM2M2JjN2M0YzAiOnsiZGVsZXRlIjoxMjAwMDAwMDAwMDAwfX0=",
|
||||
"dependencies": [
|
||||
"aws_ecs_task_definition.integreat_app",
|
||||
@@ -535,6 +542,7 @@
|
||||
"wait_for_steady_state": true
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJlMmJmYjczMC1lY2FhLTExZTYtOGY4OC0zNDM2M2JjN2M0YzAiOnsiZGVsZXRlIjoxMjAwMDAwMDAwMDAwfX0=",
|
||||
"dependencies": [
|
||||
"aws_ecs_task_definition.solr",
|
||||
@@ -580,6 +588,7 @@
|
||||
"volume": []
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ=="
|
||||
}
|
||||
]
|
||||
@@ -636,6 +645,7 @@
|
||||
]
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ==",
|
||||
"dependencies": [
|
||||
"aws_efs_file_system.solr_storage"
|
||||
@@ -681,6 +691,7 @@
|
||||
"throughput_mode": "bursting"
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "bnVsbA=="
|
||||
}
|
||||
]
|
||||
@@ -719,6 +730,7 @@
|
||||
}
|
||||
]
|
||||
],
|
||||
"identity_schema_version": 0,
|
||||
"dependencies": [
|
||||
"aws_iam_user.app_user"
|
||||
]
|
||||
@@ -744,7 +756,8 @@
|
||||
"tags_all": {},
|
||||
"unique_id": "AIDAINFBWI2I7A3TKPGW2"
|
||||
},
|
||||
"sensitive_attributes": []
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -762,6 +775,7 @@
|
||||
"user": "integreat-prod"
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"dependencies": [
|
||||
"aws_iam_user.app_user"
|
||||
]
|
||||
@@ -908,6 +922,7 @@
|
||||
]
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJlMmJmYjczMC1lY2FhLTExZTYtOGY4OC0zNDM2M2JjN2M0YzAiOnsiY3JlYXRlIjo2MDAwMDAwMDAwMDAsImRlbGV0ZSI6MTIwMDAwMDAwMDAwMCwidXBkYXRlIjo2MDAwMDAwMDAwMDB9LCJzY2hlbWFfdmVyc2lvbiI6IjEifQ==",
|
||||
"dependencies": [
|
||||
"data.aws_ami.amazon_linux_2023"
|
||||
@@ -1014,6 +1029,7 @@
|
||||
"zone_id": "Z35SXDOTRQ7X7K"
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJlMmJmYjczMC1lY2FhLTExZTYtOGY4OC0zNDM2M2JjN2M0YzAiOnsiY3JlYXRlIjo2MDAwMDAwMDAwMDAsImRlbGV0ZSI6NjAwMDAwMDAwMDAwLCJ1cGRhdGUiOjYwMDAwMDAwMDAwMH19"
|
||||
}
|
||||
]
|
||||
@@ -1063,6 +1079,7 @@
|
||||
}
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJlMmJmYjczMC1lY2FhLTExZTYtOGY4OC0zNDM2M2JjN2M0YzAiOnsicmVhZCI6NjAwMDAwMDAwMDAwfX0=",
|
||||
"dependencies": [
|
||||
"aws_lb.integreat_app"
|
||||
@@ -1106,6 +1123,7 @@
|
||||
}
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJlMmJmYjczMC1lY2FhLTExZTYtOGY4OC0zNDM2M2JjN2M0YzAiOnsicmVhZCI6NjAwMDAwMDAwMDAwfX0=",
|
||||
"dependencies": [
|
||||
"aws_acm_certificate.cert",
|
||||
@@ -1173,6 +1191,7 @@
|
||||
"tags_all": {}
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "bnVsbA==",
|
||||
"dependencies": [
|
||||
"aws_acm_certificate.cert",
|
||||
@@ -1242,6 +1261,7 @@
|
||||
"vpc_id": "vpc-b5b7d6ce"
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "bnVsbA=="
|
||||
}
|
||||
]
|
||||
@@ -1320,6 +1340,7 @@
|
||||
"website_endpoint": "data.prod.app.integreatconsult.com.s3-website-us-east-1.amazonaws.com"
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "bnVsbA=="
|
||||
}
|
||||
]
|
||||
@@ -1391,6 +1412,7 @@
|
||||
"website_endpoint": null
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "bnVsbA==",
|
||||
"dependencies": [
|
||||
"data.aws_caller_identity.current"
|
||||
@@ -1489,6 +1511,7 @@
|
||||
"website_endpoint": "app.integreatconsult.com.s3-website-us-east-1.amazonaws.com"
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "bnVsbA=="
|
||||
}
|
||||
]
|
||||
@@ -1560,6 +1583,7 @@
|
||||
"website_endpoint": null
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJlMmJmYjczMC1lY2FhLTExZTYtOGY4OC0zNDM2M2JjN2M0YzAiOnsiY3JlYXRlIjoxMjAwMDAwMDAwMDAwLCJkZWxldGUiOjM2MDAwMDAwMDAwMDAsInJlYWQiOjEyMDAwMDAwMDAwMDAsInVwZGF0ZSI6MTIwMDAwMDAwMDAwMH19"
|
||||
}
|
||||
]
|
||||
@@ -1591,6 +1615,7 @@
|
||||
"topic": []
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"dependencies": [
|
||||
"aws_s3_bucket.invoices",
|
||||
"aws_sqs_queue.integreat-mail",
|
||||
@@ -1613,6 +1638,7 @@
|
||||
"policy": "{\"Statement\":[{\"Action\":\"s3:*\",\"Effect\":\"Allow\",\"Principal\":{\"AWS\":[\"arn:aws:iam::679918342773:role/http-proxy\",\"arn:aws:iam::679918342773:role/datomic-ddb\"]},\"Resource\":[\"arn:aws:s3:::toast.prod.app.integreatconsult.com/*\",\"arn:aws:s3:::toast.prod.app.integreatconsult.com\"],\"Sid\":\"\"}],\"Version\":\"2012-10-17\"}"
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "bnVsbA==",
|
||||
"dependencies": [
|
||||
"aws_s3_bucket.toast_bucket",
|
||||
@@ -1638,6 +1664,7 @@
|
||||
"service_id": "srv-ren22oppkwwryqqr"
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "bnVsbA==",
|
||||
"dependencies": [
|
||||
"aws_instance.solr_ec2",
|
||||
@@ -1685,6 +1712,7 @@
|
||||
"type": "DNS_HTTP"
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "bnVsbA=="
|
||||
}
|
||||
]
|
||||
@@ -1727,6 +1755,7 @@
|
||||
"type": "DNS_HTTP"
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "bnVsbA=="
|
||||
}
|
||||
]
|
||||
@@ -1769,6 +1798,7 @@
|
||||
"type": "DNS_HTTP"
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "bnVsbA=="
|
||||
}
|
||||
]
|
||||
@@ -1810,6 +1840,7 @@
|
||||
"workmail_action": []
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"dependencies": [
|
||||
"aws_s3_bucket.invoices",
|
||||
"aws_ses_receipt_rule_set.main",
|
||||
@@ -1831,7 +1862,8 @@
|
||||
"id": "default-rule-set",
|
||||
"rule_set_name": "default-rule-set"
|
||||
},
|
||||
"sensitive_attributes": []
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1868,6 +1900,7 @@
|
||||
"visibility_timeout_seconds": 30
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "bnVsbA=="
|
||||
}
|
||||
]
|
||||
@@ -1905,6 +1938,7 @@
|
||||
"visibility_timeout_seconds": 30
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"dependencies": [
|
||||
"aws_s3_bucket.invoices",
|
||||
"data.aws_caller_identity.current"
|
||||
@@ -1945,6 +1979,7 @@
|
||||
"visibility_timeout_seconds": 30
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "bnVsbA=="
|
||||
}
|
||||
]
|
||||
@@ -1968,6 +2003,7 @@
|
||||
"volume_id": "vol-0069283d41ff6c010"
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJlMmJmYjczMC1lY2FhLTExZTYtOGY4OC0zNDM2M2JjN2M0YzAiOnsiY3JlYXRlIjozMDAwMDAwMDAwMDAsImRlbGV0ZSI6MzAwMDAwMDAwMDAwfX0=",
|
||||
"dependencies": [
|
||||
"aws_ebs_volume.solr_ec2_storage",
|
||||
@@ -2014,6 +2050,7 @@
|
||||
"volume": []
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ=="
|
||||
}
|
||||
]
|
||||
@@ -2043,6 +2080,7 @@
|
||||
"tags_all": {}
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "bnVsbA=="
|
||||
}
|
||||
]
|
||||
@@ -2107,6 +2145,7 @@
|
||||
"target_id": "close-auto-invoices"
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ==",
|
||||
"dependencies": [
|
||||
"module.close_auto_invoices_job.aws_cloudwatch_event_rule.schedule",
|
||||
@@ -2152,6 +2191,7 @@
|
||||
"volume": []
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ=="
|
||||
}
|
||||
]
|
||||
@@ -2181,6 +2221,7 @@
|
||||
"tags_all": {}
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "bnVsbA=="
|
||||
}
|
||||
]
|
||||
@@ -2245,6 +2286,7 @@
|
||||
"target_id": "import-uploaded-invoices"
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ==",
|
||||
"dependencies": [
|
||||
"module.import_uploaded_invoices_job.aws_cloudwatch_event_rule.schedule",
|
||||
@@ -2290,6 +2332,7 @@
|
||||
"volume": []
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ=="
|
||||
}
|
||||
]
|
||||
@@ -2319,6 +2362,7 @@
|
||||
"tags_all": {}
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "bnVsbA=="
|
||||
}
|
||||
]
|
||||
@@ -2383,6 +2427,7 @@
|
||||
"target_id": "insight-outcome-recommendation"
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ==",
|
||||
"dependencies": [
|
||||
"module.insight_outcome_recommendation_job.aws_cloudwatch_event_rule.schedule",
|
||||
@@ -2428,6 +2473,7 @@
|
||||
"volume": []
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ=="
|
||||
}
|
||||
]
|
||||
@@ -2457,6 +2503,7 @@
|
||||
"tags_all": {}
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "bnVsbA=="
|
||||
}
|
||||
]
|
||||
@@ -2521,6 +2568,7 @@
|
||||
"target_id": "intuit"
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ==",
|
||||
"dependencies": [
|
||||
"module.intuit_job.aws_cloudwatch_event_rule.schedule",
|
||||
@@ -2566,6 +2614,7 @@
|
||||
"volume": []
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ=="
|
||||
}
|
||||
]
|
||||
@@ -2607,6 +2656,7 @@
|
||||
"volume": []
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ=="
|
||||
}
|
||||
]
|
||||
@@ -2636,6 +2686,7 @@
|
||||
"tags_all": {}
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "bnVsbA=="
|
||||
}
|
||||
]
|
||||
@@ -2700,6 +2751,7 @@
|
||||
"target_id": "ntg"
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ==",
|
||||
"dependencies": [
|
||||
"module.ntg_job.aws_cloudwatch_event_rule.schedule",
|
||||
@@ -2745,6 +2797,7 @@
|
||||
"volume": []
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ=="
|
||||
}
|
||||
]
|
||||
@@ -2774,6 +2827,7 @@
|
||||
"tags_all": {}
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "bnVsbA=="
|
||||
}
|
||||
]
|
||||
@@ -2838,6 +2892,7 @@
|
||||
"target_id": "plaid"
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ==",
|
||||
"dependencies": [
|
||||
"module.plaid_job.aws_cloudwatch_event_rule.schedule",
|
||||
@@ -2883,6 +2938,7 @@
|
||||
"volume": []
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ=="
|
||||
}
|
||||
]
|
||||
@@ -2912,6 +2968,7 @@
|
||||
"tags_all": {}
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "bnVsbA=="
|
||||
}
|
||||
]
|
||||
@@ -2976,6 +3033,7 @@
|
||||
"target_id": "reconcile-ledger"
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ==",
|
||||
"dependencies": [
|
||||
"module.reconcile_ledger_job.aws_cloudwatch_event_rule.schedule",
|
||||
@@ -3021,6 +3079,7 @@
|
||||
"volume": []
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ=="
|
||||
}
|
||||
]
|
||||
@@ -3062,6 +3121,148 @@
|
||||
"volume": []
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ=="
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"module": "module.sales_summaries_job[0]",
|
||||
"mode": "managed",
|
||||
"type": "aws_cloudwatch_event_rule",
|
||||
"name": "schedule",
|
||||
"provider": "provider[\"registry.terraform.io/hashicorp/aws\"]",
|
||||
"instances": [
|
||||
{
|
||||
"index_key": 0,
|
||||
"schema_version": 0,
|
||||
"attributes": {
|
||||
"arn": "arn:aws:events:us-east-1:679918342773:rule/sales-summaries-schedule-prod",
|
||||
"description": "",
|
||||
"event_bus_name": "default",
|
||||
"event_pattern": null,
|
||||
"id": "sales-summaries-schedule-prod",
|
||||
"is_enabled": true,
|
||||
"name": "sales-summaries-schedule-prod",
|
||||
"name_prefix": "",
|
||||
"role_arn": "",
|
||||
"schedule_expression": "rate(1 day)",
|
||||
"tags": null,
|
||||
"tags_all": {}
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "bnVsbA=="
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"module": "module.sales_summaries_job[0]",
|
||||
"mode": "managed",
|
||||
"type": "aws_cloudwatch_event_target",
|
||||
"name": "job_target",
|
||||
"provider": "provider[\"registry.terraform.io/hashicorp/aws\"]",
|
||||
"instances": [
|
||||
{
|
||||
"index_key": 0,
|
||||
"schema_version": 1,
|
||||
"attributes": {
|
||||
"arn": "arn:aws:ecs:us-east-1:679918342773:cluster/default",
|
||||
"batch_target": [],
|
||||
"dead_letter_config": [],
|
||||
"ecs_target": [
|
||||
{
|
||||
"capacity_provider_strategy": [],
|
||||
"enable_ecs_managed_tags": false,
|
||||
"enable_execute_command": false,
|
||||
"group": "",
|
||||
"launch_type": "FARGATE",
|
||||
"network_configuration": [
|
||||
{
|
||||
"assign_public_ip": true,
|
||||
"security_groups": [
|
||||
"sg-004e5855310c453a3",
|
||||
"sg-02d167406b1082698"
|
||||
],
|
||||
"subnets": [
|
||||
"subnet-5e675761",
|
||||
"subnet-8519fde2",
|
||||
"subnet-89bab8d4"
|
||||
]
|
||||
}
|
||||
],
|
||||
"ordered_placement_strategy": [],
|
||||
"placement_constraint": [],
|
||||
"platform_version": "",
|
||||
"propagate_tags": "TASK_DEFINITION",
|
||||
"tags": null,
|
||||
"task_count": 1,
|
||||
"task_definition_arn": "arn:aws:ecs:us-east-1:679918342773:task-definition/sales_summaries_prod:1"
|
||||
}
|
||||
],
|
||||
"event_bus_name": "default",
|
||||
"http_target": [],
|
||||
"id": "sales-summaries-schedule-prod-sales-summaries",
|
||||
"input": "",
|
||||
"input_path": "",
|
||||
"input_transformer": [],
|
||||
"kinesis_target": [],
|
||||
"redshift_target": [],
|
||||
"retry_policy": [],
|
||||
"role_arn": "arn:aws:iam::679918342773:role/service-role/Amazon_EventBridge_Invoke_ECS_1758992733",
|
||||
"rule": "sales-summaries-schedule-prod",
|
||||
"run_command_targets": [],
|
||||
"sqs_target": [],
|
||||
"target_id": "sales-summaries"
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ==",
|
||||
"dependencies": [
|
||||
"module.sales_summaries_job.aws_cloudwatch_event_rule.schedule",
|
||||
"module.sales_summaries_job.aws_ecs_task_definition.background_taskdef"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"module": "module.sales_summaries_job[0]",
|
||||
"mode": "managed",
|
||||
"type": "aws_ecs_task_definition",
|
||||
"name": "background_taskdef",
|
||||
"provider": "provider[\"registry.terraform.io/hashicorp/aws\"]",
|
||||
"instances": [
|
||||
{
|
||||
"schema_version": 1,
|
||||
"attributes": {
|
||||
"arn": "arn:aws:ecs:us-east-1:679918342773:task-definition/sales_summaries_prod:1",
|
||||
"arn_without_revision": "arn:aws:ecs:us-east-1:679918342773:task-definition/sales_summaries_prod",
|
||||
"container_definitions": "[{\"cpu\":0,\"dockerLabels\":{\"com.datadoghq.tags.env\":\"prod\",\"com.datadoghq.tags.service\":\"sales-summaries\"},\"environment\":[{\"name\":\"DD_CONTAINER_ENV_AS_TAGS\",\"value\":\"{\\\"INTEGREAT_JOB\\\":\\\"background_job\\\"}\"},{\"name\":\"DD_ENV\",\"value\":\"prod\"},{\"name\":\"DD_SERVICE\",\"value\":\"sales-summaries\"},{\"name\":\"INTEGREAT_JOB\",\"value\":\"sales-summaries\"},{\"name\":\"config\",\"value\":\"/usr/local/config/prod-background-worker.edn\"}],\"essential\":true,\"image\":\"679918342773.dkr.ecr.us-east-1.amazonaws.com/integreat-cloud:prod\",\"logConfiguration\":{\"logDriver\":\"awslogs\",\"options\":{\"awslogs-group\":\"/ecs/integreat-app-prod\",\"awslogs-region\":\"us-east-1\",\"awslogs-stream-prefix\":\"ecs\"}},\"mountPoints\":[],\"name\":\"integreat-app\",\"portMappings\":[{\"containerPort\":9000,\"hostPort\":9000,\"protocol\":\"tcp\"},{\"containerPort\":9090,\"hostPort\":9090,\"protocol\":\"tcp\"}],\"systemControls\":[],\"volumesFrom\":[]},{\"cpu\":0,\"environment\":[{\"name\":\"DD_API_KEY\",\"value\":\"ce10d932c47b358e81081ae67bd8c112\"},{\"name\":\"ECS_FARGATE\",\"value\":\"true\"}],\"essential\":true,\"image\":\"public.ecr.aws/datadog/agent:latest\",\"mountPoints\":[],\"name\":\"datadog-agent\",\"portMappings\":[],\"systemControls\":[],\"volumesFrom\":[]}]",
|
||||
"cpu": "2048",
|
||||
"ephemeral_storage": [],
|
||||
"execution_role_arn": "arn:aws:iam::679918342773:role/ecsTaskExecutionRole",
|
||||
"family": "sales_summaries_prod",
|
||||
"id": "sales_summaries_prod",
|
||||
"inference_accelerator": [],
|
||||
"ipc_mode": "",
|
||||
"memory": "4096",
|
||||
"network_mode": "awsvpc",
|
||||
"pid_mode": "",
|
||||
"placement_constraints": [],
|
||||
"proxy_configuration": [],
|
||||
"requires_compatibilities": [
|
||||
"FARGATE"
|
||||
],
|
||||
"revision": 1,
|
||||
"runtime_platform": [],
|
||||
"skip_destroy": false,
|
||||
"tags": null,
|
||||
"tags_all": {},
|
||||
"task_role_arn": "arn:aws:iam::679918342773:role/datomic-ddb",
|
||||
"volume": []
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ=="
|
||||
}
|
||||
]
|
||||
@@ -3091,6 +3292,7 @@
|
||||
"tags_all": {}
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "bnVsbA=="
|
||||
}
|
||||
]
|
||||
@@ -3155,6 +3357,7 @@
|
||||
"target_id": "square-import-job"
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ==",
|
||||
"dependencies": [
|
||||
"module.square_import_job.aws_cloudwatch_event_rule.schedule",
|
||||
@@ -3200,6 +3403,7 @@
|
||||
"volume": []
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ=="
|
||||
}
|
||||
]
|
||||
@@ -3229,6 +3433,7 @@
|
||||
"tags_all": {}
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "bnVsbA=="
|
||||
}
|
||||
]
|
||||
@@ -3293,6 +3498,7 @@
|
||||
"target_id": "sysco"
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ==",
|
||||
"dependencies": [
|
||||
"module.sysco_job.aws_cloudwatch_event_rule.schedule",
|
||||
@@ -3338,6 +3544,7 @@
|
||||
"volume": []
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ=="
|
||||
}
|
||||
]
|
||||
@@ -3367,6 +3574,7 @@
|
||||
"tags_all": {}
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "bnVsbA=="
|
||||
}
|
||||
]
|
||||
@@ -3431,6 +3639,7 @@
|
||||
"target_id": "vendor-usages"
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ==",
|
||||
"dependencies": [
|
||||
"module.vendor_usages_job.aws_cloudwatch_event_rule.schedule",
|
||||
@@ -3476,6 +3685,7 @@
|
||||
"volume": []
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ=="
|
||||
}
|
||||
]
|
||||
@@ -3517,6 +3727,7 @@
|
||||
"volume": []
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ=="
|
||||
}
|
||||
]
|
||||
@@ -3546,6 +3757,7 @@
|
||||
"tags_all": {}
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "bnVsbA=="
|
||||
}
|
||||
]
|
||||
@@ -3610,6 +3822,7 @@
|
||||
"target_id": "yodlee2"
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ==",
|
||||
"dependencies": [
|
||||
"module.yodlee2_job.aws_cloudwatch_event_rule.schedule",
|
||||
@@ -3655,6 +3868,7 @@
|
||||
"volume": []
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ=="
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"version": 4,
|
||||
"terraform_version": "1.9.2",
|
||||
"serial": 714,
|
||||
"terraform_version": "1.15.1",
|
||||
"serial": 718,
|
||||
"lineage": "9b630886-8cee-a57d-c7a2-4f19f13f9c51",
|
||||
"outputs": {
|
||||
"aws_access_key_id": {
|
||||
@@ -115,7 +115,8 @@
|
||||
"usage_operation": "RunInstances",
|
||||
"virtualization_type": "hvm"
|
||||
},
|
||||
"sensitive_attributes": []
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -133,7 +134,8 @@
|
||||
"id": "679918342773",
|
||||
"user_id": "AIDAJPUJFTOKO4IRADMV4"
|
||||
},
|
||||
"sensitive_attributes": []
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -181,7 +183,8 @@
|
||||
],
|
||||
"version": "2012-10-17"
|
||||
},
|
||||
"sensitive_attributes": []
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -246,6 +249,7 @@
|
||||
}
|
||||
]
|
||||
],
|
||||
"identity_schema_version": 0,
|
||||
"private": "bnVsbA=="
|
||||
}
|
||||
]
|
||||
@@ -311,6 +315,7 @@
|
||||
}
|
||||
]
|
||||
],
|
||||
"identity_schema_version": 0,
|
||||
"private": "bnVsbA=="
|
||||
}
|
||||
]
|
||||
@@ -346,6 +351,7 @@
|
||||
"type": "gp2"
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJlMmJmYjczMC1lY2FhLTExZTYtOGY4OC0zNDM2M2JjN2M0YzAiOnsiY3JlYXRlIjozMDAwMDAwMDAwMDAsImRlbGV0ZSI6MzAwMDAwMDAwMDAwLCJ1cGRhdGUiOjMwMDAwMDAwMDAwMH19",
|
||||
"dependencies": [
|
||||
"aws_instance.solr_ec2",
|
||||
@@ -435,7 +441,7 @@
|
||||
],
|
||||
"tags": {},
|
||||
"tags_all": {},
|
||||
"task_definition": "arn:aws:ecs:us-east-1:679918342773:task-definition/integreat_app_prod:837",
|
||||
"task_definition": "arn:aws:ecs:us-east-1:679918342773:task-definition/integreat_app_prod:841",
|
||||
"timeouts": {
|
||||
"create": null,
|
||||
"delete": null,
|
||||
@@ -445,6 +451,7 @@
|
||||
"wait_for_steady_state": true
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJlMmJmYjczMC1lY2FhLTExZTYtOGY4OC0zNDM2M2JjN2M0YzAiOnsiZGVsZXRlIjoxMjAwMDAwMDAwMDAwfX0=",
|
||||
"dependencies": [
|
||||
"aws_ecs_task_definition.integreat_app",
|
||||
@@ -535,6 +542,7 @@
|
||||
"wait_for_steady_state": true
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJlMmJmYjczMC1lY2FhLTExZTYtOGY4OC0zNDM2M2JjN2M0YzAiOnsiZGVsZXRlIjoxMjAwMDAwMDAwMDAwfX0=",
|
||||
"dependencies": [
|
||||
"aws_ecs_task_definition.solr",
|
||||
@@ -580,6 +588,7 @@
|
||||
"volume": []
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ=="
|
||||
}
|
||||
]
|
||||
@@ -636,6 +645,7 @@
|
||||
]
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ==",
|
||||
"dependencies": [
|
||||
"aws_efs_file_system.solr_storage"
|
||||
@@ -667,9 +677,9 @@
|
||||
"provisioned_throughput_in_mibps": 0,
|
||||
"size_in_bytes": [
|
||||
{
|
||||
"value": 1432420352,
|
||||
"value": 1434062848,
|
||||
"value_in_ia": 0,
|
||||
"value_in_standard": 1432420352
|
||||
"value_in_standard": 1434062848
|
||||
}
|
||||
],
|
||||
"tags": {
|
||||
@@ -681,6 +691,7 @@
|
||||
"throughput_mode": "bursting"
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "bnVsbA=="
|
||||
}
|
||||
]
|
||||
@@ -719,6 +730,7 @@
|
||||
}
|
||||
]
|
||||
],
|
||||
"identity_schema_version": 0,
|
||||
"dependencies": [
|
||||
"aws_iam_user.app_user"
|
||||
]
|
||||
@@ -744,7 +756,8 @@
|
||||
"tags_all": {},
|
||||
"unique_id": "AIDAINFBWI2I7A3TKPGW2"
|
||||
},
|
||||
"sensitive_attributes": []
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -762,6 +775,7 @@
|
||||
"user": "integreat-prod"
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"dependencies": [
|
||||
"aws_iam_user.app_user"
|
||||
]
|
||||
@@ -908,6 +922,7 @@
|
||||
]
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJlMmJmYjczMC1lY2FhLTExZTYtOGY4OC0zNDM2M2JjN2M0YzAiOnsiY3JlYXRlIjo2MDAwMDAwMDAwMDAsImRlbGV0ZSI6MTIwMDAwMDAwMDAwMCwidXBkYXRlIjo2MDAwMDAwMDAwMDB9LCJzY2hlbWFfdmVyc2lvbiI6IjEifQ==",
|
||||
"dependencies": [
|
||||
"data.aws_ami.amazon_linux_2023"
|
||||
@@ -1014,6 +1029,7 @@
|
||||
"zone_id": "Z35SXDOTRQ7X7K"
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJlMmJmYjczMC1lY2FhLTExZTYtOGY4OC0zNDM2M2JjN2M0YzAiOnsiY3JlYXRlIjo2MDAwMDAwMDAwMDAsImRlbGV0ZSI6NjAwMDAwMDAwMDAwLCJ1cGRhdGUiOjYwMDAwMDAwMDAwMH19"
|
||||
}
|
||||
]
|
||||
@@ -1063,6 +1079,7 @@
|
||||
}
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJlMmJmYjczMC1lY2FhLTExZTYtOGY4OC0zNDM2M2JjN2M0YzAiOnsicmVhZCI6NjAwMDAwMDAwMDAwfX0=",
|
||||
"dependencies": [
|
||||
"aws_lb.integreat_app"
|
||||
@@ -1106,6 +1123,7 @@
|
||||
}
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJlMmJmYjczMC1lY2FhLTExZTYtOGY4OC0zNDM2M2JjN2M0YzAiOnsicmVhZCI6NjAwMDAwMDAwMDAwfX0=",
|
||||
"dependencies": [
|
||||
"aws_acm_certificate.cert",
|
||||
@@ -1173,6 +1191,7 @@
|
||||
"tags_all": {}
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "bnVsbA==",
|
||||
"dependencies": [
|
||||
"aws_acm_certificate.cert",
|
||||
@@ -1242,6 +1261,7 @@
|
||||
"vpc_id": "vpc-b5b7d6ce"
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "bnVsbA=="
|
||||
}
|
||||
]
|
||||
@@ -1320,6 +1340,7 @@
|
||||
"website_endpoint": "data.prod.app.integreatconsult.com.s3-website-us-east-1.amazonaws.com"
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "bnVsbA=="
|
||||
}
|
||||
]
|
||||
@@ -1391,6 +1412,7 @@
|
||||
"website_endpoint": null
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "bnVsbA==",
|
||||
"dependencies": [
|
||||
"data.aws_caller_identity.current"
|
||||
@@ -1489,6 +1511,7 @@
|
||||
"website_endpoint": "app.integreatconsult.com.s3-website-us-east-1.amazonaws.com"
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "bnVsbA=="
|
||||
}
|
||||
]
|
||||
@@ -1560,6 +1583,7 @@
|
||||
"website_endpoint": null
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJlMmJmYjczMC1lY2FhLTExZTYtOGY4OC0zNDM2M2JjN2M0YzAiOnsiY3JlYXRlIjoxMjAwMDAwMDAwMDAwLCJkZWxldGUiOjM2MDAwMDAwMDAwMDAsInJlYWQiOjEyMDAwMDAwMDAwMDAsInVwZGF0ZSI6MTIwMDAwMDAwMDAwMH19"
|
||||
}
|
||||
]
|
||||
@@ -1591,6 +1615,7 @@
|
||||
"topic": []
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"dependencies": [
|
||||
"aws_s3_bucket.invoices",
|
||||
"aws_sqs_queue.integreat-mail",
|
||||
@@ -1613,6 +1638,7 @@
|
||||
"policy": "{\"Statement\":[{\"Action\":\"s3:*\",\"Effect\":\"Allow\",\"Principal\":{\"AWS\":[\"arn:aws:iam::679918342773:role/http-proxy\",\"arn:aws:iam::679918342773:role/datomic-ddb\"]},\"Resource\":[\"arn:aws:s3:::toast.prod.app.integreatconsult.com/*\",\"arn:aws:s3:::toast.prod.app.integreatconsult.com\"],\"Sid\":\"\"}],\"Version\":\"2012-10-17\"}"
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "bnVsbA==",
|
||||
"dependencies": [
|
||||
"aws_s3_bucket.toast_bucket",
|
||||
@@ -1638,6 +1664,7 @@
|
||||
"service_id": "srv-ren22oppkwwryqqr"
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "bnVsbA==",
|
||||
"dependencies": [
|
||||
"aws_instance.solr_ec2",
|
||||
@@ -1685,6 +1712,7 @@
|
||||
"type": "DNS_HTTP"
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "bnVsbA=="
|
||||
}
|
||||
]
|
||||
@@ -1727,6 +1755,7 @@
|
||||
"type": "DNS_HTTP"
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "bnVsbA=="
|
||||
}
|
||||
]
|
||||
@@ -1769,6 +1798,7 @@
|
||||
"type": "DNS_HTTP"
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "bnVsbA=="
|
||||
}
|
||||
]
|
||||
@@ -1810,6 +1840,7 @@
|
||||
"workmail_action": []
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"dependencies": [
|
||||
"aws_s3_bucket.invoices",
|
||||
"aws_ses_receipt_rule_set.main",
|
||||
@@ -1831,7 +1862,8 @@
|
||||
"id": "default-rule-set",
|
||||
"rule_set_name": "default-rule-set"
|
||||
},
|
||||
"sensitive_attributes": []
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1868,6 +1900,7 @@
|
||||
"visibility_timeout_seconds": 30
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "bnVsbA=="
|
||||
}
|
||||
]
|
||||
@@ -1905,6 +1938,7 @@
|
||||
"visibility_timeout_seconds": 30
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"dependencies": [
|
||||
"aws_s3_bucket.invoices",
|
||||
"data.aws_caller_identity.current"
|
||||
@@ -1945,6 +1979,7 @@
|
||||
"visibility_timeout_seconds": 30
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "bnVsbA=="
|
||||
}
|
||||
]
|
||||
@@ -1968,6 +2003,7 @@
|
||||
"volume_id": "vol-0069283d41ff6c010"
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJlMmJmYjczMC1lY2FhLTExZTYtOGY4OC0zNDM2M2JjN2M0YzAiOnsiY3JlYXRlIjozMDAwMDAwMDAwMDAsImRlbGV0ZSI6MzAwMDAwMDAwMDAwfX0=",
|
||||
"dependencies": [
|
||||
"aws_ebs_volume.solr_ec2_storage",
|
||||
@@ -2014,6 +2050,7 @@
|
||||
"volume": []
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ=="
|
||||
}
|
||||
]
|
||||
@@ -2043,6 +2080,7 @@
|
||||
"tags_all": {}
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "bnVsbA=="
|
||||
}
|
||||
]
|
||||
@@ -2107,6 +2145,7 @@
|
||||
"target_id": "close-auto-invoices"
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ==",
|
||||
"dependencies": [
|
||||
"module.close_auto_invoices_job.aws_cloudwatch_event_rule.schedule",
|
||||
@@ -2152,144 +2191,7 @@
|
||||
"volume": []
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ=="
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"module": "module.current_balance_cache[0]",
|
||||
"mode": "managed",
|
||||
"type": "aws_cloudwatch_event_rule",
|
||||
"name": "schedule",
|
||||
"provider": "provider[\"registry.terraform.io/hashicorp/aws\"]",
|
||||
"instances": [
|
||||
{
|
||||
"index_key": 0,
|
||||
"schema_version": 0,
|
||||
"attributes": {
|
||||
"arn": "arn:aws:events:us-east-1:679918342773:rule/current-balance-cache-schedule-prod",
|
||||
"description": "",
|
||||
"event_bus_name": "default",
|
||||
"event_pattern": null,
|
||||
"id": "current-balance-cache-schedule-prod",
|
||||
"is_enabled": true,
|
||||
"name": "current-balance-cache-schedule-prod",
|
||||
"name_prefix": "",
|
||||
"role_arn": "",
|
||||
"schedule_expression": "rate(30 minutes)",
|
||||
"tags": {},
|
||||
"tags_all": {}
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"private": "bnVsbA=="
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"module": "module.current_balance_cache[0]",
|
||||
"mode": "managed",
|
||||
"type": "aws_cloudwatch_event_target",
|
||||
"name": "job_target",
|
||||
"provider": "provider[\"registry.terraform.io/hashicorp/aws\"]",
|
||||
"instances": [
|
||||
{
|
||||
"index_key": 0,
|
||||
"schema_version": 1,
|
||||
"attributes": {
|
||||
"arn": "arn:aws:ecs:us-east-1:679918342773:cluster/default",
|
||||
"batch_target": [],
|
||||
"dead_letter_config": [],
|
||||
"ecs_target": [
|
||||
{
|
||||
"capacity_provider_strategy": [],
|
||||
"enable_ecs_managed_tags": false,
|
||||
"enable_execute_command": false,
|
||||
"group": "",
|
||||
"launch_type": "FARGATE",
|
||||
"network_configuration": [
|
||||
{
|
||||
"assign_public_ip": true,
|
||||
"security_groups": [
|
||||
"sg-004e5855310c453a3",
|
||||
"sg-02d167406b1082698"
|
||||
],
|
||||
"subnets": [
|
||||
"subnet-5e675761",
|
||||
"subnet-8519fde2",
|
||||
"subnet-89bab8d4"
|
||||
]
|
||||
}
|
||||
],
|
||||
"ordered_placement_strategy": [],
|
||||
"placement_constraint": [],
|
||||
"platform_version": "",
|
||||
"propagate_tags": "TASK_DEFINITION",
|
||||
"tags": {},
|
||||
"task_count": 1,
|
||||
"task_definition_arn": "arn:aws:ecs:us-east-1:679918342773:task-definition/current_balance_cache_prod:3"
|
||||
}
|
||||
],
|
||||
"event_bus_name": "default",
|
||||
"http_target": [],
|
||||
"id": "current-balance-cache-schedule-prod-current-balance-cache",
|
||||
"input": "",
|
||||
"input_path": "",
|
||||
"input_transformer": [],
|
||||
"kinesis_target": [],
|
||||
"redshift_target": [],
|
||||
"retry_policy": [],
|
||||
"role_arn": "arn:aws:iam::679918342773:role/service-role/Amazon_EventBridge_Invoke_ECS_1758992733",
|
||||
"rule": "current-balance-cache-schedule-prod",
|
||||
"run_command_targets": [],
|
||||
"sqs_target": [],
|
||||
"target_id": "current-balance-cache"
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ==",
|
||||
"dependencies": [
|
||||
"module.current_balance_cache.aws_cloudwatch_event_rule.schedule",
|
||||
"module.current_balance_cache.aws_ecs_task_definition.background_taskdef"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"module": "module.current_balance_cache[0]",
|
||||
"mode": "managed",
|
||||
"type": "aws_ecs_task_definition",
|
||||
"name": "background_taskdef",
|
||||
"provider": "provider[\"registry.terraform.io/hashicorp/aws\"]",
|
||||
"instances": [
|
||||
{
|
||||
"schema_version": 1,
|
||||
"attributes": {
|
||||
"arn": "arn:aws:ecs:us-east-1:679918342773:task-definition/current_balance_cache_prod:3",
|
||||
"arn_without_revision": "arn:aws:ecs:us-east-1:679918342773:task-definition/current_balance_cache_prod",
|
||||
"container_definitions": "[{\"cpu\":0,\"dockerLabels\":{\"com.datadoghq.tags.env\":\"prod\",\"com.datadoghq.tags.service\":\"current-balance-cache\"},\"environment\":[{\"name\":\"DD_CONTAINER_ENV_AS_TAGS\",\"value\":\"{\\\"INTEGREAT_JOB\\\":\\\"background_job\\\"}\"},{\"name\":\"DD_ENV\",\"value\":\"prod\"},{\"name\":\"DD_SERVICE\",\"value\":\"current-balance-cache\"},{\"name\":\"INTEGREAT_JOB\",\"value\":\"current-balance-cache\"},{\"name\":\"config\",\"value\":\"/usr/local/config/prod-background-worker.edn\"}],\"essential\":true,\"image\":\"679918342773.dkr.ecr.us-east-1.amazonaws.com/integreat-cloud:prod\",\"logConfiguration\":{\"logDriver\":\"awslogs\",\"options\":{\"awslogs-group\":\"/ecs/integreat-app-prod\",\"awslogs-region\":\"us-east-1\",\"awslogs-stream-prefix\":\"ecs\"}},\"mountPoints\":[],\"name\":\"integreat-app\",\"portMappings\":[{\"containerPort\":9000,\"hostPort\":9000,\"protocol\":\"tcp\"},{\"containerPort\":9090,\"hostPort\":9090,\"protocol\":\"tcp\"}],\"systemControls\":[],\"volumesFrom\":[]},{\"cpu\":0,\"environment\":[{\"name\":\"DD_API_KEY\",\"value\":\"ce10d932c47b358e81081ae67bd8c112\"},{\"name\":\"ECS_FARGATE\",\"value\":\"true\"}],\"essential\":true,\"image\":\"public.ecr.aws/datadog/agent:latest\",\"mountPoints\":[],\"name\":\"datadog-agent\",\"portMappings\":[],\"systemControls\":[],\"volumesFrom\":[]}]",
|
||||
"cpu": "512",
|
||||
"ephemeral_storage": [],
|
||||
"execution_role_arn": "arn:aws:iam::679918342773:role/ecsTaskExecutionRole",
|
||||
"family": "current_balance_cache_prod",
|
||||
"id": "current_balance_cache_prod",
|
||||
"inference_accelerator": [],
|
||||
"ipc_mode": "",
|
||||
"memory": "2048",
|
||||
"network_mode": "awsvpc",
|
||||
"pid_mode": "",
|
||||
"placement_constraints": [],
|
||||
"proxy_configuration": [],
|
||||
"requires_compatibilities": [
|
||||
"FARGATE"
|
||||
],
|
||||
"revision": 3,
|
||||
"runtime_platform": [],
|
||||
"skip_destroy": false,
|
||||
"tags": {},
|
||||
"tags_all": {},
|
||||
"task_role_arn": "arn:aws:iam::679918342773:role/datomic-ddb",
|
||||
"volume": []
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ=="
|
||||
}
|
||||
]
|
||||
@@ -2319,6 +2221,7 @@
|
||||
"tags_all": {}
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "bnVsbA=="
|
||||
}
|
||||
]
|
||||
@@ -2383,6 +2286,7 @@
|
||||
"target_id": "import-uploaded-invoices"
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ==",
|
||||
"dependencies": [
|
||||
"module.import_uploaded_invoices_job.aws_cloudwatch_event_rule.schedule",
|
||||
@@ -2428,6 +2332,7 @@
|
||||
"volume": []
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ=="
|
||||
}
|
||||
]
|
||||
@@ -2457,6 +2362,7 @@
|
||||
"tags_all": {}
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "bnVsbA=="
|
||||
}
|
||||
]
|
||||
@@ -2521,6 +2427,7 @@
|
||||
"target_id": "insight-outcome-recommendation"
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ==",
|
||||
"dependencies": [
|
||||
"module.insight_outcome_recommendation_job.aws_cloudwatch_event_rule.schedule",
|
||||
@@ -2566,6 +2473,7 @@
|
||||
"volume": []
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ=="
|
||||
}
|
||||
]
|
||||
@@ -2595,6 +2503,7 @@
|
||||
"tags_all": {}
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "bnVsbA=="
|
||||
}
|
||||
]
|
||||
@@ -2659,6 +2568,7 @@
|
||||
"target_id": "intuit"
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ==",
|
||||
"dependencies": [
|
||||
"module.intuit_job.aws_cloudwatch_event_rule.schedule",
|
||||
@@ -2704,6 +2614,7 @@
|
||||
"volume": []
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ=="
|
||||
}
|
||||
]
|
||||
@@ -2745,6 +2656,7 @@
|
||||
"volume": []
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ=="
|
||||
}
|
||||
]
|
||||
@@ -2774,6 +2686,7 @@
|
||||
"tags_all": {}
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "bnVsbA=="
|
||||
}
|
||||
]
|
||||
@@ -2838,6 +2751,7 @@
|
||||
"target_id": "ntg"
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ==",
|
||||
"dependencies": [
|
||||
"module.ntg_job.aws_cloudwatch_event_rule.schedule",
|
||||
@@ -2883,6 +2797,7 @@
|
||||
"volume": []
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ=="
|
||||
}
|
||||
]
|
||||
@@ -2912,6 +2827,7 @@
|
||||
"tags_all": {}
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "bnVsbA=="
|
||||
}
|
||||
]
|
||||
@@ -2976,6 +2892,7 @@
|
||||
"target_id": "plaid"
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ==",
|
||||
"dependencies": [
|
||||
"module.plaid_job.aws_cloudwatch_event_rule.schedule",
|
||||
@@ -3021,6 +2938,7 @@
|
||||
"volume": []
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ=="
|
||||
}
|
||||
]
|
||||
@@ -3050,6 +2968,7 @@
|
||||
"tags_all": {}
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "bnVsbA=="
|
||||
}
|
||||
]
|
||||
@@ -3114,6 +3033,7 @@
|
||||
"target_id": "reconcile-ledger"
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ==",
|
||||
"dependencies": [
|
||||
"module.reconcile_ledger_job.aws_cloudwatch_event_rule.schedule",
|
||||
@@ -3159,6 +3079,7 @@
|
||||
"volume": []
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ=="
|
||||
}
|
||||
]
|
||||
@@ -3200,6 +3121,7 @@
|
||||
"volume": []
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ=="
|
||||
}
|
||||
]
|
||||
@@ -3229,6 +3151,7 @@
|
||||
"tags_all": {}
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "bnVsbA=="
|
||||
}
|
||||
]
|
||||
@@ -3293,6 +3216,7 @@
|
||||
"target_id": "square-import-job"
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ==",
|
||||
"dependencies": [
|
||||
"module.square_import_job.aws_cloudwatch_event_rule.schedule",
|
||||
@@ -3338,6 +3262,7 @@
|
||||
"volume": []
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ=="
|
||||
}
|
||||
]
|
||||
@@ -3367,6 +3292,7 @@
|
||||
"tags_all": {}
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "bnVsbA=="
|
||||
}
|
||||
]
|
||||
@@ -3431,6 +3357,7 @@
|
||||
"target_id": "sysco"
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ==",
|
||||
"dependencies": [
|
||||
"module.sysco_job.aws_cloudwatch_event_rule.schedule",
|
||||
@@ -3476,6 +3403,7 @@
|
||||
"volume": []
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ=="
|
||||
}
|
||||
]
|
||||
@@ -3505,6 +3433,7 @@
|
||||
"tags_all": {}
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "bnVsbA=="
|
||||
}
|
||||
]
|
||||
@@ -3569,6 +3498,7 @@
|
||||
"target_id": "vendor-usages"
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ==",
|
||||
"dependencies": [
|
||||
"module.vendor_usages_job.aws_cloudwatch_event_rule.schedule",
|
||||
@@ -3614,6 +3544,7 @@
|
||||
"volume": []
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ=="
|
||||
}
|
||||
]
|
||||
@@ -3655,6 +3586,7 @@
|
||||
"volume": []
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ=="
|
||||
}
|
||||
]
|
||||
@@ -3684,6 +3616,7 @@
|
||||
"tags_all": {}
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "bnVsbA=="
|
||||
}
|
||||
]
|
||||
@@ -3748,6 +3681,7 @@
|
||||
"target_id": "yodlee2"
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ==",
|
||||
"dependencies": [
|
||||
"module.yodlee2_job.aws_cloudwatch_event_rule.schedule",
|
||||
@@ -3793,6 +3727,7 @@
|
||||
"volume": []
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0,
|
||||
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ=="
|
||||
}
|
||||
]
|
||||
|
||||
193
test/clj/auto_ap/jobs/rekey_square_external_ids_test.clj
Normal file
193
test/clj/auto_ap/jobs/rekey_square_external_ids_test.clj
Normal file
@@ -0,0 +1,193 @@
|
||||
(ns auto-ap.jobs.rekey-square-external-ids-test
|
||||
(:require
|
||||
[auto-ap.datomic :refer [conn]]
|
||||
[auto-ap.integration.util :refer [setup-test-data wrap-setup]]
|
||||
[auto-ap.jobs.rekey-square-external-ids :as sut]
|
||||
[clojure.string]
|
||||
[clojure.test :refer [deftest is testing use-fixtures]]
|
||||
[datomic.api :as dc]))
|
||||
|
||||
(use-fixtures :each wrap-setup)
|
||||
|
||||
(def sales-date #inst "2026-08-01T07:00:00.000-00:00")
|
||||
|
||||
(defn- charge-count []
|
||||
(count (dc/q '[:find ?e :where [?e :charge/external-id]] (dc/db conn))))
|
||||
|
||||
(defn- charges-of [order]
|
||||
(->> (dc/datoms (dc/db conn) :eavt order :sales-order/charges)
|
||||
(map :v)
|
||||
(map (fn [c] {:eid c
|
||||
:key (:v (first (dc/datoms (dc/db conn) :eavt c :charge/external-id)))
|
||||
:total (:v (first (dc/datoms (dc/db conn) :eavt c :charge/total)))
|
||||
:tip (:v (first (dc/datoms (dc/db conn) :eavt c :charge/tip)))}))
|
||||
vec))
|
||||
|
||||
(defn- parents-of [charge]
|
||||
(reduce (fn [n _] (inc n)) 0 (dc/datoms (dc/db conn) :vaet charge :sales-order/charges)))
|
||||
|
||||
(defn- two-orders-sharing-one-charge []
|
||||
(let [{:strs [test-client-id]} (setup-test-data [])
|
||||
other (get-in @(dc/transact conn [{:db/id "other" :client/code "NGCC"}]) [:tempids "other"])
|
||||
tx @(dc/transact conn [{:db/id "charge"
|
||||
:charge/external-id "square/charge/shared1"
|
||||
:charge/type-name "CARD"
|
||||
:charge/total 120.0
|
||||
:charge/tip 20.0}
|
||||
{:db/id "order-a"
|
||||
:sales-order/external-id "square/order/NGCD-CD-o1"
|
||||
:sales-order/client test-client-id
|
||||
:sales-order/location "CD"
|
||||
:sales-order/date sales-date
|
||||
:sales-order/charges ["charge"]}
|
||||
{:db/id "order-b"
|
||||
:sales-order/external-id "square/order/NGCC-CC-o1"
|
||||
:sales-order/client other
|
||||
:sales-order/location "CC"
|
||||
:sales-order/date sales-date
|
||||
:sales-order/charges ["charge"]}])]
|
||||
{:order-a (get-in tx [:tempids "order-a"])
|
||||
:order-b (get-in tx [:tempids "order-b"])
|
||||
:charge (get-in tx [:tempids "charge"])
|
||||
:code-a (:client/code (dc/entity (dc/db conn) test-client-id))
|
||||
:code-b "NGCC"}))
|
||||
|
||||
(deftest a-shared-charge-starts-with-two-parents
|
||||
(testing "the condition under test really exists before the split runs"
|
||||
(let [{:keys [charge]} (two-orders-sharing-one-charge)]
|
||||
(is (= 1 (charge-count)))
|
||||
(is (= 2 (parents-of charge))
|
||||
"one charge entity, referenced by both clients' orders"))))
|
||||
|
||||
(deftest split-gives-each-order-its-own-charge
|
||||
(testing "each order ends up with its own charge, scoped to its own client, carrying the same
|
||||
amounts — so the component relationship means what it says and retracting one order
|
||||
cannot delete the other's payment"
|
||||
(let [{:keys [order-a order-b code-a code-b]} (two-orders-sharing-one-charge)
|
||||
result (sut/split-and-rekey-charges! [order-a order-b] 100)]
|
||||
(is (= {:rekeyed 1 :cloned 1} result) "first order keeps it, second gets a copy")
|
||||
(is (= 2 (charge-count)) "exactly one new entity was created")
|
||||
|
||||
(let [a (charges-of order-a)
|
||||
b (charges-of order-b)]
|
||||
(is (= 1 (count a)))
|
||||
(is (= 1 (count b)))
|
||||
(is (= (str "square/charge/" code-a "-CD-shared1") (:key (first a))))
|
||||
(is (= (str "square/charge/" code-b "-CC-shared1") (:key (first b))))
|
||||
(is (not= (:eid (first a)) (:eid (first b))) "two distinct entities")
|
||||
(is (= 120.0 (:total (first a)) (:total (first b))) "amounts copied")
|
||||
(is (= 20.0 (:tip (first a)) (:tip (first b))) "tips copied")
|
||||
(is (= 1 (parents-of (:eid (first a)))))
|
||||
(is (= 1 (parents-of (:eid (first b))))
|
||||
"no charge has more than one parent order any more")))))
|
||||
|
||||
(deftest split-leaves-an-unshared-charge-alone
|
||||
(testing "an order that already owns its charge outright is only re-keyed, never cloned"
|
||||
(let [{:strs [test-client-id]} (setup-test-data [])
|
||||
tx @(dc/transact conn [{:db/id "charge"
|
||||
:charge/external-id "square/charge/solo1"
|
||||
:charge/total 50.0}
|
||||
{:db/id "order"
|
||||
:sales-order/external-id "square/order/NGCD-CD-o2"
|
||||
:sales-order/client test-client-id
|
||||
:sales-order/location "CD"
|
||||
:sales-order/date sales-date
|
||||
:sales-order/charges ["charge"]}])
|
||||
order (get-in tx [:tempids "order"])
|
||||
code (:client/code (dc/entity (dc/db conn) test-client-id))]
|
||||
(is (= {:rekeyed 1 :cloned 0} (sut/split-and-rekey-charges! [order] 100)))
|
||||
(is (= 1 (charge-count)) "nothing was created")
|
||||
(is (= (str "square/charge/" code "-CD-solo1") (:key (first (charges-of order))))))))
|
||||
|
||||
(deftest split-is-idempotent
|
||||
(testing "re-running over an already-split database changes nothing further"
|
||||
(let [{:keys [order-a order-b]} (two-orders-sharing-one-charge)]
|
||||
(sut/split-and-rekey-charges! [order-a order-b] 100)
|
||||
(let [after-first (charge-count)]
|
||||
(is (= {:rekeyed 0 :cloned 0} (sut/split-and-rekey-charges! [order-a order-b] 100))
|
||||
"every charge already carries the key its order expects, so there is nothing to do")
|
||||
(is (= after-first (charge-count)) "and no further entities appear")))))
|
||||
|
||||
(deftest clone-is-not-double-scoped-across-batches
|
||||
(testing "with a batch size of one, the second order sees a charge already carrying the first
|
||||
client's scope. It must clone using the underlying Square id, not re-scope the scoped
|
||||
key — otherwise the entity ends up keyed NGCD-CD-NGCC-CC-<id>, the importer computes
|
||||
the correct key, misses, and creates a second charge that doubles the tender."
|
||||
(let [{:keys [order-a order-b code-a code-b]} (two-orders-sharing-one-charge)]
|
||||
(sut/split-and-rekey-charges! [order-a order-b] 1)
|
||||
(let [ka (:key (first (charges-of order-a)))
|
||||
kb (:key (first (charges-of order-b)))]
|
||||
(is (= (str "square/charge/" code-a "-CD-shared1") ka))
|
||||
(is (= (str "square/charge/" code-b "-CC-shared1") kb))
|
||||
(is (not (clojure.string/includes? kb (str code-a "-CD")))
|
||||
"the clone carries one scope, not two")
|
||||
(is (= 2 (charge-count)))))))
|
||||
|
||||
(deftest two-orders-of-the-same-client-keep-sharing
|
||||
(testing "one payment covering two of the SAME client's orders is left shared, on purpose.
|
||||
|
||||
There is no second name to give a copy — both orders compute the same one — and a copy
|
||||
would double that client's takings for the day. The component cascade still reaches
|
||||
these, which is why remove-voided-orders needs its own guard."
|
||||
(let [{:strs [test-client-id]} (setup-test-data [])
|
||||
tx @(dc/transact conn [{:db/id "charge"
|
||||
:charge/external-id "square/charge/same1"
|
||||
:charge/total 75.0}
|
||||
{:db/id "o1" :sales-order/external-id "square/order/x-1"
|
||||
:sales-order/client test-client-id :sales-order/location "CD"
|
||||
:sales-order/date sales-date :sales-order/charges ["charge"]}
|
||||
{:db/id "o2" :sales-order/external-id "square/order/x-2"
|
||||
:sales-order/client test-client-id :sales-order/location "CD"
|
||||
:sales-order/date sales-date :sales-order/charges ["charge"]}])
|
||||
o1 (get-in tx [:tempids "o1"]) o2 (get-in tx [:tempids "o2"])
|
||||
code (:client/code (dc/entity (dc/db conn) test-client-id))]
|
||||
(is (= {:rekeyed 1 :cloned 0} (sut/split-and-rekey-charges! [o1 o2] 100))
|
||||
"renamed once, not copied")
|
||||
(is (= 1 (charge-count)) "no copy was made, so the takings are not doubled")
|
||||
(is (= (str "square/charge/" code "-CD-same1") (:key (first (charges-of o1)))))
|
||||
(is (= (:eid (first (charges-of o1))) (:eid (first (charges-of o2))))
|
||||
"both orders still point at the one payment"))))
|
||||
|
||||
(deftest the-month-walk-runs-newest-first-and-leaves-no-gaps
|
||||
(testing "order matters operationally, not just cosmetically: the importer reads recent data, so
|
||||
an interrupted migration is only safe to resume imports against if the newest months
|
||||
are the ones already done. Walking :aevt instead would start in 2019."
|
||||
(let [windows (sut/order-months-newest-first #inst "2026-01-01T12:00:00.000-00:00")
|
||||
starts (map first windows)]
|
||||
(is (seq windows))
|
||||
(is (apply > (map #(.getTime ^java.util.Date %) starts))
|
||||
"strictly descending — newest month first")
|
||||
(is (every? (fn [[[next-start _] [_ prev-end]]]
|
||||
(= (.getTime ^java.util.Date next-start)
|
||||
(+ (.getTime ^java.util.Date prev-end) (* 24 60 60 1000))))
|
||||
(partition 2 1 windows))
|
||||
"each window ends the day before the next one starts, so no order falls between them")
|
||||
(is (every? (fn [[s e]] (.before ^java.util.Date s ^java.util.Date e)) windows)
|
||||
"and every window is non-empty"))))
|
||||
|
||||
(deftest two-orders-of-the-same-client-keep-sharing-across-batches
|
||||
(testing "batch size does not change the same-client rule, which the sibling test cannot show
|
||||
because both its orders land in one batch.
|
||||
|
||||
Once the first order re-keys the charge it also writes :charge/client/:charge/location,
|
||||
so the second order's raw-square-id takes its owner branch, new-key reconstructs the key
|
||||
the charge already has, and the (not= old new-key) guard drops the row before :action is
|
||||
read. A clone here would double that client's takings for the day."
|
||||
(let [{:strs [test-client-id]} (setup-test-data [])
|
||||
tx @(dc/transact conn [{:db/id "charge"
|
||||
:charge/external-id "square/charge/same2"
|
||||
:charge/total 75.0}
|
||||
{:db/id "o1" :sales-order/external-id "square/order/y-1"
|
||||
:sales-order/client test-client-id :sales-order/location "CD"
|
||||
:sales-order/date sales-date :sales-order/charges ["charge"]}
|
||||
{:db/id "o2" :sales-order/external-id "square/order/y-2"
|
||||
:sales-order/client test-client-id :sales-order/location "CD"
|
||||
:sales-order/date sales-date :sales-order/charges ["charge"]}])
|
||||
o1 (get-in tx [:tempids "o1"]) o2 (get-in tx [:tempids "o2"])
|
||||
code (:client/code (dc/entity (dc/db conn) test-client-id))]
|
||||
(is (= {:rekeyed 1 :cloned 0} (sut/split-and-rekey-charges! [o1 o2] 1))
|
||||
"batch size 1 puts the two orders in separate batches, and still no copy is made")
|
||||
(is (= 1 (charge-count)) "one payment, not two")
|
||||
(is (= (str "square/charge/" code "-CD-same2") (:key (first (charges-of o1)))))
|
||||
(is (= (:eid (first (charges-of o1))) (:eid (first (charges-of o2))))
|
||||
"both orders still point at the one payment"))))
|
||||
183
test/clj/auto_ap/jobs/sales_summaries_test.clj
Normal file
183
test/clj/auto_ap/jobs/sales_summaries_test.clj
Normal file
@@ -0,0 +1,183 @@
|
||||
(ns auto-ap.jobs.sales-summaries-test
|
||||
(:require
|
||||
[auto-ap.datomic :refer [conn]]
|
||||
[auto-ap.datomic.sales-summaries :as d-ss]
|
||||
[auto-ap.integration.util :refer [setup-test-data wrap-setup]]
|
||||
[auto-ap.jobs.sales-summaries :as sut]
|
||||
[clojure.test :refer [deftest is testing use-fixtures]]
|
||||
[datomic.api :as dc]))
|
||||
|
||||
(use-fixtures :each wrap-setup)
|
||||
|
||||
(def sales-date #inst "2026-08-01T07:00:00.000-00:00")
|
||||
|
||||
(defn- order
|
||||
"A sales order on `sales-date`, carrying whatever the case under test needs. The external id
|
||||
is Square-shaped by default because `get-service-charges` falls back to it when an order has
|
||||
no `:sales-order/vendor`."
|
||||
[client id attrs]
|
||||
(merge {:db/id (str "order-" id)
|
||||
:sales-order/external-id (str "square/order/TEST-" id)
|
||||
:sales-order/client client
|
||||
:sales-order/date sales-date
|
||||
:sales-order/total 100.0}
|
||||
attrs))
|
||||
|
||||
(defn- charge [id attrs]
|
||||
(merge {:db/id (str "charge-" id)
|
||||
:charge/external-id (str "square/charge/" id)
|
||||
:charge/type-name "CARD"
|
||||
:charge/total 100.0}
|
||||
attrs))
|
||||
|
||||
(defn- tip-for [client]
|
||||
(:ledger-mapped/amount (#'sut/get-tip client sales-date)))
|
||||
|
||||
(defn- service-charges-for [client]
|
||||
(#'sut/get-service-charges client sales-date))
|
||||
|
||||
(defn- enable-service-charges! [client]
|
||||
@(dc/transact conn [{:db/id client
|
||||
:client/feature-flags [sut/service-charges-flag]}]))
|
||||
|
||||
(deftest tip-counts-a-reversal-on-an-untendered-order
|
||||
(testing "a return-only order has no tender to join through, so its negative tip must come
|
||||
from the order or the day credits a tip that was handed back"
|
||||
(let [{:strs [test-client-id]} (setup-test-data [])]
|
||||
@(dc/transact conn [(order test-client-id "return-only" {:sales-order/tip -12.0})])
|
||||
(is (= -12.0 (tip-for test-client-id))))))
|
||||
|
||||
(deftest tip-on-a-tendered-order-still-comes-from-the-tender
|
||||
(testing "the tender carries a tip the order does not — auto-gratuity booked as a service
|
||||
charge. Reading the order instead of the tender would drop it."
|
||||
(let [{:strs [test-client-id]} (setup-test-data [])]
|
||||
@(dc/transact conn [(order test-client-id "tendered"
|
||||
{:sales-order/tip 0.0
|
||||
:sales-order/charges [(charge "tendered" {:charge/tip 50.0})]})])
|
||||
(is (= 50.0 (tip-for test-client-id))))))
|
||||
|
||||
(deftest tip-on-an-ordinary-order-is-counted-once
|
||||
(testing "an order that agrees with its tender is not double counted by the additive form"
|
||||
(let [{:strs [test-client-id]} (setup-test-data [])]
|
||||
@(dc/transact conn [(order test-client-id "ordinary"
|
||||
{:sales-order/tip 5.0
|
||||
:sales-order/charges [(charge "ordinary" {:charge/tip 5.0})]})])
|
||||
(is (= 5.0 (tip-for test-client-id))))))
|
||||
|
||||
(deftest service-charges-need-the-feature-flag
|
||||
(testing "without the flag the summary behaves exactly as it does today"
|
||||
(let [{:strs [test-client-id]} (setup-test-data [])]
|
||||
@(dc/transact conn [(order test-client-id "square-sc"
|
||||
{:sales-order/vendor :vendor/ccp-square
|
||||
:sales-order/service-charge 50.0})])
|
||||
(is (nil? (service-charges-for test-client-id))))))
|
||||
|
||||
(deftest service-charges-credit-square-orders
|
||||
(testing "a service charge rides along in the tender, so it needs a credit to match"
|
||||
(let [{:strs [test-client-id]} (setup-test-data [])]
|
||||
(enable-service-charges! test-client-id)
|
||||
@(dc/transact conn [(order test-client-id "square-sc"
|
||||
{:sales-order/vendor :vendor/ccp-square
|
||||
:sales-order/service-charge 50.0})])
|
||||
(let [item (service-charges-for test-client-id)]
|
||||
(is (= 50.0 (:ledger-mapped/amount item)))
|
||||
(is (= :ledger-side/credit (:ledger-mapped/ledger-side item)))
|
||||
(is (= "Service Charges" (:sales-summary-item/category item)))))))
|
||||
|
||||
(deftest service-charges-count-both-signs
|
||||
(testing "a returned catering fee arrives as a negative service charge and is subtracted back
|
||||
out of returns, so dropping negatives loses the reversal"
|
||||
(let [{:strs [test-client-id]} (setup-test-data [])]
|
||||
(enable-service-charges! test-client-id)
|
||||
@(dc/transact conn [(order test-client-id "refunded-fee"
|
||||
{:sales-order/vendor :vendor/ccp-square
|
||||
:sales-order/service-charge -140.0})])
|
||||
(is (= -140.0 (:ledger-mapped/amount (service-charges-for test-client-id)))))))
|
||||
|
||||
(deftest service-charges-exclude-non-square-vendors
|
||||
(testing "ezCater service charges are commission deducted from the restaurant rather than
|
||||
collected from the diner, so crediting them would make the day worse"
|
||||
(let [{:strs [test-client-id]} (setup-test-data [])]
|
||||
(enable-service-charges! test-client-id)
|
||||
@(dc/transact conn [(order test-client-id "ezcater-sc"
|
||||
{:sales-order/external-id "ezcater/order/TEST-ezcater-sc"
|
||||
:sales-order/vendor :vendor/ccp-ezcater
|
||||
:sales-order/service-charge -75.0})])
|
||||
(is (nil? (service-charges-for test-client-id))))))
|
||||
|
||||
(deftest service-charges-recognise-square-orders-that-carry-no-vendor
|
||||
(testing "whole eras of Square orders have no :sales-order/vendor at all; a gate on vendor
|
||||
alone would silently credit nothing"
|
||||
(let [{:strs [test-client-id]} (setup-test-data [])]
|
||||
(enable-service-charges! test-client-id)
|
||||
@(dc/transact conn [(order test-client-id "vendorless" {:sales-order/service-charge 12.5})])
|
||||
(is (= 12.5 (:ledger-mapped/amount (service-charges-for test-client-id)))))))
|
||||
|
||||
(deftest service-charges-ignore-vendorless-orders-from-other-sources
|
||||
(testing "the external id fallback is Square-specific, not a catch-all for missing vendors"
|
||||
(let [{:strs [test-client-id]} (setup-test-data [])]
|
||||
(enable-service-charges! test-client-id)
|
||||
@(dc/transact conn [(order test-client-id "ezcater-vendorless"
|
||||
{:sales-order/external-id "ezcater/order/TEST-ezcater-vendorless"
|
||||
:sales-order/service-charge -75.0})])
|
||||
(is (nil? (service-charges-for test-client-id))))))
|
||||
|
||||
(defn- refund
|
||||
"A card refund on `sales-date`. The client+date tuple is set explicitly because
|
||||
`scan-sales-refunds` walks that index rather than the plain attributes."
|
||||
[client id total]
|
||||
{:db/id (str "refund-" id)
|
||||
:sales-refund/external-id (str "square/refund/TEST-" id)
|
||||
:sales-refund/client client
|
||||
:sales-refund/date sales-date
|
||||
:sales-refund/client+date [client sales-date]
|
||||
:sales-refund/type "CARD"
|
||||
:sales-refund/total total})
|
||||
|
||||
(defn- returns-for [client]
|
||||
(#'sut/get-returns client sales-date))
|
||||
|
||||
(deftest a-refund-with-no-sales-leaves-the-day-out-of-balance
|
||||
(testing "deliberate, and load-bearing. Booking a return against the day's refunds would close
|
||||
it and is tempting for that reason. But a day with refunds and no sales at all is
|
||||
overwhelmingly a day whose ORDERS WERE NEVER IMPORTED — on a restored copy of
|
||||
production, 132 of 156 such days fell before their client's first ever synced order.
|
||||
Balancing them would turn the only signal that a client's sales are missing into
|
||||
silence. If this test starts failing, read the rollout plan before changing it."
|
||||
(let [{:strs [test-client-id]} (setup-test-data [])]
|
||||
@(dc/transact conn [(refund test-client-id "no-sales" 40.0)])
|
||||
(is (nil? (returns-for test-client-id))
|
||||
"no return is invented for a day that recorded no sales")
|
||||
(is (= -40.0 (d-ss/imbalance (sut/get-refund-items test-client-id sales-date)))
|
||||
"so the day stays out of balance by the refunded amount, visibly"))))
|
||||
|
||||
(deftest a-day-that-traded-books-its-own-return
|
||||
(testing "the ordinary case: the return comes from the day's orders, never from its refunds"
|
||||
(let [{:strs [test-client-id]} (setup-test-data [])]
|
||||
@(dc/transact conn [(order test-client-id "traded" {:sales-order/returns 7.0})
|
||||
(refund test-client-id "same-day" 40.0)])
|
||||
(is (= 7.0 (:ledger-mapped/amount (returns-for test-client-id)))))))
|
||||
|
||||
(deftest dirty-summaries-stop-at-the-client-boundary
|
||||
(testing "every dirty day for the client is returned, and none belonging to another client.
|
||||
|
||||
:sales-summary/client+dirty sorts by client, so an unbounded index scan would walk
|
||||
every later client's summaries too — correct, but quadratic in the summary count."
|
||||
(let [{:strs [test-client-id]} (setup-test-data [])
|
||||
other (get-in @(dc/transact conn [{:db/id "other" :client/code (str "OTHER" (rand-int 100000))}])
|
||||
[:tempids "other"])
|
||||
day (fn [client d dirty?]
|
||||
{:sales-summary/client client
|
||||
:sales-summary/date d
|
||||
:sales-summary/dirty dirty?})]
|
||||
@(dc/transact conn [(day test-client-id #inst "2026-08-01T07:00:00.000-00:00" true)
|
||||
(day test-client-id #inst "2026-08-02T07:00:00.000-00:00" true)
|
||||
(day test-client-id #inst "2026-08-03T07:00:00.000-00:00" false)
|
||||
(day other #inst "2026-08-01T07:00:00.000-00:00" true)
|
||||
(day other #inst "2026-08-02T07:00:00.000-00:00" true)])
|
||||
(let [mine (sut/dirty-sales-summaries test-client-id)]
|
||||
(is (= 2 (count mine)) "both dirty days, and not the clean one")
|
||||
(is (every? #(= test-client-id (:db/id (:sales-summary/client %))) mine)
|
||||
"and nothing belonging to the other client"))
|
||||
(is (= 2 (count (sut/dirty-sales-summaries other)))
|
||||
"the other client's own dirty days are still found"))))
|
||||
@@ -70,3 +70,24 @@
|
||||
(is (= "NICK THE GREEK" (:customer-identifier result)))
|
||||
(is (= "600 VISTA WAY" (str/trim (:account-number result))))
|
||||
(is (= "946.24" (:total result)))))))
|
||||
|
||||
(deftest parse-reel-produce-statement-28676
|
||||
(testing "Should parse the Reel Produce statement layout that no longer prints 'Reel Produce' on the page"
|
||||
(let [pdf-file (io/file "dev-resources/Statement1_from_REEL_Produce_Inc.28676.pdf")
|
||||
pdf-text (:out (clojure.java.shell/sh "pdftotext" "-layout" (str pdf-file) "-"))
|
||||
results (sut/parse pdf-text)]
|
||||
(is (seq results) "Template should match and return results")
|
||||
(is (= 7 (count results)) "Should parse 7 invoices from statement")
|
||||
(doseq [result results]
|
||||
(is (= "Reel Produce" (:vendor-code result)))
|
||||
(is (= "Sushi Confidential - San Jose" (:customer-identifier result))))
|
||||
(is (= ["454379" "454826" "455120" "455683" "456654" "456774" "457171"]
|
||||
(mapv :invoice-number results)))
|
||||
(is (= ["1003.10" "530.85" "605.00" "1187.40" "164.00" "675.60" "265.75"]
|
||||
(mapv :total results)))
|
||||
;; totals add up to the statement's $4,431.70 amount due
|
||||
(is (= 4431.70 (->> results (map #(Double/parseDouble (:total %))) (reduce +))))
|
||||
(let [d (:date (first results))]
|
||||
(is (= 2026 (time/year d)))
|
||||
(is (= 6 (time/month d)))
|
||||
(is (= 23 (time/day d)))))))
|
||||
|
||||
201
test/clj/auto_ap/square/core3_test.clj
Normal file
201
test/clj/auto_ap/square/core3_test.clj
Normal file
@@ -0,0 +1,201 @@
|
||||
(ns auto-ap.square.core3-test
|
||||
(:require
|
||||
[auto-ap.datomic :refer [conn]]
|
||||
[auto-ap.integration.util :refer [setup-test-data wrap-setup]]
|
||||
[auto-ap.square.core3 :as sut]
|
||||
[clojure.test :refer [deftest is testing use-fixtures]]
|
||||
[datomic.api :as dc]))
|
||||
|
||||
(use-fixtures :each wrap-setup)
|
||||
|
||||
(def client {:client/code "NGCD"})
|
||||
(def location {:square-location/client-location "CD"})
|
||||
|
||||
(defn- refund-count []
|
||||
(count (dc/q '[:find ?e :where [?e :sales-refund/external-id]] (dc/db conn))))
|
||||
|
||||
(defn- resolve-refund [id]
|
||||
(sut/existing-id (dc/db conn) :sales-refund/external-id "square/refund/" client location id))
|
||||
|
||||
(deftest scoped-key-carries-client-and-location
|
||||
(testing "the same shape sales order keys already use, so a shared location cannot contend"
|
||||
(is (= "square/refund/NGCD-CD-abc" (sut/scoped-key "square/refund/" client location "abc")))
|
||||
(is (= "square/charge/NGCD-CD-xyz" (sut/scoped-key "square/charge/" client location "xyz")))))
|
||||
|
||||
(deftest legacy-keyed-entity-is-updated-not-duplicated
|
||||
(testing "an entity still carrying its unscoped key is found and re-keyed in place.
|
||||
|
||||
This is the sharpest hazard in the migration: these external ids are
|
||||
:db.unique/identity, so writing the new key without resolving the old one first
|
||||
matches nothing and creates a second entity, orphaning the original."
|
||||
(setup-test-data [])
|
||||
@(dc/transact conn [{:db/id "r"
|
||||
:sales-refund/external-id "square/refund/abc"
|
||||
:sales-refund/total 10.0}])
|
||||
(is (= 1 (refund-count)))
|
||||
(let [eid (resolve-refund "abc")]
|
||||
(is (some? eid) "resolves an entity carrying the legacy key")
|
||||
@(dc/transact conn [{:db/id eid
|
||||
:sales-refund/external-id (sut/scoped-key "square/refund/" client location "abc")
|
||||
:sales-refund/total 10.0}])
|
||||
(is (= 1 (refund-count)) "no second entity was created")
|
||||
(is (= eid (dc/entid (dc/db conn) [:sales-refund/external-id "square/refund/NGCD-CD-abc"]))
|
||||
"the same entity now answers to the scoped key")
|
||||
(is (nil? (dc/entid (dc/db conn) [:sales-refund/external-id "square/refund/abc"]))
|
||||
"and no longer to the legacy one"))))
|
||||
|
||||
(deftest already-scoped-entity-resolves-by-its-new-key
|
||||
(testing "re-running the importer after migration finds the entity by the scoped key, so the
|
||||
migration is not undone and nothing is duplicated"
|
||||
(setup-test-data [])
|
||||
@(dc/transact conn [{:db/id "r"
|
||||
:sales-refund/external-id "square/refund/NGCD-CD-abc"
|
||||
:sales-refund/total 10.0}])
|
||||
(is (= (dc/entid (dc/db conn) [:sales-refund/external-id "square/refund/NGCD-CD-abc"])
|
||||
(resolve-refund "abc")))
|
||||
(is (= 1 (refund-count)))))
|
||||
|
||||
(deftest unknown-id-resolves-to-nothing
|
||||
(testing "a refund never seen before has no id to pin, so the importer creates it fresh"
|
||||
(setup-test-data [])
|
||||
(is (nil? (resolve-refund "never-seen")))))
|
||||
|
||||
(deftest two-clients-on-one-location-get-their-own-entities
|
||||
(testing "the point of the re-key: with the client in the key, a second client importing the
|
||||
same Square refund creates its own entity instead of taking ownership of the first"
|
||||
(setup-test-data [])
|
||||
(let [other {:client/code "NGCC"}
|
||||
other-loc {:square-location/client-location "CC"}]
|
||||
@(dc/transact conn [{:db/id "r"
|
||||
:sales-refund/external-id (sut/scoped-key "square/refund/" client location "shared")
|
||||
:sales-refund/total 10.0}])
|
||||
(is (nil? (sut/existing-id (dc/db conn) :sales-refund/external-id "square/refund/" other other-loc "shared"))
|
||||
"the second client does not resolve onto the first client's entity")
|
||||
@(dc/transact conn [{:db/id "r2"
|
||||
:sales-refund/external-id (sut/scoped-key "square/refund/" other other-loc "shared")
|
||||
:sales-refund/total 10.0}])
|
||||
(is (= 2 (refund-count)) "two stable entities, one per client, rather than one that flips"))))
|
||||
|
||||
(deftest legacy-key-of-another-client-is-not-claimed
|
||||
(testing "the legacy fallback must not hand one client a record that already belongs to another.
|
||||
|
||||
Without this, two clients on one Square location double money in the window between
|
||||
deploying and finishing the migration."
|
||||
(setup-test-data [])
|
||||
(let [tx @(dc/transact conn [{:db/id "mine" :client/code (str "MINE" (rand-int 100000))}
|
||||
{:db/id "theirs" :client/code (str "THEIRS" (rand-int 100000))}])
|
||||
mine {:db/id (get-in tx [:tempids "mine"]) :client/code "MINE"}
|
||||
theirs-id (get-in tx [:tempids "theirs"])]
|
||||
@(dc/transact conn [{:db/id "r"
|
||||
:sales-refund/external-id "square/refund/abc"
|
||||
:sales-refund/client theirs-id
|
||||
:sales-refund/total 10.0}])
|
||||
(is (nil? (sut/existing-id (dc/db conn) :sales-refund/external-id "square/refund/"
|
||||
mine location "abc"))
|
||||
"a legacy-keyed refund owned by another client is left alone")
|
||||
(is (some? (sut/existing-id (dc/db conn) :sales-refund/external-id "square/refund/"
|
||||
{:db/id theirs-id :client/code "THEIRS"} location "abc"))
|
||||
"its own client still resolves it, so re-keying in place still works"))))
|
||||
|
||||
(deftest a-charge-is-owned-by-the-client-of-the-order-that-refers-to-it
|
||||
(testing "charges predating :charge/client still have orders, and those are exactly the ones
|
||||
that could be taken by the wrong client"
|
||||
(let [{:strs [test-client-id]} (setup-test-data [])
|
||||
other (get-in @(dc/transact conn [{:db/id "o" :client/code (str "OTHER" (rand-int 100000))}])
|
||||
[:tempids "o"])]
|
||||
@(dc/transact conn [{:db/id "c" :charge/external-id "square/charge/p1" :charge/total 50.0}
|
||||
{:db/id "ord" :sales-order/external-id "square/order/x-1"
|
||||
:sales-order/client test-client-id :sales-order/location "CD"
|
||||
:sales-order/date #inst "2026-06-03T07:00:00.000-00:00"
|
||||
:sales-order/charges ["c"]}])
|
||||
(is (nil? (sut/existing-id (dc/db conn) :charge/external-id "square/charge/"
|
||||
{:db/id other :client/code "OTHER"} location "p1"))
|
||||
"ownership is read from the referencing order when :charge/client is absent"))))
|
||||
|
||||
(deftest deploy-window-does-not-double-a-second-clients-tender
|
||||
(testing "the P0 this guard exists for, end to end.
|
||||
|
||||
Client A's payout import reaches for a payment whose charge belongs to client B's
|
||||
order. If A were allowed to re-key it, B's next order import would match neither
|
||||
scheme, mint a second charge, and — since :sales-order/charges is cardinality-many —
|
||||
leave B's order holding two charges for one payment."
|
||||
(let [{:strs [test-client-id]} (setup-test-data [])
|
||||
b-code (:client/code (dc/entity (dc/db conn) test-client-id))
|
||||
b {:db/id test-client-id :client/code b-code}
|
||||
b-loc {:square-location/client-location "LB"}
|
||||
a-id (get-in @(dc/transact conn [{:db/id "a" :client/code (str "AAA" (rand-int 100000))}])
|
||||
[:tempids "a"])
|
||||
a {:db/id a-id :client/code (:client/code (dc/entity (dc/db conn) a-id))}
|
||||
a-loc {:square-location/client-location "LA"}
|
||||
tx @(dc/transact conn [{:db/id "x" :charge/external-id "square/charge/P"
|
||||
:charge/total 100.0 :charge/type-name "CARD"
|
||||
:charge/client test-client-id :charge/location "LB"}
|
||||
{:db/id "ob" :sales-order/external-id "square/order/b-1"
|
||||
:sales-order/client test-client-id :sales-order/location "LB"
|
||||
:sales-order/date #inst "2026-06-03T07:00:00.000-00:00"
|
||||
:sales-order/charges ["x"]}])
|
||||
order-b (get-in tx [:tempids "ob"])
|
||||
charges-of (fn [o] (map :v (dc/datoms (dc/db conn) :eavt o :sales-order/charges)))]
|
||||
;; client A's payout import touches the same Square payment
|
||||
@(dc/transact conn [(into {} (remove (comp nil? val))
|
||||
{:charge/external-id (sut/scoped-key "square/charge/" a a-loc "P")
|
||||
:charge/client a-id
|
||||
:charge/location "LA"
|
||||
:db/id (sut/existing-id (dc/db conn) :charge/external-id
|
||||
"square/charge/" a a-loc "P")})])
|
||||
;; client B's order re-imports
|
||||
@(dc/transact conn [{:db/id order-b
|
||||
:sales-order/charges
|
||||
[(sut/tender->charge {:id "b-1" :created_at "2026-06-03T12:00:00Z"}
|
||||
b b-loc {:id "P" :type "CARD"
|
||||
:amount_money {:amount 10000
|
||||
:currency "USD"}})]}])
|
||||
(is (= 1 (count (charges-of order-b)))
|
||||
"B's order still holds exactly one charge for the one payment")
|
||||
(is (= 100.0 (reduce + 0.0 (map #(:charge/total (dc/entity (dc/db conn) %))
|
||||
(charges-of order-b))))
|
||||
"so the day's tender is not doubled")
|
||||
(is (= (str "square/charge/" b-code "-LB-P")
|
||||
(:charge/external-id (dc/entity (dc/db conn) (first (charges-of order-b)))))
|
||||
"and B's own charge was re-keyed in place rather than abandoned"))))
|
||||
|
||||
(deftest payouts-and-shifts-are-client-scoped-too
|
||||
(testing "expected deposits and cash drawer shifts are fetched per location, so two clients on
|
||||
one location collide on them exactly as refunds and charges did"
|
||||
(is (= "square/payout/NGCD-CD-po1"
|
||||
(sut/scoped-key "square/payout/" client location "po1")))
|
||||
(is (= "square/cash-drawer-shift/NGCD-CD-sh1"
|
||||
(sut/scoped-key "square/cash-drawer-shift/" client location "sh1")))))
|
||||
|
||||
(deftest legacy-keyed-deposit-is-updated-not-duplicated
|
||||
(testing "a payout still carrying its unscoped key is found and re-keyed in place"
|
||||
(setup-test-data [])
|
||||
@(dc/transact conn [{:db/id "d"
|
||||
:expected-deposit/external-id "square/payout/po1"
|
||||
:expected-deposit/total 100.0}])
|
||||
(let [eid (sut/existing-id (dc/db conn) :expected-deposit/external-id "square/payout/" client location "po1")]
|
||||
(is (some? eid) "resolves the entity carrying the legacy key")
|
||||
@(dc/transact conn [{:db/id eid
|
||||
:expected-deposit/external-id (sut/scoped-key "square/payout/" client location "po1")
|
||||
:expected-deposit/total 100.0}])
|
||||
(is (= 1 (count (dc/q '[:find ?e :where [?e :expected-deposit/external-id]] (dc/db conn))))
|
||||
"no second deposit was created")
|
||||
(is (nil? (dc/entid (dc/db conn) [:expected-deposit/external-id "square/payout/po1"]))
|
||||
"the legacy key is gone"))))
|
||||
|
||||
(deftest two-clients-get-their-own-deposit
|
||||
(testing "with the client in the key, a second client importing the same Square payout creates
|
||||
its own entity instead of taking ownership of the first"
|
||||
(setup-test-data [])
|
||||
(let [other {:client/code "NGCC"}
|
||||
other-loc {:square-location/client-location "CC"}]
|
||||
@(dc/transact conn [{:db/id "d"
|
||||
:expected-deposit/external-id (sut/scoped-key "square/payout/" client location "shared")
|
||||
:expected-deposit/total 100.0}])
|
||||
(is (nil? (sut/existing-id (dc/db conn) :expected-deposit/external-id "square/payout/" other other-loc "shared"))
|
||||
"the second client does not resolve onto the first client's deposit")
|
||||
@(dc/transact conn [{:db/id "d2"
|
||||
:expected-deposit/external-id (sut/scoped-key "square/payout/" other other-loc "shared")
|
||||
:expected-deposit/total 100.0}])
|
||||
(is (= 2 (count (dc/q '[:find ?e :where [?e :expected-deposit/external-id]] (dc/db conn))))
|
||||
"two stable entities, one per client, rather than one that flips"))))
|
||||
153
test/clj/auto_ap/tools/compare_sales_summaries.clj
Normal file
153
test/clj/auto_ap/tools/compare_sales_summaries.clj
Normal file
@@ -0,0 +1,153 @@
|
||||
(ns auto-ap.tools.compare-sales-summaries
|
||||
"Compares sales summaries between two points in the same database.
|
||||
|
||||
A verification tool, not part of the running application: it lives on the test/dev classpath so
|
||||
nothing in production can depend on it. Load it from a REPL when auditing a recompute.
|
||||
|
||||
The question this exists to answer is narrower than \"did the totals improve\": it is *which
|
||||
days changed, and were any of them already balanced*. A day that was balanced before and still
|
||||
balances after can still have had its line amounts move, and that is a real change to the
|
||||
books even though no red turns green. Counting only balanced/unbalanced transitions would hide
|
||||
it entirely.
|
||||
|
||||
DO NOT USE `compare-against` — OR ANY `d/as-of` DATABASE — TO COMPARE AMOUNTS. The obvious
|
||||
reading is that Datomic keeps every past value, so a recompute can be audited against what was
|
||||
there before with no snapshot. That does not hold here: `:ledger-mapped/amount`,
|
||||
`:ledger-mapped/ledger-side` and `:ledger-mapped/account` are all `:db/noHistory true`, so
|
||||
superseded values are discarded rather than retained. A summary that has since been recomputed
|
||||
reads back through `as-of` with its categories intact and its amounts *absent* — which is
|
||||
indistinguishable from a legitimate all-zero day, and quietly turns every rewritten summary
|
||||
into a false \"was balanced, still balances\".
|
||||
|
||||
To compare amounts, capture `summaries-in` from a live `(d/db conn)` immediately after each
|
||||
run, keep the two captures outside the database, and diff those. `compare-window` is safe when
|
||||
both arguments are live database values; only the historical read is unsound. Categories and
|
||||
which-days-changed do survive `as-of`, since `:sales-summary-item/category` retains history."
|
||||
(:require
|
||||
[auto-ap.datomic :refer [conn]]
|
||||
[auto-ap.datomic.sales-summaries :as d-ss]
|
||||
[clj-time.coerce :as c]
|
||||
[datomic.api :as dc]))
|
||||
|
||||
(def item-read
|
||||
[:sales-summary-item/category
|
||||
:sales-summary-item/manual?
|
||||
:ledger-mapped/amount
|
||||
{:ledger-mapped/ledger-side [:db/ident]}
|
||||
{:ledger-mapped/account [:account/numeric-code]}])
|
||||
|
||||
(defn- cents
|
||||
"Amounts are doubles carrying float noise, so compare them at the cent — the unit the books are
|
||||
actually kept in. Without this, 182.87000000000003 and 182.87 read as a change."
|
||||
[x]
|
||||
(Math/round (* 100.0 (double (or x 0.0)))))
|
||||
|
||||
(defn- line
|
||||
"One item reduced to what a reader would call \"the number\": category, side, amount, account."
|
||||
[item]
|
||||
{:category (:sales-summary-item/category item)
|
||||
:side (get-in item [:ledger-mapped/ledger-side :db/ident])
|
||||
:cents (cents (:ledger-mapped/amount item))
|
||||
:account (get-in item [:ledger-mapped/account :account/numeric-code])})
|
||||
|
||||
(defn summaries-in
|
||||
"`{[client-code date] {:lines … :imbalance … :balanced?}}` for every summary in `[start end)`.
|
||||
|
||||
Keyed by client code and date rather than entity id so the two sides line up even if an entity
|
||||
were recreated between the points being compared."
|
||||
[db start end]
|
||||
(->> (dc/q {:find [(list 'pull '?s [:sales-summary/date
|
||||
{:sales-summary/client [:client/code]}
|
||||
{:sales-summary/items item-read}])]
|
||||
:in '[$ ?start ?end]
|
||||
:where '[[?s :sales-summary/date ?d]
|
||||
[(>= ?d ?start)]
|
||||
[(< ?d ?end)]]}
|
||||
db (c/to-date start) (c/to-date end))
|
||||
(map first)
|
||||
(reduce (fn [acc s]
|
||||
(let [items (map d-ss/<-pulled-item (:sales-summary/items s))]
|
||||
(assoc acc
|
||||
[(get-in s [:sales-summary/client :client/code]) (:sales-summary/date s)]
|
||||
{:lines (frequencies (map line (:sales-summary/items s)))
|
||||
:imbalance (d-ss/imbalance items)
|
||||
:balanced? (d-ss/balanced? items)})))
|
||||
{})))
|
||||
|
||||
(defn- classify
|
||||
"How one client-day differs. `:numbers-changed` is the interesting one — the lines themselves
|
||||
moved, whether or not the day's balance status did."
|
||||
[before after]
|
||||
(cond
|
||||
(nil? before) :added
|
||||
(nil? after) :removed
|
||||
(= (:lines before) (:lines after)) :identical
|
||||
:else :numbers-changed))
|
||||
|
||||
(defn compare-window
|
||||
"Compares every summary in `[start end)` between two database values.
|
||||
|
||||
Returns per-day rows plus the tallies worth reporting, including the one that is easy to miss:
|
||||
days that were **already balanced** and whose numbers moved anyway."
|
||||
[before-db after-db start end]
|
||||
(let [before (summaries-in before-db start end)
|
||||
after (summaries-in after-db start end)
|
||||
rows (for [k (distinct (concat (keys before) (keys after)))
|
||||
:let [b (get before k) a (get after k)]]
|
||||
{:client (first k)
|
||||
:date (second k)
|
||||
:change (classify b a)
|
||||
:was-balanced? (:balanced? b)
|
||||
:now-balanced? (:balanced? a)
|
||||
:before-imbalance (:imbalance b)
|
||||
:after-imbalance (:imbalance a)
|
||||
:lines-before (:lines b)
|
||||
:lines-after (:lines a)})
|
||||
rows (vec rows)
|
||||
changed (filter #(= :numbers-changed (:change %)) rows)]
|
||||
{:rows rows
|
||||
:tally {:compared (count rows)
|
||||
:identical (count (filter #(= :identical (:change %)) rows))
|
||||
:numbers-changed (count changed)
|
||||
:added (count (filter #(= :added (:change %)) rows))
|
||||
:removed (count (filter #(= :removed (:change %)) rows))}
|
||||
:balance-transitions
|
||||
{:unbalanced->balanced (count (filter #(and (false? (:was-balanced? %)) (true? (:now-balanced? %))) rows))
|
||||
:balanced->unbalanced (count (filter #(and (true? (:was-balanced? %)) (false? (:now-balanced? %))) rows))
|
||||
:stayed-balanced (count (filter #(and (true? (:was-balanced? %)) (true? (:now-balanced? %))) rows))
|
||||
:stayed-unbalanced (count (filter #(and (false? (:was-balanced? %)) (false? (:now-balanced? %))) rows))}
|
||||
:previously-balanced-and-changed
|
||||
(->> changed (filter :was-balanced?) vec)}))
|
||||
|
||||
(defn line-diff
|
||||
"Which categories actually moved on one row, as `{category [before-cents after-cents]}`. For
|
||||
reading a handful of rows by hand once the tallies point at them."
|
||||
[row]
|
||||
(let [by-cat (fn [lines] (reduce (fn [m [l n]] (assoc m (:category l) (* n (:cents l)))) {} lines))
|
||||
b (by-cat (:lines-before row))
|
||||
a (by-cat (:lines-after row))]
|
||||
(->> (distinct (concat (keys b) (keys a)))
|
||||
(keep (fn [cat]
|
||||
(let [x (get b cat 0) y (get a cat 0)]
|
||||
(when (not= x y) [cat [(/ x 100.0) (/ y 100.0)]]))))
|
||||
(into {}))))
|
||||
|
||||
(defn compare-against
|
||||
"Compare the current database against its own past value at basis `t`.
|
||||
|
||||
UNSOUND FOR AMOUNTS — see the namespace docstring. The amount, side and account attributes are
|
||||
`:db/noHistory`, so any summary rewritten since `t` reads back with no amounts and appears
|
||||
balanced. Kept only for comparing categories and identifying which days changed."
|
||||
[t start end]
|
||||
(let [db (dc/db conn)]
|
||||
(compare-window (dc/as-of db t) db start end)))
|
||||
|
||||
(comment
|
||||
;; the restore point, i.e. production's own summaries before any of this work
|
||||
(def result (compare-against 209608347
|
||||
(clj-time.core/date-time 2026 7 15)
|
||||
(clj-time.core/date-time 2026 8 14)))
|
||||
(:tally result)
|
||||
(:balance-transitions result)
|
||||
(count (:previously-balanced-and-changed result))
|
||||
(map line-diff (take 3 (:previously-balanced-and-changed result))))
|
||||
Reference in New Issue
Block a user