@arcforgelabs/flow 2026.10.37 → 2026.10.39

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 (57) hide show
  1. package/README.md +2 -1
  2. package/npm-shrinkwrap.json +2 -2
  3. package/package.json +4 -2
  4. package/server/api/custom-fields.js +27 -9
  5. package/server/api/invoicing.js +198 -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 +89 -3
  10. package/server/core/job-board.js +2 -1
  11. package/server/core/job-match.js +241 -0
  12. package/server/core/ledger.js +109 -8
  13. package/server/core/price-list.js +27 -2
  14. package/server/core/read-before-act.js +3 -2
  15. package/server/core/sending.js +36 -18
  16. package/server/db/doctor.js +18 -19
  17. package/server/db/index.js +74 -3
  18. package/server/db/migrate-all.js +3 -4
  19. package/server/db/migrations/0020_custom_field_colours.sql +21 -0
  20. package/server/db/migrations/meta/0020_snapshot.json +4377 -0
  21. package/server/db/migrations/meta/_journal.json +7 -0
  22. package/server/db/schema.js +14 -0
  23. package/server/integrations/call-guard.js +148 -0
  24. package/server/integrations/port.js +3 -2
  25. package/server/integrations/schema.js +9 -5
  26. package/server/integrations/seazona/feed.js +4 -1
  27. package/server/integrations/seazona/settings.js +8 -4
  28. package/server/integrations/xero/drafts.js +4 -4
  29. package/server/integrations/xero/fit.js +108 -0
  30. package/server/integrations/xero/import.js +6 -1
  31. package/server/integrations/xero/index.js +6 -2
  32. package/server/integrations/xero/ledger.js +3 -0
  33. package/server/integrations/xero/links.js +293 -0
  34. package/server/integrations/xero/migrations/0008_xero_invoice_links.sql +26 -0
  35. package/server/integrations/xero/migrations/0009_xero_sent_to_contact.sql +3 -0
  36. package/server/integrations/xero/migrations/meta/_journal.json +14 -0
  37. package/server/integrations/xero/mirror.js +4 -3
  38. package/server/integrations/xero/reconcile.js +42 -12
  39. package/server/integrations/xero/routes.js +66 -7
  40. package/server/integrations/xero/settings.js +6 -5
  41. package/server/ui/app-page.js +1 -1
  42. package/server/ui/app.js +89 -3
  43. package/server/ui/bulk-bar.js +4 -3
  44. package/server/ui/custom-fields.js +252 -94
  45. package/server/ui/flow.css +326 -29
  46. package/server/ui/invoice-notes.js +2 -2
  47. package/server/ui/invoicing.js +92 -137
  48. package/server/ui/job-board.js +9 -7
  49. package/server/ui/job-change.js +23 -12
  50. package/server/ui/job-view.js +189 -31
  51. package/server/ui/jobs.js +738 -70
  52. package/server/ui/price-list.js +43 -86
  53. package/server/ui/price-match.js +93 -0
  54. package/server/ui/price-reconcile.js +357 -0
  55. package/server/ui/reconcile.js +275 -0
  56. package/server/ui/routes.js +1 -1
  57. package/server/ui/xero-import.js +329 -0
@@ -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
@@ -360,3 +360,151 @@ export function createCallGuard({
360
360
 
361
361
  return { integration, policy, check, stoppedNow, record, halt, resume, status };
362
362
  }
363
+
364
+ // ── Button presses ─────────────────────────────────────────────────────────
365
+ //
366
+ // The action guard: a person's button press that calls an outside system (a
367
+ // copy, a recheck, a send) is one action. Pressed again and again, it spends
368
+ // the provider's rate limit and can trip the call guard above. So each
369
+ // guarded route:
370
+ //
371
+ // - runs once at a time per key: a second press while it runs waits
372
+ // (429, `action_running`);
373
+ // - after a run, waits `cooldownMs` before it runs again for that key
374
+ // (429, `action_cooldown`);
375
+ // - after a failed run (a 5xx, a 429 or a stopped call), waits twice as
376
+ // long each time, to at most `maxCooldownMs`; a good run resets it;
377
+ // - shares one budget per integration: at most `budget` runs in `budgetMs`
378
+ // for all its actions together (429, `action_budget`). An action with
379
+ // `budget: false` (a send a person makes one invoice at a time) is
380
+ // outside it; the provider's rate limit still paces its calls.
381
+ //
382
+ // A 429 carries Retry-After and `retryAfterMs`, so the page shows the wait.
383
+ // A good run says its cooldown in the `Flow-Cooldown-Ms` header. A refused
384
+ // request (another 4xx) costs nothing: no call went. The state is in memory:
385
+ // one Flow process serves one lab (server/rate-limit.js), and the call guard
386
+ // above still protects every process.
387
+
388
+ export const ACTION_GUARD_POLICY = Object.freeze({
389
+ cooldownMs: 30_000,
390
+ maxCooldownMs: 15 * 60_000,
391
+ budget: 30,
392
+ budgetMs: 10 * 60_000,
393
+ });
394
+
395
+ const ACTION_TEXT = {
396
+ action_running: () => 'This is already running. Wait for it to finish.',
397
+ action_cooldown: (seconds) => `Flow just did this. Try again in ${seconds} seconds.`,
398
+ action_budget: (seconds) => `Too many requests to this system. Try again in ${seconds} seconds.`,
399
+ };
400
+
401
+ // The wait after `failures` failed runs in a row.
402
+ export function actionCooldownMs(failures, { cooldownMs, maxCooldownMs }) {
403
+ if (failures <= 0) return cooldownMs;
404
+ return Math.min(maxCooldownMs, Math.max(cooldownMs, 5_000) * 2 ** Math.min(failures, 10));
405
+ }
406
+
407
+ // How a response ends a run: 'ok', 'failed' (the system or the call guard
408
+ // said no) or 'refused' (Flow refused the request; nothing was called).
409
+ export function actionOutcome(statusCode) {
410
+ if (statusCode >= 500 || statusCode === 429) return 'failed';
411
+ if (statusCode >= 400) return 'refused';
412
+ return 'ok';
413
+ }
414
+
415
+ const cleanPart = (value) => String(value ?? '').replace(/[^A-Za-z0-9_.:-]+/g, '').slice(0, 80);
416
+
417
+ // `integration` names the budget; `actions` maps each action name to its
418
+ // policy ({ cooldownMs, maxCooldownMs, budget }). `route(name, { key })` answers the
419
+ // Express middleware for one route; `key(req)` names what the cooldown
420
+ // covers inside the organisation (an invoice id, say), or null for all of it.
421
+ // `off: true` lets every press through (tests that press one route often).
422
+ export function createActionGuard({ integration, actions = {}, policy: overrides = {}, off = false, now = Date.now } = {}) {
423
+ if (!/^[a-z][a-z0-9-]{1,31}$/.test(integration ?? '')) throw new TypeError('createActionGuard: integration must be an adapter id');
424
+ const policy = { ...ACTION_GUARD_POLICY, ...overrides };
425
+ const runs = new Map(); // key -> { running, failures, readyAt }
426
+ const started = []; // start times inside the budget window
427
+ const policyOf = (name) => ({ cooldownMs: policy.cooldownMs, maxCooldownMs: policy.maxCooldownMs, ...(actions[name] ?? {}) });
428
+
429
+ const runOf = (key) => {
430
+ let run = runs.get(key);
431
+ if (!run) runs.set(key, (run = { running: false, failures: 0, readyAt: 0 }));
432
+ return run;
433
+ };
434
+
435
+ // Why `name` may not run for `key` now, or null.
436
+ function waitFor(name, key) {
437
+ const nowMs = Number(now());
438
+ const run = runs.get(key);
439
+ if (run?.running) return { code: 'action_running', retryAfterMs: 5_000 };
440
+ if (run && run.readyAt > nowMs) return { code: 'action_cooldown', retryAfterMs: run.readyAt - nowMs };
441
+ if (policyOf(name).budget === false) return null;
442
+ while (started.length && started[0] <= nowMs - policy.budgetMs) started.shift();
443
+ if (started.length >= policy.budget) return { code: 'action_budget', retryAfterMs: started[0] + policy.budgetMs - nowMs };
444
+ return null;
445
+ }
446
+
447
+ function begin(name, key) {
448
+ const wait = waitFor(name, key);
449
+ if (wait) return wait;
450
+ runOf(key).running = true;
451
+ if (policyOf(name).budget !== false) started.push(Number(now()));
452
+ return null;
453
+ }
454
+
455
+ // `retryAfterMs`: a wait the system or the call guard asked for.
456
+ function end(name, key, outcome, { retryAfterMs = 0 } = {}) {
457
+ const run = runOf(key);
458
+ const rule = policyOf(name);
459
+ run.running = false;
460
+ if (outcome === 'refused') {
461
+ if (rule.budget !== false) started.pop();
462
+ return 0;
463
+ }
464
+ run.failures = outcome === 'failed' ? run.failures + 1 : 0;
465
+ const waitMs = Math.max(actionCooldownMs(run.failures, rule), retryAfterMs || 0);
466
+ run.readyAt = Number(now()) + waitMs;
467
+ return waitMs;
468
+ }
469
+
470
+ function route(name, { key = () => null } = {}) {
471
+ if (!actions[name]) throw new TypeError(`createActionGuard: ${name} is not a declared action`);
472
+ if (off) return (_req, _res, next) => next();
473
+ return (req, res, next) => {
474
+ const organisation = cleanPart(req.body?.organisationId ?? req.query?.organisationId) || 'sole';
475
+ const sub = key(req);
476
+ const runKey = [name, organisation, sub === null || sub === undefined ? '' : cleanPart(sub)].join('|');
477
+ const wait = begin(name, runKey);
478
+ if (wait) {
479
+ const seconds = Math.max(1, Math.ceil(wait.retryAfterMs / 1000));
480
+ res.set('Retry-After', String(seconds));
481
+ return res.status(429).json({ ok: false, code: wait.code, error: ACTION_TEXT[wait.code](seconds), retryAfterMs: wait.retryAfterMs });
482
+ }
483
+ let ended = false;
484
+ const finish = (statusCode) => {
485
+ if (ended) return 0;
486
+ ended = true;
487
+ const asked = Number(res.getHeader('Retry-After')) * 1000;
488
+ return end(name, runKey, actionOutcome(statusCode), { retryAfterMs: Number.isFinite(asked) ? asked : 0 });
489
+ };
490
+ // The cooldown is set as the answer is written, so a press right after
491
+ // it already waits, and the page learns the wait from the header.
492
+ const writeHead = res.writeHead;
493
+ res.writeHead = function writeHeadWithCooldown(statusCode, ...rest) {
494
+ const waitMs = finish(statusCode);
495
+ if (waitMs && actionOutcome(statusCode) === 'ok' && !res.headersSent) res.setHeader('Flow-Cooldown-Ms', String(waitMs));
496
+ return writeHead.call(this, statusCode, ...rest);
497
+ };
498
+ res.on('close', () => finish(res.headersSent ? res.statusCode : 500));
499
+ return next();
500
+ };
501
+ }
502
+
503
+ // Forget every run: for tests, and when a person connects again.
504
+ const reset = () => {
505
+ runs.clear();
506
+ started.length = 0;
507
+ };
508
+
509
+ return { integration, policy, actions, route, waitFor, reset };
510
+ }
@@ -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) {
@@ -202,7 +202,7 @@ export async function listWork(db, { now = Date.now(), finishedDays = WORK_FINIS
202
202
  return {
203
203
  sourceSystem: 'seazona',
204
204
  sourceJobRef: order.id,
205
- // The Seazona order id is the case number. Never patient data.
205
+ // The Seazona order id is the case number.
206
206
  caseRef: order.id,
207
207
  status,
208
208
  clientId: order.clientId,
@@ -211,6 +211,9 @@ export async function listWork(db, { now = Date.now(), finishedDays = WORK_FINIS
211
211
  department: order.department,
212
212
  orderedOn: parseSeazonaDate(order.orderedText),
213
213
  dueOn: parseSeazonaDate(order.dueText),
214
+ // The patient, only when this installation keeps patient names
215
+ // (SEAZONA_KEEP_PATIENT_REFERENCE); otherwise the copy has none.
216
+ patient: order.patientReference ?? null,
214
217
  lines: (order.lines ?? []).map((l) => ({ code: l.code ?? null, description: l.description, amountCents: l.amountCents ?? null })),
215
218
  totalCents: order.subtotalCents,
216
219
  };
@@ -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;
@@ -0,0 +1,108 @@
1
+ // How sure Flow is that a Xero invoice in the copy is one Flow invoice
2
+ // (docs/XERO_IMPORT.md, "Sure match"). The checks are the ones the job
3
+ // matcher learned on work-system jobs against Xero (server/core/pipeline.js
4
+ // scoreCandidate), plus what the Flow invoice's own jobs know: the matcher's
5
+ // tie to this Xero invoice, and the work system's invoice number.
6
+ //
7
+ // A pair is sure only when one piece of hard evidence ties it, or when the
8
+ // score is high and clearly ahead; and in both cases only when it is the one
9
+ // fit both ways (`sureFits`).
10
+
11
+ export const SURE_SCORE = 11; // total, same items, and within 3 days
12
+ export const SURE_LEAD = 3;
13
+ export const POOL_DAYS = 14;
14
+ const day = (iso) => Date.parse(`${iso}T00:00:00Z`);
15
+ const daysApart = (a, b) => (a && b ? Math.abs(day(a) - day(b)) / 86_400_000 : null);
16
+ const withGst = (cents) => Math.round(cents * 1.1);
17
+ const withoutReissue = (ref) => String(ref ?? '').trim().replace(/R$/i, '').toLowerCase();
18
+ const words = (text) => String(text ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
19
+
20
+ // xero: { id, number, contactId, totalCents, issuedOn, itemCodes[], reference }
21
+ // flow: { id, contactId, totalCents, subtotalCents, date, itemCodes[], reference,
22
+ // jobs: [{ accountingRef, sourceInvoiceRef, practitioner }] }
23
+ // Answers { score, signals, hard }; `hard` names the evidence that ties them.
24
+ export function scoreFit(xero, flow) {
25
+ const signals = [];
26
+ const add = (signal, weight) => signals.push({ signal, weight });
27
+ const jobs = flow.jobs ?? [];
28
+ let hard = null;
29
+ if (jobs.some((j) => j.accountingRef && j.accountingRef === xero.id)) hard = 'job_tied';
30
+ else if (xero.number && jobs.some((j) => j.sourceInvoiceRef && withoutReissue(j.sourceInvoiceRef) === withoutReissue(xero.number))) hard = 'work_invoice_number';
31
+ if (hard) add(hard, 10);
32
+ if (xero.contactId && xero.contactId === flow.contactId) add('client', 2);
33
+ if (xero.totalCents === flow.totalCents) add('total', 6);
34
+ else if ([flow.subtotalCents, withGst(flow.subtotalCents)].includes(xero.totalCents)) add('total_gst', 5);
35
+ const ours = new Set(flow.itemCodes ?? []);
36
+ const theirs = new Set(xero.itemCodes ?? []);
37
+ if (ours.size && theirs.size) {
38
+ const shared = [...ours].filter((c) => theirs.has(c)).length;
39
+ if (shared === ours.size && shared === theirs.size) add('items_same', 3);
40
+ else if (shared) add('items_overlap', 1);
41
+ else add('items_differ', -3);
42
+ }
43
+ const apart = daysApart(xero.issuedOn, flow.date);
44
+ if (apart !== null && apart <= 3) add('date_close', 2);
45
+ else if (apart !== null && apart <= POOL_DAYS) add('date_near', 1);
46
+ // A Reference is in the copy unless XERO_KEEP_REFERENCE is off.
47
+ if (xero.reference && flow.reference) {
48
+ if (words(xero.reference) === words(flow.reference)) add('reference_same', 3);
49
+ else add('reference_differs', -2);
50
+ } else if (xero.reference && jobs.some((j) => j.practitioner && words(xero.reference).includes(words(j.practitioner)))) {
51
+ add('practitioner', 1);
52
+ }
53
+ return { score: signals.reduce((sum, s) => sum + s.weight, 0), signals, hard };
54
+ }
55
+
56
+ // Whether the pair may be offered at all: hard evidence, or the same client
57
+ // with an amount that fits and dates within two weeks.
58
+ export function canFit(xero, flow, scored) {
59
+ if (scored.hard) return true;
60
+ const amount = scored.signals.some((s) => s.signal === 'total' || s.signal === 'total_gst');
61
+ const near = scored.signals.some((s) => s.signal === 'date_close' || s.signal === 'date_near');
62
+ return Boolean(xero.contactId && xero.contactId === flow.contactId && amount && near);
63
+ }
64
+
65
+ // pairs: [{ xeroId, flowId, apart, fit }], `fit` being the candidate with its
66
+ // score. A fit loses RIVAL_WEIGHT, and is marked `rival`, when the same Flow
67
+ // invoice has another Xero invoice dated at least RIVAL_DAYS closer to it, or
68
+ // the same Xero invoice has another Flow invoice that much closer. Fits a day
69
+ // or two apart stay level, so a person picks.
70
+ export const RIVAL_WEIGHT = 4;
71
+ export const RIVAL_DAYS = 3;
72
+ export function markRivals(pairs) {
73
+ for (const p of pairs) {
74
+ if (p.fit.hard) continue;
75
+ const closer = pairs.some((o) => o !== p && o.apart + RIVAL_DAYS <= p.apart && (o.flowId === p.flowId || o.xeroId === p.xeroId));
76
+ if (closer) {
77
+ p.fit.score -= RIVAL_WEIGHT;
78
+ p.fit.rival = true;
79
+ }
80
+ }
81
+ }
82
+
83
+ // pairs: [{ xeroId, flowId, score, hard }]. Answers the set of `xeroId` whose
84
+ // best fit is sure: hard evidence or SURE_SCORE, at least SURE_LEAD ahead of
85
+ // that Xero invoice's next fit, and that Flow invoice's best fit back.
86
+ export function sureFits(pairs) {
87
+ const byXero = new Map();
88
+ const byFlow = new Map();
89
+ for (const p of pairs) {
90
+ if (!byXero.has(p.xeroId)) byXero.set(p.xeroId, []);
91
+ if (!byFlow.has(p.flowId)) byFlow.set(p.flowId, []);
92
+ byXero.get(p.xeroId).push(p);
93
+ byFlow.get(p.flowId).push(p);
94
+ }
95
+ const ranked = (list) => [...list].sort((a, b) => b.score - a.score);
96
+ const clear = (list) => {
97
+ const [top, next] = ranked(list);
98
+ if (!top || !(top.hard || top.score >= SURE_SCORE)) return null;
99
+ if (next && (next.hard || top.score - next.score < SURE_LEAD)) return null;
100
+ return top;
101
+ };
102
+ const sure = new Map();
103
+ for (const [xeroId, list] of byXero) {
104
+ const top = clear(list);
105
+ if (top && clear(byFlow.get(top.flowId))?.xeroId === xeroId) sure.set(xeroId, top.flowId);
106
+ }
107
+ return sure;
108
+ }
@@ -5,6 +5,7 @@ import { isCallsStopped } from '../call-guard.js';
5
5
  import { lastSync, syncCatalogue } from './catalogue.js';
6
6
  import { mirrorState } from './lookup.js';
7
7
  import { MIRROR_RESOURCES, mirrorXero } from './mirror.js';
8
+ import { matchXero } from './links.js';
8
9
  import { checkSentInvoices } from './reconcile.js';
9
10
  import { runWebhookWork, WEBHOOK_REFRESH_MS, webhooksActive } from './webhooks.js';
10
11
 
@@ -82,7 +83,11 @@ export async function runImportSlice(db, runtime, job, { maxPages = SLICE_PAGES
82
83
  const sent = invoices && (invoices.inserted || invoices.changed || job.payload?.reason !== 'changes')
83
84
  ? await checkSentInvoices(db, runtime, job.organisationId)
84
85
  : null;
85
- return { results, more: stopped, ...(sent ? { sent } : {}) };
86
+ // Each Xero invoice against Flow's own (links.js): sure fits are linked, and
87
+ // with the Import setting on, a Flow invoice is made for each with no fit.
88
+ // Flow's database only.
89
+ const matched = sent ? await matchXero(db, job.organisationId) : null;
90
+ return { results, more: stopped, ...(sent ? { sent } : {}), ...(matched ? { matched } : {}) };
86
91
  }
87
92
 
88
93
  // Claims and runs one due import job, then queues the next slice when one is
@@ -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';
@@ -15,7 +16,7 @@ import { readChangesFirst } from './webhooks.js';
15
16
  // XERO_MODE says which Xero it acts on. Its tables are created only while it
16
17
  // is on (docs/INTEGRATIONS.md).
17
18
 
18
- export const XERO_TABLES = Object.freeze(['xero_connections', 'xero_contacts', 'xero_items', 'xero_sync_runs', 'xero_invoice_pushes', 'xero_payment_cursors', 'xero_invoices', 'xero_credit_notes', 'xero_payments', 'xero_mirror_cursors', 'xero_call_guard', 'xero_calls', 'xero_webhook_events', 'xero_daily_checks']);
19
+ export const XERO_TABLES = Object.freeze(['xero_connections', 'xero_contacts', 'xero_items', 'xero_sync_runs', 'xero_invoice_pushes', 'xero_payment_cursors', 'xero_invoices', 'xero_credit_notes', 'xero_payments', 'xero_mirror_cursors', 'xero_call_guard', 'xero_calls', 'xero_webhook_events', 'xero_daily_checks', 'xero_invoice_links', 'xero_import_settings']);
19
20
 
20
21
  // What an owner enters in Settings, Integrations (server/integrations/setup.js).
21
22
  // The refresh token's key comes from Flow's secrets key.
@@ -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
 
@@ -218,7 +218,10 @@ export async function recordXeroPayments(db, runtime, organisationId, { actor, s
218
218
  const xeroIds = [...new Set(payments.map((p) => p.xeroInvoiceId))];
219
219
  const byFlowInvoice = new Map();
220
220
  if (xeroIds.length) {
221
+ // Flow invoices sent to Xero, and those the Import page linked (links.js).
221
222
  const { rows } = await db.execute(sql`select xero_invoice_id, invoice_id from xero_invoice_pushes
223
+ where organisation_id = ${organisationId} and xero_invoice_id = any(${sql.param(xeroIds)}::text[])
224
+ union all select xero_invoice_id, invoice_id from xero_invoice_links
222
225
  where organisation_id = ${organisationId} and xero_invoice_id = any(${sql.param(xeroIds)}::text[])`);
223
226
  for (const row of rows) byFlowInvoice.set(row.xero_invoice_id, row.invoice_id);
224
227
  }