Stabilize refunds

This commit is contained in:
2026-03-30 09:17:22 -06:00
parent 07cd4f6c0e
commit 31a11656c4
4 changed files with 236 additions and 87 deletions

View File

@@ -9,15 +9,14 @@ import java.util.List;
public class PageResponse<T> {
private List<T> content;
@JsonProperty("number")
private int pageNumber;
@JsonProperty("size")
private int pageSize;
private long totalElements;
private int totalPages;
private boolean last;
private PageMetadata page;
public PageResponse() {
}
@@ -63,10 +62,71 @@ public class PageResponse<T> {
}
public boolean isLast() {
return last;
if (last) {
return true;
}
if (page != null) {
return page.number >= Math.max(0, page.totalPages - 1);
}
return content == null || content.isEmpty();
}
public void setLast(boolean last) {
this.last = last;
}
public PageMetadata getPage() {
return page;
}
public void setPage(PageMetadata page) {
this.page = page;
if (page != null) {
this.pageNumber = page.number;
this.pageSize = page.size;
this.totalElements = page.totalElements;
this.totalPages = page.totalPages;
this.last = page.number >= Math.max(0, page.totalPages - 1);
}
}
@JsonIgnoreProperties(ignoreUnknown = true)
public static class PageMetadata {
private int size;
private int number;
private long totalElements;
private int totalPages;
public int getSize() {
return size;
}
public void setSize(int size) {
this.size = size;
}
public int getNumber() {
return number;
}
public void setNumber(int number) {
this.number = number;
}
public long getTotalElements() {
return totalElements;
}
public void setTotalElements(long totalElements) {
this.totalElements = totalElements;
}
public int getTotalPages() {
return totalPages;
}
public void setTotalPages(int totalPages) {
this.totalPages = totalPages;
}
}
}

View File

@@ -38,6 +38,39 @@ public class SaleApi {
return pageResponse.getContent();
}
public List<SaleResponse> listAllSales(String query) throws Exception {
int page = 0;
int size = 250;
List<SaleResponse> allSales = new java.util.ArrayList<>();
while (true) {
String path = "/api/v1/sales?page=" + page + "&size=" + size;
if (query != null && !query.isEmpty()) {
path += "&q=" + URLEncoder.encode(query, StandardCharsets.UTF_8);
}
String response = apiClient.getRawResponse(path);
PageResponse<SaleResponse> pageResponse = apiClient.getObjectMapper().readValue(
response,
new TypeReference<PageResponse<SaleResponse>>() {}
);
if (pageResponse == null) {
throw new IllegalStateException("Null response from sales endpoint");
}
if (pageResponse.getContent() != null) {
allSales.addAll(pageResponse.getContent());
}
if (pageResponse.isLast()) {
break;
}
page++;
}
return allSales;
}
public SaleResponse getSale(Long id) throws Exception {
return apiClient.get("/api/v1/sales/" + id, SaleResponse.class);
}

View File

@@ -4,8 +4,12 @@ import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.concurrent.Task;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.beans.property.ReadOnlyStringWrapper;
import javafx.scene.control.*;
import javafx.scene.control.cell.PropertyValueFactory;
import javafx.application.Platform;
import javafx.stage.Stage;
import org.example.petshopdesktop.api.dto.sale.SaleItemRequest;
import org.example.petshopdesktop.api.dto.sale.SaleItemResponse;
@@ -90,6 +94,7 @@ public class RefundDialogController {
private final ObservableList<SaleItemResponse> originalItems = FXCollections.observableArrayList();
private final ObservableList<RefundItem> refundItems = FXCollections.observableArrayList();
private final NumberFormat currency = NumberFormat.getCurrencyInstance(Locale.CANADA);
private boolean refundInProgress;
@FXML
public void initialize() {
@@ -100,17 +105,19 @@ public class RefundDialogController {
}
private void setupTables() {
colOriginalProduct.setCellValueFactory(new PropertyValueFactory<>("productName"));
colOriginalQuantity.setCellValueFactory(new PropertyValueFactory<>("quantity"));
colOriginalUnitPrice.setCellValueFactory(new PropertyValueFactory<>("unitPrice"));
colOriginalTotal.setCellValueFactory(new PropertyValueFactory<>("lineTotal"));
tvOriginalItems.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY);
colOriginalProduct.setCellValueFactory(cell -> new ReadOnlyStringWrapper(cell.getValue().getProductName()));
colOriginalQuantity.setCellValueFactory(cell -> new ReadOnlyObjectWrapper<>(cell.getValue().getQuantity()));
colOriginalUnitPrice.setCellValueFactory(cell -> new ReadOnlyObjectWrapper<>(cell.getValue().getUnitPrice()));
colOriginalTotal.setCellValueFactory(cell -> new ReadOnlyObjectWrapper<>(cell.getValue().getLineTotal()));
tvOriginalItems.setItems(originalItems);
tvOriginalItems.getSelectionModel().setSelectionMode(SelectionMode.SINGLE);
colRefundProduct.setCellValueFactory(new PropertyValueFactory<>("productName"));
colRefundQuantity.setCellValueFactory(new PropertyValueFactory<>("quantity"));
colRefundUnitPrice.setCellValueFactory(new PropertyValueFactory<>("unitPrice"));
colRefundTotal.setCellValueFactory(new PropertyValueFactory<>("total"));
tvRefundItems.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY);
colRefundProduct.setCellValueFactory(cell -> new ReadOnlyStringWrapper(cell.getValue().getProductName()));
colRefundQuantity.setCellValueFactory(cell -> new ReadOnlyObjectWrapper<>(cell.getValue().getQuantity()));
colRefundUnitPrice.setCellValueFactory(cell -> new ReadOnlyObjectWrapper<>(cell.getValue().getUnitPrice()));
colRefundTotal.setCellValueFactory(cell -> new ReadOnlyObjectWrapper<>(cell.getValue().getTotal()));
tvRefundItems.setItems(refundItems);
tvRefundItems.getSelectionModel().setSelectionMode(SelectionMode.SINGLE);
}
@@ -125,12 +132,13 @@ public class RefundDialogController {
return;
}
txtSaleId.setText(String.valueOf(saleId));
loadSale();
Platform.runLater(this::loadSale);
}
private void loadSale() {
String saleIdText = txtSaleId.getText().trim();
if (saleIdText.isEmpty()) {
clearLoadedSale();
showError("Load Sale", "Enter a transaction ID.");
return;
}
@@ -139,22 +147,36 @@ public class RefundDialogController {
try {
saleId = Long.parseLong(saleIdText);
} catch (NumberFormatException e) {
clearLoadedSale();
showError("Load Sale", "Invalid transaction ID.");
return;
}
try {
List<SaleResponse> allSales = SaleApi.getInstance().listSales(0, 1000, null);
currentSale = SaleApi.getInstance().getSale(saleId);
if (Boolean.TRUE.equals(currentSale.getIsRefund())) {
clearLoadedSale();
showError("Load Sale", "Select an original sale, not a refund record.");
return;
}
List<SaleResponse> previousRefunds = allSales.stream()
.filter(s -> Boolean.TRUE.equals(s.getIsRefund()) && saleId.equals(s.getOriginalSaleId()))
.collect(Collectors.toList());
setLoadingState(true, "Loading sale...");
clearLoadedSale();
Task<LoadedSaleData> task = new Task<>() {
@Override
protected LoadedSaleData call() throws Exception {
List<SaleResponse> allSales = SaleApi.getInstance().listAllSales(null);
SaleResponse sale = SaleApi.getInstance().getSale(saleId);
if (Boolean.TRUE.equals(sale.getIsRefund())) {
throw new IllegalStateException("Select an original sale, not a refund record.");
}
List<SaleResponse> previousRefunds = allSales.stream()
.filter(s -> Boolean.TRUE.equals(s.getIsRefund()) && saleId.equals(s.getOriginalSaleId()))
.collect(Collectors.toList());
List<SaleItemResponse> refundableItems = buildRefundableItems(sale, previousRefunds);
if (refundableItems.isEmpty()) {
throw new IllegalStateException("This sale has no remaining refundable items.");
}
return new LoadedSaleData(sale, refundableItems);
}
};
task.setOnSucceeded(event -> {
LoadedSaleData loaded = task.getValue();
currentSale = loaded.sale();
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
String saleInfo = String.format("Sale Date: %s | Employee: %s | Original Total: %s | Payment: %s",
currentSale.getSaleDate().format(formatter),
@@ -162,26 +184,25 @@ public class RefundDialogController {
currency.format(currentSale.getTotalAmount()),
currentSale.getPaymentMethod());
lblSaleInfo.setText(saleInfo);
List<SaleItemResponse> refundableItems = buildRefundableItems(currentSale, previousRefunds);
if (refundableItems.isEmpty()) {
showError("Load Sale", "This sale has no remaining refundable items.");
return;
}
baseOriginalItems.clear();
baseOriginalItems.addAll(copySaleItems(refundableItems));
originalItems.setAll(copySaleItems(refundableItems));
baseOriginalItems.addAll(copySaleItems(loaded.refundableItems()));
originalItems.setAll(copySaleItems(loaded.refundableItems()));
cbPaymentMethod.getSelectionModel().select(currentSale.getPaymentMethod());
refundItems.clear();
updateOriginalItemAvailability();
updateRefundTotal();
setLoadingState(false, saleInfo);
});
} catch (Exception e) {
ActivityLogger.getInstance().logException("RefundDialogController.btnLoadSaleClicked", e, "Loading sale");
task.setOnFailed(event -> {
clearLoadedSale();
Throwable e = task.getException();
ActivityLogger.getInstance().logException("RefundDialogController.btnLoadSaleClicked", (Exception) e, "Loading sale");
setLoadingState(false, "");
showError("Load Sale", e.getMessage() != null ? e.getMessage() : "Could not load sale.");
}
});
new Thread(task).start();
}
@FXML
@@ -248,6 +269,9 @@ public class RefundDialogController {
@FXML
void btnProcessRefundClicked(ActionEvent event) {
if (refundInProgress) {
return;
}
if (currentSale == null) {
showError("Process Refund", "Load a sale first.");
return;
@@ -280,36 +304,53 @@ public class RefundDialogController {
return;
}
try {
SaleRequest request = new SaleRequest();
request.setStoreId(storeId);
request.setPaymentMethod(payment);
request.setIsRefund(true);
request.setOriginalSaleId(currentSale.getSaleId());
SaleRequest request = new SaleRequest();
request.setStoreId(storeId);
request.setPaymentMethod(payment);
request.setIsRefund(true);
request.setOriginalSaleId(currentSale.getSaleId());
List<SaleItemRequest> items = new ArrayList<>();
for (RefundItem item : refundItems) {
SaleItemRequest saleItem = new SaleItemRequest();
saleItem.setProdId((long) item.getProdId());
saleItem.setQuantity(-item.getQuantity());
items.add(saleItem);
List<SaleItemRequest> items = new ArrayList<>();
for (RefundItem item : refundItems) {
SaleItemRequest saleItem = new SaleItemRequest();
saleItem.setProdId((long) item.getProdId());
saleItem.setQuantity(-item.getQuantity());
items.add(saleItem);
}
request.setItems(items);
refundInProgress = true;
setLoadingState(true, lblSaleInfo.getText());
Task<SaleResponse> task = new Task<>() {
@Override
protected SaleResponse call() throws Exception {
return SaleApi.getInstance().createSale(request);
}
request.setItems(items);
SaleResponse refundResponse = SaleApi.getInstance().createSale(request);
};
task.setOnSucceeded(evt -> {
refundInProgress = false;
setLoadingState(false, lblSaleInfo.getText());
SaleResponse refundResponse = task.getValue();
Alert success = new Alert(Alert.AlertType.INFORMATION);
success.setTitle("Refund Processed");
success.setHeaderText(null);
success.setContentText("Refund ID " + refundResponse.getSaleId() + " was created successfully.");
success.showAndWait();
closeDialog();
});
} catch (Exception e) {
ActivityLogger.getInstance().logException("RefundDialogController.btnProcessRefundClicked", e, "Processing refund");
showError("Process Refund", e.getMessage() != null ? e.getMessage() : "Could not process refund.");
}
task.setOnFailed(evt -> {
refundInProgress = false;
setLoadingState(false, lblSaleInfo.getText());
Throwable e = task.getException();
Exception ex = e instanceof Exception ? (Exception) e : new RuntimeException(e);
ActivityLogger.getInstance().logException("RefundDialogController.btnProcessRefundClicked", ex, "Processing refund");
showError("Process Refund", e != null && e.getMessage() != null ? e.getMessage() : "Could not process refund.");
});
new Thread(task).start();
}
@FXML
@@ -326,6 +367,18 @@ public class RefundDialogController {
updateRefundTotal();
}
private void setLoadingState(boolean loading, String message) {
btnLoadSale.setDisable(loading);
btnAddToRefund.setDisable(loading);
btnRemoveFromRefund.setDisable(loading);
btnProcessRefund.setDisable(loading);
txtSaleId.setDisable(loading);
cbPaymentMethod.setDisable(loading);
if (loading) {
lblSaleInfo.setText(message);
}
}
private void addOrMergeRefundItem(SaleItemResponse selected, int quantity) {
for (int i = 0; i < refundItems.size(); i++) {
RefundItem existing = refundItems.get(i);
@@ -486,4 +539,7 @@ public class RefundDialogController {
return quantity * unitPrice;
}
}
private record LoadedSaleData(SaleResponse sale, List<SaleItemResponse> refundableItems) {
}
}

View File

@@ -13,7 +13,7 @@
<?import javafx.scene.layout.VBox?>
<?import javafx.scene.text.Font?>
<VBox minHeight="-Infinity" minWidth="-Infinity" prefHeight="700.0" prefWidth="900.0" spacing="20.0" style="-fx-font-size: 14px;" xmlns="http://javafx.com/javafx/17" xmlns:fx="http://javafx.com/fxml/1" fx:controller="org.example.petshopdesktop.controllers.dialogcontrollers.RefundDialogController">
<VBox minHeight="-Infinity" minWidth="-Infinity" prefHeight="720.0" prefWidth="920.0" spacing="22.0" style="-fx-font-size: 14px;" xmlns="http://javafx.com/javafx/17" xmlns:fx="http://javafx.com/fxml/1" fx:controller="org.example.petshopdesktop.controllers.dialogcontrollers.RefundDialogController">
<padding>
<Insets bottom="20.0" left="20.0" right="20.0" top="20.0" />
</padding>
@@ -84,14 +84,14 @@
</children>
</VBox>
<VBox spacing="10.0" style="-fx-background-color: white; -fx-background-radius: 14; -fx-border-width: 2; -fx-border-color: #FF6b6b; -fx-border-radius: 14;" VBox.vgrow="ALWAYS">
<padding>
<Insets bottom="15.0" left="15.0" right="15.0" top="15.0" />
</padding>
<children>
<HBox alignment="CENTER_LEFT" spacing="10.0">
<children>
<Label text="Original Items" textFill="#2c3e50">
<VBox spacing="18.0" style="-fx-background-color: white; -fx-background-radius: 14; -fx-border-width: 2; -fx-border-color: #FF6b6b; -fx-border-radius: 14;" VBox.vgrow="ALWAYS">
<padding>
<Insets bottom="18.0" left="18.0" right="18.0" top="18.0" />
</padding>
<children>
<HBox alignment="CENTER_LEFT" spacing="14.0">
<children>
<Label text="Original Items" textFill="#2c3e50">
<font>
<Font name="System Bold" size="16.0" />
</font>
@@ -107,20 +107,20 @@
</Button>
</children>
</HBox>
<TableView fx:id="tvOriginalItems" prefHeight="150.0" style="-fx-background-color: white; -fx-background-radius: 10;">
<columns>
<TableColumn fx:id="colOriginalProduct" prefWidth="350.0" text="Product Name" />
<TableColumn fx:id="colOriginalQuantity" prefWidth="120.0" text="Quantity" />
<TableColumn fx:id="colOriginalUnitPrice" prefWidth="150.0" text="Unit Price" />
<TableColumn fx:id="colOriginalTotal" prefWidth="150.0" text="Total" />
</columns>
</TableView>
<TableView fx:id="tvOriginalItems" prefHeight="190.0" style="-fx-background-color: white; -fx-background-radius: 10; -fx-border-color: #E8EBED; -fx-border-radius: 10;">
<columns>
<TableColumn fx:id="colOriginalProduct" prefWidth="430.0" text="Product Name" />
<TableColumn fx:id="colOriginalQuantity" prefWidth="140.0" text="Quantity" />
<TableColumn fx:id="colOriginalUnitPrice" prefWidth="170.0" text="Unit Price" />
<TableColumn fx:id="colOriginalTotal" prefWidth="170.0" text="Total" />
</columns>
</TableView>
<Separator />
<Separator />
<HBox alignment="CENTER_LEFT" spacing="10.0">
<children>
<Label text="Refund Items" textFill="#2c3e50">
<HBox alignment="CENTER_LEFT" spacing="14.0">
<children>
<Label text="Refund Items" textFill="#2c3e50">
<font>
<Font name="System Bold" size="16.0" />
</font>
@@ -136,16 +136,16 @@
</Button>
</children>
</HBox>
<TableView fx:id="tvRefundItems" prefHeight="150.0" style="-fx-background-color: white; -fx-background-radius: 10;">
<columns>
<TableColumn fx:id="colRefundProduct" prefWidth="350.0" text="Product Name" />
<TableColumn fx:id="colRefundQuantity" prefWidth="120.0" text="Quantity" />
<TableColumn fx:id="colRefundUnitPrice" prefWidth="150.0" text="Unit Price" />
<TableColumn fx:id="colRefundTotal" prefWidth="150.0" text="Total" />
</columns>
</TableView>
</children>
</VBox>
<TableView fx:id="tvRefundItems" prefHeight="190.0" style="-fx-background-color: white; -fx-background-radius: 10; -fx-border-color: #E8EBED; -fx-border-radius: 10;" VBox.vgrow="ALWAYS">
<columns>
<TableColumn fx:id="colRefundProduct" prefWidth="430.0" text="Product Name" />
<TableColumn fx:id="colRefundQuantity" prefWidth="140.0" text="Quantity" />
<TableColumn fx:id="colRefundUnitPrice" prefWidth="170.0" text="Unit Price" />
<TableColumn fx:id="colRefundTotal" prefWidth="170.0" text="Total" />
</columns>
</TableView>
</children>
</VBox>
<VBox spacing="12.0" style="-fx-background-color: white; -fx-background-radius: 14; -fx-border-width: 2; -fx-border-color: #FF6b6b; -fx-border-radius: 14;">
<padding>