@arcforgelabs/flow 2026.10.29 → 2026.10.32-beta.1

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 (76) 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 +110 -3
  6. package/server/app.js +29 -2
  7. package/server/core/alerts.js +157 -0
  8. package/server/core/automations.js +32 -1
  9. package/server/core/history.js +65 -0
  10. package/server/core/invoice-pdf.js +266 -0
  11. package/server/core/invoicing-rules.js +12 -0
  12. package/server/core/job-board.js +25 -3
  13. package/server/core/line-mapping.js +36 -10
  14. package/server/core/pipeline.js +84 -6
  15. package/server/core/price-list.js +348 -0
  16. package/server/core/queue.js +22 -0
  17. package/server/core/sending.js +16 -4
  18. package/server/db/commands.js +42 -0
  19. package/server/db/doctor.js +168 -0
  20. package/server/db/migrations/0014_price_list.sql +34 -0
  21. package/server/db/migrations/0015_alert_dismissals.sql +17 -0
  22. package/server/db/migrations/meta/0014_snapshot.json +3773 -0
  23. package/server/db/migrations/meta/0015_snapshot.json +3854 -0
  24. package/server/db/migrations/meta/_journal.json +14 -0
  25. package/server/db/schema.js +44 -0
  26. package/server/flow-server.js +20 -1
  27. package/server/index.js +2 -1
  28. package/server/integrations/call-guard.js +362 -0
  29. package/server/integrations/port.js +23 -2
  30. package/server/integrations/seazona/client.js +6 -0
  31. package/server/integrations/seazona/feed.js +23 -1
  32. package/server/integrations/seazona/governor.js +74 -21
  33. package/server/integrations/seazona/history/backfill.js +208 -0
  34. package/server/integrations/seazona/history/csv.js +65 -0
  35. package/server/integrations/seazona/history/index.js +584 -0
  36. package/server/integrations/seazona/history/read.js +196 -0
  37. package/server/integrations/seazona/index.js +51 -2
  38. package/server/integrations/seazona/lanes.js +251 -62
  39. package/server/integrations/seazona/migrations/0002_call_safety.sql +15 -0
  40. package/server/integrations/seazona/migrations/0003_call_guard.sql +2 -0
  41. package/server/integrations/seazona/migrations/0004_history_import.sql +68 -0
  42. package/server/integrations/seazona/migrations/meta/0002_snapshot.json +882 -0
  43. package/server/integrations/seazona/migrations/meta/0003_snapshot.json +895 -0
  44. package/server/integrations/seazona/migrations/meta/0004_snapshot.json +1323 -0
  45. package/server/integrations/seazona/migrations/meta/_journal.json +21 -0
  46. package/server/integrations/seazona/routes.js +145 -0
  47. package/server/integrations/seazona/scheduler.js +37 -1
  48. package/server/integrations/seazona/schema.js +116 -0
  49. package/server/integrations/seazona/settings.js +24 -3
  50. package/server/integrations/seazona/store.js +74 -0
  51. package/server/integrations/seazona/sync-once.js +1 -1
  52. package/server/integrations/seazona/tables.js +5 -0
  53. package/server/integrations/xero/client.js +27 -7
  54. package/server/integrations/xero/drafts.js +46 -8
  55. package/server/integrations/xero/import.js +125 -16
  56. package/server/integrations/xero/index.js +36 -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/meta/_journal.json +7 -0
  60. package/server/integrations/xero/mirror.js +11 -2
  61. package/server/integrations/xero/oauth.js +4 -1
  62. package/server/integrations/xero/reconcile.js +173 -0
  63. package/server/integrations/xero/routes.js +35 -0
  64. package/server/integrations/xero/runtime.js +45 -4
  65. package/server/release-channel.js +3 -3
  66. package/server/ui/alerts.js +77 -0
  67. package/server/ui/app-page.js +1 -1
  68. package/server/ui/app.js +48 -14
  69. package/server/ui/clerk-appearance.js +42 -9
  70. package/server/ui/flow.css +31 -2
  71. package/server/ui/invoicing-settings.js +1 -1
  72. package/server/ui/invoicing.js +121 -8
  73. package/server/ui/line-mappings.js +3 -2
  74. package/server/ui/monitoring.js +14 -0
  75. package/server/ui/price-list.js +247 -0
  76. package/server/ui/routes.js +1 -1
@@ -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,5 +1,5 @@
1
1
  import { and, eq, sql } from 'drizzle-orm';
2
- import { automations, billingObligations } from '../db/schema.js';
2
+ import { automations, billingObligations, invoices } from '../db/schema.js';
3
3
  import { FlowError, notFound } from './errors.js';
4
4
  import { autoDraftJobs } from './invoicing-rules.js';
5
5
  import { MAPPING_DEFAULTS, readMappingConfig, remapJobs } from './line-mapping.js';
@@ -17,6 +17,8 @@ import { setReady } from './ledger.js';
17
17
  // are picked from those the work system reported (workStatuses).
18
18
  // draft_when_ready: a job that is ready to invoice and passes the invoicing
19
19
  // rules (invoicing-rules.js) gets a draft invoice in Flow.
20
+ // ready_when_drafted: a draft the draft routine made goes straight to Ready
21
+ // on the Invoices page, until a person changes it or moves it back.
20
22
 
21
23
  const BATCH = 500;
22
24
 
@@ -82,6 +84,14 @@ export const AUTOMATIONS = Object.freeze([
82
84
  defaults: { enabled: true, config: {} },
83
85
  readConfig: () => ({}),
84
86
  },
87
+ {
88
+ key: 'ready_when_drafted',
89
+ place: 'invoicing',
90
+ name: 'Ready when drafted',
91
+ hint: 'An automatic draft goes straight to Ready. A draft a person changed stays in Drafts.',
92
+ defaults: { enabled: true, config: {} },
93
+ readConfig: () => ({}),
94
+ },
85
95
  ]);
86
96
 
87
97
  const byKey = new Map(AUTOMATIONS.map((a) => [a.key, a]));
@@ -138,6 +148,25 @@ async function markReadyFromWorkStatus(db, organisationId, { statuses }) {
138
148
  return { marked };
139
149
  }
140
150
 
151
+ // Marks ready each draft the draft routine made that no person has touched.
152
+ // A person's edit or "back to drafts" moves the draft's updatedAt on from its
153
+ // createdAt, so the routine leaves it to people from then on. A draft with a
154
+ // problem shows in Problem whatever its ready mark (sending.js, invoiceViews).
155
+ async function readyWhenDrafted(db, organisationId) {
156
+ const rows = await db.update(invoices).set({ readyAt: sql`now()`, readyBy: null })
157
+ .where(and(
158
+ eq(invoices.organisationId, organisationId),
159
+ eq(invoices.status, 'draft'),
160
+ sql`${invoices.readyAt} is null`,
161
+ sql`${invoices.updatedAt} = ${invoices.createdAt}`,
162
+ sql`exists (select 1 from billing_obligations o join obligation_events e on e.obligation_id = o.id
163
+ where o.organisation_id = ${invoices.organisationId} and o.invoice_id = ${invoices.id}
164
+ and e.kind = 'drafted' and e.actor = ${actorOf('draft_when_ready')})`,
165
+ ))
166
+ .returning({ id: invoices.id });
167
+ return { marked: rows.length };
168
+ }
169
+
141
170
  // Runs every routine that is on. `accounting` is the integration whose copy
142
171
  // the draft routine checks; without one, Flow's own records are the check.
143
172
  export async function runAutomations(db, organisationId, { accounting = null, now = new Date() } = {}) {
@@ -148,6 +177,8 @@ export async function runAutomations(db, organisationId, { accounting = null, no
148
177
  summary[automation.key] = await markReadyFromWorkStatus(db, organisationId, automation.config);
149
178
  } else if (automation.key === 'draft_when_ready') {
150
179
  summary[automation.key] = await autoDraftJobs(db, organisationId, { accounting, now, actor: actorOf(automation.key) });
180
+ } else if (automation.key === 'ready_when_drafted') {
181
+ summary[automation.key] = await readyWhenDrafted(db, organisationId);
151
182
  }
152
183
  }
153
184
  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
+ }
@@ -158,6 +158,18 @@ export async function sendModeFor(db, organisationId, contactId) {
158
158
  return effectiveSettings(settings, customers.get(contactId)).sendMode;
159
159
  }
160
160
 
161
+ // How Flow sends invoices to the accounting system: the organisation's mode,
162
+ // and how many customers have a mode of their own that differs from it.
163
+ export async function sendModeSummary(db, organisationId) {
164
+ const settings = await readInvoicingSettings(db, organisationId);
165
+ const [row] = await db.select({ n: sql`count(*)::int` }).from(customerBillingSettings).where(and(
166
+ eq(customerBillingSettings.organisationId, organisationId),
167
+ sql`${customerBillingSettings.sendMode} is not null`,
168
+ sql`${customerBillingSettings.sendMode} <> ${settings.sendMode}`,
169
+ ));
170
+ return { mode: settings.sendMode, otherCustomers: row?.n ?? 0 };
171
+ }
172
+
161
173
  // What a job still waits on before Flow drafts it, as reasons a person can
162
174
  // read: Flow's own ready stage, then the lab's invoice rules (invoice-rules.js)
163
175
  // in their order. An empty list means the job may be drafted. `rules` is the
@@ -3,6 +3,7 @@ import { contacts, invoices, priceItems } from '../db/schema.js';
3
3
  import { listRules } from './invoice-rules.js';
4
4
  import { customerBillingByContact, effectiveSettings, readInvoicingSettings, ruleBlockers } from './invoicing-rules.js';
5
5
  import { listObligations } from './ledger.js';
6
+ import { isHistoryHeld } from './history.js';
6
7
  import { invoiceViews } from './sending.js';
7
8
 
8
9
  // The Jobs board (/jobs, server/ui/jobs.js). Its columns are the work
@@ -68,7 +69,9 @@ const ledgerLines = (lines) => (lines ?? []).map((l) => ({
68
69
  // Answers { jobs, statuses }. `work` is the adapters that offer listWork;
69
70
  // `live` says an accounting system checks the ledger (ledgerView).
70
71
  export async function boardJobs(db, organisationId, { work = [], accounting = null, live = false, log = console } = {}) {
71
- const listed = await listObligations(db, organisationId, { accounting: live });
72
+ // Held history jobs are past work, so the board leaves them out
73
+ // (core/history.js); the history import lists them by accounting state.
74
+ const listed = (await listObligations(db, organisationId, { accounting: live })).filter((entry) => !isHistoryHeld(entry.obligation));
72
75
  const obligations = await describeObligations(db, organisationId, listed, { accounting });
73
76
  const byJob = new Map(obligations.map((o) => [keyOf(o.sourceSystem, o.sourceJobRef), o]));
74
77
 
@@ -93,7 +96,7 @@ export async function boardJobs(db, organisationId, { work = [], accounting = nu
93
96
  totalCents: o.totalCents, lines: ledgerLines(o.lines), obligation: o,
94
97
  };
95
98
  });
96
- return { jobs, statuses: [...statuses.values()] };
99
+ return { jobs: await withIssues(jobs, work, log), statuses: [...statuses.values()] };
97
100
  }
98
101
 
99
102
  const codes = new Set((await db.select({ code: priceItems.code }).from(priceItems)
@@ -126,5 +129,24 @@ export async function boardJobs(db, organisationId, { work = [], accounting = nu
126
129
  });
127
130
  }
128
131
  }
129
- return { jobs, statuses };
132
+ return { jobs: await withIssues(jobs, work, log), statuses };
133
+ }
134
+
135
+ // Each job's open issues from its integration (the port's jobIssues): a
136
+ // short phrase and the page where a person fixes it. A failed read leaves
137
+ // the board as it is.
138
+ async function withIssues(jobs, adapters, log) {
139
+ const issues = new Map();
140
+ for (const adapter of adapters) {
141
+ if (typeof adapter.capabilities.jobIssues !== 'function') continue;
142
+ const refs = jobs.filter((j) => j.sourceSystem === adapter.id).map((j) => j.sourceJobRef);
143
+ if (!refs.length) continue;
144
+ try {
145
+ const found = await adapter.capabilities.jobIssues({ refs });
146
+ for (const [ref, list] of Object.entries(found ?? {})) issues.set(keyOf(adapter.id, ref), list);
147
+ } catch (error) {
148
+ log.error?.(`[jobs] ${adapter.id} jobIssues: ${error.message}`);
149
+ }
150
+ }
151
+ return jobs.map((j) => ({ ...j, issues: issues.get(keyOf(j.sourceSystem, j.sourceJobRef)) ?? [] }));
130
152
  }