@arcforgelabs/flow 2026.10.47 → 2026.10.48-1
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.
- package/README.md +1 -0
- package/npm-shrinkwrap.json +11 -11
- package/package.json +1 -1
- package/server/api/invoicing.js +35 -8
- package/server/core/contacts.js +160 -0
- package/server/core/directory.js +4 -9
- package/server/core/invoices.js +84 -31
- package/server/core/sending.js +65 -3
- package/server/db/doctor.js +1 -1
- package/server/db/index.js +19 -0
- package/server/db/migrations/0021_flow_schema.sql +68 -0
- package/server/db/migrations/0022_contacts.sql +11 -0
- package/server/db/migrations/meta/0021_snapshot.json +4377 -0
- package/server/db/migrations/meta/0022_snapshot.json +4446 -0
- package/server/db/migrations/meta/_journal.json +14 -0
- package/server/db/schema.js +15 -0
- package/server/integrations/port.js +2 -0
- package/server/integrations/schema.js +3 -2
- package/server/integrations/seazona/sweep.js +11 -8
- package/server/integrations/setup.js +1 -0
- package/server/integrations/xero/catalogue.js +82 -14
- package/server/integrations/xero/client.js +37 -0
- package/server/integrations/xero/create-contact.js +126 -0
- package/server/integrations/xero/delete-invoice.js +104 -0
- package/server/integrations/xero/drafts.js +19 -8
- package/server/integrations/xero/index.js +9 -0
- package/server/integrations/xero/migrations/0011_xero_invoice_deleted.sql +11 -0
- package/server/integrations/xero/migrations/0012_xero_contact_details.sql +16 -0
- package/server/integrations/xero/migrations/meta/_journal.json +14 -0
- package/server/integrations/xero/routes.js +21 -0
- package/server/integrations/xero/settings.js +3 -0
- package/server/ui/app.js +8 -3
- package/server/ui/contacts.js +260 -0
- package/server/ui/flow.css +40 -0
- package/server/ui/invoicing.js +159 -54
- package/server/ui/kit.js +28 -8
- package/server/ui/mobile.js +6 -3
- package/server/ui/nav-pins.js +5 -2
- 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
|
}
|
package/server/db/schema.js
CHANGED
|
@@ -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(${`
|
|
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;
|
|
@@ -36,7 +36,7 @@ import {
|
|
|
36
36
|
// withdraws it, so its invoice goes to review.
|
|
37
37
|
// clients the whole list (GET clients/?lastModified=), read on from the
|
|
38
38
|
// last row when it is full.
|
|
39
|
-
// invoices every change since the
|
|
39
|
+
// invoices every change since the invoice lane first read, read on the
|
|
40
40
|
// same way.
|
|
41
41
|
//
|
|
42
42
|
// A client or invoice that Flow holds and the whole list leaves out is read
|
|
@@ -282,12 +282,15 @@ const WHOLE_LISTS = {
|
|
|
282
282
|
list: (client, since) => client.get('invoices/', { lastModified: since }),
|
|
283
283
|
// The guide: on or after lastModified (inclusive).
|
|
284
284
|
stepBackMs: 0,
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
285
|
+
// Where the invoice lane first read: the start of the UTC day of its
|
|
286
|
+
// first run, less SEAZONA_INVOICE_LOOKBACK_DAYS (lanes.js). An invoice
|
|
287
|
+
// last changed before that was never the lane's to read, so it is not a
|
|
288
|
+
// miss (FLOW-HORIZON-K: a day's margin before it counted such invoices).
|
|
289
|
+
// The first row Flow stored marks the first run.
|
|
290
|
+
async firstSince(db, sync) {
|
|
291
|
+
const [{ first }] = await db.select({ first: sql`min(${seazonaInvoices.firstSeenAt})` }).from(seazonaInvoices);
|
|
292
|
+
const firstMs = first ? new Date(first).getTime() : NaN;
|
|
293
|
+
return Number.isFinite(firstMs) ? new Date(floorUtcDay(firstMs) - sync.invoiceLookbackDays * DAY_MS).toISOString() : null;
|
|
291
294
|
},
|
|
292
295
|
},
|
|
293
296
|
};
|
|
@@ -416,7 +419,7 @@ async function sweepWholeList(ctx, resource, cursor, laneStates, helpers, found)
|
|
|
416
419
|
const readsFrom = laneReadsFrom(laneStates.get(resource), sync);
|
|
417
420
|
if (readsFrom === null) return;
|
|
418
421
|
if (!cursor[resource]?.cycleStartedAt) {
|
|
419
|
-
const since = await spec.firstSince(db);
|
|
422
|
+
const since = await spec.firstSince(db, sync);
|
|
420
423
|
if (since === null) {
|
|
421
424
|
cursor[resource] = { lastFinishedAt: at.toISOString(), listed: 0 };
|
|
422
425
|
return;
|
|
@@ -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.
|
|
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
|
|
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.
|
|
175
|
-
//
|
|
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,
|
|
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
|
-
// -
|
|
28
|
-
//
|
|
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
|
|
292
|
-
|
|
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
|
|
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
|
-
?
|
|
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
|
}
|