Skip to content
Merged
7 changes: 7 additions & 0 deletions .claude/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -487,3 +487,10 @@ Migration 044 : `member_user_id` et `period_month` sur `accounting_entries` (les
- **Un adhérent, un mois** : vérifié par `EntryService` sous le verrou de l'exercice (pas de clé unique en base : une contre-passation puis une nouvelle saisie sont légitimes). Une cotisation annulée libère le mois. Limite connue : deux exercices différents saisis au même instant pour le même mois ne sont pas sérialisés (administrateurs seulement, négligeable).
- **Tableau calculé depuis le livre** (`Service/Dues/CotisationService`) : payée / dispensée / à payer (mois atteint) / à venir, chaque état écrit en toutes lettres. Une dispense (réversible) ne peut pas viser un mois déjà payé. Aucun nom dans les journaux ni les e-mails.
- **Anonymisation d'un compte** : la ligne reste, l'écriture est intacte ; le journal affiche « un ancien membre » si le nom n'est plus listé. Pas de modèle récurrent « cotisation » : le tableau montre déjà les adhérents attendus (YAGNI).

## Comptabilité : rapport, export et chiffres de l'AG (#362, livraison 9)

`GET /admin/accounting/years/{id}/report` (rapport financier imprimable, art. 11), `GET /admin/accounting/years/{id}/export.csv` (export complet, art. 13), `GET /api/admin/accounting/figures[?year=]` (texte des chiffres de l'exercice) : `ReportController`, `YearReportView`, `src/Accounting/Export/` (`CsvSafe`, `CsvResponse`, `LedgerExportService`). Administrateurs seulement, tout en lecture (`GET`, aucun effet de bord).
- **CSV pour tableur** : `;`, virgule décimale, UTF-8 avec BOM, colonnes Recette et Dépense (montants sans signe, faciles à additionner), guillemets et retours à la ligne protégés ; **tout texte saisi** (libellé, pièce, note, nom d'adhérent) passe par `CsvSafe` (apostrophe devant `=`, `+`, `-`, `@`, tabulation, retour, même après des espaces). Lecture par paquets de 500 (`eachByYear`, jamais tout en mémoire) ; nom de fichier fixé par le serveur (`comptabilite-exercice-{id}.csv`), `nosniff`, `Cache-Control: private, no-store`. Droits et exercice sont jugés AVANT le premier octet.
- **Rapport imprimable** : page autonome à fond blanc (comme le compte rendu d'AG), soldes, catégories par sens, journal ; un exercice ouvert est annoncé « provisoire ». Aucun script ni style en ligne.
- **Chiffres de l'AG** : le bouton « Reprendre les chiffres de l'exercice » du compte rendu d'AG ajoute le texte à la FIN du rapport financier (jamais d'écrasement, pas de doublon), sans rien enregistrer ; aucun nom dans ce texte.
4 changes: 4 additions & 0 deletions config/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use App\Accounting\Controller\Api\StatementApiController;
use App\Accounting\Controller\CotisationPageController;
use App\Accounting\Controller\RecurringPageController;
use App\Accounting\Controller\ReportController;
use App\Accounting\Controller\StatementPageController;
use App\Association\Controller\Api\AssociationBoardApiController;
use App\Association\Controller\Api\AssociationTextApiController;
Expand Down Expand Up @@ -66,6 +67,8 @@
['GET', '/admin/accounting', [AccountingPageController::class, 'index']],
['GET', '/admin/accounting/recurring', [RecurringPageController::class, 'index']],
['GET', '/admin/accounting/cotisations', [CotisationPageController::class, 'index']],
['GET', '/admin/accounting/years/{id}/report', [ReportController::class, 'report']],
['GET', '/admin/accounting/years/{id}/export.csv', [ReportController::class, 'csv']],
['GET', '/admin/accounting/statements', [StatementPageController::class, 'index']],
['GET', '/admin/accounting/statements/{id}/file', [StatementPageController::class, 'download']],
['GET', '/admin/board', [AssociationBoardPageController::class, 'index']],
Expand Down Expand Up @@ -130,6 +133,7 @@
['DELETE', '/api/admin/assemblies/{id}', [AssemblyApiController::class, 'destroy']],
['POST', '/api/admin/accounting/years', [AccountingApiController::class, 'createYear']],
['POST', '/api/admin/accounting/statements', [StatementApiController::class, 'store']],
['GET', '/api/admin/accounting/figures', [ReportController::class, 'figures']],
['POST', '/api/admin/accounting/cotisations/exemptions', [AccountingApiController::class, 'setExemption']],
['POST', '/api/admin/accounting/recurring', [RecurringApiController::class, 'create']],
['PUT', '/api/admin/accounting/recurring/{id}', [RecurringApiController::class, 'update']],
Expand Down
22 changes: 22 additions & 0 deletions config/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
use App\Accounting\Controller\Api\StatementApiController;
use App\Accounting\Controller\CotisationPageController;
use App\Accounting\Controller\RecurringPageController;
use App\Accounting\Controller\ReportController;
use App\Accounting\Export\LedgerExportService;
use App\Accounting\Presenter\YearReportView;
use App\Accounting\Presenter\CotisationView;
use App\Accounting\Service\Dues\CotisationService;
use App\Accounting\Presenter\RecurringView;
Expand Down Expand Up @@ -717,6 +720,25 @@
$c->get(LoggerInterface::class),
));

$container->set(LedgerExportService::class, fn ($c) => new LedgerExportService(
$c->get(AdminAccess::class),
$c->get(FiscalYearRepositoryInterface::class),
$c->get(EntryRepositoryInterface::class),
$c->get(MemberDuesRepositoryInterface::class),
$c->get(LoggerInterface::class),
));

$container->set(ReportController::class, fn ($c) => new ReportController(
$c->get(TemplateRendererInterface::class),
$c->get(AuthGuard::class),
$c->get(FiscalYearService::class),
$c->get(EntryService::class),
$c->get(CotisationService::class),
$c->get(LedgerExportService::class),
new YearReportView(new MoneyFormatter()),
(string) ($config['assembly']['association_name'] ?? ''),
));

$container->set(CotisationPageController::class, fn ($c) => new CotisationPageController(
$c->get(TemplateRendererInterface::class),
$c->get(CsrfTokenManager::class),
Expand Down
45 changes: 45 additions & 0 deletions public/assets/css/pages/accounting-print.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/* Rapport financier imprimable (#362) : tableaux sobres, noir sur blanc ; s'ajoute à assembly-print.css. */

.rb-report-table {
width: 100%;
border-collapse: collapse;
margin: 0.5rem 0 1rem;
}

.rb-report-table th,
.rb-report-table td {
padding: 0.25rem 0.5rem;
border-bottom: 1px solid #999;
text-align: left;
vertical-align: top;
}

.rb-report-table tbody th {
font-weight: 400;
}

.rb-report-table td:last-child {
text-align: right;
white-space: nowrap;
}

.rb-report-total th,
.rb-report-total td {
font-weight: 700;
border-top: 2px solid #000;
}

.rb-report-meta {
color: #333;
font-size: 0.875em;
}

.rb-report-journal {
font-size: 0.875rem;
}

@media print {
.rb-report-journal tr {
break-inside: avoid;
}
}
33 changes: 33 additions & 0 deletions public/assets/js/admin/accounting/admin-figures.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* Bouton « Reprendre les chiffres de l'exercice » du compte rendu d'AG (#362) : lit les chiffres de l'exercice le plus récent (lecture seule)
* et les ajoute au rapport financier, sans rien enregistrer : la personne relit puis enregistre le brouillon comme d'habitude.
*/
import { apiFetch } from '../../core/api.js';
import { showToast } from '../../core/toast.js';
import { mergeFigures } from './figures.js';

async function onClick(event) {
const button = event.target.closest('[data-import-figures]');
if (button === null) {
return;
}
const field = document.getElementById('assembly-financialReport');
if (field === null) {
return;
}
button.disabled = true;
try {
const figures = await apiFetch('/api/admin/accounting/figures');
field.value = mergeFigures(field.value, figures.text);
field.dispatchEvent(new Event('input', { bubbles: true }));
showToast(`Chiffres de l'exercice ${figures.label} ajoutés : relisez puis enregistrez.`, 'success');
} catch (error) {
showToast(error?.status === 404 ? 'Aucun exercice dans la comptabilité.' : 'Impossible de reprendre les chiffres.', 'error');
} finally {
button.disabled = false;
}
}

export function initAdminFigures(root = document) {
root.addEventListener('click', onClick);
}
13 changes: 13 additions & 0 deletions public/assets/js/admin/accounting/figures.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* Chiffres de l'exercice repris dans le rapport financier d'un compte rendu d'AG (#362), sans DOM : comment le texte reçu rejoint ce qui est
* déjà écrit. On n'écrase jamais la saisie : le texte s'ajoute après, et une seconde reprise ne le duplique pas.
*/
export function mergeFigures(current, figures) {
const written = current.trim();
const incoming = figures.trim();
if (incoming === '' || written.includes(incoming)) {
return current;
}

return written === '' ? incoming : `${written}\n\n${incoming}`;
}
21 changes: 21 additions & 0 deletions public/assets/js/admin/accounting/figures.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mergeFigures } from './figures.js';

test('un rapport vide reçoit les chiffres tels quels', () => {
assert.equal(mergeFigures('', 'Recettes : 20,00 €'), 'Recettes : 20,00 €');
assert.equal(mergeFigures(' \n', 'Recettes : 20,00 €'), 'Recettes : 20,00 €');
});

test('la saisie existante est conservée : les chiffres s\'ajoutent après une ligne vide', () => {
assert.equal(mergeFigures('Le trésorier présente les comptes.', 'Recettes : 20,00 €'), 'Le trésorier présente les comptes.\n\nRecettes : 20,00 €');
});

test('une seconde reprise ne duplique pas les chiffres', () => {
const once = mergeFigures('Intro', 'Recettes : 20,00 €');
assert.equal(mergeFigures(once, 'Recettes : 20,00 €'), once);
});

test('un texte reçu vide ne change rien', () => {
assert.equal(mergeFigures('Intro', ' '), 'Intro');
});
2 changes: 2 additions & 0 deletions public/assets/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { initAdminAccounting } from './admin/accounting/admin-accounting.js';
import { initAdminStatements } from './admin/accounting/admin-statements.js';
import { initAdminRecurring } from './admin/accounting/admin-recurring.js';
import { initAdminCotisations } from './admin/accounting/admin-cotisations.js';
import { initAdminFigures } from './admin/accounting/admin-figures.js';
import { initAdminBoard } from './admin/board/admin-board.js';
import { initAdminTexts } from './admin/texts/admin-texts.js';
import { initAdminAssemblies } from './admin/assembly/admin-assemblies.js';
Expand Down Expand Up @@ -48,6 +49,7 @@ document.addEventListener('DOMContentLoaded', () => {
initAdminStatements();
initAdminRecurring();
initAdminCotisations();
initAdminFigures();
initAdminBoard();
initAdminTexts();
initBookingsBadge();
Expand Down
73 changes: 73 additions & 0 deletions src/Accounting/Controller/ReportController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<?php

declare(strict_types=1);

namespace App\Accounting\Controller;

use App\Account\Entity\UserRole;
use App\Accounting\Export\CsvResponse;
use App\Accounting\Export\LedgerExportService;
use App\Accounting\Exception\FiscalYearNotFoundException;
use App\Accounting\Presenter\YearReportView;
use App\Accounting\Service\Dues\CotisationService;
use App\Accounting\Service\EntryService;
use App\Accounting\Service\FiscalYearService;
use App\Http\JsonResponse;
use App\Http\Request;
use App\Http\Response;
use App\Security\AuthGuard;
use App\Support\StrictId;
use App\View\TemplateRendererInterface;

/**
* Sorties de la comptabilité (#362), administrateurs uniquement, toutes en lecture (`GET`, aucun effet de bord) : rapport financier imprimable
* d'un exercice (art. 11), export CSV complet (art. 13) et chiffres à reprendre dans le compte rendu d'assemblée. Identifiant strictement
* numérique (sinon refus comme un accès interdit) ; exercice inconnu : 404 sans répéter l'identifiant. Nom du fichier d'export fixé par le serveur.
*/
final class ReportController
{
public function __construct(
private readonly TemplateRendererInterface $renderer,
private readonly AuthGuard $authGuard,
private readonly FiscalYearService $years,
private readonly EntryService $entries,
private readonly CotisationService $dues,
private readonly LedgerExportService $export,
private readonly YearReportView $view,
private readonly string $associationName,
) {
}

public function report(Request $request, string $id): Response
{
$admin = $this->authGuard->requireRole(UserRole::Admin);
$summary = $this->years->summary($admin, StrictId::orDenied($id));

return new Response($this->renderer->render('admin/accounting/report', [
'report' => $this->view->build($summary['year'], $summary['totals'], $this->entries->list($admin, $summary['year']->id), $this->dues->memberNames($admin), $this->associationName),
'backHref' => '/admin/accounting?year=' . $summary['year']->id,
]));
}

public function csv(Request $request, string $id): Response
{
$admin = $this->authGuard->requireRole(UserRole::Admin);
$yearId = StrictId::orDenied($id);

return new CsvResponse($this->export->rows($admin, $yearId), 'comptabilite-exercice-' . $yearId . '.csv');
}

/** Chiffres de l'exercice demandé (`year`) ou, à défaut, du plus récent, en texte brut. */
public function figures(Request $request): JsonResponse
{
$admin = $this->authGuard->requireRole(UserRole::Admin);
$requested = $request->query('year');
$yearId = $requested === null ? ($this->years->list($admin)[0]->id ?? null) : StrictId::orDenied($requested);
if ($yearId === null) {
throw new FiscalYearNotFoundException();
}
$summary = $this->years->summary($admin, $yearId);

return new JsonResponse(['label' => $summary['year']->label, 'text' => $this->view->figuresText($summary['year'], $summary['totals'])]);
}
}
57 changes: 57 additions & 0 deletions src/Accounting/Export/CsvResponse.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

declare(strict_types=1);

namespace App\Accounting\Export;

use App\Http\Response;

/**
* Réponse CSV à la française pour un tableur (#362) : séparateur `;`, UTF-8 avec BOM, guillemets et retours à la ligne protégés (fputcsv).
* Les lignes viennent d'un itérateur et sont écrites au fil de l'eau (jamais tout le fichier en mémoire à l'envoi). Pièce jointe au nom FIXE
* choisi par le serveur (jamais une valeur de l'utilisateur), `nosniff`, jamais mis en cache.
*/
final class CsvResponse extends Response
{
/** @param iterable<list<string>> $rows */
public function __construct(private readonly iterable $rows, string $filename)
{
parent::__construct('', 200, [
'Content-Type' => 'text/csv; charset=utf-8',
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
'X-Content-Type-Options' => 'nosniff',
'Cache-Control' => 'private, no-store',
]);
}

public function body(): string
{
$out = fopen('php://temp', 'w+');
if ($out === false) {
return '';
}
$this->write($out);
rewind($out);

return (string) stream_get_contents($out);
}

public function send(): void
{
$this->sendHeaders();
$out = fopen('php://output', 'w');
if ($out !== false) {
$this->write($out);
fclose($out);
}
}

/** @param resource $out */
private function write($out): void
{
fwrite($out, "\xEF\xBB\xBF");
foreach ($this->rows as $row) {
fputcsv($out, $row, ';', '"', '', "\r\n");
}
}
}
25 changes: 25 additions & 0 deletions src/Accounting/Export/CsvSafe.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

namespace App\Accounting\Export;

/**
* Neutralisation des cellules d'un export CSV (#362) : un texte saisi par un utilisateur (libellé, note, nom) qui commencerait par `=`, `+`, `-`,
* `@`, une tabulation ou un retour (même après des espaces) serait exécuté comme une formule à l'ouverture dans un tableur. On le fait précéder
* d'une apostrophe : il s'affiche tel quel, il ne s'exécute pas. Les montants ne passent PAS par ici (ils sont formatés par l'exportateur).
*/
final class CsvSafe
{
private const TRIGGERS = ['=', '+', '-', '@', "\t", "\r", "\n"];

public static function cell(string $text): string
{
$head = ltrim($text, " \u{a0}");
if ($head !== '' && in_array($head[0], self::TRIGGERS, true)) {
return "'" . $text;
}

return $text;
}
}
Loading
Loading