@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.
@@ -176,6 +176,13 @@
176
176
  "when": 1793233782043,
177
177
  "tag": "0024_rule_icon",
178
178
  "breakpoints": true
179
+ },
180
+ {
181
+ "idx": 25,
182
+ "version": "7",
183
+ "when": 1793972387349,
184
+ "tag": "0025_powerful_leopardon",
185
+ "breakpoints": true
179
186
  }
180
187
  ]
181
188
  }
@@ -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((l) => ({
521
- itemCode: l.code ?? null,
522
- description: l.description,
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
- // Unknown amount or tax: the price item with the same code fills them.
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') : undefined,
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: ledgerLinesFor(item).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
@@ -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 (changed() && !(await save())) return;
1158
- const ok = await ui.dialog({
1159
- title: `Send this invoice to ${to}?`,
1160
- body: h('p', {}, `It gets the next number (${nextNumber}). ${sendModeNote(to, invoice.sendMode, 'it')}`),
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;