@arcforgelabs/flow 2026.10.53-3 → 2026.10.53-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/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/core/ledger.js +66 -12
- package/server/core/line-mapping.js +2 -2
- package/server/core/read-before-act.js +2 -2
- package/server/core/sending.js +7 -1
- package/server/db/migrations/0025_powerful_leopardon.sql +1 -0
- package/server/db/migrations/meta/0025_snapshot.json +4709 -0
- package/server/db/migrations/meta/_journal.json +7 -0
- package/server/db/schema.js +2 -0
- package/server/integrations/seazona/history/index.js +4 -7
- package/server/integrations/seazona/ledger.js +14 -4
- package/server/ui/invoicing.js +18 -7
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcforgelabs/flow",
|
|
3
|
-
"version": "2026.10.53-
|
|
3
|
+
"version": "2026.10.53-4",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@arcforgelabs/flow",
|
|
9
|
-
"version": "2026.10.53-
|
|
9
|
+
"version": "2026.10.53-4",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@clerk/backend": "3.25.0",
|
package/package.json
CHANGED
package/server/core/ledger.js
CHANGED
|
@@ -100,12 +100,13 @@ async function normaliseLines(tx, organisationId, lines) {
|
|
|
100
100
|
priceItemId = item?.archivedAt ? null : (item?.id ?? null);
|
|
101
101
|
description = (description ?? item?.name ?? '').trim();
|
|
102
102
|
unitAmountCents ??= item?.unitAmountCents;
|
|
103
|
-
taxCode ??= item?.taxCode ?? 'GST';
|
|
103
|
+
taxCode ??= item?.taxCode ?? (line.taxCode === null ? null : 'GST');
|
|
104
104
|
const quantity = line.quantity ?? 1;
|
|
105
105
|
if (!description) throw new FlowError('invalid', `line ${index + 1}: description is required`);
|
|
106
106
|
let totals;
|
|
107
107
|
try {
|
|
108
|
-
totals = lineTotals({ quantity, unitAmountCents, taxCode });
|
|
108
|
+
totals = lineTotals({ quantity, unitAmountCents, taxCode: taxCode ?? 'GST_FREE' });
|
|
109
|
+
if (taxCode === null) totals.taxCents = null;
|
|
109
110
|
} catch (error) {
|
|
110
111
|
throw new FlowError('invalid', `line ${index + 1}: ${error.message}`);
|
|
111
112
|
}
|
|
@@ -128,6 +129,48 @@ function fingerprint(lines) {
|
|
|
128
129
|
return createHash('sha256').update(JSON.stringify(keys)).digest('hex');
|
|
129
130
|
}
|
|
130
131
|
|
|
132
|
+
const SOURCE_TOTAL_FIELDS = ['subtotalCents', 'taxCents', 'totalCents'];
|
|
133
|
+
const REVIEW_SOURCE_TOTALS = 'job.source_totals';
|
|
134
|
+
|
|
135
|
+
function readSourceTotals(raw) {
|
|
136
|
+
if (raw == null) return null;
|
|
137
|
+
const totals = {};
|
|
138
|
+
for (const field of SOURCE_TOTAL_FIELDS) {
|
|
139
|
+
if (raw[field] == null) continue;
|
|
140
|
+
if (!Number.isSafeInteger(raw[field])) throw new FlowError('invalid', 'source ' + field + ' must be whole cents');
|
|
141
|
+
totals[field] = raw[field];
|
|
142
|
+
}
|
|
143
|
+
return Object.keys(totals).length ? totals : null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// A source invoice total is evidence, not permission to allocate its tax.
|
|
147
|
+
export function sourceBillingProblems(obligation) {
|
|
148
|
+
const reasons = [];
|
|
149
|
+
const lines = obligation.lines ?? [];
|
|
150
|
+
if (lines.some((line) => line.taxCode === null)) reasons.push('a source line has unknown tax treatment');
|
|
151
|
+
const calculated = invoiceTotals(lines);
|
|
152
|
+
if (SOURCE_TOTAL_FIELDS.some((field) => obligation.sourceTotals?.[field] != null && obligation.sourceTotals[field] !== calculated[field])) {
|
|
153
|
+
reasons.push('the source invoice totals differ from the reconstructed lines');
|
|
154
|
+
}
|
|
155
|
+
return reasons;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function recordSourceReview(tx, row, actor) {
|
|
159
|
+
const reasons = sourceBillingProblems(row);
|
|
160
|
+
if (reasons.length) {
|
|
161
|
+
await openReview(tx, row.organisationId, {
|
|
162
|
+
kind: REVIEW_SOURCE_TOTALS, entity: 'obligation', flowId: row.id,
|
|
163
|
+
systems: [row.sourceSystem, 'flow'], moneyAtRiskCents: Math.abs(row.totalCents),
|
|
164
|
+
evidence: { reasons, sourceTotals: row.sourceTotals, reconstructed: invoiceTotals(row.lines),
|
|
165
|
+
unknownTaxLines: row.lines.flatMap((line, index) => line.taxCode === null ? [index + 1] : []),
|
|
166
|
+
sourceJobRef: row.sourceJobRef, invoiceId: row.invoiceId },
|
|
167
|
+
});
|
|
168
|
+
} else {
|
|
169
|
+
await resolveReviewFor(tx, row.organisationId, { kind: REVIEW_SOURCE_TOTALS, entity: 'obligation', flowId: row.id, resolution: 'source_totals_match', actor });
|
|
170
|
+
}
|
|
171
|
+
return row;
|
|
172
|
+
}
|
|
173
|
+
|
|
131
174
|
// Records what the work system reports about a billable job. The first report
|
|
132
175
|
// makes the obligation; a later report with other lines or totals changes it.
|
|
133
176
|
// If the job changes after Flow made its invoice, the obligation goes to review.
|
|
@@ -137,6 +180,9 @@ export async function observeJob(db, organisationId, job) {
|
|
|
137
180
|
const seenAt = job.observedAt ? new Date(job.observedAt) : new Date();
|
|
138
181
|
return db.transaction(async (tx) => {
|
|
139
182
|
const lines = await normaliseLines(tx, organisationId, job.lines);
|
|
183
|
+
const sourceTotals = readSourceTotals(job.sourceTotals);
|
|
184
|
+
const totals = { ...invoiceTotals(lines), ...sourceTotals };
|
|
185
|
+
if (sourceTotals?.totalCents == null) totals.totalCents = totals.subtotalCents + totals.taxCents;
|
|
140
186
|
const facts = {
|
|
141
187
|
sourceInvoiceRef: job.sourceInvoiceRef ?? null,
|
|
142
188
|
caseRef: job.caseRef ?? null,
|
|
@@ -147,7 +193,8 @@ export async function observeJob(db, organisationId, job) {
|
|
|
147
193
|
sourceStatus: job.sourceStatus ?? null,
|
|
148
194
|
lines,
|
|
149
195
|
lineFingerprint: fingerprint(lines),
|
|
150
|
-
|
|
196
|
+
sourceTotals,
|
|
197
|
+
...totals,
|
|
151
198
|
mappingComplete: lines.every((l) => l.priceItemId || l.accountCode),
|
|
152
199
|
};
|
|
153
200
|
const [existing] = await tx.select().from(billingObligations)
|
|
@@ -163,21 +210,23 @@ export async function observeJob(db, organisationId, job) {
|
|
|
163
210
|
.values({ organisationId, sourceSystem, sourceJobRef, sourceSeenAt: seenAt, ...facts })
|
|
164
211
|
.returning();
|
|
165
212
|
await addEvent(tx, row, 'observed', actor, {
|
|
166
|
-
lineFingerprint: row.lineFingerprint, totalCents: row.totalCents, mappingComplete: row.mappingComplete,
|
|
213
|
+
lineFingerprint: row.lineFingerprint, totalCents: row.totalCents, mappingComplete: row.mappingComplete, sourceTotals: row.sourceTotals,
|
|
167
214
|
});
|
|
168
|
-
return row;
|
|
215
|
+
return recordSourceReview(tx, row, actor);
|
|
169
216
|
}
|
|
170
217
|
|
|
171
218
|
// An older report never replaces a newer one.
|
|
172
219
|
if (seenAt < existing.sourceSeenAt) return existing;
|
|
173
|
-
const changed = ['sourceInvoiceRef', 'caseRef', 'contactId', 'practitioner', 'shippedOn', 'lineFingerprint', 'totalCents']
|
|
174
|
-
.filter((key) =>
|
|
220
|
+
const changed = ['sourceInvoiceRef', 'caseRef', 'contactId', 'practitioner', 'shippedOn', 'lineFingerprint', 'subtotalCents', 'taxCents', 'totalCents', 'sourceTotals']
|
|
221
|
+
.filter((key) => key === 'sourceTotals'
|
|
222
|
+
? SOURCE_TOTAL_FIELDS.some((field) => (existing.sourceTotals?.[field] ?? null) !== (facts.sourceTotals?.[field] ?? null))
|
|
223
|
+
: String(existing[key] ?? '') !== String(facts[key] ?? ''));
|
|
175
224
|
if (!changed.length) {
|
|
176
225
|
const row = await update(tx, existing, {
|
|
177
226
|
sourceSeenAt: seenAt, mappingComplete: facts.mappingComplete, lines, jobDate: facts.jobDate, sourceStatus: facts.sourceStatus,
|
|
178
227
|
});
|
|
179
228
|
await statusBillableAgain(tx, organisationId, existing, row, actor);
|
|
180
|
-
return row;
|
|
229
|
+
return recordSourceReview(tx, row, actor);
|
|
181
230
|
}
|
|
182
231
|
const fields = { ...facts, sourceSeenAt: seenAt };
|
|
183
232
|
let compare = null;
|
|
@@ -194,8 +243,8 @@ export async function observeJob(db, organisationId, job) {
|
|
|
194
243
|
const row = await update(tx, existing, fields);
|
|
195
244
|
await addEvent(tx, row, 'source_changed', actor, {
|
|
196
245
|
changed,
|
|
197
|
-
before: { lineFingerprint: existing.lineFingerprint, totalCents: existing.totalCents },
|
|
198
|
-
after: { lineFingerprint: row.lineFingerprint, totalCents: row.totalCents },
|
|
246
|
+
before: { lineFingerprint: existing.lineFingerprint, totalCents: existing.totalCents, sourceTotals: existing.sourceTotals },
|
|
247
|
+
after: { lineFingerprint: row.lineFingerprint, totalCents: row.totalCents, sourceTotals: row.sourceTotals },
|
|
199
248
|
...(compare ? { compare: { outcome: compare.outcome, fields: compare.fields.map(({ field, changedIn }) => ({ field, changedIn })) } } : {}),
|
|
200
249
|
...(fields.holdState === 'review' ? { holdReason: fields.holdReason } : {}),
|
|
201
250
|
});
|
|
@@ -205,7 +254,7 @@ export async function observeJob(db, organisationId, job) {
|
|
|
205
254
|
await openReview(tx, organisationId, changedReview(row, compare));
|
|
206
255
|
}
|
|
207
256
|
await statusBillableAgain(tx, organisationId, existing, row, actor);
|
|
208
|
-
return row;
|
|
257
|
+
return recordSourceReview(tx, row, actor);
|
|
209
258
|
});
|
|
210
259
|
}
|
|
211
260
|
|
|
@@ -313,6 +362,8 @@ export async function useSourceLines(db, organisationId, obligationId, { actor,
|
|
|
313
362
|
return db.transaction(async (tx) => {
|
|
314
363
|
const obligation = await loadObligation(tx, organisationId, obligationId, { lock: true });
|
|
315
364
|
if (!obligation.invoiceId) throw new FlowError('invalid', 'the job has no invoice');
|
|
365
|
+
const sourceProblems = sourceBillingProblems(obligation);
|
|
366
|
+
if (sourceProblems.length) throw new FlowError('not_ready', sourceProblems.join('; '));
|
|
316
367
|
const compare = await compareWithDraft(tx, organisationId, obligation, obligation);
|
|
317
368
|
if (compare.outcome === 'conflict' && (compare.sent || compare.missing || !accept)) {
|
|
318
369
|
throw new FlowError('conflict', compare.missing ? 'the invoice was not found' : compare.sent ? 'the invoice has left Flow; change it where it is now' : 'the draft changed in Flow too; a person must compare them', { fields: compare.fields });
|
|
@@ -786,7 +837,10 @@ export function ledgerView(obligation, { now = new Date(), sourceMaxAgeMs = SOUR
|
|
|
786
837
|
if (['authorised', 'part_paid'].includes(o.accountingState)) {
|
|
787
838
|
return { view: 'invoiced', reasons: [`invoice ${o.accountingRef} is ${o.accountingState.replace('_', ' ')}`] };
|
|
788
839
|
}
|
|
840
|
+
// Existing invoices own their stage. sending.js still checks source conflicts
|
|
841
|
+
// before approval or Send, after the non-chargeable rules.
|
|
789
842
|
if (o.invoiceId || o.accountingState === 'drafted') return { view: 'drafted', reasons: ['a draft invoice exists'] };
|
|
843
|
+
if (sourceBillingProblems(o).length) return { view: 'review_required', reasons: draftBlockers(o, { now, sourceMaxAgeMs, accounting }) };
|
|
790
844
|
const reasons = draftBlockers(o, { now, sourceMaxAgeMs, accounting });
|
|
791
845
|
// Flow's own stage: a job is in work until a person or an automation marks
|
|
792
846
|
// it ready to invoice (setReady).
|
|
@@ -795,7 +849,7 @@ export function ledgerView(obligation, { now = new Date(), sourceMaxAgeMs = SOUR
|
|
|
795
849
|
}
|
|
796
850
|
|
|
797
851
|
function draftBlockers(o, { now, sourceMaxAgeMs, accounting = true }) {
|
|
798
|
-
const reasons =
|
|
852
|
+
const reasons = sourceBillingProblems(o);
|
|
799
853
|
if (o.holdState !== 'none') reasons.push(`the job is ${o.holdState}`);
|
|
800
854
|
if (o.invoiceId) reasons.push('the job already has an invoice');
|
|
801
855
|
if (o.accountingState === 'unknown') {
|
|
@@ -156,7 +156,7 @@ export function mapLine(line, context) {
|
|
|
156
156
|
description: (item.description || item.name || original).trim(),
|
|
157
157
|
quantity,
|
|
158
158
|
unitAmountCents: config.linePrice === 'list' ? item.unitAmountCents : (price ?? item.unitAmountCents),
|
|
159
|
-
taxCode: taxCode ??
|
|
159
|
+
taxCode: line.taxCode ?? taxCode ?? item.taxCode,
|
|
160
160
|
mappedBy: step,
|
|
161
161
|
});
|
|
162
162
|
const fromRow = (row, step) => {
|
|
@@ -180,7 +180,7 @@ export function mapLine(line, context) {
|
|
|
180
180
|
if (row) {
|
|
181
181
|
return {
|
|
182
182
|
...base, itemCode: null, accountCode: row.accountCode, description: row.description || original, quantity,
|
|
183
|
-
unitAmountCents: price, taxCode: row.taxCode ?? line.taxCode, mappedBy: step,
|
|
183
|
+
unitAmountCents: price, taxCode: line.taxCode ?? row.taxCode ?? (line.taxCode === null ? null : undefined), mappedBy: step,
|
|
184
184
|
};
|
|
185
185
|
}
|
|
186
186
|
break;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { and, eq } from 'drizzle-orm';
|
|
2
2
|
import { billingObligations } from '../db/schema.js';
|
|
3
3
|
import { FlowError } from './errors.js';
|
|
4
|
-
import { observeJob, observeWithdrawn } from './ledger.js';
|
|
4
|
+
import { observeJob, observeWithdrawn, sourceBillingProblems } from './ledger.js';
|
|
5
5
|
import { matchJobs, workJobMaker } from './pipeline.js';
|
|
6
6
|
|
|
7
7
|
// Read before act (issue #141). Flow drafts and sends from its own copy of
|
|
@@ -50,7 +50,7 @@ export async function readBeforeAct(db, organisationId, obligations, { work, now
|
|
|
50
50
|
continue;
|
|
51
51
|
}
|
|
52
52
|
const fresh = await reload(db, organisationId, obligation.id);
|
|
53
|
-
outcomes.set(obligation.id, { outcome: fresh.holdState === 'review' ? 'review' : 'read', obligation: fresh });
|
|
53
|
+
outcomes.set(obligation.id, { outcome: fresh.holdState === 'review' || sourceBillingProblems(fresh).length ? 'review' : 'read', obligation: fresh });
|
|
54
54
|
} else if (answer?.state === 'withdrawn') {
|
|
55
55
|
// The ledger decides as for the feed (observeWithdrawn): deleted, or
|
|
56
56
|
// moved back after its invoice, goes to review; moved back while only
|
package/server/core/sending.js
CHANGED
|
@@ -5,7 +5,7 @@ import { FlowError } from './errors.js';
|
|
|
5
5
|
import { listRules, 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
|
-
import { CHANGED_REASON, diffJobLines, STATUS_BACK_REASON } from './ledger.js';
|
|
8
|
+
import { CHANGED_REASON, diffJobLines, sourceBillingProblems, STATUS_BACK_REASON } from './ledger.js';
|
|
9
9
|
import { soleOrganisation } from './pipeline.js';
|
|
10
10
|
import { needsRead, readAccountingFirst, readBeforeAct, READS_PER_PASS } from './read-before-act.js';
|
|
11
11
|
import { deadHoldSince, deadSince, enqueueJob, runNextJob } from './queue.js';
|
|
@@ -116,6 +116,12 @@ async function checkDrafts(db, organisationId, drafts, accounting) {
|
|
|
116
116
|
}
|
|
117
117
|
// A draft whose job the ledger sent to review (the job changed after the
|
|
118
118
|
// draft was made, or the work system deleted it) waits for a person.
|
|
119
|
+
for (const { obligation } of jobs) {
|
|
120
|
+
for (const reason of sourceBillingProblems(obligation)) {
|
|
121
|
+
const found = problem('job_review', 'Check the source invoice: ' + reason + '.', 'Source tax');
|
|
122
|
+
problems.set(obligation.invoiceId, [...(problems.get(obligation.invoiceId) ?? []), found]);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
119
125
|
const reviewed = jobs.map((j) => j.obligation).filter((o) => o.holdState === 'review');
|
|
120
126
|
for (const obligation of reviewed) {
|
|
121
127
|
const { invoiceId, holdReason: reason } = obligation;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ALTER TABLE "billing_obligations" ADD COLUMN IF NOT EXISTS "source_totals" jsonb;
|