@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.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.53-3",
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-3",
9
+ "version": "2026.10.53-4",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@clerk/backend": "3.25.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.53-3",
3
+ "version": "2026.10.53-4",
4
4
  "description": "Invoicing, bill entry and a financial dashboard for labs, with Seazona and Xero integrations.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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
- ...invoiceTotals(lines),
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) => String(existing[key] ?? '') !== String(facts[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 ?? line.taxCode ?? item.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
@@ -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;