@arcforgelabs/flow 2026.10.32 → 2026.10.33
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/README.md +3 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
- package/server/api/invoicing.js +56 -24
- package/server/app.js +4 -0
- package/server/core/invoicing-rules.js +50 -4
- package/server/core/job-board.js +15 -4
- package/server/core/ledger.js +242 -6
- package/server/core/pipeline.js +29 -21
- package/server/core/read-before-act.js +96 -0
- package/server/core/reconcile.js +350 -0
- package/server/core/sending.js +44 -6
- package/server/db/migrations/0017_sync_basis.sql +1 -0
- package/server/db/migrations/meta/0017_snapshot.json +3867 -0
- package/server/db/migrations/meta/_journal.json +7 -0
- package/server/db/schema.js +3 -0
- package/server/index.js +5 -0
- package/server/integrations/port.js +3 -0
- package/server/integrations/seazona/act.js +48 -0
- package/server/integrations/seazona/feed.js +70 -50
- package/server/integrations/seazona/governor.js +28 -2
- package/server/integrations/seazona/index.js +60 -3
- package/server/integrations/seazona/lanes.js +81 -8
- package/server/integrations/seazona/ledger.js +14 -9
- package/server/integrations/seazona/migrations/0005_record_reread.sql +1 -0
- package/server/integrations/seazona/migrations/0006_sweep_findings.sql +11 -0
- package/server/integrations/seazona/migrations/meta/0005_snapshot.json +1329 -0
- package/server/integrations/seazona/migrations/meta/0006_snapshot.json +1403 -0
- package/server/integrations/seazona/migrations/meta/_journal.json +14 -0
- package/server/integrations/seazona/reconcile.js +40 -0
- package/server/integrations/seazona/scheduler.js +32 -4
- package/server/integrations/seazona/schema.js +21 -0
- package/server/integrations/seazona/settings.js +12 -0
- package/server/integrations/seazona/store.js +24 -2
- package/server/integrations/seazona/sweep.js +574 -0
- package/server/integrations/seazona/tables.js +1 -0
- package/server/integrations/xero/index.js +4 -0
- package/server/integrations/xero/webhooks.js +35 -0
- package/server/ui/flow.css +9 -0
- package/server/ui/invoicing.js +101 -17
- package/server/ui/job-board.js +2 -1
- package/server/ui/jobs.js +19 -1
package/server/core/pipeline.js
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
import { FlowError } from './errors.js';
|
|
7
7
|
import { listAutomations, runAutomations } from './automations.js';
|
|
8
8
|
import { readInvoicingSettings } from './invoicing-rules.js';
|
|
9
|
-
import { observeJob, recordAccountingEvidence, setHold } from './ledger.js';
|
|
9
|
+
import { observeJob, observeWithdrawn, recordAccountingEvidence, setHold } from './ledger.js';
|
|
10
10
|
import { loadMappingContext, mapLines } from './line-mapping.js';
|
|
11
11
|
import { checkPrices, readCatalogues } from './price-list.js';
|
|
12
12
|
import { linkRecord, priorLinkFor } from './links.js';
|
|
@@ -130,25 +130,38 @@ async function writeCursor(db, organisationId, name, cursor) {
|
|
|
130
130
|
.onConflictDoUpdate({ target: [pipelineCursors.organisationId, pipelineCursors.name], set: { cursor, updatedAt: sql`now()` } });
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
let cursor = restart ? null : await readCursor(db, organisationId, name);
|
|
133
|
+
// Turns a work item into the job the ledger observes: its customer from the
|
|
134
|
+
// identity map, and its lines mapped to price items when "Map lines" is on.
|
|
135
|
+
// `mapped` says whether lines are mapped.
|
|
136
|
+
export async function workJobMaker(db, organisationId, adapter) {
|
|
138
137
|
const links = await activeLinks(db, organisationId, { system: adapter.id, entity: 'contact' });
|
|
139
138
|
const contactOf = new Map(links.map((l) => [l.externalId, l.flowId]));
|
|
140
|
-
const result = { observed: 0, withoutCustomer: 0, skipped: 0, sentToReview: 0, unmappedLines: 0, more: false };
|
|
141
139
|
// Line mapping (line-mapping.js): each line finds its price item first.
|
|
142
140
|
const mapping = (await listAutomations(db, organisationId)).find((a) => a.key === 'map_lines');
|
|
143
141
|
const context = mapping?.enabled ? await loadMappingContext(db, organisationId, mapping.config, { system: adapter.id }) : null;
|
|
142
|
+
const toJob = (item) => {
|
|
143
|
+
const job = context ? { ...item.ledgerJob, lines: mapLines(item.ledgerJob.lines ?? [], context) } : item.ledgerJob;
|
|
144
|
+
return { ...job, contactId: contactOf.get(String(item.clientId ?? '')) ?? null };
|
|
145
|
+
};
|
|
146
|
+
toJob.mapped = Boolean(context);
|
|
147
|
+
return toJob;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Reads the work system's billable jobs since the last run into the ledger.
|
|
151
|
+
// `restart` reads every job again, for example after new customer links.
|
|
152
|
+
export async function ingestWork(db, organisationId, { adapter, restart = false, pages = WORK_PAGES }) {
|
|
153
|
+
const name = `${adapter.id}.work`;
|
|
154
|
+
let cursor = restart ? null : await readCursor(db, organisationId, name);
|
|
155
|
+
const toJob = await workJobMaker(db, organisationId, adapter);
|
|
156
|
+
const result = { observed: 0, withoutCustomer: 0, skipped: 0, sentToReview: 0, readyCleared: 0, unmappedLines: 0, more: false };
|
|
144
157
|
for (let page = 0; page < pages; page += 1) {
|
|
145
158
|
const { items, withdrawn, cursor: next, more } = await adapter.capabilities.pullWorkItems(cursor, { limit: 200 });
|
|
146
159
|
for (const item of items) {
|
|
147
|
-
const
|
|
148
|
-
const
|
|
149
|
-
if (
|
|
160
|
+
const job = toJob(item);
|
|
161
|
+
const { contactId } = job;
|
|
162
|
+
if (toJob.mapped) result.unmappedLines += job.lines.filter((l) => !l.itemCode && !l.accountCode).length;
|
|
150
163
|
try {
|
|
151
|
-
await observeJob(db, organisationId,
|
|
164
|
+
await observeJob(db, organisationId, job);
|
|
152
165
|
result.observed += 1;
|
|
153
166
|
if (!contactId) result.withoutCustomer += 1;
|
|
154
167
|
} catch (error) {
|
|
@@ -157,17 +170,12 @@ export async function ingestWork(db, organisationId, { adapter, restart = false,
|
|
|
157
170
|
result.skipped += 1;
|
|
158
171
|
}
|
|
159
172
|
}
|
|
173
|
+
// A job no longer billable: deleted, or its status moved back (ledger.js).
|
|
160
174
|
for (const entry of withdrawn) {
|
|
161
|
-
if (!entry.
|
|
162
|
-
const {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
eq(billingObligations.sourceSystem, sourceSystem),
|
|
166
|
-
eq(billingObligations.sourceJobRef, sourceJobRef),
|
|
167
|
-
));
|
|
168
|
-
if (!obligation || obligation.holdState === holdState) continue;
|
|
169
|
-
await setHold(db, organisationId, obligation.id, { holdState, reason, actor: `system:${adapter.id}` });
|
|
170
|
-
result.sentToReview += 1;
|
|
175
|
+
if (!entry.ledgerWithdrawn) continue;
|
|
176
|
+
const { action } = await observeWithdrawn(db, organisationId, { ...entry.ledgerWithdrawn, actor: `system:${adapter.id}` });
|
|
177
|
+
if (action === 'review') result.sentToReview += 1;
|
|
178
|
+
if (action === 'ready_cleared') result.readyCleared += 1;
|
|
171
179
|
}
|
|
172
180
|
cursor = next;
|
|
173
181
|
await writeCursor(db, organisationId, name, cursor);
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { and, eq } from 'drizzle-orm';
|
|
2
|
+
import { billingObligations } from '../db/schema.js';
|
|
3
|
+
import { FlowError } from './errors.js';
|
|
4
|
+
import { observeJob, observeWithdrawn } from './ledger.js';
|
|
5
|
+
import { matchJobs, workJobMaker } from './pipeline.js';
|
|
6
|
+
|
|
7
|
+
// Read before act (issue #141). Flow drafts and sends from its own copy of
|
|
8
|
+
// the work system. Just before a draft, and before Send all ready, a job
|
|
9
|
+
// whose work system report is older than READ_BEFORE_ACT_MS is read again
|
|
10
|
+
// from the work system (its readWorkItem capability): one call per job. What
|
|
11
|
+
// the read finds goes into the ledger as any report does (observeJob), so a
|
|
12
|
+
// change after the invoice was made sends the job to review there.
|
|
13
|
+
//
|
|
14
|
+
// A job the work system deleted, or that is no longer billable, goes through
|
|
15
|
+
// the ledger's observeWithdrawn like a feed report: review when it was
|
|
16
|
+
// deleted or already has its invoice. A job that is not billable now, or a
|
|
17
|
+
// read that cannot run, waits: Flow never drafts or sends from an old copy.
|
|
18
|
+
//
|
|
19
|
+
// The lanes re-report each job they read at most once an hour
|
|
20
|
+
// (seazona/feed.js), so most reports are under two hours old and cost no
|
|
21
|
+
// call here.
|
|
22
|
+
|
|
23
|
+
export const READ_BEFORE_ACT_MS = 2 * 60 * 60 * 1000;
|
|
24
|
+
// The most reads one pipeline run or one Send all ready press makes; the
|
|
25
|
+
// other old jobs wait for the next one. Seazona's calls are spaced 2 s apart.
|
|
26
|
+
export const READS_PER_PASS = 20;
|
|
27
|
+
const NO_LONGER_BILLABLE = 'the work system no longer reports the job as billable';
|
|
28
|
+
|
|
29
|
+
// Reads each job that needs it. `work` is the work integration, or null.
|
|
30
|
+
// Answers Map(obligationId -> { outcome, obligation, reason? }), where
|
|
31
|
+
// outcome is fresh (no read needed), read, review (sent to review) or
|
|
32
|
+
// waiting (the read could not run).
|
|
33
|
+
export async function readBeforeAct(db, organisationId, obligations, { work, now = new Date(), actor }) {
|
|
34
|
+
const outcomes = new Map();
|
|
35
|
+
let toJob = null;
|
|
36
|
+
for (const obligation of obligations) {
|
|
37
|
+
if (!needsRead(obligation, { work, now })) {
|
|
38
|
+
outcomes.set(obligation.id, { outcome: 'fresh', obligation });
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const answer = await work.capabilities.readWorkItem({ sourceJobRef: obligation.sourceJobRef });
|
|
42
|
+
if (answer?.state === 'read') {
|
|
43
|
+
toJob ??= await workJobMaker(db, organisationId, work);
|
|
44
|
+
try {
|
|
45
|
+
await observeJob(db, organisationId, toJob(answer.item));
|
|
46
|
+
} catch (error) {
|
|
47
|
+
if (!(error instanceof FlowError) || error.code !== 'invalid') throw error;
|
|
48
|
+
outcomes.set(obligation.id, { outcome: 'waiting', obligation, reason: 'the work system job has no lines' });
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
const fresh = await reload(db, organisationId, obligation.id);
|
|
52
|
+
outcomes.set(obligation.id, { outcome: fresh.holdState === 'review' ? 'review' : 'read', obligation: fresh });
|
|
53
|
+
} else if (answer?.state === 'withdrawn') {
|
|
54
|
+
// The ledger decides as for the feed (observeWithdrawn): deleted, or
|
|
55
|
+
// moved back after its invoice, goes to review; moved back while only
|
|
56
|
+
// ready clears the ready mark.
|
|
57
|
+
if (answer.ledgerWithdrawn) await observeWithdrawn(db, organisationId, { ...answer.ledgerWithdrawn, actor });
|
|
58
|
+
const row = await reload(db, organisationId, obligation.id);
|
|
59
|
+
outcomes.set(obligation.id, row.holdState === 'review'
|
|
60
|
+
? { outcome: 'review', obligation: row, reason: row.holdReason }
|
|
61
|
+
: { outcome: 'waiting', obligation: row, reason: NO_LONGER_BILLABLE });
|
|
62
|
+
} else {
|
|
63
|
+
outcomes.set(obligation.id, { outcome: 'waiting', obligation, reason: answer?.reason || 'the work system read failed' });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return outcomes;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// True when the job's report is old and its work system can read it now.
|
|
70
|
+
export function needsRead(obligation, { work, now = new Date() }) {
|
|
71
|
+
return typeof work?.capabilities?.readWorkItem === 'function'
|
|
72
|
+
&& obligation.sourceSystem === work.id
|
|
73
|
+
&& now - new Date(obligation.sourceSeenAt) > READ_BEFORE_ACT_MS;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The accounting side of read before act (issue #45, step 5): once per
|
|
77
|
+
// automatic draft pass or Send all ready press, before the first draft or
|
|
78
|
+
// send, the accounting system reads its latest changes into its copy (its
|
|
79
|
+
// readChanges capability). When the copy changed, the matcher runs first, so
|
|
80
|
+
// a job someone invoiced in the accounting system meanwhile is linked to that
|
|
81
|
+
// invoice and never drafted or sent twice. Answers { outcome, reason? }:
|
|
82
|
+
// fresh or read (act), or waiting (act on nothing now).
|
|
83
|
+
export async function readAccountingFirst(db, organisationId, { accounting, now = new Date() }) {
|
|
84
|
+
if (typeof accounting?.capabilities?.readChanges !== 'function') return { outcome: 'fresh' };
|
|
85
|
+
const answer = await accounting.capabilities.readChanges({ organisationId });
|
|
86
|
+
if (answer?.state === 'fresh') return { outcome: 'fresh' };
|
|
87
|
+
if (answer?.state !== 'read') return { outcome: 'waiting', reason: answer?.reason || 'the accounting system read failed' };
|
|
88
|
+
if (answer.changed) await matchJobs(db, organisationId, { accounting, now });
|
|
89
|
+
return { outcome: 'read', changed: answer.changed ?? 0 };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function reload(db, organisationId, id) {
|
|
93
|
+
const [row] = await db.select().from(billingObligations)
|
|
94
|
+
.where(and(eq(billingObligations.organisationId, organisationId), eq(billingObligations.id, id)));
|
|
95
|
+
return row;
|
|
96
|
+
}
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { and, desc, eq, gte, inArray, lte, sql } from 'drizzle-orm';
|
|
3
|
+
import { backgroundJobs, billingObligations, invoices, reviewItems } from '../db/schema.js';
|
|
4
|
+
import { HISTORY_ACTOR } from './history.js';
|
|
5
|
+
import { soleOrganisation } from './pipeline.js';
|
|
6
|
+
import { deadHoldSince, deadSince, enqueueJob, runNextJob } from './queue.js';
|
|
7
|
+
import { openReview, resolveReviewFor } from './review.js';
|
|
8
|
+
|
|
9
|
+
// The daily reconcile (issue 129, docs/RECONCILE.md). Flow checks each record
|
|
10
|
+
// as it moves; once a day it also checks the whole. For each of the last
|
|
11
|
+
// `days` days it counts and totals the jobs in three places, from Flow's own
|
|
12
|
+
// tables only (no call to any other system):
|
|
13
|
+
//
|
|
14
|
+
// 1. the work system's copy (each adapter's `billableJobs`);
|
|
15
|
+
// 2. the billing obligation ledger;
|
|
16
|
+
// 3. the Flow invoices the ledger made, and the accounting system's copy of
|
|
17
|
+
// the invoices linked to them (`findInvoices`).
|
|
18
|
+
//
|
|
19
|
+
// A day where they do not agree opens one review item (`reconcile.day`) with
|
|
20
|
+
// the counts, the totals and up to 20 record ids for each difference. A
|
|
21
|
+
// difference is an alert only: the reconcile never changes a record. The item
|
|
22
|
+
// closes by itself when a later run agrees.
|
|
23
|
+
//
|
|
24
|
+
// A difference first seen is checked again an hour later, and opens an item
|
|
25
|
+
// only when it is still there: a job the pipeline has not reached yet is not
|
|
26
|
+
// a difference.
|
|
27
|
+
|
|
28
|
+
export const RECONCILE_KIND = 'reconcile.daily';
|
|
29
|
+
export const RECONCILE_REVIEW_KIND = 'reconcile.day';
|
|
30
|
+
export const ACTOR = 'system:reconcile';
|
|
31
|
+
export const RECONCILE_DAYS = 60;
|
|
32
|
+
export const EVIDENCE_IDS = 20;
|
|
33
|
+
const RUN_EVERY_MS = 24 * 60 * 60 * 1000;
|
|
34
|
+
const RECHECK_MS = 60 * 60 * 1000;
|
|
35
|
+
const TICK_MS = 10 * 60 * 1000;
|
|
36
|
+
const LEASE_MS = 15 * 60 * 1000;
|
|
37
|
+
const FIND_BATCH = 400;
|
|
38
|
+
// Accounting states where the ledger holds a link to an invoice that exists.
|
|
39
|
+
const LINKED = ['drafted', 'authorised', 'part_paid', 'paid'];
|
|
40
|
+
|
|
41
|
+
const isoDay = (ms) => new Date(ms).toISOString().slice(0, 10);
|
|
42
|
+
const withGst = (cents) => Math.round(cents * 1.1);
|
|
43
|
+
|
|
44
|
+
// A stable id for one day's review item, so a repeat finding refreshes it.
|
|
45
|
+
export function dayReviewId(organisationId, day) {
|
|
46
|
+
const hex = createHash('sha256').update(`${RECONCILE_REVIEW_KIND}:${organisationId}:${day}`).digest('hex');
|
|
47
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-5${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function emptyDay(day) {
|
|
51
|
+
return {
|
|
52
|
+
day,
|
|
53
|
+
work: { count: 0, subtotalCents: 0, unpriced: 0 },
|
|
54
|
+
ledger: { count: 0, subtotalCents: 0, totalCents: 0 },
|
|
55
|
+
flowInvoices: { count: 0, totalCents: 0 },
|
|
56
|
+
accounting: { linked: 0, found: 0, totalCents: 0 },
|
|
57
|
+
differences: {
|
|
58
|
+
missingInLedger: [], missingInWork: [], missingInAccounting: [], accountingTotalDiffers: [],
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const differenceCount = (entry) => Object.values(entry.differences).reduce((n, list) => n + list.length, 0);
|
|
64
|
+
|
|
65
|
+
// The evidence a review item keeps: the counts, the totals, and up to
|
|
66
|
+
// EVIDENCE_IDS ids for each difference with the full count beside them.
|
|
67
|
+
function evidenceFor(entry) {
|
|
68
|
+
const differences = {};
|
|
69
|
+
for (const [key, list] of Object.entries(entry.differences)) {
|
|
70
|
+
if (list.length) differences[key] = { count: list.length, ids: list.slice(0, EVIDENCE_IDS).map((d) => d.id) };
|
|
71
|
+
}
|
|
72
|
+
return { ...entry, differences };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const moneyAtRisk = (entry) => Object.values(entry.differences).flat()
|
|
76
|
+
.reduce((cents, d) => cents + Math.abs(d.cents ?? 0), 0);
|
|
77
|
+
|
|
78
|
+
// One pass over Flow's own tables. Answers one entry per day, oldest first.
|
|
79
|
+
export async function reconcileDays(db, organisationId, { integrations, from, to }) {
|
|
80
|
+
const byDay = new Map();
|
|
81
|
+
const dayOf = (day) => {
|
|
82
|
+
if (!byDay.has(day)) byDay.set(day, emptyDay(day));
|
|
83
|
+
return byDay.get(day);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// 1. The work systems' copies.
|
|
87
|
+
const workAdapters = integrations?.with('billableJobs') ?? [];
|
|
88
|
+
const work = new Map(); // `${system}:${ref}` -> job
|
|
89
|
+
for (const adapter of workAdapters) {
|
|
90
|
+
const { jobs } = await adapter.capabilities.billableJobs({ organisationId, from, to });
|
|
91
|
+
for (const job of jobs) {
|
|
92
|
+
if (!job.day || job.day < from || job.day > to) continue;
|
|
93
|
+
work.set(`${adapter.id}:${job.ref}`, { ...job, system: adapter.id });
|
|
94
|
+
const entry = dayOf(job.day);
|
|
95
|
+
entry.work.count += 1;
|
|
96
|
+
if (Number.isInteger(job.subtotalCents)) entry.work.subtotalCents += job.subtotalCents;
|
|
97
|
+
else entry.work.unpriced += 1;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 2. The ledger. A job only a history import reported is left out: the
|
|
102
|
+
// import keeps its own reconciliation record (docs/SEAZONA.md).
|
|
103
|
+
const obligations = await db.select({
|
|
104
|
+
id: billingObligations.id,
|
|
105
|
+
sourceSystem: billingObligations.sourceSystem,
|
|
106
|
+
sourceJobRef: billingObligations.sourceJobRef,
|
|
107
|
+
jobDate: billingObligations.jobDate,
|
|
108
|
+
subtotalCents: billingObligations.subtotalCents,
|
|
109
|
+
totalCents: billingObligations.totalCents,
|
|
110
|
+
accountingState: billingObligations.accountingState,
|
|
111
|
+
accountingRef: billingObligations.accountingRef,
|
|
112
|
+
invoiceId: billingObligations.invoiceId,
|
|
113
|
+
invoiceStatus: invoices.status,
|
|
114
|
+
invoiceTotalCents: invoices.totalCents,
|
|
115
|
+
})
|
|
116
|
+
.from(billingObligations)
|
|
117
|
+
.leftJoin(invoices, and(eq(invoices.organisationId, billingObligations.organisationId), eq(invoices.id, billingObligations.invoiceId)))
|
|
118
|
+
.where(and(
|
|
119
|
+
eq(billingObligations.organisationId, organisationId),
|
|
120
|
+
gte(billingObligations.jobDate, from),
|
|
121
|
+
lte(billingObligations.jobDate, to),
|
|
122
|
+
sql`exists (select 1 from obligation_events e
|
|
123
|
+
where e.organisation_id = ${billingObligations.organisationId} and e.obligation_id = ${billingObligations.id}
|
|
124
|
+
and e.kind in ('observed', 'source_changed') and e.actor <> ${HISTORY_ACTOR})`,
|
|
125
|
+
));
|
|
126
|
+
|
|
127
|
+
const reported = new Set(workAdapters.map((a) => a.id));
|
|
128
|
+
const inLedger = new Set();
|
|
129
|
+
for (const o of obligations) {
|
|
130
|
+
const entry = dayOf(o.jobDate);
|
|
131
|
+
entry.ledger.count += 1;
|
|
132
|
+
entry.ledger.subtotalCents += o.subtotalCents;
|
|
133
|
+
entry.ledger.totalCents += o.totalCents;
|
|
134
|
+
if (o.invoiceId) {
|
|
135
|
+
entry.flowInvoices.count += 1;
|
|
136
|
+
entry.flowInvoices.totalCents += o.invoiceTotalCents ?? 0;
|
|
137
|
+
}
|
|
138
|
+
// Only a work system that is on can say a job is gone from its copy.
|
|
139
|
+
if (reported.has(o.sourceSystem)) {
|
|
140
|
+
const key = `${o.sourceSystem}:${o.sourceJobRef}`;
|
|
141
|
+
inLedger.add(key);
|
|
142
|
+
if (!work.has(key)) entry.differences.missingInWork.push({ id: o.id, cents: o.totalCents });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
for (const [key, job] of work) {
|
|
146
|
+
if (!inLedger.has(key)) dayOf(job.day).differences.missingInLedger.push({ id: job.ref, cents: job.subtotalCents ?? 0 });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// 3. The accounting system's copy of each linked invoice.
|
|
150
|
+
const [accounting] = integrations?.with('findInvoices') ?? [];
|
|
151
|
+
const linked = obligations.filter((o) => o.accountingRef && LINKED.includes(o.accountingState));
|
|
152
|
+
if (accounting) {
|
|
153
|
+
const copies = new Map();
|
|
154
|
+
for (let i = 0; i < linked.length; i += FIND_BATCH) {
|
|
155
|
+
const invoiceIds = linked.slice(i, i + FIND_BATCH).map((o) => o.accountingRef);
|
|
156
|
+
const { invoices: found } = await accounting.capabilities.findInvoices({ organisationId, invoiceIds });
|
|
157
|
+
for (const invoice of found) copies.set(String(invoice.id), invoice);
|
|
158
|
+
}
|
|
159
|
+
for (const o of linked) {
|
|
160
|
+
const entry = dayOf(o.jobDate);
|
|
161
|
+
entry.accounting.linked += 1;
|
|
162
|
+
const copy = copies.get(String(o.accountingRef));
|
|
163
|
+
if (!copy) {
|
|
164
|
+
entry.differences.missingInAccounting.push({ id: o.id, cents: o.totalCents });
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
entry.accounting.found += 1;
|
|
168
|
+
entry.accounting.totalCents += copy.totalCents ?? 0;
|
|
169
|
+
// An invoice Flow issued must carry Flow's total. One Flow only matched
|
|
170
|
+
// may carry the job's total with or without GST, as the matcher allows.
|
|
171
|
+
const expected = o.invoiceId && o.invoiceStatus === 'issued'
|
|
172
|
+
? [o.invoiceTotalCents]
|
|
173
|
+
: [o.totalCents, o.subtotalCents, withGst(o.subtotalCents)];
|
|
174
|
+
if (copy.totalCents !== null && !expected.includes(copy.totalCents)) {
|
|
175
|
+
entry.differences.accountingTotalDiffers.push({ id: o.id, cents: copy.totalCents - expected[0] });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
systems: { work: workAdapters.map((a) => a.id), accounting: accounting?.id ?? null },
|
|
182
|
+
days: [...byDay.values()].sort((a, b) => a.day.localeCompare(b.day)),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function openDayItems(db, organisationId) {
|
|
187
|
+
const rows = await db.select({ flowId: reviewItems.flowId, evidence: reviewItems.evidence }).from(reviewItems).where(and(
|
|
188
|
+
eq(reviewItems.organisationId, organisationId),
|
|
189
|
+
eq(reviewItems.kind, RECONCILE_REVIEW_KIND),
|
|
190
|
+
eq(reviewItems.state, 'open'),
|
|
191
|
+
));
|
|
192
|
+
return new Map(rows.map((r) => [r.evidence?.day, r.flowId]).filter(([day]) => day));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// One run: reconcile, open or refresh an item for each day that still
|
|
196
|
+
// differs, and close the item of each day that now agrees. `unconfirmed` are
|
|
197
|
+
// the days the last run found different for the first time. Answers the
|
|
198
|
+
// summary the job keeps (GET /health shows it).
|
|
199
|
+
export async function runReconcile(db, organisationId, {
|
|
200
|
+
integrations, now = Date.now(), days = RECONCILE_DAYS, unconfirmed = [],
|
|
201
|
+
}) {
|
|
202
|
+
const to = isoDay(now);
|
|
203
|
+
const open = await openDayItems(db, organisationId);
|
|
204
|
+
// An open item older than the window is checked too, so it can close.
|
|
205
|
+
const oldest = [...open.keys()].reduce((min, day) => (day < min ? day : min), isoDay(now - (days - 1) * 86_400_000));
|
|
206
|
+
const { systems, days: entries } = await reconcileDays(db, organisationId, { integrations, from: oldest, to });
|
|
207
|
+
const differing = entries.filter((e) => differenceCount(e) > 0);
|
|
208
|
+
const recheck = new Set(unconfirmed);
|
|
209
|
+
const firstSeen = [];
|
|
210
|
+
let opened = 0;
|
|
211
|
+
for (const entry of differing) {
|
|
212
|
+
if (!open.has(entry.day) && !recheck.has(entry.day)) {
|
|
213
|
+
firstSeen.push(entry.day);
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
await openReview(db, organisationId, {
|
|
217
|
+
kind: RECONCILE_REVIEW_KIND,
|
|
218
|
+
entity: 'day',
|
|
219
|
+
flowId: dayReviewId(organisationId, entry.day),
|
|
220
|
+
systems: [...systems.work, ...(systems.accounting ? [systems.accounting] : [])],
|
|
221
|
+
evidence: evidenceFor(entry),
|
|
222
|
+
moneyAtRiskCents: moneyAtRisk(entry),
|
|
223
|
+
});
|
|
224
|
+
opened += 1;
|
|
225
|
+
}
|
|
226
|
+
const differs = new Set(differing.map((e) => e.day));
|
|
227
|
+
let closed = 0;
|
|
228
|
+
for (const [day, flowId] of open) {
|
|
229
|
+
if (differs.has(day)) continue;
|
|
230
|
+
await resolveReviewFor(db, organisationId, {
|
|
231
|
+
kind: RECONCILE_REVIEW_KIND, entity: 'day', flowId, resolution: 'a later run agreed', actor: ACTOR,
|
|
232
|
+
});
|
|
233
|
+
closed += 1;
|
|
234
|
+
}
|
|
235
|
+
return {
|
|
236
|
+
from: oldest,
|
|
237
|
+
to,
|
|
238
|
+
systems,
|
|
239
|
+
daysChecked: entries.length,
|
|
240
|
+
daysDifferent: differing.length,
|
|
241
|
+
openItems: (await openDayItems(db, organisationId)).size,
|
|
242
|
+
opened,
|
|
243
|
+
closed,
|
|
244
|
+
unconfirmed: firstSeen,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function lastRun(db, organisationId) {
|
|
249
|
+
const [row] = await db.select().from(backgroundJobs).where(and(
|
|
250
|
+
eq(backgroundJobs.organisationId, organisationId),
|
|
251
|
+
eq(backgroundJobs.kind, RECONCILE_KIND),
|
|
252
|
+
inArray(backgroundJobs.state, ['succeeded', 'failed', 'dead']),
|
|
253
|
+
)).orderBy(desc(backgroundJobs.updatedAt)).limit(1);
|
|
254
|
+
return row ?? null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// The last run and its result, for GET /health: counts only, no record ids.
|
|
258
|
+
export async function reconcileHealth(db, { now = Date.now() } = {}) {
|
|
259
|
+
const organisationId = await soleOrganisation(db);
|
|
260
|
+
if (!organisationId) return null;
|
|
261
|
+
const row = await lastRun(db, organisationId);
|
|
262
|
+
if (!row) return { lastRunAt: null };
|
|
263
|
+
const result = row.state === 'succeeded' ? row.result ?? {} : {};
|
|
264
|
+
return {
|
|
265
|
+
lastRunAt: row.updatedAt.toISOString(),
|
|
266
|
+
state: row.state,
|
|
267
|
+
...(row.state === 'succeeded' ? {
|
|
268
|
+
agrees: result.daysDifferent === 0,
|
|
269
|
+
daysChecked: result.daysChecked ?? null,
|
|
270
|
+
daysDifferent: result.daysDifferent ?? null,
|
|
271
|
+
openItems: result.openItems ?? null,
|
|
272
|
+
late: now - row.updatedAt.getTime() > RUN_EVERY_MS + 2 * TICK_MS + LEASE_MS,
|
|
273
|
+
} : { error: row.lastError }),
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Is a run due? Once a day, and an hour after a run that found a new difference.
|
|
278
|
+
export function reconcileDue(last, nowMs) {
|
|
279
|
+
if (!last) return true;
|
|
280
|
+
const age = nowMs - last.updatedAt.getTime();
|
|
281
|
+
if (last.state !== 'succeeded') return age >= RECHECK_MS;
|
|
282
|
+
if ((last.result?.unconfirmed ?? []).length) return age >= RECHECK_MS;
|
|
283
|
+
return age >= RUN_EVERY_MS;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// The worker: every ten minutes it asks whether a run is due, and runs it on
|
|
287
|
+
// the job queue, so two Flow processes never run it at once. It runs only
|
|
288
|
+
// while an integration that feeds it is on.
|
|
289
|
+
export function createReconcileWorker({
|
|
290
|
+
db, integrations, log = console, timers = { setTimeout, clearTimeout }, now = Date.now, everyMs = TICK_MS, firstDelayMs = 60_000,
|
|
291
|
+
}) {
|
|
292
|
+
const workerId = `reconcile-${randomUUID()}`;
|
|
293
|
+
const startedAtMs = now();
|
|
294
|
+
let timer = null;
|
|
295
|
+
let running = null;
|
|
296
|
+
let stopped = true;
|
|
297
|
+
|
|
298
|
+
function schedule(delayMs) {
|
|
299
|
+
if (stopped) return;
|
|
300
|
+
timer = timers.setTimeout(tick, delayMs);
|
|
301
|
+
timer?.unref?.();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function tick() {
|
|
305
|
+
timer = null;
|
|
306
|
+
if (stopped) return;
|
|
307
|
+
running = (async () => {
|
|
308
|
+
try {
|
|
309
|
+
if (!integrations?.with('billableJobs').length && !integrations?.with('findInvoices').length) return;
|
|
310
|
+
const organisationId = await soleOrganisation(db);
|
|
311
|
+
if (!organisationId) return;
|
|
312
|
+
const last = await lastRun(db, organisationId);
|
|
313
|
+
if (!reconcileDue(last, now())) return;
|
|
314
|
+
if (await deadSince(db, organisationId, { kind: RECONCILE_KIND, dedupeKey: 'all', since: deadHoldSince(startedAtMs, now()) })) return;
|
|
315
|
+
const unconfirmed = last?.state === 'succeeded' ? last.result?.unconfirmed ?? [] : [];
|
|
316
|
+
await enqueueJob(db, organisationId, { kind: RECONCILE_KIND, dedupeKey: 'all', maxAttempts: 3 });
|
|
317
|
+
const ran = await runNextJob(db, {
|
|
318
|
+
workerId,
|
|
319
|
+
leaseMs: LEASE_MS,
|
|
320
|
+
handlers: { [RECONCILE_KIND]: () => runReconcile(db, organisationId, { integrations, now: now(), unconfirmed }) },
|
|
321
|
+
});
|
|
322
|
+
if (!ran) return;
|
|
323
|
+
if (ran.outcome === 'dead') log.error?.('[reconcile] the daily reconcile gave up after its last attempt');
|
|
324
|
+
else if (ran.outcome === 'succeeded') {
|
|
325
|
+
const { daysChecked, daysDifferent, opened, closed } = ran.job.result;
|
|
326
|
+
(daysDifferent ? log.warn : log.info)?.(`[reconcile] ${daysChecked} days checked, ${daysDifferent} differ; ${opened} items open or refreshed, ${closed} closed`);
|
|
327
|
+
} else log.warn?.(`[reconcile] ${ran.outcome}: ${ran.job.lastError}`);
|
|
328
|
+
} catch (error) {
|
|
329
|
+
log.error?.(`[reconcile] ${error?.code ?? 'error'}: ${error?.message ?? error}`);
|
|
330
|
+
}
|
|
331
|
+
})();
|
|
332
|
+
await running;
|
|
333
|
+
running = null;
|
|
334
|
+
schedule(everyMs);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return {
|
|
338
|
+
start() {
|
|
339
|
+
stopped = false;
|
|
340
|
+
schedule(firstDelayMs);
|
|
341
|
+
},
|
|
342
|
+
async stop() {
|
|
343
|
+
stopped = true;
|
|
344
|
+
if (timer) timers.clearTimeout(timer);
|
|
345
|
+
timer = null;
|
|
346
|
+
await running;
|
|
347
|
+
},
|
|
348
|
+
tick,
|
|
349
|
+
};
|
|
350
|
+
}
|
package/server/core/sending.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm';
|
|
3
|
-
import { invoiceLines, invoices } from '../db/schema.js';
|
|
3
|
+
import { billingObligations, invoiceLines, invoices } from '../db/schema.js';
|
|
4
4
|
import { FlowError } from './errors.js';
|
|
5
5
|
import { issueInvoice } from './invoices.js';
|
|
6
6
|
import { soleOrganisation } from './pipeline.js';
|
|
7
|
+
import { needsRead, readAccountingFirst, readBeforeAct, READS_PER_PASS } from './read-before-act.js';
|
|
7
8
|
import { deadHoldSince, deadSince, enqueueJob, runNextJob } from './queue.js';
|
|
8
9
|
|
|
9
10
|
// Drafts, Problem, Ready and Send all ready (docs/INVOICE_SENDING.md).
|
|
@@ -60,6 +61,15 @@ async function checkDrafts(db, organisationId, drafts, accounting) {
|
|
|
60
61
|
const found = draftProblems(draft, linesOf.get(draft.id) ?? []);
|
|
61
62
|
if (found.length) problems.set(draft.id, found);
|
|
62
63
|
}
|
|
64
|
+
// A draft whose job the ledger sent to review (the job changed after the
|
|
65
|
+
// draft was made, or the work system deleted it) waits for a person.
|
|
66
|
+
const reviewed = await db.select({ invoiceId: billingObligations.invoiceId, reason: billingObligations.holdReason })
|
|
67
|
+
.from(billingObligations)
|
|
68
|
+
.where(and(eq(billingObligations.organisationId, organisationId), inArray(billingObligations.invoiceId, ids), eq(billingObligations.holdState, 'review')));
|
|
69
|
+
for (const { invoiceId, reason } of reviewed) {
|
|
70
|
+
const found = problem('job_review', `The job needs a look before this invoice goes: ${reason ?? 'it was sent to review'}.`);
|
|
71
|
+
problems.set(invoiceId, [...(problems.get(invoiceId) ?? []), found]);
|
|
72
|
+
}
|
|
63
73
|
if (accounting) {
|
|
64
74
|
const outside = await accounting.capabilities.checkDrafts({ organisationId, invoiceIds: ids });
|
|
65
75
|
for (const [id, found] of outside ?? []) {
|
|
@@ -146,18 +156,46 @@ async function assertCanSend(accounting, organisationId) {
|
|
|
146
156
|
}
|
|
147
157
|
}
|
|
148
158
|
|
|
159
|
+
// Read before act (issue #141): the jobs behind these drafts whose work
|
|
160
|
+
// system report is old are read again first. Answers the drafts that may go
|
|
161
|
+
// now; a draft whose job went to review, or could not be read, waits.
|
|
162
|
+
async function readJobsFirst(db, organisationId, invoiceIds, { work, now, personId }) {
|
|
163
|
+
if (!invoiceIds.length || typeof work?.capabilities?.readWorkItem !== 'function') return { go: invoiceIds, waiting: [] };
|
|
164
|
+
const jobs = await db.select().from(billingObligations)
|
|
165
|
+
.where(and(eq(billingObligations.organisationId, organisationId), inArray(billingObligations.invoiceId, invoiceIds)));
|
|
166
|
+
const old = jobs.filter((job) => needsRead(job, { work, now }));
|
|
167
|
+
const outcomes = await readBeforeAct(db, organisationId, old.slice(0, READS_PER_PASS), { work, now, actor: `person:${personId}` });
|
|
168
|
+
const held = new Set(old.slice(READS_PER_PASS).map((job) => job.invoiceId));
|
|
169
|
+
for (const { outcome, obligation } of outcomes.values()) {
|
|
170
|
+
if (outcome === 'waiting' || outcome === 'review') held.add(obligation.invoiceId);
|
|
171
|
+
}
|
|
172
|
+
return { go: invoiceIds.filter((id) => !held.has(id)), waiting: invoiceIds.filter((id) => held.has(id)) };
|
|
173
|
+
}
|
|
174
|
+
|
|
149
175
|
// Send all ready: issues each ready draft, then queues it for the accounting
|
|
150
176
|
// system. With `invoiceIds`, only those drafts, and each must be ready (the
|
|
151
|
-
// Send button on one invoice).
|
|
152
|
-
|
|
177
|
+
// Send button on one invoice). `work` is the work integration, for the read
|
|
178
|
+
// before act. Answers { issued, queued, remaining, waiting }.
|
|
179
|
+
export async function sendAllReady(db, organisationId, { accounting = null, work = null, personId, issueDate, invoiceIds = null, now = new Date() }) {
|
|
153
180
|
await assertCanSend(accounting, organisationId);
|
|
154
|
-
|
|
181
|
+
// Issue #45: the accounting copy reads its latest changes first, so the
|
|
182
|
+
// draft checks below see an invoice someone made there meanwhile.
|
|
183
|
+
let views = await invoiceViews(db, organisationId, { accounting });
|
|
184
|
+
const accountingRead = views.ready.length ? await readAccountingFirst(db, organisationId, { accounting, now }) : { outcome: 'fresh' };
|
|
185
|
+
if (accountingRead.changed) views = await invoiceViews(db, organisationId, { accounting });
|
|
186
|
+
let { ready } = views;
|
|
155
187
|
if (invoiceIds) {
|
|
156
188
|
const wanted = new Set(invoiceIds);
|
|
157
189
|
ready = ready.filter((id) => wanted.has(id));
|
|
158
190
|
if (ready.length !== wanted.size) throw new FlowError('not_ready', 'Only a ready invoice can be sent. Mark it ready first.');
|
|
159
191
|
}
|
|
160
|
-
const
|
|
192
|
+
const { go, waiting } = accountingRead.outcome === 'waiting'
|
|
193
|
+
? { go: [], waiting: ready.slice(0, SEND_BATCH) }
|
|
194
|
+
: await readJobsFirst(db, organisationId, ready.slice(0, SEND_BATCH), { work, now, personId });
|
|
195
|
+
if (invoiceIds && waiting.length) {
|
|
196
|
+
throw new FlowError('not_ready', 'Flow could not confirm the job with the work system or the accounting system just now, or the job changed. Nothing was sent. Look at the invoice, then try again.');
|
|
197
|
+
}
|
|
198
|
+
const batch = go;
|
|
161
199
|
let issued = 0;
|
|
162
200
|
for (const invoiceId of batch) {
|
|
163
201
|
// Queued first: the worker sends only issued invoices, so a stop between
|
|
@@ -173,7 +211,7 @@ export async function sendAllReady(db, organisationId, { accounting = null, pers
|
|
|
173
211
|
}
|
|
174
212
|
issued += 1;
|
|
175
213
|
}
|
|
176
|
-
return { issued, queued: accounting ? issued : 0, remaining: ready.length -
|
|
214
|
+
return { issued, queued: accounting ? issued : 0, remaining: Math.max(0, ready.length - SEND_BATCH), waiting: waiting.length };
|
|
177
215
|
}
|
|
178
216
|
|
|
179
217
|
// Send again: issued invoices that failed go back on the queue. With no ids,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ALTER TABLE "billing_obligations" ADD COLUMN IF NOT EXISTS "draft_basis" jsonb;
|