@arcforgelabs/flow 2026.10.22 → 2026.10.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/app.js +7 -0
- package/server/core/links.js +14 -1
- package/server/core/secrets.js +74 -0
- package/server/db/migrations/0006_prior_links.sql +18 -0
- package/server/db/migrations/0007_integration_settings.sql +23 -0
- package/server/db/migrations/meta/0006_snapshot.json +2306 -0
- package/server/db/migrations/meta/0007_snapshot.json +2473 -0
- package/server/db/migrations/meta/_journal.json +14 -0
- package/server/db/schema.js +57 -1
- package/server/import/at-start.js +69 -1
- package/server/index.js +28 -8
- package/server/integrations/index.js +9 -3
- package/server/integrations/port.js +101 -9
- package/server/integrations/seazona/index.js +25 -3
- package/server/integrations/setup.js +234 -0
- package/server/integrations/xero/client.js +13 -0
- package/server/integrations/xero/import.js +116 -0
- package/server/integrations/xero/index.js +45 -5
- package/server/integrations/xero/migrations/0004_xero_mirror.sql +96 -0
- package/server/integrations/xero/migrations/meta/_journal.json +7 -0
- package/server/integrations/xero/mirror.js +295 -0
- package/server/integrations/xero/routes.js +26 -1
- package/server/integrations/xero/settings.js +8 -0
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +142 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { sql } from 'drizzle-orm';
|
|
3
|
+
import { parseXeroDate } from './catalogue.js';
|
|
4
|
+
import { withXeroLane } from './governor.js';
|
|
5
|
+
|
|
6
|
+
// Flow's copy of the connected organisation's Xero invoices, credit notes and
|
|
7
|
+
// payments (docs/DATA_SPINE.md, issue 54). Xero owns these records. Flow keeps
|
|
8
|
+
// them so its reports cover the years before it was installed, and so the
|
|
9
|
+
// ledger and the identity map can link them without spending Xero calls.
|
|
10
|
+
//
|
|
11
|
+
// Each resource keeps a cursor. A read asks Xero for the records changed since
|
|
12
|
+
// the cursor, oldest change first, one page at a time, and stores each page in
|
|
13
|
+
// one transaction with the cursor's next position. So a read can stop at any
|
|
14
|
+
// page (the day's call budget, an error, a restart) and the next read goes on
|
|
15
|
+
// from there. The first read has no cursor and copies the whole history; every
|
|
16
|
+
// later read copies only changes.
|
|
17
|
+
//
|
|
18
|
+
// Paging is by time, not by page number: after a full page the cursor moves to
|
|
19
|
+
// the last record's change time (less one second), so a record that changes
|
|
20
|
+
// during a read moves ahead of the cursor and is read again, never skipped.
|
|
21
|
+
// Only when one second holds a whole page does the page number move on.
|
|
22
|
+
|
|
23
|
+
export const MIRROR_RESOURCES = Object.freeze(['invoices', 'credit_notes', 'payments']);
|
|
24
|
+
|
|
25
|
+
// A Xero amount (a JSON number with up to 4 decimals) to whole cents, half away
|
|
26
|
+
// from zero, without float drift.
|
|
27
|
+
export function toCents(amount) {
|
|
28
|
+
if (amount === null || amount === undefined || amount === '') return null;
|
|
29
|
+
const match = /^(-?)(\d+)(?:\.(\d+))?$/.exec(String(amount).trim());
|
|
30
|
+
if (!match) return null;
|
|
31
|
+
const [, sign, whole, fraction = ''] = match;
|
|
32
|
+
const digits = `${fraction}000`;
|
|
33
|
+
let cents = Number(whole) * 100 + Number(digits.slice(0, 2));
|
|
34
|
+
if (Number(digits[2]) >= 5) cents += 1;
|
|
35
|
+
return sign ? -cents : cents;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// A Xero date to a calendar date (YYYY-MM-DD), as Xero shows it.
|
|
39
|
+
function toDay(value) {
|
|
40
|
+
const at = parseXeroDate(value);
|
|
41
|
+
return at ? at.toISOString().slice(0, 10) : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const text = (value, keep) => (keep && value ? String(value) : null);
|
|
45
|
+
|
|
46
|
+
function mapLines(lines, keep) {
|
|
47
|
+
return (lines ?? []).map((line) => ({
|
|
48
|
+
lineItemId: line.LineItemID ?? null,
|
|
49
|
+
itemCode: line.ItemCode || null,
|
|
50
|
+
description: text(line.Description, keep),
|
|
51
|
+
quantity: line.Quantity ?? null,
|
|
52
|
+
unitAmount: line.UnitAmount ?? null,
|
|
53
|
+
lineAmountCents: toCents(line.LineAmount),
|
|
54
|
+
taxAmountCents: toCents(line.TaxAmount),
|
|
55
|
+
discountRate: line.DiscountRate ?? null,
|
|
56
|
+
accountCode: line.AccountCode || null,
|
|
57
|
+
taxType: line.TaxType || null,
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function mapInvoice(invoice, { keepReference = false } = {}) {
|
|
62
|
+
return {
|
|
63
|
+
xeroInvoiceId: invoice.InvoiceID,
|
|
64
|
+
type: invoice.Type,
|
|
65
|
+
invoiceNumber: invoice.InvoiceNumber || null,
|
|
66
|
+
reference: text(invoice.Reference, keepReference),
|
|
67
|
+
xeroContactId: invoice.Contact?.ContactID ?? null,
|
|
68
|
+
status: invoice.Status,
|
|
69
|
+
issuedOn: toDay(invoice.Date ?? invoice.DateString),
|
|
70
|
+
dueOn: toDay(invoice.DueDate ?? invoice.DueDateString),
|
|
71
|
+
fullyPaidOn: toDay(invoice.FullyPaidOnDate),
|
|
72
|
+
currency: invoice.CurrencyCode || null,
|
|
73
|
+
subtotalCents: toCents(invoice.SubTotal),
|
|
74
|
+
taxCents: toCents(invoice.TotalTax),
|
|
75
|
+
totalCents: toCents(invoice.Total),
|
|
76
|
+
amountDueCents: toCents(invoice.AmountDue),
|
|
77
|
+
amountPaidCents: toCents(invoice.AmountPaid),
|
|
78
|
+
amountCreditedCents: toCents(invoice.AmountCredited),
|
|
79
|
+
lines: mapLines(invoice.LineItems, keepReference),
|
|
80
|
+
xeroUpdatedAt: parseXeroDate(invoice.UpdatedDateUTC),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function mapCreditNote(note, { keepReference = false } = {}) {
|
|
85
|
+
return {
|
|
86
|
+
xeroCreditNoteId: note.CreditNoteID,
|
|
87
|
+
type: note.Type,
|
|
88
|
+
creditNoteNumber: note.CreditNoteNumber || null,
|
|
89
|
+
reference: text(note.Reference, keepReference),
|
|
90
|
+
xeroContactId: note.Contact?.ContactID ?? null,
|
|
91
|
+
status: note.Status,
|
|
92
|
+
issuedOn: toDay(note.Date ?? note.DateString),
|
|
93
|
+
currency: note.CurrencyCode || null,
|
|
94
|
+
subtotalCents: toCents(note.SubTotal),
|
|
95
|
+
taxCents: toCents(note.TotalTax),
|
|
96
|
+
totalCents: toCents(note.Total),
|
|
97
|
+
remainingCreditCents: toCents(note.RemainingCredit),
|
|
98
|
+
allocations: (note.Allocations ?? []).map((a) => ({
|
|
99
|
+
xeroInvoiceId: a.Invoice?.InvoiceID ?? null,
|
|
100
|
+
amountCents: toCents(a.Amount),
|
|
101
|
+
allocatedOn: toDay(a.Date),
|
|
102
|
+
})),
|
|
103
|
+
lines: mapLines(note.LineItems, keepReference),
|
|
104
|
+
xeroUpdatedAt: parseXeroDate(note.UpdatedDateUTC),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function mapPayment(payment, { keepReference = false } = {}) {
|
|
109
|
+
return {
|
|
110
|
+
xeroPaymentId: payment.PaymentID,
|
|
111
|
+
paymentType: payment.PaymentType || null,
|
|
112
|
+
status: payment.Status || 'AUTHORISED',
|
|
113
|
+
xeroInvoiceId: payment.Invoice?.InvoiceID ?? null,
|
|
114
|
+
xeroCreditNoteId: payment.CreditNote?.CreditNoteID ?? null,
|
|
115
|
+
paidOn: toDay(payment.Date),
|
|
116
|
+
amountCents: toCents(payment.Amount),
|
|
117
|
+
reference: text(payment.Reference, keepReference),
|
|
118
|
+
isReconciled: payment.IsReconciled ?? null,
|
|
119
|
+
xeroUpdatedAt: parseXeroDate(payment.UpdatedDateUTC),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// The hash covers what Flow keeps, so a change Flow does not keep (a field it
|
|
124
|
+
// drops) never counts as a change.
|
|
125
|
+
function hashOf(row) {
|
|
126
|
+
const { xeroUpdatedAt, ...content } = row;
|
|
127
|
+
return createHash('sha256').update(JSON.stringify(content)).digest('hex');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const json = (value) => JSON.stringify(value);
|
|
131
|
+
|
|
132
|
+
// Each upsert writes only when the content changed, and answers whether it did.
|
|
133
|
+
const UPSERT = {
|
|
134
|
+
invoices: (org, r, hash) => sql`
|
|
135
|
+
insert into xero_invoices (organisation_id, xero_invoice_id, type, invoice_number, reference, xero_contact_id, status,
|
|
136
|
+
issued_on, due_on, fully_paid_on, currency, subtotal_cents, tax_cents, total_cents, amount_due_cents,
|
|
137
|
+
amount_paid_cents, amount_credited_cents, lines, xero_updated_at, record_hash)
|
|
138
|
+
values (${org}, ${r.xeroInvoiceId}, ${r.type}, ${r.invoiceNumber}, ${r.reference}, ${r.xeroContactId}, ${r.status},
|
|
139
|
+
${r.issuedOn}, ${r.dueOn}, ${r.fullyPaidOn}, ${r.currency}, ${r.subtotalCents}, ${r.taxCents}, ${r.totalCents},
|
|
140
|
+
${r.amountDueCents}, ${r.amountPaidCents}, ${r.amountCreditedCents}, ${json(r.lines)}::jsonb, ${r.xeroUpdatedAt}, ${hash})
|
|
141
|
+
on conflict (organisation_id, xero_invoice_id) do update set
|
|
142
|
+
type = excluded.type, invoice_number = excluded.invoice_number, reference = excluded.reference,
|
|
143
|
+
xero_contact_id = excluded.xero_contact_id, status = excluded.status, issued_on = excluded.issued_on,
|
|
144
|
+
due_on = excluded.due_on, fully_paid_on = excluded.fully_paid_on, currency = excluded.currency,
|
|
145
|
+
subtotal_cents = excluded.subtotal_cents, tax_cents = excluded.tax_cents, total_cents = excluded.total_cents,
|
|
146
|
+
amount_due_cents = excluded.amount_due_cents, amount_paid_cents = excluded.amount_paid_cents,
|
|
147
|
+
amount_credited_cents = excluded.amount_credited_cents, lines = excluded.lines,
|
|
148
|
+
xero_updated_at = excluded.xero_updated_at, record_hash = excluded.record_hash, last_changed_at = now()
|
|
149
|
+
where xero_invoices.record_hash is distinct from excluded.record_hash
|
|
150
|
+
returning (xmax = 0) as inserted`,
|
|
151
|
+
credit_notes: (org, r, hash) => sql`
|
|
152
|
+
insert into xero_credit_notes (organisation_id, xero_credit_note_id, type, credit_note_number, reference, xero_contact_id,
|
|
153
|
+
status, issued_on, currency, subtotal_cents, tax_cents, total_cents, remaining_credit_cents, allocations, lines,
|
|
154
|
+
xero_updated_at, record_hash)
|
|
155
|
+
values (${org}, ${r.xeroCreditNoteId}, ${r.type}, ${r.creditNoteNumber}, ${r.reference}, ${r.xeroContactId}, ${r.status},
|
|
156
|
+
${r.issuedOn}, ${r.currency}, ${r.subtotalCents}, ${r.taxCents}, ${r.totalCents}, ${r.remainingCreditCents},
|
|
157
|
+
${json(r.allocations)}::jsonb, ${json(r.lines)}::jsonb, ${r.xeroUpdatedAt}, ${hash})
|
|
158
|
+
on conflict (organisation_id, xero_credit_note_id) do update set
|
|
159
|
+
type = excluded.type, credit_note_number = excluded.credit_note_number, reference = excluded.reference,
|
|
160
|
+
xero_contact_id = excluded.xero_contact_id, status = excluded.status, issued_on = excluded.issued_on,
|
|
161
|
+
currency = excluded.currency, subtotal_cents = excluded.subtotal_cents, tax_cents = excluded.tax_cents,
|
|
162
|
+
total_cents = excluded.total_cents, remaining_credit_cents = excluded.remaining_credit_cents,
|
|
163
|
+
allocations = excluded.allocations, lines = excluded.lines, xero_updated_at = excluded.xero_updated_at,
|
|
164
|
+
record_hash = excluded.record_hash, last_changed_at = now()
|
|
165
|
+
where xero_credit_notes.record_hash is distinct from excluded.record_hash
|
|
166
|
+
returning (xmax = 0) as inserted`,
|
|
167
|
+
payments: (org, r, hash) => sql`
|
|
168
|
+
insert into xero_payments (organisation_id, xero_payment_id, payment_type, status, xero_invoice_id, xero_credit_note_id,
|
|
169
|
+
paid_on, amount_cents, reference, is_reconciled, xero_updated_at, record_hash)
|
|
170
|
+
values (${org}, ${r.xeroPaymentId}, ${r.paymentType}, ${r.status}, ${r.xeroInvoiceId}, ${r.xeroCreditNoteId},
|
|
171
|
+
${r.paidOn}, ${r.amountCents}, ${r.reference}, ${r.isReconciled}, ${r.xeroUpdatedAt}, ${hash})
|
|
172
|
+
on conflict (organisation_id, xero_payment_id) do update set
|
|
173
|
+
payment_type = excluded.payment_type, status = excluded.status, xero_invoice_id = excluded.xero_invoice_id,
|
|
174
|
+
xero_credit_note_id = excluded.xero_credit_note_id, paid_on = excluded.paid_on, amount_cents = excluded.amount_cents,
|
|
175
|
+
reference = excluded.reference, is_reconciled = excluded.is_reconciled, xero_updated_at = excluded.xero_updated_at,
|
|
176
|
+
record_hash = excluded.record_hash, last_changed_at = now()
|
|
177
|
+
where xero_payments.record_hash is distinct from excluded.record_hash
|
|
178
|
+
returning (xmax = 0) as inserted`,
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const MAPPERS = { invoices: mapInvoice, credit_notes: mapCreditNote, payments: mapPayment };
|
|
182
|
+
const ID_OF = { invoices: 'xeroInvoiceId', credit_notes: 'xeroCreditNoteId', payments: 'xeroPaymentId' };
|
|
183
|
+
|
|
184
|
+
async function readCursor(db, organisationId, resource) {
|
|
185
|
+
await db.execute(sql`insert into xero_mirror_cursors (organisation_id, resource) values (${organisationId}, ${resource})
|
|
186
|
+
on conflict do nothing`);
|
|
187
|
+
const { rows: [row] } = await db.execute(sql`select * from xero_mirror_cursors
|
|
188
|
+
where organisation_id = ${organisationId} and resource = ${resource}`);
|
|
189
|
+
return { modifiedSince: row.modified_since ? new Date(row.modified_since) : null, page: row.page };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Where the next page starts, from the page just read. A full page moves the
|
|
193
|
+
// time cursor to its last change, less one second (Xero compares whole
|
|
194
|
+
// seconds); when that does not move it forward, one second holds more than a
|
|
195
|
+
// page, and the page number moves on instead.
|
|
196
|
+
export function nextPosition({ modifiedSince, page }, rows, pageSize) {
|
|
197
|
+
const times = rows.map((r) => r.xeroUpdatedAt?.getTime()).filter(Number.isFinite);
|
|
198
|
+
const latest = times.length ? Math.max(...times) : null;
|
|
199
|
+
const full = rows.length >= pageSize;
|
|
200
|
+
if (!full) {
|
|
201
|
+
const since = latest === null ? modifiedSince : new Date(Math.floor(latest / 1000) * 1000 - 1000);
|
|
202
|
+
return { modifiedSince: since && modifiedSince && since < modifiedSince ? modifiedSince : since, page: 1, done: true };
|
|
203
|
+
}
|
|
204
|
+
const since = latest === null ? null : new Date(Math.floor(latest / 1000) * 1000 - 1000);
|
|
205
|
+
if (since && (!modifiedSince || since > modifiedSince)) return { modifiedSince: since, page: 1, done: false };
|
|
206
|
+
return { modifiedSince, page: page + 1, done: false };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Reads one resource from its cursor until Xero has no more changes, the
|
|
210
|
+
// mirror's share of the day's calls is spent, or `maxPages` pages are read.
|
|
211
|
+
// Answers counts and why it stopped: `done`, `budget` or `max_pages`.
|
|
212
|
+
export async function mirrorResource(db, runtime, organisationId, resource, { maxPages = 50 } = {}) {
|
|
213
|
+
if (!MIRROR_RESOURCES.includes(resource)) throw new TypeError(`unknown Xero mirror resource: ${resource}`);
|
|
214
|
+
const { settings } = runtime;
|
|
215
|
+
const map = MAPPERS[resource];
|
|
216
|
+
const pageSize = settings.mirrorPageSize;
|
|
217
|
+
const counts = { pages: 0, read: 0, inserted: 0, changed: 0, unchanged: 0 };
|
|
218
|
+
return withXeroLane('sync', async () => {
|
|
219
|
+
const { client } = await runtime.clientFor(db, organisationId);
|
|
220
|
+
let position = await readCursor(db, organisationId, resource);
|
|
221
|
+
for (;;) {
|
|
222
|
+
if (counts.pages >= maxPages) return { resource, ...counts, stopped: 'max_pages' };
|
|
223
|
+
const budget = runtime.governor.status(client.tenantId);
|
|
224
|
+
if (budget.dayRemaining <= Math.ceil(budget.dayLimit * settings.mirrorReserveFraction)) {
|
|
225
|
+
return { resource, ...counts, stopped: 'budget' };
|
|
226
|
+
}
|
|
227
|
+
let raw;
|
|
228
|
+
try {
|
|
229
|
+
raw = await client.listChangedPage(resource, { page: position.page, pageSize, modifiedSince: position.modifiedSince });
|
|
230
|
+
} catch (error) {
|
|
231
|
+
await db.execute(sql`update xero_mirror_cursors set last_error = ${`${error.code ?? 'error'}: ${String(error.message).slice(0, 300)}`}
|
|
232
|
+
where organisation_id = ${organisationId} and resource = ${resource}`);
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
const rows = raw.map((record) => map(record, settings)).filter((r) => r[ID_OF[resource]]);
|
|
236
|
+
const next = nextPosition(position, rows, pageSize);
|
|
237
|
+
await db.transaction(async (tx) => {
|
|
238
|
+
for (const row of rows) {
|
|
239
|
+
const { rows: written } = await tx.execute(UPSERT[resource](organisationId, row, hashOf(row)));
|
|
240
|
+
if (!written.length) counts.unchanged += 1;
|
|
241
|
+
else if (written[0].inserted) counts.inserted += 1;
|
|
242
|
+
else counts.changed += 1;
|
|
243
|
+
}
|
|
244
|
+
await tx.execute(sql`update xero_mirror_cursors set
|
|
245
|
+
modified_since = ${next.modifiedSince}, page = ${next.page}, rows_read = rows_read + ${raw.length},
|
|
246
|
+
last_read_at = now(), last_error = null,
|
|
247
|
+
passes = passes + ${next.done ? 1 : 0},
|
|
248
|
+
last_pass_finished_at = case when ${next.done} then now() else last_pass_finished_at end
|
|
249
|
+
where organisation_id = ${organisationId} and resource = ${resource}`);
|
|
250
|
+
});
|
|
251
|
+
counts.pages += 1;
|
|
252
|
+
counts.read += raw.length;
|
|
253
|
+
position = next;
|
|
254
|
+
if (next.done) return { resource, ...counts, stopped: 'done' };
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Reads each resource in turn, one organisation at a time with any other Xero
|
|
260
|
+
// work for it (runtime.serial).
|
|
261
|
+
export async function mirrorXero(db, runtime, organisationId, { resources = MIRROR_RESOURCES, maxPages } = {}) {
|
|
262
|
+
return runtime.serial(organisationId, async () => {
|
|
263
|
+
const results = [];
|
|
264
|
+
for (const resource of resources) {
|
|
265
|
+
const result = await mirrorResource(db, runtime, organisationId, resource, { maxPages });
|
|
266
|
+
results.push(result);
|
|
267
|
+
if (result.stopped === 'budget') break;
|
|
268
|
+
}
|
|
269
|
+
return results;
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// What the copy holds, and where each cursor stands. No Xero calls.
|
|
274
|
+
export async function mirrorStatus(db, organisationId) {
|
|
275
|
+
const { rows: cursors } = await db.execute(sql`select resource, modified_since, page, passes, rows_read,
|
|
276
|
+
last_read_at, last_pass_finished_at, last_error from xero_mirror_cursors where organisation_id = ${organisationId}
|
|
277
|
+
order by resource`);
|
|
278
|
+
const { rows: [counts] } = await db.execute(sql`select
|
|
279
|
+
(select count(*)::int from xero_invoices where organisation_id = ${organisationId}) as invoices,
|
|
280
|
+
(select count(*)::int from xero_credit_notes where organisation_id = ${organisationId}) as credit_notes,
|
|
281
|
+
(select count(*)::int from xero_payments where organisation_id = ${organisationId}) as payments`);
|
|
282
|
+
return {
|
|
283
|
+
counts,
|
|
284
|
+
cursors: cursors.map((c) => ({
|
|
285
|
+
resource: c.resource,
|
|
286
|
+
modifiedSince: c.modified_since,
|
|
287
|
+
page: c.page,
|
|
288
|
+
passes: c.passes,
|
|
289
|
+
rowsRead: Number(c.rows_read),
|
|
290
|
+
lastReadAt: c.last_read_at,
|
|
291
|
+
lastPassFinishedAt: c.last_pass_finished_at,
|
|
292
|
+
lastError: c.last_error,
|
|
293
|
+
})),
|
|
294
|
+
};
|
|
295
|
+
}
|
|
@@ -6,6 +6,8 @@ import { lastSync, listContacts, listItems, syncCatalogue } from './catalogue.js
|
|
|
6
6
|
import { createXeroClient } from './client.js';
|
|
7
7
|
import { describePush, listPushes, MAX_BATCH, previewDrafts, pushDrafts } from './drafts.js';
|
|
8
8
|
import { recordXeroEvidence, recordXeroPayments } from './ledger.js';
|
|
9
|
+
import { queueHistoryImport, resetImport } from './import.js';
|
|
10
|
+
import { MIRROR_RESOURCES, mirrorStatus, mirrorXero } from './mirror.js';
|
|
9
11
|
import { decryptToken, deleteConnection, describeConnection, readConnection, saveConnection } from './connections.js';
|
|
10
12
|
import { authorizeUrl, exchangeCode, fetchConnections, revokeToken, selectConnection } from './oauth.js';
|
|
11
13
|
import { writesAllowed } from './settings.js';
|
|
@@ -28,6 +30,9 @@ import { messagePage as themedMessagePage } from '../../ui/page.js';
|
|
|
28
30
|
// POST /obligations/:obligationId/check owner or admin: Xero's invoice for
|
|
29
31
|
// a billable job, recorded on the ledger
|
|
30
32
|
// POST /payments/sync owner or admin: Xero payments to the ledger
|
|
33
|
+
// POST /mirror owner or admin: copies Xero invoices, credit
|
|
34
|
+
// notes and payments changed since the last copy
|
|
35
|
+
// GET /mirror?organisationId= any signed-in person: what the copy holds
|
|
31
36
|
//
|
|
32
37
|
// Only an owner, with a verified owner sign-in, can connect or disconnect.
|
|
33
38
|
// The callback carries no Flow session: the one-use state from /connect,
|
|
@@ -138,6 +143,7 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, messa
|
|
|
138
143
|
await revokeToken(settings, runtime.transport, tokens.refresh_token).catch(() => false);
|
|
139
144
|
throw error;
|
|
140
145
|
}
|
|
146
|
+
const previous = await readConnection(db, entry.organisationId);
|
|
141
147
|
const connection = await saveConnection(db, {
|
|
142
148
|
organisationId: entry.organisationId,
|
|
143
149
|
mode: settings.mode,
|
|
@@ -149,8 +155,11 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, messa
|
|
|
149
155
|
key: settings.tokenKey,
|
|
150
156
|
});
|
|
151
157
|
runtime.remember(entry.organisationId, tokens);
|
|
158
|
+
// Another Xero organisation starts its history from the beginning.
|
|
159
|
+
if (previous && previous.tenantId !== connection.tenantId) await resetImport(db, entry.organisationId);
|
|
160
|
+
await queueHistoryImport(db, entry.organisationId, { reason: 'connected' });
|
|
152
161
|
log.info?.(`[xero] organisation ${entry.organisationId} connected to Xero tenant ${connection.tenantId} (${settings.mode}) by person ${entry.personId}`);
|
|
153
|
-
return res.type('html').send(page('Xero is connected', `Flow is connected to ${connection.tenantName || 'your Xero organisation'} (${settings.mode}). You can close this tab.`, 'success'));
|
|
162
|
+
return res.type('html').send(page('Xero is connected', `Flow is connected to ${connection.tenantName || 'your Xero organisation'} (${settings.mode}). Flow is now copying its invoice and payment history in the background. You can close this tab.`, 'success'));
|
|
154
163
|
} catch (error) {
|
|
155
164
|
log.error?.(`[xero] connect failed: ${error.code ?? 'error'}: ${error.message}`);
|
|
156
165
|
const message = error instanceof FlowError ? error.message : 'Something went wrong talking to Xero. Try again.';
|
|
@@ -227,5 +236,21 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, messa
|
|
|
227
236
|
res.json({ ok: true, ...result });
|
|
228
237
|
}));
|
|
229
238
|
|
|
239
|
+
router.post('/mirror', ...manager, handle(log, async (req, res) => {
|
|
240
|
+
const organisationId = await requireOrganisation(db, req.body?.organisationId);
|
|
241
|
+
const resources = req.body?.resources ?? MIRROR_RESOURCES;
|
|
242
|
+
if (!Array.isArray(resources) || !resources.length || !resources.every((r) => MIRROR_RESOURCES.includes(r))) {
|
|
243
|
+
throw new FlowError('invalid', `resources must list some of: ${MIRROR_RESOURCES.join(', ')}`);
|
|
244
|
+
}
|
|
245
|
+
const results = await mirrorXero(db, runtime, organisationId, { resources: [...new Set(resources)] });
|
|
246
|
+
log.info?.(`[xero] organisation ${organisationId}: person ${req.person.id} copied Xero records: ${JSON.stringify(results)}`);
|
|
247
|
+
res.json({ ok: true, results, ...(await mirrorStatus(db, organisationId)) });
|
|
248
|
+
}));
|
|
249
|
+
|
|
250
|
+
router.get('/mirror', ...signedIn, handle(log, async (req, res) => {
|
|
251
|
+
const organisationId = await requireOrganisation(db, req.query.organisationId);
|
|
252
|
+
res.json({ ok: true, ...(await mirrorStatus(db, organisationId)) });
|
|
253
|
+
}));
|
|
254
|
+
|
|
230
255
|
return router;
|
|
231
256
|
}
|
|
@@ -28,6 +28,8 @@ export const XERO_CALLBACK_PATH = '/api/xero/oauth/callback';
|
|
|
28
28
|
export const DEFAULT_XERO_SCOPE = 'offline_access accounting.contacts accounting.invoices accounting.settings.read';
|
|
29
29
|
export const DEFAULT_XERO_DAY_LIMIT = 1000;
|
|
30
30
|
export const DEFAULT_DUE_DAYS = 30;
|
|
31
|
+
export const DEFAULT_MIRROR_PAGE_SIZE = 200;
|
|
32
|
+
export const MAX_MIRROR_PAGE_SIZE = 1000;
|
|
31
33
|
|
|
32
34
|
function readEnv(env, name) {
|
|
33
35
|
const value = env?.[name];
|
|
@@ -159,6 +161,12 @@ export function resolveXeroSettings(env = process.env) {
|
|
|
159
161
|
defaultDueDays: readPositiveInteger(env, 'XERO_DEFAULT_DUE_DAYS', DEFAULT_DUE_DAYS),
|
|
160
162
|
invoiceNumber,
|
|
161
163
|
replayFixtures: readEnv(env, 'XERO_REPLAY_FIXTURES') || null,
|
|
164
|
+
// Free text on Xero records (references, line descriptions) can name a
|
|
165
|
+
// patient. Flow keeps it only when the lab turns this on (VISION.md).
|
|
166
|
+
keepReference: readOnOff(env, 'XERO_KEEP_REFERENCE'),
|
|
167
|
+
mirrorPageSize: Math.min(readPositiveInteger(env, 'XERO_MIRROR_PAGE_SIZE', DEFAULT_MIRROR_PAGE_SIZE), MAX_MIRROR_PAGE_SIZE),
|
|
168
|
+
// The share of the day's calls the mirror leaves for drafts and people.
|
|
169
|
+
mirrorReserveFraction: readFraction(env, 'XERO_MIRROR_RESERVE_FRACTION', 0.3),
|
|
162
170
|
endpoints: XERO_ENDPOINTS,
|
|
163
171
|
};
|
|
164
172
|
}
|
package/server/ui/app-page.js
CHANGED
|
@@ -53,7 +53,7 @@ const BODY = `<div class="fx-app">
|
|
|
53
53
|
<div id="toasts" class="fx-toasts" aria-live="polite"></div>
|
|
54
54
|
</div>`;
|
|
55
55
|
|
|
56
|
-
export const APP_PATHS = ['/sign-in', '/people', '/account', '/appearance'];
|
|
56
|
+
export const APP_PATHS = ['/sign-in', '/people', '/integrations', '/account', '/appearance'];
|
|
57
57
|
|
|
58
58
|
export function appPage({ defaultFamily }) {
|
|
59
59
|
return renderPage({
|
package/server/ui/app.js
CHANGED
|
@@ -182,6 +182,7 @@ let me = null;
|
|
|
182
182
|
|
|
183
183
|
const VIEWS = {
|
|
184
184
|
people: { label: 'People', ownerOnly: true, render: renderPeople },
|
|
185
|
+
integrations: { label: 'Integrations', ownerOnly: true, render: renderIntegrations },
|
|
185
186
|
account: { label: 'Account', render: renderAccount },
|
|
186
187
|
appearance: { label: 'Appearance', render: renderAppearance },
|
|
187
188
|
};
|
|
@@ -347,6 +348,147 @@ function personRow(person, host) {
|
|
|
347
348
|
h('td', { class: 'fx-table__actions' }, h('div', { class: 'fx-row fx-row--end' }, actions)));
|
|
348
349
|
}
|
|
349
350
|
|
|
351
|
+
// Integrations: an owner turns Xero and Seazona on and enters their settings
|
|
352
|
+
// here. Credentials are write-only: the server says whether each is set,
|
|
353
|
+
// never its value (server/integrations/setup.js).
|
|
354
|
+
const STATE_LABEL = { ready: 'On', disabled: 'Off', failed: 'Needs attention', restart_needed: 'Restart needed', not_started: 'Starting' };
|
|
355
|
+
const STATE_TONE = { ready: 'success', disabled: 'info', failed: 'danger', restart_needed: 'warn', not_started: 'info' };
|
|
356
|
+
|
|
357
|
+
async function renderIntegrations(main) {
|
|
358
|
+
const host = h('div', { class: 'fx-fields' }, h('div', { class: 'fx-skeleton' }));
|
|
359
|
+
main.append(
|
|
360
|
+
pageHeader('Integrations', { subtitle: 'Connect Flow to your accounting and lab systems. Each one uses an app you create in that system.' }),
|
|
361
|
+
host);
|
|
362
|
+
let body;
|
|
363
|
+
try {
|
|
364
|
+
body = await flow('/api/integrations');
|
|
365
|
+
} catch (error) {
|
|
366
|
+
host.replaceChildren(notice(error.message, 'danger'));
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
const canChange = Boolean(me.ownerActions?.allowed);
|
|
370
|
+
host.replaceChildren(...[
|
|
371
|
+
canChange ? null : notice('Changing integrations needs a verified owner sign-in: Google Workspace on the owner domain with no password, or a second factor.', 'warn', 'Read only.'),
|
|
372
|
+
body.secretsKey ? null : notice('Flow has no secrets key, so it cannot store a client secret. Ask whoever hosts Flow to set FLOW_SECRETS_KEY (64 hex characters), then restart Flow.', 'warn', 'No secrets key.'),
|
|
373
|
+
...body.integrations.map((integration) => integrationCard(integration, { canChange, organisations: body.organisations })),
|
|
374
|
+
].filter(Boolean));
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function integrationCard(integration, { canChange, organisations }) {
|
|
378
|
+
const card = h('section', { class: 'fx-panel', 'aria-label': integration.name });
|
|
379
|
+
const draw = (current) => {
|
|
380
|
+
const inputs = new Map();
|
|
381
|
+
const fields = current.fields.map((field) => {
|
|
382
|
+
const fromHost = field.source === 'host';
|
|
383
|
+
const disabled = !canChange || fromHost;
|
|
384
|
+
let input;
|
|
385
|
+
if (field.kind === 'choice' || field.kind === 'onoff') {
|
|
386
|
+
const choices = field.kind === 'onoff' ? [{ value: 'off', label: 'Off' }, { value: 'on', label: 'On' }] : field.choices;
|
|
387
|
+
input = h('select', { class: 'fx-select', disabled },
|
|
388
|
+
field.kind === 'choice' ? h('option', { value: '' }, 'Choose…') : null,
|
|
389
|
+
choices.map((c) => h('option', { value: c.value, selected: (field.value ?? (field.kind === 'onoff' ? 'off' : '')) === c.value }, c.label)));
|
|
390
|
+
} else if (field.secret) {
|
|
391
|
+
input = h('input', { class: 'fx-input', type: 'password', autocomplete: 'off', disabled, placeholder: field.set ? 'Set. Type a new one to replace it.' : 'Paste it here' });
|
|
392
|
+
} else {
|
|
393
|
+
input = h('input', { class: 'fx-input', type: field.kind === 'url' ? 'url' : 'text', autocomplete: 'off', disabled, value: field.value ?? '' });
|
|
394
|
+
}
|
|
395
|
+
inputs.set(field.name, { field, input });
|
|
396
|
+
const suggestion = field.suggest === 'callback' ? `${location.origin}/api/${current.id}/oauth/callback` : null;
|
|
397
|
+
return h('label', { class: 'fx-field' },
|
|
398
|
+
h('span', { class: 'fx-label' }, field.label),
|
|
399
|
+
input,
|
|
400
|
+
fromHost ? h('span', { class: 'fx-dim' }, 'Set by the host. Change it there.') : null,
|
|
401
|
+
field.unreadable ? h('span', { class: 'fx-dim' }, 'Stored with another key. Enter it again.') : null,
|
|
402
|
+
field.help ? h('span', { class: 'fx-dim' }, field.help) : null,
|
|
403
|
+
suggestion && !disabled ? h('div', { class: 'fx-row' },
|
|
404
|
+
h('code', {}, suggestion),
|
|
405
|
+
h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button', onclick: () => { navigator.clipboard?.writeText(suggestion); toast('Copied.', 'success'); } }, 'Copy'),
|
|
406
|
+
h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button', onclick: () => { input.value = suggestion; } }, 'Use this')) : null);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
const values = () => {
|
|
410
|
+
const out = {};
|
|
411
|
+
for (const [name, { field, input }] of inputs) {
|
|
412
|
+
if (field.source === 'host') continue;
|
|
413
|
+
const value = input.value.trim();
|
|
414
|
+
if (field.secret) { if (value) out[name] = value; continue; }
|
|
415
|
+
if (value !== (field.value ?? (field.kind === 'onoff' ? 'off' : ''))) out[name] = value || null;
|
|
416
|
+
}
|
|
417
|
+
return out;
|
|
418
|
+
};
|
|
419
|
+
const save = async (enabled) => {
|
|
420
|
+
try {
|
|
421
|
+
const res = await flow(`/api/integrations/${current.id}`, { method: 'PUT', body: JSON.stringify({ values: values(), ...(enabled === undefined ? {} : { enabled }) }) });
|
|
422
|
+
const next = res.integration;
|
|
423
|
+
toast(next.state === 'ready' ? `${next.name} is on.` : next.state === 'disabled' ? `${next.name} settings saved. It is off.` : `${next.name} needs attention.`, next.state === 'failed' ? 'danger' : 'success');
|
|
424
|
+
draw(next);
|
|
425
|
+
} catch (error) {
|
|
426
|
+
toast(error.message, 'danger');
|
|
427
|
+
}
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
const on = current.enabled;
|
|
431
|
+
const guide = current.guide;
|
|
432
|
+
card.replaceChildren(...[
|
|
433
|
+
h('div', { class: 'fx-row' },
|
|
434
|
+
h('h2', { class: 'fx-title' }, current.name),
|
|
435
|
+
h('span', { class: 'fx-tag', 'data-tone': STATE_TONE[current.state] ?? 'info' }, STATE_LABEL[current.state] ?? current.state),
|
|
436
|
+
current.enabledBy === 'host' ? h('span', { class: 'fx-dim' }, 'Turned on by the host.') : null),
|
|
437
|
+
current.state === 'failed' && current.error ? notice(current.error, 'danger', 'It could not start.') : null,
|
|
438
|
+
guide ? h('details', { open: !on },
|
|
439
|
+
h('summary', {}, guide.title),
|
|
440
|
+
h('ol', {}, guide.steps.map((step) => h('li', {}, step))),
|
|
441
|
+
guide.link ? h('p', {}, h('a', { href: guide.link, target: '_blank', rel: 'noopener' }, guide.link)) : null) : null,
|
|
442
|
+
h('div', { class: 'fx-fields' }, fields),
|
|
443
|
+
canChange ? h('div', { class: 'fx-row fx-row--end' },
|
|
444
|
+
h('button', { class: 'fx-btn fx-btn--ghost', type: 'button', onclick: () => save(undefined) }, 'Save'),
|
|
445
|
+
current.enabledBy === 'host' ? null : on
|
|
446
|
+
? h('button', { class: 'fx-btn fx-btn--danger', type: 'button', onclick: () => save(false) }, `Turn ${current.name} off`)
|
|
447
|
+
: h('button', { class: 'fx-btn fx-btn--primary', type: 'button', onclick: () => save(true) }, `Save and turn ${current.name} on`)) : null,
|
|
448
|
+
current.id === 'xero' && current.state === 'ready' ? xeroConnection(organisations, canChange) : null,
|
|
449
|
+
].filter(Boolean));
|
|
450
|
+
};
|
|
451
|
+
draw(integration);
|
|
452
|
+
return card;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Once Xero is on: each organisation's connection, the history copy, and
|
|
456
|
+
// Connect, which goes to Xero and comes back.
|
|
457
|
+
function xeroConnection(organisations, canChange) {
|
|
458
|
+
const host = h('div', { class: 'fx-fields' });
|
|
459
|
+
for (const organisation of organisations) {
|
|
460
|
+
const row = h('div', { class: 'fx-row' }, h('span', {}, organisation.name), h('span', { class: 'fx-dim' }, 'Checking…'));
|
|
461
|
+
host.append(row);
|
|
462
|
+
(async () => {
|
|
463
|
+
try {
|
|
464
|
+
const status = await flow(`/api/xero/status?organisationId=${organisation.id}`);
|
|
465
|
+
const parts = [h('span', {}, organisation.name)];
|
|
466
|
+
if (status.connected) {
|
|
467
|
+
parts.push(h('span', { class: 'fx-tag', 'data-tone': 'success' }, `Connected to ${status.connection?.tenantName ?? 'Xero'}`));
|
|
468
|
+
const mirror = await flow(`/api/xero/mirror?organisationId=${organisation.id}`).catch(() => null);
|
|
469
|
+
if (mirror) parts.push(h('span', { class: 'fx-dim' }, `History copied: ${mirror.counts.invoices} invoices, ${mirror.counts.credit_notes} credit notes, ${mirror.counts.payments} payments.`));
|
|
470
|
+
} else {
|
|
471
|
+
parts.push(h('span', { class: 'fx-dim' }, 'Not connected.'));
|
|
472
|
+
}
|
|
473
|
+
if (canChange) {
|
|
474
|
+
parts.push(h('button', { class: 'fx-btn fx-btn--primary fx-btn--sm', type: 'button', onclick: async () => {
|
|
475
|
+
try {
|
|
476
|
+
const { authorizeUrl } = await flow('/api/xero/connect', { method: 'POST', body: JSON.stringify({ organisationId: organisation.id }) });
|
|
477
|
+
location.assign(authorizeUrl);
|
|
478
|
+
} catch (error) {
|
|
479
|
+
toast(error.message, 'danger');
|
|
480
|
+
}
|
|
481
|
+
} }, status.connected ? 'Connect again' : 'Connect to Xero'));
|
|
482
|
+
}
|
|
483
|
+
row.replaceChildren(...parts);
|
|
484
|
+
} catch (error) {
|
|
485
|
+
row.replaceChildren(h('span', {}, organisation.name), notice(error.message, 'danger'));
|
|
486
|
+
}
|
|
487
|
+
})();
|
|
488
|
+
}
|
|
489
|
+
return h('div', {}, h('h3', { class: 'fx-label' }, 'Connection'), host);
|
|
490
|
+
}
|
|
491
|
+
|
|
350
492
|
async function invitePerson() {
|
|
351
493
|
const data = await dialog({
|
|
352
494
|
title: 'Invite someone',
|