fix: enforce unique chronological rent payments

This commit is contained in:
nessar authored and Nessar committed 2026-08-17 14:18:15 +02:00
1 parent df6904e85d
commit 5db268bec7
8 files changed
+162 -15

No files matched your search

@@ -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();
}
}
@@ -10,5 +10,5 @@ import java.util.List;
public interface RentPaymentRepository extends MongoRepository<RentPayment, String> {
List<RentPayment> findAllByLeaseIdAndMonthBetween(String leaseId, String firstMonth, String lastMonth);
List<RentPayment> findAllByOrderByDueDateDesc();
List<RentPayment> findAllByOrderByDueDateAsc();
}
@@ -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<RentPaymentResponse> getAll() {
generateForActiveLeases();
return rentPaymentRepository.findAllByOrderByDueDateDesc().stream().map(this::toResponse).toList();
return rentPaymentRepository.findAllByOrderByDueDateAsc().stream().map(this::toResponse).toList();
}
public ResponseEntity<RentPaymentResponse> 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<Tenant> tenantsFor(List<String> ids) {
@@ -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}
@@ -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() {
@@ -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 {
}
}
@@ -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<RentPaymentResponse> 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<RentPayment> 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");
@@ -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