@arcforgelabs/flow 2026.10.32 → 2026.10.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +9 -1
  2. package/npm-shrinkwrap.json +2 -2
  3. package/package.json +2 -1
  4. package/server/api/custom-fields.js +114 -0
  5. package/server/api/invoicing.js +58 -25
  6. package/server/api/work-moves.js +80 -0
  7. package/server/app.js +16 -0
  8. package/server/core/custom-fields.js +300 -0
  9. package/server/core/invoicing-rules.js +50 -4
  10. package/server/core/job-board.js +17 -4
  11. package/server/core/ledger.js +242 -6
  12. package/server/core/pipeline.js +29 -21
  13. package/server/core/read-before-act.js +96 -0
  14. package/server/core/reconcile.js +350 -0
  15. package/server/core/sending.js +44 -6
  16. package/server/core/tracing.js +64 -0
  17. package/server/db/migrations/0017_sync_basis.sql +1 -0
  18. package/server/db/migrations/0018_custom_fields.sql +78 -0
  19. package/server/db/migrations/meta/0017_snapshot.json +3867 -0
  20. package/server/db/migrations/meta/0018_snapshot.json +4289 -0
  21. package/server/db/migrations/meta/_journal.json +14 -0
  22. package/server/db/schema.js +62 -0
  23. package/server/index.js +11 -1
  24. package/server/integrations/port.js +10 -1
  25. package/server/integrations/seazona/act.js +48 -0
  26. package/server/integrations/seazona/feed.js +70 -50
  27. package/server/integrations/seazona/governor.js +28 -2
  28. package/server/integrations/seazona/index.js +107 -4
  29. package/server/integrations/seazona/lanes.js +81 -8
  30. package/server/integrations/seazona/ledger.js +14 -9
  31. package/server/integrations/seazona/migrations/0005_record_reread.sql +1 -0
  32. package/server/integrations/seazona/migrations/0006_sweep_findings.sql +11 -0
  33. package/server/integrations/seazona/migrations/0007_stage_moves.sql +18 -0
  34. package/server/integrations/seazona/migrations/meta/0005_snapshot.json +1329 -0
  35. package/server/integrations/seazona/migrations/meta/0006_snapshot.json +1403 -0
  36. package/server/integrations/seazona/migrations/meta/0007_snapshot.json +1519 -0
  37. package/server/integrations/seazona/migrations/meta/_journal.json +21 -0
  38. package/server/integrations/seazona/reconcile.js +40 -0
  39. package/server/integrations/seazona/scheduler.js +32 -4
  40. package/server/integrations/seazona/schema.js +47 -0
  41. package/server/integrations/seazona/settings.js +26 -0
  42. package/server/integrations/seazona/stage-moves.js +135 -0
  43. package/server/integrations/seazona/store.js +24 -2
  44. package/server/integrations/seazona/sweep.js +574 -0
  45. package/server/integrations/seazona/tables.js +3 -0
  46. package/server/integrations/sentry/index.js +61 -10
  47. package/server/integrations/xero/index.js +4 -0
  48. package/server/integrations/xero/webhooks.js +35 -0
  49. package/server/ui/app-page.js +1 -1
  50. package/server/ui/app.js +40 -2
  51. package/server/ui/custom-fields.js +190 -0
  52. package/server/ui/flow.css +57 -9
  53. package/server/ui/invoicing.js +184 -22
  54. package/server/ui/job-board.js +2 -1
  55. package/server/ui/jobs.js +22 -2
  56. package/server/ui/monitoring.js +44 -2
  57. package/server/ui/routes.js +1 -1
@@ -0,0 +1,350 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { and, desc, eq, gte, inArray, lte, sql } from 'drizzle-orm';
3
+ import { backgroundJobs, billingObligations, invoices, reviewItems } from '../db/schema.js';
4
+ import { HISTORY_ACTOR } from './history.js';
5
+ import { soleOrganisation } from './pipeline.js';
6
+ import { deadHoldSince, deadSince, enqueueJob, runNextJob } from './queue.js';
7
+ import { openReview, resolveReviewFor } from './review.js';
8
+
9
+ // The daily reconcile (issue 129, docs/RECONCILE.md). Flow checks each record
10
+ // as it moves; once a day it also checks the whole. For each of the last
11
+ // `days` days it counts and totals the jobs in three places, from Flow's own
12
+ // tables only (no call to any other system):
13
+ //
14
+ // 1. the work system's copy (each adapter's `billableJobs`);
15
+ // 2. the billing obligation ledger;
16
+ // 3. the Flow invoices the ledger made, and the accounting system's copy of
17
+ // the invoices linked to them (`findInvoices`).
18
+ //
19
+ // A day where they do not agree opens one review item (`reconcile.day`) with
20
+ // the counts, the totals and up to 20 record ids for each difference. A
21
+ // difference is an alert only: the reconcile never changes a record. The item
22
+ // closes by itself when a later run agrees.
23
+ //
24
+ // A difference first seen is checked again an hour later, and opens an item
25
+ // only when it is still there: a job the pipeline has not reached yet is not
26
+ // a difference.
27
+
28
+ export const RECONCILE_KIND = 'reconcile.daily';
29
+ export const RECONCILE_REVIEW_KIND = 'reconcile.day';
30
+ export const ACTOR = 'system:reconcile';
31
+ export const RECONCILE_DAYS = 60;
32
+ export const EVIDENCE_IDS = 20;
33
+ const RUN_EVERY_MS = 24 * 60 * 60 * 1000;
34
+ const RECHECK_MS = 60 * 60 * 1000;
35
+ const TICK_MS = 10 * 60 * 1000;
36
+ const LEASE_MS = 15 * 60 * 1000;
37
+ const FIND_BATCH = 400;
38
+ // Accounting states where the ledger holds a link to an invoice that exists.
39
+ const LINKED = ['drafted', 'authorised', 'part_paid', 'paid'];
40
+
41
+ const isoDay = (ms) => new Date(ms).toISOString().slice(0, 10);
42
+ const withGst = (cents) => Math.round(cents * 1.1);
43
+
44
+ // A stable id for one day's review item, so a repeat finding refreshes it.
45
+ export function dayReviewId(organisationId, day) {
46
+ const hex = createHash('sha256').update(`${RECONCILE_REVIEW_KIND}:${organisationId}:${day}`).digest('hex');
47
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-5${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
48
+ }
49
+
50
+ function emptyDay(day) {
51
+ return {
52
+ day,
53
+ work: { count: 0, subtotalCents: 0, unpriced: 0 },
54
+ ledger: { count: 0, subtotalCents: 0, totalCents: 0 },
55
+ flowInvoices: { count: 0, totalCents: 0 },
56
+ accounting: { linked: 0, found: 0, totalCents: 0 },
57
+ differences: {
58
+ missingInLedger: [], missingInWork: [], missingInAccounting: [], accountingTotalDiffers: [],
59
+ },
60
+ };
61
+ }
62
+
63
+ const differenceCount = (entry) => Object.values(entry.differences).reduce((n, list) => n + list.length, 0);
64
+
65
+ // The evidence a review item keeps: the counts, the totals, and up to
66
+ // EVIDENCE_IDS ids for each difference with the full count beside them.
67
+ function evidenceFor(entry) {
68
+ const differences = {};
69
+ for (const [key, list] of Object.entries(entry.differences)) {
70
+ if (list.length) differences[key] = { count: list.length, ids: list.slice(0, EVIDENCE_IDS).map((d) => d.id) };
71
+ }
72
+ return { ...entry, differences };
73
+ }
74
+
75
+ const moneyAtRisk = (entry) => Object.values(entry.differences).flat()
76
+ .reduce((cents, d) => cents + Math.abs(d.cents ?? 0), 0);
77
+
78
+ // One pass over Flow's own tables. Answers one entry per day, oldest first.
79
+ export async function reconcileDays(db, organisationId, { integrations, from, to }) {
80
+ const byDay = new Map();
81
+ const dayOf = (day) => {
82
+ if (!byDay.has(day)) byDay.set(day, emptyDay(day));
83
+ return byDay.get(day);
84
+ };
85
+
86
+ // 1. The work systems' copies.
87
+ const workAdapters = integrations?.with('billableJobs') ?? [];
88
+ const work = new Map(); // `${system}:${ref}` -> job
89
+ for (const adapter of workAdapters) {
90
+ const { jobs } = await adapter.capabilities.billableJobs({ organisationId, from, to });
91
+ for (const job of jobs) {
92
+ if (!job.day || job.day < from || job.day > to) continue;
93
+ work.set(`${adapter.id}:${job.ref}`, { ...job, system: adapter.id });
94
+ const entry = dayOf(job.day);
95
+ entry.work.count += 1;
96
+ if (Number.isInteger(job.subtotalCents)) entry.work.subtotalCents += job.subtotalCents;
97
+ else entry.work.unpriced += 1;
98
+ }
99
+ }
100
+
101
+ // 2. The ledger. A job only a history import reported is left out: the
102
+ // import keeps its own reconciliation record (docs/SEAZONA.md).
103
+ const obligations = await db.select({
104
+ id: billingObligations.id,
105
+ sourceSystem: billingObligations.sourceSystem,
106
+ sourceJobRef: billingObligations.sourceJobRef,
107
+ jobDate: billingObligations.jobDate,
108
+ subtotalCents: billingObligations.subtotalCents,
109
+ totalCents: billingObligations.totalCents,
110
+ accountingState: billingObligations.accountingState,
111
+ accountingRef: billingObligations.accountingRef,
112
+ invoiceId: billingObligations.invoiceId,
113
+ invoiceStatus: invoices.status,
114
+ invoiceTotalCents: invoices.totalCents,
115
+ })
116
+ .from(billingObligations)
117
+ .leftJoin(invoices, and(eq(invoices.organisationId, billingObligations.organisationId), eq(invoices.id, billingObligations.invoiceId)))
118
+ .where(and(
119
+ eq(billingObligations.organisationId, organisationId),
120
+ gte(billingObligations.jobDate, from),
121
+ lte(billingObligations.jobDate, to),
122
+ sql`exists (select 1 from obligation_events e
123
+ where e.organisation_id = ${billingObligations.organisationId} and e.obligation_id = ${billingObligations.id}
124
+ and e.kind in ('observed', 'source_changed') and e.actor <> ${HISTORY_ACTOR})`,
125
+ ));
126
+
127
+ const reported = new Set(workAdapters.map((a) => a.id));
128
+ const inLedger = new Set();
129
+ for (const o of obligations) {
130
+ const entry = dayOf(o.jobDate);
131
+ entry.ledger.count += 1;
132
+ entry.ledger.subtotalCents += o.subtotalCents;
133
+ entry.ledger.totalCents += o.totalCents;
134
+ if (o.invoiceId) {
135
+ entry.flowInvoices.count += 1;
136
+ entry.flowInvoices.totalCents += o.invoiceTotalCents ?? 0;
137
+ }
138
+ // Only a work system that is on can say a job is gone from its copy.
139
+ if (reported.has(o.sourceSystem)) {
140
+ const key = `${o.sourceSystem}:${o.sourceJobRef}`;
141
+ inLedger.add(key);
142
+ if (!work.has(key)) entry.differences.missingInWork.push({ id: o.id, cents: o.totalCents });
143
+ }
144
+ }
145
+ for (const [key, job] of work) {
146
+ if (!inLedger.has(key)) dayOf(job.day).differences.missingInLedger.push({ id: job.ref, cents: job.subtotalCents ?? 0 });
147
+ }
148
+
149
+ // 3. The accounting system's copy of each linked invoice.
150
+ const [accounting] = integrations?.with('findInvoices') ?? [];
151
+ const linked = obligations.filter((o) => o.accountingRef && LINKED.includes(o.accountingState));
152
+ if (accounting) {
153
+ const copies = new Map();
154
+ for (let i = 0; i < linked.length; i += FIND_BATCH) {
155
+ const invoiceIds = linked.slice(i, i + FIND_BATCH).map((o) => o.accountingRef);
156
+ const { invoices: found } = await accounting.capabilities.findInvoices({ organisationId, invoiceIds });
157
+ for (const invoice of found) copies.set(String(invoice.id), invoice);
158
+ }
159
+ for (const o of linked) {
160
+ const entry = dayOf(o.jobDate);
161
+ entry.accounting.linked += 1;
162
+ const copy = copies.get(String(o.accountingRef));
163
+ if (!copy) {
164
+ entry.differences.missingInAccounting.push({ id: o.id, cents: o.totalCents });
165
+ continue;
166
+ }
167
+ entry.accounting.found += 1;
168
+ entry.accounting.totalCents += copy.totalCents ?? 0;
169
+ // An invoice Flow issued must carry Flow's total. One Flow only matched
170
+ // may carry the job's total with or without GST, as the matcher allows.
171
+ const expected = o.invoiceId && o.invoiceStatus === 'issued'
172
+ ? [o.invoiceTotalCents]
173
+ : [o.totalCents, o.subtotalCents, withGst(o.subtotalCents)];
174
+ if (copy.totalCents !== null && !expected.includes(copy.totalCents)) {
175
+ entry.differences.accountingTotalDiffers.push({ id: o.id, cents: copy.totalCents - expected[0] });
176
+ }
177
+ }
178
+ }
179
+
180
+ return {
181
+ systems: { work: workAdapters.map((a) => a.id), accounting: accounting?.id ?? null },
182
+ days: [...byDay.values()].sort((a, b) => a.day.localeCompare(b.day)),
183
+ };
184
+ }
185
+
186
+ async function openDayItems(db, organisationId) {
187
+ const rows = await db.select({ flowId: reviewItems.flowId, evidence: reviewItems.evidence }).from(reviewItems).where(and(
188
+ eq(reviewItems.organisationId, organisationId),
189
+ eq(reviewItems.kind, RECONCILE_REVIEW_KIND),
190
+ eq(reviewItems.state, 'open'),
191
+ ));
192
+ return new Map(rows.map((r) => [r.evidence?.day, r.flowId]).filter(([day]) => day));
193
+ }
194
+
195
+ // One run: reconcile, open or refresh an item for each day that still
196
+ // differs, and close the item of each day that now agrees. `unconfirmed` are
197
+ // the days the last run found different for the first time. Answers the
198
+ // summary the job keeps (GET /health shows it).
199
+ export async function runReconcile(db, organisationId, {
200
+ integrations, now = Date.now(), days = RECONCILE_DAYS, unconfirmed = [],
201
+ }) {
202
+ const to = isoDay(now);
203
+ const open = await openDayItems(db, organisationId);
204
+ // An open item older than the window is checked too, so it can close.
205
+ const oldest = [...open.keys()].reduce((min, day) => (day < min ? day : min), isoDay(now - (days - 1) * 86_400_000));
206
+ const { systems, days: entries } = await reconcileDays(db, organisationId, { integrations, from: oldest, to });
207
+ const differing = entries.filter((e) => differenceCount(e) > 0);
208
+ const recheck = new Set(unconfirmed);
209
+ const firstSeen = [];
210
+ let opened = 0;
211
+ for (const entry of differing) {
212
+ if (!open.has(entry.day) && !recheck.has(entry.day)) {
213
+ firstSeen.push(entry.day);
214
+ continue;
215
+ }
216
+ await openReview(db, organisationId, {
217
+ kind: RECONCILE_REVIEW_KIND,
218
+ entity: 'day',
219
+ flowId: dayReviewId(organisationId, entry.day),
220
+ systems: [...systems.work, ...(systems.accounting ? [systems.accounting] : [])],
221
+ evidence: evidenceFor(entry),
222
+ moneyAtRiskCents: moneyAtRisk(entry),
223
+ });
224
+ opened += 1;
225
+ }
226
+ const differs = new Set(differing.map((e) => e.day));
227
+ let closed = 0;
228
+ for (const [day, flowId] of open) {
229
+ if (differs.has(day)) continue;
230
+ await resolveReviewFor(db, organisationId, {
231
+ kind: RECONCILE_REVIEW_KIND, entity: 'day', flowId, resolution: 'a later run agreed', actor: ACTOR,
232
+ });
233
+ closed += 1;
234
+ }
235
+ return {
236
+ from: oldest,
237
+ to,
238
+ systems,
239
+ daysChecked: entries.length,
240
+ daysDifferent: differing.length,
241
+ openItems: (await openDayItems(db, organisationId)).size,
242
+ opened,
243
+ closed,
244
+ unconfirmed: firstSeen,
245
+ };
246
+ }
247
+
248
+ async function lastRun(db, organisationId) {
249
+ const [row] = await db.select().from(backgroundJobs).where(and(
250
+ eq(backgroundJobs.organisationId, organisationId),
251
+ eq(backgroundJobs.kind, RECONCILE_KIND),
252
+ inArray(backgroundJobs.state, ['succeeded', 'failed', 'dead']),
253
+ )).orderBy(desc(backgroundJobs.updatedAt)).limit(1);
254
+ return row ?? null;
255
+ }
256
+
257
+ // The last run and its result, for GET /health: counts only, no record ids.
258
+ export async function reconcileHealth(db, { now = Date.now() } = {}) {
259
+ const organisationId = await soleOrganisation(db);
260
+ if (!organisationId) return null;
261
+ const row = await lastRun(db, organisationId);
262
+ if (!row) return { lastRunAt: null };
263
+ const result = row.state === 'succeeded' ? row.result ?? {} : {};
264
+ return {
265
+ lastRunAt: row.updatedAt.toISOString(),
266
+ state: row.state,
267
+ ...(row.state === 'succeeded' ? {
268
+ agrees: result.daysDifferent === 0,
269
+ daysChecked: result.daysChecked ?? null,
270
+ daysDifferent: result.daysDifferent ?? null,
271
+ openItems: result.openItems ?? null,
272
+ late: now - row.updatedAt.getTime() > RUN_EVERY_MS + 2 * TICK_MS + LEASE_MS,
273
+ } : { error: row.lastError }),
274
+ };
275
+ }
276
+
277
+ // Is a run due? Once a day, and an hour after a run that found a new difference.
278
+ export function reconcileDue(last, nowMs) {
279
+ if (!last) return true;
280
+ const age = nowMs - last.updatedAt.getTime();
281
+ if (last.state !== 'succeeded') return age >= RECHECK_MS;
282
+ if ((last.result?.unconfirmed ?? []).length) return age >= RECHECK_MS;
283
+ return age >= RUN_EVERY_MS;
284
+ }
285
+
286
+ // The worker: every ten minutes it asks whether a run is due, and runs it on
287
+ // the job queue, so two Flow processes never run it at once. It runs only
288
+ // while an integration that feeds it is on.
289
+ export function createReconcileWorker({
290
+ db, integrations, log = console, timers = { setTimeout, clearTimeout }, now = Date.now, everyMs = TICK_MS, firstDelayMs = 60_000,
291
+ }) {
292
+ const workerId = `reconcile-${randomUUID()}`;
293
+ const startedAtMs = now();
294
+ let timer = null;
295
+ let running = null;
296
+ let stopped = true;
297
+
298
+ function schedule(delayMs) {
299
+ if (stopped) return;
300
+ timer = timers.setTimeout(tick, delayMs);
301
+ timer?.unref?.();
302
+ }
303
+
304
+ async function tick() {
305
+ timer = null;
306
+ if (stopped) return;
307
+ running = (async () => {
308
+ try {
309
+ if (!integrations?.with('billableJobs').length && !integrations?.with('findInvoices').length) return;
310
+ const organisationId = await soleOrganisation(db);
311
+ if (!organisationId) return;
312
+ const last = await lastRun(db, organisationId);
313
+ if (!reconcileDue(last, now())) return;
314
+ if (await deadSince(db, organisationId, { kind: RECONCILE_KIND, dedupeKey: 'all', since: deadHoldSince(startedAtMs, now()) })) return;
315
+ const unconfirmed = last?.state === 'succeeded' ? last.result?.unconfirmed ?? [] : [];
316
+ await enqueueJob(db, organisationId, { kind: RECONCILE_KIND, dedupeKey: 'all', maxAttempts: 3 });
317
+ const ran = await runNextJob(db, {
318
+ workerId,
319
+ leaseMs: LEASE_MS,
320
+ handlers: { [RECONCILE_KIND]: () => runReconcile(db, organisationId, { integrations, now: now(), unconfirmed }) },
321
+ });
322
+ if (!ran) return;
323
+ if (ran.outcome === 'dead') log.error?.('[reconcile] the daily reconcile gave up after its last attempt');
324
+ else if (ran.outcome === 'succeeded') {
325
+ const { daysChecked, daysDifferent, opened, closed } = ran.job.result;
326
+ (daysDifferent ? log.warn : log.info)?.(`[reconcile] ${daysChecked} days checked, ${daysDifferent} differ; ${opened} items open or refreshed, ${closed} closed`);
327
+ } else log.warn?.(`[reconcile] ${ran.outcome}: ${ran.job.lastError}`);
328
+ } catch (error) {
329
+ log.error?.(`[reconcile] ${error?.code ?? 'error'}: ${error?.message ?? error}`);
330
+ }
331
+ })();
332
+ await running;
333
+ running = null;
334
+ schedule(everyMs);
335
+ }
336
+
337
+ return {
338
+ start() {
339
+ stopped = false;
340
+ schedule(firstDelayMs);
341
+ },
342
+ async stop() {
343
+ stopped = true;
344
+ if (timer) timers.clearTimeout(timer);
345
+ timer = null;
346
+ await running;
347
+ },
348
+ tick,
349
+ };
350
+ }
@@ -1,9 +1,10 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm';
3
- import { invoiceLines, invoices } from '../db/schema.js';
3
+ import { billingObligations, invoiceLines, invoices } from '../db/schema.js';
4
4
  import { FlowError } from './errors.js';
5
5
  import { issueInvoice } from './invoices.js';
6
6
  import { soleOrganisation } from './pipeline.js';
7
+ import { needsRead, readAccountingFirst, readBeforeAct, READS_PER_PASS } from './read-before-act.js';
7
8
  import { deadHoldSince, deadSince, enqueueJob, runNextJob } from './queue.js';
8
9
 
9
10
  // Drafts, Problem, Ready and Send all ready (docs/INVOICE_SENDING.md).
@@ -60,6 +61,15 @@ async function checkDrafts(db, organisationId, drafts, accounting) {
60
61
  const found = draftProblems(draft, linesOf.get(draft.id) ?? []);
61
62
  if (found.length) problems.set(draft.id, found);
62
63
  }
64
+ // A draft whose job the ledger sent to review (the job changed after the
65
+ // draft was made, or the work system deleted it) waits for a person.
66
+ const reviewed = await db.select({ invoiceId: billingObligations.invoiceId, reason: billingObligations.holdReason })
67
+ .from(billingObligations)
68
+ .where(and(eq(billingObligations.organisationId, organisationId), inArray(billingObligations.invoiceId, ids), eq(billingObligations.holdState, 'review')));
69
+ for (const { invoiceId, reason } of reviewed) {
70
+ const found = problem('job_review', `The job needs a look before this invoice goes: ${reason ?? 'it was sent to review'}.`);
71
+ problems.set(invoiceId, [...(problems.get(invoiceId) ?? []), found]);
72
+ }
63
73
  if (accounting) {
64
74
  const outside = await accounting.capabilities.checkDrafts({ organisationId, invoiceIds: ids });
65
75
  for (const [id, found] of outside ?? []) {
@@ -146,18 +156,46 @@ async function assertCanSend(accounting, organisationId) {
146
156
  }
147
157
  }
148
158
 
159
+ // Read before act (issue #141): the jobs behind these drafts whose work
160
+ // system report is old are read again first. Answers the drafts that may go
161
+ // now; a draft whose job went to review, or could not be read, waits.
162
+ async function readJobsFirst(db, organisationId, invoiceIds, { work, now, personId }) {
163
+ if (!invoiceIds.length || typeof work?.capabilities?.readWorkItem !== 'function') return { go: invoiceIds, waiting: [] };
164
+ const jobs = await db.select().from(billingObligations)
165
+ .where(and(eq(billingObligations.organisationId, organisationId), inArray(billingObligations.invoiceId, invoiceIds)));
166
+ const old = jobs.filter((job) => needsRead(job, { work, now }));
167
+ const outcomes = await readBeforeAct(db, organisationId, old.slice(0, READS_PER_PASS), { work, now, actor: `person:${personId}` });
168
+ const held = new Set(old.slice(READS_PER_PASS).map((job) => job.invoiceId));
169
+ for (const { outcome, obligation } of outcomes.values()) {
170
+ if (outcome === 'waiting' || outcome === 'review') held.add(obligation.invoiceId);
171
+ }
172
+ return { go: invoiceIds.filter((id) => !held.has(id)), waiting: invoiceIds.filter((id) => held.has(id)) };
173
+ }
174
+
149
175
  // Send all ready: issues each ready draft, then queues it for the accounting
150
176
  // system. With `invoiceIds`, only those drafts, and each must be ready (the
151
- // Send button on one invoice). Answers { issued, queued, remaining }.
152
- export async function sendAllReady(db, organisationId, { accounting = null, personId, issueDate, invoiceIds = null }) {
177
+ // Send button on one invoice). `work` is the work integration, for the read
178
+ // before act. Answers { issued, queued, remaining, waiting }.
179
+ export async function sendAllReady(db, organisationId, { accounting = null, work = null, personId, issueDate, invoiceIds = null, now = new Date() }) {
153
180
  await assertCanSend(accounting, organisationId);
154
- let { ready } = await invoiceViews(db, organisationId, { accounting });
181
+ // Issue #45: the accounting copy reads its latest changes first, so the
182
+ // draft checks below see an invoice someone made there meanwhile.
183
+ let views = await invoiceViews(db, organisationId, { accounting });
184
+ const accountingRead = views.ready.length ? await readAccountingFirst(db, organisationId, { accounting, now }) : { outcome: 'fresh' };
185
+ if (accountingRead.changed) views = await invoiceViews(db, organisationId, { accounting });
186
+ let { ready } = views;
155
187
  if (invoiceIds) {
156
188
  const wanted = new Set(invoiceIds);
157
189
  ready = ready.filter((id) => wanted.has(id));
158
190
  if (ready.length !== wanted.size) throw new FlowError('not_ready', 'Only a ready invoice can be sent. Mark it ready first.');
159
191
  }
160
- const batch = ready.slice(0, SEND_BATCH);
192
+ const { go, waiting } = accountingRead.outcome === 'waiting'
193
+ ? { go: [], waiting: ready.slice(0, SEND_BATCH) }
194
+ : await readJobsFirst(db, organisationId, ready.slice(0, SEND_BATCH), { work, now, personId });
195
+ if (invoiceIds && waiting.length) {
196
+ throw new FlowError('not_ready', 'Flow could not confirm the job with the work system or the accounting system just now, or the job changed. Nothing was sent. Look at the invoice, then try again.');
197
+ }
198
+ const batch = go;
161
199
  let issued = 0;
162
200
  for (const invoiceId of batch) {
163
201
  // Queued first: the worker sends only issued invoices, so a stop between
@@ -173,7 +211,7 @@ export async function sendAllReady(db, organisationId, { accounting = null, pers
173
211
  }
174
212
  issued += 1;
175
213
  }
176
- return { issued, queued: accounting ? issued : 0, remaining: ready.length - batch.length };
214
+ return { issued, queued: accounting ? issued : 0, remaining: Math.max(0, ready.length - SEND_BATCH), waiting: waiting.length };
177
215
  }
178
216
 
179
217
  // Send again: issued invoices that failed go back on the queue. With no ids,
@@ -0,0 +1,64 @@
1
+ import { routePattern } from '../ui/monitoring.js';
2
+
3
+ // Performance traces for the installation's own tracing service
4
+ // (docs/SENTRY.md, "Traces"). The first integration that is on and offers the
5
+ // `traceSpan` and `traceRequest` capabilities gets them. With none on, each
6
+ // function here runs the work as it is and adds nothing.
7
+ //
8
+ // `tracers()` answers the adapters to trace with; it is read at each call,
9
+ // so an integration turned on or off later is followed.
10
+ export function createTracing({ tracers = () => [] } = {}) {
11
+ const first = () => tracers()[0]?.capabilities ?? null;
12
+ return {
13
+ // Runs `work` inside a span named `name`, of kind `op`.
14
+ span(name, op, work, attributes = {}) {
15
+ const tracer = first();
16
+ return tracer ? tracer.traceSpan({ name, op, attributes }, work) : work();
17
+ },
18
+ // Express middleware: names each request's trace by its route pattern
19
+ // ("GET /api/invoicing/invoices/:id"), never by its URL, so the same
20
+ // click on another invoice is the same name.
21
+ requests() {
22
+ return (req, res, next) => {
23
+ const rename = first()?.traceRequest() ?? null;
24
+ if (rename) res.once('finish', () => rename(requestName(req)));
25
+ next();
26
+ };
27
+ },
28
+ };
29
+ }
30
+
31
+ // The route Express matched, else the path with its ids as ":id".
32
+ export function requestName(req) {
33
+ const route = typeof req.route?.path === 'string' ? `${req.baseUrl ?? ''}${req.route.path}` : null;
34
+ return `${req.method} ${route ?? routePattern(req.originalUrl ?? req.url)}`;
35
+ }
36
+
37
+ // A span for each database query the pool runs. The span's name is the SQL
38
+ // with its values left out: Flow passes values as parameters, and any quoted
39
+ // text or number in the SQL itself becomes "?".
40
+ export function traceQueries(pool, tracing) {
41
+ const wrap = (target) => {
42
+ const query = target.query.bind(target);
43
+ target.query = (...args) => {
44
+ // Callback calls are pg's own (the pool's query runs through a
45
+ // client's): the promise call around them already has its span.
46
+ if (typeof args.at(-1) === 'function') return query(...args);
47
+ return tracing.span(sqlName(args[0]), 'db', () => query(...args), { 'db.system': 'postgresql', 'db.system.name': 'postgresql' });
48
+ };
49
+ };
50
+ wrap(pool);
51
+ // A client a transaction holds (pool.connect()) queries directly.
52
+ pool.on('connect', wrap);
53
+ return pool;
54
+ }
55
+
56
+ export function sqlName(statement) {
57
+ const text = typeof statement === 'string' ? statement : String(statement?.text ?? '');
58
+ return text
59
+ .replace(/'(?:[^']|'')*'/g, '?')
60
+ .replace(/(?<![\w$])\d+(\.\d+)?\b/g, '?')
61
+ .replace(/\s+/g, ' ')
62
+ .trim()
63
+ .slice(0, 300) || 'query';
64
+ }
@@ -0,0 +1 @@
1
+ ALTER TABLE "billing_obligations" ADD COLUMN IF NOT EXISTS "draft_basis" jsonb;
@@ -0,0 +1,78 @@
1
+ CREATE TABLE IF NOT EXISTS "job_field_events" (
2
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
3
+ "organisation_id" uuid NOT NULL,
4
+ "field_id" uuid NOT NULL,
5
+ "source_system" text NOT NULL,
6
+ "source_job_ref" text NOT NULL,
7
+ "from_value" jsonb,
8
+ "to_value" jsonb,
9
+ "actor" uuid,
10
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL
11
+ );
12
+ --> statement-breakpoint
13
+ CREATE TABLE IF NOT EXISTS "job_field_values" (
14
+ "organisation_id" uuid NOT NULL,
15
+ "field_id" uuid NOT NULL,
16
+ "source_system" text NOT NULL,
17
+ "source_job_ref" text NOT NULL,
18
+ "value" jsonb NOT NULL,
19
+ "updated_by" uuid,
20
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL,
21
+ CONSTRAINT "job_field_values_organisation_id_field_id_source_system_source_job_ref_pk" PRIMARY KEY("organisation_id","field_id","source_system","source_job_ref")
22
+ );
23
+ --> statement-breakpoint
24
+ CREATE TABLE IF NOT EXISTS "job_fields" (
25
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
26
+ "organisation_id" uuid NOT NULL,
27
+ "name" text NOT NULL,
28
+ "type" text NOT NULL,
29
+ "options" jsonb DEFAULT '[]'::jsonb NOT NULL,
30
+ "position" integer NOT NULL,
31
+ "archived_at" timestamp with time zone,
32
+ "created_by" uuid,
33
+ "updated_by" uuid,
34
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
35
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL,
36
+ CONSTRAINT "job_fields_org_id" UNIQUE("organisation_id","id"),
37
+ CONSTRAINT "job_fields_type_valid" CHECK ("job_fields"."type" in ('dropdown', 'person', 'checkbox', 'date', 'number', 'text')),
38
+ CONSTRAINT "job_fields_name_valid" CHECK (length("job_fields"."name") between 1 and 60)
39
+ );
40
+ --> statement-breakpoint
41
+ DO $flow_guard$ BEGIN
42
+ ALTER TABLE "job_field_events" ADD CONSTRAINT "job_field_events_organisation_id_organisations_id_fk" FOREIGN KEY ("organisation_id") REFERENCES "public"."organisations"("id") ON DELETE no action ON UPDATE no action;
43
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
44
+ END $flow_guard$;--> statement-breakpoint
45
+ DO $flow_guard$ BEGIN
46
+ ALTER TABLE "job_field_events" ADD CONSTRAINT "job_field_events_actor_people_id_fk" FOREIGN KEY ("actor") REFERENCES "public"."people"("id") ON DELETE set null ON UPDATE no action;
47
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
48
+ END $flow_guard$;--> statement-breakpoint
49
+ DO $flow_guard$ BEGIN
50
+ ALTER TABLE "job_field_events" ADD CONSTRAINT "job_field_events_field_same_org" FOREIGN KEY ("organisation_id","field_id") REFERENCES "public"."job_fields"("organisation_id","id") ON DELETE cascade ON UPDATE no action;
51
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
52
+ END $flow_guard$;--> statement-breakpoint
53
+ DO $flow_guard$ BEGIN
54
+ ALTER TABLE "job_field_values" ADD CONSTRAINT "job_field_values_organisation_id_organisations_id_fk" FOREIGN KEY ("organisation_id") REFERENCES "public"."organisations"("id") ON DELETE no action ON UPDATE no action;
55
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
56
+ END $flow_guard$;--> statement-breakpoint
57
+ DO $flow_guard$ BEGIN
58
+ ALTER TABLE "job_field_values" ADD CONSTRAINT "job_field_values_updated_by_people_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."people"("id") ON DELETE set null ON UPDATE no action;
59
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
60
+ END $flow_guard$;--> statement-breakpoint
61
+ DO $flow_guard$ BEGIN
62
+ ALTER TABLE "job_field_values" ADD CONSTRAINT "job_field_values_field_same_org" FOREIGN KEY ("organisation_id","field_id") REFERENCES "public"."job_fields"("organisation_id","id") ON DELETE cascade ON UPDATE no action;
63
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
64
+ END $flow_guard$;--> statement-breakpoint
65
+ DO $flow_guard$ BEGIN
66
+ ALTER TABLE "job_fields" ADD CONSTRAINT "job_fields_organisation_id_organisations_id_fk" FOREIGN KEY ("organisation_id") REFERENCES "public"."organisations"("id") ON DELETE no action ON UPDATE no action;
67
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
68
+ END $flow_guard$;--> statement-breakpoint
69
+ DO $flow_guard$ BEGIN
70
+ ALTER TABLE "job_fields" ADD CONSTRAINT "job_fields_created_by_people_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."people"("id") ON DELETE set null ON UPDATE no action;
71
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
72
+ END $flow_guard$;--> statement-breakpoint
73
+ DO $flow_guard$ BEGIN
74
+ ALTER TABLE "job_fields" ADD CONSTRAINT "job_fields_updated_by_people_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."people"("id") ON DELETE set null ON UPDATE no action;
75
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
76
+ END $flow_guard$;--> statement-breakpoint
77
+ CREATE INDEX IF NOT EXISTS "job_field_events_job" ON "job_field_events" USING btree ("organisation_id","source_system","source_job_ref","created_at");--> statement-breakpoint
78
+ CREATE INDEX IF NOT EXISTS "job_field_values_job" ON "job_field_values" USING btree ("organisation_id","source_system","source_job_ref");