@arcforgelabs/flow 2026.10.25 → 2026.10.26

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 (38) hide show
  1. package/README.md +1 -0
  2. package/npm-shrinkwrap.json +2 -2
  3. package/package.json +2 -1
  4. package/server/api/invoicing.js +375 -0
  5. package/server/app.js +11 -0
  6. package/server/auth/people.js +26 -0
  7. package/server/auth/routes.js +13 -0
  8. package/server/core/invoices.js +7 -0
  9. package/server/core/ledger.js +6 -3
  10. package/server/core/links.js +3 -3
  11. package/server/core/pipeline.js +488 -0
  12. package/server/db/migrations/0008_people_appearance.sql +1 -0
  13. package/server/db/migrations/0009_pipeline_matcher.sql +46 -0
  14. package/server/db/migrations/meta/0008_snapshot.json +2479 -0
  15. package/server/db/migrations/meta/0009_snapshot.json +2809 -0
  16. package/server/db/migrations/meta/_journal.json +14 -0
  17. package/server/db/schema.js +71 -2
  18. package/server/index.js +9 -0
  19. package/server/integrations/port.js +3 -0
  20. package/server/integrations/seazona/feed.js +11 -0
  21. package/server/integrations/seazona/index.js +7 -1
  22. package/server/integrations/seazona/ledger.js +2 -0
  23. package/server/integrations/xero/import.js +22 -0
  24. package/server/integrations/xero/index.js +10 -2
  25. package/server/integrations/xero/lookup.js +94 -0
  26. package/server/theme/color-contrast.js +39 -2
  27. package/server/theme/contrast-audit.js +63 -0
  28. package/server/theme/themes.js +10 -2
  29. package/server/ui/app-page.js +5 -10
  30. package/server/ui/app.js +351 -105
  31. package/server/ui/appearance.js +3 -2
  32. package/server/ui/clerk-appearance.js +18 -2
  33. package/server/ui/flow.css +246 -22
  34. package/server/ui/invoicing.js +451 -0
  35. package/server/ui/jobs.js +153 -0
  36. package/server/ui/logos/seazona.svg +1 -0
  37. package/server/ui/logos/xero.svg +1 -0
  38. package/server/ui/routes.js +5 -1
@@ -57,6 +57,20 @@
57
57
  "when": 1792391146736,
58
58
  "tag": "0007_integration_settings",
59
59
  "breakpoints": true
60
+ },
61
+ {
62
+ "idx": 8,
63
+ "version": "7",
64
+ "when": 1792396463612,
65
+ "tag": "0008_people_appearance",
66
+ "breakpoints": true
67
+ },
68
+ {
69
+ "idx": 9,
70
+ "version": "7",
71
+ "when": 1792404377371,
72
+ "tag": "0009_pipeline_matcher",
73
+ "breakpoints": true
60
74
  }
61
75
  ]
62
76
  }
@@ -9,6 +9,7 @@ import {
9
9
  integer,
10
10
  jsonb,
11
11
  pgTable,
12
+ primaryKey,
12
13
  text,
13
14
  timestamp,
14
15
  unique,
@@ -153,7 +154,9 @@ export const PERSON_STATUSES = ['active', 'pending', 'disabled'];
153
154
  // someone is; this table decides what they may do. One installation serves one
154
155
  // lab, so people are installation-wide and carry no organisation_id. Emails
155
156
  // are stored lower-case. `clerk_user_id` is set on the person's first verified
156
- // sign-in; until then the row is an invitation.
157
+ // sign-in; until then the row is an invitation. `appearance` is the person's
158
+ // own design system, light or dark and accent (server/auth/people.js,
159
+ // normaliseAppearance); null until they first choose.
157
160
  export const people = pgTable('people', {
158
161
  id: id(),
159
162
  email: text('email').notNull(),
@@ -163,6 +166,7 @@ export const people = pgTable('people', {
163
166
  clerkUserId: text('clerk_user_id'),
164
167
  invitedBy: uuid('invited_by').references(() => people.id, { onDelete: 'set null' }),
165
168
  linkedAt: timestamp('linked_at', { withTimezone: true }),
169
+ appearance: jsonb('appearance'),
166
170
  createdAt: createdAt(),
167
171
  updatedAt: updatedAt(),
168
172
  }, (t) => [
@@ -202,6 +206,9 @@ export const billingObligations = pgTable('billing_obligations', {
202
206
  contactId: uuid('contact_id'),
203
207
  practitioner: text('practitioner'),
204
208
  shippedOn: date('shipped_on'),
209
+ // The day of the work, for matching it to an accounting invoice: the work
210
+ // system's invoice date, else its due date, else its order date.
211
+ jobDate: date('job_date'),
205
212
  // The lines as the work system reported them, normalised.
206
213
  lines: jsonb('lines').notNull().default(sql`'[]'::jsonb`),
207
214
  lineFingerprint: text('line_fingerprint').notNull(),
@@ -316,7 +323,9 @@ export const obligationWritebacks = pgTable('obligation_writebacks', {
316
323
  // one active link per external record and per Flow record, in each system. A
317
324
  // link that is not exact stays `proposed` until a person confirms it.
318
325
  export const LINK_ENTITIES = ['contact', 'price_item', 'obligation', 'invoice', 'payment', 'credit_note'];
319
- export const LINK_MATCHES = ['created_by_flow', 'exact_id', 'imported', 'name', 'person'];
326
+ // `exact_name`: the same name, after spacing and case are evened out, held by
327
+ // exactly one record on each side.
328
+ export const LINK_MATCHES = ['created_by_flow', 'exact_id', 'exact_name', 'imported', 'name', 'person'];
320
329
  export const LINK_STATES = ['active', 'proposed', 'superseded'];
321
330
 
322
331
  export const externalLinks = pgTable('external_links', {
@@ -490,3 +499,63 @@ export const integrationSettingEvents = pgTable('integration_setting_events', {
490
499
  index('integration_setting_events_adapter').on(t.adapterId, t.createdAt),
491
500
  check('integration_setting_events_kind_valid', inList('kind', INTEGRATION_SETTING_EVENTS)),
492
501
  ]);
502
+
503
+ // The pipeline (server/core/pipeline.js, issue 58). Where each step that reads
504
+ // another system's records stands, by name, so a restart goes on from there.
505
+ export const pipelineCursors = pgTable('pipeline_cursors', {
506
+ organisationId: organisationId().references(() => organisations.id),
507
+ name: text('name').notNull(),
508
+ cursor: text('cursor'),
509
+ updatedAt: updatedAt(),
510
+ }, (t) => [
511
+ primaryKey({ name: 'pipeline_cursors_pk', columns: [t.organisationId, t.name] }),
512
+ ]);
513
+
514
+ // The matcher's suggestions: accounting invoices that could bill a job, with
515
+ // the signals that agreed. Only exact evidence links a job by itself; a
516
+ // suggestion waits for a person. A newer run marks the ones it no longer
517
+ // makes `stale`.
518
+ export const LINK_SUGGESTION_STATES = ['open', 'accepted', 'rejected', 'stale'];
519
+
520
+ export const linkSuggestions = pgTable('link_suggestions', {
521
+ id: id(),
522
+ organisationId: organisationId().references(() => organisations.id),
523
+ obligationId: uuid('obligation_id').notNull(),
524
+ system: text('system').notNull(),
525
+ externalId: text('external_id').notNull(),
526
+ externalNumber: text('external_number'),
527
+ score: integer('score').notNull(),
528
+ rank: integer('rank').notNull(),
529
+ signals: jsonb('signals').notNull().default(sql`'[]'::jsonb`),
530
+ state: text('state').notNull().default('open'),
531
+ decidedBy: text('decided_by'),
532
+ decidedAt: timestamp('decided_at', { withTimezone: true }),
533
+ createdAt: createdAt(),
534
+ updatedAt: updatedAt(),
535
+ }, (t) => [
536
+ unique('link_suggestions_pair').on(t.organisationId, t.obligationId, t.system, t.externalId),
537
+ index('link_suggestions_open').on(t.organisationId, t.state, t.obligationId),
538
+ foreignKey({
539
+ name: 'link_suggestions_obligation_same_org',
540
+ columns: [t.organisationId, t.obligationId],
541
+ foreignColumns: [billingObligations.organisationId, billingObligations.id],
542
+ }),
543
+ check('link_suggestions_state_valid', inList('state', LINK_SUGGESTION_STATES)),
544
+ ]);
545
+
546
+ // A person's "not this one": the matcher never suggests the pair again.
547
+ export const linkRejections = pgTable('link_rejections', {
548
+ organisationId: organisationId().references(() => organisations.id),
549
+ obligationId: uuid('obligation_id').notNull(),
550
+ system: text('system').notNull(),
551
+ externalId: text('external_id').notNull(),
552
+ actor: text('actor').notNull(),
553
+ createdAt: createdAt(),
554
+ }, (t) => [
555
+ primaryKey({ name: 'link_rejections_pk', columns: [t.organisationId, t.obligationId, t.system, t.externalId] }),
556
+ foreignKey({
557
+ name: 'link_rejections_obligation_same_org',
558
+ columns: [t.organisationId, t.obligationId],
559
+ foreignColumns: [billingObligations.organisationId, billingObligations.id],
560
+ }),
561
+ ]);
package/server/index.js CHANGED
@@ -6,6 +6,7 @@ import { createPersonAuth } from './auth/session.js';
6
6
  import { resolveSignInSettings } from './auth/settings.js';
7
7
  import { migrateDatabase, openDatabase } from './db/index.js';
8
8
  import { importEarlierAtStart, importEarlierLinksAtStart, resolveImportAtStart } from './import/at-start.js';
9
+ import { createPipelineWorker } from './core/pipeline.js';
9
10
  import { resolveSecretsKey } from './core/secrets.js';
10
11
  import { adapters as shippedAdapters } from './integrations/adapters.js';
11
12
  import { createIntegrations, resolveEnabledIntegrations } from './integrations/index.js';
@@ -79,6 +80,14 @@ try {
79
80
  const integrationsOn = integrations.list().filter((a) => a.enabled).map((a) => `${a.id}${a.state === 'ready' ? '' : ` (${a.state})`}`);
80
81
  console.log(`[integrations] on: ${integrationsOn.join(', ') || 'none'}; secrets key ${secrets ? `from ${secrets.source}` : 'not set (FLOW_SECRETS_KEY)'}`);
81
82
 
83
+ // The pipeline (server/core/pipeline.js) joins what the integrations copy into
84
+ // Flow: jobs into the ledger, and each job to its accounting invoice. It runs
85
+ // only while an integration that feeds it is on, and writes to no other
86
+ // system. FLOW_PIPELINE=off stops it.
87
+ if (process.env.FLOW_PIPELINE !== 'off') {
88
+ createPipelineWorker({ db: database.db, integrations }).start();
89
+ }
90
+
82
91
  let auth = null;
83
92
  if (settings.mode === 'clerk') {
84
93
  const { keys } = settings;
@@ -14,6 +14,9 @@ export const CAPABILITIES = Object.freeze([
14
14
  'pushInvoice', // send an issued invoice; the Flow record id is the idempotency key
15
15
  'pushBill', // send an approved bill; the Flow record id is the idempotency key
16
16
  'pullPayments', // payments since a cursor, matched to invoices and bills
17
+ 'checkInvoice', // does the accounting system hold an invoice for a billable job? (the ledger's liveCheck)
18
+ 'listCustomers', // the system's customers, by id and name, for the identity map (Seazona: clients; Xero: contacts)
19
+ 'findInvoices', // sales invoices in the accounting system's copy, for the matcher; never a live call
17
20
  'health', // connection state for Settings
18
21
  ]);
19
22
 
@@ -102,3 +102,14 @@ export async function readBillableWork(db, { cursor = null, limit = 200 } = {})
102
102
  more: rows.length === pageSize,
103
103
  };
104
104
  }
105
+
106
+ // Seazona clients, by id and the company name an invoice bills. A client
107
+ // with no company name is listed by its full name.
108
+ export async function listClients(db) {
109
+ const rows = await db.select({ id: seazonaClients.id, company: seazonaClients.company, fullName: seazonaClients.fullName, active: seazonaClients.active })
110
+ .from(seazonaClients);
111
+ return rows
112
+ .filter((r) => r.active !== false)
113
+ .map((r) => ({ id: r.id, name: (r.company || r.fullName || '').trim() }))
114
+ .filter((r) => r.name);
115
+ }
@@ -1,5 +1,5 @@
1
1
  import { createSeazonaClient } from './client.js';
2
- import { readBillableWork } from './feed.js';
2
+ import { listClients, readBillableWork } from './feed.js';
3
3
  import { readSeazonaSyncHealth, runSeazonaSync } from './lanes.js';
4
4
  import { createSeazonaScheduler } from './scheduler.js';
5
5
  import { describeSeazonaSettings, resolveSeazonaSettings } from './settings.js';
@@ -99,6 +99,12 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
99
99
  async pullWorkItems(cursor = null, options = {}) {
100
100
  return readBillableWork(current().db, { cursor, ...options });
101
101
  },
102
+
103
+ // Seazona clients by id and company name, for the identity map. Reads
104
+ // Flow's copy only.
105
+ async listCustomers() {
106
+ return listClients(current().db);
107
+ },
102
108
  },
103
109
  };
104
110
  }
@@ -46,6 +46,8 @@ export function toLedgerJob(item) {
46
46
  practitioner: item.doctorName ?? null,
47
47
  // The Seazona API gives a shipped status but no shipped date.
48
48
  shippedOn: null,
49
+ // The day of the work, for matching it to its accounting invoice.
50
+ jobDate: item.invoice?.invoiceDate ?? item.dueOn ?? item.orderedOn ?? null,
49
51
  lines: ledgerLinesFor(item).lines,
50
52
  observedAt: item.syncedAt,
51
53
  actor: SEAZONA_SOURCE_SYSTEM,
@@ -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;
@@ -2,7 +2,8 @@ import { fileURLToPath } from 'node:url';
2
2
  import { sql } from 'drizzle-orm';
3
3
  import { pushInvoice } from './drafts.js';
4
4
  import { createImportWorker } from './import.js';
5
- import { readPayments } from './ledger.js';
5
+ import { readPayments, xeroLiveCheck } from './ledger.js';
6
+ import { findInvoices, listCustomers } from './lookup.js';
6
7
  import { createXeroRouter } from './routes.js';
7
8
  import { createXeroRuntime, requireXeroSettings } from './runtime.js';
8
9
  import { writesAllowed } from './settings.js';
@@ -30,7 +31,7 @@ export const XERO_SETUP = {
30
31
  },
31
32
  fields: [
32
33
  { 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.' },
34
+ { name: 'XERO_REDIRECT_URI', label: 'Redirect URI', kind: 'url', suggest: 'callback', help: 'Register the same address on the Xero app.' },
34
35
  { name: 'XERO_CLIENT_ID', label: 'Client id', help: 'From the Xero app\'s Configuration.' },
35
36
  { name: 'XERO_CLIENT_SECRET', label: 'Client secret', secret: true, help: 'Stored encrypted. Flow never shows it again.' },
36
37
  { name: 'XERO_TENANT_NAME', label: 'Xero organisation name', help: 'Live mode connects only the organisation with this exact name.' },
@@ -88,6 +89,13 @@ export function createXeroAdapter({ runtime: given = null } = {}) {
88
89
  // Sales invoice payments changed since `since` (ledger.js). The answer's
89
90
  // cursor is the next `since`.
90
91
  pullPayments: ({ organisationId, since }) => readPayments(database, current(), organisationId, { since: new Date(since) }),
92
+ // Xero's invoice for one billable job, looked up live (ledger.js): the
93
+ // `liveCheck` for draftInvoiceForObligation.
94
+ checkInvoice: ({ organisationId, obligation }) => xeroLiveCheck(database, current(), organisationId)(obligation),
95
+ // Active Xero contacts, from Flow's copy (lookup.js).
96
+ listCustomers: ({ organisationId }) => listCustomers(database, organisationId),
97
+ // Sales invoices from the mirror, with the mirror's state (lookup.js).
98
+ findInvoices: ({ organisationId, ...query }) => findInvoices(database, organisationId, query),
91
99
  },
92
100
  routes: ({ db, personAuth, log, messagePage }) => createXeroRouter({ db, personAuth, runtime: current(), log, messagePage }),
93
101
  };
@@ -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
+ }
@@ -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
 
@@ -12,11 +12,6 @@ const BODY = `<div class="fx-app">
12
12
  <div class="fx-auth__brand">
13
13
  <span class="fx-brand fx-brand--large">${FLOW_MARK}<span>Flow</span></span>
14
14
  <h1 class="fx-auth__title" id="auth-title">Every job billed, every invoice paid.</h1>
15
- <ul class="fx-auth__points">
16
- <li>Jobs come in from the lab system.</li>
17
- <li>Invoices go out to Xero.</li>
18
- <li>Payments come back and close the job.</li>
19
- </ul>
20
15
  </div>
21
16
  <div class="fx-auth__form">
22
17
  <p class="fx-auth__note" id="sign-in-note"></p>
@@ -40,20 +35,20 @@ const BODY = `<div class="fx-app">
40
35
  </section>
41
36
 
42
37
  <div class="fx-state-signed-in fx-shell">
43
- <header class="fx-top">
38
+ <aside class="fx-side">
44
39
  <a class="fx-brand" href="/">${FLOW_MARK}<span>Flow</span></a>
45
40
  <nav class="fx-nav" id="nav" aria-label="Flow"></nav>
46
- <div class="fx-top__end">
41
+ <div class="fx-side__end">
42
+ <div id="user-button" class="fx-side__user"></div>
47
43
  <button type="button" class="fx-btn fx-btn--ghost fx-btn--icon fx-mode-toggle" data-mode-toggle>Light or dark</button>
48
- <div id="user-button" class="fx-top__user"></div>
49
44
  </div>
50
- </header>
45
+ </aside>
51
46
  <main id="main" class="fx-main" tabindex="-1"></main>
52
47
  </div>
53
48
  <div id="toasts" class="fx-toasts" aria-live="polite"></div>
54
49
  </div>`;
55
50
 
56
- export const APP_PATHS = ['/sign-in', '/people', '/integrations', '/account', '/appearance'];
51
+ export const APP_PATHS = ['/sign-in', '/jobs', '/invoices', '/invoices/:id', '/people', '/integrations', '/integrations/:id', '/account', '/appearance'];
57
52
 
58
53
  export function appPage({ defaultFamily }) {
59
54
  return renderPage({