@arcforgelabs/flow 2026.10.26 → 2026.10.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +6 -0
  2. package/npm-shrinkwrap.json +627 -2
  3. package/package.json +3 -2
  4. package/server/api/invoicing.js +313 -46
  5. package/server/app.js +30 -2
  6. package/server/core/automations.js +154 -0
  7. package/server/core/invoice-rules.js +226 -0
  8. package/server/core/invoices.js +8 -3
  9. package/server/core/invoicing-rules.js +255 -0
  10. package/server/core/job-board.js +130 -0
  11. package/server/core/ledger.js +57 -17
  12. package/server/core/line-mapping.js +337 -0
  13. package/server/core/monitoring.js +34 -0
  14. package/server/core/pipeline.js +16 -3
  15. package/server/core/sending.js +283 -0
  16. package/server/db/migrate-all.js +26 -0
  17. package/server/db/migrations/0010_invoicing_rules.sql +72 -0
  18. package/server/db/migrations/0011_invoice_rules.sql +27 -0
  19. package/server/db/migrations/0012_invoice_sending.sql +18 -0
  20. package/server/db/migrations/0013_line_mappings.sql +35 -0
  21. package/server/db/migrations/meta/0010_snapshot.json +3158 -0
  22. package/server/db/migrations/meta/0011_snapshot.json +3314 -0
  23. package/server/db/migrations/meta/0012_snapshot.json +3380 -0
  24. package/server/db/migrations/meta/0013_snapshot.json +3568 -0
  25. package/server/db/migrations/meta/_journal.json +28 -0
  26. package/server/db/schema.js +158 -0
  27. package/server/import/at-start.js +64 -2
  28. package/server/index.js +17 -5
  29. package/server/integrations/adapters.js +2 -1
  30. package/server/integrations/port.js +6 -0
  31. package/server/integrations/seazona/feed.js +62 -4
  32. package/server/integrations/seazona/index.js +7 -1
  33. package/server/integrations/seazona/lanes.js +4 -1
  34. package/server/integrations/seazona/ledger.js +2 -0
  35. package/server/integrations/seazona/migrations/0000_api_governor.sql +3 -3
  36. package/server/integrations/seazona/migrations/0001_mirror.sql +9 -9
  37. package/server/integrations/sentry/index.js +105 -0
  38. package/server/integrations/xero/client.js +5 -0
  39. package/server/integrations/xero/drafts.js +127 -17
  40. package/server/integrations/xero/index.js +11 -1
  41. package/server/integrations/xero/lookup.js +6 -2
  42. package/server/integrations/xero/migrations/0000_xero_connections.sql +1 -1
  43. package/server/integrations/xero/migrations/0001_xero_catalogue.sql +5 -5
  44. package/server/integrations/xero/migrations/0002_xero_invoice_pushes.sql +2 -2
  45. package/server/integrations/xero/migrations/0003_xero_payment_cursors.sql +1 -1
  46. package/server/integrations/xero/migrations/0004_xero_mirror.sql +7 -7
  47. package/server/integrations/xero/migrations/0005_xero_send_modes.sql +16 -0
  48. package/server/integrations/xero/migrations/meta/_journal.json +7 -0
  49. package/server/ui/app-page.js +7 -4
  50. package/server/ui/app.js +122 -6
  51. package/server/ui/automations.js +96 -0
  52. package/server/ui/clerk-appearance.js +21 -0
  53. package/server/ui/flow.css +243 -30
  54. package/server/ui/invoice-rules.js +237 -0
  55. package/server/ui/invoicing-settings.js +110 -0
  56. package/server/ui/invoicing.js +166 -20
  57. package/server/ui/job-board.js +155 -0
  58. package/server/ui/jobs.js +232 -84
  59. package/server/ui/line-mappings.js +60 -0
  60. package/server/ui/logos/sentry.svg +1 -0
  61. package/server/ui/monitoring.js +61 -0
  62. package/server/ui/routes.js +2 -2
  63. package/server/ui/search.js +145 -0
@@ -0,0 +1,154 @@
1
+ import { and, eq, sql } from 'drizzle-orm';
2
+ import { automations, billingObligations } from '../db/schema.js';
3
+ import { FlowError, notFound } from './errors.js';
4
+ import { autoDraftJobs } from './invoicing-rules.js';
5
+ import { MAPPING_DEFAULTS, readMappingConfig, remapJobs } from './line-mapping.js';
6
+ import { setReady } from './ledger.js';
7
+
8
+ // Automations (docs/AUTOMATIONS.md): Flow's routines. Each one watches Flow's
9
+ // own records and moves them on, and each is on or off with its own settings,
10
+ // so no other system's status is baked into Flow's stages. `place` is the
11
+ // screen that shows it: the integration whose data triggers it, or invoicing. The routines are
12
+ // defined here; the `automations` table holds an organisation's choices. The
13
+ // pipeline (pipeline.js) runs them after it reads the integrations' copies.
14
+ //
15
+ // mark_ready_from_work_status: a job whose work system status is one of the
16
+ // chosen statuses becomes ready to invoice (Flow's stage). The statuses
17
+ // are picked from those the work system reported (workStatuses).
18
+ // draft_when_ready: a job that is ready to invoice and passes the invoicing
19
+ // rules (invoicing-rules.js) gets a draft invoice in Flow.
20
+
21
+ const BATCH = 500;
22
+
23
+ const norm = (value) => String(value ?? '').trim().replace(/\s+/g, ' ').toLowerCase();
24
+
25
+ // A status matches when it is one of the chosen statuses, in any case.
26
+ export function statusMatches(status, statuses) {
27
+ const have = norm(status);
28
+ return Boolean(have) && statuses.some((wanted) => norm(wanted) === have);
29
+ }
30
+
31
+ // The statuses the work system has reported for this organisation's jobs, with
32
+ // the number of jobs at each, most jobs first. People pick the ready statuses
33
+ // from this list, so it follows each lab's own work system.
34
+ export async function workStatuses(db, organisationId) {
35
+ const rows = await db.select({ status: billingObligations.sourceStatus, jobs: sql`count(*)::int` })
36
+ .from(billingObligations)
37
+ .where(and(eq(billingObligations.organisationId, organisationId), sql`${billingObligations.sourceStatus} is not null`))
38
+ .groupBy(billingObligations.sourceStatus);
39
+ const byName = new Map();
40
+ for (const { status, jobs } of rows) {
41
+ const key = norm(status);
42
+ if (!key) continue;
43
+ const seen = byName.get(key);
44
+ if (seen) seen.jobs += jobs;
45
+ else byName.set(key, { value: key, label: status.trim(), jobs });
46
+ }
47
+ return [...byName.values()].sort((a, b) => b.jobs - a.jobs || a.label.localeCompare(b.label));
48
+ }
49
+
50
+ function readStatuses(value) {
51
+ if (!Array.isArray(value) || !value.length || value.length > 20) throw new FlowError('invalid', 'statuses must list 1 to 20 statuses');
52
+ const statuses = [...new Set(value.map((s) => (typeof s === 'string' ? norm(s) : '')).filter(Boolean))];
53
+ if (!statuses.length || statuses.some((s) => s.length > 100)) throw new FlowError('invalid', 'statuses must be short text');
54
+ return statuses;
55
+ }
56
+
57
+ export const AUTOMATIONS = Object.freeze([
58
+ {
59
+ // Runs inside the pipeline's read of the work system (pipeline.js,
60
+ // ingestWork), so every job reaches the ledger already mapped.
61
+ key: 'map_lines',
62
+ place: 'seazona',
63
+ name: 'Map lines to price items',
64
+ hint: 'Each Seazona line finds its price item.',
65
+ defaults: { enabled: true, config: { ...MAPPING_DEFAULTS } },
66
+ readConfig: (config) => readMappingConfig(config),
67
+ remap: true,
68
+ },
69
+ {
70
+ key: 'mark_ready_from_work_status',
71
+ place: 'seazona',
72
+ name: 'Mark ready to invoice',
73
+ hint: 'When the job has one of the ready statuses.',
74
+ defaults: { enabled: true, config: { statuses: ['shipped', 'delivered'] } },
75
+ readConfig: (config) => ({ statuses: readStatuses(config.statuses) }),
76
+ },
77
+ {
78
+ key: 'draft_when_ready',
79
+ place: 'invoicing',
80
+ name: 'Draft when ready',
81
+ hint: 'Drafts each ready job that passes the checks.',
82
+ defaults: { enabled: true, config: {} },
83
+ readConfig: () => ({}),
84
+ },
85
+ ]);
86
+
87
+ const byKey = new Map(AUTOMATIONS.map((a) => [a.key, a]));
88
+
89
+ // Every routine with this organisation's choice, or its default.
90
+ export async function listAutomations(db, organisationId) {
91
+ const rows = await db.select().from(automations).where(eq(automations.organisationId, organisationId));
92
+ const saved = new Map(rows.map((r) => [r.key, r]));
93
+ return AUTOMATIONS.map(({ key, place, name, hint, defaults }) => {
94
+ const row = saved.get(key);
95
+ return { key, place, name, hint, enabled: row?.enabled ?? defaults.enabled, config: { ...defaults.config, ...(row?.config ?? {}) } };
96
+ });
97
+ }
98
+
99
+ export async function saveAutomation(db, organisationId, key, changes, { personId = null } = {}) {
100
+ const automation = byKey.get(key);
101
+ if (!automation) throw notFound('automation');
102
+ if (!changes || typeof changes !== 'object' || Array.isArray(changes)) throw new FlowError('invalid', 'the change must be an object');
103
+ const unknown = Object.keys(changes).filter((k) => !['enabled', 'config'].includes(k));
104
+ if (unknown.length) throw new FlowError('invalid', `unknown field: ${unknown[0]}`);
105
+ const current = (await listAutomations(db, organisationId)).find((a) => a.key === key);
106
+ const enabled = changes.enabled ?? current.enabled;
107
+ if (typeof enabled !== 'boolean') throw new FlowError('invalid', 'enabled must be true or false');
108
+ const config = 'config' in changes ? automation.readConfig({ ...current.config, ...changes.config }) : current.config;
109
+ await db.insert(automations)
110
+ .values({ organisationId, key, enabled, config, updatedBy: personId })
111
+ .onConflictDoUpdate({ target: [automations.organisationId, automations.key], set: { enabled, config, updatedBy: personId, updatedAt: sql`now()` } });
112
+ // A routine that shapes jobs as they come in applies to the jobs already read.
113
+ if (automation.remap) await remapJobs(db, organisationId);
114
+ return (await listAutomations(db, organisationId)).find((a) => a.key === key);
115
+ }
116
+
117
+ const actorOf = (key) => `automation:${key}`;
118
+
119
+ // Marks ready each job in work whose work system status is one of the chosen
120
+ // ones. A person who put a job back in work has decided: the routine leaves
121
+ // that job to people from then on.
122
+ async function markReadyFromWorkStatus(db, organisationId, { statuses }) {
123
+ const actor = actorOf('mark_ready_from_work_status');
124
+ const jobs = await db.select().from(billingObligations).where(and(
125
+ eq(billingObligations.organisationId, organisationId),
126
+ sql`${billingObligations.readyAt} is null`,
127
+ sql`${billingObligations.invoiceId} is null`,
128
+ sql`${billingObligations.sourceStatus} is not null`,
129
+ sql`not exists (select 1 from obligation_events e where e.obligation_id = ${billingObligations.id}
130
+ and e.kind = 'in_work' and e.actor not like 'automation:%')`,
131
+ )).limit(BATCH);
132
+ let marked = 0;
133
+ for (const job of jobs) {
134
+ if (!statusMatches(job.sourceStatus, statuses)) continue;
135
+ await setReady(db, organisationId, job.id, { ready: true, actor });
136
+ marked += 1;
137
+ }
138
+ return { marked };
139
+ }
140
+
141
+ // Runs every routine that is on. `accounting` is the integration whose copy
142
+ // the draft routine checks; without one, Flow's own records are the check.
143
+ export async function runAutomations(db, organisationId, { accounting = null, now = new Date() } = {}) {
144
+ const summary = {};
145
+ for (const automation of await listAutomations(db, organisationId)) {
146
+ if (!automation.enabled) continue;
147
+ if (automation.key === 'mark_ready_from_work_status') {
148
+ summary[automation.key] = await markReadyFromWorkStatus(db, organisationId, automation.config);
149
+ } else if (automation.key === 'draft_when_ready') {
150
+ summary[automation.key] = await autoDraftJobs(db, organisationId, { accounting, now, actor: actorOf(automation.key) });
151
+ }
152
+ }
153
+ return summary;
154
+ }
@@ -0,0 +1,226 @@
1
+ import { and, asc, eq, sql } from 'drizzle-orm';
2
+ import { invoiceRules, invoicingSettings } from '../db/schema.js';
3
+ import { FlowError, notFound } from './errors.js';
4
+
5
+ // Invoice rules (docs/INVOICE_RULES.md): a lab's own checks on a job before
6
+ // Flow drafts its invoice. Each rule is When, If and Then, and a lab owner
7
+ // builds it from menus, so no lab's checks are fixed in Flow's code.
8
+ //
9
+ // When job_ready: the job is ready to invoice and has no invoice yet.
10
+ // If every condition matches: a field of the job, its customer or its
11
+ // lines, a test, and a value.
12
+ // Then wait: the job waits for a person; skip: rules never draft it.
13
+ //
14
+ // The rules read only Flow's own records, so they work with or without an
15
+ // accounting integration. The earlier fixed settings (old job limit, required
16
+ // practitioner, rush share) become starter rules, once per organisation.
17
+
18
+ const MAX_RULES = 100;
19
+ const MAX_CONDITIONS = 10;
20
+ const invalid = (message) => new FlowError('invalid', message);
21
+
22
+ const day = (date) => new Date(`${String(date).slice(0, 10)}T00:00:00Z`);
23
+ const norm = (value) => String(value ?? '').trim().replace(/\s+/g, ' ').toLowerCase();
24
+ // A line's key: its price item code, else the label line mapping gave it (a
25
+ // line booked to an account, such as a surcharge, has no code).
26
+ const code = (line) => norm(line.itemCode || line.label);
27
+ const cents = (lines) => lines.reduce((sum, l) => sum + (l.lineAmountCents ?? 0), 0);
28
+
29
+ // The share of the item's lines in the job's other lines, as the charged and
30
+ // the expected cents for `percent`.
31
+ function share(lines, item, percent) {
32
+ const own = lines.filter((l) => code(l) === norm(item));
33
+ if (!own.length) return null;
34
+ const others = cents(lines.filter((l) => code(l) !== norm(item)));
35
+ return { charged: cents(own), others, expected: Math.round((others * percent) / 100) };
36
+ }
37
+
38
+ // Each field: what it reads from a job, its kind, and the tests it allows.
39
+ export const FIELDS = Object.freeze({
40
+ job_age_days: { label: 'Job age (days)', kind: 'number', read: (j, { now }) => (j.jobDate ? Math.floor((now - day(j.jobDate)) / 86_400_000) : null) },
41
+ job_total: { label: 'Job total', kind: 'money', read: (j) => (j.totalCents ?? 0) / 100 },
42
+ job_status: { label: 'Job status', kind: 'choice', options: 'statuses', read: (j) => norm(j.sourceStatus) || null },
43
+ practitioner: { label: 'Practitioner', kind: 'text', read: (j, { customer }) => j.practitioner || customer.defaultPractitioner || null },
44
+ customer: { label: 'Customer', kind: 'choice', options: 'customers', read: (j) => j.contactId },
45
+ customer_billable: { label: 'Customer is billable', kind: 'boolean', read: (j, { customer }) => customer.billable !== false },
46
+ line_item: { label: 'Line item', kind: 'items', options: 'items', read: (j) => (j.lines ?? []).flatMap((l) => [norm(l.itemCode), norm(l.label)].filter(Boolean)) },
47
+ line_share: { label: 'Line share of other lines (%)', kind: 'share', options: 'items', read: (j) => j.lines ?? [] },
48
+ });
49
+
50
+ export const TESTS = Object.freeze({
51
+ number: ['more_than', 'at_most', 'is', 'is_not', 'is_empty'],
52
+ money: ['more_than', 'at_most', 'is', 'is_not'],
53
+ choice: ['is', 'is_not', 'is_one_of', 'is_empty'],
54
+ text: ['is', 'is_not', 'is_empty', 'is_not_empty'],
55
+ boolean: ['is'],
56
+ items: ['has', 'has_not'],
57
+ share: ['is', 'is_not'],
58
+ });
59
+
60
+ export const ACTIONS = Object.freeze(['wait', 'skip']);
61
+
62
+ function readCondition(raw, index) {
63
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw invalid(`condition ${index + 1} must be an object`);
64
+ const field = FIELDS[raw.field];
65
+ if (!field) throw invalid(`condition ${index + 1}: unknown field ${raw.field}`);
66
+ if (!TESTS[field.kind].includes(raw.test)) throw invalid(`condition ${index + 1}: ${field.label} takes ${TESTS[field.kind].join(', ')}`);
67
+ const out = { field: raw.field, test: raw.test };
68
+ if (['is_empty', 'is_not_empty'].includes(raw.test)) return out;
69
+ const { value } = raw;
70
+ if (field.kind === 'boolean') {
71
+ if (typeof value !== 'boolean') throw invalid(`condition ${index + 1}: the value must be true or false`);
72
+ } else if (['number', 'money', 'share'].includes(field.kind)) {
73
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > 1e9) throw invalid(`condition ${index + 1}: the value must be a number`);
74
+ } else if (raw.test === 'is_one_of') {
75
+ if (!Array.isArray(value) || !value.length || value.length > 50 || value.some((v) => typeof v !== 'string' || !v.trim() || v.length > 200)) {
76
+ throw invalid(`condition ${index + 1}: pick 1 to 50 values`);
77
+ }
78
+ } else if (typeof value !== 'string' || !value.trim() || value.length > 200) {
79
+ throw invalid(`condition ${index + 1}: the value must be short text`);
80
+ }
81
+ out.value = typeof value === 'string' ? value.trim() : value;
82
+ if (field.kind === 'share') {
83
+ if (typeof raw.item !== 'string' || !raw.item.trim() || raw.item.length > 100) throw invalid(`condition ${index + 1}: pick the line item`);
84
+ out.item = raw.item.trim();
85
+ }
86
+ return out;
87
+ }
88
+
89
+ export function readRule(raw) {
90
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw invalid('the rule must be an object');
91
+ const unknown = Object.keys(raw).filter((k) => !['name', 'enabled', 'conditions', 'action'].includes(k));
92
+ if (unknown.length) throw invalid(`unknown field: ${unknown[0]}`);
93
+ const name = typeof raw.name === 'string' ? raw.name.trim() : '';
94
+ if (!name || name.length > 120) throw invalid('the rule needs a name of 120 characters at most');
95
+ const enabled = raw.enabled ?? true;
96
+ if (typeof enabled !== 'boolean') throw invalid('enabled must be true or false');
97
+ if (!Array.isArray(raw.conditions) || !raw.conditions.length || raw.conditions.length > MAX_CONDITIONS) {
98
+ throw invalid(`the rule needs 1 to ${MAX_CONDITIONS} conditions`);
99
+ }
100
+ if (!ACTIONS.includes(raw.action?.kind)) throw invalid(`the action must be one of ${ACTIONS.join(', ')}`);
101
+ return { name, enabled, conditions: raw.conditions.map(readCondition), action: { kind: raw.action.kind } };
102
+ }
103
+
104
+ function matches(condition, job, context) {
105
+ const field = FIELDS[condition.field];
106
+ const have = field.read(job, context);
107
+ const { test, value } = condition;
108
+ if (test === 'is_empty') return have === null || have === '' || have === undefined;
109
+ if (test === 'is_not_empty') return !(have === null || have === '' || have === undefined);
110
+ switch (field.kind) {
111
+ case 'items': {
112
+ const found = have.includes(norm(value));
113
+ return test === 'has' ? found : !found;
114
+ }
115
+ case 'share': {
116
+ const s = share(have, condition.item, value);
117
+ if (!s) return false;
118
+ const right = Math.abs(s.charged - s.expected) <= 1;
119
+ return test === 'is' ? right : !right;
120
+ }
121
+ case 'number':
122
+ case 'money':
123
+ if (have === null) return false;
124
+ if (test === 'more_than') return have > value;
125
+ if (test === 'at_most') return have <= value;
126
+ if (test === 'is') return have === value;
127
+ return have !== value;
128
+ case 'boolean':
129
+ return have === value;
130
+ default: {
131
+ const left = norm(have);
132
+ if (test === 'is_one_of') return value.some((v) => norm(v) === left);
133
+ return test === 'is' ? left === norm(value) : left !== norm(value);
134
+ }
135
+ }
136
+ }
137
+
138
+ // The reasons the enabled rules give for one job, in rule order. An empty list
139
+ // means no rule stops its draft. `customer` is the customer's own billing
140
+ // settings (invoicing-rules.js).
141
+ export function ruleReasons(rules, job, { customer = {}, now = new Date() } = {}) {
142
+ const context = { customer, now };
143
+ const reasons = [];
144
+ for (const rule of rules) {
145
+ if (!rule.enabled || rule.trigger !== 'job_ready') continue;
146
+ if (!rule.conditions.every((c) => matches(c, job, context))) continue;
147
+ reasons.push(rule.action.kind === 'skip' ? `rule "${rule.name}": not drafted by rule` : `rule "${rule.name}": waits for a person`);
148
+ }
149
+ return reasons;
150
+ }
151
+
152
+ // The starter rules made from the earlier fixed settings.
153
+ export function starterRules(settings) {
154
+ const rules = [
155
+ { starterKey: 'not_billable', name: 'Customer not billable', enabled: true, conditions: [{ field: 'customer_billable', test: 'is', value: false }], action: { kind: 'skip' } },
156
+ { starterKey: 'zero_total', name: 'No amount', enabled: true, conditions: [{ field: 'job_total', test: 'at_most', value: 0 }], action: { kind: 'wait' } },
157
+ { starterKey: 'old_job', name: 'Old job', enabled: true, conditions: [{ field: 'job_age_days', test: 'more_than', value: settings.staleDays ?? 90 }], action: { kind: 'wait' } },
158
+ { starterKey: 'practitioner', name: 'Practitioner required', enabled: Boolean(settings.requirePractitioner), conditions: [{ field: 'practitioner', test: 'is_empty' }], action: { kind: 'wait' } },
159
+ ];
160
+ if (settings.rushItemCode) {
161
+ rules.push({
162
+ starterKey: 'rush_share', name: 'Rush charge share', enabled: true,
163
+ conditions: [{ field: 'line_share', item: settings.rushItemCode, test: 'is_not', value: settings.rushPercent ?? 20 }],
164
+ action: { kind: 'wait' },
165
+ });
166
+ }
167
+ return rules.map((r) => ({ trigger: 'job_ready', ...r }));
168
+ }
169
+
170
+ // Makes this organisation's starter rules once, from its fixed settings.
171
+ async function seed(db, organisationId) {
172
+ await db.transaction(async (tx) => {
173
+ await tx.insert(invoicingSettings).values({ organisationId }).onConflictDoNothing();
174
+ const [settings] = await tx.select().from(invoicingSettings).where(eq(invoicingSettings.organisationId, organisationId)).for('update');
175
+ if (settings.rulesSeeded) return;
176
+ const rules = starterRules(settings);
177
+ await tx.insert(invoiceRules).values(rules.map((r, position) => ({ organisationId, position, ...r }))).onConflictDoNothing();
178
+ await tx.update(invoicingSettings).set({ rulesSeeded: true }).where(eq(invoicingSettings.organisationId, organisationId));
179
+ });
180
+ }
181
+
182
+ const shape = (r) => ({
183
+ id: r.id, name: r.name, position: r.position, enabled: r.enabled, trigger: r.trigger,
184
+ conditions: r.conditions, action: r.action, starter: Boolean(r.starterKey), updatedAt: r.updatedAt,
185
+ });
186
+
187
+ // The organisation's rules, in order. The first read makes the starter rules.
188
+ export async function listRules(db, organisationId) {
189
+ const [settings] = await db.select({ seeded: invoicingSettings.rulesSeeded }).from(invoicingSettings)
190
+ .where(eq(invoicingSettings.organisationId, organisationId));
191
+ if (!settings?.seeded) await seed(db, organisationId);
192
+ const rows = await db.select().from(invoiceRules).where(eq(invoiceRules.organisationId, organisationId))
193
+ .orderBy(asc(invoiceRules.position), asc(invoiceRules.createdAt));
194
+ return rows.map(shape);
195
+ }
196
+
197
+ export async function createRule(db, organisationId, raw, { personId = null } = {}) {
198
+ const rule = readRule(raw);
199
+ const existing = await listRules(db, organisationId);
200
+ if (existing.length >= MAX_RULES) throw invalid(`an organisation can have ${MAX_RULES} rules at most`);
201
+ const position = existing.length ? Math.max(...existing.map((r) => r.position)) + 1 : 0;
202
+ const [row] = await db.insert(invoiceRules).values({ organisationId, position, ...rule, updatedBy: personId }).returning();
203
+ return shape(row);
204
+ }
205
+
206
+ async function loadRule(db, organisationId, ruleId) {
207
+ const [row] = await db.select().from(invoiceRules).where(and(eq(invoiceRules.organisationId, organisationId), eq(invoiceRules.id, ruleId)));
208
+ if (!row) throw notFound('rule');
209
+ return row;
210
+ }
211
+
212
+ // Changes a rule. `changes` holds any of name, enabled, conditions, action;
213
+ // the result must be a whole valid rule.
214
+ export async function updateRule(db, organisationId, ruleId, changes, { personId = null } = {}) {
215
+ const row = await loadRule(db, organisationId, ruleId);
216
+ if (!changes || typeof changes !== 'object' || Array.isArray(changes)) throw invalid('the change must be an object');
217
+ const rule = readRule({ name: row.name, enabled: row.enabled, conditions: row.conditions, action: row.action, ...changes });
218
+ const [saved] = await db.update(invoiceRules).set({ ...rule, updatedBy: personId, updatedAt: sql`now()` })
219
+ .where(eq(invoiceRules.id, row.id)).returning();
220
+ return shape(saved);
221
+ }
222
+
223
+ export async function deleteRule(db, organisationId, ruleId) {
224
+ const row = await loadRule(db, organisationId, ruleId);
225
+ await db.delete(invoiceRules).where(eq(invoiceRules.id, row.id));
226
+ }
@@ -39,9 +39,11 @@ async function resolveLines(tx, organisationId, lines) {
39
39
  } catch (error) {
40
40
  throw new FlowError('invalid', `line ${index + 1}: ${error.message}`);
41
41
  }
42
+ const accountCode = line.priceItemId ? null : (typeof line.accountCode === 'string' && line.accountCode.trim() ? line.accountCode.trim().slice(0, 20) : null);
42
43
  resolved.push({
43
44
  position: index + 1,
44
45
  priceItemId: line.priceItemId ?? null,
46
+ accountCode,
45
47
  description: description.trim(),
46
48
  quantity,
47
49
  unitAmountCents,
@@ -96,6 +98,8 @@ export async function updateDraftInvoice(db, organisationId, invoiceId, changes)
96
98
  }
97
99
  if ('reference' in changes) fields.reference = changes.reference;
98
100
  if ('dueDate' in changes) fields.dueDate = changes.dueDate;
101
+ // A changed draft needs a person to check it again.
102
+ if (Object.keys(fields).length || 'lines' in changes) Object.assign(fields, { readyAt: null, readyBy: null });
99
103
  let row = invoice;
100
104
  if (Object.keys(fields).length) {
101
105
  [row] = await tx.update(invoices).set({ ...fields, updatedAt: sql`now()` })
@@ -139,8 +143,8 @@ export async function issueInvoice(db, organisationId, invoiceId, { issuedBy, is
139
143
  reference: invoice.reference,
140
144
  currency: invoice.currency,
141
145
  contact: { id: contact.id, name: contact.name, email: contact.email },
142
- lines: lines.map(({ line: { position, description, quantity, unitAmountCents, taxCode, lineAmountCents, taxCents }, itemCode }) => (
143
- { position, itemCode: itemCode ?? null, description, quantity, unitAmountCents, taxCode, lineAmountCents, taxCents }
146
+ lines: lines.map(({ line: { position, description, quantity, unitAmountCents, taxCode, lineAmountCents, taxCents, accountCode }, itemCode }) => (
147
+ { position, itemCode: itemCode ?? null, ...(accountCode ? { accountCode } : {}), description, quantity, unitAmountCents, taxCode, lineAmountCents, taxCents }
144
148
  )),
145
149
  subtotalCents: invoice.subtotalCents,
146
150
  taxCents: invoice.taxCents,
@@ -160,7 +164,8 @@ export async function reviseInvoice(db, organisationId, invoiceId) {
160
164
  return db.transaction(async (tx) => {
161
165
  const invoice = await loadInvoice(tx, organisationId, invoiceId, { lock: true });
162
166
  if (invoice.status !== 'issued') throw new FlowError('not_issued', 'only an issued invoice can be revised');
163
- const [row] = await tx.update(invoices).set({ status: 'draft', updatedAt: sql`now()` })
167
+ // A revision is a new version: what happened to the last send no longer applies.
168
+ const [row] = await tx.update(invoices).set({ status: 'draft', readyAt: null, readyBy: null, sendState: null, sendError: null, updatedAt: sql`now()` })
164
169
  .where(eq(invoices.id, invoice.id)).returning();
165
170
  return row;
166
171
  });