From 7d157f4ba6d0e28a57b8e1bc8f6cba07334223d9 Mon Sep 17 00:00:00 2001 From: Martin Renvoize Date: Sun, 11 Jan 2026 17:46:01 +0000 Subject: [PATCH] Bug 41592: Add API endpoints for global account line search This patch adds new REST API endpoints to support cross-register refund functionality by allowing global searches of account lines (credits and debits) across all patrons. Changes: - Add /api/v1/account/credits endpoint for searching credits globally - Add /api/v1/account/debits endpoint for searching debits globally - Support embedding of related debits in credits endpoint - Allow searching by credit_number or accountlines_id - Fix Koha::Account::Line credits() and debits() methods to return proper Koha::Account::Credits and Koha::Account::Debits objects - Make patron_id nullable in credit and debit API definitions to support anonymous transactions The new endpoints require the 'anonymous_refund' cash_management permission and enable staff to search for and refund transactions made at any register. Test plan: 1. Apply patch and run: yarn build && ktd --restart_all 2. Test API endpoint: GET /api/v1/account/credits?q={"credit_number":"CR-123"} 3. Test with embed: GET /api/v1/account/credits?q={"credit_number":"CR-123"} with header x-koha-embed: debits 4. Verify results include credit details and embedded debit information 5. Bonus points: Run the new and updated unit tests: prove -v t/db_dependent/api/v1/account.t prove -v t/db_dependent/Koha/Account/Line.t --- Koha/Account/Credit.pm | 2 +- Koha/Account/Line.pm | 4 +- Koha/REST/V1/Account.pm | 81 ++++++ api/v1/swagger/definitions/credit.yaml | 8 +- api/v1/swagger/definitions/debit.yaml | 4 +- api/v1/swagger/paths/account.yaml | 115 +++++++++ api/v1/swagger/swagger.yaml | 4 + t/db_dependent/Koha/Account/Line.t | 11 +- t/db_dependent/api/v1/account.t | 343 +++++++++++++++++++++++++ 9 files changed, 563 insertions(+), 9 deletions(-) create mode 100644 Koha/REST/V1/Account.pm create mode 100644 api/v1/swagger/paths/account.yaml create mode 100755 t/db_dependent/api/v1/account.t diff --git a/Koha/Account/Credit.pm b/Koha/Account/Credit.pm index cf4b715788c..bd593e1ebb9 100644 --- a/Koha/Account/Credit.pm +++ b/Koha/Account/Credit.pm @@ -41,7 +41,7 @@ on the API. sub to_api_mapping { return { accountlines_id => 'account_line_id', - credit_number => undef, + credit_number => 'credit_number', credit_type_code => 'type', debit_type_code => undef, amountoutstanding => 'amount_outstanding', diff --git a/Koha/Account/Line.pm b/Koha/Account/Line.pm index f44761fcf48..737571e8a11 100644 --- a/Koha/Account/Line.pm +++ b/Koha/Account/Line.pm @@ -200,7 +200,7 @@ sub credits { my $cond_m = { map { "credit." . $_ => $cond->{$_} } keys %{$cond} }; my $rs = $self->_result->search_related('account_offsets_debits')->search_related( 'credit', $cond_m, $attr ); return unless $rs; - return Koha::Account::Lines->_new_from_dbic($rs); + return Koha::Account::Credits->_new_from_dbic($rs); } =head3 debits @@ -224,7 +224,7 @@ sub debits { my $cond_m = { map { "debit." . $_ => $cond->{$_} } keys %{$cond} }; my $rs = $self->_result->search_related('account_offsets_credits')->search_related( 'debit', $cond_m, $attr ); return unless $rs; - return Koha::Account::Lines->_new_from_dbic($rs); + return Koha::Account::Debits->_new_from_dbic($rs); } =head3 void diff --git a/Koha/REST/V1/Account.pm b/Koha/REST/V1/Account.pm new file mode 100644 index 00000000000..20888ed93ef --- /dev/null +++ b/Koha/REST/V1/Account.pm @@ -0,0 +1,81 @@ +package Koha::REST::V1::Account; + +# 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::Account::Credits; +use Koha::Account::Debits; + +use Try::Tiny qw( catch try ); + +=head1 NAME + +Koha::REST::V1::Account - Global account line endpoints (not patron-specific) + +=head1 API + +=head2 Methods + +=head3 list_credits + +Controller function that handles listing credits globally (not patron-specific). +Used for searching credits across all patrons by credit_number or accountlines_id. + +=cut + +sub list_credits { + my $c = shift->openapi->valid_input or return; + + return try { + my $credits_rs = Koha::Account::Credits->new; + my $credits = $c->objects->search($credits_rs); + + return $c->render( + status => 200, + openapi => $credits + ); + } catch { + $c->unhandled_exception($_); + }; +} + +=head3 list_debits + +Controller function that handles listing debits globally (not patron-specific). +Used for searching debits across all patrons. + +=cut + +sub list_debits { + my $c = shift->openapi->valid_input or return; + + return try { + my $debits_rs = Koha::Account::Debits->new; + my $debits = $c->objects->search($debits_rs); + + return $c->render( + status => 200, + openapi => $debits + ); + } catch { + $c->unhandled_exception($_); + }; +} + +1; diff --git a/api/v1/swagger/definitions/credit.yaml b/api/v1/swagger/definitions/credit.yaml index 6e997d5f124..676669c99f6 100644 --- a/api/v1/swagger/definitions/credit.yaml +++ b/api/v1/swagger/definitions/credit.yaml @@ -53,7 +53,9 @@ properties: - "null" description: Internal identifier for the library in which the transaction took place patron_id: - type: integer + type: + - integer + - "null" readOnly: true description: Internal identifier for the patron the account line belongs to payment_type: @@ -82,6 +84,10 @@ properties: - integer - "null" description: Internal patron identifier for the staff member that introduced the account line + debits: + type: array + items: + $ref: "debit.yaml" required: - amount additionalProperties: false diff --git a/api/v1/swagger/definitions/debit.yaml b/api/v1/swagger/definitions/debit.yaml index 07ea7d7884d..4c24eb7befc 100644 --- a/api/v1/swagger/definitions/debit.yaml +++ b/api/v1/swagger/definitions/debit.yaml @@ -58,7 +58,9 @@ properties: - "null" description: Internal identifier for the library in which the transaction took place patron_id: - type: integer + type: + - integer + - "null" description: Internal identifier for the patron the account line belongs to payout_type: type: diff --git a/api/v1/swagger/paths/account.yaml b/api/v1/swagger/paths/account.yaml new file mode 100644 index 00000000000..bc96a321a8c --- /dev/null +++ b/api/v1/swagger/paths/account.yaml @@ -0,0 +1,115 @@ +--- +/account/credits: + get: + x-mojo-to: Account#list_credits + operationId: listAccountCredits + tags: + - account + summary: List account credits (global search, not patron-specific) + 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" + - name: x-koha-embed + in: header + required: false + description: Embed list sent as a request header + type: array + items: + type: string + enum: + - debits + - register + collectionFormat: csv + responses: + "200": + description: A list of credits + schema: + type: array + items: + $ref: "../swagger.yaml#/definitions/credit" + "400": + description: | + Bad request. Possible `error_code` attribute values: + + * `invalid_query` + schema: + $ref: "../swagger.yaml#/definitions/error" + "403": + description: Access forbidden + schema: + $ref: "../swagger.yaml#/definitions/error" + "500": + description: Internal error + schema: + $ref: "../swagger.yaml#/definitions/error" + "503": + description: Under maintenance + schema: + $ref: "../swagger.yaml#/definitions/error" + x-koha-authorization: + permissions: + cash_management: anonymous_refund +/account/debits: + get: + x-mojo-to: Account#list_debits + operationId: listAccountDebits + tags: + - account + summary: List account debits (global search, not patron-specific) + 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" + - name: x-koha-embed + in: header + required: false + description: Embed list sent as a request header + type: array + items: + type: string + enum: + - credit + - register + collectionFormat: csv + responses: + "200": + description: A list of debits + schema: + type: array + items: + $ref: "../swagger.yaml#/definitions/debit" + "400": + description: | + Bad request. Possible `error_code` attribute values: + + * `invalid_query` + schema: + $ref: "../swagger.yaml#/definitions/error" + "403": + description: Access forbidden + schema: + $ref: "../swagger.yaml#/definitions/error" + "500": + description: Internal error + schema: + $ref: "../swagger.yaml#/definitions/error" + "503": + description: Under maintenance + schema: + $ref: "../swagger.yaml#/definitions/error" + x-koha-authorization: + permissions: + cash_management: anonymous_refund diff --git a/api/v1/swagger/swagger.yaml b/api/v1/swagger/swagger.yaml index dcb0a3cc66e..685e7a5f733 100644 --- a/api/v1/swagger/swagger.yaml +++ b/api/v1/swagger/swagger.yaml @@ -233,6 +233,10 @@ paths: $ref: "./paths/acquisitions_vendors.yaml#/~1acquisitions~1vendors~1{vendor_id}" "/acquisitions/vendors/{vendor_id}/issues": $ref: "./paths/acquisitions_vendor_issues.yaml#/~1acquisitions~1vendors~1{vendor_id}~1issues" + /account/credits: + $ref: ./paths/account.yaml#/~1account~1credits + /account/debits: + $ref: ./paths/account.yaml#/~1account~1debits /advanced_editor/macros: $ref: ./paths/advancededitormacros.yaml#/~1advanced_editor~1macros /advanced_editor/macros/shared: diff --git a/t/db_dependent/Koha/Account/Line.t b/t/db_dependent/Koha/Account/Line.t index 30b623d0901..965b26a04ce 100755 --- a/t/db_dependent/Koha/Account/Line.t +++ b/t/db_dependent/Koha/Account/Line.t @@ -787,7 +787,7 @@ subtest 'checkout() tests' => sub { }; subtest 'credits() and debits() tests' => sub { - plan tests => 12; + plan tests => 15; $schema->storage->txn_begin; @@ -827,7 +827,8 @@ subtest 'credits() and debits() tests' => sub { $credit2->apply( { debits => [ $debit1, $debit2 ] } ); my $credits = $debit1->credits; - is( $credits->count, 2, '2 Credits applied to debit 1' ); + is( ref($credits), 'Koha::Account::Credits', 'credits() returns a Koha::Account::Credits object' ); + is( $credits->count, 2, '2 Credits applied to debit 1' ); my $credit = $credits->next; is( $credit->amount + 0, -5, 'Correct first credit' ); $credit = $credits->next; @@ -839,12 +840,14 @@ subtest 'credits() and debits() tests' => sub { is( $credit->amount + 0, -10, 'Correct first credit' ); my $debits = $credit1->debits; - is( $debits->count, 1, 'Credit 1 applied to 1 debit' ); + is( ref($debits), 'Koha::Account::Debits', 'debits() returns a Koha::Account::Debits object' ); + is( $debits->count, 1, 'Credit 1 applied to 1 debit' ); my $debit = $debits->next; is( $debit->amount + 0, 8, 'Correct first debit' ); $debits = $credit2->debits; - is( $debits->count, 2, 'Credit 2 applied to 2 debits' ); + is( ref($debits), 'Koha::Account::Debits', 'debits() returns a Koha::Account::Debits object' ); + is( $debits->count, 2, 'Credit 2 applied to 2 debits' ); $debit = $debits->next; is( $debit->amount + 0, 8, 'Correct first debit' ); $debit = $debits->next; diff --git a/t/db_dependent/api/v1/account.t b/t/db_dependent/api/v1/account.t new file mode 100755 index 00000000000..43525cbf9ce --- /dev/null +++ b/t/db_dependent/api/v1/account.t @@ -0,0 +1,343 @@ +#!/usr/bin/perl + +# 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 Test::More tests => 4; +use Test::NoWarnings; + +use Test::Mojo; + +use t::lib::TestBuilder; +use t::lib::Mocks; + +use Koha::Account::Lines; +use Koha::Account::CreditTypes; +use Koha::Cash::Registers; + +my $schema = Koha::Database->new->schema; +my $builder = t::lib::TestBuilder->new; + +t::lib::Mocks::mock_preference( 'RESTBasicAuth', 1 ); + +my $t = Test::Mojo->new('Koha::REST::V1'); + +subtest 'list_credits() tests' => sub { + + plan tests => 23; + + $schema->storage->txn_begin; + + # Create test patron with anonymous_refund permission + my $librarian = $builder->build_object( + { + class => 'Koha::Patrons', + value => { flags => 0 } + } + ); + my $password = 'thePassword123'; + $librarian->set_password( { password => $password, skip_validation => 1 } ); + $builder->build( + { + source => 'UserPermission', + value => { + borrowernumber => $librarian->borrowernumber, + module_bit => 25, # cash_management + code => 'anonymous_refund', + }, + } + ); + my $userid = $librarian->userid; + + # Create test patron without permission + my $patron = $builder->build_object( + { + class => 'Koha::Patrons', + value => { flags => 0 } + } + ); + my $password2 = 'thePassword321'; + $patron->set_password( { password => $password2, skip_validation => 1 } ); + my $unauth_userid = $patron->userid; + + # Create test library + my $library = $builder->build_object( { class => 'Koha::Libraries' } ); + + # Mock userenv for creating account lines + t::lib::Mocks::mock_userenv( { patron => $librarian, branchcode => $library->branchcode } ); + + # Create test patrons for account lines + my $patron_1 = $builder->build_object( { class => 'Koha::Patrons' } ); + my $patron_2 = $builder->build_object( { class => 'Koha::Patrons' } ); + + # Enable credit numbers for PAYMENT type + my $credit_type = Koha::Account::CreditTypes->find('PAYMENT'); + $credit_type->credit_number_enabled(1)->store; + + # Add credits with credit numbers + t::lib::Mocks::mock_preference( 'AutoCreditNumber', 'incremental' ); + my $credit_1 = $patron_1->account->add_credit( + { + amount => 5.00, + user_id => $librarian->borrowernumber, + library_id => $library->branchcode, + interface => 'test', + type => 'PAYMENT', + } + ); + $credit_1->discard_changes; + + my $credit_2 = $patron_2->account->add_credit( + { + amount => 10.00, + user_id => $librarian->borrowernumber, + library_id => $library->branchcode, + interface => 'test', + type => 'PAYMENT', + } + ); + $credit_2->discard_changes; + + my $credit_3 = $patron_1->account->add_credit( + { + amount => 7.50, + user_id => $librarian->borrowernumber, + library_id => $library->branchcode, + interface => 'test', + type => 'PAYMENT', + } + ); + $credit_3->discard_changes; + + # TEST: Unauthorized access (no permission) + $t->get_ok("//$unauth_userid:$password2@/api/v1/account/credits")->status_is(403); + + # TEST: Authorized access - list all credits + $t->get_ok("//$userid:$password@/api/v1/account/credits")->status_is(200) + ->json_is( '/0/account_line_id', $credit_1->accountlines_id ) + ->json_is( '/1/account_line_id', $credit_2->accountlines_id ) + ->json_is( '/2/account_line_id', $credit_3->accountlines_id ); + + # TEST: Search by credit_number using query parameter + my $credit_number = $credit_1->credit_number; + $t->get_ok( "//$userid:$password@/api/v1/account/credits" . "?q=" . '{"credit_number":"' . $credit_number . '"}' ) + ->status_is(200)->json_is( '/0/account_line_id', $credit_1->accountlines_id ) + ->json_is( '/0/credit_number', $credit_number ); + + # TEST: Search by accountlines_id + $t->get_ok( "//$userid:$password@/api/v1/account/credits" . "?q=" + . '{"account_line_id":' + . $credit_2->accountlines_id + . '}' )->status_is(200)->json_is( '/0/account_line_id', $credit_2->accountlines_id ); + + # TEST: Pagination + $t->get_ok("//$userid:$password@/api/v1/account/credits?_per_page=1")->status_is(200) + ->json_is( '/0/account_line_id', $credit_1->accountlines_id )->header_is( 'X-Total-Count', 3 ); + + # TEST: Ordering + $t->get_ok("//$userid:$password@/api/v1/account/credits?_order_by=-account_line_id")->status_is(200) + ->json_is( '/0/account_line_id', $credit_3->accountlines_id ) + ->json_is( '/1/account_line_id', $credit_2->accountlines_id ) + ->json_is( '/2/account_line_id', $credit_1->accountlines_id ); + + $schema->storage->txn_rollback; +}; + +subtest 'list_credits() with embed tests' => sub { + + plan tests => 8; + + $schema->storage->txn_begin; + + # Create test patron with anonymous_refund permission + my $librarian = $builder->build_object( + { + class => 'Koha::Patrons', + value => { flags => 0 } + } + ); + my $password = 'thePassword123'; + $librarian->set_password( { password => $password, skip_validation => 1 } ); + $builder->build( + { + source => 'UserPermission', + value => { + borrowernumber => $librarian->borrowernumber, + module_bit => 25, # cash_management + code => 'anonymous_refund', + }, + } + ); + my $userid = $librarian->userid; + + # Create test library + my $library = $builder->build_object( { class => 'Koha::Libraries' } ); + + # Mock userenv for creating account lines + t::lib::Mocks::mock_userenv( { patron => $librarian, branchcode => $library->branchcode } ); + + # Create test patron + my $patron = $builder->build_object( { class => 'Koha::Patrons' } ); + + # Add debit + my $debit = $patron->account->add_debit( + { + amount => 10.00, + type => 'OVERDUE', + interface => 'test', + user_id => $librarian->borrowernumber, + library_id => $library->branchcode, + } + ); + + # Add credit and apply to debit + my $credit = $patron->account->add_credit( + { + amount => 10.00, + user_id => $librarian->borrowernumber, + library_id => $library->branchcode, + interface => 'test', + type => 'PAYMENT', + } + ); + $credit->apply( { debits => [$debit] } ); + + # TEST: Embed debits + $t->get_ok( "//$userid:$password@/api/v1/account/credits" . "?q=" + . '{"account_line_id":' + . $credit->accountlines_id + . '}' => { 'x-koha-embed' => 'debits' } )->status_is(200) + ->json_is( '/0/account_line_id', $credit->accountlines_id )->json_has('/0/debits') + ->json_is( '/0/debits/0/account_line_id', $debit->accountlines_id ); + + # TEST: Without embed, debits should not be present + $t->get_ok( + "//$userid:$password@/api/v1/account/credits" . "?q=" . '{"account_line_id":' . $credit->accountlines_id . '}' ) + ->status_is(200)->json_hasnt('/0/debits'); + + $schema->storage->txn_rollback; +}; + +subtest 'list_debits() tests' => sub { + + plan tests => 22; + + $schema->storage->txn_begin; + + # Create test patron with anonymous_refund permission + my $librarian = $builder->build_object( + { + class => 'Koha::Patrons', + value => { flags => 0 } + } + ); + my $password = 'thePassword123'; + $librarian->set_password( { password => $password, skip_validation => 1 } ); + $builder->build( + { + source => 'UserPermission', + value => { + borrowernumber => $librarian->borrowernumber, + module_bit => 25, # cash_management + code => 'anonymous_refund', + }, + } + ); + my $userid = $librarian->userid; + + # Create test patron without permission + my $patron = $builder->build_object( + { + class => 'Koha::Patrons', + value => { flags => 0 } + } + ); + my $password2 = 'thePassword321'; + $patron->set_password( { password => $password2, skip_validation => 1 } ); + my $unauth_userid = $patron->userid; + + # Create test library + my $library = $builder->build_object( { class => 'Koha::Libraries' } ); + + # Mock userenv for creating account lines + t::lib::Mocks::mock_userenv( { patron => $librarian, branchcode => $library->branchcode } ); + + # Create test patrons for account lines + my $patron_1 = $builder->build_object( { class => 'Koha::Patrons' } ); + my $patron_2 = $builder->build_object( { class => 'Koha::Patrons' } ); + + # Add debits + my $debit_1 = $patron_1->account->add_debit( + { + amount => 5.00, + type => 'OVERDUE', + interface => 'test', + user_id => $librarian->borrowernumber, + library_id => $library->branchcode, + } + ); + + my $debit_2 = $patron_2->account->add_debit( + { + amount => 10.00, + type => 'LOST', + interface => 'test', + user_id => $librarian->borrowernumber, + library_id => $library->branchcode, + } + ); + + my $debit_3 = $patron_1->account->add_debit( + { + amount => 7.50, + type => 'OVERDUE', + interface => 'test', + user_id => $librarian->borrowernumber, + library_id => $library->branchcode, + } + ); + + # TEST: Unauthorized access (no permission) + $t->get_ok("//$unauth_userid:$password2@/api/v1/account/debits")->status_is(403); + + # TEST: Authorized access - list all debits + $t->get_ok("//$userid:$password@/api/v1/account/debits")->status_is(200) + ->json_is( '/0/account_line_id', $debit_1->accountlines_id ) + ->json_is( '/1/account_line_id', $debit_2->accountlines_id ) + ->json_is( '/2/account_line_id', $debit_3->accountlines_id ); + + # TEST: Search by accountlines_id + $t->get_ok( + "//$userid:$password@/api/v1/account/debits" . "?q=" . '{"account_line_id":' . $debit_2->accountlines_id . '}' ) + ->status_is(200)->json_is( '/0/account_line_id', $debit_2->accountlines_id ); + + # TEST: Search by type + $t->get_ok( "//$userid:$password@/api/v1/account/debits" . "?q=" . '{"type":"LOST"}' )->status_is(200) + ->json_is( '/0/account_line_id', $debit_2->accountlines_id ); + + # TEST: Pagination + $t->get_ok("//$userid:$password@/api/v1/account/debits?_per_page=1")->status_is(200) + ->json_is( '/0/account_line_id', $debit_1->accountlines_id )->header_is( 'X-Total-Count', 3 ); + + # TEST: Ordering + $t->get_ok("//$userid:$password@/api/v1/account/debits?_order_by=-account_line_id")->status_is(200) + ->json_is( '/0/account_line_id', $debit_3->accountlines_id ) + ->json_is( '/1/account_line_id', $debit_2->accountlines_id ) + ->json_is( '/2/account_line_id', $debit_1->accountlines_id ); + + $schema->storage->txn_rollback; +}; -- 2.52.0