@arcforgelabs/flow 2026.10.47 → 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/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/api/invoicing.js +35 -8
- package/server/core/contacts.js +160 -0
- package/server/core/directory.js +4 -9
- package/server/core/invoices.js +84 -31
- package/server/core/sending.js +65 -3
- 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/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/app.js +8 -3
- package/server/ui/contacts.js +260 -0
- package/server/ui/flow.css +40 -0
- package/server/ui/invoicing.js +159 -54
- package/server/ui/kit.js +28 -8
- package/server/ui/mobile.js +6 -3
- package/server/ui/nav-pins.js +5 -2
- package/server/ui/routes.js +1 -1
package/README.md
CHANGED
|
@@ -43,6 +43,7 @@ run a lab.
|
|
|
43
43
|
| `docs/INVOICING_RULES.md` | The invoicing rules that draft a job's invoice, and the two ways an invoice goes to Xero |
|
|
44
44
|
| `docs/INVOICE_RULES.md` | Invoice rules a lab builds (When, If, Then) that make a ready job wait or never draft |
|
|
45
45
|
| `docs/INVOICE_SENDING.md` | The invoice tabs (Pending, Ready, Draft, Approved, Sent, Void), a changed job, and Send all ready to the accounting system |
|
|
46
|
+
| `docs/CONTACTS.md` | Contacts: Flow's own customers and suppliers, and creating a missing Xero client from one |
|
|
46
47
|
| `docs/RECONCILE.md` | The daily reconcile: counts and totals per day across the work system's copy, the ledger and the accounting copy |
|
|
47
48
|
| `docs/ALERTS.md` | Quiet alerts: the slim line on a page when something there needs a person, and how a person dismisses it |
|
|
48
49
|
| `docs/AUTOMATIONS.md` | Automations: Flow routines that mark jobs ready to invoice and draft them, each on or off |
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcforgelabs/flow",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.48",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@arcforgelabs/flow",
|
|
9
|
-
"version": "2026.10.
|
|
9
|
+
"version": "2026.10.48",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@clerk/backend": "3.25.0",
|
package/package.json
CHANGED
package/server/api/invoicing.js
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
import { isHistoryHeld } from '../core/history.js';
|
|
34
34
|
import { invoicePdfDocument, pdfFields, pdfFilename, renderInvoicePdf } from '../core/invoice-pdf.js';
|
|
35
35
|
import { invoiceView, invoiceViews, markNotReady, markReady, sendAgain, sendAllReady } from '../core/sending.js';
|
|
36
|
+
import { createContact, getContact, listContacts, presentContact, updateContact } from '../core/contacts.js';
|
|
36
37
|
import {
|
|
37
38
|
billingObligations, contacts, invoices, INVOICE_STATUSES, organisations, priceItems, TAX_CODES,
|
|
38
39
|
} from '../db/schema.js';
|
|
@@ -131,7 +132,8 @@ const MAX_UNIT_CENTS = 100_000_000;
|
|
|
131
132
|
const ERROR_STATUS = {
|
|
132
133
|
invalid: 400, organisation_required: 400, not_found: 404, not_draft: 409, not_issued: 409,
|
|
133
134
|
never_issued: 409, was_issued: 409, not_ready: 409, live_check_unavailable: 409, conflict: 409,
|
|
134
|
-
accounting_not_connected: 409, accounting_writes_off: 409,
|
|
135
|
+
accounting_not_connected: 409, accounting_writes_off: 409, accounting_unavailable: 409, accounting_approved: 409,
|
|
136
|
+
conflict: 409,
|
|
135
137
|
};
|
|
136
138
|
|
|
137
139
|
function sendError(res, error, log) {
|
|
@@ -653,21 +655,46 @@ export function createInvoicingRouter({
|
|
|
653
655
|
router.delete('/invoices/:id', ...manager, handle(log, async (req, res) => {
|
|
654
656
|
const invoiceId = pathId(req.params.id, 'invoice');
|
|
655
657
|
const organisationId = await organisationOf(req);
|
|
656
|
-
await deleteDraft(db, organisationId, invoiceId, { actor: `person:${req.person.id}
|
|
657
|
-
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} deleted draft invoice ${invoiceId}`);
|
|
658
|
+
const removed = await deleteDraft(db, organisationId, invoiceId, { actor: `person:${req.person.id}`, accounting: accounting() });
|
|
659
|
+
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} ${removed.status === 'void' ? 'voided invoice' : 'deleted draft invoice'} ${invoiceId}`);
|
|
658
660
|
res.json({ ok: true });
|
|
659
661
|
}));
|
|
660
662
|
|
|
661
663
|
router.get('/contacts', ...signedIn, handle(log, async (req, res) => {
|
|
662
664
|
const organisationId = await organisationOf(req);
|
|
663
|
-
const
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
665
|
+
const kind = text(req.query.kind);
|
|
666
|
+
if (kind && !['customer', 'supplier', 'all'].includes(kind)) throw invalid('kind must be customer, supplier or all');
|
|
667
|
+
const active = text(req.query.active);
|
|
668
|
+
if (active && !['true', 'false', 'all'].includes(active)) throw invalid('active must be true, false or all');
|
|
669
|
+
const rows = await listContacts(db, organisationId, {
|
|
670
|
+
q: text(req.query.q),
|
|
671
|
+
kind: kind === 'all' ? null : (kind || 'customer'),
|
|
672
|
+
active: active === 'all' ? null : active === 'false' ? false : true,
|
|
673
|
+
limit: limitOf(req.query.limit, 50, 500),
|
|
674
|
+
offset: offsetOf(req.query.offset),
|
|
675
|
+
});
|
|
668
676
|
res.json({ ok: true, contacts: rows });
|
|
669
677
|
}));
|
|
670
678
|
|
|
679
|
+
router.post('/contacts', ...manager, handle(log, async (req, res) => {
|
|
680
|
+
const organisationId = await organisationOf(req);
|
|
681
|
+
const contact = presentContact(await createContact(db, organisationId, req.body ?? {}));
|
|
682
|
+
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} created contact ${contact.id}`);
|
|
683
|
+
res.status(201).json({ ok: true, contact });
|
|
684
|
+
}));
|
|
685
|
+
|
|
686
|
+
router.get('/contacts/:id', ...signedIn, handle(log, async (req, res) => {
|
|
687
|
+
const organisationId = await organisationOf(req);
|
|
688
|
+
res.json({ ok: true, contact: presentContact(await getContact(db, organisationId, pathId(req.params.id, 'contact'))) });
|
|
689
|
+
}));
|
|
690
|
+
|
|
691
|
+
router.patch('/contacts/:id', ...manager, handle(log, async (req, res) => {
|
|
692
|
+
const organisationId = await organisationOf(req);
|
|
693
|
+
const contact = presentContact(await updateContact(db, organisationId, pathId(req.params.id, 'contact'), req.body ?? {}));
|
|
694
|
+
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} updated contact ${contact.id}`);
|
|
695
|
+
res.json({ ok: true, contact });
|
|
696
|
+
}));
|
|
697
|
+
|
|
671
698
|
router.get('/price-items', ...signedIn, handle(log, async (req, res) => {
|
|
672
699
|
const organisationId = await organisationOf(req);
|
|
673
700
|
const q = text(req.query.q);
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { and, asc, eq, ilike, or, sql } from 'drizzle-orm';
|
|
2
|
+
import { contacts, CONTACT_KINDS } from '../db/schema.js';
|
|
3
|
+
import { FlowError, notFound } from './errors.js';
|
|
4
|
+
|
|
5
|
+
// Flow's own contacts (docs/CONTACTS.md). A contact is a customer or a
|
|
6
|
+
// supplier the lab bills or buys from. The fields are what Xero needs to
|
|
7
|
+
// bill them and what Seazona keeps for the practice a job belongs to.
|
|
8
|
+
// Practitioners are people at that practice. Flow stores no patient.
|
|
9
|
+
|
|
10
|
+
const NAME_MAX = 255;
|
|
11
|
+
const TEXT_MAX = 255;
|
|
12
|
+
const SHORT_MAX = 50;
|
|
13
|
+
const PRACTITIONERS_MAX = 30;
|
|
14
|
+
const ADDRESS_KEYS = ['line1', 'line2', 'city', 'region', 'postalCode', 'country'];
|
|
15
|
+
|
|
16
|
+
const invalid = (message) => new FlowError('invalid', message);
|
|
17
|
+
|
|
18
|
+
function text(value, name, max) {
|
|
19
|
+
if (value == null || value === '') return null;
|
|
20
|
+
if (typeof value !== 'string') throw invalid(`${name} must be text`);
|
|
21
|
+
const trimmed = value.trim();
|
|
22
|
+
if (!trimmed) return null;
|
|
23
|
+
if (trimmed.length > max) throw invalid(`${name} is too long (${max} characters at most)`);
|
|
24
|
+
return trimmed;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function readAddress(raw) {
|
|
28
|
+
if (raw == null) return null;
|
|
29
|
+
if (typeof raw !== 'object' || Array.isArray(raw)) throw invalid('address must be an object');
|
|
30
|
+
const out = {};
|
|
31
|
+
for (const key of ADDRESS_KEYS) {
|
|
32
|
+
const value = text(raw[key], `address.${key}`, TEXT_MAX);
|
|
33
|
+
if (value) out[key] = value;
|
|
34
|
+
}
|
|
35
|
+
return Object.keys(out).length ? out : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function readPractitioners(raw) {
|
|
39
|
+
if (raw == null) return [];
|
|
40
|
+
if (!Array.isArray(raw)) throw invalid('practitioners must be a list');
|
|
41
|
+
if (raw.length > PRACTITIONERS_MAX) throw invalid(`a contact has ${PRACTITIONERS_MAX} practitioners at most`);
|
|
42
|
+
return raw.map((item, index) => {
|
|
43
|
+
const at = `practitioner ${index + 1}`;
|
|
44
|
+
const source = typeof item === 'string' ? { name: item } : item;
|
|
45
|
+
if (!source || typeof source !== 'object' || Array.isArray(source)) throw invalid(`${at} must be a name`);
|
|
46
|
+
const name = text(source.name, `${at} name`, NAME_MAX);
|
|
47
|
+
if (!name) throw invalid(`${at} needs a name`);
|
|
48
|
+
const email = text(source.email, `${at} email`, TEXT_MAX);
|
|
49
|
+
return email ? { name, email } : { name };
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// The fields a create or an update may set. `partial` is an update: only the
|
|
54
|
+
// keys the caller sent are returned.
|
|
55
|
+
export function readContact(input, { partial = false } = {}) {
|
|
56
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) throw invalid('contact must be an object');
|
|
57
|
+
const out = {};
|
|
58
|
+
const has = (key) => Object.prototype.hasOwnProperty.call(input, key);
|
|
59
|
+
if (!partial || has('kind')) {
|
|
60
|
+
if (!CONTACT_KINDS.includes(input.kind)) throw invalid(`kind must be ${CONTACT_KINDS.join(' or ')}`);
|
|
61
|
+
out.kind = input.kind;
|
|
62
|
+
}
|
|
63
|
+
if (!partial || has('name')) {
|
|
64
|
+
const name = text(input.name, 'name', NAME_MAX);
|
|
65
|
+
if (!name) throw invalid('contact name is required');
|
|
66
|
+
out.name = name;
|
|
67
|
+
}
|
|
68
|
+
if (!partial || has('firstName')) out.firstName = text(input.firstName, 'firstName', NAME_MAX);
|
|
69
|
+
if (!partial || has('lastName')) out.lastName = text(input.lastName, 'lastName', NAME_MAX);
|
|
70
|
+
if (!partial || has('email')) out.email = text(input.email, 'email', TEXT_MAX);
|
|
71
|
+
if (!partial || has('phone')) out.phone = text(input.phone, 'phone', SHORT_MAX);
|
|
72
|
+
if (!partial || has('mobile')) out.mobile = text(input.mobile, 'mobile', SHORT_MAX);
|
|
73
|
+
if (!partial || has('accountNumber')) out.accountNumber = text(input.accountNumber, 'accountNumber', SHORT_MAX);
|
|
74
|
+
if (!partial || has('taxNumber')) out.taxNumber = text(input.taxNumber, 'taxNumber', SHORT_MAX);
|
|
75
|
+
if (!partial || has('taxRate')) out.taxRate = text(input.taxRate, 'taxRate', SHORT_MAX);
|
|
76
|
+
if (!partial || has('address')) out.address = readAddress(input.address);
|
|
77
|
+
if (!partial || has('practitioners')) out.practitioners = readPractitioners(input.practitioners);
|
|
78
|
+
if (!partial || has('active')) {
|
|
79
|
+
if (input.active != null && typeof input.active !== 'boolean') throw invalid('active must be true or false');
|
|
80
|
+
out.active = input.active !== false;
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function duplicate(error, kind) {
|
|
86
|
+
const code = error?.code ?? error?.cause?.code;
|
|
87
|
+
if (code === '23505') throw new FlowError('conflict', `A ${kind ?? 'contact'} with this name already exists.`);
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function presentContact(row) {
|
|
92
|
+
return {
|
|
93
|
+
id: row.id,
|
|
94
|
+
kind: row.kind,
|
|
95
|
+
name: row.name,
|
|
96
|
+
firstName: row.firstName ?? null,
|
|
97
|
+
lastName: row.lastName ?? null,
|
|
98
|
+
email: row.email ?? null,
|
|
99
|
+
phone: row.phone ?? null,
|
|
100
|
+
mobile: row.mobile ?? null,
|
|
101
|
+
accountNumber: row.accountNumber ?? null,
|
|
102
|
+
taxNumber: row.taxNumber ?? null,
|
|
103
|
+
taxRate: row.taxRate ?? null,
|
|
104
|
+
address: row.address ?? null,
|
|
105
|
+
practitioners: row.practitioners ?? [],
|
|
106
|
+
active: row.active !== false,
|
|
107
|
+
createdAt: row.createdAt,
|
|
108
|
+
updatedAt: row.updatedAt,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function createContact(db, organisationId, input) {
|
|
113
|
+
const values = readContact(input);
|
|
114
|
+
try {
|
|
115
|
+
const [row] = await db.insert(contacts).values({ organisationId, ...values }).returning();
|
|
116
|
+
return row;
|
|
117
|
+
} catch (error) {
|
|
118
|
+
duplicate(error, values.kind);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function getContact(db, organisationId, id) {
|
|
123
|
+
const [row] = await db.select().from(contacts)
|
|
124
|
+
.where(and(eq(contacts.organisationId, organisationId), eq(contacts.id, id)));
|
|
125
|
+
if (!row) throw notFound('contact');
|
|
126
|
+
return row;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function updateContact(db, organisationId, id, input) {
|
|
130
|
+
const current = await getContact(db, organisationId, id);
|
|
131
|
+
const patch = readContact(input, { partial: true });
|
|
132
|
+
if (!Object.keys(patch).length) return current;
|
|
133
|
+
try {
|
|
134
|
+
const [row] = await db.update(contacts)
|
|
135
|
+
.set({ ...patch, updatedAt: sql`now()` })
|
|
136
|
+
.where(and(eq(contacts.organisationId, organisationId), eq(contacts.id, id)))
|
|
137
|
+
.returning();
|
|
138
|
+
return row;
|
|
139
|
+
} catch (error) {
|
|
140
|
+
duplicate(error, patch.kind ?? current.kind);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// `kind` null lists customers and suppliers. `active` null lists both.
|
|
145
|
+
export async function listContacts(db, organisationId, { q = '', kind = 'customer', active = true, limit = 200, offset = 0 } = {}) {
|
|
146
|
+
const where = [eq(contacts.organisationId, organisationId)];
|
|
147
|
+
if (kind) where.push(eq(contacts.kind, kind));
|
|
148
|
+
if (active === true) where.push(eq(contacts.active, true));
|
|
149
|
+
if (active === false) where.push(eq(contacts.active, false));
|
|
150
|
+
const needle = String(q ?? '').trim().replace(/[\\%_]/g, (c) => `\\${c}`);
|
|
151
|
+
if (needle) {
|
|
152
|
+
const like = `%${needle}%`;
|
|
153
|
+
where.push(or(ilike(contacts.name, like), ilike(contacts.email, like), ilike(contacts.accountNumber, like)));
|
|
154
|
+
}
|
|
155
|
+
const rows = await db.select().from(contacts).where(and(...where))
|
|
156
|
+
.orderBy(asc(contacts.name), asc(contacts.id))
|
|
157
|
+
.limit(limit)
|
|
158
|
+
.offset(offset);
|
|
159
|
+
return rows.map(presentContact);
|
|
160
|
+
}
|
package/server/core/directory.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { and, eq, isNull } from 'drizzle-orm';
|
|
2
|
-
import {
|
|
2
|
+
import { organisations, priceItems, TAX_CODES } from '../db/schema.js';
|
|
3
|
+
import { createContact as insertContact } from './contacts.js';
|
|
3
4
|
import { FlowError } from './errors.js';
|
|
4
5
|
|
|
5
6
|
// Organisations, contacts and the price list: the records invoices and bills
|
|
@@ -13,14 +14,8 @@ export async function createOrganisation(db, { name, invoicePrefix }) {
|
|
|
13
14
|
return row;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
if (!name?.trim()) throw new FlowError('invalid', 'contact name is required');
|
|
19
|
-
const [row] = await db.insert(contacts)
|
|
20
|
-
.values({ organisationId, kind, name: name.trim(), email })
|
|
21
|
-
.returning();
|
|
22
|
-
return row;
|
|
23
|
-
}
|
|
17
|
+
// Contacts live in contacts.js. This name stays so existing callers keep working.
|
|
18
|
+
export const createContact = insertContact;
|
|
24
19
|
|
|
25
20
|
export async function createPriceItem(db, organisationId, { code, name, unitAmountCents, taxCode = 'GST' }) {
|
|
26
21
|
if (!code?.trim() || !name?.trim()) throw new FlowError('invalid', 'price item code and name are required');
|
package/server/core/invoices.js
CHANGED
|
@@ -8,6 +8,10 @@ import { invoiceTotals, lineTotals } from './money.js';
|
|
|
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
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.
|
|
11
15
|
|
|
12
16
|
async function loadInvoice(tx, organisationId, invoiceId, { lock = false } = {}) {
|
|
13
17
|
let query = tx.select().from(invoices)
|
|
@@ -183,41 +187,90 @@ export async function voidInvoice(db, organisationId, invoiceId) {
|
|
|
183
187
|
});
|
|
184
188
|
}
|
|
185
189
|
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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);
|
|
191
264
|
return db.transaction(async (tx) => {
|
|
192
265
|
const invoice = await loadInvoice(tx, organisationId, invoiceId, { lock: true });
|
|
193
|
-
if (invoice.status
|
|
266
|
+
if (invoice.status === 'void') return invoice;
|
|
267
|
+
if (invoice.issuedVersion === 0) {
|
|
194
268
|
throw new FlowError('was_issued', 'an invoice that was issued cannot be deleted; void it');
|
|
195
269
|
}
|
|
196
|
-
const
|
|
197
|
-
.where(
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
for (const obligation of linked) {
|
|
201
|
-
await tx.update(billingObligations)
|
|
202
|
-
.set({ invoiceId: null, draftBasis: null, updatedAt: sql`now()` })
|
|
203
|
-
.where(eq(billingObligations.id, obligation.id));
|
|
204
|
-
await tx.insert(obligationEvents).values({
|
|
205
|
-
createdAt: sql`clock_timestamp()`,
|
|
206
|
-
organisationId,
|
|
207
|
-
obligationId: obligation.id,
|
|
208
|
-
kind: 'draft_deleted',
|
|
209
|
-
actor,
|
|
210
|
-
evidence: { invoiceId: invoice.id },
|
|
211
|
-
});
|
|
212
|
-
}
|
|
213
|
-
try {
|
|
214
|
-
await tx.delete(invoices).where(eq(invoices.id, invoice.id));
|
|
215
|
-
} catch (error) {
|
|
216
|
-
const code = error?.code ?? error?.cause?.code;
|
|
217
|
-
if (code === '23503') throw new FlowError('conflict', 'this draft is still linked; remove the link, then delete it');
|
|
218
|
-
throw error;
|
|
219
|
-
}
|
|
220
|
-
return invoice;
|
|
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;
|
|
221
274
|
});
|
|
222
275
|
}
|
|
223
276
|
|
package/server/core/sending.js
CHANGED
|
@@ -37,6 +37,13 @@ export const CHECK_LIMIT = 500;
|
|
|
37
37
|
const WORKER_BATCH = 25;
|
|
38
38
|
const WORKER_EVERY_MS = 30 * 1000;
|
|
39
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;
|
|
40
47
|
// Outcomes that mean the accounting system holds the invoice.
|
|
41
48
|
const SENT = new Set(['created', 'reconciled', 'existing', 'already_in_xero']);
|
|
42
49
|
|
|
@@ -339,14 +346,65 @@ function unqueue(db, organisationId, invoiceId) {
|
|
|
339
346
|
.where(and(eq(invoices.organisationId, organisationId), eq(invoices.id, invoiceId), eq(invoices.status, 'draft')));
|
|
340
347
|
}
|
|
341
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
|
+
|
|
342
399
|
// One pass of the send worker: up to `limit` queued invoices, oldest first.
|
|
343
400
|
export async function sendQueued(db, organisationId, { accounting, limit = WORKER_BATCH }) {
|
|
344
|
-
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)
|
|
345
402
|
.where(and(eq(invoices.organisationId, organisationId), eq(invoices.status, 'issued'), eq(invoices.sendState, 'queued')))
|
|
346
403
|
.orderBy(asc(invoices.sendUpdatedAt), asc(invoices.id))
|
|
347
404
|
.limit(limit);
|
|
348
405
|
const summary = { sent: 0, failed: 0, waiting: 0 };
|
|
349
406
|
for (const [by, group] of Map.groupBy(rows, (r) => r.by ?? null)) {
|
|
407
|
+
const known = new Map(group.map((row) => [row.id, row]));
|
|
350
408
|
const { invoices: results } = await accounting.capabilities.pushInvoices({
|
|
351
409
|
organisationId, invoiceIds: group.map((r) => r.id), pushedBy: by,
|
|
352
410
|
});
|
|
@@ -362,6 +420,8 @@ export async function sendQueued(db, organisationId, { accounting, limit = WORKE
|
|
|
362
420
|
await settle(db, organisationId, result.invoiceId, { sendState: 'failed', sendError: { code: error.code, message: error.message } });
|
|
363
421
|
summary.failed += 1;
|
|
364
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];
|
|
365
425
|
}
|
|
366
426
|
}
|
|
367
427
|
}
|
|
@@ -412,9 +472,10 @@ export function createSendWorker({ db, integrations, log = console, timers = { s
|
|
|
412
472
|
if (!ran) return;
|
|
413
473
|
const line = `[sending] ${ran.outcome}: ${JSON.stringify(ran.job.result ?? ran.job.lastError ?? {})}`;
|
|
414
474
|
// An invoice the accounting system refused waits in Problem for a
|
|
415
|
-
// 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.
|
|
416
477
|
if (ran.outcome === 'dead') log.error?.('[sending] the send run gave up after its last attempt');
|
|
417
|
-
if (ran.job.result?.failed) log.error?.(
|
|
478
|
+
if (ran.job.result?.failed) log.error?.(refusalReport(ran.job.result));
|
|
418
479
|
(ran.outcome === 'succeeded' && !ran.job.result?.failed ? log.info : log.warn)?.(line);
|
|
419
480
|
} catch (error) {
|
|
420
481
|
log.error?.(`[sending] ${error?.code ?? 'error'}: ${error?.message ?? error}`);
|
|
@@ -436,5 +497,6 @@ export function createSendWorker({ db, integrations, log = console, timers = { s
|
|
|
436
497
|
timer = null;
|
|
437
498
|
await running;
|
|
438
499
|
},
|
|
500
|
+
tick,
|
|
439
501
|
};
|
|
440
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$;
|