feat: add time zone support for rent payment and refund processes
- Updated API requests and responses to include time zone information. - Modified backend services to handle time zone for received and refunded dates. - Adjusted frontend components to use local date formatting based on the user's time zone. - Enhanced tests to validate time zone handling in rent payment and refund scenarios.
This commit is contained in:
1 parent
b9e99348f7
commit
d7a7aff3ee
15 files changed
+150
-60
No files matched your search
+3
-2
@@ -20,7 +20,7 @@ final class RentPaymentApi {
|
||||
|
||||
record CreateRequest(@NotBlank String operationId, @NotBlank String rentDueId,
|
||||
@NotNull @PositiveOrZero Long rentDueVersion, @NotNull LocalDate receivedAt,
|
||||
@NotNull @Positive BigDecimal amount) {
|
||||
@NotBlank String timeZone, @NotNull @Positive BigDecimal amount) {
|
||||
}
|
||||
|
||||
record AllocationRequest(@NotBlank String operationId, @NotNull @PositiveOrZero Long paymentVersion,
|
||||
@@ -32,7 +32,8 @@ final class RentPaymentApi {
|
||||
}
|
||||
|
||||
record RefundRequest(@NotBlank String operationId, @NotNull @PositiveOrZero Long paymentVersion,
|
||||
@NotNull @Positive BigDecimal amount, @NotNull LocalDate refundedAt) {
|
||||
@NotNull @Positive BigDecimal amount, @NotNull LocalDate refundedAt,
|
||||
@NotBlank String timeZone) {
|
||||
}
|
||||
|
||||
record AllocationResponse(String id, String rentDueId, BigDecimal amount, LocalDate allocatedAt) {
|
||||
|
||||
+12
-2
@@ -27,6 +27,8 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.time.DateTimeException;
|
||||
import java.time.ZoneId;
|
||||
import java.util.List;
|
||||
|
||||
@RestController("rentalRentPaymentController")
|
||||
@@ -73,7 +75,7 @@ public class RentPaymentController {
|
||||
@Valid @RequestBody RentPaymentApi.CreateRequest request) {
|
||||
LandlordId landlordId = authenticatedLandlord.id(principal);
|
||||
var created = recording.record(landlordId, request.operationId(), new RentDueId(request.rentDueId()),
|
||||
request.rentDueVersion(), request.receivedAt(), request.amount());
|
||||
request.rentDueVersion(), request.receivedAt(), timeZone(request.timeZone()), request.amount());
|
||||
return ResponseEntity.status(HttpStatus.CREATED)
|
||||
.body(RentPaymentApi.Response.from(history.get(landlordId, created.id())));
|
||||
}
|
||||
@@ -111,7 +113,7 @@ public class RentPaymentController {
|
||||
LandlordId landlordId = authenticatedLandlord.id(principal);
|
||||
RentPaymentId id = new RentPaymentId(paymentId);
|
||||
refunds.refund(landlordId, id, request.operationId(), request.paymentVersion(), request.amount(),
|
||||
request.refundedAt());
|
||||
request.refundedAt(), timeZone(request.timeZone()));
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(RentPaymentApi.Response.from(history.get(landlordId,
|
||||
id)));
|
||||
}
|
||||
@@ -121,4 +123,12 @@ public class RentPaymentController {
|
||||
.filename(document.fileName()).build().toString())
|
||||
.header(HttpHeaders.CONTENT_TYPE, document.contentType()).body(document.content());
|
||||
}
|
||||
|
||||
private static ZoneId timeZone(String value) {
|
||||
try {
|
||||
return ZoneId.of(value);
|
||||
} catch (DateTimeException exception) {
|
||||
throw new IllegalArgumentException("Le fuseau horaire est invalide.", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-4
@@ -18,6 +18,7 @@ import fr.nessar.lmnporganizer.rental.domain.rent.RentRuleViolation;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Clock;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.List;
|
||||
|
||||
public final class RecordRentPaymentService {
|
||||
@@ -35,12 +36,12 @@ public final class RecordRentPaymentService {
|
||||
}
|
||||
|
||||
public RentPayment record(LandlordId landlordId, String operationId, RentDueId rentDueId, long rentDueVersion,
|
||||
LocalDate receivedAt, BigDecimal amount) {
|
||||
LocalDate receivedAt, ZoneId timeZone, BigDecimal amount) {
|
||||
RentPaymentId paymentId = new RentPaymentId(operationId);
|
||||
return transactions.run(() -> payments.find(landlordId, paymentId)
|
||||
.map(existing -> idempotentPayment(existing, rentDueId, receivedAt, amount))
|
||||
.orElseGet(() -> recordInTransaction(landlordId, paymentId, rentDueId, rentDueVersion, receivedAt,
|
||||
amount)));
|
||||
timeZone, amount)));
|
||||
}
|
||||
|
||||
private RentPayment idempotentPayment(RentPayment existing, RentDueId rentDueId, LocalDate receivedAt,
|
||||
@@ -55,8 +56,9 @@ public final class RecordRentPaymentService {
|
||||
}
|
||||
|
||||
private RentPayment recordInTransaction(LandlordId landlordId, RentPaymentId paymentId, RentDueId rentDueId,
|
||||
long rentDueVersion, LocalDate receivedAt, BigDecimal amount) {
|
||||
LocalDate today = LocalDate.now(clock);
|
||||
long rentDueVersion, LocalDate receivedAt, ZoneId timeZone,
|
||||
BigDecimal amount) {
|
||||
LocalDate today = LocalDate.now(clock.withZone(timeZone));
|
||||
if (receivedAt == null || receivedAt.isAfter(today)) {
|
||||
throw new RentRuleViolation("La date de réception du règlement ne peut pas être future.");
|
||||
}
|
||||
|
||||
+5
-4
@@ -15,6 +15,7 @@ import fr.nessar.lmnporganizer.rental.domain.rent.RentRuleViolation;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Clock;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
|
||||
public final class RefundRentPaymentService {
|
||||
private final RentPaymentRepository payments;
|
||||
@@ -31,18 +32,18 @@ public final class RefundRentPaymentService {
|
||||
}
|
||||
|
||||
public RentPayment refund(LandlordId landlordId, RentPaymentId paymentId, String operationId,
|
||||
long paymentVersion, BigDecimal amount, LocalDate refundedAt) {
|
||||
long paymentVersion, BigDecimal amount, LocalDate refundedAt, ZoneId timeZone) {
|
||||
RentPaymentRefundId refundId = new RentPaymentRefundId(operationId);
|
||||
return transactions.run(() -> refunds.find(landlordId, refundId)
|
||||
.map(existing -> idempotentPayment(landlordId, paymentId, amount, refundedAt, existing))
|
||||
.orElseGet(() -> refundInTransaction(landlordId, paymentId, refundId, paymentVersion, amount,
|
||||
refundedAt)));
|
||||
refundedAt, timeZone)));
|
||||
}
|
||||
|
||||
private RentPayment refundInTransaction(LandlordId landlordId, RentPaymentId paymentId,
|
||||
RentPaymentRefundId refundId, long paymentVersion, BigDecimal amount,
|
||||
LocalDate refundedAt) {
|
||||
LocalDate today = LocalDate.now(clock);
|
||||
LocalDate refundedAt, ZoneId timeZone) {
|
||||
LocalDate today = LocalDate.now(clock.withZone(timeZone));
|
||||
if (refundedAt == null || refundedAt.isAfter(today)) {
|
||||
throw new RentRuleViolation("La date du remboursement ne peut pas être future.");
|
||||
}
|
||||
|
||||
+20
-12
@@ -71,8 +71,9 @@ import java.math.BigDecimal;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.YearMonth;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.YearMonth;
|
||||
import java.util.List;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
@@ -287,12 +288,12 @@ class RentalWebAdaptersTest {
|
||||
LEASE_ID, PAYMENT_ID, LocalDate.of(2026, 2, 6), "Erreur bancaire", List.of(allocation));
|
||||
RentPaymentHistory history = new RentPaymentHistory(payment, List.of(refund), java.util.Optional.of(reversed));
|
||||
when(paymentRecording.record(LANDLORD_ID, "payment-1", DUE_ID, 2,
|
||||
LocalDate.of(2026, 2, 2), new BigDecimal("1000"))).thenReturn(payment);
|
||||
LocalDate.of(2026, 2, 2), ZoneId.of("Europe/Paris"), new BigDecimal("1000"))).thenReturn(payment);
|
||||
when(paymentAllocation.allocate(LANDLORD_ID, PAYMENT_ID, "allocation-2", 7, DUE_ID, 2,
|
||||
new BigDecimal("50")))
|
||||
.thenReturn(payment);
|
||||
when(paymentRefunds.refund(LANDLORD_ID, PAYMENT_ID, "refund-1", 7, new BigDecimal("50"),
|
||||
LocalDate.of(2026, 2, 4))).thenReturn(payment);
|
||||
LocalDate.of(2026, 2, 4), ZoneId.of("Europe/Paris"))).thenReturn(payment);
|
||||
when(paymentReversal.reverse(LANDLORD_ID, PAYMENT_ID, 7, "Erreur bancaire")).thenReturn(reversed);
|
||||
when(paymentHistory.get(LANDLORD_ID, PAYMENT_ID)).thenReturn(history);
|
||||
|
||||
@@ -300,7 +301,7 @@ class RentalWebAdaptersTest {
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"operationId":"payment-1","rentDueId":"due-1","rentDueVersion":2,
|
||||
"receivedAt":"2026-02-02","amount":1000}
|
||||
"receivedAt":"2026-02-02","timeZone":"Europe/Paris","amount":1000}
|
||||
"""))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.status").value("CONFIRMED"));
|
||||
@@ -315,7 +316,7 @@ class RentalWebAdaptersTest {
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"operationId":"refund-1","paymentVersion":7,"amount":50,
|
||||
"refundedAt":"2026-02-04"}
|
||||
"refundedAt":"2026-02-04","timeZone":"Europe/Paris"}
|
||||
"""))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.refunds[0].id").value("refund-1"));
|
||||
@@ -326,11 +327,11 @@ class RentalWebAdaptersTest {
|
||||
.andExpect(jsonPath("$.reversal.reason").value("Erreur bancaire"));
|
||||
|
||||
verify(paymentRecording).record(LANDLORD_ID, "payment-1", DUE_ID, 2,
|
||||
LocalDate.of(2026, 2, 2), new BigDecimal("1000"));
|
||||
LocalDate.of(2026, 2, 2), ZoneId.of("Europe/Paris"), new BigDecimal("1000"));
|
||||
verify(paymentAllocation).allocate(LANDLORD_ID, PAYMENT_ID, "allocation-2", 7, DUE_ID, 2,
|
||||
new BigDecimal("50"));
|
||||
verify(paymentRefunds).refund(LANDLORD_ID, PAYMENT_ID, "refund-1", 7, new BigDecimal("50"),
|
||||
LocalDate.of(2026, 2, 4));
|
||||
LocalDate.of(2026, 2, 4), ZoneId.of("Europe/Paris"));
|
||||
verify(paymentReversal).reverse(LANDLORD_ID, PAYMENT_ID, 7, "Erreur bancaire");
|
||||
}
|
||||
|
||||
@@ -340,9 +341,9 @@ class RentalWebAdaptersTest {
|
||||
.thenThrow(new ResourceNotFound("Apartment"));
|
||||
doThrow(new ReferencedResource("Apartment")).when(apartments)
|
||||
.delete(LANDLORD_ID, APARTMENT_ID);
|
||||
when(paymentRecording.record(eq(LANDLORD_ID), eq("stale-operation"), eq(DUE_ID), eq(2L), any(), any()))
|
||||
when(paymentRecording.record(eq(LANDLORD_ID), eq("stale-operation"), eq(DUE_ID), eq(2L), any(), any(), any()))
|
||||
.thenThrow(new ConcurrentChange("Rent due"));
|
||||
when(paymentRecording.record(eq(LANDLORD_ID), eq("paid-operation"), eq(DUE_ID), eq(2L), any(), any()))
|
||||
when(paymentRecording.record(eq(LANDLORD_ID), eq("paid-operation"), eq(DUE_ID), eq(2L), any(), any(), any()))
|
||||
.thenThrow(new RentPaymentConflict("Échéance déjà réglée"));
|
||||
|
||||
mockMvc.perform(get("/api/apartments/missing").with(asLandlord()))
|
||||
@@ -356,24 +357,31 @@ class RentalWebAdaptersTest {
|
||||
mockMvc.perform(post("/api/rent-payments").with(asLandlord()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON).content("""
|
||||
{"operationId":"stale-operation","rentDueId":"due-1","rentDueVersion":2,
|
||||
"receivedAt":"2026-02-02","amount":1000}
|
||||
"receivedAt":"2026-02-02","timeZone":"Europe/Paris","amount":1000}
|
||||
"""))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.message").isNotEmpty());
|
||||
mockMvc.perform(post("/api/rent-payments").with(asLandlord()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON).content("""
|
||||
{"operationId":"paid-operation","rentDueId":"due-1","rentDueVersion":2,
|
||||
"receivedAt":"2026-02-02","amount":1000}
|
||||
"receivedAt":"2026-02-02","timeZone":"Europe/Paris","amount":1000}
|
||||
"""))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.message").value("Échéance déjà réglée"));
|
||||
mockMvc.perform(post("/api/rent-payments").with(asLandlord()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON).content("""
|
||||
{"operationId":"missing-version","rentDueId":"due-1",
|
||||
"receivedAt":"2026-02-02","amount":1000}
|
||||
"receivedAt":"2026-02-02","timeZone":"Europe/Paris","amount":1000}
|
||||
"""))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.message").isNotEmpty());
|
||||
mockMvc.perform(post("/api/rent-payments").with(asLandlord()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON).content("""
|
||||
{"operationId":"invalid-zone","rentDueId":"due-1","rentDueVersion":2,
|
||||
"receivedAt":"2026-02-02","timeZone":"Not/AZone","amount":1000}
|
||||
"""))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.message").value("Le fuseau horaire est invalide."));
|
||||
mockMvc.perform(post("/api/rent-payments/payment-1/reversal").with(asLandlord()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"paymentVersion\":0,\"reason\":\"" + "a".repeat(501) + "\"}"))
|
||||
|
||||
+39
-10
@@ -21,10 +21,11 @@ import java.math.BigDecimal;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.YearMonth;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Optional;
|
||||
import java.time.YearMonth;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
@@ -40,6 +41,7 @@ class RecordRentPaymentServiceTest {
|
||||
private static final LandlordId LANDLORD_ID = new LandlordId("landlord-1");
|
||||
private static final RentDueId DUE_ID = new RentDueId("due-1");
|
||||
private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-02-09T23:30:00Z"), ZoneOffset.UTC);
|
||||
private static final ZoneId PARIS = ZoneId.of("Europe/Paris");
|
||||
|
||||
@Test
|
||||
void recordsConfirmedCashAndAllocatesOnlyTheDuesRemainingBalance() {
|
||||
@@ -55,12 +57,14 @@ class RecordRentPaymentServiceTest {
|
||||
when(rentDues.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
RentPayment payment = new RecordRentPaymentService(payments, rentDues, transactions, CLOCK).record(
|
||||
LANDLORD_ID, "operation-1", DUE_ID, 3, LocalDate.of(2026, 2, 9),
|
||||
LANDLORD_ID, "operation-1", DUE_ID, 3, LocalDate.of(2026, 2, 9), PARIS,
|
||||
new BigDecimal("1000"));
|
||||
|
||||
assertEquals(new RentPaymentId("operation-1"), payment.id());
|
||||
assertEquals(new BigDecimal("950"), payment.allocatedAmount());
|
||||
assertEquals(new BigDecimal("50"), payment.unallocatedBalance());
|
||||
assertEquals(LocalDate.of(2026, 2, 10), payment.confirmedAt());
|
||||
assertEquals(LocalDate.of(2026, 2, 10), payment.allocations().getFirst().allocatedAt());
|
||||
assertEquals(RentDueSettlement.PAID, due.settlement());
|
||||
assertEquals(1, transactions.calls);
|
||||
verify(rentDues).save(due);
|
||||
@@ -81,7 +85,7 @@ class RecordRentPaymentServiceTest {
|
||||
|
||||
RentPayment result = new RecordRentPaymentService(payments, rentDues, new RecordingTransactions(), CLOCK)
|
||||
.record(LANDLORD_ID, "operation-1", DUE_ID, 999, LocalDate.of(2026, 2, 9),
|
||||
new BigDecimal("1000"));
|
||||
PARIS, new BigDecimal("1000"));
|
||||
|
||||
assertEquals(existing, result);
|
||||
verifyNoInteractions(rentDues);
|
||||
@@ -103,16 +107,16 @@ class RecordRentPaymentServiceTest {
|
||||
new RecordingTransactions(), CLOCK);
|
||||
|
||||
assertThrows(RentPaymentConflict.class, () -> service.record(LANDLORD_ID, "operation-1", DUE_ID, 0,
|
||||
LocalDate.of(2026, 2, 9), new BigDecimal("999")));
|
||||
LocalDate.of(2026, 2, 9), PARIS, new BigDecimal("999")));
|
||||
assertThrows(RentPaymentConflict.class, () -> service.record(LANDLORD_ID, "operation-1",
|
||||
new RentDueId("due-2"), 0, LocalDate.of(2026, 2, 9), new BigDecimal("1000")));
|
||||
new RentDueId("due-2"), 0, LocalDate.of(2026, 2, 9), PARIS, new BigDecimal("1000")));
|
||||
|
||||
verifyNoInteractions(rentDues);
|
||||
verify(payments, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void staleOrPaidDueAndCashFromTheNextUtcDayCreateNoPayment() {
|
||||
void staleOrPaidDueAndCashFromTheNextParisDayCreateNoPayment() {
|
||||
RentPaymentRepository payments = mock(RentPaymentRepository.class);
|
||||
RentDueRepository rentDues = mock(RentDueRepository.class);
|
||||
RentDue due = RentDue.reconstitute(DUE_ID, LANDLORD_ID, new LeaseId("lease-1"),
|
||||
@@ -124,17 +128,42 @@ class RecordRentPaymentServiceTest {
|
||||
new RecordingTransactions(), CLOCK);
|
||||
|
||||
assertThrows(ConcurrentChange.class, () -> service.record(LANDLORD_ID, "operation-1", DUE_ID, 3,
|
||||
LocalDate.of(2026, 2, 9), new BigDecimal("1000")));
|
||||
LocalDate.of(2026, 2, 9), PARIS, new BigDecimal("1000")));
|
||||
assertThrows(RentRuleViolation.class, () -> service.record(LANDLORD_ID, "operation-1", DUE_ID, 4,
|
||||
LocalDate.of(2026, 2, 10), new BigDecimal("1000")));
|
||||
LocalDate.of(2026, 2, 11), PARIS, new BigDecimal("1000")));
|
||||
due.allocate(due.total());
|
||||
assertThrows(RentPaymentConflict.class, () -> service.record(LANDLORD_ID, "operation-1", DUE_ID, 4,
|
||||
LocalDate.of(2026, 2, 9), new BigDecimal("1000")));
|
||||
LocalDate.of(2026, 2, 9), PARIS, new BigDecimal("1000")));
|
||||
|
||||
verify(rentDues, never()).save(any());
|
||||
verify(payments, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void validatesReceivedDateInTheUsersCalendarZone() {
|
||||
Clock boundary = Clock.fixed(Instant.parse("2026-07-19T06:30:00Z"), ZoneOffset.UTC);
|
||||
ZoneId losAngeles = ZoneId.of("America/Los_Angeles");
|
||||
RentPaymentRepository payments = mock(RentPaymentRepository.class);
|
||||
RentDueRepository rentDues = mock(RentDueRepository.class);
|
||||
RentDue due = RentDue.reconstitute(DUE_ID, LANDLORD_ID, new LeaseId("lease-1"),
|
||||
YearMonth.of(2026, 7), LocalDate.of(2026, 8, 5), new BigDecimal("900"),
|
||||
new BigDecimal("50"), BigDecimal.ZERO, 0);
|
||||
when(payments.find(LANDLORD_ID, new RentPaymentId("operation-1"))).thenReturn(Optional.empty());
|
||||
when(rentDues.find(LANDLORD_ID, DUE_ID)).thenReturn(Optional.of(due));
|
||||
when(payments.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(rentDues.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
RecordRentPaymentService service = new RecordRentPaymentService(payments, rentDues,
|
||||
new RecordingTransactions(), boundary);
|
||||
|
||||
assertEquals(LocalDate.of(2026, 7, 19), LocalDate.now(boundary.withZone(PARIS)));
|
||||
assertThrows(RentRuleViolation.class, () -> service.record(LANDLORD_ID, "operation-1", DUE_ID, 0,
|
||||
LocalDate.of(2026, 7, 19), losAngeles, new BigDecimal("100")));
|
||||
RentPayment accepted = service.record(LANDLORD_ID, "operation-1", DUE_ID, 0,
|
||||
LocalDate.of(2026, 7, 18), losAngeles, new BigDecimal("100"));
|
||||
|
||||
assertEquals(LocalDate.of(2026, 7, 18), accepted.confirmedAt());
|
||||
}
|
||||
|
||||
private static final class RecordingTransactions implements TransactionRunner {
|
||||
private int calls;
|
||||
|
||||
|
||||
+34
-6
@@ -18,6 +18,7 @@ import java.math.BigDecimal;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@@ -35,6 +36,7 @@ class RefundRentPaymentServiceTest {
|
||||
private static final LandlordId LANDLORD_ID = new LandlordId("landlord-1");
|
||||
private static final RentPaymentId PAYMENT_ID = new RentPaymentId("payment-1");
|
||||
private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-02-10T12:00:00Z"), ZoneOffset.UTC);
|
||||
private static final ZoneId PARIS = ZoneId.of("Europe/Paris");
|
||||
|
||||
@Test
|
||||
void atomicallyRecordsAConfirmedRefundAgainstCurrentSurplus() {
|
||||
@@ -52,7 +54,7 @@ class RefundRentPaymentServiceTest {
|
||||
|
||||
RentPayment result = new RefundRentPaymentService(payments, refunds, transactions, CLOCK).refund(
|
||||
LANDLORD_ID, PAYMENT_ID, "operation-1", 4, new BigDecimal("250"),
|
||||
LocalDate.of(2026, 2, 9));
|
||||
LocalDate.of(2026, 2, 9), PARIS);
|
||||
|
||||
assertEquals(new BigDecimal("250"), result.refundedAmount());
|
||||
assertEquals(new BigDecimal("750"), result.unallocatedBalance());
|
||||
@@ -77,11 +79,11 @@ class RefundRentPaymentServiceTest {
|
||||
new RecordingTransactions(), CLOCK);
|
||||
|
||||
assertEquals(payment, service.refund(LANDLORD_ID, PAYMENT_ID, "operation-1", 999,
|
||||
new BigDecimal("250.00"), LocalDate.of(2026, 2, 9)));
|
||||
new BigDecimal("250.00"), LocalDate.of(2026, 2, 9), PARIS));
|
||||
assertThrows(RentPaymentConflict.class, () -> service.refund(LANDLORD_ID, PAYMENT_ID, "operation-1", 5,
|
||||
new BigDecimal("249"), LocalDate.of(2026, 2, 9)));
|
||||
new BigDecimal("249"), LocalDate.of(2026, 2, 9), PARIS));
|
||||
assertThrows(RentPaymentConflict.class, () -> service.refund(LANDLORD_ID, PAYMENT_ID, "operation-1", 5,
|
||||
new BigDecimal("250"), LocalDate.of(2026, 2, 8)));
|
||||
new BigDecimal("250"), LocalDate.of(2026, 2, 8), PARIS));
|
||||
|
||||
verify(payments, never()).save(any());
|
||||
verify(refunds, never()).save(any());
|
||||
@@ -102,14 +104,40 @@ class RefundRentPaymentServiceTest {
|
||||
|
||||
assertThrows(RentRuleViolation.class,
|
||||
() -> service.refund(LANDLORD_ID, PAYMENT_ID, "operation-1", 4, new BigDecimal("50"),
|
||||
LocalDate.of(2026, 2, 11)));
|
||||
LocalDate.of(2026, 2, 11), PARIS));
|
||||
assertThrows(ConcurrentChange.class, () -> service.refund(LANDLORD_ID, PAYMENT_ID, "operation-1", 3,
|
||||
new BigDecimal("50"), LocalDate.of(2026, 2, 9)));
|
||||
new BigDecimal("50"), LocalDate.of(2026, 2, 9), PARIS));
|
||||
|
||||
verify(payments, never()).save(any());
|
||||
verify(refunds, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void validatesRefundDateInTheUsersCalendarZone() {
|
||||
Clock boundary = Clock.fixed(Instant.parse("2026-07-19T06:30:00Z"), ZoneOffset.UTC);
|
||||
ZoneId losAngeles = ZoneId.of("America/Los_Angeles");
|
||||
RentPaymentRepository payments = mock(RentPaymentRepository.class);
|
||||
RentPaymentRefundRepository refunds = mock(RentPaymentRefundRepository.class);
|
||||
RentPayment payment = RentPayment.reconstitute(PAYMENT_ID, LANDLORD_ID, new LeaseId("lease-1"),
|
||||
LocalDate.of(2026, 7, 1), new BigDecimal("1000"), RentPaymentStatus.CONFIRMED,
|
||||
List.of(), BigDecimal.ZERO, LocalDate.of(2026, 7, 1), 4);
|
||||
RentPaymentRefundId refundId = new RentPaymentRefundId("operation-1");
|
||||
when(refunds.find(LANDLORD_ID, refundId)).thenReturn(Optional.empty());
|
||||
when(payments.find(LANDLORD_ID, PAYMENT_ID)).thenReturn(Optional.of(payment));
|
||||
when(payments.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(refunds.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
RefundRentPaymentService service = new RefundRentPaymentService(payments, refunds,
|
||||
new RecordingTransactions(), boundary);
|
||||
|
||||
assertEquals(LocalDate.of(2026, 7, 19), LocalDate.now(boundary.withZone(PARIS)));
|
||||
assertThrows(RentRuleViolation.class, () -> service.refund(LANDLORD_ID, PAYMENT_ID, "operation-1", 4,
|
||||
new BigDecimal("50"), LocalDate.of(2026, 7, 19), losAngeles));
|
||||
RentPayment accepted = service.refund(LANDLORD_ID, PAYMENT_ID, "operation-1", 4,
|
||||
new BigDecimal("50"), LocalDate.of(2026, 7, 18), losAngeles);
|
||||
|
||||
assertEquals(new BigDecimal("50"), accepted.refundedAmount());
|
||||
}
|
||||
|
||||
private static final class RecordingTransactions implements TransactionRunner {
|
||||
private int calls;
|
||||
|
||||
|
||||
+8
-8
@@ -122,7 +122,7 @@ class ReplacementRentalApiJourneyIT {
|
||||
|
||||
String paymentId = create("/api/rent-payments", """
|
||||
{"operationId":"payment-1","rentDueId":"%s","rentDueVersion":0,
|
||||
"receivedAt":"2026-07-05","amount":1000.00}
|
||||
"receivedAt":"2026-07-05","timeZone":"Europe/Paris","amount":1000.00}
|
||||
""".formatted(dueId));
|
||||
mockMvc.perform(post("/api/rent-payments/{paymentId}/allocations", paymentId)
|
||||
.with(owner()).with(csrf()).contentType(MediaType.APPLICATION_JSON).content("""
|
||||
@@ -141,27 +141,27 @@ class ReplacementRentalApiJourneyIT {
|
||||
mockMvc.perform(post("/api/rent-payments").with(owner()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON).content("""
|
||||
{"operationId":"paid-conflict","rentDueId":"%s","rentDueVersion":1,
|
||||
"receivedAt":"2026-07-05","amount":1.00}
|
||||
"receivedAt":"2026-07-05","timeZone":"Europe/Paris","amount":1.00}
|
||||
""".formatted(dueId)))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.message").isNotEmpty());
|
||||
|
||||
String surplusPaymentId = create("/api/rent-payments", """
|
||||
{"operationId":"payment-2","rentDueId":"%s","rentDueVersion":1,
|
||||
"receivedAt":"2026-07-06","amount":926.00}
|
||||
"receivedAt":"2026-07-06","timeZone":"Europe/Paris","amount":926.00}
|
||||
""".formatted(secondDueId));
|
||||
mockMvc.perform(post("/api/rent-payments/{paymentId}/refunds", surplusPaymentId)
|
||||
.with(owner()).with(csrf()).contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"operationId":"invalid-refund","paymentVersion":0,"amount":1.01,
|
||||
"refundedAt":"2026-07-07"}
|
||||
"refundedAt":"2026-07-07","timeZone":"Europe/Paris"}
|
||||
"""))
|
||||
.andExpect(status().isBadRequest());
|
||||
String refundResponse = mockMvc.perform(post("/api/rent-payments/{paymentId}/refunds", surplusPaymentId)
|
||||
.with(owner()).with(csrf()).contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"operationId":"refund-1","paymentVersion":0,"amount":0.50,
|
||||
"refundedAt":"2026-07-07"}
|
||||
"refundedAt":"2026-07-07","timeZone":"Europe/Paris"}
|
||||
"""))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.refunds[0].id").value("refund-1"))
|
||||
@@ -171,7 +171,7 @@ class ReplacementRentalApiJourneyIT {
|
||||
.with(owner()).with(csrf()).contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"operationId":"refund-1","paymentVersion":0,"amount":0.50,
|
||||
"refundedAt":"2026-07-07"}
|
||||
"refundedAt":"2026-07-07","timeZone":"Europe/Paris"}
|
||||
"""))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.refunds.length()").value(1));
|
||||
@@ -280,7 +280,7 @@ class ReplacementRentalApiJourneyIT {
|
||||
mockMvc.perform(post("/api/rent-payments").with(otherLandlord()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON).content("""
|
||||
{"operationId":"foreign-payment","rentDueId":"%s","rentDueVersion":1,
|
||||
"receivedAt":"2026-07-09","amount":1.00}
|
||||
"receivedAt":"2026-07-09","timeZone":"Europe/Paris","amount":1.00}
|
||||
""".formatted(dueId)))
|
||||
.andExpect(status().isNotFound());
|
||||
mockMvc.perform(post("/api/rent-payments/{id}/allocations", paymentId)
|
||||
@@ -298,7 +298,7 @@ class ReplacementRentalApiJourneyIT {
|
||||
.with(otherLandlord()).with(csrf()).contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"operationId":"foreign-refund","paymentVersion":2,"amount":0.25,
|
||||
"refundedAt":"2026-07-10"}
|
||||
"refundedAt":"2026-07-10","timeZone":"Europe/Paris"}
|
||||
"""))
|
||||
.andExpect(status().isNotFound());
|
||||
|
||||
|
||||
Reference in new issue
Block a user