@arcforgelabs/flow 2026.10.53 → 2026.11.1
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/app.js +9 -0
- package/server/core/ledger.js +90 -16
- package/server/core/line-mapping.js +2 -2
- package/server/core/read-before-act.js +2 -2
- package/server/core/reconcile.js +15 -1
- package/server/core/sending.js +20 -2
- 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 +74 -62
- package/server/ui/flow.css +36 -5
- package/server/ui/invoicing.js +34 -7
- 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
|
|
|
@@ -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 });
|
|
@@ -786,7 +857,10 @@ export function ledgerView(obligation, { now = new Date(), sourceMaxAgeMs = SOUR
|
|
|
786
857
|
if (['authorised', 'part_paid'].includes(o.accountingState)) {
|
|
787
858
|
return { view: 'invoiced', reasons: [`invoice ${o.accountingRef} is ${o.accountingState.replace('_', ' ')}`] };
|
|
788
859
|
}
|
|
860
|
+
// Existing invoices own their stage. sending.js still checks source conflicts
|
|
861
|
+
// before approval or Send, after the non-chargeable rules.
|
|
789
862
|
if (o.invoiceId || o.accountingState === 'drafted') return { view: 'drafted', reasons: ['a draft invoice exists'] };
|
|
863
|
+
if (sourceBillingProblems(o).length) return { view: 'review_required', reasons: draftBlockers(o, { now, sourceMaxAgeMs, accounting }) };
|
|
790
864
|
const reasons = draftBlockers(o, { now, sourceMaxAgeMs, accounting });
|
|
791
865
|
// Flow's own stage: a job is in work until a person or an automation marks
|
|
792
866
|
// it ready to invoice (setReady).
|
|
@@ -795,7 +869,7 @@ export function ledgerView(obligation, { now = new Date(), sourceMaxAgeMs = SOUR
|
|
|
795
869
|
}
|
|
796
870
|
|
|
797
871
|
function draftBlockers(o, { now, sourceMaxAgeMs, accounting = true }) {
|
|
798
|
-
const reasons =
|
|
872
|
+
const reasons = sourceBillingProblems(o);
|
|
799
873
|
if (o.holdState !== 'none') reasons.push(`the job is ${o.holdState}`);
|
|
800
874
|
if (o.invoiceId) reasons.push('the job already has an invoice');
|
|
801
875
|
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/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';
|
|
@@ -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;
|
|
@@ -439,7 +445,8 @@ export async function sendQueued(db, organisationId, { accounting, limit = WORKE
|
|
|
439
445
|
summary.waiting += 1;
|
|
440
446
|
} else {
|
|
441
447
|
const error = result.error ?? { code: `send_${result.outcome}`, message: `Sending ended as ${result.outcome}.` };
|
|
442
|
-
|
|
448
|
+
const xero = xeroMatch(error);
|
|
449
|
+
await settle(db, organisationId, result.invoiceId, { sendState: 'failed', sendError: { code: error.code, message: error.message, ...(error.short ? { short: error.short } : {}), ...(xero ? { xero } : {}) } });
|
|
443
450
|
summary.failed += 1;
|
|
444
451
|
summary.refused = [...new Set([...(summary.refused ?? []), error.code])];
|
|
445
452
|
const fact = refusalFact(known.get(result.invoiceId), error);
|
|
@@ -450,6 +457,17 @@ export async function sendQueued(db, organisationId, { accounting, limit = WORKE
|
|
|
450
457
|
return summary;
|
|
451
458
|
}
|
|
452
459
|
|
|
460
|
+
// The Xero invoice a refusal matched, kept so the page can link to it.
|
|
461
|
+
// Only an id, a number and a status. Anything else in the error stays out.
|
|
462
|
+
function xeroMatch(error) {
|
|
463
|
+
const xero = error?.details?.xero;
|
|
464
|
+
if (!xero || typeof xero !== 'object') return null;
|
|
465
|
+
if (!INVOICE_ID.test(xero.invoiceId ?? '')) return null;
|
|
466
|
+
const number = INVOICE_NUMBER.test(xero.number ?? '') ? xero.number : null;
|
|
467
|
+
const status = /^[A-Z0-9_]{1,32}$/.test(xero.status ?? '') ? xero.status : null;
|
|
468
|
+
return { invoiceId: xero.invoiceId, ...(number ? { number } : {}), ...(status ? { status } : {}) };
|
|
469
|
+
}
|
|
470
|
+
|
|
453
471
|
function settle(db, organisationId, invoiceId, fields) {
|
|
454
472
|
return db.update(invoices).set({ ...fields, sendUpdatedAt: sql`now()` })
|
|
455
473
|
.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");
|