@arcforgelabs/flow 2026.10.25 → 2026.10.27

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 (60) hide show
  1. package/README.md +6 -0
  2. package/npm-shrinkwrap.json +627 -2
  3. package/package.json +3 -1
  4. package/server/api/invoicing.js +586 -0
  5. package/server/app.js +38 -1
  6. package/server/auth/people.js +26 -0
  7. package/server/auth/routes.js +13 -0
  8. package/server/core/automations.js +140 -0
  9. package/server/core/invoice-rules.js +224 -0
  10. package/server/core/invoices.js +11 -1
  11. package/server/core/invoicing-rules.js +255 -0
  12. package/server/core/ledger.js +50 -14
  13. package/server/core/links.js +3 -3
  14. package/server/core/monitoring.js +34 -0
  15. package/server/core/pipeline.js +495 -0
  16. package/server/core/sending.js +282 -0
  17. package/server/db/migrations/0008_people_appearance.sql +1 -0
  18. package/server/db/migrations/0009_pipeline_matcher.sql +46 -0
  19. package/server/db/migrations/0010_invoicing_rules.sql +51 -0
  20. package/server/db/migrations/0011_invoice_rules.sql +21 -0
  21. package/server/db/migrations/0012_invoice_sending.sql +9 -0
  22. package/server/db/migrations/meta/0008_snapshot.json +2479 -0
  23. package/server/db/migrations/meta/0009_snapshot.json +2809 -0
  24. package/server/db/migrations/meta/0010_snapshot.json +3158 -0
  25. package/server/db/migrations/meta/0011_snapshot.json +3314 -0
  26. package/server/db/migrations/meta/0012_snapshot.json +3380 -0
  27. package/server/db/migrations/meta/_journal.json +35 -0
  28. package/server/db/schema.js +186 -2
  29. package/server/index.js +20 -3
  30. package/server/integrations/adapters.js +2 -1
  31. package/server/integrations/port.js +8 -0
  32. package/server/integrations/seazona/feed.js +11 -0
  33. package/server/integrations/seazona/index.js +7 -1
  34. package/server/integrations/seazona/ledger.js +4 -0
  35. package/server/integrations/sentry/index.js +105 -0
  36. package/server/integrations/xero/client.js +5 -0
  37. package/server/integrations/xero/drafts.js +123 -16
  38. package/server/integrations/xero/import.js +22 -0
  39. package/server/integrations/xero/index.js +21 -3
  40. package/server/integrations/xero/lookup.js +94 -0
  41. package/server/integrations/xero/migrations/0005_xero_send_modes.sql +10 -0
  42. package/server/integrations/xero/migrations/meta/_journal.json +7 -0
  43. package/server/theme/color-contrast.js +39 -2
  44. package/server/theme/contrast-audit.js +63 -0
  45. package/server/theme/themes.js +10 -2
  46. package/server/ui/app-page.js +6 -10
  47. package/server/ui/app.js +364 -105
  48. package/server/ui/appearance.js +3 -2
  49. package/server/ui/automations.js +96 -0
  50. package/server/ui/clerk-appearance.js +18 -2
  51. package/server/ui/flow.css +334 -22
  52. package/server/ui/invoice-rules.js +237 -0
  53. package/server/ui/invoicing-settings.js +108 -0
  54. package/server/ui/invoicing.js +597 -0
  55. package/server/ui/jobs.js +172 -0
  56. package/server/ui/logos/seazona.svg +1 -0
  57. package/server/ui/logos/sentry.svg +1 -0
  58. package/server/ui/logos/xero.svg +1 -0
  59. package/server/ui/monitoring.js +53 -0
  60. package/server/ui/routes.js +5 -1
@@ -1,13 +1,22 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { and, eq, inArray, sql } from 'drizzle-orm';
3
3
  import { getInvoice, listInvoiceVersions } from '../../core/invoices.js';
4
- import { contacts as flowContacts, priceItems } from '../../db/schema.js';
4
+ import { sendModeFor } from '../../core/invoicing-rules.js';
5
+ import { contacts as flowContacts, invoices as flowInvoices, priceItems } from '../../db/schema.js';
6
+ import { readConnection } from './connections.js';
5
7
  import { itemsByCode, lastSuccessfulSyncAt, matchContact, normaliseName } from './catalogue.js';
6
8
  import { xeroError } from './client.js';
7
9
  import { withXeroLane } from './governor.js';
8
-
9
- // Issued Flow invoices become Xero draft invoices (ACCREC, DRAFT). The lab
10
- // checks and approves them in Xero.
10
+ import { mirrorState } from './lookup.js';
11
+
12
+ // Issued Flow invoices go to Xero. How depends on the customer's send mode
13
+ // (server/core/invoicing-rules.js):
14
+ // - accounting_draft (the default): a Xero draft (ACCREC, DRAFT). The lab
15
+ // approves and sends it in Xero.
16
+ // - approve_and_email: issuing in Flow was the approval. Flow sends it
17
+ // approved (AUTHORISED), then asks Xero to email it to the contact with the
18
+ // organisation's own template.
19
+ // Either way the mirror (mirror.js) reads Xero's status back to Flow.
11
20
  //
12
21
  // The rules, as in the earlier codebase:
13
22
  // - What Flow sends is the issued version's document, exactly. A sent
@@ -19,7 +28,12 @@ import { withXeroLane } from './governor.js';
19
28
  // - Before it creates, Flow asks Xero for invoices with the same reference.
20
29
  // One for the same contact and total is the same work: Flow records it and
21
30
  // creates nothing. One for the same contact at another total within 90
22
- // days is held for a person, unless they chose to send it anyway.
31
+ // days is held for a person, unless they chose to send it anyway. Flow skips
32
+ // this call for an invoice it drafted from a job when its complete, fresh
33
+ // copy of Xero showed no invoice for that job (docs/JOB_INVOICE_LINKS.md).
34
+ // - Xero's email action is not idempotent. Flow records `sending` before it
35
+ // calls, and calls again only after Xero refused (`failed`), never after an
36
+ // unknown outcome: a person checks Xero then.
23
37
  // - The request, with its Idempotency-Key, is stored before it leaves. An
24
38
  // uncertain outcome (timeout, 5xx) is resolved later: within Xero's
25
39
  // six-minute idempotency window Flow repeats the same request with the same
@@ -103,7 +117,7 @@ function trackingFor(settings, contact) {
103
117
  }
104
118
 
105
119
  // The Xero draft for a document, and what stops it.
106
- export function buildDraft(document, { settings, contact, items }) {
120
+ export function buildDraft(document, { settings, contact, items, sendMode = 'accounting_draft' }) {
107
121
  const problems = [];
108
122
  const tracking = trackingFor(settings, contact);
109
123
  const lines = document.lines.map((line) => {
@@ -124,7 +138,7 @@ export function buildDraft(document, { settings, contact, items }) {
124
138
  const issueDate = document.issueDate;
125
139
  const draft = {
126
140
  Type: 'ACCREC',
127
- Status: 'DRAFT',
141
+ Status: sendMode === 'approve_and_email' ? 'AUTHORISED' : 'DRAFT',
128
142
  Contact: contact ? { ContactID: contact.xeroContactId } : { Name: document.contact.name },
129
143
  ...(issueDate ? { Date: issueDate, DueDate: document.dueDate ?? addDays(issueDate, settings.defaultDueDays) } : {}),
130
144
  Reference: xeroReference(document),
@@ -168,6 +182,8 @@ function mapPush(row) {
168
182
  verified: row.verified,
169
183
  error: row.error_code ? { code: row.error_code, message: row.error_message } : null,
170
184
  attempts: row.attempts,
185
+ sendMode: row.send_mode ?? 'accounting_draft',
186
+ email: row.email_state ? { state: row.email_state, error: row.email_error ?? null, at: row.emailed_at ?? null } : null,
171
187
  pushedBy: row.pushed_by,
172
188
  firstDispatchedAt: row.first_dispatched_at,
173
189
  lastDispatchedAt: row.last_dispatched_at,
@@ -240,8 +256,12 @@ async function previewOne(db, runtime, organisationId, invoiceId) {
240
256
  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.`));
241
257
  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.`));
242
258
  const items = await itemsByCode(db, organisationId, document.lines.map((l) => l.itemCode));
243
- const { draft, problems: lineProblems } = buildDraft(document, { settings: runtime.settings, contact: match.contact, items });
259
+ const sendMode = await sendModeFor(db, organisationId, invoice.contactId);
260
+ const { draft, problems: lineProblems } = buildDraft(document, { settings: runtime.settings, contact: match.contact, items, sendMode });
244
261
  problems.push(...lineProblems);
262
+ if (sendMode === 'approve_and_email' && match.state === 'matched' && !match.contact.email) {
263
+ problems.push(problem('xero_contact_no_email', `The Xero contact "${match.contact.name}" has no email address, so Xero cannot email the invoice. Add one in Xero, then sync.`, { blocking: false }));
264
+ }
245
265
 
246
266
  const pushes = await listPushes(db, organisationId, invoiceId);
247
267
  const current = version ? pushes.find((p) => p.version === version) : null;
@@ -259,6 +279,7 @@ async function previewOne(db, runtime, organisationId, invoiceId) {
259
279
  invoiceId,
260
280
  number: document.number,
261
281
  version,
282
+ sendMode,
262
283
  state,
263
284
  problems,
264
285
  contact: { state: match.state, xeroContactId: match.contact?.xeroContactId ?? null, name: match.contact?.name ?? null },
@@ -298,10 +319,46 @@ export async function pushDrafts(db, runtime, organisationId, invoiceIds, { push
298
319
  results.push(failed(invoiceId, error));
299
320
  }
300
321
  }
322
+ await keepSendState(db, organisationId, results);
301
323
  return summarise(results);
302
324
  }));
303
325
  }
304
326
 
327
+ // Flow's Problem view (server/core/sending.js) shows an issued invoice that
328
+ // failed to send. Once Xero holds it, by any path, that problem is gone.
329
+ async function keepSendState(db, organisationId, results) {
330
+ const held = results.filter((r) => ['created', 'reconciled', 'existing', 'already_in_xero'].includes(r.outcome)).map((r) => r.invoiceId);
331
+ if (!held.length) return;
332
+ await db.update(flowInvoices).set({ sendState: 'sent', sendError: null, sendUpdatedAt: sql`now()` })
333
+ .where(and(eq(flowInvoices.organisationId, organisationId), inArray(flowInvoices.id, held), sql`${flowInvoices.sendState} is not null`));
334
+ }
335
+
336
+ // Send all ready (server/core/sending.js): issued invoices in batches.
337
+ export async function pushInvoices(db, runtime, { organisationId, invoiceIds, pushedBy = null }) {
338
+ const invoices = [];
339
+ for (let at = 0; at < invoiceIds.length; at += MAX_BATCH) {
340
+ invoices.push(...(await pushDrafts(db, runtime, organisationId, invoiceIds.slice(at, at + MAX_BATCH), { pushedBy })).invoices);
341
+ }
342
+ return { invoices };
343
+ }
344
+
345
+ // What would stop each Flow draft in Xero, from Flow's copy of Xero (no
346
+ // calls). Null while Xero is not connected: then Flow's own checks decide.
347
+ export async function checkDrafts(db, runtime, { organisationId, invoiceIds }) {
348
+ const connection = await readConnection(db, organisationId);
349
+ if (!connection || connection.mode !== runtime.settings.mode) return null;
350
+ const found = new Map();
351
+ for (let at = 0; at < invoiceIds.length; at += MAX_BATCH) {
352
+ const { invoices } = await previewDrafts(db, runtime, organisationId, invoiceIds.slice(at, at + MAX_BATCH));
353
+ for (const preview of invoices) {
354
+ found.set(preview.invoiceId, preview.problems
355
+ .filter((p) => p.blocking !== false && p.code !== 'invoice_not_issued')
356
+ .map(({ code, message }) => ({ code, message })));
357
+ }
358
+ }
359
+ return found;
360
+ }
361
+
305
362
  function failed(invoiceId, error, outcome = 'failed') {
306
363
  return { invoiceId, outcome, error: { code: error.code ?? 'xero_push_failed', message: error.message, ...(error.details ? { details: error.details } : {}) } };
307
364
  }
@@ -316,6 +373,8 @@ function summarise(results) {
316
373
  mismatched: count('mismatch'),
317
374
  ambiguous: count('ambiguous'),
318
375
  failed: count('failed') + count('not_tried'),
376
+ emailed: results.filter((r) => r.email?.state === 'sent').length,
377
+ emailProblems: results.filter((r) => r.email && ['failed', 'unknown'].includes(r.email.state)).length,
319
378
  },
320
379
  invoices: results,
321
380
  };
@@ -347,7 +406,8 @@ async function pushOne(db, runtime, client, organisationId, invoiceId, { pushedB
347
406
  const pushes = await listPushes(db, organisationId, invoiceId);
348
407
  const current = pushes.find((p) => p.version === version);
349
408
  if (current && SETTLED.has(current.state)) {
350
- return { invoiceId, version, outcome: 'already_in_xero', xero: current.xero };
409
+ const email = await emailIfDue(db, client, current);
410
+ return { invoiceId, version, outcome: 'already_in_xero', xero: current.xero, ...(email ? { email } : {}) };
351
411
  }
352
412
  if (current?.state === 'mismatch') {
353
413
  return { invoiceId, version, outcome: 'mismatch', xero: current.xero, error: current.error };
@@ -377,14 +437,18 @@ async function pushOne(db, runtime, client, organisationId, invoiceId, { pushedB
377
437
  { status: 409 });
378
438
  }
379
439
  const items = await itemsByCode(db, organisationId, document.lines.map((l) => l.itemCode));
380
- const { draft, problems } = buildDraft(document, { settings: runtime.settings, contact: match.contact, items });
440
+ const sendMode = await sendModeFor(db, organisationId, invoice.contactId);
441
+ const { draft, problems } = buildDraft(document, { settings: runtime.settings, contact: match.contact, items, sendMode });
381
442
  if (problems.length) throw xeroError(problems[0].code, problems.map((p) => p.message).join(' '), { status: 409 });
382
443
 
383
444
  // Live duplicate check: invoices in Xero with this reference, made by Flow
384
- // or by hand.
445
+ // or by hand. Not needed when Flow's own copy of Xero already answered it
446
+ // for the job this invoice bills.
385
447
  const earlierIds = new Set(pushes.map((p) => p.xero?.invoiceId).filter(Boolean));
386
- const sameReference = (await client.findInvoicesByReference(draft.Reference))
387
- .filter((inv) => !GONE.has(inv.Status) && !earlierIds.has(inv.InvoiceID));
448
+ const sameReference = (await copyShowsNoInvoice(db, runtime, organisationId, invoiceId))
449
+ ? []
450
+ : (await client.findInvoicesByReference(draft.Reference))
451
+ .filter((inv) => !GONE.has(inv.Status) && !earlierIds.has(inv.InvoiceID));
388
452
  const sameContact = sameReference.filter((inv) => inv.Contact?.ContactID === match.contact.xeroContactId);
389
453
  if (!allowExisting) {
390
454
  const same = sameContact.find((inv) => cents(inv.Total) === document.totalCents);
@@ -406,9 +470,9 @@ async function pushOne(db, runtime, client, organisationId, invoiceId, { pushedB
406
470
  const preExisting = sameReference.map((inv) => inv.InvoiceID);
407
471
  const { rows } = await db.execute(sql`
408
472
  insert into xero_invoice_pushes (organisation_id, invoice_id, version, state, idempotency_key, request_body, body_hash, pre_existing_ids, pushed_by,
409
- first_dispatched_at, last_dispatched_at)
473
+ first_dispatched_at, last_dispatched_at, send_mode, email_state)
410
474
  values (${organisationId}, ${invoiceId}, ${version}, 'dispatching', ${key}, ${body}, ${bodyHash}, ${JSON.stringify(preExisting)}::jsonb, ${pushedBy},
411
- ${new Date(runtime.now())}, ${new Date(runtime.now())})
475
+ ${new Date(runtime.now())}, ${new Date(runtime.now())}, ${sendMode}, ${sendMode === 'approve_and_email' ? 'pending' : null})
412
476
  on conflict (invoice_id, version) do nothing returning *`);
413
477
  if (!rows.length) throw xeroError('xero_push_in_progress', 'This invoice is being sent to Xero already. Check again shortly.', { status: 409 });
414
478
  return dispatch(db, client, mapPush(rows[0]), document, { fresh: true });
@@ -481,7 +545,50 @@ async function verify(db, client, push, draft, document, created, outcome) {
481
545
  return { invoiceId: push.invoiceId, version: push.version, outcome: 'mismatch', xero: settled.xero, error: settled.error };
482
546
  }
483
547
  const settled = await settle(db, push.id, { state: 'created', xeroInvoiceId: live.InvoiceID, xeroNumber: live.InvoiceNumber, xeroStatus: live.Status, verified: true });
484
- return { invoiceId: push.invoiceId, version: push.version, outcome, verified: true, xero: settled.xero };
548
+ const email = await emailIfDue(db, client, settled);
549
+ return { invoiceId: push.invoiceId, version: push.version, outcome, verified: true, xero: settled.xero, ...(email ? { email } : {}) };
550
+ }
551
+
552
+ // ---------------------------------------------------------------------------
553
+ // Email
554
+
555
+ // Xero errors that prove it sent no email: it refused the request.
556
+ const emailRefused = (error) => error.code === 'xero_validation_failed'
557
+ || (error.code === 'xero_api_error' && error.status >= 400 && error.status < 500);
558
+
559
+ // Asks Xero to email an approved invoice that Flow sent and checked, once.
560
+ // Answers the email's state, or null when the push sends no email.
561
+ async function emailIfDue(db, client, push) {
562
+ if (push.sendMode !== 'approve_and_email' || !push.email) return null;
563
+ if (!push.verified || !push.xero?.invoiceId || !['pending', 'failed'].includes(push.email.state)) return push.email;
564
+ // Claim the email, so two pushes never both send it.
565
+ const { rows } = await db.execute(sql`update xero_invoice_pushes set email_state = 'sending', updated_at = now()
566
+ where id = ${push.id} and email_state in ('pending', 'failed') returning id`);
567
+ if (!rows.length) return { state: 'sending', error: null, at: null };
568
+ try {
569
+ await client.emailInvoice(push.xero.invoiceId);
570
+ } catch (error) {
571
+ const state = emailRefused(error) ? 'failed' : 'unknown';
572
+ const message = state === 'failed'
573
+ ? `Xero did not email the invoice: ${error.message}`
574
+ : 'Flow does not know whether Xero emailed the invoice. Check the invoice in Xero before you send it again.';
575
+ await db.execute(sql`update xero_invoice_pushes set email_state = ${state}, email_error = ${message}, updated_at = now() where id = ${push.id}`);
576
+ if (error.code === 'xero_rate_limited' || error.code === 'xero_reconnect_needed') throw error;
577
+ return { state, error: message, at: null };
578
+ }
579
+ const { rows: [done] } = await db.execute(sql`update xero_invoice_pushes set email_state = 'sent', email_error = null, emailed_at = now(), updated_at = now()
580
+ where id = ${push.id} returning emailed_at`);
581
+ return { state: 'sent', error: null, at: done.emailed_at };
582
+ }
583
+
584
+ // Flow drafted this invoice from a job, and its complete, fresh copy of Xero
585
+ // showed no invoice for that job: Xero needs no live duplicate check.
586
+ async function copyShowsNoInvoice(db, runtime, organisationId, invoiceId) {
587
+ const { rows: [job] } = await db.execute(sql`select accounting_state from billing_obligations
588
+ where organisation_id = ${organisationId} and invoice_id = ${invoiceId}`);
589
+ if (job?.accounting_state !== 'not_found') return false;
590
+ const mirror = await mirrorState(db, organisationId, { now: new Date(runtime.now()) });
591
+ return mirror.complete && mirror.fresh;
485
592
  }
486
593
 
487
594
  // An earlier attempt whose outcome is unknown.
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { sql } from 'drizzle-orm';
3
3
  import { enqueueJob, runNextJob } from '../../core/queue.js';
4
+ import { lastSync, syncCatalogue } from './catalogue.js';
4
5
  import { MIRROR_RESOURCES, mirrorXero } from './mirror.js';
5
6
 
6
7
  // The history import. When an owner connects Xero, Flow copies the
@@ -17,6 +18,9 @@ const LEASE_MS = 30 * 60 * 1000;
17
18
  // Xero's day limit resets on a rolling day; an hour is a cheap re-check.
18
19
  const BUDGET_WAIT_MS = 60 * 60 * 1000;
19
20
  const IDLE_MS = 30 * 1000;
21
+ // Contacts and items are read again once a day, so the matcher can link each
22
+ // customer (server/core/pipeline.js) without a person pressing Sync.
23
+ const CATALOGUE_EVERY_MS = 24 * 60 * 60 * 1000;
20
24
 
21
25
  export async function queueHistoryImport(db, organisationId, { reason = 'connected', runAfter = null } = {}) {
22
26
  return enqueueJob(db, organisationId, { kind: IMPORT_KIND, dedupeKey: IMPORT_KEY, payload: { reason }, runAfter });
@@ -63,6 +67,23 @@ export async function runImportOnce(db, runtime, { workerId = `xero-import-${ran
63
67
  return ran;
64
68
  }
65
69
 
70
+ // Reads the contacts and items of each connected organisation whose last good
71
+ // read is older than a day. A failure is logged and tried again next hour.
72
+ export async function refreshStaleCatalogues(db, runtime, { now = Date.now, log = console } = {}) {
73
+ const { rows } = await db.execute(sql`select organisation_id from xero_connections where mode = ${runtime.settings.mode}`);
74
+ for (const { organisation_id: organisationId } of rows) {
75
+ const last = await lastSync(db, organisationId);
76
+ const at = last?.finishedAt ?? last?.startedAt;
77
+ if (last && at && Number(now()) - new Date(at) < (last.status === 'succeeded' ? CATALOGUE_EVERY_MS : BUDGET_WAIT_MS)) continue;
78
+ try {
79
+ const run = await syncCatalogue(db, runtime, organisationId);
80
+ log.info?.(`[xero] contacts and items read for organisation ${organisationId}: ${run.contactCount} contacts, ${run.itemCount} items`);
81
+ } catch (error) {
82
+ log.error?.(`[xero] reading contacts and items for organisation ${organisationId} failed: ${error?.code ?? 'error'}`);
83
+ }
84
+ }
85
+ }
86
+
66
87
  // The background worker: runs due slices one at a time, then waits. Started
67
88
  // by the adapter outside replay mode; stop() waits for a slice in flight.
68
89
  export function createImportWorker({ db, runtime, log = console, timers = { setTimeout, clearTimeout }, firstDelayMs = 10_000 }) {
@@ -83,6 +104,7 @@ export function createImportWorker({ db, runtime, log = console, timers = { setT
83
104
  let busy = false;
84
105
  running = (async () => {
85
106
  try {
107
+ await refreshStaleCatalogues(db, runtime, { log });
86
108
  const ran = await runImportOnce(db, runtime, { workerId });
87
109
  if (ran) {
88
110
  busy = true;
@@ -1,8 +1,10 @@
1
1
  import { fileURLToPath } from 'node:url';
2
2
  import { sql } from 'drizzle-orm';
3
- import { pushInvoice } from './drafts.js';
3
+ import { checkDrafts, pushInvoice, pushInvoices } from './drafts.js';
4
+ import { readConnection } from './connections.js';
4
5
  import { createImportWorker } from './import.js';
5
- import { readPayments } from './ledger.js';
6
+ import { readPayments, xeroLiveCheck } from './ledger.js';
7
+ import { findInvoices, listCustomers } from './lookup.js';
6
8
  import { createXeroRouter } from './routes.js';
7
9
  import { createXeroRuntime, requireXeroSettings } from './runtime.js';
8
10
  import { writesAllowed } from './settings.js';
@@ -30,7 +32,7 @@ export const XERO_SETUP = {
30
32
  },
31
33
  fields: [
32
34
  { name: 'XERO_MODE', label: 'Mode', kind: 'choice', choices: [{ value: 'live', label: 'Live: your Xero organisation' }, { value: 'sandbox', label: 'Sandbox: a Xero Demo Company' }] },
33
- { name: 'XERO_REDIRECT_URI', label: 'Redirect URI', kind: 'url', suggest: 'callback', help: 'This installation\'s address plus /api/xero/oauth/callback. Register the same address on the Xero app.' },
35
+ { name: 'XERO_REDIRECT_URI', label: 'Redirect URI', kind: 'url', suggest: 'callback', help: 'Register the same address on the Xero app.' },
34
36
  { name: 'XERO_CLIENT_ID', label: 'Client id', help: 'From the Xero app\'s Configuration.' },
35
37
  { name: 'XERO_CLIENT_SECRET', label: 'Client secret', secret: true, help: 'Stored encrypted. Flow never shows it again.' },
36
38
  { name: 'XERO_TENANT_NAME', label: 'Xero organisation name', help: 'Live mode connects only the organisation with this exact name.' },
@@ -85,9 +87,25 @@ export function createXeroAdapter({ runtime: given = null } = {}) {
85
87
  // One issued invoice to a Xero draft (drafts.js). The Flow invoice id
86
88
  // and version are the idempotency key.
87
89
  pushInvoice: ({ organisationId, invoiceId, pushedBy = null }) => pushInvoice(database, current(), { organisationId, invoiceId, pushedBy }),
90
+ // Several issued invoices, in batches of MAX_BATCH (drafts.js).
91
+ pushInvoices: ({ organisationId, invoiceIds, pushedBy = null }) => pushInvoices(database, current(), { organisationId, invoiceIds, pushedBy }),
92
+ // What would stop each Flow draft in Xero, from Flow's copy (no calls).
93
+ checkDrafts: ({ organisationId, invoiceIds }) => checkDrafts(database, current(), { organisationId, invoiceIds }),
94
+ sendStatus: async ({ organisationId }) => {
95
+ const { settings } = current();
96
+ const connection = await readConnection(database, organisationId);
97
+ return { connected: Boolean(connection && connection.mode === settings.mode), writesAllowed: writesAllowed(settings) };
98
+ },
88
99
  // Sales invoice payments changed since `since` (ledger.js). The answer's
89
100
  // cursor is the next `since`.
90
101
  pullPayments: ({ organisationId, since }) => readPayments(database, current(), organisationId, { since: new Date(since) }),
102
+ // Xero's invoice for one billable job, looked up live (ledger.js): the
103
+ // `liveCheck` for draftInvoiceForObligation.
104
+ checkInvoice: ({ organisationId, obligation }) => xeroLiveCheck(database, current(), organisationId)(obligation),
105
+ // Active Xero contacts, from Flow's copy (lookup.js).
106
+ listCustomers: ({ organisationId }) => listCustomers(database, organisationId),
107
+ // Sales invoices from the mirror, with the mirror's state (lookup.js).
108
+ findInvoices: ({ organisationId, ...query }) => findInvoices(database, organisationId, query),
91
109
  },
92
110
  routes: ({ db, personAuth, log, messagePage }) => createXeroRouter({ db, personAuth, runtime: current(), log, messagePage }),
93
111
  };
@@ -0,0 +1,94 @@
1
+ import { sql } from 'drizzle-orm';
2
+ import { ledgerState } from './ledger.js';
3
+ import { MIRROR_RESOURCES } from './mirror.js';
4
+
5
+ // What the core's matcher (server/core/pipeline.js) reads from Xero: the
6
+ // customers and the sales invoices in Flow's own copy. It never calls Xero, so
7
+ // matching a whole history costs no API calls.
8
+
9
+ // How old the mirror's last finished pass may be before "no invoice found"
10
+ // stops counting as proof.
11
+ export const MIRROR_FRESH_MS = 24 * 60 * 60 * 1000;
12
+
13
+ export async function listCustomers(db, organisationId) {
14
+ const { rows } = await db.execute(sql`
15
+ select xero_contact_id, name, status from xero_contacts
16
+ where organisation_id = ${organisationId} and status = 'ACTIVE'
17
+ order by name`);
18
+ return rows.map((r) => ({ id: r.xero_contact_id, name: r.name }));
19
+ }
20
+
21
+ // Whether the mirror holds the whole history and was read lately. Until then a
22
+ // search that finds nothing proves nothing.
23
+ export async function mirrorState(db, organisationId, { now = new Date() } = {}) {
24
+ const { rows } = await db.execute(sql`
25
+ select resource, passes, last_pass_finished_at from xero_mirror_cursors
26
+ where organisation_id = ${organisationId}`);
27
+ const byResource = new Map(rows.map((r) => [r.resource, r]));
28
+ const invoices = byResource.get('invoices');
29
+ const finished = MIRROR_RESOURCES.every((r) => (byResource.get(r)?.passes ?? 0) > 0);
30
+ const lastPass = invoices?.last_pass_finished_at ? new Date(invoices.last_pass_finished_at) : null;
31
+ const { rows: [changed] } = await db.execute(sql`
32
+ select max(last_changed_at) as at from xero_invoices where organisation_id = ${organisationId}`);
33
+ return {
34
+ complete: finished,
35
+ fresh: Boolean(lastPass && now - lastPass <= MIRROR_FRESH_MS),
36
+ lastPassAt: lastPass,
37
+ // The latest change the mirror copied, so the matcher looks at a job again
38
+ // only when the mirror moved on.
39
+ changedAt: changed?.at ? new Date(changed.at) : null,
40
+ };
41
+ }
42
+
43
+ const ids = (list) => sql.param([...new Set(list.map(String))]);
44
+
45
+ function invoiceRow(r) {
46
+ return {
47
+ id: r.xero_invoice_id,
48
+ number: r.invoice_number,
49
+ contactId: r.xero_contact_id,
50
+ status: r.status,
51
+ // The ledger's accounting state for this invoice.
52
+ state: ledgerState({ Status: r.status, AmountPaid: Number(r.amount_paid_cents ?? 0), AmountCredited: Number(r.amount_credited_cents ?? 0) }),
53
+ issuedOn: r.issued_on instanceof Date ? r.issued_on.toISOString().slice(0, 10) : r.issued_on,
54
+ subtotalCents: r.subtotal_cents === null ? null : Number(r.subtotal_cents),
55
+ totalCents: r.total_cents === null ? null : Number(r.total_cents),
56
+ amountDueCents: r.amount_due_cents === null ? null : Number(r.amount_due_cents),
57
+ amountPaidCents: r.amount_paid_cents === null ? null : Number(r.amount_paid_cents),
58
+ amountCreditedCents: r.amount_credited_cents === null ? null : Number(r.amount_credited_cents),
59
+ itemCodes: [...new Set((r.lines ?? []).map((l) => l.itemCode).filter(Boolean))],
60
+ flowInvoiceId: r.flow_invoice_id ?? null,
61
+ changedAt: r.last_changed_at,
62
+ };
63
+ }
64
+
65
+ // Sales invoices from the mirror, by any of: Xero ids, invoice numbers, the
66
+ // Flow invoices Flow sent, or one customer within a date range. Answers the
67
+ // mirror's state with them, and the latest change time it holds.
68
+ export async function findInvoices(db, organisationId, query = {}) {
69
+ const { invoiceIds = [], numbers = [], flowInvoiceIds = [], contactId = null, from = null, to = null } = query;
70
+ const where = [];
71
+ if (invoiceIds.length) where.push(sql`i.xero_invoice_id = any(${ids(invoiceIds)}::text[])`);
72
+ if (numbers.length) where.push(sql`upper(i.invoice_number) = any(${sql.param([...new Set(numbers.map((n) => String(n).toUpperCase()))])}::text[])`);
73
+ if (flowInvoiceIds.length) where.push(sql`p.invoice_id = any(${ids(flowInvoiceIds)}::uuid[])`);
74
+ if (contactId) {
75
+ where.push(sql`(i.xero_contact_id = ${contactId}
76
+ ${from ? sql`and i.issued_on >= ${from}::date` : sql``}
77
+ ${to ? sql`and i.issued_on <= ${to}::date` : sql``})`);
78
+ }
79
+ const state = await mirrorState(db, organisationId);
80
+ if (!where.length) return { ...state, invoices: [] };
81
+ const { rows } = await db.execute(sql`
82
+ select i.*, p.invoice_id as flow_invoice_id
83
+ from xero_invoices i
84
+ left join lateral (
85
+ select invoice_id from xero_invoice_pushes
86
+ where organisation_id = i.organisation_id and xero_invoice_id = i.xero_invoice_id
87
+ order by version desc limit 1
88
+ ) p on true
89
+ where i.organisation_id = ${organisationId} and i.type = 'ACCREC'
90
+ and (${sql.join(where, sql` or `)})
91
+ order by i.issued_on, i.xero_invoice_id
92
+ limit 500`);
93
+ return { ...state, invoices: rows.map(invoiceRow) };
94
+ }
@@ -0,0 +1,10 @@
1
+ -- How each push was sent (invoicing_settings.send_mode) and, for an invoice
2
+ -- sent approved, whether Xero emailed it to the customer. Xero's email action
3
+ -- is not idempotent, so Flow records `sending` before the call and retries
4
+ -- only a refusal (`failed`), never an unknown outcome.
5
+ ALTER TABLE "xero_invoice_pushes" ADD COLUMN "send_mode" text DEFAULT 'accounting_draft' NOT NULL;--> statement-breakpoint
6
+ ALTER TABLE "xero_invoice_pushes" ADD COLUMN "email_state" text;--> statement-breakpoint
7
+ ALTER TABLE "xero_invoice_pushes" ADD COLUMN "email_error" text;--> statement-breakpoint
8
+ ALTER TABLE "xero_invoice_pushes" ADD COLUMN "emailed_at" timestamp with time zone;--> statement-breakpoint
9
+ ALTER TABLE "xero_invoice_pushes" ADD CONSTRAINT "xero_invoice_pushes_send_mode_valid" CHECK ("send_mode" in ('accounting_draft', 'approve_and_email'));--> statement-breakpoint
10
+ ALTER TABLE "xero_invoice_pushes" ADD CONSTRAINT "xero_invoice_pushes_email_state_valid" CHECK ("email_state" is null or "email_state" in ('pending', 'sending', 'sent', 'failed', 'unknown'));
@@ -36,6 +36,13 @@
36
36
  "when": 1792388000000,
37
37
  "tag": "0004_xero_mirror",
38
38
  "breakpoints": true
39
+ },
40
+ {
41
+ "idx": 5,
42
+ "version": "7",
43
+ "when": 1792406000000,
44
+ "tag": "0005_xero_send_modes",
45
+ "breakpoints": true
39
46
  }
40
47
  ]
41
48
  }
@@ -1,5 +1,6 @@
1
- // WCAG 2.1 contrast helpers for theme tokens (server/theme/themes.js and
2
- // scripts/port-openclaw-themes.mjs). Colours are #rrggbb.
1
+ // WCAG 2.1 contrast helpers for theme tokens (server/theme/themes.js,
2
+ // server/theme/contrast-audit.js and scripts/port-openclaw-themes.mjs).
3
+ // Colours are #rrggbb, except where parseColor reads a translucent one.
3
4
 
4
5
  export const HEX = /^#[0-9a-f]{6}$/i;
5
6
 
@@ -37,6 +38,16 @@ export function readableOn(color, bg, target = 4.5) {
37
38
  return toward[0] ? '#ffffff' : '#000000';
38
39
  }
39
40
 
41
+ /** readableOn for every surface in turn, until the colour reads on all of them. */
42
+ export function readableOnEvery(color, surfaces, target = 4.5) {
43
+ let out = color;
44
+ for (let pass = 0; pass < 3; pass += 1) {
45
+ for (const bg of surfaces) out = readableOn(out, bg, target);
46
+ if (surfaces.every((bg) => !HEX.test(out) || !HEX.test(bg) || contrast(out, bg) >= target)) break;
47
+ }
48
+ return out;
49
+ }
50
+
40
51
  /**
41
52
  * A boundary colour for controls on `bg` (an input's or an outlined button's
42
53
  * edge): `bg` mixed toward `ink` just enough to stand out at `target`
@@ -52,3 +63,29 @@ export function boundaryOn(bg, ink, target = 3) {
52
63
  }
53
64
  return ink;
54
65
  }
66
+
67
+ /** [r, g, b, a] (0-255, alpha 0-1) for #rrggbb, rgb()/rgba() or color-mix(in srgb, X N%, transparent). */
68
+ export function parseColor(value) {
69
+ const v = String(value).trim();
70
+ let m = v.match(/^#([0-9a-f]{6})$/i);
71
+ if (m) return [0, 2, 4].map((i) => Number.parseInt(m[1].slice(i, i + 2), 16)).concat(1);
72
+ m = v.match(/^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)(?:[\s,/]+([\d.]+%?))?\s*\)$/i);
73
+ if (m) {
74
+ const a = m[4] === undefined ? 1 : m[4].endsWith('%') ? Number.parseFloat(m[4]) / 100 : Number(m[4]);
75
+ return [Number(m[1]), Number(m[2]), Number(m[3]), a];
76
+ }
77
+ m = v.match(/^color-mix\(in srgb,\s*(.+?)\s+([\d.]+)%,\s*transparent\)$/i);
78
+ if (m) {
79
+ const [r, g, b, a] = parseColor(m[1]);
80
+ return [r, g, b, a * Number(m[2]) / 100];
81
+ }
82
+ throw new Error(`contrast audit cannot read colour "${value}"`);
83
+ }
84
+
85
+ /** `top` painted over `under` (each a colour string), as an opaque #rrggbb. */
86
+ export function over(top, under) {
87
+ const [r, g, b, a] = parseColor(top);
88
+ const base = parseColor(under);
89
+ if (base[3] < 1) throw new Error(`contrast audit needs an opaque surface, got "${under}"`);
90
+ return `#${[r, g, b].map((c, i) => Math.round(c * a + base[i] * (1 - a)).toString(16).padStart(2, '0')).join('')}`;
91
+ }
@@ -0,0 +1,63 @@
1
+ // The WCAG AA contrast audit for every design system (server/theme/themes.js).
2
+ // Each pair is a foreground a screen really draws on a surface it really sits
3
+ // on: Flow's components (server/ui/flow.css) and Clerk's account and sign-in
4
+ // screens (server/ui/clerk-appearance.js). Translucent tokens (rgba() and
5
+ // color-mix() toward transparent) are laid over the surface they sit on before
6
+ // the ratio is taken, the way a browser paints them. test/theme-contrast.test.js
7
+ // runs it in CI; `npm run theme:contrast` prints the table.
8
+ import { contrast, over, parseColor } from './color-contrast.js';
9
+
10
+ // WCAG 2.1: 4.5:1 for text (1.4.3), 3:1 for the edge or fill that shows a
11
+ // control or its state (1.4.11).
12
+ export const TEXT = 4.5;
13
+ export const UI = 3;
14
+
15
+ function withAlpha(color, alpha) {
16
+ const [r, g, b, a] = parseColor(color);
17
+ return `rgba(${r}, ${g}, ${b}, ${a * alpha})`;
18
+ }
19
+
20
+ // [id, what it is, minimum, (tokens) => [foreground, surface]]. A surface
21
+ // given as an array is laid bottom first, so a tinted badge on a card is
22
+ // [paper, tint].
23
+ const PAIRS = [
24
+ ['text/bg', 'Body text on the page', TEXT, (k) => [k['--t-text'], k['--t-bg']]],
25
+ ['text/paper', 'Body text on a card', TEXT, (k) => [k['--t-text'], k['--t-paper']]],
26
+ ['strong/paper', 'Headings and names on a card', TEXT, (k) => [k['--t-strong'], k['--t-paper']]],
27
+ ['strong/bg', 'Page titles on the page', TEXT, (k) => [k['--t-strong'], k['--t-bg']]],
28
+ ['dim/bg', 'Secondary text on the page', TEXT, (k) => [k['--t-dim'], k['--t-bg']]],
29
+ ['dim/paper', 'Secondary text on a card (Clerk muted text)', TEXT, (k) => [k['--t-dim'], k['--t-paper']]],
30
+ ['dim/s1', 'Table headings, Clerk side menu', TEXT, (k) => [k['--t-dim'], k['--t-s1']]],
31
+ ['dim/s2', 'Segmented control labels', TEXT, (k) => [k['--t-dim'], k['--t-s2']]],
32
+ ['mute/paper', 'Muted text on a card', TEXT, (k) => [k['--t-mute'], k['--t-paper']]],
33
+ ['accent/bg', 'Links on the page', TEXT, (k) => [k['--t-accent'], k['--t-bg']]],
34
+ ['accent/paper', 'Links on a card', TEXT, (k) => [k['--t-accent'], k['--t-paper']]],
35
+ ['accent/accent-bg', 'Status and role badges', TEXT, (k) => [k['--t-accent'], [k['--t-paper'], k['--t-accent-bg']]]],
36
+ ['strong/accent-bg', 'Current page in the menu', TEXT, (k) => [k['--t-strong'], [k['--t-bg'], k['--t-accent-bg']]]],
37
+ ['text/accent-bg', 'Notices', TEXT, (k) => [k['--t-text'], [k['--t-paper'], k['--t-accent-bg']]]],
38
+ ['on-primary/primary', 'Primary buttons', TEXT, (k) => [k['--t-on-primary'], k['--t-primary']]],
39
+ ['brand-ink/brand', 'Text on the brand colour', TEXT, (k) => [k['--t-brand-ink'], k['--t-brand']]],
40
+ ['success/success-bg', 'Success badges', TEXT, (k) => [k['--t-success'], [k['--t-paper'], k['--t-success-bg']]]],
41
+ ['warn/warn-bg', 'Warning badges', TEXT, (k) => [k['--t-warn'], [k['--t-paper'], k['--t-warn-bg']]]],
42
+ ['danger/danger-bg', 'Error notices', TEXT, (k) => [k['--t-danger'], [k['--t-paper'], k['--t-danger-bg']]]],
43
+ ['paper/danger', 'Danger buttons and error toasts', TEXT, (k) => [k['--t-paper'], k['--t-danger']]],
44
+ ['bg/strong', 'Toasts', TEXT, (k) => [k['--t-bg'], k['--t-strong']]],
45
+ // server/ui/clerk-appearance.js: badge.
46
+ ['clerk-badge', 'Clerk "Primary" badge by an email', TEXT, (k) => [k['--t-dim'], [k['--t-paper'], withAlpha(k['--t-text'], 0.05)]]],
47
+ ['control/paper', 'Input and outlined button edges', UI, (k) => [k['--t-control'], k['--t-paper']]],
48
+ ['ring/paper', 'Focus ring', UI, (k) => [k['--t-ring'], k['--t-paper']]],
49
+ ['brand/paper', 'Selected theme card ring', UI, (k) => [k['--t-brand'], k['--t-paper']]],
50
+ ['primary/paper', 'Primary button edge on a card', UI, (k) => [k['--t-primary'], k['--t-paper']]],
51
+ ];
52
+
53
+ /** Every pair for one family and mode: { id, label, min, fg, bg, ratio, pass }. */
54
+ export function auditTokens(tokens) {
55
+ return PAIRS.map(([id, label, min, pick]) => {
56
+ const [fgRaw, surface] = pick(tokens);
57
+ const layers = Array.isArray(surface) ? surface : [surface];
58
+ const bg = layers.slice(1).reduce((under, top) => over(top, under), over(layers[0], '#ffffff'));
59
+ const fg = over(fgRaw, bg);
60
+ const ratio = contrast(fg, bg);
61
+ return { id, label, min, fg, bg, ratio: Math.round(ratio * 100) / 100, pass: ratio >= min };
62
+ });
63
+ }
@@ -9,7 +9,7 @@
9
9
  * ./openclaw-palettes.js (docs/DESIGN.md). The engine is Arc Forge Labs'
10
10
  * Field's, so a family looks the same in both apps.
11
11
  */
12
- import { boundaryOn, HEX, inkOn, readableOn } from './color-contrast.js';
12
+ import { boundaryOn, contrast, HEX, inkOn, over, readableOn, readableOnEvery } from './color-contrast.js';
13
13
  import { OPENCLAW_FAMILIES } from './openclaw-palettes.js';
14
14
 
15
15
  export { boundaryOn, contrast, inkOn, readableOn } from './color-contrast.js';
@@ -125,12 +125,20 @@ const MERIDIAN = {
125
125
  const { beacon, tide, claw, knot, dash, ...otherOpenclaw } = OPENCLAW_FAMILIES;
126
126
  const FAMILIES = { harbour: HARBOUR, meridian: MERIDIAN, beacon, tide, claw, knot, dash, ...otherOpenclaw };
127
127
 
128
+ // Every family keeps WCAG AA (test/theme-contrast.test.js). A ported palette
129
+ // that misses it on a surface Flow uses is nudged here, just far enough:
130
+ // secondary text on every surface, accent text on the page, cards and its own
131
+ // tint, and the ink on the brand colour. Harbour and Meridian already pass.
128
132
  // --t-control: the edge of an input or an outlined button on --t-paper, the
129
133
  // text colour faded toward the paper only as far as it still reads at 3:1.
130
134
  for (const family of Object.values(FAMILIES)) {
131
135
  for (const mode of ['light', 'dark']) {
132
136
  const tokens = family[mode];
133
- tokens['--t-control'] = boundaryOn(tokens['--t-paper'], tokens['--t-text']);
137
+ const { '--t-bg': bg, '--t-paper': paper, '--t-s1': s1, '--t-s2': s2 } = tokens;
138
+ tokens['--t-dim'] = readableOnEvery(tokens['--t-dim'], [bg, paper, s1, s2]);
139
+ tokens['--t-accent'] = readableOnEvery(tokens['--t-accent'], [bg, paper, over(tokens['--t-accent-bg'], paper)]);
140
+ if (contrast(tokens['--t-brand-ink'], tokens['--t-brand']) < 4.5) tokens['--t-brand-ink'] = inkOn(tokens['--t-brand']);
141
+ tokens['--t-control'] = boundaryOn(paper, tokens['--t-text']);
134
142
  }
135
143
  }
136
144