Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ This package contains the reusable local processing logic behind parts of [grati
- Belgian enterprise and VAT number validation
- Belgian IBAN, bank code and BIC helpers
- OGM and RF payment reference helpers
- Belgian national number parsing
- Belgian national number parsing and synthetic RN/BIS/INSZ test-number generation
- Peppol participant ID parsing
- CODA parsing and anonymisation
- CAMT parsing
Expand Down Expand Up @@ -119,11 +119,12 @@ npm test
## Example

```js
import { parseEnterprise, parseIban, parseOgm } from './src/index.js';
import { parseEnterprise, parseIban, parseOgm, makeInsz } from './src/index.js';

console.log(parseEnterprise('BE 0753.124.628'));
console.log(parseIban('BE73 7350 1234 5660'));
console.log(parseOgm('+++202/6000/12320+++'));
console.log(makeInsz({ type: 'national', birthDate: '2099-01-01', sex: 'female', sequence: 2 }));
```

## Scope
Expand Down
62 changes: 62 additions & 0 deletions src/be/ids.js
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,68 @@ export function parseNationalNumber(input) {
return out;
}


function parseSyntheticBirthDate(value) {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(value || ''));
if (!m) throw new Error('Use a birth date as YYYY-MM-DD.');
const year = +m[1], month = +m[2], day = +m[3];
if (year < 1900 || year > 2099) throw new Error('Synthetic INSZ generation supports years 1900-2099.');
const d = new Date(Date.UTC(year, month - 1, day));
if (d.getUTCFullYear() !== year || d.getUTCMonth() !== month - 1 || d.getUTCDate() !== day) throw new Error('Birth date is not a valid calendar date.');
return { year, month, day };
}

function syntheticSequence(sequence, odd) {
const n = Number(sequence);
if (!Number.isInteger(n) || n < 1 || n > 998) throw new Error('Sequence must be an integer from 001 to 998.');
if ((n % 2 === 1) !== odd) throw new Error(odd ? 'Sequence must be odd.' : 'Sequence must be even.');
return String(n).padStart(3, '0');
}

function inszFromBase(base, year) {
const source = year >= 2000 ? `2${base}` : base;
const check = String(97 - mod97(source)).padStart(2, '0');
return base + check;
}

/**
* Create a structurally valid synthetic Belgian national register number.
* This does not reserve the number and cannot prove that it is unassigned.
*/
export function makeNationalNumber({ birthDate, sex = 'male', sequence } = {}) {
const { year, month, day } = parseSyntheticBirthDate(birthDate);
if (!['male', 'female'].includes(sex)) throw new Error('Sex must be "male" or "female".');
const odd = sex === 'male';
const seq = syntheticSequence(sequence ?? (odd ? 1 : 2), odd);
const base = `${String(year).slice(-2)}${String(month).padStart(2, '0')}${String(day).padStart(2, '0')}${seq}`;
const digits = inszFromBase(base, year);
return { ...parseNationalNumber(digits), digits, synthetic: true, kind: 'national' };
}

/**
* Create a structurally valid synthetic BIS number.
* For BIS numbers the month is offset by 40 when sex was known at creation,
* or by 20 when it was unknown. Unknown-sex BIS sequence numbers are odd.
*/
export function makeBisNumber({ birthDate, sex = 'male', sexKnown = true, sequence } = {}) {
const { year, month, day } = parseSyntheticBirthDate(birthDate);
if (!['male', 'female'].includes(sex)) throw new Error('Sex must be "male" or "female".');
const odd = sexKnown ? sex === 'male' : true;
const seq = syntheticSequence(sequence ?? (odd ? 1 : 2), odd);
const encodedMonth = month + (sexKnown ? 40 : 20);
const base = `${String(year).slice(-2)}${String(encodedMonth).padStart(2, '0')}${String(day).padStart(2, '0')}${seq}`;
const digits = inszFromBase(base, year);
return { ...parseNationalNumber(digits), digits, synthetic: true, kind: 'bis', sexKnown };
}

/** Convenience wrapper for synthetic INSZ test data. */
export function makeInsz({ type = 'national', ...options } = {}) {
if (type === 'national') return makeNationalNumber(options);
if (type === 'bis') return makeBisNumber({ ...options, sexKnown: options.sexKnown !== false });
if (type === 'bis-unknown') return makeBisNumber({ ...options, sexKnown: false });
throw new Error('Type must be "national", "bis" or "bis-unknown".');
}

// ---------- Peppol participant IDs ----------

export const PEPPOL_SCHEMES = {
Expand Down
19 changes: 18 additions & 1 deletion test/core.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';

import {
parseEnterprise, parseIban, parseOgm, parsePeppolId,
parseEnterprise, parseIban, parseOgm, parsePeppolId, parseNationalNumber, makeNationalNumber, makeBisNumber, makeInsz,
parseCoda, parseCamt, readUbl, validateInvoice,
parseIntervat, parseXbrl, epcPayload,
wgs84ToLambert72, lambert72ToWgs84,
Expand All @@ -23,6 +23,23 @@ test('Belgian identifiers', () => {
assert.equal(parsePeppolId('0208:0753124628').valid, true);
});


test('synthetic Belgian INSZ generators', () => {
const rrn = makeNationalNumber({ birthDate: '1985-07-30', sex: 'male', sequence: 33 });
assert.equal(rrn.formatted, '85.07.30-033.28');
assert.equal(parseNationalNumber(rrn.digits).valid, true);
const modern = makeNationalNumber({ birthDate: '2005-01-01', sex: 'female', sequence: 124 });
assert.equal(modern.birthDate, '2005-01-01');
assert.equal(modern.sex, 'female');
const bis = makeBisNumber({ birthDate: '1990-03-15', sex: 'female', sexKnown: true, sequence: 2 });
assert.equal(bis.valid, true);
assert.equal(bis.type, 'BIS number (sex known)');
const unknown = makeInsz({ type: 'bis-unknown', birthDate: '2099-01-01', sequence: 1 });
assert.equal(unknown.valid, true);
assert.equal(unknown.type, 'BIS number (sex unknown at registration)');
assert.throws(() => makeNationalNumber({ birthDate: '2005-01-01', sex: 'female', sequence: 123 }), /even/);
});

test('CODA and CAMT', () => {
const [coda] = parseCoda(codaSample);
assert.ok(coda.transactions.length > 0);
Expand Down
Loading