From 5db268bec7222854bcedf4ab12b6d731f1c905ea Mon Sep 17 00:00:00 2001
From: TO
Date: Fri, 17 Jul 2026 01:33:15 +0200
Subject: [PATCH] fix: enforce unique chronological rent payments
---
.../backend/config/TimeConfiguration.java | 14 +++
.../repository/RentPaymentRepository.java | 2 +-
.../backend/service/RentPaymentService.java | 11 ++-
.../src/main/resources/application.properties | 1 +
.../controller/RentPaymentControllerTest.java | 17 +++-
.../integration/RentPaymentPersistenceIT.java | 96 +++++++++++++++++++
.../service/RentPaymentServiceTest.java | 35 +++++--
.../src/test/resources/application.properties | 1 +
8 files changed, 162 insertions(+), 15 deletions(-)
create mode 100644 backend/src/main/java/fr/nessar/lmnporganizer/backend/config/TimeConfiguration.java
create mode 100644 backend/src/test/java/fr/nessar/lmnporganizer/backend/integration/RentPaymentPersistenceIT.java
diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/config/TimeConfiguration.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/config/TimeConfiguration.java
new file mode 100644
index 0000000..f776c88
--- /dev/null
+++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/config/TimeConfiguration.java
@@ -0,0 +1,14 @@
+package fr.nessar.lmnporganizer.backend.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import java.time.Clock;
+
+@Configuration
+public class TimeConfiguration {
+ @Bean
+ Clock clock() {
+ return Clock.systemDefaultZone();
+ }
+}
diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/repository/RentPaymentRepository.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/repository/RentPaymentRepository.java
index 64f0088..89030b7 100644
--- a/backend/src/main/java/fr/nessar/lmnporganizer/backend/repository/RentPaymentRepository.java
+++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/repository/RentPaymentRepository.java
@@ -10,5 +10,5 @@ import java.util.List;
public interface RentPaymentRepository extends MongoRepository {
List findAllByLeaseIdAndMonthBetween(String leaseId, String firstMonth, String lastMonth);
- List findAllByOrderByDueDateDesc();
+ List findAllByOrderByDueDateAsc();
}
diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/RentPaymentService.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/RentPaymentService.java
index 110e721..4a85dcf 100644
--- a/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/RentPaymentService.java
+++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/RentPaymentService.java
@@ -19,6 +19,7 @@ import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
+import java.time.Clock;
import java.time.LocalDate;
import java.time.YearMonth;
import java.util.ArrayList;
@@ -34,28 +35,30 @@ public class RentPaymentService {
private final LandlordRepository landlordRepository;
private final TenantRepository tenantRepository;
private final RentReceiptPdfService rentReceiptPdfService;
+ private final Clock clock;
public RentPaymentService(RentPaymentRepository rentPaymentRepository, LeaseRepository leaseRepository,
ApartmentRepository apartmentRepository, LandlordRepository landlordRepository,
TenantRepository tenantRepository,
- RentReceiptPdfService rentReceiptPdfService) {
+ RentReceiptPdfService rentReceiptPdfService, Clock clock) {
this.rentPaymentRepository = rentPaymentRepository;
this.leaseRepository = leaseRepository;
this.apartmentRepository = apartmentRepository;
this.landlordRepository = landlordRepository;
this.tenantRepository = tenantRepository;
this.rentReceiptPdfService = rentReceiptPdfService;
+ this.clock = clock;
}
public List getAll() {
generateForActiveLeases();
- return rentPaymentRepository.findAllByOrderByDueDateDesc().stream().map(this::toResponse).toList();
+ return rentPaymentRepository.findAllByOrderByDueDateAsc().stream().map(this::toResponse).toList();
}
public ResponseEntity markPaid(String id) {
return rentPaymentRepository.findById(id).map(payment -> {
if (payment.getPaidAt() == null) {
- payment.setPaidAt(LocalDate.now());
+ payment.setPaidAt(LocalDate.now(clock));
payment = rentPaymentRepository.save(payment);
}
return ResponseEntity.ok(toResponse(payment));
@@ -114,7 +117,7 @@ public class RentPaymentService {
private RentPaymentResponse toResponse(RentPayment payment) {
return new RentPaymentResponse(payment.getId(), payment.getLeaseId(), payment.getMonth(), payment.getDueDate(),
payment.getRent(), payment.getCharges(), payment.getRent().add(payment.getCharges()),
- payment.statusOn(LocalDate.now()), payment.getPaidAt());
+ payment.statusOn(LocalDate.now(clock)), payment.getPaidAt());
}
private List tenantsFor(List ids) {
diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties
index 7141afd..6a3818b 100644
--- a/backend/src/main/resources/application.properties
+++ b/backend/src/main/resources/application.properties
@@ -1,5 +1,6 @@
spring.application.name=backend
spring.mongodb.uri=${SPRING_DATA_MONGODB_URI:mongodb://localhost:27017/lmnp}
+spring.data.mongodb.auto-index-creation=true
lmnp.storage.attachments=${LMNP_ATTACHMENT_STORAGE_PATH:${user.home}/.lmnp-organizer/attachments}
lmnp.attachments.max-size=${LMNP_ATTACHMENT_MAX_SIZE:250MB}
spring.servlet.multipart.max-file-size=${lmnp.attachments.max-size}
diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/RentPaymentControllerTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/RentPaymentControllerTest.java
index ab1cc23..27d477c 100644
--- a/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/RentPaymentControllerTest.java
+++ b/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/RentPaymentControllerTest.java
@@ -60,13 +60,18 @@ class RentPaymentControllerTest {
}
@Test
- void whenRentScheduleIsRequested_thenReturnsGeneratedPayments() throws Exception {
+ void whenRentScheduleIsRequested_thenReturnsPaymentsInChronologicalOrder() throws Exception {
when(leaseRepository.findAllByStatus(LeaseStatus.ACTIVE)).thenReturn(List.of());
- when(rentPaymentRepository.findAllByOrderByDueDateDesc()).thenReturn(List.of(payment(null)));
+ when(rentPaymentRepository.findAllByOrderByDueDateAsc()).thenReturn(List.of(
+ payment("payment-1", "2026-06", LocalDate.of(2026, 6, 1), null),
+ payment("payment-2", "2026-07", LocalDate.of(2026, 7, 1), null)
+ ));
mockMvc.perform(get("/api/rent-payments").with(oidcLogin()))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].id").value("payment-1"))
+ .andExpect(jsonPath("$[0].month").value("2026-06"))
+ .andExpect(jsonPath("$[1].month").value("2026-07"))
.andExpect(jsonPath("$[0].status").value("LATE"));
}
@@ -111,8 +116,12 @@ class RentPaymentControllerTest {
}
private RentPayment payment(LocalDate paidAt) {
- return new RentPayment("payment-1", "default-landlord", "lease-1", "2026-07",
- LocalDate.of(2026, 7, 1), new BigDecimal("850.00"), new BigDecimal("50.00"), paidAt);
+ return payment("payment-1", "2026-07", LocalDate.of(2026, 7, 1), paidAt);
+ }
+
+ private RentPayment payment(String id, String month, LocalDate dueDate, LocalDate paidAt) {
+ return new RentPayment(id, "default-landlord", "lease-1", month,
+ dueDate, new BigDecimal("850.00"), new BigDecimal("50.00"), paidAt);
}
private Lease lease() {
diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/backend/integration/RentPaymentPersistenceIT.java b/backend/src/test/java/fr/nessar/lmnporganizer/backend/integration/RentPaymentPersistenceIT.java
new file mode 100644
index 0000000..cda2a8f
--- /dev/null
+++ b/backend/src/test/java/fr/nessar/lmnporganizer/backend/integration/RentPaymentPersistenceIT.java
@@ -0,0 +1,96 @@
+package fr.nessar.lmnporganizer.backend.integration;
+
+import fr.nessar.lmnporganizer.backend.model.RentPayment;
+import fr.nessar.lmnporganizer.backend.repository.RentPaymentRepository;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.SpringBootConfiguration;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.autoconfigure.AutoConfigurationPackage;
+import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.data.mongodb.repository.config.EnableMongoRepositories;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.testcontainers.mongodb.MongoDBContainer;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+@SpringBootTest(
+ classes = RentPaymentPersistenceIT.MongoTestApplication.class,
+ webEnvironment = SpringBootTest.WebEnvironment.NONE,
+ properties = {
+ "lmnp.data-initializer.enabled=false",
+ "spring.data.mongodb.auto-index-creation=true"
+ }
+)
+class RentPaymentPersistenceIT {
+
+ private static final MongoDBContainer mongoDb = startMongoDb();
+
+ @Autowired
+ private RentPaymentRepository rentPaymentRepository;
+
+ @BeforeEach
+ @AfterEach
+ void clearRentPayments() {
+ rentPaymentRepository.deleteAll();
+ }
+
+ @DynamicPropertySource
+ static void configureMongoDb(DynamicPropertyRegistry registry) {
+ if (mongoDb != null) {
+ registry.add("spring.mongodb.uri", () -> mongoDb.getConnectionString() + "/lmnp-rent-payments");
+ }
+ }
+
+ @Test
+ void rejectsTwoRentPaymentsForTheSameLeaseAndMonth() {
+ rentPaymentRepository.save(rentPayment("payment-1", "2026-06", LocalDate.of(2026, 6, 5)));
+
+ assertThatThrownBy(() -> rentPaymentRepository.save(rentPayment("payment-2", "2026-06", LocalDate.of(2026, 6, 5))))
+ .isInstanceOf(DuplicateKeyException.class);
+ }
+
+ @Test
+ void returnsRentPaymentsInChronologicalOrder() {
+ rentPaymentRepository.saveAll(List.of(
+ rentPayment("payment-3", "2026-03", LocalDate.of(2026, 3, 5)),
+ rentPayment("payment-1", "2026-01", LocalDate.of(2026, 1, 5)),
+ rentPayment("payment-2", "2026-02", LocalDate.of(2026, 2, 5))
+ ));
+
+ assertThat(rentPaymentRepository.findAllByOrderByDueDateAsc())
+ .extracting(RentPayment::getMonth)
+ .containsExactly("2026-01", "2026-02", "2026-03");
+ }
+
+ private static RentPayment rentPayment(String id, String month, LocalDate dueDate) {
+ return new RentPayment(id, "default-landlord", "lease-1", month, dueDate,
+ new BigDecimal("850.00"), new BigDecimal("50.00"), null);
+ }
+
+ private static MongoDBContainer startMongoDb() {
+ if (System.getenv("SPRING_MONGODB_URI") != null) {
+ return null;
+ }
+
+ MongoDBContainer container = new MongoDBContainer("mongo:8.0");
+ container.start();
+ return container;
+ }
+
+ @SpringBootConfiguration
+ @EnableAutoConfiguration
+ @AutoConfigurationPackage(basePackageClasses = RentPayment.class)
+ @EnableMongoRepositories(basePackageClasses = RentPaymentRepository.class)
+ static class MongoTestApplication {
+ }
+}
diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/backend/service/RentPaymentServiceTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/backend/service/RentPaymentServiceTest.java
index a977973..422090b 100644
--- a/backend/src/test/java/fr/nessar/lmnporganizer/backend/service/RentPaymentServiceTest.java
+++ b/backend/src/test/java/fr/nessar/lmnporganizer/backend/service/RentPaymentServiceTest.java
@@ -27,7 +27,10 @@ import org.thymeleaf.spring6.SpringTemplateEngine;
import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver;
import java.math.BigDecimal;
+import java.time.Clock;
+import java.time.Instant;
import java.time.LocalDate;
+import java.time.ZoneOffset;
import java.util.List;
import java.util.Optional;
import java.util.stream.StreamSupport;
@@ -63,7 +66,8 @@ class RentPaymentServiceTest {
SpringTemplateEngine templateEngine = new SpringTemplateEngine();
templateEngine.setTemplateResolver(resolver);
service = new RentPaymentService(rentPaymentRepository, leaseRepository, apartmentRepository,
- landlordRepository, tenantRepository, new RentReceiptPdfService(templateEngine));
+ landlordRepository, tenantRepository, new RentReceiptPdfService(templateEngine),
+ Clock.fixed(Instant.parse("2026-07-17T00:00:00Z"), ZoneOffset.UTC));
}
@Test
@@ -72,15 +76,15 @@ class RentPaymentServiceTest {
when(leaseRepository.findAllByStatus(LeaseStatus.ACTIVE)).thenReturn(List.of(lease));
when(rentPaymentRepository.findAllByLeaseIdAndMonthBetween("lease-1", "2099-01", "2099-03"))
.thenReturn(List.of());
- when(rentPaymentRepository.findAllByOrderByDueDateDesc()).thenReturn(List.of(
- payment("payment-3", "2099-03", LocalDate.of(2099, 3, 31), null),
+ when(rentPaymentRepository.findAllByOrderByDueDateAsc()).thenReturn(List.of(
+ payment("payment-1", "2099-01", LocalDate.of(2099, 1, 31), null),
payment("payment-2", "2099-02", LocalDate.of(2099, 2, 28), null),
- payment("payment-1", "2099-01", LocalDate.of(2099, 1, 31), null)));
+ payment("payment-3", "2099-03", LocalDate.of(2099, 3, 31), null)));
List payments = service.getAll();
assertEquals(3, payments.size());
- assertEquals("2099-03", payments.getFirst().month());
+ assertEquals("2099-01", payments.getFirst().month());
assertEquals(new BigDecimal("900.00"), payments.getFirst().total());
assertEquals(RentPaymentStatus.UPCOMING, payments.getFirst().status());
verify(rentPaymentRepository).saveAll(argThat(generatedPayments -> {
@@ -90,6 +94,25 @@ class RentPaymentServiceTest {
}));
}
+ @Test
+ void generatesEachRentPaymentOnlyOnceAcrossRepeatedCalls() {
+ Lease lease = lease(LocalDate.of(2099, 1, 31), LocalDate.of(2099, 3, 31));
+ List existingPayments = List.of(
+ payment("payment-1", "2099-01", LocalDate.of(2099, 1, 31), null),
+ payment("payment-2", "2099-02", LocalDate.of(2099, 2, 28), null),
+ payment("payment-3", "2099-03", LocalDate.of(2099, 3, 31), null));
+ when(leaseRepository.findAllByStatus(LeaseStatus.ACTIVE)).thenReturn(List.of(lease));
+ when(rentPaymentRepository.findAllByLeaseIdAndMonthBetween("lease-1", "2099-01", "2099-03"))
+ .thenReturn(List.of())
+ .thenReturn(existingPayments);
+ when(rentPaymentRepository.findAllByOrderByDueDateAsc()).thenReturn(existingPayments);
+
+ service.getAll();
+ service.getAll();
+
+ verify(rentPaymentRepository, times(1)).saveAll(anyList());
+ }
+
@Test
void marksAnUnpaidRentPaymentPaidOnlyOnce() {
RentPayment payment = payment("payment-1", "2000-01", LocalDate.of(2000, 1, 1), null);
@@ -101,7 +124,7 @@ class RentPaymentServiceTest {
assertEquals(HttpStatus.OK, response.getStatusCode());
assertNotNull(response.getBody());
assertEquals(RentPaymentStatus.PAID, response.getBody().status());
- assertNotNull(response.getBody().paidAt());
+ assertEquals(LocalDate.of(2026, 7, 17), response.getBody().paidAt());
verify(rentPaymentRepository).save(payment);
service.markPaid("payment-1");
diff --git a/backend/src/test/resources/application.properties b/backend/src/test/resources/application.properties
index 20fdfc5..239f4f1 100644
--- a/backend/src/test/resources/application.properties
+++ b/backend/src/test/resources/application.properties
@@ -10,5 +10,6 @@ spring.security.oauth2.client.provider.oidc.token-uri=http://localhost:8090/real
spring.security.oauth2.client.provider.oidc.user-info-uri=http://localhost:8090/realms/test/protocol/openid-connect/userinfo
spring.security.oauth2.client.provider.oidc.jwk-set-uri=http://localhost:8090/realms/test/protocol/openid-connect/certs
spring.security.oauth2.client.provider.oidc.user-name-attribute=preferred_username
+spring.data.mongodb.auto-index-creation=false
lmnp.storage.attachments=${java.io.tmpdir}/lmnp-organizer-test/attachments
lmnp.attachments.max-size=250MB