@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
package/server/ui/routes.js
CHANGED
|
@@ -21,7 +21,7 @@ const themeDir = join(here, '..', 'theme');
|
|
|
21
21
|
const logoDir = join(here, 'logos');
|
|
22
22
|
const LOGO_FILES = new Set(['xero.svg', 'seazona.svg', 'sentry.svg']);
|
|
23
23
|
const THEME_FILES = new Set(['themes.js', 'color-contrast.js', 'openclaw-palettes.js', 'font-families.js']);
|
|
24
|
-
export const UI_FILES = new Set(['flow.css', 'appearance.js', 'clerk-appearance.js', 'app.js', 'invoicing.js', 'invoice-notes.js', 'job-change.js', 'sidecar.js', 'invoicing-settings.js', 'automations.js', 'invoice-rules.js', 'line-mappings.js', 'price-list.js', 'price-match.js', 'price-reconcile.js', 'custom-fields.js', 'contacts.js', 'jobs.js', 'job-board.js', 'reconcile.js', 'xero-import.js', 'xero-usage.js', 'xero-usage-model.js', 'job-view.js', 'list-views.js', 'saved-views.js', 'monitoring.js', 'search.js', 'alerts.js', 'bulk-bar.js', 'view-bar.js', 'tip.js', 'kit.js', 'columns.js', 'mobile.js', 'person.js', 'pager.js', 'nav-pins.js', 'flow-mark.svg']);
|
|
24
|
+
export const UI_FILES = new Set(['flow.css', 'appearance.js', 'clerk-appearance.js', 'app.js', 'invoicing.js', 'invoice-notes.js', 'job-change.js', 'sidecar.js', 'invoicing-settings.js', 'automations.js', 'invoice-rules.js', 'line-mappings.js', 'price-list.js', 'price-match.js', 'price-reconcile.js', 'custom-fields.js', 'contacts.js', 'xero-contact.js', 'jobs.js', 'job-board.js', 'reconcile.js', 'xero-import.js', 'xero-usage.js', 'xero-usage-model.js', 'job-view.js', 'list-views.js', 'saved-views.js', 'monitoring.js', 'search.js', 'alerts.js', 'bulk-bar.js', 'view-bar.js', 'tip.js', 'kit.js', 'columns.js', 'mobile.js', 'person.js', 'pager.js', 'nav-pins.js', 'flow-mark.svg']);
|
|
25
25
|
const iconDir = join(dirname(require.resolve('lucide-static/package.json')), 'icons');
|
|
26
26
|
const ICON_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.svg$/;
|
|
27
27
|
const MAX_AGE = '1h';
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Link and Create for a Flow contact that is not a Xero contact
|
|
2
|
+
// (docs/CONTACTS.md). The Pending invoice and the contact page both show
|
|
3
|
+
// them. Link picks the Xero client when the names differ or Xero has the
|
|
4
|
+
// name twice; Flow writes only its own link. Create adds the client to Xero.
|
|
5
|
+
// `done(outcome)` runs after either one works.
|
|
6
|
+
|
|
7
|
+
const SMALL = new Set(['the', 'and', 'of', 'pty', 'ltd', 'limited', 'co', 'inc']);
|
|
8
|
+
|
|
9
|
+
function words(name) {
|
|
10
|
+
return new Set(String(name ?? '').toLowerCase().replace(/&/g, ' and ').replace(/[^a-z0-9]+/g, ' ').split(' ')
|
|
11
|
+
.filter((word) => word && !SMALL.has(word)));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// How many words two names share.
|
|
15
|
+
export function closeness(a, b) {
|
|
16
|
+
const mine = words(a);
|
|
17
|
+
let shared = 0;
|
|
18
|
+
for (const word of words(b)) if (mine.has(word)) shared += 1;
|
|
19
|
+
return shared;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// The Xero contacts nearest the name first, then A to Z.
|
|
23
|
+
export function nearestFirst(name, contacts) {
|
|
24
|
+
return contacts
|
|
25
|
+
.map((contact) => ({ contact, score: closeness(name, contact.name) }))
|
|
26
|
+
.sort((a, b) => b.score - a.score || a.contact.name.localeCompare(b.contact.name))
|
|
27
|
+
.map(({ contact }) => contact);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function xeroContacts(ui, organisationId, q) {
|
|
31
|
+
const query = new URLSearchParams({ organisationId, q, limit: '1000' });
|
|
32
|
+
const { contacts } = await ui.flow(`/api/xero/contacts?${query}`);
|
|
33
|
+
return contacts.filter((contact) => contact.status === 'ACTIVE');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function xeroContactActions(ui, { organisationId, contact, offerLink = false, offerCreate = false, done }) {
|
|
37
|
+
const { h, flow, toast } = ui;
|
|
38
|
+
const failed = (button, error) => {
|
|
39
|
+
toast(error.message, error.status === 429 ? 'info' : 'danger');
|
|
40
|
+
if (error.retryAfterMs) ui.holdButton(button, error.retryAfterMs);
|
|
41
|
+
else button.disabled = false;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const link = offerLink ? h('button', {
|
|
45
|
+
class: `fx-btn ${offerCreate ? 'fx-btn--ghost' : 'fx-btn--primary'}`, type: 'button', title: 'Link this client to its Xero client',
|
|
46
|
+
onclick: async (event) => {
|
|
47
|
+
const button = event.currentTarget;
|
|
48
|
+
const picked = await ui.pickOne({
|
|
49
|
+
title: `Link ${contact.name} to Xero`,
|
|
50
|
+
intro: 'Pick the Xero client. Flow links the two. Xero does not change.',
|
|
51
|
+
label: 'Xero clients',
|
|
52
|
+
find: 'Find a Xero client',
|
|
53
|
+
remote: true,
|
|
54
|
+
load: async (q) => nearestFirst(contact.name, await xeroContacts(ui, organisationId, q)),
|
|
55
|
+
item: (row) => ({ value: row.xeroContactId, title: row.name, meta: [row.email, row.accountNumber].filter(Boolean).join(' · ') }),
|
|
56
|
+
empty: 'No Xero client has that name. Sync Xero, or use Create.',
|
|
57
|
+
confirm: 'Link',
|
|
58
|
+
});
|
|
59
|
+
if (!picked) return;
|
|
60
|
+
button.disabled = true;
|
|
61
|
+
try {
|
|
62
|
+
await flow('/api/xero/contacts/link', { method: 'POST', body: JSON.stringify({ organisationId, contactId: contact.id, xeroContactId: picked.xeroContactId }) });
|
|
63
|
+
toast(`${contact.name} is linked to ${picked.name} in Xero.`, 'success');
|
|
64
|
+
await done('linked');
|
|
65
|
+
} catch (error) {
|
|
66
|
+
failed(button, error);
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
}, 'Link') : null;
|
|
70
|
+
|
|
71
|
+
const create = offerCreate ? h('button', {
|
|
72
|
+
class: 'fx-btn fx-btn--primary', type: 'button', title: 'Create this client in Xero',
|
|
73
|
+
onclick: async (event) => {
|
|
74
|
+
const button = event.currentTarget;
|
|
75
|
+
// A close name in Xero is often this client: say so before a second one.
|
|
76
|
+
let close = [];
|
|
77
|
+
try {
|
|
78
|
+
close = (await xeroContacts(ui, organisationId, '')).filter((row) => closeness(contact.name, row.name) > 0);
|
|
79
|
+
} catch { /* Create still works without the list. */ }
|
|
80
|
+
const names = nearestFirst(contact.name, close).slice(0, 3).map((row) => row.name);
|
|
81
|
+
const confirmed = await ui.dialog({
|
|
82
|
+
title: `Create ${contact.name} in Xero?`,
|
|
83
|
+
body: h('div', { class: 'fx-pick__body' },
|
|
84
|
+
h('p', {}, 'Flow adds this client to Xero from the contact.'),
|
|
85
|
+
names.length ? ui.notice(`Xero has ${names.join(', ')}. If one is this client, use Link.`, 'info', 'Close names.') : null),
|
|
86
|
+
confirm: 'Create',
|
|
87
|
+
});
|
|
88
|
+
if (!confirmed) return;
|
|
89
|
+
button.disabled = true;
|
|
90
|
+
try {
|
|
91
|
+
await flow('/api/xero/contacts', { method: 'POST', body: JSON.stringify({ organisationId, contactId: contact.id }) });
|
|
92
|
+
toast(`${contact.name} is in Xero.`, 'success');
|
|
93
|
+
await done('created');
|
|
94
|
+
} catch (error) {
|
|
95
|
+
failed(button, error);
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
}, 'Create') : null;
|
|
99
|
+
|
|
100
|
+
return [link, create].filter(Boolean);
|
|
101
|
+
}
|