@arcforgelabs/flow 2026.10.52 → 2026.10.53-2

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.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.52",
3
+ "version": "2026.10.53-2",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@arcforgelabs/flow",
9
- "version": "2026.10.52",
9
+ "version": "2026.10.53-2",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@clerk/backend": "3.25.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.52",
3
+ "version": "2026.10.53-2",
4
4
  "description": "Invoicing, bill entry and a financial dashboard for labs, with Seazona and Xero integrations.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -3,7 +3,8 @@ import { and, desc, eq, gt, isNotNull, isNull, lt, or, sql } from 'drizzle-orm';
3
3
  import { CAPTURE_DECISIONS, captures } from '../db/schema.js';
4
4
  import { FlowError, notFound } from './errors.js';
5
5
  import { matchJobs, proposeChange, readDate } from './capture-match.js';
6
- import { listFields, setJobFieldValue } from './custom-fields.js';
6
+ import { listFields, listJobFieldValues, setJobFieldValue } from './custom-fields.js';
7
+ import { JOB_FIELDS } from '../ui/capture-fields.js';
7
8
 
8
9
  // Shown when the module is off or the keys are missing. Type it still works.
9
10
  const READER_OFF = 'reader turned off';
@@ -253,7 +254,7 @@ export async function matchCapture(db, storage, organisationId, id, { jobs, fiel
253
254
  state: 'ready',
254
255
  problem: null,
255
256
  match,
256
- ...(replace ? { proposal: proposeChange(read, match, jobs) } : {}),
257
+ ...(replace ? { proposal: await toCustomFields(db, organisationId, proposeChange(read, match, jobs)) } : {}),
257
258
  updatedAt: sql`now()`,
258
259
  }).where(and(
259
260
  eq(captures.organisationId, organisationId),
@@ -305,14 +306,43 @@ function fieldValue(field, text) {
305
306
  }
306
307
  }
307
308
 
309
+ const jobOf = (jobId) => {
310
+ const at = jobId.indexOf(':');
311
+ return { sourceSystem: jobId.slice(0, at), sourceJobRef: jobId.slice(at + 1) };
312
+ };
313
+
314
+ // A custom field's stored value as the sheet shows it ("from").
315
+ function shownValue(field, value) {
316
+ if (value == null) return '';
317
+ if (field.type === 'dropdown') return field.options?.find((option) => option.id === value)?.name ?? '';
318
+ if (field.type === 'checkbox') return value ? 'Yes' : 'No';
319
+ return String(value);
320
+ }
321
+
322
+ // A read field goes to the lab's custom field of the same name ("Shade")
323
+ // when there is one: Flow owns it, so Apply writes it. Any other field stays
324
+ // the work system's; the sheet marks it to change there (maintainer, #357).
325
+ async function toCustomFields(db, organisationId, proposal) {
326
+ if (proposal.action !== 'update') return proposal;
327
+ const byName = new Map((await listFields(db, organisationId)).map((field) => [field.name.trim().toLowerCase(), field]));
328
+ const labels = new Map(JOB_FIELDS.map((field) => [field.key, field.label.toLowerCase()]));
329
+ const values = proposal.jobId
330
+ ? new Map((await listJobFieldValues(db, organisationId, { jobs: [jobOf(proposal.jobId)] })).map((row) => [row.fieldId, row.value]))
331
+ : new Map();
332
+ const changes = proposal.changes.map((change) => {
333
+ const field = byName.get(labels.get(change.field));
334
+ return field ? { ...change, field: `custom_${field.id}`, from: shownValue(field, values.get(field.id)) } : change;
335
+ }).filter((change) => change.from !== change.to);
336
+ return { ...proposal, changes };
337
+ }
338
+
308
339
  // Apply writes what Flow owns: the job's custom fields, each with its own
309
340
  // event (who, when, from and to). A field the work system owns (due date,
310
341
  // shade) stays there; the result sheet links to the job in that system.
311
342
  // Every value is checked before the first write, so a bad one writes none.
312
343
  async function applyToJob(db, organisationId, proposal, personId) {
313
344
  if (proposal?.action !== 'update' || !proposal.jobId) return;
314
- const at = proposal.jobId.indexOf(':');
315
- const job = { sourceSystem: proposal.jobId.slice(0, at), sourceJobRef: proposal.jobId.slice(at + 1) };
345
+ const job = jobOf(proposal.jobId);
316
346
  const fields = new Map((await listFields(db, organisationId)).map((field) => [`custom_${field.id}`, field]));
317
347
  const writes = proposal.changes
318
348
  .filter((change) => fields.has(change.field))
@@ -439,7 +439,8 @@ export async function sendQueued(db, organisationId, { accounting, limit = WORKE
439
439
  summary.waiting += 1;
440
440
  } else {
441
441
  const error = result.error ?? { code: `send_${result.outcome}`, message: `Sending ended as ${result.outcome}.` };
442
- await settle(db, organisationId, result.invoiceId, { sendState: 'failed', sendError: { code: error.code, message: error.message, ...(error.short ? { short: error.short } : {}) } });
442
+ const xero = xeroMatch(error);
443
+ await settle(db, organisationId, result.invoiceId, { sendState: 'failed', sendError: { code: error.code, message: error.message, ...(error.short ? { short: error.short } : {}), ...(xero ? { xero } : {}) } });
443
444
  summary.failed += 1;
444
445
  summary.refused = [...new Set([...(summary.refused ?? []), error.code])];
445
446
  const fact = refusalFact(known.get(result.invoiceId), error);
@@ -450,6 +451,17 @@ export async function sendQueued(db, organisationId, { accounting, limit = WORKE
450
451
  return summary;
451
452
  }
452
453
 
454
+ // The Xero invoice a refusal matched, kept so the page can link to it.
455
+ // Only an id, a number and a status. Anything else in the error stays out.
456
+ function xeroMatch(error) {
457
+ const xero = error?.details?.xero;
458
+ if (!xero || typeof xero !== 'object') return null;
459
+ if (!INVOICE_ID.test(xero.invoiceId ?? '')) return null;
460
+ const number = INVOICE_NUMBER.test(xero.number ?? '') ? xero.number : null;
461
+ const status = /^[A-Z0-9_]{1,32}$/.test(xero.status ?? '') ? xero.status : null;
462
+ return { invoiceId: xero.invoiceId, ...(number ? { number } : {}), ...(status ? { status } : {}) };
463
+ }
464
+
453
465
  function settle(db, organisationId, invoiceId, fields) {
454
466
  return db.update(invoices).set({ ...fields, sendUpdatedAt: sql`now()` })
455
467
  .where(and(eq(invoices.organisationId, organisationId), eq(invoices.id, invoiceId), eq(invoices.sendState, 'queued')));
@@ -112,7 +112,8 @@ export const invoices = pgTable('invoices', {
112
112
  readyAt: timestamp('ready_at', { withTimezone: true }),
113
113
  readyBy: uuid('ready_by').references(() => people.id, { onDelete: 'set null' }),
114
114
  // Sending to the accounting system (server/core/sending.js): queued by a
115
- // person, then sent or failed. `sendError` is { code, message } while failed.
115
+ // person, then sent or failed. `sendError` is { code, message, short?, xero? }
116
+ // while failed. `xero` is the matched invoice { invoiceId, number, status }.
116
117
  sendState: text('send_state'),
117
118
  sendError: jsonb('send_error'),
118
119
  sendRequestedBy: uuid('send_requested_by').references(() => people.id, { onDelete: 'set null' }),
@@ -430,47 +430,62 @@ const laneRuns = {
430
430
 
431
431
  async orders_recent(ctx) {
432
432
  const { db, client, sync, at } = ctx;
433
- const nowMs = at.getTime();
434
- const lookbackStart = floorUtcDay(nowMs - sync.orderLookbackDays * DAY_MS);
435
- const doneThrough = Date.parse(ctx.state?.cursor?.completedThrough ?? '');
436
- const partial = ctx.state?.cursor?.partial;
437
- const partialDay = Date.parse(partial?.day ?? '');
438
- // Go on inside a day a run did not finish; otherwise re-read the last
439
- // finished day as well, so late orders on it are seen.
440
- const resumeInside = Number.isFinite(partialDay) && partialDay >= lookbackStart;
441
- const resume = resumeInside ? partialDay : Number.isFinite(doneThrough) ? Math.max(lookbackStart, doneThrough) : lookbackStart;
442
-
443
- // Issue #125: the list is paged (page, pageSize). A full page goes on to
444
- // the next page; a short page, or a page past the day, ends the day. The
445
- // guide says the list answers orders on or after `ordered`; a list that
446
- // answers one day works the same way.
433
+ const today = floorUtcDay(at.getTime());
434
+ const lookbackStart = today - sync.orderLookbackDays * DAY_MS;
435
+ const cursor = ctx.state?.cursor ?? {};
436
+ const doneThrough = Date.parse(cursor.completedThrough ?? '');
437
+ const partialDay = Date.parse(cursor.partial?.day ?? '');
438
+ const todayPartialDay = Date.parse(cursor.todayPartial?.day ?? '');
439
+ let partial = partialDay >= lookbackStart && partialDay < today ? cursor.partial : null;
440
+ // A partial newest day becomes an older day at midnight. Keep its page
441
+ // unless an older partial already owns the rolling walk; that walk will
442
+ // revisit the former newest day on its next cycle too.
443
+ if (!partial && todayPartialDay >= lookbackStart && todayPartialDay < today) partial = cursor.todayPartial;
444
+ let resume = partial ? Date.parse(partial.day) : Number.isFinite(doneThrough) ? Math.max(lookbackStart, floorUtcDay(doneThrough) + DAY_MS) : lookbackStart;
445
+ if (resume >= today) resume = lookbackStart;
446
+
447
+ // Give today and the rolling previous-day walk a share of the existing
448
+ // budgets. With only one day or call available, alternate them instead
449
+ // of permanently starving either. Missing fields migrate naturally.
450
+ const single = sync.listPagesPerRun === 1 || sync.orderDaysPerRun === 1;
451
+ const readToday = !single || !cursor.historyNext;
447
452
  const pageSize = Math.min(ORDER_PAGE_MAX, sync.listCap);
448
453
  const byId = new Map();
449
454
  const days = [];
450
455
  let calls = 0;
451
456
  let lastDone = null;
452
- let stoppedAt = null;
453
- for (let day = floorUtcDay(resume); day <= floorUtcDay(nowMs) && days.length < sync.orderDaysPerRun && !stoppedAt; day += DAY_MS) {
457
+ let stoppedAt = partial;
458
+ let todayPartial = todayPartialDay === today ? cursor.todayPartial : partialDay === today ? cursor.partial : null;
459
+ async function readDay(day, progress, callLimit) {
454
460
  const label = `order list for ${formatOrderedDay(day)}`;
455
- let page = resumeInside && day === floorUtcDay(resume) ? Math.max(1, Number(partial.page) || 1) : 1;
456
- let previous = null;
457
- for (;;) {
458
- if (calls >= sync.listPagesPerRun) {
459
- stoppedAt = { day, page };
460
- break;
461
- }
461
+ let page = Math.max(1, Number(progress?.page) || 1);
462
+ // Keep at most one page of IDs in the existing cursor, so splitting
463
+ // the budget never disables the guard against an API ignoring page.
464
+ let previous = progress?.previousIds ? new Set(progress.previousIds) : null;
465
+ while (calls < callLimit) {
462
466
  calls += 1;
463
467
  if (!days.includes(day)) days.push(day);
464
468
  const list = listOrFail(await client.get('orders/', { ordered: formatOrderedDay(day), page, pageSize }), label);
465
469
  const ids = list.map((item) => text(item?.id)).filter(Boolean);
466
- // A Seazona that does not page answers the same rows again.
467
470
  if (previous && ids.length && ids.every((id) => previous.has(id))) throw cannotGoOn(label, sync, 'the next page answers the same orders');
468
471
  for (const item of list) if (text(item?.id)) byId.set(text(item.id), item);
469
- if (list.length < pageSize || placedAfter(list[list.length - 1], day + DAY_MS)) break;
472
+ // The guide's on-or-after lists and single-day lists both work.
473
+ if (list.length < pageSize || placedAfter(list[list.length - 1], day + DAY_MS)) return null;
470
474
  previous = new Set(ids);
471
475
  page += 1;
472
476
  }
473
- if (!stoppedAt) lastDone = day;
477
+ return { day: new Date(day).toISOString(), page, previousIds: [...previous] };
478
+ }
479
+ if (readToday) {
480
+ const todayCalls = single ? sync.listPagesPerRun : Math.ceil(sync.listPagesPerRun / 2);
481
+ todayPartial = await readDay(today, todayPartial, todayCalls);
482
+ }
483
+ if (!single || !readToday) {
484
+ for (let day = resume; day < today && days.length < sync.orderDaysPerRun && calls < sync.listPagesPerRun; day += DAY_MS) {
485
+ stoppedAt = await readDay(day, day === resume ? partial : null, sync.listPagesPerRun);
486
+ if (stoppedAt) break;
487
+ lastDone = day;
488
+ }
474
489
  }
475
490
  const known = await readKnown(db, 'orders', [...byId.keys()]);
476
491
  const summary = { created: 0, changed: 0, unchanged: 0 };
@@ -506,7 +521,9 @@ const laneRuns = {
506
521
  cursorTime: through === null ? ctx.state?.cursorTime ?? null : new Date(through),
507
522
  cursor: {
508
523
  ...(through === null ? {} : { completedThrough: new Date(through).toISOString() }),
509
- ...(stoppedAt ? { partial: { day: new Date(stoppedAt.day).toISOString(), page: stoppedAt.page } } : {}),
524
+ ...(stoppedAt ? { partial: stoppedAt } : {}),
525
+ ...(todayPartial ? { todayPartial } : {}),
526
+ ...(single ? { historyNext: readToday } : {}),
510
527
  },
511
528
  };
512
529
  },
@@ -180,8 +180,14 @@ export async function renderCaptures(main, ui) {
180
180
  ? `${job.caseRef ? `Job ${job.caseRef}` : job.jobId} · ${job.clientName ?? ''}`
181
181
  : proposal.action === 'create' ? 'New job' : 'Pick a job';
182
182
 
183
+ // A field the work system owns: Apply leaves it; the person changes it
184
+ // there (until a two-way sync can send it, #357).
185
+ const system = proposal.action === 'update' && proposal.jobId ? proposal.jobId.split(':')[0] : null;
186
+ const systemName = system ? system[0].toUpperCase() + system.slice(1) : null;
187
+ const elsewhere = (change) => systemName && !change.field.startsWith('custom_');
183
188
  const changeRow = (change) => h('div', { class: 'fx-capture__change', 'data-unsure': change.sure != null && change.sure < SURE_LINE ? 'true' : 'false' },
184
- h('span', { class: 'fx-capture__field' }, labels.get(change.field) ?? change.field),
189
+ h('span', { class: 'fx-capture__field' }, labels.get(change.field) ?? change.field,
190
+ elsewhere(change) ? h('span', { class: 'fx-capture__where', title: `Change this in ${systemName}` }, systemName) : null),
185
191
  h('span', { class: 'fx-capture__from' }, change.from || '–'),
186
192
  h('span', { class: 'fx-capture__arrow', 'aria-hidden': 'true' }, '→'),
187
193
  h('span', { class: 'fx-capture__to' }, change.to));
@@ -111,7 +111,7 @@ export async function renderCustomFields(main, ui) {
111
111
  const { h, flow, toast, notice, dialog, pageHeader, me } = ui;
112
112
  const disabled = !['owner', 'admin'].includes(me?.person.role);
113
113
  const host = h('div', { class: 'fx-card fx-cfm__card' }, h('div', { class: 'fx-skeleton' }));
114
- const addButton = h('button', { class: 'fx-btn fx-btn--primary fx-btn--sm', type: 'button', disabled }, 'Create custom field');
114
+ const addButton = h('button', { class: 'fx-btn fx-btn--primary fx-btn--sm', type: 'button', disabled, title: 'New custom field' }, lucide(h, 'plus'), 'New');
115
115
  main.append(pageHeader('Custom fields', { help: { href: DOCS, label: 'How custom fields work' } }), host);
116
116
 
117
117
  let data = null;
@@ -309,7 +309,7 @@ export async function renderCustomFields(main, ui) {
309
309
  ]);
310
310
  const filterButton = h('button', { class: 'fx-chip fx-cfm__tool fx-cfm__tool--dashed', type: 'button' });
311
311
  const filterMenu = popMenu(filterButton, (close) => [
312
- item(view.archived ? 'check' : null, 'Show archived custom fields', () => { view.archived = !view.archived; close(); draw(); }, { role: 'menuitemcheckbox', checked: view.archived }),
312
+ item(view.archived ? 'check' : null, 'Archived', () => { view.archived = !view.archived; close(); draw(); }, { role: 'menuitemcheckbox', checked: view.archived }),
313
313
  ]);
314
314
  const toolbar = h('div', { class: 'fx-cfm__bar' }, search, typeButton, typeMenu, filterButton, filterMenu, h('span', { class: 'fx-cfm__gap' }), addButton);
315
315
  const table = h('div', { class: 'fx-cfm', role: 'table', 'aria-label': 'Custom fields' });
@@ -505,15 +505,9 @@ export async function renderCustomFields(main, ui) {
505
505
  parts.push(...group('archived', { text: 'Archived', icon: 'archive', color: 'gray' }, fields));
506
506
  }
507
507
  if (!count && (view.q || view.types.size)) parts.push(h('p', { class: 'fx-cfm__empty' }, 'No custom field matches.'));
508
- if (!data.fields.length && !view.q && !view.types.size) {
509
- parts.push(h('p', { class: 'fx-cfm__empty' }, 'No custom fields yet. Create one, for example Priority or Technician, to group the Jobs board by it.'));
510
- }
508
+ else if (!count) parts.push(h('p', { class: 'fx-cfm__empty' }, 'No custom fields'));
511
509
  table.replaceChildren(...parts);
512
- // The count is what the table shows, the work systems' fields too.
513
- foot.replaceChildren(h('span', {}, `${count} ${count === 1 ? 'custom field' : 'custom fields'}`));
514
510
  }
515
- const foot = h('div', { class: 'fx-cfm__foot' });
516
- host.append(foot);
517
511
 
518
512
  async function load() {
519
513
  try {
@@ -1093,6 +1093,7 @@ th:hover > .fx-th-grip::after { background: var(--t-brd-hi); }
1093
1093
  min-height: 44px; padding: 4px 8px; border-bottom: 1px solid var(--fx-line-soft);
1094
1094
  }
1095
1095
  .fx-capture__field { color: var(--t-dim); }
1096
+ .fx-capture__where { display: block; font-size: 12px; color: var(--t-dim); }
1096
1097
  .fx-capture__from { color: var(--t-dim); text-decoration: line-through; overflow-wrap: anywhere; }
1097
1098
  .fx-capture__arrow { color: var(--t-dim); }
1098
1099
  .fx-capture__to { color: var(--t-strong); font-weight: 600; overflow-wrap: anywhere; }
@@ -1682,7 +1683,6 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1682
1683
  .fx-cfm__create .fx-lucide { width: 14px; height: 14px; }
1683
1684
  .fx-cfm__empty { margin: 0; padding: 20px 16px; color: var(--t-dim); }
1684
1685
  .fx-cfm > .fx-notice { margin: 12px 16px 0; }
1685
- .fx-cfm__foot { display: flex; justify-content: space-between; gap: 12px; padding: 12px 16px; border-top: 1px solid var(--fx-line-soft); color: var(--t-dim); font-size: 13px; }
1686
1686
  .fx-pop__item:disabled { opacity: .5; cursor: default; }
1687
1687
  .fx-pop__item[data-tone="danger"], .fx-pop__item[data-tone="danger"] .fx-pop__tick { color: var(--t-danger); }
1688
1688
  .fx-pop__item .fx-lucide { width: 15px; height: 15px; }
@@ -1759,6 +1759,7 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1759
1759
  .fx-cfm__head, .fx-cfm__row { padding-left: 16px; }
1760
1760
  .fx-cfm__create { padding-left: 16px; }
1761
1761
  .fx-cfm__search { flex: 1 1 100%; width: auto; }
1762
+ .fx-cfm__bar :is(.fx-cfm__search, .fx-cfm__tool, .fx-btn) { height: auto; min-height: 44px; }
1762
1763
  .fx-cfm__edit { display: none !important; }
1763
1764
  /* Custom fields uses the same phone slots as a column list: name and the
1764
1765
  menu on line 1, up to three facts on line 2, 56px. */
@@ -1423,6 +1423,21 @@ function issuedView(invoice, ui, context, redraw) {
1423
1423
  ? h('button', { class: 'fx-btn', type: 'button', onclick: () => act('revise', `${invoice.number} is a draft again.`) }, 'Revise') : null;
1424
1424
  const preview = previewButton(ui, invoice.id);
1425
1425
  actionBar = h('div', { class: 'fx-ph__actions' }, [voidButton, reviseButton, preview, send].filter(Boolean));
1426
+ // The same problem notice as a draft. A matched Xero invoice gets the
1427
+ // same Open in Xero link as the Xero card (go.xero.com, reconcile.js).
1428
+ const found = invoice.problems ?? [];
1429
+ const shorts = found.length && found.every((p) => p.short) ? [...new Set(found.map((p) => p.short))].join(', ') : null;
1430
+ const problemNotice = found.length
1431
+ ? ui.notice([...new Set(found.map((p) => brief(p.message)))].join(' '), 'danger', 'Problem.', { short: shorts })
1432
+ : null;
1433
+ const matched = found.find((p) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(p.xero?.invoiceId ?? ''));
1434
+ const openXero = matched ? h('a', {
1435
+ class: 'fx-btn fx-btn--sm fx-btn--ghost',
1436
+ href: `https://go.xero.com/AccountsReceivable/View.aspx?InvoiceID=${encodeURIComponent(matched.xero.invoiceId)}`,
1437
+ target: '_blank',
1438
+ rel: 'noopener',
1439
+ }, 'Open in Xero') : null;
1440
+ const problemRow = problemNotice ? (openXero ? h('div', { class: 'fx-offer' }, problemNotice, openXero) : problemNotice) : null;
1426
1441
  const header = h('header', { class: 'fx-ph' },
1427
1442
  h('div', { class: 'fx-ph__text' },
1428
1443
  h('h1', { class: 'fx-title' }, invoice.number),
@@ -1449,6 +1464,7 @@ function issuedView(invoice, ui, context, redraw) {
1449
1464
  chrome.record,
1450
1465
  chrome.pager,
1451
1466
  header,
1467
+ problemRow,
1452
1468
  h('div', { class: 'fx-invoice__grid' },
1453
1469
  h('div', { class: 'fx-invoice__main' },
1454
1470
  h('section', { class: 'fx-card fx-doc' },