@arcforgelabs/flow 2026.10.30 → 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 (67) 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/history.js +65 -0
  9. package/server/core/invoice-pdf.js +266 -0
  10. package/server/core/invoicing-rules.js +12 -0
  11. package/server/core/job-board.js +25 -3
  12. package/server/core/line-mapping.js +36 -10
  13. package/server/core/pipeline.js +75 -3
  14. package/server/core/price-list.js +348 -0
  15. package/server/db/commands.js +42 -0
  16. package/server/db/doctor.js +168 -0
  17. package/server/db/migrations/0014_price_list.sql +34 -0
  18. package/server/db/migrations/0015_alert_dismissals.sql +17 -0
  19. package/server/db/migrations/meta/0014_snapshot.json +3773 -0
  20. package/server/db/migrations/meta/0015_snapshot.json +3854 -0
  21. package/server/db/migrations/meta/_journal.json +14 -0
  22. package/server/db/schema.js +44 -0
  23. package/server/flow-server.js +20 -1
  24. package/server/index.js +2 -1
  25. package/server/integrations/call-guard.js +362 -0
  26. package/server/integrations/port.js +20 -1
  27. package/server/integrations/seazona/feed.js +23 -1
  28. package/server/integrations/seazona/governor.js +50 -122
  29. package/server/integrations/seazona/history/backfill.js +208 -0
  30. package/server/integrations/seazona/history/csv.js +65 -0
  31. package/server/integrations/seazona/history/index.js +584 -0
  32. package/server/integrations/seazona/history/read.js +196 -0
  33. package/server/integrations/seazona/index.js +37 -1
  34. package/server/integrations/seazona/lanes.js +186 -50
  35. package/server/integrations/seazona/migrations/0003_call_guard.sql +2 -0
  36. package/server/integrations/seazona/migrations/0004_history_import.sql +68 -0
  37. package/server/integrations/seazona/migrations/meta/0003_snapshot.json +895 -0
  38. package/server/integrations/seazona/migrations/meta/0004_snapshot.json +1323 -0
  39. package/server/integrations/seazona/migrations/meta/_journal.json +14 -0
  40. package/server/integrations/seazona/routes.js +145 -0
  41. package/server/integrations/seazona/schema.js +94 -0
  42. package/server/integrations/seazona/settings.js +14 -3
  43. package/server/integrations/seazona/sync-once.js +1 -1
  44. package/server/integrations/seazona/tables.js +4 -0
  45. package/server/integrations/xero/client.js +27 -7
  46. package/server/integrations/xero/drafts.js +46 -8
  47. package/server/integrations/xero/import.js +125 -17
  48. package/server/integrations/xero/index.js +36 -7
  49. package/server/integrations/xero/lookup.js +26 -0
  50. package/server/integrations/xero/migrations/0006_xero_call_guard.sql +26 -0
  51. package/server/integrations/xero/migrations/meta/_journal.json +7 -0
  52. package/server/integrations/xero/mirror.js +11 -2
  53. package/server/integrations/xero/oauth.js +4 -1
  54. package/server/integrations/xero/reconcile.js +173 -0
  55. package/server/integrations/xero/routes.js +35 -0
  56. package/server/integrations/xero/runtime.js +45 -4
  57. package/server/release-channel.js +3 -3
  58. package/server/ui/alerts.js +77 -0
  59. package/server/ui/app-page.js +1 -1
  60. package/server/ui/app.js +48 -14
  61. package/server/ui/clerk-appearance.js +42 -9
  62. package/server/ui/flow.css +31 -2
  63. package/server/ui/invoicing-settings.js +1 -1
  64. package/server/ui/invoicing.js +121 -8
  65. package/server/ui/line-mappings.js +3 -2
  66. package/server/ui/price-list.js +247 -0
  67. package/server/ui/routes.js +1 -1
@@ -22,6 +22,20 @@
22
22
  "when": 1792448378477,
23
23
  "tag": "0002_call_safety",
24
24
  "breakpoints": true
25
+ },
26
+ {
27
+ "idx": 3,
28
+ "version": "7",
29
+ "when": 1792452091297,
30
+ "tag": "0003_call_guard",
31
+ "breakpoints": true
32
+ },
33
+ {
34
+ "idx": 4,
35
+ "version": "7",
36
+ "when": 1792453111948,
37
+ "tag": "0004_history_import",
38
+ "breakpoints": true
25
39
  }
26
40
  ]
27
41
  }
@@ -0,0 +1,145 @@
1
+ import express from 'express';
2
+ import { FlowError } from '../../core/errors.js';
3
+ import { listHistoryJobs, releaseHistoryJob } from '../../core/history.js';
4
+ import { soleOrganisation } from '../../core/pipeline.js';
5
+ import {
6
+ applyBatch,
7
+ cancelBatch,
8
+ decideJobs,
9
+ describeColumns,
10
+ historySummary,
11
+ listBatches,
12
+ readBatch,
13
+ reconcileBatch,
14
+ stageBatch,
15
+ } from './history/index.js';
16
+ import { readBackfill, startBackfill, stopBackfill } from './history/backfill.js';
17
+
18
+ // The Seazona adapter's routes, mounted at /api/seazona (server/integrations/port.js).
19
+ // The history import (docs/SEAZONA.md, "History import"):
20
+ //
21
+ // GET /history any signed-in person: totals and batches
22
+ // GET /history/columns any signed-in person: the headers Flow reads
23
+ // POST /history/batches owner or admin: upload a CSV export
24
+ // (text/csv body, ?fileName=; or JSON
25
+ // { csv, fileName, columns })
26
+ // GET /history/batches/:id?outcome=&limit=&offset= any signed-in person
27
+ // POST /history/batches/:id/reconcile owner or admin: match again
28
+ // POST /history/batches/:id/decisions owner or admin: { decisions: [{ jobKey, decision, clientId? }] }
29
+ // POST /history/batches/:id/apply owner or admin: write the batch into Flow's history
30
+ // POST /history/batches/:id/cancel owner or admin
31
+ // POST /history/backfill owner or admin: { from, through? } reads
32
+ // past orders from the Seazona API, a few a run
33
+ // POST /history/backfill/stop owner or admin
34
+ // GET /history/ledger?state=&limit=&offset= any signed-in person: held
35
+ // history jobs by accounting state
36
+ // (state: one state, or needs_person)
37
+ // POST /history/ledger/:obligationId/release owner or admin: the job may draft
38
+ //
39
+ // None of these call Seazona.
40
+
41
+ export const MAX_UPLOAD = '25mb';
42
+
43
+ function sendError(res, error, log) {
44
+ const status = error instanceof FlowError ? (error.status ?? 400) : 500;
45
+ if (status >= 500) log.error?.(`[seazona] history import: ${error.code ?? 'error'}: ${error.message}`);
46
+ const body = { ok: false, code: error.code ?? 'seazona_failed', error: status === 500 ? 'Something went wrong with the import.' : error.message };
47
+ if (error.details !== undefined) body.details = error.details;
48
+ return res.status(status).json(body);
49
+ }
50
+
51
+ const handle = (log, fn) => async (req, res) => {
52
+ try {
53
+ await fn(req, res);
54
+ } catch (error) {
55
+ sendError(res, error, log);
56
+ }
57
+ };
58
+
59
+ export function createSeazonaRouter({ db, personAuth, settings, log = console }) {
60
+ const router = express.Router();
61
+ // Sign-in off: every route answers 503, as the Xero routes do.
62
+ const signedIn = personAuth
63
+ ? [personAuth.requireSession]
64
+ : [(_req, res) => res.status(503).json({ ok: false, code: 'sign_in_not_configured', error: 'Sign-in is not configured on this installation (FLOW_AUTH is unset).' })];
65
+ const manager = personAuth ? [...signedIn, personAuth.requireRole('owner', 'admin')] : signedIn;
66
+ const actor = (req) => (req.person?.id ? `person:${req.person.id}` : 'person:unknown');
67
+ const json = express.json({ limit: MAX_UPLOAD });
68
+ const csv = express.text({ type: ['text/csv', 'text/plain', 'application/csv'], limit: MAX_UPLOAD });
69
+
70
+ if (!db) {
71
+ router.use((req, res) => res.status(503).json({ ok: false, code: 'no_database', error: 'Flow has no database' }));
72
+ return router;
73
+ }
74
+
75
+ router.get('/history', ...signedIn, handle(log, async (req, res) => {
76
+ res.json({ ok: true, history: await historySummary(db), backfill: await readBackfill(db), batches: await listBatches(db) });
77
+ }));
78
+
79
+ router.get('/history/columns', ...signedIn, (req, res) => res.json({ ok: true, profile: 'invoice_lines', columns: describeColumns() }));
80
+
81
+ router.post('/history/batches', ...manager, csv, json, handle(log, async (req, res) => {
82
+ const body = typeof req.body === 'string' ? { csv: req.body, fileName: req.query.fileName } : (req.body ?? {});
83
+ const batch = await stageBatch(db, {
84
+ csv: body.csv,
85
+ fileName: body.fileName ?? null,
86
+ columns: body.columns ?? {},
87
+ createdBy: actor(req),
88
+ keepPatientReference: Boolean(settings?.keepPatientReference),
89
+ });
90
+ log.info?.(`[seazona] history import: batch ${batch.id} ${batch.duplicate ? 'already staged' : 'staged'} by ${actor(req)}: ${JSON.stringify(batch.counts)}`);
91
+ res.status(batch.duplicate ? 200 : 201).json({ ok: true, batch });
92
+ }));
93
+
94
+ router.get('/history/batches/:id', ...signedIn, handle(log, async (req, res) => {
95
+ const { outcome = null, limit, offset } = req.query;
96
+ res.json({ ok: true, batch: await readBatch(db, req.params.id, { outcome: outcome || null, limit, offset }) });
97
+ }));
98
+
99
+ router.post('/history/batches/:id/reconcile', ...manager, handle(log, async (req, res) => {
100
+ res.json({ ok: true, batch: await reconcileBatch(db, req.params.id) });
101
+ }));
102
+
103
+ router.post('/history/batches/:id/decisions', ...manager, json, handle(log, async (req, res) => {
104
+ res.json({ ok: true, batch: await decideJobs(db, req.params.id, req.body?.decisions, { by: actor(req) }) });
105
+ }));
106
+
107
+ router.post('/history/batches/:id/apply', ...manager, handle(log, async (req, res) => {
108
+ const batch = await applyBatch(db, req.params.id, { by: actor(req) });
109
+ log.info?.(`[seazona] history import: batch ${batch.id} applied by ${actor(req)}: ${batch.applied} new, ${batch.updated} updated`);
110
+ res.json({ ok: true, batch });
111
+ }));
112
+
113
+ router.post('/history/batches/:id/cancel', ...manager, handle(log, async (req, res) => {
114
+ res.json({ ok: true, batch: await cancelBatch(db, req.params.id) });
115
+ }));
116
+
117
+ const organisation = async () => {
118
+ const id = await soleOrganisation(db);
119
+ if (!id) throw Object.assign(new FlowError('invalid', 'the installation needs exactly one organisation'), { status: 409 });
120
+ return id;
121
+ };
122
+
123
+ router.post('/history/backfill', ...manager, json, handle(log, async (req, res) => {
124
+ const backfill = await startBackfill(db, { from: req.body?.from, through: req.body?.through ?? null, createdBy: actor(req), settings });
125
+ log.info?.(`[seazona] history backfill from ${backfill.from} to ${backfill.through} started by ${actor(req)}`);
126
+ res.status(201).json({ ok: true, backfill });
127
+ }));
128
+
129
+ router.post('/history/backfill/stop', ...manager, handle(log, async (req, res) => {
130
+ res.json({ ok: true, backfill: await stopBackfill(db) });
131
+ }));
132
+
133
+ router.get('/history/ledger', ...signedIn, handle(log, async (req, res) => {
134
+ const { state = null, limit, offset } = req.query;
135
+ res.json({ ok: true, ...(await listHistoryJobs(db, await organisation(), { state: state || null, limit, offset })) });
136
+ }));
137
+
138
+ router.post('/history/ledger/:obligationId/release', ...manager, handle(log, async (req, res) => {
139
+ const row = await releaseHistoryJob(db, await organisation(), req.params.obligationId, { actor: actor(req) });
140
+ log.info?.(`[seazona] history import: job ${row.sourceJobRef} released by ${actor(req)}`);
141
+ res.json({ ok: true, job: { id: row.id, holdState: row.holdState } });
142
+ }));
143
+
144
+ return router;
145
+ }
@@ -21,6 +21,9 @@ export const seazonaApiGovernor = pgTable('seazona_api_governor', {
21
21
  circuitOpens: integer('circuit_opens').notNull().default(0),
22
22
  haltedReason: text('halted_reason'),
23
23
  haltedAt: at('halted_at'),
24
+ // The call guard's stopped resources (../call-guard.js). Seazona's guard
25
+ // halts on any 401 or 403, so this stays empty; the shared guard needs it.
26
+ stoppedResources: jsonb('stopped_resources').notNull().default({}),
24
27
  updatedAt: at('updated_at').notNull().defaultNow(),
25
28
  });
26
29
 
@@ -33,6 +36,7 @@ export const seazonaApiRequests = pgTable('seazona_api_requests', {
33
36
  clientKey: text('client_key').notNull(),
34
37
  workflow: text('workflow').notNull(),
35
38
  admittedAt: at('admitted_at').notNull(),
39
+ resource: text('resource'),
36
40
  requestPath: text('request_path'),
37
41
  statusCode: integer('status_code'),
38
42
  failureKind: text('failure_kind'),
@@ -177,3 +181,93 @@ export const seazonaRecordReadFailures = pgTable('seazona_record_read_failures',
177
181
  }, (t) => [
178
182
  primaryKey({ columns: [t.resource, t.recordKey] }),
179
183
  ]);
184
+
185
+ // History import (docs/SEAZONA.md, "History import"). Seazona's own CSV
186
+ // export brings in the years before the API sync started, with no API call.
187
+ // A batch is one uploaded file. Its rows are kept as Flow read them (only the
188
+ // columns Flow knows; never an unknown column, and no patient field unless
189
+ // SEAZONA_KEEP_PATIENT_REFERENCE is on). Its jobs are the rows grouped into
190
+ // work, each with what reconciliation found and what a person decided.
191
+ // Applying a batch copies its jobs into `seazona_history_jobs`, Flow's own
192
+ // record of past work. A batch is never deleted; a cancelled one stays.
193
+ export const seazonaHistoryBatches = pgTable('seazona_history_batches', {
194
+ id: bigserial('id', { mode: 'number' }).primaryKey(),
195
+ fileName: text('file_name'),
196
+ // The file's sha256: the same file uploaded twice finds its first batch.
197
+ fileSha256: text('file_sha256').notNull(),
198
+ profile: text('profile').notNull(),
199
+ // Flow field -> the file's column header.
200
+ columns: jsonb('columns').notNull().default({}),
201
+ // Headers Flow does not know. Only the names are kept, never the values.
202
+ ignoredColumns: jsonb('ignored_columns').notNull().default([]),
203
+ // staged -> reconciled -> applied, or cancelled.
204
+ status: text('status').notNull(),
205
+ counts: jsonb('counts').notNull().default({}),
206
+ createdBy: text('created_by').notNull(),
207
+ createdAt: at('created_at').notNull().defaultNow(),
208
+ reconciledAt: at('reconciled_at'),
209
+ appliedAt: at('applied_at'),
210
+ appliedBy: text('applied_by'),
211
+ }, (t) => [
212
+ index('seazona_history_batches_file').on(t.fileSha256),
213
+ ]);
214
+
215
+ export const seazonaHistoryRows = pgTable('seazona_history_rows', {
216
+ batchId: bigint('batch_id', { mode: 'number' }).notNull(),
217
+ rowNumber: integer('row_number').notNull(),
218
+ jobKey: text('job_key'),
219
+ fields: jsonb('fields').notNull(),
220
+ // Why the row cannot be read, for example `missing_date`. Empty when fine.
221
+ problems: jsonb('problems').notNull().default([]),
222
+ }, (t) => [
223
+ primaryKey({ columns: [t.batchId, t.rowNumber] }),
224
+ ]);
225
+
226
+ export const seazonaHistoryBatchJobs = pgTable('seazona_history_batch_jobs', {
227
+ batchId: bigint('batch_id', { mode: 'number' }).notNull(),
228
+ jobKey: text('job_key').notNull(),
229
+ job: jsonb('job').notNull(),
230
+ recordHash: text('record_hash').notNull(),
231
+ // What reconciliation found: new, same, changed, needs_person.
232
+ outcome: text('outcome'),
233
+ // Each finding: { code, blocking, detail }.
234
+ findings: jsonb('findings').notNull().default([]),
235
+ // What Seazona's API copy holds for the same work, when Flow found it.
236
+ mirrorOrderId: text('mirror_order_id'),
237
+ mirrorInvoiceNumber: text('mirror_invoice_number'),
238
+ clientId: text('client_id'),
239
+ // A person's choice: import, skip, or null (follow the outcome).
240
+ decision: text('decision'),
241
+ decidedBy: text('decided_by'),
242
+ decidedAt: at('decided_at'),
243
+ }, (t) => [
244
+ primaryKey({ columns: [t.batchId, t.jobKey] }),
245
+ ]);
246
+
247
+ export const seazonaHistoryJobs = pgTable('seazona_history_jobs', {
248
+ jobKey: text('job_key').primaryKey(),
249
+ invoiceNumber: text('invoice_number'),
250
+ caseRef: text('case_ref'),
251
+ clientId: text('client_id'),
252
+ company: text('company'),
253
+ doctorName: text('doctor_name'),
254
+ status: text('status'),
255
+ jobDate: text('job_date'),
256
+ dueDate: text('due_date'),
257
+ shippedDate: text('shipped_date'),
258
+ lines: jsonb('lines').notNull().default([]),
259
+ subtotalCents: cents('subtotal_cents'),
260
+ totalCents: cents('total_cents'),
261
+ patientReference: text('patient_reference'),
262
+ mirrorOrderId: text('mirror_order_id'),
263
+ mirrorInvoiceNumber: text('mirror_invoice_number'),
264
+ recordHash: text('record_hash').notNull(),
265
+ // The batch that last wrote the row, and the first one.
266
+ batchId: bigint('batch_id', { mode: 'number' }).notNull(),
267
+ firstBatchId: bigint('first_batch_id', { mode: 'number' }).notNull(),
268
+ firstImportedAt: at('first_imported_at').notNull(),
269
+ lastChangedAt: at('last_changed_at').notNull(),
270
+ }, (t) => [
271
+ index('seazona_history_jobs_invoice').on(t.invoiceNumber),
272
+ index('seazona_history_jobs_changed').on(t.lastChangedAt),
273
+ ]);
@@ -99,19 +99,30 @@ export function resolveSeazonaSettings(env = process.env) {
99
99
  clients: { cadenceMs: ms('SEAZONA_CLIENT_CADENCE_MS', 30 * MINUTE, MINUTE, DAY_MS), freshnessMs: 60 * MINUTE },
100
100
  products: { cadenceMs: ms('SEAZONA_REFERENCE_CADENCE_MS', DAY_MS, MINUTE, 30 * DAY_MS), freshnessMs: 2 * DAY_MS },
101
101
  users: { cadenceMs: ms('SEAZONA_REFERENCE_CADENCE_MS', DAY_MS, MINUTE, 30 * DAY_MS), freshnessMs: 2 * DAY_MS },
102
+ // The history backfill (docs/SEAZONA.md, "History import"). It calls
103
+ // Seazona only while a person has started one.
104
+ history: { cadenceMs: ms('SEAZONA_HISTORY_CADENCE_MS', 2 * MINUTE, MINUTE, DAY_MS), freshnessMs: 30 * DAY_MS },
102
105
  },
106
+ // Calls the history backfill makes per run, at most: a small share of
107
+ // the rate limit, so the live lanes always have room.
108
+ historyCallsPerRun: clampInt(env.SEAZONA_HISTORY_CALLS_PER_RUN, 10, 1, 25),
109
+ historyPageSize: clampInt(env.SEAZONA_HISTORY_PAGE_SIZE, 500, 1, 500),
103
110
  // Re-read this much before a lastModified cursor, so a change written
104
111
  // while the last run was reading is not missed.
105
112
  cursorOverlapMs: 5 * MINUTE,
106
113
  // The recent-order lane reads orders placed in the last N days, a few
107
- // days per run (GET orders/?ordered= matches one UTC day).
114
+ // days per run, page by page (GET orders/?ordered=&page=&pageSize=).
108
115
  orderLookbackDays: clampInt(env.SEAZONA_ORDER_LOOKBACK_DAYS, 30, 1, 3650),
109
116
  orderDaysPerRun: clampInt(env.SEAZONA_ORDER_DAYS_PER_RUN, 6, 1, 31),
110
117
  // The first invoice run reads from the start of today (UTC) minus this.
111
118
  invoiceLookbackDays: clampInt(env.SEAZONA_INVOICE_LOOKBACK_DAYS, 0, 0, 3650),
112
- // Seazona documents no paging. A list exactly this long may be cut off,
113
- // so the lane holds its cursor instead of trusting it.
119
+ // A list this long may be cut off, so the lane reads on (issue #125):
120
+ // the next part of a changed list from its last row, the next order
121
+ // page. Also the order page size (at most 500).
114
122
  listCap: clampInt(env.SEAZONA_LIST_CAP, 50, 1, 10_000),
123
+ // A lane makes at most this many list calls a run; the next run goes on
124
+ // from where it stopped.
125
+ listPagesPerRun: clampInt(env.SEAZONA_LIST_PAGES_PER_RUN, 10, 1, 100),
115
126
  // The active-order lane re-reads this many open orders per run, oldest first.
116
127
  activeOrdersPerRun: clampInt(env.SEAZONA_ACTIVE_ORDERS_PER_RUN, 25, 1, 500),
117
128
  // Issue #12: after this many failed reads in a row an order waits this long.
@@ -6,7 +6,7 @@
6
6
  // DATABASE_URL=postgres://… FLOW_INTEGRATIONS=seazona SEAZONA_MODE=sandbox … \
7
7
  // node server/integrations/seazona/sync-once.js [lane …]
8
8
  //
9
- // Lanes: invoices, orders_recent, orders_active, clients, products, users.
9
+ // Lanes: invoices, orders_recent, orders_active, clients, products, users, history.
10
10
  // None named: all of them.
11
11
  import { openDatabase } from '../../db/index.js';
12
12
  import { resolveEnabledIntegrations } from '../index.js';
@@ -14,5 +14,9 @@ export const seazonaSchema = Object.freeze({
14
14
  'seazona_lane_state',
15
15
  'seazona_order_read_failures',
16
16
  'seazona_record_read_failures',
17
+ 'seazona_history_batches',
18
+ 'seazona_history_rows',
19
+ 'seazona_history_batch_jobs',
20
+ 'seazona_history_jobs',
17
21
  ]),
18
22
  });
@@ -2,9 +2,11 @@ import { FlowError } from '../../core/errors.js';
2
2
  import { XERO_ENDPOINTS } from './settings.js';
3
3
 
4
4
  // The Xero Accounting API client. Every call goes through the rate-limit
5
- // governor (governor.js). A 429 is retried after the wait Xero asks for,
6
- // except a DayLimit 429, which opens the circuit and fails fast. Tokens are
7
- // sent only in the Authorization header and never logged or put in errors.
5
+ // governor (governor.js) and, when one is given, the call guard
6
+ // (../call-guard.js): a call the guard has stopped fails at once, and every
7
+ // call's ending is recorded there. A 429 is retried after the wait Xero asks
8
+ // for, except a DayLimit 429, which opens the circuit and fails fast. Tokens
9
+ // are sent only in the Authorization header and never logged or put in errors.
8
10
 
9
11
  export const DEFAULT_TIMEOUT_MS = 15_000;
10
12
  export const DEFAULT_ATTEMPTS = 2;
@@ -86,6 +88,14 @@ function quote(value) {
86
88
 
87
89
  const first = (list) => (Array.isArray(list) && list.length ? list[0] : null);
88
90
 
91
+ // The resource a call reads or writes, for the call guard: the first path
92
+ // segment (Invoices, Payments, Contacts). A 401 or 403 that a fresh token
93
+ // does not clear stops that resource only, so a scope the connection lacks
94
+ // (for example payments) does not stop the rest.
95
+ export function resourceOf(path) {
96
+ return String(path).replace(/^\/+/, '').split(/[/?#]/)[0] || null;
97
+ }
98
+
89
99
  // `accessToken()` returns a current access token; `invalidateAccessToken()`
90
100
  // drops a cached one after Xero answers 401, and the request is tried once
91
101
  // more with a fresh token.
@@ -95,6 +105,7 @@ export function createXeroClient({
95
105
  invalidateAccessToken = () => {},
96
106
  fetch: fetchImpl = globalThis.fetch,
97
107
  governor,
108
+ guard = null,
98
109
  apiBase = XERO_ENDPOINTS.api,
99
110
  timeoutMs = DEFAULT_TIMEOUT_MS,
100
111
  attempts = DEFAULT_ATTEMPTS,
@@ -106,10 +117,12 @@ export function createXeroClient({
106
117
  const base = apiBase.endsWith('/') ? apiBase : `${apiBase}/`;
107
118
  let lastRateLimit = null;
108
119
 
109
- async function send(method, url, headers, bodyText) {
120
+ // `call` is { resource, path } for the guard.
121
+ async function send(method, url, headers, bodyText, call) {
110
122
  await governor.acquire(tenantId);
111
123
  const controller = new AbortController();
112
124
  const timer = setTimeout(() => controller.abort(), timeoutMs);
125
+ const startedAt = Date.now();
113
126
  try {
114
127
  let response;
115
128
  try {
@@ -117,6 +130,7 @@ export function createXeroClient({
117
130
  } catch (error) {
118
131
  if (error?.code === 'xero_replay_no_match') throw error;
119
132
  const timedOut = error?.name === 'AbortError';
133
+ await guard?.record(tenantId, { ...call, failureKind: timedOut ? 'timeout' : 'network', durationMs: Date.now() - startedAt });
120
134
  throw xeroError(timedOut ? 'xero_timeout' : 'xero_unreachable',
121
135
  timedOut ? `Xero did not answer within ${timeoutMs} ms.` : 'Flow could not reach Xero.',
122
136
  { status: timedOut ? 504 : 503 });
@@ -124,7 +138,7 @@ export function createXeroClient({
124
138
  const text = await response.text().catch(() => '');
125
139
  lastRateLimit = readRateLimitHeaders(response);
126
140
  governor.observe(tenantId, lastRateLimit);
127
- return { response, text };
141
+ return { response, text, durationMs: Date.now() - startedAt };
128
142
  } finally {
129
143
  clearTimeout(timer);
130
144
  governor.release(tenantId);
@@ -139,8 +153,10 @@ export function createXeroClient({
139
153
  // Serialised once, so a retry sends the same bytes under the same
140
154
  // Idempotency-Key.
141
155
  const bodyText = body === undefined ? undefined : JSON.stringify(body);
156
+ const call = { resource: resourceOf(path), path: url.pathname };
142
157
  let refreshed = false;
143
158
  for (let attempt = 1; ; attempt += 1) {
159
+ await guard?.check(tenantId, { resource: call.resource });
144
160
  const headers = {
145
161
  ...extra,
146
162
  Authorization: `Bearer ${await accessToken()}`,
@@ -148,12 +164,16 @@ export function createXeroClient({
148
164
  'xero-tenant-id': tenantId,
149
165
  ...(bodyText === undefined ? {} : { 'Content-Type': 'application/json' }),
150
166
  };
151
- const { response, text } = await send(method, url, headers, bodyText);
167
+ const { response, text, durationMs } = await send(method, url, headers, bodyText, call);
152
168
  const payload = parseJson(text);
169
+ // A first 401 usually means the access token expired: it is not counted,
170
+ // and the call is tried once more with a fresh token.
171
+ const expiredToken = response.status === 401 && !refreshed;
172
+ if (!expiredToken) await guard?.record(tenantId, { ...call, statusCode: response.status, durationMs });
153
173
  if (response.ok) return payload ?? {};
154
174
 
155
175
  const error = apiError(response, payload);
156
- if (response.status === 401 && !refreshed) {
176
+ if (expiredToken) {
157
177
  refreshed = true;
158
178
  invalidateAccessToken();
159
179
  attempt -= 1;
@@ -1,5 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { and, eq, inArray, sql } from 'drizzle-orm';
3
+ import { isCallsStopped } from '../call-guard.js';
3
4
  import { getInvoice, listInvoiceVersions } from '../../core/invoices.js';
4
5
  import { sendModeFor } from '../../core/invoicing-rules.js';
5
6
  import { contacts as flowContacts, invoices as flowInvoices, priceItems } from '../../db/schema.js';
@@ -206,6 +207,38 @@ export async function listPushes(db, organisationId, invoiceId) {
206
207
  return rows.map(mapPush);
207
208
  }
208
209
 
210
+ // What the customer's Xero copy of one Flow invoice version says, from Flow's
211
+ // copy of Xero (no calls): the stored push, and the mirror row when the
212
+ // mirror has it. Before the version is in Xero, only who numbers it and the
213
+ // default due days are known.
214
+ export async function documentFacts(db, runtime, { organisationId, invoiceId, version = null }) {
215
+ const { settings } = runtime;
216
+ const facts = { system: 'Xero', sent: false, numberedBy: settings.invoiceNumber, defaultDueDays: settings.defaultDueDays };
217
+ if (!version) return facts;
218
+ const { rows: [push] } = await db.execute(sql`select xero_invoice_id, xero_invoice_number, xero_status, request_body
219
+ from xero_invoice_pushes
220
+ where organisation_id = ${organisationId} and invoice_id = ${invoiceId} and version = ${version} and xero_invoice_id is not null`);
221
+ if (!push) return facts;
222
+ const { rows: [copy] } = await db.execute(sql`select invoice_number, reference, status,
223
+ to_char(issued_on, 'YYYY-MM-DD') as issued_on, to_char(due_on, 'YYYY-MM-DD') as due_on
224
+ from xero_invoices where organisation_id = ${organisationId} and xero_invoice_id = ${push.xero_invoice_id}`);
225
+ let sent = {};
226
+ try {
227
+ sent = JSON.parse(push.request_body) ?? {};
228
+ } catch {
229
+ // An unreadable body leaves only the mirror and the push's own columns.
230
+ }
231
+ return {
232
+ ...facts,
233
+ sent: true,
234
+ number: copy?.invoice_number || push.xero_invoice_number || sent.InvoiceNumber || null,
235
+ reference: copy?.reference || sent.Reference || null,
236
+ issueDate: copy?.issued_on || sent.Date || null,
237
+ dueDate: copy?.due_on || sent.DueDate || null,
238
+ status: copy?.status || push.xero_status || null,
239
+ };
240
+ }
241
+
209
242
  async function settle(db, id, fields) {
210
243
  const { rows } = await db.execute(sql`update xero_invoice_pushes set
211
244
  state = ${fields.state},
@@ -313,7 +346,7 @@ export async function pushDrafts(db, runtime, organisationId, invoiceIds, { push
313
346
  try {
314
347
  results.push(await pushOne(db, runtime, client, organisationId, invoiceId, { pushedBy, allowExisting: allowed.has(invoiceId) }));
315
348
  } catch (error) {
316
- if (error.code === 'xero_rate_limited' || error.code === 'xero_reconnect_needed') {
349
+ if (stopsTheBatch(error)) {
317
350
  // Every later invoice would fail the same way: stop and say so.
318
351
  results.push(failed(invoiceId, error));
319
352
  for (const rest of invoiceIds.slice(invoiceIds.indexOf(invoiceId) + 1)) results.push(failed(rest, error, 'not_tried'));
@@ -392,9 +425,13 @@ async function assertTracking(client, tracking) {
392
425
  }
393
426
  }
394
427
 
395
- // Xero errors that prove no invoice was made: Xero refused the request.
428
+ // Errors after which every later call would fail the same way: stop the batch.
429
+ const stopsTheBatch = (error) => error.code === 'xero_rate_limited' || error.code === 'xero_reconnect_needed' || isCallsStopped(error);
430
+
431
+ // Xero errors that prove no invoice was made: Xero refused the request, or
432
+ // the call guard stopped it before it went.
396
433
  function provesNothingCreated(error) {
397
- return error.code === 'xero_validation_failed' || error.code === 'xero_rate_limited'
434
+ return isCallsStopped(error) || error.code === 'xero_validation_failed' || error.code === 'xero_rate_limited'
398
435
  || error.code === 'xero_unauthorized' || error.code === 'xero_reconnect_needed'
399
436
  || (error.code === 'xero_api_error' && error.status >= 400 && error.status < 500);
400
437
  }
@@ -509,7 +546,7 @@ async function dispatch(db, client, push, document, { fresh = false } = {}) {
509
546
  throw error;
510
547
  }
511
548
  await settle(db, push.id, { state: 'ambiguous', errorCode: error.code ?? 'xero_uncertain', errorMessage: error.message });
512
- if (error.code === 'xero_rate_limited' || error.code === 'xero_reconnect_needed') throw error;
549
+ if (stopsTheBatch(error)) throw error;
513
550
  return {
514
551
  invoiceId: push.invoiceId,
515
552
  version: push.version,
@@ -555,8 +592,9 @@ async function verify(db, client, push, draft, document, created, outcome) {
555
592
  // ---------------------------------------------------------------------------
556
593
  // Email
557
594
 
558
- // Xero errors that prove it sent no email: it refused the request.
559
- const emailRefused = (error) => error.code === 'xero_validation_failed'
595
+ // Xero errors that prove it sent no email: it refused the request, or the
596
+ // call guard stopped it before it went.
597
+ const emailRefused = (error) => isCallsStopped(error) || error.code === 'xero_validation_failed'
560
598
  || (error.code === 'xero_api_error' && error.status >= 400 && error.status < 500);
561
599
 
562
600
  // Asks Xero to email an approved invoice that Flow sent and checked, once.
@@ -576,7 +614,7 @@ async function emailIfDue(db, client, push) {
576
614
  ? `Xero did not email the invoice: ${error.message}`
577
615
  : 'Flow does not know whether Xero emailed the invoice. Check the invoice in Xero before you send it again.';
578
616
  await db.execute(sql`update xero_invoice_pushes set email_state = ${state}, email_error = ${message}, updated_at = now() where id = ${push.id}`);
579
- if (error.code === 'xero_rate_limited' || error.code === 'xero_reconnect_needed') throw error;
617
+ if (stopsTheBatch(error)) throw error;
580
618
  return { state, error: message, at: null };
581
619
  }
582
620
  const { rows: [done] } = await db.execute(sql`update xero_invoice_pushes set email_state = 'sent', email_error = null, emailed_at = now(), updated_at = now()
@@ -613,7 +651,7 @@ async function resume(db, runtime, client, push, document) {
613
651
  .filter((inv) => !GONE.has(inv.Status) && !push.preExistingIds.includes(inv.InvoiceID)
614
652
  && differences(inv, draft, document).filter((d) => d !== 'date').length === 0);
615
653
  } catch (error) {
616
- if (error.code === 'xero_rate_limited') throw error;
654
+ if (error.code === 'xero_rate_limited' || isCallsStopped(error)) throw error;
617
655
  return { invoiceId: push.invoiceId, version: push.version, outcome: 'ambiguous', error: { code: 'xero_outcome_unknown', message: 'Flow could not look in Xero to settle an earlier attempt. Send again later.' } };
618
656
  }
619
657
  if (candidates.length === 1) return verify(db, client, push, draft, document, candidates[0], 'reconciled');