@arcforgelabs/flow 2026.11.4 → 2026.11.6
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/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/api/invoicing.js +5 -2
- package/server/core/automations.js +4 -1
- package/server/core/invoice-rules.js +64 -15
- package/server/core/invoicing-rules.js +50 -6
- package/server/core/job-board.js +3 -2
- package/server/core/ledger.js +18 -3
- package/server/core/pipeline.js +5 -2
- package/server/core/price-list.js +41 -12
- package/server/core/sending.js +3 -2
- package/server/db/migrations/0029_job_remake.sql +1 -0
- package/server/db/migrations/meta/0029_snapshot.json +5069 -0
- package/server/db/migrations/meta/_journal.json +7 -0
- package/server/db/schema.js +3 -0
- package/server/integrations/port.js +1 -0
- package/server/integrations/seazona/feed.js +1 -0
- package/server/integrations/seazona/history/index.js +5 -2
- package/server/integrations/seazona/lanes.js +3 -0
- package/server/integrations/seazona/ledger.js +1 -0
- package/server/integrations/seazona/map.js +2 -0
- package/server/integrations/seazona/migrations/0008_order_remake.sql +1 -0
- package/server/integrations/seazona/migrations/meta/0008_snapshot.json +1526 -0
- package/server/integrations/seazona/migrations/meta/_journal.json +7 -0
- package/server/integrations/seazona/schema.js +2 -0
- package/server/integrations/seazona/store.js +14 -0
- package/server/integrations/seazona/sweep.js +3 -0
- package/server/integrations/sentry/index.js +9 -4
- package/server/integrations/xero/catalogue.js +40 -0
- package/server/integrations/xero/drafts.js +16 -5
- package/server/integrations/xero/index.js +10 -0
- package/server/integrations/xero/lookup.js +11 -4
- package/server/integrations/xero/settings.js +5 -0
- package/server/ui/app.js +2 -2
- package/server/ui/flow.css +37 -62
- package/server/ui/home.js +38 -47
- package/server/ui/invoicing.js +6 -2
- package/server/ui/job-change.js +15 -9
- package/server/ui/kit.js +21 -0
- package/server/ui/mobile.js +18 -0
- package/server/ui/monitoring.js +63 -3
- package/server/ui/price-list.js +5 -2
- package/server/ui/problem-prompt.js +79 -0
- package/server/ui/routes.js +1 -1
- package/skills/flow/SKILL.md +39 -1
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcforgelabs/flow",
|
|
3
|
-
"version": "2026.11.
|
|
3
|
+
"version": "2026.11.6",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@arcforgelabs/flow",
|
|
9
|
-
"version": "2026.11.
|
|
9
|
+
"version": "2026.11.6",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@clerk/backend": "3.25.0",
|
package/package.json
CHANGED
package/server/api/invoicing.js
CHANGED
|
@@ -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
|
-
|
|
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) => {
|
|
@@ -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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
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
|
-
|
|
224
|
-
|
|
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,
|
|
243
|
-
const context =
|
|
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,
|
|
261
|
-
const context =
|
|
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,
|
|
279
|
-
const context =
|
|
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 {
|
|
7
|
-
import {
|
|
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
|
-
|
|
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
|
}
|
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, 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),
|
package/server/core/ledger.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
|
@@ -190,6 +197,8 @@ export async function observeJob(db, organisationId, job) {
|
|
|
190
197
|
practitioner: job.practitioner ?? null,
|
|
191
198
|
jobDate: job.jobDate ?? null,
|
|
192
199
|
sourceStatus: job.sourceStatus ?? null,
|
|
200
|
+
// Seazona's remake flag, for the invoice rule field `remake`.
|
|
201
|
+
remake: job.remake === true,
|
|
193
202
|
lines,
|
|
194
203
|
lineFingerprint: fingerprint(lines),
|
|
195
204
|
sourceTotals,
|
|
@@ -230,7 +239,7 @@ export async function observeJob(db, organisationId, job) {
|
|
|
230
239
|
: String(existing[key] ?? '') !== String(facts[key] ?? ''));
|
|
231
240
|
if (!changed.length) {
|
|
232
241
|
const row = await update(tx, existing, {
|
|
233
|
-
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,
|
|
234
243
|
});
|
|
235
244
|
await statusChanged(tx, organisationId, existing, row, actor);
|
|
236
245
|
return recordSourceReview(tx, row, actor);
|
|
@@ -900,10 +909,16 @@ export async function linkJobToInvoice(db, organisationId, obligationId, { invoi
|
|
|
900
909
|
if (!invoice) throw notFound('invoice');
|
|
901
910
|
if (invoice.status === 'void') throw new FlowError('invalid', 'the invoice is void');
|
|
902
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);
|
|
903
917
|
const row = await update(tx, obligation, {
|
|
904
918
|
invoiceId: invoice.id,
|
|
905
919
|
draftBasis: basisOf(obligation),
|
|
906
|
-
...(
|
|
920
|
+
...(settled ? { accountingState: 'unknown', accountingCheckedAt: null } : {}),
|
|
921
|
+
...(accountingRef && !obligation.accountingRef ? { accountingRef } : {}),
|
|
907
922
|
...(obligation.readyAt ? {} : { readyAt: sql`now()`, readyBy: actor }),
|
|
908
923
|
});
|
|
909
924
|
await addEvent(tx, row, 'invoice_linked', actor, { invoiceId: invoice.id, matchedBy: 'person', rejected });
|
package/server/core/pipeline.js
CHANGED
|
@@ -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
|
-
|
|
435
|
-
|
|
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
|
|
123
|
-
//
|
|
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))
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
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
|
})),
|
package/server/core/sending.js
CHANGED
|
@@ -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 @@
|
|
|
1
|
+
ALTER TABLE "billing_obligations" ADD COLUMN IF NOT EXISTS "remake" boolean DEFAULT false NOT NULL;
|