@arcforgelabs/flow 2026.10.26 → 2026.10.29
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 +6 -0
- package/npm-shrinkwrap.json +627 -2
- package/package.json +3 -2
- package/server/api/invoicing.js +313 -46
- package/server/app.js +30 -2
- package/server/core/automations.js +154 -0
- package/server/core/invoice-rules.js +226 -0
- package/server/core/invoices.js +8 -3
- package/server/core/invoicing-rules.js +255 -0
- package/server/core/job-board.js +130 -0
- package/server/core/ledger.js +57 -17
- package/server/core/line-mapping.js +337 -0
- package/server/core/monitoring.js +34 -0
- package/server/core/pipeline.js +16 -3
- package/server/core/sending.js +283 -0
- package/server/db/migrate-all.js +26 -0
- package/server/db/migrations/0010_invoicing_rules.sql +72 -0
- package/server/db/migrations/0011_invoice_rules.sql +27 -0
- package/server/db/migrations/0012_invoice_sending.sql +18 -0
- package/server/db/migrations/0013_line_mappings.sql +35 -0
- package/server/db/migrations/meta/0010_snapshot.json +3158 -0
- package/server/db/migrations/meta/0011_snapshot.json +3314 -0
- package/server/db/migrations/meta/0012_snapshot.json +3380 -0
- package/server/db/migrations/meta/0013_snapshot.json +3568 -0
- package/server/db/migrations/meta/_journal.json +28 -0
- package/server/db/schema.js +158 -0
- package/server/import/at-start.js +64 -2
- package/server/index.js +17 -5
- package/server/integrations/adapters.js +2 -1
- package/server/integrations/port.js +6 -0
- package/server/integrations/seazona/feed.js +62 -4
- package/server/integrations/seazona/index.js +7 -1
- package/server/integrations/seazona/lanes.js +4 -1
- package/server/integrations/seazona/ledger.js +2 -0
- package/server/integrations/seazona/migrations/0000_api_governor.sql +3 -3
- package/server/integrations/seazona/migrations/0001_mirror.sql +9 -9
- package/server/integrations/sentry/index.js +105 -0
- package/server/integrations/xero/client.js +5 -0
- package/server/integrations/xero/drafts.js +127 -17
- package/server/integrations/xero/index.js +11 -1
- package/server/integrations/xero/lookup.js +6 -2
- package/server/integrations/xero/migrations/0000_xero_connections.sql +1 -1
- package/server/integrations/xero/migrations/0001_xero_catalogue.sql +5 -5
- package/server/integrations/xero/migrations/0002_xero_invoice_pushes.sql +2 -2
- package/server/integrations/xero/migrations/0003_xero_payment_cursors.sql +1 -1
- package/server/integrations/xero/migrations/0004_xero_mirror.sql +7 -7
- package/server/integrations/xero/migrations/0005_xero_send_modes.sql +16 -0
- package/server/integrations/xero/migrations/meta/_journal.json +7 -0
- package/server/ui/app-page.js +7 -4
- package/server/ui/app.js +122 -6
- package/server/ui/automations.js +96 -0
- package/server/ui/clerk-appearance.js +21 -0
- package/server/ui/flow.css +243 -30
- package/server/ui/invoice-rules.js +237 -0
- package/server/ui/invoicing-settings.js +110 -0
- package/server/ui/invoicing.js +166 -20
- package/server/ui/job-board.js +155 -0
- package/server/ui/jobs.js +232 -84
- package/server/ui/line-mappings.js +60 -0
- package/server/ui/logos/sentry.svg +1 -0
- package/server/ui/monitoring.js +61 -0
- package/server/ui/routes.js +2 -2
- package/server/ui/search.js +145 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import * as SentryNode from '@sentry/node';
|
|
2
|
+
|
|
3
|
+
// The Sentry adapter (docs/SENTRY.md). It sends the errors Flow logs, and the
|
|
4
|
+
// browser's errors and problem reports, to the installation's own Sentry
|
|
5
|
+
// project. Flow ships no DSN: each installation enters its own, here or as
|
|
6
|
+
// the host setting SENTRY_DSN. It has no tables.
|
|
7
|
+
|
|
8
|
+
// The browser SDK, loaded from Sentry's CDN only while this adapter is on
|
|
9
|
+
// (server/ui/monitoring.js). Pinned, with its integrity hash.
|
|
10
|
+
export const SENTRY_BROWSER = Object.freeze({
|
|
11
|
+
src: 'https://browser.sentry-cdn.com/11.4.0/bundle.feedback.min.js',
|
|
12
|
+
integrity: 'sha384-H9RmZ/VaqOPZrUezgCSqHp4t3LoQMd/5+0Y4oEGUB2bddZzSnwZiOYoWneK4diWt',
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
// A DSN is https://<public key>@<host>/<project id>.
|
|
16
|
+
const DSN_PATTERN = /^https:\/\/[0-9a-f]{16,64}@[a-z0-9.-]+(:\d+)?\/(\d+)$/i;
|
|
17
|
+
|
|
18
|
+
export const SENTRY_SETUP = {
|
|
19
|
+
guide: {
|
|
20
|
+
title: 'Connect your own Sentry project',
|
|
21
|
+
link: 'https://sentry.io/signup/',
|
|
22
|
+
steps: [
|
|
23
|
+
'Sign in to your organisation\'s own Sentry account, or create one.',
|
|
24
|
+
'Create a project. Choose Express as the platform.',
|
|
25
|
+
'Open the project\'s Settings, Client Keys (DSN), and copy the DSN.',
|
|
26
|
+
'Paste it below, or set SENTRY_DSN where Flow is hosted. Then turn Sentry on.',
|
|
27
|
+
'Optional: in Sentry, Settings, Integrations, add your issue tracker to turn a report into a task.',
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
fields: [
|
|
31
|
+
{ name: 'SENTRY_DSN', label: 'DSN', kind: 'url', help: 'From the Sentry project\'s Client Keys. Browsers see it, so it is not a secret.' },
|
|
32
|
+
{ name: 'SENTRY_ENVIRONMENT', label: 'Environment', help: 'How Sentry labels this installation, for example production. Empty: production.' },
|
|
33
|
+
{ 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.' },
|
|
34
|
+
],
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export function readSentrySettings(env) {
|
|
38
|
+
const dsn = String(env.SENTRY_DSN ?? '').trim();
|
|
39
|
+
if (!dsn) throw new Error('SENTRY_DSN is not set. Enter the Sentry project\'s DSN in Settings, Integrations.');
|
|
40
|
+
if (!DSN_PATTERN.test(dsn)) throw new Error('SENTRY_DSN is not a Sentry DSN (https://<key>@<host>/<project id>).');
|
|
41
|
+
const environment = String(env.SENTRY_ENVIRONMENT ?? '').trim() || 'production';
|
|
42
|
+
const feedback = String(env.SENTRY_FEEDBACK ?? 'on').trim() !== 'off';
|
|
43
|
+
return { dsn, environment, feedback };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// The release Sentry groups events by: Flow's release tag, else its commit.
|
|
47
|
+
export function sentryRelease(env) {
|
|
48
|
+
const read = (value) => String(value ?? '').trim() || null;
|
|
49
|
+
const release = read(env.FLOW_DEPLOYED_RELEASE) ?? read(env.FLOW_RELEASE);
|
|
50
|
+
const revision = read(env.FLOW_REVISION);
|
|
51
|
+
return release ? `flow@${release.replace(/^v/, '')}` : revision ? `flow@${revision.slice(0, 12)}` : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// `sdk` is for tests: anything with init, captureMessage, captureException
|
|
55
|
+
// and close, as @sentry/node has.
|
|
56
|
+
export function createSentryAdapter({ sdk = SentryNode, env: hostEnv = process.env } = {}) {
|
|
57
|
+
let settings = null;
|
|
58
|
+
let release = null;
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
id: 'sentry',
|
|
62
|
+
name: 'Sentry',
|
|
63
|
+
bundled: false,
|
|
64
|
+
setup: SENTRY_SETUP,
|
|
65
|
+
async start({ log, env }) {
|
|
66
|
+
settings = readSentrySettings(env ?? hostEnv);
|
|
67
|
+
release = sentryRelease(hostEnv);
|
|
68
|
+
sdk.init({
|
|
69
|
+
dsn: settings.dsn,
|
|
70
|
+
environment: settings.environment,
|
|
71
|
+
release: release ?? undefined,
|
|
72
|
+
// Flow sends what it logs; no request bodies, cookies or IP addresses.
|
|
73
|
+
sendDefaultPii: false,
|
|
74
|
+
tracesSampleRate: 0,
|
|
75
|
+
skipOpenTelemetrySetup: true,
|
|
76
|
+
});
|
|
77
|
+
log.info?.(`[sentry] on: environment ${settings.environment}; report button ${settings.feedback ? 'on' : 'off'}`);
|
|
78
|
+
},
|
|
79
|
+
async stop() {
|
|
80
|
+
if (settings) await sdk.close(2000);
|
|
81
|
+
settings = null;
|
|
82
|
+
},
|
|
83
|
+
capabilities: {
|
|
84
|
+
async health() {
|
|
85
|
+
return { ok: Boolean(settings), environment: settings?.environment ?? null, feedback: settings?.feedback ?? false };
|
|
86
|
+
},
|
|
87
|
+
// One error Flow logged: `source` is its "[tag]", `error` the Error when
|
|
88
|
+
// there is one.
|
|
89
|
+
captureError({ message, source = 'server', error = null }) {
|
|
90
|
+
if (!settings) return;
|
|
91
|
+
const scope = { level: 'error', tags: { source } };
|
|
92
|
+
if (error instanceof Error) sdk.captureException(error, { ...scope, extra: { log: message } });
|
|
93
|
+
else sdk.captureMessage(message, scope);
|
|
94
|
+
},
|
|
95
|
+
// What the browser needs to report to the same project. The DSN is
|
|
96
|
+
// meant for browsers.
|
|
97
|
+
browserMonitoring() {
|
|
98
|
+
if (!settings) return null;
|
|
99
|
+
return { dsn: settings.dsn, environment: settings.environment, release, feedback: settings.feedback, script: SENTRY_BROWSER };
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export const sentryAdapter = createSentryAdapter();
|
|
@@ -243,6 +243,11 @@ export function createXeroClient({
|
|
|
243
243
|
});
|
|
244
244
|
return payload[path] ?? [];
|
|
245
245
|
},
|
|
246
|
+
// Xero emails one approved invoice to its contact, with the organisation's
|
|
247
|
+
// own template, and marks it sent. Not idempotent: a repeat sends again.
|
|
248
|
+
async emailInvoice(invoiceId) {
|
|
249
|
+
await request('POST', `Invoices/${encodeURIComponent(invoiceId)}/Email`, { body: {} });
|
|
250
|
+
},
|
|
246
251
|
// One invoice. `idempotencyKey` makes a retry within Xero's window
|
|
247
252
|
// return the first result instead of a second invoice.
|
|
248
253
|
async createInvoice(invoice, { idempotencyKey }) {
|
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { and, eq, inArray, sql } from 'drizzle-orm';
|
|
3
3
|
import { getInvoice, listInvoiceVersions } from '../../core/invoices.js';
|
|
4
|
-
import {
|
|
4
|
+
import { sendModeFor } from '../../core/invoicing-rules.js';
|
|
5
|
+
import { contacts as flowContacts, invoices as flowInvoices, priceItems } from '../../db/schema.js';
|
|
6
|
+
import { readConnection } from './connections.js';
|
|
5
7
|
import { itemsByCode, lastSuccessfulSyncAt, matchContact, normaliseName } from './catalogue.js';
|
|
6
8
|
import { xeroError } from './client.js';
|
|
7
9
|
import { withXeroLane } from './governor.js';
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
//
|
|
10
|
+
import { mirrorState } from './lookup.js';
|
|
11
|
+
|
|
12
|
+
// Issued Flow invoices go to Xero. How depends on the customer's send mode
|
|
13
|
+
// (server/core/invoicing-rules.js):
|
|
14
|
+
// - accounting_draft (the default): a Xero draft (ACCREC, DRAFT). The lab
|
|
15
|
+
// approves and sends it in Xero.
|
|
16
|
+
// - approve_and_email: issuing in Flow was the approval. Flow sends it
|
|
17
|
+
// approved (AUTHORISED), then asks Xero to email it to the contact with the
|
|
18
|
+
// organisation's own template.
|
|
19
|
+
// Either way the mirror (mirror.js) reads Xero's status back to Flow.
|
|
11
20
|
//
|
|
12
21
|
// The rules, as in the earlier codebase:
|
|
13
22
|
// - What Flow sends is the issued version's document, exactly. A sent
|
|
@@ -19,7 +28,12 @@ import { withXeroLane } from './governor.js';
|
|
|
19
28
|
// - Before it creates, Flow asks Xero for invoices with the same reference.
|
|
20
29
|
// One for the same contact and total is the same work: Flow records it and
|
|
21
30
|
// creates nothing. One for the same contact at another total within 90
|
|
22
|
-
// days is held for a person, unless they chose to send it anyway.
|
|
31
|
+
// days is held for a person, unless they chose to send it anyway. Flow skips
|
|
32
|
+
// this call for an invoice it drafted from a job when its complete, fresh
|
|
33
|
+
// copy of Xero showed no invoice for that job (docs/JOB_INVOICE_LINKS.md).
|
|
34
|
+
// - Xero's email action is not idempotent. Flow records `sending` before it
|
|
35
|
+
// calls, and calls again only after Xero refused (`failed`), never after an
|
|
36
|
+
// unknown outcome: a person checks Xero then.
|
|
23
37
|
// - The request, with its Idempotency-Key, is stored before it leaves. An
|
|
24
38
|
// uncertain outcome (timeout, 5xx) is resolved later: within Xero's
|
|
25
39
|
// six-minute idempotency window Flow repeats the same request with the same
|
|
@@ -80,7 +94,7 @@ async function documentFor(db, organisationId, invoiceId) {
|
|
|
80
94
|
currency: invoice.currency,
|
|
81
95
|
contact: { id: contact.id, name: contact.name, email: contact.email },
|
|
82
96
|
lines: invoice.lines.map((l) => ({
|
|
83
|
-
position: l.position, itemCode: codes.get(l.priceItemId) ?? null, description: l.description, quantity: l.quantity,
|
|
97
|
+
position: l.position, itemCode: codes.get(l.priceItemId) ?? null, ...(l.accountCode ? { accountCode: l.accountCode } : {}), description: l.description, quantity: l.quantity,
|
|
84
98
|
unitAmountCents: l.unitAmountCents, taxCode: l.taxCode, lineAmountCents: l.lineAmountCents, taxCents: l.taxCents,
|
|
85
99
|
})),
|
|
86
100
|
subtotalCents: invoice.subtotalCents,
|
|
@@ -103,7 +117,7 @@ function trackingFor(settings, contact) {
|
|
|
103
117
|
}
|
|
104
118
|
|
|
105
119
|
// The Xero draft for a document, and what stops it.
|
|
106
|
-
export function buildDraft(document, { settings, contact, items }) {
|
|
120
|
+
export function buildDraft(document, { settings, contact, items, sendMode = 'accounting_draft' }) {
|
|
107
121
|
const problems = [];
|
|
108
122
|
const tracking = trackingFor(settings, contact);
|
|
109
123
|
const lines = document.lines.map((line) => {
|
|
@@ -117,6 +131,9 @@ export function buildDraft(document, { settings, contact, items }) {
|
|
|
117
131
|
if (line.itemCode) {
|
|
118
132
|
if (items.has(line.itemCode)) out.ItemCode = line.itemCode;
|
|
119
133
|
else problems.push(problem('xero_item_missing', `Line ${line.position}: item ${line.itemCode} is not in Xero. Add it in Xero, then sync.`, { itemCode: line.itemCode }));
|
|
134
|
+
} else if (line.accountCode) {
|
|
135
|
+
// A line with no item books to its own account, such as a surcharge.
|
|
136
|
+
out.AccountCode = line.accountCode;
|
|
120
137
|
}
|
|
121
138
|
if (tracking) out.Tracking = [tracking];
|
|
122
139
|
return out;
|
|
@@ -124,7 +141,7 @@ export function buildDraft(document, { settings, contact, items }) {
|
|
|
124
141
|
const issueDate = document.issueDate;
|
|
125
142
|
const draft = {
|
|
126
143
|
Type: 'ACCREC',
|
|
127
|
-
Status: 'DRAFT',
|
|
144
|
+
Status: sendMode === 'approve_and_email' ? 'AUTHORISED' : 'DRAFT',
|
|
128
145
|
Contact: contact ? { ContactID: contact.xeroContactId } : { Name: document.contact.name },
|
|
129
146
|
...(issueDate ? { Date: issueDate, DueDate: document.dueDate ?? addDays(issueDate, settings.defaultDueDays) } : {}),
|
|
130
147
|
Reference: xeroReference(document),
|
|
@@ -168,6 +185,8 @@ function mapPush(row) {
|
|
|
168
185
|
verified: row.verified,
|
|
169
186
|
error: row.error_code ? { code: row.error_code, message: row.error_message } : null,
|
|
170
187
|
attempts: row.attempts,
|
|
188
|
+
sendMode: row.send_mode ?? 'accounting_draft',
|
|
189
|
+
email: row.email_state ? { state: row.email_state, error: row.email_error ?? null, at: row.emailed_at ?? null } : null,
|
|
171
190
|
pushedBy: row.pushed_by,
|
|
172
191
|
firstDispatchedAt: row.first_dispatched_at,
|
|
173
192
|
lastDispatchedAt: row.last_dispatched_at,
|
|
@@ -240,8 +259,12 @@ async function previewOne(db, runtime, organisationId, invoiceId) {
|
|
|
240
259
|
if (match.state === 'missing') problems.push(problem('xero_contact_missing', `No Xero contact is named "${document.contact.name}". Add it in Xero (Flow never creates Xero contacts), then sync.`));
|
|
241
260
|
if (match.state === 'ambiguous') problems.push(problem('xero_contact_ambiguous', `More than one Xero contact is named "${document.contact.name}". Merge or rename them in Xero, then sync.`));
|
|
242
261
|
const items = await itemsByCode(db, organisationId, document.lines.map((l) => l.itemCode));
|
|
243
|
-
const
|
|
262
|
+
const sendMode = await sendModeFor(db, organisationId, invoice.contactId);
|
|
263
|
+
const { draft, problems: lineProblems } = buildDraft(document, { settings: runtime.settings, contact: match.contact, items, sendMode });
|
|
244
264
|
problems.push(...lineProblems);
|
|
265
|
+
if (sendMode === 'approve_and_email' && match.state === 'matched' && !match.contact.email) {
|
|
266
|
+
problems.push(problem('xero_contact_no_email', `The Xero contact "${match.contact.name}" has no email address, so Xero cannot email the invoice. Add one in Xero, then sync.`, { blocking: false }));
|
|
267
|
+
}
|
|
245
268
|
|
|
246
269
|
const pushes = await listPushes(db, organisationId, invoiceId);
|
|
247
270
|
const current = version ? pushes.find((p) => p.version === version) : null;
|
|
@@ -259,6 +282,7 @@ async function previewOne(db, runtime, organisationId, invoiceId) {
|
|
|
259
282
|
invoiceId,
|
|
260
283
|
number: document.number,
|
|
261
284
|
version,
|
|
285
|
+
sendMode,
|
|
262
286
|
state,
|
|
263
287
|
problems,
|
|
264
288
|
contact: { state: match.state, xeroContactId: match.contact?.xeroContactId ?? null, name: match.contact?.name ?? null },
|
|
@@ -298,10 +322,46 @@ export async function pushDrafts(db, runtime, organisationId, invoiceIds, { push
|
|
|
298
322
|
results.push(failed(invoiceId, error));
|
|
299
323
|
}
|
|
300
324
|
}
|
|
325
|
+
await keepSendState(db, organisationId, results);
|
|
301
326
|
return summarise(results);
|
|
302
327
|
}));
|
|
303
328
|
}
|
|
304
329
|
|
|
330
|
+
// Flow's Problem view (server/core/sending.js) shows an issued invoice that
|
|
331
|
+
// failed to send. Once Xero holds it, by any path, that problem is gone.
|
|
332
|
+
async function keepSendState(db, organisationId, results) {
|
|
333
|
+
const held = results.filter((r) => ['created', 'reconciled', 'existing', 'already_in_xero'].includes(r.outcome)).map((r) => r.invoiceId);
|
|
334
|
+
if (!held.length) return;
|
|
335
|
+
await db.update(flowInvoices).set({ sendState: 'sent', sendError: null, sendUpdatedAt: sql`now()` })
|
|
336
|
+
.where(and(eq(flowInvoices.organisationId, organisationId), inArray(flowInvoices.id, held), sql`${flowInvoices.sendState} is not null`));
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Send all ready (server/core/sending.js): issued invoices in batches.
|
|
340
|
+
export async function pushInvoices(db, runtime, { organisationId, invoiceIds, pushedBy = null }) {
|
|
341
|
+
const invoices = [];
|
|
342
|
+
for (let at = 0; at < invoiceIds.length; at += MAX_BATCH) {
|
|
343
|
+
invoices.push(...(await pushDrafts(db, runtime, organisationId, invoiceIds.slice(at, at + MAX_BATCH), { pushedBy })).invoices);
|
|
344
|
+
}
|
|
345
|
+
return { invoices };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// What would stop each Flow draft in Xero, from Flow's copy of Xero (no
|
|
349
|
+
// calls). Null while Xero is not connected: then Flow's own checks decide.
|
|
350
|
+
export async function checkDrafts(db, runtime, { organisationId, invoiceIds }) {
|
|
351
|
+
const connection = await readConnection(db, organisationId);
|
|
352
|
+
if (!connection || connection.mode !== runtime.settings.mode) return null;
|
|
353
|
+
const found = new Map();
|
|
354
|
+
for (let at = 0; at < invoiceIds.length; at += MAX_BATCH) {
|
|
355
|
+
const { invoices } = await previewDrafts(db, runtime, organisationId, invoiceIds.slice(at, at + MAX_BATCH));
|
|
356
|
+
for (const preview of invoices) {
|
|
357
|
+
found.set(preview.invoiceId, preview.problems
|
|
358
|
+
.filter((p) => p.blocking !== false && p.code !== 'invoice_not_issued')
|
|
359
|
+
.map(({ code, message }) => ({ code, message })));
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return found;
|
|
363
|
+
}
|
|
364
|
+
|
|
305
365
|
function failed(invoiceId, error, outcome = 'failed') {
|
|
306
366
|
return { invoiceId, outcome, error: { code: error.code ?? 'xero_push_failed', message: error.message, ...(error.details ? { details: error.details } : {}) } };
|
|
307
367
|
}
|
|
@@ -316,6 +376,8 @@ function summarise(results) {
|
|
|
316
376
|
mismatched: count('mismatch'),
|
|
317
377
|
ambiguous: count('ambiguous'),
|
|
318
378
|
failed: count('failed') + count('not_tried'),
|
|
379
|
+
emailed: results.filter((r) => r.email?.state === 'sent').length,
|
|
380
|
+
emailProblems: results.filter((r) => r.email && ['failed', 'unknown'].includes(r.email.state)).length,
|
|
319
381
|
},
|
|
320
382
|
invoices: results,
|
|
321
383
|
};
|
|
@@ -347,7 +409,8 @@ async function pushOne(db, runtime, client, organisationId, invoiceId, { pushedB
|
|
|
347
409
|
const pushes = await listPushes(db, organisationId, invoiceId);
|
|
348
410
|
const current = pushes.find((p) => p.version === version);
|
|
349
411
|
if (current && SETTLED.has(current.state)) {
|
|
350
|
-
|
|
412
|
+
const email = await emailIfDue(db, client, current);
|
|
413
|
+
return { invoiceId, version, outcome: 'already_in_xero', xero: current.xero, ...(email ? { email } : {}) };
|
|
351
414
|
}
|
|
352
415
|
if (current?.state === 'mismatch') {
|
|
353
416
|
return { invoiceId, version, outcome: 'mismatch', xero: current.xero, error: current.error };
|
|
@@ -377,14 +440,18 @@ async function pushOne(db, runtime, client, organisationId, invoiceId, { pushedB
|
|
|
377
440
|
{ status: 409 });
|
|
378
441
|
}
|
|
379
442
|
const items = await itemsByCode(db, organisationId, document.lines.map((l) => l.itemCode));
|
|
380
|
-
const
|
|
443
|
+
const sendMode = await sendModeFor(db, organisationId, invoice.contactId);
|
|
444
|
+
const { draft, problems } = buildDraft(document, { settings: runtime.settings, contact: match.contact, items, sendMode });
|
|
381
445
|
if (problems.length) throw xeroError(problems[0].code, problems.map((p) => p.message).join(' '), { status: 409 });
|
|
382
446
|
|
|
383
447
|
// Live duplicate check: invoices in Xero with this reference, made by Flow
|
|
384
|
-
// or by hand.
|
|
448
|
+
// or by hand. Not needed when Flow's own copy of Xero already answered it
|
|
449
|
+
// for the job this invoice bills.
|
|
385
450
|
const earlierIds = new Set(pushes.map((p) => p.xero?.invoiceId).filter(Boolean));
|
|
386
|
-
const sameReference = (await
|
|
387
|
-
|
|
451
|
+
const sameReference = (await copyShowsNoInvoice(db, runtime, organisationId, invoiceId))
|
|
452
|
+
? []
|
|
453
|
+
: (await client.findInvoicesByReference(draft.Reference))
|
|
454
|
+
.filter((inv) => !GONE.has(inv.Status) && !earlierIds.has(inv.InvoiceID));
|
|
388
455
|
const sameContact = sameReference.filter((inv) => inv.Contact?.ContactID === match.contact.xeroContactId);
|
|
389
456
|
if (!allowExisting) {
|
|
390
457
|
const same = sameContact.find((inv) => cents(inv.Total) === document.totalCents);
|
|
@@ -406,9 +473,9 @@ async function pushOne(db, runtime, client, organisationId, invoiceId, { pushedB
|
|
|
406
473
|
const preExisting = sameReference.map((inv) => inv.InvoiceID);
|
|
407
474
|
const { rows } = await db.execute(sql`
|
|
408
475
|
insert into xero_invoice_pushes (organisation_id, invoice_id, version, state, idempotency_key, request_body, body_hash, pre_existing_ids, pushed_by,
|
|
409
|
-
first_dispatched_at, last_dispatched_at)
|
|
476
|
+
first_dispatched_at, last_dispatched_at, send_mode, email_state)
|
|
410
477
|
values (${organisationId}, ${invoiceId}, ${version}, 'dispatching', ${key}, ${body}, ${bodyHash}, ${JSON.stringify(preExisting)}::jsonb, ${pushedBy},
|
|
411
|
-
${new Date(runtime.now())}, ${new Date(runtime.now())})
|
|
478
|
+
${new Date(runtime.now())}, ${new Date(runtime.now())}, ${sendMode}, ${sendMode === 'approve_and_email' ? 'pending' : null})
|
|
412
479
|
on conflict (invoice_id, version) do nothing returning *`);
|
|
413
480
|
if (!rows.length) throw xeroError('xero_push_in_progress', 'This invoice is being sent to Xero already. Check again shortly.', { status: 409 });
|
|
414
481
|
return dispatch(db, client, mapPush(rows[0]), document, { fresh: true });
|
|
@@ -481,7 +548,50 @@ async function verify(db, client, push, draft, document, created, outcome) {
|
|
|
481
548
|
return { invoiceId: push.invoiceId, version: push.version, outcome: 'mismatch', xero: settled.xero, error: settled.error };
|
|
482
549
|
}
|
|
483
550
|
const settled = await settle(db, push.id, { state: 'created', xeroInvoiceId: live.InvoiceID, xeroNumber: live.InvoiceNumber, xeroStatus: live.Status, verified: true });
|
|
484
|
-
|
|
551
|
+
const email = await emailIfDue(db, client, settled);
|
|
552
|
+
return { invoiceId: push.invoiceId, version: push.version, outcome, verified: true, xero: settled.xero, ...(email ? { email } : {}) };
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// ---------------------------------------------------------------------------
|
|
556
|
+
// Email
|
|
557
|
+
|
|
558
|
+
// Xero errors that prove it sent no email: it refused the request.
|
|
559
|
+
const emailRefused = (error) => error.code === 'xero_validation_failed'
|
|
560
|
+
|| (error.code === 'xero_api_error' && error.status >= 400 && error.status < 500);
|
|
561
|
+
|
|
562
|
+
// Asks Xero to email an approved invoice that Flow sent and checked, once.
|
|
563
|
+
// Answers the email's state, or null when the push sends no email.
|
|
564
|
+
async function emailIfDue(db, client, push) {
|
|
565
|
+
if (push.sendMode !== 'approve_and_email' || !push.email) return null;
|
|
566
|
+
if (!push.verified || !push.xero?.invoiceId || !['pending', 'failed'].includes(push.email.state)) return push.email;
|
|
567
|
+
// Claim the email, so two pushes never both send it.
|
|
568
|
+
const { rows } = await db.execute(sql`update xero_invoice_pushes set email_state = 'sending', updated_at = now()
|
|
569
|
+
where id = ${push.id} and email_state in ('pending', 'failed') returning id`);
|
|
570
|
+
if (!rows.length) return { state: 'sending', error: null, at: null };
|
|
571
|
+
try {
|
|
572
|
+
await client.emailInvoice(push.xero.invoiceId);
|
|
573
|
+
} catch (error) {
|
|
574
|
+
const state = emailRefused(error) ? 'failed' : 'unknown';
|
|
575
|
+
const message = state === 'failed'
|
|
576
|
+
? `Xero did not email the invoice: ${error.message}`
|
|
577
|
+
: 'Flow does not know whether Xero emailed the invoice. Check the invoice in Xero before you send it again.';
|
|
578
|
+
await db.execute(sql`update xero_invoice_pushes set email_state = ${state}, email_error = ${message}, updated_at = now() where id = ${push.id}`);
|
|
579
|
+
if (error.code === 'xero_rate_limited' || error.code === 'xero_reconnect_needed') throw error;
|
|
580
|
+
return { state, error: message, at: null };
|
|
581
|
+
}
|
|
582
|
+
const { rows: [done] } = await db.execute(sql`update xero_invoice_pushes set email_state = 'sent', email_error = null, emailed_at = now(), updated_at = now()
|
|
583
|
+
where id = ${push.id} returning emailed_at`);
|
|
584
|
+
return { state: 'sent', error: null, at: done.emailed_at };
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// Flow drafted this invoice from a job, and its complete, fresh copy of Xero
|
|
588
|
+
// showed no invoice for that job: Xero needs no live duplicate check.
|
|
589
|
+
async function copyShowsNoInvoice(db, runtime, organisationId, invoiceId) {
|
|
590
|
+
const { rows: [job] } = await db.execute(sql`select accounting_state from billing_obligations
|
|
591
|
+
where organisation_id = ${organisationId} and invoice_id = ${invoiceId}`);
|
|
592
|
+
if (job?.accounting_state !== 'not_found') return false;
|
|
593
|
+
const mirror = await mirrorState(db, organisationId, { now: new Date(runtime.now()) });
|
|
594
|
+
return mirror.complete && mirror.fresh;
|
|
485
595
|
}
|
|
486
596
|
|
|
487
597
|
// An earlier attempt whose outcome is unknown.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { fileURLToPath } from 'node:url';
|
|
2
2
|
import { sql } from 'drizzle-orm';
|
|
3
|
-
import { pushInvoice } from './drafts.js';
|
|
3
|
+
import { checkDrafts, pushInvoice, pushInvoices } from './drafts.js';
|
|
4
|
+
import { readConnection } from './connections.js';
|
|
4
5
|
import { createImportWorker } from './import.js';
|
|
5
6
|
import { readPayments, xeroLiveCheck } from './ledger.js';
|
|
6
7
|
import { findInvoices, listCustomers } from './lookup.js';
|
|
@@ -86,6 +87,15 @@ export function createXeroAdapter({ runtime: given = null } = {}) {
|
|
|
86
87
|
// One issued invoice to a Xero draft (drafts.js). The Flow invoice id
|
|
87
88
|
// and version are the idempotency key.
|
|
88
89
|
pushInvoice: ({ organisationId, invoiceId, pushedBy = null }) => pushInvoice(database, current(), { organisationId, invoiceId, pushedBy }),
|
|
90
|
+
// Several issued invoices, in batches of MAX_BATCH (drafts.js).
|
|
91
|
+
pushInvoices: ({ organisationId, invoiceIds, pushedBy = null }) => pushInvoices(database, current(), { organisationId, invoiceIds, pushedBy }),
|
|
92
|
+
// What would stop each Flow draft in Xero, from Flow's copy (no calls).
|
|
93
|
+
checkDrafts: ({ organisationId, invoiceIds }) => checkDrafts(database, current(), { organisationId, invoiceIds }),
|
|
94
|
+
sendStatus: async ({ organisationId }) => {
|
|
95
|
+
const { settings } = current();
|
|
96
|
+
const connection = await readConnection(database, organisationId);
|
|
97
|
+
return { connected: Boolean(connection && connection.mode === settings.mode), writesAllowed: writesAllowed(settings) };
|
|
98
|
+
},
|
|
89
99
|
// Sales invoice payments changed since `since` (ledger.js). The answer's
|
|
90
100
|
// cursor is the next `since`.
|
|
91
101
|
pullPayments: ({ organisationId, since }) => readPayments(database, current(), organisationId, { since: new Date(since) }),
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { sql } from 'drizzle-orm';
|
|
2
2
|
import { ledgerState } from './ledger.js';
|
|
3
|
-
import { MIRROR_RESOURCES } from './mirror.js';
|
|
4
3
|
|
|
5
4
|
// What the core's matcher (server/core/pipeline.js) reads from Xero: the
|
|
6
5
|
// customers and the sales invoices in Flow's own copy. It never calls Xero, so
|
|
@@ -10,6 +9,11 @@ import { MIRROR_RESOURCES } from './mirror.js';
|
|
|
10
9
|
// stops counting as proof.
|
|
11
10
|
export const MIRROR_FRESH_MS = 24 * 60 * 60 * 1000;
|
|
12
11
|
|
|
12
|
+
// The copies that prove whether Xero holds an invoice for a job. Payments do
|
|
13
|
+
// not: a connection without a payments scope (the default) never copies them,
|
|
14
|
+
// and must still match jobs.
|
|
15
|
+
export const PROOF_RESOURCES = Object.freeze(['invoices', 'credit_notes']);
|
|
16
|
+
|
|
13
17
|
export async function listCustomers(db, organisationId) {
|
|
14
18
|
const { rows } = await db.execute(sql`
|
|
15
19
|
select xero_contact_id, name, status from xero_contacts
|
|
@@ -26,7 +30,7 @@ export async function mirrorState(db, organisationId, { now = new Date() } = {})
|
|
|
26
30
|
where organisation_id = ${organisationId}`);
|
|
27
31
|
const byResource = new Map(rows.map((r) => [r.resource, r]));
|
|
28
32
|
const invoices = byResource.get('invoices');
|
|
29
|
-
const finished =
|
|
33
|
+
const finished = PROOF_RESOURCES.every((r) => (byResource.get(r)?.passes ?? 0) > 0);
|
|
30
34
|
const lastPass = invoices?.last_pass_finished_at ? new Date(invoices.last_pass_finished_at) : null;
|
|
31
35
|
const { rows: [changed] } = await db.execute(sql`
|
|
32
36
|
select max(last_changed_at) as at from xero_invoices where organisation_id = ${organisationId}`);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
-- The Xero connection of each Flow organisation. The refresh token is
|
|
2
2
|
-- AES-256-GCM ciphertext (XERO_TOKEN_KEY); the plain token is never stored.
|
|
3
|
-
CREATE TABLE "xero_connections" (
|
|
3
|
+
CREATE TABLE IF NOT EXISTS "xero_connections" (
|
|
4
4
|
"organisation_id" uuid PRIMARY KEY NOT NULL REFERENCES "organisations"("id"),
|
|
5
5
|
"mode" text NOT NULL,
|
|
6
6
|
"tenant_id" text NOT NULL,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
-- Flow's copy of the Xero contacts and items of each connected organisation,
|
|
2
2
|
-- and a record of each sync. Xero owns these records; a sync replaces the copy.
|
|
3
|
-
CREATE TABLE "xero_contacts" (
|
|
3
|
+
CREATE TABLE IF NOT EXISTS "xero_contacts" (
|
|
4
4
|
"organisation_id" uuid NOT NULL REFERENCES "organisations"("id"),
|
|
5
5
|
"xero_contact_id" text NOT NULL,
|
|
6
6
|
"name" text NOT NULL,
|
|
@@ -14,9 +14,9 @@ CREATE TABLE "xero_contacts" (
|
|
|
14
14
|
CONSTRAINT "xero_contacts_pk" PRIMARY KEY("organisation_id", "xero_contact_id")
|
|
15
15
|
);
|
|
16
16
|
--> statement-breakpoint
|
|
17
|
-
CREATE INDEX "xero_contacts_org_name" ON "xero_contacts" ("organisation_id", lower("name"));
|
|
17
|
+
CREATE INDEX IF NOT EXISTS "xero_contacts_org_name" ON "xero_contacts" ("organisation_id", lower("name"));
|
|
18
18
|
--> statement-breakpoint
|
|
19
|
-
CREATE TABLE "xero_items" (
|
|
19
|
+
CREATE TABLE IF NOT EXISTS "xero_items" (
|
|
20
20
|
"organisation_id" uuid NOT NULL REFERENCES "organisations"("id"),
|
|
21
21
|
"xero_item_id" text NOT NULL,
|
|
22
22
|
"code" text NOT NULL,
|
|
@@ -32,7 +32,7 @@ CREATE TABLE "xero_items" (
|
|
|
32
32
|
CONSTRAINT "xero_items_org_code" UNIQUE("organisation_id", "code")
|
|
33
33
|
);
|
|
34
34
|
--> statement-breakpoint
|
|
35
|
-
CREATE TABLE "xero_sync_runs" (
|
|
35
|
+
CREATE TABLE IF NOT EXISTS "xero_sync_runs" (
|
|
36
36
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
|
37
37
|
"organisation_id" uuid NOT NULL REFERENCES "organisations"("id"),
|
|
38
38
|
"status" text DEFAULT 'running' NOT NULL,
|
|
@@ -46,4 +46,4 @@ CREATE TABLE "xero_sync_runs" (
|
|
|
46
46
|
CONSTRAINT "xero_sync_runs_status_valid" CHECK ("status" in ('running', 'succeeded', 'failed'))
|
|
47
47
|
);
|
|
48
48
|
--> statement-breakpoint
|
|
49
|
-
CREATE INDEX "xero_sync_runs_org_started" ON "xero_sync_runs" ("organisation_id", "started_at");
|
|
49
|
+
CREATE INDEX IF NOT EXISTS "xero_sync_runs_org_started" ON "xero_sync_runs" ("organisation_id", "started_at");
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
-- One row for each issued Flow invoice version sent to Xero as a draft. It
|
|
2
2
|
-- holds the exact request and its Idempotency-Key from before the request
|
|
3
3
|
-- leaves, so an uncertain outcome is resolved without a second invoice.
|
|
4
|
-
CREATE TABLE "xero_invoice_pushes" (
|
|
4
|
+
CREATE TABLE IF NOT EXISTS "xero_invoice_pushes" (
|
|
5
5
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
|
6
6
|
"organisation_id" uuid NOT NULL,
|
|
7
7
|
"invoice_id" uuid NOT NULL,
|
|
@@ -29,4 +29,4 @@ CREATE TABLE "xero_invoice_pushes" (
|
|
|
29
29
|
CONSTRAINT "xero_invoice_pushes_settled_has_id" CHECK ("state" in ('dispatching', 'ambiguous') or "xero_invoice_id" is not null)
|
|
30
30
|
);
|
|
31
31
|
--> statement-breakpoint
|
|
32
|
-
CREATE INDEX "xero_invoice_pushes_org_invoice" ON "xero_invoice_pushes" ("organisation_id", "invoice_id");
|
|
32
|
+
CREATE INDEX IF NOT EXISTS "xero_invoice_pushes_org_invoice" ON "xero_invoice_pushes" ("organisation_id", "invoice_id");
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
-- Where the next read of Xero payments starts, for each Flow organisation.
|
|
2
2
|
-- (This is the Xero adapter's own migration journal, not the core's.)
|
|
3
|
-
CREATE TABLE "xero_payment_cursors" (
|
|
3
|
+
CREATE TABLE IF NOT EXISTS "xero_payment_cursors" (
|
|
4
4
|
"organisation_id" uuid PRIMARY KEY NOT NULL REFERENCES "organisations"("id"),
|
|
5
5
|
"payments_since" timestamp with time zone NOT NULL,
|
|
6
6
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
-- without spending Xero calls. A row is upserted by its Xero id, and changes
|
|
5
5
|
-- only when its content hash changes. Free text (references, line
|
|
6
6
|
-- descriptions) is kept only with XERO_KEEP_REFERENCE=on.
|
|
7
|
-
CREATE TABLE "xero_invoices" (
|
|
7
|
+
CREATE TABLE IF NOT EXISTS "xero_invoices" (
|
|
8
8
|
"organisation_id" uuid NOT NULL REFERENCES "organisations"("id"),
|
|
9
9
|
"xero_invoice_id" text NOT NULL,
|
|
10
10
|
"type" text NOT NULL,
|
|
@@ -30,11 +30,11 @@ CREATE TABLE "xero_invoices" (
|
|
|
30
30
|
CONSTRAINT "xero_invoices_pk" PRIMARY KEY("organisation_id", "xero_invoice_id")
|
|
31
31
|
);
|
|
32
32
|
--> statement-breakpoint
|
|
33
|
-
CREATE INDEX "xero_invoices_number" ON "xero_invoices" ("organisation_id", "invoice_number");
|
|
33
|
+
CREATE INDEX IF NOT EXISTS "xero_invoices_number" ON "xero_invoices" ("organisation_id", "invoice_number");
|
|
34
34
|
--> statement-breakpoint
|
|
35
|
-
CREATE INDEX "xero_invoices_contact" ON "xero_invoices" ("organisation_id", "xero_contact_id", "issued_on");
|
|
35
|
+
CREATE INDEX IF NOT EXISTS "xero_invoices_contact" ON "xero_invoices" ("organisation_id", "xero_contact_id", "issued_on");
|
|
36
36
|
--> statement-breakpoint
|
|
37
|
-
CREATE TABLE "xero_credit_notes" (
|
|
37
|
+
CREATE TABLE IF NOT EXISTS "xero_credit_notes" (
|
|
38
38
|
"organisation_id" uuid NOT NULL REFERENCES "organisations"("id"),
|
|
39
39
|
"xero_credit_note_id" text NOT NULL,
|
|
40
40
|
"type" text NOT NULL,
|
|
@@ -57,7 +57,7 @@ CREATE TABLE "xero_credit_notes" (
|
|
|
57
57
|
CONSTRAINT "xero_credit_notes_pk" PRIMARY KEY("organisation_id", "xero_credit_note_id")
|
|
58
58
|
);
|
|
59
59
|
--> statement-breakpoint
|
|
60
|
-
CREATE TABLE "xero_payments" (
|
|
60
|
+
CREATE TABLE IF NOT EXISTS "xero_payments" (
|
|
61
61
|
"organisation_id" uuid NOT NULL REFERENCES "organisations"("id"),
|
|
62
62
|
"xero_payment_id" text NOT NULL,
|
|
63
63
|
"payment_type" text,
|
|
@@ -75,12 +75,12 @@ CREATE TABLE "xero_payments" (
|
|
|
75
75
|
CONSTRAINT "xero_payments_pk" PRIMARY KEY("organisation_id", "xero_payment_id")
|
|
76
76
|
);
|
|
77
77
|
--> statement-breakpoint
|
|
78
|
-
CREATE INDEX "xero_payments_invoice" ON "xero_payments" ("organisation_id", "xero_invoice_id");
|
|
78
|
+
CREATE INDEX IF NOT EXISTS "xero_payments_invoice" ON "xero_payments" ("organisation_id", "xero_invoice_id");
|
|
79
79
|
--> statement-breakpoint
|
|
80
80
|
-- Where the next read of each resource starts. `modified_since` is the
|
|
81
81
|
-- keyset: the next read asks Xero for records changed since then, oldest
|
|
82
82
|
-- change first. `page` moves on only when one second holds a full page.
|
|
83
|
-
CREATE TABLE "xero_mirror_cursors" (
|
|
83
|
+
CREATE TABLE IF NOT EXISTS "xero_mirror_cursors" (
|
|
84
84
|
"organisation_id" uuid NOT NULL REFERENCES "organisations"("id"),
|
|
85
85
|
"resource" text NOT NULL,
|
|
86
86
|
"modified_since" timestamp with time zone,
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
-- How each push was sent (invoicing_settings.send_mode) and, for an invoice
|
|
2
|
+
-- sent approved, whether Xero emailed it to the customer. Xero's email action
|
|
3
|
+
-- is not idempotent, so Flow records `sending` before the call and retries
|
|
4
|
+
-- only a refusal (`failed`), never an unknown outcome.
|
|
5
|
+
ALTER TABLE "xero_invoice_pushes" ADD COLUMN IF NOT EXISTS "send_mode" text DEFAULT 'accounting_draft' NOT NULL;--> statement-breakpoint
|
|
6
|
+
ALTER TABLE "xero_invoice_pushes" ADD COLUMN IF NOT EXISTS "email_state" text;--> statement-breakpoint
|
|
7
|
+
ALTER TABLE "xero_invoice_pushes" ADD COLUMN IF NOT EXISTS "email_error" text;--> statement-breakpoint
|
|
8
|
+
ALTER TABLE "xero_invoice_pushes" ADD COLUMN IF NOT EXISTS "emailed_at" timestamp with time zone;--> statement-breakpoint
|
|
9
|
+
DO $flow_guard$ BEGIN
|
|
10
|
+
ALTER TABLE "xero_invoice_pushes" ADD CONSTRAINT "xero_invoice_pushes_send_mode_valid" CHECK ("send_mode" in ('accounting_draft', 'approve_and_email'));
|
|
11
|
+
EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
|
|
12
|
+
END $flow_guard$;--> statement-breakpoint
|
|
13
|
+
DO $flow_guard$ BEGIN
|
|
14
|
+
ALTER TABLE "xero_invoice_pushes" ADD CONSTRAINT "xero_invoice_pushes_email_state_valid" CHECK ("email_state" is null or "email_state" in ('pending', 'sending', 'sent', 'failed', 'unknown'));
|
|
15
|
+
EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
|
|
16
|
+
END $flow_guard$;
|
package/server/ui/app-page.js
CHANGED
|
@@ -34,13 +34,16 @@ const BODY = `<div class="fx-app">
|
|
|
34
34
|
<div class="fx-card fx-message__card fx-state-no-access" id="no-access"></div>
|
|
35
35
|
</section>
|
|
36
36
|
|
|
37
|
-
<div class="fx-state-signed-in fx-shell">
|
|
37
|
+
<div class="fx-state-signed-in fx-shell" id="shell">
|
|
38
38
|
<aside class="fx-side">
|
|
39
|
-
<
|
|
39
|
+
<div class="fx-side__head">
|
|
40
|
+
<a class="fx-brand" href="/">${FLOW_MARK}<span>Flow</span></a>
|
|
41
|
+
<button type="button" class="fx-btn fx-btn--ghost fx-btn--icon" id="menu-toggle" aria-controls="nav" aria-label="Menu"></button>
|
|
42
|
+
</div>
|
|
40
43
|
<nav class="fx-nav" id="nav" aria-label="Flow"></nav>
|
|
41
44
|
<div class="fx-side__end">
|
|
42
45
|
<div id="user-button" class="fx-side__user"></div>
|
|
43
|
-
<button type="button" class="fx-btn fx-btn--ghost fx-btn--icon
|
|
46
|
+
<button type="button" class="fx-btn fx-btn--ghost fx-btn--icon" id="search" title="Search (Ctrl+K)" aria-label="Search" aria-keyshortcuts="Control+K Meta+K"></button>
|
|
44
47
|
</div>
|
|
45
48
|
</aside>
|
|
46
49
|
<main id="main" class="fx-main" tabindex="-1"></main>
|
|
@@ -48,7 +51,7 @@ const BODY = `<div class="fx-app">
|
|
|
48
51
|
<div id="toasts" class="fx-toasts" aria-live="polite"></div>
|
|
49
52
|
</div>`;
|
|
50
53
|
|
|
51
|
-
export const APP_PATHS = ['/sign-in', '/jobs', '/invoices', '/invoices/:id', '/people', '/integrations', '/integrations/:id', '/account', '/appearance'];
|
|
54
|
+
export const APP_PATHS = ['/sign-in', '/jobs', '/invoices', '/invoices/:id', '/people', '/integrations', '/integrations/:id', '/account', '/appearance', '/invoicing-settings'];
|
|
52
55
|
|
|
53
56
|
export function appPage({ defaultFamily }) {
|
|
54
57
|
return renderPage({
|