@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.
- package/README.md +1 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -2
- package/server/api/invoicing.js +96 -40
- package/server/app.js +3 -1
- package/server/core/automations.js +14 -0
- package/server/core/invoice-rules.js +4 -2
- package/server/core/invoices.js +4 -2
- package/server/core/job-board.js +130 -0
- package/server/core/ledger.js +12 -5
- package/server/core/line-mapping.js +337 -0
- package/server/core/pipeline.js +9 -3
- package/server/core/sending.js +4 -3
- package/server/db/migrate-all.js +26 -0
- package/server/db/migrations/0010_invoicing_rules.sql +34 -13
- package/server/db/migrations/0011_invoice_rules.sql +11 -5
- package/server/db/migrations/0012_invoice_sending.sql +18 -9
- package/server/db/migrations/0013_line_mappings.sql +35 -0
- package/server/db/migrations/meta/0013_snapshot.json +3568 -0
- package/server/db/migrations/meta/_journal.json +7 -0
- package/server/db/schema.js +43 -0
- package/server/import/at-start.js +64 -2
- package/server/index.js +5 -1
- package/server/integrations/port.js +1 -0
- package/server/integrations/seazona/feed.js +62 -4
- package/server/integrations/seazona/index.js +7 -1
- package/server/integrations/seazona/lanes.js +4 -1
- package/server/integrations/seazona/migrations/0000_api_governor.sql +3 -3
- package/server/integrations/seazona/migrations/0001_mirror.sql +9 -9
- package/server/integrations/xero/drafts.js +4 -1
- package/server/integrations/xero/lookup.js +6 -2
- package/server/integrations/xero/migrations/0000_xero_connections.sql +1 -1
- package/server/integrations/xero/migrations/0001_xero_catalogue.sql +5 -5
- package/server/integrations/xero/migrations/0002_xero_invoice_pushes.sql +2 -2
- package/server/integrations/xero/migrations/0003_xero_payment_cursors.sql +1 -1
- package/server/integrations/xero/migrations/0004_xero_mirror.sql +7 -7
- package/server/integrations/xero/migrations/0005_xero_send_modes.sql +12 -6
- package/server/ui/app-page.js +6 -4
- package/server/ui/app.js +111 -8
- package/server/ui/clerk-appearance.js +21 -0
- package/server/ui/flow.css +155 -30
- package/server/ui/invoicing-settings.js +2 -0
- package/server/ui/job-board.js +155 -0
- package/server/ui/jobs.js +220 -91
- package/server/ui/line-mappings.js +60 -0
- package/server/ui/monitoring.js +15 -7
- package/server/ui/routes.js +1 -1
- package/server/ui/search.js +145 -0
package/server/db/schema.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
10
|
-
ALTER TABLE "xero_invoice_pushes" ADD CONSTRAINT "
|
|
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$;
|
package/server/ui/app-page.js
CHANGED
|
@@ -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
|
-
<
|
|
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="
|
|
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>
|