diff --git a/backend/src/main/java/ca/bc/gov/nrs/csp/backend/invoice/shared/LineAmount.java b/backend/src/main/java/ca/bc/gov/nrs/csp/backend/invoice/shared/LineAmount.java new file mode 100644 index 00000000..b323260d --- /dev/null +++ b/backend/src/main/java/ca/bc/gov/nrs/csp/backend/invoice/shared/LineAmount.java @@ -0,0 +1,53 @@ +package ca.bc.gov.nrs.csp.backend.invoice.shared; + +import ca.bc.gov.nrs.csp.backend.util.constants.ConstantsCode; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +/** + * Single source of truth for a line item's {@code $Amount} (volume × price). + * + *

The amount is never stored — {@code coastal_log_sale_detail} has no amount + * column, so every channel derives it. It therefore has to be derived the SAME + * way everywhere: the read path that feeds the invoice screen and the CSV/PDF + * exports, the inbound mapper, and the totals-variance rules. Previously each + * did its own {@code volume.multiply(price)} and only the rules applied the + * adjustment sign fix, so an ADJ line showed a positive amount on screen while + * the validator calculated a negative one. + */ +public final class LineAmount { + + private LineAmount() {} + + /** + * One line's amount, HALF_UP to 2dp, or {@code null} when either input is absent. + * + *

Adjustment invoices are the only type allowed to carry a negative volume + * or price (every other type rejects them as errors — see + * {@link ca.bc.gov.nrs.csp.backend.invoice.shared.rules.InvoiceLineRuleSet}). + * A plain multiply of two negatives yields a positive, but an adjustment's + * amount must stay negative, so the volume is flipped positive and the + * negative price carries the sign. + * + * @param volume the line's volume + * @param price the line's price + * @param invoiceType the parent invoice's type code, e.g. {@code ADJ} + * @return the signed amount rounded to 2dp, or null if volume or price is null + */ + public static BigDecimal compute(BigDecimal volume, BigDecimal price, String invoiceType) { + if (volume == null || price == null) { + return null; + } + BigDecimal effectiveVolume = volume; + if (isAdjustment(invoiceType) && volume.signum() < 0 && price.signum() < 0) { + effectiveVolume = volume.abs(); + } + return effectiveVolume.multiply(price).setScale(2, RoundingMode.HALF_UP); + } + + /** Whether the given invoice type code is an adjustment. */ + public static boolean isAdjustment(String invoiceType) { + return ConstantsCode.INVTYPE_ADJUST.equals(invoiceType); + } +} diff --git a/backend/src/main/java/ca/bc/gov/nrs/csp/backend/invoice/shared/rules/InvoiceTotalsRuleSet.java b/backend/src/main/java/ca/bc/gov/nrs/csp/backend/invoice/shared/rules/InvoiceTotalsRuleSet.java index 7c80621e..f6e38f3a 100644 --- a/backend/src/main/java/ca/bc/gov/nrs/csp/backend/invoice/shared/rules/InvoiceTotalsRuleSet.java +++ b/backend/src/main/java/ca/bc/gov/nrs/csp/backend/invoice/shared/rules/InvoiceTotalsRuleSet.java @@ -1,5 +1,6 @@ package ca.bc.gov.nrs.csp.backend.invoice.shared.rules; +import ca.bc.gov.nrs.csp.backend.invoice.shared.LineAmount; import ca.bc.gov.nrs.csp.backend.invoice.shared.model.Finding; import ca.bc.gov.nrs.csp.backend.invoice.shared.model.InvoiceTotals; import ca.bc.gov.nrs.csp.backend.invoice.shared.model.Severity; @@ -116,16 +117,13 @@ private static BigDecimal calculatedTotalAmount(InvoiceTotals t) { return total.setScale(2, RoundingMode.HALF_UP); } - /** One line's contribution to the total amount, or null if volume or price is absent. */ + /** + * One line's contribution to the total amount, or null if volume or price is absent. + * Delegates to {@link LineAmount} so the screen, the exports and this variance + * calculation can never disagree about an ADJ line's sign. + */ private static BigDecimal lineAmount(BigDecimal volume, BigDecimal price, boolean adjustment) { - if (volume == null || price == null) { - return null; - } - // A default multiply of two negatives yields a positive; for ADJ the amount must stay negative. - if (adjustment && volume.signum() < 0 && price.signum() < 0) { - volume = volume.abs(); - } - return volume.multiply(price).setScale(2, RoundingMode.HALF_UP); + return LineAmount.compute(volume, price, adjustment ? ConstantsCode.INVTYPE_ADJUST : null); } /** Calculated total volume = Σ volume over the line items; lines missing a volume contribute nothing. */ diff --git a/backend/src/main/java/ca/bc/gov/nrs/csp/backend/repository/InvoiceRepository.java b/backend/src/main/java/ca/bc/gov/nrs/csp/backend/repository/InvoiceRepository.java index f394ac78..9e1def9e 100644 --- a/backend/src/main/java/ca/bc/gov/nrs/csp/backend/repository/InvoiceRepository.java +++ b/backend/src/main/java/ca/bc/gov/nrs/csp/backend/repository/InvoiceRepository.java @@ -13,6 +13,7 @@ import java.sql.SQLException; import java.sql.Types; import java.time.LocalDate; +import java.util.ArrayList; import java.util.List; import java.util.Optional; @@ -601,14 +602,18 @@ public int countByCspSubmissionIdAndStatus(Long submissionId, String statusCode) * in {@code csvInvoiceNumbers}, resolving each number to its coastal_log_sale_id via the * submitter's client. The validator runs the same lookup, so any number reaching this * method should already exist for the submitter. + * + * @return the coastal_log_sale_ids the numbers resolved to (unresolved numbers are + * omitted), so callers can act on the related invoices — e.g. cancelling the + * invoices a replacement supersedes. */ - public void replaceRelatedInvoices(Long parentId, String refTypeCode, String csvInvoiceNumbers, + public List replaceRelatedInvoices(Long parentId, String refTypeCode, String csvInvoiceNumbers, String submitterClientNum, String submitterLocnCode, String userId) { jdbc.update("DELETE FROM THE.coastal_log_sale_rltd_invc" + " WHERE coastal_log_sale_id = :id AND csp_invoice_ref_type_code = :type", new MapSqlParameterSource().addValue("id", parentId).addValue("type", refTypeCode)); - if (csvInvoiceNumbers == null || csvInvoiceNumbers.isBlank()) return; + if (csvInvoiceNumbers == null || csvInvoiceNumbers.isBlank()) return List.of(); String resolveSql = """ SELECT cls.coastal_log_sale_id @@ -632,6 +637,7 @@ public void replaceRelatedInvoices(Long parentId, String refTypeCode, String csv ) """; + List relatedIds = new ArrayList<>(); for (String invNo : csvInvoiceNumbers.split(",")) { String trimmed = invNo.trim(); if (trimmed.isEmpty()) continue; @@ -648,7 +654,9 @@ public void replaceRelatedInvoices(Long parentId, String refTypeCode, String csv .addValue("relatedId", ids.get(0)) .addValue("refTypeCode", refTypeCode) .addValue("userId", userId)); + relatedIds.add(ids.get(0)); } + return relatedIds; } public void replaceLogSources(Long invoiceId, String logSourceCode, List values, String userId) { diff --git a/backend/src/main/java/ca/bc/gov/nrs/csp/backend/repository/LineItemRepository.java b/backend/src/main/java/ca/bc/gov/nrs/csp/backend/repository/LineItemRepository.java index d211d11d..7a27b26c 100644 --- a/backend/src/main/java/ca/bc/gov/nrs/csp/backend/repository/LineItemRepository.java +++ b/backend/src/main/java/ca/bc/gov/nrs/csp/backend/repository/LineItemRepository.java @@ -1,6 +1,7 @@ package ca.bc.gov.nrs.csp.backend.repository; import ca.bc.gov.nrs.csp.backend.controller.dto.invoiceDetails.LineItem; +import ca.bc.gov.nrs.csp.backend.invoice.shared.LineAmount; import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.jdbc.support.GeneratedKeyHolder; @@ -35,10 +36,12 @@ public List findByInvoiceId(Long invoiceId) { d.pieces, d.price, d.volume, - d.converted_price + d.converted_price, + cls.csp_invoice_type_code AS invoice_type FROM THE.coastal_log_sale_detail d JOIN THE.csp_species_grade_xref sgx ON d.csp_species_grade_xref_id = sgx.csp_species_grade_xref_id LEFT JOIN THE.log_sale_species_code sc ON sgx.log_sale_species_code = sc.log_sale_species_code + JOIN THE.coastal_log_sale cls ON d.coastal_log_sale_id = cls.coastal_log_sale_id WHERE d.coastal_log_sale_id = :id ORDER BY d.coastal_log_sale_detail_id """; @@ -46,7 +49,7 @@ public List findByInvoiceId(Long invoiceId) { return jdbc.query(sql, params, (rs, rowNum) -> { BigDecimal price = rs.getBigDecimal("price"); BigDecimal volume = rs.getBigDecimal("volume"); - BigDecimal amount = (price != null && volume != null) ? price.multiply(volume) : null; + BigDecimal amount = LineAmount.compute(volume, price, rs.getString("invoice_type")); return new LineItem( rs.getObject("line_item_id", Long.class), rs.getObject("invoice_id", Long.class), diff --git a/backend/src/main/java/ca/bc/gov/nrs/csp/backend/service/InvoiceService.java b/backend/src/main/java/ca/bc/gov/nrs/csp/backend/service/InvoiceService.java index 42531512..08525e48 100644 --- a/backend/src/main/java/ca/bc/gov/nrs/csp/backend/service/InvoiceService.java +++ b/backend/src/main/java/ca/bc/gov/nrs/csp/backend/service/InvoiceService.java @@ -60,6 +60,14 @@ public class InvoiceService { private static final Logger log = LoggerFactory.getLogger(InvoiceService.class); + /** + * Statuses in which an invoice sits in an active review queue, and therefore the + * ones whose cancellation has to cascade to the parent submission. Mirrors the + * statuses the UI offers Approve / Reject / Cancel from. + */ + private static final Set REVIEW_QUEUE_STATUSES = Set.of( + ConstantsCode.INVENTRYSTATUS_PROCESSING, ConstantsCode.INVENTRYSTATUS_UNAPPROVED); + private final InvoiceRepository invoiceRepo; private final LineItemRepository lineItemRepo; private final CspSubmissionRepository submissionRepo; @@ -132,7 +140,7 @@ public InvoiceResponse getById(Long id) { public InvoiceResponse create(CreateInvoiceRequest request) { String user = SecurityContextUtils.requireUsername(); InvoiceDetails details = mapper.toDetails(request, user); - List lines = mapper.toLineItems(request.lineItems(), null); + List lines = mapper.toLineItems(request.lineItems(), null, details.invType()); ValidationResult result = newValidator().validate(details, lines, request.manual(), ActionType.SAVE); throwIfErrors(result, "Invoice failed validation on create."); @@ -169,10 +177,11 @@ public InvoiceResponse create(CreateInvoiceRequest request) { invoiceRepo.replaceLogSources(newInvoiceId, ConstantsCode.LOGSOURCECODE_BOOMNUMBER, details.boomNumbers(), user); invoiceRepo.replaceLogSources(newInvoiceId, ConstantsCode.LOGSOURCECODE_TIMERMARK, details.timberMarks(), user); invoiceRepo.replaceLogSources(newInvoiceId, ConstantsCode.LOGSOURCECODE_WEIGHSLIP, details.weightSlips(), user); - invoiceRepo.replaceRelatedInvoices(newInvoiceId, ConstantsCode.INVRELATETYPE_REPLACE, + List replacedIds = invoiceRepo.replaceRelatedInvoices(newInvoiceId, ConstantsCode.INVRELATETYPE_REPLACE, details.replaceInvNum(), details.submitterClientNum(), details.submitterLocation(), user); invoiceRepo.replaceRelatedInvoices(newInvoiceId, ConstantsCode.INVRELATETYPE_ADJUST, details.adjustInvNum(), details.submitterClientNum(), details.submitterLocation(), user); + cancelReplacedInvoices(newInvoiceId, replacedIds, user); InvoiceDetails saved = withId(details, newInvoiceId, ConstantsCode.INVENTRYSTATUS_DRAFT); // A freshly-created submission has no business submission number yet @@ -201,7 +210,7 @@ public InvoiceResponse update(Long id, UpdateInvoiceRequest request) { } InvoiceDetails details = mapper.toDetails(request, id, ConstantsCode.INVENTRYSTATUS_DRAFT, existing.details().entryUserID()); - List lines = mapper.toLineItems(request.lineItems(), id); + List lines = mapper.toLineItems(request.lineItems(), id, details.invType()); ValidationResult result = newValidator().validate(details, lines, request.manual(), ActionType.SAVE); throwIfErrors(result, "Invoice failed validation on update."); @@ -220,10 +229,11 @@ public InvoiceResponse update(Long id, UpdateInvoiceRequest request) { invoiceRepo.replaceLogSources(id, ConstantsCode.LOGSOURCECODE_BOOMNUMBER, details.boomNumbers(), user); invoiceRepo.replaceLogSources(id, ConstantsCode.LOGSOURCECODE_TIMERMARK, details.timberMarks(), user); invoiceRepo.replaceLogSources(id, ConstantsCode.LOGSOURCECODE_WEIGHSLIP, details.weightSlips(), user); - invoiceRepo.replaceRelatedInvoices(id, ConstantsCode.INVRELATETYPE_REPLACE, + List replacedIds = invoiceRepo.replaceRelatedInvoices(id, ConstantsCode.INVRELATETYPE_REPLACE, details.replaceInvNum(), details.submitterClientNum(), details.submitterLocation(), user); invoiceRepo.replaceRelatedInvoices(id, ConstantsCode.INVRELATETYPE_ADJUST, details.adjustInvNum(), details.submitterClientNum(), details.submitterLocation(), user); + cancelReplacedInvoices(id, replacedIds, user); // Saving an existing invoice reverts its submission to LOBBY. if (existing.submissionId() != null) { @@ -426,6 +436,49 @@ private void applySubmissionStatusOnStatusChange(Long submissionId, String newIn log.info("Submission submissionId={} newStatus={} anyApproved={}", submissionId, newSubmissionStatus, anyApproved); } + /** + * A replacement invoice supersedes every invoice named in its "Replaces invoice + * number(s)" field, so saving it cancels those originals. Called from create and + * update with the ids {@link InvoiceRepository#replaceRelatedInvoices} resolved for + * the REP relationship (never ADJ — an adjusting invoice leaves its target alone). + * + *

Skipped: an already-CANCELLED original (nothing to change) and a self-reference + * (the validator rejects "replaces itself", so this is only a defensive guard).

+ * + *

The submission-status cascade only runs when the original was in a + * {@link #REVIEW_QUEUE_STATUSES review-queue status}, which is what the cascade was + * written for; it clears a replaced invoice out of the inbox. Cancelling a DRAFT + * original deliberately leaves its submission in the lobby: + * the cascade would otherwise push a lobby submission (possibly holding other drafts) + * to REJECTED.

+ */ + private void cancelReplacedInvoices(Long replacementId, List replacedIds, String user) { + if (replacedIds == null) return; + for (Long replacedId : replacedIds) { + cancelReplacedInvoice(replacementId, replacedId, user); + } + } + + /** + * Cancels a single replaced invoice. Skips a null id, a self-reference, an invoice + * that no longer exists, and one that is already CANCELLED. + */ + private void cancelReplacedInvoice(Long replacementId, Long replacedId, String user) { + if (replacedId == null || replacedId.equals(replacementId)) return; + LoadedInvoice replaced = invoiceRepo.findById(replacedId).orElse(null); + if (replaced == null) return; + String previousStatus = replaced.details().invStatus(); + if (ConstantsCode.INVENTRYSTATUS_CANCELLED.equals(previousStatus)) return; + + invoiceRepo.updateStatus(replacedId, ConstantsCode.INVENTRYSTATUS_CANCELLED, user); + log.info("Cancelled invoice id={} (was {}) as it is replaced by invoice id={}", + replacedId, previousStatus, replacementId); + if (REVIEW_QUEUE_STATUSES.contains(previousStatus)) { + applySubmissionStatusOnStatusChange( + replaced.submissionId(), ConstantsCode.INVENTRYSTATUS_CANCELLED, user); + } + } + // --------------------------------------------------------------- // LINE ITEMS (sub-resource — only valid on DRAFT invoices) // --------------------------------------------------------------- @@ -439,7 +492,7 @@ public InvoiceResponse addLineItem(Long invoiceId, LineItemRequest request) { // the full invoice validator against the full set so totals-variance // / line-count rules still fire correctly. List existingLines = lineItemRepo.findByInvoiceId(invoiceId); - LineItem newLine = mapper.toLineItem(request, invoiceId); + LineItem newLine = mapper.toLineItem(request, invoiceId, existing.details().invType()); List candidate = new ArrayList<>(existingLines); candidate.add(newLine); @@ -459,7 +512,7 @@ public InvoiceResponse updateLineItem(Long invoiceId, Long lineId, LineItemReque ensureLineBelongsToInvoice(invoiceId, lineId); List existingLines = lineItemRepo.findByInvoiceId(invoiceId); - LineItem mapped = mapper.toLineItem(request, invoiceId); + LineItem mapped = mapper.toLineItem(request, invoiceId, existing.details().invType()); LineItem updatedLine = new LineItem( lineId, mapped.invoiceID(), mapped.secondSort(), mapped.clientSecondarySort(), mapped.species(), mapped.speciesDescription(), mapped.grade(), mapped.numOfPieces(), mapped.price(), mapped.volume(), diff --git a/backend/src/main/java/ca/bc/gov/nrs/csp/backend/service/mapper/InvoiceMapper.java b/backend/src/main/java/ca/bc/gov/nrs/csp/backend/service/mapper/InvoiceMapper.java index f5bccaae..b584a294 100644 --- a/backend/src/main/java/ca/bc/gov/nrs/csp/backend/service/mapper/InvoiceMapper.java +++ b/backend/src/main/java/ca/bc/gov/nrs/csp/backend/service/mapper/InvoiceMapper.java @@ -8,6 +8,7 @@ import ca.bc.gov.nrs.csp.backend.controller.dto.invoiceDetails.LineItemResponse; import ca.bc.gov.nrs.csp.backend.controller.dto.invoiceDetails.UpdateInvoiceRequest; import ca.bc.gov.nrs.csp.backend.controller.dto.invoiceDetails.ValidationMessageResponse; +import ca.bc.gov.nrs.csp.backend.invoice.shared.LineAmount; import ca.bc.gov.nrs.csp.backend.invoice.shared.rules.SourceDocuments; import ca.bc.gov.nrs.csp.backend.util.validation.ValidationMessage; import ca.bc.gov.nrs.csp.backend.util.validation.ValidationResult; @@ -15,7 +16,6 @@ import org.springframework.context.NoSuchMessageException; import org.springframework.stereotype.Component; -import java.math.BigDecimal; import java.util.List; import java.util.Locale; @@ -109,7 +109,11 @@ private static List dedupSourceDocuments(List values) { return SourceDocuments.dedup(values); } - public LineItem toLineItem(LineItemRequest req, Long invoiceId) { + /** + * @param invoiceType the parent invoice's type code — an ADJ line keeps a negative + * amount when both volume and price are negative. + */ + public LineItem toLineItem(LineItemRequest req, Long invoiceId, String invoiceType) { if (req == null) return null; // Description is resolved server-side from the species code lookup; // null on the inbound path, populated when the row is read back. @@ -125,13 +129,13 @@ public LineItem toLineItem(LineItemRequest req, Long invoiceId) { req.price(), req.volume(), req.convertedPrice(), - computeAmount(req.volume(), req.price()) + LineAmount.compute(req.volume(), req.price(), invoiceType) ); } - public List toLineItems(List requests, Long invoiceId) { + public List toLineItems(List requests, Long invoiceId, String invoiceType) { if (requests == null) return List.of(); - return requests.stream().map(r -> toLineItem(r, invoiceId)).toList(); + return requests.stream().map(r -> toLineItem(r, invoiceId, invoiceType)).toList(); } public LineItemResponse toLineItemResponse(LineItem line) { @@ -231,8 +235,4 @@ public InvoiceResponse toResponse(InvoiceDetails details, Long submissionId, Lon ); } - private static BigDecimal computeAmount(BigDecimal volume, BigDecimal price) { - if (volume == null || price == null) return null; - return volume.multiply(price); - } } diff --git a/backend/src/test/java/ca/bc/gov/nrs/csp/backend/repository/InvoiceRepositoryTest.java b/backend/src/test/java/ca/bc/gov/nrs/csp/backend/repository/InvoiceRepositoryTest.java index 52408cd8..58746e61 100644 --- a/backend/src/test/java/ca/bc/gov/nrs/csp/backend/repository/InvoiceRepositoryTest.java +++ b/backend/src/test/java/ca/bc/gov/nrs/csp/backend/repository/InvoiceRepositoryTest.java @@ -933,7 +933,7 @@ void countByCspSubmissionIdAndStatus_nullCount_returnsZero() { @Test void replaceRelatedInvoices_nullCsv_onlyDeletesExistingRefs() { - repo.replaceRelatedInvoices(10L, "REP", null, "00001111", "01", "user123"); + assertThat(repo.replaceRelatedInvoices(10L, "REP", null, "00001111", "01", "user123")).isEmpty(); ArgumentCaptor sqlCaptor = ArgumentCaptor.forClass(String.class); ArgumentCaptor paramsCaptor = ArgumentCaptor.forClass(MapSqlParameterSource.class); @@ -947,7 +947,7 @@ void replaceRelatedInvoices_nullCsv_onlyDeletesExistingRefs() { @Test void replaceRelatedInvoices_blankCsv_onlyDeletesExistingRefs() { - repo.replaceRelatedInvoices(10L, "ADJ", " ", "00001111", "01", "user123"); + assertThat(repo.replaceRelatedInvoices(10L, "ADJ", " ", "00001111", "01", "user123")).isEmpty(); verify(jdbc, times(1)).update(anyString(), any(MapSqlParameterSource.class)); } @@ -962,7 +962,9 @@ void replaceRelatedInvoices_resolvesEachNumber_insertsResolved_skipsUnresolvedAn return "INV-A".equals(p.getValue("invNo")) ? List.of(101L) : List.of(); }); - repo.replaceRelatedInvoices(10L, "REP", " INV-A , , INV-B ", "00001111", "01", "user123"); + // Only the resolved id comes back — callers use it to cancel the replaced invoice. + assertThat(repo.replaceRelatedInvoices(10L, "REP", " INV-A , , INV-B ", "00001111", "01", "user123")) + .containsExactly(101L); // Two resolve lookups: INV-A and INV-B (empty token skipped) ArgumentCaptor resolveCaptor = ArgumentCaptor.forClass(MapSqlParameterSource.class); diff --git a/backend/src/test/java/ca/bc/gov/nrs/csp/backend/repository/LineItemRepositoryTest.java b/backend/src/test/java/ca/bc/gov/nrs/csp/backend/repository/LineItemRepositoryTest.java index efacf9b8..5187e04c 100644 --- a/backend/src/test/java/ca/bc/gov/nrs/csp/backend/repository/LineItemRepositoryTest.java +++ b/backend/src/test/java/ca/bc/gov/nrs/csp/backend/repository/LineItemRepositoryTest.java @@ -88,6 +88,27 @@ private MapSqlParameterSource captureInsertParams() { return paramsCaptor.getValue(); } + /** + * Stubs every column the {@code findByInvoiceId} row mapper reads. Strict stubbing + * rejects invoking a stubbed method with un-stubbed arguments — e.g. calling + * {@code getString("second_sort")} when only {@code getString("invoice_type")} is + * stubbed — so a row has to be stubbed whole rather than column by column. + */ + private static void stubRow(ResultSet rs, String invoiceType, String price, String volume) throws SQLException { + given(rs.getObject("line_item_id", Long.class)).willReturn(10L); + given(rs.getObject("invoice_id", Long.class)).willReturn(12345L); + given(rs.getString("second_sort")).willReturn("SORT01"); + given(rs.getString("client_secondary_sort")).willReturn("CLIENT01"); + given(rs.getString("species")).willReturn("SPC1"); + given(rs.getString("species_description")).willReturn("Cedar"); + given(rs.getString("grade")).willReturn("G1"); + given(rs.getString("invoice_type")).willReturn(invoiceType); + given(rs.getObject("pieces", Integer.class)).willReturn(50); + given(rs.getBigDecimal("price")).willReturn(new BigDecimal(price)); + given(rs.getBigDecimal("volume")).willReturn(new BigDecimal(volume)); + given(rs.getBigDecimal("converted_price")).willReturn(new BigDecimal("21.25")); + } + private static LineItem lineItem(String clientSecondarySort) { return new LineItem( null, null, "SORT01", clientSecondarySort, "SPC1", null, "G1", @@ -117,6 +138,7 @@ void findByInvoiceId_mapsEveryColumnAndComputesAmount() throws SQLException { given(rs.getString("species")).willReturn("SPC1"); given(rs.getString("species_description")).willReturn("Cedar"); given(rs.getString("grade")).willReturn("G1"); + given(rs.getString("invoice_type")).willReturn("SAL"); given(rs.getObject("pieces", Integer.class)).willReturn(50); given(rs.getBigDecimal("converted_price")).willReturn(new BigDecimal("21.25")); stubQueryMapsRow(rs); @@ -144,10 +166,36 @@ void findByInvoiceId_mapsEveryColumnAndComputesAmount() throws SQLException { verify(jdbc).query(sqlCaptor.capture(), paramsCaptor.capture(), any(RowMapper.class)); assertThat(sqlCaptor.getValue()) .contains("WHERE d.coastal_log_sale_id = :id") - .contains("ORDER BY d.coastal_log_sale_detail_id"); + .contains("ORDER BY d.coastal_log_sale_detail_id") + // The parent invoice's type is joined in so the derived amount can + // apply the ADJ sign rule. + .contains("cls.csp_invoice_type_code AS invoice_type"); assertThat(paramsCaptor.getValue().getValue("id")).isEqualTo(12345L); } + @Test + void findByInvoiceId_adjustment_keepsAmountNegativeWhenVolumeAndPriceAreNegative() throws SQLException { + ResultSet rs = mock(ResultSet.class); + stubRow(rs, "ADJ", "-5.00", "-5.000"); + stubQueryMapsRow(rs); + + List items = repo.findByInvoiceId(12345L); + + // A plain multiply would yield +25.00; an ADJ line must stay negative. + assertThat(items.get(0).amount()).isEqualByComparingTo("-25.00"); + } + + @Test + void findByInvoiceId_nonAdjustment_multipliesTwoNegativesToPositive() throws SQLException { + ResultSet rs = mock(ResultSet.class); + stubRow(rs, "SAL", "-5.00", "-5.000"); + stubQueryMapsRow(rs); + + List items = repo.findByInvoiceId(12345L); + + assertThat(items.get(0).amount()).isEqualByComparingTo("25.00"); + } + @Test void findByInvoiceId_nullPrice_amountIsNull() throws SQLException { ResultSet rs = mock(ResultSet.class); diff --git a/backend/src/test/java/ca/bc/gov/nrs/csp/backend/service/InvoiceServiceTest.java b/backend/src/test/java/ca/bc/gov/nrs/csp/backend/service/InvoiceServiceTest.java index 1991e7b0..4978a2a6 100644 --- a/backend/src/test/java/ca/bc/gov/nrs/csp/backend/service/InvoiceServiceTest.java +++ b/backend/src/test/java/ca/bc/gov/nrs/csp/backend/service/InvoiceServiceTest.java @@ -239,7 +239,7 @@ private CreateInvoiceRequest createRequest(boolean manual) { void create_validationError_throwsAndPersistsNothing() { CreateInvoiceRequest req = createRequest(true); given(mapper.toDetails(eq(req), anyString())).willReturn(draftDetails(null)); - given(mapper.toLineItems(any(), isNull())).willReturn(List.of()); + given(mapper.toLineItems(any(), isNull(), any())).willReturn(List.of()); given(validator.validate(any(), any(), anyBoolean(), eq(ActionType.SAVE))).willReturn(WITH_ERROR); assertThatThrownBy(() -> service.create(req)).isInstanceOf(ValidationException.class); @@ -252,7 +252,7 @@ void create_manual_insertsLobbySubmissionAndPersistsEverything() { CreateInvoiceRequest req = createRequest(true); // Registered other party (otherClientNum set) → no participant insert. given(mapper.toDetails(eq(req), anyString())).willReturn(details(null, "DFT", "5678", null, "Seller")); - given(mapper.toLineItems(any(), isNull())).willReturn(List.of(line(null, null))); + given(mapper.toLineItems(any(), isNull(), any())).willReturn(List.of(line(null, null))); given(submissionRepo.insertSubmission(any(), any(), eq("LOB"), any())).willReturn(77L); given(invoiceRepo.insertInvoice(any(), eq(77L), eq("DFT"), isNull(), isNull(), any())).willReturn(500L); @@ -270,7 +270,7 @@ void create_manual_insertsLobbySubmissionAndPersistsEverything() { void create_nonManual_insertsInboxSubmission() { CreateInvoiceRequest req = createRequest(false); given(mapper.toDetails(eq(req), anyString())).willReturn(details(null, "DFT", "5678", null, "Seller")); - given(mapper.toLineItems(any(), isNull())).willReturn(List.of()); + given(mapper.toLineItems(any(), isNull(), any())).willReturn(List.of()); given(submissionRepo.insertSubmission(any(), any(), eq("INB"), any())).willReturn(77L); given(invoiceRepo.insertInvoice(any(), any(), any(), any(), any(), any())).willReturn(500L); @@ -284,7 +284,7 @@ void create_manualOtherPartyAsBuyer_insertsParticipantIntoBuyerSlot() { CreateInvoiceRequest req = createRequest(true); // No client number + a name + submittedBy Seller → manual other party in the BUYER slot. given(mapper.toDetails(eq(req), anyString())).willReturn(details(null, "DFT", null, "ABC Logging", "Seller")); - given(mapper.toLineItems(any(), isNull())).willReturn(List.of()); + given(mapper.toLineItems(any(), isNull(), any())).willReturn(List.of()); given(submissionRepo.insertSubmission(any(), any(), any(), any())).willReturn(77L); given(participantRepo.insert(eq("ABC Logging"), any(), any(), any())).willReturn(900L); given(invoiceRepo.insertInvoice(any(), any(), any(), any(), any(), any())).willReturn(500L); @@ -300,7 +300,7 @@ void create_manualOtherPartyAsSeller_insertsParticipantIntoSellerSlot() { CreateInvoiceRequest req = createRequest(true); // submittedBy Buyer → the other party is the seller (seller slot). given(mapper.toDetails(eq(req), anyString())).willReturn(details(null, "DFT", null, "ABC Logging", "Buyer")); - given(mapper.toLineItems(any(), isNull())).willReturn(List.of()); + given(mapper.toLineItems(any(), isNull(), any())).willReturn(List.of()); given(submissionRepo.insertSubmission(any(), any(), any(), any())).willReturn(77L); given(participantRepo.insert(any(), any(), any(), any())).willReturn(900L); given(invoiceRepo.insertInvoice(any(), any(), any(), any(), any(), any())).willReturn(500L); @@ -343,7 +343,7 @@ void update_persistsConvertedPricesAndSurfacesConversionWarnings() { UpdateInvoiceRequest req = updateRequest(); given(invoiceRepo.findById(1L)).willReturn(Optional.of(loaded(draftDetails(1L), 10L, null, null))); given(mapper.toDetails(eq(req), eq(1L), any(), any())).willReturn(draftDetails(1L)); - given(mapper.toLineItems(any(), eq(1L))).willReturn(List.of()); + given(mapper.toLineItems(any(), eq(1L), any())).willReturn(List.of()); LineItem converted = line(1L, new BigDecimal("12.34")); given(priceConversionService.apply(any(), any(), any())) .willReturn(new PriceConversionService.Result(List.of(converted), List.of(A_WARNING))); @@ -361,7 +361,7 @@ void update_validationError_throws() { UpdateInvoiceRequest req = updateRequest(); given(invoiceRepo.findById(1L)).willReturn(Optional.of(loaded(draftDetails(1L), 10L, null, null))); given(mapper.toDetails(eq(req), eq(1L), any(), any())).willReturn(draftDetails(1L)); - given(mapper.toLineItems(any(), eq(1L))).willReturn(List.of()); + given(mapper.toLineItems(any(), eq(1L), any())).willReturn(List.of()); given(validator.validate(any(), any(), anyBoolean(), eq(ActionType.SAVE))).willReturn(WITH_ERROR); assertThatThrownBy(() -> service.update(1L, req)).isInstanceOf(ValidationException.class); @@ -375,7 +375,7 @@ void update_success_reconcilesLinesAndRevertsSubmissionToLobby() { given(invoiceRepo.findById(1L)).willReturn(Optional.of(loaded(draftDetails(1L), 10L, null, null))); given(mapper.toDetails(eq(req), eq(1L), any(), any())).willReturn(draftDetails(1L)); // Incoming: one existing line (id 1, updated) + one new line (null id). Existing on db: 1 and 2. - given(mapper.toLineItems(any(), eq(1L))).willReturn(List.of(line(1L, null), line(null, null))); + given(mapper.toLineItems(any(), eq(1L), any())).willReturn(List.of(line(1L, null), line(null, null))); given(lineItemRepo.findIdsByInvoiceId(1L)).willReturn(List.of(1L, 2L)); service.update(1L, req); @@ -392,7 +392,7 @@ void update_noSubmission_doesNotTouchSubmissionStatus() { UpdateInvoiceRequest req = updateRequest(); given(invoiceRepo.findById(1L)).willReturn(Optional.of(loaded(draftDetails(1L), null, null, null))); given(mapper.toDetails(eq(req), eq(1L), any(), any())).willReturn(draftDetails(1L)); - given(mapper.toLineItems(any(), eq(1L))).willReturn(List.of()); + given(mapper.toLineItems(any(), eq(1L), any())).willReturn(List.of()); service.update(1L, req); @@ -405,7 +405,7 @@ void update_registeredOtherParty_clearsBothParticipantSlots() { given(invoiceRepo.findById(1L)).willReturn(Optional.of(loaded(draftDetails(1L), 10L, 50L, 60L))); // Registered other party (otherClientNum set, no manual name). given(mapper.toDetails(eq(req), eq(1L), any(), any())).willReturn(details(1L, "DFT", "5678", null, "Seller")); - given(mapper.toLineItems(any(), eq(1L))).willReturn(List.of()); + given(mapper.toLineItems(any(), eq(1L), any())).willReturn(List.of()); service.update(1L, req); @@ -419,7 +419,7 @@ void update_manualOtherPartyExistingBuyer_updatesInPlace() { UpdateInvoiceRequest req = updateRequest(); given(invoiceRepo.findById(1L)).willReturn(Optional.of(loaded(draftDetails(1L), 10L, 50L, null))); given(mapper.toDetails(eq(req), eq(1L), any(), any())).willReturn(details(1L, "DFT", null, "ABC Logging", "Seller")); - given(mapper.toLineItems(any(), eq(1L))).willReturn(List.of()); + given(mapper.toLineItems(any(), eq(1L), any())).willReturn(List.of()); service.update(1L, req); @@ -434,7 +434,7 @@ void update_manualOtherPartyMovedSlot_insertsNewAndOrphansOld() { // Existing seller participant 60; new state puts the manual party in the buyer slot. given(invoiceRepo.findById(1L)).willReturn(Optional.of(loaded(draftDetails(1L), 10L, null, 60L))); given(mapper.toDetails(eq(req), eq(1L), any(), any())).willReturn(details(1L, "DFT", null, "ABC Logging", "Seller")); - given(mapper.toLineItems(any(), eq(1L))).willReturn(List.of()); + given(mapper.toLineItems(any(), eq(1L), any())).willReturn(List.of()); given(participantRepo.insert(any(), any(), any(), any())).willReturn(99L); service.update(1L, req); @@ -444,6 +444,118 @@ void update_manualOtherPartyMovedSlot_insertsNewAndOrphansOld() { verify(participantRepo).delete(60L); // old seller row orphaned } + // =============================================================== + // replaces → cancel the replaced invoice + // =============================================================== + + /** Stubs a create that resolves its "Replaces invoice number(s)" field to invoice 900. */ + private CreateInvoiceRequest createReplacing(String refType, Long... resolvedIds) { + CreateInvoiceRequest req = createRequest(true); + given(mapper.toDetails(eq(req), anyString())).willReturn(details(null, "DFT", "5678", null, "Seller")); + given(mapper.toLineItems(any(), isNull(), any())).willReturn(List.of()); + given(submissionRepo.insertSubmission(any(), any(), any(), any())).willReturn(77L); + given(invoiceRepo.insertInvoice(any(), any(), any(), any(), any(), any())).willReturn(500L); + // Lenient: create resolves both relationship types, and only one of them is + // stubbed here — the other legitimately falls through to the empty default. + lenient().when(invoiceRepo.replaceRelatedInvoices(eq(500L), eq(refType), any(), any(), any(), any())) + .thenReturn(List.of(resolvedIds)); + return req; + } + + @Test + void create_replacesProcessingInvoice_cancelsItAndClearsItsSubmission() { + CreateInvoiceRequest req = createReplacing("REP", 900L); + given(invoiceRepo.findById(900L)) + .willReturn(Optional.of(loaded(details(900L, "PRO", "5678", null, "Seller"), 20L, null, null))); + + service.create(req); + + verify(invoiceRepo).updateStatus(900L, "CAN", USER); + // Submission 20 has no processing invoice left and none approved → it leaves the inbox. + verify(submissionRepo).updateSubmissionStatus(20L, "REJ", USER); + } + + @Test + void create_replacesDraftInvoice_cancelsItButLeavesItsSubmissionInTheLobby() { + CreateInvoiceRequest req = createReplacing("REP", 900L); + given(invoiceRepo.findById(900L)) + .willReturn(Optional.of(loaded(draftDetails(900L), 20L, null, null))); + + service.create(req); + + verify(invoiceRepo).updateStatus(900L, "CAN", USER); + verify(submissionRepo, never()).updateSubmissionStatus(eq(20L), any(), any()); + } + + @Test + void create_replacesAlreadyCancelledInvoice_leavesItUntouched() { + CreateInvoiceRequest req = createReplacing("REP", 900L); + given(invoiceRepo.findById(900L)) + .willReturn(Optional.of(loaded(details(900L, "CAN", "5678", null, "Seller"), 20L, null, null))); + + service.create(req); + + verify(invoiceRepo, never()).updateStatus(any(), any(), any()); + } + + @Test + void create_replacesMultipleInvoices_cancelsEachOfThem() { + CreateInvoiceRequest req = createReplacing("REP", 900L, 901L); + given(invoiceRepo.findById(900L)) + .willReturn(Optional.of(loaded(details(900L, "PRO", "5678", null, "Seller"), 20L, null, null))); + given(invoiceRepo.findById(901L)) + .willReturn(Optional.of(loaded(details(901L, "UNA", "5678", null, "Seller"), 21L, null, null))); + + service.create(req); + + verify(invoiceRepo).updateStatus(900L, "CAN", USER); + verify(invoiceRepo).updateStatus(901L, "CAN", USER); + } + + @Test + void create_adjustsInvoice_doesNotCancelTheAdjustedInvoice() { + // Only the REP relationship cancels; an adjusting invoice leaves its target alone. + CreateInvoiceRequest req = createReplacing("ADJ", 900L); + + service.create(req); + + verify(invoiceRepo, never()).updateStatus(any(), any(), any()); + } + + @Test + void update_replacesUnapprovedInvoice_cancelsItAlongsideTheNormalSave() { + UpdateInvoiceRequest req = updateRequest(); + given(invoiceRepo.findById(1L)).willReturn(Optional.of(loaded(draftDetails(1L), 10L, null, null))); + given(mapper.toDetails(eq(req), eq(1L), any(), any())).willReturn(draftDetails(1L)); + given(mapper.toLineItems(any(), eq(1L), any())).willReturn(List.of()); + given(invoiceRepo.replaceRelatedInvoices(eq(1L), eq("REP"), any(), any(), any(), any())) + .willReturn(List.of(900L)); + given(invoiceRepo.findById(900L)) + .willReturn(Optional.of(loaded(details(900L, "UNA", "5678", null, "Seller"), 20L, null, null))); + + service.update(1L, req); + + verify(invoiceRepo).updateStatus(900L, "CAN", USER); + verify(submissionRepo).updateSubmissionStatus(20L, "REJ", USER); + // The saved invoice's own submission still reverts to the lobby. + verify(submissionRepo).updateSubmissionStatus(10L, "LOB", USER); + } + + @Test + void update_replacesItself_doesNotCancelTheInvoiceBeingSaved() { + // The validator rejects "replaces itself"; this guards the persistence path anyway. + UpdateInvoiceRequest req = updateRequest(); + given(invoiceRepo.findById(1L)).willReturn(Optional.of(loaded(draftDetails(1L), 10L, null, null))); + given(mapper.toDetails(eq(req), eq(1L), any(), any())).willReturn(draftDetails(1L)); + given(mapper.toLineItems(any(), eq(1L), any())).willReturn(List.of()); + given(invoiceRepo.replaceRelatedInvoices(eq(1L), eq("REP"), any(), any(), any(), any())) + .willReturn(List.of(1L)); + + service.update(1L, req); + + verify(invoiceRepo, never()).updateStatus(any(), any(), any()); + } + // =============================================================== // delete // =============================================================== @@ -698,7 +810,7 @@ void addLineItem_approvedInvoice_throwsConflict() { void addLineItem_validationError_throws() { LineItemRequest req = lineItemRequest(); given(invoiceRepo.findById(1L)).willReturn(Optional.of(loaded(draftDetails(1L), 10L, null, null))); - given(mapper.toLineItem(req, 1L)).willReturn(line(null, null)); + given(mapper.toLineItem(eq(req), eq(1L), any())).willReturn(line(null, null)); given(validator.validate(any(), any(), anyBoolean(), eq(ActionType.SAVE))).willReturn(WITH_ERROR); assertThatThrownBy(() -> service.addLineItem(1L, req)).isInstanceOf(ValidationException.class); @@ -709,7 +821,7 @@ void addLineItem_validationError_throws() { void addLineItem_onDraft_insertsWithoutStatusChange() { LineItemRequest req = lineItemRequest(); given(invoiceRepo.findById(1L)).willReturn(Optional.of(loaded(draftDetails(1L), 10L, null, null))); - given(mapper.toLineItem(req, 1L)).willReturn(line(null, null)); + given(mapper.toLineItem(eq(req), eq(1L), any())).willReturn(line(null, null)); service.addLineItem(1L, req); @@ -721,7 +833,7 @@ void addLineItem_onDraft_insertsWithoutStatusChange() { void addLineItem_onUnapproved_revertsToDraftAndLobby() { LineItemRequest req = lineItemRequest(); given(invoiceRepo.findById(1L)).willReturn(Optional.of(loaded(details(1L, "UNA", "5678", null, "Seller"), 10L, null, null))); - given(mapper.toLineItem(req, 1L)).willReturn(line(null, null)); + given(mapper.toLineItem(eq(req), eq(1L), any())).willReturn(line(null, null)); service.addLineItem(1L, req); @@ -750,7 +862,7 @@ void updateLineItem_onProcessing_succeedsAndRevertsToDraft() { given(invoiceRepo.findById(1L)).willReturn(Optional.of(loaded(details(1L, "PRO", "5678", null, "Seller"), 10L, null, null))); given(lineItemRepo.findIdsByInvoiceId(1L)).willReturn(List.of(5L)); given(lineItemRepo.findByInvoiceId(1L)).willReturn(List.of(line(5L, null))); - given(mapper.toLineItem(req, 1L)).willReturn(line(null, null)); + given(mapper.toLineItem(eq(req), eq(1L), any())).willReturn(line(null, null)); service.updateLineItem(1L, 5L, req); @@ -784,7 +896,7 @@ void updateLineItem_validationError_throws() { given(invoiceRepo.findById(1L)).willReturn(Optional.of(loaded(draftDetails(1L), 10L, null, null))); given(lineItemRepo.findIdsByInvoiceId(1L)).willReturn(List.of(5L)); given(lineItemRepo.findByInvoiceId(1L)).willReturn(List.of(line(5L, null))); - given(mapper.toLineItem(req, 1L)).willReturn(line(5L, null)); + given(mapper.toLineItem(eq(req), eq(1L), any())).willReturn(line(5L, null)); given(validator.validate(any(), any(), anyBoolean(), eq(ActionType.SAVE))).willReturn(WITH_ERROR); assertThatThrownBy(() -> service.updateLineItem(1L, 5L, req)).isInstanceOf(ValidationException.class); @@ -797,7 +909,7 @@ void updateLineItem_success_updatesLineKeyedByPathId() { given(invoiceRepo.findById(1L)).willReturn(Optional.of(loaded(draftDetails(1L), 10L, null, null))); given(lineItemRepo.findIdsByInvoiceId(1L)).willReturn(List.of(5L)); given(lineItemRepo.findByInvoiceId(1L)).willReturn(List.of(line(5L, null))); - given(mapper.toLineItem(req, 1L)).willReturn(line(null, null)); + given(mapper.toLineItem(eq(req), eq(1L), any())).willReturn(line(null, null)); service.updateLineItem(1L, 5L, req); diff --git a/backend/src/test/java/ca/bc/gov/nrs/csp/backend/service/mapper/InvoiceMapperTest.java b/backend/src/test/java/ca/bc/gov/nrs/csp/backend/service/mapper/InvoiceMapperTest.java index 09b06d1f..afc2c539 100644 --- a/backend/src/test/java/ca/bc/gov/nrs/csp/backend/service/mapper/InvoiceMapperTest.java +++ b/backend/src/test/java/ca/bc/gov/nrs/csp/backend/service/mapper/InvoiceMapperTest.java @@ -241,7 +241,7 @@ void toDetails_fromUpdateRequest_nullCollections_becomeEmptyLists() { @Test void toLineItem_mapsAllFieldsAndComputesAmount() { - LineItem line = mapper.toLineItem(lineItemRequest(), 12345L); + LineItem line = mapper.toLineItem(lineItemRequest(), 12345L, "SAL"); assertThat(line).isNotNull(); assertThat(line.lineItemID()).isEqualTo(1L); @@ -260,7 +260,7 @@ void toLineItem_mapsAllFieldsAndComputesAmount() { @Test void toLineItem_nullRequest_returnsNull() { - assertThat(mapper.toLineItem(null, 12345L)).isNull(); + assertThat(mapper.toLineItem(null, 12345L, "SAL")).isNull(); } @Test @@ -268,7 +268,7 @@ void toLineItem_nullVolume_yieldsNullAmount() { LineItemRequest req = new LineItemRequest( null, "SORT01", null, "SP1", "G1", 50, new BigDecimal("25.00"), null, null); - LineItem line = mapper.toLineItem(req, null); + LineItem line = mapper.toLineItem(req, null, "SAL"); assertThat(line.amount()).isNull(); assertThat(line.invoiceID()).isNull(); @@ -280,12 +280,32 @@ void toLineItem_nullPrice_yieldsNullAmount() { LineItemRequest req = new LineItemRequest( 2L, "SORT01", null, "SP1", "G1", 50, null, new BigDecimal("6.25"), null); - assertThat(mapper.toLineItem(req, 1L).amount()).isNull(); + assertThat(mapper.toLineItem(req, 1L, "SAL").amount()).isNull(); + } + + @Test + void toLineItem_adjustment_keepsAmountNegativeWhenVolumeAndPriceAreNegative() { + // Legacy parity (Utils.bigDecimalMultiplicationForAdj): a plain multiply of + // two negatives would yield +25.00, but an ADJ line must stay negative. + LineItemRequest req = new LineItemRequest( + null, "SORT01", null, "SP1", "G1", -5, new BigDecimal("-5.00"), new BigDecimal("-5.000"), null); + + assertThat(mapper.toLineItem(req, 1L, "ADJ").amount()).isEqualByComparingTo("-25.00"); + } + + @Test + void toLineItem_nonAdjustment_multipliesTwoNegativesToPositive() { + // Non-ADJ types reject negative volume/price upstream (InvoiceLineRuleSet), + // so the sign fix must NOT leak into them. + LineItemRequest req = new LineItemRequest( + null, "SORT01", null, "SP1", "G1", -5, new BigDecimal("-5.00"), new BigDecimal("-5.000"), null); + + assertThat(mapper.toLineItem(req, 1L, "SAL").amount()).isEqualByComparingTo("25.00"); } @Test void toLineItems_mapsEveryElementWithInvoiceId() { - List lines = mapper.toLineItems(List.of(lineItemRequest(), lineItemRequest()), 42L); + List lines = mapper.toLineItems(List.of(lineItemRequest(), lineItemRequest()), 42L, "SAL"); assertThat(lines) .hasSize(2) @@ -296,12 +316,12 @@ void toLineItems_mapsEveryElementWithInvoiceId() { @Test void toLineItems_nullInput_returnsEmptyList() { - assertThat(mapper.toLineItems(null, 42L)).isEmpty(); + assertThat(mapper.toLineItems(null, 42L, "SAL")).isEmpty(); } @Test void toLineItems_emptyInput_returnsEmptyList() { - assertThat(mapper.toLineItems(List.of(), 42L)).isEmpty(); + assertThat(mapper.toLineItems(List.of(), 42L, "SAL")).isEmpty(); } // --------------------------------------------------------------- diff --git a/frontend/src/components/Form/EditableLineItemsTable/index.browser.test.tsx b/frontend/src/components/Form/EditableLineItemsTable/index.browser.test.tsx index 0b118f33..4ec11609 100644 --- a/frontend/src/components/Form/EditableLineItemsTable/index.browser.test.tsx +++ b/frontend/src/components/Form/EditableLineItemsTable/index.browser.test.tsx @@ -39,6 +39,7 @@ const setup = (overrides: Partial, diff --git a/frontend/src/components/Form/EditableLineItemsTable/index.scss b/frontend/src/components/Form/EditableLineItemsTable/index.scss index 747030b2..ffbc1c1e 100644 --- a/frontend/src/components/Form/EditableLineItemsTable/index.scss +++ b/frontend/src/components/Form/EditableLineItemsTable/index.scss @@ -21,7 +21,6 @@ } .cds--data-table tbody td { - text-align: center !important; padding-left: $spacing-05 !important; } diff --git a/frontend/src/components/Form/EditableLineItemsTable/index.tsx b/frontend/src/components/Form/EditableLineItemsTable/index.tsx index 952bb181..1d24e871 100644 --- a/frontend/src/components/Form/EditableLineItemsTable/index.tsx +++ b/frontend/src/components/Form/EditableLineItemsTable/index.tsx @@ -4,6 +4,7 @@ import { IconButton, TextInput } from '@carbon/react'; import ResultsTable, { type ResultsTableColumn } from '@/components/Form/ResultsTable'; import SingleSelect from '@/components/Form/SingleSelect'; import { formatCurrency, formatNumber } from '@/utils/format'; +import { computeLineAmount } from '@/validations/invoice/invoice'; import './index.scss'; @@ -60,6 +61,7 @@ export interface EditableLineItemsTableProps { editDraft: EditableLineItemDraft | null; /** Per-field inline validation errors for the active edit row. Keys: secondarySort, species, grade, pieces, volume, price. */ fieldErrors: Record; + invType: string; onStartEdit: (rowId: string) => void; onCancelEdit: () => void; onSaveEdit: () => void; @@ -90,6 +92,7 @@ export default function EditableLineItemsTable({ speciesGradeCombos, editDraft, fieldErrors, + invType, onStartEdit, onCancelEdit, onSaveEdit, @@ -114,7 +117,7 @@ export default function EditableLineItemsTable({ const p = Number.parseFloat(editDraft.price); const v = Number.parseFloat(editDraft.volume); if (Number.isNaN(p) || Number.isNaN(v)) return ''; - return (Math.round(p * v * 100) / 100).toFixed(2); + return computeLineAmount(v, p, invType).toFixed(2); })(); const columns: ResultsTableColumn[] = [ @@ -122,6 +125,7 @@ export default function EditableLineItemsTable({ key: 'secondarySort', header: 'Secondary sort code', headerAlign: 'center', + cellAlign: 'center', renderCell: (r) => { if (!isEditingId(r.id) || !editDraft) return r.secondarySort; return ( @@ -144,6 +148,7 @@ export default function EditableLineItemsTable({ key: 'species', header: 'Species', headerAlign: 'center', + cellAlign: 'center', renderCell: (r) => { if (!isEditingId(r.id) || !editDraft) return r.species; return ( @@ -166,6 +171,7 @@ export default function EditableLineItemsTable({ key: 'clientSecondarySort', header: 'Client secondary sort code', headerAlign: 'center', + cellAlign: 'center', renderCell: (r) => { if (!isEditingId(r.id) || !editDraft) return r.clientSecondarySort; return ( @@ -184,6 +190,7 @@ export default function EditableLineItemsTable({ key: 'numberPieces', header: 'Number pieces', headerAlign: 'center', + cellAlign: 'right', renderCell: (r) => { if (!isEditingId(r.id) || !editDraft) return formatNumber(r.numberPieces); return ( @@ -204,6 +211,7 @@ export default function EditableLineItemsTable({ key: 'grade', header: 'Grade', headerAlign: 'center', + cellAlign: 'center', renderCell: (r) => { if (!isEditingId(r.id) || !editDraft) return r.grade; return ( @@ -226,6 +234,7 @@ export default function EditableLineItemsTable({ key: 'volume', header: 'Volume', headerAlign: 'center', + cellAlign: 'right', renderCell: (r) => { if (!isEditingId(r.id) || !editDraft) return formatNumber(r.volume, 3); return ( @@ -246,6 +255,7 @@ export default function EditableLineItemsTable({ key: 'price', header: '$ Price', headerAlign: 'center', + cellAlign: 'right', renderCell: (r) => { if (!isEditingId(r.id) || !editDraft) return formatCurrency(r.price); return ( @@ -266,6 +276,7 @@ export default function EditableLineItemsTable({ key: 'amount', header: '$ Amount', headerAlign: 'center', + cellAlign: 'right', renderCell: (r) => { if (isEditingId(r.id)) { return editComputedAmount ? `$${editComputedAmount}` : ''; @@ -276,11 +287,20 @@ export default function EditableLineItemsTable({ { key: 'id', header: '', + cellAlign: 'center', renderCell: (r) => { if (isEditingId(r.id)) { return (
- + 0} + onClick={onSaveEdit} + > diff --git a/frontend/src/components/Form/EditableLineItemsTable/index.unit.test.tsx b/frontend/src/components/Form/EditableLineItemsTable/index.unit.test.tsx index ad2cfcaf..e82d9102 100644 --- a/frontend/src/components/Form/EditableLineItemsTable/index.unit.test.tsx +++ b/frontend/src/components/Form/EditableLineItemsTable/index.unit.test.tsx @@ -81,6 +81,7 @@ describe('EditableLineItemsTable', () => { speciesGradeCombos={COMBOS} editDraft={null} fieldErrors={{}} + invType="SAL" {...handlers} {...overrides} />, diff --git a/frontend/src/context/auth/MockAuthProvider.tsx b/frontend/src/context/auth/MockAuthProvider.tsx index f568c275..6266d0c2 100644 --- a/frontend/src/context/auth/MockAuthProvider.tsx +++ b/frontend/src/context/auth/MockAuthProvider.tsx @@ -22,6 +22,7 @@ export function MockAuthProvider({ children }: { children: ReactNode }) { const value: AuthContextValue = { user: { username: 'mock-user', + idirUsername: 'mock-user', displayName: 'Mock User', email: 'mock@example.com', roles: [`CSP_${role}`], diff --git a/frontend/src/context/auth/RealAuthProvider.tsx b/frontend/src/context/auth/RealAuthProvider.tsx index 995020c3..697e7e59 100644 --- a/frontend/src/context/auth/RealAuthProvider.tsx +++ b/frontend/src/context/auth/RealAuthProvider.tsx @@ -48,8 +48,14 @@ export function RealAuthProvider({ children }: { children: ReactNode }) { [payload['given_name'], payload['family_name']].filter(Boolean).join(' ') || undefined; + // The backend reads this same claim as the principal (JwtService#extractUsername), + // so it's what lands in audit fields such as entryUserID. + const idpUsernameClaim = payload['custom:idp_username']; + const idirUsername = typeof idpUsernameClaim === 'string' ? idpUsernameClaim.trim() : ''; + setUser({ username: String(payload['cognito:username'] ?? payload.sub ?? ''), + idirUsername: idirUsername || undefined, displayName: displayName || undefined, email: String(payload['email'] ?? ''), roles: groups, diff --git a/frontend/src/context/auth/types.ts b/frontend/src/context/auth/types.ts index ecfcc9ca..4267d585 100644 --- a/frontend/src/context/auth/types.ts +++ b/frontend/src/context/auth/types.ts @@ -2,6 +2,13 @@ import type { Role } from './permissions'; export interface AuthUser { username: string; + /** + * IDIR username from the `custom:idp_username` id-token claim (e.g. "JSMITH"). + * This is the value the backend resolves as the principal and writes to audit + * fields like `entryUserID`, so prefer it over {@link username} (a Cognito id) + * anywhere the UI shows who entered or submitted a record. + */ + idirUsername?: string; displayName?: string; email: string; /** Raw Cognito group names from the id-token (e.g. "CSP_SUBMITTER"). */ diff --git a/frontend/src/pages/Invoice/index.more.unit.test.tsx b/frontend/src/pages/Invoice/index.more.unit.test.tsx index 46c751d8..3289564a 100644 --- a/frontend/src/pages/Invoice/index.more.unit.test.tsx +++ b/frontend/src/pages/Invoice/index.more.unit.test.tsx @@ -25,6 +25,10 @@ const h = vi.hoisted(() => { getClientsByNumber: vi.fn(), getClientsByName: vi.fn(), usePermission: vi.fn((_action: string) => true), + authUser: { username: 'cognito-id', idirUsername: 'TESTUSER' } as { + username: string; + idirUsername?: string; + } | null, extractValidationErrors: vi.fn((_err: unknown): unknown[] => []), params: { id: undefined as string | undefined }, invoiceQuery: { data: undefined as unknown, isLoading: false }, @@ -57,6 +61,10 @@ vi.mock('@/context/auth/usePermission', () => ({ usePermission: (p: string) => h.usePermission(p), })); +vi.mock('@/context/auth/useAuth', () => ({ + useAuth: () => ({ user: h.authUser }), +})); + vi.mock('@/utils/report', () => ({ downloadBlob: (...args: unknown[]) => h.downloadBlob(...args), parseContentDispositionFilename: () => null, @@ -221,6 +229,7 @@ beforeEach(() => { h.params.id = undefined; h.invoiceQuery = { data: undefined, isLoading: false }; h.usePermission.mockReturnValue(true); + h.authUser = { username: 'cognito-id', idirUsername: 'TESTUSER' }; h.getClientsByNumber.mockResolvedValue([CLIENT]); h.getClientsByName.mockResolvedValue([CLIENT]); h.extractValidationErrors.mockReset(); diff --git a/frontend/src/pages/Invoice/index.scss b/frontend/src/pages/Invoice/index.scss index 571698c0..431be8df 100644 --- a/frontend/src/pages/Invoice/index.scss +++ b/frontend/src/pages/Invoice/index.scss @@ -145,6 +145,10 @@ width: 100% !important; } + .cds--label { + display: block; + } + &__group-actions { display: inline-flex; gap: $spacing-02; diff --git a/frontend/src/pages/Invoice/index.tsx b/frontend/src/pages/Invoice/index.tsx index 207b3ecb..d914d491 100644 --- a/frontend/src/pages/Invoice/index.tsx +++ b/frontend/src/pages/Invoice/index.tsx @@ -41,6 +41,7 @@ import TextArea from '@/components/Form/TextArea'; import { useNotification } from '@/context/notification/useNotification'; import { ROUTES } from '@/routes/routePaths'; import { useFobCodesQuery } from '@/services/fob.service'; +import { useAuth } from '@/context/auth/useAuth'; import { usePermission } from '@/context/auth/usePermission'; import { INVOICE_DETAILS_SAVE, @@ -79,9 +80,10 @@ import { useSpeciesLookupQuery, } from '@/services/lookup.service'; import { getClientsByNumber } from '@/services/search.service'; -import { formatCurrency, formatIsoDate, formatNumber } from '@/utils/format'; +import { formatCurrency, formatIsoDate, formatNumber, formatUsername } from '@/utils/format'; import { + computeLineAmount, validate as validateInvoiceFields, validateLineItem, type InvoiceFieldValues, @@ -253,6 +255,8 @@ export function InvoicePage() { const navigate = useNavigate(); const location = useLocation(); const { addNotification } = useNotification(); + // Signed-in user — used to preview the "Entered/Submitted by" IDIR on a new invoice. + const { user: currentUser } = useAuth(); const locationState = location.state as { fromSearch?: boolean } | null; const fromSearch = locationState?.fromSearch === true; @@ -411,7 +415,7 @@ export function InvoicePage() { // price × 0 = 0.00) instead of showing blank. const p = Number.parseFloat(newLinePrice) || 0; const v = Number.parseFloat(newLineVolume) || 0; - return (Math.round(p * v * 100) / 100).toFixed(2); + return computeLineAmount(v, p, invTypeCode).toFixed(2); })(); // Inline error / warning state @@ -539,6 +543,27 @@ export function InvoicePage() { const [editLineDraft, setEditLineDraft] = useState(null); const [editLineFieldErrors, setEditLineFieldErrors] = useState>({}); + // Live structural validation for the row currently being edited + const clientEditLineErrors = useMemo( + () => + editLineDraft + ? splitMessages( + validateLineItem({ + pieces: editLineDraft.numOfPieces, + volume: editLineDraft.volume, + price: editLineDraft.price, + invType: invTypeCode, + }).messages, + CLIENT_LINE_ITEM_MESSAGE_KEY_TO_FIELD, + ).fieldErrors + : {}, + [editLineDraft, invTypeCode], + ); + const displayEditLineErrors = useMemo( + () => relabelRecord({ ...editLineFieldErrors, ...clientEditLineErrors }), + [editLineFieldErrors, clientEditLineErrors, relabelRecord], + ); + // ----- Group edit modal state ----- type EditGroupDraft = { groupId: string; @@ -833,7 +858,15 @@ export function InvoicePage() { // invoice created from scratch there's no stored received date, so default the // "Date entered/received" to today (local yyyy-MM-dd). const dateInvoiceReceived = isExisting ? (loadedInvoice?.invoiceDate ?? '—') : formatIsoDate(new Date()); - const enteredSubmittedBy = loadedInvoice?.entryUserID ?? '—'; + // Same idea for "Entered/Submitted by": an existing invoice shows its stored + // entryUserID, while a brand-new one previews the signed-in user's IDIR — which + // is exactly what the backend will stamp on save (it takes the user from the + // token, so this is display-only and never sent in the request body). + // Both go through formatUsername so a legacy "IDIR\JSMITH" id renders as just + // the username. + const enteredSubmittedBy = isExisting + ? formatUsername(loadedInvoice?.entryUserID) + : formatUsername(currentUser?.idirUsername ?? currentUser?.username); // ------ Add New Line Item validity ------ const isAddLineItemValid = @@ -1198,46 +1231,31 @@ export function InvoicePage() { const groupColumns = useMemo[]>( () => [ { key: 'groupNumber', header: 'Group number' }, - { - key: 'secondarySort', - header: 'Secondary sort', - renderCell: (r) => {r.secondarySort}, - }, + { key: 'secondarySort', header: 'Secondary sort', headerAlign: 'center', cellAlign: 'center' }, { key: 'description', header: 'Description' }, - { - key: 'species', - header: 'Species', - headerAlign: 'center', - renderCell: (r) => {r.species}, - }, + { key: 'species', header: 'Species', headerAlign: 'center', cellAlign: 'center' }, { key: 'totalPieces', header: 'Total pieces', headerAlign: 'center', - renderCell: (r) => {formatNumber(r.totalPieces)}, + cellAlign: 'right', + renderCell: (r) => formatNumber(r.totalPieces), }, { key: 'totalVolume', header: 'Total volume', headerAlign: 'center', - renderCell: (r) => ( - {formatNumber(r.totalVolume, 3)} - ), + cellAlign: 'right', + renderCell: (r) => formatNumber(r.totalVolume, 3), }, { key: 'totalAmount', header: 'Total $ amount', headerAlign: 'center', - renderCell: (r) => ( - {formatCurrency(r.totalAmount)} - ), - }, - { - key: 'priceConversion', - header: 'Price conversion', - headerAlign: 'center', - renderCell: (r) => {r.priceConversion}, + cellAlign: 'right', + renderCell: (r) => formatCurrency(r.totalAmount), }, + { key: 'priceConversion', header: 'Price conversion', headerAlign: 'center', cellAlign: 'center' }, { key: 'id', header: 'Actions', @@ -1281,16 +1299,16 @@ export function InvoicePage() { - Invoice totals + Invoice totals - - {formatNumber(totalPieces)} + + {formatNumber(totalPieces)} - - {formatNumber(totalVolume, 3)} + + {formatNumber(totalVolume, 3)} - - {formatCurrency(totalAmount)} + + {formatCurrency(totalAmount)} @@ -1470,6 +1488,7 @@ export function InvoicePage() { const handleSaveLineEdit = () => { if (!invoiceId || !editLineDraft) return; + if (Object.keys(clientEditLineErrors).length > 0) return; const lineItemID = Number(editLineDraft.id); const body: LineItemRequest = { lineItemID, @@ -1753,7 +1772,7 @@ export function InvoicePage() { Total pieces - - {totalPieces > 0 ? formatNumber(totalPieces) : '—'} - + {hasLineItems ? formatNumber(totalPieces) : '—'} Total volume (m3) - {totalVolume > 0 ? formatNumber(totalVolume, 3) : '—'} + {hasLineItems ? formatNumber(totalVolume, 3) : '—'} Total amount - - {totalAmount > 0 ? formatCurrency(totalAmount) : '—'} - + {hasLineItems ? formatCurrency(totalAmount) : '—'} @@ -2097,7 +2112,8 @@ export function InvoicePage() { gradeItems={gradeItems} speciesGradeCombos={speciesGradeCombos} editDraft={editLineDraft} - fieldErrors={relabelRecord(editLineFieldErrors)} + fieldErrors={displayEditLineErrors} + invType={invTypeCode} onStartEdit={handleStartLineEdit} onCancelEdit={handleCancelLineEdit} onSaveEdit={handleSaveLineEdit} diff --git a/frontend/src/pages/Invoice/index.unit.test.tsx b/frontend/src/pages/Invoice/index.unit.test.tsx index b1872e05..00e32260 100644 --- a/frontend/src/pages/Invoice/index.unit.test.tsx +++ b/frontend/src/pages/Invoice/index.unit.test.tsx @@ -22,6 +22,10 @@ const h = vi.hoisted(() => { getClientsByNumber: vi.fn(), getClientsByName: vi.fn(), usePermission: vi.fn((_action: string) => true), + authUser: { username: 'cognito-id', idirUsername: 'TESTUSER' } as { + username: string; + idirUsername?: string; + } | null, params: { id: undefined as string | undefined }, invoiceQuery: { data: undefined as unknown, isLoading: false }, mutations: { @@ -53,6 +57,10 @@ vi.mock('@/context/auth/usePermission', () => ({ usePermission: (p: string) => h.usePermission(p), })); +vi.mock('@/context/auth/useAuth', () => ({ + useAuth: () => ({ user: h.authUser }), +})); + vi.mock('@/utils/report', () => ({ downloadBlob: (...args: unknown[]) => h.downloadBlob(...args), parseContentDispositionFilename: () => null, @@ -181,6 +189,7 @@ beforeEach(() => { h.params.id = undefined; h.invoiceQuery = { data: undefined, isLoading: false }; h.usePermission.mockReturnValue(true); + h.authUser = { username: 'cognito-id', idirUsername: 'TESTUSER' }; h.getClientsByNumber.mockResolvedValue([CLIENT]); h.getClientsByName.mockResolvedValue([CLIENT]); Object.values(h.mutations).forEach((m) => { @@ -214,6 +223,36 @@ describe('InvoicePage — rendering', () => { expect(screen.getByText('Old growth')).toBeInTheDocument(); }); + it('autofills "Entered/Submitted by" with the signed-in IDIR on a NEW invoice', () => { + renderPage(); + expect(screen.getByText('TESTUSER')).toBeInTheDocument(); + }); + + it('falls back to the Cognito username when no IDIR claim is present', () => { + h.authUser = { username: 'cognito-id' }; + renderPage(); + expect(screen.getByText('cognito-id')).toBeInTheDocument(); + }); + + it('shows a dash for "Entered/Submitted by" when there is no signed-in user', () => { + h.authUser = null; + const { container } = renderPage(); + const metaValues = Array.from(container.querySelectorAll('.invoice-page__meta-value')); + expect(metaValues.some((el) => el.textContent === '—')).toBe(true); + }); + + it('shows the stored entryUserID, not the signed-in user, on an existing invoice', async () => { + await renderLoaded(); + expect(screen.getByText('user1')).toBeInTheDocument(); + expect(screen.queryByText('TESTUSER')).not.toBeInTheDocument(); + }); + + it('strips the domain qualifier from a legacy entryUserID', async () => { + await renderLoaded({ entryUserID: 'IDIR\\JSMITH' }); + expect(screen.getByText('JSMITH')).toBeInTheDocument(); + expect(screen.queryByText('IDIR\\JSMITH')).not.toBeInTheDocument(); + }); + it('hydrates an existing invoice: number, status tag, line items', async () => { await renderLoaded(); expect(screen.getByDisplayValue('INV-001')).toBeInTheDocument(); @@ -497,7 +536,9 @@ describe('InvoicePage — warnings & errors', () => { ], }); expect( - screen.getByText('The combination of the submitter Client Number 123 and Client Location 00 cannot be found in CSP.'), + screen.getByText( + 'The combination of the submitter Client Number 123 and Client Location 00 cannot be found in CSP.', + ), ).toBeInTheDocument(); expect(document.getElementById('submitting-client-location')).toBeInvalid(); }); diff --git a/frontend/src/utils/format.ts b/frontend/src/utils/format.ts index fcfe0859..e0f5cc34 100644 --- a/frontend/src/utils/format.ts +++ b/frontend/src/utils/format.ts @@ -22,11 +22,28 @@ export const formatNumber = (value: number | null | undefined, decimals = 0): st * * @example formatCurrency(312) // "$312.00" * @example formatCurrency(11393.61) // "$11,393.61" + * @example formatCurrency(-25) // "-$25.00" * @example formatCurrency(null) // "—" */ export const formatCurrency = (value: number | null | undefined): string => { if (value === null || value === undefined || Number.isNaN(value)) return '—'; - return `$${formatNumber(value, 2)}`; + return value < 0 ? `-$${formatNumber(Math.abs(value), 2)}` : `$${formatNumber(value, 2)}`; +}; + +/** + * Strip the domain qualifier from an audit user id so only the username shows. + * Records created by the legacy app store a domain-qualified id ("IDIR\JSMITH"), + * while this app stores the bare username from the token — both should display + * the same way. Values without a qualifier are returned unchanged. + * + * @example formatUsername('IDIR\\JSMITH') // "JSMITH" + * @example formatUsername('JSMITH') // "JSMITH" + * @example formatUsername(null) // "—" + */ +export const formatUsername = (value: string | null | undefined): string => { + if (!value) return '—'; + const stripped = value.replace(/^.*[\\/]/, '').trim(); + return stripped || '—'; }; /** diff --git a/frontend/src/utils/format.unit.test.ts b/frontend/src/utils/format.unit.test.ts index 5f5be124..244d27f0 100644 --- a/frontend/src/utils/format.unit.test.ts +++ b/frontend/src/utils/format.unit.test.ts @@ -1,6 +1,13 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { formatCurrency, formatDisplayDate, formatDisplayDateV2, formatIsoDate, formatNumber } from './format'; +import { + formatCurrency, + formatDisplayDate, + formatDisplayDateV2, + formatIsoDate, + formatNumber, + formatUsername, +} from './format'; // ── formatNumber ──────────────────────────────────────────────────────────── @@ -29,6 +36,11 @@ describe('formatCurrency', () => { expect(formatCurrency(11393.61)).toBe('$11,393.61'); }); + it('puts the minus sign outside the dollar sign for negatives', () => { + expect(formatCurrency(-25)).toBe('-$25.00'); + expect(formatCurrency(-11393.61)).toBe('-$11,393.61'); + }); + it('renders an em-dash for null / undefined / NaN', () => { expect(formatCurrency(null)).toBe('—'); expect(formatCurrency(undefined)).toBe('—'); @@ -36,6 +48,26 @@ describe('formatCurrency', () => { }); }); +// ── formatUsername ────────────────────────────────────────────────────────── + +describe('formatUsername', () => { + it('strips a legacy domain qualifier', () => { + expect(formatUsername('IDIR\\JSMITH')).toBe('JSMITH'); + expect(formatUsername('IDIR/JSMITH')).toBe('JSMITH'); + }); + + it('leaves an unqualified username unchanged', () => { + expect(formatUsername('JSMITH')).toBe('JSMITH'); + }); + + it('renders an em-dash for null / undefined / empty / qualifier-only values', () => { + expect(formatUsername(null)).toBe('—'); + expect(formatUsername(undefined)).toBe('—'); + expect(formatUsername('')).toBe('—'); + expect(formatUsername('IDIR\\')).toBe('—'); + }); +}); + // ── formatDisplayDate ─────────────────────────────────────────────────────── describe('formatDisplayDate', () => { diff --git a/frontend/src/validations/invoice/invoice.ts b/frontend/src/validations/invoice/invoice.ts index 45b0f284..1dfeff87 100644 --- a/frontend/src/validations/invoice/invoice.ts +++ b/frontend/src/validations/invoice/invoice.ts @@ -99,7 +99,20 @@ export interface LineItemFieldValues { invType: string; } -const INVTYPE_ADJUST = 'ADJ'; +export const INVTYPE_ADJUST = 'ADJ'; + +/** + * A line item's `$Amount` preview — volume × price, rounded to 2dp. + * + * @param volume the line's volume + * @param price the line's price + * @param invType the parent invoice's type code, e.g. 'ADJ' + * @returns the signed amount rounded to 2 decimal places + */ +export const computeLineAmount = (volume: number, price: number, invType: string): number => { + const effectiveVolume = invType === INVTYPE_ADJUST && volume < 0 && price < 0 ? Math.abs(volume) : volume; + return Math.round(effectiveVolume * price * 100) / 100; +}; export function validateLineItem(values: LineItemFieldValues): ValidationResult { const messages = new MessageCollector(); diff --git a/frontend/src/validations/invoice/invoice.unit.test.ts b/frontend/src/validations/invoice/invoice.unit.test.ts index e8d60e01..185c2ec6 100644 --- a/frontend/src/validations/invoice/invoice.unit.test.ts +++ b/frontend/src/validations/invoice/invoice.unit.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from 'vitest'; -import { validate, validateLineItem, type InvoiceFieldValues, type LineItemFieldValues } from './invoice'; +import { + computeLineAmount, + validate, + validateLineItem, + type InvoiceFieldValues, + type LineItemFieldValues, +} from './invoice'; const validInvoice = (): InvoiceFieldValues => ({ invNumber: 'INV-100', @@ -103,3 +109,27 @@ describe('validateLineItem', () => { ]); }); }); + +describe('computeLineAmount', () => { + it('multiplies volume by price and rounds to 2dp', () => { + expect(computeLineAmount(6.25, 25, 'SAL')).toBe(156.25); + expect(computeLineAmount(3, 3.333, 'SAL')).toBe(10); + }); + + // Adjustment invoices are the only type allowed negative volume/price, and the + // amount must stay negative rather than multiplying out to a positive. Mirrors + // the backend LineAmount.compute and legacy Utils.bigDecimalMultiplicationForAdj. + it.each([ + { volume: 5, price: 5, expected: 25 }, + { volume: -5, price: 5, expected: -25 }, + { volume: 5, price: -5, expected: -25 }, + { volume: -5, price: -5, expected: -25 }, + ])('ADJ: volume $volume x price $price is $expected', ({ volume, price, expected }) => { + expect(computeLineAmount(volume, price, 'ADJ')).toBe(expected); + }); + + it('does not apply the adjustment sign rule to other invoice types', () => { + expect(computeLineAmount(-5, -5, 'SAL')).toBe(25); + expect(computeLineAmount(-5, -5, 'PUR')).toBe(25); + }); +});