@arcforgelabs/flow 2026.10.53-1 → 2026.10.53-3
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/reconcile.js +15 -1
- package/server/core/sending.js +13 -1
- package/server/db/schema.js +2 -1
- package/server/integrations/port.js +1 -0
- package/server/integrations/seazona/index.js +5 -1
- package/server/integrations/seazona/reconcile.js +12 -0
- package/server/ui/invoicing.js +16 -0
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcforgelabs/flow",
|
|
3
|
-
"version": "2026.10.53-
|
|
3
|
+
"version": "2026.10.53-3",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@arcforgelabs/flow",
|
|
9
|
-
"version": "2026.10.53-
|
|
9
|
+
"version": "2026.10.53-3",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@clerk/backend": "3.25.0",
|
package/package.json
CHANGED
package/server/core/reconcile.js
CHANGED
|
@@ -124,6 +124,20 @@ export async function reconcileDays(db, organisationId, { integrations, from, to
|
|
|
124
124
|
and e.kind in ('observed', 'source_changed') and e.actor <> ${HISTORY_ACTOR})`,
|
|
125
125
|
));
|
|
126
126
|
|
|
127
|
+
// A billable feed cannot prove absence. Check the remaining ledger refs
|
|
128
|
+
// against the source copy without billing filters. Older adapters retain
|
|
129
|
+
// the feed comparison until they declare workJobPresence.
|
|
130
|
+
const present = new Set(work.keys());
|
|
131
|
+
for (const adapter of workAdapters) {
|
|
132
|
+
if (!adapter.capabilities.workJobPresence) continue;
|
|
133
|
+
const jobRefs = [...new Set(obligations
|
|
134
|
+
.filter((o) => o.sourceSystem === adapter.id && !present.has(`${adapter.id}:${o.sourceJobRef}`))
|
|
135
|
+
.map((o) => o.sourceJobRef))];
|
|
136
|
+
for (let i = 0; i < jobRefs.length; i += FIND_BATCH) {
|
|
137
|
+
const { presentRefs } = await adapter.capabilities.workJobPresence({ organisationId, jobRefs: jobRefs.slice(i, i + FIND_BATCH) });
|
|
138
|
+
for (const ref of presentRefs) present.add(`${adapter.id}:${ref}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
127
141
|
const reported = new Set(workAdapters.map((a) => a.id));
|
|
128
142
|
const inLedger = new Set();
|
|
129
143
|
for (const o of obligations) {
|
|
@@ -139,7 +153,7 @@ export async function reconcileDays(db, organisationId, { integrations, from, to
|
|
|
139
153
|
if (reported.has(o.sourceSystem)) {
|
|
140
154
|
const key = `${o.sourceSystem}:${o.sourceJobRef}`;
|
|
141
155
|
inLedger.add(key);
|
|
142
|
-
if (!
|
|
156
|
+
if (!present.has(key)) entry.differences.missingInWork.push({ id: o.id, cents: o.totalCents });
|
|
143
157
|
}
|
|
144
158
|
}
|
|
145
159
|
for (const [key, job] of work) {
|
package/server/core/sending.js
CHANGED
|
@@ -439,7 +439,8 @@ export async function sendQueued(db, organisationId, { accounting, limit = WORKE
|
|
|
439
439
|
summary.waiting += 1;
|
|
440
440
|
} else {
|
|
441
441
|
const error = result.error ?? { code: `send_${result.outcome}`, message: `Sending ended as ${result.outcome}.` };
|
|
442
|
-
|
|
442
|
+
const xero = xeroMatch(error);
|
|
443
|
+
await settle(db, organisationId, result.invoiceId, { sendState: 'failed', sendError: { code: error.code, message: error.message, ...(error.short ? { short: error.short } : {}), ...(xero ? { xero } : {}) } });
|
|
443
444
|
summary.failed += 1;
|
|
444
445
|
summary.refused = [...new Set([...(summary.refused ?? []), error.code])];
|
|
445
446
|
const fact = refusalFact(known.get(result.invoiceId), error);
|
|
@@ -450,6 +451,17 @@ export async function sendQueued(db, organisationId, { accounting, limit = WORKE
|
|
|
450
451
|
return summary;
|
|
451
452
|
}
|
|
452
453
|
|
|
454
|
+
// The Xero invoice a refusal matched, kept so the page can link to it.
|
|
455
|
+
// Only an id, a number and a status. Anything else in the error stays out.
|
|
456
|
+
function xeroMatch(error) {
|
|
457
|
+
const xero = error?.details?.xero;
|
|
458
|
+
if (!xero || typeof xero !== 'object') return null;
|
|
459
|
+
if (!INVOICE_ID.test(xero.invoiceId ?? '')) return null;
|
|
460
|
+
const number = INVOICE_NUMBER.test(xero.number ?? '') ? xero.number : null;
|
|
461
|
+
const status = /^[A-Z0-9_]{1,32}$/.test(xero.status ?? '') ? xero.status : null;
|
|
462
|
+
return { invoiceId: xero.invoiceId, ...(number ? { number } : {}), ...(status ? { status } : {}) };
|
|
463
|
+
}
|
|
464
|
+
|
|
453
465
|
function settle(db, organisationId, invoiceId, fields) {
|
|
454
466
|
return db.update(invoices).set({ ...fields, sendUpdatedAt: sql`now()` })
|
|
455
467
|
.where(and(eq(invoices.organisationId, organisationId), eq(invoices.id, invoiceId), eq(invoices.sendState, 'queued')));
|
package/server/db/schema.js
CHANGED
|
@@ -112,7 +112,8 @@ export const invoices = pgTable('invoices', {
|
|
|
112
112
|
readyAt: timestamp('ready_at', { withTimezone: true }),
|
|
113
113
|
readyBy: uuid('ready_by').references(() => people.id, { onDelete: 'set null' }),
|
|
114
114
|
// Sending to the accounting system (server/core/sending.js): queued by a
|
|
115
|
-
// person, then sent or failed. `sendError` is { code, message
|
|
115
|
+
// person, then sent or failed. `sendError` is { code, message, short?, xero? }
|
|
116
|
+
// while failed. `xero` is the matched invoice { invoiceId, number, status }.
|
|
116
117
|
sendState: text('send_state'),
|
|
117
118
|
sendError: jsonb('send_error'),
|
|
118
119
|
sendRequestedBy: uuid('send_requested_by').references(() => people.id, { onDelete: 'set null' }),
|
|
@@ -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`
|
|
@@ -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() {
|
|
@@ -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
|
@@ -1423,6 +1423,21 @@ function issuedView(invoice, ui, context, redraw) {
|
|
|
1423
1423
|
? h('button', { class: 'fx-btn', type: 'button', onclick: () => act('revise', `${invoice.number} is a draft again.`) }, 'Revise') : null;
|
|
1424
1424
|
const preview = previewButton(ui, invoice.id);
|
|
1425
1425
|
actionBar = h('div', { class: 'fx-ph__actions' }, [voidButton, reviseButton, preview, send].filter(Boolean));
|
|
1426
|
+
// The same problem notice as a draft. A matched Xero invoice gets the
|
|
1427
|
+
// same Open in Xero link as the Xero card (go.xero.com, reconcile.js).
|
|
1428
|
+
const found = invoice.problems ?? [];
|
|
1429
|
+
const shorts = found.length && found.every((p) => p.short) ? [...new Set(found.map((p) => p.short))].join(', ') : null;
|
|
1430
|
+
const problemNotice = found.length
|
|
1431
|
+
? ui.notice([...new Set(found.map((p) => brief(p.message)))].join(' '), 'danger', 'Problem.', { short: shorts })
|
|
1432
|
+
: null;
|
|
1433
|
+
const matched = found.find((p) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(p.xero?.invoiceId ?? ''));
|
|
1434
|
+
const openXero = matched ? h('a', {
|
|
1435
|
+
class: 'fx-btn fx-btn--sm fx-btn--ghost',
|
|
1436
|
+
href: `https://go.xero.com/AccountsReceivable/View.aspx?InvoiceID=${encodeURIComponent(matched.xero.invoiceId)}`,
|
|
1437
|
+
target: '_blank',
|
|
1438
|
+
rel: 'noopener',
|
|
1439
|
+
}, 'Open in Xero') : null;
|
|
1440
|
+
const problemRow = problemNotice ? (openXero ? h('div', { class: 'fx-offer' }, problemNotice, openXero) : problemNotice) : null;
|
|
1426
1441
|
const header = h('header', { class: 'fx-ph' },
|
|
1427
1442
|
h('div', { class: 'fx-ph__text' },
|
|
1428
1443
|
h('h1', { class: 'fx-title' }, invoice.number),
|
|
@@ -1449,6 +1464,7 @@ function issuedView(invoice, ui, context, redraw) {
|
|
|
1449
1464
|
chrome.record,
|
|
1450
1465
|
chrome.pager,
|
|
1451
1466
|
header,
|
|
1467
|
+
problemRow,
|
|
1452
1468
|
h('div', { class: 'fx-invoice__grid' },
|
|
1453
1469
|
h('div', { class: 'fx-invoice__main' },
|
|
1454
1470
|
h('section', { class: 'fx-card fx-doc' },
|