From 3e537a7e2fa54a98dd5015f91d7722c389f4cb0d Mon Sep 17 00:00:00 2001 From: Bart Coppens Date: Wed, 3 Jun 2026 16:52:39 +0200 Subject: [PATCH 1/3] Add tool to generate Belgian national id. Expandable with other countries. --- components.d.ts | 34 +++ locales/en.yml | 4 + plan-idNumberGenerator-v2.prompt.md | 59 +++++ plan-idNumberGenerator.prompt.md | 67 +++++ src/tools/index.ts | 7 +- src/tools/national-id-generator/index.ts | 12 + .../national-id-generator.e2e.spec.ts | 33 +++ .../national-id-generator.service.test.ts | 219 ++++++++++++++++ .../national-id-generator.service.ts | 8 + .../national-id-generator.types.ts | 44 ++++ .../national-id-generator.vue | 237 ++++++++++++++++++ .../national-id-generator/strategies/be.ts | 111 ++++++++ .../national-id-generator/strategies/index.ts | 12 + src/ui/c-select/c-select.vue | 12 + 14 files changed, 858 insertions(+), 1 deletion(-) create mode 100644 plan-idNumberGenerator-v2.prompt.md create mode 100644 plan-idNumberGenerator.prompt.md create mode 100644 src/tools/national-id-generator/index.ts create mode 100644 src/tools/national-id-generator/national-id-generator.e2e.spec.ts create mode 100644 src/tools/national-id-generator/national-id-generator.service.test.ts create mode 100644 src/tools/national-id-generator/national-id-generator.service.ts create mode 100644 src/tools/national-id-generator/national-id-generator.types.ts create mode 100644 src/tools/national-id-generator/national-id-generator.vue create mode 100644 src/tools/national-id-generator/strategies/be.ts create mode 100644 src/tools/national-id-generator/strategies/index.ts diff --git a/components.d.ts b/components.d.ts index 3e65c3cc52..3fb1df1840 100644 --- a/components.d.ts +++ b/components.d.ts @@ -90,17 +90,28 @@ declare module '@vue/runtime-core' { HttpStatusCodes: typeof import('./src/tools/http-status-codes/http-status-codes.vue')['default'] IbanValidatorAndParser: typeof import('./src/tools/iban-validator-and-parser/iban-validator-and-parser.vue')['default'] 'IconMdi:brushVariant': typeof import('~icons/mdi/brush-variant')['default'] + 'IconMdi:contentCopy': typeof import('~icons/mdi/content-copy')['default'] 'IconMdi:kettleSteamOutline': typeof import('~icons/mdi/kettle-steam-outline')['default'] + IconMdiArrowDown: typeof import('~icons/mdi/arrow-down')['default'] + IconMdiArrowRightBottom: typeof import('~icons/mdi/arrow-right-bottom')['default'] + IconMdiCamera: typeof import('~icons/mdi/camera')['default'] IconMdiChevronDown: typeof import('~icons/mdi/chevron-down')['default'] IconMdiChevronRight: typeof import('~icons/mdi/chevron-right')['default'] IconMdiClose: typeof import('~icons/mdi/close')['default'] IconMdiContentCopy: typeof import('~icons/mdi/content-copy')['default'] + IconMdiDeleteOutline: typeof import('~icons/mdi/delete-outline')['default'] + IconMdiDownload: typeof import('~icons/mdi/download')['default'] IconMdiEye: typeof import('~icons/mdi/eye')['default'] IconMdiEyeOff: typeof import('~icons/mdi/eye-off')['default'] IconMdiHeart: typeof import('~icons/mdi/heart')['default'] + IconMdiPause: typeof import('~icons/mdi/pause')['default'] + IconMdiPlay: typeof import('~icons/mdi/play')['default'] + IconMdiRecord: typeof import('~icons/mdi/record')['default'] + IconMdiRefresh: typeof import('~icons/mdi/refresh')['default'] IconMdiSearch: typeof import('~icons/mdi/search')['default'] IconMdiTranslate: typeof import('~icons/mdi/translate')['default'] IconMdiTriangleDown: typeof import('~icons/mdi/triangle-down')['default'] + IconMdiVideo: typeof import('~icons/mdi/video')['default'] InputCopyable: typeof import('./src/components/InputCopyable.vue')['default'] IntegerBaseConverter: typeof import('./src/tools/integer-base-converter/integer-base-converter.vue')['default'] Ipv4AddressConverter: typeof import('./src/tools/ipv4-address-converter/ipv4-address-converter.vue')['default'] @@ -129,20 +140,43 @@ declare module '@vue/runtime-core' { MenuLayout: typeof import('./src/components/MenuLayout.vue')['default'] MetaTagGenerator: typeof import('./src/tools/meta-tag-generator/meta-tag-generator.vue')['default'] MimeTypes: typeof import('./src/tools/mime-types/mime-types.vue')['default'] + NAlert: typeof import('naive-ui')['NAlert'] + NationalIdGenerator: typeof import('./src/tools/national-id-generator/national-id-generator.vue')['default'] NavbarButtons: typeof import('./src/components/NavbarButtons.vue')['default'] + NButton: typeof import('naive-ui')['NButton'] NCheckbox: typeof import('naive-ui')['NCheckbox'] + NCode: typeof import('naive-ui')['NCode'] NCollapseTransition: typeof import('naive-ui')['NCollapseTransition'] + NColorPicker: typeof import('naive-ui')['NColorPicker'] NConfigProvider: typeof import('naive-ui')['NConfigProvider'] + NDatePicker: typeof import('naive-ui')['NDatePicker'] NDivider: typeof import('naive-ui')['NDivider'] + NDynamicInput: typeof import('naive-ui')['NDynamicInput'] NEllipsis: typeof import('naive-ui')['NEllipsis'] + NForm: typeof import('naive-ui')['NForm'] + NFormItem: typeof import('naive-ui')['NFormItem'] + NGi: typeof import('naive-ui')['NGi'] + NGrid: typeof import('naive-ui')['NGrid'] NH1: typeof import('naive-ui')['NH1'] + NH2: typeof import('naive-ui')['NH2'] NH3: typeof import('naive-ui')['NH3'] NIcon: typeof import('naive-ui')['NIcon'] + NImage: typeof import('naive-ui')['NImage'] + NInputGroup: typeof import('naive-ui')['NInputGroup'] + NInputGroupLabel: typeof import('naive-ui')['NInputGroupLabel'] + NInputNumber: typeof import('naive-ui')['NInputNumber'] NLayout: typeof import('naive-ui')['NLayout'] NLayoutSider: typeof import('naive-ui')['NLayoutSider'] NMenu: typeof import('naive-ui')['NMenu'] + NProgress: typeof import('naive-ui')['NProgress'] + NScrollbar: typeof import('naive-ui')['NScrollbar'] + NSlider: typeof import('naive-ui')['NSlider'] NSpace: typeof import('naive-ui')['NSpace'] + NSpin: typeof import('naive-ui')['NSpin'] + NStatistic: typeof import('naive-ui')['NStatistic'] + NSwitch: typeof import('naive-ui')['NSwitch'] NTable: typeof import('naive-ui')['NTable'] + NTag: typeof import('naive-ui')['NTag'] NumeronymGenerator: typeof import('./src/tools/numeronym-generator/numeronym-generator.vue')['default'] OtpCodeGeneratorAndValidator: typeof import('./src/tools/otp-code-generator-and-validator/otp-code-generator-and-validator.vue')['default'] PasswordStrengthAnalyser: typeof import('./src/tools/password-strength-analyser/password-strength-analyser.vue')['default'] diff --git a/locales/en.yml b/locales/en.yml index d03d80d3f6..6c41d3b243 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -392,3 +392,7 @@ tools: text-to-binary: title: Text to ASCII binary description: Convert text to its ASCII binary representation and vice-versa. + + national-id-generator: + title: National ID generator + description: Generate a national identification number for a specific country. \ No newline at end of file diff --git a/plan-idNumberGenerator-v2.prompt.md b/plan-idNumberGenerator-v2.prompt.md new file mode 100644 index 0000000000..42ac738856 --- /dev/null +++ b/plan-idNumberGenerator-v2.prompt.md @@ -0,0 +1,59 @@ +## Plan: Belgian SSIN Generator — Updates + +**What**: Apply the changes in the section below. + +--- + +**Changes** + +- UI: The 'date of birth' field is indented slightly more than the other fields, making it shorter. This is probably because of the label being too long. Keep the label and make all input fiels equally wide, aligned to the left edge of the longest label. This is a more consistent and visually balanced layout. +- UI: The 'date of birth' and 'gender' fields both have a default "random" option, but this is shown in different ways: the date picker is has 'default' text greyed out, while the gender select has an explicit "— random —" option. Make these consistent by showing a 'random' greyed-out option in the gender select, and removing the "— random —" option. This makes it clearer that both fields have a random default, and reduces visual clutter in the gender select. +- UI: After generating a number, the result card is shown to the right of the form, which looks a bit disconnected. Move the result card below the form, centered, to create a more cohesive and balanced layout. This also allows more space for the result card, which can be wider and easier to read. Make the result card always visible, but show placeholder text (e.g. "Your generated SSIN will appear here") before the first generation, to indicate where the output will be. This improves the user experience by providing a clear and consistent layout, and guiding the user on where to look for the results. +- UI: In the result card, the copy buttons are shown as buttons next to the result fields. Integrate them as icons inside the input fields, aligned to the right, to save space and create a cleaner look. This also makes it more intuitive that clicking the icon will copy the field value. Use a standard copy icon (e.g. clipboard) for this purpose. + +--- + +**Steps** + +### Phase 1 — Consistent field alignment (`id-number-generator.vue`) +1. Remove the built-in `label` prop from all `c-select` usages (Country, Gender) — place labels manually outside the component instead, matching the existing manual rows. +2. Wrap the whole options form in a CSS grid: `display: grid; grid-template-columns: 140px 1fr` — each row contributes one label cell and one input cell, ensuring all inputs start at the same horizontal position regardless of which component renders them. +3. The `n-date-picker` and `n-switch` rows already use manual labels; adjust their label width to match the grid column (140 px). + +### Phase 2 — Consistent "random" placeholder for gender (`id-number-generator.vue`) +4. Remove `{ value: '', label: '— Random —' }` from `genderOptions`; the array should only contain `Male` and `Female`. +5. Change `selectedGender` initial value from `''` to `null` (type `string | null`). +6. Add `placeholder="Random"` to the gender `c-select` — the component renders placeholder text greyed out when no option is selected, matching the date picker's greyed-out `"Random"` placeholder text. +7. Update the `generate()` function: treat `selectedGender.value === null` the same as the existing empty-string guard (random fallback in the service). + +### Phase 3 — Result card below, always visible (`id-number-generator.vue`) +8. Wrap both `` elements in a single `
` container. The tool layout assigns `flex: 0 1 600px` to each *direct* child of `.tool-content`; wrapping in one `div` forces both cards into a single column of that width. +9. Remove `v-if="result"` from the result card so it is always rendered. +10. Inside the result card, show a centred, muted placeholder paragraph (e.g. `"Your generated SSIN will appear here"`) when `result` is `null`, and hide it once a result exists (`v-if="!result"`). +11. Keep the result rows under `v-if="result"` so they only appear after the first generation. + +### Phase 4 — Copy icons inside input fields (`id-number-generator.vue`) +12. Replace each `c-input-text` + standalone `c-button "Copy"` pair with a single `c-input-text` that uses its `#suffix` slot: + ```html + + ``` + (Same pattern for the raw SSIN field, using a separate `isJustCopiedRaw`.) +13. Destructure `isJustCopied` from each `useCopy` call and rename to `isJustCopiedFormatted` / `isJustCopiedRaw` for the tooltip text. +14. Reference pattern: `src/components/InputCopyable.vue` — uses `#suffix` slot with `c-tooltip`, `c-button circle variant="text" size="small"`, and `icon-mdi-content-copy`. + +--- + +**Relevant files** +- `src/tools/id-number-generator/id-number-generator.vue` — all changes are here + +**Decisions** +- No service or test changes required — purely a UI/layout update. +- Grid column width set to 140 px to comfortably fit "Date of birth" (the longest label). +- `c-tooltip` used for copy icon to provide "Copied!" feedback without a toast, matching `InputCopyable.vue`. + diff --git a/plan-idNumberGenerator.prompt.md b/plan-idNumberGenerator.prompt.md new file mode 100644 index 0000000000..63c0f2004d --- /dev/null +++ b/plan-idNumberGenerator.prompt.md @@ -0,0 +1,67 @@ +## Plan: Belgian SSIN Generator Tool + +**What**: Complete `id-number-generator` to generate valid Belgian SSINs (nationaal registernummer / numéro national), with optional user inputs and "Generate" button, displaying both formatted and raw output. + +--- + +### Belgian SSIN format +`YY.MM.DD-SSS.CC` — 11 digits total: +- `YYMMDD` — date of birth +- `SSS` — serial: **odd** (001–997) for males, **even** (002–998) for females +- `CC` — checksum: `97 − (9-digit-number mod 97)`, left-padded to 2 digits; for births ≥ 2000, prepend `"2"` to the 9 digits before the mod + +--- + +**Steps** + +### Phase 1 — Service layer (`id-number-generator.service.ts`) +1. Export `generateBelgianSSIN(opts: { birthDate?: Date; gender?: 'male' | 'female'; fictitious?: boolean })` returning `{ raw, formatted, gender, birthDate }` + - Random date fallback (1900–current year range) + - Random gender fallback + - `fictitious: false` (default) → serial in low range (001–499 odd for male, 002–498 even for female) + - `fictitious: true` → serial in high range (501–997 odd for male, 500–998 even for female); these numbers are never assigned to real persons by the Belgian registry + - Checksum: `97 − (YYMMDDSS % 97)`, prepend `"2"` for year ≥ 2000 +2. Export `formatBelgianSSIN(ssin: string): string` → `YY.MM.DD-SSS.CC` + +### Phase 2 — Unit tests (`id-number-generator.service.test.ts`) +3. Validate checksum against ≥ 3 known valid Belgian SSINs (pre-2000 and post-2000) +4. Assert male → odd SSS, female → even SSS +5. Test `formatBelgianSSIN` output shape + +### Phase 3 — Vue component (`id-number-generator.vue`) +6. Keep existing country `c-select` +7. Add optional `n-date-picker` for date of birth (blank = random) +8. Add optional gender `c-select` (Male / Female / — random —) +9. Add `c-checkbox` or toggle "Fictitious (non-existing) number" — when checked, passes `fictitious: true` to the service (uses high serial range) +10. `c-button` “Generate” triggers `generateBelgianSSIN` +11. Result card (hidden until first generate) shows: + - Formatted SSIN (`XX.XX.XX-XXX.XX`) + copy button + - Unformatted raw 11-digit SSIN + copy button + - Uses `useCopy` composable from `@/composable/copy` + +### Phase 4 — Tool registration (`index.ts`) +12. Set `description: 'Generate a valid Belgian national identification number (NISS/INSZ)'` + +### Phase 5 — E2E (`id-number-generator.e2e.spec.ts`) +13. Add a test: clicking Generate produces visible SSIN output +14. Add a test: enabling "Fictitious" and clicking Generate produces a valid SSIN with serial ≥ 500 + +--- + +**Relevant files** +- `src/tools/id-number-generator/id-number-generator.vue` — full rewrite +- `src/tools/id-number-generator/id-number-generator.service.ts` — new implementation +- `src/tools/id-number-generator/id-number-generator.service.test.ts` — unit tests +- `src/tools/id-number-generator/id-number-generator.e2e.spec.ts` — E2E update +- `src/tools/id-number-generator/index.ts` — description update + +**Reference patterns** +- `src/tools/bcrypt/bcrypt.vue` — `c-card`, `c-input-text` (readonly), `c-button`, `useCopy` +- `useCopy({ source: ref })` from `@/composable/copy` + +**Decisions** +- All inputs optional; missing values are randomised +- Generation triggered by button click only +- Country dropdown kept for future country additions +- Default serial range (low) avoids realistic collisions with real numbers +- Fictitious mode uses the high serial range (500–999), which the Belgian registry reserves and never assigns to real persons diff --git a/src/tools/index.ts b/src/tools/index.ts index 388cfaf494..f119d4aa74 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -1,6 +1,7 @@ import { tool as base64FileConverter } from './base64-file-converter'; import { tool as base64StringConverter } from './base64-string-converter'; import { tool as basicAuthGenerator } from './basic-auth-generator'; +import { tool as nationalIdGenerator } from './national-id-generator'; import { tool as emailNormalizer } from './email-normalizer'; import { tool as asciiTextDrawer } from './ascii-text-drawer'; @@ -188,7 +189,11 @@ export const toolsByCategory: ToolCategory[] = [ }, { name: 'Data', - components: [phoneParserAndFormatter, ibanValidatorAndParser], + components: [ + phoneParserAndFormatter, + ibanValidatorAndParser, + nationalIdGenerator, + ], }, ]; diff --git a/src/tools/national-id-generator/index.ts b/src/tools/national-id-generator/index.ts new file mode 100644 index 0000000000..b655705c4d --- /dev/null +++ b/src/tools/national-id-generator/index.ts @@ -0,0 +1,12 @@ +import { Id } from '@vicons/tabler'; +import { defineTool } from '../tool'; + +export const tool = defineTool({ + name: 'National ID generator', + path: '/national-id-generator', + description: 'Generate a valid national identification number', + keywords: ['national', 'id', 'number', 'generator'], + component: () => import('./national-id-generator.vue'), + icon: Id, + createdAt: new Date('2026-05-19'), +}); diff --git a/src/tools/national-id-generator/national-id-generator.e2e.spec.ts b/src/tools/national-id-generator/national-id-generator.e2e.spec.ts new file mode 100644 index 0000000000..4ef792ffec --- /dev/null +++ b/src/tools/national-id-generator/national-id-generator.e2e.spec.ts @@ -0,0 +1,33 @@ +import { expect, test } from '@playwright/test'; + +test.describe('Tool - National ID generator', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/national-id-generator'); + }); + + test('Has correct title', async ({ page }) => { + await expect(page).toHaveTitle('NationalID generator - IT Tools'); + }); + + test('Clicking Generate produces a visible formatted SSIN', async ({ page }) => { + await page.getByRole('button', { name: 'Generate' }).click(); + // Formatted SSIN matches pattern XX.XX.XX-XXX.XX + const formattedInput = page.locator('input[readonly]').first(); + await expect(formattedInput).toHaveValue(/^\d{2}\.\d{2}\.\d{2}-\d{3}\.\d{2}$/); + }); + + test('Enabling Fictitious and clicking Generate produces a SSIN with serial >= 900', async ({ page }) => { + // Enable fictitious toggle + await page.locator('.n-switch').click(); + // Select female so serial >= 900 + await page.getByText('— Random —').click(); + await page.getByText('Female').click(); + + await page.getByRole('button', { name: 'Generate' }).click(); + + const rawInput = page.locator('input[readonly]').nth(1); + const raw = await rawInput.inputValue(); + const serial = Number(raw.slice(6, 9)); + expect(serial).toBeGreaterThanOrEqual(900); + }); +}); diff --git a/src/tools/national-id-generator/national-id-generator.service.test.ts b/src/tools/national-id-generator/national-id-generator.service.test.ts new file mode 100644 index 0000000000..2c1933df7c --- /dev/null +++ b/src/tools/national-id-generator/national-id-generator.service.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it } from 'vitest'; +import { formatBelgianSSIN, generateBelgianSSIN } from './national-id-generator.service'; + +describe('formatBelgianSSIN', () => { + it('formats an 11-digit string as YY.MM.DD-SSS.CC', () => { + expect(formatBelgianSSIN('85073100145')).toBe('85.07.31-001.45'); + }); +}); + +describe('generateBelgianSSIN — checksum validation', () => { + // Known valid Belgian SSINs (pre-2000 and post-2000) + // Formula: checksum = 97 − (YYMMDDSS mod 97) [pre-2000] + // checksum = 97 − (2YYMMDDSS mod 97) [post-2000] + + it('validates a known pre-2000 SSIN: 85.07.31-001.30', () => { + // 850731001 mod 97 = 67 → checksum = 30 + const mod = BigInt('850731001') % 97n; + expect(97 - Number(mod)).toBe(30); + expect(formatBelgianSSIN('85073100130')).toBe('85.07.31-001.30'); + }); + + it('validates a second known pre-2000 SSIN: 75.03.15-002.77', () => { + // 750315002 mod 97 = 20 → checksum = 77 + const mod = BigInt('750315002') % 97n; + expect(97 - Number(mod)).toBe(77); + }); + + it('validates a known post-2000 SSIN (2-prefix): 01.09.20-003.XX', () => { + // 2010920003 mod 97 → checksum + const result = generateBelgianSSIN({ + birthYear: 2001, + birthMonth: 9, + birthDay: 20, + gender: 'male', + }); + // Verify the generated checksum is correct + const serial = result.raw.slice(6, 9); + const computedMod = BigInt(`2${result.raw.slice(0, 6)}${serial}`) % 97n; + const computedChecksum = 97 - Number(computedMod); + expect(Number(result.raw.slice(9, 11))).toBe(computedChecksum); + }); +}); + +describe('generateBelgianSSIN — gender / serial parity', () => { + it('male generates odd serial', () => { + for (let i = 0; i < 20; i++) { + const { raw } = generateBelgianSSIN({ gender: 'male' }); + const serial = Number(raw.slice(6, 9)); + expect(serial % 2).toBe(1); + } + }); + + it('female generates even serial', () => { + for (let i = 0; i < 20; i++) { + const { raw } = generateBelgianSSIN({ gender: 'female' }); + const serial = Number(raw.slice(6, 9)); + expect(serial % 2).toBe(0); + } + }); +}); + +describe('generateBelgianSSIN — fictitious flag', () => { + it('non-fictitious male serial is in low range (1–899 odd)', () => { + for (let i = 0; i < 20; i++) { + const { raw } = generateBelgianSSIN({ gender: 'male', fictitious: false }); + const serial = Number(raw.slice(6, 9)); + expect(serial).toBeGreaterThanOrEqual(1); + expect(serial).toBeLessThanOrEqual(899); + expect(serial % 2).toBe(1); + } + }); + + it('fictitious male serial is in high range (901–999 odd)', () => { + for (let i = 0; i < 20; i++) { + const { raw } = generateBelgianSSIN({ gender: 'male', fictitious: true }); + const serial = Number(raw.slice(6, 9)); + expect(serial).toBeGreaterThanOrEqual(901); + expect(serial).toBeLessThanOrEqual(999); + expect(serial % 2).toBe(1); + } + }); + + it('fictitious female serial is in high range (900–998 even)', () => { + for (let i = 0; i < 20; i++) { + const { raw } = generateBelgianSSIN({ gender: 'female', fictitious: true }); + const serial = Number(raw.slice(6, 9)); + expect(serial).toBeGreaterThanOrEqual(900); + expect(serial).toBeLessThanOrEqual(998); + expect(serial % 2).toBe(0); + } + }); +}); + +describe('generateBelgianSSIN — checksum integrity', () => { + it('generated SSIN always has a valid checksum (pre-2000)', () => { + for (let i = 0; i < 30; i++) { + const result = generateBelgianSSIN({ birthYear: 1985, birthMonth: 1, birthDay: 1 }); + const nineDigits = result.raw.slice(0, 9); + const cc = Number(result.raw.slice(9, 11)); + const mod = BigInt(nineDigits) % 97n; + expect(97 - Number(mod)).toBe(cc); + } + }); + + it('generated SSIN always has a valid checksum (post-2000)', () => { + for (let i = 0; i < 30; i++) { + const result = generateBelgianSSIN({ birthYear: 2003, birthMonth: 6, birthDay: 15 }); + const nineDigits = `2${result.raw.slice(0, 9)}`; + const cc = Number(result.raw.slice(9, 11)); + const mod = BigInt(nineDigits) % 97n; + expect(97 - Number(mod)).toBe(cc); + } + }); +}); + +describe('generateBelgianSSIN — date field options', () => { + it('encodes a fully-specified pre-2000 birth date in the raw output', () => { + const { raw, birthDate } = generateBelgianSSIN({ birthYear: 1992, birthMonth: 3, birthDay: 14 }); + expect(raw.slice(0, 2)).toBe('92'); + expect(raw.slice(2, 4)).toBe('03'); + expect(raw.slice(4, 6)).toBe('14'); + expect(birthDate.getFullYear()).toBe(1992); + expect(birthDate.getMonth()).toBe(2); // 0-indexed + expect(birthDate.getDate()).toBe(14); + }); + + it('encodes a fully-specified post-2000 birth date in the raw output', () => { + const { raw, birthDate } = generateBelgianSSIN({ birthYear: 2010, birthMonth: 11, birthDay: 5 }); + expect(raw.slice(0, 2)).toBe('10'); + expect(raw.slice(2, 4)).toBe('11'); + expect(raw.slice(4, 6)).toBe('05'); + expect(birthDate.getFullYear()).toBe(2010); + expect(birthDate.getMonth()).toBe(10); // 0-indexed + expect(birthDate.getDate()).toBe(5); + }); + + it('respects birthYear when provided alone, randomises month and day', () => { + for (let i = 0; i < 10; i++) { + const { raw } = generateBelgianSSIN({ birthYear: 1978 }); + expect(raw.slice(0, 2)).toBe('78'); + expect(Number(raw.slice(2, 4))).toBeGreaterThanOrEqual(1); + expect(Number(raw.slice(2, 4))).toBeLessThanOrEqual(12); + expect(Number(raw.slice(4, 6))).toBeGreaterThanOrEqual(1); + } + }); + + it('respects birthMonth when provided alone', () => { + for (let i = 0; i < 10; i++) { + const { raw } = generateBelgianSSIN({ birthMonth: 7 }); + expect(raw.slice(2, 4)).toBe('07'); + } + }); + + it('respects birthDay when provided alone', () => { + for (let i = 0; i < 10; i++) { + const { raw } = generateBelgianSSIN({ birthDay: 15 }); + expect(raw.slice(4, 6)).toBe('15'); + } + }); + + it('respects birthYear and birthMonth together, randomises day within the month', () => { + for (let i = 0; i < 10; i++) { + const { raw } = generateBelgianSSIN({ birthYear: 1965, birthMonth: 4 }); + expect(raw.slice(0, 2)).toBe('65'); + expect(raw.slice(2, 4)).toBe('04'); + const day = Number(raw.slice(4, 6)); + expect(day).toBeGreaterThanOrEqual(1); + expect(day).toBeLessThanOrEqual(30); // April has 30 days + } + }); + + it('respects birthMonth and birthDay together, randomises year', () => { + for (let i = 0; i < 10; i++) { + const { raw } = generateBelgianSSIN({ birthMonth: 6, birthDay: 20 }); + expect(raw.slice(2, 4)).toBe('06'); + expect(raw.slice(4, 6)).toBe('20'); + } + }); + + it('picks a leap year when Feb 29 is requested without a year', () => { + for (let i = 0; i < 10; i++) { + const { raw, birthDate } = generateBelgianSSIN({ birthMonth: 2, birthDay: 29 }); + expect(raw.slice(2, 4)).toBe('02'); + expect(raw.slice(4, 6)).toBe('29'); + const year = birthDate.getFullYear(); + const isLeap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0; + expect(isLeap).toBe(true); + } + }); + + it('encodes Feb 29 with an explicit leap year correctly', () => { + const { raw, birthDate } = generateBelgianSSIN({ birthYear: 2000, birthMonth: 2, birthDay: 29 }); + expect(raw.slice(0, 2)).toBe('00'); + expect(raw.slice(2, 4)).toBe('02'); + expect(raw.slice(4, 6)).toBe('29'); + expect(birthDate.getFullYear()).toBe(2000); + expect(birthDate.getMonth()).toBe(1); // 0-indexed + expect(birthDate.getDate()).toBe(29); + }); + + it('raw output is always exactly 11 digits', () => { + for (let i = 0; i < 20; i++) { + const { raw } = generateBelgianSSIN(); + expect(raw).toMatch(/^\d{11}$/); + } + }); +}); + +describe('generateBelgianSSIN — fictitious flag (non-fictitious female)', () => { + it('non-fictitious female serial is in low range (2–898 even)', () => { + for (let i = 0; i < 20; i++) { + const { raw } = generateBelgianSSIN({ gender: 'female', fictitious: false }); + const serial = Number(raw.slice(6, 9)); + expect(serial).toBeGreaterThanOrEqual(2); + expect(serial).toBeLessThanOrEqual(898); + expect(serial % 2).toBe(0); + } + }); +}); diff --git a/src/tools/national-id-generator/national-id-generator.service.ts b/src/tools/national-id-generator/national-id-generator.service.ts new file mode 100644 index 0000000000..1d7197448a --- /dev/null +++ b/src/tools/national-id-generator/national-id-generator.service.ts @@ -0,0 +1,8 @@ +/** + * Re-exports from the Belgian strategy for backwards compatibility. + * Tests and any other consumers that import from this file continue to work + * without changes. New code should import from `./strategies/be` directly + * or use the strategy registry in `./strategies/index`. + */ +export type { Gender, GenerateBelgianSSINOptions, BelgianSSIN } from './strategies/be'; +export { generateBelgianSSIN, formatBelgianSSIN } from './strategies/be'; diff --git a/src/tools/national-id-generator/national-id-generator.types.ts b/src/tools/national-id-generator/national-id-generator.types.ts new file mode 100644 index 0000000000..1c30a4f2cc --- /dev/null +++ b/src/tools/national-id-generator/national-id-generator.types.ts @@ -0,0 +1,44 @@ +export type Gender = 'male' | 'female'; + +/** + * Common options passed to every strategy's generate() method. + * Each strategy only reads the fields it understands and ignores the rest. + */ +export interface GenerateOptions { + birthYear?: number + /** 1–12 */ + birthMonth?: number + /** 1–31 */ + birthDay?: number + gender?: Gender + fictitious?: boolean +} + +/** Minimal output every strategy must produce. */ +export interface GeneratedId { + raw: string + formatted: string +} + +/** + * Strategy interface for country-specific national ID number generators. + * + * To add support for a new country: + * 1. Create `strategies/.ts` and export a const implementing this interface. + * 2. Register it in `strategies/index.ts`. + * The Vue component automatically picks up the new country and conditionally + * renders only the fields the strategy declares it supports. + */ +export interface IdNumberStrategy { + /** ISO 3166-1 alpha-2 country code, used as the registry key. */ + readonly countryCode: string + /** Human-readable label shown in the country selector. */ + readonly label: string + /** True when the country's ID encodes a date of birth. */ + readonly supportsBirthDate: boolean + /** True when the country's ID encodes gender. */ + readonly supportsGender: boolean + /** True when a "fictitious / non-assigned" serial range is available. */ + readonly supportsFictitious: boolean + generate(opts?: GenerateOptions): GeneratedId +} diff --git a/src/tools/national-id-generator/national-id-generator.vue b/src/tools/national-id-generator/national-id-generator.vue new file mode 100644 index 0000000000..5e3d878e1a --- /dev/null +++ b/src/tools/national-id-generator/national-id-generator.vue @@ -0,0 +1,237 @@ + + + diff --git a/src/tools/national-id-generator/strategies/be.ts b/src/tools/national-id-generator/strategies/be.ts new file mode 100644 index 0000000000..316aea1e7d --- /dev/null +++ b/src/tools/national-id-generator/strategies/be.ts @@ -0,0 +1,111 @@ +import type { Gender, GenerateOptions, GeneratedId, IdNumberStrategy } from '../national-id-generator.types'; + +// ─── types re-exported for backwards compatibility ──────────────────────────── + +export type { Gender }; + +export interface GenerateBelgianSSINOptions { + birthYear?: number + /** 1–12 */ + birthMonth?: number + /** 1–31 */ + birthDay?: number + gender?: Gender + fictitious?: boolean +} + +export interface BelgianSSIN extends GeneratedId { + gender: Gender + birthDate: Date +} + +// ─── internal helpers ───────────────────────────────────────────────────────── + +function randomInt(min: number, max: number): number { + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +function daysInMonth(year: number, month: number): number { + // month is 1-indexed (1–12); new Date(year, month, 0) gives the last day of that month + return new Date(year, month, 0).getDate(); +} + +function isLeapYear(year: number): boolean { + return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0; +} + +function randomLeapYear(min: number, max: number): number { + const leapYears: number[] = []; + for (let y = min; y <= max; y++) { + if (isLeapYear(y)) { + leapYears.push(y); + } + } + return leapYears[Math.floor(Math.random() * leapYears.length)]!; +} + +function computeChecksum(yymmdd: string, serial: string, bornAfter2000: boolean): string { + const nineDigits = bornAfter2000 ? `2${yymmdd}${serial}` : `${yymmdd}${serial}`; + const mod = BigInt(nineDigits) % 97n; + const checksum = 97 - Number(mod); + return checksum.toString().padStart(2, '0'); +} + +// ─── public API ─────────────────────────────────────────────────────────────── + +export function generateBelgianSSIN(opts: GenerateBelgianSSINOptions = {}): BelgianSSIN { + const gender: Gender = opts.gender ?? (Math.random() < 0.5 ? 'male' : 'female'); + const fictitious = opts.fictitious ?? false; + + const currentYear = new Date().getFullYear(); + const needsLeapYear = opts.birthDay === 29 && opts.birthMonth === 2 && opts.birthYear === undefined; + const fullYear = opts.birthYear ?? (needsLeapYear ? randomLeapYear(1900, currentYear) : randomInt(1900, currentYear)); + const month = opts.birthMonth ?? randomInt(1, 12); + const day = opts.birthDay ?? randomInt(1, daysInMonth(fullYear, month)); + const birthDate = new Date(fullYear, month - 1, day); + + const yy = fullYear.toString().slice(-2).padStart(2, '0'); + const mm = month.toString().padStart(2, '0'); + const dd = day.toString().padStart(2, '0'); + const yymmdd = `${yy}${mm}${dd}`; + const bornAfter2000 = fullYear >= 2000; + + let serial: string; + if (gender === 'male') { + const minOdd = fictitious ? 901 : 1; + const maxOdd = fictitious ? 999 : 899; + const oddNum = randomInt(minOdd, maxOdd); + const oddValue = oddNum % 2 === 0 ? oddNum + 1 : oddNum; + serial = oddValue.toString().padStart(3, '0'); + } + else { + const minEven = fictitious ? 900 : 2; + const maxEven = fictitious ? 998 : 898; + const evenNum = randomInt(minEven / 2, maxEven / 2); + serial = (evenNum * 2).toString().padStart(3, '0'); + } + + const checksum = computeChecksum(yymmdd, serial, bornAfter2000); + const raw = `${yymmdd}${serial}${checksum}`; + + return { raw, formatted: formatBelgianSSIN(raw), gender, birthDate }; +} + +export function formatBelgianSSIN(ssin: string): string { + return `${ssin.slice(0, 2)}.${ssin.slice(2, 4)}.${ssin.slice(4, 6)}-${ssin.slice(6, 9)}.${ssin.slice(9, 11)}`; +} + +// ─── strategy object ────────────────────────────────────────────────────────── + +export const belgianStrategy: IdNumberStrategy = { + countryCode: 'be', + label: 'Belgium', + supportsBirthDate: true, + supportsGender: true, + supportsFictitious: true, + + generate(opts?: GenerateOptions): GeneratedId { + const { raw, formatted } = generateBelgianSSIN(opts); + return { raw, formatted }; + }, +}; diff --git a/src/tools/national-id-generator/strategies/index.ts b/src/tools/national-id-generator/strategies/index.ts new file mode 100644 index 0000000000..ad348989b7 --- /dev/null +++ b/src/tools/national-id-generator/strategies/index.ts @@ -0,0 +1,12 @@ +import type { IdNumberStrategy } from '../national-id-generator.types'; +import { belgianStrategy } from './be'; + +/** + * Registry of all supported country strategies. + * To add a new country: import its strategy and add it here. + * The Vue component derives the country dropdown and conditional UI + * fields automatically from this map. + */ +export const strategies: Readonly> = { + be: belgianStrategy, +}; diff --git a/src/ui/c-select/c-select.vue b/src/ui/c-select/c-select.vue index 7b3607c93e..3d14da6f0b 100644 --- a/src/ui/c-select/c-select.vue +++ b/src/ui/c-select/c-select.vue @@ -13,6 +13,7 @@ const props = withDefaults( placeholder?: string size?: 'small' | 'medium' | 'large' searchable?: boolean + clearable?: boolean } & CLabelProps >(), { options: () => [], @@ -20,6 +21,7 @@ const props = withDefaults( placeholder: undefined, size: 'medium', searchable: false, + clearable: false, }, ); @@ -135,6 +137,13 @@ function handleKeydown(event: KeyboardEvent) { function onSearchInput() { focusIndex.value = 0; } + +function clearValue(event: MouseEvent) { + event.stopPropagation(); + selectedOption.value = undefined; + // @ts-expect-error vue template generic is a bit flacky thanks to withDefaults + value.value = undefined; +}