@arcforgelabs/flow 2026.10.38 → 2026.10.40

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 (47) hide show
  1. package/README.md +1 -1
  2. package/npm-shrinkwrap.json +2 -2
  3. package/package.json +4 -2
  4. package/server/api/custom-fields.js +26 -9
  5. package/server/api/invoicing.js +149 -51
  6. package/server/app.js +5 -2
  7. package/server/core/alerts.js +1 -1
  8. package/server/core/automations.js +1 -1
  9. package/server/core/custom-fields.js +74 -6
  10. package/server/core/ledger.js +29 -7
  11. package/server/core/price-list.js +27 -2
  12. package/server/core/read-before-act.js +3 -2
  13. package/server/core/sending.js +36 -18
  14. package/server/db/doctor.js +18 -19
  15. package/server/db/index.js +74 -3
  16. package/server/db/migrate-all.js +3 -4
  17. package/server/db/migrations/0020_custom_field_colours.sql +21 -0
  18. package/server/db/migrations/meta/0020_snapshot.json +4377 -0
  19. package/server/db/migrations/meta/_journal.json +7 -0
  20. package/server/db/schema.js +14 -0
  21. package/server/integrations/call-guard.js +205 -15
  22. package/server/integrations/port.js +3 -2
  23. package/server/integrations/schema.js +9 -5
  24. package/server/integrations/seazona/settings.js +8 -4
  25. package/server/integrations/xero/drafts.js +4 -4
  26. package/server/integrations/xero/index.js +5 -1
  27. package/server/integrations/xero/migrations/0009_xero_sent_to_contact.sql +3 -0
  28. package/server/integrations/xero/migrations/meta/_journal.json +7 -0
  29. package/server/integrations/xero/mirror.js +4 -3
  30. package/server/integrations/xero/reconcile.js +25 -1
  31. package/server/integrations/xero/routes.js +24 -7
  32. package/server/integrations/xero/settings.js +2 -1
  33. package/server/ui/app.js +42 -6
  34. package/server/ui/custom-fields.js +252 -94
  35. package/server/ui/flow.css +185 -29
  36. package/server/ui/invoice-notes.js +2 -2
  37. package/server/ui/invoicing.js +92 -137
  38. package/server/ui/job-board.js +1 -1
  39. package/server/ui/job-change.js +23 -12
  40. package/server/ui/job-view.js +168 -33
  41. package/server/ui/jobs.js +316 -65
  42. package/server/ui/monitoring.js +13 -12
  43. package/server/ui/price-list.js +43 -86
  44. package/server/ui/price-match.js +93 -0
  45. package/server/ui/price-reconcile.js +357 -0
  46. package/server/ui/reconcile.js +1 -1
  47. package/server/ui/routes.js +1 -1
@@ -141,6 +141,13 @@
141
141
  "when": 1792474607940,
142
142
  "tag": "0019_people_views",
143
143
  "breakpoints": true
144
+ },
145
+ {
146
+ "idx": 20,
147
+ "version": "7",
148
+ "when": 1792507133665,
149
+ "tag": "0020_custom_field_colours",
150
+ "breakpoints": true
144
151
  }
145
152
  ]
146
153
  }
@@ -784,6 +784,8 @@ export const jobFields = pgTable('job_fields', {
784
784
  name: text('name').notNull(),
785
785
  type: text('type').notNull(),
786
786
  options: jsonb('options').notNull().default(sql`'[]'::jsonb`),
787
+ // The field's colour, one of the option colours; null is its type's colour.
788
+ color: text('color'),
787
789
  position: integer('position').notNull(),
788
790
  archivedAt: timestamp('archived_at', { withTimezone: true }),
789
791
  createdBy: uuid('created_by').references(() => people.id, { onDelete: 'set null' }),
@@ -794,8 +796,20 @@ export const jobFields = pgTable('job_fields', {
794
796
  unique('job_fields_org_id').on(t.organisationId, t.id),
795
797
  check('job_fields_type_valid', sql`${t.type} in ('dropdown', 'person', 'checkbox', 'date', 'number', 'text')`),
796
798
  check('job_fields_name_valid', sql`length(${t.name}) between 1 and 60`),
799
+ check('job_fields_color_valid', sql`${t.color} is null or ${t.color} in ('gray', 'blue', 'teal', 'green', 'amber', 'red', 'purple', 'pink')`),
797
800
  ]);
798
801
 
802
+ // One row per organisation for Settings, Custom fields: each type group's
803
+ // colour ({ dropdown: 'red' }; a type not named keeps its own colour), and
804
+ // when Flow made the Assignee, so a lab that deletes it does not get it back.
805
+ export const jobFieldSettings = pgTable('job_field_settings', {
806
+ organisationId: uuid('organisation_id').primaryKey().references(() => organisations.id),
807
+ groupColors: jsonb('group_colors').notNull().default(sql`'{}'::jsonb`),
808
+ assigneeMadeAt: timestamp('assignee_made_at', { withTimezone: true }),
809
+ updatedBy: uuid('updated_by').references(() => people.id, { onDelete: 'set null' }),
810
+ updatedAt: updatedAt(),
811
+ });
812
+
799
813
  // A job's value for one custom field. A job is the work system's job
800
814
  // (source_system, source_job_ref), the same key the Jobs board uses, so a
801
815
  // value needs no Flow copy of the job. `value` is JSON: an option id, a person
@@ -9,7 +9,10 @@ import { sql } from 'drizzle-orm';
9
9
  // - a refused credential halts every call until a person acts (saves the
10
10
  // settings or connects again, which calls resume());
11
11
  // - a 401 or 403 on one resource, when the adapter's auth failures stop a
12
- // resource only, stops that resource until a person acts;
12
+ // resource only, stops that resource; after `resourceRetryMs` one probe
13
+ // call may go, and a refused probe stops it again for twice as long, to
14
+ // at most `maxResourceRetryMs`. A good call, or a person connecting
15
+ // again, starts it again;
13
16
  // - `circuitFailures` failed calls inside `circuitWindowMs` open the
14
17
  // circuit: no call for `circuitPauseMs`, then a probe; a failed probe opens
15
18
  // it again for twice as long, to at most `maxCircuitPauseMs`, and a
@@ -34,6 +37,8 @@ export const CALL_GUARD_POLICY = Object.freeze({
34
37
  // 'all': a 401 or 403 halts every call. 'resource': it stops only the
35
38
  // resource it came from; halt() still stops everything.
36
39
  authStops: 'all',
40
+ resourceRetryMs: 6 * 60 * 60_000,
41
+ maxResourceRetryMs: 24 * 60 * 60_000,
37
42
  telemetryRetentionMs: 35 * 24 * 60 * 60_000,
38
43
  });
39
44
 
@@ -68,12 +73,31 @@ export function circuitPauseMs(opens, policy = CALL_GUARD_POLICY) {
68
73
  return Math.min(policy.maxCircuitPauseMs, policy.circuitPauseMs * 2 ** Math.min(Math.max(0, opens), 10));
69
74
  }
70
75
 
76
+ // The wait before the next probe of a stopped resource, after `probes`
77
+ // refused probes.
78
+ export function resourceRetryMs(probes, policy = CALL_GUARD_POLICY) {
79
+ return Math.min(policy.maxResourceRetryMs, policy.resourceRetryMs * 2 ** Math.min(Math.max(0, probes), 10));
80
+ }
81
+
82
+ // When a stopped resource may be probed again (ms). An entry written before
83
+ // probes existed has no `retryAt`: it is due `resourceRetryMs` after `at`.
84
+ export function resourceRetryAt(entry, policy = CALL_GUARD_POLICY) {
85
+ const due = entry?.retryAt ? new Date(entry.retryAt).getTime() : NaN;
86
+ if (Number.isFinite(due)) return due;
87
+ const at = entry?.at ? new Date(entry.at).getTime() : NaN;
88
+ return Number.isFinite(at) ? at + resourceRetryMs(0, policy) : Infinity;
89
+ }
90
+
71
91
  // Why no call may go now, or null. `state` is the guard row
72
92
  // ({ haltedReason, circuitOpenUntil, stoppedResources }); `failedToday`
73
93
  // counts failed calls since the start of the UTC day.
74
94
  export function stoppedReason(state, { nowMs, failedToday, resource = null }, policy = CALL_GUARD_POLICY) {
75
95
  if (state?.haltedReason) return { reason: 'halted', retryAfterMs: null };
76
- if (resource && state?.stoppedResources?.[resource]) return { reason: 'resource_stopped', retryAfterMs: null, resource };
96
+ const entry = resource ? state?.stoppedResources?.[resource] : null;
97
+ if (entry) {
98
+ const due = resourceRetryAt(entry, policy);
99
+ if (due > nowMs) return { reason: 'resource_stopped', retryAfterMs: due - nowMs, resource };
100
+ }
77
101
  const openUntil = state?.circuitOpenUntil ? new Date(state.circuitOpenUntil).getTime() : 0;
78
102
  if (openUntil > nowMs) return { reason: 'circuit_open', retryAfterMs: openUntil - nowMs };
79
103
  const dayStart = startOfUtcDay(nowMs);
@@ -95,7 +119,7 @@ export function circuitAfterFailure(state, { nowMs, recentFailures }, policy = C
95
119
 
96
120
  const STOP_TEXT = {
97
121
  halted: (name) => `${name} calls are stopped until a person checks the connection (it was refused)`,
98
- resource_stopped: (name, resource) => `${name} calls to ${resource} are stopped until a person checks the connection (they were refused)`,
122
+ resource_stopped: (name, resource) => `${name} calls to ${resource} are stopped (they were refused); Flow tries again later by itself`,
99
123
  circuit_open: (name) => `${name} calls are paused after repeated failures`,
100
124
  budget_spent: (name) => `${name} calls are stopped for today: the day's failed calls are spent`,
101
125
  };
@@ -138,12 +162,14 @@ export function guardAlerts(name, status, { fix = 'Check the connection, then co
138
162
  members: [`halted:${status.haltedAt ?? ''}`],
139
163
  });
140
164
  }
165
+ // One refused resource is a warning, not a stop: every other call still
166
+ // goes, and Flow probes the resource again by itself.
141
167
  const resources = status?.stoppedResources ?? [];
142
168
  if (resources.length) {
143
169
  alerts.push({
144
170
  id: 'resources-stopped',
145
- tone: 'danger',
146
- title: `${name} refused Flow for ${resources.map((r) => r.resource).join(', ')}. Flow stopped those calls. ${fix}`,
171
+ tone: 'warn',
172
+ title: `${name} refused Flow for ${resources.map((r) => r.resource).join(', ')}. Other ${name} calls still work. Flow tries again by itself. To fix it now, connect again.`,
147
173
  members: resources.map((r) => `${r.resource}:${r.at ?? ''}`),
148
174
  });
149
175
  }
@@ -233,7 +259,8 @@ export function createCallGuard({
233
259
  }
234
260
 
235
261
  // Records how one call ended, and what that does to the guard. Answers
236
- // what this call stopped ({ halted, resourceStopped, opened, budgetSpent }),
262
+ // what this call changed ({ halted, resourceStopped, resourceStillStopped,
263
+ // resourceResumed, opened, closed, budgetSpent }),
237
264
  // each of which was just reported once. `callId` names a call row the
238
265
  // adapter wrote when it admitted the call: that row is updated, not added.
239
266
  async function record(rawKey, { callId = null, resource = null, path = null, statusCode = null, failureKind = null, durationMs = null } = {}) {
@@ -256,22 +283,35 @@ export function createCallGuard({
256
283
  }
257
284
  const state = await readState(tx, key);
258
285
  if (!countsAsFailure({ statusCode: status, failureKind })) {
286
+ const result = {};
287
+ // A good probe: the resource is allowed again.
288
+ if (where && state.stoppedResources[where] && status !== null && status < 400) {
289
+ await tx.execute(sql`update ${T.state} set stopped_resources = stopped_resources - ${where}, updated_at = ${nowAt} where ${C.key} = ${key}`);
290
+ result.resourceResumed = where;
291
+ }
259
292
  if (state.circuitOpens > 0) {
260
293
  await tx.execute(sql`update ${T.state} set circuit_opens = 0, updated_at = ${nowAt} where ${C.key} = ${key}`);
261
- return { closed: true };
294
+ result.closed = true;
262
295
  }
263
- return {};
296
+ return result;
264
297
  }
265
298
  const result = {};
266
299
  const dayStart = startOfUtcDay(nowMs);
267
300
  if (await failuresSince(tx, key, dayStart) === policy.dailyFailureBudget) result.budgetSpent = { until: new Date(dayStart + DAY_MS).toISOString() };
268
301
  if (isAuthRefusal(status)) {
269
302
  if (policy.authStops === 'resource' && where) {
270
- if (!state.stoppedResources[where]) {
271
- await tx.execute(sql`update ${T.state} set stopped_resources = stopped_resources || ${JSON.stringify({ [where]: { statusCode: status, at: nowAt.toISOString() } })}::jsonb,
272
- updated_at = ${nowAt} where ${C.key} = ${key}`);
273
- result.resourceStopped = where;
274
- }
303
+ const before = state.stoppedResources[where];
304
+ const probes = before ? (Number(before.probes) || 0) + 1 : 0;
305
+ const entry = {
306
+ statusCode: status,
307
+ at: before?.at ?? nowAt.toISOString(),
308
+ probes,
309
+ retryAt: new Date(nowMs + resourceRetryMs(probes, policy)).toISOString(),
310
+ };
311
+ await tx.execute(sql`update ${T.state} set stopped_resources = stopped_resources || ${JSON.stringify({ [where]: entry })}::jsonb,
312
+ updated_at = ${nowAt} where ${C.key} = ${key}`);
313
+ if (before) result.resourceStillStopped = { resource: where, retryAt: entry.retryAt };
314
+ else result.resourceStopped = where;
275
315
  return result;
276
316
  }
277
317
  if (!state.haltedReason) {
@@ -301,7 +341,9 @@ export function createCallGuard({
301
341
  if (!reports) return;
302
342
  const cause = status ? `HTTP ${status}` : failureKind;
303
343
  if (outcome.halted) log.error?.(`${tag} ${name} refused the connection (${cause}). Calls are stopped until a person checks the ${name} settings or connects again.`);
304
- if (outcome.resourceStopped) log.error?.(`${tag} ${name} refused calls to ${where} (${cause}). Calls to ${where} are stopped until a person checks the connection.`);
344
+ if (outcome.resourceStopped) log.error?.(`${tag} ${name} refused calls to ${where} (${cause}). Calls to ${where} are stopped; Flow tries again by itself, or a person connects again.`);
345
+ if (outcome.resourceStillStopped) log.warn?.(`${tag} ${name} still refuses calls to ${where} (${cause}). Flow tries again at ${outcome.resourceStillStopped.retryAt}.`);
346
+ if (outcome.resourceResumed) log.info?.(`${tag} ${name} allows calls to ${outcome.resourceResumed} again.`);
305
347
  if (outcome.opened) {
306
348
  const line = `${tag} ${name} calls failed repeatedly (last: ${cause}${where ? ` on ${where}` : ''}). Calls pause until ${outcome.opened.until}.`;
307
349
  if (outcome.opened.opens === 1) log.error?.(line);
@@ -352,7 +394,7 @@ export function createCallGuard({
352
394
  circuitOpenUntil: openUntil > nowMs ? iso(openUntil) : null,
353
395
  haltedReason: state.haltedReason,
354
396
  haltedAt: iso(state.haltedAt),
355
- stoppedResources: Object.entries(state.stoppedResources).map(([resource, entry]) => ({ resource, ...entry })),
397
+ stoppedResources: Object.entries(state.stoppedResources).map(([resource, entry]) => ({ resource, ...entry, retryAt: Number.isFinite(resourceRetryAt(entry, policy)) ? iso(resourceRetryAt(entry, policy)) : null })),
356
398
  failedToday: await failuresSince(db, key, startOfUtcDay(nowMs)),
357
399
  dailyFailureBudget: policy.dailyFailureBudget,
358
400
  };
@@ -360,3 +402,151 @@ export function createCallGuard({
360
402
 
361
403
  return { integration, policy, check, stoppedNow, record, halt, resume, status };
362
404
  }
405
+
406
+ // ── Button presses ─────────────────────────────────────────────────────────
407
+ //
408
+ // The action guard: a person's button press that calls an outside system (a
409
+ // copy, a recheck, a send) is one action. Pressed again and again, it spends
410
+ // the provider's rate limit and can trip the call guard above. So each
411
+ // guarded route:
412
+ //
413
+ // - runs once at a time per key: a second press while it runs waits
414
+ // (429, `action_running`);
415
+ // - after a run, waits `cooldownMs` before it runs again for that key
416
+ // (429, `action_cooldown`);
417
+ // - after a failed run (a 5xx, a 429 or a stopped call), waits twice as
418
+ // long each time, to at most `maxCooldownMs`; a good run resets it;
419
+ // - shares one budget per integration: at most `budget` runs in `budgetMs`
420
+ // for all its actions together (429, `action_budget`). An action with
421
+ // `budget: false` (a send a person makes one invoice at a time) is
422
+ // outside it; the provider's rate limit still paces its calls.
423
+ //
424
+ // A 429 carries Retry-After and `retryAfterMs`, so the page shows the wait.
425
+ // A good run says its cooldown in the `Flow-Cooldown-Ms` header. A refused
426
+ // request (another 4xx) costs nothing: no call went. The state is in memory:
427
+ // one Flow process serves one lab (server/rate-limit.js), and the call guard
428
+ // above still protects every process.
429
+
430
+ export const ACTION_GUARD_POLICY = Object.freeze({
431
+ cooldownMs: 30_000,
432
+ maxCooldownMs: 15 * 60_000,
433
+ budget: 30,
434
+ budgetMs: 10 * 60_000,
435
+ });
436
+
437
+ const ACTION_TEXT = {
438
+ action_running: () => 'This is already running. Wait for it to finish.',
439
+ action_cooldown: (seconds) => `Flow just did this. Try again in ${seconds} seconds.`,
440
+ action_budget: (seconds) => `Too many requests to this system. Try again in ${seconds} seconds.`,
441
+ };
442
+
443
+ // The wait after `failures` failed runs in a row.
444
+ export function actionCooldownMs(failures, { cooldownMs, maxCooldownMs }) {
445
+ if (failures <= 0) return cooldownMs;
446
+ return Math.min(maxCooldownMs, Math.max(cooldownMs, 5_000) * 2 ** Math.min(failures, 10));
447
+ }
448
+
449
+ // How a response ends a run: 'ok', 'failed' (the system or the call guard
450
+ // said no) or 'refused' (Flow refused the request; nothing was called).
451
+ export function actionOutcome(statusCode) {
452
+ if (statusCode >= 500 || statusCode === 429) return 'failed';
453
+ if (statusCode >= 400) return 'refused';
454
+ return 'ok';
455
+ }
456
+
457
+ const cleanPart = (value) => String(value ?? '').replace(/[^A-Za-z0-9_.:-]+/g, '').slice(0, 80);
458
+
459
+ // `integration` names the budget; `actions` maps each action name to its
460
+ // policy ({ cooldownMs, maxCooldownMs, budget }). `route(name, { key })` answers the
461
+ // Express middleware for one route; `key(req)` names what the cooldown
462
+ // covers inside the organisation (an invoice id, say), or null for all of it.
463
+ // `off: true` lets every press through (tests that press one route often).
464
+ export function createActionGuard({ integration, actions = {}, policy: overrides = {}, off = false, now = Date.now } = {}) {
465
+ if (!/^[a-z][a-z0-9-]{1,31}$/.test(integration ?? '')) throw new TypeError('createActionGuard: integration must be an adapter id');
466
+ const policy = { ...ACTION_GUARD_POLICY, ...overrides };
467
+ const runs = new Map(); // key -> { running, failures, readyAt }
468
+ const started = []; // start times inside the budget window
469
+ const policyOf = (name) => ({ cooldownMs: policy.cooldownMs, maxCooldownMs: policy.maxCooldownMs, ...(actions[name] ?? {}) });
470
+
471
+ const runOf = (key) => {
472
+ let run = runs.get(key);
473
+ if (!run) runs.set(key, (run = { running: false, failures: 0, readyAt: 0 }));
474
+ return run;
475
+ };
476
+
477
+ // Why `name` may not run for `key` now, or null.
478
+ function waitFor(name, key) {
479
+ const nowMs = Number(now());
480
+ const run = runs.get(key);
481
+ if (run?.running) return { code: 'action_running', retryAfterMs: 5_000 };
482
+ if (run && run.readyAt > nowMs) return { code: 'action_cooldown', retryAfterMs: run.readyAt - nowMs };
483
+ if (policyOf(name).budget === false) return null;
484
+ while (started.length && started[0] <= nowMs - policy.budgetMs) started.shift();
485
+ if (started.length >= policy.budget) return { code: 'action_budget', retryAfterMs: started[0] + policy.budgetMs - nowMs };
486
+ return null;
487
+ }
488
+
489
+ function begin(name, key) {
490
+ const wait = waitFor(name, key);
491
+ if (wait) return wait;
492
+ runOf(key).running = true;
493
+ if (policyOf(name).budget !== false) started.push(Number(now()));
494
+ return null;
495
+ }
496
+
497
+ // `retryAfterMs`: a wait the system or the call guard asked for.
498
+ function end(name, key, outcome, { retryAfterMs = 0 } = {}) {
499
+ const run = runOf(key);
500
+ const rule = policyOf(name);
501
+ run.running = false;
502
+ if (outcome === 'refused') {
503
+ if (rule.budget !== false) started.pop();
504
+ return 0;
505
+ }
506
+ run.failures = outcome === 'failed' ? run.failures + 1 : 0;
507
+ const waitMs = Math.max(actionCooldownMs(run.failures, rule), retryAfterMs || 0);
508
+ run.readyAt = Number(now()) + waitMs;
509
+ return waitMs;
510
+ }
511
+
512
+ function route(name, { key = () => null } = {}) {
513
+ if (!actions[name]) throw new TypeError(`createActionGuard: ${name} is not a declared action`);
514
+ if (off) return (_req, _res, next) => next();
515
+ return (req, res, next) => {
516
+ const organisation = cleanPart(req.body?.organisationId ?? req.query?.organisationId) || 'sole';
517
+ const sub = key(req);
518
+ const runKey = [name, organisation, sub === null || sub === undefined ? '' : cleanPart(sub)].join('|');
519
+ const wait = begin(name, runKey);
520
+ if (wait) {
521
+ const seconds = Math.max(1, Math.ceil(wait.retryAfterMs / 1000));
522
+ res.set('Retry-After', String(seconds));
523
+ return res.status(429).json({ ok: false, code: wait.code, error: ACTION_TEXT[wait.code](seconds), retryAfterMs: wait.retryAfterMs });
524
+ }
525
+ let ended = false;
526
+ const finish = (statusCode) => {
527
+ if (ended) return 0;
528
+ ended = true;
529
+ const asked = Number(res.getHeader('Retry-After')) * 1000;
530
+ return end(name, runKey, actionOutcome(statusCode), { retryAfterMs: Number.isFinite(asked) ? asked : 0 });
531
+ };
532
+ // The cooldown is set as the answer is written, so a press right after
533
+ // it already waits, and the page learns the wait from the header.
534
+ const writeHead = res.writeHead;
535
+ res.writeHead = function writeHeadWithCooldown(statusCode, ...rest) {
536
+ const waitMs = finish(statusCode);
537
+ if (waitMs && actionOutcome(statusCode) === 'ok' && !res.headersSent) res.setHeader('Flow-Cooldown-Ms', String(waitMs));
538
+ return writeHead.call(this, statusCode, ...rest);
539
+ };
540
+ res.on('close', () => finish(res.headersSent ? res.statusCode : 500));
541
+ return next();
542
+ };
543
+ }
544
+
545
+ // Forget every run: for tests, and when a person connects again.
546
+ const reset = () => {
547
+ runs.clear();
548
+ started.length = 0;
549
+ };
550
+
551
+ return { integration, policy, actions, route, waitFor, reset };
552
+ }
@@ -16,6 +16,7 @@ export const CAPABILITIES = Object.freeze([
16
16
  'pushInvoice', // send an issued invoice; the Flow record id is the idempotency key
17
17
  'pushInvoices', // send several issued invoices in one pass (Send all ready, server/core/sending.js)
18
18
  'checkDrafts', // the accounting system's problems with Flow drafts, from its copy; null while not connected
19
+ 'invoiceStages', // { organisationId, invoiceIds? }: Map(invoiceId -> draft | approved | sent | void | pending) for issued invoices, from its copy
19
20
  'documentFacts', // what the customer's copy of a sent invoice says (number, reference, dates), from the system's copy in Flow; never a live call
20
21
  'sendStatus', // { connected, writesAllowed }: may Flow send invoices now?
21
22
  'pushBill', // send an approved bill; the Flow record id is the idempotency key
@@ -150,7 +151,7 @@ export function defineAdapter(adapter) {
150
151
  // integration id, its two tables, and its policy. An adapter that calls
151
152
  // an outside system declares it (docs/INTEGRATIONS.md, "Outbound calls").
152
153
  callGuard: callGuard ? Object.freeze({ ...callGuard }) : null,
153
- // Optional HTTP routes: routes({ db, personAuth, log, messagePage }) returns a router
154
+ // Optional HTTP routes: routes({ db, personAuth, log, messagePage, buttonCooldowns }) returns a router
154
155
  // mounted at /api/<id> (registry.routes).
155
156
  routes: routes ?? null,
156
157
  // Optional: the settings an owner may enter in Settings, Integrations
@@ -317,7 +318,7 @@ export function createRegistry({ log = console, env = process.env } = {}) {
317
318
  .filter((a) => ready(a) && typeof a.capabilities[capability] === 'function');
318
319
  },
319
320
  // The adapters' own HTTP routes, each under /api/<id>. `context` is
320
- // { db, personAuth, log, messagePage }. An enabled, ready adapter's router is built
321
+ // { db, personAuth, log, messagePage, buttonCooldowns }. An enabled, ready adapter's router is built
321
322
  // once, here; a request to an adapter that is off gets its turned-off
322
323
  // answer (503), so a turned-off adapter never reads its settings or
323
324
  // tables.
@@ -1,5 +1,6 @@
1
1
  import { sql } from 'drizzle-orm';
2
2
  import { migrate } from 'drizzle-orm/node-postgres/migrator';
3
+ import { withMigrationLock } from '../db/index.js';
3
4
 
4
5
  // An adapter's tables: created from its own migrations while it is enabled,
5
6
  // never dropped by turning it off. Each adapter keeps its own migration
@@ -24,12 +25,15 @@ export async function missingAdapterTables(db, adapter) {
24
25
 
25
26
  // Runs the adapter's pending migrations, then checks they created every table
26
27
  // it declares, so the declaration stays true.
28
+ // It holds Flow's migration lock (server/db/index.js) while it does.
27
29
  export async function migrateAdapterSchema(db, adapter) {
28
- await migrate(db, { migrationsFolder: adapter.schema.migrationsFolder, migrationsTable: migrationsTable(adapter) });
29
- const missing = await missingAdapterTables(db, adapter);
30
- if (missing.length) {
31
- throw new Error(`Adapter ${adapter.id} migrations did not create ${missing.join(', ')}`);
32
- }
30
+ await withMigrationLock(db, async (locked) => {
31
+ await migrate(locked, { migrationsFolder: adapter.schema.migrationsFolder, migrationsTable: migrationsTable(adapter) });
32
+ const missing = await missingAdapterTables(locked, adapter);
33
+ if (missing.length) {
34
+ throw new Error(`Adapter ${adapter.id} migrations did not create ${missing.join(', ')}`);
35
+ }
36
+ });
33
37
  }
34
38
 
35
39
  export function isMissingTable(error) {
@@ -77,15 +77,19 @@ export function orderUrlTemplate(value) {
77
77
  }
78
78
 
79
79
  // The link to one order for "Open in Seazona": the order's own page when the
80
- // installation set SEAZONA_ORDER_URL, else the home page of the lab's
81
- // Seazona web app when the API address names it (https://{lab}.seazonaapi.net),
82
- // else null. `exact` is true only for the order's own page.
80
+ // installation set SEAZONA_ORDER_URL; else, when the API address names the
81
+ // lab (https://{lab}.seazonaapi.net), its order page
82
+ // (https://{lab}.seazona.net/OrderEdit.aspx?id={order id}), or its home page
83
+ // with no order id; else null. `exact` is true only for the order's own page.
83
84
  export function orderLink(settings, { sourceJobRef } = {}) {
84
85
  const template = settings?.orderUrlTemplate ?? null;
85
86
  if (template && sourceJobRef) return { url: template.replaceAll('{ref}', encodeURIComponent(sourceJobRef)), exact: true };
86
87
  const host = settings?.baseUrl ? new URL(settings.baseUrl).hostname : '';
87
88
  const lab = /^([a-z0-9-]+)\.seazonaapi\.net$/i.exec(host)?.[1];
88
- return lab ? { url: `https://${lab}.seazona.net/`, exact: false } : null;
89
+ if (!lab) return null;
90
+ // Seazona's own order screen takes the order id (a GUID) Flow syncs.
91
+ if (new RegExp(`^${ORDER_ID.source}$`, 'i').test(sourceJobRef ?? '')) return { url: `https://${lab}.seazona.net/OrderEdit.aspx?id=${encodeURIComponent(sourceJobRef)}`, exact: true };
92
+ return { url: `https://${lab}.seazona.net/`, exact: false };
89
93
  }
90
94
 
91
95
  // Returns the resolved settings, or throws SeazonaSettingsError. `mode` is
@@ -131,7 +131,7 @@ export function buildDraft(document, { settings, contact, items, sendMode = 'acc
131
131
  };
132
132
  if (line.itemCode) {
133
133
  if (items.has(line.itemCode)) out.ItemCode = line.itemCode;
134
- else problems.push(problem('xero_item_missing', `Line ${line.position}: item ${line.itemCode} is not in Xero. Add it in Xero, then sync.`, { itemCode: line.itemCode }));
134
+ else problems.push(problem('xero_item_missing', `Line ${line.position}: item ${line.itemCode} is not in Xero. Add it in Xero, then sync.`, { itemCode: line.itemCode, short: `${line.itemCode} not in Xero` }));
135
135
  } else if (line.accountCode) {
136
136
  // A line with no item books to its own account, such as a surcharge.
137
137
  out.AccountCode = line.accountCode;
@@ -289,8 +289,8 @@ async function previewOne(db, runtime, organisationId, invoiceId) {
289
289
  if (invoice.status === 'void') problems.push(problem('invoice_void', 'This invoice is void. Flow does not send it to Xero.'));
290
290
  if (invoice.status === 'draft') problems.push(problem('invoice_not_issued', 'This invoice is a draft. Issue it, then send it to Xero.'));
291
291
  const match = await matchContact(db, organisationId, document.contact.name);
292
- if (match.state === 'missing') problems.push(problem('xero_contact_missing', `No Xero contact is named "${document.contact.name}". Add it in Xero (Flow never creates Xero contacts), then sync.`));
293
- if (match.state === 'ambiguous') problems.push(problem('xero_contact_ambiguous', `More than one Xero contact is named "${document.contact.name}". Merge or rename them in Xero, then sync.`));
292
+ if (match.state === 'missing') problems.push(problem('xero_contact_missing', `No Xero contact is named "${document.contact.name}". Add it in Xero (Flow never creates Xero contacts), then sync.`, { short: 'Client not in Xero' }));
293
+ if (match.state === 'ambiguous') problems.push(problem('xero_contact_ambiguous', `More than one Xero contact is named "${document.contact.name}". Merge or rename them in Xero, then sync.`, { short: 'Client twice in Xero' }));
294
294
  const items = await itemsByCode(db, organisationId, document.lines.map((l) => l.itemCode));
295
295
  const sendMode = await sendModeFor(db, organisationId, invoice.contactId);
296
296
  const { draft, problems: lineProblems } = buildDraft(document, { settings: runtime.settings, contact: match.contact, items, sendMode });
@@ -389,7 +389,7 @@ export async function checkDrafts(db, runtime, { organisationId, invoiceIds }) {
389
389
  for (const preview of invoices) {
390
390
  found.set(preview.invoiceId, preview.problems
391
391
  .filter((p) => p.blocking !== false && p.code !== 'invoice_not_issued')
392
- .map(({ code, message }) => ({ code, message })));
392
+ .map(({ code, message, short }) => ({ code, message, ...(short ? { short } : {}) })));
393
393
  }
394
394
  }
395
395
  return found;
@@ -6,6 +6,7 @@ import { readConnection } from './connections.js';
6
6
  import { createImportWorker } from './import.js';
7
7
  import { readPayments, xeroLiveCheck } from './ledger.js';
8
8
  import { findInvoices, listCustomers, listPriceItems, mirrorState } from './lookup.js';
9
+ import { invoiceStages } from './reconcile.js';
9
10
  import { createXeroRouter } from './routes.js';
10
11
  import { createXeroRuntime, requireXeroSettings, XERO_CALL_GUARD } from './runtime.js';
11
12
  import { writesAllowed } from './settings.js';
@@ -116,6 +117,9 @@ export function createXeroAdapter({ runtime: given = null } = {}) {
116
117
  pushInvoice: ({ organisationId, invoiceId, pushedBy = null }) => pushInvoice(database, current(), { organisationId, invoiceId, pushedBy }),
117
118
  // Several issued invoices, in batches of MAX_BATCH (drafts.js).
118
119
  pushInvoices: ({ organisationId, invoiceIds, pushedBy = null }) => pushInvoices(database, current(), { organisationId, invoiceIds, pushedBy }),
120
+ // The Invoices page tab of each issued invoice (reconcile.js), from
121
+ // Flow's copy of Xero: draft, approved, sent, void or pending.
122
+ invoiceStages: ({ organisationId, invoiceIds = null }) => invoiceStages(database, current(), organisationId, invoiceIds),
119
123
  // What would stop each Flow draft in Xero, from Flow's copy (no calls).
120
124
  checkDrafts: ({ organisationId, invoiceIds }) => checkDrafts(database, current(), { organisationId, invoiceIds }),
121
125
  sendStatus: async ({ organisationId }) => {
@@ -142,7 +146,7 @@ export function createXeroAdapter({ runtime: given = null } = {}) {
142
146
  // Xero items, from Flow's copy, for the price list (lookup.js).
143
147
  listPriceItems: ({ organisationId }) => listPriceItems(database, organisationId, { taxTypes: current().settings.taxTypes }),
144
148
  },
145
- routes: ({ db, personAuth, log, messagePage }) => createXeroRouter({ db, personAuth, runtime: current(), log, messagePage }),
149
+ routes: ({ db, personAuth, log, messagePage, buttonCooldowns }) => createXeroRouter({ db, personAuth, runtime: current(), log, messagePage, buttonCooldowns }),
146
150
  };
147
151
  }
148
152
 
@@ -0,0 +1,3 @@
1
+ -- Whether Xero sent the invoice to the customer (Xero's SentToContact), for
2
+ -- the Invoices page Sent tab. Null until the copy reads the invoice again.
3
+ ALTER TABLE "xero_invoices" ADD COLUMN IF NOT EXISTS "sent_to_contact" boolean;
@@ -64,6 +64,13 @@
64
64
  "when": 1792510000000,
65
65
  "tag": "0008_xero_invoice_links",
66
66
  "breakpoints": true
67
+ },
68
+ {
69
+ "idx": 9,
70
+ "version": "7",
71
+ "when": 1792520000000,
72
+ "tag": "0009_xero_sent_to_contact",
73
+ "breakpoints": true
67
74
  }
68
75
  ]
69
76
  }
@@ -77,6 +77,7 @@ export function mapInvoice(invoice, { keepReference = false } = {}) {
77
77
  amountDueCents: toCents(invoice.AmountDue),
78
78
  amountPaidCents: toCents(invoice.AmountPaid),
79
79
  amountCreditedCents: toCents(invoice.AmountCredited),
80
+ sentToContact: typeof invoice.SentToContact === 'boolean' ? invoice.SentToContact : null,
80
81
  lines: mapLines(invoice.LineItems, keepReference),
81
82
  xeroUpdatedAt: parseXeroDate(invoice.UpdatedDateUTC),
82
83
  };
@@ -135,17 +136,17 @@ const UPSERT = {
135
136
  invoices: (org, r, hash) => sql`
136
137
  insert into xero_invoices (organisation_id, xero_invoice_id, type, invoice_number, reference, xero_contact_id, status,
137
138
  issued_on, due_on, fully_paid_on, currency, subtotal_cents, tax_cents, total_cents, amount_due_cents,
138
- amount_paid_cents, amount_credited_cents, lines, xero_updated_at, record_hash)
139
+ amount_paid_cents, amount_credited_cents, sent_to_contact, lines, xero_updated_at, record_hash)
139
140
  values (${org}, ${r.xeroInvoiceId}, ${r.type}, ${r.invoiceNumber}, ${r.reference}, ${r.xeroContactId}, ${r.status},
140
141
  ${r.issuedOn}, ${r.dueOn}, ${r.fullyPaidOn}, ${r.currency}, ${r.subtotalCents}, ${r.taxCents}, ${r.totalCents},
141
- ${r.amountDueCents}, ${r.amountPaidCents}, ${r.amountCreditedCents}, ${json(r.lines)}::jsonb, ${r.xeroUpdatedAt}, ${hash})
142
+ ${r.amountDueCents}, ${r.amountPaidCents}, ${r.amountCreditedCents}, ${r.sentToContact ?? null}, ${json(r.lines)}::jsonb, ${r.xeroUpdatedAt}, ${hash})
142
143
  on conflict (organisation_id, xero_invoice_id) do update set
143
144
  type = excluded.type, invoice_number = excluded.invoice_number, reference = excluded.reference,
144
145
  xero_contact_id = excluded.xero_contact_id, status = excluded.status, issued_on = excluded.issued_on,
145
146
  due_on = excluded.due_on, fully_paid_on = excluded.fully_paid_on, currency = excluded.currency,
146
147
  subtotal_cents = excluded.subtotal_cents, tax_cents = excluded.tax_cents, total_cents = excluded.total_cents,
147
148
  amount_due_cents = excluded.amount_due_cents, amount_paid_cents = excluded.amount_paid_cents,
148
- amount_credited_cents = excluded.amount_credited_cents, lines = excluded.lines,
149
+ amount_credited_cents = excluded.amount_credited_cents, sent_to_contact = excluded.sent_to_contact, lines = excluded.lines,
149
150
  xero_updated_at = excluded.xero_updated_at, record_hash = excluded.record_hash, last_changed_at = now()
150
151
  where xero_invoices.record_hash is distinct from excluded.record_hash
151
152
  returning (xmax = 0) as inserted`,
@@ -96,7 +96,7 @@ export async function accountingStatus(db, runtime, organisationId, invoiceIds =
96
96
  p.version as sent_version, p.state as push_state, p.request_body, p.xero_invoice_id, p.xero_invoice_number,
97
97
  v.document, l.xero_invoice_id as linked_xero_id, l.matched_by as linked_by,
98
98
  x.status as x_status, x.invoice_number as x_number, x.xero_contact_id, x.subtotal_cents, x.tax_cents,
99
- x.total_cents as x_total_cents, x.amount_paid_cents, x.amount_due_cents, x.lines, x.issued_on
99
+ x.total_cents as x_total_cents, x.amount_paid_cents, x.amount_due_cents, x.sent_to_contact, x.lines, x.issued_on
100
100
  from invoices i
101
101
  left join lateral (
102
102
  select * from xero_invoice_pushes
@@ -131,6 +131,7 @@ export async function accountingStatus(db, runtime, organisationId, invoiceIds =
131
131
  status: r.x_status,
132
132
  totalCents: num(r.x_total_cents),
133
133
  amountDueCents: num(r.amount_due_cents),
134
+ sentToContact: r.sent_to_contact ?? null,
134
135
  url: `${web}/AccountsReceivable/View.aspx?InvoiceID=${encodeURIComponent(xeroInvoiceId)}`,
135
136
  } : null;
136
137
  const status = {
@@ -153,6 +154,29 @@ export async function accountingStatus(db, runtime, organisationId, invoiceIds =
153
154
  return out;
154
155
  }
155
156
 
157
+ // The Invoices page tab of each issued Flow invoice, from Flow's copy of
158
+ // Xero (no calls): draft (on its way, or a Xero DRAFT), approved (AUTHORISED,
159
+ // not sent to the customer yet), sent (Xero sent it, or it is part or fully
160
+ // paid), void (voided or deleted in Xero), or pending (out of sync: a person
161
+ // looks). Answers Map(invoiceId -> stage).
162
+ export async function invoiceStages(db, runtime, organisationId, invoiceIds = null) {
163
+ const statuses = await accountingStatus(db, runtime, organisationId, invoiceIds);
164
+ const out = new Map();
165
+ for (const status of statuses.values()) {
166
+ if (status.flowStatus !== 'issued') continue;
167
+ out.set(status.invoiceId, stageOf(status));
168
+ }
169
+ return out;
170
+ }
171
+
172
+ export function stageOf(status) {
173
+ if (status.finding) return 'pending';
174
+ if (['part_paid', 'paid'].includes(status.state)) return 'sent';
175
+ if (status.state === 'approved') return status.xero?.sentToContact ? 'sent' : 'approved';
176
+ if (['voided', 'deleted'].includes(status.state)) return 'void';
177
+ return 'draft';
178
+ }
179
+
156
180
  // Opens or refreshes one review item for each finding, and closes the item of
157
181
  // an invoice whose finding went away. Answers counts.
158
182
  export async function checkSentInvoices(db, runtime, organisationId, { invoiceIds = null } = {}) {