@arcforgelabs/flow 2026.10.29 → 2026.10.32-beta.1

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 (76) 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 +110 -3
  6. package/server/app.js +29 -2
  7. package/server/core/alerts.js +157 -0
  8. package/server/core/automations.js +32 -1
  9. package/server/core/history.js +65 -0
  10. package/server/core/invoice-pdf.js +266 -0
  11. package/server/core/invoicing-rules.js +12 -0
  12. package/server/core/job-board.js +25 -3
  13. package/server/core/line-mapping.js +36 -10
  14. package/server/core/pipeline.js +84 -6
  15. package/server/core/price-list.js +348 -0
  16. package/server/core/queue.js +22 -0
  17. package/server/core/sending.js +16 -4
  18. package/server/db/commands.js +42 -0
  19. package/server/db/doctor.js +168 -0
  20. package/server/db/migrations/0014_price_list.sql +34 -0
  21. package/server/db/migrations/0015_alert_dismissals.sql +17 -0
  22. package/server/db/migrations/meta/0014_snapshot.json +3773 -0
  23. package/server/db/migrations/meta/0015_snapshot.json +3854 -0
  24. package/server/db/migrations/meta/_journal.json +14 -0
  25. package/server/db/schema.js +44 -0
  26. package/server/flow-server.js +20 -1
  27. package/server/index.js +2 -1
  28. package/server/integrations/call-guard.js +362 -0
  29. package/server/integrations/port.js +23 -2
  30. package/server/integrations/seazona/client.js +6 -0
  31. package/server/integrations/seazona/feed.js +23 -1
  32. package/server/integrations/seazona/governor.js +74 -21
  33. package/server/integrations/seazona/history/backfill.js +208 -0
  34. package/server/integrations/seazona/history/csv.js +65 -0
  35. package/server/integrations/seazona/history/index.js +584 -0
  36. package/server/integrations/seazona/history/read.js +196 -0
  37. package/server/integrations/seazona/index.js +51 -2
  38. package/server/integrations/seazona/lanes.js +251 -62
  39. package/server/integrations/seazona/migrations/0002_call_safety.sql +15 -0
  40. package/server/integrations/seazona/migrations/0003_call_guard.sql +2 -0
  41. package/server/integrations/seazona/migrations/0004_history_import.sql +68 -0
  42. package/server/integrations/seazona/migrations/meta/0002_snapshot.json +882 -0
  43. package/server/integrations/seazona/migrations/meta/0003_snapshot.json +895 -0
  44. package/server/integrations/seazona/migrations/meta/0004_snapshot.json +1323 -0
  45. package/server/integrations/seazona/migrations/meta/_journal.json +21 -0
  46. package/server/integrations/seazona/routes.js +145 -0
  47. package/server/integrations/seazona/scheduler.js +37 -1
  48. package/server/integrations/seazona/schema.js +116 -0
  49. package/server/integrations/seazona/settings.js +24 -3
  50. package/server/integrations/seazona/store.js +74 -0
  51. package/server/integrations/seazona/sync-once.js +1 -1
  52. package/server/integrations/seazona/tables.js +5 -0
  53. package/server/integrations/xero/client.js +27 -7
  54. package/server/integrations/xero/drafts.js +46 -8
  55. package/server/integrations/xero/import.js +125 -16
  56. package/server/integrations/xero/index.js +36 -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/meta/_journal.json +7 -0
  60. package/server/integrations/xero/mirror.js +11 -2
  61. package/server/integrations/xero/oauth.js +4 -1
  62. package/server/integrations/xero/reconcile.js +173 -0
  63. package/server/integrations/xero/routes.js +35 -0
  64. package/server/integrations/xero/runtime.js +45 -4
  65. package/server/release-channel.js +3 -3
  66. package/server/ui/alerts.js +77 -0
  67. package/server/ui/app-page.js +1 -1
  68. package/server/ui/app.js +48 -14
  69. package/server/ui/clerk-appearance.js +42 -9
  70. package/server/ui/flow.css +31 -2
  71. package/server/ui/invoicing-settings.js +1 -1
  72. package/server/ui/invoicing.js +121 -8
  73. package/server/ui/line-mappings.js +3 -2
  74. package/server/ui/monitoring.js +14 -0
  75. package/server/ui/price-list.js +247 -0
  76. 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.29",
3
+ "version": "2026.10.32-beta.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@arcforgelabs/flow",
9
- "version": "2026.10.29",
9
+ "version": "2026.10.32-beta.1",
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.29",
3
+ "version": "2026.10.32-beta.1",
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,15 @@ import {
13
13
  } from '../core/line-mapping.js';
14
14
  import {
15
15
  effectiveSettings, INVOICING_DEFAULTS, readCustomerBilling, readInvoicingSettings,
16
- saveCustomerBilling, saveInvoicingSettings,
16
+ saveCustomerBilling, saveInvoicingSettings, sendModeSummary,
17
17
  } from '../core/invoicing-rules.js';
18
18
  import { boardJobs, describeObligations } from '../core/job-board.js';
19
+ import {
20
+ acceptFindings, archivePriceItem, checkPrices, createPriceItem, linkPriceItem, readCatalogues, readPriceList, unlinkPriceItem, updatePriceItem,
21
+ } from '../core/price-list.js';
19
22
  import { draftInvoiceForObligation, explainObligation, listObligations, setReady } from '../core/ledger.js';
23
+ import { isHistoryHeld } from '../core/history.js';
24
+ import { invoicePdfDocument, pdfFields, pdfFilename, renderInvoicePdf } from '../core/invoice-pdf.js';
20
25
  import { invoiceView, invoiceViews, markNotReady, markReady, sendAgain, sendAllReady } from '../core/sending.js';
21
26
  import {
22
27
  billingObligations, contacts, invoices, INVOICE_STATUSES, organisations, priceItems, TAX_CODES,
@@ -34,6 +39,9 @@ import {
34
39
  // accounting system { issueDate }
35
40
  // POST /invoices/send-again owner or admin: queue failed sends again { invoiceIds? }
36
41
  // GET /invoices/:id one invoice: lines, contact, job, issued versions
42
+ // GET /invoices/:id/pdf?version= the invoice as a PDF, drawn by Flow (server/core/invoice-pdf.js),
43
+ // with the accounting system's number and dates once it holds it;
44
+ // no accounting system calls
37
45
  // POST /invoices a new draft
38
46
  // PATCH /invoices/:id change a draft
39
47
  // POST /invoices/:id/issue owner or admin: number it and keep the document as sent
@@ -58,6 +66,15 @@ import {
58
66
  // POST /line-mappings owner or admin: a new mapping
59
67
  // PUT /line-mappings/:id owner or admin: change some of a mapping
60
68
  // DELETE /line-mappings/:id owner or admin
69
+ // GET /price-list the price list with its links, the connected systems' records with
70
+ // no link, and what differs (server/core/price-list.js)
71
+ // POST /price-list/check owner or admin: run the price check now (no calls to other systems)
72
+ // POST /price-items owner or admin: a new price item { code, name, unitAmountCents, taxCode, ... }
73
+ // PUT /price-items/:id owner or admin: change some of a price item
74
+ // POST /price-items/:id/archive owner or admin: { archived } archive it, or bring it back
75
+ // POST /price-items/:id/links owner or admin: { system, externalId } link a record of a system's list
76
+ // POST /price-items/:id/resolve owner or admin: Mark resolved: check again, accept what still differs
77
+ // DELETE /price-links/:id owner or admin: remove a link
61
78
  // GET /automations?place= Flow's routines, each on or off (server/core/automations.js)
62
79
  // PUT /automations/:key owner or admin: { enabled, config }
63
80
  // GET /work-statuses the work system's job statuses, with job counts
@@ -240,7 +257,7 @@ async function invoiceDetail(db, organisationId, invoiceId, accounting = null) {
240
257
  }
241
258
 
242
259
  export function createInvoicingRouter({
243
- db, personAuth, log = console, liveCheckFor = () => null, accounting = () => null, work = () => [],
260
+ db, personAuth, log = console, liveCheckFor = () => null, accounting = () => null, work = () => [], priceSystems = () => [],
244
261
  }) {
245
262
  const router = express.Router();
246
263
  router.use(express.json({ limit: '64kb' }));
@@ -302,6 +319,7 @@ export function createInvoicingRouter({
302
319
  })),
303
320
  counts,
304
321
  sendsTo: accounting()?.name ?? null,
322
+ sendMode: accounting() ? { ...(await sendModeSummary(db, organisationId)), ...(await accounting().capabilities.sendStatus({ organisationId })) } : null,
305
323
  });
306
324
  }));
307
325
 
@@ -349,6 +367,22 @@ export function createInvoicingRouter({
349
367
  res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting()) });
350
368
  }));
351
369
 
370
+ router.get('/invoices/:id/pdf', ...signedIn, handle(log, async (req, res) => {
371
+ const invoiceId = pathId(req.params.id, 'invoice');
372
+ const organisationId = await organisationOf(req);
373
+ const version = req.query.version === undefined ? null : Number(req.query.version);
374
+ if (version !== null && !(Number.isSafeInteger(version) && version > 0)) throw invalid('version must be a whole number above 0');
375
+ const source = await invoicePdfDocument(db, organisationId, invoiceId, { version, accounting: accounting() });
376
+ const bytes = await renderInvoicePdf(source);
377
+ res.set({
378
+ 'Content-Type': 'application/pdf',
379
+ 'Content-Disposition': `inline; filename="${pdfFilename(pdfFields(source))}"`,
380
+ 'Cache-Control': 'no-store',
381
+ 'X-Content-Type-Options': 'nosniff',
382
+ });
383
+ res.end(bytes);
384
+ }));
385
+
352
386
  router.post('/invoices', ...signedIn, handle(log, async (req, res) => {
353
387
  const organisationId = await organisationOf(req);
354
388
  const body = req.body ?? {};
@@ -427,8 +461,13 @@ export function createInvoicingRouter({
427
461
  const view = text(req.query.view);
428
462
  if (view && !VIEWS.includes(view)) throw invalid(`view must be one of ${VIEWS.join(', ')}`);
429
463
  const limit = limitOf(req.query.limit, 200, 500);
430
- const all = await listObligations(db, organisationId, { accounting: Boolean(liveCheckFor(organisationId)) });
464
+ const listedAll = await listObligations(db, organisationId, { accounting: Boolean(liveCheckFor(organisationId)) });
465
+ // Held history jobs (core/history.js) are left out unless asked for: they
466
+ // are past work, listed in the history import's own reconciliation.
467
+ const withHistory = req.query.history === 'include';
468
+ const all = withHistory ? listedAll : listedAll.filter((entry) => !isHistoryHeld(entry.obligation));
431
469
  const counts = Object.fromEntries(VIEWS.map((v) => [v, 0]));
470
+ counts.history = listedAll.length - listedAll.filter((entry) => !isHistoryHeld(entry.obligation)).length;
432
471
  for (const entry of all) counts[entry.view] += 1;
433
472
  const listed = (view ? all.filter((entry) => entry.view === view) : all).slice(0, limit);
434
473
  res.json({ ok: true, jobs: await describeObligations(db, organisationId, listed, { accounting: accounting() }), counts });
@@ -602,6 +641,74 @@ export function createInvoicingRouter({
602
641
  res.json({ ok: true });
603
642
  }));
604
643
 
644
+ // The price list (server/core/price-list.js). Every read is from Flow's own
645
+ // copies of the connected systems' lists.
646
+ const catalogues = (organisationId) => readCatalogues(organisationId, priceSystems(), { log });
647
+
648
+ router.get('/price-list', ...signedIn, handle(log, async (req, res) => {
649
+ const organisationId = await organisationOf(req);
650
+ res.json({ ok: true, ...(await readPriceList(db, organisationId, { catalogues: await catalogues(organisationId) })) });
651
+ }));
652
+
653
+ router.post('/price-list/check', ...manager, handle(log, async (req, res) => {
654
+ const organisationId = await organisationOf(req);
655
+ const summary = await checkPrices(db, organisationId, { catalogues: await catalogues(organisationId), actor: `person:${req.person.id}` });
656
+ res.json({ ok: true, summary });
657
+ }));
658
+
659
+ router.post('/price-items', ...manager, handle(log, async (req, res) => {
660
+ const organisationId = await organisationOf(req);
661
+ const { organisationId: _named, ...raw } = req.body ?? {};
662
+ const item = await createPriceItem(db, organisationId, raw, { personId: req.person.id });
663
+ log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} added price item ${item.id}`);
664
+ res.status(201).json({ ok: true, item });
665
+ }));
666
+
667
+ router.put('/price-items/:id', ...manager, handle(log, async (req, res) => {
668
+ const itemId = pathId(req.params.id, 'price item');
669
+ const organisationId = await organisationOf(req);
670
+ const { organisationId: _named, ...changes } = req.body ?? {};
671
+ const item = await updatePriceItem(db, organisationId, itemId, changes, { personId: req.person.id });
672
+ log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} changed price item ${item.id}`);
673
+ res.json({ ok: true, item });
674
+ }));
675
+
676
+ router.post('/price-items/:id/archive', ...manager, handle(log, async (req, res) => {
677
+ const itemId = pathId(req.params.id, 'price item');
678
+ const organisationId = await organisationOf(req);
679
+ const archived = req.body?.archived ?? true;
680
+ if (typeof archived !== 'boolean') throw invalid('archived must be true or false');
681
+ const item = await archivePriceItem(db, organisationId, itemId, { archived, personId: req.person.id });
682
+ log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} ${archived ? 'archived' : 'restored'} price item ${item.id}`);
683
+ res.json({ ok: true, item });
684
+ }));
685
+
686
+ router.post('/price-items/:id/links', ...manager, handle(log, async (req, res) => {
687
+ const itemId = pathId(req.params.id, 'price item');
688
+ const organisationId = await organisationOf(req);
689
+ const system = text(req.body?.system);
690
+ const externalId = text(req.body?.externalId);
691
+ if (!system || !externalId) throw invalid('system and externalId are required');
692
+ const link = await linkPriceItem(db, organisationId, itemId, { system, externalId }, { catalogues: await catalogues(organisationId), personId: req.person.id });
693
+ log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} linked price item ${itemId} to ${system}`);
694
+ res.status(201).json({ ok: true, link: { id: link.id, system: link.system, externalId: link.externalId, externalCode: link.externalCode, matchedBy: link.matchedBy } });
695
+ }));
696
+
697
+ router.post('/price-items/:id/resolve', ...manager, handle(log, async (req, res) => {
698
+ const itemId = pathId(req.params.id, 'price item');
699
+ const organisationId = await organisationOf(req);
700
+ const result = await acceptFindings(db, organisationId, itemId, { catalogues: await catalogues(organisationId), actor: `person:${req.person.id}` });
701
+ res.json({ ok: true, ...result });
702
+ }));
703
+
704
+ router.delete('/price-links/:id', ...manager, handle(log, async (req, res) => {
705
+ const linkId = pathId(req.params.id, 'link');
706
+ const organisationId = await organisationOf(req);
707
+ await unlinkPriceItem(db, organisationId, linkId);
708
+ log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} removed price link ${linkId}`);
709
+ res.json({ ok: true });
710
+ }));
711
+
605
712
  router.get('/settings', ...signedIn, handle(log, async (req, res) => {
606
713
  const organisationId = await organisationOf(req);
607
714
  res.json({ ok: true, settings: await readInvoicingSettings(db, organisationId), defaults: INVOICING_DEFAULTS });
package/server/app.js CHANGED
@@ -1,7 +1,10 @@
1
1
  import express from 'express';
2
+ import { createAlertsRouter } from './api/alerts.js';
2
3
  import { createConnectorApi } from './api/connector.js';
3
4
  import { createInvoicingRouter } from './api/invoicing.js';
4
5
  import { createSignInRouter } from './auth/routes.js';
6
+ import { integrationAlertsSource, invoiceProblemsSource, priceDifferencesSource } from './core/alerts.js';
7
+ import { appliedMigrationCounts } from './db/doctor.js';
5
8
  import { deployedBuildStatus } from './deployed-build.js';
6
9
  import { integrationsHealth } from './integrations/health.js';
7
10
  import { createSetupRouter } from './integrations/setup.js';
@@ -27,10 +30,14 @@ import { createUiAssetsRouter } from './ui/routes.js';
27
30
  // each one (server/integrations/health.js). `defaultFamily` is the design
28
31
  // system every page starts on (FLOW_THEME, server/theme/themes.js). `log`
29
32
  // is the console, or the monitored log (server/core/monitoring.js) that also
30
- // sends each error to the installation's error service.
33
+ // sends each error to the installation's error service. `schemaJournals` are
34
+ // the migration journals this build ships (server/db/doctor.js): GET /health
35
+ // reports how many of each the database has applied, so the workspace's
36
+ // doctor can tell what Replit's publish step will offer (docs/REPLIT.md).
31
37
  export function createApp({
32
38
  build = deployedBuildStatus(),
33
39
  database = null,
40
+ schemaJournals = null,
34
41
  auth = null,
35
42
  updates = null,
36
43
  integrations = null,
@@ -59,6 +66,11 @@ export function createApp({
59
66
  // records). An integration problem does not fail the health check: Flow's
60
67
  // core works with no integration connected (VISION.md).
61
68
  const body = { ok, service: 'flow', database: databaseOk, build };
69
+ // Counts only: no table names and no data.
70
+ if (databaseOk && schemaJournals) {
71
+ body.schema = await appliedMigrationCounts(database.pool, schemaJournals)
72
+ .then((migrations) => ({ migrations }), () => null);
73
+ }
62
74
  if (integrations) body.integrations = await integrationsHealth(integrations);
63
75
  res.status(ok ? 200 : 503).json(body);
64
76
  });
@@ -116,7 +128,22 @@ export function createApp({
116
128
  const accounting = () => integrations?.with('pushInvoices')[0] ?? null;
117
129
  // The Jobs board: every enabled integration that lists its jobs (Seazona).
118
130
  const work = () => integrations?.with('listWork') ?? [];
119
- app.use('/api/invoicing', createInvoicingRouter({ db: database.db, personAuth: auth.personAuth, log, liveCheckFor, accounting, work }));
131
+ // The price list: every enabled integration that keeps its own (Xero
132
+ // items, Seazona products).
133
+ const priceSystems = () => integrations?.with('listPriceItems') ?? [];
134
+ app.use('/api/invoicing', createInvoicingRouter({ db: database.db, personAuth: auth.personAuth, log, liveCheckFor, accounting, work, priceSystems }));
135
+ // Quiet alerts (server/core/alerts.js): what needs a person on each page.
136
+ // Integrations add their own through the `alerts` capability.
137
+ app.use('/api/alerts', createAlertsRouter({
138
+ db: database.db,
139
+ personAuth: auth.personAuth,
140
+ log,
141
+ sources: {
142
+ invoices: [invoiceProblemsSource(database.db, { accounting })],
143
+ 'price-list': [priceDifferencesSource(database.db)],
144
+ integrations: [integrationAlertsSource(() => integrations?.with('alerts') ?? [])],
145
+ },
146
+ }));
120
147
  }
121
148
  // What a signed-in browser needs to report its errors and problems to the
122
149
  // installation's own error service (docs/SENTRY.md): the first integration