@arcforgelabs/flow 2026.10.26 → 2026.10.29

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.
Files changed (63) hide show
  1. package/README.md +6 -0
  2. package/npm-shrinkwrap.json +627 -2
  3. package/package.json +3 -2
  4. package/server/api/invoicing.js +313 -46
  5. package/server/app.js +30 -2
  6. package/server/core/automations.js +154 -0
  7. package/server/core/invoice-rules.js +226 -0
  8. package/server/core/invoices.js +8 -3
  9. package/server/core/invoicing-rules.js +255 -0
  10. package/server/core/job-board.js +130 -0
  11. package/server/core/ledger.js +57 -17
  12. package/server/core/line-mapping.js +337 -0
  13. package/server/core/monitoring.js +34 -0
  14. package/server/core/pipeline.js +16 -3
  15. package/server/core/sending.js +283 -0
  16. package/server/db/migrate-all.js +26 -0
  17. package/server/db/migrations/0010_invoicing_rules.sql +72 -0
  18. package/server/db/migrations/0011_invoice_rules.sql +27 -0
  19. package/server/db/migrations/0012_invoice_sending.sql +18 -0
  20. package/server/db/migrations/0013_line_mappings.sql +35 -0
  21. package/server/db/migrations/meta/0010_snapshot.json +3158 -0
  22. package/server/db/migrations/meta/0011_snapshot.json +3314 -0
  23. package/server/db/migrations/meta/0012_snapshot.json +3380 -0
  24. package/server/db/migrations/meta/0013_snapshot.json +3568 -0
  25. package/server/db/migrations/meta/_journal.json +28 -0
  26. package/server/db/schema.js +158 -0
  27. package/server/import/at-start.js +64 -2
  28. package/server/index.js +17 -5
  29. package/server/integrations/adapters.js +2 -1
  30. package/server/integrations/port.js +6 -0
  31. package/server/integrations/seazona/feed.js +62 -4
  32. package/server/integrations/seazona/index.js +7 -1
  33. package/server/integrations/seazona/lanes.js +4 -1
  34. package/server/integrations/seazona/ledger.js +2 -0
  35. package/server/integrations/seazona/migrations/0000_api_governor.sql +3 -3
  36. package/server/integrations/seazona/migrations/0001_mirror.sql +9 -9
  37. package/server/integrations/sentry/index.js +105 -0
  38. package/server/integrations/xero/client.js +5 -0
  39. package/server/integrations/xero/drafts.js +127 -17
  40. package/server/integrations/xero/index.js +11 -1
  41. package/server/integrations/xero/lookup.js +6 -2
  42. package/server/integrations/xero/migrations/0000_xero_connections.sql +1 -1
  43. package/server/integrations/xero/migrations/0001_xero_catalogue.sql +5 -5
  44. package/server/integrations/xero/migrations/0002_xero_invoice_pushes.sql +2 -2
  45. package/server/integrations/xero/migrations/0003_xero_payment_cursors.sql +1 -1
  46. package/server/integrations/xero/migrations/0004_xero_mirror.sql +7 -7
  47. package/server/integrations/xero/migrations/0005_xero_send_modes.sql +16 -0
  48. package/server/integrations/xero/migrations/meta/_journal.json +7 -0
  49. package/server/ui/app-page.js +7 -4
  50. package/server/ui/app.js +122 -6
  51. package/server/ui/automations.js +96 -0
  52. package/server/ui/clerk-appearance.js +21 -0
  53. package/server/ui/flow.css +243 -30
  54. package/server/ui/invoice-rules.js +237 -0
  55. package/server/ui/invoicing-settings.js +110 -0
  56. package/server/ui/invoicing.js +166 -20
  57. package/server/ui/job-board.js +155 -0
  58. package/server/ui/jobs.js +232 -84
  59. package/server/ui/line-mappings.js +60 -0
  60. package/server/ui/logos/sentry.svg +1 -0
  61. package/server/ui/monitoring.js +61 -0
  62. package/server/ui/routes.js +2 -2
  63. package/server/ui/search.js +145 -0
@@ -0,0 +1,255 @@
1
+ import { and, eq, inArray, isNull, sql } from 'drizzle-orm';
2
+ import {
3
+ billingObligations, contacts, customerBillingSettings, invoicingSettings, SEND_MODES,
4
+ } from '../db/schema.js';
5
+ import { FlowError, notFound } from './errors.js';
6
+ import { listRules, ruleReasons } from './invoice-rules.js';
7
+ import { draftInvoiceForObligation } from './ledger.js';
8
+
9
+ // Invoicing rules (docs/INVOICING_RULES.md). They are the checks a job that
10
+ // is ready to invoice passes before Flow drafts its invoice, and how a checked
11
+ // draft goes to the accounting system. Each rule is a setting of the
12
+ // organisation; a customer can override some of them. The rules come from
13
+ // the earlier lab app, where they were fixed in its code. When Flow drafts is
14
+ // an automation (automations.js): Flow's own "ready to invoice" stage
15
+ // triggers it, never another system's status.
16
+ //
17
+ // The ledger's own checks (ledger.js, draftBlockers) still guard every draft:
18
+ // the job is not held, every line maps to a price item, it has a customer, the
19
+ // accounting copy holds no invoice for it, and the work system's report is
20
+ // recent. The lab's own checks are invoice rules it builds (invoice-rules.js);
21
+ // the old job limit, practitioner and rush settings here only seed them.
22
+
23
+ export const ACTOR = 'system:rules';
24
+ const DRAFT_BATCH = 200;
25
+
26
+ export const INVOICING_DEFAULTS = Object.freeze({
27
+ sendMode: 'accounting_draft',
28
+ staleDays: 90,
29
+ paymentTermsDays: 30,
30
+ requirePractitioner: false,
31
+ rushItemCode: null,
32
+ rushPercent: 20,
33
+ });
34
+
35
+ const CUSTOMER_DEFAULTS = Object.freeze({
36
+ billable: true, defaultPractitioner: null, autoDraft: null, sendMode: null, paymentTermsDays: null,
37
+ });
38
+
39
+ const pick = (row, keys) => Object.fromEntries(keys.map((k) => [k, row[k]]));
40
+ const SETTING_KEYS = Object.keys(INVOICING_DEFAULTS);
41
+ const CUSTOMER_KEYS = Object.keys(CUSTOMER_DEFAULTS);
42
+
43
+ const invalid = (message) => new FlowError('invalid', message);
44
+
45
+ function wholeNumber(value, name, min, max) {
46
+ if (!Number.isInteger(value) || value < min || value > max) throw invalid(`${name} must be a whole number from ${min} to ${max}`);
47
+ return value;
48
+ }
49
+
50
+ function flag(value, name) {
51
+ if (typeof value !== 'boolean') throw invalid(`${name} must be true or false`);
52
+ return value;
53
+ }
54
+
55
+ function shortText(value, name) {
56
+ if (value === null || value === '') return null;
57
+ if (typeof value !== 'string' || value.length > 200) throw invalid(`${name} must be text of 200 characters at most`);
58
+ return value.trim() || null;
59
+ }
60
+
61
+ function sendMode(value, name) {
62
+ if (!SEND_MODES.includes(value)) throw invalid(`${name} must be one of ${SEND_MODES.join(', ')}`);
63
+ return value;
64
+ }
65
+
66
+ // The organisation's settings as stored, or the defaults.
67
+ export async function readInvoicingSettings(db, organisationId) {
68
+ const [row] = await db.select().from(invoicingSettings).where(eq(invoicingSettings.organisationId, organisationId));
69
+ return row ? pick(row, SETTING_KEYS) : { ...INVOICING_DEFAULTS };
70
+ }
71
+
72
+ function readSettingChanges(changes) {
73
+ if (!changes || typeof changes !== 'object' || Array.isArray(changes)) throw invalid('the settings must be an object');
74
+ const out = {};
75
+ for (const [key, value] of Object.entries(changes)) {
76
+ if (key === 'requirePractitioner') out[key] = flag(value, key);
77
+ else if (key === 'sendMode') out[key] = sendMode(value, key);
78
+ else if (key === 'staleDays') out[key] = wholeNumber(value, key, 1, 3650);
79
+ else if (key === 'paymentTermsDays') out[key] = wholeNumber(value, key, 0, 365);
80
+ else if (key === 'rushPercent') out[key] = wholeNumber(value, key, 0, 100);
81
+ else if (key === 'rushItemCode') out[key] = shortText(value, key);
82
+ else throw invalid(`unknown setting: ${key}`);
83
+ }
84
+ return out;
85
+ }
86
+
87
+ export async function saveInvoicingSettings(db, organisationId, changes, { personId = null } = {}) {
88
+ const fields = readSettingChanges(changes);
89
+ await db.insert(invoicingSettings)
90
+ .values({ organisationId, ...fields, updatedBy: personId })
91
+ .onConflictDoUpdate({ target: invoicingSettings.organisationId, set: { ...fields, updatedBy: personId, updatedAt: sql`now()` } });
92
+ return readInvoicingSettings(db, organisationId);
93
+ }
94
+
95
+ async function requireCustomer(db, organisationId, contactId) {
96
+ const [contact] = await db.select({ id: contacts.id, kind: contacts.kind }).from(contacts)
97
+ .where(and(eq(contacts.organisationId, organisationId), eq(contacts.id, contactId)));
98
+ if (!contact) throw notFound('customer');
99
+ if (contact.kind !== 'customer') throw invalid('invoicing settings are for a customer contact');
100
+ }
101
+
102
+ // One customer's own settings. A null value takes the organisation's setting.
103
+ export async function readCustomerBilling(db, organisationId, contactId) {
104
+ await requireCustomer(db, organisationId, contactId);
105
+ const [row] = await db.select().from(customerBillingSettings)
106
+ .where(and(eq(customerBillingSettings.organisationId, organisationId), eq(customerBillingSettings.contactId, contactId)));
107
+ return row ? pick(row, CUSTOMER_KEYS) : { ...CUSTOMER_DEFAULTS };
108
+ }
109
+
110
+ export async function saveCustomerBilling(db, organisationId, contactId, changes, { personId = null } = {}) {
111
+ if (!changes || typeof changes !== 'object' || Array.isArray(changes)) throw invalid('the settings must be an object');
112
+ await requireCustomer(db, organisationId, contactId);
113
+ const fields = {};
114
+ for (const [key, value] of Object.entries(changes)) {
115
+ if (key === 'billable') fields[key] = flag(value, key);
116
+ else if (key === 'defaultPractitioner') fields[key] = shortText(value, key);
117
+ else if (key === 'autoDraft') fields[key] = value === null ? null : flag(value, key);
118
+ else if (key === 'sendMode') fields[key] = value === null ? null : sendMode(value, key);
119
+ else if (key === 'paymentTermsDays') fields[key] = value === null ? null : wholeNumber(value, key, 0, 365);
120
+ else throw invalid(`unknown setting: ${key}`);
121
+ }
122
+ await db.insert(customerBillingSettings)
123
+ .values({ organisationId, contactId, ...fields, updatedBy: personId })
124
+ .onConflictDoUpdate({
125
+ target: [customerBillingSettings.organisationId, customerBillingSettings.contactId],
126
+ set: { ...fields, updatedBy: personId, updatedAt: sql`now()` },
127
+ });
128
+ return readCustomerBilling(db, organisationId, contactId);
129
+ }
130
+
131
+ // The customers' own settings, by contact id.
132
+ export async function customerBillingByContact(db, organisationId, contactIds) {
133
+ const ids = [...new Set(contactIds.filter(Boolean))];
134
+ if (!ids.length) return new Map();
135
+ const rows = await db.select().from(customerBillingSettings)
136
+ .where(and(eq(customerBillingSettings.organisationId, organisationId), inArray(customerBillingSettings.contactId, ids)));
137
+ return new Map(rows.map((r) => [r.contactId, pick(r, CUSTOMER_KEYS)]));
138
+ }
139
+
140
+ // The settings that apply to one customer: its own, else the organisation's.
141
+ // `autoDraft` is the customer's own switch for the draft automation.
142
+ export function effectiveSettings(settings, customer = CUSTOMER_DEFAULTS) {
143
+ const own = { ...CUSTOMER_DEFAULTS, ...customer };
144
+ return {
145
+ ...settings,
146
+ billable: own.billable,
147
+ defaultPractitioner: own.defaultPractitioner,
148
+ autoDraft: own.autoDraft ?? true,
149
+ sendMode: own.sendMode ?? settings.sendMode,
150
+ paymentTermsDays: own.paymentTermsDays ?? settings.paymentTermsDays,
151
+ };
152
+ }
153
+
154
+ // How one customer's checked drafts go to the accounting system.
155
+ export async function sendModeFor(db, organisationId, contactId) {
156
+ const settings = await readInvoicingSettings(db, organisationId);
157
+ const customers = await customerBillingByContact(db, organisationId, [contactId]);
158
+ return effectiveSettings(settings, customers.get(contactId)).sendMode;
159
+ }
160
+
161
+ // What a job still waits on before Flow drafts it, as reasons a person can
162
+ // read: Flow's own ready stage, then the lab's invoice rules (invoice-rules.js)
163
+ // in their order. An empty list means the job may be drafted. `rules` is the
164
+ // organisation's rules, read once per run with listRules.
165
+ export function ruleBlockers(obligation, effective, { now = new Date(), rules = [] } = {}) {
166
+ const reasons = [];
167
+ if (!obligation.readyAt) reasons.push('the job is not marked ready to invoice');
168
+ return [...reasons, ...ruleReasons(rules, obligation, { customer: effective, now })];
169
+ }
170
+
171
+ function addDays(date, days) {
172
+ const at = new Date(`${date}T00:00:00Z`);
173
+ at.setUTCDate(at.getUTCDate() + days);
174
+ return at.toISOString().slice(0, 10);
175
+ }
176
+
177
+ const isoDay = (at) => `${at.getFullYear()}-${String(at.getMonth() + 1).padStart(2, '0')}-${String(at.getDate()).padStart(2, '0')}`;
178
+
179
+ // The ledger's live check, answered from the accounting system's copy in Flow
180
+ // instead of a call for each job: the copy is complete and was read lately,
181
+ // the matcher checked the job after the copy last changed, and the copy holds
182
+ // no invoice with the job's own invoice number.
183
+ export function copyCheck(accounting, organisationId) {
184
+ return async (obligation) => {
185
+ const { complete, fresh, changedAt, invoices } = await accounting.capabilities.findInvoices({
186
+ organisationId,
187
+ numbers: obligation.sourceInvoiceRef ? [obligation.sourceInvoiceRef] : [],
188
+ });
189
+ if (!complete || !fresh) throw new Error('the accounting copy is not complete or not fresh');
190
+ if (obligation.accountingState !== 'not_found') return { state: obligation.accountingState, matchedBy: 'copy' };
191
+ if (changedAt && (!obligation.accountingCheckedAt || new Date(obligation.accountingCheckedAt) < changedAt)) {
192
+ throw new Error('the accounting copy changed after the job was last matched');
193
+ }
194
+ const sales = invoices.filter((i) => !['voided', 'deleted'].includes(i.state));
195
+ if (sales.length) return { state: sales[0].state, accountingRef: sales[0].id, matchedBy: 'copy' };
196
+ return { state: 'not_found', matchedBy: 'copy' };
197
+ };
198
+ }
199
+
200
+ // Drafts the invoice of each job that is ready to invoice and passes the
201
+ // ledger's checks and every rule, except for customers whose drafting is off.
202
+ // It writes only Flow's own records: the drafts wait in Flow for a person.
203
+ // The draft automation (automations.js) runs it. Answers counts.
204
+ // With `accounting` null no accounting system is connected: Flow's own records
205
+ // are the only check, as for a person's draft (ledger.js, ledgerView).
206
+ export async function autoDraftJobs(db, organisationId, { accounting = null, now = new Date(), limit = DRAFT_BATCH, actor = ACTOR }) {
207
+ const result = { drafted: 0, waiting: 0, off: 0, refused: 0 };
208
+ const settings = await readInvoicingSettings(db, organisationId);
209
+ const rules = await listRules(db, organisationId);
210
+ if (accounting) {
211
+ const mirror = await accounting.capabilities.findInvoices({ organisationId });
212
+ if (!mirror.complete || !mirror.fresh) return { ...result, skipped: 'the accounting copy is not complete or not fresh' };
213
+ }
214
+
215
+ const candidates = await db.select().from(billingObligations).where(and(
216
+ eq(billingObligations.organisationId, organisationId),
217
+ isNull(billingObligations.invoiceId),
218
+ sql`${billingObligations.readyAt} is not null`,
219
+ eq(billingObligations.holdState, 'none'),
220
+ accounting
221
+ ? eq(billingObligations.accountingState, 'not_found')
222
+ : inArray(billingObligations.accountingState, ['not_found', 'unknown']),
223
+ eq(billingObligations.mappingComplete, true),
224
+ sql`${billingObligations.contactId} is not null`,
225
+ // Newest first: a backlog of jobs that wait on a rule never hides new work.
226
+ )).orderBy(sql`${billingObligations.jobDate} desc nulls last`, billingObligations.id).limit(limit);
227
+ const customers = await customerBillingByContact(db, organisationId, candidates.map((o) => o.contactId));
228
+ const liveCheck = accounting ? copyCheck(accounting, organisationId) : null;
229
+ const invoiceDate = isoDay(now);
230
+
231
+ for (const obligation of candidates) {
232
+ const effective = effectiveSettings(settings, customers.get(obligation.contactId));
233
+ if (!effective.autoDraft) {
234
+ result.off += 1;
235
+ continue;
236
+ }
237
+ if (ruleBlockers(obligation, effective, { now, rules }).length) {
238
+ result.waiting += 1;
239
+ continue;
240
+ }
241
+ try {
242
+ await draftInvoiceForObligation(db, organisationId, obligation.id, {
243
+ liveCheck,
244
+ actor,
245
+ now,
246
+ dueDate: addDays(invoiceDate, effective.paymentTermsDays),
247
+ });
248
+ result.drafted += 1;
249
+ } catch (error) {
250
+ if (error.code !== 'not_ready') throw error;
251
+ result.refused += 1;
252
+ }
253
+ }
254
+ return result;
255
+ }
@@ -0,0 +1,130 @@
1
+ import { and, eq, inArray, isNull } from 'drizzle-orm';
2
+ import { contacts, invoices, priceItems } from '../db/schema.js';
3
+ import { listRules } from './invoice-rules.js';
4
+ import { customerBillingByContact, effectiveSettings, readInvoicingSettings, ruleBlockers } from './invoicing-rules.js';
5
+ import { listObligations } from './ledger.js';
6
+ import { invoiceViews } from './sending.js';
7
+
8
+ // The Jobs board (/jobs, server/ui/jobs.js). Its columns are the work
9
+ // system's own job statuses, one to one. Its jobs come from the work
10
+ // system's copy in Flow (the port's listWork: Seazona's open orders and the
11
+ // ones finished lately), so jobs still in the lab show. A job that has a
12
+ // billing obligation (ledger.js) carries it, with the state of its invoice,
13
+ // for the job's detail panel. With no work system that offers listWork, the
14
+ // board shows the ledger's jobs by the status the work system last reported.
15
+
16
+ // The ledger's jobs with what the Jobs page shows: the customer's name, the
17
+ // invoice's number, status, Invoices page view (sending.js) and problems,
18
+ // and the invoicing rules a job with no invoice waits on. `listed` is
19
+ // listObligations output. `accounting` is the accounting adapter, or null.
20
+ export async function describeObligations(db, organisationId, listed, { accounting = null } = {}) {
21
+ const contactIds = [...new Set(listed.map((e) => e.obligation.contactId).filter(Boolean))];
22
+ const invoiceIds = [...new Set(listed.map((e) => e.obligation.invoiceId).filter(Boolean))];
23
+ const [names, numbers, views] = await Promise.all([
24
+ contactIds.length
25
+ ? db.select({ id: contacts.id, name: contacts.name }).from(contacts)
26
+ .where(and(eq(contacts.organisationId, organisationId), inArray(contacts.id, contactIds)))
27
+ : [],
28
+ invoiceIds.length
29
+ ? db.select({ id: invoices.id, number: invoices.number, status: invoices.status }).from(invoices)
30
+ .where(and(eq(invoices.organisationId, organisationId), inArray(invoices.id, invoiceIds)))
31
+ : [],
32
+ invoiceIds.length ? invoiceViews(db, organisationId, { accounting }) : null,
33
+ ]);
34
+ const nameOf = new Map(names.map((c) => [c.id, c.name]));
35
+ const invoiceOf = new Map(numbers.map((i) => [i.id, i]));
36
+ const settings = await readInvoicingSettings(db, organisationId);
37
+ const customers = await customerBillingByContact(db, organisationId, contactIds);
38
+ const rules = await listRules(db, organisationId);
39
+ const ready = new Set(views?.ready ?? []);
40
+ const toCheck = new Set(views?.toCheck ?? []);
41
+ const invoiceView = (id) => (!id || !views ? null
42
+ : views.problems.has(id) ? 'problem' : ready.has(id) ? 'ready' : toCheck.has(id) ? 'to_check'
43
+ : views.sending.has(id) ? 'sending' : null);
44
+ // The invoicing rules a job with no invoice waits on, for every view a
45
+ // draft can still come from.
46
+ const waitingFor = (o, v) => (o.invoiceId || !['in_work', 'ready', 'pending', 'held'].includes(v)
47
+ ? [] : ruleBlockers(o, effectiveSettings(settings, customers.get(o.contactId)), { rules }));
48
+ return listed.map(({ obligation: { lineFingerprint: _fingerprint, organisationId: _org, ...o }, view, reasons }) => ({
49
+ ...o,
50
+ contactName: nameOf.get(o.contactId) ?? null,
51
+ invoiceNumber: invoiceOf.get(o.invoiceId)?.number ?? null,
52
+ invoiceStatus: invoiceOf.get(o.invoiceId)?.status ?? null,
53
+ invoiceView: invoiceView(o.invoiceId),
54
+ invoiceProblems: (o.invoiceId && views?.problems.get(o.invoiceId)) || [],
55
+ view,
56
+ reasons,
57
+ waitingFor: waitingFor(o, view),
58
+ }));
59
+ }
60
+
61
+ const keyOf = (system, ref) => `${system}\u0000${ref}`;
62
+
63
+ // The ledger's lines as the board shows them: whether each maps to a price item.
64
+ const ledgerLines = (lines) => (lines ?? []).map((l) => ({
65
+ code: l.itemCode ?? null, description: l.description, amountCents: l.lineAmountCents ?? null, mapped: Boolean(l.priceItemId),
66
+ }));
67
+
68
+ // Answers { jobs, statuses }. `work` is the adapters that offer listWork;
69
+ // `live` says an accounting system checks the ledger (ledgerView).
70
+ export async function boardJobs(db, organisationId, { work = [], accounting = null, live = false, log = console } = {}) {
71
+ const listed = await listObligations(db, organisationId, { accounting: live });
72
+ const obligations = await describeObligations(db, organisationId, listed, { accounting });
73
+ const byJob = new Map(obligations.map((o) => [keyOf(o.sourceSystem, o.sourceJobRef), o]));
74
+
75
+ const reports = [];
76
+ for (const adapter of work) {
77
+ try {
78
+ reports.push(await adapter.capabilities.listWork({ organisationId }));
79
+ } catch (error) {
80
+ // The board still shows the ledger's jobs when the copy cannot be read.
81
+ log.error?.(`[jobs] ${adapter.id} listWork: ${error.message}`);
82
+ }
83
+ }
84
+ if (!reports.length) {
85
+ const statuses = new Map();
86
+ const jobs = obligations.map((o) => {
87
+ const status = o.sourceStatus?.trim() || null;
88
+ if (status && !statuses.has(status)) statuses.set(status, { label: status, final: false, firstSeenAt: null });
89
+ return {
90
+ sourceSystem: o.sourceSystem, sourceJobRef: o.sourceJobRef, caseRef: o.caseRef, status,
91
+ clientName: o.contactName, workClientName: null, practitioner: o.practitioner, department: null,
92
+ orderedOn: null, dueOn: null, jobDate: o.jobDate, shippedOn: o.shippedOn,
93
+ totalCents: o.totalCents, lines: ledgerLines(o.lines), obligation: o,
94
+ };
95
+ });
96
+ return { jobs, statuses: [...statuses.values()] };
97
+ }
98
+
99
+ const codes = new Set((await db.select({ code: priceItems.code }).from(priceItems)
100
+ .where(and(eq(priceItems.organisationId, organisationId), isNull(priceItems.archivedAt)))).map((p) => p.code));
101
+ const jobs = [];
102
+ const statuses = [];
103
+ for (const report of reports) {
104
+ statuses.push(...(report.statuses ?? []));
105
+ for (const item of report.items ?? []) {
106
+ const o = byJob.get(keyOf(item.sourceSystem, item.sourceJobRef)) ?? null;
107
+ jobs.push({
108
+ sourceSystem: item.sourceSystem,
109
+ sourceJobRef: item.sourceJobRef,
110
+ caseRef: item.caseRef ?? o?.caseRef ?? null,
111
+ status: item.status ?? null,
112
+ // The Flow customer once the work system's client is linked to one;
113
+ // until then the work system's own name for the client.
114
+ clientName: o?.contactName ?? item.clientName ?? null,
115
+ workClientName: item.clientName ?? null,
116
+ practitioner: item.practitioner ?? o?.practitioner ?? null,
117
+ department: item.department ?? null,
118
+ orderedOn: item.orderedOn ?? null,
119
+ dueOn: item.dueOn ?? null,
120
+ jobDate: o?.jobDate ?? null,
121
+ shippedOn: o?.shippedOn ?? null,
122
+ totalCents: o?.totalCents ?? item.totalCents ?? null,
123
+ lines: o ? ledgerLines(o.lines)
124
+ : (item.lines ?? []).map((l) => ({ code: l.code ?? null, description: l.description, amountCents: l.amountCents ?? null, mapped: Boolean(l.code && codes.has(l.code)) })),
125
+ obligation: o,
126
+ });
127
+ }
128
+ }
129
+ return { jobs, statuses };
130
+ }
@@ -8,8 +8,8 @@ import { createDraftInvoice } from './invoices.js';
8
8
  import { invoiceTotals, lineTotals } from './money.js';
9
9
 
10
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
11
+ // obligation. Its view (In work, Ready, Pending, Held, Drafted, Invoiced,
12
+ // Paid, Review required) comes only from the evidence stored on it, never from a fresh
13
13
  // guess, and each change of evidence is an event. To make a draft invoice is a
14
14
  // guarded transition that fails closed.
15
15
 
@@ -32,8 +32,11 @@ async function loadObligation(tx, organisationId, obligationId, { lock = false }
32
32
  return row;
33
33
  }
34
34
 
35
+ // clock_timestamp, not now(): events written in one transaction keep their
36
+ // order when they are read back by time.
35
37
  async function addEvent(tx, obligation, kind, actor, evidence = {}) {
36
38
  await tx.insert(obligationEvents).values({
39
+ createdAt: sql`clock_timestamp()`,
37
40
  organisationId: obligation.organisationId,
38
41
  obligationId: obligation.id,
39
42
  kind,
@@ -82,14 +85,21 @@ async function normaliseLines(tx, organisationId, lines) {
82
85
  } catch (error) {
83
86
  throw new FlowError('invalid', `line ${index + 1}: ${error.message}`);
84
87
  }
85
- normalised.push({ itemCode, priceItemId, description, quantity, unitAmountCents, taxCode, ...totals });
88
+ // A line mapped to an account (line-mapping.js) needs no price item.
89
+ const accountCode = priceItemId ? null : (line.accountCode?.trim() || null);
90
+ const extra = {
91
+ ...(accountCode ? { accountCode } : {}),
92
+ ...(line.label ? { label: line.label } : {}),
93
+ ...(line.mappedBy ? { mappedBy: line.mappedBy } : {}),
94
+ };
95
+ normalised.push({ itemCode, priceItemId, description, quantity, unitAmountCents, taxCode, ...extra, ...totals });
86
96
  }
87
97
  return normalised;
88
98
  }
89
99
 
90
100
  function fingerprint(lines) {
91
101
  const keys = lines
92
- .map((l) => [l.itemCode ?? l.description.toLowerCase(), l.quantity, l.unitAmountCents, l.taxCode])
102
+ .map((l) => [l.itemCode ?? l.accountCode ?? l.description.toLowerCase(), l.quantity, l.unitAmountCents, l.taxCode])
93
103
  .sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
94
104
  return createHash('sha256').update(JSON.stringify(keys)).digest('hex');
95
105
  }
@@ -110,10 +120,11 @@ export async function observeJob(db, organisationId, job) {
110
120
  practitioner: job.practitioner ?? null,
111
121
  shippedOn: job.shippedOn ?? null,
112
122
  jobDate: job.jobDate ?? null,
123
+ sourceStatus: job.sourceStatus ?? null,
113
124
  lines,
114
125
  lineFingerprint: fingerprint(lines),
115
126
  ...invoiceTotals(lines),
116
- mappingComplete: lines.every((l) => l.priceItemId),
127
+ mappingComplete: lines.every((l) => l.priceItemId || l.accountCode),
117
128
  };
118
129
  const [existing] = await tx.select().from(billingObligations)
119
130
  .where(and(
@@ -138,7 +149,9 @@ export async function observeJob(db, organisationId, job) {
138
149
  const changed = ['sourceInvoiceRef', 'caseRef', 'contactId', 'practitioner', 'shippedOn', 'lineFingerprint', 'totalCents']
139
150
  .filter((key) => String(existing[key] ?? '') !== String(facts[key] ?? ''));
140
151
  if (!changed.length) {
141
- return update(tx, existing, { sourceSeenAt: seenAt, mappingComplete: facts.mappingComplete, lines, jobDate: facts.jobDate });
152
+ return update(tx, existing, {
153
+ sourceSeenAt: seenAt, mappingComplete: facts.mappingComplete, lines, jobDate: facts.jobDate, sourceStatus: facts.sourceStatus,
154
+ });
142
155
  }
143
156
  const fields = { ...facts, sourceSeenAt: seenAt };
144
157
  if (existing.invoiceId && changed.some((key) => ['lineFingerprint', 'totalCents', 'contactId'].includes(key))) {
@@ -313,6 +326,20 @@ export async function recordWritebackResult(db, organisationId, writebackId, { o
313
326
  });
314
327
  }
315
328
 
329
+ // Marks a job ready to invoice, or back in work. `actor` is a person or the
330
+ // automation that decided. A job with an invoice stays as it is.
331
+ export async function setReady(db, organisationId, obligationId, { ready = true, actor }) {
332
+ requireActor(actor);
333
+ return db.transaction(async (tx) => {
334
+ const obligation = await loadObligation(tx, organisationId, obligationId, { lock: true });
335
+ if (Boolean(obligation.readyAt) === ready) return obligation;
336
+ if (!ready && obligation.invoiceId) throw new FlowError('invalid', 'the job has an invoice already');
337
+ const row = await update(tx, obligation, ready ? { readyAt: sql`now()`, readyBy: actor } : { readyAt: null, readyBy: null });
338
+ await addEvent(tx, row, ready ? 'ready' : 'in_work', actor, {});
339
+ return row;
340
+ });
341
+ }
342
+
316
343
  // Puts an obligation on hold, sends it to review, or releases it.
317
344
  export async function setHold(db, organisationId, obligationId, { holdState, reason = null, actor }) {
318
345
  requireActor(actor);
@@ -329,7 +356,9 @@ export async function setHold(db, organisationId, obligationId, { holdState, rea
329
356
  }
330
357
 
331
358
  // The obligation's view and the reasons for it, from its stored evidence only.
332
- export function ledgerView(obligation, { now = new Date(), sourceMaxAgeMs = SOURCE_MAX_AGE_MS } = {}) {
359
+ // `accounting` is false while no accounting system is connected: Flow's own
360
+ // records are then the only check, so a job nobody checked in one can draft.
361
+ export function ledgerView(obligation, { now = new Date(), sourceMaxAgeMs = SOURCE_MAX_AGE_MS, accounting = true } = {}) {
333
362
  const o = obligation;
334
363
  if (o.holdState === 'review') return { view: 'review_required', reasons: [o.holdReason ?? 'sent to review'] };
335
364
  if (['ambiguous', 'conflict'].includes(o.accountingState)) {
@@ -344,15 +373,20 @@ export function ledgerView(obligation, { now = new Date(), sourceMaxAgeMs = SOUR
344
373
  return { view: 'invoiced', reasons: [`invoice ${o.accountingRef} is ${o.accountingState.replace('_', ' ')}`] };
345
374
  }
346
375
  if (o.invoiceId || o.accountingState === 'drafted') return { view: 'drafted', reasons: ['a draft invoice exists'] };
347
- const reasons = draftBlockers(o, { now, sourceMaxAgeMs });
376
+ const reasons = draftBlockers(o, { now, sourceMaxAgeMs, accounting });
377
+ // Flow's own stage: a job is in work until a person or an automation marks
378
+ // it ready to invoice (setReady).
379
+ if (!o.readyAt) return { view: 'in_work', reasons: ['the job is not marked ready to invoice', ...reasons] };
348
380
  return reasons.length ? { view: 'pending', reasons } : { view: 'ready', reasons: ['all checks pass'] };
349
381
  }
350
382
 
351
- function draftBlockers(o, { now, sourceMaxAgeMs }) {
383
+ function draftBlockers(o, { now, sourceMaxAgeMs, accounting = true }) {
352
384
  const reasons = [];
353
385
  if (o.holdState !== 'none') reasons.push(`the job is ${o.holdState}`);
354
386
  if (o.invoiceId) reasons.push('the job already has an invoice');
355
- if (o.accountingState === 'unknown') reasons.push('the accounting system was not checked');
387
+ if (o.accountingState === 'unknown') {
388
+ if (accounting) reasons.push('the accounting system was not checked');
389
+ }
356
390
  else if (o.accountingState !== 'not_found') reasons.push(`the accounting system reports ${o.accountingState}`);
357
391
  if (!o.mappingComplete) reasons.push('a line does not map to a price item');
358
392
  if (!o.contactId) reasons.push('the job has no customer contact');
@@ -365,15 +399,17 @@ function draftBlockers(o, { now, sourceMaxAgeMs }) {
365
399
  // the write, finds no invoice for the job. Any doubt refuses the draft, and
366
400
  // the refusal is recorded.
367
401
  export async function draftInvoiceForObligation(db, organisationId, obligationId, {
368
- liveCheck, actor, now = new Date(), sourceMaxAgeMs = SOURCE_MAX_AGE_MS,
402
+ liveCheck, actor, now = new Date(), sourceMaxAgeMs = SOURCE_MAX_AGE_MS, dueDate = null,
369
403
  }) {
370
404
  requireActor(actor);
371
- if (typeof liveCheck !== 'function') throw new FlowError('invalid', 'liveCheck is required');
405
+ // A null liveCheck says no accounting system is connected (ledgerView).
406
+ if (liveCheck !== null && typeof liveCheck !== 'function') throw new FlowError('invalid', 'liveCheck must be a function, or null with no accounting system');
407
+ const accounting = liveCheck !== null;
372
408
  const outcome = await db.transaction(async (tx) => {
373
409
  const obligation = await loadObligation(tx, organisationId, obligationId, { lock: true });
374
- const reasons = draftBlockers(obligation, { now, sourceMaxAgeMs });
410
+ const reasons = draftBlockers(obligation, { now, sourceMaxAgeMs, accounting });
375
411
  let live = null;
376
- if (!reasons.length) {
412
+ if (!reasons.length && accounting) {
377
413
  try {
378
414
  live = await liveCheck(obligation);
379
415
  } catch (error) {
@@ -388,11 +424,15 @@ export async function draftInvoiceForObligation(db, organisationId, obligationId
388
424
  const invoice = await createDraftInvoice(tx, organisationId, {
389
425
  contactId: obligation.contactId,
390
426
  reference: obligation.caseRef ?? obligation.sourceJobRef,
391
- lines: obligation.lines.map(({ priceItemId, description, quantity, unitAmountCents, taxCode }) => (
392
- { priceItemId, description, quantity, unitAmountCents, taxCode }
427
+ dueDate,
428
+ lines: obligation.lines.map(({ priceItemId, accountCode, description, quantity, unitAmountCents, taxCode }) => (
429
+ { priceItemId, accountCode, description, quantity, unitAmountCents, taxCode }
393
430
  )),
394
431
  });
395
- const row = await update(tx, obligation, { invoiceId: invoice.id, accountingCheckedAt: now });
432
+ // Drafting the invoice says the job is ready, whoever asked.
433
+ const row = await update(tx, obligation, {
434
+ invoiceId: invoice.id, accountingCheckedAt: now, ...(obligation.readyAt ? {} : { readyAt: now, readyBy: actor }),
435
+ });
396
436
  await addEvent(tx, row, 'drafted', actor, { invoiceId: invoice.id, totalCents: invoice.totalCents, live });
397
437
  return { obligation: row, invoice };
398
438
  });