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.
335 lines
No EOL
12 KiB
TypeScript
335 lines
No EOL
12 KiB
TypeScript
// 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);
|
|
} |