@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,275 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+
3
+ // Xero rate-limit governor. Every Xero API call passes through it.
4
+ //
5
+ // Xero's limits, per connected organisation (tenant) unless noted: 5 calls
6
+ // at once, 60 a minute, a daily limit that depends on the app's plan (1,000
7
+ // on Starter, 5,000 on Core and above), and 10,000 a minute across all
8
+ // tenants of one app. The governor keeps one call and five a minute of
9
+ // headroom, so a 429 comes only from the app-wide bucket or a quota change
10
+ // Flow could not see. The daily limit starts at XERO_DAY_LIMIT and follows
11
+ // Xero's X-DayLimit-Remaining header once a response shows it.
12
+ //
13
+ // State is per process and per tenant: a counting semaphore, a sliding
14
+ // one-minute window, a day counter, and a circuit that opens when Xero says
15
+ // the day's calls are used up. One Flow process serves one lab.
16
+
17
+ // Each call counts against the lane that made it, so status shows where the
18
+ // day's budget went. Callers set the lane with withXeroLane().
19
+ export const XERO_LANES = Object.freeze(['writes', 'sync', 'reads', 'other']);
20
+ const laneStorage = new AsyncLocalStorage();
21
+
22
+ export function withXeroLane(lane, fn) {
23
+ return laneStorage.run(XERO_LANES.includes(lane) ? lane : 'other', fn);
24
+ }
25
+
26
+ export function currentXeroLane() {
27
+ return laneStorage.getStore() || 'other';
28
+ }
29
+
30
+ export const DEFAULT_GOVERNOR_CONFIG = Object.freeze({
31
+ maxConcurrent: 4,
32
+ maxPerMinute: 55,
33
+ dayLimit: 1000,
34
+ usageWarningFraction: 0.7,
35
+ appMinuteLimit: 10000,
36
+ minuteWindowMs: 60_000,
37
+ dayMs: 24 * 60 * 60 * 1000,
38
+ maxRetrySleepMs: 60_000,
39
+ });
40
+
41
+ function dayKeyOf(ms) {
42
+ return new Date(ms).toISOString().slice(0, 10);
43
+ }
44
+
45
+ function circuitOpenError(circuit, now) {
46
+ const error = new Error(`Xero rate-limit circuit open (${circuit.reason}).`);
47
+ error.code = 'xero_rate_limited';
48
+ error.status = 429;
49
+ error.retryAfterMs = Math.max(0, circuit.until - now);
50
+ return error;
51
+ }
52
+
53
+ export function createGovernor({ config: overrides = {}, now = Date.now, random = Math.random, sleep, log = console } = {}) {
54
+ const config = { ...DEFAULT_GOVERNOR_CONFIG, ...overrides };
55
+ const sleepFn = sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
56
+ const tenants = new Map();
57
+ const appMinuteWindow = [];
58
+
59
+ function tenantState(tenantId) {
60
+ const key = String(tenantId || 'unknown');
61
+ if (!tenants.has(key)) {
62
+ tenants.set(key, {
63
+ tenantId: key,
64
+ inFlight: 0,
65
+ waiters: [],
66
+ minuteWindow: [],
67
+ dayCount: 0,
68
+ dayKey: dayKeyOf(now()),
69
+ laneCounts: {},
70
+ observedDayLimit: null,
71
+ observedDayRemaining: null,
72
+ observedMinuteRemaining: null,
73
+ observedAppMinuteRemaining: null,
74
+ warnedDayKey: null,
75
+ circuit: { state: 'closed', reason: '', openedAt: 0, until: 0 },
76
+ wakeScheduled: false,
77
+ });
78
+ }
79
+ return tenants.get(key);
80
+ }
81
+
82
+ function prune(window, at) {
83
+ while (window.length && window[0] <= at - config.minuteWindowMs) window.shift();
84
+ }
85
+
86
+ function rollover(tenant) {
87
+ const today = dayKeyOf(now());
88
+ if (tenant.dayKey === today) return;
89
+ tenant.dayKey = today;
90
+ tenant.dayCount = 0;
91
+ tenant.laneCounts = {};
92
+ if (tenant.circuit.state === 'open' && tenant.circuit.reason === 'DayLimit') {
93
+ tenant.circuit = { state: 'closed', reason: '', openedAt: 0, until: 0 };
94
+ }
95
+ }
96
+
97
+ // Xero's own count wins once a response has shown it.
98
+ function dayLimitOf(tenant) {
99
+ return Number.isFinite(tenant.observedDayLimit) && tenant.observedDayLimit > 0
100
+ ? tenant.observedDayLimit
101
+ : config.dayLimit;
102
+ }
103
+
104
+ function count(tenant, lane) {
105
+ tenant.dayCount += 1;
106
+ tenant.laneCounts[lane] = (tenant.laneCounts[lane] || 0) + 1;
107
+ const limit = dayLimitOf(tenant);
108
+ if (tenant.warnedDayKey !== tenant.dayKey && tenant.dayCount >= limit * config.usageWarningFraction) {
109
+ tenant.warnedDayKey = tenant.dayKey;
110
+ log.warn?.(`[xero] ${tenant.dayCount} of ${limit} Xero calls used today (${Math.round(config.usageWarningFraction * 100)}% warning). By lane: ${JSON.stringify(tenant.laneCounts)}`);
111
+ }
112
+ }
113
+
114
+ function checkCircuit(tenant) {
115
+ const at = now();
116
+ if (tenant.circuit.state === 'open' && tenant.circuit.until && at >= tenant.circuit.until) {
117
+ tenant.circuit = { ...tenant.circuit, state: 'half_open', until: 0 };
118
+ }
119
+ if (tenant.circuit.state === 'open') throw circuitOpenError(tenant.circuit, at);
120
+ }
121
+
122
+ function hasRoom(tenant) {
123
+ return tenant.inFlight < config.maxConcurrent
124
+ && tenant.minuteWindow.length < config.maxPerMinute
125
+ && appMinuteWindow.length < config.appMinuteLimit;
126
+ }
127
+
128
+ function take(tenant, lane) {
129
+ const at = now();
130
+ tenant.inFlight += 1;
131
+ tenant.minuteWindow.push(at);
132
+ appMinuteWindow.push(at);
133
+ count(tenant, lane);
134
+ }
135
+
136
+ function admitWaiting(tenant) {
137
+ while (tenant.waiters.length && tenant.inFlight < config.maxConcurrent) {
138
+ const at = now();
139
+ prune(tenant.minuteWindow, at);
140
+ prune(appMinuteWindow, at);
141
+ if (!hasRoom(tenant)) {
142
+ scheduleWake(tenant);
143
+ return;
144
+ }
145
+ const waiter = tenant.waiters.shift();
146
+ take(tenant, waiter.lane);
147
+ waiter.resolve();
148
+ }
149
+ }
150
+
151
+ function scheduleWake(tenant) {
152
+ if (!tenant.waiters.length || tenant.wakeScheduled) return;
153
+ const at = now();
154
+ prune(tenant.minuteWindow, at);
155
+ prune(appMinuteWindow, at);
156
+ const wakeTimes = [];
157
+ if (tenant.minuteWindow.length >= config.maxPerMinute) wakeTimes.push(tenant.minuteWindow[0] + config.minuteWindowMs);
158
+ if (appMinuteWindow.length >= config.appMinuteLimit) wakeTimes.push(appMinuteWindow[0] + config.minuteWindowMs);
159
+ const waitMs = wakeTimes.length ? Math.max(10, Math.max(...wakeTimes) - at) : 100;
160
+ tenant.wakeScheduled = true;
161
+ Promise.resolve(sleepFn(waitMs)).then(() => {
162
+ tenant.wakeScheduled = false;
163
+ admitWaiting(tenant);
164
+ }, () => {
165
+ tenant.wakeScheduled = false;
166
+ scheduleWake(tenant);
167
+ });
168
+ }
169
+
170
+ async function acquire(tenantId) {
171
+ const tenant = tenantState(tenantId);
172
+ rollover(tenant);
173
+ checkCircuit(tenant);
174
+ if (tenant.dayCount >= dayLimitOf(tenant)) {
175
+ tripCircuit(tenantId, 'DayLimit', config.dayMs);
176
+ checkCircuit(tenant);
177
+ }
178
+ const lane = currentXeroLane();
179
+ const at = now();
180
+ prune(tenant.minuteWindow, at);
181
+ prune(appMinuteWindow, at);
182
+ if (!tenant.waiters.length && hasRoom(tenant)) {
183
+ take(tenant, lane);
184
+ return;
185
+ }
186
+ await new Promise((resolve) => {
187
+ tenant.waiters.push({ resolve, lane });
188
+ scheduleWake(tenant);
189
+ });
190
+ }
191
+
192
+ function release(tenantId) {
193
+ const tenant = tenantState(tenantId);
194
+ tenant.inFlight = Math.max(0, tenant.inFlight - 1);
195
+ admitWaiting(tenant);
196
+ }
197
+
198
+ // Folds in Xero's rate-limit headers (see readRateLimitHeaders).
199
+ function observe(tenantId, info) {
200
+ if (!info) return;
201
+ const tenant = tenantState(tenantId);
202
+ if (typeof info.dayRemaining === 'number') {
203
+ tenant.observedDayRemaining = info.dayRemaining;
204
+ // Calls made today plus what Xero says is left. Xero's day does not
205
+ // reset at UTC midnight, so this is an estimate the next response
206
+ // corrects.
207
+ tenant.observedDayLimit = Math.max(1, tenant.dayCount + info.dayRemaining);
208
+ if (info.dayRemaining <= 0) tripCircuit(tenantId, 'DayLimit', config.dayMs);
209
+ }
210
+ if (typeof info.minuteRemaining === 'number') tenant.observedMinuteRemaining = info.minuteRemaining;
211
+ if (typeof info.appMinuteRemaining === 'number') tenant.observedAppMinuteRemaining = info.appMinuteRemaining;
212
+ }
213
+
214
+ function tripCircuit(tenantId, reason, durationMs) {
215
+ const tenant = tenantState(tenantId);
216
+ const at = now();
217
+ tenant.circuit = { state: 'open', reason: String(reason || 'Unknown'), openedAt: at, until: at + Math.max(1000, Number(durationMs) || 0) };
218
+ }
219
+
220
+ // How long to wait before retrying a 429, by Xero's X-Rate-Limit-Problem.
221
+ // Never more than a minute inside one request: a longer wait fails fast and
222
+ // the circuit and the caller decide when to try again.
223
+ function retryDelayMs(problem, retryAfterMs) {
224
+ const jitter = Math.floor(random() * 500);
225
+ const hinted = Number.isFinite(retryAfterMs) && retryAfterMs >= 0 ? retryAfterMs : null;
226
+ const clamp = (ms) => Math.max(0, Math.min(config.maxRetrySleepMs, ms));
227
+ switch (problem) {
228
+ case 'ConcurrentLimit':
229
+ return clamp((hinted ?? 200) + jitter);
230
+ case 'MinuteLimit':
231
+ case 'AppMinuteLimit':
232
+ if (hinted !== null) return clamp(hinted + jitter);
233
+ return clamp(60_000 - (now() % 60_000) + jitter);
234
+ case 'DayLimit':
235
+ return 0;
236
+ default:
237
+ return clamp((hinted ?? 1000) + jitter);
238
+ }
239
+ }
240
+
241
+ function status(tenantId) {
242
+ const tenant = tenantState(tenantId);
243
+ rollover(tenant);
244
+ const at = now();
245
+ prune(tenant.minuteWindow, at);
246
+ prune(appMinuteWindow, at);
247
+ const dayLimit = dayLimitOf(tenant);
248
+ return {
249
+ inFlight: tenant.inFlight,
250
+ waiting: tenant.waiters.length,
251
+ minuteUsed: tenant.minuteWindow.length,
252
+ minuteMax: config.maxPerMinute,
253
+ dayCount: tenant.dayCount,
254
+ dayLimit,
255
+ dayLimitSource: Number.isFinite(tenant.observedDayLimit) ? 'xero' : 'setting',
256
+ dayRemaining: Math.max(0, dayLimit - tenant.dayCount),
257
+ usageWarning: tenant.dayCount >= dayLimit * config.usageWarningFraction,
258
+ laneCounts: { ...tenant.laneCounts },
259
+ observedDayRemaining: tenant.observedDayRemaining,
260
+ observedMinuteRemaining: tenant.observedMinuteRemaining,
261
+ circuit: { ...tenant.circuit },
262
+ };
263
+ }
264
+
265
+ return {
266
+ config,
267
+ acquire,
268
+ release,
269
+ observe,
270
+ tripCircuit,
271
+ retryDelayMs,
272
+ status,
273
+ sleep: (ms) => sleepFn(ms),
274
+ };
275
+ }
@@ -0,0 +1,56 @@
1
+ import { fileURLToPath } from 'node:url';
2
+ import { sql } from 'drizzle-orm';
3
+ import { pushInvoice } from './drafts.js';
4
+ import { readPayments } from './ledger.js';
5
+ import { createXeroRouter } from './routes.js';
6
+ import { createXeroRuntime, requireXeroSettings } from './runtime.js';
7
+ import { writesAllowed } from './settings.js';
8
+
9
+ // The Xero adapter (docs/XERO.md). FLOW_INTEGRATIONS=xero turns it on and
10
+ // XERO_MODE says which Xero it acts on. Its tables are created only while it
11
+ // is on (docs/INTEGRATIONS.md).
12
+
13
+ export const XERO_TABLES = Object.freeze(['xero_connections', 'xero_contacts', 'xero_items', 'xero_sync_runs', 'xero_invoice_pushes', 'xero_payment_cursors']);
14
+
15
+ // `runtime` is for tests. Otherwise start() builds it from the environment,
16
+ // so a turned-off adapter never reads its settings, and a malformed setting
17
+ // stops boot when Xero is on.
18
+ export function createXeroAdapter({ runtime: given = null } = {}) {
19
+ let runtime = given;
20
+ let database = null;
21
+ const current = () => {
22
+ runtime ??= createXeroRuntime({ settings: requireXeroSettings(process.env) });
23
+ return runtime;
24
+ };
25
+
26
+ return {
27
+ id: 'xero',
28
+ name: 'Xero',
29
+ bundled: false,
30
+ schema: {
31
+ migrationsFolder: fileURLToPath(new URL('./migrations', import.meta.url)),
32
+ tables: [...XERO_TABLES],
33
+ },
34
+ async start({ db, log }) {
35
+ database = db;
36
+ const { settings } = current();
37
+ log.info?.(`[xero] mode ${settings.mode}; writes ${writesAllowed(settings) ? 'on' : 'off (XERO_ALLOW_LIVE_WRITES)'}; redirect URI ${settings.redirectUri ?? 'not set (FLOW_PUBLIC_ORIGIN)'}`);
38
+ },
39
+ capabilities: {
40
+ async health() {
41
+ const { settings } = current();
42
+ const { rows } = await database.execute(sql`select count(*)::int as connected from xero_connections where mode = ${settings.mode}`);
43
+ return { ok: true, mode: settings.mode, writesAllowed: writesAllowed(settings), connected: rows[0].connected };
44
+ },
45
+ // One issued invoice to a Xero draft (drafts.js). The Flow invoice id
46
+ // and version are the idempotency key.
47
+ pushInvoice: ({ organisationId, invoiceId, pushedBy = null }) => pushInvoice(database, current(), { organisationId, invoiceId, pushedBy }),
48
+ // Sales invoice payments changed since `since` (ledger.js). The answer's
49
+ // cursor is the next `since`.
50
+ pullPayments: ({ organisationId, since }) => readPayments(database, current(), organisationId, { since: new Date(since) }),
51
+ },
52
+ routes: ({ db, personAuth, log }) => createXeroRouter({ db, personAuth, runtime: current(), log }),
53
+ };
54
+ }
55
+
56
+ export const xeroAdapter = createXeroAdapter();
@@ -0,0 +1,269 @@
1
+ import { and, eq, inArray, sql } from 'drizzle-orm';
2
+ import { recordAccountingEvidence, recordPayment } from '../../core/ledger.js';
3
+ import { billingObligations, contacts as flowContacts } from '../../db/schema.js';
4
+ import { matchContact, parseXeroDate } from './catalogue.js';
5
+ import { withXeroLane } from './governor.js';
6
+
7
+ // What the billing obligation ledger (server/core/ledger.js) needs from Xero:
8
+ //
9
+ // - checkInvoice: does Xero hold an invoice for this job? It answers in the
10
+ // ledger's evidence shape ({ state, accountingRef, accountingUrl,
11
+ // matchedBy, checkedAt }) and is the `liveCheck` for
12
+ // draftInvoiceForObligation, so a draft is made only when Xero has none.
13
+ // - readPayments: sales invoice payments changed since a time, in the shape
14
+ // recordPayment takes.
15
+ // - recordXeroPayments: reads those payments, ties each to its job by an
16
+ // exact match only, refreshes the job's invoice state, and records them.
17
+ //
18
+ // Exact matches, as the ledger defines them:
19
+ // flow_invoice the Xero invoice Flow made from the job's Flow invoice
20
+ // (xero_invoice_pushes)
21
+ // accounting_ref the Xero invoice id the ledger already holds
22
+ // source_invoice_ref a Xero invoice whose number or reference is the work
23
+ // system's own invoice number for the job
24
+ // A Xero invoice for the same customer with the job's case reference is not
25
+ // exact: it is reported with matchedBy "reference", which the ledger records
26
+ // as ambiguous and which refuses a draft. Any doubt fails closed.
27
+
28
+ export const PAYMENTS_FIRST_DAYS = 90;
29
+
30
+ // Xero invoice status to the ledger's accounting state.
31
+ export function ledgerState(invoice) {
32
+ switch (invoice?.Status) {
33
+ case 'DRAFT':
34
+ case 'SUBMITTED':
35
+ return 'drafted';
36
+ case 'AUTHORISED':
37
+ return Number(invoice.AmountPaid) > 0 || Number(invoice.AmountCredited) > 0 ? 'part_paid' : 'authorised';
38
+ case 'PAID':
39
+ return 'paid';
40
+ case 'VOIDED':
41
+ return 'voided';
42
+ case 'DELETED':
43
+ return 'deleted';
44
+ default:
45
+ return null;
46
+ }
47
+ }
48
+
49
+ const shortCodes = new Map(); // tenant id -> Xero organisation short code
50
+
51
+ async function invoiceUrl(runtime, client, invoiceId) {
52
+ if (!shortCodes.has(client.tenantId)) {
53
+ const organisation = await client.getOrganisation().catch(() => null);
54
+ if (organisation?.ShortCode) shortCodes.set(client.tenantId, organisation.ShortCode);
55
+ }
56
+ const shortCode = shortCodes.get(client.tenantId);
57
+ const view = `/AccountsReceivable/View.aspx?InvoiceID=${encodeURIComponent(invoiceId)}`;
58
+ if (!shortCode) return null;
59
+ return `${runtime.settings.endpoints.web}/organisationlogin/default.aspx?shortcode=${encodeURIComponent(shortCode)}&redirecturl=${encodeURIComponent(view)}`;
60
+ }
61
+
62
+ async function evidenceFor(runtime, client, invoice, matchedBy, checkedAt) {
63
+ return {
64
+ state: ledgerState(invoice),
65
+ accountingRef: invoice.InvoiceID,
66
+ accountingUrl: await invoiceUrl(runtime, client, invoice.InvoiceID),
67
+ matchedBy,
68
+ checkedAt,
69
+ xero: { invoiceId: invoice.InvoiceID, number: invoice.InvoiceNumber ?? null, status: invoice.Status, totalCents: Math.round(Number(invoice.Total ?? 0) * 100) },
70
+ };
71
+ }
72
+
73
+ const notFound = (checkedAt) => ({ state: 'not_found', accountingRef: null, accountingUrl: null, matchedBy: null, checkedAt });
74
+
75
+ // More than one Xero invoice answers for the job: report one, with no exact
76
+ // match, so the ledger records the job as ambiguous for a person.
77
+ async function several(runtime, client, found, checkedAt) {
78
+ const evidence = await evidenceFor(runtime, client, found[0], 'several', checkedAt);
79
+ return { ...evidence, candidates: found.map((inv) => ({ invoiceId: inv.InvoiceID, number: inv.InvoiceNumber ?? null, status: inv.Status })) };
80
+ }
81
+
82
+ // The Xero invoice for one obligation (a row of billing_obligations), looked
83
+ // up live. Costs 1 to 4 Xero calls.
84
+ export async function checkInvoice(db, runtime, client, organisationId, obligation) {
85
+ const checkedAt = new Date(runtime.now()).toISOString();
86
+
87
+ // 1. The Xero invoice Flow made from the job's Flow invoice.
88
+ if (obligation.invoiceId) {
89
+ const { rows } = await db.execute(sql`select xero_invoice_id, state from xero_invoice_pushes
90
+ where organisation_id = ${organisationId} and invoice_id = ${obligation.invoiceId} order by version desc`);
91
+ const unsettled = rows.find((r) => !r.xero_invoice_id);
92
+ if (unsettled) {
93
+ // A push whose outcome is unknown: Xero may hold a draft. Never "not found".
94
+ return { state: 'drafted', accountingRef: null, accountingUrl: null, matchedBy: 'unsettled_push', checkedAt };
95
+ }
96
+ // The newest version's Xero invoice that is still live; if every one is
97
+ // deleted or voided, the newest one, so the ledger sees that.
98
+ let newest = null;
99
+ for (const row of rows) {
100
+ const live = await client.getInvoice(row.xero_invoice_id);
101
+ if (live && !['DELETED', 'VOIDED'].includes(live.Status)) return evidenceFor(runtime, client, live, 'flow_invoice', checkedAt);
102
+ newest ??= live;
103
+ }
104
+ if (newest) return evidenceFor(runtime, client, newest, 'flow_invoice', checkedAt);
105
+ }
106
+
107
+ // 2. The Xero invoice the ledger already holds.
108
+ if (obligation.accountingRef) {
109
+ const live = await client.getInvoice(obligation.accountingRef).catch((error) => {
110
+ if (error.status === 404) return null;
111
+ throw error;
112
+ });
113
+ if (live) return evidenceFor(runtime, client, live, 'accounting_ref', checkedAt);
114
+ return notFound(checkedAt); // the ledger records "seen before, now not found" as a conflict
115
+ }
116
+
117
+ const sales = (list) => list.filter((inv) => (inv.Type ?? 'ACCREC') === 'ACCREC' && !['DELETED', 'VOIDED'].includes(inv.Status));
118
+
119
+ // 3. The work system's own invoice number, as Xero's number or reference.
120
+ if (obligation.sourceInvoiceRef) {
121
+ const byId = new Map();
122
+ for (const inv of sales(await client.findInvoicesByNumber(obligation.sourceInvoiceRef))) byId.set(inv.InvoiceID, inv);
123
+ for (const inv of sales(await client.findInvoicesByReference(obligation.sourceInvoiceRef))) byId.set(inv.InvoiceID, inv);
124
+ const found = [...byId.values()];
125
+ if (found.length === 1) return evidenceFor(runtime, client, found[0], 'source_invoice_ref', checkedAt);
126
+ if (found.length > 1) return several(runtime, client, found, checkedAt);
127
+ }
128
+
129
+ // 4. The reference Flow would give the job's invoice, for the same
130
+ // customer. Not exact: it refuses a draft and goes to a person.
131
+ const reference = obligation.caseRef ?? obligation.sourceJobRef;
132
+ if (reference) {
133
+ let customer = null;
134
+ if (obligation.contactId) {
135
+ const [contact] = await db.select({ name: flowContacts.name }).from(flowContacts)
136
+ .where(and(eq(flowContacts.organisationId, organisationId), eq(flowContacts.id, obligation.contactId)));
137
+ const match = contact ? await matchContact(db, organisationId, contact.name) : { state: 'missing' };
138
+ customer = match.state === 'matched' ? match.contact.xeroContactId : null;
139
+ if (match.state === 'ambiguous') {
140
+ const error = new Error(`More than one Xero contact is named "${contact.name}"; Flow cannot tell which invoices are this customer's.`);
141
+ error.code = 'xero_contact_ambiguous';
142
+ throw error;
143
+ }
144
+ }
145
+ // Another customer's invoice with the same reference is not this job's.
146
+ const found = sales(await client.findInvoicesByReference(reference))
147
+ .filter((inv) => !customer || inv.Contact?.ContactID === customer);
148
+ if (found.length === 1) return evidenceFor(runtime, client, found[0], 'reference', checkedAt);
149
+ if (found.length > 1) return several(runtime, client, found, checkedAt);
150
+ }
151
+ return notFound(checkedAt);
152
+ }
153
+
154
+ // The ledger's liveCheck for draftInvoiceForObligation:
155
+ // draftInvoiceForObligation(db, org, id, { liveCheck: xeroLiveCheck(db, runtime, org), actor })
156
+ // A Xero error throws, and the ledger then refuses the draft.
157
+ export function xeroLiveCheck(db, runtime, organisationId) {
158
+ return (obligation) => withXeroLane('reads', async () => {
159
+ const { client } = await runtime.clientFor(db, organisationId);
160
+ return checkInvoice(db, runtime, client, organisationId, obligation);
161
+ });
162
+ }
163
+
164
+ // Checks Xero for one obligation and records what it found as accounting
165
+ // evidence on the ledger.
166
+ export async function recordXeroEvidence(db, runtime, organisationId, obligation, { actor }) {
167
+ const evidence = await xeroLiveCheck(db, runtime, organisationId)(obligation);
168
+ if (evidence.state === null) return obligation;
169
+ if (evidence.matchedBy === 'unsettled_push') return obligation;
170
+ const { state, accountingRef, accountingUrl, matchedBy, checkedAt } = evidence;
171
+ return recordAccountingEvidence(db, organisationId, obligation.id, { state, accountingRef, accountingUrl, matchedBy, checkedAt, actor });
172
+ }
173
+
174
+ function mapPayment(payment) {
175
+ return {
176
+ paymentRef: payment.PaymentID,
177
+ paidOn: parseXeroDate(payment.Date)?.toISOString().slice(0, 10) ?? null,
178
+ amountCents: Math.round(Number(payment.Amount) * 100),
179
+ reference: payment.Reference || null,
180
+ reconciled: Boolean(payment.IsReconciled),
181
+ status: payment.Status ?? 'AUTHORISED',
182
+ xeroInvoiceId: payment.Invoice?.InvoiceID ?? null,
183
+ xeroInvoiceNumber: payment.Invoice?.InvoiceNumber ?? null,
184
+ updatedAt: parseXeroDate(payment.UpdatedDateUTC),
185
+ };
186
+ }
187
+
188
+ // Sales invoice payments changed since `since` (a Date). `cursor` is the
189
+ // latest change seen: pass it as `since` next time.
190
+ export async function readPayments(db, runtime, organisationId, { since }) {
191
+ return withXeroLane('reads', async () => {
192
+ const { client } = await runtime.clientFor(db, organisationId);
193
+ const payments = (await client.listPaymentsSince(since)).map(mapPayment).filter((p) => p.paymentRef && p.xeroInvoiceId);
194
+ const latest = payments.reduce((max, p) => (p.updatedAt && p.updatedAt > max ? p.updatedAt : max), since);
195
+ return { payments, cursor: latest };
196
+ });
197
+ }
198
+
199
+ async function readCursor(db, organisationId) {
200
+ const { rows } = await db.execute(sql`select payments_since from xero_payment_cursors where organisation_id = ${organisationId}`);
201
+ return rows[0]?.payments_since ?? null;
202
+ }
203
+
204
+ // Reads Xero payments since the stored cursor and records each on its job.
205
+ // A payment counts only when its Xero invoice ties to one job exactly: the
206
+ // Xero invoice Flow made from the job's Flow invoice (flow_invoice), or the
207
+ // one the ledger holds (accounting_ref). Before it records, Flow refreshes
208
+ // that job's invoice state from Xero, so the ledger takes the payment. Others
209
+ // are listed, not recorded. A deleted payment is listed for a person: the
210
+ // ledger keeps what it recorded.
211
+ export async function recordXeroPayments(db, runtime, organisationId, { actor, since: given = null } = {}) {
212
+ return runtime.serial(organisationId, async () => {
213
+ const since = given ?? await readCursor(db, organisationId)
214
+ ?? new Date(runtime.now() - PAYMENTS_FIRST_DAYS * 86_400_000);
215
+ const { payments, cursor } = await readPayments(db, runtime, organisationId, { since: new Date(since) });
216
+ const result = { since: new Date(since).toISOString(), cursor: cursor.toISOString(), recorded: [], unmatched: [], deleted: [], failed: [] };
217
+
218
+ const xeroIds = [...new Set(payments.map((p) => p.xeroInvoiceId))];
219
+ const byFlowInvoice = new Map();
220
+ if (xeroIds.length) {
221
+ const { rows } = await db.execute(sql`select xero_invoice_id, invoice_id from xero_invoice_pushes
222
+ where organisation_id = ${organisationId} and xero_invoice_id = any(${sql.param(xeroIds)}::text[])`);
223
+ for (const row of rows) byFlowInvoice.set(row.xero_invoice_id, row.invoice_id);
224
+ }
225
+ const flowInvoiceIds = [...new Set(byFlowInvoice.values())];
226
+ const obligations = xeroIds.length ? await db.select().from(billingObligations).where(and(
227
+ eq(billingObligations.organisationId, organisationId),
228
+ sql`(${flowInvoiceIds.length ? inArray(billingObligations.invoiceId, flowInvoiceIds) : sql`false`} or ${inArray(billingObligations.accountingRef, xeroIds)})`,
229
+ )) : [];
230
+
231
+ const refreshed = new Set();
232
+ for (const payment of payments) {
233
+ const shown = { paymentRef: payment.paymentRef, xeroInvoiceId: payment.xeroInvoiceId, amountCents: payment.amountCents, paidOn: payment.paidOn };
234
+ if (payment.status === 'DELETED') {
235
+ result.deleted.push(shown);
236
+ continue;
237
+ }
238
+ const flowInvoiceId = byFlowInvoice.get(payment.xeroInvoiceId);
239
+ const byFlow = flowInvoiceId ? obligations.filter((o) => o.invoiceId === flowInvoiceId) : [];
240
+ const byRef = obligations.filter((o) => o.accountingRef === payment.xeroInvoiceId);
241
+ const [obligation, matchedBy] = byFlow.length === 1 ? [byFlow[0], 'flow_invoice'] : byRef.length === 1 ? [byRef[0], 'accounting_ref'] : [null, null];
242
+ if (!obligation) {
243
+ result.unmatched.push(shown);
244
+ continue;
245
+ }
246
+ try {
247
+ if (!refreshed.has(obligation.id)) {
248
+ await recordXeroEvidence(db, runtime, organisationId, obligation, { actor });
249
+ refreshed.add(obligation.id);
250
+ }
251
+ const { paymentRef, paidOn, amountCents, reference, reconciled } = payment;
252
+ const accountingUrl = (await db.select({ url: billingObligations.accountingUrl }).from(billingObligations)
253
+ .where(eq(billingObligations.id, obligation.id)))[0]?.url ?? null;
254
+ await recordPayment(db, organisationId, obligation.id, { paymentRef, paidOn, amountCents, reference, reconciled, accountingUrl, matchedBy, actor });
255
+ result.recorded.push({ ...shown, obligationId: obligation.id, matchedBy });
256
+ } catch (error) {
257
+ result.failed.push({ ...shown, obligationId: obligation.id, error: { code: error.code ?? 'failed', message: error.message } });
258
+ }
259
+ }
260
+
261
+ // Move the cursor only when every payment was settled one way or another,
262
+ // so a failed one is read again next time.
263
+ if (!result.failed.length) {
264
+ await db.execute(sql`insert into xero_payment_cursors (organisation_id, payments_since) values (${organisationId}, ${cursor})
265
+ on conflict (organisation_id) do update set payments_since = excluded.payments_since, updated_at = now()`);
266
+ }
267
+ return result;
268
+ });
269
+ }
@@ -0,0 +1,17 @@
1
+ -- The Xero connection of each Flow organisation. The refresh token is
2
+ -- AES-256-GCM ciphertext (XERO_TOKEN_KEY); the plain token is never stored.
3
+ CREATE TABLE "xero_connections" (
4
+ "organisation_id" uuid PRIMARY KEY NOT NULL REFERENCES "organisations"("id"),
5
+ "mode" text NOT NULL,
6
+ "tenant_id" text NOT NULL,
7
+ "tenant_name" text,
8
+ "scope" text,
9
+ "refresh_token_enc" text NOT NULL,
10
+ "connected_by" uuid REFERENCES "people"("id") ON DELETE SET NULL,
11
+ "connected_at" timestamp with time zone DEFAULT now() NOT NULL,
12
+ "refreshed_at" timestamp with time zone,
13
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL,
14
+ CONSTRAINT "xero_connections_tenant" UNIQUE("tenant_id"),
15
+ CONSTRAINT "xero_connections_mode_valid" CHECK ("mode" in ('live', 'sandbox', 'replay')),
16
+ CONSTRAINT "xero_connections_token_sealed" CHECK ("refresh_token_enc" like 'v1:%')
17
+ );
@@ -0,0 +1,49 @@
1
+ -- Flow's copy of the Xero contacts and items of each connected organisation,
2
+ -- and a record of each sync. Xero owns these records; a sync replaces the copy.
3
+ CREATE TABLE "xero_contacts" (
4
+ "organisation_id" uuid NOT NULL REFERENCES "organisations"("id"),
5
+ "xero_contact_id" text NOT NULL,
6
+ "name" text NOT NULL,
7
+ "email" text,
8
+ "status" text NOT NULL,
9
+ "is_customer" boolean DEFAULT false NOT NULL,
10
+ "is_supplier" boolean DEFAULT false NOT NULL,
11
+ "sales_tracking" jsonb DEFAULT '[]'::jsonb NOT NULL,
12
+ "xero_updated_at" timestamp with time zone,
13
+ "synced_at" timestamp with time zone DEFAULT now() NOT NULL,
14
+ CONSTRAINT "xero_contacts_pk" PRIMARY KEY("organisation_id", "xero_contact_id")
15
+ );
16
+ --> statement-breakpoint
17
+ CREATE INDEX "xero_contacts_org_name" ON "xero_contacts" ("organisation_id", lower("name"));
18
+ --> statement-breakpoint
19
+ CREATE TABLE "xero_items" (
20
+ "organisation_id" uuid NOT NULL REFERENCES "organisations"("id"),
21
+ "xero_item_id" text NOT NULL,
22
+ "code" text NOT NULL,
23
+ "name" text,
24
+ "description" text,
25
+ "is_sold" boolean DEFAULT true NOT NULL,
26
+ "sales_unit_amount_cents" integer,
27
+ "sales_account_code" text,
28
+ "sales_tax_type" text,
29
+ "xero_updated_at" timestamp with time zone,
30
+ "synced_at" timestamp with time zone DEFAULT now() NOT NULL,
31
+ CONSTRAINT "xero_items_pk" PRIMARY KEY("organisation_id", "xero_item_id"),
32
+ CONSTRAINT "xero_items_org_code" UNIQUE("organisation_id", "code")
33
+ );
34
+ --> statement-breakpoint
35
+ CREATE TABLE "xero_sync_runs" (
36
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
37
+ "organisation_id" uuid NOT NULL REFERENCES "organisations"("id"),
38
+ "status" text DEFAULT 'running' NOT NULL,
39
+ "started_by" uuid REFERENCES "people"("id") ON DELETE SET NULL,
40
+ "started_at" timestamp with time zone DEFAULT now() NOT NULL,
41
+ "finished_at" timestamp with time zone,
42
+ "contact_count" integer,
43
+ "item_count" integer,
44
+ "error_code" text,
45
+ "error_message" text,
46
+ CONSTRAINT "xero_sync_runs_status_valid" CHECK ("status" in ('running', 'succeeded', 'failed'))
47
+ );
48
+ --> statement-breakpoint
49
+ CREATE INDEX "xero_sync_runs_org_started" ON "xero_sync_runs" ("organisation_id", "started_at");