// lib/shareholder-changes.ts // // Shared vocabulary for the "5 - Change in shareholders" section. // // A shareholder change is a SPECIAL RESOLUTION OF SHAREHOLDERS, not a // resolution of directors. That single distinction drives everything here: // // - the signatories are shareholders (who may be directors, may be other // companies, may be neither), not necessarily the board // - the subject matter is a transfer of shares, not a change of office // - the legal thresholds differ: a change of share ownership is ordinarily // passed by shareholders holding at least 75% of the voting rights, and // the MOI may additionally require a specific quorum // // STORAGE // ------- // Everything reuses the collections section 4 already uses, so no parallel // schema exists to drift out of sync: // // ysterbaard_resolutions kind = "shareholder_change" (new field) // ysterbaard_resolution_attendees one row per party, with // director_role = "transferor" | "transferee" // shares = shares moving to/from them // // `kind` is blank on every pre-existing resolution row; treat blank as // "director_change" so section 4 keeps listing exactly what it listed before. // // The full structured detail (which PB record each party came from, how many // shares they held BEFORE the transfer) does not fit the attendee schema, so it // is kept as a JSON document in the resolution's `notes` field. That field was // free text and is unused by section 4. Everything important for reporting // (role + share count) is ALSO on the attendee row, so the JSON is a // convenience, not a single point of failure. /** PocketBase collection ids. */ export const COL = { /** ysterbaard_resolutions */ resolutions: "pbc_266170881", /** ysterbaard_resolution_attendees */ attendees: "pbc_1608833669", /** ysterbaard_companies */ companies: "pbc_3748838150", /** ysterbaard_directors */ directors: "pbc_4221287672", /** ysterbaard_company_directors (junction) */ companyDirectors: "pbc_2497518006", } as const; /** * `ysterbaard_resolutions.kind`. Rows created before the migration carry "" — * normalise both to one of these so callers never branch on a blank string. */ export type ResolutionKind = "director_change" | "shareholder_change"; export function normaliseKind(raw: unknown): ResolutionKind { return raw === "shareholder_change" ? "shareholder_change" : "director_change"; } /** * Where a shareholder came from. Shareholders are not a collection of their * own yet, so a shareholder is somebody already on file — either a person who * is (also) a director, or a company. */ export type ShareholderSource = "director" | "company"; /** SA Companies Act wording for the two sides of a share transfer. */ export type ShareholderRole = "transferor" | "transferee"; export const ROLE_LABEL: Record = { transferor: "Transferor (seller)", transferee: "Transferee (buyer)", }; export const ROLE_SHORT: Record = { transferor: "Transferor", transferee: "Transferee", }; /** One party to a share transfer, as captured by the wizard. */ export interface PartyInput { source: ShareholderSource; /** PB record id of the director or company this shareholder was picked from. */ source_id: string; /** Forename(s). Companies use their trade name here with empty surname. */ name: string; surname: string; email: string | null; /** * ID number for a person, enterprise number for a company — copied from the * source record when the party is picked, so the letter can recite it without * a second PocketBase round-trip per party. * * Stored verbatim. Never mask a number we already hold: the operator reads the * letter, and a string of X's in a legal document is worse than no number. */ id_number: string | null; role: ShareholderRole; /** * Shares the party held BEFORE this resolution, where known. * Only meaningful for a transferor; null when the user did not state it. */ shares_held: number | null; /** * Shares actually moving. For a transferor this is what they sell; for a * transferee this is what they acquire. */ shares: number; } /** * A transferor carries the certificate-level detail the letter recites in its * WHEREAS clauses. These are the "insert box" fields on the firm's draft — * they only exist for the selling side, so they are not on PartyInput. */ export interface TransferorInput extends PartyInput { role: "transferor"; /** Share Certificate No. */ certificate_no: string; /** Percentage of the issued shares this party held, as written in the letter. */ percentage: string; /** Distinctive numbers of the shares being sold, inclusive range. */ distinctive_from: string; distinctive_to: string; /** Shares the seller keeps after the transfer. */ shares_retained: number; retained_from: string; retained_to: string; /** Consideration for this seller's shares only (per transferor, agreed 2026-10-14). */ consideration: string; /** Cash / EFT / cheque / other — rendered as "paid in full by ___". */ payment_method: string; } export interface TransfereeInput extends PartyInput { role: "transferee"; } export type ShareholderParty = TransferorInput | TransfereeInput; /** * Someone who attended the meeting and (optionally) signs the resolution. * * This is a DIFFERENT list from the transferors and transferees. Attendees are * who was present to approve; transferors are who the shares move from. The * same director can appear in both, and does so as two separate records, * because the letter states both things about them. */ export interface MeetingAttendee { director_id: string; name: string; surname: string; email: string | null; /** * ID and cell, so the signature block can identify each signer the way * section 4 does ("ID - 9810230091087", "Cell - 0827985409"). Stored * verbatim — a masked number in a legal document is worse than none. */ id_number: string | null; cell: string | null; /** Free-text capacity in the meeting — Director, Chairman, Secretary, Guest. */ position: string; /** Whether this attendee signs the resolution (operator ticks this). */ signs: boolean; } /** The JSON document stored in the resolution's `notes` field. */ export interface ShareholderChangeDetail { version: 2; company_id: string; /** Voting rights held by those in favour, for the Voting Rights clause. */ votes_percent: string; attendees: MeetingAttendee[]; parties: ShareholderParty[]; /** Free-text note the operator typed in the wizard. */ note?: string; } export function isShareholderChangeDetail(raw: string | null | undefined): boolean { return parseShareholderChangeDetail(raw) !== null; } /** * Read the structured detail off a resolution row. * Returns null for anything that is not one of our documents — which is every * row created by section 4, so callers can safely test this before trusting it. */ export function parseShareholderChangeDetail( notes: string | null | undefined ): ShareholderChangeDetail | null { if (!notes) return null; const trimmed = notes.trim(); if (!trimmed.startsWith("{")) return null; // Typed loosely on purpose: this reader must stay able to recognise the // version-1 shape it wrote earlier, which TypeScript would reject against // the current version field. const tryParse = (text: string): ShareholderChangeDetail | null => { try { const parsed = JSON.parse(text) as { version?: number; parties?: unknown; attendees?: MeetingAttendee[]; votes_percent?: string; }; const ok = (parsed?.version === 1 || parsed?.version === 2) && Array.isArray(parsed.parties); if (!ok) return null; return { ...(parsed as unknown as ShareholderChangeDetail), attendees: parsed.attendees ?? [], votes_percent: parsed.votes_percent ?? "", }; } catch { return null; } }; // Plain path: the whole field is one JSON document. const direct = tryParse(trimmed); if (direct) return direct; // Tolerant path: an early version appended the operator's free-text note after // the JSON, which makes the field as a whole unparseable. Recover the JSON // object from the front rather than losing every party's details. const end = trimmed.lastIndexOf("}"); if (end === -1) return null; const recovered = tryParse(trimmed.slice(0, end + 1)); if (!recovered) return null; const noteMatch = /\n\s*NOTE:\s*([\s\S]*)$/.exec(trimmed.slice(end + 1)); return noteMatch ? { ...recovered, note: noteMatch[1].trim() } : recovered; } // ─── display helpers ──────────────────────────────────────────────────────── /** * Display name for a party. Directors are stored SHOUTED in PocketBase * ("SUNE PIETERS"); in a picker list that reads as shouting, so title-case it * on the way out. Company names are left alone — their capitalisation is * deliberate. */ export function displayPartyName( source: ShareholderSource, name: string, surname: string ): string { const full = [name, surname].filter(Boolean).join(" ").trim(); if (!full) return "—"; if (source === "company") return full; return full .split(/\s+/) .map((w) => (w ? w[0].toUpperCase() + w.slice(1).toLowerCase() : w)) .join(" "); } /** "1 000" style grouping, matching how share counts are written on paper. */ export function formatShares(n: number | null | undefined): string { if (n === null || n === undefined || Number.isNaN(n)) return "—"; return new Intl.NumberFormat("en-ZA").format(n); } /** Short human summary, e.g. "600 sold / 600 acquired". */ export function summariseTransfer(detail: ShareholderChangeDetail | null): string { if (!detail) return ""; let sold = 0; let acquired = 0; for (const p of detail.parties) { if (p.role === "transferor") sold += p.shares; else acquired += p.shares; } const soldPart = `${formatShares(sold)} sold`; const acquiredPart = `${formatShares(acquired)} acquired`; if (sold === 0 && acquired === 0) return "no shares"; if (sold === acquired) return `${soldPart} / ${acquiredPart}`; // A mismatch is a real data-entry signal, not a formatting quirk — surface it. return `${soldPart} / ${acquiredPart} (does not balance)`; } /** A transfer that does not balance is almost always a typo, but it is legal to * record before the author notices. Callers warn rather than block. */ export function transferBalances(detail: ShareholderChangeDetail | null): boolean { if (!detail) return true; let sold = 0; let acquired = 0; for (const p of detail.parties) { if (p.role === "transferor") sold += p.shares; else acquired += p.shares; } return sold === acquired; } // ─── letter wording helpers ───────────────────────────────────────────────── /** * How a party is identified in the letter's WHEREAS clauses: a person by ID * number, a company by enterprise number. The draft wrote * "[name surname and ID number or company name and Enterprise number]". * * The result ALREADY contains the display name, so callers must not print the * name alongside it — "the Conrad Mmako of Conrad Mmako (ID …)" is what happens * if they do. */ export function idLabel( source: ShareholderSource, name: string, surname: string, idOrEntityNo: string | null ): string { const display = displayPartyName(source, name, surname); const ref = (idOrEntityNo ?? "").trim(); if (!ref) return display; return source === "company" ? `${display} (enterprise number ${ref})` : `${display} (ID ${ref})`; } /** * How consideration was paid. A dropdown rather than free text because the * letter reads "paid for in full, by ___" and a typed answer ("by bank * transfer via FNB") produces a garbled sentence. */ export const PAYMENT_METHODS = [ "cash", "electronic funds transfer", "cheque", "debit order", "other (as agreed in writing)", ] as const; export type PaymentMethod = (typeof PAYMENT_METHODS)[number]; export function isPaymentMethod(v: string): v is PaymentMethod { return (PAYMENT_METHODS as readonly string[]).includes(v); }