@arcforgelabs/flow 2026.10.53-1 → 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.53-1",
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.53-1",
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.53-1",
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": {
@@ -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' }),
@@ -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' },