From 0c394053a8ab01b6418e15df5c7cbf35000f4146 Mon Sep 17 00:00:00 2001 From: Matt Blenkinsop Date: Wed, 21 Jan 2026 09:44:31 +0000 Subject: [PATCH] Bug 26355: Add an endpoint and class methods to handle patron renewal --- Koha/Patron.pm | 108 +++++++++++++ Koha/REST/V1/Patrons/SelfRenewal.pm | 151 ++++++++++++++++++ .../definitions/patron_self_renewal.yaml | 23 +++ api/v1/swagger/paths/public_patrons.yaml | 106 ++++++++++++ .../prog/js/fetch/patron-api-client.js | 17 ++ misc/cronjobs/membership_expiry.pl | 27 +--- 6 files changed, 408 insertions(+), 24 deletions(-) create mode 100644 Koha/REST/V1/Patrons/SelfRenewal.pm create mode 100644 api/v1/swagger/definitions/patron_self_renewal.yaml diff --git a/Koha/Patron.pm b/Koha/Patron.pm index b174636cdd4..b03e43257db 100644 --- a/Koha/Patron.pm +++ b/Koha/Patron.pm @@ -3019,6 +3019,8 @@ sub to_api { ? Mojo::JSON->true : Mojo::JSON->false; + $json_patron->{self_renewal_available} = $self->is_eligible_for_self_renewal(); + return $json_patron; } @@ -3592,6 +3594,112 @@ 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 = + $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..64efc143aca --- /dev/null +++ b/Koha/REST/V1/Patrons/SelfRenewal.pm @@ -0,0 +1,151 @@ +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 = $c->stash('koha.user'); + + return $c->render( status => 403, openapi => { error => "You are not eligible for self-renewal" } ) + if !$patron->is_eligible_for_self_renewal(); + + return try { + my $verification_checks = Koha::Patron::Attribute::Types->search_with_library_limits( + { + '-and' => { + self_renewal_verification_check => 1, + '-or' => [ + "category_code" => undef, + "category_code" => $patron->categorycode, + ] + } + }, + {} + ); + + my $category = $patron->category; + my $self_renewal_settings = { + self_renewal_failure_message => $category->self_renewal_failure_message, + opac_patron_details => C4::Context->preference('OPACPatronDetails') + }; + + return $c->render( + status => 200, + openapi => { + verification_checks => $verification_checks, + 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 = $c->stash('koha.user'); + + 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(); + + my $OPACPatronDetails = C4::Context->preference("OPACPatronDetails"); + if ($OPACPatronDetails) { + my $extended_attributes = delete $body->{extended_attributes}; + my $changed_fields = {}; + my $changes_detected; + foreach my $key ( keys %$body ) { + my $submitted_value = $body->{$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/api/v1/swagger/definitions/patron_self_renewal.yaml b/api/v1/swagger/definitions/patron_self_renewal.yaml new file mode 100644 index 00000000000..a9291b72c83 --- /dev/null +++ b/api/v1/swagger/definitions/patron_self_renewal.yaml @@ -0,0 +1,23 @@ +--- +type: "object" +properties: + verification_steps: + type: + - array + - "null" + description: Any additional verification steps set in patron attribute types + self_renewal_settings: + type: + - object + - "null" + description: The object representing the self-renewal settings for the patron's category + expiry_date: + type: + - string + - "null" + description: The new expiry date on successful self-renewal + confirmation_sent: + type: + - array + - "null" + description: The method(s) through which confirmation of renewal has been sent diff --git a/api/v1/swagger/paths/public_patrons.yaml b/api/v1/swagger/paths/public_patrons.yaml index dfa00ae8256..da0a7555a4b 100644 --- a/api/v1/swagger/paths/public_patrons.yaml +++ b/api/v1/swagger/paths/public_patrons.yaml @@ -344,3 +344,109 @@ description: Under maintenance schema: $ref: "../swagger.yaml#/definitions/error" +/public/patrons/self_renewal: + get: + x-mojo-to: Patrons::SelfRenewal#start + operationId: startSelfRenewal + tags: + - patrons + summary: List patrons + produces: + - application/json + parameters: + - $ref: "../swagger.yaml#/parameters/match" + - $ref: "../swagger.yaml#/parameters/order_by" + - $ref: "../swagger.yaml#/parameters/page" + - $ref: "../swagger.yaml#/parameters/per_page" + - $ref: "../swagger.yaml#/parameters/q_param" + - $ref: "../swagger.yaml#/parameters/q_body" + - $ref: "../swagger.yaml#/parameters/request_id_header" + responses: + "200": + description: The metadata required to start patron self-renewal + schema: + type: object + $ref: "../swagger.yaml#/definitions/patron_self_renewal" + "400": + description: | + Bad request. Possible `error_code` attribute values: + + * `invalid_query` + schema: + $ref: "../swagger.yaml#/definitions/error" + "401": + description: Authentication required + schema: + $ref: "../swagger.yaml#/definitions/error" + "403": + description: Access forbidden + schema: + $ref: "../swagger.yaml#/definitions/error" + "500": + description: | + Internal server error. Possible `error_code` attribute values: + + * `internal_server_error` + schema: + $ref: "../swagger.yaml#/definitions/error" + "503": + description: Under maintenance + schema: + $ref: "../swagger.yaml#/definitions/error" + post: + x-mojo-to: Patrons::SelfRenewal#submit + operationId: submitSelfRenewal + tags: + - patrons + summary: Submit self-renewal form + consumes: + - application/json + produces: + - application/json + parameters: + - description: A JSON object containing information about the self-renewal + in: body + name: body + required: true + schema: + $ref: "../swagger.yaml#/definitions/patron_self_renewal" + responses: + 201: + description: A successfully completed renewal + schema: + items: + $ref: "../swagger.yaml#/definitions/patron_self_renewal" + 400: + description: Bad request + schema: + $ref: "../swagger.yaml#/definitions/error" + 401: + description: Authentication required + schema: + $ref: "../swagger.yaml#/definitions/error" + 403: + description: Access forbidden + schema: + $ref: "../swagger.yaml#/definitions/error" + 404: + description: Ressource not found + schema: + $ref: "../swagger.yaml#/definitions/error" + 409: + description: Conflict in creating resource + schema: + $ref: "../swagger.yaml#/definitions/error" + 413: + description: Payload too large + schema: + $ref: "../swagger.yaml#/definitions/error" + 500: + description: |- + Internal server error. Possible `error_code` attribute values: + * `internal_server_error` + schema: + $ref: "../swagger.yaml#/definitions/error" + 503: + description: Under maintenance + schema: + $ref: "../swagger.yaml#/definitions/error" 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..7e14b32d7ad 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: (query, params, headers) => + this.httpClient.getAll({ + endpoint: "public/patrons/self_renewal", + query, + params, + headers, + }), + submit: renewal => + this.httpClient.post({ + endpoint: "public/patrons/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