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.
495 lines
No EOL
18 KiB
TypeScript
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);
|
|
} |