From d7a7aff3eeb813140d3492ad1fa176ac637ed618 Mon Sep 17 00:00:00 2001 From: Nessar Date: Sun, 19 Jul 2026 01:47:19 +0200 Subject: [PATCH] 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. --- CONTEXT.md | 4 +- .../adapter/in/web/rent/RentPaymentApi.java | 5 +- .../in/web/rent/RentPaymentController.java | 14 +++++- .../rent/RecordRentPaymentService.java | 10 ++-- .../rent/RefundRentPaymentService.java | 9 ++-- .../adapter/in/web/RentalWebAdaptersTest.java | 32 +++++++----- .../rent/RecordRentPaymentServiceTest.java | 49 +++++++++++++++---- .../rent/RefundRentPaymentServiceTest.java | 40 ++++++++++++--- .../ReplacementRentalApiJourneyIT.java | 16 +++--- .../components/rent-payment-dialogs.spec.ts | 11 +++-- .../rent-payment-refund-dialog.ts | 5 +- .../rent-payment-validation-dialog.ts | 5 +- .../src/app/services/rent-payment-format.ts | 6 ++- .../app/services/rent-payment.service.spec.ts | 2 + .../src/app/services/rent-payment.service.ts | 2 + 15 files changed, 150 insertions(+), 60 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 6228a3a..0cb2237 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -33,7 +33,7 @@ Loyer mensuel créé uniquement à l'activation d'un bail, généré à partir d _Avoid_: Paiement, mensualité **Règlement de loyer** (Code: `RentPayment`): -Encaissement déjà reçu, donc jamais daté dans le futur selon le calendrier UTC, et d'un montant strictement positif, pouvant être affecté en tout ou partie à des échéances de loyer sans que la somme de ses affectations dépasse le montant reçu ; le surplus reste non affecté jusqu'à son report, son remboursement ou un autre traitement. Un règlement est enregistré comme un fait confirmé et immuable qui ne peut être corrigé que par contrepassation puis remplacement. +Encaissement déjà reçu, donc jamais daté dans le futur selon le fuseau horaire IANA du navigateur utilisé pour l'enregistrer, et d'un montant strictement positif, pouvant être affecté en tout ou partie à des échéances de loyer sans que la somme de ses affectations dépasse le montant reçu ; le surplus reste non affecté jusqu'à son report, son remboursement ou un autre traitement. Un règlement est enregistré comme un fait confirmé et immuable qui ne peut être corrigé que par contrepassation puis remplacement. _Avoid_: Échéance, mensualité, marquage manuel comme payé **Contrepassation de règlement** (Code: `RentPaymentReversal`): @@ -49,7 +49,7 @@ Document attestant le montant total d'un règlement confirmé et détaillant ses _Avoid_: Quittance de loyer **Remboursement de trop-perçu** (Code: `RentPaymentRefund`): -Restitution déjà effectuée, donc jamais datée dans le futur selon le calendrier UTC, de tout ou partie du montant non affecté d'un règlement confirmé, sans jamais dépasser son solde non affecté. Un remboursement est enregistré comme un fait confirmé et immuable afin de préserver la trace d'audit. +Restitution déjà effectuée, donc jamais datée dans le futur selon le fuseau horaire IANA du navigateur utilisé pour l'enregistrer, de tout ou partie du montant non affecté d'un règlement confirmé, sans jamais dépasser son solde non affecté. Un remboursement est enregistré comme un fait confirmé et immuable afin de préserver la trace d'audit. _Avoid_: Suppression du règlement, affectation négative **Affectation de règlement** (Code: `PaymentAllocation`): diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/rent/RentPaymentApi.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/rent/RentPaymentApi.java index debc667..58d6777 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/rent/RentPaymentApi.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/rent/RentPaymentApi.java @@ -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) { diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/rent/RentPaymentController.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/rent/RentPaymentController.java index d9904d3..e34817b 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/rent/RentPaymentController.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/rent/RentPaymentController.java @@ -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); + } + } } diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/rent/RecordRentPaymentService.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/rent/RecordRentPaymentService.java index 59e1215..c90310f 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/rent/RecordRentPaymentService.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/rent/RecordRentPaymentService.java @@ -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."); } diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/rent/RefundRentPaymentService.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/rent/RefundRentPaymentService.java index 25097a7..ab66574 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/rent/RefundRentPaymentService.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/rent/RefundRentPaymentService.java @@ -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."); } diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/RentalWebAdaptersTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/RentalWebAdaptersTest.java index 0cfcf8c..c33ca5d 100644 --- a/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/RentalWebAdaptersTest.java +++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/RentalWebAdaptersTest.java @@ -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) + "\"}")) diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/rent/RecordRentPaymentServiceTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/rent/RecordRentPaymentServiceTest.java index 0b91765..eb28ad1 100644 --- a/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/rent/RecordRentPaymentServiceTest.java +++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/rent/RecordRentPaymentServiceTest.java @@ -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; diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/rent/RefundRentPaymentServiceTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/rent/RefundRentPaymentServiceTest.java index 37ed79e..f07b058 100644 --- a/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/rent/RefundRentPaymentServiceTest.java +++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/rent/RefundRentPaymentServiceTest.java @@ -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; diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/rental/integration/ReplacementRentalApiJourneyIT.java b/backend/src/test/java/fr/nessar/lmnporganizer/rental/integration/ReplacementRentalApiJourneyIT.java index cc12910..10fca70 100644 --- a/backend/src/test/java/fr/nessar/lmnporganizer/rental/integration/ReplacementRentalApiJourneyIT.java +++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/integration/ReplacementRentalApiJourneyIT.java @@ -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()); diff --git a/frontend/src/app/components/rent-payment-dialogs.spec.ts b/frontend/src/app/components/rent-payment-dialogs.spec.ts index fde3bd1..ddc759d 100644 --- a/frontend/src/app/components/rent-payment-dialogs.spec.ts +++ b/frontend/src/app/components/rent-payment-dialogs.spec.ts @@ -8,7 +8,7 @@ import { RentPaymentRefundDialog } from './rent-payment-refund-dialog/rent-payme import { RentPaymentReversalDialog } from './rent-payment-reversal-dialog/rent-payment-reversal-dialog'; import { RentPaymentValidationDialog } from './rent-payment-validation-dialog/rent-payment-validation-dialog'; import { RentDue } from '../services/lease.service'; -import { utcIsoDate } from '../services/rent-payment-format'; +import { browserTimeZone, localIsoDate } from '../services/rent-payment-format'; import { RentPayment, RentPaymentService } from '../services/rent-payment.service'; describe('rent payment dialogs', () => { @@ -24,8 +24,10 @@ describe('rent payment dialogs', () => { vi.clearAllMocks(); }); - it('uses UTC for payment and refund date defaults', () => { - expect(utcIsoDate(new Date('2026-07-18T23:30:00Z'))).toBe('2026-07-18'); + it('uses the user local date for payment and refund defaults', () => { + const instant = new Date('2026-07-19T06:30:00Z'); + expect(localIsoDate(instant, 'America/Los_Angeles')).toBe('2026-07-18'); + expect(localIsoDate(instant, 'Europe/Paris')).toBe('2026-07-19'); }); it('records one received amount and exposes its surplus outcome', () => { @@ -44,6 +46,7 @@ describe('rent payment dialogs', () => { operationId: expect.any(String), rentDueId: 'due-1', rentDueVersion: 2, + timeZone: browserTimeZone, receivedAt: component.today, amount: 950, }); @@ -102,7 +105,7 @@ describe('rent payment dialogs', () => { component.submit(); expect(payments.refund).toHaveBeenCalledWith( 'payment-1', - expect.objectContaining({ amount: 100, paymentVersion: 3 }), + expect.objectContaining({ amount: 100, paymentVersion: 3, timeZone: browserTimeZone }), ); }); diff --git a/frontend/src/app/components/rent-payment-refund-dialog/rent-payment-refund-dialog.ts b/frontend/src/app/components/rent-payment-refund-dialog/rent-payment-refund-dialog.ts index 20ad8d8..5d76537 100644 --- a/frontend/src/app/components/rent-payment-refund-dialog/rent-payment-refund-dialog.ts +++ b/frontend/src/app/components/rent-payment-refund-dialog/rent-payment-refund-dialog.ts @@ -14,7 +14,7 @@ import { MatInputModule } from '@angular/material/input'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { finalize } from 'rxjs'; import { httpErrorMessage } from '../../services/http-error-message'; -import { formatEuro, utcIsoDate } from '../../services/rent-payment-format'; +import { browserTimeZone, formatEuro, localIsoDate } from '../../services/rent-payment-format'; import { RentPayment, RentPaymentService } from '../../services/rent-payment.service'; import { isRentPaymentDataStale, RENT_PAYMENT_DATA_STALE } from '../rent-payment-dialog-result'; @@ -36,7 +36,7 @@ export class RentPaymentRefundDialog { private readonly dialogRef = inject(MatDialogRef); private readonly payments = inject(RentPaymentService); private readonly operationId = crypto.randomUUID(); - readonly today = utcIsoDate(); + readonly today = localIsoDate(); readonly saving = signal(false); readonly error = signal(''); readonly formatEuro = formatEuro; @@ -65,6 +65,7 @@ export class RentPaymentRefundDialog { .refund(this.payment.id, { operationId: this.operationId, paymentVersion: this.payment.version, + timeZone: browserTimeZone, ...this.form.getRawValue(), }) .pipe(finalize(() => this.saving.set(false))) diff --git a/frontend/src/app/components/rent-payment-validation-dialog/rent-payment-validation-dialog.ts b/frontend/src/app/components/rent-payment-validation-dialog/rent-payment-validation-dialog.ts index 9625458..8982a5a 100644 --- a/frontend/src/app/components/rent-payment-validation-dialog/rent-payment-validation-dialog.ts +++ b/frontend/src/app/components/rent-payment-validation-dialog/rent-payment-validation-dialog.ts @@ -16,7 +16,7 @@ import { toSignal } from '@angular/core/rxjs-interop'; import { finalize } from 'rxjs'; import { RentDue } from '../../services/lease.service'; import { httpErrorMessage } from '../../services/http-error-message'; -import { formatEuro, utcIsoDate } from '../../services/rent-payment-format'; +import { browserTimeZone, formatEuro, localIsoDate } from '../../services/rent-payment-format'; import { RentPaymentService } from '../../services/rent-payment.service'; import { isRentPaymentDataStale, RENT_PAYMENT_DATA_STALE } from '../rent-payment-dialog-result'; @@ -43,7 +43,7 @@ export class RentPaymentValidationDialog { private readonly dialogRef = inject(MatDialogRef); private readonly payments = inject(RentPaymentService); private readonly operationId = crypto.randomUUID(); - readonly today = utcIsoDate(); + readonly today = localIsoDate(); readonly saving = signal(false); readonly error = signal(''); readonly formatEuro = formatEuro; @@ -82,6 +82,7 @@ export class RentPaymentValidationDialog { operationId: this.operationId, rentDueId: this.data.rentDue.id, rentDueVersion: this.data.rentDue.version, + timeZone: browserTimeZone, ...this.form.getRawValue(), }) .pipe(finalize(() => this.saving.set(false))) diff --git a/frontend/src/app/services/rent-payment-format.ts b/frontend/src/app/services/rent-payment-format.ts index e58a9e5..a46e6dd 100644 --- a/frontend/src/app/services/rent-payment-format.ts +++ b/frontend/src/app/services/rent-payment-format.ts @@ -9,6 +9,8 @@ export function formatEuro(value: number): string { return euroFormatter.format(value); } -export function utcIsoDate(date = new Date()): string { - return date.toISOString().slice(0, 10); +export const browserTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + +export function localIsoDate(date = new Date(), timeZone = browserTimeZone): string { + return new Intl.DateTimeFormat('sv-SE', { timeZone }).format(date); } diff --git a/frontend/src/app/services/rent-payment.service.spec.ts b/frontend/src/app/services/rent-payment.service.spec.ts index a4bc1ed..2821d57 100644 --- a/frontend/src/app/services/rent-payment.service.spec.ts +++ b/frontend/src/app/services/rent-payment.service.spec.ts @@ -19,6 +19,7 @@ describe('RentPaymentService', () => { rentDueVersion: 2, receivedAt: '2026-01-05', amount: 900, + timeZone: 'America/Los_Angeles', }; const allocation = { operationId: 'operation-2', @@ -32,6 +33,7 @@ describe('RentPaymentService', () => { paymentVersion: 2, amount: 25, refundedAt: '2026-01-06', + timeZone: 'America/Los_Angeles', }; service.getRentPayments().subscribe(); diff --git a/frontend/src/app/services/rent-payment.service.ts b/frontend/src/app/services/rent-payment.service.ts index adf3545..0d5604c 100644 --- a/frontend/src/app/services/rent-payment.service.ts +++ b/frontend/src/app/services/rent-payment.service.ts @@ -47,6 +47,7 @@ export interface RecordRentPaymentRequest { rentDueVersion: number; receivedAt: string; amount: number; + timeZone: string; } export interface AllocateRentPaymentRequest { @@ -62,6 +63,7 @@ export interface RefundRentPaymentRequest { paymentVersion: number; amount: number; refundedAt: string; + timeZone: string; } @Injectable({ providedIn: 'root' })