diff --git a/descriptors/ModuleDescriptor-template.json b/descriptors/ModuleDescriptor-template.json index 6955a729ab..c460669204 100644 --- a/descriptors/ModuleDescriptor-template.json +++ b/descriptors/ModuleDescriptor-template.json @@ -636,6 +636,24 @@ "unit": "minute", "delay": "3" }, + { + "methods": [ + "POST" + ], + "pathPattern": "/circulation/actual-cost-expiration-by-timeout", + "modulePermissions": [ + "circulation-storage.loans.item.put", + "inventory-storage.items.item.put", + "actual-cost-record-storage.actual-cost-records.collection.get", + "accounts.collection.get", + "circulation.internal.fetch-items", + "lost-item-fees-policies.collection.get", + "circulation-storage.loans.collection.get", + "pubsub.publish.post" + ], + "unit": "minute", + "delay": "20" + }, { "methods": [ "POST" diff --git a/pom.xml b/pom.xml index 79db53b5c9..bd56d6154e 100644 --- a/pom.xml +++ b/pom.xml @@ -30,7 +30,7 @@ 2.17.1 UTF-8 UTF-8 - 1.18.16 + 1.18.22 5.2.22.RELEASE 3.9.1 diff --git a/src/main/java/org/folio/circulation/CirculationVerticle.java b/src/main/java/org/folio/circulation/CirculationVerticle.java index 3c9e38403a..4bc1d7821a 100644 --- a/src/main/java/org/folio/circulation/CirculationVerticle.java +++ b/src/main/java/org/folio/circulation/CirculationVerticle.java @@ -13,6 +13,7 @@ import org.folio.circulation.resources.DeclareLostResource; import org.folio.circulation.resources.DueDateNotRealTimeScheduledNoticeProcessingResource; import org.folio.circulation.resources.EndPatronActionSessionResource; +import org.folio.circulation.resources.ExpiredActualCostProcessingResource; import org.folio.circulation.resources.ExpiredSessionProcessingResource; import org.folio.circulation.resources.FeeFineScheduledNoticeProcessingResource; import org.folio.circulation.resources.ItemsInTransitResource; @@ -138,6 +139,7 @@ public void start(Promise startFuture) { startFuture.fail(result.cause()); } }); + new ExpiredActualCostProcessingResource(client).register(router); } @Override diff --git a/src/main/java/org/folio/circulation/domain/ActualCostRecord.java b/src/main/java/org/folio/circulation/domain/ActualCostRecord.java index 5a42ec1da5..0d975f8272 100644 --- a/src/main/java/org/folio/circulation/domain/ActualCostRecord.java +++ b/src/main/java/org/folio/circulation/domain/ActualCostRecord.java @@ -19,7 +19,7 @@ public class ActualCostRecord { private String userBarcode; private String loanId; private ItemLossType itemLossType; - private String dateOfLoss; + private ZonedDateTime dateOfLoss; private String title; private Collection identifiers; private String itemBarcode; @@ -31,4 +31,5 @@ public class ActualCostRecord { private String feeFineTypeId; private String feeFineType; private ZonedDateTime creationDate; + private ZonedDateTime expirationDate; } diff --git a/src/main/java/org/folio/circulation/domain/ItemRelatedRecord.java b/src/main/java/org/folio/circulation/domain/ItemRelatedRecord.java index 45b88be89d..9a3cf88053 100644 --- a/src/main/java/org/folio/circulation/domain/ItemRelatedRecord.java +++ b/src/main/java/org/folio/circulation/domain/ItemRelatedRecord.java @@ -2,4 +2,5 @@ public interface ItemRelatedRecord { String getItemId(); + ItemRelatedRecord withItem(Item item); } diff --git a/src/main/java/org/folio/circulation/domain/Loan.java b/src/main/java/org/folio/circulation/domain/Loan.java index e9fd96f457..4c5786449f 100644 --- a/src/main/java/org/folio/circulation/domain/Loan.java +++ b/src/main/java/org/folio/circulation/domain/Loan.java @@ -88,6 +88,7 @@ public class Loan implements ItemRelatedRecord, UserRelatedRecord { private final Policies policies; private final Collection accounts; + private final ActualCostRecord actualCostRecord; public static Loan from(JsonObject representation) { defaultStatusAndAction(representation); @@ -100,7 +101,7 @@ public static Loan from(JsonObject representation) { return new Loan(representation, null, null, null, null, null, getDateTimeProperty(representation, DUE_DATE), getDateTimeProperty(representation, DUE_DATE), - new Policies(loanPolicy, overdueFinePolicy, lostItemPolicy), emptyList()); + new Policies(loanPolicy, overdueFinePolicy, lostItemPolicy), emptyList(), null); } public JsonObject asJson() { @@ -272,7 +273,7 @@ public Item getItem() { public Loan replaceRepresentation(JsonObject newRepresentation) { return new Loan(newRepresentation, item, user, proxy, checkinServicePoint, - checkoutServicePoint, originalDueDate, previousDueDate, policies, accounts); + checkoutServicePoint, originalDueDate, previousDueDate, policies, accounts, actualCostRecord); } public Loan withItem(Item newItem) { @@ -283,7 +284,7 @@ public Loan withItem(Item newItem) { } return new Loan(newRepresentation, newItem, user, proxy, checkinServicePoint, - checkoutServicePoint, originalDueDate, previousDueDate, policies, accounts); + checkoutServicePoint, originalDueDate, previousDueDate, policies, accounts, actualCostRecord); } public User getUser() { @@ -298,7 +299,16 @@ public Loan withUser(User newUser) { } return new Loan(newRepresentation, item, newUser, proxy, checkinServicePoint, - checkoutServicePoint, originalDueDate, previousDueDate, policies, accounts); + checkoutServicePoint, originalDueDate, previousDueDate, policies, accounts, actualCostRecord); + } + + public Loan withActualCostRecord(ActualCostRecord actualCostRecord) { + return new Loan(representation, item, user, proxy, checkinServicePoint, checkoutServicePoint, + originalDueDate, previousDueDate, policies, accounts, actualCostRecord); + } + + public ActualCostRecord getActualCostRecord() { + return actualCostRecord; } public Loan withPatronGroupAtCheckout(PatronGroup patronGroup) { @@ -325,22 +335,22 @@ Loan withProxy(User newProxy) { } return new Loan(newRepresentation, item, user, newProxy, checkinServicePoint, - checkoutServicePoint, originalDueDate, previousDueDate, policies, accounts); + checkoutServicePoint, originalDueDate, previousDueDate, policies, accounts, actualCostRecord); } public Loan withCheckinServicePoint(ServicePoint newCheckinServicePoint) { return new Loan(representation, item, user, proxy, newCheckinServicePoint, - checkoutServicePoint, originalDueDate, previousDueDate, policies, accounts); + checkoutServicePoint, originalDueDate, previousDueDate, policies, accounts, actualCostRecord); } public Loan withCheckoutServicePoint(ServicePoint newCheckoutServicePoint) { return new Loan(representation, item, user, proxy, checkinServicePoint, - newCheckoutServicePoint, originalDueDate, previousDueDate, policies, accounts); + newCheckoutServicePoint, originalDueDate, previousDueDate, policies, accounts, actualCostRecord); } public Loan withAccounts(Collection newAccounts) { return new Loan(representation, item, user, proxy, checkinServicePoint, - checkoutServicePoint, originalDueDate, previousDueDate, policies, newAccounts); + checkoutServicePoint, originalDueDate, previousDueDate, policies, newAccounts, actualCostRecord); } public Loan withLoanPolicy(LoanPolicy newLoanPolicy) { @@ -348,7 +358,7 @@ public Loan withLoanPolicy(LoanPolicy newLoanPolicy) { return new Loan(representation, item, user, proxy, checkinServicePoint, checkoutServicePoint, originalDueDate, previousDueDate, - policies.withLoanPolicy(newLoanPolicy), accounts); + policies.withLoanPolicy(newLoanPolicy), accounts, actualCostRecord); } public Loan withOverdueFinePolicy(OverdueFinePolicy newOverdueFinePolicy) { @@ -356,7 +366,7 @@ public Loan withOverdueFinePolicy(OverdueFinePolicy newOverdueFinePolicy) { return new Loan(representation, item, user, proxy, checkinServicePoint, checkoutServicePoint, originalDueDate, previousDueDate, - policies.withOverdueFinePolicy(newOverdueFinePolicy), accounts); + policies.withOverdueFinePolicy(newOverdueFinePolicy), accounts, actualCostRecord); } public Loan withLostItemPolicy(LostItemPolicy newLostItemPolicy) { @@ -364,7 +374,7 @@ public Loan withLostItemPolicy(LostItemPolicy newLostItemPolicy) { return new Loan(representation, item, user, proxy, checkinServicePoint, checkoutServicePoint, originalDueDate, previousDueDate, - policies.withLostItemPolicy(newLostItemPolicy), accounts); + policies.withLostItemPolicy(newLostItemPolicy), accounts, actualCostRecord); } public String getLoanPolicyId() { @@ -625,7 +635,7 @@ public void closeLoanAsLostAndPaid() { public Loan copy() { final JsonObject representationCopy = representation.copy(); return new Loan(representationCopy, item, user, proxy, checkinServicePoint, - checkoutServicePoint, originalDueDate, previousDueDate, policies, accounts); + checkoutServicePoint, originalDueDate, previousDueDate, policies, accounts, actualCostRecord); } public Loan ageOverdueItemToLost(ZonedDateTime ageToLostDate) { diff --git a/src/main/java/org/folio/circulation/domain/policy/lostitem/LostItemPolicy.java b/src/main/java/org/folio/circulation/domain/policy/lostitem/LostItemPolicy.java index 9c721f8a11..d5a56363ce 100644 --- a/src/main/java/org/folio/circulation/domain/policy/lostitem/LostItemPolicy.java +++ b/src/main/java/org/folio/circulation/domain/policy/lostitem/LostItemPolicy.java @@ -34,6 +34,7 @@ public class LostItemPolicy extends Policy { private final Period patronBilledAfterItemAgedToLostInterval; private final Period recalledItemAgedToLostAfterOverdueInterval; private final Period patronBilledAfterRecalledItemAgedToLostInterval; + private final Period lostItemChargeFeeFineInterval; // There is no separate age to lost processing fee but there is a flag // that turns on/off the fee, but we're modelling it as a separate fee // to simplify logic. @@ -45,7 +46,7 @@ private LostItemPolicy(String id, String name, AutomaticallyChargeableFee declar Period itemAgedToLostAfterOverdueInterval, Period patronBilledAfterItemAgedToLostInterval, Period recalledItemAgedToLostAfterOverdueInterval, Period patronBilledAfterRecalledItemAgedToLostInterval, - AutomaticallyChargeableFee ageToLostProcessingFee) { + AutomaticallyChargeableFee ageToLostProcessingFee, Period lostItemChargeFeeFineInterval) { super(id, name); this.declareLostProcessingFee = declareLostProcessingFee; @@ -60,6 +61,7 @@ private LostItemPolicy(String id, String name, AutomaticallyChargeableFee declar this.patronBilledAfterRecalledItemAgedToLostInterval = patronBilledAfterRecalledItemAgedToLostInterval; this.ageToLostProcessingFee = ageToLostProcessingFee; + this.lostItemChargeFeeFineInterval = lostItemChargeFeeFineInterval; } public static LostItemPolicy from(JsonObject lostItemPolicy) { @@ -76,7 +78,8 @@ public static LostItemPolicy from(JsonObject lostItemPolicy) { getPeriodPropertyOrEmpty(lostItemPolicy, "patronBilledAfterAgedLost"), getPeriodPropertyOrEmpty(lostItemPolicy, "recalledItemAgedLostOverdue"), getPeriodPropertyOrEmpty(lostItemPolicy, "patronBilledAfterRecalledItemAgedLost"), - getProcessingFee(lostItemPolicy, "chargeAmountItemSystem") + getProcessingFee(lostItemPolicy, "chargeAmountItemSystem"), + getPeriodPropertyOrEmpty(lostItemPolicy, "lostItemChargeFeeFine") ); } @@ -162,6 +165,10 @@ public boolean canAgeLoanToLost(boolean isRecalled, ZonedDateTime loanDueDate) { return periodShouldPassSinceOverdue.hasPassedSinceDateTillNow(loanDueDate); } + public ZonedDateTime calculateFeeFineChargingPeriodExpirationDateTime(ZonedDateTime lostTime) { + return lostItemChargeFeeFineInterval.plusDate(lostTime); + } + public ZonedDateTime calculateDateTimeWhenPatronBilledForAgedToLost( boolean isRecalled, ZonedDateTime ageToLostDate) { @@ -189,7 +196,7 @@ private static class UnknownLostItemPolicy extends LostItemPolicy { super(id, null, noAutomaticallyChargeableFee(), noAutomaticallyChargeableFee(), noActualCostFee(), zeroDurationPeriod(), false, false, zeroDurationPeriod(), zeroDurationPeriod(), zeroDurationPeriod(), - zeroDurationPeriod(), noAutomaticallyChargeableFee()); + zeroDurationPeriod(), noAutomaticallyChargeableFee(), zeroDurationPeriod()); } } } diff --git a/src/main/java/org/folio/circulation/infrastructure/storage/ActualCostRecordRepository.java b/src/main/java/org/folio/circulation/infrastructure/storage/ActualCostRecordRepository.java index 9eeb16b556..c3223aca34 100644 --- a/src/main/java/org/folio/circulation/infrastructure/storage/ActualCostRecordRepository.java +++ b/src/main/java/org/folio/circulation/infrastructure/storage/ActualCostRecordRepository.java @@ -1,25 +1,45 @@ package org.folio.circulation.infrastructure.storage; +import static java.util.concurrent.CompletableFuture.completedFuture; +import static java.util.function.Function.identity; +import static org.folio.circulation.support.fetching.MultipleCqlIndexValuesCriteria.byIndex; +import static org.folio.circulation.support.fetching.RecordFetching.findWithMultipleCqlIndexValues; import static org.folio.circulation.support.http.ResponseMapping.forwardOnFailure; import static org.folio.circulation.support.http.ResponseMapping.mapUsingJson; +import static org.folio.circulation.support.http.client.CqlQuery.exactMatch; +import static org.folio.circulation.support.http.client.PageLimit.one; import static org.folio.circulation.support.results.Result.ofAsync; +import static org.folio.circulation.support.results.Result.succeeded; +import static org.folio.circulation.support.results.ResultBinding.mapResult; +import java.util.Collection; +import java.util.Map; +import java.util.Objects; +import java.util.Set; import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; import org.folio.circulation.domain.ActualCostRecord; +import org.folio.circulation.domain.Loan; import org.folio.circulation.domain.MultipleRecords; import org.folio.circulation.storage.mappers.ActualCostRecordMapper; import org.folio.circulation.support.Clients; import org.folio.circulation.support.CollectionResourceClient; +import org.folio.circulation.support.fetching.CqlQueryFinder; import org.folio.circulation.support.http.client.CqlQuery; import org.folio.circulation.support.http.client.PageLimit; import org.folio.circulation.support.http.client.Response; import org.folio.circulation.support.http.client.ResponseInterpreter; import org.folio.circulation.support.results.Result; +import io.vertx.core.json.JsonObject; + public class ActualCostRecordRepository { private final CollectionResourceClient actualCostRecordStorageClient; + private static final String ACTUAL_COST_RECORDS = "actualCostRecords"; + private static final String LOAN_ID_FIELD_NAME = "loanId"; + public ActualCostRecordRepository(Clients clients) { actualCostRecordStorageClient = clients.actualCostRecordsStorage(); } @@ -52,6 +72,47 @@ public CompletableFuture> getActualCostRecordByAccountI } private Result> mapResponseToActualCostRecords(Response response) { - return MultipleRecords.from(response, ActualCostRecordMapper::toDomain, "actualCostRecords"); + return MultipleRecords.from(response, ActualCostRecordMapper::toDomain, ACTUAL_COST_RECORDS); + } + + public CompletableFuture> findByLoan(Result loanResult) { + return loanResult.after(loan -> createActualCostRecordCqlFinder().findByQuery( + exactMatch(LOAN_ID_FIELD_NAME, loan.getId()), one()) + .thenApply(records -> records.map(MultipleRecords::firstOrNull)) + .thenApply(mapResult(ActualCostRecordMapper::toDomain)) + .thenApply(mapResult(loan::withActualCostRecord))); + } + + public CompletableFuture>> fetchActualCostRecords( + MultipleRecords multipleLoans) { + + if (multipleLoans.getRecords().isEmpty()) { + return completedFuture(succeeded(multipleLoans)); + } + + return buildLoanIdToActualCostRecordMap(multipleLoans.getRecords()) + .thenApply(r -> r.map(actualCostRecordMap -> multipleLoans.mapRecords( + loan -> loan.withActualCostRecord(actualCostRecordMap.getOrDefault(loan.getId(), null))))); + } + + private CompletableFuture>> buildLoanIdToActualCostRecordMap( + Collection loans) { + + final Set loanIds = loans.stream() + .filter(Objects::nonNull) + .map(Loan::getId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + + return findWithMultipleCqlIndexValues(actualCostRecordStorageClient, + ACTUAL_COST_RECORDS, ActualCostRecordMapper::toDomain) + .find(byIndex(LOAN_ID_FIELD_NAME, loanIds)) + .thenApply(mapResult(r -> r.toMap(ActualCostRecord::getLoanId))); + } + + private CqlQueryFinder createActualCostRecordCqlFinder() { + return new CqlQueryFinder<>(actualCostRecordStorageClient, ACTUAL_COST_RECORDS, + identity()); } + } diff --git a/src/main/java/org/folio/circulation/infrastructure/storage/inventory/ItemRepository.java b/src/main/java/org/folio/circulation/infrastructure/storage/inventory/ItemRepository.java index 609ba1d0a3..f46b043b73 100644 --- a/src/main/java/org/folio/circulation/infrastructure/storage/inventory/ItemRepository.java +++ b/src/main/java/org/folio/circulation/infrastructure/storage/inventory/ItemRepository.java @@ -256,9 +256,16 @@ private CompletableFuture> fetchItemByBarcode(String barcode) { } public CompletableFuture>> - fetchItemsWithHoldings(Result> result, BiFunction includeItemMap) { + fetchItemsWithHoldings(Result> result, BiFunction withItemMapper) { - return fetchItemsFor(result, includeItemMap, this::fetchItemsWithHoldingsRecords); + return fetchItemsFor(result, withItemMapper, this::fetchItems); + } + + public CompletableFuture>> + fetchItems(Result> result) { + + return fetchItemsFor(result, (itemRelatedRecord, item) -> (T) itemRelatedRecord.withItem(item), + this::fetchItems); } public CompletableFuture>> @@ -309,13 +316,6 @@ private CompletableFuture>> fetchFor( .thenComposeAsync(this::fetchItemsRelatedRecords); } - private CompletableFuture>> fetchItemsWithHoldingsRecords( - Collection itemIds) { - - return fetchItems(itemIds) - .thenComposeAsync(this::fetchHoldingsRecords); - } - public CompletableFuture> fetchItemRelatedRecords(Result itemResult) { return itemResult.combineAfter(this::fetchHoldingsRecord, Item::withHoldings) .thenComposeAsync(combineAfter(this::fetchInstance, Item::withInstance)) diff --git a/src/main/java/org/folio/circulation/resources/ExpiredActualCostProcessingResource.java b/src/main/java/org/folio/circulation/resources/ExpiredActualCostProcessingResource.java new file mode 100644 index 0000000000..57b1b61224 --- /dev/null +++ b/src/main/java/org/folio/circulation/resources/ExpiredActualCostProcessingResource.java @@ -0,0 +1,58 @@ +package org.folio.circulation.resources; + +import org.folio.circulation.infrastructure.storage.ActualCostRecordRepository; +import org.folio.circulation.infrastructure.storage.feesandfines.AccountRepository; +import org.folio.circulation.infrastructure.storage.inventory.ItemRepository; +import org.folio.circulation.infrastructure.storage.loans.LoanRepository; +import org.folio.circulation.infrastructure.storage.loans.LostItemPolicyRepository; +import org.folio.circulation.infrastructure.storage.users.UserRepository; +import org.folio.circulation.services.CloseLoanWithLostItemService; +import org.folio.circulation.services.EventPublisher; +import org.folio.circulation.services.actualcostrecord.ActualCostRecordExpirationService; +import org.folio.circulation.support.RouteRegistration; +import org.folio.circulation.support.fetching.PageableFetcher; +import org.folio.circulation.support.http.server.NoContentResponse; +import org.folio.circulation.support.http.server.WebContext; + +import io.vertx.core.http.HttpClient; +import io.vertx.ext.web.Router; +import io.vertx.ext.web.RoutingContext; +import static org.folio.circulation.support.Clients.create; +import static org.folio.circulation.support.results.MappingFunctions.toFixedValue; + +public class ExpiredActualCostProcessingResource extends Resource { + public ExpiredActualCostProcessingResource(HttpClient client) { + super(client); + } + + @Override + public void register(Router router) { + new RouteRegistration("/circulation/actual-cost-expiration-by-timeout", router) + .create(this::process); + } + + private void process(RoutingContext routingContext) { + var context = new WebContext(routingContext); + var clients = create(context, client); + + var eventPublisher = new EventPublisher(routingContext); + var itemRepository = new ItemRepository(clients); + var userRepository = new UserRepository(clients); + var loanRepository = new LoanRepository(clients, itemRepository, userRepository); + var accountRepository = new AccountRepository(clients); + var lostItemPolicyRepository = new LostItemPolicyRepository(clients); + var actualCostRecordRepository = new ActualCostRecordRepository(clients); + var closeLoanWithLostItemService = new CloseLoanWithLostItemService(loanRepository, + itemRepository, accountRepository, lostItemPolicyRepository, + eventPublisher, actualCostRecordRepository); + var loanPageableFetcher = new PageableFetcher<>(loanRepository); + var actualCostRecordExpirationService = new ActualCostRecordExpirationService( + loanPageableFetcher, closeLoanWithLostItemService, itemRepository); + + actualCostRecordExpirationService.expireActualCostRecords() + .thenApply(r -> r.map(toFixedValue(NoContentResponse::noContent))) + .thenAccept(context::writeResultToHttpResponse); + } + + +} diff --git a/src/main/java/org/folio/circulation/resources/handlers/LoanRelatedFeeFineClosedHandlerResource.java b/src/main/java/org/folio/circulation/resources/handlers/LoanRelatedFeeFineClosedHandlerResource.java index 2742ae6495..c82109fa49 100644 --- a/src/main/java/org/folio/circulation/resources/handlers/LoanRelatedFeeFineClosedHandlerResource.java +++ b/src/main/java/org/folio/circulation/resources/handlers/LoanRelatedFeeFineClosedHandlerResource.java @@ -1,8 +1,6 @@ package org.folio.circulation.resources.handlers; -import static java.util.concurrent.CompletableFuture.completedFuture; import static org.folio.circulation.domain.EventType.LOAN_RELATED_FEE_FINE_CLOSED; -import static org.folio.circulation.domain.FeeFine.lostItemFeeTypes; import static org.folio.circulation.domain.subscribers.LoanRelatedFeeFineClosedEvent.fromJson; import static org.folio.circulation.support.Clients.create; import static org.folio.circulation.support.ValidationErrorFailure.singleValidationError; @@ -13,26 +11,24 @@ import java.util.concurrent.CompletableFuture; -import org.folio.circulation.StoreLoanAndItem; -import org.folio.circulation.domain.Account; -import org.folio.circulation.domain.Loan; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.folio.circulation.domain.subscribers.LoanRelatedFeeFineClosedEvent; +import org.folio.circulation.infrastructure.storage.ActualCostRecordRepository; import org.folio.circulation.infrastructure.storage.feesandfines.AccountRepository; import org.folio.circulation.infrastructure.storage.inventory.ItemRepository; import org.folio.circulation.infrastructure.storage.loans.LoanRepository; import org.folio.circulation.infrastructure.storage.loans.LostItemPolicyRepository; import org.folio.circulation.infrastructure.storage.users.UserRepository; import org.folio.circulation.resources.Resource; +import org.folio.circulation.services.CloseLoanWithLostItemService; import org.folio.circulation.services.EventPublisher; -import org.folio.circulation.support.Clients; import org.folio.circulation.support.RouteRegistration; import org.folio.circulation.support.http.server.NoContentResponse; import org.folio.circulation.support.http.server.ValidationError; import org.folio.circulation.support.http.server.WebContext; import org.folio.circulation.support.results.CommonFailures; import org.folio.circulation.support.results.Result; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; import io.vertx.core.http.HttpClient; import io.vertx.ext.web.Router; @@ -54,13 +50,19 @@ public void register(Router router) { private void handleFeeFineClosedEvent(RoutingContext routingContext) { final WebContext context = new WebContext(routingContext); - final EventPublisher eventPublisher = new EventPublisher(routingContext); + final var eventPublisher = new EventPublisher(routingContext); + final var clients = create(context, client); + final var itemRepository = new ItemRepository(clients); + final var userRepository = new UserRepository(clients); + final var loanRepository = new LoanRepository(clients, itemRepository, userRepository); + final var closeLoanWithLostItemService = new CloseLoanWithLostItemService(loanRepository, + itemRepository, new AccountRepository(clients), new LostItemPolicyRepository(clients), + eventPublisher, new ActualCostRecordRepository(clients)); log.info("Event {} received: {}", LOAN_RELATED_FEE_FINE_CLOSED, routingContext.getBodyAsString()); createAndValidateRequest(routingContext) - .after(request -> processEvent(context, request, eventPublisher)) - .thenCompose(r -> r.after(eventPublisher::publishClosedLoanEvent)) + .after(request -> processEvent(loanRepository, request, closeLoanWithLostItemService)) .exceptionally(CommonFailures::failedDueToServerError) .thenApply(r -> r.map(toFixedValue(NoContentResponse::noContent))) .thenAccept(result -> result.applySideEffect(context::write, failure -> { @@ -71,70 +73,11 @@ private void handleFeeFineClosedEvent(RoutingContext routingContext) { })); } - private CompletableFuture> processEvent(WebContext context, - LoanRelatedFeeFineClosedEvent event, EventPublisher eventPublisher) { - - final Clients clients = create(context, client); - final var itemRepository = new ItemRepository(clients); - final var userRepository = new UserRepository(clients); - final var loanRepository = new LoanRepository(clients, itemRepository, userRepository); - final var accountRepository = new AccountRepository(clients); - final var lostItemPolicyRepository = new LostItemPolicyRepository(clients); + private CompletableFuture> processEvent(LoanRepository loanRepository, + LoanRelatedFeeFineClosedEvent event, CloseLoanWithLostItemService closeLoanWithLostItemService) { return loanRepository.getById(event.getLoanId()) - .thenCompose(r -> r.after(loan -> { - if (loan.isItemLost()) { - return closeLoanWithLostItemIfLostFeesResolved(loan, - loanRepository, itemRepository, accountRepository, - lostItemPolicyRepository, eventPublisher); - } - - return completedFuture(succeeded(loan)); - })); - } - - private CompletableFuture> closeLoanWithLostItemIfLostFeesResolved( - Loan loan, LoanRepository loanRepository, - ItemRepository itemRepository, AccountRepository accountRepository, - LostItemPolicyRepository lostItemPolicyRepository, EventPublisher eventPublisher) { - - return accountRepository.findAccountsForLoan(loan) - .thenComposeAsync(lostItemPolicyRepository::findLostItemPolicyForLoan) - .thenCompose(r -> r.after(l -> closeLoanAndUpdateItem(l, loanRepository, - itemRepository, eventPublisher))); - } - - public CompletableFuture> closeLoanAndUpdateItem(Loan loan, - LoanRepository loanRepository, ItemRepository itemRepository, EventPublisher eventPublisher) { - - if (!allLostFeesClosed(loan)) { - return completedFuture(succeeded(loan)); - } - - boolean wasLoanOpen = loan.isOpen(); - loan.closeLoanAsLostAndPaid(); - - return new StoreLoanAndItem(loanRepository, itemRepository).updateLoanAndItemInStorage(loan) - .thenCompose(r -> r.after(l -> publishLoanClosedEvent(l, wasLoanOpen, eventPublisher))); - } - - private CompletableFuture> publishLoanClosedEvent(Loan loan, boolean wasLoanOpen, - EventPublisher eventPublisher) { - - return wasLoanOpen && loan.isClosed() - ? eventPublisher.publishLoanClosedEvent(loan) - : completedFuture(succeeded(loan)); - } - - private boolean allLostFeesClosed(Loan loan) { - if (loan.getLostItemPolicy().hasActualCostFee()) { - // Actual cost fee is processed manually - return false; - } - - return loan.getAccounts().stream() - .filter(account -> lostItemFeeTypes().contains(account.getFeeFineType())) - .allMatch(Account::isClosed); + .thenCompose(r -> r.after(closeLoanWithLostItemService::closeLoanWithLostItemFeesPaid)); } private Result createAndValidateRequest(RoutingContext context) { diff --git a/src/main/java/org/folio/circulation/services/CloseLoanWithLostItemService.java b/src/main/java/org/folio/circulation/services/CloseLoanWithLostItemService.java new file mode 100644 index 0000000000..31c799ec53 --- /dev/null +++ b/src/main/java/org/folio/circulation/services/CloseLoanWithLostItemService.java @@ -0,0 +1,117 @@ +package org.folio.circulation.services; + +import static java.util.concurrent.CompletableFuture.completedFuture; +import static org.folio.circulation.domain.FeeFine.LOST_ITEM_ACTUAL_COST_FEE_TYPE; +import static org.folio.circulation.domain.FeeFine.lostItemFeeTypes; +import static org.folio.circulation.support.results.Result.succeeded; +import static org.folio.circulation.support.utils.ClockUtil.getZonedDateTime; + +import java.time.ZonedDateTime; +import java.util.concurrent.CompletableFuture; + +import org.folio.circulation.StoreLoanAndItem; +import org.folio.circulation.domain.Account; +import org.folio.circulation.domain.ActualCostRecord; +import org.folio.circulation.domain.Loan; +import org.folio.circulation.infrastructure.storage.ActualCostRecordRepository; +import org.folio.circulation.infrastructure.storage.feesandfines.AccountRepository; +import org.folio.circulation.infrastructure.storage.inventory.ItemRepository; +import org.folio.circulation.infrastructure.storage.loans.LoanRepository; +import org.folio.circulation.infrastructure.storage.loans.LostItemPolicyRepository; +import org.folio.circulation.support.results.Result; +public class CloseLoanWithLostItemService { + + private final LoanRepository loanRepository; + + private final LostItemPolicyRepository lostItemPolicyRepository; + + private final ItemRepository itemRepository; + + private final AccountRepository accountRepository; + + private final EventPublisher eventPublisher; + + private final ActualCostRecordRepository actualCostRecordRepository; + + public CloseLoanWithLostItemService(LoanRepository loanRepository, ItemRepository itemRepository, + AccountRepository accountRepository, LostItemPolicyRepository lostItemPolicyRepository, + EventPublisher eventPublisher, ActualCostRecordRepository actualCostRecordRepository) { + + this.loanRepository = loanRepository; + this.itemRepository = itemRepository; + this.accountRepository = accountRepository; + this.lostItemPolicyRepository = lostItemPolicyRepository; + this.eventPublisher = eventPublisher; + this.actualCostRecordRepository = actualCostRecordRepository; + } + + public CompletableFuture> closeLoanWithLostItemFeesPaid(Loan loan) { + if (loan == null || !loan.isItemLost()) { + return completedFuture(Result.succeeded(null)); + } + + return fetchLoanFeeFineData(loan) + .thenCompose(r -> r.after(this::closeLoanWithLostItemFeesPaidAndPublishEvents)); + } + + private CompletableFuture> closeLoanWithLostItemFeesPaidAndPublishEvents( + Loan loan) { + + return closeLoanWithLostItemFeesPaid(loan, loanRepository, itemRepository, eventPublisher) + .thenCompose(r -> r.after(eventPublisher::publishClosedLoanEvent)); + } + + private CompletableFuture> fetchLoanFeeFineData(Loan loan) { + return accountRepository.findAccountsForLoan(loan) + .thenComposeAsync(lostItemPolicyRepository::findLostItemPolicyForLoan) + .thenComposeAsync(actualCostRecordRepository::findByLoan); + } + + private CompletableFuture> closeLoanWithLostItemFeesPaid(Loan loan, + LoanRepository loanRepository, ItemRepository itemRepository, EventPublisher eventPublisher) { + + if (!shouldCloseLoan(loan)) { + return completedFuture(succeeded(loan)); + } + + boolean wasLoanOpen = loan.isOpen(); + loan.closeLoanAsLostAndPaid(); + + return new StoreLoanAndItem(loanRepository, itemRepository).updateLoanAndItemInStorage(loan) + .thenCompose(r -> r.after(l -> publishLoanClosedEvent(l, wasLoanOpen, eventPublisher))); + } + + private CompletableFuture> publishLoanClosedEvent(Loan loan, boolean wasLoanOpen, + EventPublisher eventPublisher) { + + return wasLoanOpen && loan.isClosed() + ? eventPublisher.publishLoanClosedEvent(loan) + : completedFuture(succeeded(loan)); + } + + private boolean allLostFeesClosed(Loan loan) { + return loan.getAccounts().stream() + .filter(account -> lostItemFeeTypes().contains(account.getFeeFineType())) + .allMatch(Account::isClosed); + } + + private boolean shouldCloseLoan(Loan loan) { + if (allLostFeesClosed(loan)) { + ActualCostRecord actualCostRecord = loan.getActualCostRecord(); + if (actualCostRecord == null) { + return true; + } + if (loan.getAccounts().stream().noneMatch(account -> + LOST_ITEM_ACTUAL_COST_FEE_TYPE.equals(account.getFeeFineType()))) { + + ZonedDateTime expirationDate = actualCostRecord.getExpirationDate(); + + return expirationDate != null && getZonedDateTime().isAfter(expirationDate); + } else { + return true; + } + } + + return false; + } +} diff --git a/src/main/java/org/folio/circulation/services/LostItemFeeChargingService.java b/src/main/java/org/folio/circulation/services/LostItemFeeChargingService.java index 3938e5939d..af08f05951 100644 --- a/src/main/java/org/folio/circulation/services/LostItemFeeChargingService.java +++ b/src/main/java/org/folio/circulation/services/LostItemFeeChargingService.java @@ -34,6 +34,7 @@ import org.folio.circulation.infrastructure.storage.feesandfines.FeeFineRepository; import org.folio.circulation.infrastructure.storage.inventory.LocationRepository; import org.folio.circulation.infrastructure.storage.loans.LostItemPolicyRepository; +import org.folio.circulation.services.actualcostrecord.ActualCostRecordService; import org.folio.circulation.services.support.CreateAccountCommand; import org.folio.circulation.support.Clients; import org.folio.circulation.support.results.Result; diff --git a/src/main/java/org/folio/circulation/services/LostItemFeeRefundService.java b/src/main/java/org/folio/circulation/services/LostItemFeeRefundService.java index 6c2b5065fc..9cbbdb1775 100644 --- a/src/main/java/org/folio/circulation/services/LostItemFeeRefundService.java +++ b/src/main/java/org/folio/circulation/services/LostItemFeeRefundService.java @@ -329,4 +329,4 @@ private Result noLoanFoundForLostItem(String itemId) { "Item is lost however there is no aged to lost nor declared lost loan found", "itemId", itemId)); } -} \ No newline at end of file +} diff --git a/src/main/java/org/folio/circulation/services/actualcostrecord/ActualCostRecordExpirationService.java b/src/main/java/org/folio/circulation/services/actualcostrecord/ActualCostRecordExpirationService.java new file mode 100644 index 0000000000..9c55e81c30 --- /dev/null +++ b/src/main/java/org/folio/circulation/services/actualcostrecord/ActualCostRecordExpirationService.java @@ -0,0 +1,60 @@ +package org.folio.circulation.services.actualcostrecord; + +import static org.folio.circulation.domain.ItemStatus.DECLARED_LOST; +import static org.folio.circulation.domain.representations.LoanProperties.ITEM_STATUS; +import static org.folio.circulation.support.AsyncCoordinationUtil.allOf; +import static org.folio.circulation.support.results.AsynchronousResult.fromFutureResult; +import static org.folio.circulation.support.results.Result.ofAsync; +import static org.folio.circulation.support.results.Result.succeeded; + +import java.util.concurrent.CompletableFuture; + +import org.folio.circulation.domain.Loan; +import org.folio.circulation.domain.MultipleRecords; +import org.folio.circulation.infrastructure.storage.inventory.ItemRepository; +import org.folio.circulation.services.CloseLoanWithLostItemService; +import org.folio.circulation.support.fetching.PageableFetcher; +import org.folio.circulation.support.http.client.CqlQuery; +import org.folio.circulation.support.results.Result; + +public class ActualCostRecordExpirationService { + private final PageableFetcher loanPageableFetcher; + private final CloseLoanWithLostItemService closeLoanWithLostItemService; + private final ItemRepository itemRepository; + + public ActualCostRecordExpirationService(PageableFetcher loanPageableFetcher, + CloseLoanWithLostItemService closeLoanWithLostItemService, ItemRepository itemRepository) { + + this.itemRepository = itemRepository; + this.loanPageableFetcher = loanPageableFetcher; + this.closeLoanWithLostItemService = closeLoanWithLostItemService; + } + + public CompletableFuture> expireActualCostRecords() { + return fetchLoansForLostItemsQuery() + .after(query -> loanPageableFetcher.processPages(query, this::closeLoans)); + } + + private Result fetchLoansForLostItemsQuery() { + return CqlQuery.exactMatch(ITEM_STATUS, DECLARED_LOST.getValue()); + } + + private CompletableFuture> closeLoans(MultipleRecords expiredLoans) { + if (expiredLoans.isEmpty()) { + return ofAsync(() -> null); + } + + return fromFutureResult(itemRepository.fetchItems(succeeded(expiredLoans)) + .thenApply(r -> r.next(this::excludeLoansWithNonexistentItems))) + .flatMapFuture(loans -> allOf(loans.getRecords(), + closeLoanWithLostItemService::closeLoanWithLostItemFeesPaid)) + .toCompletableFuture() + .thenApply(r -> r.map(ignored -> null)); + } + + private Result> excludeLoansWithNonexistentItems( + MultipleRecords loans) { + + return succeeded(loans.filter(loan -> loan.getItem().isFound())); + } +} diff --git a/src/main/java/org/folio/circulation/services/ActualCostRecordService.java b/src/main/java/org/folio/circulation/services/actualcostrecord/ActualCostRecordService.java similarity index 95% rename from src/main/java/org/folio/circulation/services/ActualCostRecordService.java rename to src/main/java/org/folio/circulation/services/actualcostrecord/ActualCostRecordService.java index 3f126d267b..657a0d37f8 100644 --- a/src/main/java/org/folio/circulation/services/ActualCostRecordService.java +++ b/src/main/java/org/folio/circulation/services/actualcostrecord/ActualCostRecordService.java @@ -1,4 +1,4 @@ -package org.folio.circulation.services; +package org.folio.circulation.services.actualcostrecord; import java.time.ZonedDateTime; import java.util.Map; @@ -89,7 +89,7 @@ private ActualCostRecord buildActualCostRecord(Loan loan, FeeFineOwner feeFineOw .withUserBarcode(loan.getUser().getBarcode()) .withLoanId(loan.getId()) .withItemLossType(itemLossType) - .withDateOfLoss(dateOfLoss.toString()) + .withDateOfLoss(dateOfLoss) .withTitle(item.getTitle()) .withIdentifiers(item.getIdentifiers().collect(Collectors.toList())) .withItemBarcode(item.getBarcode()) @@ -99,6 +99,8 @@ private ActualCostRecord buildActualCostRecord(Loan loan, FeeFineOwner feeFineOw .withFeeFineOwnerId(feeFineOwner.getId()) .withFeeFineOwner(feeFineOwner.getOwner()) .withFeeFineTypeId(feeFine == null ? null : feeFine.getId()) - .withFeeFineType(feeFine == null ? null : feeFine.getFeeFineType()); + .withFeeFineType(feeFine == null ? null : feeFine.getFeeFineType()) + .withExpirationDate(loan.getLostItemPolicy() + .calculateFeeFineChargingPeriodExpirationDateTime(dateOfLoss)); } } diff --git a/src/main/java/org/folio/circulation/services/agedtolost/ChargeLostFeesWhenAgedToLostService.java b/src/main/java/org/folio/circulation/services/agedtolost/ChargeLostFeesWhenAgedToLostService.java index e490bc4c08..ecfe9b3cf3 100644 --- a/src/main/java/org/folio/circulation/services/agedtolost/ChargeLostFeesWhenAgedToLostService.java +++ b/src/main/java/org/folio/circulation/services/agedtolost/ChargeLostFeesWhenAgedToLostService.java @@ -51,7 +51,7 @@ import org.folio.circulation.infrastructure.storage.loans.LoanRepository; import org.folio.circulation.infrastructure.storage.loans.LostItemPolicyRepository; import org.folio.circulation.infrastructure.storage.users.UserRepository; -import org.folio.circulation.services.ActualCostRecordService; +import org.folio.circulation.services.actualcostrecord.ActualCostRecordService; import org.folio.circulation.services.EventPublisher; import org.folio.circulation.services.FeeFineFacade; import org.folio.circulation.services.support.CreateAccountCommand; diff --git a/src/main/java/org/folio/circulation/storage/mappers/ActualCostRecordMapper.java b/src/main/java/org/folio/circulation/storage/mappers/ActualCostRecordMapper.java index cd243d052a..8a88af09bd 100644 --- a/src/main/java/org/folio/circulation/storage/mappers/ActualCostRecordMapper.java +++ b/src/main/java/org/folio/circulation/storage/mappers/ActualCostRecordMapper.java @@ -7,6 +7,7 @@ import io.vertx.core.json.JsonObject; import static org.folio.circulation.domain.representations.CallNumberComponentsRepresentation.createCallNumberComponents; +import static org.folio.circulation.support.json.JsonPropertyFetcher.getDateTimeProperty; import static org.folio.circulation.support.json.JsonPropertyFetcher.getNestedDateTimeProperty; import static org.folio.circulation.support.json.JsonPropertyFetcher.getProperty; import static org.folio.circulation.support.json.JsonPropertyWriter.write; @@ -35,6 +36,7 @@ public static JsonObject toJson(ActualCostRecord actualCostRecord) { write(json, "feeFineTypeId", actualCostRecord.getFeeFineTypeId()); write(json, "feeFineType", actualCostRecord.getFeeFineType()); write(json,"permanentItemLocation", actualCostRecord.getPermanentItemLocation()); + write(json, "expirationDate", actualCostRecord.getExpirationDate()); if (actualCostRecord.getAccountId() != null) { write(json, "accountId", actualCostRecord.getAccountId()); @@ -44,13 +46,17 @@ public static JsonObject toJson(ActualCostRecord actualCostRecord) { } public static ActualCostRecord toDomain(JsonObject representation) { + if (representation == null ) { + return null; + } + return new ActualCostRecord(getProperty(representation, "id"), getProperty(representation, "accountId"), getProperty(representation, "userId"), getProperty(representation, "userBarcode"), getProperty(representation, "loanId"), ItemLossType.from(getProperty(representation, "itemLossType")), - getProperty(representation, "dateOfLoss"), + getDateTimeProperty(representation, "dateOfLoss"), getProperty(representation, "title"), IdentifierMapper.mapIdentifiers(representation), getProperty(representation, "itemBarcode"), @@ -61,7 +67,8 @@ public static ActualCostRecord toDomain(JsonObject representation) { getProperty(representation, "feeFineOwner"), getProperty(representation, "feeFineTypeId"), getProperty(representation, "feeFineType"), - getNestedDateTimeProperty(representation, "metadata", "createdDate") + getNestedDateTimeProperty(representation, "metadata", "createdDate"), + getDateTimeProperty(representation, "expirationDate") ); } } diff --git a/src/test/java/api/handlers/CloseDeclaredLostLoanWhenLostItemFeesAreClosedApiTests.java b/src/test/java/api/handlers/CloseDeclaredLostLoanWhenLostItemFeesAreClosedApiTests.java index dcd0806654..d42f8c68f0 100644 --- a/src/test/java/api/handlers/CloseDeclaredLostLoanWhenLostItemFeesAreClosedApiTests.java +++ b/src/test/java/api/handlers/CloseDeclaredLostLoanWhenLostItemFeesAreClosedApiTests.java @@ -1,7 +1,14 @@ package api.handlers; +import static api.support.APITestContext.getOkapiHeadersFromContext; +import api.support.builders.AccountBuilder; +import api.support.builders.FeefineActionsBuilder; +import api.support.builders.LostItemFeePolicyBuilder; import static api.support.fakes.FakePubSub.getPublishedEventsAsList; import static api.support.fakes.PublishedEvents.byEventType; +import static api.support.http.InterfaceUrls.scheduledActualCostExpiration; +import static api.support.http.InterfaceUrls.scheduledAgeToLostFeeChargingUrl; +import api.support.http.TimedTaskClient; import static api.support.matchers.EventMatchers.isValidLoanClosedEvent; import static api.support.matchers.ItemMatchers.isAvailable; import static api.support.matchers.ItemMatchers.isCheckedOut; @@ -10,7 +17,12 @@ import static api.support.matchers.JsonObjectMatcher.hasJsonPath; import static api.support.matchers.LoanMatchers.isClosed; import static api.support.matchers.LoanMatchers.isOpen; +import static java.time.Clock.fixed; +import static java.time.ZoneOffset.UTC; import static org.folio.circulation.domain.EventType.LOAN_CLOSED; +import static org.folio.circulation.support.utils.ClockUtil.getZonedDateTime; +import static org.folio.circulation.support.utils.ClockUtil.setClock; +import static org.folio.circulation.support.utils.ClockUtil.setDefaultClock; import static org.hamcrest.CoreMatchers.allOf; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; @@ -20,7 +32,9 @@ import java.util.List; import java.util.UUID; +import org.folio.circulation.domain.policy.Period; import org.folio.circulation.support.http.client.Response; +import org.folio.circulation.support.utils.ClockUtil; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -33,8 +47,11 @@ class CloseDeclaredLostLoanWhenLostItemFeesAreClosedApiTests extends APITests { private IndividualResource loan; private IndividualResource item; + private final TimedTaskClient timedTaskClient = new TimedTaskClient(getOkapiHeadersFromContext()); + @BeforeEach public void createLoanAndDeclareItemLost() { + mockClockManagerToReturnDefaultDateTime(); UUID servicePointId = servicePointsFixture.cd1().getId(); useLostItemPolicy(lostItemFeePoliciesFixture.chargeFee().getId()); @@ -98,16 +115,104 @@ void shouldNotCloseLoanIfSetCostFeeIsNotClosed() { } @Test - void shouldNotCloseLoanIfActualCostFeeShouldBeCharged() { - useLostItemPolicy(lostItemFeePoliciesFixture.create( - lostItemFeePoliciesFixture.facultyStandardPolicy() + void shouldCloseLoanIfActualCostFeeHasBeenPaidWithoutProcessingFee() { + UUID servicePointId = servicePointsFixture.cd2().getId(); + UUID actualCostLostItemFeePolicyId = lostItemFeePoliciesFixture.create( + new LostItemFeePolicyBuilder().withName("test") + .doNotChargeProcessingFeeWhenDeclaredLost() + .withActualCost(10.0) + .withLostItemChargeFeeFine(Period.weeks(2))).getId(); + useLostItemPolicy(actualCostLostItemFeePolicyId); + + item = itemsFixture.basedUponNod(); + loan = checkOutFixture.checkOutByBarcode(item, usersFixture.jessica()); + declareLostFixtures.declareItemLost(new DeclareItemLostRequestBuilder() + .withServicePointId(servicePointId) + .forLoanId(loan.getId())); + createLostItemFeeActualCostAccount(10.0); + + feeFineAccountFixture.payLostItemActualCostFee(loan.getId()); + eventSubscribersFixture.publishLoanRelatedFeeFineClosedEvent(loan.getId()); + + assertThat(loansFixture.getLoanById(loan.getId()).getJson(), isClosed()); + assertThat(itemsClient.getById(item.getId()).getJson(), isLostAndPaid()); + } + + @Test + void shouldCloseLoanIfActualCostFeeAndProcessingFeeHaveBeenPaid() { + UUID servicePointId = servicePointsFixture.cd2().getId(); + UUID actualCostLostItemFeePolicyId = lostItemFeePoliciesFixture.create( + new LostItemFeePolicyBuilder().withName("test") .chargeProcessingFeeWhenDeclaredLost(10.00) - .withActualCost(10.0)).getId()); + .withActualCost(10.0) + .withLostItemChargeFeeFine(Period.weeks(2))).getId(); + useLostItemPolicy(actualCostLostItemFeePolicyId); + item = itemsFixture.basedUponNod(); + loan = checkOutFixture.checkOutByBarcode(item, usersFixture.jessica()); + declareLostFixtures.declareItemLost(new DeclareItemLostRequestBuilder() + .withServicePointId(servicePointId) + .forLoanId(loan.getId())); + createLostItemFeeActualCostAccount(10.0); + + feeFineAccountFixture.payLostItemActualCostFee(loan.getId()); feeFineAccountFixture.payLostItemProcessingFee(loan.getId()); + eventSubscribersFixture.publishLoanRelatedFeeFineClosedEvent(loan.getId()); + + assertThat(loansFixture.getLoanById(loan.getId()).getJson(), isClosed()); + assertThat(itemsClient.getById(item.getId()).getJson(), isLostAndPaid()); + } + + @Test + void shouldCloseLoanIfActualCostFeeShouldBeChargedButChargingPeriodElapsedAndProcessingFeeHasBeenPaid() { + UUID servicePointId = servicePointsFixture.cd2().getId(); + + UUID actualCostLostItemFeePolicyId = lostItemFeePoliciesFixture.create( + new LostItemFeePolicyBuilder().withName("test") + .chargeProcessingFeeWhenDeclaredLost(10.00) + .withActualCost(10.0) + .withLostItemChargeFeeFine(Period.weeks(2))).getId(); + useLostItemPolicy(actualCostLostItemFeePolicyId); + item = itemsFixture.basedUponNod(); + loan = checkOutFixture.checkOutByBarcode(item, usersFixture.jessica()); + declareLostFixtures.declareItemLost(new DeclareItemLostRequestBuilder() + .withServicePointId(servicePointId) + .forLoanId(loan.getId())); + + mockClockManagerToReturnFixedDateTime(ClockUtil.getZonedDateTime().plusWeeks(3)); + feeFineAccountFixture.payLostItemProcessingFee(loan.getId()); eventSubscribersFixture.publishLoanRelatedFeeFineClosedEvent(loan.getId()); + assertThat(loansFixture.getLoanById(loan.getId()).getJson(), isClosed()); + assertThat(itemsClient.getById(item.getId()).getJson(), isLostAndPaid()); + } + + @Test + void shouldNotCloseLoanDuringScheduledExpirationIfChargingPeriodHasNotElapsedAndProcessingFeeHasBeenPaid() { + UUID servicePointId = servicePointsFixture.cd2().getId(); + + UUID actualCostLostItemFeePolicyId = lostItemFeePoliciesFixture.create( + new LostItemFeePolicyBuilder().withName("test") + .chargeProcessingFeeWhenDeclaredLost(10.00) + .withActualCost(10.0) + .withLostItemChargeFeeFine(Period.weeks(2))).getId(); + useLostItemPolicy(actualCostLostItemFeePolicyId); + + item = itemsFixture.basedUponNod(); + loan = checkOutFixture.checkOutByBarcode(item, usersFixture.jessica()); + declareLostFixtures.declareItemLost(new DeclareItemLostRequestBuilder() + .withServicePointId(servicePointId) + .forLoanId(loan.getId())); + + feeFineAccountFixture.payLostItemProcessingFee(loan.getId()); + eventSubscribersFixture.publishLoanRelatedFeeFineClosedEvent(loan.getId()); + + mockClockManagerToReturnFixedDateTime(ClockUtil.getZonedDateTime().plusWeeks(1)); + + timedTaskClient.start(scheduledActualCostExpiration(), 204, + "scheduled-actual-cost-expiration"); + assertThat(loansFixture.getLoanById(loan.getId()).getJson(), isOpen()); assertThat(itemsClient.getById(item.getId()).getJson(), isDeclaredLost()); } @@ -177,4 +282,23 @@ void shouldNotPublishLoanClosedEventWhenLoanIsOriginallyClosed() { assertThat(getPublishedEventsAsList(byEventType(LOAN_CLOSED)), empty()); } + + protected void createLostItemFeeActualCostAccount(double amount) { + IndividualResource account = accountsClient.create(new AccountBuilder() + .withLoan(loan) + .withAmount(amount) + .withRemainingFeeFine(amount) + .feeFineStatusOpen() + .withFeeFineActualCostType() + .withFeeFine(feeFineTypeFixture.lostItemActualCostFee()) + .withOwner(feeFineOwnerFixture.cd1Owner()) + .withPaymentStatus("Outstanding")); + + feeFineActionsClient.create(new FeefineActionsBuilder() + .forAccount(account.getId()) + .withBalance(amount) + .withActionAmount(amount) + .withActionType("Lost item fee (actual cost)")); + } + } diff --git a/src/test/java/api/loans/DeclareLostAPITests.java b/src/test/java/api/loans/DeclareLostAPITests.java index b6b3e4e697..5065f48aad 100644 --- a/src/test/java/api/loans/DeclareLostAPITests.java +++ b/src/test/java/api/loans/DeclareLostAPITests.java @@ -827,56 +827,56 @@ public void shouldRefundPartiallyPaidOrTransferredLostItemFeesBeforeApplyingNewF @Test void shouldClearExistingFeesAndCloseLoanAsLostAndPaidIfLostandPaidItemDeclaredLostAndPolicySetNotToChargeFees() { - final double lostItemProcessingFee = 20.0; - UUID servicePointId = servicePointsFixture.cd1().getId(); + final double lostItemProcessingFee = 20.0; + UUID servicePointId = servicePointsFixture.cd1().getId(); - final LostItemFeePolicyBuilder lostPolicyBuilder = lostItemFeePoliciesFixture.ageToLostAfterOneMinutePolicy() - .withName("age to lost with processing fees") - .billPatronImmediatelyWhenAgedToLost() - .withNoFeeRefundInterval() - .withNoChargeAmountItem() - .doNotChargeProcessingFeeWhenDeclaredLost() - .chargeProcessingFeeWhenAgedToLost(lostItemProcessingFee); + final LostItemFeePolicyBuilder lostPolicyBuilder = lostItemFeePoliciesFixture.ageToLostAfterOneMinutePolicy() + .withName("age to lost with processing fees") + .billPatronImmediatelyWhenAgedToLost() + .withNoFeeRefundInterval() + .withNoChargeAmountItem() + .doNotChargeProcessingFeeWhenDeclaredLost() + .chargeProcessingFeeWhenAgedToLost(lostItemProcessingFee); - useLostItemPolicy(lostItemFeePoliciesFixture.create(lostPolicyBuilder).getId()); + useLostItemPolicy(lostItemFeePoliciesFixture.create(lostPolicyBuilder).getId()); - AgeToLostResult agedToLostResult = ageToLostFixture.createLoanAgeToLostAndChargeFees(lostPolicyBuilder); - UUID testLoanId = agedToLostResult.getLoanId(); - UUID itemId = agedToLostResult.getItemId(); + AgeToLostResult agedToLostResult = ageToLostFixture.createLoanAgeToLostAndChargeFees(lostPolicyBuilder); + UUID testLoanId = agedToLostResult.getLoanId(); + UUID itemId = agedToLostResult.getItemId(); - JsonObject AgeToLostItem = itemsFixture.getById(itemId).getJson(); + JsonObject AgeToLostItem = itemsFixture.getById(itemId).getJson(); - assertThat(AgeToLostItem, isAgedToLost()); + assertThat(AgeToLostItem, isAgedToLost()); - JsonObject itemFee = getAccountForLoan(testLoanId, "Lost item processing fee"); + JsonObject itemFee = getAccountForLoan(testLoanId, "Lost item processing fee"); - assertThat(itemFee, hasJsonPath("amount", lostItemProcessingFee)); + assertThat(itemFee, hasJsonPath("amount", lostItemProcessingFee)); - final ZonedDateTime declareLostDate = getZonedDateTime().plusWeeks(1); - mockClockManagerToReturnFixedDateTime(declareLostDate); + final ZonedDateTime declareLostDate = getZonedDateTime().plusWeeks(1); + mockClockManagerToReturnFixedDateTime(declareLostDate); - final DeclareItemLostRequestBuilder builder = new DeclareItemLostRequestBuilder() - .forLoanId(testLoanId) - .withServicePointId(servicePointId) - .on(declareLostDate) - .withNoComment(); + final DeclareItemLostRequestBuilder builder = new DeclareItemLostRequestBuilder() + .forLoanId(testLoanId) + .withServicePointId(servicePointId) + .on(declareLostDate) + .withNoComment(); - FakePubSub.clearPublishedEvents(); + FakePubSub.clearPublishedEvents(); - declareLostFixtures.declareItemLost(builder); + declareLostFixtures.declareItemLost(builder); - JsonObject declareLostLoan = loansClient.getById(testLoanId).getJson(); - JsonObject declareLostItem = itemsFixture.getById(itemId).getJson(); + JsonObject declareLostLoan = loansClient.getById(testLoanId).getJson(); + JsonObject declareLostItem = itemsFixture.getById(itemId).getJson(); - assertThat(declareLostItem, isLostAndPaid()); + assertThat(declareLostItem, isLostAndPaid()); - Double finalAmountRemaining = declareLostLoan.getJsonObject("feesAndFines").getDouble("amountRemainingToPay"); - assertEquals(finalAmountRemaining, 0.0, 0.01); + Double finalAmountRemaining = declareLostLoan.getJsonObject("feesAndFines").getDouble("amountRemainingToPay"); + assertEquals(finalAmountRemaining, 0.0, 0.01); - List accounts = getAccountsForLoan(testLoanId); + List accounts = getAccountsForLoan(testLoanId); - assertThat(accounts, hasSize(1)); - assertThat(getOpenAccounts(accounts), hasSize(0)); + assertThat(accounts, hasSize(1)); + assertThat(getOpenAccounts(accounts), hasSize(0)); verifyNumberOfPublishedEvents(LOAN_CLOSED, 1); verifyNumberOfPublishedEvents(ITEM_DECLARED_LOST, 0); diff --git a/src/test/java/api/support/builders/AccountBuilder.java b/src/test/java/api/support/builders/AccountBuilder.java index 1416c79e4f..c9c1da4a05 100644 --- a/src/test/java/api/support/builders/AccountBuilder.java +++ b/src/test/java/api/support/builders/AccountBuilder.java @@ -1,15 +1,11 @@ package api.support.builders; - - import static org.folio.circulation.support.json.JsonPropertyWriter.write; - import java.util.UUID; import api.support.http.IndividualResource; import io.vertx.core.json.JsonObject; public class AccountBuilder extends JsonBuilder implements Builder { - private String id; private String loanId; private Double remainingAmount; @@ -40,7 +36,6 @@ public AccountBuilder() { @Override public JsonObject create() { JsonObject accountRequest = new JsonObject(); - write(accountRequest, "id", id); write(accountRequest, "loanId", loanId); write(accountRequest, "amount", amount); diff --git a/src/test/java/api/support/builders/LostItemFeePolicyBuilder.java b/src/test/java/api/support/builders/LostItemFeePolicyBuilder.java index a91787994d..64bcb5d077 100644 --- a/src/test/java/api/support/builders/LostItemFeePolicyBuilder.java +++ b/src/test/java/api/support/builders/LostItemFeePolicyBuilder.java @@ -24,7 +24,7 @@ public class LostItemFeePolicyBuilder extends JsonBuilder implements Builder { private final Double lostItemProcessingFee; private final boolean chargeAmountItemPatron; private final boolean chargeAmountItemSystem; - private final JsonObject lostItemChargeFeeFine; + private final Period lostItemChargeFeeFine; private final boolean returnedLostItemProcessingFee; private final boolean replacedLostItemProcessingFee; private final double replacementProcessingFee; @@ -44,7 +44,7 @@ public LostItemFeePolicyBuilder() { null, false, false, - new JsonObject(), + null, false, false, 0.0, @@ -156,13 +156,16 @@ public JsonObject create() { request.put("feesFinesShallRefunded", this.feeRefundInterval.asJson()); } + if (lostItemChargeFeeFine != null) { + request.put("lostItemChargeFeeFine", this.lostItemChargeFeeFine.asJson()); + } + put(request, "name", this.name); put(request, "description", this.description); put(request, "chargeAmountItem", this.chargeAmountItem); put(request, "lostItemProcessingFee", this.lostItemProcessingFee); put(request, "chargeAmountItemPatron", this.chargeAmountItemPatron); put(request, "chargeAmountItemSystem", this.chargeAmountItemSystem); - put(request, "lostItemChargeFeeFine", this.lostItemChargeFeeFine); put(request, "returnedLostItemProcessingFee", this.returnedLostItemProcessingFee); put(request, "replacedLostItemProcessingFee", this.replacedLostItemProcessingFee); put(request, "replacementProcessingFee", String.valueOf(this.replacementProcessingFee)); diff --git a/src/test/java/api/support/fixtures/FeeFineAccountFixture.java b/src/test/java/api/support/fixtures/FeeFineAccountFixture.java index 56d8431e50..7343596548 100644 --- a/src/test/java/api/support/fixtures/FeeFineAccountFixture.java +++ b/src/test/java/api/support/fixtures/FeeFineAccountFixture.java @@ -12,6 +12,9 @@ import api.support.builders.FeefineActionsBuilder; import api.support.http.ResourceClient; import io.vertx.core.json.JsonObject; +import static org.folio.circulation.domain.FeeFine.LOST_ITEM_ACTUAL_COST_FEE_TYPE; +import static org.folio.circulation.domain.FeeFine.LOST_ITEM_FEE_TYPE; +import static org.folio.circulation.domain.FeeFine.LOST_ITEM_PROCESSING_FEE_TYPE; public final class FeeFineAccountFixture { private final ResourceClient accountsClient = forAccounts(); @@ -77,6 +80,13 @@ public void payLostItemActualCostFee(UUID loanId, double amount) { pay(accountId, amount); } + public void payLostItemActualCostFee(UUID loanId) { + final JsonObject lostItemFeeActualCostAccount = getAccount(loanId, LOST_ITEM_ACTUAL_COST_FEE_TYPE); + final String accountId = lostItemFeeActualCostAccount.getString("id"); + + pay(accountId, lostItemFeeActualCostAccount.getDouble("amount")); + } + public void payLostItemProcessingFee(UUID loanId) { final JsonObject lostItemProcessingFeeAccount = getAccount( loanId, LOST_ITEM_PROCESSING_FEE_TYPE); @@ -131,6 +141,13 @@ public IndividualResource createManualFeeForLoan(IndividualResource loan, double return account; } + private JsonObject getLostItemFeeAccount(UUID loanId) { + return accountsClient.getMany(exactMatch("loanId", loanId.toString()) + .and(exactMatch("feeFineType", "Lost item fee"))) + .getFirst(); + } + + private JsonObject getAccount(UUID loanId, String feeFineType) { return accountsClient.getMany(exactMatch("loanId", loanId.toString()) .and(exactMatch("feeFineType", feeFineType))) diff --git a/src/test/java/api/support/fixtures/LostItemFeePoliciesFixture.java b/src/test/java/api/support/fixtures/LostItemFeePoliciesFixture.java index 204860cb55..db6bca4683 100644 --- a/src/test/java/api/support/fixtures/LostItemFeePoliciesFixture.java +++ b/src/test/java/api/support/fixtures/LostItemFeePoliciesFixture.java @@ -2,6 +2,7 @@ import static api.support.http.ResourceClient.forLostItemFeePolicies; import static org.folio.circulation.domain.policy.Period.minutes; +import static org.folio.circulation.domain.policy.lostitem.ChargeAmountType.ACTUAL_COST; import static org.folio.circulation.domain.policy.lostitem.ChargeAmountType.SET_COST; import static org.folio.circulation.support.json.JsonPropertyFetcher.getProperty; @@ -34,19 +35,19 @@ public IndividualResource facultyStandard() { public IndividualResource chargeFee() { createReferenceData(); - return create(chargeFeePolicy(10.0, 5.0)); + return create(chargeSetCostFeePolicy(10.0, 5.0)); } public IndividualResource chargeFeeWithZeroLostItemFee() { createReferenceData(); - return create(chargeFeePolicy(0.0, 5.0)); + return create(chargeSetCostFeePolicy(0.0, 5.0)); } public IndividualResource chargeFeeWithZeroLostItemProcessingFee() { createReferenceData(); - return create(chargeFeePolicy(10.0, 0.0)); + return create(chargeSetCostFeePolicy(10.0, 0.0)); } public IndividualResource ageToLostAfterOneMinute() { @@ -86,12 +87,18 @@ public LostItemFeePolicyBuilder facultyStandardPolicy() { .chargeOverdueFineWhenReturned(); } - private LostItemFeePolicyBuilder chargeFeePolicy(double lostItemFeeCost, + private LostItemFeePolicyBuilder chargeSetCostFeePolicy(double lostItemFeeCost, double lostItemProcessingFeeCost) { return chargeFeePolicy(lostItemFeeCost, lostItemProcessingFeeCost, SET_COST); } + private LostItemFeePolicyBuilder chargeActualCostFeePolicy(double lostItemFeeCost, + double lostItemProcessingFeeCost) { + + return chargeFeePolicy(lostItemFeeCost, lostItemProcessingFeeCost, ACTUAL_COST); + } + private LostItemFeePolicyBuilder chargeFeePolicy(double lostItemFeeCost, double lostItemProcessingFeeCost, ChargeAmountType costType) { diff --git a/src/test/java/api/support/http/InterfaceUrls.java b/src/test/java/api/support/http/InterfaceUrls.java index 11bcce7759..793ee2d787 100644 --- a/src/test/java/api/support/http/InterfaceUrls.java +++ b/src/test/java/api/support/http/InterfaceUrls.java @@ -297,6 +297,10 @@ public static URL scheduledAgeToLostFeeChargingUrl() { return circulationModuleUrl("/circulation/scheduled-age-to-lost-fee-charging"); } + public static URL scheduledActualCostExpiration() { + return circulationModuleUrl("/circulation/actual-cost-expiration-by-timeout"); + } + public static URL actualCostRecordsStorageUrl(String subPath) { return APITestContext.viaOkapiModuleUrl("/actual-cost-record-storage/actual-cost-records" + subPath); }