@arcforgelabs/flow 2026.10.46 → 2026.10.48
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 +1 -0
- package/VISION.md +10 -10
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/api/invoicing.js +89 -22
- package/server/auth/people.js +3 -1
- package/server/auth/routes.js +4 -1
- package/server/core/contacts.js +160 -0
- package/server/core/directory.js +4 -9
- package/server/core/invoice-rules.js +34 -4
- package/server/core/invoices.js +93 -1
- package/server/core/invoicing-rules.js +20 -3
- package/server/core/job-board.js +1 -1
- package/server/core/sending.js +141 -25
- package/server/db/doctor.js +1 -1
- package/server/db/index.js +19 -0
- package/server/db/migrations/0021_flow_schema.sql +68 -0
- package/server/db/migrations/0022_contacts.sql +11 -0
- package/server/db/migrations/meta/0021_snapshot.json +4377 -0
- package/server/db/migrations/meta/0022_snapshot.json +4446 -0
- package/server/db/migrations/meta/_journal.json +14 -0
- package/server/db/schema.js +15 -0
- package/server/integrations/port.js +2 -0
- package/server/integrations/schema.js +3 -2
- package/server/integrations/sentry/index.js +14 -7
- package/server/integrations/setup.js +1 -0
- package/server/integrations/xero/catalogue.js +82 -14
- package/server/integrations/xero/client.js +37 -0
- package/server/integrations/xero/create-contact.js +126 -0
- package/server/integrations/xero/delete-invoice.js +104 -0
- package/server/integrations/xero/drafts.js +19 -8
- package/server/integrations/xero/index.js +9 -0
- package/server/integrations/xero/migrations/0011_xero_invoice_deleted.sql +11 -0
- package/server/integrations/xero/migrations/0012_xero_contact_details.sql +16 -0
- package/server/integrations/xero/migrations/meta/_journal.json +14 -0
- package/server/integrations/xero/routes.js +21 -0
- package/server/integrations/xero/settings.js +3 -0
- package/server/ui/alerts.js +2 -1
- package/server/ui/app.js +113 -28
- package/server/ui/bulk-bar.js +18 -5
- package/server/ui/columns.js +5 -2
- package/server/ui/contacts.js +260 -0
- package/server/ui/custom-fields.js +22 -7
- package/server/ui/flow.css +275 -24
- package/server/ui/invoicing.js +374 -70
- package/server/ui/job-board.js +10 -0
- package/server/ui/job-change.js +4 -3
- package/server/ui/job-view.js +3 -1
- package/server/ui/jobs.js +150 -19
- package/server/ui/kit.js +220 -45
- package/server/ui/list-views.js +50 -10
- package/server/ui/mobile.js +729 -0
- package/server/ui/monitoring.js +51 -0
- package/server/ui/nav-pins.js +44 -0
- package/server/ui/pager.js +57 -10
- package/server/ui/price-list.js +1 -1
- package/server/ui/price-reconcile.js +12 -12
- package/server/ui/reconcile.js +18 -17
- package/server/ui/routes.js +1 -1
- package/server/ui/saved-views.js +2 -1
- package/server/ui/sidecar.js +106 -6
- package/server/ui/view-bar.js +22 -11
- package/server/ui/xero-import.js +48 -19
package/server/core/invoices.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { and, asc, desc, eq, sql } from 'drizzle-orm';
|
|
2
|
-
import { contacts, invoiceLines, invoices, invoiceVersions, organisations, priceItems } from '../db/schema.js';
|
|
2
|
+
import { billingObligations, contacts, invoiceLines, invoices, invoiceVersions, obligationEvents, organisations, priceItems } from '../db/schema.js';
|
|
3
3
|
import { FlowError, notFound } from './errors.js';
|
|
4
4
|
import { invoiceTotals, lineTotals } from './money.js';
|
|
5
5
|
|
|
@@ -7,6 +7,11 @@ import { invoiceTotals, lineTotals } from './money.js';
|
|
|
7
7
|
// invoice its number and stores the document exactly as sent, as a new
|
|
8
8
|
// version. To change an issued invoice, revise it back to a draft and issue
|
|
9
9
|
// it again: the number stays and the earlier versions stay as they were.
|
|
10
|
+
// A draft that was never issued can be deleted. An issued invoice is voided.
|
|
11
|
+
// When its accounting copy is still a draft, deleteDraft asks the accounting
|
|
12
|
+
// adapter to delete it there first, and voids in Flow only after the adapter
|
|
13
|
+
// confirms (docs/XERO.md, "Deleting a draft"). With no accounting system, an
|
|
14
|
+
// issued invoice answers accounting_unavailable.
|
|
10
15
|
|
|
11
16
|
async function loadInvoice(tx, organisationId, invoiceId, { lock = false } = {}) {
|
|
12
17
|
let query = tx.select().from(invoices)
|
|
@@ -182,6 +187,93 @@ export async function voidInvoice(db, organisationId, invoiceId) {
|
|
|
182
187
|
});
|
|
183
188
|
}
|
|
184
189
|
|
|
190
|
+
// The job an invoice billed is free again: its obligation no longer points at
|
|
191
|
+
// the invoice, so the lab's rules decide whether Flow drafts it once more.
|
|
192
|
+
async function freeJobs(tx, organisationId, invoice, actor) {
|
|
193
|
+
const linked = await tx.select().from(billingObligations)
|
|
194
|
+
.where(and(eq(billingObligations.organisationId, organisationId), eq(billingObligations.invoiceId, invoice.id)))
|
|
195
|
+
.for('update');
|
|
196
|
+
if (linked.length && !actor) throw new FlowError('invalid', 'actor is required');
|
|
197
|
+
for (const obligation of linked) {
|
|
198
|
+
await tx.update(billingObligations)
|
|
199
|
+
.set({ invoiceId: null, draftBasis: null, updatedAt: sql`now()` })
|
|
200
|
+
.where(eq(billingObligations.id, obligation.id));
|
|
201
|
+
await tx.insert(obligationEvents).values({
|
|
202
|
+
createdAt: sql`clock_timestamp()`,
|
|
203
|
+
organisationId,
|
|
204
|
+
obligationId: obligation.id,
|
|
205
|
+
kind: 'draft_deleted',
|
|
206
|
+
actor,
|
|
207
|
+
evidence: { invoiceId: invoice.id },
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Same words as the Send gate (server/core/sending.js): writes that are off,
|
|
213
|
+
// or a system that is not connected, stop before anything changes. An issued
|
|
214
|
+
// invoice with no accounting delete answers accounting_unavailable.
|
|
215
|
+
async function deleteInAccounting(accounting, organisationId, invoiceId, actor) {
|
|
216
|
+
if (!accounting || typeof accounting.capabilities?.deleteInvoice !== 'function') {
|
|
217
|
+
throw new FlowError('accounting_unavailable', 'No accounting system is connected. An issued invoice stays as it is.');
|
|
218
|
+
}
|
|
219
|
+
const status = typeof accounting.capabilities.sendStatus === 'function'
|
|
220
|
+
? await accounting.capabilities.sendStatus({ organisationId })
|
|
221
|
+
: { connected: false, writesAllowed: false };
|
|
222
|
+
if (!status?.connected) {
|
|
223
|
+
throw new FlowError('accounting_not_connected', `${accounting.name} is not connected. Connect it in Integrations, then send.`);
|
|
224
|
+
}
|
|
225
|
+
if (!status.writesAllowed) {
|
|
226
|
+
throw new FlowError('accounting_writes_off', `${accounting.name} does not take invoices from this installation yet (writes are off). Nothing was issued.`);
|
|
227
|
+
}
|
|
228
|
+
const answer = await accounting.capabilities.deleteInvoice({ organisationId, invoiceId, actor });
|
|
229
|
+
if (answer?.state === 'approved') {
|
|
230
|
+
throw new FlowError('accounting_approved', answer.message || 'Approved in the accounting system. Void it there first.');
|
|
231
|
+
}
|
|
232
|
+
if (answer?.state === 'deleted' || answer?.state === 'gone') return answer;
|
|
233
|
+
throw new FlowError('accounting_unavailable', answer?.message || 'The accounting system cannot delete this invoice. Nothing was changed.');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Removes a draft that was never issued, and frees its job. An invoice that
|
|
237
|
+
// was issued is voided only after its accounting adapter deletes the draft
|
|
238
|
+
// there, or reports it already gone. The issued versions stay. With no
|
|
239
|
+
// accounting delete, or when the accounting invoice is approved or paid,
|
|
240
|
+
// nothing in Flow changes.
|
|
241
|
+
export async function deleteDraft(db, organisationId, invoiceId, { actor = null, accounting = null } = {}) {
|
|
242
|
+
const current = await loadInvoice(db, organisationId, invoiceId);
|
|
243
|
+
if (current.status === 'void') throw new FlowError('conflict', 'a void invoice is final');
|
|
244
|
+
if (current.status === 'draft' && current.issuedVersion === 0) {
|
|
245
|
+
return db.transaction(async (tx) => {
|
|
246
|
+
const invoice = await loadInvoice(tx, organisationId, invoiceId, { lock: true });
|
|
247
|
+
if (invoice.status !== 'draft' || invoice.issuedVersion !== 0) {
|
|
248
|
+
throw new FlowError('was_issued', 'an invoice that was issued cannot be deleted; void it');
|
|
249
|
+
}
|
|
250
|
+
await freeJobs(tx, organisationId, invoice, actor);
|
|
251
|
+
try {
|
|
252
|
+
await tx.delete(invoices).where(eq(invoices.id, invoice.id));
|
|
253
|
+
} catch (error) {
|
|
254
|
+
const code = error?.code ?? error?.cause?.code;
|
|
255
|
+
if (code === '23503') throw new FlowError('conflict', 'this draft is still linked; remove the link, then delete it');
|
|
256
|
+
throw error;
|
|
257
|
+
}
|
|
258
|
+
return invoice;
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
// Read and delete outside the Flow transaction. A timeout or a refusal
|
|
262
|
+
// throws here, and the invoice stays as it was. The next try reads again.
|
|
263
|
+
await deleteInAccounting(accounting, organisationId, invoiceId, actor);
|
|
264
|
+
return db.transaction(async (tx) => {
|
|
265
|
+
const invoice = await loadInvoice(tx, organisationId, invoiceId, { lock: true });
|
|
266
|
+
if (invoice.status === 'void') return invoice;
|
|
267
|
+
if (invoice.issuedVersion === 0) {
|
|
268
|
+
throw new FlowError('was_issued', 'an invoice that was issued cannot be deleted; void it');
|
|
269
|
+
}
|
|
270
|
+
const [row] = await tx.update(invoices).set({ status: 'void', updatedAt: sql`now()` })
|
|
271
|
+
.where(eq(invoices.id, invoice.id)).returning();
|
|
272
|
+
await freeJobs(tx, organisationId, row, actor);
|
|
273
|
+
return row;
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
185
277
|
export async function getInvoice(db, organisationId, invoiceId) {
|
|
186
278
|
const invoice = await loadInvoice(db, organisationId, invoiceId);
|
|
187
279
|
const lines = await db.select().from(invoiceLines)
|
|
@@ -150,6 +150,16 @@ export async function customerBillingByContact(db, organisationId, contactIds) {
|
|
|
150
150
|
return new Map(rows.map((r) => [r.contactId, pick(r, CUSTOMER_KEYS)]));
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
// The customers' names, by contact id. Customer "contains" reads the name
|
|
154
|
+
// (invoice-rules.js), not the contact id the menu tests use.
|
|
155
|
+
async function customerNames(db, organisationId, contactIds) {
|
|
156
|
+
const ids = [...new Set(contactIds.filter(Boolean))];
|
|
157
|
+
if (!ids.length) return new Map();
|
|
158
|
+
const rows = await db.select({ id: contacts.id, name: contacts.name }).from(contacts)
|
|
159
|
+
.where(and(eq(contacts.organisationId, organisationId), inArray(contacts.id, ids)));
|
|
160
|
+
return new Map(rows.map((r) => [r.id, r.name]));
|
|
161
|
+
}
|
|
162
|
+
|
|
153
163
|
// The settings that apply to one customer: its own, else the organisation's.
|
|
154
164
|
// `autoDraft` is the customer's own switch for the draft automation.
|
|
155
165
|
export function effectiveSettings(settings, customer = CUSTOMER_DEFAULTS) {
|
|
@@ -255,13 +265,16 @@ export async function autoDraftJobs(db, organisationId, { accounting = null, wor
|
|
|
255
265
|
sql`${billingObligations.contactId} is not null`,
|
|
256
266
|
// Newest first: a backlog of jobs that wait on a rule never hides new work.
|
|
257
267
|
)).orderBy(sql`${billingObligations.jobDate} desc nulls last`, billingObligations.id).limit(limit);
|
|
258
|
-
const
|
|
268
|
+
const contactIds = candidates.map((o) => o.contactId);
|
|
269
|
+
const customers = await customerBillingByContact(db, organisationId, contactIds);
|
|
270
|
+
const names = await customerNames(db, organisationId, contactIds);
|
|
271
|
+
const named = (contactId) => ({ ...effectiveSettings(settings, customers.get(contactId)), name: names.get(contactId) ?? null });
|
|
259
272
|
const liveCheck = accounting ? copyCheck(accounting, organisationId) : null;
|
|
260
273
|
const invoiceDate = isoDay(now);
|
|
261
274
|
let accountingRead = null;
|
|
262
275
|
|
|
263
276
|
for (const candidate of candidates) {
|
|
264
|
-
let effective =
|
|
277
|
+
let effective = named(candidate.contactId);
|
|
265
278
|
if (!effective.autoDraft) {
|
|
266
279
|
result.off += 1;
|
|
267
280
|
continue;
|
|
@@ -302,7 +315,11 @@ export async function autoDraftJobs(db, organisationId, { accounting = null, wor
|
|
|
302
315
|
}
|
|
303
316
|
}
|
|
304
317
|
if (obligation !== candidate) {
|
|
305
|
-
|
|
318
|
+
if (obligation.contactId && !names.has(obligation.contactId)) {
|
|
319
|
+
const extra = await customerNames(db, organisationId, [obligation.contactId]);
|
|
320
|
+
for (const [id, name] of extra) names.set(id, name);
|
|
321
|
+
}
|
|
322
|
+
effective = named(obligation.contactId);
|
|
306
323
|
if (!effective.autoDraft || ruleBlockers(obligation, effective, { now, rules }).length) {
|
|
307
324
|
result.waiting += 1;
|
|
308
325
|
continue;
|
package/server/core/job-board.js
CHANGED
|
@@ -55,7 +55,7 @@ export async function describeObligations(db, organisationId, listed, { accounti
|
|
|
55
55
|
// The invoicing rules a job with no invoice waits on, for every view a
|
|
56
56
|
// draft can still come from.
|
|
57
57
|
const waitingFor = (o, v) => (o.invoiceId || !['in_work', 'ready', 'pending', 'held'].includes(v)
|
|
58
|
-
? [] : ruleBlockers(o, effectiveSettings(settings, customers.get(o.contactId)), { rules }));
|
|
58
|
+
? [] : ruleBlockers(o, { ...effectiveSettings(settings, customers.get(o.contactId)), name: nameOf.get(o.contactId) ?? null }, { rules }));
|
|
59
59
|
return listed.map(({ obligation: { lineFingerprint: _fingerprint, organisationId: _org, draftBasis: _basis, ...o }, view, reasons }) => ({
|
|
60
60
|
...o,
|
|
61
61
|
canUseNewLines: offers.has(o.id),
|
package/server/core/sending.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm';
|
|
3
|
-
import { billingObligations, invoiceLines, invoices } from '../db/schema.js';
|
|
3
|
+
import { billingObligations, contacts, invoiceLines, invoices } from '../db/schema.js';
|
|
4
4
|
import { FlowError } from './errors.js';
|
|
5
|
-
import { listRules, rulePending } from './invoice-rules.js';
|
|
5
|
+
import { listRules, rulePending, ruleSkip } from './invoice-rules.js';
|
|
6
6
|
import { customerBillingByContact, effectiveSettings, readInvoicingSettings } from './invoicing-rules.js';
|
|
7
7
|
import { issueInvoice } from './invoices.js';
|
|
8
8
|
import { CHANGED_REASON, diffJobLines, STATUS_BACK_REASON } from './ledger.js';
|
|
@@ -17,7 +17,9 @@ import { deadHoldSince, deadSince, enqueueJob, runNextJob } from './queue.js';
|
|
|
17
17
|
// charge, an account) on every line. When an accounting
|
|
18
18
|
// system is connected, its own checks are added (for Xero: the customer
|
|
19
19
|
// matches one Xero contact, every item is in Xero), and the lab's pending
|
|
20
|
-
// rules (invoice-rules.js) add their notes.
|
|
20
|
+
// rules (invoice-rules.js) add their notes. An enabled skip rule does too:
|
|
21
|
+
// a draft whose job it matches stays Pending, even when the draft was made
|
|
22
|
+
// by hand or before the rule existed. A draft with a problem is
|
|
21
23
|
// Pending (the "problem" view). A draft without one is Ready.
|
|
22
24
|
//
|
|
23
25
|
// Send all ready issues every ready draft (each gets its number) and, when an
|
|
@@ -35,6 +37,13 @@ export const CHECK_LIMIT = 500;
|
|
|
35
37
|
const WORKER_BATCH = 25;
|
|
36
38
|
const WORKER_EVERY_MS = 30 * 1000;
|
|
37
39
|
const LEASE_MS = 15 * 60 * 1000;
|
|
40
|
+
// One error report names this many refused invoices. The count still says
|
|
41
|
+
// how many the accounting system refused.
|
|
42
|
+
const REFUSAL_REPORT_CAP = 5;
|
|
43
|
+
const REFUSAL_MESSAGE_LIMIT = 160;
|
|
44
|
+
const INVOICE_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
45
|
+
const INVOICE_NUMBER = /^[A-Za-z0-9-]{1,32}$/;
|
|
46
|
+
const REFUSAL_CODE = /^[a-z0-9_]{1,64}$/i;
|
|
38
47
|
// Outcomes that mean the accounting system holds the invoice.
|
|
39
48
|
const SENT = new Set(['created', 'reconciled', 'existing', 'already_in_xero']);
|
|
40
49
|
|
|
@@ -81,19 +90,21 @@ async function checkDrafts(db, organisationId, drafts, accounting) {
|
|
|
81
90
|
const found = draftProblems(draft, linesOf.get(draft.id) ?? []);
|
|
82
91
|
if (found.length) problems.set(draft.id, found);
|
|
83
92
|
}
|
|
84
|
-
const jobs = await db.select({ obligation: billingObligations, reference: invoices.reference }).from(billingObligations)
|
|
93
|
+
const jobs = await db.select({ obligation: billingObligations, reference: invoices.reference, customerName: contacts.name }).from(billingObligations)
|
|
85
94
|
.innerJoin(invoices, eq(invoices.id, billingObligations.invoiceId))
|
|
95
|
+
.leftJoin(contacts, and(eq(contacts.id, billingObligations.contactId), eq(contacts.organisationId, billingObligations.organisationId)))
|
|
86
96
|
.where(and(eq(billingObligations.organisationId, organisationId), inArray(billingObligations.invoiceId, ids)));
|
|
87
|
-
// The lab's
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
const rules = (await listRules(db, organisationId)).filter((r) => r.enabled && r.action.kind === 'pending');
|
|
97
|
+
// The lab's rules (invoice-rules.js). A pending rule puts its note on the
|
|
98
|
+
// draft. A skip rule holds a draft that already exists: void it, or change
|
|
99
|
+
// the rule. Matching stays in invoice-rules.js.
|
|
100
|
+
const rules = (await listRules(db, organisationId)).filter((r) => r.enabled && (r.action.kind === 'pending' || r.action.kind === 'skip'));
|
|
91
101
|
if (rules.length && jobs.length) {
|
|
92
102
|
const settings = await readInvoicingSettings(db, organisationId);
|
|
93
103
|
const customers = await customerBillingByContact(db, organisationId, jobs.map((j) => j.obligation.contactId));
|
|
94
|
-
for (const { obligation, reference } of jobs) {
|
|
95
|
-
const customer = effectiveSettings(settings, customers.get(obligation.contactId));
|
|
96
|
-
const
|
|
104
|
+
for (const { obligation, reference, customerName } of jobs) {
|
|
105
|
+
const customer = { ...effectiveSettings(settings, customers.get(obligation.contactId)), name: customerName ?? null };
|
|
106
|
+
const job = { ...obligation, reference };
|
|
107
|
+
const found = [...rulePending(rules, job, { customer }), ...ruleSkip(rules, job, { customer })];
|
|
97
108
|
if (found.length) problems.set(obligation.invoiceId, [...(problems.get(obligation.invoiceId) ?? []), ...found]);
|
|
98
109
|
}
|
|
99
110
|
}
|
|
@@ -196,23 +207,61 @@ async function assertCanSend(accounting, organisationId) {
|
|
|
196
207
|
// Read before act (issue #141): the jobs behind these drafts whose work
|
|
197
208
|
// system report is old are read again first. Answers the drafts that may go
|
|
198
209
|
// now; a draft whose job went to review, or could not be read, waits.
|
|
210
|
+
// `stops` names each held draft: `unread` (read failed or past the cap) or
|
|
211
|
+
// `review` (the job changed).
|
|
199
212
|
async function readJobsFirst(db, organisationId, invoiceIds, { work, now, personId }) {
|
|
200
|
-
if (!invoiceIds.length || typeof work?.capabilities?.readWorkItem !== 'function')
|
|
213
|
+
if (!invoiceIds.length || typeof work?.capabilities?.readWorkItem !== 'function') {
|
|
214
|
+
return { go: invoiceIds, waiting: [], stops: [] };
|
|
215
|
+
}
|
|
201
216
|
const jobs = await db.select().from(billingObligations)
|
|
202
217
|
.where(and(eq(billingObligations.organisationId, organisationId), inArray(billingObligations.invoiceId, invoiceIds)));
|
|
203
218
|
const old = jobs.filter((job) => needsRead(job, { work, now }));
|
|
204
219
|
const outcomes = await readBeforeAct(db, organisationId, old.slice(0, READS_PER_PASS), { work, now, actor: `person:${personId}` });
|
|
205
|
-
const held = new Set(
|
|
220
|
+
const held = new Set();
|
|
221
|
+
const stops = [];
|
|
222
|
+
for (const job of old.slice(READS_PER_PASS)) {
|
|
223
|
+
held.add(job.invoiceId);
|
|
224
|
+
stops.push({ invoiceId: job.invoiceId, kind: 'deferred' });
|
|
225
|
+
}
|
|
206
226
|
for (const { outcome, obligation } of outcomes.values()) {
|
|
207
|
-
if (outcome === 'waiting'
|
|
227
|
+
if (outcome === 'waiting') {
|
|
228
|
+
held.add(obligation.invoiceId);
|
|
229
|
+
stops.push({ invoiceId: obligation.invoiceId, kind: 'unread' });
|
|
230
|
+
} else if (outcome === 'review') {
|
|
231
|
+
held.add(obligation.invoiceId);
|
|
232
|
+
stops.push({ invoiceId: obligation.invoiceId, kind: 'review' });
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return { go: invoiceIds.filter((id) => !held.has(id)), waiting: invoiceIds.filter((id) => held.has(id)), stops };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// The error when a ticked press sends nothing: name the cause (and the count
|
|
239
|
+
// for jobs), never one shared sentence.
|
|
240
|
+
function stoppedSendMessage({ accountingWaiting, accountingReason, stops, waiting }) {
|
|
241
|
+
if (accountingWaiting) {
|
|
242
|
+
if (accountingReason && /more changes than one read takes/i.test(accountingReason)) {
|
|
243
|
+
return 'The accounting copy has more changes than one read takes. Nothing was sent.';
|
|
244
|
+
}
|
|
245
|
+
if (accountingReason) return `${accountingReason.replace(/\.?$/, '')}. Nothing was sent.`;
|
|
246
|
+
return 'The accounting copy could not be read. Nothing was sent.';
|
|
247
|
+
}
|
|
248
|
+
const unread = stops.filter((stop) => stop.kind === 'unread').length;
|
|
249
|
+
const reviews = stops.filter((stop) => stop.kind === 'review').length;
|
|
250
|
+
if (unread) {
|
|
251
|
+
return `${unread} ${unread === 1 ? 'job' : 'jobs'} could not be read from the work system. Nothing was sent.`;
|
|
252
|
+
}
|
|
253
|
+
if (reviews) {
|
|
254
|
+
return `${reviews} ${reviews === 1 ? 'job' : 'jobs'} changed and ${reviews === 1 ? 'needs' : 'need'} a look. Nothing was sent.`;
|
|
208
255
|
}
|
|
209
|
-
|
|
256
|
+
const n = waiting.length || 1;
|
|
257
|
+
return `${n} ${n === 1 ? 'job' : 'jobs'} could not be confirmed with the work system. Nothing was sent.`;
|
|
210
258
|
}
|
|
211
259
|
|
|
212
260
|
// Send all ready: issues each ready draft, then queues it for the accounting
|
|
213
|
-
// system. With `invoiceIds`, only those drafts
|
|
214
|
-
//
|
|
215
|
-
//
|
|
261
|
+
// system. With `invoiceIds`, only those drafts. One id (the invoice page) is
|
|
262
|
+
// all or nothing. More than one id (the bulk bar) sends the ones Flow could
|
|
263
|
+
// confirm and leaves the rest. `work` is the work integration, for the read
|
|
264
|
+
// before act. Answers { issued, queued, remaining, waiting, held }.
|
|
216
265
|
export async function sendAllReady(db, organisationId, { accounting = null, work = null, personId, issueDate, invoiceIds = null, now = new Date() }) {
|
|
217
266
|
await assertCanSend(accounting, organisationId);
|
|
218
267
|
// Issue #45: the accounting copy reads its latest changes first, so the
|
|
@@ -236,11 +285,23 @@ export async function sendAllReady(db, organisationId, { accounting = null, work
|
|
|
236
285
|
: 'Only a ready invoice can be sent. Mark it ready first.');
|
|
237
286
|
}
|
|
238
287
|
}
|
|
239
|
-
const
|
|
240
|
-
|
|
288
|
+
const accountingWaiting = accountingRead.outcome === 'waiting';
|
|
289
|
+
const { go, waiting, stops } = accountingWaiting
|
|
290
|
+
? { go: [], waiting: ready.slice(0, SEND_BATCH), stops: [] }
|
|
241
291
|
: await readJobsFirst(db, organisationId, ready.slice(0, SEND_BATCH), { work, now, personId });
|
|
242
|
-
|
|
243
|
-
|
|
292
|
+
// One invoice (the invoice page) is all or nothing: a job Flow could not
|
|
293
|
+
// confirm stops the send. A bulk press sends the ones it confirmed. The
|
|
294
|
+
// rest stay for the next press (`waiting`), or on Pending when the read
|
|
295
|
+
// sent the job to review. An accounting read that cannot run still stops
|
|
296
|
+
// every ticked invoice: that one read covers the whole press. When nothing
|
|
297
|
+
// can go, the error names the cause and the count.
|
|
298
|
+
if (invoiceIds && waiting.length && go.length === 0) {
|
|
299
|
+
throw new FlowError('not_ready', stoppedSendMessage({
|
|
300
|
+
accountingWaiting,
|
|
301
|
+
accountingReason: accountingRead.reason,
|
|
302
|
+
stops,
|
|
303
|
+
waiting,
|
|
304
|
+
}));
|
|
244
305
|
}
|
|
245
306
|
const batch = go;
|
|
246
307
|
let issued = 0;
|
|
@@ -285,14 +346,65 @@ function unqueue(db, organisationId, invoiceId) {
|
|
|
285
346
|
.where(and(eq(invoices.organisationId, organisationId), eq(invoices.id, invoiceId), eq(invoices.status, 'draft')));
|
|
286
347
|
}
|
|
287
348
|
|
|
349
|
+
// The short refusal message for an error report. An email address is dropped,
|
|
350
|
+
// and so is a double-quoted name: Flow's own messages put a customer name in
|
|
351
|
+
// quotes. Single quotes stay, because the accounting system quotes an account
|
|
352
|
+
// code that way. The invoice's own problem, shown to a person, keeps the
|
|
353
|
+
// full message.
|
|
354
|
+
function refusalMessage(message) {
|
|
355
|
+
let text = String(message ?? '')
|
|
356
|
+
.replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, '')
|
|
357
|
+
.replace(/"[^"\n]{0,240}"/g, '')
|
|
358
|
+
.replace(/\s+/g, ' ')
|
|
359
|
+
.replace(/\s+([.,;:])/g, '$1')
|
|
360
|
+
.trim();
|
|
361
|
+
// Dropping the address and the quoted name can leave an empty "See for."
|
|
362
|
+
text = text.replace(/(?:^|\.\s*)See for\.$/i, (match) => (match.startsWith('.') ? '.' : '')).trim();
|
|
363
|
+
return text.slice(0, REFUSAL_MESSAGE_LIMIT);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// One refused invoice, as the error report may name it. Anything that is not
|
|
367
|
+
// an id, a number or a code is left out.
|
|
368
|
+
function refusalFact(row, error) {
|
|
369
|
+
const invoiceId = INVOICE_ID.test(row?.id ?? '') ? row.id : null;
|
|
370
|
+
if (!invoiceId) return null;
|
|
371
|
+
const number = INVOICE_NUMBER.test(row?.number ?? '') ? row.number : null;
|
|
372
|
+
const code = REFUSAL_CODE.test(error?.code ?? '') ? error.code : 'send_failed';
|
|
373
|
+
const message = refusalMessage(error?.message);
|
|
374
|
+
return { invoiceId, ...(number ? { number } : {}), code, ...(message ? { message } : {}) };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function formatRefusal(item) {
|
|
378
|
+
const fact = refusalFact(
|
|
379
|
+
{ id: item?.invoiceId, number: item?.number },
|
|
380
|
+
{ code: item?.code, message: item?.message },
|
|
381
|
+
);
|
|
382
|
+
if (!fact) return null;
|
|
383
|
+
const who = fact.number ? `${fact.number} (${fact.invoiceId})` : fact.invoiceId;
|
|
384
|
+
return fact.message ? `${who} ${fact.code}: ${fact.message}` : `${who} ${fact.code}`;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// The line the error service gets when the accounting system refuses invoices.
|
|
388
|
+
// `result` is the send pass summary (`failed`, `refusals`).
|
|
389
|
+
export function refusalReport(result) {
|
|
390
|
+
const refusals = Array.isArray(result?.refusals) ? result.refusals : [];
|
|
391
|
+
const count = Number.isInteger(result?.failed) && result.failed > 0 ? result.failed : refusals.length;
|
|
392
|
+
const shown = refusals.slice(0, REFUSAL_REPORT_CAP).map(formatRefusal).filter(Boolean);
|
|
393
|
+
const hidden = Math.max(0, refusals.length - REFUSAL_REPORT_CAP);
|
|
394
|
+
const detail = [...shown, ...(hidden ? [`and ${hidden} more`] : [])].join('; ');
|
|
395
|
+
const word = count === 1 ? 'invoice' : 'invoices';
|
|
396
|
+
return `[sending] the accounting system refused ${count} ${word}${detail ? `: ${detail}` : ''}`;
|
|
397
|
+
}
|
|
398
|
+
|
|
288
399
|
// One pass of the send worker: up to `limit` queued invoices, oldest first.
|
|
289
400
|
export async function sendQueued(db, organisationId, { accounting, limit = WORKER_BATCH }) {
|
|
290
|
-
const rows = await db.select({ id: invoices.id, by: invoices.sendRequestedBy }).from(invoices)
|
|
401
|
+
const rows = await db.select({ id: invoices.id, number: invoices.number, by: invoices.sendRequestedBy }).from(invoices)
|
|
291
402
|
.where(and(eq(invoices.organisationId, organisationId), eq(invoices.status, 'issued'), eq(invoices.sendState, 'queued')))
|
|
292
403
|
.orderBy(asc(invoices.sendUpdatedAt), asc(invoices.id))
|
|
293
404
|
.limit(limit);
|
|
294
405
|
const summary = { sent: 0, failed: 0, waiting: 0 };
|
|
295
406
|
for (const [by, group] of Map.groupBy(rows, (r) => r.by ?? null)) {
|
|
407
|
+
const known = new Map(group.map((row) => [row.id, row]));
|
|
296
408
|
const { invoices: results } = await accounting.capabilities.pushInvoices({
|
|
297
409
|
organisationId, invoiceIds: group.map((r) => r.id), pushedBy: by,
|
|
298
410
|
});
|
|
@@ -308,6 +420,8 @@ export async function sendQueued(db, organisationId, { accounting, limit = WORKE
|
|
|
308
420
|
await settle(db, organisationId, result.invoiceId, { sendState: 'failed', sendError: { code: error.code, message: error.message } });
|
|
309
421
|
summary.failed += 1;
|
|
310
422
|
summary.refused = [...new Set([...(summary.refused ?? []), error.code])];
|
|
423
|
+
const fact = refusalFact(known.get(result.invoiceId), error);
|
|
424
|
+
if (fact) summary.refusals = [...(summary.refusals ?? []), fact];
|
|
311
425
|
}
|
|
312
426
|
}
|
|
313
427
|
}
|
|
@@ -358,9 +472,10 @@ export function createSendWorker({ db, integrations, log = console, timers = { s
|
|
|
358
472
|
if (!ran) return;
|
|
359
473
|
const line = `[sending] ${ran.outcome}: ${JSON.stringify(ran.job.result ?? ran.job.lastError ?? {})}`;
|
|
360
474
|
// An invoice the accounting system refused waits in Problem for a
|
|
361
|
-
// person
|
|
475
|
+
// person. The error report names which invoices and why, once, when
|
|
476
|
+
// they are refused. The host log keeps the same pass on warn.
|
|
362
477
|
if (ran.outcome === 'dead') log.error?.('[sending] the send run gave up after its last attempt');
|
|
363
|
-
if (ran.job.result?.failed) log.error?.(
|
|
478
|
+
if (ran.job.result?.failed) log.error?.(refusalReport(ran.job.result));
|
|
364
479
|
(ran.outcome === 'succeeded' && !ran.job.result?.failed ? log.info : log.warn)?.(line);
|
|
365
480
|
} catch (error) {
|
|
366
481
|
log.error?.(`[sending] ${error?.code ?? 'error'}: ${error?.message ?? error}`);
|
|
@@ -382,5 +497,6 @@ export function createSendWorker({ db, integrations, log = console, timers = { s
|
|
|
382
497
|
timer = null;
|
|
383
498
|
await running;
|
|
384
499
|
},
|
|
500
|
+
tick,
|
|
385
501
|
};
|
|
386
502
|
}
|
package/server/db/doctor.js
CHANGED
|
@@ -82,7 +82,7 @@ export async function diagnoseDatabase(pool, { adapters = [] } = {}) {
|
|
|
82
82
|
.map((journal) => ({ journal: journal.id, applied: applied[journal.id], shipped: journal.tags.length, next: journal.tags.slice(applied[journal.id]) }));
|
|
83
83
|
|
|
84
84
|
const { rows } = await pool.query(
|
|
85
|
-
"select table_name, column_name from information_schema.columns where table_schema =
|
|
85
|
+
"select table_name, column_name from information_schema.columns where table_schema = current_schema()",
|
|
86
86
|
);
|
|
87
87
|
const actual = new Map();
|
|
88
88
|
for (const row of rows) {
|
package/server/db/index.js
CHANGED
|
@@ -13,10 +13,28 @@ const migrationsFolder = fileURLToPath(new URL('./migrations', import.meta.url))
|
|
|
13
13
|
export const MIGRATION_LOCK_KEY = 7_139_000_101n;
|
|
14
14
|
const DEFAULT_MIGRATION_LOCK_TIMEOUT_MS = 300_000;
|
|
15
15
|
|
|
16
|
+
// Flow's tables live in their own schema, not "public": Replit's publish
|
|
17
|
+
// compares the workspace's "public" with production's and offers to make
|
|
18
|
+
// production match, deletes included (migration 0021, docs/REPLIT.md). Every
|
|
19
|
+
// connection searches "flow" first, so queries keep plain table names; the
|
|
20
|
+
// database's own default says the same for a pooler's sessions.
|
|
21
|
+
export const FLOW_SCHEMA = 'flow';
|
|
22
|
+
export const FLOW_SEARCH_PATH = `${FLOW_SCHEMA}, public`;
|
|
23
|
+
const setSearchPath = (client) => client.query(`set search_path to ${FLOW_SEARCH_PATH}`);
|
|
24
|
+
|
|
16
25
|
// Opens Flow's PostgreSQL database. The caller owns the pool: call close().
|
|
17
26
|
export function openDatabase({ url = process.env.DATABASE_URL, max = 10 } = {}) {
|
|
18
27
|
if (!url) throw new Error('flow: DATABASE_URL is not set');
|
|
19
28
|
const pool = new pg.Pool({ connectionString: url, max });
|
|
29
|
+
// A client runs its queries in order, so this comes before any other.
|
|
30
|
+
pool.on('connect', (client) => {
|
|
31
|
+
setSearchPath(client).catch(() => {});
|
|
32
|
+
});
|
|
33
|
+
// The server can close an idle connection (a restart, a pooler, migration
|
|
34
|
+
// 0021); the pool drops it and opens another. Unheard, it would stop Flow.
|
|
35
|
+
pool.on('error', (error) => {
|
|
36
|
+
console.warn(`[db] an idle database connection closed: ${error.message}`);
|
|
37
|
+
});
|
|
20
38
|
const db = drizzle(pool, { schema });
|
|
21
39
|
return { db, pool, close: () => pool.end() };
|
|
22
40
|
}
|
|
@@ -56,6 +74,7 @@ export async function withMigrationLock(db, fn, { timeoutMs = migrationLockTimeo
|
|
|
56
74
|
if (direct) {
|
|
57
75
|
client = new pg.Client({ connectionString: direct });
|
|
58
76
|
await client.connect();
|
|
77
|
+
await setSearchPath(client);
|
|
59
78
|
} else {
|
|
60
79
|
client = source instanceof pg.Pool ? await source.connect() : source;
|
|
61
80
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
-- Flow's tables move from "public" to their own schema, "flow" (issue: Replit's
|
|
2
|
+
-- publish compares the workspace's "public" schema with production's and
|
|
3
|
+
-- offers to make production match, deletes included; docs/REPLIT.md, "The
|
|
4
|
+
-- workspace database"). Every connection searches "flow" first
|
|
5
|
+
-- (server/db/index.js, FLOW_SEARCH_PATH), so queries keep their table names.
|
|
6
|
+
-- The adapters' tables move too, when they exist; an adapter turned on later
|
|
7
|
+
-- creates its tables in "flow". It runs again without harm.
|
|
8
|
+
DO $flow_guard$
|
|
9
|
+
DECLARE
|
|
10
|
+
t text;
|
|
11
|
+
BEGIN
|
|
12
|
+
CREATE SCHEMA IF NOT EXISTS flow;
|
|
13
|
+
FOREACH t IN ARRAY ARRAY[
|
|
14
|
+
'alert_dismissals', 'automations', 'background_jobs', 'billing_obligations', 'contacts',
|
|
15
|
+
'customer_billing_settings', 'external_links', 'integration_setting_events', 'integration_settings',
|
|
16
|
+
'invoice_lines', 'invoice_rules', 'invoice_versions', 'invoices', 'invoicing_settings',
|
|
17
|
+
'job_field_events', 'job_field_settings', 'job_field_values', 'job_fields', 'line_mappings',
|
|
18
|
+
'link_rejections', 'link_suggestions', 'obligation_events', 'obligation_payments',
|
|
19
|
+
'obligation_writebacks', 'organisations', 'outbox', 'people', 'pipeline_cursors',
|
|
20
|
+
'price_item_links', 'price_items', 'prior_links', 'review_items',
|
|
21
|
+
'seazona_api_governor', 'seazona_api_requests', 'seazona_clients', 'seazona_history_batch_jobs',
|
|
22
|
+
'seazona_history_batches', 'seazona_history_jobs', 'seazona_history_rows', 'seazona_invoices',
|
|
23
|
+
'seazona_lane_state', 'seazona_order_read_failures', 'seazona_orders', 'seazona_person_settings',
|
|
24
|
+
'seazona_record_read_failures', 'seazona_reference', 'seazona_stage_moves', 'seazona_sweep_findings',
|
|
25
|
+
'xero_call_guard', 'xero_calls', 'xero_connections', 'xero_contacts', 'xero_credit_notes',
|
|
26
|
+
'xero_daily_checks', 'xero_import_settings', 'xero_invoice_links', 'xero_invoice_pushes',
|
|
27
|
+
'xero_invoices', 'xero_items', 'xero_mirror_cursors', 'xero_payment_cursors', 'xero_payments',
|
|
28
|
+
'xero_sync_runs', 'xero_webhook_events'
|
|
29
|
+
] LOOP
|
|
30
|
+
IF to_regclass(format('public.%I', t)) IS NOT NULL AND to_regclass(format('flow.%I', t)) IS NULL THEN
|
|
31
|
+
EXECUTE format('ALTER TABLE public.%I SET SCHEMA flow', t);
|
|
32
|
+
END IF;
|
|
33
|
+
END LOOP;
|
|
34
|
+
FOREACH t IN ARRAY ARRAY['flow_refuse_change', 'flow_invoice_lines_draft_only', 'flow_invoice_rules'] LOOP
|
|
35
|
+
IF to_regprocedure(format('public.%I()', t)) IS NOT NULL AND to_regprocedure(format('flow.%I()', t)) IS NULL THEN
|
|
36
|
+
EXECUTE format('ALTER FUNCTION public.%I() SET SCHEMA flow', t);
|
|
37
|
+
END IF;
|
|
38
|
+
-- The trigger functions name tables without a schema.
|
|
39
|
+
IF to_regprocedure(format('flow.%I()', t)) IS NOT NULL THEN
|
|
40
|
+
EXECUTE format('ALTER FUNCTION flow.%I() SET search_path = flow, public', t);
|
|
41
|
+
END IF;
|
|
42
|
+
END LOOP;
|
|
43
|
+
-- New sessions, a pooler's included, search "flow" first: a transaction
|
|
44
|
+
-- pooler does not keep a session's SET. Flow's role sets it for itself, and
|
|
45
|
+
-- the database too when Flow owns it; Flow's own connections set it anyway.
|
|
46
|
+
BEGIN
|
|
47
|
+
EXECUTE format('ALTER ROLE %I IN DATABASE %I SET search_path TO flow, public', current_user, current_database());
|
|
48
|
+
EXCEPTION WHEN insufficient_privilege THEN
|
|
49
|
+
RAISE NOTICE 'flow: this role cannot set its own search_path; Flow sets its own';
|
|
50
|
+
END;
|
|
51
|
+
BEGIN
|
|
52
|
+
EXECUTE format('ALTER DATABASE %I SET search_path TO flow, public', current_database());
|
|
53
|
+
EXCEPTION WHEN insufficient_privilege THEN
|
|
54
|
+
RAISE NOTICE 'flow: not the database owner, so its default search_path stays; Flow sets its own';
|
|
55
|
+
END;
|
|
56
|
+
-- A pooler keeps the server sessions it opened before this move, with the
|
|
57
|
+
-- old search_path, and hands them to new clients. Close the idle ones
|
|
58
|
+
-- of this role: the pooler opens new ones, which take the setting above.
|
|
59
|
+
BEGIN
|
|
60
|
+
PERFORM pg_terminate_backend(a.pid) FROM pg_stat_activity a
|
|
61
|
+
WHERE a.datname = current_database() AND a.usename = current_user
|
|
62
|
+
AND a.pid <> pg_backend_pid() AND a.state = 'idle' AND a.backend_type = 'client backend';
|
|
63
|
+
EXCEPTION WHEN others THEN
|
|
64
|
+
RAISE NOTICE 'flow: could not close the idle sessions; a pooler''s open sessions keep the old search_path until they close';
|
|
65
|
+
END;
|
|
66
|
+
-- This session too: the migrations after this one create their tables in "flow".
|
|
67
|
+
PERFORM set_config('search_path', 'flow, public', false);
|
|
68
|
+
END $flow_guard$;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
ALTER TABLE "contacts" ADD COLUMN IF NOT EXISTS "first_name" text;--> statement-breakpoint
|
|
2
|
+
ALTER TABLE "contacts" ADD COLUMN IF NOT EXISTS "last_name" text;--> statement-breakpoint
|
|
3
|
+
ALTER TABLE "contacts" ADD COLUMN IF NOT EXISTS "phone" text;--> statement-breakpoint
|
|
4
|
+
ALTER TABLE "contacts" ADD COLUMN IF NOT EXISTS "mobile" text;--> statement-breakpoint
|
|
5
|
+
ALTER TABLE "contacts" ADD COLUMN IF NOT EXISTS "account_number" text;--> statement-breakpoint
|
|
6
|
+
ALTER TABLE "contacts" ADD COLUMN IF NOT EXISTS "tax_number" text;--> statement-breakpoint
|
|
7
|
+
ALTER TABLE "contacts" ADD COLUMN IF NOT EXISTS "tax_rate" text;--> statement-breakpoint
|
|
8
|
+
ALTER TABLE "contacts" ADD COLUMN IF NOT EXISTS "address" jsonb;--> statement-breakpoint
|
|
9
|
+
ALTER TABLE "contacts" ADD COLUMN IF NOT EXISTS "practitioners" jsonb DEFAULT '[]'::jsonb NOT NULL;--> statement-breakpoint
|
|
10
|
+
ALTER TABLE "contacts" ADD COLUMN IF NOT EXISTS "active" boolean DEFAULT true NOT NULL;--> statement-breakpoint
|
|
11
|
+
ALTER TABLE "contacts" ADD COLUMN IF NOT EXISTS "updated_at" timestamp with time zone DEFAULT now() NOT NULL;
|