@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.
- 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/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/seazona/history/index.js +4 -7
- package/server/integrations/seazona/ledger.js +14 -4
- 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(),
|
|
@@ -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
|
},
|
|
@@ -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
|
};
|
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;
|