Workflow-App/Workflow/lib/shareholder-changes.ts

335 lines
12 KiB
TypeScript
Raw Permalink Normal View History

// 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<ShareholderRole, string> = {
transferor: "Transferor (seller)",
transferee: "Transferee (buyer)",
};
export const ROLE_SHORT: Record<ShareholderRole, string> = {
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);
}