@arcforgelabs/flow 2026.10.37 → 2026.10.39

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 (57) hide show
  1. package/README.md +2 -1
  2. package/npm-shrinkwrap.json +2 -2
  3. package/package.json +4 -2
  4. package/server/api/custom-fields.js +27 -9
  5. package/server/api/invoicing.js +198 -51
  6. package/server/app.js +5 -2
  7. package/server/core/alerts.js +1 -1
  8. package/server/core/automations.js +1 -1
  9. package/server/core/custom-fields.js +89 -3
  10. package/server/core/job-board.js +2 -1
  11. package/server/core/job-match.js +241 -0
  12. package/server/core/ledger.js +109 -8
  13. package/server/core/price-list.js +27 -2
  14. package/server/core/read-before-act.js +3 -2
  15. package/server/core/sending.js +36 -18
  16. package/server/db/doctor.js +18 -19
  17. package/server/db/index.js +74 -3
  18. package/server/db/migrate-all.js +3 -4
  19. package/server/db/migrations/0020_custom_field_colours.sql +21 -0
  20. package/server/db/migrations/meta/0020_snapshot.json +4377 -0
  21. package/server/db/migrations/meta/_journal.json +7 -0
  22. package/server/db/schema.js +14 -0
  23. package/server/integrations/call-guard.js +148 -0
  24. package/server/integrations/port.js +3 -2
  25. package/server/integrations/schema.js +9 -5
  26. package/server/integrations/seazona/feed.js +4 -1
  27. package/server/integrations/seazona/settings.js +8 -4
  28. package/server/integrations/xero/drafts.js +4 -4
  29. package/server/integrations/xero/fit.js +108 -0
  30. package/server/integrations/xero/import.js +6 -1
  31. package/server/integrations/xero/index.js +6 -2
  32. package/server/integrations/xero/ledger.js +3 -0
  33. package/server/integrations/xero/links.js +293 -0
  34. package/server/integrations/xero/migrations/0008_xero_invoice_links.sql +26 -0
  35. package/server/integrations/xero/migrations/0009_xero_sent_to_contact.sql +3 -0
  36. package/server/integrations/xero/migrations/meta/_journal.json +14 -0
  37. package/server/integrations/xero/mirror.js +4 -3
  38. package/server/integrations/xero/reconcile.js +42 -12
  39. package/server/integrations/xero/routes.js +66 -7
  40. package/server/integrations/xero/settings.js +6 -5
  41. package/server/ui/app-page.js +1 -1
  42. package/server/ui/app.js +89 -3
  43. package/server/ui/bulk-bar.js +4 -3
  44. package/server/ui/custom-fields.js +252 -94
  45. package/server/ui/flow.css +326 -29
  46. package/server/ui/invoice-notes.js +2 -2
  47. package/server/ui/invoicing.js +92 -137
  48. package/server/ui/job-board.js +9 -7
  49. package/server/ui/job-change.js +23 -12
  50. package/server/ui/job-view.js +189 -31
  51. package/server/ui/jobs.js +738 -70
  52. package/server/ui/price-list.js +43 -86
  53. package/server/ui/price-match.js +93 -0
  54. package/server/ui/price-reconcile.js +357 -0
  55. package/server/ui/reconcile.js +275 -0
  56. package/server/ui/routes.js +1 -1
  57. package/server/ui/xero-import.js +329 -0
@@ -3,19 +3,19 @@ import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm';
3
3
  import { billingObligations, invoiceLines, invoices } from '../db/schema.js';
4
4
  import { FlowError } from './errors.js';
5
5
  import { issueInvoice } from './invoices.js';
6
+ import { CHANGED_REASON, diffJobLines, STATUS_BACK_REASON } from './ledger.js';
6
7
  import { soleOrganisation } from './pipeline.js';
7
8
  import { needsRead, readAccountingFirst, readBeforeAct, READS_PER_PASS } from './read-before-act.js';
8
9
  import { deadHoldSince, deadSince, enqueueJob, runNextJob } from './queue.js';
9
10
 
10
- // Drafts, Problem, Ready and Send all ready (docs/INVOICE_SENDING.md).
11
+ // Pending, Ready and Send all ready (docs/INVOICE_SENDING.md).
11
12
  //
12
13
  // Flow checks every draft. It checks its own data first: the draft has lines,
13
14
  // a total above zero, and a price item (or, for a line such as a rush
14
15
  // charge, an account) on every line. When an accounting
15
16
  // system is connected, its own checks are added (for Xero: the customer
16
17
  // matches one Xero contact, every item is in Xero). A draft with a problem is
17
- // in Problem. A person marks a draft without one ready. A saved change keeps
18
- // it ready; only Move to Drafts (markNotReady) takes it back.
18
+ // Pending (the "problem" view). A draft without one is Ready.
19
19
  //
20
20
  // Send all ready issues every ready draft (each gets its number) and, when an
21
21
  // accounting system is connected, queues it to go there. The send worker
@@ -35,19 +35,35 @@ const LEASE_MS = 15 * 60 * 1000;
35
35
  // Outcomes that mean the accounting system holds the invoice.
36
36
  const SENT = new Set(['created', 'reconciled', 'existing', 'already_in_xero']);
37
37
 
38
- const problem = (code, message) => ({ code, message });
38
+ // `short` is the few words the Invoices list shows; `message` says how to fix it.
39
+ const problem = (code, message, short = null) => ({ code, message, ...(short ? { short } : {}) });
39
40
 
40
41
  // Flow's own checks of one draft. `lines` are its invoice lines.
41
42
  export function draftProblems(invoice, lines) {
42
43
  const problems = [];
43
- if (!lines.length) problems.push(problem('no_lines', 'The invoice has no lines.'));
44
- else if (invoice.totalCents <= 0) problems.push(problem('no_amount', 'The total is $0.00 or less.'));
44
+ if (!lines.length) problems.push(problem('no_lines', 'The invoice has no lines.', 'No lines'));
45
+ else if (invoice.totalCents <= 0) problems.push(problem('no_amount', 'The total is $0.00 or less.', 'Total $0'));
45
46
  for (const line of lines) {
46
- if (!line.priceItemId && !line.accountCode) problems.push(problem('line_no_item', `Line ${line.position} has no price item.`));
47
+ if (!line.priceItemId && !line.accountCode) problems.push(problem('line_no_item', `Line ${line.position} has no price item.`, `Line ${line.position}: no item`));
47
48
  }
48
49
  return problems;
49
50
  }
50
51
 
52
+ // The few words for a job in review: what changed, for this job.
53
+ export function reviewShort(obligation) {
54
+ if (obligation.holdReason === STATUS_BACK_REASON) return 'Status moved back';
55
+ if (obligation.holdReason !== CHANGED_REASON) return /deleted/.test(obligation.holdReason ?? '') ? 'Job deleted' : 'Job needs a look';
56
+ const basis = obligation.draftBasis;
57
+ if (!basis) return 'Job changed';
58
+ const changes = diffJobLines(basis.lines ?? [], obligation.lines ?? []);
59
+ const words = changes.map((c) => (['added', 'removed'].includes(c.change)
60
+ ? `Line ${c.position} ${c.change}`
61
+ : `Line ${c.position} ${{ item: 'item', description: 'text', quantity: 'qty', price: 'price' }[c.fields[0]] ?? 'changed'}`));
62
+ if ((basis.contactId ?? null) !== (obligation.contactId ?? null)) words.unshift('Client changed');
63
+ if (!words.length) return 'Job changed';
64
+ return words.length > 1 ? `${words[0]} +${words.length - 1}` : words[0];
65
+ }
66
+
51
67
  // Flow's checks, and the accounting system's, for some drafts. Answers
52
68
  // Map(id -> problems) holding only the drafts that have one.
53
69
  async function checkDrafts(db, organisationId, drafts, accounting) {
@@ -63,11 +79,11 @@ async function checkDrafts(db, organisationId, drafts, accounting) {
63
79
  }
64
80
  // A draft whose job the ledger sent to review (the job changed after the
65
81
  // draft was made, or the work system deleted it) waits for a person.
66
- const reviewed = await db.select({ invoiceId: billingObligations.invoiceId, reason: billingObligations.holdReason })
67
- .from(billingObligations)
82
+ const reviewed = await db.select().from(billingObligations)
68
83
  .where(and(eq(billingObligations.organisationId, organisationId), inArray(billingObligations.invoiceId, ids), eq(billingObligations.holdState, 'review')));
69
- for (const { invoiceId, reason } of reviewed) {
70
- const found = problem('job_review', `The job needs a look before this invoice goes: ${reason ?? 'it was sent to review'}.`);
84
+ for (const obligation of reviewed) {
85
+ const { invoiceId, holdReason: reason } = obligation;
86
+ const found = problem('job_review', `The job needs a look before this invoice goes: ${reason ?? 'it was sent to review'}.`, reviewShort(obligation));
71
87
  problems.set(invoiceId, [...(problems.get(invoiceId) ?? []), found]);
72
88
  }
73
89
  if (accounting) {
@@ -80,8 +96,8 @@ async function checkDrafts(db, organisationId, drafts, accounting) {
80
96
  }
81
97
 
82
98
  // The views of the Invoices page. `accounting` is the accounting adapter, or
83
- // null. Every draft is in one view: Problem (a check failed), Ready (a person
84
- // marked it ready) or To check (the rest). An issued invoice is Sending while
99
+ // null. Every draft is in one view: Problem (a check failed; the Pending tab)
100
+ // or Ready (its checks pass). An issued invoice is Sending while
85
101
  // queued, and a Problem when the accounting system refused it. Answers
86
102
  // { toCheck: [ids], ready: [ids], problems: Map(id -> problems), sending: Set(ids) }.
87
103
  export async function invoiceViews(db, organisationId, { accounting = null } = {}) {
@@ -96,12 +112,14 @@ export async function invoiceViews(db, organisationId, { accounting = null } = {
96
112
  .orderBy(desc(invoices.sendUpdatedAt));
97
113
  for (const row of issued) {
98
114
  if (row.sendState === 'queued') sending.add(row.id);
99
- else problems.set(row.id, [row.sendError ?? problem('send_failed', 'Sending failed.')]);
115
+ else problems.set(row.id, [row.sendError ?? problem('send_failed', 'Sending failed.', 'Send failed')]);
100
116
  }
117
+ // A draft whose checks pass is ready (Samuel, 2026-10-20): no person
118
+ // needs to mark it. toCheck stays for callers that still read it.
101
119
  const clean = drafts.filter((d) => !problems.has(d.id));
102
120
  return {
103
- toCheck: clean.filter((d) => !d.readyAt).map((d) => d.id),
104
- ready: clean.filter((d) => d.readyAt).map((d) => d.id),
121
+ toCheck: [],
122
+ ready: clean.map((d) => d.id),
105
123
  problems,
106
124
  sending,
107
125
  };
@@ -112,11 +130,11 @@ export async function invoiceView(db, organisationId, invoice, { accounting = nu
112
130
  if (invoice.status === 'void') return { view: null, problems: [] };
113
131
  if (invoice.status === 'issued') {
114
132
  if (invoice.sendState === 'queued') return { view: 'sending', problems: [] };
115
- if (invoice.sendState === 'failed') return { view: 'problem', problems: [invoice.sendError ?? problem('send_failed', 'Sending failed.')] };
133
+ if (invoice.sendState === 'failed') return { view: 'problem', problems: [invoice.sendError ?? problem('send_failed', 'Sending failed.', 'Send failed')] };
116
134
  return { view: null, problems: [] };
117
135
  }
118
136
  const found = (await checkDrafts(db, organisationId, [invoice], accounting)).get(invoice.id) ?? [];
119
- return { view: found.length ? 'problem' : invoice.readyAt ? 'ready' : 'to_check', problems: found };
137
+ return { view: found.length ? 'problem' : 'ready', problems: found };
120
138
  }
121
139
 
122
140
  // Mark ready: a person checked these drafts. A draft with a problem stays
@@ -12,12 +12,10 @@ import { migrationsTable } from '../integrations/schema.js';
12
12
  // - the tables in public that neither declares (another app's, or an
13
13
  // earlier Flow's).
14
14
  //
15
- // On Replit it runs on the workspace's database after each update
16
- // (scripts/replit-deploy.sh), and compares that database with the live app's
17
- // GET /health, which reports the same migration counts. Replit's publish step
18
- // offers to make production's tables match the workspace's, so the
19
- // comparison says what that step will show (docs/REPLIT.md, "The workspace
20
- // updater"). Anyone who runs Flow can run it: `flow-server doctor`.
15
+ // On Replit, `flow-server doctor --live <address>` compares the workspace's
16
+ // database with the live app's GET /health, which reports the same migration
17
+ // counts, and says what Replit's publish step will show (docs/REPLIT.md, "The
18
+ // workspace database"). Anyone who runs Flow can run it: `flow-server doctor`.
21
19
 
22
20
  const CORE_MIGRATIONS = fileURLToPath(new URL('./migrations', import.meta.url));
23
21
  const CORE_TABLE = '__drizzle_migrations';
@@ -114,26 +112,27 @@ export async function diagnoseDatabase(pool, { adapters = [] } = {}) {
114
112
  };
115
113
  }
116
114
 
117
- // What Replit's publish step will offer, from the workspace's report and the
115
+ // What Replit's publish step will show, from the workspace's report and the
118
116
  // live app's migration counts (GET /health's schema.migrations, or null when
119
- // the live app could not say).
117
+ // the live app could not say). Flow migrates production itself when it
118
+ // starts, so the answer to "Development database changes detected" is always
119
+ // Cancel (docs/REPLIT.md, "The workspace database").
120
120
  //
121
- // broken the workspace database is not at this build: do not publish
122
- // behind production has migrations the workspace lacks: the list would
123
- // drop them, so Cancel and update the workspace first
124
- // adds the workspace is ahead: the list holds additions only; approve
125
121
  // same the two match: no list
126
- // unknown the live app did not say: approve additions, Cancel a DROP
122
+ // behind production has migrations the workspace lacks: follow production
123
+ // ahead the workspace has migrations production does not run
124
+ // unknown the live app did not say
125
+ export const CANCEL_RULE = 'If Replit shows "Development database changes detected", press Cancel.';
126
+
127
127
  export function publishVerdict(report, live) {
128
- if (!report.ok) return { verdict: 'broken', say: 'The workspace database is not at this build. Do not publish. Update the workspace again.' };
129
- if (!live) return { verdict: 'unknown', say: 'The live app did not report its migrations. Publish: approve a list of additions; Cancel any DROP.' };
128
+ if (!live) return { verdict: 'unknown', say: `The live app did not report its migrations. ${CANCEL_RULE}` };
130
129
  const behind = Object.entries(live).filter(([id, n]) => n > (report.migrations[id] ?? 0)).map(([id]) => id);
131
- if (behind.length) {
132
- return { verdict: 'behind', say: `Production has ${behind.join(', ')} migrations the workspace lacks. Do not publish: Replit would offer to DROP them. Update the workspace first.` };
133
- }
134
130
  const ahead = Object.entries(report.migrations).filter(([id, n]) => n > (live[id] ?? 0)).map(([id]) => id);
135
131
  if (ahead.length) {
136
- return { verdict: 'adds', say: `The workspace is ahead of production (${ahead.join(', ')}). Publish: Replit offers to add tables or columns only. Approve.` };
132
+ return { verdict: 'ahead', say: `The workspace has ${ahead.join(', ')} migrations production does not run. ${CANCEL_RULE} Production applies its own migrations when it starts.` };
133
+ }
134
+ if (behind.length) {
135
+ return { verdict: 'behind', say: `Production has ${behind.join(', ')} migrations the workspace lacks. Run npm run db:follow-production (or press Run). ${CANCEL_RULE}` };
137
136
  }
138
137
  return { verdict: 'same', say: 'The workspace and production are at the same migrations. Publish: Replit shows no database changes.' };
139
138
  }
@@ -6,6 +6,13 @@ import * as schema from './schema.js';
6
6
 
7
7
  const migrationsFolder = fileURLToPath(new URL('./migrations', import.meta.url));
8
8
 
9
+ // Session advisory lock held while any migration runs (Flow's own, each
10
+ // integration's, and the Replit workspace sync), so two processes sharing one
11
+ // database never apply the same migration at once. Field uses the same
12
+ // pattern with its own key.
13
+ export const MIGRATION_LOCK_KEY = 7_139_000_101n;
14
+ const DEFAULT_MIGRATION_LOCK_TIMEOUT_MS = 300_000;
15
+
9
16
  // Opens Flow's PostgreSQL database. The caller owns the pool: call close().
10
17
  export function openDatabase({ url = process.env.DATABASE_URL, max = 10 } = {}) {
11
18
  if (!url) throw new Error('flow: DATABASE_URL is not set');
@@ -14,8 +21,72 @@ export function openDatabase({ url = process.env.DATABASE_URL, max = 10 } = {})
14
21
  return { db, pool, close: () => pool.end() };
15
22
  }
16
23
 
17
- export function migrateDatabase(db) {
18
- return migrate(db, { migrationsFolder });
24
+ // A transaction-mode pooler (PgBouncer, Neon's "-pooler" hosts) does not keep
25
+ // a session, so it cannot hold a session lock. Neon names a pooled endpoint
26
+ // by adding "-pooler" to the endpoint id; the same host without it is the
27
+ // direct endpoint, which migrations use instead (as Field does).
28
+ const POOLER_HOST = /-pooler(?=\.|$)/i;
29
+
30
+ export function directUrl(url) {
31
+ let parsed;
32
+ try {
33
+ parsed = new URL(url);
34
+ } catch {
35
+ return null;
36
+ }
37
+ if (!POOLER_HOST.test(parsed.hostname)) return null;
38
+ parsed.hostname = parsed.hostname.replace(POOLER_HOST, '');
39
+ return parsed.toString();
40
+ }
41
+
42
+ function migrationLockTimeoutMs(env = process.env) {
43
+ const value = Number.parseInt(env.FLOW_DB_MIGRATION_LOCK_TIMEOUT_MS || '', 10);
44
+ return Number.isInteger(value) && value >= 0 ? value : DEFAULT_MIGRATION_LOCK_TIMEOUT_MS;
45
+ }
46
+
47
+ // Runs fn(lockedDb) under MIGRATION_LOCK_KEY. The lock and fn share one
48
+ // connection taken from db's pool (or the pooler's direct endpoint), because
49
+ // a session lock belongs to one connection. It waits up to
50
+ // FLOW_DB_MIGRATION_LOCK_TIMEOUT_MS (300000) for another process to finish.
51
+ export async function withMigrationLock(db, fn, { timeoutMs = migrationLockTimeoutMs() } = {}) {
52
+ const source = db?.$client;
53
+ if (!source) throw new Error('flow: a migration needs a PostgreSQL database from openDatabase');
54
+ const direct = source instanceof pg.Pool ? directUrl(source.options?.connectionString) : null;
55
+ let client;
56
+ if (direct) {
57
+ client = new pg.Client({ connectionString: direct });
58
+ await client.connect();
59
+ } else {
60
+ client = source instanceof pg.Pool ? await source.connect() : source;
61
+ }
62
+ let broken = false;
63
+ try {
64
+ const deadline = Date.now() + timeoutMs;
65
+ for (;;) {
66
+ const { rows } = await client.query('select pg_try_advisory_lock($1::bigint) as locked', [MIGRATION_LOCK_KEY.toString()]);
67
+ if (rows[0].locked) break;
68
+ if (Date.now() >= deadline) {
69
+ throw new Error(`flow: another process held the migration lock (advisory lock ${MIGRATION_LOCK_KEY}) for over ${timeoutMs} ms; is a migration hung? (FLOW_DB_MIGRATION_LOCK_TIMEOUT_MS)`);
70
+ }
71
+ await new Promise((wake) => setTimeout(wake, 250));
72
+ }
73
+ try {
74
+ return await fn(drizzle(client, { schema }));
75
+ } finally {
76
+ // A connection that cannot unlock is closed, which also frees the lock.
77
+ await client.query('select pg_advisory_unlock($1::bigint)', [MIGRATION_LOCK_KEY.toString()]).catch(() => { broken = true; });
78
+ }
79
+ } finally {
80
+ if (direct) await client.end().catch(() => {});
81
+ else if (client !== source) client.release(broken);
82
+ }
83
+ }
84
+
85
+ // Applies the migrations in `folder` (this build's own by default) under the
86
+ // migration lock. `table` names the journal: drizzle.__drizzle_migrations for
87
+ // Flow's own, one per integration (server/integrations/schema.js).
88
+ export function migrateDatabase(db, { folder = migrationsFolder, table } = {}) {
89
+ return withMigrationLock(db, (locked) => migrate(locked, { migrationsFolder: folder, ...(table ? { migrationsTable: table } : {}) }));
19
90
  }
20
91
 
21
- export { schema };
92
+ export { migrationsFolder, schema };
@@ -3,10 +3,9 @@ import { adapters as shipped } from '../integrations/adapters.js';
3
3
  import { migrateAdapterSchema } from '../integrations/schema.js';
4
4
 
5
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).
6
+ // of every adapter this build ships, whether or not it is on
7
+ // (`flow-server migrate`). Not for a Replit workspace: its database follows
8
+ // production instead (scripts/replit-workspace-db.js, docs/REPLIT.md).
10
9
  export async function migrateEverything(db, { adapters = shipped, log = () => {} } = {}) {
11
10
  await migrateDatabase(db);
12
11
  for (const adapter of adapters) {
@@ -0,0 +1,21 @@
1
+ CREATE TABLE IF NOT EXISTS "job_field_settings" (
2
+ "organisation_id" uuid PRIMARY KEY NOT NULL,
3
+ "group_colors" jsonb DEFAULT '{}'::jsonb NOT NULL,
4
+ "assignee_made_at" timestamp with time zone,
5
+ "updated_by" uuid,
6
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL
7
+ );
8
+ --> statement-breakpoint
9
+ ALTER TABLE "job_fields" ADD COLUMN IF NOT EXISTS "color" text;--> statement-breakpoint
10
+ DO $flow_guard$ BEGIN
11
+ ALTER TABLE "job_field_settings" ADD CONSTRAINT "job_field_settings_organisation_id_organisations_id_fk" FOREIGN KEY ("organisation_id") REFERENCES "public"."organisations"("id") ON DELETE no action ON UPDATE no action;
12
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
13
+ END $flow_guard$;--> statement-breakpoint
14
+ DO $flow_guard$ BEGIN
15
+ ALTER TABLE "job_field_settings" ADD CONSTRAINT "job_field_settings_updated_by_people_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."people"("id") ON DELETE set null ON UPDATE no action;
16
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
17
+ END $flow_guard$;--> statement-breakpoint
18
+ DO $flow_guard$ BEGIN
19
+ ALTER TABLE "job_fields" ADD CONSTRAINT "job_fields_color_valid" CHECK ("job_fields"."color" is null or "job_fields"."color" in ('gray', 'blue', 'teal', 'green', 'amber', 'red', 'purple', 'pink'));
20
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
21
+ END $flow_guard$;