@arcforgelabs/flow 2026.11.5 → 2026.11.7

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 (46) hide show
  1. package/npm-shrinkwrap.json +2 -2
  2. package/package.json +1 -1
  3. package/server/api/invoicing.js +5 -2
  4. package/server/app.js +1 -1
  5. package/server/core/automations.js +4 -1
  6. package/server/core/invoice-rules.js +64 -15
  7. package/server/core/invoicing-rules.js +50 -6
  8. package/server/core/job-board.js +3 -2
  9. package/server/core/ledger.js +10 -2
  10. package/server/core/pipeline.js +5 -2
  11. package/server/core/price-list.js +41 -12
  12. package/server/core/sending.js +3 -2
  13. package/server/core/sync-now.js +38 -0
  14. package/server/db/migrations/0029_job_remake.sql +1 -0
  15. package/server/db/migrations/meta/0029_snapshot.json +5069 -0
  16. package/server/db/migrations/meta/_journal.json +7 -0
  17. package/server/db/schema.js +3 -0
  18. package/server/integrations/port.js +3 -0
  19. package/server/integrations/seazona/feed.js +1 -0
  20. package/server/integrations/seazona/history/index.js +5 -2
  21. package/server/integrations/seazona/index.js +44 -0
  22. package/server/integrations/seazona/lanes.js +3 -0
  23. package/server/integrations/seazona/ledger.js +1 -0
  24. package/server/integrations/seazona/map.js +2 -0
  25. package/server/integrations/seazona/migrations/0008_order_remake.sql +1 -0
  26. package/server/integrations/seazona/migrations/meta/0008_snapshot.json +1526 -0
  27. package/server/integrations/seazona/migrations/meta/_journal.json +7 -0
  28. package/server/integrations/seazona/scheduler.js +13 -2
  29. package/server/integrations/seazona/schema.js +2 -0
  30. package/server/integrations/seazona/settings.js +3 -0
  31. package/server/integrations/seazona/store.js +14 -0
  32. package/server/integrations/seazona/sweep.js +13 -3
  33. package/server/integrations/sentry/index.js +9 -4
  34. package/server/integrations/setup.js +21 -1
  35. package/server/integrations/xero/catalogue.js +40 -0
  36. package/server/integrations/xero/drafts.js +16 -5
  37. package/server/integrations/xero/index.js +35 -0
  38. package/server/integrations/xero/lookup.js +11 -4
  39. package/server/integrations/xero/settings.js +8 -0
  40. package/server/integrations/xero/webhooks.js +4 -3
  41. package/server/ui/app.js +64 -3
  42. package/server/ui/flow.css +2 -1
  43. package/server/ui/kit.js +3 -2
  44. package/server/ui/monitoring.js +63 -3
  45. package/server/ui/price-list.js +5 -2
  46. package/skills/flow/SKILL.md +31 -1
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.11.5",
3
+ "version": "2026.11.7",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@arcforgelabs/flow",
9
- "version": "2026.11.5",
9
+ "version": "2026.11.7",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@clerk/backend": "3.25.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.11.5",
3
+ "version": "2026.11.7",
4
4
  "description": "Invoicing, bill entry and a financial dashboard for labs, with Seazona and Xero integrations.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1182,7 +1182,7 @@ export function createInvoicingRouter({
1182
1182
  if (!system || !externalId) throw invalid('system and externalId are required');
1183
1183
  const link = await linkPriceItem(db, organisationId, itemId, { system, externalId }, { catalogues: await catalogues(organisationId), personId: req.person.id });
1184
1184
  log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} linked price item ${itemId} to ${system}`);
1185
- res.status(201).json({ ok: true, link: { id: link.id, system: link.system, externalId: link.externalId, externalCode: link.externalCode, matchedBy: link.matchedBy } });
1185
+ res.status(201).json({ ok: true, link: { id: link.id, system: link.system, externalId: link.externalId, externalCode: link.externalCode, matchedBy: link.matchedBy, ...(link.movedFrom ? { movedFrom: link.movedFrom } : {}) } });
1186
1186
  }));
1187
1187
 
1188
1188
  router.post('/price-links', ...manager, handle(log, async (req, res) => {
@@ -1197,7 +1197,10 @@ export function createInvoicingRouter({
1197
1197
  });
1198
1198
  const made = await linkPriceItems(db, organisationId, links, { catalogues: await catalogues(organisationId), personId: req.person.id });
1199
1199
  log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} made ${made.length} price links`);
1200
- res.status(201).json({ ok: true, linked: made.length });
1200
+ // A link another code had moves; `moved` names each one, so the caller
1201
+ // can say which code lost it.
1202
+ const moved = made.filter((l) => l.movedFrom).map((l) => ({ priceItemId: l.priceItemId, system: l.system, externalId: l.externalId, movedFrom: l.movedFrom }));
1203
+ res.status(201).json({ ok: true, linked: made.length, ...(moved.length ? { moved } : {}) });
1201
1204
  }));
1202
1205
 
1203
1206
  router.post('/price-items/:id/resolve', ...manager, handle(log, async (req, res) => {
package/server/app.js CHANGED
@@ -146,7 +146,7 @@ export function createApp({
146
146
  // Settings, Integrations: an owner turns integrations on and enters their
147
147
  // settings (server/integrations/setup.js).
148
148
  if (integrations && database && auth?.personAuth) {
149
- app.use('/api/integrations', createSetupRouter({ db: database.db, registry: integrations, personAuth: auth.personAuth, secrets, log }));
149
+ app.use('/api/integrations', createSetupRouter({ db: database.db, registry: integrations, personAuth: auth.personAuth, secrets, log, buttonCooldowns }));
150
150
  }
151
151
  // Invoicing for signed-in people (server/api/invoicing.js). A job's draft
152
152
  // invoice needs a live check in the accounting system: the first enabled
@@ -1,7 +1,7 @@
1
1
  import { and, asc, eq, sql } from 'drizzle-orm';
2
2
  import { automations, billingObligations, invoices } from '../db/schema.js';
3
3
  import { FlowError, notFound } from './errors.js';
4
- import { autoDraftJobs, readInvoicingSettings } from './invoicing-rules.js';
4
+ import { autoDraftJobs, readInvoicingSettings, settleHistoryByRules } from './invoicing-rules.js';
5
5
  import { MAPPING_DEFAULTS, readMappingConfig, remapJobs } from './line-mapping.js';
6
6
  import { setReady } from './ledger.js';
7
7
  import { refreshReferences } from './references.js';
@@ -226,6 +226,9 @@ export async function runAutomations(db, organisationId, { accounting = null, wo
226
226
  if (automation.key === 'mark_ready_from_work_status') {
227
227
  summary[automation.key] = await markReadyFromWorkStatus(db, organisationId, automation.config);
228
228
  } else if (automation.key === 'draft_when_ready') {
229
+ // Old held jobs a non-chargeable rule matches join this run's drafts.
230
+ const settled = await settleHistoryByRules(db, organisationId, { accounting, now });
231
+ if (settled.released) summary.settle_history = settled;
229
232
  summary[automation.key] = await autoDraftJobs(db, organisationId, { accounting, work, now, actor: actorOf(automation.key) });
230
233
  } else if (automation.key === 'ready_when_drafted') {
231
234
  summary[automation.key] = await readyWhenDrafted(db, organisationId);
@@ -67,10 +67,47 @@ export const FIELDS = Object.freeze({
67
67
  read: (j) => j.contactId,
68
68
  },
69
69
  customer_billable: { label: 'Customer is billable', kind: 'boolean', read: (j, { customer }) => customer.billable !== false },
70
+ // The work system marks the job a remake (Seazona's remake flag).
71
+ remake: { label: 'Remake', kind: 'boolean', read: (j) => j.remake === true },
70
72
  line_item: { label: 'Line item', kind: 'items', options: 'items', read: (j) => (j.lines ?? []).flatMap((l) => [norm(l.itemCode), norm(l.label)].filter(Boolean)) },
71
73
  line_share: { label: 'Line share of other lines (%)', kind: 'share', options: 'items', read: (j) => j.lines ?? [] },
74
+ // Each line with a price item sends as an item of the accounting system
75
+ // (Xero: a linked item that sells, else the item with the same code). A
76
+ // line booked to an account has no item, so it does not count. `items` is
77
+ // the Flow codes that send (accountingItemsFor). It is null when no
78
+ // accounting system is on or its list was never read: then the field has
79
+ // no value and no rule on it matches.
80
+ accounting_item: {
81
+ label: 'Every item is in accounting', kind: 'boolean', accounting: true,
82
+ read: (j, { items }) => (items ? !missingItems(j, items).length : null),
83
+ detail: (j, { items }) => (items && missingItems(j, items).length ? `not in accounting: ${missingItems(j, items).join(', ')}` : null),
84
+ },
72
85
  });
73
86
 
87
+ // The codes on a job's lines that do not send as an accounting item.
88
+ function missingItems(job, items) {
89
+ return [...new Set((job.lines ?? []).map((l) => l.itemCode).filter((c) => c && !items.has(c)))];
90
+ }
91
+
92
+ // The Flow codes that send as an item in the accounting system, for the rules
93
+ // that read `accounting_item`. Null when no enabled rule reads it, or when no
94
+ // accounting system answers (not connected, or its list was never read).
95
+ // `jobs` are the jobs the rules will read, so one call serves a whole run.
96
+ export async function accountingItemsFor(accounting, organisationId, rules, jobs) {
97
+ if (typeof accounting?.capabilities?.itemMatches !== 'function') return null;
98
+ if (!rules.some((r) => r.enabled && r.conditions.some((c) => FIELDS[c.field]?.accounting))) return null;
99
+ const codes = [...new Set(jobs.flatMap((j) => (j.lines ?? []).map((l) => l.itemCode).filter(Boolean)))];
100
+ const matched = await accounting.capabilities.itemMatches({ organisationId, codes });
101
+ return matched ? new Set(matched) : null;
102
+ }
103
+
104
+ // What a matched rule's fields say about this job, such as the codes not in
105
+ // accounting, so the note names the line to fix.
106
+ function detailOf(rule, job, context) {
107
+ const parts = [...new Set(rule.conditions.map((c) => FIELDS[c.field].detail?.(job, context)).filter(Boolean))];
108
+ return parts.length ? ` (${parts.join('; ')})` : '';
109
+ }
110
+
74
111
  export const TESTS = Object.freeze({
75
112
  number: ['more_than', 'at_most', 'is', 'is_not', 'is_empty'],
76
113
  money: ['more_than', 'at_most', 'is', 'is_not'],
@@ -206,22 +243,34 @@ function matches(condition, job, context) {
206
243
  // The reasons the enabled rules give for one job, in rule order. An empty list
207
244
  // means no rule stops its draft. `customer` is the customer's own billing
208
245
  // settings (invoicing-rules.js).
209
- const waitingRules = (rules, job, { customer = {}, now = new Date() } = {}) => rules
210
- .filter((rule) => rule.enabled && rule.trigger === 'job_ready' && !['pending', 'non_chargeable'].includes(rule.action.kind))
211
- .filter((rule) => rule.conditions.every((c) => matches(c, job, { customer, now })));
212
- const waitMessage = (rule) => rule.action.kind === 'skip'
213
- ? `rule "${rule.name}": not drafted by rule` : `rule "${rule.name}": waits for a person`;
246
+ // `items`: the Flow codes that send as accounting items (accountingItemsFor).
247
+ const contextOf = ({ customer = {}, now = new Date(), items = null } = {}) => ({ customer, now, items });
248
+ // A non-chargeable rule that matches wins over the wait rules (a $0 remake
249
+ // is never billed, so "No amount" must not hold it), and over the skip rules
250
+ // below it in the list. Rules are in the lab's order.
251
+ const waitingRules = (rules, job, context) => {
252
+ const live = rules.filter((rule) => rule.enabled && rule.trigger === 'job_ready');
253
+ const hit = (rule) => rule.conditions.every((c) => matches(c, job, context));
254
+ const free = live.findIndex((rule) => rule.action.kind === 'non_chargeable' && hit(rule));
255
+ return live
256
+ .filter((rule, at) => (free < 0 ? ['wait', 'skip'].includes(rule.action.kind) : rule.action.kind === 'skip' && at < free))
257
+ .filter(hit);
258
+ };
259
+ const waitMessage = (rule, job, context) => (rule.action.kind === 'skip'
260
+ ? `rule "${rule.name}": not drafted by rule` : `rule "${rule.name}": waits for a person`) + detailOf(rule, job, context);
214
261
 
215
262
  // Keep the text-only reason API for draft checks, including evaluation of
216
263
  // starter rules before they are saved. Selection/text have one owner here.
217
264
  export function ruleReasons(rules, job, options) {
218
- return waitingRules(rules, job, options).map(waitMessage);
265
+ const context = contextOf(options);
266
+ return waitingRules(rules, job, context).map((rule) => waitMessage(rule, job, context));
219
267
  }
220
268
 
221
269
  // Pending work needs the saved rule identity, just as a Pending invoice does.
222
270
  export function ruleWait(rules, job, options) {
223
- return waitingRules(rules, job, options).map((rule) => ({ code: 'rule',
224
- message: waitMessage(rule), short: rule.name.slice(0, 40), rule: rule.id,
271
+ const context = contextOf(options);
272
+ return waitingRules(rules, job, context).map((rule) => ({ code: 'rule',
273
+ message: waitMessage(rule, job, context), short: rule.name.slice(0, 40), rule: rule.id,
225
274
  resolution: ruleResolution(rule),
226
275
  }));
227
276
  }
@@ -239,8 +288,8 @@ const ruleResolution = (rule) => {
239
288
  // A rule leaves a job alone when a person made an exception for it
240
289
  // (`job.ruleExceptions`, rule ids), or, for a rule with `addItem`, when the
241
290
  // draft already has that item (`job.draftItems`, codes): the fix was taken.
242
- export function rulePending(rules, job, { customer = {}, now = new Date() } = {}) {
243
- const context = { customer, now };
291
+ export function rulePending(rules, job, options) {
292
+ const context = contextOf(options);
244
293
  const excepted = new Set(job.ruleExceptions ?? []);
245
294
  const onDraft = new Set((job.draftItems ?? []).map(norm));
246
295
  return rules
@@ -248,7 +297,7 @@ export function rulePending(rules, job, { customer = {}, now = new Date() } = {}
248
297
  .filter((r) => !excepted.has(r.id) && !(r.action.addItem && onDraft.has(norm(r.action.addItem))))
249
298
  .filter((r) => r.conditions.every((c) => matches(c, job, context)))
250
299
  .map((r) => ({
251
- code: 'rule', message: r.action.note, short: r.name.slice(0, 40), rule: r.id ?? null, fields: [...new Set(r.conditions.map((c) => c.field))],
300
+ code: 'rule', message: r.action.note + detailOf(r, job, context), short: r.name.slice(0, 40), rule: r.id ?? null, fields: [...new Set(r.conditions.map((c) => c.field))],
252
301
  resolution: ruleResolution(r), ...(r.action.addItem ? { addItem: r.action.addItem } : {}),
253
302
  }));
254
303
  }
@@ -257,8 +306,8 @@ export function rulePending(rules, job, { customer = {}, now = new Date() } = {}
257
306
  // A person drafted the job by hand, or the rule was made after the draft.
258
307
  // The list shows the rule's name. The note says to void the draft or change
259
308
  // the rule. A wait rule does not hold a draft: a person may still send one.
260
- export function ruleSkip(rules, job, { customer = {}, now = new Date() } = {}) {
261
- const context = { customer, now };
309
+ export function ruleSkip(rules, job, options) {
310
+ const context = contextOf(options);
262
311
  return rules
263
312
  .filter((r) => r.enabled && r.trigger === 'job_ready' && r.action.kind === 'skip')
264
313
  .filter((r) => r.conditions.every((c) => matches(c, job, context)))
@@ -275,8 +324,8 @@ export function ruleSkip(rules, job, { customer = {}, now = new Date() } = {}) {
275
324
  // The enabled non-chargeable rules that match one draft's job (sending.js):
276
325
  // the draft is on the Non-chargeable tab, never on Pending or Ready, and
277
326
  // Send never sends it.
278
- export function ruleNonChargeable(rules, job, { customer = {}, now = new Date() } = {}) {
279
- const context = { customer, now };
327
+ export function ruleNonChargeable(rules, job, options) {
328
+ const context = contextOf(options);
280
329
  return rules
281
330
  .filter((r) => r.enabled && r.trigger === 'job_ready' && r.action.kind === 'non_chargeable')
282
331
  .filter((r) => r.conditions.every((c) => matches(c, job, context)))
@@ -3,8 +3,9 @@ import {
3
3
  billingObligations, contacts, customerBillingSettings, externalLinks, invoicingSettings, SEND_MODES,
4
4
  } from '../db/schema.js';
5
5
  import { FlowError, notFound } from './errors.js';
6
- import { listRules, ruleReasons } from './invoice-rules.js';
7
- import { draftInvoiceForObligation } from './ledger.js';
6
+ import { HISTORY_HOLD_REASON } from './history.js';
7
+ import { accountingItemsFor, listRules, ruleNonChargeable, ruleReasons } from './invoice-rules.js';
8
+ import { draftInvoiceForObligation, setHold, setReady } from './ledger.js';
8
9
  import { needsRead, readAccountingFirst, readBeforeAct, READS_PER_PASS } from './read-before-act.js';
9
10
  import { checkReferenceTemplate, DEFAULT_REFERENCE, jobReference } from './references.js';
10
11
 
@@ -197,10 +198,48 @@ export async function sendModeSummary(db, organisationId) {
197
198
  // read: Flow's own ready stage, then the lab's invoice rules (invoice-rules.js)
198
199
  // in their order. An empty list means the job may be drafted. `rules` is the
199
200
  // organisation's rules, read once per run with listRules.
200
- export function ruleBlockers(obligation, effective, { now = new Date(), rules = [] } = {}) {
201
+ export function ruleBlockers(obligation, effective, { now = new Date(), rules = [], items = null } = {}) {
201
202
  const reasons = [];
202
203
  if (!obligation.readyAt) reasons.push('the job is not marked ready to invoice');
203
- return [...reasons, ...ruleReasons(rules, obligation, { customer: effective, now })];
204
+ return [...reasons, ...ruleReasons(rules, obligation, { customer: effective, now, items })];
205
+ }
206
+
207
+ // Old jobs from the history import wait on hold, out of the rules' sight. A
208
+ // job that a non-chargeable rule matches (own clinics, remakes) needs no
209
+ // person: it is never billed. When no accounting invoice was found for it,
210
+ // the rule releases it and marks it ready, and the next draft run puts its
211
+ // draft on the Non-chargeable tab, never sent. A job with an accounting
212
+ // invoice, or one that is unclear, stays for a person. Answers counts.
213
+ export async function settleHistoryByRules(db, organisationId, { accounting = null, now = new Date(), limit = DRAFT_BATCH } = {}) {
214
+ const result = { checked: 0, released: 0 };
215
+ const rules = (await listRules(db, organisationId)).filter((r) => r.enabled && r.trigger === 'job_ready' && r.action.kind === 'non_chargeable');
216
+ if (!rules.length) return result;
217
+ const jobs = await db.select().from(billingObligations).where(and(
218
+ eq(billingObligations.organisationId, organisationId),
219
+ eq(billingObligations.holdState, 'held'),
220
+ eq(billingObligations.holdReason, HISTORY_HOLD_REASON),
221
+ isNull(billingObligations.invoiceId),
222
+ sql`${billingObligations.contactId} is not null`,
223
+ accounting
224
+ ? eq(billingObligations.accountingState, 'not_found')
225
+ : inArray(billingObligations.accountingState, ['not_found', 'unknown']),
226
+ )).orderBy(sql`${billingObligations.jobDate} desc nulls last`, billingObligations.id).limit(limit);
227
+ if (!jobs.length) return result;
228
+ const settings = await readInvoicingSettings(db, organisationId);
229
+ const contactIds = [...new Set(jobs.map((j) => j.contactId))];
230
+ const customers = await customerBillingByContact(db, organisationId, contactIds);
231
+ const names = await customerNames(db, organisationId, contactIds);
232
+ for (const job of jobs) {
233
+ result.checked += 1;
234
+ const customer = { ...effectiveSettings(settings, customers.get(job.contactId)), name: names.get(job.contactId) ?? null };
235
+ const [rule] = ruleNonChargeable(rules, job, { customer, now });
236
+ if (!rule) continue;
237
+ const actor = `rule:${rule.rule}`;
238
+ await setHold(db, organisationId, job.id, { holdState: 'none', reason: `${rule.short}: non-chargeable`, actor });
239
+ await setReady(db, organisationId, job.id, { actor });
240
+ result.released += 1;
241
+ }
242
+ return result;
204
243
  }
205
244
 
206
245
  function addDays(date, days) {
@@ -297,6 +336,8 @@ export async function autoDraftJobs(db, organisationId, { accounting = null, wor
297
336
  const liveCheck = accounting ? copyCheck(accounting, organisationId, { db }) : null;
298
337
  const invoiceDate = isoDay(now);
299
338
  let accountingRead = null;
339
+ // The codes that send as accounting items, for a rule that reads them.
340
+ const items = await accountingItemsFor(accounting, organisationId, rules, candidates);
300
341
 
301
342
  for (const candidate of candidates) {
302
343
  let effective = named(candidate.contactId);
@@ -304,7 +345,7 @@ export async function autoDraftJobs(db, organisationId, { accounting = null, wor
304
345
  result.off += 1;
305
346
  continue;
306
347
  }
307
- if (ruleBlockers(candidate, effective, { now, rules }).length) {
348
+ if (ruleBlockers(candidate, effective, { now, rules, items }).length) {
308
349
  result.waiting += 1;
309
350
  continue;
310
351
  }
@@ -345,7 +386,10 @@ export async function autoDraftJobs(db, organisationId, { accounting = null, wor
345
386
  for (const [id, name] of extra) names.set(id, name);
346
387
  }
347
388
  effective = named(obligation.contactId);
348
- if (!effective.autoDraft || ruleBlockers(obligation, effective, { now, rules }).length) {
389
+ // The job read again may have new codes: read their accounting items too.
390
+ const itemsNow = items && (obligation.lines ?? []).some((l) => l.itemCode && !items.has(l.itemCode))
391
+ ? await accountingItemsFor(accounting, organisationId, rules, [obligation]) : items;
392
+ if (!effective.autoDraft || ruleBlockers(obligation, effective, { now, rules, items: itemsNow }).length) {
349
393
  result.waiting += 1;
350
394
  continue;
351
395
  }
@@ -1,6 +1,6 @@
1
1
  import { and, eq, inArray, isNull } from 'drizzle-orm';
2
2
  import { contacts, invoices, priceItems, reviewItems } from '../db/schema.js';
3
- import { listRules, ruleExceptionsFor, rulePending, ruleWait } from './invoice-rules.js';
3
+ import { accountingItemsFor, listRules, ruleExceptionsFor, rulePending, ruleWait } from './invoice-rules.js';
4
4
  import { customerBillingByContact, effectiveSettings, readInvoicingSettings, ruleBlockers } from './invoicing-rules.js';
5
5
  import { REVIEW_CHANGED, listObligations } from './ledger.js';
6
6
  import { isHistoryHeld } from './history.js';
@@ -47,6 +47,7 @@ export async function describeObligations(db, organisationId, listed, { accounti
47
47
  const settings = await readInvoicingSettings(db, organisationId);
48
48
  const customers = await customerBillingByContact(db, organisationId, contactIds);
49
49
  const rules = await listRules(db, organisationId);
50
+ const items = await accountingItemsFor(accounting, organisationId, rules, listed.filter((e) => !e.obligation.invoiceId).map((e) => e.obligation));
50
51
  // A rule with an item to add also checks the job as the work system has it,
51
52
  // from the first read: the lab prints from the work system, so it must be
52
53
  // right there, not only on Flow's invoice. The card says what to add.
@@ -71,7 +72,7 @@ export async function describeObligations(db, organisationId, listed, { accounti
71
72
  const waiting = (o, v) => {
72
73
  if (o.invoiceId || !['in_work', 'ready', 'pending', 'held'].includes(v)) return { waitingFor: [], waitingRules: [] };
73
74
  const customer = { ...effectiveSettings(settings, customers.get(o.contactId)), name: nameOf.get(o.contactId) ?? null };
74
- const waitingRules = ruleWait(rules, o, { customer });
75
+ const waitingRules = ruleWait(rules, o, { customer, items });
75
76
  // Keep the existing text projection for API consumers; links use identity.
76
77
  return { waitingRules, waitingFor: [
77
78
  ...ruleBlockers(o, customer),
@@ -197,6 +197,8 @@ export async function observeJob(db, organisationId, job) {
197
197
  practitioner: job.practitioner ?? null,
198
198
  jobDate: job.jobDate ?? null,
199
199
  sourceStatus: job.sourceStatus ?? null,
200
+ // Seazona's remake flag, for the invoice rule field `remake`.
201
+ remake: job.remake === true,
200
202
  lines,
201
203
  lineFingerprint: fingerprint(lines),
202
204
  sourceTotals,
@@ -237,7 +239,7 @@ export async function observeJob(db, organisationId, job) {
237
239
  : String(existing[key] ?? '') !== String(facts[key] ?? ''));
238
240
  if (!changed.length) {
239
241
  const row = await update(tx, existing, {
240
- sourceSeenAt: seenAt, mappingComplete: facts.mappingComplete, lines, jobDate: facts.jobDate, sourceStatus: facts.sourceStatus,
242
+ sourceSeenAt: seenAt, mappingComplete: facts.mappingComplete, lines, jobDate: facts.jobDate, sourceStatus: facts.sourceStatus, remake: facts.remake,
241
243
  });
242
244
  await statusChanged(tx, organisationId, existing, row, actor);
243
245
  return recordSourceReview(tx, row, actor);
@@ -907,10 +909,16 @@ export async function linkJobToInvoice(db, organisationId, obligationId, { invoi
907
909
  if (!invoice) throw notFound('invoice');
908
910
  if (invoice.status === 'void') throw new FlowError('invalid', 'the invoice is void');
909
911
  const rejected = await rejectSuggestions(tx, obligation, { actor });
912
+ // A job that waited for a person (no invoice found, or more than one fit)
913
+ // is settled now: the matcher reads it again on its next pass and records
914
+ // the invoice's stage. A link made from an accounting invoice
915
+ // (`accountingRef`) is exact, so the matcher follows that invoice.
916
+ const settled = ['not_found', 'ambiguous'].includes(obligation.accountingState);
910
917
  const row = await update(tx, obligation, {
911
918
  invoiceId: invoice.id,
912
919
  draftBasis: basisOf(obligation),
913
- ...(obligation.accountingState === 'ambiguous' ? { accountingState: 'unknown' } : {}),
920
+ ...(settled ? { accountingState: 'unknown', accountingCheckedAt: null } : {}),
921
+ ...(accountingRef && !obligation.accountingRef ? { accountingRef } : {}),
914
922
  ...(obligation.readyAt ? {} : { readyAt: sql`now()`, readyBy: actor }),
915
923
  });
916
924
  await addEvent(tx, row, 'invoice_linked', actor, { invoiceId: invoice.id, matchedBy: 'person', rejected });
@@ -426,13 +426,16 @@ export async function matchJob(db, organisationId, obligation, {
426
426
 
427
427
  async function holdIfStale(db, organisationId, obligation, { now, staleDays }) {
428
428
  if (!staleDays || !obligation.jobDate || obligation.holdState !== 'none') return false;
429
+ // A job with a Flow invoice, or one a rule released from the history hold
430
+ // (a non-chargeable job), is decided already.
431
+ if (obligation.invoiceId) return false;
429
432
  if (now - day(obligation.jobDate) <= staleDays * 86_400_000) return false;
430
433
  // Once only: a person who releases the job has decided.
431
434
  const [held] = await db.select({ id: obligationEvents.id }).from(obligationEvents).where(and(
432
435
  eq(obligationEvents.organisationId, organisationId),
433
436
  eq(obligationEvents.obligationId, obligation.id),
434
- eq(obligationEvents.actor, ACTOR),
435
- eq(obligationEvents.kind, 'held'),
437
+ sql`((${obligationEvents.actor} = ${ACTOR} and ${obligationEvents.kind} = 'held')
438
+ or (${obligationEvents.kind} = 'released' and ${obligationEvents.actor} like 'rule:%'))`,
436
439
  )).limit(1);
437
440
  if (held) return false;
438
441
  await setHold(db, organisationId, obligation.id, {
@@ -119,14 +119,20 @@ export async function archivePriceItem(db, organisationId, id, { archived = true
119
119
  }
120
120
 
121
121
  // Each connected system's list, by system id: { id, name, kind, syncedAt,
122
- // items, byId }. A system that cannot answer is left out (it is off, or its
123
- // copy is not there yet).
122
+ // items, byId }. A system that cannot answer (its copy is not there yet, or
123
+ // the read failed) is `unread`: { id, name, unread: true }, with no items.
124
+ // The check then leaves its findings and alerts as they were, and the list
125
+ // says it was not read, so "no difference" never stands for "not read".
124
126
  export async function readCatalogues(organisationId, systems = [], { log = console } = {}) {
125
127
  const out = new Map();
128
+ const unread = (adapter) => out.set(adapter.id, { id: adapter.id, name: adapter.name, unread: true, items: [], byId: new Map() });
126
129
  for (const adapter of systems) {
127
130
  try {
128
131
  const answer = await adapter.capabilities.listPriceItems({ organisationId });
129
- if (!answer || !Array.isArray(answer.items)) continue;
132
+ if (!answer || !Array.isArray(answer.items)) {
133
+ unread(adapter);
134
+ continue;
135
+ }
130
136
  const items = answer.items.filter((i) => i && i.id);
131
137
  out.set(adapter.id, {
132
138
  id: adapter.id,
@@ -138,10 +144,12 @@ export async function readCatalogues(organisationId, systems = [], { log = conso
138
144
  });
139
145
  } catch (error) {
140
146
  log.warn?.(`[price-list] ${adapter.id} price list could not be read: ${error.message}`);
147
+ unread(adapter);
141
148
  }
142
149
  }
143
150
  return out;
144
151
  }
152
+ const readOnes = (catalogues) => [...catalogues.values()].filter((c) => !c.unread);
145
153
 
146
154
  async function readLinks(db, organisationId) {
147
155
  return db.select().from(priceItemLinks).where(eq(priceItemLinks.organisationId, organisationId))
@@ -177,13 +185,17 @@ export async function linkByCode(db, organisationId, catalogue, { items = null,
177
185
  async function writeLink(db, organisationId, priceItemId, { system, externalId }, { catalogues, personId }) {
178
186
  const item = await loadItem(db, organisationId, priceItemId);
179
187
  const catalogue = catalogues.get(system);
180
- if (!catalogue) throw new FlowError('conflict', `${system} is not connected, or its price list has not been read yet`);
188
+ if (!catalogue || catalogue.unread) throw new FlowError('conflict', `${system} is not connected, or its price list has not been read yet`);
181
189
  const record = catalogue.byId.get(String(externalId ?? ''));
182
190
  if (!record) throw notFound(`${catalogue.name} record`);
183
191
  return db.transaction(async (tx) => {
184
- await tx.delete(priceItemLinks).where(and(
192
+ // The record's earlier link, if any. A link taken from another Flow item
193
+ // is named in the answer (`movedFrom`), so the caller can say which item
194
+ // lost it: that item no longer sends as this record.
195
+ const earlier = await tx.delete(priceItemLinks).where(and(
185
196
  eq(priceItemLinks.organisationId, organisationId), eq(priceItemLinks.system, system), eq(priceItemLinks.externalId, String(record.id)),
186
- ));
197
+ )).returning({ priceItemId: priceItemLinks.priceItemId });
198
+ const movedFrom = earlier.find((l) => l.priceItemId !== item.id)?.priceItemId ?? null;
187
199
  if (catalogue.kind === 'accounting') {
188
200
  await tx.delete(priceItemLinks).where(and(
189
201
  eq(priceItemLinks.organisationId, organisationId), eq(priceItemLinks.system, system), eq(priceItemLinks.priceItemId, item.id),
@@ -192,7 +204,7 @@ async function writeLink(db, organisationId, priceItemId, { system, externalId }
192
204
  const [row] = await tx.insert(priceItemLinks).values({
193
205
  organisationId, priceItemId: item.id, system, externalId: String(record.id), externalCode: record.code ?? null, matchedBy: 'person', createdBy: personId,
194
206
  }).returning();
195
- return row;
207
+ return movedFrom ? { ...row, movedFrom } : row;
196
208
  });
197
209
  }
198
210
 
@@ -234,7 +246,7 @@ export async function unlinkPriceItem(db, organisationId, linkId) {
234
246
  // caller passes the item, its links and the catalogues.
235
247
  export function findingsFor(item, links, catalogues) {
236
248
  const findings = [];
237
- for (const catalogue of catalogues.values()) {
249
+ for (const catalogue of readOnes(catalogues)) {
238
250
  const mine = links.filter((l) => l.system === catalogue.id);
239
251
  if (!mine.length && catalogue.kind === 'accounting') {
240
252
  findings.push({ system: catalogue.id, kind: 'not_linked', code: item.code });
@@ -253,6 +265,13 @@ export function findingsFor(item, links, catalogues) {
253
265
  if (record.taxCode && record.taxCode !== item.taxCode) {
254
266
  findings.push({ ...base, kind: 'tax', flow: item.taxCode, other: record.taxCode });
255
267
  }
268
+ // Invoices send the linked accounting item. When another item there has
269
+ // the Flow code, the link decides which one (and its sales account): a
270
+ // person should confirm the link is the right one.
271
+ if (catalogue.kind === 'accounting' && record.code !== item.code
272
+ && catalogue.items.some((other) => other.code === item.code && String(other.id) !== String(link.externalId))) {
273
+ findings.push({ ...base, kind: 'code', flow: item.code, other: record.code });
274
+ }
256
275
  }
257
276
  }
258
277
  return findings;
@@ -285,12 +304,20 @@ async function lastClosedSignatures(db, organisationId, ids) {
285
304
  export async function checkPrices(db, organisationId, { catalogues, only = null, actor = 'flow:price-check' }) {
286
305
  const items = await db.select().from(priceItems).where(eq(priceItems.organisationId, organisationId));
287
306
  let linked = 0;
288
- for (const catalogue of catalogues.values()) linked += await linkByCode(db, organisationId, catalogue, { items });
307
+ for (const catalogue of readOnes(catalogues)) linked += await linkByCode(db, organisationId, catalogue, { items });
289
308
  const links = await readLinks(db, organisationId);
290
309
  const live = items.filter((i) => !i.archivedAt && (!only || i.id === only));
291
310
  const latest = await lastClosedSignatures(db, organisationId, live.map((i) => i.id));
292
- const summary = { linked, checked: live.length, differing: 0, opened: 0, closed: 0, accepted: 0 };
311
+ const unread = new Set([...catalogues.values()].filter((c) => c.unread).map((c) => c.id));
312
+ const summary = { linked, checked: live.length, differing: 0, opened: 0, closed: 0, accepted: 0, ...(unread.size ? { unread: [...unread], kept: 0 } : {}) };
293
313
  for (const item of live) {
314
+ // A list not read says nothing: an item linked to it, or whose open alert
315
+ // names it, keeps its alert as it was until the list is read.
316
+ if (unread.size && (links.some((l) => l.priceItemId === item.id && unread.has(l.system))
317
+ || (latest.get(item.id)?.state === 'open' && latest.get(item.id).evidence?.findings?.some((f) => unread.has(f.system))))) {
318
+ summary.kept += 1;
319
+ continue;
320
+ }
294
321
  const findings = findingsFor(item, links.filter((l) => l.priceItemId === item.id), catalogues);
295
322
  const signature = findingsSignature(item, findings);
296
323
  const last = latest.get(item.id);
@@ -352,7 +379,9 @@ export async function readPriceList(db, organisationId, { catalogues }) {
352
379
  }
353
380
  const linked = new Set(links.map((l) => `${l.system}:${l.externalId}`));
354
381
  return {
355
- systems: [...catalogues.values()].map((c) => ({ id: c.id, name: c.name, kind: c.kind, syncedAt: c.syncedAt, count: c.items.length })),
382
+ systems: readOnes(catalogues).map((c) => ({ id: c.id, name: c.name, kind: c.kind, syncedAt: c.syncedAt, count: c.items.length })),
383
+ // Systems whose list Flow could not read now: their differences are not shown.
384
+ unread: [...catalogues.values()].filter((c) => c.unread).map((c) => ({ id: c.id, name: c.name })),
356
385
  items: items.map((item) => {
357
386
  const mine = linksOf.get(item.id) ?? [];
358
387
  return {
@@ -365,7 +394,7 @@ export async function readPriceList(db, organisationId, { catalogues }) {
365
394
  }),
366
395
  };
367
396
  }),
368
- unlinked: [...catalogues.values()].map((c) => ({
397
+ unlinked: readOnes(catalogues).map((c) => ({
369
398
  system: c.id,
370
399
  records: c.items.filter((r) => r.sold !== false && !linked.has(`${c.id}:${r.id}`)),
371
400
  })),
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
2
2
  import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm';
3
3
  import { billingObligations, contacts, invoiceLines, invoices, people, priceItems } from '../db/schema.js';
4
4
  import { FlowError } from './errors.js';
5
- import { listRules, ruleExceptionsFor, ruleNonChargeable, rulePending, ruleSkip } from './invoice-rules.js';
5
+ import { accountingItemsFor, listRules, ruleExceptionsFor, 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
8
  import { CHANGED_REASON, diffJobLines, sourceBillingProblems, STATUS_BACK_REASON } from './ledger.js';
@@ -118,6 +118,7 @@ async function checkDrafts(db, organisationId, drafts, accounting) {
118
118
  .innerJoin(priceItems, eq(priceItems.id, invoiceLines.priceItemId))
119
119
  .where(and(inArray(invoiceLines.invoiceId, jobs.map((j) => j.obligation.invoiceId)), sql`${invoiceLines.unitAmountCents} * ${invoiceLines.quantity} > 0`));
120
120
  const itemsOf = Map.groupBy(itemRows, (r) => r.invoiceId);
121
+ const items = await accountingItemsFor(accounting, organisationId, rules, jobs.map((j) => j.obligation));
121
122
  for (const { obligation, reference, customerName } of jobs) {
122
123
  const customer = { ...effectiveSettings(settings, customers.get(obligation.contactId)), name: customerName ?? null };
123
124
  const job = {
@@ -125,7 +126,7 @@ async function checkDrafts(db, organisationId, drafts, accounting) {
125
126
  ruleExceptions: exceptions.get(obligation.id) ?? [],
126
127
  draftItems: (itemsOf.get(obligation.invoiceId) ?? []).map((r) => r.code),
127
128
  };
128
- const found = [...ruleNonChargeable(rules, job, { customer }), ...rulePending(rules, job, { customer }), ...ruleSkip(rules, job, { customer })];
129
+ const found = [...ruleNonChargeable(rules, job, { customer, items }), ...rulePending(rules, job, { customer, items }), ...ruleSkip(rules, job, { customer, items })];
129
130
  if (found.length) problems.set(obligation.invoiceId, [...(problems.get(obligation.invoiceId) ?? []), ...found]);
130
131
  }
131
132
  }
@@ -0,0 +1,38 @@
1
+ // Sync now (docs/INTEGRATIONS.md, "Sync now"): a person asks Flow to read its
2
+ // systems again now, not at the next background run. An integration takes
3
+ // part while its own "Include in Sync now" setting is on, the default. Each
4
+ // integration's page also has its own Sync, which reads that one integration
5
+ // whatever the setting says. Each read goes through the integration's call guard
6
+ // and rate limit, as a background read does. The pipeline joins what they
7
+ // bring into jobs at its next run (every 2 minutes).
8
+
9
+ const named = (adapter, answer) => ({ id: adapter.id, name: adapter.name, ...answer });
10
+
11
+ // What the Sync now button shows: each integration that can take part,
12
+ // whether it does, whether a read is going, and when the last one started.
13
+ // Reads Flow's data only.
14
+ export async function syncNowStatus(registry) {
15
+ return Promise.all(registry.with('syncNowStatus').map(async (adapter) => {
16
+ try {
17
+ return named(adapter, await adapter.capabilities.syncNowStatus());
18
+ } catch {
19
+ return named(adapter, { included: false, running: false, lastAt: null, ok: null });
20
+ }
21
+ }));
22
+ }
23
+
24
+ // Starts each included integration's read, one after the other, or only the
25
+ // integration `only` names. An integration that cannot read answers
26
+ // `unavailable` with its reason; the others still run.
27
+ export async function startSyncNow(registry, { only = null, actor = null, log = console } = {}) {
28
+ const results = [];
29
+ for (const adapter of registry.with('syncNow').filter((a) => !only || a.id === only)) {
30
+ try {
31
+ results.push(named(adapter, await adapter.capabilities.syncNow({ asked: Boolean(only) })));
32
+ } catch (error) {
33
+ results.push(named(adapter, { state: 'unavailable', reason: error?.message || 'the read failed' }));
34
+ }
35
+ }
36
+ log.info?.(`[sync-now] person ${actor ?? 'unknown'}: ${results.map((r) => `${r.id} ${r.state}`).join(', ') || 'no integration can sync'}`);
37
+ return results;
38
+ }
@@ -0,0 +1 @@
1
+ ALTER TABLE "billing_obligations" ADD COLUMN IF NOT EXISTS "remake" boolean DEFAULT false NOT NULL;