diff --git a/README.md b/README.md
index 13650de1119ed248fa68b3a98607e4dc99ed2dc8..02d784ad7a734ad17dc8638a8859a243eb425971 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,17 @@
 # simplesamlphp-module-accounting
-SimpleSAMLphp module providing user accounting functionality
+SimpleSAMLphp module providing user accounting functionality.
+
+**Module is in development and is not production ready.**
 
 ## TODO
-- [ ] MySQL store
-- [ ] MySQL Job Store
-- [ ] Cron hooks
\ No newline at end of file
+- [x] Data stores
+- [ ] Cron hooks
+- [ ] Profile page UI
+- [ ] Translation
+
+## Tests
+To run phpcs, psalm and phpunit:
+
+```shell
+composer pre-commit
+```
diff --git a/composer.json b/composer.json
index d88308a618cf7b54713f84fa21da33ef86d4d2e5..aa085a84415da5fe260fc9f4bb6ef18f4b22b8dc 100644
--- a/composer.json
+++ b/composer.json
@@ -27,7 +27,6 @@
     "require": {
         "php": "^7.4 || ^8.0",
         "ext-pdo": "*",
-        "ext-pdo_mysql": "*",
         "ext-pdo_sqlite": "*",
         "doctrine/dbal": "^3",
         "psr/log": "^1|^2|^3",
diff --git a/composer.lock b/composer.lock
index 464fd3d9ead5c9eb45c4d675936d88954e6960b4..1ae8db2b89c6a786d3f691f9491dfcc2963f47d3 100644
--- a/composer.lock
+++ b/composer.lock
@@ -157,16 +157,16 @@
         },
         {
             "name": "composer/composer",
-            "version": "2.4.0",
+            "version": "2.4.2",
             "source": {
                 "type": "git",
                 "url": "https://github.com/composer/composer.git",
-                "reference": "026d6de6ea2c913974a7756661a3faac135cb36e"
+                "reference": "7d887621e69a0311eb50aed4a16f7044b2b385b9"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/composer/composer/zipball/026d6de6ea2c913974a7756661a3faac135cb36e",
-                "reference": "026d6de6ea2c913974a7756661a3faac135cb36e",
+                "url": "https://api.github.com/repos/composer/composer/zipball/7d887621e69a0311eb50aed4a16f7044b2b385b9",
+                "reference": "7d887621e69a0311eb50aed4a16f7044b2b385b9",
                 "shasum": ""
             },
             "require": {
@@ -196,7 +196,7 @@
                 "phpstan/phpstan-deprecation-rules": "^1",
                 "phpstan/phpstan-phpunit": "^1.0",
                 "phpstan/phpstan-strict-rules": "^1",
-                "phpstan/phpstan-symfony": "^1.1",
+                "phpstan/phpstan-symfony": "^1.2.10",
                 "symfony/phpunit-bridge": "^6.0"
             },
             "suggest": {
@@ -249,7 +249,7 @@
             "support": {
                 "irc": "ircs://irc.libera.chat:6697/composer",
                 "issues": "https://github.com/composer/composer/issues",
-                "source": "https://github.com/composer/composer/tree/2.4.0"
+                "source": "https://github.com/composer/composer/tree/2.4.2"
             },
             "funding": [
                 {
@@ -265,7 +265,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2022-08-16T14:10:48+00:00"
+            "time": "2022-09-14T14:11:15+00:00"
         },
         {
             "name": "composer/metadata-minifier",
@@ -729,16 +729,16 @@
         },
         {
             "name": "doctrine/dbal",
-            "version": "3.4.1",
+            "version": "3.4.5",
             "source": {
                 "type": "git",
                 "url": "https://github.com/doctrine/dbal.git",
-                "reference": "94e016428884227245fb1219e0de7d8b86ca16d7"
+                "reference": "a5a58773109c0abb13e658c8ccd92aeec8d07f9e"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/doctrine/dbal/zipball/94e016428884227245fb1219e0de7d8b86ca16d7",
-                "reference": "94e016428884227245fb1219e0de7d8b86ca16d7",
+                "url": "https://api.github.com/repos/doctrine/dbal/zipball/a5a58773109c0abb13e658c8ccd92aeec8d07f9e",
+                "reference": "a5a58773109c0abb13e658c8ccd92aeec8d07f9e",
                 "shasum": ""
             },
             "require": {
@@ -751,16 +751,16 @@
                 "psr/log": "^1|^2|^3"
             },
             "require-dev": {
-                "doctrine/coding-standard": "9.0.0",
-                "jetbrains/phpstorm-stubs": "2022.1",
-                "phpstan/phpstan": "1.8.2",
+                "doctrine/coding-standard": "10.0.0",
+                "jetbrains/phpstorm-stubs": "2022.2",
+                "phpstan/phpstan": "1.8.3",
                 "phpstan/phpstan-strict-rules": "^1.3",
-                "phpunit/phpunit": "9.5.21",
+                "phpunit/phpunit": "9.5.24",
                 "psalm/plugin-phpunit": "0.17.0",
                 "squizlabs/php_codesniffer": "3.7.1",
                 "symfony/cache": "^5.4|^6.0",
                 "symfony/console": "^4.4|^5.4|^6.0",
-                "vimeo/psalm": "4.24.0"
+                "vimeo/psalm": "4.27.0"
             },
             "suggest": {
                 "symfony/console": "For helpful console commands such as SQL execution and import of files."
@@ -820,7 +820,7 @@
             ],
             "support": {
                 "issues": "https://github.com/doctrine/dbal/issues",
-                "source": "https://github.com/doctrine/dbal/tree/3.4.1"
+                "source": "https://github.com/doctrine/dbal/tree/3.4.5"
             },
             "funding": [
                 {
@@ -836,7 +836,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2022-08-16T18:37:46+00:00"
+            "time": "2022-09-23T17:48:57+00:00"
         },
         {
             "name": "doctrine/deprecations",
@@ -1266,16 +1266,16 @@
         },
         {
             "name": "phpmailer/phpmailer",
-            "version": "v6.6.3",
+            "version": "v6.6.4",
             "source": {
                 "type": "git",
                 "url": "https://github.com/PHPMailer/PHPMailer.git",
-                "reference": "9400f305a898f194caff5521f64e5dfa926626f3"
+                "reference": "a94fdebaea6bd17f51be0c2373ab80d3d681269b"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/9400f305a898f194caff5521f64e5dfa926626f3",
-                "reference": "9400f305a898f194caff5521f64e5dfa926626f3",
+                "url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/a94fdebaea6bd17f51be0c2373ab80d3d681269b",
+                "reference": "a94fdebaea6bd17f51be0c2373ab80d3d681269b",
                 "shasum": ""
             },
             "require": {
@@ -1332,7 +1332,7 @@
             "description": "PHPMailer is a full-featured email creation and transfer class for PHP",
             "support": {
                 "issues": "https://github.com/PHPMailer/PHPMailer/issues",
-                "source": "https://github.com/PHPMailer/PHPMailer/tree/v6.6.3"
+                "source": "https://github.com/PHPMailer/PHPMailer/tree/v6.6.4"
             },
             "funding": [
                 {
@@ -1340,7 +1340,7 @@
                     "type": "github"
                 }
             ],
-            "time": "2022-06-20T09:21:02+00:00"
+            "time": "2022-08-22T09:22:00+00:00"
         },
         {
             "name": "psr/cache",
@@ -1723,16 +1723,16 @@
         },
         {
             "name": "seld/phar-utils",
-            "version": "1.2.0",
+            "version": "1.2.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/Seldaek/phar-utils.git",
-                "reference": "9f3452c93ff423469c0d56450431562ca423dcee"
+                "reference": "ea2f4014f163c1be4c601b9b7bd6af81ba8d701c"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/Seldaek/phar-utils/zipball/9f3452c93ff423469c0d56450431562ca423dcee",
-                "reference": "9f3452c93ff423469c0d56450431562ca423dcee",
+                "url": "https://api.github.com/repos/Seldaek/phar-utils/zipball/ea2f4014f163c1be4c601b9b7bd6af81ba8d701c",
+                "reference": "ea2f4014f163c1be4c601b9b7bd6af81ba8d701c",
                 "shasum": ""
             },
             "require": {
@@ -1765,9 +1765,9 @@
             ],
             "support": {
                 "issues": "https://github.com/Seldaek/phar-utils/issues",
-                "source": "https://github.com/Seldaek/phar-utils/tree/1.2.0"
+                "source": "https://github.com/Seldaek/phar-utils/tree/1.2.1"
             },
-            "time": "2021-12-10T11:20:11+00:00"
+            "time": "2022-08-31T10:31:18+00:00"
         },
         {
             "name": "seld/signal-handler",
@@ -1832,16 +1832,16 @@
         },
         {
             "name": "simplesamlphp/assert",
-            "version": "v0.3.3",
+            "version": "v0.8.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/simplesamlphp/assert.git",
-                "reference": "9af4fe36e7b2a9c4d0695a8e8ad118c39b9e3564"
+                "reference": "d3b0f38f4ae083822471c15e3c4a0401ddaeac73"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/simplesamlphp/assert/zipball/9af4fe36e7b2a9c4d0695a8e8ad118c39b9e3564",
-                "reference": "9af4fe36e7b2a9c4d0695a8e8ad118c39b9e3564",
+                "url": "https://api.github.com/repos/simplesamlphp/assert/zipball/d3b0f38f4ae083822471c15e3c4a0401ddaeac73",
+                "reference": "d3b0f38f4ae083822471c15e3c4a0401ddaeac73",
                 "shasum": ""
             },
             "require": {
@@ -1855,7 +1855,7 @@
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "v0.2.x-dev"
+                    "dev-master": "v0.8.x-dev"
                 }
             },
             "autoload": {
@@ -1880,35 +1880,36 @@
             "description": "A wrapper around webmozart/assert to make it useful beyond checking method arguments",
             "support": {
                 "issues": "https://github.com/simplesamlphp/assert/issues",
-                "source": "https://github.com/simplesamlphp/assert/tree/v0.3.3"
+                "source": "https://github.com/simplesamlphp/assert/tree/v0.8.0"
             },
-            "time": "2022-08-02T21:14:39+00:00"
+            "time": "2022-09-20T20:18:55+00:00"
         },
         {
             "name": "simplesamlphp/composer-module-installer",
-            "version": "v1.1.8",
+            "version": "v1.2.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/simplesamlphp/composer-module-installer.git",
-                "reference": "45161b5406f3e9c82459d0f9a5a1dba064953cfa"
+                "reference": "27b4fe96198ffaff3ab49c87b40f4cb24de77b01"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/simplesamlphp/composer-module-installer/zipball/45161b5406f3e9c82459d0f9a5a1dba064953cfa",
-                "reference": "45161b5406f3e9c82459d0f9a5a1dba064953cfa",
+                "url": "https://api.github.com/repos/simplesamlphp/composer-module-installer/zipball/27b4fe96198ffaff3ab49c87b40f4cb24de77b01",
+                "reference": "27b4fe96198ffaff3ab49c87b40f4cb24de77b01",
                 "shasum": ""
             },
             "require": {
-                "composer-plugin-api": "^1.1|^2.0",
+                "composer-plugin-api": "^1.1 || ^2.0",
+                "php": "^7.4 || ^8.0",
                 "simplesamlphp/simplesamlphp": "*"
             },
             "type": "composer-plugin",
             "extra": {
-                "class": "SimpleSamlPhp\\Composer\\ModuleInstallerPlugin"
+                "class": "SimpleSAML\\Composer\\ModuleInstallerPlugin"
             },
             "autoload": {
-                "psr-0": {
-                    "SimpleSamlPhp\\Composer": "src/"
+                "psr-4": {
+                    "SimpleSAML\\Composer\\": "src/"
                 }
             },
             "notification-url": "https://packagist.org/downloads/",
@@ -1918,9 +1919,9 @@
             "description": "A Composer plugin that allows installing SimpleSAMLphp modules through Composer.",
             "support": {
                 "issues": "https://github.com/simplesamlphp/composer-module-installer/issues",
-                "source": "https://github.com/simplesamlphp/composer-module-installer/tree/v1.1.8"
+                "source": "https://github.com/simplesamlphp/composer-module-installer/tree/v1.2.0"
             },
-            "time": "2020-08-25T19:04:33+00:00"
+            "time": "2022-08-31T17:20:27+00:00"
         },
         {
             "name": "simplesamlphp/saml2",
@@ -1982,16 +1983,16 @@
         },
         {
             "name": "simplesamlphp/simplesamlphp",
-            "version": "v2.0.0-rc1",
+            "version": "v2.0.0-rc2",
             "source": {
                 "type": "git",
                 "url": "https://github.com/simplesamlphp/simplesamlphp.git",
-                "reference": "cf68e6ef7aaecf75d434023adfe30913d993f015"
+                "reference": "2cf4ec863ab9aa59eb4ad0b5287ab8ab97360089"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/simplesamlphp/simplesamlphp/zipball/cf68e6ef7aaecf75d434023adfe30913d993f015",
-                "reference": "cf68e6ef7aaecf75d434023adfe30913d993f015",
+                "url": "https://api.github.com/repos/simplesamlphp/simplesamlphp/zipball/2cf4ec863ab9aa59eb4ad0b5287ab8ab97360089",
+                "reference": "2cf4ec863ab9aa59eb4ad0b5287ab8ab97360089",
                 "shasum": ""
             },
             "require": {
@@ -2010,7 +2011,7 @@
                 "gettext/translator": "^1.0.1",
                 "php": ">=7.4 || ^8.0",
                 "phpmailer/phpmailer": "^6.5",
-                "simplesamlphp/assert": "^0.3.0",
+                "simplesamlphp/assert": "^0.8.0",
                 "simplesamlphp/saml2": "^4.6",
                 "symfony/cache": "^5.4",
                 "symfony/config": "^5.4",
@@ -2034,9 +2035,9 @@
                 "ext-curl": "*",
                 "ext-pdo_sqlite": "*",
                 "mikey179/vfsstream": "~1.6",
-                "simplesamlphp/simplesamlphp-module-adfs": ">=2.0.0-rc2",
+                "simplesamlphp/simplesamlphp-module-adfs": ">=2.0.0-rc5",
                 "simplesamlphp/simplesamlphp-test-framework": "^1.2.1",
-                "simplesamlphp/xml-security": "^0.4.5"
+                "simplesamlphp/xml-security": "^0.6.6"
             },
             "suggest": {
                 "ext-curl": "Needed in order to check for updates automatically",
@@ -2094,7 +2095,7 @@
                 "issues": "https://github.com/simplesamlphp/simplesamlphp/issues",
                 "source": "https://github.com/simplesamlphp/simplesamlphp"
             },
-            "time": "2022-07-01T19:47:50+00:00"
+            "time": "2022-09-22T06:38:05+00:00"
         },
         {
             "name": "symfony/cache",
@@ -2353,16 +2354,16 @@
         },
         {
             "name": "symfony/console",
-            "version": "v5.4.11",
+            "version": "v5.4.12",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/console.git",
-                "reference": "535846c7ee6bc4dd027ca0d93220601456734b10"
+                "reference": "c072aa8f724c3af64e2c7a96b796a4863d24dba1"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/console/zipball/535846c7ee6bc4dd027ca0d93220601456734b10",
-                "reference": "535846c7ee6bc4dd027ca0d93220601456734b10",
+                "url": "https://api.github.com/repos/symfony/console/zipball/c072aa8f724c3af64e2c7a96b796a4863d24dba1",
+                "reference": "c072aa8f724c3af64e2c7a96b796a4863d24dba1",
                 "shasum": ""
             },
             "require": {
@@ -2432,7 +2433,7 @@
                 "terminal"
             ],
             "support": {
-                "source": "https://github.com/symfony/console/tree/v5.4.11"
+                "source": "https://github.com/symfony/console/tree/v5.4.12"
             },
             "funding": [
                 {
@@ -2448,7 +2449,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2022-07-22T10:42:43+00:00"
+            "time": "2022-08-17T13:18:05+00:00"
         },
         {
             "name": "symfony/dependency-injection",
@@ -2843,16 +2844,16 @@
         },
         {
             "name": "symfony/filesystem",
-            "version": "v5.4.11",
+            "version": "v5.4.12",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/filesystem.git",
-                "reference": "6699fb0228d1bc35b12aed6dd5e7455457609ddd"
+                "reference": "2d67c1f9a1937406a9be3171b4b22250c0a11447"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/filesystem/zipball/6699fb0228d1bc35b12aed6dd5e7455457609ddd",
-                "reference": "6699fb0228d1bc35b12aed6dd5e7455457609ddd",
+                "url": "https://api.github.com/repos/symfony/filesystem/zipball/2d67c1f9a1937406a9be3171b4b22250c0a11447",
+                "reference": "2d67c1f9a1937406a9be3171b4b22250c0a11447",
                 "shasum": ""
             },
             "require": {
@@ -2887,7 +2888,7 @@
             "description": "Provides basic utilities for the filesystem",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/filesystem/tree/v5.4.11"
+                "source": "https://github.com/symfony/filesystem/tree/v5.4.12"
             },
             "funding": [
                 {
@@ -2903,7 +2904,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2022-07-20T13:00:38+00:00"
+            "time": "2022-08-02T13:48:16+00:00"
         },
         {
             "name": "symfony/finder",
@@ -2970,16 +2971,16 @@
         },
         {
             "name": "symfony/framework-bundle",
-            "version": "v5.4.11",
+            "version": "v5.4.12",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/framework-bundle.git",
-                "reference": "a0660b602357d5c2ceaac1c9f80c5820bbff803d"
+                "reference": "49f8fe5d39b7513a3f26898788885dbe66b0d910"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/a0660b602357d5c2ceaac1c9f80c5820bbff803d",
-                "reference": "a0660b602357d5c2ceaac1c9f80c5820bbff803d",
+                "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/49f8fe5d39b7513a3f26898788885dbe66b0d910",
+                "reference": "49f8fe5d39b7513a3f26898788885dbe66b0d910",
                 "shasum": ""
             },
             "require": {
@@ -3101,7 +3102,7 @@
             "description": "Provides a tight integration between Symfony components and the Symfony full-stack framework",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/framework-bundle/tree/v5.4.11"
+                "source": "https://github.com/symfony/framework-bundle/tree/v5.4.12"
             },
             "funding": [
                 {
@@ -3117,20 +3118,20 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2022-07-20T13:00:38+00:00"
+            "time": "2022-08-26T10:32:10+00:00"
         },
         {
             "name": "symfony/http-foundation",
-            "version": "v5.4.11",
+            "version": "v5.4.12",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/http-foundation.git",
-                "reference": "0a5868e0999e9d47859ba3d918548ff6943e6389"
+                "reference": "f4bfe9611b113b15d98a43da68ec9b5a00d56791"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/http-foundation/zipball/0a5868e0999e9d47859ba3d918548ff6943e6389",
-                "reference": "0a5868e0999e9d47859ba3d918548ff6943e6389",
+                "url": "https://api.github.com/repos/symfony/http-foundation/zipball/f4bfe9611b113b15d98a43da68ec9b5a00d56791",
+                "reference": "f4bfe9611b113b15d98a43da68ec9b5a00d56791",
                 "shasum": ""
             },
             "require": {
@@ -3142,8 +3143,11 @@
             "require-dev": {
                 "predis/predis": "~1.0",
                 "symfony/cache": "^4.4|^5.0|^6.0",
+                "symfony/dependency-injection": "^5.4|^6.0",
                 "symfony/expression-language": "^4.4|^5.0|^6.0",
-                "symfony/mime": "^4.4|^5.0|^6.0"
+                "symfony/http-kernel": "^5.4.12|^6.0.12|^6.1.4",
+                "symfony/mime": "^4.4|^5.0|^6.0",
+                "symfony/rate-limiter": "^5.2|^6.0"
             },
             "suggest": {
                 "symfony/mime": "To use the file extension guesser"
@@ -3174,7 +3178,7 @@
             "description": "Defines an object-oriented layer for the HTTP specification",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/http-foundation/tree/v5.4.11"
+                "source": "https://github.com/symfony/http-foundation/tree/v5.4.12"
             },
             "funding": [
                 {
@@ -3190,20 +3194,20 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2022-07-20T13:00:38+00:00"
+            "time": "2022-08-19T07:33:17+00:00"
         },
         {
             "name": "symfony/http-kernel",
-            "version": "v5.4.11",
+            "version": "v5.4.12",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/http-kernel.git",
-                "reference": "4fd590a2ef3f62560dbbf6cea511995dd77321ee"
+                "reference": "37f660fa3bcd78fe4893ce23ebe934618ec099be"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/http-kernel/zipball/4fd590a2ef3f62560dbbf6cea511995dd77321ee",
-                "reference": "4fd590a2ef3f62560dbbf6cea511995dd77321ee",
+                "url": "https://api.github.com/repos/symfony/http-kernel/zipball/37f660fa3bcd78fe4893ce23ebe934618ec099be",
+                "reference": "37f660fa3bcd78fe4893ce23ebe934618ec099be",
                 "shasum": ""
             },
             "require": {
@@ -3286,7 +3290,7 @@
             "description": "Provides a structured process for converting a Request into a Response",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/http-kernel/tree/v5.4.11"
+                "source": "https://github.com/symfony/http-kernel/tree/v5.4.12"
             },
             "funding": [
                 {
@@ -3302,7 +3306,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2022-07-29T12:30:22+00:00"
+            "time": "2022-08-26T14:40:40+00:00"
         },
         {
             "name": "symfony/intl",
@@ -4200,16 +4204,16 @@
         },
         {
             "name": "symfony/string",
-            "version": "v5.4.11",
+            "version": "v5.4.12",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/string.git",
-                "reference": "5eb661e49ad389e4ae2b6e4df8d783a8a6548322"
+                "reference": "2fc515e512d721bf31ea76bd02fe23ada4640058"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/string/zipball/5eb661e49ad389e4ae2b6e4df8d783a8a6548322",
-                "reference": "5eb661e49ad389e4ae2b6e4df8d783a8a6548322",
+                "url": "https://api.github.com/repos/symfony/string/zipball/2fc515e512d721bf31ea76bd02fe23ada4640058",
+                "reference": "2fc515e512d721bf31ea76bd02fe23ada4640058",
                 "shasum": ""
             },
             "require": {
@@ -4266,7 +4270,7 @@
                 "utf8"
             ],
             "support": {
-                "source": "https://github.com/symfony/string/tree/v5.4.11"
+                "source": "https://github.com/symfony/string/tree/v5.4.12"
             },
             "funding": [
                 {
@@ -4282,7 +4286,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2022-07-24T16:15:25+00:00"
+            "time": "2022-08-12T17:03:11+00:00"
         },
         {
             "name": "symfony/translation-contracts",
@@ -4364,16 +4368,16 @@
         },
         {
             "name": "symfony/twig-bridge",
-            "version": "v5.4.11",
+            "version": "v5.4.12",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/twig-bridge.git",
-                "reference": "63b8a50d48c9fe3d04e77307d4f1771dd848baa8"
+                "reference": "94c3b38514c953e3e84719c96d4e578a01ca1819"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/63b8a50d48c9fe3d04e77307d4f1771dd848baa8",
-                "reference": "63b8a50d48c9fe3d04e77307d4f1771dd848baa8",
+                "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/94c3b38514c953e3e84719c96d4e578a01ca1819",
+                "reference": "94c3b38514c953e3e84719c96d4e578a01ca1819",
                 "shasum": ""
             },
             "require": {
@@ -4465,7 +4469,7 @@
             "description": "Provides integration for Twig with various Symfony components",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/twig-bridge/tree/v5.4.11"
+                "source": "https://github.com/symfony/twig-bridge/tree/v5.4.12"
             },
             "funding": [
                 {
@@ -4481,7 +4485,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2022-07-20T13:00:38+00:00"
+            "time": "2022-08-03T13:09:21+00:00"
         },
         {
             "name": "symfony/var-dumper",
@@ -4647,16 +4651,16 @@
         },
         {
             "name": "symfony/yaml",
-            "version": "v5.4.11",
+            "version": "v5.4.12",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/yaml.git",
-                "reference": "05d4ea560f3402c6c116afd99fdc66e60eda227e"
+                "reference": "7a3aa21ac8ab1a96cc6de5bbcab4bc9fc943b18c"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/yaml/zipball/05d4ea560f3402c6c116afd99fdc66e60eda227e",
-                "reference": "05d4ea560f3402c6c116afd99fdc66e60eda227e",
+                "url": "https://api.github.com/repos/symfony/yaml/zipball/7a3aa21ac8ab1a96cc6de5bbcab4bc9fc943b18c",
+                "reference": "7a3aa21ac8ab1a96cc6de5bbcab4bc9fc943b18c",
                 "shasum": ""
             },
             "require": {
@@ -4702,7 +4706,7 @@
             "description": "Loads and dumps YAML files",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/yaml/tree/v5.4.11"
+                "source": "https://github.com/symfony/yaml/tree/v5.4.12"
             },
             "funding": [
                 {
@@ -4718,7 +4722,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2022-06-27T16:58:25+00:00"
+            "time": "2022-08-02T15:52:22+00:00"
         },
         {
             "name": "twig/intl-extra",
@@ -5484,16 +5488,16 @@
         },
         {
             "name": "nikic/php-parser",
-            "version": "v4.14.0",
+            "version": "v4.15.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/nikic/PHP-Parser.git",
-                "reference": "34bea19b6e03d8153165d8f30bba4c3be86184c1"
+                "reference": "0ef6c55a3f47f89d7a374e6f835197a0b5fcf900"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/34bea19b6e03d8153165d8f30bba4c3be86184c1",
-                "reference": "34bea19b6e03d8153165d8f30bba4c3be86184c1",
+                "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/0ef6c55a3f47f89d7a374e6f835197a0b5fcf900",
+                "reference": "0ef6c55a3f47f89d7a374e6f835197a0b5fcf900",
                 "shasum": ""
             },
             "require": {
@@ -5534,9 +5538,9 @@
             ],
             "support": {
                 "issues": "https://github.com/nikic/PHP-Parser/issues",
-                "source": "https://github.com/nikic/PHP-Parser/tree/v4.14.0"
+                "source": "https://github.com/nikic/PHP-Parser/tree/v4.15.1"
             },
-            "time": "2022-05-31T20:59:12+00:00"
+            "time": "2022-09-04T07:30:47+00:00"
         },
         {
             "name": "openlss/lib-array2xml",
@@ -5862,92 +5866,25 @@
             },
             "time": "2022-03-15T21:29:03+00:00"
         },
-        {
-            "name": "phpspec/prophecy",
-            "version": "v1.15.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/phpspec/prophecy.git",
-                "reference": "bbcd7380b0ebf3961ee21409db7b38bc31d69a13"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/phpspec/prophecy/zipball/bbcd7380b0ebf3961ee21409db7b38bc31d69a13",
-                "reference": "bbcd7380b0ebf3961ee21409db7b38bc31d69a13",
-                "shasum": ""
-            },
-            "require": {
-                "doctrine/instantiator": "^1.2",
-                "php": "^7.2 || ~8.0, <8.2",
-                "phpdocumentor/reflection-docblock": "^5.2",
-                "sebastian/comparator": "^3.0 || ^4.0",
-                "sebastian/recursion-context": "^3.0 || ^4.0"
-            },
-            "require-dev": {
-                "phpspec/phpspec": "^6.0 || ^7.0",
-                "phpunit/phpunit": "^8.0 || ^9.0"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "1.x-dev"
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "Prophecy\\": "src/Prophecy"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Konstantin Kudryashov",
-                    "email": "ever.zet@gmail.com",
-                    "homepage": "http://everzet.com"
-                },
-                {
-                    "name": "Marcello Duarte",
-                    "email": "marcello.duarte@gmail.com"
-                }
-            ],
-            "description": "Highly opinionated mocking framework for PHP 5.3+",
-            "homepage": "https://github.com/phpspec/prophecy",
-            "keywords": [
-                "Double",
-                "Dummy",
-                "fake",
-                "mock",
-                "spy",
-                "stub"
-            ],
-            "support": {
-                "issues": "https://github.com/phpspec/prophecy/issues",
-                "source": "https://github.com/phpspec/prophecy/tree/v1.15.0"
-            },
-            "time": "2021-12-08T12:19:24+00:00"
-        },
         {
             "name": "phpunit/php-code-coverage",
-            "version": "9.2.15",
+            "version": "9.2.17",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/php-code-coverage.git",
-                "reference": "2e9da11878c4202f97915c1cb4bb1ca318a63f5f"
+                "reference": "aa94dc41e8661fe90c7316849907cba3007b10d8"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/2e9da11878c4202f97915c1cb4bb1ca318a63f5f",
-                "reference": "2e9da11878c4202f97915c1cb4bb1ca318a63f5f",
+                "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/aa94dc41e8661fe90c7316849907cba3007b10d8",
+                "reference": "aa94dc41e8661fe90c7316849907cba3007b10d8",
                 "shasum": ""
             },
             "require": {
                 "ext-dom": "*",
                 "ext-libxml": "*",
                 "ext-xmlwriter": "*",
-                "nikic/php-parser": "^4.13.0",
+                "nikic/php-parser": "^4.14",
                 "php": ">=7.3",
                 "phpunit/php-file-iterator": "^3.0.3",
                 "phpunit/php-text-template": "^2.0.2",
@@ -5996,7 +5933,7 @@
             ],
             "support": {
                 "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues",
-                "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.15"
+                "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.17"
             },
             "funding": [
                 {
@@ -6004,7 +5941,7 @@
                     "type": "github"
                 }
             ],
-            "time": "2022-03-07T09:28:20+00:00"
+            "time": "2022-08-30T12:24:04+00:00"
         },
         {
             "name": "phpunit/php-file-iterator",
@@ -6249,16 +6186,16 @@
         },
         {
             "name": "phpunit/phpunit",
-            "version": "9.5.21",
+            "version": "9.5.25",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/phpunit.git",
-                "reference": "0e32b76be457de00e83213528f6bb37e2a38fcb1"
+                "reference": "3e6f90ca7e3d02025b1d147bd8d4a89fd4ca8a1d"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/0e32b76be457de00e83213528f6bb37e2a38fcb1",
-                "reference": "0e32b76be457de00e83213528f6bb37e2a38fcb1",
+                "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/3e6f90ca7e3d02025b1d147bd8d4a89fd4ca8a1d",
+                "reference": "3e6f90ca7e3d02025b1d147bd8d4a89fd4ca8a1d",
                 "shasum": ""
             },
             "require": {
@@ -6273,7 +6210,6 @@
                 "phar-io/manifest": "^2.0.3",
                 "phar-io/version": "^3.0.2",
                 "php": ">=7.3",
-                "phpspec/prophecy": "^1.12.1",
                 "phpunit/php-code-coverage": "^9.2.13",
                 "phpunit/php-file-iterator": "^3.0.5",
                 "phpunit/php-invoker": "^3.1.1",
@@ -6281,19 +6217,16 @@
                 "phpunit/php-timer": "^5.0.2",
                 "sebastian/cli-parser": "^1.0.1",
                 "sebastian/code-unit": "^1.0.6",
-                "sebastian/comparator": "^4.0.5",
+                "sebastian/comparator": "^4.0.8",
                 "sebastian/diff": "^4.0.3",
                 "sebastian/environment": "^5.1.3",
-                "sebastian/exporter": "^4.0.3",
+                "sebastian/exporter": "^4.0.5",
                 "sebastian/global-state": "^5.0.1",
                 "sebastian/object-enumerator": "^4.0.3",
                 "sebastian/resource-operations": "^3.0.3",
-                "sebastian/type": "^3.0",
+                "sebastian/type": "^3.2",
                 "sebastian/version": "^3.0.2"
             },
-            "require-dev": {
-                "phpspec/prophecy-phpunit": "^2.0.1"
-            },
             "suggest": {
                 "ext-soap": "*",
                 "ext-xdebug": "*"
@@ -6335,7 +6268,7 @@
             ],
             "support": {
                 "issues": "https://github.com/sebastianbergmann/phpunit/issues",
-                "source": "https://github.com/sebastianbergmann/phpunit/tree/9.5.21"
+                "source": "https://github.com/sebastianbergmann/phpunit/tree/9.5.25"
             },
             "funding": [
                 {
@@ -6345,9 +6278,13 @@
                 {
                     "url": "https://github.com/sebastianbergmann",
                     "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit",
+                    "type": "tidelift"
                 }
             ],
-            "time": "2022-06-19T12:14:25+00:00"
+            "time": "2022-09-25T03:44:45+00:00"
         },
         {
             "name": "sebastian/cli-parser",
@@ -6518,16 +6455,16 @@
         },
         {
             "name": "sebastian/comparator",
-            "version": "4.0.6",
+            "version": "4.0.8",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/comparator.git",
-                "reference": "55f4261989e546dc112258c7a75935a81a7ce382"
+                "reference": "fa0f136dd2334583309d32b62544682ee972b51a"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/55f4261989e546dc112258c7a75935a81a7ce382",
-                "reference": "55f4261989e546dc112258c7a75935a81a7ce382",
+                "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/fa0f136dd2334583309d32b62544682ee972b51a",
+                "reference": "fa0f136dd2334583309d32b62544682ee972b51a",
                 "shasum": ""
             },
             "require": {
@@ -6580,7 +6517,7 @@
             ],
             "support": {
                 "issues": "https://github.com/sebastianbergmann/comparator/issues",
-                "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.6"
+                "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.8"
             },
             "funding": [
                 {
@@ -6588,7 +6525,7 @@
                     "type": "github"
                 }
             ],
-            "time": "2020-10-26T15:49:45+00:00"
+            "time": "2022-09-14T12:41:17+00:00"
         },
         {
             "name": "sebastian/complexity",
@@ -6778,16 +6715,16 @@
         },
         {
             "name": "sebastian/exporter",
-            "version": "4.0.4",
+            "version": "4.0.5",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/exporter.git",
-                "reference": "65e8b7db476c5dd267e65eea9cab77584d3cfff9"
+                "reference": "ac230ed27f0f98f597c8a2b6eb7ac563af5e5b9d"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/65e8b7db476c5dd267e65eea9cab77584d3cfff9",
-                "reference": "65e8b7db476c5dd267e65eea9cab77584d3cfff9",
+                "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/ac230ed27f0f98f597c8a2b6eb7ac563af5e5b9d",
+                "reference": "ac230ed27f0f98f597c8a2b6eb7ac563af5e5b9d",
                 "shasum": ""
             },
             "require": {
@@ -6843,7 +6780,7 @@
             ],
             "support": {
                 "issues": "https://github.com/sebastianbergmann/exporter/issues",
-                "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.4"
+                "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.5"
             },
             "funding": [
                 {
@@ -6851,7 +6788,7 @@
                     "type": "github"
                 }
             ],
-            "time": "2021-11-11T14:18:36+00:00"
+            "time": "2022-09-14T06:03:37+00:00"
         },
         {
             "name": "sebastian/global-state",
@@ -7206,16 +7143,16 @@
         },
         {
             "name": "sebastian/type",
-            "version": "3.0.0",
+            "version": "3.2.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/type.git",
-                "reference": "b233b84bc4465aff7b57cf1c4bc75c86d00d6dad"
+                "reference": "fb3fe09c5f0bae6bc27ef3ce933a1e0ed9464b6e"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/b233b84bc4465aff7b57cf1c4bc75c86d00d6dad",
-                "reference": "b233b84bc4465aff7b57cf1c4bc75c86d00d6dad",
+                "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/fb3fe09c5f0bae6bc27ef3ce933a1e0ed9464b6e",
+                "reference": "fb3fe09c5f0bae6bc27ef3ce933a1e0ed9464b6e",
                 "shasum": ""
             },
             "require": {
@@ -7227,7 +7164,7 @@
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "3.0-dev"
+                    "dev-master": "3.2-dev"
                 }
             },
             "autoload": {
@@ -7250,7 +7187,7 @@
             "homepage": "https://github.com/sebastianbergmann/type",
             "support": {
                 "issues": "https://github.com/sebastianbergmann/type/issues",
-                "source": "https://github.com/sebastianbergmann/type/tree/3.0.0"
+                "source": "https://github.com/sebastianbergmann/type/tree/3.2.0"
             },
             "funding": [
                 {
@@ -7258,7 +7195,7 @@
                     "type": "github"
                 }
             ],
-            "time": "2022-03-15T09:54:48+00:00"
+            "time": "2022-09-12T14:47:03+00:00"
         },
         {
             "name": "sebastian/version",
@@ -7561,16 +7498,16 @@
         },
         {
             "name": "vimeo/psalm",
-            "version": "4.26.0",
+            "version": "4.27.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/vimeo/psalm.git",
-                "reference": "6998fabb2bf528b65777bf9941920888d23c03ac"
+                "reference": "faf106e717c37b8c81721845dba9de3d8deed8ff"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/vimeo/psalm/zipball/6998fabb2bf528b65777bf9941920888d23c03ac",
-                "reference": "6998fabb2bf528b65777bf9941920888d23c03ac",
+                "url": "https://api.github.com/repos/vimeo/psalm/zipball/faf106e717c37b8c81721845dba9de3d8deed8ff",
+                "reference": "faf106e717c37b8c81721845dba9de3d8deed8ff",
                 "shasum": ""
             },
             "require": {
@@ -7662,9 +7599,9 @@
             ],
             "support": {
                 "issues": "https://github.com/vimeo/psalm/issues",
-                "source": "https://github.com/vimeo/psalm/tree/4.26.0"
+                "source": "https://github.com/vimeo/psalm/tree/4.27.0"
             },
-            "time": "2022-07-31T13:10:26+00:00"
+            "time": "2022-08-31T13:47:09+00:00"
         },
         {
             "name": "webmozart/path-util",
diff --git a/config-templates/module_accounting.php b/config-templates/module_accounting.php
index 0c00e1055b3b39d7af18c79b0b2f3c5de5098293..4a3538ae0f7aea1d06bf52bea6b2405553e2c0c4 100644
--- a/config-templates/module_accounting.php
+++ b/config-templates/module_accounting.php
@@ -3,7 +3,9 @@
 declare(strict_types=1);
 
 use SimpleSAML\Module\accounting\ModuleConfiguration;
+use SimpleSAML\Module\accounting\Providers;
 use SimpleSAML\Module\accounting\Stores;
+use SimpleSAML\Module\accounting\Trackers;
 
 $config = [
     /**
@@ -12,42 +14,102 @@ $config = [
      *
      * Examples:
      * urn:oasis:names:tc:SAML:attribute:subject-id
-     * eduPersonUniqueId
+     * eduPersonTargetedID
      * eduPersonPrincipalName
+     * eduPersonUniqueID
      */
-    ModuleConfiguration::OPTION_USER_ID_ATTRIBUTE => 'urn:oasis:names:tc:SAML:attribute:subject-id',
+    ModuleConfiguration::OPTION_USER_ID_ATTRIBUTE_NAME => 'urn:oasis:names:tc:SAML:attribute:subject-id',
+
+    /**
+     * Default authentication source which will be used when authenticating users in SimpleSAMLphp Profile Page.
+     */
+    ModuleConfiguration::OPTION_DEFAULT_AUTHENTICATION_SOURCE => 'default-sp',
 
     /**
      * Accounting processing type. There are two possible types: 'synchronous' and 'asynchronous'.
-     * - 'synchronous': accounting processing will be performed during authentication itself (slower)
-     * - 'asynchronous': for each authentication event a new job will be created for later processing (faster,
-     *   but requires setting up job storage and a cron entry).
      */
     ModuleConfiguration::OPTION_ACCOUNTING_PROCESSING_TYPE =>
+        /**
+         * Synchronous option, meaning accounting processing will be performed during authentication itself
+         * (slower authentication).
+         */
         ModuleConfiguration\AccountingProcessingType::VALUE_SYNCHRONOUS,
+        /**
+         * Asynchronous option, meaning for each authentication event a new job will be created for later processing
+         * (faster authentication, but requires setting up job storage and a cron entry).
+         */
+        //ModuleConfiguration\AccountingProcessingType::VALUE_ASYNCHRONOUS,
 
     /**
-     * Jobs store. Determines which of the available stores will be used to store jobs in case the 'asynchronous'
-     * accounting processing type was set.
+     * Jobs store class. In case of the 'asynchronous' accounting processing type, this determines which class
+     * will be used to store jobs. The class must implement Stores\Interfaces\JobsStoreInterface.
      */
-    ModuleConfiguration::OPTION_JOBS_STORE => Stores\Jobs\DoctrineDbal\JobsStore::class,
+    ModuleConfiguration::OPTION_JOBS_STORE =>
+        /**
+         * Default jobs store class which expects Doctrine DBAL compatible connection to be set below.
+         */
+        Stores\Jobs\DoctrineDbal\Store::class,
+
+    /**
+     * Default data tracker and provider to be used for accounting and as a source for data display in SSP UI.
+     * This class must implement Trackers\Interfaces\AuthenticationDataTrackerInterface and
+     * Providers\Interfaces\AuthenticationDataProviderInterface
+     */
+    ModuleConfiguration::OPTION_DEFAULT_DATA_TRACKER_AND_PROVIDER =>
+        /**
+         * Track each authentication event for idp / sp / user combination, and any change in idp / sp metadata or
+         * released user attributes. Each authentication event record will have data used and released at the
+         * time of the authentication event (versioned idp / sp / user data). This tracker can also be
+         * used as an authentication data provider. It expects Doctrine DBAL compatible connection
+         * to be set below. Internally it uses store class
+         * Stores\Data\DoctrineDbal\DoctrineDbal\Versioned\Store::class.
+         */
+        Trackers\Authentication\DoctrineDbal\Versioned\Tracker::class,
+
 
     /**
-     * Store connection for particular store. Can be used to set different connections for different stores.
+     * Additional trackers to run besides default data tracker. These trackers will typically only process and
+     * persist authentication data to proper data store, and won't be used to display data in SSP UI.
+     * These tracker classes must implement Trackers\Interfaces\AuthenticationDataTrackerInterface.
      */
-    ModuleConfiguration::OPTION_STORE_TO_CONNECTION_KEY_MAP => [
-        Stores\Jobs\DoctrineDbal\JobsStore::class => 'doctrine_dbal_pdo_mysql',
+    ModuleConfiguration::OPTION_ADDITIONAL_TRACKERS => [
+        // TODO mivanci at least one more tracker and its connection
+        // tracker-class
     ],
 
     /**
-     * Store connections and their parameters.
-     *
-     * Any compatible Doctrine DBAL implementation can be used:
-     * https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html
-     * There are additional parameters for: table prefix.
-     * Examples for mysql and sqlite are provided below.
+     * Map of classes (stores, trackers, providers, ...) and connection keys, which defines which connections will
+     * be used. Value for connection key can be string, or it can be an array with two connection types as keys:
+     * master or slave. Master connection is single connection which will be used to write data to, and it
+     * must be set. If no slave connections are set, master will also be used to read data from. Slave
+     * connections are defined as array of strings. If slave connections are set, random one will
+     * be picked to read data from.
+     */
+    ModuleConfiguration::OPTION_CLASS_TO_CONNECTION_MAP => [
+        /**
+         * Connection key to be used by jobs store class.
+         */
+        Stores\Jobs\DoctrineDbal\Store::class => 'doctrine_dbal_pdo_mysql',
+        /**
+         * Connection key to be used by this data tracker and provider.
+         */
+        Trackers\Authentication\DoctrineDbal\Versioned\Tracker::class => [
+            ModuleConfiguration\ConnectionType::MASTER => 'doctrine_dbal_pdo_mysql',
+            ModuleConfiguration\ConnectionType::SLAVE => [
+                'doctrine_dbal_pdo_mysql',
+            ],
+        ],
+    ],
+
+    /**
+     * Connections and their parameters.
      */
-    ModuleConfiguration::OPTION_ALL_STORE_CONNECTIONS_AND_PARAMETERS => [
+    ModuleConfiguration::OPTION_CONNECTIONS_AND_PARAMETERS => [
+        /**
+         * Examples for Doctrine DBAL compatible mysql and sqlite connection parameters are provided below (more info
+         * on https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html).
+         * There are additional parameters for: table prefix.
+         */
         'doctrine_dbal_pdo_mysql' => [
             'driver' => 'pdo_mysql', // (string): The built-in driver implementation to use
             'user' => 'user', // (string): Username to use when connecting to the database.
@@ -58,7 +120,7 @@ $config = [
             //'unix_socket' => 'unix_socet', // (string): Name of the socket used to connect to the database.
             'charset' => 'utf8', // (string): The charset used when connecting to the database.
             //'url' => 'mysql://user:secret@localhost/mydb?charset=utf8', // ...alternative way of providing parameters.
-            // Additional parameters not originaly avaliable in Doctrine DBAL
+            // Additional parameters not originally available in Doctrine DBAL
             'table_prefix' => '', // (string): Prefix for each table.
         ],
         'doctrine_dbal_pdo_sqlite' => [
@@ -69,7 +131,7 @@ $config = [
             // Mutually exclusive with path. path takes precedence.
             //'url' => 'sqlite:////path/to/db.sqlite // ...alternative way of providing path parameter.
             //'url' => 'sqlite:///:memory:' // ...alternative way of providing memory parameter.
-            // Additional parameters not originaly avaliable in Doctrine DBAL
+            // Additional parameters not originally available in Doctrine DBAL
             'table_prefix' => '', // (string): Prefix for each table.
         ],
     ],
diff --git a/psalm.xml b/psalm.xml
index d95e9396c08954783a357a3e030d84280eed6870..87c90075a861695c2d56c923737061571b81524e 100644
--- a/psalm.xml
+++ b/psalm.xml
@@ -23,6 +23,7 @@
             <errorLevel type="suppress">
                 <directory name="config-templates" />
                 <directory name="tests/config-templates" />
+                <directory name="tests/attributemap" />
             </errorLevel>
         </UnusedVariable>
 
diff --git a/routing/routes/routes.yml b/routing/routes/routes.yml
index 9ac5397c558584c1420245aa55f088f957072bfe..6b1972c0ad84a604328e4eb16276f14b0c4a1971 100644
--- a/routing/routes/routes.yml
+++ b/routing/routes/routes.yml
@@ -1,4 +1,30 @@
 
-accounting-test:
-    path:       /test
-    defaults:   { _controller: 'SimpleSAML\Module\accounting\Controller\Test::test' }
+accounting-setup:
+    path:       /setup
+    defaults:   { _controller: 'SimpleSAML\Module\accounting\Http\Controllers\Test::setup' }
+
+accounting-job-runner:
+    path:       /job-runner
+    defaults:   { _controller: 'SimpleSAML\Module\accounting\Http\Controllers\Test::jobRunner' }
+
+accounting-admin-configuration-status:
+    path:       /admin/configuration/status
+    defaults:   { _controller: 'SimpleSAML\Module\accounting\Http\Controllers\Admin\Configuration::status' }
+
+accounting-user-personal-data:
+    path: /user/personal-data
+    defaults:   { _controller: 'SimpleSAML\Module\accounting\Http\Controllers\User\Profile::personalData' }
+
+accounting-user-connected-organizations:
+    path: /user/connected-organizations
+    defaults:   { _controller: 'SimpleSAML\Module\accounting\Http\Controllers\User\Profile::connectedOrganizations' }
+
+accounting-user-activity:
+    path: /user/activity
+    defaults:   { _controller: 'SimpleSAML\Module\accounting\Http\Controllers\User\Profile::activity' }
+
+accounting-user-logout:
+    path: /user/logout
+    methods:
+        - POST
+    defaults:   { _controller: 'SimpleSAML\Module\accounting\Http\Controllers\User\Profile::logout' }
diff --git a/routing/services/services.yml b/routing/services/services.yml
index 18958e290e6d8ca6eb8bd80f46db2f76fd8b7b8e..a85987f93c7e37693b49911bbfa5f223faf3d668 100644
--- a/routing/services/services.yml
+++ b/routing/services/services.yml
@@ -1,7 +1,19 @@
 services:
   # default configuration for services in *this* file
   _defaults:
+    autowire: true
     public: false
+    bind:
+      Psr\Log\LoggerInterface: '@accounting.logger'
 
-  SimpleSAML\Module\accounting\ModuleConfiguration:
-    class: SimpleSAML\Module\accounting\ModuleConfiguration
\ No newline at end of file
+  # Services
+  SimpleSAML\Module\accounting\:
+    resource: '../../src/*'
+    exclude: '../../src/{Http/Controllers}'
+  # Service aliases
+  accounting.logger:
+    class: SimpleSAML\Module\accounting\Services\Logger
+  # Controllers
+  SimpleSAML\Module\accounting\Http\Controllers\:
+    resource: '../../src/Http/Controllers/*'
+    tags: ['controller.service_arguments']
\ No newline at end of file
diff --git a/src/Auth/Process/Accounting.php b/src/Auth/Process/Accounting.php
new file mode 100644
index 0000000000000000000000000000000000000000..146523a848111bf9fcbdfeefcb425c8a560f8770
--- /dev/null
+++ b/src/Auth/Process/Accounting.php
@@ -0,0 +1,94 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Module\accounting\Auth\Process;
+
+use Psr\Log\LoggerInterface;
+use SimpleSAML\Auth\ProcessingFilter;
+use SimpleSAML\Module\accounting\Entities\Authentication\Event;
+use SimpleSAML\Module\accounting\Entities\Authentication\State;
+use SimpleSAML\Module\accounting\Exceptions\StoreException;
+use SimpleSAML\Module\accounting\ModuleConfiguration;
+use SimpleSAML\Module\accounting\Services\Logger;
+use SimpleSAML\Module\accounting\Stores\Builders\JobsStoreBuilder;
+use SimpleSAML\Module\accounting\Trackers\Builders\AuthenticationDataTrackerBuilder;
+use SimpleSAML\Module\accounting\Trackers\Interfaces\AuthenticationDataTrackerInterface;
+
+class Accounting extends ProcessingFilter
+{
+    protected ModuleConfiguration $moduleConfiguration;
+    protected JobsStoreBuilder $jobsStoreBuilder;
+    protected LoggerInterface $logger;
+    protected AuthenticationDataTrackerBuilder $authenticationDataTrackerBuilder;
+
+    /**
+     * @param array $config
+     * @param mixed $reserved
+     * @param ModuleConfiguration|null $moduleConfiguration
+     * @param LoggerInterface|null $logger
+     * @param JobsStoreBuilder|null $jobsStoreBuilder
+     */
+    public function __construct(
+        array &$config,
+        $reserved,
+        ModuleConfiguration $moduleConfiguration = null,
+        LoggerInterface $logger = null,
+        JobsStoreBuilder $jobsStoreBuilder = null,
+        AuthenticationDataTrackerBuilder $authenticationDataTrackerBuilder = null
+    ) {
+        parent::__construct($config, $reserved);
+
+        $this->moduleConfiguration = $moduleConfiguration ?? new ModuleConfiguration();
+        $this->logger = $logger ?? new Logger();
+        $this->jobsStoreBuilder = $jobsStoreBuilder ?? new JobsStoreBuilder($this->moduleConfiguration, $this->logger);
+
+        $this->authenticationDataTrackerBuilder = $authenticationDataTrackerBuilder ??
+            new AuthenticationDataTrackerBuilder($this->moduleConfiguration, $this->logger);
+    }
+
+    /**
+     * @throws StoreException
+     */
+    public function process(array &$state): void
+    {
+        try {
+            $authenticationEvent = new Event(new State($state));
+
+            if ($this->isAccountingProcessingTypeAsynchronous()) {
+                // Only create authentication event job for later processing...
+                $this->createAuthenticationEventJob($authenticationEvent);
+                return;
+            }
+
+            // Accounting type is synchronous, so do the processing right away...
+            $configuredTrackers = array_merge(
+                [$this->moduleConfiguration->getDefaultDataTrackerAndProviderClass()],
+                $this->moduleConfiguration->getAdditionalTrackers()
+            );
+
+            /** @var string $tracker */
+            foreach ($configuredTrackers as $tracker) {
+                ($this->authenticationDataTrackerBuilder->build($tracker))->process($authenticationEvent);
+            }
+        } catch (\Throwable $exception) {
+            $message = sprintf('Accounting error, skipping... Error was: %s.', $exception->getMessage());
+            $this->logger->error($message, $state);
+        }
+    }
+
+    protected function isAccountingProcessingTypeAsynchronous(): bool
+    {
+        return $this->moduleConfiguration->getAccountingProcessingType() ===
+            ModuleConfiguration\AccountingProcessingType::VALUE_ASYNCHRONOUS;
+    }
+
+    /**
+     * @throws StoreException
+     */
+    protected function createAuthenticationEventJob(Event $authenticationEvent): void
+    {
+        ($this->jobsStoreBuilder->build($this->moduleConfiguration->getJobsStoreClass()))
+            ->enqueue(new Event\Job($authenticationEvent));
+    }
+}
diff --git a/src/Controller/Test.php b/src/Controller/Test.php
deleted file mode 100644
index 1ef34ed82e9fcd3689c2e14ffd59648693add681..0000000000000000000000000000000000000000
--- a/src/Controller/Test.php
+++ /dev/null
@@ -1,52 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace SimpleSAML\Module\accounting\Controller;
-
-use Exception;
-use SimpleSAML\Configuration;
-use SimpleSAML\Locale\Translate;
-use SimpleSAML\Module\accounting\ModuleConfiguration;
-use SimpleSAML\Session;
-use SimpleSAML\XHTML\Template;
-use Symfony\Component\HttpFoundation\Request;
-
-class Test
-{
-    protected Configuration $configuration;
-    protected Session $session;
-    protected ModuleConfiguration $moduleConfiguration;
-
-    /**
-     * @param Configuration $configuration
-     * @param Session $session The current user session.
-     * @param ModuleConfiguration $moduleConfiguration
-     */
-    public function __construct(
-        Configuration $configuration,
-        Session $session,
-        ModuleConfiguration $moduleConfiguration
-    ) {
-        $this->configuration = $configuration;
-        $this->session = $session;
-        $this->moduleConfiguration = $moduleConfiguration;
-    }
-
-    /**
-     * @param Request $request
-     * @return Template
-     * @throws Exception
-     */
-    public function test(Request $request): Template
-    {
-        $template = new Template($this->configuration, 'accounting:configuration.twig');
-
-        $template->data = [
-            'test' => Translate::noop('Accounting'),
-            'test_config' => $this->moduleConfiguration->getConfiguration()->getString('test_config'),
-        ];
-
-        return $template;
-    }
-}
diff --git a/src/Entities/Activity.php b/src/Entities/Activity.php
new file mode 100644
index 0000000000000000000000000000000000000000..69699f56670bd73639b05f6d8f3cf430e6d1521e
--- /dev/null
+++ b/src/Entities/Activity.php
@@ -0,0 +1,48 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Module\accounting\Entities;
+
+use DateTimeImmutable;
+
+class Activity
+{
+    protected ServiceProvider $serviceProvider;
+    protected User $user;
+    protected DateTimeImmutable $happenedAt;
+
+    public function __construct(
+        ServiceProvider $serviceProvider,
+        User $user,
+        DateTimeImmutable $happenedAt
+    ) {
+        $this->serviceProvider = $serviceProvider;
+        $this->user = $user;
+        $this->happenedAt = $happenedAt;
+    }
+
+    /**
+     * @return ServiceProvider
+     */
+    public function getServiceProvider(): ServiceProvider
+    {
+        return $this->serviceProvider;
+    }
+
+    /**
+     * @return User
+     */
+    public function getUser(): User
+    {
+        return $this->user;
+    }
+
+    /**
+     * @return DateTimeImmutable
+     */
+    public function getHappenedAt(): DateTimeImmutable
+    {
+        return $this->happenedAt;
+    }
+}
diff --git a/src/Entities/Activity/Bag.php b/src/Entities/Activity/Bag.php
new file mode 100644
index 0000000000000000000000000000000000000000..7e91a1c9604527a43b3bcb5bf8e68ebc9d881a41
--- /dev/null
+++ b/src/Entities/Activity/Bag.php
@@ -0,0 +1,20 @@
+<?php
+
+namespace SimpleSAML\Module\accounting\Entities\Activity;
+
+use SimpleSAML\Module\accounting\Entities\Activity;
+
+class Bag
+{
+    protected array $activities = [];
+
+    public function add(Activity $activity): void
+    {
+        $this->activities[] = $activity;
+    }
+
+    public function getAll(): array
+    {
+        return $this->activities;
+    }
+}
diff --git a/src/Entities/Authentication/Event.php b/src/Entities/Authentication/Event.php
new file mode 100644
index 0000000000000000000000000000000000000000..08c9e8c3c44e9889632b988b00c4ffae982a5305
--- /dev/null
+++ b/src/Entities/Authentication/Event.php
@@ -0,0 +1,29 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Module\accounting\Entities\Authentication;
+
+use SimpleSAML\Module\accounting\Entities\Bases\AbstractPayload;
+
+class Event extends AbstractPayload
+{
+    protected State $state;
+    protected \DateTimeImmutable $happenedAt;
+
+    public function __construct(State $state, \DateTimeImmutable $happenedAt = null)
+    {
+        $this->state = $state;
+        $this->happenedAt = $happenedAt ?? new \DateTimeImmutable();
+    }
+
+    public function getState(): State
+    {
+        return $this->state;
+    }
+
+    public function getHappenedAt(): \DateTimeImmutable
+    {
+        return $this->happenedAt;
+    }
+}
diff --git a/src/Entities/AuthenticationEvent/Job.php b/src/Entities/Authentication/Event/Job.php
similarity index 63%
rename from src/Entities/AuthenticationEvent/Job.php
rename to src/Entities/Authentication/Event/Job.php
index eaa66aba53624337e61f73e3417e25f7e0bac2fa..5cea7d987cba7e579bdf4e7096edf7d8f65d7ecb 100644
--- a/src/Entities/AuthenticationEvent/Job.php
+++ b/src/Entities/Authentication/Event/Job.php
@@ -2,16 +2,16 @@
 
 declare(strict_types=1);
 
-namespace SimpleSAML\Module\accounting\Entities\AuthenticationEvent;
+namespace SimpleSAML\Module\accounting\Entities\Authentication\Event;
 
-use SimpleSAML\Module\accounting\Entities\AuthenticationEvent;
+use SimpleSAML\Module\accounting\Entities\Authentication\Event;
 use SimpleSAML\Module\accounting\Entities\Bases\AbstractJob;
 use SimpleSAML\Module\accounting\Entities\Bases\AbstractPayload;
 use SimpleSAML\Module\accounting\Exceptions\UnexpectedValueException;
 
 class Job extends AbstractJob
 {
-    public function getPayload(): AuthenticationEvent
+    public function getPayload(): Event
     {
         return $this->validatePayload($this->payload);
     }
@@ -21,10 +21,10 @@ class Job extends AbstractJob
         $this->payload = $this->validatePayload($payload);
     }
 
-    protected function validatePayload(AbstractPayload $payload): AuthenticationEvent
+    protected function validatePayload(AbstractPayload $payload): Event
     {
-        if (! ($payload instanceof AuthenticationEvent)) {
-            throw new UnexpectedValueException('AuthenticationEvent Job payload must be of type AuthenticationEvent.');
+        if (! ($payload instanceof Event)) {
+            throw new UnexpectedValueException('Event Job payload must be of type Event.');
         }
 
         return $payload;
diff --git a/src/Entities/Authentication/State.php b/src/Entities/Authentication/State.php
new file mode 100644
index 0000000000000000000000000000000000000000..6f0ff682e9169db82241ae27a838b52186af1987
--- /dev/null
+++ b/src/Entities/Authentication/State.php
@@ -0,0 +1,192 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Module\accounting\Entities\Authentication;
+
+use SimpleSAML\Module\accounting\Entities\Bases\AbstractProvider;
+use SimpleSAML\Module\accounting\Exceptions\UnexpectedValueException;
+use SimpleSAML\Module\accounting\Helpers\NetworkHelper;
+
+class State
+{
+    public const KEY_ATTRIBUTES = 'Attributes';
+    public const KEY_AUTHENTICATION_INSTANT = 'AuthnInstant';
+    public const KEY_IDENTITY_PROVIDER_METADATA = 'IdPMetadata';
+    public const KEY_SOURCE = 'Source';
+    public const KEY_SERVICE_PROVIDER_METADATA = 'SPMetadata';
+    public const KEY_DESTINATION = 'Destination';
+
+    public const KEY_ACCOUNTING = 'accounting';
+    public const ACCOUNTING_KEY_CLIENT_IP_ADDRESS = 'client_ip_address';
+
+    protected string $identityProviderEntityId;
+    protected string $serviceProviderEntityId;
+    protected array $attributes;
+    protected \DateTimeImmutable $createdAt;
+    protected ?\DateTimeImmutable $authenticationInstant;
+    protected array $identityProviderMetadata;
+    protected array $serviceProviderMetadata;
+    protected ?string $clientIpAddress;
+
+    public function __construct(array $state, \DateTimeImmutable $createdAt = null)
+    {
+        $this->createdAt = $createdAt ?? new \DateTimeImmutable();
+
+        $this->identityProviderMetadata = $this->resolveIdentityProviderMetadata($state);
+        $this->identityProviderEntityId = $this->resolveIdentityProviderEntityId();
+        $this->serviceProviderMetadata = $this->resolveServiceProviderMetadata($state);
+        $this->serviceProviderEntityId = $this->resolveServiceProviderEntityId();
+        $this->attributes = $this->resolveAttributes($state);
+        $this->authenticationInstant = $this->resolveAuthenticationInstant($state);
+        $this->clientIpAddress = $this->resolveClientIpAddress($state);
+    }
+
+    protected function resolveIdentityProviderEntityId(): string
+    {
+        if (
+            !empty($this->identityProviderMetadata[AbstractProvider::METADATA_KEY_ENTITY_ID]) &&
+            is_string($this->identityProviderMetadata[AbstractProvider::METADATA_KEY_ENTITY_ID])
+        ) {
+            return $this->identityProviderMetadata[AbstractProvider::METADATA_KEY_ENTITY_ID];
+        }
+
+        throw new UnexpectedValueException('IdP metadata array does not contain entity ID.');
+    }
+
+    protected function resolveServiceProviderEntityId(): string
+    {
+        if (
+            !empty($this->serviceProviderMetadata[AbstractProvider::METADATA_KEY_ENTITY_ID]) &&
+            is_string($this->serviceProviderMetadata[AbstractProvider::METADATA_KEY_ENTITY_ID])
+        ) {
+            return $this->serviceProviderMetadata[AbstractProvider::METADATA_KEY_ENTITY_ID];
+        }
+
+        throw new UnexpectedValueException('SP metadata array does not contain entity ID.');
+    }
+
+    protected function resolveAttributes(array $state): array
+    {
+        if (empty($state[self::KEY_ATTRIBUTES]) || !is_array($state[self::KEY_ATTRIBUTES])) {
+            throw new UnexpectedValueException('State array does not contain user attributes.');
+        }
+
+        return $state[self::KEY_ATTRIBUTES];
+    }
+
+    public function getIdentityProviderEntityId(): string
+    {
+        return $this->identityProviderEntityId;
+    }
+
+    public function getServiceProviderEntityId(): string
+    {
+        return $this->serviceProviderEntityId;
+    }
+
+    public function getAttributes(): array
+    {
+        return $this->attributes;
+    }
+
+    public function getAttributeValue(string $attributeName): ?string
+    {
+        if (!empty($this->attributes[$attributeName]) && is_array($this->attributes[$attributeName])) {
+            return (string)reset($this->attributes[$attributeName]);
+        }
+
+        return null;
+    }
+
+    public function getCreatedAt(): \DateTimeImmutable
+    {
+        return $this->createdAt;
+    }
+
+    protected function resolveAuthenticationInstant(array $state): ?\DateTimeImmutable
+    {
+        if (empty($state[self::KEY_AUTHENTICATION_INSTANT])) {
+            return null;
+        }
+
+        $authInstant = (string)$state[self::KEY_AUTHENTICATION_INSTANT];
+
+        try {
+            return new \DateTimeImmutable('@' . $authInstant);
+        } catch (\Throwable $exception) {
+            $message = sprintf(
+                'Unable to create DateTimeImmutable using AuthInstant value \'%s\'. Error was: %s.',
+                $authInstant,
+                $exception->getMessage()
+            );
+            throw new UnexpectedValueException($message);
+        }
+    }
+
+    public function getAuthenticationInstant(): ?\DateTimeImmutable
+    {
+        return $this->authenticationInstant;
+    }
+
+    protected function resolveIdentityProviderMetadata(array $state): array
+    {
+        if (
+            !empty($state[self::KEY_IDENTITY_PROVIDER_METADATA]) &&
+            is_array($state[self::KEY_IDENTITY_PROVIDER_METADATA])
+        ) {
+            return $state[self::KEY_IDENTITY_PROVIDER_METADATA];
+        } elseif (!empty($state[self::KEY_SOURCE]) && is_array($state[self::KEY_SOURCE])) {
+            return $state[self::KEY_SOURCE];
+        }
+
+        throw new UnexpectedValueException('State array does not contain IdP metadata.');
+    }
+
+    protected function resolveServiceProviderMetadata(array $state): array
+    {
+        if (
+            !empty($state[self::KEY_SERVICE_PROVIDER_METADATA]) &&
+            is_array($state[self::KEY_SERVICE_PROVIDER_METADATA])
+        ) {
+            return $state[self::KEY_SERVICE_PROVIDER_METADATA];
+        } elseif (!empty($state[self::KEY_DESTINATION]) && is_array($state[self::KEY_DESTINATION])) {
+            return $state[self::KEY_DESTINATION];
+        }
+
+        throw new UnexpectedValueException('State array does not contain SP metadata.');
+    }
+
+    /**
+     * @return array
+     */
+    public function getIdentityProviderMetadata(): array
+    {
+        return $this->identityProviderMetadata;
+    }
+
+    /**
+     * @return array
+     */
+    public function getServiceProviderMetadata(): array
+    {
+        return $this->serviceProviderMetadata;
+    }
+
+    protected function resolveClientIpAddress(array $state): ?string
+    {
+        return NetworkHelper::resolveClientIpAddress(
+            isset($state[self::KEY_ACCOUNTING][self::ACCOUNTING_KEY_CLIENT_IP_ADDRESS]) ?
+                (string)$state[self::KEY_ACCOUNTING][self::ACCOUNTING_KEY_CLIENT_IP_ADDRESS]
+                : null
+        );
+    }
+
+    /**
+     * @return string|null
+     */
+    public function getClientIpAddress(): ?string
+    {
+        return $this->clientIpAddress;
+    }
+}
diff --git a/src/Entities/AuthenticationEvent.php b/src/Entities/AuthenticationEvent.php
deleted file mode 100644
index 37b349d876ed9519be0b44b3308743e0734a69e2..0000000000000000000000000000000000000000
--- a/src/Entities/AuthenticationEvent.php
+++ /dev/null
@@ -1,22 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace SimpleSAML\Module\accounting\Entities;
-
-use SimpleSAML\Module\accounting\Entities\Bases\AbstractPayload;
-
-class AuthenticationEvent extends AbstractPayload
-{
-    protected array $state;
-
-    public function __construct(array $state)
-    {
-        $this->state = $state;
-    }
-
-    public function getState(): array
-    {
-        return $this->state;
-    }
-}
diff --git a/src/Entities/Bases/AbstractJob.php b/src/Entities/Bases/AbstractJob.php
index a41f3450c2dde05e438296ccd487cc83fd710e12..4b9380ea96efce220b7f9bad05c7fb341e7eede3 100644
--- a/src/Entities/Bases/AbstractJob.php
+++ b/src/Entities/Bases/AbstractJob.php
@@ -4,22 +4,23 @@ declare(strict_types=1);
 
 namespace SimpleSAML\Module\accounting\Entities\Bases;
 
+use DateTimeImmutable;
 use SimpleSAML\Module\accounting\Entities\Interfaces\JobInterface;
 
 abstract class AbstractJob implements JobInterface
 {
     protected AbstractPayload $payload;
     protected ?int $id;
-    protected \DateTimeImmutable $createdAt;
+    protected DateTimeImmutable $createdAt;
 
     public function __construct(
         AbstractPayload $payload,
         int $id = null,
-        \DateTimeImmutable $createdAt = null
+        DateTimeImmutable $createdAt = null
     ) {
         $this->setPayload($payload);
         $this->id = $id;
-        $this->createdAt = $createdAt ?? new \DateTimeImmutable();
+        $this->createdAt = $createdAt ?? new DateTimeImmutable();
     }
 
     public function getId(): ?int
@@ -37,7 +38,7 @@ abstract class AbstractJob implements JobInterface
         $this->payload = $payload;
     }
 
-    public function getCreatedAt(): \DateTimeImmutable
+    public function getCreatedAt(): DateTimeImmutable
     {
         return $this->createdAt;
     }
diff --git a/src/Entities/Bases/AbstractProvider.php b/src/Entities/Bases/AbstractProvider.php
new file mode 100644
index 0000000000000000000000000000000000000000..f96a449db5548180a278537bc3fbc14532c28222
--- /dev/null
+++ b/src/Entities/Bases/AbstractProvider.php
@@ -0,0 +1,56 @@
+<?php
+
+namespace SimpleSAML\Module\accounting\Entities\Bases;
+
+use SimpleSAML\Module\accounting\Exceptions\UnexpectedValueException;
+
+abstract class AbstractProvider
+{
+    public const METADATA_KEY_NAME = 'name';
+    public const METADATA_KEY_ENTITY_ID = 'entityid';
+
+    protected array $metadata;
+    protected string $entityId;
+
+    public function __construct(array $metadata)
+    {
+        $this->metadata = $metadata;
+        $this->entityId = $this->resolveEntityId();
+    }
+
+    public function getMetadata(): array
+    {
+        return $this->metadata;
+    }
+
+    public function getName(string $locale = 'en'): ?string
+    {
+        if (
+            isset($this->metadata[self::METADATA_KEY_NAME]) &&
+            is_array($this->metadata[self::METADATA_KEY_NAME]) &&
+            !empty($this->metadata[self::METADATA_KEY_NAME][$locale]) &&
+            is_string($this->metadata[self::METADATA_KEY_NAME][$locale])
+        ) {
+            return (string)$this->metadata[self::METADATA_KEY_NAME][$locale];
+        }
+
+        return null;
+    }
+
+    public function getEntityId(): string
+    {
+        return $this->entityId;
+    }
+
+    protected function resolveEntityId(): string
+    {
+        if (
+            !empty($this->metadata[self::METADATA_KEY_ENTITY_ID]) &&
+            is_string($this->metadata[self::METADATA_KEY_ENTITY_ID])
+        ) {
+            return $this->metadata[self::METADATA_KEY_ENTITY_ID];
+        }
+
+        throw new UnexpectedValueException('Provider entity metadata does not contain entity ID.');
+    }
+}
diff --git a/src/Entities/ConnectedServiceProvider.php b/src/Entities/ConnectedServiceProvider.php
new file mode 100644
index 0000000000000000000000000000000000000000..21bf0fc3a6c618c91faa71233d09e9b04d0ef9e5
--- /dev/null
+++ b/src/Entities/ConnectedServiceProvider.php
@@ -0,0 +1,79 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Module\accounting\Entities;
+
+/**
+ * Represents a Service Provider to which a user has authenticated at least once.
+ */
+class ConnectedServiceProvider
+{
+    protected ServiceProvider $serviceProvider;
+    protected int $numberOfAuthentications;
+    protected \DateTimeImmutable $lastAuthenticationAt;
+    protected \DateTimeImmutable $firstAuthenticationAt;
+    protected User $user;
+
+    /**
+     * TODO mivanci make sortable by name (or entity ID if not present), number of authns, last/first authn.
+     * @param ServiceProvider $serviceProvider
+     * @param int $numberOfAuthentications
+     * @param \DateTimeImmutable $lastAuthenticationAt
+     * @param \DateTimeImmutable $firstAuthenticationAt
+     * @param User $user
+     */
+    public function __construct(
+        ServiceProvider $serviceProvider,
+        int $numberOfAuthentications,
+        \DateTimeImmutable $lastAuthenticationAt,
+        \DateTimeImmutable $firstAuthenticationAt,
+        User $user
+    ) {
+        $this->serviceProvider = $serviceProvider;
+        $this->numberOfAuthentications = $numberOfAuthentications;
+        $this->lastAuthenticationAt = $lastAuthenticationAt;
+        $this->firstAuthenticationAt = $firstAuthenticationAt;
+        $this->user = $user;
+    }
+
+    /**
+     * @return ServiceProvider
+     */
+    public function getServiceProvider(): ServiceProvider
+    {
+        return $this->serviceProvider;
+    }
+
+    /**
+     * @return int
+     */
+    public function getNumberOfAuthentications(): int
+    {
+        return $this->numberOfAuthentications;
+    }
+
+    /**
+     * @return \DateTimeImmutable
+     */
+    public function getLastAuthenticationAt(): \DateTimeImmutable
+    {
+        return $this->lastAuthenticationAt;
+    }
+
+    /**
+     * @return \DateTimeImmutable
+     */
+    public function getFirstAuthenticationAt(): \DateTimeImmutable
+    {
+        return $this->firstAuthenticationAt;
+    }
+
+    /**
+     * @return User
+     */
+    public function getUser(): User
+    {
+        return $this->user;
+    }
+}
diff --git a/src/Entities/ConnectedServiceProvider/Bag.php b/src/Entities/ConnectedServiceProvider/Bag.php
new file mode 100644
index 0000000000000000000000000000000000000000..95b77877c762875f3cb79ccc63eaad02f7d74297
--- /dev/null
+++ b/src/Entities/ConnectedServiceProvider/Bag.php
@@ -0,0 +1,25 @@
+<?php
+
+namespace SimpleSAML\Module\accounting\Entities\ConnectedServiceProvider;
+
+use SimpleSAML\Module\accounting\Entities\ConnectedServiceProvider;
+
+class Bag
+{
+    /**
+     * @var ConnectedServiceProvider[]
+     */
+    protected array $connectedServiceProviders = [];
+
+    public function addOrReplace(ConnectedServiceProvider $connectedServiceProvider): void
+    {
+        $spEntityId = $connectedServiceProvider->getServiceProvider()->getEntityId();
+
+        $this->connectedServiceProviders[$spEntityId] = $connectedServiceProvider;
+    }
+
+    public function getAll(): array
+    {
+        return $this->connectedServiceProviders;
+    }
+}
diff --git a/src/Entities/GenericJob.php b/src/Entities/GenericJob.php
index 6577929bb3a978eae43619ace4a6aa1a69fb14bb..5031ba4a8975203e50691ae615752bf48a173631 100644
--- a/src/Entities/GenericJob.php
+++ b/src/Entities/GenericJob.php
@@ -1,5 +1,7 @@
 <?php
 
+declare(strict_types=1);
+
 namespace SimpleSAML\Module\accounting\Entities;
 
 class GenericJob extends Bases\AbstractJob
diff --git a/src/Entities/IdentityProvider.php b/src/Entities/IdentityProvider.php
new file mode 100644
index 0000000000000000000000000000000000000000..3207e918717099fcd26e8681e0855c2b54746c64
--- /dev/null
+++ b/src/Entities/IdentityProvider.php
@@ -0,0 +1,11 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Module\accounting\Entities;
+
+use SimpleSAML\Module\accounting\Entities\Bases\AbstractProvider;
+
+class IdentityProvider extends AbstractProvider
+{
+}
diff --git a/src/Entities/Interfaces/JobInterface.php b/src/Entities/Interfaces/JobInterface.php
index 7ebc01969c8676a6e51cad6aba7f87dc71d47e03..9578ea94d1c02611166db1e37ac9e0127d349b58 100644
--- a/src/Entities/Interfaces/JobInterface.php
+++ b/src/Entities/Interfaces/JobInterface.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
 
 namespace SimpleSAML\Module\accounting\Entities\Interfaces;
 
+use DateTimeImmutable;
 use SimpleSAML\Module\accounting\Entities\Bases\AbstractPayload;
 
 interface JobInterface
@@ -11,9 +12,10 @@ interface JobInterface
     public function getId(): ?int;
 
     public function getPayload(): AbstractPayload;
+
     public function setPayload(AbstractPayload $payload): void;
 
     public function getType(): string;
 
-    public function getCreatedAt(): \DateTimeImmutable;
+    public function getCreatedAt(): DateTimeImmutable;
 }
diff --git a/src/Entities/ServiceProvider.php b/src/Entities/ServiceProvider.php
new file mode 100644
index 0000000000000000000000000000000000000000..c5696c8f1f5301ac58f3057fc2163ca385534cda
--- /dev/null
+++ b/src/Entities/ServiceProvider.php
@@ -0,0 +1,11 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Module\accounting\Entities;
+
+use SimpleSAML\Module\accounting\Entities\Bases\AbstractProvider;
+
+class ServiceProvider extends AbstractProvider
+{
+}
diff --git a/src/Entities/User.php b/src/Entities/User.php
new file mode 100644
index 0000000000000000000000000000000000000000..138bc4cb5f99501a15aea7b7fe23ffb4f601d9db
--- /dev/null
+++ b/src/Entities/User.php
@@ -0,0 +1,23 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Module\accounting\Entities;
+
+class User
+{
+    protected array $attributes;
+
+    public function __construct(array $attributes)
+    {
+        $this->attributes = $attributes;
+    }
+
+    /**
+     * @return array
+     */
+    public function getAttributes(): array
+    {
+        return $this->attributes;
+    }
+}
diff --git a/src/Exceptions/Exception.php b/src/Exceptions/Exception.php
new file mode 100644
index 0000000000000000000000000000000000000000..7a0306d210c88d215f326f2b591e86df49ed191b
--- /dev/null
+++ b/src/Exceptions/Exception.php
@@ -0,0 +1,7 @@
+<?php
+
+namespace SimpleSAML\Module\accounting\Exceptions;
+
+class Exception extends \Exception
+{
+}
diff --git a/src/Helpers/ArrayHelper.php b/src/Helpers/ArrayHelper.php
new file mode 100644
index 0000000000000000000000000000000000000000..a87f81df2bac5e6653608e3cfac7e42c77064e41
--- /dev/null
+++ b/src/Helpers/ArrayHelper.php
@@ -0,0 +1,20 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Module\accounting\Helpers;
+
+class ArrayHelper
+{
+    public static function recursivelySortByKey(array &$array): void
+    {
+        /** @psalm-suppress MixedAssignment */
+        foreach ($array as &$value) {
+            if (is_array($value)) {
+                self::recursivelySortByKey($value);
+            }
+        }
+
+        ksort($array);
+    }
+}
diff --git a/src/Helpers/AttributesHelper.php b/src/Helpers/AttributesHelper.php
new file mode 100644
index 0000000000000000000000000000000000000000..8fc6806d02e6ddc926c12f2ec8a9594543009289
--- /dev/null
+++ b/src/Helpers/AttributesHelper.php
@@ -0,0 +1,35 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Module\accounting\Helpers;
+
+class AttributesHelper
+{
+    /**
+     * Map files which translate attribute names to (more) user-friendly format.
+     */
+    public const MAP_FILES_TO_NAME = ['facebook2name.php', 'linkedin2name.php', 'oid2name.php', 'openid2name.php',
+        'removeurnprefix.php', 'twitter2name.php', 'urn2name.php', 'windowslive2name.php'];
+
+    public static function getMergedAttributeMapForFiles(string $sspBaseDirectory, array $mapFiles): array
+    {
+        // This is the variable name used in map files. It is set to empty array by default, but later populated
+        // by each include of the map file.
+        $attributemap = [];
+
+        $fullAttributeMap = [];
+
+        /** @var string $mapFile */
+        foreach ($mapFiles as $mapFile) {
+            $mapFilePath = $sspBaseDirectory . 'attributemap' . DIRECTORY_SEPARATOR . $mapFile;
+            if (! file_exists($mapFilePath)) {
+                continue;
+            }
+            include $mapFilePath;
+            $fullAttributeMap = array_merge($fullAttributeMap, $attributemap);
+        }
+
+        return $fullAttributeMap;
+    }
+}
diff --git a/src/Helpers/HashHelper.php b/src/Helpers/HashHelper.php
new file mode 100644
index 0000000000000000000000000000000000000000..568b8268425511e180e9863ab82e13eb34e89a80
--- /dev/null
+++ b/src/Helpers/HashHelper.php
@@ -0,0 +1,19 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Module\accounting\Helpers;
+
+class HashHelper
+{
+    public static function getSha256(string $data): string
+    {
+        return hash('sha256', $data);
+    }
+
+    public static function getSha256ForArray(array $array): string
+    {
+        ArrayHelper::recursivelySortByKey($array);
+        return self::getSha256(serialize($array));
+    }
+}
diff --git a/src/Helpers/InstanceBuilderUsingModuleConfigurationHelper.php b/src/Helpers/InstanceBuilderUsingModuleConfigurationHelper.php
new file mode 100644
index 0000000000000000000000000000000000000000..fdecc9a5718995d12a72ad811e38eeda3101e091
--- /dev/null
+++ b/src/Helpers/InstanceBuilderUsingModuleConfigurationHelper.php
@@ -0,0 +1,60 @@
+<?php
+
+namespace SimpleSAML\Module\accounting\Helpers;
+
+use Psr\Log\LoggerInterface;
+use ReflectionMethod;
+use SimpleSAML\Module\accounting\Exceptions\Exception;
+use SimpleSAML\Module\accounting\Exceptions\UnexpectedValueException;
+use SimpleSAML\Module\accounting\Interfaces\BuildableUsingModuleConfigurationInterface;
+use SimpleSAML\Module\accounting\ModuleConfiguration;
+
+class InstanceBuilderUsingModuleConfigurationHelper
+{
+    /**
+     * @param class-string $class
+     * @param ModuleConfiguration $moduleConfiguration
+     * @param LoggerInterface $logger
+     * @param array $additionalArguments
+     * @param string $method
+     * @return BuildableUsingModuleConfigurationInterface
+     * @throws Exception
+     */
+    public static function build(
+        string $class,
+        ModuleConfiguration $moduleConfiguration,
+        LoggerInterface $logger,
+        array $additionalArguments = [],
+        string $method = BuildableUsingModuleConfigurationInterface::BUILD_METHOD
+    ): BuildableUsingModuleConfigurationInterface {
+        try {
+            self::validateClass($class);
+
+            $allArguments = array_merge([$moduleConfiguration, $logger], $additionalArguments);
+
+            $reflectionMethod = new ReflectionMethod($class, $method);
+            /** @var BuildableUsingModuleConfigurationInterface $instance */
+            $instance = $reflectionMethod->invoke(null, ...$allArguments);
+        } catch (\Throwable $exception) {
+            $message = \sprintf(
+                'Error building instance using module configuration. Error was: %s.',
+                $exception->getMessage()
+            );
+            throw new Exception($message, (int)$exception->getCode(), $exception);
+        }
+
+        return $instance;
+    }
+
+    protected static function validateClass(string $class): void
+    {
+        if (!is_subclass_of($class, BuildableUsingModuleConfigurationInterface::class)) {
+            $message = sprintf(
+                'Class \'%s\' does not implement interface \'%s\'.',
+                $class,
+                BuildableUsingModuleConfigurationInterface::class
+            );
+            throw new UnexpectedValueException($message);
+        }
+    }
+}
diff --git a/src/Helpers/NetworkHelper.php b/src/Helpers/NetworkHelper.php
new file mode 100644
index 0000000000000000000000000000000000000000..4d033e7fa97e8281e84fc20c9a77e689643dde08
--- /dev/null
+++ b/src/Helpers/NetworkHelper.php
@@ -0,0 +1,32 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Module\accounting\Helpers;
+
+class NetworkHelper
+{
+    public static function resolveClientIpAddress(string $clientIpAddress = null): ?string
+    {
+        /** @var string|null $clientIpAddress */
+        $clientIpAddress = $clientIpAddress ??
+            $_SERVER['HTTP_CLIENT_IP'] ??
+            $_SERVER['HTTP_X_FORWARDED_FOR'] ??
+            $_SERVER['REMOTE_ADDR'] ??
+            null;
+
+        if (!is_string($clientIpAddress)) {
+            return null;
+        }
+
+        $ips = explode(',', $clientIpAddress);
+
+        $ip = mb_substr(trim(array_pop($ips)), 0, 45);
+
+        if (filter_var($ip, FILTER_VALIDATE_IP)) {
+            return $ip;
+        }
+
+        return null;
+    }
+}
diff --git a/src/Http/Controllers/Admin/Configuration.php b/src/Http/Controllers/Admin/Configuration.php
new file mode 100644
index 0000000000000000000000000000000000000000..7c71d1b47af571cfa4992dfb2da7c1fdb56fbe35
--- /dev/null
+++ b/src/Http/Controllers/Admin/Configuration.php
@@ -0,0 +1,77 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Module\accounting\Http\Controllers\Admin;
+
+use Exception;
+use Psr\Log\LoggerInterface;
+use SimpleSAML\Configuration as SspConfiguration;
+use SimpleSAML\Module\accounting\ModuleConfiguration;
+use SimpleSAML\Module\accounting\Stores\Builders\JobsStoreBuilder;
+use SimpleSAML\Session;
+use SimpleSAML\Utils;
+use SimpleSAML\Utils\Auth;
+use SimpleSAML\XHTML\Template;
+use Symfony\Component\HttpFoundation\Request;
+use Throwable;
+
+class Configuration
+{
+    protected SspConfiguration $sspConfiguration;
+    protected Session $session;
+    protected LoggerInterface $logger;
+    protected Utils\Auth $sspAuthUtils;
+
+    public function __construct(
+        SspConfiguration $sspConfiguration,
+        Session $session,
+        LoggerInterface $logger,
+        Utils\Auth $sspAuthUtils = null
+    ) {
+        $this->sspConfiguration = $sspConfiguration;
+        $this->session = $session;
+        $this->logger = $logger;
+        $this->sspAuthUtils = $sspAuthUtils ?? new Utils\Auth();
+
+        $this->sspAuthUtils->requireAdmin();
+    }
+
+    /**
+     * @param Request $request
+     * @return Template
+     * @throws Exception
+     */
+    public function status(Request $request): Template
+    {
+        // Instantiate ModuleConfiguration here (instead in constructor) so we can check for validation errors.
+        $moduleConfiguration = null;
+        $configurationValidationErrors = null;
+        $jobsStore = null;
+
+        try {
+            $moduleConfiguration = new ModuleConfiguration();
+
+            if (
+                $moduleConfiguration->getAccountingProcessingType() ===
+                ModuleConfiguration\AccountingProcessingType::VALUE_ASYNCHRONOUS
+            ) {
+                $jobsStore = (new JobsStoreBuilder($moduleConfiguration, $this->logger))
+                    ->build($moduleConfiguration->getJobsStoreClass());
+            }
+        } catch (Throwable $exception) {
+            $configurationValidationErrors = $exception->getMessage();
+        }
+
+        $templateData = [
+            'moduleConfiguration' => $moduleConfiguration,
+            'configurationValidationErrors' => $configurationValidationErrors,
+            'jobsStore' => $jobsStore,
+        ];
+
+        $template = new Template($this->sspConfiguration, 'accounting:admin/configuration/status.twig');
+
+        $template->data = $templateData;
+        return $template;
+    }
+}
diff --git a/src/Http/Controllers/Test.php b/src/Http/Controllers/Test.php
new file mode 100644
index 0000000000000000000000000000000000000000..26878a8d441f26a44703165e61ad1f1a83469545
--- /dev/null
+++ b/src/Http/Controllers/Test.php
@@ -0,0 +1,143 @@
+<?php
+// phpcs:ignoreFile
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Module\accounting\Http\Controllers;
+
+use Exception;
+use Psr\Log\LoggerInterface;
+use SimpleSAML\Configuration as SspConfiguration;
+use SimpleSAML\Locale\Translate;
+use SimpleSAML\Module\accounting\Entities\Authentication\Event;
+use SimpleSAML\Module\accounting\Entities\Authentication\State;
+use SimpleSAML\Module\accounting\ModuleConfiguration;
+use SimpleSAML\Module\accounting\Stores\Builders\JobsStoreBuilder;
+use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store;
+use SimpleSAML\Module\accounting\Trackers\Authentication\DoctrineDbal\Versioned\Tracker;
+use SimpleSAML\Module\accounting\Trackers\Builders\AuthenticationDataTrackerBuilder;
+use SimpleSAML\Session;
+use SimpleSAML\XHTML\Template;
+use Symfony\Component\HttpFoundation\Request;
+
+/**
+ * TODO mivanci delete this file
+ * @psalm-suppress all
+ */
+class Test
+{
+    protected SspConfiguration $sspConfiguration;
+    protected Session $session;
+    protected ModuleConfiguration $moduleConfiguration;
+    protected LoggerInterface $logger;
+
+    /**
+     * @param SspConfiguration $sspConfiguration
+     * @param Session $session The current user session.
+     * @param ModuleConfiguration $moduleConfiguration
+     * @param LoggerInterface $logger
+     */
+    public function __construct(
+        SspConfiguration $sspConfiguration,
+        Session $session,
+        ModuleConfiguration $moduleConfiguration,
+        LoggerInterface $logger
+    ) {
+        $this->sspConfiguration = $sspConfiguration;
+        $this->session = $session;
+        $this->moduleConfiguration = $moduleConfiguration;
+        $this->logger = $logger;
+    }
+
+    /**
+     * @param Request $request
+     * @return Template
+     * @throws Exception
+     */
+    public function setup(Request $request): Template
+    {
+        $template = new Template($this->sspConfiguration, 'accounting:test.twig');
+
+        $jobsStore = (new JobsStoreBuilder($this->moduleConfiguration, $this->logger))
+            ->build($this->moduleConfiguration->getJobsStoreClass());
+
+        // TODO refactor to tracker builder
+        $dataStoreConnectionKey = $this->moduleConfiguration->getClassConnectionKey(Tracker::class);
+        $dataStore = Store::build($this->moduleConfiguration, $this->logger, $dataStoreConnectionKey);
+
+        $jobsStoreNeedsSetup = $jobsStore->needsSetup();
+        $jobsStoreSetupRan = false;
+
+        $dataStoreNeedsSetup = $dataStore->needsSetup();
+        $dataStoreSetupRan = false;
+
+
+        if ($jobsStoreNeedsSetup && $request->query->has('setup')) {
+            $this->logger->info('Jobs Store setup ran.');
+            $jobsStore->runSetup();
+            $jobsStoreSetupRan = true;
+        }
+
+        if ($dataStoreNeedsSetup && $request->query->has('setup')) {
+            $this->logger->info('Data Store setup ran.');
+            $dataStore->runSetup();
+            $dataStoreSetupRan = true;
+        }
+
+        $sampleStateArray = ['Responder'=>[0=>'\\SimpleSAML\\Module\\saml\\IdP\\SAML2',1=>'sendResponse',],'\\SimpleSAML\\Auth\\State.exceptionFunc'=>[0=>'\\SimpleSAML\\Module\\saml\\IdP\\SAML2',1=>'handleAuthError',],'\\SimpleSAML\\Auth\\State.restartURL'=>'https://localhost.someone.from.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/saml2/idp/SSOService.php?spentityid=https%3A%2F%2Fpc-example.org.hr%3A9074%2Fsimplesamlphp%2Fsimplesamlphp-2-beta-git%2Fmodule.php%2Fsaml%2Fsp%2Fmetadata.php%2Fdefault-sp&RelayState=https%3A%2F%2Flocalhost.someone.from.hr%3A9074%2Fsimplesamlphp%2Fsimplesamlphp-2-beta-git%2Fmodule.php%2Fadmin%2Ftest%2Fdefault-sp&cookieTime=1660912195','SPMetadata'=>['SingleLogoutService'=>[0=>['Binding'=>'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect','Location'=>'https://pc-example.org.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/module.php/saml/sp/singleLogoutService/default-sp',],],'AssertionConsumerService'=>[0=>['Binding'=>'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST','Location'=>'https://pc-example.org.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/module.php/saml/sp/assertionConsumerService/default-sp','index'=>0,],1=>['Binding'=>'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact','Location'=>'https://pc-example.org.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/module.php/saml/sp/assertionConsumerService/default-sp','index'=>1,],],'contacts'=>[0=>['emailAddress'=>'example@org.hr','givenName'=>'MarkoIvančić','contactType'=>'technical',],],'entityid'=>'https://pc-example.org.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/module.php/saml/sp/metadata.php/default-sp','metadata-index'=>'https://pc-example.org.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/module.php/saml/sp/metadata.php/default-sp','metadata-set'=>'saml20-sp-remote',],'saml:RelayState'=>'https://localhost.someone.from.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/module.php/admin/test/default-sp','saml:RequestId'=>null,'saml:IDPList'=>[],'saml:ProxyCount'=>null,'saml:RequesterID'=>null,'ForceAuthn'=>false,'isPassive'=>false,'saml:ConsumerURL'=>'https://pc-example.org.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/module.php/saml/sp/assertionConsumerService/default-sp','saml:Binding'=>'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST','saml:NameIDFormat'=>null,'saml:AllowCreate'=>true,'saml:Extensions'=>null,'saml:AuthnRequestReceivedAt'=>1660912195.505402,'saml:RequestedAuthnContext'=>null,'core:IdP'=>'saml2:https://localhost.someone.from.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/saml2/idp/metadata.php','core:SP'=>'https://pc-example.org.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/module.php/saml/sp/metadata.php/default-sp','IdPMetadata'=>['host'=>'localhost.someone.from.hr','privatekey'=>'key.pem','certificate'=>'cert.pem','auth'=>'example-userpass','attributes.NameFormat'=>'urn:oasis:names:tc:SAML:2.0:attrname-format:uri','authproc'=>[100=>['class'=>'core:AttributeMap',0=>'name2oid',],],'entityid'=>'https://localhost.someone.from.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/saml2/idp/metadata.php','metadata-index'=>'https://localhost.someone.from.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/saml2/idp/metadata.php','metadata-set'=>'saml20-idp-hosted',],'ReturnCallback'=>[0=>'\\SimpleSAML\\IdP',1=>'postAuth',],'Attributes'=>['hrEduPersonUniqueID'=>[0=>'testuser@primjer2.hr',],'urn:oid:0.9.2342.19200300.100.1.1'=>[0=>'testuser',],'urn:oid:2.5.4.3'=>[0=>'TestNameTestSurname',],'urn:oid:2.5.4.4'=>[0=>'TestSurname',1=>'TestSurname2',],'urn:oid:2.5.4.42'=>[0=>'TestName',],'urn:oid:0.9.2342.19200300.100.1.3'=>[0=>'testusermail@primjer.hr',1=>'testusermail2@primjer.hr',],'urn:oid:2.5.4.20'=>[0=>'123',],'hrEduPersonExtensionNumber'=>[0=>'123',],'urn:oid:0.9.2342.19200300.100.1.41'=>[0=>'123',],'urn:oid:2.5.4.23'=>[0=>'123',],'hrEduPersonUniqueNumber'=>[0=>'LOCAL_NO:100',1=>'OIB:00861590360',],'hrEduPersonOIB'=>[0=>'00861590360',],'hrEduPersonDateOfBirth'=>[0=>'20200101',],'hrEduPersonGender'=>[0=>'9',],'urn:oid:0.9.2342.19200300.100.1.60'=>[0=>'987654321',],'urn:oid:2.5.4.36'=>[0=>'cert-value',],'urn:oid:1.3.6.1.4.1.250.1.57'=>[0=>'http://www.org.hr/~ivekHomePage',],'hrEduPersonProfessionalStatus'=>[0=>'VSS',],'hrEduPersonAcademicStatus'=>[0=>'redovitiprofesor',],'hrEduPersonScienceArea'=>[0=>'sociologija',],'hrEduPersonAffiliation'=>[0=>'djelatnik',1=>'student',],'hrEduPersonPrimaryAffiliation'=>[0=>'djelatnik',],'hrEduPersonStudentCategory'=>[0=>'redovitistudent:diplomskisveučilišnistudij',],'hrEduPersonExpireDate'=>[0=>'20211231',],'hrEduPersonTitle'=>[0=>'rektor',],'hrEduPersonRole'=>[0=>'ICTkoordinator',1=>'ISVUkoordinator',],'hrEduPersonStaffCategory'=>[0=>'nastavnoosoblje',1=>'istraživači',],'hrEduPersonGroupMember'=>[0=>'grupa1',1=>'grupa2',],'urn:oid:2.5.4.10'=>[0=>'Testnaustanova',],'hrEduPersonHomeOrg'=>[0=>'primjer.hr',],'urn:oid:2.5.4.11'=>[0=>'Testnaorgjedinica',],'urn:oid:0.9.2342.19200300.100.1.6'=>[0=>'123',],'urn:oid:2.5.4.16'=>[0=>'Testnaustanova,Baštijanova52A,HR-10000Zagreb',],'urn:oid:2.5.4.7'=>[0=>'Zagreb',],'urn:oid:2.5.4.17'=>[0=>'HR-10000',],'urn:oid:2.5.4.9'=>[0=>'Baštijanova52A',],'urn:oid:0.9.2342.19200300.100.1.39'=>[0=>'Testnaadresa52A,HR-10000,Zagreb',],'urn:oid:0.9.2342.19200300.100.1.20'=>[0=>'123',],'hrEduPersonCommURI'=>[0=>'123',],'hrEduPersonPrivacy'=>[0=>'street',1=>'postalCode',],'hrEduPersonPersistentID'=>[0=>'da4294fb4e5746d57ab6ad88d2daf275',],'urn:oid:2.16.840.1.113730.3.1.241'=>[0=>'testname123',],'urn:oid:1.3.6.1.4.1.25178.1.2.12'=>[0=>'skype:pepe.perez',],'hrEduPersonCardNum'=>[0=>'123',],'updatedAt'=>[0=>'123456789',],'urn:oid:1.3.6.1.4.1.5923.1.1.1.7'=>[0=>'urn:example:oidc:manage:client',],],'Authority'=>'example-userpass','AuthnInstant'=>1660911943,'Expire'=>1660940743,'ReturnCall'=>[0=>'\\SimpleSAML\\IdP',1=>'postAuthProc',],'Destination'=>['SingleLogoutService'=>[0=>['Binding'=>'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect','Location'=>'https://pc-example.org.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/module.php/saml/sp/singleLogoutService/default-sp',],],'AssertionConsumerService'=>[0=>['Binding'=>'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST','Location'=>'https://pc-example.org.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/module.php/saml/sp/assertionConsumerService/default-sp','index'=>0,],1=>['Binding'=>'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact','Location'=>'https://pc-example.org.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/module.php/saml/sp/assertionConsumerService/default-sp','index'=>1,],],'contacts'=>[0=>['emailAddress'=>'example@org.hr','givenName'=>'MarkoIvančić','contactType'=>'technical',],],'entityid'=>'https://pc-example.org.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/module.php/saml/sp/metadata.php/default-sp','metadata-index'=>'https://pc-example.org.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/module.php/saml/sp/metadata.php/default-sp','metadata-set'=>'saml20-sp-remote',],'Source'=>['host'=>'localhost.someone.from.hr','privatekey'=>'key.pem','certificate'=>'cert.pem','auth'=>'example-userpass','attributes.NameFormat'=>'urn:oasis:names:tc:SAML:2.0:attrname-format:uri','authproc'=>[100=>['class'=>'core:AttributeMap',0=>'name2oid',],],'entityid'=>'https://localhost.someone.from.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/saml2/idp/metadata.php','metadata-index'=>'https://localhost.someone.from.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/saml2/idp/metadata.php','metadata-set'=>'saml20-idp-hosted',],'\\SimpleSAML\\Auth\\ProcessingChain.filters'=>[],];
+        $state = new State($sampleStateArray);
+
+        $authenticationEvent = new Event($state);
+
+        //$dataStore->persist($authenticationEvent);
+
+        $data = [
+            'jobs_store' => $this->moduleConfiguration->getJobsStoreClass(),
+            'jobs_store_needs_setup' => $jobsStoreNeedsSetup ? 'yes' : 'no',
+            'jobs_store_setup_ran' => $jobsStoreSetupRan ? 'yes' : 'no',
+
+            'data_store' => get_class($dataStore),
+            'data_store_needs_setup' => $dataStoreNeedsSetup ? 'yes' : 'no',
+            'data_store_setup_ran' => $dataStoreSetupRan ? 'yes' : 'no',
+        ];
+
+        die(var_dump($data));
+
+        $template->data = $data;
+        return $template;
+    }
+
+    public function jobRunner():void
+    {
+        $jobsStore = (new JobsStoreBuilder($this->moduleConfiguration, $this->logger))
+            ->build($this->moduleConfiguration->getJobsStoreClass());
+
+
+        $trackerBuilder = new AuthenticationDataTrackerBuilder($this->moduleConfiguration, $this->logger);
+
+        $configuredTrackers = array_merge(
+            [$this->moduleConfiguration->getDefaultDataTrackerAndProviderClass()],
+            $this->moduleConfiguration->getAdditionalTrackers()
+        );
+
+        $start = new \DateTimeImmutable();
+        $data = [
+            'jobs_processed' => 0,
+            'start_time' => $start->format('Y-m-d H:i:s.u'),
+        ];
+
+        /** @var Event\Job $job */
+        while (($job = $jobsStore->dequeue(Event\Job::class)) !== null) {
+            foreach ($configuredTrackers as $tracker) {
+                ($trackerBuilder->build($tracker))->process($job->getPayload());
+                $data['jobs_processed']++;
+            }
+        }
+
+        $end = new \DateTimeImmutable();
+        $data['end_time'] = $end->format('Y-m-d H:i:s.u');
+        $data['duration'] = $end->diff($start)->format('%s seconds, %f microseconds');
+
+        die(var_dump($data));
+    }
+}
diff --git a/src/Http/Controllers/User/Profile.php b/src/Http/Controllers/User/Profile.php
new file mode 100644
index 0000000000000000000000000000000000000000..dfd427db5834e839692b5b17dd34bef6de43aa85
--- /dev/null
+++ b/src/Http/Controllers/User/Profile.php
@@ -0,0 +1,165 @@
+<?php
+
+namespace SimpleSAML\Module\accounting\Http\Controllers\User;
+
+use Psr\Log\LoggerInterface;
+use SimpleSAML\Auth\Simple;
+use SimpleSAML\Configuration as SspConfiguration;
+use SimpleSAML\HTTP\RunnableResponse;
+use SimpleSAML\Module;
+use SimpleSAML\Module\accounting\ModuleConfiguration;
+use SimpleSAML\Module\accounting\Providers\Builders\AuthenticationDataProviderBuilder;
+use SimpleSAML\Session;
+use SimpleSAML\XHTML\Template;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+
+/**
+ * @psalm-suppress all TODO mivanci remove this psalm suppress after testing
+ */
+class Profile
+{
+    protected ModuleConfiguration $moduleConfiguration;
+    protected SspConfiguration $sspConfiguration;
+    protected Session $session;
+    protected LoggerInterface $logger;
+    protected string $defaultAuthenticationSource;
+    protected Simple $authSimple;
+    protected AuthenticationDataProviderBuilder $authenticationDataProviderBuilder;
+
+    /**
+     * @param ModuleConfiguration $moduleConfiguration
+     * @param SspConfiguration $sspConfiguration
+     * @param Session $session The current user session.
+     * @param LoggerInterface $logger
+     * @param Simple|null $authSimple
+     * @param AuthenticationDataProviderBuilder|null $authenticationDataProviderBuilder
+     */
+    public function __construct(
+        ModuleConfiguration $moduleConfiguration,
+        SspConfiguration $sspConfiguration,
+        Session $session,
+        LoggerInterface $logger,
+        Simple $authSimple = null,
+        AuthenticationDataProviderBuilder $authenticationDataProviderBuilder = null
+    ) {
+        $this->moduleConfiguration = $moduleConfiguration;
+        $this->sspConfiguration = $sspConfiguration;
+        $this->session = $session;
+        $this->logger = $logger;
+
+        $this->defaultAuthenticationSource = $moduleConfiguration->getDefaultAuthenticationSource();
+        $this->authSimple = $authSimple ?? new Simple($this->defaultAuthenticationSource, $sspConfiguration, $session);
+
+        $this->authenticationDataProviderBuilder = $authenticationDataProviderBuilder ??
+            new AuthenticationDataProviderBuilder($this->moduleConfiguration, $this->logger);
+
+        // Make sure the end user is authenticated.
+        $this->authSimple->requireAuth();
+    }
+
+    public function personalData(Request $request): Response
+    {
+        $normalizedAttributes = [];
+
+        $toNameAttributeMap = $this->prepareToNameAttributeMap();
+
+        /**
+         * @var string $name
+         * @var string[] $value
+         */
+        foreach ($this->authSimple->getAttributes() as $name => $value) {
+            // Convert attribute names to user-friendly names.
+            if (array_key_exists($name, $toNameAttributeMap)) {
+                $name = $toNameAttributeMap[$name];
+            }
+            $normalizedAttributes[$name] = implode('; ', $value);
+        }
+
+        die(var_dump($normalizedAttributes));
+        $template = new Template($this->sspConfiguration, 'accounting:user/personal-data.twig');
+        $template->data = compact('normalizedAttributes');
+        return $template;
+    }
+
+    public function connectedOrganizations(Request $request): Template
+    {
+        // TODO mivanci make sure to use slave connection for data provider if available
+        $this->authSimple->requireAuth();
+        $attributes = $this->authSimple->getAttributes();
+        $idAttributeName = $this->moduleConfiguration->getUserIdAttributeName();
+
+        if (empty($attributes[$idAttributeName]) || !is_array($attributes[$idAttributeName])) {
+            $message = sprintf('No identifier %s present in user attributes.', $idAttributeName);
+            throw new Module\accounting\Exceptions\Exception($message);
+        }
+
+        $userIdentifier = (string)reset($attributes[$idAttributeName]);
+
+        $authenticationDataProvider = $this->authenticationDataProviderBuilder
+            ->build($this->moduleConfiguration->getDefaultDataTrackerAndProviderClass());
+
+        $this->removeDebugDisplayLimits();
+        var_dump($authenticationDataProvider->getConnectedServiceProviders($userIdentifier));
+        die();
+        $data = [];
+        $template = new Template($this->sspConfiguration, 'accounting:user/personal-data.twig');
+        $template->data = compact('data');
+        return $template;
+    }
+
+    public function activity(): Template
+    {
+        $this->authSimple->requireAuth();
+        $attributes = $this->authSimple->getAttributes();
+        $idAttributeName = $this->moduleConfiguration->getUserIdAttributeName();
+
+        if (empty($attributes[$idAttributeName]) || !is_array($attributes[$idAttributeName])) {
+            $message = sprintf('No identifier %s present in user attributes.', $idAttributeName);
+            throw new Module\accounting\Exceptions\Exception($message);
+        }
+
+        $userIdentifier = (string)reset($attributes[$idAttributeName]);
+
+        $authenticationDataProvider = $this->authenticationDataProviderBuilder
+            ->build($this->moduleConfiguration->getDefaultDataTrackerAndProviderClass());
+
+        $this->removeDebugDisplayLimits();
+        var_dump($authenticationDataProvider->getActivity($userIdentifier));
+        die();
+        $data = [];
+        $template = new Template($this->sspConfiguration, 'accounting:user/personal-data.twig');
+        $template->data = compact('data');
+        return $template;
+    }
+
+    public function logout(): Response
+    {
+        // TODO mivanci make logout button available using HTTP POST
+        return new RunnableResponse([$this->authSimple, 'logout'], [$this->getLogoutUrl()]);
+    }
+
+    protected function getLogoutUrl(): string
+    {
+        return $this->sspConfiguration->getBasePath() . 'logout.php';
+    }
+
+    /**
+     * Load all attribute map files which translate attribute names to user-friendly name format.
+     */
+    protected function prepareToNameAttributeMap(): array
+    {
+        return Module\accounting\Helpers\AttributesHelper::getMergedAttributeMapForFiles(
+            $this->sspConfiguration->getBaseDir(),
+            Module\accounting\Helpers\AttributesHelper::MAP_FILES_TO_NAME
+        );
+    }
+
+    /** TODO mivanci remove after debugging */
+    protected function removeDebugDisplayLimits(): void
+    {
+        ini_set('xdebug.var_display_max_depth', -1);
+        ini_set('xdebug.var_display_max_children', -1);
+        ini_set('xdebug.var_display_max_data', -1);
+    }
+}
diff --git a/src/Interfaces/BuildableUsingModuleConfigurationInterface.php b/src/Interfaces/BuildableUsingModuleConfigurationInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..c1a4aedc646a07c1abbd697f4b25e493c8480647
--- /dev/null
+++ b/src/Interfaces/BuildableUsingModuleConfigurationInterface.php
@@ -0,0 +1,13 @@
+<?php
+
+namespace SimpleSAML\Module\accounting\Interfaces;
+
+use Psr\Log\LoggerInterface;
+use SimpleSAML\Module\accounting\ModuleConfiguration;
+
+interface BuildableUsingModuleConfigurationInterface
+{
+    public const BUILD_METHOD = 'build';
+
+    public static function build(ModuleConfiguration $moduleConfiguration, LoggerInterface $logger): self;
+}
diff --git a/src/Interfaces/SetupableInterface.php b/src/Interfaces/SetupableInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..da036e145c3d1ea6d3af16f6d92aa60ad77cd70b
--- /dev/null
+++ b/src/Interfaces/SetupableInterface.php
@@ -0,0 +1,9 @@
+<?php
+
+namespace SimpleSAML\Module\accounting\Interfaces;
+
+interface SetupableInterface
+{
+    public function needsSetup(): bool;
+    public function runSetup(): void;
+}
diff --git a/src/ModuleConfiguration.php b/src/ModuleConfiguration.php
index 1dcbe138067458f6c3481fd5e4cef86cc91aa036..6f4cce1671cd10baae8d89beb12bea8e5a1a1a89 100644
--- a/src/ModuleConfiguration.php
+++ b/src/ModuleConfiguration.php
@@ -7,6 +7,12 @@ namespace SimpleSAML\Module\accounting;
 use Exception;
 use SimpleSAML\Configuration;
 use SimpleSAML\Module\accounting\Exceptions\InvalidConfigurationException;
+use SimpleSAML\Module\accounting\ModuleConfiguration\AccountingProcessingType;
+use SimpleSAML\Module\accounting\ModuleConfiguration\ConnectionType;
+use SimpleSAML\Module\accounting\Providers\Interfaces\AuthenticationDataProviderInterface;
+use SimpleSAML\Module\accounting\Stores\Interfaces\JobsStoreInterface;
+use SimpleSAML\Module\accounting\Trackers\Interfaces\AuthenticationDataTrackerInterface;
+use Throwable;
 
 class ModuleConfiguration
 {
@@ -15,11 +21,14 @@ class ModuleConfiguration
      */
     public const FILE_NAME = 'module_accounting.php';
 
-    public const OPTION_USER_ID_ATTRIBUTE = 'user_id_attribute';
+    public const OPTION_USER_ID_ATTRIBUTE_NAME = 'user_id_attribute_name';
+    public const OPTION_DEFAULT_AUTHENTICATION_SOURCE = 'default_authentication_source';
     public const OPTION_ACCOUNTING_PROCESSING_TYPE = 'accounting_processing_type';
     public const OPTION_JOBS_STORE = 'jobs_store';
-    public const OPTION_ALL_STORE_CONNECTIONS_AND_PARAMETERS = 'all_store_connection_and_parameters';
-    public const OPTION_STORE_TO_CONNECTION_KEY_MAP = 'store_to_connection_key_map';
+    public const OPTION_DEFAULT_DATA_TRACKER_AND_PROVIDER = 'default_data_tracker_and_provider';
+    public const OPTION_ADDITIONAL_TRACKERS = 'additional_trackers';
+    public const OPTION_CONNECTIONS_AND_PARAMETERS = 'connections_and_parameters';
+    public const OPTION_CLASS_TO_CONNECTION_MAP = 'class_to_connection_map';
 
     /**
      * Contains configuration from module configuration file.
@@ -34,6 +43,48 @@ class ModuleConfiguration
         $fileName = $fileName ?? self::FILE_NAME;
 
         $this->configuration = Configuration::getConfig($fileName);
+
+        $this->validate();
+    }
+
+    public function getAccountingProcessingType(): string
+    {
+        return $this->getConfiguration()->getString(self::OPTION_ACCOUNTING_PROCESSING_TYPE);
+    }
+
+    /**
+     * Get underlying SimpleSAMLphp Configuration instance.
+     *
+     * @return Configuration
+     */
+    public function getConfiguration(): Configuration
+    {
+        return $this->configuration;
+    }
+
+    public function getJobsStoreClass(): string
+    {
+        return $this->getConfiguration()->getString(self::OPTION_JOBS_STORE);
+    }
+
+    public function getDefaultDataTrackerAndProviderClass(): string
+    {
+        return $this->getConfiguration()->getString(self::OPTION_DEFAULT_DATA_TRACKER_AND_PROVIDER);
+    }
+
+    public function getConnectionsAndParameters(): array
+    {
+        return $this->getConfiguration()->getArray(self::OPTION_CONNECTIONS_AND_PARAMETERS);
+    }
+
+    public function getAdditionalTrackers(): array
+    {
+        return $this->getConfiguration()->getArray(self::OPTION_ADDITIONAL_TRACKERS);
+    }
+
+    public function getClassToConnectionsMap(): array
+    {
+        return $this->getConfiguration()->getArray(self::OPTION_CLASS_TO_CONNECTION_MAP);
     }
 
     /**
@@ -44,7 +95,7 @@ class ModuleConfiguration
      */
     public function get(string $option)
     {
-        if (! $this->configuration->hasValue($option)) {
+        if (!$this->configuration->hasValue($option)) {
             throw new InvalidConfigurationException(
                 sprintf('Configuration option does not exist (%s).', $option)
             );
@@ -53,50 +104,85 @@ class ModuleConfiguration
         return $this->configuration->getValue($option);
     }
 
-    /**
-     * Get underlying SimpleSAMLphp Configuration instance.
-     *
-     * @return Configuration
-     */
-    public function getConfiguration(): Configuration
+    public function getUserIdAttributeName(): string
     {
-        return $this->configuration;
+        return $this->getConfiguration()->getString(self::OPTION_USER_ID_ATTRIBUTE_NAME);
+    }
+
+    public function getDefaultAuthenticationSource(): string
+    {
+        return $this->getConfiguration()->getString(self::OPTION_DEFAULT_AUTHENTICATION_SOURCE);
     }
 
-    public function getStoreConnection(string $store): string
+    public function getClassConnectionKey(string $class, string $connectionType = ConnectionType::MASTER): string
     {
-        $connectionMap = $this->getStoreToConnectionMap();
+        $this->validateConnectionType($connectionType);
+
+        $connections = $this->getClassToConnectionsMap();
+
+        if (!isset($connections[$class])) {
+            throw new InvalidConfigurationException(sprintf('Connection for class \'%s\' not set.', $class));
+        }
+
+        $connectionValue = $connections[$class];
+
+        // If the key is defined directly, return that.
+        if (is_string($connectionValue)) {
+            return $connectionValue;
+        }
 
-        if (! isset($connectionMap[$store])) {
+        if (!is_array($connectionValue)) {
             throw new InvalidConfigurationException(
-                sprintf('Connection for store %s is not set.', $store)
+                sprintf('Connection for class \'%s\' is not defined as string nor as array.', $class)
             );
         }
 
-        return (string) $connectionMap[$store];
-    }
+        if (!isset($connectionValue[ConnectionType::MASTER])) {
+            $message = sprintf(
+                'Connection for class \'%s\' is defined as array, however no master connection key is set.',
+                $class
+            );
+            throw new InvalidConfigurationException($message);
+        }
 
-    public function getStoreToConnectionMap(): array
-    {
-        return $this->getConfiguration()->getArray(self::OPTION_STORE_TO_CONNECTION_KEY_MAP);
+        // By default, use master connection key.
+        $connectionKey = (string)$connectionValue[ConnectionType::MASTER];
+
+        if ($connectionType === ConnectionType::MASTER || (! isset($connectionValue[ConnectionType::SLAVE]))) {
+            return $connectionKey;
+        }
+
+        if (is_array($connectionValue[ConnectionType::SLAVE])) {
+            // Return random slave connection key.
+            $slaveConnections = $connectionValue[ConnectionType::SLAVE];
+            $connectionKey = (string)$slaveConnections[array_rand($slaveConnections)];
+        }
+
+        return $connectionKey;
     }
 
-    public function getAllStoreConnectionsAndParameters(): array
+    /**
+     * @throws InvalidConfigurationException
+     */
+    public function getClassConnectionParameters(string $class, string $connectionType = ConnectionType::MASTER): array
     {
-        return $this->getConfiguration()->getArray(self::OPTION_ALL_STORE_CONNECTIONS_AND_PARAMETERS);
+        return $this->getConnectionParameters($this->getClassConnectionKey($class, $connectionType));
     }
 
-    public function getStoreConnectionParameters(string $connection): array
+    /**
+     * @throws InvalidConfigurationException
+     */
+    public function getConnectionParameters(string $connectionKey): array
     {
-        $connections = $this->getAllStoreConnectionsAndParameters();
+        $connections = $this->getConnectionsAndParameters();
 
-        if (! isset($connections[$connection]) || ! is_array($connections[$connection])) {
+        if (!isset($connections[$connectionKey]) || !is_array($connections[$connectionKey])) {
             throw new InvalidConfigurationException(
-                sprintf('Settings for connection %s not set', $connection)
+                sprintf('Connection parameters not set for key \'%s\'.', $connectionKey)
             );
         }
 
-        return $connections[$connection];
+        return $connections[$connectionKey];
     }
 
     public function getModuleSourceDirectory(): string
@@ -108,4 +194,186 @@ class ModuleConfiguration
     {
         return dirname(__DIR__);
     }
+
+    /**
+     * @throws InvalidConfigurationException
+     */
+    protected function validate(): void
+    {
+        $errors = [];
+
+        try {
+            $this->validateAccountingProcessingType();
+        } catch (Throwable $exception) {
+            $errors[] = $exception->getMessage();
+        }
+
+        // If accounting processing type is async, validate jobs store class and connection.
+        if ($this->getAccountingProcessingType() === AccountingProcessingType::VALUE_ASYNCHRONOUS) {
+            try {
+                $this->validateJobsStoreClass();
+            } catch (Throwable $exception) {
+                $errors[] = $exception->getMessage();
+            }
+        }
+
+        try {
+            $this->validateDefaultDataTrackerAndProvider();
+        } catch (Throwable $exception) {
+            $errors[] = $exception->getMessage();
+        }
+
+        try {
+            $this->validateAdditionalTrackers();
+        } catch (Throwable $exception) {
+            $errors[] = $exception->getMessage();
+        }
+
+        try {
+            $this->validateClassToConnectionMap();
+        } catch (Throwable $exception) {
+            $errors[] = $exception->getMessage();
+        }
+
+        if (!empty($errors)) {
+            $message = sprintf('Module configuration validation failed with errors: %s', implode(' ', $errors));
+            throw new InvalidConfigurationException($message);
+        }
+    }
+
+    /**
+     * @throws InvalidConfigurationException
+     */
+    protected function validateDefaultDataTrackerAndProvider(): void
+    {
+        $errors = [];
+
+        // Default data tracker and provider must implement proper interfaces.
+        $defaultDataTrackerAndProviderClass = $this->getDefaultDataTrackerAndProviderClass();
+
+        if (!is_subclass_of($defaultDataTrackerAndProviderClass, AuthenticationDataTrackerInterface::class)) {
+            $errors[] = sprintf(
+                'Default authentication data tracker and provider class \'%s\' does not implement interface \'%s\'.',
+                $defaultDataTrackerAndProviderClass,
+                AuthenticationDataTrackerInterface::class
+            );
+        }
+
+        if (!is_subclass_of($defaultDataTrackerAndProviderClass, AuthenticationDataProviderInterface::class)) {
+            $errors[] = sprintf(
+                'Default authentication data tracker and provider class \'%s\' does not implement interface \'%s\'.',
+                $defaultDataTrackerAndProviderClass,
+                AuthenticationDataProviderInterface::class
+            );
+        }
+
+        if (!empty($errors)) {
+            throw new InvalidConfigurationException(implode(' ', $errors));
+        }
+    }
+
+    /**
+     * @throws InvalidConfigurationException
+     */
+    protected function validateAdditionalTrackers(): void
+    {
+        $errors = [];
+
+        // Validate additional trackers
+        /**
+         * @var string $trackerClass
+         */
+        foreach ($this->getAdditionalTrackers() as $trackerClass) {
+            /** @psalm-suppress DocblockTypeContradiction */
+            if (!is_string($trackerClass)) {
+                $errors[] = 'Additional trackers array must contain class strings only.';
+            } elseif (!is_subclass_of($trackerClass, AuthenticationDataTrackerInterface::class)) {
+                $errors[] = sprintf(
+                    'Tracker class \'%s\' does not implement interface \'%s\'.',
+                    $trackerClass,
+                    AuthenticationDataTrackerInterface::class
+                );
+            }
+        }
+
+        if (!empty($errors)) {
+            throw new InvalidConfigurationException(implode(' ', $errors));
+        }
+    }
+
+    /**
+     * @throws InvalidConfigurationException
+     */
+    protected function validateConnectionType(string $connectionType): void
+    {
+        if (!in_array($connectionType, ConnectionType::VALID_OPTIONS)) {
+            $message = sprintf(
+                'Connection type \'%s\' is not valid. Possible values are: %s.',
+                $connectionType,
+                implode(', ', ConnectionType::VALID_OPTIONS)
+            );
+            throw new InvalidConfigurationException($message);
+        }
+    }
+
+    /**
+     * @throws InvalidConfigurationException
+     */
+    protected function validateAccountingProcessingType(): void
+    {
+        // Only defined accounting processing types are allowed.
+        if (!in_array($this->getAccountingProcessingType(), AccountingProcessingType::VALID_OPTIONS)) {
+            $message = sprintf(
+                'Accounting processing type is not valid; possible values are: %s.',
+                implode(', ', AccountingProcessingType::VALID_OPTIONS)
+            );
+
+            throw new InvalidConfigurationException($message);
+        }
+    }
+
+    /**
+     * @throws InvalidConfigurationException
+     */
+    protected function validateJobsStoreClass(): void
+    {
+        // Jobs store class must implement JobsStoreInterface
+        $jobsStore = $this->getJobsStoreClass();
+        if (!class_exists($jobsStore) || !is_subclass_of($jobsStore, JobsStoreInterface::class)) {
+            $message = sprintf(
+                'Provided jobs store class \'%s\' does not implement interface \'%s\'.',
+                $jobsStore,
+                JobsStoreInterface::class
+            );
+
+            throw new InvalidConfigurationException($message);
+        }
+    }
+
+    /**
+     * @throws InvalidConfigurationException
+     */
+    protected function validateClassToConnectionMap(): void
+    {
+        $errors = [];
+
+        $connectionsAndParameters = $this->getConnectionsAndParameters();
+        // Each defined class should have defined connection parameters.
+        $classToConnectionMap = array_keys($this->getClassToConnectionsMap());
+        /** @var string $class */
+        foreach ($classToConnectionMap as $class) {
+            $connectionKey = $this->getClassConnectionKey($class);
+            if (! array_key_exists($connectionKey, $connectionsAndParameters)) {
+                $errors[] = sprintf(
+                    'Class \'%s\' has connection key \'%s\' set, however parameters for that key are not set.',
+                    $class,
+                    $connectionKey
+                );
+            }
+        }
+
+        if (!empty($errors)) {
+            throw new InvalidConfigurationException(implode(' ', $errors));
+        }
+    }
 }
diff --git a/src/ModuleConfiguration/AccountingProcessingType.php b/src/ModuleConfiguration/AccountingProcessingType.php
index 53c88fe84b6178a04b461bb6f24e9e99fd231059..fef0718ff35309d446ce2120ccaa9655840ef3ca 100644
--- a/src/ModuleConfiguration/AccountingProcessingType.php
+++ b/src/ModuleConfiguration/AccountingProcessingType.php
@@ -4,7 +4,7 @@ declare(strict_types=1);
 
 namespace SimpleSAML\Module\accounting\ModuleConfiguration;
 
-class AccountingProcessingType
+final class AccountingProcessingType
 {
     public const VALUE_SYNCHRONOUS = 'synchronous';
     public const VALUE_ASYNCHRONOUS = 'asynchronous';
diff --git a/src/ModuleConfiguration/ConnectionType.php b/src/ModuleConfiguration/ConnectionType.php
new file mode 100644
index 0000000000000000000000000000000000000000..0ad53b8acb85e98bd4cae27046edc6a12b73c19a
--- /dev/null
+++ b/src/ModuleConfiguration/ConnectionType.php
@@ -0,0 +1,16 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Module\accounting\ModuleConfiguration;
+
+final class ConnectionType
+{
+    public const MASTER = 'master';
+    public const SLAVE = 'slave';
+
+    public const VALID_OPTIONS = [
+        self::MASTER,
+        self::SLAVE,
+    ];
+}
diff --git a/src/Providers/Builders/AuthenticationDataProviderBuilder.php b/src/Providers/Builders/AuthenticationDataProviderBuilder.php
new file mode 100644
index 0000000000000000000000000000000000000000..a8b2bc374d1ebd60e26e15f651a48b14d531a856
--- /dev/null
+++ b/src/Providers/Builders/AuthenticationDataProviderBuilder.php
@@ -0,0 +1,56 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Module\accounting\Providers\Builders;
+
+use Psr\Log\LoggerInterface;
+use SimpleSAML\Module\accounting\Exceptions\Exception;
+use SimpleSAML\Module\accounting\Exceptions\UnexpectedValueException;
+use SimpleSAML\Module\accounting\Helpers\InstanceBuilderUsingModuleConfigurationHelper;
+use SimpleSAML\Module\accounting\ModuleConfiguration;
+use SimpleSAML\Module\accounting\Providers\Interfaces\AuthenticationDataProviderInterface;
+use Throwable;
+
+class AuthenticationDataProviderBuilder
+{
+    protected ModuleConfiguration $moduleConfiguration;
+    protected LoggerInterface $logger;
+
+    public function __construct(ModuleConfiguration $moduleConfiguration, LoggerInterface $logger)
+    {
+        $this->moduleConfiguration = $moduleConfiguration;
+        $this->logger = $logger;
+    }
+
+    /**
+     * @throws Exception
+     */
+    public function build(string $class): AuthenticationDataProviderInterface
+    {
+        try {
+            // Make sure that the class implements proper interface
+            if (!is_subclass_of($class, AuthenticationDataProviderInterface::class)) {
+                $message = sprintf(
+                    'Class %s does not implement interface %s.',
+                    $class,
+                    AuthenticationDataProviderInterface::class
+                );
+                throw new UnexpectedValueException($message);
+            }
+
+            // Build...
+            /** @var AuthenticationDataProviderInterface $store */
+            $store = InstanceBuilderUsingModuleConfigurationHelper::build(
+                $class,
+                $this->moduleConfiguration,
+                $this->logger
+            );
+        } catch (Throwable $exception) {
+            $message = sprintf('Error building instance for class %s. Error was: %s', $class, $exception->getMessage());
+            throw new Exception($message, (int)$exception->getCode(), $exception);
+        }
+
+        return $store;
+    }
+}
diff --git a/src/Providers/Interfaces/AuthenticationDataProviderInterface.php b/src/Providers/Interfaces/AuthenticationDataProviderInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..b5e52c0896321c541e7e00759fcf9b2ce88e6bf2
--- /dev/null
+++ b/src/Providers/Interfaces/AuthenticationDataProviderInterface.php
@@ -0,0 +1,20 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Module\accounting\Providers\Interfaces;
+
+use Psr\Log\LoggerInterface;
+use SimpleSAML\Module\accounting\Entities\Activity;
+use SimpleSAML\Module\accounting\Entities\ConnectedServiceProvider;
+use SimpleSAML\Module\accounting\Interfaces\BuildableUsingModuleConfigurationInterface;
+use SimpleSAML\Module\accounting\ModuleConfiguration;
+
+interface AuthenticationDataProviderInterface extends BuildableUsingModuleConfigurationInterface
+{
+    public static function build(ModuleConfiguration $moduleConfiguration, LoggerInterface $logger): self;
+
+    public function getConnectedServiceProviders(string $userIdentifier): ConnectedServiceProvider\Bag;
+
+    public function getActivity(string $userIdentifier): Activity\Bag;
+}
diff --git a/src/Services/LoggerService.php b/src/Services/Logger.php
similarity index 60%
rename from src/Services/LoggerService.php
rename to src/Services/Logger.php
index 11827f959d2664fa76a4cc194198ecb8259ca1c3..c26c296ba52fbbedcfe722bc0a007cca795cdf8c 100644
--- a/src/Services/LoggerService.php
+++ b/src/Services/Logger.php
@@ -6,40 +6,42 @@ namespace SimpleSAML\Module\accounting\Services;
 
 use Psr\Log\AbstractLogger;
 use Psr\Log\LogLevel;
-use SimpleSAML\Logger;
+use SimpleSAML\Logger as SspLogger;
 
-class LoggerService extends AbstractLogger
+class Logger extends AbstractLogger
 {
-    public function log($level, $message, array $context = [])
+    public function log($level, $message, array $context = [], string $prefix = '(accounting) ')
     {
+        $message = $prefix . $message;
+
         if (! empty($context)) {
             $message .= ' Context: ' . var_export($context, true);
         }
 
         switch ($level) {
             case LogLevel::EMERGENCY:
-                Logger::emergency($message);
+                SspLogger::emergency($message);
                 break;
             case LogLevel::CRITICAL:
-                Logger::critical($message);
+                SspLogger::critical($message);
                 break;
             case LogLevel::ALERT:
-                Logger::alert($message);
+                SspLogger::alert($message);
                 break;
             case LogLevel::ERROR:
-                Logger::error($message);
+                SspLogger::error($message);
                 break;
             case LogLevel::WARNING:
-                Logger::warning($message);
+                SspLogger::warning($message);
                 break;
             case LogLevel::NOTICE:
-                Logger::notice($message);
+                SspLogger::notice($message);
                 break;
             case LogLevel::INFO:
-                Logger::info($message);
+                SspLogger::info($message);
                 break;
             case LogLevel::DEBUG:
-                Logger::debug($message);
+                SspLogger::debug($message);
                 break;
         }
     }
@@ -51,6 +53,6 @@ class LoggerService extends AbstractLogger
      */
     public function stats(string $message): void
     {
-        Logger::stats($message);
+        SspLogger::stats($message);
     }
 }
diff --git a/src/Stores/Bases/DoctrineDbal/AbstractRawEntity.php b/src/Stores/Bases/DoctrineDbal/AbstractRawEntity.php
new file mode 100644
index 0000000000000000000000000000000000000000..ac0b534bb0553f66b5360b82eabbd0b657250587
--- /dev/null
+++ b/src/Stores/Bases/DoctrineDbal/AbstractRawEntity.php
@@ -0,0 +1,51 @@
+<?php
+
+namespace SimpleSAML\Module\accounting\Stores\Bases\DoctrineDbal;
+
+use DateTimeImmutable;
+use Doctrine\DBAL\Platforms\AbstractPlatform;
+use Doctrine\DBAL\Types\Type;
+use Doctrine\DBAL\Types\Types;
+use SimpleSAML\Module\accounting\Exceptions\UnexpectedValueException;
+use Throwable;
+
+abstract class AbstractRawEntity
+{
+    protected array $rawRow;
+    protected AbstractPlatform $abstractPlatform;
+
+    public function __construct(array $rawRow, AbstractPlatform $abstractPlatform)
+    {
+        $this->rawRow = $rawRow;
+        $this->abstractPlatform = $abstractPlatform;
+
+        $this->validate($rawRow);
+    }
+
+    /**
+     * @throws UnexpectedValueException
+     */
+    abstract protected function validate(array $rawRow): void;
+
+    /**
+     * @param mixed $value
+     * @return DateTimeImmutable
+     */
+    protected function resolveDateTimeImmutable($value): DateTimeImmutable
+    {
+        try {
+            /** @var DateTimeImmutable $dateTimeImmutable */
+            $dateTimeImmutable = (Type::getType(Types::DATETIME_IMMUTABLE))
+                ->convertToPHPValue($value, $this->abstractPlatform);
+        } catch (Throwable $exception) {
+            $message = sprintf(
+                'Could not create DateTimeImmutable using value %s. Error was: %s.',
+                var_export($value, true),
+                $exception->getMessage()
+            );
+            throw new UnexpectedValueException($message, (int)$exception->getCode(), $exception);
+        }
+
+        return $dateTimeImmutable;
+    }
+}
diff --git a/src/Stores/Bases/DoctrineDbal/AbstractStore.php b/src/Stores/Bases/DoctrineDbal/AbstractStore.php
new file mode 100644
index 0000000000000000000000000000000000000000..1ff76100f412bcbfb810863b8f53d413307a6de8
--- /dev/null
+++ b/src/Stores/Bases/DoctrineDbal/AbstractStore.php
@@ -0,0 +1,138 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Module\accounting\Stores\Bases\DoctrineDbal;
+
+use Psr\Log\LoggerInterface;
+use ReflectionClass;
+use SimpleSAML\Module\accounting\Exceptions\StoreException;
+use SimpleSAML\Module\accounting\Exceptions\StoreException\MigrationException;
+use SimpleSAML\Module\accounting\Interfaces\BuildableUsingModuleConfigurationInterface;
+use SimpleSAML\Module\accounting\Interfaces\SetupableInterface;
+use SimpleSAML\Module\accounting\ModuleConfiguration;
+use SimpleSAML\Module\accounting\Stores\Connections\Bases\AbstractMigrator;
+use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection;
+use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Factory;
+use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Migrator;
+
+abstract class AbstractStore implements BuildableUsingModuleConfigurationInterface, SetupableInterface
+{
+    protected ModuleConfiguration $moduleConfiguration;
+    protected Connection $connection;
+    protected Migrator $migrator;
+    protected LoggerInterface $logger;
+
+    /**
+     * @throws StoreException
+     */
+    public function __construct(
+        ModuleConfiguration $moduleConfiguration,
+        LoggerInterface $logger,
+        Factory $connectionFactory,
+        string $connectionKey = null
+    ) {
+        $this->moduleConfiguration = $moduleConfiguration;
+        $this->logger = $logger;
+
+        $connectionKey = $connectionKey ?? $moduleConfiguration->getClassConnectionKey($this->getSelfClass());
+        $this->connection = $connectionFactory->buildConnection($connectionKey);
+        $this->migrator = $connectionFactory->buildMigrator($this->connection);
+    }
+
+    /**
+     * Get ReflectionClass of current store instance.
+     * @return ReflectionClass
+     */
+    protected function getReflection(): ReflectionClass
+    {
+        return new ReflectionClass($this);
+    }
+
+    /**
+     * Get class of the current store instance.
+     * @return string
+     */
+    protected function getSelfClass(): string
+    {
+        return $this->getReflection()->getName();
+    }
+
+    protected function getMigrationsNamespace(): string
+    {
+        return $this->getSelfClass() . '\\' . AbstractMigrator::DEFAULT_MIGRATIONS_DIRECTORY_NAME;
+    }
+
+    protected function areAllMigrationsImplemented(): bool
+    {
+        return !$this->migrator->hasNonImplementedMigrationClasses(
+            $this->getMigrationsDirectory(),
+            $this->getMigrationsNamespace()
+        );
+    }
+
+    /**
+     * @throws StoreException
+     * @throws MigrationException
+     */
+    public function runSetup(): void
+    {
+        if ($this->migrator->needsSetup()) {
+            $this->migrator->runSetup();
+        }
+
+        if (!$this->areAllMigrationsImplemented()) {
+            $this->migrator->runNonImplementedMigrationClasses(
+                $this->getMigrationsDirectory(),
+                $this->getMigrationsNamespace()
+            );
+        }
+    }
+
+    /**
+     * @throws StoreException
+     */
+    public function needsSetup(): bool
+    {
+        // ... if the migrator itself needs setup.
+        if ($this->migrator->needsSetup()) {
+            return true;
+        }
+
+        // ... if Store migrations need to run
+        if (!$this->areAllMigrationsImplemented()) {
+            return true;
+        }
+
+        return false;
+    }
+
+    /**
+     * Get migrations directory.
+     * By default, it will return {...}/Store/Migrations directory.
+     * @return string
+     */
+    protected function getMigrationsDirectory(): string
+    {
+        $reflection = $this->getReflection();
+        $storeDirName = dirname($reflection->getFileName());
+        $storeShortName = $reflection->getShortName();
+
+        return $storeDirName . DIRECTORY_SEPARATOR .
+            $storeShortName . DIRECTORY_SEPARATOR .
+            AbstractMigrator::DEFAULT_MIGRATIONS_DIRECTORY_NAME;
+    }
+
+    /**
+     * Build store instance. Must be implemented in child classes for proper return store type.
+     * @param ModuleConfiguration $moduleConfiguration
+     * @param LoggerInterface $logger
+     * @param string|null $connectionKey
+     * @return self
+     */
+    abstract public static function build(
+        ModuleConfiguration $moduleConfiguration,
+        LoggerInterface $logger,
+        string $connectionKey = null
+    ): self;
+}
diff --git a/src/Stores/Builders/Bases/AbstractStoreBuilder.php b/src/Stores/Builders/Bases/AbstractStoreBuilder.php
new file mode 100644
index 0000000000000000000000000000000000000000..adadc9d4640df3a4ef8edda41b5d6dc5da4c1b9c
--- /dev/null
+++ b/src/Stores/Builders/Bases/AbstractStoreBuilder.php
@@ -0,0 +1,56 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Module\accounting\Stores\Builders\Bases;
+
+use Psr\Log\LoggerInterface;
+use SimpleSAML\Module\accounting\Exceptions\StoreException;
+use SimpleSAML\Module\accounting\Helpers\InstanceBuilderUsingModuleConfigurationHelper;
+use SimpleSAML\Module\accounting\ModuleConfiguration;
+use SimpleSAML\Module\accounting\Stores\Interfaces\StoreInterface;
+use Throwable;
+
+use function sprintf;
+use function is_subclass_of;
+
+abstract class AbstractStoreBuilder
+{
+    protected ModuleConfiguration $moduleConfiguration;
+    protected LoggerInterface $logger;
+
+    public function __construct(ModuleConfiguration $moduleConfiguration, LoggerInterface $logger)
+    {
+        $this->moduleConfiguration = $moduleConfiguration;
+        $this->logger = $logger;
+    }
+
+    abstract public function build(string $class, string $connectionKey = null): StoreInterface;
+
+    /**
+     * @throws StoreException
+     */
+    protected function buildGeneric(string $class, array $additionalArguments = []): StoreInterface
+    {
+        try {
+            // Make sure that the class implements StoreInterface
+            if (!is_subclass_of($class, StoreInterface::class)) {
+                throw new StoreException(sprintf('Class %s does not implement StoreInterface.', $class));
+            }
+
+            // Build store...
+            /** @var StoreInterface $store */
+            $store = InstanceBuilderUsingModuleConfigurationHelper::build(
+                $class,
+                $this->moduleConfiguration,
+                $this->logger,
+                $additionalArguments
+            );
+        } catch (Throwable $exception) {
+            $message = sprintf('Error building store for class %s. Error was: %s', $class, $exception->getMessage());
+            throw new StoreException($message, (int)$exception->getCode(), $exception);
+        }
+
+        return $store;
+    }
+}
diff --git a/src/Stores/Builders/DataStoreBuilder.php b/src/Stores/Builders/DataStoreBuilder.php
new file mode 100644
index 0000000000000000000000000000000000000000..e9e1689f3c6829bacca7bdf411e1b6053ea02ad8
--- /dev/null
+++ b/src/Stores/Builders/DataStoreBuilder.php
@@ -0,0 +1,28 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Module\accounting\Stores\Builders;
+
+use SimpleSAML\Module\accounting\Exceptions\StoreException;
+use SimpleSAML\Module\accounting\Stores\Interfaces\DataStoreInterface;
+
+class DataStoreBuilder extends Bases\AbstractStoreBuilder
+{
+    /**
+     * @throws StoreException
+     */
+    public function build(string $class, string $connectionKey = null): DataStoreInterface
+    {
+        if (!is_subclass_of($class, DataStoreInterface::class)) {
+            throw new StoreException(
+                sprintf('Class \'%s\' does not implement interface \'%s\'.', $class, DataStoreInterface::class)
+            );
+        }
+
+        /** @var DataStoreInterface $store */
+        $store = $this->buildGeneric($class, [$connectionKey]);
+
+        return $store;
+    }
+}
diff --git a/src/Stores/Builders/JobsStoreBuilder.php b/src/Stores/Builders/JobsStoreBuilder.php
new file mode 100644
index 0000000000000000000000000000000000000000..401e524d6e687f9711226792919cbceefc6275d7
--- /dev/null
+++ b/src/Stores/Builders/JobsStoreBuilder.php
@@ -0,0 +1,32 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Module\accounting\Stores\Builders;
+
+use SimpleSAML\Module\accounting\Exceptions\StoreException;
+use SimpleSAML\Module\accounting\Stores\Interfaces\JobsStoreInterface;
+
+use function sprintf;
+
+class JobsStoreBuilder extends Bases\AbstractStoreBuilder
+{
+    /**
+     * @throws StoreException
+     */
+    public function build(string $class, string $connectionKey = null): JobsStoreInterface
+    {
+        if (!is_subclass_of($class, JobsStoreInterface::class)) {
+            throw new StoreException(
+                sprintf('Class \'%s\' does not implement interface \'%s\'.', $class, JobsStoreInterface::class)
+            );
+        }
+
+        $connectionKey = $connectionKey ?? $this->moduleConfiguration->getClassConnectionKey($class);
+
+        /** @var JobsStoreInterface $store */
+        $store = $this->buildGeneric($class, [$connectionKey]);
+
+        return $store;
+    }
+}
diff --git a/src/Stores/Connections/DoctrineDbal/Bases/AbstractMigration.php b/src/Stores/Connections/DoctrineDbal/Bases/AbstractMigration.php
index d0a8b9497ae5850509ed78b5659291a3e4b98c99..9619f19bb7b4a90d9175a18608930e15f160d7c4 100644
--- a/src/Stores/Connections/DoctrineDbal/Bases/AbstractMigration.php
+++ b/src/Stores/Connections/DoctrineDbal/Bases/AbstractMigration.php
@@ -30,11 +30,10 @@ abstract class AbstractMigration implements MigrationInterface
         }
     }
 
-    /**
-     * @throws MigrationException
-     */
-    protected function throwGenericMigrationException(string $contextDetails, Throwable $throwable): void
-    {
+    protected function prepareGenericMigrationException(
+        string $contextDetails,
+        Throwable $throwable
+    ): MigrationException {
         $message = sprintf(
             'There was an error running a migration class %s. Context details: %s. Error was: %s.',
             static::class,
@@ -42,6 +41,28 @@ abstract class AbstractMigration implements MigrationInterface
             $throwable->getMessage()
         );
 
-        throw new MigrationException($message, (int) $throwable->getCode(), $throwable);
+        return new MigrationException($message, (int) $throwable->getCode(), $throwable);
+    }
+
+    /**
+     * Prepare prefixed table name which will include table prefix from connection, local table prefix, and table name.
+     *
+     * @param string $tableName
+     * @return string
+     */
+    protected function preparePrefixedTableName(string $tableName): string
+    {
+        return $this->connection->preparePrefixedTableName($this->getLocalTablePrefix() . $tableName);
+    }
+
+    /**
+     * Get local table prefix (prefix per migration). Empty string by default. Override in particular migration to
+     * set another local prefix.
+     *
+     * @return string
+     */
+    protected function getLocalTablePrefix(): string
+    {
+        return '';
     }
 }
diff --git a/src/Stores/Connections/DoctrineDbal/Factory.php b/src/Stores/Connections/DoctrineDbal/Factory.php
index 45d9c1076bb93e4ac78425ded9c9bfcb57ab48f6..b447dbf13c6b356c6f94343e116c4081b6227a9b 100644
--- a/src/Stores/Connections/DoctrineDbal/Factory.php
+++ b/src/Stores/Connections/DoctrineDbal/Factory.php
@@ -5,6 +5,7 @@ declare(strict_types=1);
 namespace SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal;
 
 use Psr\Log\LoggerInterface;
+use SimpleSAML\Module\accounting\Exceptions\StoreException;
 use SimpleSAML\Module\accounting\ModuleConfiguration;
 
 class Factory
@@ -20,11 +21,14 @@ class Factory
 
     public function buildConnection(string $connectionKey): Connection
     {
-        return new Connection($this->moduleConfiguration->getStoreConnectionParameters($connectionKey));
+        return new Connection($this->moduleConfiguration->getConnectionParameters($connectionKey));
     }
 
-    public function buildMigrator(Connection $connection, LoggerInterface $loggerService = null): Migrator
+    /**
+     * @throws StoreException
+     */
+    public function buildMigrator(Connection $connection): Migrator
     {
-        return new Migrator($connection, $loggerService ?? $this->loggerService);
+        return new Migrator($connection, $this->loggerService);
     }
 }
diff --git a/src/Stores/Connections/DoctrineDbal/Migrator.php b/src/Stores/Connections/DoctrineDbal/Migrator.php
index e804b2876a76aa72d2e1e11d07361be25ede7c9d..5c7250f2c9b8e80c51c0ee2ddef134acfb8bb742 100644
--- a/src/Stores/Connections/DoctrineDbal/Migrator.php
+++ b/src/Stores/Connections/DoctrineDbal/Migrator.php
@@ -41,9 +41,9 @@ class Migrator extends AbstractMigrator
         $this->logger = $logger;
 
         try {
-            $this->schemaManager = $this->connection->dbal()->createSchemaManager();
+            $this->schemaManager = ($this->connection->dbal())->createSchemaManager();
         } catch (Throwable $exception) {
-            $message = 'Could not create DBAL schema manager.';
+            $message = sprintf('Could not create DBAL schema manager. Error was: %s', $exception->getMessage());
             throw new StoreException($message, (int) $exception->getCode(), $exception);
         }
         $this->prefixedTableName = $this->connection->preparePrefixedTableName(self::TABLE_NAME);
@@ -57,7 +57,11 @@ class Migrator extends AbstractMigrator
         try {
             return ! $this->schemaManager->tablesExist([$this->prefixedTableName]);
         } catch (Throwable $exception) {
-            $message = sprintf('Could not check table %s existence using schema manager.', $this->prefixedTableName);
+            $message = sprintf(
+                'Could not check table \'%s\' existence using schema manager. Error was:%s',
+                $this->prefixedTableName,
+                $exception->getMessage()
+            );
             throw new StoreException($message, (int) $exception->getCode(), $exception);
         }
     }
diff --git a/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store.php b/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store.php
new file mode 100644
index 0000000000000000000000000000000000000000..543d007dd9c451614b5284fb08f62d62ecb5f35e
--- /dev/null
+++ b/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store.php
@@ -0,0 +1,646 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned;
+
+use Psr\Log\LoggerInterface;
+use SimpleSAML\Module\accounting\Entities\Activity;
+use SimpleSAML\Module\accounting\Entities\Authentication\Event;
+use SimpleSAML\Module\accounting\Entities\ConnectedServiceProvider;
+use SimpleSAML\Module\accounting\Entities\ServiceProvider;
+use SimpleSAML\Module\accounting\Entities\User;
+use SimpleSAML\Module\accounting\Exceptions\StoreException;
+use SimpleSAML\Module\accounting\Exceptions\UnexpectedValueException;
+use SimpleSAML\Module\accounting\Helpers\HashHelper;
+use SimpleSAML\Module\accounting\ModuleConfiguration;
+use SimpleSAML\Module\accounting\Stores\Bases\DoctrineDbal\AbstractStore;
+use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Factory;
+use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\HashDecoratedState;
+use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\RawActivity;
+use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\RawConnectedServiceProvider;
+use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Repository;
+use SimpleSAML\Module\accounting\Stores\Interfaces\DataStoreInterface;
+
+class Store extends AbstractStore implements DataStoreInterface
+{
+    protected Repository $repository;
+
+    /**
+     * @throws StoreException
+     */
+    public function __construct(
+        ModuleConfiguration $moduleConfiguration,
+        LoggerInterface $logger,
+        Factory $connectionFactory,
+        string $connectionKey = null,
+        Repository $repository = null
+    ) {
+        parent::__construct($moduleConfiguration, $logger, $connectionFactory, $connectionKey);
+
+        $this->repository = $repository ?? new Repository($this->connection, $this->logger);
+    }
+
+    /**
+     * Build store instance.
+     * @throws StoreException
+     */
+    public static function build(
+        ModuleConfiguration $moduleConfiguration,
+        LoggerInterface $logger,
+        string $connectionKey = null
+    ): self {
+        return new self(
+            $moduleConfiguration,
+            $logger,
+            new Factory($moduleConfiguration, $logger),
+            $connectionKey
+        );
+    }
+
+    /**
+     * @throws StoreException
+     */
+    public function persist(Event $authenticationEvent): void
+    {
+        $hashDecoratedState = new HashDecoratedState($authenticationEvent->getState());
+
+        $idpId = $this->resolveIdpId($hashDecoratedState);
+        $idpVersionId = $this->resolveIdpVersionId($idpId, $hashDecoratedState);
+        $spId = $this->resolveSpId($hashDecoratedState);
+        $spVersionId = $this->resolveSpVersionId($spId, $hashDecoratedState);
+        $userId = $this->resolveUserId($hashDecoratedState);
+        $userVersionId = $this->resolveUserVersionId($userId, $hashDecoratedState);
+        $idpSpUserVersionId = $this->resolveIdpSpUserVersionId($idpVersionId, $spVersionId, $userVersionId);
+
+        $happenedAt = $authenticationEvent->getHappenedAt();
+        $this->repository->insertAuthenticationEvent($idpSpUserVersionId, $happenedAt);
+    }
+
+    /**
+     * @throws StoreException
+     */
+    protected function resolveIdpId(HashDecoratedState $hashDecoratedState): int
+    {
+        $idpEntityIdHashSha256 = $hashDecoratedState->getIdentityProviderEntityIdHashSha256();
+
+        // Check if it already exists.
+        try {
+            $result = $this->repository->getIdp($idpEntityIdHashSha256);
+            $idpId = $result->fetchOne();
+
+            if ($idpId !== false) {
+                return (int)$idpId;
+            }
+        } catch (\Throwable $exception) {
+            $message = sprintf('Error resolving Idp ID. Error was: %s.', $exception->getMessage());
+            throw new StoreException($message, (int)$exception->getCode(), $exception);
+        }
+
+        // Create new
+        try {
+            $this->repository->insertIdp(
+                $hashDecoratedState->getState()->getIdentityProviderEntityId(),
+                $idpEntityIdHashSha256
+            );
+        } catch (\Throwable $exception) {
+            $message = sprintf(
+                'Error inserting new IdP, however, continuing in case of race condition. Error was: %s.',
+                $exception->getMessage()
+            );
+            $this->logger->warning($message);
+        }
+
+        // Try again, this time it should exist...
+        try {
+            $result = $this->repository->getIdp($idpEntityIdHashSha256);
+            $idpIdNew = $result->fetchOne();
+
+            if ($idpIdNew !== false) {
+                return (int)$idpIdNew;
+            }
+
+            $message = sprintf(
+                'Error fetching IdP ID even after insertion for entity ID hash SHA256 %s.',
+                $idpEntityIdHashSha256
+            );
+            throw new StoreException($message);
+        } catch (\Throwable $exception) {
+            $message = sprintf('Error resolving Idp ID. Error was: %s.', $exception->getMessage());
+            throw new StoreException($message, (int)$exception->getCode(), $exception);
+        }
+    }
+
+    /**
+     * @throws StoreException
+     */
+    protected function resolveIdpVersionId(int $idpId, HashDecoratedState $hashDecoratedState): int
+    {
+        // Check if it already exists.
+        $idpMetadataArrayHashSha256 = $hashDecoratedState->getIdentityProviderMetadataArrayHashSha256();
+
+        try {
+            $result = $this->repository->getIdpVersion($idpId, $idpMetadataArrayHashSha256);
+            $idpVersionId = $result->fetchOne();
+
+            if ($idpVersionId !== false) {
+                return (int)$idpVersionId;
+            }
+        } catch (\Throwable $exception) {
+            $message = sprintf('Error resolving IdP Version ID. Error was: %s.', $exception->getMessage());
+            throw new StoreException($message, (int)$exception->getCode(), $exception);
+        }
+
+        // Create new
+        try {
+            $this->repository->insertIdpVersion(
+                $idpId,
+                serialize($hashDecoratedState->getState()->getIdentityProviderMetadata()),
+                $idpMetadataArrayHashSha256
+            );
+        } catch (\Throwable $exception) {
+            $message = sprintf(
+                'Error inserting new IdP Version, however, continuing in case of race condition. Error was: %s.',
+                $exception->getMessage()
+            );
+            $this->logger->warning($message);
+        }
+
+        // Try again, this time it should exist...
+        try {
+            $result = $this->repository->getIdpVersion($idpId, $idpMetadataArrayHashSha256);
+            $idpVersionIdNew = $result->fetchOne();
+
+            if ($idpVersionIdNew !== false) {
+                return (int)$idpVersionIdNew;
+            }
+
+            $message = sprintf(
+                'Error fetching IdP ID Version even after insertion for Idp ID %s.',
+                $idpId
+            );
+            throw new StoreException($message);
+        } catch (\Throwable $exception) {
+            $message = sprintf('Error resolving Idp Version ID. Error was: %s.', $exception->getMessage());
+            throw new StoreException($message, (int)$exception->getCode(), $exception);
+        }
+    }
+
+    protected function resolveSpId(HashDecoratedState $hashDecoratedState): int
+    {
+        $spEntityIdHashSha256 = $hashDecoratedState->getServiceProviderEntityIdHashSha256();
+
+        // Check if it already exists.
+        try {
+            $result = $this->repository->getSp($spEntityIdHashSha256);
+            $spId = $result->fetchOne();
+
+            if ($spId !== false) {
+                return (int)$spId;
+            }
+        } catch (\Throwable $exception) {
+            $message = sprintf('Error resolving SP ID. Error was: %s.', $exception->getMessage());
+            throw new StoreException($message, (int)$exception->getCode(), $exception);
+        }
+
+        // Create new
+        try {
+            $this->repository->insertSp(
+                $hashDecoratedState->getState()->getServiceProviderEntityId(),
+                $spEntityIdHashSha256
+            );
+        } catch (\Throwable $exception) {
+            $message = sprintf(
+                'Error inserting new SP, however, continuing in case of race condition. Error was: %s.',
+                $exception->getMessage()
+            );
+            $this->logger->warning($message);
+        }
+
+        // Try again, this time it should exist...
+        try {
+            $result = $this->repository->getSp($spEntityIdHashSha256);
+            $spIdNew = $result->fetchOne();
+
+            if ($spIdNew !== false) {
+                return (int)$spIdNew;
+            }
+
+            $message = sprintf(
+                'Error fetching SP ID even after insertion for entity ID hash SHA256 %s.',
+                $spEntityIdHashSha256
+            );
+            throw new StoreException($message);
+        } catch (\Throwable $exception) {
+            $message = sprintf('Error resolving SP ID. Error was: %s.', $exception->getMessage());
+            throw new StoreException($message, (int)$exception->getCode(), $exception);
+        }
+    }
+
+    /**
+     * @throws StoreException
+     */
+    protected function resolveSpVersionId(int $spId, HashDecoratedState $hashDecoratedState): int
+    {
+        // Check if it already exists.
+        $spMetadataArrayHashSha256 = $hashDecoratedState->getServiceProviderMetadataArrayHashSha256();
+
+        try {
+            $result = $this->repository->getSpVersion($spId, $spMetadataArrayHashSha256);
+            $spVersionId = $result->fetchOne();
+
+            if ($spVersionId !== false) {
+                return (int)$spVersionId;
+            }
+        } catch (\Throwable $exception) {
+            $message = sprintf('Error resolving SP Version ID. Error was: %s.', $exception->getMessage());
+            throw new StoreException($message, (int)$exception->getCode(), $exception);
+        }
+
+        // Create new
+        try {
+            $this->repository->insertSpVersion(
+                $spId,
+                serialize($hashDecoratedState->getState()->getServiceProviderMetadata()),
+                $spMetadataArrayHashSha256
+            );
+        } catch (\Throwable $exception) {
+            $message = sprintf(
+                'Error inserting new SP Version, however, continuing in case of race condition. Error was: %s.',
+                $exception->getMessage()
+            );
+            $this->logger->warning($message);
+        }
+
+        // Try again, this time it should exist...
+        try {
+            $result = $this->repository->getSpVersion($spId, $spMetadataArrayHashSha256);
+            $spVersionIdNew = $result->fetchOne();
+
+            if ($spVersionIdNew !== false) {
+                return (int)$spVersionIdNew;
+            }
+
+            $message = sprintf(
+                'Error fetching SP Version even after insertion for SP ID %s.',
+                $spId
+            );
+            throw new StoreException($message);
+        } catch (\Throwable $exception) {
+            $message = sprintf('Error resolving SP Version ID. Error was: %s.', $exception->getMessage());
+            throw new StoreException($message, (int)$exception->getCode(), $exception);
+        }
+    }
+
+    /**
+     * @throws StoreException
+     */
+    protected function resolveUserId(HashDecoratedState $hashDecoratedState): int
+    {
+        $userIdentifierAttributeName = $this->moduleConfiguration->getUserIdAttributeName();
+
+        $userIdentifierValue = $hashDecoratedState->getState()->getAttributeValue($userIdentifierAttributeName);
+        if ($userIdentifierValue === null) {
+            $message = sprintf('Attributes do not contain user ID attribute %s.', $userIdentifierAttributeName);
+            throw new UnexpectedValueException($message);
+        }
+
+        $userIdentifierValueHashSha256 = HashHelper::getSha256($userIdentifierValue);
+
+        // Check if it already exists.
+        try {
+            $result = $this->repository->getUser($userIdentifierValueHashSha256);
+            $userId = $result->fetchOne();
+
+            if ($userId !== false) {
+                return (int)$userId;
+            }
+        } catch (\Throwable $exception) {
+            $message = sprintf('Error resolving user ID. Error was: %s.', $exception->getMessage());
+            throw new StoreException($message, (int)$exception->getCode(), $exception);
+        }
+
+        // Create new
+        try {
+            $this->repository->insertUser($userIdentifierValue, $userIdentifierValueHashSha256);
+        } catch (\Throwable $exception) {
+            $message = sprintf(
+                'Error inserting new user, however, continuing in case of race condition. Error was: %s.',
+                $exception->getMessage()
+            );
+            $this->logger->warning($message);
+        }
+
+        // Try again, this time it should exist...
+        try {
+            $result = $this->repository->getUser($userIdentifierValueHashSha256);
+            $userIdNew = $result->fetchOne();
+
+            if ($userIdNew !== false) {
+                return (int)$userIdNew;
+            }
+
+            $message = sprintf(
+                'Error fetching user even after insertion for identifier value hash SHA256 %s.',
+                $userIdentifierValueHashSha256
+            );
+            throw new StoreException($message);
+        } catch (\Throwable $exception) {
+            $message = sprintf('Error resolving user ID. Error was: %s.', $exception->getMessage());
+            throw new StoreException($message, (int)$exception->getCode(), $exception);
+        }
+    }
+
+    /**
+     * @throws StoreException
+     */
+    protected function resolveUserVersionId(int $userId, HashDecoratedState $hashDecoratedState): int
+    {
+        $attributeArrayHashSha256 = $hashDecoratedState->getAttributesArrayHashSha256();
+
+        // Check if it already exists.
+        try {
+            $result = $this->repository->getUserVersion($userId, $attributeArrayHashSha256);
+            $userVersionId = $result->fetchOne();
+
+            if ($userVersionId !== false) {
+                return (int)$userVersionId;
+            }
+        } catch (\Throwable $exception) {
+            $message = sprintf('Error resolving user version ID. Error was: %s.', $exception->getMessage());
+            throw new StoreException($message, (int)$exception->getCode(), $exception);
+        }
+
+        // Create new
+        try {
+            $this->repository->insertUserVersion(
+                $userId,
+                serialize($hashDecoratedState->getState()->getAttributes()),
+                $attributeArrayHashSha256
+            );
+        } catch (\Throwable $exception) {
+            $message = sprintf(
+                'Error inserting new user version, however, continuing in case of race condition. Error was: %s.',
+                $exception->getMessage()
+            );
+            $this->logger->warning($message);
+        }
+
+        // Try again, this time it should exist...
+        try {
+            $result = $this->repository->getUserVersion($userId, $attributeArrayHashSha256);
+            $userVersionIdNew = $result->fetchOne();
+
+            if ($userVersionIdNew !== false) {
+                return (int)$userVersionIdNew;
+            }
+
+            $message = sprintf(
+                'Error fetching user version even after insertion for user ID %s.',
+                $userId
+            );
+            throw new StoreException($message);
+        } catch (\Throwable $exception) {
+            $message = sprintf('Error resolving user version ID. Error was: %s.', $exception->getMessage());
+            throw new StoreException($message, (int)$exception->getCode(), $exception);
+        }
+    }
+
+    protected function resolveIdpSpUserVersionId(int $idpVersionId, int $spVersionId, int $userVersionId): int
+    {
+        // Check if it already exists.
+        try {
+            $result = $this->repository->getIdpSpUserVersion($idpVersionId, $spVersionId, $userVersionId);
+            $IdpSpUserVersionId = $result->fetchOne();
+
+            if ($IdpSpUserVersionId !== false) {
+                return (int)$IdpSpUserVersionId;
+            }
+        } catch (\Throwable $exception) {
+            $message = sprintf('Error resolving IdpSpUserVersion ID. Error was: %s.', $exception->getMessage());
+            throw new StoreException($message, (int)$exception->getCode(), $exception);
+        }
+
+        // Create new
+        try {
+            $this->repository->insertIdpSpUserVersion($idpVersionId, $spVersionId, $userVersionId);
+        } catch (\Throwable $exception) {
+            $message = sprintf(
+                'Error inserting new IdpSpUserVersion, however, continuing in case of race condition. ' .
+                'Error was: %s.',
+                $exception->getMessage()
+            );
+            $this->logger->warning($message);
+        }
+
+        // Try again, this time it should exist...
+        try {
+            $result = $this->repository->getIdpSpUserVersion($idpVersionId, $spVersionId, $userVersionId);
+            $IdpSpUserVersionIdNew = $result->fetchOne();
+
+            if ($IdpSpUserVersionIdNew !== false) {
+                return (int)$IdpSpUserVersionIdNew;
+            }
+
+            $message = sprintf(
+                'Error fetching IdpSpUserVersion ID even after insertion for IdpVersion %s, SpVersion ID %s and ' .
+                'UserVersion ID %s.',
+                $idpVersionId,
+                $spVersionId,
+                $userVersionId
+            );
+            throw new StoreException($message);
+        } catch (\Throwable $exception) {
+            $message = sprintf('Error resolving IdpSpUserVersion ID. Error was: %s.', $exception->getMessage());
+            throw new StoreException($message, (int)$exception->getCode(), $exception);
+        }
+    }
+
+    /**
+     * @throws StoreException
+     */
+    public function getConnectedOrganizations(string $userIdentifierHashSha256): ConnectedServiceProvider\Bag
+    {
+        $connectedServiceProviderBag = new ConnectedServiceProvider\Bag();
+
+        $results = $this->repository->getConnectedServiceProviders($userIdentifierHashSha256);
+
+        if (empty($results)) {
+            return $connectedServiceProviderBag;
+        }
+
+        try {
+            $databasePlatform = $this->connection->dbal()->getDatabasePlatform();
+
+            /** @var array $result */
+            foreach ($results as $result) {
+                $rawConnectedServiceProvider = new RawConnectedServiceProvider($result, $databasePlatform);
+
+                $serviceProvider = new ServiceProvider($rawConnectedServiceProvider->getServiceProviderMetadata());
+                $user = new User($rawConnectedServiceProvider->getUserAttributes());
+
+                $connectedServiceProviderBag->addOrReplace(
+                    new ConnectedServiceProvider(
+                        $serviceProvider,
+                        $rawConnectedServiceProvider->getNumberOfAuthentications(),
+                        $rawConnectedServiceProvider->getLastAuthenticationAt(),
+                        $rawConnectedServiceProvider->getFirstAuthenticationAt(),
+                        $user
+                    )
+                );
+            }
+        } catch (\Throwable $exception) {
+            $message = sprintf(
+                'Error populating connected service provider bag. Error was: %s',
+                $exception->getMessage()
+            );
+            throw new StoreException($message, (int)$exception->getCode(), $exception);
+        }
+
+        return $connectedServiceProviderBag;
+
+        // TODO mivanci remove after unit tests
+//        $authenticationEventsQueryBuilder = $this->connection->dbal()->createQueryBuilder();
+//        $lastMetadataAndAttributesQueryBuilder = $this->connection->dbal()->createQueryBuilder();
+//
+//        /** @psalm-suppress TooManyArguments */
+//        $authenticationEventsQueryBuilder->select(
+//            'vs.entity_id AS sp_entity_id',
+//            'COUNT(vae.id) AS number_of_authentications',
+//            'MAX(vae.happened_at) AS last_authentication_at',
+//            'MIN(vae.happened_at) AS first_authentication_at',
+//        )->from('vds_authentication_event', 'vae')
+//            ->leftJoin(
+//                'vae',
+//                'vds_idp_sp_user_version',
+//                'visuv',
+//                'vae.idp_sp_user_version_id = visuv.id'
+//            )
+//            ->leftJoin('visuv', 'vds_sp_version', 'vsv', 'visuv.sp_version_id = vsv.id')
+//            ->leftJoin('vsv', 'vds_sp', 'vs', 'vsv.sp_id = vs.id')
+//            ->leftJoin('visuv', 'vds_user_version', 'vuv', 'visuv.user_version_id = vuv.id')
+//            ->leftJoin('vuv', 'vds_user', 'vu', 'vuv.user_id = vu.id')
+//            ->where(
+//                'vu.identifier_hash_sha256 = ' .
+//                $authenticationEventsQueryBuilder->createNamedParameter($userIdentifierHashSha256)
+//            )
+//            ->groupBy('vs.id')
+//            ->orderBy('number_of_authentications', 'DESC');
+//
+//        /** @psalm-suppress TooManyArguments */
+//        $lastMetadataAndAttributesQueryBuilder->select(
+//            'vs.entity_id AS sp_entity_id',
+//            'vsv.metadata AS sp_metadata',
+//            'vuv.attributes AS user_attributes',
+//            //            'vsv.id AS sp_version_id',
+//            //            'vuv.id AS user_version_id',
+//        )->from('vds_authentication_event', 'vae')
+//            ->leftJoin(
+//                'vae',
+//                'vds_idp_sp_user_version',
+//                'visuv',
+//                'vae.idp_sp_user_version_id = visuv.id'
+//            )
+//            ->leftJoin('visuv', 'vds_sp_version', 'vsv', 'visuv.sp_version_id = vsv.id')
+//            ->leftJoin('vsv', 'vds_sp', 'vs', 'vsv.sp_id = vs.id')
+//            ->leftJoin('visuv', 'vds_user_version', 'vuv', 'visuv.user_version_id = vuv.id')
+//            ->leftJoin('vuv', 'vds_user', 'vu', 'vuv.user_id = vu.id')
+//            ->leftJoin('vsv', 'vds_sp_version', 'vsv2', 'vsv.id = vsv2.id AND vsv.id < vsv2.id')
+//            ->leftJoin('vuv', 'vds_user_version', 'vuv2', 'vuv.id = vuv2.id AND vuv.id < vuv2.id')
+//            ->where(
+//                'vu.identifier_hash_sha256 = ' .
+//                $lastMetadataAndAttributesQueryBuilder->createNamedParameter($userIdentifierHashSha256)
+//            )
+//            ->andWhere('vsv2.id IS NULL')
+//            ->andWhere('vuv2.id IS NULL');
+//
+//        try {
+//            $numberOfAuthentications =
+// $authenticationEventsQueryBuilder->executeQuery()->fetchAllAssociativeIndexed();
+//            $lastMetadataAndAttributes =
+//                $lastMetadataAndAttributesQueryBuilder->executeQuery()->fetchAllAssociativeIndexed();
+//
+//            return array_merge_recursive($numberOfAuthentications, $lastMetadataAndAttributes);
+//        } catch (\Throwable $exception) {
+//            $message = sprintf(
+//                'Error executing query to get connected organizations. Error was: %s.',
+//                $exception->getMessage()
+//            );
+//            throw new StoreException($message, (int)$exception->getCode(), $exception);
+//        }
+    }
+
+
+    /**
+     * @throws StoreException
+     */
+    public function getActivity(string $userIdentifierHashSha256): Activity\Bag
+    {
+        // TODO mivanci pagination
+        $results =  $this->repository->getActivity($userIdentifierHashSha256);
+
+        $activityBag = new Activity\Bag();
+
+        if (empty($results)) {
+            return $activityBag;
+        }
+
+        try {
+            /** @var array $result */
+            foreach ($results as $result) {
+                $rawActivity = new RawActivity($result, $this->connection->dbal()->getDatabasePlatform());
+                $serviceProvider = new ServiceProvider($rawActivity->getServiceProviderMetadata());
+                $user = new User($rawActivity->getUserAttributes());
+
+                $activityBag->add(
+                    new Activity($serviceProvider, $user, $rawActivity->getHappenedAt())
+                );
+            }
+        } catch (\Throwable $exception) {
+            $message = sprintf(
+                'Error populating activity bag. Error was: %s',
+                $exception->getMessage()
+            );
+            throw new StoreException($message, (int)$exception->getCode(), $exception);
+        }
+
+        return $activityBag;
+
+        // TODO mivanci remove
+//        $authenticationEventsQueryBuilder = $this->connection->dbal()->createQueryBuilder();
+//
+//        /** @psalm-suppress TooManyArguments */
+//        $authenticationEventsQueryBuilder->select(
+//            'vae.happened_at',
+//            'vsv.metadata AS sp_metadata',
+//            'vuv.attributes AS user_attributes'
+//        )->from('vds_authentication_event', 'vae')
+//            ->leftJoin(
+//                'vae',
+//                'vds_idp_sp_user_version',
+//                'visuv',
+//                'vae.idp_sp_user_version_id = visuv.id'
+//            )
+//            ->leftJoin('visuv', 'vds_sp_version', 'vsv', 'visuv.sp_version_id = vsv.id')
+//            ->leftJoin('vsv', 'vds_sp', 'vs', 'vsv.sp_id = vs.id')
+//            ->leftJoin('visuv', 'vds_user_version', 'vuv', 'visuv.user_version_id = vuv.id')
+//            ->leftJoin('vuv', 'vds_user', 'vu', 'vuv.user_id = vu.id')
+//            ->where(
+//                'vu.identifier_hash_sha256 = ' .
+//                $authenticationEventsQueryBuilder->createNamedParameter($userIdentifierHashSha256)
+//            )
+//            ->orderBy('vae.id', 'DESC');
+//
+//        try {
+//            $numberOfAuthentications = $authenticationEventsQueryBuilder->executeQuery()->fetchAllAssociative();
+//
+//            return array_merge_recursive($numberOfAuthentications);
+//        } catch (\Throwable $exception) {
+//            $message = sprintf(
+//                'Error executing query to get connected organizations. Error was: %s.',
+//                $exception->getMessage()
+//            );
+//            throw new StoreException($message, (int)$exception->getCode(), $exception);
+//        }
+    }
+}
diff --git a/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/HashDecoratedState.php b/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/HashDecoratedState.php
new file mode 100644
index 0000000000000000000000000000000000000000..f349682236936540bc54a559630753096c227eb4
--- /dev/null
+++ b/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/HashDecoratedState.php
@@ -0,0 +1,76 @@
+<?php
+
+namespace SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store;
+
+use SimpleSAML\Module\accounting\Entities\Authentication\State;
+use SimpleSAML\Module\accounting\Helpers\HashHelper;
+
+class HashDecoratedState
+{
+    protected State $state;
+    protected string $identityProviderEntityIdHashSha256;
+    protected string $serviceProviderEntityIdHashSha256;
+    protected string $identityProviderMetadataArrayHashSha256;
+    protected string $serviceProviderMetadataArrayHashSha256;
+    protected string $attributesArrayHashSha256;
+
+    public function __construct(State $state)
+    {
+        $this->state = $state;
+
+        $this->identityProviderEntityIdHashSha256 = HashHelper::getSha256($state->getIdentityProviderEntityId());
+        $this->identityProviderMetadataArrayHashSha256 =
+            HashHelper::getSha256ForArray($state->getIdentityProviderMetadata());
+
+        $this->serviceProviderEntityIdHashSha256 = HashHelper::getSha256($state->getServiceProviderEntityId());
+        $this->serviceProviderMetadataArrayHashSha256 =
+            HashHelper::getSha256ForArray($state->getServiceProviderMetadata());
+
+        $this->attributesArrayHashSha256 = HashHelper::getSha256ForArray($state->getAttributes());
+    }
+
+    /**
+     * @return State
+     */
+    public function getState(): State
+    {
+        return $this->state;
+    }
+
+    /**
+     * @return string
+     */
+    public function getIdentityProviderEntityIdHashSha256(): string
+    {
+        return $this->identityProviderEntityIdHashSha256;
+    }
+
+    /**
+     * @return string
+     */
+    public function getServiceProviderEntityIdHashSha256(): string
+    {
+        return $this->serviceProviderEntityIdHashSha256;
+    }
+
+    /**
+     * @return string
+     */
+    public function getIdentityProviderMetadataArrayHashSha256(): string
+    {
+        return $this->identityProviderMetadataArrayHashSha256;
+    }
+
+    public function getServiceProviderMetadataArrayHashSha256(): string
+    {
+        return $this->serviceProviderMetadataArrayHashSha256;
+    }
+
+    /**
+     * @return string
+     */
+    public function getAttributesArrayHashSha256(): string
+    {
+        return $this->attributesArrayHashSha256;
+    }
+}
diff --git a/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000000CreateIdpTable.php b/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000000CreateIdpTable.php
new file mode 100644
index 0000000000000000000000000000000000000000..66d8b08f2e4d861a0e5930cb5db719a08d58ec3e
--- /dev/null
+++ b/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000000CreateIdpTable.php
@@ -0,0 +1,69 @@
+<?php
+
+namespace SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations;
+
+use Doctrine\DBAL\Schema\Table;
+use Doctrine\DBAL\Types\Types;
+use SimpleSAML\Module\accounting\Exceptions\StoreException\MigrationException;
+use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Bases\AbstractMigration;
+use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\TableConstants;
+
+class Version20220801000000CreateIdpTable extends AbstractMigration
+{
+    protected function getLocalTablePrefix(): string
+    {
+        return 'vds_';
+    }
+
+    /**
+     * @inheritDoc
+     * @throws MigrationException
+     */
+    public function run(): void
+    {
+        $tableName = $this->preparePrefixedTableName('idp');
+
+        try {
+            $table = new Table($tableName);
+
+            $table->addColumn('id', Types::BIGINT)
+                ->setUnsigned(true)
+                ->setAutoincrement(true);
+
+            $table->addColumn('entity_id', Types::STRING)
+                ->setLength(TableConstants::COLUMN_ENTITY_ID_LENGTH);
+
+            $table->addColumn('entity_id_hash_sha256', Types::STRING)
+                ->setLength(TableConstants::COLUMN_HASH_SHA265_HEXITS_LENGTH)
+                ->setFixed(true);
+
+            $table->addColumn('created_at', Types::DATETIMETZ_IMMUTABLE);
+
+            $table->setPrimaryKey(['id']);
+
+            $table->addUniqueConstraint(['entity_id_hash_sha256']);
+
+            $this->schemaManager->createTable($table);
+        } catch (\Throwable $exception) {
+            throw $this->prepareGenericMigrationException(
+                \sprintf('Error creating table \'%s.', $tableName),
+                $exception
+            );
+        }
+    }
+
+    /**
+     * @inheritDoc
+     * @throws MigrationException
+     */
+    public function revert(): void
+    {
+        $tableName = $this->preparePrefixedTableName('idp');
+
+        try {
+            $this->schemaManager->dropTable($tableName);
+        } catch (\Throwable $exception) {
+            throw $this->prepareGenericMigrationException(\sprintf('Could not drop table %s.', $tableName), $exception);
+        }
+    }
+}
diff --git a/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000100CreateIdpVersionTable.php b/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000100CreateIdpVersionTable.php
new file mode 100644
index 0000000000000000000000000000000000000000..4c18cf2929b4ace28d84c16d3a81211904e7a383
--- /dev/null
+++ b/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000100CreateIdpVersionTable.php
@@ -0,0 +1,74 @@
+<?php
+
+namespace SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations;
+
+use Doctrine\DBAL\Schema\Table;
+use Doctrine\DBAL\Schema\TableDiff;
+use Doctrine\DBAL\Types\Types;
+use SimpleSAML\Module\accounting\Exceptions\StoreException\MigrationException;
+use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Bases\AbstractMigration;
+use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\TableConstants;
+
+class Version20220801000100CreateIdpVersionTable extends AbstractMigration
+{
+    protected function getLocalTablePrefix(): string
+    {
+        return 'vds_';
+    }
+
+    /**
+     * @inheritDoc
+     * @throws MigrationException
+     */
+    public function run(): void
+    {
+        $tableName = $this->preparePrefixedTableName('idp_version');
+
+        try {
+            $table = new Table($tableName);
+
+            $table->addColumn('id', Types::BIGINT)
+                ->setUnsigned(true)
+                ->setAutoincrement(true);
+
+            $table->addColumn('idp_id', Types::BIGINT)
+                ->setUnsigned(true);
+
+            $table->addColumn('metadata', Types::TEXT);
+
+            $table->addColumn('metadata_hash_sha256', Types::STRING)
+                ->setLength(TableConstants::COLUMN_HASH_SHA265_HEXITS_LENGTH)
+                ->setFixed(true);
+
+            $table->addColumn('created_at', Types::DATETIMETZ_IMMUTABLE);
+
+            $table->setPrimaryKey(['id']);
+
+            $table->addForeignKeyConstraint($this->preparePrefixedTableName('idp'), ['idp_id'], ['id']);
+
+            $table->addUniqueConstraint(['metadata_hash_sha256']);
+
+            $this->schemaManager->createTable($table);
+        } catch (\Throwable $exception) {
+            throw $this->prepareGenericMigrationException(
+                \sprintf('Error creating table \'%s.', $tableName),
+                $exception
+            );
+        }
+    }
+
+    /**
+     * @inheritDoc
+     * @throws MigrationException
+     */
+    public function revert(): void
+    {
+        $tableName = $this->preparePrefixedTableName('idp_version');
+
+        try {
+            $this->schemaManager->dropTable($tableName);
+        } catch (\Throwable $exception) {
+            throw $this->prepareGenericMigrationException(\sprintf('Could not drop table %s.', $tableName), $exception);
+        }
+    }
+}
diff --git a/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000200CreateSpTable.php b/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000200CreateSpTable.php
new file mode 100644
index 0000000000000000000000000000000000000000..b9418b6d1224d8597253b0f90c4e3ec06871bb3f
--- /dev/null
+++ b/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000200CreateSpTable.php
@@ -0,0 +1,69 @@
+<?php
+
+namespace SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations;
+
+use Doctrine\DBAL\Schema\Table;
+use Doctrine\DBAL\Types\Types;
+use SimpleSAML\Module\accounting\Exceptions\StoreException\MigrationException;
+use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Bases\AbstractMigration;
+use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\TableConstants;
+
+class Version20220801000200CreateSpTable extends AbstractMigration
+{
+    protected function getLocalTablePrefix(): string
+    {
+        return 'vds_';
+    }
+
+    /**
+     * @inheritDoc
+     * @throws MigrationException
+     */
+    public function run(): void
+    {
+        $tableName = $this->preparePrefixedTableName('sp');
+
+        try {
+            $table = new Table($tableName);
+
+            $table->addColumn('id', Types::BIGINT)
+                ->setUnsigned(true)
+                ->setAutoincrement(true);
+
+            $table->addColumn('entity_id', Types::STRING)
+                ->setLength(TableConstants::COLUMN_ENTITY_ID_LENGTH);
+
+            $table->addColumn('entity_id_hash_sha256', Types::STRING)
+                ->setLength(TableConstants::COLUMN_HASH_SHA265_HEXITS_LENGTH)
+                ->setFixed(true);
+
+            $table->addColumn('created_at', Types::DATETIMETZ_IMMUTABLE);
+
+            $table->setPrimaryKey(['id']);
+
+            $table->addUniqueConstraint(['entity_id_hash_sha256']);
+
+            $this->schemaManager->createTable($table);
+        } catch (\Throwable $exception) {
+            throw $this->prepareGenericMigrationException(
+                \sprintf('Error creating table \'%s.', $tableName),
+                $exception
+            );
+        }
+    }
+
+    /**
+     * @inheritDoc
+     * @throws MigrationException
+     */
+    public function revert(): void
+    {
+        $tableName = $this->preparePrefixedTableName('sp');
+
+        try {
+            $this->schemaManager->dropTable($tableName);
+        } catch (\Throwable $exception) {
+            throw $this->prepareGenericMigrationException(\sprintf('Could not drop table %s.', $tableName), $exception);
+        }
+    }
+}
diff --git a/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000300CreateSpVersionTable.php b/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000300CreateSpVersionTable.php
new file mode 100644
index 0000000000000000000000000000000000000000..13a55358e0491d17099973de3f98ab9385c44136
--- /dev/null
+++ b/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000300CreateSpVersionTable.php
@@ -0,0 +1,74 @@
+<?php
+
+namespace SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations;
+
+use Doctrine\DBAL\Schema\Table;
+use Doctrine\DBAL\Schema\TableDiff;
+use Doctrine\DBAL\Types\Types;
+use SimpleSAML\Module\accounting\Exceptions\StoreException\MigrationException;
+use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Bases\AbstractMigration;
+use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\TableConstants;
+
+class Version20220801000300CreateSpVersionTable extends AbstractMigration
+{
+    protected function getLocalTablePrefix(): string
+    {
+        return 'vds_';
+    }
+
+    /**
+     * @inheritDoc
+     * @throws MigrationException
+     */
+    public function run(): void
+    {
+        $tableName = $this->preparePrefixedTableName('sp_version');
+
+        try {
+            $table = new Table($tableName);
+
+            $table->addColumn('id', Types::BIGINT)
+                ->setUnsigned(true)
+                ->setAutoincrement(true);
+
+            $table->addColumn('sp_id', Types::BIGINT)
+                ->setUnsigned(true);
+
+            $table->addColumn('metadata', Types::TEXT);
+
+            $table->addColumn('metadata_hash_sha256', Types::STRING)
+                ->setLength(TableConstants::COLUMN_HASH_SHA265_HEXITS_LENGTH)
+                ->setFixed(true);
+
+            $table->addColumn('created_at', Types::DATETIMETZ_IMMUTABLE);
+
+            $table->setPrimaryKey(['id']);
+
+            $table->addForeignKeyConstraint($this->preparePrefixedTableName('sp'), ['sp_id'], ['id']);
+
+            $table->addUniqueConstraint(['metadata_hash_sha256']);
+
+            $this->schemaManager->createTable($table);
+        } catch (\Throwable $exception) {
+            throw $this->prepareGenericMigrationException(
+                \sprintf('Error creating table \'%s.', $tableName),
+                $exception
+            );
+        }
+    }
+
+    /**
+     * @inheritDoc
+     * @throws MigrationException
+     */
+    public function revert(): void
+    {
+        $tableName = $this->preparePrefixedTableName('sp_version');
+
+        try {
+            $this->schemaManager->dropTable($tableName);
+        } catch (\Throwable $exception) {
+            throw $this->prepareGenericMigrationException(\sprintf('Could not drop table %s.', $tableName), $exception);
+        }
+    }
+}
diff --git a/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000400CreateUserTable.php b/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000400CreateUserTable.php
new file mode 100644
index 0000000000000000000000000000000000000000..667d8ffa9e4d53a669005a49218d8b74ec9f98dd
--- /dev/null
+++ b/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000400CreateUserTable.php
@@ -0,0 +1,70 @@
+<?php
+
+namespace SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations;
+
+use Doctrine\DBAL\Schema\Table;
+use Doctrine\DBAL\Schema\TableDiff;
+use Doctrine\DBAL\Types\Types;
+use SimpleSAML\Module\accounting\Exceptions\StoreException\MigrationException;
+use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Bases\AbstractMigration;
+use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\TableConstants;
+
+class Version20220801000400CreateUserTable extends AbstractMigration
+{
+    protected function getLocalTablePrefix(): string
+    {
+        return 'vds_';
+    }
+
+    /**
+     * @inheritDoc
+     * @throws MigrationException
+     */
+    public function run(): void
+    {
+        $tableName = $this->preparePrefixedTableName('user');
+
+        try {
+            $table = new Table($tableName);
+
+            $table->addColumn('id', Types::BIGINT)
+                ->setUnsigned(true)
+                ->setAutoincrement(true);
+
+            $table->addColumn('identifier', Types::TEXT)
+                ->setLength(65535);
+
+            $table->addColumn('identifier_hash_sha256', Types::STRING)
+                ->setLength(TableConstants::COLUMN_HASH_SHA265_HEXITS_LENGTH)
+                ->setFixed(true);
+
+            $table->addColumn('created_at', Types::DATETIMETZ_IMMUTABLE);
+
+            $table->setPrimaryKey(['id']);
+
+            $table->addUniqueConstraint(['identifier_hash_sha256']);
+
+            $this->schemaManager->createTable($table);
+        } catch (\Throwable $exception) {
+            throw $this->prepareGenericMigrationException(
+                \sprintf('Error creating table \'%s.', $tableName),
+                $exception
+            );
+        }
+    }
+
+    /**
+     * @inheritDoc
+     * @throws MigrationException
+     */
+    public function revert(): void
+    {
+        $tableName = $this->preparePrefixedTableName('user');
+
+        try {
+            $this->schemaManager->dropTable($tableName);
+        } catch (\Throwable $exception) {
+            throw $this->prepareGenericMigrationException(\sprintf('Could not drop table %s.', $tableName), $exception);
+        }
+    }
+}
diff --git a/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000500CreateUserVersionTable.php b/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000500CreateUserVersionTable.php
new file mode 100644
index 0000000000000000000000000000000000000000..fcb2b42ca0e72b02d561eb395aad33c1154cfe7c
--- /dev/null
+++ b/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000500CreateUserVersionTable.php
@@ -0,0 +1,75 @@
+<?php
+
+namespace SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations;
+
+use Doctrine\DBAL\Schema\Table;
+use Doctrine\DBAL\Schema\TableDiff;
+use Doctrine\DBAL\Types\Types;
+use SimpleSAML\Module\accounting\Exceptions\StoreException\MigrationException;
+use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Bases\AbstractMigration;
+use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\TableConstants;
+
+class Version20220801000500CreateUserVersionTable extends AbstractMigration
+{
+    protected function getLocalTablePrefix(): string
+    {
+        return 'vds_';
+    }
+
+    /**
+     * @inheritDoc
+     * @throws MigrationException
+     */
+    public function run(): void
+    {
+        $tableName = $this->preparePrefixedTableName('user_version');
+
+        try {
+            $table = new Table($tableName);
+
+            $table->addColumn('id', Types::BIGINT)
+                ->setUnsigned(true)
+                ->setAutoincrement(true);
+
+            $table->addColumn('user_id', Types::BIGINT)
+                ->setUnsigned(true);
+
+            $table->addColumn('attributes', Types::TEXT)
+                ->setComment('Serialized attributes.');
+
+            $table->addColumn('attributes_hash_sha256', Types::STRING)
+                ->setLength(TableConstants::COLUMN_HASH_SHA265_HEXITS_LENGTH)
+                ->setFixed(true);
+
+            $table->addColumn('created_at', Types::DATETIMETZ_IMMUTABLE);
+
+            $table->setPrimaryKey(['id']);
+
+            $table->addForeignKeyConstraint($this->preparePrefixedTableName('user'), ['user_id'], ['id']);
+
+            $table->addUniqueConstraint(['attributes_hash_sha256']);
+
+            $this->schemaManager->createTable($table);
+        } catch (\Throwable $exception) {
+            throw $this->prepareGenericMigrationException(
+                \sprintf('Error creating table \'%s.', $tableName),
+                $exception
+            );
+        }
+    }
+
+    /**
+     * @inheritDoc
+     * @throws MigrationException
+     */
+    public function revert(): void
+    {
+        $tableName = $this->preparePrefixedTableName('user_version');
+
+        try {
+            $this->schemaManager->dropTable($tableName);
+        } catch (\Throwable $exception) {
+            throw $this->prepareGenericMigrationException(\sprintf('Could not drop table %s.', $tableName), $exception);
+        }
+    }
+}
diff --git a/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000600CreateIdpSpUserVersionTable.php b/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000600CreateIdpSpUserVersionTable.php
new file mode 100644
index 0000000000000000000000000000000000000000..625e6512f4c8f3f23d8c249e82d477e323054152
--- /dev/null
+++ b/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000600CreateIdpSpUserVersionTable.php
@@ -0,0 +1,92 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations;
+
+use Doctrine\DBAL\Schema\Table;
+use Doctrine\DBAL\Schema\TableDiff;
+use Doctrine\DBAL\Types\Types;
+use SimpleSAML\Module\accounting\Exceptions\StoreException\MigrationException;
+use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Bases\AbstractMigration;
+use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\TableConstants;
+
+class Version20220801000600CreateIdpSpUserVersionTable extends AbstractMigration
+{
+    protected function getLocalTablePrefix(): string
+    {
+        return 'vds_';
+    }
+
+    /**
+     * @inheritDoc
+     * @throws MigrationException
+     */
+    public function run(): void
+    {
+        $tableName = $this->preparePrefixedTableName('idp_sp_user_version');
+
+        try {
+            $table = new Table($tableName);
+
+            $table->addColumn('id', Types::BIGINT)
+                ->setUnsigned(true)
+                ->setAutoincrement(true);
+
+            $table->addColumn('idp_version_id', Types::BIGINT)
+                ->setUnsigned(true);
+
+            $table->addColumn('sp_version_id', Types::BIGINT)
+                ->setUnsigned(true);
+
+            $table->addColumn('user_version_id', Types::BIGINT)
+                ->setUnsigned(true);
+
+            $table->addColumn('created_at', Types::DATETIMETZ_IMMUTABLE);
+
+            $table->setPrimaryKey(['id']);
+
+            $table->addForeignKeyConstraint(
+                $this->preparePrefixedTableName('idp_version'),
+                ['idp_version_id'],
+                ['id']
+            );
+
+            $table->addForeignKeyConstraint(
+                $this->preparePrefixedTableName('sp_version'),
+                ['sp_version_id'],
+                ['id']
+            );
+
+            $table->addForeignKeyConstraint(
+                $this->preparePrefixedTableName('user_version'),
+                ['user_version_id'],
+                ['id']
+            );
+
+            $table->addUniqueConstraint(['idp_version_id', 'sp_version_id', 'user_version_id']);
+
+            $this->schemaManager->createTable($table);
+        } catch (\Throwable $exception) {
+            throw $this->prepareGenericMigrationException(
+                \sprintf('Error creating table \'%s.', $tableName),
+                $exception
+            );
+        }
+    }
+
+    /**
+     * @inheritDoc
+     * @throws MigrationException
+     */
+    public function revert(): void
+    {
+        $tableName = $this->preparePrefixedTableName('idp_sp_user_version');
+
+        try {
+            $this->schemaManager->dropTable($tableName);
+        } catch (\Throwable $exception) {
+            throw $this->prepareGenericMigrationException(\sprintf('Could not drop table %s.', $tableName), $exception);
+        }
+    }
+}
diff --git a/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000700CreateAuthenticationEventTable.php b/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000700CreateAuthenticationEventTable.php
new file mode 100644
index 0000000000000000000000000000000000000000..d56082275448d94f189dfbb22b81f0b6404c3f9c
--- /dev/null
+++ b/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000700CreateAuthenticationEventTable.php
@@ -0,0 +1,72 @@
+<?php
+
+namespace SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations;
+
+use Doctrine\DBAL\Schema\Table;
+use Doctrine\DBAL\Schema\TableDiff;
+use Doctrine\DBAL\Types\Types;
+use SimpleSAML\Module\accounting\Exceptions\StoreException\MigrationException;
+use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Bases\AbstractMigration;
+use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\TableConstants;
+
+class Version20220801000700CreateAuthenticationEventTable extends AbstractMigration
+{
+    protected function getLocalTablePrefix(): string
+    {
+        return 'vds_';
+    }
+
+    /**
+     * @inheritDoc
+     * @throws MigrationException
+     */
+    public function run(): void
+    {
+        $tableName = $this->preparePrefixedTableName('authentication_event');
+
+        try {
+            $table = new Table($tableName);
+
+            $table->addColumn('id', Types::BIGINT)
+                ->setUnsigned(true)
+                ->setAutoincrement(true);
+
+            $table->addColumn('idp_sp_user_version_id', Types::BIGINT)
+                ->setUnsigned(true);
+
+            $table->addColumn('happened_at', Types::DATETIMETZ_IMMUTABLE);
+
+            $table->addColumn('created_at', Types::DATETIMETZ_IMMUTABLE);
+
+            $table->setPrimaryKey(['id']);
+
+            $table->addForeignKeyConstraint(
+                $this->preparePrefixedTableName('idp_sp_user_version'),
+                ['idp_sp_user_version_id'],
+                ['id']
+            );
+
+            $this->schemaManager->createTable($table);
+        } catch (\Throwable $exception) {
+            throw $this->prepareGenericMigrationException(
+                \sprintf('Error creating table \'%s.', $tableName),
+                $exception
+            );
+        }
+    }
+
+    /**
+     * @inheritDoc
+     * @throws MigrationException
+     */
+    public function revert(): void
+    {
+        $tableName = $this->preparePrefixedTableName('authentication_event');
+
+        try {
+            $this->schemaManager->dropTable($tableName);
+        } catch (\Throwable $exception) {
+            throw $this->prepareGenericMigrationException(\sprintf('Could not drop table %s.', $tableName), $exception);
+        }
+    }
+}
diff --git a/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/RawActivity.php b/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/RawActivity.php
new file mode 100644
index 0000000000000000000000000000000000000000..8a1f7f60ece229149941f7835aca5c343a354129
--- /dev/null
+++ b/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/RawActivity.php
@@ -0,0 +1,126 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store;
+
+use DateTimeImmutable;
+use Doctrine\DBAL\Platforms\AbstractPlatform;
+use SimpleSAML\Module\accounting\Exceptions\UnexpectedValueException;
+use SimpleSAML\Module\accounting\Stores\Bases\DoctrineDbal\AbstractRawEntity;
+
+class RawActivity extends AbstractRawEntity
+{
+    protected array $serviceProviderMetadata;
+    protected array $userAttributes;
+    protected DateTimeImmutable $happenedAt;
+
+    public function __construct(array $rawRow, AbstractPlatform $abstractPlatform)
+    {
+        parent::__construct($rawRow, $abstractPlatform);
+
+        $this->serviceProviderMetadata = $this->resolveServiceProviderMetadata(
+            (string)$rawRow[TableConstants::ENTITY_ACTIVITY_COLUMN_NAME_SP_METADATA]
+        );
+
+        $this->userAttributes = $this->resolveUserAttributes(
+            (string)$rawRow[TableConstants::ENTITY_ACTIVITY_COLUMN_NAME_USER_ATTRIBUTES]
+        );
+
+        $this->happenedAt = $this->resolveDateTimeImmutable(
+            $rawRow[TableConstants::ENTITY_ACTIVITY_COLUMN_NAME_HAPPENED_AT]
+        );
+    }
+
+    /**
+     * @return DateTimeImmutable
+     */
+    public function getHappenedAt(): DateTimeImmutable
+    {
+        return $this->happenedAt;
+    }
+
+    /**
+     * @return array
+     */
+    public function getServiceProviderMetadata(): array
+    {
+        return $this->serviceProviderMetadata;
+    }
+
+    /**
+     * @return array
+     */
+    public function getUserAttributes(): array
+    {
+        return $this->userAttributes;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    protected function validate(array $rawRow): void
+    {
+        $columnsToCheck = [
+            TableConstants::ENTITY_ACTIVITY_COLUMN_NAME_SP_METADATA,
+            TableConstants::ENTITY_ACTIVITY_COLUMN_NAME_USER_ATTRIBUTES,
+            TableConstants::ENTITY_ACTIVITY_COLUMN_NAME_HAPPENED_AT,
+        ];
+
+        foreach ($columnsToCheck as $column) {
+            if (empty($rawRow[$column])) {
+                throw new UnexpectedValueException(sprintf('Column %s must be set.', $column));
+            }
+        }
+
+        if (! is_string($rawRow[TableConstants::ENTITY_ACTIVITY_COLUMN_NAME_SP_METADATA])) {
+            $message = sprintf(
+                'Column %s must be string.',
+                TableConstants::ENTITY_ACTIVITY_COLUMN_NAME_SP_METADATA
+            );
+            throw new UnexpectedValueException($message);
+        }
+
+        if (! is_string($rawRow[TableConstants::ENTITY_ACTIVITY_COLUMN_NAME_USER_ATTRIBUTES])) {
+            $message = sprintf(
+                'Column %s must be string.',
+                TableConstants::ENTITY_ACTIVITY_COLUMN_NAME_USER_ATTRIBUTES
+            );
+            throw new UnexpectedValueException($message);
+        }
+
+        if (! is_string($rawRow[TableConstants::ENTITY_ACTIVITY_COLUMN_NAME_HAPPENED_AT])) {
+            $message = sprintf(
+                'Column %s must be string.',
+                TableConstants::ENTITY_ACTIVITY_COLUMN_NAME_HAPPENED_AT
+            );
+            throw new UnexpectedValueException($message);
+        }
+    }
+
+    protected function resolveServiceProviderMetadata(string $serializedMetadata): array
+    {
+        /** @psalm-suppress MixedAssignment - we check the type manually */
+        $metadata = unserialize($serializedMetadata);
+
+        if (is_array($metadata)) {
+            return $metadata;
+        }
+
+        $message = sprintf('Metadata not in expected array format, got type %s.', gettype($metadata));
+        throw new UnexpectedValueException($message);
+    }
+
+    protected function resolveUserAttributes(string $serializedUserAttributes): array
+    {
+        /** @psalm-suppress MixedAssignment - we check the type manually */
+        $userAttributes = unserialize($serializedUserAttributes);
+
+        if (is_array($userAttributes)) {
+            return $userAttributes;
+        }
+
+        $message = sprintf('User attributes not in expected array format, got type %s.', gettype($userAttributes));
+        throw new UnexpectedValueException($message);
+    }
+}
diff --git a/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/RawConnectedServiceProvider.php b/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/RawConnectedServiceProvider.php
new file mode 100644
index 0000000000000000000000000000000000000000..f9ed95a75e80ed431f60db63c2d3c8dba120af3c
--- /dev/null
+++ b/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/RawConnectedServiceProvider.php
@@ -0,0 +1,170 @@
+<?php
+
+namespace SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store;
+
+use DateTimeImmutable;
+use Doctrine\DBAL\Platforms\AbstractPlatform;
+use SimpleSAML\Module\accounting\Exceptions\UnexpectedValueException;
+use SimpleSAML\Module\accounting\Stores\Bases\DoctrineDbal\AbstractRawEntity;
+
+class RawConnectedServiceProvider extends AbstractRawEntity
+{
+    protected int $numberOfAuthentications;
+    protected DateTimeImmutable $lastAuthenticationAt;
+    protected DateTimeImmutable $firstAuthenticationAt;
+    protected array $serviceProviderMetadata;
+    protected array $userAttributes;
+
+    public function __construct(array $rawRow, AbstractPlatform $abstractPlatform)
+    {
+        parent::__construct($rawRow, $abstractPlatform);
+
+        $this->numberOfAuthentications = (int)$rawRow[
+            TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_NUMBER_OF_AUTHENTICATIONS
+        ];
+
+        $this->lastAuthenticationAt = $this->resolveDateTimeImmutable(
+            $rawRow[TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_LAST_AUTHENTICATION_AT]
+        );
+
+        $this->firstAuthenticationAt = $this->resolveDateTimeImmutable(
+            $rawRow[TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_FIRST_AUTHENTICATION_AT]
+        );
+
+        $this->serviceProviderMetadata = $this->resolveServiceProviderMetadata(
+            (string)$rawRow[TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_SP_METADATA]
+        );
+
+        $this->userAttributes = $this->resolveUserAttributes(
+            (string)$rawRow[TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_USER_ATTRIBUTES]
+        );
+    }
+
+    /**
+     * @return int
+     */
+    public function getNumberOfAuthentications(): int
+    {
+        return $this->numberOfAuthentications;
+    }
+
+    /**
+     * @return DateTimeImmutable
+     */
+    public function getLastAuthenticationAt(): DateTimeImmutable
+    {
+        return $this->lastAuthenticationAt;
+    }
+
+    /**
+     * @return DateTimeImmutable
+     */
+    public function getFirstAuthenticationAt(): DateTimeImmutable
+    {
+        return $this->firstAuthenticationAt;
+    }
+
+    /**
+     * @return array
+     */
+    public function getServiceProviderMetadata(): array
+    {
+        return $this->serviceProviderMetadata;
+    }
+
+    /**
+     * @return array
+     */
+    public function getUserAttributes(): array
+    {
+        return $this->userAttributes;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    protected function validate(array $rawRow): void
+    {
+        $columnsToCheck = [
+            TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_NUMBER_OF_AUTHENTICATIONS,
+            TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_LAST_AUTHENTICATION_AT,
+            TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_FIRST_AUTHENTICATION_AT,
+            TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_SP_METADATA,
+            TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_USER_ATTRIBUTES,
+        ];
+
+        foreach ($columnsToCheck as $column) {
+            if (empty($rawRow[$column])) {
+                throw new UnexpectedValueException(sprintf('Column %s must be set.', $column));
+            }
+        }
+
+        if (
+            ! is_numeric($rawRow[TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_NUMBER_OF_AUTHENTICATIONS])
+        ) {
+            $message = sprintf(
+                'Column %s must be numeric.',
+                TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_NUMBER_OF_AUTHENTICATIONS
+            );
+            throw new UnexpectedValueException($message);
+        }
+
+        if (! is_string($rawRow[TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_LAST_AUTHENTICATION_AT])) {
+            $message = sprintf(
+                'Column %s must be string.',
+                TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_LAST_AUTHENTICATION_AT
+            );
+            throw new UnexpectedValueException($message);
+        }
+
+        if (! is_string($rawRow[TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_FIRST_AUTHENTICATION_AT])) {
+            $message = sprintf(
+                'Column %s must be string.',
+                TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_FIRST_AUTHENTICATION_AT
+            );
+            throw new UnexpectedValueException($message);
+        }
+
+        if (! is_string($rawRow[TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_SP_METADATA])) {
+            $message = sprintf(
+                'Column %s must be string.',
+                TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_SP_METADATA
+            );
+            throw new UnexpectedValueException($message);
+        }
+
+        if (! is_string($rawRow[TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_USER_ATTRIBUTES])) {
+            $message = sprintf(
+                'Column %s must be string.',
+                TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_USER_ATTRIBUTES
+            );
+            throw new UnexpectedValueException($message);
+        }
+    }
+
+    protected function resolveServiceProviderMetadata(string $serializedMetadata): array
+    {
+        /** @psalm-suppress MixedAssignment - we check the type manually */
+        $metadata = unserialize($serializedMetadata);
+
+        if (is_array($metadata)) {
+            return $metadata;
+        }
+
+        $message = sprintf('Metadata not in expected array format, got type %s.', gettype($metadata));
+        throw new UnexpectedValueException($message);
+    }
+
+    protected function resolveUserAttributes(string $serializedUserAttributes): array
+    {
+        /** @psalm-suppress MixedAssignment - we check the type manually */
+        $userAttributes = unserialize($serializedUserAttributes);
+
+        if (is_array($userAttributes)) {
+            return $userAttributes;
+        }
+
+        $message = sprintf('User attributes not in expected array format, got type %s.', gettype($userAttributes));
+        throw new UnexpectedValueException($message);
+    }
+}
diff --git a/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Repository.php b/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Repository.php
new file mode 100644
index 0000000000000000000000000000000000000000..c3c41bd7aac393e9274ec46d05500366a16e728b
--- /dev/null
+++ b/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Repository.php
@@ -0,0 +1,1059 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store;
+
+use Doctrine\DBAL\ParameterType;
+use Doctrine\DBAL\Result;
+use Doctrine\DBAL\Types\Types;
+use Psr\Log\LoggerInterface;
+use SimpleSAML\Module\accounting\Exceptions\StoreException;
+use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection;
+use Throwable;
+
+class Repository
+{
+    protected Connection $connection;
+    protected LoggerInterface $logger;
+    protected string $tableNameIdp;
+    protected string $tableNameIdpVersion;
+    protected string $tableNameSp;
+    protected string $tableNameSpVersion;
+    protected string $tableNameUser;
+    protected string $tableNameUserVersion;
+    protected string $tableNameIdpSpUserVersion;
+    protected string $tableNameAuthenticationEvent;
+
+    public function __construct(Connection $connection, LoggerInterface $logger)
+    {
+        $this->connection = $connection;
+        $this->logger = $logger;
+
+        $this->tableNameIdp = $this->preparePrefixedTableName(TableConstants::TABLE_NAME_IDP);
+        $this->tableNameIdpVersion = $this->preparePrefixedTableName(TableConstants::TABLE_NAME_IDP_VERSION);
+        $this->tableNameSp = $this->preparePrefixedTableName(TableConstants::TABLE_NAME_SP);
+        $this->tableNameSpVersion = $this->preparePrefixedTableName(TableConstants::TABLE_NAME_SP_VERSION);
+        $this->tableNameUser = $this->preparePrefixedTableName(TableConstants::TABLE_NAME_USER);
+        $this->tableNameUserVersion = $this->preparePrefixedTableName(TableConstants::TABLE_NAME_USER_VERSION);
+        $this->tableNameIdpSpUserVersion =
+            $this->preparePrefixedTableName(TableConstants::TABLE_NAME_IDP_SP_USER_VERSION);
+        $this->tableNameAuthenticationEvent =
+            $this->preparePrefixedTableName(TableConstants::TABLE_NAME_AUTHENTICATION_EVENT);
+    }
+
+    protected function preparePrefixedTableName(string $tableName): string
+    {
+        return $this->connection->preparePrefixedTableName(TableConstants::TABLE_PREFIX . $tableName);
+    }
+
+    /**
+     * @throws StoreException
+     */
+    public function getIdp(string $entityIdHashSha256): Result
+    {
+        try {
+            $queryBuilder = $this->connection->dbal()->createQueryBuilder();
+
+            /** @psalm-suppress TooManyArguments */
+            $queryBuilder->select(
+                TableConstants::TABLE_IDP_COLUMN_NAME_ID,
+                TableConstants::TABLE_IDP_COLUMN_NAME_ENTITY_ID,
+                TableConstants::TABLE_IDP_COLUMN_NAME_ENTITY_ID_HASH_SHA256,
+                TableConstants::TABLE_IDP_COLUMN_NAME_CREATED_AT,
+            )
+                ->from($this->tableNameIdp)
+                ->where(
+                    TableConstants::TABLE_IDP_COLUMN_NAME_ENTITY_ID_HASH_SHA256 . ' = ' .
+                    $queryBuilder->createNamedParameter($entityIdHashSha256)
+                )->setMaxResults(1);
+
+            return $queryBuilder->executeQuery();
+        } catch (Throwable $exception) {
+            $message = sprintf(
+                'Error executing query to get IdP by entity ID hash SHA256 \'%s\'. Error was: %s.',
+                $entityIdHashSha256,
+                $exception->getMessage()
+            );
+            throw new StoreException($message, (int)$exception->getCode(), $exception);
+        }
+    }
+
+    /**
+     * @throws StoreException
+     */
+    public function insertIdp(
+        string $entityId,
+        string $entityIdHashSha256,
+        \DateTimeImmutable $createdAt = null
+    ): void {
+        $queryBuilder = $this->connection->dbal()->createQueryBuilder();
+
+        $createdAt = $createdAt ?? new \DateTimeImmutable();
+
+        $queryBuilder->insert($this->tableNameIdp)
+            ->values(
+                [
+                    TableConstants::TABLE_IDP_COLUMN_NAME_ENTITY_ID => ':' .
+                        TableConstants::TABLE_IDP_COLUMN_NAME_ENTITY_ID,
+                    TableConstants::TABLE_IDP_COLUMN_NAME_ENTITY_ID_HASH_SHA256 => ':' .
+                        TableConstants::TABLE_IDP_COLUMN_NAME_ENTITY_ID_HASH_SHA256,
+                    TableConstants::TABLE_IDP_COLUMN_NAME_CREATED_AT => ':' .
+                        TableConstants::TABLE_IDP_COLUMN_NAME_CREATED_AT,
+                ]
+            )
+            ->setParameters(
+                [
+                    TableConstants::TABLE_IDP_COLUMN_NAME_ENTITY_ID => $entityId,
+                    TableConstants::TABLE_IDP_COLUMN_NAME_ENTITY_ID_HASH_SHA256 => $entityIdHashSha256,
+                    TableConstants::TABLE_IDP_COLUMN_NAME_CREATED_AT => $createdAt,
+                ],
+                [
+                    TableConstants::TABLE_IDP_COLUMN_NAME_ENTITY_ID => Types::STRING,
+                    TableConstants::TABLE_IDP_COLUMN_NAME_ENTITY_ID_HASH_SHA256 => Types::STRING,
+                    TableConstants::TABLE_IDP_COLUMN_NAME_CREATED_AT => Types::DATETIMETZ_IMMUTABLE
+                ]
+            );
+
+        try {
+            $queryBuilder->executeStatement();
+        } catch (Throwable $exception) {
+            $message = sprintf('Error executing query to insert IdP. Error was: %s.', $exception->getMessage());
+            throw new StoreException($message, (int)$exception->getCode(), $exception);
+        }
+    }
+
+    /**
+     * @throws StoreException
+     */
+    public function getIdpVersion(int $idpId, string $metadataHashSha256): Result
+    {
+        try {
+            $queryBuilder = $this->connection->dbal()->createQueryBuilder();
+
+            /** @psalm-suppress TooManyArguments */
+            $queryBuilder->select(
+                TableConstants::TABLE_IDP_VERSION_COLUMN_NAME_ID,
+                TableConstants::TABLE_IDP_VERSION_COLUMN_NAME_IDP_ID,
+                TableConstants::TABLE_IDP_VERSION_COLUMN_NAME_METADATA,
+                TableConstants::TABLE_IDP_VERSION_COLUMN_NAME_METADATA_HASH_SHA256,
+                TableConstants::TABLE_IDP_VERSION_COLUMN_NAME_CREATED_AT,
+            )
+                ->from($this->tableNameIdpVersion)
+                ->where(
+                    $queryBuilder->expr()->and(
+                        $queryBuilder->expr()->eq(
+                            TableConstants::TABLE_IDP_VERSION_COLUMN_NAME_IDP_ID,
+                            $queryBuilder->createNamedParameter($idpId, ParameterType::INTEGER)
+                        ),
+                        $queryBuilder->expr()->eq(
+                            TableConstants::TABLE_IDP_VERSION_COLUMN_NAME_METADATA_HASH_SHA256,
+                            $queryBuilder->createNamedParameter($metadataHashSha256)
+                        )
+                    )
+                )->setMaxResults(1);
+
+            return $queryBuilder->executeQuery();
+        } catch (Throwable $exception) {
+            $message = sprintf(
+                'Error executing query to get IdP Version for IdP %s and metadata array hash %s. Error was: %s.',
+                $idpId,
+                $metadataHashSha256,
+                $exception->getMessage()
+            );
+            throw new StoreException($message, (int)$exception->getCode(), $exception);
+        }
+    }
+
+    /**
+     * @throws StoreException
+     */
+    public function insertIdpVersion(
+        int $idpId,
+        string $metadata,
+        string $metadataHashSha256,
+        \DateTimeImmutable $createdAt = null
+    ): void {
+        $queryBuilder = $this->connection->dbal()->createQueryBuilder();
+
+        $createdAt = $createdAt ?? new \DateTimeImmutable();
+
+        $queryBuilder->insert($this->tableNameIdpVersion)
+            ->values(
+                [
+                    TableConstants::TABLE_IDP_VERSION_COLUMN_NAME_IDP_ID => ':' .
+                        TableConstants::TABLE_IDP_VERSION_COLUMN_NAME_IDP_ID,
+                    TableConstants::TABLE_IDP_VERSION_COLUMN_NAME_METADATA => ':' .
+                        TableConstants::TABLE_IDP_VERSION_COLUMN_NAME_METADATA,
+                    TableConstants::TABLE_IDP_VERSION_COLUMN_NAME_METADATA_HASH_SHA256 => ':' .
+                        TableConstants::TABLE_IDP_VERSION_COLUMN_NAME_METADATA_HASH_SHA256,
+                    TableConstants::TABLE_IDP_VERSION_COLUMN_NAME_CREATED_AT => ':' .
+                        TableConstants::TABLE_IDP_VERSION_COLUMN_NAME_CREATED_AT,
+                ]
+            )
+            ->setParameters(
+                [
+                    TableConstants::TABLE_IDP_VERSION_COLUMN_NAME_IDP_ID => $idpId,
+                    TableConstants::TABLE_IDP_VERSION_COLUMN_NAME_METADATA => $metadata,
+                    TableConstants::TABLE_IDP_VERSION_COLUMN_NAME_METADATA_HASH_SHA256 => $metadataHashSha256,
+                    TableConstants::TABLE_IDP_VERSION_COLUMN_NAME_CREATED_AT => $createdAt,
+                ],
+                [
+                    TableConstants::TABLE_IDP_VERSION_COLUMN_NAME_IDP_ID => Types::BIGINT,
+                    TableConstants::TABLE_IDP_VERSION_COLUMN_NAME_METADATA => Types::TEXT,
+                    TableConstants::TABLE_IDP_VERSION_COLUMN_NAME_METADATA_HASH_SHA256 => Types::STRING,
+                    TableConstants::TABLE_IDP_VERSION_COLUMN_NAME_CREATED_AT => Types::DATETIMETZ_IMMUTABLE,
+                ]
+            );
+
+        try {
+            $queryBuilder->executeStatement();
+        } catch (Throwable $exception) {
+            $message = sprintf('Error executing query to insert IdP Version. Error was: %s.', $exception->getMessage());
+            throw new StoreException($message, (int)$exception->getCode(), $exception);
+        }
+    }
+
+    /**
+     * @throws StoreException
+     */
+    public function getSp(string $entityIdHashSha256): Result
+    {
+        try {
+            $queryBuilder = $this->connection->dbal()->createQueryBuilder();
+
+            /** @psalm-suppress TooManyArguments */
+            $queryBuilder->select(
+                TableConstants::TABLE_SP_COLUMN_NAME_ID,
+                TableConstants::TABLE_SP_COLUMN_NAME_ENTITY_ID,
+                TableConstants::TABLE_SP_COLUMN_NAME_ENTITY_ID_HASH_SHA256,
+                TableConstants::TABLE_SP_COLUMN_NAME_CREATED_AT,
+            )
+                ->from($this->tableNameSp)
+                ->where(
+                    TableConstants::TABLE_SP_COLUMN_NAME_ENTITY_ID_HASH_SHA256 . ' = ' .
+                    $queryBuilder->createNamedParameter($entityIdHashSha256)
+                )->setMaxResults(1);
+
+            return $queryBuilder->executeQuery();
+        } catch (Throwable $exception) {
+            $message = sprintf(
+                'Error executing query to get SP by entity ID hash SHA256 \'%s\'. Error was: %s.',
+                $entityIdHashSha256,
+                $exception->getMessage()
+            );
+            throw new StoreException($message, (int)$exception->getCode(), $exception);
+        }
+    }
+
+    public function insertSp(
+        string $entityId,
+        string $entityIdHashSha256,
+        \DateTimeImmutable $createdAt = null
+    ): void {
+        $queryBuilder = $this->connection->dbal()->createQueryBuilder();
+
+        $createdAt = $createdAt ?? new \DateTimeImmutable();
+
+        $queryBuilder->insert($this->tableNameSp)
+            ->values(
+                [
+                    TableConstants::TABLE_SP_COLUMN_NAME_ENTITY_ID => ':' .
+                        TableConstants::TABLE_SP_COLUMN_NAME_ENTITY_ID,
+                    TableConstants::TABLE_SP_COLUMN_NAME_ENTITY_ID_HASH_SHA256 => ':' .
+                        TableConstants::TABLE_SP_COLUMN_NAME_ENTITY_ID_HASH_SHA256,
+                    TableConstants::TABLE_SP_COLUMN_NAME_CREATED_AT => ':' .
+                        TableConstants::TABLE_SP_COLUMN_NAME_CREATED_AT,
+                ]
+            )
+            ->setParameters(
+                [
+                    TableConstants::TABLE_SP_COLUMN_NAME_ENTITY_ID => $entityId,
+                    TableConstants::TABLE_SP_COLUMN_NAME_ENTITY_ID_HASH_SHA256 => $entityIdHashSha256,
+                    TableConstants::TABLE_SP_COLUMN_NAME_CREATED_AT => $createdAt,
+                ],
+                [
+                    TableConstants::TABLE_SP_COLUMN_NAME_ENTITY_ID => Types::STRING,
+                    TableConstants::TABLE_SP_COLUMN_NAME_ENTITY_ID_HASH_SHA256 => Types::STRING,
+                    TableConstants::TABLE_SP_COLUMN_NAME_CREATED_AT => Types::DATETIMETZ_IMMUTABLE
+                ]
+            );
+
+        try {
+            $queryBuilder->executeStatement();
+        } catch (Throwable $exception) {
+            $message = sprintf('Error executing query to insert SP. Error was: %s.', $exception->getMessage());
+            throw new StoreException($message, (int)$exception->getCode(), $exception);
+        }
+    }
+
+    /**
+     * @throws StoreException
+     */
+    public function getSpVersion(int $spId, string $metadataHashSha256): Result
+    {
+        try {
+            $queryBuilder = $this->connection->dbal()->createQueryBuilder();
+
+            /** @psalm-suppress TooManyArguments */
+            $queryBuilder->select(
+                TableConstants::TABLE_SP_VERSION_COLUMN_NAME_ID,
+                TableConstants::TABLE_SP_VERSION_COLUMN_NAME_SP_ID,
+                TableConstants::TABLE_SP_VERSION_COLUMN_NAME_METADATA,
+                TableConstants::TABLE_SP_VERSION_COLUMN_NAME_METADATA_HASH_SHA256,
+                TableConstants::TABLE_SP_VERSION_COLUMN_NAME_CREATED_AT,
+            )
+                ->from($this->tableNameSpVersion)
+                ->where(
+                    $queryBuilder->expr()->and(
+                        $queryBuilder->expr()->eq(
+                            TableConstants::TABLE_SP_VERSION_COLUMN_NAME_SP_ID,
+                            $queryBuilder->createNamedParameter($spId, ParameterType::INTEGER)
+                        ),
+                        $queryBuilder->expr()->eq(
+                            TableConstants::TABLE_SP_VERSION_COLUMN_NAME_METADATA_HASH_SHA256,
+                            $queryBuilder->createNamedParameter($metadataHashSha256)
+                        )
+                    )
+                )->setMaxResults(1);
+
+            return $queryBuilder->executeQuery();
+        } catch (Throwable $exception) {
+            $message = sprintf(
+                'Error executing query to get SP Version for SP %s and metadata array hash %s. Error was: %s.',
+                $spId,
+                $metadataHashSha256,
+                $exception->getMessage()
+            );
+            throw new StoreException($message, (int)$exception->getCode(), $exception);
+        }
+    }
+
+    /**
+     * @throws StoreException
+     */
+    public function insertSpVersion(
+        int $spId,
+        string $metadata,
+        string $metadataHashSha256,
+        \DateTimeImmutable $createdAt = null
+    ): void {
+        $queryBuilder = $this->connection->dbal()->createQueryBuilder();
+
+        $createdAt = $createdAt ?? new \DateTimeImmutable();
+
+        $queryBuilder->insert($this->tableNameSpVersion)
+            ->values(
+                [
+                    TableConstants::TABLE_SP_VERSION_COLUMN_NAME_SP_ID => ':' .
+                        TableConstants::TABLE_SP_VERSION_COLUMN_NAME_SP_ID,
+                    TableConstants::TABLE_SP_VERSION_COLUMN_NAME_METADATA => ':' .
+                        TableConstants::TABLE_SP_VERSION_COLUMN_NAME_METADATA,
+                    TableConstants::TABLE_SP_VERSION_COLUMN_NAME_METADATA_HASH_SHA256 => ':' .
+                        TableConstants::TABLE_SP_VERSION_COLUMN_NAME_METADATA_HASH_SHA256,
+                    TableConstants::TABLE_SP_VERSION_COLUMN_NAME_CREATED_AT => ':' .
+                        TableConstants::TABLE_SP_VERSION_COLUMN_NAME_CREATED_AT,
+                ]
+            )
+            ->setParameters(
+                [
+                    TableConstants::TABLE_SP_VERSION_COLUMN_NAME_SP_ID => $spId,
+                    TableConstants::TABLE_SP_VERSION_COLUMN_NAME_METADATA => $metadata,
+                    TableConstants::TABLE_SP_VERSION_COLUMN_NAME_METADATA_HASH_SHA256 => $metadataHashSha256,
+                    TableConstants::TABLE_SP_VERSION_COLUMN_NAME_CREATED_AT => $createdAt,
+                ],
+                [
+                    TableConstants::TABLE_SP_VERSION_COLUMN_NAME_SP_ID => Types::BIGINT,
+                    TableConstants::TABLE_SP_VERSION_COLUMN_NAME_METADATA => Types::TEXT,
+                    TableConstants::TABLE_SP_VERSION_COLUMN_NAME_METADATA_HASH_SHA256 => Types::STRING,
+                    TableConstants::TABLE_SP_VERSION_COLUMN_NAME_CREATED_AT => Types::DATETIMETZ_IMMUTABLE,
+                ]
+            );
+
+        try {
+            $queryBuilder->executeStatement();
+        } catch (Throwable $exception) {
+            $message = sprintf('Error executing query to insert SP Version. Error was: %s.', $exception->getMessage());
+            throw new StoreException($message, (int)$exception->getCode(), $exception);
+        }
+    }
+
+    /**
+     * @throws StoreException
+     */
+    public function getUser(string $identifierHashSha256): Result
+    {
+        try {
+            $queryBuilder = $this->connection->dbal()->createQueryBuilder();
+
+            /** @psalm-suppress TooManyArguments */
+            $queryBuilder->select(
+                TableConstants::TABLE_USER_COLUMN_NAME_ID,
+                TableConstants::TABLE_USER_COLUMN_NAME_IDENTIFIER,
+                TableConstants::TABLE_USER_COLUMN_NAME_IDENTIFIER_HASH_SHA256,
+                TableConstants::TABLE_USER_COLUMN_NAME_CREATED_AT,
+            )
+                ->from($this->tableNameUser)
+                ->where(
+                    TableConstants::TABLE_USER_COLUMN_NAME_IDENTIFIER_HASH_SHA256 . ' = ' .
+                    $queryBuilder->createNamedParameter($identifierHashSha256)
+                )->setMaxResults(1);
+
+            return $queryBuilder->executeQuery();
+        } catch (Throwable $exception) {
+            $message = sprintf(
+                'Error executing query to get user by identifier hash SHA256 \'%s\'. Error was: %s.',
+                $identifierHashSha256,
+                $exception->getMessage()
+            );
+            throw new StoreException($message, (int)$exception->getCode(), $exception);
+        }
+    }
+
+    /**
+     * @throws StoreException
+     */
+    public function insertUser(
+        string $identifier,
+        string $identifierHashSha256,
+        \DateTimeImmutable $createdAt = null
+    ): void {
+        $queryBuilder = $this->connection->dbal()->createQueryBuilder();
+
+        $createdAt = $createdAt ?? new \DateTimeImmutable();
+
+        $queryBuilder->insert($this->tableNameUser)
+            ->values(
+                [
+                    TableConstants::TABLE_USER_COLUMN_NAME_IDENTIFIER => ':' .
+                        TableConstants::TABLE_USER_COLUMN_NAME_IDENTIFIER,
+                    TableConstants::TABLE_USER_COLUMN_NAME_IDENTIFIER_HASH_SHA256 => ':' .
+                        TableConstants::TABLE_USER_COLUMN_NAME_IDENTIFIER_HASH_SHA256,
+                    TableConstants::TABLE_USER_COLUMN_NAME_CREATED_AT => ':' .
+                        TableConstants::TABLE_USER_COLUMN_NAME_CREATED_AT,
+                ]
+            )
+            ->setParameters(
+                [
+                    TableConstants::TABLE_USER_COLUMN_NAME_IDENTIFIER => $identifier,
+                    TableConstants::TABLE_USER_COLUMN_NAME_IDENTIFIER_HASH_SHA256 => $identifierHashSha256,
+                    TableConstants::TABLE_USER_COLUMN_NAME_CREATED_AT => $createdAt,
+                ],
+                [
+                    TableConstants::TABLE_USER_COLUMN_NAME_IDENTIFIER => Types::TEXT,
+                    TableConstants::TABLE_USER_COLUMN_NAME_IDENTIFIER_HASH_SHA256 => Types::STRING,
+                    TableConstants::TABLE_USER_COLUMN_NAME_CREATED_AT => Types::DATETIMETZ_IMMUTABLE
+                ]
+            );
+
+        try {
+            $queryBuilder->executeStatement();
+        } catch (Throwable $exception) {
+            $message = sprintf('Error executing query to insert user. Error was: %s.', $exception->getMessage());
+            throw new StoreException($message, (int)$exception->getCode(), $exception);
+        }
+    }
+
+    /**
+     * @throws StoreException
+     */
+    public function getUserVersion(int $userId, string $attributesHashSha256): Result
+    {
+        try {
+            $queryBuilder = $this->connection->dbal()->createQueryBuilder();
+
+            /** @psalm-suppress TooManyArguments */
+            $queryBuilder->select(
+                TableConstants::TABLE_USER_VERSION_COLUMN_NAME_ID,
+                TableConstants::TABLE_USER_VERSION_COLUMN_NAME_USER_ID,
+                TableConstants::TABLE_USER_VERSION_COLUMN_NAME_ATTRIBUTES,
+                TableConstants::TABLE_USER_VERSION_COLUMN_NAME_ATTRIBUTES_HASH_SHA256,
+                TableConstants::TABLE_USER_VERSION_COLUMN_NAME_CREATED_AT,
+            )
+                ->from($this->tableNameUserVersion)
+                ->where(
+                    $queryBuilder->expr()->and(
+                        $queryBuilder->expr()->eq(
+                            TableConstants::TABLE_USER_VERSION_COLUMN_NAME_USER_ID,
+                            $queryBuilder->createNamedParameter($userId, ParameterType::INTEGER)
+                        ),
+                        $queryBuilder->expr()->eq(
+                            TableConstants::TABLE_USER_VERSION_COLUMN_NAME_ATTRIBUTES_HASH_SHA256,
+                            $queryBuilder->createNamedParameter($attributesHashSha256)
+                        )
+                    )
+                )->setMaxResults(1);
+
+            return $queryBuilder->executeQuery();
+        } catch (Throwable $exception) {
+            $message = sprintf(
+                'Error executing query to get user version for user ID %s and attribute array hash %s. Error was: %s.',
+                $userId,
+                $attributesHashSha256,
+                $exception->getMessage()
+            );
+            throw new StoreException($message, (int)$exception->getCode(), $exception);
+        }
+    }
+
+    /**
+     * @throws StoreException
+     */
+    public function insertUserVersion(
+        int $userId,
+        string $attributes,
+        string $attributesHashSha256,
+        \DateTimeImmutable $createdAt = null
+    ): void {
+        $queryBuilder = $this->connection->dbal()->createQueryBuilder();
+
+        $createdAt = $createdAt ?? new \DateTimeImmutable();
+
+        $queryBuilder->insert($this->tableNameUserVersion)
+            ->values(
+                [
+                    TableConstants::TABLE_USER_VERSION_COLUMN_NAME_USER_ID => ':' .
+                        TableConstants::TABLE_USER_VERSION_COLUMN_NAME_USER_ID,
+                    TableConstants::TABLE_USER_VERSION_COLUMN_NAME_ATTRIBUTES => ':' .
+                        TableConstants::TABLE_USER_VERSION_COLUMN_NAME_ATTRIBUTES,
+                    TableConstants::TABLE_USER_VERSION_COLUMN_NAME_ATTRIBUTES_HASH_SHA256 => ':' .
+                        TableConstants::TABLE_USER_VERSION_COLUMN_NAME_ATTRIBUTES_HASH_SHA256,
+                    TableConstants::TABLE_USER_VERSION_COLUMN_NAME_CREATED_AT => ':' .
+                        TableConstants::TABLE_USER_VERSION_COLUMN_NAME_CREATED_AT,
+                ]
+            )
+            ->setParameters(
+                [
+                    TableConstants::TABLE_USER_VERSION_COLUMN_NAME_USER_ID => $userId,
+                    TableConstants::TABLE_USER_VERSION_COLUMN_NAME_ATTRIBUTES => $attributes,
+                    TableConstants::TABLE_USER_VERSION_COLUMN_NAME_ATTRIBUTES_HASH_SHA256 => $attributesHashSha256,
+                    TableConstants::TABLE_USER_VERSION_COLUMN_NAME_CREATED_AT => $createdAt,
+                ],
+                [
+                    TableConstants::TABLE_USER_VERSION_COLUMN_NAME_USER_ID => Types::BIGINT,
+                    TableConstants::TABLE_USER_VERSION_COLUMN_NAME_ATTRIBUTES => Types::TEXT,
+                    TableConstants::TABLE_USER_VERSION_COLUMN_NAME_ATTRIBUTES_HASH_SHA256 => Types::STRING,
+                    TableConstants::TABLE_USER_VERSION_COLUMN_NAME_CREATED_AT => Types::DATETIMETZ_IMMUTABLE,
+                ]
+            );
+
+        try {
+            $queryBuilder->executeStatement();
+        } catch (Throwable $exception) {
+            $message = sprintf(
+                'Error executing query to insert user version. Error was: %s.',
+                $exception->getMessage()
+            );
+            throw new StoreException($message, (int)$exception->getCode(), $exception);
+        }
+    }
+
+    /**
+     * @throws StoreException
+     */
+    public function getIdpSpUserVersion(int $idpVersionId, int $spVersionId, int $userVersionId): Result
+    {
+        try {
+            $queryBuilder = $this->connection->dbal()->createQueryBuilder();
+
+            /** @psalm-suppress TooManyArguments */
+            $queryBuilder->select(
+                TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_ID,
+                TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_IDP_VERSION_ID,
+                TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_SP_VERSION_ID,
+                TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_USER_VERSION_ID,
+                TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_CREATED_AT,
+            )
+                ->from($this->tableNameIdpSpUserVersion)
+                ->where(
+                    $queryBuilder->expr()->and(
+                        $queryBuilder->expr()->eq(
+                            TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_IDP_VERSION_ID,
+                            $queryBuilder->createNamedParameter($idpVersionId, ParameterType::INTEGER)
+                        ),
+                        $queryBuilder->expr()->eq(
+                            TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_SP_VERSION_ID,
+                            $queryBuilder->createNamedParameter($spVersionId, ParameterType::INTEGER)
+                        ),
+                        $queryBuilder->expr()->eq(
+                            TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_USER_VERSION_ID,
+                            $queryBuilder->createNamedParameter($userVersionId, ParameterType::INTEGER)
+                        )
+                    )
+                )->setMaxResults(1);
+
+            return $queryBuilder->executeQuery();
+        } catch (Throwable $exception) {
+            $message = sprintf(
+                'Error executing query to get IdpSpUserVersion for IdpVersion %s, SpVersion %s and UserVersion %s.' .
+                ' Error was: %s.',
+                $idpVersionId,
+                $spVersionId,
+                $userVersionId,
+                $exception->getMessage()
+            );
+            throw new StoreException($message, (int)$exception->getCode(), $exception);
+        }
+    }
+
+    /**
+     * @throws StoreException
+     */
+    public function insertIdpSpUserVersion(
+        int $idpVersionId,
+        int $spVersionId,
+        int $userVersionId,
+        \DateTimeImmutable $createdAt = null
+    ): void {
+        $queryBuilder = $this->connection->dbal()->createQueryBuilder();
+
+        $createdAt = $createdAt ?? new \DateTimeImmutable();
+
+        $queryBuilder->insert($this->tableNameIdpSpUserVersion)
+            ->values(
+                [
+                    TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_IDP_VERSION_ID => ':' .
+                        TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_IDP_VERSION_ID,
+                    TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_SP_VERSION_ID => ':' .
+                        TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_SP_VERSION_ID,
+                    TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_USER_VERSION_ID => ':' .
+                        TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_USER_VERSION_ID,
+                    TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_CREATED_AT => ':' .
+                        TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_CREATED_AT,
+                ]
+            )
+            ->setParameters(
+                [
+                    TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_IDP_VERSION_ID => $idpVersionId,
+                    TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_SP_VERSION_ID => $spVersionId,
+                    TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_USER_VERSION_ID => $userVersionId,
+                    TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_CREATED_AT => $createdAt,
+                ],
+                [
+                    TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_IDP_VERSION_ID => Types::BIGINT,
+                    TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_SP_VERSION_ID => Types::BIGINT,
+                    TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_USER_VERSION_ID => Types::BIGINT,
+                    TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_CREATED_AT => Types::DATETIMETZ_IMMUTABLE
+                ]
+            );
+
+        try {
+            $queryBuilder->executeStatement();
+        } catch (Throwable $exception) {
+            $message = sprintf(
+                'Error executing query to insert IdpSpUserVersion. Error was: %s.',
+                $exception->getMessage()
+            );
+            throw new StoreException($message, (int)$exception->getCode(), $exception);
+        }
+    }
+
+    /**
+     * @throws StoreException
+     */
+    public function insertAuthenticationEvent(
+        int $IdpSpUserVersionId,
+        \DateTimeImmutable $happenedAt,
+        \DateTimeImmutable $createdAt = null
+    ): void {
+        try {
+            $queryBuilder = $this->connection->dbal()->createQueryBuilder();
+
+            $createdAt = $createdAt ?? new \DateTimeImmutable();
+
+            $queryBuilder->insert($this->tableNameAuthenticationEvent)
+                ->values(
+                    [
+                        TableConstants::TABLE_AUTHENTICATION_EVENT_COLUMN_NAME_IDP_SP_USER_VERSION_ID => ':' .
+                            TableConstants::TABLE_AUTHENTICATION_EVENT_COLUMN_NAME_IDP_SP_USER_VERSION_ID,
+                        TableConstants::TABLE_AUTHENTICATION_EVENT_COLUMN_NAME_HAPPENED_AT => ':' .
+                            TableConstants::TABLE_AUTHENTICATION_EVENT_COLUMN_NAME_HAPPENED_AT,
+                        TableConstants::TABLE_AUTHENTICATION_EVENT_COLUMN_NAME_CREATED_AT => ':' .
+                            TableConstants::TABLE_AUTHENTICATION_EVENT_COLUMN_NAME_CREATED_AT,
+                    ]
+                )
+                ->setParameters(
+                    [
+                        TableConstants::TABLE_AUTHENTICATION_EVENT_COLUMN_NAME_IDP_SP_USER_VERSION_ID =>
+                            $IdpSpUserVersionId,
+                        TableConstants::TABLE_AUTHENTICATION_EVENT_COLUMN_NAME_HAPPENED_AT => $happenedAt,
+                        TableConstants::TABLE_AUTHENTICATION_EVENT_COLUMN_NAME_CREATED_AT => $createdAt,
+                    ],
+                    [
+                        TableConstants::TABLE_AUTHENTICATION_EVENT_COLUMN_NAME_IDP_SP_USER_VERSION_ID =>
+                            Types::BIGINT,
+                        TableConstants::TABLE_AUTHENTICATION_EVENT_COLUMN_NAME_HAPPENED_AT =>
+                            Types::DATETIMETZ_IMMUTABLE,
+                        TableConstants::TABLE_AUTHENTICATION_EVENT_COLUMN_NAME_CREATED_AT =>
+                            Types::DATETIMETZ_IMMUTABLE,
+                    ]
+                );
+
+            $queryBuilder->executeStatement();
+        } catch (Throwable $exception) {
+            $message = sprintf(
+                'Error executing query to insert AuthenticationEvent. Error was: %s.',
+                $exception->getMessage()
+            );
+            throw new StoreException($message, (int)$exception->getCode(), $exception);
+        }
+    }
+
+    /**
+     * @throws StoreException
+     */
+    public function getConnectedServiceProviders(string $userIdentifierHashSha256): array
+    {
+        try {
+            $authenticationEventsQueryBuilder = $this->connection->dbal()->createQueryBuilder();
+            $lastMetadataAndAttributesQueryBuilder = $this->connection->dbal()->createQueryBuilder();
+
+            /** @psalm-suppress TooManyArguments */
+            $authenticationEventsQueryBuilder->select(
+                //'vs.entity_id AS sp_entity_id',
+                TableConstants::TABLE_ALIAS_SP . '.' .
+                TableConstants::TABLE_SP_COLUMN_NAME_ENTITY_ID . ' AS ' .
+                TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_SP_ENTITY_ID,
+                //'COUNT(vae.id) AS number_of_authentications',
+                'COUNT(' .  TableConstants::TABLE_ALIAS_AUTHENTICATION_EVENT . '.' .
+                TableConstants::TABLE_AUTHENTICATION_EVENT_COLUMN_NAME_ID . ') AS ' .
+                TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_NUMBER_OF_AUTHENTICATIONS,
+                //'MAX(vae.happened_at) AS last_authentication_at',
+                'MAX(' .  TableConstants::TABLE_ALIAS_AUTHENTICATION_EVENT . '.' .
+                TableConstants::TABLE_AUTHENTICATION_EVENT_COLUMN_NAME_HAPPENED_AT . ') AS ' .
+                TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_LAST_AUTHENTICATION_AT,
+                //'MIN(vae.happened_at) AS first_authentication_at',
+                'MIN(' .  TableConstants::TABLE_ALIAS_AUTHENTICATION_EVENT . '.' .
+                TableConstants::TABLE_AUTHENTICATION_EVENT_COLUMN_NAME_HAPPENED_AT . ') AS ' .
+                TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_FIRST_AUTHENTICATION_AT,
+            )->from($this->tableNameAuthenticationEvent, TableConstants::TABLE_ALIAS_AUTHENTICATION_EVENT)
+                ->leftJoin(
+                    //'vae',
+                    TableConstants::TABLE_ALIAS_AUTHENTICATION_EVENT,
+                    //'vds_idp_sp_user_version',
+                    $this->tableNameIdpSpUserVersion,
+                    //'visuv',
+                    TableConstants::TABLE_ALIAS_IDP_SP_USER_VERSION,
+                    //'vae.idp_sp_user_version_id = visuv.id'
+                    TableConstants::TABLE_ALIAS_AUTHENTICATION_EVENT . '.' .
+                    TableConstants::TABLE_AUTHENTICATION_EVENT_COLUMN_NAME_IDP_SP_USER_VERSION_ID . ' = ' .
+                    TableConstants::TABLE_ALIAS_IDP_SP_USER_VERSION . '.' .
+                    TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_ID
+                )
+                ->leftJoin(
+                    //'visuv',
+                    TableConstants::TABLE_ALIAS_IDP_SP_USER_VERSION,
+                    //'vds_sp_version',
+                    $this->tableNameSpVersion,
+                    //'vsv',
+                    TableConstants::TABLE_ALIAS_SP_VERSION,
+                    //'visuv.sp_version_id = vsv.id'
+                    TableConstants::TABLE_ALIAS_IDP_SP_USER_VERSION . '.' .
+                    TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_SP_VERSION_ID . ' = ' .
+                    TableConstants::TABLE_ALIAS_SP_VERSION . '.' . TableConstants::TABLE_SP_VERSION_COLUMN_NAME_ID
+                )
+                ->leftJoin(
+                    //'vsv',
+                    TableConstants::TABLE_ALIAS_SP_VERSION,
+                    //'vds_sp',
+                    $this->tableNameSp,
+                    //'vs',
+                    TableConstants::TABLE_ALIAS_SP,
+                    //'vsv.sp_id = vs.id'
+                    TableConstants::TABLE_ALIAS_SP_VERSION . '.' .
+                    TableConstants::TABLE_SP_VERSION_COLUMN_NAME_SP_ID . ' = ' .
+                    TableConstants::TABLE_ALIAS_SP . '.' . TableConstants::TABLE_SP_COLUMN_NAME_ID
+                )
+                ->leftJoin(
+                    //'visuv',
+                    TableConstants::TABLE_ALIAS_IDP_SP_USER_VERSION,
+                    //'vds_user_version',
+                    $this->tableNameUserVersion,
+                    //'vuv',
+                    TableConstants::TABLE_ALIAS_USER_VERSION,
+                    //'visuv.user_version_id = vuv.id'
+                    TableConstants::TABLE_ALIAS_IDP_SP_USER_VERSION . '.' .
+                    TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_USER_VERSION_ID . ' = ' .
+                    TableConstants::TABLE_ALIAS_USER_VERSION . '.' . TableConstants::TABLE_USER_VERSION_COLUMN_NAME_ID
+                )
+                ->leftJoin(
+                    //'vuv',
+                    TableConstants::TABLE_ALIAS_USER_VERSION,
+                    //'vds_user',
+                    $this->tableNameUser,
+                    //'vu',
+                    TableConstants::TABLE_ALIAS_USER,
+                    //'vuv.user_id = vu.id'
+                    TableConstants::TABLE_ALIAS_USER_VERSION . '.' .
+                    TableConstants::TABLE_USER_VERSION_COLUMN_NAME_USER_ID . ' = ' .
+                    TableConstants::TABLE_ALIAS_USER . '.' . TableConstants::TABLE_USER_COLUMN_NAME_ID
+                )
+                ->where(
+                    //'vu.identifier_hash_sha256 = ' .
+                    TableConstants::TABLE_ALIAS_USER . '.' .
+                    TableConstants::TABLE_USER_COLUMN_NAME_IDENTIFIER_HASH_SHA256 . ' = ' .
+                    $authenticationEventsQueryBuilder->createNamedParameter($userIdentifierHashSha256)
+                )
+                ->groupBy(
+                    //'vs.id'
+                    TableConstants::TABLE_ALIAS_SP . '.' . TableConstants::TABLE_SP_COLUMN_NAME_ID
+                )
+                ->orderBy(
+                    //'number_of_authentications',
+                    TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_NUMBER_OF_AUTHENTICATIONS,
+                    'DESC'
+                );
+
+
+            /** @psalm-suppress TooManyArguments */
+            $lastMetadataAndAttributesQueryBuilder->select(
+                //'vs.entity_id AS sp_entity_id',
+                TableConstants::TABLE_ALIAS_SP . '.' . TableConstants::TABLE_SP_COLUMN_NAME_ENTITY_ID . ' AS ' .
+                TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_SP_ENTITY_ID,
+                //'vsv.metadata AS sp_metadata',
+                TableConstants::TABLE_ALIAS_SP_VERSION . '.' . TableConstants::TABLE_SP_VERSION_COLUMN_NAME_METADATA .
+                ' AS ' . TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_SP_METADATA,
+                //'vuv.attributes AS user_attributes',
+                TableConstants::TABLE_ALIAS_USER_VERSION . '.' .
+                TableConstants::TABLE_USER_VERSION_COLUMN_NAME_ATTRIBUTES . ' AS ' .
+                TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_USER_ATTRIBUTES
+                //            'vsv.id AS sp_version_id',
+                //            'vuv.id AS user_version_id',
+            )->from(
+                //'vds_authentication_event',
+                $this->tableNameAuthenticationEvent,
+                //'vae'
+                TableConstants::TABLE_ALIAS_AUTHENTICATION_EVENT
+            )
+                ->leftJoin(
+                    //'vae',
+                    TableConstants::TABLE_ALIAS_AUTHENTICATION_EVENT,
+                    //'vds_idp_sp_user_version',
+                    $this->tableNameIdpSpUserVersion,
+                    //'visuv',
+                    TableConstants::TABLE_ALIAS_IDP_SP_USER_VERSION,
+                    //'vae.idp_sp_user_version_id = visuv.id'
+                    TableConstants::TABLE_ALIAS_AUTHENTICATION_EVENT . '.' .
+                    TableConstants::TABLE_AUTHENTICATION_EVENT_COLUMN_NAME_IDP_SP_USER_VERSION_ID . ' = ' .
+                    TableConstants::TABLE_ALIAS_IDP_SP_USER_VERSION . '.' .
+                    TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_ID
+                )
+                ->leftJoin(
+                    //'visuv',
+                    TableConstants::TABLE_ALIAS_IDP_SP_USER_VERSION,
+                    //'vds_sp_version',
+                    $this->tableNameSpVersion,
+                    //'vsv',
+                    TableConstants::TABLE_ALIAS_SP_VERSION,
+                    //'visuv.sp_version_id = vsv.id'
+                    TableConstants::TABLE_ALIAS_IDP_SP_USER_VERSION . '.' .
+                    TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_SP_VERSION_ID . ' = ' .
+                    TableConstants::TABLE_ALIAS_SP_VERSION . '.' . TableConstants::TABLE_SP_VERSION_COLUMN_NAME_ID
+                )
+                ->leftJoin(
+                    //'vsv',
+                    TableConstants::TABLE_ALIAS_SP_VERSION,
+                    //'vds_sp',
+                    $this->tableNameSp,
+                    //'vs',
+                    TableConstants::TABLE_ALIAS_SP,
+                    //'vsv.sp_id = vs.id'
+                    TableConstants::TABLE_ALIAS_SP_VERSION . '.' .
+                    TableConstants::TABLE_SP_VERSION_COLUMN_NAME_SP_ID . ' = ' . TableConstants::TABLE_ALIAS_SP . '.' .
+                    TableConstants::TABLE_SP_COLUMN_NAME_ID
+                )
+                ->leftJoin(
+                    //'visuv',
+                    TableConstants::TABLE_ALIAS_IDP_SP_USER_VERSION,
+                    //'vds_user_version',
+                    $this->tableNameUserVersion,
+                    //'vuv',
+                    TableConstants::TABLE_ALIAS_USER_VERSION,
+                    //'visuv.user_version_id = vuv.id'
+                    TableConstants::TABLE_ALIAS_IDP_SP_USER_VERSION . '.' .
+                    TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_USER_VERSION_ID . ' = ' .
+                    TableConstants::TABLE_ALIAS_USER_VERSION . '.' . TableConstants::TABLE_USER_VERSION_COLUMN_NAME_ID
+                )
+                ->leftJoin(
+                    //'vuv',
+                    TableConstants::TABLE_ALIAS_USER_VERSION,
+                    //'vds_user',
+                    $this->tableNameUser,
+                    //'vu',
+                    TableConstants::TABLE_ALIAS_USER,
+                    //'vuv.user_id = vu.id'
+                    TableConstants::TABLE_ALIAS_USER_VERSION . '.' .
+                    TableConstants::TABLE_USER_VERSION_COLUMN_NAME_USER_ID . ' = ' . TableConstants::TABLE_ALIAS_USER .
+                    '.' . TableConstants::TABLE_USER_COLUMN_NAME_ID
+                )
+                ->leftJoin(
+                    //'vsv',
+                    TableConstants::TABLE_ALIAS_SP_VERSION,
+                    //'vds_sp_version',
+                    $this->tableNameSpVersion,
+                    //'vsv2',
+                    TableConstants::TABLE_ALIAS_SP_VERSION_2, // Another alias for self joining...
+                    //'vsv.id = vsv2.id AND vsv.id < vsv2.id' // To be able to get latest one...
+                    TableConstants::TABLE_ALIAS_SP_VERSION . '.' .
+                    TableConstants::TABLE_SP_VERSION_COLUMN_NAME_ID . ' = ' . TableConstants::TABLE_ALIAS_SP_VERSION_2 .
+                    '.' . TableConstants::TABLE_SP_VERSION_COLUMN_NAME_ID . ' AND ' .
+                    TableConstants::TABLE_ALIAS_SP_VERSION . '.' . TableConstants::TABLE_SP_VERSION_COLUMN_NAME_ID .
+                    ' < ' . TableConstants::TABLE_ALIAS_SP_VERSION_2 . '.' .
+                    TableConstants::TABLE_SP_VERSION_COLUMN_NAME_ID
+                )
+                ->leftJoin(
+                    //'vuv',
+                    TableConstants::TABLE_ALIAS_USER_VERSION,
+                    //'vds_user_version',
+                    $this->tableNameUserVersion,
+                    //'vuv2',
+                    TableConstants::TABLE_ALIAS_USER_VERSION_2, // Another alias for self joining...
+                    //'vuv.id = vuv2.id AND vuv.id < vuv2.id' // To be able to get latest one...
+                    TableConstants::TABLE_ALIAS_USER_VERSION . '.' .
+                    TableConstants::TABLE_USER_VERSION_COLUMN_NAME_ID . ' = ' .
+                    TableConstants::TABLE_ALIAS_USER_VERSION_2
+                    . '.' . TableConstants::TABLE_USER_VERSION_COLUMN_NAME_ID . ' AND ' .
+                    TableConstants::TABLE_ALIAS_USER_VERSION . '.' . TableConstants::TABLE_USER_VERSION_COLUMN_NAME_ID .
+                    ' < ' . TableConstants::TABLE_ALIAS_USER_VERSION_2 . '.' .
+                    TableConstants::TABLE_USER_VERSION_COLUMN_NAME_ID
+                )
+                ->where(
+                    //'vu.identifier_hash_sha256 = ' .
+                    TableConstants::TABLE_ALIAS_USER . '.' .
+                    TableConstants::TABLE_USER_COLUMN_NAME_IDENTIFIER_HASH_SHA256 . ' = ' .
+                    $lastMetadataAndAttributesQueryBuilder->createNamedParameter($userIdentifierHashSha256)
+                )
+                ->andWhere(
+                    //'vsv2.id IS NULL'
+                    TableConstants::TABLE_ALIAS_SP_VERSION_2 . '.' . TableConstants::TABLE_SP_VERSION_COLUMN_NAME_ID
+                    . ' IS NULL'
+                )
+                ->andWhere(
+                    //'vuv2.id IS NULL'
+                    TableConstants::TABLE_ALIAS_USER_VERSION_2 . '.' .
+                    TableConstants::TABLE_USER_VERSION_COLUMN_NAME_ID . ' IS NULL'
+                );
+
+            $numberOfAuthentications = $authenticationEventsQueryBuilder->executeQuery()->fetchAllAssociativeIndexed();
+            $lastMetadataAndAttributes =
+                $lastMetadataAndAttributesQueryBuilder->executeQuery()->fetchAllAssociativeIndexed();
+
+            return array_merge_recursive($numberOfAuthentications, $lastMetadataAndAttributes);
+        } catch (\Throwable $exception) {
+            $message = sprintf(
+                'Error executing query to get connected organizations. Error was: %s.',
+                $exception->getMessage()
+            );
+            throw new StoreException($message, (int)$exception->getCode(), $exception);
+        }
+    }
+
+    /**
+     * @throws StoreException
+     */
+    public function getActivity(string $userIdentifierHashSha256): array
+    {
+        try {
+            $authenticationEventsQueryBuilder = $this->connection->dbal()->createQueryBuilder();
+
+            /** @psalm-suppress TooManyArguments */
+            $authenticationEventsQueryBuilder->select(
+                //'vae.happened_at',
+                TableConstants::TABLE_ALIAS_AUTHENTICATION_EVENT . '.' .
+                TableConstants::TABLE_AUTHENTICATION_EVENT_COLUMN_NAME_HAPPENED_AT,
+                //'vsv.metadata AS sp_metadata',
+                TableConstants::TABLE_ALIAS_SP_VERSION . '.' . TableConstants::TABLE_SP_VERSION_COLUMN_NAME_METADATA .
+                ' AS ' . TableConstants::ENTITY_ACTIVITY_COLUMN_NAME_SP_METADATA,
+                //'vuv.attributes AS user_attributes'
+                TableConstants::TABLE_ALIAS_USER_VERSION . '.' .
+                TableConstants::TABLE_USER_VERSION_COLUMN_NAME_ATTRIBUTES . ' AS ' .
+                TableConstants::ENTITY_ACTIVITY_COLUMN_NAME_USER_ATTRIBUTES
+            )->from(
+                //'vds_authentication_event', 'vae'
+                $this->tableNameAuthenticationEvent,
+                //'vae'
+                TableConstants::TABLE_ALIAS_AUTHENTICATION_EVENT
+            )
+                ->leftJoin(
+                    //'vae',
+                    TableConstants::TABLE_ALIAS_AUTHENTICATION_EVENT,
+                    //'vds_idp_sp_user_version',
+                    $this->tableNameIdpSpUserVersion,
+                    //'visuv',
+                    TableConstants::TABLE_ALIAS_IDP_SP_USER_VERSION,
+                    //'vae.idp_sp_user_version_id = visuv.id'
+                    TableConstants::TABLE_ALIAS_AUTHENTICATION_EVENT . '.' .
+                    TableConstants::TABLE_AUTHENTICATION_EVENT_COLUMN_NAME_IDP_SP_USER_VERSION_ID . ' = ' .
+                    TableConstants::TABLE_ALIAS_IDP_SP_USER_VERSION . '.' .
+                    TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_ID
+                )
+                ->leftJoin(
+                    //'visuv',
+                    TableConstants::TABLE_ALIAS_IDP_SP_USER_VERSION,
+                    //'vds_sp_version',
+                    $this->tableNameSpVersion,
+                    //'vsv',
+                    TableConstants::TABLE_ALIAS_SP_VERSION,
+                    //'visuv.sp_version_id = vsv.id'
+                    TableConstants::TABLE_ALIAS_IDP_SP_USER_VERSION . '.' .
+                    TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_SP_VERSION_ID . ' = ' .
+                    TableConstants::TABLE_ALIAS_SP_VERSION . '.' . TableConstants::TABLE_SP_VERSION_COLUMN_NAME_ID
+                )
+                ->leftJoin(
+                    //'vsv',
+                    TableConstants::TABLE_ALIAS_SP_VERSION,
+                    //'vds_sp',
+                    $this->tableNameSp,
+                    //'vs',
+                    TableConstants::TABLE_ALIAS_SP,
+                    //'vsv.sp_id = vs.id'
+                    TableConstants::TABLE_ALIAS_SP_VERSION . '.' . TableConstants::TABLE_SP_VERSION_COLUMN_NAME_SP_ID .
+                    ' = ' . TableConstants::TABLE_ALIAS_SP . '.' . TableConstants::TABLE_SP_COLUMN_NAME_ID
+                )
+                ->leftJoin(
+                    //'visuv',
+                    TableConstants::TABLE_ALIAS_IDP_SP_USER_VERSION,
+                    //'vds_user_version',
+                    $this->tableNameUserVersion,
+                    //'vuv',
+                    TableConstants::TABLE_ALIAS_USER_VERSION,
+                    //'visuv.user_version_id = vuv.id'
+                    TableConstants::TABLE_ALIAS_IDP_SP_USER_VERSION . '.' .
+                    TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_USER_VERSION_ID . ' = ' .
+                    TableConstants::TABLE_ALIAS_USER_VERSION . '.' . TableConstants::TABLE_USER_VERSION_COLUMN_NAME_ID
+                )
+                ->leftJoin(
+                    //'vuv',
+                    TableConstants::TABLE_ALIAS_USER_VERSION,
+                    //'vds_user',
+                    $this->tableNameUser,
+                    //'vu',
+                    TableConstants::TABLE_ALIAS_USER,
+                    //'vuv.user_id = vu.id'
+                    TableConstants::TABLE_ALIAS_USER_VERSION . '.' .
+                    TableConstants::TABLE_USER_VERSION_COLUMN_NAME_USER_ID . ' = ' . TableConstants::TABLE_ALIAS_USER .
+                    '.' . TableConstants::TABLE_USER_COLUMN_NAME_ID
+                )
+                ->where(
+                    //'vu.identifier_hash_sha256 = ' .
+                    TableConstants::TABLE_ALIAS_USER . '.' .
+                    TableConstants::TABLE_USER_COLUMN_NAME_IDENTIFIER_HASH_SHA256 . ' = ' .
+                    $authenticationEventsQueryBuilder->createNamedParameter($userIdentifierHashSha256)
+                )
+                ->orderBy(
+                //'vae.id',
+                    TableConstants::TABLE_ALIAS_AUTHENTICATION_EVENT . '.' .
+                    TableConstants::TABLE_AUTHENTICATION_EVENT_COLUMN_NAME_ID,
+                    'DESC'
+                );
+
+            return $authenticationEventsQueryBuilder->executeQuery()->fetchAllAssociative();
+        } catch (\Throwable $exception) {
+            $message = sprintf(
+                'Error executing query to get connected organizations. Error was: %s.',
+                $exception->getMessage()
+            );
+            throw new StoreException($message, (int)$exception->getCode(), $exception);
+        }
+    }
+}
diff --git a/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/TableConstants.php b/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/TableConstants.php
new file mode 100644
index 0000000000000000000000000000000000000000..30aa9a5a76a8b6114df8a7a97915d716604ee3ce
--- /dev/null
+++ b/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/TableConstants.php
@@ -0,0 +1,99 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store;
+
+class TableConstants
+{
+    public const TABLE_PREFIX = 'vds_'; // versioned data store
+
+    // Any SAML entity ID should have maximum 1024 chars per
+    // https://stackoverflow.com/questions/24196369/what-to-present-at-saml-entityid-url
+    public const COLUMN_ENTITY_ID_LENGTH = 1024;
+    public const COLUMN_HASH_SHA265_HEXITS_LENGTH = 64;
+
+
+    // Table 'idp'
+    public const TABLE_NAME_IDP = 'idp';
+    public const TABLE_ALIAS_IDP = self::TABLE_PREFIX . 'i';
+    public const TABLE_IDP_COLUMN_NAME_ID = 'id'; // int
+    public const TABLE_IDP_COLUMN_NAME_ENTITY_ID = 'entity_id'; // Entity ID value, string, varchar(1024)
+    public const TABLE_IDP_COLUMN_NAME_ENTITY_ID_HASH_SHA256 = 'entity_id_hash_sha256'; // ha256 hash hexits, char(64)
+    public const TABLE_IDP_COLUMN_NAME_CREATED_AT = 'created_at'; // First time IdP usage, datetime
+
+    // Table 'idp_version'
+    public const TABLE_NAME_IDP_VERSION = 'idp_version';
+    public const TABLE_ALIAS_IDP_VERSION = self::TABLE_PREFIX . 'iv';
+    public const TABLE_IDP_VERSION_COLUMN_NAME_ID = 'id'; // int ID
+    public const TABLE_IDP_VERSION_COLUMN_NAME_IDP_ID = 'idp_id'; // FK
+    public const TABLE_IDP_VERSION_COLUMN_NAME_METADATA = 'metadata'; // Serialized IdP metadata version
+    public const TABLE_IDP_VERSION_COLUMN_NAME_METADATA_HASH_SHA256 = 'metadata_hash_sha256'; // Metadata sha256 hash
+    public const TABLE_IDP_VERSION_COLUMN_NAME_CREATED_AT = 'created_at';
+
+    // Table 'sp', same structure as in 'idp'
+    public const TABLE_NAME_SP = 'sp';
+    public const TABLE_ALIAS_SP = self::TABLE_PREFIX . 's';
+    public const TABLE_SP_COLUMN_NAME_ID = 'id';
+    public const TABLE_SP_COLUMN_NAME_ENTITY_ID = 'entity_id';
+    public const TABLE_SP_COLUMN_NAME_ENTITY_ID_HASH_SHA256 = 'entity_id_hash_sha256';
+    public const TABLE_SP_COLUMN_NAME_CREATED_AT = 'created_at';
+
+    // Table 'sp_version', same structure as in 'idp_version'
+    public const TABLE_NAME_SP_VERSION = 'sp_version';
+    public const TABLE_ALIAS_SP_VERSION = self::TABLE_PREFIX . 'sv';
+    public const TABLE_ALIAS_SP_VERSION_2 = self::TABLE_ALIAS_SP_VERSION . '_2';
+    public const TABLE_SP_VERSION_COLUMN_NAME_ID = 'id';
+    public const TABLE_SP_VERSION_COLUMN_NAME_SP_ID = 'sp_id';
+    public const TABLE_SP_VERSION_COLUMN_NAME_METADATA = 'metadata';
+    public const TABLE_SP_VERSION_COLUMN_NAME_METADATA_HASH_SHA256 = 'metadata_hash_sha256';
+    public const TABLE_SP_VERSION_COLUMN_NAME_CREATED_AT = 'created_at';
+
+    // Table 'user'
+    public const TABLE_NAME_USER = 'user';
+    public const TABLE_ALIAS_USER = self::TABLE_PREFIX . 'u';
+    public const TABLE_USER_COLUMN_NAME_ID = 'id'; // int
+    public const TABLE_USER_COLUMN_NAME_IDENTIFIER = 'identifier'; // text, varies... (can be ePTID, which is long XML).
+    public const TABLE_USER_COLUMN_NAME_IDENTIFIER_HASH_SHA256 = 'identifier_hash_sha256';
+    public const TABLE_USER_COLUMN_NAME_CREATED_AT = 'created_at';
+
+    // Table 'user_version' (versioned attributes)
+    public const TABLE_NAME_USER_VERSION = 'user_version';
+    public const TABLE_ALIAS_USER_VERSION = self::TABLE_PREFIX . 'uv';
+    public const TABLE_ALIAS_USER_VERSION_2 = self::TABLE_ALIAS_USER_VERSION . '_2';
+    public const TABLE_USER_VERSION_COLUMN_NAME_ID = 'id'; // int ID
+    public const TABLE_USER_VERSION_COLUMN_NAME_USER_ID = 'user_id'; // FK
+    public const TABLE_USER_VERSION_COLUMN_NAME_ATTRIBUTES = 'attributes'; // Serialized attributes version
+    public const TABLE_USER_VERSION_COLUMN_NAME_ATTRIBUTES_HASH_SHA256 = 'attributes_hash_sha256';
+    public const TABLE_USER_VERSION_COLUMN_NAME_CREATED_AT = 'created_at';
+
+    // Attribute versions released to SP version
+    public const TABLE_NAME_IDP_SP_USER_VERSION = 'idp_sp_user_version';
+    public const TABLE_ALIAS_IDP_SP_USER_VERSION = self::TABLE_PREFIX . 'isuv';
+    public const TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_ID = 'id';
+    public const TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_IDP_VERSION_ID = 'idp_version_id';
+    public const TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_SP_VERSION_ID = 'sp_version_id';
+    public const TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_USER_VERSION_ID = 'user_version_id';
+    public const TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_CREATED_AT = 'created_at';
+
+    // Table 'authentication_event'.
+    public const TABLE_NAME_AUTHENTICATION_EVENT = 'authentication_event';
+    public const TABLE_ALIAS_AUTHENTICATION_EVENT = self::TABLE_PREFIX . 'ae';
+    public const TABLE_AUTHENTICATION_EVENT_COLUMN_NAME_ID = 'id';
+    public const TABLE_AUTHENTICATION_EVENT_COLUMN_NAME_IDP_SP_USER_VERSION_ID = 'idp_sp_user_version_id';
+    public const TABLE_AUTHENTICATION_EVENT_COLUMN_NAME_HAPPENED_AT = 'happened_at';
+    public const TABLE_AUTHENTICATION_EVENT_COLUMN_NAME_CREATED_AT = 'created_at';
+
+    // Entity 'ConnectedOrganization' (service provider) related.
+    public const ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_SP_ENTITY_ID = 'sp_entity_id';
+    public const ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_NUMBER_OF_AUTHENTICATIONS = 'number_of_authentications';
+    public const ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_LAST_AUTHENTICATION_AT = 'last_authentication_at';
+    public const ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_FIRST_AUTHENTICATION_AT = 'first_authentication_at';
+    public const ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_SP_METADATA = 'sp_metadata';
+    public const ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_USER_ATTRIBUTES = 'user_attributes';
+
+    // Entity 'Activity' related.
+    public const ENTITY_ACTIVITY_COLUMN_NAME_SP_METADATA = 'sp_metadata';
+    public const ENTITY_ACTIVITY_COLUMN_NAME_USER_ATTRIBUTES = 'user_attributes';
+    public const ENTITY_ACTIVITY_COLUMN_NAME_HAPPENED_AT = 'happened_at';
+}
diff --git a/src/Stores/Interfaces/DataStoreInterface.php b/src/Stores/Interfaces/DataStoreInterface.php
index 4d06925cd631780d351bc7eca618522d7829c726..81bcdcebf670d4079eb5707ee6c1ce00bc0fc297 100644
--- a/src/Stores/Interfaces/DataStoreInterface.php
+++ b/src/Stores/Interfaces/DataStoreInterface.php
@@ -4,6 +4,23 @@ declare(strict_types=1);
 
 namespace SimpleSAML\Module\accounting\Stores\Interfaces;
 
+use Psr\Log\LoggerInterface;
+use SimpleSAML\Module\accounting\Entities\Authentication\Event;
+use SimpleSAML\Module\accounting\Entities\ConnectedServiceProvider;
+use SimpleSAML\Module\accounting\Entities\Activity;
+use SimpleSAML\Module\accounting\ModuleConfiguration;
+
 interface DataStoreInterface extends StoreInterface
 {
+    public static function build(
+        ModuleConfiguration $moduleConfiguration,
+        LoggerInterface $logger,
+        string $connectionKey = null
+    ): self;
+
+    public function persist(Event $authenticationEvent): void;
+
+    public function getConnectedOrganizations(string $userIdentifierHashSha256): ConnectedServiceProvider\Bag;
+
+    public function getActivity(string $userIdentifierHashSha256): Activity\Bag;
 }
diff --git a/src/Stores/Interfaces/JobsStoreInterface.php b/src/Stores/Interfaces/JobsStoreInterface.php
index 0ea21d761bcea526fd3e73859b28e1725284cdd3..1ff238d66b921fc0005587f66fdb17972475b1b7 100644
--- a/src/Stores/Interfaces/JobsStoreInterface.php
+++ b/src/Stores/Interfaces/JobsStoreInterface.php
@@ -4,7 +4,9 @@ declare(strict_types=1);
 
 namespace SimpleSAML\Module\accounting\Stores\Interfaces;
 
+use Psr\Log\LoggerInterface;
 use SimpleSAML\Module\accounting\Entities\Interfaces\JobInterface;
+use SimpleSAML\Module\accounting\ModuleConfiguration;
 
 interface JobsStoreInterface extends StoreInterface
 {
@@ -21,4 +23,10 @@ interface JobsStoreInterface extends StoreInterface
      * @return ?JobInterface
      */
     public function dequeue(string $type = null): ?JobInterface;
+
+    public static function build(
+        ModuleConfiguration $moduleConfiguration,
+        LoggerInterface $logger,
+        string $connectionKey = null
+    ): self;
 }
diff --git a/src/Stores/Interfaces/StoreInterface.php b/src/Stores/Interfaces/StoreInterface.php
index ea80efa40131b993caac696176123e5a7d1544a4..29b1bd0b9734eef04185e3781e4c67b08702e94b 100644
--- a/src/Stores/Interfaces/StoreInterface.php
+++ b/src/Stores/Interfaces/StoreInterface.php
@@ -4,7 +4,16 @@ declare(strict_types=1);
 
 namespace SimpleSAML\Module\accounting\Stores\Interfaces;
 
-interface StoreInterface
+use Psr\Log\LoggerInterface;
+use SimpleSAML\Module\accounting\Interfaces\BuildableUsingModuleConfigurationInterface;
+use SimpleSAML\Module\accounting\Interfaces\SetupableInterface;
+use SimpleSAML\Module\accounting\ModuleConfiguration;
+
+interface StoreInterface extends BuildableUsingModuleConfigurationInterface, SetupableInterface
 {
-    public function needsSetUp(): bool;
+    public static function build(
+        ModuleConfiguration $moduleConfiguration,
+        LoggerInterface $logger,
+        string $connectionKey = null
+    ): self;
 }
diff --git a/src/Stores/Jobs/DoctrineDbal/JobsStore/Migrations/Version20220601000000CreateJobsTable.php b/src/Stores/Jobs/DoctrineDbal/JobsStore/Migrations/Version20220601000000CreateJobsTable.php
deleted file mode 100644
index d807e673e5040ff7b150f7d0d6ed782c4f49f3bf..0000000000000000000000000000000000000000
--- a/src/Stores/Jobs/DoctrineDbal/JobsStore/Migrations/Version20220601000000CreateJobsTable.php
+++ /dev/null
@@ -1,59 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\JobsStore\Migrations;
-
-use Doctrine\DBAL\Schema\Table;
-use Doctrine\DBAL\Types\Types;
-use SimpleSAML\Module\accounting\Exceptions\StoreException\MigrationException;
-use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Bases\AbstractMigration;
-use SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\JobsStore;
-use Throwable;
-
-class Version20220601000000CreateJobsTable extends AbstractMigration
-{
-    /**
-     * @throws MigrationException
-     */
-    public function run(): void
-    {
-        $tableName = $this->connection->preparePrefixedTableName(JobsStore::TABLE_NAME_JOBS);
-
-        try {
-            $table = new Table($tableName);
-
-            $table->addColumn(JobsStore::COLUMN_NAME_ID, Types::BIGINT)
-                ->setUnsigned(true)
-                ->setAutoincrement(true);
-
-            $table->addColumn(JobsStore::COLUMN_NAME_TYPE, Types::STRING)
-                ->setLength(JobsStore::COLUMN_LENGTH_TYPE);
-
-            $table->addColumn(JobsStore::COLUMN_NAME_PAYLOAD, Types::TEXT);
-            $table->addColumn(JobsStore::COLUMN_NAME_CREATED_AT, Types::DATETIMETZ_IMMUTABLE);
-
-            $table->setPrimaryKey([JobsStore::COLUMN_NAME_ID]);
-
-            $this->schemaManager->createTable($table);
-        } catch (Throwable $exception) {
-            $contextDetails = sprintf('Could not create table %s.', $tableName);
-            $this->throwGenericMigrationException($contextDetails, $exception);
-        }
-    }
-
-    /**
-     * @throws MigrationException
-     */
-    public function revert(): void
-    {
-        $tableName = $this->connection->preparePrefixedTableName(JobsStore::TABLE_NAME_JOBS);
-
-        try {
-            $this->schemaManager->dropTable($tableName);
-        } catch (Throwable $exception) {
-            $contextDetails = sprintf('Could not drop table %s.', $tableName);
-            $this->throwGenericMigrationException($contextDetails, $exception);
-        }
-    }
-}
diff --git a/src/Stores/Jobs/DoctrineDbal/JobsStore/RawJob.php b/src/Stores/Jobs/DoctrineDbal/JobsStore/RawJob.php
deleted file mode 100644
index f22a73f66182fd9d20df945ac527a9c01104d145..0000000000000000000000000000000000000000
--- a/src/Stores/Jobs/DoctrineDbal/JobsStore/RawJob.php
+++ /dev/null
@@ -1,130 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\JobsStore;
-
-use Doctrine\DBAL\Platforms\AbstractPlatform;
-use Doctrine\DBAL\Types\Type;
-use Doctrine\DBAL\Types\Types;
-use SimpleSAML\Module\accounting\Entities\Bases\AbstractPayload;
-use SimpleSAML\Module\accounting\Exceptions\UnexpectedValueException;
-use SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\JobsStore;
-
-class RawJob
-{
-    protected int $id;
-    protected AbstractPayload $payload;
-    protected string $type;
-    protected \DateTimeImmutable $createdAt;
-    protected AbstractPlatform $abstractPlatform;
-
-    public function __construct(array $rawRow, AbstractPlatform $abstractPlatform)
-    {
-        $this->abstractPlatform = $abstractPlatform;
-
-        $this->validate($rawRow);
-
-        $this->id = (int)$rawRow[JobsStore::COLUMN_NAME_ID];
-        $this->payload = $this->resolvePayload((string)$rawRow[JobsStore::COLUMN_NAME_PAYLOAD]);
-        $this->type = (string)$rawRow[JobsStore::COLUMN_NAME_TYPE];
-        $this->createdAt = $this->resolveCreatedAt($rawRow);
-    }
-
-    protected function validate(array $rawRow): void
-    {
-        $columnsToCheck = [
-            JobsStore::COLUMN_NAME_ID,
-            JobsStore::COLUMN_NAME_PAYLOAD,
-            JobsStore::COLUMN_NAME_TYPE,
-            JobsStore::COLUMN_NAME_CREATED_AT,
-        ];
-
-        foreach ($columnsToCheck as $column) {
-            if (empty($rawRow[$column])) {
-                throw new UnexpectedValueException(sprintf('Column %s must be set.', $column));
-            }
-        }
-
-        if (! is_numeric($rawRow[JobsStore::COLUMN_NAME_ID])) {
-            throw new UnexpectedValueException(sprintf('Column %s must be numeric.', JobsStore::COLUMN_NAME_ID));
-        }
-
-        if (! is_string($rawRow[JobsStore::COLUMN_NAME_PAYLOAD])) {
-            throw new UnexpectedValueException(sprintf('Column %s must be string.', JobsStore::COLUMN_NAME_PAYLOAD));
-        }
-
-        if (! is_string($rawRow[JobsStore::COLUMN_NAME_TYPE])) {
-            throw new UnexpectedValueException(sprintf('Column %s must be string.', JobsStore::COLUMN_NAME_TYPE));
-        }
-
-        if (! is_string($rawRow[JobsStore::COLUMN_NAME_CREATED_AT])) {
-            throw new UnexpectedValueException(sprintf('Column %s must be string.', JobsStore::COLUMN_NAME_CREATED_AT));
-        }
-    }
-
-    protected function resolvePayload(string $rawPayload): AbstractPayload
-    {
-        /** @psalm-suppress MixedAssignment - we check the type manually */
-        $payload = unserialize($rawPayload);
-
-        if ($payload instanceof AbstractPayload) {
-            return $payload;
-        }
-
-        throw new UnexpectedValueException('Job payload is not instance of AbstractPayload.');
-    }
-
-    /**
-     * @return int
-     */
-    public function getId(): int
-    {
-        return $this->id;
-    }
-
-    /**
-     * @return AbstractPayload
-     */
-    public function getPayload(): AbstractPayload
-    {
-        return $this->payload;
-    }
-
-    /**
-     * @return string
-     */
-    public function getType(): string
-    {
-        return $this->type;
-    }
-
-    protected function resolveCreatedAt(array $rawRow): \DateTimeImmutable
-    {
-        try {
-            /** @var \DateTimeImmutable $createdAt */
-            $createdAt = (Type::getType(Types::DATETIME_IMMUTABLE))
-                ->convertToPHPValue($rawRow[JobsStore::COLUMN_NAME_CREATED_AT], $this->abstractPlatform);
-        } catch (\Throwable $exception) {
-            throw new UnexpectedValueException(
-                sprintf(
-                    'Could not create instance of DateTimeImmutable using value %s for column %s.',
-                    var_export($rawRow[JobsStore::COLUMN_NAME_CREATED_AT], true),
-                    JobsStore::COLUMN_NAME_CREATED_AT
-                ),
-                (int)$exception->getCode(),
-                $exception
-            );
-        }
-
-        return $createdAt;
-    }
-
-    /**
-     * @return \DateTimeImmutable
-     */
-    public function getCreatedAt(): \DateTimeImmutable
-    {
-        return $this->createdAt;
-    }
-}
diff --git a/src/Stores/Jobs/DoctrineDbal/JobsStore.php b/src/Stores/Jobs/DoctrineDbal/Store.php
similarity index 52%
rename from src/Stores/Jobs/DoctrineDbal/JobsStore.php
rename to src/Stores/Jobs/DoctrineDbal/Store.php
index 5507ab339391a7b488eb5328f6303c01f9884e43..5e81988ffc71ab00496969bf0316c1a2ecad1527 100644
--- a/src/Stores/Jobs/DoctrineDbal/JobsStore.php
+++ b/src/Stores/Jobs/DoctrineDbal/Store.php
@@ -5,53 +5,38 @@ declare(strict_types=1);
 namespace SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal;
 
 use Psr\Log\LoggerInterface;
-use ReflectionClass;
 use SimpleSAML\Module\accounting\Entities\Interfaces\JobInterface;
 use SimpleSAML\Module\accounting\Exceptions\StoreException;
-use SimpleSAML\Module\accounting\Exceptions\StoreException\MigrationException;
 use SimpleSAML\Module\accounting\Exceptions\UnexpectedValueException;
 use SimpleSAML\Module\accounting\ModuleConfiguration;
-use SimpleSAML\Module\accounting\Stores\Connections\Bases\AbstractMigrator;
-use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection;
+use SimpleSAML\Module\accounting\Stores\Bases\DoctrineDbal\AbstractStore;
 use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Factory;
-use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Migrator;
 use SimpleSAML\Module\accounting\Stores\Interfaces\JobsStoreInterface;
-use SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\JobsStore\Repository;
+use SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\Repository;
+use SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\TableConstants;
 use Throwable;
 
-class JobsStore implements JobsStoreInterface
+class Store extends AbstractStore implements JobsStoreInterface
 {
-    public const TABLE_NAME_JOBS = 'jobs';
-    public const TABLE_NAME_FAILED_JOBS = 'failed_jobs';
-
-    public const COLUMN_NAME_ID = 'id';
-    public const COLUMN_NAME_PAYLOAD = 'payload';
-    public const COLUMN_NAME_TYPE = 'type';
-    public const COLUMN_NAME_CREATED_AT = 'created_at';
-
-    public const COLUMN_LENGTH_TYPE = 1024;
-
-    protected ModuleConfiguration $moduleConfiguration;
-    protected Connection $connection;
     protected string $prefixedTableNameJobs;
     protected string $prefixedTableNameFailedJobs;
-    protected Migrator $migrator;
-    protected LoggerInterface $logger;
     protected Repository $jobsRepository;
 
+    /**
+     * @throws StoreException
+     */
     public function __construct(
         ModuleConfiguration $moduleConfiguration,
-        Factory $factory,
         LoggerInterface $logger,
+        Factory $connectionFactory,
+        string $connectionKey = null,
         Repository $jobsRepository = null
     ) {
-        $this->moduleConfiguration = $moduleConfiguration;
-        $this->connection = $factory->buildConnection($moduleConfiguration->getStoreConnection(self::class));
-        $this->migrator = $factory->buildMigrator($this->connection);
-        $this->logger = $logger;
+        parent::__construct($moduleConfiguration, $logger, $connectionFactory, $connectionKey);
 
-        $this->prefixedTableNameJobs = $this->connection->preparePrefixedTableName(self::TABLE_NAME_JOBS);
-        $this->prefixedTableNameFailedJobs = $this->connection->preparePrefixedTableName(self::TABLE_NAME_FAILED_JOBS);
+        $this->prefixedTableNameJobs = $this->connection->preparePrefixedTableName(TableConstants::TABLE_NAME_JOB);
+        $this->prefixedTableNameFailedJobs = $this->connection
+            ->preparePrefixedTableName(TableConstants::TABLE_NAME_JOB_FAILED);
 
         $this->jobsRepository = $jobsRepository ??
             new Repository($this->connection, $this->prefixedTableNameJobs, $this->logger);
@@ -74,7 +59,10 @@ class JobsStore implements JobsStoreInterface
         $job = null;
         $attempts = 0;
         $maxDeleteAttempts = 3;
+        $this->connection->dbal()->getTransactionIsolation();
 
+        // Do the dequeue without using transactions, since the underlying database engine might not support it
+        // (for example, MyISAM engine in MySQL database).
         try {
             // Check if there are any jobs in the store...
             while (($job = $this->jobsRepository->getNext($type)) !== null) {
@@ -92,8 +80,12 @@ class JobsStore implements JobsStoreInterface
                     // It seems that this job has already been deleted in the meantime.
                     // Check if this happened before. If threshold is reached, throw.
                     // Otherwise, try to get next job again.
+                    $message = sprintf(
+                        'Job retrieval was successful, however it was deleted in the meantime. Attempt: %s',
+                        $attempts
+                    );
+                    $this->logger->warning($message, ['jobId' => $jobId]);
                     if ($attempts > $maxDeleteAttempts) {
-                        $message = 'Job retrieval was successful, however it was deleted in the meantime.';
                         throw new StoreException($message);
                     }
 
@@ -114,62 +106,6 @@ class JobsStore implements JobsStoreInterface
         return $job;
     }
 
-    /**
-     * @throws StoreException
-     * @throws MigrationException
-     */
-    public function runSetup(): void
-    {
-        if ($this->migrator->needsSetup()) {
-            $this->migrator->runSetup();
-        }
-
-        if (!$this->areAllMigrationsImplemented()) {
-            $this->migrator->runNonImplementedMigrationClasses(
-                $this->getMigrationsDirectory(),
-                $this->getMigrationsNamespace()
-            );
-        }
-    }
-
-    /**
-     * @throws StoreException
-     */
-    public function needsSetup(): bool
-    {
-        // ... if the migrator itself needs setup.
-        if ($this->migrator->needsSetup()) {
-            return true;
-        }
-
-        // ... if JobsStore migrations need to run
-        if (!$this->areAllMigrationsImplemented()) {
-            return true;
-        }
-
-        return false;
-    }
-
-    public function areAllMigrationsImplemented(): bool
-    {
-        return !$this->migrator->hasNonImplementedMigrationClasses(
-            $this->getMigrationsDirectory(),
-            $this->getMigrationsNamespace()
-        );
-    }
-
-    protected function getMigrationsDirectory(): string
-    {
-        return __DIR__ . DIRECTORY_SEPARATOR .
-            (new ReflectionClass($this))->getShortName() . DIRECTORY_SEPARATOR .
-            AbstractMigrator::DEFAULT_MIGRATIONS_DIRECTORY_NAME;
-    }
-
-    protected function getMigrationsNamespace(): string
-    {
-        return self::class . '\\' . AbstractMigrator::DEFAULT_MIGRATIONS_DIRECTORY_NAME;
-    }
-
     public function getPrefixedTableNameJobs(): string
     {
         return $this->prefixedTableNameJobs;
@@ -179,4 +115,25 @@ class JobsStore implements JobsStoreInterface
     {
         return $this->prefixedTableNameFailedJobs;
     }
+
+    /**
+     * Build store instance.
+     * @param ModuleConfiguration $moduleConfiguration
+     * @param LoggerInterface $logger
+     * @param string|null $connectionKey
+     * @return self
+     * @throws StoreException
+     */
+    public static function build(
+        ModuleConfiguration $moduleConfiguration,
+        LoggerInterface $logger,
+        string $connectionKey = null
+    ): self {
+        return new self(
+            $moduleConfiguration,
+            $logger,
+            new Factory($moduleConfiguration, $logger),
+            $connectionKey
+        );
+    }
 }
diff --git a/src/Stores/Jobs/DoctrineDbal/JobsStore/Migrations/Version20220601000100CreateFailedJobsTable.php b/src/Stores/Jobs/DoctrineDbal/Store/Migrations/Bases/AbstractCreateJobsTable.php
similarity index 58%
rename from src/Stores/Jobs/DoctrineDbal/JobsStore/Migrations/Version20220601000100CreateFailedJobsTable.php
rename to src/Stores/Jobs/DoctrineDbal/Store/Migrations/Bases/AbstractCreateJobsTable.php
index 08a39ba94f094c879a8208211643757ab9df1acb..203afb8dfb7d2a0e09dbc3c2367f6da41032adee 100644
--- a/src/Stores/Jobs/DoctrineDbal/JobsStore/Migrations/Version20220601000100CreateFailedJobsTable.php
+++ b/src/Stores/Jobs/DoctrineDbal/Store/Migrations/Bases/AbstractCreateJobsTable.php
@@ -1,24 +1,23 @@
 <?php
 
-declare(strict_types=1);
-
-namespace SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\JobsStore\Migrations;
+namespace SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\Migrations\Bases;
 
 use Doctrine\DBAL\Schema\Table;
 use Doctrine\DBAL\Types\Types;
 use SimpleSAML\Module\accounting\Exceptions\StoreException\MigrationException;
 use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Bases\AbstractMigration;
-use SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\JobsStore;
+use SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\TableConstants;
 use Throwable;
 
-class Version20220601000100CreateFailedJobsTable extends AbstractMigration
+abstract class AbstractCreateJobsTable extends AbstractMigration
 {
     /**
      * @throws MigrationException
      */
     public function run(): void
     {
-        $tableName = $this->connection->preparePrefixedTableName(JobsStore::TABLE_NAME_FAILED_JOBS);
+        $tableName = $this->preparePrefixedTableName($this->getJobsTableName());
+
         try {
             $table = new Table($tableName);
 
@@ -26,6 +25,9 @@ class Version20220601000100CreateFailedJobsTable extends AbstractMigration
                 ->setUnsigned(true)
                 ->setAutoincrement(true);
 
+            $table->addColumn('type', Types::STRING)
+                ->setLength(TableConstants::COLUMN_TYPE_LENGTH);
+
             $table->addColumn('payload', Types::TEXT);
             $table->addColumn('created_at', Types::DATETIMETZ_IMMUTABLE);
 
@@ -33,8 +35,10 @@ class Version20220601000100CreateFailedJobsTable extends AbstractMigration
 
             $this->schemaManager->createTable($table);
         } catch (Throwable $exception) {
-            $contextDetails = sprintf('Could not create table %s.', $tableName);
-            $this->throwGenericMigrationException($contextDetails, $exception);
+            throw $this->prepareGenericMigrationException(
+                \sprintf('Could not create table %s.', $tableName),
+                $exception
+            );
         }
     }
 
@@ -43,13 +47,14 @@ class Version20220601000100CreateFailedJobsTable extends AbstractMigration
      */
     public function revert(): void
     {
-        $tableName = $this->connection->preparePrefixedTableName(JobsStore::TABLE_NAME_FAILED_JOBS);
+        $tableName = $this->preparePrefixedTableName($this->getJobsTableName());
 
         try {
             $this->schemaManager->dropTable($tableName);
         } catch (Throwable $exception) {
-            $contextDetails = sprintf('Could not drop table %s.', $tableName);
-            $this->throwGenericMigrationException($contextDetails, $exception);
+            throw $this->prepareGenericMigrationException(\sprintf('Could not drop table %s.', $tableName), $exception);
         }
     }
+
+    abstract protected function getJobsTableName(): string;
 }
diff --git a/src/Stores/Jobs/DoctrineDbal/Store/Migrations/Version20220601000000CreateJobTable.php b/src/Stores/Jobs/DoctrineDbal/Store/Migrations/Version20220601000000CreateJobTable.php
new file mode 100644
index 0000000000000000000000000000000000000000..838b202bcb7403e608126e9c32f70fd62d4c79ef
--- /dev/null
+++ b/src/Stores/Jobs/DoctrineDbal/Store/Migrations/Version20220601000000CreateJobTable.php
@@ -0,0 +1,15 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\Migrations;
+
+use SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store;
+
+class Version20220601000000CreateJobTable extends Store\Migrations\Bases\AbstractCreateJobsTable
+{
+    protected function getJobsTableName(): string
+    {
+        return 'job';
+    }
+}
diff --git a/src/Stores/Jobs/DoctrineDbal/Store/Migrations/Version20220601000100CreateJobFailedTable.php b/src/Stores/Jobs/DoctrineDbal/Store/Migrations/Version20220601000100CreateJobFailedTable.php
new file mode 100644
index 0000000000000000000000000000000000000000..9aa852dc9bdd08fb3813d87c539745090e4936a7
--- /dev/null
+++ b/src/Stores/Jobs/DoctrineDbal/Store/Migrations/Version20220601000100CreateJobFailedTable.php
@@ -0,0 +1,15 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\Migrations;
+
+use SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store;
+
+class Version20220601000100CreateJobFailedTable extends Store\Migrations\Bases\AbstractCreateJobsTable
+{
+    protected function getJobsTableName(): string
+    {
+        return 'job_failed';
+    }
+}
diff --git a/src/Stores/Jobs/DoctrineDbal/Store/RawJob.php b/src/Stores/Jobs/DoctrineDbal/Store/RawJob.php
new file mode 100644
index 0000000000000000000000000000000000000000..5737b9230750963ae0c7bad7514d97deb1429261
--- /dev/null
+++ b/src/Stores/Jobs/DoctrineDbal/Store/RawJob.php
@@ -0,0 +1,119 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store;
+
+use DateTimeImmutable;
+use Doctrine\DBAL\Platforms\AbstractPlatform;
+use Doctrine\DBAL\Types\Type;
+use Doctrine\DBAL\Types\Types;
+use SimpleSAML\Module\accounting\Entities\Bases\AbstractPayload;
+use SimpleSAML\Module\accounting\Exceptions\UnexpectedValueException;
+use SimpleSAML\Module\accounting\Stores\Bases\DoctrineDbal\AbstractRawEntity;
+use SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store;
+use Throwable;
+
+use function sprintf;
+
+class RawJob extends AbstractRawEntity
+{
+    protected int $id;
+    protected AbstractPayload $payload;
+    protected string $type;
+    protected DateTimeImmutable $createdAt;
+
+    public function __construct(array $rawRow, AbstractPlatform $abstractPlatform)
+    {
+        parent::__construct($rawRow, $abstractPlatform);
+
+        $this->id = (int)$rawRow[Store\TableConstants::COLUMN_NAME_ID];
+        $this->payload = $this->resolvePayload((string)$rawRow[Store\TableConstants::COLUMN_NAME_PAYLOAD]);
+        $this->type = (string)$rawRow[Store\TableConstants::COLUMN_NAME_TYPE];
+        $this->createdAt = $this->resolveDateTimeImmutable($rawRow[Store\TableConstants::COLUMN_NAME_CREATED_AT]);
+    }
+
+    protected function validate(array $rawRow): void
+    {
+        $columnsToCheck = [
+            Store\TableConstants::COLUMN_NAME_ID,
+            Store\TableConstants::COLUMN_NAME_PAYLOAD,
+            Store\TableConstants::COLUMN_NAME_TYPE,
+            Store\TableConstants::COLUMN_NAME_CREATED_AT,
+        ];
+
+        foreach ($columnsToCheck as $column) {
+            if (empty($rawRow[$column])) {
+                throw new UnexpectedValueException(sprintf('Column %s must be set.', $column));
+            }
+        }
+
+        if (! is_numeric($rawRow[Store\TableConstants::COLUMN_NAME_ID])) {
+            throw new UnexpectedValueException(
+                sprintf('Column %s must be numeric.', Store\TableConstants::COLUMN_NAME_ID)
+            );
+        }
+
+        if (! is_string($rawRow[Store\TableConstants::COLUMN_NAME_PAYLOAD])) {
+            throw new UnexpectedValueException(
+                sprintf('Column %s must be string.', Store\TableConstants::COLUMN_NAME_PAYLOAD)
+            );
+        }
+
+        if (! is_string($rawRow[Store\TableConstants::COLUMN_NAME_TYPE])) {
+            throw new UnexpectedValueException(
+                sprintf('Column %s must be string.', Store\TableConstants::COLUMN_NAME_TYPE)
+            );
+        }
+
+        if (! is_string($rawRow[Store\TableConstants::COLUMN_NAME_CREATED_AT])) {
+            throw new UnexpectedValueException(
+                sprintf('Column %s must be string.', Store\TableConstants::COLUMN_NAME_CREATED_AT)
+            );
+        }
+    }
+
+    protected function resolvePayload(string $rawPayload): AbstractPayload
+    {
+        /** @psalm-suppress MixedAssignment - we check the type manually */
+        $payload = unserialize($rawPayload);
+
+        if ($payload instanceof AbstractPayload) {
+            return $payload;
+        }
+
+        throw new UnexpectedValueException('Job payload is not instance of AbstractPayload.');
+    }
+
+    /**
+     * @return int
+     */
+    public function getId(): int
+    {
+        return $this->id;
+    }
+
+    /**
+     * @return AbstractPayload
+     */
+    public function getPayload(): AbstractPayload
+    {
+        return $this->payload;
+    }
+
+    /**
+     * @return string
+     */
+    public function getType(): string
+    {
+        return $this->type;
+    }
+
+    /**
+     * @return DateTimeImmutable
+     */
+    public function getCreatedAt(): DateTimeImmutable
+    {
+        return $this->createdAt;
+    }
+}
diff --git a/src/Stores/Jobs/DoctrineDbal/JobsStore/Repository.php b/src/Stores/Jobs/DoctrineDbal/Store/Repository.php
similarity index 72%
rename from src/Stores/Jobs/DoctrineDbal/JobsStore/Repository.php
rename to src/Stores/Jobs/DoctrineDbal/Store/Repository.php
index 2c06c20276f208a18e250cf63404944f8ad59f80..ac983a76fa6e004411e65ebefdec50da5af939ee 100644
--- a/src/Stores/Jobs/DoctrineDbal/JobsStore/Repository.php
+++ b/src/Stores/Jobs/DoctrineDbal/Store/Repository.php
@@ -2,7 +2,7 @@
 
 declare(strict_types=1);
 
-namespace SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\JobsStore;
+namespace SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store;
 
 use Doctrine\DBAL\Types\Types;
 use Psr\Log\LoggerInterface;
@@ -10,9 +10,8 @@ use ReflectionClass;
 use SimpleSAML\Module\accounting\Entities\GenericJob;
 use SimpleSAML\Module\accounting\Entities\Interfaces\JobInterface;
 use SimpleSAML\Module\accounting\Exceptions\StoreException;
-use SimpleSAML\Module\accounting\Services\LoggerService;
 use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection;
-use SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\JobsStore;
+use SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store;
 use Throwable;
 
 class Repository
@@ -24,6 +23,9 @@ class Repository
     protected string $tableName;
     protected LoggerInterface $logger;
 
+    /**
+     * @throws StoreException
+     */
     public function __construct(Connection $connection, string $tableName, LoggerInterface $logger)
     {
         $this->connection = $connection;
@@ -39,9 +41,9 @@ class Repository
     protected function prepareValidJobsTableNames(): void
     {
         $this->validJobsTableNames[] = $this->connection
-            ->preparePrefixedTableName(JobsStore::TABLE_NAME_JOBS);
+            ->preparePrefixedTableName(Store\TableConstants::TABLE_NAME_JOB);
         $this->validJobsTableNames[] = $this->connection
-            ->preparePrefixedTableName(JobsStore::TABLE_NAME_FAILED_JOBS);
+            ->preparePrefixedTableName(Store\TableConstants::TABLE_NAME_JOB_FAILED);
     }
 
     /**
@@ -56,21 +58,21 @@ class Repository
         $queryBuilder->insert($this->tableName)
             ->values(
                 [
-                    JobsStore::COLUMN_NAME_PAYLOAD => ':' . JobsStore::COLUMN_NAME_PAYLOAD,
-                    JobsStore::COLUMN_NAME_TYPE => ':' . JobsStore::COLUMN_NAME_TYPE,
-                    JobsStore::COLUMN_NAME_CREATED_AT => ':' . JobsStore::COLUMN_NAME_CREATED_AT,
+                    Store\TableConstants::COLUMN_NAME_PAYLOAD => ':' . Store\TableConstants::COLUMN_NAME_PAYLOAD,
+                    Store\TableConstants::COLUMN_NAME_TYPE => ':' . Store\TableConstants::COLUMN_NAME_TYPE,
+                    Store\TableConstants::COLUMN_NAME_CREATED_AT => ':' . Store\TableConstants::COLUMN_NAME_CREATED_AT,
                 ]
             )
             ->setParameters(
                 [
-                    JobsStore::COLUMN_NAME_PAYLOAD => serialize($job->getPayload()),
-                    JobsStore::COLUMN_NAME_TYPE => $job->getType(),
-                    JobsStore::COLUMN_NAME_CREATED_AT => $job->getCreatedAt(),
+                    Store\TableConstants::COLUMN_NAME_PAYLOAD => serialize($job->getPayload()),
+                    Store\TableConstants::COLUMN_NAME_TYPE => $job->getType(),
+                    Store\TableConstants::COLUMN_NAME_CREATED_AT => $job->getCreatedAt(),
                 ],
                 [
-                    JobsStore::COLUMN_NAME_PAYLOAD => Types::TEXT,
-                    JobsStore::COLUMN_NAME_TYPE => Types::STRING,
-                    JobsStore::COLUMN_NAME_CREATED_AT => Types::DATETIMETZ_IMMUTABLE
+                    Store\TableConstants::COLUMN_NAME_PAYLOAD => Types::TEXT,
+                    Store\TableConstants::COLUMN_NAME_TYPE => Types::STRING,
+                    Store\TableConstants::COLUMN_NAME_CREATED_AT => Types::DATETIMETZ_IMMUTABLE
                 ]
             );
 
@@ -95,17 +97,19 @@ class Repository
          * @psalm-suppress TooManyArguments - providing array or null is deprecated
          */
         $queryBuilder->select(
-            JobsStore::COLUMN_NAME_ID,
-            JobsStore::COLUMN_NAME_PAYLOAD,
-            JobsStore::COLUMN_NAME_TYPE,
-            JobsStore::COLUMN_NAME_CREATED_AT
+            Store\TableConstants::COLUMN_NAME_ID,
+            Store\TableConstants::COLUMN_NAME_PAYLOAD,
+            Store\TableConstants::COLUMN_NAME_TYPE,
+            Store\TableConstants::COLUMN_NAME_CREATED_AT
         )
             ->from($this->tableName)
-            ->orderBy(JobsStore::COLUMN_NAME_ID)
+            ->orderBy(Store\TableConstants::COLUMN_NAME_ID)
             ->setMaxResults(1);
 
         if ($type !== null) {
-            $queryBuilder->where(JobsStore::COLUMN_NAME_TYPE . ' = ' . $queryBuilder->createNamedParameter($type));
+            $queryBuilder->where(
+                Store\TableConstants::COLUMN_NAME_TYPE . ' = ' . $queryBuilder->createNamedParameter($type)
+            );
         }
 
         try {
@@ -149,8 +153,8 @@ class Repository
             $numberOfAffectedRows = (int)$this->connection->dbal()
                 ->delete(
                     $this->tableName,
-                    [JobsStore::COLUMN_NAME_ID => $id],
-                    [JobsStore::COLUMN_NAME_ID => Types::BIGINT]
+                    [Store\TableConstants::COLUMN_NAME_ID => $id],
+                    [Store\TableConstants::COLUMN_NAME_ID => Types::BIGINT]
                 );
         } catch (Throwable $exception) {
             $message = sprintf('Error while trying to delete a job with ID %s.', $id);
@@ -181,9 +185,9 @@ class Repository
      */
     protected function validateType(string $type): void
     {
-        if (mb_strlen($type) > JobsStore::COLUMN_LENGTH_TYPE) {
+        if (mb_strlen($type) > Store\TableConstants::COLUMN_TYPE_LENGTH) {
             throw new StoreException(
-                sprintf('String length for type column exceeds %s limit.', JobsStore::COLUMN_LENGTH_TYPE)
+                sprintf('String length for type column exceeds %s limit.', Store\TableConstants::COLUMN_TYPE_LENGTH)
             );
         }
     }
diff --git a/src/Stores/Jobs/DoctrineDbal/Store/TableConstants.php b/src/Stores/Jobs/DoctrineDbal/Store/TableConstants.php
new file mode 100644
index 0000000000000000000000000000000000000000..55f1779217f7d8467ac5e2cb775c6a9c60920c99
--- /dev/null
+++ b/src/Stores/Jobs/DoctrineDbal/Store/TableConstants.php
@@ -0,0 +1,19 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store;
+
+class TableConstants
+{
+    public const TABLE_NAME_JOB = 'job';
+    public const TABLE_NAME_JOB_FAILED = 'job_failed';
+
+    // Both tables have same columns.
+    public const COLUMN_NAME_ID = 'id';
+    public const COLUMN_NAME_PAYLOAD = 'payload';
+    public const COLUMN_NAME_TYPE = 'type';
+    public const COLUMN_NAME_CREATED_AT = 'created_at';
+
+    public const COLUMN_TYPE_LENGTH = 1024;
+}
diff --git a/src/Trackers/Authentication/DoctrineDbal/Versioned/Tracker.php b/src/Trackers/Authentication/DoctrineDbal/Versioned/Tracker.php
new file mode 100644
index 0000000000000000000000000000000000000000..3a9c8d2a06f7fb158143a28af2393e200fa5a0d7
--- /dev/null
+++ b/src/Trackers/Authentication/DoctrineDbal/Versioned/Tracker.php
@@ -0,0 +1,72 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Module\accounting\Trackers\Authentication\DoctrineDbal\Versioned;
+
+use Psr\Log\LoggerInterface;
+use SimpleSAML\Module\accounting\Entities\Activity;
+use SimpleSAML\Module\accounting\Entities\Authentication\Event;
+use SimpleSAML\Module\accounting\Entities\ConnectedServiceProvider;
+use SimpleSAML\Module\accounting\Helpers\HashHelper;
+use SimpleSAML\Module\accounting\ModuleConfiguration;
+use SimpleSAML\Module\accounting\Providers\Interfaces\AuthenticationDataProviderInterface;
+use SimpleSAML\Module\accounting\Stores\Builders\DataStoreBuilder;
+use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store;
+use SimpleSAML\Module\accounting\Stores\Interfaces\DataStoreInterface;
+use SimpleSAML\Module\accounting\Trackers\Interfaces\AuthenticationDataTrackerInterface;
+
+class Tracker implements AuthenticationDataTrackerInterface, AuthenticationDataProviderInterface
+{
+    protected ModuleConfiguration $moduleConfiguration;
+    protected LoggerInterface $logger;
+    protected DataStoreInterface $dataStore;
+
+    public function __construct(
+        ModuleConfiguration $moduleConfiguration,
+        LoggerInterface $logger,
+        DataStoreInterface $dataStore = null
+    ) {
+        $this->moduleConfiguration = $moduleConfiguration;
+        $this->logger = $logger;
+
+        // Use provided store or initialize default store for this tracker.
+        $this->dataStore = $dataStore ??
+            (new DataStoreBuilder($this->moduleConfiguration, $this->logger))
+                ->build(Store::class, $this->moduleConfiguration->getClassConnectionKey(self::class));
+    }
+
+    public static function build(
+        ModuleConfiguration $moduleConfiguration,
+        LoggerInterface $logger
+    ): self {
+        return new self($moduleConfiguration, $logger);
+    }
+
+    public function process(Event $authenticationEvent): void
+    {
+        $this->dataStore->persist($authenticationEvent);
+    }
+
+    public function needsSetup(): bool
+    {
+        return $this->dataStore->needsSetup();
+    }
+
+    public function runSetup(): void
+    {
+        $this->dataStore->runSetup();
+    }
+
+    public function getConnectedServiceProviders(string $userIdentifier): ConnectedServiceProvider\Bag
+    {
+        $userIdentifierHashSha256 = HashHelper::getSha256($userIdentifier);
+        return $this->dataStore->getConnectedOrganizations($userIdentifierHashSha256);
+    }
+
+    public function getActivity(string $userIdentifier): Activity\Bag
+    {
+        $userIdentifierHashSha256 = HashHelper::getSha256($userIdentifier);
+        return $this->dataStore->getActivity($userIdentifierHashSha256);
+    }
+}
diff --git a/src/Trackers/Builders/AuthenticationDataTrackerBuilder.php b/src/Trackers/Builders/AuthenticationDataTrackerBuilder.php
new file mode 100644
index 0000000000000000000000000000000000000000..e584a6c9f8ed930200ea2a4087f4b00599b922ee
--- /dev/null
+++ b/src/Trackers/Builders/AuthenticationDataTrackerBuilder.php
@@ -0,0 +1,54 @@
+<?php
+
+namespace SimpleSAML\Module\accounting\Trackers\Builders;
+
+use Psr\Log\LoggerInterface;
+use SimpleSAML\Module\accounting\Exceptions\Exception;
+use SimpleSAML\Module\accounting\Exceptions\UnexpectedValueException;
+use SimpleSAML\Module\accounting\Helpers\InstanceBuilderUsingModuleConfigurationHelper;
+use SimpleSAML\Module\accounting\ModuleConfiguration;
+use SimpleSAML\Module\accounting\Trackers\Interfaces\AuthenticationDataTrackerInterface;
+use Throwable;
+
+class AuthenticationDataTrackerBuilder
+{
+    protected ModuleConfiguration $moduleConfiguration;
+    protected LoggerInterface $logger;
+
+    public function __construct(ModuleConfiguration $moduleConfiguration, LoggerInterface $logger)
+    {
+        $this->moduleConfiguration = $moduleConfiguration;
+        $this->logger = $logger;
+    }
+
+    /**
+     * @throws Exception
+     */
+    public function build(string $class): AuthenticationDataTrackerInterface
+    {
+        try {
+            // Make sure that the class implements proper interface
+            if (!is_subclass_of($class, AuthenticationDataTrackerInterface::class)) {
+                $message = sprintf(
+                    'Class %s does not implement interface %s.',
+                    $class,
+                    AuthenticationDataTrackerInterface::class
+                );
+                throw new UnexpectedValueException($message);
+            }
+
+            // Build...
+            /** @var AuthenticationDataTrackerInterface $store */
+            $store = InstanceBuilderUsingModuleConfigurationHelper::build(
+                $class,
+                $this->moduleConfiguration,
+                $this->logger
+            );
+        } catch (Throwable $exception) {
+            $message = sprintf('Error building instance for class %s. Error was: %s', $class, $exception->getMessage());
+            throw new Exception($message, (int)$exception->getCode(), $exception);
+        }
+
+        return $store;
+    }
+}
diff --git a/src/Trackers/Interfaces/AuthenticationDataTrackerInterface.php b/src/Trackers/Interfaces/AuthenticationDataTrackerInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..b3b95720279ab21603920b762477553244593833
--- /dev/null
+++ b/src/Trackers/Interfaces/AuthenticationDataTrackerInterface.php
@@ -0,0 +1,17 @@
+<?php
+
+namespace SimpleSAML\Module\accounting\Trackers\Interfaces;
+
+use Psr\Log\LoggerInterface;
+use SimpleSAML\Module\accounting\Entities\Authentication\Event;
+use SimpleSAML\Module\accounting\Interfaces\BuildableUsingModuleConfigurationInterface;
+use SimpleSAML\Module\accounting\Interfaces\SetupableInterface;
+use SimpleSAML\Module\accounting\ModuleConfiguration;
+use SimpleSAML\Module\accounting\Providers\Interfaces\AuthenticationDataProviderInterface;
+
+interface AuthenticationDataTrackerInterface extends BuildableUsingModuleConfigurationInterface, SetupableInterface
+{
+    public static function build(ModuleConfiguration $moduleConfiguration, LoggerInterface $logger): self;
+
+    public function process(Event $authenticationEvent): void;
+}
diff --git a/templates/admin/configuration/status.twig b/templates/admin/configuration/status.twig
new file mode 100644
index 0000000000000000000000000000000000000000..1ce05e9439de2df6715aa22048810fe8ba3ea62f
--- /dev/null
+++ b/templates/admin/configuration/status.twig
@@ -0,0 +1,47 @@
+{# @var moduleConfiguration \SimpleSAML\Module\accounting\ModuleConfiguration #}
+
+{% set pagetitle = 'Configuration Status'|trans %}
+{% set frontpage_section = 'main' %}
+
+{% extends "base.twig" %}
+
+{% block preload %}
+    <link rel="stylesheet" href="{{ asset('css/accounting.css', 'accounting') }}">
+{% endblock %}
+
+{% block content %}
+
+    <h2>{{ pagetitle }} </h2>
+
+    {% if configurationValidationErrors is not null %}
+        <p>{{ configurationValidationErrors }}</p>
+    {% elseif moduleConfiguration is not null %}
+        <ul>
+            <li>
+                <strong>{{ 'User ID Attribute Name'|trans }}</strong>: {{ moduleConfiguration.getUserIdAttributeName }}
+            </li>
+            <li>
+                <strong>{{ 'Accounting Processing Type'|trans }}</strong>: {{ moduleConfiguration.getAccountingProcessingType }}
+            </li>
+        </ul>
+
+        {% if moduleConfiguration.getAccountingProcessingType == 'asynchronous' %}
+            <ul>
+                <li>
+                    <strong>{{ 'Jobs Store Class'|trans }}</strong>: {{ moduleConfiguration.getJobsStoreClass }}
+                </li>
+                <li>
+                    {% if jobsStore is not null %}
+                        <strong>{{ 'Jobs Store Setup Needed'|trans }}</strong>:
+                        {{ jobsStore.needsSetup ? 'Yes'|trans : 'No'|trans }}
+                    {% else %}
+                        {{ 'Could not initialize jobs store.'|trans }}
+                    {% endif %}
+                </li>
+            </ul>
+        {% endif %}
+    {% else %}
+        <p>{{ 'Could not initialize module configuration.'|trans }}</p>
+    {% endif %}
+
+{% endblock %}
diff --git a/templates/configuration.twig b/templates/configuration.twig
deleted file mode 100644
index 78db9bda537ad5040df993efceb8fd746d6183d3..0000000000000000000000000000000000000000
--- a/templates/configuration.twig
+++ /dev/null
@@ -1,19 +0,0 @@
-{% set pagetitle = 'Accounting configuration page'|trans %}
-{% set frontpage_section = 'main' %}
-
-{% extends "base.twig" %}
-
-{% block preload %}
-    <link rel="stylesheet" href="{{ asset('css/accounting.css', 'accounting') }}">
-{% endblock %}
-
-{% block content %}
-
-    <h2>{{ pagetitle }} </h2>
-
-    <p>
-        {{ test }}, <br>
-        {{ test_config }}
-    </p>
-
-{% endblock %}
diff --git a/templates/test.twig b/templates/test.twig
new file mode 100644
index 0000000000000000000000000000000000000000..59615eefefbd7da302031730e709102b3c79b9e3
--- /dev/null
+++ b/templates/test.twig
@@ -0,0 +1,25 @@
+{% set pagetitle = 'Setup'|trans %}
+{% set frontpage_section = 'main' %}
+
+{% extends "base.twig" %}
+
+{% block preload %}
+    <link rel="stylesheet" href="{{ asset('css/accounting.css', 'accounting') }}">
+{% endblock %}
+
+{% block content %}
+
+    <h2>{{ pagetitle }} </h2>
+
+    <p>
+        Jobs Store class: {{ jobs_store }}, <br>
+        Jobs Store needs setup: {{ jobs_store_needs_setup }}, <br>
+        Jobs Store setup ran: {{ jobs_store_setup_ran }}, <br>
+
+        Data Store class: {{ data_store }}, <br>
+        Data Store needs setup: {{ data_store_needs_setup }}, <br>
+        Data Store setup ran: {{ data_store_setup_ran }} <br>
+    </p>
+
+{% endblock %}
+{# TODO mivanci delete this file #}
\ No newline at end of file
diff --git a/templates/user/connected-organizations.twig b/templates/user/connected-organizations.twig
new file mode 100644
index 0000000000000000000000000000000000000000..a05508ea7746936b5eacf9dad7a6f3c1d6212c9a
--- /dev/null
+++ b/templates/user/connected-organizations.twig
@@ -0,0 +1,24 @@
+{# @var moduleConfiguration \SimpleSAML\Module\accounting\ModuleConfiguration #}
+
+{% set pagetitle = 'Connected Organizations'|trans %}
+{% set frontpage_section = 'main' %}
+
+{% extends "base.twig" %}
+
+{% block preload %}
+    <link rel="stylesheet" href="{{ asset('css/accounting.css', 'accounting') }}">
+{% endblock %}
+
+{% block content %}
+
+    <h2>{{ pagetitle }} </h2>
+
+
+
+    <ul>
+        {% for name, value in normalizedAttributes %}
+            <li><strong>{{ name }}</strong>: {{ value }}</li>
+        {% endfor %}
+    </ul>
+
+{% endblock %}
diff --git a/templates/user/personal-data.twig b/templates/user/personal-data.twig
new file mode 100644
index 0000000000000000000000000000000000000000..51a0921f26e8de58817e6ee5a1f7f14758d11716
--- /dev/null
+++ b/templates/user/personal-data.twig
@@ -0,0 +1,26 @@
+{# @var moduleConfiguration \SimpleSAML\Module\accounting\ModuleConfiguration #}
+
+{% set pagetitle = 'Personal Data'|trans %}
+{% set frontpage_section = 'main' %}
+
+{% extends "base.twig" %}
+
+{% block preload %}
+    <link rel="stylesheet" href="{{ asset('css/accounting.css', 'accounting') }}">
+{% endblock %}
+
+{% block content %}
+
+    <h2>{{ pagetitle }} </h2>
+
+    <p>
+        {% trans %}This is what we know about you... Lorem ipsum...{% endtrans %}
+    </p>
+
+    <ul>
+        {% for name, value in normalizedAttributes %}
+            <li><strong>{{ name }}</strong>: {{ value }}</li>
+        {% endfor %}
+    </ul>
+
+{% endblock %}
diff --git a/tests/attributemap/test.php b/tests/attributemap/test.php
new file mode 100644
index 0000000000000000000000000000000000000000..3b756fa4f05d213e126c8b333628230cfd30a452
--- /dev/null
+++ b/tests/attributemap/test.php
@@ -0,0 +1,8 @@
+<?php
+// phpcs:ignoreFile
+
+declare(strict_types=1);
+
+$attributemap = [
+    'mobile' => 'urn:mace:dir:attribute-def:mobile'
+];
diff --git a/tests/attributemap/test2.php b/tests/attributemap/test2.php
new file mode 100644
index 0000000000000000000000000000000000000000..6d4443e7b0c97d2244356b8a562491bb1b836561
--- /dev/null
+++ b/tests/attributemap/test2.php
@@ -0,0 +1,8 @@
+<?php
+// phpcs:ignoreFile
+
+declare(strict_types=1);
+
+$attributemap = [
+    'phone' => 'urn:mace:dir:attribute-def:phone'
+];
diff --git a/tests/config-templates/config.php b/tests/config-templates/config.php
index c27decf2ca22267f2a3549033d38d2019bb7055f..a6c0417abcb66585642837aa25834dd82109c006 100644
--- a/tests/config-templates/config.php
+++ b/tests/config-templates/config.php
@@ -902,7 +902,7 @@ $config = [
      *************************************/
 
     /*
-     * Authentication processing filters that will be executed for all IdPs
+     * Tracker processing filters that will be executed for all IdPs
      */
     'authproc.idp' => [
         /* Enable the authproc filter below to add URN prefixes to all attributes
@@ -957,7 +957,7 @@ $config = [
     ],
 
     /*
-     * Authentication processing filters that will be executed for all SPs
+     * Tracker processing filters that will be executed for all SPs
      */
     'authproc.sp' => [
         /*
diff --git a/tests/config-templates/invalid_array_value_module_accounting.php b/tests/config-templates/invalid_array_value_module_accounting.php
new file mode 100644
index 0000000000000000000000000000000000000000..b5f3c8a7bca75dc435b7b801a1e48d989dc0766e
--- /dev/null
+++ b/tests/config-templates/invalid_array_value_module_accounting.php
@@ -0,0 +1,37 @@
+<?php
+
+declare(strict_types=1);
+
+use SimpleSAML\Module\accounting\ModuleConfiguration;
+use SimpleSAML\Module\accounting\Stores;
+use SimpleSAML\Module\accounting\Trackers;
+
+$config = [
+    ModuleConfiguration::OPTION_USER_ID_ATTRIBUTE_NAME => 'urn:oasis:names:tc:SAML:attribute:subject-id',
+
+    ModuleConfiguration::OPTION_ACCOUNTING_PROCESSING_TYPE =>
+        ModuleConfiguration\AccountingProcessingType::VALUE_SYNCHRONOUS,
+
+    ModuleConfiguration::OPTION_JOBS_STORE => Stores\Jobs\DoctrineDbal\Store::class,
+
+    ModuleConfiguration::OPTION_DEFAULT_DATA_TRACKER_AND_PROVIDER =>
+        Trackers\Authentication\DoctrineDbal\Versioned\Tracker::class,
+
+    ModuleConfiguration::OPTION_CLASS_TO_CONNECTION_MAP => [
+        'invalid-array-value' => [
+            'no-master-key' => 'invalid',
+        ],
+    ],
+    ModuleConfiguration::OPTION_ADDITIONAL_TRACKERS => [
+        'invalid',
+    ],
+
+    ModuleConfiguration::OPTION_CONNECTIONS_AND_PARAMETERS => [
+        'doctrine_dbal_pdo_mysql' => [
+            'driver' => 'pdo_mysql',
+        ],
+        'doctrine_dbal_pdo_sqlite' => [
+            'driver' => 'pdo_sqlite',
+        ],
+    ],
+];
diff --git a/tests/config-templates/invalid_async_module_accounting.php b/tests/config-templates/invalid_async_module_accounting.php
new file mode 100644
index 0000000000000000000000000000000000000000..77a3517abfce30e0a334f063a1ecea333492a065
--- /dev/null
+++ b/tests/config-templates/invalid_async_module_accounting.php
@@ -0,0 +1,35 @@
+<?php
+
+declare(strict_types=1);
+
+use SimpleSAML\Module\accounting\ModuleConfiguration;
+use SimpleSAML\Module\accounting\Stores;
+use SimpleSAML\Module\accounting\Trackers;
+
+$config = [
+    ModuleConfiguration::OPTION_USER_ID_ATTRIBUTE_NAME => 'urn:oasis:names:tc:SAML:attribute:subject-id',
+
+    ModuleConfiguration::OPTION_ACCOUNTING_PROCESSING_TYPE =>
+        ModuleConfiguration\AccountingProcessingType::VALUE_ASYNCHRONOUS,
+
+    ModuleConfiguration::OPTION_JOBS_STORE => 'invalid',
+
+    ModuleConfiguration::OPTION_DEFAULT_DATA_TRACKER_AND_PROVIDER => 'invalid',
+
+    ModuleConfiguration::OPTION_ADDITIONAL_TRACKERS => [
+        'invalid',
+    ],
+
+    ModuleConfiguration::OPTION_CLASS_TO_CONNECTION_MAP => [
+        'invalid'
+    ],
+
+    ModuleConfiguration::OPTION_CONNECTIONS_AND_PARAMETERS => [
+        'doctrine_dbal_pdo_mysql' => [
+            'driver' => 'pdo_mysql',
+        ],
+        'doctrine_dbal_pdo_sqlite' => [
+            'driver' => 'pdo_sqlite',
+        ],
+    ],
+];
diff --git a/tests/config-templates/invalid_module_accounting.php b/tests/config-templates/invalid_module_accounting.php
new file mode 100644
index 0000000000000000000000000000000000000000..5a700dbcb4ea13102ed5a7bd1fe871cfa5109910
--- /dev/null
+++ b/tests/config-templates/invalid_module_accounting.php
@@ -0,0 +1,46 @@
+<?php
+
+declare(strict_types=1);
+
+use SimpleSAML\Module\accounting\ModuleConfiguration;
+use SimpleSAML\Module\accounting\Stores;
+use SimpleSAML\Module\accounting\Trackers;
+
+$config = [
+    ModuleConfiguration::OPTION_USER_ID_ATTRIBUTE_NAME => 'urn:oasis:names:tc:SAML:attribute:subject-id',
+
+    ModuleConfiguration::OPTION_ACCOUNTING_PROCESSING_TYPE => 'invalid',
+
+    ModuleConfiguration::OPTION_JOBS_STORE => 'invalid',
+
+    ModuleConfiguration::OPTION_DEFAULT_DATA_TRACKER_AND_PROVIDER => 'invalid',
+
+    ModuleConfiguration::OPTION_CLASS_TO_CONNECTION_MAP => [
+        /**
+         * Connection key to be used by jobs store class.
+         */
+        Stores\Jobs\DoctrineDbal\Store::class => 'invalid',
+        /**
+         * Connection key to be used by this data tracker and provider.
+         */
+        Trackers\Authentication\DoctrineDbal\Versioned\Tracker::class => [
+            ModuleConfiguration\ConnectionType::MASTER => 'invalid',
+            ModuleConfiguration\ConnectionType::SLAVE => [
+                'invalid',
+            ],
+        ],
+    ],
+    ModuleConfiguration::OPTION_ADDITIONAL_TRACKERS => [
+        'invalid',
+        ['invalid']
+    ],
+
+    ModuleConfiguration::OPTION_CONNECTIONS_AND_PARAMETERS => [
+        'doctrine_dbal_pdo_mysql' => [
+            'driver' => 'pdo_mysql',
+        ],
+        'doctrine_dbal_pdo_sqlite' => [
+            'driver' => 'pdo_sqlite',
+        ],
+    ],
+];
diff --git a/tests/config-templates/invalid_object_value_module_accounting.php b/tests/config-templates/invalid_object_value_module_accounting.php
new file mode 100644
index 0000000000000000000000000000000000000000..6c46610918610bac25f3adc7507da16c88cf84dc
--- /dev/null
+++ b/tests/config-templates/invalid_object_value_module_accounting.php
@@ -0,0 +1,35 @@
+<?php
+
+declare(strict_types=1);
+
+use SimpleSAML\Module\accounting\ModuleConfiguration;
+use SimpleSAML\Module\accounting\Stores;
+use SimpleSAML\Module\accounting\Trackers;
+
+$config = [
+    ModuleConfiguration::OPTION_USER_ID_ATTRIBUTE_NAME => 'urn:oasis:names:tc:SAML:attribute:subject-id',
+
+    ModuleConfiguration::OPTION_ACCOUNTING_PROCESSING_TYPE =>
+        ModuleConfiguration\AccountingProcessingType::VALUE_SYNCHRONOUS,
+
+    ModuleConfiguration::OPTION_JOBS_STORE => Stores\Jobs\DoctrineDbal\Store::class,
+
+    ModuleConfiguration::OPTION_DEFAULT_DATA_TRACKER_AND_PROVIDER =>
+        Trackers\Authentication\DoctrineDbal\Versioned\Tracker::class,
+
+    ModuleConfiguration::OPTION_CLASS_TO_CONNECTION_MAP => [
+        'invalid-object-value' => new stdClass(),
+    ],
+    ModuleConfiguration::OPTION_ADDITIONAL_TRACKERS => [
+        'invalid',
+    ],
+
+    ModuleConfiguration::OPTION_CONNECTIONS_AND_PARAMETERS => [
+        'doctrine_dbal_pdo_mysql' => [
+            'driver' => 'pdo_mysql',
+        ],
+        'doctrine_dbal_pdo_sqlite' => [
+            'driver' => 'pdo_sqlite',
+        ],
+    ],
+];
diff --git a/tests/config-templates/module_accounting.php b/tests/config-templates/module_accounting.php
index e0d7108165d0f8a13e7a6cce8ce5ef21c75058c7..4eec5cf688732bbad540a467bffcf384fbcdfeb3 100644
--- a/tests/config-templates/module_accounting.php
+++ b/tests/config-templates/module_accounting.php
@@ -3,74 +3,48 @@
 declare(strict_types=1);
 
 use SimpleSAML\Module\accounting\ModuleConfiguration;
+use SimpleSAML\Module\accounting\Providers;
 use SimpleSAML\Module\accounting\Stores;
+use SimpleSAML\Module\accounting\Trackers;
 
 $config = [
-    /**
-     * User ID attribute, one that is always available and that is unique to all users.
-     * If this attribute is not available, accounting will not be performed for that user.
-     *
-     * Examples:
-     * urn:oasis:names:tc:SAML:attribute:subject-id
-     * eduPersonUniqueId
-     * eduPersonPrincipalName
-     */
-    ModuleConfiguration::OPTION_USER_ID_ATTRIBUTE => 'urn:oasis:names:tc:SAML:attribute:subject-id',
 
-    /**
-     * Accounting processing type. There are two possible types: 'synchronous' and 'asynchronous'.
-     * - 'synchronous': accounting processing will be performed during authentication itself (slower)
-     * - 'asynchronous': for each authentication event a new job will be created for later processing (faster,
-     *   but requires setting up job storage and a cron entry).
-     */
+    ModuleConfiguration::OPTION_USER_ID_ATTRIBUTE_NAME => 'urn:oasis:names:tc:SAML:attribute:subject-id',
+
+    ModuleConfiguration::OPTION_DEFAULT_AUTHENTICATION_SOURCE => 'default-sp',
+
     ModuleConfiguration::OPTION_ACCOUNTING_PROCESSING_TYPE =>
         ModuleConfiguration\AccountingProcessingType::VALUE_SYNCHRONOUS,
 
-    /**
-     * Jobs store. Determines which of the available stores will be used to store jobs in case the 'asynchronous'
-     * accounting processing type was set.
-     */
-    ModuleConfiguration::OPTION_JOBS_STORE => Stores\Jobs\DoctrineDbal\JobsStore::class,
+    ModuleConfiguration::OPTION_JOBS_STORE => Stores\Jobs\DoctrineDbal\Store::class,
+
+    ModuleConfiguration::OPTION_DEFAULT_DATA_TRACKER_AND_PROVIDER =>
+        Trackers\Authentication\DoctrineDbal\Versioned\Tracker::class,
 
-    /**
-     * Store connection for particular store. Can be used to set different connections for different stores.
-     */
-    ModuleConfiguration::OPTION_STORE_TO_CONNECTION_KEY_MAP => [
-        Stores\Jobs\DoctrineDbal\JobsStore::class => 'doctrine_dbal_pdo_mysql',
+    ModuleConfiguration::OPTION_ADDITIONAL_TRACKERS => [
+        //
     ],
 
-    /**
-     * Store connections and their parameters.
-     *
-     * Any compatible Doctrine DBAL implementation can be used:
-     * https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html
-     * There are additional parameters for: table prefix.
-     * Examples for mysql and sqlite are provided below.
-     */
-    ModuleConfiguration::OPTION_ALL_STORE_CONNECTIONS_AND_PARAMETERS => [
-        'doctrine_dbal_pdo_mysql' => [
-            'driver' => 'pdo_mysql', // (string): The built-in driver implementation to use
-            'user' => 'user', // (string): Username to use when connecting to the database.
-            'password' => 'password', // (string): Password to use when connecting to the database.
-            'host' => 'host', // (string): Hostname of the database to connect to.
-            'port' => 3306, // (integer): Port of the database to connect to.
-            'dbname' => 'dbname', // (string): Name of the database/schema to connect to.
-            //'unix_socket' => 'unix_socket', // (string): Name of the socket used to connect to the database.
-            'charset' => 'utf8', // (string): The charset used when connecting to the database.
-            //'url' => 'mysql://user:secret@localhost/mydb?charset=utf8', // ...alternative way of providing parameters.
-            // Additional parameters not originally available in Doctrine DBAL
-            'table_prefix' => '', // (string): Prefix for each table.
+    ModuleConfiguration::OPTION_CLASS_TO_CONNECTION_MAP => [
+        Stores\Jobs\DoctrineDbal\Store::class => 'doctrine_dbal_pdo_sqlite',
+        Trackers\Authentication\DoctrineDbal\Versioned\Tracker::class => [
+            ModuleConfiguration\ConnectionType::MASTER => 'doctrine_dbal_pdo_sqlite',
+            ModuleConfiguration\ConnectionType::SLAVE => [
+                'doctrine_dbal_pdo_sqlite_slave',
+            ],
         ],
+    ],
+
+    ModuleConfiguration::OPTION_CONNECTIONS_AND_PARAMETERS => [
         'doctrine_dbal_pdo_sqlite' => [
-            'driver' => 'pdo_sqlite', // (string): The built-in driver implementation to use
-            'path' => '/path/to/db.sqlite', // (string): The filesystem path to the database file.
-            // Mutually exclusive with memory. path takes precedence.
-            'memory' => false, // (boolean): True if the SQLite database should be in-memory (non-persistent).
-            // Mutually exclusive with path. path takes precedence.
-            //'url' => 'sqlite:////path/to/db.sqlite // ...alternative way of providing path parameter.
-            //'url' => 'sqlite:///:memory:' // ...alternative way of providing memory parameter.
-            // Additional parameters not originally available in Doctrine DBAL
-            'table_prefix' => '', // (string): Prefix for each table.
+            'driver' => 'pdo_sqlite',
+            'memory' => true,
+            'table_prefix' => '',
+        ],
+        'doctrine_dbal_pdo_sqlite_slave' => [
+            'driver' => 'pdo_sqlite',
+            'memory' => true,
+            'table_prefix' => '',
         ],
     ],
 ];
diff --git a/tests/src/Auth/Process/AccountingTest.php b/tests/src/Auth/Process/AccountingTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..c00dbfdc7478efe66a40a72001ba8bb845cef0fa
--- /dev/null
+++ b/tests/src/Auth/Process/AccountingTest.php
@@ -0,0 +1,173 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Test\Module\accounting\Auth\Process;
+
+use Psr\Log\LoggerInterface;
+use SimpleSAML\Module\accounting\Auth\Process\Accounting;
+use PHPUnit\Framework\TestCase;
+use SimpleSAML\Module\accounting\Entities\Authentication\Event;
+use SimpleSAML\Module\accounting\Entities\Authentication\State;
+use SimpleSAML\Module\accounting\Exceptions\InvalidConfigurationException;
+use SimpleSAML\Module\accounting\ModuleConfiguration;
+use SimpleSAML\Module\accounting\Stores\Builders\JobsStoreBuilder;
+use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection;
+use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Factory;
+use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Migrator;
+use SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store;
+use SimpleSAML\Module\accounting\Trackers\Authentication\DoctrineDbal\Versioned\Tracker;
+use SimpleSAML\Module\accounting\Trackers\Builders\AuthenticationDataTrackerBuilder;
+use SimpleSAML\Test\Module\accounting\Constants\StateArrays;
+
+/**
+ * @covers \SimpleSAML\Module\accounting\Auth\Process\Accounting
+ * @uses   \SimpleSAML\Module\accounting\Entities\Authentication\Event
+ * @uses   \SimpleSAML\Module\accounting\Entities\Authentication\State
+ * @uses   \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection
+ * @uses   \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Migrator
+ * @uses   \SimpleSAML\Module\accounting\Stores\Builders\Bases\AbstractStoreBuilder
+ * @uses   \SimpleSAML\Module\accounting\Trackers\Builders\AuthenticationDataTrackerBuilder
+ * @uses   \SimpleSAML\Module\accounting\Entities\Authentication\Event\Job
+ * @uses   \SimpleSAML\Module\accounting\Entities\Bases\AbstractJob
+ * @uses   \SimpleSAML\Module\accounting\Helpers\NetworkHelper
+ */
+class AccountingTest extends TestCase
+{
+    protected \PHPUnit\Framework\MockObject\Stub $moduleConfigurationStub;
+    protected \PHPUnit\Framework\MockObject\MockObject $loggerMock;
+    protected array $filterConfig;
+    protected \PHPUnit\Framework\MockObject\MockObject $jobsStoreBuilderMock;
+    protected \PHPUnit\Framework\MockObject\MockObject $authenticationDataTrackerBuilderMock;
+    protected \PHPUnit\Framework\MockObject\MockObject $jobsStoreMock;
+    protected \PHPUnit\Framework\MockObject\MockObject $trackerMock;
+    protected array $sampleState;
+
+    public function testCanCreateInstance(): void
+    {
+        /** @psalm-suppress InvalidArgument */
+        $this->assertInstanceOf(
+            Accounting::class,
+            new Accounting(
+                $this->filterConfig,
+                null,
+                $this->moduleConfigurationStub,
+                $this->loggerMock,
+            )
+        );
+
+        /** @psalm-suppress InvalidArgument */
+        $this->assertInstanceOf(
+            Accounting::class,
+            new Accounting(
+                $this->filterConfig,
+                null,
+                $this->moduleConfigurationStub,
+                $this->loggerMock,
+                $this->jobsStoreBuilderMock,
+                $this->authenticationDataTrackerBuilderMock
+            )
+        );
+    }
+
+    public function testCreatesJobOnAsynchronousAccountingType(): void
+    {
+        $this->moduleConfigurationStub->method('getAccountingProcessingType')
+            ->willReturn(ModuleConfiguration\AccountingProcessingType::VALUE_ASYNCHRONOUS);
+        $this->moduleConfigurationStub->method('getJobsStoreClass')
+            ->willReturn(Store::class);
+
+        $this->jobsStoreMock->expects($this->once())
+            ->method('enqueue')
+            ->with($this->isInstanceOf(Event\Job::class));
+
+        $this->jobsStoreBuilderMock->expects($this->once())
+            ->method('build')
+            ->with($this->equalTo(Store::class))
+            ->willReturn($this->jobsStoreMock);
+
+        $this->authenticationDataTrackerBuilderMock
+            ->expects($this->never())
+            ->method('build');
+
+        /** @psalm-suppress InvalidArgument */
+        (new Accounting(
+            $this->filterConfig,
+            null,
+            $this->moduleConfigurationStub,
+            $this->loggerMock,
+            $this->jobsStoreBuilderMock,
+            $this->authenticationDataTrackerBuilderMock
+        ))->process($this->sampleState);
+    }
+
+    public function testAccountingRunsOnSynchronousType(): void
+    {
+        $this->moduleConfigurationStub->method('getAccountingProcessingType')
+            ->willReturn(ModuleConfiguration\AccountingProcessingType::VALUE_SYNCHRONOUS);
+
+        $this->moduleConfigurationStub->method('getDefaultDataTrackerAndProviderClass')
+            ->willReturn(Tracker::class);
+        $this->moduleConfigurationStub->method('getAdditionalTrackers')->willReturn([]);
+
+        $this->jobsStoreBuilderMock->expects($this->never())
+            ->method('build');
+
+        $this->trackerMock
+            ->expects($this->once())
+            ->method('process')
+            ->with($this->isInstanceOf(Event::class));
+
+        $this->authenticationDataTrackerBuilderMock
+            ->expects($this->once())
+            ->method('build')
+            ->with($this->equalTo(Tracker::class))
+            ->willReturn($this->trackerMock);
+
+        /** @psalm-suppress InvalidArgument */
+        (new Accounting(
+            $this->filterConfig,
+            null,
+            $this->moduleConfigurationStub,
+            $this->loggerMock,
+            $this->jobsStoreBuilderMock,
+            $this->authenticationDataTrackerBuilderMock
+        ))->process($this->sampleState);
+    }
+
+    public function testLogsErrorOnException(): void
+    {
+        $this->moduleConfigurationStub->method('getAccountingProcessingType')
+            ->willThrowException(new InvalidConfigurationException('test'));
+
+        $this->loggerMock->expects($this->once())->method('error');
+
+        /** @psalm-suppress InvalidArgument */
+        (new Accounting(
+            $this->filterConfig,
+            null,
+            $this->moduleConfigurationStub,
+            $this->loggerMock,
+            $this->jobsStoreBuilderMock,
+            $this->authenticationDataTrackerBuilderMock
+        ))->process($this->sampleState);
+    }
+
+    protected function setUp(): void
+    {
+        $this->moduleConfigurationStub = $this->createStub(ModuleConfiguration::class);
+
+        $this->loggerMock = $this->createMock(LoggerInterface::class);
+
+        $this->jobsStoreBuilderMock = $this->createMock(JobsStoreBuilder::class);
+        $this->authenticationDataTrackerBuilderMock =
+            $this->createMock(AuthenticationDataTrackerBuilder::class);
+
+        $this->jobsStoreMock = $this->createMock(Store::class);
+        $this->trackerMock = $this->createMock(Tracker::class);
+
+        $this->sampleState = StateArrays::FULL;
+
+        $this->filterConfig = [];
+    }
+}
diff --git a/tests/src/Constants/ConnectionParameters.php b/tests/src/Constants/ConnectionParameters.php
new file mode 100644
index 0000000000000000000000000000000000000000..9aad77aa5077731e68360b81c282e602c002dca8
--- /dev/null
+++ b/tests/src/Constants/ConnectionParameters.php
@@ -0,0 +1,10 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Test\Module\accounting\Constants;
+
+class ConnectionParameters
+{
+    public const DBAL_SQLITE_MEMORY = ['driver' => 'pdo_sqlite', 'memory' => true,];
+}
diff --git a/tests/src/Constants/DateTime.php b/tests/src/Constants/DateTime.php
new file mode 100644
index 0000000000000000000000000000000000000000..df07b1951637fed22e89289a63a664846db6a112
--- /dev/null
+++ b/tests/src/Constants/DateTime.php
@@ -0,0 +1,10 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Test\Module\accounting\Constants;
+
+final class DateTime
+{
+    public const DEFAULT_FORMAT = 'Y-m-d H:i:s';
+}
diff --git a/tests/src/Constants/RawRowResult.php b/tests/src/Constants/RawRowResult.php
new file mode 100644
index 0000000000000000000000000000000000000000..f09e6fc597070ad9b05401e60fce97c1d276979d
--- /dev/null
+++ b/tests/src/Constants/RawRowResult.php
@@ -0,0 +1,26 @@
+<?php
+// phpcs:ignoreFile
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Test\Module\accounting\Constants;
+
+use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\TableConstants;
+
+class RawRowResult
+{
+    public const CONNECTED_ORGANIZATION = [
+        TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_NUMBER_OF_AUTHENTICATIONS => 1,
+        TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_LAST_AUTHENTICATION_AT => '2022-02-22 22:22:22',
+        TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_FIRST_AUTHENTICATION_AT => '2022-02-02 22:22:22',
+        TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_SP_METADATA => 'a:9:{s:19:"SingleLogoutService";a:1:{i:0;a:2:{s:7:"Binding";s:50:"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect";s:8:"Location";s:121:"https://pc-mivancic.srce.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/module.php/saml/sp/singleLogoutService/default-sp";}}s:24:"AssertionConsumerService";a:2:{i:0;a:3:{s:7:"Binding";s:46:"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST";s:8:"Location";s:126:"https://pc-mivancic.srce.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/module.php/saml/sp/assertionConsumerService/default-sp";s:5:"index";i:0;}i:1;a:3:{s:7:"Binding";s:50:"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact";s:8:"Location";s:126:"https://pc-mivancic.srce.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/module.php/saml/sp/assertionConsumerService/default-sp";s:5:"index";i:1;}}s:8:"contacts";a:1:{i:0;a:3:{s:12:"emailAddress";s:15:"mivanci@srce.hr";s:9:"givenName";s:15:"Marko Ivančić";s:11:"contactType";s:9:"technical";}}s:4:"name";a:1:{s:2:"en";s:12:"Test service";}s:11:"description";a:1:{s:2:"en";s:27:"Description of test service";}s:16:"OrganizationName";a:1:{s:2:"en";s:17:"Test organization";}s:8:"entityid";s:114:"https://pc-mivancic.srce.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/module.php/saml/sp/metadata.php/default-sp";s:14:"metadata-index";s:114:"https://pc-mivancic.srce.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/module.php/saml/sp/metadata.php/default-sp";s:12:"metadata-set";s:16:"saml20-sp-remote";}',
+        TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_USER_ATTRIBUTES => 'a:5:{s:33:"urn:oid:0.9.2342.19200300.100.1.1";a:1:{i:0;s:11:"student-uid";}s:32:"urn:oid:1.3.6.1.4.1.5923.1.1.1.1";a:2:{i:0;s:6:"member";i:1;s:7:"student";}s:15:"urn:oid:2.5.4.4";a:1:{i:0;s:10:"student-sn";}s:19:"hrEduPersonUniqueID";a:1:{i:0;s:19:"student@example.org";}s:23:"hrEduPersonPersistentID";a:1:{i:0;s:13:"student123abc";}}',
+    ];
+
+
+    public const ACTIVITY = [
+        TableConstants::ENTITY_ACTIVITY_COLUMN_NAME_HAPPENED_AT => '2022-02-22 22:22:22',
+        TableConstants::ENTITY_ACTIVITY_COLUMN_NAME_SP_METADATA => 'a:9:{s:19:"SingleLogoutService";a:1:{i:0;a:2:{s:7:"Binding";s:50:"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect";s:8:"Location";s:121:"https://pc-mivancic.srce.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/module.php/saml/sp/singleLogoutService/default-sp";}}s:24:"AssertionConsumerService";a:2:{i:0;a:3:{s:7:"Binding";s:46:"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST";s:8:"Location";s:126:"https://pc-mivancic.srce.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/module.php/saml/sp/assertionConsumerService/default-sp";s:5:"index";i:0;}i:1;a:3:{s:7:"Binding";s:50:"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact";s:8:"Location";s:126:"https://pc-mivancic.srce.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/module.php/saml/sp/assertionConsumerService/default-sp";s:5:"index";i:1;}}s:8:"contacts";a:1:{i:0;a:3:{s:12:"emailAddress";s:15:"mivanci@srce.hr";s:9:"givenName";s:15:"Marko Ivančić";s:11:"contactType";s:9:"technical";}}s:4:"name";a:1:{s:2:"en";s:12:"Test service";}s:11:"description";a:1:{s:2:"en";s:27:"Description of test service";}s:16:"OrganizationName";a:1:{s:2:"en";s:17:"Test organization";}s:8:"entityid";s:114:"https://pc-mivancic.srce.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/module.php/saml/sp/metadata.php/default-sp";s:14:"metadata-index";s:114:"https://pc-mivancic.srce.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/module.php/saml/sp/metadata.php/default-sp";s:12:"metadata-set";s:16:"saml20-sp-remote";}',
+        TableConstants::ENTITY_ACTIVITY_COLUMN_NAME_USER_ATTRIBUTES => 'a:5:{s:33:"urn:oid:0.9.2342.19200300.100.1.1";a:1:{i:0;s:11:"student-uid";}s:32:"urn:oid:1.3.6.1.4.1.5923.1.1.1.1";a:2:{i:0;s:6:"member";i:1;s:7:"student";}s:15:"urn:oid:2.5.4.4";a:1:{i:0;s:10:"student-sn";}s:19:"hrEduPersonUniqueID";a:1:{i:0;s:19:"student@example.org";}s:23:"hrEduPersonPersistentID";a:1:{i:0;s:13:"student123abc";}}',
+    ];
+}
diff --git a/tests/src/Constants/SerializedJob.php b/tests/src/Constants/SerializedJob.php
new file mode 100644
index 0000000000000000000000000000000000000000..703c055d01c3264f3659957d3a30f23a0b544bef
--- /dev/null
+++ b/tests/src/Constants/SerializedJob.php
@@ -0,0 +1,12 @@
+<?php
+
+namespace SimpleSAML\Test\Module\accounting\Constants;
+
+use SimpleSAML\Module\accounting\Entities\Authentication\Event\Job;
+
+class SerializedJob
+{
+    public const EVENT = [
+        Job::class => 'TODO'
+    ];
+}
diff --git a/tests/src/Constants/StateArrays.php b/tests/src/Constants/StateArrays.php
new file mode 100644
index 0000000000000000000000000000000000000000..7ad8ba490227e4f69f3fe0d638197300a9e7008d
--- /dev/null
+++ b/tests/src/Constants/StateArrays.php
@@ -0,0 +1,170 @@
+<?php
+// phpcs:ignoreFile
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Test\Module\accounting\Constants;
+
+final class StateArrays
+{
+    public const FULL = [
+        'Responder' => [0 => '\\SimpleSAML\\Module\\saml\\IdP\\SAML2', 1 => 'sendResponse',],
+        '\\SimpleSAML\\Auth\\State.exceptionFunc' => [
+            0 => '\\SimpleSAML\\Module\\saml\\IdP\\SAML2',
+            1 => 'handleAuthError',
+        ],
+        '\\SimpleSAML\\Auth\\State.restartURL' => 'https://localhost.someone.from.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/saml2/idp/SSOService.php?spentityid=https%3A%2F%2Fpc-example.org.hr%3A9074%2Fsimplesamlphp%2Fsimplesamlphp-2-beta-git%2Fmodule.php%2Fsaml%2Fsp%2Fmetadata.php%2Fdefault-sp&RelayState=https%3A%2F%2Flocalhost.someone.from.hr%3A9074%2Fsimplesamlphp%2Fsimplesamlphp-2-beta-git%2Fmodule.php%2Fadmin%2Ftest%2Fdefault-sp&cookieTime=1660912195',
+        'SPMetadata' => [
+            'SingleLogoutService' => [
+                0 => [
+                    'Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
+                    'Location' => 'https://pc-example.org.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/module.php/saml/sp/singleLogoutService/default-sp',
+                ],
+            ],
+            'AssertionConsumerService' => [
+                0 => [
+                    'Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',
+                    'Location' => 'https://pc-example.org.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/module.php/saml/sp/assertionConsumerService/default-sp',
+                    'index' => 0,
+                ],
+                1 => [
+                    'Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact',
+                    'Location' => 'https://pc-example.org.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/module.php/saml/sp/assertionConsumerService/default-sp',
+                    'index' => 1,
+                ],
+            ],
+            'contacts' => [
+                0 => [
+                    'emailAddress' => 'example@org.hr',
+                    'givenName' => 'Marko Ivančić',
+                    'contactType' => 'technical',
+                ],
+            ],
+            'entityid' => 'https://pc-example.org.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/module.php/saml/sp/metadata.php/default-sp',
+            'metadata-index' => 'https://pc-example.org.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/module.php/saml/sp/metadata.php/default-sp',
+            'metadata-set' => 'saml20-sp-remote',
+        ],
+        'saml:RelayState' => 'https://localhost.someone.from.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/module.php/admin/test/default-sp',
+        'saml:RequestId' => null,
+        'saml:IDPList' => [],
+        'saml:ProxyCount' => null,
+        'saml:RequesterID' => null,
+        'ForceAuthn' => false,
+        'isPassive' => false,
+        'saml:ConsumerURL' => 'https://pc-example.org.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/module.php/saml/sp/assertionConsumerService/default-sp',
+        'saml:Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',
+        'saml:NameIDFormat' => null,
+        'saml:AllowCreate' => true,
+        'saml:Extensions' => null,
+        'saml:AuthnRequestReceivedAt' => 1660912195.505402,
+        'saml:RequestedAuthnContext' => null,
+        'core:IdP' => 'saml2:https://localhost.someone.from.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/saml2/idp/metadata.php',
+        'core:SP' => 'https://pc-example.org.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/module.php/saml/sp/metadata.php/default-sp',
+        'IdPMetadata' => [
+            'host' => 'localhost.someone.from.hr',
+            'privatekey' => 'key.pem',
+            'certificate' => 'cert.pem',
+            'auth' => 'example-userpass',
+            'attributes.NameFormat' => 'urn:oasis:names:tc:SAML:2.0:attrname-format:uri',
+            'authproc' => [100 => ['class' => 'core:AttributeMap', 0 => 'name2oid',],],
+            'entityid' => 'https://localhost.someone.from.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/saml2/idp/metadata.php',
+            'metadata-index' => 'https://localhost.someone.from.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/saml2/idp/metadata.php',
+            'metadata-set' => 'saml20-idp-hosted',
+        ],
+        'ReturnCallback' => [0 => '\\SimpleSAML\\IdP', 1 => 'postAuth',],
+        'Attributes' => [
+            'hrEduPersonUniqueID' => [0 => 'testuser@primjer.hr',],
+            'urn:oid:0.9.2342.19200300.100.1.1' => [0 => 'testuser',],
+            'urn:oid:2.5.4.3' => [0 => 'TestName TestSurname',],
+            'urn:oid:2.5.4.4' => [0 => 'TestSurname', 1 => 'TestSurname2',],
+            'urn:oid:2.5.4.42' => [0 => 'TestName',],
+            'urn:oid:0.9.2342.19200300.100.1.3' => [0 => 'testusermail@primjer.hr', 1 => 'testusermail2@primjer.hr',],
+            'urn:oid:2.5.4.20' => [0 => '123',],
+            'hrEduPersonExtensionNumber' => [0 => '123',],
+            'urn:oid:0.9.2342.19200300.100.1.41' => [0 => '123',],
+            'urn:oid:2.5.4.23' => [0 => '123',],
+            'hrEduPersonUniqueNumber' => [0 => 'LOCAL_NO: 100 ', 1 => 'OIB: 00861590360',],
+            'hrEduPersonOIB' => [0 => '00861590360',],
+            'hrEduPersonDateOfBirth' => [0 => '20200101',],
+            'hrEduPersonGender' => [0 => '9',],
+            'urn:oid:0.9.2342.19200300.100.1.60' => [0 => '987654321',],
+            'urn:oid:2.5.4.36' => [0 => 'cert-value',],
+            'urn:oid:1.3.6.1.4.1.250.1.57' => [0 => 'http://www.org.hr/~ivek Home Page',],
+            'hrEduPersonProfessionalStatus' => [0 => 'VSS',],
+            'hrEduPersonAcademicStatus' => [0 => 'redoviti profesor',],
+            'hrEduPersonScienceArea' => [0 => 'sociologija',],
+            'hrEduPersonAffiliation' => [0 => 'djelatnik', 1 => 'student',],
+            'hrEduPersonPrimaryAffiliation' => [0 => 'djelatnik',],
+            'hrEduPersonStudentCategory' => [0 => 'redoviti student:diplomski sveučilišni studij',],
+            'hrEduPersonExpireDate' => [0 => '20211231',],
+            'hrEduPersonTitle' => [0 => 'rektor',],
+            'hrEduPersonRole' => [0 => 'ICT koordinator', 1 => 'ISVU koordinator',],
+            'hrEduPersonStaffCategory' => [0 => 'nastavno osoblje', 1 => 'istraživači',],
+            'hrEduPersonGroupMember' => [0 => 'grupa 1', 1 => 'grupa 2',],
+            'urn:oid:2.5.4.10' => [0 => 'Testna ustanova',],
+            'hrEduPersonHomeOrg' => [0 => 'primjer.hr',],
+            'urn:oid:2.5.4.11' => [0 => 'Testna org jedinica',],
+            'urn:oid:0.9.2342.19200300.100.1.6' => [0 => '123',],
+            'urn:oid:2.5.4.16' => [0 => 'Testna ustanova, Baštijanova 52A, HR-10000 Zagreb',],
+            'urn:oid:2.5.4.7' => [0 => 'Zagreb',],
+            'urn:oid:2.5.4.17' => [0 => 'HR-10000',],
+            'urn:oid:2.5.4.9' => [0 => 'Baštijanova 52A',],
+            'urn:oid:0.9.2342.19200300.100.1.39' => [0 => 'Testna adresa 52A, HR-10000, Zagreb',],
+            'urn:oid:0.9.2342.19200300.100.1.20' => [0 => '123',],
+            'hrEduPersonCommURI' => [0 => '123',],
+            'hrEduPersonPrivacy' => [0 => 'street', 1 => 'postalCode',],
+            'hrEduPersonPersistentID' => [0 => 'da4294fb4e5746d57ab6ad88d2daf275',],
+            'urn:oid:2.16.840.1.113730.3.1.241' => [0 => 'testname123',],
+            'urn:oid:1.3.6.1.4.1.25178.1.2.12' => [0 => 'skype: pepe.perez',],
+            'hrEduPersonCardNum' => [0 => '123',],
+            'updatedAt' => [0 => '123456789',],
+            'urn:oid:1.3.6.1.4.1.5923.1.1.1.7' => [0 => 'urn:example:oidc:manage:client',],
+        ],
+        'Authority' => 'example-userpass',
+        'AuthnInstant' => 1660911943,
+        'Expire' => 1660940743,
+        'ReturnCall' => [0 => '\\SimpleSAML\\IdP', 1 => 'postAuthProc',],
+        'Destination' => [
+            'SingleLogoutService' => [
+                0 => [
+                    'Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
+                    'Location' => 'https://pc-example.org.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/module.php/saml/sp/singleLogoutService/default-sp',
+                ],
+            ],
+            'AssertionConsumerService' => [
+                0 => [
+                    'Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',
+                    'Location' => 'https://pc-example.org.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/module.php/saml/sp/assertionConsumerService/default-sp',
+                    'index' => 0,
+                ],
+                1 => [
+                    'Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact',
+                    'Location' => 'https://pc-example.org.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/module.php/saml/sp/assertionConsumerService/default-sp',
+                    'index' => 1,
+                ],
+            ],
+            'contacts' => [
+                0 => [
+                    'emailAddress' => 'example@org.hr',
+                    'givenName' => 'Marko Ivančić',
+                    'contactType' => 'technical',
+                ],
+            ],
+            'entityid' => 'https://pc-example.org.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/module.php/saml/sp/metadata.php/default-sp',
+            'metadata-index' => 'https://pc-example.org.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/module.php/saml/sp/metadata.php/default-sp',
+            'metadata-set' => 'saml20-sp-remote',
+        ],
+        'Source' => [
+            'host' => 'localhost.someone.from.hr',
+            'privatekey' => 'key.pem',
+            'certificate' => 'cert.pem',
+            'auth' => 'example-userpass',
+            'attributes.NameFormat' => 'urn:oasis:names:tc:SAML:2.0:attrname-format:uri',
+            'authproc' => [100 => ['class' => 'core:AttributeMap', 0 => 'name2oid',],],
+            'entityid' => 'https://localhost.someone.from.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/saml2/idp/metadata.php',
+            'metadata-index' => 'https://localhost.someone.from.hr:9074/simplesamlphp/simplesamlphp-2-beta-git/saml2/idp/metadata.php',
+            'metadata-set' => 'saml20-idp-hosted',
+        ],
+        '\\SimpleSAML\\Auth\\ProcessingChain.filters' => [],
+    ];
+}
diff --git a/tests/src/Entities/Activity/BagTest.php b/tests/src/Entities/Activity/BagTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..023dcf5dcea25d8712e996feb9fe69828e5ce77f
--- /dev/null
+++ b/tests/src/Entities/Activity/BagTest.php
@@ -0,0 +1,26 @@
+<?php
+
+namespace SimpleSAML\Test\Module\accounting\Entities\Activity;
+
+use SimpleSAML\Module\accounting\Entities\Activity;
+use SimpleSAML\Module\accounting\Entities\Activity\Bag;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers \SimpleSAML\Module\accounting\Entities\Activity\Bag
+ */
+class BagTest extends TestCase
+{
+    public function testCanAddActivity(): void
+    {
+        $activityStub = $this->createStub(Activity::class);
+        $bag = new Bag();
+
+        $this->assertEmpty($bag->getAll());
+
+        /** @psalm-suppress InvalidArgument */
+        $bag->add($activityStub);
+
+        $this->assertNotEmpty($bag->getAll());
+    }
+}
diff --git a/tests/src/Entities/ActivityTest.php b/tests/src/Entities/ActivityTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..2d647655b03313da163d6b6a60e106b8afcd64b3
--- /dev/null
+++ b/tests/src/Entities/ActivityTest.php
@@ -0,0 +1,47 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Test\Module\accounting\Entities;
+
+use SimpleSAML\Module\accounting\Entities\Activity;
+use PHPUnit\Framework\TestCase;
+use SimpleSAML\Module\accounting\Entities\ServiceProvider;
+use SimpleSAML\Module\accounting\Entities\User;
+
+/**
+ * @covers \SimpleSAML\Module\accounting\Entities\Activity
+ */
+class ActivityTest extends TestCase
+{
+    /**
+     * @var \PHPUnit\Framework\MockObject\Stub|ServiceProvider|ServiceProvider&\PHPUnit\Framework\MockObject\Stub
+     */
+    protected $serviceProviderStub;
+    /**
+     * @var \PHPUnit\Framework\MockObject\Stub|User|User&\PHPUnit\Framework\MockObject\Stub
+     */
+    protected $userStub;
+    protected \DateTimeImmutable $happenedAt;
+
+    public function setUp(): void
+    {
+        $this->serviceProviderStub = $this->createStub(ServiceProvider::class);
+        $this->userStub = $this->createStub(User::class);
+        $this->happenedAt = new \DateTimeImmutable();
+    }
+
+    public function testCanCreateInstance(): void
+    {
+        /** @psalm-suppress InvalidArgument */
+        $activity = new Activity(
+            $this->serviceProviderStub,
+            $this->userStub,
+            $this->happenedAt
+        );
+
+        $this->assertSame($this->serviceProviderStub, $activity->getServiceProvider());
+        $this->assertSame($this->userStub, $activity->getUser());
+        $this->assertSame($this->happenedAt, $activity->getHappenedAt());
+    }
+}
diff --git a/tests/src/Entities/AuthenticationEvent/JobTest.php b/tests/src/Entities/Authentication/Event/JobTest.php
similarity index 56%
rename from tests/src/Entities/AuthenticationEvent/JobTest.php
rename to tests/src/Entities/Authentication/Event/JobTest.php
index cbe5e8bf5d3da071d54d62a1af4d3ed2abeeb22a..24551ef3409badd98cd82f8387a6f3c8bc5b3704 100644
--- a/tests/src/Entities/AuthenticationEvent/JobTest.php
+++ b/tests/src/Entities/Authentication/Event/JobTest.php
@@ -1,25 +1,29 @@
 <?php
 
-namespace SimpleSAML\Test\Module\accounting\Entities\AuthenticationEvent;
+namespace SimpleSAML\Test\Module\accounting\Entities\Authentication\Event;
 
-use SimpleSAML\Module\accounting\Entities\AuthenticationEvent;
-use SimpleSAML\Module\accounting\Entities\Bases\AbstractPayload;
-use SimpleSAML\Module\accounting\Entities\AuthenticationEvent\Job;
 use PHPUnit\Framework\TestCase;
+use SimpleSAML\Module\accounting\Entities\Authentication\Event;
+use SimpleSAML\Module\accounting\Entities\Authentication\Event\Job;
+use SimpleSAML\Module\accounting\Entities\Authentication\State;
+use SimpleSAML\Module\accounting\Entities\Bases\AbstractPayload;
 use SimpleSAML\Module\accounting\Exceptions\UnexpectedValueException;
+use SimpleSAML\Test\Module\accounting\Constants\StateArrays;
 
 /**
- * @covers \SimpleSAML\Module\accounting\Entities\AuthenticationEvent\Job
+ * @covers \SimpleSAML\Module\accounting\Entities\Authentication\Event\Job
  * @uses \SimpleSAML\Module\accounting\Entities\Bases\AbstractJob
- * @uses \SimpleSAML\Module\accounting\Entities\AuthenticationEvent
+ * @uses \SimpleSAML\Module\accounting\Entities\Authentication\Event
+ * @uses \SimpleSAML\Module\accounting\Entities\Authentication\State
+ * @uses \SimpleSAML\Module\accounting\Helpers\NetworkHelper
  */
 class JobTest extends TestCase
 {
     public function testCanCreateInstanceWithAuthenticationEventEntity(): void
     {
-        $job = new Job(new AuthenticationEvent(['sample' => 'state']));
+        $job = new Job(new Event(new State(StateArrays::FULL)));
 
-        $this->assertInstanceOf(AuthenticationEvent::class, $job->getPayload());
+        $this->assertInstanceOf(Event::class, $job->getPayload());
     }
 
     public function testPayloadMustBeAuthenticationEventOrThrow(): void
@@ -34,7 +38,7 @@ class JobTest extends TestCase
 
     public function testCanGetProperType(): void
     {
-        $job = new Job(new AuthenticationEvent(['sample' => 'state']));
+        $job = new Job(new Event(new State(StateArrays::FULL)));
 
         $this->assertSame(Job::class, $job->getType());
     }
diff --git a/tests/src/Entities/Authentication/EventTest.php b/tests/src/Entities/Authentication/EventTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..0141b2d32d967c7271d3f917457d99d5e426c3cf
--- /dev/null
+++ b/tests/src/Entities/Authentication/EventTest.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace SimpleSAML\Test\Module\accounting\Entities\Authentication;
+
+use PHPUnit\Framework\TestCase;
+use SimpleSAML\Module\accounting\Entities\Authentication\Event;
+use SimpleSAML\Module\accounting\Entities\Authentication\State;
+use SimpleSAML\Test\Module\accounting\Constants\StateArrays;
+
+/**
+ * @covers \SimpleSAML\Module\accounting\Entities\Authentication\Event
+ * @uses \SimpleSAML\Module\accounting\Entities\Authentication\State
+ * @uses \SimpleSAML\Module\accounting\Helpers\NetworkHelper
+ */
+class EventTest extends TestCase
+{
+    public function testCanGetState(): void
+    {
+        $dateTime = new \DateTimeImmutable();
+        $authenticationEvent = new Event(new State(StateArrays::FULL), $dateTime);
+
+        $this->assertInstanceOf(State::class, $authenticationEvent->getState());
+
+        $this->assertSame(
+            StateArrays::FULL['Source']['entityid'],
+            $authenticationEvent->getState()->getIdentityProviderEntityId()
+        );
+
+        $this->assertEquals($dateTime, $authenticationEvent->getHappenedAt());
+    }
+}
diff --git a/tests/src/Entities/Authentication/StateTest.php b/tests/src/Entities/Authentication/StateTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..32ac9e230ae03527bd3026cf7993d2cbbf8916bf
--- /dev/null
+++ b/tests/src/Entities/Authentication/StateTest.php
@@ -0,0 +1,187 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Test\Module\accounting\Entities\Authentication;
+
+use SimpleSAML\Module\accounting\Entities\Authentication\State;
+use PHPUnit\Framework\TestCase;
+use SimpleSAML\Module\accounting\Exceptions\UnexpectedValueException;
+use SimpleSAML\Test\Module\accounting\Constants\StateArrays;
+
+/**
+ * @covers \SimpleSAML\Module\accounting\Entities\Authentication\State
+ * @uses \SimpleSAML\Module\accounting\Helpers\NetworkHelper
+ */
+class StateTest extends TestCase
+{
+    public function testCanInitializeValidState(): void
+    {
+        $state = new State(StateArrays::FULL);
+
+        $this->assertSame($state->getIdentityProviderEntityId(), StateArrays::FULL['Source']['entityid']);
+    }
+
+    public function testCanResolveIdpEntityId(): void
+    {
+        $stateArray = StateArrays::FULL;
+        $state = new State($stateArray);
+        $this->assertSame($state->getIdentityProviderEntityId(), StateArrays::FULL['IdPMetadata']['entityid']);
+
+        $this->expectException(UnexpectedValueException::class);
+        unset($stateArray['IdPMetadata']['entityid']);
+        new State($stateArray);
+    }
+
+    public function testCanResolveSpEntityId(): void
+    {
+        $stateArray = StateArrays::FULL;
+        $state = new State($stateArray);
+        $this->assertSame($state->getServiceProviderEntityId(), StateArrays::FULL['SPMetadata']['entityid']);
+
+        $this->expectException(UnexpectedValueException::class);
+        unset($stateArray['SPMetadata']['entityid']);
+        new State($stateArray);
+    }
+
+    public function testCanResolveAttributes(): void
+    {
+        $state = new State(StateArrays::FULL);
+        $this->assertSame($state->getAttributes(), StateArrays::FULL['Attributes']);
+    }
+
+    public function testCanResolveAccountedClientIpAddress(): void
+    {
+        $stateArray = StateArrays::FULL;
+
+        $state = new State($stateArray);
+        $this->assertNull($state->getClientIpAddress());
+
+        $sampleIp = '123.123.123.123';
+        $stateArray[State::KEY_ACCOUNTING][State::ACCOUNTING_KEY_CLIENT_IP_ADDRESS] = $sampleIp;
+
+        $state = new State($stateArray);
+        $this->assertSame($sampleIp, $state->getClientIpAddress());
+    }
+
+    public function testReturnsNullIfAuthnInstantNotPresent(): void
+    {
+        $stateArray = StateArrays::FULL;
+
+        unset($stateArray['AuthnInstant']);
+
+        $state = new State($stateArray);
+
+        $this->assertNull($state->getAuthenticationInstant());
+    }
+
+    public function testThrowsOnMissingSourceEntityId(): void
+    {
+        $this->expectException(UnexpectedValueException::class);
+
+        $stateArray = StateArrays::FULL;
+
+        unset($stateArray['Source'], $stateArray['IdPMetadata']);
+
+        /** @psalm-suppress UnusedMethodCall */
+        (new State($stateArray));
+    }
+
+    public function testUseSpMetadataForEntityIdIfDestinationNotAvailable(): void
+    {
+        $stateArray = StateArrays::FULL;
+
+        unset($stateArray['Destination']);
+
+        $state = new State($stateArray);
+
+        $this->assertSame($state->getServiceProviderEntityId(), StateArrays::FULL['SPMetadata']['entityid']);
+    }
+
+    public function testThrowsOnMissingDestinationEntityId(): void
+    {
+        $this->expectException(UnexpectedValueException::class);
+
+        $stateArray = StateArrays::FULL;
+
+        unset($stateArray['Destination'], $stateArray['SPMetadata']);
+
+        (new State($stateArray));
+    }
+
+    public function testThrowsOnInvalidAuthnInstantValue(): void
+    {
+        $this->expectException(UnexpectedValueException::class);
+
+        $stateArray = StateArrays::FULL;
+        $stateArray['AuthnInstant'] = 'invalid';
+
+        new State($stateArray);
+    }
+
+    public function testThrowsOnMissingAttributes(): void
+    {
+        $this->expectException(UnexpectedValueException::class);
+
+        $stateArray = StateArrays::FULL;
+
+        unset($stateArray['Attributes']);
+
+        /** @psalm-suppress UnusedMethodCall */
+        (new State($stateArray));
+    }
+
+    public function testCanGetAttributeValue(): void
+    {
+        $state = new State(StateArrays::FULL);
+
+        $this->assertSame(
+            StateArrays::FULL['Attributes']['hrEduPersonUniqueID'][0],
+            $state->getAttributeValue('hrEduPersonUniqueID')
+        );
+
+        $this->assertNull($state->getAttributeValue('non-existent'));
+    }
+
+    public function testCanResolveIdpMetadataArray(): void
+    {
+        // Metadata from 'IdPMetadata'
+        $sampleState = StateArrays::FULL;
+        $state = new State($sampleState);
+        $this->assertEquals($sampleState['IdPMetadata'], $state->getIdentityProviderMetadata());
+
+        // Fallback metadata from 'Source'
+        unset($sampleState['IdPMetadata']);
+        $state = new State($sampleState);
+        $this->assertEquals($sampleState['Source'], $state->getIdentityProviderMetadata());
+
+        // Throws on no IdP metadata
+        $this->expectException(UnexpectedValueException::class);
+        unset($sampleState['Source']);
+        new State($sampleState);
+    }
+
+    public function testCanResolveSpMetadataArray(): void
+    {
+        // Metadata from 'IdPMetadata'
+        $sampleState = StateArrays::FULL;
+        $state = new State($sampleState);
+        $this->assertEquals($sampleState['SPMetadata'], $state->getServiceProviderMetadata());
+
+        // Fallback metadata from 'Destination'
+        unset($sampleState['SPMetadata']);
+        $state = new State($sampleState);
+        $this->assertEquals($sampleState['Destination'], $state->getServiceProviderMetadata());
+
+        // Throws on no SP metadata
+        $this->expectException(UnexpectedValueException::class);
+        unset($sampleState['Destination']);
+        new State($sampleState);
+    }
+
+    public function testCanGetCreatedAt(): void
+    {
+        $state = new State(StateArrays::FULL);
+        $this->assertInstanceOf(\DateTimeImmutable::class, $state->getCreatedAt());
+    }
+}
diff --git a/tests/src/Entities/AuthenticationEventTest.php b/tests/src/Entities/AuthenticationEventTest.php
deleted file mode 100644
index bf4ceb2101c946b9abea1fc5bbe8f662d5d88aa3..0000000000000000000000000000000000000000
--- a/tests/src/Entities/AuthenticationEventTest.php
+++ /dev/null
@@ -1,24 +0,0 @@
-<?php
-
-namespace SimpleSAML\Test\Module\accounting\Entities;
-
-use SimpleSAML\Module\accounting\Entities\AuthenticationEvent;
-use PHPUnit\Framework\TestCase;
-
-/**
- * @covers \SimpleSAML\Module\accounting\Entities\AuthenticationEvent
- */
-class AuthenticationEventTest extends TestCase
-{
-    protected array $state = [
-        'sample' => 'value',
-    ];
-
-
-    public function testCanGetState(): void
-    {
-        $authenticationEvent = new AuthenticationEvent($this->state);
-
-        $this->assertArrayHasKey('sample', $authenticationEvent->getState());
-    }
-}
diff --git a/tests/src/Entities/Bases/AbstractJobTest.php b/tests/src/Entities/Bases/AbstractJobTest.php
index 88d265716e59a622069ab65aa2ef27488b51a7b9..22725b8079bf41a9070af20b1b4922af94b1270f 100644
--- a/tests/src/Entities/Bases/AbstractJobTest.php
+++ b/tests/src/Entities/Bases/AbstractJobTest.php
@@ -2,16 +2,14 @@
 
 namespace SimpleSAML\Test\Module\accounting\Entities\Bases;
 
-use SimpleSAML\Module\accounting\Entities\AuthenticationEvent;
-use SimpleSAML\Module\accounting\Entities\AuthenticationEvent\Job;
-use SimpleSAML\Module\accounting\Entities\Bases\AbstractJob;
 use PHPUnit\Framework\TestCase;
+use SimpleSAML\Module\accounting\Entities\Bases\AbstractJob;
 use SimpleSAML\Module\accounting\Entities\Bases\AbstractPayload;
 
 /**
  * @covers \SimpleSAML\Module\accounting\Entities\Bases\AbstractJob
- * @uses \SimpleSAML\Module\accounting\Entities\AuthenticationEvent\Job
- * @uses \SimpleSAML\Module\accounting\Entities\AuthenticationEvent
+ * @uses \SimpleSAML\Module\accounting\Entities\Authentication\Event\Job
+ * @uses \SimpleSAML\Module\accounting\Entities\Authentication\Event
  */
 class AbstractJobTest extends TestCase
 {
diff --git a/tests/src/Entities/Bases/AbstractProviderTest.php b/tests/src/Entities/Bases/AbstractProviderTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..ea71711d5822d35a8f173c125ac34f5c1928c4fa
--- /dev/null
+++ b/tests/src/Entities/Bases/AbstractProviderTest.php
@@ -0,0 +1,66 @@
+<?php
+
+namespace SimpleSAML\Test\Module\accounting\Entities\Bases;
+
+use SimpleSAML\Module\accounting\Entities\Bases\AbstractProvider;
+use PHPUnit\Framework\TestCase;
+use SimpleSAML\Module\accounting\Entities\IdentityProvider;
+use SimpleSAML\Module\accounting\Exceptions\UnexpectedValueException;
+
+/**
+ * @covers \SimpleSAML\Module\accounting\Entities\Bases\AbstractProvider
+ * @uses \SimpleSAML\Module\accounting\Entities\IdentityProvider
+ */
+class AbstractProviderTest extends TestCase
+{
+    /**
+     * @var array
+     */
+    protected array $metadata;
+
+    public function setUp(): void
+    {
+        $this->metadata = [
+            AbstractProvider::METADATA_KEY_ENTITY_ID => 'http//example.org/idp',
+            AbstractProvider::METADATA_KEY_NAME => [
+                'en' => 'Test',
+            ],
+        ];
+    }
+
+    /**
+     * @psalm-suppress MixedArrayAccess
+     */
+    public function testCanCreateInstance(): void
+    {
+        $identityProvider = new IdentityProvider($this->metadata);
+
+        $this->assertSame($this->metadata, $identityProvider->getMetadata());
+        $this->assertSame(
+            $this->metadata[AbstractProvider::METADATA_KEY_ENTITY_ID],
+            $identityProvider->getEntityId()
+        );
+        $this->assertSame(
+            $this->metadata[AbstractProvider::METADATA_KEY_NAME]['en'],
+            $identityProvider->getName()
+        );
+    }
+
+    public function testReturnsNullIfNameNotAvailable(): void
+    {
+        $metadata = $this->metadata;
+        unset($metadata[AbstractProvider::METADATA_KEY_NAME]);
+
+        $identityProvider = new IdentityProvider($metadata);
+        $this->assertNull($identityProvider->getName());
+    }
+
+    public function testThrowsIfEntityIdNotAvailable(): void
+    {
+        $metadata = $this->metadata;
+        unset($metadata[AbstractProvider::METADATA_KEY_ENTITY_ID]);
+
+        $this->expectException(UnexpectedValueException::class);
+        new IdentityProvider($metadata);
+    }
+}
diff --git a/tests/src/Entities/ConnectedServiceProvider/BagTest.php b/tests/src/Entities/ConnectedServiceProvider/BagTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..3d62a773c429d5a84e8df4de9d4bb817e5ab712f
--- /dev/null
+++ b/tests/src/Entities/ConnectedServiceProvider/BagTest.php
@@ -0,0 +1,23 @@
+<?php
+
+namespace SimpleSAML\Test\Module\accounting\Entities\ConnectedServiceProvider;
+
+use SimpleSAML\Module\accounting\Entities\ConnectedServiceProvider;
+use SimpleSAML\Module\accounting\Entities\ConnectedServiceProvider\Bag;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers \SimpleSAML\Module\accounting\Entities\ConnectedServiceProvider\Bag
+ */
+class BagTest extends TestCase
+{
+    public function testCanAddConnectedService(): void
+    {
+        $connectedServiceProvider = $this->createStub(ConnectedServiceProvider::class);
+        $bag = new Bag();
+
+        $this->assertEmpty($bag->getAll());
+        $bag->addOrReplace($connectedServiceProvider);
+        $this->assertNotEmpty($bag->getAll());
+    }
+}
diff --git a/tests/src/Entities/ConnectedServiceProviderTest.php b/tests/src/Entities/ConnectedServiceProviderTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..186fb2e9a71b22c51e71de0be6ef6bb1b11bf406
--- /dev/null
+++ b/tests/src/Entities/ConnectedServiceProviderTest.php
@@ -0,0 +1,51 @@
+<?php
+
+namespace SimpleSAML\Test\Module\accounting\Entities;
+
+use SimpleSAML\Module\accounting\Entities\ConnectedServiceProvider;
+use PHPUnit\Framework\TestCase;
+use SimpleSAML\Module\accounting\Entities\ServiceProvider;
+use SimpleSAML\Module\accounting\Entities\User;
+
+/**
+ * @covers \SimpleSAML\Module\accounting\Entities\ConnectedServiceProvider
+ */
+class ConnectedServiceProviderTest extends TestCase
+{
+    /**
+     * @var \PHPUnit\Framework\MockObject\Stub|ServiceProvider|ServiceProvider&\PHPUnit\Framework\MockObject\Stub
+     */
+    protected $serviceProviderStub;
+    /**
+     * @var \PHPUnit\Framework\MockObject\Stub|User|User&\PHPUnit\Framework\MockObject\Stub
+     */
+    protected $userStub;
+    protected \DateTimeImmutable $dateTime;
+    protected int $numberOfAuthentications;
+
+    public function setUp(): void
+    {
+        $this->serviceProviderStub = $this->createStub(ServiceProvider::class);
+        $this->userStub = $this->createStub(User::class);
+        $this->dateTime = new \DateTimeImmutable();
+        $this->numberOfAuthentications = 1;
+    }
+
+    public function testCanCreateInstance(): void
+    {
+        /** @psalm-suppress PossiblyInvalidArgument */
+        $connectedServiceProvider = new ConnectedServiceProvider(
+            $this->serviceProviderStub,
+            $this->numberOfAuthentications,
+            $this->dateTime,
+            $this->dateTime,
+            $this->userStub
+        );
+
+        $this->assertSame($this->serviceProviderStub, $connectedServiceProvider->getServiceProvider());
+        $this->assertSame($this->numberOfAuthentications, $connectedServiceProvider->getNumberOfAuthentications());
+        $this->assertSame($this->dateTime, $connectedServiceProvider->getFirstAuthenticationAt());
+        $this->assertSame($this->dateTime, $connectedServiceProvider->getLastAuthenticationAt());
+        $this->assertSame($this->userStub, $connectedServiceProvider->getUser());
+    }
+}
diff --git a/tests/src/Entities/IdentityProviderTest.php b/tests/src/Entities/IdentityProviderTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..401c926b2162f5901a991687dc4b2ba7094258d5
--- /dev/null
+++ b/tests/src/Entities/IdentityProviderTest.php
@@ -0,0 +1,34 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Test\Module\accounting\Entities;
+
+use SimpleSAML\Module\accounting\Entities\Bases\AbstractProvider;
+use SimpleSAML\Module\accounting\Entities\IdentityProvider;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers \SimpleSAML\Module\accounting\Entities\IdentityProvider
+ * @uses \SimpleSAML\Module\accounting\Entities\Bases\AbstractProvider
+ */
+class IdentityProviderTest extends TestCase
+{
+    /**
+     * @var string[]
+     */
+    protected array $metadata;
+
+    public function setUp(): void
+    {
+        $this->metadata = [
+            AbstractProvider::METADATA_KEY_ENTITY_ID => 'http//example.org/idp'
+        ];
+    }
+
+    public function testCanCreateInstance(): void
+    {
+        $identityProvider = new IdentityProvider($this->metadata);
+        $this->assertSame($this->metadata, $identityProvider->getMetadata());
+    }
+}
diff --git a/tests/src/Entities/ServiceProviderTest.php b/tests/src/Entities/ServiceProviderTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..3c959231ab46acd593973adc08f81297e8aa2011
--- /dev/null
+++ b/tests/src/Entities/ServiceProviderTest.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace SimpleSAML\Test\Module\accounting\Entities;
+
+use SimpleSAML\Module\accounting\Entities\ServiceProvider;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers \SimpleSAML\Module\accounting\Entities\ServiceProvider
+ * @uses \SimpleSAML\Module\accounting\Entities\Bases\AbstractProvider
+ */
+class ServiceProviderTest extends TestCase
+{
+    /**
+     * @var string[]
+     */
+    protected array $metadata;
+
+    public function setUp(): void
+    {
+        $this->metadata = [
+            'entityid' => 'http//example.org'
+        ];
+    }
+
+    public function testCanCreateInstance(): void
+    {
+        $serviceProvider = new ServiceProvider($this->metadata);
+        $this->assertSame($this->metadata, $serviceProvider->getMetadata());
+    }
+}
diff --git a/tests/src/Entities/UserTest.php b/tests/src/Entities/UserTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..21199404ffe97212b410c4138b828daa4f2645fc
--- /dev/null
+++ b/tests/src/Entities/UserTest.php
@@ -0,0 +1,32 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Test\Module\accounting\Entities;
+
+use SimpleSAML\Module\accounting\Entities\User;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers \SimpleSAML\Module\accounting\Entities\User
+ */
+class UserTest extends TestCase
+{
+    /**
+     * @var string[][]
+     */
+    protected array $attributes;
+
+    protected function setUp(): void
+    {
+        $this->attributes = [
+            'uid' => ['test'],
+        ];
+    }
+
+    public function testCanCreateInstance(): void
+    {
+        $user = new User($this->attributes);
+        $this->assertSame($this->attributes, $user->getAttributes());
+    }
+}
diff --git a/tests/src/Helpers/ArrayHelperTest.php b/tests/src/Helpers/ArrayHelperTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..2e2d77912233193e23d6d38d327d948fb93901a1
--- /dev/null
+++ b/tests/src/Helpers/ArrayHelperTest.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace SimpleSAML\Test\Module\accounting\Helpers;
+
+use SimpleSAML\Module\accounting\Helpers\ArrayHelper;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers \SimpleSAML\Module\accounting\Helpers\ArrayHelper
+ */
+class ArrayHelperTest extends TestCase
+{
+    public function testCanRecursivelySortByKey(): void
+    {
+        $unsorted = [
+            'b' => [1 => 1, 0 => 0],
+            'a' => [1 => 1, 0 => 0],
+        ];
+
+        $sorted = [
+            'a' => [0 => 0, 1 => 1],
+            'b' => [0 => 0, 1 => 1],
+        ];
+
+        $this->assertNotSame($unsorted, $sorted);
+
+        ArrayHelper::recursivelySortByKey($unsorted);
+
+        $this->assertSame($unsorted, $sorted);
+    }
+}
diff --git a/tests/src/Helpers/AttributesHelperTest.php b/tests/src/Helpers/AttributesHelperTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..9d21c87a33ec8bd68ae96e513fcb8af641db0115
--- /dev/null
+++ b/tests/src/Helpers/AttributesHelperTest.php
@@ -0,0 +1,47 @@
+<?php
+
+namespace SimpleSAML\Test\Module\accounting\Helpers;
+
+use SimpleSAML\Module\accounting\Helpers\AttributesHelper;
+use PHPUnit\Framework\TestCase;
+use SimpleSAML\Module\accounting\ModuleConfiguration;
+
+/**
+ * @covers \SimpleSAML\Module\accounting\Helpers\AttributesHelper
+ * @uses \SimpleSAML\Module\accounting\ModuleConfiguration
+ */
+class AttributesHelperTest extends TestCase
+{
+    /**
+     * @var string $sspBaseDir Simulated SSP base directory.
+     */
+    protected string $sspBaseDir;
+
+    /**
+     * @var string[]
+     */
+    protected array $mapFiles;
+
+    protected function setUp(): void
+    {
+        $this->sspBaseDir = (new ModuleConfiguration())->getModuleRootDirectory() . DIRECTORY_SEPARATOR . 'tests' .
+            DIRECTORY_SEPARATOR;
+
+        $this->mapFiles = ['test.php', 'test2.php'];
+    }
+
+    public function testCanLoadAttributeMaps(): void
+    {
+        $fullAttributeMap = AttributesHelper::getMergedAttributeMapForFiles($this->sspBaseDir, $this->mapFiles);
+
+        $this->assertArrayHasKey('mobile', $fullAttributeMap);
+        $this->assertArrayHasKey('phone', $fullAttributeMap);
+    }
+
+    public function testIgnoresNonExistentMaps(): void
+    {
+        $fullAttributeMap = AttributesHelper::getMergedAttributeMapForFiles($this->sspBaseDir, ['invalid.php']);
+
+        $this->assertEmpty($fullAttributeMap);
+    }
+}
diff --git a/tests/src/Helpers/HashHelperTest.php b/tests/src/Helpers/HashHelperTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..6c2bf4325f014ff34accc1ef2e20bcd004a54629
--- /dev/null
+++ b/tests/src/Helpers/HashHelperTest.php
@@ -0,0 +1,51 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Test\Module\accounting\Helpers;
+
+use SimpleSAML\Module\accounting\Helpers\HashHelper;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers \SimpleSAML\Module\accounting\Helpers\HashHelper
+ * @uses \SimpleSAML\Module\accounting\Helpers\ArrayHelper
+ */
+class HashHelperTest extends TestCase
+{
+    protected string $data;
+    protected string $dataSha256;
+    /**
+     * @var \int[][]
+     */
+    protected array $unsortedArrayData;
+    protected string $unsortedArraySha256;
+    /**
+     * @var \int[][]
+     */
+    protected array $sortedArrayData;
+    protected string $sortedArrayDataSha256;
+
+    protected function setUp(): void
+    {
+        $this->data = 'test';
+        $this->dataSha256 = '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08';
+        $this->unsortedArrayData = ['b' => [1 => 1, 0 => 0,], 'a' => [1 => 1, 0 => 0,],];
+        $this->unsortedArraySha256 = 'c467191ddddcaa5a6cc3bac28c1fd0557eb9390dbb079195a2ae70c49ce62da7';
+        $this->sortedArrayData = ['a' => [0 => 0, 1 => 1,], 'b' => [0 => 0, 1 => 1,],];
+        $this->sortedArrayDataSha256 = 'c467191ddddcaa5a6cc3bac28c1fd0557eb9390dbb079195a2ae70c49ce62da7';
+    }
+
+    public function testCanGetSha256ForString(): void
+    {
+        $this->assertSame($this->dataSha256, HashHelper::getSha256($this->data));
+    }
+
+    public function testCanGetSha256ForArray(): void
+    {
+        // Arrays are sorted before the hash is calculated, so the value must be the same.
+        $this->assertSame($this->unsortedArraySha256, $this->sortedArrayDataSha256);
+        $this->assertSame($this->unsortedArraySha256, HashHelper::getSha256ForArray($this->unsortedArrayData));
+        $this->assertSame($this->sortedArrayDataSha256, HashHelper::getSha256ForArray($this->sortedArrayData));
+    }
+}
diff --git a/tests/src/Helpers/InstanceBuilderUsingModuleConfigurationHelperTest.php b/tests/src/Helpers/InstanceBuilderUsingModuleConfigurationHelperTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..a1caa56d4ba6a56f6601956407ba388536ba14d3
--- /dev/null
+++ b/tests/src/Helpers/InstanceBuilderUsingModuleConfigurationHelperTest.php
@@ -0,0 +1,64 @@
+<?php
+
+namespace SimpleSAML\Test\Module\accounting\Helpers;
+
+use Psr\Log\LoggerInterface;
+use SimpleSAML\Module\accounting\Exceptions\Exception;
+use SimpleSAML\Module\accounting\Helpers\InstanceBuilderUsingModuleConfigurationHelper;
+use PHPUnit\Framework\TestCase;
+use SimpleSAML\Module\accounting\Interfaces\BuildableUsingModuleConfigurationInterface;
+use SimpleSAML\Module\accounting\ModuleConfiguration;
+
+/**
+ * @covers \SimpleSAML\Module\accounting\Helpers\InstanceBuilderUsingModuleConfigurationHelper
+ */
+class InstanceBuilderUsingModuleConfigurationHelperTest extends TestCase
+{
+    protected BuildableUsingModuleConfigurationInterface $stub;
+    /** @var class-string */
+    protected string $stubClass;
+    protected \PHPUnit\Framework\MockObject\Stub $moduleConfigurationstub;
+    protected \PHPUnit\Framework\MockObject\Stub $loggerStub;
+
+    protected function setUp(): void
+    {
+        $this->stub = new class () implements BuildableUsingModuleConfigurationInterface {
+            public static function build(
+                ModuleConfiguration $moduleConfiguration,
+                LoggerInterface $logger
+            ): BuildableUsingModuleConfigurationInterface {
+                return new self();
+            }
+        };
+
+        $this->stubClass = get_class($this->stub);
+
+        $this->moduleConfigurationstub = $this->createStub(ModuleConfiguration::class);
+        $this->loggerStub = $this->createStub(LoggerInterface::class);
+    }
+
+    public function testCanBuildClassInstance(): void
+    {
+        /** @psalm-suppress InvalidArgument */
+        $this->assertInstanceOf(
+            BuildableUsingModuleConfigurationInterface::class,
+            InstanceBuilderUsingModuleConfigurationHelper::build(
+                $this->stubClass,
+                $this->moduleConfigurationstub,
+                $this->loggerStub
+            )
+        );
+    }
+
+    public function testThrowsForInvalidClass(): void
+    {
+        $this->expectException(Exception::class);
+
+        /** @psalm-suppress InvalidArgument */
+        InstanceBuilderUsingModuleConfigurationHelper::build(
+            ModuleConfiguration::class, // Sample class which is not buildable.
+            $this->moduleConfigurationstub,
+            $this->loggerStub
+        );
+    }
+}
diff --git a/tests/src/Helpers/NetworkHelperTest.php b/tests/src/Helpers/NetworkHelperTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..d4c9abe03a73b2e5649275dc2142c684d5332fc4
--- /dev/null
+++ b/tests/src/Helpers/NetworkHelperTest.php
@@ -0,0 +1,48 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Test\Module\accounting\Helpers;
+
+use SimpleSAML\Module\accounting\Helpers\NetworkHelper;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers \SimpleSAML\Module\accounting\Helpers\NetworkHelper
+ */
+class NetworkHelperTest extends TestCase
+{
+    protected string $ipAddress;
+
+    protected function setUp(): void
+    {
+        $this->ipAddress = '123.123.123.123';
+    }
+
+    public function testCanGetIpFromParameter(): void
+    {
+        $this->assertSame($this->ipAddress, NetworkHelper::resolveClientIpAddress($this->ipAddress));
+    }
+
+    public function testReturnsNullForInvalidIp(): void
+    {
+        $this->assertNull(NetworkHelper::resolveClientIpAddress('invalid'));
+    }
+
+    public function testReturnsNullForNonExistentIp(): void
+    {
+        $this->assertNull(NetworkHelper::resolveClientIpAddress());
+    }
+
+    /**
+     * @backupGlobals enabled
+     */
+    public function testCanResolveIpAddress(): void
+    {
+        global $_SERVER;
+
+        $_SERVER['REMOTE_ADDR'] = $this->ipAddress;
+
+        $this->assertSame($this->ipAddress, NetworkHelper::resolveClientIpAddress());
+    }
+}
diff --git a/tests/src/ModuleConfigurationTest.php b/tests/src/ModuleConfigurationTest.php
index 9895cb8421003962067858c67c200f7bd152c66f..80821ec553e3044be13026a758575a92605559ae 100644
--- a/tests/src/ModuleConfigurationTest.php
+++ b/tests/src/ModuleConfigurationTest.php
@@ -1,5 +1,7 @@
 <?php
 
+declare(strict_types=1);
+
 namespace SimpleSAML\Test\Module\accounting;
 
 use PHPUnit\Framework\TestCase;
@@ -7,6 +9,7 @@ use SimpleSAML\Configuration;
 use SimpleSAML\Module\accounting\Exceptions\InvalidConfigurationException;
 use SimpleSAML\Module\accounting\ModuleConfiguration;
 use SimpleSAML\Module\accounting\Stores;
+use SimpleSAML\Module\accounting\Trackers;
 
 /**
  * @covers \SimpleSAML\Module\accounting\ModuleConfiguration
@@ -36,14 +39,80 @@ class ModuleConfigurationTest extends TestCase
 
     public function testCanGetValidOption(): void
     {
-        $this->assertIsString($this->moduleConfiguration->get(ModuleConfiguration::OPTION_USER_ID_ATTRIBUTE));
+        $this->assertIsString($this->moduleConfiguration->get(ModuleConfiguration::OPTION_USER_ID_ATTRIBUTE_NAME));
+    }
+
+    public function testCanGetUserIdAttributeName(): void
+    {
+        $this->assertIsString($this->moduleConfiguration->getUserIdAttributeName());
+    }
+
+    public function testCanGetDefaultAuthenticationSource(): void
+    {
+        $this->assertIsString($this->moduleConfiguration->getDefaultAuthenticationSource());
+    }
+
+    public function testCanGetJobsStoreClass(): void
+    {
+        $this->assertTrue(
+            is_subclass_of($this->moduleConfiguration->getJobsStoreClass(), Stores\Interfaces\JobsStoreInterface::class)
+        );
+    }
+
+    public function testThrowsForInvalidConfig(): void
+    {
+        $this->expectException(InvalidConfigurationException::class);
+
+        new ModuleConfiguration('invalid_module_accounting.php');
+    }
+
+    public function testThrowsForInvalidJobsStore(): void
+    {
+        $this->expectException(InvalidConfigurationException::class);
+
+        new ModuleConfiguration('invalid_async_module_accounting.php');
     }
 
     public function testProperConnectionKeyIsReturned(): void
     {
         $this->assertSame(
-            'doctrine_dbal_pdo_mysql',
-            $this->moduleConfiguration->getStoreConnection(Stores\Jobs\DoctrineDbal\JobsStore::class)
+            'doctrine_dbal_pdo_sqlite',
+            $this->moduleConfiguration->getClassConnectionKey(Stores\Jobs\DoctrineDbal\Store::class)
+        );
+    }
+
+    public function testCanGetSlaveConnectionKey(): void
+    {
+        $this->assertSame(
+            'doctrine_dbal_pdo_sqlite_slave',
+            $this->moduleConfiguration->getClassConnectionKey(
+                Trackers\Authentication\DoctrineDbal\Versioned\Tracker::class,
+                ModuleConfiguration\ConnectionType::SLAVE
+            )
+        );
+    }
+
+    public function testThrowsForNonStringAndNonArrayConnectionKey(): void
+    {
+        $this->expectException(InvalidConfigurationException::class);
+
+        new ModuleConfiguration('invalid_object_value_module_accounting.php');
+    }
+
+    public function testThrowsForNonMasterInArrayConnection(): void
+    {
+        $this->expectException(InvalidConfigurationException::class);
+
+        new ModuleConfiguration('invalid_array_value_module_accounting.php');
+    }
+
+    public function testThrowsForInvalidConnectiontype(): void
+    {
+        $this->expectException(InvalidConfigurationException::class);
+
+        $this->moduleConfiguration->getClassConnectionKey(
+            Stores\Jobs\DoctrineDbal\Store::class,
+            'invalid'
         );
     }
 
@@ -51,26 +120,26 @@ class ModuleConfigurationTest extends TestCase
     {
         $this->expectException(InvalidConfigurationException::class);
 
-        $this->moduleConfiguration->getStoreConnection('invalid');
+        $this->moduleConfiguration->getClassConnectionParameters('invalid');
     }
 
     public function testCanGetDefinedConnections(): void
     {
         $this->assertArrayHasKey(
-            'doctrine_dbal_pdo_mysql',
-            $this->moduleConfiguration->getAllStoreConnectionsAndParameters()
+            'doctrine_dbal_pdo_sqlite',
+            $this->moduleConfiguration->getConnectionsAndParameters()
         );
     }
 
-    public function testCanGetSettingsForSpecificConnection(): void
+    public function testCanGetParametersForSpecificConnection(): void
     {
-        $this->assertIsArray($this->moduleConfiguration->getStoreConnectionParameters('doctrine_dbal_pdo_mysql'));
+        $this->assertIsArray($this->moduleConfiguration->getConnectionParameters('doctrine_dbal_pdo_sqlite'));
     }
 
     public function testGettingSettingsForInvalidConnectionThrows(): void
     {
         $this->expectException(InvalidConfigurationException::class);
-        $this->moduleConfiguration->getStoreConnectionParameters('invalid');
+        $this->moduleConfiguration->getConnectionParameters('invalid');
     }
 
     public function testCanGetModuleSourceDirectory(): void
diff --git a/tests/src/Providers/Builders/AuthenticationDataProviderBuilderTest.php b/tests/src/Providers/Builders/AuthenticationDataProviderBuilderTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..99c95c87613f4a9e1147195e2e56fed5967f5709
--- /dev/null
+++ b/tests/src/Providers/Builders/AuthenticationDataProviderBuilderTest.php
@@ -0,0 +1,69 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Test\Module\accounting\Providers\Builders;
+
+use Psr\Log\LoggerInterface;
+use SimpleSAML\Module\accounting\Exceptions\Exception;
+use SimpleSAML\Module\accounting\ModuleConfiguration;
+use SimpleSAML\Module\accounting\Providers\Builders\AuthenticationDataProviderBuilder;
+use PHPUnit\Framework\TestCase;
+use SimpleSAML\Module\accounting\Trackers\Authentication\DoctrineDbal\Versioned\Tracker;
+use SimpleSAML\Test\Module\accounting\Constants\ConnectionParameters;
+
+/**
+ * @covers \SimpleSAML\Module\accounting\Providers\Builders\AuthenticationDataProviderBuilder
+ * @uses \SimpleSAML\Module\accounting\Helpers\InstanceBuilderUsingModuleConfigurationHelper
+ * @uses \SimpleSAML\Module\accounting\Stores\Builders\Bases\AbstractStoreBuilder
+ * @uses \SimpleSAML\Module\accounting\Stores\Bases\DoctrineDbal\AbstractStore
+ * @uses \SimpleSAML\Module\accounting\Stores\Builders\DataStoreBuilder
+ * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Migrator
+ * @uses \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Repository
+ * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection
+ * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Factory
+ * @uses \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store
+ * @uses \SimpleSAML\Module\accounting\Trackers\Authentication\DoctrineDbal\Versioned\Tracker
+ */
+class AuthenticationDataProviderBuilderTest extends TestCase
+{
+    protected \PHPUnit\Framework\MockObject\Stub $moduleConfigurationStub;
+
+    protected \PHPUnit\Framework\MockObject\Stub $loggerStub;
+
+    protected function setUp(): void
+    {
+        $this->moduleConfigurationStub = $this->createStub(ModuleConfiguration::class);
+        $connectionParams = ConnectionParameters::DBAL_SQLITE_MEMORY;
+        $this->moduleConfigurationStub->method('getConnectionParameters')
+            ->willReturn($connectionParams);
+
+        $this->loggerStub = $this->createStub(LoggerInterface::class);
+    }
+
+    public function testCanCreateInstance(): void
+    {
+        /** @psalm-suppress InvalidArgument */
+        $this->assertInstanceOf(
+            AuthenticationDataProviderBuilder::class,
+            new AuthenticationDataProviderBuilder($this->moduleConfigurationStub, $this->loggerStub)
+        );
+    }
+
+    public function testCanBuildDataProvider(): void
+    {
+        /** @psalm-suppress InvalidArgument */
+        $builder = new AuthenticationDataProviderBuilder($this->moduleConfigurationStub, $this->loggerStub);
+
+        $this->assertInstanceOf(Tracker::class, $builder->build(Tracker::class));
+    }
+
+    public function testThrowsForInvalidClass(): void
+    {
+        $this->expectException(Exception::class);
+
+        /** @psalm-suppress InvalidArgument */
+        (new AuthenticationDataProviderBuilder($this->moduleConfigurationStub, $this->loggerStub))
+            ->build('invalid');
+    }
+}
diff --git a/tests/src/Services/LoggerServiceTest.php b/tests/src/Services/LoggerServiceTest.php
index a1ffe30c1dffb0b8199303755c8fa0d5e2c18fe2..241dfeb4a106a5d05bb64ea84d048dc431dcd7e5 100644
--- a/tests/src/Services/LoggerServiceTest.php
+++ b/tests/src/Services/LoggerServiceTest.php
@@ -2,17 +2,17 @@
 
 namespace SimpleSAML\Test\Module\accounting\Services;
 
-use SimpleSAML\Module\accounting\Services\LoggerService;
+use SimpleSAML\Module\accounting\Services\Logger;
 use PHPUnit\Framework\TestCase;
 
 /**
- * @covers \SimpleSAML\Module\accounting\Services\LoggerService
+ * @covers \SimpleSAML\Module\accounting\Services\Logger
  */
 class LoggerServiceTest extends TestCase
 {
     public function testCanCallAllMethods(): void
     {
-        $loggerService = new LoggerService();
+        $loggerService = new Logger();
 
         $loggerService->stats('test');
         $loggerService->debug('test');
diff --git a/tests/src/Stores/Bases/DoctrineDbal/AbstractRawEntityTest.php b/tests/src/Stores/Bases/DoctrineDbal/AbstractRawEntityTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..9f8ae4cf8bc0acc63e322a7c73a7875d52932652
--- /dev/null
+++ b/tests/src/Stores/Bases/DoctrineDbal/AbstractRawEntityTest.php
@@ -0,0 +1,74 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Test\Module\accounting\Stores\Bases\DoctrineDbal;
+
+use Doctrine\DBAL\Platforms\AbstractPlatform;
+use SimpleSAML\Module\accounting\Exceptions\UnexpectedValueException;
+use SimpleSAML\Module\accounting\Stores\Bases\DoctrineDbal\AbstractRawEntity;
+use PHPUnit\Framework\TestCase;
+use SimpleSAML\Test\Module\accounting\Constants\DateTime;
+
+/**
+ * @covers \SimpleSAML\Module\accounting\Stores\Bases\DoctrineDbal\AbstractRawEntity
+ */
+class AbstractRawEntityTest extends TestCase
+{
+    /**
+     * @var AbstractPlatform|AbstractPlatform&\PHPUnit\Framework\MockObject\Stub|\PHPUnit\Framework\MockObject\Stub
+     */
+    protected $abstractPlatformStub;
+    /**
+     * @var string[]
+     */
+    protected array $rawRow;
+
+    protected function setUp(): void
+    {
+        $this->abstractPlatformStub = $this->createStub(AbstractPlatform::class);
+        $this->abstractPlatformStub->method('getDateTimeFormatString')
+            ->willReturn(DateTime::DEFAULT_FORMAT);
+        $this->rawRow = ['sample' => 'test'];
+    }
+
+    public function testCanCreateInstance(): void
+    {
+        /** @psalm-suppress InvalidArgument */
+        $rawEntityInstance = new class ($this->rawRow, $this->abstractPlatformStub) extends AbstractRawEntity {
+            protected function validate(
+                array $rawRow
+            ): void {
+            }
+        };
+        $this->assertInstanceOf(AbstractRawEntity::class, $rawEntityInstance);
+    }
+
+    public function testCanResolveDateTimeImmutable(): void
+    {
+        /** @psalm-suppress InvalidArgument */
+        $rawEntityInstance = new class ($this->rawRow, $this->abstractPlatformStub) extends AbstractRawEntity {
+            protected function validate(
+                array $rawRow
+            ): void {
+                $this->resolveDateTimeImmutable('2022-09-21 14:49:20');
+            }
+        };
+
+        $this->assertInstanceOf(AbstractRawEntity::class, $rawEntityInstance);
+    }
+
+    public function testThrowsForInvalidDateTime(): void
+    {
+        $this->expectException(UnexpectedValueException::class);
+
+        /** @psalm-suppress InvalidArgument */
+        new class ($this->rawRow, $this->abstractPlatformStub) extends AbstractRawEntity {
+            protected function validate(
+                array $rawRow
+            ): void {
+                $this->resolveDateTimeImmutable('invalid');
+            }
+        };
+    }
+}
diff --git a/tests/src/Stores/Builders/DataStoreBuilderTest.php b/tests/src/Stores/Builders/DataStoreBuilderTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..30b4214b86d8a0425948a2370580e1dd551d7fa1
--- /dev/null
+++ b/tests/src/Stores/Builders/DataStoreBuilderTest.php
@@ -0,0 +1,52 @@
+<?php
+
+namespace SimpleSAML\Test\Module\accounting\Stores\Builders;
+
+use Psr\Log\LoggerInterface;
+use SimpleSAML\Module\accounting\Exceptions\StoreException;
+use SimpleSAML\Module\accounting\ModuleConfiguration;
+use SimpleSAML\Module\accounting\Stores\Builders\DataStoreBuilder;
+use PHPUnit\Framework\TestCase;
+use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store;
+use SimpleSAML\Test\Module\accounting\Constants\ConnectionParameters;
+
+/**
+ * @covers \SimpleSAML\Module\accounting\Stores\Builders\DataStoreBuilder
+ * @uses \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store
+ * @uses \SimpleSAML\Module\accounting\Helpers\InstanceBuilderUsingModuleConfigurationHelper
+ * @uses \SimpleSAML\Module\accounting\Stores\Bases\DoctrineDbal\AbstractStore
+ * @uses \SimpleSAML\Module\accounting\Stores\Builders\Bases\AbstractStoreBuilder
+ * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection
+ * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Factory
+ * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Migrator
+ * @uses \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Repository
+ */
+class DataStoreBuilderTest extends TestCase
+{
+    protected \PHPUnit\Framework\MockObject\Stub $moduleConfigurationStub;
+    protected \PHPUnit\Framework\MockObject\Stub $loggerStub;
+    protected DataStoreBuilder $dataStoreBuilder;
+
+    protected function setUp(): void
+    {
+        $this->moduleConfigurationStub = $this->createStub(ModuleConfiguration::class);
+        $this->moduleConfigurationStub->method('getConnectionParameters')
+            ->willReturn(ConnectionParameters::DBAL_SQLITE_MEMORY);
+
+        $this->loggerStub = $this->createStub(LoggerInterface::class);
+
+        /** @psalm-suppress InvalidArgument */
+        $this->dataStoreBuilder = new DataStoreBuilder($this->moduleConfigurationStub, $this->loggerStub);
+    }
+
+    public function testCanBuildDataStore(): void
+    {
+        $this->assertInstanceOf(Store::class, $this->dataStoreBuilder->build(Store::class));
+    }
+
+    public function testThrowsForInvalidDataStoreClass(): void
+    {
+        $this->expectException(StoreException::class);
+        $this->dataStoreBuilder->build(ModuleConfiguration::class);
+    }
+}
diff --git a/tests/src/Stores/Builders/JobsStoreBuilderTest.php b/tests/src/Stores/Builders/JobsStoreBuilderTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..fb212835e96b6a2953d8514c4b1eb6a41edcf508
--- /dev/null
+++ b/tests/src/Stores/Builders/JobsStoreBuilderTest.php
@@ -0,0 +1,116 @@
+<?php
+
+namespace SimpleSAML\Test\Module\accounting\Stores\Builders;
+
+use PHPUnit\Framework\TestCase;
+use Psr\Log\LoggerInterface;
+use SimpleSAML\Module\accounting\Exceptions\StoreException;
+use SimpleSAML\Module\accounting\ModuleConfiguration;
+use SimpleSAML\Module\accounting\Stores\Builders\Bases\AbstractStoreBuilder;
+use SimpleSAML\Module\accounting\Stores\Builders\JobsStoreBuilder;
+use SimpleSAML\Module\accounting\Stores\Interfaces\StoreInterface;
+use SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store;
+use SimpleSAML\Test\Module\accounting\Constants\ConnectionParameters;
+
+/**
+ * @covers \SimpleSAML\Module\accounting\Stores\Builders\Bases\AbstractStoreBuilder
+ * @covers \SimpleSAML\Module\accounting\Stores\Builders\JobsStoreBuilder
+ * @uses   \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store
+ * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection
+ * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Factory
+ * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Migrator
+ * @uses \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\Repository
+ * @uses \SimpleSAML\Module\accounting\Stores\Bases\DoctrineDbal\AbstractStore
+ * @uses \SimpleSAML\Module\accounting\Helpers\InstanceBuilderUsingModuleConfigurationHelper
+ */
+class JobsStoreBuilderTest extends TestCase
+{
+    protected \PHPUnit\Framework\MockObject\Stub $moduleConfigurationStub;
+    protected \PHPUnit\Framework\MockObject\Stub $loggerStub;
+    protected JobsStoreBuilder $jobsStoreBuilder;
+
+    protected function setUp(): void
+    {
+        $this->moduleConfigurationStub = $this->createStub(ModuleConfiguration::class);
+        $this->moduleConfigurationStub->method('getConnectionParameters')
+            ->willReturn(ConnectionParameters::DBAL_SQLITE_MEMORY);
+        $this->moduleConfigurationStub->method('getJobsStoreClass')->willReturn(Store::class);
+
+        $this->loggerStub = $this->createStub(LoggerInterface::class);
+
+        /** @psalm-suppress InvalidArgument */
+        $this->jobsStoreBuilder = new JobsStoreBuilder($this->moduleConfigurationStub, $this->loggerStub);
+    }
+
+    public function testCanBuildJobsStore(): void
+    {
+        $this->assertInstanceOf(Store::class, $this->jobsStoreBuilder->build(Store::class));
+    }
+
+    public function testThrowsForInvalidStoreClass(): void
+    {
+        $moduleConfigurationStub = $this->createStub(ModuleConfiguration::class);
+        $moduleConfigurationStub->method('getConnectionParameters')
+            ->willReturn(ConnectionParameters::DBAL_SQLITE_MEMORY);
+
+        $invalidStore = new class {
+        };
+
+        /** @psalm-suppress InvalidArgument */
+        $storeBuilder = new class ($moduleConfigurationStub, $this->loggerStub) extends AbstractStoreBuilder {
+            public function build(string $class, string $connectionKey = null): StoreInterface
+            {
+                return $this->buildGeneric($class, [$connectionKey]);
+            }
+        };
+
+        $this->expectException(StoreException::class);
+
+        /** @psalm-suppress InvalidArgument */
+        $storeBuilder->build(get_class($invalidStore));
+    }
+
+    public function testThrowsForInvalidJobsStoreClass(): void
+    {
+        $moduleConfigurationStub = $this->createStub(ModuleConfiguration::class);
+        $moduleConfigurationStub->method('getConnectionParameters')
+            ->willReturn(ConnectionParameters::DBAL_SQLITE_MEMORY);
+
+        $this->expectException(StoreException::class);
+
+        /** @psalm-suppress InvalidArgument */
+        (new JobsStoreBuilder($moduleConfigurationStub, $this->loggerStub))->build('invalid');
+    }
+
+    public function testJobsStoreBuilderOnlyReturnsJobsStores(): void
+    {
+        $sampleStore = new class implements StoreInterface {
+            public function needsSetup(): bool
+            {
+                return false;
+            }
+
+            public function runSetup(): void
+            {
+            }
+
+            public static function build(
+                ModuleConfiguration $moduleConfiguration,
+                LoggerInterface $logger,
+                string $connectionKey = null
+            ): StoreInterface {
+                return new self();
+            }
+        };
+
+        $moduleConfigurationStub = $this->createStub(ModuleConfiguration::class);
+        $moduleConfigurationStub->method('getConnectionParameters')
+            ->willReturn(ConnectionParameters::DBAL_SQLITE_MEMORY);
+        $moduleConfigurationStub->method('getJobsStoreClass')->willReturn(get_class($sampleStore));
+
+        $this->expectException(StoreException::class);
+
+        /** @psalm-suppress InvalidArgument */
+        (new JobsStoreBuilder($moduleConfigurationStub, $this->loggerStub))->build(get_class($sampleStore));
+    }
+}
diff --git a/tests/src/Stores/Connections/Bases/AbstractMigratorTest.php b/tests/src/Stores/Connections/Bases/AbstractMigratorTest.php
index a9bda1bf9ed49eeb2630b7d6585f2fe7582a397c..1f4f94dd1b7f023d4f941b7fc096aafc8854dc33 100644
--- a/tests/src/Stores/Connections/Bases/AbstractMigratorTest.php
+++ b/tests/src/Stores/Connections/Bases/AbstractMigratorTest.php
@@ -1,17 +1,20 @@
 <?php
 
+declare(strict_types=1);
+
 namespace SimpleSAML\Test\Module\accounting\Stores\Connections\Bases;
 
 use Doctrine\DBAL\Schema\AbstractSchemaManager;
 use SimpleSAML\Module\accounting\Exceptions\StoreException\MigrationException;
 use SimpleSAML\Module\accounting\ModuleConfiguration;
-use SimpleSAML\Module\accounting\Services\LoggerService;
+use SimpleSAML\Module\accounting\Services\Logger;
 use SimpleSAML\Module\accounting\Stores\Connections\Bases\AbstractMigrator;
 use PHPUnit\Framework\TestCase;
 use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Bases\AbstractMigration;
 use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection;
 use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Migrator;
-use SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\JobsStore;
+use SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store;
+use SimpleSAML\Test\Module\accounting\Constants\ConnectionParameters;
 
 /**
  * @covers \SimpleSAML\Module\accounting\Stores\Connections\Bases\AbstractMigrator
@@ -19,9 +22,10 @@ use SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\JobsStore;
  * @uses \SimpleSAML\Module\accounting\ModuleConfiguration
  * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection
  * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Migrator
- * @uses \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\JobsStore\Migrations\Version20220601000000CreateJobsTable
- * @uses \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\JobsStore\Migrations\Version20220601000100CreateFailedJobsTable
+ * @uses \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\Migrations\Version20220601000000CreateJobTable
+ * @uses \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\Migrations\Version20220601000100CreateJobFailedTable
  * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Bases\AbstractMigration
+ * @uses \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\Migrations\Bases\AbstractCreateJobsTable
  */
 class AbstractMigratorTest extends TestCase
 {
@@ -38,21 +42,12 @@ class AbstractMigratorTest extends TestCase
     protected function setUp(): void
     {
         parent::setUp();
-        $this->connection = new Connection(['driver' => 'pdo_sqlite', 'memory' => true,]);
-        // TODO mivanci ostavi samo sqlite verziju
-//        $this->connection = new Connection([
-//            'dbname' => 'accounting',
-//           'user' => 'apps',
-//           'password' => 'apps',
-//           'host' => '127.0.0.1',
-//           'port' => '33306',
-//           'driver' => 'pdo_mysql',
-//                                               ]);
+        $this->connection = new Connection(ConnectionParameters::DBAL_SQLITE_MEMORY);
 
         $this->schemaManager = $this->connection->dbal()->createSchemaManager();
         $this->tableName = $this->connection->preparePrefixedTableName(Migrator::TABLE_NAME);
 
-        $this->loggerServiceMock = $this->createMock(LoggerService::class);
+        $this->loggerServiceMock = $this->createMock(Logger::class);
 
         // Configuration directory is set by phpunit using php ENV setting feature (check phpunit.xml).
         $this->moduleConfiguration = new ModuleConfiguration('module_accounting.php');
@@ -60,7 +55,7 @@ class AbstractMigratorTest extends TestCase
 
     public function testCanGatherMigrationClassesFromDirectory(): void
     {
-        /** @psalm-suppress InvalidArgument Using mock instead of LoggerService instance */
+        /** @psalm-suppress InvalidArgument Using mock instead of Logger instance */
         $migrator = new Migrator($this->connection, $this->loggerServiceMock);
 
         $directory = $this->getSampleMigrationsDirectory();
@@ -69,12 +64,12 @@ class AbstractMigratorTest extends TestCase
 
         $migrationClasses = $migrator->gatherMigrationClassesFromDirectory($directory, $namespace);
 
-        $this->assertTrue(in_array($namespace . '\Version20220601000000CreateJobsTable', $migrationClasses));
+        $this->assertTrue(in_array($namespace . '\Version20220601000000CreateJobTable', $migrationClasses));
     }
 
     public function testCanRunMigrationClasses(): void
     {
-        /** @psalm-suppress InvalidArgument Using mock instead of LoggerService instance */
+        /** @psalm-suppress InvalidArgument Using mock instead of Logger instance */
         $migrator = new Migrator($this->connection, $this->loggerServiceMock);
 
         $migrator->runSetup();
@@ -85,7 +80,7 @@ class AbstractMigratorTest extends TestCase
 
         $migrationClasses = $migrator->gatherMigrationClassesFromDirectory($directory, $namespace);
 
-        $jobsTableName = $this->connection->preparePrefixedTableName(JobsStore::TABLE_NAME_JOBS);
+        $jobsTableName = $this->connection->preparePrefixedTableName(Store\TableConstants::TABLE_NAME_JOB);
 
         $this->assertFalse($this->schemaManager->tablesExist($jobsTableName));
 
@@ -96,7 +91,7 @@ class AbstractMigratorTest extends TestCase
 
     public function testCanGatherOnlyMigrationClasses(): void
     {
-        /** @psalm-suppress InvalidArgument Using mock instead of LoggerService instance */
+        /** @psalm-suppress InvalidArgument Using mock instead of Logger instance */
         $migrator = new Migrator($this->connection, $this->loggerServiceMock);
 
         $directory = __DIR__;
@@ -119,7 +114,7 @@ class AbstractMigratorTest extends TestCase
             }
         };
 
-        /** @psalm-suppress InvalidArgument Using mock instead of LoggerService instance */
+        /** @psalm-suppress InvalidArgument Using mock instead of Logger instance */
         $migrator = new Migrator($this->connection, $this->loggerServiceMock);
 
         $this->expectException(MigrationException::class);
@@ -129,7 +124,7 @@ class AbstractMigratorTest extends TestCase
 
     public function testCanGetNonImplementedMigrationClasses(): void
     {
-        /** @psalm-suppress InvalidArgument Using mock instead of LoggerService instance */
+        /** @psalm-suppress InvalidArgument Using mock instead of Logger instance */
         $migrator = new Migrator($this->connection, $this->loggerServiceMock);
 
         $migrator->runSetup();
@@ -140,14 +135,14 @@ class AbstractMigratorTest extends TestCase
         );
 
         $this->assertTrue(in_array(
-            JobsStore\Migrations\Version20220601000000CreateJobsTable::class,
+            Store\Migrations\Version20220601000000CreateJobTable::class,
             $nonImplementedMigrationClasses
         ));
     }
 
     public function testCanFindOutIfNonImplementedMigrationClassesExist(): void
     {
-        /** @psalm-suppress InvalidArgument Using mock instead of LoggerService instance */
+        /** @psalm-suppress InvalidArgument Using mock instead of Logger instance */
         $migrator = new Migrator($this->connection, $this->loggerServiceMock);
 
         $migrator->runSetup();
@@ -160,7 +155,7 @@ class AbstractMigratorTest extends TestCase
 
     public function testCanRunNonImplementedMigrationClasses(): void
     {
-        /** @psalm-suppress InvalidArgument Using mock instead of LoggerService instance */
+        /** @psalm-suppress InvalidArgument Using mock instead of Logger instance */
         $migrator = new Migrator($this->connection, $this->loggerServiceMock);
 
         $migrator->runSetup();
@@ -179,11 +174,11 @@ class AbstractMigratorTest extends TestCase
     {
         return $this->moduleConfiguration->getModuleSourceDirectory() . DIRECTORY_SEPARATOR .
             'Stores' . DIRECTORY_SEPARATOR . 'Jobs' . DIRECTORY_SEPARATOR . 'DoctrineDbal' . DIRECTORY_SEPARATOR .
-            'JobsStore' . DIRECTORY_SEPARATOR . AbstractMigrator::DEFAULT_MIGRATIONS_DIRECTORY_NAME;
+            'Store' . DIRECTORY_SEPARATOR . AbstractMigrator::DEFAULT_MIGRATIONS_DIRECTORY_NAME;
     }
 
     protected function getSampleNameSpace(): string
     {
-        return JobsStore::class . '\\' . AbstractMigrator::DEFAULT_MIGRATIONS_DIRECTORY_NAME;
+        return Store::class . '\\' . AbstractMigrator::DEFAULT_MIGRATIONS_DIRECTORY_NAME;
     }
 }
diff --git a/tests/src/Stores/Connections/DoctrineDbal/Bases/AbstractMigrationTest.php b/tests/src/Stores/Connections/DoctrineDbal/Bases/AbstractMigrationTest.php
index 954233cdd3778966802b1bb235dc86299a634e82..68db88bdeed0a9453949a55c4ae639363b2e008f 100644
--- a/tests/src/Stores/Connections/DoctrineDbal/Bases/AbstractMigrationTest.php
+++ b/tests/src/Stores/Connections/DoctrineDbal/Bases/AbstractMigrationTest.php
@@ -6,11 +6,12 @@ use SimpleSAML\Module\accounting\Exceptions\StoreException;
 use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Bases\AbstractMigration;
 use PHPUnit\Framework\TestCase;
 use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection;
-use SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\JobsStore\Migrations\Version20220601000000CreateJobsTable;
+use SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\Migrations\Version20220601000000CreateJobTable;
+use SimpleSAML\Test\Module\accounting\Constants\ConnectionParameters;
 
 /**
  * @covers \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Bases\AbstractMigration
- * @uses \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\JobsStore\Migrations\Version20220601000000CreateJobsTable
+ * @uses \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\Migrations\Version20220601000000CreateJobTable
  * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection
  */
 class AbstractMigrationTest extends TestCase
@@ -20,14 +21,14 @@ class AbstractMigrationTest extends TestCase
     protected function setUp(): void
     {
         parent::setUp();
-        $this->connection = new Connection(['driver' => 'pdo_sqlite', 'memory' => true,]);
+        $this->connection = new Connection(ConnectionParameters::DBAL_SQLITE_MEMORY);
     }
 
     public function testCanInstantiateMigrationClass(): void
     {
         $this->assertInstanceOf(
             AbstractMigration::class,
-            new Version20220601000000CreateJobsTable($this->connection)
+            new Version20220601000000CreateJobTable($this->connection)
         );
     }
 
@@ -41,7 +42,7 @@ class AbstractMigrationTest extends TestCase
 
         $this->expectException(StoreException::class);
 
-        (new Version20220601000000CreateJobsTable($connectionStub));
+        (new Version20220601000000CreateJobTable($connectionStub));
     }
 
     public function testCanThrowGenericMigrationExceptionOnRun(): void
@@ -49,7 +50,7 @@ class AbstractMigrationTest extends TestCase
         $migration = new class ($this->connection) extends AbstractMigration {
             public function run(): void
             {
-                $this->throwGenericMigrationException('test', new \Exception('test'));
+                throw $this->prepareGenericMigrationException('test', new \Exception('test'));
             }
 
             public function revert(): void
@@ -61,4 +62,54 @@ class AbstractMigrationTest extends TestCase
 
         $migration->run();
     }
+
+    public function testCanUseTableNamePrefix(): void
+    {
+        $connectionStub = $this->createStub(Connection::class);
+        $connectionStub->method('dbal')->willReturn($this->connection->dbal());
+        $connectionStub->method('preparePrefixedTableName')->willReturn('prefix-connection');
+
+        $migration = new class ($connectionStub) extends AbstractMigration {
+            public function run(): void
+            {
+                throw new \Exception($this->preparePrefixedTableName('table-name'));
+            }
+            public function revert(): void
+            {
+            }
+            protected function getLocalTablePrefix(): string
+            {
+                return 'prefix-local';
+            }
+        };
+
+        try {
+            $migration->run();
+        } catch (\Exception $exception) {
+            $this->assertStringContainsString('prefix-connection', $exception->getMessage());
+        }
+    }
+
+    public function testCanUseLocalTableNamePrefix(): void
+    {
+        $connectionStub = $this->createStub(Connection::class);
+        $connectionStub->method('dbal')->willReturn($this->connection->dbal());
+        $connectionStub->method('preparePrefixedTableName')->willReturn('prefix-connection');
+
+        $migration = new class ($connectionStub) extends AbstractMigration {
+            public function run(): void
+            {
+                throw new \Exception($this->getLocalTablePrefix());
+            }
+            public function revert(): void
+            {
+            }
+        };
+
+        try {
+            $migration->run();
+        } catch (\Exception $exception) {
+            $this->assertEmpty($exception->getMessage());
+        }
+    }
 }
diff --git a/tests/src/Stores/Connections/DoctrineDbal/FactoryTest.php b/tests/src/Stores/Connections/DoctrineDbal/FactoryTest.php
index 3790bdce74d4b819f4ecc41df718323dc4416167..2647f6962160cf7eb0a4399235e7adbba8fea699 100644
--- a/tests/src/Stores/Connections/DoctrineDbal/FactoryTest.php
+++ b/tests/src/Stores/Connections/DoctrineDbal/FactoryTest.php
@@ -3,11 +3,12 @@
 namespace SimpleSAML\Test\Module\accounting\Stores\Connections\DoctrineDbal;
 
 use SimpleSAML\Module\accounting\ModuleConfiguration;
-use SimpleSAML\Module\accounting\Services\LoggerService;
+use SimpleSAML\Module\accounting\Services\Logger;
 use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection;
 use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Factory;
 use PHPUnit\Framework\TestCase;
 use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Migrator;
+use SimpleSAML\Test\Module\accounting\Constants\ConnectionParameters;
 
 /**
  * @covers \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Factory
@@ -28,7 +29,7 @@ class FactoryTest extends TestCase
     {
         // Configuration directory is set by phpunit using php ENV setting feature (check phpunit.xml).
         $this->moduleConfiguration = new ModuleConfiguration('module_accounting.php');
-        $this->loggerServiceMock = $this->createMock(LoggerService::class);
+        $this->loggerServiceMock = $this->createMock(Logger::class);
     }
 
     public function testCanBuildConnection(): void
@@ -36,7 +37,7 @@ class FactoryTest extends TestCase
         /** @psalm-suppress InvalidArgument */
         $factory = new Factory($this->moduleConfiguration, $this->loggerServiceMock);
 
-        $this->assertInstanceOf(Connection::class, $factory->buildConnection('doctrine_dbal_pdo_mysql'));
+        $this->assertInstanceOf(Connection::class, $factory->buildConnection('doctrine_dbal_pdo_sqlite'));
     }
 
     public function testCanBuildMigrator(): void
@@ -44,7 +45,7 @@ class FactoryTest extends TestCase
         /** @psalm-suppress InvalidArgument */
         $factory = new Factory($this->moduleConfiguration, $this->loggerServiceMock);
 
-        $connection = new Connection(['driver' => 'pdo_sqlite', 'memory' => true,]);
+        $connection = new Connection(ConnectionParameters::DBAL_SQLITE_MEMORY);
 
         $this->assertInstanceOf(Migrator::class, $factory->buildMigrator($connection));
     }
diff --git a/tests/src/Stores/Connections/DoctrineDbal/MigratorTest.php b/tests/src/Stores/Connections/DoctrineDbal/MigratorTest.php
index bfa5093c4f3af1461a29695ff47b467e54dcfdf8..008d3f139288ea393b8d86b5f1e192bc831dcf73 100644
--- a/tests/src/Stores/Connections/DoctrineDbal/MigratorTest.php
+++ b/tests/src/Stores/Connections/DoctrineDbal/MigratorTest.php
@@ -8,12 +8,13 @@ use Doctrine\DBAL\Schema\AbstractSchemaManager;
 use SimpleSAML\Module\accounting\Exceptions\InvalidValueException;
 use SimpleSAML\Module\accounting\Exceptions\StoreException;
 use SimpleSAML\Module\accounting\ModuleConfiguration;
-use SimpleSAML\Module\accounting\Services\LoggerService;
+use SimpleSAML\Module\accounting\Services\Logger;
 use SimpleSAML\Module\accounting\Stores\Connections\Bases\AbstractMigrator;
 use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Migrator;
 use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection;
 use SimpleSAML\Module\accounting\Stores\Interfaces\MigrationInterface;
-use SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\JobsStore;
+use SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store;
+use SimpleSAML\Test\Module\accounting\Constants\ConnectionParameters;
 
 use function PHPUnit\Framework\assertFalse;
 
@@ -22,10 +23,11 @@ use function PHPUnit\Framework\assertFalse;
  * @covers \SimpleSAML\Module\accounting\Stores\Connections\Bases\AbstractMigrator
  * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection
  * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Bases\AbstractMigration
- * @uses \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\JobsStore\Migrations\Version20220601000000CreateJobsTable
- * @uses \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\JobsStore\Migrations\Version20220601000100CreateFailedJobsTable
+ * @uses \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\Migrations\Version20220601000000CreateJobTable
+ * @uses \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\Migrations\Version20220601000100CreateJobFailedTable
  * @uses \SimpleSAML\Module\accounting\ModuleConfiguration
  * @uses \SimpleSAML\Module\accounting\Helpers\FilesystemHelper
+ * @uses \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\Migrations\Bases\AbstractCreateJobsTable
  */
 class MigratorTest extends TestCase
 {
@@ -42,21 +44,12 @@ class MigratorTest extends TestCase
     protected function setUp(): void
     {
         parent::setUp();
-        $this->connection = new Connection(['driver' => 'pdo_sqlite', 'memory' => true,]);
-        // TODO mivanci ostavi samo sqlite verziju
-//        $this->connection = new Connection([
-//            'dbname' => 'accounting',
-//           'user' => 'apps',
-//           'password' => 'apps',
-//           'host' => '127.0.0.1',
-//           'port' => '33306',
-//           'driver' => 'pdo_mysql',
-//                                               ]);
+        $this->connection = new Connection(ConnectionParameters::DBAL_SQLITE_MEMORY);
 
         $this->schemaManager = $this->connection->dbal()->createSchemaManager();
         $this->tableName = $this->connection->preparePrefixedTableName(Migrator::TABLE_NAME);
 
-        $this->loggerServiceMock = $this->createMock(LoggerService::class);
+        $this->loggerServiceMock = $this->createMock(Logger::class);
 
         // Configuration directory is set by phpunit using php ENV setting feature (check phpunit.xml).
         $this->moduleConfiguration = new ModuleConfiguration('module_accounting.php');
@@ -66,7 +59,7 @@ class MigratorTest extends TestCase
     {
         $this->assertFalse($this->schemaManager->tablesExist([$this->tableName]));
 
-        /** @psalm-suppress InvalidArgument Using mock instead of LoggerService instance */
+        /** @psalm-suppress InvalidArgument Using mock instead of Logger instance */
         $migrator = new Migrator($this->connection, $this->loggerServiceMock);
 
         $this->assertTrue($migrator->needsSetup());
@@ -84,7 +77,7 @@ class MigratorTest extends TestCase
             ->method('warning')
             ->with($this->stringContains('setup is not needed'));
 
-        /** @psalm-suppress InvalidArgument Using mock instead of LoggerService instance */
+        /** @psalm-suppress InvalidArgument Using mock instead of Logger instance */
         $migrator = new Migrator($this->connection, $this->loggerServiceMock);
 
         $this->assertTrue($migrator->needsSetup());
@@ -100,10 +93,10 @@ class MigratorTest extends TestCase
 
         $migrator->runSetup();
 
-        $tableNameJobs = $this->connection->preparePrefixedTableName(JobsStore::TABLE_NAME_JOBS);
+        $tableNameJobs = $this->connection->preparePrefixedTableName(Store\TableConstants::TABLE_NAME_JOB);
         $this > assertFalse($this->schemaManager->tablesExist($tableNameJobs));
 
-        $migrator->runMigrationClasses([JobsStore\Migrations\Version20220601000000CreateJobsTable::class]);
+        $migrator->runMigrationClasses([Store\Migrations\Version20220601000000CreateJobTable::class]);
 
         $this->assertTrue($this->schemaManager->tablesExist($tableNameJobs));
     }
@@ -212,7 +205,7 @@ class MigratorTest extends TestCase
 
         $this->expectException(StoreException::class);
 
-        $migrator->runMigrationClasses([JobsStore\Migrations\Version20220601000000CreateJobsTable::class]);
+        $migrator->runMigrationClasses([Store\Migrations\Version20220601000000CreateJobTable::class]);
     }
 
     public function testThrowsStoreExceptionOnGetImplementedMigrationClasses(): void
@@ -234,11 +227,11 @@ class MigratorTest extends TestCase
     {
         return $this->moduleConfiguration->getModuleSourceDirectory() . DIRECTORY_SEPARATOR .
             'Stores' . DIRECTORY_SEPARATOR . 'Jobs' . DIRECTORY_SEPARATOR . 'DoctrineDbal' . DIRECTORY_SEPARATOR .
-            'JobsStore' . DIRECTORY_SEPARATOR . AbstractMigrator::DEFAULT_MIGRATIONS_DIRECTORY_NAME;
+            'Store' . DIRECTORY_SEPARATOR . AbstractMigrator::DEFAULT_MIGRATIONS_DIRECTORY_NAME;
     }
 
     protected function getSampleNameSpace(): string
     {
-        return JobsStore::class . '\\' . AbstractMigrator::DEFAULT_MIGRATIONS_DIRECTORY_NAME;
+        return Store::class . '\\' . AbstractMigrator::DEFAULT_MIGRATIONS_DIRECTORY_NAME;
     }
 }
diff --git a/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/HashDecoratedStateTest.php b/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/HashDecoratedStateTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..0094b57489467a56c49a3195bd1b3b5833364fd4
--- /dev/null
+++ b/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/HashDecoratedStateTest.php
@@ -0,0 +1,68 @@
+<?php
+
+namespace SimpleSAML\Test\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store;
+
+use SimpleSAML\Module\accounting\Entities\Authentication\State;
+use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\HashDecoratedState;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\HashDecoratedState
+ * @uses \SimpleSAML\Module\accounting\Helpers\HashHelper
+ * @uses \SimpleSAML\Module\accounting\Helpers\ArrayHelper
+ */
+class HashDecoratedStateTest extends TestCase
+{
+    /**
+     * @var \PHPUnit\Framework\MockObject\Stub|State|State&\PHPUnit\Framework\MockObject\Stub
+     */
+    protected $stateStub;
+    protected string $identityProviderEntityId;
+    /**
+     * @var string[]
+     */
+    protected array $identityProviderMetadata;
+    protected string $serviceProviderEntityId;
+    /**
+     * @var string[]
+     */
+    protected array $serviceProviderMetadata;
+    /**
+     * @var string[]
+     */
+    protected array $attributes;
+
+    protected function setUp(): void
+    {
+        $this->stateStub = $this->createStub(State::class);
+        $this->identityProviderEntityId = 'idpEntityId';
+        $this->stateStub->method('getIdentityProviderEntityId')->willReturn($this->identityProviderEntityId);
+        $this->identityProviderMetadata = ['idp' => 'metadata'];
+        $this->stateStub->method('getIdentityProviderMetadata')->willReturn($this->identityProviderMetadata);
+        $this->serviceProviderEntityId = 'spEntityId';
+        $this->stateStub->method('getServiceProviderEntityId')->willReturn($this->serviceProviderEntityId);
+        $this->serviceProviderMetadata = ['sp' => 'metadata'];
+        $this->stateStub->method('getServiceProviderMetadata')->willReturn($this->serviceProviderMetadata);
+        $this->attributes = ['sample' => 'attribute'];
+        $this->stateStub->method('getAttributes')->willReturn($this->attributes);
+    }
+
+    public function testCanCreateInstance(): void
+    {
+        /** @psalm-suppress PossiblyInvalidArgument */
+        $this->assertInstanceOf(HashDecoratedState::class, new HashDecoratedState($this->stateStub));
+    }
+
+    public function testCanGetHashedProperties(): void
+    {
+        /** @psalm-suppress PossiblyInvalidArgument */
+        $hashDecoratedState = new HashDecoratedState($this->stateStub);
+
+        $this->assertSame($this->stateStub, $hashDecoratedState->getState());
+        $this->assertIsString($hashDecoratedState->getIdentityProviderEntityIdHashSha256());
+        $this->assertIsString($hashDecoratedState->getServiceProviderEntityIdHashSha256());
+        $this->assertIsString($hashDecoratedState->getIdentityProviderMetadataArrayHashSha256());
+        $this->assertIsString($hashDecoratedState->getServiceProviderMetadataArrayHashSha256());
+        $this->assertIsString($hashDecoratedState->getAttributesArrayHashSha256());
+    }
+}
diff --git a/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000000CreateIdpTableTest.php b/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000000CreateIdpTableTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..2b99a6106c635b609e386f119f3f3ee5b733d47f
--- /dev/null
+++ b/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000000CreateIdpTableTest.php
@@ -0,0 +1,84 @@
+<?php
+
+namespace SimpleSAML\Test\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations;
+
+use Doctrine\DBAL\Schema\AbstractSchemaManager;
+use SimpleSAML\Module\accounting\Exceptions\StoreException\MigrationException;
+use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection;
+use PHPUnit\Framework\TestCase;
+use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations;
+use SimpleSAML\Test\Module\accounting\Constants\ConnectionParameters;
+
+/**
+ * @covers \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations\Version20220801000000CreateIdpTable
+ * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Bases\AbstractMigration
+ * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection
+ */
+class Version20220801000000CreateIdpTableTest extends TestCase
+{
+    protected Connection $connection;
+    protected \Doctrine\DBAL\Schema\AbstractSchemaManager $schemaManager;
+    protected string $tableName;
+    protected \PHPUnit\Framework\MockObject\Stub $connectionStub;
+    protected \PHPUnit\Framework\MockObject\Stub $dbalStub;
+    protected \PHPUnit\Framework\MockObject\Stub $schemaManagerStub;
+
+    protected function setUp(): void
+    {
+        $this->connection = new Connection(ConnectionParameters::DBAL_SQLITE_MEMORY);
+        $this->schemaManager = $this->connection->dbal()->createSchemaManager();
+        $this->tableName = 'vds_idp';
+
+        $this->connectionStub = $this->createStub(Connection::class);
+        $this->dbalStub = $this->createStub(\Doctrine\DBAL\Connection::class);
+        $this->schemaManagerStub = $this->createStub(AbstractSchemaManager::class);
+    }
+
+    public function testCanRunMigration(): void
+    {
+        $this->assertFalse($this->schemaManager->tablesExist($this->tableName));
+        $migration = new Migrations\Version20220801000000CreateIdpTable($this->connection);
+        $migration->run();
+        $this->assertTrue($this->schemaManager->tablesExist($this->tableName));
+        $migration->revert();
+        $this->assertFalse($this->schemaManager->tablesExist($this->tableName));
+    }
+
+    public function testRunThrowsMigrationException(): void
+    {
+        $this->connectionStub->method('preparePrefixedTableName')->willReturn($this->tableName);
+        $this->schemaManagerStub->method('createTable')
+            ->willThrowException(new \Doctrine\DBAL\Exception('test'));
+        $this->dbalStub->method('createSchemaManager')->willReturn($this->schemaManagerStub);
+        $this->connectionStub->method('dbal')->willReturn($this->dbalStub);
+
+        /** @psalm-suppress InvalidArgument */
+        $migration = new Migrations\Version20220801000000CreateIdpTable($this->connectionStub);
+        $this->expectException(MigrationException::class);
+        $migration->run();
+    }
+
+    public function testRevertThrowsMigrationException(): void
+    {
+        $this->schemaManagerStub->method('dropTable')
+            ->willThrowException(new \Doctrine\DBAL\Exception('test'));
+        $this->dbalStub->method('createSchemaManager')->willReturn($this->schemaManagerStub);
+        $this->connectionStub->method('dbal')->willReturn($this->dbalStub);
+
+        /** @psalm-suppress InvalidArgument */
+        $migration = new Migrations\Version20220801000000CreateIdpTable($this->connectionStub);
+        $this->expectException(MigrationException::class);
+        $migration->revert();
+    }
+
+    public function testRunThrowsOnIvalidTableNameIdp(): void
+    {
+        $this->connectionStub->method('preparePrefixedTableName')
+            ->willReturnOnConsecutiveCalls(''); // Invalid (empty) name for table
+
+        /** @psalm-suppress InvalidArgument */
+        $migration = new Migrations\Version20220801000000CreateIdpTable($this->connectionStub);
+        $this->expectException(MigrationException::class);
+        $migration->run();
+    }
+}
diff --git a/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000100CreateIdpVersionTableTest.php b/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000100CreateIdpVersionTableTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..525d7ef8226b7848679136175706122fcc92842f
--- /dev/null
+++ b/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000100CreateIdpVersionTableTest.php
@@ -0,0 +1,84 @@
+<?php
+
+namespace SimpleSAML\Test\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations;
+
+use Doctrine\DBAL\Schema\AbstractSchemaManager;
+use SimpleSAML\Module\accounting\Exceptions\StoreException\MigrationException;
+use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection;
+use PHPUnit\Framework\TestCase;
+use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations;
+use SimpleSAML\Test\Module\accounting\Constants\ConnectionParameters;
+
+/**
+ * @covers \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations\Version20220801000100CreateIdpVersionTable
+ * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Bases\AbstractMigration
+ * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection *
+ */
+class Version20220801000100CreateIdpVersionTableTest extends TestCase
+{
+    protected Connection $connection;
+    protected \Doctrine\DBAL\Schema\AbstractSchemaManager $schemaManager;
+    protected string $tableName;
+    protected \PHPUnit\Framework\MockObject\Stub $connectionStub;
+    protected \PHPUnit\Framework\MockObject\Stub $dbalStub;
+    protected \PHPUnit\Framework\MockObject\Stub $schemaManagerStub;
+
+    protected function setUp(): void
+    {
+        $this->connection = new Connection(ConnectionParameters::DBAL_SQLITE_MEMORY);
+        $this->schemaManager = $this->connection->dbal()->createSchemaManager();
+        $this->tableName = 'vds_idp_version';
+
+        $this->connectionStub = $this->createStub(Connection::class);
+        $this->dbalStub = $this->createStub(\Doctrine\DBAL\Connection::class);
+        $this->schemaManagerStub = $this->createStub(AbstractSchemaManager::class);
+    }
+
+    public function testCanRunMigration(): void
+    {
+        $this->assertFalse($this->schemaManager->tablesExist($this->tableName));
+        $migration = new Migrations\Version20220801000100CreateIdpVersionTable($this->connection);
+        $migration->run();
+        $this->assertTrue($this->schemaManager->tablesExist($this->tableName));
+        $migration->revert();
+        $this->assertFalse($this->schemaManager->tablesExist($this->tableName));
+    }
+
+    public function testRunThrowsMigrationException(): void
+    {
+        $this->connectionStub->method('preparePrefixedTableName')->willReturn($this->tableName);
+        $this->schemaManagerStub->method('createTable')
+            ->willThrowException(new \Doctrine\DBAL\Exception('test'));
+        $this->dbalStub->method('createSchemaManager')->willReturn($this->schemaManagerStub);
+        $this->connectionStub->method('dbal')->willReturn($this->dbalStub);
+
+        /** @psalm-suppress InvalidArgument */
+        $migration = new Migrations\Version20220801000100CreateIdpVersionTable($this->connectionStub);
+        $this->expectException(MigrationException::class);
+        $migration->run();
+    }
+
+    public function testRevertThrowsMigrationException(): void
+    {
+        $this->schemaManagerStub->method('dropTable')
+            ->willThrowException(new \Doctrine\DBAL\Exception('test'));
+        $this->dbalStub->method('createSchemaManager')->willReturn($this->schemaManagerStub);
+        $this->connectionStub->method('dbal')->willReturn($this->dbalStub);
+
+        /** @psalm-suppress InvalidArgument */
+        $migration = new Migrations\Version20220801000100CreateIdpVersionTable($this->connectionStub);
+        $this->expectException(MigrationException::class);
+        $migration->revert();
+    }
+
+    public function testRunThrowsOnIvalidTableNameIdp(): void
+    {
+        $this->connectionStub->method('preparePrefixedTableName')
+            ->willReturnOnConsecutiveCalls(''); // Invalid (empty) name for table
+
+        /** @psalm-suppress InvalidArgument */
+        $migration = new Migrations\Version20220801000100CreateIdpVersionTable($this->connectionStub);
+        $this->expectException(MigrationException::class);
+        $migration->run();
+    }
+}
diff --git a/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000200CreateSpTableTest.php b/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000200CreateSpTableTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..11d7874bb33138093b49fb518f595dd9e8350c06
--- /dev/null
+++ b/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000200CreateSpTableTest.php
@@ -0,0 +1,84 @@
+<?php
+
+namespace SimpleSAML\Test\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations;
+
+use Doctrine\DBAL\Schema\AbstractSchemaManager;
+use SimpleSAML\Module\accounting\Exceptions\StoreException\MigrationException;
+use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection;
+use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations;
+use PHPUnit\Framework\TestCase;
+use SimpleSAML\Test\Module\accounting\Constants\ConnectionParameters;
+
+/**
+ * @covers \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations\Version20220801000200CreateSpTable
+ * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Bases\AbstractMigration
+ * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection *
+ */
+class Version20220801000200CreateSpTableTest extends TestCase
+{
+    protected Connection $connection;
+    protected \Doctrine\DBAL\Schema\AbstractSchemaManager $schemaManager;
+    protected string $tableName;
+    protected \PHPUnit\Framework\MockObject\Stub $connectionStub;
+    protected \PHPUnit\Framework\MockObject\Stub $dbalStub;
+    protected \PHPUnit\Framework\MockObject\Stub $schemaManagerStub;
+
+    protected function setUp(): void
+    {
+        $this->connection = new Connection(ConnectionParameters::DBAL_SQLITE_MEMORY);
+        $this->schemaManager = $this->connection->dbal()->createSchemaManager();
+        $this->tableName = 'vds_sp';
+
+        $this->connectionStub = $this->createStub(Connection::class);
+        $this->dbalStub = $this->createStub(\Doctrine\DBAL\Connection::class);
+        $this->schemaManagerStub = $this->createStub(AbstractSchemaManager::class);
+    }
+
+    public function testCanRunMigration(): void
+    {
+        $this->assertFalse($this->schemaManager->tablesExist($this->tableName));
+        $migration = new Migrations\Version20220801000200CreateSpTable($this->connection);
+        $migration->run();
+        $this->assertTrue($this->schemaManager->tablesExist($this->tableName));
+        $migration->revert();
+        $this->assertFalse($this->schemaManager->tablesExist($this->tableName));
+    }
+
+    public function testRunThrowsMigrationException(): void
+    {
+        $this->connectionStub->method('preparePrefixedTableName')->willReturn($this->tableName);
+        $this->schemaManagerStub->method('createTable')
+            ->willThrowException(new \Doctrine\DBAL\Exception('test'));
+        $this->dbalStub->method('createSchemaManager')->willReturn($this->schemaManagerStub);
+        $this->connectionStub->method('dbal')->willReturn($this->dbalStub);
+
+        /** @psalm-suppress InvalidArgument */
+        $migration = new Migrations\Version20220801000200CreateSpTable($this->connectionStub);
+        $this->expectException(MigrationException::class);
+        $migration->run();
+    }
+
+    public function testRevertThrowsMigrationException(): void
+    {
+        $this->schemaManagerStub->method('dropTable')
+            ->willThrowException(new \Doctrine\DBAL\Exception('test'));
+        $this->dbalStub->method('createSchemaManager')->willReturn($this->schemaManagerStub);
+        $this->connectionStub->method('dbal')->willReturn($this->dbalStub);
+
+        /** @psalm-suppress InvalidArgument */
+        $migration = new Migrations\Version20220801000200CreateSpTable($this->connectionStub);
+        $this->expectException(MigrationException::class);
+        $migration->revert();
+    }
+
+    public function testRunThrowsOnIvalidTableNameIdp(): void
+    {
+        $this->connectionStub->method('preparePrefixedTableName')
+            ->willReturnOnConsecutiveCalls(''); // Invalid (empty) name for table
+
+        /** @psalm-suppress InvalidArgument */
+        $migration = new Migrations\Version20220801000200CreateSpTable($this->connectionStub);
+        $this->expectException(MigrationException::class);
+        $migration->run();
+    }
+}
diff --git a/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000300CreateSpVersionTableTest.php b/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000300CreateSpVersionTableTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..a839507c55379830055302d14a59ad639c16b344
--- /dev/null
+++ b/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000300CreateSpVersionTableTest.php
@@ -0,0 +1,84 @@
+<?php
+
+namespace SimpleSAML\Test\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations;
+
+use Doctrine\DBAL\Schema\AbstractSchemaManager;
+use SimpleSAML\Module\accounting\Exceptions\StoreException\MigrationException;
+use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection;
+use PHPUnit\Framework\TestCase;
+use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations;
+use SimpleSAML\Test\Module\accounting\Constants\ConnectionParameters;
+
+/**
+ * @covers \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations\Version20220801000300CreateSpVersionTable
+ * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Bases\AbstractMigration
+ * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection *
+ */
+class Version20220801000300CreateSpVersionTableTest extends TestCase
+{
+    protected Connection $connection;
+    protected \Doctrine\DBAL\Schema\AbstractSchemaManager $schemaManager;
+    protected string $tableName;
+    protected \PHPUnit\Framework\MockObject\Stub $connectionStub;
+    protected \PHPUnit\Framework\MockObject\Stub $dbalStub;
+    protected \PHPUnit\Framework\MockObject\Stub $schemaManagerStub;
+
+    protected function setUp(): void
+    {
+        $this->connection = new Connection(ConnectionParameters::DBAL_SQLITE_MEMORY);
+        $this->schemaManager = $this->connection->dbal()->createSchemaManager();
+        $this->tableName = 'vds_sp_version';
+
+        $this->connectionStub = $this->createStub(Connection::class);
+        $this->dbalStub = $this->createStub(\Doctrine\DBAL\Connection::class);
+        $this->schemaManagerStub = $this->createStub(AbstractSchemaManager::class);
+    }
+
+    public function testCanRunMigration(): void
+    {
+        $this->assertFalse($this->schemaManager->tablesExist($this->tableName));
+        $migration = new Migrations\Version20220801000300CreateSpVersionTable($this->connection);
+        $migration->run();
+        $this->assertTrue($this->schemaManager->tablesExist($this->tableName));
+        $migration->revert();
+        $this->assertFalse($this->schemaManager->tablesExist($this->tableName));
+    }
+
+    public function testRunThrowsMigrationException(): void
+    {
+        $this->connectionStub->method('preparePrefixedTableName')->willReturn($this->tableName);
+        $this->schemaManagerStub->method('createTable')
+            ->willThrowException(new \Doctrine\DBAL\Exception('test'));
+        $this->dbalStub->method('createSchemaManager')->willReturn($this->schemaManagerStub);
+        $this->connectionStub->method('dbal')->willReturn($this->dbalStub);
+
+        /** @psalm-suppress InvalidArgument */
+        $migration = new Migrations\Version20220801000300CreateSpVersionTable($this->connectionStub);
+        $this->expectException(MigrationException::class);
+        $migration->run();
+    }
+
+    public function testRevertThrowsMigrationException(): void
+    {
+        $this->schemaManagerStub->method('dropTable')
+            ->willThrowException(new \Doctrine\DBAL\Exception('test'));
+        $this->dbalStub->method('createSchemaManager')->willReturn($this->schemaManagerStub);
+        $this->connectionStub->method('dbal')->willReturn($this->dbalStub);
+
+        /** @psalm-suppress InvalidArgument */
+        $migration = new Migrations\Version20220801000300CreateSpVersionTable($this->connectionStub);
+        $this->expectException(MigrationException::class);
+        $migration->revert();
+    }
+
+    public function testRunThrowsOnIvalidTableNameIdp(): void
+    {
+        $this->connectionStub->method('preparePrefixedTableName')
+            ->willReturnOnConsecutiveCalls(''); // Invalid (empty) name for table
+
+        /** @psalm-suppress InvalidArgument */
+        $migration = new Migrations\Version20220801000300CreateSpVersionTable($this->connectionStub);
+        $this->expectException(MigrationException::class);
+        $migration->run();
+    }
+}
diff --git a/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000400CreateUserTableTest.php b/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000400CreateUserTableTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..eca699c62deb3ea3a84fa044b90ba0b58b59cbe0
--- /dev/null
+++ b/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000400CreateUserTableTest.php
@@ -0,0 +1,84 @@
+<?php
+
+namespace SimpleSAML\Test\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations;
+
+use Doctrine\DBAL\Schema\AbstractSchemaManager;
+use SimpleSAML\Module\accounting\Exceptions\StoreException\MigrationException;
+use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection;
+use PHPUnit\Framework\TestCase;
+use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations;
+use SimpleSAML\Test\Module\accounting\Constants\ConnectionParameters;
+
+/**
+ * @covers \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations\Version20220801000400CreateUserTable
+ * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Bases\AbstractMigration
+ * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection *
+ */
+class Version20220801000400CreateUserTableTest extends TestCase
+{
+    protected Connection $connection;
+    protected \Doctrine\DBAL\Schema\AbstractSchemaManager $schemaManager;
+    protected string $tableName;
+    protected \PHPUnit\Framework\MockObject\Stub $connectionStub;
+    protected \PHPUnit\Framework\MockObject\Stub $dbalStub;
+    protected \PHPUnit\Framework\MockObject\Stub $schemaManagerStub;
+
+    protected function setUp(): void
+    {
+        $this->connection = new Connection(ConnectionParameters::DBAL_SQLITE_MEMORY);
+        $this->schemaManager = $this->connection->dbal()->createSchemaManager();
+        $this->tableName = 'vds_user';
+
+        $this->connectionStub = $this->createStub(Connection::class);
+        $this->dbalStub = $this->createStub(\Doctrine\DBAL\Connection::class);
+        $this->schemaManagerStub = $this->createStub(AbstractSchemaManager::class);
+    }
+
+    public function testCanRunMigration(): void
+    {
+        $this->assertFalse($this->schemaManager->tablesExist($this->tableName));
+        $migration = new Migrations\Version20220801000400CreateUserTable($this->connection);
+        $migration->run();
+        $this->assertTrue($this->schemaManager->tablesExist($this->tableName));
+        $migration->revert();
+        $this->assertFalse($this->schemaManager->tablesExist($this->tableName));
+    }
+
+    public function testRunThrowsMigrationException(): void
+    {
+        $this->connectionStub->method('preparePrefixedTableName')->willReturn($this->tableName);
+        $this->schemaManagerStub->method('createTable')
+            ->willThrowException(new \Doctrine\DBAL\Exception('test'));
+        $this->dbalStub->method('createSchemaManager')->willReturn($this->schemaManagerStub);
+        $this->connectionStub->method('dbal')->willReturn($this->dbalStub);
+
+        /** @psalm-suppress InvalidArgument */
+        $migration = new Migrations\Version20220801000400CreateUserTable($this->connectionStub);
+        $this->expectException(MigrationException::class);
+        $migration->run();
+    }
+
+    public function testRevertThrowsMigrationException(): void
+    {
+        $this->schemaManagerStub->method('dropTable')
+            ->willThrowException(new \Doctrine\DBAL\Exception('test'));
+        $this->dbalStub->method('createSchemaManager')->willReturn($this->schemaManagerStub);
+        $this->connectionStub->method('dbal')->willReturn($this->dbalStub);
+
+        /** @psalm-suppress InvalidArgument */
+        $migration = new Migrations\Version20220801000400CreateUserTable($this->connectionStub);
+        $this->expectException(MigrationException::class);
+        $migration->revert();
+    }
+
+    public function testRunThrowsOnIvalidTableNameIdp(): void
+    {
+        $this->connectionStub->method('preparePrefixedTableName')
+            ->willReturnOnConsecutiveCalls(''); // Invalid (empty) name for table
+
+        /** @psalm-suppress InvalidArgument */
+        $migration = new Migrations\Version20220801000400CreateUserTable($this->connectionStub);
+        $this->expectException(MigrationException::class);
+        $migration->run();
+    }
+}
diff --git a/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000500CreateUserVersionTableTest.php b/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000500CreateUserVersionTableTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..42384894bdc8596fbcc51848d600c09ec02c112a
--- /dev/null
+++ b/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000500CreateUserVersionTableTest.php
@@ -0,0 +1,84 @@
+<?php
+
+namespace SimpleSAML\Test\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations;
+
+use Doctrine\DBAL\Schema\AbstractSchemaManager;
+use SimpleSAML\Module\accounting\Exceptions\StoreException\MigrationException;
+use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection;
+use PHPUnit\Framework\TestCase;
+use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations;
+use SimpleSAML\Test\Module\accounting\Constants\ConnectionParameters;
+
+/**
+ * @covers \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations\Version20220801000500CreateUserVersionTable
+ * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Bases\AbstractMigration
+ * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection *
+ */
+class Version20220801000500CreateUserVersionTableTest extends TestCase
+{
+    protected Connection $connection;
+    protected \Doctrine\DBAL\Schema\AbstractSchemaManager $schemaManager;
+    protected string $tableName;
+    protected \PHPUnit\Framework\MockObject\Stub $connectionStub;
+    protected \PHPUnit\Framework\MockObject\Stub $dbalStub;
+    protected \PHPUnit\Framework\MockObject\Stub $schemaManagerStub;
+
+    protected function setUp(): void
+    {
+        $this->connection = new Connection(ConnectionParameters::DBAL_SQLITE_MEMORY);
+        $this->schemaManager = $this->connection->dbal()->createSchemaManager();
+        $this->tableName = 'vds_user_version';
+
+        $this->connectionStub = $this->createStub(Connection::class);
+        $this->dbalStub = $this->createStub(\Doctrine\DBAL\Connection::class);
+        $this->schemaManagerStub = $this->createStub(AbstractSchemaManager::class);
+    }
+
+    public function testCanRunMigration(): void
+    {
+        $this->assertFalse($this->schemaManager->tablesExist($this->tableName));
+        $migration = new Migrations\Version20220801000500CreateUserVersionTable($this->connection);
+        $migration->run();
+        $this->assertTrue($this->schemaManager->tablesExist($this->tableName));
+        $migration->revert();
+        $this->assertFalse($this->schemaManager->tablesExist($this->tableName));
+    }
+
+    public function testRunThrowsMigrationException(): void
+    {
+        $this->connectionStub->method('preparePrefixedTableName')->willReturn($this->tableName);
+        $this->schemaManagerStub->method('createTable')
+            ->willThrowException(new \Doctrine\DBAL\Exception('test'));
+        $this->dbalStub->method('createSchemaManager')->willReturn($this->schemaManagerStub);
+        $this->connectionStub->method('dbal')->willReturn($this->dbalStub);
+
+        /** @psalm-suppress InvalidArgument */
+        $migration = new Migrations\Version20220801000500CreateUserVersionTable($this->connectionStub);
+        $this->expectException(MigrationException::class);
+        $migration->run();
+    }
+
+    public function testRevertThrowsMigrationException(): void
+    {
+        $this->schemaManagerStub->method('dropTable')
+            ->willThrowException(new \Doctrine\DBAL\Exception('test'));
+        $this->dbalStub->method('createSchemaManager')->willReturn($this->schemaManagerStub);
+        $this->connectionStub->method('dbal')->willReturn($this->dbalStub);
+
+        /** @psalm-suppress InvalidArgument */
+        $migration = new Migrations\Version20220801000500CreateUserVersionTable($this->connectionStub);
+        $this->expectException(MigrationException::class);
+        $migration->revert();
+    }
+
+    public function testRunThrowsOnIvalidTableNameIdp(): void
+    {
+        $this->connectionStub->method('preparePrefixedTableName')
+            ->willReturnOnConsecutiveCalls(''); // Invalid (empty) name for table
+
+        /** @psalm-suppress InvalidArgument */
+        $migration = new Migrations\Version20220801000500CreateUserVersionTable($this->connectionStub);
+        $this->expectException(MigrationException::class);
+        $migration->run();
+    }
+}
diff --git a/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000600CreateIdpSpUserVersionTableTest.php b/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000600CreateIdpSpUserVersionTableTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..ce214e658f70dbdeb91a971cc25c95f7769a7ed5
--- /dev/null
+++ b/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000600CreateIdpSpUserVersionTableTest.php
@@ -0,0 +1,88 @@
+<?php
+
+namespace SimpleSAML\Test\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations;
+
+use Doctrine\DBAL\Schema\AbstractSchemaManager;
+use SimpleSAML\Module\accounting\Exceptions\StoreException\MigrationException;
+use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection;
+use PHPUnit\Framework\TestCase;
+use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations;
+use SimpleSAML\Test\Module\accounting\Constants\ConnectionParameters;
+
+/**
+ * @covers \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations\Version20220801000600CreateIdpSpUserVersionTable
+ * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Bases\AbstractMigration
+ * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection *
+ */
+class Version20220801000600CreateIdpSpUserVersionTableTest extends TestCase
+{
+    protected Connection $connection;
+    protected \Doctrine\DBAL\Schema\AbstractSchemaManager $schemaManager;
+    protected string $tableName;
+    protected \PHPUnit\Framework\MockObject\Stub $connectionStub;
+    protected \PHPUnit\Framework\MockObject\Stub $dbalStub;
+    protected \PHPUnit\Framework\MockObject\Stub $schemaManagerStub;
+
+    protected function setUp(): void
+    {
+        $this->connection = new Connection(ConnectionParameters::DBAL_SQLITE_MEMORY);
+        $this->schemaManager = $this->connection->dbal()->createSchemaManager();
+        $this->tableName = 'vds_idp_sp_user_version';
+
+        $this->connectionStub = $this->createStub(Connection::class);
+        $this->dbalStub = $this->createStub(\Doctrine\DBAL\Connection::class);
+        $this->schemaManagerStub = $this->createStub(AbstractSchemaManager::class);
+    }
+
+    public function testCanRunMigration(): void
+    {
+        $this->assertFalse($this->schemaManager->tablesExist($this->tableName));
+        $migration =
+            new Migrations\Version20220801000600CreateIdpSpUserVersionTable($this->connection);
+        $migration->run();
+        $this->assertTrue($this->schemaManager->tablesExist($this->tableName));
+        $migration->revert();
+        $this->assertFalse($this->schemaManager->tablesExist($this->tableName));
+    }
+
+    public function testRunThrowsMigrationException(): void
+    {
+        $this->connectionStub->method('preparePrefixedTableName')->willReturn($this->tableName);
+        $this->schemaManagerStub->method('createTable')
+            ->willThrowException(new \Doctrine\DBAL\Exception('test'));
+        $this->dbalStub->method('createSchemaManager')->willReturn($this->schemaManagerStub);
+        $this->connectionStub->method('dbal')->willReturn($this->dbalStub);
+
+        /** @psalm-suppress InvalidArgument */
+        $migration =
+            new Migrations\Version20220801000600CreateIdpSpUserVersionTable($this->connectionStub);
+        $this->expectException(MigrationException::class);
+        $migration->run();
+    }
+
+    public function testRevertThrowsMigrationException(): void
+    {
+        $this->schemaManagerStub->method('dropTable')
+            ->willThrowException(new \Doctrine\DBAL\Exception('test'));
+        $this->dbalStub->method('createSchemaManager')->willReturn($this->schemaManagerStub);
+        $this->connectionStub->method('dbal')->willReturn($this->dbalStub);
+
+        /** @psalm-suppress InvalidArgument */
+        $migration =
+            new Migrations\Version20220801000600CreateIdpSpUserVersionTable($this->connectionStub);
+        $this->expectException(MigrationException::class);
+        $migration->revert();
+    }
+
+    public function testRunThrowsOnIvalidTableNameIdp(): void
+    {
+        $this->connectionStub->method('preparePrefixedTableName')
+            ->willReturnOnConsecutiveCalls(''); // Invalid (empty) name for table
+
+        /** @psalm-suppress InvalidArgument */
+        $migration =
+            new Migrations\Version20220801000600CreateIdpSpUserVersionTable($this->connectionStub);
+        $this->expectException(MigrationException::class);
+        $migration->run();
+    }
+}
diff --git a/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000700CreateAuthenticationEventTableTest.php b/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000700CreateAuthenticationEventTableTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..004b1923c03af384e30e1e941861fdddd665f20f
--- /dev/null
+++ b/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/Migrations/Version20220801000700CreateAuthenticationEventTableTest.php
@@ -0,0 +1,84 @@
+<?php
+
+namespace SimpleSAML\Test\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations;
+
+use Doctrine\DBAL\Schema\AbstractSchemaManager;
+use SimpleSAML\Module\accounting\Exceptions\StoreException\MigrationException;
+use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection;
+use PHPUnit\Framework\TestCase;
+use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations;
+use SimpleSAML\Test\Module\accounting\Constants\ConnectionParameters;
+
+/**
+ * @covers \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations\Version20220801000700CreateAuthenticationEventTable
+ * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Bases\AbstractMigration
+ * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection *
+ */
+class Version20220801000700CreateAuthenticationEventTableTest extends TestCase
+{
+    protected Connection $connection;
+    protected \Doctrine\DBAL\Schema\AbstractSchemaManager $schemaManager;
+    protected string $tableName;
+    protected \PHPUnit\Framework\MockObject\Stub $connectionStub;
+    protected \PHPUnit\Framework\MockObject\Stub $dbalStub;
+    protected \PHPUnit\Framework\MockObject\Stub $schemaManagerStub;
+
+    protected function setUp(): void
+    {
+        $this->connection = new Connection(ConnectionParameters::DBAL_SQLITE_MEMORY);
+        $this->schemaManager = $this->connection->dbal()->createSchemaManager();
+        $this->tableName = 'vds_authentication_event';
+
+        $this->connectionStub = $this->createStub(Connection::class);
+        $this->dbalStub = $this->createStub(\Doctrine\DBAL\Connection::class);
+        $this->schemaManagerStub = $this->createStub(AbstractSchemaManager::class);
+    }
+
+    public function testCanRunMigration(): void
+    {
+        $this->assertFalse($this->schemaManager->tablesExist($this->tableName));
+        $migration = new Migrations\Version20220801000700CreateAuthenticationEventTable($this->connection);
+        $migration->run();
+        $this->assertTrue($this->schemaManager->tablesExist($this->tableName));
+        $migration->revert();
+        $this->assertFalse($this->schemaManager->tablesExist($this->tableName));
+    }
+
+    public function testRunThrowsMigrationException(): void
+    {
+        $this->connectionStub->method('preparePrefixedTableName')->willReturn($this->tableName);
+        $this->schemaManagerStub->method('createTable')
+            ->willThrowException(new \Doctrine\DBAL\Exception('test'));
+        $this->dbalStub->method('createSchemaManager')->willReturn($this->schemaManagerStub);
+        $this->connectionStub->method('dbal')->willReturn($this->dbalStub);
+
+        /** @psalm-suppress InvalidArgument */
+        $migration = new Migrations\Version20220801000700CreateAuthenticationEventTable($this->connectionStub);
+        $this->expectException(MigrationException::class);
+        $migration->run();
+    }
+
+    public function testRevertThrowsMigrationException(): void
+    {
+        $this->schemaManagerStub->method('dropTable')
+            ->willThrowException(new \Doctrine\DBAL\Exception('test'));
+        $this->dbalStub->method('createSchemaManager')->willReturn($this->schemaManagerStub);
+        $this->connectionStub->method('dbal')->willReturn($this->dbalStub);
+
+        /** @psalm-suppress InvalidArgument */
+        $migration = new Migrations\Version20220801000700CreateAuthenticationEventTable($this->connectionStub);
+        $this->expectException(MigrationException::class);
+        $migration->revert();
+    }
+
+    public function testRunThrowsOnIvalidTableNameIdp(): void
+    {
+        $this->connectionStub->method('preparePrefixedTableName')
+            ->willReturnOnConsecutiveCalls(''); // Invalid (empty) name for table
+
+        /** @psalm-suppress InvalidArgument */
+        $migration = new Migrations\Version20220801000700CreateAuthenticationEventTable($this->connectionStub);
+        $this->expectException(MigrationException::class);
+        $migration->run();
+    }
+}
diff --git a/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/RawActivityTest.php b/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/RawActivityTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..2999244c0ddd8e302190767482b1aba675f7c972
--- /dev/null
+++ b/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/RawActivityTest.php
@@ -0,0 +1,130 @@
+<?php
+
+namespace SimpleSAML\Test\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store;
+
+use Doctrine\DBAL\Platforms\AbstractPlatform;
+use SimpleSAML\Module\accounting\Exceptions\UnexpectedValueException;
+use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\RawActivity;
+use PHPUnit\Framework\TestCase;
+use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\TableConstants;
+use SimpleSAML\Test\Module\accounting\Constants\DateTime;
+
+/**
+ * @covers \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\RawActivity
+ * @uses \SimpleSAML\Module\accounting\Stores\Bases\DoctrineDbal\AbstractRawEntity
+ */
+class RawActivityTest extends TestCase
+{
+    /**
+     * @var string[]
+     */
+    protected array $serviceProviderMetadata;
+    /**
+     * @var string[]
+     */
+    protected array $userAttributes;
+    protected string $happenedAt;
+    protected array $rawRow;
+    /**
+     * @var AbstractPlatform|AbstractPlatform&\PHPUnit\Framework\MockObject\Stub|\PHPUnit\Framework\MockObject\Stub
+     */
+    protected $abstractPlatformStub;
+
+    protected function setUp(): void
+    {
+        $this->serviceProviderMetadata = ['sp' => 'metadata'];
+        $this->userAttributes = ['user' => 'attribute'];
+        $this->happenedAt = '2022-02-22 22:22:22';
+        $this->rawRow = [
+            TableConstants::ENTITY_ACTIVITY_COLUMN_NAME_SP_METADATA => serialize($this->serviceProviderMetadata),
+            TableConstants::ENTITY_ACTIVITY_COLUMN_NAME_USER_ATTRIBUTES => serialize($this->userAttributes),
+            TableConstants::ENTITY_ACTIVITY_COLUMN_NAME_HAPPENED_AT => $this->happenedAt,
+        ];
+        $this->abstractPlatformStub = $this->createStub(AbstractPlatform::class);
+        $this->abstractPlatformStub->method('getDateTimeFormatString')->willReturn(DateTime::DEFAULT_FORMAT);
+    }
+
+    public function testCanCreateInstance(): void
+    {
+        /** @psalm-suppress PossiblyInvalidArgument */
+        $rawActivity = new RawActivity($this->rawRow, $this->abstractPlatformStub);
+
+        $this->assertInstanceOf(RawActivity::class, $rawActivity);
+    }
+
+    public function testCanGetProperties(): void
+    {
+        /** @psalm-suppress PossiblyInvalidArgument */
+        $rawActivity = new RawActivity($this->rawRow, $this->abstractPlatformStub);
+
+        $this->assertInstanceOf(\DateTimeImmutable::class, $rawActivity->getHappenedAt());
+        $this->assertSame($this->serviceProviderMetadata, $rawActivity->getServiceProviderMetadata());
+        $this->assertSame($this->userAttributes, $rawActivity->getUserAttributes());
+    }
+
+    public function testThrowsIfColumnNotPresent(): void
+    {
+        $rawRow = $this->rawRow;
+        unset($rawRow[TableConstants::ENTITY_ACTIVITY_COLUMN_NAME_HAPPENED_AT]);
+
+        $this->expectException(UnexpectedValueException::class);
+
+        /** @psalm-suppress PossiblyInvalidArgument */
+        new RawActivity($rawRow, $this->abstractPlatformStub);
+    }
+
+    public function testThrowsForNonStringServiceProviderMetadata(): void
+    {
+        $rawRow = $this->rawRow;
+        $rawRow[TableConstants::ENTITY_ACTIVITY_COLUMN_NAME_SP_METADATA] = 1;
+
+        $this->expectException(UnexpectedValueException::class);
+
+        /** @psalm-suppress PossiblyInvalidArgument */
+        new RawActivity($rawRow, $this->abstractPlatformStub);
+    }
+
+    public function testThrowsForNonStringUserAttributes(): void
+    {
+        $rawRow = $this->rawRow;
+        $rawRow[TableConstants::ENTITY_ACTIVITY_COLUMN_NAME_USER_ATTRIBUTES] = 1;
+
+        $this->expectException(UnexpectedValueException::class);
+
+        /** @psalm-suppress PossiblyInvalidArgument */
+        new RawActivity($rawRow, $this->abstractPlatformStub);
+    }
+
+    public function testThrowsForNonStringHappenedAt(): void
+    {
+        $rawRow = $this->rawRow;
+        $rawRow[TableConstants::ENTITY_ACTIVITY_COLUMN_NAME_HAPPENED_AT] = 1;
+
+        $this->expectException(UnexpectedValueException::class);
+
+        /** @psalm-suppress PossiblyInvalidArgument */
+        new RawActivity($rawRow, $this->abstractPlatformStub);
+    }
+
+    public function testThrowsForInvalidServiceProviderMetadata(): void
+    {
+        $rawRow = $this->rawRow;
+        $rawRow[TableConstants::ENTITY_ACTIVITY_COLUMN_NAME_SP_METADATA] = serialize(1);
+
+        $this->expectException(UnexpectedValueException::class);
+
+        /** @psalm-suppress PossiblyInvalidArgument */
+        new RawActivity($rawRow, $this->abstractPlatformStub);
+    }
+
+    public function testThrowsForInvalidUserAttributes(): void
+    {
+        $rawRow = $this->rawRow;
+        $rawRow[TableConstants::ENTITY_ACTIVITY_COLUMN_NAME_USER_ATTRIBUTES] = serialize(1);
+
+        $this->expectException(UnexpectedValueException::class);
+
+        /** @psalm-suppress PossiblyInvalidArgument */
+        new RawActivity($rawRow, $this->abstractPlatformStub);
+    }
+}
diff --git a/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/RawConnectedServiceProviderTest.php b/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/RawConnectedServiceProviderTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..68fe4ed768439f1b39228591cdb6e8ef1c898d84
--- /dev/null
+++ b/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/RawConnectedServiceProviderTest.php
@@ -0,0 +1,177 @@
+<?php
+
+namespace SimpleSAML\Test\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store;
+
+use Doctrine\DBAL\Platforms\AbstractPlatform;
+use SimpleSAML\Module\accounting\Exceptions\UnexpectedValueException;
+use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\RawConnectedServiceProvider;
+use PHPUnit\Framework\TestCase;
+use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\TableConstants;
+use SimpleSAML\Test\Module\accounting\Constants\DateTime;
+
+/**
+ * @covers \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\RawConnectedServiceProvider
+ * @uses \SimpleSAML\Module\accounting\Stores\Bases\DoctrineDbal\AbstractRawEntity
+ */
+class RawConnectedServiceProviderTest extends TestCase
+{
+    protected int $numberOfAuthentications;
+    protected string $lastAuthenticationAt;
+    protected string $firstAuthenticationAt;
+    /**
+     * @var string[]
+     */
+    protected array $serviceProviderMetadata;
+    /**
+     * @var string[]
+     */
+    protected array $userAttributes;
+    protected array $rawRow;
+    /**
+     * @var AbstractPlatform|AbstractPlatform&\PHPUnit\Framework\MockObject\Stub|\PHPUnit\Framework\MockObject\Stub
+     */
+    protected $abstractPlatformStub;
+    protected string $dateTimeFormat;
+
+    protected function setUp(): void
+    {
+        $this->numberOfAuthentications = 2;
+        $this->lastAuthenticationAt = '2022-02-22 22:22:22';
+        $this->firstAuthenticationAt = '2022-02-02 22:22:22';
+        $this->serviceProviderMetadata = ['sp' => 'metadata'];
+        $this->userAttributes = ['user' => 'attribute'];
+        $this->rawRow = [
+            TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_NUMBER_OF_AUTHENTICATIONS =>
+                $this->numberOfAuthentications,
+            TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_LAST_AUTHENTICATION_AT =>
+                $this->lastAuthenticationAt,
+            TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_FIRST_AUTHENTICATION_AT =>
+                $this->firstAuthenticationAt,
+            TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_SP_METADATA =>
+                serialize($this->serviceProviderMetadata),
+            TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_USER_ATTRIBUTES =>
+                serialize($this->userAttributes),
+        ];
+        $this->dateTimeFormat = DateTime::DEFAULT_FORMAT;
+        $this->abstractPlatformStub = $this->createStub(AbstractPlatform::class);
+        $this->abstractPlatformStub->method('getDateTimeFormatString')
+            ->willReturn($this->dateTimeFormat);
+    }
+
+    public function testCanCreateInstance(): void
+    {
+        /** @psalm-suppress PossiblyInvalidArgument */
+        $this->assertInstanceOf(
+            RawConnectedServiceProvider::class,
+            new RawConnectedServiceProvider($this->rawRow, $this->abstractPlatformStub)
+        );
+    }
+
+    public function testCanGetProperties(): void
+    {
+        /** @psalm-suppress PossiblyInvalidArgument */
+        $rawConnectedServiceProvider = new RawConnectedServiceProvider($this->rawRow, $this->abstractPlatformStub);
+
+        $this->assertSame($this->numberOfAuthentications, $rawConnectedServiceProvider->getNumberOfAuthentications());
+        $this->assertInstanceOf(\DateTimeImmutable::class, $rawConnectedServiceProvider->getLastAuthenticationAt());
+        $this->assertSame(
+            $this->lastAuthenticationAt,
+            $rawConnectedServiceProvider->getLastAuthenticationAt()->format($this->dateTimeFormat)
+        );
+        $this->assertInstanceOf(\DateTimeImmutable::class, $rawConnectedServiceProvider->getFirstAuthenticationAt());
+        $this->assertSame(
+            $this->firstAuthenticationAt,
+            $rawConnectedServiceProvider->getFirstAuthenticationAt()->format($this->dateTimeFormat)
+        );
+        $this->assertSame($this->serviceProviderMetadata, $rawConnectedServiceProvider->getServiceProviderMetadata());
+        $this->assertSame($this->userAttributes, $rawConnectedServiceProvider->getUserAttributes());
+    }
+
+    public function testThrowsIfColumnNotSet(): void
+    {
+        $rawRow = $this->rawRow;
+        unset($rawRow[TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_USER_ATTRIBUTES]);
+
+        $this->expectException(UnexpectedValueException::class);
+
+        /** @psalm-suppress PossiblyInvalidArgument */
+        new RawConnectedServiceProvider($rawRow, $this->abstractPlatformStub);
+    }
+
+    public function testThrowsIfNumberOfAuthenticationsNotNumeric(): void
+    {
+        $rawRow = $this->rawRow;
+        $rawRow[TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_NUMBER_OF_AUTHENTICATIONS] = 'a';
+
+        $this->expectException(UnexpectedValueException::class);
+
+        /** @psalm-suppress PossiblyInvalidArgument */
+        new RawConnectedServiceProvider($rawRow, $this->abstractPlatformStub);
+    }
+
+    public function testThrowsIfLastAuthenticationAtNotString(): void
+    {
+        $rawRow = $this->rawRow;
+        $rawRow[TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_LAST_AUTHENTICATION_AT] = 1;
+
+        $this->expectException(UnexpectedValueException::class);
+
+        /** @psalm-suppress PossiblyInvalidArgument */
+        new RawConnectedServiceProvider($rawRow, $this->abstractPlatformStub);
+    }
+
+    public function testThrowsIfFirstAuthenticationAtNotString(): void
+    {
+        $rawRow = $this->rawRow;
+        $rawRow[TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_FIRST_AUTHENTICATION_AT] = 1;
+
+        $this->expectException(UnexpectedValueException::class);
+
+        /** @psalm-suppress PossiblyInvalidArgument */
+        new RawConnectedServiceProvider($rawRow, $this->abstractPlatformStub);
+    }
+
+    public function testThrowsIfSpMetadataNotString(): void
+    {
+        $rawRow = $this->rawRow;
+        $rawRow[TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_SP_METADATA] = 1;
+
+        $this->expectException(UnexpectedValueException::class);
+
+        /** @psalm-suppress PossiblyInvalidArgument */
+        new RawConnectedServiceProvider($rawRow, $this->abstractPlatformStub);
+    }
+
+    public function testThrowsIfUserAttributesNotString(): void
+    {
+        $rawRow = $this->rawRow;
+        $rawRow[TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_USER_ATTRIBUTES] = 1;
+
+        $this->expectException(UnexpectedValueException::class);
+
+        /** @psalm-suppress PossiblyInvalidArgument */
+        new RawConnectedServiceProvider($rawRow, $this->abstractPlatformStub);
+    }
+
+    public function testThrowsIfSpMetadataNotValid(): void
+    {
+        $rawRow = $this->rawRow;
+        $rawRow[TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_SP_METADATA] = serialize(1);
+
+        $this->expectException(UnexpectedValueException::class);
+
+        /** @psalm-suppress PossiblyInvalidArgument */
+        new RawConnectedServiceProvider($rawRow, $this->abstractPlatformStub);
+    }
+
+    public function testThrowsIfUserAttributesNotValid(): void
+    {
+        $rawRow = $this->rawRow;
+        $rawRow[TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_USER_ATTRIBUTES] = serialize(1);
+
+        $this->expectException(UnexpectedValueException::class);
+
+        /** @psalm-suppress PossiblyInvalidArgument */
+        new RawConnectedServiceProvider($rawRow, $this->abstractPlatformStub);
+    }
+}
diff --git a/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/RepositoryTest.php b/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/RepositoryTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..2cd94b37a3698ae46805ac95ce47662e7e84ad4b
--- /dev/null
+++ b/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/Store/RepositoryTest.php
@@ -0,0 +1,687 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Test\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store;
+
+use Psr\Log\LoggerInterface;
+use SimpleSAML\Module\accounting\Exceptions\StoreException;
+use SimpleSAML\Module\accounting\ModuleConfiguration;
+use SimpleSAML\Module\accounting\Stores\Connections\Bases\AbstractMigrator;
+use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection;
+use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Migrator;
+use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store;
+use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Repository;
+use PHPUnit\Framework\TestCase;
+use SimpleSAML\Test\Module\accounting\Constants\ConnectionParameters;
+use SimpleSAML\Test\Module\accounting\Constants\DateTime;
+
+/**
+ * @covers \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Repository
+ * @uses \SimpleSAML\Module\accounting\Helpers\FilesystemHelper
+ * @uses \SimpleSAML\Module\accounting\ModuleConfiguration
+ * @uses \SimpleSAML\Module\accounting\Stores\Connections\Bases\AbstractMigrator
+ * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Bases\AbstractMigration
+ * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection
+ * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Migrator
+ * @uses \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations\Version20220801000000CreateIdpTable
+ * @uses \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations\Version20220801000100CreateIdpVersionTable
+ * @uses \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations\Version20220801000200CreateSpTable
+ * @uses \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations\Version20220801000300CreateSpVersionTable
+ * @uses \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations\Version20220801000400CreateUserTable
+ * @uses \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations\Version20220801000500CreateUserVersionTable
+ * @uses \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations\Version20220801000600CreateIdpSpUserVersionTable
+ * @uses \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations\Version20220801000700CreateAuthenticationEventTable
+ *
+ * @psalm-suppress all
+ */
+class RepositoryTest extends TestCase
+{
+    protected Connection $connection;
+    protected \Doctrine\DBAL\Connection $dbal;
+    /**
+     * @var \PHPUnit\Framework\MockObject\Stub|LoggerInterface|LoggerInterface&\PHPUnit\Framework\MockObject\Stub
+     */
+    protected $loggerStub;
+    protected Migrator $migrator;
+    protected string $dateTimeFormat;
+    protected string $idpEntityId;
+    protected string $idpEntityIdHash;
+    protected string $idpMetadata;
+    protected string $idpMetadataHash;
+    protected string $spEntityId;
+    protected string $spMetadataHash;
+    protected string $userIdentifier;
+    protected string $userIdentifierHash;
+    protected string $userAttributes;
+    protected string $userAttributesHash;
+    protected Repository $repository;
+    protected \DateTimeImmutable $createdAt;
+    /**
+     * @var \PHPUnit\Framework\MockObject\Stub|Connection|Connection&\PHPUnit\Framework\MockObject\Stub
+     */
+    protected $connectionStub;
+    protected string $spEntityIdHash;
+    protected string $spMetadata;
+
+    protected function setUp(): void
+    {
+        // For stubbing.
+        $this->connectionStub = $this->createStub(Connection::class);
+        $this->loggerStub = $this->createStub(LoggerInterface::class);
+
+        // For real DB testing.
+        $connectionParameters = ConnectionParameters::DBAL_SQLITE_MEMORY;
+        $this->connection = new Connection($connectionParameters);
+        $this->migrator = new Migrator($this->connection, $this->loggerStub);
+        $moduleConfiguration = new ModuleConfiguration();
+        $migrationsDirectory = $moduleConfiguration->getModuleSourceDirectory() . DIRECTORY_SEPARATOR . 'Stores' .
+            DIRECTORY_SEPARATOR . 'Data' . DIRECTORY_SEPARATOR . 'Authentication' . DIRECTORY_SEPARATOR .
+            'DoctrineDbal' . DIRECTORY_SEPARATOR . 'Versioned' . DIRECTORY_SEPARATOR . 'Store' . DIRECTORY_SEPARATOR .
+            AbstractMigrator::DEFAULT_MIGRATIONS_DIRECTORY_NAME;
+        $namespace = Store::class . '\\' . AbstractMigrator::DEFAULT_MIGRATIONS_DIRECTORY_NAME;
+
+        $this->migrator->runSetup();
+        $this->migrator->runNonImplementedMigrationClasses($migrationsDirectory, $namespace);
+
+        $this->repository = new Repository($this->connection, $this->loggerStub);
+
+        $this->dateTimeFormat = DateTime::DEFAULT_FORMAT;
+
+        $this->idpEntityId = 'idp-entity-id';
+        $this->idpEntityIdHash = 'idp-entity-id-hash';
+
+        $this->idpMetadata = 'idp-metadata';
+        $this->idpMetadataHash = 'idp-metadata-hash';
+
+        $this->spEntityId = 'sp-entity-id';
+        $this->spEntityIdHash = 'sp-entity-id-hash';
+
+        $this->spMetadata = 'sp-metadata';
+        $this->spMetadataHash = 'sp-metadata-hash';
+
+        $this->userIdentifier = 'user-identifier';
+        $this->userIdentifierHash = 'user-identifier-hash';
+
+        $this->userAttributes = 'user-attributes';
+        $this->userAttributesHash = 'user-attributes-hash';
+
+        $this->createdAt = new \DateTimeImmutable();
+    }
+
+    public function testCanCreateInstance(): void
+    {
+        $this->assertInstanceOf(
+            Repository::class,
+            new Repository($this->connection, $this->loggerStub)
+        );
+    }
+
+    public function testCanInsertAndGetIdp(): array
+    {
+        $this->repository->insertIdp($this->idpEntityId, $this->idpEntityIdHash, $this->createdAt);
+
+        $result = $this->repository->getIdp($this->idpEntityIdHash)->fetchAssociative();
+
+        $this->assertSame($this->idpEntityId, $result[Store\TableConstants::TABLE_IDP_COLUMN_NAME_ENTITY_ID]);
+        $this->assertSame(
+            $this->idpEntityIdHash,
+            $result[Store\TableConstants::TABLE_IDP_COLUMN_NAME_ENTITY_ID_HASH_SHA256]
+        );
+        $this->assertSame(
+            $this->createdAt->format($this->dateTimeFormat),
+            $result[Store\TableConstants::TABLE_IDP_COLUMN_NAME_CREATED_AT]
+        );
+
+        return $result;
+    }
+
+    public function testInsertIdpThrowsOnNonUniqueIdpEntityIdHash(): void
+    {
+        $this->expectException(StoreException::class);
+
+        // Can't insert duplicate idp entity ID hash.
+        $this->repository->insertIdp($this->idpEntityId, $this->idpEntityIdHash, $this->createdAt);
+        $this->repository->insertIdp($this->idpEntityId, $this->idpEntityIdHash, $this->createdAt);
+    }
+
+    public function testGetIdpThrowsOnInvalidDbal(): void
+    {
+        $this->connectionStub->method('dbal')->willThrowException(new \Exception('test'));
+        $repository = new Repository($this->connectionStub, $this->loggerStub);
+        $this->expectException(StoreException::class);
+
+        $repository->getIdp($this->idpEntityIdHash);
+    }
+
+    /**
+     * @depends testCanInsertAndGetIdp
+     */
+    public function testCanInsertAndGetIdpVersion(array $idpResult): array
+    {
+        $idpId = (int)$idpResult[Store\TableConstants::TABLE_IDP_COLUMN_NAME_ID];
+
+        $this->repository->insertIdpVersion($idpId, $this->idpMetadata, $this->idpMetadataHash, $this->createdAt);
+
+        $result = $this->repository->getIdpVersion($idpId, $this->idpMetadataHash)->fetchAssociative();
+
+        $this->assertSame($this->idpMetadata, $result[Store\TableConstants::TABLE_IDP_VERSION_COLUMN_NAME_METADATA]);
+        $this->assertSame(
+            $this->idpMetadataHash,
+            $result[Store\TableConstants::TABLE_IDP_VERSION_COLUMN_NAME_METADATA_HASH_SHA256]
+        );
+        $this->assertSame(
+            $this->createdAt->format($this->dateTimeFormat),
+            $result[Store\TableConstants::TABLE_IDP_VERSION_COLUMN_NAME_CREATED_AT]
+        );
+
+        return $result;
+    }
+
+    public function testInsertIdpVersionThrowsOnNonUniqueIdpMetadataHash(): void
+    {
+        $this->expectException(StoreException::class);
+        // IdP Metadata Hash must be unique.
+        $this->repository->insertIdpVersion(1, $this->idpMetadata, $this->idpMetadataHash, $this->createdAt);
+        $this->repository->insertIdpVersion(1, $this->idpMetadata, $this->idpMetadataHash, $this->createdAt);
+    }
+
+    public function testGetIdpVersionThrowsOnInvalidDbal(): void
+    {
+        $this->connectionStub->method('dbal')->willThrowException(new \Exception('test'));
+        $repository = new Repository($this->connectionStub, $this->loggerStub);
+        $this->expectException(StoreException::class);
+
+        $repository->getIdpVersion(1, $this->idpMetadataHash);
+    }
+
+    public function testCanInsertAndGetSp(): array
+    {
+        $this->repository->insertSp($this->spEntityId, $this->spEntityIdHash, $this->createdAt);
+
+        $result = $this->repository->getSp($this->spEntityIdHash)->fetchAssociative();
+
+        $this->assertSame($this->spEntityId, $result[Store\TableConstants::TABLE_SP_COLUMN_NAME_ENTITY_ID]);
+        $this->assertSame(
+            $this->spEntityIdHash,
+            $result[Store\TableConstants::TABLE_SP_COLUMN_NAME_ENTITY_ID_HASH_SHA256]
+        );
+        $this->assertSame(
+            $this->createdAt->format($this->dateTimeFormat),
+            $result[Store\TableConstants::TABLE_SP_COLUMN_NAME_CREATED_AT]
+        );
+
+        return $result;
+    }
+
+    public function testInsertSpThrowsOnNonUniqueSpEntityIdHash(): void
+    {
+        $this->expectException(StoreException::class);
+        // SP Entity ID Hash must be unique.
+        $this->repository->insertSp($this->spEntityId, $this->spEntityIdHash, $this->createdAt);
+        $this->repository->insertSp($this->spEntityId, $this->spEntityIdHash, $this->createdAt);
+    }
+
+    public function testGetSpThrowsOnInvalidDbal(): void
+    {
+        $this->connectionStub->method('dbal')->willThrowException(new \Exception('test'));
+        $repository = new Repository($this->connectionStub, $this->loggerStub);
+        $this->expectException(StoreException::class);
+
+        $repository->getSp($this->spEntityIdHash);
+    }
+
+    /**
+     * @depends testCanInsertAndGetSp
+     */
+    public function testCanInsertAndGetSpVersion(array $spResult): array
+    {
+        $spId = (int)$spResult[Store\TableConstants::TABLE_SP_COLUMN_NAME_ID];
+
+        $this->repository->insertSpVersion($spId, $this->spMetadata, $this->spMetadataHash, $this->createdAt);
+
+        $result = $this->repository->getSpVersion($spId, $this->spMetadataHash)->fetchAssociative();
+
+        $this->assertSame($this->spMetadata, $result[Store\TableConstants::TABLE_SP_VERSION_COLUMN_NAME_METADATA]);
+        $this->assertSame(
+            $this->spMetadataHash,
+            $result[Store\TableConstants::TABLE_SP_VERSION_COLUMN_NAME_METADATA_HASH_SHA256]
+        );
+        $this->assertSame(
+            $this->createdAt->format($this->dateTimeFormat),
+            $result[Store\TableConstants::TABLE_SP_VERSION_COLUMN_NAME_CREATED_AT]
+        );
+
+        return $result;
+    }
+
+    public function testInsertSpVersionThrowsOnNonUniqueMetadataHash(): void
+    {
+        $this->expectException(StoreException::class);
+        // SP metadata hash must be unique.
+        $this->repository->insertSpVersion(1, $this->spMetadata, $this->spMetadataHash, $this->createdAt);
+        $this->repository->insertSpVersion(1, $this->spMetadata, $this->spMetadataHash, $this->createdAt);
+    }
+
+    public function testGetSpVersionThrowsOnInvalidDbal(): void
+    {
+        $this->connectionStub->method('dbal')->willThrowException(new \Exception('test'));
+        $repository = new Repository($this->connectionStub, $this->loggerStub);
+        $this->expectException(StoreException::class);
+
+        $repository->getSpVersion(1, $this->spMetadataHash);
+    }
+
+    public function testCanInsertAndGetUser(): array
+    {
+        $this->repository->insertUser($this->userIdentifier, $this->userIdentifierHash, $this->createdAt);
+
+        $result = $this->repository->getUser($this->userIdentifierHash)->fetchAssociative();
+
+        $this->assertSame($this->userIdentifier, $result[Store\TableConstants::TABLE_USER_COLUMN_NAME_IDENTIFIER]);
+        $this->assertSame(
+            $this->userIdentifierHash,
+            $result[Store\TableConstants::TABLE_USER_COLUMN_NAME_IDENTIFIER_HASH_SHA256]
+        );
+        $this->assertSame(
+            $this->createdAt->format($this->dateTimeFormat),
+            $result[Store\TableConstants::TABLE_USER_COLUMN_NAME_CREATED_AT]
+        );
+
+        return $result;
+    }
+
+    public function testInsertUserThrowsOnNonUniqueIdentifierHash(): void
+    {
+        $this->expectException(StoreException::class);
+        $this->repository->insertUser($this->userIdentifier, $this->userIdentifierHash, $this->createdAt);
+        $this->repository->insertUser($this->userIdentifier, $this->userIdentifierHash, $this->createdAt);
+    }
+
+    public function testGetUserThrowsOnInvalidDbal(): void
+    {
+        $this->connectionStub->method('dbal')->willThrowException(new \Exception('test'));
+        $repository = new Repository($this->connectionStub, $this->loggerStub);
+        $this->expectException(StoreException::class);
+
+        $repository->getUser($this->userIdentifierHash);
+    }
+
+    /**
+     * @depends testCanInsertAndGetUser
+     */
+    public function testCanInsertAndGetUserVersion(array $userResult): array
+    {
+        $userId = (int)$userResult[Store\TableConstants::TABLE_USER_COLUMN_NAME_ID];
+
+        $this->repository
+            ->insertUserVersion($userId, $this->userAttributes, $this->userAttributesHash, $this->createdAt);
+
+        $result = $this->repository->getUserVersion($userId, $this->userAttributesHash)->fetchAssociative();
+
+        $this->assertSame(
+            $this->userAttributes,
+            $result[Store\TableConstants::TABLE_USER_VERSION_COLUMN_NAME_ATTRIBUTES]
+        );
+        $this->assertSame(
+            $this->userAttributesHash,
+            $result[Store\TableConstants::TABLE_USER_VERSION_COLUMN_NAME_ATTRIBUTES_HASH_SHA256]
+        );
+        $this->assertSame(
+            $this->createdAt->format($this->dateTimeFormat),
+            $result[Store\TableConstants::TABLE_USER_VERSION_COLUMN_NAME_CREATED_AT]
+        );
+
+        return $result;
+    }
+
+    public function testInsertUserVersionThrowsOnNonUniqueAttributesHash(): void
+    {
+        $this->expectException(StoreException::class);
+        $this->repository
+            ->insertUserVersion(1, $this->userAttributes, $this->userAttributesHash, $this->createdAt);
+        $this->repository
+            ->insertUserVersion(1, $this->userAttributes, $this->userAttributesHash, $this->createdAt);
+    }
+
+    public function testGetUserVersionThrowsOnInvalidDbal(): void
+    {
+        $this->connectionStub->method('dbal')->willThrowException(new \Exception('test'));
+        $repository = new Repository($this->connectionStub, $this->loggerStub);
+        $this->expectException(StoreException::class);
+
+        $repository->getUserVersion(1, $this->userIdentifierHash);
+    }
+
+    /**
+     * @depends testCanInsertAndGetIdpVersion
+     * @depends testCanInsertAndGetSpVersion
+     * @depends testCanInsertAndGetUserVersion
+     */
+    public function testCanInsertAndGetIdpSpUserVersion(
+        array $idpVersionResult,
+        array $spVersionResult,
+        array $userVersionResult
+    ): array {
+        $idpVersionId = (int)$idpVersionResult[Store\TableConstants::TABLE_IDP_VERSION_COLUMN_NAME_ID];
+        $spVersionId = (int)$spVersionResult[Store\TableConstants::TABLE_SP_VERSION_COLUMN_NAME_ID];
+        $userVersionId = (int)$userVersionResult[Store\TableConstants::TABLE_USER_VERSION_COLUMN_NAME_ID];
+
+        $this->repository->insertIdpSpUserVersion($idpVersionId, $spVersionId, $userVersionId, $this->createdAt);
+        $result = $this->repository->getIdpSpUserVersion($idpVersionId, $spVersionId, $userVersionId)
+            ->fetchAssociative();
+
+        $this->assertSame(
+            $idpVersionId,
+            (int)$result[Store\TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_IDP_VERSION_ID]
+        );
+        $this->assertSame(
+            $spVersionId,
+            (int)$result[Store\TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_SP_VERSION_ID]
+        );
+        $this->assertSame(
+            $userVersionId,
+            (int)$result[Store\TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_SP_VERSION_ID]
+        );
+        $this->assertSame(
+            $this->createdAt->format($this->dateTimeFormat),
+            $result[Store\TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_CREATED_AT]
+        );
+
+        return $result;
+    }
+
+    public function testInsertIdpSpUserVersionThrowsOnNonUnique(): void
+    {
+        $this->expectException(StoreException::class);
+        $this->repository->insertIdpSpUserVersion(1, 1, 1, $this->createdAt);
+        $this->repository->insertIdpSpUserVersion(1, 1, 1, $this->createdAt);
+    }
+
+    public function testGetIdpSpUserVersionThrowsOnInvalidDbal(): void
+    {
+        $this->connectionStub->method('dbal')->willThrowException(new \Exception('test'));
+        $repository = new Repository($this->connectionStub, $this->loggerStub);
+        $this->expectException(StoreException::class);
+
+        $repository->getIdpSpUserVersion(1, 1, 1);
+    }
+
+    /**
+     * @depends testCanInsertAndGetIdpSpUserVersion
+     */
+    public function testCanInsertAuthenticationEvent(array $idpSpUserVersionResult): void
+    {
+        $idpSpUserVersionId =
+            (int)$idpSpUserVersionResult[Store\TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_ID];
+        $createdAt = $happenedAt = new \DateTimeImmutable();
+
+        $authenticationEventCounterQueryBuilder = $this->connection->dbal()->createQueryBuilder();
+        $authenticationEventCounterQueryBuilder->select('COUNT(id) as authenticationEventCount')
+            ->from(
+                Store\TableConstants::TABLE_PREFIX .
+                $this->connection
+                    ->preparePrefixedTableName(Store\TableConstants::TABLE_NAME_AUTHENTICATION_EVENT)
+            );
+
+        $this->assertSame(0, (int)$authenticationEventCounterQueryBuilder->executeQuery()->fetchOne());
+
+        $this->repository->insertAuthenticationEvent($idpSpUserVersionId, $happenedAt, $createdAt);
+
+        $this->assertSame(1, (int)$authenticationEventCounterQueryBuilder->executeQuery()->fetchOne());
+    }
+
+    public function testInsertAuthenticationEventThrowsOnInvalidDbal(): void
+    {
+        $this->connectionStub->method('dbal')->willThrowException(new \Exception('test'));
+        $repository = new Repository($this->connectionStub, $this->loggerStub);
+        $this->expectException(StoreException::class);
+
+        $repository->insertAuthenticationEvent(1, $this->createdAt);
+    }
+
+    public function testCanGetConnectedServiceProviders(): void
+    {
+        $this->repository->insertIdp($this->idpEntityId, $this->idpEntityIdHash, $this->createdAt);
+        $idpResult = $this->repository->getIdp($this->idpEntityIdHash)->fetchAssociative();
+        $idpId = (int)$idpResult[Store\TableConstants::TABLE_IDP_COLUMN_NAME_ID];
+        $this->repository->insertIdpVersion($idpId, $this->idpMetadata, $this->idpMetadataHash, $this->createdAt);
+        $idpVersionResult = $this->repository->getIdpVersion($idpId, $this->idpMetadataHash)->fetchAssociative();
+
+        $this->repository->insertSp($this->spEntityId, $this->spEntityIdHash, $this->createdAt);
+        $spResult = $this->repository->getSp($this->spEntityIdHash)->fetchAssociative();
+        $spId = (int)$spResult[Store\TableConstants::TABLE_SP_COLUMN_NAME_ID];
+        $this->repository->insertSpVersion($spId, $this->spMetadata, $this->spMetadataHash, $this->createdAt);
+        $spVersionResult = $this->repository->getSpVersion($spId, $this->spMetadataHash)->fetchAssociative();
+
+        $this->repository->insertUser($this->userIdentifier, $this->userIdentifierHash, $this->createdAt);
+        $userResult = $this->repository->getUser($this->userIdentifierHash)->fetchAssociative();
+        $userId = (int)$userResult[Store\TableConstants::TABLE_USER_COLUMN_NAME_ID];
+        $this->repository
+            ->insertUserVersion($userId, $this->userAttributes, $this->userAttributesHash, $this->createdAt);
+        $userVersionResult = $this->repository->getUserVersion($userId, $this->userAttributesHash)->fetchAssociative();
+
+        $idpVersionId = (int)$idpVersionResult[Store\TableConstants::TABLE_IDP_VERSION_COLUMN_NAME_ID];
+        $spVersionId = (int)$spVersionResult[Store\TableConstants::TABLE_SP_VERSION_COLUMN_NAME_ID];
+        $userVersionId = (int)$userVersionResult[Store\TableConstants::TABLE_USER_VERSION_COLUMN_NAME_ID];
+
+        $this->repository->insertIdpSpUserVersion($idpVersionId, $spVersionId, $userVersionId, $this->createdAt);
+        $idpSpUserVersionResult = $this->repository->getIdpSpUserVersion($idpVersionId, $spVersionId, $userVersionId)
+            ->fetchAssociative();
+
+        $idpSpUserVersionId =
+            (int)$idpSpUserVersionResult[Store\TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_ID];
+
+        $this->repository->insertAuthenticationEvent($idpSpUserVersionId, $this->createdAt, $this->createdAt);
+
+        $resultArray = $this->repository->getConnectedServiceProviders($this->userIdentifierHash);
+
+        $this->assertCount(1, $resultArray);
+        $this->assertSame(
+            '1',
+            $resultArray[$this->spEntityId]
+            [Store\TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_NUMBER_OF_AUTHENTICATIONS]
+        );
+        $this->assertSame(
+            $this->spMetadata,
+            $resultArray[$this->spEntityId]
+            [Store\TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_SP_METADATA]
+        );
+        $this->assertSame(
+            $this->userAttributes,
+            $resultArray[$this->spEntityId]
+            [Store\TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_USER_ATTRIBUTES]
+        );
+
+        $resultArray = $this->repository->getConnectedServiceProviders($this->userIdentifierHash);
+        $this->assertCount(1, $resultArray);
+
+        $this->repository->insertAuthenticationEvent($idpSpUserVersionId, $this->createdAt, $this->createdAt);
+        $resultArray = $this->repository->getConnectedServiceProviders($this->userIdentifierHash);
+        $this->assertCount(1, $resultArray);
+        $this->assertSame(
+            '2',
+            $resultArray[$this->spEntityId]
+            [Store\TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_NUMBER_OF_AUTHENTICATIONS]
+        );
+        $this->assertSame(
+            $this->spMetadata,
+            $resultArray[$this->spEntityId]
+            [Store\TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_SP_METADATA]
+        );
+        $this->assertSame(
+            $this->userAttributes,
+            $resultArray[$this->spEntityId]
+            [Store\TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_USER_ATTRIBUTES]
+        );
+
+        // Simulate another SP
+        $spEntityIdNew = $this->spEntityId . '-new';
+        $spEntityIdHashNew = $this->spEntityIdHash . '-new';
+        $spMetadataNew = $this->spMetadata . '-new';
+        $spMetadataHashNew = $this->spMetadataHash . '-new';
+        $this->repository->insertSp($spEntityIdNew, $spEntityIdHashNew, $this->createdAt);
+        $spResult = $this->repository->getSp($spEntityIdHashNew)->fetchAssociative();
+        $spId = (int)$spResult[Store\TableConstants::TABLE_SP_COLUMN_NAME_ID];
+        $this->repository->insertSpVersion($spId, $spMetadataNew, $spMetadataHashNew, $this->createdAt);
+        $spVersionResult = $this->repository->getSpVersion($spId, $spMetadataHashNew)->fetchAssociative();
+        $spVersionId = (int)$spVersionResult[Store\TableConstants::TABLE_SP_VERSION_COLUMN_NAME_ID];
+
+        $this->repository->insertIdpSpUserVersion($idpVersionId, $spVersionId, $userVersionId, $this->createdAt);
+        $idpSpUserVersionResult = $this->repository->getIdpSpUserVersion($idpVersionId, $spVersionId, $userVersionId)
+            ->fetchAssociative();
+        $idpSpUserVersionId =
+            (int)$idpSpUserVersionResult[Store\TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_ID];
+
+        $this->repository->insertAuthenticationEvent($idpSpUserVersionId, $this->createdAt, $this->createdAt);
+
+        $resultArray = $this->repository->getConnectedServiceProviders($this->userIdentifierHash);
+        $this->assertCount(2, $resultArray);
+        $this->assertSame(
+            '1',
+            $resultArray[$spEntityIdNew]
+            [Store\TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_NUMBER_OF_AUTHENTICATIONS]
+        );
+        $this->assertSame(
+            $spMetadataNew,
+            $resultArray[$spEntityIdNew]
+            [Store\TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_SP_METADATA]
+        );
+        $this->assertSame(
+            $this->userAttributes,
+            $resultArray[$this->spEntityId]
+            [Store\TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_USER_ATTRIBUTES]
+        );
+
+        // Simulate change in user attributes
+        $userAttributesNew = $this->userAttributes . '-new';
+        $userAttributesHashNew = $this->userAttributesHash . '-new';
+        $this->repository->insertUserVersion($userId, $userAttributesNew, $userAttributesHashNew, $this->createdAt);
+        $userVersionResult = $this->repository->getUserVersion($userId, $userAttributesHashNew)->fetchAssociative();
+        $userVersionId = (int)$userVersionResult[Store\TableConstants::TABLE_USER_VERSION_COLUMN_NAME_ID];
+
+        $this->repository->insertIdpSpUserVersion($idpVersionId, $spVersionId, $userVersionId, $this->createdAt);
+
+        $idpSpUserVersionResult = $this->repository->getIdpSpUserVersion($idpVersionId, $spVersionId, $userVersionId)
+            ->fetchAssociative();
+        $idpSpUserVersionId =
+            (int)$idpSpUserVersionResult[Store\TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_ID];
+
+        $this->repository->insertAuthenticationEvent($idpSpUserVersionId, $this->createdAt, $this->createdAt);
+        $resultArray = $this->repository->getConnectedServiceProviders($this->userIdentifierHash);
+
+        $this->assertCount(2, $resultArray);
+        $this->assertSame(
+            '2',
+            $resultArray[$spEntityIdNew]
+            [Store\TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_NUMBER_OF_AUTHENTICATIONS]
+        );
+        $this->assertSame(
+            $spMetadataNew,
+            $resultArray[$spEntityIdNew]
+            [Store\TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_SP_METADATA]
+        );
+        // New SP with new user attributes version..
+        $this->assertSame(
+            $userAttributesNew,
+            $resultArray[$spEntityIdNew]
+            [Store\TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_USER_ATTRIBUTES]
+        );
+
+        // First SP still has old user attributes version...
+        $this->assertSame(
+            $this->userAttributes,
+            $resultArray[$this->spEntityId]
+            [Store\TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_USER_ATTRIBUTES]
+        );
+    }
+
+    public function testGetConnectedServiceProvidersThrowsOnInvalidDbal(): void
+    {
+        $this->connectionStub->method('dbal')->willThrowException(new \Exception('test'));
+        $repository = new Repository($this->connectionStub, $this->loggerStub);
+        $this->expectException(StoreException::class);
+
+        $repository->getConnectedServiceProviders($this->userIdentifierHash);
+    }
+
+    public function testCanGetActivity(): void
+    {
+        $this->repository->insertIdp($this->idpEntityId, $this->idpEntityIdHash, $this->createdAt);
+        $idpResult = $this->repository->getIdp($this->idpEntityIdHash)->fetchAssociative();
+        $idpId = (int)$idpResult[Store\TableConstants::TABLE_IDP_COLUMN_NAME_ID];
+        $this->repository->insertIdpVersion($idpId, $this->idpMetadata, $this->idpMetadataHash, $this->createdAt);
+        $idpVersionResult = $this->repository->getIdpVersion($idpId, $this->idpMetadataHash)->fetchAssociative();
+
+        $this->repository->insertSp($this->spEntityId, $this->spEntityIdHash, $this->createdAt);
+        $spResult = $this->repository->getSp($this->spEntityIdHash)->fetchAssociative();
+        $spId = (int)$spResult[Store\TableConstants::TABLE_SP_COLUMN_NAME_ID];
+        $this->repository->insertSpVersion($spId, $this->spMetadata, $this->spMetadataHash, $this->createdAt);
+        $spVersionResult = $this->repository->getSpVersion($spId, $this->spMetadataHash)->fetchAssociative();
+
+        $this->repository->insertUser($this->userIdentifier, $this->userIdentifierHash, $this->createdAt);
+        $userResult = $this->repository->getUser($this->userIdentifierHash)->fetchAssociative();
+        $userId = (int)$userResult[Store\TableConstants::TABLE_USER_COLUMN_NAME_ID];
+        $this->repository
+            ->insertUserVersion($userId, $this->userAttributes, $this->userAttributesHash, $this->createdAt);
+        $userVersionResult = $this->repository->getUserVersion($userId, $this->userAttributesHash)->fetchAssociative();
+
+        $idpVersionId = (int)$idpVersionResult[Store\TableConstants::TABLE_IDP_VERSION_COLUMN_NAME_ID];
+        $spVersionId = (int)$spVersionResult[Store\TableConstants::TABLE_SP_VERSION_COLUMN_NAME_ID];
+        $userVersionId = (int)$userVersionResult[Store\TableConstants::TABLE_USER_VERSION_COLUMN_NAME_ID];
+
+        $this->repository->insertIdpSpUserVersion($idpVersionId, $spVersionId, $userVersionId, $this->createdAt);
+        $idpSpUserVersionResult = $this->repository->getIdpSpUserVersion($idpVersionId, $spVersionId, $userVersionId)
+            ->fetchAssociative();
+
+        $idpSpUserVersionId =
+            (int)$idpSpUserVersionResult[Store\TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_ID];
+
+        $this->repository->insertAuthenticationEvent($idpSpUserVersionId, $this->createdAt, $this->createdAt);
+
+        $resultArray = $this->repository->getActivity($this->userIdentifierHash);
+        $this->assertCount(1, $resultArray);
+
+        $this->repository->insertAuthenticationEvent($idpSpUserVersionId, $this->createdAt, $this->createdAt);
+        $resultArray = $this->repository->getActivity($this->userIdentifierHash);
+        $this->assertCount(2, $resultArray);
+
+        $this->repository->insertAuthenticationEvent($idpSpUserVersionId, $this->createdAt, $this->createdAt);
+        $resultArray = $this->repository->getActivity($this->userIdentifierHash);
+        $this->assertCount(3, $resultArray);
+
+        // Simulate another SP
+        $spEntityIdNew = $this->spEntityId . '-new';
+        $spEntityIdHashNew = $this->spEntityIdHash . '-new';
+        $spMetadataNew = $this->spMetadata . '-new';
+        $spMetadataHashNew = $this->spMetadataHash . '-new';
+        $this->repository->insertSp($spEntityIdNew, $spEntityIdHashNew, $this->createdAt);
+        $spResult = $this->repository->getSp($spEntityIdHashNew)->fetchAssociative();
+        $spId = (int)$spResult[Store\TableConstants::TABLE_SP_COLUMN_NAME_ID];
+        $this->repository->insertSpVersion($spId, $spMetadataNew, $spMetadataHashNew, $this->createdAt);
+        $spVersionResult = $this->repository->getSpVersion($spId, $spMetadataHashNew)->fetchAssociative();
+        $spVersionId = (int)$spVersionResult[Store\TableConstants::TABLE_SP_VERSION_COLUMN_NAME_ID];
+
+        $this->repository->insertIdpSpUserVersion($idpVersionId, $spVersionId, $userVersionId, $this->createdAt);
+        $idpSpUserVersionResult = $this->repository->getIdpSpUserVersion($idpVersionId, $spVersionId, $userVersionId)
+            ->fetchAssociative();
+
+        $idpSpUserVersionId =
+            (int)$idpSpUserVersionResult[Store\TableConstants::TABLE_IDP_SP_USER_VERSION_COLUMN_NAME_ID];
+
+        $this->repository->insertAuthenticationEvent($idpSpUserVersionId, $this->createdAt, $this->createdAt);
+        $resultArray = $this->repository->getActivity($this->userIdentifierHash);
+        $this->assertCount(4, $resultArray);
+
+        // Simulate a change in user attributes
+    }
+
+    public function testGetActivityThrowsOnInvalidDbal(): void
+    {
+        $this->connectionStub->method('dbal')->willThrowException(new \Exception('test'));
+        $repository = new Repository($this->connectionStub, $this->loggerStub);
+        $this->expectException(StoreException::class);
+
+        $repository->getActivity($this->userIdentifierHash);
+    }
+}
diff --git a/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/StoreTest.php b/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/StoreTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..b7506f38917105ee8febbb2df59944ce6f45f2e9
--- /dev/null
+++ b/tests/src/Stores/Data/Authentication/DoctrineDbal/Versioned/StoreTest.php
@@ -0,0 +1,627 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Test\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned;
+
+use Doctrine\DBAL\Result;
+use Psr\Log\LoggerInterface;
+use SimpleSAML\Module\accounting\Entities\Authentication\Event;
+use SimpleSAML\Module\accounting\Entities\Authentication\State;
+use SimpleSAML\Module\accounting\Exceptions\StoreException;
+use SimpleSAML\Module\accounting\Exceptions\UnexpectedValueException;
+use SimpleSAML\Module\accounting\ModuleConfiguration;
+use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection;
+use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Factory;
+use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Migrator;
+use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store;
+use PHPUnit\Framework\TestCase;
+use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\TableConstants;
+use SimpleSAML\Test\Module\accounting\Constants\ConnectionParameters;
+use SimpleSAML\Test\Module\accounting\Constants\RawRowResult;
+use SimpleSAML\Test\Module\accounting\Constants\StateArrays;
+
+/**
+ * @covers \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store
+ * @uses   \SimpleSAML\Module\accounting\Stores\Bases\DoctrineDbal\AbstractStore
+ * @uses   \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection
+ * @uses   \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Migrator
+ * @uses   \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Factory
+ * @uses   \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Repository
+ * @uses   \SimpleSAML\Module\accounting\Entities\Authentication\Event
+ * @uses   \SimpleSAML\Module\accounting\Entities\Authentication\State
+ * @uses \SimpleSAML\Module\accounting\Stores\Connections\Bases\AbstractMigrator
+ * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Bases\AbstractMigration
+ * @uses \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations\Version20220801000000CreateIdpTable
+ * @uses \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations\Version20220801000100CreateIdpVersionTable
+ * @uses \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations\Version20220801000200CreateSpTable
+ * @uses \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations\Version20220801000300CreateSpVersionTable
+ * @uses \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations\Version20220801000400CreateUserTable
+ * @uses \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations\Version20220801000500CreateUserVersionTable
+ * @uses \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations\Version20220801000600CreateIdpSpUserVersionTable
+ * @uses \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Migrations\Version20220801000700CreateAuthenticationEventTable
+ * @uses \SimpleSAML\Module\accounting\Helpers\FilesystemHelper
+ * @uses \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\HashDecoratedState
+ * @uses \SimpleSAML\Module\accounting\Helpers\HashHelper
+ * @uses \SimpleSAML\Module\accounting\Helpers\ArrayHelper
+ * @uses \SimpleSAML\Module\accounting\Helpers\NetworkHelper
+ * @uses \SimpleSAML\Module\accounting\Entities\ConnectedServiceProvider\Bag
+ * @uses \SimpleSAML\Module\accounting\Entities\Bases\AbstractProvider
+ * @uses \SimpleSAML\Module\accounting\Entities\ConnectedServiceProvider
+ * @uses \SimpleSAML\Module\accounting\Entities\User
+ * @uses \SimpleSAML\Module\accounting\Stores\Bases\DoctrineDbal\AbstractRawEntity
+ * @uses \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\RawConnectedServiceProvider
+ * @uses \SimpleSAML\Module\accounting\Entities\Activity\Bag
+ * @uses \SimpleSAML\Module\accounting\Entities\Activity
+ * @uses \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\RawActivity
+ *
+ * @psalm-suppress all
+ */
+class StoreTest extends TestCase
+{
+    protected \PHPUnit\Framework\MockObject\Stub $moduleConfigurationStub;
+    protected Migrator $migrator;
+    protected \PHPUnit\Framework\MockObject\Stub $factoryStub;
+    protected Connection $connection;
+    protected State $state;
+    protected Event $authenticationEvent;
+    protected Store\HashDecoratedState $hashDecoratedState;
+    /**
+     * @var \PHPUnit\Framework\MockObject\Stub|Store\Repository|Store\Repository&\PHPUnit\Framework\MockObject\Stub
+     */
+    protected $repositoryStub;
+    /**
+     * @var Result|Result&\PHPUnit\Framework\MockObject\Stub|\PHPUnit\Framework\MockObject\Stub
+     */
+    protected $resultStub;
+    /**
+     * @var \PHPUnit\Framework\MockObject\MockObject|LoggerInterface|LoggerInterface&\PHPUnit\Framework\MockObject\MockObject
+     */
+    protected $loggerMock;
+
+    protected function setUp(): void
+    {
+        $connectionParams = ConnectionParameters::DBAL_SQLITE_MEMORY;
+        $this->moduleConfigurationStub = $this->createStub(ModuleConfiguration::class);
+        $this->moduleConfigurationStub->method('getConnectionParameters')
+            ->willReturn($connectionParams);
+        $this->moduleConfigurationStub->method('getUserIdAttributeName')
+            ->willReturn('hrEduPersonPersistentID');
+
+        $this->connection = new Connection($connectionParams);
+
+        $this->loggerMock = $this->createMock(LoggerInterface::class);
+
+        /** @psalm-suppress InvalidArgument */
+        $this->migrator = new Migrator($this->connection, $this->loggerMock);
+
+        $this->factoryStub = $this->createStub(Factory::class);
+        $this->factoryStub->method('buildConnection')->willReturn($this->connection);
+        $this->factoryStub->method('buildMigrator')->willReturn($this->migrator);
+
+        $this->state = new State(StateArrays::FULL);
+        $this->authenticationEvent = new Event($this->state);
+
+        $this->hashDecoratedState = new Store\HashDecoratedState($this->state);
+        $this->repositoryStub = $this->createStub(Store\Repository::class);
+
+        $this->resultStub = $this->createStub(Result::class);
+    }
+
+    public function testCanConstructInstance(): void
+    {
+        /** @psalm-suppress InvalidArgument */
+        $this->assertInstanceOf(
+            Store::class,
+            new Store($this->moduleConfigurationStub, $this->loggerMock, $this->factoryStub)
+        );
+    }
+
+    public function testCanBuildInstance(): void
+    {
+        /** @psalm-suppress InvalidArgument */
+        $this->assertInstanceOf(
+            Store::class,
+            Store::build($this->moduleConfigurationStub, $this->loggerMock)
+        );
+    }
+
+    public function testCanPersistAuthenticationEvent(): void
+    {
+        /** @psalm-suppress InvalidArgument */
+        $store = new Store($this->moduleConfigurationStub, $this->loggerMock, $this->factoryStub);
+        $store->runSetup();
+
+        $idpCountQueryBuilder = $this->connection->dbal()->createQueryBuilder();
+        $idpVersionCountQueryBuilder = $this->connection->dbal()->createQueryBuilder();
+        $spCountQueryBuilder = $this->connection->dbal()->createQueryBuilder();
+        $spVersionCountQueryBuilder = $this->connection->dbal()->createQueryBuilder();
+        $userCountQueryBuilder = $this->connection->dbal()->createQueryBuilder();
+        $userVersionCountQueryBuilder = $this->connection->dbal()->createQueryBuilder();
+        $idpSpUserVersionCountQueryBuilder = $this->connection->dbal()->createQueryBuilder();
+        $authenticationEventCountQueryBuilder = $this->connection->dbal()->createQueryBuilder();
+
+        $idpCountQueryBuilder->select('COUNT(id) as idpCount')->from(
+            //'vds_idp'
+            $this->connection->preparePrefixedTableName(
+                Store\TableConstants::TABLE_PREFIX . Store\TableConstants::TABLE_NAME_IDP
+            )
+        );
+        $idpVersionCountQueryBuilder->select('COUNT(id) as idpVersionCount')->from(
+            //'vds_idp_version'
+            $this->connection->preparePrefixedTableName(
+                Store\TableConstants::TABLE_PREFIX . Store\TableConstants::TABLE_NAME_IDP_VERSION
+            )
+        );
+        $spCountQueryBuilder->select('COUNT(id) as spCount')->from(
+            //'vds_sp'
+            $this->connection->preparePrefixedTableName(
+                Store\TableConstants::TABLE_PREFIX . Store\TableConstants::TABLE_NAME_SP
+            )
+        );
+        $spVersionCountQueryBuilder->select('COUNT(id) as spVersionCount')->from(
+            //'vds_sp_version'
+            $this->connection->preparePrefixedTableName(
+                Store\TableConstants::TABLE_PREFIX . Store\TableConstants::TABLE_NAME_SP_VERSION
+            )
+        );
+        $userCountQueryBuilder->select('COUNT(id) as userCount')->from(
+            //'vds_user'
+            $this->connection->preparePrefixedTableName(
+                Store\TableConstants::TABLE_PREFIX . Store\TableConstants::TABLE_NAME_USER
+            )
+        );
+        $userVersionCountQueryBuilder->select('COUNT(id) as userVersionCount')->from(
+            //'vds_user_version'
+            $this->connection->preparePrefixedTableName(
+                Store\TableConstants::TABLE_PREFIX . Store\TableConstants::TABLE_NAME_USER_VERSION
+            )
+        );
+        $idpSpUserVersionCountQueryBuilder->select('COUNT(id) as idpSpUserVersionCount')
+            ->from(
+                //'vds_idp_sp_user_version'
+                $this->connection->preparePrefixedTableName(
+                    Store\TableConstants::TABLE_PREFIX . Store\TableConstants::TABLE_NAME_IDP_SP_USER_VERSION
+                )
+            );
+        $authenticationEventCountQueryBuilder->select('COUNT(id) as authenticationEventCount')
+            ->from(
+                //'vds_authentication_event'
+                $this->connection->preparePrefixedTableName(
+                    Store\TableConstants::TABLE_PREFIX . Store\TableConstants::TABLE_NAME_AUTHENTICATION_EVENT
+                )
+            );
+
+        $this->assertSame(0, (int)$idpCountQueryBuilder->executeQuery()->fetchOne());
+        $this->assertSame(0, (int)$idpVersionCountQueryBuilder->executeQuery()->fetchOne());
+        $this->assertSame(0, (int)$spCountQueryBuilder->executeQuery()->fetchOne());
+        $this->assertSame(0, (int)$spVersionCountQueryBuilder->executeQuery()->fetchOne());
+        $this->assertSame(0, (int)$userCountQueryBuilder->executeQuery()->fetchOne());
+        $this->assertSame(0, (int)$userVersionCountQueryBuilder->executeQuery()->fetchOne());
+        $this->assertSame(0, (int)$idpSpUserVersionCountQueryBuilder->executeQuery()->fetchOne());
+        $this->assertSame(0, (int)$authenticationEventCountQueryBuilder->executeQuery()->fetchOne());
+
+        $store->persist($this->authenticationEvent);
+
+        $this->assertSame(1, (int)$idpCountQueryBuilder->executeQuery()->fetchOne());
+        $this->assertSame(1, (int)$idpVersionCountQueryBuilder->executeQuery()->fetchOne());
+        $this->assertSame(1, (int)$spCountQueryBuilder->executeQuery()->fetchOne());
+        $this->assertSame(1, (int)$spVersionCountQueryBuilder->executeQuery()->fetchOne());
+        $this->assertSame(1, (int)$userCountQueryBuilder->executeQuery()->fetchOne());
+        $this->assertSame(1, (int)$userVersionCountQueryBuilder->executeQuery()->fetchOne());
+        $this->assertSame(1, (int)$idpSpUserVersionCountQueryBuilder->executeQuery()->fetchOne());
+        $this->assertSame(1, (int)$authenticationEventCountQueryBuilder->executeQuery()->fetchOne());
+
+        $store->persist($this->authenticationEvent);
+
+        $this->assertSame(1, (int)$idpCountQueryBuilder->executeQuery()->fetchOne());
+        $this->assertSame(1, (int)$idpVersionCountQueryBuilder->executeQuery()->fetchOne());
+        $this->assertSame(1, (int)$spCountQueryBuilder->executeQuery()->fetchOne());
+        $this->assertSame(1, (int)$spVersionCountQueryBuilder->executeQuery()->fetchOne());
+        $this->assertSame(1, (int)$userCountQueryBuilder->executeQuery()->fetchOne());
+        $this->assertSame(1, (int)$userVersionCountQueryBuilder->executeQuery()->fetchOne());
+        $this->assertSame(1, (int)$idpSpUserVersionCountQueryBuilder->executeQuery()->fetchOne());
+        $this->assertSame(2, (int)$authenticationEventCountQueryBuilder->executeQuery()->fetchOne());
+    }
+
+    public function testResolveIdpIdThrowsOnFirstGetIdpFailure(): void
+    {
+        $this->repositoryStub->method('getIdp')->willThrowException(new \Exception('test'));
+        /** @psalm-suppress InvalidArgument */
+        $store = new Store(
+            $this->moduleConfigurationStub,
+            $this->loggerMock,
+            $this->factoryStub,
+            null,
+            $this->repositoryStub
+        );
+
+        $this->expectException(StoreException::class);
+
+        $store->persist($this->authenticationEvent);
+    }
+
+    public function testResolveIdpIdThrowsOnInsertAndGetIdpFailure(): void
+    {
+        $this->resultStub->method('fetchOne')->willReturn(false);
+        $this->repositoryStub->method('getIdp')->willReturn($this->resultStub);
+        $this->repositoryStub->method('insertIdp')->willThrowException(new \Exception('test'));
+
+        /** @psalm-suppress InvalidArgument */
+        $store = new Store(
+            $this->moduleConfigurationStub,
+            $this->loggerMock,
+            $this->factoryStub,
+            null,
+            $this->repositoryStub
+        );
+
+        $this->expectException(StoreException::class);
+        $this->loggerMock->expects($this->once())->method('warning');
+
+        $store->persist($this->authenticationEvent);
+    }
+
+    public function testResolveIdpVersionIdThrowsOnFirstGetIdpVersionFailure(): void
+    {
+        $this->repositoryStub->method('getIdpVersion')->willThrowException(new \Exception('test'));
+        /** @psalm-suppress InvalidArgument */
+        $store = new Store(
+            $this->moduleConfigurationStub,
+            $this->loggerMock,
+            $this->factoryStub,
+            null,
+            $this->repositoryStub
+        );
+
+        $this->expectException(StoreException::class);
+
+        $store->persist($this->authenticationEvent);
+    }
+
+    public function testResolveIdpVersionIdThrowsOnInsertAndGetIdpVersionFailure(): void
+    {
+        $this->resultStub->method('fetchOne')->willReturn(false);
+        $this->repositoryStub->method('getIdpVersion')->willReturn($this->resultStub);
+        $this->repositoryStub->method('insertIdpVersion')->willThrowException(new \Exception('test'));
+
+        /** @psalm-suppress InvalidArgument */
+        $store = new Store(
+            $this->moduleConfigurationStub,
+            $this->loggerMock,
+            $this->factoryStub,
+            null,
+            $this->repositoryStub
+        );
+
+        $this->expectException(StoreException::class);
+        $this->loggerMock->expects($this->once())->method('warning');
+
+        $store->persist($this->authenticationEvent);
+    }
+
+    public function testResolveSpIdThrowsOnFirstGetSpFailure(): void
+    {
+        $this->repositoryStub->method('getSp')->willThrowException(new \Exception('test'));
+        /** @psalm-suppress InvalidArgument */
+        $store = new Store(
+            $this->moduleConfigurationStub,
+            $this->loggerMock,
+            $this->factoryStub,
+            null,
+            $this->repositoryStub
+        );
+
+        $this->expectException(StoreException::class);
+
+        $store->persist($this->authenticationEvent);
+    }
+
+    public function testResolveSpIdThrowsOnInsertAndGetSpFailure(): void
+    {
+        $this->resultStub->method('fetchOne')->willReturn(false);
+        $this->repositoryStub->method('getSp')->willReturn($this->resultStub);
+        $this->repositoryStub->method('insertSp')->willThrowException(new \Exception('test'));
+
+        /** @psalm-suppress InvalidArgument */
+        $store = new Store(
+            $this->moduleConfigurationStub,
+            $this->loggerMock,
+            $this->factoryStub,
+            null,
+            $this->repositoryStub
+        );
+
+        $this->expectException(StoreException::class);
+        $this->loggerMock->expects($this->once())->method('warning');
+
+        $store->persist($this->authenticationEvent);
+    }
+
+    public function testResolveSpVersionIdThrowsOnFirstGetSpVersionFailure(): void
+    {
+        $this->repositoryStub->method('getSpVersion')->willThrowException(new \Exception('test'));
+        /** @psalm-suppress InvalidArgument */
+        $store = new Store(
+            $this->moduleConfigurationStub,
+            $this->loggerMock,
+            $this->factoryStub,
+            null,
+            $this->repositoryStub
+        );
+
+        $this->expectException(StoreException::class);
+
+        $store->persist($this->authenticationEvent);
+    }
+
+    public function testResolveSpVersionIdThrowsOnInsertAndGetSpVersionFailure(): void
+    {
+        $this->resultStub->method('fetchOne')->willReturn(false);
+        $this->repositoryStub->method('getSpVersion')->willReturn($this->resultStub);
+        $this->repositoryStub->method('insertSpVersion')->willThrowException(new \Exception('test'));
+
+        /** @psalm-suppress InvalidArgument */
+        $store = new Store(
+            $this->moduleConfigurationStub,
+            $this->loggerMock,
+            $this->factoryStub,
+            null,
+            $this->repositoryStub
+        );
+
+        $this->expectException(StoreException::class);
+        $this->loggerMock->expects($this->once())->method('warning');
+
+        $store->persist($this->authenticationEvent);
+    }
+
+    public function testResolveUserIdThrowsOnInvalidUserIdentifierValue(): void
+    {
+        $moduleConfigurationStub = $this->createStub(ModuleConfiguration::class);
+        $moduleConfigurationStub->method('getUserIdAttributeName')->willReturn('invalid');
+
+        /** @psalm-suppress InvalidArgument */
+        $store = new Store(
+            $moduleConfigurationStub,
+            $this->loggerMock,
+            $this->factoryStub,
+            null,
+            $this->repositoryStub
+        );
+
+        $this->expectException(UnexpectedValueException::class);
+
+        $store->persist($this->authenticationEvent);
+    }
+
+    public function testResolveUserIdThrowsOnFirstGetUserFailure(): void
+    {
+        $this->repositoryStub->method('getUser')->willThrowException(new \Exception('test'));
+        /** @psalm-suppress InvalidArgument */
+        $store = new Store(
+            $this->moduleConfigurationStub,
+            $this->loggerMock,
+            $this->factoryStub,
+            null,
+            $this->repositoryStub
+        );
+
+        $this->expectException(StoreException::class);
+
+        $store->persist($this->authenticationEvent);
+    }
+
+    public function testResolveUserIdThrowsOnInsertAndGetUserFailure(): void
+    {
+        $this->resultStub->method('fetchOne')->willReturn(false);
+        $this->repositoryStub->method('getUser')->willReturn($this->resultStub);
+        $this->repositoryStub->method('insertUser')->willThrowException(new \Exception('test'));
+
+        /** @psalm-suppress InvalidArgument */
+        $store = new Store(
+            $this->moduleConfigurationStub,
+            $this->loggerMock,
+            $this->factoryStub,
+            null,
+            $this->repositoryStub
+        );
+
+        $this->expectException(StoreException::class);
+        $this->loggerMock->expects($this->once())->method('warning');
+
+        $store->persist($this->authenticationEvent);
+    }
+
+    public function testResolveUserVersionIdThrowsOnFirstGetUserVersionFailure(): void
+    {
+        $this->repositoryStub->method('getUserVersion')->willThrowException(new \Exception('test'));
+        /** @psalm-suppress InvalidArgument */
+        $store = new Store(
+            $this->moduleConfigurationStub,
+            $this->loggerMock,
+            $this->factoryStub,
+            null,
+            $this->repositoryStub
+        );
+
+        $this->expectException(StoreException::class);
+
+        $store->persist($this->authenticationEvent);
+    }
+
+    public function testResolveUserVersionIdThrowsOnInsertAndGetUserVersionFailure(): void
+    {
+        $this->resultStub->method('fetchOne')->willReturn(false);
+        $this->repositoryStub->method('getUserVersion')->willReturn($this->resultStub);
+        $this->repositoryStub->method('insertUserVersion')->willThrowException(new \Exception('test'));
+
+        /** @psalm-suppress InvalidArgument */
+        $store = new Store(
+            $this->moduleConfigurationStub,
+            $this->loggerMock,
+            $this->factoryStub,
+            null,
+            $this->repositoryStub
+        );
+
+        $this->expectException(StoreException::class);
+        $this->loggerMock->expects($this->once())->method('warning');
+
+        $store->persist($this->authenticationEvent);
+    }
+
+    public function testResolveIdpSpUserVersionIdThrowsOnFirstGetIdpSpUserVersionFailure(): void
+    {
+        $this->repositoryStub->method('getIdpSpUserVersion')->willThrowException(new \Exception('test'));
+        /** @psalm-suppress InvalidArgument */
+        $store = new Store(
+            $this->moduleConfigurationStub,
+            $this->loggerMock,
+            $this->factoryStub,
+            null,
+            $this->repositoryStub
+        );
+
+        $this->expectException(StoreException::class);
+
+        $store->persist($this->authenticationEvent);
+    }
+
+    public function testResolveIdpSpUserVersionIdThrowsOnInsertAndGetIdpSpUserVersionFailure(): void
+    {
+        $this->resultStub->method('fetchOne')->willReturn(false);
+        $this->repositoryStub->method('getIdpSpUserVersion')->willReturn($this->resultStub);
+        $this->repositoryStub->method('insertIdpSpUserVersion')->willThrowException(new \Exception('test'));
+
+        /** @psalm-suppress InvalidArgument */
+        $store = new Store(
+            $this->moduleConfigurationStub,
+            $this->loggerMock,
+            $this->factoryStub,
+            null,
+            $this->repositoryStub
+        );
+
+        $this->expectException(StoreException::class);
+        $this->loggerMock->expects($this->once())->method('warning');
+
+        $store->persist($this->authenticationEvent);
+    }
+
+    public function testGetConnectedOrganizationsReturnsEmptyBagIfNoResults(): void
+    {
+        $this->repositoryStub->method('getConnectedServiceProviders')->willReturn([]);
+
+        /** @psalm-suppress InvalidArgument */
+        $store = new Store(
+            $this->moduleConfigurationStub,
+            $this->loggerMock,
+            $this->factoryStub,
+            null,
+            $this->repositoryStub
+        );
+
+        $connectedServiceProviderBag = $store->getConnectedOrganizations('test');
+
+        $this->assertEmpty($connectedServiceProviderBag->getAll());
+    }
+
+    public function testCanGetConnectedOrganizationsBag(): void
+    {
+        $this->repositoryStub->method('getConnectedServiceProviders')
+            ->willReturn([RawRowResult::CONNECTED_ORGANIZATION]);
+
+        /** @psalm-suppress InvalidArgument */
+        $store = new Store(
+            $this->moduleConfigurationStub,
+            $this->loggerMock,
+            $this->factoryStub,
+            null,
+            $this->repositoryStub
+        );
+
+        $connectedServiceProviderBag = $store->getConnectedOrganizations('test');
+
+        $this->assertNotEmpty($connectedServiceProviderBag->getAll());
+    }
+
+    public function testGetConnectedOrganizationsThrowsForInvalidResult(): void
+    {
+        $rawResult = RawRowResult::CONNECTED_ORGANIZATION;
+        unset($rawResult[TableConstants::ENTITY_CONNECTED_ORGANIZATION_COLUMN_NAME_NUMBER_OF_AUTHENTICATIONS]);
+
+        $this->repositoryStub->method('getConnectedServiceProviders')
+            ->willReturn([$rawResult]);
+
+        /** @psalm-suppress InvalidArgument */
+        $store = new Store(
+            $this->moduleConfigurationStub,
+            $this->loggerMock,
+            $this->factoryStub,
+            null,
+            $this->repositoryStub
+        );
+
+        $this->expectException(StoreException::class);
+        $store->getConnectedOrganizations('test');
+    }
+
+    public function testGetActivityReturnsEmptyBagIfNoResults(): void
+    {
+        $this->repositoryStub->method('getActivity')->willReturn([]);
+
+        /** @psalm-suppress InvalidArgument */
+        $store = new Store(
+            $this->moduleConfigurationStub,
+            $this->loggerMock,
+            $this->factoryStub,
+            null,
+            $this->repositoryStub
+        );
+
+        $activityBag = $store->getActivity('test');
+
+        $this->assertEmpty($activityBag->getAll());
+    }
+
+    public function testCanGetActivityBag(): void
+    {
+        $this->repositoryStub->method('getActivity')
+            ->willReturn([RawRowResult::ACTIVITY]);
+
+        /** @psalm-suppress InvalidArgument */
+        $store = new Store(
+            $this->moduleConfigurationStub,
+            $this->loggerMock,
+            $this->factoryStub,
+            null,
+            $this->repositoryStub
+        );
+
+        $activityBag = $store->getActivity('test');
+
+        $this->assertNotEmpty($activityBag->getAll());
+    }
+
+    public function testGetActivityThrowsForInvalidResult(): void
+    {
+        $rawResult = RawRowResult::ACTIVITY;
+        unset($rawResult[TableConstants::ENTITY_ACTIVITY_COLUMN_NAME_HAPPENED_AT]);
+
+        $this->repositoryStub->method('getActivity')
+            ->willReturn([$rawResult]);
+
+        /** @psalm-suppress InvalidArgument */
+        $store = new Store(
+            $this->moduleConfigurationStub,
+            $this->loggerMock,
+            $this->factoryStub,
+            null,
+            $this->repositoryStub
+        );
+
+        $this->expectException(StoreException::class);
+        $store->getActivity('test');
+    }
+}
diff --git a/tests/src/Stores/Jobs/DoctrineDbal/JobsStore/Migrations/Version20220601000100CreateFailedJobsTableTest.php b/tests/src/Stores/Jobs/DoctrineDbal/Store/Migrations/Version20220601000000CreateJobTableTest.php
similarity index 74%
rename from tests/src/Stores/Jobs/DoctrineDbal/JobsStore/Migrations/Version20220601000100CreateFailedJobsTableTest.php
rename to tests/src/Stores/Jobs/DoctrineDbal/Store/Migrations/Version20220601000000CreateJobTableTest.php
index d6624b3f291282e481153e8cd1f7d413d25802ff..b56de0e42d4ff53ea3c72c71ff6d81fa7e67fb40 100644
--- a/tests/src/Stores/Jobs/DoctrineDbal/JobsStore/Migrations/Version20220601000100CreateFailedJobsTableTest.php
+++ b/tests/src/Stores/Jobs/DoctrineDbal/Store/Migrations/Version20220601000000CreateJobTableTest.php
@@ -1,21 +1,22 @@
 <?php
 
-namespace SimpleSAML\Test\Module\accounting\Stores\Jobs\DoctrineDbal\JobsStore\Migrations;
+namespace SimpleSAML\Test\Module\accounting\Stores\Jobs\DoctrineDbal\Store\Migrations;
 
 use Doctrine\DBAL\Schema\AbstractSchemaManager;
 use SimpleSAML\Module\accounting\Exceptions\StoreException\MigrationException;
 use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection;
-use SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\JobsStore;
-use SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\JobsStore\Migrations;
+use SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store;
+use SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\Migrations\Version20220601000000CreateJobTable;
 use PHPUnit\Framework\TestCase;
+use SimpleSAML\Test\Module\accounting\Constants\ConnectionParameters;
 
 /**
- * @covers \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\JobsStore\Migrations\Version20220601000100CreateFailedJobsTable
+ * @covers \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\Migrations\Version20220601000000CreateJobTable
+ * @covers \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\Migrations\Bases\AbstractCreateJobsTable
  * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection
  * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Bases\AbstractMigration
- *
  */
-class Version20220601000100CreateFailedJobsTableTest extends TestCase
+class Version20220601000000CreateJobTableTest extends TestCase
 {
     protected Connection $connection;
     protected \Doctrine\DBAL\Schema\AbstractSchemaManager $schemaManager;
@@ -23,15 +24,15 @@ class Version20220601000100CreateFailedJobsTableTest extends TestCase
 
     protected function setUp(): void
     {
-        $this->connection = new Connection(['driver' => 'pdo_sqlite', 'memory' => true,]);
+        $this->connection = new Connection(ConnectionParameters::DBAL_SQLITE_MEMORY);
         $this->schemaManager = $this->connection->dbal()->createSchemaManager();
-        $this->tableName = $this->connection->preparePrefixedTableName(JobsStore::TABLE_NAME_FAILED_JOBS);
+        $this->tableName = $this->connection->preparePrefixedTableName(Store\TableConstants::TABLE_NAME_JOB);
     }
 
     public function testCanRunMigration(): void
     {
         $this->assertFalse($this->schemaManager->tablesExist($this->tableName));
-        $migration = new Migrations\Version20220601000100CreateFailedJobsTable($this->connection);
+        $migration = new Version20220601000000CreateJobTable($this->connection);
         $migration->run();
         $this->assertTrue($this->schemaManager->tablesExist($this->tableName));
         $migration->revert();
@@ -49,7 +50,7 @@ class Version20220601000100CreateFailedJobsTableTest extends TestCase
         $schemaManagerStub->method('createTable')
             ->willThrowException(new \Doctrine\DBAL\Exception('test'));
 
-        $migration = new Migrations\Version20220601000100CreateFailedJobsTable($connectionStub);
+        $migration = new Version20220601000000CreateJobTable($connectionStub);
         $this->expectException(MigrationException::class);
         $migration->run();
     }
@@ -65,7 +66,7 @@ class Version20220601000100CreateFailedJobsTableTest extends TestCase
         $schemaManagerStub->method('dropTable')
             ->willThrowException(new \Doctrine\DBAL\Exception('test'));
 
-        $migration = new Migrations\Version20220601000100CreateFailedJobsTable($connectionStub);
+        $migration = new Version20220601000000CreateJobTable($connectionStub);
         $this->expectException(MigrationException::class);
         $migration->revert();
     }
diff --git a/tests/src/Stores/Jobs/DoctrineDbal/JobsStore/Migrations/Version20220601000000CreateJobsTableTest.php b/tests/src/Stores/Jobs/DoctrineDbal/Store/Migrations/Version20220601000100CreateJobFailedTableTest.php
similarity index 73%
rename from tests/src/Stores/Jobs/DoctrineDbal/JobsStore/Migrations/Version20220601000000CreateJobsTableTest.php
rename to tests/src/Stores/Jobs/DoctrineDbal/Store/Migrations/Version20220601000100CreateJobFailedTableTest.php
index 011b249112ee52f246d272fed33acc107639241d..0240fc6c4d035d518df04978980ba707761477c0 100644
--- a/tests/src/Stores/Jobs/DoctrineDbal/JobsStore/Migrations/Version20220601000000CreateJobsTableTest.php
+++ b/tests/src/Stores/Jobs/DoctrineDbal/Store/Migrations/Version20220601000100CreateJobFailedTableTest.php
@@ -1,20 +1,22 @@
 <?php
 
-namespace SimpleSAML\Test\Module\accounting\Stores\Jobs\DoctrineDbal\JobsStore\Migrations;
+namespace SimpleSAML\Test\Module\accounting\Stores\Jobs\DoctrineDbal\Store\Migrations;
 
 use Doctrine\DBAL\Schema\AbstractSchemaManager;
 use SimpleSAML\Module\accounting\Exceptions\StoreException\MigrationException;
 use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection;
-use SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\JobsStore;
-use SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\JobsStore\Migrations\Version20220601000000CreateJobsTable;
+use SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store;
+use SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\Migrations;
 use PHPUnit\Framework\TestCase;
+use SimpleSAML\Test\Module\accounting\Constants\ConnectionParameters;
 
 /**
- * @covers \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\JobsStore\Migrations\Version20220601000000CreateJobsTable
+ * @covers \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\Migrations\Version20220601000100CreateJobFailedTable
  * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection
  * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Bases\AbstractMigration
+ * @uses \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\Migrations\Bases\AbstractCreateJobsTable
  */
-class Version20220601000000CreateJobsTableTest extends TestCase
+class Version20220601000100CreateJobFailedTableTest extends TestCase
 {
     protected Connection $connection;
     protected \Doctrine\DBAL\Schema\AbstractSchemaManager $schemaManager;
@@ -22,15 +24,15 @@ class Version20220601000000CreateJobsTableTest extends TestCase
 
     protected function setUp(): void
     {
-        $this->connection = new Connection(['driver' => 'pdo_sqlite', 'memory' => true,]);
+        $this->connection = new Connection(ConnectionParameters::DBAL_SQLITE_MEMORY);
         $this->schemaManager = $this->connection->dbal()->createSchemaManager();
-        $this->tableName = $this->connection->preparePrefixedTableName(JobsStore::TABLE_NAME_JOBS);
+        $this->tableName = $this->connection->preparePrefixedTableName(Store\TableConstants::TABLE_NAME_JOB_FAILED);
     }
 
     public function testCanRunMigration(): void
     {
         $this->assertFalse($this->schemaManager->tablesExist($this->tableName));
-        $migration = new Version20220601000000CreateJobsTable($this->connection);
+        $migration = new Migrations\Version20220601000100CreateJobFailedTable($this->connection);
         $migration->run();
         $this->assertTrue($this->schemaManager->tablesExist($this->tableName));
         $migration->revert();
@@ -48,7 +50,7 @@ class Version20220601000000CreateJobsTableTest extends TestCase
         $schemaManagerStub->method('createTable')
             ->willThrowException(new \Doctrine\DBAL\Exception('test'));
 
-        $migration = new Version20220601000000CreateJobsTable($connectionStub);
+        $migration = new Migrations\Version20220601000100CreateJobFailedTable($connectionStub);
         $this->expectException(MigrationException::class);
         $migration->run();
     }
@@ -64,7 +66,7 @@ class Version20220601000000CreateJobsTableTest extends TestCase
         $schemaManagerStub->method('dropTable')
             ->willThrowException(new \Doctrine\DBAL\Exception('test'));
 
-        $migration = new Version20220601000000CreateJobsTable($connectionStub);
+        $migration = new Migrations\Version20220601000100CreateJobFailedTable($connectionStub);
         $this->expectException(MigrationException::class);
         $migration->revert();
     }
diff --git a/tests/src/Stores/Jobs/DoctrineDbal/JobsStore/RawJobTest.php b/tests/src/Stores/Jobs/DoctrineDbal/Store/RawJobTest.php
similarity index 58%
rename from tests/src/Stores/Jobs/DoctrineDbal/JobsStore/RawJobTest.php
rename to tests/src/Stores/Jobs/DoctrineDbal/Store/RawJobTest.php
index 7615243deec2246d0de11e98973c8ff355ae8bff..65b9ab9e940fffb0fd4a0806829cbbf71f026742 100644
--- a/tests/src/Stores/Jobs/DoctrineDbal/JobsStore/RawJobTest.php
+++ b/tests/src/Stores/Jobs/DoctrineDbal/Store/RawJobTest.php
@@ -1,51 +1,56 @@
 <?php
 
-namespace SimpleSAML\Test\Module\accounting\Stores\Jobs\DoctrineDbal\JobsStore;
+namespace SimpleSAML\Test\Module\accounting\Stores\Jobs\DoctrineDbal\Store;
 
 use Doctrine\DBAL\Platforms\AbstractPlatform;
 use Doctrine\DBAL\Platforms\SqlitePlatform;
 use PHPUnit\Framework\TestCase;
-use SimpleSAML\Module\accounting\Entities\AuthenticationEvent;
+use SimpleSAML\Module\accounting\Entities\Authentication\Event;
+use SimpleSAML\Module\accounting\Entities\Authentication\State;
 use SimpleSAML\Module\accounting\Exceptions\UnexpectedValueException;
-use SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\JobsStore;
-use SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\JobsStore\RawJob;
+use SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store;
+use SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\RawJob;
+use SimpleSAML\Test\Module\accounting\Constants\StateArrays;
 
 /**
- * @covers \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\JobsStore\RawJob
- * @uses \SimpleSAML\Module\accounting\Entities\AuthenticationEvent
+ * @covers \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\RawJob
+ * @uses \SimpleSAML\Module\accounting\Entities\Authentication\Event
+ * @uses \SimpleSAML\Module\accounting\Entities\Authentication\State
+ * @uses \SimpleSAML\Module\accounting\Stores\Bases\DoctrineDbal\AbstractRawEntity
+ * @uses \SimpleSAML\Module\accounting\Helpers\NetworkHelper
  */
 class RawJobTest extends TestCase
 {
-    protected AuthenticationEvent $authenticationEvent;
+    protected Event $authenticationEvent;
     protected array $validRawRow;
     protected \PHPUnit\Framework\MockObject\Stub $abstractPlatformStub;
 
     protected function setUp(): void
     {
         $this->abstractPlatformStub = $this->createStub(AbstractPlatform::class);
-        $this->authenticationEvent = new AuthenticationEvent(['sample' => 'state']);
+        $this->authenticationEvent = new Event(new State(StateArrays::FULL));
         $this->validRawRow = [
-            JobsStore::COLUMN_NAME_ID => 1,
-            JobsStore::COLUMN_NAME_PAYLOAD => serialize($this->authenticationEvent),
-            JobsStore::COLUMN_NAME_TYPE => get_class($this->authenticationEvent),
-            JobsStore::COLUMN_NAME_CREATED_AT => '2022-08-17 13:26:12',
+            Store\TableConstants::COLUMN_NAME_ID => 1,
+            Store\TableConstants::COLUMN_NAME_PAYLOAD => serialize($this->authenticationEvent),
+            Store\TableConstants::COLUMN_NAME_TYPE => get_class($this->authenticationEvent),
+            Store\TableConstants::COLUMN_NAME_CREATED_AT => '2022-08-17 13:26:12',
         ];
     }
 
     public function testCanInstantiateValidRawJob(): void
     {
         $abstractPlatform = new SqlitePlatform();
-        $rawJob = new JobsStore\RawJob($this->validRawRow, $abstractPlatform);
-        $this->assertSame($rawJob->getId(), $this->validRawRow[JobsStore::COLUMN_NAME_ID]);
+        $rawJob = new Store\RawJob($this->validRawRow, $abstractPlatform);
+        $this->assertSame($rawJob->getId(), $this->validRawRow[Store\TableConstants::COLUMN_NAME_ID]);
         $this->assertEquals($rawJob->getPayload(), $this->authenticationEvent);
-        $this->assertSame($rawJob->getType(), $this->validRawRow[JobsStore::COLUMN_NAME_TYPE]);
+        $this->assertSame($rawJob->getType(), $this->validRawRow[Store\TableConstants::COLUMN_NAME_TYPE]);
         $this->assertInstanceOf(\DateTimeImmutable::class, $rawJob->getCreatedAt());
     }
 
     public function testThrowsOnEmptyColumn(): void
     {
         $invalidRawRow = $this->validRawRow;
-        unset($invalidRawRow[JobsStore::COLUMN_NAME_ID]);
+        unset($invalidRawRow[Store\TableConstants::COLUMN_NAME_ID]);
 
         $this->expectException(UnexpectedValueException::class);
 
@@ -56,7 +61,7 @@ class RawJobTest extends TestCase
     public function testThrowsOnNonNumericId(): void
     {
         $invalidRawRow = $this->validRawRow;
-        $invalidRawRow[JobsStore::COLUMN_NAME_ID] = 'a';
+        $invalidRawRow[Store\TableConstants::COLUMN_NAME_ID] = 'a';
 
         $this->expectException(UnexpectedValueException::class);
 
@@ -67,7 +72,7 @@ class RawJobTest extends TestCase
     public function testThrowsOnNonStringPayload(): void
     {
         $invalidRawRow = $this->validRawRow;
-        $invalidRawRow[JobsStore::COLUMN_NAME_PAYLOAD] = 123;
+        $invalidRawRow[Store\TableConstants::COLUMN_NAME_PAYLOAD] = 123;
 
         $this->expectException(UnexpectedValueException::class);
 
@@ -78,29 +83,29 @@ class RawJobTest extends TestCase
     public function testThrowsOnNonAbstractPayload(): void
     {
         $invalidRawRow = $this->validRawRow;
-        $invalidRawRow[JobsStore::COLUMN_NAME_PAYLOAD] = serialize('abc');
+        $invalidRawRow[Store\TableConstants::COLUMN_NAME_PAYLOAD] = serialize('abc');
 
         $this->expectException(UnexpectedValueException::class);
 
         /** @psalm-suppress InvalidArgument */
-        new JobsStore\RawJob($invalidRawRow, $this->abstractPlatformStub);
+        new Store\RawJob($invalidRawRow, $this->abstractPlatformStub);
     }
 
     public function testThrowsOnNonStringType(): void
     {
         $invalidRawRow = $this->validRawRow;
-        $invalidRawRow[JobsStore::COLUMN_NAME_TYPE] = 123;
+        $invalidRawRow[Store\TableConstants::COLUMN_NAME_TYPE] = 123;
 
         $this->expectException(UnexpectedValueException::class);
 
         /** @psalm-suppress InvalidArgument */
-        new JobsStore\RawJob($invalidRawRow, $this->abstractPlatformStub);
+        new Store\RawJob($invalidRawRow, $this->abstractPlatformStub);
     }
 
     public function testThrowsOnNonStringCreatedAt(): void
     {
         $invalidRawRow = $this->validRawRow;
-        $invalidRawRow[JobsStore::COLUMN_NAME_CREATED_AT] = 123;
+        $invalidRawRow[Store\TableConstants::COLUMN_NAME_CREATED_AT] = 123;
 
         $this->expectException(UnexpectedValueException::class);
 
@@ -111,11 +116,11 @@ class RawJobTest extends TestCase
     public function testThrowsOnNonValidCreatedAt(): void
     {
         $invalidRawRow = $this->validRawRow;
-        $invalidRawRow[JobsStore::COLUMN_NAME_CREATED_AT] = '123';
+        $invalidRawRow[Store\TableConstants::COLUMN_NAME_CREATED_AT] = '123';
 
         $this->expectException(UnexpectedValueException::class);
 
         /** @psalm-suppress InvalidArgument */
-        new JobsStore\RawJob($invalidRawRow, $this->abstractPlatformStub);
+        new Store\RawJob($invalidRawRow, $this->abstractPlatformStub);
     }
 }
diff --git a/tests/src/Stores/Jobs/DoctrineDbal/JobsStore/RepositoryTest.php b/tests/src/Stores/Jobs/DoctrineDbal/Store/RepositoryTest.php
similarity index 81%
rename from tests/src/Stores/Jobs/DoctrineDbal/JobsStore/RepositoryTest.php
rename to tests/src/Stores/Jobs/DoctrineDbal/Store/RepositoryTest.php
index 11c09baa0fce5395a128a5e439977df5890926ad..fba787b148762a3f2079904f691b54ae3e4cfc72 100644
--- a/tests/src/Stores/Jobs/DoctrineDbal/JobsStore/RepositoryTest.php
+++ b/tests/src/Stores/Jobs/DoctrineDbal/Store/RepositoryTest.php
@@ -1,24 +1,28 @@
 <?php
 
-namespace SimpleSAML\Test\Module\accounting\Stores\Jobs\DoctrineDbal\JobsStore;
+declare(strict_types=1);
 
-use SimpleSAML\Module\accounting\Entities\AuthenticationEvent;
+namespace SimpleSAML\Test\Module\accounting\Stores\Jobs\DoctrineDbal\Store;
+
+use PHPUnit\Framework\TestCase;
+use SimpleSAML\Module\accounting\Entities\Authentication\Event;
+use SimpleSAML\Module\accounting\Entities\Authentication\State;
 use SimpleSAML\Module\accounting\Entities\Bases\AbstractJob;
 use SimpleSAML\Module\accounting\Entities\Bases\AbstractPayload;
 use SimpleSAML\Module\accounting\Entities\GenericJob;
 use SimpleSAML\Module\accounting\Exceptions\StoreException;
 use SimpleSAML\Module\accounting\ModuleConfiguration;
-use SimpleSAML\Module\accounting\Services\LoggerService;
+use SimpleSAML\Module\accounting\Services\Logger;
 use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection;
 use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Factory;
 use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Migrator;
-use SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\JobsStore;
-use SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\JobsStore\Repository;
-use PHPUnit\Framework\TestCase;
-use Symfony\Component\HttpKernel\HttpCache\Store;
+use SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store;
+use SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\Repository;
+use SimpleSAML\Test\Module\accounting\Constants\ConnectionParameters;
+use SimpleSAML\Test\Module\accounting\Constants\StateArrays;
 
 /**
- * @covers \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\JobsStore\Repository
+ * @covers \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\Repository
  * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection
  * @uses \SimpleSAML\Module\accounting\Entities\Bases\AbstractJob
  * @uses \SimpleSAML\Module\accounting\Helpers\FilesystemHelper
@@ -26,12 +30,17 @@ use Symfony\Component\HttpKernel\HttpCache\Store;
  * @uses \SimpleSAML\Module\accounting\Stores\Connections\Bases\AbstractMigrator
  * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Bases\AbstractMigration
  * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Migrator
- * @uses \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\JobsStore
- * @uses \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\JobsStore\Migrations\Version20220601000000CreateJobsTable
- * @uses \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\JobsStore\Migrations\Version20220601000100CreateFailedJobsTable
- * @uses \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\JobsStore\RawJob
- * @uses \SimpleSAML\Module\accounting\Entities\AuthenticationEvent
- * @uses \SimpleSAML\Module\accounting\Entities\AuthenticationEvent\Job
+ * @uses \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store
+ * @uses \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\Migrations\Version20220601000000CreateJobTable
+ * @uses \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\Migrations\Version20220601000100CreateJobFailedTable
+ * @uses \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\RawJob
+ * @uses \SimpleSAML\Module\accounting\Entities\Authentication\Event
+ * @uses \SimpleSAML\Module\accounting\Entities\Authentication\Event\Job
+ * @uses \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\Migrations\Bases\AbstractCreateJobsTable
+ * @uses \SimpleSAML\Module\accounting\Stores\Bases\DoctrineDbal\AbstractStore
+ * @uses \SimpleSAML\Module\accounting\Entities\Authentication\State
+ * @uses \SimpleSAML\Module\accounting\Stores\Bases\DoctrineDbal\AbstractRawEntity
+ * @uses \SimpleSAML\Module\accounting\Helpers\NetworkHelper
  */
 class RepositoryTest extends TestCase
 {
@@ -42,16 +51,16 @@ class RepositoryTest extends TestCase
     protected \PHPUnit\Framework\MockObject\Stub $factoryStub;
     protected \PHPUnit\Framework\MockObject\Stub $payloadStub;
     protected \PHPUnit\Framework\MockObject\Stub $jobStub;
-    protected JobsStore $jobsStore;
+    protected Store $jobsStore;
     protected string $jobsTableName;
 
     protected function setUp(): void
     {
         // Configuration directory is set by phpunit using php ENV setting feature (check phpunit.xml).
         $this->moduleConfiguration = new ModuleConfiguration('module_accounting.php');
-        $this->connection = new Connection(['driver' => 'pdo_sqlite', 'memory' => true,]);
+        $this->connection = new Connection(ConnectionParameters::DBAL_SQLITE_MEMORY);
 
-        $this->loggerServiceStub = $this->createStub(LoggerService::class);
+        $this->loggerServiceStub = $this->createStub(Logger::class);
 
         /** @psalm-suppress InvalidArgument */
         $this->migrator = new Migrator($this->connection, $this->loggerServiceStub);
@@ -67,9 +76,9 @@ class RepositoryTest extends TestCase
         $this->jobStub->method('getCreatedAt')->willReturn(new \DateTimeImmutable());
 
         /** @psalm-suppress InvalidArgument */
-        $this->jobsStore = new JobsStore($this->moduleConfiguration, $this->factoryStub, $this->loggerServiceStub);
+        $this->jobsStore = new Store($this->moduleConfiguration, $this->loggerServiceStub, $this->factoryStub);
 
-        $this->jobsTableName = $this->connection->preparePrefixedTableName(JobsStore::TABLE_NAME_JOBS);
+        $this->jobsTableName = $this->connection->preparePrefixedTableName(Store\TableConstants::TABLE_NAME_JOB);
     }
 
     public function testCanInsertAndGetJob(): void
@@ -109,7 +118,7 @@ class RepositoryTest extends TestCase
 
         $this->expectException(StoreException::class);
 
-        $invalidType = str_pad('abc', JobsStore::COLUMN_LENGTH_TYPE + 1);
+        $invalidType = str_pad('abc', Store\TableConstants::COLUMN_TYPE_LENGTH + 1);
         $jobStub = $this->createStub(GenericJob::class);
         $jobStub->method('getPayload')->willReturn($this->payloadStub);
         $jobStub->method('getType')->willReturn($invalidType);
@@ -198,12 +207,12 @@ class RepositoryTest extends TestCase
         /** @psalm-suppress InvalidArgument */
         $repository->insert($this->jobStub);
 
-        $authenticationEvent = new AuthenticationEvent(['sample-state']);
-        $authenticationEventJob = new AuthenticationEvent\Job($authenticationEvent);
+        $authenticationEvent = new Event(new State(StateArrays::FULL));
+        $authenticationEventJob = new Event\Job($authenticationEvent);
 
         $repository->insert($authenticationEventJob);
 
-        $this->assertInstanceOf(AuthenticationEvent\Job::class, $repository->getNext(AuthenticationEvent\Job::class));
+        $this->assertInstanceOf(Event\Job::class, $repository->getNext(Event\Job::class));
     }
 
     public function testInitializationThrowsForInvalidJobsTableName(): void
diff --git a/tests/src/Stores/Jobs/DoctrineDbal/JobsStoreTest.php b/tests/src/Stores/Jobs/DoctrineDbal/StoreTest.php
similarity index 72%
rename from tests/src/Stores/Jobs/DoctrineDbal/JobsStoreTest.php
rename to tests/src/Stores/Jobs/DoctrineDbal/StoreTest.php
index d7c5a7db520e571d5a50f93de12e1e02f8540377..4949b3548f649e406ad3987fb215df8986350ea9 100644
--- a/tests/src/Stores/Jobs/DoctrineDbal/JobsStoreTest.php
+++ b/tests/src/Stores/Jobs/DoctrineDbal/StoreTest.php
@@ -1,22 +1,28 @@
 <?php
 
+declare(strict_types=1);
+
 namespace SimpleSAML\Test\Module\accounting\Stores\Jobs\DoctrineDbal;
 
 use PHPUnit\Framework\TestCase;
-use SimpleSAML\Module\accounting\Entities\AuthenticationEvent;
+use SimpleSAML\Module\accounting\Entities\Authentication\Event;
+use SimpleSAML\Module\accounting\Entities\Authentication\State;
 use SimpleSAML\Module\accounting\Entities\Bases\AbstractJob;
 use SimpleSAML\Module\accounting\Entities\Bases\AbstractPayload;
 use SimpleSAML\Module\accounting\Entities\GenericJob;
 use SimpleSAML\Module\accounting\Exceptions\StoreException;
 use SimpleSAML\Module\accounting\ModuleConfiguration;
-use SimpleSAML\Module\accounting\Services\LoggerService;
+use SimpleSAML\Module\accounting\Services\Logger;
 use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection;
 use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Factory;
 use SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Migrator;
-use SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\JobsStore;
+use SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store;
+use SimpleSAML\Test\Module\accounting\Constants\ConnectionParameters;
+use SimpleSAML\Test\Module\accounting\Constants\StateArrays;
 
 /**
- * @covers \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\JobsStore
+ * @covers \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store
+ * @covers \SimpleSAML\Module\accounting\Stores\Bases\DoctrineDbal\AbstractStore
  * @uses \SimpleSAML\Module\accounting\ModuleConfiguration
  * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection
  * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Factory
@@ -24,20 +30,24 @@ use SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\JobsStore;
  * @uses \SimpleSAML\Module\accounting\Stores\Connections\Bases\AbstractMigrator
  * @uses \SimpleSAML\Module\accounting\Helpers\FilesystemHelper
  * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Bases\AbstractMigration
- * @uses \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\JobsStore\Migrations\Version20220601000000CreateJobsTable
- * @uses \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\JobsStore\Migrations\Version20220601000100CreateFailedJobsTable
+ * @uses \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\Migrations\Version20220601000000CreateJobTable
+ * @uses \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\Migrations\Version20220601000100CreateJobFailedTable
  * @uses \SimpleSAML\Module\accounting\Entities\Bases\AbstractJob
- * @uses \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\JobsStore\RawJob
- * @uses \SimpleSAML\Module\accounting\Entities\AuthenticationEvent
- * @uses \SimpleSAML\Module\accounting\Entities\AuthenticationEvent\Job
- * @uses \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\JobsStore\Repository
+ * @uses \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\RawJob
+ * @uses \SimpleSAML\Module\accounting\Entities\Authentication\Event
+ * @uses \SimpleSAML\Module\accounting\Entities\Authentication\Event\Job
+ * @uses \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\Repository
+ * @uses \SimpleSAML\Module\accounting\Stores\Jobs\DoctrineDbal\Store\Migrations\Bases\AbstractCreateJobsTable
+ * @uses \SimpleSAML\Module\accounting\Entities\Authentication\State
+ * @uses \SimpleSAML\Module\accounting\Stores\Bases\DoctrineDbal\AbstractRawEntity
+ * @uses \SimpleSAML\Module\accounting\Helpers\NetworkHelper
  */
-class JobsStoreTest extends TestCase
+class StoreTest extends TestCase
 {
     protected ModuleConfiguration $moduleConfiguration;
     protected \PHPUnit\Framework\MockObject\Stub $factoryStub;
     protected Connection $connection;
-    protected \PHPUnit\Framework\MockObject\Stub $loggerServiceStub;
+    protected \PHPUnit\Framework\MockObject\Stub $loggerStub;
     protected Migrator $migrator;
     protected \PHPUnit\Framework\MockObject\Stub $payloadStub;
     protected \PHPUnit\Framework\MockObject\Stub $jobStub;
@@ -46,12 +56,12 @@ class JobsStoreTest extends TestCase
     {
         // Configuration directory is set by phpunit using php ENV setting feature (check phpunit.xml).
         $this->moduleConfiguration = new ModuleConfiguration('module_accounting.php');
-        $this->connection = new Connection(['driver' => 'pdo_sqlite', 'memory' => true,]);
+        $this->connection = new Connection(ConnectionParameters::DBAL_SQLITE_MEMORY);
 
-        $this->loggerServiceStub = $this->createStub(LoggerService::class);
+        $this->loggerStub = $this->createStub(Logger::class);
 
         /** @psalm-suppress InvalidArgument */
-        $this->migrator = new Migrator($this->connection, $this->loggerServiceStub);
+        $this->migrator = new Migrator($this->connection, $this->loggerStub);
 
         $this->factoryStub = $this->createStub(Factory::class);
         $this->factoryStub->method('buildConnection')->willReturn($this->connection);
@@ -68,7 +78,7 @@ class JobsStoreTest extends TestCase
     public function testSetupDependsOnMigratorSetup(): void
     {
         /** @psalm-suppress InvalidArgument */
-        $jobsStore = new JobsStore($this->moduleConfiguration, $this->factoryStub, $this->loggerServiceStub);
+        $jobsStore = new Store($this->moduleConfiguration, $this->loggerStub, $this->factoryStub);
 
         $this->assertTrue($this->migrator->needsSetup());
         $this->assertTrue($jobsStore->needsSetup());
@@ -82,9 +92,9 @@ class JobsStoreTest extends TestCase
     public function testSetupDependsOnMigrations(): void
     {
         /** @psalm-suppress InvalidArgument */
-        $jobsStore = new JobsStore($this->moduleConfiguration, $this->factoryStub, $this->loggerServiceStub);
+        $jobsStore = new Store($this->moduleConfiguration, $this->loggerStub, $this->factoryStub);
 
-        // Run migrator setup beforehand, so it only depends on JobsStore migrations setup
+        // Run migrator setup beforehand, so it only depends on Store migrations setup
         $this->migrator->runSetup();
         $this->assertTrue($jobsStore->needsSetup());
 
@@ -96,23 +106,34 @@ class JobsStoreTest extends TestCase
     public function testCanGetPrefixedTableNames(): void
     {
         /** @psalm-suppress InvalidArgument */
-        $jobsStore = new JobsStore($this->moduleConfiguration, $this->factoryStub, $this->loggerServiceStub);
+        $jobsStore = new Store($this->moduleConfiguration, $this->loggerStub, $this->factoryStub);
 
-        $tableNameJobs = $this->connection->preparePrefixedTableName(JobsStore::TABLE_NAME_JOBS);
-        $tableNameFailedJobs = $this->connection->preparePrefixedTableName(JobsStore::TABLE_NAME_FAILED_JOBS);
+        $tableNameJobs = $this->connection->preparePrefixedTableName(Store\TableConstants::TABLE_NAME_JOB);
+        $tableNameFailedJobs = $this->connection->preparePrefixedTableName(
+            Store\TableConstants::TABLE_NAME_JOB_FAILED
+        );
 
         $this->assertSame($tableNameJobs, $jobsStore->getPrefixedTableNameJobs());
         $this->assertSame($tableNameFailedJobs, $jobsStore->getPrefixedTableNameFailedJobs());
     }
 
+    public function testCanBuildInstanceStatically(): void
+    {
+        $moduleConfiguration = $this->createStub(ModuleConfiguration::class);
+        $moduleConfiguration->method('getConnectionParameters')
+            ->willReturn(ConnectionParameters::DBAL_SQLITE_MEMORY);
+        /** @psalm-suppress InvalidArgument */
+        $this->assertInstanceOf(Store::class, Store::build($moduleConfiguration, $this->loggerStub));
+    }
+
     public function testCanEnqueueJob(): void
     {
         /** @psalm-suppress InvalidArgument */
-        $jobsStore = new JobsStore($this->moduleConfiguration, $this->factoryStub, $this->loggerServiceStub);
+        $jobsStore = new Store($this->moduleConfiguration, $this->loggerStub, $this->factoryStub);
         $jobsStore->runSetup();
 
         $queryBuilder = $this->connection->dbal()->createQueryBuilder();
-        $queryBuilder->select('COUNT(id) as jobsCount')->from($jobsStore->getPrefixedTableNameJobs())->fetchOne();
+        $queryBuilder->select('COUNT(id) as jobsCount')->from($jobsStore->getPrefixedTableNameJobs());
 
         $this->assertSame(0, (int) $queryBuilder->executeQuery()->fetchOne());
 
@@ -132,7 +153,7 @@ class JobsStoreTest extends TestCase
     public function testEnqueueThrowsStoreExceptionOnNonSetupRun(): void
     {
         /** @psalm-suppress InvalidArgument */
-        $jobsStore = new JobsStore($this->moduleConfiguration, $this->factoryStub, $this->loggerServiceStub);
+        $jobsStore = new Store($this->moduleConfiguration, $this->loggerStub, $this->factoryStub);
         // Don't run setup, so we get exception
         //$jobsStore->runSetup();
 
@@ -148,7 +169,7 @@ class JobsStoreTest extends TestCase
     public function testCanDequeueJob(): void
     {
         /** @psalm-suppress InvalidArgument */
-        $jobsStore = new JobsStore($this->moduleConfiguration, $this->factoryStub, $this->loggerServiceStub);
+        $jobsStore = new Store($this->moduleConfiguration, $this->loggerStub, $this->factoryStub);
         $jobsStore->runSetup();
 
         $queryBuilder = $this->connection->dbal()->createQueryBuilder();
@@ -171,11 +192,11 @@ class JobsStoreTest extends TestCase
     public function testCanDequeueSpecificJobType(): void
     {
         /** @psalm-suppress InvalidArgument */
-        $jobsStore = new JobsStore($this->moduleConfiguration, $this->factoryStub, $this->loggerServiceStub);
+        $jobsStore = new Store($this->moduleConfiguration, $this->loggerStub, $this->factoryStub);
         $jobsStore->runSetup();
 
-        $authenticationEvent = new AuthenticationEvent(['sample-state']);
-        $authenticationEventJob = new AuthenticationEvent\Job($authenticationEvent);
+        $authenticationEvent = new Event(new State(StateArrays::FULL));
+        $authenticationEventJob = new Event\Job($authenticationEvent);
 
         $queryBuilder = $this->connection->dbal()->createQueryBuilder();
         $queryBuilder->select('COUNT(id) as jobsCount')->from($jobsStore->getPrefixedTableNameJobs())->fetchOne();
@@ -188,11 +209,11 @@ class JobsStoreTest extends TestCase
 
         $this->assertSame(2, (int) $queryBuilder->executeQuery()->fetchOne());
 
-        $this->assertInstanceOf(AuthenticationEvent\Job::class, $jobsStore->dequeue(AuthenticationEvent\Job::class));
+        $this->assertInstanceOf(Event\Job::class, $jobsStore->dequeue(Event\Job::class));
 
         $this->assertSame(1, (int) $queryBuilder->executeQuery()->fetchOne());
 
-        $this->assertNull($jobsStore->dequeue(AuthenticationEvent::class));
+        $this->assertNull($jobsStore->dequeue(Event::class));
 
         $this->assertSame(1, (int) $queryBuilder->executeQuery()->fetchOne());
     }
@@ -200,7 +221,7 @@ class JobsStoreTest extends TestCase
     public function testDequeueThrowsWhenSetupNotRun(): void
     {
         /** @psalm-suppress InvalidArgument */
-        $jobsStore = new JobsStore($this->moduleConfiguration, $this->factoryStub, $this->loggerServiceStub);
+        $jobsStore = new Store($this->moduleConfiguration, $this->loggerStub, $this->factoryStub);
 //        $jobsStore->runSetup();
 
         $payloadStub = $this->createStub(AbstractPayload::class);
@@ -214,7 +235,7 @@ class JobsStoreTest extends TestCase
 
     public function testDequeueThrowsForJobWithInvalidId(): void
     {
-        $repositoryStub = $this->createStub(JobsStore\Repository::class);
+        $repositoryStub = $this->createStub(Store\Repository::class);
         $jobStub = $this->createStub(GenericJob::class);
         $jobStub->method('getPayload')->willReturn($this->payloadStub);
         $jobStub->method('getCreatedAt')->willReturn(new \DateTimeImmutable());
@@ -224,10 +245,11 @@ class JobsStoreTest extends TestCase
         $repositoryStub->method('getNext')->willReturn($jobStub);
 
         /** @psalm-suppress InvalidArgument */
-        $jobsStore = new JobsStore(
+        $jobsStore = new Store(
             $this->moduleConfiguration,
+            $this->loggerStub,
             $this->factoryStub,
-            $this->loggerServiceStub,
+            null,
             $repositoryStub
         );
         $jobsStore->runSetup();
@@ -239,15 +261,16 @@ class JobsStoreTest extends TestCase
 
     public function testDequeThrowsAfterMaxDeleteAttempts(): void
     {
-        $repositoryStub = $this->createStub(JobsStore\Repository::class);
+        $repositoryStub = $this->createStub(Store\Repository::class);
         $repositoryStub->method('getNext')->willReturn($this->jobStub);
         $repositoryStub->method('delete')->willReturn(false);
 
         /** @psalm-suppress InvalidArgument */
-        $jobsStore = new JobsStore(
+        $jobsStore = new Store(
             $this->moduleConfiguration,
+            $this->loggerStub,
             $this->factoryStub,
-            $this->loggerServiceStub,
+            null,
             $repositoryStub
         );
         $jobsStore->runSetup();
@@ -259,15 +282,16 @@ class JobsStoreTest extends TestCase
 
     public function testCanContinueSearchingInCaseOfJobDeletion(): void
     {
-        $repositoryStub = $this->createStub(JobsStore\Repository::class);
+        $repositoryStub = $this->createStub(Store\Repository::class);
         $repositoryStub->method('getNext')->willReturn($this->jobStub);
         $repositoryStub->method('delete')->willReturnOnConsecutiveCalls(false, true);
 
         /** @psalm-suppress InvalidArgument */
-        $jobsStore = new JobsStore(
+        $jobsStore = new Store(
             $this->moduleConfiguration,
+            $this->loggerStub,
             $this->factoryStub,
-            $this->loggerServiceStub,
+            null,
             $repositoryStub
         );
         $jobsStore->runSetup();
diff --git a/tests/src/Trackers/Authentication/DoctrineDbal/Versioned/TrackerTest.php b/tests/src/Trackers/Authentication/DoctrineDbal/Versioned/TrackerTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..00776a3cf5c01283cfb8a046e139d8a519d2026a
--- /dev/null
+++ b/tests/src/Trackers/Authentication/DoctrineDbal/Versioned/TrackerTest.php
@@ -0,0 +1,138 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Test\Module\accounting\Trackers\Authentication\DoctrineDbal\Versioned;
+
+use Psr\Log\LoggerInterface;
+use SimpleSAML\Module\accounting\Entities\Activity;
+use SimpleSAML\Module\accounting\Entities\Authentication\Event;
+use SimpleSAML\Module\accounting\Entities\ConnectedServiceProvider;
+use SimpleSAML\Module\accounting\ModuleConfiguration;
+use SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store;
+use SimpleSAML\Module\accounting\Trackers\Authentication\DoctrineDbal\Versioned\Tracker;
+use PHPUnit\Framework\TestCase;
+use SimpleSAML\Test\Module\accounting\Constants\ConnectionParameters;
+
+/**
+ * @covers \SimpleSAML\Module\accounting\Trackers\Authentication\DoctrineDbal\Versioned\Tracker
+ * @uses \SimpleSAML\Module\accounting\Helpers\InstanceBuilderUsingModuleConfigurationHelper
+ * @uses \SimpleSAML\Module\accounting\Stores\Builders\Bases\AbstractStoreBuilder
+ * @uses \SimpleSAML\Module\accounting\Stores\Builders\DataStoreBuilder
+ * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Connection
+ * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Factory
+ * @uses \SimpleSAML\Module\accounting\Stores\Connections\DoctrineDbal\Migrator
+ * @uses \SimpleSAML\Module\accounting\Stores\Bases\DoctrineDbal\AbstractStore
+ * @uses \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store
+ * @uses \SimpleSAML\Module\accounting\Stores\Data\Authentication\DoctrineDbal\Versioned\Store\Repository
+ * @uses \SimpleSAML\Module\accounting\Helpers\HashHelper
+ *
+ * @psalm-suppress all
+ */
+class TrackerTest extends TestCase
+{
+    /**
+     * @var \PHPUnit\Framework\MockObject\Stub|ModuleConfiguration|ModuleConfiguration&\PHPUnit\Framework\MockObject\Stub
+     */
+    protected $moduleConfigurationStub;
+    /**
+     * @var \PHPUnit\Framework\MockObject\MockObject|LoggerInterface|LoggerInterface&\PHPUnit\Framework\MockObject\MockObject
+     */
+    protected $loggerMock;
+    /**
+     * @var \PHPUnit\Framework\MockObject\MockObject|Store|Store&\PHPUnit\Framework\MockObject\MockObject
+     */
+    protected $dataStoreMock;
+
+    protected function setUp(): void
+    {
+        $this->moduleConfigurationStub = $this->createStub(ModuleConfiguration::class);
+        $this->moduleConfigurationStub->method('getConnectionParameters')
+            ->willReturn(ConnectionParameters::DBAL_SQLITE_MEMORY);
+        $this->loggerMock = $this->createMock(LoggerInterface::class);
+        $this->dataStoreMock = $this->createMock(Store::class);
+    }
+
+    /**
+     * @psalm-suppress PossiblyInvalidArgument
+     */
+    public function testCanCreateInstance(): void
+    {
+        $this->assertInstanceOf(
+            Tracker::class,
+            new Tracker($this->moduleConfigurationStub, $this->loggerMock, $this->dataStoreMock)
+        );
+
+        $this->assertInstanceOf(
+            Tracker::class,
+            new Tracker($this->moduleConfigurationStub, $this->loggerMock)
+        );
+
+        $this->assertInstanceOf(
+            Tracker::class,
+            Tracker::build($this->moduleConfigurationStub, $this->loggerMock)
+        );
+    }
+
+    public function testProcessCallsPersistOnDataStore(): void
+    {
+        $authenticationEventStub = $this->createStub(Event::class);
+
+        $this->dataStoreMock->expects($this->once())
+            ->method('persist')
+            ->with($authenticationEventStub);
+
+        /** @psalm-suppress PossiblyInvalidArgument */
+        $tracker = new Tracker($this->moduleConfigurationStub, $this->loggerMock, $this->dataStoreMock);
+
+        $tracker->process($authenticationEventStub);
+    }
+
+    public function testSetupDependsOnDataStore(): void
+    {
+        $this->dataStoreMock->expects($this->once())
+            ->method('needsSetup')
+            ->willReturn(true);
+
+        $this->dataStoreMock->expects($this->once())
+            ->method('runSetup');
+
+        /** @psalm-suppress PossiblyInvalidArgument */
+        $tracker = new Tracker($this->moduleConfigurationStub, $this->loggerMock, $this->dataStoreMock);
+
+        $this->assertTrue($tracker->needsSetup());
+
+        $tracker->runSetup();
+    }
+
+    public function testGetConnectedServiceProviders(): void
+    {
+        $connectedOrganizationsBagStub = $this->createStub(ConnectedServiceProvider\Bag::class);
+        $this->dataStoreMock->expects($this->once())
+            ->method('getConnectedOrganizations')
+            ->willReturn($connectedOrganizationsBagStub);
+
+        /** @psalm-suppress PossiblyInvalidArgument */
+        $tracker = new Tracker($this->moduleConfigurationStub, $this->loggerMock, $this->dataStoreMock);
+
+        $this->assertInstanceOf(
+            ConnectedServiceProvider\Bag::class,
+            $tracker->getConnectedServiceProviders('test')
+        );
+    }
+
+    public function testGetActivity(): void
+    {
+        $activityBag = $this->createStub(Activity\Bag::class);
+        $this->dataStoreMock->expects($this->once())
+            ->method('getActivity')
+            ->willReturn($activityBag);
+
+        $tracker = new Tracker($this->moduleConfigurationStub, $this->loggerMock, $this->dataStoreMock);
+
+        $this->assertInstanceOf(
+            Activity\Bag::class,
+            $tracker->getActivity('test')
+        );
+    }
+}
diff --git a/tests/src/Trackers/Builders/AuthenticationDataTrackerBuilderTest.php b/tests/src/Trackers/Builders/AuthenticationDataTrackerBuilderTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..6fd033f2cede8c4c430ee49c18c4dcd2d0a96003
--- /dev/null
+++ b/tests/src/Trackers/Builders/AuthenticationDataTrackerBuilderTest.php
@@ -0,0 +1,96 @@
+<?php
+
+declare(strict_types=1);
+
+namespace SimpleSAML\Test\Module\accounting\Trackers\Builders;
+
+use Psr\Log\LoggerInterface;
+use SimpleSAML\Module\accounting\Entities\Authentication\Event;
+use SimpleSAML\Module\accounting\Exceptions\Exception;
+use SimpleSAML\Module\accounting\ModuleConfiguration;
+use SimpleSAML\Module\accounting\Trackers\Builders\AuthenticationDataTrackerBuilder;
+use PHPUnit\Framework\TestCase;
+use SimpleSAML\Module\accounting\Trackers\Interfaces\AuthenticationDataTrackerInterface;
+use SimpleSAML\Test\Module\accounting\Constants\ConnectionParameters;
+
+/**
+ * @covers \SimpleSAML\Module\accounting\Trackers\Builders\AuthenticationDataTrackerBuilder
+ * @uses \SimpleSAML\Module\accounting\Helpers\InstanceBuilderUsingModuleConfigurationHelper
+ *
+ * @psalm-suppress all
+ */
+class AuthenticationDataTrackerBuilderTest extends TestCase
+{
+    /**
+     * @var \PHPUnit\Framework\MockObject\MockObject|LoggerInterface|LoggerInterface&\PHPUnit\Framework\MockObject\MockObject
+     */
+    protected $loggerMock;
+    /**
+     * @var \PHPUnit\Framework\MockObject\Stub|ModuleConfiguration|ModuleConfiguration&\PHPUnit\Framework\MockObject\Stub
+     */
+    protected $moduleConfigurationStub;
+
+    protected AuthenticationDataTrackerInterface $trackerStub;
+
+    protected function setUp(): void
+    {
+        $this->moduleConfigurationStub = $this->createStub(ModuleConfiguration::class);
+        $this->moduleConfigurationStub->method('getConnectionParameters')
+            ->willReturn(ConnectionParameters::DBAL_SQLITE_MEMORY);
+        $this->loggerMock = $this->createMock(LoggerInterface::class);
+
+        $this->trackerStub = new class implements AuthenticationDataTrackerInterface {
+            public static function build(
+                ModuleConfiguration $moduleConfiguration,
+                LoggerInterface $logger
+            ): AuthenticationDataTrackerInterface {
+                return new self();
+            }
+
+            public function process(Event $authenticationEvent): void
+            {
+            }
+
+            public function needsSetup(): bool
+            {
+                return false;
+            }
+
+            public function runSetup(): void
+            {
+            }
+        };
+    }
+
+    public function testCanCreateInstance(): void
+    {
+        $this->assertInstanceOf(
+            AuthenticationDataTrackerBuilder::class,
+            new AuthenticationDataTrackerBuilder($this->moduleConfigurationStub, $this->loggerMock)
+        );
+    }
+
+    public function testCanBuildAuthenticationDataTracker(): void
+    {
+        $authenticationDataTrackerBuilder = new AuthenticationDataTrackerBuilder(
+            $this->moduleConfigurationStub,
+            $this->loggerMock
+        );
+
+        $trackerClass = get_class($this->trackerStub);
+
+        $this->assertInstanceOf($trackerClass, $authenticationDataTrackerBuilder->build($trackerClass));
+    }
+
+    public function testBuildThrowsForInvalidTrackerClass(): void
+    {
+        $authenticationDataTrackerBuilder = new AuthenticationDataTrackerBuilder(
+            $this->moduleConfigurationStub,
+            $this->loggerMock
+        );
+
+        $this->expectException(Exception::class);
+
+        $authenticationDataTrackerBuilder->build('invalid');
+    }
+}