fix: enforce unique chronological rent payments
This commit is contained in:
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();
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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();
|
||||
}
|
||||
+7
-4
@@ -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}
|
||||
|
||||
+13
-4
@@ -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() {
|
||||
|
||||
+96
@@ -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 {
|
||||
}
|
||||
}
|
||||
+29
-6
@@ -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
|
||||
Reference in new issue
Block a user