// 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; 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; 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; 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): 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[] { const rows: Record[] = []; 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 } : {}) }); }