@arcforgelabs/flow 2026.10.18 → 2026.10.19

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 (65) hide show
  1. package/README.md +3 -0
  2. package/SECURITY.md +4 -1
  3. package/npm-shrinkwrap.json +32 -3
  4. package/package.json +3 -2
  5. package/server/app.js +26 -3
  6. package/server/auth/routes.js +27 -12
  7. package/server/auth/session.js +6 -3
  8. package/server/auth/settings.js +26 -7
  9. package/server/auth/sign-in.html +17 -6
  10. package/server/core/invoices.js +6 -3
  11. package/server/core/ledger.js +420 -0
  12. package/server/db/migrations/0003_billing_ledger.sql +84 -0
  13. package/server/db/migrations/0004_ledger_events_append_only.sql +5 -0
  14. package/server/db/migrations/meta/0003_snapshot.json +1327 -0
  15. package/server/db/migrations/meta/0004_snapshot.json +1327 -0
  16. package/server/db/migrations/meta/_journal.json +14 -0
  17. package/server/db/schema.js +137 -0
  18. package/server/import/earlier-flow.js +230 -0
  19. package/server/import-earlier-flow.js +66 -0
  20. package/server/index.js +16 -4
  21. package/server/integrations/adapters.js +6 -0
  22. package/server/integrations/health.js +21 -0
  23. package/server/integrations/index.js +26 -0
  24. package/server/integrations/port.js +154 -6
  25. package/server/integrations/remove-tables.js +31 -0
  26. package/server/integrations/schema.js +62 -0
  27. package/server/integrations/seazona/client.js +196 -0
  28. package/server/integrations/seazona/drizzle.config.js +9 -0
  29. package/server/integrations/seazona/feed.js +104 -0
  30. package/server/integrations/seazona/governor.js +174 -0
  31. package/server/integrations/seazona/index.js +84 -0
  32. package/server/integrations/seazona/lanes.js +431 -0
  33. package/server/integrations/seazona/ledger.js +66 -0
  34. package/server/integrations/seazona/map.js +229 -0
  35. package/server/integrations/seazona/migrations/0000_api_governor.sql +20 -0
  36. package/server/integrations/seazona/migrations/0001_mirror.sql +106 -0
  37. package/server/integrations/seazona/migrations/meta/0000_snapshot.json +149 -0
  38. package/server/integrations/seazona/migrations/meta/0001_snapshot.json +793 -0
  39. package/server/integrations/seazona/migrations/meta/_journal.json +20 -0
  40. package/server/integrations/seazona/replay.js +79 -0
  41. package/server/integrations/seazona/scheduler.js +78 -0
  42. package/server/integrations/seazona/schema.js +157 -0
  43. package/server/integrations/seazona/settings.js +161 -0
  44. package/server/integrations/seazona/store.js +219 -0
  45. package/server/integrations/seazona/sync-once.js +44 -0
  46. package/server/integrations/seazona/tables.js +17 -0
  47. package/server/integrations/xero/catalogue.js +184 -0
  48. package/server/integrations/xero/client.js +253 -0
  49. package/server/integrations/xero/connections.js +104 -0
  50. package/server/integrations/xero/drafts.js +525 -0
  51. package/server/integrations/xero/fixtures/replay.json +45 -0
  52. package/server/integrations/xero/governor.js +275 -0
  53. package/server/integrations/xero/index.js +56 -0
  54. package/server/integrations/xero/ledger.js +269 -0
  55. package/server/integrations/xero/migrations/0000_xero_connections.sql +17 -0
  56. package/server/integrations/xero/migrations/0001_xero_catalogue.sql +49 -0
  57. package/server/integrations/xero/migrations/0002_xero_invoice_pushes.sql +32 -0
  58. package/server/integrations/xero/migrations/0003_xero_payment_cursors.sql +7 -0
  59. package/server/integrations/xero/migrations/meta/_journal.json +34 -0
  60. package/server/integrations/xero/oauth.js +143 -0
  61. package/server/integrations/xero/replay.js +83 -0
  62. package/server/integrations/xero/routes.js +234 -0
  63. package/server/integrations/xero/runtime.js +135 -0
  64. package/server/integrations/xero/settings.js +170 -0
  65. package/server/rate-limit.js +13 -19
@@ -0,0 +1,219 @@
1
+ import { and, eq, getTableColumns, gt, inArray, isNotNull, isNull, lt, ne, or, sql } from 'drizzle-orm';
2
+ import { hashOf } from './map.js';
3
+ import {
4
+ seazonaClients,
5
+ seazonaInvoices,
6
+ seazonaLaneState,
7
+ seazonaOrderReadFailures,
8
+ seazonaOrders,
9
+ seazonaReference,
10
+ } from './schema.js';
11
+
12
+ // Reads and writes of the Seazona mirror. Every write takes `at`, the lane's
13
+ // clock, so one run uses one time throughout.
14
+
15
+ export const MIRRORS = Object.freeze({
16
+ orders: { table: seazonaOrders, key: 'id' },
17
+ invoices: { table: seazonaInvoices, key: 'invoiceNumber' },
18
+ clients: { table: seazonaClients, key: 'id' },
19
+ });
20
+
21
+ function excludedSet(table, fields) {
22
+ const columns = getTableColumns(table);
23
+ return Object.fromEntries(fields.map((field) => [field, sql.raw(`excluded."${columns[field].name}"`)]));
24
+ }
25
+
26
+ // Stored list signatures and deleted marks for a set of keys.
27
+ export async function readKnown(db, mirror, keys) {
28
+ const { table, key } = MIRRORS[mirror];
29
+ if (!keys.length) return new Map();
30
+ const columns = { key: table[key], listSignature: table.listSignature, recordHash: table.recordHash };
31
+ if (mirror === 'orders') columns.deletedInSourceAt = table.deletedInSourceAt;
32
+ const rows = await db.select(columns).from(table).where(inArray(table[key], keys));
33
+ return new Map(rows.map((row) => [row.key, row]));
34
+ }
35
+
36
+ // Upserts mapped records. `last_changed_at` moves only when the record
37
+ // differs from what was stored. Returns { created, changed, unchanged }.
38
+ export async function upsertRecords(db, mirror, records, { at, listSignatures = new Map() } = {}) {
39
+ const { table, key } = MIRRORS[mirror];
40
+ const summary = { created: 0, changed: 0, unchanged: 0 };
41
+ if (!records.length) return summary;
42
+ const known = await readKnown(db, mirror, records.map((record) => record[key]));
43
+ const values = records.map((record) => {
44
+ const row = { ...record };
45
+ if (mirror === 'orders') row.deletedInSourceAt = null; // a successful read clears the 404 mark
46
+ const recordHash = hashOf(row);
47
+ const before = known.get(record[key]);
48
+ if (!before) summary.created += 1;
49
+ else if (before.recordHash !== recordHash || before.deletedInSourceAt) summary.changed += 1;
50
+ else summary.unchanged += 1;
51
+ return {
52
+ ...row,
53
+ listSignature: listSignatures.get(record[key]) ?? null,
54
+ recordHash,
55
+ firstSeenAt: at,
56
+ lastSyncedAt: at,
57
+ lastChangedAt: at,
58
+ };
59
+ });
60
+ // A detail-only read (no list row) keeps the stored list signature.
61
+ const keep = new Set([key, 'firstSeenAt', 'lastChangedAt', ...(listSignatures.size ? [] : ['listSignature'])]);
62
+ const updatable = Object.keys(values[0]).filter((field) => !keep.has(field));
63
+ await db.insert(table).values(values).onConflictDoUpdate({
64
+ target: table[key],
65
+ set: {
66
+ ...excludedSet(table, updatable),
67
+ // An order that comes back after a 404 counts as changed.
68
+ lastChangedAt: mirror === 'orders'
69
+ ? sql`case when ${table.recordHash} = excluded.record_hash and ${seazonaOrders.deletedInSourceAt} is null then ${table.lastChangedAt} else excluded.last_changed_at end`
70
+ : sql`case when ${table.recordHash} = excluded.record_hash then ${table.lastChangedAt} else excluded.last_changed_at end`,
71
+ },
72
+ });
73
+ return summary;
74
+ }
75
+
76
+ // A list row that did not change since the last read: only its sync time moves.
77
+ export async function touchSynced(db, mirror, keys, { at }) {
78
+ const { table, key } = MIRRORS[mirror];
79
+ if (!keys.length) return;
80
+ await db.update(table).set({ lastSyncedAt: at }).where(inArray(table[key], keys));
81
+ }
82
+
83
+ // Issue #12: Seazona answered 404. Keep the row and its history, mark it, and
84
+ // refresh its sync time so it never comes first in the rotation again. An
85
+ // order seen in a list but never stored is kept from that list row.
86
+ export async function markOrderDeleted(db, id, { at, fallback = null }) {
87
+ const [row] = await db.update(seazonaOrders)
88
+ .set({
89
+ deletedInSourceAt: sql`coalesce(${seazonaOrders.deletedInSourceAt}, ${at})`,
90
+ lastChangedAt: sql`case when ${seazonaOrders.deletedInSourceAt} is null then ${at} else ${seazonaOrders.lastChangedAt} end`,
91
+ lastSyncedAt: at,
92
+ })
93
+ .where(eq(seazonaOrders.id, id))
94
+ .returning({ id: seazonaOrders.id });
95
+ if (row || !fallback) return;
96
+ await db.insert(seazonaOrders).values({
97
+ ...fallback,
98
+ recordHash: hashOf(fallback),
99
+ deletedInSourceAt: at,
100
+ firstSeenAt: at,
101
+ lastSyncedAt: at,
102
+ lastChangedAt: at,
103
+ }).onConflictDoNothing();
104
+ }
105
+
106
+ export async function upsertReference(db, resource, records, { at }) {
107
+ if (!records.length) return { stored: 0 };
108
+ const values = records.map((record) => ({ ...record, resource, recordHash: hashOf(record), lastSyncedAt: at }));
109
+ await db.insert(seazonaReference).values(values).onConflictDoUpdate({
110
+ target: [seazonaReference.resource, seazonaReference.id],
111
+ set: excludedSet(seazonaReference, ['name', 'code', 'kind', 'priceCents', 'taxable', 'recordHash', 'lastSyncedAt']),
112
+ });
113
+ return { stored: values.length };
114
+ }
115
+
116
+ // Open orders for the active-order lane: not final, not deleted in Seazona,
117
+ // not quarantined, oldest observation first. A failed read never refreshes
118
+ // last_synced_at, so the rotation uses whichever is later, the last success
119
+ // or the last failed attempt: a failed order goes to the back. Complete
120
+ // orders win only an equal-age tie.
121
+ export async function selectActiveOrders(db, { at, limit, finalStatusPattern }) {
122
+ const rotationAt = sql`greatest(${seazonaOrders.lastSyncedAt}, coalesce(${seazonaOrderReadFailures.lastAttemptAt}, ${seazonaOrders.lastSyncedAt}))`;
123
+ const open = and(
124
+ isNull(seazonaOrders.deletedInSourceAt),
125
+ or(isNull(seazonaOrders.status), sql`${seazonaOrders.status} !~* ${finalStatusPattern}`),
126
+ );
127
+ const notQuarantined = or(isNull(seazonaOrderReadFailures.quarantinedUntil), lt(seazonaOrderReadFailures.quarantinedUntil, at));
128
+ const targets = await db.select({
129
+ id: seazonaOrders.id,
130
+ failureCount: seazonaOrderReadFailures.failureCount,
131
+ })
132
+ .from(seazonaOrders)
133
+ .leftJoin(seazonaOrderReadFailures, eq(seazonaOrderReadFailures.orderId, seazonaOrders.id))
134
+ .where(and(open, notQuarantined))
135
+ .orderBy(rotationAt, sql`(${seazonaOrders.status} ~* '\\mcomplete') desc nulls last`, seazonaOrders.id)
136
+ .limit(limit);
137
+ const [{ candidates }] = await db.select({ candidates: sql`count(*)::int` })
138
+ .from(seazonaOrders)
139
+ .where(open);
140
+ return { targets, candidates };
141
+ }
142
+
143
+ export async function readOrderFailures(db, ids) {
144
+ if (!ids.length) return new Map();
145
+ const rows = await db.select().from(seazonaOrderReadFailures).where(inArray(seazonaOrderReadFailures.orderId, ids));
146
+ return new Map(rows.map((row) => [row.orderId, row]));
147
+ }
148
+
149
+ export async function clearOrderFailure(db, id) {
150
+ await db.delete(seazonaOrderReadFailures).where(eq(seazonaOrderReadFailures.orderId, id));
151
+ }
152
+
153
+ // Records one failed read. After `quarantineFailures` failures in a row the
154
+ // order waits `quarantineMs`; when that ends it gets one more read, and a
155
+ // failure then quarantines it again.
156
+ export async function recordOrderFailure(db, id, failure, { at, quarantineFailures, quarantineMs }) {
157
+ const [row] = await db.insert(seazonaOrderReadFailures).values({
158
+ orderId: id,
159
+ failureCount: 1,
160
+ lastAttemptAt: at,
161
+ lastHttpStatus: failure.httpStatus,
162
+ lastFailureKind: failure.kind,
163
+ lastError: failure.error.slice(0, 300),
164
+ quarantinedUntil: quarantineFailures <= 1 ? new Date(at.getTime() + quarantineMs) : null,
165
+ }).onConflictDoUpdate({
166
+ target: seazonaOrderReadFailures.orderId,
167
+ set: {
168
+ failureCount: sql`${seazonaOrderReadFailures.failureCount} + 1`,
169
+ lastAttemptAt: at,
170
+ lastHttpStatus: failure.httpStatus,
171
+ lastFailureKind: failure.kind,
172
+ lastError: failure.error.slice(0, 300),
173
+ quarantinedUntil: sql`case when ${seazonaOrderReadFailures.failureCount} + 1 >= ${quarantineFailures}
174
+ then ${new Date(at.getTime() + quarantineMs)}::timestamptz else null end`,
175
+ },
176
+ }).returning();
177
+ return { failureCount: row.failureCount, quarantinedUntil: row.quarantinedUntil?.toISOString() ?? null };
178
+ }
179
+
180
+ export async function readQuarantinedOrders(db, { at, limit = 25 }) {
181
+ const rows = await db.select().from(seazonaOrderReadFailures)
182
+ .where(and(isNotNull(seazonaOrderReadFailures.quarantinedUntil), gt(seazonaOrderReadFailures.quarantinedUntil, at)))
183
+ .orderBy(seazonaOrderReadFailures.orderId)
184
+ .limit(limit);
185
+ return rows.map((row) => ({
186
+ id: row.orderId,
187
+ httpStatus: row.lastHttpStatus,
188
+ kind: row.lastFailureKind,
189
+ failureCount: row.failureCount,
190
+ lastAttemptAt: row.lastAttemptAt.toISOString(),
191
+ quarantinedUntil: row.quarantinedUntil.toISOString(),
192
+ }));
193
+ }
194
+
195
+ // Lane state.
196
+
197
+ export async function readLaneStates(db) {
198
+ const rows = await db.select().from(seazonaLaneState);
199
+ return new Map(rows.map((row) => [row.lane, row]));
200
+ }
201
+
202
+ // Claims a lane for this run. Returns false when another run holds it and has
203
+ // not been running longer than `stuckMs`, so two processes never run one lane
204
+ // at once.
205
+ export async function claimLane(db, lane, { at, stuckMs }) {
206
+ await db.insert(seazonaLaneState).values({ lane }).onConflictDoNothing();
207
+ const claimed = await db.update(seazonaLaneState)
208
+ .set({ status: 'processing', lastAttemptAt: at, updatedAt: at })
209
+ .where(and(
210
+ eq(seazonaLaneState.lane, lane),
211
+ or(ne(seazonaLaneState.status, 'processing'), lt(seazonaLaneState.lastAttemptAt, new Date(at.getTime() - stuckMs))),
212
+ ))
213
+ .returning({ lane: seazonaLaneState.lane });
214
+ return claimed.length > 0;
215
+ }
216
+
217
+ export async function finishLane(db, lane, patch) {
218
+ await db.update(seazonaLaneState).set({ ...patch, updatedAt: patch.lastAttemptAt ?? new Date() }).where(eq(seazonaLaneState.lane, lane));
219
+ }
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env node
2
+ // Runs the Seazona sync lanes once, now, and prints what each did. This is
3
+ // how a sandbox or replay installation syncs, since only live mode runs on a
4
+ // timer. Every request still goes through the rate governor.
5
+ //
6
+ // DATABASE_URL=postgres://… FLOW_INTEGRATIONS=seazona SEAZONA_MODE=sandbox … \
7
+ // node server/integrations/seazona/sync-once.js [lane …]
8
+ //
9
+ // Lanes: invoices, orders_recent, orders_active, clients, products, users.
10
+ // None named: all of them.
11
+ import { openDatabase } from '../../db/index.js';
12
+ import { resolveEnabledIntegrations } from '../index.js';
13
+ import { migrateAdapterSchema } from '../schema.js';
14
+ import { createSeazonaClient } from './client.js';
15
+ import { requireSeazonaSettings } from './index.js';
16
+ import { LANES, runSeazonaSync } from './lanes.js';
17
+ import { seazonaSchema } from './tables.js';
18
+
19
+ const lanes = process.argv.slice(2);
20
+ const unknown = lanes.filter((lane) => !LANES.includes(lane));
21
+ if (unknown.length) {
22
+ console.error(`Unknown lane ${unknown.join(', ')}. Lanes: ${LANES.join(', ')}.`);
23
+ process.exit(2);
24
+ }
25
+
26
+ let database;
27
+ try {
28
+ // The adapter's tables exist only while it is on (docs/INTEGRATIONS.md).
29
+ if (!resolveEnabledIntegrations().includes('seazona')) {
30
+ throw new Error('Seazona is turned off: add seazona to FLOW_INTEGRATIONS first');
31
+ }
32
+ const settings = requireSeazonaSettings();
33
+ database = openDatabase({ max: 2 });
34
+ await migrateAdapterSchema(database.db, { id: 'seazona', schema: seazonaSchema });
35
+ const client = createSeazonaClient({ settings, db: database.db, workflow: 'seazona-sync-once' });
36
+ const result = await runSeazonaSync(database.db, client, { settings, only: lanes, force: true });
37
+ console.log(JSON.stringify({ mode: settings.mode, requests: client.stats.requests, ...result }, null, 2));
38
+ process.exitCode = result.ok ? 0 : 1;
39
+ } catch (error) {
40
+ console.error(`[seazona] ${error.message}`);
41
+ process.exitCode = 1;
42
+ } finally {
43
+ await database?.close();
44
+ }
@@ -0,0 +1,17 @@
1
+ import { fileURLToPath } from 'node:url';
2
+
3
+ // The adapter's schema declaration for the integration port: its own
4
+ // migrations, and every table they create (docs/INTEGRATIONS.md).
5
+ export const seazonaSchema = Object.freeze({
6
+ migrationsFolder: fileURLToPath(new URL('./migrations', import.meta.url)),
7
+ tables: Object.freeze([
8
+ 'seazona_api_governor',
9
+ 'seazona_api_requests',
10
+ 'seazona_orders',
11
+ 'seazona_invoices',
12
+ 'seazona_clients',
13
+ 'seazona_reference',
14
+ 'seazona_lane_state',
15
+ 'seazona_order_read_failures',
16
+ ]),
17
+ });
@@ -0,0 +1,184 @@
1
+ import { sql } from 'drizzle-orm';
2
+ import { withXeroLane } from './governor.js';
3
+
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
6
+ // reads them all (a few calls: contacts 100 a page, items in one) and
7
+ // replaces the copy in one transaction, so invoice preview can match
8
+ // contacts and item codes without spending Xero calls.
9
+
10
+ // Xero dates come as "/Date(1792000000000+0000)/" or ISO text.
11
+ export function parseXeroDate(value) {
12
+ if (!value) return null;
13
+ const legacy = /^\/Date\((-?\d+)/.exec(String(value));
14
+ const at = legacy ? Number(legacy[1]) : Date.parse(value);
15
+ return Number.isFinite(at) ? new Date(at) : null;
16
+ }
17
+
18
+ // Names compare case-insensitively with spaces collapsed, as Xero's own
19
+ // contact name check does.
20
+ export function normaliseName(value) {
21
+ return String(value ?? '').trim().replace(/\s+/g, ' ').toLowerCase();
22
+ }
23
+
24
+ const toCents = (amount) => (typeof amount === 'number' && Number.isFinite(amount) ? Math.round(amount * 100) : null);
25
+
26
+ export function mapContact(contact) {
27
+ return {
28
+ xeroContactId: contact.ContactID,
29
+ name: String(contact.Name ?? '').trim(),
30
+ email: contact.EmailAddress || null,
31
+ status: contact.ContactStatus || 'ACTIVE',
32
+ isCustomer: Boolean(contact.IsCustomer),
33
+ isSupplier: Boolean(contact.IsSupplier),
34
+ salesTracking: (contact.SalesTrackingCategories ?? []).map((t) => ({ category: t.TrackingCategoryName, option: t.TrackingOptionName })),
35
+ xeroUpdatedAt: parseXeroDate(contact.UpdatedDateUTC),
36
+ };
37
+ }
38
+
39
+ export function mapItem(item) {
40
+ return {
41
+ xeroItemId: item.ItemID,
42
+ code: String(item.Code ?? '').trim(),
43
+ name: item.Name || null,
44
+ description: item.Description || null,
45
+ isSold: item.IsSold !== false,
46
+ salesUnitAmountCents: toCents(item.SalesDetails?.UnitPrice),
47
+ salesAccountCode: item.SalesDetails?.AccountCode || null,
48
+ salesTaxType: item.SalesDetails?.TaxType || null,
49
+ xeroUpdatedAt: parseXeroDate(item.UpdatedDateUTC),
50
+ };
51
+ }
52
+
53
+ async function replaceContacts(tx, organisationId, contacts) {
54
+ const ids = contacts.map((c) => c.xeroContactId);
55
+ await tx.execute(sql`delete from xero_contacts where organisation_id = ${organisationId}
56
+ 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
+ }
66
+ }
67
+
68
+ async function replaceItems(tx, organisationId, items) {
69
+ const ids = items.map((i) => i.xeroItemId);
70
+ await tx.execute(sql`delete from xero_items where organisation_id = ${organisationId}
71
+ and not (xero_item_id = any(${sql.param(ids)}::text[]))`);
72
+ for (const i of items) {
73
+ await tx.execute(sql`
74
+ insert into xero_items (organisation_id, xero_item_id, code, name, description, is_sold, sales_unit_amount_cents, sales_account_code, sales_tax_type, xero_updated_at, synced_at)
75
+ values (${organisationId}, ${i.xeroItemId}, ${i.code}, ${i.name}, ${i.description}, ${i.isSold}, ${i.salesUnitAmountCents}, ${i.salesAccountCode}, ${i.salesTaxType}, ${i.xeroUpdatedAt}, now())
76
+ on conflict (organisation_id, xero_item_id) do update set
77
+ code = excluded.code, name = excluded.name, description = excluded.description, is_sold = excluded.is_sold,
78
+ sales_unit_amount_cents = excluded.sales_unit_amount_cents, sales_account_code = excluded.sales_account_code,
79
+ sales_tax_type = excluded.sales_tax_type, xero_updated_at = excluded.xero_updated_at, synced_at = now()`);
80
+ }
81
+ }
82
+
83
+ // Reads every Xero contact and item and replaces Flow's copy. A failed read
84
+ // leaves the copy as it was and records the failure.
85
+ export async function syncCatalogue(db, runtime, organisationId, { startedBy = null } = {}) {
86
+ return runtime.serial(organisationId, () => withXeroLane('sync', async () => {
87
+ const { client } = await runtime.clientFor(db, organisationId);
88
+ const { rows: [run] } = await db.execute(sql`insert into xero_sync_runs (organisation_id, started_by) values (${organisationId}, ${startedBy}) returning id, started_at`);
89
+ try {
90
+ const contacts = (await client.listContacts()).map(mapContact).filter((c) => c.xeroContactId && c.name);
91
+ // An item without a code cannot go on an invoice line by code.
92
+ const items = (await client.listItems()).map(mapItem).filter((i) => i.xeroItemId && i.code);
93
+ await db.transaction(async (tx) => {
94
+ await replaceContacts(tx, organisationId, contacts);
95
+ await replaceItems(tx, organisationId, items);
96
+ await tx.execute(sql`update xero_sync_runs set status = 'succeeded', finished_at = now(), contact_count = ${contacts.length}, item_count = ${items.length} where id = ${run.id}`);
97
+ });
98
+ return { id: run.id, status: 'succeeded', contactCount: contacts.length, itemCount: items.length };
99
+ } catch (error) {
100
+ await db.execute(sql`update xero_sync_runs set status = 'failed', finished_at = now(), error_code = ${error.code ?? 'xero_sync_failed'}, error_message = ${String(error.message).slice(0, 500)} where id = ${run.id}`);
101
+ throw error;
102
+ }
103
+ }));
104
+ }
105
+
106
+ export async function lastSync(db, organisationId) {
107
+ const { rows } = await db.execute(sql`
108
+ select id, status, started_at, finished_at, contact_count, item_count, error_code, error_message
109
+ from xero_sync_runs where organisation_id = ${organisationId} order by started_at desc limit 1`);
110
+ const row = rows[0];
111
+ if (!row) return null;
112
+ return {
113
+ id: row.id,
114
+ status: row.status,
115
+ startedAt: row.started_at,
116
+ finishedAt: row.finished_at,
117
+ contactCount: row.contact_count,
118
+ itemCount: row.item_count,
119
+ error: row.error_code ? { code: row.error_code, message: row.error_message } : null,
120
+ };
121
+ }
122
+
123
+ // When contacts and items were last copied from Xero, or null.
124
+ export async function lastSuccessfulSyncAt(db, organisationId) {
125
+ const { rows } = await db.execute(sql`select max(finished_at) as at from xero_sync_runs where organisation_id = ${organisationId} and status = 'succeeded'`);
126
+ return rows[0]?.at ?? null;
127
+ }
128
+
129
+ const contactRow = (row) => ({
130
+ xeroContactId: row.xero_contact_id,
131
+ name: row.name,
132
+ email: row.email,
133
+ status: row.status,
134
+ isCustomer: row.is_customer,
135
+ isSupplier: row.is_supplier,
136
+ salesTracking: row.sales_tracking,
137
+ xeroUpdatedAt: row.xero_updated_at,
138
+ syncedAt: row.synced_at,
139
+ });
140
+
141
+ const itemRow = (row) => ({
142
+ xeroItemId: row.xero_item_id,
143
+ code: row.code,
144
+ name: row.name,
145
+ description: row.description,
146
+ isSold: row.is_sold,
147
+ salesUnitAmountCents: row.sales_unit_amount_cents,
148
+ salesAccountCode: row.sales_account_code,
149
+ salesTaxType: row.sales_tax_type,
150
+ syncedAt: row.synced_at,
151
+ });
152
+
153
+ export async function listContacts(db, organisationId, { q = '', limit = 200 } = {}) {
154
+ const like = `%${String(q).trim().replace(/[\\%_]/g, (c) => `\\${c}`)}%`;
155
+ const { rows } = await db.execute(sql`select * from xero_contacts where organisation_id = ${organisationId}
156
+ and name ilike ${like} order by lower(name) limit ${Math.min(Math.max(1, Number(limit) || 200), 1000)}`);
157
+ return rows.map(contactRow);
158
+ }
159
+
160
+ export async function listItems(db, organisationId) {
161
+ const { rows } = await db.execute(sql`select * from xero_items where organisation_id = ${organisationId} order by code`);
162
+ return rows.map(itemRow);
163
+ }
164
+
165
+ export async function itemsByCode(db, organisationId, codes) {
166
+ const wanted = [...new Set(codes.filter(Boolean))];
167
+ if (!wanted.length) return new Map();
168
+ const { rows } = await db.execute(sql`select * from xero_items where organisation_id = ${organisationId}
169
+ and code = any(${sql.param(wanted)}::text[])`);
170
+ return new Map(rows.map((row) => [row.code, itemRow(row)]));
171
+ }
172
+
173
+ // 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.
176
+ export async function matchContact(db, organisationId, name) {
177
+ const wanted = normaliseName(name);
178
+ const { rows } = await db.execute(sql`select * from xero_contacts where organisation_id = ${organisationId}
179
+ and regexp_replace(lower(trim(name)), '\\s+', ' ', 'g') = ${wanted}`);
180
+ const matches = rows.map(contactRow).filter((c) => c.status === 'ACTIVE' && normaliseName(c.name) === wanted);
181
+ if (matches.length === 1) return { state: 'matched', contact: matches[0] };
182
+ if (matches.length > 1) return { state: 'ambiguous', matches };
183
+ return { state: 'missing' };
184
+ }