Archetypy Programowania

Party Identifier Pattern (Wzorzec identyfikatora podmiotu)

Based on "Enterprise Patterns and MDA" by Jim Arlow & Ila Neustadt

Problem

Party posiadają wiele identyfikatorów z różnych kontekstów:

  • Person ma: numer paszportu, numer PESEL, prawo jazdy, numer ubezpieczenia społecznego
  • Organization ma: NIP, numer KRS, numer DUNS, numer VAT UE
  • Twój system przydziela: numer klienta, identyfikator pracownika, numer konta

Te identyfikatory:

  • Pochodzą od różnych organów wydających (rząd, twój system, organizacje międzynarodowe)
  • Mają różne okresy ważności (paszport wygasa, numer klienta jest stały)
  • Muszą być wyszukiwalne („znajdź Customer po PESEL")
  • Mogą mieć status weryfikacji (potwierdzony vs niezweryfikowany)

Koncepcja

Party Identifier oddziela zarządzanie tożsamością od samego Party. Każdy identyfikator jest osobną encją z własnym typem, wartością, organem wydającym i cyklem życia.

Pozwala to Party mieć dowolną liczbę identyfikatorów, dodawanych lub usuwanych w czasie, bez zmiany struktury Party.

Struktura

1

0..*

0..*

1

Party

+id: UUID

+name: String

+identifiers: PartyIdentifier[]

+getIdentifier(type) : : PartyIdentifier

PartyIdentifier

+id: UUID

+party: Party

+identifierType: IdentifierType

+value: String

+issuingAuthority: String

+validFrom: Date

+validTo: Date

+verified: Boolean

+isActive() : : Boolean

IdentifierType

+code: String

+name: String

+pattern: String

+appliesToPerson: Boolean

+appliesToOrganization: Boolean

+validate(value) : : Boolean

Typy identyfikatorów

Identyfikatory Person

Kod Nazwa Przykład formatu Wydawca
PESEL Polski nr PESEL 11 cyfr Rząd RP
PASSPORT Numer paszportu Zależny od kraju Rząd
SSN Numer ubezpieczenia społecznego XXX-XX-XXXX Rząd USA
DRIVING Prawo jazdy Różne Urząd wydający
TAX_ID Osobisty nr podatkowy Różne Urząd skarbowy

Identyfikatory Organization

Kod Nazwa Przykład formatu Wydawca
NIP Polski NIP 10 cyfr Urząd skarbowy
KRS Polski KRS 10 cyfr Sąd rejestrowy
REGON Polski REGON 9 lub 14 cyfr GUS
VAT_EU Numer VAT UE Kod kraju + numer Państwo członkowskie UE
DUNS Numer D-U-N-S 9 cyfr Dun & Bradstreet
LEI Legal Entity Identifier 20 znaków GLEIF

Identyfikatory systemowe

Kod Nazwa Generowany przez
CUSTOMER_NO Numer klienta Twój system
EMPLOYEE_ID Identyfikator pracownika System HR
ACCOUNT_NO Numer konta System finansowy
MEMBER_ID Identyfikator członka System członkostwa

Atrybuty

PartyIdentifier

Atrybut Typ Wymagane Opis
id UUID Tak Unikalny identyfikator
party Party Tak Właściciel tego identyfikatora
identifierType IdentifierType Tak Typ (PESEL, NIP, itd.)
value String Tak Wartość identyfikatora
issuingAuthority String Nie Kto go wydał
issuedDate Date Nie Kiedy został wydany
validFrom Date Nie Od kiedy jest ważny
validTo Date Nie Data wygaśnięcia
verified Boolean Tak Czy został zweryfikowany?
verifiedAt Timestamp Nie Kiedy nastąpiła weryfikacja
verifiedBy String Nie Kto/co zweryfikowało

IdentifierType

Atrybut Typ Wymagane Opis
code String Tak Unikalny kod (PESEL, NIP)
name String Tak Nazwa czytelna dla człowieka
description String Nie Wyjaśnienie
pattern String Nie Wzorzec walidacji (regex)
appliesToPerson Boolean Tak Czy dotyczy Person?
appliesToOrganization Boolean Tak Czy dotyczy Organization?
requiresVerification Boolean Tak Czy wymaga weryfikacji przed użyciem?

Zachowania

Entity PartyIdentifier:
    
    function isActive(): Boolean
        today = currentDate()
        fromOk = validFrom is None or validFrom <= today
        toOk = validTo is None or validTo >= today
        return fromOk and toOk
    
    function isExpired(): Boolean
        return validTo is not None and validTo < currentDate()
    
    function isExpiringSoon(days: Integer): Boolean
        if validTo is None:
            return false
        return validTo <= currentDate() + days
    
    function verify(verifier: String):
        require not verified  // Don't re-verify
        require identifierType.validate(value)  // Format is valid
        
        verified = true
        verifiedAt = now()
        verifiedBy = verifier
        
        emit IdentifierVerified(this, verifier)
    
    function revoke(reason: String):
        validTo = currentDate()
        revokedReason = reason
        
        emit IdentifierRevoked(this, reason)


Entity IdentifierType:
    
    function validate(value: String): Boolean
        if pattern is None:
            return true
        return value matches pattern
    
    function canApplyTo(party: Party): Boolean
        if party is Person:
            return appliesToPerson
        if party is Organization:
            return appliesToOrganization
        return false


// Query helpers on Party
Entity Party:
    
    function addIdentifier(type: IdentifierType, value: String): PartyIdentifier
        require type.canApplyTo(this)
        require type.validate(value)
        require not hasActiveIdentifier(type)  // No duplicates
        
        identifier = new PartyIdentifier(
            party: this,
            identifierType: type,
            value: value,
            verified: false
        )
        identifiers.add(identifier)
        return identifier
    
    function getIdentifier(type: IdentifierType): PartyIdentifier or None
        return identifiers
            .filter(i => i.identifierType == type and i.isActive())
            .first()
    
    function hasActiveIdentifier(type: IdentifierType): Boolean
        return getIdentifier(type) is not None
    
    function getVerifiedIdentifiers(): PartyIdentifier[]
        return identifiers.filter(i => i.verified and i.isActive())
    
    function getExpiringIdentifiers(days: Integer): PartyIdentifier[]
        return identifiers.filter(i => i.isExpiringSoon(days))


// Lookup function (repository/service level)
function findPartyByIdentifier(type: IdentifierType, value: String): Party or None
    identifier = findOne PartyIdentifier 
        where identifierType == type 
        and value == value 
        and isActive()
    
    if identifier is None:
        return None
    return identifier.party

Przykłady

Dodawanie identyfikatorów do Person

jan = findParty("Jan Kowalski")
peselType = findIdentifierType("PESEL")
passportType = findIdentifierType("PASSPORT")

// Add PESEL
pesel = jan.addIdentifier(peselType, "85031512345")
pesel.issuingAuthority = "Urzad Stanu Cywilnego Warszawa"
pesel.verified = false

// Verify it
pesel.verify("System:PESEL-API")
// emits IdentifierVerified(pesel, "System:PESEL-API")

// Add passport with expiry
passport = jan.addIdentifier(passportType, "EA1234567")
passport.issuingAuthority = "Wojewoda Mazowiecki"
passport.validFrom = 2020-06-15
passport.validTo = 2030-06-15

passport.isExpiringSoon(365)  // false (expires 2030)

jan.getVerifiedIdentifiers()  // [pesel]

Wyszukiwanie Party po identyfikatorze

// Find party by NIP (tax ID)
nipType = findIdentifierType("NIP")
acme = findPartyByIdentifier(nipType, "5261234567")
// Returns: Acme Sp. z o.o.

// Find expiring documents
expiringIds = jan.getExpiringIdentifiers(90)
// Returns identifiers expiring within 90 days

Reguły walidacji

PESEL (polski numer ewidencyjny)

function validatePESEL(value: String): Boolean
    if length(value) != 11:
        return false
    if not isAllDigits(value):
        return false
    
    // Checksum validation
    weights = [1, 3, 7, 9, 1, 3, 7, 9, 1, 3]
    sum = 0
    for i in 0..9:
        sum += digit(value, i) * weights[i]
    
    checkDigit = (10 - (sum mod 10)) mod 10
    return checkDigit == digit(value, 10)

NIP (polski numer identyfikacji podatkowej)

function validateNIP(value: String): Boolean
    // Remove dashes
    normalized = value.replace("-", "")
    
    if length(normalized) != 10:
        return false
    if not isAllDigits(normalized):
        return false
    
    weights = [6, 5, 7, 2, 3, 4, 5, 6, 7]
    sum = 0
    for i in 0..8:
        sum += digit(normalized, i) * weights[i]
    
    checkDigit = sum mod 11
    return checkDigit == digit(normalized, 9)

REGON (polski numer statystyczny — 9-cyfrowy)

function validateREGON9(value: String): Boolean
    if length(value) != 9:
        return false
    if not isAllDigits(value):
        return false
    
    weights = [8, 9, 2, 3, 4, 5, 6, 7]
    sum = 0
    for i in 0..7:
        sum += digit(value, i) * weights[i]
    
    checkDigit = sum mod 11
    if checkDigit == 10:
        checkDigit = 0
    return checkDigit == digit(value, 8)

REGON (14-cyfrowy — dla jednostek lokalnych)

function validateREGON14(value: String): Boolean
    if length(value) != 14:
        return false
    if not isAllDigits(value):
        return false
    
    // First 9 digits must be valid REGON-9
    if not validateREGON9(value.substring(0, 9)):
        return false
    
    weights = [2, 4, 8, 5, 0, 9, 7, 3, 6, 1, 2, 4, 8]
    sum = 0
    for i in 0..12:
        sum += digit(value, i) * weights[i]
    
    checkDigit = sum mod 11
    if checkDigit == 10:
        checkDigit = 0
    return checkDigit == digit(value, 13)

KRS (Krajowy Rejestr Sądowy — 10 cyfr)

function validateKRS(value: String): Boolean
    if length(value) != 10:
        return false
    if not isAllDigits(value):
        return false
    // KRS has no checksum — just format validation
    return true

Numer VAT UE

function validateVATEU(value: String): Boolean
    if length(value) < 4:
        return false
    
    countryCode = value.substring(0, 2)
    number = value.substring(2)
    
    // Country code must be valid EU member
    validCountries = ["AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE",
                      "FI", "FR", "DE", "GR", "HU", "IE", "IT", "LV",
                      "LT", "LU", "MT", "NL", "PL", "PT", "RO", "SK",
                      "SI", "ES", "SE"]
    if countryCode not in validCountries:
        return false
    
    // Country-specific length and format
    match countryCode:
        case "PL":
            return length(number) == 10 and isAllDigits(number)
        case "DE":
            return length(number) == 9 and isAllDigits(number)
        case "FR":
            return length(number) == 11
        // ... other countries have their own rules
        default:
            return isAlphanumeric(number) and length(number) >= 2

Algorytm Luhna (karty kredytowe, IMEI, itd.)

function validateLuhn(value: String): Boolean
    if not isAllDigits(value):
        return false
    
    sum = 0
    doubleNext = false
    
    // Process from right to left
    for i in (length(value) - 1) downTo 0:
        d = digit(value, i)
        
        if doubleNext:
            d = d * 2
            if d > 9:
                d = d - 9
        
        sum += d
        doubleNext = not doubleNext
    
    return sum mod 10 == 0

Niezmienniki

  1. Zgodność z typem Party — PESEL tylko dla Person, NIP tylko dla Organization
  2. Unikalny aktywny identyfikator — Brak dwóch aktywnych identyfikatorów tego samego typu dla tego samego Party
  3. Walidacja formatu — Wartość musi pasować do wzorca typu
  4. Weryfikacja jeśli wymagana — Jeśli typ wymaga weryfikacji, musi być zweryfikowany przed użyciem
  5. Prawidłowy zakres dat — validFrom <= validTo

Obsługa błędów

Operacja Naruszony warunek wstępny Błąd
addIdentifier(type, value) Typ nie dotyczy tego Party PreconditionError: PESEL cannot be assigned to Organization
addIdentifier(type, value) Wartość nie przechodzi walidacji formatu PreconditionError: value does not match pattern for NIP
addIdentifier(type, value) Istnieje aktywny identyfikator tego samego typu PreconditionError: party already has active PESEL
verify(verifier) Już zweryfikowany PreconditionError: identifier already verified
verify(verifier) Wartość nie przechodzi walidacji formatu PreconditionError: cannot verify — invalid format
findPartyByIdentifier(type, value) Nie znaleziono Zwraca None (bez wyjątku)
getIdentifier(type) Nie znaleziono Zwraca None (bez wyjątku)

Schemat SQL

sql-- Identifier types (reference data)
CREATE TABLE identifier_type (
    code                    VARCHAR(30) PRIMARY KEY,
    name                    VARCHAR(100) NOT NULL,
    description             TEXT,
    pattern                 VARCHAR(255),  -- Regex pattern
    applies_to_person       BOOLEAN NOT NULL DEFAULT FALSE,
    applies_to_organization BOOLEAN NOT NULL DEFAULT FALSE,
    requires_verification   BOOLEAN NOT NULL DEFAULT FALSE
);

-- Party identifiers
CREATE TABLE party_identifier (
    id                  UUID PRIMARY KEY,
    party_id            UUID NOT NULL REFERENCES party(id) ON DELETE CASCADE,
    identifier_type     VARCHAR(30) NOT NULL REFERENCES identifier_type(code),
    value               VARCHAR(100) NOT NULL,
    issuing_authority   VARCHAR(100),
    issued_date         DATE,
    valid_from          DATE,
    valid_to            DATE,
    verified            BOOLEAN NOT NULL DEFAULT FALSE,
    verified_at         TIMESTAMP,
    verified_by         VARCHAR(100),
    created_at          TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    
    CONSTRAINT chk_valid_dates CHECK (valid_to IS NULL OR valid_from IS NULL OR valid_to >= valid_from)
);

-- Unique constraint: one active identifier per type per party
CREATE UNIQUE INDEX idx_party_identifier_unique 
    ON party_identifier(party_id, identifier_type)
    WHERE valid_to IS NULL OR valid_to >= CURRENT_DATE;

-- Fast lookup by identifier value
CREATE INDEX idx_party_identifier_lookup 
    ON party_identifier(identifier_type, value);

-- Find parties with expiring identifiers
CREATE INDEX idx_party_identifier_expiry 
    ON party_identifier(valid_to) 
    WHERE valid_to IS NOT NULL;

-- Insert standard identifier types
INSERT INTO identifier_type (code, name, applies_to_person, applies_to_organization, pattern) VALUES
    ('PESEL', 'Polish National ID', TRUE, FALSE, '^\d{11}$'),
    ('NIP', 'Polish Tax ID', FALSE, TRUE, '^\d{10}$'),
    ('KRS', 'Polish Company Registry', FALSE, TRUE, '^\d{10}$'),
    ('PASSPORT', 'Passport Number', TRUE, FALSE, NULL),
    ('VAT_EU', 'EU VAT Number', FALSE, TRUE, '^[A-Z]{2}[A-Z0-9]+$'),
    ('CUSTOMER_NO', 'Customer Number', TRUE, TRUE, '^CUST-\d{8}$'),
    ('EMPLOYEE_ID', 'Employee ID', TRUE, FALSE, '^EMP-\d{6}$');

Typowe zapytania

sql-- Find party by identifier
SELECT p.*
FROM party p
JOIN party_identifier pi ON pi.party_id = p.id
WHERE pi.identifier_type = 'PESEL'
  AND pi.value = '12345678901'
  AND (pi.valid_to IS NULL OR pi.valid_to >= CURRENT_DATE);

-- Get all identifiers for a party
SELECT pi.*, it.name as type_name
FROM party_identifier pi
JOIN identifier_type it ON it.code = pi.identifier_type
WHERE pi.party_id = :partyId
ORDER BY pi.identifier_type;

-- Find identifiers expiring in next 30 days
SELECT pi.*, p.name as party_name, it.name as type_name
FROM party_identifier pi
JOIN party p ON p.id = pi.party_id
JOIN identifier_type it ON it.code = pi.identifier_type
WHERE pi.valid_to BETWEEN CURRENT_DATE AND CURRENT_DATE + INTERVAL '30 days'
ORDER BY pi.valid_to;

-- Find unverified identifiers requiring verification
SELECT pi.*, p.name as party_name
FROM party_identifier pi
JOIN party p ON p.id = pi.party_id
JOIN identifier_type it ON it.code = pi.identifier_type
WHERE it.requires_verification = TRUE
  AND pi.verified = FALSE
  AND (pi.valid_to IS NULL OR pi.valid_to >= CURRENT_DATE);

Zagadnienia projektowe

Szyfrowanie

Wrażliwe identyfikatory (SSN, PESEL) powinny być szyfrowane w spoczynku:

sql-- Option 1: Application-level encryption
value_encrypted BYTEA NOT NULL,

-- Option 2: Database-level (PostgreSQL)
value VARCHAR(100) NOT NULL,  -- Use pgcrypto extension

-- Keep searchable hash for lookup
value_hash VARCHAR(64) NOT NULL,  -- SHA-256 hash for searching

Integracja weryfikacji

Weryfikacja może być:

  • Ręczna — Employee potwierdza dokument
  • Automatyczna — Wywołanie API do rejestru rządowego
  • Zewnętrzna — Usługa weryfikacji tożsamości (Onfido, itd.)

Wartości historyczne

Gdy identyfikator się zmienia (nowy numer paszportu), można:

  1. Zastąpić — Zaktualizować wartość, utracić historię
  2. Wygasić i dodać nowy — Ustawić validTo na starym, dodać nowy z validFrom
  3. Wersjonować — Dodać numer wersji do śledzenia zmian

Opcja 2 jest zalecana ze względu na ścieżki audytu.

  • Party — Encja będąca identyfikowana
  • Party Role — Identyfikatory specyficzne dla roli (identyfikator Employee należy do roli Employee)
  • Dokument — Fizyczny dokument potwierdzający tożsamość