@arcforgelabs/flow 2026.10.30 → 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.
- 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 +110 -3
- package/server/app.js +29 -2
- package/server/core/alerts.js +157 -0
- package/server/core/history.js +65 -0
- package/server/core/invoice-pdf.js +266 -0
- package/server/core/invoicing-rules.js +12 -0
- package/server/core/job-board.js +25 -3
- package/server/core/line-mapping.js +36 -10
- package/server/core/pipeline.js +75 -3
- package/server/core/price-list.js +348 -0
- 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/meta/0014_snapshot.json +3773 -0
- package/server/db/migrations/meta/0015_snapshot.json +3854 -0
- package/server/db/migrations/meta/_journal.json +14 -0
- package/server/db/schema.js +44 -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 +20 -1
- package/server/integrations/seazona/feed.js +23 -1
- 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 +37 -1
- package/server/integrations/seazona/lanes.js +186 -50
- 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/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 +27 -7
- package/server/integrations/xero/drafts.js +46 -8
- package/server/integrations/xero/import.js +125 -17
- package/server/integrations/xero/index.js +36 -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/meta/_journal.json +7 -0
- package/server/integrations/xero/mirror.js +11 -2
- package/server/integrations/xero/oauth.js +4 -1
- package/server/integrations/xero/reconcile.js +173 -0
- package/server/integrations/xero/routes.js +35 -0
- package/server/integrations/xero/runtime.js +45 -4
- 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 +48 -14
- package/server/ui/clerk-appearance.js +42 -9
- package/server/ui/flow.css +31 -2
- package/server/ui/invoicing-settings.js +1 -1
- package/server/ui/invoicing.js +121 -8
- package/server/ui/line-mappings.js +3 -2
- package/server/ui/price-list.js +247 -0
- 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
|
+
}
|
|
@@ -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
|
package/server/core/job-board.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|