@arcforgelabs/flow 2026.11.3 → 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.
- package/SECURITY.md +2 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/agents/gate.js +25 -5
- package/server/api/invoicing.js +68 -1
- package/server/app.js +3 -2
- package/server/auth/session.js +13 -7
- package/server/auth/settings.js +18 -7
- package/server/core/alerts.js +38 -3
- package/server/core/automations.js +8 -3
- package/server/core/invoice-rules.js +158 -8
- package/server/core/invoices.js +9 -1
- package/server/core/invoicing-rules.js +31 -6
- package/server/core/job-board.js +16 -1
- package/server/core/ledger.js +3 -1
- package/server/core/reconcile.js +11 -3
- package/server/core/sending.js +14 -3
- package/server/db/migrations/0027_high_doctor_strange.sql +24 -0
- package/server/db/migrations/0028_sharp_power_pack.sql +2 -0
- package/server/db/migrations/meta/0027_snapshot.json +5050 -0
- package/server/db/migrations/meta/0028_snapshot.json +5062 -0
- package/server/db/migrations/meta/_journal.json +14 -0
- package/server/db/schema.js +21 -0
- package/server/integrations/seazona/feed.js +10 -3
- package/server/integrations/xero/drafts.js +1 -1
- package/server/integrations/xero/index.js +1 -1
- package/server/integrations/xero/ledger.js +12 -7
- package/server/integrations/xero/links.js +39 -1
- package/server/integrations/xero/lookup.js +3 -1
- package/server/integrations/xero/routes.js +18 -1
- package/server/ui/flow.css +1 -0
- package/server/ui/invoice-rules.js +11 -3
- package/server/ui/invoicing.js +73 -5
- package/server/ui/jobs.js +14 -2
- package/skills/flow/SKILL.md +71 -2
- package/skills/flow/flow.sh +32 -1
package/server/core/invoices.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
219
|
-
|
|
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
|
|
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
|
|
package/server/core/job-board.js
CHANGED
|
@@ -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
|
}
|
package/server/core/ledger.js
CHANGED
|
@@ -970,7 +970,9 @@ export async function draftInvoiceForObligation(db, organisationId, obligationId
|
|
|
970
970
|
let live = null;
|
|
971
971
|
if (!reasons.length && accounting) {
|
|
972
972
|
try {
|
|
973
|
-
|
|
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 });
|
|
974
976
|
} catch (error) {
|
|
975
977
|
reasons.push(`the live accounting check failed: ${error.message}`);
|
|
976
978
|
}
|
package/server/core/reconcile.js
CHANGED
|
@@ -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 = [];
|
package/server/core/sending.js
CHANGED
|
@@ -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 = {
|
|
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");
|