feat: add rent payment history and reversal dialogs

- Implemented RentPaymentHistoryServiceTest to validate payment retrieval with refunds and reversals.
- Created ReverseRentPaymentServiceTest to ensure correct handling of payment reversals and allocation releases.
- Developed rent-payment-allocation-dialog component with form for surplus allocation.
- Added rent-payment-refund-dialog component for processing refunds with validation.
- Introduced rent-payment-reversal-dialog component for reversing payments with reason input.
- Implemented utility functions for formatting Euro values and handling date formatting.
- Enhanced rent-payment-dialogs.spec.ts to cover new dialog functionalities and error handling.
This commit is contained in:
nessar committed 2026-08-17 14:18:15 +02:00
1 parent e02ad54bcb
commit b9e99348f7
76 files changed
+3141 -1531

No files matched your search

@@ -1,37 +1,67 @@
import { DatePipe } from '@angular/common';
import { Component, computed, inject, signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import {
EligibleRentDue,
RentPaymentAllocationDialog,
} from '../rent-payment-allocation-dialog/rent-payment-allocation-dialog';
import { RentPaymentRefundDialog } from '../rent-payment-refund-dialog/rent-payment-refund-dialog';
import { RentPaymentReversalDialog } from '../rent-payment-reversal-dialog/rent-payment-reversal-dialog';
import {
RentPaymentValidationDialog,
RentPaymentValidationDialogData,
} from '../rent-payment-validation-dialog/rent-payment-validation-dialog';
import { RentDue } from '../../services/lease.service';
import { formatEuro } from '../../services/rent-payment-format';
import { PaymentAllocation, RentPayment } from '../../services/rent-payment.service';
import { WorkspaceService } from '../../services/workspace.service';
import { DatePipe } from '@angular/common';
import { RentPaymentDialogResult, RENT_PAYMENT_DATA_STALE } from '../rent-payment-dialog-result';
interface PaymentFeedback {
kind: 'success' | 'error';
message: string;
}
@Component({
selector: 'app-rent-payments-page',
imports: [MatButtonModule, MatProgressBarModule, DatePipe],
imports: [DatePipe, MatButtonModule, MatProgressBarModule, MatProgressSpinnerModule],
templateUrl: './rent-payments-page.html',
styleUrl: './rent-payments-page.css',
})
export class RentPaymentsPage {
readonly workspace = inject(WorkspaceService);
private readonly dialog = inject(MatDialog);
readonly showFullSchedule = signal(false);
readonly feedback = signal<PaymentFeedback | null>(null);
readonly formatEuro = formatEuro;
readonly displayedRentDues = computed(() =>
this.showFullSchedule()
? sortRentDues(this.workspace.rentDues())
: currentRentDues(this.workspace.rentDues()),
);
readonly contentReady = computed(
readonly displayedPayments = computed(() =>
[...this.workspace.rentPayments()].sort(
(left, right) =>
right.receivedAt.localeCompare(left.receivedAt) ||
right.confirmedAt.localeCompare(left.confirmedAt),
),
);
readonly scheduleReady = computed(
() =>
this.workspace.rentDueLoadState().loaded &&
this.workspace.leaseLoadState().loaded &&
this.workspace.apartmentLoadState().loaded,
);
readonly contentLoading = computed(
readonly scheduleLoading = computed(
() =>
this.workspace.rentDueLoadState().loading ||
this.workspace.leaseLoadState().loading ||
this.workspace.apartmentLoadState().loading,
);
readonly loadError = computed(
readonly scheduleError = computed(
() =>
this.workspace.rentDueLoadState().error ||
this.workspace.leaseLoadState().error ||
@@ -66,15 +96,119 @@ export class RentPaymentsPage {
return { UPCOMING: 'À venir', OVERDUE: 'En retard' }[timing];
}
paymentStatusLabel(status: RentPayment['status']): string {
return { CONFIRMED: 'Confirmé', REVERSED: 'Contrepassé' }[status];
}
allocationLabel(allocation: PaymentAllocation): string {
const due = this.workspace.rentDues().find((item) => item.id === allocation.rentDueId);
return due ? `${this.apartmentName(due)} — ${this.monthLabel(due.month)}` : 'Échéance inconnue';
}
load(): void {
this.workspace.loadRentDues();
this.workspace.loadLeases();
this.workspace.loadApartments();
this.workspace.loadRentPayments();
}
toggleSchedule(): void {
this.showFullSchedule.update((showFullSchedule) => !showFullSchedule);
}
recordPayment(rentDue: RentDue): void {
this.dialog
.open<RentPaymentValidationDialog, RentPaymentValidationDialogData, RentPaymentDialogResult>(
RentPaymentValidationDialog,
{
data: { rentDue, apartmentName: this.apartmentName(rentDue) },
width: 'min(36rem, calc(100vw - 2rem))',
maxWidth: 'calc(100vw - 2rem)',
},
)
.afterClosed()
.subscribe((payment) => this.accept(payment, 'Le règlement a été enregistré.'));
}
allocateSurplus(payment: RentPayment): void {
const eligibleRentDues = this.eligibleRentDues(payment);
if (eligibleRentDues.length === 0) return;
this.dialog
.open<
RentPaymentAllocationDialog,
{ payment: RentPayment; eligibleRentDues: EligibleRentDue[] },
RentPaymentDialogResult
>(RentPaymentAllocationDialog, {
data: { payment, eligibleRentDues },
width: 'min(36rem, calc(100vw - 2rem))',
maxWidth: 'calc(100vw - 2rem)',
})
.afterClosed()
.subscribe((updated) => this.accept(updated, 'Le surplus a été affecté.'));
}
refundSurplus(payment: RentPayment): void {
this.dialog
.open<RentPaymentRefundDialog, RentPayment, RentPaymentDialogResult>(
RentPaymentRefundDialog,
{
data: payment,
width: 'min(34rem, calc(100vw - 2rem))',
maxWidth: 'calc(100vw - 2rem)',
},
)
.afterClosed()
.subscribe((updated) => this.accept(updated, 'Le remboursement a été enregistré.'));
}
reversePayment(payment: RentPayment): void {
this.dialog
.open<RentPaymentReversalDialog, RentPayment, RentPaymentDialogResult>(
RentPaymentReversalDialog,
{
data: payment,
role: 'alertdialog',
width: 'min(34rem, calc(100vw - 2rem))',
maxWidth: 'calc(100vw - 2rem)',
},
)
.afterClosed()
.subscribe((updated) => this.accept(updated, 'Le règlement a été contrepassé.'));
}
canAllocate(payment: RentPayment): boolean {
return payment.unallocatedBalance > 0 && this.eligibleRentDues(payment).length > 0;
}
private eligibleRentDues(payment: RentPayment): EligibleRentDue[] {
const allocatedDueIds = new Set(payment.allocations.map((allocation) => allocation.rentDueId));
return sortRentDues(this.workspace.rentDues())
.filter(
(due) =>
due.leaseId === payment.leaseId &&
due.settlement !== 'PAID' &&
!allocatedDueIds.has(due.id),
)
.map((rentDue) => ({
rentDue,
label: `${this.apartmentName(rentDue)} — ${this.monthLabel(rentDue.month)}`,
}));
}
private accept(result: RentPaymentDialogResult | undefined, message: string): void {
if (!result) return;
if (result === RENT_PAYMENT_DATA_STALE) {
this.workspace.loadRentDues();
this.workspace.loadRentPayments();
this.feedback.set({
kind: 'error',
message: 'Les données ont changé. Elles ont été actualisées ; recommencez l’opération.',
});
return;
}
this.workspace.acceptRentPayment(result);
this.feedback.set({ kind: 'success', message });
}
}
function sortRentDues(rentDues: readonly RentDue[]): RentDue[] {