From 4ababdf905dc639c6412c9d41600d5d2a0f686b8 Mon Sep 17 00:00:00 2001 From: Boburbek Kadirkhodjaev Date: Fri, 11 Oct 2024 19:49:29 +0500 Subject: [PATCH] [MODINVOICE-554]. Invoices app: Incorrect formula for calculating adjustments, that are included and pro-rated by amount (#509) * [MODINVOICE-554]. Invoices app: Incorrect formula for calculating adjustments, that are included and pro-rated by amount * [MODINVOICE-554]. Refactor methods, add tests --- .../adjusment/AdjustmentsService.java | 53 +++++++-- .../InvoiceLinesProratedAdjustmentsTest.java | 81 +++++++++++++ .../impl/InvoicesProratedAdjustmentsTest.java | 107 ++++++++++++++++++ 3 files changed, 231 insertions(+), 10 deletions(-) diff --git a/src/main/java/org/folio/services/adjusment/AdjustmentsService.java b/src/main/java/org/folio/services/adjusment/AdjustmentsService.java index 8a30538f2..2d6f94d14 100644 --- a/src/main/java/org/folio/services/adjusment/AdjustmentsService.java +++ b/src/main/java/org/folio/services/adjusment/AdjustmentsService.java @@ -31,6 +31,7 @@ import io.vertx.core.json.JsonObject; public class AdjustmentsService { + private final Logger logger = LogManager.getLogger(this.getClass()); public static final Predicate NOT_PRORATED_ADJUSTMENTS_PREDICATE = adj -> adj.getProrate() == NOT_PRORATED; public static final Predicate PRORATED_ADJUSTMENTS_PREDICATE = NOT_PRORATED_ADJUSTMENTS_PREDICATE.negate(); @@ -65,7 +66,7 @@ public List applyProratedAdjustments(List lines, Invoi updatedLines.addAll(applyProratedAdjustmentByLines(adjustment, lines, currencyUnit)); break; case BY_AMOUNT: - updatedLines.addAll(applyProratedAdjustmentByAmount(adjustment, lines, currencyUnit)); + updatedLines.addAll(applyAdjustmentsAndUpdateLines(adjustment, lines, currencyUnit)); break; case BY_QUANTITY: updatedLines.addAll(applyProratedAdjustmentByQuantity(adjustment, lines, currencyUnit)); @@ -80,6 +81,15 @@ public List applyProratedAdjustments(List lines, Invoi .collect(toList()); } + private List applyAdjustmentsAndUpdateLines(Adjustment adjustment, List lines, + CurrencyUnit currencyUnit) { + if (adjustment.getRelationToTotal() == Adjustment.RelationToTotal.INCLUDED_IN) { + return applyProratedAmountTypeIncludedInAdjustments(adjustment, lines, currencyUnit); + } else { + return applyProratedAdjustmentByAmount(adjustment, lines, currencyUnit); + } + } + public void processProratedAdjustments(List lines, Invoice invoice) { List proratedAdjustments = getProratedAdjustments(invoice); @@ -88,7 +98,6 @@ public void processProratedAdjustments(List lines, Invoice invoice) // Apply prorated adjustments to each invoice line applyProratedAdjustments(lines, invoice); - } /** @@ -99,10 +108,10 @@ public void processProratedAdjustments(List lines, Invoice invoice) void filterDeletedAdjustments(List proratedAdjustments, List invoiceLines) { List adjIds = proratedAdjustments.stream() .map(Adjustment::getId) - .collect(toList()); + .toList(); invoiceLines.forEach(line -> line.getAdjustments() - .removeIf(adj -> Objects.nonNull(adj.getAdjustmentId()) && !adjIds.contains(adj.getAdjustmentId()))); + .removeIf(adj -> Objects.nonNull(adj.getAdjustmentId()) && !adjIds.contains(adj.getAdjustmentId()))); } /** @@ -179,7 +188,7 @@ private List applyAmountTypeProratedAdjustments(Adjustment adjustme int remainderSignum = remainder.signum(); MonetaryAmount smallestUnit = getSmallestUnit(expectedAdjustmentTotal, remainderSignum); - for (ListIterator iterator = getIterator(lines, remainderSignum); isIteratorHasNext(iterator, remainderSignum);) { + for (ListIterator iterator = getIterator(lines, remainderSignum); isIteratorHasNext(iterator, remainderSignum); ) { final InvoiceLine line = iteratorNext(iterator, remainderSignum); MonetaryAmount amount = lineIdAdjustmentValueMap.get(line.getId()); @@ -190,8 +199,7 @@ private List applyAmountTypeProratedAdjustments(Adjustment adjustme } Adjustment proratedAdjustment = prepareAdjustmentForLine(adjustment); - proratedAdjustment.setValue(amount.getNumber() - .doubleValue()); + proratedAdjustment.setValue(amount.getNumber().doubleValue()); if (addAdjustmentToLine(line, proratedAdjustment)) { updatedLines.add(line); } @@ -200,13 +208,40 @@ private List applyAmountTypeProratedAdjustments(Adjustment adjustme return updatedLines; } + private List applyProratedAmountTypeIncludedInAdjustments(Adjustment adjustment, List lines, + CurrencyUnit currencyUnit) { + List updatedLines = new ArrayList<>(); + for (InvoiceLine line : lines) { + if (invoiceLineWasAdjustedById(adjustment, line)) { + continue; + } + MonetaryAmount lineSubtotal = Money.of(line.getSubTotal(), currencyUnit); + MonetaryAmount amountAdjustmentValue = lineSubtotal.multiply(adjustment.getValue()) + .divide(Money.of(100, currencyUnit).add(Money.of(adjustment.getValue(), currencyUnit)).getNumber().doubleValue()) + .with(Monetary.getDefaultRounding()); + Adjustment preparedAdjustment = prepareAdjustmentForLine(adjustment.withType(Adjustment.Type.AMOUNT)) + .withValue(amountAdjustmentValue.getNumber().doubleValue()); + line.withSubTotal(lineSubtotal.subtract(amountAdjustmentValue).getNumber().doubleValue()); + if (addAdjustmentToLine(line, preparedAdjustment)) { + updatedLines.add(line); + } + } + return updatedLines; + } + + private static boolean invoiceLineWasAdjustedById(Adjustment adjustment, InvoiceLine line) { + return Objects.nonNull(adjustment.getId()) && line.getAdjustments().stream() + .map(Adjustment::getAdjustmentId) + .filter(Objects::nonNull) + .anyMatch(lineAdjustmentId -> lineAdjustmentId.equals(adjustment.getId())); + } + /** * Each invoiceLine gets a portion of the amount proportionate to the invoiceLine's contribution to the invoice subTotal. * Prorated percentage adjustments of this type aren't split but rather each invoiceLine gets an adjustment of that percentage */ private List applyProratedAdjustmentByAmount(Adjustment adjustment, List lines, CurrencyUnit currencyUnit) { - if (adjustment.getType() == Adjustment.Type.PERCENTAGE) { adjustment = convertToAmountAdjustment(adjustment, lines, currencyUnit); } @@ -232,7 +267,6 @@ private BiFunction prorateByAmountF */ private List applyProratedAdjustmentByQuantity(Adjustment adjustment, List lines, CurrencyUnit currencyUnit) { - if (adjustment.getType() == Adjustment.Type.PERCENTAGE) { return applyPercentageAdjustmentsByQuantity(adjustment, lines, currencyUnit); } @@ -285,5 +319,4 @@ private InvoiceLine iteratorNext(ListIterator iterator, int remaind private BiFunction prorateByLines(List lines) { return (amount, line) -> amount.divide(lines.size()).with(Monetary.getDefaultRounding()); } - } diff --git a/src/test/java/org/folio/rest/impl/InvoiceLinesProratedAdjustmentsTest.java b/src/test/java/org/folio/rest/impl/InvoiceLinesProratedAdjustmentsTest.java index fafaa0397..95011347a 100644 --- a/src/test/java/org/folio/rest/impl/InvoiceLinesProratedAdjustmentsTest.java +++ b/src/test/java/org/folio/rest/impl/InvoiceLinesProratedAdjustmentsTest.java @@ -11,6 +11,9 @@ import static org.folio.rest.impl.MockServer.getInvoiceLineCreations; import static org.folio.rest.impl.MockServer.getInvoiceLineUpdates; import static org.folio.rest.impl.MockServer.getInvoiceUpdates; +import static org.folio.rest.jaxrs.model.Adjustment.Prorate.BY_AMOUNT; +import static org.folio.rest.jaxrs.model.Adjustment.RelationToTotal.INCLUDED_IN; +import static org.folio.rest.jaxrs.model.Adjustment.Type.PERCENTAGE; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.is; @@ -19,6 +22,7 @@ import io.vertx.junit5.VertxExtension; import java.util.Collections; +import java.util.UUID; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -27,6 +31,7 @@ import org.folio.rest.jaxrs.model.InvoiceLine; import org.folio.rest.jaxrs.model.InvoiceLineCollection; import org.hamcrest.Matchers; +import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; @@ -182,6 +187,82 @@ public void testDeleteLineForInvoiceWithOneAdj(Adjustment.Prorate prorate, Adjus assertThat(lineAdjustment.getValue(), is(expectedAdjValue)); } + @Test + public void testCreateInvoiceWithOnePercentageTypeByAmountProrateIncludedByTotalAdjustment() { + logger.info("=== Creating invoice with one adjustment by amount prorate included by total ==="); + + // Prepare data "from storage" + Invoice invoice = getMockAsJson(OPEN_INVOICE_SAMPLE_PATH).mapTo(Invoice.class).withId(randomUUID().toString()); + Adjustment invoiceAdjustment = new Adjustment() + .withId(UUID.randomUUID().toString()) + .withDescription("VAT") + .withProrate(BY_AMOUNT) + .withType(PERCENTAGE) + .withRelationToTotal(INCLUDED_IN) + .withValue(7d); + invoice.withAdjustments(Collections.singletonList(invoiceAdjustment)); + addMockEntry(INVOICES, invoice); + + // Prepare request body + InvoiceLine invoiceLineBody = getMockInvoiceLine(invoice.getId()).withAdjustmentsTotal(0d).withSubTotal(30d).withQuantity(1); + + // Send create request + InvoiceLine invoiceLine = verifySuccessPost(INVOICE_LINES_PATH, invoiceLineBody).as(InvoiceLine.class); + + // Verification + assertThat(getInvoiceLineUpdates(), Matchers.hasSize(0)); + assertThat(getInvoiceUpdates(), Matchers.hasSize(1)); + compareRecordWithSentToStorage(invoiceLine); + + assertThat(invoiceLine.getAdjustments(), hasSize(1)); + assertThat(invoiceLine.getAdjustmentsTotal(), is(0d)); + assertThat(invoiceLine.getSubTotal(), is(28.04d)); + + Adjustment lineAdjustment = invoiceLine.getAdjustments().get(0); + verifyInvoiceLineAdjustmentCommon(invoiceAdjustment, lineAdjustment); + assertThat(lineAdjustment.getValue(), is(1.96d)); + } + + @Test + public void testDeleteInvoiceWithOnePercentageTypeByAmountProrateIncludedByTotalAdjustment() { + logger.info("=== Deleting invoice with one adjustment by amount prorate included by total ==="); + + // Prepare data "from storage" + Invoice invoice = getMockAsJson(OPEN_INVOICE_SAMPLE_PATH).mapTo(Invoice.class).withId(randomUUID().toString()); + Adjustment invoiceAdjustment = new Adjustment() + .withId(UUID.randomUUID().toString()) + .withDescription("VAT") + .withProrate(BY_AMOUNT) + .withType(PERCENTAGE) + .withRelationToTotal(INCLUDED_IN) + .withValue(7d); + invoice.withAdjustments(Collections.singletonList(invoiceAdjustment)); + addMockEntry(INVOICES, invoice); + + InvoiceLine line1 = getMockInvoiceLine(invoice.getId()).withAdjustmentsTotal(0d).withSubTotal(30d).withQuantity(1); + addMockEntry(INVOICE_LINES, line1); + InvoiceLine line2 = getMockInvoiceLine(invoice.getId()).withAdjustmentsTotal(0d).withSubTotal(30d).withQuantity(1); + addMockEntry(INVOICE_LINES, line2); + + // Send delete request + verifyDeleteResponse(String.format(INVOICE_LINE_ID_PATH, line2.getId()), "", 204); + + // Verification + assertThat(getInvoiceLineUpdates(), Matchers.hasSize(1)); + assertThat(getInvoiceUpdates(), Matchers.hasSize(1)); + + InvoiceLine lineToStorage = getLineToStorageById(line1.getId()); + assertThat(lineToStorage.getAdjustments(), hasSize(1)); + + assertThat(lineToStorage.getAdjustments(), hasSize(1)); + assertThat(lineToStorage.getAdjustmentsTotal(), is(0d)); + assertThat(lineToStorage.getSubTotal(), is(28.04)); + + Adjustment lineAdjustment = lineToStorage.getAdjustments().get(0); + verifyInvoiceLineAdjustmentCommon(invoiceAdjustment, lineAdjustment); + assertThat(lineAdjustment.getValue(), is(1.96d)); + } + private InvoiceLine getLineToStorageById(String invoiceLineId) { return getInvoiceLineUpdates().stream() .filter(line -> invoiceLineId.equals(line.getString("id"))) diff --git a/src/test/java/org/folio/rest/impl/InvoicesProratedAdjustmentsTest.java b/src/test/java/org/folio/rest/impl/InvoicesProratedAdjustmentsTest.java index 58a2a4bc9..534b81c3a 100644 --- a/src/test/java/org/folio/rest/impl/InvoicesProratedAdjustmentsTest.java +++ b/src/test/java/org/folio/rest/impl/InvoicesProratedAdjustmentsTest.java @@ -13,6 +13,7 @@ import static org.folio.rest.jaxrs.model.Adjustment.Prorate.BY_LINE; import static org.folio.rest.jaxrs.model.Adjustment.Prorate.BY_QUANTITY; import static org.folio.rest.jaxrs.model.Adjustment.Prorate.NOT_PRORATED; +import static org.folio.rest.jaxrs.model.Adjustment.RelationToTotal.INCLUDED_IN; import static org.folio.rest.jaxrs.model.Adjustment.Type.AMOUNT; import static org.folio.rest.jaxrs.model.Adjustment.Type.PERCENTAGE; import static org.hamcrest.MatcherAssert.assertThat; @@ -30,6 +31,7 @@ import java.util.Collections; import java.util.Date; import java.util.List; +import java.util.UUID; import java.util.stream.Stream; import org.apache.logging.log4j.LogManager; @@ -1015,6 +1017,111 @@ public void testUpdateInvoiceWithThreeLinesAddingPercentageAdjustmentByLines() { }); } + @Test + public void testUpdateInvoiceWithOneLinePercentageTypeByAmountProrateIncludedByTotalAdjustment() { + logger.info("=== Updating invoice with one line adding 7% adjustment by amount prorate included by total ==="); + + // Prepare data "from storage" + Invoice invoice = getMockAsJson(OPEN_INVOICE_SAMPLE_PATH).mapTo(Invoice.class).withId(randomUUID().toString()); + invoice.getAdjustments().clear(); + addMockEntry(INVOICES, invoice); + + InvoiceLine invoiceLine = getMockInvoiceLine(invoice.getId()).withAdjustmentsTotal(0d).withSubTotal(30d).withInvoiceLineNumber("n-1"); + addMockEntry(INVOICE_LINES, invoiceLine); + + // Prepare request body + Invoice invoiceBody = copyObject(invoice); + Adjustment adjustment = new Adjustment() + .withId(UUID.randomUUID().toString()) + .withDescription("VAT") + .withProrate(BY_AMOUNT) + .withType(PERCENTAGE) + .withRelationToTotal(INCLUDED_IN) + .withValue(7d); + invoiceBody.getAdjustments().add(adjustment); + + // Send update request + verifyPut(String.format(INVOICE_ID_PATH, invoice.getId()), invoiceBody, "", 204); + + // Verification + assertThat(getInvoiceUpdates(), hasSize(1)); + assertThat(getInvoiceLineUpdates(), hasSize(1)); + + Invoice invoiceToStorage = getInvoiceUpdates().get(0).mapTo(Invoice.class); + assertThat(invoiceToStorage.getAdjustments(), hasSize(1)); + assertThat(invoiceToStorage.getAdjustmentsTotal(), is(0.0)); + Adjustment invoiceAdjustment = invoiceToStorage.getAdjustments().get(0); + assertThat(invoiceAdjustment.getId(), not(is(emptyOrNullString()))); + + Stream.of(invoiceLine.getId()) + .forEach(id -> { + InvoiceLine lineToStorage = getLineToStorageById(id); + assertThat(lineToStorage.getAdjustments(), hasSize(1)); + assertThat(lineToStorage.getAdjustmentsTotal(), is(0d)); + assertThat(lineToStorage.getSubTotal(), is(28.04d)); + + Adjustment lineAdjustment = lineToStorage.getAdjustments().get(0); + verifyInvoiceLineAdjustmentCommon(invoiceAdjustment, lineAdjustment); + assertThat(lineAdjustment.getValue(), is(1.96d)); + }); + } + + @Test + public void testUpdateInvoiceWithThreeLinesPercentageTypeByAmountProrateIncludedByTotalAdjustment() { + logger.info("=== Updating invoice with three lines adding 7% adjustment by amount prorate included by total ==="); + + // Prepare data "from storage" + Invoice invoice = getMockAsJson(OPEN_INVOICE_SAMPLE_PATH).mapTo(Invoice.class).withId(randomUUID().toString()); + invoice.getAdjustments().clear(); + addMockEntry(INVOICES, invoice); + + InvoiceLine invoiceLine1 = getMockInvoiceLine(invoice.getId()).withAdjustmentsTotal(0d).withSubTotal(30d).withInvoiceLineNumber("n-1"); + addMockEntry(INVOICE_LINES, invoiceLine1); + + InvoiceLine invoiceLine2 = getMockInvoiceLine(invoice.getId()).withAdjustmentsTotal(0d).withSubTotal(30d).withInvoiceLineNumber("n-2"); + addMockEntry(INVOICE_LINES, invoiceLine2); + + InvoiceLine invoiceLine3 = getMockInvoiceLine(invoice.getId()).withAdjustmentsTotal(0d).withSubTotal(30d).withInvoiceLineNumber("n-3"); + addMockEntry(INVOICE_LINES, invoiceLine3); + + // Prepare request body + Invoice invoiceBody = copyObject(invoice); + Adjustment adjustment = new Adjustment() + .withId(UUID.randomUUID().toString()) + .withDescription("VAT") + .withProrate(BY_AMOUNT) + .withType(PERCENTAGE) + .withRelationToTotal(INCLUDED_IN) + .withValue(7d); + invoiceBody.getAdjustments().add(adjustment); + + // Send update request + verifyPut(String.format(INVOICE_ID_PATH, invoice.getId()), invoiceBody, "", 204); + + // Verification + assertThat(getInvoiceUpdates(), hasSize(1)); + assertThat(getInvoiceLineUpdates(), hasSize(3)); + + Invoice invoiceToStorage = getInvoiceUpdates().get(0).mapTo(Invoice.class); + assertThat(invoiceToStorage.getAdjustments(), hasSize(1)); + assertThat(invoiceToStorage.getAdjustmentsTotal(), is(0.0)); + Adjustment invoiceAdjustment = invoiceToStorage.getAdjustments().get(0); + assertThat(invoiceAdjustment.getId(), not(is(emptyOrNullString()))); + + Stream.of(invoiceLine1.getId(), invoiceLine2.getId(), invoiceLine3.getId()) + .forEach(id -> { + InvoiceLine lineToStorage = getLineToStorageById(id); + assertThat(lineToStorage.getAdjustments(), hasSize(1)); + assertThat(lineToStorage.getAdjustmentsTotal(), is(0d)); + assertThat(lineToStorage.getSubTotal(), is(28.04d)); + + Adjustment lineAdjustment = lineToStorage.getAdjustments().get(0); + verifyInvoiceLineAdjustmentCommon(invoiceAdjustment, lineAdjustment); + assertThat(lineAdjustment.getValue(), is(1.96d)); + }); + } + + private InvoiceLine getLineToStorageById(String invoiceLineId) { return getInvoiceLineUpdates().stream() .filter(line -> invoiceLineId.equals(line.getString("id")))