@arcforgelabs/flow 2026.10.48-1 → 2026.10.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/core/automations.js +34 -0
- package/server/core/pipeline.js +46 -6
- package/server/integrations/port.js +1 -1
- package/server/integrations/seazona/feed.js +41 -4
- package/server/integrations/sentry/index.js +2 -2
- package/server/integrations/xero/client.js +23 -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 +15 -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/routes.js +16 -2
- package/server/ui/app.js +44 -39
- package/server/ui/automations.js +2 -4
- package/server/ui/contacts.js +20 -27
- package/server/ui/flow.css +120 -40
- package/server/ui/invoice-rules.js +2 -4
- package/server/ui/invoicing-settings.js +3 -7
- package/server/ui/invoicing.js +49 -75
- package/server/ui/kit.js +101 -10
- package/server/ui/line-mappings.js +2 -4
- package/server/ui/mobile.js +48 -3
- package/server/ui/price-list.js +3 -5
- package/server/ui/routes.js +1 -1
- package/server/ui/xero-contact.js +101 -0
|
@@ -68,13 +68,19 @@ function xeroDate(value) {
|
|
|
68
68
|
|
|
69
69
|
const problem = (code, message, extra = {}) => ({ code, message, ...extra });
|
|
70
70
|
|
|
71
|
+
// A person can always link the Flow contact to a Xero contact of another
|
|
72
|
+
// name (create-contact.js, linkCustomer): no Xero call.
|
|
71
73
|
function missingContact(name, { offerCreate }) {
|
|
72
74
|
const message = offerCreate
|
|
73
|
-
? `No Xero contact is named "${name}".
|
|
74
|
-
: `No Xero contact is named "${name}".
|
|
75
|
-
return problem('xero_contact_missing', message, { short: 'Client not in Xero', ...(offerCreate ? { offerCreate: true } : {}) });
|
|
75
|
+
? `No Xero contact is named "${name}". Link the Xero client, or create it from this contact.`
|
|
76
|
+
: `No Xero contact is named "${name}". Link the Xero client, or add it in Xero, then sync.`;
|
|
77
|
+
return problem('xero_contact_missing', message, { short: 'Client not in Xero', offerLink: true, ...(offerCreate ? { offerCreate: true } : {}) });
|
|
76
78
|
}
|
|
77
79
|
|
|
80
|
+
const twiceContact = (name) => problem('xero_contact_ambiguous',
|
|
81
|
+
`More than one Xero contact is named "${name}". Link the right one, or merge or rename them in Xero, then sync.`,
|
|
82
|
+
{ short: 'Client twice in Xero', offerLink: true });
|
|
83
|
+
|
|
78
84
|
// ---------------------------------------------------------------------------
|
|
79
85
|
// The document to send
|
|
80
86
|
|
|
@@ -300,7 +306,7 @@ async function previewOne(db, runtime, organisationId, invoiceId) {
|
|
|
300
306
|
const match = await matchContactFor(db, organisationId, { contactId: invoice.contactId, name: document.contact.name });
|
|
301
307
|
const offerCreate = match.state === 'missing' && runtime.settings.createContacts && writesAllowed(runtime.settings);
|
|
302
308
|
if (match.state === 'missing') problems.push(missingContact(document.contact.name, { offerCreate }));
|
|
303
|
-
if (match.state === 'ambiguous') problems.push(
|
|
309
|
+
if (match.state === 'ambiguous') problems.push(twiceContact(document.contact.name));
|
|
304
310
|
const items = await itemsByCode(db, organisationId, document.lines.map((l) => l.itemCode));
|
|
305
311
|
const sendMode = await sendModeFor(db, organisationId, invoice.contactId);
|
|
306
312
|
const { draft, problems: lineProblems } = buildDraft(document, { settings: runtime.settings, contact: match.contact, items, sendMode });
|
|
@@ -399,7 +405,9 @@ export async function checkDrafts(db, runtime, { organisationId, invoiceIds }) {
|
|
|
399
405
|
for (const preview of invoices) {
|
|
400
406
|
found.set(preview.invoiceId, preview.problems
|
|
401
407
|
.filter((p) => p.blocking !== false && p.code !== 'invoice_not_issued')
|
|
402
|
-
.map(({ code, message, short, offerCreate }) => ({
|
|
408
|
+
.map(({ code, message, short, offerCreate, offerLink }) => ({
|
|
409
|
+
code, message, ...(short ? { short } : {}), ...(offerCreate ? { offerCreate: true } : {}), ...(offerLink ? { offerLink: true } : {}),
|
|
410
|
+
})));
|
|
403
411
|
}
|
|
404
412
|
}
|
|
405
413
|
return found;
|
|
@@ -484,7 +492,7 @@ async function pushOne(db, runtime, client, organisationId, invoiceId, { pushedB
|
|
|
484
492
|
throw xeroError(match.state === 'missing' ? 'xero_contact_missing' : 'xero_contact_ambiguous',
|
|
485
493
|
match.state === 'missing'
|
|
486
494
|
? missingContact(document.contact.name, { offerCreate }).message
|
|
487
|
-
:
|
|
495
|
+
: twiceContact(document.contact.name).message,
|
|
488
496
|
{ status: 409 });
|
|
489
497
|
}
|
|
490
498
|
const items = await itemsByCode(db, organisationId, document.lines.map((l) => l.itemCode));
|
|
@@ -3,6 +3,7 @@ import { sql } from 'drizzle-orm';
|
|
|
3
3
|
import { deadHoldSince, deadSince, enqueueJob, runNextJob } from '../../core/queue.js';
|
|
4
4
|
import { isCallsStopped } from '../call-guard.js';
|
|
5
5
|
import { lastSync, syncCatalogue } from './catalogue.js';
|
|
6
|
+
import { syncContactsForAll } from './contact-sync.js';
|
|
6
7
|
import { mirrorState } from './lookup.js';
|
|
7
8
|
import { MIRROR_RESOURCES, mirrorXero } from './mirror.js';
|
|
8
9
|
import { matchXero } from './links.js';
|
|
@@ -17,7 +18,8 @@ import { runWebhookWork, WEBHOOK_REFRESH_MS, webhooksActive } from './webhooks.j
|
|
|
17
18
|
// same job is queued again for REFRESH_MS later and reads only what changed
|
|
18
19
|
// since its cursor (issue #131), so the copy stays fresh (lookup.js). While
|
|
19
20
|
// Xero's webhooks arrive (webhooks.js), it waits WEBHOOK_REFRESH_MS instead.
|
|
20
|
-
// It only reads from Xero
|
|
21
|
+
// It only reads from Xero; the one write on its tick is a contact edit, with
|
|
22
|
+
// "Two-way edits" on (contact-sync.js).
|
|
21
23
|
|
|
22
24
|
export const IMPORT_KIND = 'xero.import';
|
|
23
25
|
const IMPORT_KEY = 'history';
|
|
@@ -219,6 +221,8 @@ export function createImportWorker({ db, runtime, log = console, timers = { setT
|
|
|
219
221
|
running = (async () => {
|
|
220
222
|
try {
|
|
221
223
|
await refreshStaleCatalogues(db, runtime, { log });
|
|
224
|
+
// Two-way contact edits, when that setting is on (contact-sync.js).
|
|
225
|
+
await syncContactsForAll(db, runtime, { log });
|
|
222
226
|
await keepReadsQueued(db, runtime, { startedAtMs });
|
|
223
227
|
await reportStaleCopies(db, runtime, { stale, log });
|
|
224
228
|
await runWebhookWork(db, runtime, { log, holds });
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { fileURLToPath } from 'node:url';
|
|
2
2
|
import { sql } from 'drizzle-orm';
|
|
3
|
+
import { listReview } from '../../core/review.js';
|
|
3
4
|
import { guardAlerts } from '../call-guard.js';
|
|
5
|
+
import { CONTACT_DIFFERS } from './contact-sync.js';
|
|
4
6
|
import { deleteXeroInvoice } from './delete-invoice.js';
|
|
5
7
|
import { createCustomer } from './create-contact.js';
|
|
6
8
|
import { checkDrafts, documentFacts, pushInvoice, pushInvoices } from './drafts.js';
|
|
@@ -18,7 +20,7 @@ import { readChangesFirst } from './webhooks.js';
|
|
|
18
20
|
// XERO_MODE says which Xero it acts on. Its tables are created only while it
|
|
19
21
|
// is on (docs/INTEGRATIONS.md).
|
|
20
22
|
|
|
21
|
-
export const XERO_TABLES = Object.freeze(['xero_connections', 'xero_contacts', 'xero_items', 'xero_sync_runs', 'xero_invoice_pushes', 'xero_payment_cursors', 'xero_invoices', 'xero_credit_notes', 'xero_payments', 'xero_mirror_cursors', 'xero_call_guard', 'xero_calls', 'xero_webhook_events', 'xero_daily_checks', 'xero_invoice_links', 'xero_import_settings']);
|
|
23
|
+
export const XERO_TABLES = Object.freeze(['xero_connections', 'xero_contacts', 'xero_items', 'xero_sync_runs', 'xero_invoice_pushes', 'xero_payment_cursors', 'xero_invoices', 'xero_credit_notes', 'xero_payments', 'xero_mirror_cursors', 'xero_call_guard', 'xero_calls', 'xero_webhook_events', 'xero_daily_checks', 'xero_invoice_links', 'xero_import_settings', 'xero_contact_sync']);
|
|
22
24
|
|
|
23
25
|
// What an owner enters in Settings, Integrations (server/integrations/setup.js).
|
|
24
26
|
// The refresh token's key comes from Flow's secrets key.
|
|
@@ -113,7 +115,18 @@ export function createXeroAdapter({ runtime: given = null } = {}) {
|
|
|
113
115
|
async alerts({ organisationId }) {
|
|
114
116
|
const connection = await readConnection(database, organisationId);
|
|
115
117
|
if (!connection || connection.mode !== current().settings.mode) return [];
|
|
116
|
-
|
|
118
|
+
const alerts = guardAlerts('Xero', await current().guardFor(database).status(connection.tenantId));
|
|
119
|
+
// Contacts both sides changed (Two-way edits, contact-sync.js).
|
|
120
|
+
const differ = await listReview(database, organisationId, { kind: CONTACT_DIFFERS, limit: 500 });
|
|
121
|
+
if (differ.length) {
|
|
122
|
+
alerts.push({
|
|
123
|
+
id: 'contacts-differ',
|
|
124
|
+
tone: 'warn',
|
|
125
|
+
title: `${differ.length === 1 ? '1 contact differs' : `${differ.length} contacts differ`} in Flow and Xero. Open the contact, then make both sides the same.`,
|
|
126
|
+
members: differ.map((item) => `${item.flowId}:${JSON.stringify(item.evidence?.fields?.map((f) => f.field) ?? [])}`),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
return alerts;
|
|
117
130
|
},
|
|
118
131
|
// One issued invoice to a Xero draft (drafts.js). The Flow invoice id
|
|
119
132
|
// and version are the idempotency key.
|
|
@@ -14,12 +14,25 @@ export const MIRROR_FRESH_MS = 24 * 60 * 60 * 1000;
|
|
|
14
14
|
// and must still match jobs.
|
|
15
15
|
export const PROOF_RESOURCES = Object.freeze(['invoices', 'credit_notes']);
|
|
16
16
|
|
|
17
|
+
// `contact` is the Flow contact a new client becomes (docs/CONTACTS.md,
|
|
18
|
+
// "Add Xero clients"). A contact Xero marks a supplier only still links by
|
|
19
|
+
// name, but never becomes a Flow customer (`adoptable` false).
|
|
17
20
|
export async function listCustomers(db, organisationId) {
|
|
18
21
|
const { rows } = await db.execute(sql`
|
|
19
|
-
select xero_contact_id, name,
|
|
22
|
+
select xero_contact_id, name, first_name, last_name, email, phone, mobile, account_number, tax_number, address,
|
|
23
|
+
is_customer, is_supplier
|
|
24
|
+
from xero_contacts
|
|
20
25
|
where organisation_id = ${organisationId} and status = 'ACTIVE'
|
|
21
26
|
order by name`);
|
|
22
|
-
return rows.map((r) => ({
|
|
27
|
+
return rows.map((r) => ({
|
|
28
|
+
id: r.xero_contact_id,
|
|
29
|
+
name: r.name,
|
|
30
|
+
adoptable: Boolean(r.is_customer || !r.is_supplier),
|
|
31
|
+
contact: {
|
|
32
|
+
kind: 'customer', name: r.name, firstName: r.first_name, lastName: r.last_name, email: r.email, phone: r.phone,
|
|
33
|
+
mobile: r.mobile, accountNumber: r.account_number, taxNumber: r.tax_number, address: r.address,
|
|
34
|
+
},
|
|
35
|
+
}));
|
|
23
36
|
}
|
|
24
37
|
|
|
25
38
|
// Xero's items, from Flow's copy (catalogue.js, read on each sync), for the
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
-- Two-way contact edits (docs/CONTACTS.md, xero/contact-sync.js). One row for
|
|
2
|
+
-- each linked pair: the fields both sides last agreed on (the merge base), so
|
|
3
|
+
-- Flow can tell which side changed a field. `clashes` lists the fields both
|
|
4
|
+
-- sides changed; they wait for a person. The Xero adapter's own table.
|
|
5
|
+
CREATE TABLE IF NOT EXISTS "xero_contact_sync" (
|
|
6
|
+
"organisation_id" uuid NOT NULL REFERENCES "organisations"("id"),
|
|
7
|
+
"xero_contact_id" text NOT NULL,
|
|
8
|
+
"contact_id" uuid NOT NULL,
|
|
9
|
+
"base" jsonb NOT NULL,
|
|
10
|
+
"clashes" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
|
11
|
+
"last_error" text,
|
|
12
|
+
"checked_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
13
|
+
"pushed_at" timestamp with time zone,
|
|
14
|
+
CONSTRAINT "xero_contact_sync_pk" PRIMARY KEY("organisation_id", "xero_contact_id")
|
|
15
|
+
);
|
|
@@ -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';
|
|
@@ -31,6 +31,8 @@ import { messagePage as themedMessagePage } from '../../ui/page.js';
|
|
|
31
31
|
// GET /contacts/match?organisationId=&contactId= any signed-in person:
|
|
32
32
|
// whether this Flow contact is in the copy
|
|
33
33
|
// POST /contacts owner or admin: create the Xero contact
|
|
34
|
+
// POST /contacts/link owner or admin: link a Flow contact to a
|
|
35
|
+
// Xero contact of another name (no Xero call)
|
|
34
36
|
// GET /items?organisationId= any signed-in person: the copy
|
|
35
37
|
// POST /invoices/preview any signed-in person: the Xero drafts Flow
|
|
36
38
|
// would make, and what stops them (no Xero calls)
|
|
@@ -274,7 +276,8 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, butto
|
|
|
274
276
|
|
|
275
277
|
router.get('/contacts', ...signedIn, handle(log, async (req, res) => {
|
|
276
278
|
const organisationId = await requireOrganisation(db, req.query.organisationId);
|
|
277
|
-
|
|
279
|
+
const limit = Number(req.query.limit) || undefined;
|
|
280
|
+
res.json({ ok: true, contacts: await listContacts(db, organisationId, { q: typeof req.query.q === 'string' ? req.query.q : '', limit }) });
|
|
278
281
|
}));
|
|
279
282
|
|
|
280
283
|
// Whether this Flow contact is a Xero contact, and whether Create is offered.
|
|
@@ -293,6 +296,17 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, butto
|
|
|
293
296
|
res.status(result.outcome === 'created' ? 201 : 200).json({ ok: true, ...result });
|
|
294
297
|
}));
|
|
295
298
|
|
|
299
|
+
// A person links a Flow contact to a Xero contact of another name. Flow's
|
|
300
|
+
// identity map only: no Xero call.
|
|
301
|
+
router.post('/contacts/link', ...manager, handle(log, async (req, res) => {
|
|
302
|
+
const organisationId = await requireOrganisation(db, req.body?.organisationId);
|
|
303
|
+
if (typeof req.body?.contactId !== 'string' || !UUID.test(req.body.contactId)) throw new FlowError('invalid', 'contactId must be a Flow contact id');
|
|
304
|
+
if (typeof req.body?.xeroContactId !== 'string' || !req.body.xeroContactId.trim()) throw new FlowError('invalid', 'xeroContactId must be a Xero contact id');
|
|
305
|
+
const result = await linkCustomer(db, runtime, { organisationId, contactId: req.body.contactId, xeroContactId: req.body.xeroContactId.trim() });
|
|
306
|
+
log.info?.(`[xero] organisation ${organisationId}: person ${req.person.id} linked Xero contact ${result.xeroContactId} to Flow contact ${req.body.contactId}`);
|
|
307
|
+
res.json({ ok: true, ...result });
|
|
308
|
+
}));
|
|
309
|
+
|
|
296
310
|
router.get('/items', ...signedIn, handle(log, async (req, res) => {
|
|
297
311
|
const organisationId = await requireOrganisation(db, req.query.organisationId);
|
|
298
312
|
res.json({ ok: true, items: await listItems(db, organisationId) });
|
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;
|
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
|
+
};
|