Workflow-App/Workflow/lib/shareholder-change-payload.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

337 lines
No EOL
12 KiB
TypeScript

// lib/shareholder-change-payload.ts
//
// Parsing and validation shared by the create and update endpoints for
// shareholder-change resolutions.
//
// WHY THIS IS A SEPARATE MODULE
// -----------------------------
// `create` and the later `[id] PATCH` take the SAME body shape. When each route
// carried its own copy of the parsers, the edit path accepted things the create
// path rejected (and vice versa) — which for a legal document means a resolution
// that could not have been created by hand, or an edit that silently lost the
// certificate-level detail. One implementation, both routes.
//
// It also means a validation rule is changed once.
import {
isPaymentMethod,
type MeetingAttendee,
type ShareholderChangeDetail,
type TransferorInput,
type TransfereeInput,
} from "./shareholder-changes";
// ─── primitives ──────────────────────────────────────────────────────────────
export function isValidISODate(s: string): boolean {
return /^\d{4}-\d{2}-\d{2}$/.test(s);
}
export function isValidShareCount(n: unknown): n is number {
return typeof n === "number" && Number.isInteger(n) && n > 0;
}
/** PocketBase filter values are single-quoted; escape the quote, not the data. */
export function safeEscape(s: string): string {
return s.replace(/'/g, "''");
}
export function str(v: unknown, max = 500): string {
return typeof v === "string" ? v.trim().slice(0, max) : "";
}
/** Free-text optional: "" stays "" so the letter prints its underscore fill. */
export function optStr(v: unknown, max = 500): string {
return str(v, max);
}
export function optionalInt(v: unknown): number | null {
return typeof v === "number" && Number.isInteger(v) && v >= 0 ? v : null;
}
// ─── party parsing ───────────────────────────────────────────────────────────
export function parseAttendees(raw: unknown): MeetingAttendee[] {
if (!Array.isArray(raw)) return [];
return raw.map((r, i) => {
const p = (r ?? {}) as Record<string, unknown>;
const director_id = str(p.director_id);
if (!director_id) {
throw new Error(`Attendee ${i + 1}: no director selected.`);
}
return {
director_id,
name: str(p.name),
surname: str(p.surname),
email: str(p.email) || null,
// Verbatim, never masked — the signature block recites them.
id_number: str(p.id_number, 40) || null,
cell: str(p.cell, 40) || null,
position: optStr(p.position),
// Absent means "not signing" — the signature block must not silently
// include everyone who happened to attend.
signs: p.signs === true,
};
});
}
export function parseTransferors(raw: unknown): TransferorInput[] {
if (!Array.isArray(raw)) return [];
return raw.map((r, i) => {
const p = (r ?? {}) as Record<string, unknown>;
const source = p.source;
if (source !== "director" && source !== "company") {
throw new Error(`Transferor ${i + 1}: source must be "director" or "company".`);
}
const source_id = str(p.source_id);
const name = str(p.name);
if (!source_id) throw new Error(`Transferor ${i + 1}: no shareholder selected.`);
if (!name) throw new Error(`Transferor ${i + 1}: name is required.`);
if (!isValidShareCount(p.shares)) {
throw new Error(`Transferor ${i + 1} (${name}): shares must be a whole number above zero.`);
}
const payment_method = str(p.payment_method, 80);
const shares_held = optionalInt(p.shares_held);
const retainedGiven = optionalInt(p.shares_retained);
// Left BLANK means "whatever the seller keeps", which the letter has to get
// right. A seller holding 100 and selling 51 retains 49 — but a blank was
// read as a flat 0 and printed "shall retain the remaining 0 shares
// (Distinctive numbers: 52 to 100)", which is self-contradictory nonsense in
// a signed document. An explicit 0 is still honoured: that is the seller
// keeping nothing, and the retention clause is then omitted entirely.
const shares_retained =
retainedGiven !== null
? retainedGiven
: shares_held !== null
? Math.max(0, shares_held - (p.shares as number))
: 0;
return {
source,
source_id,
name,
surname: str(p.surname),
email: str(p.email) || null,
// Kept so the letter can recite the ID / enterprise number without a
// second PocketBase read per party. Stored verbatim — never masked.
id_number: str(p.id_number, 40) || null,
role: "transferor" as const,
shares_held,
shares: p.shares,
certificate_no: optStr(p.certificate_no, 60),
percentage: optStr(p.percentage, 20),
distinctive_from: optStr(p.distinctive_from, 30),
distinctive_to: optStr(p.distinctive_to, 30),
shares_retained,
retained_from: optStr(p.retained_from, 30),
retained_to: optStr(p.retained_to, 30),
consideration: optStr(p.consideration, 60),
payment_method: isPaymentMethod(payment_method) ? payment_method : optStr(payment_method, 80),
};
});
}
export function parseTransferees(raw: unknown): TransfereeInput[] {
if (!Array.isArray(raw)) return [];
return raw.map((r, i) => {
const p = (r ?? {}) as Record<string, unknown>;
const source = p.source;
if (source !== "director" && source !== "company") {
throw new Error(`Transferee ${i + 1}: source must be "director" or "company".`);
}
const source_id = str(p.source_id);
const name = str(p.name);
if (!source_id) throw new Error(`Transferee ${i + 1}: no shareholder selected.`);
if (!name) throw new Error(`Transferee ${i + 1}: name is required.`);
if (!isValidShareCount(p.shares)) {
throw new Error(`Transferee ${i + 1} (${name}): shares must be a whole number above zero.`);
}
return {
source,
source_id,
name,
surname: str(p.surname),
email: str(p.email) || null,
id_number: str(p.id_number, 40) || null,
role: "transferee" as const,
shares_held: optionalInt(p.shares_held),
shares: p.shares,
};
});
}
// ─── whole-body validation ───────────────────────────────────────────────────
export interface ParsedChange {
attendees: MeetingAttendee[];
transferors: TransferorInput[];
transferees: TransfereeInput[];
sold: number;
acquired: number;
signers: MeetingAttendee[];
/** Parties with no email address — surfaced now, not at send time. */
withoutEmail: string[];
}
/**
* Parse and validate a create/update body. Throws with an operator-readable
* message; the route turns that into a 400.
*/
export function parseChangeBody(body: Record<string, unknown>): ParsedChange {
const meeting_date = str(body.meeting_date, 10);
if (!isValidISODate(meeting_date)) {
throw new Error("meeting_date is required (YYYY-MM-DD)");
}
const path = str(body.path, 40);
if (path !== "letter_generated" && path !== "letters_received") {
throw new Error(`path must be "letter_generated" or "letters_received"`);
}
const attendees = parseAttendees(body.attendees);
const transferors = parseTransferors(body.transferors);
const transferees = parseTransferees(body.transferees);
if (transferors.length === 0) {
throw new Error("Add at least one transferor (the shareholder selling the shares).");
}
if (transferees.length === 0) {
throw new Error("Add at least one transferee (the shareholder buying the shares).");
}
// Shares leaving a transferor must equal shares entering a transferee. A
// cession of shares that does not balance is meaningless, and it would reach
// a legal document.
const sold = transferors.reduce((n, p) => n + p.shares, 0);
const acquired = transferees.reduce((n, p) => n + p.shares, 0);
if (sold !== acquired) {
throw new Error(
`The transfer does not balance: ${sold} share${sold === 1 ? "" : "s"} ` +
`being sold but ${acquired} being acquired. Every share sold must be taken ` +
`by someone.`
);
}
// The same PB record must not appear on both sides of the transfer.
const sellerIds = new Set(transferors.map((p) => `${p.source}:${p.source_id}`));
const clash = transferees.find((p) => sellerIds.has(`${p.source}:${p.source_id}`));
if (clash) {
throw new Error(`${clash.name} is listed as both a transferor and a transferee.`);
}
return {
attendees,
transferors,
transferees,
sold,
acquired,
signers: attendees.filter((a) => a.signs),
withoutEmail: [
...attendees.filter((a) => !a.email).map((a) => `${a.name} ${a.surname}`.trim()),
...[...transferors, ...transferees].filter((p) => !p.email).map((p) => p.name),
],
};
}
// ─── serialisation ───────────────────────────────────────────────────────────
/** Human-readable one-liner stored on the attendee's free-text `position`. */
export function positionForTransferor(p: TransferorInput): string {
const cert = p.certificate_no ? ` cert ${p.certificate_no}` : "";
return `Transferor — selling ${p.shares} share${p.shares === 1 ? "" : "s"}${cert}`;
}
/**
* The attendee rows a resolution is made of, in order.
*
* Lives here rather than in a route file because App Router route modules may
* only export HTTP handlers — and because create and update must describe the
* three lists identically or an edit produces something the wizard never could.
*/
export function attendeeRowFields(
resolutionId: string,
meetingDate: string,
change: ParsedChange
): Record<string, unknown>[] {
const rows: Record<string, unknown>[] = [];
let order = 0;
// Meeting attendees first, so they occupy the low sign_order values.
for (const a of change.attendees) {
rows.push({
resolution: resolutionId,
director: a.director_id,
name: a.name,
surname: a.surname,
email: a.email,
attended: true,
must_sign: a.signs,
director_role: "existing",
position: a.position || "Shareholder",
effective_date: meetingDate,
sign_order: order++,
});
}
for (const p of change.transferors) {
rows.push({
resolution: resolutionId,
director: p.source === "director" ? p.source_id : "",
name: p.name,
surname: p.surname,
email: p.email,
attended: true,
// Transferors receive the document but the signature block is the meeting
// attendees' — must_sign stays false so the two never blur.
must_sign: false,
director_role: "transferor",
position: positionForTransferor(p),
shares: p.shares,
effective_date: meetingDate,
sign_order: order++,
});
}
for (const p of change.transferees) {
rows.push({
resolution: resolutionId,
director: p.source === "director" ? p.source_id : "",
name: p.name,
surname: p.surname,
email: p.email,
attended: false,
must_sign: false,
director_role: "transferee",
position: `Transferee — acquiring ${p.shares} share${p.shares === 1 ? "" : "s"}`,
shares: p.shares,
effective_date: meetingDate,
sign_order: order++,
});
}
return rows;
}
/**
* The `notes` column holds the whole detail document as JSON.
*
* The operator's free-text note travels INSIDE the JSON. Appending it after the
* JSON made the whole field unparseable, which silently cost us every
* certificate-level detail in the letter.
*/
export function serialiseDetail(
companyId: string,
votesPercent: unknown,
change: ParsedChange,
operatorNote: string
): string {
const detail: ShareholderChangeDetail = {
version: 2,
company_id: companyId,
votes_percent: str(votesPercent, 10),
attendees: change.attendees,
parties: [...change.transferors, ...change.transferees],
};
const note = str(operatorNote, 2000);
return JSON.stringify({ ...detail, ...(note ? { note } : {}) });
}