@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.
- package/README.md +1 -0
- package/VISION.md +10 -10
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/api/invoicing.js +89 -22
- package/server/auth/people.js +3 -1
- package/server/auth/routes.js +4 -1
- package/server/core/contacts.js +160 -0
- package/server/core/directory.js +4 -9
- package/server/core/invoice-rules.js +34 -4
- package/server/core/invoices.js +93 -1
- package/server/core/invoicing-rules.js +20 -3
- package/server/core/job-board.js +1 -1
- package/server/core/sending.js +141 -25
- 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/sentry/index.js +14 -7
- 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/alerts.js +2 -1
- package/server/ui/app.js +113 -28
- package/server/ui/bulk-bar.js +18 -5
- package/server/ui/columns.js +5 -2
- package/server/ui/contacts.js +260 -0
- package/server/ui/custom-fields.js +22 -7
- package/server/ui/flow.css +275 -24
- package/server/ui/invoicing.js +374 -70
- package/server/ui/job-board.js +10 -0
- package/server/ui/job-change.js +4 -3
- package/server/ui/job-view.js +3 -1
- package/server/ui/jobs.js +150 -19
- package/server/ui/kit.js +220 -45
- package/server/ui/list-views.js +50 -10
- package/server/ui/mobile.js +729 -0
- package/server/ui/monitoring.js +51 -0
- package/server/ui/nav-pins.js +44 -0
- package/server/ui/pager.js +57 -10
- package/server/ui/price-list.js +1 -1
- package/server/ui/price-reconcile.js +12 -12
- package/server/ui/reconcile.js +18 -17
- package/server/ui/routes.js +1 -1
- package/server/ui/saved-views.js +2 -1
- package/server/ui/sidecar.js +106 -6
- package/server/ui/view-bar.js +22 -11
- 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
|
}
|
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;
|
|
@@ -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
|
|
13
|
-
// the bundle
|
|
14
|
-
// it
|
|
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.
|
|
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
|
+
}
|