@arcforgelabs/flow 2026.10.53-2 → 2026.10.53-4
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/core/ledger.js +66 -12
- package/server/core/line-mapping.js +2 -2
- package/server/core/read-before-act.js +2 -2
- package/server/core/reconcile.js +15 -1
- package/server/core/sending.js +7 -1
- package/server/db/migrations/0025_powerful_leopardon.sql +1 -0
- package/server/db/migrations/meta/0025_snapshot.json +4709 -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/history/index.js +4 -7
- package/server/integrations/seazona/index.js +5 -1
- package/server/integrations/seazona/ledger.js +14 -4
- package/server/integrations/seazona/reconcile.js +12 -0
- package/server/ui/invoicing.js +18 -7
package/server/db/schema.js
CHANGED
|
@@ -260,6 +260,8 @@ export const billingObligations = pgTable('billing_obligations', {
|
|
|
260
260
|
// The lines as the work system reported them, normalised.
|
|
261
261
|
lines: jsonb('lines').notNull().default(sql`'[]'::jsonb`),
|
|
262
262
|
lineFingerprint: text('line_fingerprint').notNull(),
|
|
263
|
+
// Exact source aggregates for a job billed alone. Missing fields are unknown.
|
|
264
|
+
sourceTotals: jsonb('source_totals'),
|
|
263
265
|
subtotalCents: integer('subtotal_cents').notNull(),
|
|
264
266
|
taxCents: integer('tax_cents').notNull(),
|
|
265
267
|
totalCents: integer('total_cents').notNull(),
|
|
@@ -24,6 +24,7 @@ export const CAPABILITIES = Object.freeze([
|
|
|
24
24
|
'pullPayments', // payments since a cursor, matched to invoices and bills
|
|
25
25
|
'checkInvoice', // does the accounting system hold an invoice for a billable job? (the ledger's liveCheck)
|
|
26
26
|
'billableJobs', // billable jobs by day from the work system's copy in Flow, for the daily reconcile; never a live call
|
|
27
|
+
'workJobPresence', // { organisationId, jobRefs } -> { presentRefs }: requested jobs still present in the work copy, without status, date or line filters; deleted jobs are absent; never a live call
|
|
27
28
|
'jobReferenceFields', // { patient }: the fields a job gives the invoice Reference (server/core/references.js)
|
|
28
29
|
'jobLink', // { url, exact } or null: the link to one job in the work system's own web app, for "Open in" on a job's problem; never a live call
|
|
29
30
|
'listWork', // the work system's jobs from its copy in Flow, open and lately finished, for the Jobs board; a job may carry `pendingMove`
|
|
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
|
|
|
2
2
|
import { and, asc, desc, eq, gt, inArray, sql } from 'drizzle-orm';
|
|
3
3
|
import { FlowError } from '../../../core/errors.js';
|
|
4
4
|
import { listClients } from '../feed.js';
|
|
5
|
+
import { ledgerLine } from '../ledger.js';
|
|
5
6
|
import { hashOf, parseSeazonaDate, text } from '../map.js';
|
|
6
7
|
import {
|
|
7
8
|
seazonaClients,
|
|
@@ -517,13 +518,9 @@ export async function readHistoryWork(db, { cursor = null, limit = 200 } = {}) {
|
|
|
517
518
|
shippedOn: job.shippedDate,
|
|
518
519
|
jobDate: job.jobDate ?? job.dueDate ?? job.shippedDate,
|
|
519
520
|
sourceStatus: job.status,
|
|
520
|
-
lines: (job.lines ?? []).map(
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
quantity: 1,
|
|
524
|
-
unitAmountCents: l.amountCents ?? undefined,
|
|
525
|
-
taxCode: typeof l.taxable === 'boolean' ? (l.taxable ? 'GST' : 'GST_FREE') : undefined,
|
|
526
|
-
})),
|
|
521
|
+
lines: (job.lines ?? []).map(ledgerLine),
|
|
522
|
+
// The CSV reports a total, but no invoice tax allocation.
|
|
523
|
+
sourceTotals: job.totalCents == null ? null : { totalCents: job.totalCents },
|
|
527
524
|
observedAt: job.lastChangedAt.toISOString(),
|
|
528
525
|
actor: 'seazona:history_import',
|
|
529
526
|
},
|
|
@@ -4,7 +4,7 @@ import { createSeazonaClient } from './client.js';
|
|
|
4
4
|
import { listClients, listProducts, listWork, readBillableWork } from './feed.js';
|
|
5
5
|
import { historyJobIssues, historySummary, readHistoryWork } from './history/index.js';
|
|
6
6
|
import { readSeazonaSyncHealth, runSeazonaSync } from './lanes.js';
|
|
7
|
-
import { readBillableJobs } from './reconcile.js';
|
|
7
|
+
import { readBillableJobs, readWorkJobPresence } from './reconcile.js';
|
|
8
8
|
import { SEAZONA_CALL_GUARD } from './governor.js';
|
|
9
9
|
import { createSeazonaRouter } from './routes.js';
|
|
10
10
|
import {
|
|
@@ -200,6 +200,10 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
|
|
|
200
200
|
return readBillableJobs(current().db, { from, to });
|
|
201
201
|
},
|
|
202
202
|
|
|
203
|
+
async workJobPresence({ jobRefs }) {
|
|
204
|
+
return readWorkJobPresence(current().db, { jobRefs });
|
|
205
|
+
},
|
|
206
|
+
|
|
203
207
|
// Seazona clients by id and company name, for the identity map. Reads
|
|
204
208
|
// Flow's copy only.
|
|
205
209
|
async listCustomers() {
|
|
@@ -11,15 +11,16 @@
|
|
|
11
11
|
|
|
12
12
|
export const SEAZONA_SOURCE_SYSTEM = 'seazona';
|
|
13
13
|
|
|
14
|
-
function ledgerLine(line) {
|
|
14
|
+
export function ledgerLine(line) {
|
|
15
15
|
return {
|
|
16
16
|
// The ledger maps a line to a price item by its exact code only.
|
|
17
17
|
itemCode: line.code ?? null,
|
|
18
18
|
description: line.description,
|
|
19
19
|
quantity: 1,
|
|
20
|
-
//
|
|
20
|
+
// A mapped price item can fill unknown amounts or tax.
|
|
21
|
+
// Explicit null tax must not take the legacy GST default.
|
|
21
22
|
unitAmountCents: line.amountCents ?? undefined,
|
|
22
|
-
taxCode: typeof line.taxable === 'boolean' ? (line.taxable ? 'GST' : 'GST_FREE') :
|
|
23
|
+
taxCode: typeof line.taxable === 'boolean' ? (line.taxable ? 'GST' : 'GST_FREE') : null,
|
|
23
24
|
};
|
|
24
25
|
}
|
|
25
26
|
|
|
@@ -37,6 +38,14 @@ export function ledgerLinesFor(item) {
|
|
|
37
38
|
// `observedAt` is the last successful read from Seazona, so the ledger's
|
|
38
39
|
// out-of-date check measures how old Seazona's report really is.
|
|
39
40
|
export function toLedgerJob(item) {
|
|
41
|
+
const { lines } = ledgerLinesFor(item);
|
|
42
|
+
// Totals remain evidence when an invoice has no lines.
|
|
43
|
+
// A shared invoice cannot supply one job's totals. Never split its tax.
|
|
44
|
+
const sourceTotals = item.invoice && item.invoiceOrderCount === 1 ? {
|
|
45
|
+
subtotalCents: item.invoice.subtotalCents ?? null,
|
|
46
|
+
taxCents: item.invoice.taxCents ?? null,
|
|
47
|
+
totalCents: item.invoice.totalCents ?? null,
|
|
48
|
+
} : null;
|
|
40
49
|
return {
|
|
41
50
|
sourceSystem: SEAZONA_SOURCE_SYSTEM,
|
|
42
51
|
sourceJobRef: item.sourceId,
|
|
@@ -50,7 +59,8 @@ export function toLedgerJob(item) {
|
|
|
50
59
|
jobDate: item.invoice?.invoiceDate ?? item.dueOn ?? item.orderedOn ?? null,
|
|
51
60
|
// The draft rules bill only the statuses the lab chose (for example Shipped).
|
|
52
61
|
sourceStatus: item.status ?? null,
|
|
53
|
-
lines
|
|
62
|
+
lines,
|
|
63
|
+
sourceTotals,
|
|
54
64
|
observedAt: item.syncedAt,
|
|
55
65
|
actor: SEAZONA_SOURCE_SYSTEM,
|
|
56
66
|
};
|
|
@@ -1,4 +1,16 @@
|
|
|
1
|
+
import { and, inArray, isNull } from 'drizzle-orm';
|
|
1
2
|
import { readBillableWork } from './feed.js';
|
|
3
|
+
import { seazonaOrders } from './schema.js';
|
|
4
|
+
|
|
5
|
+
// Presence is not billing eligibility. Keep a job that returned to an open
|
|
6
|
+
// status, lost its lines, or changed its date separate from a missing job.
|
|
7
|
+
export async function readWorkJobPresence(db, { jobRefs }) {
|
|
8
|
+
if (!jobRefs.length) return { presentRefs: [] };
|
|
9
|
+
const rows = await db.select({ ref: seazonaOrders.id }).from(seazonaOrders).where(and(
|
|
10
|
+
inArray(seazonaOrders.id, jobRefs), isNull(seazonaOrders.deletedInSourceAt),
|
|
11
|
+
));
|
|
12
|
+
return { presentRefs: rows.map((row) => row.ref) };
|
|
13
|
+
}
|
|
2
14
|
|
|
3
15
|
// The billable jobs in Flow's copy of Seazona, by day, behind the port's
|
|
4
16
|
// `billableJobs` (the daily reconcile, server/core/reconcile.js). It walks the
|
package/server/ui/invoicing.js
CHANGED
|
@@ -1154,20 +1154,31 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
1154
1154
|
const sendButton = context.canIssue && to && ready ? h('button', {
|
|
1155
1155
|
class: 'fx-btn fx-btn--primary', type: 'button',
|
|
1156
1156
|
onclick: async () => {
|
|
1157
|
-
if (
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
confirm: emails ? 'Send and email' : 'Send',
|
|
1162
|
-
});
|
|
1163
|
-
if (!ok) return;
|
|
1157
|
+
if (sendButton.disabled) return;
|
|
1158
|
+
sendButton.disabled = true;
|
|
1159
|
+
const label = sendButton.textContent;
|
|
1160
|
+
let cancelled = false;
|
|
1164
1161
|
try {
|
|
1162
|
+
if (changed() && !(await save())) return;
|
|
1163
|
+
const ok = await ui.dialog({
|
|
1164
|
+
title: `Send this invoice to ${to}?`,
|
|
1165
|
+
body: h('p', {}, `It gets the next number (${nextNumber}). ${sendModeNote(to, invoice.sendMode, 'it')}`),
|
|
1166
|
+
confirm: emails ? 'Send and email' : 'Send',
|
|
1167
|
+
});
|
|
1168
|
+
if (!ok) { cancelled = true; return; }
|
|
1169
|
+
sendButton.setAttribute('aria-busy', 'true');
|
|
1170
|
+
sendButton.replaceChildren(h('span', { class: 'fx-spinner fx-spinner--sm', 'aria-hidden': 'true' }), 'Sending…');
|
|
1165
1171
|
await flow('/api/invoicing/invoices/send-ready', { method: 'POST', body: JSON.stringify({ issueDate: localDate(), invoiceIds: [invoice.id] }) });
|
|
1166
1172
|
const { invoice: fresh } = await flow(`/api/invoicing/invoices/${invoice.id}`);
|
|
1167
1173
|
toast(`${fresh.number} is on the way to ${to}.`, 'success');
|
|
1168
1174
|
openNext(ui, invoice.id);
|
|
1169
1175
|
} catch (error) {
|
|
1170
1176
|
toast(error.message, 'danger', { stay: true });
|
|
1177
|
+
} finally {
|
|
1178
|
+
sendButton.disabled = false;
|
|
1179
|
+
sendButton.removeAttribute('aria-busy');
|
|
1180
|
+
sendButton.textContent = label;
|
|
1181
|
+
if (cancelled && sendButton.isConnected && document.activeElement === document.body) sendButton.focus();
|
|
1171
1182
|
}
|
|
1172
1183
|
},
|
|
1173
1184
|
}, emails ? `Send to ${to} and email` : `Send to ${to}`) : null;
|