diff --git a/pom.xml b/pom.xml
index 8482c04..a5bfe99 100644
--- a/pom.xml
+++ b/pom.xml
@@ -10,7 +10,7 @@
de.zendric.app
XpenselyServer
- 1.2.0
+ 1.2.1
XpenselyServer
XpenselyServer used to handle the Xpensely App
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/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..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;
@@ -33,6 +35,12 @@ public class ExpenseChangeRequest {
private Double personalUseAmount;
private Double otherPersonAmount;
+ private SplitMode splitMode;
+
+ @Min(0)
+ @Max(100)
+ private Integer splitPercentage;
+
@NotNull(message = "Date is required")
private LocalDate date;
@@ -55,6 +63,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..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;
@@ -33,6 +35,12 @@ public class ExpenseInput {
private Double personalUseAmount;
private Double otherPersonAmount;
+ private SplitMode splitMode;
+
+ @Min(0)
+ @Max(100)
+ private Integer splitPercentage;
+
@NotNull(message = "Date is required")
private LocalDate date;
@@ -56,6 +64,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..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);
}
@@ -137,6 +144,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 +162,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/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/controller/ExpenseListControllerTest.java b/src/test/java/de/zendric/app/xpensely_Server/controller/ExpenseListControllerTest.java
index a4e0210..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
@@ -314,6 +314,40 @@ 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);
+ }
+
+ @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
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);
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"));