@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.
- package/README.md +1 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/api/custom-fields.js +2 -1
- package/server/api/invoicing.js +61 -3
- package/server/auth/clerk.js +35 -0
- package/server/auth/routes.js +45 -6
- package/server/core/custom-fields.js +18 -0
- package/server/core/host.js +24 -0
- package/server/core/job-board.js +2 -1
- package/server/core/job-match.js +241 -0
- package/server/core/ledger.js +80 -1
- package/server/index.js +9 -3
- package/server/integrations/seazona/feed.js +4 -1
- package/server/integrations/seazona/index.js +1 -1
- package/server/integrations/seazona/settings.js +5 -2
- package/server/integrations/sentry/index.js +5 -2
- package/server/integrations/xero/fit.js +108 -0
- package/server/integrations/xero/import.js +6 -1
- package/server/integrations/xero/index.js +4 -4
- package/server/integrations/xero/ledger.js +3 -0
- package/server/integrations/xero/links.js +293 -0
- package/server/integrations/xero/migrations/0008_xero_invoice_links.sql +26 -0
- package/server/integrations/xero/migrations/meta/_journal.json +7 -0
- package/server/integrations/xero/reconcile.js +17 -11
- package/server/integrations/xero/routes.js +42 -0
- package/server/integrations/xero/settings.js +12 -5
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +142 -23
- package/server/ui/bulk-bar.js +100 -0
- package/server/ui/flow.css +225 -11
- package/server/ui/invoicing.js +234 -30
- package/server/ui/job-board.js +60 -34
- package/server/ui/job-view.js +30 -7
- package/server/ui/jobs.js +469 -38
- package/server/ui/reconcile.js +275 -0
- package/server/ui/routes.js +1 -1
- package/server/ui/xero-import.js +329 -0
package/server/core/ledger.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|