@arcforgelabs/flow 2026.11.8 → 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/seazona/sweep.js +58 -2
- 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
|
@@ -23,6 +23,12 @@ import { withXeroLane } from './governor.js';
|
|
|
23
23
|
|
|
24
24
|
export const MIRROR_RESOURCES = Object.freeze(['invoices', 'credit_notes', 'payments']);
|
|
25
25
|
|
|
26
|
+
// The mapper's version. Raise it when a mapper copies a new field: each
|
|
27
|
+
// resource is then read again from the beginning, so old rows get the field
|
|
28
|
+
// (docs/XERO.md, "Copy version").
|
|
29
|
+
export const COPY_VERSION = 1;
|
|
30
|
+
const copyVersion = (keepReference) => `${COPY_VERSION}:${keepReference ? 'text' : 'no_text'}`;
|
|
31
|
+
|
|
26
32
|
// A Xero amount (a JSON number with up to 4 decimals) to whole cents, half away
|
|
27
33
|
// from zero, without float drift.
|
|
28
34
|
export function toCents(amount) {
|
|
@@ -202,13 +208,18 @@ export async function storeRows(tx, organisationId, resource, rows, counts) {
|
|
|
202
208
|
// would keep the free text they were read with. The rows stay; each is
|
|
203
209
|
// written again only if it differs.
|
|
204
210
|
async function readCursor(db, organisationId, resource, { keepReference }) {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
211
|
+
const version = copyVersion(keepReference);
|
|
212
|
+
await db.execute(sql`insert into xero_mirror_cursors (organisation_id, resource, kept_reference, copy_version)
|
|
213
|
+
values (${organisationId}, ${resource}, ${keepReference}, ${version}) on conflict do nothing`);
|
|
214
|
+
await db.execute(sql`update xero_mirror_cursors set modified_since = null, page = 1, kept_reference = ${keepReference}, copy_version = ${version}
|
|
215
|
+
where organisation_id = ${organisationId} and resource = ${resource}
|
|
216
|
+
and (kept_reference is distinct from ${keepReference} or (copy_version is not null and copy_version <> ${version}))`);
|
|
217
|
+
// A copy read before Flow noted its version takes the version as it is.
|
|
218
|
+
await db.execute(sql`update xero_mirror_cursors set copy_version = ${version}
|
|
219
|
+
where organisation_id = ${organisationId} and resource = ${resource} and copy_version is null`);
|
|
209
220
|
const { rows: [row] } = await db.execute(sql`select * from xero_mirror_cursors
|
|
210
221
|
where organisation_id = ${organisationId} and resource = ${resource}`);
|
|
211
|
-
return { modifiedSince: row.modified_since ? new Date(row.modified_since) : null, page: row.page };
|
|
222
|
+
return { modifiedSince: row.modified_since ? new Date(row.modified_since) : null, page: row.page, rereadAt: row.reread_at ?? null };
|
|
212
223
|
}
|
|
213
224
|
|
|
214
225
|
// Where the next page starts, from the page just read. A full page moves the
|
|
@@ -255,18 +266,29 @@ export async function mirrorResource(db, runtime, organisationId, resource, { ma
|
|
|
255
266
|
}
|
|
256
267
|
const rows = mapRecords(resource, raw, settings);
|
|
257
268
|
const next = nextPosition(position, rows, pageSize);
|
|
258
|
-
await db.transaction(async (tx) => {
|
|
269
|
+
const asked = await db.transaction(async (tx) => {
|
|
259
270
|
await storeRows(tx, organisationId, resource, rows, counts);
|
|
271
|
+
// An owner asked for a read again while this pass ran: keep the
|
|
272
|
+
// cursor at the start they set, and stop.
|
|
273
|
+
const { rows: [cursor] } = await tx.execute(sql`select reread_at from xero_mirror_cursors
|
|
274
|
+
where organisation_id = ${organisationId} and resource = ${resource} for update`);
|
|
275
|
+
if (String(cursor?.reread_at ?? '') !== String(position.rereadAt ?? '')) {
|
|
276
|
+
await tx.execute(sql`update xero_mirror_cursors set rows_read = rows_read + ${raw.length}, last_read_at = now(), last_error = null
|
|
277
|
+
where organisation_id = ${organisationId} and resource = ${resource}`);
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
260
280
|
await tx.execute(sql`update xero_mirror_cursors set
|
|
261
281
|
modified_since = ${next.modifiedSince}, page = ${next.page}, rows_read = rows_read + ${raw.length},
|
|
262
282
|
last_read_at = now(), last_error = null,
|
|
263
283
|
passes = passes + ${next.done ? 1 : 0},
|
|
264
284
|
last_pass_finished_at = case when ${next.done} then now() else last_pass_finished_at end
|
|
265
285
|
where organisation_id = ${organisationId} and resource = ${resource}`);
|
|
286
|
+
return false;
|
|
266
287
|
});
|
|
267
288
|
counts.pages += 1;
|
|
268
289
|
counts.read += raw.length;
|
|
269
|
-
|
|
290
|
+
if (asked) return { resource, ...counts, stopped: 'reread' };
|
|
291
|
+
position = { ...next, rereadAt: position.rereadAt };
|
|
270
292
|
if (next.done) return { resource, ...counts, stopped: 'done' };
|
|
271
293
|
}
|
|
272
294
|
});
|
|
@@ -311,10 +333,39 @@ export async function mirrorXero(db, runtime, organisationId, { resources = MIRR
|
|
|
311
333
|
});
|
|
312
334
|
}
|
|
313
335
|
|
|
336
|
+
// A person asks for the whole copy to be read again (an owner only): the next
|
|
337
|
+
// read of each resource starts from the beginning. The rows stay; each is
|
|
338
|
+
// written again only if it differs. No call is made here.
|
|
339
|
+
export async function rereadCopy(db, organisationId, { resources = MIRROR_RESOURCES, actor }) {
|
|
340
|
+
if (!actor) throw new TypeError('actor is required');
|
|
341
|
+
for (const resource of resources) {
|
|
342
|
+
await db.execute(sql`insert into xero_mirror_cursors (organisation_id, resource) values (${organisationId}, ${resource}) on conflict do nothing`);
|
|
343
|
+
await db.execute(sql`update xero_mirror_cursors set modified_since = null, page = 1, reread_by = ${actor}, reread_at = now()
|
|
344
|
+
where organisation_id = ${organisationId} and resource = ${resource}`);
|
|
345
|
+
}
|
|
346
|
+
return mirrorStatus(db, organisationId);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// For each of the last 12 months, the sales invoices in the copy and how many
|
|
350
|
+
// have a Reference and line item codes: the fields the matcher reads. A low
|
|
351
|
+
// month is a copy that missed them (docs/XERO.md, "Copy version").
|
|
352
|
+
async function completeness(db, organisationId) {
|
|
353
|
+
const { rows } = await db.execute(sql`select to_char(date_trunc('month', issued_on), 'YYYY-MM') as month,
|
|
354
|
+
count(*)::int as invoices,
|
|
355
|
+
count(*) filter (where coalesce(trim(reference), '') <> '')::int as with_reference,
|
|
356
|
+
count(*) filter (where jsonb_typeof(lines) = 'array'
|
|
357
|
+
and exists (select 1 from jsonb_array_elements(lines) l where coalesce(l->>'itemCode', '') <> ''))::int as with_item_codes
|
|
358
|
+
from xero_invoices
|
|
359
|
+
where organisation_id = ${organisationId} and type = 'ACCREC' and status not in ('DELETED', 'VOIDED')
|
|
360
|
+
and issued_on >= date_trunc('month', now()) - interval '11 months'
|
|
361
|
+
group by 1 order by 1`);
|
|
362
|
+
return rows.map((r) => ({ month: r.month, invoices: r.invoices, withReference: r.with_reference, withItemCodes: r.with_item_codes }));
|
|
363
|
+
}
|
|
364
|
+
|
|
314
365
|
// What the copy holds, and where each cursor stands. No Xero calls.
|
|
315
366
|
export async function mirrorStatus(db, organisationId) {
|
|
316
367
|
const { rows: cursors } = await db.execute(sql`select resource, modified_since, page, passes, rows_read,
|
|
317
|
-
last_read_at, last_pass_finished_at, last_error from xero_mirror_cursors where organisation_id = ${organisationId}
|
|
368
|
+
last_read_at, last_pass_finished_at, last_error, copy_version, kept_reference, reread_by, reread_at from xero_mirror_cursors where organisation_id = ${organisationId}
|
|
318
369
|
order by resource`);
|
|
319
370
|
const { rows: [counts] } = await db.execute(sql`select
|
|
320
371
|
(select count(*)::int from xero_invoices where organisation_id = ${organisationId}) as invoices,
|
|
@@ -331,6 +382,10 @@ export async function mirrorStatus(db, organisationId) {
|
|
|
331
382
|
lastReadAt: c.last_read_at,
|
|
332
383
|
lastPassFinishedAt: c.last_pass_finished_at,
|
|
333
384
|
lastError: c.last_error,
|
|
385
|
+
copyVersion: c.copy_version ?? null,
|
|
386
|
+
keptReference: c.kept_reference ?? null,
|
|
387
|
+
rereadAt: c.reread_at ?? null,
|
|
334
388
|
})),
|
|
389
|
+
completeness: await completeness(db, organisationId),
|
|
335
390
|
};
|
|
336
391
|
}
|
|
@@ -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}".`
|