From dbae796faeccd89574730cc46f84207ec48f7182 Mon Sep 17 00:00:00 2001 From: Johan Cwiklinski Date: Wed, 20 Mar 2024 17:02:53 +0100 Subject: [PATCH] Add scheduled payments feature closes #1193 --- galette/docs/CHANGES | 2 + galette/includes/core_acls.php | 4 +- .../includes/routes/contributions.routes.php | 63 ++ galette/install/scripts/mysql.sql | 16 + galette/install/scripts/pgsql.sql | 23 + .../scripts/sql/upgrade-to-1.10-mysql.sql | 20 + .../scripts/sql/upgrade-to-1.10-pgsql.sql | 30 + .../Crud/ContributionsController.php | 18 +- .../Crud/ScheduledPaymentController.php | 538 +++++++++++++++ .../lib/Galette/Controllers/CsvController.php | 35 + .../Galette/Controllers/GaletteController.php | 2 +- galette/lib/Galette/Core/Galette.php | 15 + galette/lib/Galette/Entity/Contribution.php | 34 +- galette/lib/Galette/Entity/PaymentType.php | 12 +- .../lib/Galette/Entity/ScheduledPayment.php | 653 ++++++++++++++++++ galette/lib/Galette/Features/EntityHelper.php | 10 + .../Galette/Filters/ScheduledPaymentsList.php | 293 ++++++++ .../lib/Galette/IO/ScheduledPaymentsCsv.php | 154 +++++ .../lib/Galette/Repository/Contributions.php | 9 +- galette/lib/Galette/Repository/Members.php | 2 +- .../lib/Galette/Repository/PaymentTypes.php | 14 +- .../Galette/Repository/ScheduledPayments.php | 450 ++++++++++++ .../components/forms/payment_types.html.twig | 18 +- .../templates/default/elements/list.html.twig | 2 +- .../default/pages/contribution_form.html.twig | 348 ++++++++-- .../pages/contributions_list.html.twig | 3 +- .../pages/scheduledpayment_form.html.twig | 175 +++++ .../pages/scheduledpayments_list.html.twig | 212 ++++++ .../default/pages/transaction_form.html.twig | 2 +- tests/Galette/Core/tests/units/Db.php | 1 + .../Entity/tests/units/ScheduledPayment.php | 576 +++++++++++++++ .../Repository/tests/units/PaymentTypes.php | 4 +- .../tests/units/ScheduledPayments.php | 286 ++++++++ tests/GaletteTestCase.php | 6 +- 34 files changed, 3921 insertions(+), 109 deletions(-) create mode 100644 galette/lib/Galette/Controllers/Crud/ScheduledPaymentController.php create mode 100644 galette/lib/Galette/Entity/ScheduledPayment.php create mode 100644 galette/lib/Galette/Filters/ScheduledPaymentsList.php create mode 100644 galette/lib/Galette/IO/ScheduledPaymentsCsv.php create mode 100644 galette/lib/Galette/Repository/ScheduledPayments.php create mode 100644 galette/templates/default/pages/scheduledpayment_form.html.twig create mode 100644 galette/templates/default/pages/scheduledpayments_list.html.twig create mode 100644 tests/Galette/Entity/tests/units/ScheduledPayment.php create mode 100644 tests/Galette/Repository/tests/units/ScheduledPayments.php diff --git a/galette/docs/CHANGES b/galette/docs/CHANGES index 462cc538b..9dcb4c7b5 100644 --- a/galette/docs/CHANGES +++ b/galette/docs/CHANGES @@ -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 diff --git a/galette/includes/core_acls.php b/galette/includes/core_acls.php index 4a2b3d3da..991d6ad67 100644 --- a/galette/includes/core_acls.php +++ b/galette/includes/core_acls.php @@ -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' ]; diff --git a/galette/includes/routes/contributions.routes.php b/galette/includes/routes/contributions.routes.php index 6754ede63..e79bd921d 100644 --- a/galette/includes/routes/contributions.routes.php +++ b/galette/includes/routes/contributions.routes.php @@ -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); diff --git a/galette/install/scripts/mysql.sql b/galette/install/scripts/mysql.sql index 04f40921a..8e516b4e0 100644 --- a/galette/install/scripts/mysql.sql +++ b/galette/install/scripts/mysql.sql @@ -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 ( diff --git a/galette/install/scripts/pgsql.sql b/galette/install/scripts/pgsql.sql index 28646e856..5e192e35f 100644 --- a/galette/install/scripts/pgsql.sql +++ b/galette/install/scripts/pgsql.sql @@ -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 ( diff --git a/galette/install/scripts/sql/upgrade-to-1.10-mysql.sql b/galette/install/scripts/sql/upgrade-to-1.10-mysql.sql index 3112cda18..3a0b7abd0 100644 --- a/galette/install/scripts/sql/upgrade-to-1.10-mysql.sql +++ b/galette/install/scripts/sql/upgrade-to-1.10-mysql.sql @@ -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; diff --git a/galette/install/scripts/sql/upgrade-to-1.10-pgsql.sql b/galette/install/scripts/sql/upgrade-to-1.10-pgsql.sql index c4cbe2faa..df6bcd8b7 100644 --- a/galette/install/scripts/sql/upgrade-to-1.10-pgsql.sql +++ b/galette/install/scripts/sql/upgrade-to-1.10-pgsql.sql @@ -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; diff --git a/galette/lib/Galette/Controllers/Crud/ContributionsController.php b/galette/lib/Galette/Controllers/Crud/ContributionsController.php index 9a07ccb85..52478b809 100644 --- a/galette/lib/Galette/Controllers/Crud/ContributionsController.php +++ b/galette/lib/Galette/Controllers/Crud/ContributionsController.php @@ -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 index 000000000..20b7bcfb7 --- /dev/null +++ b/galette/lib/Galette/Controllers/Crud/ScheduledPaymentController.php @@ -0,0 +1,538 @@ +. + */ + +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 + */ + +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 $args Route arguments + * + * @return string + */ + public function redirectUri(array $args): string + { + return $this->routeparser->urlFor('scheduledPayments'); + } + + /** + * Get form URI + * + * @param array $args Route arguments + * + * @return string + */ + public function formUri(array $args): string + { + return $this->routeparser->urlFor( + 'doRemoveScheduledPayment', + $args + ); + } + + /** + * Get confirmation removal page title + * + * @param array $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 $args Route arguments + * @param array $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|null $args Route arguments + * + * @return string + */ + public function getFilterName(array $args = null): string + { + return 'filter_scheduled_payments'; + } +} diff --git a/galette/lib/Galette/Controllers/CsvController.php b/galette/lib/Galette/Controllers/CsvController.php index 9cbfa48e9..0c57512dd 100644 --- a/galette/lib/Galette/Controllers/CsvController.php +++ b/galette/lib/Galette/Controllers/CsvController.php @@ -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); + } } diff --git a/galette/lib/Galette/Controllers/GaletteController.php b/galette/lib/Galette/Controllers/GaletteController.php index dd6c85860..cab1c2726 100644 --- a/galette/lib/Galette/Controllers/GaletteController.php +++ b/galette/lib/Galette/Controllers/GaletteController.php @@ -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); diff --git a/galette/lib/Galette/Core/Galette.php b/galette/lib/Galette/Core/Galette.php index 87e0476cd..599079937 100644 --- a/galette/lib/Galette/Core/Galette.php +++ b/galette/lib/Galette/Core/Galette.php @@ -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"), diff --git a/galette/lib/Galette/Entity/Contribution.php b/galette/lib/Galette/Entity/Contribution.php index 191b2463e..01757bb1f 100644 --- a/galette/lib/Galette/Entity/Contribution.php +++ b/galette/lib/Galette/Entity/Contribution.php @@ -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, diff --git a/galette/lib/Galette/Entity/PaymentType.php b/galette/lib/Galette/Entity/PaymentType.php index 1f06805da..721984888 100644 --- a/galette/lib/Galette/Entity/PaymentType.php +++ b/galette/lib/Galette/Entity/PaymentType.php @@ -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 index 000000000..f9a7ac995 --- /dev/null +++ b/galette/lib/Galette/Entity/ScheduledPayment.php @@ -0,0 +1,653 @@ +. + */ + +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 + */ + +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|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 $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 $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; + } +} diff --git a/galette/lib/Galette/Features/EntityHelper.php b/galette/lib/Galette/Features/EntityHelper.php index 5bf1961ea..5fa8af29f 100644 --- a/galette/lib/Galette/Features/EntityHelper.php +++ b/galette/lib/Galette/Features/EntityHelper.php @@ -63,6 +63,16 @@ trait EntityHelper */ abstract protected function setFields(): self; + /** + * Get fields + * + * @return array> + */ + 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 index 000000000..b6c926305 --- /dev/null +++ b/galette/lib/Galette/Filters/ScheduledPaymentsList.php @@ -0,0 +1,293 @@ +. + */ + +namespace Galette\Filters; + +use Throwable; +use Analog\Analog; +use Galette\Core\Pagination; + +/** + * Contributions lists filters and paginator + * + * @author Johan Cwiklinski + * + * @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 */ + private array $selected = []; + + /** @var array */ + protected array $list_fields = array( + 'start_date_filter', + 'end_date_filter', + 'date_field', + 'payment_type_filter', + 'from_contribution', + 'selected' + ); + + /** @var array */ + 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.
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 index 000000000..89f63d6bb --- /dev/null +++ b/galette/lib/Galette/IO/ScheduledPaymentsCsv.php @@ -0,0 +1,154 @@ +. + */ + +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 + */ + +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 $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; + } +} diff --git a/galette/lib/Galette/Repository/Contributions.php b/galette/lib/Galette/Repository/Contributions.php index 1f418d998..481317c10 100644 --- a/galette/lib/Galette/Repository/Contributions.php +++ b/galette/lib/Galette/Repository/Contributions.php @@ -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 { diff --git a/galette/lib/Galette/Repository/Members.php b/galette/lib/Galette/Repository/Members.php index a8d87e19f..e0f8c43f0 100644 --- a/galette/lib/Galette/Repository/Members.php +++ b/galette/lib/Galette/Repository/Members.php @@ -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(); } diff --git a/galette/lib/Galette/Repository/PaymentTypes.php b/galette/lib/Galette/Repository/PaymentTypes.php index d86d40fad..cd989b1fc 100644 --- a/galette/lib/Galette/Repository/PaymentTypes.php +++ b/galette/lib/Galette/Repository/PaymentTypes.php @@ -37,26 +37,34 @@ class PaymentTypes extends Repository /** * Get payments types * + * @param boolean $schedulable Types that can be used in schedules only + * * @return array */ - 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|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 index 000000000..f1b1083a9 --- /dev/null +++ b/galette/lib/Galette/Repository/ScheduledPayments.php @@ -0,0 +1,450 @@ +. + */ + +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 + */ +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 */ + 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 $ids an array of members id that has been selected + * @param bool $as_object return the results as an array of + * @param ?array $fields field(s) name(s) to get. Should be a string or + * an array. If null, all fields will be returned + * + * @return array|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 $fields field(s) name(s) to get. Should be a string or + * an array. If null, all fields will be returned + * + * @return array|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 $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 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 $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; + } + } +} diff --git a/galette/templates/default/components/forms/payment_types.html.twig b/galette/templates/default/components/forms/payment_types.html.twig index 055bea1df..f3550cb3c 100644 --- a/galette/templates/default/components/forms/payment_types.html.twig +++ b/galette/templates/default/components/forms/payment_types.html.twig @@ -18,17 +18,21 @@ * along with Galette. If not, see . */ #} -{% if show_inline is not defined %} -
-{% else %} -
-{% endif %} +
- {% if empty is defined %} {% 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 %} {% endfor %} diff --git a/galette/templates/default/elements/list.html.twig b/galette/templates/default/elements/list.html.twig index 7f03ee645..a40a49b35 100644 --- a/galette/templates/default/elements/list.html.twig +++ b/galette/templates/default/elements/list.html.twig @@ -97,7 +97,7 @@ {% endif %} {% 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) %} {{ _T('Actions') }} {% endif %} {% endblock %} diff --git a/galette/templates/default/pages/contribution_form.html.twig b/galette/templates/default/pages/contribution_form.html.twig index 695e8ff5d..66da420c4 100644 --- a/galette/templates/default/pages/contribution_form.html.twig +++ b/galette/templates/default/pages/contribution_form.html.twig @@ -37,11 +37,16 @@ {{ _T("Transaction related") }} + {% endif %} + {% if contribution.hasSchedule() %} + + {{ _T("Has scheduled payments") }} + {% endif %}
-
+
{% if not require_mass %}
@@ -77,79 +82,124 @@
- {% if contribution.isTransactionPart() and contribution.transaction.getMissingAmount() %} -
- - - - -
- {% endif %}
- {% if contribution.isTransactionPart() %} -
- {% set mid = contribution.transaction.member %} -
{{ _T("Related transaction information") }}
- - - - - - - - - - - - {% if contribution.id and contribution.isTransactionPart() %} - - - - - - {% endif %} - - - - - - - - - - -
#{{ _T("Description") }}{{ _T("Date") }}{{ _T("Member") }}{{ _T("Amount") }}{{ _T("Not dispatched amount") }}
-
- - - {{ _T("View transaction") }} - - {% if contribution.transaction.getMissingAmount() > 0 %} - - - - {{ _T("Create a new fee that will be attached to the current transaction") }} - + {% if contribution.isTransactionPart() or contribution.hasSchedule() %} +
+ {% if contribution.isTransactionPart() %} +
+
+ + + {{ _T("View transaction") }} + + {{ _T("Related transaction information") }} +
+
+
+
+
+
{{ _T("Date") }}
+ {{ contribution.transaction.date }} +
+
+
+
+
{{ _T("Member") }}
+ {{ memberName({'id': contribution.transaction.member}) }} +
+
+
+
+
{{ _T("Amount") }}
+ {{ contribution.transaction.amount }} +
+
+ {% if contribution.transaction.getMissingAmount() > 0 %} +
+
+
{{ _T("Not dispatched amount") }}
+ {{ contribution.transaction.getMissingAmount() }} + {% if contribution.id != '' %} + + + + {{ _T("Create a new fee that will be attached to the current transaction") }} + + + + + {{ _T("Create a new donation that will be attached to the current transaction") }} + + {% endif %} +
+
+ {% endif %} +
+ {% if contribution.id == '' %} +
+ + + + +
+ {% endif %} +
+
+ {% endif %} + {% if contribution.hasSchedule() %} + {% set scheduled_amount = scheduled.getAllocation(contribution.id) %} +
+
+ + + {{ _T("View scheduled payments") }} + + {{ _T("Scheduled payments") }} +
+ {% if not contribution.isScheduleFullyAllocated() %} +
+
+
+
+
{{ _T("Amount") }}
+ {{ scheduled_amount }} +
+
+
+
+
{{ _T("Not dispatched amount") }}
+ {{ contribution.amount - scheduled_amount }} - - {{ _T("Create a new donation that will be attached to the current transaction") }} + + {{ _T("Create a new scheduled payment") }} - {% endif %}
- -
{{ contribution.transaction.id }}{{ contribution.transaction.description }}{{ contribution.transaction.date }}{{ memberName({'id': mid}) }}{{ contribution.transaction.amount }}{{ contribution.transaction.getMissingAmount() }}
+
+
+
+ {% endif %} +
+ {% endif %} {% endif %} @@ -179,7 +229,8 @@ {% endif %} {% include 'components/forms/payment_types.html.twig' with { 'current': ptype, - 'varname': 'type_paiement_cotis' + 'varname': 'type_paiement_cotis', + 'disabled': contribution.hasSchedule() } %}
@@ -292,7 +343,7 @@ {% endif %} $(function() { - {% if not contribution.id -%} + {% if not contribution.id -%} var _types_amounts = { {%- for key, values in type_cotis_options -%} {%- if values.amount > 0 -%} @@ -340,6 +391,163 @@ } }); {% 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 %} }); {% endblock %} diff --git a/galette/templates/default/pages/contributions_list.html.twig b/galette/templates/default/pages/contributions_list.html.twig index 17205403b..ab4665884 100644 --- a/galette/templates/default/pages/contributions_list.html.twig +++ b/galette/templates/default/pages/contributions_list.html.twig @@ -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")} } %} @@ -231,7 +230,7 @@ {% block footer %} {% if nb != 0 %} - + {{ _T("Found contributions total %f")|replace({"%f": contribs.getSum()}) }} diff --git a/galette/templates/default/pages/scheduledpayment_form.html.twig b/galette/templates/default/pages/scheduledpayment_form.html.twig new file mode 100644 index 000000000..4002e503b --- /dev/null +++ b/galette/templates/default/pages/scheduledpayment_form.html.twig @@ -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 . + */ +#} +{% extends (mode == 'ajax') ? "ajax.html.twig" : "page.html.twig" %} + +{% block content %} +
+
+
+ + {{ _T("Scheduled payment") }} +
+
+
+
+
+ + +
+ {# 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 + } %} +
+ + +
+
+
+ {% set contribution = scheduled.getContribution() %} +
+
+ + + {{ _T("View contribution") }} + + {{ _T("Related contribution information") }} +
+
+
+
+
+
{{ _T("Type") }}
+ {{ contribution.type.libelle }} +
+
+
+
+
{{ _T("Begin") }}
+ {{ contribution.begin_date }} +
+
+
+
+
{{ _T("End") }}
+ {{ contribution.end_date }} +
+
+ {% if ((login.isAdmin() or login.isStaff()) and member is not defined) or pmember is defined %} +
+
+
{{ _T("Member") }}
+ {{ memberName({'id': contribution.member}) }} +
+
+ {% endif %} +
+
+
{{ _T("Amount") }}
+ {{ contribution.amount }} +
+
+ {% if not contribution.isScheduleFullyAllocated() %} +
+
+
{{ _T("Not dispatched amount") }}
+ {{ scheduled.getMissingAmount() }} + {% if scheduled.getId() != '' %} + + + + {{ _T("Create a new scheduled payment") }} + + {% endif %} +
+
+ {% endif %} +
+
+
+
+
+
+
+ +
+
+ + +
+
+
+
+ +
+
+ + +
+
+
+
+
+ + +
+
+
+
+
+ + {% if mode != 'ajax' %} +
+ + + + {% endif %} + {% if scheduled.getId() %} + + {% endif %} + + {% include 'components/forms/csrf.html.twig' %} + {% if mode != 'ajax' %} +
+ {% endif %} +
+{% 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 index 000000000..5eaccb2d6 --- /dev/null +++ b/galette/templates/default/pages/scheduledpayments_list.html.twig @@ -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 . + */ +#} +{% 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 %} +
+
+
+
+ + +
+
+
+ +
+
+ + +
+
+
+
+ +
+
+ + +
+
+
+
+
+ {% include "components/forms/payment_types.html.twig" with { + current: filters.payment_type_filter, + varname: "payment_type_filter", + classname: "", + empty: {'value': -1, 'label': _T("Select")} + } %} +
+
+ + +
+
+
+ + {% if mode == 'ajax'%} + + + {% else %} + {% include "components/forms/csrf.html.twig" %} + {% endif %} +
+{% 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 %} + + + {{ _T("Found total scheduled %f")|replace({"%f": scheduled.getSum()}) }} + + + {% 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 %} + + + + + {% 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' %} + + + + {{ _T("Contribution %id")|replace({"%id": contribution.id}) }} + + + {% endif %} + + {{ memberName({id: scheduled.getContribution().member}) }} + {{ scheduled.getCreationDate() }} + {{ scheduled.getScheduledDate() }} + {{ scheduled.getAmount() }} + {{ scheduled.getPaymentType() }} + {% if (login.isAdmin() or login.isStaff()) and mode != 'ajax' %} + + {% if (login.isAdmin() or login.isStaff()) %} + + + {{ _T("Edit scheduled payment") }} + + + + {{ _T("Delete scheduled payment") }} + + {% endif %} + + {% endif %} + + {% else %} + {{ _T("No scheduled payment") }} + {% endfor %} +{% endblock %} diff --git a/galette/templates/default/pages/transaction_form.html.twig b/galette/templates/default/pages/transaction_form.html.twig index 4759c76eb..efbccf835 100644 --- a/galette/templates/default/pages/transaction_form.html.twig +++ b/galette/templates/default/pages/transaction_form.html.twig @@ -241,7 +241,7 @@ }, 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, diff --git a/tests/Galette/Core/tests/units/Db.php b/tests/Galette/Core/tests/units/Db.php index 6b70b677e..422da6d14 100644 --- a/tests/Galette/Core/tests/units/Db.php +++ b/tests/Galette/Core/tests/units/Db.php @@ -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 index 000000000..7c11a122a --- /dev/null +++ b/tests/Galette/Entity/tests/units/ScheduledPayment.php @@ -0,0 +1,576 @@ +. + */ + +namespace Galette\Entity\test\units; + +use Galette\GaletteTestCase; + +/** + * Scheduled payment tests + * + * @author Johan Cwiklinski + */ +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)); + } +} diff --git a/tests/Galette/Repository/tests/units/PaymentTypes.php b/tests/Galette/Repository/tests/units/PaymentTypes.php index fdeaf3a1f..5dbb5bd99 100644 --- a/tests/Galette/Repository/tests/units/PaymentTypes.php +++ b/tests/Galette/Repository/tests/units/PaymentTypes.php @@ -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 index 000000000..dacf4b301 --- /dev/null +++ b/tests/Galette/Repository/tests/units/ScheduledPayments.php @@ -0,0 +1,286 @@ +. + */ + +namespace Galette\Repository\test\units; + +use Galette\GaletteTestCase; + +/** + * Scheduled payments repository tests + * + * @author Johan Cwiklinski + */ +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); + } +} diff --git a/tests/GaletteTestCase.php b/tests/GaletteTestCase.php index cd9818ae6..b4a3da3be 100644 --- a/tests/GaletteTestCase.php +++ b/tests/GaletteTestCase.php @@ -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); } -- 2.39.2