Workflow-App/Workflow/lib/shareholder-letter.ts
sjcoetzeesj-coder 11d0178ab1 feat: commit Workflow app source; move CIPC credentials to env
Adds the full Next.js app (app/, components/, lib/, scripts/, assets/,
docs/, Dockerfile, compose, configs) to version control.

Security:
- CIPC BizPortal customer-code logins (Login 1 + Login 3) now read
  CIPC_LOGIN1_CODE / CIPC_LOGIN1_PASSWORD / CIPC_LOGIN3_CODE /
  CIPC_LOGIN3_PASSWORD from .env.local instead of source.
- BizPortal personal login in apply-bizportal route reads
  BIZPORTAL_ID_NO / BIZPORTAL_PASSWORD.
- Added .env.example as the committed template.

.gitignore excludes .env*, node_modules, .next, logs, sqlite, Playwright
storage_state.json (live CIPC cookies), scratch dirs, client PDFs and
debug screenshots.
2026-10-18 15:21:45 +02:00

495 lines
No EOL
18 KiB
TypeScript

// lib/shareholder-letter.ts
//
// SERVER-ONLY letter assembly for section 5 ("Change in shareholders").
//
// Pipeline (mirrors lib/letter.ts, deliberately not shared):
// 1. Load the resolution, its company and its attendee rows from PocketBase
// 2. Write a context.json describing the parties
// 3. Spawn `python scripts/subst_shareholder.py`, which fills the master
// template and generates the transfer / holdings tables
// 4. Convert the DOCX to PDF (Microsoft Word via COM first, LibreOffice as
// fallback — the same preference order section 4 uses, because this is a
// legal template and fidelity matters)
// 5. Return the PDF as a Buffer
//
// WHY THIS DOES NOT REUSE lib/letter.ts
// -------------------------------------
// lib/letter.ts encodes the DIRECTOR-change document: its template registry,
// its section-collapse rules, its "None this meeting" sentinels, its signature
// PNG embedding. None of that applies to a special resolution of shareholders,
// and its helpers (convertViaWord, resolveSoffice, resolvePython) are private to
// that module. Reusing it would mean either parameterising a file section 4's
// legal output depends on, or forking it anyway. The ~60 lines of conversion
// plumbing duplicated here are the cheaper trade: section 4's letter pipeline
// stays byte-for-byte untouched, and this one can diverge freely later.
import "server-only";
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { promises as fs } from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { pbFetch } from "./pb";
import {
COL,
idLabel,
parseShareholderChangeDetail,
type PartyInput,
type TransferorInput,
type TransfereeInput,
} from "./shareholder-changes";
export interface ShareholderLetterOutput {
pdf: Buffer;
/** The same document before conversion — editable in Word. */
docx: Buffer;
filename: string;
docxFilename: string;
/** Tokens left unfilled in the rendered document — should be empty. */
unfilledPlaceholders: string[];
}
// ─── data loading ───────────────────────────────────────────────────────────
interface CompanyRec {
id: string;
enterprise_name?: string;
enterprise_no?: string;
physical_address?: string;
physical_address_line_1?: string;
physical_address_line_2?: string;
physical_town_city?: string;
physical_province?: string;
physical_postal_code?: string;
/** Comma-joined fallback ("... GAUTENG, 2522") used to recover a postcode. */
physical_address_normalized?: string;
}
interface AttendeeRec {
id: string;
resolution?: string;
name?: string;
surname?: string;
email?: string | null;
director_role?: string;
shares?: number | null;
sign_order?: number | null;
director?: string;
}
interface ResolutionRec {
id: string;
company?: string;
meeting_date?: string;
notes?: string;
}
/**
* The parties for a resolution.
*
* Prefers the structured detail stored in `notes` (it knows whether a party
* came from a director or a company, and what they held before). Falls back to
* the attendee rows, which is what a hand-edited record might carry.
*/
async function loadParties(resolution: ResolutionRec): Promise<PartyInput[]> {
const detail = parseShareholderChangeDetail(resolution.notes);
if (detail) return detail.parties;
const attendees = await pbFetch<{ items: AttendeeRec[] }>(
`/api/collections/${COL.attendees}/records`,
{ perPage: 500, filter: `resolution="${resolution.id}"` }
);
return (attendees.items || [])
.filter((a) => a.director_role === "transferor" || a.director_role === "transferee")
.sort((a, b) => (a.sign_order ?? 0) - (b.sign_order ?? 0))
.map((a) => ({
// Without the detail document we cannot know whether this party came from
// a director or a company record. The `director` relation is the only
// surviving hint, and a wrong guess here only affects nothing visible —
// the letter prints names, not record provenance.
source: a.director ? "director" : "company",
source_id: a.director || "",
name: a.name ?? "",
surname: a.surname ?? "",
email: a.email ?? null,
// Unknown without the detail document — the attendee row does not carry
// an ID number, and the letter falls back to the plain name.
id_number: null,
role: a.director_role as PartyInput["role"],
shares_held: null,
shares: a.shares ?? 0,
}));
}
/** South African postal codes are four digits. */
function isPostalCode(part: string): boolean {
return /^\d{4}$/.test(part);
}
/**
* Join whatever address parts exist; fall back to the flattened address.
*
* Two things go wrong if you just concatenate the fields:
*
* - DUPLICATION. A company whose `physical_address_line_2` and
* `physical_town_city` both read "CARLETONVILLE" printed
* "CARLETONVILLE, CARLETONVILLE" in the letter. Repeat identical parts once.
*
* - A MISSING POSTAL CODE. `physical_postal_code` is often empty while
* `physical_address_normalized` ("... GAUTENG, 2522") still carries it, so
* the letter dropped the postcode even though we held it. Recover it from
* the normalized string before giving up.
*/
function buildAddress(c: CompanyRec): { address: string; address_city: string } {
const raw = [
c.physical_address_line_1,
c.physical_address_line_2,
c.physical_town_city,
c.physical_province,
c.physical_postal_code,
]
.map((p) => (p ?? "").trim())
.filter(Boolean);
// Keep the first occurrence of each distinct part.
const parts = raw.filter(
(p, i) => raw.findIndex((q) => q.toLowerCase() === p.toLowerCase()) === i
);
let hasPostal = parts.some(isPostalCode);
if (!hasPostal) {
const fromNormalized = (c.physical_address_normalized ?? "")
.trim()
.match(/(\d{4})\s*$/);
if (fromNormalized) {
parts.push(fromNormalized[1]);
hasPostal = true;
}
}
// The postcode belongs at the end, whatever order the fields arrived in.
if (hasPostal) {
const postal = parts.find(isPostalCode)!;
const rest = parts.filter((p) => !isPostalCode(p));
parts.length = 0;
parts.push(...rest, postal);
}
return {
address: parts.length ? parts.join(", ") : (c.physical_address ?? "").trim(),
address_city: (c.physical_town_city ?? "").trim(),
};
}
// ─── binaries ───────────────────────────────────────────────────────────────
//
// A bare "python" / "soffice" relies on process PATH, which a Next.js server
// captures at STARTUP — an entry added later stays invisible until restart.
// Resolve absolute paths at call time so this never depends on that.
const TEMPLATE_DIR =
process.env.LETTER_TEMPLATE_DIR?.trim() ||
path.join(process.cwd(), "assets", "templates");
const TEMPLATE_FILE = "shareholder_change_resolution_master.docx";
function resolvePython(): string {
const venv = path.join(
process.cwd(),
"scripts",
".venv",
process.platform === "win32" ? "Scripts" : "bin",
process.platform === "win32" ? "python.exe" : "python"
);
if (existsSync(venv)) return venv;
const override = process.env.LETTER_PYTHON?.trim();
if (override && existsSync(override)) return override;
return process.platform === "win32" ? "python" : "python3";
}
function resolveScript(): string {
const override = process.env.SUBST_SCRIPT_SHAREHOLDER?.trim();
if (override && existsSync(override)) return override;
const fromCwd = path.join(process.cwd(), "scripts", "subst_shareholder.py");
if (existsSync(fromCwd)) return fromCwd;
throw new Error(
`Could not find scripts/subst_shareholder.py (looked in ${fromCwd}). ` +
`Set SUBST_SCRIPT_SHAREHOLDER to its full path.`
);
}
function resolveSoffice(): string | null {
const override = process.env.SOFFICE_BIN?.trim();
if (override && existsSync(override)) return override;
const candidates = [
process.env.PROGRAMFILES &&
path.join(process.env.PROGRAMFILES, "LibreOffice", "program", "soffice.exe"),
process.env["PROGRAMFILES(X86)"] &&
path.join(process.env["PROGRAMFILES(X86)"], "LibreOffice", "program", "soffice.exe"),
process.env.LOCALAPPDATA &&
path.join(process.env.LOCALAPPDATA, "Programs", "LibreOffice", "program", "soffice.exe"),
"/Applications/LibreOffice.app/Contents/MacOS/soffice",
"/usr/bin/soffice",
"/usr/local/bin/soffice",
].filter((p): p is string => typeof p === "string" && p.length > 0);
return candidates.find((c) => existsSync(c)) ?? null;
}
interface RunResult {
exitCode: number;
stdout: string;
stderr: string;
}
function run(cmd: string, args: string[], timeoutMs: number): Promise<RunResult> {
return new Promise((resolve, reject) => {
const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
let stdout = "";
let stderr = "";
child.stdout.on("data", (b) => (stdout += b.toString()));
child.stderr.on("data", (b) => (stderr += b.toString()));
const timer = setTimeout(() => {
child.kill("SIGKILL");
reject(new Error(`${cmd} timed out after ${timeoutMs}ms`));
}, timeoutMs);
child.on("error", (err) => {
clearTimeout(timer);
reject(err);
});
child.on("close", (code) => {
clearTimeout(timer);
resolve({ exitCode: code ?? -1, stdout, stderr });
});
});
}
// ─── DOCX -> PDF ────────────────────────────────────────────────────────────
/**
* Microsoft Word via a generated VBScript.
*
* PowerShell's COM interop mangles Word's positional arguments, and cscript.exe
* is present on every Windows box. Word also renders DOCX with full fidelity,
* which matters for a legal template. Returns the PDF path, or null.
*/
async function convertViaWord(docxPath: string, pdfPath: string): Promise<string | null> {
const vbsPath = path.join(
os.tmpdir(),
`shletter-word-${Math.random().toString(36).slice(2, 10)}.vbs`
);
const q = (s: string) => `"${s.replace(/"/g, '""')}"`;
const script = [
'Set word = CreateObject("Word.Application")',
"word.Visible = False",
"word.DisplayAlerts = 0",
`Set doc = word.Documents.Open(${q(docxPath)})`,
`doc.ExportAsFixedFormat ${q(pdfPath)}, 17`,
"doc.Close 0",
"word.Quit",
'WScript.Echo "OK"',
].join("\r\n");
try {
await fs.writeFile(vbsPath, script, "utf8");
const res = await run("cscript.exe", ["//NoLogo", "//B", vbsPath], 120_000);
if (res.exitCode !== 0) {
console.error(
"[shareholder-letter] Word conversion failed:",
(res.stderr || res.stdout).slice(0, 300)
);
return null;
}
return existsSync(pdfPath) ? pdfPath : null;
} catch (e) {
console.error(
"[shareholder-letter] Word conversion threw:",
e instanceof Error ? e.message.slice(0, 300) : e
);
return null;
} finally {
fs.unlink(vbsPath).catch(() => {});
}
}
async function convertViaSoffice(docxPath: string, outDir: string): Promise<string | null> {
const soffice = resolveSoffice();
if (!soffice) return null;
const pdfPath = path.join(outDir, path.basename(docxPath).replace(/\.docx$/i, ".pdf"));
const res = await run(
soffice,
["--headless", "--norestore", "--convert-to", "pdf", "--outdir", outDir, docxPath],
120_000
);
if (res.exitCode !== 0) {
console.error("[shareholder-letter] LibreOffice conversion failed:", res.stderr.slice(0, 300));
return null;
}
return existsSync(pdfPath) ? pdfPath : null;
}
// ─── public API ─────────────────────────────────────────────────────────────
export async function generateShareholderLetter(
resolutionId: string
): Promise<ShareholderLetterOutput> {
const templatePath = path.join(TEMPLATE_DIR, TEMPLATE_FILE);
if (!existsSync(templatePath)) {
throw new Error(
`Letter template not found: ${templatePath}. Regenerate it with ` +
`python scripts/build_shareholder_master.py, or set LETTER_TEMPLATE_DIR.`
);
}
const resolution = await pbFetch<ResolutionRec>(
`/api/collections/${COL.resolutions}/records/${encodeURIComponent(resolutionId)}`
);
if (!resolution?.id) {
throw new Error(`Resolution ${resolutionId} not found`);
}
if (!resolution.company) {
throw new Error("This resolution has no company linked, so no letter can be addressed.");
}
const company = await pbFetch<CompanyRec>(
`/api/collections/${COL.companies}/records/${encodeURIComponent(resolution.company)}`
);
const detail = parseShareholderChangeDetail(resolution.notes);
const parties = await loadParties(resolution);
if (parties.length === 0) {
throw new Error(
"This resolution has no transferor/transferee rows, so there is nothing to resolve."
);
}
const { address, address_city } = buildAddress(company);
const meetingDate = (resolution.meeting_date ?? "").slice(0, 10);
const workDir = await fs.mkdtemp(path.join(os.tmpdir(), "shletter-"));
const outDocx = path.join(workDir, "letter.docx");
const ctxPath = path.join(workDir, "context.json");
const pdfOutDir = path.join(workDir, "pdf");
await fs.mkdir(pdfOutDir, { recursive: true });
try {
// The context shape below is the contract with scripts/subst_shareholder.py
// and is documented in that file's docstring. It is spelled out EXPLICITLY
// rather than by dumping the parsed detail: the two sides use different key
// names (`shares` here, `shares_sold` there), and passing our objects
// straight through silently produced a letter saying the transferor sold
// "0 shares". Build the contract, don't hope it matches.
const allParties = detail?.parties ?? parties;
const transferors = allParties
.filter((p): p is TransferorInput => p.role === "transferor")
.map((p) => ({
name: p.name,
surname: p.surname,
id_label: idLabel(p.source, p.name, p.surname, p.id_number ?? null),
// Needed by the script to decide whether a clause needs a leading "The":
// "The Acme (Pty) Ltd shall retain…" but never "The John Smith shall…".
source: p.source,
percentage: p.percentage,
certificate_no: p.certificate_no,
shares_sold: p.shares,
shares_held: p.shares_held ?? null,
distinctive_from: p.distinctive_from,
distinctive_to: p.distinctive_to,
shares_retained: p.shares_retained,
retained_from: p.retained_from,
retained_to: p.retained_to,
consideration: p.consideration,
payment_method: p.payment_method,
}));
const transferees = allParties
.filter((p): p is TransfereeInput => p.role === "transferee")
.map((p) => ({
name: p.name,
surname: p.surname,
id_label: idLabel(p.source, p.name, p.surname, p.id_number ?? null),
shares: p.shares,
}));
const context = {
company: {
name: company.enterprise_name ?? "",
enterprise_no: company.enterprise_no ?? "",
address,
address_city,
},
meeting_date: meetingDate,
votes_percent: detail?.votes_percent ?? "",
// Meeting attendees. Falls back to nothing rather than to the parties —
// an attendee list that silently contained the sellers would put the
// wrong names in the table at the top of a legal document.
attendees: detail?.attendees ?? [],
transferors,
transferees,
};
await fs.writeFile(ctxPath, JSON.stringify(context, null, 2), "utf8");
const subst = await run(
resolvePython(),
[resolveScript(), templatePath, outDocx, ctxPath],
60_000
);
if (subst.exitCode !== 0) {
throw new Error(
`shareholder letter substitution failed (exit ${subst.exitCode}): ${subst.stderr.slice(0, 800)}`
);
}
const unfilledPlaceholders = parseUnfilled(subst.stdout);
const pdfPath =
(await convertViaWord(outDocx, path.join(pdfOutDir, "letter.pdf"))) ??
(await convertViaSoffice(outDocx, pdfOutDir));
if (!pdfPath) {
throw new Error(
"No DOCX to PDF converter available. Neither Microsoft Word (COM) nor " +
"LibreOffice (soffice) could be used. Install LibreOffice, or set SOFFICE_BIN."
);
}
const pdf = await fs.readFile(pdfPath);
// Keep the pre-conversion DOCX too. The PDF is what gets emailed and signed,
// but operators still need an editable copy — a wording fix or a re-dated
// letter is far quicker to make in Word than by re-running the pipeline.
const docx = await fs.readFile(outDocx);
const slug =
(company.enterprise_name ?? "")
.replace(/[^a-z0-9]+/gi, "-")
.replace(/^-|-$/g, "")
.toLowerCase() || "company";
const stamp = meetingDate || "undated";
return {
pdf,
docx,
filename: `ShareholderResolution_${slug}_${stamp}.pdf`,
docxFilename: `ShareholderResolution_${slug}_${stamp}.docx`,
unfilledPlaceholders,
};
} finally {
fs.rm(workDir, { recursive: true, force: true }).catch(() => {});
}
}
/** Pull the "UNFILLED: a, b" line the Python script prints on stdout. */
function parseUnfilled(stdout: string): string[] {
const line = stdout.split("\n").find((l) => l.startsWith("UNFILLED:"));
if (!line) return [];
return line
.slice("UNFILLED:".length)
.split(",")
.map((s) => s.trim())
.filter(Boolean);
}