View | Details | Raw Unified | Return to bug 3492
Collapse All | Expand All

(-)a/C4/Reserves.pm (-84 / +2 lines)
Lines 29-37 BEGIN { Link Here
29
29
30
        GetReserveStatus
30
        GetReserveStatus
31
31
32
        ChargeReserveFee
33
        GetReserveFee
34
35
        ModReserveAffect
32
        ModReserveAffect
36
        ModReserve
33
        ModReserve
37
        ModReserveStatus
34
        ModReserveStatus
Lines 287-295 sub AddReserve { Link Here
287
    my $reserve_id = $hold->id();
284
    my $reserve_id = $hold->id();
288
285
289
    # add a reserve fee if needed
286
    # add a reserve fee if needed
290
    if ( C4::Context->preference('HoldFeeMode') ne 'any_time_is_collected' ) {
287
    if ( $hold->should_charge('placement') ) {
291
        my $reserve_fee = GetReserveFee( $borrowernumber, $biblionumber );
288
        $hold->charge_hold_fee();
292
        ChargeReserveFee( $borrowernumber, $reserve_fee, $title );
293
    }
289
    }
294
290
295
    FixPriority( { biblionumber => $biblionumber } );
291
    FixPriority( { biblionumber => $biblionumber } );
Lines 724-807 sub CanItemBeReserved { Link Here
724
    return _cache { status => 'OK' };
720
    return _cache { status => 'OK' };
725
}
721
}
726
722
727
=head2 ChargeReserveFee
728
729
    $fee = ChargeReserveFee( $borrowernumber, $fee, $title );
730
731
    Charge the fee for a reserve (if $fee > 0)
732
733
=cut
734
735
sub ChargeReserveFee {
736
    my ( $borrowernumber, $fee, $title ) = @_;
737
    return if !$fee || $fee == 0;    # the last test is needed to include 0.00
738
    Koha::Account->new( { patron_id => $borrowernumber } )->add_debit(
739
        {
740
            amount       => $fee,
741
            description  => $title,
742
            note         => undef,
743
            user_id      => C4::Context->userenv ? C4::Context->userenv->{'number'} : undef,
744
            library_id   => C4::Context->userenv ? C4::Context->userenv->{'branch'} : undef,
745
            interface    => C4::Context->interface,
746
            invoice_type => undef,
747
            type         => 'RESERVE',
748
            item_id      => undef
749
        }
750
    );
751
}
752
753
=head2 GetReserveFee
754
755
    $fee = GetReserveFee( $borrowernumber, $biblionumber );
756
757
    Calculate the fee for a reserve (if applicable).
758
759
=cut
760
761
sub GetReserveFee {
762
    my ( $borrowernumber, $biblionumber ) = @_;
763
    my $borquery = qq{
764
SELECT reservefee FROM borrowers LEFT JOIN categories ON borrowers.categorycode = categories.categorycode WHERE borrowernumber = ?
765
    };
766
    my $issue_qry = qq{
767
SELECT COUNT(*) FROM items
768
LEFT JOIN issues USING (itemnumber)
769
WHERE items.biblionumber=? AND issues.issue_id IS NULL
770
    };
771
    my $holds_qry = qq{
772
SELECT COUNT(*) FROM reserves WHERE biblionumber=? AND borrowernumber<>?
773
    };
774
775
    my $dbh = C4::Context->dbh;
776
    my ($fee) = $dbh->selectrow_array( $borquery, undef, ($borrowernumber) );
777
    $fee += 0;
778
    my $hold_fee_mode = C4::Context->preference('HoldFeeMode') || 'not_always';
779
    if ( $fee and $fee > 0 and $hold_fee_mode eq 'not_always' ) {
780
781
        # This is a reconstruction of the old code:
782
        # Compare number of items with items issued, and optionally check holds
783
        # If not all items are issued and there are no holds: charge no fee
784
        # NOTE: Lost, damaged, not-for-loan, etc. are just ignored here
785
        my ( $notissued, $reserved );
786
        ($notissued) = $dbh->selectrow_array(
787
            $issue_qry, undef,
788
            ($biblionumber)
789
        );
790
        if ( $notissued == 0 ) {
791
792
            # all items are issued
793
            ($reserved) = $dbh->selectrow_array(
794
                $holds_qry, undef,
795
                ( $biblionumber, $borrowernumber )
796
            );
797
            $fee = 0 if $reserved == 0;
798
        } else {
799
            $fee = 0;
800
        }
801
    }
802
    return $fee;
803
}
804
805
=head2 GetReserveStatus
723
=head2 GetReserveStatus
806
724
807
  $reservestatus = GetReserveStatus($itemnumber);
725
  $reservestatus = GetReserveStatus($itemnumber);
(-)a/Koha/Hold.pm (-15 / +210 lines)
Lines 1038-1058 sub fill { Link Here
1038
            # now fix the priority on the others....
1038
            # now fix the priority on the others....
1039
            C4::Reserves::FixPriority( { biblionumber => $self->biblionumber } );
1039
            C4::Reserves::FixPriority( { biblionumber => $self->biblionumber } );
1040
1040
1041
            if ( C4::Context->preference('HoldFeeMode') eq 'any_time_is_collected' ) {
1041
            if ( $self->should_charge('collection') ) {
1042
                my $fee = $patron->category->reservefee // 0;
1042
                $self->charge_hold_fee();
1043
                if ( $fee > 0 ) {
1044
                    $patron->account->add_debit(
1045
                        {
1046
                            amount      => $fee,
1047
                            description => $self->biblio->title,
1048
                            user_id     => C4::Context->userenv ? C4::Context->userenv->{'number'} : undef,
1049
                            library_id  => C4::Context->userenv ? C4::Context->userenv->{'branch'} : undef,
1050
                            interface   => C4::Context->interface,
1051
                            type        => 'RESERVE',
1052
                            item_id     => $self->itemnumber
1053
                        }
1054
                    );
1055
                }
1056
            }
1043
            }
1057
1044
1058
            C4::Log::logaction( 'HOLDS', 'FILL', $self->id, $self, undef, $original )
1045
            C4::Log::logaction( 'HOLDS', 'FILL', $self->id, $self, undef, $original )
Lines 1259-1264 sub hold_group { Link Here
1259
    return;
1246
    return;
1260
}
1247
}
1261
1248
1249
=head3 calculate_hold_fee
1250
1251
    my $fee = $hold->calculate_hold_fee();
1252
1253
Calculate the hold fee for this hold using circulation rules.
1254
Returns the fee amount as a decimal.
1255
1256
=cut
1257
1258
sub calculate_hold_fee {
1259
    my ($self) = @_;
1260
1261
    my $item = $self->item;
1262
1263
    if ($item) {
1264
1265
        # Item-level hold - straightforward fee calculation
1266
        return $item->holds_fee( $self->patron );
1267
    } else {
1268
1269
        # Title-level hold - use strategy to determine fee
1270
        return $self->_calculate_title_hold_fee();
1271
    }
1272
}
1273
1274
=head3 should_charge
1275
1276
    my $should_charge = $hold->should_charge($stage);
1277
1278
Returns true if the hold fee should be charged at the given stage
1279
based on HoldFeeMode preference and current hold state.
1280
1281
Stage can be:
1282
- 'placement': When the hold is first placed
1283
- 'collection': When the hold is filled/collected
1284
1285
=cut
1286
1287
sub should_charge {
1288
    my ( $self, $stage ) = @_;
1289
1290
    return 0 unless $stage;
1291
    return 0 unless $stage =~ /^(placement|collection)$/;
1292
1293
    my $mode = C4::Context->preference('HoldFeeMode') || 'not_always';
1294
1295
    if ( $stage eq 'placement' ) {
1296
        return 0 if $mode eq 'any_time_is_collected';    # Don't charge at placement
1297
        return 1 if $mode eq 'any_time_is_placed';       # Always charge at placement
1298
1299
        # 'not_always' mode - check conditions at placement time
1300
        return $self->_should_charge_not_always_mode();
1301
    } elsif ( $stage eq 'collection' ) {
1302
        return 1 if $mode eq 'any_time_is_collected';    # Charge at collection
1303
        return 0 if $mode eq 'any_time_is_placed';       # Already charged at placement
1304
1305
        # 'not_always' mode - no additional fee at collection
1306
        return 0;
1307
    }
1308
1309
    return 0;
1310
}
1311
1312
=head3 charge_hold_fee
1313
1314
    $hold->charge_hold_fee({ amount => $fee });
1315
1316
Charge the patron for the hold fee.
1317
1318
=cut
1319
1320
sub charge_hold_fee {
1321
    my ( $self, $params ) = @_;
1322
1323
    my $amount = $params->{amount} // $self->calculate_hold_fee();
1324
    return unless $amount && $amount > 0;
1325
1326
    my $line = $self->patron->account->add_debit(
1327
        {
1328
            amount      => $amount,
1329
            description => $self->biblio->title,
1330
            type        => 'RESERVE',
1331
            item_id     => $self->itemnumber,
1332
            user_id     => C4::Context->userenv ? C4::Context->userenv->{number} : undef,
1333
            library_id  => C4::Context->userenv ? C4::Context->userenv->{branch} : undef,
1334
            interface   => C4::Context->interface,
1335
        }
1336
    );
1337
1338
    return $line;
1339
}
1340
1341
=head3 _calculate_title_hold_fee
1342
1343
    my $fee = $hold->_calculate_title_hold_fee();
1344
1345
Calculate the hold fee for a title-level hold using the TitleHoldFeeStrategy
1346
system preference to determine which fee to charge when items have different fees.
1347
1348
=cut
1349
1350
sub _calculate_title_hold_fee {
1351
    my ($self) = @_;
1352
1353
    # Get all holdable items for this biblio and calculate their fees
1354
    my $biblio         = $self->biblio;
1355
    my @holdable_items = $biblio->items->search(
1356
        {
1357
            -or => [
1358
                { 'me.notforloan' => 0 },
1359
                { 'me.notforloan' => undef }
1360
            ]
1361
        }
1362
    )->as_list;
1363
1364
    my @fees;
1365
    foreach my $item (@holdable_items) {
1366
1367
        # Check if item is holdable for this patron
1368
        next unless C4::Reserves::CanItemBeReserved( $self->patron, $item )->{status} eq 'OK';
1369
1370
        my $fee = $item->holds_fee( $self->patron );
1371
        push @fees, $fee;
1372
    }
1373
1374
    return 0 unless @fees;
1375
1376
    # Apply the strategy from system preference
1377
    my $strategy = C4::Context->preference('TitleHoldFeeStrategy') || 'highest';
1378
1379
    if ( $strategy eq 'highest' ) {
1380
        return ( sort { $b <=> $a } @fees )[0];    # Maximum fee
1381
    } elsif ( $strategy eq 'lowest' ) {
1382
        return ( sort { $a <=> $b } @fees )[0];    # Minimum fee
1383
    } elsif ( $strategy eq 'most_common' ) {
1384
        return $self->_get_most_common_fee(@fees);
1385
    } else {
1386
1387
        # Default to highest if unknown strategy
1388
        return ( sort { $b <=> $a } @fees )[0];
1389
    }
1390
}
1391
1392
=head3 _get_most_common_fee
1393
1394
Helper method to find the most frequently occurring fee in a list.
1395
1396
=cut
1397
1398
sub _get_most_common_fee {
1399
    my ( $self, @fees ) = @_;
1400
1401
    return 0 unless @fees;
1402
1403
    # Count frequency of each fee
1404
    my %fee_count;
1405
    for my $fee (@fees) {
1406
        $fee_count{$fee}++;
1407
    }
1408
1409
    # Sort by frequency (desc), then by value (desc) as tie breaker
1410
    my $most_common_fee =
1411
        ( sort { $fee_count{$b} <=> $fee_count{$a} || $b <=> $a } keys %fee_count )[0];
1412
1413
    return $most_common_fee;
1414
}
1415
1416
=head3 _should_charge_not_always_mode
1417
1418
Helper method to implement the 'not_always' HoldFeeMode logic.
1419
Returns true if a fee should be charged in not_always mode.
1420
1421
=cut
1422
1423
sub _should_charge_not_always_mode {
1424
    my ($self) = @_;
1425
1426
    # First check if we have a calculated fee > 0
1427
    my $potential_fee = $self->calculate_hold_fee();
1428
    return 0 unless $potential_fee > 0;
1429
1430
    # Apply not_always logic:
1431
    # - If items are available (not issued) → No fee
1432
    # - If all items issued AND no other holds → No fee
1433
    # - If all items issued AND other holds exist → Charge fee
1434
1435
    # Count items that are not on loan (available)
1436
    my $biblio          = $self->biblio;
1437
    my $available_items = $biblio->items->search( { onloan => undef } )->count;
1438
1439
    if ( $available_items > 0 ) {
1440
1441
        # Items are available, no fee needed
1442
        return 0;
1443
    }
1444
1445
    # All items are issued, check for other holds
1446
    my $other_holds = Koha::Holds->search(
1447
        {
1448
            biblionumber   => $self->biblionumber,
1449
            borrowernumber => { '!=' => $self->borrowernumber }
1450
        }
1451
    )->count;
1452
1453
    # Charge fee only if there are other holds (patron joins a queue)
1454
    return $other_holds > 0 ? 1 : 0;
1455
}
1456
1262
=head3 _move_to_old
1457
=head3 _move_to_old
1263
1458
1264
my $is_moved = $hold->_move_to_old;
1459
my $is_moved = $hold->_move_to_old;
(-)a/Koha/Item.pm (+33 lines)
Lines 1331-1336 sub has_pending_hold { Link Here
1331
    return $self->_result->tmp_holdsqueue ? 1 : 0;
1331
    return $self->_result->tmp_holdsqueue ? 1 : 0;
1332
}
1332
}
1333
1333
1334
=head3 holds_fee
1335
1336
    my $fee = $item->holds_fee($patron);
1337
1338
Calculate the hold fee for placing a hold on this specific item
1339
for the given patron. Uses ReservesControlBranch preference to determine
1340
which library's rules apply. Returns the fee amount as a decimal.
1341
1342
=cut
1343
1344
sub holds_fee {
1345
    my ( $self, $patron, $params ) = @_;
1346
1347
    return 0 unless $patron;
1348
1349
    # Use ReservesControlBranch policy to determine fee calculation branch
1350
    my $control_branch = $patron->branchcode;    # Default to patron's home library
1351
    if ( C4::Context->preference('ReservesControlBranch') eq 'ItemHomeLibrary' ) {
1352
        $control_branch = $self->homebranch;
1353
    }
1354
1355
    my $rule = Koha::CirculationRules->get_effective_rule(
1356
        {
1357
            branchcode   => $control_branch,
1358
            categorycode => $patron->categorycode,
1359
            itemtype     => $self->effective_itemtype,
1360
            rule_name    => 'hold_fee',
1361
        }
1362
    );
1363
1364
    return $rule ? $rule->rule_value : 0;
1365
}
1366
1334
=head3 has_pending_recall {
1367
=head3 has_pending_recall {
1335
1368
1336
  my $has_pending_recall
1369
  my $has_pending_recall
(-)a/installer/data/mysql/atomicupdate/bug_3492.pl (-17 / +14 lines)
Lines 3-31 use Koha::Installer::Output qw(say_warning say_failure say_success say_info); Link Here
3
3
4
return {
4
return {
5
    bug_number  => "3492",
5
    bug_number  => "3492",
6
    description => "Migrate reservefee from categories to circulation rules and remove deprecated column",
6
    description => "Migrate reservefee from categories to circulation rules",
7
    up          => sub {
7
    up          => sub {
8
        my ($args) = @_;
8
        my ($args) = @_;
9
        my ( $dbh, $out ) = @$args{qw(dbh out)};
9
        my ( $dbh, $out ) = @$args{qw(dbh out)};
10
10
11
        # Check if the reservefee column still exists
11
        if ( column_exists( 'categories', 'reservefee' ) ) {
12
        my $column_exists = $dbh->selectrow_array(
13
            q{
14
            SELECT COUNT(*) 
15
            FROM INFORMATION_SCHEMA.COLUMNS 
16
            WHERE TABLE_SCHEMA = DATABASE() 
17
            AND TABLE_NAME = 'categories' 
18
            AND COLUMN_NAME = 'reservefee'
19
        }
20
        );
21
22
        if ($column_exists) {
23
12
24
            # Check if we have any existing reservefees to migrate
13
            # Check if we have any existing reservefees to migrate
25
            my $existing_fees = $dbh->selectall_arrayref(
14
            my $existing_fees = $dbh->selectall_arrayref(
26
                q{
15
                q{
27
                SELECT categorycode, reservefee 
16
                SELECT categorycode, reservefee
28
                FROM categories 
17
                FROM categories
29
                WHERE reservefee IS NOT NULL AND reservefee > 0
18
                WHERE reservefee IS NOT NULL AND reservefee > 0
30
            }, { Slice => {} }
19
            }, { Slice => {} }
31
            );
20
            );
Lines 41-48 return { Link Here
41
                # Migrate existing reservefee values to circulation_rules
30
                # Migrate existing reservefee values to circulation_rules
42
                my $insert_rule = $dbh->prepare(
31
                my $insert_rule = $dbh->prepare(
43
                    q{
32
                    q{
44
                    INSERT IGNORE INTO circulation_rules 
33
                    INSERT IGNORE INTO circulation_rules
45
                    (branchcode, categorycode, itemtype, rule_name, rule_value) 
34
                    (branchcode, categorycode, itemtype, rule_name, rule_value)
46
                    VALUES (NULL, ?, NULL, 'hold_fee', ?)
35
                    VALUES (NULL, ?, NULL, 'hold_fee', ?)
47
                }
36
                }
48
                );
37
                );
Lines 73-78 return { Link Here
73
            say_info( $out, "The reservefee column has already been removed from the categories table." );
62
            say_info( $out, "The reservefee column has already been removed from the categories table." );
74
        }
63
        }
75
64
65
        # Add the new system preference
66
        $dbh->do(
67
            q{
68
            INSERT IGNORE INTO systempreferences (variable, value, options, explanation, type) VALUES
69
            ('TitleHoldFeeStrategy', 'highest', 'highest|lowest|most_common', 'Strategy for calculating fees on title-level holds when items have different fees: highest = charge maximum fee, lowest = charge minimum fee, most_common = charge most frequently occurring fee', 'Choice')
70
        }
71
        );
72
        say_success( $out, "Added TitleHoldFeeStrategy system preference" );
76
        say_info( $out, "Hold fees can now be configured in Administration > Circulation and fine rules." );
73
        say_info( $out, "Hold fees can now be configured in Administration > Circulation and fine rules." );
77
        say_success( $out, "Migration complete: Hold fees are now fully managed through circulation rules." );
74
        say_success( $out, "Migration complete: Hold fees are now fully managed through circulation rules." );
78
    },
75
    },
(-)a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/circulation.pref (+7 lines)
Lines 1234-1239 Circulation: Link Here
1234
                  any_time_is_placed: "any time a hold is placed."
1234
                  any_time_is_placed: "any time a hold is placed."
1235
                  not_always: "only if all items are checked out and the record has at least one hold already."
1235
                  not_always: "only if all items are checked out and the record has at least one hold already."
1236
                  any_time_is_collected: "any time a hold is collected."
1236
                  any_time_is_collected: "any time a hold is collected."
1237
        -
1238
            - "When a title-level hold is placed and different items could result in different hold fees, choose the fee level to apply as"
1239
            - pref: TitleHoldFeeStrategy
1240
              choices:
1241
                  highest: "the highest applicable fee among the items."
1242
                  lowest: "the lowest applicable fee among the items."
1243
                  most_common: "the most common fee among the items."
1237
        -
1244
        -
1238
            - pref: useDefaultReplacementCost
1245
            - pref: useDefaultReplacementCost
1239
              choices:
1246
              choices:
(-)a/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-reserve.tt (-5 / +63 lines)
Lines 145-156 Link Here
145
                                    </div>
145
                                    </div>
146
                                [% END %]
146
                                [% END %]
147
147
148
                                [% IF ( bibitemloo.reserve_charge ) %]
148
                                [% IF ( bibitemloo.reserve_charge || bibitemloo.item_hold_fees.size ) %]
149
                                    <div class="alert" id="reserve_fee">
149
                                    <div class="alert" id="reserve_fee">
150
                                        [% IF Koha.Preference('HoldFeeMode') == 'any_time_is_collected' %]
150
                                        [% IF bibitemloo.item_hold_fees.size %]
151
                                            <span>You will be charged a hold fee of [% bibitemloo.reserve_charge | $Price %] when you collect this item</span>
151
                                            [% SET fee_message_time = Koha.Preference('HoldFeeMode') == 'any_time_is_collected' ? 'when you collect' : 'for placing this hold' %]
152
                                        [% ELSE %]
152
                                            [% SET unique_fees = {} %]
153
                                            <span>You will be charged a hold fee of [% bibitemloo.reserve_charge | $Price %] for placing this hold</span>
153
                                            [% SET max_fee = 0 %]
154
                                            [% FOREACH fee IN bibitemloo.item_hold_fees %]
155
                                                [% SET unique_fees.${fee.fee} = fee.fee %]
156
                                                [% IF fee.fee > max_fee %][% SET max_fee = fee.fee %][% END %]
157
                                            [% END %]
158
                                            [% SET fee_list = unique_fees.keys.sort('cmp_numeric') %]
159
160
                                            [% IF fee_list.size == 1 && fee_list.0 > 0 %]
161
                                                <span>You will be charged a hold fee of [% fee_list.0 | $Price %] [% fee_message_time | html %]</span>
162
                                            [% ELSIF fee_list.size > 1 %]
163
                                                [% SET strategy = Koha.Preference('TitleHoldFeeStrategy') || 'highest' %]
164
                                                [% IF strategy == 'highest' %]
165
                                                    <span>You will be charged a hold fee of [% max_fee | $Price %] [% fee_message_time | html %].</span>
166
                                                [% ELSIF strategy == 'lowest' %]
167
                                                    [% SET min_fee = fee_list.sort('cmp_numeric').0 %]
168
                                                    <span>You will be charged a hold fee of [% min_fee | $Price %] [% fee_message_time | html %].</span>
169
                                                [% ELSIF strategy == 'most_common' %]
170
                                                    [% SET fee_counts = {} %]
171
                                                    [% FOREACH fee IN fee_list %]
172
                                                        [% SET fee_counts.$fee = fee_counts.$fee + 1 || 1 %]
173
                                                    [% END %]
174
                                                    [% SET most_common_fee = fee_list.0 %]
175
                                                    [% SET max_count = 0 %]
176
                                                    [% FOREACH fee IN fee_counts.keys %]
177
                                                        [% IF fee_counts.$fee > max_count %]
178
                                                            [% SET max_count = fee_counts.$fee %]
179
                                                            [% SET most_common_fee = fee %]
180
                                                        [% END %]
181
                                                    [% END %]
182
                                                    <span>You will be charged a hold fee of [% most_common_fee | $Price %] [% fee_message_time | html %].</span>
183
                                                [% ELSE %]
184
                                                    <span>You will be charged a hold fee of [% max_fee | $Price %] [% fee_message_time | html %].</span>
185
                                                [% END %]
186
                                            [% END %]
187
                                        [% ELSIF bibitemloo.reserve_charge %]
188
                                            [% IF Koha.Preference('HoldFeeMode') == 'any_time_is_collected' %]
189
                                                <span>You will be charged a hold fee of [% bibitemloo.reserve_charge | $Price %] when you collect this item</span>
190
                                            [% ELSE %]
191
                                                <span>You will be charged a hold fee of [% bibitemloo.reserve_charge | $Price %] for placing this hold</span>
192
                                            [% END %]
154
                                        [% END %]
193
                                        [% END %]
155
                                    </div>
194
                                    </div>
156
                                [% END %]
195
                                [% END %]
Lines 378-383 Link Here
378
                                                                [% END %]
417
                                                                [% END %]
379
                                                                <th>Notes</th>
418
                                                                <th>Notes</th>
380
                                                                <th>Information</th>
419
                                                                <th>Information</th>
420
                                                                [% IF bibitemloo.item_hold_fees.size > 1 %]
421
                                                                    <th>Hold fee</th>
422
                                                                [% END %]
381
                                                            </tr>
423
                                                            </tr>
382
                                                        </thead>
424
                                                        </thead>
383
                                                        <tbody>
425
                                                        <tbody>
Lines 494-499 Link Here
494
                                                                            <span class="notonhold">Not on hold</span>
536
                                                                            <span class="notonhold">Not on hold</span>
495
                                                                        [% END # / IF ( itemLoo.first_hold ) %]
537
                                                                        [% END # / IF ( itemLoo.first_hold ) %]
496
                                                                    </td>
538
                                                                    </td>
539
                                                                    [% IF bibitemloo.item_hold_fees.size > 1 %]
540
                                                                        <td class="hold_fee">
541
                                                                            [% SET item_fee = 0 %]
542
                                                                            [% FOREACH fee IN bibitemloo.item_hold_fees %]
543
                                                                                [% IF fee.itemnumber == itemLoo.itemnumber %]
544
                                                                                    [% SET item_fee = fee.fee %]
545
                                                                                    [% LAST %]
546
                                                                                [% END %]
547
                                                                            [% END %]
548
                                                                            [% IF item_fee > 0 %]
549
                                                                                [% item_fee | $Price %]
550
                                                                            [% ELSE %]
551
                                                                                <span>No fee</span>
552
                                                                            [% END %]
553
                                                                        </td>
554
                                                                    [% END %]
497
                                                                </tr>
555
                                                                </tr>
498
                                                            [% END # / FOREACH itemLoo IN bibitemloo.itemLoop %]
556
                                                            [% END # / FOREACH itemLoo IN bibitemloo.itemLoop %]
499
                                                        </tbody>
557
                                                        </tbody>
(-)a/opac/opac-reserve.pl (-3 / +40 lines)
Lines 24-30 use CGI qw ( -utf8 ); Link Here
24
use C4::Auth        qw( get_template_and_user );
24
use C4::Auth        qw( get_template_and_user );
25
use C4::Koha        qw( getitemtypeimagelocation getitemtypeimagesrc );
25
use C4::Koha        qw( getitemtypeimagelocation getitemtypeimagesrc );
26
use C4::Circulation qw( GetBranchItemRule );
26
use C4::Circulation qw( GetBranchItemRule );
27
use C4::Reserves    qw( CanItemBeReserved CanBookBeReserved AddReserve IsAvailableForItemLevelRequest GetReserveFee );
27
use C4::Reserves    qw( CanItemBeReserved CanBookBeReserved AddReserve IsAvailableForItemLevelRequest );
28
use C4::Biblio      qw( GetBiblioData GetFrameworkCode );
28
use C4::Biblio      qw( GetBiblioData GetFrameworkCode );
29
use C4::Output      qw( output_html_with_http_headers );
29
use C4::Output      qw( output_html_with_http_headers );
30
use C4::Context;
30
use C4::Context;
Lines 579-586 foreach my $biblioNum (@biblionumbers) { Link Here
579
        $biblioLoopIter{forced_hold_level} = $forced_hold_level;
579
        $biblioLoopIter{forced_hold_level} = $forced_hold_level;
580
    }
580
    }
581
581
582
    # Pass through any reserve charge
582
    # Pass through any reserve charge - get fees per item for better display
583
    $biblioLoopIter{reserve_charge} = GetReserveFee( $patron->id, $biblioNum );
583
    my $biblio_obj = Koha::Biblios->find($biblioNum);
584
    my @item_fees;
585
586
    if ($biblio_obj) {
587
588
        # Get holdable items and calculate fees using object methods
589
        my @holdable_items = $biblio_obj->items->search(
590
            {
591
                -or => [
592
                    { 'me.notforloan' => 0 },
593
                    { 'me.notforloan' => undef }
594
                ]
595
            }
596
        )->as_list;
597
598
        foreach my $item (@holdable_items) {
599
600
            # Check if item is holdable for this patron
601
            next unless C4::Reserves::CanItemBeReserved( $patron, $item )->{status} eq 'OK';
602
603
            my $fee = $item->holds_fee($patron);
604
605
            push @item_fees, {
606
                itemnumber => $item->id,
607
                fee        => $fee,
608
                barcode    => $item->barcode,
609
                callnumber => $item->itemcallnumber,
610
                location   => $item->location,
611
            };
612
        }
613
    }
614
615
    if (@item_fees) {
616
        $biblioLoopIter{reserve_charge} = $item_fees[0]->{fee};    # Legacy compatibility
617
        $biblioLoopIter{item_hold_fees} = \@item_fees;             # New array for templates
618
    } else {
619
        $biblioLoopIter{reserve_charge} = 0;
620
    }
584
621
585
    push @$biblioLoop, \%biblioLoopIter;
622
    push @$biblioLoop, \%biblioLoopIter;
586
623
(-)a/t/db_dependent/Koha/Hold.t (-12 / +369 lines)
Lines 20-26 Link Here
20
use Modern::Perl;
20
use Modern::Perl;
21
21
22
use Test::NoWarnings;
22
use Test::NoWarnings;
23
use Test::More tests => 21;
23
use Test::More tests => 22;
24
24
25
use Test::Exception;
25
use Test::Exception;
26
use Test::MockModule;
26
use Test::MockModule;
Lines 32-37 use t::lib::Mocks; Link Here
32
use C4::Reserves qw(AddReserve);
32
use C4::Reserves qw(AddReserve);
33
33
34
use Koha::ActionLogs;
34
use Koha::ActionLogs;
35
use Koha::CirculationRules;
35
use Koha::DateUtils qw(dt_from_string);
36
use Koha::DateUtils qw(dt_from_string);
36
use Koha::Holds;
37
use Koha::Holds;
37
use Koha::Libraries;
38
use Koha::Libraries;
Lines 138-156 subtest 'fill() tests' => sub { Link Here
138
139
139
    my $fee = 15;
140
    my $fee = 15;
140
141
141
    my $category = $builder->build_object(
142
    my $category = $builder->build_object( { class => 'Koha::Patron::Categories' } );
142
        {
143
    my $patron   = $builder->build_object(
143
            class => 'Koha::Patron::Categories',
144
            value => { reservefee => $fee }
145
        }
146
    );
147
    my $patron = $builder->build_object(
148
        {
144
        {
149
            class => 'Koha::Patrons',
145
            class => 'Koha::Patrons',
150
            value => { categorycode => $category->id }
146
            value => { categorycode => $category->id }
151
        }
147
        }
152
    );
148
    );
153
    my $manager = $builder->build_object( { class => 'Koha::Patrons' } );
149
    my $manager = $builder->build_object( { class => 'Koha::Patrons' } );
150
    my $library = $builder->build_object( { class => 'Koha::Libraries' } );
151
152
    # Set up circulation rules for hold fees
153
    Koha::CirculationRules->set_rules(
154
        {
155
            branchcode   => undef,
156
            categorycode => $category->id,
157
            itemtype     => undef,
158
            rules        => {
159
                hold_fee => $fee,
160
            }
161
        }
162
    );
154
163
155
    my $title  = 'Do what you want';
164
    my $title  = 'Do what you want';
156
    my $biblio = $builder->build_sample_biblio( { title => $title } );
165
    my $biblio = $builder->build_sample_biblio( { title => $title } );
Lines 162-167 subtest 'fill() tests' => sub { Link Here
162
                biblionumber   => $biblio->id,
171
                biblionumber   => $biblio->id,
163
                borrowernumber => $patron->id,
172
                borrowernumber => $patron->id,
164
                itemnumber     => $item->id,
173
                itemnumber     => $item->id,
174
                branchcode     => $library->branchcode,
165
                timestamp      => dt_from_string('2021-06-25 14:05:35'),
175
                timestamp      => dt_from_string('2021-06-25 14:05:35'),
166
                priority       => 10,
176
                priority       => 10,
167
            }
177
            }
Lines 190-201 subtest 'fill() tests' => sub { Link Here
190
200
191
    subtest 'item_id parameter' => sub {
201
    subtest 'item_id parameter' => sub {
192
        plan tests => 1;
202
        plan tests => 1;
193
        $category->reservefee(0)->store;    # do not disturb later accounts
203
204
        # Clear hold fee rule to not disturb later accounts
205
        Koha::CirculationRules->set_rules(
206
            {
207
                branchcode   => undef,
208
                categorycode => $category->id,
209
                itemtype     => undef,
210
                rules        => {
211
                    hold_fee => 0,
212
                }
213
            }
214
        );
194
        $hold = $builder->build_object(
215
        $hold = $builder->build_object(
195
            {
216
            {
196
                class => 'Koha::Holds',
217
                class => 'Koha::Holds',
197
                value =>
218
                value => {
198
                    { biblionumber => $biblio->id, borrowernumber => $patron->id, itemnumber => undef, priority => 1 }
219
                    biblionumber => $biblio->id, borrowernumber => $patron->id, itemnumber => undef, priority => 1,
220
                    branchcode   => $library->branchcode
221
                }
199
            }
222
            }
200
        );
223
        );
201
224
Lines 204-210 subtest 'fill() tests' => sub { Link Here
204
        $old_hold = Koha::Old::Holds->find( $hold->id );
227
        $old_hold = Koha::Old::Holds->find( $hold->id );
205
        is( $old_hold->itemnumber, $item->itemnumber, 'The itemnumber has been saved in old_reserves by fill' );
228
        is( $old_hold->itemnumber, $item->itemnumber, 'The itemnumber has been saved in old_reserves by fill' );
206
        $old_hold->delete;
229
        $old_hold->delete;
207
        $category->reservefee($fee)->store;    # restore
230
231
        # Restore hold fee rule
232
        Koha::CirculationRules->set_rules(
233
            {
234
                branchcode   => undef,
235
                categorycode => $category->id,
236
                itemtype     => undef,
237
                rules        => {
238
                    hold_fee => $fee,
239
                }
240
            }
241
        );
208
    };
242
    };
209
243
210
    subtest 'fee applied tests' => sub {
244
    subtest 'fee applied tests' => sub {
Lines 1884-1889 subtest 'move_hold() tests' => sub { Link Here
1884
1918
1885
    $schema->storage->txn_rollback;
1919
    $schema->storage->txn_rollback;
1886
};
1920
};
1921
1887
subtest 'is_hold_group_target, cleanup_hold_group and set_as_hold_group_target tests' => sub {
1922
subtest 'is_hold_group_target, cleanup_hold_group and set_as_hold_group_target tests' => sub {
1888
1923
1889
    plan tests => 16;
1924
    plan tests => 16;
Lines 2188-2195 subtest '_Findgroupreserve in the context of hold groups' => sub { Link Here
2188
    );
2223
    );
2189
};
2224
};
2190
2225
2226
subtest 'calculate_hold_fee() tests' => sub {
2227
    plan tests => 12;
2228
2229
    $schema->storage->txn_begin;
2230
2231
    # Create patron category and patron
2232
    my $cat     = $builder->build( { source => 'Category', value => { categorycode => 'XYZ1' } } );
2233
    my $patron1 = $builder->build_object(
2234
        {
2235
            class => 'Koha::Patrons',
2236
            value => { categorycode => 'XYZ1' }
2237
        }
2238
    );
2239
    my $patron2 = $builder->build_object(
2240
        {
2241
            class => 'Koha::Patrons',
2242
            value => { categorycode => 'XYZ1' }
2243
        }
2244
    );
2245
2246
    # Create itemtypes with different fees
2247
    my $itemtype1 = $builder->build( { source => 'Itemtype' } );
2248
    my $itemtype2 = $builder->build( { source => 'Itemtype' } );
2249
    my $itemtype3 = $builder->build( { source => 'Itemtype' } );
2250
2251
    # Set up circulation rules with different hold fees for different itemtypes
2252
    Koha::CirculationRules->set_rules(
2253
        {
2254
            branchcode   => undef,
2255
            categorycode => 'XYZ1',
2256
            itemtype     => $itemtype1->{itemtype},
2257
            rules        => { hold_fee => 1.00, reservesallowed => 10 }
2258
        }
2259
    );
2260
    Koha::CirculationRules->set_rules(
2261
        {
2262
            branchcode   => undef,
2263
            categorycode => 'XYZ1',
2264
            itemtype     => $itemtype2->{itemtype},
2265
            rules        => { hold_fee => 3.00, reservesallowed => 10 }
2266
        }
2267
    );
2268
    Koha::CirculationRules->set_rules(
2269
        {
2270
            branchcode   => undef,
2271
            categorycode => 'XYZ1',
2272
            itemtype     => $itemtype3->{itemtype},
2273
            rules        => { hold_fee => 2.00, reservesallowed => 10 }
2274
        }
2275
    );
2276
2277
    # Set generic rules for permission checking
2278
    Koha::CirculationRules->set_rules(
2279
        {
2280
            branchcode   => undef,
2281
            categorycode => undef,
2282
            itemtype     => undef,
2283
            rules        => { reservesallowed => 10, holds_per_record => 10, holds_per_day => 10 }
2284
        }
2285
    );
2286
2287
    my $biblio = $builder->build_sample_biblio();
2288
2289
    # Create multiple items with different itemtypes and fees
2290
    my $item1 = $builder->build_sample_item(
2291
        {
2292
            biblionumber => $biblio->biblionumber,
2293
            itype        => $itemtype1->{itemtype},
2294
            notforloan   => 0,
2295
        }
2296
    );
2297
    my $item2 = $builder->build_sample_item(
2298
        {
2299
            biblionumber => $biblio->biblionumber,
2300
            itype        => $itemtype2->{itemtype},
2301
            notforloan   => 0,
2302
        }
2303
    );
2304
    my $item3 = $builder->build_sample_item(
2305
        {
2306
            biblionumber => $biblio->biblionumber,
2307
            itype        => $itemtype3->{itemtype},
2308
            notforloan   => 0,
2309
        }
2310
    );
2311
2312
    # Test 1: Item-level hold calculates fee correctly
2313
    my $item_hold = $builder->build_object(
2314
        {
2315
            class => 'Koha::Holds',
2316
            value => {
2317
                borrowernumber => $patron1->borrowernumber,
2318
                biblionumber   => $biblio->biblionumber,
2319
                itemnumber     => $item2->itemnumber,         # Use item2 with 3.00 fee
2320
            }
2321
        }
2322
    );
2323
2324
    my $fee = $item_hold->calculate_hold_fee();
2325
    is( $fee, 3.00, 'Item-level hold calculates fee correctly' );
2326
2327
    # Create title-level hold for comprehensive testing
2328
    my $title_hold = $builder->build_object(
2329
        {
2330
            class => 'Koha::Holds',
2331
            value => {
2332
                borrowernumber => $patron2->borrowernumber,
2333
                biblionumber   => $biblio->biblionumber,
2334
                itemnumber     => undef,
2335
            }
2336
        }
2337
    );
2338
2339
    # Test 2: Default 'highest' strategy should return 3.00 (highest fee)
2340
    t::lib::Mocks::mock_preference( 'TitleHoldFeeStrategy', 'highest' );
2341
    $fee = $title_hold->calculate_hold_fee();
2342
    is( $fee, 3.00, 'Title-level hold: highest strategy returns highest fee (3.00)' );
2343
2344
    # Test 3: 'lowest' strategy should return 1.00 (lowest fee)
2345
    t::lib::Mocks::mock_preference( 'TitleHoldFeeStrategy', 'lowest' );
2346
    $fee = $title_hold->calculate_hold_fee();
2347
    is( $fee, 1.00, 'Title-level hold: lowest strategy returns lowest fee (1.00)' );
2348
2349
    # Test 4: 'most_common' strategy with unique fees should return highest
2350
    t::lib::Mocks::mock_preference( 'TitleHoldFeeStrategy', 'most_common' );
2351
    $fee = $title_hold->calculate_hold_fee();
2352
    is( $fee, 3.00, 'Title-level hold: most_common strategy with unique fees returns highest fee (3.00)' );
2353
2354
    # Test 5: Add another item with same fee to test most_common properly
2355
    my $item4 = $builder->build_sample_item(
2356
        {
2357
            biblionumber => $biblio->biblionumber,
2358
            itype        => $itemtype1->{itemtype},
2359
            notforloan   => 0,
2360
        }
2361
    );
2362
    $fee = $title_hold->calculate_hold_fee();
2363
    is( $fee, 1.00, 'Title-level hold: most_common strategy returns most common fee (1.00 - 2 items)' );
2364
2365
    # Test 6: Unknown/invalid strategy defaults to 'highest'
2366
    t::lib::Mocks::mock_preference( 'TitleHoldFeeStrategy', 'invalid_strategy' );
2367
    $fee = $title_hold->calculate_hold_fee();
2368
    is( $fee, 3.00, 'Title-level hold: invalid strategy defaults to highest fee (3.00)' );
2369
2370
    # Test 7: Empty preference defaults to 'highest'
2371
    t::lib::Mocks::mock_preference( 'TitleHoldFeeStrategy', '' );
2372
    $fee = $title_hold->calculate_hold_fee();
2373
    is( $fee, 3.00, 'Title-level hold: empty strategy preference defaults to highest fee (3.00)' );
2374
2375
    # Test 8: No holdable items returns 0
2376
    # Make all items not holdable
2377
    $item1->notforloan(1)->store;
2378
    $item2->notforloan(1)->store;
2379
    $item3->notforloan(1)->store;
2380
    $item4->notforloan(1)->store;
2381
2382
    $fee = $title_hold->calculate_hold_fee();
2383
    is( $fee, 0, 'Title-level hold: no holdable items returns 0 fee' );
2384
2385
    # Test 9: Mix of holdable and non-holdable items (highest)
2386
    # Make some items holdable again
2387
    $item2->notforloan(0)->store;    # Fee: 3.00
2388
    $item3->notforloan(0)->store;    # Fee: 2.00
2389
2390
    t::lib::Mocks::mock_preference( 'TitleHoldFeeStrategy', 'highest' );
2391
    $fee = $title_hold->calculate_hold_fee();
2392
    is( $fee, 3.00, 'Title-level hold: highest strategy with mixed holdable items returns 3.00' );
2393
2394
    # Test 10: Mix of holdable and non-holdable items (lowest)
2395
    t::lib::Mocks::mock_preference( 'TitleHoldFeeStrategy', 'lowest' );
2396
    $fee = $title_hold->calculate_hold_fee();
2397
    is( $fee, 2.00, 'Title-level hold: lowest strategy with mixed holdable items returns 2.00' );
2398
2399
    # Test 11: Items with 0 fee
2400
    my $itemtype_free = $builder->build( { source => 'Itemtype' } );
2401
    Koha::CirculationRules->set_rules(
2402
        {
2403
            branchcode   => undef,
2404
            categorycode => 'XYZ1',
2405
            itemtype     => $itemtype_free->{itemtype},
2406
            rules        => { hold_fee => 0, reservesallowed => 10 }
2407
        }
2408
    );
2409
2410
    my $item_free = $builder->build_sample_item(
2411
        {
2412
            biblionumber => $biblio->biblionumber,
2413
            itype        => $itemtype_free->{itemtype},
2414
            notforloan   => 0,
2415
        }
2416
    );
2417
2418
    t::lib::Mocks::mock_preference( 'TitleHoldFeeStrategy', 'lowest' );
2419
    $fee = $title_hold->calculate_hold_fee();
2420
    is( $fee, 0, 'Title-level hold: lowest strategy with free item returns 0' );
2421
2422
    # Test 12: All items have same fee
2423
    # Set all holdable items to same fee
2424
    Koha::CirculationRules->set_rules(
2425
        {
2426
            branchcode   => undef,
2427
            categorycode => 'XYZ1',
2428
            itemtype     => $itemtype2->{itemtype},
2429
            rules        => { hold_fee => 2.50, reservesallowed => 10 }
2430
        }
2431
    );
2432
    Koha::CirculationRules->set_rules(
2433
        {
2434
            branchcode   => undef,
2435
            categorycode => 'XYZ1',
2436
            itemtype     => $itemtype3->{itemtype},
2437
            rules        => { hold_fee => 2.50, reservesallowed => 10 }
2438
        }
2439
    );
2440
    Koha::CirculationRules->set_rules(
2441
        {
2442
            branchcode   => undef,
2443
            categorycode => 'XYZ1',
2444
            itemtype     => $itemtype_free->{itemtype},
2445
            rules        => { hold_fee => 2.50, reservesallowed => 10 }
2446
        }
2447
    );
2448
2449
    $fee = $title_hold->calculate_hold_fee();
2450
    is( $fee, 2.50, 'Title-level hold: all items with same fee returns that fee regardless of strategy' );
2451
2452
    $schema->storage->txn_rollback;
2453
};
2454
2455
subtest 'should_charge() tests' => sub {
2456
    plan tests => 6;
2457
2458
    $schema->storage->txn_begin;
2459
2460
    my $patron = $builder->build_object( { class => 'Koha::Patrons' } );
2461
    my $biblio = $builder->build_sample_biblio();
2462
    my $item   = $builder->build_sample_item( { biblionumber => $biblio->biblionumber } );
2463
2464
    my $hold = $builder->build_object(
2465
        {
2466
            class => 'Koha::Holds',
2467
            value => {
2468
                borrowernumber => $patron->borrowernumber,
2469
                biblionumber   => $biblio->biblionumber,
2470
                itemnumber     => $item->itemnumber,
2471
            }
2472
        }
2473
    );
2474
2475
    # Test any_time_is_placed mode
2476
    t::lib::Mocks::mock_preference( 'HoldFeeMode', 'any_time_is_placed' );
2477
    ok( $hold->should_charge('placement'),   'any_time_is_placed charges at placement' );
2478
    ok( !$hold->should_charge('collection'), 'any_time_is_placed does not charge at collection' );
2479
2480
    # Test any_time_is_collected mode
2481
    t::lib::Mocks::mock_preference( 'HoldFeeMode', 'any_time_is_collected' );
2482
    ok( !$hold->should_charge('placement'), 'any_time_is_collected does not charge at placement' );
2483
    ok( $hold->should_charge('collection'), 'any_time_is_collected charges at collection' );
2484
2485
    # Test not_always mode (basic case - items available)
2486
    t::lib::Mocks::mock_preference( 'HoldFeeMode', 'not_always' );
2487
    ok( !$hold->should_charge('placement'),  'not_always does not charge at placement when items available' );
2488
    ok( !$hold->should_charge('collection'), 'not_always does not charge at collection' );
2489
2490
    $schema->storage->txn_rollback;
2491
};
2492
2493
subtest 'charge_hold_fee() tests' => sub {
2494
    plan tests => 2;
2495
2496
    $schema->storage->txn_begin;
2497
2498
    my $cat = $builder->build( { source => 'Category', value => { categorycode => 'XYZ1' } } );
2499
2500
    # Set up circulation rules for hold fees
2501
    Koha::CirculationRules->set_rules(
2502
        {
2503
            branchcode   => undef,
2504
            categorycode => 'XYZ1',
2505
            itemtype     => undef,
2506
            rules        => {
2507
                hold_fee => 2.00,
2508
            }
2509
        }
2510
    );
2511
2512
    my $patron = $builder->build_object(
2513
        {
2514
            class => 'Koha::Patrons',
2515
            value => { categorycode => 'XYZ1' }
2516
        }
2517
    );
2518
    t::lib::Mocks::mock_userenv( { patron => $patron, branchcode => $patron->branchcode } );
2519
2520
    my $biblio = $builder->build_sample_biblio();
2521
    my $item   = $builder->build_sample_item( { biblionumber => $biblio->biblionumber } );
2522
2523
    my $hold = $builder->build_object(
2524
        {
2525
            class => 'Koha::Holds',
2526
            value => {
2527
                borrowernumber => $patron->borrowernumber,
2528
                biblionumber   => $biblio->biblionumber,
2529
                itemnumber     => $item->itemnumber,
2530
            }
2531
        }
2532
    );
2533
2534
    my $account        = $patron->account;
2535
    my $balance_before = $account->balance;
2536
2537
    # Charge fee
2538
    my $account_line = $hold->charge_hold_fee();
2539
    is( $account_line->amount, 2.00, 'charge_hold_fee returns account line with correct amount' );
2540
2541
    my $balance_after = $account->balance;
2542
    is( $balance_after, $balance_before + 2.00, 'Patron account charged correctly' );
2543
2544
    $schema->storage->txn_rollback;
2545
};
2546
2191
sub set_userenv {
2547
sub set_userenv {
2192
    my ($library) = @_;
2548
    my ($library) = @_;
2193
    my $staff = $builder->build_object( { class => "Koha::Patrons" } );
2549
    my $staff = $builder->build_object( { class => "Koha::Patrons" } );
2194
    t::lib::Mocks::mock_userenv( { patron => $staff, branchcode => $library->{branchcode} } );
2550
    t::lib::Mocks::mock_userenv( { patron => $staff, branchcode => $library->{branchcode} } );
2195
}
2551
}
2552
(-)a/t/db_dependent/Koha/Item.t (-1 / +61 lines)
Lines 21-27 use Modern::Perl; Link Here
21
use utf8;
21
use utf8;
22
22
23
use Test::NoWarnings;
23
use Test::NoWarnings;
24
use Test::More tests => 41;
24
use Test::More tests => 42;
25
use Test::Exception;
25
use Test::Exception;
26
use Test::MockModule;
26
use Test::MockModule;
27
use Test::Warn;
27
use Test::Warn;
Lines 3899-3901 subtest 'effective_bookable() tests' => sub { Link Here
3899
3899
3900
    $schema->storage->txn_rollback;
3900
    $schema->storage->txn_rollback;
3901
};
3901
};
3902
3903
subtest 'holds_fee() tests' => sub {
3904
    plan tests => 3;
3905
3906
    $schema->storage->txn_begin;
3907
3908
    my $cat1 = $builder->build( { source => 'Category', value => { categorycode => 'XYZ1' } } );
3909
    my $cat2 = $builder->build( { source => 'Category', value => { categorycode => 'XYZ2' } } );
3910
3911
    # Set up circulation rules for hold fees
3912
    Koha::CirculationRules->set_rules(
3913
        {
3914
            branchcode   => undef,
3915
            categorycode => 'XYZ1',
3916
            itemtype     => undef,
3917
            rules        => {
3918
                hold_fee => 2.00,
3919
            }
3920
        }
3921
    );
3922
    Koha::CirculationRules->set_rules(
3923
        {
3924
            branchcode   => undef,
3925
            categorycode => 'XYZ2',
3926
            itemtype     => undef,
3927
            rules        => {
3928
                hold_fee => 0,
3929
            }
3930
        }
3931
    );
3932
3933
    my $patron1 = $builder->build_object(
3934
        {
3935
            class => 'Koha::Patrons',
3936
            value => { categorycode => 'XYZ1' }
3937
        }
3938
    );
3939
    my $patron2 = $builder->build_object(
3940
        {
3941
            class => 'Koha::Patrons',
3942
            value => { categorycode => 'XYZ2' }
3943
        }
3944
    );
3945
3946
    my $item = $builder->build_sample_item();
3947
3948
    # Test with fee rule
3949
    my $fee = $item->holds_fee($patron1);
3950
    is( $fee, 2.00, 'Item holds_fee returns correct fee from circulation rule' );
3951
3952
    # Test with no fee rule
3953
    $fee = $item->holds_fee($patron2);
3954
    is( $fee, 0, 'Item holds_fee returns 0 when no fee configured' );
3955
3956
    # Test without patron
3957
    $fee = $item->holds_fee(undef);
3958
    is( $fee, 0, 'Item holds_fee returns 0 when no patron provided' );
3959
3960
    $schema->storage->txn_rollback;
3961
};
(-)a/t/db_dependent/Reserves.t (-12 / +23 lines)
Lines 35-41 use C4::Biblio qw( GetMarcFromKohaField ModBiblio ); Link Here
35
use C4::HoldsQueue;
35
use C4::HoldsQueue;
36
use C4::Members;
36
use C4::Members;
37
use C4::Reserves
37
use C4::Reserves
38
    qw( AddReserve AlterPriority CheckReserves ModReserve ModReserveAffect ReserveSlip CalculatePriority CanBookBeReserved IsAvailableForItemLevelRequest MoveReserve ChargeReserveFee CanItemBeReserved MergeHolds );
38
    qw( AddReserve AlterPriority CheckReserves ModReserve ModReserveAffect ReserveSlip CalculatePriority CanBookBeReserved IsAvailableForItemLevelRequest MoveReserve CanItemBeReserved MergeHolds );
39
use Koha::ActionLogs;
39
use Koha::ActionLogs;
40
use Koha::Biblios;
40
use Koha::Biblios;
41
use Koha::Caches;
41
use Koha::Caches;
Lines 1034-1062 subtest 'ReservesNeedReturns' => sub { Link Here
1034
    t::lib::Mocks::mock_preference( 'ReservesNeedReturns', 1 );    # Don't affect other tests
1034
    t::lib::Mocks::mock_preference( 'ReservesNeedReturns', 1 );    # Don't affect other tests
1035
};
1035
};
1036
1036
1037
subtest 'ChargeReserveFee tests' => sub {
1037
subtest 'Hold fee charging tests' => sub {
1038
1038
1039
    plan tests => 8;
1039
    plan tests => 8;
1040
1040
1041
    my $library = $builder->build_object( { class => 'Koha::Libraries' } );
1041
    my $library = $builder->build_object( { class => 'Koha::Libraries' } );
1042
    my $patron  = $builder->build_object( { class => 'Koha::Patrons' } );
1042
    my $patron  = $builder->build_object( { class => 'Koha::Patrons' } );
1043
1043
    my $biblio  = $builder->build_sample_biblio();
1044
    my $fee   = 20;
1044
    my $item    = $builder->build_sample_item( { biblionumber => $biblio->biblionumber } );
1045
    my $title = 'A title';
1046
1045
1047
    my $context = Test::MockModule->new('C4::Context');
1046
    my $context = Test::MockModule->new('C4::Context');
1048
    $context->mock( userenv => { branch => $library->id } );
1047
    $context->mock( userenv => { branch => $library->id } );
1049
1048
1050
    my $line = C4::Reserves::ChargeReserveFee( $patron->id, $fee, $title );
1049
    my $hold = $builder->build_object(
1050
        {
1051
            class => 'Koha::Holds',
1052
            value => {
1053
                borrowernumber => $patron->id,
1054
                biblionumber   => $biblio->biblionumber,
1055
                itemnumber     => $item->itemnumber,
1056
            }
1057
        }
1058
    );
1059
1060
    my $fee  = 20;
1061
    my $line = $hold->charge_hold_fee( { amount => $fee } );
1051
1062
1052
    is( ref($line), 'Koha::Account::Line', 'Returns a Koha::Account::Line object' );
1063
    is( ref($line), 'Koha::Account::Line', 'Returns a Koha::Account::Line object' );
1053
    ok( $line->is_debit, 'Generates a debit line' );
1064
    ok( $line->is_debit, 'Generates a debit line' );
1054
    is( $line->debit_type_code,   'RESERVE',    'generates RESERVE debit_type' );
1065
    is( $line->debit_type_code,   'RESERVE',      'generates RESERVE debit_type' );
1055
    is( $line->borrowernumber,    $patron->id,  'generated line belongs to the passed patron' );
1066
    is( $line->borrowernumber,    $patron->id,    'generated line belongs to the passed patron' );
1056
    is( $line->amount,            $fee,         'amount set correctly' );
1067
    is( $line->amount,            $fee,           'amount set correctly' );
1057
    is( $line->amountoutstanding, $fee,         'amountoutstanding set correctly' );
1068
    is( $line->amountoutstanding, $fee,           'amountoutstanding set correctly' );
1058
    is( $line->description,       "$title",     'description is title of reserved item' );
1069
    is( $line->description,       $biblio->title, 'description is title of reserved item' );
1059
    is( $line->branchcode,        $library->id, "Library id is picked from userenv and stored correctly" );
1070
    is( $line->branchcode,        $library->id,   "Library id is picked from userenv and stored correctly" );
1060
};
1071
};
1061
1072
1062
subtest 'MoveReserve additional test' => sub {
1073
subtest 'MoveReserve additional test' => sub {
(-)a/t/db_dependent/Reserves/GetReserveFee.t (-282 lines)
Lines 1-281 Link Here
1
#!/usr/bin/perl
2
3
# This script includes tests for GetReserveFee and ChargeReserveFee
4
5
# Copyright 2015 Rijksmuseum
6
#
7
# This file is part of Koha.
8
#
9
# Koha is free software; you can redistribute it and/or modify it
10
# under the terms of the GNU General Public License as published by
11
# the Free Software Foundation; either version 3 of the License, or
12
# (at your option) any later version.
13
#
14
# Koha is distributed in the hope that it will be useful, but
15
# WITHOUT ANY WARRANTY; without even the implied warranty of
16
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17
# GNU General Public License for more details.
18
#
19
# You should have received a copy of the GNU General Public License
20
# along with Koha; if not, see <https://www.gnu.org/licenses>.
21
22
use Modern::Perl;
23
24
use Test::NoWarnings;
25
use Test::More tests => 4;
26
use Test::MockModule;
27
use t::lib::TestBuilder;
28
use t::lib::Mocks;
29
30
use C4::Circulation qw( AddIssue );
31
use C4::Reserves    qw( GetReserveFee ChargeReserveFee AddReserve );
32
use Koha::Database;
33
34
my $schema = Koha::Database->new->schema;
35
$schema->storage->txn_begin;
36
37
my $builder = t::lib::TestBuilder->new();
38
my $library = $builder->build(
39
    {
40
        source => 'Branch',
41
    }
42
);
43
my $mContext = Test::MockModule->new('C4::Context');
44
$mContext->mock(
45
    'userenv',
46
    sub {
47
        return { branch => $library->{branchcode} };
48
    }
49
);
50
51
my $dbh = C4::Context->dbh;    # after start transaction of testbuilder
52
53
# Category with hold fee, two patrons
54
$builder->build(
55
    {
56
        source => 'Category',
57
        value  => {
58
            categorycode => 'XYZ1',
59
            reservefee   => 2,
60
        },
61
    }
62
);
63
$builder->build(
64
    {
65
        source => 'Category',
66
        value  => {
67
            categorycode => 'XYZ2',
68
            reservefee   => 0,
69
        },
70
    }
71
);
72
my $patron1 = $builder->build_object(
73
    {
74
        class => 'Koha::Patrons',
75
        value => {
76
            categorycode => 'XYZ1',
77
        },
78
    }
79
);
80
my $patron2 = $builder->build_object(
81
    {
82
        class => 'Koha::Patrons',
83
        value => {
84
            categorycode => 'XYZ1',
85
        },
86
    }
87
);
88
my $patron3 = $builder->build_object(
89
    {
90
        class => 'Koha::Patrons',
91
    }
92
);
93
my $patron4 = $builder->build_object(
94
    {
95
        class => 'Koha::Patrons',
96
        value => {
97
            categorycode => 'XYZ2',
98
        },
99
    }
100
);
101
102
# One biblio and two items
103
my $biblio = $builder->build_sample_biblio;
104
my $item1  = $builder->build_sample_item(
105
    {
106
        biblionumber => $biblio->biblionumber,
107
        notforloan   => 0,
108
    }
109
);
110
my $item2 = $builder->build_sample_item(
111
    {
112
        biblionumber => $biblio->biblionumber,
113
        notforloan   => 0,
114
    }
115
);
116
117
subtest 'GetReserveFee' => sub {
118
    plan tests => 6;
119
120
    C4::Circulation::AddIssue( $patron1, $item1->barcode, '2015-12-31', 0, undef, 0, {} )
121
        ;    # the date does not really matter
122
    C4::Circulation::AddIssue( $patron3, $item2->barcode, '2015-12-31', 0, undef, 0, {} )
123
        ;    # the date does not really matter
124
    my $acc2 = acctlines( $patron2->borrowernumber );
125
    my $res1 = addreserve( $patron1->borrowernumber );
126
127
    t::lib::Mocks::mock_preference( 'HoldFeeMode', 'not_always' );
128
    my $fee = C4::Reserves::GetReserveFee( $patron2->borrowernumber, $biblio->biblionumber );
129
    is( $fee > 0, 1, 'Patron 2 should be charged cf GetReserveFee' );
130
    C4::Reserves::ChargeReserveFee( $patron2->borrowernumber, $fee, $biblio->title );
131
    is( acctlines( $patron2->borrowernumber ), $acc2 + 1, 'Patron 2 has been charged by ChargeReserveFee' );
132
133
    # If we delete the reserve, there should be no charge
134
    $dbh->do( "DELETE FROM reserves WHERE borrowernumber = ?", undef, ( $patron1->borrowernumber ) );
135
    $fee = C4::Reserves::GetReserveFee( $patron2->borrowernumber, $biblio->biblionumber );
136
    is( $fee, 0, 'HoldFeeMode=not_always, Patron 2 should not be charged' );
137
138
    t::lib::Mocks::mock_preference( 'HoldFeeMode', 'any_time_is_placed' );
139
    $fee = C4::Reserves::GetReserveFee( $patron2->borrowernumber, $biblio->biblionumber );
140
    is( int($fee), 2, 'HoldFeeMode=any_time_is_placed, Patron 2 should be charged' );
141
142
    t::lib::Mocks::mock_preference( 'HoldFeeMode', 'any_time_is_collected' );
143
    $fee = C4::Reserves::GetReserveFee( $patron2->borrowernumber, $biblio->biblionumber );
144
    is( int($fee), 2, 'HoldFeeMode=any_time_is_collected, Patron 2 should be charged' );
145
146
    t::lib::Mocks::mock_preference( 'HoldFeeMode', 'any_time_is_placed' );
147
    $fee = C4::Reserves::GetReserveFee( $patron4->borrowernumber, $biblio->biblionumber );
148
    is( $fee, 0, 'HoldFeeMode=any_time_is_placed ; fee == 0, Patron 4 should not be charged' );
149
};
150
151
subtest 'Integration with AddReserve' => sub {
152
    plan tests => 2;
153
154
    my $dbh = C4::Context->dbh;
155
156
    subtest 'Items are not issued' => sub {
157
        plan tests => 3;
158
159
        t::lib::Mocks::mock_preference( 'HoldFeeMode', 'not_always' );
160
        $dbh->do( "DELETE FROM reserves     WHERE biblionumber=?",   undef, $biblio->biblionumber );
161
        $dbh->do( "DELETE FROM accountlines WHERE borrowernumber=?", undef, $patron1->borrowernumber );
162
        addreserve( $patron1->borrowernumber );
163
        is( acctlines( $patron1->borrowernumber ), 0, 'not_always - No fee charged for patron 1 if not issued' );
164
165
        t::lib::Mocks::mock_preference( 'HoldFeeMode', 'any_time_is_placed' );
166
        $dbh->do( "DELETE FROM reserves     WHERE biblionumber=?",   undef, $biblio->biblionumber );
167
        $dbh->do( "DELETE FROM accountlines WHERE borrowernumber=?", undef, $patron1->borrowernumber );
168
        addreserve( $patron1->borrowernumber );
169
        is( acctlines( $patron1->borrowernumber ), 1, 'any_time_is_placed - Patron should be always charged' );
170
171
        t::lib::Mocks::mock_preference( 'HoldFeeMode', 'any_time_is_collected' );
172
        $dbh->do( "DELETE FROM reserves     WHERE biblionumber=?",   undef, $biblio->biblionumber );
173
        $dbh->do( "DELETE FROM accountlines WHERE borrowernumber=?", undef, $patron1->borrowernumber );
174
        addreserve( $patron1->borrowernumber );
175
        is(
176
            acctlines( $patron1->borrowernumber ), 0,
177
            'any_time_is_collected - Patron should not be charged when placing a hold'
178
        );
179
    };
180
181
    subtest 'Items are issued' => sub {
182
        plan tests => 4;
183
184
        $dbh->do( "DELETE FROM issues       WHERE itemnumber=?", undef, $item1->itemnumber );
185
        $dbh->do( "DELETE FROM issues       WHERE itemnumber=?", undef, $item2->itemnumber );
186
        C4::Circulation::AddIssue( $patron2, $item1->barcode, '2015-12-31', 0, undef, 0, {} );
187
188
        t::lib::Mocks::mock_preference( 'HoldFeeMode', 'not_always' );
189
        $dbh->do( "DELETE FROM reserves     WHERE biblionumber=?",   undef, $biblio->biblionumber );
190
        $dbh->do( "DELETE FROM accountlines WHERE borrowernumber=?", undef, $patron1->borrowernumber );
191
        addreserve( $patron1->borrowernumber );
192
        is(
193
            acctlines( $patron1->borrowernumber ), 0,
194
            'not_always - Patron should not be charged if items are not all checked out'
195
        );
196
197
        $dbh->do( "DELETE FROM reserves     WHERE biblionumber=?",   undef, $biblio->biblionumber );
198
        $dbh->do( "DELETE FROM accountlines WHERE borrowernumber=?", undef, $patron1->borrowernumber );
199
        addreserve( $patron3->borrowernumber );
200
        addreserve( $patron1->borrowernumber );
201
        is(
202
            acctlines( $patron1->borrowernumber ), 0,
203
            'not_always - Patron should not be charged if all the items are not checked out, even if 1 hold is already placed'
204
        );
205
206
        C4::Circulation::AddIssue( $patron3, $item2->barcode, '2015-12-31', 0, undef, 0, {} );
207
        $dbh->do( "DELETE FROM reserves     WHERE biblionumber=?",   undef, $biblio->biblionumber );
208
        $dbh->do( "DELETE FROM accountlines WHERE borrowernumber=?", undef, $patron1->borrowernumber );
209
        addreserve( $patron1->borrowernumber );
210
        is(
211
            acctlines( $patron1->borrowernumber ), 0,
212
            'not_always - Patron should not be charged if all items are checked out but no holds are placed'
213
        );
214
215
        $dbh->do( "DELETE FROM reserves     WHERE biblionumber=?",   undef, $biblio->biblionumber );
216
        $dbh->do( "DELETE FROM accountlines WHERE borrowernumber=?", undef, $patron1->borrowernumber );
217
        addreserve( $patron3->borrowernumber );
218
        addreserve( $patron1->borrowernumber );
219
        is(
220
            acctlines( $patron1->borrowernumber ), 1,
221
            'not_always - Patron should only be charged if all items are checked out and at least 1 hold is already placed'
222
        );
223
    };
224
};
225
226
subtest 'Integration with AddIssue' => sub {
227
    plan tests => 5;
228
229
    $dbh->do( "DELETE FROM issues       WHERE borrowernumber = ?", undef, $patron1->borrowernumber );
230
    $dbh->do( "DELETE FROM reserves     WHERE biblionumber=?",     undef, $biblio->biblionumber );
231
    $dbh->do( "DELETE FROM accountlines WHERE borrowernumber=?",   undef, $patron1->borrowernumber );
232
233
    t::lib::Mocks::mock_preference( 'HoldFeeMode', 'not_always' );
234
    C4::Circulation::AddIssue( $patron1, $item1->barcode, '2015-12-31', 0, undef, 0, {} );
235
    is( acctlines( $patron1->borrowernumber ), 0, 'not_always - Patron should not be charged' );
236
237
    t::lib::Mocks::mock_preference( 'HoldFeeMode', 'any_time_is_placed' );
238
    $dbh->do( "DELETE FROM issues       WHERE borrowernumber = ?", undef, $patron1->borrowernumber );
239
    C4::Circulation::AddIssue( $patron1, $item1->barcode, '2015-12-31', 0, undef, 0, {} );
240
    is( acctlines( $patron1->borrowernumber ), 0, 'not_always - Patron should not be charged' );
241
242
    t::lib::Mocks::mock_preference( 'HoldFeeMode', 'any_time_is_collected' );
243
    $dbh->do( "DELETE FROM issues       WHERE borrowernumber = ?", undef, $patron1->borrowernumber );
244
    C4::Circulation::AddIssue( $patron1, $item1->barcode, '2015-12-31', 0, undef, 0, {} );
245
    is(
246
        acctlines( $patron1->borrowernumber ), 0,
247
        'any_time_is_collected - Patron should not be charged when checking out an item which was not placed hold for him'
248
    );
249
250
    $dbh->do( "DELETE FROM issues       WHERE borrowernumber = ?", undef, $patron1->borrowernumber );
251
    my $id = addreserve( $patron1->borrowernumber );
252
    is(
253
        acctlines( $patron1->borrowernumber ), 0,
254
        'any_time_is_collected - Patron should not be charged yet (just checking to make sure)'
255
    );
256
    C4::Circulation::AddIssue( $patron1, $item1->barcode, '2015-12-31', 0, undef, 0, {} );
257
    is(
258
        acctlines( $patron1->borrowernumber ), 1,
259
        'any_time_is_collected - Patron should not be charged when checking out an item which was not placed hold for him'
260
    );
261
};
262
263
sub acctlines {    #calculate number of accountlines for a patron
264
    my @temp = $dbh->selectrow_array( "SELECT COUNT(*) FROM accountlines WHERE borrowernumber=?", undef, ( $_[0] ) );
265
    return $temp[0];
266
}
267
268
sub addreserve {
269
    return AddReserve(
270
        {
271
            branchcode     => $library->{branchcode},
272
            borrowernumber => $_[0],
273
            biblionumber   => $biblio->biblionumber,
274
            priority       => '1',
275
            title          => $biblio->title,
276
        }
277
    );
278
}
279
280
$schema->storage->txn_rollback;
281
282
- 

Return to bug 3492