@arcforgelabs/flow 2026.10.27 → 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 (48) hide show
  1. package/README.md +1 -0
  2. package/npm-shrinkwrap.json +2 -2
  3. package/package.json +2 -2
  4. package/server/api/invoicing.js +96 -40
  5. package/server/app.js +3 -1
  6. package/server/core/automations.js +14 -0
  7. package/server/core/invoice-rules.js +4 -2
  8. package/server/core/invoices.js +4 -2
  9. package/server/core/job-board.js +130 -0
  10. package/server/core/ledger.js +12 -5
  11. package/server/core/line-mapping.js +337 -0
  12. package/server/core/pipeline.js +9 -3
  13. package/server/core/sending.js +4 -3
  14. package/server/db/migrate-all.js +26 -0
  15. package/server/db/migrations/0010_invoicing_rules.sql +34 -13
  16. package/server/db/migrations/0011_invoice_rules.sql +11 -5
  17. package/server/db/migrations/0012_invoice_sending.sql +18 -9
  18. package/server/db/migrations/0013_line_mappings.sql +35 -0
  19. package/server/db/migrations/meta/0013_snapshot.json +3568 -0
  20. package/server/db/migrations/meta/_journal.json +7 -0
  21. package/server/db/schema.js +43 -0
  22. package/server/import/at-start.js +64 -2
  23. package/server/index.js +5 -1
  24. package/server/integrations/port.js +1 -0
  25. package/server/integrations/seazona/feed.js +62 -4
  26. package/server/integrations/seazona/index.js +7 -1
  27. package/server/integrations/seazona/lanes.js +4 -1
  28. package/server/integrations/seazona/migrations/0000_api_governor.sql +3 -3
  29. package/server/integrations/seazona/migrations/0001_mirror.sql +9 -9
  30. package/server/integrations/xero/drafts.js +4 -1
  31. package/server/integrations/xero/lookup.js +6 -2
  32. package/server/integrations/xero/migrations/0000_xero_connections.sql +1 -1
  33. package/server/integrations/xero/migrations/0001_xero_catalogue.sql +5 -5
  34. package/server/integrations/xero/migrations/0002_xero_invoice_pushes.sql +2 -2
  35. package/server/integrations/xero/migrations/0003_xero_payment_cursors.sql +1 -1
  36. package/server/integrations/xero/migrations/0004_xero_mirror.sql +7 -7
  37. package/server/integrations/xero/migrations/0005_xero_send_modes.sql +12 -6
  38. package/server/ui/app-page.js +6 -4
  39. package/server/ui/app.js +111 -8
  40. package/server/ui/clerk-appearance.js +21 -0
  41. package/server/ui/flow.css +155 -30
  42. package/server/ui/invoicing-settings.js +2 -0
  43. package/server/ui/job-board.js +155 -0
  44. package/server/ui/jobs.js +220 -91
  45. package/server/ui/line-mappings.js +60 -0
  46. package/server/ui/monitoring.js +15 -7
  47. package/server/ui/routes.js +1 -1
  48. package/server/ui/search.js +145 -0
@@ -92,6 +92,13 @@
92
92
  "when": 1792414436676,
93
93
  "tag": "0012_invoice_sending",
94
94
  "breakpoints": true
95
+ },
96
+ {
97
+ "idx": 13,
98
+ "version": "7",
99
+ "when": 1792414148360,
100
+ "tag": "0013_line_mappings",
101
+ "breakpoints": true
95
102
  }
96
103
  ]
97
104
  }
@@ -59,6 +59,9 @@ export const priceItems = pgTable('price_items', {
59
59
  organisationId: organisationId().references(() => organisations.id),
60
60
  code: text('code').notNull(),
61
61
  name: text('name').notNull(),
62
+ // The accounting system's longer text for the item, when it has one. Line
63
+ // mapping (server/core/line-mapping.js) compares a work line with it too.
64
+ description: text('description'),
62
65
  unitAmountCents: integer('unit_amount_cents').notNull(),
63
66
  taxCode: text('tax_code').notNull().default('GST'),
64
67
  archivedAt: timestamp('archived_at', { withTimezone: true }),
@@ -120,6 +123,9 @@ export const invoiceLines = pgTable('invoice_lines', {
120
123
  quantity: integer('quantity').notNull(),
121
124
  unitAmountCents: integer('unit_amount_cents').notNull(),
122
125
  taxCode: text('tax_code').notNull(),
126
+ // A line with no price item may name the accounting account it books to,
127
+ // for example a surcharge (server/core/line-mapping.js).
128
+ accountCode: text('account_code'),
123
129
  lineAmountCents: integer('line_amount_cents').notNull(),
124
130
  taxCents: integer('tax_cents').notNull(),
125
131
  }, (t) => [
@@ -674,3 +680,40 @@ export const automations = pgTable('automations', {
674
680
  }, (t) => [
675
681
  primaryKey({ name: 'automations_pk', columns: [t.organisationId, t.key] }),
676
682
  ]);
683
+
684
+ // Line mappings (docs/LINE_MAPPING.md): how a work system's line label finds
685
+ // the lab's price item, as rows the lab edits. `kind` says which step of the
686
+ // line mapping routine uses the row:
687
+ // alias label (exact, or the start of it) -> price item code
688
+ // price_alias label and unit price -> price item code
689
+ // saved label and unit price -> price item code, chosen by a person
690
+ // account label -> a line with no price item, booked to an account
691
+ // `label` is stored in the lookup form (lineLookupText), so it matches in any
692
+ // case, spacing or punctuation.
693
+ export const LINE_MAPPING_KINDS = ['alias', 'price_alias', 'saved', 'account'];
694
+ export const lineMappings = pgTable('line_mappings', {
695
+ id: id(),
696
+ organisationId: organisationId().references(() => organisations.id),
697
+ kind: text('kind').notNull(),
698
+ match: text('match').notNull().default('exact'),
699
+ label: text('label').notNull(),
700
+ unitAmountCents: integer('unit_amount_cents'),
701
+ itemCode: text('item_code'),
702
+ accountCode: text('account_code'),
703
+ taxCode: text('tax_code'),
704
+ description: text('description'),
705
+ enabled: boolean('enabled').notNull().default(true),
706
+ // Who made it: a person, a setup profile, or the earlier codebase's rules.
707
+ origin: text('origin').notNull().default('person'),
708
+ updatedBy: uuid('updated_by').references(() => people.id, { onDelete: 'set null' }),
709
+ createdAt: createdAt(),
710
+ updatedAt: updatedAt(),
711
+ }, (t) => [
712
+ uniqueIndex('line_mappings_unique').on(t.organisationId, t.kind, t.match, t.label, sql`coalesce(${t.unitAmountCents}, -1)`),
713
+ check('line_mappings_kind_valid', sql`${t.kind} in ('alias', 'price_alias', 'saved', 'account')`),
714
+ check('line_mappings_match_valid', sql`${t.match} in ('exact', 'starts_with')`),
715
+ check('line_mappings_tax_code_valid', sql`${t.taxCode} is null or ${t.taxCode} in ('GST', 'GST_FREE')`),
716
+ check('line_mappings_target', sql`(${t.kind} = 'account' and ${t.accountCode} is not null) or (${t.kind} <> 'account' and ${t.itemCode} is not null)`),
717
+ check('line_mappings_price', sql`${t.kind} not in ('price_alias', 'saved') or ${t.unitAmountCents} is not null`),
718
+ check('line_mappings_label_valid', sql`length(${t.label}) between 1 and 200`),
719
+ ]);
@@ -1,5 +1,6 @@
1
- import { sql } from 'drizzle-orm';
2
- import { organisations, priorLinks } from '../db/schema.js';
1
+ import { and, eq, isNull, sql } from 'drizzle-orm';
2
+ import { lineMappings, organisations, priceItems, priorLinks } from '../db/schema.js';
3
+ import { lineLookupText, remapJobs } from '../core/line-mapping.js';
3
4
  import { applyImport, planImport, readEarlierFlow, summarisePlan } from './earlier-flow.js';
4
5
 
5
6
  // The import from the earlier codebase, run once by the server itself. On a
@@ -118,3 +119,64 @@ export async function importEarlierLinksAtStart({ db, pool, settings, log = cons
118
119
  log.info?.(`[import] links from schema "${settings.schema}": ${JSON.stringify(counts)}`);
119
120
  return counts;
120
121
  }
122
+
123
+ // Carries the earlier app's line mapping knowledge into Flow, at every start
124
+ // while the import settings are set, in the same read-only way:
125
+ // - each label and price a person matched to an item becomes a "saved" line
126
+ // mapping (server/core/line-mapping.js), unless the lab already has one;
127
+ // - each price item with no description takes the accounting item's own.
128
+ // Answers counts, or null when there is nothing to read.
129
+ const EARLIER_RULES = 'xero_item_match_rules';
130
+ const EARLIER_ITEMS = 'xero_catalog_items';
131
+
132
+ export async function importEarlierMappingsAtStart({ db, pool, settings, log = console }) {
133
+ if (!settings) return null;
134
+ const { rows: labs } = await db.execute(sql`select id, name from ${organisations}`);
135
+ const organisation = labs.find((lab) => lab.name === settings.organisationName) ?? (labs.length === 1 ? labs[0] : null);
136
+ if (!organisation) return null;
137
+
138
+ let rules = [];
139
+ let items = [];
140
+ const client = await pool.connect();
141
+ try {
142
+ await client.query('BEGIN TRANSACTION READ ONLY');
143
+ await client.query(`SET LOCAL search_path TO "${settings.schema}"`);
144
+ const present = async (table) => (await client.query('select to_regclass($1) is not null as present', [table])).rows[0].present;
145
+ if (await present(EARLIER_RULES)) {
146
+ ({ rows: rules } = await client.query(`select source_lookup_text, unit_amount_cents, xero_item_code from ${EARLIER_RULES}`));
147
+ }
148
+ if (await present(EARLIER_ITEMS)) {
149
+ ({ rows: items } = await client.query(`select item_code, description from ${EARLIER_ITEMS} where coalesce(description, '') <> ''`));
150
+ }
151
+ await client.query('COMMIT');
152
+ } catch (error) {
153
+ await client.query('ROLLBACK').catch(() => {});
154
+ throw new Error(`could not read mappings from schema "${settings.schema}": ${error.message}`);
155
+ } finally {
156
+ client.release();
157
+ }
158
+
159
+ const values = rules
160
+ .map((row) => ({
161
+ organisationId: organisation.id,
162
+ kind: 'saved',
163
+ match: 'exact',
164
+ label: lineLookupText(row.source_lookup_text),
165
+ unitAmountCents: Number(row.unit_amount_cents),
166
+ itemCode: String(row.xero_item_code ?? '').trim(),
167
+ origin: `earlier:${EARLIER_RULES}`,
168
+ }))
169
+ .filter((v) => v.label && v.label.length <= 200 && v.itemCode && Number.isInteger(v.unitAmountCents) && v.unitAmountCents >= 0);
170
+ const added = values.length ? (await db.insert(lineMappings).values(values).onConflictDoNothing().returning({ id: lineMappings.id })).length : 0;
171
+ let described = 0;
172
+ for (const item of items) {
173
+ const updated = await db.update(priceItems).set({ description: String(item.description).trim().slice(0, 1000) })
174
+ .where(and(eq(priceItems.organisationId, organisation.id), eq(priceItems.code, String(item.item_code).trim()), isNull(priceItems.description)))
175
+ .returning({ id: priceItems.id });
176
+ described += updated.length;
177
+ }
178
+ if (added) await remapJobs(db, organisation.id);
179
+ const counts = { rules: values.length, added, described };
180
+ log.info?.(`[import] mappings from schema "${settings.schema}": ${JSON.stringify(counts)}`);
181
+ return counts;
182
+ }
package/server/index.js CHANGED
@@ -5,7 +5,9 @@ import { seedFirstOwner } from './auth/people.js';
5
5
  import { createPersonAuth } from './auth/session.js';
6
6
  import { resolveSignInSettings } from './auth/settings.js';
7
7
  import { migrateDatabase, openDatabase } from './db/index.js';
8
- import { importEarlierAtStart, importEarlierLinksAtStart, resolveImportAtStart } from './import/at-start.js';
8
+ import {
9
+ importEarlierAtStart, importEarlierLinksAtStart, importEarlierMappingsAtStart, resolveImportAtStart,
10
+ } from './import/at-start.js';
9
11
  import { createMonitoredLog } from './core/monitoring.js';
10
12
  import { createPipelineWorker } from './core/pipeline.js';
11
13
  import { createSendWorker } from './core/sending.js';
@@ -66,6 +68,8 @@ try {
66
68
  // failure is logged and the start goes on.
67
69
  await importEarlierLinksAtStart({ db: database.db, pool: database.pool, settings: importAtStart })
68
70
  .catch((error) => console.error(`[import] ${error.message}`));
71
+ await importEarlierMappingsAtStart({ db: database.db, pool: database.pool, settings: importAtStart })
72
+ .catch((error) => console.error(`[import] ${error.message}`));
69
73
  // What an owner set in Settings, Integrations, with the host's own
70
74
  // environment over it (server/integrations/setup.js).
71
75
  // Every part of Flow logs here: each error also goes to the installation's
@@ -18,6 +18,7 @@ export const CAPABILITIES = Object.freeze([
18
18
  'pushBill', // send an approved bill; the Flow record id is the idempotency key
19
19
  'pullPayments', // payments since a cursor, matched to invoices and bills
20
20
  'checkInvoice', // does the accounting system hold an invoice for a billable job? (the ledger's liveCheck)
21
+ 'listWork', // the work system's jobs from its copy in Flow, open and lately finished, for the Jobs board
21
22
  'listCustomers', // the system's customers, by id and name, for the identity map (Seazona: clients; Xero: contacts)
22
23
  'findInvoices', // sales invoices in the accounting system's copy, for the matcher; never a live call
23
24
  'health', // connection state for Settings
@@ -1,6 +1,6 @@
1
- import { eq, sql } from 'drizzle-orm';
1
+ import { and, desc, eq, gte, isNull, or, sql } from 'drizzle-orm';
2
2
  import { ledgerLinesFor, toLedgerHold, toLedgerJob } from './ledger.js';
3
- import { orderStatusLabel, parseSeazonaDate } from './map.js';
3
+ import { isFinalStatus, orderStatusLabel, parseSeazonaDate } from './map.js';
4
4
  import { seazonaClients, seazonaInvoices, seazonaOrders } from './schema.js';
5
5
 
6
6
  // The billing feed behind the port's pullWorkItems: every order that changed
@@ -104,12 +104,70 @@ export async function readBillableWork(db, { cursor = null, limit = 200 } = {})
104
104
  }
105
105
 
106
106
  // Seazona clients, by id and the company name an invoice bills. A client
107
- // with no company name is listed by its full name.
107
+ // with no company name is listed by its full name. A client the client list
108
+ // never gave (the list can be closed to an API client) is listed by the
109
+ // company name its orders carry, so its jobs still find their customer.
108
110
  export async function listClients(db) {
109
111
  const rows = await db.select({ id: seazonaClients.id, company: seazonaClients.company, fullName: seazonaClients.fullName, active: seazonaClients.active })
110
112
  .from(seazonaClients);
111
- return rows
113
+ const listed = rows
112
114
  .filter((r) => r.active !== false)
113
115
  .map((r) => ({ id: r.id, name: (r.company || r.fullName || '').trim() }))
114
116
  .filter((r) => r.name);
117
+ const known = new Set(rows.map((r) => r.id));
118
+ const fromOrders = await db.selectDistinctOn([seazonaOrders.clientId], { id: seazonaOrders.clientId, company: seazonaOrders.company })
119
+ .from(seazonaOrders)
120
+ .where(sql`${seazonaOrders.clientId} is not null and coalesce(${seazonaOrders.company}, '') <> '' and ${seazonaOrders.deletedInSourceAt} is null`)
121
+ .orderBy(seazonaOrders.clientId, sql`${seazonaOrders.lastChangedAt} desc`);
122
+ return [
123
+ ...listed,
124
+ ...fromOrders.filter((r) => !known.has(r.id)).map((r) => ({ id: r.id, name: r.company.trim() })),
125
+ ];
126
+ }
127
+
128
+ // A final order (shipped, delivered, cancelled; map.js isFinalStatus) stays on
129
+ // the Jobs board this long after it last changed.
130
+ export const WORK_FINISHED_DAYS = 30;
131
+ const WORK_LIMIT = 2000;
132
+ const FINAL_SQL = sql`coalesce(${seazonaOrders.status}, '') ~* '(shipped|delivered|cancelled|canceled|void|closed|archived|deleted)'`;
133
+
134
+ // The orders behind the port's listWork, for the Jobs board: every open
135
+ // order, and each final order that changed in the last WORK_FINISHED_DAYS
136
+ // days, newest change first. An order deleted in Seazona is never listed.
137
+ // The client name is the Seazona client's company (or full name), else the
138
+ // order's own company. `statuses` lists each status once, with the first time
139
+ // Flow saw an order in it, so the board can order its columns.
140
+ export async function listWork(db, { now = Date.now(), finishedDays = WORK_FINISHED_DAYS, limit = WORK_LIMIT } = {}) {
141
+ const since = new Date(now - finishedDays * 86_400_000);
142
+ const rows = await db.select({ order: seazonaOrders, company: seazonaClients.company, fullName: seazonaClients.fullName })
143
+ .from(seazonaOrders)
144
+ .leftJoin(seazonaClients, eq(seazonaClients.id, seazonaOrders.clientId))
145
+ .where(and(isNull(seazonaOrders.deletedInSourceAt), or(sql`not (${FINAL_SQL})`, gte(seazonaOrders.lastChangedAt, since))))
146
+ .orderBy(desc(seazonaOrders.lastChangedAt), seazonaOrders.id)
147
+ .limit(limit);
148
+ const firstSeen = new Map();
149
+ const items = rows.map(({ order, company, fullName }) => {
150
+ const status = orderStatusLabel(order) || null;
151
+ const seen = firstSeen.get(status);
152
+ if (status && (!seen || order.firstSeenAt < seen)) firstSeen.set(status, order.firstSeenAt);
153
+ return {
154
+ sourceSystem: 'seazona',
155
+ sourceJobRef: order.id,
156
+ // The Seazona order id is the case number. Never patient data.
157
+ caseRef: order.id,
158
+ status,
159
+ clientId: order.clientId,
160
+ clientName: (company || order.company || fullName || '').trim() || null,
161
+ practitioner: order.doctorName,
162
+ department: order.department,
163
+ orderedOn: parseSeazonaDate(order.orderedText),
164
+ dueOn: parseSeazonaDate(order.dueText),
165
+ lines: (order.lines ?? []).map((l) => ({ code: l.code ?? null, description: l.description, amountCents: l.amountCents ?? null })),
166
+ totalCents: order.subtotalCents,
167
+ };
168
+ });
169
+ return {
170
+ items,
171
+ statuses: [...firstSeen].map(([label, at]) => ({ label, final: isFinalStatus(label), firstSeenAt: at.toISOString() })),
172
+ };
115
173
  }
@@ -1,5 +1,5 @@
1
1
  import { createSeazonaClient } from './client.js';
2
- import { listClients, readBillableWork } from './feed.js';
2
+ import { listClients, listWork, readBillableWork } from './feed.js';
3
3
  import { readSeazonaSyncHealth, runSeazonaSync } from './lanes.js';
4
4
  import { createSeazonaScheduler } from './scheduler.js';
5
5
  import { describeSeazonaSettings, resolveSeazonaSettings } from './settings.js';
@@ -105,6 +105,12 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
105
105
  async listCustomers() {
106
106
  return listClients(current().db);
107
107
  },
108
+
109
+ // Open orders and orders finished lately, with their statuses, for the
110
+ // Jobs board. Reads Flow's copy only.
111
+ async listWork() {
112
+ return listWork(current().db, { now: now() });
113
+ },
108
114
  },
109
115
  };
110
116
  }
@@ -309,8 +309,11 @@ const laneRuns = {
309
309
  async clients(ctx) {
310
310
  const { client, sync, at } = ctx;
311
311
  // The first run reads the whole list; later runs read changes only.
312
+ // Seazona answers the whole list only to an empty `?lastModified=`; a
313
+ // bare path is a 404.
312
314
  const since = ctx.state?.cursorTime ? cursorSince(ctx, 0) : '';
313
- const items = listOrFail(await client.get('clients/', since ? { lastModified: since } : {}), 'client list', sync);
315
+ const reply = since ? await client.get('clients/', { lastModified: since }) : await client.get('clients/?lastModified=');
316
+ const items = listOrFail(reply, 'client list', sync);
314
317
  const result = await syncListWithDetail(ctx, {
315
318
  mirror: 'clients',
316
319
  what: 'client',
@@ -1,11 +1,11 @@
1
- CREATE TABLE "seazona_api_governor" (
1
+ CREATE TABLE IF NOT EXISTS "seazona_api_governor" (
2
2
  "client_key" text PRIMARY KEY NOT NULL,
3
3
  "next_eligible_at" timestamp with time zone DEFAULT 'epoch' NOT NULL,
4
4
  "cooldown_until" timestamp with time zone DEFAULT 'epoch' NOT NULL,
5
5
  "updated_at" timestamp with time zone DEFAULT now() NOT NULL
6
6
  );
7
7
  --> statement-breakpoint
8
- CREATE TABLE "seazona_api_requests" (
8
+ CREATE TABLE IF NOT EXISTS "seazona_api_requests" (
9
9
  "id" bigserial PRIMARY KEY NOT NULL,
10
10
  "client_key" text NOT NULL,
11
11
  "workflow" text NOT NULL,
@@ -17,4 +17,4 @@ CREATE TABLE "seazona_api_requests" (
17
17
  "is_retry" boolean DEFAULT false NOT NULL
18
18
  );
19
19
  --> statement-breakpoint
20
- CREATE INDEX "seazona_api_requests_window" ON "seazona_api_requests" USING btree ("client_key","admitted_at");
20
+ CREATE INDEX IF NOT EXISTS "seazona_api_requests_window" ON "seazona_api_requests" USING btree ("client_key","admitted_at");
@@ -1,4 +1,4 @@
1
- CREATE TABLE "seazona_clients" (
1
+ CREATE TABLE IF NOT EXISTS "seazona_clients" (
2
2
  "id" text PRIMARY KEY NOT NULL,
3
3
  "account_number" text,
4
4
  "company" text,
@@ -17,7 +17,7 @@ CREATE TABLE "seazona_clients" (
17
17
  "last_changed_at" timestamp with time zone NOT NULL
18
18
  );
19
19
  --> statement-breakpoint
20
- CREATE TABLE "seazona_invoices" (
20
+ CREATE TABLE IF NOT EXISTS "seazona_invoices" (
21
21
  "invoice_number" text PRIMARY KEY NOT NULL,
22
22
  "seazona_id" text,
23
23
  "client_id" text,
@@ -41,7 +41,7 @@ CREATE TABLE "seazona_invoices" (
41
41
  "last_changed_at" timestamp with time zone NOT NULL
42
42
  );
43
43
  --> statement-breakpoint
44
- CREATE TABLE "seazona_lane_state" (
44
+ CREATE TABLE IF NOT EXISTS "seazona_lane_state" (
45
45
  "lane" text PRIMARY KEY NOT NULL,
46
46
  "status" text DEFAULT 'idle' NOT NULL,
47
47
  "cursor_time" timestamp with time zone,
@@ -56,7 +56,7 @@ CREATE TABLE "seazona_lane_state" (
56
56
  "updated_at" timestamp with time zone DEFAULT now() NOT NULL
57
57
  );
58
58
  --> statement-breakpoint
59
- CREATE TABLE "seazona_order_read_failures" (
59
+ CREATE TABLE IF NOT EXISTS "seazona_order_read_failures" (
60
60
  "order_id" text PRIMARY KEY NOT NULL,
61
61
  "failure_count" integer NOT NULL,
62
62
  "last_attempt_at" timestamp with time zone NOT NULL,
@@ -66,7 +66,7 @@ CREATE TABLE "seazona_order_read_failures" (
66
66
  "quarantined_until" timestamp with time zone
67
67
  );
68
68
  --> statement-breakpoint
69
- CREATE TABLE "seazona_orders" (
69
+ CREATE TABLE IF NOT EXISTS "seazona_orders" (
70
70
  "id" text PRIMARY KEY NOT NULL,
71
71
  "invoice_number" text,
72
72
  "client_id" text,
@@ -88,7 +88,7 @@ CREATE TABLE "seazona_orders" (
88
88
  "last_changed_at" timestamp with time zone NOT NULL
89
89
  );
90
90
  --> statement-breakpoint
91
- CREATE TABLE "seazona_reference" (
91
+ CREATE TABLE IF NOT EXISTS "seazona_reference" (
92
92
  "resource" text NOT NULL,
93
93
  "id" text NOT NULL,
94
94
  "name" text,
@@ -101,6 +101,6 @@ CREATE TABLE "seazona_reference" (
101
101
  CONSTRAINT "seazona_reference_resource_id_pk" PRIMARY KEY("resource","id")
102
102
  );
103
103
  --> statement-breakpoint
104
- CREATE INDEX "seazona_invoices_changed" ON "seazona_invoices" USING btree ("last_changed_at");--> statement-breakpoint
105
- CREATE INDEX "seazona_orders_invoice_number" ON "seazona_orders" USING btree ("invoice_number");--> statement-breakpoint
106
- CREATE INDEX "seazona_orders_changed" ON "seazona_orders" USING btree ("last_changed_at");
104
+ CREATE INDEX IF NOT EXISTS "seazona_invoices_changed" ON "seazona_invoices" USING btree ("last_changed_at");--> statement-breakpoint
105
+ CREATE INDEX IF NOT EXISTS "seazona_orders_invoice_number" ON "seazona_orders" USING btree ("invoice_number");--> statement-breakpoint
106
+ CREATE INDEX IF NOT EXISTS "seazona_orders_changed" ON "seazona_orders" USING btree ("last_changed_at");
@@ -94,7 +94,7 @@ async function documentFor(db, organisationId, invoiceId) {
94
94
  currency: invoice.currency,
95
95
  contact: { id: contact.id, name: contact.name, email: contact.email },
96
96
  lines: invoice.lines.map((l) => ({
97
- position: l.position, itemCode: codes.get(l.priceItemId) ?? null, description: l.description, quantity: l.quantity,
97
+ position: l.position, itemCode: codes.get(l.priceItemId) ?? null, ...(l.accountCode ? { accountCode: l.accountCode } : {}), description: l.description, quantity: l.quantity,
98
98
  unitAmountCents: l.unitAmountCents, taxCode: l.taxCode, lineAmountCents: l.lineAmountCents, taxCents: l.taxCents,
99
99
  })),
100
100
  subtotalCents: invoice.subtotalCents,
@@ -131,6 +131,9 @@ export function buildDraft(document, { settings, contact, items, sendMode = 'acc
131
131
  if (line.itemCode) {
132
132
  if (items.has(line.itemCode)) out.ItemCode = line.itemCode;
133
133
  else problems.push(problem('xero_item_missing', `Line ${line.position}: item ${line.itemCode} is not in Xero. Add it in Xero, then sync.`, { itemCode: line.itemCode }));
134
+ } else if (line.accountCode) {
135
+ // A line with no item books to its own account, such as a surcharge.
136
+ out.AccountCode = line.accountCode;
134
137
  }
135
138
  if (tracking) out.Tracking = [tracking];
136
139
  return out;
@@ -1,6 +1,5 @@
1
1
  import { sql } from 'drizzle-orm';
2
2
  import { ledgerState } from './ledger.js';
3
- import { MIRROR_RESOURCES } from './mirror.js';
4
3
 
5
4
  // What the core's matcher (server/core/pipeline.js) reads from Xero: the
6
5
  // customers and the sales invoices in Flow's own copy. It never calls Xero, so
@@ -10,6 +9,11 @@ import { MIRROR_RESOURCES } from './mirror.js';
10
9
  // stops counting as proof.
11
10
  export const MIRROR_FRESH_MS = 24 * 60 * 60 * 1000;
12
11
 
12
+ // The copies that prove whether Xero holds an invoice for a job. Payments do
13
+ // not: a connection without a payments scope (the default) never copies them,
14
+ // and must still match jobs.
15
+ export const PROOF_RESOURCES = Object.freeze(['invoices', 'credit_notes']);
16
+
13
17
  export async function listCustomers(db, organisationId) {
14
18
  const { rows } = await db.execute(sql`
15
19
  select xero_contact_id, name, status from xero_contacts
@@ -26,7 +30,7 @@ export async function mirrorState(db, organisationId, { now = new Date() } = {})
26
30
  where organisation_id = ${organisationId}`);
27
31
  const byResource = new Map(rows.map((r) => [r.resource, r]));
28
32
  const invoices = byResource.get('invoices');
29
- const finished = MIRROR_RESOURCES.every((r) => (byResource.get(r)?.passes ?? 0) > 0);
33
+ const finished = PROOF_RESOURCES.every((r) => (byResource.get(r)?.passes ?? 0) > 0);
30
34
  const lastPass = invoices?.last_pass_finished_at ? new Date(invoices.last_pass_finished_at) : null;
31
35
  const { rows: [changed] } = await db.execute(sql`
32
36
  select max(last_changed_at) as at from xero_invoices where organisation_id = ${organisationId}`);
@@ -1,6 +1,6 @@
1
1
  -- The Xero connection of each Flow organisation. The refresh token is
2
2
  -- AES-256-GCM ciphertext (XERO_TOKEN_KEY); the plain token is never stored.
3
- CREATE TABLE "xero_connections" (
3
+ CREATE TABLE IF NOT EXISTS "xero_connections" (
4
4
  "organisation_id" uuid PRIMARY KEY NOT NULL REFERENCES "organisations"("id"),
5
5
  "mode" text NOT NULL,
6
6
  "tenant_id" text NOT NULL,
@@ -1,6 +1,6 @@
1
1
  -- Flow's copy of the Xero contacts and items of each connected organisation,
2
2
  -- and a record of each sync. Xero owns these records; a sync replaces the copy.
3
- CREATE TABLE "xero_contacts" (
3
+ CREATE TABLE IF NOT EXISTS "xero_contacts" (
4
4
  "organisation_id" uuid NOT NULL REFERENCES "organisations"("id"),
5
5
  "xero_contact_id" text NOT NULL,
6
6
  "name" text NOT NULL,
@@ -14,9 +14,9 @@ CREATE TABLE "xero_contacts" (
14
14
  CONSTRAINT "xero_contacts_pk" PRIMARY KEY("organisation_id", "xero_contact_id")
15
15
  );
16
16
  --> statement-breakpoint
17
- CREATE INDEX "xero_contacts_org_name" ON "xero_contacts" ("organisation_id", lower("name"));
17
+ CREATE INDEX IF NOT EXISTS "xero_contacts_org_name" ON "xero_contacts" ("organisation_id", lower("name"));
18
18
  --> statement-breakpoint
19
- CREATE TABLE "xero_items" (
19
+ CREATE TABLE IF NOT EXISTS "xero_items" (
20
20
  "organisation_id" uuid NOT NULL REFERENCES "organisations"("id"),
21
21
  "xero_item_id" text NOT NULL,
22
22
  "code" text NOT NULL,
@@ -32,7 +32,7 @@ CREATE TABLE "xero_items" (
32
32
  CONSTRAINT "xero_items_org_code" UNIQUE("organisation_id", "code")
33
33
  );
34
34
  --> statement-breakpoint
35
- CREATE TABLE "xero_sync_runs" (
35
+ CREATE TABLE IF NOT EXISTS "xero_sync_runs" (
36
36
  "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
37
37
  "organisation_id" uuid NOT NULL REFERENCES "organisations"("id"),
38
38
  "status" text DEFAULT 'running' NOT NULL,
@@ -46,4 +46,4 @@ CREATE TABLE "xero_sync_runs" (
46
46
  CONSTRAINT "xero_sync_runs_status_valid" CHECK ("status" in ('running', 'succeeded', 'failed'))
47
47
  );
48
48
  --> statement-breakpoint
49
- CREATE INDEX "xero_sync_runs_org_started" ON "xero_sync_runs" ("organisation_id", "started_at");
49
+ CREATE INDEX IF NOT EXISTS "xero_sync_runs_org_started" ON "xero_sync_runs" ("organisation_id", "started_at");
@@ -1,7 +1,7 @@
1
1
  -- One row for each issued Flow invoice version sent to Xero as a draft. It
2
2
  -- holds the exact request and its Idempotency-Key from before the request
3
3
  -- leaves, so an uncertain outcome is resolved without a second invoice.
4
- CREATE TABLE "xero_invoice_pushes" (
4
+ CREATE TABLE IF NOT EXISTS "xero_invoice_pushes" (
5
5
  "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
6
6
  "organisation_id" uuid NOT NULL,
7
7
  "invoice_id" uuid NOT NULL,
@@ -29,4 +29,4 @@ CREATE TABLE "xero_invoice_pushes" (
29
29
  CONSTRAINT "xero_invoice_pushes_settled_has_id" CHECK ("state" in ('dispatching', 'ambiguous') or "xero_invoice_id" is not null)
30
30
  );
31
31
  --> statement-breakpoint
32
- CREATE INDEX "xero_invoice_pushes_org_invoice" ON "xero_invoice_pushes" ("organisation_id", "invoice_id");
32
+ CREATE INDEX IF NOT EXISTS "xero_invoice_pushes_org_invoice" ON "xero_invoice_pushes" ("organisation_id", "invoice_id");
@@ -1,6 +1,6 @@
1
1
  -- Where the next read of Xero payments starts, for each Flow organisation.
2
2
  -- (This is the Xero adapter's own migration journal, not the core's.)
3
- CREATE TABLE "xero_payment_cursors" (
3
+ CREATE TABLE IF NOT EXISTS "xero_payment_cursors" (
4
4
  "organisation_id" uuid PRIMARY KEY NOT NULL REFERENCES "organisations"("id"),
5
5
  "payments_since" timestamp with time zone NOT NULL,
6
6
  "updated_at" timestamp with time zone DEFAULT now() NOT NULL
@@ -4,7 +4,7 @@
4
4
  -- without spending Xero calls. A row is upserted by its Xero id, and changes
5
5
  -- only when its content hash changes. Free text (references, line
6
6
  -- descriptions) is kept only with XERO_KEEP_REFERENCE=on.
7
- CREATE TABLE "xero_invoices" (
7
+ CREATE TABLE IF NOT EXISTS "xero_invoices" (
8
8
  "organisation_id" uuid NOT NULL REFERENCES "organisations"("id"),
9
9
  "xero_invoice_id" text NOT NULL,
10
10
  "type" text NOT NULL,
@@ -30,11 +30,11 @@ CREATE TABLE "xero_invoices" (
30
30
  CONSTRAINT "xero_invoices_pk" PRIMARY KEY("organisation_id", "xero_invoice_id")
31
31
  );
32
32
  --> statement-breakpoint
33
- CREATE INDEX "xero_invoices_number" ON "xero_invoices" ("organisation_id", "invoice_number");
33
+ CREATE INDEX IF NOT EXISTS "xero_invoices_number" ON "xero_invoices" ("organisation_id", "invoice_number");
34
34
  --> statement-breakpoint
35
- CREATE INDEX "xero_invoices_contact" ON "xero_invoices" ("organisation_id", "xero_contact_id", "issued_on");
35
+ CREATE INDEX IF NOT EXISTS "xero_invoices_contact" ON "xero_invoices" ("organisation_id", "xero_contact_id", "issued_on");
36
36
  --> statement-breakpoint
37
- CREATE TABLE "xero_credit_notes" (
37
+ CREATE TABLE IF NOT EXISTS "xero_credit_notes" (
38
38
  "organisation_id" uuid NOT NULL REFERENCES "organisations"("id"),
39
39
  "xero_credit_note_id" text NOT NULL,
40
40
  "type" text NOT NULL,
@@ -57,7 +57,7 @@ CREATE TABLE "xero_credit_notes" (
57
57
  CONSTRAINT "xero_credit_notes_pk" PRIMARY KEY("organisation_id", "xero_credit_note_id")
58
58
  );
59
59
  --> statement-breakpoint
60
- CREATE TABLE "xero_payments" (
60
+ CREATE TABLE IF NOT EXISTS "xero_payments" (
61
61
  "organisation_id" uuid NOT NULL REFERENCES "organisations"("id"),
62
62
  "xero_payment_id" text NOT NULL,
63
63
  "payment_type" text,
@@ -75,12 +75,12 @@ CREATE TABLE "xero_payments" (
75
75
  CONSTRAINT "xero_payments_pk" PRIMARY KEY("organisation_id", "xero_payment_id")
76
76
  );
77
77
  --> statement-breakpoint
78
- CREATE INDEX "xero_payments_invoice" ON "xero_payments" ("organisation_id", "xero_invoice_id");
78
+ CREATE INDEX IF NOT EXISTS "xero_payments_invoice" ON "xero_payments" ("organisation_id", "xero_invoice_id");
79
79
  --> statement-breakpoint
80
80
  -- Where the next read of each resource starts. `modified_since` is the
81
81
  -- keyset: the next read asks Xero for records changed since then, oldest
82
82
  -- change first. `page` moves on only when one second holds a full page.
83
- CREATE TABLE "xero_mirror_cursors" (
83
+ CREATE TABLE IF NOT EXISTS "xero_mirror_cursors" (
84
84
  "organisation_id" uuid NOT NULL REFERENCES "organisations"("id"),
85
85
  "resource" text NOT NULL,
86
86
  "modified_since" timestamp with time zone,
@@ -2,9 +2,15 @@
2
2
  -- sent approved, whether Xero emailed it to the customer. Xero's email action
3
3
  -- is not idempotent, so Flow records `sending` before the call and retries
4
4
  -- only a refusal (`failed`), never an unknown outcome.
5
- ALTER TABLE "xero_invoice_pushes" ADD COLUMN "send_mode" text DEFAULT 'accounting_draft' NOT NULL;--> statement-breakpoint
6
- ALTER TABLE "xero_invoice_pushes" ADD COLUMN "email_state" text;--> statement-breakpoint
7
- ALTER TABLE "xero_invoice_pushes" ADD COLUMN "email_error" text;--> statement-breakpoint
8
- ALTER TABLE "xero_invoice_pushes" ADD COLUMN "emailed_at" timestamp with time zone;--> statement-breakpoint
9
- ALTER TABLE "xero_invoice_pushes" ADD CONSTRAINT "xero_invoice_pushes_send_mode_valid" CHECK ("send_mode" in ('accounting_draft', 'approve_and_email'));--> statement-breakpoint
10
- ALTER TABLE "xero_invoice_pushes" ADD CONSTRAINT "xero_invoice_pushes_email_state_valid" CHECK ("email_state" is null or "email_state" in ('pending', 'sending', 'sent', 'failed', 'unknown'));
5
+ ALTER TABLE "xero_invoice_pushes" ADD COLUMN IF NOT EXISTS "send_mode" text DEFAULT 'accounting_draft' NOT NULL;--> statement-breakpoint
6
+ ALTER TABLE "xero_invoice_pushes" ADD COLUMN IF NOT EXISTS "email_state" text;--> statement-breakpoint
7
+ ALTER TABLE "xero_invoice_pushes" ADD COLUMN IF NOT EXISTS "email_error" text;--> statement-breakpoint
8
+ ALTER TABLE "xero_invoice_pushes" ADD COLUMN IF NOT EXISTS "emailed_at" timestamp with time zone;--> statement-breakpoint
9
+ DO $flow_guard$ BEGIN
10
+ ALTER TABLE "xero_invoice_pushes" ADD CONSTRAINT "xero_invoice_pushes_send_mode_valid" CHECK ("send_mode" in ('accounting_draft', 'approve_and_email'));
11
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
12
+ END $flow_guard$;--> statement-breakpoint
13
+ DO $flow_guard$ BEGIN
14
+ ALTER TABLE "xero_invoice_pushes" ADD CONSTRAINT "xero_invoice_pushes_email_state_valid" CHECK ("email_state" is null or "email_state" in ('pending', 'sending', 'sent', 'failed', 'unknown'));
15
+ EXCEPTION WHEN duplicate_object OR duplicate_table OR invalid_table_definition THEN NULL;
16
+ END $flow_guard$;
@@ -34,14 +34,16 @@ const BODY = `<div class="fx-app">
34
34
  <div class="fx-card fx-message__card fx-state-no-access" id="no-access"></div>
35
35
  </section>
36
36
 
37
- <div class="fx-state-signed-in fx-shell">
37
+ <div class="fx-state-signed-in fx-shell" id="shell">
38
38
  <aside class="fx-side">
39
- <a class="fx-brand" href="/">${FLOW_MARK}<span>Flow</span></a>
39
+ <div class="fx-side__head">
40
+ <a class="fx-brand" href="/">${FLOW_MARK}<span>Flow</span></a>
41
+ <button type="button" class="fx-btn fx-btn--ghost fx-btn--icon" id="menu-toggle" aria-controls="nav" aria-label="Menu"></button>
42
+ </div>
40
43
  <nav class="fx-nav" id="nav" aria-label="Flow"></nav>
41
44
  <div class="fx-side__end">
42
45
  <div id="user-button" class="fx-side__user"></div>
43
- <button type="button" class="fx-btn fx-btn--ghost fx-btn--icon" id="report-problem" title="Report a problem" aria-label="Report a problem" hidden></button>
44
- <button type="button" class="fx-btn fx-btn--ghost fx-btn--icon fx-mode-toggle" data-mode-toggle>Light or dark</button>
46
+ <button type="button" class="fx-btn fx-btn--ghost fx-btn--icon" id="search" title="Search (Ctrl+K)" aria-label="Search" aria-keyshortcuts="Control+K Meta+K"></button>
45
47
  </div>
46
48
  </aside>
47
49
  <main id="main" class="fx-main" tabindex="-1"></main>