diff --git a/README.md b/README.md index 132fdb6..8ac3911 100644 --- a/README.md +++ b/README.md @@ -6,15 +6,15 @@ data. ## Modules -| Module | Route | Status | What it does | -| ------------------------------ | -------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Scrubber** | `/scrubber` | Available | Strip personally identifying fields from `pacs.*` / `camt.*` XML before sharing with peers or vendors. Produces a privacy-safe mapping summary. | -| **Storyteller** | `/storyteller` | Available | Turn a `pacs.002 / 004 / 008 / 009` or `camt.052 / 053 / 054` message into a plain-language narrative plus a structured field projection and quick insights. | -| **IBAN Validator & Explainer** | `/iban` | Available | Validate and explain an IBAN from the bundled SWIFT IBAN Registry-derived data. Breaks the value into country, check digits, BBAN, bank, branch, account, and national-check segments where the registry supports it. | -| **BIC Validator\*** | `/bic` | Demo | ISO 9362 syntax checks plus a tiny bundled snapshot lookup. **Demonstration only:** bundled BIC data is not accurate/current enough for production, routing, compliance, reachability, or payment decisions. | -| **CBPR+ Readiness Checker** | `/cbpr` | Available | Browser-only AppHdr / Document namespace / CBPR+ schema-profile coverage checks, plus UETR, BIC syntax, and IBAN syntax/checksum hints. Not a certified validator or MyStandards usage-rule engine. | -| **Payment Insights Lite** | `/insights` | Available | Local lifecycle insight over ACK/NACK, `pacs.*`, and `camt.*` files you provide. Groups files by identifiers in memory; not live payment tracking, VOP, reachability, or settlement monitoring. | -| Vault | — | Planned | Planned encrypted local export bundle: user-controlled download/import, no cloud vault, no server storage, and no persistent browser storage by default. Not built yet. | +| Module | Route | Status | What it does | +| --------------------------- | -------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **Scrubber** | `/scrubber` | Available | Strip personally identifying fields from `pacs.*` / `camt.*` XML before sharing with peers or vendors. Produces a privacy-safe mapping summary. | +| **Storyteller** | `/storyteller` | Available | Turn a `pacs.002 / 004 / 008 / 009` or `camt.052 / 053 / 054` message into a plain-language narrative plus a structured field projection and quick insights. | +| **IBAN Workbench** | `/iban` | Available | Validate, build, catalogue, and trace provenance for IBANs from bundled SWIFT IBAN Registry-derived data. Builder computes MOD-97 check digits from exact-length BBAN fields; no live BIC, VOP, account existence, or reachability checks. | +| **BIC Validator\*** | `/bic` | Demo | ISO 9362 syntax checks plus a tiny bundled snapshot lookup. **Demonstration only:** bundled BIC data is not accurate/current enough for production, routing, compliance, reachability, or payment decisions. | +| **CBPR+ Readiness Checker** | `/cbpr` | Available | Browser-only AppHdr / Document namespace / CBPR+ schema-profile coverage checks, plus UETR, BIC syntax, and IBAN syntax/checksum hints. Not a certified validator or MyStandards usage-rule engine. | +| **Payment Insights Lite** | `/insights` | Available | Local lifecycle insight over ACK/NACK, `pacs.*`, and `camt.*` files you provide. Groups files by identifiers in memory; not live payment tracking, VOP, reachability, or settlement monitoring. | +| Vault | — | Planned | Planned encrypted local export bundle: user-controlled download/import, no cloud vault, no server storage, and no persistent browser storage by default. Not built yet. | \*The BIC module intentionally does **not** perform live BIC Directory lookup, current bank-directory enrichment, current SEPA reachability checks, SWIFT FIN @@ -43,7 +43,9 @@ banned APIs or absolute-URL `fetch` calls appear under `src/`. - **IBAN:** uses generated browser data derived from the curated SWIFT IBAN Registry material available to the project, with deterministic metadata beside - the generated JSON. + the generated JSON. `/iban` exposes Validate, Build, Catalogue, and Provenance + panels; Build derives MOD-97 check digits locally from exact-length BBAN fields + and still does not perform account existence, VOP, live BIC, or reachability checks. - **BIC:** uses a deliberately tiny, allowlisted demo snapshot plus source-snapshot country-code evidence for shape feedback. It is snapshot-bound and not a replacement for a current BIC Directory feed/update process. @@ -93,8 +95,8 @@ pnpm verify # lint + format:check + typecheck + test + privacy/data ch - Vite + React 18 + TypeScript strict mode - Tailwind v3 with light fintech tokens in `src/index.css`, derived from [`DESIGN.md`](./DESIGN.md) -- React Router for `/`, `/scrubber`, `/storyteller`, `/iban`, `/bic`, `/cbpr`, plus a `*` - fallback +- React Router for `/`, `/scrubber`, `/storyteller`, `/iban`, `/bic`, `/cbpr`, + `/insights`, plus a `*` fallback - Vitest + Testing Library for unit/page tests - Playwright for end-to-end smoke tests - ESLint + Prettier @@ -111,7 +113,7 @@ src/ version.ts # SUITE_VERSION constant (kept in sync with package.json) components/ bic/ # BIC demo validator UI primitives - iban/ # IBAN validator/explainer UI primitives + iban/ # IBAN Workbench UI primitives layout/ SuiteLayout.tsx # shell with skip link, header, outlet, footer SuiteHeader.tsx # wordmark + primary nav @@ -132,7 +134,7 @@ src/ lib/ bic/ # browser-only BIC syntax/demo snapshot helpers cbpr/ # browser-only CBPR+ readiness checks + generated manifest - iban/ # browser-only IBAN validation/decomposition helpers + iban/ # browser-only IBAN validate/build/catalogue/provenance helpers iso20022/ # Scrubber parsing/rewriting (no UI deps) storyteller/ # Storyteller parsing + narrative builders utils.ts # cn() helper @@ -161,8 +163,8 @@ To deploy on any static host, the host must: - Serve the contents of `dist/` from the site root. - For unknown / deep-link routes (for example `/scrubber`, `/storyteller`, - `/iban`, `/bic`, `/cbpr`, or `/anything-else`), fall back to serving `index.html` so - React Router can resolve the route on the client. This is sometimes called + `/iban`, `/bic`, `/cbpr`, `/insights`, or `/anything-else`), fall back to serving + `index.html` so React Router can resolve the route on the client. This is sometimes called "SPA fallback" or "rewrite all to index.html". - Not require any server-side execution. There is no API. - Preserve the privacy posture: no analytics injection, no remote logging, and no diff --git a/ROADMAP.md b/ROADMAP.md index b3d7e44..9cc091f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6,15 +6,15 @@ the suite is browser-only, static-host friendly, and privacy-first. ## Shipped baseline -| Area | Status | Notes | -| --------------------------------------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Scrubber | Shipped | Browser-side ISO 20022 XML scrubbing for `pacs.*` and `camt.*` samples. | -| Storyteller | Shipped | Browser-side ISO 20022 narrative/field projection for supported `pacs.*` and `camt.*` messages, with compact operator summary export. | -| IBAN Validator & Explainer | Shipped | Offline validation/decomposition from bundled SWIFT IBAN Registry-derived data. This is the strongest presently shipped reference-data module for durable offline payment intelligence. | -| BIC Validator\* | Shipped as demo | ISO 9362 syntax/decomposition plus tiny bundled snapshot lookup. Demonstration only; data is not accurate/current enough for production decisions. | -| CBPR+ Readiness Checker | Shipped | Browser-only AppHdr / Document namespace / bundled CBPR+ schema-profile coverage checks plus UETR, BIC syntax, and IBAN syntax/checksum hints. Not a certified validator. | -| Payment Insights Lite | Shipped | Browser-only local lifecycle insight over ACK/NACK, `pacs.*`, and `camt.*` files the user provides. Not live payment tracking, VOP, reachability, or settlement monitoring. | -| raafetchoukri.com-derived design system | Shipped | `DESIGN.md` and Tailwind tokens align the app with the light fintech/practitioner UI direction. | +| Area | Status | Notes | +| --------------------------------------- | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Scrubber | Shipped | Browser-side ISO 20022 XML scrubbing for `pacs.*` and `camt.*` samples. | +| Storyteller | Shipped | Browser-side ISO 20022 narrative/field projection for supported `pacs.*` and `camt.*` messages, with compact operator summary export. | +| IBAN Workbench | Shipped | Offline Validate, Build, Catalogue, and Provenance panels from bundled SWIFT IBAN Registry-derived data. Build computes MOD-97 check digits from exact-length BBAN fields; no live BIC, VOP, account-existence, or reachability claim. | +| BIC Validator\* | Shipped as demo | ISO 9362 syntax/decomposition plus tiny bundled snapshot lookup. Demonstration only; data is not accurate/current enough for production decisions. | +| CBPR+ Readiness Checker | Shipped | Browser-only AppHdr / Document namespace / bundled CBPR+ schema-profile coverage checks plus UETR, BIC syntax, and IBAN syntax/checksum hints. Not a certified validator. | +| Payment Insights Lite | Shipped | Browser-only local lifecycle insight over ACK/NACK, `pacs.*`, and `camt.*` files the user provides. Not live payment tracking, VOP, reachability, or settlement monitoring. | +| raafetchoukri.com-derived design system | Shipped | `DESIGN.md` and Tailwind tokens align the app with the light fintech/practitioner UI direction. | ## Recommended next steps @@ -39,37 +39,7 @@ Acceptance criteria: persistence introduced by the host configuration. - README hosting notes are updated with the chosen target. -### 2. IBAN Workbench - -Refine the next IBAN module from a narrow calculator into a single workbench -that consolidates validation, registry-supported building, and registry-derived -reference content. - -Sub-capabilities: - -- **Validate** — keep the shipped offline validation/decomposition, with - clearer empty/success/warning/error states. -- **Build** — assemble an IBAN from a country selector and registry-supported - BBAN components where the structure permits it. Generate IBAN check digits - with MOD97. Explain each step and show why unsupported countries/structures - are blocked. -- **Catalogue** — browse country IBAN/BBAN structures, sample IBANs, segment - labels, BBAN patterns, and registry-expressed national check digits. Link - catalogue entries back to Validate examples. (Absorbs the previously listed - BBAN Structure Explorer / Country Catalogue item.) -- **Provenance** — surface source-registry metadata, generated-at timestamp, - source hash, and registry/version label where available. Provenance copy must - state that no live bank or reference-data lookup is performed. - -Non-goals (carried forward): - -- No live bank lookup. -- No account-owner/name matching. -- No VOP. -- No live BIC enrichment. -- No guarantee that a domestic account exists or is open. - -### 3. Shared provenance pattern +### 2. Shared provenance pattern Promote the per-module provenance idea into a cross-module shared pattern rather than an IBAN-only panel. @@ -90,7 +60,7 @@ Acceptance criteria: - The component does not introduce any network call beyond the existing same-origin sample/data fetches. -### 4. Vault — encrypted local export bundle only +### 3. Vault — encrypted local export bundle only Vault remains planned and unbuilt. The recommended design is **Option A: an encrypted local export bundle**, not a cloud vault and not default browser @@ -139,9 +109,8 @@ is approved: 1. Documentation and roadmap refresh. 2. Deployment target decision and static-host configuration. -3. IBAN Workbench (Validate + Build + Catalogue + Provenance). -4. Shared provenance pattern across modules. -5. Vault only as the reviewed encrypted-local-export-bundle design above. +3. Shared provenance pattern across modules. +4. Vault only as the reviewed encrypted-local-export-bundle design above. ## Unscheduled candidates diff --git a/e2e/iban.spec.ts b/e2e/iban.spec.ts index b596412..56dc950 100644 --- a/e2e/iban.spec.ts +++ b/e2e/iban.spec.ts @@ -1,9 +1,9 @@ import { expect, test } from "@playwright/test"; -test("iban module validates a known-good GB sample", async ({ page }) => { +test("iban workbench validates a known-good GB sample", async ({ page }) => { await page.goto("/iban"); - await expect(page.getByRole("heading", { level: 1, name: /iban validator/i })).toBeVisible(); + await expect(page.getByRole("heading", { level: 1, name: /iban workbench/i })).toBeVisible(); await expect( page.getByText(/runs entirely in your browser\. nothing is uploaded\./i).first(), ).toBeVisible(); @@ -17,7 +17,9 @@ test("iban module validates a known-good GB sample", async ({ page }) => { await expect(breakdown).toContainText("NWBK"); }); -test("iban module flags a flipped check digit and shows what it does not do", async ({ page }) => { +test("iban workbench flags a flipped check digit and shows what it does not do", async ({ + page, +}) => { await page.goto("/iban"); const input = page.getByLabel("IBAN input"); await input.fill("GB28NWBK60161331926819"); @@ -31,3 +33,30 @@ test("iban module flags a flipped check digit and shows what it does not do", as await expect(page.getByText(/no live bic/i)).toBeVisible(); await expect(page.getByText(/no live sepa/i)).toBeVisible(); }); + +test("iban workbench builds an IBAN from the bundled BBAN template", async ({ page }) => { + await page.goto("/iban"); + await page.getByRole("button", { name: /build.*assemble an iban/i }).click(); + await page.getByRole("combobox", { name: /build country/i }).selectOption("GB"); + await page.getByRole("button", { name: /fill registry example/i }).click(); + + await expect(page.getByLabel(/built iban electronic/i)).toContainText("GB29NWBK60161331926819"); + await expect(page.getByLabel(/computed check digits/i)).toContainText("29"); + await expect(page.getByText(/No live bank, BIC, account-owner \/ VOP/i)).toBeVisible(); +}); + +test("iban workbench catalogues bundled structures without live-reachability claims", async ({ + page, +}) => { + await page.goto("/iban"); + await page.getByRole("button", { name: /catalogue.*browse bundled country/i }).click(); + await page.getByRole("textbox", { name: /search countries/i }).fill("United Kingdom"); + await page.getByRole("button", { name: /GB · United Kingdom/i }).click(); + + await expect(page.getByText(/The SEPA flag here is the bundled registry/i)).toBeVisible(); + await page.getByRole("button", { name: /validate this example/i }).click(); + await expect( + page.getByRole("button", { name: /validate.*bundled registry snapshot/i }), + ).toHaveAttribute("aria-pressed", "true"); + await expect(page.getByLabel("IBAN input")).toHaveValue(/^GB/); +}); diff --git a/scripts/privacy-audit.sh b/scripts/privacy-audit.sh index fc9ef73..98c9879 100755 --- a/scripts/privacy-audit.sh +++ b/scripts/privacy-audit.sh @@ -8,6 +8,12 @@ # - fetch("http…") / fetch('http…') — any absolute URL begins a network call # that leaves this site. # +# What we additionally ban inside the IBAN module (`src/lib/iban`, +# `src/components/iban`, and `src/pages/IbanPage.tsx`): +# - any fetch(...) or XMLHttpRequest usage, even same-origin. IBAN Workbench +# must be fully static/bundled and synchronous. +# - cookie writes/reads and common telemetry entrypoints. +# # What we deliberately allow: # - Same-origin fetches to relative paths such as `fetch("/samples/scrubber/index.json")`. # These are bundled fixtures shipped with the site itself. @@ -61,6 +67,54 @@ for i in "${!PATTERNS[@]}"; do fi done +IBAN_ROOTS=() +for path in "$ROOT/lib/iban" "$ROOT/components/iban" "$ROOT/pages/IbanPage.tsx"; do + if [[ -e "$path" ]]; then + IBAN_ROOTS+=("$path") + fi +done + +IBAN_PATTERNS=( + '\blocalStorage\s*[.\[]' + '\bsessionStorage\s*[.\[]' + '\bindexedDB\s*[.\[]' + 'document\.cookie' + 'navigator\.sendBeacon\s*\(' + '\bfetch\s*\(' + 'XMLHttpRequest' + '\bgtag\s*\(' + '\bdataLayer\s*[.\[]' +) + +IBAN_LABELS=( + 'IBAN localStorage' + 'IBAN sessionStorage' + 'IBAN indexedDB' + 'IBAN document.cookie' + 'IBAN navigator.sendBeacon' + 'IBAN fetch(...)' + 'IBAN XMLHttpRequest' + 'IBAN gtag(...)' + 'IBAN dataLayer' +) + +if [[ ${#IBAN_ROOTS[@]} -gt 0 ]]; then + for i in "${!IBAN_PATTERNS[@]}"; do + pattern="${IBAN_PATTERNS[$i]}" + label="${IBAN_LABELS[$i]}" + matches=$(grep -RInE --include='*.ts' --include='*.tsx' "$pattern" "${IBAN_ROOTS[@]}" || true) + if [[ -n "$matches" ]]; then + if [[ $found -eq 0 ]]; then + echo "privacy-audit: banned API usage detected in $ROOT/" >&2 + fi + echo "" >&2 + echo " [$label]" >&2 + echo "$matches" | sed 's/^/ /' >&2 + found=1 + fi + done +fi + if [[ $found -ne 0 ]]; then echo "" >&2 echo "privacy-audit: failed. The suite is browser-only with no persistence or external calls." >&2 diff --git a/src/App.test.tsx b/src/App.test.tsx index d893ec4..32491c4 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -57,7 +57,7 @@ describe("App", () => { it("renders the IBAN page at /iban", () => { renderAt("/iban"); - expect(screen.getByRole("heading", { level: 1, name: /iban validator/i })).toBeInTheDocument(); + expect(screen.getByRole("heading", { level: 1, name: /iban workbench/i })).toBeInTheDocument(); }); it("renders the CBPR+ page at /cbpr", () => { diff --git a/src/components/iban/IbanBuildPanel.tsx b/src/components/iban/IbanBuildPanel.tsx new file mode 100644 index 0000000..cbd8a1f --- /dev/null +++ b/src/components/iban/IbanBuildPanel.tsx @@ -0,0 +1,365 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { ArrowRight, CheckCircle2, ShieldCheck } from "lucide-react"; +import { WorkbenchPanel } from "@/components/layout/WorkbenchPanel"; +import { IbanBreakdown } from "@/components/iban/IbanBreakdown"; +import { + buildIban, + describeCharClass, + getBbanTemplate, + getCountry, + listCountries, +} from "@/lib/iban"; +import type { BbanFieldTemplate, BuildIbanResult, RegistryCountry } from "@/lib/iban"; +import { cn } from "@/lib/utils"; + +interface IbanBuildPanelProps { + /** Two-letter country code (or empty) supplied via the URL. */ + initialCountry: string; + /** Map of BBAN field id → initial value, supplied via the URL. */ + initialFields: Record; + /** + * Called when the user clicks "Open in Validate" with the assembled + * electronic IBAN. The shell uses this to switch panels. + */ + onOpenInValidate: (electronic: string) => void; +} + +type CopyState = "idle" | "copied"; + +export function IbanBuildPanel({ + initialCountry, + initialFields, + onOpenInValidate, +}: IbanBuildPanelProps) { + const countries = useMemo(() => listCountries(), []); + const [countryCode, setCountryCode] = useState(() => normalizeCountry(initialCountry)); + const [fieldValues, setFieldValues] = useState>(() => initialFields); + const [copyState, setCopyState] = useState("idle"); + const previousInitialCountry = useRef(normalizeCountry(initialCountry)); + + // If the URL country changes (e.g., user navigates from Catalogue), reset + // stale values unless the URL supplied explicit field prefill values. + useEffect(() => { + const next = normalizeCountry(initialCountry); + const hasIncoming = Object.values(initialFields).some((v) => v.length > 0); + const countryChanged = previousInitialCountry.current !== next; + previousInitialCountry.current = next; + setCountryCode(next); + if (countryChanged) { + setFieldValues(hasIncoming ? { ...initialFields } : {}); + } + }, [initialCountry, initialFields]); + + useEffect(() => { + setFieldValues((current) => { + const hasIncoming = Object.values(initialFields).some((v) => v.length > 0); + if (!hasIncoming) return current; + return { ...initialFields }; + }); + }, [initialFields]); + + const country = useMemo(() => { + if (!countryCode) return undefined; + return getCountry(countryCode); + }, [countryCode]); + + const template = useMemo(() => (country ? getBbanTemplate(country) : undefined), [country]); + + // When the country changes, drop stale field values that no longer apply. + useEffect(() => { + if (!template) return; + setFieldValues((current) => { + const next: Record = {}; + for (const f of template.fields) { + next[f.id] = current[f.id] ?? ""; + } + return next; + }); + }, [template]); + + const allFieldsFilled = template + ? template.fields.every((f) => (fieldValues[f.id] ?? "").trim().length > 0) + : false; + + const buildResult: BuildIbanResult | null = useMemo(() => { + if (!country || !template) return null; + if (!allFieldsFilled) return null; + return buildIban({ countryCode: country.countryCode, fields: fieldValues }); + }, [country, template, allFieldsFilled, fieldValues]); + + const handleFieldChange = (id: string, raw: string) => { + setFieldValues((current) => ({ ...current, [id]: raw })); + setCopyState("idle"); + }; + + const handleClearFields = () => { + if (!template) return; + const cleared: Record = {}; + for (const f of template.fields) cleared[f.id] = ""; + setFieldValues(cleared); + setCopyState("idle"); + }; + + const handleFillExample = () => { + if (!country || !template) return; + const exBban = country.exampleIbanElectronic.slice(4); + const next: Record = {}; + for (const f of template.fields) { + next[f.id] = exBban.slice(f.start, f.start + f.length); + } + setFieldValues(next); + setCopyState("idle"); + }; + + const handleCopyElectronic = async () => { + if (!buildResult || buildResult.ok !== true) return; + try { + await navigator.clipboard.writeText(buildResult.electronic); + setCopyState("copied"); + window.setTimeout(() => setCopyState("idle"), 1500); + } catch { + // Clipboard denied — fall through quietly. + } + }; + + return ( +
+ + + +
+ ) : null + } + > + + + {!template ? ( +

+ Pick a country to load its exact-length BBAN field template. +

+ ) : ( +
+ + BBAN fields ({template.bbanLength} characters) + + {template.fields.map((field) => ( + handleFieldChange(field.id, v)} + error={extractFieldError(buildResult, field.id)} + /> + ))} +
+ )} + +
+

+

+
    +
  • + MOD-97 check digits prove ISO 13616 shape, not that an account exists or is open. +
  • +
  • + No live bank, BIC, account-owner / VOP, or SEPA-reachability lookup happens here. +
  • +
  • + Field templates and examples come from the bundled registry snapshot; they are not a + real-time directory. +
  • +
+
+ + + + {!country ? ( +

+ Pick a country and fill in every BBAN field to see the assembled IBAN. +

+ ) : !buildResult ? ( +

+ Fill every BBAN field with its exact length to see the assembled IBAN. +

+ ) : buildResult.ok ? ( +
+
+

+ Pretty print +

+

+ {buildResult.pretty} +

+
+
+

+ Electronic +

+

+ {buildResult.electronic} +

+
+
+

+ Computed check digits +

+

+ {buildResult.checkDigits} +

+
+
+ + + + +
+ +
+ ) : ( +
+

{buildResult.message}

+ {buildResult.fieldErrors && buildResult.fieldErrors.length > 0 ? ( +
    + {buildResult.fieldErrors.map((err) => ( +
  • {err.message}
  • + ))} +
+ ) : null} +
+ )} +
+ + ); +} + +interface BuildFieldInputProps { + field: BbanFieldTemplate; + value: string; + onChange: (raw: string) => void; + error: string | undefined; +} + +function BuildFieldInput({ field, value, onChange, error }: BuildFieldInputProps) { + const helpId = `field-${field.id}-help`; + const errorId = `field-${field.id}-error`; + const help = `Position ${field.position} · ${field.length} ${describeCharClass(field.charClass)}`; + return ( + + ); +} + +function extractFieldError(result: BuildIbanResult | null, fieldId: string): string | undefined { + if (!result || result.ok) return undefined; + if (result.reason !== "field-errors") return undefined; + return result.fieldErrors?.find((e) => e.fieldId === fieldId)?.message; +} + +function normalizeCountry(code: string): string { + if (!code) return ""; + const upper = code.trim().toUpperCase(); + return /^[A-Z]{2}$/.test(upper) ? upper : ""; +} diff --git a/src/components/iban/IbanCataloguePanel.tsx b/src/components/iban/IbanCataloguePanel.tsx new file mode 100644 index 0000000..94dde6d --- /dev/null +++ b/src/components/iban/IbanCataloguePanel.tsx @@ -0,0 +1,236 @@ +import { useMemo, useState } from "react"; +import { ArrowRight, Search } from "lucide-react"; +import { WorkbenchPanel } from "@/components/layout/WorkbenchPanel"; +import { getBbanTemplate, listCountries } from "@/lib/iban"; +import type { BbanTemplate, RegistryCountry } from "@/lib/iban"; + +interface IbanCataloguePanelProps { + /** Initial search query supplied via the URL `q` param. */ + initialQuery: string; + /** Country code to expand on first render, supplied via the URL `detail` param. */ + initialDetail: string; + onValidateExample: (electronic: string) => void; + onBuildCountry: (countryCode: string) => void; +} + +export function IbanCataloguePanel({ + initialQuery, + initialDetail, + onValidateExample, + onBuildCountry, +}: IbanCataloguePanelProps) { + const countries = useMemo(() => listCountries(), []); + const [query, setQuery] = useState(initialQuery); + const [sepaOnly, setSepaOnly] = useState(false); + const [openCode, setOpenCode] = useState(initialDetail.toUpperCase()); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + return countries.filter((c) => { + if (sepaOnly && !c.sepa) return false; + if (!q) return true; + return c.countryCode.toLowerCase().includes(q) || c.countryName.toLowerCase().includes(q); + }); + }, [countries, query, sepaOnly]); + + const toggleOpen = (code: string) => { + setOpenCode((current) => (current === code ? "" : code)); + }; + + return ( +
+ + + +

+ {filtered.length} of {countries.length} bundled countries +

+ +
    + {filtered.map((country) => ( + toggleOpen(country.countryCode)} + onValidateExample={() => onValidateExample(country.exampleIbanElectronic)} + onBuildCountry={() => onBuildCountry(country.countryCode)} + /> + ))} +
+ {filtered.length === 0 ? ( +

+ No bundled country matches “{query}”. +

+ ) : null} +
+
+ ); +} + +interface CountryRowProps { + country: RegistryCountry; + isOpen: boolean; + onToggle: () => void; + onValidateExample: () => void; + onBuildCountry: () => void; +} + +function CountryRow({ + country, + isOpen, + onToggle, + onValidateExample, + onBuildCountry, +}: CountryRowProps) { + const template = useMemo(() => getBbanTemplate(country), [country]); + const headingId = `catalogue-${country.countryCode}-heading`; + return ( +
  • + + {isOpen ? ( +
    +
    +
    +
    Example (electronic)
    +
    + {country.exampleIbanElectronic} +
    +
    +
    +
    Example (print)
    +
    + {country.exampleIbanPrint} +
    +
    +
    +
    IBAN structure
    +
    {country.ibanStructure}
    +
    +
    +
    BBAN structure
    +
    {country.bbanStructure}
    +
    + {country.bankIdentifier ? ( +
    +
    Bank identifier position
    +
    + {country.bankIdentifier.position} · {country.bankIdentifier.structure} +
    +
    + ) : null} + {country.branchIdentifier ? ( +
    +
    Branch identifier position
    +
    + {country.branchIdentifier.position} · {country.branchIdentifier.structure} +
    +
    + ) : null} + {country.contact ? ( +
    +
    Registry contact
    +
    {country.contact}
    +
    + ) : null} +
    +
    SEPA flag
    +
    + {country.sepa ? "Yes (registry snapshot)" : "No (registry snapshot)"} +
    +
    +
    + +
    +

    + BBAN field template +

    +
      + {template.fields.map((f) => ( +
    • + {f.label} + + pos {f.position} · {f.length}!{f.charClass} + +
    • + ))} +
    +
    + +

    + The SEPA flag here is the bundled registry’s scheme-country flag at snapshot time, not a + live SEPA-reachability check. The “bank identifier” is a structural field, not a live + BIC, account-owner / VOP, or bank-directory entry. +

    + +
    + + +
    +
    + ) : null} +
  • + ); +} diff --git a/src/components/iban/IbanProvenancePanel.tsx b/src/components/iban/IbanProvenancePanel.tsx new file mode 100644 index 0000000..9868a1e --- /dev/null +++ b/src/components/iban/IbanProvenancePanel.tsx @@ -0,0 +1,111 @@ +import { useMemo } from "react"; +import { Lock, ShieldCheck } from "lucide-react"; +import { WorkbenchPanel } from "@/components/layout/WorkbenchPanel"; +import { getRegistryMeta, listCountries } from "@/lib/iban"; + +const nonGoals = [ + "Live BIC Directory or current bank-directory enrichment.", + "Verification of Payee (VOP) or account-owner / name matching.", + "Live SEPA-reachability lookup; the SEPA flag is the bundled registry’s scheme-country flag.", + "Account existence, account-open status, or settlement / clearing reachability.", +]; + +const privacyBoundary = [ + "All parsing, validation, and assembly happen in this page using bundled data.", + "No backend or remote IBAN service is contacted — there is no fetch or XHR in this module.", + "No localStorage, sessionStorage, IndexedDB, or cookies are written.", + "No analytics, telemetry, or remote logging.", +]; + +export function IbanProvenancePanel() { + const meta = useMemo(() => getRegistryMeta(), []); + const countryCount = useMemo(() => listCountries().length, []); + + return ( +
    + +
    +
    +
    Registry version
    +
    {meta.registryVersion}
    +
    +
    +
    Countries bundled
    +
    {countryCount}
    +
    +
    +
    Source file
    +
    {meta.sourceFile}
    +
    +
    +
    Source SHA-256
    +
    + {meta.sourceSha256} +
    +
    + {meta.latestCountryEffectiveDate ? ( +
    +
    Latest country effective date
    +
    + {meta.latestCountryEffectiveDate} +
    +
    + ) : null} +
    + +
    +

    +

    +
      + {privacyBoundary.map((line) => ( +
    • +
    • + ))} +
    +
    +
    + + +
    +

    +

    +
      + {nonGoals.map((line) => ( +
    • +
    • + ))} +
    +
    +

    + A valid Validate verdict means the supplied value passes ISO 13616 country / length / + structure / MOD-97 checks against the bundled registry snapshot. It does not assert that + the bank, branch, or account exists, is open, or is reachable today. +

    +
    +
    + ); +} diff --git a/src/components/iban/IbanValidatePanel.tsx b/src/components/iban/IbanValidatePanel.tsx new file mode 100644 index 0000000..75c9f50 --- /dev/null +++ b/src/components/iban/IbanValidatePanel.tsx @@ -0,0 +1,200 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { ArrowRight, CheckCircle2, Lock, ShieldCheck, XCircle } from "lucide-react"; +import { WorkbenchPanel } from "@/components/layout/WorkbenchPanel"; +import { IbanChecks } from "@/components/iban/IbanChecks"; +import { IbanBreakdown } from "@/components/iban/IbanBreakdown"; +import { IbanSamples } from "@/components/iban/IbanSamples"; +import { analyzeIban, listCountries, normalizeIban } from "@/lib/iban"; +import type { AnalyzeResult } from "@/lib/iban"; +import { cn } from "@/lib/utils"; + +interface IbanValidatePanelProps { + /** Initial IBAN value, e.g. supplied via the `iban` query parameter. */ + initialIban: string; + /** Called when the user wants to open the current valid IBAN in the builder. */ + onOpenInBuild: (countryCode: string, electronic: string) => void; +} + +type CopyState = "idle" | "copied"; + +const limitations = [ + "No Verification of Payee (VOP) and no account-owner / name matching.", + "No live BIC lookup or current bank-directory enrichment.", + "No live SEPA-reachability check; the SEPA flag is the bundled registry’s scheme-country flag, not a real-time reachability signal.", +]; + +export function IbanValidatePanel({ initialIban, onOpenInBuild }: IbanValidatePanelProps) { + const [input, setInput] = useState(initialIban); + const [sampleCode, setSampleCode] = useState(""); + const [copyState, setCopyState] = useState("idle"); + const inputRef = useRef(null); + const initialIbanRef = useRef(initialIban); + + const countries = useMemo(() => listCountries(), []); + const result = useMemo(() => analyzeIban(input), [input]); + + // When the controlled `initialIban` prop changes (e.g., from another panel), + // sync the local input. We only react to actual changes, not first mount. + useEffect(() => { + if (initialIban === initialIbanRef.current) return; + initialIbanRef.current = initialIban; + setInput(initialIban); + }, [initialIban]); + + const handleSamplePick = (electronic: string) => { + setInput(electronic); + const code = normalizeIban(electronic).slice(0, 2); + setSampleCode(code); + inputRef.current?.focus(); + }; + + const handleClear = () => { + setInput(""); + setSampleCode(""); + setCopyState("idle"); + }; + + const handleCopyNormalized = async () => { + if (!result.normalized) return; + try { + await navigator.clipboard.writeText(result.normalized); + setCopyState("copied"); + window.setTimeout(() => setCopyState("idle"), 1500); + } catch { + // Clipboard access denied — fall back silently; users can still select. + } + }; + + const verdictBadge = renderVerdict(result); + + return ( +
    + + Clear input + + } + > + + +