Workflow-App/Workflow/lib/vat-cadence.ts
sjcoetzeesj-coder 11d0178ab1 feat: commit Workflow app source; move CIPC credentials to env
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.
2026-10-18 15:21:45 +02:00

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,
}));
}