]> git.agnieray.net Git - galette.git/commitdiff
Add scheduled payments feature
authorJohan Cwiklinski <johan@x-tnd.be>
Wed, 20 Mar 2024 16:02:53 +0000 (17:02 +0100)
committerJohan Cwiklinski <johan@x-tnd.be>
Sat, 13 Apr 2024 07:20:45 +0000 (09:20 +0200)
closes #1193

34 files changed:
galette/docs/CHANGES
galette/includes/core_acls.php
galette/includes/routes/contributions.routes.php
galette/install/scripts/mysql.sql
galette/install/scripts/pgsql.sql
galette/install/scripts/sql/upgrade-to-1.10-mysql.sql
galette/install/scripts/sql/upgrade-to-1.10-pgsql.sql
galette/lib/Galette/Controllers/Crud/ContributionsController.php
galette/lib/Galette/Controllers/Crud/ScheduledPaymentController.php [new file with mode: 0644]
galette/lib/Galette/Controllers/CsvController.php
galette/lib/Galette/Controllers/GaletteController.php
galette/lib/Galette/Core/Galette.php
galette/lib/Galette/Entity/Contribution.php
galette/lib/Galette/Entity/PaymentType.php
galette/lib/Galette/Entity/ScheduledPayment.php [new file with mode: 0644]
galette/lib/Galette/Features/EntityHelper.php
galette/lib/Galette/Filters/ScheduledPaymentsList.php [new file with mode: 0644]
galette/lib/Galette/IO/ScheduledPaymentsCsv.php [new file with mode: 0644]
galette/lib/Galette/Repository/Contributions.php
galette/lib/Galette/Repository/Members.php
galette/lib/Galette/Repository/PaymentTypes.php
galette/lib/Galette/Repository/ScheduledPayments.php [new file with mode: 0644]
galette/templates/default/components/forms/payment_types.html.twig
galette/templates/default/elements/list.html.twig
galette/templates/default/pages/contribution_form.html.twig
galette/templates/default/pages/contributions_list.html.twig
galette/templates/default/pages/scheduledpayment_form.html.twig [new file with mode: 0644]
galette/templates/default/pages/scheduledpayments_list.html.twig [new file with mode: 0644]
galette/templates/default/pages/transaction_form.html.twig
tests/Galette/Core/tests/units/Db.php
tests/Galette/Entity/tests/units/ScheduledPayment.php [new file with mode: 0644]
tests/Galette/Repository/tests/units/PaymentTypes.php
tests/Galette/Repository/tests/units/ScheduledPayments.php [new file with mode: 0644]
tests/GaletteTestCase.php

index 462cc538b3b2bdde705f295a1e4f3f3306072919..9dcb4c7b500a464c92fce33c26daa754b1523877 100644 (file)
@@ -24,6 +24,8 @@ Changes
 - Fix color for staff members on member cards
 - Display first staff members on public lists
 - Identify sponsors in members list
+- Add scheduled payments feature
+- Dispatch contribution into scheduled payments
 
 1.0.3 -> 1.0.4
 
index 4a2b3d3dab11524c6a92f8047538b90b70868bc3..991d6ad6756a54d02d2386b015e1ca8deb953a5c 100644 (file)
@@ -86,5 +86,7 @@ $core_acls = [
     'pdfModels'                         => 'staff',
     'attendance_sheet_details'          => 'groupmanager',
     'attendance_sheet'                  => 'groupmanager',
-    '/(.+)?document(.+)?/i'             => 'staff'
+    '/(.+)?document(.+)?/i'             => 'staff',
+    'myScheduledPayments'               => 'member',
+    '/(.+)?scheduledPayment(.+)?/i'      => 'staff'
 ];
index 6754ede630cdb03b1c2850f4a5ccbf1a60b51a1c..e79bd921dc91010b34232a1f4bed9cff2c510f13 100644 (file)
@@ -148,3 +148,66 @@ $app->post(
     '/contribution/do-mass-add',
     [Crud\ContributionsController::class, 'doMassAddContributions']
 )->setName('doMassAddContributions')->add($authenticate);
+
+$app->get(
+    '/scheduled-payments/mine',
+    [Crud\ScheduledPaymentController::class, 'myList']
+)->setName('myScheduledPayments')->add($authenticate);
+
+$app->get(
+    '/scheduled-payments[/{option:page|order|member}/{value:\d+|all}]',
+    [Crud\ScheduledPaymentController::class, 'list']
+)->setName('scheduledPayments')->add($authenticate);
+
+$app->post(
+    '/scheduled-payments/filter',
+    [Crud\ScheduledPaymentController::class, 'filter']
+)->setName('filterScheduledPayments')->add($authenticate);
+
+$app->get(
+    '/scheduled-payment/{id_cotis:\d+}/add',
+    [Crud\ScheduledPaymentController::class, 'add']
+)->setName('addScheduledPayment')->add($authenticate);
+
+$app->get(
+    '/scheduled-payment/edit/{id:\d+}',
+    [Crud\ScheduledPaymentController::class, 'edit']
+)->setName('editScheduledPayment')->add($authenticate);
+
+$app->post(
+    '/scheduled-payments/{id_cotis:\d+}/add',
+    [Crud\ScheduledPaymentController::class, 'doAdd']
+)->setName('doAddScheduledPayment')->add($authenticate);
+
+$app->post(
+    '/scheduled-payments/edit/{id:\d+}',
+    [Crud\ScheduledPaymentController::class, 'doEdit']
+)->setName('doEditScheduledPayment')->add($authenticate);
+
+//Batch actions on scheduled payments list
+$app->post(
+    '/scheduled-payments/batch',
+    [Crud\ScheduledPaymentController::class, 'handleBatch']
+)->setName('batch-scheduledPaymentslist')->add($authenticate);
+
+//scheduled payments list CSV export
+$app->map(
+    ['GET', 'POST'],
+    '/scheduled-payments/export/csv',
+    [CsvController::class, 'scheduledPaymentsExport']
+)->setName('csv-scheduledPaymentslist')->add($authenticate);
+
+$app->get(
+    '/scheduled-payment/remove' . '/{id:\d+}',
+    [Crud\ScheduledPaymentController::class, 'confirmDelete']
+)->setName('removeScheduledPayment')->add($authenticate);
+
+$app->get(
+    '/scheduled-payment/batch/remove',
+    [Crud\ScheduledPaymentController::class, 'confirmDelete']
+)->setName('removeScheduledPayments')->add($authenticate);
+
+$app->post(
+    '/scheduled-payment/remove[/{id}]',
+    [Crud\ScheduledPaymentController::class, 'delete']
+)->setName('doRemoveScheduledPayment')->add($authenticate);
index 04f40921a99bd56bc86d09eed55b1a2fb95a9101..8e516b4e06120d8acf576d04711d3ec74f167141 100644 (file)
@@ -367,6 +367,22 @@ CREATE TABLE galette_documents (
   KEY (type)
 ) ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci;
 
+-- table for payments schedules
+DROP TABLE IF EXISTS galette_payments_schedules;
+CREATE TABLE galette_payments_schedules (
+  id_schedule int(10) unsigned NOT NULL auto_increment,
+  id_cotis int(10) unsigned NOT NULL,
+  id_paymenttype int(10) unsigned NOT NULL,
+  creation_date datetime NOT NULL,
+  scheduled_date datetime NOT NULL,
+  amount decimal(15, 2) NOT NULL,
+  paid tinyint(1) DEFAULT FALSE,
+  comment text,
+  PRIMARY KEY (id_schedule),
+  FOREIGN KEY (id_cotis) REFERENCES galette_cotisations (id_cotis) ON DELETE CASCADE ON UPDATE CASCADE,
+  FOREIGN KEY (id_paymenttype) REFERENCES galette_paymenttypes (type_id) ON DELETE CASCADE ON UPDATE CASCADE
+);
+
 -- table for database version
 DROP TABLE IF EXISTS galette_database;
 CREATE TABLE galette_database (
index 28646e8562232325ba708e400e388664673b5c55..5e192e35f9d650f4f6f2fe1c1432dcf4f25257b2 100644 (file)
@@ -174,6 +174,15 @@ CREATE SEQUENCE galette_documents_id_seq
     MINVALUE 1
     CACHE 1;
 
+-- sequence for payments schedules
+DROP SEQUENCE IF EXISTS galette_payments_schedules_id_seq;
+CREATE SEQUENCE galette_payments_schedules_id_seq
+    START 1
+    INCREMENT 1
+    MAXVALUE 2147483647
+    MINVALUE 1
+    CACHE 1;
+
 -- Schema
 -- REMINDER: Create order IS important, dependencies first !!
 DROP TABLE IF EXISTS galette_paymenttypes CASCADE;
@@ -522,6 +531,20 @@ CREATE TABLE galette_documents (
 -- add index on table to look for type
 CREATE INDEX galette_documents_idx ON galette_documents (type);
 
+-- table for payments schedules
+DROP TABLE IF EXISTS galette_payments_schedules CASCADE;
+CREATE TABLE galette_payments_schedules (
+  id_schedule integer DEFAULT nextval('galette_payments_schedules_id_seq'::text) NOT NULL,
+  id_cotis integer REFERENCES galette_cotisations (id_cotis) ON DELETE CASCADE ON UPDATE CASCADE,
+  id_paymenttype integer REFERENCES galette_paymenttypes (type_id) ON DELETE RESTRICT ON UPDATE CASCADE,
+  creation_date date NOT NULL,
+  scheduled_date date NOT NULL,
+  amount decimal(15,2) NOT NULL,
+  paid boolean DEFAULT FALSE,
+  comment text,
+  PRIMARY KEY (id_schedule)
+);
+
 -- table for database version
 DROP TABLE IF EXISTS galette_database CASCADE;
 CREATE TABLE galette_database (
index 3112cda18ecc11bf1e48b25d7e258eca74caee55..3a0b7abd0d703a83dd4707bca271b2b7b4bcc542 100644 (file)
@@ -62,6 +62,26 @@ CREATE TABLE galette_documents (
 ) ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci;
 
 
+-- change fields types and default values
+ALTER TABLE galette_cotisations CHANGE montant_cotis montant_cotis decimal(15,2) NOT NULL;
+ALTER TABLE galette_transactions CHANGE trans_amount trans_amount decimal(15,2) NOT NULL;
+
+-- table for payments schedules
+DROP TABLE IF EXISTS galette_payments_schedules;
+CREATE TABLE galette_payments_schedules (
+  id_schedule int(10) unsigned NOT NULL auto_increment,
+  id_cotis int(10) unsigned NOT NULL,
+  id_paymenttype int(10) unsigned NOT NULL,
+  creation_date datetime NOT NULL,
+  scheduled_date datetime NOT NULL,
+  amount decimal(15,2) NOT NULL,
+  paid tinyint(1) DEFAULT FALSE,
+  comment text,
+  PRIMARY KEY (id_schedule),
+  FOREIGN KEY (id_cotis) REFERENCES galette_cotisations (id_cotis) ON DELETE CASCADE ON UPDATE CASCADE,
+  FOREIGN KEY (id_paymenttype) REFERENCES galette_paymenttypes (type_id) ON DELETE CASCADE ON UPDATE CASCADE
+);
+
 -- change fields types and default values
 ALTER TABLE galette_cotisations CHANGE montant_cotis montant_cotis decimal(15,2) NOT NULL;
 ALTER TABLE galette_transactions CHANGE trans_amount trans_amount decimal(15,2) NOT NULL;
index c4cbe2faaee62007a70eaa2d07262114b60a2d8a..df6bcd8b7165cdacd5017b777ea2dd2288f523b2 100644 (file)
@@ -40,6 +40,36 @@ CREATE TABLE galette_documents (
 -- add index on table to look for type
 CREATE INDEX galette_documents_idx ON galette_documents (type);
 
+-- change fields types and default values
+ALTER TABLE galette_cotisations ALTER COLUMN montant_cotis TYPE decimal(15,2);
+ALTER TABLE galette_cotisations ALTER COLUMN montant_cotis DROP DEFAULT;
+ALTER TABLE galette_cotisations ALTER COLUMN montant_cotis SET NOT NULL;
+ALTER TABLE galette_transactions ALTER COLUMN trans_amount TYPE decimal(15,2);
+ALTER TABLE galette_transactions ALTER COLUMN trans_amount DROP DEFAULT;
+ALTER TABLE galette_transactions ALTER COLUMN trans_amount SET NOT NULL;
+
+-- sequence for payments schedules
+DROP SEQUENCE IF EXISTS galette_payments_schedules_id_seq;
+CREATE SEQUENCE galette_payments_schedules_id_seq
+    START 1
+    INCREMENT 1
+    MAXVALUE 2147483647
+    MINVALUE 1
+    CACHE 1;
+
+-- table for payments schedules
+DROP TABLE IF EXISTS galette_payments_schedules CASCADE;
+CREATE TABLE galette_payments_schedules (
+  id_schedule integer DEFAULT nextval('galette_payments_schedules_id_seq'::text) NOT NULL,
+  id_cotis integer REFERENCES galette_cotisations (id_cotis) ON DELETE CASCADE ON UPDATE CASCADE,
+  id_paymenttype integer REFERENCES galette_paymenttypes (type_id) ON DELETE RESTRICT ON UPDATE CASCADE,
+  creation_date date NOT NULL,
+  scheduled_date date NOT NULL,
+  amount decimal(15,2) NOT NULL,
+  paid boolean DEFAULT FALSE,
+  comment text,
+  PRIMARY KEY (id_schedule)
+);
 -- change fields types and default values
 ALTER TABLE galette_cotisations ALTER COLUMN montant_cotis TYPE decimal(15,2);
 ALTER TABLE galette_cotisations ALTER COLUMN montant_cotis DROP DEFAULT;
index 9a07ccb855cd4628ffe0ccf487ffbde3a956b5ab..52478b80953e2bdd5bb2368a68686e59a9622147 100644 (file)
@@ -21,6 +21,8 @@
 
 namespace Galette\Controllers\Crud;
 
+use Galette\Entity\PaymentType;
+use Galette\Entity\ScheduledPayment;
 use Galette\Features\BatchList;
 use Analog\Analog;
 use Galette\Controllers\CrudController;
@@ -107,6 +109,10 @@ class ContributionsController extends CrudController
         // contribution types
         $params['type_cotis_options'] = $contributions_types;
 
+        if ($contrib->id != '') {
+            $params['scheduled'] = new ScheduledPayment($this->zdb, $contrib->id);
+        }
+
         // members
         $m = new Members();
         $members = $m->getDropdownMembers(
@@ -853,14 +859,22 @@ class ContributionsController extends CrudController
         if (count($error_detected) == 0) {
             $this->session->contribution = null;
             if ($contrib->isTransactionPart() && $contrib->transaction->getMissingAmount() > 0) {
-                //new contribution
+                //if part of a transaction, and transaction is not fully allocated, create a new contribution
                 $redirect_url = $this->routeparser->urlFor(
                     'addContribution',
                     [
-                        'type'      => $post['contrib_type'] ?? $type
+                        'type' => $post['contrib_type'] ?? $type
                     ]
                 ) . '?' . Transaction::PK . '=' . $contrib->transaction->id .
                 '&' . Adherent::PK . '=' . $contrib->member;
+            } elseif ($contrib->payment_type === PaymentType::SCHEDULED/* && !$contrib->isScheduleFullyAllocated() */) {
+                //if payment type is a payment schedule, and schedule is not fully allocated, create a new schedule entry
+                $redirect_url = $this->routeparser->urlFor(
+                    'addScheduledPayment',
+                    [
+                        Contribution::PK => $contrib->id
+                    ]
+                );
             } else {
                 //contributions list for member
                 $redirect_url = $this->routeparser->urlFor(
diff --git a/galette/lib/Galette/Controllers/Crud/ScheduledPaymentController.php b/galette/lib/Galette/Controllers/Crud/ScheduledPaymentController.php
new file mode 100644 (file)
index 0000000..20b7bcf
--- /dev/null
@@ -0,0 +1,538 @@
+<?php
+
+/**
+ * Copyright © 2003-2024 The Galette Team
+ *
+ * This file is part of Galette (https://galette.eu).
+ *
+ * Galette is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Galette is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ *  GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Galette. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+namespace Galette\Controllers\Crud;
+
+use Galette\Controllers\CrudController;
+use Galette\Entity\Adherent;
+use Galette\Entity\Contribution;
+use Galette\Entity\ScheduledPayment;
+use Galette\Filters\ScheduledPaymentsList;
+use Galette\Repository\ScheduledPayments;
+use Slim\Psr7\Request;
+use Slim\Psr7\Response;
+use Galette\Repository\PaymentTypes;
+
+/**
+ * Galette payment types controller
+ *
+ * @author Johan Cwiklinski <johan@x-tnd.be>
+ */
+
+class ScheduledPaymentController extends CrudController
+{
+    // CRUD - Create
+
+    /**
+     * Add page
+     *
+     * @param Request  $request  PSR Request
+     * @param Response $response PSR Response
+     * @param integer  $id_cotis Contribution id
+     *
+     * @return Response
+     */
+    public function add(Request $request, Response $response, int $id_cotis = 0): Response
+    {
+        if (isset($this->session->scheduled_payment)) {
+            $scheduled = $this->session->scheduled_payment;
+            unset($this->session->scheduled_payment);
+        } else {
+            $scheduled = new ScheduledPayment($this->zdb);
+        }
+        $scheduled->setContribution($id_cotis);
+        $mode = $request->getHeaderLine('X-Requested-With') === 'XMLHttpRequest' ? 'ajax' : '';
+
+        if ($scheduled->getMissingAmount() == 0) {
+            $this->flash->addMessage(
+                'error_detected',
+                _T("Contribution is fully scheduled!")
+            );
+            return $response
+                ->withStatus(301)
+                ->withHeader(
+                    'Location',
+                    $this->routeparser->urlFor(
+                        'editContribution',
+                        [
+                            'type' => ($scheduled->getContribution()->isFee() ? Contribution::TYPE_FEE : Contribution::TYPE_DONATION),
+                            'id' => $id_cotis
+                        ]
+                    )
+                );
+        }
+
+        // display page
+        $this->view->render(
+            $response,
+            'pages/scheduledpayment_form.html.twig',
+            [
+                'page_title'    => _T("Add scheduled payment"),
+                'scheduled'     => $scheduled,
+                'mode'          => $mode
+            ]
+        );
+        return $response;
+    }
+
+    /**
+     * Add action
+     *
+     * @param Request  $request  PSR Request
+     * @param Response $response PSR Response
+     *
+     * @return Response
+     */
+    public function doAdd(Request $request, Response $response): Response
+    {
+        return $this->store($request, $response, null);
+    }
+
+    // /CRUD - Create
+    // CRUD - Read
+
+    /**
+     * List page
+     *
+     * @param Request             $request  PSR Request
+     * @param Response            $response PSR Response
+     * @param string|null         $option   One of 'page' or 'order'
+     * @param integer|string|null $value    Value of the option
+     *
+     * @return Response
+     */
+    public function list(Request $request, Response $response, string $option = null, int|string $value = null): Response
+    {
+        $get = $request->getQueryParams();
+        $ajax = false;
+        $session_varname = $this->getFilterName();
+
+        if (
+            ($request->getHeaderLine('X-Requested-With') === 'XMLHttpRequest')
+            || isset($get['ajax'])
+            && $get['ajax'] == 'true'
+        ) {
+            $ajax = true;
+            $session_varname = 'ajax_' . $session_varname;
+        }
+
+        if (isset($this->session->$session_varname)) {
+            $filters = $this->session->$session_varname;
+        } else {
+            $filters = new ScheduledPaymentsList();
+        }
+
+        if ($ajax && $get[Contribution::PK]) {
+            $filters->from_contribution = (int)$get[Contribution::PK];
+        }
+
+        if ($option !== null) {
+            switch ($option) {
+                case 'page':
+                    $filters->current_page = (int)$value;
+                    break;
+                case 'order':
+                    $filters->orderby = $value;
+                    break;
+            }
+        }
+
+        $scheduled = new ScheduledPayments(
+            $this->zdb,
+            $this->login,
+            $filters
+        );
+        $list = $scheduled->getList();
+
+        //store filters into session
+        if ($ajax === false) {
+            $this->session->$session_varname = $filters;
+        }
+
+        //assign pagination variables to the template and add pagination links
+        $filters->setViewPagination($this->routeparser, $this->view);
+
+        // display page
+        $this->view->render(
+            $response,
+            'pages/scheduledpayments_list.html.twig',
+            [
+                'page_title'        => _T("Scheduled payments management"),
+                'scheduled'         => $scheduled,
+                'list'              => $list,
+                'nb'                => $scheduled->getCount(),
+                'filters'           => $filters,
+                'mode'              => $ajax ? 'ajax' : ''
+            ]
+        );
+        return $response;
+    }
+
+    /**
+     * List page for logged-in member
+     *
+     * @param Request     $request  PSR Request
+     * @param Response    $response PSR Response
+     * @param string|null $type     One of 'transactions' or 'contributions'
+     *
+     * @return Response
+     */
+    public function myList(Request $request, Response $response, string $type = null): Response
+    {
+        return $this->list(
+            $request->withQueryParams(
+                $request->getQueryParams() + [
+                    Adherent::PK => $this->login->id
+                ]
+            ),
+            $response
+        );
+    }
+
+    /**
+     * Scheduled payments filtering
+     *
+     * @param Request  $request  PSR Request
+     * @param Response $response PSR Response
+     *
+     * @return Response
+     */
+    public function filter(Request $request, Response $response): Response
+    {
+        $ajax = false;
+        $filter_name = $this->getFilterName();
+        if ($request->getHeaderLine('X-Requested-With') === 'XMLHttpRequest') {
+            $ajax = true;
+            $filter_name = 'ajax_' . $filter_name;
+        }
+
+        $post = $request->getParsedBody();
+        $error_detected = [];
+
+        if ($this->session->$filter_name !== null) {
+            $filters = $this->session->$filter_name;
+        } else {
+            $filters = new ScheduledPaymentsList();
+        }
+
+        if (isset($post['clear_filter'])) {
+            $filters->reinit($ajax);
+        } else {
+            if (
+                (isset($post['nbshow']) && is_numeric($post['nbshow']))
+            ) {
+                $filters->show = $post['nbshow'];
+            }
+
+            if (isset($post['date_field'])) {
+                $filters->date_field = $post['date_field'];
+            }
+
+            if (isset($post['end_date_filter']) || isset($post['start_date_filter'])) {
+                if (isset($post['start_date_filter'])) {
+                    $filters->start_date_filter = $post['start_date_filter'];
+                }
+                if (isset($post['end_date_filter'])) {
+                    $filters->end_date_filter = $post['end_date_filter'];
+                }
+            }
+
+            if (isset($post['payment_type_filter'])) {
+                $ptf = (int)$post['payment_type_filter'];
+                $ptypes = new PaymentTypes(
+                    $this->zdb,
+                    $this->preferences,
+                    $this->login
+                );
+                $ptlist = $ptypes->getList(false);
+                if (isset($ptlist[$ptf])) {
+                    $filters->payment_type_filter = $ptf;
+                } elseif ($ptf == -1) {
+                    $filters->payment_type_filter = null;
+                } else {
+                    $error_detected[] = _T("- Unknown payment type!");
+                }
+            }
+        }
+
+        $this->session->$filter_name = $filters;
+
+        if (count($error_detected) > 0) {
+            //report errors
+            foreach ($error_detected as $error) {
+                $this->flash->addMessage(
+                    'error_detected',
+                    $error
+                );
+            }
+        }
+
+        return $response
+            ->withStatus(301)
+            ->withHeader('Location', $this->routeparser->urlFor('scheduledPayments'));
+    }
+
+    /**
+     * Batch actions handler
+     *
+     * @param Request  $request  PSR Request
+     * @param Response $response PSR Response
+     *
+     * @return Response
+     */
+    public function handleBatch(Request $request, Response $response): Response
+    {
+        $filter_name = $this->getFilterName();
+        $post = $request->getParsedBody();
+
+        if (isset($post['entries_sel'])) {
+            $filters = $this->session->$filter_name ?? new ScheduledPaymentsList();
+            $filters->selected = $post['entries_sel'];
+            $this->session->$filter_name = $filters;
+
+            if (isset($post['csv'])) {
+                return $response
+                    ->withStatus(301)
+                    ->withHeader('Location', $this->routeparser->urlFor('csv-scheduledPaymentslist'));
+            }
+
+            if (isset($post['delete'])) {
+                return $response
+                    ->withStatus(301)
+                    ->withHeader('Location', $this->routeparser->urlFor('removeScheduledPayments'));
+            }
+
+            throw new \RuntimeException('Does not know what to batch :(');
+        } else {
+            $this->flash->addMessage(
+                'error_detected',
+                _T("No scheduled payment was selected, please check at least one.")
+            );
+
+            return $response
+                ->withStatus(301)
+                ->withHeader('Location', $this->routeparser->urlFor('scheduledPayments'));
+        }
+    }
+
+    // /CRUD - Read
+    // CRUD - Update
+
+    /**
+     * Edit page
+     *
+     * @param Request  $request  PSR Request
+     * @param Response $response PSR Response
+     * @param integer  $id       Scheduled payment id
+     *
+     * @return Response
+     */
+    public function edit(Request $request, Response $response, int $id): Response
+    {
+        if (isset($this->session->scheduled_payment)) {
+            $scheduled = $this->session->scheduled_payment;
+            unset($this->session->scheduled_payment);
+        } else {
+            $scheduled = new ScheduledPayment($this->zdb, $id);
+        }
+        $mode = $request->getHeaderLine('X-Requested-With') === 'XMLHttpRequest' ? 'ajax' : '';
+
+        // display page
+        $this->view->render(
+            $response,
+            'pages/scheduledpayment_form.html.twig',
+            [
+                'page_title'    => _T("Edit scheduled payment"),
+                'scheduled'     => $scheduled,
+                'mode'          => $mode
+            ]
+        );
+        return $response;
+    }
+
+    /**
+     * Edit action
+     *
+     * @param Request  $request  PSR Request
+     * @param Response $response PSR Response
+     * @param integer  $id       Type id
+     *
+     * @return Response
+     */
+    public function doEdit(Request $request, Response $response, int $id): Response
+    {
+        return $this->store($request, $response, $id);
+    }
+
+    /**
+     * Store
+     *
+     * @param Request  $request  PSR Request
+     * @param Response $response PSR Response
+     * @param ?integer $id       Type id
+     *
+     * @return Response
+     */
+    public function store(Request $request, Response $response, int $id = null): Response
+    {
+        $post = $request->getParsedBody();
+
+        if (isset($post['cancel'])) {
+            return $response
+                ->withStatus(301)
+                ->withHeader('Location', $this->cancelUri($this->getArgs($request)));
+        }
+
+        $error_detected = [];
+        $msg = null;
+
+        $redirect_uri = $this->redirectUri($this->getArgs($request));
+        $scheduled = new ScheduledPayment($this->zdb, $id);
+
+        if (!$scheduled->check($post)) {
+            $this->session->scheduled_payment = $scheduled;
+            if ($id === null) {
+                $redirect_uri = $this->routeparser->urlFor(
+                    'addScheduledPayment',
+                    [
+                        Contribution::PK => $post[Contribution::PK]
+                    ]
+                );
+            } else {
+                $redirect_uri = $this->routeparser->urlFor(
+                    'editScheduledPayment',
+                    [
+                        'id' => (string)$scheduled->getId()
+                    ]
+                );
+            }
+            $error_detected = $scheduled->getErrors();
+        } else {
+            $res = $scheduled->store();
+            if (!$res) {
+                $this->session->scheduled_payment = $scheduled;
+                if ($id === null) {
+                    $error_detected[] = _T("Scheduled payment has not been added!");
+                } else {
+                    $error_detected[] = _T("Scheduled payment has not been modified!");
+                    //redirect to edition
+                    $redirect_uri = $this->routeparser->urlFor('editScheduledPayment', ['id' => (string)$id]);
+                }
+            } else {
+                if ($id === null) {
+                    $msg = _T("Scheduled payment has been successfully added.");
+                } else {
+                    $msg = _T("Scheduled payment has been successfully modified.");
+                }
+            }
+        }
+
+        if (count($error_detected) > 0) {
+            foreach ($error_detected as $error) {
+                $this->flash->addMessage(
+                    'error_detected',
+                    $error
+                );
+            }
+        } else {
+            $this->flash->addMessage(
+                'success_detected',
+                $msg
+            );
+        }
+
+        return $response
+            ->withStatus(301)
+            ->withHeader('Location', $redirect_uri);
+    }
+
+
+    // /CRUD - Update
+    // CRUD - Delete
+
+    /**
+     * Get redirection URI
+     *
+     * @param array<string,mixed> $args Route arguments
+     *
+     * @return string
+     */
+    public function redirectUri(array $args): string
+    {
+        return $this->routeparser->urlFor('scheduledPayments');
+    }
+
+    /**
+     * Get form URI
+     *
+     * @param array<string,mixed> $args Route arguments
+     *
+     * @return string
+     */
+    public function formUri(array $args): string
+    {
+        return $this->routeparser->urlFor(
+            'doRemoveScheduledPayment',
+            $args
+        );
+    }
+
+    /**
+     * Get confirmation removal page title
+     *
+     * @param array<string,mixed> $args Route arguments
+     *
+     * @return string
+     */
+    public function confirmRemoveTitle(array $args): string
+    {
+        return _Tn('Remove scheduled payment', 'Remove scheduled payments', (count($args['ids'] ?? []) > 1 ? 3 : 1));
+    }
+
+    /**
+     * Remove object
+     *
+     * @param array<string,mixed> $args Route arguments
+     * @param array<string,mixed> $post POST values
+     *
+     * @return boolean
+     */
+    protected function doDelete(array $args, array $post): bool
+    {
+        $scheduleds = new ScheduledPayments($this->zdb, $this->login);
+        $rm = $scheduleds->remove($args['ids'] ?? $args['id'], $this->history);
+        return $rm;
+    }
+
+    // CRUD - Delete
+
+    /**
+     * Get filter name in session4
+     *
+     * @param array<string,mixed>|null $args Route arguments
+     *
+     * @return string
+     */
+    public function getFilterName(array $args = null): string
+    {
+        return 'filter_scheduled_payments';
+    }
+}
index 9cbfa48e98c0d3fda4b2d790616c7e7568e4d9c1..0c57512dd8aeb8baa4afefbcb24ed59396b684a2 100644 (file)
@@ -22,7 +22,9 @@
 namespace Galette\Controllers;
 
 use Galette\Filters\ContributionsList;
+use Galette\Filters\ScheduledPaymentsList;
 use Galette\IO\ContributionsCsv;
+use Galette\IO\ScheduledPaymentsCsv;
 use Laminas\Db\ResultSet\ResultSet;
 use Slim\Psr7\Request;
 use Slim\Psr7\Response;
@@ -730,4 +732,37 @@ class CsvController extends AbstractController
 
         return $this->sendResponse($response, $filepath, $filename);
     }
+
+    /**
+     * Scheduled payments CSV exports
+     *
+     * @param Request  $request  PSR Request
+     * @param Response $response PSR Response
+     *
+     * @return Response
+     */
+    public function scheduledPaymentsExport(Request $request, Response $response): Response
+    {
+        $post = $request->getParsedBody();
+        $get = $request->getQueryParams();
+
+        $session_var = $post['session_var'] ?? $get['session_var'] ?? 'filter_scheduled_payments';
+
+        if (isset($this->session->$session_var)) {
+            $filters = $this->session->$session_var;
+        } else {
+            $filters = new ScheduledPaymentsList();
+        }
+
+        $csv = new ScheduledPaymentsCsv(
+            $this->zdb,
+            $this->login
+        );
+        $csv->exportScheduledPayments($filters);
+
+        $filepath = $csv->getPath();
+        $filename = $csv->getFileName();
+
+        return $this->sendResponse($response, $filepath, $filename);
+    }
 }
index dd6c8586062d17ddd332f0f7ded0c3481b11d367..cab1c27267f5d221178b83eca5e51ca096349295 100644 (file)
@@ -206,7 +206,7 @@ class GaletteController extends AbstractController
             $this->preferences,
             $this->login
         );
-        $ptlist = $ptypes->getList();
+        $ptlist = $ptypes->getList(false);
 
         $m = new Members();
         $s = new Status($this->zdb);
index 87e0476cd96a582ed984795d79d6015b1317e392..599079937a43516208fc271d89d90c7b5dc2eb21 100644 (file)
@@ -130,6 +130,13 @@ class Galette
                                 'args' => ['type' => 'contributions']
                             ]
                         ],
+                        [
+                            'label' => _T('My scheduled payments'),
+                            'title' => _T('View and filter all my scheduled payments'),
+                            'route' => [
+                                'name' => 'myScheduledPayments'
+                            ]
+                        ],
                         [
                             'label' => _T('My transactions'),
                             'title' => _T('View and filter all my transactions'),
@@ -221,6 +228,14 @@ class Galette
                                 'aliases' => ['editContribution']
                             ]
                         ],
+                        [
+                            'label' => _T("List of scheduled payments"),
+                            'title' => _T("View and filter scheduled payments"),
+                            'route' => [
+                                'name' => 'scheduledPayments',
+                                'aliases' => ['addScheduledPayment', 'editScheduledPayment']
+                            ]
+                        ],
                         [
                             'label' => _T("List of transactions"),
                             'title' => _T("View and filter transactions"),
index 191b2463e86675a9401bc1da532f81ec827ef7e8..01757bb1f172c099f6a8ecae6e026cb788b7f317 100644 (file)
@@ -182,7 +182,7 @@ class Contribution
                 $this->retrieveEndDate();
             }
             if (isset($args['payment_type'])) {
-                $this->payment_type = $args['payment_type'];
+                $this->setPaymentType((int)$args['payment_type']);
             }
         } elseif (is_object($args)) {
             $this->loadFromRS($args);
@@ -469,7 +469,7 @@ class Contribution
                         break;
                     case 'type_paiement_cotis':
                         if ($value != '') {
-                            $this->payment_type = (int)$value;
+                            $this->setPaymentType((int)$value);
                         }
                         break;
                     case 'info_cotis':
@@ -1394,6 +1394,30 @@ class Contribution
         return 'contribution';
     }
 
+    /**
+     * Does contribution have attached scheduled payment?
+     *
+     * @return bool
+     * @throws Throwable
+     */
+    public function hasSchedule(): bool
+    {
+        $schedule = new ScheduledPayment($this->zdb);
+        return $schedule->isContributionHandled($this->id ?? 0);
+    }
+
+    /**
+     * Is schedule fully allocated
+     *
+     * @return bool
+     * @throws Throwable
+     */
+    public function isScheduleFullyAllocated(): bool
+    {
+        $schedule = new ScheduledPayment($this->zdb);
+        return $schedule->isFullyAllocated($this);
+    }
+
     /**
      * Set (and check) payment type
      *
@@ -1415,7 +1439,11 @@ class Contribution
             $this->ptypes_list = $ptypes->getList();
         }
         if (isset($this->ptypes_list[$value])) {
-            $this->payment_type = $value;
+            if (isset($this->id) && $this->payment_type != $value && $this->hasSchedule()) {
+                $this->errors[] = _T("Cannot change payment type if there is an attached scheduled payment");
+            } else {
+                $this->payment_type = $value;
+            }
         } else {
             Analog::log(
                 'Unknown payment type ' . $value,
index 1f06805dae1e3bcfca3ac429526c0b7bc61557e4..7219848881ceca50243e0116cf32cbbb46789011 100644 (file)
@@ -48,6 +48,7 @@ class PaymentType
     private Db $zdb;
     private int $id;
 
+    public const SCHEDULED = 7;
     public const OTHER = 6;
     public const CASH = 1;
     public const CREDITCARD = 2;
@@ -76,9 +77,9 @@ class PaymentType
      *
      * @param integer $id Identifier
      *
-     * @return void
+     * @return bool
      */
-    private function load(int $id): void
+    public function load(int $id): bool
     {
         try {
             $select = $this->zdb->select(self::TABLE);
@@ -89,6 +90,7 @@ class PaymentType
 
             $this->id = $id;
             $this->name = $res->type_name;
+            return true;
         } catch (Throwable $e) {
             Analog::log(
                 'An error occurred loading payment type #' . $id . "Message:\n" .
@@ -277,7 +279,8 @@ class PaymentType
                 self::CREDITCARD    => _T("Credit card"),
                 self::CHECK         => _T("Check"),
                 self::TRANSFER      => _T("Transfer"),
-                self::PAYPAL        => _T("Paypal")
+                self::PAYPAL        => _T("Paypal"),
+                self::SCHEDULED     => _T("Payment schedule")
             ];
         } else {
             $systypes = [
@@ -286,7 +289,8 @@ class PaymentType
                 self::CREDITCARD    => "Credit card",
                 self::CHECK         => "Check",
                 self::TRANSFER      => "Transfer",
-                self::PAYPAL        => "Paypal"
+                self::PAYPAL        => "Paypal",
+                self::SCHEDULED     => "Payment schedule"
             ];
         }
         return $systypes;
diff --git a/galette/lib/Galette/Entity/ScheduledPayment.php b/galette/lib/Galette/Entity/ScheduledPayment.php
new file mode 100644 (file)
index 0000000..f9a7ac9
--- /dev/null
@@ -0,0 +1,653 @@
+<?php
+
+/**
+ * Copyright © 2003-2024 The Galette Team
+ *
+ * This file is part of Galette (https://galette.eu).
+ *
+ * Galette is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Galette is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ *  GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Galette. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+namespace Galette\Entity;
+
+use ArrayObject;
+use DateTime;
+use Galette\Features\EntityHelper;
+use Laminas\Db\Sql\Expression;
+use Laminas\Db\Sql\Predicate\IsNull;
+use Laminas\Db\Sql\Predicate\Operator;
+use Laminas\Db\Sql\Predicate\PredicateSet;
+use Throwable;
+use Galette\Core\Db;
+use Analog\Analog;
+
+/**
+ * Scheduled payment
+ *
+ * @author Johan Cwiklinski <johan@x-tnd.be>
+ */
+
+class ScheduledPayment
+{
+    use EntityHelper;
+
+    public const TABLE = 'payments_schedules';
+    public const PK = 'id_schedule';
+    private Db $zdb;
+    private int $id;
+    private Contribution $contribution;
+    private PaymentType $payment_type;
+    private string $creation_date;
+    private string $scheduled_date;
+    private float $amount;
+    private bool $is_paid = false;
+    private ?string $comment = null;
+    /** @var string[] */
+    private array $errors = [];
+
+    /**
+     * Main constructor
+     *
+     * @param Db                                      $zdb  Database instance
+     * @param ArrayObject<string,int|string>|int|null $args Arguments
+     */
+    public function __construct(Db $zdb, ArrayObject|int $args = null)
+    {
+        $this->zdb = $zdb;
+        $now = new DateTime();
+        $this->creation_date = $now->format('Y-m-d');
+        $this->scheduled_date = $now->format('Y-m-d');
+
+        $this->setFields();
+
+        if (is_int($args)) {
+            $this->load($args);
+        } elseif ($args instanceof ArrayObject) {
+            $this->loadFromRs($args);
+        }
+    }
+
+    /**
+     * Load a scheduled payment from its identifier
+     *
+     * @param integer $id Identifier
+     *
+     * @return bool
+     */
+    public function load(int $id): bool
+    {
+        try {
+            $select = $this->zdb->select(self::TABLE);
+            $select->limit(1)->where([self::PK => $id]);
+
+            $results = $this->zdb->execute($select);
+            $rs = $results->current();
+
+            if (!$rs) {
+                return false;
+            }
+            $this->loadFromRs($rs);
+            return true;
+        } catch (Throwable $e) {
+            Analog::log(
+                'An error occurred loading scheduled payment #' . $id . "Message:\n" .
+                $e->getMessage(),
+                Analog::ERROR
+            );
+            throw $e;
+        }
+    }
+
+    /**
+     * Load scheduled payment from a db ResultSet
+     *
+     * @param ArrayObject<string, int|string> $rs ResultSet
+     *
+     * @return void
+     */
+    private function loadFromRs(ArrayObject $rs): void
+    {
+        global $login;
+
+        $pk = self::PK;
+        $this->id = $rs->$pk;
+        $this->contribution = new Contribution($this->zdb, $login, $rs->{Contribution::PK});
+        $this->payment_type = new PaymentType($this->zdb, $rs->id_paymenttype);
+        $this->creation_date = $rs->creation_date;
+        $this->scheduled_date = $rs->scheduled_date;
+        $this->amount = $rs->amount;
+        $this->is_paid = (bool)$rs->paid;
+        $this->comment = $rs->comment;
+    }
+
+    /**
+     * Check data
+     *
+     * @param array<string,mixed> $data Data
+     *
+     * @return boolean
+     */
+    public function check(array $data): bool
+    {
+        global $login;
+
+        $this->errors = [];
+        $this->contribution = new Contribution($this->zdb, $login);
+
+        if (!isset($data[Contribution::PK]) || !is_numeric($data[Contribution::PK])) {
+            $this->errors[] = _T('Contribution is required');
+        } else {
+            if (!$this->contribution->load($data[Contribution::PK])) {
+                $this->errors[] = _T('Unable to load contribution');
+            } else {
+                if (isset($data['amount'])) {
+                    //Amount is not required (will defaults to contribution amount)
+                    if (!is_numeric($data['amount']) || $data['amount'] <= 0) {
+                        $this->errors[] = _T('Amount must be a positive number');
+                    } else {
+                        $not_allocated = $this->contribution->amount - $this->getAllocation($this->contribution->id);
+                        if (isset($this->id)) {
+                            $not_allocated += $this->amount;
+                        }
+                        if ($data['amount'] > $not_allocated) {
+                            $this->errors[] = _T('Amount cannot be greater than non allocated amount');
+                        }
+                    }
+                }
+                if ($this->contribution->payment_type !== PaymentType::SCHEDULED) {
+                    $this->errors[] = _T('Payment type for contribution must be set to scheduled');
+                }
+            }
+        }
+
+        if (!isset($data['id_paymenttype']) || !is_numeric($data['id_paymenttype'])) {
+            $this->errors[] = _T('Payment type is required');
+        } else {
+            //no schedule inception allowed!
+            if ($data['id_paymenttype'] === PaymentType::SCHEDULED) {
+                $this->errors[] = _T('Cannot schedule a scheduled payment!');
+            } else {
+                $this->payment_type = new PaymentType($this->zdb, $data['id_paymenttype']);
+            }
+        }
+
+        if (!isset($data['scheduled_date'])) {
+            $this->errors[] = _T('Scheduled date is required');
+        }
+
+        if (count($this->errors) > 0) {
+            return false;
+        }
+
+        $this
+            ->setContribution($data[Contribution::PK])
+            ->setPaymentType($data['id_paymenttype'])
+            ->setCreationDate($data['creation_date'] ?? date('Y-m-d'))
+            ->setScheduledDate($data['scheduled_date'])
+            ->setAmount($data['amount'] ?? $this->contribution->amount)
+            ->setPaid($data['is_paid'] ?? false)
+            ->setComment($data['comment'] ?? null);
+
+        return count($this->errors) === 0;
+    }
+
+    /**
+     * Store scheduled payment in database
+     *
+     * @return boolean
+     */
+    public function store(): bool
+    {
+        $data = array(
+            Contribution::PK => $this->contribution->id,
+            'id_paymenttype' => $this->payment_type->id,
+            'scheduled_date' => $this->scheduled_date,
+            'amount' => $this->amount,
+            'paid' => ($this->is_paid ? true : ($this->zdb->isPostgres() ? 'false' : 0)),
+            'comment' => $this->comment
+        );
+        try {
+            if (isset($this->id) && $this->id > 0) {
+                $update = $this->zdb->update(self::TABLE);
+                $update->set($data)->where([self::PK => $this->id]);
+                $this->zdb->execute($update);
+            } else {
+                $data['creation_date'] = $this->creation_date;
+                $insert = $this->zdb->insert(self::TABLE);
+                $insert->values($data);
+                $add = $this->zdb->execute($insert);
+                if (!$add->count() > 0) {
+                    Analog::log('Not stored!', Analog::ERROR);
+                    return false;
+                }
+
+                $this->id = $this->zdb->getLastGeneratedValue($this);
+            }
+            return true;
+        } catch (Throwable $e) {
+            Analog::log(
+                'An error occurred storing shceduled payment: ' . $e->getMessage() .
+                "\n" . print_r($data, true),
+                Analog::ERROR
+            );
+            throw $e;
+        }
+    }
+
+    /**
+     * Remove current
+     *
+     * @return boolean
+     */
+    public function remove(): bool
+    {
+        $id = $this->id;
+
+        try {
+            $delete = $this->zdb->delete(self::TABLE);
+            $delete->where([self::PK => $id]);
+            $this->zdb->execute($delete);
+            Analog::log(
+                'Scheduled Payment #' . $id .  ' deleted successfully.',
+                Analog::INFO
+            );
+            return true;
+        } catch (Throwable $e) {
+            Analog::log(
+                'Unable to delete scheduled payment ' . $id . ' | ' . $e->getMessage(),
+                Analog::ERROR
+            );
+            throw $e;
+        }
+    }
+
+    /**
+     * Get identifier
+     *
+     * @return ?int
+     */
+    public function getId(): ?int
+    {
+        return $this->id ?? null;
+    }
+
+    /**
+     * Get contribution
+     *
+     * @return Contribution
+     */
+    public function getContribution(): Contribution
+    {
+        return $this->contribution;
+    }
+
+    /**
+     * Set contribution
+     *
+     * @param int|Contribution $contribution Contribution instance or id
+     *
+     * @return self
+     */
+    public function setContribution(int|Contribution $contribution): self
+    {
+        if (is_int($contribution)) {
+            global $login;
+            try {
+                $contrib = new Contribution($this->zdb, $login);
+                if ($contrib->load($contribution)) {
+                    $this->contribution = $contrib;
+                } else {
+                    throw new \RuntimeException('Cannot load contribution #' . $contribution);
+                }
+            } catch (Throwable $e) {
+                Analog::log(
+                    'Unable to load contribution #' . $contribution . ' | ' . $e->getMessage(),
+                    Analog::ERROR
+                );
+                $this->errors[] = _T('Unable to load contribution');
+            }
+        } else {
+            $this->contribution = $contribution;
+        }
+        return $this;
+    }
+
+    /**
+     * Get payment type
+     *
+     * @return PaymentType
+     */
+    public function getPaymentType(): PaymentType
+    {
+        global $preferences;
+
+        return $this->payment_type ?? new PaymentType($this->zdb, $preferences->pref_default_paymenttype);
+    }
+
+    /**
+     * Set payment type
+     *
+     * @param int|PaymentType $payment_type Payment type instance or id
+     *
+     * @return self
+     */
+    public function setPaymentType(int|PaymentType $payment_type): self
+    {
+        if (is_int($payment_type)) {
+            try {
+                $ptype = new PaymentType($this->zdb);
+                if ($ptype->load($payment_type)) {
+                    $this->payment_type = $ptype;
+                } else {
+                    throw new \RuntimeException('Cannot load payment type #' . $payment_type);
+                }
+            } catch (Throwable $e) {
+                Analog::log(
+                    'Unable to load payment type #' . $payment_type . ' | ' . $e->getMessage(),
+                    Analog::ERROR
+                );
+                $this->errors[] = _T('Unable to load payment type');
+            }
+        } else {
+            $this->payment_type = $payment_type;
+        }
+
+        return $this;
+    }
+
+    /**
+     * Get creation date
+     *
+     * @param bool $formatted Get formatted date, or DateTime object
+     *
+     * @return string|DateTime|null
+     */
+    public function getCreationDate(bool $formatted = true): string|DateTime|null
+    {
+        return $this->getDate('creation_date', $formatted);
+    }
+
+    /**
+     * Set creation date
+     *
+     * @param string $creation_date Creation date
+     *
+     * @return self
+     */
+    public function setCreationDate(string $creation_date): self
+    {
+        $this->setDate('creation_date', $creation_date);
+        return $this;
+    }
+
+    /**
+     * Get scheduled date
+     *
+     * @param bool $formatted Get formatted date, or DateTime object
+     *
+     * @return string|DateTime|null
+     */
+    public function getScheduledDate(bool $formatted = true): string|DateTime|null
+    {
+        return $this->getDate('scheduled_date', $formatted);
+    }
+
+    /**
+     * Set scheduled date
+     *
+     * @param string $scheduled_date Scheduled date
+     *
+     * @return self
+     */
+    public function setScheduledDate(string $scheduled_date): self
+    {
+        $this->setDate('scheduled_date', $scheduled_date);
+        return $this;
+    }
+
+    /**
+     * Get amount
+     *
+     * @return float
+     */
+    public function getAmount(): ?float
+    {
+        return $this->amount ?? null;
+    }
+
+    /**
+     * Set amount
+     *
+     * @param float $amount Amount
+     *
+     * @return self
+     */
+    public function setAmount(float $amount): self
+    {
+        $this->amount = $amount;
+        return $this;
+    }
+
+    /**
+     * Is payment done?
+     *
+     * @return bool
+     */
+    public function isPaid(): bool
+    {
+        return $this->is_paid;
+    }
+
+    /**
+     * Set paid
+     *
+     * @param bool $is_paid Paid status
+     *
+     * @return self
+     */
+    public function setPaid(bool $is_paid = true): self
+    {
+        $this->is_paid = $is_paid;
+        return $this;
+    }
+
+    /**
+     * Get comment
+     *
+     * @return string
+     */
+    public function getComment(): ?string
+    {
+        return $this->comment;
+    }
+
+    /**
+     * Set comment
+     *
+     * @param ?string $comment Comment
+     *
+     * @return self
+     */
+    public function setComment(?string $comment): self
+    {
+        $this->comment = $comment;
+        return $this;
+    }
+
+    /**
+     * Is a contribution handled from a scheduled payment?
+     *
+     * @param int $id_cotis Contribution identifier
+     *
+     * @return bool
+     * @throws Throwable
+     */
+    public function isContributionHandled(int $id_cotis): bool
+    {
+        $select = $this->zdb->select(self::TABLE);
+        $select->limit(1)->where([Contribution::PK => $id_cotis]);
+
+        $results = $this->zdb->execute($select);
+        return ($results->count() > 0);
+    }
+
+    /**
+     * Get allocated amount
+     *
+     * @param int $id_cotis Contribution identifier
+     *
+     * @return float
+     * @throws Throwable
+     */
+    public function getAllocation(int $id_cotis): float
+    {
+        $select = $this->zdb->select(self::TABLE);
+        $select->columns(['allocation' => new Expression('SUM(amount)')]);
+        $select->where([Contribution::PK => $id_cotis]);
+
+        $results = $this->zdb->execute($select);
+        $result = $results->current();
+        return $result->allocation ?? 0;
+    }
+
+    /**
+     * Get allocated amount for current contribution
+     *
+     * @return float
+     * @throws Throwable
+     */
+    public function getAllocated(): float
+    {
+        return $this->getAllocation($this->contribution->id);
+    }
+
+    /**
+     * Get missing amount
+     *
+     * @return float
+     */
+    public function getMissingAmount(): float
+    {
+        return $this->contribution->amount - $this->getAllocated();
+    }
+
+    /**
+     * Is scheduled payment fully allocated?
+     *
+     * @param Contribution $contrib Contribution
+     *
+     * @return bool
+     */
+    public function isFullyAllocated(Contribution $contrib): bool
+    {
+        return !($this->getAllocation($contrib->id) < $contrib->amount);
+    }
+
+    /**
+     * Get not fully allocated scheduled payments
+     *
+     * @return Contribution[]
+     */
+    public function getNotFullyAllocated(): array
+    {
+        $select = $this->zdb->select(Contribution::TABLE, 'c');
+        $select->columns([Contribution::PK, 'montant_cotis']);
+        $select->quantifier('DISTINCT');
+
+        $select->join(
+            array('s' => PREFIX_DB . self::TABLE),
+            //$on,
+            'c.' . Contribution::PK . '=s.' . Contribution::PK,
+            array('allocated' => new Expression('SUM(s.amount)')),
+            $select::JOIN_LEFT
+        );
+
+        $select->group('c.' . Contribution::PK);
+        $select->where(['c.type_paiement_cotis' => PaymentType::SCHEDULED]);
+        $select->having([
+            new PredicateSet(
+                array(
+                    new Operator(
+                        /** @phpstan-ignore-next-line  */
+                        new \Laminas\Db\Sql\Predicate\Expression('SUM(s.amount)'),
+                        '<',
+                        new \Laminas\Db\Sql\Predicate\Expression('c.montant_cotis')
+                    ),
+                    /** @phpstan-ignore-next-line  */
+                    new IsNull(new \Laminas\Db\Sql\Predicate\Expression('SUM(s.amount)'))
+                ),
+                PredicateSet::OP_OR
+            )
+        ]);
+
+        $results = $this->zdb->execute($select);
+
+        return $results->toArray();
+    }
+
+    /**
+     * Get errors
+     *
+     * @return string[]
+     */
+    public function getErrors(): array
+    {
+        return $this->errors;
+    }
+
+    /**
+     * Set fields, must populate $this->fields
+     *
+     * @return self
+     */
+    protected function setFields(): self
+    {
+        $this->fields = array(
+            self::PK            => array(
+                'label'    => _T('Scheduled payment ID'), //not a field in the form
+                'propname' => 'id'
+            ),
+            Contribution::PK       => array(
+                'label'    => _T('Contribution ID'), //not a field in the form
+                'propname' => 'contribution'
+            ),
+            'id_paymenttype'   => array(
+                'label'    => _T('Payment type'),
+                'propname' => 'payment_type'
+            ),
+            'creation_date'    => array(
+                'label'    => _T('Creation date'),
+                'propname' => 'creation_date'
+            ),
+            'scheduled_date'   => array(
+                'label'    => _T('Scheduled date'),
+                'propname' => 'scheduled_date'
+            ),
+            'amount'           => array(
+                'label'    => _T('Amount'),
+                'propname' => 'amount'
+            ),
+            'paid'          => array(
+                'label'    => _T('Paid'),
+                'propname' => 'is_paid'
+            ),
+            'comment'          => array(
+                'label'    => _T('Comment'),
+                'propname' => 'comment'
+            )
+        );
+
+        return $this;
+    }
+}
index 5bf1961eabd7ab65c13a5cbfce014b92e95a6221..5fa8af29f8d28265704119b5ac518e6b53057c79 100644 (file)
@@ -63,6 +63,16 @@ trait EntityHelper
      */
     abstract protected function setFields(): self;
 
+    /**
+     * Get fields
+     *
+     * @return array<string, array<string, string>>
+     */
+    public function getFields(): array
+    {
+        return $this->fields;
+    }
+
     /**
      * Global isset method
      * Required for twig to access properties via __get
diff --git a/galette/lib/Galette/Filters/ScheduledPaymentsList.php b/galette/lib/Galette/Filters/ScheduledPaymentsList.php
new file mode 100644 (file)
index 0000000..b6c9263
--- /dev/null
@@ -0,0 +1,293 @@
+<?php
+
+/**
+ * Copyright © 2003-2024 The Galette Team
+ *
+ * This file is part of Galette (https://galette.eu).
+ *
+ * Galette is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Galette is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ *  GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Galette. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+namespace Galette\Filters;
+
+use Throwable;
+use Analog\Analog;
+use Galette\Core\Pagination;
+
+/**
+ * Contributions lists filters and paginator
+ *
+ * @author Johan Cwiklinski <johan@x-tnd.be>
+ *
+ * @property ?string $start_date_filter
+ * @property ?string $end_date_filter
+ * @property integer $date_field
+ * @property ?integer $payment_type_filter
+ * @property integer|false $from_contribution
+ * @property string $rstart_date_filter
+ * @property string $rend_date_filter
+ * @property array $selected
+ */
+
+class ScheduledPaymentsList extends Pagination
+{
+    public const ORDERBY_DATE = 0;
+    public const ORDERBY_MEMBER = 1;
+    public const ORDERBY_SCHEDULED_DATE = 2;
+    public const ORDERBY_CONTRIBUTION = 3;
+    public const ORDERBY_AMOUNT = 5;
+    public const ORDERBY_PAYMENT_TYPE = 7;
+    public const ORDERBY_ID = 8;
+
+    public const DATE_RECORD = 0;
+
+    public const DATE_SCHEDULED = 1;
+
+    //filters
+    private ?int $date_field = null;
+    private ?string $start_date_filter = null;
+    private ?string $end_date_filter = null;
+    private ?int $payment_type_filter = null;
+    private int|false $from_contribution = false;
+
+
+    /** @var array<int> */
+    private array $selected = [];
+
+    /** @var array<string> */
+    protected array $list_fields = array(
+        'start_date_filter',
+        'end_date_filter',
+        'date_field',
+        'payment_type_filter',
+        'from_contribution',
+        'selected'
+    );
+
+    /** @var array<string>  */
+    protected array $virtuals_list_fields = array(
+        'rstart_date_filter',
+        'rend_date_filter'
+    );
+
+    /**
+     * Default constructor
+     */
+    public function __construct()
+    {
+        $this->reinit();
+    }
+
+    /**
+     * Returns the field we want to default set order to
+     *
+     * @return int|string
+     */
+    protected function getDefaultOrder(): int|string
+    {
+        return self::ORDERBY_SCHEDULED_DATE;
+    }
+
+    /**
+     * Reinit default parameters
+     *
+     * @param boolean $ajax Called form an ajax query
+     *
+     * @return void
+     */
+    public function reinit(bool $ajax = false): void
+    {
+        parent::reinit();
+        $this->date_field = self::DATE_SCHEDULED;
+        $this->start_date_filter = null;
+        $this->end_date_filter = null;
+        $this->payment_type_filter = null;
+        $this->selected = [];
+
+        if ($ajax === false) {
+            $this->from_contribution = false;
+        }
+    }
+
+    /**
+     * Global getter method
+     *
+     * @param string $name name of the property we want to retrieve
+     *
+     * @return mixed the called property
+     */
+    public function __get(string $name)
+    {
+        if (in_array($name, $this->pagination_fields)) {
+            return parent::__get($name);
+        } else {
+            if (in_array($name, $this->list_fields) || in_array($name, $this->virtuals_list_fields)) {
+                switch ($name) {
+                    case 'start_date_filter':
+                    case 'end_date_filter':
+                        if ($this->$name === null) {
+                            return $this->$name;
+                        }
+                        try {
+                            $d = \DateTime::createFromFormat(__("Y-m-d"), $this->$name);
+                            if ($d === false) {
+                                //try with non localized date
+                                $d = \DateTime::createFromFormat("Y-m-d", $this->$name);
+                                if ($d === false) {
+                                    throw new \Exception('Incorrect format');
+                                }
+                            }
+                            return $d->format(__("Y-m-d"));
+                        } catch (Throwable $e) {
+                            //oops, we've got a bad date :/
+                            Analog::log(
+                                'Bad date (' . $this->$name . ') | ' .
+                                $e->getMessage(),
+                                Analog::INFO
+                            );
+                            return $this->$name;
+                        }
+                    case 'rstart_date_filter':
+                    case 'rend_date_filter':
+                        //same as above, but raw format
+                        $rname = substr($name, 1);
+                        return $this->$rname;
+                    default:
+                        return $this->$name;
+                }
+            } else {
+                Analog::log(
+                    '[ScheduledPaymentsList] Unable to get property `' . $name . '`',
+                    Analog::WARNING
+                );
+            }
+        }
+    }
+
+    /**
+     * 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(string $name): bool
+    {
+        if (in_array($name, $this->pagination_fields)) {
+            return true;
+        } elseif (in_array($name, $this->list_fields) || in_array($name, $this->virtuals_list_fields)) {
+            return true;
+        }
+
+        return false;
+    }
+
+    /**
+     * Global setter method
+     *
+     * @param string $name  name of the property we want to assign a value to
+     * @param mixed  $value a relevant value for the property
+     *
+     * @return void
+     */
+    public function __set(string $name, $value): void
+    {
+        if (in_array($name, $this->pagination_fields)) {
+            parent::__set($name, $value);
+        } else {
+            switch ($name) {
+                case 'start_date_filter':
+                case 'end_date_filter':
+                    try {
+                        if ($value !== '') {
+                            $y = \DateTime::createFromFormat(__("Y"), $value);
+                            if ($y !== false) {
+                                $month = 1;
+                                $day = 1;
+                                if ($name === 'end_date_filter') {
+                                    $month = 12;
+                                    $day = 31;
+                                }
+                                $y->setDate(
+                                    (int)$y->format('Y'),
+                                    $month,
+                                    $day
+                                );
+                                $this->$name = $y->format('Y-m-d');
+                            }
+
+                            $ym = \DateTime::createFromFormat(__("Y-m"), $value);
+                            if ($y === false && $ym !== false) {
+                                $day = 1;
+                                if ($name === 'end_date_filter') {
+                                    $day = (int)$ym->format('t');
+                                }
+                                $ym->setDate(
+                                    (int)$ym->format('Y'),
+                                    (int)$ym->format('m'),
+                                    $day
+                                );
+                                $this->$name = $ym->format('Y-m-d');
+                            }
+
+                            $d = \DateTime::createFromFormat(__("Y-m-d"), $value);
+                            if ($y === false && $ym === false && $d !== false) {
+                                $this->$name = $d->format('Y-m-d');
+                            }
+
+                            if ($y === false && $ym === false && $d === false) {
+                                $formats = array(
+                                    __("Y"),
+                                    __("Y-m"),
+                                    __("Y-m-d"),
+                                );
+
+                                $field = null;
+                                if ($name === 'start_date_filter') {
+                                    $field = _T("start date filter");
+                                }
+                                if ($name === 'end_date_filter') {
+                                    $field = _T("end date filter");
+                                }
+
+                                throw new \Exception(
+                                    sprintf(
+                                        //TRANS: %1$s is field name, %2$s is list of known date formats
+                                        _T('Unknown date format for %1$s.<br/>Know formats are: %2$s'),
+                                        $field,
+                                        implode(', ', $formats)
+                                    )
+                                );
+                            }
+                        } else {
+                            $this->$name = null;
+                        }
+                    } catch (Throwable $e) {
+                        Analog::log(
+                            'Wrong date format. field: ' . $name .
+                            ', value: ' . $value . ', expected fmt: ' .
+                            __("Y-m-d") . ' | ' . $e->getMessage(),
+                            Analog::INFO
+                        );
+                        throw $e;
+                    }
+                    break;
+                default:
+                    $this->$name = $value;
+                    break;
+            }
+        }
+    }
+}
diff --git a/galette/lib/Galette/IO/ScheduledPaymentsCsv.php b/galette/lib/Galette/IO/ScheduledPaymentsCsv.php
new file mode 100644 (file)
index 0000000..89f63d6
--- /dev/null
@@ -0,0 +1,154 @@
+<?php
+
+/**
+ * Copyright © 2003-2024 The Galette Team
+ *
+ * This file is part of Galette (https://galette.eu).
+ *
+ * Galette is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Galette is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ *  GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Galette. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+namespace Galette\IO;
+
+use ArrayObject;
+use DateTime;
+use Galette\Core\Db;
+use Galette\Core\Login;
+use Galette\Entity\Adherent;
+use Galette\Entity\ScheduledPayment;
+use Galette\Filters\ScheduledPaymentsList;
+use Galette\Repository\PaymentTypes;
+use Galette\Repository\ScheduledPayments;
+
+/**
+ * Contributions CSV exports
+ *
+ * @author Johan Cwiklinski <johan@x-tnd.be>
+ */
+
+class ScheduledPaymentsCsv extends CsvOut
+{
+    private string $filename;
+    private string $path;
+    private Db $zdb;
+    private Login $login;
+
+    /**
+     * Default constructor
+     *
+     * @param Db    $zdb   Db instance
+     * @param Login $login Login instance
+     */
+    public function __construct(Db $zdb, Login $login)
+    {
+        $this->filename = 'filtered_shceduledpaymentslist.csv';
+        $this->path = self::DEFAULT_DIRECTORY . $this->filename;
+        $this->zdb = $zdb;
+        $this->login = $login;
+        parent::__construct();
+    }
+
+    /**
+     * Export members CSV
+     *
+     * @param ScheduledPaymentsList $filters Current filters
+     *
+     * @return void
+     */
+    public function exportScheduledPayments(ScheduledPaymentsList $filters)
+    {
+        $scheduled = new ScheduledPayment($this->zdb);
+        $fields = $scheduled->getFields();
+        $labels = array();
+
+        foreach ($fields as $k => $f) {
+            $label = $f['label'];
+            $labels[] = $label;
+        }
+
+        $scheduleds = new ScheduledPayments($this->zdb, $this->login, $filters);
+        $scheduled_list = $scheduleds->getArrayList($filters->selected);
+        $ptypes = PaymentTypes::getAll(false);
+
+        foreach ($scheduled_list as &$scheduled) {
+            /** @var ArrayObject<string, int|string> $scheduled */
+            if (isset($scheduled->id_paymenttype)) {
+                //add textual payment type
+                $scheduled->id_paymenttype = $ptypes[$scheduled->id_paymenttype];
+            }
+
+            //handle dates
+            if (isset($scheduled->date)) {
+                if (
+                    $scheduled->date != ''
+                    && $scheduled->date != '1901-01-01'
+                ) {
+                    $date = new DateTime($scheduled->date);
+                    $scheduled->date = $date->format(__("Y-m-d"));
+                } else {
+                    $scheduled->date = '';
+                }
+            }
+
+            if (isset($scheduled->scheduled_date)) {
+                if (
+                    $scheduled->scheduled_date != ''
+                    && $scheduled->scheduled_date != '1901-01-01'
+                ) {
+                    $date = new DateTime($scheduled->scheduled_date);
+                    $scheduled->scheduled_date = $date->format(__("Y-m-d"));
+                } else {
+                    $scheduled->scheduled_date = '';
+                }
+            }
+
+            //member name
+            if (isset($scheduled->{Adherent::PK})) {
+                $scheduled->{Adherent::PK} = Adherent::getSName($this->zdb, $scheduled->{Adherent::PK});
+            }
+        }
+
+        $fp = fopen($this->path, 'w');
+        if ($fp) {
+            $this->export(
+                $scheduled_list,
+                self::DEFAULT_SEPARATOR,
+                self::DEFAULT_QUOTE,
+                $labels,
+                $fp
+            );
+            fclose($fp);
+        }
+    }
+
+    /**
+     * Get file path on disk
+     *
+     * @return string
+     */
+    public function getPath()
+    {
+        return $this->path;
+    }
+
+    /**
+     * Get file name
+     *
+     * @return string
+     */
+    public function getFileName()
+    {
+        return $this->filename;
+    }
+}
index 1f418d9986968cb39af06dcda14c3dc3ceb4a589..481317c1095fe9db2c70a354986c2d5b828d3712 100644 (file)
@@ -474,15 +474,8 @@ class Contributions
         $list = array();
         if (is_array($ids)) {
             $list = $ids;
-        } elseif (is_numeric($ids)) {
-            $list = [(int)$ids];
         } else {
-            //not numeric and not an array: incorrect.
-            Analog::log(
-                'Asking to remove contribution, but without providing an array or a single numeric value.',
-                Analog::WARNING
-            );
-            return false;
+            $list = [$ids];
         }
 
         try {
index a8d87e19f4e9d3af29dfa580eb438a74cb0d58df..e0f8c43f06d6e396157f24536e9a4f3a989e93d4 100644 (file)
@@ -948,7 +948,7 @@ class Members
                 break;
         }
 
-        //anyways, we want to order by firstname, lastname
+        //anyway, we want to order by firstname, lastname
         if ($this->canOrderBy('nom_adh', $fields)) {
             $order[] = 'nom_adh ' . $this->filters->getDirection();
         }
index d86d40fad86b497aaa5c1b4b0a86c6b615b32e5f..cd989b1fc523404ccf4016ebbf612e473dceac39 100644 (file)
@@ -37,26 +37,34 @@ class PaymentTypes extends Repository
     /**
      * Get payments types
      *
+     * @param boolean $schedulable Types that can be used in schedules only
+     *
      * @return array<int, PaymentType>
      */
-    public static function getAll(): array
+    public static function getAll(bool $schedulable = true): array
     {
         global $zdb, $preferences, $login;
         $ptypes = new self($zdb, $preferences, $login);
-        return $ptypes->getList();
+        return $ptypes->getList($schedulable);
     }
 
     /**
      * Get list
      *
+     * @param boolean $schedulable Types that can be used in schedules only
+     *
      * @return array<int, PaymentType>|ResultSet
      */
-    public function getList(): array|ResultSet
+    public function getList(bool $schedulable = true): array|ResultSet
     {
         try {
             $select = $this->zdb->select(PaymentType::TABLE, 'a');
             $select->order(PaymentType::PK);
 
+            if ($schedulable === false) {
+                $select->where->notEqualTo('a.' . PaymentType::PK, PaymentType::SCHEDULED);
+            }
+
             $types = array();
             $results = $this->zdb->execute($select);
             foreach ($results as $row) {
diff --git a/galette/lib/Galette/Repository/ScheduledPayments.php b/galette/lib/Galette/Repository/ScheduledPayments.php
new file mode 100644 (file)
index 0000000..f1b1083
--- /dev/null
@@ -0,0 +1,450 @@
+<?php
+
+/**
+ * Copyright © 2003-2024 The Galette Team
+ *
+ * This file is part of Galette (https://galette.eu).
+ *
+ * Galette is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Galette is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ *  GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Galette. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+namespace Galette\Repository;
+
+use Galette\Entity\ScheduledPayment;
+use Galette\Filters\ScheduledPaymentsList;
+use Laminas\Db\ResultSet\ResultSet;
+use Throwable;
+use Analog\Analog;
+use Laminas\Db\Sql\Expression;
+use Galette\Core\Db;
+use Galette\Core\Login;
+use Galette\Core\History;
+use Galette\Entity\Contribution;
+use Galette\Entity\Adherent;
+use Laminas\Db\Sql\Select;
+
+/**
+ * Scheduled payments class for galette
+ *
+ * @author Johan Cwiklinski <johan@x-tnd.be>
+ */
+class ScheduledPayments
+{
+    public const TABLE = ScheduledPayment::TABLE;
+    public const PK = ScheduledPayment::PK;
+
+    private ScheduledPaymentsList $filters;
+    private int $count = 0;
+
+    private Db $zdb;
+    private Login $login;
+    private float $sum = 0;
+    /** @var array<int> */
+    private array $current_selection;
+
+    /**
+     * Default constructor
+     *
+     * @param Db                     $zdb     Database
+     * @param Login                  $login   Login
+     * @param ?ScheduledPaymentsList $filters Filtering
+     */
+    public function __construct(Db $zdb, Login $login, ?ScheduledPaymentsList $filters = null)
+    {
+        $this->zdb = $zdb;
+        $this->login = $login;
+
+        if ($filters === null) {
+            $this->filters = new ScheduledPaymentsList();
+        } else {
+            $this->filters = $filters;
+        }
+    }
+
+    /**
+     * Get scheduled payments list for a specific contribution
+     *
+     * @param int $contrib_id Contribution identifier
+     *
+     * @return ScheduledPayment[]
+     */
+    public function getListFromContribution(int $contrib_id): array
+    {
+        $this->filters->from_contribution = $contrib_id;
+        /** @phpstan-ignore-next-line */
+        return $this->getList(true);
+    }
+
+    /**
+     * Get scheduled payments list for a specific contribution
+     *
+     * @param array<int>     $ids       an array of members id that has been selected
+     * @param bool           $as_object return the results as an array of
+     * @param ?array<string> $fields    field(s) name(s) to get. Should be a string or
+     *                                  an array. If null, all fields will be returned
+     *
+     * @return array<int, Contribution>|false
+     */
+    public function getArrayList(array $ids, bool $as_object = false, ?array $fields = null): array|false
+    {
+        if (count($ids) < 1) {
+            Analog::log('No scheduled payment selected.', Analog::INFO);
+            return false;
+        }
+
+        $this->current_selection = $ids;
+        $list = $this->getList($as_object, $fields);
+        $array_list = [];
+        foreach ($list as $entry) {
+            $array_list[] = $entry;
+        }
+        return $array_list;
+    }
+
+    /**
+     * Get scheduled payments list
+     *
+     * @param bool           $as_object return the results as an array of
+     *                                  ScheduledPayment object.
+     * @param ?array<string> $fields    field(s) name(s) to get. Should be a string or
+     *                                  an array. If null, all fields will be returned
+     *
+     * @return array<int, Contribution>|ResultSet
+     */
+    public function getList(bool $as_object = true, ?array $fields = null): array|ResultSet
+    {
+        try {
+            $select = $this->buildSelect($fields);
+
+            $this->filters->setLimits($select);
+
+            $scheduleds = array();
+            $results = $this->zdb->execute($select);
+            if ($as_object) {
+                foreach ($results as $row) {
+                    $scheduleds[] = new ScheduledPayment($this->zdb, $row);
+                }
+            } else {
+                $scheduleds = $results;
+            }
+            return $scheduleds;
+        } catch (Throwable $e) {
+            Analog::log(
+                'Cannot list scheduled payments | ' . $e->getMessage(),
+                Analog::WARNING
+            );
+            throw $e;
+        }
+    }
+
+    /**
+     * Builds the SELECT statement
+     *
+     * @param ?array<string> $fields fields list to retrieve
+     *
+     * @return Select SELECT statement
+     */
+    private function buildSelect(?array $fields): Select
+    {
+        try {
+            $fieldsList = ['*'];
+            if (is_array($fields) && count($fields)) {
+                $fieldsList = $fields;
+            }
+
+            $select = $this->zdb->select(self::TABLE, 's');
+            $select->columns($fieldsList);
+
+            $select->join(
+                array('c' => PREFIX_DB . Contribution::TABLE),
+                's.' . Contribution::PK . '= c.' . Contribution::PK,
+                array()
+            );
+
+            $select->join(
+                array('a' => PREFIX_DB . Adherent::TABLE),
+                'c.' . Adherent::PK . '= a.' . Adherent::PK,
+                array()
+            );
+
+            $this->buildWhereClause($select);
+            $select->order(self::buildOrderClause());
+
+            $this->calculateSum($select);
+
+            $this->proceedCount($select);
+
+            return $select;
+        } catch (Throwable $e) {
+            Analog::log(
+                'Cannot build SELECT clause for scheduled payments | ' . $e->getMessage(),
+                Analog::WARNING
+            );
+            throw $e;
+        }
+    }
+
+    /**
+     * Count scheduled payments from the query
+     *
+     * @param Select $select Original select
+     *
+     * @return void
+     */
+    private function proceedCount(Select $select): void
+    {
+        try {
+            $countSelect = clone $select;
+            $countSelect->reset($countSelect::COLUMNS);
+            $countSelect->reset($countSelect::ORDER);
+            $countSelect->columns(
+                array(
+                    self::PK => new Expression('COUNT(' . self::PK . ')')
+                )
+            );
+
+            $results = $this->zdb->execute($countSelect);
+            $result = $results->current();
+
+            $k = self::PK;
+            $this->count = $result->$k;
+            $this->filters->setCounter($this->count);
+        } catch (Throwable $e) {
+            Analog::log(
+                'Cannot count scheduled payments | ' . $e->getMessage(),
+                Analog::WARNING
+            );
+            throw $e;
+        }
+    }
+
+    /**
+     * Calculate sum of all selected scheduled payments
+     *
+     * @param Select $select Original select
+     *
+     * @return void
+     */
+    private function calculateSum(Select $select): void
+    {
+        try {
+            $sumSelect = clone $select;
+            $sumSelect->reset($sumSelect::COLUMNS);
+            $sumSelect->reset($sumSelect::ORDER);
+            $sumSelect->columns(
+                array(
+                    'scheduledsum' => new Expression('SUM(amount)')
+                )
+            );
+
+            $results = $this->zdb->execute($sumSelect);
+            $result = $results->current();
+            if ($result->scheduledsum) {
+                $this->sum = round($result->scheduledsum, 2);
+            }
+        } catch (Throwable $e) {
+            Analog::log(
+                'Cannot calculate scheduled payments sum | ' . $e->getMessage(),
+                Analog::WARNING
+            );
+            throw $e;
+        }
+    }
+
+    /**
+     * Builds the order clause
+     *
+     * @return array<string> SQL ORDER clauses
+     */
+    private function buildOrderClause(): array
+    {
+        $order = array();
+
+        switch ($this->filters->orderby) {
+            case ScheduledPaymentsList::ORDERBY_ID:
+                $order[] = ScheduledPayment::PK . ' ' . $this->filters->ordered;
+                break;
+            case ScheduledPaymentsList::ORDERBY_MEMBER:
+                $order[] = 'a.nom_adh ' . $this->filters->getDirection();
+                $order[] = 'a.prenom_adh ' . $this->filters->getDirection();
+                break;
+            case ScheduledPaymentsList::ORDERBY_DATE:
+                $order[] = 'creation_date ' . $this->filters->ordered;
+                break;
+            case ScheduledPaymentsList::ORDERBY_SCHEDULED_DATE:
+                $order[] = 'scheduled_date ' . $this->filters->ordered;
+                break;
+            case ScheduledPaymentsList::ORDERBY_CONTRIBUTION:
+                $order[] = Contribution::PK . ' ' . $this->filters->ordered;
+                break;
+            case ScheduledPaymentsList::ORDERBY_AMOUNT:
+                $order[] = 'amount ' . $this->filters->ordered;
+                break;
+            case ScheduledPaymentsList::ORDERBY_PAYMENT_TYPE:
+                $order[] = 'id_paymenttype ' . $this->filters->ordered;
+                break;
+            default:
+                $order[] = $this->filters->orderby . ' ' . $this->filters->ordered;
+                break;
+        }
+
+        return $order;
+    }
+
+    /**
+     * Builds where clause, for filtering on simple list mode
+     *
+     * @param Select $select Original select
+     *
+     * @return void
+     */
+    private function buildWhereClause(Select $select): void
+    {
+        switch ($this->filters->date_field) {
+            case ScheduledPaymentsList::DATE_RECORD:
+                $field = 'creation_date';
+                break;
+            case ScheduledPaymentsList::DATE_SCHEDULED:
+            default:
+                $field = 'scheduled_date';
+                break;
+        }
+
+        if (isset($this->current_selection)) {
+            $select->where->in('s.' . self::PK, $this->current_selection);
+        }
+
+        try {
+            if ($this->filters->start_date_filter != null) {
+                $d = new \DateTime($this->filters->rstart_date_filter);
+                $select->where->greaterThanOrEqualTo(
+                    $field,
+                    $d->format('Y-m-d')
+                );
+            }
+
+            if ($this->filters->end_date_filter != null) {
+                $d = new \DateTime($this->filters->rend_date_filter);
+                $select->where->lessThanOrEqualTo(
+                    $field,
+                    $d->format('Y-m-d')
+                );
+            }
+
+            if ($this->filters->payment_type_filter !== null) {
+                $select->where->equalTo(
+                    'id_paymenttype',
+                    $this->filters->payment_type_filter
+                );
+            }
+
+            if ($this->filters->from_contribution !== false) {
+                $select->where->equalTo(
+                    'c.' . Contribution::PK,
+                    $this->filters->from_contribution
+                );
+            }
+
+            if (!$this->login->isAdmin() && !$this->login->isStaff()) {
+                $select->where(
+                    array(
+                        'a.' . Adherent::PK => $this->login->id
+                    )
+                );
+            }
+        } catch (Throwable $e) {
+            Analog::log(
+                __METHOD__ . ' | ' . $e->getMessage(),
+                Analog::WARNING
+            );
+            throw $e;
+        }
+    }
+
+    /**
+     * Get count for current query
+     *
+     * @return int
+     */
+    public function getCount(): int
+    {
+        return $this->count;
+    }
+
+    /**
+     * Get sum
+     *
+     * @return float
+     */
+    public function getSum(): float
+    {
+        return $this->sum;
+    }
+
+    /**
+     * Remove specified scheduled payments
+     *
+     * @param integer|array<int> $ids         Scheduled payments identifiers to delete
+     * @param History            $hist        History
+     * @param boolean            $transaction True to begin a database transaction
+     *
+     * @return boolean
+     */
+    public function remove(int|array $ids, History $hist, bool $transaction = true): bool
+    {
+        $list = array();
+        if (is_array($ids)) {
+            $list = $ids;
+        } else {
+            $list = [$ids];
+        }
+
+        try {
+            if ($transaction) {
+                $this->zdb->connection->beginTransaction();
+            }
+            $select = $this->zdb->select(self::TABLE);
+            $select->where->in(self::PK, $list);
+            $scheduleds = $this->zdb->execute($select);
+            foreach ($scheduleds as $scheduled) {
+                $c = new ScheduledPayment($this->zdb, $scheduled);
+                $res = $c->remove();
+                if ($res === false) {
+                    throw new \Exception();
+                }
+            }
+            if ($transaction) {
+                $this->zdb->connection->commit();
+            }
+            $hist->add(
+                str_replace(
+                    '%list',
+                    print_r($list, true),
+                    _T("Scheduled payments deleted (%list)")
+                )
+            );
+            return true;
+        } catch (Throwable $e) {
+            if ($transaction) {
+                $this->zdb->connection->rollBack();
+            }
+            Analog::log(
+                'An error occurred trying to remove scheduled payments | ' .
+                $e->getMessage(),
+                Analog::ERROR
+            );
+            throw $e;
+        }
+    }
+}
index 055bea1df6a550a966921d723328df7260aaa496..f3550cb3c636829581173ad2270a7f8049be734d 100644 (file)
  * along with Galette. If not, see <http://www.gnu.org/licenses/>.
  */
 #}
-{% if show_inline is not defined %}
-<div class="field">
-{% else %}
-<div class="inline field">
-{% endif %}
+<div class="{% if show_inline is defined %}inline {% endif %}field{% if required is defined and required == true %} required{% endif %}">
     <label for="{{ varname }}">{% if label is defined %}{{ label }}{% else %}{{ _T("Payment type:") }}{% endif %}</label>
-    <select name="{{ varname }}" id="{{ varname }}" class="ui search dropdown">
+    <select
+        name="{{ varname }}"
+        id="{{ varname }}"
+        {% if required is defined and required == true %} required="required"{% endif %}
+        {% if disabled is defined and disabled == true %} disabled="disabled"{% endif %}
+        class="ui search dropdown"
+    >
 {% if empty is defined %}
         <option value="{{ empty.value }}">{{ empty.label }}</option>
 {% endif %}
-{% set ptypes = callstatic('\\Galette\\Repository\\PaymentTypes', 'getAll') %}
+{% if ptypes is not defined %}
+    {% set ptypes = callstatic('\\Galette\\Repository\\PaymentTypes', 'getAll') %}
+{% endif %}
 {% for ptype in ptypes %}
         <option value="{{ ptype.id }}"{% if current == ptype.id %} selected="selected"{% endif %}>{{ ptype.getName() }}</option>
 {% endfor %}
index 7f03ee6459e521fb0e91e35fe4367de1915d3621..a40a49b351910bf3f331de4ca6d7ff279b3bbe57 100644 (file)
@@ -97,7 +97,7 @@
                                     {% endif %}
                                 </th>
                             {% endfor %}
-                            {% if mode != 'ajax' and no_action is not defined or no_action == false %}
+                            {% if mode != 'ajax' and (no_action is not defined or no_action == false) %}
                                 <th class="actions_row">{{ _T('Actions') }}</th>
                             {% endif %}
                         {% endblock %}
index 695e8ff5da75cd0b39b86142869825a3cab689e4..66da420c4769c21e663fcb8371c6720d61112b92 100644 (file)
                     <span class="ui teal horizontal label">
                         {{ _T("Transaction related") }}
                     </span>
+    {% endif %}
+    {% if contribution.hasSchedule() %}
+                    <span class="ui teal horizontal label">
+                        {{ _T("Has scheduled payments") }}
+                    </span>
     {% endif %}
                 </div>
                 <div class="active content">
                     <div class="ui mobile reversed stackable grid">
-                        <div class="{% if contribution.isTransactionPart() %}five wide {% endif %}column">
+                        <div class="{% if contribution.isTransactionPart() %}six wide {% endif %}column">
     {% if not require_mass %}
                             <div class="inline field">
                                 <label for="id_adh">{{ _T("Contributor:") }}</label>
                                     </div>
                                 </div>
                             </div>
-    {% if contribution.isTransactionPart() and contribution.transaction.getMissingAmount() %}
-                            <div class="inline field">
-                                <label>{{ _T("Dispatch type:") }}</label>
-                                <i class="circular inverted primary small icon info tooltip" title="{{ _T("Select a contribution type to create for dispatch transaction") }}" aria-hidden="true"></i>
-                                <input type="radio" name="contrib_type" id="contrib_type_fee" value="{{ constant('Galette\\Entity\\Contribution::TYPE_FEE') }}" checked="checked"/> <label for="contrib_type_fee">{{ _T("Membership fee") }}</label>
-                                <input type="radio" name="contrib_type" id="contrib_type_donation" value="donation"/> <label for="contrib_type_donation">{{ _T("Donation") }}</label>
-                            </div>
-    {% endif %}
                         </div>
-    {% if contribution.isTransactionPart() %}
-                        <div class="eleven wide column">
-        {% set mid = contribution.transaction.member %}
-                            <div class="ui tiny header">{{ _T("Related transaction information") }}</div>
-                            <table id="transaction_detail" class="listing ui very compact yellow table">
-                                <thead>
-                                    <tr>
-                                        <th class="listing">#</th>
-                                        <th class="listing">{{ _T("Description") }}</th>
-                                        <th class="listing">{{ _T("Date") }}</th>
-                                        <th class="listing">{{ _T("Member") }}</th>
-                                        <th class="listing">{{ _T("Amount") }}</th>
-                                        <th class="listing">{{ _T("Not dispatched amount") }}</th>
-                                    </tr>
-                                </thead>
-    {% if contribution.id and contribution.isTransactionPart() %}
-                                <tfoot>
-                                    <tr>
-                                        <td colspan="6">
-                                            <div class="ui basic fitted right aligned segment">
-                                                <a
-                                                    href="{{ url_for("editTransaction", {"id": contribution.transaction.id}) }}"
-                                                    class="ui icon blue compact button tooltip"
-                                                >
-                                                    <i class="eye icon tooltip" aria-hidden="true"></i>
-                                                    <span class="ui special popup">{{ _T("View transaction") }}</span>
-                                                </a>
-        {% if contribution.transaction.getMissingAmount() > 0 %}
-                                                <a
-                                                    href="{{ url_for("addContribution", {"type": constant('Galette\\Entity\\Contribution::TYPE_FEE')}) }}?trans_id={{ contribution.transaction.id }}"
-                                                    class="ui icon green compact button tooltip"
-                                                    title="{{ _T("Create a new fee that will be attached to the current transaction") }}"
-                                                >
-                                                    <i class="plus tiny icon" aria-hidden="true"></i>
-                                                    <i class="user check icon" aria-hidden="true"></i>
-                                                    <span class="visually-hidden">{{ _T("Create a new fee that will be attached to the current transaction") }}</span>
-                                                </a>
+    {% if contribution.isTransactionPart() or contribution.hasSchedule() %}
+                        <div class="ten wide column">
+        {% if contribution.isTransactionPart() %}
+                            <div class="ui yellow segment">
+                                <div class="ui tiny header">
+                                    <a
+                                        href="{{ url_for("editTransaction", {"id": contribution.transaction.id}) }}"
+                                        class="ui mini icon blue compact button tooltip"
+                                    >
+                                        <i class="eye icon tooltip" aria-hidden="true"></i>
+                                        <span class="ui special popup">{{ _T("View transaction") }}</span>
+                                    </a>
+                                    {{ _T("Related transaction information") }}
+                                </div>
+                                <div class="content">
+                                    <div class="ui relaxed horizontal list">
+                                        <div class="item">
+                                            <div class="content">
+                                                <div class="header">{{ _T("Date") }}</div>
+                                                {{ contribution.transaction.date }}
+                                            </div>
+                                        </div>
+                                        <div class="item">
+                                            <div class="content">
+                                                <div class="header">{{ _T("Member") }}</div>
+                                                {{ memberName({'id': contribution.transaction.member}) }}
+                                            </div>
+                                        </div>
+                                        <div class="item">
+                                            <div class="content">
+                                                <div class="header">{{ _T("Amount") }}</div>
+                                                {{ contribution.transaction.amount }}
+                                            </div>
+                                        </div>
+            {% if contribution.transaction.getMissingAmount() > 0 %}
+                                        <div class="item">
+                                            <div class="content">
+                                                <div class="header">{{ _T("Not dispatched amount") }}</div>
+                                                {{ contribution.transaction.getMissingAmount() }}
+                {% if contribution.id != '' %}
+                                                    <a
+                                                        href="{{ url_for("addContribution", {"type": constant('Galette\\Entity\\Contribution::TYPE_FEE')}) }}?trans_id={{ contribution.transaction.id }}"
+                                                        class="ui mini icon green compact button tooltip"
+                                                        title="{{ _T("Create a new fee that will be attached to the current transaction") }}"
+                                                    >
+                                                        <i class="plus tiny icon" aria-hidden="true"></i>
+                                                        <i class="user check icon" aria-hidden="true"></i>
+                                                        <span class="visually-hidden">{{ _T("Create a new fee that will be attached to the current transaction") }}</span>
+                                                    </a>
+                                                    <a
+                                                        href="{{ url_for("addContribution", {"type": constant('Galette\\Entity\\Contribution::TYPE_DONATION')}) }}?trans_id={{ contribution.transaction.id }}"
+                                                        class="ui mini icon green compact button tooltip"
+                                                        title="{{ _T("Create a new donation that will be attached to the current transaction") }}"
+                                                    >
+                                                        <i class="plus tiny icon" aria-hidden="true"></i>
+                                                        <i class="gift icon" aria-hidden="true"></i>
+                                                        <span class="visually-hidden">{{ _T("Create a new donation that will be attached to the current transaction") }}</span>
+                                                    </a>
+                {% endif %}
+                                            </div>
+                                        </div>
+            {% endif %}
+                                    </div>
+            {% if contribution.id == '' %}
+                                    <div class="inline field">
+                                        <label>{{ _T("Dispatch type:") }}</label>
+                                        <i class="circular inverted primary small icon info tooltip" title="{{ _T("Select a contribution type to create for dispatch transaction") }}" aria-hidden="true"></i>
+                                        <input type="radio" name="contrib_type" id="contrib_type_fee" value="{{ constant('Galette\\Entity\\Contribution::TYPE_FEE') }}"{% if type == constant('Galette\\Entity\\Contribution::TYPE_FEE') %} checked="checked"{% endif %}/> <label for="contrib_type_fee">{{ _T("Membership fee") }}</label>
+                                        <input type="radio" name="contrib_type" id="contrib_type_donation" value="{{ constant('Galette\\Entity\\Contribution::TYPE_DONATION') }}"{% if type == constant('Galette\\Entity\\Contribution::TYPE_DONATION') %} checked="checked"{% endif %}/> <label for="contrib_type_donation">{{ _T("Donation") }}</label>
+                                    </div>
+            {% endif %}
+                                </div>
+                            </div>
+        {% endif %}
+        {% if contribution.hasSchedule() %}
+            {% set scheduled_amount = scheduled.getAllocation(contribution.id) %}
+                            <div class="ui yellow segment">
+                                <div class="ui tiny header">
+                                    <a
+                                        href="#"
+                                        class="ui mini icon blue compact button tooltip"
+                                        id="scheduledslist"
+                                    >
+                                        <i class="eye icon tooltip" aria-hidden="true"></i>
+                                        <span class="ui special popup">{{ _T("View scheduled payments") }}</span>
+                                    </a>
+                                    {{ _T("Scheduled payments") }}
+                                </div>
+            {% if not contribution.isScheduleFullyAllocated() %}
+                                <div class="content">
+                                    <div class="ui relaxed horizontal list">
+                                        <div class="item">
+                                            <div class="content">
+                                                <div class="header">{{ _T("Amount") }}</div>
+                                                {{ scheduled_amount }}
+                                            </div>
+                                        </div>
+                                        <div class="item">
+                                            <div class="content">
+                                                <div class="header">{{ _T("Not dispatched amount") }}</div>
+                                                {{ contribution.amount - scheduled_amount }}
                                                 <a
-                                                    href="{{ url_for("addContribution", {"type": constant('Galette\\Entity\\Contribution::TYPE_DONATION')}) }}?trans_id={{ contribution.transaction.id }}"
-                                                    class="ui icon green compact button tooltip"
-                                                    title="{{ _T("Create a new donation that will be attached to the current transaction") }}"
+                                                    href="{{ url_for("addScheduledPayment", {"id_cotis": contribution.id}) }}"
+                                                    class="ui mini icon green compact button tooltip"
+                                                    title="{{ _T("Create a new scheduled payment") }}"
                                                 >
                                                     <i class="plus tiny icon" aria-hidden="true"></i>
-                                                    <i class="gift icon" aria-hidden="true"></i>
-                                                    <span class="visually-hidden">{{ _T("Create a new donation that will be attached to the current transaction") }}</span>
+                                                    <i class="money bill wave icon" aria-hidden="true"></i>
+                                                    <span class="visually-hidden">{{ _T("Create a new scheduled payment") }}</span>
                                                 </a>
-        {% endif %}
                                             </div>
-
-                                        </td>
-                                    </tr>
-                                </tfoot>
-    {% endif %}
-                                <tbody>
-                                    <tr>
-                                        <td>{{ contribution.transaction.id }}</td>
-                                        <td>{{ contribution.transaction.description }}</td>
-                                        <td>{{ contribution.transaction.date }}</td>
-                                        <td>{{ memberName({'id': mid}) }}</td>
-                                        <td class="right">{{ contribution.transaction.amount }}</td>
-                                        <td class="right">{{ contribution.transaction.getMissingAmount() }}</td>
-                                    </tr>
-                                </tbody>
-                            </table>
+                                        </div>
+                                    </div>
+                                </div>
+            {% endif %}
+                            </div>
+        {% endif %}
                         </div>
     {% endif %}
                     </div>
                             {% endif %}
                             {% include 'components/forms/payment_types.html.twig' with {
                                     'current': ptype,
-                                    'varname': 'type_paiement_cotis'
+                                    'varname': 'type_paiement_cotis',
+                                    'disabled': contribution.hasSchedule()
                             } %}
                         </div>
                         <div class="{% if type == constant('Galette\\Entity\\Contribution::TYPE_FEE') %}three{% else %}two{% endif %} fields">
     {% endif %}
 
     $(function() {
-        {% if not contribution.id -%}
+    {% if not contribution.id -%}
         var _types_amounts = {
             {%- for key, values in type_cotis_options -%}
                {%- if values.amount > 0 -%}
             }
         });
     {% endif %}
+
+    {% if contribution.hasSchedule() %}
+            {# Contributions popup #}
+            var _btnuser_mapping = function(){
+                $('#scheduledslist').click(function(){
+                    $.ajax({
+                        url: '{{ url_for("scheduledPayments") }}',
+                        type: "GET",
+                        data: {
+                            ajax: true,
+                            id_cotis: '{{ contribution.id }}'
+                        },
+                        {% include "elements/js/loader.js.twig" with {
+                            selector: '#scheduledslist',
+                            loader: 'button'
+                        } %},
+                        success: function(res){
+                            _contribs_dialog(res, '{{ contribution.id }}');
+                        },
+                        error: function() {
+                            {% include "elements/js/modal.js.twig" with {
+                                modal_title_twig: _T("An error occurred displaying scheduled payments :(")|e("js"),
+                                modal_without_content: true,
+                                modal_class: "mini",
+                                modal_deny_only: true,
+                                modal_cancel_text: _T("Close")|e("js"),
+                                modal_classname: "redalert",
+                            } %}
+                        }
+                    });
+                });
+            }
+            _btnuser_mapping();
+
+            var _contribs_dialog = function(res, id_cotis){
+                {% include "elements/js/modal.js.twig" with {
+                    modal_title_twig: _T("Scheduled payments")|e("js"),
+                    modal_content: "res",
+                    modal_class: "scheduledpayments fullscreen",
+                    modal_content_class: "scrolling",
+                    modal_deny_only: true,
+                    modal_cancel_text: _T("Close")|e('js')
+                } %}
+                _contribs_ajax_mapper(res, id_cotis);
+            }
+
+            var _contribs_ajax_mapper = function(res, id_cotis){
+                /*$('.scheduledpayments .filter.icon').remove();
+                $('.scheduledpayments .infoline .button').remove();
+                $('.scheduledpayments .contribution_row input[type=checkbox]').hide();
+
+                //Initialize Fomantic components
+                $('.scheduledpayments .dropdown').dropdown();
+                {% include "elements/js/calendar.js.twig" %}
+
+                //Deactivate contributions list links
+                $('.scheduledpayments tbody a').click(function(){
+                    //for links in body (members links), we de nothing
+                    return false;
+                });
+
+                //Use JS to send forms
+                $('.scheduledpayments form').on('submit', function(){
+                    var _form = $(this);
+                    $.ajax({
+                        url: _form.attr('action'),
+                        type: "POST",
+                        data: _form.serialize(),
+                        {% include "elements/js/loader.js.twig" with {
+                            selector: '.scheduledpayments'
+                        } %},
+                        success: function(res){
+                            $('#main-container').remove();
+                            $('.scheduledpayments .content').append(res);
+                            _contribs_ajax_mapper(res, max_amount);
+                        },
+                        error: function() {
+                            {% include "elements/js/modal.js.twig" with {
+                                modal_title_twig: _T("An error occurred displaying contributions :(")|e("js"),
+                                modal_without_content: true,
+                                modal_class: "mini",
+                                modal_deny_only: true,
+                                modal_cancel_text: _T("Close")|e("js"),
+                                modal_classname: "redalert",
+                            } %}
+                        }
+                    });
+                    return false;
+                });
+
+                _bindDropdownsAutosubmit();*/
+
+                //Bind pagination and ordering links
+                /*$('.scheduledpayments .pagination a, .scheduledpayments thead a').click(function() {
+                    $.ajax({
+                        url: this.href,
+                        type: "GET",
+                        data: {
+                            ajax: true,
+                            max_amount: max_amount
+                        },
+                        {% include "elements/js/loader.js.twig" with {
+                            selector: '.scheduledpayments'
+                        } %},
+                        success: function(res){
+                            $('#main-container').remove();
+                            $('.scheduledpayments .content').append(res);
+                            _contribs_ajax_mapper(res, max_amount);
+                        },
+                        error: function() {
+                            {% include "elements/js/modal.js.twig" with {
+                                modal_title_twig: _T("An error occurred displaying contributions :(")|e("js"),
+                                modal_without_content: true,
+                                modal_class: "mini",
+                                modal_deny_only: true,
+                                modal_cancel_text: _T("Close")|e("js"),
+                                modal_classname: "redalert",
+                            } %}
+                        },
+                    });
+                    return false;
+                });*/
+
+                //Bind reset filters button
+                /*$('#clear_filter').click(function(event) {
+                    var _this = $(this);
+                    _this.closest('form').submit(function(event) {
+                        var _form = $(this);
+                        $.ajax({
+                            url: _form.attr('action'),
+                            type: "POST",
+                            data: {
+                                clear_filter: true
+                            },
+                            {% include "elements/js/loader.js.twig" with {
+                                selector: '.scheduledpayments'
+                            } %},
+                            success: function(res){
+                                $('#main-container').remove();
+                                $('.scheduledpayments .content').append(res);
+                                _contribs_ajax_mapper(res, max_amount);
+                            },
+                            error: function() {
+                                {% include "elements/js/modal.js.twig" with {
+                                    modal_title_twig: _T("An error occurred displaying contributions :(")|e("js"),
+                                    modal_without_content: true,
+                                    modal_class: "mini",
+                                    modal_deny_only: true,
+                                    modal_cancel_text: _T("Close")|e("js"),
+                                    modal_classname: "redalert",
+                                } %}
+                            }
+                        });
+                    });
+                });*/
+            }
+    {% endif %}
     });
 </script>
 {% endblock %}
index 17205403bdab6205054b43910d5aa6e99b45493c..ab46658845807c6f271c063ae5b4b748c1d9e219 100644 (file)
@@ -94,7 +94,6 @@
                 {% include "components/forms/payment_types.html.twig" with {
                     current: filters.payment_type_filter,
                     varname: "payment_type_filter",
-                    show_inline: "",
                     classname: "",
                     empty: {'value': -1, 'label': _T("Select")}
                 } %}
 {% block footer %}
     {% if nb != 0 %}
         <tr>
-            <th class="right" colspan="{% if (login.isAdmin() or login.isStaff()) and member is not defined %}10{% else %}9{% endif %}">
+            <th class="right aligned" colspan="{% if (login.isAdmin() or login.isStaff()) and member is not defined %}10{% else %}9{% endif %}">
                 {{ _T("Found contributions total %f")|replace({"%f": contribs.getSum()}) }}
             </th>
         </tr>
diff --git a/galette/templates/default/pages/scheduledpayment_form.html.twig b/galette/templates/default/pages/scheduledpayment_form.html.twig
new file mode 100644 (file)
index 0000000..4002e50
--- /dev/null
@@ -0,0 +1,175 @@
+{#
+/**
+ * Copyright © 2003-2024 The Galette Team
+ *
+ * This file is part of Galette (https://galette.eu).
+ *
+ * Galette is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Galette is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ *  GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Galette. If not, see <http://www.gnu.org/licenses/>.
+ */
+#}
+{% extends (mode == 'ajax') ? "ajax.html.twig" : "page.html.twig" %}
+
+{% block content %}
+    <form action="{% if scheduled.getId() is null %}{{ url_for('doAddScheduledPayment', {'id_cotis': scheduled.getContribution().id}) }}{% else %}{{ url_for('doEditScheduledPayment', {'id': scheduled.getId()}) }}{% endif %}" method="post" class="ui form">
+        <div class="ui styled fluid accordion field">
+            <div class="active title">
+                <i class="jsonly displaynone icon dropdown" aria-hidden="true"></i>
+                {{ _T("Scheduled payment") }}
+            </div>
+            <div class="active content">
+                <div class="ui mobile reversed stackable grid">
+                    <div class="six wide column">
+                        <div class="inline field required">
+                            <label for="montant_cotis">{{ _T("Amount") }}</label>
+                            <input type="text" name="amount" id="amount" value="{% if scheduled.getId() %}{{ scheduled.getAmount() }}{% else %}{{ scheduled.getMissingAmount() }}{% endif %}" maxlength="10" required="required"/>
+                        </div>
+    {# payment type #}
+    {% set ptype = scheduled.getPaymentType().id %}
+    {% if ptype == null %}
+        {% set ptype = preferences.pref_default_paymenttype %}
+    {% endif %}
+
+    {% set ptypes = callstatic('\\Galette\\Repository\\PaymentTypes', 'getAll', false) %}
+    {% include 'components/forms/payment_types.html.twig' with {
+        'current': ptype,
+        'varname': 'id_paymenttype',
+        'show_inline': true,
+        'component_class': 'inline field',
+        'required': true
+    } %}
+                        <div class="inline field">
+                            <label for="paid">{{ _T('Paid') }}</label>
+                            <input type="checkbox" name="paid" id="paid" value="1"{% if scheduled.isPaid() %} checked="checked"{% endif %}/>
+                        </div>
+                    </div>
+                    <div class="ten wide column">
+    {% set contribution = scheduled.getContribution() %}
+                            <div class="ui yellow segment">
+                                <div class="ui tiny header">
+                                    <a
+                                        href="{{ url_for("editContribution", {"type": contribution.isFee() ? constant('Galette\\Entity\\Contribution::TYPE_FEE') : constant('Galette\\Entity\\Contribution::TYPE_DONATION'), "id": contribution.id}) }}"
+                                        class="ui mini icon blue compact button tooltip"
+                                    >
+                                        <i class="eye icon tooltip" aria-hidden="true"></i>
+                                        <span class="ui special popup">{{ _T("View contribution") }}</span>
+                                    </a>
+                                    {{ _T("Related contribution information") }}
+                                </div>
+                                <div class="content">
+                                    <div class="ui relaxed horizontal list">
+                                        <div class="item">
+                                            <div class="content">
+                                                <div class="header">{{ _T("Type") }}</div>
+                                                {{ contribution.type.libelle }}
+                                            </div>
+                                        </div>
+                                        <div class="item">
+                                            <div class="content">
+                                                <div class="header">{{ _T("Begin") }}</div>
+                                                {{ contribution.begin_date }}
+                                            </div>
+                                        </div>
+                                        <div class="item">
+                                            <div class="content">
+                                                <div class="header">{{ _T("End") }}</div>
+                                                {{ contribution.end_date }}
+                                            </div>
+                                        </div>
+            {% if ((login.isAdmin() or login.isStaff()) and member is not defined) or pmember is defined %}
+                                        <div class="item">
+                                            <div class="content">
+                                                <div class="header">{{ _T("Member") }}</div>
+                                                {{ memberName({'id': contribution.member}) }}
+                                            </div>
+                                        </div>
+            {% endif %}
+                                        <div class="item">
+                                            <div class="content">
+                                                <div class="header">{{ _T("Amount") }}</div>
+                                                {{ contribution.amount }}
+                                            </div>
+                                        </div>
+            {% if not contribution.isScheduleFullyAllocated() %}
+                                        <div class="item">
+                                            <div class="content">
+                                                <div class="header">{{ _T("Not dispatched amount") }}</div>
+                                                {{ scheduled.getMissingAmount() }}
+                {% if scheduled.getId() != '' %}
+                                                    <a
+                                                        href="{{ url_for("addScheduledPayment", {"id_cotis": contribution.id}) }}"
+                                                        class="ui mini icon green compact button tooltip"
+                                                        title="{{ _T("Create a new scheduled payment") }}"
+                                                    >
+                                                        <i class="plus tiny icon" aria-hidden="true"></i>
+                                                        <i class="money bill wave icon" aria-hidden="true"></i>
+                                                        <span class="visually-hidden">{{ _T("Create a new scheduled payment") }}</span>
+                                                    </a>
+                {% endif %}
+                                            </div>
+                                        </div>
+            {% endif %}
+                                    </div>
+                                </div>
+                            </div>
+                        </div>
+                    <div class="sixteen wide column">
+                        <div class="two fields">
+                            <div class="inline field required">
+                                <label for="creation_date">
+                                    {{ _T("Record date") }}
+                                </label>
+                                <div class="ui calendar" id="scheduled-creationdate">
+                                    <div class="ui input left icon">
+                                        <i class="calendar icon" aria-hidden="true"></i>
+                                        <input type="text" name="creation_date" id="creation_date" value="{{ scheduled.getCreationDate() }}" maxlength="10" required="required" placeholder="{{ _T('yyyy-mm-dd format') }}" />
+                                    </div>
+                                </div>
+                            </div>
+                            <div class="inline field required">
+                                <label for="scheduled_date">{{ _T("Scheduled date") }}</label>
+                                <div class="ui calendar" id="scheduled-scheduleddate">
+                                    <div class="ui input left icon">
+                                        <i class="calendar icon" aria-hidden="true"></i>
+                                        <input type="text" name="scheduled_date" id="scheduled_date" value="{{ scheduled.getScheduledDate() }}" maxlength="10" required="required" placeholder="{{ _T('yyyy-mm-dd format') }}" />
+                                    </div>
+                                </div>
+                            </div>
+                        </div>
+                        <div class="field">
+                            <label for="comment">{{ _T("Comments") }}</label>
+                            <textarea name="comment" id="comment" cols="61" rows="6">{{ scheduled.getComment() }}</textarea>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </div>
+
+    {% if mode != 'ajax' %}
+        <div class="ui basic center aligned segment">
+            <button type="submit" class="ui labeled icon primary button action">
+                <i class="save icon" aria-hidden="true"></i> {{ _T("Save") }}
+            </button>
+            <input type="submit" name="cancel" value="{{ _T("Cancel") }}" class="ui button"/>
+            <input type="hidden" name="id_cotis" id="id_cotis" value="{{ scheduled.getContribution().id }}"/>
+    {% endif %}
+    {% if scheduled.getId() %}
+            <input type="hidden" name="id" id="id" value="{{ scheduled.getId() }}"/>
+    {% endif %}
+
+            {% include 'components/forms/csrf.html.twig' %}
+    {% if mode != 'ajax' %}
+        </div>
+    {% endif %}
+     </form>
+{% endblock %}
diff --git a/galette/templates/default/pages/scheduledpayments_list.html.twig b/galette/templates/default/pages/scheduledpayments_list.html.twig
new file mode 100644 (file)
index 0000000..5eaccb2
--- /dev/null
@@ -0,0 +1,212 @@
+{#
+/**
+ * Copyright © 2003-2024 The Galette Team
+ *
+ * This file is part of Galette (https://galette.eu).
+ *
+ * Galette is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Galette is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ *  GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Galette. If not, see <http://www.gnu.org/licenses/>.
+ */
+#}
+{% extends 'elements/list.html.twig' %}
+
+{% set form = {
+    'route': {
+        'name': "batch-scheduledPaymentslist"
+    },
+    'order': {
+        'name': "scheduledPayments"
+    }
+} %}
+
+{% if nb != 0 and (login.isAdmin() or login.isStaff()) and mode != 'ajax' %}
+    {% set batch = {
+        'route': {
+            'name': 'batch-scheduledPaymentslist'
+        },
+        'modal': {
+            'title': _T("No entry selected"),
+            'content': _T("Please make sure to select at least one entry from the list to perform this action.")
+        }
+    } %}
+    {% set batch_actions = [
+        {
+            'name': 'delete',
+            'label': _T("Delete"),
+            'icon': 'trash red'
+        },
+        {
+            'name': 'csv__directdownload',
+            'label': _T("Export as CSV"),
+            'icon': 'file csv'
+        }
+    ] %}
+{% endif %}
+
+{% block search %}
+    <form action="{{ url_for("filterScheduledPayments") }}" method="post" class="ui form filters">
+    <div class="ui secondary yellow segment">
+        <div class="four fields">
+            <div class="field">
+                <label for="date_field">{{ _T("Show contributions by") }}</label>
+                <select name="date_field" id="date_field" class="ui search dropdown">
+                    <option value="{{ constant("Galette\\Filters\\ScheduledPaymentsList::DATE_RECORD") }}"{% if filters.date_field == constant('Galette\\Filters\\ScheduledPaymentsList::DATE_RECORD') %} selected="selected"{% endif %}>{{ _T("Date") }}</option>
+                    <option value="{{ constant("Galette\\Filters\\ScheduledPaymentsList::DATE_SCHEDULED") }}"{% if filters.date_field == constant('Galette\\Filters\\ScheduledPaymentsList::DATE_SCHEDULED') %} selected="selected"{% endif %}>{{ _T("Scheduled date") }}</option>
+                </select>
+            </div>
+            <div class="two fields">
+                <div class="field">
+                    <label for="start_date_filter">{{ _T("since") }}</label>
+                    <div class="ui calendar" id="contrib-rangestart">
+                        <div class="ui input left icon">
+                            <i class="calendar icon" aria-hidden="true"></i>
+                            <input placeholder="{{ _T("yyyy-mm-dd format") }}" type="text" name="start_date_filter" id="start_date_filter" maxlength="10" size="10" value="{{ filters.start_date_filter }}"/>
+                        </div>
+                    </div>
+                </div>
+                <div class="field">
+                    <label for="end_date_filter">{{ _T("until") }}</label>
+                    <div class="ui calendar" id="contrib-rangeend">
+                        <div class="ui input left icon">
+                            <i class="calendar icon" aria-hidden="true"></i>
+                            <input placeholder="{{ _T("yyyy-mm-dd format") }}" type="text" name="end_date_filter" id="end_date_filter" maxlength="10" size="10" value="{{ filters.end_date_filter }}"/>
+                        </div>
+                    </div>
+                </div>
+            </div>
+            <div class="field">
+                {% include "components/forms/payment_types.html.twig" with {
+                    current: filters.payment_type_filter,
+                    varname: "payment_type_filter",
+                    classname: "",
+                    empty: {'value': -1, 'label': _T("Select")}
+                } %}
+            </div>
+                <div class="ui right aligned basic fitted segment field flexend">
+                    <button type="submit"  class="tooltip ui labeled icon primary button" title="{{ _T("Apply filters") }}" name="filter">
+                        <i class="search icon" aria-hidden="true"></i>
+                        {{ _T("Filter") }}
+                    </button>
+                    <button type="submit" id="clear_filter" name="clear_filter" class="tooltip ui labeled icon button" title="{{ _T("Reset all filters to defaults") }}">
+                        <i class="trash alt red icon" aria-hidden="true"></i>
+                        {{ _T("Clear filter") }}
+                    </button>
+            </div>
+        </div>
+    </div>
+
+    {% if mode == 'ajax'%}
+        <input type="hidden" name="ajax" value="true"/>
+        <input type="hidden" name="from_contribution" value="{{ filters.from_contribution }}"/>
+    {% else %}
+        {% include "components/forms/csrf.html.twig" %}
+    {% endif %}
+    </form>
+{% endblock %}
+
+{% block infoline %}
+    {% set infoline = {
+        'label': _Tn("%count shceduled payment", "%count scheduled payments", nb)|replace({"%count": nb}),
+        'route': {
+            'name': 'filterScheduledPayments'
+        }
+    } %}
+    {{ parent() }}
+{% endblock %}
+
+{% block header %}
+    {% set columns = [
+        {'label': '#', 'order': constant("Galette\\Filters\\ScheduledPaymentsList::ORDERBY_ID")},
+        {'label': _T("Member"), 'order': constant("Galette\\Filters\\ScheduledPaymentsList::ORDERBY_MEMBER")},
+        {'label': _T("Date"), 'order': constant("Galette\\Filters\\ScheduledPaymentsList::ORDERBY_DATE")},
+        {'label': _T("Scheduled date"), 'order': constant("Galette\\Filters\\ScheduledPaymentsList::ORDERBY_SCHEDULED_DATE")},
+        {'label': _T('Amount'), 'order': constant("Galette\\Filters\\ScheduledPaymentsList::ORDERBY_AMOUNT")},
+        {'label': _T("Payment type"), 'order': constant("Galette\\Filters\\ScheduledPaymentsList::ORDERBY_PAYMENT_TYPE")}
+    ] %}
+
+    {% if (not login.isAdmin() and not login.isStaff()) or mode == 'ajax' %}
+        {% set no_action = true %}
+    {% endif %}
+
+    {{ parent() }}
+{% endblock %}
+
+{% block footer %}
+    {% if nb != 0 %}
+        <tr>
+            <th class="right aligned" colspan="{% if (login.isAdmin() or login.isStaff()) and member is not defined %}7{% else %}6{% endif %}">
+                {{ _T("Found total scheduled %f")|replace({"%f": scheduled.getSum()}) }}
+            </th>
+        </tr>
+    {% endif %}
+{% endblock %}
+
+{% block body %}
+    {% for ordre, scheduled in list %}
+        {% set contribution = scheduled.getContribution() %}
+        {% if contribution.isFee() %}
+            {% set ctype = constant('Galette\\Entity\\Contribution::TYPE_FEE') %}
+        {% else %}
+            {% set ctype = constant('Galette\\Entity\\Contribution::TYPE_DONATION') %}
+        {% endif %}
+
+        <tr class="{% if mode == 'ajax' %}schedule_row {% endif %}" id="row_{{ scheduled.getId() }}">
+            <td data-scope="row">
+                <input type="checkbox" name="entries_sel[]" value="{{ scheduled.getId() }}"/>
+                {% if preferences.pref_show_id %}
+                    {{ scheduled.getId() }}
+                {% else %}
+                    {{ ordre + 1 + (filters.current_page - 1) * numrows }}
+                {% endif %}
+                {% if (login.isAdmin() or login.isStaff()) and mode != 'ajax' %}
+                    <span>
+                        <a href="{{ url_for("editContribution", {"type": ctype, "id": contribution.id}) }}">
+                            <i
+                                class="ui {% if contribution.isFee() %}user check{% else %}gift{% endif %} grey icon tooltip"
+                                title="{% if contribution.isFee() %}{{ _T('Contribution') }}{% else %}{{ _T('Donation') }}{% endif %}"
+                            ></i>
+                            <span class="visually-hidden">{{ _T("Contribution %id")|replace({"%id": contribution.id}) }}</span>
+                        </a>
+                    </span>
+                {% endif %}
+            </td>
+            <td data-col-label="{{ _T("Member") }}">{{ memberName({id: scheduled.getContribution().member}) }}</td>
+            <td data-col-label="{{ _T("Date") }}">{{ scheduled.getCreationDate() }}</td>
+            <td data-col-label="{{ _T("Scheduled date") }}">{{ scheduled.getScheduledDate() }}</td>
+            <td data-col-label="{{ _T("Amount") }}">{{ scheduled.getAmount() }}</td>
+            <td data-col-label="{{ _T("Payment type") }}">{{ scheduled.getPaymentType() }}</td>
+            {% if (login.isAdmin() or login.isStaff()) and mode != 'ajax' %}
+                <td class="actions_row center">
+                    {% if (login.isAdmin() or login.isStaff()) %}
+                        <a
+                            href="{{ url_for("editScheduledPayment", {"id": scheduled.getId()}) }}"
+                            class="action"
+                        >
+                            <i class="ui edit icon tooltip" aria-hidden="true"></i>
+                            <span class="ui special popup">{{ _T("Edit scheduled payment") }}</span>
+                        </a>
+                        <a
+                            href="{{ url_for("removeScheduledPayment", {"id": scheduled.getId()}) }}"
+                            class="delete"
+                        >
+                            <i class="ui trash red icon tooltip" aria-hidden="true"></i>
+                            <span class="ui special popup">{{ _T("Delete scheduled payment") }}</span>
+                        </a>
+                    {% endif %}
+                </td>
+            {% endif %}
+        </tr>
+    {% else %}
+        <tr><td colspan="{% if (login.isAdmin() or login.isStaff()) and member is not defined %}6{% elseif login.isAdmin() or login.isStaff() %}6{% else %}5{% endif %}" class="emptylist">{{ _T("No scheduled payment") }}</td></tr>
+    {% endfor %}
+{% endblock %}
index 4759c76ebef1acce136b38c5c3751ae515a0eee0..efbccf83570fea71ef4d798c437303f27ebe76ea 100644 (file)
                         },
                         error: function() {
                             {% include "elements/js/modal.js.twig" with {
-                                modal_title_twig: _T("An error occurred displaying members interface :(")|e("js"),
+                                modal_title_twig: _T("An error occurred displaying contributions :(")|e("js"),
                                 modal_without_content: true,
                                 modal_class: "mini",
                                 modal_deny_only: true,
index 6b70b677e9f79d0e8d17ee4077f4198e34a2300a..422da6d14744e5d8120cc1db86a913fdad0bea9d 100644 (file)
@@ -567,6 +567,7 @@ class Db extends TestCase
             'galette_field_types',
             'galette_fields_categories',
             'galette_mailing_history',
+            'galette_payments_schedules',
             'galette_pdfmodels',
             'galette_preferences',
             'galette_searches',
diff --git a/tests/Galette/Entity/tests/units/ScheduledPayment.php b/tests/Galette/Entity/tests/units/ScheduledPayment.php
new file mode 100644 (file)
index 0000000..7c11a12
--- /dev/null
@@ -0,0 +1,576 @@
+<?php
+
+/**
+ * Copyright © 2003-2024 The Galette Team
+ *
+ * This file is part of Galette (https://galette.eu).
+ *
+ * Galette is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Galette is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ *  GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Galette. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+namespace Galette\Entity\test\units;
+
+use Galette\GaletteTestCase;
+
+/**
+ * Scheduled payment tests
+ *
+ * @author Johan Cwiklinski <johan@x-tnd.be>
+ */
+class ScheduledPayment extends GaletteTestCase
+{
+    protected int $seed = 20240321210526;
+
+    /**
+     * Tear down tests
+     *
+     * @return void
+     */
+    public function tearDown(): void
+    {
+        parent::tearDown();
+        $this->deleteScheduledPayments();
+    }
+
+    /**
+     * Delete scheduled payments
+     *
+     * @return void
+     */
+    private function deleteScheduledPayments()
+    {
+        $delete = $this->zdb->delete(\Galette\Entity\ScheduledPayment::TABLE);
+        $delete->where(['comment' => 'FAKER' . $this->seed]);
+        $this->zdb->execute($delete);
+
+        $delete = $this->zdb->delete(\Galette\Entity\Contribution::TABLE);
+        $delete->where(['info_cotis' => 'FAKER' . $this->seed]);
+        $this->zdb->execute($delete);
+
+        $delete = $this->zdb->delete(\Galette\Entity\Adherent::TABLE);
+        $delete->where(['fingerprint' => 'FAKER' . $this->seed]);
+        $this->zdb->execute($delete);
+    }
+
+    /**
+     * Test add
+     *
+     * @return void
+     */
+    public function testAdd(): void
+    {
+        $this->logSuperAdmin();
+        $this->getMemberOne();
+        //create contribution for member
+        $this->createContribution();
+
+        $this->assertFalse($this->contrib->hasSchedule());
+        $scheduledPayment = new \Galette\Entity\ScheduledPayment($this->zdb);
+        $this->assertFalse($scheduledPayment->isContributionHandled($this->contrib->id));
+        $now = new \DateTime();
+
+        $data = [
+            \Galette\Entity\Contribution::PK => $this->contrib->id,
+            'id_paymenttype' => \Galette\Entity\PaymentType::CASH,
+            'scheduled_date' => $now->format('Y-m-d'),
+            'amount' => 10.0,
+            'comment' => 'FAKER' . $this->seed
+        ];
+        $this->contrib->payment_type = \Galette\Entity\PaymentType::SCHEDULED;
+        $this->assertTrue($this->contrib->store());
+
+        $check = $scheduledPayment->check($data);
+        if (count($scheduledPayment->getErrors())) {
+            var_dump($scheduledPayment->getErrors());
+        }
+        $this->assertTrue($check);
+        $this->assertTrue($scheduledPayment->store());
+
+        $pid = $scheduledPayment->getId();
+        $this->assertTrue($this->contrib->hasSchedule());
+        $this->assertTrue($scheduledPayment->isContributionHandled($this->contrib->id));
+
+        $scheduledPayment = new \Galette\Entity\ScheduledPayment($this->zdb, $pid);
+        $this->assertSame($data[\Galette\Entity\Contribution::PK], $scheduledPayment->getContribution()->id);
+        $this->assertSame($data['id_paymenttype'], $scheduledPayment->getPaymentType()->id);
+        $this->assertSame($data['scheduled_date'], $scheduledPayment->getScheduledDate());
+        $this->assertSame($data['scheduled_date'], $scheduledPayment->getScheduledDate(false)->format('Y-m-d'));
+        $this->assertSame($data['amount'], $scheduledPayment->getAmount());
+        $this->assertSame($data['comment'], $scheduledPayment->getComment());
+    }
+
+    /**
+     * Test update
+     *
+     * @return void
+     */
+    public function testUpdate(): void
+    {
+        $this->logSuperAdmin();
+        $this->getMemberOne();
+        //create contribution for member
+        $this->createContribution();
+
+        $scheduledPayment = new \Galette\Entity\ScheduledPayment($this->zdb);
+        $now = new \DateTime();
+
+        //no amount, will take contribution amount
+        $data = [
+            \Galette\Entity\Contribution::PK => $this->contrib->id,
+            'id_paymenttype' => \Galette\Entity\PaymentType::CASH,
+            'scheduled_date' => $now->format('Y-m-d'),
+            'comment' => 'FAKER' . $this->seed
+        ];
+        $this->contrib->payment_type = \Galette\Entity\PaymentType::SCHEDULED;
+        $this->assertTrue($this->contrib->store());
+
+        $check = $scheduledPayment->check($data);
+        if (count($scheduledPayment->getErrors())) {
+            var_dump($scheduledPayment->getErrors());
+        }
+        $this->assertTrue($check);
+        $this->assertTrue($scheduledPayment->store());
+
+        $pid = $scheduledPayment->getId();
+
+        $scheduledPayment = new \Galette\Entity\ScheduledPayment($this->zdb, $pid);
+        $this->assertSame($data[\Galette\Entity\Contribution::PK], $scheduledPayment->getContribution()->id);
+        $this->assertSame($data['id_paymenttype'], $scheduledPayment->getPaymentType()->id);
+        $this->assertSame($data['scheduled_date'], $scheduledPayment->getScheduledDate());
+        $this->assertSame($data['scheduled_date'], $scheduledPayment->getScheduledDate(false)->format('Y-m-d'));
+        $this->assertSame($this->contrib->amount, $scheduledPayment->getAmount());
+        $this->assertSame($data['comment'], $scheduledPayment->getComment());
+
+        $data['amount'] = 20.0;
+        $check = $scheduledPayment->check($data);
+        if (count($scheduledPayment->getErrors())) {
+            var_dump($scheduledPayment->getErrors());
+        }
+        $this->assertTrue($check);
+        $this->assertTrue($scheduledPayment->store());
+
+        $scheduledPayment = new \Galette\Entity\ScheduledPayment($this->zdb, $pid);
+        $this->assertSame($data[\Galette\Entity\Contribution::PK], $scheduledPayment->getContribution()->id);
+        $this->assertSame($data['id_paymenttype'], $scheduledPayment->getPaymentType()->id);
+        $this->assertSame($data['scheduled_date'], $scheduledPayment->getScheduledDate());
+        $this->assertSame($data['scheduled_date'], $scheduledPayment->getScheduledDate(false)->format('Y-m-d'));
+        $this->assertSame($data['amount'], $scheduledPayment->getAmount());
+        $this->assertSame($data['comment'], $scheduledPayment->getComment());
+    }
+
+    /**
+     * Test update
+     *
+     * @return void
+     */
+    public function testCheck(): void
+    {
+        $this->logSuperAdmin();
+        $this->getMemberOne();
+        //create contribution for member
+        $this->createContribution();
+
+        $scheduledPayment = new \Galette\Entity\ScheduledPayment($this->zdb);
+        $now = new \DateTime();
+
+        $data = [];
+        $check = $scheduledPayment->check($data);
+        $this->assertFalse($check);
+        $this->assertSame(
+            [
+                'Contribution is required',
+                'Payment type is required',
+                'Scheduled date is required'
+            ],
+            $scheduledPayment->getErrors()
+        );
+
+        $data = [
+            'scheduled_date' => $now->format('Y-m-d')
+        ];
+        $check = $scheduledPayment->check($data);
+        $this->assertFalse($check);
+        $this->assertSame(
+            [
+                'Contribution is required',
+                'Payment type is required'
+            ],
+            $scheduledPayment->getErrors()
+        );
+
+        $data += [
+            'id_paymenttype' => \Galette\Entity\PaymentType::CREDITCARD
+        ];
+        $check = $scheduledPayment->check($data);
+        $this->assertFalse($check);
+        $this->assertSame(
+            [
+                'Contribution is required'
+            ],
+            $scheduledPayment->getErrors()
+        );
+
+        $data += [
+            \Galette\Entity\Contribution::PK => $this->contrib->id
+        ];
+        $check = $scheduledPayment->check($data);
+        $this->assertFalse($check);
+        $this->assertSame(
+            [
+                'Payment type for contribution must be set to scheduled'
+            ],
+            $scheduledPayment->getErrors()
+        );
+
+        $this->contrib->payment_type = \Galette\Entity\PaymentType::SCHEDULED;
+        $this->assertTrue($this->contrib->store());
+        $check = $scheduledPayment->check($data);
+        if (count($scheduledPayment->getErrors())) {
+            var_dump($scheduledPayment->getErrors());
+        }
+        $this->assertTrue($check);
+
+        $data += [
+            'amount' => -1
+        ];
+        $check = $scheduledPayment->check($data);
+        $this->assertFalse($check);
+        $this->assertSame(
+            [
+                'Amount must be a positive number'
+            ],
+            $scheduledPayment->getErrors()
+        );
+
+        $data['amount'] = 0;
+        $check = $scheduledPayment->check($data);
+        $this->assertFalse($check);
+        $this->assertSame(
+            [
+                'Amount must be a positive number'
+            ],
+            $scheduledPayment->getErrors()
+        );
+
+        $data['amount'] = 200.0;
+        $check = $scheduledPayment->check($data);
+        $this->assertFalse($check);
+        $this->assertSame(
+            [
+                'Amount cannot be greater than non allocated amount'
+            ],
+            $scheduledPayment->getErrors()
+        );
+
+        $data['amount'] = 10.0;
+        $check = $scheduledPayment->check($data);
+        if (count($scheduledPayment->getErrors())) {
+            var_dump($scheduledPayment->getErrors());
+        }
+        $this->assertTrue($check);
+
+        $data['id_paymenttype'] = \Galette\Entity\PaymentType::SCHEDULED;
+        $check = $scheduledPayment->check($data);
+        $this->assertFalse($check);
+        $this->assertSame(
+            [
+                'Cannot schedule a scheduled payment!'
+            ],
+            $scheduledPayment->getErrors()
+        );
+    }
+
+    /**
+     * Test delete
+     *
+     * @return void
+     */
+    public function testDelete(): void
+    {
+        $this->logSuperAdmin();
+        $this->getMemberOne();
+        //create contribution for member
+        $this->createContribution();
+
+        $scheduledPayment = new \Galette\Entity\ScheduledPayment($this->zdb);
+        $now = new \DateTime();
+
+        //no amount, will take contribution amount
+        $data = [
+            \Galette\Entity\Contribution::PK => $this->contrib->id,
+            'id_paymenttype' => \Galette\Entity\PaymentType::CASH,
+            'scheduled_date' => $now->format('Y-m-d'),
+            'comment' => 'FAKER' . $this->seed
+        ];
+        $this->contrib->payment_type = \Galette\Entity\PaymentType::SCHEDULED;
+        $this->assertTrue($this->contrib->store());
+
+        $check = $scheduledPayment->check($data);
+        if (count($scheduledPayment->getErrors())) {
+            var_dump($scheduledPayment->getErrors());
+        }
+        $this->assertTrue($check);
+        $this->assertTrue($scheduledPayment->store());
+
+        $pid = $scheduledPayment->getId();
+
+        $this->assertTrue($scheduledPayment->load($pid));
+        $this->assertTrue($scheduledPayment->remove());
+        $this->assertFalse($scheduledPayment->load($pid));
+    }
+
+    /**
+     * Test restrictions on contributions with a scheduled payment
+     *
+     * @return void
+     */
+    public function testContributionRestriction(): void
+    {
+        $this->logSuperAdmin();
+        $this->getMemberOne();
+        //create contribution for member
+        $this->createContribution();
+
+        $scheduledPayment = new \Galette\Entity\ScheduledPayment($this->zdb);
+        $now = new \DateTime();
+
+        //no amount, will take contribution amount
+        $data = [
+            \Galette\Entity\Contribution::PK => $this->contrib->id,
+            'id_paymenttype' => \Galette\Entity\PaymentType::CASH,
+            'scheduled_date' => $now->format('Y-m-d'),
+            'comment' => 'FAKER' . $this->seed
+        ];
+        $this->contrib->payment_type = \Galette\Entity\PaymentType::SCHEDULED;
+        $this->assertTrue($this->contrib->store());
+        $this->assertSame([], $this->contrib->getErrors());
+
+        $check = $scheduledPayment->check($data);
+        if (count($scheduledPayment->getErrors())) {
+            var_dump($scheduledPayment->getErrors());
+        }
+        $this->assertTrue($check);
+        $this->assertTrue($scheduledPayment->store());
+
+        //test it's not possible to change payment type if there is a scheduled payment
+        $this->contrib->payment_type = \Galette\Entity\PaymentType::CASH;
+        $this->expectException('RuntimeException');
+        $this->expectExceptionMessage('Existing errors prevents storing contribution: Array
+(
+    [0] => Cannot change payment type if there is an attached scheduled payment
+)
+');
+        $this->assertFalse($this->contrib->store());
+        $this->assertSame(
+            ['Cannot change payment type if there is an attached scheduled payment'],
+            $this->contrib->getErrors()
+        );
+    }
+
+    /**
+     * Test getNotFullyAllocated
+     *
+     * @return void
+     */
+    public function testGetNotFullyAllocated(): void
+    {
+        // retrieve contributions with schedule as payment type and that are not allocated, or not fully allocated
+        $this->logSuperAdmin();
+        $this->getMemberOne();
+        //create contribution for member
+        $this->createContribution();
+
+        $scheduledPayment = new \Galette\Entity\ScheduledPayment($this->zdb);
+        $now = new \DateTime();
+
+        $nonfulls = $scheduledPayment->getNotFullyAllocated();
+        $this->assertCount(0, $nonfulls); //no contributiopn with SCHEDULED payment type
+
+        $this->contrib->payment_type = \Galette\Entity\PaymentType::SCHEDULED;
+        $this->assertTrue($this->contrib->store());
+        $this->assertSame([], $this->contrib->getErrors());
+
+        $nonfulls = $scheduledPayment->getNotFullyAllocated();
+        $this->assertCount(1, $nonfulls);
+        $test = array_pop($nonfulls);
+        $this->assertSame(
+            [
+                \Galette\Entity\Contribution::PK => $this->contrib->id,
+                'montant_cotis' => '92.00',
+                'allocated' => null,
+            ],
+            $test
+        );
+
+        $data = [
+            \Galette\Entity\Contribution::PK => $this->contrib->id,
+            'id_paymenttype' => \Galette\Entity\PaymentType::CASH,
+            'scheduled_date' => $now->format('Y-m-d'),
+            'comment' => 'FAKER' . $this->seed,
+            'amount' => 10.0
+        ];
+
+        $check = $scheduledPayment->check($data);
+        if (count($scheduledPayment->getErrors())) {
+            var_dump($scheduledPayment->getErrors());
+        }
+        $this->assertTrue($check);
+        $this->assertTrue($scheduledPayment->store());
+
+        $scheduledPayment = new \Galette\Entity\ScheduledPayment($this->zdb);
+        $data['amount'] = 24.5;
+        $check = $scheduledPayment->check($data);
+        if (count($scheduledPayment->getErrors())) {
+            var_dump($scheduledPayment->getErrors());
+        }
+        $this->assertTrue($check);
+        $this->assertTrue($scheduledPayment->store());
+
+        $nonfulls = $scheduledPayment->getNotFullyAllocated();
+        $this->assertCount(1, $nonfulls);
+        $test = array_pop($nonfulls);
+        $this->assertSame(
+            [
+                \Galette\Entity\Contribution::PK => $this->contrib->id,
+                'montant_cotis' => '92.00',
+                'allocated' => '34.50',
+            ],
+            $test
+        );
+
+        $scheduledPayment = new \Galette\Entity\ScheduledPayment($this->zdb);
+        $data['amount'] = 92 - 34.5;
+        $check = $scheduledPayment->check($data);
+        if (count($scheduledPayment->getErrors())) {
+            var_dump($scheduledPayment->getErrors());
+        }
+        $this->assertTrue($check);
+        $this->assertTrue($scheduledPayment->store());
+
+        $nonfulls = $scheduledPayment->getNotFullyAllocated();
+        $this->assertCount(0, $nonfulls);
+    }
+
+    /**
+     * Test getAllocation
+     *
+     * @return void
+     */
+    public function testGetAllocation(): void
+    {
+        $this->logSuperAdmin();
+        $this->getMemberOne();
+        //create contribution for member
+        $this->createContribution();
+
+        $scheduledPayment = new \Galette\Entity\ScheduledPayment($this->zdb);
+        $now = new \DateTime();
+
+        $data = [
+            \Galette\Entity\Contribution::PK => $this->contrib->id,
+            'id_paymenttype' => \Galette\Entity\PaymentType::CASH,
+            'scheduled_date' => $now->format('Y-m-d'),
+            'comment' => 'FAKER' . $this->seed,
+            'amount' => 10.0
+        ];
+        $this->contrib->payment_type = \Galette\Entity\PaymentType::SCHEDULED;
+        $this->assertTrue($this->contrib->store());
+        $this->assertSame([], $this->contrib->getErrors());
+
+        $check = $scheduledPayment->check($data);
+        if (count($scheduledPayment->getErrors())) {
+            var_dump($scheduledPayment->getErrors());
+        }
+        $this->assertTrue($check);
+        $this->assertTrue($scheduledPayment->store());
+
+        $scheduledPayment = new \Galette\Entity\ScheduledPayment($this->zdb);
+        $data['amount'] = 25.0;
+        $check = $scheduledPayment->check($data);
+        if (count($scheduledPayment->getErrors())) {
+            var_dump($scheduledPayment->getErrors());
+        }
+        $this->assertTrue($check);
+        $this->assertTrue($scheduledPayment->store());
+
+        $this->assertSame(35.0, $scheduledPayment->getAllocation($this->contrib->id));
+    }
+
+    /**
+     * Test isFullyAllocated
+     *
+     * @return void
+     */
+    public function testIsFullyAllocated(): void
+    {
+        $this->logSuperAdmin();
+        $this->getMemberOne();
+        //create contribution for member
+        $this->createContribution();
+
+        $scheduledPayment = new \Galette\Entity\ScheduledPayment($this->zdb);
+        $now = new \DateTime();
+
+        $data = [
+            \Galette\Entity\Contribution::PK => $this->contrib->id,
+            'id_paymenttype' => \Galette\Entity\PaymentType::CASH,
+            'scheduled_date' => $now->format('Y-m-d'),
+            'comment' => 'FAKER' . $this->seed,
+            'amount' => 10.0
+        ];
+        $this->contrib->payment_type = \Galette\Entity\PaymentType::SCHEDULED;
+        $this->assertTrue($this->contrib->store());
+        $this->assertSame([], $this->contrib->getErrors());
+
+        $check = $scheduledPayment->check($data);
+        if (count($scheduledPayment->getErrors())) {
+            var_dump($scheduledPayment->getErrors());
+        }
+        $this->assertTrue($check);
+        $this->assertTrue($scheduledPayment->store());
+
+        $scheduledPayment = new \Galette\Entity\ScheduledPayment($this->zdb);
+        $data['amount'] = 25.0;
+        $check = $scheduledPayment->check($data);
+        if (count($scheduledPayment->getErrors())) {
+            var_dump($scheduledPayment->getErrors());
+        }
+        $this->assertTrue($check);
+        $this->assertTrue($scheduledPayment->store());
+
+        $this->assertSame(35.0, $scheduledPayment->getAllocation($this->contrib->id));
+        $this->assertSame(92.0 - 35.0, $scheduledPayment->getMissingAmount());
+        $this->assertFalse($scheduledPayment->isFullyAllocated($this->contrib));
+
+        //contribution amount is 92
+        $scheduledPayment = new \Galette\Entity\ScheduledPayment($this->zdb);
+        $data['amount'] = 92 - 35 + 1;
+        $check = $scheduledPayment->check($data);
+        $this->assertFalse($check);
+        $this->assertSame(['Amount cannot be greater than non allocated amount'], $scheduledPayment->getErrors());
+
+        $data['amount'] = 92 - 35;
+        $check = $scheduledPayment->check($data);
+        if (count($scheduledPayment->getErrors())) {
+            var_dump($scheduledPayment->getErrors());
+        }
+        $this->assertTrue($check);
+        $this->assertTrue($scheduledPayment->store());
+
+        $this->assertSame(92.0, $scheduledPayment->getAllocation($this->contrib->id));
+        $this->assertSame(92.0, $scheduledPayment->getAllocated());
+        $this->assertSame(0.0, $scheduledPayment->getMissingAmount());
+        $this->assertTrue($scheduledPayment->isFullyAllocated($this->contrib));
+    }
+}
index fdeaf3a1f143e5dbe393db279ce7ee95287e04d9..5dbb5bd9941a804151de68766f8674659271b92c 100644 (file)
@@ -87,7 +87,7 @@ class PaymentTypes extends GaletteTestCase
         $types = new \Galette\Repository\PaymentTypes($this->zdb, $this->preferences, $this->login);
 
         $list = $types->getList();
-        $this->assertCount(6, $list);
+        $this->assertCount(7, $list);
 
         if ($this->zdb->isPostgres()) {
             $select = $this->zdb->select(\Galette\Entity\PaymentType::TABLE . '_id_seq');
@@ -101,7 +101,7 @@ class PaymentTypes extends GaletteTestCase
         $types->installInit();
 
         $list = $types->getList();
-        $this->assertCount(6, $list);
+        $this->assertCount(7, $list);
 
         if ($this->zdb->isPostgres()) {
             $select = $this->zdb->select(\Galette\Entity\PaymentType::TABLE . '_id_seq');
diff --git a/tests/Galette/Repository/tests/units/ScheduledPayments.php b/tests/Galette/Repository/tests/units/ScheduledPayments.php
new file mode 100644 (file)
index 0000000..dacf4b3
--- /dev/null
@@ -0,0 +1,286 @@
+<?php
+
+/**
+ * Copyright © 2003-2024 The Galette Team
+ *
+ * This file is part of Galette (https://galette.eu).
+ *
+ * Galette is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Galette is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ *  GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Galette. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+namespace Galette\Repository\test\units;
+
+use Galette\GaletteTestCase;
+
+/**
+ * Scheduled payments repository tests
+ *
+ * @author Johan Cwiklinski <johan@x-tnd.be>
+ */
+class ScheduledPayments extends GaletteTestCase
+{
+    protected int $seed = 20240407181603;
+
+    /**
+     * Tear down tests
+     *
+     * @return void
+     */
+    public function tearDown(): void
+    {
+        parent::tearDown();
+        $this->deleteScheduledPayments();
+    }
+
+    /**
+     * Delete scheduled payments
+     *
+     * @return void
+     */
+    private function deleteScheduledPayments()
+    {
+        $delete = $this->zdb->delete(\Galette\Entity\ScheduledPayment::TABLE);
+        $delete->where(['comment' => 'FAKER' . $this->seed]);
+        $this->zdb->execute($delete);
+
+        $delete = $this->zdb->delete(\Galette\Entity\Contribution::TABLE);
+        $delete->where(['info_cotis' => 'FAKER' . $this->seed]);
+        $this->zdb->execute($delete);
+
+        $delete = $this->zdb->delete(\Galette\Entity\Adherent::TABLE);
+        $delete->where(['fingerprint' => 'FAKER' . $this->seed]);
+        $this->zdb->execute($delete);
+    }
+
+    /**
+     * Test getList
+     *
+     * @return void
+     */
+    public function testGetList()
+    {
+        $this->logSuperAdmin();
+        $scheduledPayments = new \Galette\Repository\ScheduledPayments($this->zdb, $this->login);
+
+        $list = $scheduledPayments->getList(true, null);
+        $this->assertIsArray($list);
+        $this->assertCount(0, $list);
+        $this->assertSame(0, $scheduledPayments->getCount());
+        $this->assertSame(0.0, $scheduledPayments->getSum());
+
+        //create contribution, and associated scheduled payments
+        $this->logSuperAdmin();
+        $this->getMemberOne();
+        //create contribution for member
+        $this->createContribution();
+
+        $scheduledPayment = new \Galette\Entity\ScheduledPayment($this->zdb);
+        $now = new \DateTime();
+
+        $data = [
+            \Galette\Entity\Contribution::PK => $this->contrib->id,
+            'id_paymenttype' => \Galette\Entity\PaymentType::CASH,
+            'scheduled_date' => $now->format('Y-m-d'),
+            'comment' => 'FAKER' . $this->seed,
+            'amount' => 10.0
+        ];
+        $this->contrib->payment_type = \Galette\Entity\PaymentType::SCHEDULED;
+        $this->assertTrue($this->contrib->store());
+        $this->assertSame([], $this->contrib->getErrors());
+
+        $check = $scheduledPayment->check($data);
+        if (count($scheduledPayment->getErrors())) {
+            var_dump($scheduledPayment->getErrors());
+        }
+        $this->assertTrue($check);
+        $this->assertTrue($scheduledPayment->store());
+
+        $scheduledPayment = new \Galette\Entity\ScheduledPayment($this->zdb);
+        $scheduled_date = $now->modify('+1 month');
+        $data['scheduled_date'] = $scheduled_date->format('Y-m-d');
+        $data['amount'] = 25.0;
+        $data['id_paymenttype'] = \Galette\Entity\PaymentType::CREDITCARD;
+        $check = $scheduledPayment->check($data);
+        if (count($scheduledPayment->getErrors())) {
+            var_dump($scheduledPayment->getErrors());
+        }
+        $this->assertTrue($check);
+        $this->assertTrue($scheduledPayment->store());
+
+        $list = $scheduledPayments->getList(true);
+        $this->assertIsArray($list);
+        $this->assertCount(2, $list);
+        $this->assertSame(35.0, $scheduledPayments->getSum());
+
+        //filters
+        $filters = new \Galette\Filters\ScheduledPaymentsList();
+        $filters->from_contribution = $this->contrib->id + 1;
+        $scheduledPayments = new \Galette\Repository\ScheduledPayments($this->zdb, $this->login, $filters);
+        $list = $scheduledPayments->getList(true);
+        $this->assertCount(0, $list);
+
+        $filters = new \Galette\Filters\ScheduledPaymentsList();
+        $filters->date_field = \Galette\Filters\ScheduledPaymentsList::DATE_SCHEDULED;
+        $filters->start_date_filter = $scheduled_date->modify('-1 day')->format('Y-m-d');
+        $filters->end_date_filter = $scheduled_date->modify('+1 day')->format('Y-m-d');
+        $scheduledPayments = new \Galette\Repository\ScheduledPayments($this->zdb, $this->login, $filters);
+        $list = $scheduledPayments->getList(true);
+        $this->assertCount(1, $list);
+        $this->assertSame(25.0, $scheduledPayments->getSum());
+
+        $filters = new \Galette\Filters\ScheduledPaymentsList();
+        $filters->payment_type_filter = \Galette\Entity\PaymentType::CASH;
+        $scheduledPayments = new \Galette\Repository\ScheduledPayments($this->zdb, $this->login, $filters);
+        $list = $scheduledPayments->getList(true);
+        $this->assertCount(1, $list);
+        $this->assertSame(10.0, $scheduledPayments->getSum());
+    }
+
+    /**
+     * Test getArrayList
+     *
+     * @return void
+     */
+    public function testGetArrayList()
+    {
+        $this->logSuperAdmin();
+        $scheduledPayments = new \Galette\Repository\ScheduledPayments($this->zdb, $this->login);
+
+        $list = $scheduledPayments->getList(true, null);
+        $this->assertIsArray($list);
+        $this->assertCount(0, $list);
+        $this->assertSame(0, $scheduledPayments->getCount());
+        $this->assertSame(0.0, $scheduledPayments->getSum());
+
+        //create contribution, and associated scheduled payments
+        $this->logSuperAdmin();
+        $this->getMemberOne();
+        //create contribution for member
+        $this->createContribution();
+
+        $scheduledPayment = new \Galette\Entity\ScheduledPayment($this->zdb);
+        $now = new \DateTime();
+
+        $data = [
+            \Galette\Entity\Contribution::PK => $this->contrib->id,
+            'id_paymenttype' => \Galette\Entity\PaymentType::CASH,
+            'scheduled_date' => $now->format('Y-m-d'),
+            'comment' => 'FAKER' . $this->seed,
+            'amount' => 10.0
+        ];
+        $this->contrib->payment_type = \Galette\Entity\PaymentType::SCHEDULED;
+        $this->assertTrue($this->contrib->store());
+        $this->assertSame([], $this->contrib->getErrors());
+
+        $check = $scheduledPayment->check($data);
+        if (count($scheduledPayment->getErrors())) {
+            var_dump($scheduledPayment->getErrors());
+        }
+        $this->assertTrue($check);
+        $this->assertTrue($scheduledPayment->store());
+        $id_1 = $scheduledPayment->getId();
+
+        $scheduledPayment = new \Galette\Entity\ScheduledPayment($this->zdb);
+        $scheduled_date = $now->modify('+1 month');
+        $data['scheduled_date'] = $scheduled_date->format('Y-m-d');
+        $data['amount'] = 25.0;
+        $data['id_paymenttype'] = \Galette\Entity\PaymentType::CREDITCARD;
+        $check = $scheduledPayment->check($data);
+        if (count($scheduledPayment->getErrors())) {
+            var_dump($scheduledPayment->getErrors());
+        }
+        $this->assertTrue($check);
+        $this->assertTrue($scheduledPayment->store());
+        $id_2 = $scheduledPayment->getId();
+
+        $list = $scheduledPayments->getArrayList([$id_1, $id_2], true);
+        $this->assertIsArray($list);
+        $this->assertCount(2, $list);
+        $contrib = array_pop($list);
+        $this->assertTrue($contrib instanceof \Galette\Entity\ScheduledPayment);
+
+        $list = $scheduledPayments->getArrayList([$id_1, $id_2], false);
+        $this->assertIsArray($list);
+        $this->assertCount(2, $list);
+        $contrib = array_pop($list);
+        $this->assertFalse($contrib instanceof \Galette\Entity\ScheduledPayment);
+    }
+
+    /**
+     * Test remove
+     *
+     * @return void
+     */
+    public function testRemove()
+    {
+        $this->logSuperAdmin();
+        $scheduledPayments = new \Galette\Repository\ScheduledPayments($this->zdb, $this->login);
+
+        $list = $scheduledPayments->getList(true, null);
+        $this->assertIsArray($list);
+        $this->assertCount(0, $list);
+        $this->assertSame(0, $scheduledPayments->getCount());
+        $this->assertSame(0.0, $scheduledPayments->getSum());
+
+        //create contribution, and associated scheduled payments
+        $this->logSuperAdmin();
+        $this->getMemberOne();
+        //create contribution for member
+        $this->createContribution();
+
+        $scheduledPayment = new \Galette\Entity\ScheduledPayment($this->zdb);
+        $now = new \DateTime();
+
+        $data = [
+            \Galette\Entity\Contribution::PK => $this->contrib->id,
+            'id_paymenttype' => \Galette\Entity\PaymentType::CASH,
+            'scheduled_date' => $now->format('Y-m-d'),
+            'comment' => 'FAKER' . $this->seed,
+            'amount' => 10.0
+        ];
+        $this->contrib->payment_type = \Galette\Entity\PaymentType::SCHEDULED;
+        $this->assertTrue($this->contrib->store());
+        $this->assertSame([], $this->contrib->getErrors());
+
+        $check = $scheduledPayment->check($data);
+        if (count($scheduledPayment->getErrors())) {
+            var_dump($scheduledPayment->getErrors());
+        }
+        $this->assertTrue($check);
+        $this->assertTrue($scheduledPayment->store());
+        $id_1 = $scheduledPayment->getId();
+
+        $scheduledPayment = new \Galette\Entity\ScheduledPayment($this->zdb);
+        $scheduled_date = $now->modify('+1 month');
+        $data['scheduled_date'] = $scheduled_date->format('Y-m-d');
+        $data['amount'] = 25.0;
+        $data['id_paymenttype'] = \Galette\Entity\PaymentType::CREDITCARD;
+        $check = $scheduledPayment->check($data);
+        if (count($scheduledPayment->getErrors())) {
+            var_dump($scheduledPayment->getErrors());
+        }
+        $this->assertTrue($check);
+        $this->assertTrue($scheduledPayment->store());
+        $id_2 = $scheduledPayment->getId();
+
+        $list = $scheduledPayments->getList(true);
+        $this->assertIsArray($list);
+        $this->assertCount(2, $list);
+
+        $this->assertTrue($scheduledPayments->remove([$id_1, $id_2], $this->history));
+
+        $list = $scheduledPayments->getList(true);
+        $this->assertCount(0, $list);
+    }
+}
index cd9818ae66322aa53b9bfe414a7df395e34ca0c7..b4a3da3be4d3cfbb5ea86769b9b2ff9b8b9b31f1 100644 (file)
@@ -713,7 +713,7 @@ abstract class GaletteTestCase extends TestCase
     {
         $ct = new \Galette\Entity\ContributionsTypes($this->zdb);
         if (count($ct->getCompleteList()) === 0) {
-            //status are not yet instanciated.
+            //contributions types are not yet instanciated.
             $res = $ct->installInit();
             $this->assertTrue($res);
         }
@@ -729,7 +729,7 @@ abstract class GaletteTestCase extends TestCase
         $types = new \Galette\Repository\PaymentTypes($this->zdb, $this->preferences, $this->login);
         if (count($types->getList()) === 0) {
             //payment types are not yet instanciated.
-            $res = $types->installInit(false);
+            $res = $types->installInit();
             $this->assertTrue($res);
         }
     }
@@ -742,7 +742,7 @@ abstract class GaletteTestCase extends TestCase
     protected function initTitles(): void
     {
         $titles = new \Galette\Repository\Titles($this->zdb);
-        if (count($titles->getList($this->zdb)) === 0) {
+        if (count($titles->getList()) === 0) {
             $res = $titles->installInit();
             $this->assertTrue($res);
         }