@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,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
|
+
}
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
// GitHub Releases (with npm's dist-tags as the fallback). Used by the Replit
|
|
3
3
|
// deploy (scripts/deploy-release.js, FLOW_DEPLOY_REF=stable|beta).
|
|
4
4
|
//
|
|
5
|
-
// Release names follow docs/RELEASING.md: vYYYY.M.
|
|
6
|
-
// vYYYY.M.
|
|
5
|
+
// Release names follow docs/RELEASING.md: vYYYY.M.N is stable, and
|
|
6
|
+
// vYYYY.M.N-beta.B is a beta of the upcoming YYYY.M.N.
|
|
7
7
|
//
|
|
8
8
|
// stable GitHub's latest release (what Flow Release Promote points at, so
|
|
9
9
|
// a rollback is followed), else npm `latest`
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// Nothing here sends anything about the installation: each request is an
|
|
14
14
|
// anonymous GET of public data.
|
|
15
15
|
|
|
16
|
-
const TAG = /^v(20\d{2})\.([1-9]|1[0-2])\.([1-9]
|
|
16
|
+
const TAG = /^v(20\d{2})\.([1-9]|1[0-2])\.([1-9]\d*)(?:-beta\.([1-9]\d*))?$/;
|
|
17
17
|
export const CHANNELS = ["stable", "beta"];
|
|
18
18
|
export const DEFAULT_GITHUB_API = "https://api.github.com";
|
|
19
19
|
export const DEFAULT_GITHUB_DOWNLOADS = "https://github.com";
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// The quiet alert strip (issue 117, docs/ALERTS.md): one slim line under a
|
|
2
|
+
// page's header when something on that page needs this person. It is hidden
|
|
3
|
+
// when nothing does. It shows the worst alert first, with "+N more" when there
|
|
4
|
+
// are others; × hides that alert for this person until something new joins
|
|
5
|
+
// it, and the next one shows. Data comes from /api/alerts
|
|
6
|
+
// (server/api/alerts.js). The caller passes in app.js's shared pieces as `ui`.
|
|
7
|
+
|
|
8
|
+
const money = new Intl.NumberFormat('en-AU', { style: 'currency', currency: 'AUD' });
|
|
9
|
+
const CLOSE = '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>';
|
|
10
|
+
|
|
11
|
+
// `onTab(tab)` opens a tab on the page, for an alert whose action names one.
|
|
12
|
+
// `scope` narrows the page to one part (an integration's own page), and
|
|
13
|
+
// `onLoad({ alerts, danger })` hears each answer (`danger`: a danger alert is
|
|
14
|
+
// open, even one this person hid), so the page can show the same state
|
|
15
|
+
// elsewhere. An action that links to the page already open is left out.
|
|
16
|
+
// Answers { element, refresh }: put `element` on the page, and call
|
|
17
|
+
// `refresh()` after a change that can fix or make an alert.
|
|
18
|
+
export function alertStrip(ui, page, { onTab = () => {}, scope = null, onLoad = () => {} } = {}) {
|
|
19
|
+
const { h, flow, go, toast } = ui;
|
|
20
|
+
const element = h('div', { class: 'fx-alerts', hidden: true, role: 'status', 'aria-live': 'polite' });
|
|
21
|
+
let alerts = [];
|
|
22
|
+
|
|
23
|
+
function draw() {
|
|
24
|
+
const [first] = alerts;
|
|
25
|
+
element.hidden = !first;
|
|
26
|
+
if (!first) {
|
|
27
|
+
element.replaceChildren();
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
element.dataset.tone = first.tone;
|
|
31
|
+
const close = h('button', { class: 'fx-alerts__close', type: 'button', 'aria-label': 'Dismiss', title: 'Dismiss', onclick: () => dismiss(first.key) });
|
|
32
|
+
close.innerHTML = CLOSE;
|
|
33
|
+
let action = null;
|
|
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
|
+
else if (first.action?.tab) action = h('button', { class: 'fx-alerts__action', type: 'button', onclick: () => onTab(first.action.tab) }, first.action.label);
|
|
36
|
+
element.replaceChildren(...[
|
|
37
|
+
h('p', { class: 'fx-alerts__text' },
|
|
38
|
+
first.title,
|
|
39
|
+
first.moneyAtRiskCents > 0 ? h('span', { class: 'fx-alerts__money' }, ` ${money.format(first.moneyAtRiskCents / 100)} at risk.`) : null),
|
|
40
|
+
action,
|
|
41
|
+
alerts.length > 1 ? h('span', { class: 'fx-alerts__more' }, `+${alerts.length - 1} more`) : null,
|
|
42
|
+
close,
|
|
43
|
+
].filter(Boolean));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function dismiss(key) {
|
|
47
|
+
alerts = alerts.filter((a) => a.key !== key);
|
|
48
|
+
draw();
|
|
49
|
+
try {
|
|
50
|
+
await flow('/api/alerts/dismiss', { method: 'POST', body: JSON.stringify({ key }) });
|
|
51
|
+
} catch (error) {
|
|
52
|
+
toast(error.message, 'danger');
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// An alert that cannot load shows nothing: the page works without it.
|
|
57
|
+
async function refresh() {
|
|
58
|
+
let danger = false;
|
|
59
|
+
try {
|
|
60
|
+
const query = new URLSearchParams({ page, ...(scope ? { scope } : {}) });
|
|
61
|
+
({ alerts, danger = false } = await flow(`/api/alerts?${query}`));
|
|
62
|
+
} catch {
|
|
63
|
+
alerts = [];
|
|
64
|
+
}
|
|
65
|
+
onLoad({ alerts, danger });
|
|
66
|
+
draw();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Coming back to the tab checks again, while this page is open.
|
|
70
|
+
const onVisible = () => {
|
|
71
|
+
if (!element.isConnected) document.removeEventListener('visibilitychange', onVisible);
|
|
72
|
+
else if (document.visibilityState === 'visible') refresh();
|
|
73
|
+
};
|
|
74
|
+
document.addEventListener('visibilitychange', onVisible);
|
|
75
|
+
refresh();
|
|
76
|
+
return { element, refresh };
|
|
77
|
+
}
|
package/server/ui/app-page.js
CHANGED
|
@@ -51,7 +51,7 @@ const BODY = `<div class="fx-app">
|
|
|
51
51
|
<div id="toasts" class="fx-toasts" aria-live="polite"></div>
|
|
52
52
|
</div>`;
|
|
53
53
|
|
|
54
|
-
export const APP_PATHS = ['/sign-in', '/jobs', '/invoices', '/invoices/:id', '/people', '/integrations', '/integrations/:id', '/account', '/appearance', '/invoicing-settings'];
|
|
54
|
+
export const APP_PATHS = ['/sign-in', '/jobs', '/invoices', '/invoices/:id', '/people', '/integrations', '/integrations/:id', '/account', '/appearance', '/invoicing-settings', '/price-list'];
|
|
55
55
|
|
|
56
56
|
export function appPage({ defaultFamily }) {
|
|
57
57
|
return renderPage({
|
package/server/ui/app.js
CHANGED
|
@@ -12,9 +12,11 @@ import { ACCENT_PRESETS, normalizeAccent, THEME_FAMILIES, tokensFor } from '/ass
|
|
|
12
12
|
import { renderInvoice, renderInvoiceList } from '/assets/ui/invoicing.js';
|
|
13
13
|
import { automationSection } from '/assets/ui/automations.js';
|
|
14
14
|
import { renderInvoicingSettings, sendModeSection } from '/assets/ui/invoicing-settings.js';
|
|
15
|
+
import { renderPriceList } from '/assets/ui/price-list.js';
|
|
15
16
|
import { renderJobs } from '/assets/ui/jobs.js';
|
|
16
17
|
import { startMonitoring } from '/assets/ui/monitoring.js';
|
|
17
18
|
import { openSearch } from '/assets/ui/search.js';
|
|
19
|
+
import { alertStrip } from '/assets/ui/alerts.js';
|
|
18
20
|
|
|
19
21
|
const $ = (id) => document.getElementById(id);
|
|
20
22
|
|
|
@@ -194,10 +196,13 @@ let me = null;
|
|
|
194
196
|
const VIEWS = {
|
|
195
197
|
jobs: { label: 'Jobs', icon: 'jobs', group: 'Work', render: (main) => renderJobs(main, ui()) },
|
|
196
198
|
invoices: { label: 'Invoices', icon: 'invoices', group: 'Work', render: (main, id) => (id ? renderInvoice(main, ui(), id) : renderInvoiceList(main, ui())) },
|
|
197
|
-
'invoicing-settings': { label: '
|
|
199
|
+
'invoicing-settings': { label: 'Invoice rules', icon: 'invoices', group: 'Settings', render: (main) => renderInvoicingSettings(main, ui()) },
|
|
200
|
+
'price-list': { label: 'Price list', icon: 'tag', group: 'Settings', render: (main) => renderPriceList(main, ui()) },
|
|
198
201
|
people: { label: 'People', icon: 'people', group: 'Settings', ownerOnly: true, render: renderPeople },
|
|
199
202
|
integrations: { label: 'Integrations', icon: 'plug', group: 'Settings', ownerOnly: true, render: (main, id) => (id ? renderIntegration(main, id) : renderIntegrations(main)) },
|
|
200
|
-
|
|
203
|
+
// Not in the sidebar: "Manage account" in the account menu opens the same
|
|
204
|
+
// Clerk profile. The page stays for links and Search.
|
|
205
|
+
account: { label: 'Account', icon: 'person', group: 'Settings', sidebar: false, render: renderAccount },
|
|
201
206
|
appearance: { label: 'Appearance', icon: 'palette', group: 'Settings', render: renderAppearance },
|
|
202
207
|
};
|
|
203
208
|
|
|
@@ -206,11 +211,12 @@ const ICON_PATHS = {
|
|
|
206
211
|
jobs: '<rect x="3" y="4" width="5" height="16" rx="1"/><rect x="10" y="4" width="5" height="11" rx="1"/><rect x="17" y="4" width="4" height="14" rx="1"/>',
|
|
207
212
|
invoices: '<path d="M6 3h9l4 4v14H6z"/><path d="M14 3v5h5M9 13h7M9 17h5"/>',
|
|
208
213
|
people: '<circle cx="9" cy="8" r="3"/><path d="M3 20c0-3.5 2.7-6 6-6s6 2.5 6 6"/><path d="M16 5a3 3 0 010 6M21 20c0-3-1.6-5-4-5.7"/>',
|
|
214
|
+
tag: '<path d="M3 12V4h8l10 10-8 8z"/><circle cx="7.5" cy="8.5" r="1.5"/>',
|
|
209
215
|
plug: '<path d="M9 3v5M15 3v5M6 8h12v3a6 6 0 01-12 0zM12 17v4"/>',
|
|
210
216
|
person: '<circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 3.6-7 8-7s8 3 8 7"/>',
|
|
211
217
|
flag: '<path d="M5 21V4M5 4h11l-2 4 2 4H5"/>',
|
|
212
218
|
search: '<circle cx="11" cy="11" r="6.5"/><path d="M16 16l4.5 4.5"/>',
|
|
213
|
-
|
|
219
|
+
sidebar: '<rect x="3" y="4" width="18" height="16" rx="2.5"/><path d="M9.5 4v16"/>',
|
|
214
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"/>',
|
|
215
221
|
};
|
|
216
222
|
|
|
@@ -220,10 +226,12 @@ function icon(name) {
|
|
|
220
226
|
return node;
|
|
221
227
|
}
|
|
222
228
|
|
|
223
|
-
// The
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
// 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.
|
|
227
235
|
const NARROW = window.matchMedia('(max-width: 820px)');
|
|
228
236
|
const FOLDED_KEY = 'flow_sidebar_folded';
|
|
229
237
|
|
|
@@ -237,7 +245,7 @@ function drawMenu() {
|
|
|
237
245
|
shell.toggleAttribute('data-folded', folded);
|
|
238
246
|
const open = NARROW.matches ? shell.hasAttribute('data-menu-open') : !folded;
|
|
239
247
|
const toggle = $('menu-toggle');
|
|
240
|
-
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');
|
|
241
249
|
toggle.setAttribute('aria-expanded', String(open));
|
|
242
250
|
toggle.setAttribute('aria-label', label);
|
|
243
251
|
toggle.title = label;
|
|
@@ -368,6 +376,7 @@ function renderShell() {
|
|
|
368
376
|
let group = null;
|
|
369
377
|
for (const [name, view] of Object.entries(VIEWS)) {
|
|
370
378
|
if (view.ownerOnly && me?.person.role !== 'owner') continue;
|
|
379
|
+
if (view.sidebar === false) continue;
|
|
371
380
|
if (view.group && view.group !== group) items.push(h('span', { class: 'fx-nav__group' }, view.group));
|
|
372
381
|
group = view.group;
|
|
373
382
|
items.push(h('a', { href: `/${name}`, 'data-nav': name, 'data-label': view.label, onclick: navigate }, icon(view.icon), h('span', { class: 'fx-nav__label' }, view.label)));
|
|
@@ -377,7 +386,13 @@ function renderShell() {
|
|
|
377
386
|
const report = h('button', { type: 'button', class: 'fx-nav__report', 'data-nav': 'report', 'data-label': 'Report a problem', hidden: true },
|
|
378
387
|
icon('flag'), h('span', { class: 'fx-nav__label' }, 'Report a problem'));
|
|
379
388
|
nav.replaceChildren(...items, report);
|
|
380
|
-
|
|
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);
|
|
381
396
|
$('menu-toggle').onclick = toggleMenu;
|
|
382
397
|
drawMenu();
|
|
383
398
|
// The sidebar foot shows who is signed in: photo or initial, then the name
|
|
@@ -539,6 +554,7 @@ async function renderIntegrations(main) {
|
|
|
539
554
|
const content = h('div', { class: 'fx-int-sections' }, h('div', { class: 'fx-skeleton' }));
|
|
540
555
|
main.append(
|
|
541
556
|
h('header', { class: 'fx-ph' }, h('div', { class: 'fx-ph__text' }, h('h1', { class: 'fx-title' }, 'Integrations'))),
|
|
557
|
+
alertStrip(ui(), 'integrations').element,
|
|
542
558
|
h('div', { class: 'fx-int-bar' }, tabs, search),
|
|
543
559
|
notices, content);
|
|
544
560
|
let body;
|
|
@@ -623,8 +639,24 @@ async function renderIntegration(main, id) {
|
|
|
623
639
|
return;
|
|
624
640
|
}
|
|
625
641
|
const canChange = Boolean(me.ownerActions?.allowed);
|
|
642
|
+
// The quiet alert line for this integration only (server/ui/alerts.js).
|
|
643
|
+
// While a danger alert is open, even one this person hid, the status says
|
|
644
|
+
// calls are stopped instead of the integration's state.
|
|
645
|
+
let stopped = false;
|
|
646
|
+
const statusPill = (current) => (stopped && current.state === 'ready'
|
|
647
|
+
? h('span', { class: 'fx-status', 'data-tone': 'danger', 'data-int-status': '' }, 'Calls stopped')
|
|
648
|
+
: h('span', { class: 'fx-status', 'data-tone': STATE_TONE[current.state] ?? 'mute', 'data-int-status': '' }, STATE_LABEL[current.state] ?? current.state));
|
|
649
|
+
let shown = found;
|
|
650
|
+
const alerts = alertStrip(ui(), 'integrations', {
|
|
651
|
+
scope: id,
|
|
652
|
+
onLoad: ({ danger }) => {
|
|
653
|
+
stopped = danger;
|
|
654
|
+
for (const pill of host.querySelectorAll('[data-int-status]')) pill.replaceWith(statusPill(shown));
|
|
655
|
+
},
|
|
656
|
+
});
|
|
626
657
|
|
|
627
658
|
const draw = (current, progress = null) => {
|
|
659
|
+
shown = current;
|
|
628
660
|
const inputs = new Map();
|
|
629
661
|
const rows = [];
|
|
630
662
|
for (const field of current.fields) {
|
|
@@ -732,10 +764,11 @@ async function renderIntegration(main, id) {
|
|
|
732
764
|
h('p', { class: 'fx-int-hero__blurb' }, entry?.blurb ?? ''),
|
|
733
765
|
h('div', { class: 'fx-int-hero__actions' },
|
|
734
766
|
primary,
|
|
735
|
-
on ?
|
|
767
|
+
on ? statusPill(current) : null,
|
|
736
768
|
progress ? connectProgress(progress) : null))),
|
|
737
769
|
h('div', { class: 'fx-int-page__grid' },
|
|
738
770
|
h('div', { class: 'fx-int-page__main' }, ...[
|
|
771
|
+
alerts.element,
|
|
739
772
|
!canChange ? notice('Changing integrations needs a verified owner sign-in: Google Workspace on the owner domain with no password, or a second factor.', 'warn', 'Read only.') : null,
|
|
740
773
|
!body.secretsKey ? notice('Flow has no secrets key, so it cannot store a client secret. Ask whoever hosts Flow to set FLOW_SECRETS_KEY (64 hex characters), then restart Flow.', 'warn', 'No secrets key.') : null,
|
|
741
774
|
current.state === 'failed' && current.error ? notice(current.error, 'danger', 'It could not start.') : null,
|
|
@@ -753,7 +786,7 @@ async function renderIntegration(main, id) {
|
|
|
753
786
|
].filter(Boolean)),
|
|
754
787
|
h('aside', { class: 'fx-int-page__meta' },
|
|
755
788
|
h('dl', { class: 'fx-meta' }, ...[
|
|
756
|
-
['Status',
|
|
789
|
+
['Status', statusPill(current)],
|
|
757
790
|
['Category', h('span', { class: 'fx-chip fx-chip--static' }, CATEGORY_LABEL[entry?.category] ?? 'Integration')],
|
|
758
791
|
modeLabel ? ['Mode', modeLabel] : null,
|
|
759
792
|
['Turned on by', current.enabledBy === 'host' ? 'The host' : current.enabledBy === 'flow' ? 'An owner, here' : '—'],
|
|
@@ -768,11 +801,11 @@ async function renderIntegration(main, id) {
|
|
|
768
801
|
// One setting, laid out the same on every settings screen: a short name, one
|
|
769
802
|
// line of smaller text under it, and the control on the right. Longer help
|
|
770
803
|
// lives behind a link, not here. A wide control (a grid of choices) sits on
|
|
771
|
-
// its own line under the name.
|
|
772
|
-
function settingRow({ label, hint, control, wide = false }) {
|
|
804
|
+
// its own line under the name. `tone: 'danger'` marks a row that needs a person.
|
|
805
|
+
function settingRow({ label, hint, control, wide = false, tone = null }) {
|
|
773
806
|
const target = control?.matches?.('input, select, textarea') ? control : control?.querySelector?.('input:not([type=color]), select, textarea');
|
|
774
807
|
const id = target ? (target.id ||= `fx-set-${++settingRow.next}`) : null;
|
|
775
|
-
return h('div', { class: `fx-setting${wide ? ' fx-setting--wide' : ''}
|
|
808
|
+
return h('div', { class: `fx-setting${wide ? ' fx-setting--wide' : ''}`, 'data-tone': tone },
|
|
776
809
|
h('div', { class: 'fx-setting__text' },
|
|
777
810
|
id ? h('label', { class: 'fx-setting__name', for: id }, label) : h('span', { class: 'fx-setting__name' }, label),
|
|
778
811
|
hint ? h('p', { class: 'fx-setting__hint' }, hint) : null),
|
|
@@ -800,16 +833,25 @@ function xeroConnection(organisations, canChange) {
|
|
|
800
833
|
(async () => {
|
|
801
834
|
let hint;
|
|
802
835
|
let control = null;
|
|
836
|
+
let stopped = false;
|
|
803
837
|
try {
|
|
804
838
|
const status = await flow(`/api/xero/status?organisationId=${organisation.id}`);
|
|
805
|
-
if (status.connected) {
|
|
839
|
+
if (status.connected && status.calls?.stopped) {
|
|
840
|
+
// A refused connection stopped calls (the call guard): say so, and
|
|
841
|
+
// make Connect again the main button, which starts them again.
|
|
842
|
+
const at = status.calls.since ? ` at ${new Date(status.calls.since).toLocaleString('en-AU', { dateStyle: 'medium', timeStyle: 'short' })}` : '';
|
|
843
|
+
hint = status.calls.all
|
|
844
|
+
? `Xero refused Flow${at}. Flow stopped all calls.`
|
|
845
|
+
: `Xero refused Flow for ${status.calls.resources.join(', ')}${at}. Flow stopped those calls.`;
|
|
846
|
+
stopped = true;
|
|
847
|
+
} else if (status.connected) {
|
|
806
848
|
const mirror = await flow(`/api/xero/mirror?organisationId=${organisation.id}`).catch(() => null);
|
|
807
849
|
hint = `Connected to ${status.connection?.tenantName ?? 'Xero'}.${mirror ? ` ${mirror.counts.invoices} invoices copied.` : ''}`;
|
|
808
850
|
} else {
|
|
809
851
|
hint = 'Not connected.';
|
|
810
852
|
}
|
|
811
853
|
if (canChange) {
|
|
812
|
-
control = h('button', { class: `fx-btn fx-btn--sm${status.connected ? '' : ' fx-btn--primary'}`, type: 'button', onclick: async () => {
|
|
854
|
+
control = h('button', { class: `fx-btn fx-btn--sm${status.connected && !stopped ? '' : ' fx-btn--primary'}`, type: 'button', onclick: async () => {
|
|
813
855
|
try {
|
|
814
856
|
const { authorizeUrl } = await flow('/api/xero/connect', { method: 'POST', body: JSON.stringify({ organisationId: organisation.id }) });
|
|
815
857
|
location.assign(authorizeUrl);
|
|
@@ -821,7 +863,7 @@ function xeroConnection(organisations, canChange) {
|
|
|
821
863
|
} catch (error) {
|
|
822
864
|
hint = error.message;
|
|
823
865
|
}
|
|
824
|
-
row.replaceWith(settingRow({ label: organisation.name, hint, control }));
|
|
866
|
+
row.replaceWith(settingRow({ label: organisation.name, hint, control, tone: stopped ? 'danger' : null }));
|
|
825
867
|
})();
|
|
826
868
|
}
|
|
827
869
|
return h('div', {}, h('h2', { class: 'fx-int-section__title' }, 'Xero organisation'), host);
|
|
@@ -854,9 +896,9 @@ async function invitePerson() {
|
|
|
854
896
|
// security), with the person's Flow role above it.
|
|
855
897
|
function renderAccount(main) {
|
|
856
898
|
const host = h('div', { class: 'fx-clerk-profile' });
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
899
|
+
// Clerk's card has its own "Account" heading; Flow's page heading is for
|
|
900
|
+
// screen readers only, so the title shows once.
|
|
901
|
+
main.append(h('h1', { class: 'fx-sr' }, 'Account'), host);
|
|
860
902
|
// Clerk keeps its own pages (profile, security) in memory, so the URL
|
|
861
903
|
// stays Flow's.
|
|
862
904
|
mountClerk('profile', host, { routing: 'virtual' });
|
|
@@ -1011,7 +1053,7 @@ drawModeToggles();
|
|
|
1011
1053
|
// the Clerk instance is called.
|
|
1012
1054
|
const LOCALIZATION = {
|
|
1013
1055
|
signIn: {
|
|
1014
|
-
start: { title: 'Sign in to Flow', subtitle: '
|
|
1056
|
+
start: { title: 'Sign in to Flow', subtitle: 'Choose how to sign in.' },
|
|
1015
1057
|
password: { subtitle: 'Enter the password for your account.' },
|
|
1016
1058
|
},
|
|
1017
1059
|
signUp: { start: { title: 'Ask for access to Flow', subtitle: 'Use your work email. An owner approves new people.' } },
|