@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
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcforgelabs/flow",
|
|
3
|
-
"version": "2026.11.
|
|
3
|
+
"version": "2026.11.10",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@arcforgelabs/flow",
|
|
9
|
-
"version": "2026.11.
|
|
9
|
+
"version": "2026.11.10",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@clerk/backend": "3.25.0",
|
package/package.json
CHANGED
package/server/api/invoicing.js
CHANGED
|
@@ -35,6 +35,7 @@ import { isHistoryHeld } from '../core/history.js';
|
|
|
35
35
|
import { invoicePdfDocument, pdfFields, pdfFilename, renderInvoicePdf } from '../core/invoice-pdf.js';
|
|
36
36
|
import { invoiceView, invoiceViews, markNotReady, markReady, sendAgain, sendAllReady } from '../core/sending.js';
|
|
37
37
|
import { createContact, getContact, listContacts, presentContact, updateContact } from '../core/contacts.js';
|
|
38
|
+
import { mergeContacts } from '../core/merge-contacts.js';
|
|
38
39
|
import {
|
|
39
40
|
billingObligations, contacts, invoices, INVOICE_STATUSES, organisations, priceItems, TAX_CODES,
|
|
40
41
|
} from '../db/schema.js';
|
|
@@ -64,7 +65,7 @@ import {
|
|
|
64
65
|
// POST /invoices/:id/void owner or admin
|
|
65
66
|
// DELETE /invoices/:id owner or admin: a draft that was never issued.
|
|
66
67
|
// Frees its job, and the lab's rules then decide.
|
|
67
|
-
// GET /contacts?q=&limit= customers, by name
|
|
68
|
+
// GET /contacts?q=&limit= customers, by name (hidden=true: the contacts that are not clients)
|
|
68
69
|
// GET /price-items?q= the price list, by code
|
|
69
70
|
// GET /jobs?view=&limit= billable jobs (the obligation ledger), with counts by view;
|
|
70
71
|
// a job with no invoice lists the invoicing rules it waits on;
|
|
@@ -695,10 +696,13 @@ export function createInvoicingRouter({
|
|
|
695
696
|
if (kind && !['customer', 'supplier', 'all'].includes(kind)) throw invalid('kind must be customer, supplier or all');
|
|
696
697
|
const active = text(req.query.active);
|
|
697
698
|
if (active && !['true', 'false', 'all'].includes(active)) throw invalid('active must be true, false or all');
|
|
699
|
+
const hidden = text(req.query.hidden);
|
|
700
|
+
if (hidden && !['true', 'false', 'all'].includes(hidden)) throw invalid('hidden must be true, false or all');
|
|
698
701
|
const rows = await listContacts(db, organisationId, {
|
|
699
702
|
q: text(req.query.q),
|
|
700
703
|
kind: kind === 'all' ? null : (kind || 'customer'),
|
|
701
704
|
active: active === 'all' ? null : active === 'false' ? false : true,
|
|
705
|
+
hidden: hidden === 'all' ? null : hidden === 'true',
|
|
702
706
|
limit: limitOf(req.query.limit, 50, 500),
|
|
703
707
|
offset: offsetOf(req.query.offset),
|
|
704
708
|
});
|
|
@@ -724,6 +728,18 @@ export function createInvoicingRouter({
|
|
|
724
728
|
res.json({ ok: true, contact });
|
|
725
729
|
}));
|
|
726
730
|
|
|
731
|
+
// Merge two contacts: this one stays; `fromId` moves into it
|
|
732
|
+
// (docs/CONTACTS.md, "Merge two contacts").
|
|
733
|
+
router.post('/contacts/:id/merge', ...manager, handle(log, async (req, res) => {
|
|
734
|
+
const organisationId = await organisationOf(req);
|
|
735
|
+
const keepId = pathId(req.params.id, 'contact');
|
|
736
|
+
const fromId = text(req.body?.fromId);
|
|
737
|
+
if (!UUID.test(fromId)) throw invalid('fromId must name the contact to merge');
|
|
738
|
+
const result = await mergeContacts(db, organisationId, { keepId, fromId, actor: `person:${req.person.id}` });
|
|
739
|
+
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} merged contact ${fromId} into ${keepId}`);
|
|
740
|
+
res.json({ ok: true, ...result, contact: presentContact(await getContact(db, organisationId, keepId)) });
|
|
741
|
+
}));
|
|
742
|
+
|
|
727
743
|
router.get('/price-items', ...signedIn, handle(log, async (req, res) => {
|
|
728
744
|
const organisationId = await organisationOf(req);
|
|
729
745
|
const q = text(req.query.q);
|
|
@@ -988,7 +1004,7 @@ export function createInvoicingRouter({
|
|
|
988
1004
|
const [statuses, customers, items, practitioners] = await Promise.all([
|
|
989
1005
|
workStatuses(db, organisationId),
|
|
990
1006
|
db.select({ value: contacts.id, label: contacts.name }).from(contacts)
|
|
991
|
-
.where(and(eq(contacts.organisationId, organisationId), eq(contacts.kind, 'customer')))
|
|
1007
|
+
.where(and(eq(contacts.organisationId, organisationId), eq(contacts.kind, 'customer'), isNull(contacts.mergedIntoId)))
|
|
992
1008
|
.orderBy(asc(contacts.name)).limit(1000),
|
|
993
1009
|
db.select({ value: priceItems.code, label: priceItems.name }).from(priceItems)
|
|
994
1010
|
.where(and(eq(priceItems.organisationId, organisationId), isNull(priceItems.archivedAt)))
|
package/server/core/contacts.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { and, asc, eq, ilike, or, sql } from 'drizzle-orm';
|
|
2
|
-
import { contacts, CONTACT_KINDS } from '../db/schema.js';
|
|
1
|
+
import { and, asc, eq, ilike, inArray, isNotNull, isNull, or, sql } from 'drizzle-orm';
|
|
2
|
+
import { contacts, CONTACT_KINDS, customerBillingSettings } from '../db/schema.js';
|
|
3
3
|
import { FlowError, notFound } from './errors.js';
|
|
4
4
|
|
|
5
5
|
// Flow's own contacts (docs/CONTACTS.md). A contact is a customer or a
|
|
@@ -79,6 +79,11 @@ export function readContact(input, { partial = false } = {}) {
|
|
|
79
79
|
if (input.active != null && typeof input.active !== 'boolean') throw invalid('active must be true or false');
|
|
80
80
|
out.active = input.active !== false;
|
|
81
81
|
}
|
|
82
|
+
// Only when sent: a new contact is never hidden by default.
|
|
83
|
+
if (has('hidden')) {
|
|
84
|
+
if (typeof input.hidden !== 'boolean') throw invalid('hidden must be true or false');
|
|
85
|
+
out.hidden = input.hidden;
|
|
86
|
+
}
|
|
82
87
|
return out;
|
|
83
88
|
}
|
|
84
89
|
|
|
@@ -88,6 +93,22 @@ function duplicate(error, kind) {
|
|
|
88
93
|
throw error;
|
|
89
94
|
}
|
|
90
95
|
|
|
96
|
+
// A hidden contact of that name (not a client, docs/CONTACTS.md) is the usual
|
|
97
|
+
// reason when the lists do not show it: say so. Read before the write, so a
|
|
98
|
+
// caller's transaction is never left aborted.
|
|
99
|
+
async function refuseHiddenName(db, organisationId, { kind, name }) {
|
|
100
|
+
// Another name of a contact (a merge kept it): that contact is the one.
|
|
101
|
+
const [alias] = await db.select({ id: contacts.id, name: contacts.name }).from(contacts).where(and(
|
|
102
|
+
eq(contacts.organisationId, organisationId), eq(contacts.kind, kind), isNull(contacts.mergedIntoId),
|
|
103
|
+
sql`${contacts.aliases} @> ${JSON.stringify([name])}::jsonb`,
|
|
104
|
+
)).limit(1);
|
|
105
|
+
if (alias) throw new FlowError('conflict', `${alias.name} has this name as another name. Use that one.`, { contactId: alias.id });
|
|
106
|
+
const [same] = await db.select({ id: contacts.id }).from(contacts).where(and(
|
|
107
|
+
eq(contacts.organisationId, organisationId), eq(contacts.kind, kind), eq(contacts.name, name), eq(contacts.hidden, true),
|
|
108
|
+
));
|
|
109
|
+
if (same) throw new FlowError('conflict', `A hidden ${kind} with this name exists. Show it in Contacts, Hidden.`, { contactId: same.id });
|
|
110
|
+
}
|
|
111
|
+
|
|
91
112
|
export function presentContact(row) {
|
|
92
113
|
return {
|
|
93
114
|
id: row.id,
|
|
@@ -104,6 +125,9 @@ export function presentContact(row) {
|
|
|
104
125
|
address: row.address ?? null,
|
|
105
126
|
practitioners: row.practitioners ?? [],
|
|
106
127
|
active: row.active !== false,
|
|
128
|
+
hidden: row.hidden === true,
|
|
129
|
+
aliases: row.aliases ?? [],
|
|
130
|
+
mergedIntoId: row.mergedIntoId ?? null,
|
|
107
131
|
createdAt: row.createdAt,
|
|
108
132
|
updatedAt: row.updatedAt,
|
|
109
133
|
};
|
|
@@ -111,6 +135,7 @@ export function presentContact(row) {
|
|
|
111
135
|
|
|
112
136
|
export async function createContact(db, organisationId, input) {
|
|
113
137
|
const values = readContact(input);
|
|
138
|
+
await refuseHiddenName(db, organisationId, values);
|
|
114
139
|
try {
|
|
115
140
|
const [row] = await db.insert(contacts).values({ organisationId, ...values }).returning();
|
|
116
141
|
return row;
|
|
@@ -130,6 +155,16 @@ export async function updateContact(db, organisationId, id, input) {
|
|
|
130
155
|
const current = await getContact(db, organisationId, id);
|
|
131
156
|
const patch = readContact(input, { partial: true });
|
|
132
157
|
if (!Object.keys(patch).length) return current;
|
|
158
|
+
if (current.mergedIntoId) throw invalid('this contact was merged into another; change that one');
|
|
159
|
+
// Bill to joins customers only (docs/CONTACTS.md).
|
|
160
|
+
if (patch.kind && patch.kind !== current.kind) {
|
|
161
|
+
const [billing] = await db.select({ id: customerBillingSettings.contactId }).from(customerBillingSettings).where(and(
|
|
162
|
+
eq(customerBillingSettings.organisationId, organisationId),
|
|
163
|
+
or(and(eq(customerBillingSettings.contactId, id), isNotNull(customerBillingSettings.billToContactId)),
|
|
164
|
+
eq(customerBillingSettings.billToContactId, id)),
|
|
165
|
+
)).limit(1);
|
|
166
|
+
if (billing) throw invalid('this customer is in a Bill to; clear Bill to first');
|
|
167
|
+
}
|
|
133
168
|
try {
|
|
134
169
|
const [row] = await db.update(contacts)
|
|
135
170
|
.set({ ...patch, updatedAt: sql`now()` })
|
|
@@ -142,15 +177,21 @@ export async function updateContact(db, organisationId, id, input) {
|
|
|
142
177
|
}
|
|
143
178
|
|
|
144
179
|
// `kind` null lists customers and suppliers. `active` null lists both.
|
|
145
|
-
|
|
146
|
-
|
|
180
|
+
// `hidden` false (the default) leaves out the contacts that are not clients
|
|
181
|
+
// (docs/CONTACTS.md, "Hidden contacts"); true lists only those; null, both.
|
|
182
|
+
export async function listContacts(db, organisationId, { q = '', kind = 'customer', active = true, hidden = false, limit = 200, offset = 0 } = {}) {
|
|
183
|
+
// A merged contact is in no list (docs/CONTACTS.md, "Merge two contacts").
|
|
184
|
+
const where = [eq(contacts.organisationId, organisationId), isNull(contacts.mergedIntoId)];
|
|
147
185
|
if (kind) where.push(eq(contacts.kind, kind));
|
|
148
186
|
if (active === true) where.push(eq(contacts.active, true));
|
|
149
187
|
if (active === false) where.push(eq(contacts.active, false));
|
|
188
|
+
if (hidden === true) where.push(eq(contacts.hidden, true));
|
|
189
|
+
if (hidden === false) where.push(eq(contacts.hidden, false));
|
|
150
190
|
const needle = String(q ?? '').trim().replace(/[\\%_]/g, (c) => `\\${c}`);
|
|
151
191
|
if (needle) {
|
|
152
192
|
const like = `%${needle}%`;
|
|
153
|
-
where.push(or(ilike(contacts.name, like), ilike(contacts.email, like), ilike(contacts.accountNumber, like)
|
|
193
|
+
where.push(or(ilike(contacts.name, like), ilike(contacts.email, like), ilike(contacts.accountNumber, like),
|
|
194
|
+
sql`${contacts.aliases}::text ilike ${like}`));
|
|
154
195
|
}
|
|
155
196
|
const rows = await db.select().from(contacts).where(and(...where))
|
|
156
197
|
.orderBy(asc(contacts.name), asc(contacts.id))
|
|
@@ -158,3 +199,36 @@ export async function listContacts(db, organisationId, { q = '', kind = 'custome
|
|
|
158
199
|
.offset(offset);
|
|
159
200
|
return rows.map(presentContact);
|
|
160
201
|
}
|
|
202
|
+
|
|
203
|
+
// Bill to (docs/CONTACTS.md): the contact each customer's work is billed to
|
|
204
|
+
// in the accounting system. A customer with a payer bills to the payer; any
|
|
205
|
+
// other bills to itself. Answers a map for every id given.
|
|
206
|
+
export async function payersOf(db, organisationId, contactIds) {
|
|
207
|
+
const ids = [...new Set(contactIds.filter(Boolean))];
|
|
208
|
+
const out = new Map(ids.map((id) => [id, id]));
|
|
209
|
+
if (!ids.length) return out;
|
|
210
|
+
const rows = await db.select({ contactId: customerBillingSettings.contactId, payer: customerBillingSettings.billToContactId })
|
|
211
|
+
.from(customerBillingSettings)
|
|
212
|
+
.where(and(eq(customerBillingSettings.organisationId, organisationId), inArray(customerBillingSettings.contactId, ids),
|
|
213
|
+
isNotNull(customerBillingSettings.billToContactId)));
|
|
214
|
+
for (const r of rows) out.set(r.contactId, r.payer);
|
|
215
|
+
return out;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// The customers that bill to each payer given, the payer included.
|
|
219
|
+
export async function billedBy(db, organisationId, payerIds) {
|
|
220
|
+
const ids = [...new Set(payerIds.filter(Boolean))];
|
|
221
|
+
const out = new Map(ids.map((id) => [id, new Set([id])]));
|
|
222
|
+
if (!ids.length) return out;
|
|
223
|
+
const rows = await db.select({ contactId: customerBillingSettings.contactId, payer: customerBillingSettings.billToContactId })
|
|
224
|
+
.from(customerBillingSettings)
|
|
225
|
+
.where(and(eq(customerBillingSettings.organisationId, organisationId), inArray(customerBillingSettings.billToContactId, ids)));
|
|
226
|
+
for (const r of rows) out.get(r.payer).add(r.contactId);
|
|
227
|
+
return out;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// The contact one customer is billed as: its payer, else itself.
|
|
231
|
+
export async function billedContact(db, organisationId, contactId) {
|
|
232
|
+
const payer = (await payersOf(db, organisationId, [contactId])).get(contactId);
|
|
233
|
+
return getContact(db, organisationId, payer);
|
|
234
|
+
}
|
|
@@ -2,7 +2,7 @@ import { existsSync } from 'node:fs';
|
|
|
2
2
|
import { createRequire } from 'node:module';
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
4
|
import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm';
|
|
5
|
-
import { billingObligations, invoiceRuleExceptions, invoiceRules, invoicingSettings, obligationEvents, priceItems } from '../db/schema.js';
|
|
5
|
+
import { billingObligations, contacts, invoiceRuleExceptions, invoiceRules, invoicingSettings, obligationEvents, priceItems } from '../db/schema.js';
|
|
6
6
|
import { FlowError, notFound } from './errors.js';
|
|
7
7
|
import { getInvoice, updateDraftInvoice } from './invoices.js';
|
|
8
8
|
|
|
@@ -466,9 +466,21 @@ async function checkRuleItem(db, organisationId, rule) {
|
|
|
466
466
|
if (item.archivedAt) throw invalid(`price item ${code} is archived`);
|
|
467
467
|
}
|
|
468
468
|
|
|
469
|
+
// A rule never names a merged contact: it could never match
|
|
470
|
+
// (docs/CONTACTS.md, "Merge two contacts").
|
|
471
|
+
async function checkRuleCustomers(db, organisationId, rule) {
|
|
472
|
+
const ids = rule.conditions.filter((c) => c.field === 'customer' && c.value != null && !['contains', 'does_not_contain'].includes(c.test))
|
|
473
|
+
.flatMap((c) => (Array.isArray(c.value) ? c.value : [c.value])).filter((id) => /^[0-9a-f-]{36}$/i.test(id));
|
|
474
|
+
if (!ids.length) return;
|
|
475
|
+
const [merged] = await db.select({ name: contacts.name }).from(contacts).where(and(eq(contacts.organisationId, organisationId),
|
|
476
|
+
inArray(contacts.id, ids), sql`${contacts.mergedIntoId} is not null`)).limit(1);
|
|
477
|
+
if (merged) throw invalid('a customer in this rule was merged into another; pick that one');
|
|
478
|
+
}
|
|
479
|
+
|
|
469
480
|
export async function createRule(db, organisationId, raw, { personId = null } = {}) {
|
|
470
481
|
const rule = readRule(raw);
|
|
471
482
|
await checkRuleItem(db, organisationId, rule);
|
|
483
|
+
await checkRuleCustomers(db, organisationId, rule);
|
|
472
484
|
const existing = await listRules(db, organisationId);
|
|
473
485
|
if (existing.length >= MAX_RULES) throw invalid(`an organisation can have ${MAX_RULES} rules at most`);
|
|
474
486
|
const position = existing.length ? Math.max(...existing.map((r) => r.position)) + 1 : 0;
|
|
@@ -489,6 +501,7 @@ export async function updateRule(db, organisationId, ruleId, changes, { personId
|
|
|
489
501
|
if (!changes || typeof changes !== 'object' || Array.isArray(changes)) throw invalid('the change must be an object');
|
|
490
502
|
const rule = readRule({ name: row.name, icon: row.icon, enabled: row.enabled, conditions: row.conditions, action: row.action, ...changes });
|
|
491
503
|
if (rule.action.addItem && rule.action.addItem !== row.action?.addItem) await checkRuleItem(db, organisationId, rule);
|
|
504
|
+
if (Object.prototype.hasOwnProperty.call(changes, 'conditions')) await checkRuleCustomers(db, organisationId, rule);
|
|
492
505
|
const [saved] = await db.update(invoiceRules).set({ ...rule, updatedBy: personId, updatedAt: sql`now()` })
|
|
493
506
|
.where(eq(invoiceRules.id, row.id)).returning();
|
|
494
507
|
return shape(saved);
|
package/server/core/invoices.js
CHANGED
|
@@ -81,6 +81,7 @@ export async function createDraftInvoice(db, organisationId, { contactId, refere
|
|
|
81
81
|
.where(and(eq(contacts.organisationId, organisationId), eq(contacts.id, contactId)));
|
|
82
82
|
if (!contact) throw new FlowError('invalid', 'contact not found');
|
|
83
83
|
if (contact.kind !== 'customer') throw new FlowError('invalid', 'an invoice goes to a customer contact');
|
|
84
|
+
if (contact.mergedIntoId) throw new FlowError('invalid', 'this contact was merged into another; pick that one');
|
|
84
85
|
const resolved = await resolveLines(tx, organisationId, lines);
|
|
85
86
|
const [invoice] = await tx.insert(invoices)
|
|
86
87
|
.values({ organisationId, contactId, reference, dueDate })
|
|
@@ -99,6 +100,7 @@ export async function updateDraftInvoice(db, organisationId, invoiceId, changes)
|
|
|
99
100
|
.where(and(eq(contacts.organisationId, organisationId), eq(contacts.id, changes.contactId)));
|
|
100
101
|
if (!contact) throw new FlowError('invalid', 'contact not found');
|
|
101
102
|
if (contact.kind !== 'customer') throw new FlowError('invalid', 'an invoice goes to a customer contact');
|
|
103
|
+
if (contact.mergedIntoId) throw new FlowError('invalid', 'this contact was merged into another; pick that one');
|
|
102
104
|
fields.contactId = changes.contactId;
|
|
103
105
|
}
|
|
104
106
|
if ('reference' in changes) fields.reference = changes.reference;
|
|
@@ -2,6 +2,7 @@ import { and, eq, inArray, isNull, sql } from 'drizzle-orm';
|
|
|
2
2
|
import {
|
|
3
3
|
billingObligations, contacts, customerBillingSettings, externalLinks, invoicingSettings, SEND_MODES,
|
|
4
4
|
} from '../db/schema.js';
|
|
5
|
+
import { payersOf } from './contacts.js';
|
|
5
6
|
import { FlowError, notFound } from './errors.js';
|
|
6
7
|
import { HISTORY_HOLD_REASON } from './history.js';
|
|
7
8
|
import { accountingItemsFor, listRules, ruleNonChargeable, ruleReasons } from './invoice-rules.js';
|
|
@@ -38,8 +39,9 @@ export const INVOICING_DEFAULTS = Object.freeze({
|
|
|
38
39
|
});
|
|
39
40
|
|
|
40
41
|
const CUSTOMER_DEFAULTS = Object.freeze({
|
|
41
|
-
billable: true, defaultPractitioner: null, autoDraft: null, sendMode: null, paymentTermsDays: null,
|
|
42
|
+
billable: true, defaultPractitioner: null, autoDraft: null, sendMode: null, paymentTermsDays: null, billToContactId: null,
|
|
42
43
|
});
|
|
44
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
43
45
|
|
|
44
46
|
const pick = (row, keys) => Object.fromEntries(keys.map((k) => [k, row[k]]));
|
|
45
47
|
const SETTING_KEYS = Object.keys(INVOICING_DEFAULTS);
|
|
@@ -108,10 +110,11 @@ export async function saveInvoicingSettings(db, organisationId, changes, { perso
|
|
|
108
110
|
}
|
|
109
111
|
|
|
110
112
|
async function requireCustomer(db, organisationId, contactId) {
|
|
111
|
-
const [contact] = await db.select({ id: contacts.id, kind: contacts.kind }).from(contacts)
|
|
113
|
+
const [contact] = await db.select({ id: contacts.id, kind: contacts.kind, mergedIntoId: contacts.mergedIntoId }).from(contacts)
|
|
112
114
|
.where(and(eq(contacts.organisationId, organisationId), eq(contacts.id, contactId)));
|
|
113
115
|
if (!contact) throw notFound('customer');
|
|
114
116
|
if (contact.kind !== 'customer') throw invalid('invoicing settings are for a customer contact');
|
|
117
|
+
return contact;
|
|
115
118
|
}
|
|
116
119
|
|
|
117
120
|
// One customer's own settings. A null value takes the organisation's setting.
|
|
@@ -124,7 +127,23 @@ export async function readCustomerBilling(db, organisationId, contactId) {
|
|
|
124
127
|
|
|
125
128
|
export async function saveCustomerBilling(db, organisationId, contactId, changes, { personId = null } = {}) {
|
|
126
129
|
if (!changes || typeof changes !== 'object' || Array.isArray(changes)) throw invalid('the settings must be an object');
|
|
127
|
-
await requireCustomer(db, organisationId, contactId);
|
|
130
|
+
const customer = await requireCustomer(db, organisationId, contactId);
|
|
131
|
+
if (customer.mergedIntoId) throw invalid('this customer was merged into another; change that one');
|
|
132
|
+
// Bill to is checked and saved under one lock per organisation, so two
|
|
133
|
+
// saves at once never make a chain of payers.
|
|
134
|
+
if (Object.prototype.hasOwnProperty.call(changes, 'billToContactId')) {
|
|
135
|
+
return db.transaction(async (tx) => {
|
|
136
|
+
await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${`bill_to:${organisationId}`}))`);
|
|
137
|
+
// A merge takes the same lock: read again inside it.
|
|
138
|
+
if ((await requireCustomer(tx, organisationId, contactId)).mergedIntoId) throw invalid('this customer was merged into another; change that one');
|
|
139
|
+
return saveBilling(tx, organisationId, contactId, changes, personId);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
return saveBilling(db, organisationId, contactId, changes, personId);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function saveBilling(db, organisationId, contactId, changes, personId) {
|
|
146
|
+
const before = (await readCustomerBilling(db, organisationId, contactId)).billToContactId;
|
|
128
147
|
const fields = {};
|
|
129
148
|
for (const [key, value] of Object.entries(changes)) {
|
|
130
149
|
if (key === 'billable') fields[key] = flag(value, key);
|
|
@@ -132,6 +151,7 @@ export async function saveCustomerBilling(db, organisationId, contactId, changes
|
|
|
132
151
|
else if (key === 'autoDraft') fields[key] = value === null ? null : flag(value, key);
|
|
133
152
|
else if (key === 'sendMode') fields[key] = value === null ? null : sendMode(value, key);
|
|
134
153
|
else if (key === 'paymentTermsDays') fields[key] = value === null ? null : wholeNumber(value, key, 0, 365);
|
|
154
|
+
else if (key === 'billToContactId') fields[key] = await billTo(db, organisationId, contactId, value);
|
|
135
155
|
else throw invalid(`unknown setting: ${key}`);
|
|
136
156
|
}
|
|
137
157
|
await db.insert(customerBillingSettings)
|
|
@@ -140,9 +160,38 @@ export async function saveCustomerBilling(db, organisationId, contactId, changes
|
|
|
140
160
|
target: [customerBillingSettings.organisationId, customerBillingSettings.contactId],
|
|
141
161
|
set: { ...fields, updatedBy: personId, updatedAt: sql`now()` },
|
|
142
162
|
});
|
|
163
|
+
// A new payer is a new place to look: the matcher checks this customer's
|
|
164
|
+
// unbilled jobs again on its next pass.
|
|
165
|
+
if ('billToContactId' in fields && fields.billToContactId !== before) {
|
|
166
|
+
await db.update(billingObligations).set({ accountingCheckedAt: null, updatedAt: sql`now()` }).where(and(
|
|
167
|
+
eq(billingObligations.organisationId, organisationId), eq(billingObligations.contactId, contactId),
|
|
168
|
+
isNull(billingObligations.accountingRef), isNull(billingObligations.invoiceId),
|
|
169
|
+
));
|
|
170
|
+
}
|
|
143
171
|
return readCustomerBilling(db, organisationId, contactId);
|
|
144
172
|
}
|
|
145
173
|
|
|
174
|
+
// Bill to (docs/CONTACTS.md): another customer, the payer, whose accounting
|
|
175
|
+
// contact this customer's work is billed to. One level only: a payer bills
|
|
176
|
+
// to itself, and a customer that is a payer names no payer.
|
|
177
|
+
async function billTo(db, organisationId, contactId, value) {
|
|
178
|
+
if (value === null) return null;
|
|
179
|
+
if (typeof value !== 'string' || !UUID.test(value)) throw invalid('billToContactId must name a customer');
|
|
180
|
+
if (value === contactId) throw invalid('a customer cannot bill to itself; clear Bill to instead');
|
|
181
|
+
const [payer] = await db.select({ id: contacts.id, kind: contacts.kind, name: contacts.name, mergedIntoId: contacts.mergedIntoId }).from(contacts)
|
|
182
|
+
.where(and(eq(contacts.organisationId, organisationId), eq(contacts.id, value)));
|
|
183
|
+
if (!payer) throw notFound('customer');
|
|
184
|
+
if (payer.kind !== 'customer') throw invalid('the payer must be a customer');
|
|
185
|
+
if (payer.mergedIntoId) throw invalid(`${payer.name} was merged into another customer; pick that one`);
|
|
186
|
+
const [payerOwn] = await db.select({ billTo: customerBillingSettings.billToContactId }).from(customerBillingSettings)
|
|
187
|
+
.where(and(eq(customerBillingSettings.organisationId, organisationId), eq(customerBillingSettings.contactId, value)));
|
|
188
|
+
if (payerOwn?.billTo) throw invalid(`${payer.name} bills to another customer; pick that one`);
|
|
189
|
+
const [billed] = await db.select({ id: customerBillingSettings.contactId }).from(customerBillingSettings)
|
|
190
|
+
.where(and(eq(customerBillingSettings.organisationId, organisationId), eq(customerBillingSettings.billToContactId, contactId))).limit(1);
|
|
191
|
+
if (billed) throw invalid('other customers bill to this customer, so it cannot bill to another');
|
|
192
|
+
return value;
|
|
193
|
+
}
|
|
194
|
+
|
|
146
195
|
// The customers' own settings, by contact id.
|
|
147
196
|
export async function customerBillingByContact(db, organisationId, contactIds) {
|
|
148
197
|
const ids = [...new Set(contactIds.filter(Boolean))];
|
|
@@ -263,9 +312,11 @@ const SAME_REFERENCE_DAYS = 90;
|
|
|
263
312
|
export function copyCheck(accounting, organisationId, { db = null } = {}) {
|
|
264
313
|
const contactOf = async (contactId) => {
|
|
265
314
|
if (!db || !contactId) return null;
|
|
315
|
+
// A customer with a payer is billed under the payer's contact (Bill to).
|
|
316
|
+
const payer = (await payersOf(db, organisationId, [contactId])).get(contactId);
|
|
266
317
|
const [link] = await db.select({ externalId: externalLinks.externalId }).from(externalLinks).where(and(
|
|
267
318
|
eq(externalLinks.organisationId, organisationId), eq(externalLinks.system, accounting.id),
|
|
268
|
-
eq(externalLinks.entity, 'contact'), eq(externalLinks.state, 'active'), eq(externalLinks.flowId,
|
|
319
|
+
eq(externalLinks.entity, 'contact'), eq(externalLinks.state, 'active'), eq(externalLinks.flowId, payer),
|
|
269
320
|
));
|
|
270
321
|
return link?.externalId ?? null;
|
|
271
322
|
};
|
package/server/core/ledger.js
CHANGED
|
@@ -828,6 +828,26 @@ export async function freeVoidedJob(db, organisationId, obligationId, { actor, r
|
|
|
828
828
|
});
|
|
829
829
|
}
|
|
830
830
|
|
|
831
|
+
// A person found that the accounting invoice a job points at is not its bill
|
|
832
|
+
// (a wrong pair). The job lets go of it: it is unchecked again, and the
|
|
833
|
+
// matcher never pairs the two again. Nothing else on the job changes. A job
|
|
834
|
+
// that still holds a Flow invoice keeps it; free that first.
|
|
835
|
+
export async function releaseAccountingRef(db, organisationId, obligationId, { accountingRef, system, actor, reason }) {
|
|
836
|
+
requireActor(actor);
|
|
837
|
+
if (!reason?.trim()) throw new FlowError('invalid', 'say why the accounting invoice is released');
|
|
838
|
+
return db.transaction(async (tx) => {
|
|
839
|
+
const obligation = await loadObligation(tx, organisationId, obligationId, { lock: true });
|
|
840
|
+
if (obligation.accountingRef !== accountingRef) return obligation;
|
|
841
|
+
if (obligation.invoiceId) throw new FlowError('invalid', 'the job still holds a Flow invoice: free it first');
|
|
842
|
+
const row = await update(tx, obligation, {
|
|
843
|
+
accountingState: 'unknown', accountingRef: null, accountingUrl: null, accountingMatchedBy: null, accountingCheckedAt: null,
|
|
844
|
+
});
|
|
845
|
+
await tx.insert(linkRejections).values({ organisationId, obligationId: obligation.id, system, externalId: accountingRef, actor }).onConflictDoNothing();
|
|
846
|
+
await addEvent(tx, row, 'accounting_released', actor, { released: accountingRef, reason: reason.trim(), from: obligation.accountingState });
|
|
847
|
+
return row;
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
|
|
831
851
|
// A job with no invoice of its own whose accounting evidence is ambiguous:
|
|
832
852
|
// the matcher found accounting invoices that may be this job's, but none
|
|
833
853
|
// exactly. Or the accounting invoice the matcher linked was voided or deleted
|
|
@@ -912,13 +932,16 @@ export async function linkJobToInvoice(db, organisationId, obligationId, { invoi
|
|
|
912
932
|
// A job that waited for a person (no invoice found, or more than one fit)
|
|
913
933
|
// is settled now: the matcher reads it again on its next pass and records
|
|
914
934
|
// the invoice's stage. A link made from an accounting invoice
|
|
915
|
-
// (`accountingRef`) is exact, so the matcher follows that invoice
|
|
935
|
+
// (`accountingRef`) is exact, so the matcher follows that invoice: the
|
|
936
|
+
// job is due on its next pass, whatever state it was in (a job with no
|
|
937
|
+
// accounting contact was `unknown`, checked, and would wait for the copy
|
|
938
|
+
// to change).
|
|
916
939
|
const settled = ['not_found', 'ambiguous'].includes(obligation.accountingState);
|
|
917
940
|
const row = await update(tx, obligation, {
|
|
918
941
|
invoiceId: invoice.id,
|
|
919
942
|
draftBasis: basisOf(obligation),
|
|
920
943
|
...(settled ? { accountingState: 'unknown', accountingCheckedAt: null } : {}),
|
|
921
|
-
...(accountingRef && !obligation.accountingRef ? { accountingRef } : {}),
|
|
944
|
+
...(accountingRef && !obligation.accountingRef ? { accountingRef, accountingCheckedAt: null } : {}),
|
|
922
945
|
...(obligation.readyAt ? {} : { readyAt: sql`now()`, readyBy: actor }),
|
|
923
946
|
});
|
|
924
947
|
await addEvent(tx, row, 'invoice_linked', actor, { invoiceId: invoice.id, matchedBy: 'person', rejected });
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm';
|
|
2
|
+
import { FlowError, notFound } from './errors.js';
|
|
3
|
+
|
|
4
|
+
// Merge two contacts (docs/CONTACTS.md, "Merge two contacts"). One client is
|
|
5
|
+
// sometimes two Flow contacts: the work system's name and the accounting
|
|
6
|
+
// system's longer name. A person merges them in one step. The kept contact
|
|
7
|
+
// gets the other's jobs, invoices, links, rule values and Bill to rows, and
|
|
8
|
+
// keeps its name as an alias. The merged row stays, out of every list; Flow
|
|
9
|
+
// never deletes it and never merges without a person.
|
|
10
|
+
|
|
11
|
+
const invalid = (message, details) => new FlowError('invalid', message, details);
|
|
12
|
+
const conflict = (message, details) => new FlowError('conflict', message, details);
|
|
13
|
+
|
|
14
|
+
const ID_TESTS = new Set(['is', 'is_not', 'is_one_of', 'is_not_one_of']);
|
|
15
|
+
|
|
16
|
+
// A rule's conditions with the merged contact's id changed to the kept one,
|
|
17
|
+
// or null when no condition names it.
|
|
18
|
+
export function rewriteConditions(conditions, fromId, keepId) {
|
|
19
|
+
let changed = false;
|
|
20
|
+
const out = (conditions ?? []).map((c) => {
|
|
21
|
+
if (c?.field !== 'customer' || !ID_TESTS.has(c.test)) return c;
|
|
22
|
+
if (Array.isArray(c.value)) {
|
|
23
|
+
if (!c.value.includes(fromId)) return c;
|
|
24
|
+
changed = true;
|
|
25
|
+
return { ...c, value: [...new Set(c.value.map((v) => (v === fromId ? keepId : v)))] };
|
|
26
|
+
}
|
|
27
|
+
if (c.value !== fromId) return c;
|
|
28
|
+
changed = true;
|
|
29
|
+
return { ...c, value: keepId };
|
|
30
|
+
});
|
|
31
|
+
return changed ? out : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function lockedContact(tx, organisationId, id) {
|
|
35
|
+
const { rows: [row] } = await tx.execute(sql`select id, kind, name, aliases, merged_into_id from contacts
|
|
36
|
+
where organisation_id = ${organisationId} and id = ${id} for update`);
|
|
37
|
+
if (!row) throw notFound('contact');
|
|
38
|
+
return row;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Answers what moved: { keep, from, moved: { jobs, invoices, links, rules, billTo } }.
|
|
42
|
+
export async function mergeContacts(db, organisationId, { keepId, fromId, actor }) {
|
|
43
|
+
if (!actor) throw new TypeError('actor is required');
|
|
44
|
+
if (!keepId || !fromId) throw invalid('keepId and fromId are required');
|
|
45
|
+
if (keepId === fromId) throw invalid('pick two different contacts');
|
|
46
|
+
return db.transaction(async (tx) => {
|
|
47
|
+
// The same lock as Bill to, so a Bill to save and a merge take turns.
|
|
48
|
+
await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${`bill_to:${organisationId}`}))`);
|
|
49
|
+
const [first, second] = [keepId, fromId].sort();
|
|
50
|
+
const rows = new Map();
|
|
51
|
+
for (const id of [first, second]) rows.set(id, await lockedContact(tx, organisationId, id));
|
|
52
|
+
const keep = rows.get(keepId);
|
|
53
|
+
const from = rows.get(fromId);
|
|
54
|
+
if (keep.merged_into_id || from.merged_into_id) throw conflict('one of these contacts was already merged');
|
|
55
|
+
if (keep.kind !== from.kind) throw invalid('a customer merges only with a customer, a supplier only with a supplier');
|
|
56
|
+
|
|
57
|
+
// Links: the kept contact may hold one active link for each system.
|
|
58
|
+
const { rows: links } = await tx.execute(sql`select id, system, external_id, state, flow_id from external_links
|
|
59
|
+
where organisation_id = ${organisationId} and entity = 'contact' and flow_id in (${keepId}, ${fromId}) and state <> 'superseded'`);
|
|
60
|
+
const activeKeep = new Map(links.filter((l) => l.flow_id === keepId && l.state === 'active').map((l) => [l.system, l]));
|
|
61
|
+
for (const l of links) {
|
|
62
|
+
if (l.flow_id === fromId && l.state === 'active' && activeKeep.has(l.system)) {
|
|
63
|
+
throw conflict(`both contacts are linked in ${l.system}; keep one link first`, { system: l.system });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Bill to stays one level (docs/CONTACTS.md, "Bill to").
|
|
68
|
+
const { rows: billing } = await tx.execute(sql`select contact_id, bill_to_contact_id from customer_billing_settings
|
|
69
|
+
where organisation_id = ${organisationId} and (contact_id in (${keepId}, ${fromId}) or bill_to_contact_id in (${keepId}, ${fromId}))`);
|
|
70
|
+
const own = (id) => billing.find((b) => b.contact_id === id) ?? null;
|
|
71
|
+
const payerOf = (id) => own(id)?.bill_to_contact_id ?? null;
|
|
72
|
+
const outside = (id) => (id === keepId || id === fromId ? null : id);
|
|
73
|
+
const keepPayer = outside(payerOf(keepId));
|
|
74
|
+
const fromPayer = outside(payerOf(fromId));
|
|
75
|
+
if (keepPayer && fromPayer && keepPayer !== fromPayer) throw conflict('the two contacts bill to different payers; clear one Bill to first');
|
|
76
|
+
// The kept contact bills to the payer either one had.
|
|
77
|
+
const payer = keepPayer ?? fromPayer;
|
|
78
|
+
const clients = billing.filter((b) => [keepId, fromId].includes(b.bill_to_contact_id) && ![keepId, fromId].includes(b.contact_id));
|
|
79
|
+
if (payer && clients.length) throw conflict('the merged contact would bill to a payer and be a payer; clear Bill to first');
|
|
80
|
+
|
|
81
|
+
// Jobs: each moved job notes the merge, and an unbilled one goes back to
|
|
82
|
+
// the matcher under its new customer.
|
|
83
|
+
const { rows: jobs } = await tx.execute(sql`update billing_obligations set contact_id = ${keepId},
|
|
84
|
+
draft_basis = case when draft_basis->>'contactId' = ${fromId} then jsonb_set(draft_basis, '{contactId}', to_jsonb(${keepId}::text)) else draft_basis end,
|
|
85
|
+
updated_at = now()
|
|
86
|
+
where organisation_id = ${organisationId} and contact_id = ${fromId} returning id`);
|
|
87
|
+
if (jobs.length) {
|
|
88
|
+
const evidence = JSON.stringify({ from: fromId, to: keepId });
|
|
89
|
+
await tx.execute(sql`insert into obligation_events (organisation_id, obligation_id, kind, actor, evidence)
|
|
90
|
+
select ${organisationId}, id, 'contact_merged', ${actor}, ${evidence}::jsonb from unnest(${sql.param(jobs.map((j) => j.id))}::uuid[]) as id`);
|
|
91
|
+
}
|
|
92
|
+
const { rows: invoices } = await tx.execute(sql`update invoices set contact_id = ${keepId}, updated_at = now()
|
|
93
|
+
where organisation_id = ${organisationId} and contact_id = ${fromId} returning id`);
|
|
94
|
+
|
|
95
|
+
// Links move; a guess that the kept contact already answers is closed,
|
|
96
|
+
// with its review item.
|
|
97
|
+
let movedLinks = 0;
|
|
98
|
+
for (const l of links.filter((x) => x.flow_id === fromId)) {
|
|
99
|
+
if (l.state === 'proposed' && activeKeep.has(l.system)) {
|
|
100
|
+
await tx.execute(sql`update external_links set state = 'superseded', updated_at = now() where id = ${l.id}`);
|
|
101
|
+
await tx.execute(sql`update review_items set state = 'resolved', resolution = 'contact_merged', resolved_by = ${actor},
|
|
102
|
+
resolved_at = now(), updated_at = now()
|
|
103
|
+
where organisation_id = ${organisationId} and entity = 'contact' and flow_id = ${fromId} and state = 'open'
|
|
104
|
+
and kind = 'link.proposed' and evidence->>'linkId' = ${l.id}`);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
await tx.execute(sql`update external_links set flow_id = ${keepId}, updated_at = now() where id = ${l.id}`);
|
|
108
|
+
movedLinks += 1;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Rules that name the merged contact name the kept one.
|
|
112
|
+
const { rows: rules } = await tx.execute(sql`select id, conditions from invoice_rules
|
|
113
|
+
where organisation_id = ${organisationId} and conditions::text like ${`%${fromId}%`}`);
|
|
114
|
+
let movedRules = 0;
|
|
115
|
+
for (const rule of rules) {
|
|
116
|
+
const next = rewriteConditions(rule.conditions, fromId, keepId);
|
|
117
|
+
if (!next) continue;
|
|
118
|
+
await tx.execute(sql`update invoice_rules set conditions = ${JSON.stringify(next)}::jsonb, updated_at = now() where id = ${rule.id}`);
|
|
119
|
+
movedRules += 1;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Bill to: clients of the merged contact bill to the kept one. The kept
|
|
123
|
+
// contact takes the merged one's own row when it has none, and bills to
|
|
124
|
+
// the payer either had. The merged contact's leftover row names no payer.
|
|
125
|
+
const { rows: billTo } = await tx.execute(sql`update customer_billing_settings
|
|
126
|
+
set bill_to_contact_id = case when contact_id = ${keepId} then null else ${keepId}::uuid end, updated_at = now()
|
|
127
|
+
where organisation_id = ${organisationId} and bill_to_contact_id = ${fromId} returning contact_id`);
|
|
128
|
+
let movedBillTo = billTo.filter((b) => b.contact_id !== keepId).length;
|
|
129
|
+
if (own(fromId) && !own(keepId)) {
|
|
130
|
+
await tx.execute(sql`update customer_billing_settings set contact_id = ${keepId}, bill_to_contact_id = ${payer}, updated_at = now()
|
|
131
|
+
where organisation_id = ${organisationId} and contact_id = ${fromId}`);
|
|
132
|
+
movedBillTo += 1;
|
|
133
|
+
} else {
|
|
134
|
+
if (own(keepId)) {
|
|
135
|
+
await tx.execute(sql`update customer_billing_settings set bill_to_contact_id = ${payer}, updated_at = now()
|
|
136
|
+
where organisation_id = ${organisationId} and contact_id = ${keepId}`);
|
|
137
|
+
}
|
|
138
|
+
if (own(fromId)) {
|
|
139
|
+
await tx.execute(sql`update customer_billing_settings set bill_to_contact_id = null, updated_at = now()
|
|
140
|
+
where organisation_id = ${organisationId} and contact_id = ${fromId}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Every unbilled job of the kept contact, and of the clients that bill to
|
|
145
|
+
// it, goes back to the matcher: its links and payer may have changed.
|
|
146
|
+
await tx.execute(sql`update billing_obligations set accounting_checked_at = null
|
|
147
|
+
where organisation_id = ${organisationId} and accounting_ref is null and invoice_id is null
|
|
148
|
+
and (contact_id = ${keepId} or contact_id in (select contact_id from customer_billing_settings
|
|
149
|
+
where organisation_id = ${organisationId} and bill_to_contact_id = ${keepId}))`);
|
|
150
|
+
|
|
151
|
+
// Open review items of the merged contact go to the kept one, unless it
|
|
152
|
+
// has one of the same kind open; those close.
|
|
153
|
+
await tx.execute(sql`update review_items r set flow_id = ${keepId}, updated_at = now()
|
|
154
|
+
where r.organisation_id = ${organisationId} and r.entity = 'contact' and r.flow_id = ${fromId} and r.state = 'open'
|
|
155
|
+
and not exists (select 1 from review_items o where o.organisation_id = r.organisation_id and o.kind = r.kind
|
|
156
|
+
and o.entity = 'contact' and o.flow_id = ${keepId} and o.state = 'open')`);
|
|
157
|
+
await tx.execute(sql`update review_items set state = 'resolved', resolution = 'contact_merged', resolved_by = ${actor},
|
|
158
|
+
resolved_at = now(), updated_at = now()
|
|
159
|
+
where organisation_id = ${organisationId} and entity = 'contact' and flow_id = ${fromId} and state = 'open'`);
|
|
160
|
+
|
|
161
|
+
// The names: the kept contact answers to the merged one's names too.
|
|
162
|
+
const names = [from.name, ...(from.aliases ?? []), ...(keep.aliases ?? [])];
|
|
163
|
+
const aliases = [...new Set(names.filter((n) => n && n !== keep.name))].slice(0, 20);
|
|
164
|
+
await tx.execute(sql`update contacts set aliases = ${JSON.stringify(aliases)}::jsonb, updated_at = now()
|
|
165
|
+
where organisation_id = ${organisationId} and id = ${keepId}`);
|
|
166
|
+
// The merged row frees its name, so the kept contact can take it; the
|
|
167
|
+
// name lives on as the kept contact's alias. Earlier merges into the
|
|
168
|
+
// merged contact point at the kept one.
|
|
169
|
+
const freed = `${from.name.slice(0, 230)} (merged ${fromId.slice(0, 8)})`;
|
|
170
|
+
await tx.execute(sql`update contacts set merged_into_id = ${keepId}, merged_at = now(), merged_by = ${actor},
|
|
171
|
+
name = ${freed}, hidden = true, active = false, updated_at = now()
|
|
172
|
+
where organisation_id = ${organisationId} and id = ${fromId}`);
|
|
173
|
+
await tx.execute(sql`update contacts set merged_into_id = ${keepId}, updated_at = now()
|
|
174
|
+
where organisation_id = ${organisationId} and merged_into_id = ${fromId}`);
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
keep: keepId,
|
|
178
|
+
from: fromId,
|
|
179
|
+
moved: { jobs: jobs.length, invoices: invoices.length, links: movedLinks, rules: movedRules, billTo: movedBillTo },
|
|
180
|
+
};
|
|
181
|
+
});
|
|
182
|
+
}
|