@arcforgelabs/flow 2026.10.30 → 2026.10.32
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 +2 -0
- package/npm-shrinkwrap.json +188 -2
- package/package.json +2 -1
- package/server/api/alerts.js +49 -0
- package/server/api/invoicing.js +125 -7
- package/server/app.js +29 -2
- package/server/core/alerts.js +157 -0
- package/server/core/automations.js +17 -3
- package/server/core/history.js +65 -0
- package/server/core/invoice-pdf.js +266 -0
- package/server/core/invoices.js +2 -2
- package/server/core/invoicing-rules.js +26 -1
- package/server/core/job-board.js +25 -3
- package/server/core/ledger.js +3 -2
- package/server/core/line-mapping.js +36 -10
- package/server/core/pipeline.js +76 -4
- package/server/core/price-list.js +348 -0
- package/server/core/references.js +99 -0
- package/server/core/sending.js +11 -5
- package/server/db/commands.js +42 -0
- package/server/db/doctor.js +168 -0
- package/server/db/migrations/0014_price_list.sql +34 -0
- package/server/db/migrations/0015_alert_dismissals.sql +17 -0
- package/server/db/migrations/0016_invoice_reference.sql +1 -0
- package/server/db/migrations/meta/0014_snapshot.json +3773 -0
- package/server/db/migrations/meta/0015_snapshot.json +3854 -0
- package/server/db/migrations/meta/0016_snapshot.json +3861 -0
- package/server/db/migrations/meta/_journal.json +21 -0
- package/server/db/schema.js +46 -0
- package/server/flow-server.js +20 -1
- package/server/index.js +2 -1
- package/server/integrations/call-guard.js +362 -0
- package/server/integrations/port.js +21 -1
- package/server/integrations/seazona/feed.js +31 -2
- package/server/integrations/seazona/governor.js +50 -122
- package/server/integrations/seazona/history/backfill.js +208 -0
- package/server/integrations/seazona/history/csv.js +65 -0
- package/server/integrations/seazona/history/index.js +584 -0
- package/server/integrations/seazona/history/read.js +196 -0
- package/server/integrations/seazona/index.js +46 -1
- package/server/integrations/seazona/lanes.js +187 -51
- package/server/integrations/seazona/migrations/0003_call_guard.sql +2 -0
- package/server/integrations/seazona/migrations/0004_history_import.sql +68 -0
- package/server/integrations/seazona/migrations/meta/0003_snapshot.json +895 -0
- package/server/integrations/seazona/migrations/meta/0004_snapshot.json +1323 -0
- package/server/integrations/seazona/migrations/meta/_journal.json +14 -0
- package/server/integrations/seazona/patients.js +30 -0
- package/server/integrations/seazona/routes.js +145 -0
- package/server/integrations/seazona/schema.js +94 -0
- package/server/integrations/seazona/settings.js +14 -3
- package/server/integrations/seazona/sync-once.js +1 -1
- package/server/integrations/seazona/tables.js +4 -0
- package/server/integrations/xero/client.js +39 -7
- package/server/integrations/xero/drafts.js +46 -8
- package/server/integrations/xero/import.js +131 -17
- package/server/integrations/xero/index.js +38 -7
- package/server/integrations/xero/lookup.js +26 -0
- package/server/integrations/xero/migrations/0006_xero_call_guard.sql +26 -0
- package/server/integrations/xero/migrations/0007_xero_webhooks.sql +30 -0
- package/server/integrations/xero/migrations/meta/_journal.json +14 -0
- package/server/integrations/xero/mirror.js +27 -10
- package/server/integrations/xero/oauth.js +4 -1
- package/server/integrations/xero/reconcile.js +173 -0
- package/server/integrations/xero/routes.js +55 -0
- package/server/integrations/xero/runtime.js +45 -4
- package/server/integrations/xero/settings.js +3 -0
- package/server/integrations/xero/webhooks.js +311 -0
- package/server/release-channel.js +3 -3
- package/server/ui/alerts.js +77 -0
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +63 -21
- package/server/ui/clerk-appearance.js +42 -9
- package/server/ui/flow.css +78 -9
- package/server/ui/invoice-notes.js +26 -0
- package/server/ui/invoicing-settings.js +4 -2
- package/server/ui/invoicing.js +241 -49
- package/server/ui/line-mappings.js +3 -2
- package/server/ui/price-list.js +247 -0
- package/server/ui/routes.js +1 -1
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm';
|
|
2
|
+
import { openReview, resolveReviewFor } from '../../core/review.js';
|
|
3
|
+
|
|
4
|
+
// Each invoice Flow sent, against Flow's copy of Xero (issue #132). People
|
|
5
|
+
// change invoices in both places after Flow sends them: a Xero draft is edited
|
|
6
|
+
// before approval, an invoice is voided in Xero, or voided in Flow. The issued
|
|
7
|
+
// version Flow sent is the merge base: the document both sides agreed on.
|
|
8
|
+
// This module compares the Xero copy with it and with the Flow invoice now.
|
|
9
|
+
// It never calls Xero and never writes to Xero or to the Flow invoice: a
|
|
10
|
+
// difference opens one review item (alert only), and a person fixes it where
|
|
11
|
+
// they choose. The item closes when a later check finds no difference.
|
|
12
|
+
|
|
13
|
+
export const OUT_OF_SYNC = 'invoice.accounting_differs';
|
|
14
|
+
export const ACTOR = 'system:xero-reconcile';
|
|
15
|
+
|
|
16
|
+
// Xero's status in the words the Flow screens show.
|
|
17
|
+
const LABELS = {
|
|
18
|
+
not_sent: 'Not in Xero',
|
|
19
|
+
sending: 'Sending to Xero',
|
|
20
|
+
not_copied: 'Sent, not read back yet',
|
|
21
|
+
draft: 'Draft in Xero',
|
|
22
|
+
approved: 'Approved in Xero',
|
|
23
|
+
part_paid: 'Part paid in Xero',
|
|
24
|
+
paid: 'Paid in Xero',
|
|
25
|
+
voided: 'Voided in Xero',
|
|
26
|
+
deleted: 'Deleted in Xero',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const GONE = new Set(['VOIDED', 'DELETED']);
|
|
30
|
+
|
|
31
|
+
function xeroState(row) {
|
|
32
|
+
switch (row.status) {
|
|
33
|
+
case 'DRAFT':
|
|
34
|
+
case 'SUBMITTED':
|
|
35
|
+
return 'draft';
|
|
36
|
+
case 'AUTHORISED':
|
|
37
|
+
return Number(row.amount_paid_cents ?? 0) > 0 ? 'part_paid' : 'approved';
|
|
38
|
+
case 'PAID':
|
|
39
|
+
return 'paid';
|
|
40
|
+
case 'VOIDED':
|
|
41
|
+
return 'voided';
|
|
42
|
+
case 'DELETED':
|
|
43
|
+
return 'deleted';
|
|
44
|
+
default:
|
|
45
|
+
return 'draft';
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const num = (value) => (value === null || value === undefined ? null : Number(value));
|
|
50
|
+
const day = (value) => (value instanceof Date ? value.toISOString().slice(0, 10) : value ? String(value).slice(0, 10) : null);
|
|
51
|
+
const codes = (lines) => [...(lines ?? []).map((l) => l.itemCode ?? '')].sort().join(',');
|
|
52
|
+
|
|
53
|
+
// What differs between the Xero copy and the version Flow sent. Free text
|
|
54
|
+
// (reference, descriptions) is not compared: the copy keeps it only when the
|
|
55
|
+
// lab turns that on (XERO_KEEP_REFERENCE).
|
|
56
|
+
export function sentDifferences(sent, document, copy) {
|
|
57
|
+
const found = [];
|
|
58
|
+
const add = (field, flow, xero) => found.push({ field, flow, xero });
|
|
59
|
+
if (sent?.Contact?.ContactID && copy.xero_contact_id !== sent.Contact.ContactID) add('contact', sent.Contact.ContactID, copy.xero_contact_id);
|
|
60
|
+
if (num(copy.subtotal_cents) !== document.subtotalCents) add('subtotal', document.subtotalCents, num(copy.subtotal_cents));
|
|
61
|
+
if (num(copy.tax_cents) !== document.taxCents) add('tax', document.taxCents, num(copy.tax_cents));
|
|
62
|
+
if (num(copy.total_cents) !== document.totalCents) add('total', document.totalCents, num(copy.total_cents));
|
|
63
|
+
const lines = copy.lines ?? [];
|
|
64
|
+
if (lines.length !== document.lines.length) add('line count', document.lines.length, lines.length);
|
|
65
|
+
else if (codes(lines) !== codes(document.lines)) add('items', codes(document.lines), codes(lines));
|
|
66
|
+
if (sent?.Date && day(copy.issued_on) !== sent.Date) add('date', sent.Date, day(copy.issued_on));
|
|
67
|
+
return found;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// The finding for one invoice, or null when Flow and Xero agree.
|
|
71
|
+
// differs Xero's copy no longer matches the version Flow sent
|
|
72
|
+
// voided_in_xero Xero voided or deleted it; the Flow invoice is still issued
|
|
73
|
+
// voided_in_flow Flow voided it; Xero's invoice is still live
|
|
74
|
+
function findingFor(status) {
|
|
75
|
+
if (!status.xero) return null;
|
|
76
|
+
const live = !GONE.has(status.xero.status);
|
|
77
|
+
if (status.flowStatus === 'void' && live) return 'voided_in_flow';
|
|
78
|
+
if (status.flowStatus === 'issued' && status.current && !live) return 'voided_in_xero';
|
|
79
|
+
if (live && status.differences.length) return 'differs';
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const MESSAGES = {
|
|
84
|
+
differs: 'Xero no longer matches the invoice Flow sent.',
|
|
85
|
+
voided_in_xero: 'Xero voided or deleted this invoice. It is still issued in Flow.',
|
|
86
|
+
voided_in_flow: 'This invoice is void in Flow. Xero still holds it as a live invoice.',
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// Each invoice's Xero status, from Flow's copy (no Xero calls). Answers a map
|
|
90
|
+
// from Flow invoice id to { state, label, xero, differences, finding, message }.
|
|
91
|
+
export async function accountingStatus(db, runtime, organisationId, invoiceIds = null) {
|
|
92
|
+
const only = invoiceIds ? sql`and i.id = any(${sql.param([...new Set(invoiceIds.map(String))])}::uuid[])` : sql``;
|
|
93
|
+
// Without ids: only invoices Flow sent, for the check after each read.
|
|
94
|
+
const sentOnly = invoiceIds ? sql`` : sql`and p.id is not null`;
|
|
95
|
+
const { rows } = await db.execute(sql`
|
|
96
|
+
select i.id, i.status, i.issued_version, i.total_cents,
|
|
97
|
+
p.version as sent_version, p.state as push_state, p.request_body, p.xero_invoice_id, p.xero_invoice_number,
|
|
98
|
+
v.document,
|
|
99
|
+
x.status as x_status, x.invoice_number as x_number, x.xero_contact_id, x.subtotal_cents, x.tax_cents,
|
|
100
|
+
x.total_cents as x_total_cents, x.amount_paid_cents, x.amount_due_cents, x.lines, x.issued_on
|
|
101
|
+
from invoices i
|
|
102
|
+
left join lateral (
|
|
103
|
+
select * from xero_invoice_pushes
|
|
104
|
+
where organisation_id = i.organisation_id and invoice_id = i.id
|
|
105
|
+
order by version desc limit 1
|
|
106
|
+
) p on true
|
|
107
|
+
left join invoice_versions v on v.invoice_id = i.id and v.version = p.version
|
|
108
|
+
left join xero_invoices x on x.organisation_id = i.organisation_id and x.xero_invoice_id = p.xero_invoice_id
|
|
109
|
+
where i.organisation_id = ${organisationId} ${only} ${sentOnly}`);
|
|
110
|
+
const web = runtime.settings.endpoints?.web ?? 'https://go.xero.com';
|
|
111
|
+
const out = new Map();
|
|
112
|
+
for (const r of rows) {
|
|
113
|
+
let state;
|
|
114
|
+
if (!r.push_state) state = 'not_sent';
|
|
115
|
+
else if (['dispatching', 'ambiguous'].includes(r.push_state)) state = 'sending';
|
|
116
|
+
else if (!r.x_status) state = 'not_copied';
|
|
117
|
+
else state = xeroState({ status: r.x_status, amount_paid_cents: r.amount_paid_cents });
|
|
118
|
+
let sent = null;
|
|
119
|
+
try {
|
|
120
|
+
sent = r.request_body ? JSON.parse(r.request_body) : null;
|
|
121
|
+
} catch {
|
|
122
|
+
sent = null;
|
|
123
|
+
}
|
|
124
|
+
const xero = r.x_status ? {
|
|
125
|
+
invoiceId: r.xero_invoice_id,
|
|
126
|
+
number: r.x_number ?? r.xero_invoice_number ?? null,
|
|
127
|
+
status: r.x_status,
|
|
128
|
+
totalCents: num(r.x_total_cents),
|
|
129
|
+
amountDueCents: num(r.amount_due_cents),
|
|
130
|
+
url: `${web}/AccountsReceivable/View.aspx?InvoiceID=${encodeURIComponent(r.xero_invoice_id)}`,
|
|
131
|
+
} : null;
|
|
132
|
+
const status = {
|
|
133
|
+
invoiceId: r.id,
|
|
134
|
+
flowStatus: r.status,
|
|
135
|
+
sentVersion: r.sent_version ?? null,
|
|
136
|
+
current: r.sent_version !== null && r.sent_version === r.issued_version,
|
|
137
|
+
state,
|
|
138
|
+
label: LABELS[state],
|
|
139
|
+
xero,
|
|
140
|
+
differences: xero && r.document && !GONE.has(r.x_status) ? sentDifferences(sent, r.document, { ...r, total_cents: r.x_total_cents }) : [],
|
|
141
|
+
flowTotalCents: num(r.total_cents),
|
|
142
|
+
};
|
|
143
|
+
status.finding = findingFor(status);
|
|
144
|
+
status.message = status.finding ? MESSAGES[status.finding] : null;
|
|
145
|
+
out.set(r.id, status);
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Opens or refreshes one review item for each finding, and closes the item of
|
|
151
|
+
// an invoice whose finding went away. Answers counts.
|
|
152
|
+
export async function checkSentInvoices(db, runtime, organisationId, { invoiceIds = null } = {}) {
|
|
153
|
+
const statuses = await accountingStatus(db, runtime, organisationId, invoiceIds);
|
|
154
|
+
const result = { checked: 0, open: 0, closed: 0 };
|
|
155
|
+
for (const status of statuses.values()) {
|
|
156
|
+
if (status.state === 'not_sent') continue;
|
|
157
|
+
result.checked += 1;
|
|
158
|
+
if (status.finding) {
|
|
159
|
+
await openReview(db, organisationId, {
|
|
160
|
+
kind: OUT_OF_SYNC,
|
|
161
|
+
entity: 'invoice',
|
|
162
|
+
flowId: status.invoiceId,
|
|
163
|
+
systems: ['flow', 'xero'],
|
|
164
|
+
evidence: { finding: status.finding, message: status.message, flowStatus: status.flowStatus, sentVersion: status.sentVersion, xero: status.xero, differences: status.differences },
|
|
165
|
+
moneyAtRiskCents: Math.abs(status.xero?.totalCents ?? status.flowTotalCents ?? 0),
|
|
166
|
+
});
|
|
167
|
+
result.open += 1;
|
|
168
|
+
} else if (await resolveReviewFor(db, organisationId, { kind: OUT_OF_SYNC, entity: 'invoice', flowId: status.invoiceId, resolution: 'no_difference', actor: ACTOR })) {
|
|
169
|
+
result.closed += 1;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return result;
|
|
173
|
+
}
|
|
@@ -8,9 +8,11 @@ import { describePush, listPushes, MAX_BATCH, previewDrafts, pushDrafts } from '
|
|
|
8
8
|
import { recordXeroEvidence, recordXeroPayments } from './ledger.js';
|
|
9
9
|
import { queueHistoryImport, resetImport } from './import.js';
|
|
10
10
|
import { MIRROR_RESOURCES, mirrorStatus, mirrorXero } from './mirror.js';
|
|
11
|
+
import { accountingStatus, checkSentInvoices } from './reconcile.js';
|
|
11
12
|
import { decryptToken, deleteConnection, describeConnection, readConnection, saveConnection } from './connections.js';
|
|
12
13
|
import { authorizeUrl, exchangeCode, fetchConnections, revokeToken, selectConnection } from './oauth.js';
|
|
13
14
|
import { writesAllowed } from './settings.js';
|
|
15
|
+
import { saveEvents, signatureValid } from './webhooks.js';
|
|
14
16
|
import { DEFAULT_FAMILY } from '../../theme/themes.js';
|
|
15
17
|
import { messagePage as themedMessagePage } from '../../ui/page.js';
|
|
16
18
|
|
|
@@ -27,12 +29,18 @@ import { messagePage as themedMessagePage } from '../../ui/page.js';
|
|
|
27
29
|
// would make, and what stops them (no Xero calls)
|
|
28
30
|
// POST /invoices/push owner or admin: issued invoices to Xero drafts
|
|
29
31
|
// GET /invoices/:invoiceId/pushes?organisationId= any signed-in person
|
|
32
|
+
// POST /invoices/status any signed-in person: each invoice's Xero
|
|
33
|
+
// status and any difference, from the copy
|
|
34
|
+
// POST /invoices/:invoiceId/recheck owner or admin: reads Xero's changes,
|
|
35
|
+
// then checks that one invoice again (Mark resolved)
|
|
30
36
|
// POST /obligations/:obligationId/check owner or admin: Xero's invoice for
|
|
31
37
|
// a billable job, recorded on the ledger
|
|
32
38
|
// POST /payments/sync owner or admin: Xero payments to the ledger
|
|
33
39
|
// POST /mirror owner or admin: copies Xero invoices, credit
|
|
34
40
|
// notes and payments changed since the last copy
|
|
35
41
|
// GET /mirror?organisationId= any signed-in person: what the copy holds
|
|
42
|
+
// POST /webhooks Xero: change events, signed with the
|
|
43
|
+
// webhook key (XERO_WEBHOOK_KEY); no session
|
|
36
44
|
//
|
|
37
45
|
// Only an owner, with a verified owner sign-in, can connect or disconnect.
|
|
38
46
|
// The callback carries no Flow session: the one-use state from /connect,
|
|
@@ -76,10 +84,35 @@ export const handle = (log, fn) => async (req, res) => {
|
|
|
76
84
|
|
|
77
85
|
// `messagePage` draws the callback's answer in the installation's design
|
|
78
86
|
// system (server/ui/page.js); the server's app passes it.
|
|
87
|
+
// What the Xero page says about calls for one connection: stopped until a
|
|
88
|
+
// person connects again (a refused connection or resource), and since when.
|
|
89
|
+
export function callsState(status) {
|
|
90
|
+
const resources = status.stoppedResources.map((r) => r.resource);
|
|
91
|
+
const since = status.haltedAt ?? status.stoppedResources.map((r) => r.at).filter(Boolean).sort()[0] ?? null;
|
|
92
|
+
return { stopped: Boolean(status.haltedReason) || resources.length > 0, all: Boolean(status.haltedReason), resources, since };
|
|
93
|
+
}
|
|
94
|
+
|
|
79
95
|
export function createXeroRouter({ db, personAuth, runtime, log = console, messagePage = (options) => themedMessagePage({ ...options, defaultFamily: DEFAULT_FAMILY }) }) {
|
|
80
96
|
const page = (title, text, tone) => messagePage({ title, text, tone });
|
|
81
97
|
const { settings } = runtime;
|
|
82
98
|
const router = express.Router();
|
|
99
|
+
|
|
100
|
+
// Xero's webhooks (webhooks.js), before the JSON parser: the signature
|
|
101
|
+
// covers the raw body. Xero wants 200 with no body within 5 seconds, and
|
|
102
|
+
// 401 for a bad signature (its "intent to receive" test sends both).
|
|
103
|
+
router.post('/webhooks', express.raw({ type: () => true, limit: '1mb' }), async (req, res) => {
|
|
104
|
+
if (!settings.webhookKey) return res.status(404).end();
|
|
105
|
+
const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
|
|
106
|
+
if (!signatureValid(settings.webhookKey, raw, req.get('x-xero-signature'))) return res.status(401).end();
|
|
107
|
+
try {
|
|
108
|
+
await saveEvents(db, runtime, JSON.parse(raw.toString('utf8') || '{}'));
|
|
109
|
+
} catch (error) {
|
|
110
|
+
log.error?.(`[xero] saving webhook events failed: ${error?.code ?? error?.name ?? 'error'}`);
|
|
111
|
+
return res.status(500).end();
|
|
112
|
+
}
|
|
113
|
+
return res.status(200).end();
|
|
114
|
+
});
|
|
115
|
+
|
|
83
116
|
router.use(express.json({ limit: '256kb' }));
|
|
84
117
|
|
|
85
118
|
// Sign-in off: nothing but the callback answers, and it fails for want of
|
|
@@ -101,6 +134,7 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, messa
|
|
|
101
134
|
connected: Boolean(connection && connection.mode === settings.mode),
|
|
102
135
|
connection: describeConnection(connection),
|
|
103
136
|
rateLimit: connection ? runtime.governor.status(connection.tenantId) : null,
|
|
137
|
+
calls: connection ? callsState(await runtime.guardFor(db).status(connection.tenantId)) : null,
|
|
104
138
|
lastSync: await lastSync(db, organisationId),
|
|
105
139
|
});
|
|
106
140
|
}));
|
|
@@ -155,6 +189,8 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, messa
|
|
|
155
189
|
key: settings.tokenKey,
|
|
156
190
|
});
|
|
157
191
|
runtime.remember(entry.organisationId, tokens);
|
|
192
|
+
// A new connection is a person acting: calls stopped before resume.
|
|
193
|
+
await runtime.guardFor(db).resume();
|
|
158
194
|
// Another Xero organisation starts its history from the beginning.
|
|
159
195
|
if (previous && previous.tenantId !== connection.tenantId) await resetImport(db, entry.organisationId);
|
|
160
196
|
await queueHistoryImport(db, entry.organisationId, { reason: 'connected' });
|
|
@@ -205,6 +241,25 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, messa
|
|
|
205
241
|
res.json({ ok: true, mode: settings.mode, writesAllowed: writesAllowed(settings), ...preview });
|
|
206
242
|
}));
|
|
207
243
|
|
|
244
|
+
// Xero's status of each invoice, from Flow's copy: no Xero calls.
|
|
245
|
+
router.post('/invoices/status', ...signedIn, handle(log, async (req, res) => {
|
|
246
|
+
const organisationId = await requireOrganisation(db, req.body?.organisationId);
|
|
247
|
+
const statuses = await accountingStatus(db, runtime, organisationId, requireInvoiceIds(req.body?.invoiceIds));
|
|
248
|
+
res.json({ ok: true, invoices: [...statuses.values()] });
|
|
249
|
+
}));
|
|
250
|
+
|
|
251
|
+
// Mark resolved: read Xero's changes (a few calls, through the call guard),
|
|
252
|
+
// then check this one invoice again. The item closes only when the
|
|
253
|
+
// difference is gone; otherwise it stays open with the new evidence.
|
|
254
|
+
router.post('/invoices/:invoiceId/recheck', ...manager, handle(log, async (req, res) => {
|
|
255
|
+
const organisationId = await requireOrganisation(db, req.body?.organisationId);
|
|
256
|
+
const [invoiceId] = requireInvoiceIds([req.params.invoiceId], 'invoiceId');
|
|
257
|
+
await mirrorXero(db, runtime, organisationId, { resources: ['invoices'] });
|
|
258
|
+
const result = await checkSentInvoices(db, runtime, organisationId, { invoiceIds: [invoiceId] });
|
|
259
|
+
const status = (await accountingStatus(db, runtime, organisationId, [invoiceId])).get(invoiceId) ?? null;
|
|
260
|
+
res.json({ ok: true, resolved: !status?.finding, closed: result.closed, status });
|
|
261
|
+
}));
|
|
262
|
+
|
|
208
263
|
router.post('/invoices/push', ...manager, handle(log, async (req, res) => {
|
|
209
264
|
const organisationId = await requireOrganisation(db, req.body?.organisationId);
|
|
210
265
|
const invoiceIds = requireInvoiceIds(req.body?.invoiceIds);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createCallGuard } from '../call-guard.js';
|
|
1
2
|
import { createXeroClient, xeroError } from './client.js';
|
|
2
3
|
import { decryptToken, readConnection, storeRotatedToken } from './connections.js';
|
|
3
4
|
import { createGovernor } from './governor.js';
|
|
@@ -6,9 +7,21 @@ import { createReplayFetch, DEFAULT_REPLAY_FIXTURES, loadReplayFixtures } from '
|
|
|
6
7
|
import { resolveXeroSettings, writesAllowed } from './settings.js';
|
|
7
8
|
|
|
8
9
|
// The Xero adapter's state for one Flow process: settings, the rate-limit
|
|
9
|
-
// governor, the
|
|
10
|
-
//
|
|
11
|
-
// stored; the refresh token is stored
|
|
10
|
+
// governor, the call guard (its state is in PostgreSQL), the transport (real
|
|
11
|
+
// fetch, or replay), OAuth states, and the access tokens in memory. Access
|
|
12
|
+
// tokens live 30 minutes and are never stored; the refresh token is stored
|
|
13
|
+
// sealed (connections.js).
|
|
14
|
+
|
|
15
|
+
// The call guard's tables and policy (../call-guard.js, docs/XERO.md). A 401
|
|
16
|
+
// or 403 that a fresh token does not clear stops only the resource it came
|
|
17
|
+
// from: a connection can lack one scope (payments) and still serve the rest.
|
|
18
|
+
// A refused token refresh halts every call.
|
|
19
|
+
export const XERO_CALL_GUARD = Object.freeze({
|
|
20
|
+
integration: 'xero',
|
|
21
|
+
name: 'Xero',
|
|
22
|
+
tables: Object.freeze({ state: 'xero_call_guard', calls: 'xero_calls' }),
|
|
23
|
+
policy: Object.freeze({ authStops: 'resource' }),
|
|
24
|
+
});
|
|
12
25
|
|
|
13
26
|
export function requireXeroSettings(env = process.env) {
|
|
14
27
|
const settings = resolveXeroSettings(env);
|
|
@@ -31,6 +44,14 @@ export function createXeroRuntime({ settings, fetch, now = Date.now, log = conso
|
|
|
31
44
|
const accessTokens = new Map(); // organisation id -> { token, expiresAt }
|
|
32
45
|
const refreshing = new Map(); // organisation id -> promise
|
|
33
46
|
const queues = new Map(); // organisation id -> tail of its work queue
|
|
47
|
+
const guards = new WeakMap(); // database handle -> call guard
|
|
48
|
+
|
|
49
|
+
// The call guard for one database. Keyed by the Xero tenant id: Xero's
|
|
50
|
+
// limits are per organisation.
|
|
51
|
+
function guardFor(db) {
|
|
52
|
+
if (!guards.has(db)) guards.set(db, createCallGuard({ db, ...XERO_CALL_GUARD, now, log }));
|
|
53
|
+
return guards.get(db);
|
|
54
|
+
}
|
|
34
55
|
|
|
35
56
|
// Runs heavy Xero work for one organisation one at a time, in order: a
|
|
36
57
|
// sync and a batch of drafts draw on the same call budget, and running them
|
|
@@ -67,11 +88,29 @@ export function createXeroRuntime({ settings, fetch, now = Date.now, log = conso
|
|
|
67
88
|
|
|
68
89
|
// Refresh under a row lock, and store the rotated refresh token in the same
|
|
69
90
|
// transaction, so two refreshes never race and the newest token is kept.
|
|
91
|
+
// A refresh Xero refuses for good halts every call until an owner connects
|
|
92
|
+
// again or saves the settings; another failed refresh counts as a failed
|
|
93
|
+
// call.
|
|
70
94
|
async function refresh(db, organisationId) {
|
|
95
|
+
const guard = guardFor(db);
|
|
71
96
|
return db.transaction(async (tx) => {
|
|
72
97
|
const connection = usable(await readConnection(tx, organisationId, { lock: true }));
|
|
98
|
+
await guard.check(connection.tenantId, { resource: 'token' });
|
|
73
99
|
const refreshToken = decryptToken(connection.refreshTokenEnc, settings.tokenKey, organisationId);
|
|
74
|
-
|
|
100
|
+
let tokens;
|
|
101
|
+
try {
|
|
102
|
+
tokens = await refreshTokens(settings, transport, refreshToken);
|
|
103
|
+
} catch (error) {
|
|
104
|
+
// invalid_grant: the connection is gone. invalid_client: the app's
|
|
105
|
+
// client id or secret is wrong. Neither clears without a person.
|
|
106
|
+
if (error?.code === 'xero_reconnect_needed' || ['invalid_client', 'unauthorized_client'].includes(error?.oauthError)) {
|
|
107
|
+
await guard.halt(connection.tenantId, 'auth', { detail: `Xero refused the token refresh${error.oauthError ? ` (${error.oauthError})` : ''}` });
|
|
108
|
+
} else if (error?.code !== 'xero_replay_no_match') {
|
|
109
|
+
await guard.record(connection.tenantId, { resource: 'token', failureKind: error?.code === 'xero_unreachable' ? 'network' : 'token_refresh' });
|
|
110
|
+
}
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
await guard.record(connection.tenantId, { resource: 'token', statusCode: 200 });
|
|
75
114
|
await storeRotatedToken(tx, organisationId, { refreshToken: tokens.refresh_token, scope: tokens.scope, key: settings.tokenKey });
|
|
76
115
|
remember(organisationId, tokens);
|
|
77
116
|
return tokens.access_token;
|
|
@@ -96,6 +135,7 @@ export function createXeroRuntime({ settings, fetch, now = Date.now, log = conso
|
|
|
96
135
|
invalidateAccessToken: () => accessTokens.delete(organisationId),
|
|
97
136
|
fetch: transport,
|
|
98
137
|
governor,
|
|
138
|
+
guard: guardFor(db),
|
|
99
139
|
apiBase: settings.endpoints.api,
|
|
100
140
|
log,
|
|
101
141
|
});
|
|
@@ -122,6 +162,7 @@ export function createXeroRuntime({ settings, fetch, now = Date.now, log = conso
|
|
|
122
162
|
settings,
|
|
123
163
|
transport,
|
|
124
164
|
governor,
|
|
165
|
+
guardFor,
|
|
125
166
|
states,
|
|
126
167
|
remember,
|
|
127
168
|
forget: (organisationId) => accessTokens.delete(organisationId),
|
|
@@ -167,6 +167,9 @@ export function resolveXeroSettings(env = process.env) {
|
|
|
167
167
|
mirrorPageSize: Math.min(readPositiveInteger(env, 'XERO_MIRROR_PAGE_SIZE', DEFAULT_MIRROR_PAGE_SIZE), MAX_MIRROR_PAGE_SIZE),
|
|
168
168
|
// The share of the day's calls the mirror leaves for drafts and people.
|
|
169
169
|
mirrorReserveFraction: readFraction(env, 'XERO_MIRROR_RESERVE_FRACTION', 0.3),
|
|
170
|
+
// The key Xero signs each webhook with (issue #45), from the Xero app's
|
|
171
|
+
// Webhooks page. Unset, Flow answers no webhook and reads by polling only.
|
|
172
|
+
webhookKey: readEnv(env, 'XERO_WEBHOOK_KEY') || null,
|
|
170
173
|
endpoints: XERO_ENDPOINTS,
|
|
171
174
|
};
|
|
172
175
|
}
|