@arcforgelabs/flow 2026.10.32-beta.1 → 2026.10.32

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 (34) hide show
  1. package/npm-shrinkwrap.json +2 -2
  2. package/package.json +1 -1
  3. package/server/api/invoicing.js +16 -5
  4. package/server/core/automations.js +17 -3
  5. package/server/core/invoices.js +2 -2
  6. package/server/core/invoicing-rules.js +14 -1
  7. package/server/core/ledger.js +3 -2
  8. package/server/core/pipeline.js +1 -1
  9. package/server/core/references.js +99 -0
  10. package/server/core/sending.js +11 -5
  11. package/server/db/migrations/0016_invoice_reference.sql +1 -0
  12. package/server/db/migrations/meta/0016_snapshot.json +3861 -0
  13. package/server/db/migrations/meta/_journal.json +7 -0
  14. package/server/db/schema.js +2 -0
  15. package/server/integrations/port.js +1 -0
  16. package/server/integrations/seazona/feed.js +8 -1
  17. package/server/integrations/seazona/index.js +9 -0
  18. package/server/integrations/seazona/lanes.js +1 -1
  19. package/server/integrations/seazona/patients.js +30 -0
  20. package/server/integrations/xero/client.js +12 -0
  21. package/server/integrations/xero/import.js +9 -3
  22. package/server/integrations/xero/index.js +3 -1
  23. package/server/integrations/xero/migrations/0007_xero_webhooks.sql +30 -0
  24. package/server/integrations/xero/migrations/meta/_journal.json +7 -0
  25. package/server/integrations/xero/mirror.js +16 -8
  26. package/server/integrations/xero/routes.js +20 -0
  27. package/server/integrations/xero/settings.js +3 -0
  28. package/server/integrations/xero/webhooks.js +311 -0
  29. package/server/ui/app.js +15 -7
  30. package/server/ui/flow.css +47 -7
  31. package/server/ui/invoice-notes.js +26 -0
  32. package/server/ui/invoicing-settings.js +3 -1
  33. package/server/ui/invoicing.js +136 -57
  34. package/server/ui/routes.js +1 -1
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.32-beta.1",
3
+ "version": "2026.10.32",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@arcforgelabs/flow",
9
- "version": "2026.10.32-beta.1",
9
+ "version": "2026.10.32",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@clerk/backend": "3.23.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.32-beta.1",
3
+ "version": "2026.10.32",
4
4
  "description": "Invoicing, bill entry and a financial dashboard for labs, with Seazona and Xero integrations.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -13,9 +13,10 @@ import {
13
13
  } from '../core/line-mapping.js';
14
14
  import {
15
15
  effectiveSettings, INVOICING_DEFAULTS, readCustomerBilling, readInvoicingSettings,
16
- saveCustomerBilling, saveInvoicingSettings, sendModeSummary,
16
+ saveCustomerBilling, saveInvoicingSettings, sendModeFor, sendModeSummary,
17
17
  } from '../core/invoicing-rules.js';
18
18
  import { boardJobs, describeObligations } from '../core/job-board.js';
19
+ import { jobReference } from '../core/references.js';
19
20
  import {
20
21
  acceptFindings, archivePriceItem, checkPrices, createPriceItem, linkPriceItem, readCatalogues, readPriceList, unlinkPriceItem, updatePriceItem,
21
22
  } from '../core/price-list.js';
@@ -36,9 +37,9 @@ import {
36
37
  // POST /invoices/mark-ready { invoiceIds }: a person checked these drafts; one with a problem stays
37
38
  // POST /invoices/mark-not-ready { invoiceIds }: back to To check
38
39
  // POST /invoices/send-ready owner or admin: issue every ready draft and queue it for the
39
- // accounting system { issueDate }
40
+ // accounting system { issueDate, invoiceIds? (only these, each ready) }
40
41
  // POST /invoices/send-again owner or admin: queue failed sends again { invoiceIds? }
41
- // GET /invoices/:id one invoice: lines, contact, job, issued versions
42
+ // GET /invoices/:id one invoice: lines, contact, job, issued versions, and where it sends
42
43
  // GET /invoices/:id/pdf?version= the invoice as a PDF, drawn by Flow (server/core/invoice-pdf.js),
43
44
  // with the accounting system's number and dates once it holds it;
44
45
  // no accounting system calls
@@ -250,6 +251,11 @@ async function invoiceDetail(db, organisationId, invoiceId, accounting = null) {
250
251
  return {
251
252
  ...invoice,
252
253
  ...(await invoiceView(db, organisationId, invoice, { accounting })),
254
+ // Where a draft goes when it is sent, and how: this customer's send mode.
255
+ sendsTo: accounting?.name ?? null,
256
+ sendMode: accounting && invoice.status === 'draft'
257
+ ? { mode: await sendModeFor(db, organisationId, invoice.contactId), otherCustomers: 0, ...(await accounting.capabilities.sendStatus({ organisationId })) }
258
+ : null,
253
259
  contact: contact ?? null,
254
260
  job: jobs.get(invoiceId) ?? null,
255
261
  versions: versions.map(({ version, issuedAt, issuedBy }) => ({ version, issuedAt, issuedBy })),
@@ -348,7 +354,8 @@ export function createInvoicingRouter({
348
354
  router.post('/invoices/send-ready', ...manager, handle(log, async (req, res) => {
349
355
  const organisationId = await organisationOf(req);
350
356
  const issueDate = optionalDate(req.body?.issueDate, 'issueDate') ?? today();
351
- const result = await sendAllReady(db, organisationId, { accounting: accounting(), personId: req.person.id, issueDate });
357
+ const ids = req.body?.invoiceIds === undefined ? null : invoiceIdsOf(req.body);
358
+ const result = await sendAllReady(db, organisationId, { accounting: accounting(), personId: req.person.id, issueDate, invoiceIds: ids });
352
359
  log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} sent all ready: ${JSON.stringify(result)}`);
353
360
  res.json({ ok: true, ...result });
354
361
  }));
@@ -488,7 +495,11 @@ export function createInvoicingRouter({
488
495
  const organisationId = await organisationOf(req);
489
496
  // With no accounting system connected, Flow's own records are the check.
490
497
  const liveCheck = liveCheckFor(organisationId) ?? null;
491
- const { invoice } = await draftInvoiceForObligation(db, organisationId, obligationId, { liveCheck, actor: `person:${req.person.id}` });
498
+ const [obligation] = await db.select().from(billingObligations)
499
+ .where(and(eq(billingObligations.organisationId, organisationId), eq(billingObligations.id, obligationId)));
500
+ const { referenceTemplate: template } = await readInvoicingSettings(db, organisationId);
501
+ const reference = obligation ? await jobReference(db, organisationId, obligation, { template, work: work().find((a) => a.id === obligation.sourceSystem) ?? null }) : null;
502
+ const { invoice } = await draftInvoiceForObligation(db, organisationId, obligationId, { liveCheck, actor: `person:${req.person.id}`, reference });
492
503
  log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} drafted invoice ${invoice.id} from job ${obligationId}`);
493
504
  res.status(201).json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoice.id) });
494
505
  }));
@@ -1,9 +1,10 @@
1
1
  import { and, eq, sql } from 'drizzle-orm';
2
2
  import { automations, billingObligations, invoices } from '../db/schema.js';
3
3
  import { FlowError, notFound } from './errors.js';
4
- import { autoDraftJobs } from './invoicing-rules.js';
4
+ import { autoDraftJobs, readInvoicingSettings } from './invoicing-rules.js';
5
5
  import { MAPPING_DEFAULTS, readMappingConfig, remapJobs } from './line-mapping.js';
6
6
  import { setReady } from './ledger.js';
7
+ import { refreshReferences } from './references.js';
7
8
 
8
9
  // Automations (docs/AUTOMATIONS.md): Flow's routines. Each one watches Flow's
9
10
  // own records and moves them on, and each is on or off with its own settings,
@@ -19,6 +20,8 @@ import { setReady } from './ledger.js';
19
20
  // rules (invoicing-rules.js) gets a draft invoice in Flow.
20
21
  // ready_when_drafted: a draft the draft routine made goes straight to Ready
21
22
  // on the Invoices page, until a person changes it or moves it back.
23
+ // reference_from_rule: a draft made from a job keeps the Reference the
24
+ // lab's Reference rule gives (references.js), as the job's fields arrive.
22
25
 
23
26
  const BATCH = 500;
24
27
 
@@ -92,6 +95,14 @@ export const AUTOMATIONS = Object.freeze([
92
95
  defaults: { enabled: true, config: {} },
93
96
  readConfig: () => ({}),
94
97
  },
98
+ {
99
+ key: 'reference_from_rule',
100
+ place: 'invoicing',
101
+ name: 'Reference from the rule',
102
+ hint: 'A draft made from a job gets its Reference from the Reference rule. A Reference a person typed stays.',
103
+ defaults: { enabled: true, config: {} },
104
+ readConfig: () => ({}),
105
+ },
95
106
  ]);
96
107
 
97
108
  const byKey = new Map(AUTOMATIONS.map((a) => [a.key, a]));
@@ -169,16 +180,19 @@ async function readyWhenDrafted(db, organisationId) {
169
180
 
170
181
  // Runs every routine that is on. `accounting` is the integration whose copy
171
182
  // the draft routine checks; without one, Flow's own records are the check.
172
- export async function runAutomations(db, organisationId, { accounting = null, now = new Date() } = {}) {
183
+ export async function runAutomations(db, organisationId, { accounting = null, work = null, now = new Date() } = {}) {
173
184
  const summary = {};
174
185
  for (const automation of await listAutomations(db, organisationId)) {
175
186
  if (!automation.enabled) continue;
176
187
  if (automation.key === 'mark_ready_from_work_status') {
177
188
  summary[automation.key] = await markReadyFromWorkStatus(db, organisationId, automation.config);
178
189
  } else if (automation.key === 'draft_when_ready') {
179
- summary[automation.key] = await autoDraftJobs(db, organisationId, { accounting, now, actor: actorOf(automation.key) });
190
+ summary[automation.key] = await autoDraftJobs(db, organisationId, { accounting, work, now, actor: actorOf(automation.key) });
180
191
  } else if (automation.key === 'ready_when_drafted') {
181
192
  summary[automation.key] = await readyWhenDrafted(db, organisationId);
193
+ } else if (automation.key === 'reference_from_rule') {
194
+ const { referenceTemplate: template } = await readInvoicingSettings(db, organisationId);
195
+ summary[automation.key] = await refreshReferences(db, organisationId, { template, work });
182
196
  }
183
197
  }
184
198
  return summary;
@@ -98,8 +98,8 @@ export async function updateDraftInvoice(db, organisationId, invoiceId, changes)
98
98
  }
99
99
  if ('reference' in changes) fields.reference = changes.reference;
100
100
  if ('dueDate' in changes) fields.dueDate = changes.dueDate;
101
- // A changed draft needs a person to check it again.
102
- if (Object.keys(fields).length || 'lines' in changes) Object.assign(fields, { readyAt: null, readyBy: null });
101
+ // A saved change keeps the draft where it is. Flow's checks still run on
102
+ // it, so a change that breaks one puts the draft in Problem.
103
103
  let row = invoice;
104
104
  if (Object.keys(fields).length) {
105
105
  [row] = await tx.update(invoices).set({ ...fields, updatedAt: sql`now()` })
@@ -5,6 +5,7 @@ import {
5
5
  import { FlowError, notFound } from './errors.js';
6
6
  import { listRules, ruleReasons } from './invoice-rules.js';
7
7
  import { draftInvoiceForObligation } from './ledger.js';
8
+ import { checkReferenceTemplate, DEFAULT_REFERENCE, jobReference } from './references.js';
8
9
 
9
10
  // Invoicing rules (docs/INVOICING_RULES.md). They are the checks a job that
10
11
  // is ready to invoice passes before Flow drafts its invoice, and how a checked
@@ -30,6 +31,7 @@ export const INVOICING_DEFAULTS = Object.freeze({
30
31
  requirePractitioner: false,
31
32
  rushItemCode: null,
32
33
  rushPercent: 20,
34
+ referenceTemplate: DEFAULT_REFERENCE,
33
35
  });
34
36
 
35
37
  const CUSTOMER_DEFAULTS = Object.freeze({
@@ -58,6 +60,15 @@ function shortText(value, name) {
58
60
  return value.trim() || null;
59
61
  }
60
62
 
63
+ // An empty rule gives no Reference: the accounting system shows the number.
64
+ function referenceTemplate(value) {
65
+ if (value === null) return '';
66
+ if (typeof value !== 'string' || value.length > 200) throw invalid('referenceTemplate must be text of 200 characters at most');
67
+ const problem = checkReferenceTemplate(value);
68
+ if (problem) throw invalid(`referenceTemplate: ${problem}`);
69
+ return value.trim();
70
+ }
71
+
61
72
  function sendMode(value, name) {
62
73
  if (!SEND_MODES.includes(value)) throw invalid(`${name} must be one of ${SEND_MODES.join(', ')}`);
63
74
  return value;
@@ -79,6 +90,7 @@ function readSettingChanges(changes) {
79
90
  else if (key === 'paymentTermsDays') out[key] = wholeNumber(value, key, 0, 365);
80
91
  else if (key === 'rushPercent') out[key] = wholeNumber(value, key, 0, 100);
81
92
  else if (key === 'rushItemCode') out[key] = shortText(value, key);
93
+ else if (key === 'referenceTemplate') out[key] = referenceTemplate(value);
82
94
  else throw invalid(`unknown setting: ${key}`);
83
95
  }
84
96
  return out;
@@ -215,7 +227,7 @@ export function copyCheck(accounting, organisationId) {
215
227
  // The draft automation (automations.js) runs it. Answers counts.
216
228
  // With `accounting` null no accounting system is connected: Flow's own records
217
229
  // are the only check, as for a person's draft (ledger.js, ledgerView).
218
- export async function autoDraftJobs(db, organisationId, { accounting = null, now = new Date(), limit = DRAFT_BATCH, actor = ACTOR }) {
230
+ export async function autoDraftJobs(db, organisationId, { accounting = null, work = null, now = new Date(), limit = DRAFT_BATCH, actor = ACTOR }) {
219
231
  const result = { drafted: 0, waiting: 0, off: 0, refused: 0 };
220
232
  const settings = await readInvoicingSettings(db, organisationId);
221
233
  const rules = await listRules(db, organisationId);
@@ -255,6 +267,7 @@ export async function autoDraftJobs(db, organisationId, { accounting = null, now
255
267
  liveCheck,
256
268
  actor,
257
269
  now,
270
+ reference: await jobReference(db, organisationId, obligation, { template: settings.referenceTemplate, work }),
258
271
  dueDate: addDays(invoiceDate, effective.paymentTermsDays),
259
272
  });
260
273
  result.drafted += 1;
@@ -399,7 +399,7 @@ function draftBlockers(o, { now, sourceMaxAgeMs, accounting = true }) {
399
399
  // the write, finds no invoice for the job. Any doubt refuses the draft, and
400
400
  // the refusal is recorded.
401
401
  export async function draftInvoiceForObligation(db, organisationId, obligationId, {
402
- liveCheck, actor, now = new Date(), sourceMaxAgeMs = SOURCE_MAX_AGE_MS, dueDate = null,
402
+ liveCheck, actor, now = new Date(), sourceMaxAgeMs = SOURCE_MAX_AGE_MS, dueDate = null, reference = null,
403
403
  }) {
404
404
  requireActor(actor);
405
405
  // A null liveCheck says no accounting system is connected (ledgerView).
@@ -423,7 +423,8 @@ export async function draftInvoiceForObligation(db, organisationId, obligationId
423
423
  }
424
424
  const invoice = await createDraftInvoice(tx, organisationId, {
425
425
  contactId: obligation.contactId,
426
- reference: obligation.caseRef ?? obligation.sourceJobRef,
426
+ // The Reference rule's text (references.js), never a work system id.
427
+ reference,
427
428
  dueDate,
428
429
  lines: obligation.lines.map(({ priceItemId, accountCode, description, quantity, unitAmountCents, taxCode }) => (
429
430
  { priceItemId, accountCode, description, quantity, unitAmountCents, taxCode }
@@ -511,7 +511,7 @@ export async function runPipelineOnce(db, integrations, { now = new Date() } = {
511
511
  const settings = await readInvoicingSettings(db, organisationId);
512
512
  summary.matching = await matchJobs(db, organisationId, { accounting, now, staleDays: settings.staleDays });
513
513
  }
514
- if (work || accounting) summary.automations = await runAutomations(db, organisationId, { accounting: accounting ?? null, now });
514
+ if (work || accounting) summary.automations = await runAutomations(db, organisationId, { accounting: accounting ?? null, work: work ?? null, now });
515
515
  return summary;
516
516
  }
517
517
 
@@ -0,0 +1,99 @@
1
+ import { and, eq, inArray, isNotNull } from 'drizzle-orm';
2
+ import { billingObligations, invoices } from '../db/schema.js';
3
+
4
+ // The invoice Reference (docs/INVOICING_RULES.md, "Reference"). The lab writes it as a
5
+ // rule in Invoice rules, from tokens: {practitioner} is the job's
6
+ // practitioner, {patient} the patient name the work system gives (only when
7
+ // its integration keeps patient names). Flow
8
+ // never puts a work system's ids in the Reference: the customer reads it.
9
+
10
+ export const REFERENCE_TOKENS = Object.freeze(['practitioner', 'patient']);
11
+ export const DEFAULT_REFERENCE = '{practitioner} - {patient}';
12
+ const TOKEN = /\{([a-z]+)\}/g;
13
+ const MAX = 255;
14
+
15
+ // The template's parts: text and tokens, in order.
16
+ function parts(template) {
17
+ const out = [];
18
+ let at = 0;
19
+ for (const match of String(template ?? '').matchAll(TOKEN)) {
20
+ out.push({ text: template.slice(at, match.index) }, { token: match[1] });
21
+ at = match.index + match[0].length;
22
+ }
23
+ out.push({ text: String(template ?? '').slice(at) });
24
+ return out;
25
+ }
26
+
27
+ // Unknown tokens are refused, so a typing error shows when it is saved.
28
+ export function checkReferenceTemplate(template) {
29
+ const unknown = parts(template).filter((p) => p.token && !REFERENCE_TOKENS.includes(p.token)).map((p) => `{${p.token}}`);
30
+ return unknown.length ? `unknown token ${unknown[0]}: use ${REFERENCE_TOKENS.map((t) => `{${t}}`).join(', ')}` : null;
31
+ }
32
+
33
+ // The Reference for some fields. Text between two tokens is a spacer: it
34
+ // shows only when the tokens on both sides have a value, so
35
+ // "{practitioner} - {patient}" with no patient is the practitioner alone.
36
+ // Text before the first token and after the last always shows. Null when no
37
+ // token has a value.
38
+ export function renderReference(template, fields) {
39
+ const list = parts(template);
40
+ let out = list[0].text;
41
+ let filled = false;
42
+ for (let i = 1; i < list.length; i += 2) {
43
+ const value = String(fields[list[i].token] ?? '').trim().replace(/\s+/g, ' ');
44
+ if (!value) continue;
45
+ if (filled) out += list[i - 1].text;
46
+ out += value;
47
+ filled = true;
48
+ }
49
+ if (!filled) return null;
50
+ return `${out}${list.at(-1).text}`.trim().slice(0, MAX) || null;
51
+ }
52
+
53
+ // The fields of one job. `work` is the work system adapter; it gives the
54
+ // patient name when it keeps one. With `read`, it may ask the work system for
55
+ // a name it has not read yet.
56
+ export async function jobReferenceFields(db, organisationId, obligation, { work = null, read = false } = {}) {
57
+ let found = {};
58
+ if (work?.id === obligation.sourceSystem && typeof work.capabilities.jobReferenceFields === 'function') {
59
+ // A work system that cannot answer gives no name; the rule still works.
60
+ found = (await work.capabilities.jobReferenceFields({ organisationId, sourceJobRef: obligation.sourceJobRef, read }).catch(() => null)) ?? {};
61
+ }
62
+ return { fields: { practitioner: obligation.practitioner, patient: found.patient ?? null }, asked: Boolean(found.asked) };
63
+ }
64
+
65
+ // The Reference of a new draft for a job. `template` is the organisation's
66
+ // rule (invoicing settings, referenceTemplate).
67
+ export async function jobReference(db, organisationId, obligation, { template, ...options }) {
68
+ return renderReference(template, (await jobReferenceFields(db, organisationId, obligation, options)).fields);
69
+ }
70
+
71
+ // Drafts Flow made from a job keep the Reference the rule gives, as the rule
72
+ // or the job's fields change. A Reference a person typed is never changed:
73
+ // only one that is empty, a work system id (as earlier releases wrote), or
74
+ // the rule's own text without a field it now has, is updated. The status of
75
+ // the draft does not change. `reads` caps how many jobs may ask the work
76
+ // system for a name in one pass. Answers { updated }.
77
+ export async function refreshReferences(db, organisationId, { template, work = null, reads = 10 }) {
78
+ const rows = await db.select({ obligation: billingObligations, invoiceId: invoices.id, reference: invoices.reference })
79
+ .from(billingObligations)
80
+ .innerJoin(invoices, eq(invoices.id, billingObligations.invoiceId))
81
+ .where(and(eq(billingObligations.organisationId, organisationId), isNotNull(billingObligations.invoiceId), eq(invoices.status, 'draft')));
82
+ let updated = 0;
83
+ let asked = 0;
84
+ for (const { obligation, invoiceId, reference } of rows) {
85
+ const found = await jobReferenceFields(db, organisationId, obligation, { work, read: asked < reads });
86
+ const { fields } = found;
87
+ if (found.asked) asked += 1;
88
+ const wanted = renderReference(template, fields);
89
+ if ((wanted ?? null) === (reference ?? null)) continue;
90
+ const ours = [null, '', obligation.caseRef, obligation.sourceJobRef,
91
+ renderReference(template, { ...fields, patient: null }), renderReference(DEFAULT_REFERENCE, { ...fields, patient: null }),
92
+ renderReference(DEFAULT_REFERENCE, fields)];
93
+ if (!ours.includes(reference)) continue;
94
+ await db.update(invoices).set({ reference: wanted })
95
+ .where(and(eq(invoices.organisationId, organisationId), inArray(invoices.id, [invoiceId]), eq(invoices.status, 'draft')));
96
+ updated += 1;
97
+ }
98
+ return { updated };
99
+ }
@@ -13,8 +13,8 @@ import { deadHoldSince, deadSince, enqueueJob, runNextJob } from './queue.js';
13
13
  // charge, an account) on every line. When an accounting
14
14
  // system is connected, its own checks are added (for Xero: the customer
15
15
  // matches one Xero contact, every item is in Xero). A draft with a problem is
16
- // in Problem. A person marks a draft without one ready; any later change to
17
- // the draft sends it back to be checked again.
16
+ // in Problem. A person marks a draft without one ready. A saved change keeps
17
+ // it ready; only Move to Drafts (markNotReady) takes it back.
18
18
  //
19
19
  // Send all ready issues every ready draft (each gets its number) and, when an
20
20
  // accounting system is connected, queues it to go there. The send worker
@@ -147,10 +147,16 @@ async function assertCanSend(accounting, organisationId) {
147
147
  }
148
148
 
149
149
  // Send all ready: issues each ready draft, then queues it for the accounting
150
- // system. Answers { issued, queued, remaining }.
151
- export async function sendAllReady(db, organisationId, { accounting = null, personId, issueDate }) {
150
+ // system. With `invoiceIds`, only those drafts, and each must be ready (the
151
+ // Send button on one invoice). Answers { issued, queued, remaining }.
152
+ export async function sendAllReady(db, organisationId, { accounting = null, personId, issueDate, invoiceIds = null }) {
152
153
  await assertCanSend(accounting, organisationId);
153
- const { ready } = await invoiceViews(db, organisationId, { accounting });
154
+ let { ready } = await invoiceViews(db, organisationId, { accounting });
155
+ if (invoiceIds) {
156
+ const wanted = new Set(invoiceIds);
157
+ ready = ready.filter((id) => wanted.has(id));
158
+ if (ready.length !== wanted.size) throw new FlowError('not_ready', 'Only a ready invoice can be sent. Mark it ready first.');
159
+ }
154
160
  const batch = ready.slice(0, SEND_BATCH);
155
161
  let issued = 0;
156
162
  for (const invoiceId of batch) {
@@ -0,0 +1 @@
1
+ ALTER TABLE "invoicing_settings" ADD COLUMN IF NOT EXISTS "reference_template" text DEFAULT '{practitioner} - {patient}' NOT NULL;