@arcforgelabs/flow 2026.11.5 → 2026.11.7

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.
Files changed (46) hide show
  1. package/npm-shrinkwrap.json +2 -2
  2. package/package.json +1 -1
  3. package/server/api/invoicing.js +5 -2
  4. package/server/app.js +1 -1
  5. package/server/core/automations.js +4 -1
  6. package/server/core/invoice-rules.js +64 -15
  7. package/server/core/invoicing-rules.js +50 -6
  8. package/server/core/job-board.js +3 -2
  9. package/server/core/ledger.js +10 -2
  10. package/server/core/pipeline.js +5 -2
  11. package/server/core/price-list.js +41 -12
  12. package/server/core/sending.js +3 -2
  13. package/server/core/sync-now.js +38 -0
  14. package/server/db/migrations/0029_job_remake.sql +1 -0
  15. package/server/db/migrations/meta/0029_snapshot.json +5069 -0
  16. package/server/db/migrations/meta/_journal.json +7 -0
  17. package/server/db/schema.js +3 -0
  18. package/server/integrations/port.js +3 -0
  19. package/server/integrations/seazona/feed.js +1 -0
  20. package/server/integrations/seazona/history/index.js +5 -2
  21. package/server/integrations/seazona/index.js +44 -0
  22. package/server/integrations/seazona/lanes.js +3 -0
  23. package/server/integrations/seazona/ledger.js +1 -0
  24. package/server/integrations/seazona/map.js +2 -0
  25. package/server/integrations/seazona/migrations/0008_order_remake.sql +1 -0
  26. package/server/integrations/seazona/migrations/meta/0008_snapshot.json +1526 -0
  27. package/server/integrations/seazona/migrations/meta/_journal.json +7 -0
  28. package/server/integrations/seazona/scheduler.js +13 -2
  29. package/server/integrations/seazona/schema.js +2 -0
  30. package/server/integrations/seazona/settings.js +3 -0
  31. package/server/integrations/seazona/store.js +14 -0
  32. package/server/integrations/seazona/sweep.js +13 -3
  33. package/server/integrations/sentry/index.js +9 -4
  34. package/server/integrations/setup.js +21 -1
  35. package/server/integrations/xero/catalogue.js +40 -0
  36. package/server/integrations/xero/drafts.js +16 -5
  37. package/server/integrations/xero/index.js +35 -0
  38. package/server/integrations/xero/lookup.js +11 -4
  39. package/server/integrations/xero/settings.js +8 -0
  40. package/server/integrations/xero/webhooks.js +4 -3
  41. package/server/ui/app.js +64 -3
  42. package/server/ui/flow.css +2 -1
  43. package/server/ui/kit.js +3 -2
  44. package/server/ui/monitoring.js +63 -3
  45. package/server/ui/price-list.js +5 -2
  46. package/skills/flow/SKILL.md +31 -1
@@ -204,6 +204,13 @@
204
204
  "when": 1794456064675,
205
205
  "tag": "0028_sharp_power_pack",
206
206
  "breakpoints": true
207
+ },
208
+ {
209
+ "idx": 29,
210
+ "version": "7",
211
+ "when": 1794463202822,
212
+ "tag": "0029_job_remake",
213
+ "breakpoints": true
207
214
  }
208
215
  ]
209
216
  }
@@ -253,6 +253,9 @@ export const billingObligations = pgTable('billing_obligations', {
253
253
  // The work system's status for the job, as it reported it (for example
254
254
  // "Shipped"). The draft rules read it.
255
255
  sourceStatus: text('source_status'),
256
+ // The work system marks the job a remake (Seazona's remake flag): the
257
+ // invoice rule field `remake` reads it.
258
+ remake: boolean('remake').notNull().default(false),
256
259
  // Flow's own stage: the job is ready to invoice, since when and by whom (a
257
260
  // person, or an automation such as one that reads the work system status).
258
261
  readyAt: timestamp('ready_at', { withTimezone: true }),
@@ -37,7 +37,10 @@ export const CAPABILITIES = Object.freeze([
37
37
  'createCustomer', // { organisationId, contactId, actor }: create the system's customer from the Flow contact. Answers { outcome, contact }. Xero, when Create missing clients is on.
38
38
  'findInvoices', // sales invoices in the accounting system's copy, for the matcher; never a live call
39
39
  'readChanges', // { state: fresh | read | unavailable }: the accounting system's latest changes into its copy now, just before Flow drafts or sends (read before act)
40
+ 'itemMatches', // { organisationId, codes } -> the Flow price codes that send as an item the system sells (Xero: linked item, else same code), from its copy; null while not connected or its item list was never read; never a live call
40
41
  'listPriceItems', // the system's own price list from its copy in Flow, for the price list's links and check; never a live call
42
+ 'syncNow', // { asked } -> { state: started | running | read | off | unavailable, reason?, changed? }: a person's Sync now; read the system's latest changes into its copy now, inside its call guard. off: its "Include in Sync now" setting is off and `asked` (a person's Sync for this integration alone) is false (server/core/sync-now.js)
43
+ 'syncNowStatus', // {} -> { included, running, lastAt, ok }: for the Sync now button; reads Flow's data only
41
44
  'health', // connection state for Settings
42
45
  'alerts', // what only a person can clear (a refused sign-in, stopped calls), for the Integrations page; reads Flow's data only
43
46
  'captureError', // an error Flow logged, sent to the installation's own error service (Sentry)
@@ -77,6 +77,7 @@ function toFeedEntry({ order, invoice, client, invoiceOrderCount }) {
77
77
  lines: order.lines,
78
78
  subtotalCents: order.subtotalCents,
79
79
  patientReference: order.patientReference,
80
+ remake: order.remake === true,
80
81
  syncedAt: order.lastSyncedAt.toISOString(),
81
82
  invoice: invoice ? {
82
83
  invoiceNumber: invoice.invoiceNumber,
@@ -495,12 +495,13 @@ export async function readHistoryWork(db, { cursor = null, limit = 200 } = {}) {
495
495
  }
496
496
  const pageSize = Math.max(1, Math.min(1000, limit));
497
497
  const changedAt = sql`to_char(${seazonaHistoryJobs.lastChangedAt} at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`;
498
- const rows = await db.select({ job: seazonaHistoryJobs, changedAt })
498
+ const rows = await db.select({ job: seazonaHistoryJobs, changedAt, remake: seazonaOrders.remake })
499
499
  .from(seazonaHistoryJobs)
500
+ .leftJoin(seazonaOrders, eq(seazonaOrders.id, seazonaHistoryJobs.mirrorOrderId))
500
501
  .where(after ? sql`(${seazonaHistoryJobs.lastChangedAt}, ${seazonaHistoryJobs.jobKey}) > (${after.at}::timestamptz, ${after.key})` : undefined)
501
502
  .orderBy(seazonaHistoryJobs.lastChangedAt, seazonaHistoryJobs.jobKey)
502
503
  .limit(pageSize);
503
- const items = rows.map(({ job }) => {
504
+ const items = rows.map(({ job, remake }) => {
504
505
  const sourceJobRef = job.mirrorOrderId ?? job.caseRef ?? `history:${job.jobKey}`;
505
506
  return {
506
507
  source: 'seazona',
@@ -518,6 +519,8 @@ export async function readHistoryWork(db, { cursor = null, limit = 200 } = {}) {
518
519
  shippedOn: job.shippedDate,
519
520
  jobDate: job.jobDate ?? job.dueDate ?? job.shippedDate,
520
521
  sourceStatus: job.status,
522
+ // The order's remake flag, from Flow's copy of the order.
523
+ remake: remake === true,
521
524
  lines: (job.lines ?? []).map(ledgerLine),
522
525
  // The CSV reports a total, but no invoice tax allocation.
523
526
  sourceTotals: job.totalCents == null ? null : { totalCents: job.totalCents },
@@ -53,10 +53,15 @@ export const SEAZONA_SETUP = {
53
53
  { name: 'SEAZONA_API_CLIENT_SECRET', label: 'Client secret', secret: true, help: 'Stored encrypted. Flow never shows it again.' },
54
54
  { name: 'SEAZONA_ORDER_URL', label: 'Order address', kind: 'url', help: 'Optional. Open any order in Seazona and paste its address here, or write {ref} where the order id goes. Stage moves on the Jobs board and Open in Seazona use it.' },
55
55
  { name: 'SEAZONA_KEEP_PATIENT_REFERENCE', label: 'Keep patient names', kind: 'onoff', help: 'On: Flow keeps the patient name of each job for the invoice Reference.' },
56
+ { name: 'SEAZONA_SYNC_NOW', label: 'Include in Sync now', kind: 'onoff', default: 'on', help: 'On: Sync now on the Integrations page reads Seazona\'s new and changed orders, invoices and practices.' },
56
57
  { name: 'SEAZONA_API_READ_HIDE_PATIENT', label: 'Hide patient details', kind: 'onoff', help: 'On: an AI assistant reading Seazona through Flow gets no patient, note, file or clinical fields.' },
57
58
  ],
58
59
  };
59
60
 
61
+ // The lanes Sync now reads: what changes from day to day. Products, lab
62
+ // users, the history read and the sweep keep their own schedule.
63
+ export const SYNC_NOW_LANES = Object.freeze(['invoices', 'orders_recent', 'orders_active', 'clients']);
64
+
60
65
  // What a person reads for each lane: the Seazona records it copies.
61
66
  const READ_NAMES = {
62
67
  invoices: 'invoices',
@@ -72,6 +77,10 @@ const readNames = (lanes) => lanes.map((lane) => READ_NAMES[lane.lane] ?? lane.l
72
77
  export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.now, timers } = {}) {
73
78
  let runtime = null; // { settings, db, client, scheduler }
74
79
  const actReader = createActReader({ now });
80
+ // Sync now with no background sync (sandbox, replay, or background jobs
81
+ // off): one run at a time, and when the last one ended.
82
+ let manual = null;
83
+ let manualLast = null;
75
84
 
76
85
  function current() {
77
86
  if (!runtime) throw new Error('The Seazona adapter has not started');
@@ -121,6 +130,7 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
121
130
 
122
131
  async stop() {
123
132
  await runtime?.scheduler?.stop();
133
+ await manual;
124
134
  runtime = null;
125
135
  },
126
136
 
@@ -178,6 +188,40 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
178
188
  return alerts;
179
189
  },
180
190
 
191
+ // Sync now (server/core/sync-now.js): read new and changed orders,
192
+ // invoices and practices now, inside the rate governor. It answers at
193
+ // once; the run goes on in the background. Off while "Include in Sync
194
+ // now" is off, unless a person asked for Seazona alone (`asked`, its own
195
+ // Sync button).
196
+ async syncNow({ asked = false } = {}) {
197
+ if (!runtime) return { state: 'unavailable', reason: 'Seazona has not started' };
198
+ if (!runtime.settings.syncNow && !asked) return { state: 'off' };
199
+ const options = { only: SYNC_NOW_LANES };
200
+ if (runtime.scheduler) {
201
+ const state = runtime.scheduler.runNow(options);
202
+ return state === 'stopped' ? { state: 'unavailable', reason: 'Seazona is stopping' } : { state };
203
+ }
204
+ if (manual) return { state: 'running' };
205
+ const at = new Date(Number(now())).toISOString();
206
+ manual = runtime.run(options)
207
+ .then((result) => { manualLast = { at, ok: result.ok }; })
208
+ .catch(() => { manualLast = { at, ok: false }; })
209
+ .finally(() => { manual = null; });
210
+ return { state: 'started' };
211
+ },
212
+
213
+ async syncNowStatus() {
214
+ if (!runtime) return { included: false, running: false, lastAt: null, ok: null };
215
+ const scheduled = runtime.scheduler?.status();
216
+ const last = scheduled ? scheduled.lastRun : manualLast;
217
+ return {
218
+ included: runtime.settings.syncNow,
219
+ running: Boolean(scheduled?.running || manual),
220
+ lastAt: last?.at ?? null,
221
+ ok: last ? last.ok : null,
222
+ };
223
+ },
224
+
181
225
  // Billable work since a cursor: complete and shipped orders that
182
226
  // changed, with the Seazona invoice that carries each one. Orders
183
227
  // deleted in Seazona are never billable; they come back in `withdrawn`
@@ -28,6 +28,7 @@ import {
28
28
  recordOrderFailure,
29
29
  recordRecordFailure,
30
30
  selectActiveOrders,
31
+ syncRemakeFlags,
31
32
  touchSynced,
32
33
  upsertRecords,
33
34
  upsertReference,
@@ -507,6 +508,8 @@ const laneRuns = {
507
508
  else failures.push(result.failure);
508
509
  }
509
510
  await touchSynced(db, 'orders', unchanged, { at });
511
+ // The remake flag is on the list row: kept with no detail read.
512
+ await syncRemakeFlags(db, new Map(unchanged.map((id) => [id, byId.get(id)?.isRemake === true])), { at });
510
513
  summary.unchanged += unchanged.length;
511
514
  const failureDetails = judgeReads('recent order reads', detailReads, failures);
512
515
  const through = lastDone ?? (Number.isFinite(doneThrough) ? doneThrough : null);
@@ -60,6 +60,7 @@ export function toLedgerJob(item) {
60
60
  jobDate: item.invoice?.invoiceDate ?? item.dueOn ?? item.orderedOn ?? null,
61
61
  // The draft rules bill only the statuses the lab chose (for example Shipped).
62
62
  sourceStatus: item.status ?? null,
63
+ remake: item.remake === true,
63
64
  lines,
64
65
  sourceTotals,
65
66
  observedAt: item.syncedAt,
@@ -136,6 +136,8 @@ export function mapOrder(listItem = {}, detail = null, { keepPatientReference =
136
136
  lines,
137
137
  subtotalCents: lines.length ? sumCents(lines) : null,
138
138
  patientReference: keepPatientReference ? textOrNull(merged.patient) : null,
139
+ // Seazona's own remake flag (the list and the order both carry it).
140
+ remake: merged.isRemake === true,
139
141
  };
140
142
  }
141
143
 
@@ -0,0 +1 @@
1
+ ALTER TABLE "seazona_orders" ADD COLUMN IF NOT EXISTS "remake" boolean DEFAULT false NOT NULL;