@arcforgelabs/flow 2026.10.47 → 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 (38) hide show
  1. package/README.md +1 -0
  2. package/npm-shrinkwrap.json +2 -2
  3. package/package.json +1 -1
  4. package/server/api/invoicing.js +35 -8
  5. package/server/core/contacts.js +160 -0
  6. package/server/core/directory.js +4 -9
  7. package/server/core/invoices.js +84 -31
  8. package/server/core/sending.js +65 -3
  9. package/server/db/doctor.js +1 -1
  10. package/server/db/index.js +19 -0
  11. package/server/db/migrations/0021_flow_schema.sql +68 -0
  12. package/server/db/migrations/0022_contacts.sql +11 -0
  13. package/server/db/migrations/meta/0021_snapshot.json +4377 -0
  14. package/server/db/migrations/meta/0022_snapshot.json +4446 -0
  15. package/server/db/migrations/meta/_journal.json +14 -0
  16. package/server/db/schema.js +15 -0
  17. package/server/integrations/port.js +2 -0
  18. package/server/integrations/schema.js +3 -2
  19. package/server/integrations/setup.js +1 -0
  20. package/server/integrations/xero/catalogue.js +82 -14
  21. package/server/integrations/xero/client.js +37 -0
  22. package/server/integrations/xero/create-contact.js +126 -0
  23. package/server/integrations/xero/delete-invoice.js +104 -0
  24. package/server/integrations/xero/drafts.js +19 -8
  25. package/server/integrations/xero/index.js +9 -0
  26. package/server/integrations/xero/migrations/0011_xero_invoice_deleted.sql +11 -0
  27. package/server/integrations/xero/migrations/0012_xero_contact_details.sql +16 -0
  28. package/server/integrations/xero/migrations/meta/_journal.json +14 -0
  29. package/server/integrations/xero/routes.js +21 -0
  30. package/server/integrations/xero/settings.js +3 -0
  31. package/server/ui/app.js +8 -3
  32. package/server/ui/contacts.js +260 -0
  33. package/server/ui/flow.css +40 -0
  34. package/server/ui/invoicing.js +159 -54
  35. package/server/ui/kit.js +28 -8
  36. package/server/ui/mobile.js +6 -3
  37. package/server/ui/nav-pins.js +5 -2
  38. package/server/ui/routes.js +1 -1
@@ -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;
@@ -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
+ }
@@ -5,7 +5,8 @@ import { getInvoice, listInvoiceVersions } from '../../core/invoices.js';
5
5
  import { sendModeFor } from '../../core/invoicing-rules.js';
6
6
  import { contacts as flowContacts, invoices as flowInvoices, priceItems } from '../../db/schema.js';
7
7
  import { readConnection } from './connections.js';
8
- import { itemsByCode, lastSuccessfulSyncAt, matchContact, normaliseName } from './catalogue.js';
8
+ import { itemsByCode, lastSuccessfulSyncAt, matchContactFor, normaliseName } from './catalogue.js';
9
+ import { writesAllowed } from './settings.js';
9
10
  import { xeroError } from './client.js';
10
11
  import { withXeroLane } from './governor.js';
11
12
  import { mirrorState } from './lookup.js';
@@ -24,8 +25,9 @@ import { mirrorState } from './lookup.js';
24
25
  // version is never changed, here or in Xero: Flow never updates a Xero
25
26
  // invoice. A revised invoice (version 2) goes to Xero only after the
26
27
  // earlier version's Xero invoice is deleted or voided there.
27
- // - Flow never creates a Xero contact. The customer must match one Xero
28
- // contact by name (the synced copy, catalogue.js).
28
+ // - The customer must match one Xero contact, by the identity-map link or by
29
+ // name (the synced copy, catalogue.js). When Create missing clients is on,
30
+ // a missing contact can be created from the Flow contact (create-contact.js).
29
31
  // - Before it creates, Flow asks Xero for invoices with the same reference.
30
32
  // One for the same contact and total is the same work: Flow records it and
31
33
  // creates nothing. One for the same contact at another total within 90
@@ -66,6 +68,13 @@ function xeroDate(value) {
66
68
 
67
69
  const problem = (code, message, extra = {}) => ({ code, message, ...extra });
68
70
 
71
+ function missingContact(name, { offerCreate }) {
72
+ const message = offerCreate
73
+ ? `No Xero contact is named "${name}". Create the client in Xero from this contact.`
74
+ : `No Xero contact is named "${name}". Add it in Xero, then sync.`;
75
+ return problem('xero_contact_missing', message, { short: 'Client not in Xero', ...(offerCreate ? { offerCreate: true } : {}) });
76
+ }
77
+
69
78
  // ---------------------------------------------------------------------------
70
79
  // The document to send
71
80
 
@@ -288,8 +297,9 @@ async function previewOne(db, runtime, organisationId, invoiceId) {
288
297
  const problems = [];
289
298
  if (invoice.status === 'void') problems.push(problem('invoice_void', 'This invoice is void. Flow does not send it to Xero.'));
290
299
  if (invoice.status === 'draft') problems.push(problem('invoice_not_issued', 'This invoice is a draft. Issue it, then send it to Xero.'));
291
- const match = await matchContact(db, organisationId, document.contact.name);
292
- 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.`, { short: 'Client not in Xero' }));
300
+ const match = await matchContactFor(db, organisationId, { contactId: invoice.contactId, name: document.contact.name });
301
+ const offerCreate = match.state === 'missing' && runtime.settings.createContacts && writesAllowed(runtime.settings);
302
+ if (match.state === 'missing') problems.push(missingContact(document.contact.name, { offerCreate }));
293
303
  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.`, { short: 'Client twice in Xero' }));
294
304
  const items = await itemsByCode(db, organisationId, document.lines.map((l) => l.itemCode));
295
305
  const sendMode = await sendModeFor(db, organisationId, invoice.contactId);
@@ -389,7 +399,7 @@ export async function checkDrafts(db, runtime, { organisationId, invoiceIds }) {
389
399
  for (const preview of invoices) {
390
400
  found.set(preview.invoiceId, preview.problems
391
401
  .filter((p) => p.blocking !== false && p.code !== 'invoice_not_issued')
392
- .map(({ code, message, short }) => ({ code, message, ...(short ? { short } : {}) })));
402
+ .map(({ code, message, short, offerCreate }) => ({ code, message, ...(short ? { short } : {}), ...(offerCreate ? { offerCreate: true } : {}) })));
393
403
  }
394
404
  }
395
405
  return found;
@@ -468,11 +478,12 @@ async function pushOne(db, runtime, client, organisationId, invoiceId, { pushedB
468
478
  }
469
479
  }
470
480
 
471
- const match = await matchContact(db, organisationId, document.contact.name);
481
+ const match = await matchContactFor(db, organisationId, { contactId: invoice.contactId, name: document.contact.name });
472
482
  if (match.state !== 'matched') {
483
+ const offerCreate = match.state === 'missing' && runtime.settings.createContacts && writesAllowed(runtime.settings);
473
484
  throw xeroError(match.state === 'missing' ? 'xero_contact_missing' : 'xero_contact_ambiguous',
474
485
  match.state === 'missing'
475
- ? `No Xero contact is named "${document.contact.name}". Add it in Xero (Flow never creates Xero contacts), then sync.`
486
+ ? missingContact(document.contact.name, { offerCreate }).message
476
487
  : `More than one Xero contact is named "${document.contact.name}". Merge or rename them in Xero, then sync.`,
477
488
  { status: 409 });
478
489
  }
@@ -1,6 +1,8 @@
1
1
  import { fileURLToPath } from 'node:url';
2
2
  import { sql } from 'drizzle-orm';
3
3
  import { guardAlerts } from '../call-guard.js';
4
+ import { deleteXeroInvoice } from './delete-invoice.js';
5
+ import { createCustomer } from './create-contact.js';
4
6
  import { checkDrafts, documentFacts, pushInvoice, pushInvoices } from './drafts.js';
5
7
  import { readConnection } from './connections.js';
6
8
  import { createImportWorker } from './import.js';
@@ -41,6 +43,7 @@ export const XERO_SETUP = {
41
43
  { name: 'XERO_CLIENT_SECRET', label: 'Client secret', secret: true, help: 'Stored encrypted. Flow never shows it again.' },
42
44
  { name: 'XERO_TENANT_NAME', label: 'Xero organisation name', help: 'Live mode connects only the organisation with this exact name.' },
43
45
  { name: 'XERO_ALLOW_LIVE_WRITES', label: 'Create draft invoices in live Xero', kind: 'onoff', help: 'Off: Flow only reads from Xero.' },
46
+ { name: 'XERO_CREATE_CONTACTS', label: 'Create missing clients', kind: 'onoff', default: 'on', help: 'On: a pending invoice whose client is not in Xero offers Create. Off: add the client in Xero, then sync.' },
44
47
  { name: 'XERO_WEBHOOK_KEY', label: 'Webhook key', secret: true, help: 'From the Xero app\'s Webhooks page. Empty: Flow reads Xero\'s changes every 15 minutes instead.' },
45
48
  ],
46
49
  derivedKeys: ['XERO_TOKEN_KEY'],
@@ -115,6 +118,9 @@ export function createXeroAdapter({ runtime: given = null } = {}) {
115
118
  // One issued invoice to a Xero draft (drafts.js). The Flow invoice id
116
119
  // and version are the idempotency key.
117
120
  pushInvoice: ({ organisationId, invoiceId, pushedBy = null }) => pushInvoice(database, current(), { organisationId, invoiceId, pushedBy }),
121
+ // Read the linked Xero invoice, and delete it only while it is a draft
122
+ // (delete-invoice.js). The core voids in Flow after deleted or gone.
123
+ deleteInvoice: ({ organisationId, invoiceId, actor = null }) => deleteXeroInvoice(database, current(), { organisationId, invoiceId, actor }),
118
124
  // Several issued invoices, in batches of MAX_BATCH (drafts.js).
119
125
  pushInvoices: ({ organisationId, invoiceIds, pushedBy = null }) => pushInvoices(database, current(), { organisationId, invoiceIds, pushedBy }),
120
126
  // The Invoices page tab of each issued invoice (reconcile.js), from
@@ -141,6 +147,9 @@ export function createXeroAdapter({ runtime: given = null } = {}) {
141
147
  readChanges: ({ organisationId }) => readChangesFirst(database, current(), organisationId),
142
148
  // Active Xero contacts, from Flow's copy (lookup.js).
143
149
  listCustomers: ({ organisationId }) => listCustomers(database, organisationId),
150
+ // Create the Xero contact for one Flow contact, when that setting is on
151
+ // (create-contact.js). The Flow contact id is the idempotency key.
152
+ createCustomer: ({ organisationId, contactId, actor = null }) => createCustomer(database, current(), { organisationId, contactId, actor }),
144
153
  // Sales invoices from the mirror, with the mirror's state (lookup.js).
145
154
  findInvoices: ({ organisationId, ...query }) => findInvoices(database, organisationId, query),
146
155
  // Xero items, from Flow's copy, for the price list (lookup.js).
@@ -0,0 +1,11 @@
1
+ -- Who deleted a Xero draft from Flow, and when. The push keeps Xero's status
2
+ -- (DELETED or VOIDED). A link made on the Import page has no push, so it
3
+ -- keeps the same who and when. The Xero adapter's own table: the core does
4
+ -- not store this.
5
+ ALTER TABLE "xero_invoice_pushes" ADD COLUMN IF NOT EXISTS "deleted_by" text;
6
+ --> statement-breakpoint
7
+ ALTER TABLE "xero_invoice_pushes" ADD COLUMN IF NOT EXISTS "deleted_at" timestamp with time zone;
8
+ --> statement-breakpoint
9
+ ALTER TABLE "xero_invoice_links" ADD COLUMN IF NOT EXISTS "deleted_by" text;
10
+ --> statement-breakpoint
11
+ ALTER TABLE "xero_invoice_links" ADD COLUMN IF NOT EXISTS "deleted_at" timestamp with time zone;