@arcforgelabs/flow 2026.10.27 → 2026.10.30
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 +1 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -2
- package/server/api/invoicing.js +96 -40
- package/server/app.js +3 -1
- package/server/core/automations.js +46 -1
- package/server/core/invoice-rules.js +4 -2
- package/server/core/invoices.js +4 -2
- package/server/core/job-board.js +130 -0
- package/server/core/ledger.js +12 -5
- package/server/core/line-mapping.js +337 -0
- package/server/core/pipeline.js +18 -6
- package/server/core/queue.js +22 -0
- package/server/core/sending.js +20 -7
- package/server/db/migrate-all.js +26 -0
- package/server/db/migrations/0010_invoicing_rules.sql +34 -13
- package/server/db/migrations/0011_invoice_rules.sql +11 -5
- package/server/db/migrations/0012_invoice_sending.sql +18 -9
- package/server/db/migrations/0013_line_mappings.sql +35 -0
- package/server/db/migrations/meta/0013_snapshot.json +3568 -0
- package/server/db/migrations/meta/_journal.json +7 -0
- package/server/db/schema.js +43 -0
- package/server/import/at-start.js +64 -2
- package/server/index.js +5 -1
- package/server/integrations/port.js +4 -1
- package/server/integrations/seazona/client.js +6 -0
- package/server/integrations/seazona/feed.js +62 -4
- package/server/integrations/seazona/governor.js +138 -13
- package/server/integrations/seazona/index.js +21 -2
- package/server/integrations/seazona/lanes.js +69 -13
- package/server/integrations/seazona/migrations/0000_api_governor.sql +3 -3
- package/server/integrations/seazona/migrations/0001_mirror.sql +9 -9
- package/server/integrations/seazona/migrations/0002_call_safety.sql +15 -0
- package/server/integrations/seazona/migrations/meta/0002_snapshot.json +882 -0
- package/server/integrations/seazona/migrations/meta/_journal.json +7 -0
- package/server/integrations/seazona/scheduler.js +37 -1
- package/server/integrations/seazona/schema.js +22 -0
- package/server/integrations/seazona/settings.js +10 -0
- package/server/integrations/seazona/store.js +74 -0
- package/server/integrations/seazona/tables.js +1 -0
- package/server/integrations/xero/drafts.js +4 -1
- package/server/integrations/xero/import.js +1 -0
- package/server/integrations/xero/lookup.js +6 -2
- package/server/integrations/xero/migrations/0000_xero_connections.sql +1 -1
- package/server/integrations/xero/migrations/0001_xero_catalogue.sql +5 -5
- package/server/integrations/xero/migrations/0002_xero_invoice_pushes.sql +2 -2
- package/server/integrations/xero/migrations/0003_xero_payment_cursors.sql +1 -1
- package/server/integrations/xero/migrations/0004_xero_mirror.sql +7 -7
- package/server/integrations/xero/migrations/0005_xero_send_modes.sql +12 -6
- package/server/ui/app-page.js +6 -4
- package/server/ui/app.js +111 -8
- package/server/ui/clerk-appearance.js +21 -0
- package/server/ui/flow.css +155 -30
- package/server/ui/invoicing-settings.js +2 -0
- package/server/ui/job-board.js +155 -0
- package/server/ui/jobs.js +220 -91
- package/server/ui/line-mappings.js +60 -0
- package/server/ui/monitoring.js +29 -7
- package/server/ui/routes.js +1 -1
- package/server/ui/search.js +145 -0
|
@@ -4,12 +4,20 @@ import { readLaneStates } from './store.js';
|
|
|
4
4
|
// The background sync, for live mode only (issue #10: index.js never creates
|
|
5
5
|
// it in sandbox or replay). It runs the lanes that are due, then sleeps until
|
|
6
6
|
// the next lane is due, between 15 seconds and 30 minutes. One run at a time.
|
|
7
|
+
// A stopped lane (issue #101) is not due until a person or a release resets it.
|
|
8
|
+
//
|
|
9
|
+
// Issue #101: each held failure is reported once to the error services
|
|
10
|
+
// (log.error, server/core/monitoring.js): when a lane starts failing or its
|
|
11
|
+
// failure code changes, and when records are quarantined. A repeat of the
|
|
12
|
+
// same failure is a warning in the host log only. The report text is stable,
|
|
13
|
+
// so the error service groups repeats.
|
|
7
14
|
|
|
8
15
|
const MIN_DELAY_MS = 15_000;
|
|
9
16
|
const MAX_DELAY_MS = 30 * 60_000;
|
|
10
17
|
|
|
11
18
|
export function createSeazonaScheduler({
|
|
12
19
|
db,
|
|
20
|
+
settings = null,
|
|
13
21
|
run,
|
|
14
22
|
now = Date.now,
|
|
15
23
|
log = console,
|
|
@@ -21,11 +29,39 @@ export function createSeazonaScheduler({
|
|
|
21
29
|
let stopped = false;
|
|
22
30
|
let nextRunAt = null;
|
|
23
31
|
let last = null;
|
|
32
|
+
// lane -> the failure code last reported, while that lane keeps failing.
|
|
33
|
+
const reported = new Map();
|
|
34
|
+
const quarantineAt = settings?.sync?.orderQuarantineFailures ?? null;
|
|
35
|
+
|
|
36
|
+
function newlyQuarantined(outcome) {
|
|
37
|
+
if (Number.isInteger(outcome.newlyQuarantined)) return outcome.newlyQuarantined;
|
|
38
|
+
const failures = outcome.failureDetails?.failures ?? [];
|
|
39
|
+
return failures.filter((f) => f.quarantinedUntil && f.failureCount === quarantineAt).length;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function report(results) {
|
|
43
|
+
for (const [lane, outcome] of Object.entries(results)) {
|
|
44
|
+
if (outcome.ok) {
|
|
45
|
+
reported.delete(lane);
|
|
46
|
+
const count = newlyQuarantined(outcome);
|
|
47
|
+
if (count) log.error?.(`[seazona] ${lane}: records quarantined after repeated failed reads`);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (reported.get(lane) === outcome.code) {
|
|
51
|
+
log.warn?.(`[seazona] ${lane}: ${outcome.error}`);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
reported.set(lane, outcome.code);
|
|
55
|
+
log.error?.(`[seazona] ${lane} lane ${outcome.status === 'stopped' ? 'stopped' : 'failed'}: ${outcome.code}`);
|
|
56
|
+
log.warn?.(`[seazona] ${lane}: ${outcome.error}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
24
59
|
|
|
25
60
|
async function nextDelayMs() {
|
|
26
61
|
const states = await readLaneStates(db);
|
|
27
62
|
let soonest = Infinity;
|
|
28
63
|
for (const lane of LANES) {
|
|
64
|
+
if (states.get(lane)?.status === 'stopped') continue;
|
|
29
65
|
const at = states.get(lane)?.nextRunAt?.getTime();
|
|
30
66
|
soonest = Math.min(soonest, Number.isFinite(at) ? at - Number(now()) : 0);
|
|
31
67
|
}
|
|
@@ -48,7 +84,7 @@ export function createSeazonaScheduler({
|
|
|
48
84
|
const result = await run();
|
|
49
85
|
const failed = Object.entries(result.results).filter(([, lane]) => !lane.ok);
|
|
50
86
|
last = { at: startedAt, ok: result.ok, lanes: Object.keys(result.results), failed: failed.map(([lane]) => lane) };
|
|
51
|
-
|
|
87
|
+
report(result.results);
|
|
52
88
|
} catch (error) {
|
|
53
89
|
last = { at: startedAt, ok: false, error: error?.message || String(error) };
|
|
54
90
|
log.error?.(`[seazona] sync run failed: ${last.error}`);
|
|
@@ -10,10 +10,17 @@ const at = (name) => timestamp(name, { withTimezone: true });
|
|
|
10
10
|
|
|
11
11
|
// One row per API client: when the next request may go, and any cooldown a
|
|
12
12
|
// 429 asked for. Every process that shares the database shares this row.
|
|
13
|
+
// Issue #101: the circuit breaker (open until, and how many times in a row it
|
|
14
|
+
// opened) and a stop that waits for a person (`halted_reason`, for example
|
|
15
|
+
// `auth` after a 401).
|
|
13
16
|
export const seazonaApiGovernor = pgTable('seazona_api_governor', {
|
|
14
17
|
clientKey: text('client_key').primaryKey(),
|
|
15
18
|
nextEligibleAt: at('next_eligible_at').notNull().default(sql`'epoch'`),
|
|
16
19
|
cooldownUntil: at('cooldown_until').notNull().default(sql`'epoch'`),
|
|
20
|
+
circuitOpenUntil: at('circuit_open_until').notNull().default(sql`'epoch'`),
|
|
21
|
+
circuitOpens: integer('circuit_opens').notNull().default(0),
|
|
22
|
+
haltedReason: text('halted_reason'),
|
|
23
|
+
haltedAt: at('halted_at'),
|
|
17
24
|
updatedAt: at('updated_at').notNull().defaultNow(),
|
|
18
25
|
});
|
|
19
26
|
|
|
@@ -155,3 +162,18 @@ export const seazonaOrderReadFailures = pgTable('seazona_order_read_failures', {
|
|
|
155
162
|
lastError: text('last_error'),
|
|
156
163
|
quarantinedUntil: at('quarantined_until'),
|
|
157
164
|
});
|
|
165
|
+
|
|
166
|
+
// Issue #101: each failed detail read of an invoice or a client, per record.
|
|
167
|
+
// Orders keep their own table (issue #12). After a few failures in a row the
|
|
168
|
+
// record is quarantined: the lane skips it and its cursor moves on.
|
|
169
|
+
export const seazonaRecordReadFailures = pgTable('seazona_record_read_failures', {
|
|
170
|
+
resource: text('resource').notNull(),
|
|
171
|
+
recordKey: text('record_key').notNull(),
|
|
172
|
+
failureCount: integer('failure_count').notNull(),
|
|
173
|
+
lastAttemptAt: at('last_attempt_at').notNull(),
|
|
174
|
+
lastHttpStatus: integer('last_http_status'),
|
|
175
|
+
lastFailureKind: text('last_failure_kind'),
|
|
176
|
+
quarantinedUntil: at('quarantined_until'),
|
|
177
|
+
}, (t) => [
|
|
178
|
+
primaryKey({ columns: [t.resource, t.recordKey] }),
|
|
179
|
+
]);
|
|
@@ -75,6 +75,16 @@ export function resolveSeazonaSettings(env = process.env) {
|
|
|
75
75
|
maxWaitMs: clampInt(env.SEAZONA_API_MAX_WAIT_MS, 120_000, 1_000, 15 * 60_000),
|
|
76
76
|
// Long enough to answer a vendor's question about last month.
|
|
77
77
|
telemetryRetentionMs: clampInt(env.SEAZONA_API_TELEMETRY_RETENTION_DAYS, 35, 1, 90) * DAY_MS,
|
|
78
|
+
// Issue #101: a vendor can cut off API access after many failed calls,
|
|
79
|
+
// even inside the rate limit. This many failed calls inside the window
|
|
80
|
+
// open the circuit: no call goes out for the pause, which doubles each
|
|
81
|
+
// time the probe after it fails, to at most 6 hours.
|
|
82
|
+
circuitFailures: clampInt(env.SEAZONA_CIRCUIT_FAILURES, 5, 1, 100),
|
|
83
|
+
circuitWindowMs: clampInt(env.SEAZONA_CIRCUIT_WINDOW_MS, 10 * 60_000, 60_000, DAY_MS),
|
|
84
|
+
circuitPauseMs: clampInt(env.SEAZONA_CIRCUIT_PAUSE_MS, 5 * 60_000, 60_000, 6 * 60 * 60_000),
|
|
85
|
+
// Failed calls allowed in one UTC day. When they are spent, calls stop
|
|
86
|
+
// until the next day.
|
|
87
|
+
dailyFailureBudget: clampInt(env.SEAZONA_DAILY_FAILURE_BUDGET, 50, 1, 10_000),
|
|
78
88
|
};
|
|
79
89
|
|
|
80
90
|
const MINUTE = 60_000;
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
seazonaLaneState,
|
|
7
7
|
seazonaOrderReadFailures,
|
|
8
8
|
seazonaOrders,
|
|
9
|
+
seazonaRecordReadFailures,
|
|
9
10
|
seazonaReference,
|
|
10
11
|
} from './schema.js';
|
|
11
12
|
|
|
@@ -192,6 +193,60 @@ export async function readQuarantinedOrders(db, { at, limit = 25 }) {
|
|
|
192
193
|
}));
|
|
193
194
|
}
|
|
194
195
|
|
|
196
|
+
// Issue #101: failed detail reads of invoices and clients, per record.
|
|
197
|
+
// Answers Map(key -> row) for the keys that are quarantined at `at`.
|
|
198
|
+
export async function readQuarantinedRecords(db, resource, keys, { at }) {
|
|
199
|
+
if (!keys.length) return new Map();
|
|
200
|
+
const rows = await db.select().from(seazonaRecordReadFailures).where(and(
|
|
201
|
+
eq(seazonaRecordReadFailures.resource, resource),
|
|
202
|
+
inArray(seazonaRecordReadFailures.recordKey, keys),
|
|
203
|
+
gt(seazonaRecordReadFailures.quarantinedUntil, at),
|
|
204
|
+
));
|
|
205
|
+
return new Map(rows.map((row) => [row.recordKey, row]));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export async function clearRecordFailures(db, resource, keys) {
|
|
209
|
+
if (!keys.length) return;
|
|
210
|
+
await db.delete(seazonaRecordReadFailures).where(and(
|
|
211
|
+
eq(seazonaRecordReadFailures.resource, resource),
|
|
212
|
+
inArray(seazonaRecordReadFailures.recordKey, keys),
|
|
213
|
+
));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Records one failed read, as recordOrderFailure does for orders. Answers
|
|
217
|
+
// { failureCount, quarantinedUntil }.
|
|
218
|
+
export async function recordRecordFailure(db, resource, key, failure, { at, quarantineFailures, quarantineMs }) {
|
|
219
|
+
const until = new Date(at.getTime() + quarantineMs);
|
|
220
|
+
const [row] = await db.insert(seazonaRecordReadFailures).values({
|
|
221
|
+
resource,
|
|
222
|
+
recordKey: key,
|
|
223
|
+
failureCount: 1,
|
|
224
|
+
lastAttemptAt: at,
|
|
225
|
+
lastHttpStatus: failure.httpStatus,
|
|
226
|
+
lastFailureKind: failure.kind,
|
|
227
|
+
quarantinedUntil: quarantineFailures <= 1 ? until : null,
|
|
228
|
+
}).onConflictDoUpdate({
|
|
229
|
+
target: [seazonaRecordReadFailures.resource, seazonaRecordReadFailures.recordKey],
|
|
230
|
+
set: {
|
|
231
|
+
failureCount: sql`${seazonaRecordReadFailures.failureCount} + 1`,
|
|
232
|
+
lastAttemptAt: at,
|
|
233
|
+
lastHttpStatus: failure.httpStatus,
|
|
234
|
+
lastFailureKind: failure.kind,
|
|
235
|
+
quarantinedUntil: sql`case when ${seazonaRecordReadFailures.failureCount} + 1 >= ${quarantineFailures}
|
|
236
|
+
then ${until}::timestamptz else null end`,
|
|
237
|
+
},
|
|
238
|
+
}).returning();
|
|
239
|
+
return { failureCount: row.failureCount, quarantinedUntil: row.quarantinedUntil?.toISOString() ?? null };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export async function readQuarantinedRecordCounts(db, { at }) {
|
|
243
|
+
const rows = await db.select({ resource: seazonaRecordReadFailures.resource, records: sql`count(*)::int` })
|
|
244
|
+
.from(seazonaRecordReadFailures)
|
|
245
|
+
.where(gt(seazonaRecordReadFailures.quarantinedUntil, at))
|
|
246
|
+
.groupBy(seazonaRecordReadFailures.resource);
|
|
247
|
+
return Object.fromEntries(rows.map((row) => [row.resource, row.records]));
|
|
248
|
+
}
|
|
249
|
+
|
|
195
250
|
// Lane state.
|
|
196
251
|
|
|
197
252
|
export async function readLaneStates(db) {
|
|
@@ -214,6 +269,25 @@ export async function claimLane(db, lane, { at, stuckMs }) {
|
|
|
214
269
|
return claimed.length > 0;
|
|
215
270
|
}
|
|
216
271
|
|
|
272
|
+
// Issue #101: a lane stopped for a person or a release runs again. Answers
|
|
273
|
+
// the lanes it reset.
|
|
274
|
+
export async function resetStoppedLanes(db, { at }) {
|
|
275
|
+
const rows = await db.update(seazonaLaneState)
|
|
276
|
+
.set({ status: 'idle', nextRunAt: at, failureCount: 0, heldReason: null, updatedAt: at })
|
|
277
|
+
.where(eq(seazonaLaneState.status, 'stopped'))
|
|
278
|
+
.returning({ lane: seazonaLaneState.lane });
|
|
279
|
+
return rows.map((row) => row.lane);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// A person acted: every lane that failed or stopped runs at the next tick.
|
|
283
|
+
export async function resetFailedLanes(db, { at }) {
|
|
284
|
+
const rows = await db.update(seazonaLaneState)
|
|
285
|
+
.set({ status: 'idle', nextRunAt: at, failureCount: 0, heldReason: null, updatedAt: at })
|
|
286
|
+
.where(inArray(seazonaLaneState.status, ['stopped', 'failed', 'held']))
|
|
287
|
+
.returning({ lane: seazonaLaneState.lane });
|
|
288
|
+
return rows.map((row) => row.lane);
|
|
289
|
+
}
|
|
290
|
+
|
|
217
291
|
export async function finishLane(db, lane, patch) {
|
|
218
292
|
await db.update(seazonaLaneState).set({ ...patch, updatedAt: patch.lastAttemptAt ?? new Date() }).where(eq(seazonaLaneState.lane, lane));
|
|
219
293
|
}
|
|
@@ -94,7 +94,7 @@ async function documentFor(db, organisationId, invoiceId) {
|
|
|
94
94
|
currency: invoice.currency,
|
|
95
95
|
contact: { id: contact.id, name: contact.name, email: contact.email },
|
|
96
96
|
lines: invoice.lines.map((l) => ({
|
|
97
|
-
position: l.position, itemCode: codes.get(l.priceItemId) ?? null, description: l.description, quantity: l.quantity,
|
|
97
|
+
position: l.position, itemCode: codes.get(l.priceItemId) ?? null, ...(l.accountCode ? { accountCode: l.accountCode } : {}), description: l.description, quantity: l.quantity,
|
|
98
98
|
unitAmountCents: l.unitAmountCents, taxCode: l.taxCode, lineAmountCents: l.lineAmountCents, taxCents: l.taxCents,
|
|
99
99
|
})),
|
|
100
100
|
subtotalCents: invoice.subtotalCents,
|
|
@@ -131,6 +131,9 @@ export function buildDraft(document, { settings, contact, items, sendMode = 'acc
|
|
|
131
131
|
if (line.itemCode) {
|
|
132
132
|
if (items.has(line.itemCode)) out.ItemCode = line.itemCode;
|
|
133
133
|
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 }));
|
|
134
|
+
} else if (line.accountCode) {
|
|
135
|
+
// A line with no item books to its own account, such as a surcharge.
|
|
136
|
+
out.AccountCode = line.accountCode;
|
|
134
137
|
}
|
|
135
138
|
if (tracking) out.Tracking = [tracking];
|
|
136
139
|
return out;
|
|
@@ -109,6 +109,7 @@ export function createImportWorker({ db, runtime, log = console, timers = { setT
|
|
|
109
109
|
if (ran) {
|
|
110
110
|
busy = true;
|
|
111
111
|
const read = (ran.job.result?.results ?? []).map((r) => `${r.resource} ${r.read}`).join(', ');
|
|
112
|
+
if (ran.outcome === 'dead') log.error?.('[xero] the history import gave up after its last attempt');
|
|
112
113
|
log.info?.(`[xero] import ${ran.outcome} for organisation ${ran.job.organisationId}${read ? `: ${read}` : ''}${ran.job.result?.more ? `; more (${ran.job.result.more})` : ''}`);
|
|
113
114
|
}
|
|
114
115
|
} catch (error) {
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { sql } from 'drizzle-orm';
|
|
2
2
|
import { ledgerState } from './ledger.js';
|
|
3
|
-
import { MIRROR_RESOURCES } from './mirror.js';
|
|
4
3
|
|
|
5
4
|
// What the core's matcher (server/core/pipeline.js) reads from Xero: the
|
|
6
5
|
// customers and the sales invoices in Flow's own copy. It never calls Xero, so
|
|
@@ -10,6 +9,11 @@ import { MIRROR_RESOURCES } from './mirror.js';
|
|
|
10
9
|
// stops counting as proof.
|
|
11
10
|
export const MIRROR_FRESH_MS = 24 * 60 * 60 * 1000;
|
|
12
11
|
|
|
12
|
+
// The copies that prove whether Xero holds an invoice for a job. Payments do
|
|
13
|
+
// not: a connection without a payments scope (the default) never copies them,
|
|
14
|
+
// and must still match jobs.
|
|
15
|
+
export const PROOF_RESOURCES = Object.freeze(['invoices', 'credit_notes']);
|
|
16
|
+
|
|
13
17
|
export async function listCustomers(db, organisationId) {
|
|
14
18
|
const { rows } = await db.execute(sql`
|
|
15
19
|
select xero_contact_id, name, status from xero_contacts
|
|
@@ -26,7 +30,7 @@ export async function mirrorState(db, organisationId, { now = new Date() } = {})
|
|
|
26
30
|
where organisation_id = ${organisationId}`);
|
|
27
31
|
const byResource = new Map(rows.map((r) => [r.resource, r]));
|
|
28
32
|
const invoices = byResource.get('invoices');
|
|
29
|
-
const finished =
|
|
33
|
+
const finished = PROOF_RESOURCES.every((r) => (byResource.get(r)?.passes ?? 0) > 0);
|
|
30
34
|
const lastPass = invoices?.last_pass_finished_at ? new Date(invoices.last_pass_finished_at) : null;
|
|
31
35
|
const { rows: [changed] } = await db.execute(sql`
|
|
32
36
|
select max(last_changed_at) as at from xero_invoices where organisation_id = ${organisationId}`);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
-- The Xero connection of each Flow organisation. The refresh token is
|
|
2
2
|
-- AES-256-GCM ciphertext (XERO_TOKEN_KEY); the plain token is never stored.
|
|
3
|
-
CREATE TABLE "xero_connections" (
|
|
3
|
+
CREATE TABLE IF NOT EXISTS "xero_connections" (
|
|
4
4
|
"organisation_id" uuid PRIMARY KEY NOT NULL REFERENCES "organisations"("id"),
|
|
5
5
|
"mode" text NOT NULL,
|
|
6
6
|
"tenant_id" text NOT NULL,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
-- Flow's copy of the Xero contacts and items of each connected organisation,
|
|
2
2
|
-- and a record of each sync. Xero owns these records; a sync replaces the copy.
|
|
3
|
-
CREATE TABLE "xero_contacts" (
|
|
3
|
+
CREATE TABLE IF NOT EXISTS "xero_contacts" (
|
|
4
4
|
"organisation_id" uuid NOT NULL REFERENCES "organisations"("id"),
|
|
5
5
|
"xero_contact_id" text NOT NULL,
|
|
6
6
|
"name" text NOT NULL,
|
|
@@ -14,9 +14,9 @@ CREATE TABLE "xero_contacts" (
|
|
|
14
14
|
CONSTRAINT "xero_contacts_pk" PRIMARY KEY("organisation_id", "xero_contact_id")
|
|
15
15
|
);
|
|
16
16
|
--> statement-breakpoint
|
|
17
|
-
CREATE INDEX "xero_contacts_org_name" ON "xero_contacts" ("organisation_id", lower("name"));
|
|
17
|
+
CREATE INDEX IF NOT EXISTS "xero_contacts_org_name" ON "xero_contacts" ("organisation_id", lower("name"));
|
|
18
18
|
--> statement-breakpoint
|
|
19
|
-
CREATE TABLE "xero_items" (
|
|
19
|
+
CREATE TABLE IF NOT EXISTS "xero_items" (
|
|
20
20
|
"organisation_id" uuid NOT NULL REFERENCES "organisations"("id"),
|
|
21
21
|
"xero_item_id" text NOT NULL,
|
|
22
22
|
"code" text NOT NULL,
|
|
@@ -32,7 +32,7 @@ CREATE TABLE "xero_items" (
|
|
|
32
32
|
CONSTRAINT "xero_items_org_code" UNIQUE("organisation_id", "code")
|
|
33
33
|
);
|
|
34
34
|
--> statement-breakpoint
|
|
35
|
-
CREATE TABLE "xero_sync_runs" (
|
|
35
|
+
CREATE TABLE IF NOT EXISTS "xero_sync_runs" (
|
|
36
36
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
|
37
37
|
"organisation_id" uuid NOT NULL REFERENCES "organisations"("id"),
|
|
38
38
|
"status" text DEFAULT 'running' NOT NULL,
|
|
@@ -46,4 +46,4 @@ CREATE TABLE "xero_sync_runs" (
|
|
|
46
46
|
CONSTRAINT "xero_sync_runs_status_valid" CHECK ("status" in ('running', 'succeeded', 'failed'))
|
|
47
47
|
);
|
|
48
48
|
--> statement-breakpoint
|
|
49
|
-
CREATE INDEX "xero_sync_runs_org_started" ON "xero_sync_runs" ("organisation_id", "started_at");
|
|
49
|
+
CREATE INDEX IF NOT EXISTS "xero_sync_runs_org_started" ON "xero_sync_runs" ("organisation_id", "started_at");
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
-- One row for each issued Flow invoice version sent to Xero as a draft. It
|
|
2
2
|
-- holds the exact request and its Idempotency-Key from before the request
|
|
3
3
|
-- leaves, so an uncertain outcome is resolved without a second invoice.
|
|
4
|
-
CREATE TABLE "xero_invoice_pushes" (
|
|
4
|
+
CREATE TABLE IF NOT EXISTS "xero_invoice_pushes" (
|
|
5
5
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
|
6
6
|
"organisation_id" uuid NOT NULL,
|
|
7
7
|
"invoice_id" uuid NOT NULL,
|
|
@@ -29,4 +29,4 @@ CREATE TABLE "xero_invoice_pushes" (
|
|
|
29
29
|
CONSTRAINT "xero_invoice_pushes_settled_has_id" CHECK ("state" in ('dispatching', 'ambiguous') or "xero_invoice_id" is not null)
|
|
30
30
|
);
|
|
31
31
|
--> statement-breakpoint
|
|
32
|
-
CREATE INDEX "xero_invoice_pushes_org_invoice" ON "xero_invoice_pushes" ("organisation_id", "invoice_id");
|
|
32
|
+
CREATE INDEX IF NOT EXISTS "xero_invoice_pushes_org_invoice" ON "xero_invoice_pushes" ("organisation_id", "invoice_id");
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
-- Where the next read of Xero payments starts, for each Flow organisation.
|
|
2
2
|
-- (This is the Xero adapter's own migration journal, not the core's.)
|
|
3
|
-
CREATE TABLE "xero_payment_cursors" (
|
|
3
|
+
CREATE TABLE IF NOT EXISTS "xero_payment_cursors" (
|
|
4
4
|
"organisation_id" uuid PRIMARY KEY NOT NULL REFERENCES "organisations"("id"),
|
|
5
5
|
"payments_since" timestamp with time zone NOT NULL,
|
|
6
6
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
-- without spending Xero calls. A row is upserted by its Xero id, and changes
|
|
5
5
|
-- only when its content hash changes. Free text (references, line
|
|
6
6
|
-- descriptions) is kept only with XERO_KEEP_REFERENCE=on.
|
|
7
|
-
CREATE TABLE "xero_invoices" (
|
|
7
|
+
CREATE TABLE IF NOT EXISTS "xero_invoices" (
|
|
8
8
|
"organisation_id" uuid NOT NULL REFERENCES "organisations"("id"),
|
|
9
9
|
"xero_invoice_id" text NOT NULL,
|
|
10
10
|
"type" text NOT NULL,
|
|
@@ -30,11 +30,11 @@ CREATE TABLE "xero_invoices" (
|
|
|
30
30
|
CONSTRAINT "xero_invoices_pk" PRIMARY KEY("organisation_id", "xero_invoice_id")
|
|
31
31
|
);
|
|
32
32
|
--> statement-breakpoint
|
|
33
|
-
CREATE INDEX "xero_invoices_number" ON "xero_invoices" ("organisation_id", "invoice_number");
|
|
33
|
+
CREATE INDEX IF NOT EXISTS "xero_invoices_number" ON "xero_invoices" ("organisation_id", "invoice_number");
|
|
34
34
|
--> statement-breakpoint
|
|
35
|
-
CREATE INDEX "xero_invoices_contact" ON "xero_invoices" ("organisation_id", "xero_contact_id", "issued_on");
|
|
35
|
+
CREATE INDEX IF NOT EXISTS "xero_invoices_contact" ON "xero_invoices" ("organisation_id", "xero_contact_id", "issued_on");
|
|
36
36
|
--> statement-breakpoint
|
|
37
|
-
CREATE TABLE "xero_credit_notes" (
|
|
37
|
+
CREATE TABLE IF NOT EXISTS "xero_credit_notes" (
|
|
38
38
|
"organisation_id" uuid NOT NULL REFERENCES "organisations"("id"),
|
|
39
39
|
"xero_credit_note_id" text NOT NULL,
|
|
40
40
|
"type" text NOT NULL,
|
|
@@ -57,7 +57,7 @@ CREATE TABLE "xero_credit_notes" (
|
|
|
57
57
|
CONSTRAINT "xero_credit_notes_pk" PRIMARY KEY("organisation_id", "xero_credit_note_id")
|
|
58
58
|
);
|
|
59
59
|
--> statement-breakpoint
|
|
60
|
-
CREATE TABLE "xero_payments" (
|
|
60
|
+
CREATE TABLE IF NOT EXISTS "xero_payments" (
|
|
61
61
|
"organisation_id" uuid NOT NULL REFERENCES "organisations"("id"),
|
|
62
62
|
"xero_payment_id" text NOT NULL,
|
|
63
63
|
"payment_type" text,
|
|
@@ -75,12 +75,12 @@ CREATE TABLE "xero_payments" (
|
|
|
75
75
|
CONSTRAINT "xero_payments_pk" PRIMARY KEY("organisation_id", "xero_payment_id")
|
|
76
76
|
);
|
|
77
77
|
--> statement-breakpoint
|
|
78
|
-
CREATE INDEX "xero_payments_invoice" ON "xero_payments" ("organisation_id", "xero_invoice_id");
|
|
78
|
+
CREATE INDEX IF NOT EXISTS "xero_payments_invoice" ON "xero_payments" ("organisation_id", "xero_invoice_id");
|
|
79
79
|
--> statement-breakpoint
|
|
80
80
|
-- Where the next read of each resource starts. `modified_since` is the
|
|
81
81
|
-- keyset: the next read asks Xero for records changed since then, oldest
|
|
82
82
|
-- change first. `page` moves on only when one second holds a full page.
|
|
83
|
-
CREATE TABLE "xero_mirror_cursors" (
|
|
83
|
+
CREATE TABLE IF NOT EXISTS "xero_mirror_cursors" (
|
|
84
84
|
"organisation_id" uuid NOT NULL REFERENCES "organisations"("id"),
|
|
85
85
|
"resource" text NOT NULL,
|
|
86
86
|
"modified_since" timestamp with time zone,
|
|
@@ -2,9 +2,15 @@
|
|
|
2
2
|
-- sent approved, whether Xero emailed it to the customer. Xero's email action
|
|
3
3
|
-- is not idempotent, so Flow records `sending` before the call and retries
|
|
4
4
|
-- only a refusal (`failed`), never an unknown outcome.
|
|
5
|
-
ALTER TABLE "xero_invoice_pushes" ADD COLUMN "send_mode" text DEFAULT 'accounting_draft' NOT NULL;--> statement-breakpoint
|
|
6
|
-
ALTER TABLE "xero_invoice_pushes" ADD COLUMN "email_state" text;--> statement-breakpoint
|
|
7
|
-
ALTER TABLE "xero_invoice_pushes" ADD COLUMN "email_error" text;--> statement-breakpoint
|
|
8
|
-
ALTER TABLE "xero_invoice_pushes" ADD COLUMN "emailed_at" timestamp with time zone;--> statement-breakpoint
|
|
9
|
-
|
|
10
|
-
ALTER TABLE "xero_invoice_pushes" ADD CONSTRAINT "
|
|
5
|
+
ALTER TABLE "xero_invoice_pushes" ADD COLUMN IF NOT EXISTS "send_mode" text DEFAULT 'accounting_draft' NOT NULL;--> statement-breakpoint
|
|
6
|
+
ALTER TABLE "xero_invoice_pushes" ADD COLUMN IF NOT EXISTS "email_state" text;--> statement-breakpoint
|
|
7
|
+
ALTER TABLE "xero_invoice_pushes" ADD COLUMN IF NOT EXISTS "email_error" text;--> statement-breakpoint
|
|
8
|
+
ALTER TABLE "xero_invoice_pushes" ADD COLUMN IF NOT EXISTS "emailed_at" timestamp with time zone;--> statement-breakpoint
|
|
9
|
+
DO $flow_guard$ BEGIN
|
|
10
|
+
ALTER TABLE "xero_invoice_pushes" ADD CONSTRAINT "xero_invoice_pushes_send_mode_valid" CHECK ("send_mode" in ('accounting_draft', 'approve_and_email'));
|
|
11
|
+
EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
|
|
12
|
+
END $flow_guard$;--> statement-breakpoint
|
|
13
|
+
DO $flow_guard$ BEGIN
|
|
14
|
+
ALTER TABLE "xero_invoice_pushes" ADD CONSTRAINT "xero_invoice_pushes_email_state_valid" CHECK ("email_state" is null or "email_state" in ('pending', 'sending', 'sent', 'failed', 'unknown'));
|
|
15
|
+
EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
|
|
16
|
+
END $flow_guard$;
|
package/server/ui/app-page.js
CHANGED
|
@@ -34,14 +34,16 @@ const BODY = `<div class="fx-app">
|
|
|
34
34
|
<div class="fx-card fx-message__card fx-state-no-access" id="no-access"></div>
|
|
35
35
|
</section>
|
|
36
36
|
|
|
37
|
-
<div class="fx-state-signed-in fx-shell">
|
|
37
|
+
<div class="fx-state-signed-in fx-shell" id="shell">
|
|
38
38
|
<aside class="fx-side">
|
|
39
|
-
<
|
|
39
|
+
<div class="fx-side__head">
|
|
40
|
+
<a class="fx-brand" href="/">${FLOW_MARK}<span>Flow</span></a>
|
|
41
|
+
<button type="button" class="fx-btn fx-btn--ghost fx-btn--icon" id="menu-toggle" aria-controls="nav" aria-label="Menu"></button>
|
|
42
|
+
</div>
|
|
40
43
|
<nav class="fx-nav" id="nav" aria-label="Flow"></nav>
|
|
41
44
|
<div class="fx-side__end">
|
|
42
45
|
<div id="user-button" class="fx-side__user"></div>
|
|
43
|
-
<button type="button" class="fx-btn fx-btn--ghost fx-btn--icon" id="
|
|
44
|
-
<button type="button" class="fx-btn fx-btn--ghost fx-btn--icon fx-mode-toggle" data-mode-toggle>Light or dark</button>
|
|
46
|
+
<button type="button" class="fx-btn fx-btn--ghost fx-btn--icon" id="search" title="Search (Ctrl+K)" aria-label="Search" aria-keyshortcuts="Control+K Meta+K"></button>
|
|
45
47
|
</div>
|
|
46
48
|
</aside>
|
|
47
49
|
<main id="main" class="fx-main" tabindex="-1"></main>
|
package/server/ui/app.js
CHANGED
|
@@ -14,6 +14,7 @@ import { automationSection } from '/assets/ui/automations.js';
|
|
|
14
14
|
import { renderInvoicingSettings, sendModeSection } from '/assets/ui/invoicing-settings.js';
|
|
15
15
|
import { renderJobs } from '/assets/ui/jobs.js';
|
|
16
16
|
import { startMonitoring } from '/assets/ui/monitoring.js';
|
|
17
|
+
import { openSearch } from '/assets/ui/search.js';
|
|
17
18
|
|
|
18
19
|
const $ = (id) => document.getElementById(id);
|
|
19
20
|
|
|
@@ -208,6 +209,8 @@ const ICON_PATHS = {
|
|
|
208
209
|
plug: '<path d="M9 3v5M15 3v5M6 8h12v3a6 6 0 01-12 0zM12 17v4"/>',
|
|
209
210
|
person: '<circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 3.6-7 8-7s8 3 8 7"/>',
|
|
210
211
|
flag: '<path d="M5 21V4M5 4h11l-2 4 2 4H5"/>',
|
|
212
|
+
search: '<circle cx="11" cy="11" r="6.5"/><path d="M16 16l4.5 4.5"/>',
|
|
213
|
+
menu: '<path d="M4 6h16M4 12h16M4 18h16"/>',
|
|
211
214
|
palette: '<path d="M12 3a9 9 0 100 18c1.1 0 1.5-.8 1.5-1.6 0-1-.8-1.4-.8-2.4 0-.9.7-1.5 1.6-1.5H17a4 4 0 004-4c0-4.7-4-8.5-9-8.5z"/><circle cx="7.5" cy="11" r="1"/><circle cx="10" cy="7" r="1"/><circle cx="15" cy="7" r="1"/>',
|
|
212
215
|
};
|
|
213
216
|
|
|
@@ -217,6 +220,87 @@ function icon(name) {
|
|
|
217
220
|
return node;
|
|
218
221
|
}
|
|
219
222
|
|
|
223
|
+
// The menu button at the top of the sidebar. On a wide screen it folds the
|
|
224
|
+
// sidebar to its icons and back, and the choice stays in this browser. On a
|
|
225
|
+
// narrow screen the sidebar is a bar across the top, and the button opens
|
|
226
|
+
// the menu below it; choosing a page, Escape or a click outside closes it.
|
|
227
|
+
const NARROW = window.matchMedia('(max-width: 820px)');
|
|
228
|
+
const FOLDED_KEY = 'flow_sidebar_folded';
|
|
229
|
+
|
|
230
|
+
function readFolded() {
|
|
231
|
+
try { return localStorage.getItem(FOLDED_KEY) === '1'; } catch { return false; }
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function drawMenu() {
|
|
235
|
+
const shell = $('shell');
|
|
236
|
+
const folded = readFolded();
|
|
237
|
+
shell.toggleAttribute('data-folded', folded);
|
|
238
|
+
const open = NARROW.matches ? shell.hasAttribute('data-menu-open') : !folded;
|
|
239
|
+
const toggle = $('menu-toggle');
|
|
240
|
+
const label = NARROW.matches ? (open ? 'Close the menu' : 'Open the menu') : (folded ? 'Show the menu' : 'Fold the menu');
|
|
241
|
+
toggle.setAttribute('aria-expanded', String(open));
|
|
242
|
+
toggle.setAttribute('aria-label', label);
|
|
243
|
+
toggle.title = label;
|
|
244
|
+
// Folded, a page's name shows when the pointer rests on its icon.
|
|
245
|
+
for (const link of document.querySelectorAll('[data-nav]')) {
|
|
246
|
+
if (folded && !NARROW.matches) link.title = link.dataset.label;
|
|
247
|
+
else link.removeAttribute('title');
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function closeMenu() {
|
|
252
|
+
if (!$('shell').hasAttribute('data-menu-open')) return;
|
|
253
|
+
$('shell').removeAttribute('data-menu-open');
|
|
254
|
+
drawMenu();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function toggleMenu() {
|
|
258
|
+
if (NARROW.matches) {
|
|
259
|
+
$('shell').toggleAttribute('data-menu-open');
|
|
260
|
+
} else {
|
|
261
|
+
try { localStorage.setItem(FOLDED_KEY, readFolded() ? '0' : '1'); } catch { /* the choice is only a convenience */ }
|
|
262
|
+
}
|
|
263
|
+
drawMenu();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
NARROW.addEventListener('change', () => { $('shell').removeAttribute('data-menu-open'); drawMenu(); });
|
|
267
|
+
document.addEventListener('keydown', (event) => {
|
|
268
|
+
if (event.key === 'Escape' && $('shell')?.hasAttribute('data-menu-open')) {
|
|
269
|
+
closeMenu();
|
|
270
|
+
$('menu-toggle').focus();
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
document.addEventListener('click', (event) => {
|
|
274
|
+
if (!event.target.closest?.('.fx-side') || event.target.closest?.('[data-nav]')) closeMenu();
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// Search, from the sidebar foot or Ctrl+K (⌘K): the pages this person may
|
|
278
|
+
// open, invoices and jobs (search.js).
|
|
279
|
+
function search() {
|
|
280
|
+
const views = Object.entries(VIEWS)
|
|
281
|
+
.filter(([, view]) => !view.ownerOnly || me?.person.role === 'owner')
|
|
282
|
+
.map(([name, view]) => ({ name, label: view.label, icon: view.icon }));
|
|
283
|
+
openSearch({ h, flow, go, icon, views });
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
document.addEventListener('keydown', (event) => {
|
|
287
|
+
if (event.key.toLowerCase() !== 'k' || !(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return;
|
|
288
|
+
if (document.body.dataset.state !== 'signed-in') return;
|
|
289
|
+
event.preventDefault();
|
|
290
|
+
search();
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// "Report a problem" in Clerk's account menu, between "Manage account" and
|
|
294
|
+
// "Sign out". Clerk mounts and unmounts the icon itself.
|
|
295
|
+
function reportMenuItem(openReport) {
|
|
296
|
+
return {
|
|
297
|
+
label: 'Report a problem',
|
|
298
|
+
onClick: () => { openReport().catch((error) => toast(error.message, 'danger')); },
|
|
299
|
+
mountIcon: (element) => element.replaceChildren(icon('flag')),
|
|
300
|
+
unmountIcon: (element) => element.replaceChildren(),
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
220
304
|
// The view and, for one record, its id: /invoices/<id> is ['invoices', '<id>'].
|
|
221
305
|
function currentView() {
|
|
222
306
|
const [name, id = null] = location.pathname.replace(/^\/+|\/+$/g, '').split('/');
|
|
@@ -230,7 +314,7 @@ async function route() {
|
|
|
230
314
|
const [view, id] = currentView();
|
|
231
315
|
const path = id ? `/${view}/${id}` : `/${view}`;
|
|
232
316
|
if (location.pathname !== path) history.replaceState(null, '', path);
|
|
233
|
-
for (const link of document.querySelectorAll('[data-nav]')) {
|
|
317
|
+
for (const link of document.querySelectorAll('a[data-nav]')) {
|
|
234
318
|
link.setAttribute('aria-current', link.dataset.nav === view ? 'page' : 'false');
|
|
235
319
|
}
|
|
236
320
|
main.replaceChildren();
|
|
@@ -242,7 +326,7 @@ async function route() {
|
|
|
242
326
|
|
|
243
327
|
// Opens a path in place; the address changes, the page stays.
|
|
244
328
|
function go(path, { replace = false } = {}) {
|
|
245
|
-
if (path !== location.pathname) history[replace ? 'replaceState' : 'pushState'](null, '', path);
|
|
329
|
+
if (path !== location.pathname + location.search) history[replace ? 'replaceState' : 'pushState'](null, '', path);
|
|
246
330
|
route();
|
|
247
331
|
}
|
|
248
332
|
|
|
@@ -286,14 +370,33 @@ function renderShell() {
|
|
|
286
370
|
if (view.ownerOnly && me?.person.role !== 'owner') continue;
|
|
287
371
|
if (view.group && view.group !== group) items.push(h('span', { class: 'fx-nav__group' }, view.group));
|
|
288
372
|
group = view.group;
|
|
289
|
-
items.push(h('a', { href: `/${name}`, 'data-nav': name, onclick: navigate }, icon(view.icon), h('span', {}, view.label)));
|
|
373
|
+
items.push(h('a', { href: `/${name}`, 'data-nav': name, 'data-label': view.label, onclick: navigate }, icon(view.icon), h('span', { class: 'fx-nav__label' }, view.label)));
|
|
290
374
|
}
|
|
291
|
-
|
|
292
|
-
|
|
375
|
+
// "Report a problem" sits at the foot of the menu, above the account chip,
|
|
376
|
+
// while Sentry and its report form are on (monitoring.js).
|
|
377
|
+
const report = h('button', { type: 'button', class: 'fx-nav__report', 'data-nav': 'report', 'data-label': 'Report a problem', hidden: true },
|
|
378
|
+
icon('flag'), h('span', { class: 'fx-nav__label' }, 'Report a problem'));
|
|
379
|
+
nav.replaceChildren(...items, report);
|
|
380
|
+
$('menu-toggle').replaceChildren(icon('menu'));
|
|
381
|
+
$('menu-toggle').onclick = toggleMenu;
|
|
382
|
+
drawMenu();
|
|
383
|
+
// The sidebar foot shows who is signed in: photo or initial, then the name
|
|
384
|
+
// (the email when the account has no name). It opens the account menu.
|
|
385
|
+
const userButton = { afterSignOutUrl: '/sign-in', showName: true };
|
|
386
|
+
$('search').replaceChildren(icon('search'));
|
|
387
|
+
$('search').onclick = search;
|
|
388
|
+
mountClerk('userButton', $('user-button'), userButton);
|
|
293
389
|
// Errors and problem reports go to the installation's own Sentry, when the
|
|
294
|
-
// Sentry integration is on (monitoring.js).
|
|
295
|
-
|
|
296
|
-
startMonitoring({ flow, person: me.person
|
|
390
|
+
// Sentry integration is on (monitoring.js). Then "Report a problem" shows
|
|
391
|
+
// in the menu and in the account menu. Flow works the same without it.
|
|
392
|
+
startMonitoring({ flow, person: me.person })
|
|
393
|
+
.then((openReport) => {
|
|
394
|
+
if (!openReport) return;
|
|
395
|
+
report.hidden = false;
|
|
396
|
+
report.onclick = () => { openReport().catch((error) => toast(error.message, 'danger')); };
|
|
397
|
+
unmountClerk($('user-button'));
|
|
398
|
+
mountClerk('userButton', $('user-button'), { ...userButton, customMenuItems: [reportMenuItem(openReport)] });
|
|
399
|
+
})
|
|
297
400
|
.catch((error) => console.warn(`[monitoring] ${error.message}`));
|
|
298
401
|
window.addEventListener('popstate', route);
|
|
299
402
|
return route();
|