From 3699cab95ee09ef9d5c682eb236930b590846448 Mon Sep 17 00:00:00 2001 From: Matt Blenkinsop Date: Thu, 19 Feb 2026 13:31:27 +0000 Subject: [PATCH] Bug 26355: Add the class methods and endpoint to handle renewal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is part of a sponsored piece of development from one of our customers. There was a specification of requirements but I was conscious of the ideas already discussed on this bug and wanted to try and develop a solution that would balance both sets of requirements. The concept works as follows: - Self-renewal can be enabled and controlled at a category level - The window within which it is available is customisable (both pre and post-expiry) - Defaults to the NotifyBorrowerDeparture syspref - The amount in fines that block self-renewal is customisable - Defaults to the category level charges and then the noissescharge preference - The message to display if renewal fails is customisable - Libraries can also add information to display to the patron at the start of the process. - The self-renewal process will also respect the OPACPatronDetails and AutoApprovePatronProfileSettings sysprefs. If OPACPatronDetails is enabled, the renewal will redirect the patron to the OPAC details form and ask them to confirm their details are still correct. Any changes will be logged as patron modifications and approved in line with the auto approval syspref - Renewal happens regardless of whether OPACPatronDetails is enabled, only the details changes are logged as modification requests - Notifications are sent to the patron in line with their messaging preferences Test plan: 1. Apply all patches and reset_all 2. In the intranet, navigate to patron categories and edit the ‘Patron’ category 3. Scroll to the bottom, there should be a section called ‘Account expiry and self-renewal’ 4. Set ‘Allow self-renewal’ to Yes 5. Set Self-renewal availability period to be 10 days 6. Set Self-renewal availability post-expiry’ to also be 10 days and save 7. In system preferences, set the OPACPatronDetails preference to ‘Don’t allow’ 8. Choose a patron that has the ‘Patron’ type category 9. Edit that patron so that you can log into the OPAC and set their expiry date to be yesterday’s date 10. Log into the OPAC as that patron (it may be easiest to do this in incognito as we will be swapping between the intranet and OPAC throughout the test plan and it will remove the need to keep logging out and in) 11. You should see a message that says “You are eligible for self-renewal. Please click here to renew your account.” 12. Click that link - you will be presented with a modal that says “Are you sure you want to renew your account?” 13. Click “Close” - we don’t want to complete renewal yet as there is more functionality available to test 14. Navigate to the patron category again, set ‘Self-renewal availability post-expiry’ to be 0 15. Refresh the OPAC, the self-renewal link should not be there as we are now outside the patron self-renewal window 16. Undo that change in the patron category, the link should reappear when refreshing the OPAC 17. Now set the patron’s expiry date to be tomorrow 18. In the category, set ‘Self-renewal availability period’ to be blank 19. Refresh the OPAC, it should still show the link as it is defaulting to NotifyBorrowerDeparture 20. Set NotifyBorrowerDeparture to 0 21. Refresh the OPAC, the link should vanish 22. In the category, restore ‘Self-renewal availability period’ to 10 days 23. Refresh the OPAC, the link should reappear 24. In the intranet, set the patron to be debarred using a manual restriction 25. In the OPAC refresh the page - self-renewal should no longer be available 26. Remove the restriction - the renewal link will be visible again 27. Set a fine on the patron record of 6 USD 28. Refresh the OPAC, the self-renewal link will be gone because we are over the global ‘noissuescharge’ preference value of 5 29. In the category, set ‘Checkout charge limit’ to 7 30. Refresh the OPAC, the link will be visible because we are now under the category charge 31. Add an additional fine of 2 to the patron account 32. Refresh the OPAC, the self-renewal link is gone 33. In the category, set ‘Self-renewal charge limit’ to be 10 34. Refresh the OPAC, the link will be visible again 35. In the category, set ‘Self-renewal failure message’ to be whatever you want 36. In the OPAC, refresh the page and then click into the self-renewal process 37. This time, click “No” - you should see the failure message you have just set 38. Click “Close” and refresh the page to reset this message 39. In the category, set a message in the “Self-renewal information message” 40. In the OPAC, refresh the page and then click the renewal link again 41. You should be met with the modal, this time displaying the information message we just set up in the patron attributes 42. Click “Yes” - you should see the “Are you sure you want to renew your account?” message again 43. This time when prompted with the “Are you sure” message, click “Yes” 44. The page should refresh and display a confirmation with the new expiry date. 45. In the intranet, view the patron’s record - the date should have been updated 46. In the Modification log tab, there should be a log entry for “Membership renewed” 47. Change the patron’s expiry date again, this time to be yesterday’s date 48. In the OPAC, click on “Personal detail”, the new expiry date should show there 49. Now set the OPACPatronDetails syspref to be “Allow” 50. Click on “Summary” again, the self-renewal link should be there again 51. Click into the self-renewal process and click “Yes” on your verification step 52. This time you should be met with a message that says “Confirm your account details” 53. Click “Continue” - you will be redirected to the OPAC details form 54. There should be a message at the top that says “Please verify your details to proceed with your self-renewal.” 55. Update a field somewhere on the patron form 56. Scroll to the bottom - the button should say “Submit renewal request” 57. Click this button 58. You will be shown the renewal confirmation message 59. In the intranet, on the homepage you should now see a modification request 60. Check the request, it should match the fields you changed - don’t approve or deny for now 61. Set ‘AutoApprovePatronProfileSettings’ in sysprefs to “Enable” 62. Reset your patron’s expiry date to today’s date 63. In the OPAC, click into Personal details and then back into Summary again (we need to do this to remove the success parameter from the URL) 64. The renewal link will be visible again - go through the process again 65. On the form, you should see an alert that you have already sent a modification request 66. This time, change a different field to the previous attempt 67. Submit the form 68. In the intranet there should be no modification this time and the change should be visible in the patron record as it has been automatically approved and handled as per the syspref 69. prove t/db_dependent/Koha/Patron.t 70. prove t/db_dependent/api/v1/patrons_self_renewal.t 71. yarn cypress run --spec t/cypress/integration/Islands/SelfRenewal.ts 72. Sign off! --- Koha/Patron.pm | 110 ++++++++++++++ Koha/REST/V1/Patrons/SelfRenewal.pm | 137 ++++++++++++++++++ .../prog/js/fetch/patron-api-client.js | 17 +++ misc/cronjobs/membership_expiry.pl | 27 +--- 4 files changed, 267 insertions(+), 24 deletions(-) create mode 100644 Koha/REST/V1/Patrons/SelfRenewal.pm diff --git a/Koha/Patron.pm b/Koha/Patron.pm index c0ef6e292c5..e36539ca3c5 100644 --- a/Koha/Patron.pm +++ b/Koha/Patron.pm @@ -3020,6 +3020,8 @@ sub to_api { ? Mojo::JSON->true : Mojo::JSON->false; + $json_patron->{self_renewal_available} = $self->is_eligible_for_self_renewal(); + return $json_patron; } @@ -3593,6 +3595,114 @@ sub is_anonymous { return ( $anonymous_patron && $self->borrowernumber eq $anonymous_patron ) ? 1 : 0; } +=head3 is_eligible_for_self_renewal + +my $eligible_for_self_renewal = $patron->is_eligible_for_self_renewal(); + +Returns a boolean value for whether self-renewal is available or not + +=cut + +sub is_eligible_for_self_renewal { + my ($self) = @_; + + my $category = $self->category; + return 0 if !$category->self_renewal_enabled; + + return 0 if $self->debarred; + + my $expiry_window = + defined $category->self_renewal_availability_start + ? $category->self_renewal_availability_start + : C4::Context->preference('NotifyBorrowerDeparture'); + my $post_expiry_window = $category->self_renewal_if_expired || 0; + + my $expiry_date = dt_from_string( $self->dateexpiry, undef, 'floating' ); + my $window_start = dt_from_string( $self->dateexpiry, undef, 'floating' )->subtract( days => $expiry_window ); + my $window_end = dt_from_string( $self->dateexpiry, undef, 'floating' )->add( days => $post_expiry_window ); + my $today = dt_from_string( undef, undef, 'floating' ); + + my $within_expiry_window = + $window_start < $today->truncate( to => 'day' ) && $today < $window_end->truncate( to => 'day' ); + return 0 if !$within_expiry_window; + + my $charges_status = $self->is_patron_inside_charge_limits(); + my $self_renewal_charge_limit = $category->self_renewal_fines_block; + foreach my $key ( keys %$charges_status ) { + my $within_renewal_limit = + ( $self_renewal_charge_limit && $self_renewal_charge_limit > $charges_status->{$key}->{charge} ) ? 1 : 0; + return 0 if $charges_status->{$key}->{overlimit} && !$within_renewal_limit; + } + + return 1; +} + +=head3 request_modification + +$patron->request_modification + +Used in the OPAC and in the patron self-renewal workflow to request a modification to a patron's account +Automatically approves the request based on the AutoApprovePatronProfileSettings syspref + +=cut + +sub request_modification { + my ( $self, $modification ) = @_; + + Koha::Patron::Modifications->search( { borrowernumber => $self->borrowernumber } )->delete; + + $modification->{verification_token} = q{} if !$modification->{verification_token}; + $modification->{borrowernumber} = $self->borrowernumber if !$modification->{borrowernumber}; + + my $patron_modification = Koha::Patron::Modification->new($modification)->store()->discard_changes; + + #Automatically approve patron profile changes if AutoApprovePatronProfileSettings is enabled + $patron_modification->approve() if C4::Context->preference('AutoApprovePatronProfileSettings'); +} + +=head3 create_expiry_notice_parameters + +my $letter_params = $expiring_patron->create_expiry_notice_parameters( + { letter_code => $which_notice, forceprint => $forceprint, is_notice_mandatory => $is_notice_mandatory } ); + +Returns the parameters to send an expiry notice to a patron +Used by both the membership_expiry.pl cron and the self-renewal workflow + +=cut + +sub create_expiry_notice_parameters { + my ( $self, $args ) = @_; + + my $letter_code = $args->{letter_code}; + my $forceprint = $args->{forceprint} || 0; + my $is_notice_mandatory = $args->{is_notice_mandatory}; + + my $letter_params = { + module => 'members', + letter_code => $letter_code, + branchcode => $self->branchcode, + lang => $self->lang, + borrowernumber => $self->borrowernumber, + tables => { + borrowers => $self->borrowernumber, + branches => $self->branchcode, + }, + }; + + my $sending_params = { + letter_params => $letter_params, + message_name => 'Patron_Expiry', + forceprint => $forceprint + }; + + if ($is_notice_mandatory) { + $sending_params->{expiry_notice_mandatory} = 1; + $sending_params->{primary_contact_method} = $forceprint ? 'print' : $self->primary_contact_method; + } + + return $sending_params; +} + =head2 Internal methods =head3 _type diff --git a/Koha/REST/V1/Patrons/SelfRenewal.pm b/Koha/REST/V1/Patrons/SelfRenewal.pm new file mode 100644 index 00000000000..00f3a66cf48 --- /dev/null +++ b/Koha/REST/V1/Patrons/SelfRenewal.pm @@ -0,0 +1,137 @@ +package Koha::REST::V1::Patrons::SelfRenewal; + +# This file is part of Koha. +# +# Koha 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. +# +# Koha 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 Koha; if not, see . + +use Modern::Perl; + +use Mojo::Base 'Mojolicious::Controller'; + +use Koha::Patrons; +use Koha::Patron::Attribute::Types; + +use Try::Tiny qw( catch try ); +use JSON qw( to_json ); + +=head1 NAME + +Koha::REST::V1::Patrons::SelfRenewal + +=head1 API + +=head2 Methods + +=head3 start + +Controller function that retrieves the metadata required to begin a patron self renewal + +=cut + +sub start { + my $c = shift->openapi->valid_input or return; + + my $patron = Koha::Patrons->find( $c->param('patron_id') ); + + return $c->render( status => 403, openapi => { error => "You are not eligible for self-renewal" } ) + if !$patron->is_eligible_for_self_renewal() || !$patron; + + return try { + my $category = $patron->category; + my $self_renewal_settings = { + self_renewal_failure_message => $category->self_renewal_failure_message, + self_renewal_information_message => $category->self_renewal_information_message, + opac_patron_details => C4::Context->preference('OPACPatronDetails') + }; + + return $c->render( + status => 200, + openapi => { self_renewal_settings => $self_renewal_settings } + ); + } catch { + $c->unhandled_exception($_); + }; +} + +=head3 submit + +Controller function that receives the renewal request and process the renewal + +=cut + +sub submit { + my $c = shift->openapi->valid_input or return; + + my $patron = Koha::Patrons->find( $c->param('patron_id') ); + + return try { + Koha::Database->new->schema->txn_do( + sub { + my $body = $c->req->json; + + return $c->render( status => 403, openapi => { error => "You are not eligible for self-renewal" } ) + if !$patron->is_eligible_for_self_renewal() || !$patron; + + my $OPACPatronDetails = C4::Context->preference("OPACPatronDetails"); + if ($OPACPatronDetails) { + my $patron_details = $body->{patron}; + my $extended_attributes = delete $patron_details->{extended_attributes}; + my $changed_fields = {}; + my $changes_detected; + foreach my $key ( keys %$patron_details ) { + my $submitted_value = $patron_details->{$key}; + my $original_value = $patron->$key; + + if ( $submitted_value ne $original_value ) { + $changed_fields->{$key} = $submitted_value; + $changes_detected++; + } + } + if ($changes_detected) { + $changed_fields->{changed_fields} = join ',', keys %$changed_fields; + $changed_fields->{extended_attributes} = to_json($extended_attributes) if $extended_attributes; + $patron->request_modification($changed_fields); + } + } + + my $new_expiry_date = $patron->renew_account; + my $response = { expiry_date => $new_expiry_date }; + + if ($new_expiry_date) { + my $is_notice_mandatory = $patron->category->enforce_expiry_notice; + my $letter_params = $patron->create_expiry_notice_parameters( + { + letter_code => "MEMBERSHIP_RENEWED", is_notice_mandatory => $is_notice_mandatory, + forceprint => 1 + } + ); + + my $result = $patron->queue_notice($letter_params); + $response->{confirmation_sent} = 1 if $result->{sent}; + } + + return $c->render( + status => 201, + openapi => $response + ); + } + ); + + } catch { + $c->unhandled_exception($_); + }; + +} + +1; diff --git a/koha-tmpl/intranet-tmpl/prog/js/fetch/patron-api-client.js b/koha-tmpl/intranet-tmpl/prog/js/fetch/patron-api-client.js index 4ddaed0cfcb..b12135b50b8 100644 --- a/koha-tmpl/intranet-tmpl/prog/js/fetch/patron-api-client.js +++ b/koha-tmpl/intranet-tmpl/prog/js/fetch/patron-api-client.js @@ -25,6 +25,23 @@ export class PatronAPIClient { }), }; } + + get self_renewal() { + return { + start: (id, query, params, headers) => + this.httpClient.getAll({ + endpoint: `public/patrons/${id}/self_renewal`, + query, + params, + headers, + }), + submit: (id, renewal) => + this.httpClient.post({ + endpoint: `public/patrons/${id}/self_renewal`, + body: renewal, + }), + }; + } } export default PatronAPIClient; diff --git a/misc/cronjobs/membership_expiry.pl b/misc/cronjobs/membership_expiry.pl index 5cecbe8fc32..1ea7cb03139 100755 --- a/misc/cronjobs/membership_expiry.pl +++ b/misc/cronjobs/membership_expiry.pl @@ -285,32 +285,11 @@ while ( my $expiring_patron = $upcoming_mem_expires->next ) { $which_notice = $letter_expiry; } - my $from_address = $expiring_patron->library->from_email_address; - my $letter_params = { - module => 'members', - letter_code => $which_notice, - branchcode => $expiring_patron->branchcode, - lang => $expiring_patron->lang, - borrowernumber => $expiring_patron->borrowernumber, - tables => { - borrowers => $expiring_patron->borrowernumber, - branches => $expiring_patron->branchcode, - }, - }; - - my $sending_params = { - letter_params => $letter_params, - message_name => 'Patron_Expiry', - forceprint => $forceprint - }; - my $is_notice_mandatory = grep( $expiring_patron->categorycode, @mandatory_expiry_notice_categories ); - if ($is_notice_mandatory) { - $sending_params->{expiry_notice_mandatory} = 1; - $sending_params->{primary_contact_method} = $forceprint ? 'print' : $expiring_patron->primary_contact_method; - } + my $letter_params = $expiring_patron->create_expiry_notice_parameters( + { letter_code => $which_notice, forceprint => $forceprint, is_notice_mandatory => $is_notice_mandatory } ); - my $result = $expiring_patron->queue_notice($sending_params); + my $result = $expiring_patron->queue_notice($letter_params); $count_enqueued++ if $result->{sent}; } -- 2.39.5