@arcforgelabs/flow 2026.10.30 → 2026.10.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +2 -0
  2. package/npm-shrinkwrap.json +188 -2
  3. package/package.json +2 -1
  4. package/server/api/alerts.js +49 -0
  5. package/server/api/invoicing.js +125 -7
  6. package/server/app.js +29 -2
  7. package/server/core/alerts.js +157 -0
  8. package/server/core/automations.js +17 -3
  9. package/server/core/history.js +65 -0
  10. package/server/core/invoice-pdf.js +266 -0
  11. package/server/core/invoices.js +2 -2
  12. package/server/core/invoicing-rules.js +26 -1
  13. package/server/core/job-board.js +25 -3
  14. package/server/core/ledger.js +3 -2
  15. package/server/core/line-mapping.js +36 -10
  16. package/server/core/pipeline.js +76 -4
  17. package/server/core/price-list.js +348 -0
  18. package/server/core/references.js +99 -0
  19. package/server/core/sending.js +11 -5
  20. package/server/db/commands.js +42 -0
  21. package/server/db/doctor.js +168 -0
  22. package/server/db/migrations/0014_price_list.sql +34 -0
  23. package/server/db/migrations/0015_alert_dismissals.sql +17 -0
  24. package/server/db/migrations/0016_invoice_reference.sql +1 -0
  25. package/server/db/migrations/meta/0014_snapshot.json +3773 -0
  26. package/server/db/migrations/meta/0015_snapshot.json +3854 -0
  27. package/server/db/migrations/meta/0016_snapshot.json +3861 -0
  28. package/server/db/migrations/meta/_journal.json +21 -0
  29. package/server/db/schema.js +46 -0
  30. package/server/flow-server.js +20 -1
  31. package/server/index.js +2 -1
  32. package/server/integrations/call-guard.js +362 -0
  33. package/server/integrations/port.js +21 -1
  34. package/server/integrations/seazona/feed.js +31 -2
  35. package/server/integrations/seazona/governor.js +50 -122
  36. package/server/integrations/seazona/history/backfill.js +208 -0
  37. package/server/integrations/seazona/history/csv.js +65 -0
  38. package/server/integrations/seazona/history/index.js +584 -0
  39. package/server/integrations/seazona/history/read.js +196 -0
  40. package/server/integrations/seazona/index.js +46 -1
  41. package/server/integrations/seazona/lanes.js +187 -51
  42. package/server/integrations/seazona/migrations/0003_call_guard.sql +2 -0
  43. package/server/integrations/seazona/migrations/0004_history_import.sql +68 -0
  44. package/server/integrations/seazona/migrations/meta/0003_snapshot.json +895 -0
  45. package/server/integrations/seazona/migrations/meta/0004_snapshot.json +1323 -0
  46. package/server/integrations/seazona/migrations/meta/_journal.json +14 -0
  47. package/server/integrations/seazona/patients.js +30 -0
  48. package/server/integrations/seazona/routes.js +145 -0
  49. package/server/integrations/seazona/schema.js +94 -0
  50. package/server/integrations/seazona/settings.js +14 -3
  51. package/server/integrations/seazona/sync-once.js +1 -1
  52. package/server/integrations/seazona/tables.js +4 -0
  53. package/server/integrations/xero/client.js +39 -7
  54. package/server/integrations/xero/drafts.js +46 -8
  55. package/server/integrations/xero/import.js +131 -17
  56. package/server/integrations/xero/index.js +38 -7
  57. package/server/integrations/xero/lookup.js +26 -0
  58. package/server/integrations/xero/migrations/0006_xero_call_guard.sql +26 -0
  59. package/server/integrations/xero/migrations/0007_xero_webhooks.sql +30 -0
  60. package/server/integrations/xero/migrations/meta/_journal.json +14 -0
  61. package/server/integrations/xero/mirror.js +27 -10
  62. package/server/integrations/xero/oauth.js +4 -1
  63. package/server/integrations/xero/reconcile.js +173 -0
  64. package/server/integrations/xero/routes.js +55 -0
  65. package/server/integrations/xero/runtime.js +45 -4
  66. package/server/integrations/xero/settings.js +3 -0
  67. package/server/integrations/xero/webhooks.js +311 -0
  68. package/server/release-channel.js +3 -3
  69. package/server/ui/alerts.js +77 -0
  70. package/server/ui/app-page.js +1 -1
  71. package/server/ui/app.js +63 -21
  72. package/server/ui/clerk-appearance.js +42 -9
  73. package/server/ui/flow.css +78 -9
  74. package/server/ui/invoice-notes.js +26 -0
  75. package/server/ui/invoicing-settings.js +4 -2
  76. package/server/ui/invoicing.js +241 -49
  77. package/server/ui/line-mappings.js +3 -2
  78. package/server/ui/price-list.js +247 -0
  79. package/server/ui/routes.js +1 -1
package/README.md CHANGED
@@ -37,8 +37,10 @@ run a lab.
37
37
  | `docs/INVOICING_RULES.md` | The invoicing rules that draft a job's invoice, and the two ways an invoice goes to Xero |
38
38
  | `docs/INVOICE_RULES.md` | Invoice rules a lab builds (When, If, Then) that make a ready job wait or never draft |
39
39
  | `docs/INVOICE_SENDING.md` | Drafts, Problem and Ready invoices, Mark ready, and Send all ready to the accounting system |
40
+ | `docs/ALERTS.md` | Quiet alerts: the slim line on a page when something there needs a person, and how a person dismisses it |
40
41
  | `docs/AUTOMATIONS.md` | Automations: Flow routines that mark jobs ready to invoice and draft them, each on or off |
41
42
  | `docs/LINE_MAPPING.md` | Line mapping: how each work system line finds its price item, and Price codes in Settings |
43
+ | `docs/PRICE_LIST.md` | The price list: Flow's own codes and prices, their links to Xero items and Seazona products, and the price check |
42
44
  | `docs/RELEASING.md` | Versions and channels, the release workflows, how to cut and roll back a release, and the one-time setup |
43
45
  | `docs/SELF_HOSTING.md` | Running a Flow release with Docker, Node or Replit, the update notice, and Clerk sign-in |
44
46
  | `docs/REPLIT.md` | A Replit workspace of this repository that follows a release channel (`FLOW_DEPLOY_REF`) |
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.30",
3
+ "version": "2026.10.32",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@arcforgelabs/flow",
9
- "version": "2026.10.30",
9
+ "version": "2026.10.32",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@clerk/backend": "3.23.2",
@@ -25,6 +25,7 @@
25
25
  "drizzle-orm": "0.45.3",
26
26
  "express": "5.3.0",
27
27
  "express-rate-limit": "8.7.0",
28
+ "pdfkit": "0.20.2",
28
29
  "pg": "8.23.1"
29
30
  },
30
31
  "bin": {
@@ -656,6 +657,30 @@
656
657
  "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
657
658
  "license": "MIT"
658
659
  },
660
+ "node_modules/@noble/ciphers": {
661
+ "version": "1.3.0",
662
+ "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
663
+ "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
664
+ "license": "MIT",
665
+ "engines": {
666
+ "node": "^14.21.3 || >=16"
667
+ },
668
+ "funding": {
669
+ "url": "https://paulmillr.com/funding/"
670
+ }
671
+ },
672
+ "node_modules/@noble/hashes": {
673
+ "version": "1.8.0",
674
+ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
675
+ "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
676
+ "license": "MIT",
677
+ "engines": {
678
+ "node": "^14.21.3 || >=16"
679
+ },
680
+ "funding": {
681
+ "url": "https://paulmillr.com/funding/"
682
+ }
683
+ },
659
684
  "node_modules/@opentelemetry/api": {
660
685
  "version": "1.9.1",
661
686
  "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz",
@@ -1100,6 +1125,15 @@
1100
1125
  "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
1101
1126
  "license": "MIT"
1102
1127
  },
1128
+ "node_modules/@swc/helpers": {
1129
+ "version": "0.5.23",
1130
+ "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
1131
+ "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
1132
+ "license": "Apache-2.0",
1133
+ "dependencies": {
1134
+ "tslib": "^2.8.0"
1135
+ }
1136
+ },
1103
1137
  "node_modules/@tanstack/query-core": {
1104
1138
  "version": "5.104.4",
1105
1139
  "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.104.4.tgz",
@@ -1132,6 +1166,26 @@
1132
1166
  "node": "18 || 20 || >=22"
1133
1167
  }
1134
1168
  },
1169
+ "node_modules/base64-js": {
1170
+ "version": "1.5.1",
1171
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
1172
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
1173
+ "funding": [
1174
+ {
1175
+ "type": "github",
1176
+ "url": "https://github.com/sponsors/feross"
1177
+ },
1178
+ {
1179
+ "type": "patreon",
1180
+ "url": "https://www.patreon.com/feross"
1181
+ },
1182
+ {
1183
+ "type": "consulting",
1184
+ "url": "https://feross.org/support"
1185
+ }
1186
+ ],
1187
+ "license": "MIT"
1188
+ },
1135
1189
  "node_modules/body-parser": {
1136
1190
  "version": "2.3.0",
1137
1191
  "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
@@ -1168,6 +1222,15 @@
1168
1222
  "node": "20 || >=22"
1169
1223
  }
1170
1224
  },
1225
+ "node_modules/brotli": {
1226
+ "version": "1.3.3",
1227
+ "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
1228
+ "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
1229
+ "license": "MIT",
1230
+ "dependencies": {
1231
+ "base64-js": "^1.1.2"
1232
+ }
1233
+ },
1171
1234
  "node_modules/buffer-from": {
1172
1235
  "version": "1.1.2",
1173
1236
  "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@@ -1213,6 +1276,15 @@
1213
1276
  "url": "https://github.com/sponsors/ljharb"
1214
1277
  }
1215
1278
  },
1279
+ "node_modules/clone": {
1280
+ "version": "2.1.2",
1281
+ "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
1282
+ "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
1283
+ "license": "MIT",
1284
+ "engines": {
1285
+ "node": ">=0.8"
1286
+ }
1287
+ },
1216
1288
  "node_modules/content-disposition": {
1217
1289
  "version": "2.0.1",
1218
1290
  "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz",
@@ -1292,6 +1364,12 @@
1292
1364
  "node": ">=6"
1293
1365
  }
1294
1366
  },
1367
+ "node_modules/dfa": {
1368
+ "version": "1.2.0",
1369
+ "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
1370
+ "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
1371
+ "license": "MIT"
1372
+ },
1295
1373
  "node_modules/dotenv": {
1296
1374
  "version": "17.4.2",
1297
1375
  "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
@@ -1627,12 +1705,24 @@
1627
1705
  "express": ">= 4.11"
1628
1706
  }
1629
1707
  },
1708
+ "node_modules/fast-deep-equal": {
1709
+ "version": "3.1.3",
1710
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
1711
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
1712
+ "license": "MIT"
1713
+ },
1630
1714
  "node_modules/fast-sha256": {
1631
1715
  "version": "1.3.0",
1632
1716
  "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
1633
1717
  "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
1634
1718
  "license": "Unlicense"
1635
1719
  },
1720
+ "node_modules/fflate": {
1721
+ "version": "0.8.3",
1722
+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
1723
+ "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
1724
+ "license": "MIT"
1725
+ },
1636
1726
  "node_modules/finalhandler": {
1637
1727
  "version": "2.1.1",
1638
1728
  "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
@@ -1654,6 +1744,23 @@
1654
1744
  "url": "https://opencollective.com/express"
1655
1745
  }
1656
1746
  },
1747
+ "node_modules/fontkit": {
1748
+ "version": "2.0.4",
1749
+ "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
1750
+ "integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
1751
+ "license": "MIT",
1752
+ "dependencies": {
1753
+ "@swc/helpers": "^0.5.12",
1754
+ "brotli": "^1.3.2",
1755
+ "clone": "^2.1.2",
1756
+ "dfa": "^1.2.0",
1757
+ "fast-deep-equal": "^3.1.3",
1758
+ "restructure": "^3.0.0",
1759
+ "tiny-inflate": "^1.0.3",
1760
+ "unicode-properties": "^1.4.0",
1761
+ "unicode-trie": "^2.0.0"
1762
+ }
1763
+ },
1657
1764
  "node_modules/forwarded": {
1658
1765
  "version": "0.2.0",
1659
1766
  "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -1886,6 +1993,25 @@
1886
1993
  "integrity": "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==",
1887
1994
  "license": "MIT"
1888
1995
  },
1996
+ "node_modules/linebreak": {
1997
+ "version": "1.1.0",
1998
+ "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
1999
+ "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
2000
+ "license": "MIT",
2001
+ "dependencies": {
2002
+ "base64-js": "0.0.8",
2003
+ "unicode-trie": "^2.0.0"
2004
+ }
2005
+ },
2006
+ "node_modules/linebreak/node_modules/base64-js": {
2007
+ "version": "0.0.8",
2008
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
2009
+ "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
2010
+ "license": "MIT",
2011
+ "engines": {
2012
+ "node": ">= 0.4"
2013
+ }
2014
+ },
1889
2015
  "node_modules/lru-cache": {
1890
2016
  "version": "11.5.3",
1891
2017
  "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
@@ -2078,6 +2204,12 @@
2078
2204
  "@oxc-parser/binding-win32-x64-msvc": "0.152.0"
2079
2205
  }
2080
2206
  },
2207
+ "node_modules/pako": {
2208
+ "version": "0.2.9",
2209
+ "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
2210
+ "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
2211
+ "license": "MIT"
2212
+ },
2081
2213
  "node_modules/parseurl": {
2082
2214
  "version": "1.3.3",
2083
2215
  "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -2113,6 +2245,20 @@
2113
2245
  "url": "https://opencollective.com/express"
2114
2246
  }
2115
2247
  },
2248
+ "node_modules/pdfkit": {
2249
+ "version": "0.20.2",
2250
+ "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.20.2.tgz",
2251
+ "integrity": "sha512-Q/w03ICAQyXfHNfTsg1udp0ADerdBN0s7a6XSPL7J7Ro6ABnafoBOCDBstIO9U02mvzHEV8HrvFIw5iV5ejIRA==",
2252
+ "license": "MIT",
2253
+ "dependencies": {
2254
+ "@noble/ciphers": "^1.3.0",
2255
+ "@noble/hashes": "^1.8.0",
2256
+ "fflate": "^0.8.3",
2257
+ "fontkit": "^2.0.4",
2258
+ "linebreak": "^1.1.0",
2259
+ "png-js": "^2.0.0"
2260
+ }
2261
+ },
2116
2262
  "node_modules/pg": {
2117
2263
  "version": "8.23.1",
2118
2264
  "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz",
@@ -2202,6 +2348,14 @@
2202
2348
  "split2": "^4.1.0"
2203
2349
  }
2204
2350
  },
2351
+ "node_modules/png-js": {
2352
+ "version": "2.0.0",
2353
+ "resolved": "https://registry.npmjs.org/png-js/-/png-js-2.0.0.tgz",
2354
+ "integrity": "sha512-GdzJuUMc6ZSpxFJWVxtOH1bzYHym+TOnveqUjb+VJIbZWbZzyiRGFiKhbiielfpYbgMlhHVhsJ0FTazfuRFkMA==",
2355
+ "dependencies": {
2356
+ "fflate": "^0.8.2"
2357
+ }
2358
+ },
2205
2359
  "node_modules/postgres-array": {
2206
2360
  "version": "2.0.0",
2207
2361
  "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
@@ -2312,6 +2466,12 @@
2312
2466
  "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
2313
2467
  }
2314
2468
  },
2469
+ "node_modules/restructure": {
2470
+ "version": "3.0.2",
2471
+ "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
2472
+ "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
2473
+ "license": "MIT"
2474
+ },
2315
2475
  "node_modules/router": {
2316
2476
  "version": "2.2.0",
2317
2477
  "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
@@ -2533,6 +2693,12 @@
2533
2693
  "url": "https://github.com/chalk/supports-color?sponsor=1"
2534
2694
  }
2535
2695
  },
2696
+ "node_modules/tiny-inflate": {
2697
+ "version": "1.0.3",
2698
+ "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
2699
+ "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
2700
+ "license": "MIT"
2701
+ },
2536
2702
  "node_modules/toidentifier": {
2537
2703
  "version": "1.0.1",
2538
2704
  "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -3069,6 +3235,26 @@
3069
3235
  "url": "https://opencollective.com/express"
3070
3236
  }
3071
3237
  },
3238
+ "node_modules/unicode-properties": {
3239
+ "version": "1.4.1",
3240
+ "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
3241
+ "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
3242
+ "license": "MIT",
3243
+ "dependencies": {
3244
+ "base64-js": "^1.3.0",
3245
+ "unicode-trie": "^2.0.0"
3246
+ }
3247
+ },
3248
+ "node_modules/unicode-trie": {
3249
+ "version": "2.0.0",
3250
+ "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
3251
+ "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
3252
+ "license": "MIT",
3253
+ "dependencies": {
3254
+ "pako": "^0.2.5",
3255
+ "tiny-inflate": "^1.0.0"
3256
+ }
3257
+ },
3072
3258
  "node_modules/unpipe": {
3073
3259
  "version": "1.0.0",
3074
3260
  "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.30",
3
+ "version": "2026.10.32",
4
4
  "description": "Invoicing, bill entry and a financial dashboard for labs, with Seazona and Xero integrations.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -51,6 +51,7 @@
51
51
  "drizzle-orm": "0.45.3",
52
52
  "express": "5.3.0",
53
53
  "express-rate-limit": "8.7.0",
54
+ "pdfkit": "0.20.2",
54
55
  "pg": "8.23.1"
55
56
  },
56
57
  "devDependencies": {
@@ -0,0 +1,49 @@
1
+ import express from 'express';
2
+ import { ALERT_PAGES, alertForBrowser, alertsFor, dismissAlert } from '../core/alerts.js';
3
+ import { FlowError } from '../core/errors.js';
4
+ import { resolveOrganisation } from './invoicing.js';
5
+
6
+ // Quiet alerts for signed-in people (server/core/alerts.js, docs/ALERTS.md).
7
+ //
8
+ // GET /api/alerts?page=invoices[&scope=][&organisationId=] what needs this person on that page;
9
+ // `scope` names one part of it (an integration id on page integrations)
10
+ // POST /api/alerts/dismiss { key[, organisationId] } hide one alert for this person
11
+ //
12
+ // `sources` maps each page to the functions that find its alerts.
13
+ export function createAlertsRouter({ db, personAuth, sources, log = console }) {
14
+ const router = express.Router();
15
+ router.use(express.json({ limit: '8kb' }));
16
+
17
+ const handle = (fn) => async (req, res) => {
18
+ try {
19
+ await fn(req, res);
20
+ } catch (error) {
21
+ if (!(error instanceof FlowError)) {
22
+ log.error?.(`[alerts] ${error.message}`);
23
+ return res.status(500).json({ ok: false, code: 'internal', error: 'Something went wrong.' });
24
+ }
25
+ const status = { not_found: 404, organisation_required: 409 }[error.code] ?? 400;
26
+ return res.status(status).json({ ok: false, code: error.code, error: error.message });
27
+ }
28
+ };
29
+
30
+ router.get('/', personAuth.requireSession, handle(async (req, res) => {
31
+ const page = typeof req.query.page === 'string' ? req.query.page : '';
32
+ if (!ALERT_PAGES.includes(page)) throw new FlowError('invalid', `page must be one of ${ALERT_PAGES.join(', ')}`);
33
+ const organisationId = await resolveOrganisation(db, req.query.organisationId);
34
+ const scope = typeof req.query.scope === 'string' && req.query.scope ? req.query.scope : null;
35
+ if (scope && !/^[a-z][a-z0-9-]{1,31}$/.test(scope)) throw new FlowError('invalid', 'scope must be an integration id');
36
+ const alerts = await alertsFor(db, organisationId, req.person, { page, scope, sources, withHidden: true });
37
+ res.set('Cache-Control', 'no-store');
38
+ // `danger`: an alert of that tone is open, even one this person hid.
39
+ res.json({ ok: true, alerts: alerts.filter((a) => !a.hidden).map(alertForBrowser), danger: alerts.some((a) => a.tone === 'danger') });
40
+ }));
41
+
42
+ router.post('/dismiss', personAuth.requireSession, handle(async (req, res) => {
43
+ const organisationId = await resolveOrganisation(db, req.body?.organisationId ?? req.query.organisationId);
44
+ const result = await dismissAlert(db, organisationId, req.person, { key: req.body?.key, sources });
45
+ res.json({ ok: true, ...result });
46
+ }));
47
+
48
+ return router;
49
+ }
@@ -13,10 +13,16 @@ import {
13
13
  } from '../core/line-mapping.js';
14
14
  import {
15
15
  effectiveSettings, INVOICING_DEFAULTS, readCustomerBilling, readInvoicingSettings,
16
- saveCustomerBilling, saveInvoicingSettings,
16
+ saveCustomerBilling, saveInvoicingSettings, sendModeFor, sendModeSummary,
17
17
  } from '../core/invoicing-rules.js';
18
18
  import { boardJobs, describeObligations } from '../core/job-board.js';
19
+ import { jobReference } from '../core/references.js';
20
+ import {
21
+ acceptFindings, archivePriceItem, checkPrices, createPriceItem, linkPriceItem, readCatalogues, readPriceList, unlinkPriceItem, updatePriceItem,
22
+ } from '../core/price-list.js';
19
23
  import { draftInvoiceForObligation, explainObligation, listObligations, setReady } from '../core/ledger.js';
24
+ import { isHistoryHeld } from '../core/history.js';
25
+ import { invoicePdfDocument, pdfFields, pdfFilename, renderInvoicePdf } from '../core/invoice-pdf.js';
20
26
  import { invoiceView, invoiceViews, markNotReady, markReady, sendAgain, sendAllReady } from '../core/sending.js';
21
27
  import {
22
28
  billingObligations, contacts, invoices, INVOICE_STATUSES, organisations, priceItems, TAX_CODES,
@@ -31,9 +37,12 @@ import {
31
37
  // POST /invoices/mark-ready { invoiceIds }: a person checked these drafts; one with a problem stays
32
38
  // POST /invoices/mark-not-ready { invoiceIds }: back to To check
33
39
  // POST /invoices/send-ready owner or admin: issue every ready draft and queue it for the
34
- // accounting system { issueDate }
40
+ // accounting system { issueDate, invoiceIds? (only these, each ready) }
35
41
  // POST /invoices/send-again owner or admin: queue failed sends again { invoiceIds? }
36
- // GET /invoices/:id one invoice: lines, contact, job, issued versions
42
+ // GET /invoices/:id one invoice: lines, contact, job, issued versions, and where it sends
43
+ // GET /invoices/:id/pdf?version= the invoice as a PDF, drawn by Flow (server/core/invoice-pdf.js),
44
+ // with the accounting system's number and dates once it holds it;
45
+ // no accounting system calls
37
46
  // POST /invoices a new draft
38
47
  // PATCH /invoices/:id change a draft
39
48
  // POST /invoices/:id/issue owner or admin: number it and keep the document as sent
@@ -58,6 +67,15 @@ import {
58
67
  // POST /line-mappings owner or admin: a new mapping
59
68
  // PUT /line-mappings/:id owner or admin: change some of a mapping
60
69
  // DELETE /line-mappings/:id owner or admin
70
+ // GET /price-list the price list with its links, the connected systems' records with
71
+ // no link, and what differs (server/core/price-list.js)
72
+ // POST /price-list/check owner or admin: run the price check now (no calls to other systems)
73
+ // POST /price-items owner or admin: a new price item { code, name, unitAmountCents, taxCode, ... }
74
+ // PUT /price-items/:id owner or admin: change some of a price item
75
+ // POST /price-items/:id/archive owner or admin: { archived } archive it, or bring it back
76
+ // POST /price-items/:id/links owner or admin: { system, externalId } link a record of a system's list
77
+ // POST /price-items/:id/resolve owner or admin: Mark resolved: check again, accept what still differs
78
+ // DELETE /price-links/:id owner or admin: remove a link
61
79
  // GET /automations?place= Flow's routines, each on or off (server/core/automations.js)
62
80
  // PUT /automations/:key owner or admin: { enabled, config }
63
81
  // GET /work-statuses the work system's job statuses, with job counts
@@ -233,6 +251,11 @@ async function invoiceDetail(db, organisationId, invoiceId, accounting = null) {
233
251
  return {
234
252
  ...invoice,
235
253
  ...(await invoiceView(db, organisationId, invoice, { accounting })),
254
+ // Where a draft goes when it is sent, and how: this customer's send mode.
255
+ sendsTo: accounting?.name ?? null,
256
+ sendMode: accounting && invoice.status === 'draft'
257
+ ? { mode: await sendModeFor(db, organisationId, invoice.contactId), otherCustomers: 0, ...(await accounting.capabilities.sendStatus({ organisationId })) }
258
+ : null,
236
259
  contact: contact ?? null,
237
260
  job: jobs.get(invoiceId) ?? null,
238
261
  versions: versions.map(({ version, issuedAt, issuedBy }) => ({ version, issuedAt, issuedBy })),
@@ -240,7 +263,7 @@ async function invoiceDetail(db, organisationId, invoiceId, accounting = null) {
240
263
  }
241
264
 
242
265
  export function createInvoicingRouter({
243
- db, personAuth, log = console, liveCheckFor = () => null, accounting = () => null, work = () => [],
266
+ db, personAuth, log = console, liveCheckFor = () => null, accounting = () => null, work = () => [], priceSystems = () => [],
244
267
  }) {
245
268
  const router = express.Router();
246
269
  router.use(express.json({ limit: '64kb' }));
@@ -302,6 +325,7 @@ export function createInvoicingRouter({
302
325
  })),
303
326
  counts,
304
327
  sendsTo: accounting()?.name ?? null,
328
+ sendMode: accounting() ? { ...(await sendModeSummary(db, organisationId)), ...(await accounting().capabilities.sendStatus({ organisationId })) } : null,
305
329
  });
306
330
  }));
307
331
 
@@ -330,7 +354,8 @@ export function createInvoicingRouter({
330
354
  router.post('/invoices/send-ready', ...manager, handle(log, async (req, res) => {
331
355
  const organisationId = await organisationOf(req);
332
356
  const issueDate = optionalDate(req.body?.issueDate, 'issueDate') ?? today();
333
- const result = await sendAllReady(db, organisationId, { accounting: accounting(), personId: req.person.id, issueDate });
357
+ const ids = req.body?.invoiceIds === undefined ? null : invoiceIdsOf(req.body);
358
+ const result = await sendAllReady(db, organisationId, { accounting: accounting(), personId: req.person.id, issueDate, invoiceIds: ids });
334
359
  log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} sent all ready: ${JSON.stringify(result)}`);
335
360
  res.json({ ok: true, ...result });
336
361
  }));
@@ -349,6 +374,22 @@ export function createInvoicingRouter({
349
374
  res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting()) });
350
375
  }));
351
376
 
377
+ router.get('/invoices/:id/pdf', ...signedIn, handle(log, async (req, res) => {
378
+ const invoiceId = pathId(req.params.id, 'invoice');
379
+ const organisationId = await organisationOf(req);
380
+ const version = req.query.version === undefined ? null : Number(req.query.version);
381
+ if (version !== null && !(Number.isSafeInteger(version) && version > 0)) throw invalid('version must be a whole number above 0');
382
+ const source = await invoicePdfDocument(db, organisationId, invoiceId, { version, accounting: accounting() });
383
+ const bytes = await renderInvoicePdf(source);
384
+ res.set({
385
+ 'Content-Type': 'application/pdf',
386
+ 'Content-Disposition': `inline; filename="${pdfFilename(pdfFields(source))}"`,
387
+ 'Cache-Control': 'no-store',
388
+ 'X-Content-Type-Options': 'nosniff',
389
+ });
390
+ res.end(bytes);
391
+ }));
392
+
352
393
  router.post('/invoices', ...signedIn, handle(log, async (req, res) => {
353
394
  const organisationId = await organisationOf(req);
354
395
  const body = req.body ?? {};
@@ -427,8 +468,13 @@ export function createInvoicingRouter({
427
468
  const view = text(req.query.view);
428
469
  if (view && !VIEWS.includes(view)) throw invalid(`view must be one of ${VIEWS.join(', ')}`);
429
470
  const limit = limitOf(req.query.limit, 200, 500);
430
- const all = await listObligations(db, organisationId, { accounting: Boolean(liveCheckFor(organisationId)) });
471
+ const listedAll = await listObligations(db, organisationId, { accounting: Boolean(liveCheckFor(organisationId)) });
472
+ // Held history jobs (core/history.js) are left out unless asked for: they
473
+ // are past work, listed in the history import's own reconciliation.
474
+ const withHistory = req.query.history === 'include';
475
+ const all = withHistory ? listedAll : listedAll.filter((entry) => !isHistoryHeld(entry.obligation));
431
476
  const counts = Object.fromEntries(VIEWS.map((v) => [v, 0]));
477
+ counts.history = listedAll.length - listedAll.filter((entry) => !isHistoryHeld(entry.obligation)).length;
432
478
  for (const entry of all) counts[entry.view] += 1;
433
479
  const listed = (view ? all.filter((entry) => entry.view === view) : all).slice(0, limit);
434
480
  res.json({ ok: true, jobs: await describeObligations(db, organisationId, listed, { accounting: accounting() }), counts });
@@ -449,7 +495,11 @@ export function createInvoicingRouter({
449
495
  const organisationId = await organisationOf(req);
450
496
  // With no accounting system connected, Flow's own records are the check.
451
497
  const liveCheck = liveCheckFor(organisationId) ?? null;
452
- const { invoice } = await draftInvoiceForObligation(db, organisationId, obligationId, { liveCheck, actor: `person:${req.person.id}` });
498
+ const [obligation] = await db.select().from(billingObligations)
499
+ .where(and(eq(billingObligations.organisationId, organisationId), eq(billingObligations.id, obligationId)));
500
+ const { referenceTemplate: template } = await readInvoicingSettings(db, organisationId);
501
+ const reference = obligation ? await jobReference(db, organisationId, obligation, { template, work: work().find((a) => a.id === obligation.sourceSystem) ?? null }) : null;
502
+ const { invoice } = await draftInvoiceForObligation(db, organisationId, obligationId, { liveCheck, actor: `person:${req.person.id}`, reference });
453
503
  log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} drafted invoice ${invoice.id} from job ${obligationId}`);
454
504
  res.status(201).json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoice.id) });
455
505
  }));
@@ -602,6 +652,74 @@ export function createInvoicingRouter({
602
652
  res.json({ ok: true });
603
653
  }));
604
654
 
655
+ // The price list (server/core/price-list.js). Every read is from Flow's own
656
+ // copies of the connected systems' lists.
657
+ const catalogues = (organisationId) => readCatalogues(organisationId, priceSystems(), { log });
658
+
659
+ router.get('/price-list', ...signedIn, handle(log, async (req, res) => {
660
+ const organisationId = await organisationOf(req);
661
+ res.json({ ok: true, ...(await readPriceList(db, organisationId, { catalogues: await catalogues(organisationId) })) });
662
+ }));
663
+
664
+ router.post('/price-list/check', ...manager, handle(log, async (req, res) => {
665
+ const organisationId = await organisationOf(req);
666
+ const summary = await checkPrices(db, organisationId, { catalogues: await catalogues(organisationId), actor: `person:${req.person.id}` });
667
+ res.json({ ok: true, summary });
668
+ }));
669
+
670
+ router.post('/price-items', ...manager, handle(log, async (req, res) => {
671
+ const organisationId = await organisationOf(req);
672
+ const { organisationId: _named, ...raw } = req.body ?? {};
673
+ const item = await createPriceItem(db, organisationId, raw, { personId: req.person.id });
674
+ log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} added price item ${item.id}`);
675
+ res.status(201).json({ ok: true, item });
676
+ }));
677
+
678
+ router.put('/price-items/:id', ...manager, handle(log, async (req, res) => {
679
+ const itemId = pathId(req.params.id, 'price item');
680
+ const organisationId = await organisationOf(req);
681
+ const { organisationId: _named, ...changes } = req.body ?? {};
682
+ const item = await updatePriceItem(db, organisationId, itemId, changes, { personId: req.person.id });
683
+ log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} changed price item ${item.id}`);
684
+ res.json({ ok: true, item });
685
+ }));
686
+
687
+ router.post('/price-items/:id/archive', ...manager, handle(log, async (req, res) => {
688
+ const itemId = pathId(req.params.id, 'price item');
689
+ const organisationId = await organisationOf(req);
690
+ const archived = req.body?.archived ?? true;
691
+ if (typeof archived !== 'boolean') throw invalid('archived must be true or false');
692
+ const item = await archivePriceItem(db, organisationId, itemId, { archived, personId: req.person.id });
693
+ log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} ${archived ? 'archived' : 'restored'} price item ${item.id}`);
694
+ res.json({ ok: true, item });
695
+ }));
696
+
697
+ router.post('/price-items/:id/links', ...manager, handle(log, async (req, res) => {
698
+ const itemId = pathId(req.params.id, 'price item');
699
+ const organisationId = await organisationOf(req);
700
+ const system = text(req.body?.system);
701
+ const externalId = text(req.body?.externalId);
702
+ if (!system || !externalId) throw invalid('system and externalId are required');
703
+ const link = await linkPriceItem(db, organisationId, itemId, { system, externalId }, { catalogues: await catalogues(organisationId), personId: req.person.id });
704
+ log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} linked price item ${itemId} to ${system}`);
705
+ res.status(201).json({ ok: true, link: { id: link.id, system: link.system, externalId: link.externalId, externalCode: link.externalCode, matchedBy: link.matchedBy } });
706
+ }));
707
+
708
+ router.post('/price-items/:id/resolve', ...manager, handle(log, async (req, res) => {
709
+ const itemId = pathId(req.params.id, 'price item');
710
+ const organisationId = await organisationOf(req);
711
+ const result = await acceptFindings(db, organisationId, itemId, { catalogues: await catalogues(organisationId), actor: `person:${req.person.id}` });
712
+ res.json({ ok: true, ...result });
713
+ }));
714
+
715
+ router.delete('/price-links/:id', ...manager, handle(log, async (req, res) => {
716
+ const linkId = pathId(req.params.id, 'link');
717
+ const organisationId = await organisationOf(req);
718
+ await unlinkPriceItem(db, organisationId, linkId);
719
+ log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} removed price link ${linkId}`);
720
+ res.json({ ok: true });
721
+ }));
722
+
605
723
  router.get('/settings', ...signedIn, handle(log, async (req, res) => {
606
724
  const organisationId = await organisationOf(req);
607
725
  res.json({ ok: true, settings: await readInvoicingSettings(db, organisationId), defaults: INVOICING_DEFAULTS });