@arcforgelabs/flow 2026.10.37 → 2026.10.39
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/README.md +2 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +4 -2
- package/server/api/custom-fields.js +27 -9
- package/server/api/invoicing.js +198 -51
- package/server/app.js +5 -2
- package/server/core/alerts.js +1 -1
- package/server/core/automations.js +1 -1
- package/server/core/custom-fields.js +89 -3
- package/server/core/job-board.js +2 -1
- package/server/core/job-match.js +241 -0
- package/server/core/ledger.js +109 -8
- package/server/core/price-list.js +27 -2
- package/server/core/read-before-act.js +3 -2
- package/server/core/sending.js +36 -18
- package/server/db/doctor.js +18 -19
- package/server/db/index.js +74 -3
- package/server/db/migrate-all.js +3 -4
- package/server/db/migrations/0020_custom_field_colours.sql +21 -0
- package/server/db/migrations/meta/0020_snapshot.json +4377 -0
- package/server/db/migrations/meta/_journal.json +7 -0
- package/server/db/schema.js +14 -0
- package/server/integrations/call-guard.js +148 -0
- package/server/integrations/port.js +3 -2
- package/server/integrations/schema.js +9 -5
- package/server/integrations/seazona/feed.js +4 -1
- package/server/integrations/seazona/settings.js +8 -4
- package/server/integrations/xero/drafts.js +4 -4
- package/server/integrations/xero/fit.js +108 -0
- package/server/integrations/xero/import.js +6 -1
- package/server/integrations/xero/index.js +6 -2
- package/server/integrations/xero/ledger.js +3 -0
- package/server/integrations/xero/links.js +293 -0
- package/server/integrations/xero/migrations/0008_xero_invoice_links.sql +26 -0
- package/server/integrations/xero/migrations/0009_xero_sent_to_contact.sql +3 -0
- package/server/integrations/xero/migrations/meta/_journal.json +14 -0
- package/server/integrations/xero/mirror.js +4 -3
- package/server/integrations/xero/reconcile.js +42 -12
- package/server/integrations/xero/routes.js +66 -7
- package/server/integrations/xero/settings.js +6 -5
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +89 -3
- package/server/ui/bulk-bar.js +4 -3
- package/server/ui/custom-fields.js +252 -94
- package/server/ui/flow.css +326 -29
- package/server/ui/invoice-notes.js +2 -2
- package/server/ui/invoicing.js +92 -137
- package/server/ui/job-board.js +9 -7
- package/server/ui/job-change.js +23 -12
- package/server/ui/job-view.js +189 -31
- package/server/ui/jobs.js +738 -70
- package/server/ui/price-list.js +43 -86
- package/server/ui/price-match.js +93 -0
- package/server/ui/price-reconcile.js +357 -0
- package/server/ui/reconcile.js +275 -0
- package/server/ui/routes.js +1 -1
- package/server/ui/xero-import.js +329 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { and, eq, sql } from 'drizzle-orm';
|
|
2
|
+
import { contacts, priceItems } from '../../db/schema.js';
|
|
3
|
+
import { createContact } from '../../core/directory.js';
|
|
4
|
+
import { FlowError } from '../../core/errors.js';
|
|
5
|
+
import { createDraftInvoice, issueInvoice } from '../../core/invoices.js';
|
|
6
|
+
import { linkJobToInvoice } from '../../core/ledger.js';
|
|
7
|
+
import { normaliseName } from './catalogue.js';
|
|
8
|
+
import { canFit, markRivals, scoreFit, sureFits } from './fit.js';
|
|
9
|
+
|
|
10
|
+
// The Import page (docs/XERO_IMPORT.md): each sales invoice in Flow's copy of
|
|
11
|
+
// Xero against Flow's own invoices. Flow's own records are the ones it works
|
|
12
|
+
// from; this integration says which Flow invoice each Xero invoice is.
|
|
13
|
+
//
|
|
14
|
+
// linked a Flow invoice is this Xero invoice: Flow sent it (a push),
|
|
15
|
+
// the import matched it, or a person linked or made it.
|
|
16
|
+
// invoice_fits a Flow invoice with no Xero invoice fits (fit.js: hard
|
|
17
|
+
// evidence from its jobs, or the same client, an amount that
|
|
18
|
+
// fits and dates within two weeks). A sure fit is linked by
|
|
19
|
+
// `matchXero`; the rest wait for a person.
|
|
20
|
+
// job_fits no Flow invoice, but a Flow job with no invoice fits: the
|
|
21
|
+
// earlier matcher tied it to this Xero invoice, its work-system
|
|
22
|
+
// invoice number is this Xero number, or it has the
|
|
23
|
+
// same client and total within a week. A person makes the Flow
|
|
24
|
+
// invoice from the Xero one and links the job.
|
|
25
|
+
// none nothing in Flow fits. The Xero invoice stays in the copy; a
|
|
26
|
+
// person can make its Flow invoice at any time, or the
|
|
27
|
+
// `make_unmatched` setting makes one for each.
|
|
28
|
+
//
|
|
29
|
+
// It reads only Flow's database: no Xero call.
|
|
30
|
+
|
|
31
|
+
export const IMPORT_STATES = Object.freeze(['linked', 'invoice_fits', 'job_fits', 'none']);
|
|
32
|
+
const CHECK = new Set(['invoice_fits', 'job_fits']);
|
|
33
|
+
const FIT_DAYS = 7;
|
|
34
|
+
const SKIP_STATUSES = ['DELETED', 'VOIDED'];
|
|
35
|
+
const num = (value) => (value === null || value === undefined ? null : Number(value));
|
|
36
|
+
const day = (value) => (value instanceof Date ? value.toISOString().slice(0, 10) : value ? String(value).slice(0, 10) : null);
|
|
37
|
+
const daysApart = (a, b) => (a && b ? Math.abs(Math.round((Date.parse(a) - Date.parse(b)) / 86_400_000)) : null);
|
|
38
|
+
const near = (a, b) => {
|
|
39
|
+
const days = daysApart(a, b);
|
|
40
|
+
return days !== null && days <= FIT_DAYS;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Every sales invoice in the copy, and what in Flow fits it.
|
|
44
|
+
async function classify(db, organisationId) {
|
|
45
|
+
const { rows: xero } = await db.execute(sql`
|
|
46
|
+
select x.xero_invoice_id, x.invoice_number, x.reference, x.status, x.issued_on, x.total_cents, x.amount_due_cents,
|
|
47
|
+
x.xero_contact_id, x.lines, c.name as contact_name, c.email as contact_email
|
|
48
|
+
from xero_invoices x
|
|
49
|
+
left join xero_contacts c on c.organisation_id = x.organisation_id and c.xero_contact_id = x.xero_contact_id
|
|
50
|
+
where x.organisation_id = ${organisationId} and x.type = 'ACCREC'
|
|
51
|
+
and not (x.status = any(${sql.param(SKIP_STATUSES)}::text[]))
|
|
52
|
+
order by x.issued_on desc nulls last, x.invoice_number desc`);
|
|
53
|
+
const { rows: linkRows } = await db.execute(sql`
|
|
54
|
+
select xero_invoice_id, invoice_id, 'sent' as matched_by from xero_invoice_pushes
|
|
55
|
+
where organisation_id = ${organisationId} and xero_invoice_id is not null
|
|
56
|
+
union all
|
|
57
|
+
select xero_invoice_id, invoice_id, matched_by from xero_invoice_links where organisation_id = ${organisationId}`);
|
|
58
|
+
const linkOf = new Map();
|
|
59
|
+
const linkedInvoices = new Set();
|
|
60
|
+
for (const r of linkRows) {
|
|
61
|
+
if (!linkOf.has(r.xero_invoice_id)) linkOf.set(r.xero_invoice_id, r);
|
|
62
|
+
linkedInvoices.add(r.invoice_id);
|
|
63
|
+
}
|
|
64
|
+
// Flow's customers by name, as the drafts match them (only a name with one
|
|
65
|
+
// contact counts).
|
|
66
|
+
const people = await db.select({ id: contacts.id, name: contacts.name }).from(contacts)
|
|
67
|
+
.where(and(eq(contacts.organisationId, organisationId), eq(contacts.kind, 'customer')));
|
|
68
|
+
const byName = new Map();
|
|
69
|
+
for (const p of people) {
|
|
70
|
+
const key = normaliseName(p.name);
|
|
71
|
+
byName.set(key, byName.has(key) ? null : p.id);
|
|
72
|
+
}
|
|
73
|
+
const { rows: flowInvoices } = await db.execute(sql`
|
|
74
|
+
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
|
+
coalesce((select array_agg(distinct p.code) from invoice_lines l join price_items p on p.id = l.price_item_id
|
|
76
|
+
where l.invoice_id = i.id and p.code is not null), '{}') as item_codes,
|
|
77
|
+
coalesce((select jsonb_agg(jsonb_build_object('accountingRef', o.accounting_ref, 'sourceInvoiceRef', o.source_invoice_ref, 'practitioner', o.practitioner))
|
|
78
|
+
from billing_obligations o where o.organisation_id = i.organisation_id and o.invoice_id = i.id), '[]') as jobs
|
|
79
|
+
from invoices i
|
|
80
|
+
where i.organisation_id = ${organisationId} and i.status <> 'void'`);
|
|
81
|
+
const open = flowInvoices.filter((i) => !linkedInvoices.has(i.id));
|
|
82
|
+
const { rows: jobs } = await db.execute(sql`
|
|
83
|
+
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
|
+
from billing_obligations o
|
|
85
|
+
where o.organisation_id = ${organisationId} and o.invoice_id is null`);
|
|
86
|
+
const jobByRef = new Map(jobs.filter((j) => j.accounting_ref).map((j) => [j.accounting_ref, j]));
|
|
87
|
+
// The work system's own invoice number, as Xero numbers it (a trailing R is
|
|
88
|
+
// a reissue). Only a number one job holds counts.
|
|
89
|
+
const workNumber = (ref) => String(ref ?? '').trim().replace(/R$/i, '').toLowerCase();
|
|
90
|
+
const jobByNumber = new Map();
|
|
91
|
+
for (const j of jobs) {
|
|
92
|
+
if (!j.source_invoice_ref) continue;
|
|
93
|
+
const key = workNumber(j.source_invoice_ref);
|
|
94
|
+
jobByNumber.set(key, jobByNumber.has(key) ? null : j);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const rows = xero.map((x) => {
|
|
98
|
+
const contactId = x.contact_name ? byName.get(normaliseName(x.contact_name)) ?? null : null;
|
|
99
|
+
const row = {
|
|
100
|
+
xeroInvoiceId: x.xero_invoice_id,
|
|
101
|
+
number: x.invoice_number,
|
|
102
|
+
reference: x.reference,
|
|
103
|
+
status: x.status,
|
|
104
|
+
issuedOn: day(x.issued_on),
|
|
105
|
+
totalCents: num(x.total_cents),
|
|
106
|
+
amountDueCents: num(x.amount_due_cents),
|
|
107
|
+
clientName: x.contact_name,
|
|
108
|
+
contactId,
|
|
109
|
+
link: null,
|
|
110
|
+
invoices: [],
|
|
111
|
+
jobs: [],
|
|
112
|
+
};
|
|
113
|
+
const link = linkOf.get(x.xero_invoice_id);
|
|
114
|
+
if (link) {
|
|
115
|
+
const invoice = flowInvoices.find((i) => i.id === link.invoice_id);
|
|
116
|
+
row.link = { invoiceId: link.invoice_id, number: invoice?.number ?? null, matchedBy: link.matched_by };
|
|
117
|
+
return { ...row, state: 'linked' };
|
|
118
|
+
}
|
|
119
|
+
row.itemCodes = [...new Set((x.lines ?? []).map((l) => l.itemCode).filter(Boolean))];
|
|
120
|
+
const xeroFit = { id: x.xero_invoice_id, number: x.invoice_number, contactId, totalCents: row.totalCents, issuedOn: row.issuedOn,
|
|
121
|
+
itemCodes: (x.lines ?? []).map((l) => l.itemCode).filter(Boolean), reference: x.reference };
|
|
122
|
+
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),
|
|
124
|
+
itemCodes: i.item_codes ?? [], reference: i.reference, jobs: i.jobs ?? [] };
|
|
125
|
+
const scored = scoreFit(xeroFit, flowFit);
|
|
126
|
+
return canFit(xeroFit, flowFit, scored)
|
|
127
|
+
? { id: i.id, number: i.number, status: i.status, date: day(i.date), totalCents: num(i.total_cents), reference: i.reference, itemCodes: flowFit.itemCodes,
|
|
128
|
+
score: scored.score, hard: scored.hard, apart: daysApart(day(i.date), row.issuedOn) ?? 99 }
|
|
129
|
+
: null;
|
|
130
|
+
}).filter(Boolean);
|
|
131
|
+
const tied = jobByRef.get(x.xero_invoice_id) ?? (x.invoice_number ? jobByNumber.get(workNumber(x.invoice_number)) : null) ?? null;
|
|
132
|
+
const fits = tied ? [tied] : contactId
|
|
133
|
+
? jobs.filter((j) => !j.accounting_ref && j.contact_id === contactId && num(j.total_cents) === row.totalCents && near(day(j.job_date), row.issuedOn))
|
|
134
|
+
: [];
|
|
135
|
+
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
|
+
itemCodes: [...new Set((j.lines ?? []).map((l) => l.itemCode).filter(Boolean))] }));
|
|
137
|
+
return row;
|
|
138
|
+
});
|
|
139
|
+
// A Flow invoice dated closer to another Xero invoice more likely is that
|
|
140
|
+
// one (the job matcher's closer_job lesson): those fits lose points.
|
|
141
|
+
markRivals(rows.flatMap((r) => r.invoices.map((i) => ({ xeroId: r.xeroInvoiceId, flowId: i.id, apart: i.apart, fit: i }))));
|
|
142
|
+
for (const r of rows) {
|
|
143
|
+
if (r.state === 'linked') continue;
|
|
144
|
+
r.invoices.sort((a, b) => b.score - a.score);
|
|
145
|
+
const hard = r.invoices.some((i) => i.hard);
|
|
146
|
+
const tiedJob = r.jobs.some((j) => j.tied);
|
|
147
|
+
const plain = r.invoices.some((i) => !i.rival);
|
|
148
|
+
// Hard evidence wins; a job tied to this Xero invoice beats a plain fit;
|
|
149
|
+
// a Flow invoice that is closer to another Xero invoice yields to a job.
|
|
150
|
+
if (hard || (!tiedJob && (plain || !r.jobs.length) && r.invoices.length)) r.state = 'invoice_fits';
|
|
151
|
+
else if (r.jobs.length) {
|
|
152
|
+
r.state = 'job_fits';
|
|
153
|
+
r.invoices = [];
|
|
154
|
+
} else r.state = 'none';
|
|
155
|
+
if (r.state === 'invoice_fits') r.jobs = [];
|
|
156
|
+
}
|
|
157
|
+
// Sure only when it is clearly the one fit both ways (fit.js).
|
|
158
|
+
const sure = sureFits(rows.filter((r) => r.state === 'invoice_fits')
|
|
159
|
+
.flatMap((r) => r.invoices.map((i) => ({ xeroId: r.xeroInvoiceId, flowId: i.id, score: i.score, hard: i.hard }))));
|
|
160
|
+
for (const r of rows) {
|
|
161
|
+
r.sure = r.state === 'invoice_fits' && sure.has(r.xeroInvoiceId);
|
|
162
|
+
if (r.sure) r.invoices.sort((a, b) => (b.id === sure.get(r.xeroInvoiceId)) - (a.id === sure.get(r.xeroInvoiceId)));
|
|
163
|
+
for (const i of r.invoices) { delete i.apart; delete i.rival; }
|
|
164
|
+
}
|
|
165
|
+
return rows;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const matches = (row, q) => {
|
|
169
|
+
const words = q.trim().toLowerCase();
|
|
170
|
+
if (!words) return true;
|
|
171
|
+
const amount = /^\$?\s*(\d+(?:,\d{3})*(?:\.\d{1,2})?)$/.exec(words);
|
|
172
|
+
if (amount && row.totalCents === Math.round(Number(amount[1].replace(/,/g, '')) * 100)) return true;
|
|
173
|
+
return [row.number, row.clientName, row.reference].some((v) => v && String(v).toLowerCase().includes(words));
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
// The page: counts by state, and one page of rows. `show` is a state, 'check'
|
|
177
|
+
// (what a person checks: a Flow invoice or a job fits), or 'all'; `q`
|
|
178
|
+
// searches number, client, Reference or amount.
|
|
179
|
+
export async function importOverview(db, organisationId, { show = 'all', q = '', limit = 50, offset = 0 } = {}) {
|
|
180
|
+
const rows = await classify(db, organisationId);
|
|
181
|
+
const counts = Object.fromEntries(IMPORT_STATES.map((s) => [s, 0]));
|
|
182
|
+
for (const r of rows) counts[r.state] += 1;
|
|
183
|
+
const shown = rows.filter((r) => (show === 'all' || r.state === show || (show === 'check' && CHECK.has(r.state))) && matches(r, q));
|
|
184
|
+
const settings = await readImportSettings(db, organisationId);
|
|
185
|
+
return { counts, total: shown.length, rows: shown.slice(offset, offset + limit), settings };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// `autoLink` (on): the import links each sure fit. `makeUnmatched` (off):
|
|
189
|
+
// it also makes a Flow invoice for each Xero invoice nothing fits.
|
|
190
|
+
export async function readImportSettings(db, organisationId) {
|
|
191
|
+
const { rows } = await db.execute(sql`select auto_link, make_unmatched from xero_import_settings where organisation_id = ${organisationId}`);
|
|
192
|
+
return { autoLink: rows[0] ? Boolean(rows[0].auto_link) : true, makeUnmatched: Boolean(rows[0]?.make_unmatched) };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Saves the settings given; a setting left out keeps its value.
|
|
196
|
+
export async function saveImportSettings(db, organisationId, { autoLink, makeUnmatched, actor }) {
|
|
197
|
+
for (const [name, value] of [['autoLink', autoLink], ['makeUnmatched', makeUnmatched]]) {
|
|
198
|
+
if (value !== undefined && typeof value !== 'boolean') throw new FlowError('invalid', `${name} must be true or false`);
|
|
199
|
+
}
|
|
200
|
+
const next = { ...(await readImportSettings(db, organisationId)) };
|
|
201
|
+
if (autoLink !== undefined) next.autoLink = autoLink;
|
|
202
|
+
if (makeUnmatched !== undefined) next.makeUnmatched = makeUnmatched;
|
|
203
|
+
await db.execute(sql`insert into xero_import_settings (organisation_id, auto_link, make_unmatched, updated_by, updated_at)
|
|
204
|
+
values (${organisationId}, ${next.autoLink}, ${next.makeUnmatched}, ${actor}, now())
|
|
205
|
+
on conflict (organisation_id) do update set auto_link = excluded.auto_link, make_unmatched = excluded.make_unmatched,
|
|
206
|
+
updated_by = excluded.updated_by, updated_at = now()`);
|
|
207
|
+
return readImportSettings(db, organisationId);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function insertLink(db, organisationId, { xeroInvoiceId, invoiceId, matchedBy, actor }) {
|
|
211
|
+
const { rows } = await db.execute(sql`insert into xero_invoice_links (organisation_id, xero_invoice_id, invoice_id, matched_by, linked_by)
|
|
212
|
+
values (${organisationId}, ${xeroInvoiceId}, ${invoiceId}, ${matchedBy}, ${actor})
|
|
213
|
+
on conflict do nothing returning xero_invoice_id`);
|
|
214
|
+
if (!rows.length) throw new FlowError('invalid', 'the Xero invoice or the Flow invoice is linked already');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function rowFor(db, organisationId, xeroInvoiceId) {
|
|
218
|
+
const row = (await classify(db, organisationId)).find((r) => r.xeroInvoiceId === xeroInvoiceId);
|
|
219
|
+
if (!row) throw new FlowError('not_found', 'Xero invoice not found in the copy');
|
|
220
|
+
if (row.state === 'linked') throw new FlowError('invalid', 'the Xero invoice is linked already');
|
|
221
|
+
return row;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// A person says which Flow invoice a Xero invoice is. Any Flow invoice of the
|
|
225
|
+
// same client with no Xero invoice may be picked, not only a fit.
|
|
226
|
+
export async function linkXeroInvoice(db, organisationId, xeroInvoiceId, { invoiceId, actor }) {
|
|
227
|
+
await rowFor(db, organisationId, xeroInvoiceId);
|
|
228
|
+
const { rows } = await db.execute(sql`select id, status from invoices where organisation_id = ${organisationId} and id = ${invoiceId}`);
|
|
229
|
+
if (!rows.length) throw new FlowError('not_found', 'Flow invoice not found');
|
|
230
|
+
if (rows[0].status === 'void') throw new FlowError('invalid', 'the Flow invoice is void');
|
|
231
|
+
const { rows: pushed } = await db.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`);
|
|
232
|
+
if (pushed.length) throw new FlowError('invalid', 'Flow sent that invoice to Xero already');
|
|
233
|
+
await insertLink(db, organisationId, { xeroInvoiceId, invoiceId, matchedBy: 'person', actor });
|
|
234
|
+
return { xeroInvoiceId, invoiceId };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Makes Flow's own invoice from a Xero invoice in the copy, finalised on the
|
|
238
|
+
// Xero invoice's date, and links the two. With `jobId`, the job gets it as its
|
|
239
|
+
// invoice. The client is Flow's customer of the same name, or a new one.
|
|
240
|
+
// Lines keep the Xero item code where Flow's price list has it; Flow works
|
|
241
|
+
// out its own totals from the lines.
|
|
242
|
+
export async function makeFlowInvoice(db, organisationId, xeroInvoiceId, { jobId = null, actor }) {
|
|
243
|
+
return makeFrom(db, organisationId, await rowFor(db, organisationId, xeroInvoiceId), { jobId, actor });
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function makeFrom(db, organisationId, row, { jobId = null, actor }) {
|
|
247
|
+
const { xeroInvoiceId } = row;
|
|
248
|
+
const { rows: [x] } = await db.execute(sql`select x.lines, x.due_on, c.email from xero_invoices x
|
|
249
|
+
left join xero_contacts c on c.organisation_id = x.organisation_id and c.xero_contact_id = x.xero_contact_id
|
|
250
|
+
where x.organisation_id = ${organisationId} and x.xero_invoice_id = ${xeroInvoiceId}`);
|
|
251
|
+
if (!row.clientName) throw new FlowError('invalid', 'the Xero invoice has no contact in the copy; sync contacts first');
|
|
252
|
+
return db.transaction(async (tx) => {
|
|
253
|
+
const contactId = row.contactId ?? (await createContact(tx, organisationId, { kind: 'customer', name: row.clientName, email: x.email ?? null })).id;
|
|
254
|
+
const items = await tx.select({ id: priceItems.id, code: priceItems.code, name: priceItems.name }).from(priceItems)
|
|
255
|
+
.where(eq(priceItems.organisationId, organisationId));
|
|
256
|
+
const byCode = new Map(items.map((i) => [i.code.toUpperCase(), i]));
|
|
257
|
+
const lines = (x.lines ?? []).filter((l) => l.lineAmountCents).map((l) => {
|
|
258
|
+
const item = l.itemCode ? byCode.get(String(l.itemCode).toUpperCase()) : null;
|
|
259
|
+
const whole = Number.isInteger(l.quantity) && l.quantity > 0 && l.lineAmountCents % l.quantity === 0;
|
|
260
|
+
return {
|
|
261
|
+
priceItemId: item?.id ?? null,
|
|
262
|
+
accountCode: item ? null : l.accountCode ?? null,
|
|
263
|
+
description: l.description ?? item?.name ?? l.itemCode ?? 'Line from Xero',
|
|
264
|
+
quantity: whole ? l.quantity : 1,
|
|
265
|
+
unitAmountCents: whole ? l.lineAmountCents / l.quantity : l.lineAmountCents,
|
|
266
|
+
taxCode: l.taxAmountCents ? 'GST' : 'GST_FREE',
|
|
267
|
+
};
|
|
268
|
+
});
|
|
269
|
+
if (!lines.length) throw new FlowError('invalid', 'the Xero invoice has no lines in the copy');
|
|
270
|
+
const draft = await createDraftInvoice(tx, organisationId, { contactId, reference: row.reference ?? null, dueDate: day(x.due_on), lines });
|
|
271
|
+
const invoice = await issueInvoice(tx, organisationId, draft.id, { issuedBy: actor, issueDate: row.issuedOn ?? new Date().toISOString().slice(0, 10) });
|
|
272
|
+
await insertLink(tx, organisationId, { xeroInvoiceId, invoiceId: invoice.id, matchedBy: 'made', actor });
|
|
273
|
+
if (jobId) await linkJobToInvoice(tx, organisationId, jobId, { invoiceId: invoice.id, accountingRef: xeroInvoiceId, actor });
|
|
274
|
+
return { xeroInvoiceId, invoice };
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// With `autoLink` on, links each sure fit; with `makeUnmatched` on, makes a Flow invoice for
|
|
279
|
+
// each Xero invoice nothing fits. Answers counts. The history import runs it
|
|
280
|
+
// after each read of the copy; a person can run it from the page.
|
|
281
|
+
export async function matchXero(db, organisationId, { actor = 'automation:xero-import' } = {}) {
|
|
282
|
+
const rows = await classify(db, organisationId);
|
|
283
|
+
const { autoLink, makeUnmatched } = await readImportSettings(db, organisationId);
|
|
284
|
+
const result = { linked: 0, made: 0, failed: 0 };
|
|
285
|
+
for (const row of rows) {
|
|
286
|
+
if (row.sure && autoLink) {
|
|
287
|
+
await insertLink(db, organisationId, { xeroInvoiceId: row.xeroInvoiceId, invoiceId: row.invoices[0].id, matchedBy: 'auto', actor }).then(() => { result.linked += 1; }, () => { result.failed += 1; });
|
|
288
|
+
} else if (makeUnmatched && row.state === 'none') {
|
|
289
|
+
await makeFrom(db, organisationId, row, { actor }).then(() => { result.made += 1; }, () => { result.failed += 1; });
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return result;
|
|
293
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
-- Which Flow invoice each Xero invoice is (the Import page, xero/links.js). A
|
|
2
|
+
-- Flow invoice that Flow sent to Xero is linked by its push already; this
|
|
3
|
+
-- table holds the others: matched by the import (`auto`), picked by a person
|
|
4
|
+
-- (`person`), or a Flow invoice made from the Xero invoice (`made`). One Flow
|
|
5
|
+
-- invoice per Xero invoice, and one Xero invoice per Flow invoice.
|
|
6
|
+
CREATE TABLE IF NOT EXISTS "xero_invoice_links" (
|
|
7
|
+
"organisation_id" uuid NOT NULL REFERENCES "organisations"("id"),
|
|
8
|
+
"xero_invoice_id" text NOT NULL,
|
|
9
|
+
"invoice_id" uuid NOT NULL,
|
|
10
|
+
"matched_by" text NOT NULL,
|
|
11
|
+
"linked_by" text NOT NULL,
|
|
12
|
+
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
13
|
+
CONSTRAINT "xero_invoice_links_pk" PRIMARY KEY("organisation_id", "xero_invoice_id"),
|
|
14
|
+
CONSTRAINT "xero_invoice_links_invoice" UNIQUE("organisation_id", "invoice_id"),
|
|
15
|
+
CONSTRAINT "xero_invoice_links_invoice_same_org" FOREIGN KEY ("organisation_id", "invoice_id") REFERENCES "invoices"("organisation_id", "id"),
|
|
16
|
+
CONSTRAINT "xero_invoice_links_matched_by_valid" CHECK ("matched_by" in ('auto', 'person', 'made'))
|
|
17
|
+
);
|
|
18
|
+
--> statement-breakpoint
|
|
19
|
+
-- The Import page's settings, one row for each organisation.
|
|
20
|
+
CREATE TABLE IF NOT EXISTS "xero_import_settings" (
|
|
21
|
+
"organisation_id" uuid PRIMARY KEY NOT NULL REFERENCES "organisations"("id"),
|
|
22
|
+
"auto_link" boolean DEFAULT true NOT NULL,
|
|
23
|
+
"make_unmatched" boolean DEFAULT false NOT NULL,
|
|
24
|
+
"updated_by" text,
|
|
25
|
+
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
|
26
|
+
);
|
|
@@ -57,6 +57,20 @@
|
|
|
57
57
|
"when": 1792500000000,
|
|
58
58
|
"tag": "0007_xero_webhooks",
|
|
59
59
|
"breakpoints": true
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"idx": 8,
|
|
63
|
+
"version": "7",
|
|
64
|
+
"when": 1792510000000,
|
|
65
|
+
"tag": "0008_xero_invoice_links",
|
|
66
|
+
"breakpoints": true
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"idx": 9,
|
|
70
|
+
"version": "7",
|
|
71
|
+
"when": 1792520000000,
|
|
72
|
+
"tag": "0009_xero_sent_to_contact",
|
|
73
|
+
"breakpoints": true
|
|
60
74
|
}
|
|
61
75
|
]
|
|
62
76
|
}
|
|
@@ -77,6 +77,7 @@ export function mapInvoice(invoice, { keepReference = false } = {}) {
|
|
|
77
77
|
amountDueCents: toCents(invoice.AmountDue),
|
|
78
78
|
amountPaidCents: toCents(invoice.AmountPaid),
|
|
79
79
|
amountCreditedCents: toCents(invoice.AmountCredited),
|
|
80
|
+
sentToContact: typeof invoice.SentToContact === 'boolean' ? invoice.SentToContact : null,
|
|
80
81
|
lines: mapLines(invoice.LineItems, keepReference),
|
|
81
82
|
xeroUpdatedAt: parseXeroDate(invoice.UpdatedDateUTC),
|
|
82
83
|
};
|
|
@@ -135,17 +136,17 @@ const UPSERT = {
|
|
|
135
136
|
invoices: (org, r, hash) => sql`
|
|
136
137
|
insert into xero_invoices (organisation_id, xero_invoice_id, type, invoice_number, reference, xero_contact_id, status,
|
|
137
138
|
issued_on, due_on, fully_paid_on, currency, subtotal_cents, tax_cents, total_cents, amount_due_cents,
|
|
138
|
-
amount_paid_cents, amount_credited_cents, lines, xero_updated_at, record_hash)
|
|
139
|
+
amount_paid_cents, amount_credited_cents, sent_to_contact, lines, xero_updated_at, record_hash)
|
|
139
140
|
values (${org}, ${r.xeroInvoiceId}, ${r.type}, ${r.invoiceNumber}, ${r.reference}, ${r.xeroContactId}, ${r.status},
|
|
140
141
|
${r.issuedOn}, ${r.dueOn}, ${r.fullyPaidOn}, ${r.currency}, ${r.subtotalCents}, ${r.taxCents}, ${r.totalCents},
|
|
141
|
-
${r.amountDueCents}, ${r.amountPaidCents}, ${r.amountCreditedCents}, ${json(r.lines)}::jsonb, ${r.xeroUpdatedAt}, ${hash})
|
|
142
|
+
${r.amountDueCents}, ${r.amountPaidCents}, ${r.amountCreditedCents}, ${r.sentToContact ?? null}, ${json(r.lines)}::jsonb, ${r.xeroUpdatedAt}, ${hash})
|
|
142
143
|
on conflict (organisation_id, xero_invoice_id) do update set
|
|
143
144
|
type = excluded.type, invoice_number = excluded.invoice_number, reference = excluded.reference,
|
|
144
145
|
xero_contact_id = excluded.xero_contact_id, status = excluded.status, issued_on = excluded.issued_on,
|
|
145
146
|
due_on = excluded.due_on, fully_paid_on = excluded.fully_paid_on, currency = excluded.currency,
|
|
146
147
|
subtotal_cents = excluded.subtotal_cents, tax_cents = excluded.tax_cents, total_cents = excluded.total_cents,
|
|
147
148
|
amount_due_cents = excluded.amount_due_cents, amount_paid_cents = excluded.amount_paid_cents,
|
|
148
|
-
amount_credited_cents = excluded.amount_credited_cents, lines = excluded.lines,
|
|
149
|
+
amount_credited_cents = excluded.amount_credited_cents, sent_to_contact = excluded.sent_to_contact, lines = excluded.lines,
|
|
149
150
|
xero_updated_at = excluded.xero_updated_at, record_hash = excluded.record_hash, last_changed_at = now()
|
|
150
151
|
where xero_invoices.record_hash is distinct from excluded.record_hash
|
|
151
152
|
returning (xmax = 0) as inserted`,
|
|
@@ -51,8 +51,7 @@ const day = (value) => (value instanceof Date ? value.toISOString().slice(0, 10)
|
|
|
51
51
|
const codes = (lines) => [...(lines ?? []).map((l) => l.itemCode ?? '')].sort().join(',');
|
|
52
52
|
|
|
53
53
|
// What differs between the Xero copy and the version Flow sent. Free text
|
|
54
|
-
// (reference, descriptions) is not compared
|
|
55
|
-
// lab turns that on (XERO_KEEP_REFERENCE).
|
|
54
|
+
// (reference, descriptions) is not compared yet.
|
|
56
55
|
export function sentDifferences(sent, document, copy) {
|
|
57
56
|
const found = [];
|
|
58
57
|
const add = (field, flow, xero) => found.push({ field, flow, xero });
|
|
@@ -90,14 +89,14 @@ const MESSAGES = {
|
|
|
90
89
|
// from Flow invoice id to { state, label, xero, differences, finding, message }.
|
|
91
90
|
export async function accountingStatus(db, runtime, organisationId, invoiceIds = null) {
|
|
92
91
|
const only = invoiceIds ? sql`and i.id = any(${sql.param([...new Set(invoiceIds.map(String))])}::uuid[])` : sql``;
|
|
93
|
-
// Without ids: only invoices Flow sent, for the check after each read.
|
|
94
|
-
const sentOnly = invoiceIds ? sql`` : sql`and p.id is not null`;
|
|
92
|
+
// Without ids: only invoices Flow sent or linked, for the check after each read.
|
|
93
|
+
const sentOnly = invoiceIds ? sql`` : sql`and (p.id is not null or l.xero_invoice_id is not null)`;
|
|
95
94
|
const { rows } = await db.execute(sql`
|
|
96
95
|
select i.id, i.status, i.issued_version, i.total_cents,
|
|
97
96
|
p.version as sent_version, p.state as push_state, p.request_body, p.xero_invoice_id, p.xero_invoice_number,
|
|
98
|
-
v.document,
|
|
97
|
+
v.document, l.xero_invoice_id as linked_xero_id, l.matched_by as linked_by,
|
|
99
98
|
x.status as x_status, x.invoice_number as x_number, x.xero_contact_id, x.subtotal_cents, x.tax_cents,
|
|
100
|
-
x.total_cents as x_total_cents, x.amount_paid_cents, x.amount_due_cents, x.lines, x.issued_on
|
|
99
|
+
x.total_cents as x_total_cents, x.amount_paid_cents, x.amount_due_cents, x.sent_to_contact, x.lines, x.issued_on
|
|
101
100
|
from invoices i
|
|
102
101
|
left join lateral (
|
|
103
102
|
select * from xero_invoice_pushes
|
|
@@ -105,13 +104,17 @@ export async function accountingStatus(db, runtime, organisationId, invoiceIds =
|
|
|
105
104
|
order by version desc limit 1
|
|
106
105
|
) p on true
|
|
107
106
|
left join invoice_versions v on v.invoice_id = i.id and v.version = p.version
|
|
108
|
-
|
|
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
|
|
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)
|
|
109
110
|
where i.organisation_id = ${organisationId} ${only} ${sentOnly}`);
|
|
110
111
|
const web = runtime.settings.endpoints?.web ?? 'https://go.xero.com';
|
|
111
112
|
const out = new Map();
|
|
112
113
|
for (const r of rows) {
|
|
113
114
|
let state;
|
|
114
|
-
|
|
115
|
+
const linked = !r.push_state && Boolean(r.linked_xero_id);
|
|
116
|
+
if (linked) state = r.x_status ? xeroState({ status: r.x_status, amount_paid_cents: r.amount_paid_cents }) : 'not_copied';
|
|
117
|
+
else if (!r.push_state) state = 'not_sent';
|
|
115
118
|
else if (['dispatching', 'ambiguous'].includes(r.push_state)) state = 'sending';
|
|
116
119
|
else if (!r.x_status) state = 'not_copied';
|
|
117
120
|
else state = xeroState({ status: r.x_status, amount_paid_cents: r.amount_paid_cents });
|
|
@@ -121,23 +124,27 @@ export async function accountingStatus(db, runtime, organisationId, invoiceIds =
|
|
|
121
124
|
} catch {
|
|
122
125
|
sent = null;
|
|
123
126
|
}
|
|
127
|
+
const xeroInvoiceId = r.xero_invoice_id ?? r.linked_xero_id;
|
|
124
128
|
const xero = r.x_status ? {
|
|
125
|
-
invoiceId:
|
|
129
|
+
invoiceId: xeroInvoiceId,
|
|
126
130
|
number: r.x_number ?? r.xero_invoice_number ?? null,
|
|
127
131
|
status: r.x_status,
|
|
128
132
|
totalCents: num(r.x_total_cents),
|
|
129
133
|
amountDueCents: num(r.amount_due_cents),
|
|
130
|
-
|
|
134
|
+
sentToContact: r.sent_to_contact ?? null,
|
|
135
|
+
url: `${web}/AccountsReceivable/View.aspx?InvoiceID=${encodeURIComponent(xeroInvoiceId)}`,
|
|
131
136
|
} : null;
|
|
132
137
|
const status = {
|
|
133
138
|
invoiceId: r.id,
|
|
134
139
|
flowStatus: r.status,
|
|
135
140
|
sentVersion: r.sent_version ?? null,
|
|
136
|
-
|
|
141
|
+
// A linked invoice was never sent: it is the Xero invoice, as it stands.
|
|
142
|
+
current: linked || (r.sent_version !== null && r.sent_version === r.issued_version),
|
|
143
|
+
linkedBy: linked ? r.linked_by : null,
|
|
137
144
|
state,
|
|
138
145
|
label: LABELS[state],
|
|
139
146
|
xero,
|
|
140
|
-
differences: xero && r.document && !GONE.has(r.x_status) ? sentDifferences(sent, r.document, { ...r, total_cents: r.x_total_cents }) : [],
|
|
147
|
+
differences: !linked && xero && r.document && !GONE.has(r.x_status) ? sentDifferences(sent, r.document, { ...r, total_cents: r.x_total_cents }) : [],
|
|
141
148
|
flowTotalCents: num(r.total_cents),
|
|
142
149
|
};
|
|
143
150
|
status.finding = findingFor(status);
|
|
@@ -147,6 +154,29 @@ export async function accountingStatus(db, runtime, organisationId, invoiceIds =
|
|
|
147
154
|
return out;
|
|
148
155
|
}
|
|
149
156
|
|
|
157
|
+
// The Invoices page tab of each issued Flow invoice, from Flow's copy of
|
|
158
|
+
// Xero (no calls): draft (on its way, or a Xero DRAFT), approved (AUTHORISED,
|
|
159
|
+
// not sent to the customer yet), sent (Xero sent it, or it is part or fully
|
|
160
|
+
// paid), void (voided or deleted in Xero), or pending (out of sync: a person
|
|
161
|
+
// looks). Answers Map(invoiceId -> stage).
|
|
162
|
+
export async function invoiceStages(db, runtime, organisationId, invoiceIds = null) {
|
|
163
|
+
const statuses = await accountingStatus(db, runtime, organisationId, invoiceIds);
|
|
164
|
+
const out = new Map();
|
|
165
|
+
for (const status of statuses.values()) {
|
|
166
|
+
if (status.flowStatus !== 'issued') continue;
|
|
167
|
+
out.set(status.invoiceId, stageOf(status));
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function stageOf(status) {
|
|
173
|
+
if (status.finding) return 'pending';
|
|
174
|
+
if (['part_paid', 'paid'].includes(status.state)) return 'sent';
|
|
175
|
+
if (status.state === 'approved') return status.xero?.sentToContact ? 'sent' : 'approved';
|
|
176
|
+
if (['voided', 'deleted'].includes(status.state)) return 'void';
|
|
177
|
+
return 'draft';
|
|
178
|
+
}
|
|
179
|
+
|
|
150
180
|
// Opens or refreshes one review item for each finding, and closes the item of
|
|
151
181
|
// an invoice whose finding went away. Answers counts.
|
|
152
182
|
export async function checkSentInvoices(db, runtime, organisationId, { invoiceIds = null } = {}) {
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { and, eq } from 'drizzle-orm';
|
|
2
2
|
import express from 'express';
|
|
3
3
|
import { FlowError } from '../../core/errors.js';
|
|
4
|
+
import { createActionGuard } from '../call-guard.js';
|
|
4
5
|
import { billingObligations, organisations } from '../../db/schema.js';
|
|
5
6
|
import { lastSync, listContacts, listItems, syncCatalogue } from './catalogue.js';
|
|
6
7
|
import { createXeroClient } from './client.js';
|
|
7
8
|
import { describePush, listPushes, MAX_BATCH, previewDrafts, pushDrafts } from './drafts.js';
|
|
8
9
|
import { recordXeroEvidence, recordXeroPayments } from './ledger.js';
|
|
10
|
+
import { IMPORT_STATES, importOverview, linkXeroInvoice, makeFlowInvoice, matchXero, saveImportSettings } from './links.js';
|
|
9
11
|
import { queueHistoryImport, resetImport } from './import.js';
|
|
10
12
|
import { MIRROR_RESOURCES, mirrorStatus, mirrorXero } from './mirror.js';
|
|
11
13
|
import { accountingStatus, checkSentInvoices } from './reconcile.js';
|
|
@@ -42,10 +44,25 @@ import { messagePage as themedMessagePage } from '../../ui/page.js';
|
|
|
42
44
|
// POST /webhooks Xero: change events, signed with the
|
|
43
45
|
// webhook key (XERO_WEBHOOK_KEY); no session
|
|
44
46
|
//
|
|
47
|
+
// Each POST that calls Xero waits between presses (XERO_ACTIONS, the action
|
|
48
|
+
// guard): a press too soon answers 429 with the wait.
|
|
49
|
+
//
|
|
45
50
|
// Only an owner, with a verified owner sign-in, can connect or disconnect.
|
|
46
51
|
// The callback carries no Flow session: the one-use state from /connect,
|
|
47
52
|
// issued to that owner for that organisation, is what admits it.
|
|
48
53
|
|
|
54
|
+
// The buttons that call Xero, and how long each waits before it runs again
|
|
55
|
+
// (the action guard, call-guard.js). A send is one invoice at a time, so it
|
|
56
|
+
// only runs once at a time and backs off after a failure.
|
|
57
|
+
export const XERO_ACTIONS = Object.freeze({
|
|
58
|
+
sync: { cooldownMs: 5 * 60_000 },
|
|
59
|
+
recheck: { cooldownMs: 30_000 },
|
|
60
|
+
push: { cooldownMs: 0, budget: false },
|
|
61
|
+
obligationCheck: { cooldownMs: 30_000 },
|
|
62
|
+
paymentsSync: { cooldownMs: 5 * 60_000 },
|
|
63
|
+
mirror: { cooldownMs: 2 * 60_000 },
|
|
64
|
+
});
|
|
65
|
+
|
|
49
66
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
50
67
|
|
|
51
68
|
|
|
@@ -92,10 +109,11 @@ export function callsState(status) {
|
|
|
92
109
|
return { stopped: Boolean(status.haltedReason) || resources.length > 0, all: Boolean(status.haltedReason), resources, since };
|
|
93
110
|
}
|
|
94
111
|
|
|
95
|
-
export function createXeroRouter({ db, personAuth, runtime, log = console, messagePage = (options) => themedMessagePage({ ...options, defaultFamily: DEFAULT_FAMILY }) }) {
|
|
112
|
+
export function createXeroRouter({ db, personAuth, runtime, log = console, buttonCooldowns = true, actions = createActionGuard({ integration: 'xero', actions: XERO_ACTIONS, off: !buttonCooldowns }), messagePage = (options) => themedMessagePage({ ...options, defaultFamily: DEFAULT_FAMILY }) }) {
|
|
96
113
|
const page = (title, text, tone) => messagePage({ title, text, tone });
|
|
97
114
|
const { settings } = runtime;
|
|
98
115
|
const router = express.Router();
|
|
116
|
+
const act = (name, key) => actions.route(name, { key });
|
|
99
117
|
|
|
100
118
|
// Xero's webhooks (webhooks.js), before the JSON parser: the signature
|
|
101
119
|
// covers the raw body. Xero wants 200 with no body within 5 seconds, and
|
|
@@ -219,7 +237,7 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, messa
|
|
|
219
237
|
return res.json({ ok: true, disconnected: true, revoked });
|
|
220
238
|
}));
|
|
221
239
|
|
|
222
|
-
router.post('/sync', ...manager, handle(log, async (req, res) => {
|
|
240
|
+
router.post('/sync', ...manager, act('sync'), handle(log, async (req, res) => {
|
|
223
241
|
const organisationId = await requireOrganisation(db, req.body?.organisationId);
|
|
224
242
|
const run = await syncCatalogue(db, runtime, organisationId, { startedBy: req.person.id });
|
|
225
243
|
res.json({ ok: true, sync: run });
|
|
@@ -251,7 +269,7 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, messa
|
|
|
251
269
|
// Mark resolved: read Xero's changes (a few calls, through the call guard),
|
|
252
270
|
// then check this one invoice again. The item closes only when the
|
|
253
271
|
// difference is gone; otherwise it stays open with the new evidence.
|
|
254
|
-
router.post('/invoices/:invoiceId/recheck', ...manager, handle(log, async (req, res) => {
|
|
272
|
+
router.post('/invoices/:invoiceId/recheck', ...manager, act('recheck', (req) => req.params.invoiceId), handle(log, async (req, res) => {
|
|
255
273
|
const organisationId = await requireOrganisation(db, req.body?.organisationId);
|
|
256
274
|
const [invoiceId] = requireInvoiceIds([req.params.invoiceId], 'invoiceId');
|
|
257
275
|
await mirrorXero(db, runtime, organisationId, { resources: ['invoices'] });
|
|
@@ -260,7 +278,7 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, messa
|
|
|
260
278
|
res.json({ ok: true, resolved: !status?.finding, closed: result.closed, status });
|
|
261
279
|
}));
|
|
262
280
|
|
|
263
|
-
router.post('/invoices/push', ...manager, handle(log, async (req, res) => {
|
|
281
|
+
router.post('/invoices/push', ...manager, act('push'), handle(log, async (req, res) => {
|
|
264
282
|
const organisationId = await requireOrganisation(db, req.body?.organisationId);
|
|
265
283
|
const invoiceIds = requireInvoiceIds(req.body?.invoiceIds);
|
|
266
284
|
const allowExistingReference = req.body?.allowExistingReference === undefined ? [] : requireInvoiceIds(req.body.allowExistingReference, 'allowExistingReference');
|
|
@@ -275,7 +293,7 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, messa
|
|
|
275
293
|
res.json({ ok: true, pushes: (await listPushes(db, organisationId, invoiceId)).map(describePush) });
|
|
276
294
|
}));
|
|
277
295
|
|
|
278
|
-
router.post('/obligations/:obligationId/check', ...manager, handle(log, async (req, res) => {
|
|
296
|
+
router.post('/obligations/:obligationId/check', ...manager, act('obligationCheck', (req) => req.params.obligationId), handle(log, async (req, res) => {
|
|
279
297
|
const organisationId = await requireOrganisation(db, req.body?.organisationId);
|
|
280
298
|
const [obligationId] = requireInvoiceIds([req.params.obligationId], 'obligationId');
|
|
281
299
|
const [obligation] = await db.select().from(billingObligations)
|
|
@@ -285,13 +303,13 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, messa
|
|
|
285
303
|
res.json({ ok: true, obligation: row });
|
|
286
304
|
}));
|
|
287
305
|
|
|
288
|
-
router.post('/payments/sync', ...manager, handle(log, async (req, res) => {
|
|
306
|
+
router.post('/payments/sync', ...manager, act('paymentsSync'), handle(log, async (req, res) => {
|
|
289
307
|
const organisationId = await requireOrganisation(db, req.body?.organisationId);
|
|
290
308
|
const result = await recordXeroPayments(db, runtime, organisationId, { actor: `person:${req.person.id}` });
|
|
291
309
|
res.json({ ok: true, ...result });
|
|
292
310
|
}));
|
|
293
311
|
|
|
294
|
-
router.post('/mirror', ...manager, handle(log, async (req, res) => {
|
|
312
|
+
router.post('/mirror', ...manager, act('mirror'), handle(log, async (req, res) => {
|
|
295
313
|
const organisationId = await requireOrganisation(db, req.body?.organisationId);
|
|
296
314
|
const resources = req.body?.resources ?? MIRROR_RESOURCES;
|
|
297
315
|
if (!Array.isArray(resources) || !resources.length || !resources.every((r) => MIRROR_RESOURCES.includes(r))) {
|
|
@@ -307,5 +325,46 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, messa
|
|
|
307
325
|
res.json({ ok: true, ...(await mirrorStatus(db, organisationId)) });
|
|
308
326
|
}));
|
|
309
327
|
|
|
328
|
+
// The Import page (links.js): each Xero sales invoice in Flow's copy against
|
|
329
|
+
// Flow's own invoices. Reads Flow's database only.
|
|
330
|
+
router.get('/import', ...signedIn, handle(log, async (req, res) => {
|
|
331
|
+
const organisationId = await requireOrganisation(db, req.query.organisationId);
|
|
332
|
+
const show = typeof req.query.show === 'string' && [...IMPORT_STATES, 'check', 'all'].includes(req.query.show) ? req.query.show : 'all';
|
|
333
|
+
const q = typeof req.query.q === 'string' ? req.query.q.slice(0, 200) : '';
|
|
334
|
+
const offset = Math.max(0, Math.min(Number.parseInt(req.query.offset, 10) || 0, 1_000_000));
|
|
335
|
+
const limit = Math.max(1, Math.min(Number.parseInt(req.query.limit, 10) || 50, 100));
|
|
336
|
+
res.json({ ok: true, ...(await importOverview(db, organisationId, { show, q, limit, offset })) });
|
|
337
|
+
}));
|
|
338
|
+
|
|
339
|
+
router.post('/import/match', ...manager, handle(log, async (req, res) => {
|
|
340
|
+
const organisationId = await requireOrganisation(db, req.body?.organisationId);
|
|
341
|
+
const result = await matchXero(db, organisationId, { actor: `person:${req.person.id}` });
|
|
342
|
+
log.info?.(`[xero] organisation ${organisationId}: person ${req.person.id} matched Xero invoices: ${JSON.stringify(result)}`);
|
|
343
|
+
res.json({ ok: true, ...result });
|
|
344
|
+
}));
|
|
345
|
+
|
|
346
|
+
router.put('/import/settings', ...manager, handle(log, async (req, res) => {
|
|
347
|
+
const organisationId = await requireOrganisation(db, req.body?.organisationId);
|
|
348
|
+
res.json({ ok: true, settings: await saveImportSettings(db, organisationId, { autoLink: req.body?.autoLink, makeUnmatched: req.body?.makeUnmatched, actor: `person:${req.person.id}` }) });
|
|
349
|
+
}));
|
|
350
|
+
|
|
351
|
+
const xeroId = (raw) => {
|
|
352
|
+
if (typeof raw !== 'string' || !UUID.test(raw)) throw Object.assign(new FlowError('not_found', 'Xero invoice not found'), { status: 404 });
|
|
353
|
+
return raw;
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
router.post('/import/:xeroInvoiceId/link', ...manager, handle(log, async (req, res) => {
|
|
357
|
+
const organisationId = await requireOrganisation(db, req.body?.organisationId);
|
|
358
|
+
const [invoiceId] = requireInvoiceIds([req.body?.invoiceId], 'invoiceId');
|
|
359
|
+
res.json({ ok: true, ...(await linkXeroInvoice(db, organisationId, xeroId(req.params.xeroInvoiceId), { invoiceId, actor: `person:${req.person.id}` })) });
|
|
360
|
+
}));
|
|
361
|
+
|
|
362
|
+
router.post('/import/:xeroInvoiceId/make', ...manager, handle(log, async (req, res) => {
|
|
363
|
+
const organisationId = await requireOrganisation(db, req.body?.organisationId);
|
|
364
|
+
const jobId = req.body?.jobId == null ? null : requireInvoiceIds([req.body.jobId], 'jobId')[0];
|
|
365
|
+
const { invoice } = await makeFlowInvoice(db, organisationId, xeroId(req.params.xeroInvoiceId), { jobId, actor: `person:${req.person.id}` });
|
|
366
|
+
res.json({ ok: true, invoice: { id: invoice.id, number: invoice.number } });
|
|
367
|
+
}));
|
|
368
|
+
|
|
310
369
|
return router;
|
|
311
370
|
}
|