@arcforgelabs/flow 2026.11.2 → 2026.11.4

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 (49) hide show
  1. package/SECURITY.md +12 -0
  2. package/npm-shrinkwrap.json +2 -2
  3. package/package.json +2 -1
  4. package/server/agents/gate.js +25 -5
  5. package/server/api/invoicing.js +119 -3
  6. package/server/app.js +3 -2
  7. package/server/auth/routes.js +20 -0
  8. package/server/auth/session.js +44 -1
  9. package/server/auth/settings.js +27 -0
  10. package/server/core/alerts.js +38 -3
  11. package/server/core/automations.js +8 -3
  12. package/server/core/invoice-rules.js +158 -8
  13. package/server/core/invoices.js +9 -1
  14. package/server/core/invoicing-rules.js +31 -6
  15. package/server/core/job-board.js +16 -1
  16. package/server/core/job-match.js +6 -4
  17. package/server/core/ledger.js +67 -8
  18. package/server/core/pipeline.js +6 -2
  19. package/server/core/reconcile.js +11 -3
  20. package/server/core/sending.js +30 -3
  21. package/server/db/migrations/0027_high_doctor_strange.sql +24 -0
  22. package/server/db/migrations/0028_sharp_power_pack.sql +2 -0
  23. package/server/db/migrations/meta/0027_snapshot.json +5050 -0
  24. package/server/db/migrations/meta/0028_snapshot.json +5062 -0
  25. package/server/db/migrations/meta/_journal.json +14 -0
  26. package/server/db/schema.js +21 -0
  27. package/server/integrations/seazona/feed.js +10 -3
  28. package/server/integrations/seazona/index.js +6 -1
  29. package/server/integrations/seazona/routes.js +65 -2
  30. package/server/integrations/seazona/settings.js +3 -0
  31. package/server/integrations/xero/drafts.js +1 -1
  32. package/server/integrations/xero/index.js +1 -1
  33. package/server/integrations/xero/ledger.js +17 -8
  34. package/server/integrations/xero/links.js +39 -1
  35. package/server/integrations/xero/lookup.js +3 -1
  36. package/server/integrations/xero/routes.js +18 -1
  37. package/server/ui/app-page.js +1 -1
  38. package/server/ui/app.js +7 -4
  39. package/server/ui/flow.css +69 -1
  40. package/server/ui/home.js +120 -0
  41. package/server/ui/invoice-rules.js +11 -3
  42. package/server/ui/invoicing.js +81 -6
  43. package/server/ui/job-board.js +8 -3
  44. package/server/ui/jobs.js +14 -2
  45. package/server/ui/nav-pins.js +5 -3
  46. package/server/ui/reconcile.js +4 -2
  47. package/server/ui/routes.js +1 -1
  48. package/skills/flow/SKILL.md +306 -0
  49. package/skills/flow/flow.sh +121 -0
@@ -1,9 +1,10 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { createRequire } from 'node:module';
3
3
  import { dirname, join } from 'node:path';
4
- import { and, asc, eq, sql } from 'drizzle-orm';
5
- import { invoiceRules, invoicingSettings } from '../db/schema.js';
4
+ import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm';
5
+ import { billingObligations, invoiceRuleExceptions, invoiceRules, invoicingSettings, obligationEvents, priceItems } from '../db/schema.js';
6
6
  import { FlowError, notFound } from './errors.js';
7
+ import { getInvoice, updateDraftInvoice } from './invoices.js';
7
8
 
8
9
  // Invoice rules (docs/INVOICE_RULES.md): a lab's own checks on a job before
9
10
  // Flow drafts its invoice. Each rule is When, If and Then, and a lab owner
@@ -62,7 +63,7 @@ export const FIELDS = Object.freeze({
62
63
  // one marker, so a new branch needs no new rule.
63
64
  customer: {
64
65
  label: 'Customer', kind: 'choice', options: 'customers',
65
- tests: ['is', 'is_not', 'is_one_of', 'contains', 'does_not_contain', 'is_empty'],
66
+ tests: ['is', 'is_not', 'is_one_of', 'is_not_one_of', 'contains', 'does_not_contain', 'is_empty'],
66
67
  read: (j) => j.contactId,
67
68
  },
68
69
  customer_billable: { label: 'Customer is billable', kind: 'boolean', read: (j, { customer }) => customer.billable !== false },
@@ -73,7 +74,7 @@ export const FIELDS = Object.freeze({
73
74
  export const TESTS = Object.freeze({
74
75
  number: ['more_than', 'at_most', 'is', 'is_not', 'is_empty'],
75
76
  money: ['more_than', 'at_most', 'is', 'is_not'],
76
- choice: ['is', 'is_not', 'is_one_of', 'is_empty'],
77
+ choice: ['is', 'is_not', 'is_one_of', 'is_not_one_of', 'is_empty'],
77
78
  text: ['is', 'is_not', 'is_one_of', 'is_not_one_of', 'contains', 'does_not_contain', 'starts_with', 'does_not_start_with', 'has_number', 'number_in_words', 'one_word', 'is_empty', 'is_not_empty'],
78
79
  boolean: ['is'],
79
80
  items: ['has', 'has_not'],
@@ -134,6 +135,13 @@ export function readRule(raw) {
134
135
  const note = typeof raw.action.note === 'string' ? raw.action.note.trim() : '';
135
136
  if (!note || note.length > MAX_NOTE) throw invalid(`a pending rule needs a note of ${MAX_NOTE} characters at most: what a person must fix`);
136
137
  action.note = note;
138
+ // The fix a person can take with one press: add this price list item to
139
+ // the draft (for example a shipping line the work system left off).
140
+ if (raw.action.addItem !== undefined && raw.action.addItem !== null) {
141
+ const item = typeof raw.action.addItem === 'string' ? raw.action.addItem.trim() : '';
142
+ if (!item || item.length > 40) throw invalid('addItem must be a price list code of 40 characters at most');
143
+ action.addItem = item;
144
+ }
137
145
  } else {
138
146
  const early = conditions.find((c) => FIELDS[c.field].draftOnly);
139
147
  if (early) throw invalid(`${FIELDS[early.field].label} is on the draft only: use Then "Pending, with a note"`);
@@ -189,6 +197,7 @@ function matches(condition, job, context) {
189
197
  default: {
190
198
  const left = norm(have);
191
199
  if (test === 'is_one_of') return value.some((v) => norm(v) === left);
200
+ if (test === 'is_not_one_of') return !value.some((v) => norm(v) === left);
192
201
  return test === 'is' ? left === norm(value) : left !== norm(value);
193
202
  }
194
203
  }
@@ -227,12 +236,21 @@ const ruleResolution = (rule) => {
227
236
  return { kind: 'rule_review' };
228
237
  };
229
238
 
239
+ // A rule leaves a job alone when a person made an exception for it
240
+ // (`job.ruleExceptions`, rule ids), or, for a rule with `addItem`, when the
241
+ // draft already has that item (`job.draftItems`, codes): the fix was taken.
230
242
  export function rulePending(rules, job, { customer = {}, now = new Date() } = {}) {
231
243
  const context = { customer, now };
244
+ const excepted = new Set(job.ruleExceptions ?? []);
245
+ const onDraft = new Set((job.draftItems ?? []).map(norm));
232
246
  return rules
233
247
  .filter((r) => r.enabled && r.trigger === 'job_ready' && r.action.kind === 'pending')
248
+ .filter((r) => !excepted.has(r.id) && !(r.action.addItem && onDraft.has(norm(r.action.addItem))))
234
249
  .filter((r) => r.conditions.every((c) => matches(c, job, context)))
235
- .map((r) => ({ 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))], resolution: ruleResolution(r) }));
250
+ .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))],
252
+ resolution: ruleResolution(r), ...(r.action.addItem ? { addItem: r.action.addItem } : {}),
253
+ }));
236
254
  }
237
255
 
238
256
  // Drafts an enabled skip rule matches stay on Pending at Send (sending.js).
@@ -295,9 +313,9 @@ async function seed(db, organisationId) {
295
313
  });
296
314
  }
297
315
 
298
- const shape = (r) => ({
316
+ const shape = (r, exceptions = 0) => ({
299
317
  id: r.id, name: r.name, icon: r.icon, position: r.position, enabled: r.enabled, trigger: r.trigger,
300
- conditions: r.conditions, action: r.action, starter: Boolean(r.starterKey), updatedAt: r.updatedAt,
318
+ conditions: r.conditions, action: r.action, starter: Boolean(r.starterKey), updatedAt: r.updatedAt, exceptions,
301
319
  });
302
320
 
303
321
  // The organisation's rules, in order. The first read makes the starter rules.
@@ -307,11 +325,101 @@ export async function listRules(db, organisationId) {
307
325
  if (!settings?.seeded) await seed(db, organisationId);
308
326
  const rows = await db.select().from(invoiceRules).where(eq(invoiceRules.organisationId, organisationId))
309
327
  .orderBy(asc(invoiceRules.position), asc(invoiceRules.createdAt));
310
- return rows.map(shape);
328
+ const counts = await db.select({ ruleId: invoiceRuleExceptions.ruleId, count: sql`count(*)::int` }).from(invoiceRuleExceptions)
329
+ .where(eq(invoiceRuleExceptions.organisationId, organisationId)).groupBy(invoiceRuleExceptions.ruleId);
330
+ const byRule = new Map(counts.map((c) => [c.ruleId, Number(c.count)]));
331
+ return rows.map((r) => shape(r, byRule.get(r.id) ?? 0));
332
+ }
333
+
334
+ // ── Exceptions ─────────────────────────────────────────────────────────────
335
+
336
+ // A person says one job is fine although a rule matches it. The rule then
337
+ // leaves the job alone. Only a rule with an item to add takes one: its Skip.
338
+ // Each exception keeps who, when and why, stays listed on the rule, and is
339
+ // in the job's history. It holds while the rule's item and the job's lines
340
+ // stay as they were; a change to either ends it, so a person looks again.
341
+ export async function addRuleException(db, organisationId, ruleId, obligationId, { reason, actor }) {
342
+ const why = typeof reason === 'string' ? reason.trim() : '';
343
+ if (!why || why.length > 200) throw invalid('an exception needs a reason of 1 to 200 characters');
344
+ if (!actor) throw invalid('actor is required');
345
+ const rule = await loadRule(db, organisationId, ruleId);
346
+ if (rule.action?.kind !== 'pending' || !rule.action.addItem) throw invalid('only a pending rule with an item to add takes an exception; change or turn off the rule');
347
+ return db.transaction(async (tx) => {
348
+ const [job] = await tx.select({ id: billingObligations.id, lineFingerprint: billingObligations.lineFingerprint }).from(billingObligations)
349
+ .where(and(eq(billingObligations.organisationId, organisationId), eq(billingObligations.id, obligationId))).for('update');
350
+ if (!job) throw notFound('job');
351
+ const values = { reason: why, actor, itemCode: rule.action.addItem, lineFingerprint: job.lineFingerprint, createdAt: sql`now()` };
352
+ const [row] = await tx.insert(invoiceRuleExceptions).values({ organisationId, ruleId, obligationId, ...values })
353
+ .onConflictDoUpdate({ target: [invoiceRuleExceptions.ruleId, invoiceRuleExceptions.obligationId], set: values })
354
+ .returning();
355
+ await tx.insert(obligationEvents).values({ organisationId, obligationId, kind: 'rule_exception_added', actor,
356
+ evidence: { ruleId, rule: rule.name, item: rule.action.addItem, reason: why } });
357
+ return row;
358
+ });
359
+ }
360
+
361
+ // The rule ids each job has a current exception for, by job.
362
+ export async function ruleExceptionsFor(db, organisationId, obligationIds) {
363
+ if (!obligationIds.length) return new Map();
364
+ const rows = await db.select({ ruleId: invoiceRuleExceptions.ruleId, obligationId: invoiceRuleExceptions.obligationId }).from(invoiceRuleExceptions)
365
+ .innerJoin(invoiceRules, eq(invoiceRules.id, invoiceRuleExceptions.ruleId))
366
+ .innerJoin(billingObligations, eq(billingObligations.id, invoiceRuleExceptions.obligationId))
367
+ .where(and(eq(invoiceRuleExceptions.organisationId, organisationId), inArray(invoiceRuleExceptions.obligationId, obligationIds), CURRENT));
368
+ const out = new Map();
369
+ for (const r of rows) out.set(r.obligationId, [...(out.get(r.obligationId) ?? []), r.ruleId]);
370
+ return out;
371
+ }
372
+ const CURRENT = sql`(${invoiceRuleExceptions.itemCode} is not distinct from (${invoiceRules.action} ->> 'addItem')
373
+ and ${invoiceRuleExceptions.lineFingerprint} is not distinct from ${billingObligations.lineFingerprint})`;
374
+
375
+ // One rule's exceptions, newest first. `current` is false when the rule's
376
+ // item or the job's lines changed since: the rule applies to that job again.
377
+ export async function listRuleExceptions(db, organisationId, ruleId) {
378
+ await loadRule(db, organisationId, ruleId);
379
+ return db.select({ id: invoiceRuleExceptions.id, obligationId: invoiceRuleExceptions.obligationId, reason: invoiceRuleExceptions.reason,
380
+ actor: invoiceRuleExceptions.actor, itemCode: invoiceRuleExceptions.itemCode, createdAt: invoiceRuleExceptions.createdAt, current: sql`${CURRENT}`.mapWith(Boolean) })
381
+ .from(invoiceRuleExceptions)
382
+ .innerJoin(invoiceRules, eq(invoiceRules.id, invoiceRuleExceptions.ruleId))
383
+ .innerJoin(billingObligations, eq(billingObligations.id, invoiceRuleExceptions.obligationId))
384
+ .where(and(eq(invoiceRuleExceptions.organisationId, organisationId), eq(invoiceRuleExceptions.ruleId, ruleId)))
385
+ .orderBy(desc(invoiceRuleExceptions.createdAt));
386
+ }
387
+
388
+ // One job's exceptions, for its invoice page.
389
+ export async function jobRuleExceptions(db, organisationId, obligationId) {
390
+ return db.select({ id: invoiceRuleExceptions.id, ruleId: invoiceRuleExceptions.ruleId, rule: invoiceRules.name, reason: invoiceRuleExceptions.reason,
391
+ actor: invoiceRuleExceptions.actor, itemCode: invoiceRuleExceptions.itemCode, createdAt: invoiceRuleExceptions.createdAt, current: sql`${CURRENT}`.mapWith(Boolean) })
392
+ .from(invoiceRuleExceptions)
393
+ .innerJoin(invoiceRules, eq(invoiceRules.id, invoiceRuleExceptions.ruleId))
394
+ .innerJoin(billingObligations, eq(billingObligations.id, invoiceRuleExceptions.obligationId))
395
+ .where(and(eq(invoiceRuleExceptions.organisationId, organisationId), eq(invoiceRuleExceptions.obligationId, obligationId)))
396
+ .orderBy(desc(invoiceRuleExceptions.createdAt));
397
+ }
398
+
399
+ // Takes an exception back: the rule applies to the job again.
400
+ export async function removeRuleException(db, organisationId, ruleId, exceptionId, { actor = 'system' } = {}) {
401
+ return db.transaction(async (tx) => {
402
+ const [row] = await tx.delete(invoiceRuleExceptions).where(and(eq(invoiceRuleExceptions.organisationId, organisationId),
403
+ eq(invoiceRuleExceptions.ruleId, ruleId), eq(invoiceRuleExceptions.id, exceptionId))).returning();
404
+ if (!row) throw notFound('exception');
405
+ await tx.insert(obligationEvents).values({ organisationId, obligationId: row.obligationId, kind: 'rule_exception_removed', actor,
406
+ evidence: { ruleId, item: row.itemCode, reason: row.reason } });
407
+ });
408
+ }
409
+
410
+ // A rule's item must be on the price list, and not archived.
411
+ async function checkRuleItem(db, organisationId, rule) {
412
+ const code = rule.action.addItem;
413
+ if (!code) return;
414
+ const [item] = await db.select({ archivedAt: priceItems.archivedAt }).from(priceItems)
415
+ .where(and(eq(priceItems.organisationId, organisationId), sql`upper(${priceItems.code}) = upper(${code})`));
416
+ if (!item) throw invalid(`the price list has no item ${code}`);
417
+ if (item.archivedAt) throw invalid(`price item ${code} is archived`);
311
418
  }
312
419
 
313
420
  export async function createRule(db, organisationId, raw, { personId = null } = {}) {
314
421
  const rule = readRule(raw);
422
+ await checkRuleItem(db, organisationId, rule);
315
423
  const existing = await listRules(db, organisationId);
316
424
  if (existing.length >= MAX_RULES) throw invalid(`an organisation can have ${MAX_RULES} rules at most`);
317
425
  const position = existing.length ? Math.max(...existing.map((r) => r.position)) + 1 : 0;
@@ -331,6 +439,7 @@ export async function updateRule(db, organisationId, ruleId, changes, { personId
331
439
  const row = await loadRule(db, organisationId, ruleId);
332
440
  if (!changes || typeof changes !== 'object' || Array.isArray(changes)) throw invalid('the change must be an object');
333
441
  const rule = readRule({ name: row.name, icon: row.icon, enabled: row.enabled, conditions: row.conditions, action: row.action, ...changes });
442
+ if (rule.action.addItem && rule.action.addItem !== row.action?.addItem) await checkRuleItem(db, organisationId, rule);
334
443
  const [saved] = await db.update(invoiceRules).set({ ...rule, updatedBy: personId, updatedAt: sql`now()` })
335
444
  .where(eq(invoiceRules.id, row.id)).returning();
336
445
  return shape(saved);
@@ -340,3 +449,44 @@ export async function deleteRule(db, organisationId, ruleId) {
340
449
  const row = await loadRule(db, organisationId, ruleId);
341
450
  await db.delete(invoiceRules).where(eq(invoiceRules.id, row.id));
342
451
  }
452
+
453
+ // ── A rule's one-press fix ─────────────────────────────────────────────────
454
+
455
+ // "Add <item>" on a Pending draft: adds one of the rule's price list item to
456
+ // the draft, at the price list's price. The rule then leaves the draft alone.
457
+ // Flow never writes the line to the work system: the invoice page says the
458
+ // job and the work system differ until the work system has it too.
459
+ export async function addRuleItem(db, organisationId, invoiceId, ruleId) {
460
+ const rule = await loadRule(db, organisationId, ruleId);
461
+ const code = rule.action?.addItem;
462
+ if (!code || rule.action.kind !== 'pending') throw invalid('this rule has no item to add');
463
+ if (!rule.enabled) throw invalid('this rule is off');
464
+ const [item] = await db.select().from(priceItems)
465
+ .where(and(eq(priceItems.organisationId, organisationId), sql`upper(${priceItems.code}) = upper(${code})`));
466
+ if (!item) throw invalid(`the price list has no item ${code}`);
467
+ if (item.archivedAt) throw invalid(`price item ${code} is archived`);
468
+ // The lines are read under the draft's lock: a second press, or an edit at
469
+ // the same time, adds nothing twice and loses nothing.
470
+ await updateDraftInvoice(db, organisationId, invoiceId, {
471
+ lines: (current) => (current.some((l) => l.priceItemId === item.id) ? null : [
472
+ ...current.map(({ priceItemId, accountCode, description, quantity, unitAmountCents, taxCode }) => (
473
+ { priceItemId, accountCode, description, quantity, unitAmountCents, taxCode })),
474
+ { priceItemId: item.id, quantity: 1 },
475
+ ]),
476
+ });
477
+ return getInvoice(db, organisationId, invoiceId);
478
+ }
479
+
480
+ // "Skip" on a Pending draft: an exception for the draft's job, with a reason.
481
+ // The same, before the job has a draft: the job board shows the job needs an
482
+ // item, and the person knows it does not (picked up, for example).
483
+ export async function exceptJob(db, organisationId, obligationId, ruleId, { reason, actor }) {
484
+ return addRuleException(db, organisationId, ruleId, obligationId, { reason, actor });
485
+ }
486
+
487
+ export async function exceptDraft(db, organisationId, invoiceId, ruleId, { reason, actor }) {
488
+ const [job] = await db.select({ id: billingObligations.id }).from(billingObligations)
489
+ .where(and(eq(billingObligations.organisationId, organisationId), eq(billingObligations.invoiceId, invoiceId)));
490
+ if (!job) throw invalid('this invoice has no job; edit the invoice instead');
491
+ return addRuleException(db, organisationId, ruleId, job.id, { reason, actor });
492
+ }
@@ -111,7 +111,15 @@ export async function updateDraftInvoice(db, organisationId, invoiceId, changes)
111
111
  .where(eq(invoices.id, invoice.id)).returning();
112
112
  }
113
113
  if ('lines' in changes) {
114
- row = await writeLines(tx, row, await resolveLines(tx, organisationId, changes.lines));
114
+ // `lines` may be a function of the draft's current lines, read under
115
+ // the lock, so a change made at the same time is not lost.
116
+ let lines = changes.lines;
117
+ if (typeof lines === 'function') {
118
+ const current = await tx.select().from(invoiceLines).where(eq(invoiceLines.invoiceId, invoice.id)).orderBy(asc(invoiceLines.position));
119
+ lines = lines(current);
120
+ if (lines === null) return row;
121
+ }
122
+ row = await writeLines(tx, row, await resolveLines(tx, organisationId, lines));
115
123
  }
116
124
  return row;
117
125
  });
@@ -1,6 +1,6 @@
1
1
  import { and, eq, inArray, isNull, sql } from 'drizzle-orm';
2
2
  import {
3
- billingObligations, contacts, customerBillingSettings, invoicingSettings, SEND_MODES,
3
+ billingObligations, contacts, customerBillingSettings, externalLinks, invoicingSettings, SEND_MODES,
4
4
  } from '../db/schema.js';
5
5
  import { FlowError, notFound } from './errors.js';
6
6
  import { listRules, ruleReasons } from './invoice-rules.js';
@@ -214,20 +214,45 @@ const isoDay = (at) => `${at.getFullYear()}-${String(at.getMonth() + 1).padStart
214
214
  // The ledger's live check, answered from the accounting system's copy in Flow
215
215
  // instead of a call for each job: the copy is complete and was read lately,
216
216
  // the matcher checked the job after the copy last changed, and the copy holds
217
- // no invoice with the job's own invoice number.
218
- export function copyCheck(accounting, organisationId) {
219
- return async (obligation) => {
217
+ // no invoice with the job's own invoice number. Another customer's invoice
218
+ // with the same number is not this job's: when the job's customer is linked to
219
+ // an accounting contact (`db` given), only that contact's invoices count.
220
+ // Send's window for the same client and Reference (xero/drafts.js).
221
+ const SAME_REFERENCE_DAYS = 90;
222
+
223
+ export function copyCheck(accounting, organisationId, { db = null } = {}) {
224
+ const contactOf = async (contactId) => {
225
+ if (!db || !contactId) return null;
226
+ const [link] = await db.select({ externalId: externalLinks.externalId }).from(externalLinks).where(and(
227
+ eq(externalLinks.organisationId, organisationId), eq(externalLinks.system, accounting.id),
228
+ eq(externalLinks.entity, 'contact'), eq(externalLinks.state, 'active'), eq(externalLinks.flowId, contactId),
229
+ ));
230
+ return link?.externalId ?? null;
231
+ };
232
+ return async (obligation, { reference = null } = {}) => {
220
233
  const { complete, fresh, changedAt, invoices } = await accounting.capabilities.findInvoices({
221
234
  organisationId,
222
235
  numbers: obligation.sourceInvoiceRef ? [obligation.sourceInvoiceRef] : [],
236
+ references: reference ? [reference] : [],
223
237
  });
224
238
  if (!complete || !fresh) throw new Error('the accounting copy is not complete or not fresh');
225
239
  if (obligation.accountingState !== 'not_found') return { state: obligation.accountingState, matchedBy: 'copy' };
226
240
  if (changedAt && (!obligation.accountingCheckedAt || new Date(obligation.accountingCheckedAt) < changedAt)) {
227
241
  throw new Error('the accounting copy changed after the job was last matched');
228
242
  }
229
- const sales = invoices.filter((i) => !['voided', 'deleted'].includes(i.state));
243
+ const xeroContactId = await contactOf(obligation.contactId);
244
+ const live = invoices.filter((i) => !['voided', 'deleted'].includes(i.state));
245
+ const sameNumber = (i) => obligation.sourceInvoiceRef && String(i.number ?? '').toUpperCase() === obligation.sourceInvoiceRef.toUpperCase();
246
+ const sales = live.filter((i) => sameNumber(i) && (!xeroContactId || i.contactId === xeroContactId));
230
247
  if (sales.length) return { state: sales[0].state, accountingRef: sales[0].id, matchedBy: 'copy' };
248
+ // The same client and Reference as this draft, within Send's window: Send
249
+ // would stop it (xero/drafts.js), so the draft is not made. A person
250
+ // links the job to that invoice, or says it is another job.
251
+ const day = obligation.jobDate ? Date.parse(`${String(obligation.jobDate).slice(0, 10)}T00:00:00Z`) : Date.now();
252
+ const sameReference = reference && xeroContactId ? live.filter((i) => i.contactId === xeroContactId
253
+ && String(i.reference ?? '').toUpperCase() === reference.toUpperCase()
254
+ && (!i.issuedOn || Math.abs(Date.parse(`${String(i.issuedOn).slice(0, 10)}T00:00:00Z`) - day) <= SAME_REFERENCE_DAYS * 86_400_000)) : [];
255
+ if (sameReference.length) return { state: 'ambiguous', accountingRef: sameReference[0].id, matchedBy: 'reference' };
231
256
  return { state: 'not_found', matchedBy: 'copy' };
232
257
  };
233
258
  }
@@ -269,7 +294,7 @@ export async function autoDraftJobs(db, organisationId, { accounting = null, wor
269
294
  const customers = await customerBillingByContact(db, organisationId, contactIds);
270
295
  const names = await customerNames(db, organisationId, contactIds);
271
296
  const named = (contactId) => ({ ...effectiveSettings(settings, customers.get(contactId)), name: names.get(contactId) ?? null });
272
- const liveCheck = accounting ? copyCheck(accounting, organisationId) : null;
297
+ const liveCheck = accounting ? copyCheck(accounting, organisationId, { db }) : null;
273
298
  const invoiceDate = isoDay(now);
274
299
  let accountingRead = null;
275
300
 
@@ -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, ruleWait } from './invoice-rules.js';
3
+ import { 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,20 @@ 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
+ // A rule with an item to add also checks the job as the work system has it,
51
+ // from the first read: the lab prints from the work system, so it must be
52
+ // right there, not only on Flow's invoice. The card says what to add.
53
+ const addRules = rules.filter((r) => r.action.kind === 'pending' && r.action.addItem);
54
+ const excepted = addRules.length ? await ruleExceptionsFor(db, organisationId, listed.map((e) => e.obligation.id)) : new Map();
55
+ const sourceNeeds = (o) => {
56
+ if (!addRules.length) return [];
57
+ const customer = { ...effectiveSettings(settings, customers.get(o.contactId)), name: nameOf.get(o.contactId) ?? null };
58
+ // The work system's own lines count as having the item, whatever the
59
+ // rule's conditions say, so the badge shows only while the item is missing there.
60
+ const inSource = (o.lines ?? []).flatMap((l) => [l.itemCode, l.label].filter(Boolean));
61
+ return rulePending(addRules, { ...o, ruleExceptions: excepted.get(o.id) ?? [], draftItems: inSource }, { customer })
62
+ .map((p) => ({ rule: p.rule, item: p.addItem, note: p.message, short: p.short }));
63
+ };
50
64
  const ready = new Set(views?.ready ?? []);
51
65
  const toCheck = new Set(views?.toCheck ?? []);
52
66
  const invoiceView = (id) => (!id || !views ? null
@@ -74,6 +88,7 @@ export async function describeObligations(db, organisationId, listed, { accounti
74
88
  invoiceProblems: (o.invoiceId && views?.problems.get(o.invoiceId)) || [],
75
89
  view,
76
90
  reasons,
91
+ sourceNeeds: sourceNeeds(o),
77
92
  ...waiting(o, view),
78
93
  }));
79
94
  }
@@ -101,8 +101,9 @@ export async function listReconcile(db, organisationId) {
101
101
  status: billingObligations.sourceStatus,
102
102
  sourceSystem: billingObligations.sourceSystem,
103
103
  sourceJobRef: billingObligations.sourceJobRef,
104
- // An unclear accounting match: "Make invoice" settles it first.
105
- unclear: sql`${billingObligations.accountingState} = 'ambiguous'`.mapWith(Boolean),
104
+ // An unclear accounting match, or a matched accounting invoice that was
105
+ // voided or deleted there: "Make invoice" settles it first.
106
+ unclear: sql`${billingObligations.accountingState} in ('ambiguous', 'voided', 'deleted') and ${billingObligations.invoiceId} is null`.mapWith(Boolean),
106
107
  possible: sql`(select count(*) from invoices i
107
108
  where i.organisation_id = ${billingObligations.organisationId} and i.contact_id = ${billingObligations.contactId}
108
109
  and i.status <> 'void' and i.total_cents = ${billingObligations.totalCents}
@@ -152,8 +153,9 @@ export async function jobMatchDetail(db, organisationId, obligationId, { referen
152
153
  lines: jobLines(o, codes),
153
154
  totalCents: o.totalCents,
154
155
  open: !o.invoiceId && !o.accountingRef,
155
- // An unclear accounting match: "Make invoice" settles it first.
156
- unclear: o.accountingState === 'ambiguous',
156
+ // An unclear accounting match, or a matched accounting invoice that was
157
+ // voided or deleted there: "Make invoice" settles it first.
158
+ unclear: !o.invoiceId && ['ambiguous', 'voided', 'deleted'].includes(o.accountingState),
157
159
  };
158
160
 
159
161
  const search = String(q ?? '').trim();
@@ -769,7 +769,51 @@ export async function setHold(db, organisationId, obligationId, { holdState, rea
769
769
  const obligation = await loadObligation(tx, organisationId, obligationId, { lock: true });
770
770
  const row = await update(tx, obligation, { holdState, holdReason: holdState === 'none' ? null : reason.trim() });
771
771
  await addEvent(tx, row, holdState === 'none' ? 'released' : holdState, actor, {
772
- from: obligation.holdState, reason: row.holdReason,
772
+ from: obligation.holdState, reason: holdState === 'none' ? (reason?.trim() || null) : row.holdReason,
773
+ });
774
+ return row;
775
+ });
776
+ }
777
+
778
+ const GONE_STATES = ['voided', 'deleted'];
779
+
780
+ // A person (or the assistant, after the lab says yes) frees a job that is
781
+ // stuck on an invoice that bills nothing now: a Flow invoice that was voided,
782
+ // or an accounting invoice that was voided or deleted there. The job lets go
783
+ // of that invoice and goes on hold, so nothing drafts until a person releases
784
+ // it (setHold to 'none'). A Flow invoice that is still live must be voided
785
+ // first: this never voids or changes an invoice.
786
+ export async function freeVoidedJob(db, organisationId, obligationId, { actor, reason, system = null }) {
787
+ requireActor(actor);
788
+ if (!reason?.trim()) throw new FlowError('invalid', 'say why the job is freed');
789
+ return db.transaction(async (tx) => {
790
+ const obligation = await loadObligation(tx, organisationId, obligationId, { lock: true });
791
+ let flowInvoice = null;
792
+ if (obligation.invoiceId) {
793
+ [flowInvoice] = await tx.select({ id: invoices.id, number: invoices.number, status: invoices.status }).from(invoices)
794
+ .where(and(eq(invoices.organisationId, organisationId), eq(invoices.id, obligation.invoiceId)));
795
+ if (flowInvoice && flowInvoice.status !== 'void') {
796
+ throw new FlowError('invalid', `Flow invoice ${flowInvoice.number ?? flowInvoice.id} is ${flowInvoice.status}: void it first, then free the job`);
797
+ }
798
+ }
799
+ const gone = GONE_STATES.includes(obligation.accountingState) ? obligation.accountingRef : null;
800
+ if (!flowInvoice && !gone) throw new FlowError('invalid', 'the job is not linked to a void or deleted invoice');
801
+ const row = await update(tx, obligation, {
802
+ ...(flowInvoice ? { invoiceId: null, draftBasis: null } : {}),
803
+ ...(gone || GONE_STATES.includes(obligation.accountingState)
804
+ ? { accountingState: 'not_found', accountingRef: null, accountingUrl: null, accountingMatchedBy: null, accountingCheckedAt: sql`now()` }
805
+ : {}),
806
+ holdState: 'held',
807
+ holdReason: reason.trim(),
808
+ });
809
+ if (gone && system) {
810
+ await tx.insert(linkRejections).values({ organisationId, obligationId: obligation.id, system, externalId: gone, actor }).onConflictDoNothing();
811
+ }
812
+ await addEvent(tx, row, 'freed', actor, {
813
+ reason: reason.trim(),
814
+ ...(flowInvoice ? { invoiceId: flowInvoice.id } : {}),
815
+ ...(gone ? { released: gone } : {}),
816
+ from: obligation.holdState,
773
817
  });
774
818
  return row;
775
819
  });
@@ -777,9 +821,10 @@ export async function setHold(db, organisationId, obligationId, { holdState, rea
777
821
 
778
822
  // A job with no invoice of its own whose accounting evidence is ambiguous:
779
823
  // the matcher found accounting invoices that may be this job's, but none
780
- // exactly. A person settles it on the Reconcile page: "Make invoice", or a
781
- // link to a Flow invoice (linkJobToInvoice).
782
- const unlinked = (o) => o.accountingState === 'ambiguous' && !o.invoiceId && !o.accountingRef;
824
+ // exactly. Or the accounting invoice the matcher linked was voided or deleted
825
+ // there: it bills nothing now. A person settles it on the Reconcile page or
826
+ // the job: "Make invoice", or a link to a Flow invoice (linkJobToInvoice).
827
+ const unlinked = (o) => !o.invoiceId && (GONE_STATES.includes(o.accountingState) || (o.accountingState === 'ambiguous' && !o.accountingRef));
783
828
 
784
829
  // The matcher's open suggestions for one job, best first.
785
830
  export async function linkSuggestionsFor(db, organisationId, obligationId) {
@@ -814,14 +859,26 @@ async function rejectSuggestions(tx, obligation, { actor }) {
814
859
  // "Make invoice": a person says the job has no invoice yet. Every suggestion
815
860
  // is rejected and the job is recorded as not found, so it can draft. The
816
861
  // live check before the draft still runs (draftInvoiceForObligation).
817
- export async function confirmNoInvoice(db, organisationId, obligationId, { actor }) {
862
+ // `system` is the accounting system's id: a released voided or deleted link
863
+ // becomes that person's "not this one" too, so the matcher never offers it.
864
+ export async function confirmNoInvoice(db, organisationId, obligationId, { actor, system = null }) {
818
865
  requireActor(actor);
819
866
  return db.transaction(async (tx) => {
820
867
  const obligation = await loadObligation(tx, organisationId, obligationId, { lock: true });
821
868
  if (!unlinked(obligation)) throw new FlowError('invalid', 'the job is not waiting for a person to settle its invoice');
822
869
  const rejected = await rejectSuggestions(tx, obligation, { actor });
823
- const row = await update(tx, obligation, { accountingState: 'not_found', accountingCheckedAt: sql`now()` });
824
- await addEvent(tx, row, 'accounting_checked', actor, { reported: 'not_found', state: 'not_found', matchedBy: 'person', rejected });
870
+ // A voided or deleted link is let go, so the matcher stops following it.
871
+ const gone = GONE_STATES.includes(obligation.accountingState) ? obligation.accountingRef : null;
872
+ const row = await update(tx, obligation, {
873
+ accountingState: 'not_found', accountingCheckedAt: sql`now()`,
874
+ ...(gone ? { accountingRef: null, accountingUrl: null, accountingMatchedBy: null } : {}),
875
+ });
876
+ if (gone && system) {
877
+ await tx.insert(linkRejections).values({ organisationId, obligationId: obligation.id, system, externalId: gone, actor }).onConflictDoNothing();
878
+ }
879
+ await addEvent(tx, row, 'accounting_checked', actor, {
880
+ reported: 'not_found', state: 'not_found', matchedBy: 'person', rejected, ...(gone ? { released: gone } : {}),
881
+ });
825
882
  return row;
826
883
  });
827
884
  }
@@ -913,7 +970,9 @@ export async function draftInvoiceForObligation(db, organisationId, obligationId
913
970
  let live = null;
914
971
  if (!reasons.length && accounting) {
915
972
  try {
916
- live = await liveCheck(obligation);
973
+ // The draft's own Reference too: Send stops the same client and
974
+ // Reference in Xero, so the draft must not be made either.
975
+ live = await liveCheck(obligation, { reference });
917
976
  } catch (error) {
918
977
  reasons.push(`the live accounting check failed: ${error.message}`);
919
978
  }
@@ -342,6 +342,10 @@ export function rankCandidates(obligation, invoices, { prior = null, rivals = []
342
342
 
343
343
  const amountsAgree = (obligation, invoice) => totalsFor(obligation).has(invoice.totalCents);
344
344
  const withoutReissue = (ref) => String(ref).replace(/R$/i, '');
345
+ // A voided or deleted accounting invoice bills nothing: it never links a job
346
+ // by itself. A lab that reissued work voids the first invoice, and that one
347
+ // keeps the work system's number.
348
+ const live = (invoice) => !['voided', 'deleted'].includes(invoice.state);
345
349
 
346
350
  async function saveSuggestions(db, organisationId, obligationId, system, ranked) {
347
351
  const keep = ranked.slice(0, 5);
@@ -392,14 +396,14 @@ export async function matchJob(db, organisationId, obligation, {
392
396
  });
393
397
  if (prior) {
394
398
  const [invoice] = await find({ invoiceIds: [prior.externalId] });
395
- if (invoice && !rejected.has(invoice.id) && (prior.matchedBy === 'person' || amountsAgree(obligation, invoice))) {
399
+ if (invoice && live(invoice) && !rejected.has(invoice.id) && (prior.matchedBy === 'person' || amountsAgree(obligation, invoice))) {
396
400
  return exact(invoice, 'prior_link');
397
401
  }
398
402
  }
399
403
  if (obligation.sourceInvoiceRef) {
400
404
  const numbers = [obligation.sourceInvoiceRef, withoutReissue(obligation.sourceInvoiceRef)];
401
405
  const same = (await find({ numbers }))
402
- .filter((i) => !rejected.has(i.id) && (!xeroContactId || i.contactId === xeroContactId));
406
+ .filter((i) => live(i) && !rejected.has(i.id) && (!xeroContactId || i.contactId === xeroContactId));
403
407
  if (same.length === 1) return exact(same[0], 'source_invoice_ref');
404
408
  }
405
409
  // Without the customer's accounting contact there is nothing to compare.
@@ -30,6 +30,9 @@ export const RECONCILE_REVIEW_KIND = 'reconcile.day';
30
30
  export const ACTOR = 'system:reconcile';
31
31
  export const RECONCILE_DAYS = 60;
32
32
  export const EVIDENCE_IDS = 20;
33
+ // A job ready this long with no invoice in Flow or the accounting system,
34
+ // and no hold, is a difference: something stops it that nobody sees.
35
+ export const READY_GRACE_DAYS = 3;
33
36
  const RUN_EVERY_MS = 24 * 60 * 60 * 1000;
34
37
  const RECHECK_MS = 60 * 60 * 1000;
35
38
  const TICK_MS = 10 * 60 * 1000;
@@ -55,7 +58,7 @@ function emptyDay(day) {
55
58
  flowInvoices: { count: 0, totalCents: 0 },
56
59
  accounting: { linked: 0, found: 0, totalCents: 0 },
57
60
  differences: {
58
- missingInLedger: [], missingInWork: [], missingInAccounting: [], accountingTotalDiffers: [],
61
+ missingInLedger: [], missingInWork: [], missingInAccounting: [], accountingTotalDiffers: [], readyNotInvoiced: [],
59
62
  },
60
63
  };
61
64
  }
@@ -76,7 +79,7 @@ const moneyAtRisk = (entry) => Object.values(entry.differences).flat()
76
79
  .reduce((cents, d) => cents + Math.abs(d.cents ?? 0), 0);
77
80
 
78
81
  // One pass over Flow's own tables. Answers one entry per day, oldest first.
79
- export async function reconcileDays(db, organisationId, { integrations, from, to }) {
82
+ export async function reconcileDays(db, organisationId, { integrations, from, to, now = Date.now() }) {
80
83
  const byDay = new Map();
81
84
  const dayOf = (day) => {
82
85
  if (!byDay.has(day)) byDay.set(day, emptyDay(day));
@@ -110,6 +113,8 @@ export async function reconcileDays(db, organisationId, { integrations, from, to
110
113
  accountingState: billingObligations.accountingState,
111
114
  accountingRef: billingObligations.accountingRef,
112
115
  invoiceId: billingObligations.invoiceId,
116
+ readyAt: billingObligations.readyAt,
117
+ holdState: billingObligations.holdState,
113
118
  invoiceStatus: invoices.status,
114
119
  invoiceTotalCents: invoices.totalCents,
115
120
  })
@@ -148,6 +153,9 @@ export async function reconcileDays(db, organisationId, { integrations, from, to
148
153
  if (o.invoiceId) {
149
154
  entry.flowInvoices.count += 1;
150
155
  entry.flowInvoices.totalCents += o.invoiceTotalCents ?? 0;
156
+ } else if (!o.accountingRef && o.holdState === 'none' && o.readyAt
157
+ && new Date(o.readyAt).getTime() < now - READY_GRACE_DAYS * 86_400_000) {
158
+ entry.differences.readyNotInvoiced.push({ id: o.id, cents: o.totalCents });
151
159
  }
152
160
  // Only a work system that is on can say a job is gone from its copy.
153
161
  if (reported.has(o.sourceSystem)) {
@@ -217,7 +225,7 @@ export async function runReconcile(db, organisationId, {
217
225
  const open = await openDayItems(db, organisationId);
218
226
  // An open item older than the window is checked too, so it can close.
219
227
  const oldest = [...open.keys()].reduce((min, day) => (day < min ? day : min), isoDay(now - (days - 1) * 86_400_000));
220
- const { systems, days: entries } = await reconcileDays(db, organisationId, { integrations, from: oldest, to });
228
+ const { systems, days: entries } = await reconcileDays(db, organisationId, { integrations, from: oldest, to, now });
221
229
  const differing = entries.filter((e) => differenceCount(e) > 0);
222
230
  const recheck = new Set(unconfirmed);
223
231
  const firstSeen = [];