diff --git a/.claude/architecture.md b/.claude/architecture.md index eea3b155..cef4d967 100644 --- a/.claude/architecture.md +++ b/.claude/architecture.md @@ -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. diff --git a/config/routes.php b/config/routes.php index 880f1bc2..bebae9ed 100644 --- a/config/routes.php +++ b/config/routes.php @@ -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; @@ -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']], @@ -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']], diff --git a/config/services.php b/config/services.php index bd5bdd80..483e9fef 100644 --- a/config/services.php +++ b/config/services.php @@ -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; @@ -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), diff --git a/public/assets/css/pages/accounting-print.css b/public/assets/css/pages/accounting-print.css new file mode 100644 index 00000000..850e1c4c --- /dev/null +++ b/public/assets/css/pages/accounting-print.css @@ -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; + } +} diff --git a/public/assets/js/admin/accounting/admin-figures.js b/public/assets/js/admin/accounting/admin-figures.js new file mode 100644 index 00000000..5a93f8ae --- /dev/null +++ b/public/assets/js/admin/accounting/admin-figures.js @@ -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); +} diff --git a/public/assets/js/admin/accounting/figures.js b/public/assets/js/admin/accounting/figures.js new file mode 100644 index 00000000..a0b75b5c --- /dev/null +++ b/public/assets/js/admin/accounting/figures.js @@ -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}`; +} diff --git a/public/assets/js/admin/accounting/figures.test.js b/public/assets/js/admin/accounting/figures.test.js new file mode 100644 index 00000000..eb3c345b --- /dev/null +++ b/public/assets/js/admin/accounting/figures.test.js @@ -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'); +}); diff --git a/public/assets/js/app.js b/public/assets/js/app.js index 553bf6e9..5923c372 100644 --- a/public/assets/js/app.js +++ b/public/assets/js/app.js @@ -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'; @@ -48,6 +49,7 @@ document.addEventListener('DOMContentLoaded', () => { initAdminStatements(); initAdminRecurring(); initAdminCotisations(); + initAdminFigures(); initAdminBoard(); initAdminTexts(); initBookingsBadge(); diff --git a/src/Accounting/Controller/ReportController.php b/src/Accounting/Controller/ReportController.php new file mode 100644 index 00000000..6502576e --- /dev/null +++ b/src/Accounting/Controller/ReportController.php @@ -0,0 +1,73 @@ +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'])]); + } +} diff --git a/src/Accounting/Export/CsvResponse.php b/src/Accounting/Export/CsvResponse.php new file mode 100644 index 00000000..6450f901 --- /dev/null +++ b/src/Accounting/Export/CsvResponse.php @@ -0,0 +1,57 @@ +> $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"); + } + } +} diff --git a/src/Accounting/Export/CsvSafe.php b/src/Accounting/Export/CsvSafe.php new file mode 100644 index 00000000..b0bc1fc3 --- /dev/null +++ b/src/Accounting/Export/CsvSafe.php @@ -0,0 +1,25 @@ +> l'en-tête puis une ligne par écriture, par numéro + * @throws FiscalYearNotFoundException + */ + public function rows(User $actor, int $yearId): iterable + { + $this->access->assertAdmin($actor); + $this->years->find($yearId) ?? throw new FiscalYearNotFoundException(); + $this->logger->info('Comptabilité : export du livre', ['actor' => $actor->id(), 'year' => $yearId]); + + return $this->generate($yearId); + } + + /** @return \Generator> */ + private function generate(int $yearId): \Generator + { + $names = []; + foreach ($this->members->listActive(self::MEMBER_LIMIT) as $member) { + $names[$member->id] = $member->displayName; + } + yield self::HEADER; + $numbers = []; + foreach ($this->entries->eachByYear($yearId) as $entry) { + $numbers[$entry->id] = $entry->number; + yield $this->row($entry, $names, $numbers); + } + } + + /** + * @param array $names + * @param array $numbers numéros des écritures déjà lues, par identifiant + * @return list + */ + private function row(AccountingEntry $entry, array $names, array $numbers): array + { + $isRecette = $entry->direction === Direction::Recette; + $amount = intdiv($entry->amountCents, 100) . ',' . str_pad((string) ($entry->amountCents % 100), 2, '0', STR_PAD_LEFT); + + return [ + (string) $entry->number, + $entry->occurredOn->format('d/m/Y'), + CsvSafe::cell($entry->label), + $entry->category->label(), + $entry->direction->label(), + $isRecette ? $amount : '', + $isRecette ? '' : $amount, + $entry->paymentMethod->label(), + CsvSafe::cell($entry->voucherRef ?? ''), + CsvSafe::cell($entry->note ?? ''), + $entry->memberUserId === null ? '' : CsvSafe::cell($names[$entry->memberUserId] ?? 'Ancien membre'), + $entry->periodMonth === null ? '' : MonthLabel::of($entry->periodMonth), + $entry->reversesEntryId === null ? '' : (isset($numbers[$entry->reversesEntryId]) ? (string) $numbers[$entry->reversesEntryId] : 'autre exercice'), + ]; + } +} diff --git a/src/Accounting/Presenter/YearReportView.php b/src/Accounting/Presenter/YearReportView.php new file mode 100644 index 00000000..f26143e7 --- /dev/null +++ b/src/Accounting/Presenter/YearReportView.php @@ -0,0 +1,92 @@ + $entries par numéro + * @param array $memberNames noms des comptes actifs par identifiant + * @return array + */ + public function build(FiscalYear $year, LedgerTotals $totals, array $entries, array $memberNames, string $associationName): array + { + $balance = $year->openingBalanceCents + $totals->netCents(); + + return [ + 'associationName' => $associationName, + 'title' => 'Rapport financier — exercice ' . $year->label, + 'period' => 'du ' . $year->startsOn->format('d/m/Y') . ' au ' . $year->endsOn->format('d/m/Y'), + 'banner' => $year->isOpen() ? 'Exercice en cours : ces chiffres sont provisoires.' : null, + 'statusLine' => $year->status->label() . ($year->approvedOn !== null ? ' le ' . $year->approvedOn->format('d/m/Y') : ''), + 'opening' => $this->money->format($year->openingBalanceCents), + 'recettes' => $this->money->format($totals->recettesCents), + 'depenses' => $this->money->format($totals->depensesCents), + 'closing' => $this->money->format($year->closingBalanceCents ?? $balance), + 'closingLabel' => $year->isOpen() ? 'Solde actuel' : 'Solde final', + 'recetteLines' => $this->lines($totals, Direction::Recette), + 'depenseLines' => $this->lines($totals, Direction::Depense), + 'entries' => array_map(fn (AccountingEntry $e): array => [ + 'number' => $e->number, + 'date' => $e->occurredOn->format('d/m/Y'), + 'label' => $e->label, + 'category' => $e->category->label(), + 'amount' => $this->money->format($e->direction === Direction::Recette ? $e->amountCents : -$e->amountCents, true), + 'detail' => $e->periodMonth === null ? '' : 'Cotisation de ' . ($memberNames[$e->memberUserId ?? 0] ?? 'un ancien membre') . ' pour ' . MonthLabel::of($e->periodMonth), + ], $entries), + ]; + } + + /** Texte à reprendre dans le rapport financier du compte rendu d'assemblée : chiffres de l'exercice, sans aucun nom. */ + public function figuresText(FiscalYear $year, LedgerTotals $totals): string + { + $balance = $year->openingBalanceCents + $totals->netCents(); + $final = $year->closingBalanceCents ?? $balance; + $lines = [ + 'Chiffres de l\'exercice ' . $year->label . ' (du ' . $year->startsOn->format('d/m/Y') . ' au ' . $year->endsOn->format('d/m/Y') . ')' . ($year->isOpen() ? ', provisoires : l\'exercice n\'est pas clôturé' : '') . ' :', + 'Solde d\'ouverture : ' . $this->money->format($year->openingBalanceCents), + 'Recettes : ' . $this->money->format($totals->recettesCents), + ]; + foreach ($this->lines($totals, Direction::Recette) as $line) { + $lines[] = '- ' . $line['label'] . ' : ' . $line['amount']; + } + $lines[] = 'Dépenses : ' . $this->money->format($totals->depensesCents); + foreach ($this->lines($totals, Direction::Depense) as $line) { + $lines[] = '- ' . $line['label'] . ' : ' . $line['amount']; + } + $lines[] = ($year->isOpen() ? 'Solde actuel' : 'Solde final') . ' : ' . $this->money->format($final); + + return implode("\n", $lines); + } + + /** @return list les catégories d'un sens, montants positifs */ + private function lines(LedgerTotals $totals, Direction $direction): array + { + $lines = []; + foreach ($totals->netByCategory as $value => $net) { + $category = EntryCategory::tryFrom($value); + if ($category !== null && $category->direction() === $direction) { + $lines[] = ['label' => $category->label(), 'amount' => $this->money->format(abs($net))]; + } + } + + return $lines; + } +} diff --git a/src/Accounting/Repository/EntryRepositoryInterface.php b/src/Accounting/Repository/EntryRepositoryInterface.php index c42ee408..0dc1bda7 100644 --- a/src/Accounting/Repository/EntryRepositoryInterface.php +++ b/src/Accounting/Repository/EntryRepositoryInterface.php @@ -26,6 +26,13 @@ public function reversalOf(int $id): ?int; /** @return list par numéro, au plus `$limit` */ public function listByYear(int $fiscalYearId, int $limit): array; + /** + * Toutes les écritures de l'exercice par numéro, lues par paquets (jamais tout en mémoire) : pour l'export complet. + * + * @return \Generator + */ + public function eachByYear(int $fiscalYearId): \Generator; + public function totals(int $fiscalYearId): LedgerTotals; /** Mouvement net de la BANQUE (tout sauf les espèces) de l'exercice jusqu'à `$date` incluse : recettes moins dépenses. */ diff --git a/src/Accounting/Repository/MysqlEntryRepository.php b/src/Accounting/Repository/MysqlEntryRepository.php index 5aa56ef3..1042c7b2 100644 --- a/src/Accounting/Repository/MysqlEntryRepository.php +++ b/src/Accounting/Repository/MysqlEntryRepository.php @@ -83,6 +83,21 @@ public function listByYear(int $fiscalYearId, int $limit): array return array_map($this->hydrate(...), $statement->fetchAll(\PDO::FETCH_ASSOC)); } + public function eachByYear(int $fiscalYearId): \Generator + { + $after = 0; + do { + $statement = $this->pdo->prepare('SELECT * FROM accounting_entries WHERE fiscal_year_id = :year AND entry_number > :after ORDER BY entry_number LIMIT 500'); + $statement->execute(['year' => $fiscalYearId, 'after' => $after]); + $rows = $statement->fetchAll(\PDO::FETCH_ASSOC); + foreach ($rows as $row) { + $entry = $this->hydrate($row); + $after = $entry->number; + yield $entry; + } + } while (count($rows) === 500); + } + public function totals(int $fiscalYearId): LedgerTotals { $statement = $this->pdo->prepare( diff --git a/templates/admin/accounting/index.php b/templates/admin/accounting/index.php index a6696979..938b4da1 100644 --- a/templates/admin/accounting/index.php +++ b/templates/admin/accounting/index.php @@ -51,7 +51,11 @@ -

Relevés de compte Écritures récurrentes

+

Relevés de compte Écritures récurrentes Cotisations

+

+ Rapport imprimable + Exporter en CSV +

diff --git a/templates/admin/accounting/report.php b/templates/admin/accounting/report.php new file mode 100644 index 00000000..fc5acc86 --- /dev/null +++ b/templates/admin/accounting/report.php @@ -0,0 +1,89 @@ + $report voir YearReportView::build + * @var string $backHref + */ +?> + + + + + + <?= e($report['title']) ?> — RehearsalBox + + + + + +
+
+ + +
+ + +

+ + +

+

+

—

+ +
+

Soldes

+ + + + + + + +
Solde d'ouverture
Recettes
Dépenses
+
+ +
+

Recettes par catégorie

+ +

Aucune recette.

+ + + +
+ +

Dépenses par catégorie

+ +

Aucune dépense.

+ + + +
+ +
+ +
+

Journal

+ +

Aucune écriture.

+ + + + + + + + + + + + + +
N°DateLibelléMontant
()
+ +
+
+ + + diff --git a/templates/admin/assemblies/edit.php b/templates/admin/assemblies/edit.php index 8d8ecedb..9506b312 100644 --- a/templates/admin/assemblies/edit.php +++ b/templates/admin/assemblies/edit.php @@ -165,6 +165,7 @@
+
diff --git a/tests/Accounting/Export/CsvSafeTest.php b/tests/Accounting/Export/CsvSafeTest.php new file mode 100644 index 00000000..e5fac742 --- /dev/null +++ b/tests/Accounting/Export/CsvSafeTest.php @@ -0,0 +1,55 @@ + */ + public static function dangerous(): iterable + { + yield 'égal' => ['=1+1', "'=1+1"]; + yield 'plus' => ['+33612345678', "'+33612345678"]; + yield 'moins' => ['-2+3', "'-2+3"]; + yield 'arobase' => ['@SUM(A1)', "'@SUM(A1)"]; + yield 'tabulation' => ["\t=1", "'\t=1"]; + yield 'retour chariot' => ["\r=1", "'\r=1"]; + yield 'saut de ligne' => ["\n=1", "'\n=1"]; + yield 'espaces puis égal' => [' =1+1', "' =1+1"]; + yield 'espace insécable puis égal' => ["\u{a0}=1", "'\u{a0}=1"]; + yield 'DDE' => ['=cmd|\' /C calc\'!A0', "'=cmd|' /C calc'!A0"]; + yield 'hyperlien' => ['=HYPERLINK("http://x.test","a")', "'=HYPERLINK(\"http://x.test\",\"a\")"]; + } + + #[Test] + #[DataProvider('dangerous')] + public function testADangerousStartIsNeutralised(string $input, string $expected): void + { + self::assertSame($expected, CsvSafe::cell($input)); + } + + /** @return iterable */ + public static function harmless(): iterable + { + yield 'texte' => ['Cotisation']; + yield 'vide' => ['']; + yield 'accents' => ['Électricité de novembre']; + yield 'signe au milieu' => ['Loyer = 166 €']; + yield 'tiret au milieu' => ['Loyer - octobre']; + yield 'point-virgule' => ['a;b']; + } + + #[Test] + #[DataProvider('harmless')] + public function testAnOrdinaryTextIsUntouched(string $input): void + { + self::assertSame($input, CsvSafe::cell($input)); + } +} diff --git a/tests/Accounting/Export/LedgerExportServiceTest.php b/tests/Accounting/Export/LedgerExportServiceTest.php new file mode 100644 index 00000000..7fbaaedd --- /dev/null +++ b/tests/Accounting/Export/LedgerExportServiceTest.php @@ -0,0 +1,123 @@ +export = new LedgerExportService(new AdminAccess(), $this->yearRepository, $this->entryRepository, new MysqlMemberDuesRepository($this->pdo), new NullLogger()); + } + + /** @return list> */ + private function rows(int $year): array + { + return iterator_to_array($this->export->rows($this->admin, $year), false); + } + + #[Test] + public function testTheExportHasAHeaderAndOneLinePerEntryWithFrenchAmounts(): void + { + $year = $this->openYear(); + $this->record($year, ['amount' => '1234,50']); + $this->expense($year, '156,00'); + + $rows = $this->rows($year); + + self::assertCount(3, $rows); + self::assertSame('Numéro', $rows[0][0]); + self::assertSame(['1', '05/11/2025', 'Cotisation', 'Cotisations', 'Argent reçu', '1234,50', '', 'Virement'], array_slice($rows[1], 0, 8)); + self::assertSame(['2', '05/11/2025', 'Loyer', 'Loyer', 'Argent dépensé', '', '156,00', 'Virement'], array_slice($rows[2], 0, 8)); + } + + #[Test] + public function testHostileTextIsNeutralisedInEveryTextColumn(): void + { + $year = $this->openYear(); + $this->record($year, ['label' => '=HYPERLINK("http://x.test","a")', 'voucherRef' => '+33612345678', 'note' => '@SUM(A1)']); + + $row = $this->rows($year)[1]; + + self::assertSame("'=HYPERLINK(\"http://x.test\",\"a\")", $row[2]); + self::assertSame("'+33612345678", $row[8]); + self::assertSame("'@SUM(A1)", $row[9]); + } + + #[Test] + public function testAReversalPointsToTheOriginalNumberAndADuesLineNamesTheMember(): void + { + $year = $this->openYear(); + $dues = $this->record($year, ['category' => 'cotisations', 'memberUserId' => (string) $this->musician->id(), 'periodMonth' => '2025-11']); + $this->entries->reverse($this->admin, $dues->id, ['reason' => 'Erreur', 'occurredOn' => '2025-11-06'], $this->clock); + + [, $original, $reversal] = $this->rows($year); + + self::assertSame('Musicien', $original[10]); + self::assertSame('novembre 2025', $original[11]); + self::assertSame('1', $reversal[12]); + } + + #[Test] + public function testOnlyAdministratorsExportAndAnUnknownYearIsRefusedBeforeAnyLine(): void + { + $year = $this->openYear(); + foreach ([$this->musician, $this->gone] as $actor) { + try { + $this->export->rows($actor, $year); + self::fail('refus attendu'); + } catch (AccessDeniedException) { + } + } + $this->expectException(FiscalYearNotFoundException::class); + $this->export->rows($this->admin, 99999999); + } + + #[Test] + public function testTheCsvResponseIsAFrenchSpreadsheetAttachmentWithAFixedName(): void + { + $year = $this->openYear(); + $this->record($year, ['label' => 'Libellé; avec "guillemets"']); + + $response = new CsvResponse($this->export->rows($this->admin, $year), 'comptabilite.csv'); + $body = $response->body(); + + self::assertStringStartsWith("\xEF\xBB\xBF", $body); + self::assertStringContainsString("Numéro;Date;Libellé;", $body); + self::assertStringContainsString('"Libellé; avec ""guillemets"""', $body); + self::assertStringContainsString("\"deux\nlignes\"", (new CsvResponse([["deux\nlignes"]], 'x.csv'))->body()); + self::assertSame('attachment; filename="comptabilite.csv"', $response->headers()['Content-Disposition']); + self::assertSame('nosniff', $response->headers()['X-Content-Type-Options']); + self::assertStringContainsString('no-store', $response->headers()['Cache-Control']); + } + + #[Test] + public function testMoreThanOnePageOfEntriesIsStreamedWithoutLossOrDuplicate(): void + { + $year = $this->openYear(); + $insert = $this->pdo->prepare('INSERT INTO accounting_entries (fiscal_year_id, entry_number, occurred_on, label, amount_cents, direction, category, payment_method, created_by_user_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'); + for ($i = 1; $i <= 1203; ++$i) { + $insert->execute([$year, $i, '2025-11-05', "Ligne {$i}", 100, 'recette', 'cotisations', 'virement', $this->admin->id(), '2025-11-05 10:00:00']); + } + + $rows = $this->rows($year); + + self::assertCount(1204, $rows); + self::assertSame(range(1, 1203), array_map('intval', array_column(array_slice($rows, 1), 0))); + } +} diff --git a/tests/Accounting/Http/AccountingReportHttpTest.php b/tests/Accounting/Http/AccountingReportHttpTest.php new file mode 100644 index 00000000..060ce659 --- /dev/null +++ b/tests/Accounting/Http/AccountingReportHttpTest.php @@ -0,0 +1,124 @@ +makeUser('admin', UserRole::Admin); + $this->makeUser('musicien', UserRole::Musicien); + } + + private function year(): int + { + [, $body] = $this->call('admin', 'POST', '/api/admin/accounting/years', ['label' => '2025-2026', 'startsOn' => '2025-10-01', 'endsOn' => '2026-09-30', 'openingBalance' => '100,00']); + $id = (int) $this->json($body)['id']; + $this->call('admin', 'POST', "/api/admin/accounting/years/{$id}/entries", ['occurredOn' => '2025-11-05', 'label' => '=HYPERLINK("http://x.test")', 'amount' => '20,00', 'direction' => 'recette', 'category' => 'cotisations', 'paymentMethod' => 'virement']); + $this->call('admin', 'POST', "/api/admin/accounting/years/{$id}/entries", ['occurredOn' => '2025-11-06', 'label' => '', 'amount' => '5,00', 'direction' => 'depense', 'category' => 'loyer', 'paymentMethod' => 'virement', 'voucherRef' => 'F-1']); + + return $id; + } + + /** @return list */ + private function urls(int|string $id): array + { + return ["/admin/accounting/years/{$id}/report", "/admin/accounting/years/{$id}/export.csv"]; + } + + #[Test] + public function testTheReportShowsTheFiguresAndEscapesEverything(): void + { + $id = $this->year(); + + [$status, $html] = $this->call('admin', 'GET', "/admin/accounting/years/{$id}/report"); + + self::assertSame(200, $status); + self::assertStringContainsString("100,00\u{a0}€", $html); + self::assertStringContainsString('provisoires', $html); + self::assertStringNotContainsString('