From 2f8ef52315896b4fc6fc6bd0a6e49f8ad14d9e08 Mon Sep 17 00:00:00 2001 From: damien-git Date: Wed, 3 Apr 2024 11:15:27 -0400 Subject: [PATCH] [MODINVOICE-537] Update po lines paymentStatus when cancelling invoices (#484) --- descriptors/ModuleDescriptor-template.json | 1 + .../folio/config/ServicesConfiguration.java | 6 +- .../invoice/InvoiceCancelService.java | 196 +++++++++++++++--- .../services/invoice/InvoiceLineService.java | 9 + .../invoice/InvoiceCancelServiceTest.java | 113 +++++++++- 5 files changed, 292 insertions(+), 33 deletions(-) diff --git a/descriptors/ModuleDescriptor-template.json b/descriptors/ModuleDescriptor-template.json index 3cbd67436..9f4f86d25 100644 --- a/descriptors/ModuleDescriptor-template.json +++ b/descriptors/ModuleDescriptor-template.json @@ -63,6 +63,7 @@ ], "modulePermissions": [ "configuration.entries.collection.get", + "invoice-storage.invoices.collection.get", "invoice-storage.invoices.item.put", "invoice-storage.invoices.item.get", "invoice-storage.invoice-lines.item.put", diff --git a/src/main/java/org/folio/config/ServicesConfiguration.java b/src/main/java/org/folio/config/ServicesConfiguration.java index 3ebb38722..2675626b6 100644 --- a/src/main/java/org/folio/config/ServicesConfiguration.java +++ b/src/main/java/org/folio/config/ServicesConfiguration.java @@ -199,9 +199,11 @@ InvoiceCancelService invoiceCancelService(BaseTransactionService baseTransaction VoucherService voucherService, OrderLineService orderLineService, OrderService orderService, + InvoiceLineService invoiceLineService, + BaseInvoiceService baseInvoiceService, InvoiceWorkflowDataHolderBuilder invoiceWorkflowDataHolderBuilder) { - return new InvoiceCancelService(baseTransactionService, encumbranceService, - voucherService, orderLineService, orderService, invoiceWorkflowDataHolderBuilder); + return new InvoiceCancelService(baseTransactionService, encumbranceService, voucherService, orderLineService, + orderService, invoiceLineService, baseInvoiceService, invoiceWorkflowDataHolderBuilder); } @Bean BatchVoucherService batchVoucherService(RestClient restClient) { diff --git a/src/main/java/org/folio/services/invoice/InvoiceCancelService.java b/src/main/java/org/folio/services/invoice/InvoiceCancelService.java index 2d5dd96f5..e242ebcce 100644 --- a/src/main/java/org/folio/services/invoice/InvoiceCancelService.java +++ b/src/main/java/org/folio/services/invoice/InvoiceCancelService.java @@ -3,7 +3,7 @@ import static io.vertx.core.Future.succeededFuture; import static java.util.Collections.emptyList; import static java.util.Objects.requireNonNullElse; -import static java.util.stream.Collectors.toList; +import static java.util.stream.Collectors.groupingBy; import static org.folio.invoices.utils.ErrorCodes.CANCEL_TRANSACTIONS_ERROR; import static org.folio.invoices.utils.ErrorCodes.CANNOT_CANCEL_INVOICE; import static org.folio.invoices.utils.ErrorCodes.ERROR_UNRELEASING_ENCUMBRANCES; @@ -15,10 +15,17 @@ import static org.folio.rest.acq.model.finance.Transaction.TransactionType.CREDIT; import static org.folio.rest.acq.model.finance.Transaction.TransactionType.PAYMENT; import static org.folio.rest.acq.model.finance.Transaction.TransactionType.PENDING_PAYMENT; +import static org.folio.rest.acq.model.orders.PoLine.PaymentStatus.AWAITING_PAYMENT; +import static org.folio.rest.acq.model.orders.PoLine.PaymentStatus.PARTIALLY_PAID; +import java.util.ArrayList; import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; import io.vertx.core.Future; +import io.vertx.core.json.JsonObject; import one.util.streamex.StreamEx; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -28,10 +35,12 @@ import org.folio.rest.acq.model.finance.Transaction; import org.folio.rest.acq.model.finance.Transaction.TransactionType; import org.folio.rest.acq.model.finance.TransactionCollection; +import org.folio.rest.acq.model.orders.CompositePoLine; import org.folio.rest.acq.model.orders.PoLine; import org.folio.rest.acq.model.orders.PurchaseOrder; import org.folio.rest.core.models.RequestContext; import org.folio.rest.jaxrs.model.Invoice; +import org.folio.rest.jaxrs.model.InvoiceCollection; import org.folio.rest.jaxrs.model.InvoiceLine; import org.folio.rest.jaxrs.model.Parameter; import org.folio.services.finance.transaction.BaseTransactionService; @@ -43,15 +52,19 @@ public class InvoiceCancelService { private static final String PO_LINES_WITH_RIGHT_PAYMENT_STATUS_QUERY = "paymentStatus==(\"Awaiting Payment\" OR \"Partially Paid\" OR \"Fully Paid\" OR \"Ongoing\")"; + private static final String PAYMENT_STATUS_PAID_QUERY = "paymentStatus==(\"Fully Paid\" OR \"Partially Paid\")"; private static final String OPEN_ORDERS_QUERY = "workflowStatus==\"Open\""; + private static final String AND = " AND "; - private static final Logger logger = LogManager.getLogger(InvoiceCancelService.class); + private static final Logger logger = LogManager.getLogger(); private final BaseTransactionService baseTransactionService; private final EncumbranceService encumbranceService; private final VoucherService voucherService; private final OrderLineService orderLineService; private final OrderService orderService; + private final InvoiceLineService invoiceLineService; + private final BaseInvoiceService invoiceService; private final InvoiceWorkflowDataHolderBuilder holderBuilder; public InvoiceCancelService(BaseTransactionService baseTransactionService, @@ -59,12 +72,16 @@ public InvoiceCancelService(BaseTransactionService baseTransactionService, VoucherService voucherService, OrderLineService orderLineService, OrderService orderService, + InvoiceLineService invoiceLineService, + BaseInvoiceService baseInvoiceService, InvoiceWorkflowDataHolderBuilder holderBuilder) { this.baseTransactionService = baseTransactionService; this.encumbranceService = encumbranceService; this.voucherService = voucherService; this.orderLineService = orderLineService; this.orderService = orderService; + this.invoiceLineService = invoiceLineService; + this.invoiceService = baseInvoiceService; this.holderBuilder = holderBuilder; } @@ -81,6 +98,7 @@ public InvoiceCancelService(BaseTransactionService baseTransactionService, */ public Future cancelInvoice(Invoice invoiceFromStorage, List lines, RequestContext requestContext) { String invoiceId = invoiceFromStorage.getId(); + logger.info("cancelInvoice:: Cancelling invoice {}...", invoiceId); return Future.succeededFuture() .map(v -> { @@ -95,7 +113,10 @@ public Future cancelInvoice(Invoice invoiceFromStorage, List return null; }) .compose(v -> cancelVoucher(invoiceId, requestContext)) - .compose(v -> unreleaseEncumbrances(lines, invoiceFromStorage, requestContext)); + .compose(v -> unreleaseEncumbrances(lines, invoiceFromStorage, requestContext)) + .compose(v -> updatePoLinePaymentStatus(invoiceFromStorage, lines, requestContext)) + .onSuccess(v -> logger.info("cancelInvoice:: Invoice {} cancelled successfully", invoiceId)) + .onFailure(t -> logger.error("cancelInvoice:: Failed to cancel invoice {}", invoiceId, t)); } private void validateCancelInvoice(Invoice invoiceFromStorage) { @@ -121,7 +142,8 @@ private void validateCancelInvoice(Invoice invoiceFromStorage) { private Future validateBudgetsStatus(Invoice invoice, List lines, RequestContext requestContext) { List dataHolders = holderBuilder.buildHoldersSkeleton(lines, invoice); return holderBuilder.withBudgets(dataHolders, requestContext) - .onFailure(t -> logger.error("Could not find an active budget for the invoice with id {}", invoice.getId(), t)) + .onFailure(t -> logger.error("validateBudgetsStatus:: Could not find an active budget for the invoice with id {}", + invoice.getId(), t)) .mapEmpty(); } @@ -131,16 +153,17 @@ private Future> getTransactions(String invoiceId, RequestConte return baseTransactionService.getTransactions(query, 0, Integer.MAX_VALUE, requestContext) .map(TransactionCollection::getTransactions) .map(transactions -> transactions.stream() - .filter(tr -> relevantTransactionTypes.contains(tr.getTransactionType())).collect(toList())); + .filter(tr -> relevantTransactionTypes.contains(tr.getTransactionType())).toList()); } - private Future cancelTransactions(String invoiceId, List transactions, - RequestContext requestContext) { - if (transactions.isEmpty()) + private Future cancelTransactions(String invoiceId, List transactions, RequestContext requestContext) { + if (transactions.isEmpty()) { return succeededFuture(null); + } + logger.info("cancelTransactions:: Cancelling invoice transactions, invoiceId={}...", invoiceId); return baseTransactionService.batchCancel(transactions, requestContext) .recover(t -> { - logger.error("Failed to cancel transactions for invoice with id {}", invoiceId, t); + logger.error("cancelTransactions:: Failed to cancel transactions for invoice with id {}", invoiceId, t); var param = new Parameter().withKey(INVOICE_ID).withValue(invoiceId); var causeParam = new Parameter().withKey("cause").withValue(t.getMessage()); throw new HttpException(500, CANCEL_TRANSACTIONS_ERROR, List.of(param, causeParam)); @@ -152,6 +175,7 @@ private void cancelInvoiceLines(List lines) { } private Future cancelVoucher(String invoiceId, RequestContext requestContext) { + logger.info("cancelVoucher:: Cancelling voucher, invoiceId={}...", invoiceId); return voucherService.cancelInvoiceVoucher(invoiceId, requestContext); } @@ -161,30 +185,26 @@ private Future unreleaseEncumbrances(List invoiceLines, Invoi .filter(InvoiceLine::getReleaseEncumbrance) .map(InvoiceLine::getPoLineId) .distinct() - .collect(toList()); - if (poLineIds.isEmpty()) + .toList(); + if (poLineIds.isEmpty()) { return succeededFuture(); - List>> futureList = StreamEx - .ofSubLists(poLineIds, MAX_IDS_FOR_GET_RQ) - .map(this::queryToGetPoLinesWithRightPaymentStatusByIds) - .map(query -> orderLineService.getPoLines(query, requestContext)) - .collect(toList()); - - Future> poLinesFuture = collectResultsOnSuccess(futureList) - .map(col -> col.stream().flatMap(List::stream).collect(toList())); - - return poLinesFuture.compose(poLines -> selectPoLinesWithOpenOrders(poLines, requestContext)) + } + String invoiceId = invoiceFromStorage.getId(); + logger.info("unreleaseEncumbrances:: Unreleasing encumbrances, invoiceId={}...", invoiceId); + return getPoLinesByIdAndQuery(poLineIds, this::queryToGetPoLinesWithRightPaymentStatusByIds, requestContext) + .compose(poLines -> selectPoLinesWithOpenOrders(poLines, requestContext)) .compose(poLines -> unreleaseEncumbrancesForPoLines(poLines, invoiceFromStorage, requestContext)) .recover(t -> { - logger.error("Failed to unrelease encumbrance for po lines", t); - var param = new Parameter().withKey("cause").withValue(requireNonNullElse(t.getCause(), t).toString()); - var error = ERROR_UNRELEASING_ENCUMBRANCES.toError().withParameters(List.of(param)); + logger.error("unreleaseEncumbrances:: Failed to unrelease encumbrance for po lines, invoiceId={}", invoiceId, t); + var causeParam = new Parameter().withKey("cause").withValue(requireNonNullElse(t.getCause(), t).toString()); + var invoiceIdParam = new Parameter().withKey("invoiceId").withValue(invoiceId); + var error = ERROR_UNRELEASING_ENCUMBRANCES.toError().withParameters(List.of(causeParam, invoiceIdParam)); throw new HttpException(500, error); }); } private String queryToGetPoLinesWithRightPaymentStatusByIds(List poLineIds) { - return PO_LINES_WITH_RIGHT_PAYMENT_STATUS_QUERY + " AND " + convertIdsToCqlQuery(poLineIds); + return PO_LINES_WITH_RIGHT_PAYMENT_STATUS_QUERY + AND + convertIdsToCqlQuery(poLineIds); } private Future> selectPoLinesWithOpenOrders(List poLines, RequestContext requestContext) { @@ -193,25 +213,25 @@ private Future> selectPoLinesWithOpenOrders(List poLines, R List orderIds = poLines.stream() .map(PoLine::getPurchaseOrderId) .distinct() - .collect(toList()); + .toList(); return orderService.getOrders(queryToGetOpenOrdersByIds(orderIds), requestContext) .map(orders -> { List openOrderIds = orders.stream().map(PurchaseOrder::getId).toList(); return poLines.stream() .filter(poLine -> openOrderIds.contains(poLine.getPurchaseOrderId())) - .collect(toList()); + .toList(); }); } private String queryToGetOpenOrdersByIds(List orderIds) { - return OPEN_ORDERS_QUERY + " AND " + convertIdsToCqlQuery(orderIds); + return OPEN_ORDERS_QUERY + AND + convertIdsToCqlQuery(orderIds); } private Future unreleaseEncumbrancesForPoLines(List poLines, Invoice invoiceFromStorage, RequestContext requestContext) { if (poLines.isEmpty()) return succeededFuture(null); - List poLineIds = poLines.stream().map(PoLine::getId).collect(toList()); + List poLineIds = poLines.stream().map(PoLine::getId).toList(); String fiscalYearId = invoiceFromStorage.getFiscalYearId(); return encumbranceService.getEncumbrancesByPoLineIds(poLineIds, fiscalYearId, requestContext) .map(transactions -> transactions.stream() @@ -224,4 +244,122 @@ private Future unreleaseEncumbrancesForPoLines(List poLines, Invoi }); } + private Future updatePoLinePaymentStatus(Invoice invoiceFromStorage, List invoiceLines, + RequestContext requestContext) { + String invoiceId = invoiceFromStorage.getId(); + if (!Invoice.Status.PAID.equals(invoiceFromStorage.getStatus()) || invoiceFromStorage.getFiscalYearId() == null) { + // in the unlikely case the fiscal year is undefined, the payment status update is skipped + // (the MODINVOSTO-177 script should fill it up for pre-Poppy invoices) + logger.info("updatePoLinePaymentStatus:: The invoice was not paid or the fiscal year is unknown; " + + "skipping po line paymentStatus update. invoiceId={}", invoiceId); + return succeededFuture(); + } + List allPoLineIds = invoiceLines.stream() + .map(InvoiceLine::getPoLineId) + .filter(Objects::nonNull) + .distinct() + .toList(); + if (allPoLineIds.isEmpty()) { + return succeededFuture(); + } + logger.info("updatePoLinePaymentStatus:: Retrieving linked po lines that might need a paymentStatus update, " + + "invoiceId={}...", invoiceId); + return getPoLinesByIdAndQuery(allPoLineIds, this::queryToGetPoLinesWithFullyOrPartiallyPaidPaymentStatusByIds, requestContext) + .compose(filteredPoLines -> { + if (filteredPoLines.isEmpty()) { + logger.info("updatePoLinePaymentStatus:: No matching po line, no paymentStatus update needed, invoiceId={}", + invoiceId); + return succeededFuture(); + } + logger.info("updatePoLinePaymentStatus:: There are po lines linked to the invoice that might need a paymentStatus update;" + + " retrieving paid invoice lines linked to the po lines..."); + List filteredPoLineIds = filteredPoLines.stream().map(PoLine::getId).toList(); + return getInvoiceLinesByPoLineIdsAndQuery(filteredPoLineIds, + ids -> queryToGetRelatedPaidInvoiceLinesByPoLineIds(invoiceId, ids), requestContext) + .compose(relatedInvoiceLines -> { + if (relatedInvoiceLines.isEmpty()) { + logger.info("updatePoLinePaymentStatus:: No linked paid invoice line; setting paymentStatus to Awaiting Payment..."); + return updateRelatedPoLines(relatedInvoiceLines, filteredPoLines, emptyList(), invoiceId, requestContext); + } + logger.info("updatePoLinePaymentStatus:: There are linked paid invoice lines; " + + "retrieving the related invoices to select only invoices with the same fiscal year as the cancelled invoice..."); + List relatedInvoiceIds = relatedInvoiceLines.stream().map(InvoiceLine::getInvoiceId).distinct().toList(); + String invoiceQuery = "status==Paid AND fiscalYearId==" + invoiceFromStorage.getFiscalYearId(); + return getInvoicesByIdsAndQuery(relatedInvoiceIds, invoiceQuery, requestContext) + .compose(relatedInvoices -> updateRelatedPoLines(relatedInvoiceLines, filteredPoLines, relatedInvoices, + invoiceId, requestContext)); + }); + }); + } + + private String queryToGetPoLinesWithFullyOrPartiallyPaidPaymentStatusByIds(List poLineIds) { + return PAYMENT_STATUS_PAID_QUERY + AND + convertIdsToCqlQuery(poLineIds); + } + + private String queryToGetRelatedPaidInvoiceLinesByPoLineIds(String invoiceId, List poLineIds) { + return "invoiceId<>" + invoiceId + " AND invoiceLineStatus==(\"Paid\") AND " + + convertIdsToCqlQuery(poLineIds, "poLineId", true); + } + + private Future> getPoLinesByIdAndQuery(List poLineIds, Function, String> queryFunction, + RequestContext requestContext) { + List>> futureList = StreamEx + .ofSubLists(poLineIds, MAX_IDS_FOR_GET_RQ) + .map(queryFunction) + .map(query -> orderLineService.getPoLines(query, requestContext)) + .toList(); + + return collectResultsOnSuccess(futureList) + .map(col -> col.stream().flatMap(List::stream).toList()); + } + + private Future> getInvoiceLinesByPoLineIdsAndQuery(List poLineIds, + Function, String> queryFunction, RequestContext requestContext) { + List>> futureList = StreamEx + .ofSubLists(poLineIds, MAX_IDS_FOR_GET_RQ) + .map(queryFunction) + .map(query -> invoiceLineService.getInvoiceLinesByQuery(query, requestContext)) + .toList(); + + return collectResultsOnSuccess(futureList) + .map(col -> col.stream().flatMap(List::stream).toList()); + } + + private Future> getInvoicesByIdsAndQuery(List invoiceIds, String query, RequestContext requestContext) { + String query2 = "(" + query + ") AND " + convertIdsToCqlQuery(invoiceIds); + return invoiceService.getInvoices(query2, 0, Integer.MAX_VALUE, requestContext) + .map(InvoiceCollection::getInvoices); + } + + private Future updateRelatedPoLines(List allRelatedInvoiceLines, List filteredPoLines, + List relatedInvoices, String invoiceId, RequestContext requestContext) { + List relatedInvoiceIds = relatedInvoices.stream().map(Invoice::getId).toList(); + List filteredInvoiceLines = allRelatedInvoiceLines.stream() + .filter(invoiceLine -> relatedInvoiceIds.contains(invoiceLine.getInvoiceId())) + .toList(); + Map> poLineIdToInvoiceLines = filteredInvoiceLines.stream() + .collect(groupingBy(InvoiceLine::getPoLineId)); + List modifiedPoLines = new ArrayList<>(); + filteredPoLines.forEach(poLine -> { + List relatedInvoiceLines = poLineIdToInvoiceLines.get(poLine.getId()); + if (relatedInvoiceLines == null || relatedInvoiceLines.isEmpty()) { + poLine.setPaymentStatus(AWAITING_PAYMENT); + modifiedPoLines.add(poLine); + } else if (relatedInvoiceLines.stream().noneMatch(InvoiceLine::getReleaseEncumbrance) && + !PARTIALLY_PAID.equals(poLine.getPaymentStatus())) { + poLine.setPaymentStatus(PARTIALLY_PAID); + modifiedPoLines.add(poLine); + } + }); + if (modifiedPoLines.isEmpty()) { + logger.info("updateRelatedPoLines:: No po line paymentStatus update needed, invoiceId={}", invoiceId); + return succeededFuture(); + } + logger.info("updateRelatedPoLines:: {} po lines need a paymentStatus update for invoice {}. Updating...", + modifiedPoLines.size(), invoiceId); + List compositePoLines = modifiedPoLines.stream() + .map(poLine -> JsonObject.mapFrom(poLine).mapTo(CompositePoLine.class)) + .toList(); + return orderLineService.updateCompositePoLines(compositePoLines, requestContext); + } } diff --git a/src/main/java/org/folio/services/invoice/InvoiceLineService.java b/src/main/java/org/folio/services/invoice/InvoiceLineService.java index 866b9e27c..b6a6f108c 100644 --- a/src/main/java/org/folio/services/invoice/InvoiceLineService.java +++ b/src/main/java/org/folio/services/invoice/InvoiceLineService.java @@ -67,6 +67,15 @@ public Future getInvoiceLines(RequestEntry requestEntry, return restClient.get(requestEntry.buildEndpoint(), InvoiceLineCollection.class, requestContext); } + public Future> getInvoiceLinesByQuery(String query, RequestContext requestContext) { + RequestEntry requestEntry = new RequestEntry(INVOICE_LINES_ENDPOINT) + .withQuery(query) + .withLimit(Integer.MAX_VALUE) + .withOffset(0); + return restClient.get(requestEntry, InvoiceLineCollection.class, requestContext) + .map(InvoiceLineCollection::getInvoiceLines); + } + public Future> getInvoiceLinesRelatedForOrder(List orderPoLineIds, String invoiceId, RequestContext requestContext) { return getInvoiceLinesByInvoiceId(invoiceId, requestContext) .map(invoiceLines -> invoiceLines.getInvoiceLines().stream() diff --git a/src/test/java/org/folio/services/invoice/InvoiceCancelServiceTest.java b/src/test/java/org/folio/services/invoice/InvoiceCancelServiceTest.java index 15381f2d9..93a7608d1 100644 --- a/src/test/java/org/folio/services/invoice/InvoiceCancelServiceTest.java +++ b/src/test/java/org/folio/services/invoice/InvoiceCancelServiceTest.java @@ -23,6 +23,7 @@ import static org.folio.rest.acq.model.finance.Encumbrance.Status.PENDING; import static org.folio.rest.acq.model.finance.Encumbrance.Status.RELEASED; import static org.folio.rest.acq.model.finance.Encumbrance.Status.UNRELEASED; +import static org.folio.rest.acq.model.orders.PoLine.PaymentStatus.FULLY_PAID; import static org.folio.rest.impl.ApiTestBase.X_OKAPI_TOKEN; import static org.folio.rest.impl.ApiTestBase.X_OKAPI_USER_ID; import static org.folio.services.finance.transaction.BaseTransactionServiceTest.X_OKAPI_TENANT; @@ -30,11 +31,14 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Paths; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -49,6 +53,7 @@ import org.folio.rest.acq.model.finance.BudgetCollection; import org.folio.rest.acq.model.finance.TransactionCollection; import org.folio.rest.acq.model.finance.Transaction.TransactionType; +import org.folio.rest.acq.model.orders.CompositePoLine; import org.folio.rest.acq.model.orders.PoLine; import org.folio.rest.acq.model.orders.PoLineCollection; import org.folio.rest.acq.model.orders.PurchaseOrder; @@ -58,6 +63,7 @@ import org.folio.rest.core.models.RequestContext; import org.folio.rest.core.models.RequestEntry; import org.folio.rest.jaxrs.model.Invoice; +import org.folio.rest.jaxrs.model.InvoiceCollection; import org.folio.rest.jaxrs.model.InvoiceLine; import org.folio.rest.jaxrs.model.InvoiceLineCollection; import org.folio.rest.jaxrs.model.Voucher; @@ -74,6 +80,7 @@ import org.folio.services.order.OrderService; import org.folio.services.validator.VoucherValidator; import org.folio.services.voucher.VoucherService; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -101,6 +108,7 @@ public class InvoiceCancelServiceTest { private static final String VOUCHER_SAMPLE_PATH = VOUCHER_MOCK_DATA_PATH + EXISTING_VOUCHER_ID + ".json"; private InvoiceCancelService cancelService; + private AutoCloseable mockitoMocks; @Mock private RestClient restClient; @@ -108,7 +116,7 @@ public class InvoiceCancelServiceTest { @BeforeEach public void initMocks() { - MockitoAnnotations.openMocks(this); + mockitoMocks = MockitoAnnotations.openMocks(this); Map okapiHeaders = new HashMap<>(); okapiHeaders.put(OKAPI_URL, "http://localhost:" + mockPort); @@ -123,6 +131,7 @@ public void initMocks() { OrderLineService orderLineService = new OrderLineService(restClient); InvoiceLineService invoiceLineService = new InvoiceLineService(restClient); OrderService orderService = new OrderService(restClient, invoiceLineService, orderLineService); + BaseInvoiceService baseInvoiceService = new BaseInvoiceService(restClient, invoiceLineService, orderService); ExchangeRateProviderResolver exchangeRateProviderResolver = new ExchangeRateProviderResolver(); FiscalYearService fiscalYearService = new FiscalYearService(restClient); @@ -134,7 +143,12 @@ public void initMocks() { fiscalYearService, fundService, ledgerService, baseTransactionService, budgetService, expenseClassRetrieveService); cancelService = new InvoiceCancelService(baseTransactionService, encumbranceService, - voucherService, orderLineService, orderService, holderBuilder); + voucherService, orderLineService, orderService, invoiceLineService, baseInvoiceService, holderBuilder); + } + + @AfterEach + public void afterEach() throws Exception { + mockitoMocks.close(); } @Test @@ -231,6 +245,29 @@ public void errorUnreleasingEncumbrances(VertxTestContext vertxTestContext) thro }); } + @Test + public void updatePaymentStatusTest(VertxTestContext vertxTestContext) throws IOException { + Invoice invoice = getMockAs(PAID_INVOICE_SAMPLE_PATH, Invoice.class); + invoice.setFiscalYearId("0fc631d6-45fb-494d-8b48-321f7a2fccf6"); + List invoiceLines = List.of( + new InvoiceLine().withId("1077f1a2-9c4d-4ff0-9152-adb5646d2107").withPoLineId("f9c7a38d-3f8e-4758-b303-a12d365fbf57"), + new InvoiceLine().withId("736a8649-1f7b-45cb-92d9-8aa83a169e9f").withPoLineId("0818c22e-d469-431a-9277-b6849a39e523"), + new InvoiceLine().withId("e6666187-e626-432d-87bc-a4bd4b697679").withPoLineId("a36d30c0-6031-4ae8-889b-9408d98b7ee4") + ); + + setupRestCalls(invoice, false, false); + setupPaymentStatusCalls(invoiceLines); + + Future future = cancelService.cancelInvoice(invoice, invoiceLines, requestContext); + vertxTestContext.assertComplete(future) + .onSuccess(result -> { + verify(restClient, times(2)).put(any(RequestEntry.class), any(CompositePoLine.class), + eq(requestContext)); + vertxTestContext.completeNow(); + }) + .onFailure(vertxTestContext::failNow); + } + private void setupRestCalls(Invoice invoice) throws IOException { setupRestCalls(invoice, false, false); } @@ -385,6 +422,78 @@ private void setupEncumbrancePutWithError(Transaction transaction) { .postEmptyResponse(eq(endpoint), eq(updateBatch), eq(requestContext)); } + private void setupInvoiceLineQuery(List invoiceLines) { + InvoiceLineCollection invoiceLineCollection = new InvoiceLineCollection() + .withInvoiceLines(invoiceLines) + .withTotalRecords(invoiceLines.size()); + doReturn(succeededFuture(invoiceLineCollection)) + .when(restClient).get(any(RequestEntry.class), eq(InvoiceLineCollection.class), eq(requestContext)); + } + + private void setupInvoiceQuery(List invoices) { + InvoiceCollection invoiceCollection = new InvoiceCollection() + .withInvoices(invoices) + .withTotalRecords(invoices.size()); + doReturn(succeededFuture(invoiceCollection)) + .when(restClient).get(any(RequestEntry.class), eq(InvoiceCollection.class), eq(requestContext)); + } + + private void setupUpdatePoLinesQuery(List expectedPoLines) { + expectedPoLines.forEach(expectedPoLine -> + doReturn(succeededFuture(null)) + .when(restClient).put(any(RequestEntry.class), eq(expectedPoLine), eq(requestContext)) + ); + } + + private void setupPaymentStatusCalls(List invoiceLines) { + List poLines = new ArrayList<>(); + for (InvoiceLine invoiceLine : invoiceLines) { + poLines.add(new PoLine() + .withId(invoiceLine.getPoLineId()) + .withPaymentStatus(FULLY_PAID)); + } + List relatedInvoices = List.of( + new Invoice().withId("52a6cd6f-33dc-41ac-8bb3-cf14c8fabd40"), + new Invoice().withId("6c39878d-fd46-4083-a287-1a70b4f06828") + ); + List relatedInvoiceLines = List.of( + // invoice line linked to a filtered invoice, no invoice line linked to the same po line has releaseEncumbrance=true, + // the po line will be set to PARTIALLY_PAID + new InvoiceLine() + .withId("846ef0f9-1eab-4a4d-8329-9a717994ea72") + .withInvoiceId(relatedInvoices.get(0).getId()) + .withPoLineId(poLines.get(0).getId()) + .withReleaseEncumbrance(false), + // invoice line linked to a filtered invoice, no invoice line linked to the same po line has releaseEncumbrance=true, + // the po line will be set to PARTIALLY_PAID + new InvoiceLine() + .withId("8e2d8bb6-de5b-4ed0-bb10-983e31ec7711") + .withInvoiceId(relatedInvoices.get(1).getId()) + .withPoLineId(poLines.get(0).getId()) + .withReleaseEncumbrance(false), + // invoice line linked to a filtered invoice with releaseEncumbrance=true, the po line will not be changed + new InvoiceLine() + .withId("c63f293d-077d-4f00-9d5d-a3160da975db") + .withInvoiceId(relatedInvoices.get(1).getId()) + .withPoLineId(poLines.get(1).getId()) + .withReleaseEncumbrance(true), + // invoice line not linked to a filtered invoice, the po line will be set to AWAITING_PAYMENT + new InvoiceLine() + .withId("f384585f-fc57-4f96-bca4-292e0e080a88") + .withInvoiceId("89910180-9828-4408-825d-8f70af3ae14d") + .withPoLineId(poLines.get(2).getId()) + .withReleaseEncumbrance(false) + ); + setupPoLineQuery(poLines); + setupInvoiceLineQuery(relatedInvoiceLines); + setupInvoiceQuery(relatedInvoices); + CompositePoLine expectedPoLine1 = JsonObject.mapFrom(poLines.get(0)).mapTo(CompositePoLine.class) + .withPaymentStatus(CompositePoLine.PaymentStatus.PARTIALLY_PAID); + CompositePoLine expectedPoLine2 = JsonObject.mapFrom(poLines.get(2)).mapTo(CompositePoLine.class) + .withPaymentStatus(CompositePoLine.PaymentStatus.AWAITING_PAYMENT); + setupUpdatePoLinesQuery(List.of(expectedPoLine1, expectedPoLine2)); + } + private T getMockAs(String path, Class c) throws IOException { String contents = new String(Files.readAllBytes(Paths.get(RESOURCES_PATH, path))); return new JsonObject(contents).mapTo(c);