@arcforgelabs/flow 2026.10.47 → 2026.10.48-1

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.
Files changed (39) hide show
  1. package/README.md +1 -0
  2. package/npm-shrinkwrap.json +11 -11
  3. package/package.json +1 -1
  4. package/server/api/invoicing.js +35 -8
  5. package/server/core/contacts.js +160 -0
  6. package/server/core/directory.js +4 -9
  7. package/server/core/invoices.js +84 -31
  8. package/server/core/sending.js +65 -3
  9. package/server/db/doctor.js +1 -1
  10. package/server/db/index.js +19 -0
  11. package/server/db/migrations/0021_flow_schema.sql +68 -0
  12. package/server/db/migrations/0022_contacts.sql +11 -0
  13. package/server/db/migrations/meta/0021_snapshot.json +4377 -0
  14. package/server/db/migrations/meta/0022_snapshot.json +4446 -0
  15. package/server/db/migrations/meta/_journal.json +14 -0
  16. package/server/db/schema.js +15 -0
  17. package/server/integrations/port.js +2 -0
  18. package/server/integrations/schema.js +3 -2
  19. package/server/integrations/seazona/sweep.js +11 -8
  20. package/server/integrations/setup.js +1 -0
  21. package/server/integrations/xero/catalogue.js +82 -14
  22. package/server/integrations/xero/client.js +37 -0
  23. package/server/integrations/xero/create-contact.js +126 -0
  24. package/server/integrations/xero/delete-invoice.js +104 -0
  25. package/server/integrations/xero/drafts.js +19 -8
  26. package/server/integrations/xero/index.js +9 -0
  27. package/server/integrations/xero/migrations/0011_xero_invoice_deleted.sql +11 -0
  28. package/server/integrations/xero/migrations/0012_xero_contact_details.sql +16 -0
  29. package/server/integrations/xero/migrations/meta/_journal.json +14 -0
  30. package/server/integrations/xero/routes.js +21 -0
  31. package/server/integrations/xero/settings.js +3 -0
  32. package/server/ui/app.js +8 -3
  33. package/server/ui/contacts.js +260 -0
  34. package/server/ui/flow.css +40 -0
  35. package/server/ui/invoicing.js +159 -54
  36. package/server/ui/kit.js +28 -8
  37. package/server/ui/mobile.js +6 -3
  38. package/server/ui/nav-pins.js +5 -2
  39. 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 |
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.47",
3
+ "version": "2026.10.48-1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@arcforgelabs/flow",
9
- "version": "2026.10.47",
9
+ "version": "2026.10.48-1",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@clerk/backend": "3.25.0",
@@ -1254,18 +1254,18 @@
1254
1254
  "license": "MIT"
1255
1255
  },
1256
1256
  "node_modules/body-parser": {
1257
- "version": "2.3.0",
1258
- "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
1259
- "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
1257
+ "version": "2.4.0",
1258
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz",
1259
+ "integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==",
1260
1260
  "license": "MIT",
1261
1261
  "dependencies": {
1262
1262
  "bytes": "^3.1.2",
1263
- "content-type": "^2.0.0",
1263
+ "content-type": "^2.1.0",
1264
1264
  "debug": "^4.4.3",
1265
1265
  "http-errors": "^2.0.1",
1266
- "iconv-lite": "^0.7.2",
1266
+ "iconv-lite": "^0.7.3",
1267
1267
  "on-finished": "^2.4.1",
1268
- "qs": "^6.15.2",
1268
+ "qs": "^6.16.1",
1269
1269
  "raw-body": "^3.0.2",
1270
1270
  "type-is": "^2.1.0"
1271
1271
  },
@@ -2031,9 +2031,9 @@
2031
2031
  "license": "ISC"
2032
2032
  },
2033
2033
  "node_modules/ip-address": {
2034
- "version": "10.7.3",
2035
- "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.3.tgz",
2036
- "integrity": "sha512-A1kdq/tSb5QjvKvAMgIoEvDBIgL7qaqVP/jkvSwYYRZ9iEzvPpopxp2wQfu3SuZRHtpHNxMn8Fs0bS+gf5Xmwg==",
2034
+ "version": "10.7.5",
2035
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.5.tgz",
2036
+ "integrity": "sha512-t2v0rkILYv9WXmSL7TpAaU3HXgaFYOPUcH3QHgwQ++gpJuH3bqyMy4Fvt/DJCTKwzH3Cf+bAp5W/mFL39KPLzA==",
2037
2037
  "license": "MIT",
2038
2038
  "engines": {
2039
2039
  "node": ">= 12"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.47",
3
+ "version": "2026.10.48-1",
4
4
  "description": "Invoicing, bill entry and a financial dashboard for labs, with Seazona and Xero integrations.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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 q = text(req.query.q);
664
- const where = [eq(contacts.organisationId, organisationId), eq(contacts.kind, 'customer')];
665
- if (q) where.push(ilike(contacts.name, containing(q)));
666
- const rows = await db.select({ id: contacts.id, name: contacts.name, email: contacts.email }).from(contacts)
667
- .where(and(...where)).orderBy(asc(contacts.name), asc(contacts.id)).limit(limitOf(req.query.limit, 50, 500));
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
+ }
@@ -1,5 +1,6 @@
1
1
  import { and, eq, isNull } from 'drizzle-orm';
2
- import { contacts, organisations, priceItems, CONTACT_KINDS, TAX_CODES } from '../db/schema.js';
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
- export async function createContact(db, organisationId, { kind, name, email = null }) {
17
- if (!CONTACT_KINDS.includes(kind)) throw new FlowError('invalid', `unknown contact kind: ${kind}`);
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');
@@ -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
- // Removes a draft that was never issued. The job it billed is free again:
187
- // its obligation no longer points at the draft, so the lab's rules decide
188
- // whether Flow drafts it once more. An invoice that was issued is voided,
189
- // not deleted (the database holds the same rule).
190
- export async function deleteDraft(db, organisationId, invoiceId, { actor = null } = {}) {
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 !== 'draft' || invoice.issuedVersion !== 0) {
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 linked = await tx.select().from(billingObligations)
197
- .where(and(eq(billingObligations.organisationId, organisationId), eq(billingObligations.invoiceId, invoice.id)))
198
- .for('update');
199
- if (linked.length && !actor) throw new FlowError('invalid', 'actor is required');
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
 
@@ -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; it is reported once, when it is refused.
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?.('[sending] the accounting system refused an invoice');
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
  }
@@ -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 = 'public'",
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) {
@@ -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
  }