@arcforgelabs/flow 2026.10.25 → 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.
Files changed (60) hide show
  1. package/README.md +6 -0
  2. package/npm-shrinkwrap.json +627 -2
  3. package/package.json +3 -1
  4. package/server/api/invoicing.js +586 -0
  5. package/server/app.js +38 -1
  6. package/server/auth/people.js +26 -0
  7. package/server/auth/routes.js +13 -0
  8. package/server/core/automations.js +140 -0
  9. package/server/core/invoice-rules.js +224 -0
  10. package/server/core/invoices.js +11 -1
  11. package/server/core/invoicing-rules.js +255 -0
  12. package/server/core/ledger.js +50 -14
  13. package/server/core/links.js +3 -3
  14. package/server/core/monitoring.js +34 -0
  15. package/server/core/pipeline.js +495 -0
  16. package/server/core/sending.js +282 -0
  17. package/server/db/migrations/0008_people_appearance.sql +1 -0
  18. package/server/db/migrations/0009_pipeline_matcher.sql +46 -0
  19. package/server/db/migrations/0010_invoicing_rules.sql +51 -0
  20. package/server/db/migrations/0011_invoice_rules.sql +21 -0
  21. package/server/db/migrations/0012_invoice_sending.sql +9 -0
  22. package/server/db/migrations/meta/0008_snapshot.json +2479 -0
  23. package/server/db/migrations/meta/0009_snapshot.json +2809 -0
  24. package/server/db/migrations/meta/0010_snapshot.json +3158 -0
  25. package/server/db/migrations/meta/0011_snapshot.json +3314 -0
  26. package/server/db/migrations/meta/0012_snapshot.json +3380 -0
  27. package/server/db/migrations/meta/_journal.json +35 -0
  28. package/server/db/schema.js +186 -2
  29. package/server/index.js +20 -3
  30. package/server/integrations/adapters.js +2 -1
  31. package/server/integrations/port.js +8 -0
  32. package/server/integrations/seazona/feed.js +11 -0
  33. package/server/integrations/seazona/index.js +7 -1
  34. package/server/integrations/seazona/ledger.js +4 -0
  35. package/server/integrations/sentry/index.js +105 -0
  36. package/server/integrations/xero/client.js +5 -0
  37. package/server/integrations/xero/drafts.js +123 -16
  38. package/server/integrations/xero/import.js +22 -0
  39. package/server/integrations/xero/index.js +21 -3
  40. package/server/integrations/xero/lookup.js +94 -0
  41. package/server/integrations/xero/migrations/0005_xero_send_modes.sql +10 -0
  42. package/server/integrations/xero/migrations/meta/_journal.json +7 -0
  43. package/server/theme/color-contrast.js +39 -2
  44. package/server/theme/contrast-audit.js +63 -0
  45. package/server/theme/themes.js +10 -2
  46. package/server/ui/app-page.js +6 -10
  47. package/server/ui/app.js +364 -105
  48. package/server/ui/appearance.js +3 -2
  49. package/server/ui/automations.js +96 -0
  50. package/server/ui/clerk-appearance.js +18 -2
  51. package/server/ui/flow.css +334 -22
  52. package/server/ui/invoice-rules.js +237 -0
  53. package/server/ui/invoicing-settings.js +108 -0
  54. package/server/ui/invoicing.js +597 -0
  55. package/server/ui/jobs.js +172 -0
  56. package/server/ui/logos/seazona.svg +1 -0
  57. package/server/ui/logos/sentry.svg +1 -0
  58. package/server/ui/logos/xero.svg +1 -0
  59. package/server/ui/monitoring.js +53 -0
  60. package/server/ui/routes.js +5 -1
@@ -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 @@
1
+ ALTER TABLE "people" ADD COLUMN "appearance" jsonb;
@@ -0,0 +1,46 @@
1
+ CREATE TABLE "link_rejections" (
2
+ "organisation_id" uuid NOT NULL,
3
+ "obligation_id" uuid NOT NULL,
4
+ "system" text NOT NULL,
5
+ "external_id" text NOT NULL,
6
+ "actor" text NOT NULL,
7
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
8
+ CONSTRAINT "link_rejections_pk" PRIMARY KEY("organisation_id","obligation_id","system","external_id")
9
+ );
10
+ --> statement-breakpoint
11
+ CREATE TABLE "link_suggestions" (
12
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
13
+ "organisation_id" uuid NOT NULL,
14
+ "obligation_id" uuid NOT NULL,
15
+ "system" text NOT NULL,
16
+ "external_id" text NOT NULL,
17
+ "external_number" text,
18
+ "score" integer NOT NULL,
19
+ "rank" integer NOT NULL,
20
+ "signals" jsonb DEFAULT '[]'::jsonb NOT NULL,
21
+ "state" text DEFAULT 'open' NOT NULL,
22
+ "decided_by" text,
23
+ "decided_at" timestamp with time zone,
24
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
25
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL,
26
+ CONSTRAINT "link_suggestions_pair" UNIQUE("organisation_id","obligation_id","system","external_id"),
27
+ CONSTRAINT "link_suggestions_state_valid" CHECK (state in ('open', 'accepted', 'rejected', 'stale'))
28
+ );
29
+ --> statement-breakpoint
30
+ CREATE TABLE "pipeline_cursors" (
31
+ "organisation_id" uuid NOT NULL,
32
+ "name" text NOT NULL,
33
+ "cursor" text,
34
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL,
35
+ CONSTRAINT "pipeline_cursors_pk" PRIMARY KEY("organisation_id","name")
36
+ );
37
+ --> statement-breakpoint
38
+ ALTER TABLE "external_links" DROP CONSTRAINT "external_links_matched_by_valid";--> statement-breakpoint
39
+ ALTER TABLE "billing_obligations" ADD COLUMN "job_date" date;--> statement-breakpoint
40
+ ALTER TABLE "link_rejections" ADD CONSTRAINT "link_rejections_organisation_id_organisations_id_fk" FOREIGN KEY ("organisation_id") REFERENCES "public"."organisations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
41
+ ALTER TABLE "link_rejections" ADD CONSTRAINT "link_rejections_obligation_same_org" FOREIGN KEY ("organisation_id","obligation_id") REFERENCES "public"."billing_obligations"("organisation_id","id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
42
+ ALTER TABLE "link_suggestions" ADD CONSTRAINT "link_suggestions_organisation_id_organisations_id_fk" FOREIGN KEY ("organisation_id") REFERENCES "public"."organisations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
43
+ ALTER TABLE "link_suggestions" ADD CONSTRAINT "link_suggestions_obligation_same_org" FOREIGN KEY ("organisation_id","obligation_id") REFERENCES "public"."billing_obligations"("organisation_id","id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
44
+ ALTER TABLE "pipeline_cursors" ADD CONSTRAINT "pipeline_cursors_organisation_id_organisations_id_fk" FOREIGN KEY ("organisation_id") REFERENCES "public"."organisations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
45
+ CREATE INDEX "link_suggestions_open" ON "link_suggestions" USING btree ("organisation_id","state","obligation_id");--> statement-breakpoint
46
+ ALTER TABLE "external_links" ADD CONSTRAINT "external_links_matched_by_valid" CHECK (matched_by in ('created_by_flow', 'exact_id', 'exact_name', 'imported', 'name', 'person'));
@@ -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'));