@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
|
@@ -211,6 +211,27 @@
|
|
|
211
211
|
"when": 1794463202822,
|
|
212
212
|
"tag": "0029_job_remake",
|
|
213
213
|
"breakpoints": true
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
"idx": 30,
|
|
217
|
+
"version": "7",
|
|
218
|
+
"when": 1794487419926,
|
|
219
|
+
"tag": "0030_contact_hidden",
|
|
220
|
+
"breakpoints": true
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
"idx": 31,
|
|
224
|
+
"version": "7",
|
|
225
|
+
"when": 1794488676084,
|
|
226
|
+
"tag": "0031_bill_to",
|
|
227
|
+
"breakpoints": true
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
"idx": 32,
|
|
231
|
+
"version": "7",
|
|
232
|
+
"when": 1794491329391,
|
|
233
|
+
"tag": "0032_contact_merge",
|
|
234
|
+
"breakpoints": true
|
|
214
235
|
}
|
|
215
236
|
]
|
|
216
237
|
}
|
package/server/db/schema.js
CHANGED
|
@@ -61,10 +61,27 @@ export const contacts = pgTable('contacts', {
|
|
|
61
61
|
address: jsonb('address'),
|
|
62
62
|
practitioners: jsonb('practitioners').notNull().default([]),
|
|
63
63
|
active: boolean('active').notNull().default(true),
|
|
64
|
+
// Not a client: Flow made the contact from an accounting contact that the
|
|
65
|
+
// accounting system does not mark a customer, and nothing in Flow makes it
|
|
66
|
+
// one. It stays, out of the lists (docs/CONTACTS.md, "Hidden contacts").
|
|
67
|
+
hidden: boolean('hidden').notNull().default(false),
|
|
68
|
+
// Merge (docs/CONTACTS.md, "Merge two contacts"): a person merged this
|
|
69
|
+
// contact into another. Its jobs, invoices and links moved there; the row
|
|
70
|
+
// stays, out of every list. The kept contact holds its name in `aliases`.
|
|
71
|
+
mergedIntoId: uuid('merged_into_id'),
|
|
72
|
+
mergedAt: timestamp('merged_at', { withTimezone: true }),
|
|
73
|
+
mergedBy: text('merged_by'),
|
|
74
|
+
aliases: jsonb('aliases').notNull().default([]),
|
|
64
75
|
createdAt: createdAt(),
|
|
65
76
|
updatedAt: updatedAt(),
|
|
66
77
|
}, (t) => [
|
|
67
78
|
unique('contacts_org_id').on(t.organisationId, t.id),
|
|
79
|
+
foreignKey({
|
|
80
|
+
name: 'contacts_merged_into_same_org',
|
|
81
|
+
columns: [t.organisationId, t.mergedIntoId],
|
|
82
|
+
foreignColumns: [t.organisationId, t.id],
|
|
83
|
+
}),
|
|
84
|
+
check('contacts_merged_into_other', sql`${t.mergedIntoId} is null or ${t.mergedIntoId} <> ${t.id}`),
|
|
68
85
|
unique('contacts_org_kind_name').on(t.organisationId, t.kind, t.name),
|
|
69
86
|
check('contacts_kind_valid', sql`${t.kind} in ('customer', 'supplier')`),
|
|
70
87
|
]);
|
|
@@ -678,6 +695,10 @@ export const customerBillingSettings = pgTable('customer_billing_settings', {
|
|
|
678
695
|
autoDraft: boolean('auto_draft'),
|
|
679
696
|
sendMode: text('send_mode'),
|
|
680
697
|
paymentTermsDays: integer('payment_terms_days'),
|
|
698
|
+
// The payer: another Flow customer whose accounting contact this
|
|
699
|
+
// customer's work is billed to (a parent organisation). Many customers may
|
|
700
|
+
// name one payer. Null bills the customer itself (docs/CONTACTS.md, "Bill to").
|
|
701
|
+
billToContactId: uuid('bill_to_contact_id'),
|
|
681
702
|
updatedBy: uuid('updated_by').references(() => people.id, { onDelete: 'set null' }),
|
|
682
703
|
updatedAt: updatedAt(),
|
|
683
704
|
}, (t) => [
|
|
@@ -687,6 +708,13 @@ export const customerBillingSettings = pgTable('customer_billing_settings', {
|
|
|
687
708
|
columns: [t.organisationId, t.contactId],
|
|
688
709
|
foreignColumns: [contacts.organisationId, contacts.id],
|
|
689
710
|
}),
|
|
711
|
+
foreignKey({
|
|
712
|
+
name: 'customer_billing_settings_bill_to_same_org',
|
|
713
|
+
columns: [t.organisationId, t.billToContactId],
|
|
714
|
+
foreignColumns: [contacts.organisationId, contacts.id],
|
|
715
|
+
}),
|
|
716
|
+
index('customer_billing_settings_bill_to').on(t.organisationId, t.billToContactId),
|
|
717
|
+
check('customer_billing_settings_bill_to_other', sql`${t.billToContactId} is null or ${t.billToContactId} <> ${t.contactId}`),
|
|
690
718
|
check('customer_billing_settings_send_mode_valid', sql`${t.sendMode} is null or ${t.sendMode} in ('accounting_draft', 'approve_and_email')`),
|
|
691
719
|
check('customer_billing_settings_terms_valid', sql`${t.paymentTermsDays} is null or ${t.paymentTermsDays} between 0 and 365`),
|
|
692
720
|
]);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { sql } from 'drizzle-orm';
|
|
3
|
-
import { getContact } from '../../core/contacts.js';
|
|
3
|
+
import { billedContact, getContact } from '../../core/contacts.js';
|
|
4
4
|
import { flowIdFor, linkRecord, supersedeLink } from '../../core/links.js';
|
|
5
5
|
import { mapContact, matchContactFor, normaliseName, upsertContact } from './catalogue.js';
|
|
6
6
|
import { xeroError } from './client.js';
|
|
@@ -79,8 +79,20 @@ export async function matchCustomer(db, runtime, { organisationId, contactId })
|
|
|
79
79
|
const connected = Boolean(connection && connection.mode === runtime.settings.mode);
|
|
80
80
|
if (!connected) return { state: 'off', offerCreate: false, offerLink: false, xeroContactId: null, name: null, differs: [] };
|
|
81
81
|
const contact = await getContact(db, organisationId, contactId);
|
|
82
|
+
// Bill to: a customer with a payer is billed as the payer. Link and Create
|
|
83
|
+
// are for the payer's own page.
|
|
84
|
+
const payer = await billedContact(db, organisationId, contactId);
|
|
85
|
+
if (payer.id !== contactId) {
|
|
86
|
+
const billed = await matchContactFor(db, organisationId, { contactId: payer.id, name: payer.name });
|
|
87
|
+
return {
|
|
88
|
+
state: billed.state, offerCreate: false, offerLink: false, differs: [],
|
|
89
|
+
xeroContactId: billed.contact?.xeroContactId ?? null, name: billed.contact?.name ?? null,
|
|
90
|
+
billTo: { id: payer.id, name: payer.name },
|
|
91
|
+
};
|
|
92
|
+
}
|
|
82
93
|
const match = await matchContactFor(db, organisationId, { contactId, name: contact.name });
|
|
83
94
|
return {
|
|
95
|
+
billTo: null,
|
|
84
96
|
// Fields both sides changed (Two-way edits, contact-sync.js): alert only.
|
|
85
97
|
differs: match.state === 'matched' ? (await contactClashes(db, organisationId, contactId)).map(({ field, flow, xero }) => ({ field, flow, xero })) : [],
|
|
86
98
|
state: match.state,
|
|
@@ -97,6 +109,7 @@ export async function createCustomer(db, runtime, { organisationId, contactId, a
|
|
|
97
109
|
}
|
|
98
110
|
runtime.assertWritesAllowed();
|
|
99
111
|
const flowContact = await getContact(db, organisationId, contactId);
|
|
112
|
+
if (flowContact.mergedIntoId) throw xeroError('contact_merged', 'This contact was merged into another. Use that one.', { status: 409 });
|
|
100
113
|
const known = await matchContactFor(db, organisationId, { contactId, name: flowContact.name });
|
|
101
114
|
if (known.state === 'ambiguous') {
|
|
102
115
|
throw xeroError('xero_contact_ambiguous', `More than one Xero contact is named "${flowContact.name}". Merge or rename them in Xero, then sync.`, { status: 409 });
|
|
@@ -140,6 +153,7 @@ export async function linkCustomer(db, runtime, { organisationId, contactId, xer
|
|
|
140
153
|
throw xeroError('xero_not_connected', 'Connect Xero first.', { status: 409 });
|
|
141
154
|
}
|
|
142
155
|
const flowContact = await getContact(db, organisationId, contactId);
|
|
156
|
+
if (flowContact.mergedIntoId) throw xeroError('contact_merged', 'This contact was merged into another. Use that one.', { status: 409 });
|
|
143
157
|
const { rows: [row] } = await db.execute(sql`select xero_contact_id, name, status from xero_contacts
|
|
144
158
|
where organisation_id = ${organisationId} and xero_contact_id = ${xeroContactId}`);
|
|
145
159
|
if (!row) throw xeroError('xero_contact_unknown', 'Flow has no copy of that Xero contact. Sync, then try again.', { status: 404 });
|
|
@@ -30,7 +30,7 @@ async function linkedXeroId(db, organisationId, invoiceId) {
|
|
|
30
30
|
if (pushes[0]?.xero_invoice_id) return pushes[0].xero_invoice_id;
|
|
31
31
|
const { rows: links } = await db.execute(sql`
|
|
32
32
|
select xero_invoice_id from xero_invoice_links
|
|
33
|
-
where organisation_id = ${organisationId} and invoice_id = ${invoiceId}`);
|
|
33
|
+
where organisation_id = ${organisationId} and invoice_id = ${invoiceId} and released_at is null`);
|
|
34
34
|
return links[0]?.xero_invoice_id ?? null;
|
|
35
35
|
}
|
|
36
36
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { and, eq, inArray, sql } from 'drizzle-orm';
|
|
3
3
|
import { isCallsStopped } from '../call-guard.js';
|
|
4
|
+
import { billedContact } from '../../core/contacts.js';
|
|
4
5
|
import { getInvoice, listInvoiceVersions } from '../../core/invoices.js';
|
|
5
6
|
import { sendModeFor } from '../../core/invoicing-rules.js';
|
|
6
7
|
import { contacts as flowContacts, invoices as flowInvoices, priceItems } from '../../db/schema.js';
|
|
@@ -325,15 +326,29 @@ export async function previewDrafts(db, runtime, organisationId, invoiceIds) {
|
|
|
325
326
|
return { syncedAt, warnings, invoices: results };
|
|
326
327
|
}
|
|
327
328
|
|
|
329
|
+
// The Xero contact an invoice goes to: its customer's, or its payer's when the
|
|
330
|
+
// customer bills to another customer (docs/CONTACTS.md, "Bill to"). A missing
|
|
331
|
+
// payer is linked or made on the payer's own contact page, not here.
|
|
332
|
+
async function accountingContactFor(db, organisationId, invoice, document) {
|
|
333
|
+
const payer = await billedContact(db, organisationId, invoice.contactId);
|
|
334
|
+
const own = payer.id === invoice.contactId;
|
|
335
|
+
const name = own ? document.contact.name : payer.name;
|
|
336
|
+
return { match: await matchContactFor(db, organisationId, { contactId: payer.id, name }), name, own };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const payerMissing = (name) => problem('xero_contact_missing',
|
|
340
|
+
`This client is billed to ${name}, and no Xero contact is named "${name}". Link ${name} to its Xero client on its contact page, then check again.`,
|
|
341
|
+
{ short: 'Payer not in Xero' });
|
|
342
|
+
|
|
328
343
|
async function previewOne(db, runtime, organisationId, invoiceId) {
|
|
329
344
|
const { invoice, version, document } = await documentFor(db, organisationId, invoiceId);
|
|
330
345
|
const problems = [];
|
|
331
346
|
if (invoice.status === 'void') problems.push(problem('invoice_void', 'This invoice is void. Flow does not send it to Xero.'));
|
|
332
347
|
if (invoice.status === 'draft') problems.push(problem('invoice_not_issued', 'This invoice is a draft. Issue it, then send it to Xero.'));
|
|
333
|
-
const match = await
|
|
348
|
+
const { match, name, own } = await accountingContactFor(db, organisationId, invoice, document);
|
|
334
349
|
const offerCreate = match.state === 'missing' && runtime.settings.createContacts && writesAllowed(runtime.settings);
|
|
335
|
-
if (match.state === 'missing') problems.push(missingContact(
|
|
336
|
-
if (match.state === 'ambiguous') problems.push(twiceContact(
|
|
350
|
+
if (match.state === 'missing') problems.push(own ? missingContact(name, { offerCreate }) : payerMissing(name));
|
|
351
|
+
if (match.state === 'ambiguous') problems.push(own ? twiceContact(name) : { ...twiceContact(name), offerLink: false });
|
|
337
352
|
const items = await xeroItemsForCodes(db, organisationId, document.lines.map((l) => l.itemCode));
|
|
338
353
|
const sendMode = await sendModeFor(db, organisationId, invoice.contactId);
|
|
339
354
|
const { draft, problems: lineProblems } = buildDraft(document, { settings: runtime.settings, contact: match.contact, items, sendMode });
|
|
@@ -513,10 +528,10 @@ async function pushOne(db, runtime, client, organisationId, invoiceId, { pushedB
|
|
|
513
528
|
}
|
|
514
529
|
}
|
|
515
530
|
|
|
516
|
-
const match = await
|
|
531
|
+
const { match, name, own } = await accountingContactFor(db, organisationId, invoice, document);
|
|
517
532
|
if (match.state !== 'matched') {
|
|
518
533
|
const offerCreate = match.state === 'missing' && runtime.settings.createContacts && writesAllowed(runtime.settings);
|
|
519
|
-
const found = match.state === 'missing' ? missingContact(
|
|
534
|
+
const found = match.state === 'missing' ? (own ? missingContact(name, { offerCreate }) : payerMissing(name)) : { ...twiceContact(name), offerLink: own };
|
|
520
535
|
throw xeroError(found.code, found.message, { status: 409, short: found.short });
|
|
521
536
|
}
|
|
522
537
|
const items = await xeroItemsForCodes(db, organisationId, document.lines.map((l) => l.itemCode));
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { and, eq, inArray, sql } from 'drizzle-orm';
|
|
2
|
+
import { payersOf } from '../../core/contacts.js';
|
|
2
3
|
import { recordAccountingEvidence, recordPayment } from '../../core/ledger.js';
|
|
3
4
|
import { billingObligations, contacts as flowContacts } from '../../db/schema.js';
|
|
4
|
-
import {
|
|
5
|
+
import { matchContactFor, parseXeroDate } from './catalogue.js';
|
|
5
6
|
import { withXeroLane } from './governor.js';
|
|
6
7
|
|
|
7
8
|
// What the billing obligation ledger (server/core/ledger.js) needs from Xero:
|
|
@@ -136,9 +137,11 @@ export async function checkInvoice(db, runtime, client, organisationId, obligati
|
|
|
136
137
|
if (references.length) {
|
|
137
138
|
let customer = null;
|
|
138
139
|
if (obligation.contactId) {
|
|
140
|
+
// A customer with a payer is billed under the payer's name (Bill to).
|
|
141
|
+
const payer = (await payersOf(db, organisationId, [obligation.contactId])).get(obligation.contactId);
|
|
139
142
|
const [contact] = await db.select({ name: flowContacts.name }).from(flowContacts)
|
|
140
|
-
.where(and(eq(flowContacts.organisationId, organisationId), eq(flowContacts.id,
|
|
141
|
-
const match = contact ? await
|
|
143
|
+
.where(and(eq(flowContacts.organisationId, organisationId), eq(flowContacts.id, payer)));
|
|
144
|
+
const match = contact ? await matchContactFor(db, organisationId, { contactId: payer, name: contact.name }) : { state: 'missing' };
|
|
142
145
|
customer = match.state === 'matched' ? match.contact.xeroContactId : null;
|
|
143
146
|
if (match.state === 'ambiguous') {
|
|
144
147
|
const error = new Error(`More than one Xero contact is named "${contact.name}"; Flow cannot tell which invoices are this customer's.`);
|
|
@@ -231,7 +234,7 @@ export async function recordXeroPayments(db, runtime, organisationId, { actor, s
|
|
|
231
234
|
const { rows } = await db.execute(sql`select xero_invoice_id, invoice_id from xero_invoice_pushes
|
|
232
235
|
where organisation_id = ${organisationId} and xero_invoice_id = any(${sql.param(xeroIds)}::text[])
|
|
233
236
|
union all select xero_invoice_id, invoice_id from xero_invoice_links
|
|
234
|
-
where organisation_id = ${organisationId} and xero_invoice_id = any(${sql.param(xeroIds)}::text[])`);
|
|
237
|
+
where organisation_id = ${organisationId} and xero_invoice_id = any(${sql.param(xeroIds)}::text[]) and released_at is null`);
|
|
235
238
|
for (const row of rows) byFlowInvoice.set(row.xero_invoice_id, row.invoice_id);
|
|
236
239
|
}
|
|
237
240
|
const flowInvoiceIds = [...new Set(byFlowInvoice.values())];
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import { and, eq, sql } from 'drizzle-orm';
|
|
1
|
+
import { and, eq, isNull, sql } from 'drizzle-orm';
|
|
2
2
|
import { billingObligations, contacts, invoices, obligationEvents, priceItems } from '../../db/schema.js';
|
|
3
|
+
import { payersOf } from '../../core/contacts.js';
|
|
3
4
|
import { createContact } from '../../core/directory.js';
|
|
4
5
|
import { FlowError } from '../../core/errors.js';
|
|
5
6
|
import { createDraftInvoice, issueInvoice } from '../../core/invoices.js';
|
|
6
|
-
import { linkJobToInvoice } from '../../core/ledger.js';
|
|
7
|
+
import { linkJobToInvoice, recordAccountingEvidence, releaseAccountingRef } from '../../core/ledger.js';
|
|
7
8
|
import { normaliseName } from './catalogue.js';
|
|
9
|
+
import { findInvoices } from './lookup.js';
|
|
8
10
|
import { canFit, markRivals, scoreFit, sureFits } from './fit.js';
|
|
9
11
|
|
|
10
12
|
// The Import page (docs/XERO_IMPORT.md): each sales invoice in Flow's copy of
|
|
@@ -54,7 +56,7 @@ async function classify(db, organisationId) {
|
|
|
54
56
|
select xero_invoice_id, invoice_id, 'sent' as matched_by from xero_invoice_pushes
|
|
55
57
|
where organisation_id = ${organisationId} and xero_invoice_id is not null
|
|
56
58
|
union all
|
|
57
|
-
select xero_invoice_id, invoice_id, matched_by from xero_invoice_links where organisation_id = ${organisationId}`);
|
|
59
|
+
select xero_invoice_id, invoice_id, matched_by from xero_invoice_links where organisation_id = ${organisationId} and released_at is null`);
|
|
58
60
|
const linkOf = new Map();
|
|
59
61
|
const linkedInvoices = new Set();
|
|
60
62
|
for (const r of linkRows) {
|
|
@@ -63,13 +65,23 @@ async function classify(db, organisationId) {
|
|
|
63
65
|
}
|
|
64
66
|
// Flow's customers by name, as the drafts match them (only a name with one
|
|
65
67
|
// contact counts).
|
|
66
|
-
|
|
67
|
-
|
|
68
|
+
// A merged contact is gone; the kept one answers to its aliases too.
|
|
69
|
+
const people = await db.select({ id: contacts.id, name: contacts.name, aliases: contacts.aliases }).from(contacts)
|
|
70
|
+
.where(and(eq(contacts.organisationId, organisationId), eq(contacts.kind, 'customer'), isNull(contacts.mergedIntoId)));
|
|
68
71
|
const byName = new Map();
|
|
69
72
|
for (const p of people) {
|
|
70
|
-
const
|
|
71
|
-
|
|
73
|
+
for (const name of [p.name, ...(p.aliases ?? [])]) {
|
|
74
|
+
const key = normaliseName(name);
|
|
75
|
+
if (byName.has(key) && byName.get(key) === p.id) continue;
|
|
76
|
+
byName.set(key, byName.has(key) ? null : p.id);
|
|
77
|
+
}
|
|
72
78
|
}
|
|
79
|
+
// The identity-map link first, so a Xero contact of another name finds its
|
|
80
|
+
// Flow customer, then the name.
|
|
81
|
+
const { rows: contactLinks } = await db.execute(sql`select l.external_id, l.flow_id from external_links l
|
|
82
|
+
join contacts c on c.organisation_id = l.organisation_id and c.id = l.flow_id and c.kind = 'customer'
|
|
83
|
+
where l.organisation_id = ${organisationId} and l.system = 'xero' and l.entity = 'contact' and l.state = 'active'`);
|
|
84
|
+
const byLink = new Map(contactLinks.map((l) => [l.external_id, l.flow_id]));
|
|
73
85
|
const { rows: flowInvoices } = await db.execute(sql`
|
|
74
86
|
select i.id, i.number, i.contact_id, i.total_cents, i.subtotal_cents, i.reference, i.status, coalesce(i.issue_date, i.created_at::date) as date,
|
|
75
87
|
coalesce((select array_agg(distinct p.code) from invoice_lines l join price_items p on p.id = l.price_item_id
|
|
@@ -83,6 +95,11 @@ async function classify(db, organisationId) {
|
|
|
83
95
|
select o.id, o.contact_id, o.total_cents, o.job_date, o.practitioner, o.case_ref, o.accounting_ref, o.source_invoice_ref, o.lines
|
|
84
96
|
from billing_obligations o
|
|
85
97
|
where o.organisation_id = ${organisationId} and o.invoice_id is null`);
|
|
98
|
+
// Bill to (docs/CONTACTS.md): a customer with a payer is billed under the
|
|
99
|
+
// payer's Xero contact, so its jobs and invoices fit that contact's invoices.
|
|
100
|
+
// A Xero row of a client's own Xero contact fits as that client's payer too.
|
|
101
|
+
const payerOf = await payersOf(db, organisationId, people.map((p) => p.id));
|
|
102
|
+
const billedAs = (contactId) => (contactId ? payerOf.get(contactId) ?? contactId : null);
|
|
86
103
|
const jobByRef = new Map(jobs.filter((j) => j.accounting_ref).map((j) => [j.accounting_ref, j]));
|
|
87
104
|
// The work system's own invoice number, as Xero numbers it (a trailing R is
|
|
88
105
|
// a reissue). Only a number one job holds counts.
|
|
@@ -95,7 +112,7 @@ async function classify(db, organisationId) {
|
|
|
95
112
|
}
|
|
96
113
|
|
|
97
114
|
const rows = xero.map((x) => {
|
|
98
|
-
const contactId = x.contact_name ? byName.get(normaliseName(x.contact_name)) ?? null : null;
|
|
115
|
+
const contactId = byLink.get(x.xero_contact_id) ?? (x.contact_name ? byName.get(normaliseName(x.contact_name)) ?? null : null);
|
|
99
116
|
const row = {
|
|
100
117
|
xeroInvoiceId: x.xero_invoice_id,
|
|
101
118
|
number: x.invoice_number,
|
|
@@ -117,10 +134,11 @@ async function classify(db, organisationId) {
|
|
|
117
134
|
return { ...row, state: 'linked' };
|
|
118
135
|
}
|
|
119
136
|
row.itemCodes = [...new Set((x.lines ?? []).map((l) => l.itemCode).filter(Boolean))];
|
|
120
|
-
const
|
|
137
|
+
const billedTo = billedAs(contactId);
|
|
138
|
+
const xeroFit = { id: x.xero_invoice_id, number: x.invoice_number, contactId: billedTo, totalCents: row.totalCents, issuedOn: row.issuedOn,
|
|
121
139
|
itemCodes: (x.lines ?? []).map((l) => l.itemCode).filter(Boolean), reference: x.reference };
|
|
122
140
|
row.invoices = open.map((i) => {
|
|
123
|
-
const flowFit = { id: i.id, contactId: i.contact_id, totalCents: num(i.total_cents), subtotalCents: num(i.subtotal_cents), date: day(i.date),
|
|
141
|
+
const flowFit = { id: i.id, contactId: billedAs(i.contact_id), totalCents: num(i.total_cents), subtotalCents: num(i.subtotal_cents), date: day(i.date),
|
|
124
142
|
itemCodes: i.item_codes ?? [], reference: i.reference, jobs: i.jobs ?? [] };
|
|
125
143
|
const scored = scoreFit(xeroFit, flowFit);
|
|
126
144
|
return canFit(xeroFit, flowFit, scored)
|
|
@@ -129,8 +147,8 @@ async function classify(db, organisationId) {
|
|
|
129
147
|
: null;
|
|
130
148
|
}).filter(Boolean);
|
|
131
149
|
const tied = jobByRef.get(x.xero_invoice_id) ?? (x.invoice_number ? jobByNumber.get(workNumber(x.invoice_number)) : null) ?? null;
|
|
132
|
-
const fits = tied ? [tied] :
|
|
133
|
-
? jobs.filter((j) => !j.accounting_ref && j.contact_id ===
|
|
150
|
+
const fits = tied ? [tied] : billedTo
|
|
151
|
+
? jobs.filter((j) => !j.accounting_ref && billedAs(j.contact_id) === billedTo && num(j.total_cents) === row.totalCents && near(day(j.job_date), row.issuedOn))
|
|
134
152
|
: [];
|
|
135
153
|
row.jobs = fits.map((j) => ({ id: j.id, jobDate: day(j.job_date), practitioner: j.practitioner, caseRef: j.case_ref, totalCents: num(j.total_cents), tied: j === tied,
|
|
136
154
|
itemCodes: [...new Set((j.lines ?? []).map((l) => l.itemCode).filter(Boolean))] }));
|
|
@@ -208,9 +226,16 @@ export async function saveImportSettings(db, organisationId, { autoLink, makeUnm
|
|
|
208
226
|
}
|
|
209
227
|
|
|
210
228
|
async function insertLink(db, organisationId, { xeroInvoiceId, invoiceId, matchedBy, actor }) {
|
|
229
|
+
// A released link (releaseXeroLink) gives its row to the new one.
|
|
230
|
+
const { rows: held } = await db.execute(sql`select 1 from xero_invoice_links where organisation_id = ${organisationId}
|
|
231
|
+
and invoice_id = ${invoiceId} and xero_invoice_id <> ${xeroInvoiceId} limit 1`);
|
|
232
|
+
if (held.length) throw new FlowError('invalid', 'the Xero invoice or the Flow invoice is linked already');
|
|
211
233
|
const { rows } = await db.execute(sql`insert into xero_invoice_links (organisation_id, xero_invoice_id, invoice_id, matched_by, linked_by)
|
|
212
234
|
values (${organisationId}, ${xeroInvoiceId}, ${invoiceId}, ${matchedBy}, ${actor})
|
|
213
|
-
on conflict do
|
|
235
|
+
on conflict (organisation_id, xero_invoice_id) do update set invoice_id = excluded.invoice_id, matched_by = excluded.matched_by,
|
|
236
|
+
linked_by = excluded.linked_by, created_at = now(), released_by = null, released_at = null, deleted_by = null, deleted_at = null
|
|
237
|
+
where xero_invoice_links.released_at is not null
|
|
238
|
+
returning xero_invoice_id`);
|
|
214
239
|
if (!rows.length) throw new FlowError('invalid', 'the Xero invoice or the Flow invoice is linked already');
|
|
215
240
|
}
|
|
216
241
|
|
|
@@ -251,7 +276,8 @@ export async function useXeroInvoice(db, organisationId, invoiceId, { xeroInvoic
|
|
|
251
276
|
if (!invoice) throw new FlowError('not_found', 'Flow invoice not found');
|
|
252
277
|
// A copy voided earlier, still holding its job, gives way the same way.
|
|
253
278
|
if (!['issued', 'void'].includes(invoice.status)) throw new FlowError('invalid', 'only an approved or void Flow invoice can give way to a Xero invoice');
|
|
254
|
-
|
|
279
|
+
const payers = await payersOf(tx, organisationId, [invoice.contact_id, row.contactId]);
|
|
280
|
+
if (!row.contactId || payers.get(row.contactId) !== payers.get(invoice.contact_id)) throw new FlowError('invalid', 'the Xero invoice is for another client');
|
|
255
281
|
const { rows: pushed } = await tx.execute(sql`select 1 from xero_invoice_pushes where organisation_id = ${organisationId} and invoice_id = ${invoiceId} and xero_invoice_id is not null limit 1`);
|
|
256
282
|
if (pushed.length) throw new FlowError('invalid', 'Flow sent that invoice to Xero already');
|
|
257
283
|
const jobs = await tx.select({ id: billingObligations.id }).from(billingObligations)
|
|
@@ -267,7 +293,8 @@ export async function useXeroInvoice(db, organisationId, invoiceId, { xeroInvoic
|
|
|
267
293
|
evidence: { invoiceId, reason: 'used_xero_invoice', xeroInvoiceId, xeroNumber: row.number ?? null },
|
|
268
294
|
});
|
|
269
295
|
}
|
|
270
|
-
|
|
296
|
+
// The new Flow invoice stays with the client of the one it replaces.
|
|
297
|
+
const made = await makeFrom(tx, organisationId, row, { jobId: jobs[0]?.id ?? null, contactId: invoice.contact_id, actor });
|
|
271
298
|
return { voided: { id: invoiceId, number: invoice.number }, invoice: { id: made.invoice.id, number: made.invoice.number }, xeroInvoiceId, jobId: jobs[0]?.id ?? null };
|
|
272
299
|
});
|
|
273
300
|
}
|
|
@@ -281,14 +308,21 @@ export async function makeFlowInvoice(db, organisationId, xeroInvoiceId, { jobId
|
|
|
281
308
|
return makeFrom(db, organisationId, await rowFor(db, organisationId, xeroInvoiceId), { jobId, actor });
|
|
282
309
|
}
|
|
283
310
|
|
|
284
|
-
async function makeFrom(db, organisationId, row, { jobId = null, actor }) {
|
|
311
|
+
async function makeFrom(db, organisationId, row, { jobId = null, contactId: chosen = null, actor }) {
|
|
285
312
|
const { xeroInvoiceId } = row;
|
|
286
313
|
const { rows: [x] } = await db.execute(sql`select x.lines, x.due_on, c.email from xero_invoices x
|
|
287
314
|
left join xero_contacts c on c.organisation_id = x.organisation_id and c.xero_contact_id = x.xero_contact_id
|
|
288
315
|
where x.organisation_id = ${organisationId} and x.xero_invoice_id = ${xeroInvoiceId}`);
|
|
289
316
|
if (!row.clientName) throw new FlowError('invalid', 'the Xero invoice has no contact in the copy; sync contacts first');
|
|
290
317
|
return db.transaction(async (tx) => {
|
|
291
|
-
|
|
318
|
+
// The job's own customer when it bills to this payer (Bill to), so the
|
|
319
|
+
// Flow invoice stays with the client the work was for.
|
|
320
|
+
const { rows: [job] } = jobId ? await tx.execute(sql`select contact_id from billing_obligations
|
|
321
|
+
where organisation_id = ${organisationId} and id = ${jobId}`) : { rows: [] };
|
|
322
|
+
const payers = await payersOf(tx, organisationId, [job?.contact_id, row.contactId]);
|
|
323
|
+
const samePayer = job?.contact_id && row.contactId && payers.get(job.contact_id) === payers.get(row.contactId);
|
|
324
|
+
const contactId = chosen ?? (samePayer ? job.contact_id : null)
|
|
325
|
+
?? row.contactId ?? (await createContact(tx, organisationId, { kind: 'customer', name: row.clientName, email: x.email ?? null })).id;
|
|
292
326
|
const items = await tx.select({ id: priceItems.id, code: priceItems.code, name: priceItems.name }).from(priceItems)
|
|
293
327
|
.where(eq(priceItems.organisationId, organisationId));
|
|
294
328
|
const byCode = new Map(items.map((i) => [i.code.toUpperCase(), i]));
|
|
@@ -308,7 +342,16 @@ async function makeFrom(db, organisationId, row, { jobId = null, actor }) {
|
|
|
308
342
|
const draft = await createDraftInvoice(tx, organisationId, { contactId, reference: row.reference ?? null, dueDate: day(x.due_on), lines });
|
|
309
343
|
const invoice = await issueInvoice(tx, organisationId, draft.id, { issuedBy: actor, issueDate: row.issuedOn ?? new Date().toISOString().slice(0, 10) });
|
|
310
344
|
await insertLink(tx, organisationId, { xeroInvoiceId, invoiceId: invoice.id, matchedBy: 'made', actor });
|
|
311
|
-
if (jobId)
|
|
345
|
+
if (jobId) {
|
|
346
|
+
await linkJobToInvoice(tx, organisationId, jobId, { invoiceId: invoice.id, accountingRef: xeroInvoiceId, actor });
|
|
347
|
+
// The job's accounting state in the same step, from the copy the
|
|
348
|
+
// invoice was made from: the ledger says billed or paid at once, not
|
|
349
|
+
// after the matcher's next pass.
|
|
350
|
+
const { invoices: [copy] } = await findInvoices(tx, organisationId, { invoiceIds: [xeroInvoiceId] });
|
|
351
|
+
if (copy?.state) {
|
|
352
|
+
await recordAccountingEvidence(tx, organisationId, jobId, { state: copy.state, accountingRef: xeroInvoiceId, matchedBy: 'accounting_ref', actor });
|
|
353
|
+
}
|
|
354
|
+
}
|
|
312
355
|
return { xeroInvoiceId, invoice };
|
|
313
356
|
});
|
|
314
357
|
}
|
|
@@ -329,3 +372,27 @@ export async function matchXero(db, organisationId, { actor = 'automation:xero-i
|
|
|
329
372
|
}
|
|
330
373
|
return result;
|
|
331
374
|
}
|
|
375
|
+
|
|
376
|
+
// A person releases a wrong pair: the Xero invoice is linked to a Flow invoice
|
|
377
|
+
// that was voided, and is another job's bill. The link stays as a record
|
|
378
|
+
// (released_by, released_at) and the Xero invoice can link again. Each job
|
|
379
|
+
// that points at the Xero invoice and no longer holds a Flow invoice lets go
|
|
380
|
+
// of it, and the matcher never pairs them again. A live Flow invoice keeps
|
|
381
|
+
// its link: void it first.
|
|
382
|
+
export async function releaseXeroLink(db, organisationId, xeroInvoiceId, { actor, reason }) {
|
|
383
|
+
if (!actor) throw new FlowError('invalid', 'actor is required');
|
|
384
|
+
if (!reason?.trim()) throw new FlowError('invalid', 'say why the link is released');
|
|
385
|
+
return db.transaction(async (tx) => {
|
|
386
|
+
const { rows: [link] } = await tx.execute(sql`select l.invoice_id, i.number, i.status from xero_invoice_links l
|
|
387
|
+
join invoices i on i.organisation_id = l.organisation_id and i.id = l.invoice_id
|
|
388
|
+
where l.organisation_id = ${organisationId} and l.xero_invoice_id = ${xeroInvoiceId} and l.released_at is null for update of l`);
|
|
389
|
+
if (!link) throw new FlowError('not_found', 'the Xero invoice has no link to release');
|
|
390
|
+
if (link.status !== 'void') throw new FlowError('invalid', `Flow invoice ${link.number ?? link.invoice_id} is ${link.status}: void it first`);
|
|
391
|
+
await tx.execute(sql`update xero_invoice_links set released_by = ${actor}, released_at = now()
|
|
392
|
+
where organisation_id = ${organisationId} and xero_invoice_id = ${xeroInvoiceId}`);
|
|
393
|
+
const { rows: jobs } = await tx.execute(sql`select id from billing_obligations
|
|
394
|
+
where organisation_id = ${organisationId} and accounting_ref = ${xeroInvoiceId} and invoice_id is null`);
|
|
395
|
+
for (const job of jobs) await releaseAccountingRef(tx, organisationId, job.id, { accountingRef: xeroInvoiceId, system: 'xero', actor, reason });
|
|
396
|
+
return { xeroInvoiceId, invoice: { id: link.invoice_id, number: link.number }, jobs: jobs.map((j) => j.id) };
|
|
397
|
+
});
|
|
398
|
+
}
|
|
@@ -15,8 +15,10 @@ export const MIRROR_FRESH_MS = 24 * 60 * 60 * 1000;
|
|
|
15
15
|
export const PROOF_RESOURCES = Object.freeze(['invoices', 'credit_notes']);
|
|
16
16
|
|
|
17
17
|
// `contact` is the Flow contact a new client becomes (docs/CONTACTS.md,
|
|
18
|
-
// "Add Xero clients").
|
|
19
|
-
//
|
|
18
|
+
// "Add Xero clients"). Only a contact Xero marks a customer (it has a sales
|
|
19
|
+
// invoice there) becomes one (`adoptable`). The others (suppliers, staff,
|
|
20
|
+
// names from bank lines) still link by name. `customer` is Xero's mark: the
|
|
21
|
+
// core hides a Flow contact made from one that has none ("Hidden contacts").
|
|
20
22
|
export async function listCustomers(db, organisationId) {
|
|
21
23
|
const { rows } = await db.execute(sql`
|
|
22
24
|
select xero_contact_id, name, first_name, last_name, email, phone, mobile, account_number, tax_number, address,
|
|
@@ -27,7 +29,8 @@ export async function listCustomers(db, organisationId) {
|
|
|
27
29
|
return rows.map((r) => ({
|
|
28
30
|
id: r.xero_contact_id,
|
|
29
31
|
name: r.name,
|
|
30
|
-
adoptable: Boolean(r.is_customer
|
|
32
|
+
adoptable: Boolean(r.is_customer),
|
|
33
|
+
customer: Boolean(r.is_customer),
|
|
31
34
|
contact: {
|
|
32
35
|
kind: 'customer', name: r.name, firstName: r.first_name, lastName: r.last_name, email: r.email, phone: r.phone,
|
|
33
36
|
mobile: r.mobile, accountNumber: r.account_number, taxNumber: r.tax_number, address: r.address,
|
|
@@ -135,7 +138,7 @@ export async function findInvoices(db, organisationId, query = {}) {
|
|
|
135
138
|
where organisation_id = i.organisation_id and xero_invoice_id = i.xero_invoice_id
|
|
136
139
|
union all
|
|
137
140
|
select invoice_id, 1, 0 from xero_invoice_links
|
|
138
|
-
where organisation_id = i.organisation_id and xero_invoice_id = i.xero_invoice_id
|
|
141
|
+
where organisation_id = i.organisation_id and xero_invoice_id = i.xero_invoice_id and released_at is null
|
|
139
142
|
) f order by pick, version desc limit 1
|
|
140
143
|
) p on true
|
|
141
144
|
where i.organisation_id = ${organisationId} and i.type = 'ACCREC'
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
-- A person releases a Xero invoice's link to a Flow invoice that was voided
|
|
2
|
+
-- (links.js, releaseXeroLink): the pair was wrong, and the Xero invoice is
|
|
3
|
+
-- free to link again. The row stays, with who and when; a new link takes its
|
|
4
|
+
-- place. Nothing is deleted.
|
|
5
|
+
ALTER TABLE "xero_invoice_links" ADD COLUMN IF NOT EXISTS "released_by" text;
|
|
6
|
+
--> statement-breakpoint
|
|
7
|
+
ALTER TABLE "xero_invoice_links" ADD COLUMN IF NOT EXISTS "released_at" timestamp with time zone;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
-- The version of the copy each resource was read with: the mapper's version
|
|
2
|
+
-- (mirror.js COPY_VERSION) and whether free text is kept. When it changes,
|
|
3
|
+
-- the next read starts again from the beginning, so old rows get a new field.
|
|
4
|
+
-- Null: read before Flow noted it; the first read notes it without starting
|
|
5
|
+
-- again (kept_reference still starts one when the setting differs).
|
|
6
|
+
ALTER TABLE "xero_mirror_cursors" ADD COLUMN IF NOT EXISTS "copy_version" text;
|
|
7
|
+
--> statement-breakpoint
|
|
8
|
+
-- Who asked for a full read again, and when (POST /api/xero/mirror/reread).
|
|
9
|
+
ALTER TABLE "xero_mirror_cursors" ADD COLUMN IF NOT EXISTS "reread_by" text;
|
|
10
|
+
--> statement-breakpoint
|
|
11
|
+
ALTER TABLE "xero_mirror_cursors" ADD COLUMN IF NOT EXISTS "reread_at" timestamp with time zone;
|
|
@@ -106,6 +106,20 @@
|
|
|
106
106
|
"when": 1792570000000,
|
|
107
107
|
"tag": "0014_xero_mirror_reference",
|
|
108
108
|
"breakpoints": true
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
"idx": 15,
|
|
112
|
+
"version": "7",
|
|
113
|
+
"when": 1792580000000,
|
|
114
|
+
"tag": "0015_xero_link_release",
|
|
115
|
+
"breakpoints": true
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
"idx": 16,
|
|
119
|
+
"version": "7",
|
|
120
|
+
"when": 1792590000000,
|
|
121
|
+
"tag": "0016_xero_copy_version",
|
|
122
|
+
"breakpoints": true
|
|
109
123
|
}
|
|
110
124
|
]
|
|
111
125
|
}
|
|
@@ -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
|
}
|