diff --git a/README.md b/README.md index b66d1c4..2fe8714 100644 --- a/README.md +++ b/README.md @@ -17,12 +17,13 @@ composer require personnummer/personnummer #### Instance | Method | Arguments | Returns | -| ---------------------|:----------------|--------:| +|----------------------|:----------------|--------:| | format | bool longFormat | string | | getAge | none | int | | isMale | none | bool | | isFemale | none | bool | | isCoordinationNumber | none | bool | +| isInterimNumber | none | bool | | Property | Type | Description | | ---------|:-------|----------------------------:| @@ -39,9 +40,10 @@ composer require personnummer/personnummer When a personnummer is invalid a PersonnummerException is thrown. ## Options -| Option | Type | Default | Description | -| ------------------------|:-----|:--------|:---------------------------:| +| Option | Type | Default | Description | +|-------------------------|:-----|:--------|:---------------------------:| | allowCoordinationNumber | bool | true | Accept coordination numbers | +| allowInterimNumber | bool | false | Accept interim/T numbers | ## Examples diff --git a/src/Personnummer.php b/src/Personnummer.php index 7d3d7e8..015001b 100644 --- a/src/Personnummer.php +++ b/src/Personnummer.php @@ -25,6 +25,8 @@ final class Personnummer implements PersonnummerInterface private array $options; + private bool $isInterim; + /** * @inheritDoc */ @@ -88,6 +90,14 @@ public function isCoordinationNumber(): bool return checkdate((int)$parts['month'], $parts['day'] - 60, $parts['fullYear']); } + /** + * @inheritDoc + */ + public function isInterimNumber(): bool + { + return $this->isInterim; + } + /** * @inheritDoc */ @@ -111,7 +121,7 @@ public static function valid(string $ssn, array $options = []): bool private static function getParts(string $ssn): array { // phpcs:ignore - $reg = '/^(?\'century\'\d{2}){0,1}(?\'year\'\d{2})(?\'month\'\d{2})(?\'day\'\d{2})(?\'sep\'[\+\-]?)(?\'num\'(?!000)\d{3})(?\'check\'\d)$/'; + $reg = '/^(?\'century\'\d{2}){0,1}(?\'year\'\d{2})(?\'month\'\d{2})(?\'day\'\d{2})(?\'sep\'[\+\-]?)(?\'num\'(?!000)\d{3}|[TRSUWXJKLMN]\d{2})(?\'check\'\d)$/'; preg_match($reg, $ssn, $match); if (empty($match)) { @@ -139,6 +149,7 @@ private static function getParts(string $ssn): array $parts['fullYear'] = $parts['century'] . $parts['year']; + $parts['original'] = $ssn; return $parts; } @@ -181,6 +192,18 @@ public function __construct(string $ssn, array $options = []) $this->options = $this->parseOptions($options); $this->parts = self::getParts($ssn); + // Sanity checks. + $ssn = trim($ssn); + $len = strlen($ssn); + if ($len > 13 || $len < 10) { + throw new PersonnummerException( + sprintf( + 'Input string too %s', + $len < 10 ? 'short' : 'long' + ) + ); + } + if (!$this->isValid()) { throw new PersonnummerException(); } @@ -241,13 +264,29 @@ private function isValid(): bool { $parts = $this->parts; + // Correct interim if allowed. + $interimTest = '/(?![-+])\D/'; + $this->isInterim = preg_match($interimTest, $parts['original']) !== 0; + + if ($this->options['allowInterimNumber'] === false && $this->isInterim) { + throw new PersonnummerException(sprintf( + '%s contains non-integer characters and options are set to not allow interim numbers', + $parts['original'] + )); + } + + $num = $parts['num']; + if ($this->options['allowInterimNumber'] && $this->isInterim) { + $num = preg_replace($interimTest, '1', $num); + } + if ($this->options['allowCoordinationNumber'] && $this->isCoordinationNumber()) { $validDate = true; } else { $validDate = checkdate($parts['month'], $parts['day'], $parts['century'] . $parts['year']); } - $checkStr = $parts['year'] . $parts['month'] . $parts['day'] . $parts['num']; + $checkStr = $parts['year'] . $parts['month'] . $parts['day'] . $num; $validCheck = self::luhn($checkStr) === (int)$parts['check']; return $validDate && $validCheck; @@ -257,6 +296,7 @@ private function parseOptions(array $options): array { $defaultOptions = [ 'allowCoordinationNumber' => true, + 'allowInterimNumber' => false, ]; if ($unknownKeys = array_diff_key($options, $defaultOptions)) { diff --git a/src/PersonnummerInterface.php b/src/PersonnummerInterface.php index c41a3d6..cc87151 100644 --- a/src/PersonnummerInterface.php +++ b/src/PersonnummerInterface.php @@ -72,6 +72,13 @@ public function isMale(): bool; */ public function isCoordinationNumber(): bool; + /** + * Check if the Swedish social security number is an interim number. + * + * @return bool + */ + public function isInterimNumber(): bool; + public function __construct(string $ssn, array $options = []); /** diff --git a/tests/InterimNumberTest.php b/tests/InterimNumberTest.php new file mode 100644 index 0000000..b1489f5 --- /dev/null +++ b/tests/InterimNumberTest.php @@ -0,0 +1,115 @@ + true, + ]; + + private static function init(): void + { + if (self::$interim === null) { + $data = json_decode(file_get_contents('https://raw.githubusercontent.com/personnummer/meta/master/testdata/interim.json'), true, 512, JSON_THROW_ON_ERROR); // phpcs:ignore + self::$interim = array_map( + static fn(array $p) => new PersonnummerData($p), + $data, + ); + } + } + + public static function validProvider(): array + { + self::init(); + return array_map( + static fn(PersonnummerData $p) => ['num' => $p], + array_filter(self::$interim, static fn($p) => $p->valid) + ); + } + public static function invalidProvider(): array + { + self::init(); + return array_map( + static fn(PersonnummerData $p) => ['num' => $p], + array_filter(self::$interim, static fn($p) => !$p->valid) + ); + } + + #[DataProvider('validProvider')] + public function testValidateInterim(PersonnummerData $num): void + { + self::assertTrue(Personnummer::valid($num->longFormat, $this->options)); + self::assertTrue(Personnummer::valid($num->separatedFormat, $this->options)); + } + + #[DataProvider('invalidProvider')] + public function testValidateInvalidInterim(PersonnummerData $num): void + { + self::assertFalse(Personnummer::valid($num->longFormat, $this->options)); + self::assertFalse(Personnummer::valid($num->separatedFormat, $this->options)); + } + + #[DataProvider('validProvider')] + public function testIsInterim(PersonnummerData $num): void + { + self::assertTrue(Personnummer::parse($num->longFormat, $this->options)->isInterimNumber()); + self::assertTrue(Personnummer::parse($num->separatedFormat, $this->options)->isInterimNumber()); + } + + #[DataProvider('validProvider')] + public function testFormatLongInterim(PersonnummerData $num): void + { + $p = Personnummer::parse($num->longFormat, $this->options); + self::assertEquals($p->format(true), $num->longFormat); + self::assertEquals($p->format(false), $num->separatedFormat); + } + + #[DataProvider('validProvider')] + public function testFormatShortInterim(PersonnummerData $num): void + { + $p = Personnummer::parse($num->separatedFormat, $this->options); + self::assertEquals($p->format(true), $num->longFormat); + self::assertEquals($p->format(false), $num->separatedFormat); + } + + #[DataProvider('invalidProvider')] + public function testInvalidInterimThrows(PersonnummerData $num): void + { + $this->assertThrows( + PersonnummerException::class, + fn () => Personnummer::parse($num->longFormat, $this->options) + ); + $this->assertThrows( + PersonnummerException::class, + fn () => Personnummer::parse($num->separatedFormat, $this->options) + ); + } + + #[DataProvider('validProvider')] + public function testInterimThrowsIfNotActive(PersonnummerData $num): void + { + $this->assertThrows( + PersonnummerException::class, + fn () => Personnummer::parse($num->longFormat, [ + 'allowInterimNumber' => false, + ]) + ); + $this->assertThrows( + PersonnummerException::class, + fn () => Personnummer::parse($num->separatedFormat, [ + 'allowInterimNumber' => false, + ]) + ); + } +} diff --git a/tests/PersonnummerData.php b/tests/PersonnummerData.php new file mode 100644 index 0000000..5f3f79e --- /dev/null +++ b/tests/PersonnummerData.php @@ -0,0 +1,26 @@ +longFormat = $p['long_format']; + $this->shortFormat = $p['short_format']; + $this->separatedFormat = $p['separated_format']; + $this->separatedLong = $p['separated_long']; + $this->valid = $p['valid']; + $this->type = $p['type']; + $this->isMale = $p['isMale']; + $this->isFemale = $p['isFemale']; + } + public string $longFormat; + public string $shortFormat; + public string $separatedFormat; + public string $separatedLong; + public bool $valid; + public string $type; + public bool $isMale; + public bool $isFemale; +} diff --git a/tests/PersonnummerTest.php b/tests/PersonnummerTest.php index 29fcba3..a9824e8 100644 --- a/tests/PersonnummerTest.php +++ b/tests/PersonnummerTest.php @@ -227,4 +227,13 @@ public function testMissingProperties(): void }, E_USER_NOTICE); $this->assertFalse(isset(Personnummer::parse('121212-1212')->missingProperty)); } + + public function testIsNotInterim(): void + { + foreach (self::$testdataList as $testdata) { + if ($testdata['valid']) { + $this->assertFalse(Personnummer::parse($testdata['separated_format'])->isInterimNumber()); + } + } + } }