@arcforgelabs/flow 2026.10.53 → 2026.11.2
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 +1 -0
- package/SECURITY.md +7 -4
- package/npm-shrinkwrap.json +442 -4
- package/package.json +8 -3
- package/server/agents/auth-worker.js +7 -0
- package/server/agents/auth.js +50 -0
- package/server/agents/gate.js +138 -0
- package/server/agents/grants.js +51 -0
- package/server/agents/reads.js +47 -0
- package/server/agents/server.js +74 -0
- package/server/agents/settings.js +24 -0
- package/server/api/invoicing.js +27 -10
- package/server/app.js +9 -0
- package/server/core/invoice-rules.js +25 -10
- package/server/core/job-board.js +12 -4
- package/server/core/ledger.js +110 -22
- package/server/core/line-mapping.js +9 -5
- package/server/core/read-before-act.js +2 -2
- package/server/core/reconcile.js +15 -1
- package/server/core/sending.js +33 -9
- package/server/db/migrations/0025_powerful_leopardon.sql +1 -0
- package/server/db/migrations/0026_bitter_sersi.sql +38 -0
- package/server/db/migrations/meta/0025_snapshot.json +4709 -0
- package/server/db/migrations/meta/0026_snapshot.json +4926 -0
- package/server/db/migrations/meta/_journal.json +14 -0
- package/server/db/schema.js +38 -1
- package/server/index.js +9 -1
- package/server/integrations/port.js +1 -0
- package/server/integrations/seazona/history/backfill.js +3 -1
- package/server/integrations/seazona/history/index.js +4 -7
- package/server/integrations/seazona/index.js +5 -1
- package/server/integrations/seazona/lanes.js +44 -27
- package/server/integrations/seazona/ledger.js +17 -6
- package/server/integrations/seazona/reconcile.js +12 -0
- package/server/rate-limit.js +2 -1
- package/server/ui/app.js +76 -64
- package/server/ui/flow.css +43 -8
- package/server/ui/invoice-rules.js +24 -3
- package/server/ui/invoicing.js +76 -13
- package/server/ui/job-board.js +12 -7
- package/server/ui/job-change.js +63 -15
- package/server/ui/jobs.js +4 -4
- package/server/ui/mobile.js +17 -0
- package/server/ui/routes.js +1 -1
- package/server/ui/sync-status.js +67 -0
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,17 +180,20 @@ 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,
|
|
143
189
|
contactId: job.contactId ?? null,
|
|
144
190
|
practitioner: job.practitioner ?? null,
|
|
145
|
-
shippedOn: job.shippedOn ?? null,
|
|
146
191
|
jobDate: job.jobDate ?? null,
|
|
147
192
|
sourceStatus: job.sourceStatus ?? null,
|
|
148
193
|
lines,
|
|
149
194
|
lineFingerprint: fingerprint(lines),
|
|
150
|
-
|
|
195
|
+
sourceTotals,
|
|
196
|
+
...totals,
|
|
151
197
|
mappingComplete: lines.every((l) => l.priceItemId || l.accountCode),
|
|
152
198
|
};
|
|
153
199
|
const [existing] = await tx.select().from(billingObligations)
|
|
@@ -158,26 +204,36 @@ export async function observeJob(db, organisationId, job) {
|
|
|
158
204
|
))
|
|
159
205
|
.for('update');
|
|
160
206
|
|
|
207
|
+
// A work system without a shipped date (Seazona) says only that the job
|
|
208
|
+
// is shipped: the day Flow sees its status move to a shipped one is its
|
|
209
|
+
// shipped day. A job Flow first sees already shipped (a first sync, or a
|
|
210
|
+
// job shipped before Flow kept this day) stays without one.
|
|
211
|
+
const statusMoved = existing && String(existing.sourceStatus ?? '') !== String(facts.sourceStatus ?? '');
|
|
212
|
+
facts.shippedOn = job.shippedOn ?? existing?.shippedOn
|
|
213
|
+
?? (job.shipped && statusMoved ? localDay(seenAt) : null);
|
|
214
|
+
|
|
161
215
|
if (!existing) {
|
|
162
216
|
const [row] = await tx.insert(billingObligations)
|
|
163
217
|
.values({ organisationId, sourceSystem, sourceJobRef, sourceSeenAt: seenAt, ...facts })
|
|
164
218
|
.returning();
|
|
165
219
|
await addEvent(tx, row, 'observed', actor, {
|
|
166
|
-
lineFingerprint: row.lineFingerprint, totalCents: row.totalCents, mappingComplete: row.mappingComplete,
|
|
220
|
+
lineFingerprint: row.lineFingerprint, totalCents: row.totalCents, mappingComplete: row.mappingComplete, sourceStatus: row.sourceStatus, sourceTotals: row.sourceTotals,
|
|
167
221
|
});
|
|
168
|
-
return row;
|
|
222
|
+
return recordSourceReview(tx, row, actor);
|
|
169
223
|
}
|
|
170
224
|
|
|
171
225
|
// An older report never replaces a newer one.
|
|
172
226
|
if (seenAt < existing.sourceSeenAt) return existing;
|
|
173
|
-
const changed = ['sourceInvoiceRef', 'caseRef', 'contactId', 'practitioner', 'shippedOn', 'lineFingerprint', 'totalCents']
|
|
174
|
-
.filter((key) =>
|
|
227
|
+
const changed = ['sourceInvoiceRef', 'caseRef', 'contactId', 'practitioner', 'shippedOn', 'lineFingerprint', 'subtotalCents', 'taxCents', 'totalCents', 'sourceTotals']
|
|
228
|
+
.filter((key) => key === 'sourceTotals'
|
|
229
|
+
? SOURCE_TOTAL_FIELDS.some((field) => (existing.sourceTotals?.[field] ?? null) !== (facts.sourceTotals?.[field] ?? null))
|
|
230
|
+
: String(existing[key] ?? '') !== String(facts[key] ?? ''));
|
|
175
231
|
if (!changed.length) {
|
|
176
232
|
const row = await update(tx, existing, {
|
|
177
233
|
sourceSeenAt: seenAt, mappingComplete: facts.mappingComplete, lines, jobDate: facts.jobDate, sourceStatus: facts.sourceStatus,
|
|
178
234
|
});
|
|
179
|
-
await
|
|
180
|
-
return row;
|
|
235
|
+
await statusChanged(tx, organisationId, existing, row, actor);
|
|
236
|
+
return recordSourceReview(tx, row, actor);
|
|
181
237
|
}
|
|
182
238
|
const fields = { ...facts, sourceSeenAt: seenAt };
|
|
183
239
|
let compare = null;
|
|
@@ -194,8 +250,8 @@ export async function observeJob(db, organisationId, job) {
|
|
|
194
250
|
const row = await update(tx, existing, fields);
|
|
195
251
|
await addEvent(tx, row, 'source_changed', actor, {
|
|
196
252
|
changed,
|
|
197
|
-
before: { lineFingerprint: existing.lineFingerprint, totalCents: existing.totalCents },
|
|
198
|
-
after: { lineFingerprint: row.lineFingerprint, totalCents: row.totalCents },
|
|
253
|
+
before: { lineFingerprint: existing.lineFingerprint, totalCents: existing.totalCents, sourceTotals: existing.sourceTotals },
|
|
254
|
+
after: { lineFingerprint: row.lineFingerprint, totalCents: row.totalCents, sourceTotals: row.sourceTotals },
|
|
199
255
|
...(compare ? { compare: { outcome: compare.outcome, fields: compare.fields.map(({ field, changedIn }) => ({ field, changedIn })) } } : {}),
|
|
200
256
|
...(fields.holdState === 'review' ? { holdReason: fields.holdReason } : {}),
|
|
201
257
|
});
|
|
@@ -204,15 +260,28 @@ export async function observeJob(db, organisationId, job) {
|
|
|
204
260
|
} else if (compare) {
|
|
205
261
|
await openReview(tx, organisationId, changedReview(row, compare));
|
|
206
262
|
}
|
|
207
|
-
await
|
|
208
|
-
return row;
|
|
263
|
+
await statusChanged(tx, organisationId, existing, row, actor);
|
|
264
|
+
return recordSourceReview(tx, row, actor);
|
|
209
265
|
});
|
|
210
266
|
}
|
|
211
267
|
|
|
268
|
+
// Each move of the work system status is an event, so "was the job shipped
|
|
269
|
+
// in this range" has an answer after the status moves on.
|
|
270
|
+
async function statusChanged(tx, organisationId, before, row, actor) {
|
|
271
|
+
if (String(before.sourceStatus ?? '') === String(row.sourceStatus ?? '')) return;
|
|
272
|
+
await addEvent(tx, row, 'status_changed', actor, { from: before.sourceStatus ?? null, to: row.sourceStatus ?? null });
|
|
273
|
+
await statusBillableAgain(tx, organisationId, before, row, actor);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// The calendar day of a time where the lab works (FLOW_TIME_ZONE).
|
|
277
|
+
function localDay(at) {
|
|
278
|
+
return new Intl.DateTimeFormat('en-CA', { timeZone: process.env.FLOW_TIME_ZONE || 'Australia/Sydney' }).format(at);
|
|
279
|
+
}
|
|
280
|
+
|
|
212
281
|
// A job billable again after its status moved back (observeWithdrawn): the
|
|
213
282
|
// alert is over when no invoice was made in the meantime.
|
|
214
283
|
async function statusBillableAgain(tx, organisationId, before, row, actor) {
|
|
215
|
-
if (row.invoiceId
|
|
284
|
+
if (row.invoiceId) return;
|
|
216
285
|
await resolveReviewFor(tx, organisationId, { kind: REVIEW_STATUS_BACK, entity: 'obligation', flowId: row.id, resolution: 'status_billable_again', actor });
|
|
217
286
|
}
|
|
218
287
|
|
|
@@ -280,7 +349,7 @@ async function compareWithDraft(tx, organisationId, obligation, facts) {
|
|
|
280
349
|
}
|
|
281
350
|
const flow = { lines: invoice.lines, contactId: invoice.contactId };
|
|
282
351
|
const compare = compareThreeWays(base, facts, flow, { sent: invoiceSent(invoice, obligation) });
|
|
283
|
-
return { ...compare, base, flow, invoiceStatus: invoice.status };
|
|
352
|
+
return { ...compare, base, flow, invoiceStatus: invoice.status, invoiceTotals: { subtotalCents: invoice.subtotalCents, taxCents: invoice.taxCents, totalCents: invoice.totalCents } };
|
|
284
353
|
}
|
|
285
354
|
|
|
286
355
|
function changedReview(obligation, compare) {
|
|
@@ -313,6 +382,8 @@ export async function useSourceLines(db, organisationId, obligationId, { actor,
|
|
|
313
382
|
return db.transaction(async (tx) => {
|
|
314
383
|
const obligation = await loadObligation(tx, organisationId, obligationId, { lock: true });
|
|
315
384
|
if (!obligation.invoiceId) throw new FlowError('invalid', 'the job has no invoice');
|
|
385
|
+
const sourceProblems = sourceBillingProblems(obligation);
|
|
386
|
+
if (sourceProblems.length) throw new FlowError('not_ready', sourceProblems.join('; '));
|
|
316
387
|
const compare = await compareWithDraft(tx, organisationId, obligation, obligation);
|
|
317
388
|
if (compare.outcome === 'conflict' && (compare.sent || compare.missing || !accept)) {
|
|
318
389
|
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 });
|
|
@@ -408,17 +479,29 @@ export function diffJobLines(wasLines = [], nowLines = []) {
|
|
|
408
479
|
return changes.sort((a, b) => a.position - b.position || (a.change === 'removed' ? -1 : 1));
|
|
409
480
|
}
|
|
410
481
|
|
|
411
|
-
// The open
|
|
482
|
+
// The open job-change or source-billing problem, for the invoice page, or
|
|
412
483
|
// null: the job as it was when the invoice was made, as the work system has
|
|
413
484
|
// it now, and whether a person can adopt the change into Flow's own draft
|
|
414
485
|
// ("Adopt changes", useSourceLines). An invoice drafted before the basis was
|
|
415
486
|
// kept compares with the invoice's own lines.
|
|
416
487
|
export async function jobChangeFor(db, organisationId, invoiceId) {
|
|
417
488
|
const [obligation] = await db.select().from(billingObligations)
|
|
418
|
-
.where(and(eq(billingObligations.organisationId, organisationId), eq(billingObligations.invoiceId, invoiceId)
|
|
419
|
-
eq(billingObligations.holdState, 'review'), eq(billingObligations.holdReason, CHANGED_REASON)));
|
|
489
|
+
.where(and(eq(billingObligations.organisationId, organisationId), eq(billingObligations.invoiceId, invoiceId)));
|
|
420
490
|
if (!obligation) return null;
|
|
491
|
+
const changed = obligation.holdState === 'review' && obligation.holdReason === CHANGED_REASON;
|
|
492
|
+
const sourceProblems = sourceBillingProblems(obligation);
|
|
493
|
+
if (!changed && !sourceProblems.length) return null;
|
|
421
494
|
const compare = await compareWithDraft(db, organisationId, obligation, obligation);
|
|
495
|
+
// Source review is only for Flow's draft. Issued documents keep the existing
|
|
496
|
+
// job-change/accounting comparison, never a new source-resolution action.
|
|
497
|
+
const sourceReview = sourceProblems.length && compare.invoiceStatus === 'draft' ? {
|
|
498
|
+
sourceTotals: obligation.sourceTotals ?? null,
|
|
499
|
+
reconstructed: { ...invoiceTotals(obligation.lines), ...(obligation.lines.some((line) => line.taxCode === null) ? { taxCents: null, totalCents: null } : {}) },
|
|
500
|
+
unknownTaxLines: obligation.lines.flatMap((line, index) => line.taxCode === null
|
|
501
|
+
? [{ position: index + 1, description: line.description, itemCode: line.itemCode ?? null }] : []),
|
|
502
|
+
savedInvoice: compare.invoiceTotals,
|
|
503
|
+
} : null;
|
|
504
|
+
if (!changed && !sourceReview) return null;
|
|
422
505
|
const was = obligation.draftBasis
|
|
423
506
|
?? (compare.flow ? { lines: compare.flow.lines, contactId: compare.flow.contactId, totalCents: null } : basisOf(obligation));
|
|
424
507
|
return {
|
|
@@ -426,13 +509,15 @@ export async function jobChangeFor(db, organisationId, invoiceId) {
|
|
|
426
509
|
sourceSystem: obligation.sourceSystem,
|
|
427
510
|
sourceJobRef: obligation.sourceJobRef,
|
|
428
511
|
sourceInvoiceRef: obligation.sourceInvoiceRef,
|
|
512
|
+
caseRef: obligation.caseRef,
|
|
513
|
+
sourceReview,
|
|
429
514
|
seenAt: obligation.sourceSeenAt,
|
|
430
515
|
outcome: compare.outcome,
|
|
431
516
|
sent: compare.sent,
|
|
432
|
-
canAdopt: compare.outcome === 'offer',
|
|
517
|
+
canAdopt: !sourceReview && compare.outcome === 'offer',
|
|
433
518
|
// "Accept change": the draft takes the work system's values, even when
|
|
434
519
|
// the draft changed too. Not once the invoice has left Flow.
|
|
435
|
-
canAccept: !compare.sent && !compare.missing,
|
|
520
|
+
canAccept: !sourceReview && !compare.sent && !compare.missing,
|
|
436
521
|
lines: diffJobLines(was.lines ?? [], obligation.lines ?? []),
|
|
437
522
|
contact: (was.contactId ?? null) === (obligation.contactId ?? null) ? null : { was: was.contactId ?? null, now: obligation.contactId ?? null },
|
|
438
523
|
total: { was: was.totalCents ?? null, now: obligation.totalCents },
|
|
@@ -786,7 +871,10 @@ export function ledgerView(obligation, { now = new Date(), sourceMaxAgeMs = SOUR
|
|
|
786
871
|
if (['authorised', 'part_paid'].includes(o.accountingState)) {
|
|
787
872
|
return { view: 'invoiced', reasons: [`invoice ${o.accountingRef} is ${o.accountingState.replace('_', ' ')}`] };
|
|
788
873
|
}
|
|
874
|
+
// Existing invoices own their stage. sending.js still checks source conflicts
|
|
875
|
+
// before approval or Send, after the non-chargeable rules.
|
|
789
876
|
if (o.invoiceId || o.accountingState === 'drafted') return { view: 'drafted', reasons: ['a draft invoice exists'] };
|
|
877
|
+
if (sourceBillingProblems(o).length) return { view: 'review_required', reasons: draftBlockers(o, { now, sourceMaxAgeMs, accounting }) };
|
|
790
878
|
const reasons = draftBlockers(o, { now, sourceMaxAgeMs, accounting });
|
|
791
879
|
// Flow's own stage: a job is in work until a person or an automation marks
|
|
792
880
|
// it ready to invoice (setReady).
|
|
@@ -795,7 +883,7 @@ export function ledgerView(obligation, { now = new Date(), sourceMaxAgeMs = SOUR
|
|
|
795
883
|
}
|
|
796
884
|
|
|
797
885
|
function draftBlockers(o, { now, sourceMaxAgeMs, accounting = true }) {
|
|
798
|
-
const reasons =
|
|
886
|
+
const reasons = sourceBillingProblems(o);
|
|
799
887
|
if (o.holdState !== 'none') reasons.push(`the job is ${o.holdState}`);
|
|
800
888
|
if (o.invoiceId) reasons.push('the job already has an invoice');
|
|
801
889
|
if (o.accountingState === 'unknown') {
|
|
@@ -24,8 +24,10 @@ import { FlowError, notFound } from './errors.js';
|
|
|
24
24
|
//
|
|
25
25
|
// A mapped line keeps the work system's amount and tax flag; the price item
|
|
26
26
|
// gives its code, its text and, when the work system gave none, its price and
|
|
27
|
-
// tax.
|
|
28
|
-
//
|
|
27
|
+
// tax. A closest-name guess cannot supply missing source tax; it needs an
|
|
28
|
+
// explicit tax flag before mapping. With `linePrice: 'list'` the price item's
|
|
29
|
+
// price wins instead. A line no step answers keeps no code, so the ledger
|
|
30
|
+
// holds the job.
|
|
29
31
|
|
|
30
32
|
export const STEPS = Object.freeze([
|
|
31
33
|
'same_code', 'account', 'saved', 'catalog_name', 'price_alias', 'catalog_name_price', 'catalog_score', 'alias',
|
|
@@ -156,7 +158,7 @@ export function mapLine(line, context) {
|
|
|
156
158
|
description: (item.description || item.name || original).trim(),
|
|
157
159
|
quantity,
|
|
158
160
|
unitAmountCents: config.linePrice === 'list' ? item.unitAmountCents : (price ?? item.unitAmountCents),
|
|
159
|
-
taxCode: taxCode ??
|
|
161
|
+
taxCode: line.taxCode ?? taxCode ?? item.taxCode,
|
|
160
162
|
mappedBy: step,
|
|
161
163
|
});
|
|
162
164
|
const fromRow = (row, step) => {
|
|
@@ -180,7 +182,7 @@ export function mapLine(line, context) {
|
|
|
180
182
|
if (row) {
|
|
181
183
|
return {
|
|
182
184
|
...base, itemCode: null, accountCode: row.accountCode, description: row.description || original, quantity,
|
|
183
|
-
unitAmountCents: price, taxCode: row.taxCode ?? line.taxCode, mappedBy: step,
|
|
185
|
+
unitAmountCents: price, taxCode: line.taxCode ?? row.taxCode ?? (line.taxCode === null ? null : undefined), mappedBy: step,
|
|
184
186
|
};
|
|
185
187
|
}
|
|
186
188
|
break;
|
|
@@ -207,7 +209,9 @@ export function mapLine(line, context) {
|
|
|
207
209
|
break;
|
|
208
210
|
}
|
|
209
211
|
case 'catalog_score': {
|
|
210
|
-
|
|
212
|
+
// A guessed item would also supply its tax here (or in the ledger).
|
|
213
|
+
// Leave unknown tax for an exact match or a person's saved choice.
|
|
214
|
+
if (price === null || line.taxCode == null) break;
|
|
211
215
|
const scored = (context.byPrice.get(price) ?? [])
|
|
212
216
|
.map((item) => ({ item, score: nameScore(item, { exact, lookup, original: lineText(original) }) }))
|
|
213
217
|
.sort((a, b) => b.score - a.score || a.item.name.localeCompare(b.item.name) || a.item.code.localeCompare(b.item.code));
|
|
@@ -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/reconcile.js
CHANGED
|
@@ -124,6 +124,20 @@ export async function reconcileDays(db, organisationId, { integrations, from, to
|
|
|
124
124
|
and e.kind in ('observed', 'source_changed') and e.actor <> ${HISTORY_ACTOR})`,
|
|
125
125
|
));
|
|
126
126
|
|
|
127
|
+
// A billable feed cannot prove absence. Check the remaining ledger refs
|
|
128
|
+
// against the source copy without billing filters. Older adapters retain
|
|
129
|
+
// the feed comparison until they declare workJobPresence.
|
|
130
|
+
const present = new Set(work.keys());
|
|
131
|
+
for (const adapter of workAdapters) {
|
|
132
|
+
if (!adapter.capabilities.workJobPresence) continue;
|
|
133
|
+
const jobRefs = [...new Set(obligations
|
|
134
|
+
.filter((o) => o.sourceSystem === adapter.id && !present.has(`${adapter.id}:${o.sourceJobRef}`))
|
|
135
|
+
.map((o) => o.sourceJobRef))];
|
|
136
|
+
for (let i = 0; i < jobRefs.length; i += FIND_BATCH) {
|
|
137
|
+
const { presentRefs } = await adapter.capabilities.workJobPresence({ organisationId, jobRefs: jobRefs.slice(i, i + FIND_BATCH) });
|
|
138
|
+
for (const ref of presentRefs) present.add(`${adapter.id}:${ref}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
127
141
|
const reported = new Set(workAdapters.map((a) => a.id));
|
|
128
142
|
const inLedger = new Set();
|
|
129
143
|
for (const o of obligations) {
|
|
@@ -139,7 +153,7 @@ export async function reconcileDays(db, organisationId, { integrations, from, to
|
|
|
139
153
|
if (reported.has(o.sourceSystem)) {
|
|
140
154
|
const key = `${o.sourceSystem}:${o.sourceJobRef}`;
|
|
141
155
|
inLedger.add(key);
|
|
142
|
-
if (!
|
|
156
|
+
if (!present.has(key)) entry.differences.missingInWork.push({ id: o.id, cents: o.totalCents });
|
|
143
157
|
}
|
|
144
158
|
}
|
|
145
159
|
for (const [key, job] of work) {
|
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';
|
|
@@ -50,20 +50,24 @@ const REFUSAL_CODE = /^[a-z0-9_]{1,64}$/i;
|
|
|
50
50
|
const SENT = new Set(['created', 'reconciled', 'existing', 'already_in_xero']);
|
|
51
51
|
|
|
52
52
|
// `short` is the few words the Invoices list shows; `message` says how to fix
|
|
53
|
-
// it.
|
|
54
|
-
|
|
53
|
+
// it. Draft warnings must name their resolution UI at the owner, not in a
|
|
54
|
+
// separate warning-code inventory. The invoice page dispatches this intent.
|
|
55
|
+
const problem = (code, message, short, resolution, extra = {}) => {
|
|
56
|
+
if (typeof resolution?.kind !== 'string' || !resolution.kind) throw new FlowError('warning_resolution_required', `Draft warning ${code} needs a resolution UI.`);
|
|
57
|
+
return { code, message, ...(short ? { short } : {}), ...extra, resolution };
|
|
58
|
+
};
|
|
55
59
|
|
|
56
60
|
// An issued invoice the accounting system refused. A refusal stored without
|
|
57
61
|
// its few words (before 2026-10-28) shows "Not sent"; its message stays.
|
|
58
|
-
const refusal = (sendError) => (sendError ? { short: 'Not sent', ...sendError } :
|
|
62
|
+
const refusal = (sendError) => (sendError ? { short: 'Not sent', ...sendError } : { code: 'send_failed', message: 'Sending failed.', short: 'Not sent' });
|
|
59
63
|
|
|
60
64
|
// Flow's own checks of one draft. `lines` are its invoice lines.
|
|
61
65
|
export function draftProblems(invoice, lines) {
|
|
62
66
|
const problems = [];
|
|
63
|
-
if (!lines.length) problems.push(problem('no_lines', 'The invoice has no lines.', 'No lines'));
|
|
64
|
-
else if (invoice.totalCents <= 0) problems.push(problem('no_amount', 'The total is $0.00 or less.', 'Total $0'));
|
|
67
|
+
if (!lines.length) problems.push(problem('no_lines', 'The invoice has no lines.', 'No lines', { kind: 'invoice_edit' }));
|
|
68
|
+
else if (invoice.totalCents <= 0) problems.push(problem('no_amount', 'The total is $0.00 or less.', 'Total $0', { kind: 'invoice_edit' }));
|
|
65
69
|
for (const line of lines) {
|
|
66
|
-
if (!line.priceItemId && !line.accountCode) problems.push(problem('line_no_item', `Line ${line.position} has no price item.`, `Line ${line.position}: no item`, { fields: ['line_item'], line: line.position }));
|
|
70
|
+
if (!line.priceItemId && !line.accountCode) problems.push(problem('line_no_item', `Line ${line.position} has no price item.`, `Line ${line.position}: no item`, { kind: 'invoice_edit' }, { fields: ['line_item'], line: line.position }));
|
|
67
71
|
}
|
|
68
72
|
return problems;
|
|
69
73
|
}
|
|
@@ -116,10 +120,18 @@ async function checkDrafts(db, organisationId, drafts, accounting) {
|
|
|
116
120
|
}
|
|
117
121
|
// A draft whose job the ledger sent to review (the job changed after the
|
|
118
122
|
// draft was made, or the work system deleted it) waits for a person.
|
|
123
|
+
for (const { obligation } of jobs) {
|
|
124
|
+
for (const reason of sourceBillingProblems(obligation)) {
|
|
125
|
+
const found = { ...problem('job_review', 'Check the source invoice: ' + reason + '.', 'Source tax', { kind: 'job_review' }), sourceBilling: true };
|
|
126
|
+
problems.set(obligation.invoiceId, [...(problems.get(obligation.invoiceId) ?? []), found]);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
119
129
|
const reviewed = jobs.map((j) => j.obligation).filter((o) => o.holdState === 'review');
|
|
120
130
|
for (const obligation of reviewed) {
|
|
121
131
|
const { invoiceId, holdReason: reason } = obligation;
|
|
122
|
-
const
|
|
132
|
+
const resolution = reason === CHANGED_REASON ? { kind: 'job_review' }
|
|
133
|
+
: { kind: 'job_hold', reason: reason ?? 'the job is review' };
|
|
134
|
+
const found = problem('job_review', `The job needs a look before this invoice goes: ${reason ?? 'it was sent to review'}.`, reviewShort(obligation), resolution);
|
|
123
135
|
problems.set(invoiceId, [...(problems.get(invoiceId) ?? []), found]);
|
|
124
136
|
}
|
|
125
137
|
if (accounting) {
|
|
@@ -439,7 +451,8 @@ export async function sendQueued(db, organisationId, { accounting, limit = WORKE
|
|
|
439
451
|
summary.waiting += 1;
|
|
440
452
|
} else {
|
|
441
453
|
const error = result.error ?? { code: `send_${result.outcome}`, message: `Sending ended as ${result.outcome}.` };
|
|
442
|
-
|
|
454
|
+
const xero = xeroMatch(error);
|
|
455
|
+
await settle(db, organisationId, result.invoiceId, { sendState: 'failed', sendError: { code: error.code, message: error.message, ...(error.short ? { short: error.short } : {}), ...(xero ? { xero } : {}) } });
|
|
443
456
|
summary.failed += 1;
|
|
444
457
|
summary.refused = [...new Set([...(summary.refused ?? []), error.code])];
|
|
445
458
|
const fact = refusalFact(known.get(result.invoiceId), error);
|
|
@@ -450,6 +463,17 @@ export async function sendQueued(db, organisationId, { accounting, limit = WORKE
|
|
|
450
463
|
return summary;
|
|
451
464
|
}
|
|
452
465
|
|
|
466
|
+
// The Xero invoice a refusal matched, kept so the page can link to it.
|
|
467
|
+
// Only an id, a number and a status. Anything else in the error stays out.
|
|
468
|
+
function xeroMatch(error) {
|
|
469
|
+
const xero = error?.details?.xero;
|
|
470
|
+
if (!xero || typeof xero !== 'object') return null;
|
|
471
|
+
if (!INVOICE_ID.test(xero.invoiceId ?? '')) return null;
|
|
472
|
+
const number = INVOICE_NUMBER.test(xero.number ?? '') ? xero.number : null;
|
|
473
|
+
const status = /^[A-Z0-9_]{1,32}$/.test(xero.status ?? '') ? xero.status : null;
|
|
474
|
+
return { invoiceId: xero.invoiceId, ...(number ? { number } : {}), ...(status ? { status } : {}) };
|
|
475
|
+
}
|
|
476
|
+
|
|
453
477
|
function settle(db, organisationId, invoiceId, fields) {
|
|
454
478
|
return db.update(invoices).set({ ...fields, sendUpdatedAt: sql`now()` })
|
|
455
479
|
.where(and(eq(invoices.organisationId, organisationId), eq(invoices.id, invoiceId), eq(invoices.sendState, 'queued')));
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ALTER TABLE "billing_obligations" ADD COLUMN IF NOT EXISTS "source_totals" jsonb;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS "agent_audit" (
|
|
2
|
+
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
|
3
|
+
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
4
|
+
"grant_id" uuid NOT NULL,
|
|
5
|
+
"person_id" uuid NOT NULL,
|
|
6
|
+
"tool" text NOT NULL,
|
|
7
|
+
"status" integer NOT NULL,
|
|
8
|
+
"row_count" integer DEFAULT 0 NOT NULL,
|
|
9
|
+
"duration_ms" integer NOT NULL
|
|
10
|
+
);
|
|
11
|
+
--> statement-breakpoint
|
|
12
|
+
CREATE TABLE IF NOT EXISTS "agent_grants" (
|
|
13
|
+
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
|
14
|
+
"person_id" uuid NOT NULL,
|
|
15
|
+
"client_id" text NOT NULL,
|
|
16
|
+
"app_name" text NOT NULL,
|
|
17
|
+
"resource" text NOT NULL,
|
|
18
|
+
"organisation_id" uuid NOT NULL,
|
|
19
|
+
"access_level" text DEFAULT 'read' NOT NULL,
|
|
20
|
+
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
21
|
+
"last_used_at" timestamp with time zone,
|
|
22
|
+
"revoked_at" timestamp with time zone,
|
|
23
|
+
"failure_day" date,
|
|
24
|
+
"failure_count" integer DEFAULT 0 NOT NULL,
|
|
25
|
+
"paused_at" timestamp with time zone,
|
|
26
|
+
CONSTRAINT "agent_grants_person_client_resource" UNIQUE("person_id","client_id","resource"),
|
|
27
|
+
CONSTRAINT "agent_grants_read_only" CHECK (access_level = 'read')
|
|
28
|
+
);
|
|
29
|
+
--> statement-breakpoint
|
|
30
|
+
DO $flow_guard$ BEGIN
|
|
31
|
+
ALTER TABLE "agent_grants" ADD CONSTRAINT "agent_grants_person_id_people_id_fk" FOREIGN KEY ("person_id") REFERENCES "flow"."people"("id") ON DELETE cascade ON UPDATE no action;
|
|
32
|
+
EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
|
|
33
|
+
END $flow_guard$;--> statement-breakpoint
|
|
34
|
+
DO $flow_guard$ BEGIN
|
|
35
|
+
ALTER TABLE "agent_grants" ADD CONSTRAINT "agent_grants_organisation_id_organisations_id_fk" FOREIGN KEY ("organisation_id") REFERENCES "flow"."organisations"("id") ON DELETE no action ON UPDATE no action;
|
|
36
|
+
EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
|
|
37
|
+
END $flow_guard$;--> statement-breakpoint
|
|
38
|
+
CREATE INDEX IF NOT EXISTS "agent_audit_created" ON "agent_audit" USING btree ("created_at");
|