@arcforgelabs/flow 2026.10.30 → 2026.10.32

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 (79) hide show
  1. package/README.md +2 -0
  2. package/npm-shrinkwrap.json +188 -2
  3. package/package.json +2 -1
  4. package/server/api/alerts.js +49 -0
  5. package/server/api/invoicing.js +125 -7
  6. package/server/app.js +29 -2
  7. package/server/core/alerts.js +157 -0
  8. package/server/core/automations.js +17 -3
  9. package/server/core/history.js +65 -0
  10. package/server/core/invoice-pdf.js +266 -0
  11. package/server/core/invoices.js +2 -2
  12. package/server/core/invoicing-rules.js +26 -1
  13. package/server/core/job-board.js +25 -3
  14. package/server/core/ledger.js +3 -2
  15. package/server/core/line-mapping.js +36 -10
  16. package/server/core/pipeline.js +76 -4
  17. package/server/core/price-list.js +348 -0
  18. package/server/core/references.js +99 -0
  19. package/server/core/sending.js +11 -5
  20. package/server/db/commands.js +42 -0
  21. package/server/db/doctor.js +168 -0
  22. package/server/db/migrations/0014_price_list.sql +34 -0
  23. package/server/db/migrations/0015_alert_dismissals.sql +17 -0
  24. package/server/db/migrations/0016_invoice_reference.sql +1 -0
  25. package/server/db/migrations/meta/0014_snapshot.json +3773 -0
  26. package/server/db/migrations/meta/0015_snapshot.json +3854 -0
  27. package/server/db/migrations/meta/0016_snapshot.json +3861 -0
  28. package/server/db/migrations/meta/_journal.json +21 -0
  29. package/server/db/schema.js +46 -0
  30. package/server/flow-server.js +20 -1
  31. package/server/index.js +2 -1
  32. package/server/integrations/call-guard.js +362 -0
  33. package/server/integrations/port.js +21 -1
  34. package/server/integrations/seazona/feed.js +31 -2
  35. package/server/integrations/seazona/governor.js +50 -122
  36. package/server/integrations/seazona/history/backfill.js +208 -0
  37. package/server/integrations/seazona/history/csv.js +65 -0
  38. package/server/integrations/seazona/history/index.js +584 -0
  39. package/server/integrations/seazona/history/read.js +196 -0
  40. package/server/integrations/seazona/index.js +46 -1
  41. package/server/integrations/seazona/lanes.js +187 -51
  42. package/server/integrations/seazona/migrations/0003_call_guard.sql +2 -0
  43. package/server/integrations/seazona/migrations/0004_history_import.sql +68 -0
  44. package/server/integrations/seazona/migrations/meta/0003_snapshot.json +895 -0
  45. package/server/integrations/seazona/migrations/meta/0004_snapshot.json +1323 -0
  46. package/server/integrations/seazona/migrations/meta/_journal.json +14 -0
  47. package/server/integrations/seazona/patients.js +30 -0
  48. package/server/integrations/seazona/routes.js +145 -0
  49. package/server/integrations/seazona/schema.js +94 -0
  50. package/server/integrations/seazona/settings.js +14 -3
  51. package/server/integrations/seazona/sync-once.js +1 -1
  52. package/server/integrations/seazona/tables.js +4 -0
  53. package/server/integrations/xero/client.js +39 -7
  54. package/server/integrations/xero/drafts.js +46 -8
  55. package/server/integrations/xero/import.js +131 -17
  56. package/server/integrations/xero/index.js +38 -7
  57. package/server/integrations/xero/lookup.js +26 -0
  58. package/server/integrations/xero/migrations/0006_xero_call_guard.sql +26 -0
  59. package/server/integrations/xero/migrations/0007_xero_webhooks.sql +30 -0
  60. package/server/integrations/xero/migrations/meta/_journal.json +14 -0
  61. package/server/integrations/xero/mirror.js +27 -10
  62. package/server/integrations/xero/oauth.js +4 -1
  63. package/server/integrations/xero/reconcile.js +173 -0
  64. package/server/integrations/xero/routes.js +55 -0
  65. package/server/integrations/xero/runtime.js +45 -4
  66. package/server/integrations/xero/settings.js +3 -0
  67. package/server/integrations/xero/webhooks.js +311 -0
  68. package/server/release-channel.js +3 -3
  69. package/server/ui/alerts.js +77 -0
  70. package/server/ui/app-page.js +1 -1
  71. package/server/ui/app.js +63 -21
  72. package/server/ui/clerk-appearance.js +42 -9
  73. package/server/ui/flow.css +78 -9
  74. package/server/ui/invoice-notes.js +26 -0
  75. package/server/ui/invoicing-settings.js +4 -2
  76. package/server/ui/invoicing.js +241 -49
  77. package/server/ui/line-mappings.js +3 -2
  78. package/server/ui/price-list.js +247 -0
  79. package/server/ui/routes.js +1 -1
package/server/app.js CHANGED
@@ -1,7 +1,10 @@
1
1
  import express from 'express';
2
+ import { createAlertsRouter } from './api/alerts.js';
2
3
  import { createConnectorApi } from './api/connector.js';
3
4
  import { createInvoicingRouter } from './api/invoicing.js';
4
5
  import { createSignInRouter } from './auth/routes.js';
6
+ import { integrationAlertsSource, invoiceProblemsSource, priceDifferencesSource } from './core/alerts.js';
7
+ import { appliedMigrationCounts } from './db/doctor.js';
5
8
  import { deployedBuildStatus } from './deployed-build.js';
6
9
  import { integrationsHealth } from './integrations/health.js';
7
10
  import { createSetupRouter } from './integrations/setup.js';
@@ -27,10 +30,14 @@ import { createUiAssetsRouter } from './ui/routes.js';
27
30
  // each one (server/integrations/health.js). `defaultFamily` is the design
28
31
  // system every page starts on (FLOW_THEME, server/theme/themes.js). `log`
29
32
  // is the console, or the monitored log (server/core/monitoring.js) that also
30
- // sends each error to the installation's error service.
33
+ // sends each error to the installation's error service. `schemaJournals` are
34
+ // the migration journals this build ships (server/db/doctor.js): GET /health
35
+ // reports how many of each the database has applied, so the workspace's
36
+ // doctor can tell what Replit's publish step will offer (docs/REPLIT.md).
31
37
  export function createApp({
32
38
  build = deployedBuildStatus(),
33
39
  database = null,
40
+ schemaJournals = null,
34
41
  auth = null,
35
42
  updates = null,
36
43
  integrations = null,
@@ -59,6 +66,11 @@ export function createApp({
59
66
  // records). An integration problem does not fail the health check: Flow's
60
67
  // core works with no integration connected (VISION.md).
61
68
  const body = { ok, service: 'flow', database: databaseOk, build };
69
+ // Counts only: no table names and no data.
70
+ if (databaseOk && schemaJournals) {
71
+ body.schema = await appliedMigrationCounts(database.pool, schemaJournals)
72
+ .then((migrations) => ({ migrations }), () => null);
73
+ }
62
74
  if (integrations) body.integrations = await integrationsHealth(integrations);
63
75
  res.status(ok ? 200 : 503).json(body);
64
76
  });
@@ -116,7 +128,22 @@ export function createApp({
116
128
  const accounting = () => integrations?.with('pushInvoices')[0] ?? null;
117
129
  // The Jobs board: every enabled integration that lists its jobs (Seazona).
118
130
  const work = () => integrations?.with('listWork') ?? [];
119
- app.use('/api/invoicing', createInvoicingRouter({ db: database.db, personAuth: auth.personAuth, log, liveCheckFor, accounting, work }));
131
+ // The price list: every enabled integration that keeps its own (Xero
132
+ // items, Seazona products).
133
+ const priceSystems = () => integrations?.with('listPriceItems') ?? [];
134
+ app.use('/api/invoicing', createInvoicingRouter({ db: database.db, personAuth: auth.personAuth, log, liveCheckFor, accounting, work, priceSystems }));
135
+ // Quiet alerts (server/core/alerts.js): what needs a person on each page.
136
+ // Integrations add their own through the `alerts` capability.
137
+ app.use('/api/alerts', createAlertsRouter({
138
+ db: database.db,
139
+ personAuth: auth.personAuth,
140
+ log,
141
+ sources: {
142
+ invoices: [invoiceProblemsSource(database.db, { accounting })],
143
+ 'price-list': [priceDifferencesSource(database.db)],
144
+ integrations: [integrationAlertsSource(() => integrations?.with('alerts') ?? [])],
145
+ },
146
+ }));
120
147
  }
121
148
  // What a signed-in browser needs to report its errors and problems to the
122
149
  // installation's own error service (docs/SENTRY.md): the first integration
@@ -0,0 +1,157 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { and, eq, inArray, sql } from 'drizzle-orm';
3
+ import { alertDismissals, invoices } from '../db/schema.js';
4
+ import { FlowError } from './errors.js';
5
+ import { listReview } from './review.js';
6
+ import { invoiceViews } from './sending.js';
7
+
8
+ // Quiet alerts (issue 117, docs/ALERTS.md). An alert says that something on
9
+ // one page needs a person. Nothing shows when nothing does: there is no feed,
10
+ // no "done" message, no email and no sound. An alert is worked out each time
11
+ // a page asks, from Flow's own records (the review queue, the Invoices views,
12
+ // each integration's call guard), so it goes away for everyone as soon as its
13
+ // cause is fixed. Only a person's dismissal is stored: it hides the alert for
14
+ // that person until something new joins it.
15
+ //
16
+ // An alert is { key, page, tone, title, action, moneyAtRiskCents, roles,
17
+ // members }. `key` starts with its page. `members` are the things it is
18
+ // about (an invoice id, a review item and its evidence, a stop and its time);
19
+ // they never leave the server. `roles` are who may see it. `action`, when
20
+ // there is one, is { label, href } or { label, tab } for a tab on the page.
21
+
22
+ export const ALERT_PAGES = Object.freeze(['invoices', 'price-list', 'integrations']);
23
+ const ALL_ROLES = Object.freeze(['owner', 'admin', 'staff']);
24
+ const MANAGERS = Object.freeze(['owner', 'admin']);
25
+ const TONES = new Set(['warn', 'danger']);
26
+ // A dismissal keeps at most this many members. An alert about more things
27
+ // than that comes back after a dismiss: it is too large to set aside.
28
+ const MAX_MEMBERS = 500;
29
+
30
+ const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
31
+ const digest = (value) => createHash('sha256').update(JSON.stringify(value ?? null)).digest('hex').slice(0, 16);
32
+
33
+ function checkAlert(alert) {
34
+ if (!ALERT_PAGES.includes(alert.page) || !alert.key?.startsWith(`${alert.page}:`)) {
35
+ throw new TypeError(`alert ${alert.key} must start with its page`);
36
+ }
37
+ if (!TONES.has(alert.tone)) throw new TypeError(`alert ${alert.key} tone must be warn or danger`);
38
+ if (!Array.isArray(alert.members) || !alert.members.length) throw new TypeError(`alert ${alert.key} needs members`);
39
+ return { moneyAtRiskCents: 0, roles: ALL_ROLES, action: null, ...alert, members: alert.members.map(String) };
40
+ }
41
+
42
+ // ── Sources ────────────────────────────────────────────────────────────────
43
+
44
+ // Invoices: drafts a check stopped and issued invoices the accounting system
45
+ // refused (server/core/sending.js). One alert for all of them.
46
+ export function invoiceProblemsSource(db, { accounting = () => null } = {}) {
47
+ return async (organisationId) => {
48
+ const { problems } = await invoiceViews(db, organisationId, { accounting: accounting() });
49
+ const ids = [...problems.keys()];
50
+ if (!ids.length) return [];
51
+ const [{ total }] = await db.select({ total: sql`coalesce(sum(${invoices.totalCents}), 0)` }).from(invoices)
52
+ .where(and(eq(invoices.organisationId, organisationId), inArray(invoices.id, ids)));
53
+ return [{
54
+ key: 'invoices:problems',
55
+ page: 'invoices',
56
+ tone: 'warn',
57
+ title: `${plural(ids.length, 'invoice has', 'invoices have')} a problem.`,
58
+ action: { label: 'Open Problem', tab: 'problem' },
59
+ moneyAtRiskCents: Number(total),
60
+ members: ids,
61
+ }];
62
+ };
63
+ }
64
+
65
+ // Price list: codes whose price, tax or name differs in a connected system
66
+ // (server/core/price-list.js). A difference is an alert only: the person
67
+ // fixes it at the source, or marks it resolved on the code.
68
+ export function priceDifferencesSource(db) {
69
+ return async (organisationId) => {
70
+ const open = await listReview(db, organisationId, { kind: 'price_check', limit: MAX_MEMBERS });
71
+ if (!open.length) return [];
72
+ return [{
73
+ key: 'price-list:differences',
74
+ page: 'price-list',
75
+ tone: 'warn',
76
+ title: `${plural(open.length, 'code differs', 'codes differ')} from a connected system. Fix it at the source, or open the code and mark it resolved.`,
77
+ moneyAtRiskCents: open.reduce((sum, item) => sum + Number(item.moneyAtRiskCents ?? 0), 0),
78
+ members: open.map((item) => `${item.id}:${digest(item.evidence)}`),
79
+ }];
80
+ };
81
+ }
82
+
83
+ // Integrations: each enabled integration that offers `alerts` says what only
84
+ // a person can clear (a refused sign-in, a stopped call). Owners and admins
85
+ // see these; staff cannot change integrations.
86
+ export function integrationAlertsSource(adapters = () => []) {
87
+ return async (organisationId) => {
88
+ const found = await Promise.all(adapters().map(async (adapter) => {
89
+ const items = await adapter.capabilities.alerts({ organisationId });
90
+ return (items ?? []).map((item) => ({
91
+ key: `integrations:${adapter.id}:${item.id}`,
92
+ page: 'integrations',
93
+ tone: item.tone ?? 'danger',
94
+ title: item.title,
95
+ action: { label: `Open ${adapter.name}`, href: `/integrations/${adapter.id}` },
96
+ roles: MANAGERS,
97
+ members: item.members,
98
+ }));
99
+ }));
100
+ return found.flat();
101
+ };
102
+ }
103
+
104
+ // ── Reading and dismissing ─────────────────────────────────────────────────
105
+
106
+ async function collect(sources, page, organisationId) {
107
+ const list = await Promise.all((sources[page] ?? []).map((source) => source(organisationId)));
108
+ return list.flat().map(checkAlert);
109
+ }
110
+
111
+ // The alerts one person should see on one page, worst first: danger before
112
+ // warn, then most money at risk. A dismissed alert stays hidden while every
113
+ // member it has now was a member when it was dismissed.
114
+ // `scope` narrows a page to one part of it: an integration's own page
115
+ // (/integrations/xero) shows only the alerts whose key is integrations:xero:….
116
+ // `withHidden` also answers the alerts this person dismissed, each marked
117
+ // `hidden`, for a page that shows a state (a red status) whatever the line.
118
+ export async function alertsFor(db, organisationId, person, { page, scope = null, sources, withHidden = false }) {
119
+ if (!ALERT_PAGES.includes(page)) throw new FlowError('invalid', `page must be one of ${ALERT_PAGES.join(', ')}`);
120
+ const prefix = scope ? `${page}:${scope}:` : null;
121
+ const alerts = (await collect(sources, page, organisationId))
122
+ .filter((a) => a.roles.includes(person.role) && (!prefix || a.key.startsWith(prefix)));
123
+ if (!alerts.length) return [];
124
+ const rows = await db.select().from(alertDismissals).where(and(
125
+ eq(alertDismissals.organisationId, organisationId),
126
+ eq(alertDismissals.personId, person.id),
127
+ inArray(alertDismissals.alertKey, alerts.map((a) => a.key)),
128
+ ));
129
+ const dismissed = new Map(rows.map((r) => [r.alertKey, new Set(r.members)]));
130
+ return alerts
131
+ .map((a) => ({ ...a, hidden: dismissed.has(a.key) && a.members.every((m) => dismissed.get(a.key).has(m)) }))
132
+ .filter((a) => withHidden || !a.hidden)
133
+ .sort((a, b) => (a.tone === b.tone ? 0 : a.tone === 'danger' ? -1 : 1) || b.moneyAtRiskCents - a.moneyAtRiskCents);
134
+ }
135
+
136
+ // What the browser gets: no members, only how many there are.
137
+ export const alertForBrowser = ({ key, tone, title, action, moneyAtRiskCents, members }) => ({
138
+ key, tone, title, action, moneyAtRiskCents, count: members.length,
139
+ });
140
+
141
+ // Hides one alert for one person. The server works the alert out again, so
142
+ // the stored members are what the person saw, not what a browser sent.
143
+ // Dismissing an alert that is gone is not an error.
144
+ export async function dismissAlert(db, organisationId, person, { key, sources }) {
145
+ const page = ALERT_PAGES.find((p) => String(key ?? '').startsWith(`${p}:`));
146
+ if (!page) throw new FlowError('invalid', 'key must name an alert');
147
+ const alert = (await collect(sources, page, organisationId)).find((a) => a.key === key && a.roles.includes(person.role));
148
+ if (!alert) return { dismissed: false };
149
+ const members = alert.members.slice(0, MAX_MEMBERS);
150
+ await db.insert(alertDismissals)
151
+ .values({ organisationId, personId: person.id, alertKey: key, members })
152
+ .onConflictDoUpdate({
153
+ target: [alertDismissals.organisationId, alertDismissals.personId, alertDismissals.alertKey],
154
+ set: { members, dismissedAt: sql`now()` },
155
+ });
156
+ return { dismissed: true };
157
+ }
@@ -1,9 +1,10 @@
1
1
  import { and, eq, sql } from 'drizzle-orm';
2
2
  import { automations, billingObligations, invoices } from '../db/schema.js';
3
3
  import { FlowError, notFound } from './errors.js';
4
- import { autoDraftJobs } from './invoicing-rules.js';
4
+ import { autoDraftJobs, readInvoicingSettings } from './invoicing-rules.js';
5
5
  import { MAPPING_DEFAULTS, readMappingConfig, remapJobs } from './line-mapping.js';
6
6
  import { setReady } from './ledger.js';
7
+ import { refreshReferences } from './references.js';
7
8
 
8
9
  // Automations (docs/AUTOMATIONS.md): Flow's routines. Each one watches Flow's
9
10
  // own records and moves them on, and each is on or off with its own settings,
@@ -19,6 +20,8 @@ import { setReady } from './ledger.js';
19
20
  // rules (invoicing-rules.js) gets a draft invoice in Flow.
20
21
  // ready_when_drafted: a draft the draft routine made goes straight to Ready
21
22
  // on the Invoices page, until a person changes it or moves it back.
23
+ // reference_from_rule: a draft made from a job keeps the Reference the
24
+ // lab's Reference rule gives (references.js), as the job's fields arrive.
22
25
 
23
26
  const BATCH = 500;
24
27
 
@@ -92,6 +95,14 @@ export const AUTOMATIONS = Object.freeze([
92
95
  defaults: { enabled: true, config: {} },
93
96
  readConfig: () => ({}),
94
97
  },
98
+ {
99
+ key: 'reference_from_rule',
100
+ place: 'invoicing',
101
+ name: 'Reference from the rule',
102
+ hint: 'A draft made from a job gets its Reference from the Reference rule. A Reference a person typed stays.',
103
+ defaults: { enabled: true, config: {} },
104
+ readConfig: () => ({}),
105
+ },
95
106
  ]);
96
107
 
97
108
  const byKey = new Map(AUTOMATIONS.map((a) => [a.key, a]));
@@ -169,16 +180,19 @@ async function readyWhenDrafted(db, organisationId) {
169
180
 
170
181
  // Runs every routine that is on. `accounting` is the integration whose copy
171
182
  // the draft routine checks; without one, Flow's own records are the check.
172
- export async function runAutomations(db, organisationId, { accounting = null, now = new Date() } = {}) {
183
+ export async function runAutomations(db, organisationId, { accounting = null, work = null, now = new Date() } = {}) {
173
184
  const summary = {};
174
185
  for (const automation of await listAutomations(db, organisationId)) {
175
186
  if (!automation.enabled) continue;
176
187
  if (automation.key === 'mark_ready_from_work_status') {
177
188
  summary[automation.key] = await markReadyFromWorkStatus(db, organisationId, automation.config);
178
189
  } else if (automation.key === 'draft_when_ready') {
179
- summary[automation.key] = await autoDraftJobs(db, organisationId, { accounting, now, actor: actorOf(automation.key) });
190
+ summary[automation.key] = await autoDraftJobs(db, organisationId, { accounting, work, now, actor: actorOf(automation.key) });
180
191
  } else if (automation.key === 'ready_when_drafted') {
181
192
  summary[automation.key] = await readyWhenDrafted(db, organisationId);
193
+ } else if (automation.key === 'reference_from_rule') {
194
+ const { referenceTemplate: template } = await readInvoicingSettings(db, organisationId);
195
+ summary[automation.key] = await refreshReferences(db, organisationId, { template, work });
182
196
  }
183
197
  }
184
198
  return summary;
@@ -0,0 +1,65 @@
1
+ import { and, asc, eq, sql } from 'drizzle-orm';
2
+ import { billingObligations } from '../db/schema.js';
3
+ import { FlowError, notFound } from './errors.js';
4
+ import { setHold } from './ledger.js';
5
+
6
+ // Past jobs from a work system's history import (docs/SEAZONA.md, "History
7
+ // import"). The pipeline holds each new one (pipeline.js, ingestHistory), so
8
+ // it never drafts. The matcher still sets its accounting state. Only a
9
+ // person's release moves one into drafting.
10
+
11
+ export const HISTORY_HOLD_REASON = 'history_import';
12
+ export const HISTORY_ACTOR = 'import:seazona_history';
13
+
14
+ export const isHistoryHeld = (obligation) => obligation?.holdState === 'held' && obligation?.holdReason === HISTORY_HOLD_REASON;
15
+
16
+ // Accounting states that need a person: no invoice found, or more than one fits.
17
+ export const HISTORY_NEEDS_PERSON = ['not_found', 'ambiguous', 'conflict'];
18
+
19
+ const historyHeld = (organisationId) => and(
20
+ eq(billingObligations.organisationId, organisationId),
21
+ eq(billingObligations.holdState, 'held'),
22
+ eq(billingObligations.holdReason, HISTORY_HOLD_REASON),
23
+ );
24
+
25
+ // Held history jobs by accounting state: counts, and one page of jobs
26
+ // (`state` one accounting state, or `needs_person`).
27
+ export async function listHistoryJobs(db, organisationId, { state = null, limit = 100, offset = 0 } = {}) {
28
+ const counts = Object.fromEntries((await db.select({ state: billingObligations.accountingState, n: sql`count(*)::int` })
29
+ .from(billingObligations).where(historyHeld(organisationId)).groupBy(billingObligations.accountingState))
30
+ .map((r) => [r.state, r.n]));
31
+ const filters = [historyHeld(organisationId)];
32
+ if (state === 'needs_person') filters.push(sql`${billingObligations.accountingState} in ('not_found', 'ambiguous', 'conflict')`);
33
+ else if (state) filters.push(eq(billingObligations.accountingState, state));
34
+ const jobs = await db.select().from(billingObligations).where(and(...filters))
35
+ .orderBy(asc(billingObligations.jobDate), asc(billingObligations.sourceJobRef))
36
+ .limit(Math.max(1, Math.min(500, Number(limit) || 100))).offset(Math.max(0, Number(offset) || 0));
37
+ const needsPerson = HISTORY_NEEDS_PERSON.reduce((sum, s) => sum + (counts[s] ?? 0), 0);
38
+ return {
39
+ counts: { ...counts, total: Object.values(counts).reduce((a, b) => a + b, 0), needsPerson },
40
+ jobs: jobs.map((o) => ({
41
+ id: o.id,
42
+ sourceJobRef: o.sourceJobRef,
43
+ sourceInvoiceRef: o.sourceInvoiceRef,
44
+ jobDate: o.jobDate,
45
+ contactId: o.contactId,
46
+ practitioner: o.practitioner,
47
+ totalCents: o.totalCents,
48
+ accountingState: o.accountingState,
49
+ accountingRef: o.accountingRef,
50
+ accountingMatchedBy: o.accountingMatchedBy,
51
+ })),
52
+ };
53
+ }
54
+
55
+ // A person moves one held history job into drafting.
56
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
57
+
58
+ export async function releaseHistoryJob(db, organisationId, obligationId, { actor }) {
59
+ if (!UUID.test(String(obligationId))) throw Object.assign(new FlowError('invalid', 'the job id must be a Flow id'), { status: 400 });
60
+ const [row] = await db.select().from(billingObligations)
61
+ .where(and(eq(billingObligations.organisationId, organisationId), eq(billingObligations.id, obligationId)));
62
+ if (!row) throw Object.assign(notFound('job'), { status: 404 });
63
+ if (!isHistoryHeld(row)) throw Object.assign(new FlowError('conflict', 'the job is not a held history job'), { status: 409 });
64
+ return setHold(db, organisationId, obligationId, { holdState: 'none', actor });
65
+ }
@@ -0,0 +1,266 @@
1
+ import { and, asc, eq } from 'drizzle-orm';
2
+ import PDFDocument from 'pdfkit';
3
+ import { contacts, invoiceLines, invoiceVersions, organisations, priceItems } from '../db/schema.js';
4
+ import { FlowError, notFound } from './errors.js';
5
+ import { getInvoice } from './invoices.js';
6
+
7
+ // An invoice as a PDF, drawn from Flow's own data: it makes no calls to an
8
+ // accounting system. A draft is drawn from its lines as they are now. An
9
+ // issued or void invoice is drawn from the version Flow stored when it was
10
+ // issued, so the PDF shows what was sent. Every page carries an EXAMPLE mark:
11
+ // the customer's copy is the one the accounting system sends.
12
+ //
13
+ // Nothing on the PDF is made up. Once the accounting system holds the
14
+ // version, its number, reference and dates come from Flow's copy of that
15
+ // system (the adapter's documentFacts, no calls). A value nobody knows yet is
16
+ // drawn as a grey note that says so, never as a sample value.
17
+
18
+ // The document for one invoice: the stored version for an issued or void
19
+ // invoice (or the version asked for), else the draft's lines now.
20
+ // `accounting` is the accounting adapter, or null.
21
+ export async function invoicePdfDocument(db, organisationId, invoiceId, { version = null, accounting = null } = {}) {
22
+ const source = await flowDocument(db, organisationId, invoiceId, { version });
23
+ const ask = accounting?.capabilities?.documentFacts;
24
+ const facts = ask ? await ask({ organisationId, invoiceId, version: source.document.version ?? null }) : null;
25
+ return { ...source, facts };
26
+ }
27
+
28
+ async function flowDocument(db, organisationId, invoiceId, { version }) {
29
+ const invoice = await getInvoice(db, organisationId, invoiceId);
30
+ const [organisation] = await db.select({ name: organisations.name }).from(organisations)
31
+ .where(eq(organisations.id, organisationId));
32
+ if (version !== null || (invoice.issuedVersion > 0 && invoice.status !== 'draft')) {
33
+ const wanted = version ?? invoice.issuedVersion;
34
+ const [row] = await db.select().from(invoiceVersions)
35
+ .where(and(eq(invoiceVersions.organisationId, organisationId), eq(invoiceVersions.invoiceId, invoiceId), eq(invoiceVersions.version, wanted)));
36
+ if (!row) throw notFound('invoice version');
37
+ return { seller: organisation?.name ?? null, document: row.document };
38
+ }
39
+ const [contact] = await db.select({ id: contacts.id, name: contacts.name, email: contacts.email }).from(contacts)
40
+ .where(eq(contacts.id, invoice.contactId));
41
+ const lines = await db.select({ line: invoiceLines, itemCode: priceItems.code }).from(invoiceLines)
42
+ .leftJoin(priceItems, eq(priceItems.id, invoiceLines.priceItemId))
43
+ .where(eq(invoiceLines.invoiceId, invoice.id)).orderBy(asc(invoiceLines.position));
44
+ return {
45
+ seller: organisation?.name ?? null,
46
+ document: {
47
+ number: invoice.number,
48
+ version: null,
49
+ issueDate: invoice.issueDate,
50
+ dueDate: invoice.dueDate,
51
+ reference: invoice.reference,
52
+ currency: invoice.currency,
53
+ contact: contact ?? null,
54
+ lines: lines.map(({ line, itemCode }) => ({
55
+ position: line.position,
56
+ itemCode: itemCode ?? null,
57
+ description: line.description,
58
+ quantity: line.quantity,
59
+ unitAmountCents: line.unitAmountCents,
60
+ taxCode: line.taxCode,
61
+ lineAmountCents: line.lineAmountCents,
62
+ taxCents: line.taxCents,
63
+ })),
64
+ subtotalCents: invoice.subtotalCents,
65
+ taxCents: invoice.taxCents,
66
+ totalCents: invoice.totalCents,
67
+ },
68
+ };
69
+ }
70
+
71
+ // The reference the accounting system gets: the invoice's own reference, else
72
+ // its number (the same rule as server/integrations/xero/drafts.js).
73
+ export function pdfReference(document) {
74
+ return document.reference || document.number || null;
75
+ }
76
+
77
+ function addDays(date, days) {
78
+ const at = new Date(`${date}T00:00:00Z`);
79
+ at.setUTCDate(at.getUTCDate() + days);
80
+ return at.toISOString().slice(0, 10);
81
+ }
82
+
83
+ // The number, reference and dates the PDF shows. Each is { value } when it is
84
+ // known, or { missing } with a short note on why it is not.
85
+ export function pdfFields({ document: doc, facts = null }) {
86
+ const known = (value) => ({ value });
87
+ const missing = (note) => ({ missing: note });
88
+ if (facts?.sent) {
89
+ const fromCopy = (value) => (value ? known(value) : missing(`Not in Flow's copy of ${facts.system}`));
90
+ return {
91
+ number: fromCopy(facts.number),
92
+ reference: fromCopy(facts.reference),
93
+ issueDate: fromCopy(facts.issueDate),
94
+ dueDate: fromCopy(facts.dueDate),
95
+ };
96
+ }
97
+ const notIssued = missing('Set when issued');
98
+ const issueDate = doc.issueDate ? known(doc.issueDate) : notIssued;
99
+ let dueDate = doc.dueDate ? known(doc.dueDate) : notIssued;
100
+ // The accounting system sets the due date from the invoice date when Flow
101
+ // sends none (Xero: XERO_DEFAULT_DUE_DAYS).
102
+ if (!doc.dueDate && doc.issueDate && facts?.defaultDueDays) dueDate = known(addDays(doc.issueDate, facts.defaultDueDays));
103
+ const reference = pdfReference(doc);
104
+ return {
105
+ number: facts?.numberedBy === 'xero'
106
+ ? missing(`${facts.system} sets it when sent`)
107
+ : doc.number ? known(doc.number) : notIssued,
108
+ reference: reference ? known(reference) : notIssued,
109
+ issueDate,
110
+ dueDate,
111
+ };
112
+ }
113
+
114
+ export function pdfFilename(fields) {
115
+ const name = String(fields.number.value || 'invoice').replace(/[^A-Za-z0-9._-]+/g, '-');
116
+ return `${name}.pdf`;
117
+ }
118
+
119
+ const C = { ink: '#000000', body: '#333333', muted: '#666666', rule: '#bbbbbb', mark: '#e6e6e6' };
120
+
121
+ function money(cents) {
122
+ return new Intl.NumberFormat('en-AU', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(cents / 100);
123
+ }
124
+
125
+ function showDate(value) {
126
+ const [y, m, d] = String(value).slice(0, 10).split('-').map(Number);
127
+ return new Date(Date.UTC(y, m - 1, d)).toLocaleDateString('en-AU', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' });
128
+ }
129
+
130
+ // A4 PDF bytes for a document from invoicePdfDocument(). The layout follows
131
+ // the accounting system's standard invoice (Xero's standard branding theme),
132
+ // so the customer's copy looks much the same: title and customer on the
133
+ // left, dates and numbers in the middle, the seller on the right, then the
134
+ // lines, the totals and the due date. The payment advice slip is left out.
135
+ export function renderInvoicePdf({ seller, document: doc, facts = null }) {
136
+ if (!doc || !Array.isArray(doc.lines)) throw new FlowError('invalid', 'nothing to draw');
137
+ const fields = pdfFields({ document: doc, facts });
138
+ return new Promise((resolve, reject) => {
139
+ const pdf = new PDFDocument({
140
+ size: 'A4',
141
+ margins: { top: 50, bottom: 50, left: 56, right: 56 },
142
+ info: { Title: fields.number.value ? `Invoice ${fields.number.value}` : 'Invoice', Producer: 'Flow', Creator: 'Flow' },
143
+ });
144
+ const chunks = [];
145
+ pdf.on('data', (chunk) => chunks.push(chunk));
146
+ pdf.on('end', () => resolve(Buffer.concat(chunks)));
147
+ pdf.on('error', reject);
148
+
149
+ const left = pdf.page.margins.left;
150
+ const width = pdf.page.width - left - pdf.page.margins.right;
151
+ const bottom = pdf.page.height - pdf.page.margins.bottom;
152
+ const currency = doc.currency || 'AUD';
153
+ const regular = 'Helvetica';
154
+ const bold = 'Helvetica-Bold';
155
+ const italic = 'Helvetica-Oblique';
156
+
157
+ const drawMark = () => {
158
+ pdf.save();
159
+ pdf.rotate(-30, { origin: [pdf.page.width / 2, pdf.page.height / 2] });
160
+ pdf.font(bold).fontSize(96).fillColor(C.mark)
161
+ .text('EXAMPLE', 0, pdf.page.height / 2 - 60, { width: pdf.page.width, align: 'center', lineBreak: false });
162
+ pdf.restore();
163
+ };
164
+ drawMark();
165
+
166
+ // Title, top left.
167
+ pdf.font(regular).fontSize(26).fillColor(C.ink).text('TAX INVOICE', left, 56);
168
+
169
+ // Three columns: customer, invoice facts, seller.
170
+ const top = 110;
171
+ const colW = width / 3;
172
+ pdf.font(regular).fontSize(9).fillColor(C.ink).text(doc.contact?.name ?? '', left, top, { width: colW - 12 });
173
+ if (doc.contact?.email) pdf.fillColor(C.body).text(doc.contact.email, { width: colW - 12 });
174
+ const customerEnd = pdf.y;
175
+
176
+ // A value nobody knows yet is a grey italic note, never a sample value.
177
+ const field = (f, show = (v) => v) => (f.value ? { text: show(f.value), font: regular, color: C.body } : { text: f.missing, font: italic, color: C.muted });
178
+ const rows = [
179
+ ['Invoice Date', field(fields.issueDate, showDate)],
180
+ ['Invoice Number', field(fields.number)],
181
+ ['Reference', field(fields.reference)],
182
+ ];
183
+ let fy = top;
184
+ for (const [label, shown] of rows) {
185
+ pdf.font(bold).fontSize(9).fillColor(C.ink).text(label, left + colW, fy, { width: colW - 12 });
186
+ pdf.font(shown.font).fillColor(shown.color).text(shown.text, left + colW, pdf.y, { width: colW - 12 });
187
+ fy = pdf.y + 8;
188
+ }
189
+ pdf.font(regular).fontSize(9).fillColor(C.ink).text(seller ?? '', left + colW * 2, top, { width: colW });
190
+
191
+ // Lines.
192
+ const cols = [
193
+ { key: 'description', label: 'Description', width: width - 300, align: 'left' },
194
+ { key: 'quantity', label: 'Quantity', width: 60, align: 'right' },
195
+ { key: 'unit', label: 'Unit Price', width: 80, align: 'right' },
196
+ { key: 'tax', label: 'GST', width: 70, align: 'right' },
197
+ { key: 'amount', label: `Amount ${currency}`, width: 90, align: 'right' },
198
+ ];
199
+ const gap = 10;
200
+ const tableHead = (y) => {
201
+ let x = left;
202
+ pdf.font(bold).fontSize(9).fillColor(C.ink);
203
+ for (const col of cols) {
204
+ pdf.text(col.label, x, y, { width: col.width, align: col.align });
205
+ x += col.width;
206
+ }
207
+ pdf.moveTo(left, y + 14).lineTo(left + width, y + 14).lineWidth(0.75).strokeColor(C.ink).stroke();
208
+ return y + 22;
209
+ };
210
+ let y = tableHead(Math.max(customerEnd, fy, pdf.y) + 36);
211
+ for (const line of doc.lines) {
212
+ const cells = {
213
+ description: line.description,
214
+ quantity: Number(line.quantity).toFixed(2),
215
+ unit: money(line.unitAmountCents),
216
+ tax: line.taxCode === 'GST' ? '10%' : 'GST Free',
217
+ amount: money(line.lineAmountCents),
218
+ };
219
+ pdf.font(regular).fontSize(9);
220
+ const height = Math.max(12, pdf.heightOfString(cells.description, { width: cols[0].width - gap }));
221
+ if (y + height > bottom - 110) {
222
+ pdf.addPage();
223
+ drawMark();
224
+ y = tableHead(pdf.page.margins.top);
225
+ }
226
+ let x = left;
227
+ for (const col of cols) {
228
+ pdf.font(regular).fontSize(9).fillColor(C.ink)
229
+ .text(cells[col.key], x, y, { width: col.key === 'description' ? col.width - gap : col.width, align: col.align });
230
+ x += col.width;
231
+ }
232
+ y += height + 6;
233
+ pdf.moveTo(left, y - 2).lineTo(left + width, y - 2).lineWidth(0.5).strokeColor(C.rule).stroke();
234
+ y += 4;
235
+ }
236
+
237
+ // Totals, right aligned under the Amount column.
238
+ if (y > bottom - 110) {
239
+ pdf.addPage();
240
+ drawMark();
241
+ y = pdf.page.margins.top;
242
+ }
243
+ y += 4;
244
+ const totalsX = left + width - 240;
245
+ const totals = [['Subtotal', doc.subtotalCents], ['TOTAL GST 10%', doc.taxCents], [`TOTAL ${currency}`, doc.totalCents]];
246
+ for (const [index, [label, cents]] of totals.entries()) {
247
+ const grand = index === totals.length - 1;
248
+ if (grand) {
249
+ pdf.moveTo(totalsX, y - 3).lineTo(left + width, y - 3).lineWidth(0.75).strokeColor(C.ink).stroke();
250
+ y += 3;
251
+ }
252
+ pdf.font(grand ? bold : regular).fontSize(9).fillColor(C.ink)
253
+ .text(label, totalsX, y, { width: 150, align: 'right' })
254
+ .text(money(cents), totalsX + 150, y, { width: 90, align: 'right' });
255
+ y += 16;
256
+ }
257
+
258
+ // Due date.
259
+ y += 24;
260
+ pdf.font(bold).fontSize(11).fillColor(C.ink).text('Due Date: ', left, y, { continued: true });
261
+ if (fields.dueDate.value) pdf.text(showDate(fields.dueDate.value));
262
+ else pdf.font(italic).fillColor(C.muted).text(fields.dueDate.missing);
263
+
264
+ pdf.end();
265
+ });
266
+ }
@@ -98,8 +98,8 @@ export async function updateDraftInvoice(db, organisationId, invoiceId, changes)
98
98
  }
99
99
  if ('reference' in changes) fields.reference = changes.reference;
100
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 });
101
+ // A saved change keeps the draft where it is. Flow's checks still run on
102
+ // it, so a change that breaks one puts the draft in Problem.
103
103
  let row = invoice;
104
104
  if (Object.keys(fields).length) {
105
105
  [row] = await tx.update(invoices).set({ ...fields, updatedAt: sql`now()` })