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