@arcforgelabs/flow 2026.10.21 → 2026.10.23

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 (35) hide show
  1. package/README.md +2 -1
  2. package/VISION.md +40 -9
  3. package/npm-shrinkwrap.json +2 -414
  4. package/package.json +6 -1
  5. package/server/app.js +7 -0
  6. package/server/core/links.js +166 -0
  7. package/server/core/outbox.js +129 -0
  8. package/server/core/queue.js +142 -0
  9. package/server/core/review.js +92 -0
  10. package/server/core/secrets.js +74 -0
  11. package/server/db/migrations/0005_data_spine.sql +99 -0
  12. package/server/db/migrations/0006_prior_links.sql +18 -0
  13. package/server/db/migrations/0007_integration_settings.sql +23 -0
  14. package/server/db/migrations/meta/0005_snapshot.json +2145 -0
  15. package/server/db/migrations/meta/0006_snapshot.json +2306 -0
  16. package/server/db/migrations/meta/0007_snapshot.json +2473 -0
  17. package/server/db/migrations/meta/_journal.json +21 -0
  18. package/server/db/schema.js +187 -1
  19. package/server/import/at-start.js +69 -1
  20. package/server/index.js +28 -8
  21. package/server/integrations/index.js +9 -3
  22. package/server/integrations/port.js +101 -9
  23. package/server/integrations/seazona/index.js +25 -3
  24. package/server/integrations/setup.js +234 -0
  25. package/server/integrations/xero/client.js +13 -0
  26. package/server/integrations/xero/import.js +116 -0
  27. package/server/integrations/xero/index.js +45 -5
  28. package/server/integrations/xero/migrations/0004_xero_mirror.sql +96 -0
  29. package/server/integrations/xero/migrations/meta/_journal.json +7 -0
  30. package/server/integrations/xero/mirror.js +295 -0
  31. package/server/integrations/xero/routes.js +26 -1
  32. package/server/integrations/xero/settings.js +8 -0
  33. package/server/ui/app-page.js +1 -1
  34. package/server/ui/app.js +142 -0
  35. package/server/ui/routes.js +9 -2
@@ -0,0 +1,295 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { sql } from 'drizzle-orm';
3
+ import { parseXeroDate } from './catalogue.js';
4
+ import { withXeroLane } from './governor.js';
5
+
6
+ // Flow's copy of the connected organisation's Xero invoices, credit notes and
7
+ // payments (docs/DATA_SPINE.md, issue 54). Xero owns these records. Flow keeps
8
+ // them so its reports cover the years before it was installed, and so the
9
+ // ledger and the identity map can link them without spending Xero calls.
10
+ //
11
+ // Each resource keeps a cursor. A read asks Xero for the records changed since
12
+ // the cursor, oldest change first, one page at a time, and stores each page in
13
+ // one transaction with the cursor's next position. So a read can stop at any
14
+ // page (the day's call budget, an error, a restart) and the next read goes on
15
+ // from there. The first read has no cursor and copies the whole history; every
16
+ // later read copies only changes.
17
+ //
18
+ // Paging is by time, not by page number: after a full page the cursor moves to
19
+ // the last record's change time (less one second), so a record that changes
20
+ // during a read moves ahead of the cursor and is read again, never skipped.
21
+ // Only when one second holds a whole page does the page number move on.
22
+
23
+ export const MIRROR_RESOURCES = Object.freeze(['invoices', 'credit_notes', 'payments']);
24
+
25
+ // A Xero amount (a JSON number with up to 4 decimals) to whole cents, half away
26
+ // from zero, without float drift.
27
+ export function toCents(amount) {
28
+ if (amount === null || amount === undefined || amount === '') return null;
29
+ const match = /^(-?)(\d+)(?:\.(\d+))?$/.exec(String(amount).trim());
30
+ if (!match) return null;
31
+ const [, sign, whole, fraction = ''] = match;
32
+ const digits = `${fraction}000`;
33
+ let cents = Number(whole) * 100 + Number(digits.slice(0, 2));
34
+ if (Number(digits[2]) >= 5) cents += 1;
35
+ return sign ? -cents : cents;
36
+ }
37
+
38
+ // A Xero date to a calendar date (YYYY-MM-DD), as Xero shows it.
39
+ function toDay(value) {
40
+ const at = parseXeroDate(value);
41
+ return at ? at.toISOString().slice(0, 10) : null;
42
+ }
43
+
44
+ const text = (value, keep) => (keep && value ? String(value) : null);
45
+
46
+ function mapLines(lines, keep) {
47
+ return (lines ?? []).map((line) => ({
48
+ lineItemId: line.LineItemID ?? null,
49
+ itemCode: line.ItemCode || null,
50
+ description: text(line.Description, keep),
51
+ quantity: line.Quantity ?? null,
52
+ unitAmount: line.UnitAmount ?? null,
53
+ lineAmountCents: toCents(line.LineAmount),
54
+ taxAmountCents: toCents(line.TaxAmount),
55
+ discountRate: line.DiscountRate ?? null,
56
+ accountCode: line.AccountCode || null,
57
+ taxType: line.TaxType || null,
58
+ }));
59
+ }
60
+
61
+ export function mapInvoice(invoice, { keepReference = false } = {}) {
62
+ return {
63
+ xeroInvoiceId: invoice.InvoiceID,
64
+ type: invoice.Type,
65
+ invoiceNumber: invoice.InvoiceNumber || null,
66
+ reference: text(invoice.Reference, keepReference),
67
+ xeroContactId: invoice.Contact?.ContactID ?? null,
68
+ status: invoice.Status,
69
+ issuedOn: toDay(invoice.Date ?? invoice.DateString),
70
+ dueOn: toDay(invoice.DueDate ?? invoice.DueDateString),
71
+ fullyPaidOn: toDay(invoice.FullyPaidOnDate),
72
+ currency: invoice.CurrencyCode || null,
73
+ subtotalCents: toCents(invoice.SubTotal),
74
+ taxCents: toCents(invoice.TotalTax),
75
+ totalCents: toCents(invoice.Total),
76
+ amountDueCents: toCents(invoice.AmountDue),
77
+ amountPaidCents: toCents(invoice.AmountPaid),
78
+ amountCreditedCents: toCents(invoice.AmountCredited),
79
+ lines: mapLines(invoice.LineItems, keepReference),
80
+ xeroUpdatedAt: parseXeroDate(invoice.UpdatedDateUTC),
81
+ };
82
+ }
83
+
84
+ export function mapCreditNote(note, { keepReference = false } = {}) {
85
+ return {
86
+ xeroCreditNoteId: note.CreditNoteID,
87
+ type: note.Type,
88
+ creditNoteNumber: note.CreditNoteNumber || null,
89
+ reference: text(note.Reference, keepReference),
90
+ xeroContactId: note.Contact?.ContactID ?? null,
91
+ status: note.Status,
92
+ issuedOn: toDay(note.Date ?? note.DateString),
93
+ currency: note.CurrencyCode || null,
94
+ subtotalCents: toCents(note.SubTotal),
95
+ taxCents: toCents(note.TotalTax),
96
+ totalCents: toCents(note.Total),
97
+ remainingCreditCents: toCents(note.RemainingCredit),
98
+ allocations: (note.Allocations ?? []).map((a) => ({
99
+ xeroInvoiceId: a.Invoice?.InvoiceID ?? null,
100
+ amountCents: toCents(a.Amount),
101
+ allocatedOn: toDay(a.Date),
102
+ })),
103
+ lines: mapLines(note.LineItems, keepReference),
104
+ xeroUpdatedAt: parseXeroDate(note.UpdatedDateUTC),
105
+ };
106
+ }
107
+
108
+ export function mapPayment(payment, { keepReference = false } = {}) {
109
+ return {
110
+ xeroPaymentId: payment.PaymentID,
111
+ paymentType: payment.PaymentType || null,
112
+ status: payment.Status || 'AUTHORISED',
113
+ xeroInvoiceId: payment.Invoice?.InvoiceID ?? null,
114
+ xeroCreditNoteId: payment.CreditNote?.CreditNoteID ?? null,
115
+ paidOn: toDay(payment.Date),
116
+ amountCents: toCents(payment.Amount),
117
+ reference: text(payment.Reference, keepReference),
118
+ isReconciled: payment.IsReconciled ?? null,
119
+ xeroUpdatedAt: parseXeroDate(payment.UpdatedDateUTC),
120
+ };
121
+ }
122
+
123
+ // The hash covers what Flow keeps, so a change Flow does not keep (a field it
124
+ // drops) never counts as a change.
125
+ function hashOf(row) {
126
+ const { xeroUpdatedAt, ...content } = row;
127
+ return createHash('sha256').update(JSON.stringify(content)).digest('hex');
128
+ }
129
+
130
+ const json = (value) => JSON.stringify(value);
131
+
132
+ // Each upsert writes only when the content changed, and answers whether it did.
133
+ const UPSERT = {
134
+ invoices: (org, r, hash) => sql`
135
+ insert into xero_invoices (organisation_id, xero_invoice_id, type, invoice_number, reference, xero_contact_id, status,
136
+ issued_on, due_on, fully_paid_on, currency, subtotal_cents, tax_cents, total_cents, amount_due_cents,
137
+ amount_paid_cents, amount_credited_cents, lines, xero_updated_at, record_hash)
138
+ values (${org}, ${r.xeroInvoiceId}, ${r.type}, ${r.invoiceNumber}, ${r.reference}, ${r.xeroContactId}, ${r.status},
139
+ ${r.issuedOn}, ${r.dueOn}, ${r.fullyPaidOn}, ${r.currency}, ${r.subtotalCents}, ${r.taxCents}, ${r.totalCents},
140
+ ${r.amountDueCents}, ${r.amountPaidCents}, ${r.amountCreditedCents}, ${json(r.lines)}::jsonb, ${r.xeroUpdatedAt}, ${hash})
141
+ on conflict (organisation_id, xero_invoice_id) do update set
142
+ type = excluded.type, invoice_number = excluded.invoice_number, reference = excluded.reference,
143
+ xero_contact_id = excluded.xero_contact_id, status = excluded.status, issued_on = excluded.issued_on,
144
+ due_on = excluded.due_on, fully_paid_on = excluded.fully_paid_on, currency = excluded.currency,
145
+ subtotal_cents = excluded.subtotal_cents, tax_cents = excluded.tax_cents, total_cents = excluded.total_cents,
146
+ amount_due_cents = excluded.amount_due_cents, amount_paid_cents = excluded.amount_paid_cents,
147
+ amount_credited_cents = excluded.amount_credited_cents, lines = excluded.lines,
148
+ xero_updated_at = excluded.xero_updated_at, record_hash = excluded.record_hash, last_changed_at = now()
149
+ where xero_invoices.record_hash is distinct from excluded.record_hash
150
+ returning (xmax = 0) as inserted`,
151
+ credit_notes: (org, r, hash) => sql`
152
+ insert into xero_credit_notes (organisation_id, xero_credit_note_id, type, credit_note_number, reference, xero_contact_id,
153
+ status, issued_on, currency, subtotal_cents, tax_cents, total_cents, remaining_credit_cents, allocations, lines,
154
+ xero_updated_at, record_hash)
155
+ values (${org}, ${r.xeroCreditNoteId}, ${r.type}, ${r.creditNoteNumber}, ${r.reference}, ${r.xeroContactId}, ${r.status},
156
+ ${r.issuedOn}, ${r.currency}, ${r.subtotalCents}, ${r.taxCents}, ${r.totalCents}, ${r.remainingCreditCents},
157
+ ${json(r.allocations)}::jsonb, ${json(r.lines)}::jsonb, ${r.xeroUpdatedAt}, ${hash})
158
+ on conflict (organisation_id, xero_credit_note_id) do update set
159
+ type = excluded.type, credit_note_number = excluded.credit_note_number, reference = excluded.reference,
160
+ xero_contact_id = excluded.xero_contact_id, status = excluded.status, issued_on = excluded.issued_on,
161
+ currency = excluded.currency, subtotal_cents = excluded.subtotal_cents, tax_cents = excluded.tax_cents,
162
+ total_cents = excluded.total_cents, remaining_credit_cents = excluded.remaining_credit_cents,
163
+ allocations = excluded.allocations, lines = excluded.lines, xero_updated_at = excluded.xero_updated_at,
164
+ record_hash = excluded.record_hash, last_changed_at = now()
165
+ where xero_credit_notes.record_hash is distinct from excluded.record_hash
166
+ returning (xmax = 0) as inserted`,
167
+ payments: (org, r, hash) => sql`
168
+ insert into xero_payments (organisation_id, xero_payment_id, payment_type, status, xero_invoice_id, xero_credit_note_id,
169
+ paid_on, amount_cents, reference, is_reconciled, xero_updated_at, record_hash)
170
+ values (${org}, ${r.xeroPaymentId}, ${r.paymentType}, ${r.status}, ${r.xeroInvoiceId}, ${r.xeroCreditNoteId},
171
+ ${r.paidOn}, ${r.amountCents}, ${r.reference}, ${r.isReconciled}, ${r.xeroUpdatedAt}, ${hash})
172
+ on conflict (organisation_id, xero_payment_id) do update set
173
+ payment_type = excluded.payment_type, status = excluded.status, xero_invoice_id = excluded.xero_invoice_id,
174
+ xero_credit_note_id = excluded.xero_credit_note_id, paid_on = excluded.paid_on, amount_cents = excluded.amount_cents,
175
+ reference = excluded.reference, is_reconciled = excluded.is_reconciled, xero_updated_at = excluded.xero_updated_at,
176
+ record_hash = excluded.record_hash, last_changed_at = now()
177
+ where xero_payments.record_hash is distinct from excluded.record_hash
178
+ returning (xmax = 0) as inserted`,
179
+ };
180
+
181
+ const MAPPERS = { invoices: mapInvoice, credit_notes: mapCreditNote, payments: mapPayment };
182
+ const ID_OF = { invoices: 'xeroInvoiceId', credit_notes: 'xeroCreditNoteId', payments: 'xeroPaymentId' };
183
+
184
+ async function readCursor(db, organisationId, resource) {
185
+ await db.execute(sql`insert into xero_mirror_cursors (organisation_id, resource) values (${organisationId}, ${resource})
186
+ on conflict do nothing`);
187
+ const { rows: [row] } = await db.execute(sql`select * from xero_mirror_cursors
188
+ where organisation_id = ${organisationId} and resource = ${resource}`);
189
+ return { modifiedSince: row.modified_since ? new Date(row.modified_since) : null, page: row.page };
190
+ }
191
+
192
+ // Where the next page starts, from the page just read. A full page moves the
193
+ // time cursor to its last change, less one second (Xero compares whole
194
+ // seconds); when that does not move it forward, one second holds more than a
195
+ // page, and the page number moves on instead.
196
+ export function nextPosition({ modifiedSince, page }, rows, pageSize) {
197
+ const times = rows.map((r) => r.xeroUpdatedAt?.getTime()).filter(Number.isFinite);
198
+ const latest = times.length ? Math.max(...times) : null;
199
+ const full = rows.length >= pageSize;
200
+ if (!full) {
201
+ const since = latest === null ? modifiedSince : new Date(Math.floor(latest / 1000) * 1000 - 1000);
202
+ return { modifiedSince: since && modifiedSince && since < modifiedSince ? modifiedSince : since, page: 1, done: true };
203
+ }
204
+ const since = latest === null ? null : new Date(Math.floor(latest / 1000) * 1000 - 1000);
205
+ if (since && (!modifiedSince || since > modifiedSince)) return { modifiedSince: since, page: 1, done: false };
206
+ return { modifiedSince, page: page + 1, done: false };
207
+ }
208
+
209
+ // Reads one resource from its cursor until Xero has no more changes, the
210
+ // mirror's share of the day's calls is spent, or `maxPages` pages are read.
211
+ // Answers counts and why it stopped: `done`, `budget` or `max_pages`.
212
+ export async function mirrorResource(db, runtime, organisationId, resource, { maxPages = 50 } = {}) {
213
+ if (!MIRROR_RESOURCES.includes(resource)) throw new TypeError(`unknown Xero mirror resource: ${resource}`);
214
+ const { settings } = runtime;
215
+ const map = MAPPERS[resource];
216
+ const pageSize = settings.mirrorPageSize;
217
+ const counts = { pages: 0, read: 0, inserted: 0, changed: 0, unchanged: 0 };
218
+ return withXeroLane('sync', async () => {
219
+ const { client } = await runtime.clientFor(db, organisationId);
220
+ let position = await readCursor(db, organisationId, resource);
221
+ for (;;) {
222
+ if (counts.pages >= maxPages) return { resource, ...counts, stopped: 'max_pages' };
223
+ const budget = runtime.governor.status(client.tenantId);
224
+ if (budget.dayRemaining <= Math.ceil(budget.dayLimit * settings.mirrorReserveFraction)) {
225
+ return { resource, ...counts, stopped: 'budget' };
226
+ }
227
+ let raw;
228
+ try {
229
+ raw = await client.listChangedPage(resource, { page: position.page, pageSize, modifiedSince: position.modifiedSince });
230
+ } catch (error) {
231
+ await db.execute(sql`update xero_mirror_cursors set last_error = ${`${error.code ?? 'error'}: ${String(error.message).slice(0, 300)}`}
232
+ where organisation_id = ${organisationId} and resource = ${resource}`);
233
+ throw error;
234
+ }
235
+ const rows = raw.map((record) => map(record, settings)).filter((r) => r[ID_OF[resource]]);
236
+ const next = nextPosition(position, rows, pageSize);
237
+ await db.transaction(async (tx) => {
238
+ for (const row of rows) {
239
+ const { rows: written } = await tx.execute(UPSERT[resource](organisationId, row, hashOf(row)));
240
+ if (!written.length) counts.unchanged += 1;
241
+ else if (written[0].inserted) counts.inserted += 1;
242
+ else counts.changed += 1;
243
+ }
244
+ await tx.execute(sql`update xero_mirror_cursors set
245
+ modified_since = ${next.modifiedSince}, page = ${next.page}, rows_read = rows_read + ${raw.length},
246
+ last_read_at = now(), last_error = null,
247
+ passes = passes + ${next.done ? 1 : 0},
248
+ last_pass_finished_at = case when ${next.done} then now() else last_pass_finished_at end
249
+ where organisation_id = ${organisationId} and resource = ${resource}`);
250
+ });
251
+ counts.pages += 1;
252
+ counts.read += raw.length;
253
+ position = next;
254
+ if (next.done) return { resource, ...counts, stopped: 'done' };
255
+ }
256
+ });
257
+ }
258
+
259
+ // Reads each resource in turn, one organisation at a time with any other Xero
260
+ // work for it (runtime.serial).
261
+ export async function mirrorXero(db, runtime, organisationId, { resources = MIRROR_RESOURCES, maxPages } = {}) {
262
+ return runtime.serial(organisationId, async () => {
263
+ const results = [];
264
+ for (const resource of resources) {
265
+ const result = await mirrorResource(db, runtime, organisationId, resource, { maxPages });
266
+ results.push(result);
267
+ if (result.stopped === 'budget') break;
268
+ }
269
+ return results;
270
+ });
271
+ }
272
+
273
+ // What the copy holds, and where each cursor stands. No Xero calls.
274
+ export async function mirrorStatus(db, organisationId) {
275
+ const { rows: cursors } = await db.execute(sql`select resource, modified_since, page, passes, rows_read,
276
+ last_read_at, last_pass_finished_at, last_error from xero_mirror_cursors where organisation_id = ${organisationId}
277
+ order by resource`);
278
+ const { rows: [counts] } = await db.execute(sql`select
279
+ (select count(*)::int from xero_invoices where organisation_id = ${organisationId}) as invoices,
280
+ (select count(*)::int from xero_credit_notes where organisation_id = ${organisationId}) as credit_notes,
281
+ (select count(*)::int from xero_payments where organisation_id = ${organisationId}) as payments`);
282
+ return {
283
+ counts,
284
+ cursors: cursors.map((c) => ({
285
+ resource: c.resource,
286
+ modifiedSince: c.modified_since,
287
+ page: c.page,
288
+ passes: c.passes,
289
+ rowsRead: Number(c.rows_read),
290
+ lastReadAt: c.last_read_at,
291
+ lastPassFinishedAt: c.last_pass_finished_at,
292
+ lastError: c.last_error,
293
+ })),
294
+ };
295
+ }
@@ -6,6 +6,8 @@ import { lastSync, listContacts, listItems, syncCatalogue } from './catalogue.js
6
6
  import { createXeroClient } from './client.js';
7
7
  import { describePush, listPushes, MAX_BATCH, previewDrafts, pushDrafts } from './drafts.js';
8
8
  import { recordXeroEvidence, recordXeroPayments } from './ledger.js';
9
+ import { queueHistoryImport, resetImport } from './import.js';
10
+ import { MIRROR_RESOURCES, mirrorStatus, mirrorXero } from './mirror.js';
9
11
  import { decryptToken, deleteConnection, describeConnection, readConnection, saveConnection } from './connections.js';
10
12
  import { authorizeUrl, exchangeCode, fetchConnections, revokeToken, selectConnection } from './oauth.js';
11
13
  import { writesAllowed } from './settings.js';
@@ -28,6 +30,9 @@ import { messagePage as themedMessagePage } from '../../ui/page.js';
28
30
  // POST /obligations/:obligationId/check owner or admin: Xero's invoice for
29
31
  // a billable job, recorded on the ledger
30
32
  // POST /payments/sync owner or admin: Xero payments to the ledger
33
+ // POST /mirror owner or admin: copies Xero invoices, credit
34
+ // notes and payments changed since the last copy
35
+ // GET /mirror?organisationId= any signed-in person: what the copy holds
31
36
  //
32
37
  // Only an owner, with a verified owner sign-in, can connect or disconnect.
33
38
  // The callback carries no Flow session: the one-use state from /connect,
@@ -138,6 +143,7 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, messa
138
143
  await revokeToken(settings, runtime.transport, tokens.refresh_token).catch(() => false);
139
144
  throw error;
140
145
  }
146
+ const previous = await readConnection(db, entry.organisationId);
141
147
  const connection = await saveConnection(db, {
142
148
  organisationId: entry.organisationId,
143
149
  mode: settings.mode,
@@ -149,8 +155,11 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, messa
149
155
  key: settings.tokenKey,
150
156
  });
151
157
  runtime.remember(entry.organisationId, tokens);
158
+ // Another Xero organisation starts its history from the beginning.
159
+ if (previous && previous.tenantId !== connection.tenantId) await resetImport(db, entry.organisationId);
160
+ await queueHistoryImport(db, entry.organisationId, { reason: 'connected' });
152
161
  log.info?.(`[xero] organisation ${entry.organisationId} connected to Xero tenant ${connection.tenantId} (${settings.mode}) by person ${entry.personId}`);
153
- return res.type('html').send(page('Xero is connected', `Flow is connected to ${connection.tenantName || 'your Xero organisation'} (${settings.mode}). You can close this tab.`, 'success'));
162
+ return res.type('html').send(page('Xero is connected', `Flow is connected to ${connection.tenantName || 'your Xero organisation'} (${settings.mode}). Flow is now copying its invoice and payment history in the background. You can close this tab.`, 'success'));
154
163
  } catch (error) {
155
164
  log.error?.(`[xero] connect failed: ${error.code ?? 'error'}: ${error.message}`);
156
165
  const message = error instanceof FlowError ? error.message : 'Something went wrong talking to Xero. Try again.';
@@ -227,5 +236,21 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, messa
227
236
  res.json({ ok: true, ...result });
228
237
  }));
229
238
 
239
+ router.post('/mirror', ...manager, handle(log, async (req, res) => {
240
+ const organisationId = await requireOrganisation(db, req.body?.organisationId);
241
+ const resources = req.body?.resources ?? MIRROR_RESOURCES;
242
+ if (!Array.isArray(resources) || !resources.length || !resources.every((r) => MIRROR_RESOURCES.includes(r))) {
243
+ throw new FlowError('invalid', `resources must list some of: ${MIRROR_RESOURCES.join(', ')}`);
244
+ }
245
+ const results = await mirrorXero(db, runtime, organisationId, { resources: [...new Set(resources)] });
246
+ log.info?.(`[xero] organisation ${organisationId}: person ${req.person.id} copied Xero records: ${JSON.stringify(results)}`);
247
+ res.json({ ok: true, results, ...(await mirrorStatus(db, organisationId)) });
248
+ }));
249
+
250
+ router.get('/mirror', ...signedIn, handle(log, async (req, res) => {
251
+ const organisationId = await requireOrganisation(db, req.query.organisationId);
252
+ res.json({ ok: true, ...(await mirrorStatus(db, organisationId)) });
253
+ }));
254
+
230
255
  return router;
231
256
  }
@@ -28,6 +28,8 @@ export const XERO_CALLBACK_PATH = '/api/xero/oauth/callback';
28
28
  export const DEFAULT_XERO_SCOPE = 'offline_access accounting.contacts accounting.invoices accounting.settings.read';
29
29
  export const DEFAULT_XERO_DAY_LIMIT = 1000;
30
30
  export const DEFAULT_DUE_DAYS = 30;
31
+ export const DEFAULT_MIRROR_PAGE_SIZE = 200;
32
+ export const MAX_MIRROR_PAGE_SIZE = 1000;
31
33
 
32
34
  function readEnv(env, name) {
33
35
  const value = env?.[name];
@@ -159,6 +161,12 @@ export function resolveXeroSettings(env = process.env) {
159
161
  defaultDueDays: readPositiveInteger(env, 'XERO_DEFAULT_DUE_DAYS', DEFAULT_DUE_DAYS),
160
162
  invoiceNumber,
161
163
  replayFixtures: readEnv(env, 'XERO_REPLAY_FIXTURES') || null,
164
+ // Free text on Xero records (references, line descriptions) can name a
165
+ // patient. Flow keeps it only when the lab turns this on (VISION.md).
166
+ keepReference: readOnOff(env, 'XERO_KEEP_REFERENCE'),
167
+ mirrorPageSize: Math.min(readPositiveInteger(env, 'XERO_MIRROR_PAGE_SIZE', DEFAULT_MIRROR_PAGE_SIZE), MAX_MIRROR_PAGE_SIZE),
168
+ // The share of the day's calls the mirror leaves for drafts and people.
169
+ mirrorReserveFraction: readFraction(env, 'XERO_MIRROR_RESERVE_FRACTION', 0.3),
162
170
  endpoints: XERO_ENDPOINTS,
163
171
  };
164
172
  }
@@ -53,7 +53,7 @@ const BODY = `<div class="fx-app">
53
53
  <div id="toasts" class="fx-toasts" aria-live="polite"></div>
54
54
  </div>`;
55
55
 
56
- export const APP_PATHS = ['/sign-in', '/people', '/account', '/appearance'];
56
+ export const APP_PATHS = ['/sign-in', '/people', '/integrations', '/account', '/appearance'];
57
57
 
58
58
  export function appPage({ defaultFamily }) {
59
59
  return renderPage({
package/server/ui/app.js CHANGED
@@ -182,6 +182,7 @@ let me = null;
182
182
 
183
183
  const VIEWS = {
184
184
  people: { label: 'People', ownerOnly: true, render: renderPeople },
185
+ integrations: { label: 'Integrations', ownerOnly: true, render: renderIntegrations },
185
186
  account: { label: 'Account', render: renderAccount },
186
187
  appearance: { label: 'Appearance', render: renderAppearance },
187
188
  };
@@ -347,6 +348,147 @@ function personRow(person, host) {
347
348
  h('td', { class: 'fx-table__actions' }, h('div', { class: 'fx-row fx-row--end' }, actions)));
348
349
  }
349
350
 
351
+ // Integrations: an owner turns Xero and Seazona on and enters their settings
352
+ // here. Credentials are write-only: the server says whether each is set,
353
+ // never its value (server/integrations/setup.js).
354
+ const STATE_LABEL = { ready: 'On', disabled: 'Off', failed: 'Needs attention', restart_needed: 'Restart needed', not_started: 'Starting' };
355
+ const STATE_TONE = { ready: 'success', disabled: 'info', failed: 'danger', restart_needed: 'warn', not_started: 'info' };
356
+
357
+ async function renderIntegrations(main) {
358
+ const host = h('div', { class: 'fx-fields' }, h('div', { class: 'fx-skeleton' }));
359
+ main.append(
360
+ pageHeader('Integrations', { subtitle: 'Connect Flow to your accounting and lab systems. Each one uses an app you create in that system.' }),
361
+ host);
362
+ let body;
363
+ try {
364
+ body = await flow('/api/integrations');
365
+ } catch (error) {
366
+ host.replaceChildren(notice(error.message, 'danger'));
367
+ return;
368
+ }
369
+ const canChange = Boolean(me.ownerActions?.allowed);
370
+ host.replaceChildren(...[
371
+ canChange ? null : notice('Changing integrations needs a verified owner sign-in: Google Workspace on the owner domain with no password, or a second factor.', 'warn', 'Read only.'),
372
+ body.secretsKey ? null : notice('Flow has no secrets key, so it cannot store a client secret. Ask whoever hosts Flow to set FLOW_SECRETS_KEY (64 hex characters), then restart Flow.', 'warn', 'No secrets key.'),
373
+ ...body.integrations.map((integration) => integrationCard(integration, { canChange, organisations: body.organisations })),
374
+ ].filter(Boolean));
375
+ }
376
+
377
+ function integrationCard(integration, { canChange, organisations }) {
378
+ const card = h('section', { class: 'fx-panel', 'aria-label': integration.name });
379
+ const draw = (current) => {
380
+ const inputs = new Map();
381
+ const fields = current.fields.map((field) => {
382
+ const fromHost = field.source === 'host';
383
+ const disabled = !canChange || fromHost;
384
+ let input;
385
+ if (field.kind === 'choice' || field.kind === 'onoff') {
386
+ const choices = field.kind === 'onoff' ? [{ value: 'off', label: 'Off' }, { value: 'on', label: 'On' }] : field.choices;
387
+ input = h('select', { class: 'fx-select', disabled },
388
+ field.kind === 'choice' ? h('option', { value: '' }, 'Choose…') : null,
389
+ choices.map((c) => h('option', { value: c.value, selected: (field.value ?? (field.kind === 'onoff' ? 'off' : '')) === c.value }, c.label)));
390
+ } else if (field.secret) {
391
+ input = h('input', { class: 'fx-input', type: 'password', autocomplete: 'off', disabled, placeholder: field.set ? 'Set. Type a new one to replace it.' : 'Paste it here' });
392
+ } else {
393
+ input = h('input', { class: 'fx-input', type: field.kind === 'url' ? 'url' : 'text', autocomplete: 'off', disabled, value: field.value ?? '' });
394
+ }
395
+ inputs.set(field.name, { field, input });
396
+ const suggestion = field.suggest === 'callback' ? `${location.origin}/api/${current.id}/oauth/callback` : null;
397
+ return h('label', { class: 'fx-field' },
398
+ h('span', { class: 'fx-label' }, field.label),
399
+ input,
400
+ fromHost ? h('span', { class: 'fx-dim' }, 'Set by the host. Change it there.') : null,
401
+ field.unreadable ? h('span', { class: 'fx-dim' }, 'Stored with another key. Enter it again.') : null,
402
+ field.help ? h('span', { class: 'fx-dim' }, field.help) : null,
403
+ suggestion && !disabled ? h('div', { class: 'fx-row' },
404
+ h('code', {}, suggestion),
405
+ h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button', onclick: () => { navigator.clipboard?.writeText(suggestion); toast('Copied.', 'success'); } }, 'Copy'),
406
+ h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button', onclick: () => { input.value = suggestion; } }, 'Use this')) : null);
407
+ });
408
+
409
+ const values = () => {
410
+ const out = {};
411
+ for (const [name, { field, input }] of inputs) {
412
+ if (field.source === 'host') continue;
413
+ const value = input.value.trim();
414
+ if (field.secret) { if (value) out[name] = value; continue; }
415
+ if (value !== (field.value ?? (field.kind === 'onoff' ? 'off' : ''))) out[name] = value || null;
416
+ }
417
+ return out;
418
+ };
419
+ const save = async (enabled) => {
420
+ try {
421
+ const res = await flow(`/api/integrations/${current.id}`, { method: 'PUT', body: JSON.stringify({ values: values(), ...(enabled === undefined ? {} : { enabled }) }) });
422
+ const next = res.integration;
423
+ toast(next.state === 'ready' ? `${next.name} is on.` : next.state === 'disabled' ? `${next.name} settings saved. It is off.` : `${next.name} needs attention.`, next.state === 'failed' ? 'danger' : 'success');
424
+ draw(next);
425
+ } catch (error) {
426
+ toast(error.message, 'danger');
427
+ }
428
+ };
429
+
430
+ const on = current.enabled;
431
+ const guide = current.guide;
432
+ card.replaceChildren(...[
433
+ h('div', { class: 'fx-row' },
434
+ h('h2', { class: 'fx-title' }, current.name),
435
+ h('span', { class: 'fx-tag', 'data-tone': STATE_TONE[current.state] ?? 'info' }, STATE_LABEL[current.state] ?? current.state),
436
+ current.enabledBy === 'host' ? h('span', { class: 'fx-dim' }, 'Turned on by the host.') : null),
437
+ current.state === 'failed' && current.error ? notice(current.error, 'danger', 'It could not start.') : null,
438
+ guide ? h('details', { open: !on },
439
+ h('summary', {}, guide.title),
440
+ h('ol', {}, guide.steps.map((step) => h('li', {}, step))),
441
+ guide.link ? h('p', {}, h('a', { href: guide.link, target: '_blank', rel: 'noopener' }, guide.link)) : null) : null,
442
+ h('div', { class: 'fx-fields' }, fields),
443
+ canChange ? h('div', { class: 'fx-row fx-row--end' },
444
+ h('button', { class: 'fx-btn fx-btn--ghost', type: 'button', onclick: () => save(undefined) }, 'Save'),
445
+ current.enabledBy === 'host' ? null : on
446
+ ? h('button', { class: 'fx-btn fx-btn--danger', type: 'button', onclick: () => save(false) }, `Turn ${current.name} off`)
447
+ : h('button', { class: 'fx-btn fx-btn--primary', type: 'button', onclick: () => save(true) }, `Save and turn ${current.name} on`)) : null,
448
+ current.id === 'xero' && current.state === 'ready' ? xeroConnection(organisations, canChange) : null,
449
+ ].filter(Boolean));
450
+ };
451
+ draw(integration);
452
+ return card;
453
+ }
454
+
455
+ // Once Xero is on: each organisation's connection, the history copy, and
456
+ // Connect, which goes to Xero and comes back.
457
+ function xeroConnection(organisations, canChange) {
458
+ const host = h('div', { class: 'fx-fields' });
459
+ for (const organisation of organisations) {
460
+ const row = h('div', { class: 'fx-row' }, h('span', {}, organisation.name), h('span', { class: 'fx-dim' }, 'Checking…'));
461
+ host.append(row);
462
+ (async () => {
463
+ try {
464
+ const status = await flow(`/api/xero/status?organisationId=${organisation.id}`);
465
+ const parts = [h('span', {}, organisation.name)];
466
+ if (status.connected) {
467
+ parts.push(h('span', { class: 'fx-tag', 'data-tone': 'success' }, `Connected to ${status.connection?.tenantName ?? 'Xero'}`));
468
+ const mirror = await flow(`/api/xero/mirror?organisationId=${organisation.id}`).catch(() => null);
469
+ if (mirror) parts.push(h('span', { class: 'fx-dim' }, `History copied: ${mirror.counts.invoices} invoices, ${mirror.counts.credit_notes} credit notes, ${mirror.counts.payments} payments.`));
470
+ } else {
471
+ parts.push(h('span', { class: 'fx-dim' }, 'Not connected.'));
472
+ }
473
+ if (canChange) {
474
+ parts.push(h('button', { class: 'fx-btn fx-btn--primary fx-btn--sm', type: 'button', onclick: async () => {
475
+ try {
476
+ const { authorizeUrl } = await flow('/api/xero/connect', { method: 'POST', body: JSON.stringify({ organisationId: organisation.id }) });
477
+ location.assign(authorizeUrl);
478
+ } catch (error) {
479
+ toast(error.message, 'danger');
480
+ }
481
+ } }, status.connected ? 'Connect again' : 'Connect to Xero'));
482
+ }
483
+ row.replaceChildren(...parts);
484
+ } catch (error) {
485
+ row.replaceChildren(h('span', {}, organisation.name), notice(error.message, 'danger'));
486
+ }
487
+ })();
488
+ }
489
+ return h('div', {}, h('h3', { class: 'fx-label' }, 'Connection'), host);
490
+ }
491
+
350
492
  async function invitePerson() {
351
493
  const data = await dialog({
352
494
  title: 'Invite someone',
@@ -21,6 +21,13 @@ const THEME_FILES = new Set(['themes.js', 'color-contrast.js', 'openclaw-palette
21
21
  const UI_FILES = new Set(['flow.css', 'appearance.js', 'clerk-appearance.js', 'app.js', 'flow-mark.svg']);
22
22
  const MAX_AGE = '1h';
23
23
 
24
+ // Send one named file from dir. Passing dir as root limits the dotfile check
25
+ // to the file name, so an install under a dot directory (such as
26
+ // .flow-deploy/) still serves its assets. A missing file falls through to the
27
+ // app's own 404.
28
+ const sendNamed = (res, next, dir, file) =>
29
+ res.sendFile(file, { root: dir, maxAge: MAX_AGE }, (err) => err && !res.headersSent && next());
30
+
24
31
  export function createUiAssetsRouter({ rateLimitPerMinute = 600 } = {}) {
25
32
  const router = express.Router();
26
33
  const css = themeStylesheet();
@@ -33,14 +40,14 @@ export function createUiAssetsRouter({ rateLimitPerMinute = 600 } = {}) {
33
40
 
34
41
  const sendFrom = (dir, allowed) => (req, res, next) => {
35
42
  if (!allowed.has(req.params.file)) return next();
36
- res.sendFile(join(dir, req.params.file), { maxAge: MAX_AGE });
43
+ sendNamed(res, next, dir, req.params.file);
37
44
  };
38
45
  router.get('/assets/theme/:file', sendFrom(themeDir, THEME_FILES));
39
46
  router.get('/assets/ui/:file', sendFrom(here, UI_FILES));
40
47
 
41
48
  for (const name of FONT_PACKAGES) {
42
49
  const dir = dirname(require.resolve(`@fontsource-variable/${name}/LICENSE`));
43
- router.get(`/assets/fonts/${name}/index.css`, (_req, res) => res.sendFile(join(dir, 'index.css'), { maxAge: MAX_AGE }));
50
+ router.get(`/assets/fonts/${name}/index.css`, (_req, res, next) => sendNamed(res, next, dir, 'index.css'));
44
51
  router.use(`/assets/fonts/${name}/files`, express.static(join(dir, 'files'), { maxAge: MAX_AGE, index: false, fallthrough: false }));
45
52
  }
46
53