@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.
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/api/invoicing.js +16 -5
- package/server/core/automations.js +17 -3
- package/server/core/invoices.js +2 -2
- package/server/core/invoicing-rules.js +14 -1
- package/server/core/ledger.js +3 -2
- package/server/core/pipeline.js +1 -1
- package/server/core/references.js +99 -0
- package/server/core/sending.js +11 -5
- package/server/db/migrations/0016_invoice_reference.sql +1 -0
- package/server/db/migrations/meta/0016_snapshot.json +3861 -0
- package/server/db/migrations/meta/_journal.json +7 -0
- package/server/db/schema.js +2 -0
- package/server/integrations/port.js +1 -0
- package/server/integrations/seazona/feed.js +8 -1
- package/server/integrations/seazona/index.js +9 -0
- package/server/integrations/seazona/lanes.js +1 -1
- package/server/integrations/seazona/patients.js +30 -0
- package/server/integrations/xero/client.js +12 -0
- package/server/integrations/xero/import.js +9 -3
- package/server/integrations/xero/index.js +3 -1
- package/server/integrations/xero/migrations/0007_xero_webhooks.sql +30 -0
- package/server/integrations/xero/migrations/meta/_journal.json +7 -0
- package/server/integrations/xero/mirror.js +16 -8
- package/server/integrations/xero/routes.js +20 -0
- package/server/integrations/xero/settings.js +3 -0
- package/server/integrations/xero/webhooks.js +311 -0
- package/server/ui/app.js +15 -7
- package/server/ui/flow.css +47 -7
- package/server/ui/invoice-notes.js +26 -0
- package/server/ui/invoicing-settings.js +3 -1
- package/server/ui/invoicing.js +136 -57
- package/server/ui/routes.js +1 -1
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcforgelabs/flow",
|
|
3
|
-
"version": "2026.10.32
|
|
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
|
|
9
|
+
"version": "2026.10.32",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@clerk/backend": "3.23.2",
|
package/package.json
CHANGED
package/server/api/invoicing.js
CHANGED
|
@@ -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
|
|
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
|
|
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;
|
package/server/core/invoices.js
CHANGED
|
@@ -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
|
|
102
|
-
|
|
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;
|
package/server/core/ledger.js
CHANGED
|
@@ -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
|
-
|
|
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 }
|
package/server/core/pipeline.js
CHANGED
|
@@ -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
|
+
}
|
package/server/core/sending.js
CHANGED
|
@@ -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
|
|
17
|
-
//
|
|
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.
|
|
151
|
-
|
|
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
|
-
|
|
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;
|