]> git.agnieray.net Git - galette.git/blob - galette/lib/Galette/Entity/Contribution.php
Add entity helper feature
[galette.git] / galette / lib / Galette / Entity / Contribution.php
1 <?php
2
3 /**
4 * Copyright © 2003-2024 The Galette Team
5 *
6 * This file is part of Galette (https://galette.eu).
7 *
8 * Galette is free software: you can redistribute it and/or modify
9 * it under the terms of the GNU General Public License as published by
10 * the Free Software Foundation, either version 3 of the License, or
11 * (at your option) any later version.
12 *
13 * Galette is distributed in the hope that it will be useful,
14 * but WITHOUT ANY WARRANTY; without even the implied warranty of
15 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16 * GNU General Public License for more details.
17 *
18 * You should have received a copy of the GNU General Public License
19 * along with Galette. If not, see <http://www.gnu.org/licenses/>.
20 */
21
22 namespace Galette\Entity;
23
24 use ArrayObject;
25 use DateTime;
26 use Galette\Events\GaletteEvent;
27 use Galette\Features\HasEvent;
28 use Throwable;
29 use Analog\Analog;
30 use Laminas\Db\Sql\Expression;
31 use Galette\Core\Db;
32 use Galette\Core\Login;
33 use Galette\IO\ExternalScript;
34 use Galette\IO\PdfContribution;
35 use Galette\Repository\PaymentTypes;
36 use Galette\Features\Dynamics;
37 use Galette\Features\EntityHelper;
38
39 /**
40 * Contribution class for galette
41 * Manage membership fees and donations.
42 *
43 * @author Johan Cwiklinski <johan@x-tnd.be>
44 *
45 * @property integer $id
46 * @property string $date
47 * @property DateTime $raw_date
48 * @property integer $member
49 * @property ContributionsTypes $type
50 * @property double $amount
51 * @property integer $payment_type
52 * @property double $orig_amount
53 * @property string $info
54 * @property string $begin_date
55 * @property DateTime $raw_begin_date
56 * @property string $end_date
57 * @property DateTime $raw_end_date
58 * @property Transaction|null $transaction
59 * @property integer $extension
60 * @property integer $duration
61 * @property integer $model
62 * @property array<string, array<string, string>> $fields
63 */
64 class Contribution
65 {
66 use Dynamics;
67 use HasEvent;
68 use EntityHelper {
69 getFieldLabel as protected trait_getFieldLabel;
70 __isset as protected trait___isset;
71 }
72
73 public const TABLE = 'cotisations';
74 public const PK = 'id_cotis';
75
76 public const TYPE_FEE = 'fee';
77 public const TYPE_DONATION = 'donation';
78
79 public const STATUS_NEVER = -1;
80 public const STATUS_UNKNOWN = 0;
81 public const STATUS_UPTODATE = 1;
82 public const STATUS_DUEFREE = 2;
83 public const STATUS_IMPENDING = 3;
84 public const STATUS_LATE = 4;
85 public const STATUS_OLD = 5;
86
87 private int $_id;
88 private ?string $_date = null;
89 private ?int $_member = null;
90 private ?ContributionsTypes $_type = null;
91 private ?float $_amount = null;
92 private ?int $_payment_type;
93 private ?float $_orig_amount = null;
94 private ?string $_info = null;
95 private ?string $_begin_date = null;
96 private ?string $_end_date = null;
97 private ?Transaction $_transaction = null;
98 private bool $_is_cotis;
99 private ?int $_extension = null;
100
101 /** @var Db */
102 private Db $zdb;
103 /** @var Login */
104 private Login $login;
105 /** @var array<string> */
106 protected array $errors = [];
107
108 private bool $sendmail = false;
109
110 /** @var string[] */
111 protected array $forbidden_fields = ['is_cotis'];
112
113 /** @var string[] */
114 protected array $virtual_fields = [
115 'duration',
116 'model',
117 'raw_date',
118 'raw_begin_date',
119 'raw_end_date',
120 ];
121
122 /**
123 * Default constructor
124 *
125 * @param Db $zdb Database
126 * @param Login $login Login instance
127 * @param null|int|array<string,mixed>|ArrayObject<string,int|string> $args Either a ResultSet row to load
128 * a specific contribution, or a type id
129 * to just instantiate object
130 */
131 public function __construct(Db $zdb, Login $login, int|array|ArrayObject $args = null)
132 {
133 $this->zdb = $zdb;
134 $this->login = $login;
135
136 global $preferences;
137 $this->_payment_type = $preferences->pref_default_paymenttype;
138
139 $this
140 ->setFields()
141 ->withAddEvent()
142 ->withEditEvent()
143 ->withoutDeleteEvent()
144 ->activateEvents();
145
146
147 if (is_int($args)) {
148 $this->load($args);
149 } elseif (is_array($args)) {
150 $this->_date = date("Y-m-d");
151 if (isset($args['adh']) && $args['adh'] != '') {
152 $this->_member = (int)$args['adh'];
153 }
154 if (isset($args['trans'])) {
155 $this->_transaction = new Transaction($this->zdb, $this->login, (int)$args['trans']);
156 if (!isset($this->_member)) {
157 $this->_member = $this->_transaction->member;
158 }
159 $this->_amount = $this->_transaction->getMissingAmount();
160 }
161 $this->setContributionType((int)$args['type']);
162 //calculate begin date for membership fee
163 $this->_begin_date = $this->_date;
164 if ($this->_is_cotis) {
165 $due_date = self::getDueDate($this->zdb, $this->_member);
166 if ($due_date != '') {
167 $now = new \DateTime();
168 $due_date = new \DateTime($due_date);
169 if ($due_date < $now) {
170 // Member didn't renew on time
171 $this->_begin_date = $now->format('Y-m-d');
172 } else {
173 // Caution : the next_begin_date is the day after the due_date.
174 $next_begin_date = clone $due_date;
175 $next_begin_date->add(new \DateInterval('P1D'));
176 $this->_begin_date = $next_begin_date->format('Y-m-d');
177 }
178 }
179 $this->retrieveEndDate();
180 }
181 if (isset($args['payment_type'])) {
182 $this->_payment_type = $args['payment_type'];
183 }
184 } elseif (is_object($args)) {
185 $this->loadFromRS($args);
186 }
187
188 $this->loadDynamicFields();
189 }
190
191 /**
192 * Set fields, must populate $this->fields
193 *
194 * @return self
195 */
196 protected function setFields(): self
197 {
198 $this->fields = array(
199 'id_cotis' => array(
200 'label' => _T('Contribution id'), //not a field in the form
201 'propname' => 'id'
202 ),
203 Adherent::PK => array(
204 'label' => _T("Contributor:"),
205 'propname' => 'member'
206 ),
207 ContributionsTypes::PK => array(
208 'label' => _T("Contribution type:"),
209 'propname' => 'type'
210 ),
211 'montant_cotis' => array(
212 'label' => _T("Amount:"),
213 'propname' => 'amount'
214 ),
215 'type_paiement_cotis' => array(
216 'label' => _T("Payment type:"),
217 'propname' => 'payment_type'
218 ),
219 'info_cotis' => array(
220 'label' => _T("Comments:"),
221 'propname' => 'info'
222 ),
223 'date_enreg' => array(
224 'label' => _T('Date'), //not a field in the form
225 'propname' => 'date'
226 ),
227 'date_debut_cotis' => array(
228 'label' => _T("Date of contribution:"),
229 'cotlabel' => _T("Start date of membership:"), //if contribution is a membership fee, label differs
230 'propname' => 'begin_date'
231 ),
232 'date_fin_cotis' => array(
233 'label' => _T("End date of membership:"),
234 'propname' => 'end_date'
235 ),
236 Transaction::PK => array(
237 'label' => _T('Transaction ID'), //not a field in the form
238 'propname' => 'transaction'
239 ),
240 //this one is not really a field, but is required in some cases...
241 //adding it here make more simple to check required fields
242 'duree_mois_cotis' => array(
243 'label' => _T("Membership extension:"),
244 'propname' => 'extension'
245 )
246 );
247
248 return $this;
249 }
250
251
252 /**
253 * Sets end contribution date
254 *
255 * @return void
256 */
257 private function retrieveEndDate(): void
258 {
259 global $preferences;
260
261 $now = new \DateTime();
262 $begin_date = new \DateTime($this->_begin_date);
263 if ($preferences->pref_beg_membership != '') {
264 //case beginning of membership
265 list($j, $m) = explode('/', $preferences->pref_beg_membership);
266 $next_begin_date = new \DateTime($begin_date->format('Y') . '-' . $m . '-' . $j);
267 while ($next_begin_date <= $begin_date) {
268 $next_begin_date->add(new \DateInterval('P1Y'));
269 }
270
271 if ($preferences->pref_membership_offermonths > 0) {
272 //count days until next membership begin date
273 $diff1 = (int)$now->diff($next_begin_date)->format('%a');
274
275 //count days between next membership begin date and offered months
276 $tdate = clone $next_begin_date;
277 $tdate->sub(new \DateInterval('P' . $preferences->pref_membership_offermonths . 'M'));
278 $diff2 = (int)$next_begin_date->diff($tdate)->format('%a');
279
280 //when number of days until next membership begin date is less than or equal to the offered months, it's free :)
281 if ($diff1 <= $diff2) {
282 $next_begin_date->add(new \DateInterval('P1Y'));
283 }
284 }
285
286 // Caution : the end_date to retrieve is the day before the next_begin_date.
287 $end_date = clone $next_begin_date;
288 $end_date->sub(new \DateInterval('P1D'));
289 $this->_end_date = $end_date->format('Y-m-d');
290 } elseif ($preferences->pref_membership_ext != '') {
291 //case membership extension
292 if ($this->_extension == null) {
293 $this->_extension = $preferences->pref_membership_ext;
294 }
295 $dext = new \DateInterval('P' . $this->_extension . 'M');
296 // Caution : the end_date to retrieve is the day before the next_begin_date.
297 $next_begin_date = $begin_date->add($dext);
298 $end_date = clone $next_begin_date;
299 $end_date->sub(new \DateInterval('P1D'));
300 $this->_end_date = $end_date->format('Y-m-d');
301 } else {
302 throw new \RuntimeException(
303 'Unable to define end date; none of pref_beg_membership nor pref_membership_ext are defined!'
304 );
305 }
306 }
307
308 /**
309 * Loads a contribution from its id
310 *
311 * @param int $id the identifier for the contribution to load
312 *
313 * @return bool true if query succeed, false otherwise
314 */
315 public function load(int $id): bool
316 {
317 if (!$this->login->isLogged() && $this->login->id == '') {
318 return false;
319 }
320
321 try {
322 $select = $this->zdb->select(self::TABLE, 'c');
323 $select->join(
324 array('a' => PREFIX_DB . Adherent::TABLE),
325 'c.' . Adherent::PK . '=a.' . Adherent::PK,
326 array()
327 );
328 //restrict query on current member id if he's not admin nor staff member
329 if (!$this->login->isAdmin() && !$this->login->isStaff()) {
330 $select->where
331 ->nest()
332 ->equalTo('a.' . Adherent::PK, $this->login->id)
333 ->or
334 ->equalTo('a.parent_id', $this->login->id)
335 ->unnest()
336 ->and
337 ->equalTo('c.' . self::PK, $id)
338 ;
339 } else {
340 $select->where->equalTo(self::PK, $id);
341 }
342
343 $results = $this->zdb->execute($select);
344 if ($results->count() > 0) {
345 $row = $results->current();
346 $this->loadFromRS($row);
347 return true;
348 } else {
349 Analog::log(
350 'No contribution #' . $id . ' (user ' . $this->login->id . ')',
351 Analog::ERROR
352 );
353 return false;
354 }
355 } catch (Throwable $e) {
356 Analog::log(
357 'An error occurred attempting to load contribution #' . $id .
358 $e->getMessage(),
359 Analog::ERROR
360 );
361 throw $e;
362 }
363 }
364
365 /**
366 * Populate object from a resultset row
367 *
368 * @param ArrayObject<string, int|string> $r the resultset row
369 *
370 * @return void
371 */
372 private function loadFromRS(ArrayObject $r): void
373 {
374 $pk = self::PK;
375 $this->_id = (int)$r->$pk;
376 $this->_date = $r->date_enreg;
377 $this->_amount = (double)$r->montant_cotis;
378 //save original amount, we need it for transactions parts calculations
379 $this->_orig_amount = (double)$r->montant_cotis;
380 $this->_payment_type = $r->type_paiement_cotis;
381 $this->_info = $r->info_cotis;
382 $this->_begin_date = $r->date_debut_cotis;
383 $end_date = $r->date_fin_cotis;
384 //do not work with knows bad dates...
385 //the one with BC comes from 0.63/pgsql demo... Why the hell a so
386 //strange date? don't know :(
387 if (
388 $end_date !== '0000-00-00'
389 && $end_date !== '1901-01-01'
390 && $end_date !== '0001-01-01 BC'
391 ) {
392 $this->_end_date = $r->date_fin_cotis;
393 }
394 $adhpk = Adherent::PK;
395 $this->_member = (int)$r->$adhpk;
396
397 $transpk = Transaction::PK;
398 if ($r->$transpk != '') {
399 $this->_transaction = new Transaction($this->zdb, $this->login, (int)$r->$transpk);
400 }
401
402 $this->setContributionType((int)$r->id_type_cotis);
403 $this->loadDynamicFields();
404 }
405
406 /**
407 * Check posted values validity
408 *
409 * @param array<string,mixed> $values All values to check, basically the $_POST array
410 * after sending the form
411 * @param array<string,int> $required Array of required fields
412 * @param array<string> $disabled Array of disabled fields
413 *
414 * @return true|array<string>
415 */
416 public function check(array $values, array $required, array $disabled): bool|array
417 {
418 global $preferences;
419 $this->errors = array();
420
421 $fields = array_keys($this->fields);
422 foreach ($fields as $key) {
423 //first, let's sanitize values
424 $key = strtolower($key);
425 $prop = '_' . $this->fields[$key]['propname'];
426
427 if (isset($values[$key])) {
428 $value = trim($values[$key]);
429 } else {
430 $value = '';
431 }
432
433 // if the field is enabled, check it
434 if (!isset($disabled[$key])) {
435 // fill up the adherent structure
436 //$this->$prop = stripslashes($value); //not relevant here!
437
438 // now, check validity
439 switch ($key) {
440 // dates
441 case 'date_enreg':
442 case 'date_debut_cotis':
443 case 'date_fin_cotis':
444 if ($value != '') {
445 $this->setDate($key, $value);
446 }
447 break;
448 case Adherent::PK:
449 if ($value != '') {
450 $this->_member = (int)$value;
451 }
452 break;
453 case ContributionsTypes::PK:
454 if ($value != '') {
455 $this->setContributionType((int)$value);
456 }
457 break;
458 case 'montant_cotis':
459 $value = strtr($value, ',', '.');
460 if (!empty($value) || $value === '0') {
461 $this->_amount = (double)$value;
462 }
463 if (!is_numeric($value) && $value !== '') {
464 $this->errors[] = _T("- The amount must be an integer!");
465 }
466 break;
467 case 'type_paiement_cotis':
468 $ptypes = new PaymentTypes(
469 $this->zdb,
470 $preferences,
471 $this->login
472 );
473 $ptlist = $ptypes->getList();
474 if (isset($ptlist[$value])) {
475 $this->_payment_type = (int)$value;
476 } else {
477 $this->errors[] = _T("- Unknown payment type");
478 }
479 break;
480 case 'info_cotis':
481 $this->_info = $value;
482 break;
483 case Transaction::PK:
484 if ($value != '') {
485 $this->_transaction = new Transaction($this->zdb, $this->login, (int)$value);
486 }
487 break;
488 case 'duree_mois_cotis':
489 if ($value != '') {
490 if (!is_numeric($value) || $value <= 0) {
491 $this->errors[] = _T("- The duration must be a positive integer!");
492 }
493 $this->$prop = $value;
494 $this->retrieveEndDate();
495 }
496 break;
497 }
498 }
499 }
500
501 // missing required fields?
502 foreach ($required as $key => $val) {
503 if ($val === 1) {
504 $prop = '_' . $this->fields[$key]['propname'];
505 if (
506 !isset($disabled[$key])
507 && (!isset($this->$prop)
508 || (!is_object($this->$prop) && trim($this->$prop) == '')
509 || (is_object($this->$prop) && trim($this->$prop->id) == ''))
510 ) {
511 $this->errors[] = str_replace(
512 '%field',
513 '<a href="#' . $key . '">' . $this->getFieldLabel($key) . '</a>',
514 _T("- Mandatory field %field empty.")
515 );
516 }
517 }
518 }
519
520 if ($this->_transaction != null && $this->_amount != null) {
521 $missing = $this->_transaction->getMissingAmount();
522 //calculate new missing amount
523 $missing = $missing + $this->_orig_amount - $this->_amount;
524 if ($missing < 0) {
525 $this->errors[] = _T("- Sum of all contributions exceed corresponding transaction amount.");
526 }
527 }
528
529 if ($this->isFee() && count($this->errors) == 0) {
530 $overlap = $this->checkOverlap();
531 if ($overlap !== true) {
532 //method directly return error message
533 $this->errors[] = $overlap;
534 }
535 }
536
537 $this->dynamicsCheck($values, $required, $disabled);
538
539 if (count($this->errors) > 0) {
540 Analog::log(
541 'Some errors has been threw attempting to edit/store a contribution' .
542 print_r($this->errors, true),
543 Analog::ERROR
544 );
545 return $this->errors;
546 } else {
547 Analog::log(
548 'Contribution checked successfully.',
549 Analog::DEBUG
550 );
551 return true;
552 }
553 }
554
555 /**
556 * Check that membership fees does not overlap
557 *
558 * @return boolean|string True if all is ok, false if error,
559 * error message if overlap
560 */
561 public function checkOverlap(): bool|string
562 {
563 try {
564 $select = $this->zdb->select(self::TABLE, 'c');
565 //@phpstan-ignore-next-line
566 $select->columns(
567 array('date_debut_cotis', 'date_fin_cotis')
568 )->join(
569 array('ct' => PREFIX_DB . ContributionsTypes::TABLE),
570 'c.' . ContributionsTypes::PK . '=ct.' . ContributionsTypes::PK,
571 array()
572 )->where([Adherent::PK => $this->_member])
573 ->where(array('cotis_extension' => new Expression('true')))
574 ->where->nest->nest
575 ->greaterThanOrEqualTo('date_debut_cotis', $this->_begin_date)
576 ->lessThanOrEqualTo('date_debut_cotis', $this->_end_date)
577 ->unnest
578 ->or->nest
579 ->greaterThanOrEqualTo('date_fin_cotis', $this->_begin_date)
580 ->lessThanOrEqualTo('date_fin_cotis', $this->_end_date);
581
582 if ($this->id != '') {
583 $select->where->notEqualTo(self::PK, $this->id);
584 }
585
586 $results = $this->zdb->execute($select);
587 if ($results->count() > 0) {
588 $result = $results->current();
589
590 $d_begin = new \DateTime($result->date_debut_cotis);
591 $d_end = new \DateTime($result->date_fin_cotis);
592
593 if ($d_begin->format('m-d') == $d_end->format('m-d') && $result->date_fin_cotis == $this->_begin_date) {
594 //see https://bugs.galette.eu/issues/1762
595 return true;
596 }
597
598 return _T("- Membership period overlaps period starting at ") .
599 $d_begin->format(__("Y-m-d"));
600 }
601 return true;
602 } catch (Throwable $e) {
603 Analog::log(
604 'An error occurred checking overlapping fee. ' . $e->getMessage(),
605 Analog::ERROR
606 );
607 throw $e;
608 }
609 }
610
611 /**
612 * Store the contribution
613 *
614 * @return boolean
615 */
616 public function store(): bool
617 {
618 global $hist, $emitter;
619
620 $event = null;
621
622 if (count($this->errors) > 0) {
623 throw new \RuntimeException(
624 'Existing errors prevents storing contribution: ' .
625 print_r($this->errors, true)
626 );
627 }
628
629 try {
630 $this->zdb->connection->beginTransaction();
631 $values = array();
632 $fields = self::getDbFields($this->zdb);
633 foreach ($fields as $field) {
634 $prop = '_' . $this->fields[$field]['propname'];
635 if (!isset($this->$prop)) {
636 continue;
637 }
638 switch ($field) {
639 case ContributionsTypes::PK:
640 case Transaction::PK:
641 $values[$field] = $this->$prop->id;
642 break;
643 default:
644 $values[$field] = $this->$prop;
645 break;
646 }
647 }
648
649 //no end date, let's take database defaults
650 if (!$this->isFee() && !$this->_end_date) {
651 unset($values['date_fin_cotis']);
652 }
653
654 if (!isset($this->_id) || $this->_id == '') {
655 //we're inserting a new contribution
656 unset($values[self::PK]);
657
658 $insert = $this->zdb->insert(self::TABLE);
659 $insert->values($values);
660 $add = $this->zdb->execute($insert);
661
662 if ($add->count() > 0) {
663 $this->_id = $this->zdb->getLastGeneratedValue($this);
664
665 // logging
666 $hist->add(
667 _T("Contribution added"),
668 Adherent::getSName($this->zdb, $this->_member)
669 );
670 $event = $this->getAddEventName();
671 } else {
672 $hist->add(_T("Fail to add new contribution."));
673 throw new \Exception(
674 'An error occurred inserting new contribution!'
675 );
676 }
677 } else {
678 //we're editing an existing contribution
679 $update = $this->zdb->update(self::TABLE);
680 $update->set($values)->where([self::PK => $this->_id]);
681 $edit = $this->zdb->execute($update);
682
683 //edit == 0 does not mean there were an error, but that there
684 //were nothing to change
685 if ($edit->count() > 0) {
686 $hist->add(
687 _T("Contribution updated"),
688 Adherent::getSName($this->zdb, $this->_member)
689 );
690 }
691
692 $event = $this->getEditEventName();
693 }
694 //update deadline
695 if ($this->isFee()) {
696 $this->updateDeadline();
697 }
698
699 //dynamic fields
700 $this->dynamicsStore(true);
701
702 $this->zdb->connection->commit();
703 $this->_orig_amount = $this->_amount;
704
705 //send event at the end of process, once all has been stored
706 if ($event !== null && $this->areEventsEnabled()) {
707 $emitter->dispatch(new GaletteEvent($event, $this));
708 }
709
710 return true;
711 } catch (Throwable $e) {
712 if ($this->zdb->connection->inTransaction()) {
713 $this->zdb->connection->rollBack();
714 }
715 throw $e;
716 }
717 }
718
719 /**
720 * Update member deadline
721 *
722 * @return boolean
723 */
724 private function updateDeadline(): bool
725 {
726 try {
727 $due_date = self::getDueDate($this->zdb, $this->_member);
728
729 if ($due_date != '') {
730 $due_date_update = $due_date;
731 } else {
732 $due_date_update = new Expression('NULL');
733 }
734
735 $update = $this->zdb->update(Adherent::TABLE);
736 $update->set(
737 array('date_echeance' => $due_date_update)
738 )->where(
739 [Adherent::PK => $this->_member]
740 );
741 $this->zdb->execute($update);
742 return true;
743 } catch (Throwable $e) {
744 Analog::log(
745 'An error occurred updating member ' . $this->_member .
746 '\'s deadline |' .
747 $e->getMessage(),
748 Analog::ERROR
749 );
750 throw $e;
751 }
752 }
753
754 /**
755 * Remove contribution from database
756 *
757 * @param boolean $transaction Activate transaction mode (defaults to true)
758 *
759 * @return boolean
760 */
761 public function remove(bool $transaction = true): bool
762 {
763 global $emitter;
764
765 try {
766 if ($transaction) {
767 $this->zdb->connection->beginTransaction();
768 }
769
770 $delete = $this->zdb->delete(self::TABLE);
771 $delete->where([self::PK => $this->_id]);
772 $del = $this->zdb->execute($delete);
773 if ($del->count() > 0) {
774 $this->updateDeadline();
775 $this->dynamicsRemove(true);
776 } else {
777 Analog::log(
778 'Contribution has not been removed!',
779 Analog::WARNING
780 );
781 return false;
782 }
783 if ($transaction) {
784 $this->zdb->connection->commit();
785 }
786 $emitter->dispatch(new GaletteEvent('contribution.remove', $this));
787 return true;
788 } catch (Throwable $e) {
789 if ($transaction) {
790 $this->zdb->connection->rollBack();
791 }
792 Analog::log(
793 'An error occurred trying to remove contribution #' .
794 $this->_id . ' | ' . $e->getMessage(),
795 Analog::ERROR
796 );
797 throw $e;
798 }
799 }
800
801 /**
802 * Get field label
803 *
804 * @param string $field Field name
805 * @param string $entry Array entry to use (defaults to "label")
806 *
807 * @return string
808 */
809 public function getFieldLabel(string $field, string $entry = 'label'): string
810 {
811 if ($field == 'date_debut_cotis' && !empty($this->_is_cotis) && $this->isFee()) {
812 $entry = 'cotlabel';
813 }
814 return $this->trait_getFieldLabel($field, $entry);
815 }
816
817 /**
818 * Retrieve fields from database
819 *
820 * @param Db $zdb Database instance
821 *
822 * @return array<string>
823 */
824 public static function getDbFields(Db $zdb): array
825 {
826 $columns = $zdb->getColumns(self::TABLE);
827 $fields = array();
828 foreach ($columns as $col) {
829 $fields[] = $col->getName();
830 }
831 return $fields;
832 }
833
834 /**
835 * Get the relevant CSS class for current contribution
836 *
837 * @return string current contribution row class
838 */
839 public function getRowClass(): string
840 {
841 return ($this->_end_date != $this->_begin_date && $this->_is_cotis) ?
842 'cotis-normal' : 'cotis-give';
843 }
844
845 /**
846 * Retrieve member due date
847 *
848 * @param Db $zdb Database instance
849 * @param ?integer $member_id Member identifier
850 *
851 * @return string|null
852 */
853 public static function getDueDate(Db $zdb, ?int $member_id): ?string
854 {
855 if (!$member_id) {
856 return '';
857 }
858 try {
859 $select = $zdb->select(self::TABLE, 'c');
860 $select->columns(
861 array(
862 'max_date' => new Expression('MAX(date_fin_cotis)')
863 )
864 )->join(
865 array('ct' => PREFIX_DB . ContributionsTypes::TABLE),
866 'c.' . ContributionsTypes::PK . '=ct.' . ContributionsTypes::PK,
867 array()
868 )->where(
869 [Adherent::PK => $member_id]
870 )->where(
871 array('cotis_extension' => new Expression('true'))
872 );
873
874 $results = $zdb->execute($select);
875 $result = $results->current();
876 $due_date = $result->max_date;
877
878 //avoid bad dates in postgres and bad mysql return from zenddb
879 if ($due_date == '0001-01-01 BC' || $due_date == '1901-01-01') {
880 $due_date = '';
881 }
882 return $due_date;
883 } catch (Throwable $e) {
884 Analog::log(
885 'An error occurred trying to retrieve member\'s due date',
886 Analog::ERROR
887 );
888 throw $e;
889 }
890 }
891
892 /**
893 * Detach a contribution from a transaction
894 *
895 * @param Db $zdb Database instance
896 * @param Login $login Login instance
897 * @param int $trans_id Transaction identifier
898 * @param int $contrib_id Contribution identifier
899 *
900 * @return boolean
901 */
902 public static function unsetTransactionPart(Db $zdb, Login $login, int $trans_id, int $contrib_id): bool
903 {
904 try {
905 //first, we check if contribution is part of transaction
906 $c = new Contribution($zdb, $login, (int)$contrib_id);
907 if ($c->isTransactionPartOf($trans_id)) {
908 $update = $zdb->update(self::TABLE);
909 $update->set(
910 array(Transaction::PK => null)
911 )->where(
912 [self::PK => $contrib_id]
913 );
914 $zdb->execute($update);
915 return true;
916 } else {
917 Analog::log(
918 'Contribution #' . $contrib_id .
919 ' is not actually part of transaction #' . $trans_id,
920 Analog::WARNING
921 );
922 return false;
923 }
924 } catch (Throwable $e) {
925 Analog::log(
926 'Unable to detach contribution #' . $contrib_id .
927 ' to transaction #' . $trans_id . ' | ' . $e->getMessage(),
928 Analog::ERROR
929 );
930 throw $e;
931 }
932 }
933
934 /**
935 * Set a contribution as a transaction part
936 *
937 * @param Db $zdb Database instance
938 * @param int $trans_id Transaction identifier
939 * @param int $contrib_id Contribution identifier
940 *
941 * @return boolean
942 */
943 public static function setTransactionPart(Db $zdb, int $trans_id, int $contrib_id): bool
944 {
945 try {
946 $update = $zdb->update(self::TABLE);
947 $update->set(
948 array(Transaction::PK => $trans_id)
949 )->where([self::PK => $contrib_id]);
950
951 $zdb->execute($update);
952 return true;
953 } catch (Throwable $e) {
954 Analog::log(
955 'Unable to attach contribution #' . $contrib_id .
956 ' to transaction #' . $trans_id . ' | ' . $e->getMessage(),
957 Analog::ERROR
958 );
959 throw $e;
960 }
961 }
962
963 /**
964 * Is current contribution a membership fee
965 *
966 * @return boolean
967 */
968 public function isFee(): bool
969 {
970 return $this->_is_cotis;
971 }
972
973 /**
974 * Is current contribution part of specified transaction
975 *
976 * @param int $id Transaction identifier
977 *
978 * @return boolean
979 */
980 public function isTransactionPartOf(int $id): bool
981 {
982 if ($this->isTransactionPart()) {
983 return $id == $this->_transaction->id;
984 } else {
985 return false;
986 }
987 }
988
989 /**
990 * Is current contribution part of transaction
991 *
992 * @return boolean
993 */
994 public function isTransactionPart(): bool
995 {
996 return $this->_transaction != null;
997 }
998
999 /**
1000 * Execute post contribution script
1001 *
1002 * @param ExternalScript $es External script to execute
1003 * @param ?array<string,mixed> $extra Extra information on contribution
1004 * Defaults to null
1005 * @param ?array<string,mixed> $pextra Extra information on payment
1006 * Defaults to null
1007 *
1008 * @return string|bool Script return value on success, values and script output on fail
1009 */
1010 public function executePostScript(
1011 ExternalScript $es,
1012 array $extra = null,
1013 array $pextra = null
1014 ): string|bool {
1015 global $preferences;
1016
1017 $payment = array(
1018 'type' => $this->getPaymentType()
1019 );
1020
1021 if ($pextra !== null) {
1022 $payment = array_merge($payment, $pextra);
1023 }
1024
1025 if (!file_exists(GALETTE_CACHE_DIR . '/pdf_contribs')) {
1026 @mkdir(GALETTE_CACHE_DIR . '/pdf_contribs');
1027 }
1028
1029 $voucher_path = null;
1030 if (isset($this->_id)) {
1031 $voucher = new PdfContribution($this, $this->zdb, $preferences);
1032 $voucher->store(GALETTE_CACHE_DIR . '/pdf_contribs');
1033 $voucher_path = $voucher->getPath();
1034 }
1035
1036 $contrib = array(
1037 'id' => $this->_id,
1038 'date' => $this->_date,
1039 'type' => $this->getRawType(),
1040 'amount' => $this->amount,
1041 'voucher' => $voucher_path,
1042 'category' => array(
1043 'id' => $this->type->id,
1044 'label' => $this->type->libelle
1045 ),
1046 'payment' => $payment
1047 );
1048
1049 if ($this->_member !== null) {
1050 $m = new Adherent($this->zdb, (int)$this->_member);
1051 $member = array(
1052 'id' => (int)$this->_member,
1053 'name' => $m->sfullname,
1054 'email' => $m->email,
1055 'organization' => ($m->isCompany() ? 1 : 0),
1056 'status' => array(
1057 'id' => $m->status,
1058 'label' => $m->sstatus
1059 ),
1060 'country' => $m->country
1061 );
1062
1063 if ($m->isCompany()) {
1064 $member['organization_name'] = $m->company_name;
1065 }
1066
1067 $contrib['member'] = $member;
1068 }
1069
1070 if ($extra !== null) {
1071 $contrib = array_merge($contrib, $extra);
1072 }
1073
1074 $res = $es->send($contrib);
1075
1076 if ($res !== true) {
1077 Analog::log(
1078 'An error occurred calling post contribution ' .
1079 "script:\n" . $es->getOutput(),
1080 Analog::ERROR
1081 );
1082 $res = _T("Contribution information") . "\n";
1083 $res .= print_r($contrib, true);
1084 $res .= "\n\n" . _T("Script output") . "\n";
1085 $res .= $es->getOutput();
1086 }
1087
1088 return $res;
1089 }
1090 /**
1091 * Get raw contribution type
1092 *
1093 * @return string
1094 */
1095 public function getRawType(): string
1096 {
1097 if ($this->isFee()) {
1098 return 'membership';
1099 } else {
1100 return 'donation';
1101 }
1102 }
1103
1104 /**
1105 * Get contribution type label
1106 *
1107 * @return string
1108 */
1109 public function getTypeLabel(): string
1110 {
1111 if ($this->isFee()) {
1112 return _T("Membership");
1113 } else {
1114 return _T("Donation");
1115 }
1116 }
1117
1118 /**
1119 * Get payment type label
1120 *
1121 * @return string
1122 */
1123 public function getPaymentType(): string
1124 {
1125 if ($this->_payment_type === null) {
1126 return '-';
1127 }
1128
1129 $ptype = new PaymentType($this->zdb, $this->payment_type);
1130 return $ptype->getName();
1131 }
1132
1133 /**
1134 * Global getter method
1135 *
1136 * @param string $name name of the property we want to retrieve
1137 *
1138 * @return mixed the called property
1139 */
1140 public function __get(string $name)
1141 {
1142 $rname = '_' . $name;
1143
1144 if (in_array($name, $this->forbidden_fields)) {
1145 Analog::log(
1146 "Call to __get for '$name' is forbidden!",
1147 Analog::WARNING
1148 );
1149
1150 switch ($name) {
1151 case 'is_cotis':
1152 return $this->isFee();
1153 default:
1154 throw new \RuntimeException("Call to __get for '$name' is forbidden!");
1155 }
1156 } elseif (
1157 property_exists($this, $rname)
1158 || property_exists($this, $name)
1159 || in_array($name, $this->virtual_fields)
1160 ) {
1161 switch ($name) {
1162 case 'raw_date':
1163 case 'raw_begin_date':
1164 case 'raw_end_date':
1165 $rname = '_' . substr($name, 4);
1166 return $this->getDate($rname, false);
1167 case 'date':
1168 case 'begin_date':
1169 case 'end_date':
1170 return $this->getDate($rname);
1171 case 'duration':
1172 if (isset($this->_is_cotis)) {
1173 // Caution : the end_date stored is actually the due date.
1174 // Adding a day to compute the next_begin_date is required
1175 // to return the right number of months.
1176 $next_begin_date = new \DateTime($this->_end_date ?? $this->_begin_date);
1177 $next_begin_date->add(new \DateInterval('P1D'));
1178 $begin_date = new \DateTime($this->_begin_date);
1179 $diff = $next_begin_date->diff($begin_date);
1180 return (int)$diff->format('%y') * 12 + (int)$diff->format('%m');
1181 } else {
1182 return '';
1183 }
1184 case 'model':
1185 if (!isset($this->_is_cotis)) {
1186 return null;
1187 }
1188 return ($this->isFee()) ?
1189 PdfModel::INVOICE_MODEL : PdfModel::RECEIPT_MODEL;
1190 case 'fields':
1191 return $this->fields;
1192 default:
1193 if (property_exists($this, $rname)) {
1194 if (isset($this->$rname)) {
1195 return $this->$rname;
1196 }
1197 } else {
1198 throw new \LogicException("Property '" . __CLASS__ . "::$rname' does not exist!");
1199 }
1200 }
1201 } else {
1202 Analog::log(
1203 "Unknown property '$rname'",
1204 Analog::WARNING
1205 );
1206 return null;
1207 }
1208 }
1209
1210 /**
1211 * Global isset method
1212 * Required for twig to access properties via __get
1213 *
1214 * @param string $name name of the property we want to retrieve
1215 *
1216 * @return bool
1217 */
1218 public function __isset(string $name): bool
1219 {
1220 return $this->trait___isset('_' . $name) || $this->trait___isset($name);
1221 }
1222
1223
1224 /**
1225 * Global setter method
1226 *
1227 * @param string $name name of the property we want to assign a value to
1228 * @param mixed $value a relevant value for the property
1229 *
1230 * @return void
1231 */
1232 public function __set(string $name, $value): void
1233 {
1234 global $preferences;
1235
1236 $forbidden = array('fields', 'is_cotis', 'end_date');
1237
1238 if (!in_array($name, $forbidden)) {
1239 $rname = '_' . $name;
1240 switch ($name) {
1241 case 'transaction':
1242 if (is_int($value)) {
1243 $this->$rname = new Transaction($this->zdb, $this->login, $value);
1244 } else {
1245 Analog::log(
1246 'Trying to set a transaction from an id that is not an integer.',
1247 Analog::WARNING
1248 );
1249 }
1250 break;
1251 case 'type':
1252 $this->setContributionType($value);
1253 break;
1254 case 'begin_date':
1255 $this->setDate($rname, $value);
1256 break;
1257 case 'amount':
1258 if (is_numeric($value) && $value > 0) {
1259 $this->$rname = $value;
1260 } else {
1261 Analog::log(
1262 'Trying to set an amount with a non numeric value, ' .
1263 'or with a zero value',
1264 Analog::WARNING
1265 );
1266 }
1267 break;
1268 case 'member':
1269 if (is_int($value)) {
1270 //set type
1271 $this->$rname = $value;
1272 }
1273 break;
1274 case 'payment_type':
1275 $ptypes = new PaymentTypes(
1276 $this->zdb,
1277 $preferences,
1278 $this->login
1279 );
1280 $list = $ptypes->getList();
1281 if (isset($list[$value])) {
1282 $this->_payment_type = $value;
1283 } else {
1284 Analog::log(
1285 'Unknown payment type ' . $value,
1286 Analog::WARNING
1287 );
1288 }
1289 break;
1290 default:
1291 Analog::log(
1292 '[' . __CLASS__ . ']: Trying to set an unknown property (' .
1293 $name . ')',
1294 Analog::WARNING
1295 );
1296 break;
1297 }
1298 }
1299 }
1300
1301 /**
1302 * Flag creation mail sending
1303 *
1304 * @param boolean $send True (default) to send creation email
1305 *
1306 * @return self
1307 */
1308 public function setSendmail(bool $send = true): self
1309 {
1310 $this->sendmail = $send;
1311 return $this;
1312 }
1313
1314 /**
1315 * Should we send administrative emails to member?
1316 *
1317 * @return boolean
1318 */
1319 public function sendEMail(): bool
1320 {
1321 return $this->sendmail;
1322 }
1323
1324 /**
1325 * Handle files (dynamics files)
1326 *
1327 * @param array<string, mixed> $files Files sent
1328 *
1329 * @return array<string>|true
1330 */
1331 public function handleFiles(array $files): bool|array
1332 {
1333 $this->errors = [];
1334
1335 $this->dynamicsFiles($files);
1336
1337 if (count($this->errors) > 0) {
1338 Analog::log(
1339 'Some errors has been threw attempting to edit/store a contribution files' . "\n" .
1340 print_r($this->errors, true),
1341 Analog::ERROR
1342 );
1343 return $this->errors;
1344 } else {
1345 return true;
1346 }
1347 }
1348
1349 /**
1350 * Get required fields list
1351 *
1352 * @return array<string, int>
1353 */
1354 public function getRequired(): array
1355 {
1356 return [
1357 'id_type_cotis' => 1,
1358 'id_adh' => 1,
1359 'date_enreg' => 1,
1360 'date_debut_cotis' => 1,
1361 'date_fin_cotis' => $this->isFee() ? 1 : 0,
1362 'montant_cotis' => $this->isFee() ? 1 : 0
1363 ];
1364 }
1365
1366 /**
1367 * Can current logged-in user display contribution
1368 *
1369 * @param Login $login Login instance
1370 *
1371 * @return boolean
1372 */
1373 public function canShow(Login $login): bool
1374 {
1375 //non-logged-in members cannot show contributions
1376 if (!$login->isLogged()) {
1377 return false;
1378 }
1379
1380 //admin and staff users can edit, as well as member itself
1381 if (!$this->id || $login->id == $this->_member || $login->isAdmin() || $login->isStaff()) {
1382 return true;
1383 }
1384
1385 //parent can see their children contributions
1386 $parent = new Adherent($this->zdb);
1387 $parent
1388 ->disableAllDeps()
1389 ->enableDep('children')
1390 ->load($this->login->id);
1391 if ($parent->hasChildren()) {
1392 foreach ($parent->children as $child) {
1393 if ($child->id === $this->_member) {
1394 return true;
1395 }
1396 }
1397 return false;
1398 }
1399
1400 return false;
1401 }
1402
1403 /**
1404 * Set contribution type and determine if it is a contribution or a donation
1405 *
1406 * @param int|null $type Type
1407 *
1408 * @return self
1409 */
1410 public function setContributionType(?int $type): self
1411 {
1412 if (is_int($type)) {
1413 //set type
1414 $this->_type = new ContributionsTypes($this->zdb, $type);
1415 //set is_cotis according to type
1416 if ($this->_type->extension == 1) {
1417 $this->_is_cotis = true;
1418 } else {
1419 $this->_is_cotis = false;
1420 }
1421 } else {
1422 Analog::log(
1423 'Trying to set a type from an id that is not an integer.',
1424 Analog::WARNING
1425 );
1426 }
1427
1428 return $this;
1429 }
1430
1431 /**
1432 * Get prefix for events
1433 *
1434 * @return string
1435 */
1436 protected function getEventsPrefix(): string
1437 {
1438 return 'contribution';
1439 }
1440 }