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.
166 lines
No EOL
6.4 KiB
TypeScript
166 lines
No EOL
6.4 KiB
TypeScript
// lib/vat-cadence.ts
|
|
// Maps a company's PocketBase VAT cadence to the period months that
|
|
// actually exist on SARS, and to the exact `--cadence` label the capture
|
|
// script expects.
|
|
//
|
|
// WHY THIS EXISTS
|
|
// A VAT201 return only exists for the months its company's SARS
|
|
// "VAT Return Every" setting generates. An uneven-months vendor has NO
|
|
// February, April, June, August, October or December return — not
|
|
// "unfiled", simply non-existent. Asking the capture script for those
|
|
// months made it report every one of them as `not_filed`, and the
|
|
// push-to-PB step then wrote them into the compliance tree as genuine
|
|
// outstanding returns. Filtering here removes that class of bad data at
|
|
// the source.
|
|
//
|
|
// CADENCE SOURCE OF TRUTH
|
|
// `ysterbaard_companies.sars_vat_returns_period` (see lib/types.ts):
|
|
// 0 = None · 1 = Every Month · 2 = Even Months · 3 = Uneven Months
|
|
// 6 = Bi-Annually · 12 = Annually
|
|
// The Compliance tab already reasons about this field correctly via its
|
|
// own `vatPeriodMonths()`; this module is the shared version.
|
|
//
|
|
// RELATIONSHIP TO THE CAPTURE SCRIPT
|
|
// The .py owns the authoritative month-set (VAT_CADENCE_PERIODS in
|
|
// "SARS Retun VAT201 single or all pty.py"). This module passes the
|
|
// cadence LABEL across and mirrors the month-set only for UI display.
|
|
// `VAT_PERIOD_MONTHS` below must stay identical to the .py's table —
|
|
// if you change one, change both.
|
|
//
|
|
// CLIENT-SAFE: this module must stay free of `server-only` imports so the
|
|
// SARS Returns workbench (a client component) can use the period maths for
|
|
// its chips and badges. The PocketBase lookup lives in
|
|
// `vat-cadence.server.ts`, which is server-only.
|
|
|
|
/** PB `sars_vat_returns_period` → the canonical label the .py accepts. */
|
|
export const VAT_CADENCE_LABELS: Record<number, string> = {
|
|
1: "Every month",
|
|
2: "Even months",
|
|
3: "Uneven months",
|
|
6: "Bi-annually",
|
|
12: "Annually",
|
|
};
|
|
|
|
/**
|
|
* Valid period months per cadence — MIRROR of VAT_CADENCE_PERIODS in the
|
|
* capture .py. Used for display/chipping only; the .py is what actually
|
|
* filters during a run.
|
|
*
|
|
* CAUTION: the 6 (Bi-annually) and 12 (Annually) rows encode SARS period
|
|
* NUMBERING, not month arithmetic — 6 → {2, 8} and 12 → {3}. These were
|
|
* transcribed from the .py and have not yet been confirmed against a real
|
|
* bi-annual/annual client. Check one before relying on the UI chips for
|
|
* those two cadences; the capture itself is unaffected either way, because
|
|
* the .py re-derives the set from the label we send it.
|
|
*/
|
|
export const VAT_PERIOD_MONTHS: Record<number, number[]> = {
|
|
1: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], // Every month
|
|
2: [2, 4, 6, 8, 10, 12], // Even months
|
|
3: [1, 3, 5, 7, 9, 11], // Uneven months
|
|
6: [2, 8], // Bi-annually
|
|
12: [3], // Annually
|
|
};
|
|
|
|
/** Human-readable short label for badges. */
|
|
export const VAT_CADENCE_SHORT: Record<number, string> = {
|
|
0: "None",
|
|
1: "Monthly",
|
|
2: "Even months",
|
|
3: "Uneven months",
|
|
6: "Bi-annually",
|
|
12: "Annually",
|
|
};
|
|
|
|
/** True when this cadence generates VAT periods at all (not VAT-registered). */
|
|
export function isVatCadenceActive(code: number | null | undefined): boolean {
|
|
return !!code && code !== 0 && !!VAT_CADENCE_LABELS[code];
|
|
}
|
|
|
|
/** Filter YYYYMM periods down to the ones that exist for a cadence. */
|
|
export function filterPeriodsByCadence(periods: string[], code: number | null | undefined): string[] {
|
|
const months = code == null ? null : VAT_PERIOD_MONTHS[code];
|
|
if (!months || months.length === 0) return periods;
|
|
return periods.filter((p) => months.includes(Number(p.slice(4, 6))));
|
|
}
|
|
|
|
/** Human label for a cadence code, safe for null/unknown. */
|
|
export function describeVatCadence(code: number | null | undefined): string {
|
|
if (code == null) return "Unknown";
|
|
return VAT_CADENCE_SHORT[code] ?? `Unknown (${code})`;
|
|
}
|
|
|
|
/**
|
|
* A selectable client, as passed from the SARS Returns page to the
|
|
* workbench. Declared here (client-safe) rather than in page.tsx so the
|
|
* client component does not have to import a server module for a type.
|
|
*/
|
|
export interface ActiveClient {
|
|
id: string;
|
|
name: string;
|
|
/** PAYE registration — gates EMP201 eligibility. */
|
|
payeNo: string;
|
|
/** VAT registration — gates VAT201 eligibility. */
|
|
vatNo: string;
|
|
/** SARS "VAT Return Every" code; see the tables above. */
|
|
vatCadence: number | null;
|
|
}
|
|
|
|
export interface CompanyCadence {
|
|
name: string;
|
|
id: string;
|
|
/** Raw `sars_vat_returns_period`. */
|
|
cadence: number | null;
|
|
/** Canonical .py label, or "" when the company has no VAT cadence. */
|
|
cadenceLabel: string;
|
|
/** False when cadence is 0/null → not VAT-registered, must be skipped. */
|
|
vatActive: boolean;
|
|
/** Whether the company has a SARS VAT number at all. */
|
|
hasVatNo: boolean;
|
|
}
|
|
|
|
/** Build a CompanyCadence from a raw PB record's field values. */
|
|
export function toCompanyCadence(rec: {
|
|
id?: string;
|
|
enterprise_name?: string;
|
|
sars_vat_no?: string | null;
|
|
sars_vat_returns_period?: number | null;
|
|
}): CompanyCadence {
|
|
const raw = rec.sars_vat_returns_period;
|
|
const cadence = typeof raw === "number" ? raw : Number(raw ?? 0) || 0;
|
|
return {
|
|
name: rec.enterprise_name || "",
|
|
id: rec.id || "",
|
|
cadence,
|
|
cadenceLabel: VAT_CADENCE_LABELS[cadence] ?? "",
|
|
vatActive: isVatCadenceActive(cadence),
|
|
hasVatNo: ((rec.sars_vat_no as string) ?? "").trim() !== "",
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Group companies into one job per distinct cadence.
|
|
*
|
|
* The capture script accepts a SINGLE `--cadence` and applies it to every
|
|
* company in the invocation, so mixed cadences cannot share a run. Each
|
|
* bucket becomes its own sequential .py invocation.
|
|
*
|
|
* `fallbackLabel` is used when PB has no cadence for a company — keeping
|
|
* the old unfiltered behaviour rather than silently dropping the company.
|
|
*/
|
|
export function groupCompaniesByCadence(
|
|
companies: CompanyCadence[],
|
|
fallbackLabel = ""
|
|
): Array<{ cadenceLabel: string; companies: string[] }> {
|
|
const buckets = new Map<string, string[]>();
|
|
for (const c of companies) {
|
|
const label = c.vatActive ? c.cadenceLabel : fallbackLabel;
|
|
const key = label || "__unfiltered__";
|
|
const arr = buckets.get(key);
|
|
if (arr) arr.push(c.name);
|
|
else buckets.set(key, [c.name]);
|
|
}
|
|
return [...buckets.entries()].map(([key, names]) => ({
|
|
cadenceLabel: key === "__unfiltered__" ? fallbackLabel : key,
|
|
companies: names,
|
|
}));
|
|
} |