@arcforgelabs/flow 2026.10.48 → 2026.10.50
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/VISION.md +18 -9
- package/npm-shrinkwrap.json +11 -11
- package/package.json +1 -1
- package/server/api/invoicing.js +7 -3
- package/server/core/automations.js +34 -0
- package/server/core/invoice-rules.js +17 -3
- package/server/core/job-board.js +1 -1
- package/server/core/pipeline.js +46 -6
- package/server/core/sending.js +27 -9
- package/server/integrations/port.js +1 -1
- package/server/integrations/seazona/feed.js +41 -4
- package/server/integrations/seazona/sweep.js +11 -8
- package/server/integrations/sentry/index.js +2 -2
- package/server/integrations/xero/client.js +23 -0
- package/server/integrations/xero/connections.js +2 -0
- package/server/integrations/xero/contact-sync.js +289 -0
- package/server/integrations/xero/create-contact.js +42 -2
- package/server/integrations/xero/drafts.js +14 -6
- package/server/integrations/xero/import.js +5 -1
- package/server/integrations/xero/index.js +16 -2
- package/server/integrations/xero/lookup.js +15 -2
- package/server/integrations/xero/migrations/0013_xero_contact_sync.sql +15 -0
- package/server/integrations/xero/migrations/meta/_journal.json +7 -0
- package/server/integrations/xero/oauth.js +10 -3
- package/server/integrations/xero/routes.js +74 -42
- package/server/integrations/xero/runtime.js +7 -4
- package/server/integrations/xero/settings.js +18 -1
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +48 -40
- package/server/ui/automations.js +2 -4
- package/server/ui/contacts.js +20 -27
- package/server/ui/flow.css +125 -41
- package/server/ui/invoice-rules.js +3 -5
- package/server/ui/invoicing-settings.js +3 -7
- package/server/ui/invoicing.js +69 -84
- package/server/ui/job-board.js +3 -1
- package/server/ui/jobs.js +1 -1
- package/server/ui/kit.js +101 -10
- package/server/ui/line-mappings.js +2 -4
- package/server/ui/mobile.js +49 -4
- package/server/ui/price-list.js +3 -5
- package/server/ui/routes.js +1 -1
- package/server/ui/xero-contact.js +101 -0
|
@@ -4,7 +4,7 @@ import { FlowError } from '../../core/errors.js';
|
|
|
4
4
|
import { createActionGuard } from '../call-guard.js';
|
|
5
5
|
import { billingObligations, organisations } from '../../db/schema.js';
|
|
6
6
|
import { lastSync, listContacts, listItems, syncCatalogue } from './catalogue.js';
|
|
7
|
-
import { createCustomer, matchCustomer } from './create-contact.js';
|
|
7
|
+
import { createCustomer, linkCustomer, matchCustomer } from './create-contact.js';
|
|
8
8
|
import { createXeroClient } from './client.js';
|
|
9
9
|
import { describePush, listPushes, MAX_BATCH, previewDrafts, pushDrafts } from './drafts.js';
|
|
10
10
|
import { recordXeroEvidence, recordXeroPayments } from './ledger.js';
|
|
@@ -12,8 +12,8 @@ import { IMPORT_STATES, importOverview, linkXeroInvoice, makeFlowInvoice, matchX
|
|
|
12
12
|
import { queueHistoryImport, resetImport } from './import.js';
|
|
13
13
|
import { MIRROR_RESOURCES, mirrorStatus, mirrorXero } from './mirror.js';
|
|
14
14
|
import { accountingStatus, checkSentInvoices } from './reconcile.js';
|
|
15
|
-
import { decryptToken, deleteConnection, describeConnection, readConnection, saveConnection } from './connections.js';
|
|
16
|
-
import { authorizeUrl, exchangeCode, fetchConnections, revokeToken, selectConnection } from './oauth.js';
|
|
15
|
+
import { CUSTOM_CONNECTION, decryptToken, deleteConnection, describeConnection, readConnection, saveConnection } from './connections.js';
|
|
16
|
+
import { authorizeUrl, clientCredentials, exchangeCode, fetchConnections, revokeToken, selectConnection } from './oauth.js';
|
|
17
17
|
import { writesAllowed } from './settings.js';
|
|
18
18
|
import { readXeroUsage } from './usage.js';
|
|
19
19
|
import { saveEvents, signatureValid } from './webhooks.js';
|
|
@@ -23,7 +23,8 @@ import { messagePage as themedMessagePage } from '../../ui/page.js';
|
|
|
23
23
|
// The Xero adapter's routes, mounted at /api/xero (server/integrations/port.js).
|
|
24
24
|
//
|
|
25
25
|
// GET /status?organisationId= any signed-in person: mode and connection
|
|
26
|
-
// POST /connect owner: returns Xero's approval address
|
|
26
|
+
// POST /connect owner: returns Xero's approval address, or
|
|
27
|
+
// connects a Custom Connection at once
|
|
27
28
|
// GET /oauth/callback Xero sends the owner back here
|
|
28
29
|
// POST /disconnect owner: revokes and forgets the connection
|
|
29
30
|
// POST /sync owner or admin: copies Xero contacts and items
|
|
@@ -31,6 +32,8 @@ import { messagePage as themedMessagePage } from '../../ui/page.js';
|
|
|
31
32
|
// GET /contacts/match?organisationId=&contactId= any signed-in person:
|
|
32
33
|
// whether this Flow contact is in the copy
|
|
33
34
|
// POST /contacts owner or admin: create the Xero contact
|
|
35
|
+
// POST /contacts/link owner or admin: link a Flow contact to a
|
|
36
|
+
// Xero contact of another name (no Xero call)
|
|
34
37
|
// GET /items?organisationId= any signed-in person: the copy
|
|
35
38
|
// POST /invoices/preview any signed-in person: the Xero drafts Flow
|
|
36
39
|
// would make, and what stops them (no Xero calls)
|
|
@@ -180,8 +183,59 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, butto
|
|
|
180
183
|
});
|
|
181
184
|
}));
|
|
182
185
|
|
|
186
|
+
// Keeps the one Xero organisation these tokens reach, after proving it is
|
|
187
|
+
// one Flow may use (a Demo Company in sandbox). A refused code-flow grant
|
|
188
|
+
// is revoked at Xero. Then the history import starts.
|
|
189
|
+
async function connectWith(tokens, { organisationId, personId }) {
|
|
190
|
+
const connections = await fetchConnections(settings, runtime.transport, tokens.access_token);
|
|
191
|
+
let chosen;
|
|
192
|
+
try {
|
|
193
|
+
chosen = selectConnection(connections, settings);
|
|
194
|
+
const probe = createXeroClient({
|
|
195
|
+
tenantId: chosen.tenantId,
|
|
196
|
+
accessToken: async () => tokens.access_token,
|
|
197
|
+
fetch: runtime.transport,
|
|
198
|
+
governor: runtime.governor,
|
|
199
|
+
apiBase: settings.endpoints.api,
|
|
200
|
+
log,
|
|
201
|
+
});
|
|
202
|
+
await runtime.assertEnvironment(probe);
|
|
203
|
+
} catch (error) {
|
|
204
|
+
// Refused: Xero keeps no grant for an organisation Flow will not use.
|
|
205
|
+
if (tokens.refresh_token) await revokeToken(settings, runtime.transport, tokens.refresh_token).catch(() => false);
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
const previous = await readConnection(db, organisationId);
|
|
209
|
+
const connection = await saveConnection(db, {
|
|
210
|
+
organisationId,
|
|
211
|
+
mode: settings.mode,
|
|
212
|
+
tenantId: chosen.tenantId,
|
|
213
|
+
tenantName: chosen.tenantName ?? null,
|
|
214
|
+
scope: tokens.scope ?? settings.scope,
|
|
215
|
+
// A Custom Connection has no refresh token; the column holds a sealed
|
|
216
|
+
// marker that is never read (runtime.js asks Xero again instead).
|
|
217
|
+
refreshToken: tokens.refresh_token ?? CUSTOM_CONNECTION,
|
|
218
|
+
connectedBy: personId,
|
|
219
|
+
key: settings.tokenKey,
|
|
220
|
+
});
|
|
221
|
+
runtime.remember(organisationId, tokens);
|
|
222
|
+
// A new connection is a person acting: calls stopped before resume.
|
|
223
|
+
await runtime.guardFor(db).resume();
|
|
224
|
+
// Another Xero organisation starts its history from the beginning.
|
|
225
|
+
if (previous && previous.tenantId !== connection.tenantId) await resetImport(db, organisationId);
|
|
226
|
+
await queueHistoryImport(db, organisationId, { reason: 'connected' });
|
|
227
|
+
log.info?.(`[xero] organisation ${organisationId} connected to Xero tenant ${connection.tenantId} (${settings.mode}, ${settings.connection}) by person ${personId}`);
|
|
228
|
+
return connection;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// A Custom Connection connects here at once; a Xero app answers with the
|
|
232
|
+
// approval address the browser opens.
|
|
183
233
|
router.post('/connect', ...owner, handle(log, async (req, res) => {
|
|
184
234
|
const organisationId = await requireOrganisation(db, req.body?.organisationId);
|
|
235
|
+
if (settings.connection === 'custom') {
|
|
236
|
+
const connection = await connectWith(await clientCredentials(settings, runtime.transport), { organisationId, personId: req.person.id });
|
|
237
|
+
return res.json({ ok: true, connected: true, connection: describeConnection(connection), mode: settings.mode });
|
|
238
|
+
}
|
|
185
239
|
if (!settings.redirectUri) {
|
|
186
240
|
throw Object.assign(new FlowError('xero_redirect_uri_missing', 'Set FLOW_PUBLIC_ORIGIN (or XERO_REDIRECT_URI) to this installation\'s address, and register its /api/xero/oauth/callback on the Xero app.'), { status: 503 });
|
|
187
241
|
}
|
|
@@ -200,42 +254,7 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, butto
|
|
|
200
254
|
}
|
|
201
255
|
try {
|
|
202
256
|
const tokens = await exchangeCode(settings, runtime.transport, typeof req.query.code === 'string' ? req.query.code : '');
|
|
203
|
-
const
|
|
204
|
-
let chosen;
|
|
205
|
-
try {
|
|
206
|
-
chosen = selectConnection(connections, settings);
|
|
207
|
-
const probe = createXeroClient({
|
|
208
|
-
tenantId: chosen.tenantId,
|
|
209
|
-
accessToken: async () => tokens.access_token,
|
|
210
|
-
fetch: runtime.transport,
|
|
211
|
-
governor: runtime.governor,
|
|
212
|
-
apiBase: settings.endpoints.api,
|
|
213
|
-
log,
|
|
214
|
-
});
|
|
215
|
-
await runtime.assertEnvironment(probe);
|
|
216
|
-
} catch (error) {
|
|
217
|
-
// Refused: Xero keeps no grant for an organisation Flow will not use.
|
|
218
|
-
await revokeToken(settings, runtime.transport, tokens.refresh_token).catch(() => false);
|
|
219
|
-
throw error;
|
|
220
|
-
}
|
|
221
|
-
const previous = await readConnection(db, entry.organisationId);
|
|
222
|
-
const connection = await saveConnection(db, {
|
|
223
|
-
organisationId: entry.organisationId,
|
|
224
|
-
mode: settings.mode,
|
|
225
|
-
tenantId: chosen.tenantId,
|
|
226
|
-
tenantName: chosen.tenantName ?? null,
|
|
227
|
-
scope: tokens.scope ?? settings.scope,
|
|
228
|
-
refreshToken: tokens.refresh_token,
|
|
229
|
-
connectedBy: entry.personId,
|
|
230
|
-
key: settings.tokenKey,
|
|
231
|
-
});
|
|
232
|
-
runtime.remember(entry.organisationId, tokens);
|
|
233
|
-
// A new connection is a person acting: calls stopped before resume.
|
|
234
|
-
await runtime.guardFor(db).resume();
|
|
235
|
-
// Another Xero organisation starts its history from the beginning.
|
|
236
|
-
if (previous && previous.tenantId !== connection.tenantId) await resetImport(db, entry.organisationId);
|
|
237
|
-
await queueHistoryImport(db, entry.organisationId, { reason: 'connected' });
|
|
238
|
-
log.info?.(`[xero] organisation ${entry.organisationId} connected to Xero tenant ${connection.tenantId} (${settings.mode}) by person ${entry.personId}`);
|
|
257
|
+
const connection = await connectWith(tokens, entry);
|
|
239
258
|
const organisation = connection.tenantName || 'your Xero organisation';
|
|
240
259
|
return res.type('html').send(page(
|
|
241
260
|
'Xero is connected',
|
|
@@ -255,7 +274,8 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, butto
|
|
|
255
274
|
const connection = await readConnection(db, organisationId);
|
|
256
275
|
if (!connection) return res.json({ ok: true, disconnected: false });
|
|
257
276
|
let revoked = false;
|
|
258
|
-
|
|
277
|
+
// A Custom Connection has no token to revoke: it ends in Xero's My Apps.
|
|
278
|
+
if (settings.connection !== 'custom') try {
|
|
259
279
|
revoked = await revokeToken(settings, runtime.transport, decryptToken(connection.refreshTokenEnc, settings.tokenKey, organisationId));
|
|
260
280
|
} catch (error) {
|
|
261
281
|
log.warn?.(`[xero] could not revoke the Xero token for organisation ${organisationId}: ${error.code ?? 'error'}`);
|
|
@@ -274,7 +294,8 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, butto
|
|
|
274
294
|
|
|
275
295
|
router.get('/contacts', ...signedIn, handle(log, async (req, res) => {
|
|
276
296
|
const organisationId = await requireOrganisation(db, req.query.organisationId);
|
|
277
|
-
|
|
297
|
+
const limit = Number(req.query.limit) || undefined;
|
|
298
|
+
res.json({ ok: true, contacts: await listContacts(db, organisationId, { q: typeof req.query.q === 'string' ? req.query.q : '', limit }) });
|
|
278
299
|
}));
|
|
279
300
|
|
|
280
301
|
// Whether this Flow contact is a Xero contact, and whether Create is offered.
|
|
@@ -293,6 +314,17 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, butto
|
|
|
293
314
|
res.status(result.outcome === 'created' ? 201 : 200).json({ ok: true, ...result });
|
|
294
315
|
}));
|
|
295
316
|
|
|
317
|
+
// A person links a Flow contact to a Xero contact of another name. Flow's
|
|
318
|
+
// identity map only: no Xero call.
|
|
319
|
+
router.post('/contacts/link', ...manager, handle(log, async (req, res) => {
|
|
320
|
+
const organisationId = await requireOrganisation(db, req.body?.organisationId);
|
|
321
|
+
if (typeof req.body?.contactId !== 'string' || !UUID.test(req.body.contactId)) throw new FlowError('invalid', 'contactId must be a Flow contact id');
|
|
322
|
+
if (typeof req.body?.xeroContactId !== 'string' || !req.body.xeroContactId.trim()) throw new FlowError('invalid', 'xeroContactId must be a Xero contact id');
|
|
323
|
+
const result = await linkCustomer(db, runtime, { organisationId, contactId: req.body.contactId, xeroContactId: req.body.xeroContactId.trim() });
|
|
324
|
+
log.info?.(`[xero] organisation ${organisationId}: person ${req.person.id} linked Xero contact ${result.xeroContactId} to Flow contact ${req.body.contactId}`);
|
|
325
|
+
res.json({ ok: true, ...result });
|
|
326
|
+
}));
|
|
327
|
+
|
|
296
328
|
router.get('/items', ...signedIn, handle(log, async (req, res) => {
|
|
297
329
|
const organisationId = await requireOrganisation(db, req.query.organisationId);
|
|
298
330
|
res.json({ ok: true, items: await listItems(db, organisationId) });
|
|
@@ -2,7 +2,7 @@ import { createCallGuard } from '../call-guard.js';
|
|
|
2
2
|
import { createXeroClient, xeroError } from './client.js';
|
|
3
3
|
import { decryptToken, readConnection, storeRotatedToken } from './connections.js';
|
|
4
4
|
import { createGovernor } from './governor.js';
|
|
5
|
-
import { createStateStore, refreshTokens } from './oauth.js';
|
|
5
|
+
import { clientCredentials, createStateStore, refreshTokens } from './oauth.js';
|
|
6
6
|
import { createReplayFetch, DEFAULT_REPLAY_FIXTURES, loadReplayFixtures } from './replay.js';
|
|
7
7
|
import { resolveXeroSettings, writesAllowed } from './settings.js';
|
|
8
8
|
|
|
@@ -101,10 +101,13 @@ export function createXeroRuntime({ settings, fetch, now = Date.now, log = conso
|
|
|
101
101
|
return db.transaction(async (tx) => {
|
|
102
102
|
const connection = usable(await readConnection(tx, organisationId, { lock: true }));
|
|
103
103
|
await guard.check(connection.tenantId, { resource: 'token' });
|
|
104
|
-
|
|
104
|
+
// A Custom Connection has no refresh token: Flow asks with the client
|
|
105
|
+
// id and secret again, and nothing rotates.
|
|
106
|
+
const custom = settings.connection === 'custom';
|
|
107
|
+
const refreshToken = custom ? null : decryptToken(connection.refreshTokenEnc, settings.tokenKey, organisationId);
|
|
105
108
|
let tokens;
|
|
106
109
|
try {
|
|
107
|
-
tokens = await refreshTokens(settings, transport, refreshToken);
|
|
110
|
+
tokens = custom ? await clientCredentials(settings, transport) : await refreshTokens(settings, transport, refreshToken);
|
|
108
111
|
} catch (error) {
|
|
109
112
|
// invalid_grant: the connection is gone. invalid_client: the app's
|
|
110
113
|
// client id or secret is wrong. Neither clears without a person.
|
|
@@ -116,7 +119,7 @@ export function createXeroRuntime({ settings, fetch, now = Date.now, log = conso
|
|
|
116
119
|
throw error;
|
|
117
120
|
}
|
|
118
121
|
await guard.record(connection.tenantId, { resource: 'token', statusCode: 200 });
|
|
119
|
-
await storeRotatedToken(tx, organisationId, { refreshToken: tokens.refresh_token, scope: tokens.scope, key: settings.tokenKey });
|
|
122
|
+
if (!custom) await storeRotatedToken(tx, organisationId, { refreshToken: tokens.refresh_token, scope: tokens.scope, key: settings.tokenKey });
|
|
120
123
|
remember(organisationId, tokens);
|
|
121
124
|
return tokens.access_token;
|
|
122
125
|
});
|
|
@@ -10,6 +10,14 @@
|
|
|
10
10
|
// is not DEMO, so a sandbox can never write to a real ledger.
|
|
11
11
|
// replay no network: recorded, synthetic Xero responses
|
|
12
12
|
// (server/integrations/xero/fixtures). Development and CI.
|
|
13
|
+
//
|
|
14
|
+
// XERO_CONNECTION says how Flow gets its Xero access tokens.
|
|
15
|
+
// app (default) a Xero web app: an owner approves in the browser, and
|
|
16
|
+
// Flow keeps the rotating refresh token.
|
|
17
|
+
// custom a Xero Custom Connection (client credentials): no browser step
|
|
18
|
+
// and no refresh token. Sandbox only: Xero makes Custom
|
|
19
|
+
// Connections free on the Demo Company, so a demo or a test
|
|
20
|
+
// session connects with the client id and secret alone.
|
|
13
21
|
|
|
14
22
|
import { backgroundJobsOn } from '../../core/host.js';
|
|
15
23
|
|
|
@@ -29,6 +37,8 @@ export const XERO_ENDPOINTS = Object.freeze({
|
|
|
29
37
|
export const XERO_CALLBACK_PATH = '/api/xero/oauth/callback';
|
|
30
38
|
// accounting.payments.read: Flow reads payments back (ledger.js, mirror.js).
|
|
31
39
|
export const DEFAULT_XERO_SCOPE = 'offline_access accounting.contacts accounting.invoices accounting.payments.read accounting.settings.read';
|
|
40
|
+
// A Custom Connection has no refresh token, so it asks for no offline_access.
|
|
41
|
+
export const DEFAULT_CUSTOM_SCOPE = DEFAULT_XERO_SCOPE.replace('offline_access ', '');
|
|
32
42
|
export const DEFAULT_XERO_DAY_LIMIT = 1000;
|
|
33
43
|
export const DEFAULT_DUE_DAYS = 30;
|
|
34
44
|
export const DEFAULT_MIRROR_PAGE_SIZE = 200;
|
|
@@ -121,6 +131,12 @@ export function resolveXeroSettings(env = process.env) {
|
|
|
121
131
|
if (!mode) return null;
|
|
122
132
|
if (!XERO_MODES.includes(mode)) throw new Error('XERO_MODE must be "live", "sandbox", "replay" or unset');
|
|
123
133
|
|
|
134
|
+
const connection = (readEnv(env, 'XERO_CONNECTION') || 'app').toLowerCase();
|
|
135
|
+
if (!['app', 'custom'].includes(connection)) throw new Error('XERO_CONNECTION must be "app", "custom" or unset');
|
|
136
|
+
if (connection === 'custom' && mode !== 'sandbox') {
|
|
137
|
+
throw new Error('XERO_CONNECTION=custom works only with XERO_MODE=sandbox (a Xero Demo Company)');
|
|
138
|
+
}
|
|
139
|
+
|
|
124
140
|
const clientId = readEnv(env, 'XERO_CLIENT_ID');
|
|
125
141
|
const clientSecret = readEnv(env, 'XERO_CLIENT_SECRET');
|
|
126
142
|
const tokenKey = decodeTokenKey(readEnv(env, 'XERO_TOKEN_KEY'));
|
|
@@ -150,11 +166,12 @@ export function resolveXeroSettings(env = process.env) {
|
|
|
150
166
|
|
|
151
167
|
return {
|
|
152
168
|
mode,
|
|
169
|
+
connection,
|
|
153
170
|
clientId: clientId || (mode === 'replay' ? 'replay-client-id' : ''),
|
|
154
171
|
clientSecret: clientSecret || (mode === 'replay' ? 'replay-client-secret' : ''),
|
|
155
172
|
tokenKey,
|
|
156
173
|
redirectUri: resolveRedirectUri(env),
|
|
157
|
-
scope: readEnv(env, 'XERO_SCOPE') || DEFAULT_XERO_SCOPE,
|
|
174
|
+
scope: readEnv(env, 'XERO_SCOPE') || (connection === 'custom' ? DEFAULT_CUSTOM_SCOPE : DEFAULT_XERO_SCOPE),
|
|
158
175
|
tenantId,
|
|
159
176
|
tenantName,
|
|
160
177
|
liveWrites: readOnOff(env, 'XERO_ALLOW_LIVE_WRITES'),
|
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', '/reconcile', '/reconcile/:id', '/xero-import', '/people', '/integrations', '/integrations/:id', '/account', '/appearance', '/invoicing-settings', '/invoicing-settings/:id', '/automations', '/price-list', '/custom-fields'];
|
|
54
|
+
export const APP_PATHS = ['/sign-in', '/jobs', '/invoices', '/invoices/:id', '/contacts', '/contacts/:id', '/reconcile', '/reconcile/:id', '/xero-import', '/people', '/integrations', '/integrations/:id', '/account', '/appearance', '/invoicing-settings', '/invoicing-settings/:id', '/automations', '/price-list', '/price-list/:id', '/custom-fields'];
|
|
55
55
|
|
|
56
56
|
export function appPage({ defaultFamily }) {
|
|
57
57
|
return renderPage({
|
package/server/ui/app.js
CHANGED
|
@@ -25,7 +25,7 @@ import { isSearchKey, openSearch } from '/assets/ui/search.js';
|
|
|
25
25
|
import { alertStrip } from '/assets/ui/alerts.js';
|
|
26
26
|
import { initialsOf, personLabel } from '/assets/ui/person.js';
|
|
27
27
|
import { startTips } from '/assets/ui/tip.js';
|
|
28
|
-
import { crumbs, dialog, edgeFade, fieldError, h, holdButton, linksOpenWith, listFrame, listHead, notice, pageHeader, rowMenu, sectionTitle, settingRow, tileCard, tileSection, toast } from '/assets/ui/kit.js';
|
|
28
|
+
import { crumbs, dialog, edgeFade, fieldError, h, holdButton, linksOpenWith, listFrame, listHead, notice, pageHeader, pickOne, rowMenu, sectionTitle, settingRow, tileCard, tileSection, toast } from '/assets/ui/kit.js';
|
|
29
29
|
import { NAV_DEFAULT, NAV_LOCKED } from '/assets/ui/nav-pins.js';
|
|
30
30
|
|
|
31
31
|
const $ = (id) => document.getElementById(id);
|
|
@@ -332,7 +332,7 @@ function navigate(event) {
|
|
|
332
332
|
// The shared pieces a view module (invoicing.js) builds with. Its `go`
|
|
333
333
|
// takes a path, or a link's click event.
|
|
334
334
|
const ui = () => ({
|
|
335
|
-
h, flow, toast, notice, fieldError, pageHeader, listHead, listFrame, edgeFade, sectionTitle, dialog, me, holdButton, tileCard, tileSection, crumbs,
|
|
335
|
+
h, flow, toast, notice, fieldError, pageHeader, listHead, listFrame, edgeFade, sectionTitle, settingRow, dialog, pickOne, me, holdButton, tileCard, tileSection, crumbs,
|
|
336
336
|
go: (target, options) => (typeof target === 'string' ? go(target, options) : navigate(target)),
|
|
337
337
|
});
|
|
338
338
|
|
|
@@ -622,6 +622,11 @@ const CATALOGUE = {
|
|
|
622
622
|
const CATEGORIES = [['all', 'All'], ['accounting', 'Accounting'], ['lab', 'Lab software'], ['monitoring', 'Monitoring']];
|
|
623
623
|
const CATEGORY_LABEL = Object.fromEntries(CATEGORIES);
|
|
624
624
|
|
|
625
|
+
// The two notices every integration page can show, each one short line. The
|
|
626
|
+
// detail is on the ? (docs/INTEGRATIONS.md has the rest).
|
|
627
|
+
const readOnlyNotice = () => notice('Verified owner sign-in needed.', 'warn', 'Read only.');
|
|
628
|
+
const secretsKeyNotice = () => notice('Set FLOW_SECRETS_KEY, then restart Flow.', 'warn', 'No secrets key.');
|
|
629
|
+
|
|
625
630
|
function integrationLogo(integration, size = '') {
|
|
626
631
|
const file = CATALOGUE[integration.id]?.logo;
|
|
627
632
|
return h('span', { class: `fx-logo${size ? ` fx-logo--${size}` : ''}`, 'aria-hidden': 'true' },
|
|
@@ -631,7 +636,7 @@ function integrationLogo(integration, size = '') {
|
|
|
631
636
|
async function renderIntegrations(main) {
|
|
632
637
|
const state = { tab: 'all', category: 'all', q: '' };
|
|
633
638
|
const tabs = h('div', { class: 'fx-seg', role: 'tablist' });
|
|
634
|
-
const search = h('input', { class: 'fx-input fx-search', type: 'search', placeholder: 'Search
|
|
639
|
+
const search = h('input', { class: 'fx-input fx-search', type: 'search', placeholder: 'Search', 'aria-label': 'Search integrations' });
|
|
635
640
|
const category = h('select', { class: 'fx-select', 'aria-label': 'Category' },
|
|
636
641
|
CATEGORIES.map(([id, label]) => h('option', { value: id }, id === 'all' ? 'All categories' : label)));
|
|
637
642
|
const notices = h('div', { class: 'fx-fields' });
|
|
@@ -650,8 +655,8 @@ async function renderIntegrations(main) {
|
|
|
650
655
|
}
|
|
651
656
|
const canChange = Boolean(me.ownerActions?.allowed);
|
|
652
657
|
notices.replaceChildren(...[
|
|
653
|
-
canChange ? null :
|
|
654
|
-
body.secretsKey ? null :
|
|
658
|
+
canChange ? null : readOnlyNotice(),
|
|
659
|
+
body.secretsKey ? null : secretsKeyNotice(),
|
|
655
660
|
].filter(Boolean));
|
|
656
661
|
const list = [...body.integrations].sort((a, b) => a.name.localeCompare(b.name));
|
|
657
662
|
const connected = (i) => i.enabled;
|
|
@@ -706,8 +711,8 @@ function integrationTile(integration) {
|
|
|
706
711
|
}
|
|
707
712
|
|
|
708
713
|
|
|
709
|
-
// One integration's page (/integrations/<id>): its logo and
|
|
710
|
-
//
|
|
714
|
+
// One integration's page (/integrations/<id>): its logo, name and Connect
|
|
715
|
+
// (each step shown as it runs), the setup steps (closed), its settings, and the
|
|
711
716
|
// facts beside them.
|
|
712
717
|
async function renderIntegration(main, id) {
|
|
713
718
|
const host = h('div', { class: 'fx-int-page' }, h('div', { class: 'fx-skeleton' }));
|
|
@@ -733,6 +738,9 @@ async function renderIntegration(main, id) {
|
|
|
733
738
|
? h('span', { class: 'fx-status', 'data-tone': 'danger', 'data-int-status': '' }, 'Calls stopped')
|
|
734
739
|
: h('span', { class: 'fx-status', 'data-tone': STATE_TONE[current.state] ?? 'mute', 'data-int-status': '' }, STATE_LABEL[current.state] ?? current.state));
|
|
735
740
|
let shown = found;
|
|
741
|
+
// Settings: closed on a phone, open on a wider screen, then as the person
|
|
742
|
+
// leaves it while the page redraws (Connect).
|
|
743
|
+
let settingsOpen = !window.matchMedia?.('(max-width: 639px)').matches;
|
|
736
744
|
const alerts = alertStrip(ui(), 'integrations', {
|
|
737
745
|
scope: id,
|
|
738
746
|
onLoad: ({ danger }) => {
|
|
@@ -750,7 +758,7 @@ async function renderIntegration(main, id) {
|
|
|
750
758
|
// The host sets it: a value to read, not a box to type in.
|
|
751
759
|
rows.push(settingRow({
|
|
752
760
|
label: field.label,
|
|
753
|
-
|
|
761
|
+
help: 'Set by whoever hosts Flow.',
|
|
754
762
|
control: field.secret ? h('span', { class: 'fx-dim' }, 'Set') : h('code', { class: 'fx-setting__value' }, field.value),
|
|
755
763
|
}));
|
|
756
764
|
continue;
|
|
@@ -772,10 +780,11 @@ async function renderIntegration(main, id) {
|
|
|
772
780
|
const suggestion = field.suggest === 'callback' ? `${location.origin}/api/${current.id}/oauth/callback` : null;
|
|
773
781
|
rows.push(settingRow({
|
|
774
782
|
label: field.label,
|
|
775
|
-
hint: field.unreadable ? 'Stored with another key. Enter it again.' :
|
|
783
|
+
hint: field.unreadable ? 'Stored with another key. Enter it again.' : null,
|
|
784
|
+
help: field.help,
|
|
776
785
|
control: suggestion && !disabled
|
|
777
786
|
? h('div', { class: 'fx-setting__stack' }, input,
|
|
778
|
-
h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button', title: suggestion, onclick: () => { input.value = suggestion; navigator.clipboard?.writeText(suggestion); toast('Address filled in and copied.', 'success'); } }, 'Use this
|
|
787
|
+
h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button', title: suggestion, onclick: () => { input.value = suggestion; navigator.clipboard?.writeText(suggestion); toast('Address filled in and copied.', 'success'); } }, 'Use this address'))
|
|
779
788
|
: input,
|
|
780
789
|
}));
|
|
781
790
|
}
|
|
@@ -831,7 +840,6 @@ async function renderIntegration(main, id) {
|
|
|
831
840
|
const on = current.enabled;
|
|
832
841
|
const guide = current.guide;
|
|
833
842
|
const hostOwned = current.enabledBy === 'host';
|
|
834
|
-
const entry = CATALOGUE[current.id];
|
|
835
843
|
const mode = current.fields.find((f) => /_MODE$/.test(f.name));
|
|
836
844
|
const modeLabel = mode?.choices?.find((c) => c.value === mode.value)?.label ?? mode?.value;
|
|
837
845
|
const busy = progress && progress.steps.some((step) => step.state === 'running');
|
|
@@ -843,44 +851,41 @@ async function renderIntegration(main, id) {
|
|
|
843
851
|
|
|
844
852
|
host.replaceChildren(
|
|
845
853
|
crumbs(['/integrations', 'Integrations'], current.name),
|
|
846
|
-
h('header', { class: 'fx-int-hero' },
|
|
847
|
-
integrationLogo(current, 'xl'),
|
|
848
|
-
h('div', { class: 'fx-int-hero__text' },
|
|
849
|
-
h('h1', { class: 'fx-title' }, current.name),
|
|
850
|
-
h('p', { class: 'fx-int-hero__maker' }, entry?.maker ?? current.name, h('span', { class: 'fx-dim' }, CATEGORY_LABEL[entry?.category] ?? 'Integration')),
|
|
851
|
-
h('p', { class: 'fx-int-hero__blurb' }, entry?.blurb ?? ''),
|
|
852
|
-
h('div', { class: 'fx-int-hero__actions' },
|
|
853
|
-
primary,
|
|
854
|
-
on ? statusPill(current) : null,
|
|
855
|
-
progress ? connectProgress(progress) : null))),
|
|
856
854
|
h('div', { class: 'fx-int-page__grid' },
|
|
857
855
|
h('div', { class: 'fx-int-page__main' }, ...[
|
|
856
|
+
h('header', { class: 'fx-int-hero' },
|
|
857
|
+
integrationLogo(current, 'lg'),
|
|
858
|
+
h('h1', { class: 'fx-title' }, current.name),
|
|
859
|
+
h('div', { class: 'fx-int-hero__actions' },
|
|
860
|
+
on ? statusPill(current) : null,
|
|
861
|
+
primary,
|
|
862
|
+
progress ? connectProgress(progress) : null)),
|
|
858
863
|
alerts.element,
|
|
859
|
-
!canChange ?
|
|
860
|
-
!body.secretsKey ?
|
|
864
|
+
!canChange ? readOnlyNotice() : null,
|
|
865
|
+
!body.secretsKey ? secretsKeyNotice() : null,
|
|
861
866
|
current.state === 'failed' && current.error ? notice(current.error, 'danger', 'It could not start.') : null,
|
|
862
867
|
current.id === 'xero' && current.state === 'ready' ? h('section', { class: 'fx-int-section' }, xeroConnection(body.organisations, canChange)) : null,
|
|
863
868
|
current.id === 'seazona' ? h('section', { class: 'fx-int-section fx-card', style: { padding: '8px 20px 16px' } }, automationSection(ui(), 'seazona', 'Ready to invoice')) : null,
|
|
864
869
|
on && current.state === 'ready' ? personSettingsSection(current) : null,
|
|
865
870
|
current.id === 'xero' && current.state === 'ready' ? xeroImportSection(body.organisations) : null,
|
|
871
|
+
current.id === 'xero' ? h('section', { class: 'fx-int-section fx-card', style: { padding: '8px 20px 16px' } }, automationSection(ui(), 'xero', 'Contacts')) : null,
|
|
866
872
|
current.id === 'xero' ? h('section', { class: 'fx-int-section fx-card', style: { padding: '8px 20px 16px' } }, sendModeSection(ui())) : null,
|
|
867
|
-
guide ? h('
|
|
868
|
-
h('
|
|
873
|
+
guide ? h('details', { class: 'fx-int-section fx-int-fold' },
|
|
874
|
+
h('summary', { class: 'fx-int-section__title' }, 'Set up'),
|
|
869
875
|
h('ol', { class: 'fx-steps' }, guide.steps.map((step) => h('li', {}, step))),
|
|
870
|
-
guide.link ? h('a', { class: 'fx-btn fx-btn--sm', href: guide.link, target: '_blank', rel: 'noopener' },
|
|
871
|
-
rows.length ? h('
|
|
872
|
-
h('
|
|
876
|
+
guide.link ? h('a', { class: 'fx-btn fx-btn--sm', href: guide.link, target: '_blank', rel: 'noopener' }, `${current.name} site ↗`) : null) : null,
|
|
877
|
+
rows.length ? h('details', { class: 'fx-int-section fx-int-fold', open: settingsOpen, ontoggle: (event) => { settingsOpen = event.currentTarget.open; } },
|
|
878
|
+
h('summary', { class: 'fx-int-section__title' }, 'Settings'),
|
|
873
879
|
h('div', { class: 'fx-settings' }, rows),
|
|
874
880
|
canChange && on && inputs.size ? h('div', { class: 'fx-row fx-row--end' }, h('button', { class: 'fx-btn', type: 'button', onclick: () => save(undefined) }, 'Save changes')) : null) : null,
|
|
875
881
|
].filter(Boolean)),
|
|
876
882
|
h('aside', { class: 'fx-int-page__meta' },
|
|
877
883
|
h('dl', { class: 'fx-meta' }, ...[
|
|
878
884
|
['Status', statusPill(current)],
|
|
879
|
-
['Category', h('span', { class: 'fx-chip fx-chip--static' }, CATEGORY_LABEL[entry?.category] ?? 'Integration')],
|
|
880
885
|
modeLabel ? ['Mode', modeLabel] : null,
|
|
881
|
-
|
|
882
|
-
current.updatedAt ? ['
|
|
883
|
-
guide?.link ? ['
|
|
886
|
+
current.enabledBy === 'host' ? ['On by', 'Host'] : current.enabledBy === 'flow' ? ['On by', 'Owner'] : null,
|
|
887
|
+
current.updatedAt ? ['Changed', new Date(current.updatedAt).toLocaleString('en-AU', { dateStyle: 'medium', timeStyle: 'short' })] : null,
|
|
888
|
+
guide?.link ? ['Docs', h('a', { href: guide.link, target: '_blank', rel: 'noopener' }, `${current.name} ↗`)] : null,
|
|
884
889
|
].filter(Boolean).map(([label, value]) => h('div', {}, h('dt', {}, label), h('dd', {}, value)))))));
|
|
885
890
|
};
|
|
886
891
|
document.title = `${found.name} · Flow`;
|
|
@@ -910,7 +915,7 @@ function personSettingsSection(integration) {
|
|
|
910
915
|
}
|
|
911
916
|
input.disabled = false;
|
|
912
917
|
});
|
|
913
|
-
return settingRow({ label: setting.label,
|
|
918
|
+
return settingRow({ label: setting.label, help: setting.hint, control: input });
|
|
914
919
|
});
|
|
915
920
|
section.replaceChildren(section.firstChild, h('div', { class: 'fx-settings' }, rows));
|
|
916
921
|
}).catch(() => section.remove());
|
|
@@ -933,7 +938,7 @@ function xeroImportSection(organisations) {
|
|
|
933
938
|
const organisationId = organisations?.[0]?.id;
|
|
934
939
|
const canAct = ['owner', 'admin'].includes(me?.person?.role);
|
|
935
940
|
const count = h('span', { class: 'fx-num fx-xi-count' }, '…');
|
|
936
|
-
const copied = h('span', {}, '
|
|
941
|
+
const copied = h('span', {}, '');
|
|
937
942
|
const switchFor = (name, label) => {
|
|
938
943
|
const button = h('button', { class: 'fx-switch', type: 'button', role: 'switch', 'aria-label': label, 'aria-checked': 'false', disabled: true });
|
|
939
944
|
button.addEventListener('click', async () => {
|
|
@@ -954,16 +959,16 @@ function xeroImportSection(organisations) {
|
|
|
954
959
|
const makeUnmatched = switchFor('makeUnmatched', 'Make Flow invoices for the rest');
|
|
955
960
|
const section = h('section', { class: 'fx-set fx-int-section fx-card', style: { padding: '8px 20px 16px' } },
|
|
956
961
|
h('h2', { class: 'fx-set__title' }, 'Import'),
|
|
957
|
-
settingRow({ label: 'To check',
|
|
962
|
+
settingRow({ label: 'To check', help: 'Xero invoices that fit a Flow invoice or a Flow job.',
|
|
958
963
|
control: h('span', { class: 'fx-row' }, count, h('a', { class: 'fx-btn fx-btn--sm fx-btn--primary', href: '/xero-import', onclick: navigate }, 'Review')) }),
|
|
959
|
-
settingRow({ label: 'Link sure matches',
|
|
960
|
-
settingRow({ label: 'Make Flow invoices for the rest',
|
|
961
|
-
settingRow({ label: 'Xero history', hint: copied, control: h('a', { class: 'fx-btn fx-btn--sm', href: '/xero-import?show=all', onclick: navigate }, 'Search') }));
|
|
964
|
+
settingRow({ label: 'Link sure matches', help: 'Same client, total and week, and only one fit. Flow links them.', control: autoLink }),
|
|
965
|
+
settingRow({ label: 'Make Flow invoices for the rest', help: 'Off: they stay in the Xero history.', control: makeUnmatched }),
|
|
966
|
+
settingRow({ label: 'Xero history', hint: copied, help: 'Every Xero invoice Flow copied. A search makes no Xero call.', control: h('a', { class: 'fx-btn fx-btn--sm', href: '/xero-import?show=all', onclick: navigate }, 'Search') }));
|
|
962
967
|
if (organisationId) {
|
|
963
968
|
flow(`/api/xero/import?organisationId=${organisationId}&show=check&limit=1`)
|
|
964
969
|
.then(({ counts: c, settings }) => {
|
|
965
970
|
count.replaceChildren(String(c.job_fits + c.invoice_fits));
|
|
966
|
-
copied.replaceChildren(`${Object.values(c).reduce((a, b) => a + b, 0)}
|
|
971
|
+
copied.replaceChildren(`${Object.values(c).reduce((a, b) => a + b, 0)} copied, ${c.linked} in Flow`);
|
|
967
972
|
autoLink.setAttribute('aria-checked', String(settings.autoLink));
|
|
968
973
|
makeUnmatched.setAttribute('aria-checked', String(settings.makeUnmatched));
|
|
969
974
|
autoLink.disabled = makeUnmatched.disabled = !canAct;
|
|
@@ -1009,8 +1014,11 @@ function xeroConnection(organisations, canChange) {
|
|
|
1009
1014
|
if (canChange) {
|
|
1010
1015
|
control = h('button', { class: `fx-btn fx-btn--sm${status.connected && !stopped && !refused ? '' : ' fx-btn--primary'}`, type: 'button', onclick: async () => {
|
|
1011
1016
|
try {
|
|
1017
|
+
// A Custom Connection connects at once; a Xero app sends the
|
|
1018
|
+
// browser to Xero to approve.
|
|
1012
1019
|
const { authorizeUrl } = await flow('/api/xero/connect', { method: 'POST', body: JSON.stringify({ organisationId: organisation.id }) });
|
|
1013
|
-
location.assign(authorizeUrl);
|
|
1020
|
+
if (authorizeUrl) location.assign(authorizeUrl);
|
|
1021
|
+
else location.reload();
|
|
1014
1022
|
} catch (error) {
|
|
1015
1023
|
toast(error.message, 'danger');
|
|
1016
1024
|
}
|
package/server/ui/automations.js
CHANGED
|
@@ -9,7 +9,7 @@ const DOCS = 'https://github.com/arcforgelabs/flow/blob/main/docs/AUTOMATIONS.md
|
|
|
9
9
|
|
|
10
10
|
// A section of the automations kept at `place`, filled when they load.
|
|
11
11
|
export function automationSection(ui, place, title) {
|
|
12
|
-
const { h, flow, toast, notice, me } = ui;
|
|
12
|
+
const { h, flow, toast, notice, me, settingRow } = ui;
|
|
13
13
|
const section = h('section', { class: 'fx-set' }, h('h2', { class: 'fx-set__title' }, title), h('div', { class: 'fx-skeleton' }));
|
|
14
14
|
const disabled = !['owner', 'admin'].includes(me?.person.role);
|
|
15
15
|
|
|
@@ -27,9 +27,7 @@ export function automationSection(ui, place, title) {
|
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
const row = (
|
|
31
|
-
h('div', { class: 'fx-set__text' }, h('span', { class: 'fx-set__name' }, name), h('span', { class: 'fx-set__hint' }, hint)),
|
|
32
|
-
h('div', { class: 'fx-set__control' }, control));
|
|
30
|
+
const row = (label, help, control) => settingRow({ label, help, control });
|
|
33
31
|
|
|
34
32
|
// A menu of the statuses the work system reported, each with its job count.
|
|
35
33
|
// A status saved earlier stays in the menu even when no job has it now.
|
package/server/ui/contacts.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// Contacts (/contacts, /contacts/<id>). Flow's own customers and suppliers
|
|
2
2
|
// (server/core/contacts.js, docs/CONTACTS.md). The list and the record work
|
|
3
3
|
// with no integration on. When Xero is connected, the record says whether
|
|
4
|
-
// that contact is in Xero and, for an owner or admin, offers Create.
|
|
4
|
+
// that contact is in Xero and, for an owner or admin, offers Link and Create.
|
|
5
5
|
import { initialsOf } from '/assets/ui/person.js';
|
|
6
|
+
import { xeroContactActions } from '/assets/ui/xero-contact.js';
|
|
6
7
|
|
|
7
8
|
const DOCS = 'https://github.com/arcforgelabs/flow/blob/main/docs/CONTACTS.md';
|
|
8
9
|
const KINDS = [['customer', 'Customer'], ['supplier', 'Supplier']];
|
|
@@ -225,36 +226,28 @@ async function loadXero(ui, contact, canEdit, host) {
|
|
|
225
226
|
return;
|
|
226
227
|
}
|
|
227
228
|
if (match.state === 'off') return;
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
const confirmed = await ui.dialog({
|
|
234
|
-
title: `Create ${contact.name} in Xero?`,
|
|
235
|
-
body: h('p', {}, 'Flow adds this client to Xero from the contact.'),
|
|
236
|
-
confirm: 'Create',
|
|
237
|
-
});
|
|
238
|
-
if (!confirmed) return;
|
|
239
|
-
button.disabled = true;
|
|
240
|
-
try {
|
|
241
|
-
await flow('/api/xero/contacts', { method: 'POST', body: JSON.stringify({ organisationId, contactId: contact.id }) });
|
|
242
|
-
toast(`${contact.name} is in Xero.`, 'success');
|
|
243
|
-
host.replaceChildren(h('section', { class: 'fx-card' }, notice(`${contact.name} is in Xero.`, 'success')));
|
|
244
|
-
} catch (error) {
|
|
245
|
-
toast(error.message, error.status === 429 ? 'info' : 'danger');
|
|
246
|
-
if (error.retryAfterMs) ui.holdButton(button, error.retryAfterMs);
|
|
247
|
-
else button.disabled = false;
|
|
248
|
-
}
|
|
249
|
-
},
|
|
250
|
-
}, 'Create')
|
|
251
|
-
: null;
|
|
229
|
+
const actions = canEdit ? xeroContactActions(ui, {
|
|
230
|
+
organisationId, contact, offerLink: match.offerLink, offerCreate: match.offerCreate,
|
|
231
|
+
done: (outcome) => host.replaceChildren(h('section', { class: 'fx-card' },
|
|
232
|
+
notice(outcome === 'linked' ? `${contact.name} is linked to Xero.` : `${contact.name} is in Xero.`, 'success'))),
|
|
233
|
+
}) : [];
|
|
252
234
|
const text = match.state === 'matched'
|
|
253
235
|
? `In Xero${match.name ? ` as ${match.name}` : ''}.`
|
|
254
236
|
: match.state === 'ambiguous'
|
|
255
237
|
? `More than one Xero contact is named "${contact.name}".`
|
|
256
238
|
: `No Xero contact is named "${contact.name}".`;
|
|
239
|
+
// Fields both sides changed (Two-way edits): alert only. A person makes the
|
|
240
|
+
// two the same, here or in Xero, and the next check clears it.
|
|
241
|
+
const differs = match.differs ?? [];
|
|
242
|
+
const clash = differs.length
|
|
243
|
+
? notice(`Changed in both: ${differs.map((d) => FIELD_LABELS[d.field] ?? d.field).join(', ')}. Make Flow and Xero the same.`, 'warn', 'Xero differs.')
|
|
244
|
+
: null;
|
|
257
245
|
host.replaceChildren(h('section', { class: 'fx-card fx-offer' },
|
|
258
|
-
notice(text, match.state === 'matched' ? 'success' : 'danger', match.state === 'matched' ? 'Xero.' : 'Xero.'),
|
|
259
|
-
|
|
246
|
+
clash ?? notice(text, match.state === 'matched' ? 'success' : 'danger', match.state === 'matched' ? 'Xero.' : 'Xero.'),
|
|
247
|
+
...actions));
|
|
260
248
|
}
|
|
249
|
+
|
|
250
|
+
const FIELD_LABELS = {
|
|
251
|
+
name: 'Name', firstName: 'First name', lastName: 'Last name', email: 'Email', phone: 'Phone',
|
|
252
|
+
mobile: 'Mobile', accountNumber: 'Account', taxNumber: 'Tax number', address: 'Address',
|
|
253
|
+
};
|