From da0cf48dc35cab3a8bf52fe3cd96e81631c59ff3 Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Tue, 7 Jul 2026 21:55:55 +0200 Subject: [PATCH 1/4] feat: persist splitMode/splitPercentage and list defaultSplitPercentage Co-Authored-By: Claude Opus 4.8 --- .../app/xpensely_server/model/Expense.java | 8 +++++++ .../model/ExpenseChangeRequest.java | 5 ++++ .../xpensely_server/model/ExpenseInput.java | 5 ++++ .../xpensely_server/model/ExpenseList.java | 2 ++ .../app/xpensely_server/model/SplitMode.java | 6 +++++ .../services/ExpenseListService.java | 4 ++++ .../services/ExpenseListServiceTest.java | 24 +++++++++++++++++++ 7 files changed, 54 insertions(+) create mode 100644 src/main/java/de/zendric/app/xpensely_server/model/SplitMode.java diff --git a/src/main/java/de/zendric/app/xpensely_server/model/Expense.java b/src/main/java/de/zendric/app/xpensely_server/model/Expense.java index 34bc45f..59983b3 100644 --- a/src/main/java/de/zendric/app/xpensely_server/model/Expense.java +++ b/src/main/java/de/zendric/app/xpensely_server/model/Expense.java @@ -6,6 +6,8 @@ import java.time.LocalDateTime; import com.fasterxml.jackson.annotation.JsonBackReference; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; @@ -36,6 +38,12 @@ public class Expense { private Double personalUseAmount; private Double otherPersonAmount; private String category; + + @Enumerated(EnumType.STRING) + private SplitMode splitMode; + + private Integer splitPercentage; + private LocalDate date; private LocalDateTime lastModified; diff --git a/src/main/java/de/zendric/app/xpensely_server/model/ExpenseChangeRequest.java b/src/main/java/de/zendric/app/xpensely_server/model/ExpenseChangeRequest.java index 561ff11..1414a03 100644 --- a/src/main/java/de/zendric/app/xpensely_server/model/ExpenseChangeRequest.java +++ b/src/main/java/de/zendric/app/xpensely_server/model/ExpenseChangeRequest.java @@ -33,6 +33,9 @@ public class ExpenseChangeRequest { private Double personalUseAmount; private Double otherPersonAmount; + private SplitMode splitMode; + private Integer splitPercentage; + @NotNull(message = "Date is required") private LocalDate date; @@ -55,6 +58,8 @@ public class ExpenseChangeRequest { // The edit dialog sends "" for a missing category; store null so the // stub state stays uniform and history diffs don't report null -> "". expense.setCategory(category == null || category.isBlank() ? null : category); + expense.setSplitMode(splitMode == null ? SplitMode.DETAILED : splitMode); + expense.setSplitPercentage(splitPercentage); return expense; } diff --git a/src/main/java/de/zendric/app/xpensely_server/model/ExpenseInput.java b/src/main/java/de/zendric/app/xpensely_server/model/ExpenseInput.java index ee11b4b..00531fa 100644 --- a/src/main/java/de/zendric/app/xpensely_server/model/ExpenseInput.java +++ b/src/main/java/de/zendric/app/xpensely_server/model/ExpenseInput.java @@ -33,6 +33,9 @@ public class ExpenseInput { private Double personalUseAmount; private Double otherPersonAmount; + private SplitMode splitMode; + private Integer splitPercentage; + @NotNull(message = "Date is required") private LocalDate date; @@ -56,6 +59,8 @@ public class ExpenseInput { expense.setOwner(appUser); expense.setTitle(title); expense.setCategory(category); + expense.setSplitMode(splitMode == null ? SplitMode.DETAILED : splitMode); + expense.setSplitPercentage(splitPercentage); return expense; } diff --git a/src/main/java/de/zendric/app/xpensely_server/model/ExpenseList.java b/src/main/java/de/zendric/app/xpensely_server/model/ExpenseList.java index 4902f2f..3b4db33 100644 --- a/src/main/java/de/zendric/app/xpensely_server/model/ExpenseList.java +++ b/src/main/java/de/zendric/app/xpensely_server/model/ExpenseList.java @@ -31,6 +31,8 @@ public class ExpenseList { private String name; + private Integer defaultSplitPercentage = 50; + private String inviteCode; @JsonIgnore private LocalDateTime inviteCodeExpiration; diff --git a/src/main/java/de/zendric/app/xpensely_server/model/SplitMode.java b/src/main/java/de/zendric/app/xpensely_server/model/SplitMode.java new file mode 100644 index 0000000..4c48bfb --- /dev/null +++ b/src/main/java/de/zendric/app/xpensely_server/model/SplitMode.java @@ -0,0 +1,6 @@ +package de.zendric.app.xpensely_server.model; + +public enum SplitMode { + DETAILED, + PERCENTAGE +} diff --git a/src/main/java/de/zendric/app/xpensely_server/services/ExpenseListService.java b/src/main/java/de/zendric/app/xpensely_server/services/ExpenseListService.java index 57756e7..e76c1b2 100644 --- a/src/main/java/de/zendric/app/xpensely_server/services/ExpenseListService.java +++ b/src/main/java/de/zendric/app/xpensely_server/services/ExpenseListService.java @@ -137,6 +137,8 @@ public class ExpenseListService { existingExpense.setDate(updatedExpense.getDate()); existingExpense.setOwner(updatedExpense.getOwner()); existingExpense.setCategory(updatedExpense.getCategory()); + existingExpense.setSplitMode(updatedExpense.getSplitMode()); + existingExpense.setSplitPercentage(updatedExpense.getSplitPercentage()); existingExpense.setLastModified(LocalDateTime.now()); Expense saved = expenseRepository.save(existingExpense); @@ -153,6 +155,8 @@ public class ExpenseListService { copy.setPersonalUseAmount(e.getPersonalUseAmount()); copy.setOtherPersonAmount(e.getOtherPersonAmount()); copy.setCategory(e.getCategory()); + copy.setSplitMode(e.getSplitMode()); + copy.setSplitPercentage(e.getSplitPercentage()); copy.setDate(e.getDate()); copy.setOwner(e.getOwner()); return copy; diff --git a/src/test/java/de/zendric/app/xpensely_Server/services/ExpenseListServiceTest.java b/src/test/java/de/zendric/app/xpensely_Server/services/ExpenseListServiceTest.java index 7c30ef6..5f90ed1 100644 --- a/src/test/java/de/zendric/app/xpensely_Server/services/ExpenseListServiceTest.java +++ b/src/test/java/de/zendric/app/xpensely_Server/services/ExpenseListServiceTest.java @@ -3,6 +3,7 @@ package de.zendric.app.xpensely_Server.services; import de.zendric.app.xpensely_server.model.AppUser; import de.zendric.app.xpensely_server.model.Expense; import de.zendric.app.xpensely_server.model.ExpenseList; +import de.zendric.app.xpensely_server.model.SplitMode; import de.zendric.app.xpensely_server.repo.ExpenseListRepository; import de.zendric.app.xpensely_server.repo.ExpenseRepository; import de.zendric.app.xpensely_server.repo.XpenselyCustomCategoryRepository; @@ -109,6 +110,29 @@ class ExpenseListServiceTest { assertThat(beforeCaptor.getValue().getAmount()).isEqualTo(12.0); } + @Test + void updateExpense_persistsSplitModeAndPercentage() { + AppUser actor = new AppUser(); actor.setId(1L); + ExpenseList list = new ExpenseList(); list.setId(10L); list.setOwner(actor); + Expense existing = makeExpense(5L, "Groceries", 100.0); + list.setExpenses(new ArrayList<>(List.of(existing))); + + Expense incoming = makeExpense(5L, "Groceries", 100.0); + incoming.setDate(LocalDate.now()); + incoming.setOwner(list.getOwner()); + incoming.setSplitMode(SplitMode.PERCENTAGE); + incoming.setSplitPercentage(70); + + when(repository.findById(10L)).thenReturn(Optional.of(list)); + when(expenseRepository.findById(5L)).thenReturn(Optional.of(existing)); + when(expenseRepository.save(any(Expense.class))).thenAnswer(inv -> inv.getArgument(0)); + + Expense saved = service.updateExpense(10L, incoming, actor); + + assertThat(saved.getSplitMode()).isEqualTo(SplitMode.PERCENTAGE); + assertThat(saved.getSplitPercentage()).isEqualTo(70); + } + @Test void deleteById_deletesHistoryEntriesBeforeDeletingList() { service.deleteById(10L); From a3d66129ac43ec78c422e54d073f9277ac3f47bf Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Tue, 7 Jul 2026 21:59:30 +0200 Subject: [PATCH 2/4] feat: record split mode/percentage changes in history diff --- .../services/HistoryService.java | 4 ++++ .../services/HistoryServiceTest.java | 17 +++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/src/main/java/de/zendric/app/xpensely_server/services/HistoryService.java b/src/main/java/de/zendric/app/xpensely_server/services/HistoryService.java index 6ea8ebf..a9b5df3 100644 --- a/src/main/java/de/zendric/app/xpensely_server/services/HistoryService.java +++ b/src/main/java/de/zendric/app/xpensely_server/services/HistoryService.java @@ -69,6 +69,10 @@ public class HistoryService { addIfChanged(changes, "personalUseAmount", before.getPersonalUseAmount(), after.getPersonalUseAmount()); addIfChanged(changes, "otherPersonAmount", before.getOtherPersonAmount(), after.getOtherPersonAmount()); addIfChanged(changes, "category", before.getCategory(), after.getCategory()); + addIfChanged(changes, "splitMode", + before.getSplitMode() == null ? null : before.getSplitMode().name(), + after.getSplitMode() == null ? null : after.getSplitMode().name()); + addIfChanged(changes, "splitPercentage", before.getSplitPercentage(), after.getSplitPercentage()); addIfChanged(changes, "date", before.getDate(), after.getDate()); addIfChanged(changes, "owner", before.getOwner() == null ? null : before.getOwner().getUsername(), diff --git a/src/test/java/de/zendric/app/xpensely_Server/services/HistoryServiceTest.java b/src/test/java/de/zendric/app/xpensely_Server/services/HistoryServiceTest.java index 7301a35..4528dd1 100644 --- a/src/test/java/de/zendric/app/xpensely_Server/services/HistoryServiceTest.java +++ b/src/test/java/de/zendric/app/xpensely_Server/services/HistoryServiceTest.java @@ -24,6 +24,7 @@ import de.zendric.app.xpensely_server.model.ExpenseHistoryEntry; import de.zendric.app.xpensely_server.model.ExpenseList; import de.zendric.app.xpensely_server.model.FieldChange; import de.zendric.app.xpensely_server.model.HistoryEntryType; +import de.zendric.app.xpensely_server.model.SplitMode; import de.zendric.app.xpensely_server.repo.ExpenseHistoryRepository; import de.zendric.app.xpensely_server.services.HistoryService; @@ -86,6 +87,22 @@ class HistoryServiceTest { assertThat(changes).containsExactly(new FieldChange("owner", "alice", "ben")); } + @Test + void diff_reportsSplitModeAndPercentageChanges() { + Expense before = new Expense(); + before.setSplitMode(SplitMode.DETAILED); + before.setSplitPercentage(null); + + Expense after = new Expense(); + after.setSplitMode(SplitMode.PERCENTAGE); + after.setSplitPercentage(70); + + List changes = service.diff(before, after); + + assertThat(changes).contains(new FieldChange("splitMode", "DETAILED", "PERCENTAGE")); + assertThat(changes).contains(new FieldChange("splitPercentage", null, "70")); + } + @Test void diff_nullToValue_isReported() { Expense before = expense("Taxi", 9.0, null, LocalDate.of(2026, 7, 1), user(1L, "alice")); From 693289525e180d5f566a71d2dc1e1057e9b04a38 Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Tue, 7 Jul 2026 22:03:24 +0200 Subject: [PATCH 3/4] feat: add PUT /{id}/default-split endpoint --- .../controller/ExpenseListController.java | 12 ++++++++++++ .../model/DefaultSplitRequest.java | 17 +++++++++++++++++ .../services/ExpenseListService.java | 7 +++++++ .../controller/ExpenseListControllerTest.java | 19 +++++++++++++++++++ 4 files changed, 55 insertions(+) create mode 100644 src/main/java/de/zendric/app/xpensely_server/model/DefaultSplitRequest.java diff --git a/src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java b/src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java index 13b6606..85d2d7a 100644 --- a/src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java +++ b/src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java @@ -98,6 +98,18 @@ public class ExpenseListController { return new ResponseEntity<>(renamed, HttpStatus.OK); } + @PutMapping("/{id}/default-split") + public ResponseEntity updateDefaultSplit(@PathVariable("id") Long id, + @RequestBody @Valid DefaultSplitRequest request, Authentication authentication) { + AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication); + Optional listOpt = expenseListService.findById(id); + if (listOpt.isEmpty()) + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + assertMember(user, listOpt.get()); + ExpenseList updated = expenseListService.updateDefaultSplit(id, request.getPercentage()); + return new ResponseEntity<>(updated, HttpStatus.OK); + } + @PostMapping("/{id}/add") public ResponseEntity addExpenseToList( @PathVariable("id") Long expenseListId, diff --git a/src/main/java/de/zendric/app/xpensely_server/model/DefaultSplitRequest.java b/src/main/java/de/zendric/app/xpensely_server/model/DefaultSplitRequest.java new file mode 100644 index 0000000..6f10a88 --- /dev/null +++ b/src/main/java/de/zendric/app/xpensely_server/model/DefaultSplitRequest.java @@ -0,0 +1,17 @@ +package de.zendric.app.xpensely_server.model; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class DefaultSplitRequest { + + @NotNull(message = "Percentage is required") + @Min(value = 0, message = "Percentage must be between 0 and 100") + @Max(value = 100, message = "Percentage must be between 0 and 100") + private Integer percentage; +} diff --git a/src/main/java/de/zendric/app/xpensely_server/services/ExpenseListService.java b/src/main/java/de/zendric/app/xpensely_server/services/ExpenseListService.java index e76c1b2..240528f 100644 --- a/src/main/java/de/zendric/app/xpensely_server/services/ExpenseListService.java +++ b/src/main/java/de/zendric/app/xpensely_server/services/ExpenseListService.java @@ -50,6 +50,13 @@ public class ExpenseListService { return repository.save(list); } + public ExpenseList updateDefaultSplit(Long id, int percentage) { + ExpenseList list = repository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("ExpenseList not found with id: " + id)); + list.setDefaultSplitPercentage(percentage); + return repository.save(list); + } + public Optional findById(Long id) { return repository.findById(id); } diff --git a/src/test/java/de/zendric/app/xpensely_Server/controller/ExpenseListControllerTest.java b/src/test/java/de/zendric/app/xpensely_Server/controller/ExpenseListControllerTest.java index a4e0210..8da4b49 100644 --- a/src/test/java/de/zendric/app/xpensely_Server/controller/ExpenseListControllerTest.java +++ b/src/test/java/de/zendric/app/xpensely_Server/controller/ExpenseListControllerTest.java @@ -314,6 +314,25 @@ class ExpenseListControllerTest { .andExpect(status().isNotFound()); } + // --- Default split --- + + @Test + void updateDefaultSplit_setsPercentageForMember() throws Exception { + AppUser owner = new AppUser(); owner.setId(1L); + ExpenseList list = new ExpenseList(); list.setId(5L); list.setOwner(owner); + + when(expenseListService.findById(5L)).thenReturn(Optional.of(list)); + when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(owner); + when(expenseListService.updateDefaultSplit(5L, 60)).thenReturn(list); + + mockMvc.perform(put("/api/expenselist/5/default-split") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"percentage\":60}")) + .andExpect(status().isOk()); + + org.mockito.Mockito.verify(expenseListService).updateDefaultSplit(5L, 60); + } + // --- Duplicate-username invite guard --- @Test From 9eb9483162b2d7495e956205b6442f237a6e3bf4 Mon Sep 17 00:00:00 2001 From: Cedric Hornberger Date: Wed, 8 Jul 2026 09:15:23 +0200 Subject: [PATCH 4/4] fix: validate splitPercentage bounds and cover default-split auth --- .../model/ExpenseChangeRequest.java | 5 +++++ .../app/xpensely_server/model/ExpenseInput.java | 5 +++++ .../controller/ExpenseListControllerTest.java | 15 +++++++++++++++ 3 files changed, 25 insertions(+) diff --git a/src/main/java/de/zendric/app/xpensely_server/model/ExpenseChangeRequest.java b/src/main/java/de/zendric/app/xpensely_server/model/ExpenseChangeRequest.java index 1414a03..a6db39c 100644 --- a/src/main/java/de/zendric/app/xpensely_server/model/ExpenseChangeRequest.java +++ b/src/main/java/de/zendric/app/xpensely_server/model/ExpenseChangeRequest.java @@ -3,6 +3,8 @@ package de.zendric.app.xpensely_server.model; import java.time.LocalDate; import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; @@ -34,6 +36,9 @@ public class ExpenseChangeRequest { private Double otherPersonAmount; private SplitMode splitMode; + + @Min(0) + @Max(100) private Integer splitPercentage; @NotNull(message = "Date is required") diff --git a/src/main/java/de/zendric/app/xpensely_server/model/ExpenseInput.java b/src/main/java/de/zendric/app/xpensely_server/model/ExpenseInput.java index 00531fa..fedadb4 100644 --- a/src/main/java/de/zendric/app/xpensely_server/model/ExpenseInput.java +++ b/src/main/java/de/zendric/app/xpensely_server/model/ExpenseInput.java @@ -3,6 +3,8 @@ package de.zendric.app.xpensely_server.model; import java.time.LocalDate; import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; @@ -34,6 +36,9 @@ public class ExpenseInput { private Double otherPersonAmount; private SplitMode splitMode; + + @Min(0) + @Max(100) private Integer splitPercentage; @NotNull(message = "Date is required") diff --git a/src/test/java/de/zendric/app/xpensely_Server/controller/ExpenseListControllerTest.java b/src/test/java/de/zendric/app/xpensely_Server/controller/ExpenseListControllerTest.java index 8da4b49..2b68fb3 100644 --- a/src/test/java/de/zendric/app/xpensely_Server/controller/ExpenseListControllerTest.java +++ b/src/test/java/de/zendric/app/xpensely_Server/controller/ExpenseListControllerTest.java @@ -333,6 +333,21 @@ class ExpenseListControllerTest { org.mockito.Mockito.verify(expenseListService).updateDefaultSplit(5L, 60); } + @Test + void updateDefaultSplit_nonMember_returns403() throws Exception { + AppUser owner = new AppUser(); owner.setId(1L); + AppUser stranger = new AppUser(); stranger.setId(2L); + ExpenseList list = new ExpenseList(); list.setId(5L); list.setOwner(owner); + + when(expenseListService.findById(5L)).thenReturn(Optional.of(list)); + when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(stranger); + + mockMvc.perform(put("/api/expenselist/5/default-split") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"percentage\":60}")) + .andExpect(status().isForbidden()); + } + // --- Duplicate-username invite guard --- @Test