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

@@ -0,0 +1,41 @@
.action-dialog-content,
.dialog-fieldset {
display: grid;
gap: var(--space-4, 1rem);
}
.action-dialog-content {
width: min(32rem, calc(100vw - 4rem));
max-width: 100%;
max-height: 75vh;
overscroll-behavior-y: contain;
}
.dialog-fieldset {
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.outcome-summary {
margin: 0;
padding: var(--space-4, 1rem);
border: 1px solid var(--border, #dce5df);
border-radius: var(--radius-md, 0.8125rem);
background: var(--surface-accent, #dcefeb);
}
.dialog-error {
margin: 0;
color: var(--danger, #b42318);
}
mat-dialog-actions button {
min-height: 2.75rem;
}
mat-spinner {
display: inline-block;
margin-inline-end: var(--space-2, 0.5rem);
}
@@ -0,0 +1,49 @@
<h2 mat-dialog-title>Affecter le surplus</h2>
<form [formGroup]="form" (ngSubmit)="submit()">
<mat-dialog-content class="action-dialog-content">
<p>
Solde non affecté disponible :
<strong>{{ formatEuro(data.payment.unallocatedBalance) }}</strong>
</p>
<fieldset class="dialog-fieldset" [disabled]="saving()">
<mat-form-field appearance="outline">
<mat-label>Échéance cible</mat-label>
<mat-select formControlName="rentDueId" required>
@for (item of data.eligibleRentDues; track item.rentDue.id) {
<mat-option [value]="item.rentDue.id">
{{ item.label }} — {{ formatEuro(item.rentDue.remainingAmount) }} restant
</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Montant à affecter</mat-label>
<input matInput type="number" step="0.01" min="0.01" formControlName="amount" required />
<span matTextSuffix>€</span>
@if (form.controls.amount.hasError('unavailableAmount')) {
<mat-error>Le montant dépasse le surplus ou le solde de l’échéance.</mat-error>
} @else if (form.controls.amount.invalid) {
<mat-error>Le montant doit être strictement positif.</mat-error>
}
</mat-form-field>
</fieldset>
@if (selectedRentDue(); as item) {
<p class="outcome-summary" aria-live="polite">
Solde de l’échéance après affectation :
<strong>{{ formatEuro(remainingAfter()) }}</strong>
</p>
}
@if (error()) {
<p class="dialog-error" role="alert">{{ error() }}</p>
}
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button mat-dialog-close type="button" [disabled]="saving()">Annuler</button>
<button mat-flat-button type="submit" [disabled]="form.invalid || saving()">
@if (saving()) {
<mat-spinner diameter="18" aria-label="Affectation en cours" />
}
Confirmer l’affectation
</button>
</mat-dialog-actions>
</form>
@@ -0,0 +1,131 @@
import { Component, computed, DestroyRef, inject, signal } from '@angular/core';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import {
AbstractControl,
FormControl,
FormGroup,
ReactiveFormsModule,
ValidationErrors,
Validators,
} from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSelectModule } from '@angular/material/select';
import { finalize } from 'rxjs';
import { RentDue } from '../../services/lease.service';
import { httpErrorMessage } from '../../services/http-error-message';
import { formatEuro } from '../../services/rent-payment-format';
import { RentPayment, RentPaymentService } from '../../services/rent-payment.service';
import { isRentPaymentDataStale, RENT_PAYMENT_DATA_STALE } from '../rent-payment-dialog-result';
export interface EligibleRentDue {
rentDue: RentDue;
label: string;
}
export interface RentPaymentAllocationDialogData {
payment: RentPayment;
eligibleRentDues: EligibleRentDue[];
}
@Component({
selector: 'app-rent-payment-allocation-dialog',
imports: [
ReactiveFormsModule,
MatButtonModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule,
MatProgressSpinnerModule,
MatSelectModule,
],
templateUrl: './rent-payment-allocation-dialog.html',
styleUrl: './rent-payment-allocation-dialog.css',
})
export class RentPaymentAllocationDialog {
readonly data = inject<RentPaymentAllocationDialogData>(MAT_DIALOG_DATA);
private readonly dialogRef = inject(MatDialogRef<RentPaymentAllocationDialog>);
private readonly payments = inject(RentPaymentService);
private readonly operationId = crypto.randomUUID();
readonly saving = signal(false);
readonly error = signal('');
readonly formatEuro = formatEuro;
readonly form = new FormGroup({
rentDueId: new FormControl(this.data.eligibleRentDues[0]?.rentDue.id ?? '', {
nonNullable: true,
validators: Validators.required,
}),
amount: new FormControl(0, {
nonNullable: true,
validators: [Validators.required, Validators.min(0.01)],
}),
});
private readonly rentDueId = toSignal(this.form.controls.rentDueId.valueChanges, {
initialValue: this.form.controls.rentDueId.value,
});
readonly selectedRentDue = computed(() =>
this.data.eligibleRentDues.find((item) => item.rentDue.id === this.rentDueId()),
);
readonly maximumAmount = computed(() =>
Math.min(
this.data.payment.unallocatedBalance,
this.selectedRentDue()?.rentDue.remainingAmount ?? 0,
),
);
private readonly amount = toSignal(this.form.controls.amount.valueChanges, {
initialValue: this.form.controls.amount.value,
});
readonly remainingAfter = computed(() =>
Math.max((this.selectedRentDue()?.rentDue.remainingAmount ?? 0) - (this.amount() ?? 0), 0),
);
constructor() {
const destroyRef = inject(DestroyRef);
this.form.controls.amount.addValidators(this.withinAvailableAmount.bind(this));
this.form.controls.rentDueId.valueChanges
.pipe(takeUntilDestroyed(destroyRef))
.subscribe(() => this.useMaximum());
this.useMaximum();
}
useMaximum(): void {
this.form.controls.amount.setValue(this.maximumAmount());
this.form.controls.amount.updateValueAndValidity();
}
submit(): void {
this.form.markAllAsTouched();
const due = this.selectedRentDue()?.rentDue;
if (!due || this.form.invalid || this.saving()) return;
this.saving.set(true);
this.error.set('');
this.payments
.allocate(this.data.payment.id, {
operationId: this.operationId,
paymentVersion: this.data.payment.version,
rentDueId: due.id,
rentDueVersion: due.version,
amount: this.form.controls.amount.value,
})
.pipe(finalize(() => this.saving.set(false)))
.subscribe({
next: (payment) => this.dialogRef.close(payment),
error: (error) => {
if (isRentPaymentDataStale(error)) {
this.dialogRef.close(RENT_PAYMENT_DATA_STALE);
return;
}
this.error.set(httpErrorMessage(error, 'Impossible d’affecter ce règlement.'));
},
});
}
private withinAvailableAmount(control: AbstractControl<number>): ValidationErrors | null {
return control.value > this.maximumAmount() ? { unavailableAmount: true } : null;
}
}