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.
337 lines
No EOL
12 KiB
TypeScript
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 } : {}) });
|
|
} |