@arcforgelabs/flow 2026.10.32-beta.1 → 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/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/api/invoicing.js +16 -5
- package/server/core/automations.js +17 -3
- package/server/core/invoices.js +2 -2
- package/server/core/invoicing-rules.js +14 -1
- package/server/core/ledger.js +3 -2
- package/server/core/pipeline.js +1 -1
- package/server/core/references.js +99 -0
- package/server/core/sending.js +11 -5
- package/server/db/migrations/0016_invoice_reference.sql +1 -0
- package/server/db/migrations/meta/0016_snapshot.json +3861 -0
- package/server/db/migrations/meta/_journal.json +7 -0
- package/server/db/schema.js +2 -0
- package/server/integrations/port.js +1 -0
- package/server/integrations/seazona/feed.js +8 -1
- package/server/integrations/seazona/index.js +9 -0
- package/server/integrations/seazona/lanes.js +1 -1
- package/server/integrations/seazona/patients.js +30 -0
- package/server/integrations/xero/client.js +12 -0
- package/server/integrations/xero/import.js +9 -3
- package/server/integrations/xero/index.js +3 -1
- package/server/integrations/xero/migrations/0007_xero_webhooks.sql +30 -0
- package/server/integrations/xero/migrations/meta/_journal.json +7 -0
- package/server/integrations/xero/mirror.js +16 -8
- package/server/integrations/xero/routes.js +20 -0
- package/server/integrations/xero/settings.js +3 -0
- package/server/integrations/xero/webhooks.js +311 -0
- package/server/ui/app.js +15 -7
- package/server/ui/flow.css +47 -7
- package/server/ui/invoice-notes.js +26 -0
- package/server/ui/invoicing-settings.js +3 -1
- package/server/ui/invoicing.js +136 -57
- package/server/ui/routes.js +1 -1
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { sql } from 'drizzle-orm';
|
|
3
|
+
import { isCallsStopped } from '../call-guard.js';
|
|
4
|
+
import { lastSync, parseXeroDate, syncCatalogue } from './catalogue.js';
|
|
5
|
+
import { withXeroLane } from './governor.js';
|
|
6
|
+
import { mirrorState } from './lookup.js';
|
|
7
|
+
import { mapRecords, mirrorXero, storeRows, toCents } from './mirror.js';
|
|
8
|
+
import { checkSentInvoices } from './reconcile.js';
|
|
9
|
+
|
|
10
|
+
// Xero's webhooks, and the daily check (issue #45). The poll (import.js) reads
|
|
11
|
+
// Xero's changes every 15 minutes. Webhooks make a change reach Flow in
|
|
12
|
+
// seconds, and the daily check finds what both of them missed.
|
|
13
|
+
//
|
|
14
|
+
// 1. Xero posts an event to /api/xero/webhooks: a record id and what
|
|
15
|
+
// happened, never the record. Flow checks the signature, saves the id in
|
|
16
|
+
// the inbox (xero_webhook_events) and answers 200 at once.
|
|
17
|
+
// 2. The worker reads the waiting invoices by id, up to 50 a call, into the
|
|
18
|
+
// copy (mirror.js), then compares the sent invoices again (reconcile.js).
|
|
19
|
+
// Credit notes are read as changes since the cursor; contacts refresh the
|
|
20
|
+
// contacts and items copy.
|
|
21
|
+
// 3. While webhooks arrive, the poll waits an hour instead of 15 minutes. If
|
|
22
|
+
// they stop, it goes back to 15 minutes by itself.
|
|
23
|
+
// 4. Once a day, one summary read of every invoice (about 1,000 a call)
|
|
24
|
+
// finds each copy row whose status, amounts or change time differ from
|
|
25
|
+
// Xero, and each row Xero no longer lists. Those go into the inbox and are
|
|
26
|
+
// read by id like an event. This catches part payments that do not move
|
|
27
|
+
// Xero's change time, and a webhook that never came.
|
|
28
|
+
//
|
|
29
|
+
// Every read goes through the call guard and the governor's sync lane, and
|
|
30
|
+
// leaves the mirror's reserve of the day's calls for drafts and people. Flow
|
|
31
|
+
// never writes to Xero here.
|
|
32
|
+
|
|
33
|
+
export const BY_ID_BATCH = 50;
|
|
34
|
+
// The poll's wait while webhooks arrive, and how recent the last one must be.
|
|
35
|
+
export const WEBHOOK_REFRESH_MS = 60 * 60 * 1000;
|
|
36
|
+
export const WEBHOOK_ACTIVE_MS = 24 * 60 * 60 * 1000;
|
|
37
|
+
export const DAILY_CHECK_EVERY_MS = 24 * 60 * 60 * 1000;
|
|
38
|
+
// A daily check that failed is tried again after this wait.
|
|
39
|
+
const DAILY_CHECK_RETRY_MS = 60 * 60 * 1000;
|
|
40
|
+
const SUMMARY_PAGE = 1000;
|
|
41
|
+
// At most this many records a day go back for a read by id (5 calls), so a
|
|
42
|
+
// check that finds the whole copy different cannot spend the day. The rest
|
|
43
|
+
// wait for the next day's check.
|
|
44
|
+
export const DAILY_FLAG_MAX = 250;
|
|
45
|
+
const MAX_SUMMARY_PAGES = 50;
|
|
46
|
+
// Contacts and items are read again at most this often for contact events.
|
|
47
|
+
const CONTACTS_EVERY_MS = 60 * 60 * 1000;
|
|
48
|
+
|
|
49
|
+
const CATEGORIES = { INVOICE: 'invoices', CREDITNOTE: 'credit_notes', CREDIT_NOTE: 'credit_notes', CONTACT: 'contacts' };
|
|
50
|
+
|
|
51
|
+
// Xero signs the raw body with HMAC-SHA256 and the webhook key, in base64
|
|
52
|
+
// (x-xero-signature). Compared in constant time.
|
|
53
|
+
export function signatureValid(key, rawBody, signature) {
|
|
54
|
+
if (!key || typeof signature !== 'string' || !signature) return false;
|
|
55
|
+
const expected = createHmac('sha256', key).update(rawBody).digest();
|
|
56
|
+
let given;
|
|
57
|
+
try {
|
|
58
|
+
given = Buffer.from(signature, 'base64');
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
return given.length === expected.length && timingSafeEqual(given, expected);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Saves the events of one verified delivery. An event for an organisation not
|
|
66
|
+
// connected in this mode is dropped. Answers how many were saved.
|
|
67
|
+
export async function saveEvents(db, runtime, payload, { now = new Date() } = {}) {
|
|
68
|
+
const events = Array.isArray(payload?.events) ? payload.events : [];
|
|
69
|
+
if (!events.length) return 0;
|
|
70
|
+
const { rows } = await db.execute(sql`select organisation_id, tenant_id from xero_connections where mode = ${runtime.settings.mode}`);
|
|
71
|
+
const byTenant = new Map(rows.map((r) => [r.tenant_id, r.organisation_id]));
|
|
72
|
+
let saved = 0;
|
|
73
|
+
for (const event of events) {
|
|
74
|
+
const organisationId = byTenant.get(event?.tenantId);
|
|
75
|
+
if (!organisationId || typeof event.resourceId !== 'string' || !event.resourceId) continue;
|
|
76
|
+
const resource = CATEGORIES[String(event.eventCategory ?? '').toUpperCase()] ?? 'other';
|
|
77
|
+
const at = Date.parse(`${String(event.eventDateUtc ?? '').replace(/Z?$/, 'Z')}`);
|
|
78
|
+
await flag(db, organisationId, resource, event.resourceId, {
|
|
79
|
+
source: 'webhook', eventType: event.eventType ?? null, eventAt: Number.isNaN(at) ? null : new Date(at), now,
|
|
80
|
+
});
|
|
81
|
+
saved += 1;
|
|
82
|
+
}
|
|
83
|
+
return saved;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Marks one record as waiting to be read by id.
|
|
87
|
+
async function flag(db, organisationId, resource, resourceId, { source, eventType = null, eventAt = null, now = new Date() }) {
|
|
88
|
+
await db.execute(sql`
|
|
89
|
+
insert into xero_webhook_events (organisation_id, resource, resource_id, event_type, source, event_at, received_at)
|
|
90
|
+
values (${organisationId}, ${resource}, ${resourceId}, ${eventType}, ${source}, ${eventAt}, ${now})
|
|
91
|
+
on conflict (organisation_id, resource, resource_id) do update set
|
|
92
|
+
event_type = excluded.event_type, source = excluded.source, event_at = excluded.event_at,
|
|
93
|
+
received_at = excluded.received_at, pending = true`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function markRead(db, organisationId, resource, ids = null) {
|
|
97
|
+
const only = ids ? sql`and resource_id = any(${sql.param(ids)}::text[])` : sql``;
|
|
98
|
+
await db.execute(sql`update xero_webhook_events set pending = false, read_at = now()
|
|
99
|
+
where organisation_id = ${organisationId} and resource = ${resource} and pending ${only}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Whether a webhook for this organisation arrived recently: the poll can wait.
|
|
103
|
+
export async function webhooksActive(db, runtime, organisationId, { now = Date.now() } = {}) {
|
|
104
|
+
if (!runtime.settings.webhookKey) return false;
|
|
105
|
+
const { rows: [row] } = await db.execute(sql`select max(received_at) as at from xero_webhook_events
|
|
106
|
+
where organisation_id = ${organisationId} and source = 'webhook'`);
|
|
107
|
+
return Boolean(row?.at && Number(now) - new Date(row.at).getTime() <= WEBHOOK_ACTIVE_MS);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Calls left above the reserve the mirror keeps for drafts and people.
|
|
111
|
+
function overReserve(runtime, tenantId) {
|
|
112
|
+
const budget = runtime.governor.status(tenantId);
|
|
113
|
+
return budget.dayRemaining > Math.ceil(budget.dayLimit * runtime.settings.mirrorReserveFraction);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function pendingIds(db, organisationId) {
|
|
117
|
+
const { rows } = await db.execute(sql`select resource, resource_id from xero_webhook_events
|
|
118
|
+
where organisation_id = ${organisationId} and pending order by received_at`);
|
|
119
|
+
const out = new Map();
|
|
120
|
+
for (const r of rows) out.set(r.resource, [...(out.get(r.resource) ?? []), r.resource_id]);
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Reads every waiting record of one organisation. Answers counts, or a
|
|
125
|
+
// `stopped` reason: 'calls_stopped' (the call guard) or 'budget'.
|
|
126
|
+
export async function readInbox(db, runtime, organisationId, { now = Date.now } = {}) {
|
|
127
|
+
const waiting = await pendingIds(db, organisationId);
|
|
128
|
+
const result = { invoices: { read: 0, inserted: 0, changed: 0, unchanged: 0 }, creditNotes: 0, contacts: false, sent: null };
|
|
129
|
+
if (!waiting.size) return result;
|
|
130
|
+
const { rows: [connection] } = await db.execute(sql`select tenant_id from xero_connections
|
|
131
|
+
where organisation_id = ${organisationId} and mode = ${runtime.settings.mode}`);
|
|
132
|
+
if (!connection) return result;
|
|
133
|
+
try {
|
|
134
|
+
await runtime.guardFor(db).check(connection.tenant_id);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
if (isCallsStopped(error)) return { ...result, stopped: 'calls_stopped' };
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const invoiceIds = waiting.get('invoices') ?? [];
|
|
141
|
+
if (invoiceIds.length) {
|
|
142
|
+
const counts = result.invoices;
|
|
143
|
+
const stopped = await runtime.serial(organisationId, () => withXeroLane('sync', async () => {
|
|
144
|
+
const { client } = await runtime.clientFor(db, organisationId);
|
|
145
|
+
for (let i = 0; i < invoiceIds.length; i += BY_ID_BATCH) {
|
|
146
|
+
if (!overReserve(runtime, client.tenantId)) return 'budget';
|
|
147
|
+
const batch = invoiceIds.slice(i, i + BY_ID_BATCH);
|
|
148
|
+
const rows = mapRecords('invoices', await client.listInvoicesByIds(batch), runtime.settings);
|
|
149
|
+
await db.transaction(async (tx) => {
|
|
150
|
+
await storeRows(tx, organisationId, 'invoices', rows, counts);
|
|
151
|
+
// An id Xero did not return is read too: there is nothing more to get.
|
|
152
|
+
await markRead(tx, organisationId, 'invoices', batch);
|
|
153
|
+
});
|
|
154
|
+
counts.read += rows.length;
|
|
155
|
+
}
|
|
156
|
+
return null;
|
|
157
|
+
}));
|
|
158
|
+
if (stopped) return { ...result, stopped };
|
|
159
|
+
if (counts.inserted || counts.changed) result.sent = await checkSentInvoices(db, runtime, organisationId);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (waiting.get('credit_notes')?.length) {
|
|
163
|
+
// Credit notes are few: one read of the changes since the cursor gets them.
|
|
164
|
+
const [read] = await mirrorXero(db, runtime, organisationId, { resources: ['credit_notes'] });
|
|
165
|
+
if (read?.stopped === 'budget') return { ...result, stopped: 'budget' };
|
|
166
|
+
result.creditNotes = read?.read ?? 0;
|
|
167
|
+
await markRead(db, organisationId, 'credit_notes');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (waiting.get('contacts')?.length) {
|
|
171
|
+
const last = await lastSync(db, organisationId);
|
|
172
|
+
const at = last?.finishedAt ?? last?.startedAt;
|
|
173
|
+
if (!at || Number(now()) - new Date(at).getTime() >= CONTACTS_EVERY_MS) {
|
|
174
|
+
await syncCatalogue(db, runtime, organisationId);
|
|
175
|
+
await markRead(db, organisationId, 'contacts');
|
|
176
|
+
result.contacts = true;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Events Flow does not copy (other categories) need no read.
|
|
181
|
+
if (waiting.get('other')?.length) await markRead(db, organisationId, 'other');
|
|
182
|
+
return result;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// The daily check for one organisation: one summary read of every invoice,
|
|
186
|
+
// compared with the copy. Each row that differs, and each live copy row Xero
|
|
187
|
+
// no longer lists, waits in the inbox to be read by id. Answers counts, or
|
|
188
|
+
// `stopped`.
|
|
189
|
+
export async function dailyCheck(db, runtime, organisationId, { now = () => new Date() } = {}) {
|
|
190
|
+
await db.execute(sql`insert into xero_daily_checks (organisation_id, last_started_at) values (${organisationId}, ${now()})
|
|
191
|
+
on conflict (organisation_id) do update set last_started_at = excluded.last_started_at`);
|
|
192
|
+
try {
|
|
193
|
+
const seen = [];
|
|
194
|
+
const stopped = await runtime.serial(organisationId, () => withXeroLane('sync', async () => {
|
|
195
|
+
const { client } = await runtime.clientFor(db, organisationId);
|
|
196
|
+
for (let page = 1; page <= MAX_SUMMARY_PAGES; page += 1) {
|
|
197
|
+
if (!overReserve(runtime, client.tenantId)) return 'budget';
|
|
198
|
+
const list = await client.listInvoiceSummaries({ page, pageSize: SUMMARY_PAGE });
|
|
199
|
+
seen.push(...list.filter((i) => i.InvoiceID));
|
|
200
|
+
if (list.length < SUMMARY_PAGE) return null;
|
|
201
|
+
}
|
|
202
|
+
return 'max_pages';
|
|
203
|
+
}));
|
|
204
|
+
if (stopped) {
|
|
205
|
+
await db.execute(sql`update xero_daily_checks set last_error = ${stopped} where organisation_id = ${organisationId}`);
|
|
206
|
+
return { read: seen.length, flagged: 0, stopped };
|
|
207
|
+
}
|
|
208
|
+
const { rows: copy } = await db.execute(sql`select xero_invoice_id, status, total_cents, amount_due_cents,
|
|
209
|
+
amount_paid_cents, amount_credited_cents, xero_updated_at from xero_invoices where organisation_id = ${organisationId}`);
|
|
210
|
+
const byId = new Map(copy.map((r) => [r.xero_invoice_id, r]));
|
|
211
|
+
const flagged = new Set();
|
|
212
|
+
for (const x of seen) {
|
|
213
|
+
const row = byId.get(x.InvoiceID);
|
|
214
|
+
byId.delete(x.InvoiceID);
|
|
215
|
+
if (!row || summaryDiffers(row, x)) flagged.add(x.InvoiceID);
|
|
216
|
+
}
|
|
217
|
+
// A live copy row Xero no longer lists: read it by id to learn its state.
|
|
218
|
+
for (const row of byId.values()) if (!['DELETED', 'VOIDED'].includes(row.status)) flagged.add(row.xero_invoice_id);
|
|
219
|
+
const at = now();
|
|
220
|
+
const list = [...flagged].slice(0, DAILY_FLAG_MAX);
|
|
221
|
+
for (const id of list) await flag(db, organisationId, 'invoices', id, { source: 'daily_check', now: at });
|
|
222
|
+
await db.execute(sql`update xero_daily_checks set last_finished_at = ${at}, last_read = ${seen.length},
|
|
223
|
+
last_flagged = ${flagged.size}, last_error = null where organisation_id = ${organisationId}`);
|
|
224
|
+
return { read: seen.length, flagged: flagged.size, queued: list.length, stopped: null };
|
|
225
|
+
} catch (error) {
|
|
226
|
+
await db.execute(sql`update xero_daily_checks set last_error = ${`${error.code ?? 'error'}: ${String(error.message).slice(0, 300)}`}
|
|
227
|
+
where organisation_id = ${organisationId}`);
|
|
228
|
+
throw error;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const cents = (value) => (value === null || value === undefined ? null : Number(value));
|
|
233
|
+
|
|
234
|
+
// Whether a summary from Xero differs from the copy row in what the summary
|
|
235
|
+
// carries: status, money, and the change time.
|
|
236
|
+
export function summaryDiffers(row, summary) {
|
|
237
|
+
if (row.status !== summary.Status) return true;
|
|
238
|
+
for (const [column, field] of [['total_cents', 'Total'], ['amount_due_cents', 'AmountDue'], ['amount_paid_cents', 'AmountPaid'], ['amount_credited_cents', 'AmountCredited']]) {
|
|
239
|
+
if (summary[field] !== undefined && cents(row[column]) !== toCents(summary[field])) return true;
|
|
240
|
+
}
|
|
241
|
+
// Xero changed it after the copy's version (whole seconds, as Xero compares).
|
|
242
|
+
const changed = parseXeroDate(summary.UpdatedDateUTC);
|
|
243
|
+
const copied = row.xero_updated_at ? new Date(row.xero_updated_at) : null;
|
|
244
|
+
return Boolean(changed && (!copied || Math.floor(changed / 1000) > Math.floor(copied / 1000)));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// The organisations whose daily check is due: a complete copy, and no check
|
|
248
|
+
// finished in the last day (or none tried in the last hour after a failure).
|
|
249
|
+
export async function dueDailyChecks(db, runtime, { now = Date.now() } = {}) {
|
|
250
|
+
const { rows } = await db.execute(sql`select c.organisation_id, c.tenant_id, d.last_started_at, d.last_finished_at
|
|
251
|
+
from xero_connections c left join xero_daily_checks d on d.organisation_id = c.organisation_id
|
|
252
|
+
where c.mode = ${runtime.settings.mode}`);
|
|
253
|
+
const due = [];
|
|
254
|
+
for (const r of rows) {
|
|
255
|
+
const finished = r.last_finished_at ? new Date(r.last_finished_at).getTime() : null;
|
|
256
|
+
const started = r.last_started_at ? new Date(r.last_started_at).getTime() : null;
|
|
257
|
+
if (finished && Number(now) - finished < DAILY_CHECK_EVERY_MS) continue;
|
|
258
|
+
if (started && (!finished || started > finished) && Number(now) - started < DAILY_CHECK_RETRY_MS) continue;
|
|
259
|
+
if (!(await mirrorState(db, r.organisation_id, { now: new Date(Number(now)) })).complete) continue;
|
|
260
|
+
due.push({ organisationId: r.organisation_id, tenantId: r.tenant_id });
|
|
261
|
+
}
|
|
262
|
+
return due;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// After a failed read, an organisation waits 1 minute, then 2, 4 and up to an
|
|
266
|
+
// hour, so a failing read is never tried every tick.
|
|
267
|
+
export const RETRY_FIRST_MS = 60 * 1000;
|
|
268
|
+
export const RETRY_MAX_MS = 60 * 60 * 1000;
|
|
269
|
+
|
|
270
|
+
// One worker step: reads each organisation's inbox, then runs any daily check
|
|
271
|
+
// that is due. A stop by the call guard skips that organisation quietly; the
|
|
272
|
+
// guard has reported it. A failure is logged once as an error; the same
|
|
273
|
+
// failure again is a warning. `holds` keeps each organisation's wait between
|
|
274
|
+
// steps.
|
|
275
|
+
export async function runWebhookWork(db, runtime, { log = console, now = Date.now, holds = new Map() } = {}) {
|
|
276
|
+
const failed = (organisationId, what, error) => {
|
|
277
|
+
const held = holds.get(organisationId);
|
|
278
|
+
const failures = (held?.failures ?? 0) + 1;
|
|
279
|
+
holds.set(organisationId, { failures, until: Number(now()) + Math.min(RETRY_MAX_MS, RETRY_FIRST_MS * 2 ** (failures - 1)), code: error?.code ?? 'error' });
|
|
280
|
+
const line = `[xero] ${what} for organisation ${organisationId} failed: ${error?.code ?? 'error'}`;
|
|
281
|
+
if (held?.code === (error?.code ?? 'error')) log.warn?.(line);
|
|
282
|
+
else log.error?.(line);
|
|
283
|
+
};
|
|
284
|
+
const waiting = (organisationId) => (holds.get(organisationId)?.until ?? 0) > Number(now());
|
|
285
|
+
|
|
286
|
+
const { rows } = await db.execute(sql`select distinct e.organisation_id from xero_webhook_events e
|
|
287
|
+
join xero_connections c on c.organisation_id = e.organisation_id and c.mode = ${runtime.settings.mode}
|
|
288
|
+
where e.pending`);
|
|
289
|
+
for (const { organisation_id: organisationId } of rows) {
|
|
290
|
+
if (waiting(organisationId)) continue;
|
|
291
|
+
try {
|
|
292
|
+
const read = await readInbox(db, runtime, organisationId, { now });
|
|
293
|
+
holds.delete(organisationId);
|
|
294
|
+
if (read.invoices.read || read.creditNotes || read.contacts) {
|
|
295
|
+
log.info?.(`[xero] read waiting changes for organisation ${organisationId}: ${read.invoices.read} invoices, ${read.creditNotes} credit notes${read.contacts ? ', contacts' : ''}`);
|
|
296
|
+
}
|
|
297
|
+
} catch (error) {
|
|
298
|
+
if (!isCallsStopped(error)) failed(organisationId, 'reading waiting changes', error);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
for (const { organisationId, tenantId } of await dueDailyChecks(db, runtime, { now: now() })) {
|
|
302
|
+
try {
|
|
303
|
+
await runtime.guardFor(db).check(tenantId);
|
|
304
|
+
const check = await dailyCheck(db, runtime, organisationId, { now: () => new Date(Number(now())) });
|
|
305
|
+
log.info?.(`[xero] daily check for organisation ${organisationId}: ${check.read} invoices, ${check.flagged} to read again${check.stopped ? `; stopped (${check.stopped})` : ''}`);
|
|
306
|
+
} catch (error) {
|
|
307
|
+
// dueDailyChecks waits an hour after a failed check.
|
|
308
|
+
if (!isCallsStopped(error)) log.error?.(`[xero] daily check for organisation ${organisationId} failed: ${error?.code ?? 'error'}`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
package/server/ui/app.js
CHANGED
|
@@ -216,7 +216,7 @@ const ICON_PATHS = {
|
|
|
216
216
|
person: '<circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 3.6-7 8-7s8 3 8 7"/>',
|
|
217
217
|
flag: '<path d="M5 21V4M5 4h11l-2 4 2 4H5"/>',
|
|
218
218
|
search: '<circle cx="11" cy="11" r="6.5"/><path d="M16 16l4.5 4.5"/>',
|
|
219
|
-
|
|
219
|
+
sidebar: '<rect x="3" y="4" width="18" height="16" rx="2.5"/><path d="M9.5 4v16"/>',
|
|
220
220
|
palette: '<path d="M12 3a9 9 0 100 18c1.1 0 1.5-.8 1.5-1.6 0-1-.8-1.4-.8-2.4 0-.9.7-1.5 1.6-1.5H17a4 4 0 004-4c0-4.7-4-8.5-9-8.5z"/><circle cx="7.5" cy="11" r="1"/><circle cx="10" cy="7" r="1"/><circle cx="15" cy="7" r="1"/>',
|
|
221
221
|
};
|
|
222
222
|
|
|
@@ -226,10 +226,12 @@ function icon(name) {
|
|
|
226
226
|
return node;
|
|
227
227
|
}
|
|
228
228
|
|
|
229
|
-
// The
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
// the
|
|
229
|
+
// The sidebar button at the top of the sidebar (a panel icon, not a
|
|
230
|
+
// hamburger; folded or narrow, the logo stands in for it). On a wide screen it folds the sidebar to its icons and back,
|
|
231
|
+
// and the choice stays in this browser. On a narrow screen the sidebar is a
|
|
232
|
+
// bar across the top, and the button slides the menu in from the left as a
|
|
233
|
+
// drawer over a dimmed page; choosing a page, Escape or a click on the page
|
|
234
|
+
// closes it.
|
|
233
235
|
const NARROW = window.matchMedia('(max-width: 820px)');
|
|
234
236
|
const FOLDED_KEY = 'flow_sidebar_folded';
|
|
235
237
|
|
|
@@ -243,7 +245,7 @@ function drawMenu() {
|
|
|
243
245
|
shell.toggleAttribute('data-folded', folded);
|
|
244
246
|
const open = NARROW.matches ? shell.hasAttribute('data-menu-open') : !folded;
|
|
245
247
|
const toggle = $('menu-toggle');
|
|
246
|
-
const label = NARROW.matches ? (open ? 'Close the menu' : 'Open the menu') : (folded ? 'Show the
|
|
248
|
+
const label = NARROW.matches ? (open ? 'Close the menu' : 'Open the menu') : (folded ? 'Show the sidebar' : 'Hide the sidebar');
|
|
247
249
|
toggle.setAttribute('aria-expanded', String(open));
|
|
248
250
|
toggle.setAttribute('aria-label', label);
|
|
249
251
|
toggle.title = label;
|
|
@@ -384,7 +386,13 @@ function renderShell() {
|
|
|
384
386
|
const report = h('button', { type: 'button', class: 'fx-nav__report', 'data-nav': 'report', 'data-label': 'Report a problem', hidden: true },
|
|
385
387
|
icon('flag'), h('span', { class: 'fx-nav__label' }, 'Report a problem'));
|
|
386
388
|
nav.replaceChildren(...items, report);
|
|
387
|
-
|
|
389
|
+
// Folded or narrow, the logo is the sidebar button: it shows the mark and
|
|
390
|
+
// turns into the sidebar icon under the pointer or keyboard focus.
|
|
391
|
+
const mark = h('span', { class: 'fx-toggle__mark', 'aria-hidden': 'true' });
|
|
392
|
+
mark.innerHTML = document.querySelector('.fx-side__head .fx-brand svg')?.outerHTML ?? '';
|
|
393
|
+
const sidebar = icon('sidebar');
|
|
394
|
+
sidebar.classList.add('fx-toggle__icon');
|
|
395
|
+
$('menu-toggle').replaceChildren(mark, sidebar);
|
|
388
396
|
$('menu-toggle').onclick = toggleMenu;
|
|
389
397
|
drawMenu();
|
|
390
398
|
// The sidebar foot shows who is signed in: photo or initial, then the name
|
package/server/ui/flow.css
CHANGED
|
@@ -113,10 +113,17 @@ body:is([data-state="off"], [data-state="error"], [data-state="no-access"]) .fx-
|
|
|
113
113
|
display: flex; flex-direction: column; gap: 4px; flex: none;
|
|
114
114
|
width: 232px; height: 100vh; padding: 16px 12px 12px;
|
|
115
115
|
background: var(--t-s1); border-right: 1px solid var(--fx-line);
|
|
116
|
+
transition: width .18s ease;
|
|
116
117
|
}
|
|
117
118
|
.fx-side__head { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 0 0 14px 8px; }
|
|
118
119
|
.fx-side__head .fx-brand { min-width: 0; font-size: 19px; }
|
|
119
120
|
.fx-side__head .fx-mark { width: 26px; height: 26px; }
|
|
121
|
+
/* The sidebar button. Open, it shows the sidebar icon beside the brand.
|
|
122
|
+
Folded or narrow, the brand is hidden and the button shows the logo, which
|
|
123
|
+
turns into the sidebar icon on hover or focus (and while the drawer is
|
|
124
|
+
open), so the two never sit side by side. */
|
|
125
|
+
.fx-toggle__mark { display: none; }
|
|
126
|
+
.fx-toggle__mark .fx-mark { display: block; }
|
|
120
127
|
.fx-nav { display: flex; flex-direction: column; gap: 2px; flex: 1; min-height: 0; overflow-y: auto; }
|
|
121
128
|
.fx-nav__group { padding: 16px 10px 6px; color: var(--t-dim); font: var(--t-label-weight) var(--t-label-size)/1.3 var(--t-body); letter-spacing: var(--t-label-spacing); text-transform: var(--t-label-case); }
|
|
122
129
|
.fx-nav__group:first-child { padding-top: 4px; }
|
|
@@ -141,7 +148,7 @@ body:is([data-state="off"], [data-state="error"], [data-state="no-access"]) .fx-
|
|
|
141
148
|
@media (min-width: 821px) {
|
|
142
149
|
.fx-shell[data-folded] .fx-side { width: 64px; padding-inline: 10px; }
|
|
143
150
|
.fx-shell[data-folded] .fx-side__head { flex-direction: column; gap: 10px; padding: 0 0 14px; }
|
|
144
|
-
.fx-shell[data-folded] .fx-brand
|
|
151
|
+
.fx-shell[data-folded] .fx-side__head .fx-brand { display: none; }
|
|
145
152
|
.fx-shell[data-folded] .fx-nav__group { height: 1px; margin: 10px 6px; padding: 0; overflow: hidden; background: var(--fx-line); color: transparent; }
|
|
146
153
|
.fx-shell[data-folded] :is(.fx-nav a, .fx-nav__report) { justify-content: center; padding: 9px 0; }
|
|
147
154
|
.fx-shell[data-folded] .fx-nav__label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
|
|
@@ -659,19 +666,41 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
|
|
|
659
666
|
.fx-shell { flex-direction: column; }
|
|
660
667
|
.fx-side { flex-direction: row; align-items: center; width: auto; height: 60px; padding: 0 12px; border-right: 0; border-bottom: 1px solid var(--fx-line); }
|
|
661
668
|
.fx-side__head { flex-direction: row-reverse; gap: 6px; padding: 0; }
|
|
662
|
-
.fx-side__head .fx-brand
|
|
663
|
-
/* The menu
|
|
669
|
+
.fx-side__head .fx-brand { display: none; }
|
|
670
|
+
/* The menu is a drawer: it slides in from the left, below the bar, over
|
|
671
|
+
a dimmed page. A click on the dimmed page closes it (app.js). */
|
|
664
672
|
.fx-nav {
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
background: var(--t-s1); border-
|
|
673
|
+
position: fixed; top: 60px; bottom: 0; left: 0; z-index: 2;
|
|
674
|
+
width: min(280px, 85vw); padding: 8px 12px 12px;
|
|
675
|
+
background: var(--t-s1); border-right: 1px solid var(--fx-line); box-shadow: var(--t-shadow);
|
|
676
|
+
transform: translateX(-100%); visibility: hidden;
|
|
677
|
+
transition: transform .2s ease, visibility .2s;
|
|
678
|
+
}
|
|
679
|
+
.fx-shell[data-menu-open] .fx-nav { transform: none; visibility: visible; }
|
|
680
|
+
.fx-shell[data-menu-open]::after {
|
|
681
|
+
content: ""; position: fixed; inset: 60px 0 0; z-index: 9;
|
|
682
|
+
background: rgba(0, 0, 0, .32);
|
|
668
683
|
}
|
|
669
|
-
.fx-shell[data-menu-open] .fx-nav { display: flex; }
|
|
670
684
|
.fx-nav__report { margin-top: 8px; }
|
|
671
685
|
.fx-side__end { margin-left: auto; padding: 0; border-top: 0; }
|
|
672
686
|
#user-button { flex: none; }
|
|
673
687
|
}
|
|
674
688
|
|
|
689
|
+
/* Collapsed (folded on wide screens, or narrow): logo at rest, sidebar icon
|
|
690
|
+
on hover, focus or while open. */
|
|
691
|
+
@media (min-width: 821px) {
|
|
692
|
+
.fx-shell[data-folded] .fx-toggle__mark { display: block; }
|
|
693
|
+
.fx-shell[data-folded] .fx-toggle__icon { display: none; }
|
|
694
|
+
.fx-shell[data-folded] #menu-toggle:is(:hover, :focus-visible) .fx-toggle__mark { display: none; }
|
|
695
|
+
.fx-shell[data-folded] #menu-toggle:is(:hover, :focus-visible) .fx-toggle__icon { display: inline-flex; }
|
|
696
|
+
}
|
|
697
|
+
@media (max-width: 820px) {
|
|
698
|
+
.fx-toggle__mark { display: block; }
|
|
699
|
+
.fx-toggle__icon { display: none; }
|
|
700
|
+
#menu-toggle:is(:hover, :focus-visible, [aria-expanded="true"]) .fx-toggle__mark { display: none; }
|
|
701
|
+
#menu-toggle:is(:hover, :focus-visible, [aria-expanded="true"]) .fx-toggle__icon { display: inline-flex; }
|
|
702
|
+
}
|
|
703
|
+
|
|
675
704
|
/* ── Phones ─────────────────────────────────────────────────────────────── */
|
|
676
705
|
@media (max-width: 640px) {
|
|
677
706
|
.fx-main { padding-top: 20px; }
|
|
@@ -778,3 +807,14 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
|
|
|
778
807
|
color-scheme: light;
|
|
779
808
|
}
|
|
780
809
|
[data-mode="dark"] #sentry-feedback { --interactive-filter: brightness(150%); color-scheme: dark; }
|
|
810
|
+
|
|
811
|
+
/* The bar above one invoice: Back, its place in the list, previous and next.
|
|
812
|
+
Quiet: no frame, dim text, round icon buttons. */
|
|
813
|
+
.fx-pager { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 12px; min-height: 32px; }
|
|
814
|
+
.fx-pager__back { justify-self: start; background: none; border: 0; padding: 4px 0; color: var(--t-dim); font: inherit; font-size: 13.5px; cursor: pointer; transition: color 120ms var(--fx-ease); }
|
|
815
|
+
.fx-pager__back:hover, .fx-pager__back:focus-visible { color: var(--t-strong); }
|
|
816
|
+
.fx-pager__count { grid-column: 2; color: var(--t-dim); font-size: 13px; font-variant-numeric: tabular-nums; }
|
|
817
|
+
.fx-pager__steps { grid-column: 3; justify-self: end; display: flex; gap: 4px; }
|
|
818
|
+
.fx-pager__step { display: inline-grid; place-items: center; width: 30px; height: 30px; padding: 0; border: 1px solid var(--fx-line-soft); border-radius: 999px; background: transparent; color: var(--t-text); cursor: pointer; transition: background 120ms var(--fx-ease), border-color 120ms var(--fx-ease); }
|
|
819
|
+
.fx-pager__step:hover:not(:disabled) { background: var(--fx-hover); border-color: var(--fx-line); }
|
|
820
|
+
.fx-pager__step:disabled { opacity: 0.35; cursor: default; }
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// The notes on the Invoices page and on one invoice (invoicing.js). One fact
|
|
2
|
+
// per sentence; they follow the accounting integration's settings. Kept apart
|
|
3
|
+
// so tests can load them without a browser.
|
|
4
|
+
|
|
5
|
+
// What happens to a ready invoice in the accounting system, one fact per
|
|
6
|
+
// sentence. It follows the integration's settings: its connection, whether it
|
|
7
|
+
// takes writes, and the send mode on its page. `what` names the invoices:
|
|
8
|
+
// "each invoice" on the list, "this invoice" on one invoice.
|
|
9
|
+
export function sendModeNote(to, sendMode, what = 'each invoice') {
|
|
10
|
+
if (!to) return 'No accounting system is on. Flow keeps each invoice here only.';
|
|
11
|
+
if (sendMode && !sendMode.connected) return `${to} is not connected. Connect it in Integrations before you send.`;
|
|
12
|
+
if (sendMode && !sendMode.writesAllowed) return `${to} does not take invoices from Flow yet. Turn this on in Integrations, ${to}.`;
|
|
13
|
+
const text = sendMode?.mode === 'approve_and_email'
|
|
14
|
+
? [`Flow sends ${what} to ${to} as approved.`, `${to} emails it to the customer at once.`]
|
|
15
|
+
: [`Flow sends ${what} to ${to} as a draft.`, `You approve and send it in ${to}.`];
|
|
16
|
+
const others = sendMode?.otherCustomers ?? 0;
|
|
17
|
+
if (others) text.push(`${others} ${others === 1 ? 'customer has' : 'customers have'} a different setting.`);
|
|
18
|
+
return text.join(' ');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// The line under a draft's title, one fact per sentence.
|
|
22
|
+
export function draftNote(to, invoice, ready) {
|
|
23
|
+
if (!to) return ready ? 'A person checked it. A saved change keeps it ready.' : 'Flow gives it a number when you issue it.';
|
|
24
|
+
if (!ready) return `Mark it ready, then send it to ${to}.`;
|
|
25
|
+
return sendModeNote(to, invoice?.sendMode, 'this invoice');
|
|
26
|
+
}
|
|
@@ -66,7 +66,9 @@ export async function renderInvoicingSettings(main, ui) {
|
|
|
66
66
|
host.replaceChildren(...[
|
|
67
67
|
disabled ? notice('Only an owner or admin can change these.', 'info') : null,
|
|
68
68
|
section('Drafts',
|
|
69
|
-
row('Payment terms', 'Days from invoice to due date.', days('paymentTermsDays', 'Payment terms in days'))
|
|
69
|
+
row('Payment terms', 'Days from invoice to due date.', days('paymentTermsDays', 'Payment terms in days')),
|
|
70
|
+
row('Reference', 'The Reference on each invoice. Use {practitioner} and {patient}.',
|
|
71
|
+
box('referenceTemplate', 'Reference rule', { placeholder: '{practitioner} - {patient}' }))),
|
|
70
72
|
lineMappingSection(ui),
|
|
71
73
|
automationSection(ui, 'invoicing', 'Automations'),
|
|
72
74
|
rulesSection(ui),
|