@arcforgelabs/flow 2026.10.45 → 2026.10.47
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/VISION.md +10 -10
- package/npm-shrinkwrap.json +104 -2
- package/package.json +6 -2
- package/server/api/invoicing.js +56 -16
- package/server/app.js +4 -2
- package/server/auth/people.js +5 -3
- package/server/auth/routes.js +4 -1
- package/server/core/invoice-rules.js +34 -4
- package/server/core/invoices.js +40 -1
- package/server/core/invoicing-rules.js +20 -3
- package/server/core/job-board.js +1 -1
- package/server/core/sending.js +76 -22
- package/server/integrations/call-guard.js +21 -4
- package/server/integrations/sentry/index.js +14 -7
- package/server/integrations/xero/client.js +12 -2
- package/server/integrations/xero/migrations/0010_xero_call_usage.sql +11 -0
- package/server/integrations/xero/migrations/meta/_journal.json +7 -0
- package/server/integrations/xero/routes.js +29 -5
- package/server/integrations/xero/runtime.js +5 -0
- package/server/integrations/xero/usage.js +74 -0
- package/server/ui/alerts.js +2 -1
- package/server/ui/app.js +110 -27
- package/server/ui/bulk-bar.js +18 -5
- package/server/ui/columns.js +54 -2
- package/server/ui/custom-fields.js +22 -7
- package/server/ui/flow.css +353 -45
- package/server/ui/invoicing.js +286 -64
- package/server/ui/job-board.js +10 -0
- package/server/ui/job-change.js +4 -3
- package/server/ui/job-view.js +14 -4
- package/server/ui/jobs.js +156 -56
- package/server/ui/kit.js +212 -51
- package/server/ui/list-views.js +50 -10
- package/server/ui/mobile.js +726 -0
- package/server/ui/monitoring.js +51 -0
- package/server/ui/nav-pins.js +41 -0
- package/server/ui/page.js +110 -5
- package/server/ui/pager.js +57 -10
- package/server/ui/price-list.js +1 -1
- package/server/ui/price-reconcile.js +12 -12
- package/server/ui/reconcile.js +19 -18
- package/server/ui/routes.js +1 -1
- package/server/ui/saved-views.js +6 -5
- package/server/ui/sidecar.js +106 -6
- package/server/ui/view-bar.js +22 -11
- package/server/ui/xero-import.js +48 -19
- package/server/ui/xero-usage-model.js +106 -0
- package/server/ui/xero-usage.js +133 -0
package/server/core/sending.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm';
|
|
3
|
-
import { billingObligations, invoiceLines, invoices } from '../db/schema.js';
|
|
3
|
+
import { billingObligations, contacts, invoiceLines, invoices } from '../db/schema.js';
|
|
4
4
|
import { FlowError } from './errors.js';
|
|
5
|
-
import { listRules, rulePending } from './invoice-rules.js';
|
|
5
|
+
import { listRules, rulePending, ruleSkip } from './invoice-rules.js';
|
|
6
6
|
import { customerBillingByContact, effectiveSettings, readInvoicingSettings } from './invoicing-rules.js';
|
|
7
7
|
import { issueInvoice } from './invoices.js';
|
|
8
8
|
import { CHANGED_REASON, diffJobLines, STATUS_BACK_REASON } from './ledger.js';
|
|
@@ -17,7 +17,9 @@ import { deadHoldSince, deadSince, enqueueJob, runNextJob } from './queue.js';
|
|
|
17
17
|
// charge, an account) on every line. When an accounting
|
|
18
18
|
// system is connected, its own checks are added (for Xero: the customer
|
|
19
19
|
// matches one Xero contact, every item is in Xero), and the lab's pending
|
|
20
|
-
// rules (invoice-rules.js) add their notes.
|
|
20
|
+
// rules (invoice-rules.js) add their notes. An enabled skip rule does too:
|
|
21
|
+
// a draft whose job it matches stays Pending, even when the draft was made
|
|
22
|
+
// by hand or before the rule existed. A draft with a problem is
|
|
21
23
|
// Pending (the "problem" view). A draft without one is Ready.
|
|
22
24
|
//
|
|
23
25
|
// Send all ready issues every ready draft (each gets its number) and, when an
|
|
@@ -81,19 +83,21 @@ async function checkDrafts(db, organisationId, drafts, accounting) {
|
|
|
81
83
|
const found = draftProblems(draft, linesOf.get(draft.id) ?? []);
|
|
82
84
|
if (found.length) problems.set(draft.id, found);
|
|
83
85
|
}
|
|
84
|
-
const jobs = await db.select({ obligation: billingObligations, reference: invoices.reference }).from(billingObligations)
|
|
86
|
+
const jobs = await db.select({ obligation: billingObligations, reference: invoices.reference, customerName: contacts.name }).from(billingObligations)
|
|
85
87
|
.innerJoin(invoices, eq(invoices.id, billingObligations.invoiceId))
|
|
88
|
+
.leftJoin(contacts, and(eq(contacts.id, billingObligations.contactId), eq(contacts.organisationId, billingObligations.organisationId)))
|
|
86
89
|
.where(and(eq(billingObligations.organisationId, organisationId), inArray(billingObligations.invoiceId, ids)));
|
|
87
|
-
// The lab's
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
const rules = (await listRules(db, organisationId)).filter((r) => r.enabled && r.action.kind === 'pending');
|
|
90
|
+
// The lab's rules (invoice-rules.js). A pending rule puts its note on the
|
|
91
|
+
// draft. A skip rule holds a draft that already exists: void it, or change
|
|
92
|
+
// the rule. Matching stays in invoice-rules.js.
|
|
93
|
+
const rules = (await listRules(db, organisationId)).filter((r) => r.enabled && (r.action.kind === 'pending' || r.action.kind === 'skip'));
|
|
91
94
|
if (rules.length && jobs.length) {
|
|
92
95
|
const settings = await readInvoicingSettings(db, organisationId);
|
|
93
96
|
const customers = await customerBillingByContact(db, organisationId, jobs.map((j) => j.obligation.contactId));
|
|
94
|
-
for (const { obligation, reference } of jobs) {
|
|
95
|
-
const customer = effectiveSettings(settings, customers.get(obligation.contactId));
|
|
96
|
-
const
|
|
97
|
+
for (const { obligation, reference, customerName } of jobs) {
|
|
98
|
+
const customer = { ...effectiveSettings(settings, customers.get(obligation.contactId)), name: customerName ?? null };
|
|
99
|
+
const job = { ...obligation, reference };
|
|
100
|
+
const found = [...rulePending(rules, job, { customer }), ...ruleSkip(rules, job, { customer })];
|
|
97
101
|
if (found.length) problems.set(obligation.invoiceId, [...(problems.get(obligation.invoiceId) ?? []), ...found]);
|
|
98
102
|
}
|
|
99
103
|
}
|
|
@@ -196,23 +200,61 @@ async function assertCanSend(accounting, organisationId) {
|
|
|
196
200
|
// Read before act (issue #141): the jobs behind these drafts whose work
|
|
197
201
|
// system report is old are read again first. Answers the drafts that may go
|
|
198
202
|
// now; a draft whose job went to review, or could not be read, waits.
|
|
203
|
+
// `stops` names each held draft: `unread` (read failed or past the cap) or
|
|
204
|
+
// `review` (the job changed).
|
|
199
205
|
async function readJobsFirst(db, organisationId, invoiceIds, { work, now, personId }) {
|
|
200
|
-
if (!invoiceIds.length || typeof work?.capabilities?.readWorkItem !== 'function')
|
|
206
|
+
if (!invoiceIds.length || typeof work?.capabilities?.readWorkItem !== 'function') {
|
|
207
|
+
return { go: invoiceIds, waiting: [], stops: [] };
|
|
208
|
+
}
|
|
201
209
|
const jobs = await db.select().from(billingObligations)
|
|
202
210
|
.where(and(eq(billingObligations.organisationId, organisationId), inArray(billingObligations.invoiceId, invoiceIds)));
|
|
203
211
|
const old = jobs.filter((job) => needsRead(job, { work, now }));
|
|
204
212
|
const outcomes = await readBeforeAct(db, organisationId, old.slice(0, READS_PER_PASS), { work, now, actor: `person:${personId}` });
|
|
205
|
-
const held = new Set(
|
|
213
|
+
const held = new Set();
|
|
214
|
+
const stops = [];
|
|
215
|
+
for (const job of old.slice(READS_PER_PASS)) {
|
|
216
|
+
held.add(job.invoiceId);
|
|
217
|
+
stops.push({ invoiceId: job.invoiceId, kind: 'deferred' });
|
|
218
|
+
}
|
|
206
219
|
for (const { outcome, obligation } of outcomes.values()) {
|
|
207
|
-
if (outcome === 'waiting'
|
|
220
|
+
if (outcome === 'waiting') {
|
|
221
|
+
held.add(obligation.invoiceId);
|
|
222
|
+
stops.push({ invoiceId: obligation.invoiceId, kind: 'unread' });
|
|
223
|
+
} else if (outcome === 'review') {
|
|
224
|
+
held.add(obligation.invoiceId);
|
|
225
|
+
stops.push({ invoiceId: obligation.invoiceId, kind: 'review' });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return { go: invoiceIds.filter((id) => !held.has(id)), waiting: invoiceIds.filter((id) => held.has(id)), stops };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// The error when a ticked press sends nothing: name the cause (and the count
|
|
232
|
+
// for jobs), never one shared sentence.
|
|
233
|
+
function stoppedSendMessage({ accountingWaiting, accountingReason, stops, waiting }) {
|
|
234
|
+
if (accountingWaiting) {
|
|
235
|
+
if (accountingReason && /more changes than one read takes/i.test(accountingReason)) {
|
|
236
|
+
return 'The accounting copy has more changes than one read takes. Nothing was sent.';
|
|
237
|
+
}
|
|
238
|
+
if (accountingReason) return `${accountingReason.replace(/\.?$/, '')}. Nothing was sent.`;
|
|
239
|
+
return 'The accounting copy could not be read. Nothing was sent.';
|
|
240
|
+
}
|
|
241
|
+
const unread = stops.filter((stop) => stop.kind === 'unread').length;
|
|
242
|
+
const reviews = stops.filter((stop) => stop.kind === 'review').length;
|
|
243
|
+
if (unread) {
|
|
244
|
+
return `${unread} ${unread === 1 ? 'job' : 'jobs'} could not be read from the work system. Nothing was sent.`;
|
|
245
|
+
}
|
|
246
|
+
if (reviews) {
|
|
247
|
+
return `${reviews} ${reviews === 1 ? 'job' : 'jobs'} changed and ${reviews === 1 ? 'needs' : 'need'} a look. Nothing was sent.`;
|
|
208
248
|
}
|
|
209
|
-
|
|
249
|
+
const n = waiting.length || 1;
|
|
250
|
+
return `${n} ${n === 1 ? 'job' : 'jobs'} could not be confirmed with the work system. Nothing was sent.`;
|
|
210
251
|
}
|
|
211
252
|
|
|
212
253
|
// Send all ready: issues each ready draft, then queues it for the accounting
|
|
213
|
-
// system. With `invoiceIds`, only those drafts
|
|
214
|
-
//
|
|
215
|
-
//
|
|
254
|
+
// system. With `invoiceIds`, only those drafts. One id (the invoice page) is
|
|
255
|
+
// all or nothing. More than one id (the bulk bar) sends the ones Flow could
|
|
256
|
+
// confirm and leaves the rest. `work` is the work integration, for the read
|
|
257
|
+
// before act. Answers { issued, queued, remaining, waiting, held }.
|
|
216
258
|
export async function sendAllReady(db, organisationId, { accounting = null, work = null, personId, issueDate, invoiceIds = null, now = new Date() }) {
|
|
217
259
|
await assertCanSend(accounting, organisationId);
|
|
218
260
|
// Issue #45: the accounting copy reads its latest changes first, so the
|
|
@@ -236,11 +278,23 @@ export async function sendAllReady(db, organisationId, { accounting = null, work
|
|
|
236
278
|
: 'Only a ready invoice can be sent. Mark it ready first.');
|
|
237
279
|
}
|
|
238
280
|
}
|
|
239
|
-
const
|
|
240
|
-
|
|
281
|
+
const accountingWaiting = accountingRead.outcome === 'waiting';
|
|
282
|
+
const { go, waiting, stops } = accountingWaiting
|
|
283
|
+
? { go: [], waiting: ready.slice(0, SEND_BATCH), stops: [] }
|
|
241
284
|
: await readJobsFirst(db, organisationId, ready.slice(0, SEND_BATCH), { work, now, personId });
|
|
242
|
-
|
|
243
|
-
|
|
285
|
+
// One invoice (the invoice page) is all or nothing: a job Flow could not
|
|
286
|
+
// confirm stops the send. A bulk press sends the ones it confirmed. The
|
|
287
|
+
// rest stay for the next press (`waiting`), or on Pending when the read
|
|
288
|
+
// sent the job to review. An accounting read that cannot run still stops
|
|
289
|
+
// every ticked invoice: that one read covers the whole press. When nothing
|
|
290
|
+
// can go, the error names the cause and the count.
|
|
291
|
+
if (invoiceIds && waiting.length && go.length === 0) {
|
|
292
|
+
throw new FlowError('not_ready', stoppedSendMessage({
|
|
293
|
+
accountingWaiting,
|
|
294
|
+
accountingReason: accountingRead.reason,
|
|
295
|
+
stops,
|
|
296
|
+
waiting,
|
|
297
|
+
}));
|
|
244
298
|
}
|
|
245
299
|
const batch = go;
|
|
246
300
|
let issued = 0;
|
|
@@ -203,6 +203,10 @@ export function createCallGuard({
|
|
|
203
203
|
lockName = `${integration}-call-guard`,
|
|
204
204
|
report: reports = true,
|
|
205
205
|
policy: overrides = {},
|
|
206
|
+
// Optional columns on this adapter's call table, filled from record().
|
|
207
|
+
// `lane` is the allow-list (anything else is stored null). `rateLimitProblem`
|
|
208
|
+
// keeps a short token such as Xero's X-Rate-Limit-Problem.
|
|
209
|
+
telemetry = {},
|
|
206
210
|
now = Date.now,
|
|
207
211
|
log = console,
|
|
208
212
|
} = {}) {
|
|
@@ -215,6 +219,14 @@ export function createCallGuard({
|
|
|
215
219
|
const C = { key: sql.identifier(columns.key ?? 'key'), at: sql.identifier(columns.at ?? 'at') };
|
|
216
220
|
const tag = `[${integration}]`;
|
|
217
221
|
const failed = sql`(failure_kind is not null or status_code >= 500 or (status_code >= 400 and status_code <> 404))`;
|
|
222
|
+
const laneAllowed = Array.isArray(telemetry.lane) ? telemetry.lane : null;
|
|
223
|
+
const keepProblem = telemetry.rateLimitProblem === true;
|
|
224
|
+
const cleanLane = (value) => (laneAllowed?.includes(value) ? value : null);
|
|
225
|
+
const cleanProblem = (value) => {
|
|
226
|
+
if (!keepProblem || typeof value !== 'string') return null;
|
|
227
|
+
const text = value.trim();
|
|
228
|
+
return /^[A-Za-z][A-Za-z0-9_-]{0,39}$/.test(text) ? text : null;
|
|
229
|
+
};
|
|
218
230
|
|
|
219
231
|
const lock = (tx, key) => tx.execute(sql`select pg_advisory_xact_lock(hashtext(${`${lockName}:${key}`}))`);
|
|
220
232
|
const extraColumns = Object.keys(callDefaults).map((column) => sql`, ${sql.identifier(column)}`);
|
|
@@ -263,12 +275,17 @@ export function createCallGuard({
|
|
|
263
275
|
// resourceResumed, opened, closed, budgetSpent }),
|
|
264
276
|
// each of which was just reported once. `callId` names a call row the
|
|
265
277
|
// adapter wrote when it admitted the call: that row is updated, not added.
|
|
266
|
-
async function record(rawKey, { callId = null, resource = null, path = null, statusCode = null, failureKind = null, durationMs = null } = {}) {
|
|
278
|
+
async function record(rawKey, { callId = null, resource = null, path = null, statusCode = null, failureKind = null, durationMs = null, lane: laneRaw = null, rateLimitProblem = null } = {}) {
|
|
267
279
|
const key = cleanKey(rawKey);
|
|
268
280
|
const where = cleanResource(resource);
|
|
269
281
|
const nowMs = Number(now());
|
|
270
282
|
const nowAt = new Date(nowMs);
|
|
271
283
|
const status = Number.isInteger(statusCode) ? statusCode : null;
|
|
284
|
+
const lane = cleanLane(laneRaw);
|
|
285
|
+
const problem = cleanProblem(rateLimitProblem);
|
|
286
|
+
const extraCols = sql`${laneAllowed ? sql`, lane` : sql``}${keepProblem ? sql`, rate_limit_problem` : sql``}`;
|
|
287
|
+
const extraVals = sql`${laneAllowed ? sql`, ${lane}` : sql``}${keepProblem ? sql`, ${problem}` : sql``}`;
|
|
288
|
+
const extraSet = sql`${laneAllowed ? sql`, lane = ${lane}` : sql``}${keepProblem ? sql`, rate_limit_problem = ${problem}` : sql``}`;
|
|
272
289
|
const outcome = await db.transaction(async (tx) => {
|
|
273
290
|
await lock(tx, key);
|
|
274
291
|
await ensureRow(tx, key);
|
|
@@ -276,10 +293,10 @@ export function createCallGuard({
|
|
|
276
293
|
const took = Number.isFinite(durationMs) ? Math.max(0, Math.round(durationMs)) : null;
|
|
277
294
|
if (callId !== null) {
|
|
278
295
|
await tx.execute(sql`update ${T.calls} set resource = ${where}, request_path = ${normaliseRequestPath(path)}, status_code = ${status},
|
|
279
|
-
failure_kind = ${kind}, duration_ms = ${took} where id = ${callId} and ${C.key} = ${key}`);
|
|
296
|
+
failure_kind = ${kind}, duration_ms = ${took}${extraSet} where id = ${callId} and ${C.key} = ${key}`);
|
|
280
297
|
} else {
|
|
281
|
-
await tx.execute(sql`insert into ${T.calls} (${C.key}, ${C.at}, resource, request_path, status_code, failure_kind, duration_ms${sql.join(extraColumns, sql``)})
|
|
282
|
-
values (${key}, ${nowAt}, ${where}, ${normaliseRequestPath(path)}, ${status}, ${kind}, ${took}${sql.join(extraValues, sql``)})`);
|
|
298
|
+
await tx.execute(sql`insert into ${T.calls} (${C.key}, ${C.at}, resource, request_path, status_code, failure_kind, duration_ms${extraCols}${sql.join(extraColumns, sql``)})
|
|
299
|
+
values (${key}, ${nowAt}, ${where}, ${normaliseRequestPath(path)}, ${status}, ${kind}, ${took}${extraVals}${sql.join(extraValues, sql``)})`);
|
|
283
300
|
}
|
|
284
301
|
const state = await readState(tx, key);
|
|
285
302
|
if (!countsAsFailure({ statusCode: status, failureKind })) {
|
|
@@ -9,13 +9,17 @@ import { scrubSpan } from '../../ui/monitoring.js';
|
|
|
9
9
|
// the host setting SENTRY_DSN. It has no tables.
|
|
10
10
|
|
|
11
11
|
// The browser SDK, loaded from Sentry's CDN only while this adapter is on
|
|
12
|
-
// (server/ui/monitoring.js). Pinned, with its integrity hash. With
|
|
13
|
-
// the bundle
|
|
14
|
-
// it
|
|
12
|
+
// (server/ui/monitoring.js). Pinned, with its integrity hash. With replay on,
|
|
13
|
+
// the bundle with session replay; with traces on, the bundle that also traces
|
|
14
|
+
// (Sentry ships it only with replay in it).
|
|
15
15
|
export const SENTRY_BROWSER = Object.freeze({
|
|
16
16
|
src: 'https://browser.sentry-cdn.com/11.4.0/bundle.feedback.min.js',
|
|
17
17
|
integrity: 'sha384-H9RmZ/VaqOPZrUezgCSqHp4t3LoQMd/5+0Y4oEGUB2bddZzSnwZiOYoWneK4diWt',
|
|
18
18
|
});
|
|
19
|
+
export const SENTRY_BROWSER_REPLAY = Object.freeze({
|
|
20
|
+
src: 'https://browser.sentry-cdn.com/11.4.0/bundle.replay.feedback.min.js',
|
|
21
|
+
integrity: 'sha384-9Iyc17wHvpK0bqZe+cuAedQDHoWwenMrxXR+HgLLkQ3rHM4zQD+amLzBnmrQgowj',
|
|
22
|
+
});
|
|
19
23
|
export const SENTRY_BROWSER_TRACING = Object.freeze({
|
|
20
24
|
src: 'https://browser.sentry-cdn.com/11.4.0/bundle.tracing.replay.feedback.min.js',
|
|
21
25
|
integrity: 'sha384-LjwHQKAojcITl6Q7innDSAFjziFnqV9ovZBNXnXnQwub1VXIdBMJnYrQNihtxSa6',
|
|
@@ -44,6 +48,7 @@ export const SENTRY_SETUP = {
|
|
|
44
48
|
{ name: 'SENTRY_ENVIRONMENT', label: 'Environment', help: 'How Sentry labels this installation, for example production. Empty: production, or workspace in a Replit workspace.' },
|
|
45
49
|
{ name: 'SENTRY_CLIENT', label: 'Client', help: 'The name your support desk files this installation\'s reports under. Sent as the client tag. Empty: no client tag.' },
|
|
46
50
|
{ 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.' },
|
|
51
|
+
{ name: 'SENTRY_REPLAY', label: 'Replay with reports', kind: 'onoff', help: 'On: each report and each browser error keeps up to the last minute of the page, as a replay with every word hidden.' },
|
|
47
52
|
{ 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.' },
|
|
48
53
|
],
|
|
49
54
|
};
|
|
@@ -56,6 +61,7 @@ export function readSentrySettings(env) {
|
|
|
56
61
|
// labelled apart so they never read as the live app's.
|
|
57
62
|
const environment = String(env.SENTRY_ENVIRONMENT ?? '').trim() || (isReplitWorkspace(env) ? 'workspace' : 'production');
|
|
58
63
|
const feedback = String(env.SENTRY_FEEDBACK ?? 'on').trim() !== 'off';
|
|
64
|
+
const replay = String(env.SENTRY_REPLAY ?? 'on').trim() !== 'off';
|
|
59
65
|
const rate = String(env.SENTRY_TRACES_SAMPLE_RATE ?? '').trim();
|
|
60
66
|
const tracesSampleRate = rate === '' ? DEFAULT_TRACES_SAMPLE_RATE : Number(rate);
|
|
61
67
|
if (!/^\d*\.?\d+$/.test(rate || '0') || !(tracesSampleRate >= 0 && tracesSampleRate <= 1)) {
|
|
@@ -66,7 +72,7 @@ export function readSentrySettings(env) {
|
|
|
66
72
|
// Every event says which product and, when set, which client it is from,
|
|
67
73
|
// so one support desk can serve many installations.
|
|
68
74
|
const tags = client ? { product: 'flow', client } : { product: 'flow' };
|
|
69
|
-
return { dsn, environment, feedback, tracesSampleRate, tags };
|
|
75
|
+
return { dsn, environment, feedback, replay, tracesSampleRate, tags };
|
|
70
76
|
}
|
|
71
77
|
|
|
72
78
|
// No trace for the host's health check: it is not a person waiting.
|
|
@@ -108,7 +114,7 @@ export function createSentryAdapter({ sdk = SentryNode, env: hostEnv = process.e
|
|
|
108
114
|
),
|
|
109
115
|
beforeSendSpan: scrubSpan,
|
|
110
116
|
});
|
|
111
|
-
log.info?.(`[sentry] on: environment ${settings.environment}; report button ${settings.feedback ? 'on' : 'off'}; traces ${settings.tracesSampleRate > 0 ? settings.tracesSampleRate : 'off'}`);
|
|
117
|
+
log.info?.(`[sentry] on: environment ${settings.environment}; report button ${settings.feedback ? 'on' : 'off'}; replay ${settings.replay ? 'on' : 'off'}; traces ${settings.tracesSampleRate > 0 ? settings.tracesSampleRate : 'off'}`);
|
|
112
118
|
},
|
|
113
119
|
async stop() {
|
|
114
120
|
if (settings) await sdk.close(2000);
|
|
@@ -133,15 +139,16 @@ export function createSentryAdapter({ sdk = SentryNode, env: hostEnv = process.e
|
|
|
133
139
|
// meant for browsers.
|
|
134
140
|
browserMonitoring() {
|
|
135
141
|
if (!settings) return null;
|
|
136
|
-
const { tracesSampleRate } = settings;
|
|
142
|
+
const { tracesSampleRate, replay } = settings;
|
|
137
143
|
return {
|
|
138
144
|
dsn: settings.dsn,
|
|
139
145
|
environment: settings.environment,
|
|
140
146
|
release,
|
|
141
147
|
feedback: settings.feedback,
|
|
148
|
+
replay,
|
|
142
149
|
tags: settings.tags,
|
|
143
150
|
tracesSampleRate,
|
|
144
|
-
script: tracesSampleRate > 0 ? SENTRY_BROWSER_TRACING : SENTRY_BROWSER,
|
|
151
|
+
script: tracesSampleRate > 0 ? SENTRY_BROWSER_TRACING : replay ? SENTRY_BROWSER_REPLAY : SENTRY_BROWSER,
|
|
145
152
|
};
|
|
146
153
|
},
|
|
147
154
|
// Runs `work` in a span of the current trace (server/core/tracing.js).
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { FlowError } from '../../core/errors.js';
|
|
2
|
+
import { currentXeroLane } from './governor.js';
|
|
2
3
|
import { XERO_ENDPOINTS } from './settings.js';
|
|
3
4
|
|
|
4
5
|
// The Xero Accounting API client. Every call goes through the rate-limit
|
|
@@ -130,7 +131,7 @@ export function createXeroClient({
|
|
|
130
131
|
} catch (error) {
|
|
131
132
|
if (error?.code === 'xero_replay_no_match') throw error;
|
|
132
133
|
const timedOut = error?.name === 'AbortError';
|
|
133
|
-
await guard?.record(tenantId, { ...call, failureKind: timedOut ? 'timeout' : 'network', durationMs: Date.now() - startedAt });
|
|
134
|
+
await guard?.record(tenantId, { ...call, failureKind: timedOut ? 'timeout' : 'network', durationMs: Date.now() - startedAt, lane: currentXeroLane() });
|
|
134
135
|
throw xeroError(timedOut ? 'xero_timeout' : 'xero_unreachable',
|
|
135
136
|
timedOut ? `Xero did not answer within ${timeoutMs} ms.` : 'Flow could not reach Xero.',
|
|
136
137
|
{ status: timedOut ? 504 : 503 });
|
|
@@ -169,7 +170,16 @@ export function createXeroClient({
|
|
|
169
170
|
// A first 401 usually means the access token expired: it is not counted,
|
|
170
171
|
// and the call is tried once more with a fresh token.
|
|
171
172
|
const expiredToken = response.status === 401 && !refreshed;
|
|
172
|
-
|
|
173
|
+
const problem = response.status === 429 ? response.headers?.get?.('x-rate-limit-problem')?.trim() || null : null;
|
|
174
|
+
if (!expiredToken) {
|
|
175
|
+
await guard?.record(tenantId, {
|
|
176
|
+
...call,
|
|
177
|
+
statusCode: response.status,
|
|
178
|
+
durationMs,
|
|
179
|
+
lane: currentXeroLane(),
|
|
180
|
+
rateLimitProblem: problem,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
173
183
|
if (response.ok) return payload ?? {};
|
|
174
184
|
|
|
175
185
|
const error = apiError(response, payload);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
-- The lane a call counted against, and a 429's X-Rate-Limit-Problem, so the
|
|
2
|
+
-- Xero card can show the day's calls without asking Xero. A row from before
|
|
3
|
+
-- this column has no lane: the card does not guess one.
|
|
4
|
+
ALTER TABLE "xero_calls" ADD COLUMN IF NOT EXISTS "lane" text;
|
|
5
|
+
--> statement-breakpoint
|
|
6
|
+
ALTER TABLE "xero_calls" ADD COLUMN IF NOT EXISTS "rate_limit_problem" text;
|
|
7
|
+
--> statement-breakpoint
|
|
8
|
+
DO $flow_guard$ BEGIN
|
|
9
|
+
ALTER TABLE "xero_calls" ADD CONSTRAINT "xero_calls_lane_valid" CHECK ("lane" is null or "lane" in ('writes', 'sync', 'reads', 'other'));
|
|
10
|
+
EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
|
|
11
|
+
END $flow_guard$;
|
|
@@ -14,6 +14,7 @@ import { accountingStatus, checkSentInvoices } from './reconcile.js';
|
|
|
14
14
|
import { decryptToken, deleteConnection, describeConnection, readConnection, saveConnection } from './connections.js';
|
|
15
15
|
import { authorizeUrl, exchangeCode, fetchConnections, revokeToken, selectConnection } from './oauth.js';
|
|
16
16
|
import { writesAllowed } from './settings.js';
|
|
17
|
+
import { readXeroUsage } from './usage.js';
|
|
17
18
|
import { saveEvents, signatureValid } from './webhooks.js';
|
|
18
19
|
import { DEFAULT_FAMILY } from '../../theme/themes.js';
|
|
19
20
|
import { messagePage as themedMessagePage } from '../../ui/page.js';
|
|
@@ -109,8 +110,12 @@ export function callsState(status) {
|
|
|
109
110
|
return { stopped: Boolean(status.haltedReason) || resources.length > 0, all: Boolean(status.haltedReason), resources, since };
|
|
110
111
|
}
|
|
111
112
|
|
|
113
|
+
const XERO_INTEGRATION = Object.freeze({ name: 'Xero', logo: 'xero.svg' });
|
|
114
|
+
|
|
112
115
|
export function createXeroRouter({ db, personAuth, runtime, log = console, buttonCooldowns = true, actions = createActionGuard({ integration: 'xero', actions: XERO_ACTIONS, off: !buttonCooldowns }), messagePage = (options) => themedMessagePage({ ...options, defaultFamily: DEFAULT_FAMILY }) }) {
|
|
113
|
-
|
|
116
|
+
// Shared callback card (server/ui/page.js): integration marks, status line,
|
|
117
|
+
// and a short auto-return on success only (issue #211).
|
|
118
|
+
const page = (title, text, tone, extra = {}) => messagePage({ title, text, tone, integration: XERO_INTEGRATION, ...extra });
|
|
114
119
|
const { settings } = runtime;
|
|
115
120
|
const router = express.Router();
|
|
116
121
|
const act = (name, key) => actions.route(name, { key });
|
|
@@ -144,16 +149,29 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, butto
|
|
|
144
149
|
router.get('/status', ...signedIn, handle(log, async (req, res) => {
|
|
145
150
|
const organisationId = await requireOrganisation(db, req.query.organisationId);
|
|
146
151
|
const connection = await readConnection(db, organisationId);
|
|
152
|
+
const connected = Boolean(connection && connection.mode === settings.mode);
|
|
153
|
+
const picture = connected
|
|
154
|
+
? await readXeroUsage(db, {
|
|
155
|
+
organisationId,
|
|
156
|
+
tenantId: connection.tenantId,
|
|
157
|
+
governor: runtime.governor.status(connection.tenantId),
|
|
158
|
+
warningFraction: settings.usageWarningFraction,
|
|
159
|
+
refreshedAt: connection.refreshedAt,
|
|
160
|
+
now: runtime.now(),
|
|
161
|
+
})
|
|
162
|
+
: { usage: null, freshness: null };
|
|
147
163
|
res.json({
|
|
148
164
|
ok: true,
|
|
149
165
|
mode: settings.mode,
|
|
150
166
|
writesAllowed: writesAllowed(settings),
|
|
151
167
|
redirectUri: settings.redirectUri,
|
|
152
|
-
connected
|
|
168
|
+
connected,
|
|
153
169
|
connection: describeConnection(connection),
|
|
154
|
-
rateLimit:
|
|
155
|
-
calls:
|
|
170
|
+
rateLimit: connected ? runtime.governor.status(connection.tenantId) : null,
|
|
171
|
+
calls: connected ? callsState(await runtime.guardFor(db).status(connection.tenantId)) : null,
|
|
156
172
|
lastSync: await lastSync(db, organisationId),
|
|
173
|
+
usage: picture.usage,
|
|
174
|
+
freshness: picture.freshness,
|
|
157
175
|
});
|
|
158
176
|
}));
|
|
159
177
|
|
|
@@ -213,7 +231,13 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, butto
|
|
|
213
231
|
if (previous && previous.tenantId !== connection.tenantId) await resetImport(db, entry.organisationId);
|
|
214
232
|
await queueHistoryImport(db, entry.organisationId, { reason: 'connected' });
|
|
215
233
|
log.info?.(`[xero] organisation ${entry.organisationId} connected to Xero tenant ${connection.tenantId} (${settings.mode}) by person ${entry.personId}`);
|
|
216
|
-
|
|
234
|
+
const organisation = connection.tenantName || 'your Xero organisation';
|
|
235
|
+
return res.type('html').send(page(
|
|
236
|
+
'Xero is connected',
|
|
237
|
+
'Flow is now copying its invoice and payment history in the background.',
|
|
238
|
+
'success',
|
|
239
|
+
{ organisation, mode: settings.mode, autoReturnSeconds: 5 },
|
|
240
|
+
));
|
|
217
241
|
} catch (error) {
|
|
218
242
|
log.error?.(`[xero] connect failed: ${error.code ?? 'error'}: ${error.message}`);
|
|
219
243
|
const message = error instanceof FlowError ? error.message : 'Something went wrong talking to Xero. Try again.';
|
|
@@ -21,6 +21,11 @@ export const XERO_CALL_GUARD = Object.freeze({
|
|
|
21
21
|
name: 'Xero',
|
|
22
22
|
tables: Object.freeze({ state: 'xero_call_guard', calls: 'xero_calls' }),
|
|
23
23
|
policy: Object.freeze({ authStops: 'resource' }),
|
|
24
|
+
// Kept on the call row so the Xero card can show lanes and 429s after a restart.
|
|
25
|
+
telemetry: Object.freeze({
|
|
26
|
+
lane: Object.freeze(['writes', 'sync', 'reads', 'other']),
|
|
27
|
+
rateLimitProblem: true,
|
|
28
|
+
}),
|
|
24
29
|
});
|
|
25
30
|
|
|
26
31
|
export function requireXeroSettings(env = process.env) {
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm';
|
|
2
|
+
import { assembleUsage, startOfUtcDay } from '../../ui/xero-usage-model.js';
|
|
3
|
+
|
|
4
|
+
// What the Xero card draws. Read from counters Flow already keeps: the
|
|
5
|
+
// governor's budget, the call log, and the copy's own timestamps. No Xero call.
|
|
6
|
+
|
|
7
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
8
|
+
const iso = (value) => (value ? new Date(value).toISOString() : null);
|
|
9
|
+
|
|
10
|
+
// Calls that count toward the day's budget. A token refresh is recorded, and
|
|
11
|
+
// it is not an Accounting API call, so it stays out of the lanes.
|
|
12
|
+
const BUDGET_CALL = sql`lane is not null and coalesce(resource, '') <> 'token'`;
|
|
13
|
+
|
|
14
|
+
export async function readXeroUsage(db, { organisationId, tenantId, governor, warningFraction, refreshedAt, now = Date.now() }) {
|
|
15
|
+
const at = Number(now);
|
|
16
|
+
const dayStart = startOfUtcDay(at);
|
|
17
|
+
const weekStart = new Date(dayStart - 6 * DAY_MS);
|
|
18
|
+
const dayEnd = new Date(dayStart + DAY_MS);
|
|
19
|
+
const today = new Date(dayStart);
|
|
20
|
+
|
|
21
|
+
const [hours, days, tasks, limited, freshness] = await Promise.all([
|
|
22
|
+
db.execute(sql`
|
|
23
|
+
select to_char(date_trunc('hour', "at" at time zone 'UTC'), 'YYYY-MM-DD"T"HH24:00:00"Z"') as hour,
|
|
24
|
+
lane, count(*)::int as n
|
|
25
|
+
from xero_calls
|
|
26
|
+
where key = ${tenantId} and "at" >= ${today} and "at" < ${dayEnd} and ${BUDGET_CALL}
|
|
27
|
+
group by 1, 2`),
|
|
28
|
+
db.execute(sql`
|
|
29
|
+
select to_char("at" at time zone 'UTC', 'YYYY-MM-DD') as day, count(*)::int as n
|
|
30
|
+
from xero_calls
|
|
31
|
+
where key = ${tenantId} and "at" >= ${weekStart} and "at" < ${dayEnd} and ${BUDGET_CALL}
|
|
32
|
+
group by 1`),
|
|
33
|
+
db.execute(sql`
|
|
34
|
+
select coalesce(nullif(resource, ''), 'call') as task, count(*)::int as n
|
|
35
|
+
from xero_calls
|
|
36
|
+
where key = ${tenantId} and "at" >= ${today} and "at" < ${dayEnd} and coalesce(resource, '') <> 'token'
|
|
37
|
+
group by 1
|
|
38
|
+
order by n desc, task
|
|
39
|
+
limit 8`),
|
|
40
|
+
db.execute(sql`
|
|
41
|
+
select "at", rate_limit_problem as problem, lane, resource as task
|
|
42
|
+
from xero_calls
|
|
43
|
+
where key = ${tenantId} and status_code = 429 and "at" >= ${weekStart}
|
|
44
|
+
order by "at" desc
|
|
45
|
+
limit 12`),
|
|
46
|
+
db.execute(sql`
|
|
47
|
+
select
|
|
48
|
+
(select max(received_at) from xero_webhook_events where organisation_id = ${organisationId} and source = 'webhook') as webhook_at,
|
|
49
|
+
(select max(last_read_at) from xero_mirror_cursors where organisation_id = ${organisationId}) as poll_at,
|
|
50
|
+
(select last_finished_at from xero_daily_checks where organisation_id = ${organisationId}) as nightly_at,
|
|
51
|
+
(select max(read_at) from xero_webhook_events where organisation_id = ${organisationId}) as read_at`),
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
const fresh = freshness.rows[0] ?? {};
|
|
55
|
+
const copyCandidates = [fresh.poll_at, fresh.nightly_at, fresh.read_at].filter(Boolean).map((value) => new Date(value).getTime());
|
|
56
|
+
return {
|
|
57
|
+
usage: assembleUsage({
|
|
58
|
+
hourRows: hours.rows,
|
|
59
|
+
dayRows: days.rows,
|
|
60
|
+
taskRows: tasks.rows,
|
|
61
|
+
limitRows: limited.rows,
|
|
62
|
+
governor,
|
|
63
|
+
warningFraction,
|
|
64
|
+
now: at,
|
|
65
|
+
}),
|
|
66
|
+
freshness: {
|
|
67
|
+
webhookAt: iso(fresh.webhook_at),
|
|
68
|
+
pollAt: iso(fresh.poll_at),
|
|
69
|
+
nightlyAt: iso(fresh.nightly_at),
|
|
70
|
+
copyAt: copyCandidates.length ? new Date(Math.max(...copyCandidates)).toISOString() : null,
|
|
71
|
+
tokenRefreshedAt: iso(refreshedAt),
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
package/server/ui/alerts.js
CHANGED
|
@@ -33,8 +33,9 @@ export function alertStrip(ui, page, { onTab = () => {}, scope = null, onLoad =
|
|
|
33
33
|
let action = null;
|
|
34
34
|
if (first.action?.href && first.action.href !== location.pathname) action = h('a', { class: 'fx-alerts__action', href: first.action.href, onclick: go }, first.action.label);
|
|
35
35
|
else if (first.action?.tab) action = h('button', { class: 'fx-alerts__action', type: 'button', onclick: () => onTab(first.action.tab) }, first.action.label);
|
|
36
|
+
const line = first.moneyAtRiskCents > 0 ? `${first.title} ${money.format(first.moneyAtRiskCents / 100)} at risk.` : first.title;
|
|
36
37
|
element.replaceChildren(...[
|
|
37
|
-
h('p', { class: 'fx-alerts__text' },
|
|
38
|
+
h('p', { class: 'fx-alerts__text', title: line },
|
|
38
39
|
first.title,
|
|
39
40
|
first.moneyAtRiskCents > 0 ? h('span', { class: 'fx-alerts__money' }, ` ${money.format(first.moneyAtRiskCents / 100)} at risk.`) : null),
|
|
40
41
|
action,
|