Skip to content

Commit

Permalink
Support for interim numbers. (#61)
Browse files Browse the repository at this point in the history
  • Loading branch information
Johannestegner authored Jan 21, 2024
1 parent 5850721 commit 7dace66
Show file tree
Hide file tree
Showing 6 changed files with 204 additions and 5 deletions.
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| ---------|:-------|----------------------------:|
Expand All @@ -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

Expand Down
44 changes: 42 additions & 2 deletions src/Personnummer.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ final class Personnummer implements PersonnummerInterface

private array $options;

private bool $isInterim;

/**
* @inheritDoc
*/
Expand Down Expand Up @@ -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
*/
Expand All @@ -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)) {
Expand Down Expand Up @@ -139,6 +149,7 @@ private static function getParts(string $ssn): array

$parts['fullYear'] = $parts['century'] . $parts['year'];

$parts['original'] = $ssn;
return $parts;
}

Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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;
Expand All @@ -257,6 +296,7 @@ private function parseOptions(array $options): array
{
$defaultOptions = [
'allowCoordinationNumber' => true,
'allowInterimNumber' => false,
];

if ($unknownKeys = array_diff_key($options, $defaultOptions)) {
Expand Down
7 changes: 7 additions & 0 deletions src/PersonnummerInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []);

/**
Expand Down
115 changes: 115 additions & 0 deletions tests/InterimNumberTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
<?php

namespace Personnummer\Tests;

use Jchook\AssertThrows\AssertThrows;
use Personnummer\Personnummer;
use Personnummer\PersonnummerException;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

class InterimNumberTest extends TestCase
{
use AssertThrows;

private static ?array $interim = null;

private array $options = [
'allowInterimNumber' => 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,
])
);
}
}
26 changes: 26 additions & 0 deletions tests/PersonnummerData.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

namespace Personnummer\Tests;

class PersonnummerData
{
public function __construct(array $p)
{
$this->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;
}
9 changes: 9 additions & 0 deletions tests/PersonnummerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
}
}
}

0 comments on commit 7dace66

Please sign in to comment.