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);
+ });
+});