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:
1 parent
e02ad54bcb
commit
b9e99348f7
76 files changed
+3141
-1531
No files matched your search
+130
-1
@@ -275,6 +275,9 @@ test('creates a tenant with an attachment and a lease with a guarantor', async (
|
||||
}
|
||||
await route.fulfill({ contentType: 'application/json', json: leases });
|
||||
});
|
||||
await page.route('**/api/rent-dues', (route) =>
|
||||
route.fulfill({ contentType: 'application/json', json: [] }),
|
||||
);
|
||||
|
||||
await page.goto('/');
|
||||
await page.getByTestId('primary-navigation').getByRole('button', { name: 'Locataires' }).click();
|
||||
@@ -312,7 +315,7 @@ test('creates a tenant with an attachment and a lease with a guarantor', async (
|
||||
await page.getByLabel('Nom du garant', { exact: true }).fill('Durand');
|
||||
await page.getByLabel('E-mail du garant').fill('alex@example.com');
|
||||
await page.getByRole('button', { name: 'Ajouter le garant' }).click();
|
||||
await page.getByRole('button', { name: 'Enregistrer le bail' }).click();
|
||||
await page.getByRole('button', { name: 'Enregistrer le brouillon' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Studio République' })).toBeVisible();
|
||||
await expect(page.getByText('Alex Durand')).toBeVisible();
|
||||
@@ -322,3 +325,129 @@ test('creates a tenant with an attachment and a lease with a guarantor', async (
|
||||
expect(download.suggestedFilename()).toBe('bail-lease-1.pdf');
|
||||
await expectNoHorizontalOverflow(page);
|
||||
});
|
||||
|
||||
test('records a received rent payment and keeps its surplus visible', async ({ page }) => {
|
||||
const apartment = {
|
||||
id: 'apartment-1',
|
||||
name: 'Studio République',
|
||||
address: '12 rue de la République, Lyon',
|
||||
purchasePrice: 150000,
|
||||
description: '',
|
||||
};
|
||||
const lease = {
|
||||
id: 'lease-1',
|
||||
apartmentId: apartment.id,
|
||||
tenantIds: ['tenant-1'],
|
||||
guarantors: [],
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2027-06-30',
|
||||
monthlyRent: 850,
|
||||
monthlyCharges: 50,
|
||||
securityDeposit: 850,
|
||||
status: 'ACTIVE',
|
||||
};
|
||||
let rentDues = [
|
||||
{
|
||||
id: 'due-1',
|
||||
leaseId: lease.id,
|
||||
month: '2026-07',
|
||||
dueDate: '2026-07-05',
|
||||
rent: 850,
|
||||
charges: 50,
|
||||
total: 900,
|
||||
allocatedAmount: 0,
|
||||
remainingAmount: 900,
|
||||
settlement: 'UNPAID',
|
||||
timing: 'OVERDUE',
|
||||
version: 1,
|
||||
},
|
||||
];
|
||||
let payments: Record<string, unknown>[] = [];
|
||||
let submittedPayment: Record<string, unknown> | undefined;
|
||||
|
||||
await mockUser(page, true);
|
||||
await page.route('**/api/apartments', (route) =>
|
||||
route.fulfill({ contentType: 'application/json', json: [apartment] }),
|
||||
);
|
||||
await page.route('**/api/leases', (route) =>
|
||||
route.fulfill({ contentType: 'application/json', json: [lease] }),
|
||||
);
|
||||
await page.route('**/api/rent-dues', (route) =>
|
||||
route.fulfill({ contentType: 'application/json', json: rentDues }),
|
||||
);
|
||||
await page.route('**/api/rent-payments', async (route) => {
|
||||
if (route.request().method() === 'POST') {
|
||||
submittedPayment = route.request().postDataJSON() as Record<string, unknown>;
|
||||
const receivedAt = submittedPayment['receivedAt'] as string;
|
||||
payments = [
|
||||
{
|
||||
id: 'payment-1',
|
||||
leaseId: lease.id,
|
||||
receivedAt,
|
||||
amount: 950,
|
||||
status: 'CONFIRMED',
|
||||
allocations: [
|
||||
{
|
||||
id: 'allocation-1',
|
||||
rentDueId: 'due-1',
|
||||
amount: 900,
|
||||
allocatedAt: `${receivedAt}T12:00:00Z`,
|
||||
},
|
||||
],
|
||||
allocatedAmount: 900,
|
||||
refundedAmount: 0,
|
||||
unallocatedBalance: 50,
|
||||
confirmedAt: `${receivedAt}T12:00:00Z`,
|
||||
version: 1,
|
||||
refunds: [],
|
||||
reversal: null,
|
||||
},
|
||||
];
|
||||
rentDues = [
|
||||
{
|
||||
...rentDues[0],
|
||||
allocatedAmount: 900,
|
||||
remainingAmount: 0,
|
||||
settlement: 'PAID',
|
||||
version: 2,
|
||||
},
|
||||
];
|
||||
await route.fulfill({ contentType: 'application/json', json: payments[0] });
|
||||
return;
|
||||
}
|
||||
await route.fulfill({ contentType: 'application/json', json: payments });
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
await page.getByTestId('primary-navigation').getByRole('button', { name: 'Loyers' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Studio République' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Enregistrer un règlement' }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog.getByRole('heading', { name: 'Enregistrer un règlement' })).toBeVisible();
|
||||
await dialog.getByLabel('Montant reçu').fill('950');
|
||||
await expect(dialog.getByText(/Surplus non affecté/)).toContainText('50,00');
|
||||
await dialog.getByRole('button', { name: 'Confirmer le règlement' }).click();
|
||||
|
||||
await expect(page.getByRole('status')).toHaveText('Le règlement a été enregistré.');
|
||||
expect(submittedPayment).toMatchObject({
|
||||
rentDueId: 'due-1',
|
||||
rentDueVersion: 1,
|
||||
amount: 950,
|
||||
});
|
||||
expect(submittedPayment?.['operationId']).toEqual(expect.any(String));
|
||||
|
||||
const dueCard = page
|
||||
.getByRole('article')
|
||||
.filter({ has: page.getByRole('heading', { name: 'Studio République' }) });
|
||||
await expect(dueCard.getByText('Réglée', { exact: true })).toBeVisible();
|
||||
await expect(dueCard.getByRole('button', { name: 'Télécharger la quittance' })).toBeVisible();
|
||||
|
||||
const paymentCard = page
|
||||
.getByRole('article')
|
||||
.filter({ has: page.getByText('Confirmé', { exact: true }) });
|
||||
await expect(paymentCard.getByText(/^950,00\s€$/)).toBeVisible();
|
||||
await expect(paymentCard.getByText(/^50,00\s€$/)).toBeVisible();
|
||||
await expect(paymentCard.getByRole('button', { name: 'Rembourser' })).toBeVisible();
|
||||
await expectNoHorizontalOverflow(page);
|
||||
});
|
||||
+41
@@ -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);
|
||||
}
|
||||
+49
@@ -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>
|
||||
+131
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { RentPayment } from '../services/rent-payment.service';
|
||||
|
||||
export const RENT_PAYMENT_DATA_STALE = 'RENT_PAYMENT_DATA_STALE';
|
||||
|
||||
export type RentPaymentDialogResult = RentPayment | typeof RENT_PAYMENT_DATA_STALE;
|
||||
|
||||
export function isRentPaymentDataStale(error: unknown): boolean {
|
||||
return typeof error === 'object' && error !== null && 'status' in error && error.status === 409;
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { Type } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { RENT_PAYMENT_DATA_STALE } from './rent-payment-dialog-result';
|
||||
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 { RentDue } from '../services/lease.service';
|
||||
import { utcIsoDate } from '../services/rent-payment-format';
|
||||
import { RentPayment, RentPaymentService } from '../services/rent-payment.service';
|
||||
|
||||
describe('rent payment dialogs', () => {
|
||||
const dialogRef = { close: vi.fn() };
|
||||
const payments = {
|
||||
record: vi.fn(),
|
||||
allocate: vi.fn(),
|
||||
refund: vi.fn(),
|
||||
reverse: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
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('records one received amount and exposes its surplus outcome', () => {
|
||||
const result = payment({ amount: 950, unallocatedBalance: 100 });
|
||||
payments.record.mockReturnValue(of(result));
|
||||
const component = create(RentPaymentValidationDialog, {
|
||||
rentDue: due(),
|
||||
apartmentName: 'T2',
|
||||
});
|
||||
component.form.controls.amount.setValue(950);
|
||||
|
||||
component.submit();
|
||||
|
||||
expect(component.surplus()).toBe(100);
|
||||
expect(payments.record).toHaveBeenCalledWith({
|
||||
operationId: expect.any(String),
|
||||
rentDueId: 'due-1',
|
||||
rentDueVersion: 2,
|
||||
receivedAt: component.today,
|
||||
amount: 950,
|
||||
});
|
||||
expect(dialogRef.close).toHaveBeenCalledWith(result);
|
||||
});
|
||||
|
||||
it('closes stale data so the page can refresh instead of retrying an obsolete version', () => {
|
||||
payments.record.mockReturnValue(throwError(() => ({ status: 409 })));
|
||||
const component = create(RentPaymentValidationDialog, {
|
||||
rentDue: due(),
|
||||
apartmentName: 'T2',
|
||||
});
|
||||
|
||||
component.submit();
|
||||
|
||||
expect(dialogRef.close).toHaveBeenCalledWith(RENT_PAYMENT_DATA_STALE);
|
||||
});
|
||||
|
||||
it('defaults a surplus allocation to the largest safe amount', () => {
|
||||
const result = payment({ unallocatedBalance: 20, version: 4 });
|
||||
payments.allocate.mockReturnValue(of(result));
|
||||
const component = create(RentPaymentAllocationDialog, {
|
||||
payment: payment({ unallocatedBalance: 100, version: 3 }),
|
||||
eligibleRentDues: [
|
||||
{ rentDue: due({ id: 'due-2', remainingAmount: 80, version: 5 }), label: 'Août' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(component.form.controls.amount.value).toBe(80);
|
||||
component.form.controls.amount.setValue(50);
|
||||
component.submit();
|
||||
|
||||
expect(payments.allocate).toHaveBeenCalledWith('payment-1', {
|
||||
operationId: expect.any(String),
|
||||
paymentVersion: 3,
|
||||
rentDueId: 'due-2',
|
||||
rentDueVersion: 5,
|
||||
amount: 50,
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults a refund to the full surplus and prevents a future date', () => {
|
||||
const result = payment({ unallocatedBalance: 0, refundedAmount: 100, version: 4 });
|
||||
payments.refund.mockReturnValue(of(result));
|
||||
const component = create(
|
||||
RentPaymentRefundDialog,
|
||||
payment({ unallocatedBalance: 100, version: 3 }),
|
||||
);
|
||||
|
||||
expect(component.form.controls.amount.value).toBe(100);
|
||||
component.form.controls.refundedAt.setValue('9999-01-01');
|
||||
component.submit();
|
||||
expect(payments.refund).not.toHaveBeenCalled();
|
||||
|
||||
component.form.controls.refundedAt.setValue(component.today);
|
||||
component.submit();
|
||||
expect(payments.refund).toHaveBeenCalledWith(
|
||||
'payment-1',
|
||||
expect.objectContaining({ amount: 100, paymentVersion: 3 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('requires and submits a reversal reason', () => {
|
||||
const result = payment({ status: 'REVERSED', version: 4 });
|
||||
payments.reverse.mockReturnValue(of(result));
|
||||
const component = create(RentPaymentReversalDialog, payment({ version: 3 }));
|
||||
|
||||
component.submit();
|
||||
expect(payments.reverse).not.toHaveBeenCalled();
|
||||
|
||||
component.form.controls.reason.setValue('Erreur de saisie');
|
||||
component.submit();
|
||||
expect(payments.reverse).toHaveBeenCalledWith('payment-1', 3, 'Erreur de saisie');
|
||||
expect(dialogRef.close).toHaveBeenCalledWith(result);
|
||||
});
|
||||
|
||||
function create<T>(component: Type<T>, data: unknown): T {
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: MAT_DIALOG_DATA, useValue: data },
|
||||
{ provide: MatDialogRef, useValue: dialogRef },
|
||||
{ provide: RentPaymentService, useValue: payments },
|
||||
],
|
||||
});
|
||||
return TestBed.createComponent(component).componentInstance;
|
||||
}
|
||||
});
|
||||
|
||||
function due(overrides: Partial<RentDue> = {}): RentDue {
|
||||
return {
|
||||
id: 'due-1',
|
||||
leaseId: 'lease-1',
|
||||
month: '2026-07',
|
||||
dueDate: '2026-07-05',
|
||||
rent: 800,
|
||||
charges: 50,
|
||||
total: 850,
|
||||
allocatedAmount: 0,
|
||||
remainingAmount: 850,
|
||||
settlement: 'UNPAID',
|
||||
timing: 'OVERDUE',
|
||||
version: 2,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function payment(overrides: Partial<RentPayment> = {}): RentPayment {
|
||||
return {
|
||||
id: 'payment-1',
|
||||
leaseId: 'lease-1',
|
||||
receivedAt: '2026-07-06',
|
||||
amount: 850,
|
||||
status: 'CONFIRMED',
|
||||
allocations: [],
|
||||
allocatedAmount: 850,
|
||||
refundedAmount: 0,
|
||||
unallocatedBalance: 0,
|
||||
confirmedAt: '2026-07-06',
|
||||
version: 0,
|
||||
refunds: [],
|
||||
reversal: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
.action-dialog-content,
|
||||
.dialog-fieldset {
|
||||
display: grid;
|
||||
gap: var(--space-4, 1rem);
|
||||
}
|
||||
|
||||
.action-dialog-content {
|
||||
width: min(30rem, calc(100vw - 4rem));
|
||||
max-width: 100%;
|
||||
max-height: 75vh;
|
||||
overscroll-behavior-y: contain;
|
||||
}
|
||||
|
||||
.dialog-fieldset {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
<h2 mat-dialog-title>Rembourser le surplus</h2>
|
||||
<form [formGroup]="form" (ngSubmit)="submit()">
|
||||
<mat-dialog-content class="action-dialog-content">
|
||||
<p>
|
||||
Solde non affecté disponible : <strong>{{ formatEuro(payment.unallocatedBalance) }}</strong>
|
||||
</p>
|
||||
<fieldset class="dialog-fieldset" [disabled]="saving()">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Date du remboursement</mat-label>
|
||||
<input matInput type="date" formControlName="refundedAt" [max]="today" required />
|
||||
@if (form.controls.refundedAt.hasError('futureDate')) {
|
||||
<mat-error>La date du remboursement ne peut pas être dans le futur.</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Montant remboursé</mat-label>
|
||||
<input matInput type="number" step="0.01" min="0.01" formControlName="amount" required />
|
||||
<span matTextSuffix>€</span>
|
||||
@if (form.controls.amount.hasError('max')) {
|
||||
<mat-error>Le remboursement dépasse le solde non affecté.</mat-error>
|
||||
} @else if (form.controls.amount.invalid) {
|
||||
<mat-error>Le montant doit être strictement positif.</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</fieldset>
|
||||
@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="Remboursement en cours" />
|
||||
}
|
||||
Confirmer le remboursement
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
</form>
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
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 { finalize } from 'rxjs';
|
||||
import { httpErrorMessage } from '../../services/http-error-message';
|
||||
import { formatEuro, utcIsoDate } from '../../services/rent-payment-format';
|
||||
import { RentPayment, RentPaymentService } from '../../services/rent-payment.service';
|
||||
import { isRentPaymentDataStale, RENT_PAYMENT_DATA_STALE } from '../rent-payment-dialog-result';
|
||||
|
||||
@Component({
|
||||
selector: 'app-rent-payment-refund-dialog',
|
||||
imports: [
|
||||
ReactiveFormsModule,
|
||||
MatButtonModule,
|
||||
MatDialogModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatProgressSpinnerModule,
|
||||
],
|
||||
templateUrl: './rent-payment-refund-dialog.html',
|
||||
styleUrl: './rent-payment-refund-dialog.css',
|
||||
})
|
||||
export class RentPaymentRefundDialog {
|
||||
readonly payment = inject<RentPayment>(MAT_DIALOG_DATA);
|
||||
private readonly dialogRef = inject(MatDialogRef<RentPaymentRefundDialog>);
|
||||
private readonly payments = inject(RentPaymentService);
|
||||
private readonly operationId = crypto.randomUUID();
|
||||
readonly today = utcIsoDate();
|
||||
readonly saving = signal(false);
|
||||
readonly error = signal('');
|
||||
readonly formatEuro = formatEuro;
|
||||
|
||||
readonly form = new FormGroup({
|
||||
refundedAt: new FormControl(this.today, {
|
||||
nonNullable: true,
|
||||
validators: [Validators.required, this.notFuture.bind(this)],
|
||||
}),
|
||||
amount: new FormControl(this.payment.unallocatedBalance, {
|
||||
nonNullable: true,
|
||||
validators: [
|
||||
Validators.required,
|
||||
Validators.min(0.01),
|
||||
Validators.max(this.payment.unallocatedBalance),
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
submit(): void {
|
||||
this.form.markAllAsTouched();
|
||||
if (this.form.invalid || this.saving()) return;
|
||||
this.saving.set(true);
|
||||
this.error.set('');
|
||||
this.payments
|
||||
.refund(this.payment.id, {
|
||||
operationId: this.operationId,
|
||||
paymentVersion: this.payment.version,
|
||||
...this.form.getRawValue(),
|
||||
})
|
||||
.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’enregistrer ce remboursement.'));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private notFuture(control: AbstractControl<string>): ValidationErrors | null {
|
||||
return control.value > this.today ? { futureDate: true } : null;
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
.action-dialog-content,
|
||||
.dialog-fieldset {
|
||||
display: grid;
|
||||
gap: var(--space-4, 1rem);
|
||||
}
|
||||
|
||||
.action-dialog-content {
|
||||
width: min(30rem, calc(100vw - 4rem));
|
||||
max-width: 100%;
|
||||
max-height: 75vh;
|
||||
overscroll-behavior-y: contain;
|
||||
}
|
||||
|
||||
.dialog-fieldset {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.refund-warning {
|
||||
padding: var(--space-4, 1rem);
|
||||
border-radius: var(--radius-md, 0.8125rem);
|
||||
background: var(--danger-soft, #fee9e7);
|
||||
color: var(--danger, #b42318);
|
||||
}
|
||||
|
||||
.dialog-error {
|
||||
margin: 0;
|
||||
color: var(--danger, #b42318);
|
||||
}
|
||||
|
||||
.danger-action.mat-mdc-unelevated-button {
|
||||
--mdc-filled-button-container-color: var(--danger, #b42318);
|
||||
--mdc-filled-button-label-text-color: #ffffff;
|
||||
}
|
||||
|
||||
mat-dialog-actions button {
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
|
||||
mat-spinner {
|
||||
display: inline-block;
|
||||
margin-inline-end: var(--space-2, 0.5rem);
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
<h2 mat-dialog-title>Contrepasser le règlement</h2>
|
||||
<form [formGroup]="form" (ngSubmit)="submit()">
|
||||
<mat-dialog-content class="action-dialog-content">
|
||||
<p>
|
||||
Les affectations de ce règlement de <strong>{{ formatEuro(payment.amount) }}</strong> seront
|
||||
annulées. Le règlement restera visible dans l’historique.
|
||||
</p>
|
||||
@if (payment.refundedAmount > 0) {
|
||||
<p class="refund-warning" role="note">
|
||||
Les remboursements déjà confirmés, pour {{ formatEuro(payment.refundedAmount) }}, resteront
|
||||
des sorties historiques.
|
||||
</p>
|
||||
}
|
||||
<fieldset class="dialog-fieldset" [disabled]="saving()">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Motif de la contrepassation</mat-label>
|
||||
<textarea matInput rows="4" maxlength="500" formControlName="reason" required></textarea>
|
||||
@if (form.controls.reason.invalid) {
|
||||
<mat-error>Le motif est obligatoire.</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</fieldset>
|
||||
@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"
|
||||
class="danger-action"
|
||||
[disabled]="form.invalid || saving()"
|
||||
>
|
||||
@if (saving()) {
|
||||
<mat-spinner diameter="18" aria-label="Contrepassation en cours" />
|
||||
}
|
||||
Contrepasser
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
</form>
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { FormControl, FormGroup, ReactiveFormsModule, 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 { finalize } from 'rxjs';
|
||||
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';
|
||||
|
||||
@Component({
|
||||
selector: 'app-rent-payment-reversal-dialog',
|
||||
imports: [
|
||||
ReactiveFormsModule,
|
||||
MatButtonModule,
|
||||
MatDialogModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatProgressSpinnerModule,
|
||||
],
|
||||
templateUrl: './rent-payment-reversal-dialog.html',
|
||||
styleUrl: './rent-payment-reversal-dialog.css',
|
||||
})
|
||||
export class RentPaymentReversalDialog {
|
||||
readonly payment = inject<RentPayment>(MAT_DIALOG_DATA);
|
||||
private readonly dialogRef = inject(MatDialogRef<RentPaymentReversalDialog>);
|
||||
private readonly payments = inject(RentPaymentService);
|
||||
readonly saving = signal(false);
|
||||
readonly error = signal('');
|
||||
readonly formatEuro = formatEuro;
|
||||
|
||||
readonly form = new FormGroup({
|
||||
reason: new FormControl('', {
|
||||
nonNullable: true,
|
||||
validators: [Validators.required, Validators.maxLength(500)],
|
||||
}),
|
||||
});
|
||||
|
||||
submit(): void {
|
||||
this.form.markAllAsTouched();
|
||||
if (this.form.invalid || this.saving()) return;
|
||||
this.saving.set(true);
|
||||
this.error.set('');
|
||||
this.payments
|
||||
.reverse(this.payment.id, this.payment.version, this.form.controls.reason.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 de contrepasser ce règlement.'));
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
+63
-9
@@ -1,12 +1,66 @@
|
||||
.form-dialog-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding-top: 8px;
|
||||
.payment-dialog-content,
|
||||
.dialog-fieldset,
|
||||
.outcome-summary {
|
||||
display: grid;
|
||||
gap: var(--space-4, 1rem);
|
||||
}
|
||||
|
||||
.form-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
.payment-dialog-content {
|
||||
width: min(32rem, calc(100vw - 4rem));
|
||||
max-width: 100%;
|
||||
max-height: 75vh;
|
||||
overscroll-behavior-y: contain;
|
||||
}
|
||||
|
||||
.due-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 12rem), 1fr));
|
||||
gap: var(--space-2, 0.5rem) var(--space-5, 1.25rem);
|
||||
margin: 0;
|
||||
padding: var(--space-4, 1rem);
|
||||
border-radius: var(--radius-md, 0.8125rem);
|
||||
background: var(--surface-soft, #e9efeb);
|
||||
}
|
||||
|
||||
.due-summary div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.due-summary dt {
|
||||
color: var(--text-muted, #687670);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.due-summary dd {
|
||||
margin: var(--space-1, 0.25rem) 0 0;
|
||||
font-weight: 700;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.dialog-fieldset {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.outcome-summary {
|
||||
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);
|
||||
}
|
||||
+61
-10
@@ -1,20 +1,71 @@
|
||||
<h2 mat-dialog-title>Valider le paiement</h2>
|
||||
<h2 mat-dialog-title>Enregistrer un règlement</h2>
|
||||
<form [formGroup]="form" (ngSubmit)="submit()">
|
||||
<mat-dialog-content class="form-dialog-content">
|
||||
<p>Vérifiez ou modifiez les montants payés avant de valider l'échéance.</p>
|
||||
<div class="form-fields">
|
||||
<mat-dialog-content class="payment-dialog-content">
|
||||
<p>
|
||||
Règlement reçu pour <strong>{{ data.apartmentName }}</strong
|
||||
>. Vérifiez la date et le montant avant de confirmer.
|
||||
</p>
|
||||
|
||||
<dl class="due-summary">
|
||||
<div>
|
||||
<dt>Loyer</dt>
|
||||
<dd>{{ formatEuro(data.rentDue.rent) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Charges</dt>
|
||||
<dd>{{ formatEuro(data.rentDue.charges) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Déjà affecté</dt>
|
||||
<dd>{{ formatEuro(data.rentDue.allocatedAmount) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Restant à régler</dt>
|
||||
<dd>{{ formatEuro(data.rentDue.remainingAmount) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<fieldset class="dialog-fieldset" [disabled]="saving()">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Loyer principal (€)</mat-label>
|
||||
<input matInput type="number" step="0.01" min="0" formControlName="rent" required />
|
||||
<mat-label>Date de réception</mat-label>
|
||||
<input matInput type="date" formControlName="receivedAt" [max]="today" required />
|
||||
@if (form.controls.receivedAt.hasError('futureDate')) {
|
||||
<mat-error>La date de réception ne peut pas être dans le futur.</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Charges (€)</mat-label>
|
||||
<input matInput type="number" step="0.01" min="0" formControlName="charges" required />
|
||||
<mat-label>Montant reçu</mat-label>
|
||||
<input matInput type="number" step="0.01" min="0.01" formControlName="amount" required />
|
||||
<span matTextSuffix>€</span>
|
||||
@if (form.controls.amount.invalid) {
|
||||
<mat-error>Le montant doit être strictement positif.</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</fieldset>
|
||||
|
||||
<div class="outcome-summary" aria-live="polite">
|
||||
@if (surplus() > 0) {
|
||||
<strong>L’échéance sera réglée.</strong>
|
||||
<span>Surplus non affecté : {{ formatEuro(surplus()) }}</span>
|
||||
} @else if (remainingAfter() > 0) {
|
||||
<strong>L’échéance restera partiellement réglée.</strong>
|
||||
<span>Solde après règlement : {{ formatEuro(remainingAfter()) }}</span>
|
||||
} @else {
|
||||
<strong>L’échéance sera entièrement réglée.</strong>
|
||||
}
|
||||
</div>
|
||||
|
||||
@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">Annuler</button>
|
||||
<button mat-flat-button type="submit" [disabled]="form.invalid">Valider le paiement</button>
|
||||
<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="Enregistrement en cours" />
|
||||
}
|
||||
Confirmer le règlement
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
</form>
|
||||
+70
-12
@@ -1,13 +1,28 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { Component, computed, inject, signal } from '@angular/core';
|
||||
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 { 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 { RentPaymentService } from '../../services/rent-payment.service';
|
||||
import { isRentPaymentDataStale, RENT_PAYMENT_DATA_STALE } from '../rent-payment-dialog-result';
|
||||
|
||||
export interface RentPaymentValidationDialogData {
|
||||
rent: number;
|
||||
charges: number;
|
||||
rentDue: RentDue;
|
||||
apartmentName: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -18,28 +33,71 @@ export interface RentPaymentValidationDialogData {
|
||||
MatButtonModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatProgressSpinnerModule,
|
||||
],
|
||||
templateUrl: './rent-payment-validation-dialog.html',
|
||||
styleUrl: './rent-payment-validation-dialog.css',
|
||||
})
|
||||
export class RentPaymentValidationDialog {
|
||||
readonly data = inject<RentPaymentValidationDialogData>(MAT_DIALOG_DATA);
|
||||
readonly dialogRef = inject(MatDialogRef<RentPaymentValidationDialog>);
|
||||
private readonly dialogRef = inject(MatDialogRef<RentPaymentValidationDialog>);
|
||||
private readonly payments = inject(RentPaymentService);
|
||||
private readonly operationId = crypto.randomUUID();
|
||||
readonly today = utcIsoDate();
|
||||
readonly saving = signal(false);
|
||||
readonly error = signal('');
|
||||
readonly formatEuro = formatEuro;
|
||||
|
||||
readonly form = new FormGroup({
|
||||
rent: new FormControl(this.data.rent, {
|
||||
receivedAt: new FormControl(this.today, {
|
||||
nonNullable: true,
|
||||
validators: [Validators.required, Validators.min(0)],
|
||||
validators: [Validators.required, this.notFuture.bind(this)],
|
||||
}),
|
||||
charges: new FormControl(this.data.charges, {
|
||||
amount: new FormControl(this.data.rentDue.remainingAmount, {
|
||||
nonNullable: true,
|
||||
validators: [Validators.required, Validators.min(0)],
|
||||
validators: [Validators.required, Validators.min(0.01)],
|
||||
}),
|
||||
});
|
||||
|
||||
private readonly amount = toSignal(this.form.controls.amount.valueChanges, {
|
||||
initialValue: this.form.controls.amount.value,
|
||||
});
|
||||
readonly allocatedAmount = computed(() =>
|
||||
Math.min(Math.max(this.amount() ?? 0, 0), this.data.rentDue.remainingAmount),
|
||||
);
|
||||
readonly remainingAfter = computed(() =>
|
||||
Math.max(this.data.rentDue.remainingAmount - this.allocatedAmount(), 0),
|
||||
);
|
||||
readonly surplus = computed(() =>
|
||||
Math.max((this.amount() ?? 0) - this.data.rentDue.remainingAmount, 0),
|
||||
);
|
||||
|
||||
submit(): void {
|
||||
if (this.form.valid) {
|
||||
this.dialogRef.close(this.form.getRawValue());
|
||||
}
|
||||
this.form.markAllAsTouched();
|
||||
if (this.form.invalid || this.saving()) return;
|
||||
this.saving.set(true);
|
||||
this.error.set('');
|
||||
this.payments
|
||||
.record({
|
||||
operationId: this.operationId,
|
||||
rentDueId: this.data.rentDue.id,
|
||||
rentDueVersion: this.data.rentDue.version,
|
||||
...this.form.getRawValue(),
|
||||
})
|
||||
.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’enregistrer ce règlement.'));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private notFuture(control: AbstractControl<string>): ValidationErrors | null {
|
||||
return control.value > this.today ? { futureDate: true } : null;
|
||||
}
|
||||
}
|
||||
@@ -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[] {
|
||||
|
||||
@@ -39,6 +39,7 @@ describe('LeaseService', () => {
|
||||
service.activateLease('lease-1').subscribe();
|
||||
service.deleteLease('lease-1').subscribe();
|
||||
service.downloadPdf('lease-1').subscribe();
|
||||
service.downloadRentReceipt('due-1').subscribe();
|
||||
|
||||
expect(http.get).toHaveBeenCalledWith('/api/leases');
|
||||
expect(http.get).toHaveBeenCalledWith('/api/rent-dues');
|
||||
@@ -49,5 +50,8 @@ describe('LeaseService', () => {
|
||||
expect(http.get).toHaveBeenCalledWith('/api/leases/lease-1/pdf', {
|
||||
responseType: 'blob',
|
||||
});
|
||||
expect(http.get).toHaveBeenCalledWith('/api/rent-dues/due-1/receipt', {
|
||||
responseType: 'blob',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -35,9 +35,11 @@ export interface RentDue {
|
||||
rent: number;
|
||||
charges: number;
|
||||
total: number;
|
||||
allocatedAmount: number;
|
||||
remainingAmount: number;
|
||||
settlement: 'UNPAID' | 'PARTIALLY_PAID' | 'PAID';
|
||||
timing: 'UPCOMING' | 'OVERDUE';
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface LeaseActivation {
|
||||
@@ -57,6 +59,10 @@ export class LeaseService {
|
||||
return this.http.get<RentDue[]>('/api/rent-dues');
|
||||
}
|
||||
|
||||
downloadRentReceipt(id: string): Observable<Blob> {
|
||||
return this.http.get(`/api/rent-dues/${id}/receipt`, { responseType: 'blob' });
|
||||
}
|
||||
|
||||
createLease(lease: LeaseInput): Observable<Lease> {
|
||||
return this.http.post<Lease>('/api/leases', lease);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
const euroFormatter = new Intl.NumberFormat('fr-FR', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
export function formatEuro(value: number): string {
|
||||
return euroFormatter.format(value);
|
||||
}
|
||||
|
||||
export function utcIsoDate(date = new Date()): string {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { RentPayment } from './rent-payment.service';
|
||||
|
||||
export function sortRentPayments(payments: readonly RentPayment[]): RentPayment[] {
|
||||
return [...payments].sort((left, right) => left.dueDate.localeCompare(right.dueDate));
|
||||
}
|
||||
|
||||
export function currentRentSchedule(payments: readonly RentPayment[]): RentPayment[] {
|
||||
const paymentsByLease = new Map<string, RentPayment[]>();
|
||||
for (const payment of sortRentPayments(payments)) {
|
||||
const schedule = paymentsByLease.get(payment.leaseId);
|
||||
if (schedule) schedule.push(payment);
|
||||
else paymentsByLease.set(payment.leaseId, [payment]);
|
||||
}
|
||||
|
||||
return sortRentPayments([...paymentsByLease.values()].flatMap(currentSchedule));
|
||||
}
|
||||
|
||||
function currentSchedule(schedule: RentPayment[]): RentPayment[] {
|
||||
const nextDueIndex = schedule.findIndex((payment) => payment.status === 'UPCOMING');
|
||||
const paymentsThroughNextDue = nextDueIndex === -1 ? schedule : schedule.slice(0, nextDueIndex + 1);
|
||||
const lastPaidIndex = paymentsThroughNextDue.reduce(
|
||||
(lastPaid, payment, index) => (payment.status === 'PAID' ? index : lastPaid),
|
||||
-1,
|
||||
);
|
||||
const firstUnpaidIndex = paymentsThroughNextDue.findIndex((payment) => payment.status !== 'PAID');
|
||||
const firstVisibleIndex = Math.min(...[lastPaidIndex, firstUnpaidIndex].filter((index) => index !== -1));
|
||||
|
||||
return firstVisibleIndex === Infinity ? [] : paymentsThroughNextDue.slice(firstVisibleIndex);
|
||||
}
|
||||
@@ -4,22 +4,51 @@ import { of } from 'rxjs';
|
||||
import { RentPaymentService } from './rent-payment.service';
|
||||
|
||||
describe('RentPaymentService', () => {
|
||||
it('uses the rent payment schedule, payment, and receipt endpoints', () => {
|
||||
it('uses the confirmed payment lifecycle and receipt endpoints', () => {
|
||||
const http = {
|
||||
get: vi.fn(() => of([])),
|
||||
put: vi.fn(() => of({})),
|
||||
post: vi.fn(() => of({})),
|
||||
};
|
||||
TestBed.configureTestingModule({
|
||||
providers: [RentPaymentService, { provide: HttpClient, useValue: http }],
|
||||
});
|
||||
const service = TestBed.inject(RentPaymentService);
|
||||
const record = {
|
||||
operationId: 'operation-1',
|
||||
rentDueId: 'due-1',
|
||||
rentDueVersion: 2,
|
||||
receivedAt: '2026-01-05',
|
||||
amount: 900,
|
||||
};
|
||||
const allocation = {
|
||||
operationId: 'operation-2',
|
||||
paymentVersion: 1,
|
||||
rentDueId: 'due-2',
|
||||
rentDueVersion: 3,
|
||||
amount: 50,
|
||||
};
|
||||
const refund = {
|
||||
operationId: 'operation-3',
|
||||
paymentVersion: 2,
|
||||
amount: 25,
|
||||
refundedAt: '2026-01-06',
|
||||
};
|
||||
|
||||
service.getRentPayments().subscribe();
|
||||
service.markPaid('payment-1', 850, 50).subscribe();
|
||||
service.record(record).subscribe();
|
||||
service.allocate('payment-1', allocation).subscribe();
|
||||
service.refund('payment-1', refund).subscribe();
|
||||
service.reverse('payment-1', 3, 'Erreur de saisie').subscribe();
|
||||
service.downloadReceipt('payment-1').subscribe();
|
||||
|
||||
expect(http.get).toHaveBeenCalledWith('/api/rent-payments');
|
||||
expect(http.put).toHaveBeenCalledWith('/api/rent-payments/payment-1/paid', { rent: 850, charges: 50 });
|
||||
expect(http.post).toHaveBeenCalledWith('/api/rent-payments', record);
|
||||
expect(http.post).toHaveBeenCalledWith('/api/rent-payments/payment-1/allocations', allocation);
|
||||
expect(http.post).toHaveBeenCalledWith('/api/rent-payments/payment-1/refunds', refund);
|
||||
expect(http.post).toHaveBeenCalledWith('/api/rent-payments/payment-1/reversal', {
|
||||
paymentVersion: 3,
|
||||
reason: 'Erreur de saisie',
|
||||
});
|
||||
expect(http.get).toHaveBeenCalledWith('/api/rent-payments/payment-1/receipt', {
|
||||
responseType: 'blob',
|
||||
});
|
||||
|
||||
@@ -2,18 +2,66 @@ import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
export type RentPaymentStatus = 'PAID' | 'LATE' | 'UPCOMING';
|
||||
export type RentPaymentStatus = 'CONFIRMED' | 'REVERSED';
|
||||
|
||||
export interface PaymentAllocation {
|
||||
id: string;
|
||||
rentDueId: string;
|
||||
amount: number;
|
||||
allocatedAt: string;
|
||||
}
|
||||
|
||||
export interface RentPaymentRefund {
|
||||
id: string;
|
||||
amount: number;
|
||||
refundedAt: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface RentPaymentReversal {
|
||||
id: string;
|
||||
reversedAt: string;
|
||||
reason: string;
|
||||
reversedAllocations: PaymentAllocation[];
|
||||
}
|
||||
|
||||
export interface RentPayment {
|
||||
id: string;
|
||||
leaseId: string;
|
||||
month: string;
|
||||
dueDate: string;
|
||||
rent: number;
|
||||
charges: number;
|
||||
total: number;
|
||||
receivedAt: string;
|
||||
amount: number;
|
||||
status: RentPaymentStatus;
|
||||
paidAt: string | null;
|
||||
allocations: PaymentAllocation[];
|
||||
allocatedAmount: number;
|
||||
refundedAmount: number;
|
||||
unallocatedBalance: number;
|
||||
confirmedAt: string;
|
||||
version: number;
|
||||
refunds: RentPaymentRefund[];
|
||||
reversal: RentPaymentReversal | null;
|
||||
}
|
||||
|
||||
export interface RecordRentPaymentRequest {
|
||||
operationId: string;
|
||||
rentDueId: string;
|
||||
rentDueVersion: number;
|
||||
receivedAt: string;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export interface AllocateRentPaymentRequest {
|
||||
operationId: string;
|
||||
paymentVersion: number;
|
||||
rentDueId: string;
|
||||
rentDueVersion: number;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export interface RefundRentPaymentRequest {
|
||||
operationId: string;
|
||||
paymentVersion: number;
|
||||
amount: number;
|
||||
refundedAt: string;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
@@ -24,8 +72,23 @@ export class RentPaymentService {
|
||||
return this.http.get<RentPayment[]>('/api/rent-payments');
|
||||
}
|
||||
|
||||
markPaid(id: string, rent: number, charges: number): Observable<RentPayment> {
|
||||
return this.http.put<RentPayment>(`/api/rent-payments/${id}/paid`, { rent, charges });
|
||||
record(request: RecordRentPaymentRequest): Observable<RentPayment> {
|
||||
return this.http.post<RentPayment>('/api/rent-payments', request);
|
||||
}
|
||||
|
||||
allocate(paymentId: string, request: AllocateRentPaymentRequest): Observable<RentPayment> {
|
||||
return this.http.post<RentPayment>(`/api/rent-payments/${paymentId}/allocations`, request);
|
||||
}
|
||||
|
||||
refund(paymentId: string, request: RefundRentPaymentRequest): Observable<RentPayment> {
|
||||
return this.http.post<RentPayment>(`/api/rent-payments/${paymentId}/refunds`, request);
|
||||
}
|
||||
|
||||
reverse(paymentId: string, paymentVersion: number, reason: string): Observable<RentPayment> {
|
||||
return this.http.post<RentPayment>(`/api/rent-payments/${paymentId}/reversal`, {
|
||||
paymentVersion,
|
||||
reason,
|
||||
});
|
||||
}
|
||||
|
||||
downloadReceipt(id: string): Observable<Blob> {
|
||||
|
||||
@@ -41,13 +41,19 @@ describe('WorkspaceService', () => {
|
||||
const rentPayment = {
|
||||
id: 'payment-1',
|
||||
leaseId: 'lease-1',
|
||||
month: '2026-01',
|
||||
dueDate: '2026-01-01',
|
||||
rent: 800,
|
||||
charges: 50,
|
||||
total: 850,
|
||||
status: 'LATE' as const,
|
||||
paidAt: null,
|
||||
receivedAt: '2026-01-02',
|
||||
amount: 850,
|
||||
status: 'CONFIRMED' as const,
|
||||
allocations: [
|
||||
{ id: 'allocation-1', rentDueId: 'due-1', amount: 850, allocatedAt: '2026-01-02' },
|
||||
],
|
||||
allocatedAmount: 850,
|
||||
refundedAmount: 0,
|
||||
unallocatedBalance: 0,
|
||||
confirmedAt: '2026-01-02',
|
||||
version: 0,
|
||||
refunds: [],
|
||||
reversal: null,
|
||||
};
|
||||
const rentDue = {
|
||||
id: 'due-1',
|
||||
@@ -57,9 +63,11 @@ describe('WorkspaceService', () => {
|
||||
rent: 800,
|
||||
charges: 50,
|
||||
total: 850,
|
||||
allocatedAmount: 0,
|
||||
remainingAmount: 850,
|
||||
settlement: 'UNPAID' as const,
|
||||
timing: 'UPCOMING' as const,
|
||||
version: 0,
|
||||
};
|
||||
const apartments = {
|
||||
getApartments: vi.fn(),
|
||||
@@ -84,10 +92,10 @@ describe('WorkspaceService', () => {
|
||||
activateLease: vi.fn(),
|
||||
deleteLease: vi.fn(),
|
||||
downloadPdf: vi.fn(),
|
||||
downloadRentReceipt: vi.fn(),
|
||||
};
|
||||
const rentPayments = {
|
||||
getRentPayments: vi.fn(),
|
||||
markPaid: vi.fn(),
|
||||
downloadReceipt: vi.fn(),
|
||||
};
|
||||
const dialog = {
|
||||
@@ -114,10 +122,8 @@ describe('WorkspaceService', () => {
|
||||
leases.activateLease.mockReturnValue(of({ lease, rentDues: [rentDue] }));
|
||||
leases.deleteLease.mockReturnValue(of(undefined));
|
||||
leases.downloadPdf.mockReturnValue(of(new Blob(['pdf'])));
|
||||
leases.downloadRentReceipt.mockReturnValue(of(new Blob(['rent receipt'])));
|
||||
rentPayments.getRentPayments.mockReturnValue(of([rentPayment]));
|
||||
rentPayments.markPaid.mockReturnValue(
|
||||
of({ ...rentPayment, status: 'PAID', paidAt: '2026-01-02' }),
|
||||
);
|
||||
rentPayments.downloadReceipt.mockReturnValue(of(new Blob(['receipt'])));
|
||||
dialog.open.mockImplementation(() => ({ afterClosed: () => of(true) }));
|
||||
vi.stubGlobal('URL', { createObjectURL: vi.fn(() => 'blob:test'), revokeObjectURL: vi.fn() });
|
||||
@@ -371,7 +377,7 @@ describe('WorkspaceService', () => {
|
||||
service.saveLease(lease, lease.id);
|
||||
service.deleteLease(lease.id);
|
||||
service.loadRentPayments();
|
||||
service.markRentPaymentPaid(rentPayment.id, rentPayment.rent, rentPayment.charges);
|
||||
service.acceptRentPayment(rentPayment);
|
||||
|
||||
expect(service.apartments()).toEqual([apartment]);
|
||||
expect(service.tenants()).toEqual([tenant]);
|
||||
@@ -391,11 +397,6 @@ describe('WorkspaceService', () => {
|
||||
securityDeposit: lease.securityDeposit,
|
||||
});
|
||||
expect(leases.deleteLease).toHaveBeenCalledWith('lease-1');
|
||||
expect(rentPayments.markPaid).toHaveBeenCalledWith(
|
||||
'payment-1',
|
||||
rentPayment.rent,
|
||||
rentPayment.charges,
|
||||
);
|
||||
});
|
||||
|
||||
it('opens a confirmation pop-in before deleting a resource', () => {
|
||||
@@ -430,13 +431,14 @@ describe('WorkspaceService', () => {
|
||||
service.uploadTenantAttachment('tenant-1', new File(['id'], 'id.pdf'));
|
||||
service.downloadTenantAttachment('tenant-1', { id: 'attachment-1', fileName: 'id.pdf' });
|
||||
service.downloadLeasePdf(lease);
|
||||
service.downloadRentReceipt(rentPayment);
|
||||
service.downloadRentReceipt(rentDue);
|
||||
service.downloadPaymentReceipt(rentPayment);
|
||||
service.downloadLeasePdf({ ...lease, id: undefined });
|
||||
|
||||
expect(tenants.deleteTenant).toHaveBeenCalledWith('tenant-1');
|
||||
expect(leases.deleteLease).toHaveBeenCalledWith('lease-1');
|
||||
expect(URL.createObjectURL).toHaveBeenCalledTimes(3);
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledTimes(3);
|
||||
expect(URL.createObjectURL).toHaveBeenCalledTimes(4);
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('displays a server attachment error or its fallback', () => {
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
import { Apartment, ApartmentService } from './apartment.service';
|
||||
import { FileDownloadService } from './file-download.service';
|
||||
import { finalize } from 'rxjs';
|
||||
import { Lease, LeaseInput, LeaseService } from './lease.service';
|
||||
import { Lease, LeaseInput, LeaseService, RentDue } from './lease.service';
|
||||
import { RentPayment, RentPaymentService } from './rent-payment.service';
|
||||
import { Tenant, TenantAttachment, TenantService } from './tenant.service';
|
||||
import { TenantAttachmentTransferService } from './tenant-attachment-transfer.service';
|
||||
@@ -302,27 +302,30 @@ export class WorkspaceService {
|
||||
this.rentPaymentResource.load();
|
||||
}
|
||||
|
||||
markRentPaymentPaid(id: string, rent: number, charges: number): void {
|
||||
this.rentPaymentResource.save(id, this.rentPaymentService.markPaid(id, rent, charges));
|
||||
acceptRentPayment(payment: RentPayment): void {
|
||||
this.rentPaymentResource.upsert(payment);
|
||||
this.rentPaymentResource.load();
|
||||
this.rentDueResource.load();
|
||||
}
|
||||
|
||||
rentPaymentSaving(id: string): boolean {
|
||||
return this.rentPaymentResource.savingKeys().has(id);
|
||||
}
|
||||
|
||||
rentPaymentSaveError(id: string): string {
|
||||
return this.rentPaymentResource.saveErrors()[id] ?? '';
|
||||
}
|
||||
|
||||
downloadRentReceipt(payment: RentPayment): void {
|
||||
downloadRentReceipt(rentDue: RentDue): void {
|
||||
this.downloads.download(
|
||||
this.rentReceiptDownloadKey(payment.id),
|
||||
this.rentPaymentService.downloadReceipt(payment.id),
|
||||
`quittance-${payment.month}.pdf`,
|
||||
this.rentReceiptDownloadKey(rentDue.id),
|
||||
this.leaseService.downloadRentReceipt(rentDue.id),
|
||||
`quittance-${rentDue.month}.pdf`,
|
||||
'Impossible de télécharger cette quittance.',
|
||||
);
|
||||
}
|
||||
|
||||
downloadPaymentReceipt(payment: RentPayment): void {
|
||||
this.downloads.download(
|
||||
this.paymentReceiptDownloadKey(payment.id),
|
||||
this.rentPaymentService.downloadReceipt(payment.id),
|
||||
`recu-reglement-${payment.id}.pdf`,
|
||||
'Impossible de télécharger ce reçu de règlement.',
|
||||
);
|
||||
}
|
||||
|
||||
rentReceiptDownloading(paymentId: string): boolean {
|
||||
return this.downloads.isPending(this.rentReceiptDownloadKey(paymentId));
|
||||
}
|
||||
@@ -331,6 +334,14 @@ export class WorkspaceService {
|
||||
return this.downloads.error(this.rentReceiptDownloadKey(paymentId));
|
||||
}
|
||||
|
||||
paymentReceiptDownloading(paymentId: string): boolean {
|
||||
return this.downloads.isPending(this.paymentReceiptDownloadKey(paymentId));
|
||||
}
|
||||
|
||||
paymentReceiptDownloadError(paymentId: string): string {
|
||||
return this.downloads.error(this.paymentReceiptDownloadKey(paymentId));
|
||||
}
|
||||
|
||||
private resourceKey(id?: string): string {
|
||||
return id ?? newResourceKey;
|
||||
}
|
||||
@@ -343,6 +354,10 @@ export class WorkspaceService {
|
||||
return `rent-receipt:${paymentId}`;
|
||||
}
|
||||
|
||||
private paymentReceiptDownloadKey(paymentId: string): string {
|
||||
return `payment-receipt:${paymentId}`;
|
||||
}
|
||||
|
||||
private requestConfirmation(data: ConfirmationDialogData, onConfirmed: () => void): void {
|
||||
this.dialog
|
||||
.open(ConfirmationDialog, {
|
||||
|
||||
Reference in new issue
Block a user