@arcforgelabs/flow 2026.10.53 → 2026.11.2

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 (45) hide show
  1. package/README.md +1 -0
  2. package/SECURITY.md +7 -4
  3. package/npm-shrinkwrap.json +442 -4
  4. package/package.json +8 -3
  5. package/server/agents/auth-worker.js +7 -0
  6. package/server/agents/auth.js +50 -0
  7. package/server/agents/gate.js +138 -0
  8. package/server/agents/grants.js +51 -0
  9. package/server/agents/reads.js +47 -0
  10. package/server/agents/server.js +74 -0
  11. package/server/agents/settings.js +24 -0
  12. package/server/api/invoicing.js +27 -10
  13. package/server/app.js +9 -0
  14. package/server/core/invoice-rules.js +25 -10
  15. package/server/core/job-board.js +12 -4
  16. package/server/core/ledger.js +110 -22
  17. package/server/core/line-mapping.js +9 -5
  18. package/server/core/read-before-act.js +2 -2
  19. package/server/core/reconcile.js +15 -1
  20. package/server/core/sending.js +33 -9
  21. package/server/db/migrations/0025_powerful_leopardon.sql +1 -0
  22. package/server/db/migrations/0026_bitter_sersi.sql +38 -0
  23. package/server/db/migrations/meta/0025_snapshot.json +4709 -0
  24. package/server/db/migrations/meta/0026_snapshot.json +4926 -0
  25. package/server/db/migrations/meta/_journal.json +14 -0
  26. package/server/db/schema.js +38 -1
  27. package/server/index.js +9 -1
  28. package/server/integrations/port.js +1 -0
  29. package/server/integrations/seazona/history/backfill.js +3 -1
  30. package/server/integrations/seazona/history/index.js +4 -7
  31. package/server/integrations/seazona/index.js +5 -1
  32. package/server/integrations/seazona/lanes.js +44 -27
  33. package/server/integrations/seazona/ledger.js +17 -6
  34. package/server/integrations/seazona/reconcile.js +12 -0
  35. package/server/rate-limit.js +2 -1
  36. package/server/ui/app.js +76 -64
  37. package/server/ui/flow.css +43 -8
  38. package/server/ui/invoice-rules.js +24 -3
  39. package/server/ui/invoicing.js +76 -13
  40. package/server/ui/job-board.js +12 -7
  41. package/server/ui/job-change.js +63 -15
  42. package/server/ui/jobs.js +4 -4
  43. package/server/ui/mobile.js +17 -0
  44. package/server/ui/routes.js +1 -1
  45. package/server/ui/sync-status.js +67 -0
@@ -100,12 +100,13 @@ async function normaliseLines(tx, organisationId, lines) {
100
100
  priceItemId = item?.archivedAt ? null : (item?.id ?? null);
101
101
  description = (description ?? item?.name ?? '').trim();
102
102
  unitAmountCents ??= item?.unitAmountCents;
103
- taxCode ??= item?.taxCode ?? 'GST';
103
+ taxCode ??= item?.taxCode ?? (line.taxCode === null ? null : 'GST');
104
104
  const quantity = line.quantity ?? 1;
105
105
  if (!description) throw new FlowError('invalid', `line ${index + 1}: description is required`);
106
106
  let totals;
107
107
  try {
108
- totals = lineTotals({ quantity, unitAmountCents, taxCode });
108
+ totals = lineTotals({ quantity, unitAmountCents, taxCode: taxCode ?? 'GST_FREE' });
109
+ if (taxCode === null) totals.taxCents = null;
109
110
  } catch (error) {
110
111
  throw new FlowError('invalid', `line ${index + 1}: ${error.message}`);
111
112
  }
@@ -128,6 +129,48 @@ function fingerprint(lines) {
128
129
  return createHash('sha256').update(JSON.stringify(keys)).digest('hex');
129
130
  }
130
131
 
132
+ const SOURCE_TOTAL_FIELDS = ['subtotalCents', 'taxCents', 'totalCents'];
133
+ const REVIEW_SOURCE_TOTALS = 'job.source_totals';
134
+
135
+ function readSourceTotals(raw) {
136
+ if (raw == null) return null;
137
+ const totals = {};
138
+ for (const field of SOURCE_TOTAL_FIELDS) {
139
+ if (raw[field] == null) continue;
140
+ if (!Number.isSafeInteger(raw[field])) throw new FlowError('invalid', 'source ' + field + ' must be whole cents');
141
+ totals[field] = raw[field];
142
+ }
143
+ return Object.keys(totals).length ? totals : null;
144
+ }
145
+
146
+ // A source invoice total is evidence, not permission to allocate its tax.
147
+ export function sourceBillingProblems(obligation) {
148
+ const reasons = [];
149
+ const lines = obligation.lines ?? [];
150
+ if (lines.some((line) => line.taxCode === null)) reasons.push('a source line has unknown tax treatment');
151
+ const calculated = invoiceTotals(lines);
152
+ if (SOURCE_TOTAL_FIELDS.some((field) => obligation.sourceTotals?.[field] != null && obligation.sourceTotals[field] !== calculated[field])) {
153
+ reasons.push('the source invoice totals differ from the reconstructed lines');
154
+ }
155
+ return reasons;
156
+ }
157
+
158
+ async function recordSourceReview(tx, row, actor) {
159
+ const reasons = sourceBillingProblems(row);
160
+ if (reasons.length) {
161
+ await openReview(tx, row.organisationId, {
162
+ kind: REVIEW_SOURCE_TOTALS, entity: 'obligation', flowId: row.id,
163
+ systems: [row.sourceSystem, 'flow'], moneyAtRiskCents: Math.abs(row.totalCents),
164
+ evidence: { reasons, sourceTotals: row.sourceTotals, reconstructed: invoiceTotals(row.lines),
165
+ unknownTaxLines: row.lines.flatMap((line, index) => line.taxCode === null ? [index + 1] : []),
166
+ sourceJobRef: row.sourceJobRef, invoiceId: row.invoiceId },
167
+ });
168
+ } else {
169
+ await resolveReviewFor(tx, row.organisationId, { kind: REVIEW_SOURCE_TOTALS, entity: 'obligation', flowId: row.id, resolution: 'source_totals_match', actor });
170
+ }
171
+ return row;
172
+ }
173
+
131
174
  // Records what the work system reports about a billable job. The first report
132
175
  // makes the obligation; a later report with other lines or totals changes it.
133
176
  // If the job changes after Flow made its invoice, the obligation goes to review.
@@ -137,17 +180,20 @@ export async function observeJob(db, organisationId, job) {
137
180
  const seenAt = job.observedAt ? new Date(job.observedAt) : new Date();
138
181
  return db.transaction(async (tx) => {
139
182
  const lines = await normaliseLines(tx, organisationId, job.lines);
183
+ const sourceTotals = readSourceTotals(job.sourceTotals);
184
+ const totals = { ...invoiceTotals(lines), ...sourceTotals };
185
+ if (sourceTotals?.totalCents == null) totals.totalCents = totals.subtotalCents + totals.taxCents;
140
186
  const facts = {
141
187
  sourceInvoiceRef: job.sourceInvoiceRef ?? null,
142
188
  caseRef: job.caseRef ?? null,
143
189
  contactId: job.contactId ?? null,
144
190
  practitioner: job.practitioner ?? null,
145
- shippedOn: job.shippedOn ?? null,
146
191
  jobDate: job.jobDate ?? null,
147
192
  sourceStatus: job.sourceStatus ?? null,
148
193
  lines,
149
194
  lineFingerprint: fingerprint(lines),
150
- ...invoiceTotals(lines),
195
+ sourceTotals,
196
+ ...totals,
151
197
  mappingComplete: lines.every((l) => l.priceItemId || l.accountCode),
152
198
  };
153
199
  const [existing] = await tx.select().from(billingObligations)
@@ -158,26 +204,36 @@ export async function observeJob(db, organisationId, job) {
158
204
  ))
159
205
  .for('update');
160
206
 
207
+ // A work system without a shipped date (Seazona) says only that the job
208
+ // is shipped: the day Flow sees its status move to a shipped one is its
209
+ // shipped day. A job Flow first sees already shipped (a first sync, or a
210
+ // job shipped before Flow kept this day) stays without one.
211
+ const statusMoved = existing && String(existing.sourceStatus ?? '') !== String(facts.sourceStatus ?? '');
212
+ facts.shippedOn = job.shippedOn ?? existing?.shippedOn
213
+ ?? (job.shipped && statusMoved ? localDay(seenAt) : null);
214
+
161
215
  if (!existing) {
162
216
  const [row] = await tx.insert(billingObligations)
163
217
  .values({ organisationId, sourceSystem, sourceJobRef, sourceSeenAt: seenAt, ...facts })
164
218
  .returning();
165
219
  await addEvent(tx, row, 'observed', actor, {
166
- lineFingerprint: row.lineFingerprint, totalCents: row.totalCents, mappingComplete: row.mappingComplete,
220
+ lineFingerprint: row.lineFingerprint, totalCents: row.totalCents, mappingComplete: row.mappingComplete, sourceStatus: row.sourceStatus, sourceTotals: row.sourceTotals,
167
221
  });
168
- return row;
222
+ return recordSourceReview(tx, row, actor);
169
223
  }
170
224
 
171
225
  // An older report never replaces a newer one.
172
226
  if (seenAt < existing.sourceSeenAt) return existing;
173
- const changed = ['sourceInvoiceRef', 'caseRef', 'contactId', 'practitioner', 'shippedOn', 'lineFingerprint', 'totalCents']
174
- .filter((key) => String(existing[key] ?? '') !== String(facts[key] ?? ''));
227
+ const changed = ['sourceInvoiceRef', 'caseRef', 'contactId', 'practitioner', 'shippedOn', 'lineFingerprint', 'subtotalCents', 'taxCents', 'totalCents', 'sourceTotals']
228
+ .filter((key) => key === 'sourceTotals'
229
+ ? SOURCE_TOTAL_FIELDS.some((field) => (existing.sourceTotals?.[field] ?? null) !== (facts.sourceTotals?.[field] ?? null))
230
+ : String(existing[key] ?? '') !== String(facts[key] ?? ''));
175
231
  if (!changed.length) {
176
232
  const row = await update(tx, existing, {
177
233
  sourceSeenAt: seenAt, mappingComplete: facts.mappingComplete, lines, jobDate: facts.jobDate, sourceStatus: facts.sourceStatus,
178
234
  });
179
- await statusBillableAgain(tx, organisationId, existing, row, actor);
180
- return row;
235
+ await statusChanged(tx, organisationId, existing, row, actor);
236
+ return recordSourceReview(tx, row, actor);
181
237
  }
182
238
  const fields = { ...facts, sourceSeenAt: seenAt };
183
239
  let compare = null;
@@ -194,8 +250,8 @@ export async function observeJob(db, organisationId, job) {
194
250
  const row = await update(tx, existing, fields);
195
251
  await addEvent(tx, row, 'source_changed', actor, {
196
252
  changed,
197
- before: { lineFingerprint: existing.lineFingerprint, totalCents: existing.totalCents },
198
- after: { lineFingerprint: row.lineFingerprint, totalCents: row.totalCents },
253
+ before: { lineFingerprint: existing.lineFingerprint, totalCents: existing.totalCents, sourceTotals: existing.sourceTotals },
254
+ after: { lineFingerprint: row.lineFingerprint, totalCents: row.totalCents, sourceTotals: row.sourceTotals },
199
255
  ...(compare ? { compare: { outcome: compare.outcome, fields: compare.fields.map(({ field, changedIn }) => ({ field, changedIn })) } } : {}),
200
256
  ...(fields.holdState === 'review' ? { holdReason: fields.holdReason } : {}),
201
257
  });
@@ -204,15 +260,28 @@ export async function observeJob(db, organisationId, job) {
204
260
  } else if (compare) {
205
261
  await openReview(tx, organisationId, changedReview(row, compare));
206
262
  }
207
- await statusBillableAgain(tx, organisationId, existing, row, actor);
208
- return row;
263
+ await statusChanged(tx, organisationId, existing, row, actor);
264
+ return recordSourceReview(tx, row, actor);
209
265
  });
210
266
  }
211
267
 
268
+ // Each move of the work system status is an event, so "was the job shipped
269
+ // in this range" has an answer after the status moves on.
270
+ async function statusChanged(tx, organisationId, before, row, actor) {
271
+ if (String(before.sourceStatus ?? '') === String(row.sourceStatus ?? '')) return;
272
+ await addEvent(tx, row, 'status_changed', actor, { from: before.sourceStatus ?? null, to: row.sourceStatus ?? null });
273
+ await statusBillableAgain(tx, organisationId, before, row, actor);
274
+ }
275
+
276
+ // The calendar day of a time where the lab works (FLOW_TIME_ZONE).
277
+ function localDay(at) {
278
+ return new Intl.DateTimeFormat('en-CA', { timeZone: process.env.FLOW_TIME_ZONE || 'Australia/Sydney' }).format(at);
279
+ }
280
+
212
281
  // A job billable again after its status moved back (observeWithdrawn): the
213
282
  // alert is over when no invoice was made in the meantime.
214
283
  async function statusBillableAgain(tx, organisationId, before, row, actor) {
215
- if (row.invoiceId || String(before.sourceStatus ?? '') === String(row.sourceStatus ?? '')) return;
284
+ if (row.invoiceId) return;
216
285
  await resolveReviewFor(tx, organisationId, { kind: REVIEW_STATUS_BACK, entity: 'obligation', flowId: row.id, resolution: 'status_billable_again', actor });
217
286
  }
218
287
 
@@ -280,7 +349,7 @@ async function compareWithDraft(tx, organisationId, obligation, facts) {
280
349
  }
281
350
  const flow = { lines: invoice.lines, contactId: invoice.contactId };
282
351
  const compare = compareThreeWays(base, facts, flow, { sent: invoiceSent(invoice, obligation) });
283
- return { ...compare, base, flow, invoiceStatus: invoice.status };
352
+ return { ...compare, base, flow, invoiceStatus: invoice.status, invoiceTotals: { subtotalCents: invoice.subtotalCents, taxCents: invoice.taxCents, totalCents: invoice.totalCents } };
284
353
  }
285
354
 
286
355
  function changedReview(obligation, compare) {
@@ -313,6 +382,8 @@ export async function useSourceLines(db, organisationId, obligationId, { actor,
313
382
  return db.transaction(async (tx) => {
314
383
  const obligation = await loadObligation(tx, organisationId, obligationId, { lock: true });
315
384
  if (!obligation.invoiceId) throw new FlowError('invalid', 'the job has no invoice');
385
+ const sourceProblems = sourceBillingProblems(obligation);
386
+ if (sourceProblems.length) throw new FlowError('not_ready', sourceProblems.join('; '));
316
387
  const compare = await compareWithDraft(tx, organisationId, obligation, obligation);
317
388
  if (compare.outcome === 'conflict' && (compare.sent || compare.missing || !accept)) {
318
389
  throw new FlowError('conflict', compare.missing ? 'the invoice was not found' : compare.sent ? 'the invoice has left Flow; change it where it is now' : 'the draft changed in Flow too; a person must compare them', { fields: compare.fields });
@@ -408,17 +479,29 @@ export function diffJobLines(wasLines = [], nowLines = []) {
408
479
  return changes.sort((a, b) => a.position - b.position || (a.change === 'removed' ? -1 : 1));
409
480
  }
410
481
 
411
- // The open "job changed" problem of an invoice, for the invoice page, or
482
+ // The open job-change or source-billing problem, for the invoice page, or
412
483
  // null: the job as it was when the invoice was made, as the work system has
413
484
  // it now, and whether a person can adopt the change into Flow's own draft
414
485
  // ("Adopt changes", useSourceLines). An invoice drafted before the basis was
415
486
  // kept compares with the invoice's own lines.
416
487
  export async function jobChangeFor(db, organisationId, invoiceId) {
417
488
  const [obligation] = await db.select().from(billingObligations)
418
- .where(and(eq(billingObligations.organisationId, organisationId), eq(billingObligations.invoiceId, invoiceId),
419
- eq(billingObligations.holdState, 'review'), eq(billingObligations.holdReason, CHANGED_REASON)));
489
+ .where(and(eq(billingObligations.organisationId, organisationId), eq(billingObligations.invoiceId, invoiceId)));
420
490
  if (!obligation) return null;
491
+ const changed = obligation.holdState === 'review' && obligation.holdReason === CHANGED_REASON;
492
+ const sourceProblems = sourceBillingProblems(obligation);
493
+ if (!changed && !sourceProblems.length) return null;
421
494
  const compare = await compareWithDraft(db, organisationId, obligation, obligation);
495
+ // Source review is only for Flow's draft. Issued documents keep the existing
496
+ // job-change/accounting comparison, never a new source-resolution action.
497
+ const sourceReview = sourceProblems.length && compare.invoiceStatus === 'draft' ? {
498
+ sourceTotals: obligation.sourceTotals ?? null,
499
+ reconstructed: { ...invoiceTotals(obligation.lines), ...(obligation.lines.some((line) => line.taxCode === null) ? { taxCents: null, totalCents: null } : {}) },
500
+ unknownTaxLines: obligation.lines.flatMap((line, index) => line.taxCode === null
501
+ ? [{ position: index + 1, description: line.description, itemCode: line.itemCode ?? null }] : []),
502
+ savedInvoice: compare.invoiceTotals,
503
+ } : null;
504
+ if (!changed && !sourceReview) return null;
422
505
  const was = obligation.draftBasis
423
506
  ?? (compare.flow ? { lines: compare.flow.lines, contactId: compare.flow.contactId, totalCents: null } : basisOf(obligation));
424
507
  return {
@@ -426,13 +509,15 @@ export async function jobChangeFor(db, organisationId, invoiceId) {
426
509
  sourceSystem: obligation.sourceSystem,
427
510
  sourceJobRef: obligation.sourceJobRef,
428
511
  sourceInvoiceRef: obligation.sourceInvoiceRef,
512
+ caseRef: obligation.caseRef,
513
+ sourceReview,
429
514
  seenAt: obligation.sourceSeenAt,
430
515
  outcome: compare.outcome,
431
516
  sent: compare.sent,
432
- canAdopt: compare.outcome === 'offer',
517
+ canAdopt: !sourceReview && compare.outcome === 'offer',
433
518
  // "Accept change": the draft takes the work system's values, even when
434
519
  // the draft changed too. Not once the invoice has left Flow.
435
- canAccept: !compare.sent && !compare.missing,
520
+ canAccept: !sourceReview && !compare.sent && !compare.missing,
436
521
  lines: diffJobLines(was.lines ?? [], obligation.lines ?? []),
437
522
  contact: (was.contactId ?? null) === (obligation.contactId ?? null) ? null : { was: was.contactId ?? null, now: obligation.contactId ?? null },
438
523
  total: { was: was.totalCents ?? null, now: obligation.totalCents },
@@ -786,7 +871,10 @@ export function ledgerView(obligation, { now = new Date(), sourceMaxAgeMs = SOUR
786
871
  if (['authorised', 'part_paid'].includes(o.accountingState)) {
787
872
  return { view: 'invoiced', reasons: [`invoice ${o.accountingRef} is ${o.accountingState.replace('_', ' ')}`] };
788
873
  }
874
+ // Existing invoices own their stage. sending.js still checks source conflicts
875
+ // before approval or Send, after the non-chargeable rules.
789
876
  if (o.invoiceId || o.accountingState === 'drafted') return { view: 'drafted', reasons: ['a draft invoice exists'] };
877
+ if (sourceBillingProblems(o).length) return { view: 'review_required', reasons: draftBlockers(o, { now, sourceMaxAgeMs, accounting }) };
790
878
  const reasons = draftBlockers(o, { now, sourceMaxAgeMs, accounting });
791
879
  // Flow's own stage: a job is in work until a person or an automation marks
792
880
  // it ready to invoice (setReady).
@@ -795,7 +883,7 @@ export function ledgerView(obligation, { now = new Date(), sourceMaxAgeMs = SOUR
795
883
  }
796
884
 
797
885
  function draftBlockers(o, { now, sourceMaxAgeMs, accounting = true }) {
798
- const reasons = [];
886
+ const reasons = sourceBillingProblems(o);
799
887
  if (o.holdState !== 'none') reasons.push(`the job is ${o.holdState}`);
800
888
  if (o.invoiceId) reasons.push('the job already has an invoice');
801
889
  if (o.accountingState === 'unknown') {
@@ -24,8 +24,10 @@ import { FlowError, notFound } from './errors.js';
24
24
  //
25
25
  // A mapped line keeps the work system's amount and tax flag; the price item
26
26
  // gives its code, its text and, when the work system gave none, its price and
27
- // tax. With `linePrice: 'list'` the price item's price wins instead. A line
28
- // no step answers keeps no code, so the ledger holds the job.
27
+ // tax. A closest-name guess cannot supply missing source tax; it needs an
28
+ // explicit tax flag before mapping. With `linePrice: 'list'` the price item's
29
+ // price wins instead. A line no step answers keeps no code, so the ledger
30
+ // holds the job.
29
31
 
30
32
  export const STEPS = Object.freeze([
31
33
  'same_code', 'account', 'saved', 'catalog_name', 'price_alias', 'catalog_name_price', 'catalog_score', 'alias',
@@ -156,7 +158,7 @@ export function mapLine(line, context) {
156
158
  description: (item.description || item.name || original).trim(),
157
159
  quantity,
158
160
  unitAmountCents: config.linePrice === 'list' ? item.unitAmountCents : (price ?? item.unitAmountCents),
159
- taxCode: taxCode ?? line.taxCode ?? item.taxCode,
161
+ taxCode: line.taxCode ?? taxCode ?? item.taxCode,
160
162
  mappedBy: step,
161
163
  });
162
164
  const fromRow = (row, step) => {
@@ -180,7 +182,7 @@ export function mapLine(line, context) {
180
182
  if (row) {
181
183
  return {
182
184
  ...base, itemCode: null, accountCode: row.accountCode, description: row.description || original, quantity,
183
- unitAmountCents: price, taxCode: row.taxCode ?? line.taxCode, mappedBy: step,
185
+ unitAmountCents: price, taxCode: line.taxCode ?? row.taxCode ?? (line.taxCode === null ? null : undefined), mappedBy: step,
184
186
  };
185
187
  }
186
188
  break;
@@ -207,7 +209,9 @@ export function mapLine(line, context) {
207
209
  break;
208
210
  }
209
211
  case 'catalog_score': {
210
- if (price === null) break;
212
+ // A guessed item would also supply its tax here (or in the ledger).
213
+ // Leave unknown tax for an exact match or a person's saved choice.
214
+ if (price === null || line.taxCode == null) break;
211
215
  const scored = (context.byPrice.get(price) ?? [])
212
216
  .map((item) => ({ item, score: nameScore(item, { exact, lookup, original: lineText(original) }) }))
213
217
  .sort((a, b) => b.score - a.score || a.item.name.localeCompare(b.item.name) || a.item.code.localeCompare(b.item.code));
@@ -1,7 +1,7 @@
1
1
  import { and, eq } from 'drizzle-orm';
2
2
  import { billingObligations } from '../db/schema.js';
3
3
  import { FlowError } from './errors.js';
4
- import { observeJob, observeWithdrawn } from './ledger.js';
4
+ import { observeJob, observeWithdrawn, sourceBillingProblems } from './ledger.js';
5
5
  import { matchJobs, workJobMaker } from './pipeline.js';
6
6
 
7
7
  // Read before act (issue #141). Flow drafts and sends from its own copy of
@@ -50,7 +50,7 @@ export async function readBeforeAct(db, organisationId, obligations, { work, now
50
50
  continue;
51
51
  }
52
52
  const fresh = await reload(db, organisationId, obligation.id);
53
- outcomes.set(obligation.id, { outcome: fresh.holdState === 'review' ? 'review' : 'read', obligation: fresh });
53
+ outcomes.set(obligation.id, { outcome: fresh.holdState === 'review' || sourceBillingProblems(fresh).length ? 'review' : 'read', obligation: fresh });
54
54
  } else if (answer?.state === 'withdrawn') {
55
55
  // The ledger decides as for the feed (observeWithdrawn): deleted, or
56
56
  // moved back after its invoice, goes to review; moved back while only
@@ -124,6 +124,20 @@ export async function reconcileDays(db, organisationId, { integrations, from, to
124
124
  and e.kind in ('observed', 'source_changed') and e.actor <> ${HISTORY_ACTOR})`,
125
125
  ));
126
126
 
127
+ // A billable feed cannot prove absence. Check the remaining ledger refs
128
+ // against the source copy without billing filters. Older adapters retain
129
+ // the feed comparison until they declare workJobPresence.
130
+ const present = new Set(work.keys());
131
+ for (const adapter of workAdapters) {
132
+ if (!adapter.capabilities.workJobPresence) continue;
133
+ const jobRefs = [...new Set(obligations
134
+ .filter((o) => o.sourceSystem === adapter.id && !present.has(`${adapter.id}:${o.sourceJobRef}`))
135
+ .map((o) => o.sourceJobRef))];
136
+ for (let i = 0; i < jobRefs.length; i += FIND_BATCH) {
137
+ const { presentRefs } = await adapter.capabilities.workJobPresence({ organisationId, jobRefs: jobRefs.slice(i, i + FIND_BATCH) });
138
+ for (const ref of presentRefs) present.add(`${adapter.id}:${ref}`);
139
+ }
140
+ }
127
141
  const reported = new Set(workAdapters.map((a) => a.id));
128
142
  const inLedger = new Set();
129
143
  for (const o of obligations) {
@@ -139,7 +153,7 @@ export async function reconcileDays(db, organisationId, { integrations, from, to
139
153
  if (reported.has(o.sourceSystem)) {
140
154
  const key = `${o.sourceSystem}:${o.sourceJobRef}`;
141
155
  inLedger.add(key);
142
- if (!work.has(key)) entry.differences.missingInWork.push({ id: o.id, cents: o.totalCents });
156
+ if (!present.has(key)) entry.differences.missingInWork.push({ id: o.id, cents: o.totalCents });
143
157
  }
144
158
  }
145
159
  for (const [key, job] of work) {
@@ -5,7 +5,7 @@ import { FlowError } from './errors.js';
5
5
  import { listRules, ruleNonChargeable, rulePending, ruleSkip } from './invoice-rules.js';
6
6
  import { customerBillingByContact, effectiveSettings, readInvoicingSettings } from './invoicing-rules.js';
7
7
  import { issueInvoice } from './invoices.js';
8
- import { CHANGED_REASON, diffJobLines, STATUS_BACK_REASON } from './ledger.js';
8
+ import { CHANGED_REASON, diffJobLines, sourceBillingProblems, STATUS_BACK_REASON } from './ledger.js';
9
9
  import { soleOrganisation } from './pipeline.js';
10
10
  import { needsRead, readAccountingFirst, readBeforeAct, READS_PER_PASS } from './read-before-act.js';
11
11
  import { deadHoldSince, deadSince, enqueueJob, runNextJob } from './queue.js';
@@ -50,20 +50,24 @@ const REFUSAL_CODE = /^[a-z0-9_]{1,64}$/i;
50
50
  const SENT = new Set(['created', 'reconciled', 'existing', 'already_in_xero']);
51
51
 
52
52
  // `short` is the few words the Invoices list shows; `message` says how to fix
53
- // it. `extra` adds `fields` (the invoice fields to fix) and `line`.
54
- const problem = (code, message, short = null, extra = {}) => ({ code, message, ...(short ? { short } : {}), ...extra });
53
+ // it. Draft warnings must name their resolution UI at the owner, not in a
54
+ // separate warning-code inventory. The invoice page dispatches this intent.
55
+ const problem = (code, message, short, resolution, extra = {}) => {
56
+ if (typeof resolution?.kind !== 'string' || !resolution.kind) throw new FlowError('warning_resolution_required', `Draft warning ${code} needs a resolution UI.`);
57
+ return { code, message, ...(short ? { short } : {}), ...extra, resolution };
58
+ };
55
59
 
56
60
  // An issued invoice the accounting system refused. A refusal stored without
57
61
  // its few words (before 2026-10-28) shows "Not sent"; its message stays.
58
- const refusal = (sendError) => (sendError ? { short: 'Not sent', ...sendError } : problem('send_failed', 'Sending failed.', 'Not sent'));
62
+ const refusal = (sendError) => (sendError ? { short: 'Not sent', ...sendError } : { code: 'send_failed', message: 'Sending failed.', short: 'Not sent' });
59
63
 
60
64
  // Flow's own checks of one draft. `lines` are its invoice lines.
61
65
  export function draftProblems(invoice, lines) {
62
66
  const problems = [];
63
- if (!lines.length) problems.push(problem('no_lines', 'The invoice has no lines.', 'No lines'));
64
- else if (invoice.totalCents <= 0) problems.push(problem('no_amount', 'The total is $0.00 or less.', 'Total $0'));
67
+ if (!lines.length) problems.push(problem('no_lines', 'The invoice has no lines.', 'No lines', { kind: 'invoice_edit' }));
68
+ else if (invoice.totalCents <= 0) problems.push(problem('no_amount', 'The total is $0.00 or less.', 'Total $0', { kind: 'invoice_edit' }));
65
69
  for (const line of lines) {
66
- if (!line.priceItemId && !line.accountCode) problems.push(problem('line_no_item', `Line ${line.position} has no price item.`, `Line ${line.position}: no item`, { fields: ['line_item'], line: line.position }));
70
+ if (!line.priceItemId && !line.accountCode) problems.push(problem('line_no_item', `Line ${line.position} has no price item.`, `Line ${line.position}: no item`, { kind: 'invoice_edit' }, { fields: ['line_item'], line: line.position }));
67
71
  }
68
72
  return problems;
69
73
  }
@@ -116,10 +120,18 @@ async function checkDrafts(db, organisationId, drafts, accounting) {
116
120
  }
117
121
  // A draft whose job the ledger sent to review (the job changed after the
118
122
  // draft was made, or the work system deleted it) waits for a person.
123
+ for (const { obligation } of jobs) {
124
+ for (const reason of sourceBillingProblems(obligation)) {
125
+ const found = { ...problem('job_review', 'Check the source invoice: ' + reason + '.', 'Source tax', { kind: 'job_review' }), sourceBilling: true };
126
+ problems.set(obligation.invoiceId, [...(problems.get(obligation.invoiceId) ?? []), found]);
127
+ }
128
+ }
119
129
  const reviewed = jobs.map((j) => j.obligation).filter((o) => o.holdState === 'review');
120
130
  for (const obligation of reviewed) {
121
131
  const { invoiceId, holdReason: reason } = obligation;
122
- const found = problem('job_review', `The job needs a look before this invoice goes: ${reason ?? 'it was sent to review'}.`, reviewShort(obligation));
132
+ const resolution = reason === CHANGED_REASON ? { kind: 'job_review' }
133
+ : { kind: 'job_hold', reason: reason ?? 'the job is review' };
134
+ const found = problem('job_review', `The job needs a look before this invoice goes: ${reason ?? 'it was sent to review'}.`, reviewShort(obligation), resolution);
123
135
  problems.set(invoiceId, [...(problems.get(invoiceId) ?? []), found]);
124
136
  }
125
137
  if (accounting) {
@@ -439,7 +451,8 @@ export async function sendQueued(db, organisationId, { accounting, limit = WORKE
439
451
  summary.waiting += 1;
440
452
  } else {
441
453
  const error = result.error ?? { code: `send_${result.outcome}`, message: `Sending ended as ${result.outcome}.` };
442
- await settle(db, organisationId, result.invoiceId, { sendState: 'failed', sendError: { code: error.code, message: error.message, ...(error.short ? { short: error.short } : {}) } });
454
+ const xero = xeroMatch(error);
455
+ await settle(db, organisationId, result.invoiceId, { sendState: 'failed', sendError: { code: error.code, message: error.message, ...(error.short ? { short: error.short } : {}), ...(xero ? { xero } : {}) } });
443
456
  summary.failed += 1;
444
457
  summary.refused = [...new Set([...(summary.refused ?? []), error.code])];
445
458
  const fact = refusalFact(known.get(result.invoiceId), error);
@@ -450,6 +463,17 @@ export async function sendQueued(db, organisationId, { accounting, limit = WORKE
450
463
  return summary;
451
464
  }
452
465
 
466
+ // The Xero invoice a refusal matched, kept so the page can link to it.
467
+ // Only an id, a number and a status. Anything else in the error stays out.
468
+ function xeroMatch(error) {
469
+ const xero = error?.details?.xero;
470
+ if (!xero || typeof xero !== 'object') return null;
471
+ if (!INVOICE_ID.test(xero.invoiceId ?? '')) return null;
472
+ const number = INVOICE_NUMBER.test(xero.number ?? '') ? xero.number : null;
473
+ const status = /^[A-Z0-9_]{1,32}$/.test(xero.status ?? '') ? xero.status : null;
474
+ return { invoiceId: xero.invoiceId, ...(number ? { number } : {}), ...(status ? { status } : {}) };
475
+ }
476
+
453
477
  function settle(db, organisationId, invoiceId, fields) {
454
478
  return db.update(invoices).set({ ...fields, sendUpdatedAt: sql`now()` })
455
479
  .where(and(eq(invoices.organisationId, organisationId), eq(invoices.id, invoiceId), eq(invoices.sendState, 'queued')));
@@ -0,0 +1 @@
1
+ ALTER TABLE "billing_obligations" ADD COLUMN IF NOT EXISTS "source_totals" jsonb;
@@ -0,0 +1,38 @@
1
+ CREATE TABLE IF NOT EXISTS "agent_audit" (
2
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
3
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
4
+ "grant_id" uuid NOT NULL,
5
+ "person_id" uuid NOT NULL,
6
+ "tool" text NOT NULL,
7
+ "status" integer NOT NULL,
8
+ "row_count" integer DEFAULT 0 NOT NULL,
9
+ "duration_ms" integer NOT NULL
10
+ );
11
+ --> statement-breakpoint
12
+ CREATE TABLE IF NOT EXISTS "agent_grants" (
13
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
14
+ "person_id" uuid NOT NULL,
15
+ "client_id" text NOT NULL,
16
+ "app_name" text NOT NULL,
17
+ "resource" text NOT NULL,
18
+ "organisation_id" uuid NOT NULL,
19
+ "access_level" text DEFAULT 'read' NOT NULL,
20
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
21
+ "last_used_at" timestamp with time zone,
22
+ "revoked_at" timestamp with time zone,
23
+ "failure_day" date,
24
+ "failure_count" integer DEFAULT 0 NOT NULL,
25
+ "paused_at" timestamp with time zone,
26
+ CONSTRAINT "agent_grants_person_client_resource" UNIQUE("person_id","client_id","resource"),
27
+ CONSTRAINT "agent_grants_read_only" CHECK (access_level = 'read')
28
+ );
29
+ --> statement-breakpoint
30
+ DO $flow_guard$ BEGIN
31
+ ALTER TABLE "agent_grants" ADD CONSTRAINT "agent_grants_person_id_people_id_fk" FOREIGN KEY ("person_id") REFERENCES "flow"."people"("id") ON DELETE cascade ON UPDATE no action;
32
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
33
+ END $flow_guard$;--> statement-breakpoint
34
+ DO $flow_guard$ BEGIN
35
+ ALTER TABLE "agent_grants" ADD CONSTRAINT "agent_grants_organisation_id_organisations_id_fk" FOREIGN KEY ("organisation_id") REFERENCES "flow"."organisations"("id") ON DELETE no action ON UPDATE no action;
36
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
37
+ END $flow_guard$;--> statement-breakpoint
38
+ CREATE INDEX IF NOT EXISTS "agent_audit_created" ON "agent_audit" USING btree ("created_at");