@arcforgelabs/flow 2026.10.26 → 2026.10.27
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 +5 -0
- package/npm-shrinkwrap.json +627 -2
- package/package.json +2 -1
- package/server/api/invoicing.js +231 -20
- package/server/app.js +28 -2
- package/server/core/automations.js +140 -0
- package/server/core/invoice-rules.js +224 -0
- package/server/core/invoices.js +4 -1
- package/server/core/invoicing-rules.js +255 -0
- package/server/core/ledger.js +45 -12
- package/server/core/monitoring.js +34 -0
- package/server/core/pipeline.js +8 -1
- package/server/core/sending.js +282 -0
- package/server/db/migrations/0010_invoicing_rules.sql +51 -0
- package/server/db/migrations/0011_invoice_rules.sql +21 -0
- package/server/db/migrations/0012_invoice_sending.sql +9 -0
- package/server/db/migrations/meta/0010_snapshot.json +3158 -0
- package/server/db/migrations/meta/0011_snapshot.json +3314 -0
- package/server/db/migrations/meta/0012_snapshot.json +3380 -0
- package/server/db/migrations/meta/_journal.json +21 -0
- package/server/db/schema.js +115 -0
- package/server/index.js +12 -4
- package/server/integrations/adapters.js +2 -1
- package/server/integrations/port.js +5 -0
- package/server/integrations/seazona/ledger.js +2 -0
- package/server/integrations/sentry/index.js +105 -0
- package/server/integrations/xero/client.js +5 -0
- package/server/integrations/xero/drafts.js +123 -16
- package/server/integrations/xero/index.js +11 -1
- package/server/integrations/xero/migrations/0005_xero_send_modes.sql +10 -0
- package/server/integrations/xero/migrations/meta/_journal.json +7 -0
- package/server/ui/app-page.js +2 -1
- package/server/ui/app.js +14 -1
- package/server/ui/automations.js +96 -0
- package/server/ui/flow.css +88 -0
- package/server/ui/invoice-rules.js +237 -0
- package/server/ui/invoicing-settings.js +108 -0
- package/server/ui/invoicing.js +166 -20
- package/server/ui/jobs.js +19 -0
- package/server/ui/logos/sentry.svg +1 -0
- package/server/ui/monitoring.js +53 -0
- package/server/ui/routes.js +2 -2
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// The logger every part of Flow writes to. It passes each line to the host's
|
|
2
|
+
// own log first, unchanged, then sends each error to the error services that
|
|
3
|
+
// are on (the `captureError` capability, docs/SENTRY.md). With none on, it is
|
|
4
|
+
// the host's log. A line's leading "[tag]" is its source.
|
|
5
|
+
//
|
|
6
|
+
// `reporters()` answers the adapters to send to; it is read at each error,
|
|
7
|
+
// so an integration turned on or off later is followed. A failure to send is
|
|
8
|
+
// logged as a warning at most once a minute, never sent, so it cannot loop.
|
|
9
|
+
export function createMonitoredLog({ log = console, reporters = () => [], now = Date.now }) {
|
|
10
|
+
let lastFailure = -Infinity;
|
|
11
|
+
|
|
12
|
+
function error(...args) {
|
|
13
|
+
log.error?.(...args);
|
|
14
|
+
const cause = args.find((arg) => arg instanceof Error) ?? null;
|
|
15
|
+
const message = args.map((arg) => (arg instanceof Error ? arg.message : String(arg))).join(' ');
|
|
16
|
+
const source = /^\[([a-z0-9_-]+)\]/i.exec(message)?.[1].toLowerCase() ?? 'server';
|
|
17
|
+
for (const adapter of reporters()) {
|
|
18
|
+
try {
|
|
19
|
+
adapter.capabilities.captureError({ message, source, error: cause });
|
|
20
|
+
} catch (failure) {
|
|
21
|
+
const at = now();
|
|
22
|
+
if (at - lastFailure < 60_000) continue;
|
|
23
|
+
lastFailure = at;
|
|
24
|
+
log.warn?.(`[monitoring] ${adapter.id} could not take an error: ${failure.message}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
info: (...args) => log.info?.(...args),
|
|
31
|
+
warn: (...args) => log.warn?.(...args),
|
|
32
|
+
error,
|
|
33
|
+
};
|
|
34
|
+
}
|
package/server/core/pipeline.js
CHANGED
|
@@ -4,6 +4,8 @@ import {
|
|
|
4
4
|
billingObligations, contacts, externalLinks, linkRejections, linkSuggestions, obligationEvents, organisations, pipelineCursors,
|
|
5
5
|
} from '../db/schema.js';
|
|
6
6
|
import { FlowError } from './errors.js';
|
|
7
|
+
import { runAutomations } from './automations.js';
|
|
8
|
+
import { readInvoicingSettings } from './invoicing-rules.js';
|
|
7
9
|
import { observeJob, recordAccountingEvidence, setHold } from './ledger.js';
|
|
8
10
|
import { linkRecord, priorLinkFor } from './links.js';
|
|
9
11
|
import { enqueueJob, runNextJob } from './queue.js';
|
|
@@ -20,6 +22,9 @@ import { enqueueJob, runNextJob } from './queue.js';
|
|
|
20
22
|
// sales invoices. Exact evidence links the job. A likely invoice is a
|
|
21
23
|
// suggestion for a person. When the copy is complete and fresh and holds no
|
|
22
24
|
// invoice that could bill the job, the job has none.
|
|
25
|
+
// 4. Automations (automations.js): the routines that are on, for example
|
|
26
|
+
// marking a job ready to invoice from its work system status, and drafting
|
|
27
|
+
// the invoice of a ready job. Drafts wait in Flow for a person.
|
|
23
28
|
//
|
|
24
29
|
// The matcher reads only Flow's copies, so matching years of history costs no
|
|
25
30
|
// API calls (docs/JOB_INVOICE_LINKS.md).
|
|
@@ -425,8 +430,10 @@ export async function runPipelineOnce(db, integrations, { now = new Date() } = {
|
|
|
425
430
|
system: accounting.id, customers: await accounting.capabilities.listCustomers({ organisationId }),
|
|
426
431
|
});
|
|
427
432
|
}
|
|
428
|
-
|
|
433
|
+
const settings = await readInvoicingSettings(db, organisationId);
|
|
434
|
+
summary.matching = await matchJobs(db, organisationId, { accounting, now, staleDays: settings.staleDays });
|
|
429
435
|
}
|
|
436
|
+
if (work || accounting) summary.automations = await runAutomations(db, organisationId, { accounting: accounting ?? null, now });
|
|
430
437
|
return summary;
|
|
431
438
|
}
|
|
432
439
|
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm';
|
|
3
|
+
import { invoiceLines, invoices } from '../db/schema.js';
|
|
4
|
+
import { FlowError } from './errors.js';
|
|
5
|
+
import { issueInvoice } from './invoices.js';
|
|
6
|
+
import { soleOrganisation } from './pipeline.js';
|
|
7
|
+
import { enqueueJob, runNextJob } from './queue.js';
|
|
8
|
+
|
|
9
|
+
// Drafts, Problem, Ready and Send all ready (docs/INVOICE_SENDING.md).
|
|
10
|
+
//
|
|
11
|
+
// Flow checks every draft. It checks its own data first: the draft has lines,
|
|
12
|
+
// a total above zero, and a price item on every line. When an accounting
|
|
13
|
+
// system is connected, its own checks are added (for Xero: the customer
|
|
14
|
+
// matches one Xero contact, every item is in Xero). A draft with a problem is
|
|
15
|
+
// in Problem. A person marks a draft without one ready; any later change to
|
|
16
|
+
// the draft sends it back to be checked again.
|
|
17
|
+
//
|
|
18
|
+
// Send all ready issues every ready draft (each gets its number) and, when an
|
|
19
|
+
// accounting system is connected, queues it to go there. The send worker
|
|
20
|
+
// sends the queue in the background on the job queue, so a long send never
|
|
21
|
+
// holds a person's request and two Flow processes never send at once. An
|
|
22
|
+
// issued invoice the accounting system refused has a problem until a person
|
|
23
|
+
// sends it again.
|
|
24
|
+
|
|
25
|
+
export const SEND_KIND = 'invoices.send';
|
|
26
|
+
// Drafts one press issues; the rest wait for the next press.
|
|
27
|
+
export const SEND_BATCH = 100;
|
|
28
|
+
// Drafts checked for Ready and Problem, oldest first.
|
|
29
|
+
export const CHECK_LIMIT = 500;
|
|
30
|
+
const WORKER_BATCH = 25;
|
|
31
|
+
const WORKER_EVERY_MS = 30 * 1000;
|
|
32
|
+
const LEASE_MS = 15 * 60 * 1000;
|
|
33
|
+
// Outcomes that mean the accounting system holds the invoice.
|
|
34
|
+
const SENT = new Set(['created', 'reconciled', 'existing', 'already_in_xero']);
|
|
35
|
+
|
|
36
|
+
const problem = (code, message) => ({ code, message });
|
|
37
|
+
|
|
38
|
+
// Flow's own checks of one draft. `lines` are its invoice lines.
|
|
39
|
+
export function draftProblems(invoice, lines) {
|
|
40
|
+
const problems = [];
|
|
41
|
+
if (!lines.length) problems.push(problem('no_lines', 'The invoice has no lines.'));
|
|
42
|
+
else if (invoice.totalCents <= 0) problems.push(problem('no_amount', 'The total is $0.00 or less.'));
|
|
43
|
+
for (const line of lines) {
|
|
44
|
+
if (!line.priceItemId) problems.push(problem('line_no_item', `Line ${line.position} has no price item.`));
|
|
45
|
+
}
|
|
46
|
+
return problems;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Flow's checks, and the accounting system's, for some drafts. Answers
|
|
50
|
+
// Map(id -> problems) holding only the drafts that have one.
|
|
51
|
+
async function checkDrafts(db, organisationId, drafts, accounting) {
|
|
52
|
+
const ids = drafts.map((d) => d.id);
|
|
53
|
+
const problems = new Map();
|
|
54
|
+
if (!ids.length) return problems;
|
|
55
|
+
const lines = await db.select({ invoiceId: invoiceLines.invoiceId, position: invoiceLines.position, priceItemId: invoiceLines.priceItemId })
|
|
56
|
+
.from(invoiceLines).where(inArray(invoiceLines.invoiceId, ids)).orderBy(asc(invoiceLines.position));
|
|
57
|
+
const linesOf = Map.groupBy(lines, (l) => l.invoiceId);
|
|
58
|
+
for (const draft of drafts) {
|
|
59
|
+
const found = draftProblems(draft, linesOf.get(draft.id) ?? []);
|
|
60
|
+
if (found.length) problems.set(draft.id, found);
|
|
61
|
+
}
|
|
62
|
+
if (accounting) {
|
|
63
|
+
const outside = await accounting.capabilities.checkDrafts({ organisationId, invoiceIds: ids });
|
|
64
|
+
for (const [id, found] of outside ?? []) {
|
|
65
|
+
if (found.length) problems.set(id, [...(problems.get(id) ?? []), ...found]);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return problems;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// The views of the Invoices page. `accounting` is the accounting adapter, or
|
|
72
|
+
// null. Every draft is in one view: Problem (a check failed), Ready (a person
|
|
73
|
+
// marked it ready) or To check (the rest). An issued invoice is Sending while
|
|
74
|
+
// queued, and a Problem when the accounting system refused it. Answers
|
|
75
|
+
// { toCheck: [ids], ready: [ids], problems: Map(id -> problems), sending: Set(ids) }.
|
|
76
|
+
export async function invoiceViews(db, organisationId, { accounting = null } = {}) {
|
|
77
|
+
const drafts = await db.select({ id: invoices.id, totalCents: invoices.totalCents, readyAt: invoices.readyAt }).from(invoices)
|
|
78
|
+
.where(and(eq(invoices.organisationId, organisationId), eq(invoices.status, 'draft')))
|
|
79
|
+
.orderBy(asc(invoices.createdAt), asc(invoices.id))
|
|
80
|
+
.limit(CHECK_LIMIT);
|
|
81
|
+
const problems = await checkDrafts(db, organisationId, drafts, accounting);
|
|
82
|
+
const sending = new Set();
|
|
83
|
+
const issued = await db.select({ id: invoices.id, sendState: invoices.sendState, sendError: invoices.sendError }).from(invoices)
|
|
84
|
+
.where(and(eq(invoices.organisationId, organisationId), eq(invoices.status, 'issued'), inArray(invoices.sendState, ['queued', 'failed'])))
|
|
85
|
+
.orderBy(desc(invoices.sendUpdatedAt));
|
|
86
|
+
for (const row of issued) {
|
|
87
|
+
if (row.sendState === 'queued') sending.add(row.id);
|
|
88
|
+
else problems.set(row.id, [row.sendError ?? problem('send_failed', 'Sending failed.')]);
|
|
89
|
+
}
|
|
90
|
+
const clean = drafts.filter((d) => !problems.has(d.id));
|
|
91
|
+
return {
|
|
92
|
+
toCheck: clean.filter((d) => !d.readyAt).map((d) => d.id),
|
|
93
|
+
ready: clean.filter((d) => d.readyAt).map((d) => d.id),
|
|
94
|
+
problems,
|
|
95
|
+
sending,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// The view of one invoice, with its problems, for the invoice page.
|
|
100
|
+
export async function invoiceView(db, organisationId, invoice, { accounting = null } = {}) {
|
|
101
|
+
if (invoice.status === 'void') return { view: null, problems: [] };
|
|
102
|
+
if (invoice.status === 'issued') {
|
|
103
|
+
if (invoice.sendState === 'queued') return { view: 'sending', problems: [] };
|
|
104
|
+
if (invoice.sendState === 'failed') return { view: 'problem', problems: [invoice.sendError ?? problem('send_failed', 'Sending failed.')] };
|
|
105
|
+
return { view: null, problems: [] };
|
|
106
|
+
}
|
|
107
|
+
const found = (await checkDrafts(db, organisationId, [invoice], accounting)).get(invoice.id) ?? [];
|
|
108
|
+
return { view: found.length ? 'problem' : invoice.readyAt ? 'ready' : 'to_check', problems: found };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Mark ready: a person checked these drafts. A draft with a problem stays
|
|
112
|
+
// where it is. Answers { marked, refused: [{ invoiceId, problems }] }.
|
|
113
|
+
export async function markReady(db, organisationId, invoiceIds, { personId, accounting = null }) {
|
|
114
|
+
const drafts = await db.select({ id: invoices.id, totalCents: invoices.totalCents }).from(invoices)
|
|
115
|
+
.where(and(eq(invoices.organisationId, organisationId), eq(invoices.status, 'draft'), inArray(invoices.id, invoiceIds)));
|
|
116
|
+
if (drafts.length !== new Set(invoiceIds).size) throw new FlowError('not_draft', 'only a draft can be marked ready');
|
|
117
|
+
const problems = await checkDrafts(db, organisationId, drafts, accounting);
|
|
118
|
+
const good = drafts.filter((d) => !problems.has(d.id)).map((d) => d.id);
|
|
119
|
+
if (good.length) {
|
|
120
|
+
await db.update(invoices).set({ readyAt: sql`now()`, readyBy: personId ?? null })
|
|
121
|
+
.where(and(eq(invoices.organisationId, organisationId), eq(invoices.status, 'draft'), inArray(invoices.id, good)));
|
|
122
|
+
}
|
|
123
|
+
return { marked: good.length, refused: [...problems].map(([invoiceId, found]) => ({ invoiceId, problems: found })) };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Back to drafts: the drafts need another look before they go.
|
|
127
|
+
export async function markNotReady(db, organisationId, invoiceIds) {
|
|
128
|
+
const rows = await db.update(invoices).set({ readyAt: null, readyBy: null })
|
|
129
|
+
.where(and(eq(invoices.organisationId, organisationId), eq(invoices.status, 'draft'), inArray(invoices.id, invoiceIds)))
|
|
130
|
+
.returning({ id: invoices.id });
|
|
131
|
+
return { moved: rows.length };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Before Flow issues anything: an accounting system that is on must be
|
|
135
|
+
// connected and take writes, or the invoices would be issued and stuck.
|
|
136
|
+
async function assertCanSend(accounting, organisationId) {
|
|
137
|
+
if (!accounting) return;
|
|
138
|
+
const status = await accounting.capabilities.sendStatus({ organisationId });
|
|
139
|
+
if (!status.connected) {
|
|
140
|
+
throw new FlowError('accounting_not_connected', `${accounting.name} is not connected. Connect it in Integrations, then send.`);
|
|
141
|
+
}
|
|
142
|
+
if (!status.writesAllowed) {
|
|
143
|
+
throw new FlowError('accounting_writes_off', `${accounting.name} does not take invoices from this installation yet (writes are off). Nothing was issued.`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Send all ready: issues each ready draft, then queues it for the accounting
|
|
148
|
+
// system. Answers { issued, queued, remaining }.
|
|
149
|
+
export async function sendAllReady(db, organisationId, { accounting = null, personId, issueDate }) {
|
|
150
|
+
await assertCanSend(accounting, organisationId);
|
|
151
|
+
const { ready } = await invoiceViews(db, organisationId, { accounting });
|
|
152
|
+
const batch = ready.slice(0, SEND_BATCH);
|
|
153
|
+
let issued = 0;
|
|
154
|
+
for (const invoiceId of batch) {
|
|
155
|
+
// Queued first: the worker sends only issued invoices, so a stop between
|
|
156
|
+
// the two steps leaves a queued draft, never an issued invoice nobody sends.
|
|
157
|
+
if (accounting) await queue(db, organisationId, [invoiceId], personId);
|
|
158
|
+
try {
|
|
159
|
+
await issueInvoice(db, organisationId, invoiceId, { issuedBy: `person:${personId}`, issueDate });
|
|
160
|
+
} catch (error) {
|
|
161
|
+
if (accounting) await unqueue(db, organisationId, invoiceId);
|
|
162
|
+
// Someone issued or changed it a moment ago: leave it to them.
|
|
163
|
+
if (error.code === 'not_draft' || error.code === 'not_found' || error.code === 'invalid') continue;
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
issued += 1;
|
|
167
|
+
}
|
|
168
|
+
return { issued, queued: accounting ? issued : 0, remaining: ready.length - batch.length };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Send again: issued invoices that failed go back on the queue. With no ids,
|
|
172
|
+
// every failed one.
|
|
173
|
+
export async function sendAgain(db, organisationId, { accounting = null, personId, invoiceIds = null }) {
|
|
174
|
+
await assertCanSend(accounting, organisationId);
|
|
175
|
+
if (!accounting) throw new FlowError('accounting_not_connected', 'No accounting system is on. Nothing to send.');
|
|
176
|
+
const where = [eq(invoices.organisationId, organisationId), eq(invoices.status, 'issued'), eq(invoices.sendState, 'failed')];
|
|
177
|
+
if (invoiceIds) where.push(inArray(invoices.id, invoiceIds));
|
|
178
|
+
const rows = await db.select({ id: invoices.id }).from(invoices).where(and(...where));
|
|
179
|
+
await queue(db, organisationId, rows.map((r) => r.id), personId);
|
|
180
|
+
return { queued: rows.length };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function queue(db, organisationId, invoiceIds, personId) {
|
|
184
|
+
if (!invoiceIds.length) return;
|
|
185
|
+
await db.update(invoices)
|
|
186
|
+
.set({ sendState: 'queued', sendError: null, sendRequestedBy: personId ?? null, sendUpdatedAt: sql`now()` })
|
|
187
|
+
.where(and(eq(invoices.organisationId, organisationId), inArray(invoices.id, invoiceIds)));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function unqueue(db, organisationId, invoiceId) {
|
|
191
|
+
return db.update(invoices).set({ sendState: null, sendRequestedBy: null, sendUpdatedAt: sql`now()` })
|
|
192
|
+
.where(and(eq(invoices.organisationId, organisationId), eq(invoices.id, invoiceId), eq(invoices.status, 'draft')));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// One pass of the send worker: up to `limit` queued invoices, oldest first.
|
|
196
|
+
export async function sendQueued(db, organisationId, { accounting, limit = WORKER_BATCH }) {
|
|
197
|
+
const rows = await db.select({ id: invoices.id, by: invoices.sendRequestedBy }).from(invoices)
|
|
198
|
+
.where(and(eq(invoices.organisationId, organisationId), eq(invoices.status, 'issued'), eq(invoices.sendState, 'queued')))
|
|
199
|
+
.orderBy(asc(invoices.sendUpdatedAt), asc(invoices.id))
|
|
200
|
+
.limit(limit);
|
|
201
|
+
const summary = { sent: 0, failed: 0, waiting: 0 };
|
|
202
|
+
for (const [by, group] of Map.groupBy(rows, (r) => r.by ?? null)) {
|
|
203
|
+
const { invoices: results } = await accounting.capabilities.pushInvoices({
|
|
204
|
+
organisationId, invoiceIds: group.map((r) => r.id), pushedBy: by,
|
|
205
|
+
});
|
|
206
|
+
for (const result of results) {
|
|
207
|
+
if (SENT.has(result.outcome)) {
|
|
208
|
+
await settle(db, organisationId, result.invoiceId, { sendState: 'sent', sendError: null });
|
|
209
|
+
summary.sent += 1;
|
|
210
|
+
} else if (result.outcome === 'not_tried' && result.error?.code === 'xero_rate_limited') {
|
|
211
|
+
// The day's or minute's calls ran out: it stays queued for a later pass.
|
|
212
|
+
summary.waiting += 1;
|
|
213
|
+
} else {
|
|
214
|
+
const error = result.error ?? { code: `send_${result.outcome}`, message: `Sending ended as ${result.outcome}.` };
|
|
215
|
+
await settle(db, organisationId, result.invoiceId, { sendState: 'failed', sendError: { code: error.code, message: error.message } });
|
|
216
|
+
summary.failed += 1;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return summary;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function settle(db, organisationId, invoiceId, fields) {
|
|
224
|
+
return db.update(invoices).set({ ...fields, sendUpdatedAt: sql`now()` })
|
|
225
|
+
.where(and(eq(invoices.organisationId, organisationId), eq(invoices.id, invoiceId), eq(invoices.sendState, 'queued')));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// The send worker: every 30 seconds, when invoices are queued, one pass on
|
|
229
|
+
// the job queue.
|
|
230
|
+
export function createSendWorker({ db, integrations, log = console, timers = { setTimeout, clearTimeout }, everyMs = WORKER_EVERY_MS }) {
|
|
231
|
+
const workerId = `send-${randomUUID()}`;
|
|
232
|
+
let timer = null;
|
|
233
|
+
let running = null;
|
|
234
|
+
let stopped = true;
|
|
235
|
+
|
|
236
|
+
function schedule() {
|
|
237
|
+
if (stopped) return;
|
|
238
|
+
timer = timers.setTimeout(tick, everyMs);
|
|
239
|
+
timer?.unref?.();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function tick() {
|
|
243
|
+
timer = null;
|
|
244
|
+
if (stopped) return;
|
|
245
|
+
running = (async () => {
|
|
246
|
+
try {
|
|
247
|
+
const [accounting] = integrations.with('pushInvoices');
|
|
248
|
+
if (!accounting) return;
|
|
249
|
+
const organisationId = await soleOrganisation(db);
|
|
250
|
+
if (!organisationId) return;
|
|
251
|
+
const [waiting] = await db.select({ id: invoices.id }).from(invoices)
|
|
252
|
+
.where(and(eq(invoices.organisationId, organisationId), eq(invoices.sendState, 'queued'))).limit(1);
|
|
253
|
+
if (!waiting) return;
|
|
254
|
+
await enqueueJob(db, organisationId, { kind: SEND_KIND, dedupeKey: 'all' });
|
|
255
|
+
const ran = await runNextJob(db, {
|
|
256
|
+
workerId,
|
|
257
|
+
leaseMs: LEASE_MS,
|
|
258
|
+
handlers: { [SEND_KIND]: () => sendQueued(db, organisationId, { accounting }) },
|
|
259
|
+
});
|
|
260
|
+
if (ran) log.info?.(`[sending] ${ran.outcome}: ${JSON.stringify(ran.job.result ?? ran.job.lastError ?? {})}`);
|
|
261
|
+
} catch (error) {
|
|
262
|
+
log.error?.(`[sending] ${error?.code ?? 'error'}: ${error?.message ?? error}`);
|
|
263
|
+
}
|
|
264
|
+
})();
|
|
265
|
+
await running;
|
|
266
|
+
running = null;
|
|
267
|
+
schedule();
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
start() {
|
|
272
|
+
stopped = false;
|
|
273
|
+
schedule();
|
|
274
|
+
},
|
|
275
|
+
async stop() {
|
|
276
|
+
stopped = true;
|
|
277
|
+
if (timer) timers.clearTimeout(timer);
|
|
278
|
+
timer = null;
|
|
279
|
+
await running;
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
CREATE TABLE "automations" (
|
|
2
|
+
"organisation_id" uuid NOT NULL,
|
|
3
|
+
"key" text NOT NULL,
|
|
4
|
+
"enabled" boolean NOT NULL,
|
|
5
|
+
"config" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
|
6
|
+
"updated_by" uuid,
|
|
7
|
+
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
8
|
+
CONSTRAINT "automations_pk" PRIMARY KEY("organisation_id","key")
|
|
9
|
+
);
|
|
10
|
+
--> statement-breakpoint
|
|
11
|
+
CREATE TABLE "customer_billing_settings" (
|
|
12
|
+
"organisation_id" uuid NOT NULL,
|
|
13
|
+
"contact_id" uuid NOT NULL,
|
|
14
|
+
"billable" boolean DEFAULT true NOT NULL,
|
|
15
|
+
"default_practitioner" text,
|
|
16
|
+
"auto_draft" boolean,
|
|
17
|
+
"send_mode" text,
|
|
18
|
+
"payment_terms_days" integer,
|
|
19
|
+
"updated_by" uuid,
|
|
20
|
+
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
21
|
+
CONSTRAINT "customer_billing_settings_pk" PRIMARY KEY("organisation_id","contact_id"),
|
|
22
|
+
CONSTRAINT "customer_billing_settings_send_mode_valid" CHECK ("customer_billing_settings"."send_mode" is null or "customer_billing_settings"."send_mode" in ('accounting_draft', 'approve_and_email')),
|
|
23
|
+
CONSTRAINT "customer_billing_settings_terms_valid" CHECK ("customer_billing_settings"."payment_terms_days" is null or "customer_billing_settings"."payment_terms_days" between 0 and 365)
|
|
24
|
+
);
|
|
25
|
+
--> statement-breakpoint
|
|
26
|
+
CREATE TABLE "invoicing_settings" (
|
|
27
|
+
"organisation_id" uuid PRIMARY KEY NOT NULL,
|
|
28
|
+
"send_mode" text DEFAULT 'accounting_draft' NOT NULL,
|
|
29
|
+
"stale_days" integer DEFAULT 90 NOT NULL,
|
|
30
|
+
"payment_terms_days" integer DEFAULT 30 NOT NULL,
|
|
31
|
+
"require_practitioner" boolean DEFAULT false NOT NULL,
|
|
32
|
+
"rush_item_code" text,
|
|
33
|
+
"rush_percent" integer DEFAULT 20 NOT NULL,
|
|
34
|
+
"updated_by" uuid,
|
|
35
|
+
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
36
|
+
CONSTRAINT "invoicing_settings_send_mode_valid" CHECK (send_mode in ('accounting_draft', 'approve_and_email')),
|
|
37
|
+
CONSTRAINT "invoicing_settings_stale_days_valid" CHECK ("invoicing_settings"."stale_days" between 1 and 3650),
|
|
38
|
+
CONSTRAINT "invoicing_settings_terms_valid" CHECK ("invoicing_settings"."payment_terms_days" between 0 and 365),
|
|
39
|
+
CONSTRAINT "invoicing_settings_rush_valid" CHECK ("invoicing_settings"."rush_percent" between 0 and 100)
|
|
40
|
+
);
|
|
41
|
+
--> statement-breakpoint
|
|
42
|
+
ALTER TABLE "billing_obligations" ADD COLUMN "source_status" text;--> statement-breakpoint
|
|
43
|
+
ALTER TABLE "billing_obligations" ADD COLUMN "ready_at" timestamp with time zone;--> statement-breakpoint
|
|
44
|
+
ALTER TABLE "billing_obligations" ADD COLUMN "ready_by" text;--> statement-breakpoint
|
|
45
|
+
ALTER TABLE "automations" ADD CONSTRAINT "automations_organisation_id_organisations_id_fk" FOREIGN KEY ("organisation_id") REFERENCES "public"."organisations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
46
|
+
ALTER TABLE "automations" ADD CONSTRAINT "automations_updated_by_people_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."people"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
|
47
|
+
ALTER TABLE "customer_billing_settings" ADD CONSTRAINT "customer_billing_settings_organisation_id_organisations_id_fk" FOREIGN KEY ("organisation_id") REFERENCES "public"."organisations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
48
|
+
ALTER TABLE "customer_billing_settings" ADD CONSTRAINT "customer_billing_settings_updated_by_people_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."people"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
|
49
|
+
ALTER TABLE "customer_billing_settings" ADD CONSTRAINT "customer_billing_settings_contact_same_org" FOREIGN KEY ("organisation_id","contact_id") REFERENCES "public"."contacts"("organisation_id","id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
50
|
+
ALTER TABLE "invoicing_settings" ADD CONSTRAINT "invoicing_settings_organisation_id_organisations_id_fk" FOREIGN KEY ("organisation_id") REFERENCES "public"."organisations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
51
|
+
ALTER TABLE "invoicing_settings" ADD CONSTRAINT "invoicing_settings_updated_by_people_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."people"("id") ON DELETE set null ON UPDATE no action;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
CREATE TABLE "invoice_rules" (
|
|
2
|
+
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
|
3
|
+
"organisation_id" uuid NOT NULL,
|
|
4
|
+
"name" text NOT NULL,
|
|
5
|
+
"position" integer NOT NULL,
|
|
6
|
+
"enabled" boolean DEFAULT true NOT NULL,
|
|
7
|
+
"trigger" text DEFAULT 'job_ready' NOT NULL,
|
|
8
|
+
"conditions" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
|
9
|
+
"action" jsonb NOT NULL,
|
|
10
|
+
"starter_key" text,
|
|
11
|
+
"updated_by" uuid,
|
|
12
|
+
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
13
|
+
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
14
|
+
CONSTRAINT "invoice_rules_trigger_valid" CHECK ("invoice_rules"."trigger" in ('job_ready')),
|
|
15
|
+
CONSTRAINT "invoice_rules_name_valid" CHECK (length("invoice_rules"."name") between 1 and 120)
|
|
16
|
+
);
|
|
17
|
+
--> statement-breakpoint
|
|
18
|
+
ALTER TABLE "invoicing_settings" ADD COLUMN "rules_seeded" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
|
19
|
+
ALTER TABLE "invoice_rules" ADD CONSTRAINT "invoice_rules_organisation_id_organisations_id_fk" FOREIGN KEY ("organisation_id") REFERENCES "public"."organisations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
20
|
+
ALTER TABLE "invoice_rules" ADD CONSTRAINT "invoice_rules_updated_by_people_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."people"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
|
21
|
+
CREATE UNIQUE INDEX "invoice_rules_starter_key" ON "invoice_rules" USING btree ("organisation_id","starter_key");
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
ALTER TABLE "invoices" ADD COLUMN "ready_at" timestamp with time zone;--> statement-breakpoint
|
|
2
|
+
ALTER TABLE "invoices" ADD COLUMN "ready_by" uuid;--> statement-breakpoint
|
|
3
|
+
ALTER TABLE "invoices" ADD COLUMN "send_state" text;--> statement-breakpoint
|
|
4
|
+
ALTER TABLE "invoices" ADD COLUMN "send_error" jsonb;--> statement-breakpoint
|
|
5
|
+
ALTER TABLE "invoices" ADD COLUMN "send_requested_by" uuid;--> statement-breakpoint
|
|
6
|
+
ALTER TABLE "invoices" ADD COLUMN "send_updated_at" timestamp with time zone;--> statement-breakpoint
|
|
7
|
+
ALTER TABLE "invoices" ADD CONSTRAINT "invoices_ready_by_people_id_fk" FOREIGN KEY ("ready_by") REFERENCES "public"."people"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
|
8
|
+
ALTER TABLE "invoices" ADD CONSTRAINT "invoices_send_requested_by_people_id_fk" FOREIGN KEY ("send_requested_by") REFERENCES "public"."people"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
|
9
|
+
ALTER TABLE "invoices" ADD CONSTRAINT "invoices_send_state_valid" CHECK ("invoices"."send_state" is null or "invoices"."send_state" in ('queued', 'sent', 'failed'));
|