@arcforgelabs/flow 2026.10.46 → 2026.10.48

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 +1 -0
  2. package/VISION.md +10 -10
  3. package/npm-shrinkwrap.json +2 -2
  4. package/package.json +1 -1
  5. package/server/api/invoicing.js +89 -22
  6. package/server/auth/people.js +3 -1
  7. package/server/auth/routes.js +4 -1
  8. package/server/core/contacts.js +160 -0
  9. package/server/core/directory.js +4 -9
  10. package/server/core/invoice-rules.js +34 -4
  11. package/server/core/invoices.js +93 -1
  12. package/server/core/invoicing-rules.js +20 -3
  13. package/server/core/job-board.js +1 -1
  14. package/server/core/sending.js +141 -25
  15. package/server/db/doctor.js +1 -1
  16. package/server/db/index.js +19 -0
  17. package/server/db/migrations/0021_flow_schema.sql +68 -0
  18. package/server/db/migrations/0022_contacts.sql +11 -0
  19. package/server/db/migrations/meta/0021_snapshot.json +4377 -0
  20. package/server/db/migrations/meta/0022_snapshot.json +4446 -0
  21. package/server/db/migrations/meta/_journal.json +14 -0
  22. package/server/db/schema.js +15 -0
  23. package/server/integrations/port.js +2 -0
  24. package/server/integrations/schema.js +3 -2
  25. package/server/integrations/sentry/index.js +14 -7
  26. package/server/integrations/setup.js +1 -0
  27. package/server/integrations/xero/catalogue.js +82 -14
  28. package/server/integrations/xero/client.js +37 -0
  29. package/server/integrations/xero/create-contact.js +126 -0
  30. package/server/integrations/xero/delete-invoice.js +104 -0
  31. package/server/integrations/xero/drafts.js +19 -8
  32. package/server/integrations/xero/index.js +9 -0
  33. package/server/integrations/xero/migrations/0011_xero_invoice_deleted.sql +11 -0
  34. package/server/integrations/xero/migrations/0012_xero_contact_details.sql +16 -0
  35. package/server/integrations/xero/migrations/meta/_journal.json +14 -0
  36. package/server/integrations/xero/routes.js +21 -0
  37. package/server/integrations/xero/settings.js +3 -0
  38. package/server/ui/alerts.js +2 -1
  39. package/server/ui/app.js +113 -28
  40. package/server/ui/bulk-bar.js +18 -5
  41. package/server/ui/columns.js +5 -2
  42. package/server/ui/contacts.js +260 -0
  43. package/server/ui/custom-fields.js +22 -7
  44. package/server/ui/flow.css +275 -24
  45. package/server/ui/invoicing.js +374 -70
  46. package/server/ui/job-board.js +10 -0
  47. package/server/ui/job-change.js +4 -3
  48. package/server/ui/job-view.js +3 -1
  49. package/server/ui/jobs.js +150 -19
  50. package/server/ui/kit.js +220 -45
  51. package/server/ui/list-views.js +50 -10
  52. package/server/ui/mobile.js +729 -0
  53. package/server/ui/monitoring.js +51 -0
  54. package/server/ui/nav-pins.js +44 -0
  55. package/server/ui/pager.js +57 -10
  56. package/server/ui/price-list.js +1 -1
  57. package/server/ui/price-reconcile.js +12 -12
  58. package/server/ui/reconcile.js +18 -17
  59. package/server/ui/routes.js +1 -1
  60. package/server/ui/saved-views.js +2 -1
  61. package/server/ui/sidecar.js +106 -6
  62. package/server/ui/view-bar.js +22 -11
  63. package/server/ui/xero-import.js +48 -19
@@ -148,6 +148,20 @@
148
148
  "when": 1792507133665,
149
149
  "tag": "0020_custom_field_colours",
150
150
  "breakpoints": true
151
+ },
152
+ {
153
+ "idx": 21,
154
+ "version": "7",
155
+ "when": 1792667751284,
156
+ "tag": "0021_flow_schema",
157
+ "breakpoints": true
158
+ },
159
+ {
160
+ "idx": 22,
161
+ "version": "7",
162
+ "when": 1792733287865,
163
+ "tag": "0022_contacts",
164
+ "breakpoints": true
151
165
  }
152
166
  ]
153
167
  }
@@ -41,13 +41,28 @@ export const organisations = pgTable('organisations', {
41
41
  check('organisations_next_invoice_number_positive', sql`${t.nextInvoiceNumber} > 0`),
42
42
  ]);
43
43
 
44
+ // A contact is Flow's own client or supplier (docs/CONTACTS.md). The fields
45
+ // cover what Xero needs to bill the contact and what Seazona keeps for the
46
+ // practice a job belongs to. Practitioners are people at the practice, never
47
+ // patients. An integration keeps its own copy; this row does not.
44
48
  export const contacts = pgTable('contacts', {
45
49
  id: id(),
46
50
  organisationId: organisationId().references(() => organisations.id),
47
51
  kind: text('kind').notNull(),
48
52
  name: text('name').notNull(),
53
+ firstName: text('first_name'),
54
+ lastName: text('last_name'),
49
55
  email: text('email'),
56
+ phone: text('phone'),
57
+ mobile: text('mobile'),
58
+ accountNumber: text('account_number'),
59
+ taxNumber: text('tax_number'),
60
+ taxRate: text('tax_rate'),
61
+ address: jsonb('address'),
62
+ practitioners: jsonb('practitioners').notNull().default([]),
63
+ active: boolean('active').notNull().default(true),
50
64
  createdAt: createdAt(),
65
+ updatedAt: updatedAt(),
51
66
  }, (t) => [
52
67
  unique('contacts_org_id').on(t.organisationId, t.id),
53
68
  unique('contacts_org_kind_name').on(t.organisationId, t.kind, t.name),
@@ -15,6 +15,7 @@ export const CAPABILITIES = Object.freeze([
15
15
  'pullHistoryJobs', // past work from a history import, as ledger jobs marked historical (Seazona: its API backfill)
16
16
  'pushInvoice', // send an issued invoice; the Flow record id is the idempotency key
17
17
  'pushInvoices', // send several issued invoices in one pass (Send all ready, server/core/sending.js)
18
+ 'deleteInvoice', // { organisationId, invoiceId, actor }: read the accounting invoice live, then delete it only while it is still a draft. Answers { state: deleted | gone | approved | unavailable, status, message? }. deleted: it was deleted now. gone: it was already deleted or voided, and nothing was written. approved: it is approved or paid; nothing was written. unavailable: it cannot be deleted. The core voids the Flow invoice only after deleted or gone.
18
19
  'checkDrafts', // the accounting system's problems with Flow drafts, from its copy; null while not connected
19
20
  'invoiceStages', // { organisationId, invoiceIds? }: Map(invoiceId -> draft | approved | sent | void | pending) for issued invoices, from its copy
20
21
  'documentFacts', // what the customer's copy of a sent invoice says (number, reference, dates), from the system's copy in Flow; never a live call
@@ -32,6 +33,7 @@ export const CAPABILITIES = Object.freeze([
32
33
  'setPersonSetting', // { personId, key, value }: change one, answers the new list
33
34
  'jobIssues', // open issues the integration holds for jobs, by job ref: a short phrase and the page where a person fixes it
34
35
  'listCustomers', // the system's customers, by id and name, for the identity map (Seazona: clients; Xero: contacts)
36
+ 'createCustomer', // { organisationId, contactId, actor }: create the system's customer from the Flow contact. Answers { outcome, contact }. Xero, when Create missing clients is on.
35
37
  'findInvoices', // sales invoices in the accounting system's copy, for the matcher; never a live call
36
38
  'readChanges', // { state: fresh | read | unavailable }: the accounting system's latest changes into its copy now, just before Flow drafts or sends (read before act)
37
39
  'listPriceItems', // the system's own price list from its copy in Flow, for the price list's links and check; never a live call
@@ -13,11 +13,12 @@ export function migrationsTable(adapter) {
13
13
  return `__drizzle_migrations_${adapter.id.replaceAll('-', '_')}`;
14
14
  }
15
15
 
16
- // The adapter's declared tables that do not exist.
16
+ // The adapter's declared tables that do not exist where Flow's connections
17
+ // look (FLOW_SEARCH_PATH, server/db/index.js).
17
18
  export async function missingAdapterTables(db, adapter) {
18
19
  const missing = [];
19
20
  for (const table of adapter.schema.tables) {
20
- const { rows } = await db.execute(sql`select to_regclass(${`public.${table}`}) is null as missing`);
21
+ const { rows } = await db.execute(sql`select to_regclass(${`"${table.replaceAll('"', '""')}"`}) is null as missing`);
21
22
  if (rows[0].missing) missing.push(table);
22
23
  }
23
24
  return missing;
@@ -9,13 +9,17 @@ import { scrubSpan } from '../../ui/monitoring.js';
9
9
  // the host setting SENTRY_DSN. It has no tables.
10
10
 
11
11
  // The browser SDK, loaded from Sentry's CDN only while this adapter is on
12
- // (server/ui/monitoring.js). Pinned, with its integrity hash. With traces on,
13
- // the bundle that also traces; Sentry ships it only with session replay in
14
- // it too, which Flow never turns on.
12
+ // (server/ui/monitoring.js). Pinned, with its integrity hash. With replay on,
13
+ // the bundle with session replay; with traces on, the bundle that also traces
14
+ // (Sentry ships it only with replay in it).
15
15
  export const SENTRY_BROWSER = Object.freeze({
16
16
  src: 'https://browser.sentry-cdn.com/11.4.0/bundle.feedback.min.js',
17
17
  integrity: 'sha384-H9RmZ/VaqOPZrUezgCSqHp4t3LoQMd/5+0Y4oEGUB2bddZzSnwZiOYoWneK4diWt',
18
18
  });
19
+ export const SENTRY_BROWSER_REPLAY = Object.freeze({
20
+ src: 'https://browser.sentry-cdn.com/11.4.0/bundle.replay.feedback.min.js',
21
+ integrity: 'sha384-9Iyc17wHvpK0bqZe+cuAedQDHoWwenMrxXR+HgLLkQ3rHM4zQD+amLzBnmrQgowj',
22
+ });
19
23
  export const SENTRY_BROWSER_TRACING = Object.freeze({
20
24
  src: 'https://browser.sentry-cdn.com/11.4.0/bundle.tracing.replay.feedback.min.js',
21
25
  integrity: 'sha384-LjwHQKAojcITl6Q7innDSAFjziFnqV9ovZBNXnXnQwub1VXIdBMJnYrQNihtxSa6',
@@ -44,6 +48,7 @@ export const SENTRY_SETUP = {
44
48
  { name: 'SENTRY_ENVIRONMENT', label: 'Environment', help: 'How Sentry labels this installation, for example production. Empty: production, or workspace in a Replit workspace.' },
45
49
  { name: 'SENTRY_CLIENT', label: 'Client', help: 'The name your support desk files this installation\'s reports under. Sent as the client tag. Empty: no client tag.' },
46
50
  { 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.' },
51
+ { name: 'SENTRY_REPLAY', label: 'Replay with reports', kind: 'onoff', help: 'On: each report and each browser error keeps up to the last minute of the page, as a replay with every word hidden.' },
47
52
  { name: 'SENTRY_TRACES_SAMPLE_RATE', label: 'Traces', help: 'The share of clicks, page loads and requests timed, from 0 (off) to 1 (all). Empty: 0.2.' },
48
53
  ],
49
54
  };
@@ -56,6 +61,7 @@ export function readSentrySettings(env) {
56
61
  // labelled apart so they never read as the live app's.
57
62
  const environment = String(env.SENTRY_ENVIRONMENT ?? '').trim() || (isReplitWorkspace(env) ? 'workspace' : 'production');
58
63
  const feedback = String(env.SENTRY_FEEDBACK ?? 'on').trim() !== 'off';
64
+ const replay = String(env.SENTRY_REPLAY ?? 'on').trim() !== 'off';
59
65
  const rate = String(env.SENTRY_TRACES_SAMPLE_RATE ?? '').trim();
60
66
  const tracesSampleRate = rate === '' ? DEFAULT_TRACES_SAMPLE_RATE : Number(rate);
61
67
  if (!/^\d*\.?\d+$/.test(rate || '0') || !(tracesSampleRate >= 0 && tracesSampleRate <= 1)) {
@@ -66,7 +72,7 @@ export function readSentrySettings(env) {
66
72
  // Every event says which product and, when set, which client it is from,
67
73
  // so one support desk can serve many installations.
68
74
  const tags = client ? { product: 'flow', client } : { product: 'flow' };
69
- return { dsn, environment, feedback, tracesSampleRate, tags };
75
+ return { dsn, environment, feedback, replay, tracesSampleRate, tags };
70
76
  }
71
77
 
72
78
  // No trace for the host's health check: it is not a person waiting.
@@ -108,7 +114,7 @@ export function createSentryAdapter({ sdk = SentryNode, env: hostEnv = process.e
108
114
  ),
109
115
  beforeSendSpan: scrubSpan,
110
116
  });
111
- log.info?.(`[sentry] on: environment ${settings.environment}; report button ${settings.feedback ? 'on' : 'off'}; traces ${settings.tracesSampleRate > 0 ? settings.tracesSampleRate : 'off'}`);
117
+ log.info?.(`[sentry] on: environment ${settings.environment}; report button ${settings.feedback ? 'on' : 'off'}; replay ${settings.replay ? 'on' : 'off'}; traces ${settings.tracesSampleRate > 0 ? settings.tracesSampleRate : 'off'}`);
112
118
  },
113
119
  async stop() {
114
120
  if (settings) await sdk.close(2000);
@@ -133,15 +139,16 @@ export function createSentryAdapter({ sdk = SentryNode, env: hostEnv = process.e
133
139
  // meant for browsers.
134
140
  browserMonitoring() {
135
141
  if (!settings) return null;
136
- const { tracesSampleRate } = settings;
142
+ const { tracesSampleRate, replay } = settings;
137
143
  return {
138
144
  dsn: settings.dsn,
139
145
  environment: settings.environment,
140
146
  release,
141
147
  feedback: settings.feedback,
148
+ replay,
142
149
  tags: settings.tags,
143
150
  tracesSampleRate,
144
- script: tracesSampleRate > 0 ? SENTRY_BROWSER_TRACING : SENTRY_BROWSER,
151
+ script: tracesSampleRate > 0 ? SENTRY_BROWSER_TRACING : replay ? SENTRY_BROWSER_REPLAY : SENTRY_BROWSER,
145
152
  };
146
153
  },
147
154
  // Runs `work` in a span of the current trace (server/core/tracing.js).
@@ -81,6 +81,7 @@ export function describeSetup({ adapter, row, env, secrets, entry, unreadable =
81
81
  help: field.help ?? null,
82
82
  choices: field.choices ?? null,
83
83
  suggest: field.suggest ?? null,
84
+ default: field.default ?? null,
84
85
  source: fromHost ? 'host' : null,
85
86
  };
86
87
  if (field.secret) {
@@ -1,11 +1,13 @@
1
1
  import { sql } from 'drizzle-orm';
2
+ import { externalIdFor } from '../../core/links.js';
2
3
  import { withXeroLane } from './governor.js';
3
4
 
4
- // Flow's copy of a connected organisation's Xero contacts and items. Xero
5
- // owns them: Flow never creates or changes a Xero contact or item. A sync
5
+ // Flow's copy of a connected organisation's Xero contacts and items. A sync
6
6
  // reads them all (a few calls: contacts 100 a page, items in one) and
7
7
  // replaces the copy in one transaction, so invoice preview can match
8
- // contacts and item codes without spending Xero calls.
8
+ // contacts and item codes without spending Xero calls. Creating a missing
9
+ // client (create-contact.js) writes one row into this copy; it does not
10
+ // change any other Xero contact. Flow never changes a Xero item.
9
11
 
10
12
  // Xero dates come as "/Date(1792000000000+0000)/" or ISO text.
11
13
  export function parseXeroDate(value) {
@@ -22,12 +24,45 @@ export function normaliseName(value) {
22
24
  }
23
25
 
24
26
  const toCents = (amount) => (typeof amount === 'number' && Number.isFinite(amount) ? Math.round(amount * 100) : null);
27
+ const blank = (value) => {
28
+ const text = String(value ?? '').trim();
29
+ return text || null;
30
+ };
31
+
32
+ function phoneOf(contact, type) {
33
+ const phone = (contact.Phones ?? []).find((item) => item.PhoneType === type);
34
+ if (!phone) return null;
35
+ return [phone.PhoneCountryCode, phone.PhoneAreaCode, phone.PhoneNumber].map(blank).filter(Boolean).join(' ') || null;
36
+ }
37
+
38
+ function addressOf(contact) {
39
+ const list = contact.Addresses ?? [];
40
+ const raw = list.find((item) => item.AddressType === 'POBOX') ?? list.find((item) => item.AddressType === 'STREET');
41
+ if (!raw) return null;
42
+ const address = {
43
+ line1: blank(raw.AddressLine1),
44
+ line2: blank(raw.AddressLine2),
45
+ city: blank(raw.City),
46
+ region: blank(raw.Region),
47
+ postalCode: blank(raw.PostalCode),
48
+ country: blank(raw.Country),
49
+ };
50
+ const kept = Object.fromEntries(Object.entries(address).filter(([, value]) => value));
51
+ return Object.keys(kept).length ? kept : null;
52
+ }
25
53
 
26
54
  export function mapContact(contact) {
27
55
  return {
28
56
  xeroContactId: contact.ContactID,
29
57
  name: String(contact.Name ?? '').trim(),
58
+ firstName: blank(contact.FirstName),
59
+ lastName: blank(contact.LastName),
30
60
  email: contact.EmailAddress || null,
61
+ phone: phoneOf(contact, 'DEFAULT'),
62
+ mobile: phoneOf(contact, 'MOBILE'),
63
+ accountNumber: blank(contact.AccountNumber),
64
+ taxNumber: blank(contact.TaxNumber),
65
+ address: addressOf(contact),
31
66
  status: contact.ContactStatus || 'ACTIVE',
32
67
  isCustomer: Boolean(contact.IsCustomer),
33
68
  isSupplier: Boolean(contact.IsSupplier),
@@ -50,19 +85,30 @@ export function mapItem(item) {
50
85
  };
51
86
  }
52
87
 
88
+ export async function upsertContact(db, organisationId, contact) {
89
+ const address = contact.address ? JSON.stringify(contact.address) : null;
90
+ await db.execute(sql`
91
+ insert into xero_contacts (
92
+ organisation_id, xero_contact_id, name, first_name, last_name, email, phone, mobile,
93
+ account_number, tax_number, address, status, is_customer, is_supplier, sales_tracking, xero_updated_at, synced_at)
94
+ values (
95
+ ${organisationId}, ${contact.xeroContactId}, ${contact.name}, ${contact.firstName ?? null}, ${contact.lastName ?? null},
96
+ ${contact.email ?? null}, ${contact.phone ?? null}, ${contact.mobile ?? null}, ${contact.accountNumber ?? null},
97
+ ${contact.taxNumber ?? null}, ${address}::jsonb, ${contact.status}, ${contact.isCustomer}, ${contact.isSupplier},
98
+ ${JSON.stringify(contact.salesTracking ?? [])}::jsonb, ${contact.xeroUpdatedAt ?? null}, now())
99
+ on conflict (organisation_id, xero_contact_id) do update set
100
+ name = excluded.name, first_name = excluded.first_name, last_name = excluded.last_name, email = excluded.email,
101
+ phone = excluded.phone, mobile = excluded.mobile, account_number = excluded.account_number,
102
+ tax_number = excluded.tax_number, address = excluded.address, status = excluded.status,
103
+ is_customer = excluded.is_customer, is_supplier = excluded.is_supplier, sales_tracking = excluded.sales_tracking,
104
+ xero_updated_at = excluded.xero_updated_at, synced_at = now()`);
105
+ }
106
+
53
107
  async function replaceContacts(tx, organisationId, contacts) {
54
108
  const ids = contacts.map((c) => c.xeroContactId);
55
109
  await tx.execute(sql`delete from xero_contacts where organisation_id = ${organisationId}
56
110
  and not (xero_contact_id = any(${sql.param(ids)}::text[]))`);
57
- for (const c of contacts) {
58
- await tx.execute(sql`
59
- insert into xero_contacts (organisation_id, xero_contact_id, name, email, status, is_customer, is_supplier, sales_tracking, xero_updated_at, synced_at)
60
- values (${organisationId}, ${c.xeroContactId}, ${c.name}, ${c.email}, ${c.status}, ${c.isCustomer}, ${c.isSupplier}, ${JSON.stringify(c.salesTracking)}::jsonb, ${c.xeroUpdatedAt}, now())
61
- on conflict (organisation_id, xero_contact_id) do update set
62
- name = excluded.name, email = excluded.email, status = excluded.status, is_customer = excluded.is_customer,
63
- is_supplier = excluded.is_supplier, sales_tracking = excluded.sales_tracking,
64
- xero_updated_at = excluded.xero_updated_at, synced_at = now()`);
65
- }
111
+ for (const contact of contacts) await upsertContact(tx, organisationId, contact);
66
112
  }
67
113
 
68
114
  async function replaceItems(tx, organisationId, items) {
@@ -129,7 +175,14 @@ export async function lastSuccessfulSyncAt(db, organisationId) {
129
175
  const contactRow = (row) => ({
130
176
  xeroContactId: row.xero_contact_id,
131
177
  name: row.name,
178
+ firstName: row.first_name ?? null,
179
+ lastName: row.last_name ?? null,
132
180
  email: row.email,
181
+ phone: row.phone ?? null,
182
+ mobile: row.mobile ?? null,
183
+ accountNumber: row.account_number ?? null,
184
+ taxNumber: row.tax_number ?? null,
185
+ address: row.address ?? null,
133
186
  status: row.status,
134
187
  isCustomer: row.is_customer,
135
188
  isSupplier: row.is_supplier,
@@ -171,8 +224,9 @@ export async function itemsByCode(db, organisationId, codes) {
171
224
  }
172
225
 
173
226
  // The Xero contact for a Flow contact name: exactly one active contact with
174
- // the same name, or why not. Flow never creates a Xero contact (as before):
175
- // a missing or doubled name is for a person to fix in Xero, then sync.
227
+ // the same name, or why not. A missing name can be created from the Flow
228
+ // contact when that setting is on (create-contact.js). A doubled name is
229
+ // still for a person to fix in Xero, then sync.
176
230
  export async function matchContact(db, organisationId, name) {
177
231
  const wanted = normaliseName(name);
178
232
  const { rows } = await db.execute(sql`select * from xero_contacts where organisation_id = ${organisationId}
@@ -182,3 +236,17 @@ export async function matchContact(db, organisationId, name) {
182
236
  if (matches.length > 1) return { state: 'ambiguous', matches };
183
237
  return { state: 'missing' };
184
238
  }
239
+
240
+ // The Xero contact for a Flow contact: the identity-map link first, then the
241
+ // name. A link Flow made stays matched after a rename on one side.
242
+ export async function matchContactFor(db, organisationId, { contactId = null, name }) {
243
+ if (contactId) {
244
+ const linked = await externalIdFor(db, organisationId, { system: 'xero', entity: 'contact', flowId: contactId });
245
+ if (linked) {
246
+ const { rows } = await db.execute(sql`select * from xero_contacts where organisation_id = ${organisationId} and xero_contact_id = ${linked}`);
247
+ const contact = rows[0] ? contactRow(rows[0]) : null;
248
+ if (contact?.status === 'ACTIVE') return { state: 'matched', contact, by: 'link' };
249
+ }
250
+ }
251
+ return matchContact(db, organisationId, name);
252
+ }
@@ -292,6 +292,43 @@ export function createXeroClient({
292
292
  },
293
293
  // One invoice. `idempotencyKey` makes a retry within Xero's window
294
294
  // return the first result instead of a second invoice.
295
+ // Deletes one draft. The body is only the id and Status DELETED: Flow never
296
+ // changes any other field. The caller has already read the invoice and
297
+ // seen DRAFT or SUBMITTED. A result that is not DELETED is uncertain.
298
+ async deleteInvoice(invoiceId) {
299
+ if (!invoiceId) throw new TypeError('deleteInvoice: invoiceId is required');
300
+ const payload = await request('POST', `Invoices/${encodeURIComponent(invoiceId)}`, {
301
+ body: { InvoiceID: invoiceId, Status: 'DELETED' },
302
+ });
303
+ const updated = first(payload.Invoices);
304
+ const messages = validationMessages(updated);
305
+ if (!updated || updated.HasErrors || updated.StatusAttributeString === 'ERROR' || messages.length || updated.Status !== 'DELETED') {
306
+ throw xeroError('xero_uncertain', messages.join(' ') || 'Xero did not confirm the invoice was deleted.', {
307
+ status: 502,
308
+ details: messages.length ? { validationErrors: messages } : undefined,
309
+ });
310
+ }
311
+ return updated;
312
+ },
313
+ // One contact. `idempotencyKey` makes a retry within Xero's window return
314
+ // the first result instead of a second contact.
315
+ async createContact(contact, { idempotencyKey }) {
316
+ if (!idempotencyKey) throw new TypeError('createContact: idempotencyKey is required');
317
+ const payload = await request('POST', 'Contacts', {
318
+ query: { summarizeErrors: 'false' },
319
+ body: { Contacts: [contact] },
320
+ headers: { 'Idempotency-Key': idempotencyKey },
321
+ });
322
+ const created = first(payload.Contacts);
323
+ const messages = validationMessages(created);
324
+ if (!created?.ContactID || created.HasErrors || created.StatusAttributeString === 'ERROR' || messages.length) {
325
+ throw xeroError('xero_validation_failed', messages.join(' ') || 'Xero refused the contact.', {
326
+ status: 422,
327
+ details: messages.length ? { validationErrors: messages } : undefined,
328
+ });
329
+ }
330
+ return created;
331
+ },
295
332
  async createInvoice(invoice, { idempotencyKey }) {
296
333
  if (!idempotencyKey) throw new TypeError('createInvoice: idempotencyKey is required');
297
334
  const payload = await request('POST', 'Invoices', {
@@ -0,0 +1,126 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { getContact } from '../../core/contacts.js';
3
+ import { linkRecord } from '../../core/links.js';
4
+ import { mapContact, matchContactFor, normaliseName, upsertContact } from './catalogue.js';
5
+ import { xeroError } from './client.js';
6
+ import { readConnection } from './connections.js';
7
+ import { withXeroLane } from './governor.js';
8
+ import { writesAllowed } from './settings.js';
9
+
10
+ // Creates the Xero contact for one Flow contact (docs/CONTACTS.md). Flow's
11
+ // contact stays the record. Xero gets a copy, and this file stores that copy
12
+ // and the identity-map link. A second press for the same contact finds the
13
+ // first and creates nothing.
14
+
15
+ const PERSONS_MAX = 5;
16
+
17
+ export function xeroContactBody(contact) {
18
+ const body = {
19
+ Name: contact.name,
20
+ IsCustomer: contact.kind === 'customer',
21
+ IsSupplier: contact.kind === 'supplier',
22
+ };
23
+ if (contact.firstName) body.FirstName = contact.firstName;
24
+ if (contact.lastName) body.LastName = contact.lastName;
25
+ if (contact.email) body.EmailAddress = contact.email;
26
+ if (contact.accountNumber) body.AccountNumber = contact.accountNumber;
27
+ if (contact.taxNumber) body.TaxNumber = contact.taxNumber;
28
+ const phones = [];
29
+ if (contact.phone) phones.push({ PhoneType: 'DEFAULT', PhoneNumber: contact.phone });
30
+ if (contact.mobile) phones.push({ PhoneType: 'MOBILE', PhoneNumber: contact.mobile });
31
+ if (phones.length) body.Phones = phones;
32
+ const address = contact.address;
33
+ if (address && (address.line1 || address.city || address.postalCode)) {
34
+ const lines = {
35
+ AddressLine1: address.line1 || '',
36
+ AddressLine2: address.line2 || '',
37
+ City: address.city || '',
38
+ Region: address.region || '',
39
+ PostalCode: address.postalCode || '',
40
+ Country: address.country || '',
41
+ };
42
+ body.Addresses = [{ AddressType: 'POBOX', ...lines }, { AddressType: 'STREET', ...lines }];
43
+ }
44
+ const people = (contact.practitioners ?? []).slice(0, PERSONS_MAX).map((person) => {
45
+ const [first, ...rest] = String(person.name).trim().split(/\s+/);
46
+ const out = { FirstName: first, IncludeInEmails: false };
47
+ if (rest.length) out.LastName = rest.join(' ');
48
+ if (person.email) out.EmailAddress = person.email;
49
+ return out;
50
+ });
51
+ if (people.length) body.ContactPersons = people;
52
+ return body;
53
+ }
54
+
55
+ function idempotencyKey(organisationId, contactId, body) {
56
+ const digest = createHash('sha256').update([organisationId, contactId, JSON.stringify(body)].join('\u0000')).digest('hex');
57
+ return `flow-xero-contact-${digest.slice(0, 40)}`;
58
+ }
59
+
60
+ async function remember(db, organisationId, flowId, xeroContact, matchedBy) {
61
+ const mapped = mapContact(xeroContact);
62
+ await upsertContact(db, organisationId, mapped);
63
+ const link = await linkRecord(db, organisationId, {
64
+ entity: 'contact', flowId, system: 'xero', externalId: mapped.xeroContactId, matchedBy,
65
+ });
66
+ return { contact: mapped, link: link.outcome };
67
+ }
68
+
69
+ function activeNamed(contacts, name) {
70
+ const wanted = normaliseName(name);
71
+ return contacts.filter((contact) => contact.ContactStatus !== 'ARCHIVED' && normaliseName(contact.Name) === wanted);
72
+ }
73
+
74
+ // What a pending invoice, or the contact page, can offer. No Xero calls.
75
+ export async function matchCustomer(db, runtime, { organisationId, contactId }) {
76
+ const connection = await readConnection(db, organisationId);
77
+ const connected = Boolean(connection && connection.mode === runtime.settings.mode);
78
+ if (!connected) return { state: 'off', offerCreate: false, xeroContactId: null, name: null };
79
+ const contact = await getContact(db, organisationId, contactId);
80
+ const match = await matchContactFor(db, organisationId, { contactId, name: contact.name });
81
+ return {
82
+ state: match.state,
83
+ offerCreate: match.state === 'missing' && runtime.settings.createContacts && writesAllowed(runtime.settings),
84
+ xeroContactId: match.contact?.xeroContactId ?? null,
85
+ name: match.contact?.name ?? null,
86
+ };
87
+ }
88
+
89
+ export async function createCustomer(db, runtime, { organisationId, contactId, actor = null }) {
90
+ if (!runtime.settings.createContacts) {
91
+ throw xeroError('xero_create_contacts_off', 'Creating Xero clients is off. Add the client in Xero, then sync. Or turn Create missing clients on.', { status: 409 });
92
+ }
93
+ runtime.assertWritesAllowed();
94
+ const flowContact = await getContact(db, organisationId, contactId);
95
+ const known = await matchContactFor(db, organisationId, { contactId, name: flowContact.name });
96
+ if (known.state === 'ambiguous') {
97
+ throw xeroError('xero_contact_ambiguous', `More than one Xero contact is named "${flowContact.name}". Merge or rename them in Xero, then sync.`, { status: 409 });
98
+ }
99
+ if (known.state === 'matched') {
100
+ const linked = await linkRecord(db, organisationId, {
101
+ entity: 'contact', flowId: contactId, system: 'xero', externalId: known.contact.xeroContactId, matchedBy: known.by === 'link' ? 'created_by_flow' : 'exact_name',
102
+ });
103
+ return { outcome: 'existing', contact: known.contact, link: linked.outcome, actor };
104
+ }
105
+
106
+ return runtime.serial(organisationId, () => withXeroLane('writes', async () => {
107
+ const { client } = await runtime.clientFor(db, organisationId);
108
+ await runtime.assertEnvironment(client);
109
+ const live = await client.findContactsByName(flowContact.name);
110
+ const active = activeNamed(live, flowContact.name);
111
+ if (active.length > 1) {
112
+ throw xeroError('xero_contact_ambiguous', `More than one Xero contact is named "${flowContact.name}". Merge or rename them in Xero, then sync.`, { status: 409 });
113
+ }
114
+ if (!active.length && live.length) {
115
+ throw xeroError('xero_contact_archived', `A Xero contact named "${flowContact.name}" is archived. Restore it in Xero, then sync.`, { status: 409 });
116
+ }
117
+ if (active.length === 1) {
118
+ const kept = await remember(db, organisationId, contactId, active[0], 'exact_name');
119
+ return { outcome: 'existing', ...kept };
120
+ }
121
+ const body = xeroContactBody(flowContact);
122
+ const created = await client.createContact(body, { idempotencyKey: idempotencyKey(organisationId, contactId, body) });
123
+ const kept = await remember(db, organisationId, contactId, created, 'created_by_flow');
124
+ return { outcome: 'created', ...kept };
125
+ }));
126
+ }
@@ -0,0 +1,104 @@
1
+ import { and, eq, sql } from 'drizzle-orm';
2
+ import { recordAccountingEvidence } from '../../core/ledger.js';
3
+ import { billingObligations } from '../../db/schema.js';
4
+ import { xeroError } from './client.js';
5
+ import { readConnection } from './connections.js';
6
+ import { withXeroLane } from './governor.js';
7
+ import { ledgerState } from './ledger.js';
8
+ import { writesAllowed } from './settings.js';
9
+
10
+ // Delete the Xero draft behind an issued Flow invoice (docs/XERO.md).
11
+ // The core calls this through the port (`deleteInvoice`) and voids in Flow
12
+ // only after the answer is `deleted` or `gone`. This file is the only place
13
+ // that knows Xero's statuses.
14
+ //
15
+ // Read first, then write. DRAFT and SUBMITTED are set to DELETED. DELETED
16
+ // and VOIDED need no write. AUTHORISED and PAID are refused, and nothing is
17
+ // written. A timeout or a 5xx throws, so Flow stays as it was; the next try
18
+ // reads again and does not repeat a delete Xero already made.
19
+
20
+ const DRAFTS = new Set(['DRAFT', 'SUBMITTED']);
21
+ const GONE = new Set(['DELETED', 'VOIDED']);
22
+ const APPROVED = new Set(['AUTHORISED', 'PAID']);
23
+ export const APPROVED_MESSAGE = 'Approved in Xero. Void it in Xero first.';
24
+
25
+ async function linkedXeroId(db, organisationId, invoiceId) {
26
+ const { rows: pushes } = await db.execute(sql`
27
+ select xero_invoice_id from xero_invoice_pushes
28
+ where organisation_id = ${organisationId} and invoice_id = ${invoiceId} and xero_invoice_id is not null
29
+ order by version desc limit 1`);
30
+ if (pushes[0]?.xero_invoice_id) return pushes[0].xero_invoice_id;
31
+ const { rows: links } = await db.execute(sql`
32
+ select xero_invoice_id from xero_invoice_links
33
+ where organisation_id = ${organisationId} and invoice_id = ${invoiceId}`);
34
+ return links[0]?.xero_invoice_id ?? null;
35
+ }
36
+
37
+ // Who deleted it, and the status Xero confirmed, on the push, the link and
38
+ // Flow's copy. The ledger records deleted or voided so the job is not treated
39
+ // as still drafted.
40
+ async function recordDeletion(db, organisationId, invoiceId, xeroId, status, actor) {
41
+ await db.execute(sql`
42
+ update xero_invoice_pushes set
43
+ xero_status = ${status}, deleted_by = ${actor}, deleted_at = now(), updated_at = now()
44
+ where organisation_id = ${organisationId} and invoice_id = ${invoiceId} and xero_invoice_id = ${xeroId}`);
45
+ await db.execute(sql`
46
+ update xero_invoice_links set deleted_by = ${actor}, deleted_at = now()
47
+ where organisation_id = ${organisationId} and invoice_id = ${invoiceId}`);
48
+ // A new hash, so the next read of Xero replaces this row with what Xero says.
49
+ await db.execute(sql`
50
+ update xero_invoices set status = ${status}, record_hash = ${`flow-delete:${status}:${Date.now()}`}, last_changed_at = now()
51
+ where organisation_id = ${organisationId} and xero_invoice_id = ${xeroId}`);
52
+ const state = ledgerState({ Status: status });
53
+ if (!state || !actor) return;
54
+ const jobs = await db.select({ id: billingObligations.id }).from(billingObligations)
55
+ .where(and(eq(billingObligations.organisationId, organisationId), eq(billingObligations.invoiceId, invoiceId)));
56
+ for (const job of jobs) {
57
+ await recordAccountingEvidence(db, organisationId, job.id, {
58
+ state, accountingRef: xeroId, matchedBy: 'flow_invoice', actor, checkedAt: new Date().toISOString(),
59
+ });
60
+ }
61
+ }
62
+
63
+ async function readLive(client, xeroId) {
64
+ try {
65
+ return await withXeroLane('reads', () => client.getInvoice(xeroId));
66
+ } catch (error) {
67
+ if (error.status === 404) return null;
68
+ throw error;
69
+ }
70
+ }
71
+
72
+ export async function deleteXeroInvoice(db, runtime, { organisationId, invoiceId, actor = null }) {
73
+ // Writes off: no call at all. The core says so with the Send gate's message
74
+ // before it gets here; this is the same stop for a direct call.
75
+ if (!writesAllowed(runtime.settings)) return { state: 'unavailable', status: null };
76
+ const connection = await readConnection(db, organisationId);
77
+ if (!connection || connection.mode !== runtime.settings.mode) return { state: 'unavailable', status: null };
78
+ const xeroId = await linkedXeroId(db, organisationId, invoiceId);
79
+ if (!xeroId) return { state: 'unavailable', status: null };
80
+
81
+ return runtime.serial(organisationId, async () => {
82
+ const { client } = await runtime.clientFor(db, organisationId);
83
+ const live = await readLive(client, xeroId);
84
+ const status = live?.Status ?? null;
85
+ if (!live) {
86
+ await recordDeletion(db, organisationId, invoiceId, xeroId, 'DELETED', actor);
87
+ return { state: 'gone', status: null };
88
+ }
89
+ if (GONE.has(status)) {
90
+ await recordDeletion(db, organisationId, invoiceId, xeroId, status, actor);
91
+ return { state: 'gone', status };
92
+ }
93
+ if (APPROVED.has(status)) return { state: 'approved', status, message: APPROVED_MESSAGE };
94
+ if (!DRAFTS.has(status)) return { state: 'unavailable', status };
95
+
96
+ await runtime.assertEnvironment(client);
97
+ const updated = await withXeroLane('writes', () => client.deleteInvoice(xeroId));
98
+ if (updated?.Status !== 'DELETED') {
99
+ throw xeroError('xero_uncertain', 'Xero did not confirm the invoice was deleted. Nothing was changed in Flow.', { status: 502 });
100
+ }
101
+ await recordDeletion(db, organisationId, invoiceId, xeroId, 'DELETED', actor);
102
+ return { state: 'deleted', status: 'DELETED' };
103
+ });
104
+ }