@arcforgelabs/flow 2026.10.36 → 2026.10.38

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 (38) hide show
  1. package/README.md +1 -0
  2. package/npm-shrinkwrap.json +2 -2
  3. package/package.json +1 -1
  4. package/server/api/custom-fields.js +2 -1
  5. package/server/api/invoicing.js +61 -3
  6. package/server/auth/clerk.js +35 -0
  7. package/server/auth/routes.js +45 -6
  8. package/server/core/custom-fields.js +18 -0
  9. package/server/core/host.js +24 -0
  10. package/server/core/job-board.js +2 -1
  11. package/server/core/job-match.js +241 -0
  12. package/server/core/ledger.js +80 -1
  13. package/server/index.js +9 -3
  14. package/server/integrations/seazona/feed.js +4 -1
  15. package/server/integrations/seazona/index.js +1 -1
  16. package/server/integrations/seazona/settings.js +5 -2
  17. package/server/integrations/sentry/index.js +5 -2
  18. package/server/integrations/xero/fit.js +108 -0
  19. package/server/integrations/xero/import.js +6 -1
  20. package/server/integrations/xero/index.js +4 -4
  21. package/server/integrations/xero/ledger.js +3 -0
  22. package/server/integrations/xero/links.js +293 -0
  23. package/server/integrations/xero/migrations/0008_xero_invoice_links.sql +26 -0
  24. package/server/integrations/xero/migrations/meta/_journal.json +7 -0
  25. package/server/integrations/xero/reconcile.js +17 -11
  26. package/server/integrations/xero/routes.js +42 -0
  27. package/server/integrations/xero/settings.js +12 -5
  28. package/server/ui/app-page.js +1 -1
  29. package/server/ui/app.js +142 -23
  30. package/server/ui/bulk-bar.js +100 -0
  31. package/server/ui/flow.css +225 -11
  32. package/server/ui/invoicing.js +234 -30
  33. package/server/ui/job-board.js +60 -34
  34. package/server/ui/job-view.js +30 -7
  35. package/server/ui/jobs.js +469 -38
  36. package/server/ui/reconcile.js +275 -0
  37. package/server/ui/routes.js +1 -1
  38. package/server/ui/xero-import.js +329 -0
@@ -1,7 +1,7 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm';
3
3
  import {
4
- billingObligations, invoices, obligationEvents, obligationPayments, obligationWritebacks, priceItems,
4
+ billingObligations, invoices, linkRejections, linkSuggestions, obligationEvents, obligationPayments, obligationWritebacks, priceItems,
5
5
  } from '../db/schema.js';
6
6
  import { FlowError, notFound } from './errors.js';
7
7
  import { createDraftInvoice, getInvoice, updateDraftInvoice } from './invoices.js';
@@ -668,6 +668,85 @@ export async function setHold(db, organisationId, obligationId, { holdState, rea
668
668
  });
669
669
  }
670
670
 
671
+ // A job with no invoice of its own whose accounting evidence is ambiguous:
672
+ // the matcher found accounting invoices that may be this job's, but none
673
+ // exactly. A person settles it on the Reconcile page: "Make invoice", or a
674
+ // link to a Flow invoice (linkJobToInvoice).
675
+ const unlinked = (o) => o.accountingState === 'ambiguous' && !o.invoiceId && !o.accountingRef;
676
+
677
+ // The matcher's open suggestions for one job, best first.
678
+ export async function linkSuggestionsFor(db, organisationId, obligationId) {
679
+ return db.select({
680
+ system: linkSuggestions.system, externalId: linkSuggestions.externalId, externalNumber: linkSuggestions.externalNumber,
681
+ score: linkSuggestions.score, signals: linkSuggestions.signals,
682
+ }).from(linkSuggestions).where(and(
683
+ eq(linkSuggestions.organisationId, organisationId),
684
+ eq(linkSuggestions.obligationId, obligationId),
685
+ eq(linkSuggestions.state, 'open'),
686
+ )).orderBy(asc(linkSuggestions.rank));
687
+ }
688
+
689
+ // Rejects every open suggestion for the job ("not this one"): the matcher
690
+ // never suggests those pairs again.
691
+ async function rejectSuggestions(tx, obligation, { actor }) {
692
+ const open = await tx.select().from(linkSuggestions).where(and(
693
+ eq(linkSuggestions.organisationId, obligation.organisationId),
694
+ eq(linkSuggestions.obligationId, obligation.id),
695
+ eq(linkSuggestions.state, 'open'),
696
+ ));
697
+ for (const suggestion of open) {
698
+ await tx.update(linkSuggestions).set({ state: 'rejected', decidedBy: actor, decidedAt: sql`now()`, updatedAt: sql`now()` })
699
+ .where(eq(linkSuggestions.id, suggestion.id));
700
+ await tx.insert(linkRejections)
701
+ .values({ organisationId: obligation.organisationId, obligationId: obligation.id, system: suggestion.system, externalId: suggestion.externalId, actor })
702
+ .onConflictDoNothing();
703
+ }
704
+ return open.map((s) => s.externalId);
705
+ }
706
+
707
+ // "Make invoice": a person says the job has no invoice yet. Every suggestion
708
+ // is rejected and the job is recorded as not found, so it can draft. The
709
+ // live check before the draft still runs (draftInvoiceForObligation).
710
+ export async function confirmNoInvoice(db, organisationId, obligationId, { actor }) {
711
+ requireActor(actor);
712
+ return db.transaction(async (tx) => {
713
+ const obligation = await loadObligation(tx, organisationId, obligationId, { lock: true });
714
+ if (!unlinked(obligation)) throw new FlowError('invalid', 'the job is not waiting for a person to settle its invoice');
715
+ const rejected = await rejectSuggestions(tx, obligation, { actor });
716
+ const row = await update(tx, obligation, { accountingState: 'not_found', accountingCheckedAt: sql`now()` });
717
+ await addEvent(tx, row, 'accounting_checked', actor, { reported: 'not_found', state: 'not_found', matchedBy: 'person', rejected });
718
+ return row;
719
+ });
720
+ }
721
+
722
+ // A person links a job to a Flow invoice that already exists (Reconcile).
723
+ // The job then has its invoice: Flow drafts no other. A job held on unclear
724
+ // accounting evidence is settled by it; whether the Flow invoice matches one
725
+ // in the accounting system is that integration's own work, not the job's.
726
+ // `accountingRef` is given by an accounting integration that made the Flow
727
+ // invoice from the accounting invoice the job is tied to already.
728
+ export async function linkJobToInvoice(db, organisationId, obligationId, { invoiceId, accountingRef = null, actor }) {
729
+ requireActor(actor);
730
+ return db.transaction(async (tx) => {
731
+ const obligation = await loadObligation(tx, organisationId, obligationId, { lock: true });
732
+ if (obligation.invoiceId) throw new FlowError('invalid', 'the job has an invoice already');
733
+ if (obligation.accountingRef && obligation.accountingRef !== accountingRef) throw new FlowError('invalid', 'the job is linked to an accounting invoice already');
734
+ const [invoice] = await tx.select({ id: invoices.id, status: invoices.status, contactId: invoices.contactId }).from(invoices)
735
+ .where(and(eq(invoices.organisationId, organisationId), eq(invoices.id, invoiceId)));
736
+ if (!invoice) throw notFound('invoice');
737
+ if (invoice.status === 'void') throw new FlowError('invalid', 'the invoice is void');
738
+ const rejected = await rejectSuggestions(tx, obligation, { actor });
739
+ const row = await update(tx, obligation, {
740
+ invoiceId: invoice.id,
741
+ draftBasis: basisOf(obligation),
742
+ ...(obligation.accountingState === 'ambiguous' ? { accountingState: 'unknown' } : {}),
743
+ ...(obligation.readyAt ? {} : { readyAt: sql`now()`, readyBy: actor }),
744
+ });
745
+ await addEvent(tx, row, 'invoice_linked', actor, { invoiceId: invoice.id, matchedBy: 'person', rejected });
746
+ return row;
747
+ });
748
+ }
749
+
671
750
  // The obligation's view and the reasons for it, from its stored evidence only.
672
751
  // `accounting` is false while no accounting system is connected: Flow's own
673
752
  // records are then the only check, so a job nobody checked in one can draft.
package/server/index.js CHANGED
@@ -13,6 +13,7 @@ import { createMonitoredLog } from './core/monitoring.js';
13
13
  import { createTracing, traceQueries } from './core/tracing.js';
14
14
  import { createPipelineWorker } from './core/pipeline.js';
15
15
  import { createReconcileWorker } from './core/reconcile.js';
16
+ import { backgroundJobsOn } from './core/host.js';
16
17
  import { createSendWorker } from './core/sending.js';
17
18
  import { resolveSecretsKey } from './core/secrets.js';
18
19
  import { adapters as shippedAdapters } from './integrations/adapters.js';
@@ -96,20 +97,25 @@ try {
96
97
  const integrationsOn = integrations.list().filter((a) => a.enabled).map((a) => `${a.id}${a.state === 'ready' ? '' : ` (${a.state})`}`);
97
98
  console.log(`[integrations] on: ${integrationsOn.join(', ') || 'none'}; secrets key ${secrets ? `from ${secrets.source}` : 'not set (FLOW_SECRETS_KEY)'}`);
98
99
 
100
+ // Background jobs run only where FLOW_BACKGROUND_JOBS allows: never in a
101
+ // Replit workspace, which shares the published app's secrets
102
+ // (server/core/host.js).
103
+ const jobsOn = backgroundJobsOn(process.env);
104
+ if (!jobsOn) console.log('[flow] background jobs off (FLOW_BACKGROUND_JOBS): no sync, pipeline, reconcile or sending in this copy');
99
105
  // The pipeline (server/core/pipeline.js) joins what the integrations copy into
100
106
  // Flow: jobs into the ledger, and each job to its accounting invoice. It runs
101
107
  // only while an integration that feeds it is on, and writes to no other
102
108
  // system. FLOW_PIPELINE=off stops it.
103
- if (process.env.FLOW_PIPELINE !== 'off') {
109
+ if (jobsOn && process.env.FLOW_PIPELINE !== 'off') {
104
110
  createPipelineWorker({ db: database.db, integrations, log }).start();
105
111
  }
106
112
  // The daily reconcile (server/core/reconcile.js) compares Flow's own copies
107
113
  // and opens a review item for a day that does not agree. It never calls or
108
114
  // writes to another system.
109
- createReconcileWorker({ db: database.db, integrations, log }).start();
115
+ if (jobsOn) createReconcileWorker({ db: database.db, integrations, log }).start();
110
116
  // The send worker (server/core/sending.js) sends only what a person queued
111
117
  // with Send all ready, to the accounting integration that is on.
112
- createSendWorker({ db: database.db, integrations, log }).start();
118
+ if (jobsOn) createSendWorker({ db: database.db, integrations, log }).start();
113
119
 
114
120
  let auth = null;
115
121
  if (settings.mode === 'clerk') {
@@ -202,7 +202,7 @@ export async function listWork(db, { now = Date.now(), finishedDays = WORK_FINIS
202
202
  return {
203
203
  sourceSystem: 'seazona',
204
204
  sourceJobRef: order.id,
205
- // The Seazona order id is the case number. Never patient data.
205
+ // The Seazona order id is the case number.
206
206
  caseRef: order.id,
207
207
  status,
208
208
  clientId: order.clientId,
@@ -211,6 +211,9 @@ export async function listWork(db, { now = Date.now(), finishedDays = WORK_FINIS
211
211
  department: order.department,
212
212
  orderedOn: parseSeazonaDate(order.orderedText),
213
213
  dueOn: parseSeazonaDate(order.dueText),
214
+ // The patient, only when this installation keeps patient names
215
+ // (SEAZONA_KEEP_PATIENT_REFERENCE); otherwise the copy has none.
216
+ patient: order.patientReference ?? null,
214
217
  lines: (order.lines ?? []).map((l) => ({ code: l.code ?? null, description: l.description, amountCents: l.amountCents ?? null })),
215
218
  totalCents: order.subtotalCents,
216
219
  };
@@ -111,7 +111,7 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
111
111
  const scheduler = settings.scheduled ? createSeazonaScheduler({ db, settings, run, now, log, timers }) : null;
112
112
  runtime = { settings, db, client, run, scheduler };
113
113
  scheduler?.start();
114
- log.info?.(`[seazona] mode ${settings.mode}; background sync ${scheduler ? 'on' : 'off (live mode only)'}; ${settings.rate.requestsPerMinute} requests a minute at most`);
114
+ log.info?.(`[seazona] mode ${settings.mode}; background sync ${scheduler ? 'on' : settings.mode === 'live' ? 'off (FLOW_BACKGROUND_JOBS)' : 'off (live mode only)'}; ${settings.rate.requestsPerMinute} requests a minute at most`);
115
115
  },
116
116
 
117
117
  async stop() {
@@ -8,6 +8,8 @@
8
8
  // replay recorded, synthetic responses from a file; no network at all.
9
9
  // There is no default mode. Without one the adapter does nothing.
10
10
 
11
+ import { backgroundJobsOn } from '../../core/host.js';
12
+
11
13
  export const MODES = Object.freeze(['live', 'sandbox', 'replay']);
12
14
 
13
15
  // Seazona publishes 60 requests a minute per API client. Flow stays at half of
@@ -190,8 +192,9 @@ export function resolveSeazonaSettings(env = process.env) {
190
192
  credentials: null,
191
193
  replayFile: '',
192
194
  rate,
193
- // The scheduler starts only in live mode (issue #10).
194
- scheduled: mode === 'live',
195
+ // The scheduler starts only in live mode (issue #10), and only where
196
+ // background jobs run: never in a Replit workspace (server/core/host.js).
197
+ scheduled: mode === 'live' && backgroundJobsOn(env),
195
198
  // The address of one order in Seazona's own screens, with {ref} for the
196
199
  // order number, for "Open in Seazona" on a stage move. Optional; one
197
200
  // without {ref} is ignored.
@@ -1,4 +1,5 @@
1
1
  import * as SentryNode from '@sentry/node';
2
+ import { isReplitWorkspace } from '../../core/host.js';
2
3
  import { scrubSpan } from '../../ui/monitoring.js';
3
4
 
4
5
  // The Sentry adapter (docs/SENTRY.md). It sends the errors Flow logs, the
@@ -40,7 +41,7 @@ export const SENTRY_SETUP = {
40
41
  },
41
42
  fields: [
42
43
  { name: 'SENTRY_DSN', label: 'DSN', kind: 'url', help: 'From the Sentry project\'s Client Keys. Browsers see it, so it is not a secret.' },
43
- { name: 'SENTRY_ENVIRONMENT', label: 'Environment', help: 'How Sentry labels this installation, for example production. Empty: production.' },
44
+ { name: 'SENTRY_ENVIRONMENT', label: 'Environment', help: 'How Sentry labels this installation, for example production. Empty: production, or workspace in a Replit workspace.' },
44
45
  { name: 'SENTRY_FEEDBACK', label: 'Report a problem button', kind: 'onoff', help: 'On: everyone signed in can send a report, with a screenshot, to this Sentry project.' },
45
46
  { name: 'SENTRY_TRACES_SAMPLE_RATE', label: 'Traces', help: 'The share of clicks, page loads and requests timed, from 0 (off) to 1 (all). Empty: 0.2.' },
46
47
  ],
@@ -50,7 +51,9 @@ export function readSentrySettings(env) {
50
51
  const dsn = String(env.SENTRY_DSN ?? '').trim();
51
52
  if (!dsn) throw new Error('SENTRY_DSN is not set. Enter the Sentry project\'s DSN in Settings, Integrations.');
52
53
  if (!DSN_PATTERN.test(dsn)) throw new Error('SENTRY_DSN is not a Sentry DSN (https://<key>@<host>/<project id>).');
53
- const environment = String(env.SENTRY_ENVIRONMENT ?? '').trim() || 'production';
54
+ // A Replit workspace shares the published app's secrets; its errors are
55
+ // labelled apart so they never read as the live app's.
56
+ const environment = String(env.SENTRY_ENVIRONMENT ?? '').trim() || (isReplitWorkspace(env) ? 'workspace' : 'production');
54
57
  const feedback = String(env.SENTRY_FEEDBACK ?? 'on').trim() !== 'off';
55
58
  const rate = String(env.SENTRY_TRACES_SAMPLE_RATE ?? '').trim();
56
59
  const tracesSampleRate = rate === '' ? DEFAULT_TRACES_SAMPLE_RATE : Number(rate);
@@ -0,0 +1,108 @@
1
+ // How sure Flow is that a Xero invoice in the copy is one Flow invoice
2
+ // (docs/XERO_IMPORT.md, "Sure match"). The checks are the ones the job
3
+ // matcher learned on work-system jobs against Xero (server/core/pipeline.js
4
+ // scoreCandidate), plus what the Flow invoice's own jobs know: the matcher's
5
+ // tie to this Xero invoice, and the work system's invoice number.
6
+ //
7
+ // A pair is sure only when one piece of hard evidence ties it, or when the
8
+ // score is high and clearly ahead; and in both cases only when it is the one
9
+ // fit both ways (`sureFits`).
10
+
11
+ export const SURE_SCORE = 11; // total, same items, and within 3 days
12
+ export const SURE_LEAD = 3;
13
+ export const POOL_DAYS = 14;
14
+ const day = (iso) => Date.parse(`${iso}T00:00:00Z`);
15
+ const daysApart = (a, b) => (a && b ? Math.abs(day(a) - day(b)) / 86_400_000 : null);
16
+ const withGst = (cents) => Math.round(cents * 1.1);
17
+ const withoutReissue = (ref) => String(ref ?? '').trim().replace(/R$/i, '').toLowerCase();
18
+ const words = (text) => String(text ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
19
+
20
+ // xero: { id, number, contactId, totalCents, issuedOn, itemCodes[], reference }
21
+ // flow: { id, contactId, totalCents, subtotalCents, date, itemCodes[], reference,
22
+ // jobs: [{ accountingRef, sourceInvoiceRef, practitioner }] }
23
+ // Answers { score, signals, hard }; `hard` names the evidence that ties them.
24
+ export function scoreFit(xero, flow) {
25
+ const signals = [];
26
+ const add = (signal, weight) => signals.push({ signal, weight });
27
+ const jobs = flow.jobs ?? [];
28
+ let hard = null;
29
+ if (jobs.some((j) => j.accountingRef && j.accountingRef === xero.id)) hard = 'job_tied';
30
+ else if (xero.number && jobs.some((j) => j.sourceInvoiceRef && withoutReissue(j.sourceInvoiceRef) === withoutReissue(xero.number))) hard = 'work_invoice_number';
31
+ if (hard) add(hard, 10);
32
+ if (xero.contactId && xero.contactId === flow.contactId) add('client', 2);
33
+ if (xero.totalCents === flow.totalCents) add('total', 6);
34
+ else if ([flow.subtotalCents, withGst(flow.subtotalCents)].includes(xero.totalCents)) add('total_gst', 5);
35
+ const ours = new Set(flow.itemCodes ?? []);
36
+ const theirs = new Set(xero.itemCodes ?? []);
37
+ if (ours.size && theirs.size) {
38
+ const shared = [...ours].filter((c) => theirs.has(c)).length;
39
+ if (shared === ours.size && shared === theirs.size) add('items_same', 3);
40
+ else if (shared) add('items_overlap', 1);
41
+ else add('items_differ', -3);
42
+ }
43
+ const apart = daysApart(xero.issuedOn, flow.date);
44
+ if (apart !== null && apart <= 3) add('date_close', 2);
45
+ else if (apart !== null && apart <= POOL_DAYS) add('date_near', 1);
46
+ // A Reference is in the copy unless XERO_KEEP_REFERENCE is off.
47
+ if (xero.reference && flow.reference) {
48
+ if (words(xero.reference) === words(flow.reference)) add('reference_same', 3);
49
+ else add('reference_differs', -2);
50
+ } else if (xero.reference && jobs.some((j) => j.practitioner && words(xero.reference).includes(words(j.practitioner)))) {
51
+ add('practitioner', 1);
52
+ }
53
+ return { score: signals.reduce((sum, s) => sum + s.weight, 0), signals, hard };
54
+ }
55
+
56
+ // Whether the pair may be offered at all: hard evidence, or the same client
57
+ // with an amount that fits and dates within two weeks.
58
+ export function canFit(xero, flow, scored) {
59
+ if (scored.hard) return true;
60
+ const amount = scored.signals.some((s) => s.signal === 'total' || s.signal === 'total_gst');
61
+ const near = scored.signals.some((s) => s.signal === 'date_close' || s.signal === 'date_near');
62
+ return Boolean(xero.contactId && xero.contactId === flow.contactId && amount && near);
63
+ }
64
+
65
+ // pairs: [{ xeroId, flowId, apart, fit }], `fit` being the candidate with its
66
+ // score. A fit loses RIVAL_WEIGHT, and is marked `rival`, when the same Flow
67
+ // invoice has another Xero invoice dated at least RIVAL_DAYS closer to it, or
68
+ // the same Xero invoice has another Flow invoice that much closer. Fits a day
69
+ // or two apart stay level, so a person picks.
70
+ export const RIVAL_WEIGHT = 4;
71
+ export const RIVAL_DAYS = 3;
72
+ export function markRivals(pairs) {
73
+ for (const p of pairs) {
74
+ if (p.fit.hard) continue;
75
+ const closer = pairs.some((o) => o !== p && o.apart + RIVAL_DAYS <= p.apart && (o.flowId === p.flowId || o.xeroId === p.xeroId));
76
+ if (closer) {
77
+ p.fit.score -= RIVAL_WEIGHT;
78
+ p.fit.rival = true;
79
+ }
80
+ }
81
+ }
82
+
83
+ // pairs: [{ xeroId, flowId, score, hard }]. Answers the set of `xeroId` whose
84
+ // best fit is sure: hard evidence or SURE_SCORE, at least SURE_LEAD ahead of
85
+ // that Xero invoice's next fit, and that Flow invoice's best fit back.
86
+ export function sureFits(pairs) {
87
+ const byXero = new Map();
88
+ const byFlow = new Map();
89
+ for (const p of pairs) {
90
+ if (!byXero.has(p.xeroId)) byXero.set(p.xeroId, []);
91
+ if (!byFlow.has(p.flowId)) byFlow.set(p.flowId, []);
92
+ byXero.get(p.xeroId).push(p);
93
+ byFlow.get(p.flowId).push(p);
94
+ }
95
+ const ranked = (list) => [...list].sort((a, b) => b.score - a.score);
96
+ const clear = (list) => {
97
+ const [top, next] = ranked(list);
98
+ if (!top || !(top.hard || top.score >= SURE_SCORE)) return null;
99
+ if (next && (next.hard || top.score - next.score < SURE_LEAD)) return null;
100
+ return top;
101
+ };
102
+ const sure = new Map();
103
+ for (const [xeroId, list] of byXero) {
104
+ const top = clear(list);
105
+ if (top && clear(byFlow.get(top.flowId))?.xeroId === xeroId) sure.set(xeroId, top.flowId);
106
+ }
107
+ return sure;
108
+ }
@@ -5,6 +5,7 @@ import { isCallsStopped } from '../call-guard.js';
5
5
  import { lastSync, syncCatalogue } from './catalogue.js';
6
6
  import { mirrorState } from './lookup.js';
7
7
  import { MIRROR_RESOURCES, mirrorXero } from './mirror.js';
8
+ import { matchXero } from './links.js';
8
9
  import { checkSentInvoices } from './reconcile.js';
9
10
  import { runWebhookWork, WEBHOOK_REFRESH_MS, webhooksActive } from './webhooks.js';
10
11
 
@@ -82,7 +83,11 @@ export async function runImportSlice(db, runtime, job, { maxPages = SLICE_PAGES
82
83
  const sent = invoices && (invoices.inserted || invoices.changed || job.payload?.reason !== 'changes')
83
84
  ? await checkSentInvoices(db, runtime, job.organisationId)
84
85
  : null;
85
- return { results, more: stopped, ...(sent ? { sent } : {}) };
86
+ // Each Xero invoice against Flow's own (links.js): sure fits are linked, and
87
+ // with the Import setting on, a Flow invoice is made for each with no fit.
88
+ // Flow's database only.
89
+ const matched = sent ? await matchXero(db, job.organisationId) : null;
90
+ return { results, more: stopped, ...(sent ? { sent } : {}), ...(matched ? { matched } : {}) };
86
91
  }
87
92
 
88
93
  // Claims and runs one due import job, then queues the next slice when one is
@@ -15,7 +15,7 @@ import { readChangesFirst } from './webhooks.js';
15
15
  // XERO_MODE says which Xero it acts on. Its tables are created only while it
16
16
  // is on (docs/INTEGRATIONS.md).
17
17
 
18
- export const XERO_TABLES = Object.freeze(['xero_connections', 'xero_contacts', 'xero_items', 'xero_sync_runs', 'xero_invoice_pushes', 'xero_payment_cursors', 'xero_invoices', 'xero_credit_notes', 'xero_payments', 'xero_mirror_cursors', 'xero_call_guard', 'xero_calls', 'xero_webhook_events', 'xero_daily_checks']);
18
+ export const XERO_TABLES = Object.freeze(['xero_connections', 'xero_contacts', 'xero_items', 'xero_sync_runs', 'xero_invoice_pushes', 'xero_payment_cursors', 'xero_invoices', 'xero_credit_notes', 'xero_payments', 'xero_mirror_cursors', 'xero_call_guard', 'xero_calls', 'xero_webhook_events', 'xero_daily_checks', 'xero_invoice_links', 'xero_import_settings']);
19
19
 
20
20
  // What an owner enters in Settings, Integrations (server/integrations/setup.js).
21
21
  // The refresh token's key comes from Flow's secrets key.
@@ -75,10 +75,10 @@ export function createXeroAdapter({ runtime: given = null } = {}) {
75
75
  if (!given) runtime = createXeroRuntime({ settings: requireXeroSettings(env ?? process.env) });
76
76
  const { settings } = current();
77
77
  if (reconfigured) await current().guardFor(db).resume();
78
- log.info?.(`[xero] mode ${settings.mode}; writes ${writesAllowed(settings) ? 'on' : 'off (XERO_ALLOW_LIVE_WRITES)'}; redirect URI ${settings.redirectUri ?? 'not set (FLOW_PUBLIC_ORIGIN)'}`);
78
+ log.info?.(`[xero] mode ${settings.mode}; writes ${writesAllowed(settings) ? 'on' : settings.backgroundJobs === false ? 'off (FLOW_BACKGROUND_JOBS)' : 'off (XERO_ALLOW_LIVE_WRITES)'}; redirect URI ${settings.redirectUri ?? 'not set (FLOW_PUBLIC_ORIGIN)'}`);
79
79
  // The history import (import.js) runs in the background outside replay
80
- // mode. Replay tests run its slices directly.
81
- if (settings.mode !== 'replay') {
80
+ // mode, where background jobs run. Replay tests run its slices directly.
81
+ if (settings.mode !== 'replay' && settings.backgroundJobs !== false) {
82
82
  importer = createImportWorker({ db, runtime: current(), log });
83
83
  await importer.start();
84
84
  }
@@ -218,7 +218,10 @@ export async function recordXeroPayments(db, runtime, organisationId, { actor, s
218
218
  const xeroIds = [...new Set(payments.map((p) => p.xeroInvoiceId))];
219
219
  const byFlowInvoice = new Map();
220
220
  if (xeroIds.length) {
221
+ // Flow invoices sent to Xero, and those the Import page linked (links.js).
221
222
  const { rows } = await db.execute(sql`select xero_invoice_id, invoice_id from xero_invoice_pushes
223
+ where organisation_id = ${organisationId} and xero_invoice_id = any(${sql.param(xeroIds)}::text[])
224
+ union all select xero_invoice_id, invoice_id from xero_invoice_links
222
225
  where organisation_id = ${organisationId} and xero_invoice_id = any(${sql.param(xeroIds)}::text[])`);
223
226
  for (const row of rows) byFlowInvoice.set(row.xero_invoice_id, row.invoice_id);
224
227
  }