@arcforgelabs/flow 2026.10.18 → 2026.10.19-beta.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 +3 -0
- package/SECURITY.md +4 -1
- package/npm-shrinkwrap.json +32 -3
- package/package.json +3 -2
- package/server/app.js +26 -3
- package/server/auth/routes.js +27 -12
- package/server/auth/session.js +6 -3
- package/server/auth/settings.js +26 -7
- package/server/auth/sign-in.html +17 -6
- package/server/core/invoices.js +6 -3
- package/server/core/ledger.js +420 -0
- package/server/db/migrations/0003_billing_ledger.sql +84 -0
- package/server/db/migrations/0004_ledger_events_append_only.sql +5 -0
- package/server/db/migrations/meta/0003_snapshot.json +1327 -0
- package/server/db/migrations/meta/0004_snapshot.json +1327 -0
- package/server/db/migrations/meta/_journal.json +14 -0
- package/server/db/schema.js +137 -0
- package/server/import/earlier-flow.js +230 -0
- package/server/import-earlier-flow.js +66 -0
- package/server/index.js +16 -4
- package/server/integrations/adapters.js +6 -0
- package/server/integrations/health.js +21 -0
- package/server/integrations/index.js +26 -0
- package/server/integrations/port.js +154 -6
- package/server/integrations/remove-tables.js +31 -0
- package/server/integrations/schema.js +62 -0
- package/server/integrations/seazona/client.js +196 -0
- package/server/integrations/seazona/drizzle.config.js +9 -0
- package/server/integrations/seazona/feed.js +104 -0
- package/server/integrations/seazona/governor.js +174 -0
- package/server/integrations/seazona/index.js +84 -0
- package/server/integrations/seazona/lanes.js +431 -0
- package/server/integrations/seazona/ledger.js +66 -0
- package/server/integrations/seazona/map.js +229 -0
- package/server/integrations/seazona/migrations/0000_api_governor.sql +20 -0
- package/server/integrations/seazona/migrations/0001_mirror.sql +106 -0
- package/server/integrations/seazona/migrations/meta/0000_snapshot.json +149 -0
- package/server/integrations/seazona/migrations/meta/0001_snapshot.json +793 -0
- package/server/integrations/seazona/migrations/meta/_journal.json +20 -0
- package/server/integrations/seazona/replay.js +79 -0
- package/server/integrations/seazona/scheduler.js +78 -0
- package/server/integrations/seazona/schema.js +157 -0
- package/server/integrations/seazona/settings.js +161 -0
- package/server/integrations/seazona/store.js +219 -0
- package/server/integrations/seazona/sync-once.js +44 -0
- package/server/integrations/seazona/tables.js +17 -0
- package/server/integrations/xero/catalogue.js +184 -0
- package/server/integrations/xero/client.js +253 -0
- package/server/integrations/xero/connections.js +104 -0
- package/server/integrations/xero/drafts.js +525 -0
- package/server/integrations/xero/fixtures/replay.json +45 -0
- package/server/integrations/xero/governor.js +275 -0
- package/server/integrations/xero/index.js +56 -0
- package/server/integrations/xero/ledger.js +269 -0
- package/server/integrations/xero/migrations/0000_xero_connections.sql +17 -0
- package/server/integrations/xero/migrations/0001_xero_catalogue.sql +49 -0
- package/server/integrations/xero/migrations/0002_xero_invoice_pushes.sql +32 -0
- package/server/integrations/xero/migrations/0003_xero_payment_cursors.sql +7 -0
- package/server/integrations/xero/migrations/meta/_journal.json +34 -0
- package/server/integrations/xero/oauth.js +143 -0
- package/server/integrations/xero/replay.js +83 -0
- package/server/integrations/xero/routes.js +234 -0
- package/server/integrations/xero/runtime.js +135 -0
- package/server/integrations/xero/settings.js +170 -0
- package/server/rate-limit.js +13 -19
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm';
|
|
3
|
+
import {
|
|
4
|
+
billingObligations, obligationEvents, obligationPayments, obligationWritebacks, priceItems,
|
|
5
|
+
} from '../db/schema.js';
|
|
6
|
+
import { FlowError, notFound } from './errors.js';
|
|
7
|
+
import { createDraftInvoice } from './invoices.js';
|
|
8
|
+
import { invoiceTotals, lineTotals } from './money.js';
|
|
9
|
+
|
|
10
|
+
// The billing obligation ledger. One billable job in a work system is one
|
|
11
|
+
// obligation. Its view (Ready, Pending, Held, Drafted, Invoiced, Paid, Review
|
|
12
|
+
// required) comes only from the evidence stored on it, never from a fresh
|
|
13
|
+
// guess, and each change of evidence is an event. To make a draft invoice is a
|
|
14
|
+
// guarded transition that fails closed.
|
|
15
|
+
|
|
16
|
+
// How old the work system's report of a job may be when Flow drafts its invoice.
|
|
17
|
+
export const SOURCE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
18
|
+
|
|
19
|
+
// Ways to tie accounting evidence to a job that are exact, not a guess.
|
|
20
|
+
export const EXACT_MATCHES = ['flow_invoice', 'source_invoice_ref', 'accounting_ref'];
|
|
21
|
+
|
|
22
|
+
const LINKED_STATES = ['drafted', 'authorised', 'part_paid', 'paid', 'voided', 'deleted'];
|
|
23
|
+
|
|
24
|
+
async function loadObligation(tx, organisationId, obligationId, { lock = false } = {}) {
|
|
25
|
+
let query = tx.select().from(billingObligations)
|
|
26
|
+
.where(and(eq(billingObligations.organisationId, organisationId), eq(billingObligations.id, obligationId)));
|
|
27
|
+
if (lock) query = query.for('update');
|
|
28
|
+
const [row] = await query;
|
|
29
|
+
if (!row) throw notFound('obligation');
|
|
30
|
+
return row;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function addEvent(tx, obligation, kind, actor, evidence = {}) {
|
|
34
|
+
await tx.insert(obligationEvents).values({
|
|
35
|
+
organisationId: obligation.organisationId,
|
|
36
|
+
obligationId: obligation.id,
|
|
37
|
+
kind,
|
|
38
|
+
actor,
|
|
39
|
+
evidence,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function update(tx, obligation, fields) {
|
|
44
|
+
const [row] = await tx.update(billingObligations)
|
|
45
|
+
.set({ ...fields, updatedAt: sql`now()` })
|
|
46
|
+
.where(eq(billingObligations.id, obligation.id))
|
|
47
|
+
.returning();
|
|
48
|
+
return row;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function requireActor(actor) {
|
|
52
|
+
if (!actor) throw new FlowError('invalid', 'actor is required');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function normaliseLines(tx, organisationId, lines) {
|
|
56
|
+
if (!Array.isArray(lines) || !lines.length) throw new FlowError('invalid', 'a job needs at least one line');
|
|
57
|
+
const normalised = [];
|
|
58
|
+
for (const [index, line] of lines.entries()) {
|
|
59
|
+
const itemCode = line.itemCode?.trim() || null;
|
|
60
|
+
let priceItemId = line.priceItemId ?? null;
|
|
61
|
+
let { description, unitAmountCents, taxCode } = line;
|
|
62
|
+
// A line maps to a price item by its exact code only.
|
|
63
|
+
const [item] = priceItemId
|
|
64
|
+
? await tx.select().from(priceItems)
|
|
65
|
+
.where(and(eq(priceItems.organisationId, organisationId), eq(priceItems.id, priceItemId)))
|
|
66
|
+
: itemCode
|
|
67
|
+
? await tx.select().from(priceItems)
|
|
68
|
+
.where(and(eq(priceItems.organisationId, organisationId), eq(priceItems.code, itemCode)))
|
|
69
|
+
: [];
|
|
70
|
+
if (priceItemId && !item) throw new FlowError('invalid', `line ${index + 1}: price item not found`);
|
|
71
|
+
priceItemId = item?.archivedAt ? null : (item?.id ?? null);
|
|
72
|
+
description = (description ?? item?.name ?? '').trim();
|
|
73
|
+
unitAmountCents ??= item?.unitAmountCents;
|
|
74
|
+
taxCode ??= item?.taxCode ?? 'GST';
|
|
75
|
+
const quantity = line.quantity ?? 1;
|
|
76
|
+
if (!description) throw new FlowError('invalid', `line ${index + 1}: description is required`);
|
|
77
|
+
let totals;
|
|
78
|
+
try {
|
|
79
|
+
totals = lineTotals({ quantity, unitAmountCents, taxCode });
|
|
80
|
+
} catch (error) {
|
|
81
|
+
throw new FlowError('invalid', `line ${index + 1}: ${error.message}`);
|
|
82
|
+
}
|
|
83
|
+
normalised.push({ itemCode, priceItemId, description, quantity, unitAmountCents, taxCode, ...totals });
|
|
84
|
+
}
|
|
85
|
+
return normalised;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function fingerprint(lines) {
|
|
89
|
+
const keys = lines
|
|
90
|
+
.map((l) => [l.itemCode ?? l.description.toLowerCase(), l.quantity, l.unitAmountCents, l.taxCode])
|
|
91
|
+
.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
|
|
92
|
+
return createHash('sha256').update(JSON.stringify(keys)).digest('hex');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Records what the work system reports about a billable job. The first report
|
|
96
|
+
// makes the obligation; a later report with other lines or totals changes it.
|
|
97
|
+
// If the job changes after Flow made its invoice, the obligation goes to review.
|
|
98
|
+
export async function observeJob(db, organisationId, job) {
|
|
99
|
+
const { sourceSystem, sourceJobRef, actor = sourceSystem } = job;
|
|
100
|
+
if (!sourceSystem || !sourceJobRef) throw new FlowError('invalid', 'sourceSystem and sourceJobRef are required');
|
|
101
|
+
const seenAt = job.observedAt ? new Date(job.observedAt) : new Date();
|
|
102
|
+
return db.transaction(async (tx) => {
|
|
103
|
+
const lines = await normaliseLines(tx, organisationId, job.lines);
|
|
104
|
+
const facts = {
|
|
105
|
+
sourceInvoiceRef: job.sourceInvoiceRef ?? null,
|
|
106
|
+
caseRef: job.caseRef ?? null,
|
|
107
|
+
contactId: job.contactId ?? null,
|
|
108
|
+
practitioner: job.practitioner ?? null,
|
|
109
|
+
shippedOn: job.shippedOn ?? null,
|
|
110
|
+
lines,
|
|
111
|
+
lineFingerprint: fingerprint(lines),
|
|
112
|
+
...invoiceTotals(lines),
|
|
113
|
+
mappingComplete: lines.every((l) => l.priceItemId),
|
|
114
|
+
};
|
|
115
|
+
const [existing] = await tx.select().from(billingObligations)
|
|
116
|
+
.where(and(
|
|
117
|
+
eq(billingObligations.organisationId, organisationId),
|
|
118
|
+
eq(billingObligations.sourceSystem, sourceSystem),
|
|
119
|
+
eq(billingObligations.sourceJobRef, sourceJobRef),
|
|
120
|
+
))
|
|
121
|
+
.for('update');
|
|
122
|
+
|
|
123
|
+
if (!existing) {
|
|
124
|
+
const [row] = await tx.insert(billingObligations)
|
|
125
|
+
.values({ organisationId, sourceSystem, sourceJobRef, sourceSeenAt: seenAt, ...facts })
|
|
126
|
+
.returning();
|
|
127
|
+
await addEvent(tx, row, 'observed', actor, {
|
|
128
|
+
lineFingerprint: row.lineFingerprint, totalCents: row.totalCents, mappingComplete: row.mappingComplete,
|
|
129
|
+
});
|
|
130
|
+
return row;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// An older report never replaces a newer one.
|
|
134
|
+
if (seenAt < existing.sourceSeenAt) return existing;
|
|
135
|
+
const changed = ['sourceInvoiceRef', 'caseRef', 'contactId', 'practitioner', 'shippedOn', 'lineFingerprint', 'totalCents']
|
|
136
|
+
.filter((key) => String(existing[key] ?? '') !== String(facts[key] ?? ''));
|
|
137
|
+
if (!changed.length) {
|
|
138
|
+
return update(tx, existing, { sourceSeenAt: seenAt, mappingComplete: facts.mappingComplete, lines });
|
|
139
|
+
}
|
|
140
|
+
const fields = { ...facts, sourceSeenAt: seenAt };
|
|
141
|
+
if (existing.invoiceId && changed.some((key) => ['lineFingerprint', 'totalCents', 'contactId'].includes(key))) {
|
|
142
|
+
fields.holdState = 'review';
|
|
143
|
+
fields.holdReason = 'the job changed after its invoice was made';
|
|
144
|
+
}
|
|
145
|
+
const row = await update(tx, existing, fields);
|
|
146
|
+
await addEvent(tx, row, 'source_changed', actor, {
|
|
147
|
+
changed,
|
|
148
|
+
before: { lineFingerprint: existing.lineFingerprint, totalCents: existing.totalCents },
|
|
149
|
+
after: { lineFingerprint: row.lineFingerprint, totalCents: row.totalCents },
|
|
150
|
+
...(fields.holdState ? { holdReason: fields.holdReason } : {}),
|
|
151
|
+
});
|
|
152
|
+
return row;
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Records what the accounting system says about the job's invoice. Evidence
|
|
157
|
+
// that is not tied to the job by an exact match never counts as found: it
|
|
158
|
+
// makes the obligation ambiguous, for a person to resolve.
|
|
159
|
+
export async function recordAccountingEvidence(db, organisationId, obligationId, evidence) {
|
|
160
|
+
const { state, accountingRef = null, accountingUrl = null, matchedBy = null, actor } = evidence;
|
|
161
|
+
requireActor(actor);
|
|
162
|
+
if (!['not_found', ...LINKED_STATES].includes(state)) throw new FlowError('invalid', `unknown accounting state: ${state}`);
|
|
163
|
+
const checkedAt = evidence.checkedAt ? new Date(evidence.checkedAt) : new Date();
|
|
164
|
+
return db.transaction(async (tx) => {
|
|
165
|
+
const obligation = await loadObligation(tx, organisationId, obligationId, { lock: true });
|
|
166
|
+
let next = state;
|
|
167
|
+
let reason = null;
|
|
168
|
+
if (state !== 'not_found' && !EXACT_MATCHES.includes(matchedBy)) {
|
|
169
|
+
next = 'ambiguous';
|
|
170
|
+
reason = `matched by ${matchedBy ?? 'nothing'}, which is not an exact match`;
|
|
171
|
+
} else if (state !== 'not_found' && !accountingRef) {
|
|
172
|
+
throw new FlowError('invalid', 'accountingRef is required when an invoice was found');
|
|
173
|
+
} else if (obligation.accountingRef && accountingRef && accountingRef !== obligation.accountingRef) {
|
|
174
|
+
next = 'conflict';
|
|
175
|
+
reason = `a second invoice ${accountingRef} answers for this job`;
|
|
176
|
+
} else if (state === 'not_found' && obligation.accountingRef) {
|
|
177
|
+
// The invoice Flow saw before is gone from the search. That needs a person.
|
|
178
|
+
next = 'conflict';
|
|
179
|
+
reason = `invoice ${obligation.accountingRef} was seen before and is now not found`;
|
|
180
|
+
}
|
|
181
|
+
const fields = { accountingState: next, accountingCheckedAt: checkedAt };
|
|
182
|
+
if (next !== 'conflict' && next !== 'ambiguous' && state !== 'not_found') {
|
|
183
|
+
fields.accountingRef = accountingRef;
|
|
184
|
+
fields.accountingUrl = accountingUrl ?? obligation.accountingUrl;
|
|
185
|
+
fields.accountingMatchedBy = matchedBy;
|
|
186
|
+
}
|
|
187
|
+
const row = await update(tx, obligation, fields);
|
|
188
|
+
if (next !== obligation.accountingState || (fields.accountingRef && fields.accountingRef !== obligation.accountingRef)) {
|
|
189
|
+
await addEvent(tx, row, 'accounting_checked', actor, {
|
|
190
|
+
reported: state, state: next, accountingRef, accountingUrl, matchedBy, ...(reason ? { reason } : {}),
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
return row;
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Records one payment against the job's invoice. Only a payment tied to the
|
|
198
|
+
// job by an exact match is accepted. When the payments cover the total, the
|
|
199
|
+
// job is paid and a "paid" status, with its evidence, waits to go back to the
|
|
200
|
+
// work system. A part payment shows as part paid.
|
|
201
|
+
export async function recordPayment(db, organisationId, obligationId, payment) {
|
|
202
|
+
const { paymentRef, paidOn, amountCents, reference = null, reconciled = false, accountingUrl = null, matchedBy, actor } = payment;
|
|
203
|
+
requireActor(actor);
|
|
204
|
+
if (!EXACT_MATCHES.includes(matchedBy)) {
|
|
205
|
+
throw new FlowError('unmatched_payment', 'a payment counts only when it matches the job exactly', { matchedBy });
|
|
206
|
+
}
|
|
207
|
+
if (!paymentRef) throw new FlowError('invalid', 'paymentRef is required');
|
|
208
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(paidOn ?? '')) throw new FlowError('invalid', 'paidOn must be YYYY-MM-DD');
|
|
209
|
+
if (!Number.isInteger(amountCents) || amountCents <= 0) throw new FlowError('invalid', 'amountCents must be a positive whole number');
|
|
210
|
+
return db.transaction(async (tx) => {
|
|
211
|
+
const obligation = await loadObligation(tx, organisationId, obligationId, { lock: true });
|
|
212
|
+
if (!obligation.accountingRef || !['authorised', 'part_paid', 'paid'].includes(obligation.accountingState)) {
|
|
213
|
+
throw new FlowError('not_linked', 'the job has no confirmed invoice to take a payment', {
|
|
214
|
+
accountingState: obligation.accountingState,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
const inserted = await tx.insert(obligationPayments)
|
|
218
|
+
.values({
|
|
219
|
+
organisationId, obligationId, paymentRef, paidOn, amountCents, reference, reconciled, accountingUrl,
|
|
220
|
+
})
|
|
221
|
+
.onConflictDoNothing()
|
|
222
|
+
.returning();
|
|
223
|
+
if (!inserted.length) {
|
|
224
|
+
// The same payment again: keep the newer reconciliation flag only.
|
|
225
|
+
await tx.update(obligationPayments).set({ reconciled })
|
|
226
|
+
.where(and(
|
|
227
|
+
eq(obligationPayments.organisationId, organisationId),
|
|
228
|
+
eq(obligationPayments.obligationId, obligationId),
|
|
229
|
+
eq(obligationPayments.paymentRef, paymentRef),
|
|
230
|
+
));
|
|
231
|
+
return obligation;
|
|
232
|
+
}
|
|
233
|
+
const payments = await tx.select().from(obligationPayments)
|
|
234
|
+
.where(and(eq(obligationPayments.organisationId, organisationId), eq(obligationPayments.obligationId, obligationId)))
|
|
235
|
+
.orderBy(asc(obligationPayments.paidOn), asc(obligationPayments.paymentRef));
|
|
236
|
+
const paidCents = payments.reduce((sum, p) => sum + p.amountCents, 0);
|
|
237
|
+
const accountingState = paidCents >= obligation.totalCents ? 'paid' : 'part_paid';
|
|
238
|
+
const row = await update(tx, obligation, { paidCents, accountingState });
|
|
239
|
+
await addEvent(tx, row, 'payment_recorded', actor, {
|
|
240
|
+
paymentRef, paidOn, amountCents, reference, reconciled, matchedBy, paidCents, totalCents: row.totalCents,
|
|
241
|
+
});
|
|
242
|
+
if (accountingState === 'paid') {
|
|
243
|
+
const queued = await tx.insert(obligationWritebacks).values({
|
|
244
|
+
organisationId,
|
|
245
|
+
obligationId,
|
|
246
|
+
targetSystem: obligation.sourceSystem,
|
|
247
|
+
status: 'paid',
|
|
248
|
+
evidence: paidEvidence(row, payments),
|
|
249
|
+
}).onConflictDoNothing().returning();
|
|
250
|
+
if (queued.length) await addEvent(tx, row, 'paid', actor, { paidCents, totalCents: row.totalCents });
|
|
251
|
+
}
|
|
252
|
+
return row;
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function paidEvidence(obligation, payments) {
|
|
257
|
+
return {
|
|
258
|
+
sourceJobRef: obligation.sourceJobRef,
|
|
259
|
+
accountingRef: obligation.accountingRef,
|
|
260
|
+
accountingUrl: obligation.accountingUrl,
|
|
261
|
+
totalCents: obligation.totalCents,
|
|
262
|
+
paidCents: obligation.paidCents,
|
|
263
|
+
payments: payments.map((p) => ({
|
|
264
|
+
paymentRef: p.paymentRef,
|
|
265
|
+
paidOn: p.paidOn,
|
|
266
|
+
amountCents: p.amountCents,
|
|
267
|
+
reference: p.reference,
|
|
268
|
+
reconciled: p.reconciled,
|
|
269
|
+
accountingUrl: p.accountingUrl,
|
|
270
|
+
})),
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// The status write-backs that still have to reach a work system.
|
|
275
|
+
export async function listPendingWritebacks(db, organisationId, { targetSystem } = {}) {
|
|
276
|
+
const where = [
|
|
277
|
+
eq(obligationWritebacks.organisationId, organisationId),
|
|
278
|
+
inArray(obligationWritebacks.state, ['pending', 'failed']),
|
|
279
|
+
];
|
|
280
|
+
if (targetSystem) where.push(eq(obligationWritebacks.targetSystem, targetSystem));
|
|
281
|
+
return db.select().from(obligationWritebacks).where(and(...where)).orderBy(asc(obligationWritebacks.createdAt));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Records the result of one try to send a write-back.
|
|
285
|
+
export async function recordWritebackResult(db, organisationId, writebackId, { outcome, error = null, actor }) {
|
|
286
|
+
requireActor(actor);
|
|
287
|
+
if (!['sent', 'failed', 'not_supported'].includes(outcome)) throw new FlowError('invalid', `unknown outcome: ${outcome}`);
|
|
288
|
+
return db.transaction(async (tx) => {
|
|
289
|
+
const [writeback] = await tx.select().from(obligationWritebacks)
|
|
290
|
+
.where(and(eq(obligationWritebacks.organisationId, organisationId), eq(obligationWritebacks.id, writebackId)))
|
|
291
|
+
.for('update');
|
|
292
|
+
if (!writeback) throw notFound('write-back');
|
|
293
|
+
if (writeback.state === 'sent') return writeback;
|
|
294
|
+
const [row] = await tx.update(obligationWritebacks)
|
|
295
|
+
.set({
|
|
296
|
+
state: outcome,
|
|
297
|
+
attempts: sql`${obligationWritebacks.attempts} + 1`,
|
|
298
|
+
lastError: outcome === 'sent' ? null : error,
|
|
299
|
+
sentAt: outcome === 'sent' ? sql`now()` : null,
|
|
300
|
+
updatedAt: sql`now()`,
|
|
301
|
+
})
|
|
302
|
+
.where(eq(obligationWritebacks.id, writeback.id))
|
|
303
|
+
.returning();
|
|
304
|
+
const obligation = await loadObligation(tx, organisationId, writeback.obligationId);
|
|
305
|
+
await addEvent(tx, obligation, `writeback_${outcome}`, actor, {
|
|
306
|
+
writebackId: row.id, targetSystem: row.targetSystem, status: row.status, attempts: row.attempts,
|
|
307
|
+
...(error ? { error } : {}),
|
|
308
|
+
});
|
|
309
|
+
return row;
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Puts an obligation on hold, sends it to review, or releases it.
|
|
314
|
+
export async function setHold(db, organisationId, obligationId, { holdState, reason = null, actor }) {
|
|
315
|
+
requireActor(actor);
|
|
316
|
+
if (!['none', 'held', 'review'].includes(holdState)) throw new FlowError('invalid', `unknown hold state: ${holdState}`);
|
|
317
|
+
if (holdState !== 'none' && !reason?.trim()) throw new FlowError('invalid', 'a hold needs a reason');
|
|
318
|
+
return db.transaction(async (tx) => {
|
|
319
|
+
const obligation = await loadObligation(tx, organisationId, obligationId, { lock: true });
|
|
320
|
+
const row = await update(tx, obligation, { holdState, holdReason: holdState === 'none' ? null : reason.trim() });
|
|
321
|
+
await addEvent(tx, row, holdState === 'none' ? 'released' : holdState, actor, {
|
|
322
|
+
from: obligation.holdState, reason: row.holdReason,
|
|
323
|
+
});
|
|
324
|
+
return row;
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// The obligation's view and the reasons for it, from its stored evidence only.
|
|
329
|
+
export function ledgerView(obligation, { now = new Date(), sourceMaxAgeMs = SOURCE_MAX_AGE_MS } = {}) {
|
|
330
|
+
const o = obligation;
|
|
331
|
+
if (o.holdState === 'review') return { view: 'review_required', reasons: [o.holdReason ?? 'sent to review'] };
|
|
332
|
+
if (['ambiguous', 'conflict'].includes(o.accountingState)) {
|
|
333
|
+
return { view: 'review_required', reasons: [`the accounting evidence is ${o.accountingState}`] };
|
|
334
|
+
}
|
|
335
|
+
if (['voided', 'deleted'].includes(o.accountingState)) {
|
|
336
|
+
return { view: 'review_required', reasons: [`invoice ${o.accountingRef} was ${o.accountingState}`] };
|
|
337
|
+
}
|
|
338
|
+
if (o.holdState === 'held') return { view: 'held', reasons: [o.holdReason ?? 'held'] };
|
|
339
|
+
if (o.accountingState === 'paid') return { view: 'paid', reasons: [`invoice ${o.accountingRef} is paid`] };
|
|
340
|
+
if (['authorised', 'part_paid'].includes(o.accountingState)) {
|
|
341
|
+
return { view: 'invoiced', reasons: [`invoice ${o.accountingRef} is ${o.accountingState.replace('_', ' ')}`] };
|
|
342
|
+
}
|
|
343
|
+
if (o.invoiceId || o.accountingState === 'drafted') return { view: 'drafted', reasons: ['a draft invoice exists'] };
|
|
344
|
+
const reasons = draftBlockers(o, { now, sourceMaxAgeMs });
|
|
345
|
+
return reasons.length ? { view: 'pending', reasons } : { view: 'ready', reasons: ['all checks pass'] };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function draftBlockers(o, { now, sourceMaxAgeMs }) {
|
|
349
|
+
const reasons = [];
|
|
350
|
+
if (o.holdState !== 'none') reasons.push(`the job is ${o.holdState}`);
|
|
351
|
+
if (o.invoiceId) reasons.push('the job already has an invoice');
|
|
352
|
+
if (o.accountingState === 'unknown') reasons.push('the accounting system was not checked');
|
|
353
|
+
else if (o.accountingState !== 'not_found') reasons.push(`the accounting system reports ${o.accountingState}`);
|
|
354
|
+
if (!o.mappingComplete) reasons.push('a line does not map to a price item');
|
|
355
|
+
if (!o.contactId) reasons.push('the job has no customer contact');
|
|
356
|
+
if (now - new Date(o.sourceSeenAt) > sourceMaxAgeMs) reasons.push('the work system report is out of date');
|
|
357
|
+
return reasons;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// The guarded transition to a draft invoice. It runs only when the stored
|
|
361
|
+
// evidence allows it AND a live check in the accounting system, just before
|
|
362
|
+
// the write, finds no invoice for the job. Any doubt refuses the draft, and
|
|
363
|
+
// the refusal is recorded.
|
|
364
|
+
export async function draftInvoiceForObligation(db, organisationId, obligationId, {
|
|
365
|
+
liveCheck, actor, now = new Date(), sourceMaxAgeMs = SOURCE_MAX_AGE_MS,
|
|
366
|
+
}) {
|
|
367
|
+
requireActor(actor);
|
|
368
|
+
if (typeof liveCheck !== 'function') throw new FlowError('invalid', 'liveCheck is required');
|
|
369
|
+
const outcome = await db.transaction(async (tx) => {
|
|
370
|
+
const obligation = await loadObligation(tx, organisationId, obligationId, { lock: true });
|
|
371
|
+
const reasons = draftBlockers(obligation, { now, sourceMaxAgeMs });
|
|
372
|
+
let live = null;
|
|
373
|
+
if (!reasons.length) {
|
|
374
|
+
try {
|
|
375
|
+
live = await liveCheck(obligation);
|
|
376
|
+
} catch (error) {
|
|
377
|
+
reasons.push(`the live accounting check failed: ${error.message}`);
|
|
378
|
+
}
|
|
379
|
+
if (live && live.state !== 'not_found') reasons.push(`the live accounting check reports ${live.state ?? 'nothing'}`);
|
|
380
|
+
}
|
|
381
|
+
if (reasons.length) {
|
|
382
|
+
await addEvent(tx, obligation, 'draft_refused', actor, { reasons, live });
|
|
383
|
+
return { refused: reasons };
|
|
384
|
+
}
|
|
385
|
+
const invoice = await createDraftInvoice(tx, organisationId, {
|
|
386
|
+
contactId: obligation.contactId,
|
|
387
|
+
reference: obligation.caseRef ?? obligation.sourceJobRef,
|
|
388
|
+
lines: obligation.lines.map(({ priceItemId, description, quantity, unitAmountCents, taxCode }) => (
|
|
389
|
+
{ priceItemId, description, quantity, unitAmountCents, taxCode }
|
|
390
|
+
)),
|
|
391
|
+
});
|
|
392
|
+
const row = await update(tx, obligation, { invoiceId: invoice.id, accountingCheckedAt: now });
|
|
393
|
+
await addEvent(tx, row, 'drafted', actor, { invoiceId: invoice.id, totalCents: invoice.totalCents, live });
|
|
394
|
+
return { obligation: row, invoice };
|
|
395
|
+
});
|
|
396
|
+
if (outcome.refused) {
|
|
397
|
+
throw new FlowError('not_ready', 'the draft invoice was refused', { reasons: outcome.refused });
|
|
398
|
+
}
|
|
399
|
+
return outcome;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// One obligation with all its evidence and the reason for its state.
|
|
403
|
+
export async function explainObligation(db, organisationId, obligationId, options) {
|
|
404
|
+
const obligation = await loadObligation(db, organisationId, obligationId);
|
|
405
|
+
const scope = (table) => and(eq(table.organisationId, organisationId), eq(table.obligationId, obligationId));
|
|
406
|
+
const [events, payments, writebacks] = await Promise.all([
|
|
407
|
+
db.select().from(obligationEvents).where(scope(obligationEvents)).orderBy(asc(obligationEvents.createdAt)),
|
|
408
|
+
db.select().from(obligationPayments).where(scope(obligationPayments)).orderBy(asc(obligationPayments.paidOn)),
|
|
409
|
+
db.select().from(obligationWritebacks).where(scope(obligationWritebacks)).orderBy(asc(obligationWritebacks.createdAt)),
|
|
410
|
+
]);
|
|
411
|
+
return { obligation, ...ledgerView(obligation, options), events, payments, writebacks };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export async function listObligations(db, organisationId, { view, ...options } = {}) {
|
|
415
|
+
const rows = await db.select().from(billingObligations)
|
|
416
|
+
.where(eq(billingObligations.organisationId, organisationId))
|
|
417
|
+
.orderBy(desc(billingObligations.sourceSeenAt));
|
|
418
|
+
const listed = rows.map((obligation) => ({ obligation, ...ledgerView(obligation, options) }));
|
|
419
|
+
return view ? listed.filter((entry) => entry.view === view) : listed;
|
|
420
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
CREATE TABLE "billing_obligations" (
|
|
2
|
+
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
|
3
|
+
"organisation_id" uuid NOT NULL,
|
|
4
|
+
"source_system" text NOT NULL,
|
|
5
|
+
"source_job_ref" text NOT NULL,
|
|
6
|
+
"source_invoice_ref" text,
|
|
7
|
+
"case_ref" text,
|
|
8
|
+
"contact_id" uuid,
|
|
9
|
+
"practitioner" text,
|
|
10
|
+
"shipped_on" date,
|
|
11
|
+
"lines" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
|
12
|
+
"line_fingerprint" text NOT NULL,
|
|
13
|
+
"subtotal_cents" integer NOT NULL,
|
|
14
|
+
"tax_cents" integer NOT NULL,
|
|
15
|
+
"total_cents" integer NOT NULL,
|
|
16
|
+
"mapping_complete" boolean NOT NULL,
|
|
17
|
+
"source_seen_at" timestamp with time zone NOT NULL,
|
|
18
|
+
"hold_state" text DEFAULT 'none' NOT NULL,
|
|
19
|
+
"hold_reason" text,
|
|
20
|
+
"invoice_id" uuid,
|
|
21
|
+
"accounting_state" text DEFAULT 'unknown' NOT NULL,
|
|
22
|
+
"accounting_ref" text,
|
|
23
|
+
"accounting_url" text,
|
|
24
|
+
"accounting_matched_by" text,
|
|
25
|
+
"accounting_checked_at" timestamp with time zone,
|
|
26
|
+
"paid_cents" integer DEFAULT 0 NOT NULL,
|
|
27
|
+
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
28
|
+
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
29
|
+
CONSTRAINT "billing_obligations_org_id" UNIQUE("organisation_id","id"),
|
|
30
|
+
CONSTRAINT "billing_obligations_source_job" UNIQUE("organisation_id","source_system","source_job_ref"),
|
|
31
|
+
CONSTRAINT "billing_obligations_invoice" UNIQUE("organisation_id","invoice_id"),
|
|
32
|
+
CONSTRAINT "billing_obligations_hold_state_valid" CHECK (hold_state in ('none', 'held', 'review')),
|
|
33
|
+
CONSTRAINT "billing_obligations_accounting_state_valid" CHECK (accounting_state in ('unknown', 'not_found', 'drafted', 'authorised', 'part_paid', 'paid', 'voided', 'deleted', 'ambiguous', 'conflict')),
|
|
34
|
+
CONSTRAINT "billing_obligations_paid_not_negative" CHECK ("billing_obligations"."paid_cents" >= 0)
|
|
35
|
+
);
|
|
36
|
+
--> statement-breakpoint
|
|
37
|
+
CREATE TABLE "obligation_events" (
|
|
38
|
+
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
|
39
|
+
"organisation_id" uuid NOT NULL,
|
|
40
|
+
"obligation_id" uuid NOT NULL,
|
|
41
|
+
"kind" text NOT NULL,
|
|
42
|
+
"actor" text NOT NULL,
|
|
43
|
+
"evidence" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
|
44
|
+
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
|
45
|
+
);
|
|
46
|
+
--> statement-breakpoint
|
|
47
|
+
CREATE TABLE "obligation_payments" (
|
|
48
|
+
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
|
49
|
+
"organisation_id" uuid NOT NULL,
|
|
50
|
+
"obligation_id" uuid NOT NULL,
|
|
51
|
+
"payment_ref" text NOT NULL,
|
|
52
|
+
"paid_on" date NOT NULL,
|
|
53
|
+
"amount_cents" integer NOT NULL,
|
|
54
|
+
"reference" text,
|
|
55
|
+
"reconciled" boolean NOT NULL,
|
|
56
|
+
"accounting_url" text,
|
|
57
|
+
"observed_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
58
|
+
CONSTRAINT "obligation_payments_ref" UNIQUE("organisation_id","obligation_id","payment_ref"),
|
|
59
|
+
CONSTRAINT "obligation_payments_amount_positive" CHECK ("obligation_payments"."amount_cents" > 0)
|
|
60
|
+
);
|
|
61
|
+
--> statement-breakpoint
|
|
62
|
+
CREATE TABLE "obligation_writebacks" (
|
|
63
|
+
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
|
64
|
+
"organisation_id" uuid NOT NULL,
|
|
65
|
+
"obligation_id" uuid NOT NULL,
|
|
66
|
+
"target_system" text NOT NULL,
|
|
67
|
+
"status" text NOT NULL,
|
|
68
|
+
"state" text DEFAULT 'pending' NOT NULL,
|
|
69
|
+
"evidence" jsonb NOT NULL,
|
|
70
|
+
"attempts" integer DEFAULT 0 NOT NULL,
|
|
71
|
+
"last_error" text,
|
|
72
|
+
"sent_at" timestamp with time zone,
|
|
73
|
+
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
74
|
+
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
75
|
+
CONSTRAINT "obligation_writebacks_status" UNIQUE("organisation_id","obligation_id","status"),
|
|
76
|
+
CONSTRAINT "obligation_writebacks_state_valid" CHECK (state in ('pending', 'sent', 'failed', 'not_supported'))
|
|
77
|
+
);
|
|
78
|
+
--> statement-breakpoint
|
|
79
|
+
ALTER TABLE "billing_obligations" ADD CONSTRAINT "billing_obligations_organisation_id_organisations_id_fk" FOREIGN KEY ("organisation_id") REFERENCES "public"."organisations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
80
|
+
ALTER TABLE "billing_obligations" ADD CONSTRAINT "billing_obligations_contact_same_org" FOREIGN KEY ("organisation_id","contact_id") REFERENCES "public"."contacts"("organisation_id","id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
81
|
+
ALTER TABLE "billing_obligations" ADD CONSTRAINT "billing_obligations_invoice_same_org" FOREIGN KEY ("organisation_id","invoice_id") REFERENCES "public"."invoices"("organisation_id","id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
82
|
+
ALTER TABLE "obligation_events" ADD CONSTRAINT "obligation_events_obligation_same_org" FOREIGN KEY ("organisation_id","obligation_id") REFERENCES "public"."billing_obligations"("organisation_id","id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
83
|
+
ALTER TABLE "obligation_payments" ADD CONSTRAINT "obligation_payments_obligation_same_org" FOREIGN KEY ("organisation_id","obligation_id") REFERENCES "public"."billing_obligations"("organisation_id","id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
84
|
+
ALTER TABLE "obligation_writebacks" ADD CONSTRAINT "obligation_writebacks_obligation_same_org" FOREIGN KEY ("organisation_id","obligation_id") REFERENCES "public"."billing_obligations"("organisation_id","id") ON DELETE no action ON UPDATE no action;
|