@arcforgelabs/flow 2026.11.9 → 2026.11.10
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/api/invoicing.js +18 -2
- package/server/core/contacts.js +79 -5
- package/server/core/invoice-rules.js +14 -1
- package/server/core/invoices.js +2 -0
- package/server/core/invoicing-rules.js +55 -4
- package/server/core/ledger.js +25 -2
- package/server/core/merge-contacts.js +182 -0
- package/server/core/pipeline.js +77 -13
- package/server/db/migrations/0030_contact_hidden.sql +1 -0
- package/server/db/migrations/0031_bill_to.sql +10 -0
- package/server/db/migrations/0032_contact_merge.sql +12 -0
- package/server/db/migrations/meta/0030_snapshot.json +5076 -0
- package/server/db/migrations/meta/0031_snapshot.json +5123 -0
- package/server/db/migrations/meta/0032_snapshot.json +5167 -0
- package/server/db/migrations/meta/_journal.json +21 -0
- package/server/db/schema.js +28 -0
- package/server/integrations/xero/create-contact.js +15 -1
- package/server/integrations/xero/delete-invoice.js +1 -1
- package/server/integrations/xero/drafts.js +20 -5
- package/server/integrations/xero/ledger.js +7 -4
- package/server/integrations/xero/links.js +85 -18
- package/server/integrations/xero/lookup.js +7 -4
- package/server/integrations/xero/migrations/0015_xero_link_release.sql +7 -0
- package/server/integrations/xero/migrations/0016_xero_copy_version.sql +11 -0
- package/server/integrations/xero/migrations/meta/_journal.json +14 -0
- package/server/integrations/xero/mirror.js +63 -8
- package/server/integrations/xero/reconcile.js +1 -1
- package/server/integrations/xero/routes.js +22 -2
- package/server/ui/app.js +31 -2
- package/server/ui/contacts.js +150 -15
|
@@ -105,7 +105,7 @@ export async function accountingStatus(db, runtime, organisationId, invoiceIds =
|
|
|
105
105
|
) p on true
|
|
106
106
|
left join invoice_versions v on v.invoice_id = i.id and v.version = p.version
|
|
107
107
|
-- A Flow invoice the Import page linked to a Xero invoice (links.js).
|
|
108
|
-
left join xero_invoice_links l on l.organisation_id = i.organisation_id and l.invoice_id = i.id
|
|
108
|
+
left join xero_invoice_links l on l.organisation_id = i.organisation_id and l.invoice_id = i.id and l.released_at is null
|
|
109
109
|
left join xero_invoices x on x.organisation_id = i.organisation_id and x.xero_invoice_id = coalesce(p.xero_invoice_id, l.xero_invoice_id)
|
|
110
110
|
where i.organisation_id = ${organisationId} ${only} ${sentOnly}`);
|
|
111
111
|
const web = runtime.settings.endpoints?.web ?? 'https://go.xero.com';
|
|
@@ -8,9 +8,9 @@ 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';
|
|
11
|
-
import { IMPORT_STATES, importOverview, linkXeroInvoice, makeFlowInvoice, matchXero, saveImportSettings, useXeroInvoice } from './links.js';
|
|
11
|
+
import { IMPORT_STATES, importOverview, linkXeroInvoice, makeFlowInvoice, matchXero, releaseXeroLink, saveImportSettings, useXeroInvoice } from './links.js';
|
|
12
12
|
import { queueHistoryImport, resetImport } from './import.js';
|
|
13
|
-
import { MIRROR_RESOURCES, mirrorStatus, mirrorXero, readInvoiceFromXero } from './mirror.js';
|
|
13
|
+
import { MIRROR_RESOURCES, mirrorStatus, mirrorXero, readInvoiceFromXero, rereadCopy } from './mirror.js';
|
|
14
14
|
import { accountingStatus, checkSentInvoices } from './reconcile.js';
|
|
15
15
|
import { CUSTOM_CONNECTION, decryptToken, deleteConnection, describeConnection, readConnection, saveConnection } from './connections.js';
|
|
16
16
|
import { authorizeUrl, clientCredentials, exchangeCode, fetchConnections, revokeToken, selectConnection } from './oauth.js';
|
|
@@ -415,6 +415,18 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, butto
|
|
|
415
415
|
res.json({ ok: true, results, ...(await mirrorStatus(db, organisationId)) });
|
|
416
416
|
}));
|
|
417
417
|
|
|
418
|
+
// An owner asks for the whole copy to be read again; the next sync reads it.
|
|
419
|
+
router.post('/mirror/reread', ...owner, handle(log, async (req, res) => {
|
|
420
|
+
const organisationId = await requireOrganisation(db, req.body?.organisationId);
|
|
421
|
+
const resources = req.body?.resources ?? MIRROR_RESOURCES;
|
|
422
|
+
if (!Array.isArray(resources) || !resources.length || !resources.every((r) => MIRROR_RESOURCES.includes(r))) {
|
|
423
|
+
throw new FlowError('invalid', `resources must list some of: ${MIRROR_RESOURCES.join(', ')}`);
|
|
424
|
+
}
|
|
425
|
+
const status = await rereadCopy(db, organisationId, { resources: [...new Set(resources)], actor: `person:${req.person.id}` });
|
|
426
|
+
log.info?.(`[xero] organisation ${organisationId}: person ${req.person.id} asked for the copy to be read again: ${resources.join(', ')}`);
|
|
427
|
+
res.json({ ok: true, ...status });
|
|
428
|
+
}));
|
|
429
|
+
|
|
418
430
|
router.get('/mirror', ...signedIn, handle(log, async (req, res) => {
|
|
419
431
|
const organisationId = await requireOrganisation(db, req.query.organisationId);
|
|
420
432
|
res.json({ ok: true, ...(await mirrorStatus(db, organisationId)) });
|
|
@@ -463,6 +475,14 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, butto
|
|
|
463
475
|
res.json({ ok: true, source: 'xero', ...found });
|
|
464
476
|
}));
|
|
465
477
|
|
|
478
|
+
// A wrong pair: the Xero invoice's Flow invoice is void and the Xero invoice
|
|
479
|
+
// is another job's bill. Frees the Xero invoice and the jobs that point at it.
|
|
480
|
+
router.post('/import/:xeroInvoiceId/release', ...manager, handle(log, async (req, res) => {
|
|
481
|
+
const organisationId = await requireOrganisation(db, req.body?.organisationId);
|
|
482
|
+
const reason = typeof req.body?.reason === 'string' ? req.body.reason : null;
|
|
483
|
+
res.json({ ok: true, ...(await releaseXeroLink(db, organisationId, xeroId(req.params.xeroInvoiceId), { actor: `person:${req.person.id}`, reason })) });
|
|
484
|
+
}));
|
|
485
|
+
|
|
466
486
|
router.post('/import/:xeroInvoiceId/make', ...manager, handle(log, async (req, res) => {
|
|
467
487
|
const organisationId = await requireOrganisation(db, req.body?.organisationId);
|
|
468
488
|
const jobId = req.body?.jobId == null ? null : requireInvoiceIds([req.body.jobId], 'jobId')[0];
|
package/server/ui/app.js
CHANGED
|
@@ -1140,6 +1140,18 @@ function seazonaHistorySection() {
|
|
|
1140
1140
|
return section;
|
|
1141
1141
|
}
|
|
1142
1142
|
|
|
1143
|
+
// How much of the last 12 months of the copy has a Reference: a low share
|
|
1144
|
+
// is a copy that missed it (docs/XERO.md, "Copy version").
|
|
1145
|
+
function copyFill(mirror) {
|
|
1146
|
+
// With free text off the copy keeps no Reference: that is the setting.
|
|
1147
|
+
if (mirror.cursors?.find((c) => c.resource === 'invoices')?.keptReference === false) return '';
|
|
1148
|
+
const months = mirror.completeness ?? [];
|
|
1149
|
+
const invoices = months.reduce((sum, m) => sum + m.invoices, 0);
|
|
1150
|
+
if (!invoices) return '';
|
|
1151
|
+
const withReference = months.reduce((sum, m) => sum + m.withReference, 0);
|
|
1152
|
+
return ` Reference on ${withReference} of ${invoices} in the last 12 months.`;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1143
1155
|
// Once Xero is on: each organisation's connection, the history copy, and
|
|
1144
1156
|
// Connect, which goes to Xero and comes back.
|
|
1145
1157
|
function xeroConnection(organisations, canChange) {
|
|
@@ -1153,6 +1165,7 @@ function xeroConnection(organisations, canChange) {
|
|
|
1153
1165
|
let control = null;
|
|
1154
1166
|
let stopped = false;
|
|
1155
1167
|
let refused = false;
|
|
1168
|
+
let mirror = null;
|
|
1156
1169
|
try {
|
|
1157
1170
|
const status = await flow(`/api/xero/status?organisationId=${organisation.id}`);
|
|
1158
1171
|
usage.replaceChildren(xeroUsage(status) ?? '');
|
|
@@ -1168,8 +1181,8 @@ function xeroConnection(organisations, canChange) {
|
|
|
1168
1181
|
stopped = status.calls.all;
|
|
1169
1182
|
refused = !status.calls.all;
|
|
1170
1183
|
} else if (status.connected) {
|
|
1171
|
-
|
|
1172
|
-
hint = `Connected to ${status.connection?.tenantName ?? 'Xero'}.${mirror ? ` ${mirror.counts.invoices} invoices copied
|
|
1184
|
+
mirror = await flow(`/api/xero/mirror?organisationId=${organisation.id}`).catch(() => null);
|
|
1185
|
+
hint = `Connected to ${status.connection?.tenantName ?? 'Xero'}.${mirror ? ` ${mirror.counts.invoices} invoices copied.${copyFill(mirror)}` : ''}`;
|
|
1173
1186
|
} else {
|
|
1174
1187
|
hint = 'Not connected.';
|
|
1175
1188
|
}
|
|
@@ -1185,6 +1198,22 @@ function xeroConnection(organisations, canChange) {
|
|
|
1185
1198
|
toast(error.message, 'danger');
|
|
1186
1199
|
}
|
|
1187
1200
|
} }, status.connected ? 'Connect again' : 'Connect to Xero');
|
|
1201
|
+
// The owner can ask for the whole copy to be read again (a copy
|
|
1202
|
+
// that missed a field). The next sync reads it; no call now.
|
|
1203
|
+
if (mirror && me?.person.role === 'owner') {
|
|
1204
|
+
const reread = h('button', { class: 'fx-btn fx-btn--sm', type: 'button', title: 'Read every Xero invoice again on the next sync', onclick: async () => {
|
|
1205
|
+
// One press is enough: each press starts the read again.
|
|
1206
|
+
reread.disabled = true;
|
|
1207
|
+
try {
|
|
1208
|
+
await flow('/api/xero/mirror/reread', { method: 'POST', body: JSON.stringify({ organisationId: organisation.id }) });
|
|
1209
|
+
toast('Flow reads the copy again on the next sync.');
|
|
1210
|
+
} catch (error) {
|
|
1211
|
+
reread.disabled = false;
|
|
1212
|
+
toast(error.message, 'danger');
|
|
1213
|
+
}
|
|
1214
|
+
} }, 'Read again');
|
|
1215
|
+
control = h('div', { class: 'fx-row' }, reread, control);
|
|
1216
|
+
}
|
|
1188
1217
|
}
|
|
1189
1218
|
} catch (error) {
|
|
1190
1219
|
hint = error.message;
|
package/server/ui/contacts.js
CHANGED
|
@@ -23,7 +23,9 @@ async function renderContactList(main, ui) {
|
|
|
23
23
|
const search = input(h, { type: 'search', 'aria-label': 'Search', placeholder: 'Search', class: 'fx-input fx-contacts__search' });
|
|
24
24
|
const kind = h('select', { class: 'fx-select', 'aria-label': 'Kind' },
|
|
25
25
|
h('option', { value: 'all', selected: true }, 'All'),
|
|
26
|
-
...KINDS.map(([value, label]) => h('option', { value }, `${label}s`))
|
|
26
|
+
...KINDS.map(([value, label]) => h('option', { value }, `${label}s`)),
|
|
27
|
+
// Contacts that are not clients (docs/CONTACTS.md, "Hidden contacts").
|
|
28
|
+
h('option', { value: 'hidden' }, 'Hidden'));
|
|
27
29
|
const page = h('div', { class: 'fx-contacts' });
|
|
28
30
|
const add = h('button', { class: 'fx-btn fx-btn--primary', type: 'button', hidden: !canEdit, onclick: () => createContact() }, 'New');
|
|
29
31
|
page.append(
|
|
@@ -36,7 +38,7 @@ async function renderContactList(main, ui) {
|
|
|
36
38
|
const draw = () => {
|
|
37
39
|
const needle = search.value.trim().toLowerCase();
|
|
38
40
|
const shown = rows.filter((contact) => {
|
|
39
|
-
if (kind.value
|
|
41
|
+
if (!['all', 'hidden'].includes(kind.value) && contact.kind !== kind.value) return false;
|
|
40
42
|
if (!needle) return true;
|
|
41
43
|
return [contact.name, contact.email, contact.accountNumber].some((value) => String(value ?? '').toLowerCase().includes(needle));
|
|
42
44
|
});
|
|
@@ -47,7 +49,9 @@ async function renderContactList(main, ui) {
|
|
|
47
49
|
h('td', {}, h('a', { class: 'fx-person', href: `/contacts/${contact.id}`, onclick: go },
|
|
48
50
|
h('span', { class: 'fx-avatar', 'aria-hidden': 'true' }, initialsOf(contact.name)),
|
|
49
51
|
h('span', { class: 'fx-person__text' },
|
|
50
|
-
h('span', { class: 'fx-person__name' }, contact.name,
|
|
52
|
+
h('span', { class: 'fx-person__name' }, contact.name,
|
|
53
|
+
contact.active ? null : h('span', { class: 'fx-tag' }, 'Inactive'),
|
|
54
|
+
contact.hidden ? h('span', { class: 'fx-tag' }, 'Hidden') : null),
|
|
51
55
|
contact.email ? h('span', { class: 'fx-person__email' }, contact.email) : null))),
|
|
52
56
|
h('td', {}, KINDS.find(([value]) => value === contact.kind)?.[1] ?? contact.kind),
|
|
53
57
|
h('td', {}, contact.accountNumber ?? '')))))
|
|
@@ -58,8 +62,23 @@ async function renderContactList(main, ui) {
|
|
|
58
62
|
);
|
|
59
63
|
};
|
|
60
64
|
|
|
65
|
+
// The hidden contacts are their own list: the server leaves them out of
|
|
66
|
+
// every other one.
|
|
67
|
+
let listed = null;
|
|
68
|
+
async function load() {
|
|
69
|
+
const hidden = kind.value === 'hidden' ? 'true' : 'false';
|
|
70
|
+
if (hidden === listed) return draw();
|
|
71
|
+
try {
|
|
72
|
+
({ contacts: rows } = await flow(`/api/invoicing/contacts?kind=all&active=all&hidden=${hidden}&limit=500`));
|
|
73
|
+
listed = hidden;
|
|
74
|
+
draw();
|
|
75
|
+
} catch (error) {
|
|
76
|
+
host.replaceChildren(notice(error.message, 'danger'));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
61
80
|
search.addEventListener('input', draw);
|
|
62
|
-
kind.addEventListener('change',
|
|
81
|
+
kind.addEventListener('change', load);
|
|
63
82
|
|
|
64
83
|
async function createContact() {
|
|
65
84
|
const name = input(h, { name: 'name', required: true, 'aria-label': 'Name' });
|
|
@@ -90,17 +109,12 @@ async function renderContactList(main, ui) {
|
|
|
90
109
|
}
|
|
91
110
|
}
|
|
92
111
|
|
|
93
|
-
|
|
94
|
-
({ contacts: rows } = await flow('/api/invoicing/contacts?kind=all&active=all&limit=200'));
|
|
95
|
-
draw();
|
|
96
|
-
} catch (error) {
|
|
97
|
-
host.replaceChildren(notice(error.message, 'danger'));
|
|
98
|
-
}
|
|
112
|
+
await load();
|
|
99
113
|
}
|
|
100
114
|
|
|
101
115
|
async function renderContact(main, ui, id) {
|
|
102
|
-
const { h, flow, toast, notice, pageHeader, crumbs, me } = ui;
|
|
103
|
-
|
|
116
|
+
const { h, flow, toast, notice, pageHeader, crumbs, me, go } = ui;
|
|
117
|
+
let canEdit = ['owner', 'admin'].includes(me?.person.role);
|
|
104
118
|
const page = h('div', { class: 'fx-contacts' }, h('div', { class: 'fx-skeleton' }));
|
|
105
119
|
main.append(page);
|
|
106
120
|
let contact;
|
|
@@ -110,6 +124,8 @@ async function renderContact(main, ui, id) {
|
|
|
110
124
|
page.replaceChildren(notice(error.message, 'danger'));
|
|
111
125
|
return;
|
|
112
126
|
}
|
|
127
|
+
// A merged contact is read only, and points at the one it moved into.
|
|
128
|
+
if (contact.mergedIntoId) canEdit = false;
|
|
113
129
|
|
|
114
130
|
const values = {
|
|
115
131
|
name: input(h, { value: contact.name ?? '' }),
|
|
@@ -176,10 +192,67 @@ async function renderContact(main, ui, id) {
|
|
|
176
192
|
},
|
|
177
193
|
}, 'Save');
|
|
178
194
|
|
|
195
|
+
// Merge (docs/CONTACTS.md, "Merge two contacts"): another contact moves
|
|
196
|
+
// into this one, which keeps its name as an alias.
|
|
197
|
+
const merge = h('button', {
|
|
198
|
+
class: 'fx-btn', type: 'button', hidden: !canEdit, title: 'Merge another contact into this one',
|
|
199
|
+
onclick: async () => {
|
|
200
|
+
const picked = await ui.pickOne({
|
|
201
|
+
title: `Merge into ${contact.name}`,
|
|
202
|
+
intro: `Its jobs, invoices, links and rules move to ${contact.name}. Its name stays as another name of ${contact.name}.`,
|
|
203
|
+
label: 'Contacts',
|
|
204
|
+
find: 'Find a contact',
|
|
205
|
+
remote: true,
|
|
206
|
+
load: async (q) => (await flow(`/api/invoicing/contacts?${new URLSearchParams({ q, kind: contact.kind, hidden: 'all', active: 'all', limit: '50' })}`)).contacts
|
|
207
|
+
.filter((row) => row.id !== contact.id),
|
|
208
|
+
item: (row) => ({ value: row.id, title: row.name, meta: row.email ?? null }),
|
|
209
|
+
empty: 'No contact has that name.',
|
|
210
|
+
confirm: 'Merge',
|
|
211
|
+
});
|
|
212
|
+
if (!picked) return;
|
|
213
|
+
merge.disabled = true;
|
|
214
|
+
try {
|
|
215
|
+
const { moved } = await flow(`/api/invoicing/contacts/${id}/merge`, { method: 'POST', body: JSON.stringify({ fromId: picked.id }) });
|
|
216
|
+
toast(`Merged: ${moved.jobs} jobs, ${moved.invoices} invoices.`, 'success');
|
|
217
|
+
go(`/contacts/${id}`);
|
|
218
|
+
} catch (error) {
|
|
219
|
+
toast(error.message, 'danger');
|
|
220
|
+
merge.disabled = false;
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
}, 'Merge');
|
|
224
|
+
const mergedNote = contact.mergedIntoId ? h('section', { class: 'fx-card fx-offer' },
|
|
225
|
+
notice('This contact moved into another.', 'info', 'Merged.'),
|
|
226
|
+
h('a', { class: 'fx-btn', href: `/contacts/${contact.mergedIntoId}`, onclick: go }, 'Open')) : null;
|
|
179
227
|
const xero = h('div');
|
|
228
|
+
const billTo = h('div');
|
|
229
|
+
// A contact that is not a client (docs/CONTACTS.md, "Hidden contacts").
|
|
230
|
+
const hiddenNote = h('div');
|
|
231
|
+
const drawHidden = () => {
|
|
232
|
+
if (!contact.hidden) return hiddenNote.replaceChildren();
|
|
233
|
+
const show = canEdit ? h('button', {
|
|
234
|
+
class: 'fx-btn', type: 'button', title: 'Show this contact in the lists',
|
|
235
|
+
onclick: async () => {
|
|
236
|
+
show.disabled = true;
|
|
237
|
+
try {
|
|
238
|
+
({ contact } = await flow(`/api/invoicing/contacts/${id}`, { method: 'PATCH', body: JSON.stringify({ hidden: false }) }));
|
|
239
|
+
toast('Shown.', 'success');
|
|
240
|
+
drawHidden();
|
|
241
|
+
} catch (error) {
|
|
242
|
+
toast(error.message, 'danger');
|
|
243
|
+
show.disabled = false;
|
|
244
|
+
}
|
|
245
|
+
},
|
|
246
|
+
}, 'Show') : null;
|
|
247
|
+
hiddenNote.replaceChildren(h('section', { class: 'fx-card fx-offer' },
|
|
248
|
+
notice('Not a client: no job and no invoice.', 'info', 'Hidden.'), show));
|
|
249
|
+
};
|
|
250
|
+
drawHidden();
|
|
180
251
|
page.replaceChildren(
|
|
181
252
|
crumbs(['/contacts', 'Contacts'], contact.name),
|
|
182
|
-
pageHeader(contact.name, { actions: [save] }),
|
|
253
|
+
pageHeader(contact.name, { actions: [merge, save] }),
|
|
254
|
+
mergedNote,
|
|
255
|
+
contact.mergedIntoId ? null : hiddenNote,
|
|
183
256
|
h('section', { class: 'fx-card' },
|
|
184
257
|
h('div', { class: 'fx-contacts__grid' },
|
|
185
258
|
field(h, 'Name', values.name),
|
|
@@ -204,9 +277,67 @@ async function renderContact(main, ui, id) {
|
|
|
204
277
|
h('span', { class: 'fx-label' }, 'Practitioners'),
|
|
205
278
|
practitioners),
|
|
206
279
|
h('label', { class: 'fx-check' }, active, 'Active'))),
|
|
280
|
+
billTo,
|
|
207
281
|
xero,
|
|
208
282
|
);
|
|
209
|
-
|
|
283
|
+
const reloadXero = () => loadXero(ui, contact, canEdit, xero);
|
|
284
|
+
if (contact.kind === 'customer' && !contact.mergedIntoId) void loadBillTo(ui, contact, canEdit, billTo, reloadXero);
|
|
285
|
+
void reloadXero();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Bill to (docs/CONTACTS.md): another customer, the payer, whose accounting
|
|
289
|
+
// contact this client's work is billed to. Many clients may name one payer.
|
|
290
|
+
async function loadBillTo(ui, contact, canEdit, host, changed) {
|
|
291
|
+
const { h, flow, toast, notice } = ui;
|
|
292
|
+
let payer = null;
|
|
293
|
+
const save = async (billToContactId, button) => {
|
|
294
|
+
if (button) button.disabled = true;
|
|
295
|
+
try {
|
|
296
|
+
await flow(`/api/invoicing/contacts/${contact.id}/billing`, { method: 'PUT', body: JSON.stringify({ billToContactId }) });
|
|
297
|
+
toast(billToContactId ? 'Bill to saved.' : 'Bill to cleared.', 'success');
|
|
298
|
+
await load();
|
|
299
|
+
await changed();
|
|
300
|
+
} catch (error) {
|
|
301
|
+
toast(error.message, 'danger');
|
|
302
|
+
if (button) button.disabled = false;
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
const pick = async (event) => {
|
|
306
|
+
const button = event.currentTarget;
|
|
307
|
+
const picked = await ui.pickOne({
|
|
308
|
+
title: `Bill ${contact.name} to`,
|
|
309
|
+
intro: 'Pick the customer that pays for this client. Its Xero contact gets the invoices.',
|
|
310
|
+
label: 'Customers',
|
|
311
|
+
find: 'Find a customer',
|
|
312
|
+
remote: true,
|
|
313
|
+
load: async (q) => (await flow(`/api/invoicing/contacts?${new URLSearchParams({ q, limit: '50' })}`)).contacts
|
|
314
|
+
.filter((row) => row.id !== contact.id),
|
|
315
|
+
item: (row) => ({ value: row.id, title: row.name, meta: row.email ?? null }),
|
|
316
|
+
empty: 'No customer has that name.',
|
|
317
|
+
confirm: 'Save',
|
|
318
|
+
});
|
|
319
|
+
if (picked) await save(picked.id, button);
|
|
320
|
+
};
|
|
321
|
+
const draw = () => {
|
|
322
|
+
const change = canEdit ? h('button', { class: 'fx-btn', type: 'button', title: 'Pick the customer that pays', onclick: pick }, payer ? 'Change' : 'Set') : null;
|
|
323
|
+
const clear = canEdit && payer ? h('button', {
|
|
324
|
+
class: 'fx-btn fx-btn--ghost', type: 'button', title: 'Bill this client itself',
|
|
325
|
+
onclick: (event) => save(null, event.currentTarget),
|
|
326
|
+
}, 'Clear') : null;
|
|
327
|
+
host.replaceChildren(h('section', { class: 'fx-card fx-offer' },
|
|
328
|
+
notice(payer ? `Billed to ${payer.name}.` : 'Billed to itself.', 'info', 'Bill to.'),
|
|
329
|
+
change, clear));
|
|
330
|
+
};
|
|
331
|
+
async function load() {
|
|
332
|
+
try {
|
|
333
|
+
const { billing } = await flow(`/api/invoicing/contacts/${contact.id}/billing`);
|
|
334
|
+
payer = billing.billToContactId ? (await flow(`/api/invoicing/contacts/${billing.billToContactId}`)).contact : null;
|
|
335
|
+
draw();
|
|
336
|
+
} catch (error) {
|
|
337
|
+
host.replaceChildren(notice(error.message, 'danger'));
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
await load();
|
|
210
341
|
}
|
|
211
342
|
|
|
212
343
|
async function loadXero(ui, contact, canEdit, host) {
|
|
@@ -231,7 +362,11 @@ async function loadXero(ui, contact, canEdit, host) {
|
|
|
231
362
|
done: (outcome) => host.replaceChildren(h('section', { class: 'fx-card' },
|
|
232
363
|
notice(outcome === 'linked' ? `${contact.name} is linked to Xero.` : `${contact.name} is in Xero.`, 'success'))),
|
|
233
364
|
}) : [];
|
|
234
|
-
const text = match.
|
|
365
|
+
const text = match.billTo
|
|
366
|
+
? (match.state === 'matched'
|
|
367
|
+
? `Billed to ${match.billTo.name}, in Xero as ${match.name}.`
|
|
368
|
+
: `Billed to ${match.billTo.name}, which is not linked to Xero. Link it on its contact page.`)
|
|
369
|
+
: match.state === 'matched'
|
|
235
370
|
? `In Xero${match.name ? ` as ${match.name}` : ''}.`
|
|
236
371
|
: match.state === 'ambiguous'
|
|
237
372
|
? `More than one Xero contact is named "${contact.name}".`
|