]> 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 c13a0152d393702251d7732e1d0dbec646f1ca30..3457da44e01dc8437c3eeeb0d82943f3376dc18a 100644 (file)
@@ -4,10 +4,11 @@
 
 /**
  * Contribution class for galette
+ * Manage membership fees and donations.
  *
  * PHP version 5
  *
- * Copyright © 2010-2014 The Galette Team
+ * Copyright © 2010-2023 The Galette Team
  *
  * This file is part of Galette (http://galette.tuxfamily.org).
  *
  * @package   Galette
  *
  * @author    Johan Cwiklinski <johan@x-tnd.be>
- * @copyright 2010-2014 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
- * @version   SVN: $Id$
  * @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 Zend\Db\Sql\Expression;
+use Laminas\Db\Sql\Expression;
 use Galette\Core\Db;
 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-2014 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;
 
-    const TABLE = 'cotisations';
-    const PK = 'id_cotis';
+    public const TABLE = 'cotisations';
+    public const PK = 'id_cotis';
 
-    const PAYMENT_OTHER = 0;
-    const PAYMENT_CASH = 1;
-    const PAYMENT_CREDITCARD = 2;
-    const PAYMENT_CHECK = 3;
-    const PAYMENT_TRANSFER = 4;
-    const PAYMENT_PAYPAL = 5;
+    public const TYPE_FEE = 'fee';
+    public const TYPE_DONATION = 'donation';
 
     private $_id;
     private $_date;
@@ -87,30 +111,43 @@ class Contribution
     //fields list and their translation
     private $_fields;
 
+    /** @var Db */
     private $zdb;
+    /** @var Login */
     private $login;
-
+    /** @var array */
     private $errors;
 
+    private $sendmail = false;
+
     /**
      * 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...
@@ -118,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(
@@ -142,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(
@@ -155,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'
@@ -180,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();
@@ -204,9 +243,7 @@ class Contribution
             $this->loadFromRS($args);
         }
 
-        if ($this->id !== null) {
-            $this->loadDynamicFields();
-        }
+        $this->loadDynamicFields();
     }
 
     /**
@@ -218,21 +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 next membership begin date
+                $diff1 = (int)$now->diff($next_begin_date)->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 next membership begin date is less than or equal to the offered months, it's free :)
+                if ($diff1 <= $diff2) {
+                    $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
-            $this->_extension = $preferences->pref_membership_ext;
+            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!'
@@ -243,65 +305,86 @@ 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);
-            $row = $results->current();
-            if ($row !== false) {
+            if ($results->count() > 0) {
+                $row = $results->current();
                 $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 (\Exception $e) {
+        } catch (Throwable $e) {
             Analog::log(
-                'An error occured attempting to load contribution #' . $id .
+                'An error occurred attempting to load contribution #' . $id .
                 $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 :(
-        if ($enddate !== '0000-00-00'
-            && $enddate !== '1901-01-01'
-            && $enddate !== '0001-01-01 BC'
+        //the one with BC comes from 0.63/pgsql demo... Why the hell a so
+        //strange date? don't know :(
+        if (
+            $end_date !== '0000-00-00'
+            && $end_date !== '1901-01-01'
+            && $end_date !== '0001-01-01 BC'
         ) {
             $this->_end_date = $r->date_fin_cotis;
         }
@@ -314,6 +397,7 @@ class Contribution
         }
 
         $this->type = (int)$r->id_type_cotis;
+        $this->loadDynamicFields();
     }
 
     /**
@@ -328,11 +412,12 @@ class Contribution
      */
     public function check($values, $required, $disabled)
     {
+        global $preferences;
         $this->errors = array();
 
         $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'];
 
@@ -357,10 +442,14 @@ 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 (\Exception $e) {
+                            } catch (Throwable $e) {
                                 Analog::log(
                                     'Wrong date format. field: ' . $key .
                                     ', value: ' . $value . ', expected fmt: ' .
@@ -383,7 +472,7 @@ class Contribution
                         break;
                     case Adherent::PK:
                         if ($value != '') {
-                            $this->_member = $value;
+                            $this->_member = (int)$value;
                         }
                         break;
                     case ContributionsTypes::PK:
@@ -392,20 +481,22 @@ class Contribution
                         }
                         break;
                     case 'montant_cotis':
-                        $this->_amount = $value;
                         $value = strtr($value, ',', '.');
-                        if (!is_numeric($value)) {
+                        if (!empty($value) || $value === '0') {
+                            $this->_amount = (double)$value;
+                        }
+                        if (!is_numeric($value) && $value !== '') {
                             $this->errors[] = _T("- The amount must be an integer!");
                         }
                         break;
                     case 'type_paiement_cotis':
-                        if ($value == self::PAYMENT_OTHER
-                            || $value == self::PAYMENT_CASH
-                            || $value == self::PAYMENT_CREDITCARD
-                            || $value == self::PAYMENT_CHECK
-                            || $value == self::PAYMENT_TRANSFER
-                            || $value == self::PAYMENT_PAYPAL
-                        ) {
+                        $ptypes = new PaymentTypes(
+                            $this->zdb,
+                            $preferences,
+                            $this->login
+                        );
+                        $ptlist = $ptypes->getList();
+                        if (isset($ptlist[$value])) {
                             $this->_payment_type = $value;
                         } else {
                             $this->errors[] = _T("- Unknown payment type");
@@ -436,13 +527,17 @@ class Contribution
         foreach ($required as $key => $val) {
             if ($val === 1) {
                 $prop = '_' . $this->_fields[$key]['propname'];
-                if (!isset($disabled[$key])
+                if (
+                    !isset($disabled[$key])
                     && (!isset($this->$prop)
                     || (!is_object($this->$prop) && trim($this->$prop) == '')
                     || (is_object($this->$prop) && trim($this->$prop->id) == ''))
                 ) {
-                    $this->errors[] = _T("- Mandatory field empty: ") .
-                    ' <a href="#' . $key . '">' . $this->getFieldLabel($key) .'</a>';
+                    $this->errors[] = str_replace(
+                        '%field',
+                        '<a href="#' . $key . '">' . $this->getFieldLabel($key) . '</a>',
+                        _T("- Mandatory field %field empty.")
+                    );
                 }
             }
         }
@@ -456,21 +551,19 @@ 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 occured checking overlaping fees :(");
-                } else {
-                    //method directly return error message
-                    $this->errors[] = $overlap;
-                }
+                //method directly return error message
+                $this->errors[] = $overlap;
             }
         }
 
+        $this->dynamicsCheck($values, $required, $disabled);
+
         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
             );
@@ -494,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 (\Exception $e) {
+        } catch (Throwable $e) {
             Analog::log(
-                'An error occured checking overlaping fee. ' . $e->getMessage(),
+                'An error occurred checking overlapping fee. ' . $e->getMessage(),
                 Analog::ERROR
             );
-            return false;
+            throw $e;
         }
     }
 
@@ -539,7 +640,16 @@ class Contribution
      */
     public function store()
     {
-        global $hist;
+        global $hist, $emitter;
+
+        $event = null;
+
+        if (count($this->errors) > 0) {
+            throw new \RuntimeException(
+                'Existing errors prevents storing contribution: ' .
+                print_r($this->errors, true)
+            );
+        }
 
         try {
             $this->zdb->connection->beginTransaction();
@@ -561,7 +671,7 @@ 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']);
             }
 
@@ -574,31 +684,24 @@ class Contribution
                 $add = $this->zdb->execute($insert);
 
                 if ($add->count() > 0) {
-                    if ($this->zdb->isPostgres()) {
-                        $this->_id = $this->zdb->driver->getLastGeneratedValue(
-                            PREFIX_DB . 'cotisations_id_seq'
-                        );
-                    } else {
-                        $this->_id = $this->zdb->driver->getLastGeneratedValue();
-                    }
+                    $this->_id = $this->zdb->getLastGeneratedValue($this);
 
                     // logging
                     $hist->add(
                         _T("Contribution added"),
                         Adherent::getSName($this->zdb, $this->_member)
                     );
+                    $event = $this->getAddEventName();
                 } else {
                     $hist->add(_T("Fail to add new contribution."));
                     throw new \Exception(
-                        'An error occured inserting new contribution!'
+                        'An error occurred inserting new 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
@@ -608,31 +711,32 @@ class Contribution
                         _T("Contribution updated"),
                         Adherent::getSName($this->zdb, $this->_member)
                     );
-                } elseif ($edit === false) {
-                    throw new \Exception(
-                        'An error occured updating contribution # ' . $this->_id . '!'
-                    );
                 }
+
+                $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 occured updating member\'s deadline');
-                }
+            if ($this->isFee()) {
+                $this->updateDeadline();
             }
+
+            //dynamic fields
+            $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 && $this->areEventsEnabled()) {
+                $emitter->dispatch(new GaletteEvent($event, $this));
+            }
+
             return true;
-        } catch (\Exception $e) {
-            $this->zdb->connection->rollBack();
-            Analog::log(
-                'Something went wrong :\'( | ' . $e->getMessage() . "\n" .
-                $e->getTraceAsString(),
-                Analog::ERROR
-            );
-            return false;
+        } catch (Throwable $e) {
+            if ($this->zdb->connection->inTransaction()) {
+                $this->zdb->connection->rollBack();
+            }
+            throw $e;
         }
     }
 
@@ -647,27 +751,27 @@ 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;
-        } catch (\Exception $e) {
+        } catch (Throwable $e) {
             Analog::log(
-                'An error occured updating member ' . $this->_member .
+                'An error occurred updating member ' . $this->_member .
                 '\'s deadline |' .
                 $e->getMessage(),
                 Analog::ERROR
             );
-            return false;
+            throw $e;
         }
     }
 
@@ -680,31 +784,41 @@ class Contribution
      */
     public function remove($transaction = true)
     {
+        global $emitter;
+
         try {
             if ($transaction) {
                 $this->zdb->connection->beginTransaction();
             }
 
             $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 {
+                Analog::log(
+                    'Contribution has not been removed!',
+                    Analog::WARNING
+                );
+                return false;
             }
             if ($transaction) {
                 $this->zdb->connection->commit();
             }
+            $emitter->dispatch(new GaletteEvent('contribution.remove', $this));
             return true;
-        } catch (\Exception $e) {
+        } catch (Throwable $e) {
             if ($transaction) {
                 $this->zdb->connection->rollBack();
             }
             Analog::log(
-                'An error occured trying to remove contribution #' .
+                'An error occurred trying to remove contribution #' .
                 $this->_id . ' | ' . $e->getMessage(),
                 Analog::ERROR
             );
-            return false;
+            throw $e;
         }
     }
 
@@ -718,11 +832,13 @@ 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'];
         }
-        //remove trailing ':' and then nbsp (for french at least)
-        $label = trim(trim($label, ':'), '&nbsp;');
+        //replace "&nbsp;"
+        $label = str_replace('&nbsp;', ' ', $label);
+        //remove trailing ':' and then trim
+        $label = trim(trim($label, ':'));
         return $label;
     }
 
@@ -750,9 +866,8 @@ class Contribution
      */
     public function getRowClass()
     {
-        return ( $this->_end_date != $this->_begin_date && $this->_is_cotis) ?
-            'cotis-normal' :
-            'cotis-give';
+        return ($this->_end_date != $this->_begin_date && $this->_is_cotis) ?
+            'cotis-normal' : 'cotis-give';
     }
 
     /**
@@ -761,10 +876,13 @@ 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)
     {
+        if (!$member_id) {
+            return '';
+        }
         try {
             $select = $zdb->select(self::TABLE, 'c');
             $select->columns(
@@ -776,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'))
             );
@@ -790,12 +908,12 @@ class Contribution
                 $due_date = '';
             }
             return $due_date;
-        } catch (\Exception $e) {
+        } catch (Throwable $e) {
             Analog::log(
-                'An error occured trying to retrieve member\'s due date',
+                'An error occurred trying to retrieve member\'s due date',
                 Analog::ERROR
             );
-            return false;
+            throw $e;
         }
     }
 
@@ -819,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;
@@ -831,13 +949,13 @@ class Contribution
                 );
                 return false;
             }
-        } catch (\Exception $e) {
+        } catch (Throwable $e) {
             Analog::log(
                 'Unable to detach contribution #' . $contrib_id .
                 ' to transaction #' . $trans_id . ' | ' . $e->getMessage(),
                 Analog::ERROR
             );
-            return false;
+            throw $e;
         }
     }
 
@@ -856,26 +974,26 @@ 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;
-        } catch (\Exception $e) {
+        } catch (Throwable $e) {
             Analog::log(
                 'Unable to attach contribution #' . $contrib_id .
                 ' 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;
     }
@@ -910,7 +1028,7 @@ class Contribution
      * Execute post contribution script
      *
      * @param ExternalScript $es     External script to execute
-     * @param array          $extra  Extra informations on contribution
+     * @param array          $extra  Extra information on contribution
      *                               Defaults to null
      * @param array          $pextra Extra information on payment
      *                               Defaults to null
@@ -944,6 +1062,7 @@ class Contribution
         }
 
         $contrib = array(
+            'id'        => (int)$this->_id,
             'date'      => $this->_date,
             'type'      => $this->getRawType(),
             'amount'    => $this->amount,
@@ -958,6 +1077,7 @@ class Contribution
         if ($this->_member !== null) {
             $m = new Adherent($this->zdb, (int)$this->_member);
             $member = array(
+                'id'            => (int)$this->_member,
                 'name'          => $m->sfullname,
                 'email'         => $m->email,
                 'organization'  => ($m->isCompany() ? 1 : 0),
@@ -983,11 +1103,11 @@ class Contribution
 
         if ($res !== true) {
             Analog::log(
-                'An error occured calling post contribution ' .
+                'An error occurred calling post contribution ' .
                 "script:\n" . $es->getOutput(),
                 Analog::ERROR
             );
-            $res = _T("Contribution informations") . "\n";
+            $res = _T("Contribution information") . "\n";
             $res .= print_r($contrib, true);
             $res .= "\n\n" . _T("Script output") . "\n";
             $res .= $es->getOutput();
@@ -1002,7 +1122,7 @@ class Contribution
      */
     public function getRawType()
     {
-        if ($this->isCotis()) {
+        if ($this->isFee()) {
             return 'membership';
         } else {
             return 'donation';
@@ -1016,7 +1136,7 @@ class Contribution
      */
     public function getTypeLabel()
     {
-        if ($this->isCotis()) {
+        if ($this->isFee()) {
             return _T("Membership");
         } else {
             return _T("Donation");
@@ -1024,52 +1144,28 @@ class Contribution
     }
 
     /**
-     * Get payent type label
+     * 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 '-';
         }
 
-        switch ($this->payment_type) {
-            case Contribution::PAYMENT_CASH:
-                return 'cash';
-                break;
-            case Contribution::PAYMENT_CREDITCARD:
-                return 'credit_card';
-                break;
-            case Contribution::PAYMENT_CHECK:
-                return 'check';
-                break;
-            case Contribution::PAYMENT_TRANSFER:
-                return 'transfer';
-                break;
-            case Contribution::PAYMENT_PAYPAL:
-                return 'paypal';
-                break;
-            case Contribution::PAYMENT_OTHER:
-                return 'other';
-                break;
-            default:
-                Analog::log(
-                    __METHOD__ . ' Unknonw payment type ' . $this->payment_type,
-                    Analog::ERROR
-                );
-                throw new \RuntimeException(
-                    'Unknonw payment type ' . $this->payment_type
-                );
-        }
+        $ptype = new PaymentType($this->zdb, (int)$this->payment_type);
+        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)
     {
@@ -1089,12 +1185,12 @@ 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!");
             }
-        } elseif (property_exists($this, $rname)
+        } elseif (
+            property_exists($this, $rname)
             || in_array($name, $virtuals)
         ) {
             switch ($name) {
@@ -1102,11 +1198,11 @@ 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;
-                        } catch (\Exception $e) {
+                        } catch (Throwable $e) {
                             //oops, we've got a bad date :/
                             Analog::log(
                                 'Bad date (' . $this->$rname . ') | ' .
@@ -1120,11 +1216,11 @@ 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"));
-                        } catch (\Exception $e) {
+                        } catch (Throwable $e) {
                             //oops, we've got a bad date :/
                             Analog::log(
                                 'Bad date (' . $this->$rname . ') | ' .
@@ -1137,57 +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 '-';
-                    }
-                    switch ($this->_payment_type) {
-                        case self::PAYMENT_OTHER:
-                            return _T("Other");
-                            break;
-                        case self::PAYMENT_CASH:
-                            return _T("Cash");
-                            break;
-                        case self::PAYMENT_CREDITCARD:
-                            return _T("Credit card");
-                            break;
-                        case self::PAYMENT_CHECK:
-                            return _T("Check");
-                            break;
-                        case self::PAYMENT_TRANSFER:
-                            return _T("Transfer");
-                            break;
-                        case self::PAYMENT_PAYPAL:
-                            return _T("Paypal");
-                            break;
-                        default:
-                            Analog::log(
-                                'Unknown payment type ' . $this->_payment_type,
-                                Analog::WARNING
-                            );
-                            return '-';
-                            break;
-                    }
-                    break;
+                    return $this->getPaymentType(true);
                 case 'model':
                     if ($this->_is_cotis === null) {
                         return null;
                     }
-                    return ($this->isCotis()) ?
-                        PdfModel::INVOICE_MODEL :
-                        PdfModel::RECEIPT_MODEL;
-                    break;
+                    return ($this->isFee()) ?
+                        PdfModel::INVOICE_MODEL : PdfModel::RECEIPT_MODEL;
                 default:
                     return $this->$rname;
-                    break;
             }
         } else {
             Analog::log(
@@ -1198,16 +1264,51 @@ 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
      */
     public function __set($name, $value)
     {
+        global $preferences;
+
         $forbidden = array('fields', 'is_cotis', 'end_date');
 
         if (!in_array($name, $forbidden)) {
@@ -1247,9 +1348,9 @@ class Contribution
                             throw new \Exception('Incorrect format');
                         }
                         $this->_begin_date = $d->format('Y-m-d');
-                    } catch (\Exception $e) {
+                    } catch (Throwable $e) {
                         Analog::log(
-                            'Wrong date format. field: ' . $key .
+                            'Wrong date format. field: ' . $name .
                             ', value: ' . $value . ', expected fmt: ' .
                             __("Y-m-d") . ' | ' . $e->getMessage(),
                             Analog::INFO
@@ -1261,7 +1362,7 @@ class Contribution
                             ),
                             array(
                                 __("Y-m-d"),
-                                $this->_fields[$key]['label']
+                                $this->_fields['date_debut_cotis']['label']
                             ),
                             _T("- Wrong date format (%date_format) for %field!")
                         );
@@ -1284,6 +1385,22 @@ class Contribution
                         $this->$rname = $value;
                     }
                     break;
+                case 'payment_type':
+                    $ptypes = new PaymentTypes(
+                        $this->zdb,
+                        $preferences,
+                        $this->login
+                    );
+                    $list = $ptypes->getList();
+                    if (isset($list[$value])) {
+                        $this->_payment_type = $value;
+                    } else {
+                        Analog::log(
+                            'Unknown payment type ' . $value,
+                            Analog::WARNING
+                        );
+                    }
+                    break;
                 default:
                     Analog::log(
                         '[' . __CLASS__ . ']: Trying to set an unknown property (' .
@@ -1294,4 +1411,116 @@ class Contribution
             }
         }
     }
+
+    /**
+     * Flag creation mail sending
+     *
+     * @param boolean $send True (default) to send creation email
+     *
+     * @return Contribution
+     */
+    public function setSendmail(bool $send = true)
+    {
+        $this->sendmail = $send;
+        return $this;
+    }
+
+    /**
+     * Should we send administrative emails to member?
+     *
+     * @return boolean
+     */
+    public function sendEMail()
+    {
+        return $this->sendmail;
+    }
+
+    /**
+     * Handle files (dynamics files)
+     *
+     * @param array $files Files sent
+     *
+     * @return array|true
+     */
+    public function handleFiles($files)
+    {
+        $this->errors = [];
+
+        $this->dynamicsFiles($files);
+
+        if (count($this->errors) > 0) {
+            Analog::log(
+                'Some errors has been threw attempting to edit/store a contribution files' . "\n" .
+                print_r($this->errors, true),
+                Analog::ERROR
+            );
+            return $this->errors;
+        } else {
+            return true;
+        }
+    }
+
+    /**
+     * Get required fields list
+     *
+     * @return array
+     */
+    public function getRequired(): array
+    {
+        return [
+            'id_type_cotis'     => 1,
+            'id_adh'            => 1,
+            'date_enreg'        => 1,
+            'date_debut_cotis'  => 1,
+            'date_fin_cotis'    => $this->isFee() ? 1 : 0,
+            'montant_cotis'     => $this->isFee() ? 1 : 0
+        ];
+    }
+
+    /**
+     * 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';
+    }
 }