@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.
- package/README.md +3 -0
- package/SECURITY.md +4 -1
- package/npm-shrinkwrap.json +32 -3
- package/package.json +3 -2
- package/server/app.js +26 -3
- package/server/auth/routes.js +27 -12
- package/server/auth/session.js +6 -3
- package/server/auth/settings.js +26 -7
- package/server/auth/sign-in.html +17 -6
- package/server/core/invoices.js +6 -3
- package/server/core/ledger.js +420 -0
- package/server/db/migrations/0003_billing_ledger.sql +84 -0
- package/server/db/migrations/0004_ledger_events_append_only.sql +5 -0
- package/server/db/migrations/meta/0003_snapshot.json +1327 -0
- package/server/db/migrations/meta/0004_snapshot.json +1327 -0
- package/server/db/migrations/meta/_journal.json +14 -0
- package/server/db/schema.js +137 -0
- package/server/import/earlier-flow.js +230 -0
- package/server/import-earlier-flow.js +66 -0
- package/server/index.js +16 -4
- package/server/integrations/adapters.js +6 -0
- package/server/integrations/health.js +21 -0
- package/server/integrations/index.js +26 -0
- package/server/integrations/port.js +154 -6
- package/server/integrations/remove-tables.js +31 -0
- package/server/integrations/schema.js +62 -0
- package/server/integrations/seazona/client.js +196 -0
- package/server/integrations/seazona/drizzle.config.js +9 -0
- package/server/integrations/seazona/feed.js +104 -0
- package/server/integrations/seazona/governor.js +174 -0
- package/server/integrations/seazona/index.js +84 -0
- package/server/integrations/seazona/lanes.js +431 -0
- package/server/integrations/seazona/ledger.js +66 -0
- package/server/integrations/seazona/map.js +229 -0
- package/server/integrations/seazona/migrations/0000_api_governor.sql +20 -0
- package/server/integrations/seazona/migrations/0001_mirror.sql +106 -0
- package/server/integrations/seazona/migrations/meta/0000_snapshot.json +149 -0
- package/server/integrations/seazona/migrations/meta/0001_snapshot.json +793 -0
- package/server/integrations/seazona/migrations/meta/_journal.json +20 -0
- package/server/integrations/seazona/replay.js +79 -0
- package/server/integrations/seazona/scheduler.js +78 -0
- package/server/integrations/seazona/schema.js +157 -0
- package/server/integrations/seazona/settings.js +161 -0
- package/server/integrations/seazona/store.js +219 -0
- package/server/integrations/seazona/sync-once.js +44 -0
- package/server/integrations/seazona/tables.js +17 -0
- package/server/integrations/xero/catalogue.js +184 -0
- package/server/integrations/xero/client.js +253 -0
- package/server/integrations/xero/connections.js +104 -0
- package/server/integrations/xero/drafts.js +525 -0
- package/server/integrations/xero/fixtures/replay.json +45 -0
- package/server/integrations/xero/governor.js +275 -0
- package/server/integrations/xero/index.js +56 -0
- package/server/integrations/xero/ledger.js +269 -0
- package/server/integrations/xero/migrations/0000_xero_connections.sql +17 -0
- package/server/integrations/xero/migrations/0001_xero_catalogue.sql +49 -0
- package/server/integrations/xero/migrations/0002_xero_invoice_pushes.sql +32 -0
- package/server/integrations/xero/migrations/0003_xero_payment_cursors.sql +7 -0
- package/server/integrations/xero/migrations/meta/_journal.json +34 -0
- package/server/integrations/xero/oauth.js +143 -0
- package/server/integrations/xero/replay.js +83 -0
- package/server/integrations/xero/routes.js +234 -0
- package/server/integrations/xero/runtime.js +135 -0
- package/server/integrations/xero/settings.js +170 -0
- package/server/rate-limit.js +13 -19
|
@@ -22,6 +22,20 @@
|
|
|
22
22
|
"when": 1792322911044,
|
|
23
23
|
"tag": "0002_people",
|
|
24
24
|
"breakpoints": true
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"idx": 3,
|
|
28
|
+
"version": "7",
|
|
29
|
+
"when": 1792329648463,
|
|
30
|
+
"tag": "0003_billing_ledger",
|
|
31
|
+
"breakpoints": true
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"idx": 4,
|
|
35
|
+
"version": "7",
|
|
36
|
+
"when": 1792329649575,
|
|
37
|
+
"tag": "0004_ledger_events_append_only",
|
|
38
|
+
"breakpoints": true
|
|
25
39
|
}
|
|
26
40
|
]
|
|
27
41
|
}
|
package/server/db/schema.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { sql } from 'drizzle-orm';
|
|
2
2
|
import {
|
|
3
|
+
boolean,
|
|
3
4
|
check,
|
|
4
5
|
date,
|
|
5
6
|
foreignKey,
|
|
@@ -167,3 +168,139 @@ export const people = pgTable('people', {
|
|
|
167
168
|
check('people_role_valid', sql`${t.role} in ('owner', 'admin', 'staff')`),
|
|
168
169
|
check('people_status_valid', sql`${t.status} in ('active', 'pending', 'disabled')`),
|
|
169
170
|
]);
|
|
171
|
+
|
|
172
|
+
// Billing obligations (issue 3). One billable job in a work system gives one
|
|
173
|
+
// obligation. The obligation keeps the evidence for its state: what the work
|
|
174
|
+
// system reported, what the accounting system reported, each payment, and each
|
|
175
|
+
// status write-back to the work system. Flow is not the source of truth for
|
|
176
|
+
// accounting; the ledger is its audit and workflow memory over what the
|
|
177
|
+
// integrations report.
|
|
178
|
+
|
|
179
|
+
export const ACCOUNTING_STATES = [
|
|
180
|
+
'unknown', 'not_found', 'drafted', 'authorised', 'part_paid', 'paid',
|
|
181
|
+
'voided', 'deleted', 'ambiguous', 'conflict',
|
|
182
|
+
];
|
|
183
|
+
export const HOLD_STATES = ['none', 'held', 'review'];
|
|
184
|
+
export const WRITEBACK_STATES = ['pending', 'sent', 'failed', 'not_supported'];
|
|
185
|
+
|
|
186
|
+
const inList = (column, values) => sql.raw(`${column} in (${values.map((v) => `'${v}'`).join(', ')})`);
|
|
187
|
+
|
|
188
|
+
export const billingObligations = pgTable('billing_obligations', {
|
|
189
|
+
id: id(),
|
|
190
|
+
organisationId: organisationId().references(() => organisations.id),
|
|
191
|
+
// The job's identity in the work system, for example `seazona` and its job id.
|
|
192
|
+
sourceSystem: text('source_system').notNull(),
|
|
193
|
+
sourceJobRef: text('source_job_ref').notNull(),
|
|
194
|
+
// The work system's own invoice identity for the job, when it has one.
|
|
195
|
+
sourceInvoiceRef: text('source_invoice_ref'),
|
|
196
|
+
// A case number only. Never patient data.
|
|
197
|
+
caseRef: text('case_ref'),
|
|
198
|
+
contactId: uuid('contact_id'),
|
|
199
|
+
practitioner: text('practitioner'),
|
|
200
|
+
shippedOn: date('shipped_on'),
|
|
201
|
+
// The lines as the work system reported them, normalised.
|
|
202
|
+
lines: jsonb('lines').notNull().default(sql`'[]'::jsonb`),
|
|
203
|
+
lineFingerprint: text('line_fingerprint').notNull(),
|
|
204
|
+
subtotalCents: integer('subtotal_cents').notNull(),
|
|
205
|
+
taxCents: integer('tax_cents').notNull(),
|
|
206
|
+
totalCents: integer('total_cents').notNull(),
|
|
207
|
+
// Every line maps to a price item.
|
|
208
|
+
mappingComplete: boolean('mapping_complete').notNull(),
|
|
209
|
+
sourceSeenAt: timestamp('source_seen_at', { withTimezone: true }).notNull(),
|
|
210
|
+
holdState: text('hold_state').notNull().default('none'),
|
|
211
|
+
holdReason: text('hold_reason'),
|
|
212
|
+
// The Flow invoice made for this job.
|
|
213
|
+
invoiceId: uuid('invoice_id'),
|
|
214
|
+
// What the accounting system reported, how it matched, and when Flow checked.
|
|
215
|
+
accountingState: text('accounting_state').notNull().default('unknown'),
|
|
216
|
+
accountingRef: text('accounting_ref'),
|
|
217
|
+
accountingUrl: text('accounting_url'),
|
|
218
|
+
accountingMatchedBy: text('accounting_matched_by'),
|
|
219
|
+
accountingCheckedAt: timestamp('accounting_checked_at', { withTimezone: true }),
|
|
220
|
+
paidCents: integer('paid_cents').notNull().default(0),
|
|
221
|
+
createdAt: createdAt(),
|
|
222
|
+
updatedAt: updatedAt(),
|
|
223
|
+
}, (t) => [
|
|
224
|
+
unique('billing_obligations_org_id').on(t.organisationId, t.id),
|
|
225
|
+
unique('billing_obligations_source_job').on(t.organisationId, t.sourceSystem, t.sourceJobRef),
|
|
226
|
+
unique('billing_obligations_invoice').on(t.organisationId, t.invoiceId),
|
|
227
|
+
foreignKey({
|
|
228
|
+
name: 'billing_obligations_contact_same_org',
|
|
229
|
+
columns: [t.organisationId, t.contactId],
|
|
230
|
+
foreignColumns: [contacts.organisationId, contacts.id],
|
|
231
|
+
}),
|
|
232
|
+
foreignKey({
|
|
233
|
+
name: 'billing_obligations_invoice_same_org',
|
|
234
|
+
columns: [t.organisationId, t.invoiceId],
|
|
235
|
+
foreignColumns: [invoices.organisationId, invoices.id],
|
|
236
|
+
}),
|
|
237
|
+
check('billing_obligations_hold_state_valid', inList('hold_state', HOLD_STATES)),
|
|
238
|
+
check('billing_obligations_accounting_state_valid', inList('accounting_state', ACCOUNTING_STATES)),
|
|
239
|
+
check('billing_obligations_paid_not_negative', sql`${t.paidCents} >= 0`),
|
|
240
|
+
]);
|
|
241
|
+
|
|
242
|
+
// Why each obligation is in its state. Append-only: a trigger in the
|
|
243
|
+
// migrations refuses UPDATE and DELETE.
|
|
244
|
+
export const obligationEvents = pgTable('obligation_events', {
|
|
245
|
+
id: id(),
|
|
246
|
+
organisationId: organisationId(),
|
|
247
|
+
obligationId: uuid('obligation_id').notNull(),
|
|
248
|
+
kind: text('kind').notNull(),
|
|
249
|
+
actor: text('actor').notNull(),
|
|
250
|
+
evidence: jsonb('evidence').notNull().default(sql`'{}'::jsonb`),
|
|
251
|
+
createdAt: createdAt(),
|
|
252
|
+
}, (t) => [
|
|
253
|
+
foreignKey({
|
|
254
|
+
name: 'obligation_events_obligation_same_org',
|
|
255
|
+
columns: [t.organisationId, t.obligationId],
|
|
256
|
+
foreignColumns: [billingObligations.organisationId, billingObligations.id],
|
|
257
|
+
}),
|
|
258
|
+
]);
|
|
259
|
+
|
|
260
|
+
// Each payment the accounting system reported against the job's invoice.
|
|
261
|
+
export const obligationPayments = pgTable('obligation_payments', {
|
|
262
|
+
id: id(),
|
|
263
|
+
organisationId: organisationId(),
|
|
264
|
+
obligationId: uuid('obligation_id').notNull(),
|
|
265
|
+
paymentRef: text('payment_ref').notNull(),
|
|
266
|
+
paidOn: date('paid_on').notNull(),
|
|
267
|
+
amountCents: integer('amount_cents').notNull(),
|
|
268
|
+
reference: text('reference'),
|
|
269
|
+
reconciled: boolean('reconciled').notNull(),
|
|
270
|
+
accountingUrl: text('accounting_url'),
|
|
271
|
+
observedAt: timestamp('observed_at', { withTimezone: true }).notNull().defaultNow(),
|
|
272
|
+
}, (t) => [
|
|
273
|
+
unique('obligation_payments_ref').on(t.organisationId, t.obligationId, t.paymentRef),
|
|
274
|
+
foreignKey({
|
|
275
|
+
name: 'obligation_payments_obligation_same_org',
|
|
276
|
+
columns: [t.organisationId, t.obligationId],
|
|
277
|
+
foreignColumns: [billingObligations.organisationId, billingObligations.id],
|
|
278
|
+
}),
|
|
279
|
+
check('obligation_payments_amount_positive', sql`${t.amountCents} > 0`),
|
|
280
|
+
]);
|
|
281
|
+
|
|
282
|
+
// Status sent back to the work system (for example "paid"), with the evidence
|
|
283
|
+
// sent. A failed write-back stays here and is tried again; it never changes the
|
|
284
|
+
// accounting record.
|
|
285
|
+
export const obligationWritebacks = pgTable('obligation_writebacks', {
|
|
286
|
+
id: id(),
|
|
287
|
+
organisationId: organisationId(),
|
|
288
|
+
obligationId: uuid('obligation_id').notNull(),
|
|
289
|
+
targetSystem: text('target_system').notNull(),
|
|
290
|
+
status: text('status').notNull(),
|
|
291
|
+
state: text('state').notNull().default('pending'),
|
|
292
|
+
evidence: jsonb('evidence').notNull(),
|
|
293
|
+
attempts: integer('attempts').notNull().default(0),
|
|
294
|
+
lastError: text('last_error'),
|
|
295
|
+
sentAt: timestamp('sent_at', { withTimezone: true }),
|
|
296
|
+
createdAt: createdAt(),
|
|
297
|
+
updatedAt: updatedAt(),
|
|
298
|
+
}, (t) => [
|
|
299
|
+
unique('obligation_writebacks_status').on(t.organisationId, t.obligationId, t.status),
|
|
300
|
+
foreignKey({
|
|
301
|
+
name: 'obligation_writebacks_obligation_same_org',
|
|
302
|
+
columns: [t.organisationId, t.obligationId],
|
|
303
|
+
foreignColumns: [billingObligations.organisationId, billingObligations.id],
|
|
304
|
+
}),
|
|
305
|
+
check('obligation_writebacks_state_valid', inList('state', WRITEBACK_STATES)),
|
|
306
|
+
]);
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm';
|
|
2
|
+
import { contacts, organisations, priceItems } from '../db/schema.js';
|
|
3
|
+
|
|
4
|
+
// A one-off move of an installation's records from the earlier Flow codebase
|
|
5
|
+
// (its PostgreSQL database, read only) into a new, empty Flow database.
|
|
6
|
+
//
|
|
7
|
+
// What moves is only what the earlier database holds as a fact:
|
|
8
|
+
// - contacts, from its copy of the accounting system's contacts;
|
|
9
|
+
// - the price list, from its copy of the accounting system's sold items;
|
|
10
|
+
// - the invoice prefix and next number, so the first invoice Flow issues
|
|
11
|
+
// follows the last one the accounting system holds.
|
|
12
|
+
// Patient and case details, sync state and logins never move. Integrations
|
|
13
|
+
// read their own records again from their source once they are on.
|
|
14
|
+
//
|
|
15
|
+
// planImport is pure: it turns the source rows into the rows to write and a
|
|
16
|
+
// list of every source row it leaves out, with the reason. applyImport writes
|
|
17
|
+
// the plan in one transaction and checks each table's count before it commits.
|
|
18
|
+
|
|
19
|
+
const SOURCE_QUERIES = {
|
|
20
|
+
contacts: `SELECT contact_id, name, email_address, status, raw_json FROM xero_catalog_contacts ORDER BY contact_id`,
|
|
21
|
+
items: `SELECT item_code, name, description, sales_details_json, is_sold FROM xero_catalog_items ORDER BY item_code`,
|
|
22
|
+
invoices: `SELECT invoice_number, type FROM xero_catalog_invoices WHERE invoice_number IS NOT NULL`,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// Accounting tax types that charge GST on a sale. Every other set type is GST
|
|
26
|
+
// free (exempt, export, outside the GST system).
|
|
27
|
+
const GST_TAX_TYPES = new Set(['OUTPUT', 'OUTPUT2']);
|
|
28
|
+
const SKIPPED_CONTACT_STATUSES = new Set(['ARCHIVED', 'GDPRREQUEST']);
|
|
29
|
+
|
|
30
|
+
export async function readEarlierFlow(pool) {
|
|
31
|
+
const source = {};
|
|
32
|
+
for (const [name, query] of Object.entries(SOURCE_QUERIES)) {
|
|
33
|
+
source[name] = (await pool.query(query)).rows;
|
|
34
|
+
}
|
|
35
|
+
return source;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parseJson(text) {
|
|
39
|
+
if (text === null || text === undefined || text === '') return {};
|
|
40
|
+
if (typeof text === 'object') return text;
|
|
41
|
+
try {
|
|
42
|
+
return JSON.parse(text);
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isTrue(value) {
|
|
49
|
+
return value === true || value === 1 || value === '1' || value === 'true';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// A decimal amount to whole cents, half away from zero, without float drift.
|
|
53
|
+
export function toCents(amount) {
|
|
54
|
+
const text = String(amount ?? '').trim();
|
|
55
|
+
const match = /^(-?)(\d+)(?:\.(\d+))?$/.exec(text);
|
|
56
|
+
if (!match) return null;
|
|
57
|
+
const [, sign, whole, fraction = ''] = match;
|
|
58
|
+
const digits = (fraction + '000').slice(0, 3);
|
|
59
|
+
let cents = Number(whole) * 100 + Number(digits.slice(0, 2));
|
|
60
|
+
if (Number(digits[2]) >= 5) cents += 1;
|
|
61
|
+
if (!Number.isSafeInteger(cents)) return null;
|
|
62
|
+
return sign === '-' && cents !== 0 ? -cents : cents;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function planContacts(rows, skipped) {
|
|
66
|
+
const planned = [];
|
|
67
|
+
const seen = new Set();
|
|
68
|
+
for (const row of rows) {
|
|
69
|
+
const key = row.contact_id;
|
|
70
|
+
const name = String(row.name ?? '').trim();
|
|
71
|
+
const status = String(row.status ?? '').toUpperCase();
|
|
72
|
+
if (!name) {
|
|
73
|
+
skipped.push({ table: 'contacts', key, reason: 'no_name' });
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (SKIPPED_CONTACT_STATUSES.has(status)) {
|
|
77
|
+
skipped.push({ table: 'contacts', key, reason: `status_${status.toLowerCase()}` });
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const raw = parseJson(row.raw_json);
|
|
81
|
+
if (raw === null) {
|
|
82
|
+
skipped.push({ table: 'contacts', key, reason: 'unreadable_raw_json' });
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const customer = isTrue(raw.IsCustomer);
|
|
86
|
+
const supplier = isTrue(raw.IsSupplier);
|
|
87
|
+
// A contact the accounting system has never billed or paid is most often
|
|
88
|
+
// a new customer, so it comes across as one.
|
|
89
|
+
const kinds = supplier && !customer ? ['supplier'] : supplier ? ['customer', 'supplier'] : ['customer'];
|
|
90
|
+
const email = String(row.email_address ?? '').trim() || null;
|
|
91
|
+
for (const kind of kinds) {
|
|
92
|
+
const unique = `${kind}\u0000${name}`;
|
|
93
|
+
if (seen.has(unique)) {
|
|
94
|
+
skipped.push({ table: 'contacts', key, reason: `duplicate_${kind}_name` });
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
seen.add(unique);
|
|
98
|
+
planned.push({ sourceKey: key, kind, name, email });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return planned;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function planPriceItems(rows, skipped, defaulted) {
|
|
105
|
+
const planned = [];
|
|
106
|
+
for (const row of rows) {
|
|
107
|
+
const key = row.item_code;
|
|
108
|
+
const code = String(row.item_code ?? '').trim();
|
|
109
|
+
const name = String(row.name ?? '').trim() || String(row.description ?? '').trim();
|
|
110
|
+
if (!isTrue(row.is_sold)) {
|
|
111
|
+
skipped.push({ table: 'price_items', key, reason: 'not_sold' });
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (!code || !name) {
|
|
115
|
+
skipped.push({ table: 'price_items', key, reason: 'no_code_or_name' });
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const sales = parseJson(row.sales_details_json);
|
|
119
|
+
if (sales === null) {
|
|
120
|
+
skipped.push({ table: 'price_items', key, reason: 'unreadable_sales_details' });
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const unitAmountCents = toCents(sales.UnitPrice);
|
|
124
|
+
if (unitAmountCents === null) {
|
|
125
|
+
skipped.push({ table: 'price_items', key, reason: 'no_sales_price' });
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const taxType = String(sales.TaxType ?? '').trim().toUpperCase();
|
|
129
|
+
if (!taxType) defaulted.push({ table: 'price_items', key, reason: 'no_tax_type_used_gst' });
|
|
130
|
+
const taxCode = !taxType || GST_TAX_TYPES.has(taxType) ? 'GST' : 'GST_FREE';
|
|
131
|
+
planned.push({ sourceKey: key, code, name, unitAmountCents, taxCode });
|
|
132
|
+
}
|
|
133
|
+
return planned;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// The prefix and next number after the highest sales invoice number, so Flow
|
|
137
|
+
// never issues a number the accounting system already holds.
|
|
138
|
+
function planNumbering(rows, fallbackPrefix) {
|
|
139
|
+
const byPrefix = new Map();
|
|
140
|
+
for (const row of rows) {
|
|
141
|
+
if (String(row.type ?? '').toUpperCase() !== 'ACCREC') continue;
|
|
142
|
+
const match = /^(.*?)(\d+)$/.exec(String(row.invoice_number ?? '').trim());
|
|
143
|
+
if (!match) continue;
|
|
144
|
+
const [, prefix, digits] = match;
|
|
145
|
+
const entry = byPrefix.get(prefix) ?? { count: 0, highest: 0 };
|
|
146
|
+
entry.count += 1;
|
|
147
|
+
entry.highest = Math.max(entry.highest, Number(digits));
|
|
148
|
+
byPrefix.set(prefix, entry);
|
|
149
|
+
}
|
|
150
|
+
// The prefix most invoices use is the organisation's numbering.
|
|
151
|
+
let best = null;
|
|
152
|
+
for (const [prefix, entry] of byPrefix) {
|
|
153
|
+
if (!best || entry.count > best.count) best = { prefix, ...entry };
|
|
154
|
+
}
|
|
155
|
+
if (!best) return { invoicePrefix: fallbackPrefix, nextInvoiceNumber: 1, basis: 'no_sales_invoices' };
|
|
156
|
+
return { invoicePrefix: best.prefix, nextInvoiceNumber: best.highest + 1, basis: 'highest_sales_invoice' };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function planImport(source, { fallbackPrefix = 'INV-' } = {}) {
|
|
160
|
+
const skipped = [];
|
|
161
|
+
const defaulted = [];
|
|
162
|
+
return {
|
|
163
|
+
contacts: planContacts(source.contacts ?? [], skipped),
|
|
164
|
+
priceItems: planPriceItems(source.items ?? [], skipped, defaulted),
|
|
165
|
+
numbering: planNumbering(source.invoices ?? [], fallbackPrefix),
|
|
166
|
+
skipped,
|
|
167
|
+
defaulted,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Counts and reasons only. Names, emails and amounts stay out of logs.
|
|
172
|
+
export function summarisePlan(plan) {
|
|
173
|
+
const reasons = {};
|
|
174
|
+
for (const entry of [...plan.skipped, ...plan.defaulted]) {
|
|
175
|
+
const key = `${entry.table}: ${entry.reason}`;
|
|
176
|
+
reasons[key] = (reasons[key] ?? 0) + 1;
|
|
177
|
+
}
|
|
178
|
+
return {
|
|
179
|
+
contacts: {
|
|
180
|
+
customer: plan.contacts.filter((c) => c.kind === 'customer').length,
|
|
181
|
+
supplier: plan.contacts.filter((c) => c.kind === 'supplier').length,
|
|
182
|
+
},
|
|
183
|
+
priceItems: plan.priceItems.length,
|
|
184
|
+
numbering: plan.numbering,
|
|
185
|
+
leftOutOrDefaulted: reasons,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function countRows(tx, table, organisationId) {
|
|
190
|
+
const [{ count }] = (await tx.execute(
|
|
191
|
+
sql`select count(*)::int as count from ${table} where organisation_id = ${organisationId}`,
|
|
192
|
+
)).rows;
|
|
193
|
+
return count;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Writes the plan into a database that has no organisation yet. Everything
|
|
197
|
+
// is one transaction: any error, or a count that does not match, writes
|
|
198
|
+
// nothing.
|
|
199
|
+
export async function applyImport(db, plan, { organisationName }) {
|
|
200
|
+
if (!organisationName?.trim()) throw new Error('organisationName is required');
|
|
201
|
+
return db.transaction(async (tx) => {
|
|
202
|
+
const [{ count: existing }] = (await tx.execute(sql`select count(*)::int as count from ${organisations}`)).rows;
|
|
203
|
+
if (existing > 0) throw new Error('the target database already has an organisation; import only into a new Flow database');
|
|
204
|
+
|
|
205
|
+
const [organisation] = await tx.insert(organisations).values({
|
|
206
|
+
name: organisationName.trim(),
|
|
207
|
+
invoicePrefix: plan.numbering.invoicePrefix,
|
|
208
|
+
nextInvoiceNumber: plan.numbering.nextInvoiceNumber,
|
|
209
|
+
}).returning();
|
|
210
|
+
const organisationId = organisation.id;
|
|
211
|
+
|
|
212
|
+
for (const contact of plan.contacts) {
|
|
213
|
+
await tx.insert(contacts).values({ organisationId, kind: contact.kind, name: contact.name, email: contact.email });
|
|
214
|
+
}
|
|
215
|
+
for (const item of plan.priceItems) {
|
|
216
|
+
await tx.insert(priceItems).values({
|
|
217
|
+
organisationId, code: item.code, name: item.name, unitAmountCents: item.unitAmountCents, taxCode: item.taxCode,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const counts = {
|
|
222
|
+
contacts: await countRows(tx, contacts, organisationId),
|
|
223
|
+
priceItems: await countRows(tx, priceItems, organisationId),
|
|
224
|
+
};
|
|
225
|
+
if (counts.contacts !== plan.contacts.length || counts.priceItems !== plan.priceItems.length) {
|
|
226
|
+
throw new Error(`row counts do not match the plan: ${JSON.stringify(counts)}`);
|
|
227
|
+
}
|
|
228
|
+
return { organisationId, counts };
|
|
229
|
+
});
|
|
230
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import pg from 'pg';
|
|
3
|
+
import { migrateDatabase, openDatabase } from './db/index.js';
|
|
4
|
+
import { applyImport, planImport, readEarlierFlow, summarisePlan } from './import/earlier-flow.js';
|
|
5
|
+
|
|
6
|
+
// Moves an installation's records from the earlier Flow codebase into a new
|
|
7
|
+
// Flow database (server/import/earlier-flow.js).
|
|
8
|
+
//
|
|
9
|
+
// EARLIER_FLOW_DATABASE_URL=… DATABASE_URL=… \
|
|
10
|
+
// node server/import-earlier-flow.js --organisation "<lab name>" [--dry-run]
|
|
11
|
+
//
|
|
12
|
+
// The source is only read. Run it against a copy of the source first. The
|
|
13
|
+
// target must be a new Flow database: the tool runs Flow's migrations on it
|
|
14
|
+
// and refuses one that already has an organisation. It prints counts and
|
|
15
|
+
// reasons only, never names, emails or amounts. Keep both connection strings
|
|
16
|
+
// out of chat and shell history.
|
|
17
|
+
|
|
18
|
+
function parseArgs(argv) {
|
|
19
|
+
const args = { dryRun: false, organisation: null };
|
|
20
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
21
|
+
if (argv[i] === '--dry-run') args.dryRun = true;
|
|
22
|
+
else if (argv[i] === '--organisation') args.organisation = argv[++i] ?? null;
|
|
23
|
+
else throw new Error(`unknown argument: ${argv[i]}`);
|
|
24
|
+
}
|
|
25
|
+
return args;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function main() {
|
|
29
|
+
const args = parseArgs(process.argv.slice(2));
|
|
30
|
+
const sourceUrl = process.env.EARLIER_FLOW_DATABASE_URL;
|
|
31
|
+
if (!sourceUrl) throw new Error('EARLIER_FLOW_DATABASE_URL is not set');
|
|
32
|
+
if (!args.dryRun && !args.organisation?.trim()) throw new Error('--organisation "<lab name>" is required');
|
|
33
|
+
|
|
34
|
+
const source = new pg.Pool({ connectionString: sourceUrl, max: 2 });
|
|
35
|
+
let plan;
|
|
36
|
+
try {
|
|
37
|
+
// A read-only transaction: the source cannot change by mistake.
|
|
38
|
+
const client = await source.connect();
|
|
39
|
+
try {
|
|
40
|
+
await client.query('BEGIN TRANSACTION READ ONLY');
|
|
41
|
+
plan = planImport(await readEarlierFlow(client));
|
|
42
|
+
await client.query('COMMIT');
|
|
43
|
+
} finally {
|
|
44
|
+
client.release();
|
|
45
|
+
}
|
|
46
|
+
} finally {
|
|
47
|
+
await source.end();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
console.log(JSON.stringify({ plan: summarisePlan(plan) }, null, 2));
|
|
51
|
+
if (args.dryRun) return;
|
|
52
|
+
|
|
53
|
+
const target = openDatabase({ max: 2 });
|
|
54
|
+
try {
|
|
55
|
+
await migrateDatabase(target.db);
|
|
56
|
+
const result = await applyImport(target.db, plan, { organisationName: args.organisation });
|
|
57
|
+
console.log(JSON.stringify({ imported: result.counts }, null, 2));
|
|
58
|
+
} finally {
|
|
59
|
+
await target.close();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
main().catch((error) => {
|
|
64
|
+
console.error(`[import] ${error.message}`);
|
|
65
|
+
process.exit(1);
|
|
66
|
+
});
|
package/server/index.js
CHANGED
|
@@ -5,11 +5,12 @@ import { seedFirstOwner } from './auth/people.js';
|
|
|
5
5
|
import { createPersonAuth } from './auth/session.js';
|
|
6
6
|
import { resolveSignInSettings } from './auth/settings.js';
|
|
7
7
|
import { migrateDatabase, openDatabase } from './db/index.js';
|
|
8
|
+
import { createIntegrations } from './integrations/index.js';
|
|
8
9
|
import { createUpdateChecker, UPDATE_CHECK_INTERVAL_MS } from './update-check.js';
|
|
9
10
|
|
|
10
11
|
const port = Number(process.env.FLOW_PORT || process.env.PORT || 19300);
|
|
11
12
|
|
|
12
|
-
//
|
|
13
|
+
// Settings first: a malformed setting stops boot before anything runs.
|
|
13
14
|
let settings;
|
|
14
15
|
try {
|
|
15
16
|
settings = resolveSignInSettings();
|
|
@@ -17,11 +18,22 @@ try {
|
|
|
17
18
|
console.error(`[auth] ${error.message}`);
|
|
18
19
|
process.exit(1);
|
|
19
20
|
}
|
|
21
|
+
let integrations;
|
|
22
|
+
try {
|
|
23
|
+
integrations = createIntegrations();
|
|
24
|
+
} catch (error) {
|
|
25
|
+
console.error(`[integrations] ${error.message}`);
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
20
28
|
|
|
21
29
|
// Flow's records live in PostgreSQL. Migrations run before the server accepts
|
|
22
|
-
// requests, so a release never serves an older schema.
|
|
30
|
+
// requests, so a release never serves an older schema. Then each enabled
|
|
31
|
+
// integration's own tables, and no others (docs/INTEGRATIONS.md).
|
|
23
32
|
const database = openDatabase();
|
|
24
33
|
await migrateDatabase(database.db);
|
|
34
|
+
await integrations.start(database.db);
|
|
35
|
+
const integrationsOn = integrations.list().filter((a) => a.enabled).map((a) => a.id);
|
|
36
|
+
console.log(`[integrations] on: ${integrationsOn.join(', ') || 'none'} (FLOW_INTEGRATIONS)`);
|
|
25
37
|
|
|
26
38
|
let auth = null;
|
|
27
39
|
if (settings.mode === 'clerk') {
|
|
@@ -49,7 +61,7 @@ if (settings.mode === 'clerk') {
|
|
|
49
61
|
...(settings.ownerGoogleDomains.length ? [`Google Workspace sign-in on ${settings.ownerGoogleDomains.join(', ')} with no password`] : []),
|
|
50
62
|
`a Clerk second factor within ${settings.ownerMfaMaxAgeMinutes} min`,
|
|
51
63
|
];
|
|
52
|
-
console.log(`[auth] Clerk sign-in on (CLERK_SECRET_KEY, ${keys.publishableSource}); public sign-up ${settings.publicSignUp ? 'on' : 'off'}; owner actions: ${ownerPaths.join(', or ')}${proxyPath ? `; Clerk proxied at ${proxyPath}` : ''}`);
|
|
64
|
+
console.log(`[auth] Clerk sign-in on (CLERK_SECRET_KEY, ${keys.publishableSource}); public sign-up ${settings.publicSignUp ? 'on' : settings.signUpDomains.length ? `only ${settings.signUpDomains.join(', ')}` : 'off'}; owner actions: ${ownerPaths.join(', or ')}${proxyPath ? `; Clerk proxied at ${proxyPath}` : ''}`);
|
|
53
65
|
// Replit's managed Clerk has no MFA, so there the Google Workspace path is
|
|
54
66
|
// the only way an owner can manage people.
|
|
55
67
|
if (!settings.ownerGoogleDomains.length && (process.env.REPL_ID || process.env.REPLIT_DEPLOYMENT)) {
|
|
@@ -64,7 +76,7 @@ const trustProxy = process.env.REPL_ID || process.env.REPLIT_DEPLOYMENT ? 1 : fa
|
|
|
64
76
|
|
|
65
77
|
const updates = createUpdateChecker();
|
|
66
78
|
|
|
67
|
-
createApp({ database, auth, trustProxy, updates }).listen(port, () => {
|
|
79
|
+
createApp({ database, auth, trustProxy, updates, integrations }).listen(port, () => {
|
|
68
80
|
console.log(`[flow] listening on ${port}`);
|
|
69
81
|
});
|
|
70
82
|
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// The adapters this build of Flow ships. server/index.js registers each one;
|
|
2
|
+
// FLOW_INTEGRATIONS chooses which are on (server/integrations/index.js).
|
|
3
|
+
import { seazonaAdapter } from './seazona/index.js';
|
|
4
|
+
import { xeroAdapter } from './xero/index.js';
|
|
5
|
+
|
|
6
|
+
export const adapters = [seazonaAdapter, xeroAdapter];
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// What GET /health says about each integration: whether it is on, its state,
|
|
2
|
+
// and, when it is ready, its own health() answer (issue #10: each adapter's
|
|
3
|
+
// mode). An adapter's health reads Flow's own data and must answer quickly;
|
|
4
|
+
// one that fails or takes longer than `timeoutMs` is reported, never thrown.
|
|
5
|
+
export async function integrationsHealth(registry, { timeoutMs = 5_000 } = {}) {
|
|
6
|
+
return Promise.all(registry.list().map(async (adapter) => {
|
|
7
|
+
const entry = { id: adapter.id, name: adapter.name, enabled: adapter.enabled, state: adapter.state };
|
|
8
|
+
if (adapter.state !== 'ready' || !adapter.capabilities.includes('health')) return entry;
|
|
9
|
+
let timer;
|
|
10
|
+
const timeout = new Promise((resolve) => {
|
|
11
|
+
timer = setTimeout(() => resolve({ ok: false, error: `health did not answer within ${timeoutMs} ms` }), timeoutMs);
|
|
12
|
+
});
|
|
13
|
+
const answer = registry.whenEnabled(adapter.id, (defined) => defined.capabilities.health())
|
|
14
|
+
.catch((error) => ({ ok: false, error: error?.message || String(error) }));
|
|
15
|
+
try {
|
|
16
|
+
return { ...entry, health: await Promise.race([answer, timeout]) };
|
|
17
|
+
} finally {
|
|
18
|
+
clearTimeout(timer);
|
|
19
|
+
}
|
|
20
|
+
}));
|
|
21
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { adapters as shipped } from './adapters.js';
|
|
2
|
+
import { createRegistry } from './port.js';
|
|
3
|
+
|
|
4
|
+
// FLOW_INTEGRATIONS: the adapter ids to turn on, separated by commas
|
|
5
|
+
// (`seazona,xero`). Unset or empty: none. An unknown id stops boot, so a typo
|
|
6
|
+
// never leaves an integration silently off.
|
|
7
|
+
export function resolveEnabledIntegrations(env = process.env, adapters = shipped) {
|
|
8
|
+
const known = new Set(adapters.map((a) => a.id));
|
|
9
|
+
const ids = [...new Set((env.FLOW_INTEGRATIONS ?? '').split(',').map((id) => id.trim().toLowerCase()).filter(Boolean))];
|
|
10
|
+
const unknown = ids.filter((id) => !known.has(id));
|
|
11
|
+
if (unknown.length) {
|
|
12
|
+
const offer = known.size ? `Known: ${[...known].join(', ')}.` : 'This build ships no integrations.';
|
|
13
|
+
throw new Error(`FLOW_INTEGRATIONS names an unknown integration: ${unknown.join(', ')}. ${offer}`);
|
|
14
|
+
}
|
|
15
|
+
return ids;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// The registry for one Flow process: every shipped adapter registered, the
|
|
19
|
+
// chosen ones enabled. Call registry.start(db) after the core migrations.
|
|
20
|
+
export function createIntegrations({ env = process.env, adapters = shipped, log = console } = {}) {
|
|
21
|
+
const enabled = resolveEnabledIntegrations(env, adapters);
|
|
22
|
+
const registry = createRegistry({ log });
|
|
23
|
+
for (const adapter of adapters) registry.register(adapter);
|
|
24
|
+
for (const id of enabled) registry.enable(id);
|
|
25
|
+
return registry;
|
|
26
|
+
}
|