@arcforgelabs/flow 2026.10.32 → 2026.10.33
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 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
- package/server/api/invoicing.js +56 -24
- package/server/app.js +4 -0
- package/server/core/invoicing-rules.js +50 -4
- package/server/core/job-board.js +15 -4
- package/server/core/ledger.js +242 -6
- package/server/core/pipeline.js +29 -21
- package/server/core/read-before-act.js +96 -0
- package/server/core/reconcile.js +350 -0
- package/server/core/sending.js +44 -6
- package/server/db/migrations/0017_sync_basis.sql +1 -0
- package/server/db/migrations/meta/0017_snapshot.json +3867 -0
- package/server/db/migrations/meta/_journal.json +7 -0
- package/server/db/schema.js +3 -0
- package/server/index.js +5 -0
- package/server/integrations/port.js +3 -0
- package/server/integrations/seazona/act.js +48 -0
- package/server/integrations/seazona/feed.js +70 -50
- package/server/integrations/seazona/governor.js +28 -2
- package/server/integrations/seazona/index.js +60 -3
- package/server/integrations/seazona/lanes.js +81 -8
- package/server/integrations/seazona/ledger.js +14 -9
- package/server/integrations/seazona/migrations/0005_record_reread.sql +1 -0
- package/server/integrations/seazona/migrations/0006_sweep_findings.sql +11 -0
- package/server/integrations/seazona/migrations/meta/0005_snapshot.json +1329 -0
- package/server/integrations/seazona/migrations/meta/0006_snapshot.json +1403 -0
- package/server/integrations/seazona/migrations/meta/_journal.json +14 -0
- package/server/integrations/seazona/reconcile.js +40 -0
- package/server/integrations/seazona/scheduler.js +32 -4
- package/server/integrations/seazona/schema.js +21 -0
- package/server/integrations/seazona/settings.js +12 -0
- package/server/integrations/seazona/store.js +24 -2
- package/server/integrations/seazona/sweep.js +574 -0
- package/server/integrations/seazona/tables.js +1 -0
- package/server/integrations/xero/index.js +4 -0
- package/server/integrations/xero/webhooks.js +35 -0
- package/server/ui/flow.css +9 -0
- package/server/ui/invoicing.js +101 -17
- package/server/ui/job-board.js +2 -1
- package/server/ui/jobs.js +19 -1
package/README.md
CHANGED
|
@@ -27,7 +27,8 @@ run a lab.
|
|
|
27
27
|
| --- | --- |
|
|
28
28
|
| `VISION.md` | What Flow is for, the core and its integrations, and what it will not do |
|
|
29
29
|
| `AGENTS.md` | Agent entry point (`CLAUDE.md` links to it) |
|
|
30
|
-
| `CONTRIBUTING.md` | How to file issues and pull requests, porting rules, merging |
|
|
30
|
+
| `CONTRIBUTING.md` | How to file issues and pull requests, porting rules, the changelog, merging |
|
|
31
|
+
| `CHANGELOG.md` | What changed in each release: an index of one file per release in `CHANGELOG/` |
|
|
31
32
|
| `SECURITY.md` | How to report a security problem, the trust model, and the rules held hard |
|
|
32
33
|
| `LICENSE` | MIT licence |
|
|
33
34
|
| `docs/DESIGN.md` | The design systems (theme families), how pages and Clerk's components take them, and the screens |
|
|
@@ -37,6 +38,7 @@ run a lab.
|
|
|
37
38
|
| `docs/INVOICING_RULES.md` | The invoicing rules that draft a job's invoice, and the two ways an invoice goes to Xero |
|
|
38
39
|
| `docs/INVOICE_RULES.md` | Invoice rules a lab builds (When, If, Then) that make a ready job wait or never draft |
|
|
39
40
|
| `docs/INVOICE_SENDING.md` | Drafts, Problem and Ready invoices, Mark ready, and Send all ready to the accounting system |
|
|
41
|
+
| `docs/RECONCILE.md` | The daily reconcile: counts and totals per day across the work system's copy, the ledger and the accounting copy |
|
|
40
42
|
| `docs/ALERTS.md` | Quiet alerts: the slim line on a page when something there needs a person, and how a person dismisses it |
|
|
41
43
|
| `docs/AUTOMATIONS.md` | Automations: Flow routines that mark jobs ready to invoice and draft them, each on or off |
|
|
42
44
|
| `docs/LINE_MAPPING.md` | Line mapping: how each work system line finds its price item, and Price codes in Settings |
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcforgelabs/flow",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.33",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@arcforgelabs/flow",
|
|
9
|
-
"version": "2026.10.
|
|
9
|
+
"version": "2026.10.33",
|
|
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.
|
|
3
|
+
"version": "2026.10.33",
|
|
4
4
|
"description": "Invoicing, bill entry and a financial dashboard for labs, with Seazona and Xero integrations.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"scripts": {
|
|
32
32
|
"start": "node server/index.js",
|
|
33
33
|
"test": "node --test --test-concurrency=1 test/*.test.js",
|
|
34
|
+
"changelog:check": "node scripts/release/changelog.mjs check",
|
|
34
35
|
"theme:contrast": "node scripts/theme-contrast.mjs",
|
|
35
36
|
"db:generate": "drizzle-kit generate && node scripts/db-guard-migrations.js"
|
|
36
37
|
},
|
package/server/api/invoicing.js
CHANGED
|
@@ -16,11 +16,12 @@ import {
|
|
|
16
16
|
saveCustomerBilling, saveInvoicingSettings, sendModeFor, sendModeSummary,
|
|
17
17
|
} from '../core/invoicing-rules.js';
|
|
18
18
|
import { boardJobs, describeObligations } from '../core/job-board.js';
|
|
19
|
+
import { readBeforeAct } from '../core/read-before-act.js';
|
|
19
20
|
import { jobReference } from '../core/references.js';
|
|
20
21
|
import {
|
|
21
22
|
acceptFindings, archivePriceItem, checkPrices, createPriceItem, linkPriceItem, readCatalogues, readPriceList, unlinkPriceItem, updatePriceItem,
|
|
22
23
|
} from '../core/price-list.js';
|
|
23
|
-
import { draftInvoiceForObligation, explainObligation, listObligations, setReady } from '../core/ledger.js';
|
|
24
|
+
import { draftInvoiceForObligation, explainObligation, listObligations, setReady, useSourceLines } from '../core/ledger.js';
|
|
24
25
|
import { isHistoryHeld } from '../core/history.js';
|
|
25
26
|
import { invoicePdfDocument, pdfFields, pdfFilename, renderInvoicePdf } from '../core/invoice-pdf.js';
|
|
26
27
|
import { invoiceView, invoiceViews, markNotReady, markReady, sendAgain, sendAllReady } from '../core/sending.js';
|
|
@@ -287,25 +288,36 @@ export function createInvoicingRouter({
|
|
|
287
288
|
}
|
|
288
289
|
const q = text(req.query.q);
|
|
289
290
|
const limit = limitOf(req.query.limit, 200, 500);
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
const
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
else if (status === 'problem') where.push(only([...views.problems.keys()]));
|
|
296
|
-
else if (status) where.push(eq(invoices.status, status));
|
|
297
|
-
if (q) {
|
|
298
|
-
const pattern = containing(q);
|
|
299
|
-
where.push(or(ilike(invoices.number, pattern), ilike(invoices.reference, pattern), ilike(contacts.name, pattern)));
|
|
300
|
-
}
|
|
301
|
-
const rows = await db.select({ invoice: invoices, contactName: contacts.name }).from(invoices)
|
|
302
|
-
.leftJoin(contacts, eq(contacts.id, invoices.contactId))
|
|
303
|
-
.where(and(...where))
|
|
304
|
-
.orderBy(desc(invoices.createdAt), desc(invoices.id))
|
|
305
|
-
.limit(limit);
|
|
306
|
-
const jobs = await jobsByInvoice(db, organisationId, rows.map((r) => r.invoice.id));
|
|
307
|
-
const counted = await db.select({ status: invoices.status, n: sql`count(*)::int` }).from(invoices)
|
|
291
|
+
// Each tab switch runs this, so the independent reads run side by side;
|
|
292
|
+
// only the to-check, ready and problem lists wait on the draft checks.
|
|
293
|
+
const system = accounting();
|
|
294
|
+
const checking = invoiceViews(db, organisationId, { accounting: system });
|
|
295
|
+
const counting = db.select({ status: invoices.status, n: sql`count(*)::int` }).from(invoices)
|
|
308
296
|
.where(eq(invoices.organisationId, organisationId)).groupBy(invoices.status);
|
|
297
|
+
const sendMode = system
|
|
298
|
+
? Promise.all([sendModeSummary(db, organisationId), system.capabilities.sendStatus({ organisationId })])
|
|
299
|
+
.then(([summary, sendStatus]) => ({ ...summary, ...sendStatus }))
|
|
300
|
+
: null;
|
|
301
|
+
const listing = (async () => {
|
|
302
|
+
const views = VIEWS_OF_INVOICES.includes(status) ? await checking : null;
|
|
303
|
+
const where = [eq(invoices.organisationId, organisationId)];
|
|
304
|
+
const only = (ids) => (ids.length ? inArray(invoices.id, ids) : sql`false`);
|
|
305
|
+
if (status === 'to_check') where.push(only(views.toCheck));
|
|
306
|
+
else if (status === 'ready') where.push(only(views.ready));
|
|
307
|
+
else if (status === 'problem') where.push(only([...views.problems.keys()]));
|
|
308
|
+
else if (status) where.push(eq(invoices.status, status));
|
|
309
|
+
if (q) {
|
|
310
|
+
const pattern = containing(q);
|
|
311
|
+
where.push(or(ilike(invoices.number, pattern), ilike(invoices.reference, pattern), ilike(contacts.name, pattern)));
|
|
312
|
+
}
|
|
313
|
+
const rows = await db.select({ invoice: invoices, contactName: contacts.name }).from(invoices)
|
|
314
|
+
.leftJoin(contacts, eq(contacts.id, invoices.contactId))
|
|
315
|
+
.where(and(...where))
|
|
316
|
+
.orderBy(desc(invoices.createdAt), desc(invoices.id))
|
|
317
|
+
.limit(limit);
|
|
318
|
+
return { rows, jobs: await jobsByInvoice(db, organisationId, rows.map((r) => r.invoice.id)) };
|
|
319
|
+
})();
|
|
320
|
+
const [views, counted, { rows, jobs }, mode] = await Promise.all([checking, counting, listing, sendMode]);
|
|
309
321
|
const counts = Object.fromEntries(INVOICE_STATUSES.map((s) => [s, 0]));
|
|
310
322
|
for (const { status: s, n } of counted) counts[s] = n;
|
|
311
323
|
counts.to_check = views.toCheck.length;
|
|
@@ -324,8 +336,8 @@ export function createInvoicingRouter({
|
|
|
324
336
|
problems: views.problems.get(invoice.id) ?? [],
|
|
325
337
|
})),
|
|
326
338
|
counts,
|
|
327
|
-
sendsTo:
|
|
328
|
-
sendMode:
|
|
339
|
+
sendsTo: system?.name ?? null,
|
|
340
|
+
sendMode: mode,
|
|
329
341
|
});
|
|
330
342
|
}));
|
|
331
343
|
|
|
@@ -355,7 +367,9 @@ export function createInvoicingRouter({
|
|
|
355
367
|
const organisationId = await organisationOf(req);
|
|
356
368
|
const issueDate = optionalDate(req.body?.issueDate, 'issueDate') ?? today();
|
|
357
369
|
const ids = req.body?.invoiceIds === undefined ? null : invoiceIdsOf(req.body);
|
|
358
|
-
|
|
370
|
+
// Read before act (issue #141) reads each old job from the work system that can.
|
|
371
|
+
const reader = work().find((a) => typeof a.capabilities.readWorkItem === 'function') ?? null;
|
|
372
|
+
const result = await sendAllReady(db, organisationId, { accounting: accounting(), work: reader, personId: req.person.id, issueDate, invoiceIds: ids });
|
|
359
373
|
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} sent all ready: ${JSON.stringify(result)}`);
|
|
360
374
|
res.json({ ok: true, ...result });
|
|
361
375
|
}));
|
|
@@ -495,15 +509,33 @@ export function createInvoicingRouter({
|
|
|
495
509
|
const organisationId = await organisationOf(req);
|
|
496
510
|
// With no accounting system connected, Flow's own records are the check.
|
|
497
511
|
const liveCheck = liveCheckFor(organisationId) ?? null;
|
|
498
|
-
|
|
512
|
+
let [obligation] = await db.select().from(billingObligations)
|
|
499
513
|
.where(and(eq(billingObligations.organisationId, organisationId), eq(billingObligations.id, obligationId)));
|
|
514
|
+
const source = obligation ? work().find((a) => a.id === obligation.sourceSystem) ?? null : null;
|
|
515
|
+
if (obligation) {
|
|
516
|
+
// Read before act (issue #141): an old job report is read again first.
|
|
517
|
+
const read = (await readBeforeAct(db, organisationId, [obligation], { work: source, actor: `person:${req.person.id}` })).get(obligation.id);
|
|
518
|
+
if (read.outcome === 'waiting' || read.outcome === 'review') {
|
|
519
|
+
throw new FlowError('not_ready', 'the draft invoice was refused', { reasons: [read.reason ?? read.obligation.holdReason ?? 'the job needs a look'] });
|
|
520
|
+
}
|
|
521
|
+
obligation = read.obligation;
|
|
522
|
+
}
|
|
500
523
|
const { referenceTemplate: template } = await readInvoicingSettings(db, organisationId);
|
|
501
|
-
const reference = obligation ? await jobReference(db, organisationId, obligation, { template, work:
|
|
524
|
+
const reference = obligation ? await jobReference(db, organisationId, obligation, { template, work: source }) : null;
|
|
502
525
|
const { invoice } = await draftInvoiceForObligation(db, organisationId, obligationId, { liveCheck, actor: `person:${req.person.id}`, reference });
|
|
503
526
|
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} drafted invoice ${invoice.id} from job ${obligationId}`);
|
|
504
527
|
res.status(201).json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoice.id) });
|
|
505
528
|
}));
|
|
506
529
|
|
|
530
|
+
// "Use new lines": the job changed in the work system after its draft was
|
|
531
|
+
// made, and only there. Flow changes its own draft; no other system.
|
|
532
|
+
router.post('/jobs/:id/use-new-lines', ...manager, handle(log, async (req, res) => {
|
|
533
|
+
const obligationId = pathId(req.params.id, 'job');
|
|
534
|
+
const organisationId = await organisationOf(req);
|
|
535
|
+
const { invoice } = await useSourceLines(db, organisationId, obligationId, { actor: `person:${req.person.id}` });
|
|
536
|
+
res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoice.id) });
|
|
537
|
+
}));
|
|
538
|
+
|
|
507
539
|
router.post('/jobs/:id/ready', ...signedIn, handle(log, async (req, res) => {
|
|
508
540
|
const obligationId = pathId(req.params.id, 'job');
|
|
509
541
|
const organisationId = await organisationOf(req);
|
package/server/app.js
CHANGED
|
@@ -4,6 +4,7 @@ import { createConnectorApi } from './api/connector.js';
|
|
|
4
4
|
import { createInvoicingRouter } from './api/invoicing.js';
|
|
5
5
|
import { createSignInRouter } from './auth/routes.js';
|
|
6
6
|
import { integrationAlertsSource, invoiceProblemsSource, priceDifferencesSource } from './core/alerts.js';
|
|
7
|
+
import { reconcileHealth } from './core/reconcile.js';
|
|
7
8
|
import { appliedMigrationCounts } from './db/doctor.js';
|
|
8
9
|
import { deployedBuildStatus } from './deployed-build.js';
|
|
9
10
|
import { integrationsHealth } from './integrations/health.js';
|
|
@@ -72,6 +73,9 @@ export function createApp({
|
|
|
72
73
|
.then((migrations) => ({ migrations }), () => null);
|
|
73
74
|
}
|
|
74
75
|
if (integrations) body.integrations = await integrationsHealth(integrations);
|
|
76
|
+
// The daily reconcile's last run (server/core/reconcile.js): counts only.
|
|
77
|
+
// A difference does not fail the health check.
|
|
78
|
+
if (databaseOk && database.db) body.reconcile = await reconcileHealth(database.db).catch(() => null);
|
|
75
79
|
res.status(ok ? 200 : 503).json(body);
|
|
76
80
|
});
|
|
77
81
|
|
|
@@ -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 { needsRead, readAccountingFirst, readBeforeAct, READS_PER_PASS } from './read-before-act.js';
|
|
8
9
|
import { checkReferenceTemplate, DEFAULT_REFERENCE, jobReference } from './references.js';
|
|
9
10
|
|
|
10
11
|
// Invoicing rules (docs/INVOICING_RULES.md). They are the checks a job that
|
|
@@ -221,6 +222,12 @@ export function copyCheck(accounting, organisationId) {
|
|
|
221
222
|
};
|
|
222
223
|
}
|
|
223
224
|
|
|
225
|
+
async function reloadObligation(db, organisationId, id) {
|
|
226
|
+
const [row] = await db.select().from(billingObligations)
|
|
227
|
+
.where(and(eq(billingObligations.organisationId, organisationId), eq(billingObligations.id, id)));
|
|
228
|
+
return row;
|
|
229
|
+
}
|
|
230
|
+
|
|
224
231
|
// Drafts the invoice of each job that is ready to invoice and passes the
|
|
225
232
|
// ledger's checks and every rule, except for customers whose drafting is off.
|
|
226
233
|
// It writes only Flow's own records: the drafts wait in Flow for a person.
|
|
@@ -228,7 +235,7 @@ export function copyCheck(accounting, organisationId) {
|
|
|
228
235
|
// With `accounting` null no accounting system is connected: Flow's own records
|
|
229
236
|
// are the only check, as for a person's draft (ledger.js, ledgerView).
|
|
230
237
|
export async function autoDraftJobs(db, organisationId, { accounting = null, work = null, now = new Date(), limit = DRAFT_BATCH, actor = ACTOR }) {
|
|
231
|
-
const result = { drafted: 0, waiting: 0, off: 0, refused: 0 };
|
|
238
|
+
const result = { drafted: 0, waiting: 0, off: 0, refused: 0, readFirst: 0 };
|
|
232
239
|
const settings = await readInvoicingSettings(db, organisationId);
|
|
233
240
|
const rules = await listRules(db, organisationId);
|
|
234
241
|
if (accounting) {
|
|
@@ -251,17 +258,56 @@ export async function autoDraftJobs(db, organisationId, { accounting = null, wor
|
|
|
251
258
|
const customers = await customerBillingByContact(db, organisationId, candidates.map((o) => o.contactId));
|
|
252
259
|
const liveCheck = accounting ? copyCheck(accounting, organisationId) : null;
|
|
253
260
|
const invoiceDate = isoDay(now);
|
|
261
|
+
let accountingRead = null;
|
|
254
262
|
|
|
255
|
-
for (const
|
|
256
|
-
|
|
263
|
+
for (const candidate of candidates) {
|
|
264
|
+
let effective = effectiveSettings(settings, customers.get(candidate.contactId));
|
|
257
265
|
if (!effective.autoDraft) {
|
|
258
266
|
result.off += 1;
|
|
259
267
|
continue;
|
|
260
268
|
}
|
|
261
|
-
if (ruleBlockers(
|
|
269
|
+
if (ruleBlockers(candidate, effective, { now, rules }).length) {
|
|
270
|
+
result.waiting += 1;
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
// Issue #141: an old report is read again first. The rules then check
|
|
274
|
+
// what the work system says now; the ledger checks the rest.
|
|
275
|
+
if (result.readFirst >= READS_PER_PASS && needsRead(candidate, { work, now })) {
|
|
262
276
|
result.waiting += 1;
|
|
263
277
|
continue;
|
|
264
278
|
}
|
|
279
|
+
let { outcome, obligation } = (await readBeforeAct(db, organisationId, [candidate], { work, now, actor })).get(candidate.id);
|
|
280
|
+
if (outcome !== 'fresh') result.readFirst += 1;
|
|
281
|
+
if (outcome === 'waiting' || outcome === 'review') {
|
|
282
|
+
result.waiting += 1;
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
// Issue #45: the accounting copy reads its latest changes once, before
|
|
286
|
+
// the first draft. When it changed, the matcher ran: this job may now
|
|
287
|
+
// have its invoice.
|
|
288
|
+
if (accounting) {
|
|
289
|
+
if (!accountingRead) {
|
|
290
|
+
accountingRead = await readAccountingFirst(db, organisationId, { accounting, now });
|
|
291
|
+
// The summary says when the copy read or could not read.
|
|
292
|
+
if (accountingRead.outcome !== 'fresh') result.accountingRead = accountingRead.outcome;
|
|
293
|
+
}
|
|
294
|
+
if (accountingRead.outcome === 'waiting') {
|
|
295
|
+
result.waiting += 1;
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (accountingRead.outcome === 'read') obligation = await reloadObligation(db, organisationId, obligation.id);
|
|
299
|
+
if (obligation.accountingState !== 'not_found') {
|
|
300
|
+
result.waiting += 1;
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (obligation !== candidate) {
|
|
305
|
+
effective = effectiveSettings(settings, customers.get(obligation.contactId));
|
|
306
|
+
if (!effective.autoDraft || ruleBlockers(obligation, effective, { now, rules }).length) {
|
|
307
|
+
result.waiting += 1;
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
265
311
|
try {
|
|
266
312
|
await draftInvoiceForObligation(db, organisationId, obligation.id, {
|
|
267
313
|
liveCheck,
|
package/server/core/job-board.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { and, eq, inArray, isNull } from 'drizzle-orm';
|
|
2
|
-
import { contacts, invoices, priceItems } from '../db/schema.js';
|
|
2
|
+
import { contacts, invoices, priceItems, reviewItems } from '../db/schema.js';
|
|
3
3
|
import { listRules } from './invoice-rules.js';
|
|
4
4
|
import { customerBillingByContact, effectiveSettings, readInvoicingSettings, ruleBlockers } from './invoicing-rules.js';
|
|
5
|
-
import { listObligations } from './ledger.js';
|
|
5
|
+
import { REVIEW_CHANGED, listObligations } from './ledger.js';
|
|
6
6
|
import { isHistoryHeld } from './history.js';
|
|
7
7
|
import { invoiceViews } from './sending.js';
|
|
8
8
|
|
|
@@ -21,7 +21,8 @@ import { invoiceViews } from './sending.js';
|
|
|
21
21
|
export async function describeObligations(db, organisationId, listed, { accounting = null } = {}) {
|
|
22
22
|
const contactIds = [...new Set(listed.map((e) => e.obligation.contactId).filter(Boolean))];
|
|
23
23
|
const invoiceIds = [...new Set(listed.map((e) => e.obligation.invoiceId).filter(Boolean))];
|
|
24
|
-
const
|
|
24
|
+
const changedIds = listed.filter((e) => e.obligation.holdState === 'review' && e.obligation.invoiceId).map((e) => e.obligation.id);
|
|
25
|
+
const [names, numbers, views, changed] = await Promise.all([
|
|
25
26
|
contactIds.length
|
|
26
27
|
? db.select({ id: contacts.id, name: contacts.name }).from(contacts)
|
|
27
28
|
.where(and(eq(contacts.organisationId, organisationId), inArray(contacts.id, contactIds)))
|
|
@@ -31,7 +32,16 @@ export async function describeObligations(db, organisationId, listed, { accounti
|
|
|
31
32
|
.where(and(eq(invoices.organisationId, organisationId), inArray(invoices.id, invoiceIds)))
|
|
32
33
|
: [],
|
|
33
34
|
invoiceIds.length ? invoiceViews(db, organisationId, { accounting }) : null,
|
|
35
|
+
// The open "job changed" alerts (ledger.js): only the work system changed
|
|
36
|
+
// and the draft is not sent, so a person can use the new lines.
|
|
37
|
+
changedIds.length
|
|
38
|
+
? db.select({ flowId: reviewItems.flowId, evidence: reviewItems.evidence }).from(reviewItems).where(and(
|
|
39
|
+
eq(reviewItems.organisationId, organisationId), eq(reviewItems.kind, REVIEW_CHANGED),
|
|
40
|
+
eq(reviewItems.state, 'open'), inArray(reviewItems.flowId, changedIds),
|
|
41
|
+
))
|
|
42
|
+
: [],
|
|
34
43
|
]);
|
|
44
|
+
const offers = new Set(changed.filter((r) => r.evidence?.canUseNewLines).map((r) => r.flowId));
|
|
35
45
|
const nameOf = new Map(names.map((c) => [c.id, c.name]));
|
|
36
46
|
const invoiceOf = new Map(numbers.map((i) => [i.id, i]));
|
|
37
47
|
const settings = await readInvoicingSettings(db, organisationId);
|
|
@@ -46,8 +56,9 @@ export async function describeObligations(db, organisationId, listed, { accounti
|
|
|
46
56
|
// draft can still come from.
|
|
47
57
|
const waitingFor = (o, v) => (o.invoiceId || !['in_work', 'ready', 'pending', 'held'].includes(v)
|
|
48
58
|
? [] : ruleBlockers(o, effectiveSettings(settings, customers.get(o.contactId)), { rules }));
|
|
49
|
-
return listed.map(({ obligation: { lineFingerprint: _fingerprint, organisationId: _org, ...o }, view, reasons }) => ({
|
|
59
|
+
return listed.map(({ obligation: { lineFingerprint: _fingerprint, organisationId: _org, draftBasis: _basis, ...o }, view, reasons }) => ({
|
|
50
60
|
...o,
|
|
61
|
+
canUseNewLines: offers.has(o.id),
|
|
51
62
|
contactName: nameOf.get(o.contactId) ?? null,
|
|
52
63
|
invoiceNumber: invoiceOf.get(o.invoiceId)?.number ?? null,
|
|
53
64
|
invoiceStatus: invoiceOf.get(o.invoiceId)?.status ?? null,
|
package/server/core/ledger.js
CHANGED
|
@@ -4,8 +4,9 @@ import {
|
|
|
4
4
|
billingObligations, obligationEvents, obligationPayments, obligationWritebacks, priceItems,
|
|
5
5
|
} from '../db/schema.js';
|
|
6
6
|
import { FlowError, notFound } from './errors.js';
|
|
7
|
-
import { createDraftInvoice } from './invoices.js';
|
|
7
|
+
import { createDraftInvoice, getInvoice, updateDraftInvoice } from './invoices.js';
|
|
8
8
|
import { invoiceTotals, lineTotals } from './money.js';
|
|
9
|
+
import { openReview, resolveReviewFor } from './review.js';
|
|
9
10
|
|
|
10
11
|
// The billing obligation ledger. One billable job in a work system is one
|
|
11
12
|
// obligation. Its view (In work, Ready, Pending, Held, Drafted, Invoiced,
|
|
@@ -23,6 +24,29 @@ export const EXACT_MATCHES = ['flow_invoice', 'source_invoice_ref', 'accounting_
|
|
|
23
24
|
|
|
24
25
|
const LINKED_STATES = ['drafted', 'authorised', 'part_paid', 'paid', 'voided', 'deleted'];
|
|
25
26
|
|
|
27
|
+
// Which system owns each fact about a billed job (docs/DATA_SPINE.md, "Who
|
|
28
|
+
// owns each field"). The work system owns the work; Flow owns its invoice
|
|
29
|
+
// and the send; the accounting system owns approval and payment. Only the
|
|
30
|
+
// work fields are compared when a job changes after its invoice was made.
|
|
31
|
+
export const FIELD_OWNERS = {
|
|
32
|
+
lines: 'work',
|
|
33
|
+
contactId: 'work',
|
|
34
|
+
sourceStatus: 'work',
|
|
35
|
+
reference: 'flow',
|
|
36
|
+
dueDate: 'flow',
|
|
37
|
+
send: 'flow',
|
|
38
|
+
approval: 'accounting',
|
|
39
|
+
payment: 'accounting',
|
|
40
|
+
};
|
|
41
|
+
const COMPARED = Object.keys(FIELD_OWNERS).filter((field) => FIELD_OWNERS[field] === 'work' && field !== 'sourceStatus');
|
|
42
|
+
|
|
43
|
+
export const CHANGED_REASON = 'the job changed after its invoice was made';
|
|
44
|
+
export const STATUS_BACK_REASON = 'the job status moved back after its invoice was made';
|
|
45
|
+
// Review queue kinds (server/core/review.js). An item only alerts.
|
|
46
|
+
export const REVIEW_CHANGED = 'job.changed';
|
|
47
|
+
export const REVIEW_STATUS_BACK = 'job.status_back';
|
|
48
|
+
export const REVIEW_DELETED = 'job.deleted';
|
|
49
|
+
|
|
26
50
|
async function loadObligation(tx, organisationId, obligationId, { lock = false } = {}) {
|
|
27
51
|
let query = tx.select().from(billingObligations)
|
|
28
52
|
.where(and(eq(billingObligations.organisationId, organisationId), eq(billingObligations.id, obligationId)));
|
|
@@ -149,26 +173,235 @@ export async function observeJob(db, organisationId, job) {
|
|
|
149
173
|
const changed = ['sourceInvoiceRef', 'caseRef', 'contactId', 'practitioner', 'shippedOn', 'lineFingerprint', 'totalCents']
|
|
150
174
|
.filter((key) => String(existing[key] ?? '') !== String(facts[key] ?? ''));
|
|
151
175
|
if (!changed.length) {
|
|
152
|
-
|
|
176
|
+
const row = await update(tx, existing, {
|
|
153
177
|
sourceSeenAt: seenAt, mappingComplete: facts.mappingComplete, lines, jobDate: facts.jobDate, sourceStatus: facts.sourceStatus,
|
|
154
178
|
});
|
|
179
|
+
await statusBillableAgain(tx, organisationId, existing, row, actor);
|
|
180
|
+
return row;
|
|
155
181
|
}
|
|
156
182
|
const fields = { ...facts, sourceSeenAt: seenAt };
|
|
183
|
+
let compare = null;
|
|
157
184
|
if (existing.invoiceId && changed.some((key) => ['lineFingerprint', 'totalCents', 'contactId'].includes(key))) {
|
|
158
|
-
|
|
159
|
-
|
|
185
|
+
compare = await compareWithDraft(tx, organisationId, existing, facts);
|
|
186
|
+
if (compare.outcome !== 'none') {
|
|
187
|
+
fields.holdState = 'review';
|
|
188
|
+
fields.holdReason = CHANGED_REASON;
|
|
189
|
+
} else if (existing.holdState === 'review' && existing.holdReason === CHANGED_REASON) {
|
|
190
|
+
// The job is back to what the invoice was built from.
|
|
191
|
+
Object.assign(fields, { holdState: 'none', holdReason: null });
|
|
192
|
+
}
|
|
160
193
|
}
|
|
161
194
|
const row = await update(tx, existing, fields);
|
|
162
195
|
await addEvent(tx, row, 'source_changed', actor, {
|
|
163
196
|
changed,
|
|
164
197
|
before: { lineFingerprint: existing.lineFingerprint, totalCents: existing.totalCents },
|
|
165
198
|
after: { lineFingerprint: row.lineFingerprint, totalCents: row.totalCents },
|
|
166
|
-
...(
|
|
199
|
+
...(compare ? { compare: { outcome: compare.outcome, fields: compare.fields.map(({ field, changedIn }) => ({ field, changedIn })) } } : {}),
|
|
200
|
+
...(fields.holdState === 'review' ? { holdReason: fields.holdReason } : {}),
|
|
167
201
|
});
|
|
202
|
+
if (compare?.outcome === 'none') {
|
|
203
|
+
await resolveReviewFor(tx, organisationId, { kind: REVIEW_CHANGED, entity: 'obligation', flowId: row.id, resolution: 'job_matches_invoice', actor });
|
|
204
|
+
} else if (compare) {
|
|
205
|
+
await openReview(tx, organisationId, changedReview(row, compare));
|
|
206
|
+
}
|
|
207
|
+
await statusBillableAgain(tx, organisationId, existing, row, actor);
|
|
168
208
|
return row;
|
|
169
209
|
});
|
|
170
210
|
}
|
|
171
211
|
|
|
212
|
+
// A job billable again after its status moved back (observeWithdrawn): the
|
|
213
|
+
// alert is over when no invoice was made in the meantime.
|
|
214
|
+
async function statusBillableAgain(tx, organisationId, before, row, actor) {
|
|
215
|
+
if (row.invoiceId || String(before.sourceStatus ?? '') === String(row.sourceStatus ?? '')) return;
|
|
216
|
+
await resolveReviewFor(tx, organisationId, { kind: REVIEW_STATUS_BACK, entity: 'obligation', flowId: row.id, resolution: 'status_billable_again', actor });
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// The job facts an invoice is built from, kept on the obligation when Flow
|
|
220
|
+
// drafts the invoice: the merge base of the three-way compare.
|
|
221
|
+
function basisOf(o) {
|
|
222
|
+
return {
|
|
223
|
+
lines: o.lines.map(({ itemCode = null, priceItemId = null, accountCode = null, description, quantity, unitAmountCents, taxCode }) => (
|
|
224
|
+
{ itemCode, priceItemId, accountCode, description, quantity, unitAmountCents, taxCode }
|
|
225
|
+
)),
|
|
226
|
+
contactId: o.contactId ?? null,
|
|
227
|
+
subtotalCents: o.subtotalCents,
|
|
228
|
+
taxCents: o.taxCents,
|
|
229
|
+
totalCents: o.totalCents,
|
|
230
|
+
sourceStatus: o.sourceStatus ?? null,
|
|
231
|
+
sourceSeenAt: o.sourceSeenAt ? new Date(o.sourceSeenAt).toISOString() : null,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// One key for a set of lines, the same for the job's lines and the invoice's.
|
|
236
|
+
function linesKey(lines) {
|
|
237
|
+
const keys = (lines ?? [])
|
|
238
|
+
.map((l) => [l.priceItemId ?? l.accountCode ?? String(l.description ?? '').trim().toLowerCase(), l.quantity, l.unitAmountCents, l.taxCode])
|
|
239
|
+
.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
|
|
240
|
+
return createHash('sha256').update(JSON.stringify(keys)).digest('hex');
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const sideValue = (side, field) => (field === 'lines' ? linesKey(side.lines) : (side[field] ?? null));
|
|
244
|
+
|
|
245
|
+
// The three-way compare, field by field, of the facts the invoice was built
|
|
246
|
+
// from (base), the work system now (source) and Flow's invoice now (flow).
|
|
247
|
+
// Only work fields are compared (FIELD_OWNERS). The outcome:
|
|
248
|
+
// none the work system is as it was, or as the invoice is: nothing to do
|
|
249
|
+
// offer only the work system changed and the draft is not sent: a person
|
|
250
|
+
// can take the new lines into Flow's own draft (useSourceLines)
|
|
251
|
+
// conflict both changed, or the invoice left Flow: an alert only
|
|
252
|
+
export function compareThreeWays(base, source, flow, { sent = false } = {}) {
|
|
253
|
+
const fields = COMPARED.map((field) => {
|
|
254
|
+
const [b, s, f] = [base, source, flow].map((side) => sideValue(side, field));
|
|
255
|
+
const changedIn = s === b ? (f === b ? 'none' : 'flow') : (f === b ? 'work' : (f === s ? 'same' : 'both'));
|
|
256
|
+
return { field, owner: FIELD_OWNERS[field], changedIn };
|
|
257
|
+
});
|
|
258
|
+
const work = fields.filter((f) => ['work', 'both'].includes(f.changedIn));
|
|
259
|
+
let outcome = 'none';
|
|
260
|
+
if (work.length) outcome = sent || work.some((f) => f.changedIn === 'both') ? 'conflict' : 'offer';
|
|
261
|
+
return { outcome, sent, fields };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// The invoice has left Flow's hands: issued, queued or sent, or the
|
|
265
|
+
// accounting system holds it.
|
|
266
|
+
function invoiceSent(invoice, obligation) {
|
|
267
|
+
return invoice.status !== 'draft' || ['queued', 'sent'].includes(invoice.sendState)
|
|
268
|
+
|| LINKED_STATES.includes(obligation.accountingState);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function compareWithDraft(tx, organisationId, obligation, facts) {
|
|
272
|
+
// An invoice drafted before the basis was kept: the last report stands in.
|
|
273
|
+
const base = obligation.draftBasis ?? basisOf(obligation);
|
|
274
|
+
let invoice;
|
|
275
|
+
try {
|
|
276
|
+
invoice = await getInvoice(tx, organisationId, obligation.invoiceId);
|
|
277
|
+
} catch (error) {
|
|
278
|
+
if (error?.code !== 'not_found') throw error;
|
|
279
|
+
return { outcome: 'conflict', sent: false, fields: [], base, missing: true };
|
|
280
|
+
}
|
|
281
|
+
const flow = { lines: invoice.lines, contactId: invoice.contactId };
|
|
282
|
+
const compare = compareThreeWays(base, facts, flow, { sent: invoiceSent(invoice, obligation) });
|
|
283
|
+
return { ...compare, base, flow, invoiceStatus: invoice.status };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function changedReview(obligation, compare) {
|
|
287
|
+
return {
|
|
288
|
+
kind: REVIEW_CHANGED,
|
|
289
|
+
entity: 'obligation',
|
|
290
|
+
flowId: obligation.id,
|
|
291
|
+
systems: [obligation.sourceSystem, 'flow'],
|
|
292
|
+
moneyAtRiskCents: Math.max(0, obligation.totalCents ?? 0),
|
|
293
|
+
evidence: {
|
|
294
|
+
outcome: compare.outcome,
|
|
295
|
+
sent: compare.sent,
|
|
296
|
+
invoiceId: obligation.invoiceId,
|
|
297
|
+
fields: compare.fields,
|
|
298
|
+
basis: compare.base,
|
|
299
|
+
job: basisOf(obligation),
|
|
300
|
+
invoice: compare.flow ? { contactId: compare.flow.contactId, lines: compare.flow.lines.map(({ priceItemId, accountCode, description, quantity, unitAmountCents, taxCode }) => ({ priceItemId, accountCode, description, quantity, unitAmountCents, taxCode })) } : null,
|
|
301
|
+
canUseNewLines: compare.outcome === 'offer',
|
|
302
|
+
},
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// "Use new lines": a person takes the work system's new lines (and its
|
|
307
|
+
// customer, when that changed) into Flow's own unsent draft. Flow writes only
|
|
308
|
+
// its own draft, never another system. Refused when the draft changed too,
|
|
309
|
+
// or has left Flow: those stay alerts for a person.
|
|
310
|
+
export async function useSourceLines(db, organisationId, obligationId, { actor }) {
|
|
311
|
+
requireActor(actor);
|
|
312
|
+
return db.transaction(async (tx) => {
|
|
313
|
+
const obligation = await loadObligation(tx, organisationId, obligationId, { lock: true });
|
|
314
|
+
if (!obligation.invoiceId) throw new FlowError('invalid', 'the job has no invoice');
|
|
315
|
+
const compare = await compareWithDraft(tx, organisationId, obligation, obligation);
|
|
316
|
+
if (compare.outcome === 'conflict') {
|
|
317
|
+
throw new FlowError('conflict', compare.sent ? 'the invoice has left Flow; change it where it is now' : 'the draft changed in Flow too; a person must compare them', { fields: compare.fields });
|
|
318
|
+
}
|
|
319
|
+
const changes = {};
|
|
320
|
+
for (const { field, changedIn } of compare.fields) {
|
|
321
|
+
if (changedIn !== 'work') continue;
|
|
322
|
+
if (field === 'lines') {
|
|
323
|
+
changes.lines = obligation.lines.map(({ priceItemId, accountCode, description, quantity, unitAmountCents, taxCode }) => (
|
|
324
|
+
{ priceItemId, accountCode, description, quantity, unitAmountCents, taxCode }
|
|
325
|
+
));
|
|
326
|
+
} else changes[field] = obligation[field];
|
|
327
|
+
}
|
|
328
|
+
const invoice = Object.keys(changes).length
|
|
329
|
+
? await updateDraftInvoice(tx, organisationId, obligation.invoiceId, changes)
|
|
330
|
+
: await getInvoice(tx, organisationId, obligation.invoiceId);
|
|
331
|
+
const fields = { draftBasis: basisOf(obligation) };
|
|
332
|
+
if (obligation.holdState === 'review' && obligation.holdReason === CHANGED_REASON) Object.assign(fields, { holdState: 'none', holdReason: null });
|
|
333
|
+
const row = await update(tx, obligation, fields);
|
|
334
|
+
await addEvent(tx, row, 'source_lines_used', actor, { invoiceId: invoice.id, changed: Object.keys(changes), totalCents: invoice.totalCents });
|
|
335
|
+
await resolveReviewFor(tx, organisationId, { kind: REVIEW_CHANGED, entity: 'obligation', flowId: row.id, resolution: 'used_new_lines', actor });
|
|
336
|
+
return { obligation: row, invoice };
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Records that a job the ledger knows is no longer billable in the work
|
|
341
|
+
// system: deleted there, or its status moved back (for example from Shipped
|
|
342
|
+
// to In production). Flow writes nothing to another system:
|
|
343
|
+
// deleted review hold and an alert
|
|
344
|
+
// status back, invoice made review hold and an alert
|
|
345
|
+
// status back, only ready the ready mark is cleared and an alert opens;
|
|
346
|
+
// the alert closes when the job is billable again
|
|
347
|
+
// The status is stored, so a repeat report of the same status does nothing.
|
|
348
|
+
export async function observeWithdrawn(db, organisationId, entry) {
|
|
349
|
+
const { sourceSystem, sourceJobRef, sourceStatus = null, deleted = false, reason = null, actor = `system:${sourceSystem}` } = entry;
|
|
350
|
+
if (!sourceSystem || !sourceJobRef) throw new FlowError('invalid', 'sourceSystem and sourceJobRef are required');
|
|
351
|
+
const seenAt = entry.observedAt ? new Date(entry.observedAt) : new Date();
|
|
352
|
+
return db.transaction(async (tx) => {
|
|
353
|
+
const [obligation] = await tx.select().from(billingObligations)
|
|
354
|
+
.where(and(
|
|
355
|
+
eq(billingObligations.organisationId, organisationId),
|
|
356
|
+
eq(billingObligations.sourceSystem, sourceSystem),
|
|
357
|
+
eq(billingObligations.sourceJobRef, sourceJobRef),
|
|
358
|
+
))
|
|
359
|
+
.for('update');
|
|
360
|
+
if (!obligation) return { obligation: null, action: 'none' };
|
|
361
|
+
if (deleted) {
|
|
362
|
+
if (obligation.holdState === 'review') return { obligation, action: 'none' };
|
|
363
|
+
const holdReason = reason ?? 'the job was deleted in the work system';
|
|
364
|
+
const row = await update(tx, obligation, { holdState: 'review', holdReason });
|
|
365
|
+
await addEvent(tx, row, 'review', actor, { from: obligation.holdState, reason: holdReason });
|
|
366
|
+
await openReview(tx, organisationId, statusReview(REVIEW_DELETED, row, { reason: holdReason }));
|
|
367
|
+
return { obligation: row, action: 'review' };
|
|
368
|
+
}
|
|
369
|
+
if (seenAt < obligation.sourceSeenAt || String(obligation.sourceStatus ?? '') === String(sourceStatus ?? '')) {
|
|
370
|
+
return { obligation, action: 'none' };
|
|
371
|
+
}
|
|
372
|
+
const fields = { sourceStatus, sourceSeenAt: seenAt };
|
|
373
|
+
let action = 'status';
|
|
374
|
+
if (obligation.invoiceId) {
|
|
375
|
+
if (obligation.holdState !== 'review') Object.assign(fields, { holdState: 'review', holdReason: STATUS_BACK_REASON });
|
|
376
|
+
action = 'review';
|
|
377
|
+
} else if (obligation.readyAt) {
|
|
378
|
+
// Not setReady's "in work": that marks a person's choice, and the ready
|
|
379
|
+
// routine must mark the job again when its status is billable again.
|
|
380
|
+
Object.assign(fields, { readyAt: null, readyBy: null });
|
|
381
|
+
action = 'ready_cleared';
|
|
382
|
+
}
|
|
383
|
+
const row = await update(tx, obligation, fields);
|
|
384
|
+
await addEvent(tx, row, 'status_moved_back', actor, {
|
|
385
|
+
from: obligation.sourceStatus, to: sourceStatus, action, ...(fields.holdState ? { holdReason: fields.holdReason } : {}),
|
|
386
|
+
});
|
|
387
|
+
if (action !== 'status') {
|
|
388
|
+
await openReview(tx, organisationId, statusReview(REVIEW_STATUS_BACK, row, { from: obligation.sourceStatus, to: sourceStatus, action }));
|
|
389
|
+
}
|
|
390
|
+
return { obligation: row, action };
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function statusReview(kind, obligation, evidence) {
|
|
395
|
+
return {
|
|
396
|
+
kind,
|
|
397
|
+
entity: 'obligation',
|
|
398
|
+
flowId: obligation.id,
|
|
399
|
+
systems: [obligation.sourceSystem, 'flow'],
|
|
400
|
+
moneyAtRiskCents: Math.max(0, obligation.totalCents ?? 0),
|
|
401
|
+
evidence: { ...evidence, sourceJobRef: obligation.sourceJobRef, invoiceId: obligation.invoiceId ?? null },
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
172
405
|
// Records what the accounting system says about the job's invoice. Evidence
|
|
173
406
|
// that is not tied to the job by an exact match never counts as found: it
|
|
174
407
|
// makes the obligation ambiguous, for a person to resolve.
|
|
@@ -432,7 +665,10 @@ export async function draftInvoiceForObligation(db, organisationId, obligationId
|
|
|
432
665
|
});
|
|
433
666
|
// Drafting the invoice says the job is ready, whoever asked.
|
|
434
667
|
const row = await update(tx, obligation, {
|
|
435
|
-
invoiceId: invoice.id,
|
|
668
|
+
invoiceId: invoice.id,
|
|
669
|
+
draftBasis: basisOf(obligation),
|
|
670
|
+
accountingCheckedAt: now,
|
|
671
|
+
...(obligation.readyAt ? {} : { readyAt: now, readyBy: actor }),
|
|
436
672
|
});
|
|
437
673
|
await addEvent(tx, row, 'drafted', actor, { invoiceId: invoice.id, totalCents: invoice.totalCents, live });
|
|
438
674
|
return { obligation: row, invoice };
|