diff --git a/FEATURES/98009 b/FEATURES/98009
new file mode 100644
index 0000000000000000000000000000000000000000..54f6ecbdba06d2f192d9c76d448b8e0cf016a403
--- /dev/null
+++ b/FEATURES/98009
@@ -0,0 +1,10 @@
+        '98009' =>
+            ['Label' => $this->_('Intégration des Types d\'abonnements avec les dates d\'abonnements en provenance de l\'étalon Nanook et dans les critères de filtre utilisateurs'),
+             'Desc' => '',
+             'Image' => '',
+             'Video' => '',
+             'Category' => '',
+             'Right' => function($feature_description, $user) {return true;},
+             'Wiki' => '',
+             'Test' => '',
+             'Date' => '2023-01-06'],
\ No newline at end of file
diff --git a/VERSIONS_WIP/98009 b/VERSIONS_WIP/98009
new file mode 100644
index 0000000000000000000000000000000000000000..91f616f3eef389a91258e238f2df8e52b7b0371e
--- /dev/null
+++ b/VERSIONS_WIP/98009
@@ -0,0 +1 @@
+ - fonctionnalité #98009 : Administration Utilisateurs : gestion des critères de tarifs présents dans l'étalon Nanook
\ No newline at end of file
diff --git a/cosmogramme/cosmozend/application/modules/cosmo/controllers/MembershipController.php b/cosmogramme/cosmozend/application/modules/cosmo/controllers/MembershipController.php
new file mode 100644
index 0000000000000000000000000000000000000000..ed83ed8ae39cf61f8f526c7bb61ea83cd7edb229
--- /dev/null
+++ b/cosmogramme/cosmozend/application/modules/cosmo/controllers/MembershipController.php
@@ -0,0 +1,27 @@
+<?php
+/**
+ * Copyright (c) 2012-2017, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class Cosmo_MembershipController extends ZendAfi_Controller_Action {
+  public function getPlugins() :array {
+    return [ZendAfi_Controller_Plugin_ResourceDefinition_Membership::class];
+  }
+}
diff --git a/cosmogramme/cosmozend/application/modules/cosmo/views/scripts/membership/index.phtml b/cosmogramme/cosmozend/application/modules/cosmo/views/scripts/membership/index.phtml
new file mode 100644
index 0000000000000000000000000000000000000000..e55db27f575d0ff61962ae0e0e817dbf72a3da3a
--- /dev/null
+++ b/cosmogramme/cosmozend/application/modules/cosmo/views/scripts/membership/index.phtml
@@ -0,0 +1,4 @@
+<?php
+echo $this->tag('h1', $this->titre);
+
+echo $this->renderTable(new Class_TableDescription_CosmoMembership('membership'), $this->memberships);
diff --git a/cosmogramme/cosmozend/tests/application/modules/cosmo/controllers/IntegrationControllerTest.php b/cosmogramme/cosmozend/tests/application/modules/cosmo/controllers/IntegrationControllerTest.php
index 89338ca32a335fa7f4edcb10d3d00fa71d0d2162..7804d67d56b3f16d03c1d3e7814f011a2833dd74 100644
--- a/cosmogramme/cosmozend/tests/application/modules/cosmo/controllers/IntegrationControllerTest.php
+++ b/cosmogramme/cosmozend/tests/application/modules/cosmo/controllers/IntegrationControllerTest.php
@@ -217,6 +217,18 @@ abstract class Cosmo_IntegrationControllerGenerateActionTestCase extends CosmoCo
                                  '0|2|Bande dessinée|f',
                                  '0|3|Biographie|f']))
 
+      ->touch('tarifs.txt')
+      ->filePutContents('tarifs.txt',
+                        implode("\n",
+                                [
+                                 'ID|LIBELLE|ACTIF',
+                                 '0|Tous les sites|1',
+                                 '21|Hem 30 €|1',
+                                 '22|Bibliothécaire 15 €|1',
+                                 '23|Hors Hem 45 €|1',
+                                 '24|Enfant seul Gratuit (ex 515)|1',
+                                 '25|Groupe|1',
+                                 '26|Vacancier 2 €|1']))
       ->cd('/www/monbokeh')
       ;
 
@@ -344,7 +356,7 @@ class Cosmo_IntegrationControllerGenerateActionNanookPostTest
 
   /** @test */
   public function shouldNotHaveError() {
-    $this->assertNotXPath('//font[@color="red"]', $this->_response->getBody());
+    $this->assertNotXPath('//font[@color="red"]');
   }
 
 
@@ -613,6 +625,7 @@ abstract class Cosmo_IntegrationControllerWaitingFilesTestCase extends CosmoCont
       ->touch('transferts/ccpl34/etalon/annexes.txt')
       ->touch('transferts/ccpl34/etalon/sections.txt')
       ->touch('transferts/ccpl34/etalon/emplacements.txt')
+      ->touch('transferts/ccpl34/etalon/tarifs.txt')
 
 
       ->touch('transferts/ccpl34/lunel/20190116220001_records_tot.mrc',
diff --git a/cosmogramme/cosmozend/tests/application/modules/cosmo/controllers/MembershipControllerTest.php b/cosmogramme/cosmozend/tests/application/modules/cosmo/controllers/MembershipControllerTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..282011a1c07fa71532f1b82d8f7b06e2664df676
--- /dev/null
+++ b/cosmogramme/cosmozend/tests/application/modules/cosmo/controllers/MembershipControllerTest.php
@@ -0,0 +1,79 @@
+<?php
+/**
+ * Copyright (c) 2012-2017, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+abstract class MembershipControllerTestCase extends CosmoControllerTestCase {
+  public function setUp() {
+    parent::setUp();
+
+    $this->fixture(Class_Membership::class,
+                   ['id' => 1,
+                    'code' => 1,
+                    'libelle' => 'Abonnement Abonné Adulte Interne',
+                    'enabled' => 1]);
+
+    $this->fixture(Class_Membership::class,
+                   ['id' => 2,
+                    'code' => 2,
+                    'libelle' => 'Abonnement Abonné Jeune Interne',
+                    'enabled' => 0]);
+  }
+}
+
+
+
+
+class MembershipControllerIndexTest extends MembershipControllerTestCase {
+  public function setUp() {
+    parent::setUp();
+    $this->dispatch('/cosmo/membership');
+  }
+
+
+  /** @test */
+  public function membershipPageTitleShouldContainsListeDesMemberships() {
+    $this->assertXPathContentContains('//h1', 'Parcourir les types d\'abonnement');
+  }
+
+
+  /** @test */
+  public function firstMembershipLineFirstColumnShouldContains1() {
+    $this->assertXPathContentContains('//table//tr[1]//td[1]', '1');
+  }
+
+
+  /** @test */
+  public function firstmembershipLineSecondColumnShouldContainsMembershipAbonneAdulteInterne() {
+    $this->assertXPathContentContains('//table//tr[1]//td[2]', 'Abonnement Abonné Adulte Interne');
+  }
+
+
+  /** @test */
+  public function firstmembershipLineThirdColumnShouldContains1() {
+    $this->assertXPathContentContains('//table//tr[1]//td[3]', '1');
+  }
+
+
+  /** @test */
+  public function secondMembershipLineSecondColumnShouldContainsAbonneJeuneInterne() {
+    $this->assertXPathContentContains('//table//tr[2]//td', 'Abonnement Abonné Jeune Interne');
+  }
+}
diff --git a/cosmogramme/php/_menu.php b/cosmogramme/php/_menu.php
index a64b70cb57f66500a59c2c942813ed66dfe400bc..2337aa82d19eb9c938b356599942d53aa2a6f64a 100644
--- a/cosmogramme/php/_menu.php
+++ b/cosmogramme/php/_menu.php
@@ -96,6 +96,7 @@ else
 	?>
 	<div class="menu_section">Autorités et codifications</div>
 	<?php
+	ligneMenu("Types d'abonnement","../cosmozend/cosmo/membership");
 	ligneMenu("Sections","../cosmozend/cosmo/section");
 	ligneMenu("Genres","../cosmozend/cosmo/genre");
 	ligneMenu("Emplacements", '../cosmozend/cosmo/emplacement');
diff --git a/cosmogramme/sql/patch/patch_447.php b/cosmogramme/sql/patch/patch_447.php
new file mode 100644
index 0000000000000000000000000000000000000000..b5dfbde253279492a02cd7bf4d46b88f327e5e16
--- /dev/null
+++ b/cosmogramme/sql/patch/patch_447.php
@@ -0,0 +1,42 @@
+<?php
+
+(new Class_Migration_AbonnementsUtilisateurs())->run();
+
+$adapter = Zend_Db_Table_Abstract::getDefaultAdapter();
+
+try {
+  $adapter->query('CREATE TABLE if not exists `membership` ( '
+                  . '`id` int(11) unsigned not null auto_increment,'
+                  . '`code` varchar(255) not null,'
+                  . '`libelle` varchar(255) not null default \'\','
+                  . '`enabled` boolean default 0,'
+                  . '`date_created` datetime not null default current_timestamp,'
+                  . '`date_maj` datetime null default null,'
+                  . 'primary key (`id`),'
+                  . 'unique key (`code`),'
+                  . 'key (`libelle`)'
+                  . ') engine=MyISAM default charset=utf8');
+}
+catch(Exception $e){
+}
+
+try{
+  $adapter
+    ->query('CREATE TABLE if not exists `user_membership` ( '
+            . '`id` int(11) unsigned not null auto_increment primary key,'
+            . '`user_id` int(11) unsigned not null DEFAULT 0,'
+            . '`membership_id` int(11) unsigned not null DEFAULT 0,'
+            . '`start_date` date not null,'
+            . '`end_date` date not null'
+            . ') engine=MyISAM default charset=utf8');
+}
+catch(Exception $e){
+}
+
+
+try {
+  $adapter->query('drop TABLE if  exists `tarif`;');
+  $adapter->query('drop TABLE if  exists `user_tarif`;');
+}
+catch(Exception $e){
+}
diff --git a/cosmogramme/tests/php/classes/AbonneIntegrationTest.php b/cosmogramme/tests/php/classes/AbonneIntegrationTest.php
index 28be431c49df41c40b0a92632810320692135ade..4f92d79e6f5170919b69a32686524515df2a96d9 100644
--- a/cosmogramme/tests/php/classes/AbonneIntegrationTest.php
+++ b/cosmogramme/tests/php/classes/AbonneIntegrationTest.php
@@ -34,7 +34,8 @@ abstract class AbonneIntegrationTestCase extends ModelTestCase {
                    ['id' => 2,
                     'id_bib' => 2]);
 
-    $this->abon_config = new Class_Cosmogramme_Integration_Record_Patron($this->getIntegration());
+    $this->abon_config = new Class_Cosmogramme_Integration_Record_Patron($this->getIntegration(),
+                                                                         fn($message) =>  $message);
   }
 
   public function getIntegration() {
diff --git a/library/Class/Bib/PortalBorrowers.php b/library/Class/Bib/PortalBorrowers.php
index 2d1c4672738565cbb4dddaa235c74d9e1ecfa593..0ea3c67c7855e26c07c5d0880bb2f859eb3d0cdd 100644
--- a/library/Class/Bib/PortalBorrowers.php
+++ b/library/Class/Bib/PortalBorrowers.php
@@ -23,8 +23,6 @@
 class Class_Bib_PortalBorrowers {
   use Trait_Translator, Trait_TimeSource, Trait_GetterByAttributeName, Trait_Loggable;
 
-  const FR_DATE_FORMAT = 'd/m/Y';
-  const SQL_DATE_FORMAT = 'Y-m-d';
   const ACCEPT_TOKEN = 'oui';
   const DENY_TOKEN = 'non';
 
@@ -298,7 +296,7 @@ class Class_Bib_PortalBorrowers {
 
     $date_fin = $this->getTimeSource()->asDateTime()
                      ->modify('+' . $this->_subscriptionDelay(). ' days');
-    $user->setDateFin($date_fin->format(static::SQL_DATE_FORMAT));
+    $user->setDateFin($date_fin->format(Class_TimeSource::SQL_DATE_FORMAT));
 
     if ($line[static::BIRTH_POS]) {
       $naissance = $this->_dateFrToSql($line[static::BIRTH_POS]);
@@ -315,8 +313,8 @@ class Class_Bib_PortalBorrowers {
 
 
   protected function _dateFrToSql(string $date) : string {
-    return DateTime::createFromFormat(static::FR_DATE_FORMAT, $date)
-      ->format(static::SQL_DATE_FORMAT);
+    return DateTime::createFromFormat(Class_TimeSource::FR_DATE_FORMAT, $date)
+      ->format(Class_TimeSource::SQL_DATE_FORMAT);
   }
 
 
@@ -341,7 +339,7 @@ class Class_Bib_PortalBorrowers {
     if ( ! trim($birthdate))
       return $errors;
 
-    if ( ! $this->_isDateFormatValid($birthdate))
+    if ( ! $this->_isDateFormatValid($birthdate, Class_TimeSource::FR_DATE_FORMAT))
       $errors [] = $this->_('la colonne "Date de naissance (format jj/mm/aaaa)" doit être valide et au format jj/mm/aaaa.');
 
     return $errors;
@@ -365,12 +363,6 @@ class Class_Bib_PortalBorrowers {
   }
 
 
-  protected function _isDateFormatValid(string $date) : bool {
-    $d = DateTime::createFromFormat(static::FR_DATE_FORMAT, $date);
-    return $d && $d->format(static::FR_DATE_FORMAT) == $date;
-  }
-
-
   protected function _isInternalIdValid(string $id, array $errors) : array {
     if ( ! trim($id)) {
       $errors [] = $this->_('la colonne "ID Bokeh (ne pas modifier !)" NE DOIT PAS ÊTRE MODIFIÉE.');
@@ -443,7 +435,7 @@ class Class_Bib_PortalBorrowers {
   public function getUserBirthdate(Class_Users $user) : string {
     // guard against SQL default date value
     return (($date = $user->getNaissance()) && '0000-00-00' !== $date)
-      ? date(static::FR_DATE_FORMAT, strtotime($date))
+      ? date(Class_TimeSource::FR_DATE_FORMAT, strtotime($date))
       : '';
   }
 
diff --git a/library/Class/Cosmogramme/Generator.php b/library/Class/Cosmogramme/Generator.php
index 484b1d4a27540c37c28b52b0467d6f86f57bf221..cab4f858498ae9a51c68531b15ed1cedb251729a 100644
--- a/library/Class/Cosmogramme/Generator.php
+++ b/library/Class/Cosmogramme/Generator.php
@@ -42,7 +42,8 @@ class Class_Cosmogramme_Generator {
       && $this->_generateSections()
       && $this->_generateLocations()
       && $this->_generateKinds()
-      && $this->_generateDewey();
+      && $this->_generateDewey()
+      && $this->_generateMemberships();
 
     $this->log('<br><h2>'. $this->_('Traitement terminé.') .'</h2>');
 
@@ -146,6 +147,12 @@ class Class_Cosmogramme_Generator {
   }
 
 
+  protected function _generateMemberships() :bool {
+    $memberships = $this->getLandingDirectory()->getMembershipsOf($this->_params['path_ftp']);
+    return (new Class_Cosmogramme_Generator_MembershipsTask($this, $this->_params))->run($memberships);
+  }
+
+
   protected function _generateDewey() {
     $this->logTitle($this->_('7 - Création des classes Dewey'));
     if ($this->isUpdate()) {
@@ -290,4 +297,4 @@ class Class_Cosmogramme_GeneratorProfilesPergameValidator
            [102, 'Prêts Pergame', Class_IntProfilDonnees::FT_LOANS],
            [103, 'Réservations Pergame', Class_IntProfilDonnees::FT_HOLDS]];
   }
-}
\ No newline at end of file
+}
diff --git a/library/Class/Cosmogramme/Generator/AbstractTask.php b/library/Class/Cosmogramme/Generator/AbstractTask.php
index ca88a3596497dc07ab3117a36e36018e61e35782..6a46ba252317efb22ec6bdba67bbd102375171ae 100644
--- a/library/Class/Cosmogramme/Generator/AbstractTask.php
+++ b/library/Class/Cosmogramme/Generator/AbstractTask.php
@@ -47,7 +47,7 @@ abstract class Class_Cosmogramme_Generator_AbstractTask {
   }
 
 
-  public function run($datas) {
+  public function run($datas) :bool {
     if (!$datas) {
       $this->logError($this->_('Étalon des %s vide', $this->_name));
       return false;
@@ -62,7 +62,7 @@ abstract class Class_Cosmogramme_Generator_AbstractTask {
     foreach($datas as $data)
       $html .= $this->_runOne($data);
 
-    $this->log('<div class="liste"><table class="blank">' . $html . '</table></div>');
+    $this->log('<div class="liste"><table class="blank"><caption>'.$this->_name.'</caption>'.  $html . '</table></div>');
     $this->_removeDeleted();
 
     return true;
@@ -89,7 +89,7 @@ abstract class Class_Cosmogramme_Generator_AbstractTask {
   }
 
 
-  protected function _getOrCreate($elems) {
+  protected function _getOrCreate(array $elems) : object {
     $model_class = $this->_model_class;
     $label = $this->getLabel($elems);
     $code = $this->getCode($elems);
@@ -119,12 +119,12 @@ abstract class Class_Cosmogramme_Generator_AbstractTask {
   }
 
 
-  protected function getLabel($elems) {
+  protected function getLabel(array $elems) :string {
     return trim($elems[1]);
   }
 
 
-  protected function getCode($elems) {
+  protected function getCode(array $elems) :string {
     return strtolower($elems[0]);
   }
 
@@ -217,7 +217,7 @@ abstract class Class_Cosmogramme_Generator_AbstractTask {
   }
 
 
-  protected function _prepare($datas) {
+  protected function _prepare(array $datas) :array {
     return $datas;
   }
 
@@ -237,4 +237,4 @@ abstract class Class_Cosmogramme_Generator_AbstractTask {
       ? static::$_db_adapter
       : Zend_Db_Table::getDefaultAdapter();
   }
-}
\ No newline at end of file
+}
diff --git a/library/Class/Cosmogramme/Generator/BranchesTask.php b/library/Class/Cosmogramme/Generator/BranchesTask.php
index 44863ef2d57f141d540a97976f5ae393c94560ce..1ba3733b56c785ca54e925fff0a55ce036dd506b 100644
--- a/library/Class/Cosmogramme/Generator/BranchesTask.php
+++ b/library/Class/Cosmogramme/Generator/BranchesTask.php
@@ -29,7 +29,7 @@ class Class_Cosmogramme_Generator_BranchesTask
     $_table = 'codif_annexe',
     $_model_class = 'Class_CodifAnnexe';
 
-  protected function _getOrCreate($elems) {
+  protected function _getOrCreate(array $elems) :object {
     if (!$model = Class_CodifAnnexe::findFirstBy(['id_origine' => $elems[0]]))
       $model = Class_CodifAnnexe::newInstance(['id_bib' => $elems[0],
                                                'id_origine' => $elems[0],
@@ -39,7 +39,7 @@ class Class_Cosmogramme_Generator_BranchesTask
   }
 
 
-  protected function getCode($elems) {
+  protected function getCode(array $elems) :string {
     return $this->_('Site n° %s', $elems[0]);
   }
 
@@ -57,4 +57,4 @@ class Class_Cosmogramme_Generator_BranchesTask
   protected function getItemValueFor($model) {
     return $model->getCode();
   }
-}
\ No newline at end of file
+}
diff --git a/library/Class/Cosmogramme/Generator/KindsTask.php b/library/Class/Cosmogramme/Generator/KindsTask.php
index 78c23611f203aab6fbc1aeb0a973e4f0367d29fa..05cb194c1d3449b09c9551ad883a6cd04c1facac 100644
--- a/library/Class/Cosmogramme/Generator/KindsTask.php
+++ b/library/Class/Cosmogramme/Generator/KindsTask.php
@@ -32,12 +32,12 @@ class Class_Cosmogramme_Generator_KindsTask extends Class_Cosmogramme_Generator_
   }
 
 
-  protected function getLabel($elems) {
+  protected function getLabel(array $elems):string {
     return trim($elems[2]);
   }
 
 
-  protected function getCode($elems) {
+  protected function getCode(array $elems):string {
     return trim($elems[1]);
   }
 
diff --git a/library/Class/Cosmogramme/Generator/LibrariesTask.php b/library/Class/Cosmogramme/Generator/LibrariesTask.php
index 6fc4e48165b853490acb8ba8c0765835bfbae5f5..6274f0ba4e197b4a5945b1434370b94a17d248a0 100644
--- a/library/Class/Cosmogramme/Generator/LibrariesTask.php
+++ b/library/Class/Cosmogramme/Generator/LibrariesTask.php
@@ -30,7 +30,7 @@ class Class_Cosmogramme_Generator_LibrariesTask extends Class_Cosmogramme_Genera
   }
 
 
-  protected function _prepare($libraries) {
+  protected function _prepare(array $libraries) : array {
     $this->_disableDeletedLibraries($libraries);
     return $libraries;
   }
@@ -46,7 +46,7 @@ class Class_Cosmogramme_Generator_LibrariesTask extends Class_Cosmogramme_Genera
   }
 
 
-  protected function _getOrCreate($elems) {
+  protected function _getOrCreate(array $elems) :object {
     if (!$bib = Class_Cosmogramme_Generator_FixedIdBib::findFirstBy(['id_site' => $elems[0]]))
       $bib = Class_Cosmogramme_Generator_FixedIdBib::newInstance(['ville' => $this->_params['path_ftp']]);
 
@@ -115,4 +115,4 @@ class Class_Cosmogramme_Generator_LibrariesTask extends Class_Cosmogramme_Genera
 
   protected function _removeDeleted() {
   }
-}
\ No newline at end of file
+}
diff --git a/library/Class/Cosmogramme/Generator/MembershipsTask.php b/library/Class/Cosmogramme/Generator/MembershipsTask.php
new file mode 100644
index 0000000000000000000000000000000000000000..9c9d6b475ee96539ac0286bb0fbc8d7b66f58e47
--- /dev/null
+++ b/library/Class/Cosmogramme/Generator/MembershipsTask.php
@@ -0,0 +1,68 @@
+<?php
+/**
+ * Copyright (c) 2012-2014, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+class Class_Cosmogramme_Generator_MembershipsTask extends Class_Cosmogramme_Generator_AbstractTask {
+  protected
+    $_index = 8,
+    $_name = 'memberships',
+    $_table = 'membership',
+    $_model_class = Class_Membership::class;
+
+  protected function _prepare(array $datas) :array{
+    array_shift($datas);
+    return $datas;
+  }
+
+
+  protected function getCode(array $elems) :string {
+    return trim($elems[0] ?? '');
+  }
+
+
+  protected function getLabel(array $elems) : string {
+    return trim($elems[1] ?? '');
+  }
+
+
+  protected function getEnabled(array $elems) :string {
+    return trim($elems[2] ?? 0);
+  }
+
+
+  protected function _getOrCreate(array $elems):object {
+    $label = $this->getLabel($elems);
+    $code = $this->getCode($elems);
+
+    $membership = Class_Membership::findOrCreate($code, $label);
+
+    $membership
+      ->setEnabled((bool)$this->getEnabled($elems))
+      ->setDateMaj($this->_date_time);
+
+    return $membership;
+  }
+
+
+  protected function _removeDeleted() {
+    $this
+      ->_directQuery('delete from ' . $this->_table . ' where date(date_maj) != "' . $this->_date . '"');
+  }
+}
diff --git a/library/Class/Cosmogramme/Generator/PlannedIntegrationsTask.php b/library/Class/Cosmogramme/Generator/PlannedIntegrationsTask.php
index e700d9567c7a9ec3f6da3ff562095f819f641b37..970f83112e49b409f6830db738e1d231a9129b43 100644
--- a/library/Class/Cosmogramme/Generator/PlannedIntegrationsTask.php
+++ b/library/Class/Cosmogramme/Generator/PlannedIntegrationsTask.php
@@ -34,7 +34,7 @@ class Class_Cosmogramme_Generator_PlannedIntegrationsTask
   }
 
 
-  protected function _getOrCreate($elems) {
+  protected function _getOrCreate(array $elems):object {
     $id_bib = $elems[0];
     Class_IntMajAuto::deleteBy(['id_bib' => $id_bib]);
 
@@ -244,4 +244,4 @@ class Class_Cosmogramme_GeneratorPlannedPergame extends Class_Cosmogramme_Genera
     $_records_profile = 100,
     $_users_profile = 101,
     $_holds = true;
-}
\ No newline at end of file
+}
diff --git a/library/Class/Cosmogramme/Integration/PhasePatrons.php b/library/Class/Cosmogramme/Integration/PhasePatrons.php
index 8948ad76b923735495130ba2d6cce7f00ee563ee..7f566bd983f69ae8207265693cd5dddb13a9bd26 100644
--- a/library/Class/Cosmogramme/Integration/PhasePatrons.php
+++ b/library/Class/Cosmogramme/Integration/PhasePatrons.php
@@ -38,7 +38,8 @@ class Class_Cosmogramme_Integration_PhasePatrons extends Class_Cosmogramme_Integ
 
 
   public function importPatronRecord($data, $integration) {
-    $patron = new Class_Cosmogramme_Integration_Record_Patron($integration);
+    $patron = new Class_Cosmogramme_Integration_Record_Patron($integration,
+                                                              fn($message) => $this->_logInfo($message));
     return $patron->import($integration->isFormatXml()
                            ? $data
                            : $this->mapRecordColumns($integration, $data),
diff --git a/library/Class/Cosmogramme/Integration/Record/Patron.php b/library/Class/Cosmogramme/Integration/Record/Patron.php
index ec5d2449a05e92230fbcff32603e74c5bb440afb..dc2e30eeccb2e3f00049eaae3a0fbc19c4abcb18 100644
--- a/library/Class/Cosmogramme/Integration/Record/Patron.php
+++ b/library/Class/Cosmogramme/Integration/Record/Patron.php
@@ -21,13 +21,17 @@
 
 
 class Class_Cosmogramme_Integration_Record_Patron {
+  use Trait_TimeSource;
+
   protected
     $_integration,
     $_champs,
+    $_log_callback,
     $_known_fields;
 
-  public function __construct($integration) {
+  public function __construct($integration, Closure $log_callback) {
     $this->_integration = $integration;
+    $this->_log_callback = $log_callback;
     $this->_known_fields = new Class_IntProfilDonnees_PatronFields;
   }
 
@@ -141,12 +145,20 @@ class Class_Cosmogramme_Integration_Record_Patron {
 
 
   protected function saveOrUpdateInDB($data){
+    $subscription = '';
+    if (isset($data[Class_IntProfilDonnees_PatronFields::ABONNEMENTS])) {
+      $subscription = $data[Class_IntProfilDonnees_PatronFields::ABONNEMENTS];
+      unset($data[Class_IntProfilDonnees_PatronFields::ABONNEMENTS]);
+    }
     $new_user = Class_Users::newInstance($data);
     $finder = new Class_User_DoubleFinder($new_user);
     $user = $finder->find()
       ? $finder->getDouble()
       : $new_user;
 
+    if ($subscription)
+      $data = $this->_prepareUserMemberships($subscription, $data, $user);
+
     $user
       ->updateAttributes($data)
       ->setStatut(0)
@@ -156,4 +168,50 @@ class Class_Cosmogramme_Integration_Record_Patron {
 
     return $this;
   }
+
+
+  protected function _prepareUserMemberships(string $subscription,array $data, Class_Users $user) : array {
+
+    $data[Class_IntProfilDonnees_PatronFields::USER_MEMBERSHIPS] = $this->_decodeDataAbonnements($subscription, $user);
+
+    return $data;
+  }
+
+
+  protected function _decodeDataAbonnements(string $data, Class_Users $user) :array {
+    $array_memberships = explode(';',$data);
+    $user_memberships=[];
+
+    foreach (array_chunk($array_memberships,3) as $user_membership_as_array)
+      if ($user_membership_decoded = $this->_decodeUserMembership($user_membership_as_array, $user))
+        $user_memberships[] = $user_membership_decoded;
+
+    return $user_memberships;
+  }
+
+
+  protected function _decodeUserMembership(array $user_membership_array, Class_Users $user) : ?Class_User_Membership {
+    $log_callback = $this->_log_callback;
+
+    if (!$membership = Class_Membership::findFirstBy(['code' => $membership_id = array_shift($user_membership_array)])){
+      $log_callback('membership '.$membership_id.' introuvable dans la table Membership');
+      return null;
+    }
+
+    if (!$this->_isDateFormatValid($date_debut = array_shift($user_membership_array), Class_TimeSource::SQL_DATE_FORMAT)){
+      $log_callback('date de début '. $date_debut .' invalide pour le membership '.$membership_id);
+      return null;
+    }
+
+    if (!$this->_isDateFormatValid($date_fin = array_shift($user_membership_array), Class_TimeSource::SQL_DATE_FORMAT)){
+      $log_callback('date de fin '. $date_fin .' invalide pour le membership '.$membership_id);
+      return null;
+    }
+
+    return Class_User_Membership::findOrCreate( $user,
+                                           $membership,
+                                           $date_debut,
+                                           $date_fin
+    );
+  }
 }
diff --git a/library/Class/Cosmogramme/LandingDirectory.php b/library/Class/Cosmogramme/LandingDirectory.php
index c60e0da9dcd45cb3bac8340011abafa3333235f9..a4caa2e66df4b2aa3cb2c0c10492a51945a32ff0 100644
--- a/library/Class/Cosmogramme/LandingDirectory.php
+++ b/library/Class/Cosmogramme/LandingDirectory.php
@@ -33,7 +33,8 @@ class Class_Cosmogramme_LandingDirectory {
     $_required_files = ['libraries' => 'annexes.txt',
                         'kinds' => 'genres.txt',
                         'sections' => 'sections.txt',
-                        'locations' => 'emplacements.txt'];
+                        'locations' => 'emplacements.txt',
+                        'tarifs' => 'tarifs.txt'];
 
 
   public function __construct() {
@@ -123,26 +124,31 @@ class Class_Cosmogramme_LandingDirectory {
   }
 
 
-  public function getLibrariesOf($subdir) {
+  public function getLibrariesOf(string $subdir) :array {
     return $this->getFileNamed('libraries', $subdir);
   }
 
 
-  public function getSectionsOf($subdir) {
+  public function getSectionsOf(string $subdir) :array {
     return $this->getFileNamed('sections', $subdir);
   }
 
 
-  public function getLocationsOf($subdir) {
+  public function getLocationsOf(string $subdir) :array {
     return $this->getFileNamed('locations', $subdir);
   }
 
 
-  public function getKindsOf($subdir) {
+  public function getKindsOf(string $subdir) :array {
     return $this->getFileNamed('kinds', $subdir);
   }
 
 
+  public function getMembershipsOf(string $subdir) :array {
+    return $this->getFileNamed('tarifs', $subdir);
+  }
+
+
   protected function getFileNamed($name, $subdir) {
     $file = $this->_getStandardFilePath($subdir, $this->_required_files[$name]);
     return $this->getFileSystem()->fileGetContentAsArray($file);
diff --git a/library/Class/IntMajAuto.php b/library/Class/IntMajAuto.php
index 37192eb385911d04f82bb75360722aedbf836ae9..20cc33bbe73a10aa21d1872c3d0c3277eeac93c7 100644
--- a/library/Class/IntMajAuto.php
+++ b/library/Class/IntMajAuto.php
@@ -133,4 +133,15 @@ class Class_IntMajAuto extends Storm_Model_Abstract {
       ? Class_IntProfilDonnees_OaiFormats::metadataFormatFor($this)
       : '';
   }
+
+
+  public static function getPatronConfigurations() {
+    return array_unique((new Storm_Collection(Class_IntMajAuto::findAllBy([])))
+                        ->select( fn($maj_auto)
+                                  => $maj_auto->getIntBib()->isNanook()
+                                  && (Class_IntProfilDonnees::FT_PATRONS == $maj_auto->getProfilDonnees()->getTypeFichier())
+                        )
+                        ->collect(fn($maj_auto) => $maj_auto->getProfilDonnees())
+                        ->getArrayCopy());
+  }
 }
diff --git a/library/Class/IntProfilDonnees.php b/library/Class/IntProfilDonnees.php
index de194ff6d3773db3abf8093c9d5245cb57ad5c03..e5ac320ac45ebb0c088dadd2d3eacc96cafde34d 100644
--- a/library/Class/IntProfilDonnees.php
+++ b/library/Class/IntProfilDonnees.php
@@ -1417,4 +1417,12 @@ class Class_IntProfilDonnees extends Storm_Model_Abstract {
   public function isAsciiDosEncoded() {
     return static::ENCODING_ASCII_DOS === $this->getAccents();
   }
+
+
+  public function getAttributsAsArray() :array {
+    $a = unserialize($this->getAttributs());
+    if (!is_array($a))
+      return [];
+    return $a;
+  }
 }
diff --git a/library/Class/IntProfilDonnees/PatronFields.php b/library/Class/IntProfilDonnees/PatronFields.php
index e52a2a101c4e8469dd6c11555986ce567c844b81..1b368c4bd951ef8a9b981a5eaa08283f8f7cfabc 100644
--- a/library/Class/IntProfilDonnees/PatronFields.php
+++ b/library/Class/IntProfilDonnees/PatronFields.php
@@ -36,6 +36,8 @@ class Class_IntProfilDonnees_PatronFields {
     ID_IN_ILS = 'ID_SIGB',
     CARD_NUMBER = 'NUM_CARTE',
     LIBRARY_CODE = 'LIBRARY_CODE',
+    ABONNEMENTS = 'ABONNEMENTS',
+    USER_MEMBERSHIPS = 'USER_MEMBERSHIPS',
     IGNORE = 'NULL';
 
   public function options() {
@@ -51,6 +53,7 @@ class Class_IntProfilDonnees_PatronFields {
             static::ID_IN_ILS => $this->_('Identifiant interne dans le sigb'),
             static::CARD_NUMBER => $this->_('Numéro de carte (si différent id abonné)'),
             static::LIBRARY_CODE => $this->_('Code de la bibliothèque / annexe de rattachement'),
+            static::ABONNEMENTS => $this->_('Liste des abonnements souscrits par l\'utilisateur'),
             static::IGNORE => $this->_('ignorer ce champ')];
   }
 
diff --git a/library/Class/Membership.php b/library/Class/Membership.php
new file mode 100644
index 0000000000000000000000000000000000000000..f4c435f84b5d3cc7e53c43b5abcf5a60bd050181
--- /dev/null
+++ b/library/Class/Membership.php
@@ -0,0 +1,71 @@
+<?php
+/**
+ * Copyright (c) 2012-2022, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class MembershipLoader extends Storm_Model_Loader {
+  protected
+    $_used_memberships_cache;
+
+  public function findOrCreate(string $code,
+                               string $libelle) : Class_Membership {
+    $params = ['code' => $code ?? 0];
+    if ($libelle)
+      $params['libelle'] = $libelle;
+
+    return Class_Membership::findFirstBy($params) ?? Class_Membership::newInstance($params);
+  }
+
+
+  public function getMultiOptions() :array{
+    $options = [];
+    if (!$memberships = Class_Membership::findAllBy(['order' => 'libelle']))
+      return [];
+
+    foreach ($memberships as $type_abonnement)
+      $options[$type_abonnement->getId()] = $type_abonnement->getLibelle();
+
+    return $options;
+  }
+}
+
+
+
+
+class Class_Membership extends Storm_Model_Abstract {
+  protected
+    $_table_name = 'membership',
+    $_loader_class = 'MembershipLoader',
+
+    $_has_many =[
+                 'user_memberships' => ['model' => Class_User_Membership::class,
+                                   'role' => 'membership',
+                                   'dependents' => 'delete'],
+
+                 'users' => ['through' => 'user_memberships'],
+    ],
+
+
+    $_default_attribute_values = ['id' => 0,
+                                  'code' => '',
+                                  'libelle' => '',
+                                  'enabled' => 0,
+                                  'date_maj' => ''];
+}
diff --git a/library/Class/Migration/AbonnementsUtilisateurs.php b/library/Class/Migration/AbonnementsUtilisateurs.php
new file mode 100644
index 0000000000000000000000000000000000000000..ae2f3715fc200d214d9bd9c7575e8d771fcb3f1d
--- /dev/null
+++ b/library/Class/Migration/AbonnementsUtilisateurs.php
@@ -0,0 +1,55 @@
+<?php
+/**
+ * Copyright (c) 2012-2022, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class Class_Migration_AbonnementsUtilisateurs {
+  public function run() :string {
+    if (!Class_IntBib::isSingleNanook())
+      return '';
+
+    $count=0;
+    foreach (Class_IntMajAuto::getPatronConfigurations() as $configuration){
+      $this->_processAttributs($configuration);
+      $count++;
+    }
+    return $count. " configurations modifiées";
+  }
+
+  protected function _processAttributs($configuration){
+    $attributs = unserialize($configuration->getAttributs());
+
+    $edited_attr = array_map(function($attribute){
+      return (is_array($attribute) && isset($attribute['champs']))
+        ? $this->_addAbonnement($attribute)
+        : $attribute;
+    },
+                             $attributs);
+    $configuration->setAttributs($edited_attr);
+    $configuration->save();
+  }
+
+  protected function _addAbonnement($attribute){
+    if (false === strpos($attribute['champs'], ";ABONNEMENTS" ))
+       $attribute['champs'] .= ";ABONNEMENTS" ;
+    return $attribute;
+  }
+
+}
diff --git a/library/Class/RendezVous/SearchCriteria/Location.php b/library/Class/RendezVous/SearchCriteria/Location.php
index 50036b0aef53f417b19650bb41bba940d62b200f..f94f7adc1cba71251107981c20eceeee6735dafc 100644
--- a/library/Class/RendezVous/SearchCriteria/Location.php
+++ b/library/Class/RendezVous/SearchCriteria/Location.php
@@ -37,15 +37,16 @@ class Class_RendezVous_SearchCriteria_Location extends Class_SearchCriteria_Abst
   }
 
 
-  public function acceptSearchVisitor($visitor) {
+  public function acceptSearchVisitor($visitor) :Class_SearchCriteria_Abstract {
     if (!$this->_element->isValid($this->_value))
-      return;
+      return $this;
 
     if (static::NONE == $this->_value) {
       $visitor->addWhereParam('location_id is null or location_id=0');
-      return;
+      return $this;
     }
 
     parent::acceptSearchVisitor($visitor);
+    return $this;
   }
 }
diff --git a/library/Class/SearchCriteria.php b/library/Class/SearchCriteria.php
index 01f9915ba840574ac0968d10b029057088e9eb45..52d0b398285ed33e49381e40b23cfdfe1f675b0c 100644
--- a/library/Class/SearchCriteria.php
+++ b/library/Class/SearchCriteria.php
@@ -20,7 +20,7 @@
  */
 
 
-abstract class Class_SearchCriteria {
+abstract class Class_SearchCriteria  {
   use Trait_Translator;
   const PAGE_NO_LIMIT = -1;
 
@@ -198,7 +198,7 @@ abstract class Class_SearchCriteria {
   }
 
 
-  public function modelMatch($model) {
+  public function modelMatch($model) :bool {
     return (new Storm_Collection($this->_criteria))
       ->detect(function($each) use($model)
                {
diff --git a/library/Class/SearchCriteria/Abstract.php b/library/Class/SearchCriteria/Abstract.php
index dd5a69c2630ba8ed34503cdf1c8f23ce2323cb00..e399644ca2d33db86fb8b89d46748512d3aa0800 100644
--- a/library/Class/SearchCriteria/Abstract.php
+++ b/library/Class/SearchCriteria/Abstract.php
@@ -65,11 +65,12 @@ abstract class Class_SearchCriteria_Abstract {
   }
 
 
-  public function acceptSearchVisitor($visitor) {
+  public function acceptSearchVisitor($visitor) :Class_SearchCriteria_Abstract {
     if ($this->_isAllValues())
-      return;
+      return $this;
 
     $visitor->addParam($this->_name, $this->_value);
+    return $this;
   }
 
 
@@ -77,14 +78,14 @@ abstract class Class_SearchCriteria_Abstract {
   }
 
 
-  public function modelMatch($model) {
+  public function modelMatch($model) :bool {
     return $this->_isAllValues()
       ? true
       : $model->callGetterByAttributeName($this->_name) == $this->_value;
   }
 
 
-  public function shouldFilter($search_criteria) {
+  public function shouldFilter($search_criteria) :bool{
     return true;
   }
 
diff --git a/library/Class/SearchCriteria/CustomField.php b/library/Class/SearchCriteria/CustomField.php
index 2b8e0baeb698fe0dfaaa6c2a701e4c13b64905d8..3f912d915d737ba42fe2a61fa7556742cbb201e7 100644
--- a/library/Class/SearchCriteria/CustomField.php
+++ b/library/Class/SearchCriteria/CustomField.php
@@ -73,19 +73,22 @@ abstract class Class_SearchCriteria_CustomField extends Class_SearchCriteria_Abs
   }
 
 
-  public function acceptSearchVisitor($visitor) {
+  public function acceptSearchVisitor($visitor) :Class_SearchCriteria_Abstract {
     if (!$this->_shouldSearch())
-      return;
+      return $this;
 
     $params = array_merge($this->_findAllByValueParams(),
                           ['custom_field_id' => $this->_field->getId()]);
 
     $values = new Storm_Model_Collection(Class_CustomField_Value::findAllBy($params));
 
-    if (!$values->isEmpty())
-      return $visitor->addWhereParam('id in (' . implode(',', $values->collect('model_id')->getArrayCopy()) . ')');
+    if (!$values->isEmpty()){
+      $visitor->addWhereParam('id in (' . implode(',', $values->collect('model_id')->getArrayCopy()) . ')');
+      return $this;
+    }
 
     $visitor->hasNoResult();
+    return $this;
   }
 
 
diff --git a/library/Class/SearchCriteria/CustomField/DateRange.php b/library/Class/SearchCriteria/CustomField/DateRange.php
index 85f6ff9282c9bcebf925c9bbf13ba1687ecfd906..18e19a555f242f13e3b7b62b8b831ad6a49710c4 100644
--- a/library/Class/SearchCriteria/CustomField/DateRange.php
+++ b/library/Class/SearchCriteria/CustomField/DateRange.php
@@ -55,7 +55,7 @@ class Class_SearchCriteria_CustomField_DateRange extends Class_SearchCriteria_Cu
 
 
 
-  public function addWhereParam($param) {
+  public function addWhereParam($param) : self{
     $this->_where_params[] = str_replace($this->_name, static::CAST_VALUE_TO_DATE, $param);
     return $this;
   }
diff --git a/library/Class/SearchCriteria/DateRange.php b/library/Class/SearchCriteria/DateRange.php
index 703e3f71139ada248ffba33be1383d0e8730389c..907e507ba41fd0b5621d33a5593f91e800db3105 100644
--- a/library/Class/SearchCriteria/DateRange.php
+++ b/library/Class/SearchCriteria/DateRange.php
@@ -32,12 +32,12 @@ class Class_SearchCriteria_DateRange extends Class_SearchCriteria_Range {
   }
 
 
-  public function isValidDate($value) {
+  public function isValidDate($value) :bool {
     return (new ZendAfi_Validate_DateFormat())->isValid($value);
   }
 
 
-  public function modelMatch($model) {
+  public function modelMatch($model) :bool {
     if ($this->_isAllValues())
       return true;
 
@@ -59,7 +59,7 @@ class Class_SearchCriteria_DateRange extends Class_SearchCriteria_Range {
 
 
 
-  public function acceptSearchVisitor($visitor) {
+  public function acceptSearchVisitor($visitor) :Class_SearchCriteria_Abstract {
     if ($this->_value_start)
       $visitor->addWhereParam(sprintf('left(%s, 10) >= "%s"',
                                       $this->_name,
@@ -69,12 +69,13 @@ class Class_SearchCriteria_DateRange extends Class_SearchCriteria_Range {
       $visitor->addWhereParam(sprintf('left(%s, 10) <= "%s"',
                                       $this->_name,
                                       $this->_sqlFormat($this->_value_end)));
+    return $this;
   }
 
 
 
 
-  protected function _filterValue($value) {
+  protected function _filterValue($value)  {
     if ($value === null || $value === '')
       return $value;
 
diff --git a/library/Class/SearchCriteria/MultiCheckbox.php b/library/Class/SearchCriteria/MultiCheckbox.php
index 2c2d28f4bb55f4c624de7f1f15f7d9e0665a0a9d..847094836407679672dbfec80bbc11a9879b94f9 100644
--- a/library/Class/SearchCriteria/MultiCheckbox.php
+++ b/library/Class/SearchCriteria/MultiCheckbox.php
@@ -26,11 +26,12 @@ class Class_SearchCriteria_MultiCheckbox extends Class_SearchCriteria_Abstract {
 
   protected $_value = Class_SearchCriteria_Abstract::ALL_VALUES;
 
-  public function acceptSearchVisitor($visitor) {
+  public function acceptSearchVisitor($visitor) :Class_SearchCriteria_Abstract {
     if (!$this->_value || $this->_isAllValues())
-      return;
+      return $this;
 
-    return $visitor->addWhereParam($this->_matchValuesInField( $this->_name, $this->_value));
+    $visitor->addWhereParam($this->_matchValuesInField( $this->_name, $this->_value));
+    return $this;
   }
 
 
@@ -66,18 +67,12 @@ class Class_SearchCriteria_MultiCheckbox extends Class_SearchCriteria_Abstract {
   }
 
 
-  protected function _getCheckboxName() {
-    return $this->_checkbox_name
-      ? $this->_checkbox_name
-      : $this->_name ;
-  }
-
 
   public function buildElement() {
     return new ZendAfi_Form_Element_CochesSuggestion($this->getName(),
                                                      ['label' => $this->_getLabel(),
                                                       'value' => $this->_value,
                                                       'name' =>  $this->getName(),
-                                                      'rubrique' => $this->_getCheckboxName()]);
+                                                      'rubrique' => $this->_name]);
   }
 }
diff --git a/library/Class/SearchCriteria/NumRange.php b/library/Class/SearchCriteria/NumRange.php
index f7973f58bf09951fc3ba6d34a16a418b323a442a..66dd83c618fcbfedff505a9d2b39e2303661895b 100644
--- a/library/Class/SearchCriteria/NumRange.php
+++ b/library/Class/SearchCriteria/NumRange.php
@@ -57,7 +57,7 @@ class Class_SearchCriteria_NumRange extends Class_SearchCriteria_Range {
   }
 
 
-  public function modelMatch($model) {
+  public function modelMatch($model) :bool {
     if ($this->_isAllValues())
       return true;
 
diff --git a/library/Class/SearchCriteria/Order.php b/library/Class/SearchCriteria/Order.php
index c6e125f8be05373c450f059d7740c0c097597d02..d4930a9c42f630a5dbd74bee0b170cbe468d7ff7 100644
--- a/library/Class/SearchCriteria/Order.php
+++ b/library/Class/SearchCriteria/Order.php
@@ -31,17 +31,18 @@ class Class_SearchCriteria_Order extends Class_SearchCriteria_Abstract {
   }
 
 
-  public function acceptSearchVisitor($visitor) {
+  public function acceptSearchVisitor($visitor) :Class_SearchCriteria_Abstract {
     if (!$this->_value)
-      return;
+      return $this;
 
     $visitor->addParam('order',
                        false !== strpos($this->_value, ',')
                        ? explode(',', $this->_value) : $this->_value);
+    return $this;
   }
 
 
-  public function modelMatch($model) {
+  public function modelMatch($model) :bool {
     return true;
   }
 }
diff --git a/library/Class/SearchCriteria/Range.php b/library/Class/SearchCriteria/Range.php
index e256f7ab46215b5d4e13aa0339bd83f421214e0a..6b74f9e5ff6f0bf486e787abd5aba5d4fe1fc1b0 100644
--- a/library/Class/SearchCriteria/Range.php
+++ b/library/Class/SearchCriteria/Range.php
@@ -80,12 +80,13 @@ class Class_SearchCriteria_Range extends Class_SearchCriteria_Abstract {
   }
 
 
-  public function acceptSearchVisitor($visitor) {
+  public function acceptSearchVisitor($visitor) :Class_SearchCriteria_Abstract {
     if ($this->_value_start)
       $visitor->addWhereParam($this->_name . ' >= "' . $this->_sqlFormat($this->_value_start) . '"');
 
     if ($this->_value_end)
       $visitor->addWhereParam($this->_name . ' <= "' . $this->_sqlFormat($this->_value_end) . '"');
+    return $this;
   }
 
 
diff --git a/library/Class/SearchCriteria/SelectYesNo.php b/library/Class/SearchCriteria/SelectYesNo.php
index 21ea90a5fb7a4d8d9e77fdd012adb06e58a01100..25a940b8b659f8727a4515f3524768a04a2480db 100644
--- a/library/Class/SearchCriteria/SelectYesNo.php
+++ b/library/Class/SearchCriteria/SelectYesNo.php
@@ -34,16 +34,19 @@ class Class_SearchCriteria_SelectYesNo extends Class_SearchCriteria_Select {
   }
 
 
-  public function acceptSearchVisitor($visitor) {
+  public function acceptSearchVisitor($visitor) :Class_SearchCriteria_Abstract {
     if ($this->_isAllValues())
-      return;
+      return $this;
 
-    if (!$ids = call_user_func([$this->_linked_model, 'findAllUserIds']))
-      return $this->_reactToNoLinkedData($visitor);
+    if (!$ids = call_user_func([$this->_linked_model, 'findAllUserIds'])){
+       $this->_reactToNoLinkedData($visitor);
+       return $this;
+    }
 
     $ids = implode(',', $ids);
     $operator = (static::NO == $this->_value) ? 'not in' : 'in';
     $visitor->addWhereParam('id_user ' . $operator . ' (' . $ids .  ')');
+    return $this;
   }
 
 
@@ -54,7 +57,7 @@ class Class_SearchCriteria_SelectYesNo extends Class_SearchCriteria_Select {
   }
 
 
-  public function modelMatch($model) {
+  public function modelMatch($model) :bool {
     if ($this->_isAllValues())
       return true;
 
diff --git a/library/Class/SearchCriteria/TextLike.php b/library/Class/SearchCriteria/TextLike.php
index 0a0209ec48ad257ff891592757f8cecb4be8c12c..9ed264a6adca81ec5366b74003690c87b0044576 100644
--- a/library/Class/SearchCriteria/TextLike.php
+++ b/library/Class/SearchCriteria/TextLike.php
@@ -35,10 +35,11 @@ class Class_SearchCriteria_TextLike extends Class_SearchCriteria_Abstract {
   }
 
 
-  public function acceptSearchVisitor($visitor) {
+  public function acceptSearchVisitor($visitor) :Class_SearchCriteria_Abstract {
     if (!$this->_value)
-      return;
+      return $this;
 
     $visitor->addWhereParam($this->_name . ' like "%' . $this->_value . '%"');
+    return $this;
   }
 }
diff --git a/library/Class/Systeme/ModulesMenu.php b/library/Class/Systeme/ModulesMenu.php
index a16d26bfb24c9dc5b60cd477675cce3a99d251a8..6b253445257465fa6785f4333165bd185c580c39 100644
--- a/library/Class/Systeme/ModulesMenu.php
+++ b/library/Class/Systeme/ModulesMenu.php
@@ -197,10 +197,7 @@ class Class_Systeme_ModulesMenu extends Class_Systeme_ModulesAbstract {
       ->setLabel($entry->getLibelle())
       ->setForm($entry->getForm());
 
-    usort($entries, function($a, $b)
-          {
-            return strtolower($a->getLabel()) > strtolower($b->getLabel());
-          });
+    usort($entries, fn($a, $b) => strtolower($a->getLabel()) <=> strtolower($b->getLabel()));
 
     return $entries;
   }
diff --git a/library/Class/TableDescription/CosmoMembership.php b/library/Class/TableDescription/CosmoMembership.php
new file mode 100644
index 0000000000000000000000000000000000000000..562464c5329a934213bc4022b0b1220053c1525b
--- /dev/null
+++ b/library/Class/TableDescription/CosmoMembership.php
@@ -0,0 +1,29 @@
+<?php
+/**
+ * Copyright (c) 2012-2019, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class Class_TableDescription_CosmoMembership extends Class_TableDescription {
+    public function init() {
+      $this->addColumn($this->_('Code'), 'code')
+           ->addColumn($this->_('Libellé'), 'libelle')
+           ->addColumn($this->_('Actif'), 'enabled');
+  }
+}
diff --git a/library/Class/TimeSource.php b/library/Class/TimeSource.php
index d5563f95be80f188318e80cbcf4f4a0f80f04d1e..8d39e30299c04e1a95a17dbcac4b0b6ed9a576d9 100644
--- a/library/Class/TimeSource.php
+++ b/library/Class/TimeSource.php
@@ -25,6 +25,8 @@
  */
 class Class_TimeSource {
   const DAY_AND_HOURS_FORMAT = 'Y-m-d H:i:s';
+  const FR_DATE_FORMAT='d/m/Y';
+  const SQL_DATE_FORMAT='Y-m-d';
 
   /** @return int */
   public function time() {
diff --git a/library/Class/User/ILSSubscription.php b/library/Class/User/ILSSubscription.php
index 7d4908053fc6601e1b4f08389f6364bea4a5aef7..f3f72a2068eb0229bd339b89ff62ffa86f064c14 100644
--- a/library/Class/User/ILSSubscription.php
+++ b/library/Class/User/ILSSubscription.php
@@ -29,19 +29,64 @@ class Class_User_ILSSubscription {
   }
 
 
-  public function isExpirable() {
+  public static function newFor(object $model):self {
+    if ($model instanceOf Class_User_Membership)
+      return Class_User_ILSSubscriptionUserMembership::newWith($model);
+    return new Class_User_ILSSubscription($model);
+  }
+
+
+  public function getLibelle() :string {
+    return $this->isBlocked()
+      ?  $this->_('bloqué(e)')
+      :  $this->_('abonné(e)');
+  }
+
+
+  public function getDateFin(){
+    return $this->_user->getDateFin();
+  }
+
+
+  public function getBadgeLabel() :string {
+    return '';
+  }
+
+
+  public function validityDate() : string {
+    return (new DateTime($this->getDateFin()))->format($this->_('d/m/Y'));
+  }
+
+
+  public function validityClass() :string {
+    $validity_class = $this->isValid()
+      ? Intonation_Library_Styles::CSS_SUCCESS
+      :  Intonation_Library_Styles::CSS_DANGER;
+
+    return ($this->isAboutToExpire())
+      ?  Intonation_Library_Styles::CSS_WARNING
+      : $validity_class;
+  }
+
+
+  public function isDisplayed() :bool {
+    return true;
+  }
+
+
+  public function isExpirable() :bool {
     return $this->_user->isAbonne() && $this->_user->hasDateFin();
   }
 
 
-  public function isExpired() {
+  public function isExpired() :bool {
     return
       $this->isExpirable()
-      && ($this->_user->getDateFin() < $this->getTimeSource()->dateYmd());
+      && ($this->getDateFin() < $this->getTimeSource()->dateYmd());
   }
 
 
-  public function isAboutToExpire() {
+  public function isAboutToExpire() :bool {
     return
       $this->isExpirable()
       && ($this->ilsExpireIn() >= 0)
@@ -49,14 +94,14 @@ class Class_User_ILSSubscription {
   }
 
 
-  public function ilsExpireIn() {
+  public function ilsExpireIn() :string {
     $date_expiry = new DateTime($this->_user->getDateFin());
     $today = new DateTime($this->getTimeSource()->dateYmd());
     return $today->diff($date_expiry)->format("%r%a");
   }
 
 
-  public function isValid() {
+  public function isValid() :bool {
     if (!$this->_user->isAbonne())
       return false;
 
@@ -64,7 +109,7 @@ class Class_User_ILSSubscription {
   }
 
 
-  public function isBlocked() {
+  public function isBlocked() :bool {
     return $this->_user->isAbonne() && $this->_user->isBlocked();
   }
 
@@ -153,3 +198,53 @@ class Class_User_ILSSubscription {
     return $this;
   }
 }
+
+
+
+class Class_User_ILSSubscriptionUserMembership extends Class_User_ILSSubscription {
+  protected $_user_membership;
+
+
+  public static function newWith(Class_User_Membership $user_membership) :self{
+    return (new Class_User_ILSSubscriptionUserMembership($user_membership->getUser()))
+      ->setUserMembership($user_membership);
+  }
+
+  public function setUserMembership(Class_User_Membership $user_membership):self{
+    $this->_user_membership =$user_membership;
+    return $this;
+  }
+
+
+  public function getLibelle():string{
+    return $this->_user_membership->getMembershipLibelle();
+  }
+
+
+  public function getDateFin():string{
+    return $this->_user_membership->getEndDate();
+  }
+
+
+  public function getBadgeLabel():string{
+    return $this->_user_membership->getMembershipLibelle().' ';
+  }
+
+
+  public function isDisplayed():bool{
+    return (sizeOf($this->_user->getUserMemberships())==1)
+      || $this->isValid();
+  }
+
+
+  public function isExpirable() :bool{
+    return $this->_user->isAbonne() && $this->getDateFin();
+  }
+
+
+  public function ilsExpireIn() :string{
+    $date_expiry = new DateTime($this->_user_membership->getEndDate());
+    $today = new DateTime($this->getTimeSource()->dateYmd());
+    return $today->diff($date_expiry)->format("%r%a");
+  }
+}
diff --git a/library/Class/User/Membership.php b/library/Class/User/Membership.php
new file mode 100644
index 0000000000000000000000000000000000000000..824cfc700488ab6bb4676af2788607e9f5221cee
--- /dev/null
+++ b/library/Class/User/Membership.php
@@ -0,0 +1,78 @@
+<?php
+/**
+ * Copyright (c) 2012-2022, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+class User_MembershipLoader extends Storm_Model_Loader {
+  public function findOrCreate(Class_Users $user,
+                               Class_Membership $membership,
+                               string $start_date,
+                               string $end_date) : Class_User_Membership {
+    $params = ['start_date' => $start_date,
+               'end_date' => $end_date,
+               'membership_id' => $membership->getId() ?? 0,
+               'user_id' => $user->getId() ?? 0];
+
+    return Class_User_Membership::findFirstBy($params) ?? Class_User_Membership::newInstance($params);
+  }
+
+
+  public function findUserIdWithCriteria($where){
+    return Zend_Registry::get('sql')->fetchAllByColumn(sprintf('select user_id, max(end_date) from user_membership group by user_id having %s', $where));
+  }
+
+
+  public function isUserMembershipContext() : bool{
+    return Class_IntBib::isSingleNanook() && (Class_User_Membership::count() >0);
+  }
+}
+
+
+
+class Class_User_Membership extends Storm_Model_Abstract {
+  use Trait_Timesource;
+
+  protected
+    $_table_name = 'user_membership',
+    $_loader_class = 'User_MembershipLoader',
+
+    $_belongs_to = ['user' => ['model' => Class_Users::class,
+                               'referenced_in' => 'user_id'],
+                    'membership' => ['model' => Class_Membership::class,
+                                'referenced_in' => 'membership_id']
+    ],
+
+    $_default_attribute_values = ['id' => 0,
+                                  'user_id' => 0,
+                                  'membership_id' => 0,
+                                  'start_date' => '',
+                                  'end_date' => ''];
+
+  public function getMembershipLibelle() :string{
+    if ( $membership=$this->getMembership() )
+      return $this->getMembership()->getLibelle() ?? '';
+    return '';
+  }
+
+
+  public function isValidSubscription() : bool {
+    return (!$this->getEndDate()
+            || ($this->getCurrentDate() <= $this->getEndDate()));
+  }
+}
diff --git a/library/Class/User/SearchCriteria.php b/library/Class/User/SearchCriteria.php
index b7d739a5a55cf87efaaae7236eb6de38a6ab3d55..095b6c3b193f782323aede55df9725bc4185eb74 100644
--- a/library/Class/User/SearchCriteria.php
+++ b/library/Class/User/SearchCriteria.php
@@ -21,27 +21,29 @@
 
 
 class Class_User_SearchCriteria extends Class_SearchCriteria {
-  protected $_model_class = 'Class_Users';
+  protected $_model_class = Class_Users::class;
 
 
   public function __construct($params) {
-    $this->_criteria = array_filter([new Class_User_SearchCriteriaLibrary($params),
-                                     new Class_User_SearchCriteria_RoleLevelLimited($params),
-                                     new Class_User_SearchCriteriaValidSubscription($params),
-                                     new Class_User_SearchCriteria_EndSubscriptionDate($params),
-                                     new Class_User_SearchCriteria_DateFin($params),
-                                     new Class_User_SearchCriteria_InLastSigbExport($params),
-                                     new Class_User_SearchCriteria_DateMaj($params),
-                                     Class_AdminVar::get('ENABLED_SEARCH_USER_AGE')
-                                     ? new Class_User_SearchCriteria_Age($params)
-                                     : null,
-                                     new Class_User_SearchCriteria_LastLogin($params),
-                                     new Class_User_SearchCriteria_NeverLogged($params),
-                                     new Class_User_SearchCriteria_NumberOfReviews($params),
-                                     new Class_User_SearchCriteria_NumberOfBaskets($params),
-                                     new Class_User_SearchCriteriaSearchFor($params),
-                                     new Class_User_SearchCriteria_Order($params)]
-    );
+    $this->_criteria =
+      array_filter([new Class_User_SearchCriteriaLibrary($params),
+                    new Class_User_SearchCriteria_RoleLevelLimited($params),
+                    Class_User_SearchCriteriaValidSubscription::newFor($params),
+                    Class_User_SearchCriteria_EndSubscriptionDate::newFor($params),
+                    Class_User_SearchCriteria_DateFin::newFor($params),
+                    new Class_User_SearchCriteria_Membership($params),
+                    new Class_User_SearchCriteria_InLastSigbExport($params),
+                    new Class_User_SearchCriteria_DateMaj($params),
+                    Class_AdminVar::get('ENABLED_SEARCH_USER_AGE')
+                    ? new Class_User_SearchCriteria_Age($params)
+                    : null,
+                    new Class_User_SearchCriteria_LastLogin($params),
+                    new Class_User_SearchCriteria_NeverLogged($params),
+                    new Class_User_SearchCriteria_NumberOfReviews($params),
+                    new Class_User_SearchCriteria_NumberOfBaskets($params),
+                    new Class_User_SearchCriteriaSearchFor($params),
+                    new Class_User_SearchCriteria_Order($params)]
+      );
   }
 }
 
@@ -68,6 +70,13 @@ class Class_User_SearchCriteriaValidSubscription
     $_name = 'valid_subscription',
     $_value = 0;
 
+
+  public static function newFor($params){
+    return (Class_User_Membership::isUserMembershipContext())
+      ? Class_User_SearchCriteriaValidSubscriptionUserMembership::newFor($params)
+      : Class_User_SearchCriteriaValidSubscriptionUser::newFor($params);
+  }
+
   public function buildElement() {
     return new Zend_Form_Element_Checkbox($this->getName(),
                                           ['label' => $this->_('Abonnement valide'),
@@ -80,12 +89,12 @@ class Class_User_SearchCriteriaValidSubscription
   }
 
 
-  public function acceptSearchVisitor($visitor) {
-    $visitor->addWhereParam('STR_TO_DATE(date_fin, \'%Y-%m-%d\') >= CURDATE()');
+  public function acceptSearchVisitor($visitor) :Class_SearchCriteria_Abstract{
+    return $this;
   }
 
 
-  public function modelMatch($model) {
+  public function modelMatch($model) :bool {
     return $model->isAbonnementValid();
   }
 
@@ -105,6 +114,7 @@ class Class_User_SearchCriteriaValidSubscription
           ->findFirstCriteriaByClass(Class_User_SearchCriteria_RoleLevel::class))
       return false;
 
+
     return $role_level->isAbonneSigb() || $role_level->isAbonne();
   }
 }
@@ -112,6 +122,42 @@ class Class_User_SearchCriteriaValidSubscription
 
 
 
+class Class_User_SearchCriteriaValidSubscriptionUser
+  extends Class_User_SearchCriteriaValidSubscription {
+
+
+  public static function newFor($params){
+    return new Class_User_SearchCriteriaValidSubscriptionUser($params);
+  }
+
+
+  public function acceptSearchVisitor($visitor) :Class_SearchCriteria_Abstract {
+    $visitor->addWhereParam('STR_TO_DATE(date_fin, \'%Y-%m-%d\') >= CURDATE()');
+    return $this;
+  }
+}
+
+
+
+
+class Class_User_SearchCriteriaValidSubscriptionUserMembership
+  extends Class_User_SearchCriteriaValidSubscription {
+
+
+  public static function newFor($params){
+    return new Class_User_SearchCriteriaValidSubscriptionUserMembership($params);
+  }
+
+
+  public function acceptSearchVisitor($visitor) :Class_SearchCriteria_Abstract {
+    $visitor->addWhereParam('id_user IN (select user_id from user_membership where CURDATE() <= user_membership.end_date)');
+    return $this;
+  }
+}
+
+
+
+
 class Class_User_SearchCriteriaSearchFor extends Class_SearchCriteria_Abstract{
   protected
     $_name = 'search_for',
@@ -125,18 +171,19 @@ class Class_User_SearchCriteriaSearchFor extends Class_SearchCriteria_Abstract{
   }
 
 
-  public function acceptSearchVisitor($visitor) {
+  public function acceptSearchVisitor($visitor) :Class_SearchCriteria_Abstract {
     if (!$search_value = $this->_sanitize($this->_value))
-      return;
+      return $this;
 
     foreach(array_fill_keys($this->_columns, $search_value) as $column => $value)
       $table_or[] = $column . ' LIKE "%' . $value . '%"';
 
     $visitor->addWhereParam(implode(' OR ', $table_or));
+    return $this;
   }
 
 
-  public function modelMatch($user) {
+  public function modelMatch($user) :bool {
     if (!$search_value = $this->_sanitize($this->_value))
       return true;
 
diff --git a/library/Class/User/SearchCriteria/Age.php b/library/Class/User/SearchCriteria/Age.php
index f03184e9ff804e6c95f0936e83f44c50c26b426e..8410286692b79090f160374fc41bcd2eb04cbaf5 100644
--- a/library/Class/User/SearchCriteria/Age.php
+++ b/library/Class/User/SearchCriteria/Age.php
@@ -54,9 +54,9 @@ class Class_User_SearchCriteria_Age extends Class_SearchCriteria_Abstract {
   }
 
 
-  public function acceptSearchVisitor($visitor) {
+  public function acceptSearchVisitor($visitor) :Class_SearchCriteria_Abstract {
     if ($this->_isAllValues())
-      return;
+      return $this;
 
     if ($this->_hasFrom()) {
       $date = $this->substractYearsToCurrentDate((int)$this->_from);
@@ -67,10 +67,11 @@ class Class_User_SearchCriteria_Age extends Class_SearchCriteria_Abstract {
       $date = $this->substractYearsToCurrentDate((int)$this->_to);
       $visitor->addWhereParam('naissance >=\'' . $date.'\'');
     }
+    return $this;
   }
 
 
-  public function modelMatch($user) {
+  public function modelMatch($user) :bool {
     if ($this->_isAllValues())
       return true;
 
diff --git a/library/Class/User/SearchCriteria/DateFin.php b/library/Class/User/SearchCriteria/DateFin.php
index a16366f6a7723249e36f1c9bf863ba9f8d78e73a..8f667f3e2827a74b730feb29ab20f2cd067e9663 100644
--- a/library/Class/User/SearchCriteria/DateFin.php
+++ b/library/Class/User/SearchCriteria/DateFin.php
@@ -27,7 +27,94 @@ class Class_User_SearchCriteria_DateFin extends Class_SearchCriteria_DateRange {
     $_end_suffix = '_end';
 
 
+  public static function newFor($params){
+    return (Class_User_Membership::isUserMembershipContext())
+      ? Class_User_SearchCriteria_EndDateUserMembership::newFor($params)
+      : Class_User_SearchCriteria_DateFinUser::newFor($params);
+  }
+
+
   public function buildElement() {
     return parent::buildElement()->setLabel($this->_('Abonnement échu'));
   }
 }
+
+
+
+
+class Class_User_SearchCriteria_DateFinUser
+  extends Class_User_SearchCriteria_DateFin {
+
+  public static function newFor($params){
+    return new Class_User_SearchCriteria_DateFinUser($params);
+  }
+}
+
+
+
+
+class Class_User_SearchCriteria_EndDateUserMembership
+  extends Class_User_SearchCriteria_DateFin {
+
+  public static function newFor($params){
+    return new Class_User_SearchCriteria_EndDateUserMembership($params);
+  }
+
+
+  public function acceptSearchVisitor($visitor) :Class_SearchCriteria_Abstract {
+    $where_parts = [];
+    if (!$this->_value_start && !$this->_value_end)
+      return $this;
+
+    if ($this->_value_start)
+      $where_parts[] = sprintf('max(end_date) >= "%s"',
+                               $this->_sqlFormat($this->_value_start));
+
+    if ($this->_value_end)
+      $where_parts[] = sprintf('max(end_date) <= "%s"',
+                               $this->_sqlFormat($this->_value_end));
+
+    if ($userids = Class_User_Membership::findUserIdWithCriteria(implode(' AND ', $where_parts))){
+      $visitor->addWhereParam('id_user in ('. implode(',' ,$userids) .')');
+      return $this;
+    }
+    $visitor->hasNoResult();
+    return $this;
+  }
+
+
+  public function modelMatch($model) :bool {
+    if (!$this->_value_start && !$this->_value_end)
+      return true;
+
+    if (parent::Modelmatch($model))
+      return true;
+
+    if (!$user_memberships = $model->getUserMemberships())
+      return false;
+
+    usort($user_memberships, fn($a,$b) => ($a->getEndDate() <=> $b->getEndDate()));
+    $user_membership = reset($user_memberships);
+    if (!$end_date = $user_membership->getEndDate())
+      return false;
+    $user_membership = end($user_memberships);
+    $end_date = $user_membership->getEndDate();
+    if (!$this->isValidDate($end_date))
+      return false;
+
+    if ($this->_value_start
+        && $this->_sqlFormat($this->_value_start) > $end_date)
+      return false;
+
+    if ($this->_value_end
+        && $this->_sqlFormat($this->_value_end) < $end_date)
+      return false;
+
+    return true;
+  }
+
+
+  public function isValidDate($value) :bool {
+    return  (new ZendAfi_Validate_DateFormat())->setFormat('Y-m-d')->isValid($value);
+  }
+}
diff --git a/library/Class/User/SearchCriteria/DateMaj.php b/library/Class/User/SearchCriteria/DateMaj.php
index b2a1987118730653391fc719dd14194e22a3c467..be322bef3c69b3397a36ff083b2a8412d0daa199 100644
--- a/library/Class/User/SearchCriteria/DateMaj.php
+++ b/library/Class/User/SearchCriteria/DateMaj.php
@@ -33,7 +33,7 @@ class Class_User_SearchCriteria_DateMaj extends Class_SearchCriteria_DateRange {
   }
 
 
-  public function modelMatch($model) {
+  public function modelMatch($model) :bool {
     if (!$model->isNew())
       return parent::modelMatch($model);
 
diff --git a/library/Class/User/SearchCriteria/EndSubscriptionDate.php b/library/Class/User/SearchCriteria/EndSubscriptionDate.php
index 6aea797de1beb4fc83db2c98c694493bbb2655ab..41884ba135c324573d8687f5ac8fa492eb741192 100644
--- a/library/Class/User/SearchCriteria/EndSubscriptionDate.php
+++ b/library/Class/User/SearchCriteria/EndSubscriptionDate.php
@@ -27,6 +27,14 @@ class Class_User_SearchCriteria_EndSubscriptionDate
     $_name = 'end_subscription_days',
     $_value = '';
 
+
+  public static function newFor($params){
+    return (Class_User_Membership::isUserMembershipContext())
+      ? Class_User_SearchCriteria_EndSubscriptionDateUserMembership::newFor($params)
+      : Class_User_SearchCriteria_EndSubscriptionDateUser::newFor($params);
+  }
+
+
   public function buildElement() {
     return new Zend_Form_Element_Text($this->getName(),
                                       ['label' => $this->_('Abonnement échu d\'ici (jours)'),
@@ -34,22 +42,87 @@ class Class_User_SearchCriteria_EndSubscriptionDate
   }
 
 
-  public function modelMatch($user) {
-    return $user->getDateFin()
-      ? $user->getDateFin() <= $this->addDaysToCurrentDate($this->_value)
-      : true;
+  public function modelMatch($user) :bool{
+    return false;
   }
 
 
-  public function acceptSearchVisitor($visitor) {
-    $visitor->addWhereParam('date_fin!=\'\' and date_fin <=\'' . $this->addDaysToCurrentDate($this->_value).'\'');
+  public function acceptSearchVisitor($visitor) :Class_SearchCriteria_Abstract {
+    return $this;
   }
 
 
-  public function shouldFilter($search_criteria) {
+  public function shouldFilter($search_criteria) :bool {
     return $this->_value
       && ($role_level = $search_criteria
           ->findFirstCriteriaByClass(Class_User_SearchCriteria_RoleLevel::class))
       && ($role_level->isAbonneSigb() || $role_level->isAbonne());
   }
 }
+
+
+
+
+class Class_User_SearchCriteria_EndSubscriptionDateUser
+  extends Class_User_SearchCriteria_EndSubscriptionDate {
+
+  public static function newFor($params){
+    return new Class_User_SearchCriteria_EndSubscriptionDateUser($params);
+  }
+
+
+  public function acceptSearchVisitor($visitor) :Class_SearchCriteria_Abstract {
+    $visitor->addWhereParam('date_fin!=\'\' and date_fin <=\'' . $this->addDaysToCurrentDate($this->_value).'\'');
+    return $this;
+  }
+
+
+  public function modelMatch($user) :bool{
+    return $user->getDateFin()
+      ? $user->getDateFin() <= $this->addDaysToCurrentDate($this->_value)
+      : true;
+  }
+}
+
+
+
+
+class Class_User_SearchCriteria_EndSubscriptionDateUserMembership
+  extends Class_User_SearchCriteria_EndSubscriptionDate {
+
+  public static function newFor($params){
+    return new Class_User_SearchCriteria_EndSubscriptionDateUserMembership($params);
+  }
+
+
+  public function acceptSearchVisitor($visitor) :Class_SearchCriteria_Abstract {
+    if (!$this->_value)
+      return $this;
+    if ($userids = Class_User_Membership::findUserIdWithCriteria(' max(end_date) >= CURDATE() AND max(end_date) <=\'' .$this->addDaysToCurrentDate($this->_value) .'\' ')){
+      $visitor->addWhereParam('id_user in ('. implode(',' ,$userids) .')');
+      return $this;
+    }
+
+    $visitor->hasNoResult();
+    return $this;
+  }
+
+
+  public function modelMatch($user) :bool{
+    if (!$this->_value)
+      return true;
+    if (!$user->isAbonne())
+      return false;
+    $user_memberships = $user->getUserMemberships();
+    if (!$user_memberships)
+      return false;
+    usort($user_memberships, fn($a,$b) => ($a->getEndDate() <=> $b->getEndDate()));
+    $user_membership = reset($user_memberships);
+    if (!$date_fin = $user_membership->getEndDate())
+      return false;
+    $user_membership = end($user_memberships);
+    $date_fin = $user_membership->getEndDate();
+    return (($date_fin >= $this->getCurrentDate())
+            && ($date_fin <= $this->addDaysToCurrentDate($this->_value)));
+  }
+}
diff --git a/library/Class/User/SearchCriteria/LastLogin.php b/library/Class/User/SearchCriteria/LastLogin.php
index ed6a215481b657e53acdb88b2cebdf5e6379e1ee..459ca935de96d19702cebaef2e3bd348233f7570 100644
--- a/library/Class/User/SearchCriteria/LastLogin.php
+++ b/library/Class/User/SearchCriteria/LastLogin.php
@@ -32,7 +32,7 @@ class Class_User_SearchCriteria_LastLogin extends Class_SearchCriteria_DateRange
   }
 
 
-  public function isValidDate($value) {
+  public function isValidDate($value) :bool {
     return  (new ZendAfi_Validate_DateFormat())->setFormat('Y-m-d H:i:s')->isValid($value);
   }
 }
diff --git a/library/Class/User/SearchCriteria/Membership.php b/library/Class/User/SearchCriteria/Membership.php
new file mode 100644
index 0000000000000000000000000000000000000000..613cc2422f3c2001b2c730de726f55f509542a06
--- /dev/null
+++ b/library/Class/User/SearchCriteria/Membership.php
@@ -0,0 +1,100 @@
+<?php
+/**
+ * Copyright (c) 2012-2022, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class Class_User_SearchCriteria_Membership
+  extends Class_SearchCriteria_MultiCheckbox {
+  use Trait_TimeSource;
+
+  protected $_name = 'membership';
+  protected $_value;
+
+  public function _getLabel(){
+    return $this->_('Type d\'abonnement');
+  }
+
+
+  public function buildElement(){
+    return Class_IntBib::isSingleNanook()
+      ? parent::buildElement()->setSelectedAllMeansNothing(false)
+      : '';
+  }
+
+
+  public function getArrayValues(){
+    return (is_array($this->_value))
+      ? (array_filter($this->_value,
+                      function ($element){
+                        return $element != 'all';
+                      }))
+      : explode(';', $this->_value);
+  }
+
+
+  public function acceptSearchVisitor($visitor) :Class_SearchCriteria_Abstract {
+    $wheres = [];
+    if ($this->_isIgnore())
+      $wheres []= 'id_user not in (select distinct (user_id) from user_membership)';
+
+    if ($ids = array_filter($this->getArrayValues(),
+                            fn($element) => $element!= 'ignore'))
+      $wheres [] = 'id_user in ( select distinct (user_id) from user_membership where membership_id in ('
+        .implode(',',$ids)
+        .'))';
+
+    if ($wheres)
+      $visitor->addWhereParam('('.implode(' or ', $wheres).')');
+    return $this;
+  }
+
+
+  protected function _isIgnore(){
+    return in_array('ignore', $this->getArrayValues());
+  }
+
+
+  public function modelMatch($model) : bool {
+    $user_memberships = $model->getUserMemberships();
+    if ($this->_isIgnore() && (sizeOf($user_memberships)==0))
+      return true;
+
+    if (!$user_memberships && !sizeOf($this->getArrayValues()))
+      return true;
+
+    foreach ($user_memberships as $user_membership)
+      if (in_array($user_membership->getMembershipId(),$this->getArrayValues()))
+        return true;
+
+    return false;
+  }
+
+
+  public function describeOn($view) {
+    return ($this->_value)
+      ? ($this->_element->getLabel() .$this->_( ' dans ').': ' . implode(',',$this->_value))
+      : '';
+  }
+
+
+  public function shouldFilter($search_criteria) : bool {
+    return Class_IntBib::isSingleNanook() && (bool) $this->_value;
+  }
+}
diff --git a/library/Class/User/SearchCriteria/NeverLogged.php b/library/Class/User/SearchCriteria/NeverLogged.php
index 2d6bba571bf3cd238e294c7bb7fbc35969fca247..5c861b957539c8ad879dd14bfc96cb9e98858315 100644
--- a/library/Class/User/SearchCriteria/NeverLogged.php
+++ b/library/Class/User/SearchCriteria/NeverLogged.php
@@ -39,7 +39,7 @@ class Class_User_SearchCriteria_NeverLogged extends Class_SearchCriteria_Abstrac
   }
 
 
-  public function modelMatch($model) {
+  public function modelMatch($model):bool {
     if (!$this->_value)
       return true;
 
@@ -48,9 +48,10 @@ class Class_User_SearchCriteria_NeverLogged extends Class_SearchCriteria_Abstrac
   }
 
 
-  public function acceptSearchVisitor($visitor) {
+  public function acceptSearchVisitor($visitor) :Class_SearchCriteria_Abstract {
     if (!$this->_value)
-      return;
+      return $this;
     $visitor->addWhereParam('last_login is null or last_login="'.static::EMPTY_DATE.'"');
+    return $this;
   }
 }
diff --git a/library/Class/User/SearchCriteria/NewsletterSubscriptionStatus.php b/library/Class/User/SearchCriteria/NewsletterSubscriptionStatus.php
index 3b5dd0f5b1aa4b41012263b5dc913ab672aa1fbb..4ab4ee7cc0d0ae421c11bbe09f5a36cf67024d5e 100644
--- a/library/Class/User/SearchCriteria/NewsletterSubscriptionStatus.php
+++ b/library/Class/User/SearchCriteria/NewsletterSubscriptionStatus.php
@@ -43,45 +43,52 @@ class Class_User_SearchCriteria_NewsletterSubscriptionStatus extends Class_Searc
   }
 
 
-  public function acceptSearchVisitor($visitor) {
+  public function acceptSearchVisitor($visitor) :Class_SearchCriteria_Abstract {
     if (!$this->_newsletter || 'all' == $this->_value)
-      return;
+      return $this;
 
     if ('not_subscribed' == $this->_value)
-      return $this->_applyNotSubscribedFilterOn($visitor);
+       return $this->_applyNotSubscribedFilterOn($visitor);
 
     if ('unsubscribed' == $this->_value)
-      return $this->_applyUnsubscribedFilterOn($visitor);
+       return $this->_applyUnsubscribedFilterOn($visitor);
 
     return $this->_applySubscribedFilterOn($visitor);
   }
 
 
-  protected function _applySubscribedFilterOn($visitor) {
-    if (!$recipient_ids = $this->_newsletter->getRecipientsUsersIds())
-      return $visitor->hasNoResult();
+  protected function _applySubscribedFilterOn($visitor) :self{
+    if (!$recipient_ids = $this->_newsletter->getRecipientsUsersIds()){
+      $visitor->hasNoResult();
+      return $this;
+    }
 
     $visitor->addWhereParam('id_user in (' . implode(',', $recipient_ids). ')');
 
     if ($blacklisted = $this->_newsletter->getBlackListedMails())
       $visitor->addWhereParam('mail not in ("' . implode('","', $blacklisted). '")');
+    return $this;
   }
 
 
-  protected function _applyNotSubscribedFilterOn($visitor) {
+  protected function _applyNotSubscribedFilterOn($visitor) :self{
     if (!$recipient_ids = $this->_newsletter->getRecipientsUsersIds())
-      return;
+      return $this;
 
-    return $visitor->addWhereParam('id_user not in (' . implode(',', $recipient_ids). ')');
+    $visitor->addWhereParam('id_user not in (' . implode(',', $recipient_ids). ')');
+    return $this;
   }
 
 
-  protected function _applyUnsubscribedFilterOn($visitor) {
-    if (!$blacklisted = $this->_newsletter->getBlackListedMails())
-      return $visitor->hasNoResult();
+  protected function _applyUnsubscribedFilterOn($visitor) :self{
+    if (!$blacklisted = $this->_newsletter->getBlackListedMails()){
+      $visitor->hasNoResult();
+      return $this;
+    }
 
     $visitor
       ->addWhereParam('id_user in (' . implode(',', $this->_newsletter->getRecipientsUsersIds()). ')')
       ->addWhereParam('mail in ("' . implode('","', $blacklisted). '")');
+    return $this;
   }
-}
\ No newline at end of file
+}
diff --git a/library/Class/User/SearchCriteria/RoleLevel.php b/library/Class/User/SearchCriteria/RoleLevel.php
index 2b202c4393faa0b5a0c9d68b226f2747520fb501..e9d6d9b3ab26b1b612cc6d89cd7582569b687c95 100644
--- a/library/Class/User/SearchCriteria/RoleLevel.php
+++ b/library/Class/User/SearchCriteria/RoleLevel.php
@@ -64,8 +64,12 @@ class Class_User_SearchCriteria_RoleLevel extends Class_SearchCriteria_Select {
                                     'end_subscription_days',
                                     'date_fin_start']);
 
+    $abonne_toggle = ['statut'];
+    if (Class_IntBib::isSingleNanook())
+      $abonne_toggle [] = 'membership';
+
     $this->_addToggleVisibilityFor([ZendAfi_Acl_AdminControllerRoles::ABONNE_SIGB],
-                                   ['statut']);
+                                   $abonne_toggle);
   }
 
 
diff --git a/library/Class/User/SearchCriteria/RoleLevelLimit.php b/library/Class/User/SearchCriteria/RoleLevelLimit.php
index 5ec3ada2f9eb3b227001b39768464df2fda479c5..c1ef9208fa4a1fad89bd44eaa660c80d6c0cdb17 100644
--- a/library/Class/User/SearchCriteria/RoleLevelLimit.php
+++ b/library/Class/User/SearchCriteria/RoleLevelLimit.php
@@ -26,7 +26,8 @@ class Class_User_SearchCriteria_RoleLevelLimit extends Class_SearchCriteria_Abst
   }
 
 
-  public function acceptSearchVisitor($visitor) {
+  public function acceptSearchVisitor($visitor) :Class_SearchCriteria_Abstract {
     $visitor->addWhereParam('role_level <= ' . Class_Users::getIdentity()->getRoleLevel());
+    return $this;
   }
 }
diff --git a/library/Class/User/SearchCriteria/SessionActivitySubscriptionStatus.php b/library/Class/User/SearchCriteria/SessionActivitySubscriptionStatus.php
index d111e1d44a72e70feffae99e76fb355b8a1a067e..e152b0a8fa8b9ce5d3c69132445f5cefe1e29392 100644
--- a/library/Class/User/SearchCriteria/SessionActivitySubscriptionStatus.php
+++ b/library/Class/User/SearchCriteria/SessionActivitySubscriptionStatus.php
@@ -51,39 +51,37 @@ class Class_User_SearchCriteria_SessionActivitySubscriptionStatus
   }
 
 
-  public function acceptSearchVisitor($visitor) {
+  public function acceptSearchVisitor($visitor) :Class_SearchCriteria_Abstract {
     if (!$this->_model || static::ALL_VALUES == $this->_value)
-      return;
+      return $this;
 
-    if (static::OPTION_NOT_SUBSCRIBE == $this->_value) {
-      $this->_applyNotSubscribedFilterOn($visitor, null);
-      return;
-    }
+    if (static::OPTION_NOT_SUBSCRIBE == $this->_value)
+       return $this->_applyNotSubscribedFilterOn($visitor, null);
 
-    if (static::OPTION_IN_QUEUE == $this->_value) {
-      $this->_applySubscribedFilterOn($visitor, true);
-      return;
-    }
+    if (static::OPTION_IN_QUEUE == $this->_value)
+      return $this->_applySubscribedFilterOn($visitor, true);
 
-    $this->_applySubscribedFilterOn($visitor, false);
+    return $this->_applySubscribedFilterOn($visitor, false);
   }
 
 
-  protected function _applySubscribedFilterOn($visitor, $with_queue) {
-    if (!$ids = $this->_registeredUserIds($with_queue)) {
+  protected function _applySubscribedFilterOn($visitor, $with_queue) :Class_SearchCriteria_Abstract{
+    if (!$ids = $this->_registeredUserIds($with_queue)){
       $visitor->hasNoResult();
-      return;
+      return $this;
     }
 
     $visitor->addWhereParam('id_user in (' . implode(',', $ids). ')');
+    return $this;
   }
 
 
-  protected function _applyNotSubscribedFilterOn($visitor, $with_queue) {
+  protected function _applyNotSubscribedFilterOn($visitor, $with_queue) :Class_SearchCriteria_Abstract{
     if (!$ids = $this->_registeredUserIds($with_queue))
-      return;
+      return $this;
 
     $visitor->addWhereParam('id_user not in (' . implode(',', $ids). ')');
+    return $this;
   }
 
 
diff --git a/library/Class/User/SearchCriteria/WithMail.php b/library/Class/User/SearchCriteria/WithMail.php
index 2a3fa4081e0252584c8e975a654364cc1475798b..ee8899a37a4337fa9192ab4c1252e24360fe121c 100644
--- a/library/Class/User/SearchCriteria/WithMail.php
+++ b/library/Class/User/SearchCriteria/WithMail.php
@@ -31,11 +31,12 @@ class Class_User_SearchCriteria_WithMail extends Class_SearchCriteria_Abstract {
                                            'value' => $this->_value]);
   }
 
-  public function acceptSearchVisitor($visitor) {
+  public function acceptSearchVisitor($visitor) :Class_SearchCriteria_Abstract {
     if (!$this->_value)
-      return;
+      return $this;
 
     $visitor->addWhereParam('mail is not null')
             ->addWhereParam('mail <> \'\'');
+    return $this;
   }
-}
\ No newline at end of file
+}
diff --git a/library/Class/User/SearchCriteria/WithRight.php b/library/Class/User/SearchCriteria/WithRight.php
index 537f11be11a16a4a7819743092f28260b59fb93e..1298c9baed286e995fb49d3ac4cef9ab510166fc 100644
--- a/library/Class/User/SearchCriteria/WithRight.php
+++ b/library/Class/User/SearchCriteria/WithRight.php
@@ -39,23 +39,24 @@ class Class_User_SearchCriteria_WithRight extends Class_SearchCriteria_Abstract
   }
 
 
-  public function acceptSearchVisitor($visitor) {
+  public function acceptSearchVisitor($visitor) :Class_SearchCriteria_Abstract {
     if (null === $this->_right)
-      return;
+      return $this;
 
     $ids = array_merge(Class_UserGroup::findAllUserIdsHavingRight($this->_right),
                        Class_SessionActivityInscription::userIdsOptimizedFor($this->_model));
 
     if (!$ids = array_unique($ids)) {
       $visitor->hasNoResult();
-      return;
+      return $this;
     }
 
     $visitor->addWhereParam('id_user in (' . implode(',', $ids). ')');
+    return $this;
   }
 
 
-  public function modelMatch($model) {
+  public function modelMatch($model) :bool {
     return $model->hasRightSuivreActivity();
   }
 }
diff --git a/library/Class/UserGroup.php b/library/Class/UserGroup.php
index 2f20c3b96364056e3a65621c2cee94143f187d3c..0f26c13bfe44b11fac84f7592aea8bc75063abe2 100644
--- a/library/Class/UserGroup.php
+++ b/library/Class/UserGroup.php
@@ -765,6 +765,12 @@ class Class_UserGroup extends Storm_Model_Abstract {
   }
 
 
+  public function cleanCriteriaCache() :self {
+    $this->_criteria_cache = null;
+    return $this;
+  }
+
+
   public function isDeletable() : bool {
     return false === (bool) $this->getProtected();
   }
diff --git a/library/Class/UserGroup/Agenda/SearchCriteria/User.php b/library/Class/UserGroup/Agenda/SearchCriteria/User.php
index dfe2287eac50f36ea046f2182174a2cdbd619246..9c598415680036c313a45c13028ba4347b09b506 100644
--- a/library/Class/UserGroup/Agenda/SearchCriteria/User.php
+++ b/library/Class/UserGroup/Agenda/SearchCriteria/User.php
@@ -31,24 +31,31 @@ class Class_UserGroup_Agenda_SearchCriteria_User extends Class_SearchCriteria_Ab
   }
 
 
-  public function acceptSearchVisitor($visitor) {
+  public function acceptSearchVisitor($visitor) :Class_SearchCriteria_Abstract{
     $value = str_replace(["\000", "\n", "\r", "\"", "\'", "'", "\032"],
                          '',
                          trim($this->_value));
 
     if (!$value)
-      return;
+      return $this;
 
-    if ((!$members = $this->_groupMembers()) || $members->isEmpty())
-      return $visitor->hasNoResult();
+    if ((!$members = $this->_groupMembers()) || $members->isEmpty()){
+      $visitor->hasNoResult();
+      return $this;
+    }
 
-    if (!$matching_members = $this->_getMatchingMembers($members, $value))
-      return $visitor->hasNoResult();
+    if (!$matching_members = $this->_getMatchingMembers($members, $value)) {
+      $visitor->hasNoResult();
+      return $this;
+    }
 
-    if (!$matching_groups = $this->_getMatchingGroups($members, $matching_members))
-      return $visitor->hasNoResult();
+    if (!$matching_groups = $this->_getMatchingGroups($members, $matching_members)){
+       $visitor->hasNoResult();
+       return $this;
+    }
 
     $visitor->addWhereParam('id in (' . implode(',', $matching_groups) . ')');
+    return $this;
   }
 
 
diff --git a/library/Class/Users.php b/library/Class/Users.php
index 01694aaddd842a34d468962645d53a4c8e18e59d..a1b4ba01db90af03f77372e786438218261e7707 100644
--- a/library/Class/Users.php
+++ b/library/Class/Users.php
@@ -452,7 +452,11 @@ class Class_Users extends Storm_Model_Abstract {
 
                   'drive_checkouts' => ['model' => 'Class_DriveCheckout',
                                         'role' => 'user',
-                                        'dependents' => 'delete']
+                                        'dependents' => 'delete'],
+
+                  'user_memberships' => ['model' => Class_User_Membership::class,
+                                    'role' => 'user',
+                                    'dependents' => 'delete']
 
     ],
 
@@ -2040,4 +2044,23 @@ class Class_Users extends Storm_Model_Abstract {
   public function isAbonnePortail() : bool {
     return ZendAfi_Acl_AdminControllerRoles::ABONNE === $this->getRoleLevel();
   }
+
+
+  public function getLastUserMemberships() : array {
+    $user_memberships = $this->getUserMemberships();
+
+    if (sizeOf($user_memberships)==0)
+      return [];
+
+    usort($user_memberships,
+          function($a,$b){
+            return $b->getEndDate() <=> $a->getEndDate();
+          });
+    return ($array_filtered = array_filter($user_memberships,
+                        function($element){
+                          return $element->isValidSubscription();
+                        }))
+      ? $array_filtered
+      : [reset($user_memberships)];
+  }
 }
diff --git a/library/Class/WebService/SIGB/Emprunteur.php b/library/Class/WebService/SIGB/Emprunteur.php
index c9940eee284672bd899a5c475abeb54e19fceb6f..ee8cd89062d506f938d2180d7abb0ccb7855a1e1 100644
--- a/library/Class/WebService/SIGB/Emprunteur.php
+++ b/library/Class/WebService/SIGB/Emprunteur.php
@@ -24,9 +24,9 @@ class Class_WebService_SIGB_Emprunteur {
     $_id,
     $_name,
     $_emprunts = [],
-    $_subscriptions = [],
     $_reservations = [],
     $_waiting_holds = [],
+    $_subscriptions,
     $_email = '',
     $_nom = null,
     $_prenom = null,
@@ -66,10 +66,12 @@ class Class_WebService_SIGB_Emprunteur {
       $this->getEmprunts();
 
     $this->getReservations();
+
     return ['_id',
             '_name',
             '_emprunts',
             '_reservations',
+            '_subscriptions',
             '_email',
             '_nom',
             '_prenom',
@@ -87,7 +89,6 @@ class Class_WebService_SIGB_Emprunteur {
             '_is_contact_sms',
             '_library_code',
             '_id_int_bib',
-            '_subscriptions',
             '_blocked',
             '_multimedia_access',
             '_parental_authorization'];
@@ -332,12 +333,16 @@ class Class_WebService_SIGB_Emprunteur {
 
 
   public function subscriptionAdd($subscription_id, $subscription_label) {
-    $this->_subscriptions[$subscription_id] = $subscription_label;
+    $this->getSubscriptions()->add( (new Class_WebService_SIGB_Subscription)
+                                    ->setId($subscription_id)
+                                    ->setLabel($subscription_label));
     return $this;
   }
 
 
   public function getSubscriptions() {
+    if (!isset($this->_subscriptions))
+      $this->_subscriptions = new Storm_collection;
     return $this->_subscriptions;
   }
 
diff --git a/library/Class/WebService/SIGB/Koha/Emprunteur.php b/library/Class/WebService/SIGB/Koha/Emprunteur.php
index 42217bd8fbd0fa14a759f4433c834c545416c04b..584e217f34b01524f9b0dcd3f14d5adff6507131 100644
--- a/library/Class/WebService/SIGB/Koha/Emprunteur.php
+++ b/library/Class/WebService/SIGB/Koha/Emprunteur.php
@@ -24,9 +24,11 @@ class Class_WebService_SIGB_Koha_Emprunteur extends Class_WebService_SIGB_Emprun
   const CATEGORY_KOHA = 'Koha';
 
   public function updateUserRelations(Class_Users $user) {
-    if ( ! $user->getId())
+    if (! $user->getId())
       return;
 
+    parent::updateUserRelations($user);
+
     if ( ! $this->_service->getCreateCategoryUsergroup() )
       return;
 
@@ -35,7 +37,7 @@ class Class_WebService_SIGB_Koha_Emprunteur extends Class_WebService_SIGB_Emprun
     foreach($category->getUserGroups() as $group)
       $group->removeUser($user)->save();
 
-    foreach($this->_subscriptions as $label)
-      Class_UserGroup::getOrCreate($category, $label, $user);
+    foreach($this->getSubscriptions() as $subscription)
+      Class_UserGroup::getOrCreate($category, $subscription->getLabel(), $user);
   }
 }
diff --git a/library/Class/WebService/SIGB/Nanook/Emprunteur.php b/library/Class/WebService/SIGB/Nanook/Emprunteur.php
new file mode 100644
index 0000000000000000000000000000000000000000..e7beb4e270f08c6f1c2fcf76b30440a26d5d0426
--- /dev/null
+++ b/library/Class/WebService/SIGB/Nanook/Emprunteur.php
@@ -0,0 +1,54 @@
+<?php
+/**
+ * Copyright (c) 2012-2022, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class Class_WebService_SIGB_Nanook_Emprunteur extends Class_WebService_SIGB_Emprunteur {
+  public function updateUserRelations($user) {
+    if ($this->_mustProcessSubscription($user))
+      $this->_processSubscriptions($user);
+  }
+
+
+  protected function _mustProcessSubscription(Class_Users $user) :bool{
+    return Class_IntBib::isSingleNanook()
+      && (Class_Membership::count() >0)
+      && ($user->isNew() ||$user->isAbonne());
+  }
+
+
+  protected function _processSubscriptions(Class_Users $user) :void{
+    foreach ($this->getSubscriptions() as $subscription){
+      $membership = Class_Membership::findOrCreate( $subscription->getId(), $subscription->getLabel());
+      if ($membership->isNew())
+        $membership->save();
+      $user_membership = Class_User_Membership::findOrCreate(
+                                                   $user,
+                                                   $membership,
+                                                   $subscription->getStartDate(),
+                                                   $subscription->getEndDate()
+      );
+      if ($user_membership->isNew()){
+        $user_membership->save();
+        $user->addUserMembership($user_membership);
+      }
+    }
+  }
+}
diff --git a/library/Class/WebService/SIGB/Nanook/PatronInfoReader.php b/library/Class/WebService/SIGB/Nanook/PatronInfoReader.php
index e075bd90e951b76a85c2d4bf5fdfcc5c1690730d..01c9b6172aa3c30b71c3a45185e0c08188dff707 100644
--- a/library/Class/WebService/SIGB/Nanook/PatronInfoReader.php
+++ b/library/Class/WebService/SIGB/Nanook/PatronInfoReader.php
@@ -25,7 +25,8 @@ class Class_WebService_SIGB_Nanook_PatronInfoReader
     $_user,
     $_suggests = [],
     $_item_priorities = [],
-    $_current_suggest;
+    $_current_suggest,
+    $_current_subscription;
 
   /**
    * @return Class_WebService_SIGB_Nanook_PatronInfoReader
@@ -64,11 +65,23 @@ class Class_WebService_SIGB_Nanook_PatronInfoReader
    * @param $data string
    */
   public function endEndDate($data) {
-    if ($this->_xml_parser->inParents('subscriptions'))
+    if (!$data || ($data == 'null'))
       return;
 
-    if ($data && 'null' != $data)
-      $this->getEmprunteur()->setEndDate($data);
+    return ($this->_xml_parser->inParents('subscriptions'))
+      ? $this->_addSubscriptionEndDate($data)
+      : $this->_addEmprunteurEndDate($data);
+  }
+
+
+  protected function _addSubscriptionEndDate(string $data){
+    if ($this->_current_subscription)
+      $this->_current_subscription->setEndDate($data);
+  }
+
+
+  protected function _addEmprunteurEndDate(string $data){
+    $this->getEmprunteur()->setEndDate($data);
   }
 
 
@@ -298,6 +311,54 @@ class Class_WebService_SIGB_Nanook_PatronInfoReader
   }
 
 
+  public function startSubscription() {
+      $this->_newSubscription();
+  }
+
+
+  public function startSubscriptions() {
+    if ($this->_xml_parser->inParents('subscriptions'))
+      $this->_newSubscription();
+  }
+
+
+  protected function _newSubscription(){
+    $this->_current_subscription = new Class_WebService_SIGB_Subscription;
+  }
+
+
+  public function endRateId($data) {
+    $this->_current_subscription->setId($data);
+  }
+
+
+  public function endRateLabel($data) {
+    $this->_current_subscription->setLabel($data);
+  }
+
+
+  public function endStartDate($data) {
+    $this->_current_subscription->setStartDate($data);
+  }
+
+
+  public function endSubscriptions() {
+    $this->_addToSubscriptions();
+  }
+
+
+  public function endSubscription() {
+    $this->_addToSubscriptions();
+  }
+
+  protected function _addToSubscriptions(){
+    if ($this->_current_subscription){
+      $this->getEmprunteur()->getSubscriptions()->add($this->_current_subscription);
+      $this->_current_subscription = null;
+    }
+  }
+
+
   public function endSiteId($data) {
     $this->getEmprunteur()->setLibraryCode($data);
   }
@@ -317,4 +378,4 @@ class Class_WebService_SIGB_Nanook_PatronInfoReader
   public function endParentalAuthorization($data) : void {
     $this->getEmprunteur()->setParentalAuthorization((int) $data);
   }
-}
\ No newline at end of file
+}
diff --git a/library/Class/WebService/SIGB/Nanook/Service.php b/library/Class/WebService/SIGB/Nanook/Service.php
index 55f97b91b9093d40f3e13c632dd9830c9131b930..9acfeae1f214f484acf03d991c89c082d3db8bb7 100644
--- a/library/Class/WebService/SIGB/Nanook/Service.php
+++ b/library/Class/WebService/SIGB/Nanook/Service.php
@@ -114,7 +114,12 @@ class Class_Webservice_SIGB_Nanook_Service
   }
 
 
-  /**
+  protected function _newEmprunteur() : Class_WebService_SIGB_Emprunteur {
+    return Class_WebService_SIGB_Nanook_Emprunteur::newInstance();
+  }
+
+
+/**
    * @param Class_Users $user
    * @return Class_WebService_SIGB_Emprunteur
    */
diff --git a/library/Class/WebService/SIGB/Subscription.php b/library/Class/WebService/SIGB/Subscription.php
new file mode 100644
index 0000000000000000000000000000000000000000..96efd25119a39ae62cc28af980ce75d17b9ae1ae
--- /dev/null
+++ b/library/Class/WebService/SIGB/Subscription.php
@@ -0,0 +1,68 @@
+<?php
+/**
+ * Copyright (c) 2012-2022, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class Class_WebService_SIGB_Subscription {
+  protected $_id='',
+    $_label='';
+
+  public function setId(string $id) :self{
+    $this->_id = $id;
+    return $this;
+  }
+
+
+  public function setLabel(string $label) :self{
+    $this->_label = $label;
+    return $this;
+  }
+
+
+  public function getLabel() :string{
+    return $this->_label;
+  }
+
+
+  public function getId() :string{
+    return $this->_id;
+  }
+
+
+  public function getStartDate() :string{
+    return  $this->_start_date;
+  }
+
+  public function getEndDate() :string{
+    return  $this->_end_date;
+  }
+
+
+  public function setStartDate(string $start_date) :self{
+    $this->_start_date = $start_date;
+    return $this;
+  }
+
+
+  public function setEndDate(string $end_date) :self{
+    $this->_end_date = $end_date;
+    return $this;
+  }
+}
diff --git a/library/Trait/TimeSource.php b/library/Trait/TimeSource.php
index 94eca6d4378e5b2121a9e3c9d4c690396dd0f691..03da053e5c87de65e8561d6edc38a8de1a2d1700 100644
--- a/library/Trait/TimeSource.php
+++ b/library/Trait/TimeSource.php
@@ -31,6 +31,7 @@ trait Trait_TimeSource {
     return self::getTimeSource()->time();
   }
 
+
   public function getCurrentMicroTime() : float {
     return self::getTimeSource()->microtime();
   }
@@ -102,4 +103,10 @@ trait Trait_TimeSource {
   public static function setTimeSource($time_source) {
     self::$_time_source = $time_source;
   }
+
+
+  protected function _isDateFormatValid(string $date, string $format) : bool {
+    $d = DateTime::createFromFormat($format, $date);
+    return $d && $d->format( $format ) == $date;
+  }
 }
diff --git a/library/ZendAfi/Controller/Plugin/ResourceDefinition/Membership.php b/library/ZendAfi/Controller/Plugin/ResourceDefinition/Membership.php
new file mode 100644
index 0000000000000000000000000000000000000000..e7a91b2fa0a68fa74471dd73ca228641a9df23c7
--- /dev/null
+++ b/library/ZendAfi/Controller/Plugin/ResourceDefinition/Membership.php
@@ -0,0 +1,36 @@
+<?php
+/**
+ * Copyright (c) 2012-2017, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class ZendAfi_Controller_Plugin_ResourceDefinition_Membership extends ZendAfi_Controller_Plugin_ResourceDefinition_Abstract {
+
+  public function getDefinitions() {
+    return ['model' => ['class' => 'Class_Membership',
+                        'name' => 'membership',
+                        'order' => 'code',
+                        'model_id' => 'id'],
+
+            'actions' => ['index' => ['title' => $this->_('Parcourir les types d\'abonnement')]],
+
+            'form_class_name' => ZendAfi_Form_Admin_Membership::class];
+  }
+}
+?>
diff --git a/library/ZendAfi/Form/Admin/User.php b/library/ZendAfi/Form/Admin/User.php
index 9813c54ea0e9dd7e9b9c8dc7be93cc2402fdd76f..fbaf329bc72263c9d15a9b46ed3665b25940256c 100644
--- a/library/ZendAfi/Form/Admin/User.php
+++ b/library/ZendAfi/Form/Admin/User.php
@@ -289,11 +289,24 @@ class ZendAfi_Form_Admin_User extends ZendAfi_Form {
                     'end' => ['name' => 'date_fin',
                               'allowEmpty' => true,
                               'dateOnly' => true,
-                              'toggleAllDay' => 'all_day']])
+                              'toggleAllDay' => 'all_day']]);
+    $array_group = ['idabon',
+                    'ordreabon'];
 
-      ->addDisplayGroup(['idabon',
-                         'ordreabon',
-                         'subscription_range_date'],
+    if ($user_memberships = $this->_user->getLastUserMemberships()){
+      $this->addElement('userMemberships',
+                        'subscription_information',
+                        ['label' => $this->_('Abonnement(s) SIGB'),
+                         'user_memberships' => $user_memberships]);
+      $array_group []= 'subscription_information';
+    }
+
+    $array_group []= 'subscription_range_date';
+
+
+
+    $this
+      ->addDisplayGroup($array_group,
                         'sigb',
                         ['legend' => $this->_('Abonnement')]);
     return $this;
diff --git a/library/ZendAfi/Form/Decorator/UserMemberships.php b/library/ZendAfi/Form/Decorator/UserMemberships.php
new file mode 100644
index 0000000000000000000000000000000000000000..e8f31939867cef9d8b64ccbd1ac715cc91e55f03
--- /dev/null
+++ b/library/ZendAfi/Form/Decorator/UserMemberships.php
@@ -0,0 +1,34 @@
+<?php
+/**
+ * Copyright (c) 2012, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+class ZendAfi_Form_Decorator_UserMemberships extends Zend_Form_Decorator_Abstract {
+  /**
+   * @param  string $content
+   * @return string
+   */
+  public function render($content) {
+    if (! $this->_element->user_memberships)
+      return $content;
+    return $content.
+      $this->_element->getView()->renderUserMemberships($this->_element->user_memberships);
+
+  }
+}
diff --git a/library/ZendAfi/Form/Element/UserMemberships.php b/library/ZendAfi/Form/Element/UserMemberships.php
new file mode 100644
index 0000000000000000000000000000000000000000..2f75a137132bba0034a708ea9b7203be7f0f460c
--- /dev/null
+++ b/library/ZendAfi/Form/Element/UserMemberships.php
@@ -0,0 +1,34 @@
+<?php
+/**
+ * Copyright (c) 2012-2022, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class ZendAfi_Form_Element_UserMemberships extends Zend_Form_Element_Xhtml {
+  public function __construct($spec, $options = null) {
+    parent::__construct($spec, $options);
+    $decorators = $this->_decorators;
+    $this->_decorators = ['UserMemberships' => new ZendAfi_Form_Decorator_UserMemberships()];
+
+    foreach ($decorators as $name => $value)
+      $this->_decorators[$name] = $value;
+
+    $this->removeDecorator('ViewHelper');
+  }
+}
diff --git a/library/ZendAfi/View/Helper/Admin/SearchUsers.php b/library/ZendAfi/View/Helper/Admin/SearchUsers.php
index b94ff46eeec0abcf066ad359ef98d98ebf5916c6..f09493c922537b57003023648be9dd3a570e32de 100644
--- a/library/ZendAfi/View/Helper/Admin/SearchUsers.php
+++ b/library/ZendAfi/View/Helper/Admin/SearchUsers.php
@@ -24,7 +24,7 @@ class ZendAfi_View_Helper_Admin_SearchUsers
   extends ZendAfi_View_Helper_Admin_Search {
 
   protected
-    $_table_description_class = 'Class_TableDescription_Users',
+    $_table_description_class = Class_TableDescription_Users::class,
     $_table_id = 'users_table',
     $_with_delete;
 
@@ -74,4 +74,4 @@ class ZendAfi_View_Helper_Admin_SearchUsers
 
     return http_build_query($params);
   }
-}
\ No newline at end of file
+}
diff --git a/library/ZendAfi/View/Helper/RenderUserMembership.php b/library/ZendAfi/View/Helper/RenderUserMembership.php
new file mode 100644
index 0000000000000000000000000000000000000000..d0c6daf7cef237dd3d9959b97f73713e78f6a8f4
--- /dev/null
+++ b/library/ZendAfi/View/Helper/RenderUserMembership.php
@@ -0,0 +1,39 @@
+<?php
+/**
+ * Copyright (c) 2012-2022, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class ZendAfi_View_Helper_RenderUserMembership extends ZendAfi_View_Helper_BaseHelper {
+  public function renderUserMembership(Class_User_Membership $user_membership) {
+    $string_date = $this->_('du ')
+      . Class_Date::human($user_membership->getStartDate());
+    $string_date .= ($user_membership->getEndDate())
+      ? $this->_(' au ').Class_Date::human($user_membership->getEndDate())
+      :'';
+
+    $html= [$this->_tag('strong',
+                        $user_membership->getMembership()->getLibelle()),
+            $this->_tag('span',
+                        $string_date
+            )
+    ];
+    return $this->_tag('p',implode(" ",$html));
+  }
+}
diff --git a/library/ZendAfi/View/Helper/RenderUserMemberships.php b/library/ZendAfi/View/Helper/RenderUserMemberships.php
new file mode 100644
index 0000000000000000000000000000000000000000..bbfe3a3d4e057e65eb035a50b8dd0c95f1e9c2b3
--- /dev/null
+++ b/library/ZendAfi/View/Helper/RenderUserMemberships.php
@@ -0,0 +1,34 @@
+<?php
+/**
+ * Copyright (c) 2012-2022, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class ZendAfi_View_Helper_RenderUserMemberships extends ZendAfi_View_Helper_BaseHelper {
+  public function renderUserMemberships(array $user_memberships) {
+    if (!$user_memberships)
+      return '';
+
+    return implode( array_map(function($user_membership){
+      return ($user_membership instanceOf Class_User_Membership)
+        ? $this->view->renderUserMembership($user_membership)
+        :'';},
+                              $user_memberships) );
+  }
+}
diff --git a/library/ZendAfi/View/Helper/TagListeCoches.php b/library/ZendAfi/View/Helper/TagListeCoches.php
index 40988079d8ab118cd4e03134b846df0781865696..ef2d9797cb9c23b8662e0f6e70e8b5756bd9fce3 100644
--- a/library/ZendAfi/View/Helper/TagListeCoches.php
+++ b/library/ZendAfi/View/Helper/TagListeCoches.php
@@ -23,7 +23,9 @@ class ZendAfi_View_Helper_TagListeCoches extends ZendAfi_View_Helper_BaseHelper
   const
     SOURCE_TYPE_DOC = 'type_doc',
     SOURCE_GENRE_PNB = 'genre_pnb',
-    SOURCE_SECTION_PNB = 'section_pnb';
+    SOURCE_SECTION_PNB = 'section_pnb',
+    SOURCE_TARIF = 'membership';
+
   protected
     $selected_all_means_nothing = true,
     $_name,
@@ -82,7 +84,8 @@ class ZendAfi_View_Helper_TagListeCoches extends ZendAfi_View_Helper_BaseHelper
        ->setWhere(array_filter(['id_bib' => $library_id,
                                 'invisible' => 0])),
 
-       'profile' => new ZendAfi_View_Helper_TagListeCochesSourceProfile()
+       'profile' => new ZendAfi_View_Helper_TagListeCochesSourceProfile(),
+       STATIC::SOURCE_TARIF => new ZendAfi_View_Helper_TagListeCochesSourceMembership(),
       ];
 
     return array_key_exists($data, $datas)
@@ -178,7 +181,6 @@ class ZendAfi_View_Helper_TagListeCoches extends ZendAfi_View_Helper_BaseHelper
 
     if (!$values)
       return $this;
-
     $codes = array_filter(preg_split('/[-;,]/', $values));
     foreach($codes as $code)
       $this->_handleValue($code);
@@ -208,6 +210,8 @@ class ZendAfi_View_Helper_TagListeCoches extends ZendAfi_View_Helper_BaseHelper
 
 
 class ZendAfi_View_Helper_TagListeCochesSource extends Class_Entity {
+  use Trait_Translator;
+
   public function __construct($model) {
     $this->setModelClass($model);
   }
@@ -270,7 +274,6 @@ class ZendAfi_View_Helper_TagListeCochesSourceDocType extends ZendAfi_View_Helpe
 
 
 class ZendAfi_View_Helper_TagListeCochesSourceProfile extends ZendAfi_View_Helper_TagListeCochesSource {
-  use Trait_Translator;
 
   public function __construct() {
     parent::__construct('Class_Profil');
@@ -324,3 +327,28 @@ class ZendAfi_View_Helper_TagListeCochesSourcePnb extends ZendAfi_View_Helper_Ta
                           Class_Album::findAllDilicom());
   }
 }
+
+
+
+
+class ZendAfi_View_Helper_TagListeCochesSourceMembership extends ZendAfi_View_Helper_TagListeCochesSource {
+  public function __construct() {
+    parent::__construct(Class_Membership::class);
+  }
+
+
+  protected function getKey($instance) {
+    return $instance->getId() ?? 'ignore';
+  }
+
+
+  protected function getInstances() {
+    $instances = parent::getInstances();
+
+    array_unshift($instances,
+                  (new Class_Membership)
+                  ->setCode('ignore')
+                  ->setLibelle( $this->_('Non Renseigné')));
+    return $instances;
+  }
+}
diff --git a/library/templates/Intonation/Library/Styles.php b/library/templates/Intonation/Library/Styles.php
index 333cf4fe501b6cd89727e4033bd0e928d256d54e..6c45da86cf234dae502c8d4229eba7561bed2fcf 100644
--- a/library/templates/Intonation/Library/Styles.php
+++ b/library/templates/Intonation/Library/Styles.php
@@ -22,6 +22,10 @@
 
 class Intonation_Library_Styles {
   protected $_template;
+  const CSS_DANGER='danger';
+  const CSS_WARNING='warning';
+  const CSS_SUCCESS='success';
+
 
   public function __construct($template) {
     $this->_template = $template;
diff --git a/library/templates/Intonation/Library/View/Wrapper/PNBLoan.php b/library/templates/Intonation/Library/View/Wrapper/PNBLoan.php
index 78325d92efdbea0017e43e3d1d529e239bbf1275..61c7b9421600b4d869eaf6be05cee393716ce5f0 100644
--- a/library/templates/Intonation/Library/View/Wrapper/PNBLoan.php
+++ b/library/templates/Intonation/Library/View/Wrapper/PNBLoan.php
@@ -47,8 +47,8 @@ class Intonation_Library_View_Wrapper_PNBLoan extends Intonation_Library_View_Wr
                ((new Intonation_Library_Badge)
                 ->setTag('span')
                 ->setClass(($this->_model->isLate()
-                            ? 'danger'
-                            : 'success'))
+                            ? Intonation_Library_Styles::CSS_WARNING
+                            : Intonation_Library_Styles::CSS_SUCCESS))
                 ->setImage(Class_Template::current()->getIco($this->_view,
                                                              'return-date',
                                                              'library'))
diff --git a/library/templates/Intonation/Library/View/Wrapper/User.php b/library/templates/Intonation/Library/View/Wrapper/User.php
index 9de43130a8f0fe490add097186ad1a0da0780062..9a86a624b4d4f0bd3292601e3e7cc45141adc99b 100644
--- a/library/templates/Intonation/Library/View/Wrapper/User.php
+++ b/library/templates/Intonation/Library/View/Wrapper/User.php
@@ -130,28 +130,8 @@ class Intonation_Library_View_Wrapper_User extends Intonation_Library_View_Wrapp
                     ->setTitle($this->_('Votre numéro de carte %s',
                                         $card_number)));
 
-    $validity_date = (new DateTime($this->_model->getDateFin()))->format($this->_('d/m/Y'));
-    $subscription = (new Class_User_ILSSubscription($this->_model));
-    $this->_model_validity = $subscription->isValid();
-    $validity_class = $this->_model_validity
-      ? 'success'
-      : 'danger';
-
-    $validity_class = $subscription->isAboutToExpire()
-      ? 'warning'
-      : $validity_class;
-
-    if ($this->_model->getDateFin())
-      $badges [] = ((new Intonation_Library_Badge)
-                    ->setTag('span')
-                    ->setClass($validity_class)
-                    ->setImage(Class_Template::current()
-                               ->getIco($this->_view,
-                                        'subscription',
-                                        'library'))
-                    ->setText($validity_date)
-                    ->setTitle($this->_('Vous êtes abonné(e) jusqu\'au %s',
-                                        $validity_date)));
+    $badges=[...$badges, ...array_filter($this->_subscriptionBadges($this->_model))];
+
 
     if ($number_of_loans = $cards->getLoansCount())
       $badges [] = ((new Intonation_Library_Badge)
@@ -262,6 +242,18 @@ class Intonation_Library_View_Wrapper_User extends Intonation_Library_View_Wrapp
   }
 
 
+  protected function _subscriptionBadges(object $user) : array {
+
+    return array_map(function($element){
+        return $this->_view->abonne_ILSSubscriptionsBadge(Class_User_ILSSubscription::newFor($element));
+    },
+                     ($user_memberships = $user->getUserMemberships())
+                     ? $user_memberships
+                     : [$user]
+    );
+  }
+
+
   public function getActions() {
     return [(new Intonation_Library_Link)
             ->setUrl($this->_view->url(['controller' => 'abonne',
diff --git a/library/templates/Intonation/View/Abonne/ILSSubscriptionsBadge.php b/library/templates/Intonation/View/Abonne/ILSSubscriptionsBadge.php
new file mode 100644
index 0000000000000000000000000000000000000000..8c0b5b9c2c569857dfe373c82ae346ba4fbd7b8f
--- /dev/null
+++ b/library/templates/Intonation/View/Abonne/ILSSubscriptionsBadge.php
@@ -0,0 +1,44 @@
+<?php
+/**
+ * Copyright (c) 2012-2022, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class Intonation_View_Abonne_ILSSubscriptionsBadge extends ZendAfi_View_Helper_BaseHelper {
+  public function abonne_ILSSubscriptionsBadge(Class_User_ILSSubscription $subscription) : ?Intonation_Library_Badge {
+    if (!$subscription->isDisplayed() ||!$subscription->getDateFin())
+      return null;
+
+    $validity_date = $subscription->validityDate();
+
+    return (new Intonation_Library_Badge)
+      ->setTag('span')
+      ->setClass(sprintf('%s subscription',$subscription->validityClass()))
+      ->setImage(Class_Template::current()
+                 ->getIco($this->view,
+                          'subscription',
+                          'library'))
+      ->setText($subscription->getBadgeLabel()
+                .$validity_date)
+      ->setTitle($this->_('Vous êtes %s jusqu\'au %s',
+                          $subscription->getLibelle(),
+                          $validity_date));
+
+  }
+}
diff --git a/library/templates/Intonation/View/User/Informations.php b/library/templates/Intonation/View/User/Informations.php
index e26f050d6dbb950794e14aa4a58a12d1881f7a3b..b8abdd28b777bede71fca2bcb433d82742082c11 100644
--- a/library/templates/Intonation/View/User/Informations.php
+++ b/library/templates/Intonation/View/User/Informations.php
@@ -105,9 +105,12 @@ class Intonation_View_User_Informations extends ZendAfi_View_Helper_BaseHelper {
                                               : ''),
 
             $this->_('Numéro de carte') => $user->getIdabon(),
-            $this->_('Bibliothèque') => $this->_getLibrary($user),
+            $this->_('Bibliothèque') => $this->_getLibrary($user)
     ];
 
+    if ($user_memberships = $user->getLastUserMemberships())
+      $map [$this->_('Abonnements')] = $this->_getAbonnements($user_memberships);
+
     $html = [];
 
     foreach ($map as $label => $value) {
@@ -129,4 +132,30 @@ class Intonation_View_User_Informations extends ZendAfi_View_Helper_BaseHelper {
                                   ['title' => $this->_('En lire plus sur %s',
                                                        $library->getLibelle())]);
   }
+
+  protected function _getAbonnements(array $user_memberships) :string {
+    return implode('',array_map(
+                                function ($user_membership){
+                                  $membership_libelle = $user_membership
+                                    ->getMembership()
+                                    ->getLibelle();
+                                  $date_string =  $this->_(' du %s',
+                                                           Class_Date::human($user_membership->getStartDate()));
+                                  $date_string .= ($user_membership->getEndDate())
+                                    ? $this->_(' au %s',
+                                               Class_Date::human($user_membership->getEndDate()))
+                                    :'';
+                                  return $this->view->tag('p',
+                                                          $this->view->tag('strong',
+                                                                           $membership_libelle)
+                                                          . $this->view->tag('span',
+                                                                             $date_string
+                                                          ),
+                                                          ['class' => 'subscription-information user_info',
+                                                           'title' => $this->_('Vous êtes "%s" jusqu\'au %s',
+                                                                               $membership_libelle,
+                                                                               Class_Date::human($user_membership->getEndDate()))]);
+                                },
+                                $user_memberships));
+  }
 }
diff --git a/tests/application/modules/admin/controllers/UserGroupControllerTest.php b/tests/application/modules/admin/controllers/UserGroupControllerTest.php
index 2c4dc18976029c506304c91189aca3ef65764596..fcd0e60a31d4f88d3d1adccf3cbc434492757989 100644
--- a/tests/application/modules/admin/controllers/UserGroupControllerTest.php
+++ b/tests/application/modules/admin/controllers/UserGroupControllerTest.php
@@ -1675,3 +1675,376 @@ class Admin_UserGroupControllerIndexWithSystemGroupsTest
     $this->assertNotNull(Class_UserGroup::find(2));
   }
 }
+
+
+
+
+abstract class Admin_UserGroupControllerSelectionWithMembershipTestCase extends Admin_UserGroupControllerTestCase {
+  protected $_group = null;
+  public function setUp() {
+    parent::setUp();
+
+    Class_User_Membership::setTimesource(new TimeSourceForTest('2022-12-25 00:00:01'));
+    Class_User_SearchCriteria_EndSubscriptionDate::setTimesource(new TimeSourceForTest('2022-12-25 02:01:01'));
+    Class_User_SearchCriteria_DateFin::setTimesource(new TimeSourceForTest('2022-12-25 00:00:01'));
+    $this->fixture(Class_IntBib::class,
+                   ['id' => 100,
+                    'sigb' => Class_IntBib::SIGB_NANOOK,
+                    'comm_sigb' => Class_IntBib::COM_NANOOK,
+                    'comm_params' => ['url_serveur' => 'https://mynanook.org']]);
+
+    $this->_prepareMemberships();
+
+    $this->_prepareDispatch();
+
+    $this->_group = Class_UserGroup::find(7);
+    $this->_group->cleanCriteriaCache();
+  }
+
+  protected function _prepareMemberships(){
+    $this->_batman->setRoleLevel(ZendAfi_Acl_AdminControllerRoles::ABONNE_SIGB);
+
+    $this->fixture(Class_Membership::class,
+                   ['id' => 2,
+                    'libelle' => 'Abonné Adulte Interne',
+                    'enabled' => 1,
+                   ]);
+
+    $this->fixture(Class_User_Membership::class,
+                   ['id' => 1,
+                    'user_id' => 31,
+                    'membership_id' => 2,
+                    'start_date' => '2022-01-01',
+                    'end_date' => '2023-01-01',
+                   ]);
+
+    $this->fixture(Class_Users::class,
+                   ['id' => 23,
+                    'nom' => 'logan',
+                    'prenom' => 'Wolverine',
+                    'bib' => Class_Bib::find(9),
+                    'password' => '123',
+                    'login' => '0123',
+                    'idabon' => '12344',
+                    'role_level' => ZendAfi_Acl_AdminControllerRoles::ABONNE_SIGB]);
+
+    $this->fixture(Class_User_Membership::class,
+                   ['id' => 2,
+                    'user_id' => 23,
+                    'membership_id' => 2,
+                    'start_date' => '2022-01-01',
+                    'end_date' => '2022-12-01',
+                   ]);
+
+    $this->fixture(Class_Users::class,
+                   ['id' => 24,
+                    'nom' => 'Tony',
+                    'prenom' => 'Stark',
+                    'bib' => Class_Bib::find(9),
+                    'password' => '123',
+                    'login' => '0124',
+                    'idabon' => '12345',
+                    'role_level' => ZendAfi_Acl_AdminControllerRoles::ABONNE_SIGB]);
+
+    $this->fixture(Class_User_Membership::class,
+                   ['id' => 3,
+                    'user_id' => 24,
+                    'membership_id' => 2,
+                    'start_date' => '2023-12-01',
+                    'end_date' => '2023-12-02',
+                   ]);
+
+    $this->fixture(Class_User_Membership::class,
+                   ['id' => 4,
+                    'user_id' => 24,
+                    'membership_id' => 2,
+                    'date_debut' => '2022-12-01',
+                    'date_fin' => '2022-12-25',
+                   ]);
+
+    $this->fixture(Class_Users::class,
+                   ['id' => 25,
+                    'nom' => 'Red',
+                    'prenom' => 'Richards',
+                    'bib' => Class_Bib::find(9),
+                    'password' => '123',
+                    'login' => '0125',
+                    'idabon' => '12346',
+                    'role_level' => ZendAfi_Acl_AdminControllerRoles::ABONNE_SIGB]);
+
+    $this->fixture(Class_User_Membership::class,
+                   ['id' => 5,
+                    'user_id' => 25,
+                    'membership_id' => 2,
+                    'start_date' => '2023-12-01',
+                    'end_date' => '',
+                   ]);
+
+  }
+
+
+  protected function _prepareDispatch(){
+  }
+
+  public function tearDown(){
+    Class_User_Membership::setTimesource(null);
+    Class_User_SearchCriteria_EndSubscriptionDate::setTimesource(null);
+    Class_User_SearchCriteria_DateFin::setTimesource(null);
+    parent::tearDown();
+  }
+}
+
+
+
+
+class Admin_UserGroupControllerEditPostMembershipSelectionTest
+  extends Admin_UserGroupControllerSelectionWithMembershipTestCase {
+
+  protected function _prepareDispatch(){
+    $this->postDispatch('admin/usergroup/edit/id/7',
+                        ['libelle' => 'Abonné Adultes',
+                         'search_membership' => '2']);
+  }
+
+
+  /** @test */
+  public function groupeNewLibelleShouldBeAbonneAdultes() {
+    $this->assertEquals('Abonné Adultes', $this->_group->getLibelle());
+  }
+
+
+  /** @test */
+  public function group7ShouldHaveUserBatman() {
+    $this->assertTrue($this->_group->hasUser($this->_batman));
+  }
+
+
+  /** @test */
+  public function group7ShouldNotHaveUserSpiderman() {
+    $this->assertFalse($this->_group->hasUser($this->_spiderman));
+  }
+
+
+  public function getUsersIdAndReturn(){
+    return [['23'],
+            ['24'],
+            ['25']];
+  }
+
+
+  /** @test
+   *  @dataProvider getUsersIdAndReturn
+   */
+  public function group7ShouldHaveUserIdentified($userid) {
+    $this->assertTrue($this->_group->hasUser(Class_Users::find($userid)));
+  }
+
+
+  /** @test */
+  public function responseShouldRedirectToEdit() {
+    $this->assertRedirectTo(Class_Url::absolute('/admin/usergroup/edit/id/7'));
+  }
+
+
+  /** @test */
+  public function shouldNotifySave() {
+    $this->assertFlashMessengerContentContains('Groupe "Abonné Adultes" sauvegardé');
+  }
+}
+
+
+
+
+class Admin_UserGroupControllerEditPostMembershipWithIgnoreSelectionTest
+  extends Admin_UserGroupControllerSelectionWithMembershipTestCase {
+
+  protected function _prepareDispatch(){
+    $this->postDispatch('admin/usergroup/edit/id/7',
+                        ['libelle' => 'Abonné Adultes',
+                         'search_membership' => '2;ignore']);
+  }
+
+
+  /** @test */
+  public function groupeNewLibelleShouldBeAbonneAdultes() {
+    $this->assertEquals('Abonné Adultes', $this->_group->getLibelle());
+  }
+
+
+  /** @test */
+  public function group7ShouldHaveUserBatman() {
+    $this->assertTrue($this->_group->hasUser($this->_batman));
+  }
+
+
+  /** @test */
+  public function group7ShouldHaveUserSpiderman() {
+    $this->assertTrue($this->_group->hasUser($this->_spiderman));
+  }
+
+
+  public function getUsersIdAndReturn(){
+    return [['23'],
+            ['24'],
+            ['25']];
+  }
+
+
+  /** @test
+   *  @dataProvider getUsersIdAndReturn
+   */
+  public function group7ShouldHaveUserIdentified($userid) {
+    $this->assertTrue($this->_group->hasUser(Class_Users::find($userid)));
+  }
+
+
+  /** @test */
+  public function responseShouldRedirectToEdit() {
+    $this->assertRedirectTo(Class_Url::absolute('/admin/usergroup/edit/id/7'));
+  }
+
+
+  /** @test */
+  public function shouldNotifySave() {
+    $this->assertFlashMessengerContentContains('Groupe "Abonné Adultes" sauvegardé');
+  }
+}
+
+
+
+
+class Admin_UserGroupControllerEditPostValidSubscriptionWithMembershipTest
+  extends Admin_UserGroupControllerSelectionWithMembershipTestCase {
+
+  protected function _prepareDispatch(){
+    $this->postDispatch('admin/usergroup/edit/id/7',
+                        ['libelle' => 'Abonné Adultes',
+                         'search_valid_subscription' => 1]);
+  }
+
+
+  /** @test */
+  public function groupeNewLibelleShouldBeAbonneAdultes() {
+    $this->assertEquals('Abonné Adultes', $this->_group->getLibelle());
+  }
+
+
+  /** @test */
+  public function group7ShouldHaveUserBatman() {
+    $this->assertTrue($this->_group->hasUser($this->_batman));
+  }
+
+
+  /** @test */
+  public function group7ShouldHaveUserSpiderman() {
+    $this->assertTrue($this->_group->hasUser($this->_spiderman));
+  }
+
+
+  /** @test */
+  public function responseShouldRedirectToEdit() {
+    $this->assertRedirectTo(Class_Url::absolute('/admin/usergroup/edit/id/7'));
+  }
+
+
+  /** @test */
+  public function shouldNotifySave() {
+    $this->assertFlashMessengerContentContains('Groupe "Abonné Adultes" sauvegardé');
+  }
+}
+
+
+
+
+class Admin_UserGroupControllerEditPostEndSubscriptionWithMembershipTest
+  extends Admin_UserGroupControllerSelectionWithMembershipTestCase {
+
+  protected function _prepareDispatch(){
+    $this->postDispatch('admin/usergroup/edit/id/7',
+                        ['libelle' => 'Abonnement expire dans 12 jours',
+                         'search_role_level' => 2,
+                         'search_end_subscription_days' => 12]);
+  }
+
+
+  /** @test */
+  public function groupeNewLibelleShouldBeAbonnementExpireDans() {
+    $this->assertEquals('Abonnement expire dans 12 jours', $this->_group->getLibelle());
+  }
+
+
+  /** @test */
+  public function group7ShouldHaveUserBatman() {
+    $this->assertTrue($this->_group->hasUser($this->_batman));
+  }
+
+
+  /** @test */
+  public function group7ShouldNotHaveUserSpiderman() {
+    $this->assertFalse($this->_group->hasUser($this->_spiderman));
+  }
+
+
+  public function getUsersId(){
+    return [['23'],
+            ['24'],
+            ['25']];
+  }
+
+
+  /** @test
+   *  @dataProvider getUsersId
+   */
+  public function group7ShouldNotHaveUserIdentified($userid) {
+    $this->assertFalse($this->_group->hasUser(Class_Users::find($userid)));
+  }
+}
+
+
+
+
+class Admin_UserGroupControllerEditPostDateFinWithMembershipTest
+  extends Admin_UserGroupControllerSelectionWithMembershipTestCase {
+
+  protected function _prepareDispatch(){
+    $this->postDispatch('admin/usergroup/edit/id/7',
+                        ['libelle' => 'Abonnement expire entre le 1er et le 10 janvier',
+                         'search_role_level' => 2,
+                         'search_date_fin_end' => '10/01/2023',
+                         'search_date_fin_start' => '01/01/2023']);
+  }
+
+
+  /** @test */
+  public function groupeNewLibelleShouldBeAbonnementExpireDans() {
+    $this->assertEquals('Abonnement expire entre le 1er et le 10 janvier', $this->_group->getLibelle());
+  }
+
+
+  /** @test */
+  public function group7ShouldHaveUserBatman() {
+    $this->assertTrue($this->_group->hasUser($this->_batman));
+  }
+
+
+  /** @test */
+  public function group7ShouldNotHaveUserSpiderman() {
+    $this->assertFalse($this->_group->hasUser($this->_spiderman));
+  }
+
+
+  public function getUsersId(){
+    return [['23'],
+#            ['24'],
+            //            ['25']
+    ];
+  }
+
+
+  /** @test
+   *  @dataProvider getUsersId
+   */
+  public function group7ShouldNotHaveUserIdentified($userid) {
+    $this->assertFalse($this->_group->hasUser(Class_Users::find($userid)));
+  }
+}
diff --git a/tests/application/modules/admin/controllers/UsersControllerTest.php b/tests/application/modules/admin/controllers/UsersControllerTest.php
index da54fa4325385c6c785a6070e6375ff0dbc87e8e..2a915b3bd6387e2003cbc93c0c21b7374b83fb3f 100644
--- a/tests/application/modules/admin/controllers/UsersControllerTest.php
+++ b/tests/application/modules/admin/controllers/UsersControllerTest.php
@@ -22,6 +22,7 @@
 abstract class UsersControllerWithMarcusTestCase extends AbstractControllerTestCase {
 
   protected $_storm_default_to_volatile = true;
+  protected $_intbib;
 
 
   public function setUp() {
@@ -74,14 +75,20 @@ abstract class UsersControllerWithMarcusTestCase extends AbstractControllerTestC
                                     'last_login' => null,
                                     'civilite' => Class_Users::CIVILITE_MONSIEUR]);
 
+    $this->_setIntBib();
     $this->marcus->setFicheSIGB(['type_comm' => 0])
                  ->setUserGroups([$group_vodeclic,$group_referent])
-                 ->setIntBib($this->fixture('Class_IntBib', ['id' => 100, 'comm_sigb' => 0]));
+                 ->setIntBib($this->_intbib);
 
     $this->user_loader = $this->onLoaderOfModel(Class_Users::class);
   }
 
 
+  protected function _setIntBib(){
+    $this->_intbib = $this->fixture(Class_IntBib::class, ['id' => 100, 'comm_sigb' => 0]);
+  }
+
+
   protected function _postEditData($data) {
     $this
       ->getRequest()
@@ -106,18 +113,36 @@ abstract class UsersControllerIndexTestCase extends UsersControllerWithMarcusTes
     Class_User_SearchCriteria_Age::setTimeSource($time_source);
     Class_AdminVar::set('ENABLED_SEARCH_USER_AGE',1);
 
-    Zend_Registry::set('sql', $this->mock()
-                       ->whenCalled('fetchAll')
-                       ->with('select min(id_user) as id_user, login, idabon, ordreabon, password, count(*) as doublon from bib_admin_users where role_level = 2 and id_user > 0 group by login, idabon, ordreabon, password having doublon > 1 order by id_user asc')
-                       ->answers([])
+    $this->fixture(Class_Membership::class,
+                   ['id' => 1,
+                    'code' => 1,
+                    'libelle' => 'Abonné Adulte Interne'
+                   ]);
 
-                       ->whenCalled('fetchAll')
-                       ->with('select idabon, count(*) as doublon from bib_admin_users where role_level = ' . $this->_role_level . ' group by idabon having doublon > 1;')
-                       ->answers([])
+    $this->fixture(Class_User_Membership::class,
+                   ['id' => 1,
+                    'membership_id' => 1,
+                    'user_id' => 10,
+                    'start_date' => '2012-01-01',
+                    'end_date' => '2013-01-01'
+                   ]);
 
-                       ->whenCalled('fetchAllByColumn')
-                       ->with('select distinct(id_user) as id from notices_avis')
-                       ->answers([2233, 987398]));
+    $sql_mock = $this
+      ->mock()
+      ->whenCalled('fetchAll')
+      ->with('select min(id_user) as id_user, login, idabon, ordreabon, password, count(*) as doublon from bib_admin_users where role_level = 2 and id_user > 0 group by login, idabon, ordreabon, password having doublon > 1 order by id_user asc')
+      ->answers([])
+
+      ->whenCalled('fetchAll')
+      ->with('select idabon, count(*) as doublon from bib_admin_users where role_level = ' . $this->_role_level . ' group by idabon having doublon > 1;')
+      ->answers([])
+
+      ->whenCalled('fetchAllByColumn')
+      ->with('select distinct(id_user) as id from notices_avis')
+      ->answers([2233, 987398]);
+
+
+    Zend_Registry::set('sql', $sql_mock);
 
     $user = $this->fixture(Class_Users::class,
                            ['id' => 1,
@@ -132,8 +157,10 @@ abstract class UsersControllerIndexTestCase extends UsersControllerWithMarcusTes
                                'password' => 'francis',
                                'last_login' => 0]);
 
-    $borrower_birthdate = (in_array($this->_role_level, [1,2]))
-      ? '(STR_TO_DATE(date_fin, \'%Y-%m-%d\') >= CURDATE()) AND '
+    $borrower_validSubscription = (in_array($this->_role_level, [1,2]))
+      ? (Class_IntBib::isSingleNanook()
+         ? '(id_user IN (select user_id from user_membership where CURDATE() <= user_membership.end_date)) AND '
+         : '(STR_TO_DATE(date_fin, \'%Y-%m-%d\') >= CURDATE()) AND ')
       : '';
 
     $this->onLoaderOfModel(Class_Users::class)
@@ -163,14 +190,14 @@ abstract class UsersControllerIndexTestCase extends UsersControllerWithMarcusTes
          ->whenCalled('findAllBy')
          ->with(['role_level' => (string) $this->_role_level,
                  'order' => 'nom asc',
-                 'where' => $borrower_birthdate . '(naissance <=\'2002-02-01\') AND (naissance >=\'1913-02-01\') AND (id_user in (2233,987398)) AND (login LIKE "%francis%" OR nom LIKE "%francis%" OR prenom LIKE "%francis%" OR pseudo LIKE "%francis%" OR mail LIKE "%francis%" OR idabon LIKE "%francis%") AND (role_level <= 7)',
+                 'where' => $borrower_validSubscription . '(naissance <=\'2002-02-01\') AND (naissance >=\'1913-02-01\') AND (id_user in (2233,987398)) AND (login LIKE "%francis%" OR nom LIKE "%francis%" OR prenom LIKE "%francis%" OR pseudo LIKE "%francis%" OR mail LIKE "%francis%" OR idabon LIKE "%francis%") AND (role_level <= 7)',
                  'limitPage' => [1, 20]])
          ->answers([$francis])
 
          ->whenCalled('countBy')
          ->with(['role_level' => (string) $this->_role_level,
                  'order' => 'nom asc',
-                 'where' => $borrower_birthdate . '(naissance <=\'2002-02-01\') AND (naissance >=\'1913-02-01\') AND (id_user in (2233,987398)) AND (login LIKE "%francis%" OR nom LIKE "%francis%" OR prenom LIKE "%francis%" OR pseudo LIKE "%francis%" OR mail LIKE "%francis%" OR idabon LIKE "%francis%") AND (role_level <= 7)'])
+                 'where' => $borrower_validSubscription . '(naissance <=\'2002-02-01\') AND (naissance >=\'1913-02-01\') AND (id_user in (2233,987398)) AND (login LIKE "%francis%" OR nom LIKE "%francis%" OR prenom LIKE "%francis%" OR pseudo LIKE "%francis%" OR mail LIKE "%francis%" OR idabon LIKE "%francis%") AND (role_level <= 7)'])
          ->answers(55)
 
          ->whenCalled('findAllBy')
@@ -185,6 +212,13 @@ abstract class UsersControllerIndexTestCase extends UsersControllerWithMarcusTes
 
     $this->dispatch('/admin/users?search_id_site=all&search_role_level=' . $this->_role_level . '&search_age_debut=10&search_age_fin=99&search_valid_subscription=1&search_review=1&search_search_for=\"\'fra"n\'cis"');
   }
+
+  public function tearDown(){
+    Zend_Registry::set('sql', null);
+    Class_User_SearchCriteria_Age::setTimeSource(null);
+    Class_AdminVar::set('ENABLED_SEARCH_USER_AGE',0);
+    parent::tearDown();
+  }
 }
 
 
@@ -194,7 +228,6 @@ class UsersControllerIndexSearchRoleLevelBorowersTest extends UsersControllerInd
 
   protected $_role_level = 2;
 
-
   /** @test */
   public function formShouldContainsTextSearch() {
     $this->assertXPath('//input[@name="search_search_for"]');
@@ -233,6 +266,29 @@ class UsersControllerIndexSearchRoleLevelBorowersTest extends UsersControllerInd
   }
 
 
+  /** @test */
+  public function formShouldNotContainsMembershipDropDown() {
+    $this->assertNotXPathContentContains('//label[@data-name="search_membership"]',
+                                      "Type d'abonnement");
+  }
+
+
+  /** @test */
+  public function formMulticheckboxDropdownMembershipShouldNotContainsMembershipNonRenseigne() {
+    $this->assertNotXPathContentContains('//div[@id="search_membership_saisie"]',
+                                      'Non Renseigné<br/>');
+    $this->assertNotXPath('//input[@type="checkbox"][@clef="ignore"]');
+  }
+
+
+  /** @test */
+  public function formMulticheckboxDropdownMembershipShouldNotContainsMembershipAbonneInterne() {
+    $this->assertNotXPathContentContains('//div[@id="search_membership_saisie"]',
+                                      'Abonné Adulte Interne<br/>');
+    $this->assertNotXPath('//input[@type="checkbox"][@clef="1"]');
+  }
+
+
   /** @test */
   public function formShouldContainsInLastExportSelect() {
     $this->assertXPathContentContains('//select[@name="search_statut"]//option[@value="all"]',
@@ -285,7 +341,8 @@ class UsersControllerIndexSearchRoleLevelBorowersTest extends UsersControllerInd
 
   /** @test */
   public function jsToogleVisibilityForRoleLevelBorrowersShouldBeForStatut() {
-    $this->assertXPathContentContains('//script', 'formSelectToggleVisibilityForElement("#search_role_level", $("#search_statut").closest("tr"), ["2"]);');
+    $this->assertXPathContentContains('//script',
+                                      'formSelectToggleVisibilityForElement("#search_role_level", $("#search_statut").closest("tr"), ["2"]);');
   }
 
 
@@ -317,6 +374,57 @@ class UsersControllerIndexSearchRoleLevelBorowersTest extends UsersControllerInd
 
 
 
+
+class UsersControllerIndexSearchRoleLevelAbonneWithNanookUniqueTest extends UsersControllerIndexTestCase {
+
+  protected $_role_level = 2;
+
+  protected function _setIntBib(){
+    $this->_intbib = $this->fixture(Class_IntBib::class,
+                                    ['id' => 100,
+                                     'sigb' => Class_IntBib::SIGB_NANOOK,
+                                     'comm_sigb' => Class_IntBib::COM_NANOOK,
+                                     'comm_params' => ['url_serveur' => 'https://mynanook.org']]);
+  }
+
+
+  /** @test */
+  public function singleNanookShouldBeTrue() {
+    $this->assertEquals(1,Class_IntBib::isSingleNanook());
+  }
+
+  /** @test */
+  public function formShouldContainsMembershipDropDown() {
+    $this->assertXPathContentContains('//label[@data-name="search_membership"]',
+                                      "Type d'abonnement");
+  }
+
+
+  /** @test */
+  public function formMulticheckboxDropdownMembershipShouldContainsMembershipNonRenseigne() {
+    $this->assertXPathContentContains('//div[@id="search_membership_saisie"]',
+                                      'Non Renseigné<br/>');
+    $this->assertXPath('//input[@type="checkbox"][@clef="ignore"]');
+  }
+
+
+  /** @test */
+  public function formMulticheckboxDropdownMembershipShouldContainsMembershipAbonneInterne() {
+    $this->assertXPathContentContains('//div[@id="search_membership_saisie"]',
+                                      'Abonné Adulte Interne<br/>');
+    $this->assertXPath('//input[@type="checkbox"][@clef="1"]');
+  }
+
+
+  /** @test */
+  public function jsToogleVisibilityForRoleLevelBorrowersShouldBeForStatut() {
+    $this->assertXPathContentContains('//script',
+                                      'formSelectToggleVisibilityForElement("#search_role_level", $("#search_statut,#search_membership").closest("tr"), ["2"]);');
+  }
+}
+
+
+
 class UsersControllerIndexSearchRoleLevelGuestTest extends UsersControllerIndexTestCase {
 
   protected $_role_level = 0;
@@ -1961,25 +2069,25 @@ class UsersControllerManageDoubleUserTest extends UsersControllerDoubleTestCase
 
   /** @test */
   public function buttonMergeShouldBe654to25() {
-    $this->assertXpath('//button[contains(@onclick, "/admin/users/manage-double-merge/id_user/25/id_user_to/25/id_user_from/654")]',$this->_response->getBody());
+    $this->assertXpath('//button[contains(@onclick, "/admin/users/manage-double-merge/id_user/25/id_user_to/25/id_user_from/654")]');
   }
 
 
   /** @test */
   public function buttonMergeShouldBe25to654() {
-    $this->assertXpath('//button[contains(@onclick, "/admin/users/manage-double-merge/id_user/25/id_user_to/654/id_user_from/25_655")]',$this->_response->getBody());
+    $this->assertXpath('//button[contains(@onclick, "/admin/users/manage-double-merge/id_user/25/id_user_to/654/id_user_from/25_655")]');
   }
 
 
   /** @test */
   public function buttonIgnoreAndContinueShouldHaveUserId2() {
-    $this->assertXpath('//button[contains(@onclick, "/admin/users/manage-double-user/id_user/2")]',$this->_response->getBody());
+    $this->assertXpath('//button[contains(@onclick, "/admin/users/manage-double-user/id_user/2")]');
   }
 
 
   /** @test */
   public function buttonPreviousShouldNotBeAvailable() {
-    $this->assertXpathContentContains('//button[contains(@disabled, "disabled")]','Précédent',$this->_response->getBody());
+    $this->assertXpathContentContains('//button[contains(@disabled, "disabled")]','Précédent');
   }
 }
 
@@ -1995,7 +2103,7 @@ class UsersControllerManageNextDoubleUserTest extends UsersControllerDoubleTestC
 
   /** @test*/
   public function buttonMergeShouldBe2to29() {
-    $this->assertXpath('//button[contains(@onclick, "/admin/users/manage-double-merge/id_user/2/id_user_to/29/id_user_from/2")]',$this->_response->getBody());
+    $this->assertXpath('//button[contains(@onclick, "/admin/users/manage-double-merge/id_user/2/id_user_to/29/id_user_from/2")]');
   }
 
 
@@ -2475,6 +2583,12 @@ class Admin_UsersControllerEditModoBibTest extends Admin_AbstractControllerTestC
   public function formIdSIteOptionsShouldNotContainsWonderVille() {
     $this->assertXPathCount('//form//select[@id="id_site"]/option',2);
   }
+
+
+  /** @test */
+  public function formShouldNotContainsSubscriptionInformation() {
+    $this->assertNotXPath('//form//tr[@class="subscription_information"]');
+  }
 }
 
 
@@ -2577,7 +2691,7 @@ class Admin_UsersControllerEditFormTest extends AbstractControllerTestCase {
 
 class UsersControllerIndexSearchRoleLevelPortalBorowersTest extends UsersControllerIndexTestCase {
 
-  protected $_role_level = 1;
+  protected $_role_level = ZendAfi_Acl_AdminControllerRoles::ABONNE;
 
 
   /** @test */
@@ -2611,9 +2725,458 @@ class UsersControllerIndexSearchRoleLevelPortalBorowersTest extends UsersControl
   }
 
 
+  /** @test */
+  public function formShouldNotContainsAnyUserMembershipElement() {
+    $this->assertNotXPath('//input[contains(@name,"search_user_membership")]');
+  }
+
+
   /** @test */
   public function formShouldContainsSubscriptionEndDateRange() {
     $this->assertXPath('//input[@name="search_date_fin_start"]');
     $this->assertXPath('//input[@name="search_date_fin_end"]');
   }
 }
+
+
+
+
+class UsersControllerSearchUserMembershipPostDispatchTest extends AbstractControllerTestCase {
+  protected $_role_level = ZendAfi_Acl_AdminControllerRoles::ABONNE_SIGB;
+
+  public function setUp() {
+    parent::setUp();
+    $this->fixture(Class_IntBib::class,
+                   ['id' => 100,
+                    'sigb' => Class_IntBib::SIGB_NANOOK,
+                    'comm_sigb' => Class_IntBib::COM_NANOOK,
+                    'comm_params' => ['url_serveur' => 'https://mynanook.org']]);
+
+    $mock_sql =
+      $this->mock()
+           ->whenCalled('fetchAllByColumn')
+           ->with('select user_id, max(end_date) from user_membership group by user_id having  max(end_date) >= CURDATE() AND max(end_date) <=\'2012-02-04\' ')
+           ->answers([123,456])
+
+           ->whenCalled('fetchAllByColumn')
+           ->with('select user_id, max(end_date) from user_membership group by user_id having max(end_date) >= "2012-12-09" AND max(end_date) <= "2013-01-09"')
+           ->answers([123,456])
+
+           ->whenCalled('fetchAllByColumn')
+           ->with('select user_id, max(end_date) from user_membership group by user_id having max(end_date) >= "2010-12-01" AND max(end_date) <= "2010-12-31"')
+           ->answers([])
+
+           ->whenCalled('fetchAll')
+           ->with('select min(id_user) as id_user, id_sigb, count(*) as doublon from bib_admin_users where role_level = 2 and id_user > 0 group by id_sigb having doublon > 1 order by id_user asc')
+           ->answers([])
+
+           ->whenCalled('fetchAll')
+           ->with('select min(id_user) as id_user, login, idabon, ordreabon, password, count(*) as doublon from bib_admin_users where role_level = 2 and id_user > 0 group by login, idabon, ordreabon, password having doublon > 1 order by id_user asc')
+           ->answers([])
+           ->beStrict();
+
+    Zend_Registry::set('sql', $mock_sql);
+
+    $this->fixture(Class_User_Membership::class,
+                   ['id'=>1,
+                    'membership_id'=>1,
+                    'user_id'=>1,
+                    'start_date' => '2012-01-01',
+                    'end_date' => '2013-01-01']);
+
+    $time_source = new TimeSourceForTest('2012-02-01 14:00:00');
+    Class_User_SearchCriteria_Membership::setTimeSource($time_source);
+    Class_User_SearchCriteria_EndSubscriptionDate::setTimeSource($time_source);
+    $this->OnLoaderOfModel(Class_Users::class);
+  }
+
+
+  public function tearDown(){
+    Class_User_SearchCriteria_EndSubscriptionDate::setTimeSource(null);
+    Class_User_SearchCriteria_Membership::setTimeSource(null);
+    parent::tearDown();
+  }
+
+
+  /** @test */
+  public function queryOnUsersShouldSearchOnMembershipIdOneOrTwo() {
+    $this->postDispatch('/admin/users',['search_membership' => '1;2']);
+    $this->assertEquals([
+                         'order' => 'nom asc',
+                         'where' => '((id_user in ( select distinct (user_id) from user_membership where membership_id in (1,2)))) AND (role_level <= 6)',
+                         'limitPage' => [1, 20]
+                         ],
+                        Class_Users::getFirstAttributeForMethodCallAt('findAllBy',1)
+    );
+  }
+
+
+  /** @test */
+  public function whenAllMembershipSelectedQueryOnUsersShouldNotFilterByMembership() {
+    $this->postDispatch('/admin/users',['search_membership' => 'ignore']);
+    $this->assertEquals([
+                         'order' => 'nom asc',
+                         'where' => '((id_user not in (select distinct (user_id) from user_membership))) AND (role_level <= 6)',
+                         'limitPage' => [1, 20]
+                         ],
+                        Class_Users::getFirstAttributeForMethodCallAt('findAllBy',1)
+    );
+  }
+
+
+  /** @test */
+  public function whenNoSearchMembershipSentQueryOnUsersShouldNotFilterOnMembership() {
+    $this->postDispatch('/admin/users',['role_level' => 2]);
+    $this->assertEquals([
+                         'order' => 'nom asc',
+                         'where' => '(role_level <= 6)',
+                         'limitPage' => [1, 20]
+                         ],
+                        Class_Users::getFirstAttributeForMethodCallAt('findAllBy',1)
+    );
+  }
+
+
+  /** @test */
+  public function whenSearchMembershipProvidedShouldReturnNoResult() {
+    $this->postDispatch('/admin/users',['search_role_level' => 2,
+                                        'search_valid_subscription' => 1,
+                                        'search_membership' => 1]);
+    $this->assertEquals([
+                         'order' => 'nom asc',
+                         'where' => '(id_user IN (select user_id from user_membership where CURDATE() <= user_membership.end_date)) AND ((id_user in ( select distinct (user_id) from user_membership where membership_id in (1)))) AND (role_level <= 6)',
+                         'role_level' => 2,
+                         'limitPage' => [1, 20]
+                         ],
+                        Class_Users::getFirstAttributeForMethodCallAt('findAllBy',1)
+    );
+  }
+
+
+  /** @test */
+  public function whenSearchMembershipProvidedWithEndSubscriptionInXDaysShouldProcessExpectedQuery() {
+    $this->postDispatch('/admin/users',['role_level' => 2,
+                                        'search_role_level' => 2,
+                                        'search_end_subscription_days' => 3,
+                                        'search_membership' => 1]);
+    $this->assertEquals([
+                         'order' => 'nom asc',
+                         'where' => '(id_user in (123,456)) AND ((id_user in ( select distinct (user_id) from user_membership where membership_id in (1)))) AND (role_level <= 6)',
+                         'role_level' => 2,
+                         'limitPage' => [1, 20]
+                         ],
+                        Class_Users::getFirstAttributeForMethodCallAt('findAllBy',1)
+    );
+  }
+
+
+  /** @test */
+  public function whenSearchMembershipProvidedWithDateFinStartAndEndShouldProcessExpectedQuery() {
+    $this->postDispatch('/admin/users',['role_level' => 2,
+                                        'search_date_fin_start' => '09/12/2012',
+                                        'search_date_fin_end' => '09/01/2013',
+                                        'search_membership' => 1]);
+    $this->assertEquals([
+                         'order' => 'nom asc',
+                         'where' => '(id_user in (123,456)) AND ((id_user in ( select distinct (user_id) from user_membership where membership_id in (1)))) AND (role_level <= 6)',
+                         'limitPage' => [1, 20]
+                         ],
+                        Class_Users::getFirstAttributeForMethodCallAt('findAllBy',1)
+    );
+  }
+
+
+  /** @test */
+  public function whenSearchMembershipProvidedWithDateFinAndNoUserhouldDisplayAucunUtilisateur() {
+    $this->postDispatch('/admin/users',['role_level' => 2,
+                                        'search_date_fin_start' => '01/12/2010',
+                                        'search_date_fin_end' => '31/12/2010',
+                                        'search_membership' => 1]);
+    $this->assertXPathContentContains('//p',
+                                      'Aucun utilisateur trouvé'
+    );
+  }
+}
+
+
+
+
+class UsersControllerSearchNoUserMembershipPostDispatchTest extends AbstractControllerTestCase {
+  protected $_role_level = ZendAfi_Acl_AdminControllerRoles::ABONNE_SIGB;
+
+
+  public function setUp() {
+    parent::setUp();
+    $this->fixture(Class_IntBib::class,
+                   ['id' => 100,
+                    'sigb' => Class_IntBib::SIGB_NANOOK,
+                    'comm_sigb' => Class_IntBib::COM_NANOOK,
+                    'comm_params' => ['url_serveur' => 'https://mynanook.org']]);
+
+    $time_source = new TimeSourceForTest('2012-02-01 14:00:00');
+    Class_User_SearchCriteria_EndSubscriptionDate::setTimeSource($time_source);
+    Class_User_SearchCriteria_Membership::setTimeSource($time_source);
+    $this->OnLoaderOfModel(Class_Users::class);
+  }
+
+
+  public function tearDown(){
+    Class_User_SearchCriteria_EndSubscriptionDate::setTimeSource(null);
+    Class_User_SearchCriteria_Membership::setTimeSource(null);
+    parent::tearDown();
+  }
+
+
+  /** @test */
+  public function queryOnUsersShouldSearchOnMembershipIdOneOrTwo() {
+    $this->postDispatch('/admin/users',['search_membership' => '1;2']);
+    $this->assertEquals([
+                         'order' => 'nom asc',
+                         'where' => '((id_user in ( select distinct (user_id) from user_membership where membership_id in (1,2)))) AND (role_level <= 6)',
+                         'limitPage' => [1, 20]
+                         ],
+                        Class_Users::getFirstAttributeForMethodCallAt('findAllBy',1)
+    );
+  }
+
+
+  /** @test */
+  public function whenValidSubscriptionQueryOnUsersShouldNotUseUserMembership() {
+    $this->postDispatch('/admin/users',['search_role_level' => 2,
+                                        'search_valid_subscription' => 1]);
+    $this->assertEquals([
+                         'order' => 'nom asc',
+                         'where' => '(STR_TO_DATE(date_fin, \'%Y-%m-%d\') >= CURDATE()) AND (role_level <= 6)',
+                         'role_level' => 2,
+                         'limitPage' => [1, 20]
+                         ],
+                        Class_Users::getFirstAttributeForMethodCallAt('findAllBy',1)
+    );
+  }
+
+
+  /** @test */
+  public function whenDateFinQueryOnUsersShouldFilterOnUserDateFin() {
+    $this->postDispatch('/admin/users',['role_level' => 2,
+                                        'search_date_fin_start' => '01/02/2012',
+                                        'search_date_fin_end' => '01/05/2013',
+                                        ]);
+    $this->assertEquals([
+                         'order' => 'nom asc',
+                         'where' => '(left(date_fin, 10) >= "2012-02-01") AND (left(date_fin, 10) <= "2013-05-01") AND (role_level <= 6)',
+                         'limitPage' => [1, 20]
+                         ],
+                        Class_Users::getFirstAttributeForMethodCallAt('findAllBy',1)
+    );
+  }
+
+
+  /** @test */
+  public function whenEndSubscriptionDaysQueryOnUsersShouldNotUseUserMembership() {
+    $this->postDispatch('/admin/users',['search_role_level' => 2,
+                                        'search_end_subscription_days' => 3]);
+    $this->assertEquals([
+                         'order' => 'nom asc',
+                         'role_level' => 2,
+                         'where' => '(date_fin!=\'\' and date_fin <=\'2012-02-04\') AND (role_level <= 6)',
+                         'limitPage' => [1, 20]
+                         ],
+                        Class_Users::getFirstAttributeForMethodCallAt('findAllBy',1)
+    );
+  }
+}
+
+
+
+
+abstract class UsersControllerEditUserMarcusWithUserMembershipsTestCase extends UsersControllerWithMarcusTestCase {
+
+  public function setUp() {
+    parent::setUp();
+
+    $this->_prepareMemberships();
+
+    $this->_prepareTimesource();
+
+    $this->dispatch('/admin/users/edit/id/10');
+  }
+
+
+  public function tearDown(){
+    Class_User_Membership::setTimesource(null);
+    parent::tearDown();
+  }
+
+  protected function _prepareMemberships(){
+    $this->fixture(Class_Membership::class,
+                   ['id'=>1,
+                    'code'=>1,
+                    'libelle' => 'Abonné Adulte intra-muros',
+                    'enabled' => 1]);
+
+    $this->fixture(Class_Membership::class,
+                   ['id'=>2,
+                    'code'=>2,
+                    'libelle' => 'Abonnement invalide',
+                    'enabled' => 1]);
+
+    $this->fixture(Class_Membership::class,
+                   ['id'=>3,
+                    'code'=>3,
+                    'libelle' => 'Bouquets numériques',
+                    'enabled' => 1]);
+
+    $this->fixture(Class_User_Membership::class,
+                   ['id' => 1,
+                    'membership_id' => 1,
+                    'user_id' => 10,
+                    'start_date' => '2012-05-01',
+                    'end_date' => '2013-01-01']);
+
+    $this->fixture(Class_User_Membership::class,
+                   ['id' => 2,
+                    'membership_id' => 2,
+                    'user_id' => 10,
+                    'start_date' => '1970-01-01',
+                    'end_date' => '1971-06-06']);
+
+    $this->fixture(Class_User_Membership::class,
+                   ['id' => 3,
+                    'membership_id' => 3,
+                    'user_id' => 10,
+                    'start_date' => '2012-01-01',
+                    'end_date' => '']);
+
+  }
+}
+
+
+
+class UsersControllerEditUserMarcusWithUserMembershipsIn2011TestCase
+  extends UsersControllerEditUserMarcusWithUserMembershipsTestCase {
+  protected function _prepareTimesource(){
+    $time_source = new TimeSourceForTest('2011-02-01 14:00:00');
+    Class_User_Membership::setTimeSource($time_source);
+  }
+
+  /** @test **/
+  public function LabelInformationAbonnementSIGBShouldBeDisplayed() {
+    $this->assertXPathContentContains('//tr[@class="subscription_information"]//td','Abonnement(s) SIGB');
+  }
+
+
+  /** @test **/
+  public function abonnementInformationShouldContainsExactlyTwoItems() {
+    $this->assertXPathCount('//tr[@class="subscription_information"]//td//p',2);
+  }
+
+
+  /** @test **/
+  public function abonnementInformationShouldContainsAdulteIntraMuros() {
+    $this->assertXPathContentContains('//tr[@class="subscription_information"]//td//p//strong','Abonné Adulte intra-muros');
+  }
+
+
+  /** @test **/
+  public function abonnementInformationShouldContainsDu1Mai2012() {
+    $this->assertXPathContentContains('//tr[@class="subscription_information"]//td//p','du 01/05/2012', $this->_response->getBody());
+  }
+
+
+  /** @test **/
+  public function abonnementInformationShouldContainsBouquetNumerique() {
+    $this->assertXPathContentContains('//tr[@class="subscription_information"]//td//p//strong','Bouquets numériques');
+  }
+
+
+  /** @test **/
+  public function abonnementInformationShouldContainsDu1Janvier2012() {
+    $this->assertXPathContentContains('//tr[@class="subscription_information"]//td//p',
+                                      'du 01/01/2012');
+  }
+
+
+  /** @test **/
+  public function abonnementShouldNotContainsAbonnementInvalide() {
+    $this->assertNotXPathContentContains('//tr[@class="subscription_information"]//td//p//strong','Abonnement invalide');
+  }
+}
+
+
+class UsersControllerEditUserMarcusWithUserMembershipsIn2023TestCase
+  extends UsersControllerEditUserMarcusWithUserMembershipsTestCase {
+
+  protected function _prepareMemberships() {
+    parent::_prepareMemberships();
+    Class_User_Membership::find(3)->setEndDate('2021-12-01')
+                             ->save();
+  }
+
+  protected function _prepareTimesource(){
+    $time_source = new TimeSourceForTest('2023-02-01 14:00:00');
+    Class_User_Membership::setTimeSource($time_source);
+  }
+
+  /** @test **/
+  public function LabelInformationAbonnementSIGBShouldBeDisplayed() {
+    $this->assertXPathContentContains('//tr[@class="subscription_information"]//td','Abonnement(s) SIGB');
+  }
+
+
+  /** @test **/
+  public function abonnementInformationShouldContainsOneItemOnly() {
+    $this->assertXPathCount('//tr[@class="subscription_information"]//td//p',1);
+  }
+
+
+  /** @test **/
+  public function abonnementInformationShouldNotContainsAdulteIntraMuros() {
+    $this->assertNotXPathContentContains('//tr[@class="subscription_information"]//td//p//strong','Abonné Adulte intra-muros');
+  }
+
+
+  /** @test **/
+  public function abonnementInformationShouldNotContainsDu1Mai2012() {
+    $this->assertNotXPathContentContains('//tr[@class="subscription_information"]//td//p','du 01/05/2012');
+  }
+
+
+  /** @test **/
+  public function abonnementInformationShouldContainsBouquetNumerique() {
+    $this->assertXPathContentContains('//tr[@class="subscription_information"]//td//p//strong','Bouquets numériques');
+  }
+
+
+  /** @test **/
+  public function abonnementInformationShouldContainsDu1Janvier2012() {
+    $this->assertXPathContentContains('//tr[@class="subscription_information"]//td//p',
+                                      'du 01/01/2012 au 01/12/2021');
+  }
+
+
+  /** @test **/
+  public function abonnementShouldNotContainsAbonnementInvalide() {
+    $this->assertNotXPathContentContains('//tr[@class="subscription_information"]//td//p//strong','Abonnement invalide');
+  }
+}
+
+
+
+
+class UsersControllerEditUserMarcusWithNoUserMembershipsIn2023TestCase
+  extends UsersControllerEditUserMarcusWithUserMembershipsTestCase {
+
+  protected function _prepareMemberships() {
+    Class_User_Membership::deleteBy(['user_id'=>10]);
+  }
+
+  protected function _prepareTimesource(){
+    $time_source = new TimeSourceForTest('2023-02-01 14:00:00');
+    Class_User_Membership::setTimeSource($time_source);
+  }
+
+  /** @test **/
+  public function LabelInformationAbonnementSIGBShouldNotBeDisplayed() {
+    $this->assertNotXPathContentContains('//tr[@class="subscription_information"]//td','Abonnement(s) SIGB');
+  }
+}
diff --git a/tests/application/modules/opac/controllers/AuthControllerLostPasswordTest.php b/tests/application/modules/opac/controllers/AuthControllerLostPasswordTest.php
index 6e0acb7c4b692fa4798871ed30fe5de4bd1f14e4..901ad9e742f2cd116cb04182eda66fed7364bb66 100644
--- a/tests/application/modules/opac/controllers/AuthControllerLostPasswordTest.php
+++ b/tests/application/modules/opac/controllers/AuthControllerLostPasswordTest.php
@@ -385,8 +385,8 @@ class AuthControllerLostPasswordOrpheeOnDemandBorrowerCreationTest
               <cat_pret><![CDATA[7]]></cat_pret>
               <codif><![CDATA[0]]></codif>
               <lib_cat_pret><![CDATA[  Pers médiat]]></lib_cat_pret>
-              <cat_tarif><![CDATA[1]]></cat_tarif>
-              <lib_cat_tarif><![CDATA[  Gratuit]]></lib_cat_tarif>
+              <cat_membership><![CDATA[1]]></cat_membership>
+              <lib_cat_membership><![CDATA[  Gratuit]]></lib_cat_membership>
               <anx><![CDATA[1]]></anx>
               <lib_anx><![CDATA[  Bibliothèque]]></lib_anx>
               <site><![CDATA[20]]></site>
diff --git a/tests/application/modules/opac/controllers/AuthControllerWithNanookTest.php b/tests/application/modules/opac/controllers/AuthControllerWithNanookTest.php
index fc757ac0b58b333b98bc6f991c7e026ae7076efd..50a64d3b10cfa77cb00e976b82e4019439335c18 100644
--- a/tests/application/modules/opac/controllers/AuthControllerWithNanookTest.php
+++ b/tests/application/modules/opac/controllers/AuthControllerWithNanookTest.php
@@ -58,6 +58,7 @@ abstract class AuthControllerNanookTestCase extends AbstractControllerTestCase {
 
     $this->fixture('Class_IntBib',
                    ['id' => 5,
+                    'sigb' => Class_IntBib::SIGB_NANOOK,
                     'comm_sigb' => Class_IntBib::COM_NANOOK,
                     'comm_params' => serialize($params)]);
   }
@@ -327,15 +328,18 @@ class AuthControllerWithNanookPostLoginWithMailAndUnsecurePasswordOthersLogins
 
 
 require_once(__DIR__ . '/../../../../fixtures/NanookFixtures.php');
-class AuthControllerWithNanookPostAxelLogin
+abstract class AuthControllerWithNanookPostAxelLoginTestCase
   extends AuthControllerNanookTestCase {
 
+  protected $_patron_info;
 
   public function setUp() {
     parent::setUp();
 
     Class_WebService_SIGB_AbstractRESTService::shouldThrowError(true);
 
+    $this->_prepareMemberships();
+
     $this->_web_client
       ->whenCalled('open_url')
       ->with('http://localhost:8080/afi_Nanook/ilsdi/service/AuthenticatePatron/username/Axel/password/2022')
@@ -343,7 +347,8 @@ class AuthControllerWithNanookPostAxelLogin
 
       ->whenCalled('open_url')
       ->with('http://localhost:8080/afi_Nanook/ilsdi/service/GetPatronInfo/patronId/8')
-      ->answers(NanookFixtures::axelPatronInfo());
+      ->answers($this->_patron_info);
+
 
     $this->postDispatch('/opac/auth/login',
                         ['username' => 'Axel',
@@ -351,6 +356,25 @@ class AuthControllerWithNanookPostAxelLogin
   }
 
 
+  public function tearDown(){
+    parent::tearDown();
+    Class_User_Membership::setTimesource(null);
+  }
+
+
+  protected function _prepareMemberships(){
+    Class_User_Membership::setTimesource(new TimeSourceForTest('2022-01-01 08:00:00'));
+    $this->_patron_info = NanookFixtures::axelPatronInfo();
+    $this->fixture(Class_Membership::class,
+                   ['id' => 1415,
+                    'code' => '14',
+                    'libelle' => 'Abonne Adulte',
+                    'enabled' => 1,
+                    'date_maj' => ""
+                   ]);
+  }
+
+
   /** @test */
   public function axelMultimediaAccessShouldBeUpdatedToZero() {
     $this->assertEquals(0, Class_Users::findFirstBy(['login' => 'Axel'])->getMultimediaAccess());
@@ -361,4 +385,210 @@ class AuthControllerWithNanookPostAxelLogin
   public function axelParentalAuthorizationShouldBeUpdatedToOne() {
     $this->assertEquals(1, Class_Users::findFirstBy(['login' => 'Axel'])->getParentalAuthorization());
   }
-}
\ No newline at end of file
+}
+
+
+
+class AuthControllerWithNanookPostAxelLoginNoMemberships
+  extends AuthControllerWithNanookPostAxelLoginTestCase {
+
+
+  /** @test */
+  public function membershipShouldHaveBeenCreated() {
+    $this->assertEquals(['id' => 1416,
+                         'code' => '3',
+                         'libelle' => 'Abonné Adulte intra-muros',
+                         'enabled' => 0,
+                         'date_maj'=>''],
+                        Class_Membership::findFirstBy(['code' => 3])->toArray());
+  }
+
+
+  /** @test */
+  public function userMembershipShouldHaveBeenCreated() {
+    $user_membership = Class_User_Membership::find(1);
+    $this->assertEquals(['id'=> 1,
+                         'membership_id'=> 1416,
+                         'user_id' => 667,
+                         'start_date' => '2022-02-16',
+                         'end_date'=>'2023-02-16'],
+                        $user_membership->toArray());
+    return $user_membership;
+  }
+
+
+  /** @test
+   * @depends userMembershipShouldHaveBeenCreated
+   */
+  public function userAxelgetUserMembershipsShouldBeUserMembershipCreated($usermembership) {
+    $this->assertEquals([$usermembership],
+                        Class_Users::find(667)->getUserMemberships());
+  }
+}
+
+
+
+
+class AuthControllerWithNanookPostAxelLoginWithExistingMemberships
+  extends AuthControllerWithNanookPostAxelLoginTestCase {
+
+  protected function _prepareMemberships(){
+    parent::_prepareMemberships();
+    $this->fixture(Class_Membership::class,
+                   ['id' => 1,
+                    'libelle' => 'Abonné Adulte intra-muros',
+                    'enabled' => 1,
+                    'code' => 3
+                   ]);
+
+    $this->fixture(Class_User_Membership::class,
+                   ['id' => 1,
+                    'user_id' => 667,
+                    'membership_id' => 1,
+                    'start_date' => '2022-02-16',
+                    'end_date' => '2023-02-16'
+                   ]);
+  }
+
+
+  /** @test */
+  public function membershipShouldNotHaveBeenCreated() {
+    $this->assertEquals(2,Class_Membership::count());
+  }
+
+
+  /** @test */
+  public function userMembershipShouldNotHaveBeenCreated() {
+    $user_membership = Class_User_Membership::find(1);
+    $this->assertEquals(1, Class_User_Membership::count());
+    return $user_membership;
+  }
+
+
+  /** @test
+   */
+  public function userAxelgetUserMembershipsShouldBeUserMembership() {
+    $this->assertEquals([Class_User_Membership::find(1)],
+                        Class_Users::find(667)->getUserMemberships());
+  }
+}
+
+
+
+
+
+class AuthControllerWithNanookPostAxelLoginWithoutLabelButExistingMemberships
+  extends AuthControllerWithNanookPostAxelLoginTestCase {
+
+  protected function _prepareMemberships(){
+    parent::_prepareMemberships();
+    $this->_patron_info = NanookFixtures::axelPatronInfoWithoutSubscriptionLabel();
+    $this->fixture(Class_Membership::class,
+                   ['id' => 1,
+                    'libelle' => 'Abonné Adulte intra-muros',
+                    'enabled' => 1,
+                    'code' => 3
+                   ]);
+
+    $this->fixture(Class_User_Membership::class,
+                   ['id' => 1,
+                    'user_id' => 667,
+                    'membership_id' => 1,
+                    'start_date' => '2022-02-16',
+                    'end_date' => '2023-02-16'
+                   ]);
+  }
+
+
+  /** @test */
+  public function membershipShouldNotHaveBeenCreated() {
+    $this->assertEquals(2,Class_Membership::count());
+  }
+
+
+  /** @test */
+  public function userMembershipShouldNotHaveBeenCreated() {
+    $user_membership = Class_User_Membership::find(1);
+    $this->assertEquals(1, Class_User_Membership::count());
+    return $user_membership;
+  }
+
+
+  /** @test
+   */
+  public function userAxelgetUserMembershipsShouldBeUserMembership() {
+    $this->assertEquals([Class_User_Membership::find(1)],
+                        Class_Users::find(667)->getUserMemberships());
+  }
+}
+
+
+
+
+class AuthControllerWithNanookPostAxelLoginWithRoleLevelModoBibWithoutLabelButExistingMemberships
+  extends  AuthControllerNanookTestCase {
+  protected  $_patron_info;
+
+  public function setUp() {
+    parent::setUp();
+
+    Class_WebService_SIGB_AbstractRESTService::shouldThrowError(true);
+
+    $this->_prepareMemberships();
+
+    $this->_web_client
+      ->whenCalled('open_url')
+      ->with('http://localhost:8080/afi_Nanook/ilsdi/service/AuthenticatePatron/username/Axel/password/2022')
+      ->answers(NanookFixtures::axelAuthenticatePatron())
+
+      ->whenCalled('open_url')
+      ->with('http://localhost:8080/afi_Nanook/ilsdi/service/GetPatronInfo/patronId/8')
+      ->answers($this->_patron_info);
+
+
+    $this->postDispatch('/opac/auth/login',
+                        ['username' => 'Axel',
+                         'password' => '2022']);
+  }
+
+
+  protected function _prepareMemberships(){
+    Class_User_Membership::setTimesource(new TimeSourceForTest('2022-01-01 08:00:00'));
+    $this->_patron_info = NanookFixtures::axelPatronInfo();
+
+    $this->fixture(Class_Membership::class,
+                   ['id' => 1415,
+                    'code' => '14',
+                    'libelle' => 'Abonne Adulte',
+                    'enabled' => 1,
+                    'date_maj' => ""
+                   ]);
+
+    $this->_patron_info = NanookFixtures::axelPatronInfoWithoutSubscriptionLabel();
+    $this->fixture(Class_Membership::class,
+                   ['id' => 1,
+                    'libelle' => 'Abonné Adulte intra-muros',
+                    'enabled' => 1,
+                    'code' => 3
+                   ]);
+
+    $this->fixture(Class_Users::class,
+                   ['id' => 667,
+                    'login' => 'Axel',
+                    'password' => '2022',
+                    'role' => ZendAfi_Acl_AdminControllerRoles::MODO_BIB
+                   ]);
+  }
+
+
+  /** @test */
+  public function membershipShouldNotHaveBeenCreated() {
+    $this->assertEquals(2,Class_Membership::count());
+  }
+
+
+  /** @test */
+  public function userMembershipShouldNotHaveBeenCreated() {
+    $this->assertEquals(0, Class_User_Membership::count());
+  }
+}
diff --git a/tests/db/UpgradeDBTest.php b/tests/db/UpgradeDBTest.php
index 41a413f99d73e78eb6e7d343936f15a5ed2ab7ea..9d0a873efb8b829b38b956bd7a2e8ab40a9ca089 100644
--- a/tests/db/UpgradeDBTest.php
+++ b/tests/db/UpgradeDBTest.php
@@ -5093,3 +5093,80 @@ class UpgradeDB_446_Test extends UpgradeDBTestCase {
                         $this->query("select * from variables where clef='max_items'")->fetch()['valeur']);
   }
 }
+
+
+
+
+class UpgradeDB_447_Test extends UpgradeDBTestCase {
+
+  public function prepare() {
+    $this->silentQuery('drop table membership');
+    $this->silentQuery('drop table user_membership');
+  }
+
+
+  /** @test */
+  public function membershipTableShouldBeCreated() {
+    $this->assertTable('membership');
+  }
+
+
+  /** @test */
+  public function userMembershipTableShouldBeCreated() {
+    $this->assertTable('user_membership');
+  }
+
+
+  public function fields() : array {
+    return [['membership', 'id', 'int(11) unsigned'],
+            ['membership', 'code', 'varchar(255)'],
+            ['membership', 'libelle', 'varchar(255)'],
+            ['membership', 'enabled', 'tinyint(1)'],
+            ['membership', 'date_maj', 'datetime'],
+            ['membership', 'date_created', 'datetime'],
+            ['user_membership', 'id', 'int(11) unsigned'],
+            ['user_membership', 'user_id', 'int(11) unsigned'],
+            ['user_membership', 'membership_id', 'int(11) unsigned'],
+            ['user_membership', 'start_date', 'date'],
+            ['user_membership', 'end_date', 'date']
+    ];
+  }
+
+
+  /**
+   * @test
+   * @dataProvider fields
+   */
+  public function fieldForTableShouldBeOfType(string $table_name, string $field, string $type) {
+    $this->assertFieldType($table_name, $field, $type);
+  }
+
+  /** @test */
+  public function userMembershipIdFieldShouldBePrimary() {
+    $this->assertPrimary('user_membership', 'id');
+  }
+
+
+  /** @test */
+  public function membershipIdFieldShouldBePrimary() {
+    $this->assertPrimary('membership', 'id');
+  }
+
+
+  public function fields_not_null() : array {
+    return [['membership', 'id'],
+            ['membership', 'code'],
+            ['membership', 'libelle'],
+            ['user_membership', 'id'],
+            ['user_membership', 'user_id'],
+            ['user_membership', 'membership_id'],
+    ];
+  }
+
+  /** @test
+   *  @dataProvider fields_not_null
+   */
+  public function notNullableFieldShouldNotBeNullable(string $table_name, string $field_name) {
+    $this->assertFieldNotNullable($table_name, $field_name);
+  }
+}
diff --git a/tests/fixtures/NanookFixtures.php b/tests/fixtures/NanookFixtures.php
index f37523b15473a4285bdf938e93bcbb375852df10..46f7bb98049faece7b536ecf80c492f25add3268 100644
--- a/tests/fixtures/NanookFixtures.php
+++ b/tests/fixtures/NanookFixtures.php
@@ -327,10 +327,16 @@ class NanookFixtures {
     </suggest>
   </suggests>
   <subscriptions>
-    <subscriptions>
+    <subscription>
       <rateId>958</rateId>
+      <rateLabel>Abonné Adulte</rateLabel>
       <startDate>2011-10-25</startDate>
       <endDate>2011-11-25</endDate>
+    </subscription>
+    <subscriptions>
+      <rateId>976</rateId>
+      <startDate>2012-10-25</startDate>
+      <endDate>2013-11-25</endDate>
     </subscriptions>
   </subscriptions>
 </GetPatronInfo>';
@@ -407,11 +413,11 @@ class NanookFixtures {
     </hold>
   </holds>
   <subscriptions>
-    <subscriptions>
+    <subscription>
       <rateId>958</rateId>
       <startDate>2011-10-25</startDate>
       <endDate>2011-11-25</endDate>
-    </subscriptions>
+    </subscription>
   </subscriptions>
 </GetPatronInfo>';
   }
@@ -841,9 +847,54 @@ class NanookFixtures {
 <holds>
 </holds>
 <subscriptions>
+<subscriptions>
+<rateId>3</rateId>
+<rateLabel>Abonné Adulte intra-muros</rateLabel>
 <startDate>2022-02-16</startDate>
 <endDate>2023-02-16</endDate>
 </subscriptions>
+</subscriptions>
+</GetPatronInfo>';
+  }
+
+
+  public static function axelPatronInfoWithoutSubscriptionLabel() : string {
+    return '<?xml version="1.0" encoding="UTF-8"?>
+<GetPatronInfo>
+<patronId>8</patronId>
+<siteId>1</siteId>
+<barcode>U-00008</barcode>
+<cardType>Particulier</cardType>
+<cardStatus>Validité</cardStatus>
+<notes></notes>
+<lastName>Ho Ho Ho</lastName>
+<firstName>Axel</firstName>
+<displayOrder>1</displayOrder>
+<birthDate>2022-07-12</birthDate>
+<phoneNumber></phoneNumber>
+<town>Valleiry</town>
+<zipcode>74520</zipcode>
+<address></address>
+<endDate>2023-02-16</endDate>
+<mail></mail>
+<parentalAuthorization>1</parentalAuthorization>
+<multimediaAccess>0</multimediaAccess>
+<newsletter>1</newsletter>
+<alertPreviousLoanOnDocument>0</alertPreviousLoanOnDocument>
+<noAnonymization>0</noAnonymization>
+<loanForbidden>0</loanForbidden>
+<holdForbidden>0</holdForbidden>
+<loans>
+</loans>
+<holds>
+</holds>
+<subscriptions>
+<subscriptions>
+<rateId>3</rateId>
+<startDate>2022-02-16</startDate>
+<endDate>2023-02-16</endDate>
+</subscriptions>
+</subscriptions>
 </GetPatronInfo>';
   }
 
diff --git a/tests/library/Class/Cosmogramme/Integration/PhasePatronsTest.php b/tests/library/Class/Cosmogramme/Integration/PhasePatronsTest.php
index 22633ea39df2d0ebbc229262ad29a2cd74d17516..3d18342027475b199fda75311fb7da261b11413b 100644
--- a/tests/library/Class/Cosmogramme/Integration/PhasePatronsTest.php
+++ b/tests/library/Class/Cosmogramme/Integration/PhasePatronsTest.php
@@ -438,7 +438,8 @@ abstract class PhasePatronsIntegrationTestCase extends ModelTestCase {
                    ['id' => 2,
                     'id_bib' => 2]);
 
-    $this->abon_config = new Class_Cosmogramme_Integration_Record_Patron($this->getIntegration());
+    $this->abon_config = new Class_Cosmogramme_Integration_Record_Patron($this->getIntegration(),
+                                                                         fn($message)=>print $message);
   }
 
   public function getIntegration() {
@@ -887,3 +888,122 @@ class PhasePatronsImportOrpheeTest
     $this->assertEquals('Céline DELBECQUE', $user->getNomComplet());
   }
 }
+
+
+
+
+class PhasePatronsFullImportWithAbonnementsTest extends PhasePatronsTestCase {
+  public function _prepareFixtures(){
+    parent::_prepareFixtures();
+
+    $this->fixture(Class_Membership::class,
+                   ['id' => 22,
+                    'code' => 22,
+                    'libelle' => 'Abonnement Bouquet Numérique'
+                   ]);
+    $this->fixture(Class_Membership::class,
+                   ['id' => 21,
+                    'code' => 21,
+                    'libelle' => 'Membership Adulte'
+                   ]);
+
+    $this->fixture(Class_IntProfilDonnees::class,
+                   ['id' => 102,
+                    'libelle' => 'Patrons',
+                    'accents' => Class_IntProfilDonnees::ENCODING_UTF8,
+                    'type_fichier' => Class_IntProfilDonnees::FT_PATRONS,
+                    'format' => Class_IntProfilDonnees::FORMAT_PIPED_ASCII,
+                    'attributs' => [1 => ['champs' => 'IDABON;ID_SIGB;ORDREABON;NOM;PRENOM;PASSWORD;MAIL;NAISSANCE;DATE_FIN;ABONNEMENTS']]]);
+
+    Class_Cosmogramme_Integration::find(999)->setProfilDonnees(Class_IntProfilDonnees::find(102))
+                                            ->setFichier('abonnes.txt')
+                                            ->save();
+  }
+
+  public function setUp() {
+    parent::setUp();
+
+    $this->_phase->run();
+    Class_Users::clearCache();
+  }
+
+  /** @test */
+  public function patron4NomCompletShouldBeIggyPop() {
+    $this->assertEquals('Iggy POP',Class_Users::find(4)->getNomComplet());
+  }
+
+
+  /** @test */
+  public function patron5NomCompletShouldBeDemisRoussos() {
+    $this->assertEquals('DEMIS ROUSSOS',Class_Users::find(5)->getNomComplet());
+  }
+
+
+  /** @test */
+  public function patron6NomCompletShouldBeKaasPatricia() {
+    $this->assertEquals('Patricia KAAS',Class_Users::find(6)->getNomComplet());
+  }
+
+
+  /** @test */
+  public function patron6UserMembershipsCountShouldBeTwo() {
+    $this->assertEquals(2,sizeOf(Class_Users::find(6)->getUserMemberships()));
+  }
+
+
+  /** @test */
+  public function patron7NomCompletShouldBeJohnBanner() {
+    $this->assertEquals('John Banner',Class_Users::find(7)->getNomComplet());
+  }
+
+
+  /** @test */
+  public function patron7DateFinShouldBe06Fevrier2022() {
+    $this->assertEquals('2022-02-06',Class_Users::find(7)->getDateFin());
+  }
+
+
+  /** @test */
+  public function patron7UserMembershipsShouldEmpty() {
+    $this->assertEmpty(Class_Users::find(7)->getUserMemberships());
+  }
+
+
+  public function usermemberships(){
+    return [[1, ['id' => 1,
+                 'user_id' => 5,
+                 'membership_id' => 22,
+                 'start_date' => '2019-02-06',
+                 'end_date' => '2020-02-06']],
+            [2, ['id' => 2,
+                 'user_id' => 6,
+                 'membership_id' => 22,
+                 'start_date' => '2019-05-06',
+                 'end_date' => '2020-06-06']],
+            [3, ['id' => 3,
+                 'user_id' => 6,
+                 'membership_id' => 21,
+                 'start_date' => '2019-07-06',
+                 'end_date' => '2020-09-06']]];
+
+  }
+
+
+  /** @test
+   *  @dataProvider usermemberships
+   */
+  public function userMembershipsSavedShouldBeAsExpected($user_membership_id, $expected) {
+    $this->assertEquals($expected,Class_User_Membership::find($user_membership_id)->toArray());
+  }
+
+
+  /** @test */
+  public function logShouldContainsMembership42introuvable() {
+    $this->assertLogContains('membership 42 introuvable dans la table Membership');
+  }
+
+  /** @test */
+  public function logShouldContainsdateDouzeDouzeDeuxmilleVingtDeuxInvalide() {
+    $this->assertLogContains('date de début 12-12-2022 invalide pour le membership 21');
+  }
+}
diff --git a/tests/library/Class/Cosmogramme/Integration/PhasePrepareIntegrationsTest.php b/tests/library/Class/Cosmogramme/Integration/PhasePrepareIntegrationsTest.php
index 7ab0e294cea8a2ff67ebad4d8edfe831ae3cdaa0..9f19a2fdf10d2bed3500ad2e77806d73aafbf94c 100644
--- a/tests/library/Class/Cosmogramme/Integration/PhasePrepareIntegrationsTest.php
+++ b/tests/library/Class/Cosmogramme/Integration/PhasePrepareIntegrationsTest.php
@@ -441,6 +441,12 @@ abstract class PhasePrepareIntegrationsNanookStandardTestCase
                  '0|3|Bande dessinée|f',
                  '0|4|' . iconv('UTF-8', 'ISO-8859-1', 'Bande dessinée') . '|f',])
 
+      ->whenCalled('getMembershipsOf')->with('foo')
+      ->answers(['ID|LIBELLE|ENABLED',
+                 '1|Abonné Adulte|1',
+                 '2|Abonné Comm Comm|1',
+                 '4|' . iconv('UTF-8', 'ISO-8859-1', 'Abonné') . '|0',])
+
       ->beStrict();
 
     Class_Cosmogramme_LandingDirectory::setInstance($landing_directory);
@@ -544,6 +550,13 @@ abstract class PhasePrepareIntegrationsNanookStandardTestCase
                  });
 
     }
+    $db_adapter->whenCalled('query')
+               ->with('delete from membership where date(date_maj) != "' . $now->dateYmd() . '"')
+               ->willDo(function() use ($model_class, $now)
+               {
+                 $model_class::deleteBy(['date_maj not' => $now->dateYmd()]);
+               });
+
 
     Class_Cosmogramme_Generator_AbstractTask::setDbAdapter($db_adapter);
   }
@@ -745,6 +758,12 @@ class PhasePrepareIntegrationsNanookStandardTest
   public function renamedSectionShouldBeUpdated() {
     $this->assertEquals('Jeunes', Class_CodifSection::find(38)->getLibelle());
   }
+
+
+  /** @test */
+  public function membership1LibelleShouldBeAbonneAdulte() {
+    $this->assertEquals('Abonné Adulte', Class_Membership::find(1)->getLibelle());
+  }
 }
 
 
diff --git a/tests/library/Class/Cosmogramme/Integration/abonnes.txt b/tests/library/Class/Cosmogramme/Integration/abonnes.txt
new file mode 100644
index 0000000000000000000000000000000000000000..d65d2e293afe76fbf8904187097fd02f26d8518d
--- /dev/null
+++ b/tests/library/Class/Cosmogramme/Integration/abonnes.txt
@@ -0,0 +1,5 @@
+BIB_ABON_CARTE|ID_ABON|ORDRE|NOM|PRENOM|MOT_DE_PASSE|E_MAIL|NAISSANCE|DATE_FIN|ABONNEMENTS
+B592071393|4333|4|POP|Iggy|mdpasse|iggy@yahoo.fr|2000-06-07|2019-11-30|
+B592070002|1|1|ROUSSOS|DEMIS|secret|droussos@sfr.fr|1948-03-19|2020-02-06|22;2019-02-06;2020-02-06
+B592070003|2|1|KAAS|Patricia|password|p.kaas@sfr.fr|1973-03-19|2020-02-06|22;2019-05-06;2020-06-06;21;2019-07-06;2020-09-06;42;12-12-2022;12-12-2023;21;12-12-2022;1976-01-000
+B592070004|6|1|Banner|John|password|johnSavage@combination.fr|1973-05-19|2022-02-06|
diff --git a/tests/library/Class/Cosmogramme/Integration/tarifs.txt b/tests/library/Class/Cosmogramme/Integration/tarifs.txt
new file mode 100644
index 0000000000000000000000000000000000000000..c1f4d93b9c800958e842089300681c18443f37cb
--- /dev/null
+++ b/tests/library/Class/Cosmogramme/Integration/tarifs.txt
@@ -0,0 +1,8 @@
+ID|LIBELLE|ACTIF
+0|Tous les sites|1
+21|Hem 30 €|1
+22|Bibliothécaire 15 €|1
+23|Hors Hem 45 €|1
+24|Enfant seul Gratuit (ex 515)|1
+25|Groupe|1
+26|Vacancier 2 €|1
diff --git a/tests/library/Class/Migration/AbonnementsUtilisateursTest.php b/tests/library/Class/Migration/AbonnementsUtilisateursTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..4fbfbe927676afbc8270f27b1b6897926dd4d9cb
--- /dev/null
+++ b/tests/library/Class/Migration/AbonnementsUtilisateursTest.php
@@ -0,0 +1,229 @@
+<?php
+/**
+ * Copyright (c) 2012-2023, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+
+abstract class AbonnementsUtilisateursTestCase extends ModelTestCase {
+  protected $_return_from_run,
+    $_profil;
+
+  public function setUp(){
+    parent::setUp();
+    $this->_setIntProfilDonnees();
+    $this->_setIntBib();
+    $this->_return_from_run = (new Class_Migration_AbonnementsUtilisateurs)
+      ->run();
+  }
+
+  public function tearDown(){
+    parent::tearDown();
+  }
+
+
+  protected function _setIntProfilDonnees() :void{
+    $this->_profil = clone($this->fixture(Class_IntProfilDonnees::class,
+                                          ['id' => 4,
+                                           'libelle' => 'Nanook Patrons',
+                                           'type_fichier' => Class_IntProfilDonnees::FT_PATRONS,
+                                           'format' => Class_IntProfilDonnees::FORMAT_PIPED_ASCII,
+                                           'attributs' =>
+                                           [['type_doc' =>
+                                             [[ 'code' => '0', 'label' => '', 'zone_995' => '' ],
+                                              [ 'code' => '1', 'label' => 'am;na', 'zone_995' => '' ],
+                                              [ 'code' => '2', 'label' => 'as', 'zone_995' => ''],
+                                              [ 'code' => '3', 'label' => 'i;j', 'zone_995' => ''],
+                                              [ 'code' => '4', 'label' => 'g','zone_995' => ''],
+                                              [ 'code' => '5', 'label' => 'l;m', 'zone_995' => ''],
+                                              [ 'code' => '6', 'label' => '', 'zone_995' => '' ],
+                                              [ 'code' => '7', 'label' => '', 'zone_995' => '' ],
+                                              [ 'code' => '8', 'label' => '', 'zone_995' => ''],
+                                              [ 'code' => '9', 'label' => '', 'zone_995' => '']
+                                             ],
+                                             Class_IntProfilDonnees::FIELD_ITEM_BARCODE => 'f',
+                                             Class_IntProfilDonnees::FIELD_ITEM_COTE => 'k',
+                                             Class_IntProfilDonnees::FIELD_ITEM_TYPE_DOC => '',
+                                             Class_IntProfilDonnees::FIELD_ITEM_GENRE => '',
+                                             Class_IntProfilDonnees::FIELD_ITEM_SECTION => 'j',
+                                             Class_IntProfilDonnees::FIELD_ITEM_EMPLACEMENT => 'u',
+                                             Class_IntProfilDonnees::FIELD_ITEM_ANNEXE => ''
+                                             ],
+                                            ['champs' => 'IDABON;ORDREABON;NOM;PRENOM;NAISSANCE;DATE_DEBUT;DATE_FIN;MAIL'],
+                                            ['champs' => 'IDABON;ORDREABON;NOM;PRENOM;NAISSANCE;DATE_DEBUT;DATE_FIN;MAIL'],
+                                            ['champs' => 'IDABON;ORDREABON;NOM;PRENOM;NAISSANCE;DATE_DEBUT;DATE_FIN;MAIL']
+                                           ]
+                                          ]));
+  }
+
+
+  protected function _setSingleNanook(){
+    $params = ['url_serveur' => 'http://localhost:8080/afi_Nanook/ilsdi/',
+               'id_bib' => 5,
+               'type' => Class_IntBib::COM_NANOOK];
+
+
+    $intbib = $this->fixture(Class_IntBib::class,
+                             ['id' => 5,
+                              'sigb' => Class_IntBib::SIGB_NANOOK,
+                              'comm_sigb' => Class_IntBib::COM_NANOOK,
+                              'comm_params' => serialize($params)]);
+
+    $this->fixture(Class_IntMajAuto::class,
+                   ['id' => 4,
+                    'int_bib' => $intbib,
+                    'profil' => 4
+                   ]);
+  }
+
+
+  protected function _setIntProfilDonneesCustomized() {
+    return $this->fixture(Class_IntProfilDonnees::class,
+                          ['id' => 4,
+                           'libelle' => 'Nanook Patrons',
+                           'type_fichier' => Class_IntProfilDonnees::FT_PATRONS,
+                           'format' => Class_IntProfilDonnees::FORMAT_PIPED_ASCII,
+                           'attributs' =>
+                           [
+                            ['champs' => $this->_string],
+                           ]
+                          ]);
+  }
+
+
+  protected function _setIntBib() :void {
+    $this->_intBib = $this->fixture('Class_IntBib',
+                                    ['id' => 5,
+                                     'comm_sigb' => Class_IntBib::COM_KOHA]);
+  }
+
+  protected function _checkChampsValueWith(string $value) : void{
+    $attributs = Class_IntProfilDonnees::find(4)->getAttributsAsArray();
+    foreach($this->_filterArrayOnChamps($attributs, true) as $attr)
+      $this->assertEquals($value, $attr['champs'] );
+
+  }
+
+
+  protected function _checkOtherFields() :void {
+    $this->assertEquals($this->_filterArrayOnChamps($this->_profil->getAttributsAsArray(),false),
+                        $this->_filterArrayOnChamps(Class_IntProfilDonnees::find(4)->getAttributsAsArray(),false));
+
+  }
+
+
+  protected function _filterArrayOnChamps(array $array, bool $champs_filter = true) :array{
+    return array_filter($array, fn($elt) => is_array($elt) && ($champs_filter
+                                                               ? isset($elt['champs'])
+                                                               : !isset($elt['champs'])));
+  }
+}
+
+
+
+
+class AbonnementsUtilisateursNoSingleNanookTest extends AbonnementsUtilisateursTestCase {
+  /** @test */
+  public function intProfilDonneesChampsFieldShouldNotBeEdited() {
+    $this->_checkChampsValueWith('IDABON;ORDREABON;NOM;PRENOM;NAISSANCE;DATE_DEBUT;DATE_FIN;MAIL');
+  }
+
+
+  /** @test */
+  public function intProfilDonneesOtherFieldsShouldNotBeEdited() {
+    $this->_checkOtherFields();
+  }
+
+
+  /** @test */
+  public function resultShouldBeEmptyString() {
+    $this->assertEmpty($this->_return_from_run);
+  }
+}
+
+
+
+
+class AbonnementsUtilisateursNoSingleNanookWithAbonnementsTest extends AbonnementsUtilisateursTestCase {
+  protected $_string = 'NAISSANCE;DATE_DEBUT;DATE_FIN;MAIL;ABONNEMENTS';
+
+  protected function _setIntProfilDonnees() :void{
+    $this->_profil = $this->_setIntProfilDonneesCustomized();
+  }
+
+  /** @test */
+  public function intProfilDonneesChampsFieldShouldNotBeEdited() {
+    $this->_checkChampsValueWith($this->_string);
+  }
+
+
+  /** @test */
+  public function resultShouldBeEmptyString() {
+    $this->assertEmpty($this->_return_from_run);
+  }
+}
+
+
+
+
+class AbonnementsUtilisateursSingleNanookTest extends AbonnementsUtilisateursTestCase {
+  protected function _setIntBib() :void{
+    $this->_setSingleNanook();
+  }
+
+
+  /** @test */
+  public function intProfilDonneesShouldContainsAbonnements() {
+    $this->_checkChampsValueWith('IDABON;ORDREABON;NOM;PRENOM;NAISSANCE;DATE_DEBUT;DATE_FIN;MAIL;ABONNEMENTS');
+  }
+
+
+  /** @test */
+  public function intProfilDonneesOtherFieldsShouldNotBeEdited() {
+    $this->_checkOtherFields();
+  }
+
+
+  /** @test */
+  public function resultShouldBeOneConfigurationModifiee(){
+    $this->assertEquals("1 configurations modifiées",$this->_return_from_run);
+  }
+}
+
+
+
+
+class AbonnementsUtilisateursSingleNanookWithAbonnementsTest extends AbonnementsUtilisateursTestCase {
+  protected $_string = 'NAISSANCE;DATE_DEBUT;DATE_FIN;MAIL;ABONNEMENTS';
+
+
+  protected function _setIntBib() :void{
+    $this->_setSingleNanook();
+  }
+
+  protected function _setIntProfilDonnees():void {
+    $this->_profil = $this->_setIntProfilDonneesCustomized();
+  }
+
+
+  /** @test */
+  public function intProfilDonneesChampsFieldShouldNotBeEdited() {
+    $this->_checkChampsValueWith($this->_string);
+  }
+}
diff --git a/tests/library/Class/Migration/SigbStandardCodificationsTest.php b/tests/library/Class/Migration/SigbStandardCodificationsTest.php
index 5b93ab803a6955ca4874af53ede2f518651d6f03..e21c0b147f684bc28ed43c628c124ae4983ef7a2 100644
--- a/tests/library/Class/Migration/SigbStandardCodificationsTest.php
+++ b/tests/library/Class/Migration/SigbStandardCodificationsTest.php
@@ -78,6 +78,10 @@ class Class_Migration_SigbStandardCodificationsTest extends ModelTestCase {
                  '0|3|Bande dessinée|f',
                  '0|4|' . iconv('UTF-8', 'ISO-8859-1', 'Bande dessinée') . '|f',])
 
+      ->whenCalled('getMembershipsOf')->with('foo')
+      ->answers(['CODE|LIBELLE|ENABLED',
+                 '1|Adulte Interne|1'])
+
       ->beStrict();
 
     Class_Cosmogramme_LandingDirectory::setInstance($landing_directory);
@@ -96,6 +100,11 @@ class Class_Migration_SigbStandardCodificationsTest extends ModelTestCase {
         ->with('update ' . $codif . ' set date_maj=""')
         ->answers(0);
 
+    $db_adapter
+      ->whenCalled('query')
+      ->with('delete from membership where date(date_maj) != "2015-03-26"')
+      ->answers(0);
+
     Class_Cosmogramme_Generator_AbstractTask::setDbAdapter($db_adapter);
   }
 
diff --git a/tests/library/Class/WebService/SIGB/FloraTest.php b/tests/library/Class/WebService/SIGB/FloraTest.php
index 2dc06f043e321b222646cc2520de9f7587e989f1..a22ba53197ad749348a73556d416ea5da8ed8a08 100644
--- a/tests/library/Class/WebService/SIGB/FloraTest.php
+++ b/tests/library/Class/WebService/SIGB/FloraTest.php
@@ -426,7 +426,7 @@ class FloraPatronIntegrationTest extends ModelTestCase {
                                    'traite' => 'non',
                                    'fichier' => 'patrons.txt',
                                    'pointeur_reprise' => 0]);
-    $this->_record_patron = (new Class_Cosmogramme_Integration_Record_Patron($integration))
+    $this->_record_patron = (new Class_Cosmogramme_Integration_Record_Patron($integration, fn($message)=> print $message))
       ->import(['IDABON' => 'abon123',
                 'ID_SIGB' => 123,
                 'NOM' => 'Brun',
@@ -505,4 +505,4 @@ class FloraSaveEmprunteurTest extends FloraTestCase {
     $this->assertTrue($this->_service->providesChangePasswordService());
 
   }
-}
\ No newline at end of file
+}
diff --git a/tests/library/Class/WebService/SIGB/KohaTest.php b/tests/library/Class/WebService/SIGB/KohaTest.php
index 3401f8910d017114848b6b3e49da5e3224aad958..6da0073c0d56c358d5159eec51575b191355e981 100644
--- a/tests/library/Class/WebService/SIGB/KohaTest.php
+++ b/tests/library/Class/WebService/SIGB/KohaTest.php
@@ -614,8 +614,9 @@ class KohaGetEmprunteurLaureAfondTest extends KohaTestCase {
 
   /** @test */
   public function subscriptionShouldBeINDIVIDU() {
-    $this->assertEquals(['INDIVIDU' => 'INDIVIDU'],
-                        $this->laurent->getSubscriptions());
+    $subscriptions = $this->laurent->getSubscriptions();
+    $this->assertEquals('INDIVIDU',
+                        $subscriptions[0]->getId());
   }
 
 
diff --git a/tests/library/Class/WebService/SIGB/NanookTest.php b/tests/library/Class/WebService/SIGB/NanookTest.php
index 76e32ccb917fb77682981c5afbe3181dc33ffc1b..749d4c38320504c19f97d01e8987a5939be65f9a 100644
--- a/tests/library/Class/WebService/SIGB/NanookTest.php
+++ b/tests/library/Class/WebService/SIGB/NanookTest.php
@@ -526,6 +526,52 @@ class NanookGetEmprunteurChristelDelpeyrouxAsAdminTest
   public function nbReservationsShouldBeFour() {
     $this->assertEquals(4, $this->_emprunteur->getNbReservations());
   }
+
+
+  /** @test */
+  public function numberOfDecodedSubscriptionsShouldBeTwo() {
+    $this->assertEquals(2, sizeOf($subscriptions = $this->_emprunteur->getSubscriptions()));
+    return $subscriptions;
+  }
+
+  /** @test
+   * @depends numberOfDecodedSubscriptionsShouldBeTwo
+   */
+  public function firstSubscriptionIdShouldBe958($subscriptions) {
+    $this->assertEquals('958', $subscriptions[0]->getId());
+  }
+
+
+  /** @test
+   * @depends numberOfDecodedSubscriptionsShouldBeTwo
+   */
+  public function firstSubscriptionLabelShouldBe($subscriptions) {
+    $this->assertEquals('Abonné Adulte', $subscriptions[0]->getLabel());
+  }
+
+
+  /** @test
+   * @depends numberOfDecodedSubscriptionsShouldBeTwo
+   */
+  public function firstSubscriptionDateDebutShouldBe($subscriptions) {
+    $this->assertEquals('2011-10-25', $subscriptions[0]->getStartDate());
+  }
+
+
+  /** @test
+   * @depends numberOfDecodedSubscriptionsShouldBeTwo
+   */
+  public function firstSubscriptionDateFinShouldBe($subscriptions) {
+    $this->assertEquals('2011-11-25', $subscriptions[0]->getEndDate());
+  }
+
+
+  /** @test
+   * @depends numberOfDecodedSubscriptionsShouldBeTwo
+   */
+  public function secondSubscriptionIdShouldBe($subscriptions) {
+    $this->assertEquals('976', $subscriptions[1]->getId());
+  }
 }
 
 
diff --git a/tests/library/Class/WebService/SIGB/OrpheeServiceTest.php b/tests/library/Class/WebService/SIGB/OrpheeServiceTest.php
index 8378060f86094496e3175011411d460c0a3de603..8d384044a2ec7936348d113943b69da34093f4d1 100644
--- a/tests/library/Class/WebService/SIGB/OrpheeServiceTest.php
+++ b/tests/library/Class/WebService/SIGB/OrpheeServiceTest.php
@@ -1027,8 +1027,10 @@ class OrpheeServiceGetInfoUserCarteHenryDupontTest extends OrpheeServiceTestCase
 
 
   /** @test */
-  public function emprunteurShouldHaveLibCatPretContains() {
-    $this->assertEquals(['1' => 'Lecteur adulte'], $this->emprunteur->getSubscriptions());
+  public function emprunteurShouldHaveLibCatPret() {
+    $this->assertEquals(1, sizeOf($subscriptions = $this->emprunteur->getSubscriptions()));
+    $this->assertEquals(1, $subscriptions[0]->getId());
+    $this->assertEquals('Lecteur adulte', $subscriptions[0]->getLabel());
   }
 
 
diff --git a/tests/scenarios/Templates/TemplatesAbonneTest.php b/tests/scenarios/Templates/TemplatesAbonneTest.php
index c7eb555053d3cb43e3e9f88ca32b23a8b4d71762..4000d3d9da84eb1fa8c41f570131caf3f9ae1b53 100644
--- a/tests/scenarios/Templates/TemplatesAbonneTest.php
+++ b/tests/scenarios/Templates/TemplatesAbonneTest.php
@@ -300,6 +300,21 @@ abstract class TemplatesIntonationAccountTestCase extends TemplatesIntonationTes
                     'loan_link' => 'https://pnb-dilicom.centprod.com/v2//XXXXXXXX.do',
                     'options' => 'LCP',
                     'order_line_id' => '584837a045ce56ef0a072a8b']);
+
+    $membership = $this->fixture(Class_Membership::class,
+                            [ 'id' => 1,
+                              'code' => 1,
+                              'libelle' => 'Abonné adulte',
+                              'enabled' => 1
+                            ]);
+
+    $this->fixture(Class_User_Membership::class,
+                   [ 'id' => 1,
+                     'user' => $current_user,
+                     'membership' => $membership,
+                     'start_date' => '2012-01-01',
+                     'end_date' => '2020-01-01'
+                   ]);
   }
 }
 
@@ -2157,3 +2172,186 @@ class TemplatesAbonnePaginatedReviewsSearchConsRoiPage2Test extends TemplatesAbo
     $this->assertXPath('//input[@value="cons roi"]');
   }
 }
+
+
+
+
+class TemplatesDispatchAbonneUserMembershipTest extends TemplatesIntonationAccountTestCase {
+  public function setUp() {
+    parent::setUp();
+    Class_AdminVar::set('TEMPLATING', 1);
+
+    $this->fixture(Class_Membership::class,
+                   ['id'=>2,
+                    'code'=>2,
+                    'libelle' => 'Abonnement invalide',
+                    'enabled' => 1]);
+
+    $this->fixture(Class_Membership::class,
+                   ['id'=>3,
+                    'code'=>3,
+                    'libelle' => 'Bouquets numériques',
+                    'enabled' => 1]);
+
+
+    $user = Class_Users::getIdentity();
+
+     $this->fixture(Class_User_Membership::class,
+                   ['id' => 4,
+                    'membership_id' => 1,
+                    'user' => $user,
+                    'start_date' => '2012-05-01',
+                    'end_date' => '2013-01-01']);
+
+    $this->fixture(Class_User_Membership::class,
+                   ['id' => 2,
+                    'membership_id' => 2,
+                    'user' => $user,
+                    'start_date' => '1970-01-01',
+                    'end_date' => '1971-06-06']);
+
+    $this->fixture(Class_User_Membership::class,
+                   ['id' => 3,
+                    'membership_id' => 3,
+                    'user' => $user,
+                    'start_date' => '2019-01-01',
+                    'end_date' => '']);
+
+
+    Class_User_Membership::setTimesource(new TimeSourceForTest('2017-01-31'));
+    $this->_buildTemplateProfil(['id'=>1]);
+    $this->dispatch('/opac/abonne');
+  }
+
+  /** @test */
+  public function pageShouldContainsAbonnements() {
+    $this->assertXPathContentContains('//dt[contains(@class,"user_info")]', 'Abonnements');
+  }
+
+
+  /** @test */
+  public function pageShouldContainsAbonneAdulte() {
+    $this->assertXPathContentContains('//dd//p//strong', 'Abonné adulte');
+  }
+
+
+  /** @test */
+  public function pageShouldContainsdu() {
+    $this->assertXPathContentContains('//dd//p//span', 'du 01/01/2012 au 01/01/2020');
+  }
+  /** @test */
+  public function pageShouldContainsBouquetsNumeriques() {
+    $this->assertXPathContentContains('//dd//p//strong', 'Bouquets numériques');
+  }
+
+
+  /** @test */
+  public function pageShouldContainsDuPremierJanvier2019() {
+    $this->assertXPathContentContains('//dd//p//span', 'du 01/01/2019');
+  }
+
+
+  /** @test */
+  public function pageShouldNotContainsAbonnementInvalide() {
+    $this->assertNotXPathContentContains('//dd//p//strong', 'Abonnement invalide');
+  }
+}
+
+
+class TemplatesAbonneDispatchFicheWithMembershipBadgeAbonnementTest extends TemplatesIntonationAccountTestCase {
+  public function setUp() {
+    parent::setUp();
+    Class_FileManager::setFileSystem(null);
+
+    Class_User_ILSSubscription::setTimesource(new TimeSourceForTest('2022-01-01'));
+    $this->fixture(Class_User_Membership::class,
+                   ['id'=> 12,
+                    'user' => Class_Users::getIdentity(),
+                    'membership' => Class_Membership::find(1),
+                    'start_date' => '2021-01-03',
+                    'end_date' => '2022-12-12']);
+
+    $this->fixture(Class_User_Membership::class,
+                   ['id'=> 15,
+                    'user' => Class_Users::getIdentity(),
+                    'membership' => Class_Membership::find(1),
+                    'start_date' => '2010-01-03',
+                    'end_date' => '2012-06-01']);
+
+    $this->dispatch('/opac/abonne/fiche/id_profil/72');
+  }
+
+  public function tearDown(){
+    Class_User_ILSSubscription::setTimesource(null);
+    parent::tearDown();
+  }
+
+
+  /** @test */
+  public function badgeSubscriptionContentShouldContainsAbonneAdulte12Decembre() {
+    $this->assertXPathContentContains('//span[contains(@class,"badge_text")]',"Abonné adulte 12/12/2022");
+  }
+
+
+  /** @test */
+  public function badgeSubscriptionCountShouldBeTwo() {
+    $this->assertXPathCount('//span[contains(@class,"subscription")]',2);
+  }
+}
+
+
+
+class TemplatesAbonneDispatchFicheWithMembershipBadgeAbonnementExpiredButUniqueTest extends TemplatesIntonationAccountTestCase {
+  public function setUp() {
+    parent::setUp();
+    Class_FileManager::setFileSystem(null);
+
+    Class_User_ILSSubscription::setTimesource(new TimeSourceForTest('2022-01-01'));
+
+    $this->dispatch('/opac/abonne/fiche/id_profil/72');
+  }
+
+  public function tearDown(){
+    Class_User_ILSSubscription::setTimesource(null);
+    parent::tearDown();
+  }
+
+
+  /** @test */
+  public function badgeSubscriptionInformationTitleShouldContainsAbonneAdultePremierJanvier2020() {
+    $this->assertXPathContentContains('//span[contains(@class,"badge_tag danger")]//span[contains(@class,"badge_text")]',"Abonné adulte 01/01/2020");
+  }
+}
+
+
+
+
+class TemplatesAbonneDispatchFicheBadgeAbonnementWithoutMembershipsTest extends TemplatesIntonationAccountTestCase {
+  public function setUp() {
+    parent::setUp();
+    Class_FileManager::setFileSystem(null);
+
+    Class_User_Membership::deleteBy([]);
+
+    Class_User_ILSSubscription::setTimesource(new TimeSourceForTest('2020-01-01'));
+
+    $this->dispatch('/opac/abonne/fiche/id_profil/72');
+  }
+
+  public function tearDown(){
+    Class_User_ILSSubscription::setTimesource(null);
+    parent::tearDown();
+  }
+
+
+  /** @test */
+  public function badgeSubscriptionInformationContentShouldContainsAbonneAdultePremierJanvier2020() {
+    $this->assertXPathContentContains('//span[contains(@class,"badge_tag")]//span[contains(@class,"badge_text")]',"01/01/2020");
+  }
+
+
+  /** @test */
+  public function badgeSubscriptionInformationTitleShouldContainsAbonneAdultePremierJanvier2020() {
+    $this->assertXPathContentContains('//span[contains(@class,"badge_tag")]//span[contains(@class,"sr-only")]',"Vous êtes abonné(e) jusqu'au");
+  }
+}
diff --git a/tests/scenarios/Templates/TemplatesPatronConfigurationsTest.php b/tests/scenarios/Templates/TemplatesPatronConfigurationsTest.php
index 3a4ac7218173a0157e089cc2ed0e33c2d5f115a8..2ab41036b71e6ebb30bdb6a0430619adb359613c 100644
--- a/tests/scenarios/Templates/TemplatesPatronConfigurationsTest.php
+++ b/tests/scenarios/Templates/TemplatesPatronConfigurationsTest.php
@@ -98,7 +98,7 @@ class TemplatesPatronConfigurationsSubscriptionDateTest
   /** @test */
   public function withSubscribtionIn2020DateShouldBeDisplayInSpanBadgeDanger() {
     $this->dispatch('/opac/abonne/configurations/id_profil/72');
-    $this->assertXPathContentContains('//div//span[@class="badge_tag danger text-left badge badge-danger text-light"]',
+    $this->assertXPathContentContains('//div//span[@class="badge_tag danger subscription text-left badge badge-danger text-light"]',
                                       '01/01/2020');
   }
 
@@ -107,7 +107,7 @@ class TemplatesPatronConfigurationsSubscriptionDateTest
   public function withSubscribtionIn2020DateShouldBeDisplayInSpanBadgeSuccess() {
     Class_User_ILSSubscription::setTimeSource(new TimeSourceForTest('2019-02-12 11:25:31'));
     $this->dispatch('/opac/abonne/configurations/id_profil/72');
-    $this->assertXPathContentContains('//div//span[@class="badge_tag success text-left badge badge-success text-light"]',
+    $this->assertXPathContentContains('//div//span[@class="badge_tag success subscription text-left badge badge-success text-light"]',
                                       '01/01/2020');
   }
 
@@ -116,9 +116,8 @@ class TemplatesPatronConfigurationsSubscriptionDateTest
   public function withSubscribtionIsAboutToExpireDateShouldBeDisplayInSpanBadgeWarning() {
     Class_User_ILSSubscription::setTimeSource(new TimeSourceForTest('2020-01-01 11:25:31'));
     $this->dispatch('/opac/abonne/configurations/id_profil/72');
-    $this->assertXPathContentContains('//div//span[@class="badge_tag warning text-left badge badge-warning text-dark"]',
-                                      '01/01/2020',
-                                      $this->_response->getBody());
+    $this->assertXPathContentContains('//div//span[@class="badge_tag warning subscription text-left badge badge-warning text-dark"]',
+                                      '01/01/2020');
   }