@arcforgelabs/flow 2026.10.26 → 2026.10.29

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 (63) hide show
  1. package/README.md +6 -0
  2. package/npm-shrinkwrap.json +627 -2
  3. package/package.json +3 -2
  4. package/server/api/invoicing.js +313 -46
  5. package/server/app.js +30 -2
  6. package/server/core/automations.js +154 -0
  7. package/server/core/invoice-rules.js +226 -0
  8. package/server/core/invoices.js +8 -3
  9. package/server/core/invoicing-rules.js +255 -0
  10. package/server/core/job-board.js +130 -0
  11. package/server/core/ledger.js +57 -17
  12. package/server/core/line-mapping.js +337 -0
  13. package/server/core/monitoring.js +34 -0
  14. package/server/core/pipeline.js +16 -3
  15. package/server/core/sending.js +283 -0
  16. package/server/db/migrate-all.js +26 -0
  17. package/server/db/migrations/0010_invoicing_rules.sql +72 -0
  18. package/server/db/migrations/0011_invoice_rules.sql +27 -0
  19. package/server/db/migrations/0012_invoice_sending.sql +18 -0
  20. package/server/db/migrations/0013_line_mappings.sql +35 -0
  21. package/server/db/migrations/meta/0010_snapshot.json +3158 -0
  22. package/server/db/migrations/meta/0011_snapshot.json +3314 -0
  23. package/server/db/migrations/meta/0012_snapshot.json +3380 -0
  24. package/server/db/migrations/meta/0013_snapshot.json +3568 -0
  25. package/server/db/migrations/meta/_journal.json +28 -0
  26. package/server/db/schema.js +158 -0
  27. package/server/import/at-start.js +64 -2
  28. package/server/index.js +17 -5
  29. package/server/integrations/adapters.js +2 -1
  30. package/server/integrations/port.js +6 -0
  31. package/server/integrations/seazona/feed.js +62 -4
  32. package/server/integrations/seazona/index.js +7 -1
  33. package/server/integrations/seazona/lanes.js +4 -1
  34. package/server/integrations/seazona/ledger.js +2 -0
  35. package/server/integrations/seazona/migrations/0000_api_governor.sql +3 -3
  36. package/server/integrations/seazona/migrations/0001_mirror.sql +9 -9
  37. package/server/integrations/sentry/index.js +105 -0
  38. package/server/integrations/xero/client.js +5 -0
  39. package/server/integrations/xero/drafts.js +127 -17
  40. package/server/integrations/xero/index.js +11 -1
  41. package/server/integrations/xero/lookup.js +6 -2
  42. package/server/integrations/xero/migrations/0000_xero_connections.sql +1 -1
  43. package/server/integrations/xero/migrations/0001_xero_catalogue.sql +5 -5
  44. package/server/integrations/xero/migrations/0002_xero_invoice_pushes.sql +2 -2
  45. package/server/integrations/xero/migrations/0003_xero_payment_cursors.sql +1 -1
  46. package/server/integrations/xero/migrations/0004_xero_mirror.sql +7 -7
  47. package/server/integrations/xero/migrations/0005_xero_send_modes.sql +16 -0
  48. package/server/integrations/xero/migrations/meta/_journal.json +7 -0
  49. package/server/ui/app-page.js +7 -4
  50. package/server/ui/app.js +122 -6
  51. package/server/ui/automations.js +96 -0
  52. package/server/ui/clerk-appearance.js +21 -0
  53. package/server/ui/flow.css +243 -30
  54. package/server/ui/invoice-rules.js +237 -0
  55. package/server/ui/invoicing-settings.js +110 -0
  56. package/server/ui/invoicing.js +166 -20
  57. package/server/ui/job-board.js +155 -0
  58. package/server/ui/jobs.js +232 -84
  59. package/server/ui/line-mappings.js +60 -0
  60. package/server/ui/logos/sentry.svg +1 -0
  61. package/server/ui/monitoring.js +61 -0
  62. package/server/ui/routes.js +2 -2
  63. package/server/ui/search.js +145 -0
@@ -0,0 +1,105 @@
1
+ import * as SentryNode from '@sentry/node';
2
+
3
+ // The Sentry adapter (docs/SENTRY.md). It sends the errors Flow logs, and the
4
+ // browser's errors and problem reports, to the installation's own Sentry
5
+ // project. Flow ships no DSN: each installation enters its own, here or as
6
+ // the host setting SENTRY_DSN. It has no tables.
7
+
8
+ // The browser SDK, loaded from Sentry's CDN only while this adapter is on
9
+ // (server/ui/monitoring.js). Pinned, with its integrity hash.
10
+ export const SENTRY_BROWSER = Object.freeze({
11
+ src: 'https://browser.sentry-cdn.com/11.4.0/bundle.feedback.min.js',
12
+ integrity: 'sha384-H9RmZ/VaqOPZrUezgCSqHp4t3LoQMd/5+0Y4oEGUB2bddZzSnwZiOYoWneK4diWt',
13
+ });
14
+
15
+ // A DSN is https://<public key>@<host>/<project id>.
16
+ const DSN_PATTERN = /^https:\/\/[0-9a-f]{16,64}@[a-z0-9.-]+(:\d+)?\/(\d+)$/i;
17
+
18
+ export const SENTRY_SETUP = {
19
+ guide: {
20
+ title: 'Connect your own Sentry project',
21
+ link: 'https://sentry.io/signup/',
22
+ steps: [
23
+ 'Sign in to your organisation\'s own Sentry account, or create one.',
24
+ 'Create a project. Choose Express as the platform.',
25
+ 'Open the project\'s Settings, Client Keys (DSN), and copy the DSN.',
26
+ 'Paste it below, or set SENTRY_DSN where Flow is hosted. Then turn Sentry on.',
27
+ 'Optional: in Sentry, Settings, Integrations, add your issue tracker to turn a report into a task.',
28
+ ],
29
+ },
30
+ fields: [
31
+ { name: 'SENTRY_DSN', label: 'DSN', kind: 'url', help: 'From the Sentry project\'s Client Keys. Browsers see it, so it is not a secret.' },
32
+ { name: 'SENTRY_ENVIRONMENT', label: 'Environment', help: 'How Sentry labels this installation, for example production. Empty: production.' },
33
+ { name: 'SENTRY_FEEDBACK', label: 'Report a problem button', kind: 'onoff', help: 'On: everyone signed in can send a report, with a screenshot, to this Sentry project.' },
34
+ ],
35
+ };
36
+
37
+ export function readSentrySettings(env) {
38
+ const dsn = String(env.SENTRY_DSN ?? '').trim();
39
+ if (!dsn) throw new Error('SENTRY_DSN is not set. Enter the Sentry project\'s DSN in Settings, Integrations.');
40
+ if (!DSN_PATTERN.test(dsn)) throw new Error('SENTRY_DSN is not a Sentry DSN (https://<key>@<host>/<project id>).');
41
+ const environment = String(env.SENTRY_ENVIRONMENT ?? '').trim() || 'production';
42
+ const feedback = String(env.SENTRY_FEEDBACK ?? 'on').trim() !== 'off';
43
+ return { dsn, environment, feedback };
44
+ }
45
+
46
+ // The release Sentry groups events by: Flow's release tag, else its commit.
47
+ export function sentryRelease(env) {
48
+ const read = (value) => String(value ?? '').trim() || null;
49
+ const release = read(env.FLOW_DEPLOYED_RELEASE) ?? read(env.FLOW_RELEASE);
50
+ const revision = read(env.FLOW_REVISION);
51
+ return release ? `flow@${release.replace(/^v/, '')}` : revision ? `flow@${revision.slice(0, 12)}` : null;
52
+ }
53
+
54
+ // `sdk` is for tests: anything with init, captureMessage, captureException
55
+ // and close, as @sentry/node has.
56
+ export function createSentryAdapter({ sdk = SentryNode, env: hostEnv = process.env } = {}) {
57
+ let settings = null;
58
+ let release = null;
59
+
60
+ return {
61
+ id: 'sentry',
62
+ name: 'Sentry',
63
+ bundled: false,
64
+ setup: SENTRY_SETUP,
65
+ async start({ log, env }) {
66
+ settings = readSentrySettings(env ?? hostEnv);
67
+ release = sentryRelease(hostEnv);
68
+ sdk.init({
69
+ dsn: settings.dsn,
70
+ environment: settings.environment,
71
+ release: release ?? undefined,
72
+ // Flow sends what it logs; no request bodies, cookies or IP addresses.
73
+ sendDefaultPii: false,
74
+ tracesSampleRate: 0,
75
+ skipOpenTelemetrySetup: true,
76
+ });
77
+ log.info?.(`[sentry] on: environment ${settings.environment}; report button ${settings.feedback ? 'on' : 'off'}`);
78
+ },
79
+ async stop() {
80
+ if (settings) await sdk.close(2000);
81
+ settings = null;
82
+ },
83
+ capabilities: {
84
+ async health() {
85
+ return { ok: Boolean(settings), environment: settings?.environment ?? null, feedback: settings?.feedback ?? false };
86
+ },
87
+ // One error Flow logged: `source` is its "[tag]", `error` the Error when
88
+ // there is one.
89
+ captureError({ message, source = 'server', error = null }) {
90
+ if (!settings) return;
91
+ const scope = { level: 'error', tags: { source } };
92
+ if (error instanceof Error) sdk.captureException(error, { ...scope, extra: { log: message } });
93
+ else sdk.captureMessage(message, scope);
94
+ },
95
+ // What the browser needs to report to the same project. The DSN is
96
+ // meant for browsers.
97
+ browserMonitoring() {
98
+ if (!settings) return null;
99
+ return { dsn: settings.dsn, environment: settings.environment, release, feedback: settings.feedback, script: SENTRY_BROWSER };
100
+ },
101
+ },
102
+ };
103
+ }
104
+
105
+ export const sentryAdapter = createSentryAdapter();
@@ -243,6 +243,11 @@ export function createXeroClient({
243
243
  });
244
244
  return payload[path] ?? [];
245
245
  },
246
+ // Xero emails one approved invoice to its contact, with the organisation's
247
+ // own template, and marks it sent. Not idempotent: a repeat sends again.
248
+ async emailInvoice(invoiceId) {
249
+ await request('POST', `Invoices/${encodeURIComponent(invoiceId)}/Email`, { body: {} });
250
+ },
246
251
  // One invoice. `idempotencyKey` makes a retry within Xero's window
247
252
  // return the first result instead of a second invoice.
248
253
  async createInvoice(invoice, { idempotencyKey }) {
@@ -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
@@ -80,7 +94,7 @@ async function documentFor(db, organisationId, invoiceId) {
80
94
  currency: invoice.currency,
81
95
  contact: { id: contact.id, name: contact.name, email: contact.email },
82
96
  lines: invoice.lines.map((l) => ({
83
- position: l.position, itemCode: codes.get(l.priceItemId) ?? null, description: l.description, quantity: l.quantity,
97
+ position: l.position, itemCode: codes.get(l.priceItemId) ?? null, ...(l.accountCode ? { accountCode: l.accountCode } : {}), description: l.description, quantity: l.quantity,
84
98
  unitAmountCents: l.unitAmountCents, taxCode: l.taxCode, lineAmountCents: l.lineAmountCents, taxCents: l.taxCents,
85
99
  })),
86
100
  subtotalCents: invoice.subtotalCents,
@@ -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) => {
@@ -117,6 +131,9 @@ export function buildDraft(document, { settings, contact, items }) {
117
131
  if (line.itemCode) {
118
132
  if (items.has(line.itemCode)) out.ItemCode = line.itemCode;
119
133
  else problems.push(problem('xero_item_missing', `Line ${line.position}: item ${line.itemCode} is not in Xero. Add it in Xero, then sync.`, { itemCode: line.itemCode }));
134
+ } else if (line.accountCode) {
135
+ // A line with no item books to its own account, such as a surcharge.
136
+ out.AccountCode = line.accountCode;
120
137
  }
121
138
  if (tracking) out.Tracking = [tracking];
122
139
  return out;
@@ -124,7 +141,7 @@ export function buildDraft(document, { settings, contact, items }) {
124
141
  const issueDate = document.issueDate;
125
142
  const draft = {
126
143
  Type: 'ACCREC',
127
- Status: 'DRAFT',
144
+ Status: sendMode === 'approve_and_email' ? 'AUTHORISED' : 'DRAFT',
128
145
  Contact: contact ? { ContactID: contact.xeroContactId } : { Name: document.contact.name },
129
146
  ...(issueDate ? { Date: issueDate, DueDate: document.dueDate ?? addDays(issueDate, settings.defaultDueDays) } : {}),
130
147
  Reference: xeroReference(document),
@@ -168,6 +185,8 @@ function mapPush(row) {
168
185
  verified: row.verified,
169
186
  error: row.error_code ? { code: row.error_code, message: row.error_message } : null,
170
187
  attempts: row.attempts,
188
+ sendMode: row.send_mode ?? 'accounting_draft',
189
+ email: row.email_state ? { state: row.email_state, error: row.email_error ?? null, at: row.emailed_at ?? null } : null,
171
190
  pushedBy: row.pushed_by,
172
191
  firstDispatchedAt: row.first_dispatched_at,
173
192
  lastDispatchedAt: row.last_dispatched_at,
@@ -240,8 +259,12 @@ async function previewOne(db, runtime, organisationId, invoiceId) {
240
259
  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
260
  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
261
  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 });
262
+ const sendMode = await sendModeFor(db, organisationId, invoice.contactId);
263
+ const { draft, problems: lineProblems } = buildDraft(document, { settings: runtime.settings, contact: match.contact, items, sendMode });
244
264
  problems.push(...lineProblems);
265
+ if (sendMode === 'approve_and_email' && match.state === 'matched' && !match.contact.email) {
266
+ 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 }));
267
+ }
245
268
 
246
269
  const pushes = await listPushes(db, organisationId, invoiceId);
247
270
  const current = version ? pushes.find((p) => p.version === version) : null;
@@ -259,6 +282,7 @@ async function previewOne(db, runtime, organisationId, invoiceId) {
259
282
  invoiceId,
260
283
  number: document.number,
261
284
  version,
285
+ sendMode,
262
286
  state,
263
287
  problems,
264
288
  contact: { state: match.state, xeroContactId: match.contact?.xeroContactId ?? null, name: match.contact?.name ?? null },
@@ -298,10 +322,46 @@ export async function pushDrafts(db, runtime, organisationId, invoiceIds, { push
298
322
  results.push(failed(invoiceId, error));
299
323
  }
300
324
  }
325
+ await keepSendState(db, organisationId, results);
301
326
  return summarise(results);
302
327
  }));
303
328
  }
304
329
 
330
+ // Flow's Problem view (server/core/sending.js) shows an issued invoice that
331
+ // failed to send. Once Xero holds it, by any path, that problem is gone.
332
+ async function keepSendState(db, organisationId, results) {
333
+ const held = results.filter((r) => ['created', 'reconciled', 'existing', 'already_in_xero'].includes(r.outcome)).map((r) => r.invoiceId);
334
+ if (!held.length) return;
335
+ await db.update(flowInvoices).set({ sendState: 'sent', sendError: null, sendUpdatedAt: sql`now()` })
336
+ .where(and(eq(flowInvoices.organisationId, organisationId), inArray(flowInvoices.id, held), sql`${flowInvoices.sendState} is not null`));
337
+ }
338
+
339
+ // Send all ready (server/core/sending.js): issued invoices in batches.
340
+ export async function pushInvoices(db, runtime, { organisationId, invoiceIds, pushedBy = null }) {
341
+ const invoices = [];
342
+ for (let at = 0; at < invoiceIds.length; at += MAX_BATCH) {
343
+ invoices.push(...(await pushDrafts(db, runtime, organisationId, invoiceIds.slice(at, at + MAX_BATCH), { pushedBy })).invoices);
344
+ }
345
+ return { invoices };
346
+ }
347
+
348
+ // What would stop each Flow draft in Xero, from Flow's copy of Xero (no
349
+ // calls). Null while Xero is not connected: then Flow's own checks decide.
350
+ export async function checkDrafts(db, runtime, { organisationId, invoiceIds }) {
351
+ const connection = await readConnection(db, organisationId);
352
+ if (!connection || connection.mode !== runtime.settings.mode) return null;
353
+ const found = new Map();
354
+ for (let at = 0; at < invoiceIds.length; at += MAX_BATCH) {
355
+ const { invoices } = await previewDrafts(db, runtime, organisationId, invoiceIds.slice(at, at + MAX_BATCH));
356
+ for (const preview of invoices) {
357
+ found.set(preview.invoiceId, preview.problems
358
+ .filter((p) => p.blocking !== false && p.code !== 'invoice_not_issued')
359
+ .map(({ code, message }) => ({ code, message })));
360
+ }
361
+ }
362
+ return found;
363
+ }
364
+
305
365
  function failed(invoiceId, error, outcome = 'failed') {
306
366
  return { invoiceId, outcome, error: { code: error.code ?? 'xero_push_failed', message: error.message, ...(error.details ? { details: error.details } : {}) } };
307
367
  }
@@ -316,6 +376,8 @@ function summarise(results) {
316
376
  mismatched: count('mismatch'),
317
377
  ambiguous: count('ambiguous'),
318
378
  failed: count('failed') + count('not_tried'),
379
+ emailed: results.filter((r) => r.email?.state === 'sent').length,
380
+ emailProblems: results.filter((r) => r.email && ['failed', 'unknown'].includes(r.email.state)).length,
319
381
  },
320
382
  invoices: results,
321
383
  };
@@ -347,7 +409,8 @@ async function pushOne(db, runtime, client, organisationId, invoiceId, { pushedB
347
409
  const pushes = await listPushes(db, organisationId, invoiceId);
348
410
  const current = pushes.find((p) => p.version === version);
349
411
  if (current && SETTLED.has(current.state)) {
350
- return { invoiceId, version, outcome: 'already_in_xero', xero: current.xero };
412
+ const email = await emailIfDue(db, client, current);
413
+ return { invoiceId, version, outcome: 'already_in_xero', xero: current.xero, ...(email ? { email } : {}) };
351
414
  }
352
415
  if (current?.state === 'mismatch') {
353
416
  return { invoiceId, version, outcome: 'mismatch', xero: current.xero, error: current.error };
@@ -377,14 +440,18 @@ async function pushOne(db, runtime, client, organisationId, invoiceId, { pushedB
377
440
  { status: 409 });
378
441
  }
379
442
  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 });
443
+ const sendMode = await sendModeFor(db, organisationId, invoice.contactId);
444
+ const { draft, problems } = buildDraft(document, { settings: runtime.settings, contact: match.contact, items, sendMode });
381
445
  if (problems.length) throw xeroError(problems[0].code, problems.map((p) => p.message).join(' '), { status: 409 });
382
446
 
383
447
  // Live duplicate check: invoices in Xero with this reference, made by Flow
384
- // or by hand.
448
+ // or by hand. Not needed when Flow's own copy of Xero already answered it
449
+ // for the job this invoice bills.
385
450
  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));
451
+ const sameReference = (await copyShowsNoInvoice(db, runtime, organisationId, invoiceId))
452
+ ? []
453
+ : (await client.findInvoicesByReference(draft.Reference))
454
+ .filter((inv) => !GONE.has(inv.Status) && !earlierIds.has(inv.InvoiceID));
388
455
  const sameContact = sameReference.filter((inv) => inv.Contact?.ContactID === match.contact.xeroContactId);
389
456
  if (!allowExisting) {
390
457
  const same = sameContact.find((inv) => cents(inv.Total) === document.totalCents);
@@ -406,9 +473,9 @@ async function pushOne(db, runtime, client, organisationId, invoiceId, { pushedB
406
473
  const preExisting = sameReference.map((inv) => inv.InvoiceID);
407
474
  const { rows } = await db.execute(sql`
408
475
  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)
476
+ first_dispatched_at, last_dispatched_at, send_mode, email_state)
410
477
  values (${organisationId}, ${invoiceId}, ${version}, 'dispatching', ${key}, ${body}, ${bodyHash}, ${JSON.stringify(preExisting)}::jsonb, ${pushedBy},
411
- ${new Date(runtime.now())}, ${new Date(runtime.now())})
478
+ ${new Date(runtime.now())}, ${new Date(runtime.now())}, ${sendMode}, ${sendMode === 'approve_and_email' ? 'pending' : null})
412
479
  on conflict (invoice_id, version) do nothing returning *`);
413
480
  if (!rows.length) throw xeroError('xero_push_in_progress', 'This invoice is being sent to Xero already. Check again shortly.', { status: 409 });
414
481
  return dispatch(db, client, mapPush(rows[0]), document, { fresh: true });
@@ -481,7 +548,50 @@ async function verify(db, client, push, draft, document, created, outcome) {
481
548
  return { invoiceId: push.invoiceId, version: push.version, outcome: 'mismatch', xero: settled.xero, error: settled.error };
482
549
  }
483
550
  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 };
551
+ const email = await emailIfDue(db, client, settled);
552
+ return { invoiceId: push.invoiceId, version: push.version, outcome, verified: true, xero: settled.xero, ...(email ? { email } : {}) };
553
+ }
554
+
555
+ // ---------------------------------------------------------------------------
556
+ // Email
557
+
558
+ // Xero errors that prove it sent no email: it refused the request.
559
+ const emailRefused = (error) => error.code === 'xero_validation_failed'
560
+ || (error.code === 'xero_api_error' && error.status >= 400 && error.status < 500);
561
+
562
+ // Asks Xero to email an approved invoice that Flow sent and checked, once.
563
+ // Answers the email's state, or null when the push sends no email.
564
+ async function emailIfDue(db, client, push) {
565
+ if (push.sendMode !== 'approve_and_email' || !push.email) return null;
566
+ if (!push.verified || !push.xero?.invoiceId || !['pending', 'failed'].includes(push.email.state)) return push.email;
567
+ // Claim the email, so two pushes never both send it.
568
+ const { rows } = await db.execute(sql`update xero_invoice_pushes set email_state = 'sending', updated_at = now()
569
+ where id = ${push.id} and email_state in ('pending', 'failed') returning id`);
570
+ if (!rows.length) return { state: 'sending', error: null, at: null };
571
+ try {
572
+ await client.emailInvoice(push.xero.invoiceId);
573
+ } catch (error) {
574
+ const state = emailRefused(error) ? 'failed' : 'unknown';
575
+ const message = state === 'failed'
576
+ ? `Xero did not email the invoice: ${error.message}`
577
+ : 'Flow does not know whether Xero emailed the invoice. Check the invoice in Xero before you send it again.';
578
+ await db.execute(sql`update xero_invoice_pushes set email_state = ${state}, email_error = ${message}, updated_at = now() where id = ${push.id}`);
579
+ if (error.code === 'xero_rate_limited' || error.code === 'xero_reconnect_needed') throw error;
580
+ return { state, error: message, at: null };
581
+ }
582
+ const { rows: [done] } = await db.execute(sql`update xero_invoice_pushes set email_state = 'sent', email_error = null, emailed_at = now(), updated_at = now()
583
+ where id = ${push.id} returning emailed_at`);
584
+ return { state: 'sent', error: null, at: done.emailed_at };
585
+ }
586
+
587
+ // Flow drafted this invoice from a job, and its complete, fresh copy of Xero
588
+ // showed no invoice for that job: Xero needs no live duplicate check.
589
+ async function copyShowsNoInvoice(db, runtime, organisationId, invoiceId) {
590
+ const { rows: [job] } = await db.execute(sql`select accounting_state from billing_obligations
591
+ where organisation_id = ${organisationId} and invoice_id = ${invoiceId}`);
592
+ if (job?.accounting_state !== 'not_found') return false;
593
+ const mirror = await mirrorState(db, organisationId, { now: new Date(runtime.now()) });
594
+ return mirror.complete && mirror.fresh;
485
595
  }
486
596
 
487
597
  // An earlier attempt whose outcome is unknown.
@@ -1,6 +1,7 @@
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
6
  import { readPayments, xeroLiveCheck } from './ledger.js';
6
7
  import { findInvoices, listCustomers } from './lookup.js';
@@ -86,6 +87,15 @@ export function createXeroAdapter({ runtime: given = null } = {}) {
86
87
  // One issued invoice to a Xero draft (drafts.js). The Flow invoice id
87
88
  // and version are the idempotency key.
88
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
+ },
89
99
  // Sales invoice payments changed since `since` (ledger.js). The answer's
90
100
  // cursor is the next `since`.
91
101
  pullPayments: ({ organisationId, since }) => readPayments(database, current(), organisationId, { since: new Date(since) }),
@@ -1,6 +1,5 @@
1
1
  import { sql } from 'drizzle-orm';
2
2
  import { ledgerState } from './ledger.js';
3
- import { MIRROR_RESOURCES } from './mirror.js';
4
3
 
5
4
  // What the core's matcher (server/core/pipeline.js) reads from Xero: the
6
5
  // customers and the sales invoices in Flow's own copy. It never calls Xero, so
@@ -10,6 +9,11 @@ import { MIRROR_RESOURCES } from './mirror.js';
10
9
  // stops counting as proof.
11
10
  export const MIRROR_FRESH_MS = 24 * 60 * 60 * 1000;
12
11
 
12
+ // The copies that prove whether Xero holds an invoice for a job. Payments do
13
+ // not: a connection without a payments scope (the default) never copies them,
14
+ // and must still match jobs.
15
+ export const PROOF_RESOURCES = Object.freeze(['invoices', 'credit_notes']);
16
+
13
17
  export async function listCustomers(db, organisationId) {
14
18
  const { rows } = await db.execute(sql`
15
19
  select xero_contact_id, name, status from xero_contacts
@@ -26,7 +30,7 @@ export async function mirrorState(db, organisationId, { now = new Date() } = {})
26
30
  where organisation_id = ${organisationId}`);
27
31
  const byResource = new Map(rows.map((r) => [r.resource, r]));
28
32
  const invoices = byResource.get('invoices');
29
- const finished = MIRROR_RESOURCES.every((r) => (byResource.get(r)?.passes ?? 0) > 0);
33
+ const finished = PROOF_RESOURCES.every((r) => (byResource.get(r)?.passes ?? 0) > 0);
30
34
  const lastPass = invoices?.last_pass_finished_at ? new Date(invoices.last_pass_finished_at) : null;
31
35
  const { rows: [changed] } = await db.execute(sql`
32
36
  select max(last_changed_at) as at from xero_invoices where organisation_id = ${organisationId}`);
@@ -1,6 +1,6 @@
1
1
  -- The Xero connection of each Flow organisation. The refresh token is
2
2
  -- AES-256-GCM ciphertext (XERO_TOKEN_KEY); the plain token is never stored.
3
- CREATE TABLE "xero_connections" (
3
+ CREATE TABLE IF NOT EXISTS "xero_connections" (
4
4
  "organisation_id" uuid PRIMARY KEY NOT NULL REFERENCES "organisations"("id"),
5
5
  "mode" text NOT NULL,
6
6
  "tenant_id" text NOT NULL,
@@ -1,6 +1,6 @@
1
1
  -- Flow's copy of the Xero contacts and items of each connected organisation,
2
2
  -- and a record of each sync. Xero owns these records; a sync replaces the copy.
3
- CREATE TABLE "xero_contacts" (
3
+ CREATE TABLE IF NOT EXISTS "xero_contacts" (
4
4
  "organisation_id" uuid NOT NULL REFERENCES "organisations"("id"),
5
5
  "xero_contact_id" text NOT NULL,
6
6
  "name" text NOT NULL,
@@ -14,9 +14,9 @@ CREATE TABLE "xero_contacts" (
14
14
  CONSTRAINT "xero_contacts_pk" PRIMARY KEY("organisation_id", "xero_contact_id")
15
15
  );
16
16
  --> statement-breakpoint
17
- CREATE INDEX "xero_contacts_org_name" ON "xero_contacts" ("organisation_id", lower("name"));
17
+ CREATE INDEX IF NOT EXISTS "xero_contacts_org_name" ON "xero_contacts" ("organisation_id", lower("name"));
18
18
  --> statement-breakpoint
19
- CREATE TABLE "xero_items" (
19
+ CREATE TABLE IF NOT EXISTS "xero_items" (
20
20
  "organisation_id" uuid NOT NULL REFERENCES "organisations"("id"),
21
21
  "xero_item_id" text NOT NULL,
22
22
  "code" text NOT NULL,
@@ -32,7 +32,7 @@ CREATE TABLE "xero_items" (
32
32
  CONSTRAINT "xero_items_org_code" UNIQUE("organisation_id", "code")
33
33
  );
34
34
  --> statement-breakpoint
35
- CREATE TABLE "xero_sync_runs" (
35
+ CREATE TABLE IF NOT EXISTS "xero_sync_runs" (
36
36
  "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
37
37
  "organisation_id" uuid NOT NULL REFERENCES "organisations"("id"),
38
38
  "status" text DEFAULT 'running' NOT NULL,
@@ -46,4 +46,4 @@ CREATE TABLE "xero_sync_runs" (
46
46
  CONSTRAINT "xero_sync_runs_status_valid" CHECK ("status" in ('running', 'succeeded', 'failed'))
47
47
  );
48
48
  --> statement-breakpoint
49
- CREATE INDEX "xero_sync_runs_org_started" ON "xero_sync_runs" ("organisation_id", "started_at");
49
+ CREATE INDEX IF NOT EXISTS "xero_sync_runs_org_started" ON "xero_sync_runs" ("organisation_id", "started_at");
@@ -1,7 +1,7 @@
1
1
  -- One row for each issued Flow invoice version sent to Xero as a draft. It
2
2
  -- holds the exact request and its Idempotency-Key from before the request
3
3
  -- leaves, so an uncertain outcome is resolved without a second invoice.
4
- CREATE TABLE "xero_invoice_pushes" (
4
+ CREATE TABLE IF NOT EXISTS "xero_invoice_pushes" (
5
5
  "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
6
6
  "organisation_id" uuid NOT NULL,
7
7
  "invoice_id" uuid NOT NULL,
@@ -29,4 +29,4 @@ CREATE TABLE "xero_invoice_pushes" (
29
29
  CONSTRAINT "xero_invoice_pushes_settled_has_id" CHECK ("state" in ('dispatching', 'ambiguous') or "xero_invoice_id" is not null)
30
30
  );
31
31
  --> statement-breakpoint
32
- CREATE INDEX "xero_invoice_pushes_org_invoice" ON "xero_invoice_pushes" ("organisation_id", "invoice_id");
32
+ CREATE INDEX IF NOT EXISTS "xero_invoice_pushes_org_invoice" ON "xero_invoice_pushes" ("organisation_id", "invoice_id");
@@ -1,6 +1,6 @@
1
1
  -- Where the next read of Xero payments starts, for each Flow organisation.
2
2
  -- (This is the Xero adapter's own migration journal, not the core's.)
3
- CREATE TABLE "xero_payment_cursors" (
3
+ CREATE TABLE IF NOT EXISTS "xero_payment_cursors" (
4
4
  "organisation_id" uuid PRIMARY KEY NOT NULL REFERENCES "organisations"("id"),
5
5
  "payments_since" timestamp with time zone NOT NULL,
6
6
  "updated_at" timestamp with time zone DEFAULT now() NOT NULL
@@ -4,7 +4,7 @@
4
4
  -- without spending Xero calls. A row is upserted by its Xero id, and changes
5
5
  -- only when its content hash changes. Free text (references, line
6
6
  -- descriptions) is kept only with XERO_KEEP_REFERENCE=on.
7
- CREATE TABLE "xero_invoices" (
7
+ CREATE TABLE IF NOT EXISTS "xero_invoices" (
8
8
  "organisation_id" uuid NOT NULL REFERENCES "organisations"("id"),
9
9
  "xero_invoice_id" text NOT NULL,
10
10
  "type" text NOT NULL,
@@ -30,11 +30,11 @@ CREATE TABLE "xero_invoices" (
30
30
  CONSTRAINT "xero_invoices_pk" PRIMARY KEY("organisation_id", "xero_invoice_id")
31
31
  );
32
32
  --> statement-breakpoint
33
- CREATE INDEX "xero_invoices_number" ON "xero_invoices" ("organisation_id", "invoice_number");
33
+ CREATE INDEX IF NOT EXISTS "xero_invoices_number" ON "xero_invoices" ("organisation_id", "invoice_number");
34
34
  --> statement-breakpoint
35
- CREATE INDEX "xero_invoices_contact" ON "xero_invoices" ("organisation_id", "xero_contact_id", "issued_on");
35
+ CREATE INDEX IF NOT EXISTS "xero_invoices_contact" ON "xero_invoices" ("organisation_id", "xero_contact_id", "issued_on");
36
36
  --> statement-breakpoint
37
- CREATE TABLE "xero_credit_notes" (
37
+ CREATE TABLE IF NOT EXISTS "xero_credit_notes" (
38
38
  "organisation_id" uuid NOT NULL REFERENCES "organisations"("id"),
39
39
  "xero_credit_note_id" text NOT NULL,
40
40
  "type" text NOT NULL,
@@ -57,7 +57,7 @@ CREATE TABLE "xero_credit_notes" (
57
57
  CONSTRAINT "xero_credit_notes_pk" PRIMARY KEY("organisation_id", "xero_credit_note_id")
58
58
  );
59
59
  --> statement-breakpoint
60
- CREATE TABLE "xero_payments" (
60
+ CREATE TABLE IF NOT EXISTS "xero_payments" (
61
61
  "organisation_id" uuid NOT NULL REFERENCES "organisations"("id"),
62
62
  "xero_payment_id" text NOT NULL,
63
63
  "payment_type" text,
@@ -75,12 +75,12 @@ CREATE TABLE "xero_payments" (
75
75
  CONSTRAINT "xero_payments_pk" PRIMARY KEY("organisation_id", "xero_payment_id")
76
76
  );
77
77
  --> statement-breakpoint
78
- CREATE INDEX "xero_payments_invoice" ON "xero_payments" ("organisation_id", "xero_invoice_id");
78
+ CREATE INDEX IF NOT EXISTS "xero_payments_invoice" ON "xero_payments" ("organisation_id", "xero_invoice_id");
79
79
  --> statement-breakpoint
80
80
  -- Where the next read of each resource starts. `modified_since` is the
81
81
  -- keyset: the next read asks Xero for records changed since then, oldest
82
82
  -- change first. `page` moves on only when one second holds a full page.
83
- CREATE TABLE "xero_mirror_cursors" (
83
+ CREATE TABLE IF NOT EXISTS "xero_mirror_cursors" (
84
84
  "organisation_id" uuid NOT NULL REFERENCES "organisations"("id"),
85
85
  "resource" text NOT NULL,
86
86
  "modified_since" timestamp with time zone,
@@ -0,0 +1,16 @@
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 IF NOT EXISTS "send_mode" text DEFAULT 'accounting_draft' NOT NULL;--> statement-breakpoint
6
+ ALTER TABLE "xero_invoice_pushes" ADD COLUMN IF NOT EXISTS "email_state" text;--> statement-breakpoint
7
+ ALTER TABLE "xero_invoice_pushes" ADD COLUMN IF NOT EXISTS "email_error" text;--> statement-breakpoint
8
+ ALTER TABLE "xero_invoice_pushes" ADD COLUMN IF NOT EXISTS "emailed_at" timestamp with time zone;--> statement-breakpoint
9
+ DO $flow_guard$ BEGIN
10
+ ALTER TABLE "xero_invoice_pushes" ADD CONSTRAINT "xero_invoice_pushes_send_mode_valid" CHECK ("send_mode" in ('accounting_draft', 'approve_and_email'));
11
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
12
+ END $flow_guard$;--> statement-breakpoint
13
+ DO $flow_guard$ BEGIN
14
+ 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'));
15
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
16
+ END $flow_guard$;
@@ -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
  }
@@ -34,13 +34,16 @@ const BODY = `<div class="fx-app">
34
34
  <div class="fx-card fx-message__card fx-state-no-access" id="no-access"></div>
35
35
  </section>
36
36
 
37
- <div class="fx-state-signed-in fx-shell">
37
+ <div class="fx-state-signed-in fx-shell" id="shell">
38
38
  <aside class="fx-side">
39
- <a class="fx-brand" href="/">${FLOW_MARK}<span>Flow</span></a>
39
+ <div class="fx-side__head">
40
+ <a class="fx-brand" href="/">${FLOW_MARK}<span>Flow</span></a>
41
+ <button type="button" class="fx-btn fx-btn--ghost fx-btn--icon" id="menu-toggle" aria-controls="nav" aria-label="Menu"></button>
42
+ </div>
40
43
  <nav class="fx-nav" id="nav" aria-label="Flow"></nav>
41
44
  <div class="fx-side__end">
42
45
  <div id="user-button" class="fx-side__user"></div>
43
- <button type="button" class="fx-btn fx-btn--ghost fx-btn--icon fx-mode-toggle" data-mode-toggle>Light or dark</button>
46
+ <button type="button" class="fx-btn fx-btn--ghost fx-btn--icon" id="search" title="Search (Ctrl+K)" aria-label="Search" aria-keyshortcuts="Control+K Meta+K"></button>
44
47
  </div>
45
48
  </aside>
46
49
  <main id="main" class="fx-main" tabindex="-1"></main>
@@ -48,7 +51,7 @@ const BODY = `<div class="fx-app">
48
51
  <div id="toasts" class="fx-toasts" aria-live="polite"></div>
49
52
  </div>`;
50
53
 
51
- export const APP_PATHS = ['/sign-in', '/jobs', '/invoices', '/invoices/:id', '/people', '/integrations', '/integrations/:id', '/account', '/appearance'];
54
+ export const APP_PATHS = ['/sign-in', '/jobs', '/invoices', '/invoices/:id', '/people', '/integrations', '/integrations/:id', '/account', '/appearance', '/invoicing-settings'];
52
55
 
53
56
  export function appPage({ defaultFamily }) {
54
57
  return renderPage({