@arcforgelabs/flow 2026.10.26 → 2026.10.29

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 (63) hide show
  1. package/README.md +6 -0
  2. package/npm-shrinkwrap.json +627 -2
  3. package/package.json +3 -2
  4. package/server/api/invoicing.js +313 -46
  5. package/server/app.js +30 -2
  6. package/server/core/automations.js +154 -0
  7. package/server/core/invoice-rules.js +226 -0
  8. package/server/core/invoices.js +8 -3
  9. package/server/core/invoicing-rules.js +255 -0
  10. package/server/core/job-board.js +130 -0
  11. package/server/core/ledger.js +57 -17
  12. package/server/core/line-mapping.js +337 -0
  13. package/server/core/monitoring.js +34 -0
  14. package/server/core/pipeline.js +16 -3
  15. package/server/core/sending.js +283 -0
  16. package/server/db/migrate-all.js +26 -0
  17. package/server/db/migrations/0010_invoicing_rules.sql +72 -0
  18. package/server/db/migrations/0011_invoice_rules.sql +27 -0
  19. package/server/db/migrations/0012_invoice_sending.sql +18 -0
  20. package/server/db/migrations/0013_line_mappings.sql +35 -0
  21. package/server/db/migrations/meta/0010_snapshot.json +3158 -0
  22. package/server/db/migrations/meta/0011_snapshot.json +3314 -0
  23. package/server/db/migrations/meta/0012_snapshot.json +3380 -0
  24. package/server/db/migrations/meta/0013_snapshot.json +3568 -0
  25. package/server/db/migrations/meta/_journal.json +28 -0
  26. package/server/db/schema.js +158 -0
  27. package/server/import/at-start.js +64 -2
  28. package/server/index.js +17 -5
  29. package/server/integrations/adapters.js +2 -1
  30. package/server/integrations/port.js +6 -0
  31. package/server/integrations/seazona/feed.js +62 -4
  32. package/server/integrations/seazona/index.js +7 -1
  33. package/server/integrations/seazona/lanes.js +4 -1
  34. package/server/integrations/seazona/ledger.js +2 -0
  35. package/server/integrations/seazona/migrations/0000_api_governor.sql +3 -3
  36. package/server/integrations/seazona/migrations/0001_mirror.sql +9 -9
  37. package/server/integrations/sentry/index.js +105 -0
  38. package/server/integrations/xero/client.js +5 -0
  39. package/server/integrations/xero/drafts.js +127 -17
  40. package/server/integrations/xero/index.js +11 -1
  41. package/server/integrations/xero/lookup.js +6 -2
  42. package/server/integrations/xero/migrations/0000_xero_connections.sql +1 -1
  43. package/server/integrations/xero/migrations/0001_xero_catalogue.sql +5 -5
  44. package/server/integrations/xero/migrations/0002_xero_invoice_pushes.sql +2 -2
  45. package/server/integrations/xero/migrations/0003_xero_payment_cursors.sql +1 -1
  46. package/server/integrations/xero/migrations/0004_xero_mirror.sql +7 -7
  47. package/server/integrations/xero/migrations/0005_xero_send_modes.sql +16 -0
  48. package/server/integrations/xero/migrations/meta/_journal.json +7 -0
  49. package/server/ui/app-page.js +7 -4
  50. package/server/ui/app.js +122 -6
  51. package/server/ui/automations.js +96 -0
  52. package/server/ui/clerk-appearance.js +21 -0
  53. package/server/ui/flow.css +243 -30
  54. package/server/ui/invoice-rules.js +237 -0
  55. package/server/ui/invoicing-settings.js +110 -0
  56. package/server/ui/invoicing.js +166 -20
  57. package/server/ui/job-board.js +155 -0
  58. package/server/ui/jobs.js +232 -84
  59. package/server/ui/line-mappings.js +60 -0
  60. package/server/ui/logos/sentry.svg +1 -0
  61. package/server/ui/monitoring.js +61 -0
  62. package/server/ui/routes.js +2 -2
  63. package/server/ui/search.js +145 -0
@@ -0,0 +1,283 @@
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 (or, for a line such as a rush
13
+ // charge, an account) on every line. When an accounting
14
+ // system is connected, its own checks are added (for Xero: the customer
15
+ // matches one Xero contact, every item is in Xero). A draft with a problem is
16
+ // in Problem. A person marks a draft without one ready; any later change to
17
+ // the draft sends it back to be checked again.
18
+ //
19
+ // Send all ready issues every ready draft (each gets its number) and, when an
20
+ // accounting system is connected, queues it to go there. The send worker
21
+ // sends the queue in the background on the job queue, so a long send never
22
+ // holds a person's request and two Flow processes never send at once. An
23
+ // issued invoice the accounting system refused has a problem until a person
24
+ // sends it again.
25
+
26
+ export const SEND_KIND = 'invoices.send';
27
+ // Drafts one press issues; the rest wait for the next press.
28
+ export const SEND_BATCH = 100;
29
+ // Drafts checked for Ready and Problem, oldest first.
30
+ export const CHECK_LIMIT = 500;
31
+ const WORKER_BATCH = 25;
32
+ const WORKER_EVERY_MS = 30 * 1000;
33
+ const LEASE_MS = 15 * 60 * 1000;
34
+ // Outcomes that mean the accounting system holds the invoice.
35
+ const SENT = new Set(['created', 'reconciled', 'existing', 'already_in_xero']);
36
+
37
+ const problem = (code, message) => ({ code, message });
38
+
39
+ // Flow's own checks of one draft. `lines` are its invoice lines.
40
+ export function draftProblems(invoice, lines) {
41
+ const problems = [];
42
+ if (!lines.length) problems.push(problem('no_lines', 'The invoice has no lines.'));
43
+ else if (invoice.totalCents <= 0) problems.push(problem('no_amount', 'The total is $0.00 or less.'));
44
+ for (const line of lines) {
45
+ if (!line.priceItemId && !line.accountCode) problems.push(problem('line_no_item', `Line ${line.position} has no price item.`));
46
+ }
47
+ return problems;
48
+ }
49
+
50
+ // Flow's checks, and the accounting system's, for some drafts. Answers
51
+ // Map(id -> problems) holding only the drafts that have one.
52
+ async function checkDrafts(db, organisationId, drafts, accounting) {
53
+ const ids = drafts.map((d) => d.id);
54
+ const problems = new Map();
55
+ if (!ids.length) return problems;
56
+ const lines = await db.select({ invoiceId: invoiceLines.invoiceId, position: invoiceLines.position, priceItemId: invoiceLines.priceItemId, accountCode: invoiceLines.accountCode })
57
+ .from(invoiceLines).where(inArray(invoiceLines.invoiceId, ids)).orderBy(asc(invoiceLines.position));
58
+ const linesOf = Map.groupBy(lines, (l) => l.invoiceId);
59
+ for (const draft of drafts) {
60
+ const found = draftProblems(draft, linesOf.get(draft.id) ?? []);
61
+ if (found.length) problems.set(draft.id, found);
62
+ }
63
+ if (accounting) {
64
+ const outside = await accounting.capabilities.checkDrafts({ organisationId, invoiceIds: ids });
65
+ for (const [id, found] of outside ?? []) {
66
+ if (found.length) problems.set(id, [...(problems.get(id) ?? []), ...found]);
67
+ }
68
+ }
69
+ return problems;
70
+ }
71
+
72
+ // The views of the Invoices page. `accounting` is the accounting adapter, or
73
+ // null. Every draft is in one view: Problem (a check failed), Ready (a person
74
+ // marked it ready) or To check (the rest). An issued invoice is Sending while
75
+ // queued, and a Problem when the accounting system refused it. Answers
76
+ // { toCheck: [ids], ready: [ids], problems: Map(id -> problems), sending: Set(ids) }.
77
+ export async function invoiceViews(db, organisationId, { accounting = null } = {}) {
78
+ const drafts = await db.select({ id: invoices.id, totalCents: invoices.totalCents, readyAt: invoices.readyAt }).from(invoices)
79
+ .where(and(eq(invoices.organisationId, organisationId), eq(invoices.status, 'draft')))
80
+ .orderBy(asc(invoices.createdAt), asc(invoices.id))
81
+ .limit(CHECK_LIMIT);
82
+ const problems = await checkDrafts(db, organisationId, drafts, accounting);
83
+ const sending = new Set();
84
+ const issued = await db.select({ id: invoices.id, sendState: invoices.sendState, sendError: invoices.sendError }).from(invoices)
85
+ .where(and(eq(invoices.organisationId, organisationId), eq(invoices.status, 'issued'), inArray(invoices.sendState, ['queued', 'failed'])))
86
+ .orderBy(desc(invoices.sendUpdatedAt));
87
+ for (const row of issued) {
88
+ if (row.sendState === 'queued') sending.add(row.id);
89
+ else problems.set(row.id, [row.sendError ?? problem('send_failed', 'Sending failed.')]);
90
+ }
91
+ const clean = drafts.filter((d) => !problems.has(d.id));
92
+ return {
93
+ toCheck: clean.filter((d) => !d.readyAt).map((d) => d.id),
94
+ ready: clean.filter((d) => d.readyAt).map((d) => d.id),
95
+ problems,
96
+ sending,
97
+ };
98
+ }
99
+
100
+ // The view of one invoice, with its problems, for the invoice page.
101
+ export async function invoiceView(db, organisationId, invoice, { accounting = null } = {}) {
102
+ if (invoice.status === 'void') return { view: null, problems: [] };
103
+ if (invoice.status === 'issued') {
104
+ if (invoice.sendState === 'queued') return { view: 'sending', problems: [] };
105
+ if (invoice.sendState === 'failed') return { view: 'problem', problems: [invoice.sendError ?? problem('send_failed', 'Sending failed.')] };
106
+ return { view: null, problems: [] };
107
+ }
108
+ const found = (await checkDrafts(db, organisationId, [invoice], accounting)).get(invoice.id) ?? [];
109
+ return { view: found.length ? 'problem' : invoice.readyAt ? 'ready' : 'to_check', problems: found };
110
+ }
111
+
112
+ // Mark ready: a person checked these drafts. A draft with a problem stays
113
+ // where it is. Answers { marked, refused: [{ invoiceId, problems }] }.
114
+ export async function markReady(db, organisationId, invoiceIds, { personId, accounting = null }) {
115
+ const drafts = await db.select({ id: invoices.id, totalCents: invoices.totalCents }).from(invoices)
116
+ .where(and(eq(invoices.organisationId, organisationId), eq(invoices.status, 'draft'), inArray(invoices.id, invoiceIds)));
117
+ if (drafts.length !== new Set(invoiceIds).size) throw new FlowError('not_draft', 'only a draft can be marked ready');
118
+ const problems = await checkDrafts(db, organisationId, drafts, accounting);
119
+ const good = drafts.filter((d) => !problems.has(d.id)).map((d) => d.id);
120
+ if (good.length) {
121
+ await db.update(invoices).set({ readyAt: sql`now()`, readyBy: personId ?? null })
122
+ .where(and(eq(invoices.organisationId, organisationId), eq(invoices.status, 'draft'), inArray(invoices.id, good)));
123
+ }
124
+ return { marked: good.length, refused: [...problems].map(([invoiceId, found]) => ({ invoiceId, problems: found })) };
125
+ }
126
+
127
+ // Back to drafts: the drafts need another look before they go.
128
+ export async function markNotReady(db, organisationId, invoiceIds) {
129
+ const rows = await db.update(invoices).set({ readyAt: null, readyBy: null })
130
+ .where(and(eq(invoices.organisationId, organisationId), eq(invoices.status, 'draft'), inArray(invoices.id, invoiceIds)))
131
+ .returning({ id: invoices.id });
132
+ return { moved: rows.length };
133
+ }
134
+
135
+ // Before Flow issues anything: an accounting system that is on must be
136
+ // connected and take writes, or the invoices would be issued and stuck.
137
+ async function assertCanSend(accounting, organisationId) {
138
+ if (!accounting) return;
139
+ const status = await accounting.capabilities.sendStatus({ organisationId });
140
+ if (!status.connected) {
141
+ throw new FlowError('accounting_not_connected', `${accounting.name} is not connected. Connect it in Integrations, then send.`);
142
+ }
143
+ if (!status.writesAllowed) {
144
+ throw new FlowError('accounting_writes_off', `${accounting.name} does not take invoices from this installation yet (writes are off). Nothing was issued.`);
145
+ }
146
+ }
147
+
148
+ // Send all ready: issues each ready draft, then queues it for the accounting
149
+ // system. Answers { issued, queued, remaining }.
150
+ export async function sendAllReady(db, organisationId, { accounting = null, personId, issueDate }) {
151
+ await assertCanSend(accounting, organisationId);
152
+ const { ready } = await invoiceViews(db, organisationId, { accounting });
153
+ const batch = ready.slice(0, SEND_BATCH);
154
+ let issued = 0;
155
+ for (const invoiceId of batch) {
156
+ // Queued first: the worker sends only issued invoices, so a stop between
157
+ // the two steps leaves a queued draft, never an issued invoice nobody sends.
158
+ if (accounting) await queue(db, organisationId, [invoiceId], personId);
159
+ try {
160
+ await issueInvoice(db, organisationId, invoiceId, { issuedBy: `person:${personId}`, issueDate });
161
+ } catch (error) {
162
+ if (accounting) await unqueue(db, organisationId, invoiceId);
163
+ // Someone issued or changed it a moment ago: leave it to them.
164
+ if (error.code === 'not_draft' || error.code === 'not_found' || error.code === 'invalid') continue;
165
+ throw error;
166
+ }
167
+ issued += 1;
168
+ }
169
+ return { issued, queued: accounting ? issued : 0, remaining: ready.length - batch.length };
170
+ }
171
+
172
+ // Send again: issued invoices that failed go back on the queue. With no ids,
173
+ // every failed one.
174
+ export async function sendAgain(db, organisationId, { accounting = null, personId, invoiceIds = null }) {
175
+ await assertCanSend(accounting, organisationId);
176
+ if (!accounting) throw new FlowError('accounting_not_connected', 'No accounting system is on. Nothing to send.');
177
+ const where = [eq(invoices.organisationId, organisationId), eq(invoices.status, 'issued'), eq(invoices.sendState, 'failed')];
178
+ if (invoiceIds) where.push(inArray(invoices.id, invoiceIds));
179
+ const rows = await db.select({ id: invoices.id }).from(invoices).where(and(...where));
180
+ await queue(db, organisationId, rows.map((r) => r.id), personId);
181
+ return { queued: rows.length };
182
+ }
183
+
184
+ async function queue(db, organisationId, invoiceIds, personId) {
185
+ if (!invoiceIds.length) return;
186
+ await db.update(invoices)
187
+ .set({ sendState: 'queued', sendError: null, sendRequestedBy: personId ?? null, sendUpdatedAt: sql`now()` })
188
+ .where(and(eq(invoices.organisationId, organisationId), inArray(invoices.id, invoiceIds)));
189
+ }
190
+
191
+ function unqueue(db, organisationId, invoiceId) {
192
+ return db.update(invoices).set({ sendState: null, sendRequestedBy: null, sendUpdatedAt: sql`now()` })
193
+ .where(and(eq(invoices.organisationId, organisationId), eq(invoices.id, invoiceId), eq(invoices.status, 'draft')));
194
+ }
195
+
196
+ // One pass of the send worker: up to `limit` queued invoices, oldest first.
197
+ export async function sendQueued(db, organisationId, { accounting, limit = WORKER_BATCH }) {
198
+ const rows = await db.select({ id: invoices.id, by: invoices.sendRequestedBy }).from(invoices)
199
+ .where(and(eq(invoices.organisationId, organisationId), eq(invoices.status, 'issued'), eq(invoices.sendState, 'queued')))
200
+ .orderBy(asc(invoices.sendUpdatedAt), asc(invoices.id))
201
+ .limit(limit);
202
+ const summary = { sent: 0, failed: 0, waiting: 0 };
203
+ for (const [by, group] of Map.groupBy(rows, (r) => r.by ?? null)) {
204
+ const { invoices: results } = await accounting.capabilities.pushInvoices({
205
+ organisationId, invoiceIds: group.map((r) => r.id), pushedBy: by,
206
+ });
207
+ for (const result of results) {
208
+ if (SENT.has(result.outcome)) {
209
+ await settle(db, organisationId, result.invoiceId, { sendState: 'sent', sendError: null });
210
+ summary.sent += 1;
211
+ } else if (result.outcome === 'not_tried' && result.error?.code === 'xero_rate_limited') {
212
+ // The day's or minute's calls ran out: it stays queued for a later pass.
213
+ summary.waiting += 1;
214
+ } else {
215
+ const error = result.error ?? { code: `send_${result.outcome}`, message: `Sending ended as ${result.outcome}.` };
216
+ await settle(db, organisationId, result.invoiceId, { sendState: 'failed', sendError: { code: error.code, message: error.message } });
217
+ summary.failed += 1;
218
+ }
219
+ }
220
+ }
221
+ return summary;
222
+ }
223
+
224
+ function settle(db, organisationId, invoiceId, fields) {
225
+ return db.update(invoices).set({ ...fields, sendUpdatedAt: sql`now()` })
226
+ .where(and(eq(invoices.organisationId, organisationId), eq(invoices.id, invoiceId), eq(invoices.sendState, 'queued')));
227
+ }
228
+
229
+ // The send worker: every 30 seconds, when invoices are queued, one pass on
230
+ // the job queue.
231
+ export function createSendWorker({ db, integrations, log = console, timers = { setTimeout, clearTimeout }, everyMs = WORKER_EVERY_MS }) {
232
+ const workerId = `send-${randomUUID()}`;
233
+ let timer = null;
234
+ let running = null;
235
+ let stopped = true;
236
+
237
+ function schedule() {
238
+ if (stopped) return;
239
+ timer = timers.setTimeout(tick, everyMs);
240
+ timer?.unref?.();
241
+ }
242
+
243
+ async function tick() {
244
+ timer = null;
245
+ if (stopped) return;
246
+ running = (async () => {
247
+ try {
248
+ const [accounting] = integrations.with('pushInvoices');
249
+ if (!accounting) return;
250
+ const organisationId = await soleOrganisation(db);
251
+ if (!organisationId) return;
252
+ const [waiting] = await db.select({ id: invoices.id }).from(invoices)
253
+ .where(and(eq(invoices.organisationId, organisationId), eq(invoices.sendState, 'queued'))).limit(1);
254
+ if (!waiting) return;
255
+ await enqueueJob(db, organisationId, { kind: SEND_KIND, dedupeKey: 'all' });
256
+ const ran = await runNextJob(db, {
257
+ workerId,
258
+ leaseMs: LEASE_MS,
259
+ handlers: { [SEND_KIND]: () => sendQueued(db, organisationId, { accounting }) },
260
+ });
261
+ if (ran) log.info?.(`[sending] ${ran.outcome}: ${JSON.stringify(ran.job.result ?? ran.job.lastError ?? {})}`);
262
+ } catch (error) {
263
+ log.error?.(`[sending] ${error?.code ?? 'error'}: ${error?.message ?? error}`);
264
+ }
265
+ })();
266
+ await running;
267
+ running = null;
268
+ schedule();
269
+ }
270
+
271
+ return {
272
+ start() {
273
+ stopped = false;
274
+ schedule();
275
+ },
276
+ async stop() {
277
+ stopped = true;
278
+ if (timer) timers.clearTimeout(timer);
279
+ timer = null;
280
+ await running;
281
+ },
282
+ };
283
+ }
@@ -0,0 +1,26 @@
1
+ import { migrateDatabase, openDatabase } from './index.js';
2
+ import { adapters as shipped } from '../integrations/adapters.js';
3
+ import { migrateAdapterSchema } from '../integrations/schema.js';
4
+
5
+ // Brings a database up to this build: the core migrations, then the tables
6
+ // of every adapter this build ships, whether or not it is on. On Replit the
7
+ // workspace's database runs this before a publish (scripts/replit-deploy.sh
8
+ // update-workspace), so it holds every table production can hold and
9
+ // publishing never offers to drop one (docs/REPLIT.md).
10
+ export async function migrateEverything(db, { adapters = shipped, log = () => {} } = {}) {
11
+ await migrateDatabase(db);
12
+ for (const adapter of adapters) {
13
+ if (!adapter.schema) continue;
14
+ await migrateAdapterSchema(db, adapter);
15
+ log(`${adapter.id} tables are up to date`);
16
+ }
17
+ }
18
+
19
+ if (import.meta.url === `file://${process.argv[1]}`) {
20
+ const handle = openDatabase({ max: 1 });
21
+ try {
22
+ await migrateEverything(handle.db, { log: (line) => console.log(`[deploy] ${line}`) });
23
+ } finally {
24
+ await handle.close();
25
+ }
26
+ }
@@ -0,0 +1,72 @@
1
+ CREATE TABLE IF NOT EXISTS "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 IF NOT EXISTS "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 IF NOT EXISTS "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 IF NOT EXISTS "source_status" text;--> statement-breakpoint
43
+ ALTER TABLE "billing_obligations" ADD COLUMN IF NOT EXISTS "ready_at" timestamp with time zone;--> statement-breakpoint
44
+ ALTER TABLE "billing_obligations" ADD COLUMN IF NOT EXISTS "ready_by" text;--> statement-breakpoint
45
+ DO $flow_guard$ BEGIN
46
+ 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;
47
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
48
+ END $flow_guard$;--> statement-breakpoint
49
+ DO $flow_guard$ BEGIN
50
+ 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;
51
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
52
+ END $flow_guard$;--> statement-breakpoint
53
+ DO $flow_guard$ BEGIN
54
+ 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;
55
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
56
+ END $flow_guard$;--> statement-breakpoint
57
+ DO $flow_guard$ BEGIN
58
+ 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;
59
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
60
+ END $flow_guard$;--> statement-breakpoint
61
+ DO $flow_guard$ BEGIN
62
+ 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;
63
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
64
+ END $flow_guard$;--> statement-breakpoint
65
+ DO $flow_guard$ BEGIN
66
+ 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;
67
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
68
+ END $flow_guard$;--> statement-breakpoint
69
+ DO $flow_guard$ BEGIN
70
+ 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;
71
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
72
+ END $flow_guard$;
@@ -0,0 +1,27 @@
1
+ CREATE TABLE IF NOT EXISTS "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 IF NOT EXISTS "rules_seeded" boolean DEFAULT false NOT NULL;--> statement-breakpoint
19
+ DO $flow_guard$ BEGIN
20
+ 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;
21
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
22
+ END $flow_guard$;--> statement-breakpoint
23
+ DO $flow_guard$ BEGIN
24
+ 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;
25
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
26
+ END $flow_guard$;--> statement-breakpoint
27
+ CREATE UNIQUE INDEX IF NOT EXISTS "invoice_rules_starter_key" ON "invoice_rules" USING btree ("organisation_id","starter_key");
@@ -0,0 +1,18 @@
1
+ ALTER TABLE "invoices" ADD COLUMN IF NOT EXISTS "ready_at" timestamp with time zone;--> statement-breakpoint
2
+ ALTER TABLE "invoices" ADD COLUMN IF NOT EXISTS "ready_by" uuid;--> statement-breakpoint
3
+ ALTER TABLE "invoices" ADD COLUMN IF NOT EXISTS "send_state" text;--> statement-breakpoint
4
+ ALTER TABLE "invoices" ADD COLUMN IF NOT EXISTS "send_error" jsonb;--> statement-breakpoint
5
+ ALTER TABLE "invoices" ADD COLUMN IF NOT EXISTS "send_requested_by" uuid;--> statement-breakpoint
6
+ ALTER TABLE "invoices" ADD COLUMN IF NOT EXISTS "send_updated_at" timestamp with time zone;--> statement-breakpoint
7
+ DO $flow_guard$ BEGIN
8
+ 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;
9
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
10
+ END $flow_guard$;--> statement-breakpoint
11
+ DO $flow_guard$ BEGIN
12
+ 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;
13
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
14
+ END $flow_guard$;--> statement-breakpoint
15
+ DO $flow_guard$ BEGIN
16
+ ALTER TABLE "invoices" ADD CONSTRAINT "invoices_send_state_valid" CHECK ("invoices"."send_state" is null or "invoices"."send_state" in ('queued', 'sent', 'failed'));
17
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
18
+ END $flow_guard$;
@@ -0,0 +1,35 @@
1
+ CREATE TABLE IF NOT EXISTS "line_mappings" (
2
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
3
+ "organisation_id" uuid NOT NULL,
4
+ "kind" text NOT NULL,
5
+ "match" text DEFAULT 'exact' NOT NULL,
6
+ "label" text NOT NULL,
7
+ "unit_amount_cents" integer,
8
+ "item_code" text,
9
+ "account_code" text,
10
+ "tax_code" text,
11
+ "description" text,
12
+ "enabled" boolean DEFAULT true NOT NULL,
13
+ "origin" text DEFAULT 'person' NOT NULL,
14
+ "updated_by" uuid,
15
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
16
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL,
17
+ CONSTRAINT "line_mappings_kind_valid" CHECK ("line_mappings"."kind" in ('alias', 'price_alias', 'saved', 'account')),
18
+ CONSTRAINT "line_mappings_match_valid" CHECK ("line_mappings"."match" in ('exact', 'starts_with')),
19
+ CONSTRAINT "line_mappings_tax_code_valid" CHECK ("line_mappings"."tax_code" is null or "line_mappings"."tax_code" in ('GST', 'GST_FREE')),
20
+ CONSTRAINT "line_mappings_target" CHECK (("line_mappings"."kind" = 'account' and "line_mappings"."account_code" is not null) or ("line_mappings"."kind" <> 'account' and "line_mappings"."item_code" is not null)),
21
+ CONSTRAINT "line_mappings_price" CHECK ("line_mappings"."kind" not in ('price_alias', 'saved') or "line_mappings"."unit_amount_cents" is not null),
22
+ CONSTRAINT "line_mappings_label_valid" CHECK (length("line_mappings"."label") between 1 and 200)
23
+ );
24
+ --> statement-breakpoint
25
+ ALTER TABLE "invoice_lines" ADD COLUMN IF NOT EXISTS "account_code" text;--> statement-breakpoint
26
+ ALTER TABLE "price_items" ADD COLUMN IF NOT EXISTS "description" text;--> statement-breakpoint
27
+ DO $flow_guard$ BEGIN
28
+ ALTER TABLE "line_mappings" ADD CONSTRAINT "line_mappings_organisation_id_organisations_id_fk" FOREIGN KEY ("organisation_id") REFERENCES "public"."organisations"("id") ON DELETE no action ON UPDATE no action;
29
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
30
+ END $flow_guard$;--> statement-breakpoint
31
+ DO $flow_guard$ BEGIN
32
+ ALTER TABLE "line_mappings" ADD CONSTRAINT "line_mappings_updated_by_people_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."people"("id") ON DELETE set null ON UPDATE no action;
33
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
34
+ END $flow_guard$;--> statement-breakpoint
35
+ CREATE UNIQUE INDEX IF NOT EXISTS "line_mappings_unique" ON "line_mappings" USING btree ("organisation_id","kind","match","label",coalesce("unit_amount_cents", -1));