@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.
- package/README.md +2 -0
- package/npm-shrinkwrap.json +188 -2
- package/package.json +2 -1
- package/server/api/alerts.js +49 -0
- package/server/api/invoicing.js +125 -7
- package/server/app.js +29 -2
- package/server/core/alerts.js +157 -0
- package/server/core/automations.js +17 -3
- package/server/core/history.js +65 -0
- package/server/core/invoice-pdf.js +266 -0
- package/server/core/invoices.js +2 -2
- package/server/core/invoicing-rules.js +26 -1
- package/server/core/job-board.js +25 -3
- package/server/core/ledger.js +3 -2
- package/server/core/line-mapping.js +36 -10
- package/server/core/pipeline.js +76 -4
- package/server/core/price-list.js +348 -0
- package/server/core/references.js +99 -0
- package/server/core/sending.js +11 -5
- package/server/db/commands.js +42 -0
- package/server/db/doctor.js +168 -0
- package/server/db/migrations/0014_price_list.sql +34 -0
- package/server/db/migrations/0015_alert_dismissals.sql +17 -0
- package/server/db/migrations/0016_invoice_reference.sql +1 -0
- package/server/db/migrations/meta/0014_snapshot.json +3773 -0
- package/server/db/migrations/meta/0015_snapshot.json +3854 -0
- package/server/db/migrations/meta/0016_snapshot.json +3861 -0
- package/server/db/migrations/meta/_journal.json +21 -0
- package/server/db/schema.js +46 -0
- package/server/flow-server.js +20 -1
- package/server/index.js +2 -1
- package/server/integrations/call-guard.js +362 -0
- package/server/integrations/port.js +21 -1
- package/server/integrations/seazona/feed.js +31 -2
- package/server/integrations/seazona/governor.js +50 -122
- package/server/integrations/seazona/history/backfill.js +208 -0
- package/server/integrations/seazona/history/csv.js +65 -0
- package/server/integrations/seazona/history/index.js +584 -0
- package/server/integrations/seazona/history/read.js +196 -0
- package/server/integrations/seazona/index.js +46 -1
- package/server/integrations/seazona/lanes.js +187 -51
- package/server/integrations/seazona/migrations/0003_call_guard.sql +2 -0
- package/server/integrations/seazona/migrations/0004_history_import.sql +68 -0
- package/server/integrations/seazona/migrations/meta/0003_snapshot.json +895 -0
- package/server/integrations/seazona/migrations/meta/0004_snapshot.json +1323 -0
- package/server/integrations/seazona/migrations/meta/_journal.json +14 -0
- package/server/integrations/seazona/patients.js +30 -0
- package/server/integrations/seazona/routes.js +145 -0
- package/server/integrations/seazona/schema.js +94 -0
- package/server/integrations/seazona/settings.js +14 -3
- package/server/integrations/seazona/sync-once.js +1 -1
- package/server/integrations/seazona/tables.js +4 -0
- package/server/integrations/xero/client.js +39 -7
- package/server/integrations/xero/drafts.js +46 -8
- package/server/integrations/xero/import.js +131 -17
- package/server/integrations/xero/index.js +38 -7
- package/server/integrations/xero/lookup.js +26 -0
- package/server/integrations/xero/migrations/0006_xero_call_guard.sql +26 -0
- package/server/integrations/xero/migrations/0007_xero_webhooks.sql +30 -0
- package/server/integrations/xero/migrations/meta/_journal.json +14 -0
- package/server/integrations/xero/mirror.js +27 -10
- package/server/integrations/xero/oauth.js +4 -1
- package/server/integrations/xero/reconcile.js +173 -0
- package/server/integrations/xero/routes.js +55 -0
- package/server/integrations/xero/runtime.js +45 -4
- package/server/integrations/xero/settings.js +3 -0
- package/server/integrations/xero/webhooks.js +311 -0
- package/server/release-channel.js +3 -3
- package/server/ui/alerts.js +77 -0
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +63 -21
- package/server/ui/clerk-appearance.js +42 -9
- package/server/ui/flow.css +78 -9
- package/server/ui/invoice-notes.js +26 -0
- package/server/ui/invoicing-settings.js +4 -2
- package/server/ui/invoicing.js +241 -49
- package/server/ui/line-mappings.js +3 -2
- package/server/ui/price-list.js +247 -0
- 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
|
-
|
|
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
|
+
}
|
package/server/core/invoices.js
CHANGED
|
@@ -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
|
|
102
|
-
|
|
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()` })
|