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,88 +1,188 @@
.rent-payment-grid {
.rent-payment-grid,
.payment-history-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 20rem), 1fr));
gap: var(--space-4, 1rem);
}
.schedule-actions {
margin-bottom: var(--space-4, 1rem);
.schedule-actions,
.payment-history {
margin-block-start: var(--space-6, 1.5rem);
}
.rent-payment {
.schedule-actions {
margin-block-end: var(--space-4, 1rem);
}
.payment-history {
padding-block-start: var(--space-8, 2rem);
border-block-start: 1px solid var(--border, #dce5df);
}
.operation-feedback {
padding: var(--space-4, 1rem);
border: 1px solid;
border-radius: var(--radius-md, 0.8125rem);
}
.success-message {
border-color: var(--accent, #0f766e);
background: var(--surface-accent, #dcefeb);
color: var(--accent-strong, #0a5f59);
}
.error-message {
border-color: var(--danger, #b42318);
background: var(--danger-soft, #fee9e7);
color: var(--danger, #b42318);
}
.rent-payment,
.payment-history-card {
display: flex;
min-width: 0;
flex-direction: column;
padding: var(--space-5, 1.25rem);
border: 1px solid var(--border, #dce5df);
border-radius: var(--radius-xl, 1.375rem);
background: var(--surface, #ffffff);
}
.payment-history-card.reversed {
background: var(--surface-soft, #e9efeb);
}
.payment-topline,
.rent-payment dl > div {
.rent-payment dl > div,
.payment-history-card dl > div,
.movement-list li {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--space-3, 0.75rem);
}
.payment-topline {
margin-bottom: var(--space-4, 1rem);
margin-block-end: var(--space-4, 1rem);
}
.rent-payment h2 {
margin-bottom: var(--space-1, 0.25rem);
margin-block-end: var(--space-1, 0.25rem);
}
.payment-month,
.rent-payment dt {
.rent-payment dt,
.payment-history-card dt,
.payment-history-card > p {
color: var(--text-muted, #687670);
}
.rent-payment dl {
.rent-payment dl,
.payment-history-card dl {
margin: var(--space-5, 1.25rem) 0;
}
.rent-payment dl > div {
padding: var(--space-2, 0.5rem) 0;
border-bottom: 1px solid var(--border, #dce5df);
.rent-payment dl > div,
.payment-history-card dl > div {
padding-block: var(--space-2, 0.5rem);
border-block-end: 1px solid var(--border, #dce5df);
}
.rent-payment dd {
.rent-payment dd,
.payment-history-card dd {
margin: 0;
font-weight: 700;
}
.rent-due-labels {
.rent-due-labels,
.card-actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-2, 0.5rem);
}
.card-actions {
margin-block-start: auto;
padding-block-start: var(--space-4, 1rem);
}
.card-actions button {
min-height: 2.75rem;
}
.settlement-status,
.timing-status {
.timing-status,
.payment-status {
font-size: 0.8rem;
font-weight: 700;
}
.settlement-status.paid {
.settlement-status.paid,
.payment-status.confirmed {
color: var(--accent, #0f766e);
}
.settlement-status.unpaid,
.settlement-status.partially_paid {
color: var(--warning, #9a6700);
}
.timing-status.overdue {
.timing-status.overdue,
.payment-status.reversed,
.danger-link.mat-mdc-button {
color: var(--danger, #b42318);
}
.timing-status.upcoming {
color: var(--text-muted, #687670);
}
@media (min-width: 700px) {
.rent-payment-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.movement-list {
margin-block-start: var(--space-4, 1rem);
}
@media (min-width: 1100px) {
.rent-payment-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.movement-list h3 {
margin-block-end: var(--space-2, 0.5rem);
font-size: 1rem;
}
.movement-list ul {
display: grid;
gap: var(--space-2, 0.5rem);
margin: 0;
padding: 0;
list-style: none;
}
.movement-list li {
min-width: 0;
padding: var(--space-2, 0.5rem);
border-radius: var(--radius-sm, 0.75rem);
background: var(--surface-soft, #e9efeb);
}
.movement-list li span {
min-width: 0;
overflow-wrap: break-word;
}
.reversal-details {
display: grid;
gap: var(--space-2, 0.5rem);
margin-block-start: var(--space-4, 1rem);
padding: var(--space-4, 1rem);
border-radius: var(--radius-md, 0.8125rem);
background: var(--danger-soft, #fee9e7);
color: var(--danger, #b42318);
}
.card-error {
margin: var(--space-3, 0.75rem) 0 0;
color: var(--danger, #b42318);
}
mat-spinner {
display: inline-block;
margin-inline-end: var(--space-2, 0.5rem);
}
@@ -3,18 +3,29 @@
<div>
<p class="eyebrow">Location</p>
<h1>Mes loyers</h1>
<p>Suivez les échéances de vos baux actifs.</p>
<p>Suivez les échéances et enregistrez les règlements déjà reçus.</p>
</div>
</header>
<div class="content-region" [attr.aria-busy]="contentLoading()">
@if (!contentReady() && loadError()) {
@if (feedback(); as feedback) {
<p
class="operation-feedback"
[class.success-message]="feedback.kind === 'success'"
[class.error-message]="feedback.kind === 'error'"
[attr.role]="feedback.kind === 'error' ? 'alert' : 'status'"
>
{{ feedback.message }}
</p>
}
<div class="content-region" [attr.aria-busy]="scheduleLoading()">
@if (!scheduleReady() && scheduleError()) {
<article class="load-error" role="alert">
<h2>Chargement impossible</h2>
<p>{{ loadError() }}</p>
<p>{{ scheduleError() }}</p>
<button mat-stroked-button type="button" (click)="load()">Réessayer</button>
</article>
} @else if (!contentReady()) {
} @else if (!scheduleReady()) {
<div class="rent-payment-grid" aria-label="Chargement des loyers">
@for (placeholder of [1, 2, 3]; track placeholder) {
<article class="skeleton-card" aria-hidden="true">
@@ -25,12 +36,12 @@
}
</div>
} @else {
@if (contentLoading()) {
@if (scheduleLoading()) {
<mat-progress-bar mode="indeterminate" aria-label="Actualisation des loyers" />
}
@if (loadError()) {
@if (scheduleError()) {
<div class="refresh-error" role="alert">
<p>{{ loadError() }}</p>
<p>{{ scheduleError() }}</p>
<button mat-button type="button" (click)="load()">Réessayer</button>
</div>
}
@@ -66,32 +77,196 @@
{{ timingLabel(rentDue.timing) }}
</span>
</div>
<strong>{{ rentDue.total.toLocaleString('fr-FR') }} €</strong>
<strong>{{ formatEuro(rentDue.total) }}</strong>
</div>
<h2>{{ apartmentName(rentDue) }}</h2>
<p class="payment-month">{{ monthLabel(rentDue.month) }}</p>
<dl>
<div>
<dt>Loyer</dt>
<dd>{{ rentDue.rent.toLocaleString('fr-FR') }} €</dd>
<dd>{{ formatEuro(rentDue.rent) }}</dd>
</div>
<div>
<dt>Charges</dt>
<dd>{{ rentDue.charges.toLocaleString('fr-FR') }} €</dd>
<dd>{{ formatEuro(rentDue.charges) }}</dd>
</div>
<div>
<dt>Déjà réglé</dt>
<dd>{{ formatEuro(rentDue.allocatedAmount) }}</dd>
</div>
<div>
<dt>Restant à régler</dt>
<dd>{{ rentDue.remainingAmount.toLocaleString('fr-FR') }} €</dd>
<dd>{{ formatEuro(rentDue.remainingAmount) }}</dd>
</div>
<div>
<dt>Échéance</dt>
<dd>{{ rentDue.dueDate | date: 'dd MMMM yyyy' }}</dd>
</div>
</dl>
<div class="card-actions">
@if (rentDue.settlement !== 'PAID') {
<button mat-flat-button type="button" (click)="recordPayment(rentDue)">
Enregistrer un règlement
</button>
} @else {
<button
mat-stroked-button
type="button"
[disabled]="workspace.rentReceiptDownloading(rentDue.id)"
(click)="workspace.downloadRentReceipt(rentDue)"
>
@if (workspace.rentReceiptDownloading(rentDue.id)) {
<mat-spinner diameter="18" aria-label="Téléchargement en cours" />
}
Télécharger la quittance
</button>
}
</div>
@if (workspace.rentReceiptDownloadError(rentDue.id)) {
<p class="card-error" role="alert">
{{ workspace.rentReceiptDownloadError(rentDue.id) }}
</p>
}
</article>
}
</div>
}
}
</div>
<section class="payment-history" aria-labelledby="payment-history-title">
<header>
<p class="eyebrow">Encaissements</p>
<h2 id="payment-history-title">Règlements enregistrés</h2>
</header>
<div [attr.aria-busy]="workspace.rentPaymentLoadState().loading">
@if (!workspace.rentPaymentLoadState().loaded && workspace.rentPaymentLoadState().error) {
<article class="load-error" role="alert">
<p>{{ workspace.rentPaymentLoadState().error }}</p>
<button mat-stroked-button type="button" (click)="workspace.loadRentPayments()">
Réessayer
</button>
</article>
} @else if (!workspace.rentPaymentLoadState().loaded) {
<div class="payment-history-grid" aria-label="Chargement des règlements">
@for (placeholder of [1, 2]; track placeholder) {
<article class="skeleton-card" aria-hidden="true"></article>
}
</div>
} @else {
@if (workspace.rentPaymentLoadState().loading) {
<mat-progress-bar mode="indeterminate" aria-label="Actualisation des règlements" />
}
@if (workspace.rentPaymentLoadState().error) {
<p class="refresh-error" role="alert">{{ workspace.rentPaymentLoadState().error }}</p>
}
@if (displayedPayments().length === 0) {
<article class="empty-state"><p>Aucun règlement enregistré.</p></article>
} @else {
<div class="payment-history-grid">
@for (payment of displayedPayments(); track payment.id) {
<article
class="payment-history-card"
[class.reversed]="payment.status === 'REVERSED'"
>
<div class="payment-topline">
<span class="payment-status" [class]="payment.status.toLowerCase()">
{{ paymentStatusLabel(payment.status) }}
</span>
<strong>{{ formatEuro(payment.amount) }}</strong>
</div>
<p>Reçu le {{ payment.receivedAt | date: 'dd MMMM yyyy' }}</p>
<dl>
<div>
<dt>Affecté</dt>
<dd>{{ formatEuro(payment.allocatedAmount) }}</dd>
</div>
<div>
<dt>Remboursé</dt>
<dd>{{ formatEuro(payment.refundedAmount) }}</dd>
</div>
<div>
<dt>Solde non affecté</dt>
<dd>{{ formatEuro(payment.unallocatedBalance) }}</dd>
</div>
</dl>
@if (payment.allocations.length > 0) {
<div class="movement-list">
<h3>Affectations</h3>
<ul>
@for (allocation of payment.allocations; track allocation.id) {
<li>
<span>{{ allocationLabel(allocation) }}</span
><strong>{{ formatEuro(allocation.amount) }}</strong>
</li>
}
</ul>
</div>
}
@if (payment.refunds.length > 0) {
<div class="movement-list">
<h3>Remboursements</h3>
<ul>
@for (refund of payment.refunds; track refund.id) {
<li>
<span>{{ refund.refundedAt | date: 'dd MMMM yyyy' }}</span
><strong>{{ formatEuro(refund.amount) }}</strong>
</li>
}
</ul>
</div>
}
@if (payment.reversal; as reversal) {
<div class="reversal-details">
<strong>Contrepassé le {{ reversal.reversedAt | date: 'dd MMMM yyyy' }}</strong>
<span>{{ reversal.reason }}</span>
</div>
}
@if (payment.status === 'CONFIRMED') {
<div class="card-actions">
<button
mat-stroked-button
type="button"
[disabled]="workspace.paymentReceiptDownloading(payment.id)"
(click)="workspace.downloadPaymentReceipt(payment)"
>
@if (workspace.paymentReceiptDownloading(payment.id)) {
<mat-spinner diameter="18" aria-label="Téléchargement en cours" />
}
Télécharger le reçu
</button>
@if (canAllocate(payment)) {
<button mat-stroked-button type="button" (click)="allocateSurplus(payment)">
Affecter
</button>
}
@if (payment.unallocatedBalance > 0) {
<button mat-stroked-button type="button" (click)="refundSurplus(payment)">
Rembourser
</button>
}
<button
mat-button
type="button"
class="danger-link"
(click)="reversePayment(payment)"
>
Contrepasser
</button>
</div>
}
@if (workspace.paymentReceiptDownloadError(payment.id)) {
<p class="card-error" role="alert">
{{ workspace.paymentReceiptDownloadError(payment.id) }}
</p>
}
</article>
}
</div>
}
}
</div>
</section>
</section>
@@ -1,103 +1,90 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { of } from 'rxjs';
import { 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 } from '../rent-payment-validation-dialog/rent-payment-validation-dialog';
import { RentPaymentDialogResult, RENT_PAYMENT_DATA_STALE } from '../rent-payment-dialog-result';
import { RentDue } from '../../services/lease.service';
import { RentPayment } from '../../services/rent-payment.service';
import { WorkspaceService } from '../../services/workspace.service';
import { RentPaymentsPage } from './rent-payments-page';
describe('RentPaymentsPage', () => {
it('loads and renders RentDue records instead of the legacy combined model', () => {
const workspace = {
...createWorkspace([rentDue('due-1', '2027-08', 'UPCOMING')]),
rentPayments: signal([payment('legacy-payment', '2026-07', 'LATE')]),
loadRentPayments: vi.fn(),
};
const fixture = createFixture(workspace);
it('loads dues and payment history and offers recording on every non-paid due', () => {
const { fixture, workspace } = createFixture([
rentDue('due-1', '2027-08', 'UPCOMING'),
rentDue('due-2', '2027-09', 'UPCOMING', 'PAID', { allocatedAmount: 850, remainingAmount: 0 }),
]);
expect(workspace.loadRentDues).toHaveBeenCalledOnce();
expect(workspace.loadRentPayments).not.toHaveBeenCalled();
expect(paymentMonths(fixture)).toEqual(['août 2027']);
expect(fixture.nativeElement.textContent).not.toContain('Marquer comme payé');
expect(workspace.loadRentPayments).toHaveBeenCalledOnce();
expect(button(fixture, 'Enregistrer un règlement')).not.toBeNull();
expect(fixture.nativeElement.textContent).toContain('Aucun règlement enregistré');
});
it('renders amounts, settlement and timing as independent RentDue facts', () => {
const fixture = createFixture(
createWorkspace([
rentDue('due-1', '2026-07', 'OVERDUE', 'PARTIALLY_PAID', {
remainingAmount: 350,
}),
]),
);
it('renders amounts, settlement, and timing as independent RentDue facts', () => {
const { fixture } = createFixture([
rentDue('due-1', '2026-07', 'OVERDUE', 'PARTIALLY_PAID', {
allocatedAmount: 500,
remainingAmount: 350,
}),
]);
const card = fixture.nativeElement.querySelector('.rent-payment') as HTMLElement;
expect(card.textContent).toContain('T2');
expect(card.textContent).toContain('800 €');
expect(card.textContent).toContain('50 €');
expect(card.textContent).toContain('850 €');
expect(card.textContent).toContain('350 €');
expect(card.textContent).toContain('800,00 €');
expect(card.textContent).toContain('500,00 €');
expect(card.textContent).toContain('350,00 €');
expect(card.querySelector('.settlement-status.partially_paid')?.textContent).toContain(
'Partiellement réglée',
);
expect(card.querySelector('.timing-status.overdue')?.textContent).toContain('En retard');
expect(card.querySelector('.card-actions')).toBeNull();
});
it('shows overdue dues through the next upcoming due', () => {
const workspace = createWorkspace([
const { fixture } = createFixture([
rentDue('overdue-january', '2026-01', 'OVERDUE'),
rentDue('overdue-february', '2026-02', 'OVERDUE'),
rentDue('upcoming-march', '2026-03', 'UPCOMING'),
rentDue('upcoming-april', '2026-04', 'UPCOMING'),
]);
const fixture = createFixture(workspace);
expect(paymentMonths(fixture)).toEqual(['janvier 2026', 'février 2026', 'mars 2026']);
});
it('starts the current schedule at the older of the last paid and first unpaid due', () => {
const workspace = createWorkspace([
rentDue('paid-january', '2026-01', 'OVERDUE', 'PAID'),
const { fixture } = createFixture([
rentDue('paid-january', '2026-01', 'OVERDUE', 'PAID', {
allocatedAmount: 850,
remainingAmount: 0,
}),
rentDue('unpaid-february', '2026-02', 'OVERDUE'),
rentDue('paid-march', '2026-03', 'OVERDUE', 'PAID'),
rentDue('paid-march', '2026-03', 'OVERDUE', 'PAID', {
allocatedAmount: 850,
remainingAmount: 0,
}),
rentDue('unpaid-april', '2026-04', 'OVERDUE'),
rentDue('upcoming-may', '2026-05', 'UPCOMING'),
rentDue('upcoming-june', '2026-06', 'UPCOMING'),
]);
const fixture = createFixture(workspace);
expect(paymentMonths(fixture)).toEqual([
'février 2026',
'mars 2026',
'avril 2026',
'mai 2026',
]);
});
it('shows only the next due when none are overdue', () => {
const workspace = createWorkspace([
rentDue('upcoming-march', '2026-03', 'UPCOMING'),
rentDue('upcoming-april', '2026-04', 'UPCOMING'),
]);
const fixture = createFixture(workspace);
expect(paymentMonths(fixture)).toEqual(['mars 2026']);
expect(paymentMonths(fixture)).toEqual(['février 2026', 'mars 2026', 'avril 2026', 'mai 2026']);
});
it('reveals and hides the full chronological schedule', () => {
const workspace = createWorkspace([
const { fixture } = createFixture([
rentDue('upcoming-april', '2026-04', 'UPCOMING'),
rentDue('overdue-january', '2026-01', 'OVERDUE'),
rentDue('upcoming-march', '2026-03', 'UPCOMING'),
rentDue('overdue-february', '2026-02', 'OVERDUE'),
]);
const fixture = createFixture(workspace);
const toggle = fixture.nativeElement.querySelector('[aria-pressed]') as HTMLButtonElement;
expect(toggle.textContent).toContain('Afficher toutes les échéances');
expect(paymentMonths(fixture)).toEqual(['janvier 2026', 'février 2026', 'mars 2026']);
toggle.click();
fixture.detectChanges();
expect(toggle.getAttribute('aria-pressed')).toBe('true');
expect(paymentMonths(fixture)).toEqual([
'janvier 2026',
@@ -105,52 +92,134 @@ describe('RentPaymentsPage', () => {
'mars 2026',
'avril 2026',
]);
toggle.click();
fixture.detectChanges();
expect(toggle.getAttribute('aria-pressed')).toBe('false');
expect(paymentMonths(fixture)).toEqual(['janvier 2026', 'février 2026', 'mars 2026']);
});
it('shows loading and error states before the schedule is ready', () => {
const loadingFixture = createFixture(
createWorkspace([], { loaded: false, loading: true, error: '' }),
);
expect(loadingFixture.nativeElement.querySelector('.skeleton-card')).not.toBeNull();
it('keeps the payment modal result and refreshes the ledger after success', () => {
const recorded = payment({ unallocatedBalance: 100, amount: 950 });
const { fixture, workspace, dialog } = createFixture([rentDue('due-1', '2026-07', 'UPCOMING')]);
dialog.open.mockReturnValueOnce({ afterClosed: () => of(recorded) });
const errorFixture = createFixture(
createWorkspace([], { loaded: false, loading: false, error: 'Erreur de chargement.' }),
button(fixture, 'Enregistrer un règlement')?.click();
fixture.detectChanges();
expect(dialog.open).toHaveBeenCalledWith(
RentPaymentValidationDialog,
expect.objectContaining({
data: expect.objectContaining({ apartmentName: 'T2' }),
}),
);
expect(errorFixture.nativeElement.textContent).toContain('Erreur de chargement.');
expect(workspace.acceptRentPayment).toHaveBeenCalledWith(recorded);
expect(fixture.nativeElement.textContent).toContain('Le règlement a été enregistré.');
});
it('refreshes stale payment data and announces the rejected operation', () => {
const { fixture, workspace, dialog } = createFixture([rentDue('due-1', '2026-07', 'UPCOMING')]);
dialog.open.mockReturnValueOnce({ afterClosed: () => of(RENT_PAYMENT_DATA_STALE) });
button(fixture, 'Enregistrer un règlement')?.click();
fixture.detectChanges();
expect(workspace.loadRentDues).toHaveBeenCalledTimes(2);
expect(workspace.loadRentPayments).toHaveBeenCalledTimes(2);
expect(workspace.acceptRentPayment).not.toHaveBeenCalled();
expect(fixture.nativeElement.querySelector('[role="alert"]')?.textContent).toContain(
'Les données ont changé',
);
});
it('shows confirmed payment history and opens each lifecycle action', () => {
const recorded = payment({ unallocatedBalance: 100, amount: 950 });
const { fixture, dialog } = createFixture(
[
rentDue('due-1', '2026-07', 'OVERDUE', 'PAID', {
allocatedAmount: 850,
remainingAmount: 0,
}),
rentDue('due-2', '2026-08', 'UPCOMING'),
],
[recorded],
);
dialog.open.mockReturnValue({ afterClosed: () => of(undefined) });
button(fixture, 'Affecter')?.click();
button(fixture, 'Rembourser')?.click();
button(fixture, 'Contrepasser')?.click();
expect(dialog.open.mock.calls.map((call) => call[0])).toEqual([
RentPaymentAllocationDialog,
RentPaymentRefundDialog,
RentPaymentReversalDialog,
]);
expect(fixture.nativeElement.textContent).toContain('100,00 €');
expect(fixture.nativeElement.textContent).toContain('Télécharger le reçu');
});
it('keeps reversal metadata visible without mutation actions', () => {
const reversed = payment({
status: 'REVERSED',
reversal: {
id: 'reversal-1',
reversedAt: '2026-07-08',
reason: 'Erreur de saisie',
reversedAllocations: [],
},
});
const { fixture } = createFixture([], [reversed]);
const history = fixture.nativeElement.querySelector('.payment-history-card') as HTMLElement;
expect(history.textContent).toContain('Contrepassé');
expect(history.textContent).toContain('Erreur de saisie');
expect(history.textContent).not.toContain('Télécharger le reçu');
});
it('shows independent loading and error states', () => {
const { fixture } = createFixture([], [], { loaded: false, loading: false, error: 'Erreur.' });
expect(fixture.nativeElement.textContent).toContain('Erreur.');
expect(fixture.nativeElement.querySelector('[role="alert"]')).not.toBeNull();
});
});
function createFixture(workspace: ReturnType<typeof createWorkspace>) {
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [{ provide: WorkspaceService, useValue: workspace }],
});
const fixture = TestBed.createComponent(RentPaymentsPage);
fixture.detectChanges();
return fixture;
}
function createWorkspace(
function createFixture(
rentDues: RentDue[],
rentPayments: RentPayment[] = [],
loadState = { loaded: true, loading: false, error: '' },
) {
return {
TestBed.resetTestingModule();
const workspace = {
apartments: signal([{ id: 'apartment-1', name: 'T2' }]),
leases: signal([{ id: 'lease-1', apartmentId: 'apartment-1' }]),
rentDues: signal(rentDues),
rentPayments: signal(rentPayments),
apartmentLoadState: signal(loadState),
leaseLoadState: signal(loadState),
rentDueLoadState: signal(loadState),
rentPaymentLoadState: signal(loadState),
loadApartments: vi.fn(),
loadLeases: vi.fn(),
loadRentDues: vi.fn(),
loadRentPayments: vi.fn(),
acceptRentPayment: vi.fn(),
rentReceiptDownloading: vi.fn(() => false),
rentReceiptDownloadError: vi.fn(() => ''),
downloadRentReceipt: vi.fn(),
paymentReceiptDownloading: vi.fn(() => false),
paymentReceiptDownloadError: vi.fn(() => ''),
downloadPaymentReceipt: vi.fn(),
};
const dialog = {
open: vi
.fn()
.mockReturnValue({ afterClosed: () => of<RentPaymentDialogResult | undefined>(undefined) }),
};
TestBed.configureTestingModule({
providers: [
{ provide: WorkspaceService, useValue: workspace },
{ provide: MatDialog, useValue: dialog },
],
});
const fixture = TestBed.createComponent(RentPaymentsPage);
fixture.detectChanges();
return { fixture, workspace, dialog };
}
function rentDue(
@@ -168,29 +237,47 @@ function rentDue(
rent: 800,
charges: 50,
total: 850,
allocatedAmount: 0,
remainingAmount: 850,
settlement,
timing,
version: 0,
...overrides,
};
}
function payment(id: string, month: string, status: 'PAID' | 'LATE' | 'UPCOMING') {
function payment(overrides: Partial<RentPayment> = {}): RentPayment {
return {
id,
id: 'payment-1',
leaseId: 'lease-1',
month,
dueDate: `${month}-05`,
rent: 800,
charges: 50,
total: 850,
status,
paidAt: status === 'PAID' ? `${month}-05` : null,
receivedAt: '2026-07-06',
amount: 850,
status: 'CONFIRMED',
allocations: [
{ id: 'allocation-1', rentDueId: 'due-1', amount: 850, allocatedAt: '2026-07-06' },
],
allocatedAmount: 850,
refundedAmount: 0,
unallocatedBalance: 0,
confirmedAt: '2026-07-06',
version: 0,
refunds: [],
reversal: null,
...overrides,
};
}
function paymentMonths(fixture: ReturnType<typeof createFixture>): string[] {
function paymentMonths(fixture: ReturnType<typeof TestBed.createComponent>): string[] {
return [...fixture.nativeElement.querySelectorAll('.payment-month')].map((element) =>
element.textContent.trim(),
);
}
function button(
fixture: ReturnType<typeof TestBed.createComponent>,
text: string,
): HTMLButtonElement | undefined {
return [...fixture.nativeElement.querySelectorAll('button')].find((element) =>
element.textContent.includes(text),
);
}
@@ -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[] {