@arcforgelabs/flow 2026.10.53-3 → 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(),
@@ -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
  },
@@ -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
  };
@@ -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;