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:
nessar committed 2026-08-17 14:18:15 +02:00
1 parent b9e99348f7
commit d7a7aff3ee
15 files changed
+150 -60

No files matched your search

@@ -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) {
@@ -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);
}
}
}
@@ -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.");
}
@@ -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.");
}
@@ -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) + "\"}"))
@@ -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;
@@ -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;
@@ -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());