@arcforgelabs/flow 2026.11.3 → 2026.11.5

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 (43) hide show
  1. package/SECURITY.md +2 -1
  2. package/npm-shrinkwrap.json +2 -2
  3. package/package.json +1 -1
  4. package/server/agents/gate.js +25 -5
  5. package/server/api/invoicing.js +68 -1
  6. package/server/app.js +3 -2
  7. package/server/auth/session.js +13 -7
  8. package/server/auth/settings.js +18 -7
  9. package/server/core/alerts.js +38 -3
  10. package/server/core/automations.js +8 -3
  11. package/server/core/invoice-rules.js +158 -8
  12. package/server/core/invoices.js +9 -1
  13. package/server/core/invoicing-rules.js +31 -6
  14. package/server/core/job-board.js +16 -1
  15. package/server/core/ledger.js +11 -2
  16. package/server/core/reconcile.js +11 -3
  17. package/server/core/sending.js +14 -3
  18. package/server/db/migrations/0027_high_doctor_strange.sql +24 -0
  19. package/server/db/migrations/0028_sharp_power_pack.sql +2 -0
  20. package/server/db/migrations/meta/0027_snapshot.json +5050 -0
  21. package/server/db/migrations/meta/0028_snapshot.json +5062 -0
  22. package/server/db/migrations/meta/_journal.json +14 -0
  23. package/server/db/schema.js +21 -0
  24. package/server/integrations/seazona/feed.js +10 -3
  25. package/server/integrations/xero/drafts.js +1 -1
  26. package/server/integrations/xero/index.js +1 -1
  27. package/server/integrations/xero/ledger.js +12 -7
  28. package/server/integrations/xero/links.js +39 -1
  29. package/server/integrations/xero/lookup.js +3 -1
  30. package/server/integrations/xero/routes.js +18 -1
  31. package/server/ui/app.js +2 -2
  32. package/server/ui/flow.css +38 -62
  33. package/server/ui/home.js +38 -47
  34. package/server/ui/invoice-rules.js +11 -3
  35. package/server/ui/invoicing.js +79 -7
  36. package/server/ui/job-change.js +15 -9
  37. package/server/ui/jobs.js +14 -2
  38. package/server/ui/kit.js +21 -0
  39. package/server/ui/mobile.js +18 -0
  40. package/server/ui/problem-prompt.js +79 -0
  41. package/server/ui/routes.js +1 -1
  42. package/skills/flow/SKILL.md +79 -2
  43. package/skills/flow/flow.sh +32 -1
@@ -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
  }
@@ -149,7 +149,14 @@ export function sourceBillingProblems(obligation) {
149
149
  const lines = obligation.lines ?? [];
150
150
  if (lines.some((line) => line.taxCode === null)) reasons.push('a source line has unknown tax treatment');
151
151
  const calculated = invoiceTotals(lines);
152
- if (SOURCE_TOTAL_FIELDS.some((field) => obligation.sourceTotals?.[field] != null && obligation.sourceTotals[field] !== calculated[field])) {
152
+ const source = obligation.sourceTotals ?? {};
153
+ // When the source total and GST both match Flow's lines, Flow's subtotal is
154
+ // their difference, so a different source subtotal (Seazona can send 0) is a
155
+ // fault in that one source field, not a wrong invoice. It stays in evidence.
156
+ const totalAndTaxMatch = source.totalCents != null && source.taxCents != null
157
+ && source.totalCents === calculated.totalCents && source.taxCents === calculated.taxCents;
158
+ const compared = totalAndTaxMatch ? ['taxCents', 'totalCents'] : SOURCE_TOTAL_FIELDS;
159
+ if (compared.some((field) => source[field] != null && source[field] !== calculated[field])) {
153
160
  reasons.push('the source invoice totals differ from the reconstructed lines');
154
161
  }
155
162
  return reasons;
@@ -970,7 +977,9 @@ export async function draftInvoiceForObligation(db, organisationId, obligationId
970
977
  let live = null;
971
978
  if (!reasons.length && accounting) {
972
979
  try {
973
- live = await liveCheck(obligation);
980
+ // The draft's own Reference too: Send stops the same client and
981
+ // Reference in Xero, so the draft must not be made either.
982
+ live = await liveCheck(obligation, { reference });
974
983
  } catch (error) {
975
984
  reasons.push(`the live accounting check failed: ${error.message}`);
976
985
  }
@@ -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 = [];
@@ -1,8 +1,8 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm';
3
- import { billingObligations, contacts, invoiceLines, invoices, people } from '../db/schema.js';
3
+ import { billingObligations, contacts, invoiceLines, invoices, people, priceItems } from '../db/schema.js';
4
4
  import { FlowError } from './errors.js';
5
- import { listRules, ruleNonChargeable, rulePending, ruleSkip } from './invoice-rules.js';
5
+ import { 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';
@@ -111,9 +111,20 @@ async function checkDrafts(db, organisationId, drafts, accounting) {
111
111
  if (rules.length && jobs.length) {
112
112
  const settings = await readInvoicingSettings(db, organisationId);
113
113
  const customers = await customerBillingByContact(db, organisationId, jobs.map((j) => j.obligation.contactId));
114
+ const exceptions = await ruleExceptionsFor(db, organisationId, jobs.map((j) => j.obligation.id));
115
+ // A rule's item counts only on a charged line: an item edited to $0
116
+ // does not satisfy the rule; Skip, with a reason, is the way to say so.
117
+ const itemRows = await db.select({ invoiceId: invoiceLines.invoiceId, code: priceItems.code }).from(invoiceLines)
118
+ .innerJoin(priceItems, eq(priceItems.id, invoiceLines.priceItemId))
119
+ .where(and(inArray(invoiceLines.invoiceId, jobs.map((j) => j.obligation.invoiceId)), sql`${invoiceLines.unitAmountCents} * ${invoiceLines.quantity} > 0`));
120
+ const itemsOf = Map.groupBy(itemRows, (r) => r.invoiceId);
114
121
  for (const { obligation, reference, customerName } of jobs) {
115
122
  const customer = { ...effectiveSettings(settings, customers.get(obligation.contactId)), name: customerName ?? null };
116
- const job = { ...obligation, reference };
123
+ const job = {
124
+ ...obligation, reference,
125
+ ruleExceptions: exceptions.get(obligation.id) ?? [],
126
+ draftItems: (itemsOf.get(obligation.invoiceId) ?? []).map((r) => r.code),
127
+ };
117
128
  const found = [...ruleNonChargeable(rules, job, { customer }), ...rulePending(rules, job, { customer }), ...ruleSkip(rules, job, { customer })];
118
129
  if (found.length) problems.set(obligation.invoiceId, [...(problems.get(obligation.invoiceId) ?? []), ...found]);
119
130
  }
@@ -0,0 +1,24 @@
1
+ CREATE TABLE IF NOT EXISTS "invoice_rule_exceptions" (
2
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
3
+ "organisation_id" uuid NOT NULL,
4
+ "rule_id" uuid NOT NULL,
5
+ "obligation_id" uuid NOT NULL,
6
+ "reason" text NOT NULL,
7
+ "actor" text NOT NULL,
8
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
9
+ CONSTRAINT "invoice_rule_exceptions_reason_valid" CHECK (length("invoice_rule_exceptions"."reason") between 1 and 200)
10
+ );
11
+ --> statement-breakpoint
12
+ DO $flow_guard$ BEGIN
13
+ ALTER TABLE "invoice_rule_exceptions" ADD CONSTRAINT "invoice_rule_exceptions_organisation_id_organisations_id_fk" FOREIGN KEY ("organisation_id") REFERENCES "flow"."organisations"("id") ON DELETE no action ON UPDATE no action;
14
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
15
+ END $flow_guard$;--> statement-breakpoint
16
+ DO $flow_guard$ BEGIN
17
+ ALTER TABLE "invoice_rule_exceptions" ADD CONSTRAINT "invoice_rule_exceptions_rule_id_invoice_rules_id_fk" FOREIGN KEY ("rule_id") REFERENCES "flow"."invoice_rules"("id") ON DELETE cascade ON UPDATE no action;
18
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
19
+ END $flow_guard$;--> statement-breakpoint
20
+ DO $flow_guard$ BEGIN
21
+ ALTER TABLE "invoice_rule_exceptions" ADD CONSTRAINT "invoice_rule_exceptions_obligation_id_billing_obligations_id_fk" FOREIGN KEY ("obligation_id") REFERENCES "flow"."billing_obligations"("id") ON DELETE no action ON UPDATE no action;
22
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
23
+ END $flow_guard$;--> statement-breakpoint
24
+ CREATE UNIQUE INDEX IF NOT EXISTS "invoice_rule_exceptions_rule_job" ON "invoice_rule_exceptions" USING btree ("rule_id","obligation_id");
@@ -0,0 +1,2 @@
1
+ ALTER TABLE "invoice_rule_exceptions" ADD COLUMN IF NOT EXISTS "item_code" text;--> statement-breakpoint
2
+ ALTER TABLE "invoice_rule_exceptions" ADD COLUMN IF NOT EXISTS "line_fingerprint" text;