diff --git a/pom.xml b/pom.xml
index 9d421c4..73bd9ed 100644
--- a/pom.xml
+++ b/pom.xml
@@ -72,6 +72,10 @@
lombok
true
+
+ com.fasterxml.jackson.core
+ jackson-databind
+
org.springframework.boot
spring-boot-starter-test
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
new file mode 100644
index 0000000..a07cdf0
--- /dev/null
+++ b/src/main/java/de/zendric/app/xpensely_server/services/HistoryService.java
@@ -0,0 +1,87 @@
+package de.zendric.app.xpensely_server.services;
+
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import de.zendric.app.xpensely_server.model.AppUser;
+import de.zendric.app.xpensely_server.model.Expense;
+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.repo.ExpenseHistoryRepository;
+
+@Service
+@Transactional
+public class HistoryService {
+
+ private final ExpenseHistoryRepository historyRepository;
+ private final ObjectMapper objectMapper;
+
+ public HistoryService(ExpenseHistoryRepository historyRepository, ObjectMapper objectMapper) {
+ this.historyRepository = historyRepository;
+ this.objectMapper = objectMapper;
+ }
+
+ public void recordCreated(ExpenseList list, Expense expense, AppUser actor) {
+ historyRepository.save(baseEntry(list, expense, actor, HistoryEntryType.CREATED));
+ }
+
+ public void recordUpdated(ExpenseList list, Expense before, Expense after, AppUser actor) {
+ List changes = diff(before, after);
+ if (changes.isEmpty())
+ return;
+ ExpenseHistoryEntry entry = baseEntry(list, after, actor, HistoryEntryType.UPDATED);
+ try {
+ entry.setChanges(objectMapper.writeValueAsString(changes));
+ } catch (JsonProcessingException e) {
+ throw new IllegalStateException("Failed to serialize history changes", e);
+ }
+ historyRepository.save(entry);
+ }
+
+ public void recordDeleted(ExpenseList list, Expense expense, AppUser actor) {
+ historyRepository.save(baseEntry(list, expense, actor, HistoryEntryType.DELETED));
+ }
+
+ public List diff(Expense before, Expense after) {
+ List changes = new ArrayList<>();
+ addIfChanged(changes, "title", before.getTitle(), after.getTitle());
+ addIfChanged(changes, "amount", before.getAmount(), after.getAmount());
+ addIfChanged(changes, "personalUseAmount", before.getPersonalUseAmount(), after.getPersonalUseAmount());
+ addIfChanged(changes, "otherPersonAmount", before.getOtherPersonAmount(), after.getOtherPersonAmount());
+ addIfChanged(changes, "category", before.getCategory(), after.getCategory());
+ addIfChanged(changes, "date", before.getDate(), after.getDate());
+ addIfChanged(changes, "owner",
+ before.getOwner() == null ? null : before.getOwner().getUsername(),
+ after.getOwner() == null ? null : after.getOwner().getUsername());
+ return changes;
+ }
+
+ private ExpenseHistoryEntry baseEntry(ExpenseList list, Expense expense, AppUser actor, HistoryEntryType type) {
+ ExpenseHistoryEntry entry = new ExpenseHistoryEntry();
+ entry.setExpenseList(list);
+ entry.setExpenseId(expense.getId());
+ entry.setExpenseTitle(expense.getTitle());
+ entry.setActor(actor);
+ entry.setType(type);
+ entry.setTimestamp(LocalDateTime.now());
+ return entry;
+ }
+
+ private void addIfChanged(List changes, String field, Object oldVal, Object newVal) {
+ if (!Objects.equals(oldVal, newVal)) {
+ changes.add(new FieldChange(field,
+ oldVal == null ? null : String.valueOf(oldVal),
+ newVal == null ? null : String.valueOf(newVal)));
+ }
+ }
+}
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
new file mode 100644
index 0000000..9d512a0
--- /dev/null
+++ b/src/test/java/de/zendric/app/xpensely_Server/services/HistoryServiceTest.java
@@ -0,0 +1,155 @@
+package de.zendric.app.xpensely_Server.services;
+
+import java.time.LocalDate;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.Spy;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import de.zendric.app.xpensely_server.model.AppUser;
+import de.zendric.app.xpensely_server.model.Expense;
+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.repo.ExpenseHistoryRepository;
+import de.zendric.app.xpensely_server.services.HistoryService;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.ArgumentMatchers.any;
+
+@ExtendWith(MockitoExtension.class)
+class HistoryServiceTest {
+
+ @Mock ExpenseHistoryRepository historyRepository;
+ @Spy ObjectMapper objectMapper = new ObjectMapper();
+
+ @InjectMocks
+ HistoryService service;
+
+ private AppUser user(long id, String name) {
+ AppUser u = new AppUser();
+ u.setId(id);
+ u.setUsername(name);
+ return u;
+ }
+
+ private Expense expense(String title, double amount, String category, LocalDate date, AppUser owner) {
+ Expense e = new Expense();
+ e.setId(42L);
+ e.setTitle(title);
+ e.setAmount(amount);
+ e.setPersonalUseAmount(0.0);
+ e.setOtherPersonAmount(0.0);
+ e.setCategory(category);
+ e.setDate(date);
+ e.setOwner(owner);
+ return e;
+ }
+
+ @Test
+ void diff_detectsChangedFields_withOldAndNewValues() {
+ AppUser alice = user(1L, "alice");
+ Expense before = expense("Groceries", 12.0, "Food", LocalDate.of(2026, 7, 1), alice);
+ Expense after = expense("Groceries", 15.5, "Household", LocalDate.of(2026, 7, 1), alice);
+
+ List changes = service.diff(before, after);
+
+ assertThat(changes).containsExactlyInAnyOrder(
+ new FieldChange("amount", "12.0", "15.5"),
+ new FieldChange("category", "Food", "Household"));
+ }
+
+ @Test
+ void diff_ownerChange_isReportedByUsername() {
+ Expense before = expense("Taxi", 9.0, null, LocalDate.of(2026, 7, 1), user(1L, "alice"));
+ Expense after = expense("Taxi", 9.0, null, LocalDate.of(2026, 7, 1), user(2L, "ben"));
+
+ List changes = service.diff(before, after);
+
+ assertThat(changes).containsExactly(new FieldChange("owner", "alice", "ben"));
+ }
+
+ @Test
+ void diff_nullToValue_isReported() {
+ Expense before = expense("Taxi", 9.0, null, LocalDate.of(2026, 7, 1), user(1L, "alice"));
+ Expense after = expense("Taxi", 9.0, "Transportation", LocalDate.of(2026, 7, 1), user(1L, "alice"));
+
+ assertThat(service.diff(before, after))
+ .containsExactly(new FieldChange("category", null, "Transportation"));
+ }
+
+ @Test
+ void recordUpdated_noChanges_writesNothing() {
+ AppUser alice = user(1L, "alice");
+ Expense same = expense("Groceries", 12.0, "Food", LocalDate.of(2026, 7, 1), alice);
+ ExpenseList list = new ExpenseList();
+ list.setId(5L);
+
+ service.recordUpdated(list, same, same, alice);
+
+ verify(historyRepository, never()).save(any());
+ }
+
+ @Test
+ void recordUpdated_savesEntryWithJsonChanges() {
+ AppUser alice = user(1L, "alice");
+ ExpenseList list = new ExpenseList();
+ list.setId(5L);
+ Expense before = expense("Groceries", 12.0, "Food", LocalDate.of(2026, 7, 1), alice);
+ Expense after = expense("Groceries", 15.5, "Food", LocalDate.of(2026, 7, 1), alice);
+
+ service.recordUpdated(list, before, after, alice);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(ExpenseHistoryEntry.class);
+ verify(historyRepository).save(captor.capture());
+ ExpenseHistoryEntry entry = captor.getValue();
+ assertThat(entry.getType()).isEqualTo(HistoryEntryType.UPDATED);
+ assertThat(entry.getExpenseId()).isEqualTo(42L);
+ assertThat(entry.getExpenseTitle()).isEqualTo("Groceries");
+ assertThat(entry.getActor()).isEqualTo(alice);
+ assertThat(entry.getExpenseList()).isEqualTo(list);
+ assertThat(entry.getTimestamp()).isNotNull();
+ assertThat(entry.getChanges())
+ .isEqualTo("[{\"field\":\"amount\",\"old\":\"12.0\",\"new\":\"15.5\"}]");
+ }
+
+ @Test
+ void recordCreated_savesEntryWithoutChanges() {
+ AppUser alice = user(1L, "alice");
+ ExpenseList list = new ExpenseList();
+ list.setId(5L);
+ Expense e = expense("Groceries", 12.0, "Food", LocalDate.of(2026, 7, 1), alice);
+
+ service.recordCreated(list, e, alice);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(ExpenseHistoryEntry.class);
+ verify(historyRepository).save(captor.capture());
+ assertThat(captor.getValue().getType()).isEqualTo(HistoryEntryType.CREATED);
+ assertThat(captor.getValue().getChanges()).isNull();
+ }
+
+ @Test
+ void recordDeleted_savesEntry() {
+ AppUser alice = user(1L, "alice");
+ ExpenseList list = new ExpenseList();
+ list.setId(5L);
+ Expense e = expense("Taxi", 9.0, null, LocalDate.of(2026, 7, 1), alice);
+
+ service.recordDeleted(list, e, alice);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(ExpenseHistoryEntry.class);
+ verify(historyRepository).save(captor.capture());
+ assertThat(captor.getValue().getType()).isEqualTo(HistoryEntryType.DELETED);
+ assertThat(captor.getValue().getExpenseTitle()).isEqualTo("Taxi");
+ }
+}