]> git.agnieray.net Git - galette.git/blobdiff - galette/lib/Galette/Entity/Contribution.php
Disable events from mass changes; closes #1733
[galette.git] / galette / lib / Galette / Entity / Contribution.php
index aa7a433f3aec05227475b4566685fdc6452f687c..3457da44e01dc8437c3eeeb0d82943f3376dc18a 100644 (file)
@@ -4,10 +4,11 @@
 
 /**
  * Contribution class for galette
+ * Manage membership fees and donations.
  *
  * PHP version 5
  *
- * Copyright © 2010-2021 The Galette Team
+ * Copyright © 2010-2023 The Galette Team
  *
  * This file is part of Galette (http://galette.tuxfamily.org).
  *
@@ -28,7 +29,7 @@
  * @package   Galette
  *
  * @author    Johan Cwiklinski <johan@x-tnd.be>
- * @copyright 2010-2021 The Galette Team
+ * @copyright 2010-2023 The Galette Team
  * @license   http://www.gnu.org/licenses/gpl-3.0.html GPL License 3.0 or (at your option) any later version
  * @link      http://galette.tuxfamily.org
  * @since     Available since 0.7dev - 2010-03-11
 
 namespace Galette\Entity;
 
+use ArrayObject;
+use DateTime;
+use Galette\Events\GaletteEvent;
+use Galette\Features\HasEvent;
 use Throwable;
 use Analog\Analog;
 use Laminas\Db\Sql\Expression;
@@ -44,26 +49,51 @@ use Galette\Core\Login;
 use Galette\IO\ExternalScript;
 use Galette\IO\PdfContribution;
 use Galette\Repository\PaymentTypes;
+use Galette\Features\Dynamics;
 
 /**
  * Contribution class for galette
+ * Manage membership fees and donations.
  *
  * @category  Entity
  * @name      Contribution
  * @package   Galette
  * @author    Johan Cwiklinski <johan@x-tnd.be>
- * @copyright 2010-2021 The Galette Team
+ * @copyright 2010-2023 The Galette Team
  * @license   http://www.gnu.org/licenses/gpl-3.0.html GPL License 3.0 or (at your option) any later version
  * @link      http://galette.tuxfamily.org
  * @since     Available since 0.7dev - 2010-03-11
+ *
+ * @property integer $id
+ * @property string $date
+ * @property DateTime $raw_date
+ * @property integer $member
+ * @property ContributionsTypes|int $type
+ * @property double $amount
+ * @property integer $payment_type
+ * @property double $orig_amount
+ * @property string $info
+ * @property string $begin_date
+ * @property DateTime $raw_begin_date
+ * @property string $end_date
+ * @property DateTime $raw_end_date
+ * @property Transaction|null $transaction
+ * @property integer $extension
+ * @property integer $duration
+ * @property string $spayment_type
+ * @property integer $model
  */
 class Contribution
 {
-    use DynamicsTrait;
+    use Dynamics;
+    use HasEvent;
 
     public const TABLE = 'cotisations';
     public const PK = 'id_cotis';
 
+    public const TYPE_FEE = 'fee';
+    public const TYPE_DONATION = 'donation';
+
     private $_id;
     private $_date;
     private $_member;
@@ -81,9 +111,11 @@ class Contribution
     //fields list and their translation
     private $_fields;
 
+    /** @var Db */
     private $zdb;
+    /** @var Login */
     private $login;
-
+    /** @var array */
     private $errors;
 
     private $sendmail = false;
@@ -91,22 +123,31 @@ class Contribution
     /**
      * Default constructor
      *
-     * @param Db                 $zdb   Database
-     * @param Login              $login Login instance
-     * @param null|int|ResultSet $args  Either a ResultSet row to load
-     *                                  a specific contribution, or an type id
-     *                                  to just instanciate object
+     * @param Db                         $zdb   Database
+     * @param Login                      $login Login instance
+     * @param null|int|array|ArrayObject $args  Either a ResultSet row to load
+     *                                          a specific contribution, or a type id
+     *                                          to just instantiate object
      */
     public function __construct(Db $zdb, Login $login, $args = null)
     {
         $this->zdb = $zdb;
         $this->login = $login;
 
+        global $preferences;
+        $this->_payment_type = (int)$preferences->pref_default_paymenttype;
+
+        $this
+            ->withAddEvent()
+            ->withEditEvent()
+            ->withoutDeleteEvent()
+            ->activateEvents();
+
         /*
          * Fields configuration. Each field is an array and must reflect:
          * array(
          *   (string)label,
-         *   (string) propname
+         *   (string) property name
          * )
          *
          * I'd prefer a static private variable for this...
@@ -114,7 +155,7 @@ class Contribution
          */
         $this->_fields = array(
             'id_cotis'            => array(
-                'label'    => null, //not a field in the form
+                'label'    => _T('Contribution id'), //not a field in the form
                 'propname' => 'id'
             ),
             Adherent::PK          => array(
@@ -138,12 +179,12 @@ class Contribution
                 'propname' => 'info'
             ),
             'date_enreg'          => array(
-                'label'    => null, //not a field in the form
+                'label'    => _T('Date'), //not a field in the form
                 'propname' => 'date'
             ),
             'date_debut_cotis'    => array(
                 'label'    => _T("Date of contribution:"),
-                'cotlabel' => _T("Start date of membership:"), //if contribution is a cotisation, label differs
+                'cotlabel' => _T("Start date of membership:"), //if contribution is a membership fee, label differs
                 'propname' => 'begin_date'
             ),
             'date_fin_cotis'      => array(
@@ -151,11 +192,11 @@ class Contribution
                 'propname' => 'end_date'
             ),
             Transaction::PK       => array(
-                'label'    => null, //not a field in the form
+                'label'    => _T('Transaction ID'), //not a field in the form
                 'propname' => 'transaction'
             ),
             //this one is not really a field, but is required in some cases...
-            //adding it here make simplier to check required fields
+            //adding it here make more simple to check required fields
             'duree_mois_cotis'    => array(
                 'label'    => _T("Membership extension:"),
                 'propname' => 'extension'
@@ -176,19 +217,21 @@ class Contribution
                 $this->_amount = $this->_transaction->getMissingAmount();
             }
             $this->type = (int)$args['type'];
-            //calculate begin date for cotisation
+            //calculate begin date for membership fee
             $this->_begin_date = $this->_date;
             if ($this->_is_cotis) {
-                $curend = self::getDueDate($this->zdb, $this->_member);
-                if ($curend != '') {
-                    $dend = new \DateTime($curend);
-                    $now = date('Y-m-d');
-                    $dnow = new \DateTime($now);
-                    if ($dend < $dnow) {
+                $due_date = self::getDueDate($this->zdb, $this->_member);
+                if ($due_date != '') {
+                    $now = new \DateTime();
+                    $due_date = new \DateTime($due_date);
+                    if ($due_date < $now) {
                         // Member didn't renew on time
-                        $this->_begin_date = $now;
+                        $this->_begin_date = $now->format('Y-m-d');
                     } else {
-                        $this->_begin_date = $curend;
+                        // Caution : the next_begin_date is the day after the due_date.
+                        $next_begin_date = clone $due_date;
+                        $next_begin_date->add(new \DateInterval('P1D'));
+                        $this->_begin_date = $next_begin_date->format('Y-m-d');
                     }
                 }
                 $this->retrieveEndDate();
@@ -212,39 +255,46 @@ class Contribution
     {
         global $preferences;
 
-        $bdate = new \DateTime($this->_begin_date);
+        $now = new \DateTime();
+        $begin_date = new \DateTime($this->_begin_date);
         if ($preferences->pref_beg_membership != '') {
             //case beginning of membership
             list($j, $m) = explode('/', $preferences->pref_beg_membership);
-            $edate = new \DateTime($bdate->format('Y') . '-' . $m . '-' . $j);
-            while ($edate <= $bdate) {
-                $edate->modify('+1 year');
+            $next_begin_date = new \DateTime($begin_date->format('Y') . '-' . $m . '-' . $j);
+            while ($next_begin_date <= $begin_date) {
+                $next_begin_date->add(new \DateInterval('P1Y'));
             }
 
             if ($preferences->pref_membership_offermonths > 0) {
-                //count days until end of membership date
-                $diff1 = (int)$bdate->diff($edate)->format('%a');
+                //count days until next membership begin date
+                $diff1 = (int)$now->diff($next_begin_date)->format('%a');
 
-                //count days beetween end of membership date and offered months
-                $tdate = clone $edate;
-                $tdate->modify('-' . $preferences->pref_membership_offermonths . ' month');
-                $diff2 = (int)$edate->diff($tdate)->format('%a');
+                //count days between next membership begin date and offered months
+                $tdate = clone $next_begin_date;
+                $tdate->sub(new \DateInterval('P' . $preferences->pref_membership_offermonths . 'M'));
+                $diff2 = (int)$next_begin_date->diff($tdate)->format('%a');
 
-                //when number of days until end of membership is less than for offered months, it's free :)
+                //when number of days until next membership begin date is less than or equal to the offered months, it's free :)
                 if ($diff1 <= $diff2) {
-                    $edate->modify('+1 year');
+                    $next_begin_date->add(new \DateInterval('P1Y'));
                 }
             }
 
-            $this->_end_date = $edate->format('Y-m-d');
+            // Caution : the end_date to retrieve is the day before the next_begin_date.
+            $end_date = clone $next_begin_date;
+            $end_date->sub(new \DateInterval('P1D'));
+            $this->_end_date = $end_date->format('Y-m-d');
         } elseif ($preferences->pref_membership_ext != '') {
             //case membership extension
             if ($this->_extension == null) {
                 $this->_extension = $preferences->pref_membership_ext;
             }
             $dext = new \DateInterval('P' . $this->_extension . 'M');
-            $edate = $bdate->add($dext);
-            $this->_end_date = $edate->format('Y-m-d');
+            // Caution : the end_date to retrieve is the day before the next_begin_date.
+            $next_begin_date = $begin_date->add($dext);
+            $end_date = clone $next_begin_date;
+            $end_date->sub(new \DateInterval('P1D'));
+            $this->_end_date = $end_date->format('Y-m-d');
         } else {
             throw new \RuntimeException(
                 'Unable to define end date; none of pref_beg_membership nor pref_membership_ext are defined!'
@@ -255,18 +305,36 @@ class Contribution
     /**
      * Loads a contribution from its id
      *
-     * @param int $id the identifiant for the contribution to load
+     * @param int $id the identifier for the contribution to load
      *
      * @return bool true if query succeed, false otherwise
      */
     public function load($id)
     {
+        if (!$this->login->isLogged() && $this->login->id == '') {
+            return false;
+        }
+
         try {
-            $select = $this->zdb->select(self::TABLE);
-            $select->where(self::PK . ' = ' . $id);
+            $select = $this->zdb->select(self::TABLE, 'c');
+            $select->join(
+                array('a' => PREFIX_DB . Adherent::TABLE),
+                'c.' . Adherent::PK . '=a.' . Adherent::PK,
+                array()
+            );
             //restrict query on current member id if he's not admin nor staff member
             if (!$this->login->isAdmin() && !$this->login->isStaff()) {
-                $select->where(Adherent::PK . ' = ' . $this->login->id);
+                $select->where
+                    ->nest()
+                        ->equalTo('a.' . Adherent::PK, $this->login->id)
+                        ->or
+                        ->equalTo('a.parent_id', $this->login->id)
+                    ->unnest()
+                    ->and
+                    ->equalTo('c.' . self::PK, $id)
+                ;
+            } else {
+                $select->where->equalTo(self::PK, $id);
             }
 
             $results = $this->zdb->execute($select);
@@ -275,9 +343,11 @@ class Contribution
                 $this->loadFromRS($row);
                 return true;
             } else {
-                throw new \Exception(
-                    'No contribution #' . $id . ' (user ' . $this->login->id . ')'
+                Analog::log(
+                    'No contribution #' . $id . ' (user ' . $this->login->id . ')',
+                    Analog::ERROR
                 );
+                return false;
             }
         } catch (Throwable $e) {
             Analog::log(
@@ -285,36 +355,36 @@ class Contribution
                 $e->getMessage(),
                 Analog::ERROR
             );
-            return false;
+            throw $e;
         }
     }
 
     /**
      * Populate object from a resultset row
      *
-     * @param ResultSet $r the resultset row
+     * @param ArrayObject $r the resultset row
      *
      * @return void
      */
-    private function loadFromRS($r)
+    private function loadFromRS(ArrayObject $r)
     {
         $pk = self::PK;
         $this->_id = (int)$r->$pk;
         $this->_date = $r->date_enreg;
-        $this->_amount = $r->montant_cotis;
-        //save original amount, we need it for transactions parts calulations
-        $this->_orig_amount = $r->montant_cotis;
+        $this->_amount = (double)$r->montant_cotis;
+        //save original amount, we need it for transactions parts calculations
+        $this->_orig_amount = (double)$r->montant_cotis;
         $this->_payment_type = $r->type_paiement_cotis;
         $this->_info = $r->info_cotis;
         $this->_begin_date = $r->date_debut_cotis;
-        $enddate = $r->date_fin_cotis;
+        $end_date = $r->date_fin_cotis;
         //do not work with knows bad dates...
-        //the one with BC comes from 0.63/pgsl demo... Why the hell a so
-        //strange date? dont know :(
+        //the one with BC comes from 0.63/pgsql demo... Why the hell a so
+        //strange date? don't know :(
         if (
-            $enddate !== '0000-00-00'
-            && $enddate !== '1901-01-01'
-            && $enddate !== '0001-01-01 BC'
+            $end_date !== '0000-00-00'
+            && $end_date !== '1901-01-01'
+            && $end_date !== '0001-01-01 BC'
         ) {
             $this->_end_date = $r->date_fin_cotis;
         }
@@ -347,7 +417,7 @@ class Contribution
 
         $fields = array_keys($this->_fields);
         foreach ($fields as $key) {
-            //first of all, let's sanitize values
+            //first, let's sanitize values
             $key = strtolower($key);
             $prop = '_' . $this->_fields[$key]['propname'];
 
@@ -372,7 +442,11 @@ class Contribution
                             try {
                                 $d = \DateTime::createFromFormat(__("Y-m-d"), $value);
                                 if ($d === false) {
-                                    throw new \Exception('Incorrect format');
+                                    //try with non localized date
+                                    $d = \DateTime::createFromFormat("Y-m-d", $value);
+                                    if ($d === false) {
+                                        throw new \Exception('Incorrect format');
+                                    }
                                 }
                                 $this->$prop = $d->format('Y-m-d');
                             } catch (Throwable $e) {
@@ -407,8 +481,10 @@ class Contribution
                         }
                         break;
                     case 'montant_cotis':
-                        $this->_amount = $value;
                         $value = strtr($value, ',', '.');
+                        if (!empty($value) || $value === '0') {
+                            $this->_amount = (double)$value;
+                        }
                         if (!is_numeric($value) && $value !== '') {
                             $this->errors[] = _T("- The amount must be an integer!");
                         }
@@ -475,15 +551,11 @@ class Contribution
             }
         }
 
-        if ($this->isCotis() && count($this->errors) == 0) {
+        if ($this->isFee() && count($this->errors) == 0) {
             $overlap = $this->checkOverlap();
             if ($overlap !== true) {
-                if ($overlap === false) {
-                    $this->errors[] = _T("An error occurred checking overlaping fees :(");
-                } else {
-                    //method directly return error message
-                    $this->errors[] = $overlap;
-                }
+                //method directly return error message
+                $this->errors[] = $overlap;
             }
         }
 
@@ -491,7 +563,7 @@ class Contribution
 
         if (count($this->errors) > 0) {
             Analog::log(
-                'Some errors has been throwed attempting to edit/store a contribution' .
+                'Some errors has been threw attempting to edit/store a contribution' .
                 print_r($this->errors, true),
                 Analog::ERROR
             );
@@ -515,41 +587,49 @@ class Contribution
     {
         try {
             $select = $this->zdb->select(self::TABLE, 'c');
+            //@phpstan-ignore-next-line
             $select->columns(
                 array('date_debut_cotis', 'date_fin_cotis')
             )->join(
                 array('ct' => PREFIX_DB . ContributionsTypes::TABLE),
                 'c.' . ContributionsTypes::PK . '=ct.' . ContributionsTypes::PK,
                 array()
-            )->where(Adherent::PK . ' = ' . $this->_member)
+            )->where([Adherent::PK => $this->_member])
                 ->where(array('cotis_extension' => new Expression('true')))
                 ->where->nest->nest
                 ->greaterThanOrEqualTo('date_debut_cotis', $this->_begin_date)
-                ->lessThan('date_debut_cotis', $this->_end_date)
+                ->lessThanOrEqualTo('date_debut_cotis', $this->_end_date)
                 ->unnest
                 ->or->nest
-                ->greaterThan('date_fin_cotis', $this->_begin_date)
+                ->greaterThanOrEqualTo('date_fin_cotis', $this->_begin_date)
                 ->lessThanOrEqualTo('date_fin_cotis', $this->_end_date);
 
             if ($this->id != '') {
-                $select->where(self::PK . ' != ' . $this->id);
+                $select->where->notEqualTo(self::PK, $this->id);
             }
 
             $results = $this->zdb->execute($select);
             if ($results->count() > 0) {
                 $result = $results->current();
-                $d = new \DateTime($result->date_debut_cotis);
+
+                $d_begin = new \DateTime($result->date_debut_cotis);
+                $d_end = new \DateTime($result->date_fin_cotis);
+
+                if ($d_begin->format('m-d') == $d_end->format('m-d') && $result->date_fin_cotis == $this->_begin_date) {
+                    //see https://bugs.galette.eu/issues/1762
+                    return true;
+                }
 
                 return _T("- Membership period overlaps period starting at ") .
-                    $d->format(__("Y-m-d"));
+                    $d_begin->format(__("Y-m-d"));
             }
             return true;
         } catch (Throwable $e) {
             Analog::log(
-                'An error occurred checking overlaping fee. ' . $e->getMessage(),
+                'An error occurred checking overlapping fee. ' . $e->getMessage(),
                 Analog::ERROR
             );
-            return false;
+            throw $e;
         }
     }
 
@@ -591,11 +671,10 @@ class Contribution
             }
 
             //no end date, let's take database defaults
-            if (!$this->isCotis() && !$this->_end_date) {
+            if (!$this->isFee() && !$this->_end_date) {
                 unset($values['date_fin_cotis']);
             }
 
-            $success = false;
             if (!isset($this->_id) || $this->_id == '') {
                 //we're inserting a new contribution
                 unset($values[self::PK]);
@@ -612,8 +691,7 @@ class Contribution
                         _T("Contribution added"),
                         Adherent::getSName($this->zdb, $this->_member)
                     );
-                    $success = true;
-                    $event = 'contribution.add';
+                    $event = $this->getAddEventName();
                 } else {
                     $hist->add(_T("Fail to add new contribution."));
                     throw new \Exception(
@@ -623,9 +701,7 @@ class Contribution
             } else {
                 //we're editing an existing contribution
                 $update = $this->zdb->update(self::TABLE);
-                $update->set($values)->where(
-                    self::PK . '=' . $this->_id
-                );
+                $update->set($values)->where([self::PK => $this->_id]);
                 $edit = $this->zdb->execute($update);
 
                 //edit == 0 does not mean there were an error, but that there
@@ -637,34 +713,22 @@ class Contribution
                     );
                 }
 
-                if ($edit === false) {
-                    throw new \Exception(
-                        'An error occurred updating contribution # ' . $this->_id . '!'
-                    );
-                }
-                $success = true;
-                $event = 'contribution.edit';
+                $event = $this->getEditEventName();
             }
             //update deadline
-            if ($this->isCotis()) {
-                $deadline = $this->updateDeadline();
-                if ($deadline !== true) {
-                    //if something went wrong, we rollback transaction
-                    throw new \Exception('An error occurred updating member\'s deadline');
-                }
+            if ($this->isFee()) {
+                $this->updateDeadline();
             }
 
             //dynamic fields
-            if ($success) {
-                $success = $this->dynamicsStore(true);
-            }
+            $this->dynamicsStore(true);
 
             $this->zdb->connection->commit();
             $this->_orig_amount = $this->_amount;
 
             //send event at the end of process, once all has been stored
-            if ($event !== null) {
-                $emitter->emit($event, $this);
+            if ($event !== null && $this->areEventsEnabled()) {
+                $emitter->dispatch(new GaletteEvent($event, $this));
             }
 
             return true;
@@ -672,7 +736,7 @@ class Contribution
             if ($this->zdb->connection->inTransaction()) {
                 $this->zdb->connection->rollBack();
             }
-            return false;
+            throw $e;
         }
     }
 
@@ -687,16 +751,16 @@ class Contribution
             $due_date = self::getDueDate($this->zdb, $this->_member);
 
             if ($due_date != '') {
-                $date_fin_update = $due_date;
+                $due_date_update = $due_date;
             } else {
-                $date_fin_update = new Expression('NULL');
+                $due_date_update = new Expression('NULL');
             }
 
             $update = $this->zdb->update(Adherent::TABLE);
             $update->set(
-                array('date_echeance' => $date_fin_update)
+                array('date_echeance' => $due_date_update)
             )->where(
-                Adherent::PK . '=' . $this->_member
+                [Adherent::PK => $this->_member]
             );
             $this->zdb->execute($update);
             return true;
@@ -707,7 +771,7 @@ class Contribution
                 $e->getMessage(),
                 Analog::ERROR
             );
-            return false;
+            throw $e;
         }
     }
 
@@ -728,20 +792,22 @@ class Contribution
             }
 
             $delete = $this->zdb->delete(self::TABLE);
-            $delete->where(self::PK . ' = ' . $this->_id);
+            $delete->where([self::PK => $this->_id]);
             $del = $this->zdb->execute($delete);
             if ($del->count() > 0) {
                 $this->updateDeadline();
                 $this->dynamicsRemove(true);
             } else {
-                throw new \RuntimeException(
-                    'Contribution has not been removed!'
+                Analog::log(
+                    'Contribution has not been removed!',
+                    Analog::WARNING
                 );
+                return false;
             }
             if ($transaction) {
                 $this->zdb->connection->commit();
             }
-            $emitter->emit('contribution.remove', $this);
+            $emitter->dispatch(new GaletteEvent('contribution.remove', $this));
             return true;
         } catch (Throwable $e) {
             if ($transaction) {
@@ -752,7 +818,7 @@ class Contribution
                 $this->_id . ' | ' . $e->getMessage(),
                 Analog::ERROR
             );
-            return false;
+            throw $e;
         }
     }
 
@@ -766,7 +832,7 @@ class Contribution
     public function getFieldLabel($field)
     {
         $label = $this->_fields[$field]['label'];
-        if ($this->isCotis() && $field == 'date_debut_cotis') {
+        if ($this->isFee() && $field == 'date_debut_cotis') {
             $label = $this->_fields[$field]['cotlabel'];
         }
         //replace "&nbsp;"
@@ -810,7 +876,7 @@ class Contribution
      * @param Db      $zdb       Database instance
      * @param integer $member_id Member identifier
      *
-     * @return date
+     * @return string
      */
     public static function getDueDate(Db $zdb, $member_id)
     {
@@ -828,7 +894,7 @@ class Contribution
                 'c.' . ContributionsTypes::PK . '=ct.' . ContributionsTypes::PK,
                 array()
             )->where(
-                Adherent::PK . ' = ' . $member_id
+                [Adherent::PK => $member_id]
             )->where(
                 array('cotis_extension' => new Expression('true'))
             );
@@ -847,7 +913,7 @@ class Contribution
                 'An error occurred trying to retrieve member\'s due date',
                 Analog::ERROR
             );
-            return false;
+            throw $e;
         }
     }
 
@@ -871,7 +937,7 @@ class Contribution
                 $update->set(
                     array(Transaction::PK => null)
                 )->where(
-                    self::PK . ' = ' . $contrib_id
+                    [self::PK => $contrib_id]
                 );
                 $zdb->execute($update);
                 return true;
@@ -889,7 +955,7 @@ class Contribution
                 ' to transaction #' . $trans_id . ' | ' . $e->getMessage(),
                 Analog::ERROR
             );
-            return false;
+            throw $e;
         }
     }
 
@@ -908,7 +974,7 @@ class Contribution
             $update = $zdb->update(self::TABLE);
             $update->set(
                 array(Transaction::PK => $trans_id)
-            )->where(self::PK . ' = ' . $contrib_id);
+            )->where([self::PK => $contrib_id]);
 
             $zdb->execute($update);
             return true;
@@ -918,16 +984,16 @@ class Contribution
                 ' to transaction #' . $trans_id . ' | ' . $e->getMessage(),
                 Analog::ERROR
             );
-            return false;
+            throw $e;
         }
     }
 
     /**
-     * Is current contribution a cotisation
+     * Is current contribution a membership fee
      *
      * @return boolean
      */
-    public function isCotis()
+    public function isFee()
     {
         return $this->_is_cotis;
     }
@@ -1056,7 +1122,7 @@ class Contribution
      */
     public function getRawType()
     {
-        if ($this->isCotis()) {
+        if ($this->isFee()) {
             return 'membership';
         } else {
             return 'donation';
@@ -1070,7 +1136,7 @@ class Contribution
      */
     public function getTypeLabel()
     {
-        if ($this->isCotis()) {
+        if ($this->isFee()) {
             return _T("Membership");
         } else {
             return _T("Donation");
@@ -1080,24 +1146,26 @@ class Contribution
     /**
      * Get payment type label
      *
+     * @param boolean $translated Whether to translate
+     *
      * @return string
      */
-    public function getPaymentType()
+    public function getPaymentType(bool $translated = false): string
     {
         if ($this->_payment_type === null) {
             return '-';
         }
 
         $ptype = new PaymentType($this->zdb, (int)$this->payment_type);
-        return $ptype->getName(false);
+        return $ptype->getName($translated);
     }
 
     /**
      * Global getter method
      *
-     * @param string $name name of the property we want to retrive
+     * @param string $name name of the property we want to retrieve
      *
-     * @return false|object the called property
+     * @return mixed the called property
      */
     public function __get($name)
     {
@@ -1117,8 +1185,7 @@ class Contribution
 
             switch ($name) {
                 case 'is_cotis':
-                    return $this->isCotis();
-                    break;
+                    return $this->isFee();
                 default:
                     throw new \RuntimeException("Call to __get for '$name' is forbidden!");
             }
@@ -1131,7 +1198,7 @@ class Contribution
                 case 'raw_begin_date':
                 case 'raw_end_date':
                     $rname = '_' . substr($name, 4);
-                    if ($this->$rname != '') {
+                    if ($this->$rname !== null && $this->$rname != '') {
                         try {
                             $d = new \DateTime($this->$rname);
                             return $d;
@@ -1149,7 +1216,7 @@ class Contribution
                 case 'date':
                 case 'begin_date':
                 case 'end_date':
-                    if ($this->$rname != '') {
+                    if ($this->$rname !== null && $this->$rname != '') {
                         try {
                             $d = new \DateTime($this->$rname);
                             return $d->format(__("Y-m-d"));
@@ -1166,33 +1233,27 @@ class Contribution
                     break;
                 case 'duration':
                     if ($this->_is_cotis) {
-                        $date_end = new \DateTime($this->_end_date);
-                        $date_start = new \DateTime($this->_begin_date);
-                        $diff = $date_end->diff($date_start);
-                        return $diff->format('%y') * 12 + $diff->format('%m');
+                        // Caution : the end_date stored is actually the due date.
+                        // Adding a day to compute the next_begin_date is required
+                        // to return the right number of months.
+                        $next_begin_date = new \DateTime($this->_end_date ?? $this->_begin_date);
+                        $next_begin_date->add(new \DateInterval('P1D'));
+                        $begin_date = new \DateTime($this->_begin_date);
+                        $diff = $next_begin_date->diff($begin_date);
+                        return (int)$diff->format('%y') * 12 + (int)$diff->format('%m');
                     } else {
                         return '';
                     }
-                    break;
                 case 'spayment_type':
-                    if ($this->_payment_type === null) {
-                        return '-';
-                    }
-
-                    $ptype = new PaymentType($this->zdb, (int)$this->payment_type);
-                    return $ptype->getName();
-
-                    break;
+                    return $this->getPaymentType(true);
                 case 'model':
                     if ($this->_is_cotis === null) {
                         return null;
                     }
-                    return ($this->isCotis()) ?
+                    return ($this->isFee()) ?
                         PdfModel::INVOICE_MODEL : PdfModel::RECEIPT_MODEL;
-                    break;
                 default:
                     return $this->$rname;
-                    break;
             }
         } else {
             Analog::log(
@@ -1203,11 +1264,44 @@ class Contribution
         }
     }
 
+    /**
+     * Global isset method
+     * Required for twig to access properties via __get
+     *
+     * @param string $name name of the property we want to retrieve
+     *
+     * @return bool
+     */
+    public function __isset($name)
+    {
+        $forbidden = array('is_cotis');
+        $virtuals = array('duration', 'spayment_type', 'model', 'raw_date',
+            'raw_begin_date', 'raw_end_date'
+        );
+
+        $rname = '_' . $name;
+
+        if (in_array($name, $forbidden)) {
+            switch ($name) {
+                case 'is_cotis':
+                    return true;
+            }
+        } elseif (
+            property_exists($this, $rname)
+            || in_array($name, $virtuals)
+        ) {
+            return true;
+        }
+
+        return false;
+    }
+
+
     /**
      * Global setter method
      *
      * @param string $name  name of the property we want to assign a value to
-     * @param object $value a relevant value for the property
+     * @param mixed  $value a relevant value for the property
      *
      * @return void
      */
@@ -1325,7 +1419,7 @@ class Contribution
      *
      * @return Contribution
      */
-    public function setSendmail($send = true)
+    public function setSendmail(bool $send = true)
     {
         $this->sendmail = $send;
         return $this;
@@ -1356,7 +1450,7 @@ class Contribution
 
         if (count($this->errors) > 0) {
             Analog::log(
-                'Some errors has been throwed attempting to edit/store a contribution files' . "\n" .
+                'Some errors has been threw attempting to edit/store a contribution files' . "\n" .
                 print_r($this->errors, true),
                 Analog::ERROR
             );
@@ -1373,15 +1467,60 @@ class Contribution
      */
     public function getRequired(): array
     {
-        // required fields
-        $required = [
+        return [
             'id_type_cotis'     => 1,
             'id_adh'            => 1,
             'date_enreg'        => 1,
             'date_debut_cotis'  => 1,
-            'date_fin_cotis'    => $this->isCotis(),
-            'montant_cotis'     => $this->isCotis() ? 1 : 0
+            'date_fin_cotis'    => $this->isFee() ? 1 : 0,
+            'montant_cotis'     => $this->isFee() ? 1 : 0
         ];
-        return $required;
+    }
+
+    /**
+     * Can current logged-in user display contribution
+     *
+     * @param Login $login Login instance
+     *
+     * @return boolean
+     */
+    public function canShow(Login $login): bool
+    {
+        //non-logged-in members cannot show contributions
+        if (!$login->isLogged()) {
+            return false;
+        }
+
+        //admin and staff users can edit, as well as member itself
+        if (!$this->id || $login->id == $this->_member || $login->isAdmin() || $login->isStaff()) {
+            return true;
+        }
+
+        //parent can see their children contributions
+        $parent = new Adherent($this->zdb);
+        $parent
+            ->disableAllDeps()
+            ->enableDep('children')
+            ->load($this->login->id);
+        if ($parent->hasChildren()) {
+            foreach ($parent->children as $child) {
+                if ($child->id === $this->_member) {
+                    return true;
+                }
+            }
+            return false;
+        }
+
+        return false;
+    }
+
+    /**
+     * Get prefix for events
+     *
+     * @return string
+     */
+    protected function getEventsPrefix(): string
+    {
+        return 'contribution';
     }
 }