@arcforgelabs/flow 2026.10.18 → 2026.10.19-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,525 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { and, eq, inArray, sql } from 'drizzle-orm';
3
+ import { getInvoice, listInvoiceVersions } from '../../core/invoices.js';
4
+ import { contacts as flowContacts, priceItems } from '../../db/schema.js';
5
+ import { itemsByCode, lastSuccessfulSyncAt, matchContact, normaliseName } from './catalogue.js';
6
+ import { xeroError } from './client.js';
7
+ import { withXeroLane } from './governor.js';
8
+
9
+ // Issued Flow invoices become Xero draft invoices (ACCREC, DRAFT). The lab
10
+ // checks and approves them in Xero.
11
+ //
12
+ // The rules, as in the earlier codebase:
13
+ // - What Flow sends is the issued version's document, exactly. A sent
14
+ // version is never changed, here or in Xero: Flow never updates a Xero
15
+ // invoice. A revised invoice (version 2) goes to Xero only after the
16
+ // earlier version's Xero invoice is deleted or voided there.
17
+ // - Flow never creates a Xero contact. The customer must match one Xero
18
+ // contact by name (the synced copy, catalogue.js).
19
+ // - Before it creates, Flow asks Xero for invoices with the same reference.
20
+ // One for the same contact and total is the same work: Flow records it and
21
+ // creates nothing. One for the same contact at another total within 90
22
+ // days is held for a person, unless they chose to send it anyway.
23
+ // - The request, with its Idempotency-Key, is stored before it leaves. An
24
+ // uncertain outcome (timeout, 5xx) is resolved later: within Xero's
25
+ // six-minute idempotency window Flow repeats the same request with the same
26
+ // key; after it Flow looks for the draft in Xero, and sends again only when
27
+ // it has shown that no matching draft exists.
28
+ // - After a create Flow reads the draft back and compares it with the
29
+ // document. A difference is recorded as `mismatch` for a person to check.
30
+
31
+ export const IDEMPOTENCY_WINDOW_MS = 6 * 60 * 1000;
32
+ export const SAME_REFERENCE_WINDOW_DAYS = 90;
33
+ export const MAX_BATCH = 100;
34
+
35
+ const SETTLED = new Set(['created', 'existing']);
36
+ const GONE = new Set(['DELETED', 'VOIDED']);
37
+ const cents = (amount) => (typeof amount === 'number' && Number.isFinite(amount) ? Math.round(amount * 100) : null);
38
+ const amount = (value) => value / 100;
39
+
40
+ function addDays(date, days) {
41
+ const at = new Date(`${date}T00:00:00Z`);
42
+ at.setUTCDate(at.getUTCDate() + days);
43
+ return at.toISOString().slice(0, 10);
44
+ }
45
+
46
+ function xeroDate(value) {
47
+ const legacy = /^\/Date\((-?\d+)/.exec(String(value ?? ''));
48
+ if (legacy) return new Date(Number(legacy[1])).toISOString().slice(0, 10);
49
+ return /^\d{4}-\d{2}-\d{2}/.test(String(value ?? '')) ? String(value).slice(0, 10) : null;
50
+ }
51
+
52
+ const problem = (code, message, extra = {}) => ({ code, message, ...extra });
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // The document to send
56
+
57
+ // The document a preview or a push works from: the latest issued version, or
58
+ // for a draft that was never issued, what issuing it now would store.
59
+ async function documentFor(db, organisationId, invoiceId) {
60
+ const invoice = await getInvoice(db, organisationId, invoiceId);
61
+ if (invoice.status !== 'draft') {
62
+ const latest = (await listInvoiceVersions(db, organisationId, invoiceId)).at(-1);
63
+ return { invoice, version: latest.version, document: latest.document };
64
+ }
65
+ const [contact] = await db.select().from(flowContacts).where(eq(flowContacts.id, invoice.contactId));
66
+ const ids = invoice.lines.map((l) => l.priceItemId).filter(Boolean);
67
+ const codes = new Map(ids.length
68
+ ? (await db.select({ id: priceItems.id, code: priceItems.code }).from(priceItems)
69
+ .where(and(eq(priceItems.organisationId, organisationId), inArray(priceItems.id, ids)))).map((r) => [r.id, r.code])
70
+ : []);
71
+ return {
72
+ invoice,
73
+ version: null,
74
+ document: {
75
+ number: invoice.number,
76
+ version: invoice.issuedVersion + 1,
77
+ issueDate: null,
78
+ dueDate: invoice.dueDate,
79
+ reference: invoice.reference,
80
+ currency: invoice.currency,
81
+ contact: { id: contact.id, name: contact.name, email: contact.email },
82
+ lines: invoice.lines.map((l) => ({
83
+ position: l.position, itemCode: codes.get(l.priceItemId) ?? null, description: l.description, quantity: l.quantity,
84
+ unitAmountCents: l.unitAmountCents, taxCode: l.taxCode, lineAmountCents: l.lineAmountCents, taxCents: l.taxCents,
85
+ })),
86
+ subtotalCents: invoice.subtotalCents,
87
+ taxCents: invoice.taxCents,
88
+ totalCents: invoice.totalCents,
89
+ },
90
+ };
91
+ }
92
+
93
+ export function xeroReference(document) {
94
+ return document.reference || document.number || null;
95
+ }
96
+
97
+ // The tracking for a contact's lines: the contact's own sales tracking option
98
+ // in the configured category, or the configured option.
99
+ function trackingFor(settings, contact) {
100
+ if (!settings.tracking) return null;
101
+ const own = (contact?.salesTracking ?? []).find((t) => normaliseName(t.category) === normaliseName(settings.tracking.category));
102
+ return { Name: settings.tracking.category, Option: own?.option || settings.tracking.option };
103
+ }
104
+
105
+ // The Xero draft for a document, and what stops it.
106
+ export function buildDraft(document, { settings, contact, items }) {
107
+ const problems = [];
108
+ const tracking = trackingFor(settings, contact);
109
+ const lines = document.lines.map((line) => {
110
+ const out = {
111
+ Description: line.description,
112
+ Quantity: line.quantity,
113
+ UnitAmount: amount(line.unitAmountCents),
114
+ LineAmount: amount(line.lineAmountCents),
115
+ TaxType: settings.taxTypes[line.taxCode],
116
+ };
117
+ if (line.itemCode) {
118
+ if (items.has(line.itemCode)) out.ItemCode = line.itemCode;
119
+ else problems.push(problem('xero_item_missing', `Line ${line.position}: item ${line.itemCode} is not in Xero. Add it in Xero, then sync.`, { itemCode: line.itemCode }));
120
+ }
121
+ if (tracking) out.Tracking = [tracking];
122
+ return out;
123
+ });
124
+ const issueDate = document.issueDate;
125
+ const draft = {
126
+ Type: 'ACCREC',
127
+ Status: 'DRAFT',
128
+ Contact: contact ? { ContactID: contact.xeroContactId } : { Name: document.contact.name },
129
+ ...(issueDate ? { Date: issueDate, DueDate: document.dueDate ?? addDays(issueDate, settings.defaultDueDays) } : {}),
130
+ Reference: xeroReference(document),
131
+ CurrencyCode: document.currency,
132
+ LineAmountTypes: 'Exclusive',
133
+ ...(settings.invoiceNumber === 'flow' && document.number ? { InvoiceNumber: document.number } : {}),
134
+ LineItems: lines,
135
+ };
136
+ return { draft, problems };
137
+ }
138
+
139
+ // What differs between a Xero invoice and the document Flow sent.
140
+ export function differences(xeroInvoice, draft, document) {
141
+ const found = [];
142
+ if (xeroInvoice?.Contact?.ContactID !== draft.Contact.ContactID) found.push('contact');
143
+ if (cents(xeroInvoice?.SubTotal) !== document.subtotalCents) found.push('subtotal');
144
+ if (cents(xeroInvoice?.TotalTax) !== document.taxCents) found.push('tax');
145
+ if (cents(xeroInvoice?.Total) !== document.totalCents) found.push('total');
146
+ if ((xeroInvoice?.LineItems ?? []).length !== draft.LineItems.length) found.push('line count');
147
+ if (normaliseName(xeroInvoice?.Reference) !== normaliseName(draft.Reference)) found.push('reference');
148
+ if (draft.Date && xeroDate(xeroInvoice?.DateString ?? xeroInvoice?.Date) !== draft.Date) found.push('date');
149
+ return found;
150
+ }
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // Stored pushes
154
+
155
+ function mapPush(row) {
156
+ if (!row) return null;
157
+ return {
158
+ id: row.id,
159
+ organisationId: row.organisation_id,
160
+ invoiceId: row.invoice_id,
161
+ version: row.version,
162
+ state: row.state,
163
+ idempotencyKey: row.idempotency_key,
164
+ requestBody: row.request_body,
165
+ bodyHash: row.body_hash,
166
+ preExistingIds: row.pre_existing_ids ?? [],
167
+ xero: row.xero_invoice_id ? { invoiceId: row.xero_invoice_id, number: row.xero_invoice_number, status: row.xero_status } : null,
168
+ verified: row.verified,
169
+ error: row.error_code ? { code: row.error_code, message: row.error_message } : null,
170
+ attempts: row.attempts,
171
+ pushedBy: row.pushed_by,
172
+ firstDispatchedAt: row.first_dispatched_at,
173
+ lastDispatchedAt: row.last_dispatched_at,
174
+ };
175
+ }
176
+
177
+ // A push as it may be shown: no request body.
178
+ export function describePush(push) {
179
+ if (!push) return null;
180
+ const { requestBody, bodyHash, idempotencyKey: key, organisationId, ...shown } = push;
181
+ return shown;
182
+ }
183
+
184
+ export async function listPushes(db, organisationId, invoiceId) {
185
+ const { rows } = await db.execute(sql`select * from xero_invoice_pushes
186
+ where organisation_id = ${organisationId} and invoice_id = ${invoiceId} order by version`);
187
+ return rows.map(mapPush);
188
+ }
189
+
190
+ async function settle(db, id, fields) {
191
+ const { rows } = await db.execute(sql`update xero_invoice_pushes set
192
+ state = ${fields.state},
193
+ xero_invoice_id = coalesce(${fields.xeroInvoiceId ?? null}, xero_invoice_id),
194
+ xero_invoice_number = coalesce(${fields.xeroNumber ?? null}, xero_invoice_number),
195
+ xero_status = coalesce(${fields.xeroStatus ?? null}, xero_status),
196
+ verified = ${fields.verified ?? false},
197
+ error_code = ${fields.errorCode ?? null},
198
+ error_message = ${fields.errorMessage ?? null},
199
+ updated_at = now()
200
+ where id = ${id} returning *`);
201
+ return mapPush(rows[0]);
202
+ }
203
+
204
+ function idempotencyKey(organisationId, invoiceId, version, attempt, bodyHash) {
205
+ const digest = createHash('sha256').update([organisationId, invoiceId, version, attempt, bodyHash].join('\u0000')).digest('hex');
206
+ return `flow-xero-${digest.slice(0, 48)}`;
207
+ }
208
+
209
+ // ---------------------------------------------------------------------------
210
+ // Preview: no Xero calls. It reads Flow's copy of Xero contacts and items and
211
+ // the stored pushes, so it costs none of the day's budget.
212
+
213
+ export async function previewDrafts(db, runtime, organisationId, invoiceIds) {
214
+ if (!Array.isArray(invoiceIds) || !invoiceIds.length || invoiceIds.length > MAX_BATCH) {
215
+ throw xeroError('invalid', `invoiceIds must list 1 to ${MAX_BATCH} invoices`, { status: 400 });
216
+ }
217
+ const syncedAt = await lastSuccessfulSyncAt(db, organisationId);
218
+ const warnings = [];
219
+ if (!syncedAt) warnings.push(problem('xero_not_synced', 'Xero contacts and items were never copied to Flow. Sync first.'));
220
+ else if (runtime.now() - new Date(syncedAt).getTime() > 24 * 60 * 60 * 1000) {
221
+ warnings.push(problem('xero_sync_stale', `Xero contacts and items were last copied ${new Date(syncedAt).toISOString().slice(0, 10)}. Sync for current names and items.`));
222
+ }
223
+ const results = [];
224
+ for (const invoiceId of invoiceIds) {
225
+ try {
226
+ results.push(await previewOne(db, runtime, organisationId, invoiceId));
227
+ } catch (error) {
228
+ results.push({ invoiceId, state: 'blocked', problems: [problem(error.code ?? 'xero_preview_failed', error.message)] });
229
+ }
230
+ }
231
+ return { syncedAt, warnings, invoices: results };
232
+ }
233
+
234
+ async function previewOne(db, runtime, organisationId, invoiceId) {
235
+ const { invoice, version, document } = await documentFor(db, organisationId, invoiceId);
236
+ const problems = [];
237
+ if (invoice.status === 'void') problems.push(problem('invoice_void', 'This invoice is void. Flow does not send it to Xero.'));
238
+ if (invoice.status === 'draft') problems.push(problem('invoice_not_issued', 'This invoice is a draft. Issue it, then send it to Xero.'));
239
+ const match = await matchContact(db, organisationId, document.contact.name);
240
+ if (match.state === 'missing') problems.push(problem('xero_contact_missing', `No Xero contact is named "${document.contact.name}". Add it in Xero (Flow never creates Xero contacts), then sync.`));
241
+ if (match.state === 'ambiguous') problems.push(problem('xero_contact_ambiguous', `More than one Xero contact is named "${document.contact.name}". Merge or rename them in Xero, then sync.`));
242
+ const items = await itemsByCode(db, organisationId, document.lines.map((l) => l.itemCode));
243
+ const { draft, problems: lineProblems } = buildDraft(document, { settings: runtime.settings, contact: match.contact, items });
244
+ problems.push(...lineProblems);
245
+
246
+ const pushes = await listPushes(db, organisationId, invoiceId);
247
+ const current = version ? pushes.find((p) => p.version === version) : null;
248
+ const earlier = pushes.filter((p) => version && p.version < version);
249
+ if (earlier.length) {
250
+ const shown = earlier.at(-1);
251
+ problems.push(problem('xero_earlier_version_in_xero',
252
+ `Version ${shown.version} of this invoice is in Xero${shown.xero?.number ? ` as ${shown.xero.number}` : ''}. Flow never changes it: delete or void it in Xero, then send this version.`,
253
+ { blocking: false, xero: shown.xero }));
254
+ }
255
+ let state = problems.some((p) => p.blocking !== false) ? 'blocked' : 'ready';
256
+ if (current && SETTLED.has(current.state)) state = 'in_xero';
257
+ else if (current) state = current.state; // dispatching, ambiguous or mismatch
258
+ return {
259
+ invoiceId,
260
+ number: document.number,
261
+ version,
262
+ state,
263
+ problems,
264
+ contact: { state: match.state, xeroContactId: match.contact?.xeroContactId ?? null, name: match.contact?.name ?? null },
265
+ totalCents: document.totalCents,
266
+ draft,
267
+ push: describePush(current),
268
+ };
269
+ }
270
+
271
+ // ---------------------------------------------------------------------------
272
+ // Push
273
+
274
+ // Sends issued invoices to Xero as drafts, one at a time, on the writes lane.
275
+ // `allowExistingReference` lists invoice ids a person chose to send although
276
+ // Xero holds an invoice with the same reference.
277
+ export async function pushDrafts(db, runtime, organisationId, invoiceIds, { pushedBy = null, allowExistingReference = [] } = {}) {
278
+ if (!Array.isArray(invoiceIds) || !invoiceIds.length || invoiceIds.length > MAX_BATCH) {
279
+ throw xeroError('invalid', `invoiceIds must list 1 to ${MAX_BATCH} invoices`, { status: 400 });
280
+ }
281
+ runtime.assertWritesAllowed();
282
+ return runtime.serial(organisationId, () => withXeroLane('writes', async () => {
283
+ const { client } = await runtime.clientFor(db, organisationId);
284
+ await runtime.assertEnvironment(client);
285
+ if (runtime.settings.tracking) await assertTracking(client, runtime.settings.tracking);
286
+ const allowed = new Set(allowExistingReference);
287
+ const results = [];
288
+ for (const invoiceId of invoiceIds) {
289
+ try {
290
+ results.push(await pushOne(db, runtime, client, organisationId, invoiceId, { pushedBy, allowExisting: allowed.has(invoiceId) }));
291
+ } catch (error) {
292
+ if (error.code === 'xero_rate_limited' || error.code === 'xero_reconnect_needed') {
293
+ // Every later invoice would fail the same way: stop and say so.
294
+ results.push(failed(invoiceId, error));
295
+ for (const rest of invoiceIds.slice(invoiceIds.indexOf(invoiceId) + 1)) results.push(failed(rest, error, 'not_tried'));
296
+ break;
297
+ }
298
+ results.push(failed(invoiceId, error));
299
+ }
300
+ }
301
+ return summarise(results);
302
+ }));
303
+ }
304
+
305
+ function failed(invoiceId, error, outcome = 'failed') {
306
+ return { invoiceId, outcome, error: { code: error.code ?? 'xero_push_failed', message: error.message, ...(error.details ? { details: error.details } : {}) } };
307
+ }
308
+
309
+ function summarise(results) {
310
+ const count = (outcome) => results.filter((r) => r.outcome === outcome).length;
311
+ return {
312
+ summary: {
313
+ created: count('created') + count('reconciled'),
314
+ existing: count('existing'),
315
+ alreadyInXero: count('already_in_xero'),
316
+ mismatched: count('mismatch'),
317
+ ambiguous: count('ambiguous'),
318
+ failed: count('failed') + count('not_tried'),
319
+ },
320
+ invoices: results,
321
+ };
322
+ }
323
+
324
+ async function assertTracking(client, tracking) {
325
+ const categories = await client.getTrackingCategories();
326
+ const category = categories.find((c) => normaliseName(c.Name) === normaliseName(tracking.category) && c.Status !== 'ARCHIVED');
327
+ if (!category) throw xeroError('xero_tracking_missing', `Xero has no tracking category "${tracking.category}" (XERO_TRACKING_CATEGORY).`, { status: 409 });
328
+ if (!(category.Options ?? []).some((o) => normaliseName(o.Name) === normaliseName(tracking.option) && o.Status !== 'ARCHIVED')) {
329
+ throw xeroError('xero_tracking_missing', `Xero tracking category "${tracking.category}" has no option "${tracking.option}" (XERO_TRACKING_OPTION).`, { status: 409 });
330
+ }
331
+ }
332
+
333
+ // Xero errors that prove no invoice was made: Xero refused the request.
334
+ function provesNothingCreated(error) {
335
+ return error.code === 'xero_validation_failed' || error.code === 'xero_rate_limited'
336
+ || error.code === 'xero_unauthorized' || error.code === 'xero_reconnect_needed'
337
+ || (error.code === 'xero_api_error' && error.status >= 400 && error.status < 500);
338
+ }
339
+
340
+ const shownXero = (inv) => ({ invoiceId: inv.InvoiceID, number: inv.InvoiceNumber ?? null, status: inv.Status ?? null });
341
+
342
+ async function pushOne(db, runtime, client, organisationId, invoiceId, { pushedBy, allowExisting }) {
343
+ const { invoice, version, document } = await documentFor(db, organisationId, invoiceId);
344
+ if (invoice.status === 'void') throw xeroError('invoice_void', 'This invoice is void. Flow does not send it to Xero.', { status: 409 });
345
+ if (invoice.status !== 'issued') throw xeroError('invoice_not_issued', 'Only an issued invoice goes to Xero. Issue it first.', { status: 409 });
346
+
347
+ const pushes = await listPushes(db, organisationId, invoiceId);
348
+ const current = pushes.find((p) => p.version === version);
349
+ if (current && SETTLED.has(current.state)) {
350
+ return { invoiceId, version, outcome: 'already_in_xero', xero: current.xero };
351
+ }
352
+ if (current?.state === 'mismatch') {
353
+ return { invoiceId, version, outcome: 'mismatch', xero: current.xero, error: current.error };
354
+ }
355
+ if (current) return resume(db, runtime, client, current, document);
356
+
357
+ // A revision goes to Xero only once each earlier version's Xero invoice is
358
+ // gone, because Flow never changes an invoice Xero already has.
359
+ for (const earlier of pushes.filter((p) => p.version < version)) {
360
+ if (!earlier.xero) {
361
+ throw xeroError('xero_earlier_version_unresolved', `Version ${earlier.version} of this invoice may be in Xero; push it again to settle that first.`, { status: 409 });
362
+ }
363
+ const live = await client.getInvoice(earlier.xero.invoiceId);
364
+ if (live && !GONE.has(live.Status)) {
365
+ throw xeroError('xero_earlier_version_in_xero',
366
+ `Version ${earlier.version} of this invoice is in Xero as ${live.InvoiceNumber || 'a draft'} (${live.Status}). Flow never changes it: delete or void it in Xero, then send this version.`,
367
+ { status: 409, details: { xero: shownXero(live) } });
368
+ }
369
+ }
370
+
371
+ const match = await matchContact(db, organisationId, document.contact.name);
372
+ if (match.state !== 'matched') {
373
+ throw xeroError(match.state === 'missing' ? 'xero_contact_missing' : 'xero_contact_ambiguous',
374
+ match.state === 'missing'
375
+ ? `No Xero contact is named "${document.contact.name}". Add it in Xero (Flow never creates Xero contacts), then sync.`
376
+ : `More than one Xero contact is named "${document.contact.name}". Merge or rename them in Xero, then sync.`,
377
+ { status: 409 });
378
+ }
379
+ const items = await itemsByCode(db, organisationId, document.lines.map((l) => l.itemCode));
380
+ const { draft, problems } = buildDraft(document, { settings: runtime.settings, contact: match.contact, items });
381
+ if (problems.length) throw xeroError(problems[0].code, problems.map((p) => p.message).join(' '), { status: 409 });
382
+
383
+ // Live duplicate check: invoices in Xero with this reference, made by Flow
384
+ // or by hand.
385
+ const earlierIds = new Set(pushes.map((p) => p.xero?.invoiceId).filter(Boolean));
386
+ const sameReference = (await client.findInvoicesByReference(draft.Reference))
387
+ .filter((inv) => !GONE.has(inv.Status) && !earlierIds.has(inv.InvoiceID));
388
+ const sameContact = sameReference.filter((inv) => inv.Contact?.ContactID === match.contact.xeroContactId);
389
+ if (!allowExisting) {
390
+ const same = sameContact.find((inv) => cents(inv.Total) === document.totalCents);
391
+ if (same) return recordExisting(db, { organisationId, invoiceId, version, draft, same, pushedBy });
392
+ const recent = sameContact.find((inv) => {
393
+ const date = xeroDate(inv.DateString ?? inv.Date);
394
+ return date && Math.abs(runtime.now() - Date.parse(`${date}T00:00:00Z`)) <= SAME_REFERENCE_WINDOW_DAYS * 86_400_000;
395
+ });
396
+ if (recent) {
397
+ throw xeroError('xero_same_reference_different_amount',
398
+ `Possible duplicate: Xero ${recent.InvoiceNumber || 'invoice'} (${recent.Status}, ${(cents(recent.Total) / 100).toFixed(2)}) has this contact and reference "${draft.Reference}" at another amount. Nothing was sent. Check it, then send again choosing to send anyway.`,
399
+ { status: 409, details: { xero: shownXero(recent) } });
400
+ }
401
+ }
402
+
403
+ const body = JSON.stringify(draft);
404
+ const bodyHash = createHash('sha256').update(body).digest('hex');
405
+ const key = idempotencyKey(organisationId, invoiceId, version, 1, bodyHash);
406
+ const preExisting = sameReference.map((inv) => inv.InvoiceID);
407
+ const { rows } = await db.execute(sql`
408
+ insert into xero_invoice_pushes (organisation_id, invoice_id, version, state, idempotency_key, request_body, body_hash, pre_existing_ids, pushed_by,
409
+ first_dispatched_at, last_dispatched_at)
410
+ values (${organisationId}, ${invoiceId}, ${version}, 'dispatching', ${key}, ${body}, ${bodyHash}, ${JSON.stringify(preExisting)}::jsonb, ${pushedBy},
411
+ ${new Date(runtime.now())}, ${new Date(runtime.now())})
412
+ on conflict (invoice_id, version) do nothing returning *`);
413
+ if (!rows.length) throw xeroError('xero_push_in_progress', 'This invoice is being sent to Xero already. Check again shortly.', { status: 409 });
414
+ return dispatch(db, client, mapPush(rows[0]), document, { fresh: true });
415
+ }
416
+
417
+ async function recordExisting(db, { organisationId, invoiceId, version, draft, same, pushedBy }) {
418
+ const body = JSON.stringify(draft);
419
+ const bodyHash = createHash('sha256').update(body).digest('hex');
420
+ await db.execute(sql`
421
+ insert into xero_invoice_pushes (organisation_id, invoice_id, version, state, idempotency_key, request_body, body_hash, pushed_by,
422
+ xero_invoice_id, xero_invoice_number, xero_status, verified)
423
+ values (${organisationId}, ${invoiceId}, ${version}, 'existing', ${idempotencyKey(organisationId, invoiceId, version, 0, bodyHash)},
424
+ ${body}, ${bodyHash}, ${pushedBy}, ${same.InvoiceID}, ${same.InvoiceNumber ?? null}, ${same.Status ?? null}, false)
425
+ on conflict (invoice_id, version) do nothing`);
426
+ return { invoiceId, version, outcome: 'existing', xero: shownXero(same) };
427
+ }
428
+
429
+ // Sends the stored request once and settles the push from the answer.
430
+ // `fresh`: this push's first request. If Xero refuses that one, nothing was
431
+ // made and the attempt is forgotten, so a person can fix the cause and send
432
+ // again. A refusal of a repeated request proves nothing about the first one,
433
+ // so the push stays unsettled.
434
+ async function dispatch(db, client, push, document, { fresh = false } = {}) {
435
+ const draft = JSON.parse(push.requestBody);
436
+ let created;
437
+ try {
438
+ created = await client.createInvoice(draft, { idempotencyKey: push.idempotencyKey });
439
+ } catch (error) {
440
+ if (fresh && provesNothingCreated(error)) {
441
+ await db.execute(sql`delete from xero_invoice_pushes where id = ${push.id} and state = 'dispatching'`);
442
+ throw error;
443
+ }
444
+ await settle(db, push.id, { state: 'ambiguous', errorCode: error.code ?? 'xero_uncertain', errorMessage: error.message });
445
+ if (error.code === 'xero_rate_limited' || error.code === 'xero_reconnect_needed') throw error;
446
+ return {
447
+ invoiceId: push.invoiceId,
448
+ version: push.version,
449
+ outcome: 'ambiguous',
450
+ error: { code: 'xero_outcome_unknown', message: 'Xero did not say whether it made the draft. Send again: Flow repeats the same request within six minutes, and after that looks in Xero first, so no second invoice is made.' },
451
+ };
452
+ }
453
+ if (!created?.InvoiceID) {
454
+ await settle(db, push.id, { state: 'ambiguous', errorCode: 'xero_no_invoice_id', errorMessage: 'Xero answered without an invoice id.' });
455
+ return { invoiceId: push.invoiceId, version: push.version, outcome: 'ambiguous', error: { code: 'xero_outcome_unknown', message: 'Xero answered without an invoice id. Send again to settle it.' } };
456
+ }
457
+ return verify(db, client, push, draft, document, created, 'created');
458
+ }
459
+
460
+ // Reads the draft back and records whether Xero holds what Flow sent.
461
+ async function verify(db, client, push, draft, document, created, outcome) {
462
+ let live = null;
463
+ try {
464
+ live = await client.getInvoice(created.InvoiceID);
465
+ } catch {
466
+ live = null;
467
+ }
468
+ if (!live) {
469
+ const settled = await settle(db, push.id, {
470
+ state: 'created', xeroInvoiceId: created.InvoiceID, xeroNumber: created.InvoiceNumber, xeroStatus: created.Status, verified: false,
471
+ errorCode: 'xero_not_verified', errorMessage: 'Xero made the draft, but Flow could not read it back to check it.',
472
+ });
473
+ return { invoiceId: push.invoiceId, version: push.version, outcome, verified: false, xero: settled.xero };
474
+ }
475
+ const found = differences(live, draft, document);
476
+ if (found.length) {
477
+ const settled = await settle(db, push.id, {
478
+ state: 'mismatch', xeroInvoiceId: live.InvoiceID, xeroNumber: live.InvoiceNumber, xeroStatus: live.Status,
479
+ errorCode: 'xero_draft_differs', errorMessage: `The Xero draft differs from the Flow invoice in: ${found.join(', ')}. Check it in Xero.`,
480
+ });
481
+ return { invoiceId: push.invoiceId, version: push.version, outcome: 'mismatch', xero: settled.xero, error: settled.error };
482
+ }
483
+ const settled = await settle(db, push.id, { state: 'created', xeroInvoiceId: live.InvoiceID, xeroNumber: live.InvoiceNumber, xeroStatus: live.Status, verified: true });
484
+ return { invoiceId: push.invoiceId, version: push.version, outcome, verified: true, xero: settled.xero };
485
+ }
486
+
487
+ // An earlier attempt whose outcome is unknown.
488
+ async function resume(db, runtime, client, push, document) {
489
+ const draft = JSON.parse(push.requestBody);
490
+ // last_dispatched_at is when the current key was first sent.
491
+ const sinceKey = runtime.now() - new Date(push.lastDispatchedAt).getTime();
492
+ if (sinceKey < IDEMPOTENCY_WINDOW_MS) {
493
+ // Inside Xero's window the same key returns the first result, if there
494
+ // was one, instead of making a second invoice.
495
+ await db.execute(sql`update xero_invoice_pushes set attempts = attempts + 1, updated_at = now() where id = ${push.id}`);
496
+ return dispatch(db, client, push, document);
497
+ }
498
+ // Outside it, look for the draft first. Only proof that none exists allows
499
+ // a new key.
500
+ let candidates;
501
+ try {
502
+ candidates = (await client.findInvoicesByReference(draft.Reference))
503
+ .filter((inv) => !GONE.has(inv.Status) && !push.preExistingIds.includes(inv.InvoiceID)
504
+ && differences(inv, draft, document).filter((d) => d !== 'date').length === 0);
505
+ } catch (error) {
506
+ if (error.code === 'xero_rate_limited') throw error;
507
+ return { invoiceId: push.invoiceId, version: push.version, outcome: 'ambiguous', error: { code: 'xero_outcome_unknown', message: 'Flow could not look in Xero to settle an earlier attempt. Send again later.' } };
508
+ }
509
+ if (candidates.length === 1) return verify(db, client, push, draft, document, candidates[0], 'reconciled');
510
+ if (candidates.length > 1) {
511
+ return { invoiceId: push.invoiceId, version: push.version, outcome: 'ambiguous', error: { code: 'xero_outcome_unknown', message: `Xero holds ${candidates.length} drafts that match this invoice. Delete the extra ones in Xero, then send again.` } };
512
+ }
513
+ const attempt = push.attempts + 1;
514
+ const key = idempotencyKey(push.organisationId, push.invoiceId, push.version, attempt, push.bodyHash);
515
+ const { rows } = await db.execute(sql`update xero_invoice_pushes set idempotency_key = ${key}, attempts = ${attempt},
516
+ state = 'dispatching', last_dispatched_at = ${new Date(runtime.now())}, updated_at = now()
517
+ where id = ${push.id} returning *`);
518
+ return dispatch(db, client, mapPush(rows[0]), document);
519
+ }
520
+
521
+ // The port's pushInvoice: one issued invoice.
522
+ export async function pushInvoice(db, runtime, { organisationId, invoiceId, pushedBy = null }) {
523
+ const { invoices: [result] } = await pushDrafts(db, runtime, organisationId, [invoiceId], { pushedBy });
524
+ return result;
525
+ }
@@ -0,0 +1,45 @@
1
+ {
2
+ "description": "A synthetic Xero Demo Company for XERO_MODE=replay. Every name, number and id here is made up.",
3
+ "interactions": [
4
+ {
5
+ "repeat": true,
6
+ "request": { "method": "GET", "url": "https://api.xero.com/api.xro/2.0/Organisation" },
7
+ "response": {
8
+ "headers": { "x-daylimit-remaining": "990", "x-minlimit-remaining": "59", "x-appminlimit-remaining": "9999" },
9
+ "body": { "Organisations": [{ "OrganisationID": "00000000-0000-4000-8000-0000000000a1", "Name": "Example Lab (Demo)", "Class": "DEMO", "CountryCode": "AU", "BaseCurrency": "AUD" }] }
10
+ }
11
+ },
12
+ {
13
+ "repeat": true,
14
+ "request": { "method": "GET", "url": "https://api.xero.com/api.xro/2.0/Contacts" },
15
+ "response": {
16
+ "body": { "Contacts": [
17
+ { "ContactID": "00000000-0000-4000-8000-0000000000c1", "Name": "Example Practice", "EmailAddress": "accounts@example.test", "ContactStatus": "ACTIVE", "IsCustomer": true, "IsSupplier": false, "UpdatedDateUTC": "/Date(1792000000000+0000)/" },
18
+ { "ContactID": "00000000-0000-4000-8000-0000000000c2", "Name": "Another Practice", "ContactStatus": "ACTIVE", "IsCustomer": true, "IsSupplier": false, "UpdatedDateUTC": "/Date(1792000000000+0000)/" },
19
+ { "ContactID": "00000000-0000-4000-8000-0000000000c3", "Name": "Example Supplier", "ContactStatus": "ACTIVE", "IsCustomer": false, "IsSupplier": true, "UpdatedDateUTC": "/Date(1792000000000+0000)/" }
20
+ ] }
21
+ }
22
+ },
23
+ {
24
+ "repeat": true,
25
+ "request": { "method": "GET", "url": "https://api.xero.com/api.xro/2.0/Items" },
26
+ "response": {
27
+ "body": { "Items": [
28
+ { "ItemID": "00000000-0000-4000-8000-0000000000e1", "Code": "CROWN", "Name": "Crown", "Description": "Crown", "IsSold": true, "SalesDetails": { "UnitPrice": 250.0, "AccountCode": "200", "TaxType": "OUTPUT" }, "UpdatedDateUTC": "/Date(1792000000000+0000)/" },
29
+ { "ItemID": "00000000-0000-4000-8000-0000000000e2", "Code": "REPAIR", "Name": "Repair", "IsSold": true, "SalesDetails": { "UnitPrice": 80.0, "AccountCode": "200", "TaxType": "OUTPUT" }, "UpdatedDateUTC": "/Date(1792000000000+0000)/" }
30
+ ] }
31
+ }
32
+ },
33
+ {
34
+ "repeat": true,
35
+ "request": { "method": "GET", "url": "https://api.xero.com/api.xro/2.0/TrackingCategories" },
36
+ "response": {
37
+ "body": { "TrackingCategories": [
38
+ { "TrackingCategoryID": "00000000-0000-4000-8000-0000000000f1", "Name": "Department", "Status": "ACTIVE", "Options": [
39
+ { "TrackingOptionID": "00000000-0000-4000-8000-0000000000f2", "Name": "Laboratory", "Status": "ACTIVE" }
40
+ ] }
41
+ ] }
42
+ }
43
+ }
44
+ ]
45
+ }