diff --git a/.claude/architecture.md b/.claude/architecture.md index 5922bec..eea3b15 100644 --- a/.claude/architecture.md +++ b/.claude/architecture.md @@ -479,3 +479,11 @@ Migration 043 (`accounting_recurring_templates`, `accounting_recurring_occurrenc - **Rien ne s'écrit tout seul** : un modèle (libellé, sens, catégorie, moyen, montant fixe ou **variable**, rythme mensuel/trimestriel/annuel, jour 1 à 28, premier mois, actif) fait apparaître chaque échéance atteinte dans « À confirmer » ; le trésorier la **confirme** ou la **abandonne**. Une échéance future ne se confirme pas ; un modèle se désactive, ne se supprime pas ; modifier un modèle ne touche ni les écritures ni les décisions passées. - **Confirmation = `EntryService::record`** (mêmes règles, même numérotation, même verrou d'exercice) avec un rappel exécuté DANS la transaction qui inscrit la décision sous la clé unique (modèle, mois) : le double clic ou deux onglets ne créent jamais deux écritures, et l'échec de l'un annule l'autre (numéro rendu). Le client ne fournit que l'échéance, le montant (variable), la date et la pièce ; libellé, sens, catégorie, moyen, numéro et auteur viennent du modèle et du serveur. - Les cotisations par personne (livraison 6) et le rapprochement avec le CSV (livraison 7) s'appuieront sur ces modèles. + +## Comptabilité : cotisations par personne (#362, livraison 6) + +Migration 044 : `member_user_id` et `period_month` sur `accounting_entries` (les deux ensemble, jamais l'un sans l'autre) et table `accounting_member_exemptions`. Page `/admin/accounting/cotisations` (tableau adhérents × mois), API `POST /api/admin/accounting/cotisations/exemptions` ; la saisie d'une cotisation passe par l'API des écritures (`memberUserId`, `periodMonth`). +- **Cotisation par adhérent et par mois** (20 € par mois, préremplis ; l'article 6 des statuts dit 20 € par an : point d'attention du ticket). L'adhérent est un COMPTE ACTIF désigné par son identifiant, jamais un nom saisi ; les champs ne concernent que la catégorie « cotisations ». +- **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). diff --git a/.claude/rgpd.md b/.claude/rgpd.md index 8e3edab..21cb2c1 100644 --- a/.claude/rgpd.md +++ b/.claude/rgpd.md @@ -21,6 +21,7 @@ Ce qu'RehearsalBox garde d'une personne, pourquoi, combien de temps, et ce que l | Journal d'erreurs applicatif | `storage/logs` (hors base) | diagnostic | rotation par taille (voir `logging` dans `config/config.php`), fichier réservé au propriétaire ; jamais de mot de passe ni de jeton | | Documents des groupes (PDF, images) | `storage/group-documents` | partage de fiches techniques | tant que le groupe les garde ; images ré-encodées sans métadonnées (#432) | | Relevés de compte de l'association (PDF, images) (#362) | `storage/accounting-statements` | pièces justificatives du livre (IBAN et noms de tiers possibles, jamais lus ni journalisés) | 10 ans (obligation comptable) ; administrateurs seulement, ni suppression ni partage | +| Cotisations par adhérent (#362) | `accounting_entries.member_user_id`, `period_month`, `accounting_member_exemptions` | savoir qui a cotisé quel mois (obligation comptable de l'association) | 10 ans avec le livre ; compte supprimé : la ligne reste, le compte est anonymisé ; administrateurs seulement ; aucun nom dans les journaux ni les e-mails | | Sauvegardes de la base | `backups/` (hors racine web) | reprise après incident | 14 quotidiennes + 7 d'avant déploiement, puis effacées ; **une suppression de compte n'y est appliquée qu'à leur expiration** | ## Droits exercés par la personne, depuis « Mon compte » diff --git a/config/routes.php b/config/routes.php index 8754d3e..880f1bc 100644 --- a/config/routes.php +++ b/config/routes.php @@ -6,6 +6,7 @@ use App\Accounting\Controller\Api\AccountingApiController; use App\Accounting\Controller\Api\RecurringApiController; use App\Accounting\Controller\Api\StatementApiController; +use App\Accounting\Controller\CotisationPageController; use App\Accounting\Controller\RecurringPageController; use App\Accounting\Controller\StatementPageController; use App\Association\Controller\Api\AssociationBoardApiController; @@ -64,6 +65,7 @@ ['GET', '/admin/assemblies/{id}/print', [AssemblyPageController::class, 'print']], ['GET', '/admin/accounting', [AccountingPageController::class, 'index']], ['GET', '/admin/accounting/recurring', [RecurringPageController::class, 'index']], + ['GET', '/admin/accounting/cotisations', [CotisationPageController::class, 'index']], ['GET', '/admin/accounting/statements', [StatementPageController::class, 'index']], ['GET', '/admin/accounting/statements/{id}/file', [StatementPageController::class, 'download']], ['GET', '/admin/board', [AssociationBoardPageController::class, 'index']], @@ -128,6 +130,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']], + ['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']], ['POST', '/api/admin/accounting/recurring/{id}/confirm', [RecurringApiController::class, 'confirm']], diff --git a/config/services.php b/config/services.php index 14400c5..bd5bdd8 100644 --- a/config/services.php +++ b/config/services.php @@ -6,7 +6,10 @@ use App\Accounting\Controller\Api\AccountingApiController; use App\Accounting\Controller\Api\RecurringApiController; use App\Accounting\Controller\Api\StatementApiController; +use App\Accounting\Controller\CotisationPageController; use App\Accounting\Controller\RecurringPageController; +use App\Accounting\Presenter\CotisationView; +use App\Accounting\Service\Dues\CotisationService; use App\Accounting\Presenter\RecurringView; use App\Accounting\Repository\MysqlRecurringOccurrenceRepository; use App\Accounting\Repository\MysqlRecurringTemplateRepository; @@ -27,7 +30,9 @@ use App\Accounting\Presenter\MoneyFormatter; use App\Accounting\Repository\EntryRepositoryInterface; use App\Accounting\Repository\FiscalYearRepositoryInterface; +use App\Accounting\Repository\MemberDuesRepositoryInterface; use App\Accounting\Repository\MysqlEntryRepository; +use App\Accounting\Repository\MysqlMemberDuesRepository; use App\Accounting\Repository\MysqlFiscalYearRepository; use App\Accounting\Service\AccountingAmountParser; use App\Accounting\Service\EntryInputPolicy; @@ -693,18 +698,38 @@ $c->get(LoggerInterface::class), )); + $container->set(MemberDuesRepositoryInterface::class, fn ($c) => new MysqlMemberDuesRepository($c->get(PDO::class))); + $container->set(EntryService::class, fn ($c) => new EntryService( $c->get(AdminAccess::class), new EntryInputPolicy(new AccountingAmountParser()), $c->get(FiscalYearRepositoryInterface::class), $c->get(EntryRepositoryInterface::class), + $c->get(MemberDuesRepositoryInterface::class), $c->get(TransactionRunner::class), $c->get(LoggerInterface::class), )); + $container->set(CotisationService::class, fn ($c) => new CotisationService( + $c->get(AdminAccess::class), + $c->get(EntryRepositoryInterface::class), + $c->get(MemberDuesRepositoryInterface::class), + $c->get(LoggerInterface::class), + )); + + $container->set(CotisationPageController::class, fn ($c) => new CotisationPageController( + $c->get(TemplateRendererInterface::class), + $c->get(CsrfTokenManager::class), + $c->get(AuthGuard::class), + $c->get(FiscalYearService::class), + $c->get(CotisationService::class), + new CotisationView(), + )); + $container->set(AccountingApiController::class, fn ($c) => new AccountingApiController( $c->get(FiscalYearService::class), $c->get(EntryService::class), + $c->get(CotisationService::class), $c->get(AuthGuard::class), )); @@ -715,6 +740,7 @@ $c->get(FiscalYearService::class), $c->get(EntryService::class), new AccountingJournalView(new MoneyFormatter()), + $c->get(CotisationService::class), )); $container->set(StatementRepositoryInterface::class, fn ($c) => new MysqlStatementRepository($c->get(PDO::class))); diff --git a/database/migrations/044_accounting_member_dues.sql b/database/migrations/044_accounting_member_dues.sql new file mode 100644 index 0000000..40c10b6 --- /dev/null +++ b/database/migrations/044_accounting_member_dues.sql @@ -0,0 +1,22 @@ +-- Cotisations par personne (#362), livraison 6 : la cotisation est due PAR ADHÉRENT et par mois (décision du propriétaire), pas par groupe. +-- Une écriture de la catégorie « cotisations » peut désigner un compte (`member_user_id`, jamais un nom saisi en texte) et le mois couvert +-- (`period_month`, premier jour du mois) ; les deux vont ensemble et ne concernent que les cotisations (contrôlé par le service). Les comptes +-- ne sont jamais supprimés (anonymisés) : la clé reste valide et l'écriture intacte. L'unicité « un adhérent, un mois » ne peut pas être une clé +-- de la base (une contre-passation, puis une nouvelle saisie, sont légitimes) : elle est vérifiée par le service sous le verrou de l'exercice. +-- `accounting_member_exemptions` : mois où un adhérent est dispensé de cotisation (décision du trésorier), réversible. +ALTER TABLE accounting_entries + ADD COLUMN member_user_id INT UNSIGNED NULL AFTER reverses_entry_id, + ADD COLUMN period_month DATE NULL AFTER member_user_id, + ADD KEY idx_entries_member_month (member_user_id, period_month), + ADD CONSTRAINT fk_entries_member FOREIGN KEY (member_user_id) REFERENCES users(id), + ADD CONSTRAINT chk_entries_member_month CHECK ((member_user_id IS NULL) = (period_month IS NULL)); + +CREATE TABLE accounting_member_exemptions ( + member_user_id INT UNSIGNED NOT NULL, + period_month DATE NOT NULL, + created_by_user_id INT UNSIGNED NOT NULL, + created_at DATETIME NOT NULL, + PRIMARY KEY (member_user_id, period_month), + CONSTRAINT fk_exemption_member FOREIGN KEY (member_user_id) REFERENCES users(id), + CONSTRAINT fk_exemption_creator FOREIGN KEY (created_by_user_id) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/public/assets/css/pages/accounting.css b/public/assets/css/pages/accounting.css index 2df7a68..d0b3e92 100644 --- a/public/assets/css/pages/accounting.css +++ b/public/assets/css/pages/accounting.css @@ -271,3 +271,63 @@ .rb-statement-gap-warn { color: var(--rb-warn); } + +/* Cotisations : tableau adhérents × mois, défilement horizontal DANS le cadre (jamais sur la page) ; chaque état est écrit en toutes lettres. */ +.rb-dues-scroll { + overflow-x: auto; + margin-top: var(--rb-space-3); + border: 1px solid var(--rb-border); + border-radius: var(--rb-radius-sm); +} + +.rb-dues-table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; +} + +.rb-dues-table caption { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); +} + +.rb-dues-table th, +.rb-dues-table td { + padding: var(--rb-space-2); + border-bottom: 1px solid var(--rb-border); + text-align: center; + white-space: nowrap; +} + +.rb-dues-table tbody th { + position: sticky; + left: 0; + background: var(--rb-surface); + text-align: left; +} + +.rb-dues-payee { + color: var(--rb-ok); + font-weight: 600; +} + +.rb-dues-a_payer { + color: var(--rb-warn); +} + +.rb-dues-dispensee { + color: var(--rb-text-2); +} + +.rb-dues-table .rb-btn { + min-height: 44px; +} + +.rb-dues-form { + display: grid; + gap: var(--rb-space-3); + margin-top: var(--rb-space-3); +} diff --git a/public/assets/js/admin/accounting/accounting-requests.js b/public/assets/js/admin/accounting/accounting-requests.js index 6efd046..6f824cf 100644 --- a/public/assets/js/admin/accounting/accounting-requests.js +++ b/public/assets/js/admin/accounting/accounting-requests.js @@ -39,7 +39,11 @@ export function createYearRequest(values) { } export function recordEntryRequest(yearId, values) { - const body = pick(values, ['occurredOn', 'label', 'amount', 'direction', 'category', 'paymentMethod', 'voucherRef', 'note']); + const body = pick(values, ['occurredOn', 'label', 'amount', 'direction', 'category', 'paymentMethod', 'voucherRef', 'note', 'memberUserId', 'periodMonth']); + if (body.memberUserId === '') { + delete body.memberUserId; + delete body.periodMonth; + } return { path: `/api/admin/accounting/years/${idOrThrow(yearId, 'd\'exercice')}/entries`, options: { method: 'POST', body: JSON.stringify(body) } }; } @@ -107,3 +111,15 @@ export function ignoreOccurrenceRequest(templateId, values) { options: { method: 'POST', body: JSON.stringify(pick(values, ['period'])) }, }; } + +/** Dispense de cotisation : l'adhérent (identifiant), le mois (AAAA-MM) et l'état voulu ; rien d'autre ne part. */ +export function exemptionRequest(memberId, month, exempt) { + if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(month)) { + throw new Error('Mois invalide.'); + } + + return { + path: '/api/admin/accounting/cotisations/exemptions', + options: { method: 'POST', body: JSON.stringify({ memberUserId: idOrThrow(memberId, 'd\'adhérent'), periodMonth: month, exempt: exempt === true }) }, + }; +} diff --git a/public/assets/js/admin/accounting/accounting-requests.test.js b/public/assets/js/admin/accounting/accounting-requests.test.js index 7167b79..66b2431 100644 --- a/public/assets/js/admin/accounting/accounting-requests.test.js +++ b/public/assets/js/admin/accounting/accounting-requests.test.js @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { approveYearRequest, closeYearRequest, createYearRequest, depositStatementRequest, confirmOccurrenceRequest, ignoreOccurrenceRequest, templateRequest, recordEntryRequest, reverseRequest } from './accounting-requests.js'; +import { approveYearRequest, closeYearRequest, createYearRequest, depositStatementRequest, exemptionRequest, confirmOccurrenceRequest, ignoreOccurrenceRequest, templateRequest, recordEntryRequest, reverseRequest } from './accounting-requests.js'; test('chaque requête vise la bonne route et ne porte que les champs attendus', () => { assert.deepEqual(createYearRequest({ label: '2025-2026', startsOn: '2025-10-01', endsOn: '2026-09-30', openingBalance: '100,00', role: 'admin' }), { @@ -87,3 +87,23 @@ test('confirmer ou abandonner une échéance n\'envoie que l\'échéance, le mon assert.deepEqual(JSON.parse(ignore.options.body), { period: '2025-10-01' }); assert.throws(() => confirmOccurrenceRequest('0', {})); }); + +test('cotisation d\'un adhérent : identifiant et mois partent avec l\'écriture ; sans adhérent, le mois est retiré', () => { + const withMember = recordEntryRequest('4', { label: 'Cotisation', memberUserId: '12', periodMonth: '2025-11', amount: '20,00' }); + assert.equal(JSON.parse(withMember.options.body).memberUserId, '12'); + assert.equal(JSON.parse(withMember.options.body).periodMonth, '2025-11'); + const without = JSON.parse(recordEntryRequest('4', { label: 'Don', memberUserId: '', periodMonth: '2025-11' }).options.body); + assert.equal('memberUserId' in without, false); + assert.equal('periodMonth' in without, false); +}); + +test('dispense : route, corps minimal, identifiant et mois contrôlés', () => { + assert.deepEqual(exemptionRequest('12', '2025-11', true), { + path: '/api/admin/accounting/cotisations/exemptions', + options: { method: 'POST', body: '{"memberUserId":"12","periodMonth":"2025-11","exempt":true}' }, + }); + assert.equal(JSON.parse(exemptionRequest('12', '2025-11', 'oui').options.body).exempt, false); + assert.throws(() => exemptionRequest('0', '2025-11', true)); + assert.throws(() => exemptionRequest('12', '2025-13', true)); + assert.throws(() => exemptionRequest('12', '2025-11-01', true)); +}); diff --git a/public/assets/js/admin/accounting/admin-accounting.js b/public/assets/js/admin/accounting/admin-accounting.js index c738541..4154946 100644 --- a/public/assets/js/admin/accounting/admin-accounting.js +++ b/public/assets/js/admin/accounting/admin-accounting.js @@ -8,7 +8,7 @@ import { clearErrors, reportFailure } from '../form-feedback.js'; import { categoriesFor, isVoucherRequired, presetValues, todayIso } from './accounting-form.js'; import { approveYearRequest, closeYearRequest, createYearRequest, failureMessage, recordEntryRequest, reverseRequest } from './accounting-requests.js'; -const ENTRY_FIELDS = ['occurredOn', 'label', 'amount', 'direction', 'category', 'paymentMethod', 'voucherRef', 'note']; +const ENTRY_FIELDS = ['occurredOn', 'label', 'amount', 'direction', 'category', 'paymentMethod', 'voucherRef', 'note', 'memberUserId', 'periodMonth']; const YEAR_FIELDS = ['label', 'startsOn', 'endsOn', 'openingBalance']; const REVERSE_FIELDS = ['reason', 'occurredOn']; const APPROVE_FIELDS = ['approvedOn']; @@ -25,11 +25,22 @@ function syncCategories(form) { const previous = select.value; select.replaceChildren(...categoriesFor(direction, all).map((category) => new Option(category.label, category.value))); select.value = [...select.options].some((option) => option.value === previous) ? previous : (select.options[0]?.value ?? ''); + syncDues(form); const required = isVoucherRequired(direction); form.elements.voucherRef.required = required; form.querySelector('[data-voucher-hint]').textContent = required ? '(obligatoire pour une dépense)' : '(facultatif)'; } +/** Adhérent et mois n'ont de sens que pour une cotisation : ils s'affichent (et partent) seulement dans ce cas. */ +function syncDues(form) { + const dues = form.querySelector('[data-dues-fields]'); + const show = form.elements.category.value === 'cotisations'; + dues.hidden = !show; + if (!show) { + form.elements.memberUserId.value = ''; + } +} + function openEntryForm(section) { const form = section.querySelector('[data-entry-form]'); form.hidden = false; @@ -58,6 +69,7 @@ function applyPreset(button) { form.elements.direction.value = preset.direction; syncCategories(form); form.elements.category.value = preset.category; + syncDues(form); form.elements.paymentMethod.value = preset.paymentMethod; form.elements.label.value = preset.label; if (preset.amount !== '') { @@ -171,6 +183,8 @@ export function initAdminAccounting(root = document) { root.addEventListener('change', (event) => { if (event.target.matches('[data-entry-form] [name="direction"]')) { syncCategories(event.target.closest('[data-entry-form]')); + } else if (event.target.matches('[data-entry-form] [name="category"]')) { + syncDues(event.target.closest('[data-entry-form]')); } }); } diff --git a/public/assets/js/admin/accounting/admin-cotisations.js b/public/assets/js/admin/accounting/admin-cotisations.js new file mode 100644 index 0000000..05e62af --- /dev/null +++ b/public/assets/js/admin/accounting/admin-cotisations.js @@ -0,0 +1,82 @@ +/** + * Page admin « Cotisations » (#362) : pointer la cotisation d'un adhérent pour un mois (écriture de 20 € préremplie, enregistrée par l'API des + * écritures) et poser ou retirer une dispense. Appelle l'API (apiFetch, CSRF) et recharge la page au succès ; aucun envoi de formulaire natif. + */ +import { apiFetch } from '../../core/api.js'; +import { showToast } from '../../core/toast.js'; +import { clearErrors, reportFailure } from '../form-feedback.js'; +import { todayIso } from './accounting-form.js'; +import { exemptionRequest, failureMessage, recordEntryRequest } from './accounting-requests.js'; + +const FIELDS = ['occurredOn', 'amount', 'paymentMethod', 'memberUserId', 'periodMonth']; + +function openForm(section, button) { + const form = section.querySelector('[data-dues-form]'); + clearErrors(form, FIELDS); + form.elements.memberUserId.value = button.dataset.memberId; + form.elements.periodMonth.value = button.dataset.month; + form.elements.occurredOn.value = todayIso(); + form.elements.amount.value = form.dataset.defaultAmount; + form.querySelector('[data-dues-title]').textContent = `Cotisation de ${button.dataset.memberName} pour ${button.dataset.monthLabel}`; + form.hidden = false; + form.scrollIntoView({ block: 'center' }); + form.elements.amount.focus(); +} + +async function onClick(event) { + const target = event.target.closest('[data-action]'); + const section = target?.closest('[data-cotisations]'); + if (section === null || section === undefined) { + return; + } + if (target.dataset.action === 'pay-dues') { + openForm(section, target); + } else if (target.dataset.action === 'cancel-dues') { + section.querySelector('[data-dues-form]').hidden = true; + } else if (target.dataset.action === 'exempt-dues') { + target.disabled = true; + try { + const { path, options } = exemptionRequest(target.dataset.memberId, target.dataset.month, target.dataset.exempt === '1'); + const done = await apiFetch(path, options); + window.location.assign(done.url); + } catch (error) { + target.disabled = false; + showToast(error?.fields?.periodMonth ?? failureMessage(error), 'error'); + } + } +} + +async function onSubmit(event) { + const form = event.target; + if (!form.matches('[data-dues-form]')) { + return; + } + event.preventDefault(); + clearErrors(form, FIELDS); + const button = form.querySelector('[type="submit"]'); + button.disabled = true; + try { + const values = { + occurredOn: form.elements.occurredOn.value, + amount: form.elements.amount.value, + paymentMethod: form.elements.paymentMethod.value, + memberUserId: form.elements.memberUserId.value, + periodMonth: form.elements.periodMonth.value, + direction: 'recette', + category: 'cotisations', + label: 'Cotisation', + }; + const { path, options } = recordEntryRequest(form.dataset.yearId, values); + const done = await apiFetch(path, options); + showToast(`Écriture n° ${done.number} enregistrée.`, 'success'); + window.location.reload(); + } catch (error) { + button.disabled = false; + reportFailure(form, FIELDS, error, failureMessage); + } +} + +export function initAdminCotisations(root = document) { + root.addEventListener('click', onClick); + root.addEventListener('submit', onSubmit); +} diff --git a/public/assets/js/app.js b/public/assets/js/app.js index 4f28307..553bf6e 100644 --- a/public/assets/js/app.js +++ b/public/assets/js/app.js @@ -13,6 +13,7 @@ import { initAdminUsers } from './account/admin-users.js'; 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 { initAdminBoard } from './admin/board/admin-board.js'; import { initAdminTexts } from './admin/texts/admin-texts.js'; import { initAdminAssemblies } from './admin/assembly/admin-assemblies.js'; @@ -46,6 +47,7 @@ document.addEventListener('DOMContentLoaded', () => { initAdminAccounting(); initAdminStatements(); initAdminRecurring(); + initAdminCotisations(); initAdminBoard(); initAdminTexts(); initBookingsBadge(); diff --git a/src/Accounting/Controller/AccountingPageController.php b/src/Accounting/Controller/AccountingPageController.php index cf36d3d..be6282e 100644 --- a/src/Accounting/Controller/AccountingPageController.php +++ b/src/Accounting/Controller/AccountingPageController.php @@ -6,6 +6,7 @@ use App\Account\Entity\UserRole; use App\Accounting\Presenter\AccountingJournalView; +use App\Accounting\Service\Dues\CotisationService; use App\Accounting\Service\EntryService; use App\Accounting\Service\FiscalYearService; use App\Http\Request; @@ -29,6 +30,7 @@ public function __construct( private readonly FiscalYearService $years, private readonly EntryService $entries, private readonly AccountingJournalView $view, + private readonly CotisationService $dues, ) { } @@ -48,6 +50,7 @@ public function index(Request $request): Response $year === null ? [] : $this->entries->list($admin, $year->id), is_string($request->query('direction')) ? $request->query('direction') : '', is_string($request->query('category')) ? $request->query('category') : '', + $this->dues->memberNames($admin), ), 'currentUserRole' => $admin->role(), ])); diff --git a/src/Accounting/Controller/Api/AccountingApiController.php b/src/Accounting/Controller/Api/AccountingApiController.php index 84c349c..1c36f96 100644 --- a/src/Accounting/Controller/Api/AccountingApiController.php +++ b/src/Accounting/Controller/Api/AccountingApiController.php @@ -5,6 +5,7 @@ namespace App\Accounting\Controller\Api; use App\Account\Entity\UserRole; +use App\Accounting\Service\Dues\CotisationService; use App\Accounting\Service\EntryService; use App\Accounting\Service\FiscalYearService; use App\Http\JsonResponse; @@ -23,6 +24,7 @@ final class AccountingApiController public function __construct( private readonly FiscalYearService $years, private readonly EntryService $entries, + private readonly CotisationService $dues, private readonly AuthGuard $authGuard, ) { } @@ -69,4 +71,13 @@ public function approveYear(Request $request, string $id): JsonResponse return new JsonResponse(['id' => $yearId, 'url' => '/admin/accounting?year=' . $yearId]); } + + /** Pose ou retire la dispense de cotisation d'un adhérent pour un mois (le corps ne contient que l'adhérent, le mois et l'état). */ + public function setExemption(Request $request): JsonResponse + { + $admin = $this->authGuard->requireRole(UserRole::Admin); + $this->dues->setExemption($admin, $request->allBody()); + + return new JsonResponse(['url' => '/admin/accounting/cotisations']); + } } diff --git a/src/Accounting/Controller/CotisationPageController.php b/src/Accounting/Controller/CotisationPageController.php new file mode 100644 index 0000000..735b694 --- /dev/null +++ b/src/Accounting/Controller/CotisationPageController.php @@ -0,0 +1,43 @@ +authGuard->requireRole(UserRole::Admin); + $years = $this->years->list($admin); + $year = YearChooser::choose($years, StrictId::from($request->query('year'))); + + return new Response($this->renderer->render('admin/accounting/cotisations', [ + 'csrfToken' => $this->csrfTokenManager->getToken(), + 'page' => $this->view->page($years, $year, $year === null ? null : $this->dues->table($admin, $year, new \DateTimeImmutable('today'))), + 'currentUserRole' => $admin->role(), + ])); + } +} diff --git a/src/Accounting/Entity/AccountingEntry.php b/src/Accounting/Entity/AccountingEntry.php index e77d4e4..486c852 100644 --- a/src/Accounting/Entity/AccountingEntry.php +++ b/src/Accounting/Entity/AccountingEntry.php @@ -20,6 +20,8 @@ public function __construct( public readonly ?string $voucherRef, public readonly ?string $note, public readonly ?int $reversesEntryId, + public readonly ?int $memberUserId = null, + public readonly ?\DateTimeImmutable $periodMonth = null, ) { } } diff --git a/src/Accounting/Entity/Member.php b/src/Accounting/Entity/Member.php new file mode 100644 index 0000000..ee9e67d --- /dev/null +++ b/src/Accounting/Entity/Member.php @@ -0,0 +1,15 @@ + $years du plus récent au plus ancien * @param list $entries de l'exercice choisi, par numéro + * @param array $memberNames noms des comptes actifs par identifiant (adhérents d'une cotisation) * @return array */ - public function page(array $years, ?FiscalYear $year, ?LedgerTotals $totals, array $entries, string $direction, string $category): array + public function page(array $years, ?FiscalYear $year, ?LedgerTotals $totals, array $entries, string $direction, string $category, array $memberNames = []): array { $directionFilter = Direction::tryFrom($direction); $categoryFilter = EntryCategory::tryFrom($category); @@ -41,7 +42,7 @@ public function page(array $years, ?FiscalYear $year, ?LedgerTotals $totals, arr 'url' => '/admin/accounting?year=' . $y->id, ], $years), 'year' => $year === null || $totals === null ? null : $this->summary($year, $totals), - 'entries' => $year === null ? [] : $this->rows($year, $entries, $directionFilter, $categoryFilter), + 'entries' => $year === null ? [] : $this->rows($year, $entries, $directionFilter, $categoryFilter, $memberNames), 'filters' => [ 'direction' => $directionFilter === null ? '' : $directionFilter->value, 'category' => $categoryFilter === null ? '' : $categoryFilter->value, @@ -49,7 +50,7 @@ public function page(array $years, ?FiscalYear $year, ?LedgerTotals $totals, arr 'directions' => $this->directions(), 'categories' => $this->categories(), ], - 'form' => ['directions' => $this->directions(), 'categories' => $this->categories(), 'methods' => $this->methods()], + 'form' => ['directions' => $this->directions(), 'categories' => $this->categories(), 'methods' => $this->methods(), 'members' => array_map(static fn (int $id, string $name): array => ['value' => (string) $id, 'label' => $name], array_keys($memberNames), array_values($memberNames)), 'month' => date('Y-m')], 'next' => $this->next($years[0] ?? null), ]; } @@ -80,9 +81,10 @@ private function summary(FiscalYear $year, LedgerTotals $totals): array /** * @param list $entries + * @param array $memberNames * @return list> */ - private function rows(FiscalYear $year, array $entries, ?Direction $direction, ?EntryCategory $category): array + private function rows(FiscalYear $year, array $entries, ?Direction $direction, ?EntryCategory $category, array $memberNames): array { $reversed = []; foreach ($entries as $entry) { @@ -109,6 +111,7 @@ private function rows(FiscalYear $year, array $entries, ?Direction $direction, ? 'method' => $entry->paymentMethod->label(), 'voucher' => $entry->voucherRef ?? '', 'note' => $entry->note ?? '', + 'dues' => $entry->periodMonth === null ? '' : 'Cotisation de ' . ($memberNames[$entry->memberUserId ?? 0] ?? 'un ancien membre') . ' pour ' . MonthLabel::of($entry->periodMonth), 'isReversal' => $isReversal, 'isReversed' => $isReversed, 'canReverse' => $year->isOpen() && !$isReversal && !$isReversed, diff --git a/src/Accounting/Presenter/CotisationView.php b/src/Accounting/Presenter/CotisationView.php new file mode 100644 index 0000000..938880c --- /dev/null +++ b/src/Accounting/Presenter/CotisationView.php @@ -0,0 +1,50 @@ + 'Payée', + 'dispensee' => 'Dispensée', + 'a_payer' => 'À payer', + 'a_venir' => '', + ]; + + /** + * @param list $years du plus récent au plus ancien + * @return array + */ + public function page(array $years, ?FiscalYear $year, ?CotisationTable $table): array + { + return [ + 'years' => array_map(static fn (FiscalYear $y): array => [ + 'label' => $y->label, + 'selected' => $year !== null && $y->id === $year->id, + 'url' => '/admin/accounting/cotisations?year=' . $y->id, + ], $years), + 'year' => $year === null ? null : ['id' => $year->id, 'label' => $year->label, 'isOpen' => $year->isOpen()], + 'defaultAmount' => self::DEFAULT_AMOUNT, + 'dueCount' => $table?->dueCount() ?? 0, + 'months' => $table === null ? [] : array_map(static fn (\DateTimeImmutable $m): array => ['key' => $m->format('Y-m'), 'short' => MonthLabel::short($m), 'long' => MonthLabel::of($m)], $table->months), + 'rows' => $table === null ? [] : array_map(static function (array $row): array { + $cells = []; + foreach ($row['cells'] as $key => $cell) { + $cells[] = ['key' => $key, 'state' => $cell->value, 'label' => self::LABELS[$cell->value], 'isDue' => $cell === CotisationCell::APayer, 'isExempt' => $cell === CotisationCell::Dispensee]; + } + + return ['id' => $row['member']->id, 'name' => $row['member']->displayName, 'cells' => $cells]; + }, $table->rows), + ]; + } +} diff --git a/src/Accounting/Presenter/MonthLabel.php b/src/Accounting/Presenter/MonthLabel.php new file mode 100644 index 0000000..bab835b --- /dev/null +++ b/src/Accounting/Presenter/MonthLabel.php @@ -0,0 +1,22 @@ +format('n') - 1] . ' ' . $month->format('Y'); + } + + /** Forme courte pour l'en-tête d'un tableau : « nov. 25 ». */ + public static function short(\DateTimeImmutable $month): string + { + return mb_substr(self::NAMES[(int) $month->format('n') - 1], 0, 4) . '. ' . $month->format('y'); + } +} diff --git a/src/Accounting/Repository/EntryRepositoryInterface.php b/src/Accounting/Repository/EntryRepositoryInterface.php index e806ad6..c42ee40 100644 --- a/src/Accounting/Repository/EntryRepositoryInterface.php +++ b/src/Accounting/Repository/EntryRepositoryInterface.php @@ -29,5 +29,15 @@ public function listByYear(int $fiscalYearId, int $limit): array; 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. */ + /** Une cotisation non annulée existe-t-elle déjà pour cet adhérent et ce mois (premier jour) ? */ + public function memberMonthPaid(int $memberUserId, \DateTimeImmutable $periodMonth): bool; + + /** + * Cotisations non annulées dont le mois couvert est dans l'intervalle. + * + * @return array clés `idAdhérent|AAAA-MM-01` + */ + public function paidMonths(\DateTimeImmutable $from, \DateTimeImmutable $to): array; + public function bankNetUntil(int $fiscalYearId, \DateTimeImmutable $date): int; } diff --git a/src/Accounting/Repository/MemberDuesRepositoryInterface.php b/src/Accounting/Repository/MemberDuesRepositoryInterface.php new file mode 100644 index 0000000..7919942 --- /dev/null +++ b/src/Accounting/Repository/MemberDuesRepositoryInterface.php @@ -0,0 +1,24 @@ + par nom, au plus `$limit` */ + public function listActive(int $limit): array; + + /** Pose (`$exempt` vrai) ou retire la dispense d'un adhérent pour un mois (premier jour) ; sans effet si l'état est déjà celui demandé. */ + public function setExemption(int $userId, \DateTimeImmutable $month, bool $exempt, int $actorId, \DateTimeImmutable $now): void; + + /** + * @return array clés `idAdhérent|AAAA-MM-01` des mois dispensés dans l'intervalle + */ + public function exemptions(\DateTimeImmutable $from, \DateTimeImmutable $to): array; +} diff --git a/src/Accounting/Repository/MysqlEntryRepository.php b/src/Accounting/Repository/MysqlEntryRepository.php index 0f418ed..5aa56ef 100644 --- a/src/Accounting/Repository/MysqlEntryRepository.php +++ b/src/Accounting/Repository/MysqlEntryRepository.php @@ -26,8 +26,8 @@ public function insert(int $fiscalYearId, int $number, EntryDraft $draft, ?int $ $this->pdo->prepare( 'INSERT INTO accounting_entries (fiscal_year_id, entry_number, occurred_on, label, amount_cents, direction, category, payment_method, voucher_ref, note, - reverses_entry_id, created_by_user_id, created_at) - VALUES (:year, :number, :occurred_on, :label, :amount, :direction, :category, :method, :voucher, :note, :reverses, :actor, :now)' + reverses_entry_id, member_user_id, period_month, created_by_user_id, created_at) + VALUES (:year, :number, :occurred_on, :label, :amount, :direction, :category, :method, :voucher, :note, :reverses, :member, :month, :actor, :now)' )->execute([ 'year' => $fiscalYearId, 'number' => $number, @@ -40,6 +40,8 @@ public function insert(int $fiscalYearId, int $number, EntryDraft $draft, ?int $ 'voucher' => $draft->voucherRef, 'note' => $draft->note, 'reverses' => $reversesEntryId, + 'member' => $draft->memberUserId, + 'month' => $draft->periodMonth?->format('Y-m-d'), 'actor' => $actorId, 'now' => $now->format('Y-m-d H:i:s'), ]); @@ -100,6 +102,34 @@ public function totals(int $fiscalYearId): LedgerTotals return new LedgerTotals($recettes, $depenses, $net); } + public function memberMonthPaid(int $memberUserId, \DateTimeImmutable $periodMonth): bool + { + $statement = $this->pdo->prepare( + "SELECT 1 FROM accounting_entries e + WHERE e.member_user_id = :member AND e.period_month = :month AND e.category = 'cotisations' AND e.reverses_entry_id IS NULL + AND NOT EXISTS (SELECT 1 FROM accounting_entries r WHERE r.reverses_entry_id = e.id) LIMIT 1" + ); + $statement->execute(['member' => $memberUserId, 'month' => $periodMonth->format('Y-m-d')]); + + return $statement->fetchColumn() !== false; + } + + public function paidMonths(\DateTimeImmutable $from, \DateTimeImmutable $to): array + { + $statement = $this->pdo->prepare( + "SELECT e.member_user_id, e.period_month FROM accounting_entries e + WHERE e.member_user_id IS NOT NULL AND e.period_month BETWEEN :from AND :to AND e.category = 'cotisations' AND e.reverses_entry_id IS NULL + AND NOT EXISTS (SELECT 1 FROM accounting_entries r WHERE r.reverses_entry_id = e.id) LIMIT 20000" + ); + $statement->execute(['from' => $from->format('Y-m-d'), 'to' => $to->format('Y-m-d')]); + $paid = []; + foreach ($statement->fetchAll(\PDO::FETCH_ASSOC) as $row) { + $paid[$row['member_user_id'] . '|' . $row['period_month']] = true; + } + + return $paid; + } + public function bankNetUntil(int $fiscalYearId, \DateTimeImmutable $date): int { $statement = $this->pdo->prepare( @@ -127,6 +157,8 @@ private function hydrate(array $row): AccountingEntry $row['voucher_ref'] === null ? null : (string) $row['voucher_ref'], $row['note'] === null ? null : (string) $row['note'], $row['reverses_entry_id'] === null ? null : (int) $row['reverses_entry_id'], + $row['member_user_id'] === null ? null : (int) $row['member_user_id'], + $row['period_month'] === null ? null : new \DateTimeImmutable((string) $row['period_month']), ); } } diff --git a/src/Accounting/Repository/MysqlMemberDuesRepository.php b/src/Accounting/Repository/MysqlMemberDuesRepository.php new file mode 100644 index 0000000..2f6a021 --- /dev/null +++ b/src/Accounting/Repository/MysqlMemberDuesRepository.php @@ -0,0 +1,56 @@ +pdo->prepare('SELECT 1 FROM users WHERE id = :id AND is_active = 1'); + $statement->execute(['id' => $userId]); + + return $statement->fetchColumn() !== false; + } + + public function listActive(int $limit): array + { + $statement = $this->pdo->query('SELECT id, display_name FROM users WHERE is_active = 1 ORDER BY display_name, id LIMIT ' . max(1, $limit)); + + return $statement === false ? [] : array_map( + static fn (array $row): Member => new Member((int) $row['id'], (string) $row['display_name']), + $statement->fetchAll(\PDO::FETCH_ASSOC), + ); + } + + public function setExemption(int $userId, \DateTimeImmutable $month, bool $exempt, int $actorId, \DateTimeImmutable $now): void + { + if ($exempt) { + $this->pdo->prepare('INSERT IGNORE INTO accounting_member_exemptions (member_user_id, period_month, created_by_user_id, created_at) VALUES (:member, :month, :actor, :now)') + ->execute(['member' => $userId, 'month' => $month->format('Y-m-d'), 'actor' => $actorId, 'now' => $now->format('Y-m-d H:i:s')]); + + return; + } + $this->pdo->prepare('DELETE FROM accounting_member_exemptions WHERE member_user_id = :member AND period_month = :month') + ->execute(['member' => $userId, 'month' => $month->format('Y-m-d')]); + } + + public function exemptions(\DateTimeImmutable $from, \DateTimeImmutable $to): array + { + $statement = $this->pdo->prepare('SELECT member_user_id, period_month FROM accounting_member_exemptions WHERE period_month BETWEEN :from AND :to LIMIT 20000'); + $statement->execute(['from' => $from->format('Y-m-d'), 'to' => $to->format('Y-m-d')]); + $found = []; + foreach ($statement->fetchAll(\PDO::FETCH_ASSOC) as $row) { + $found[$row['member_user_id'] . '|' . $row['period_month']] = true; + } + + return $found; + } +} diff --git a/src/Accounting/Service/Dues/CotisationCell.php b/src/Accounting/Service/Dues/CotisationCell.php new file mode 100644 index 0000000..175522c --- /dev/null +++ b/src/Accounting/Service/Dues/CotisationCell.php @@ -0,0 +1,14 @@ +access->assertAdmin($actor); + $months = self::monthsOf($year); + $paid = $this->entries->paidMonths($months[0], $months[count($months) - 1]); + $exempt = $this->members->exemptions($months[0], $months[count($months) - 1]); + $reached = $today->modify('first day of this month')->setTime(0, 0); + $rows = []; + foreach ($this->members->listActive(self::MEMBER_LIMIT) as $member) { + $cells = []; + foreach ($months as $month) { + $key = $member->id . '|' . $month->format('Y-m-d'); + $cells[$month->format('Y-m')] = match (true) { + isset($paid[$key]) => CotisationCell::Payee, + isset($exempt[$key]) => CotisationCell::Dispensee, + $month <= $reached => CotisationCell::APayer, + default => CotisationCell::AVenir, + }; + } + $rows[] = ['member' => $member, 'cells' => $cells]; + } + + return new CotisationTable($months, $rows); + } + + /** @return array nom d'affichage des comptes actifs par identifiant (adhérents proposés à la saisie d'une cotisation) */ + public function memberNames(User $actor): array + { + $this->access->assertAdmin($actor); + $names = []; + foreach ($this->members->listActive(self::MEMBER_LIMIT) as $member) { + $names[$member->id] = $member->displayName; + } + + return $names; + } + + /** + * @param array $input `memberUserId`, `periodMonth` (AAAA-MM), `exempt` (vrai pose la dispense, faux la retire) + * @throws AccountingValidationException compte inconnu, mois invalide, mois déjà payé + */ + public function setExemption(User $actor, array $input, ?\DateTimeImmutable $now = null): void + { + $this->access->assertAdmin($actor); + $errors = []; + $memberId = StrictId::from($input['memberUserId'] ?? null); + $raw = $input['periodMonth'] ?? null; + $month = is_string($raw) && preg_match('/\A[0-9]{4}-(?:0[1-9]|1[0-2])\z/D', $raw) === 1 && $raw >= '1900-01' && $raw <= '2999-12' ? new \DateTimeImmutable($raw . '-01') : null; + $exempt = $input['exempt'] ?? null; + if ($memberId === null || !$this->members->isActiveMember($memberId)) { + $errors['memberUserId'] = 'Ce compte est introuvable ou désactivé.'; + } + if ($month === null) { + $errors['periodMonth'] = 'Choisissez le mois, par exemple 2025-10.'; + } + if (!is_bool($exempt) && $exempt !== '1' && $exempt !== '0') { + $errors['exempt'] = 'Indiquez si la dispense est posée ou retirée.'; + } + if ($errors === [] && $memberId !== null && $month !== null && ($exempt === true || $exempt === '1') && $this->entries->memberMonthPaid($memberId, $month)) { + $errors['periodMonth'] = 'La cotisation de ce mois est déjà payée : elle ne peut pas être dispensée.'; + } + if ($errors !== [] || $memberId === null || $month === null) { + throw new AccountingValidationException($errors); + } + $this->members->setExemption($memberId, $month, $exempt === true || $exempt === '1', $actor->id(), $now ?? new \DateTimeImmutable()); + $this->logger->info('Comptabilité : dispense de cotisation modifiée', ['actor' => $actor->id(), 'member' => $memberId]); + } + + /** @return non-empty-list<\DateTimeImmutable> */ + private static function monthsOf(FiscalYear $year): array + { + $months = []; + $month = $year->startsOn->modify('first day of this month')->setTime(0, 0); + while ($month <= $year->endsOn && count($months) < self::MAX_MONTHS) { + $months[] = $month; + $month = $month->modify('+1 month'); + } + + return $months; + } +} diff --git a/src/Accounting/Service/Dues/CotisationTable.php b/src/Accounting/Service/Dues/CotisationTable.php new file mode 100644 index 0000000..3deea4a --- /dev/null +++ b/src/Accounting/Service/Dues/CotisationTable.php @@ -0,0 +1,33 @@ + $months premiers jours des mois de l'exercice + * @param list}> $rows cellules indexées par `AAAA-MM` + */ + public function __construct( + public readonly array $months, + public readonly array $rows, + ) { + } + + public function dueCount(): int + { + $count = 0; + foreach ($this->rows as $row) { + foreach ($row['cells'] as $cell) { + $count += $cell === CotisationCell::APayer ? 1 : 0; + } + } + + return $count; + } +} diff --git a/src/Accounting/Service/EntryDraft.php b/src/Accounting/Service/EntryDraft.php index 77ba323..67090f5 100644 --- a/src/Accounting/Service/EntryDraft.php +++ b/src/Accounting/Service/EntryDraft.php @@ -20,6 +20,8 @@ public function __construct( public readonly PaymentMethod $paymentMethod, public readonly ?string $voucherRef, public readonly ?string $note, + public readonly ?int $memberUserId = null, + public readonly ?\DateTimeImmutable $periodMonth = null, ) { } } diff --git a/src/Accounting/Service/EntryInputPolicy.php b/src/Accounting/Service/EntryInputPolicy.php index bb67815..bb9cdb6 100644 --- a/src/Accounting/Service/EntryInputPolicy.php +++ b/src/Accounting/Service/EntryInputPolicy.php @@ -52,12 +52,49 @@ public function parse(array $input): EntryDraft $errors['voucherRef'] = 'Indiquez la pièce justificative (numéro de facture, de ticket…).'; } $note = self::line($input['note'] ?? null, 'note', self::NOTE_MAX, false, '', $errors); + [$member, $month] = self::dues($input, $category, $errors); if ($errors !== [] || $date === null || $label === null || $cents === null || $direction === null || $category === null || $method === null) { throw new AccountingValidationException($errors); } - return new EntryDraft($date, $label, $cents, $direction, $category, $method, $voucher, $note); + return new EntryDraft($date, $label, $cents, $direction, $category, $method, $voucher, $note, $member, $month); + } + + /** + * Adhérent et mois couverts d'une cotisation (#362) : réservés à la catégorie « cotisations », fournis ensemble, identifiant strictement + * numérique, mois au format AAAA-MM. L'existence du compte est jugée par le service. + * + * @param array $input + * @param array $errors + * @return array{0: int|null, 1: \DateTimeImmutable|null} + */ + private static function dues(array $input, ?EntryCategory $category, array &$errors): array + { + $rawMember = $input['memberUserId'] ?? null; + $rawMonth = $input['periodMonth'] ?? null; + $rawMember = $rawMember === '' ? null : $rawMember; + $rawMonth = $rawMonth === '' ? null : $rawMonth; + if ($rawMember === null && $rawMonth === null) { + return [null, null]; + } + if ($category !== EntryCategory::Cotisations) { + $errors['memberUserId'] = 'L\'adhérent et le mois ne concernent que les cotisations.'; + + return [null, null]; + } + $member = \App\Support\StrictId::from($rawMember); + if ($member === null) { + $errors['memberUserId'] = 'Choisissez l\'adhérent.'; + } + $month = null; + if (is_string($rawMonth) && preg_match('/\A[0-9]{4}-(?:0[1-9]|1[0-2])\z/D', $rawMonth) === 1 && $rawMonth >= '1900-01' && $rawMonth <= '2999-12') { + $month = new \DateTimeImmutable($rawMonth . '-01'); + } else { + $errors['periodMonth'] = 'Choisissez le mois couvert, par exemple 2025-10.'; + } + + return [$member, $month]; } /** diff --git a/src/Accounting/Service/EntryService.php b/src/Accounting/Service/EntryService.php index 95ef596..4b5360f 100644 --- a/src/Accounting/Service/EntryService.php +++ b/src/Accounting/Service/EntryService.php @@ -12,6 +12,7 @@ use App\Accounting\Exception\FiscalYearNotFoundException; use App\Accounting\Repository\EntryRepositoryInterface; use App\Accounting\Repository\FiscalYearRepositoryInterface; +use App\Accounting\Repository\MemberDuesRepositoryInterface; use App\Database\TransactionRunner; use App\Security\AdminAccess; use Psr\Log\LoggerInterface; @@ -32,6 +33,7 @@ public function __construct( private readonly EntryInputPolicy $policy, private readonly FiscalYearRepositoryInterface $years, private readonly EntryRepositoryInterface $entries, + private readonly MemberDuesRepositoryInterface $members, private readonly TransactionRunner $transactions, private readonly LoggerInterface $logger, ) { @@ -59,6 +61,7 @@ public function record(User $actor, int $fiscalYearId, array $input, ?\DateTimeI throw new AccountingValidationException(['occurredOn' => 'La date doit être comprise dans l\'exercice (du ' . $year->startsOn->format('d/m/Y') . ' au ' . $year->endsOn->format('d/m/Y') . ').']); } + $this->assertDuesAreFree($draft); $entry = $this->write($year->id, $year->nextEntryNumber, $draft, null, $actor, $now); if ($inTransaction !== null) { $inTransaction($entry); @@ -126,6 +129,25 @@ public function list(User $actor, int $fiscalYearId): array return $this->entries->listByYear($fiscalYearId, self::LIST_LIMIT); } + /** + * Cotisation d'un adhérent (#362) : le compte doit être actif et le mois ne doit pas être déjà payé (hors écriture annulée). Jugé sous le + * verrou de l'exercice, dans la transaction de l'écriture. + * + * @throws AccountingValidationException + */ + private function assertDuesAreFree(EntryDraft $draft): void + { + if ($draft->memberUserId === null || $draft->periodMonth === null) { + return; + } + if (!$this->members->isActiveMember($draft->memberUserId)) { + throw new AccountingValidationException(['memberUserId' => 'Ce compte est introuvable ou désactivé.']); + } + if ($this->entries->memberMonthPaid($draft->memberUserId, $draft->periodMonth)) { + throw new AccountingValidationException(['periodMonth' => 'La cotisation de cet adhérent est déjà enregistrée pour ce mois.']); + } + } + /** À appeler sous le verrou de l'exercice : insère, prend le numéro suivant, relit l'écriture. */ private function write(int $yearId, int $number, EntryDraft $draft, ?int $reverses, User $actor, \DateTimeImmutable $now): AccountingEntry { diff --git a/templates/admin/accounting/_entry-card.php b/templates/admin/accounting/_entry-card.php index 322b608..0e14eea 100644 --- a/templates/admin/accounting/_entry-card.php +++ b/templates/admin/accounting/_entry-card.php @@ -18,6 +18,7 @@ Annulation Annulée

+

Pièce :

diff --git a/templates/admin/accounting/_entry-form.php b/templates/admin/accounting/_entry-form.php index d49b853..920fa05 100644 --- a/templates/admin/accounting/_entry-form.php +++ b/templates/admin/accounting/_entry-form.php @@ -39,6 +39,23 @@ +
diff --git a/templates/admin/accounting/cotisations.php b/templates/admin/accounting/cotisations.php new file mode 100644 index 0000000..8aaf4f6 --- /dev/null +++ b/templates/admin/accounting/cotisations.php @@ -0,0 +1,121 @@ + $page voir CotisationView::page + * @var \App\Account\Entity\UserRole $currentUserRole + */ +$year = $page['year']; +$adminTab = 'accounting'; +?> + + + + + + + Admin — Cotisations — RehearsalBox + + + + + +
+ + +

Cotisations

+

Une cotisation par adhérent et par mois. Le tableau se lit dans le livre : une cotisation enregistrée pour un mois le marque « Payée ».

+ +
+ +

Aucun exercice pour l'instant : créez-le d'abord dans la comptabilité.

+ + 1): ?> + + +

+

1 ? 's' : '') . ' à pointer.' ?>

+ + + + + + +

Aucun adhérent actif.

+ +
+ + + + + + + + + + + + + $cell): ?> + + + + + +
Cotisations par adhérent et par mois, exercice
Adhérent
+ + + + + + + + +
+
+ + +
+
+ + + + + diff --git a/tests/Accounting/Http/AccountingDuesHttpTest.php b/tests/Accounting/Http/AccountingDuesHttpTest.php new file mode 100644 index 0000000..5bd048f --- /dev/null +++ b/tests/Accounting/Http/AccountingDuesHttpTest.php @@ -0,0 +1,121 @@ +makeUser('admin', UserRole::Admin); + $this->makeUser('musicien', UserRole::Musicien); + $this->member = $this->makeUser('adherent', UserRole::Musicien)->id(); + } + + private function year(): int + { + $start = date('Y-m-01', strtotime('-2 months')); + [, $body] = $this->call('admin', 'POST', '/api/admin/accounting/years', ['label' => 'Courant', 'startsOn' => $start, 'endsOn' => date('Y-m-d', strtotime($start . ' +12 months -1 day')), 'openingBalance' => '0']); + + return (int) $this->json($body)['id']; + } + + /** @return array */ + private function dues(array $override = []): array + { + return $override + ['occurredOn' => date('Y-m-d'), 'label' => 'Cotisation', 'amount' => '20,00', 'direction' => 'recette', 'category' => 'cotisations', 'paymentMethod' => 'virement', 'memberUserId' => (string) $this->member, 'periodMonth' => date('Y-m')]; + } + + #[Test] + public function testRecordingADuesEntryThenTheSameMonthAgainIs422(): void + { + $year = $this->year(); + + [$status, $body] = $this->call('admin', 'POST', "/api/admin/accounting/years/{$year}/entries", $this->dues()); + self::assertSame(201, $status, $body); + [$status, $body] = $this->call('admin', 'POST', "/api/admin/accounting/years/{$year}/entries", $this->dues()); + + self::assertSame(422, $status); + self::assertArrayHasKey('periodMonth', $this->json($body)['fields']); + self::assertSame(1, (int) $this->pdo->query('SELECT COUNT(*) FROM accounting_entries')->fetchColumn()); + } + + #[Test] + public function testAForgedMemberOrAMemberOnAnotherCategoryIsRefused(): void + { + $year = $this->year(); + + foreach ([['memberUserId' => '99999999'], ['memberUserId' => 'Jean Dupont'], ['category' => 'subventions']] as $override) { + [$status, $body] = $this->call('admin', 'POST', "/api/admin/accounting/years/{$year}/entries", $this->dues($override)); + self::assertSame(422, $status, json_encode($override)); + self::assertArrayHasKey('memberUserId', $this->json($body)['fields']); + } + self::assertSame(0, (int) $this->pdo->query('SELECT COUNT(*) FROM accounting_entries')->fetchColumn()); + } + + #[Test] + public function testExemptionsAreForAdministratorsWithCsrfAndAreIdempotent(): void + { + $body = ['memberUserId' => (string) $this->member, 'periodMonth' => date('Y-m'), 'exempt' => true]; + + self::assertSame(401, $this->call('anon', 'POST', '/api/admin/accounting/cotisations/exemptions', $body)[0]); + self::assertSame(403, $this->call('musicien', 'POST', '/api/admin/accounting/cotisations/exemptions', $body)[0]); + self::assertSame(403, $this->call('admin', 'POST', '/api/admin/accounting/cotisations/exemptions', $body, [], false)[0]); + self::assertSame(0, (int) $this->pdo->query('SELECT COUNT(*) FROM accounting_member_exemptions')->fetchColumn()); + + self::assertSame(200, $this->call('admin', 'POST', '/api/admin/accounting/cotisations/exemptions', $body)[0]); + self::assertSame(200, $this->call('admin', 'POST', '/api/admin/accounting/cotisations/exemptions', $body)[0]); + self::assertSame(1, (int) $this->pdo->query('SELECT COUNT(*) FROM accounting_member_exemptions')->fetchColumn()); + self::assertSame(200, $this->call('admin', 'POST', '/api/admin/accounting/cotisations/exemptions', ['exempt' => false] + $body)[0]); + self::assertSame(0, (int) $this->pdo->query('SELECT COUNT(*) FROM accounting_member_exemptions')->fetchColumn()); + } + + #[Test] + public function testInvalidExemptionsAre422WithTheFieldName(): void + { + [$status, $response] = $this->call('admin', 'POST', '/api/admin/accounting/cotisations/exemptions', ['memberUserId' => 'x', 'periodMonth' => '2025-13', 'exempt' => 'peut-être']); + + self::assertSame(422, $status); + self::assertEqualsCanonicalizing(['memberUserId', 'periodMonth', 'exempt'], array_keys($this->json($response)['fields'])); + } + + #[Test] + public function testThePageShowsTheTableEscapesNamesAndKeepsTheJournalReadable(): void + { + $year = $this->year(); + $this->pdo->prepare('UPDATE users SET display_name = ? WHERE id = ?')->execute(['', $this->member]); + $this->call('admin', 'POST', "/api/admin/accounting/years/{$year}/entries", $this->dues()); + + [$status, $html] = $this->call('admin', 'GET', '/admin/accounting/cotisations', [], ['year' => (string) $year]); + self::assertSame(200, $status); + self::assertStringNotContainsString('call('admin', 'GET', '/admin/accounting', [], ['year' => (string) $year]); + self::assertStringContainsString('Cotisation de <img', $journal); + self::assertStringNotContainsString('call('anon', 'GET', '/admin/accounting/cotisations')[0]); + self::assertSame(403, $this->call('musicien', 'GET', '/admin/accounting/cotisations')[0]); + self::assertSame(200, $this->call('admin', 'GET', '/admin/accounting/cotisations')[0]); + self::assertSame(200, $this->call('admin', 'GET', '/admin/accounting/cotisations', [], ['year' => '99999999'])[0], 'exercice inconnu ignoré'); + } +} diff --git a/tests/Accounting/Service/AccountingTestCase.php b/tests/Accounting/Service/AccountingTestCase.php index d0acd5a..16d31a7 100644 --- a/tests/Accounting/Service/AccountingTestCase.php +++ b/tests/Accounting/Service/AccountingTestCase.php @@ -108,7 +108,7 @@ public function log($level, string|\Stringable $message, array $context = []): v $transactions = new TransactionRunner($pdo); $amounts = new AccountingAmountParser(); $this->years = new FiscalYearService(new AdminAccess(), new FiscalYearInputPolicy($amounts), $years, $entries, $transactions, $logger); - $this->entries = new EntryService(new AdminAccess(), new EntryInputPolicy($amounts), $years, $entries, $transactions, $logger); + $this->entries = new EntryService(new AdminAccess(), new EntryInputPolicy($amounts), $years, $entries, new \App\Accounting\Repository\MysqlMemberDuesRepository($pdo), $transactions, $logger); } /** Crée un exercice du 1er octobre 2025 au 30 septembre 2026 (ouvert, solde d'ouverture 100,00 €) et rend son identifiant. */ diff --git a/tests/Accounting/Service/Dues/CotisationServiceTest.php b/tests/Accounting/Service/Dues/CotisationServiceTest.php new file mode 100644 index 0000000..5b976de --- /dev/null +++ b/tests/Accounting/Service/Dues/CotisationServiceTest.php @@ -0,0 +1,140 @@ +dues = new CotisationService(new AdminAccess(), new MysqlEntryRepository($this->pdo), new MysqlMemberDuesRepository($this->pdo), new NullLogger()); + $this->today = new \DateTimeImmutable('2025-12-15'); + } + + private function pay(int $year, int $member, string $month, string $date = '2025-11-05'): \App\Accounting\Entity\AccountingEntry + { + return $this->record($year, ['category' => 'cotisations', 'memberUserId' => (string) $member, 'periodMonth' => $month, 'occurredOn' => $date]); + } + + /** @return array */ + private function cellsOf(int $yearId, int $memberId): array + { + $table = $this->dues->table($this->admin, $this->yearRepository->find($yearId) ?? throw new \LogicException(), $this->today); + foreach ($table->rows as $row) { + if ($row['member']->id === $memberId) { + return $row['cells']; + } + } + self::fail('adhérent absent du tableau'); + } + + #[Test] + public function testTheTableShowsPaidDueAndUpcomingMonthsFromTheLedger(): void + { + $year = $this->openYear(); + $this->pay($year, $this->musician->id(), '2025-11'); + + $cells = $this->cellsOf($year, $this->musician->id()); + + self::assertSame(CotisationCell::APayer, $cells['2025-10']); + self::assertSame(CotisationCell::Payee, $cells['2025-11']); + self::assertSame(CotisationCell::APayer, $cells['2025-12'], 'le mois courant est dû'); + self::assertSame(CotisationCell::AVenir, $cells['2026-01']); + self::assertCount(12, $cells); + } + + #[Test] + public function testAReversedPaymentIsDueAgainAndOnlyActiveAccountsAreListed(): void + { + $year = $this->openYear(); + $entry = $this->pay($year, $this->musician->id(), '2025-11'); + $this->entries->reverse($this->admin, $entry->id, ['reason' => 'Erreur', 'occurredOn' => '2025-11-06'], $this->clock); + + self::assertSame(CotisationCell::APayer, $this->cellsOf($year, $this->musician->id())['2025-11']); + $table = $this->dues->table($this->admin, $this->yearRepository->find($year) ?? throw new \LogicException(), $this->today); + self::assertNotContains($this->gone->id(), array_map(static fn (array $r): int => $r['member']->id, $table->rows)); + } + + #[Test] + public function testAnExemptionCanBeSetAndRemovedButNotOnAPaidMonth(): void + { + $year = $this->openYear(); + $member = $this->musician->id(); + $this->pay($year, $member, '2025-11'); + + $this->dues->setExemption($this->admin, ['memberUserId' => (string) $member, 'periodMonth' => '2025-10', 'exempt' => true], $this->clock); + self::assertSame(CotisationCell::Dispensee, $this->cellsOf($year, $member)['2025-10']); + $this->dues->setExemption($this->admin, ['memberUserId' => (string) $member, 'periodMonth' => '2025-10', 'exempt' => true], $this->clock); + $this->dues->setExemption($this->admin, ['memberUserId' => (string) $member, 'periodMonth' => '2025-10', 'exempt' => false], $this->clock); + self::assertSame(CotisationCell::APayer, $this->cellsOf($year, $member)['2025-10']); + + try { + $this->dues->setExemption($this->admin, ['memberUserId' => (string) $member, 'periodMonth' => '2025-11', 'exempt' => true], $this->clock); + self::fail('refus attendu'); + } catch (AccountingValidationException $e) { + self::assertArrayHasKey('periodMonth', $e->fields()); + } + } + + #[Test] + public function testInvalidExemptionsAreRefusedByField(): void + { + foreach ([ + ['memberUserId' => 'x', 'periodMonth' => '2025-10', 'exempt' => true, 'field' => 'memberUserId'], + ['memberUserId' => '99999999', 'periodMonth' => '2025-10', 'exempt' => true, 'field' => 'memberUserId'], + ['memberUserId' => (string) $this->gone->id(), 'periodMonth' => '2025-10', 'exempt' => true, 'field' => 'memberUserId'], + ['memberUserId' => (string) $this->musician->id(), 'periodMonth' => '2025-10-01', 'exempt' => true, 'field' => 'periodMonth'], + ['memberUserId' => (string) $this->musician->id(), 'periodMonth' => '2025-10', 'exempt' => 'oui', 'field' => 'exempt'], + ] as $case) { + $field = $case['field']; + unset($case['field']); + try { + $this->dues->setExemption($this->admin, $case, $this->clock); + self::fail("refus attendu pour {$field}"); + } catch (AccountingValidationException $e) { + self::assertArrayHasKey($field, $e->fields()); + } + } + self::assertSame(0, (int) $this->pdo->query('SELECT COUNT(*) FROM accounting_member_exemptions')->fetchColumn()); + } + + #[Test] + public function testOnlyAdministratorsSeeOrChangeTheTable(): void + { + $year = $this->yearRepository->find($this->openYear()) ?? throw new \LogicException(); + $denied = 0; + foreach ([$this->musician, $this->gone] as $actor) { + foreach ([ + fn () => $this->dues->table($actor, $year, $this->today), + fn () => $this->dues->setExemption($actor, ['memberUserId' => '1', 'periodMonth' => '2025-10', 'exempt' => true]), + ] as $call) { + try { + $call(); + } catch (AccessDeniedException) { + ++$denied; + } + } + } + self::assertSame(4, $denied); + self::assertSame(0, (int) $this->pdo->query('SELECT COUNT(*) FROM accounting_member_exemptions')->fetchColumn()); + } +} diff --git a/tests/Accounting/Service/MemberDuesEntryTest.php b/tests/Accounting/Service/MemberDuesEntryTest.php new file mode 100644 index 0000000..dbd6902 --- /dev/null +++ b/tests/Accounting/Service/MemberDuesEntryTest.php @@ -0,0 +1,133 @@ + $override */ + private function dues(int $year, array $override = []): \App\Accounting\Entity\AccountingEntry + { + return $this->record($year, $override + ['memberUserId' => (string) $this->musician->id(), 'periodMonth' => '2025-11', 'category' => 'cotisations']); + } + + /** @param array $override */ + private function refused(int $year, array $override, string $field): void + { + try { + $this->dues($year, $override); + self::fail('refus attendu'); + } catch (AccountingValidationException $e) { + self::assertArrayHasKey($field, $e->fields()); + } + } + + #[Test] + public function testADuesEntryNamesTheMemberAndTheMonthCovered(): void + { + $entry = $this->dues($this->openYear()); + + self::assertSame($this->musician->id(), $entry->memberUserId); + self::assertSame('2025-11-01', $entry->periodMonth?->format('Y-m-d')); + self::assertSame($this->musician->id(), $this->entryRepository->find($entry->id)?->memberUserId); + } + + #[Test] + public function testTheSameMemberCannotPayTheSameMonthTwiceEvenInAnotherYear(): void + { + $year = $this->openYear(); + $this->dues($year); + + $this->refused($year, ['occurredOn' => '2025-11-20'], 'periodMonth'); + self::assertSame(1, (int) $this->pdo->query('SELECT COUNT(*) FROM accounting_entries')->fetchColumn()); + self::assertSame(2, $this->yearRepository->find($year)?->nextEntryNumber, 'le refus ne consomme pas de numéro'); + } + + #[Test] + public function testAnotherMemberOrAnotherMonthIsFree(): void + { + $year = $this->openYear(); + $other = (new \App\Account\Repository\MysqlUserRepository($this->pdo))->save(new \App\Account\Entity\User(0, 'autre@rehearsalbox.test', 'hash', 'Autre', \App\Account\Entity\UserRole::Musicien, true, 0, null)); + $this->dues($year); + + $this->dues($year, ['periodMonth' => '2025-12']); + $this->dues($year, ['memberUserId' => (string) $other->id()]); + + self::assertSame(3, (int) $this->pdo->query('SELECT COUNT(*) FROM accounting_entries')->fetchColumn()); + } + + #[Test] + public function testAReversedPaymentFreesTheMonthAgain(): void + { + $year = $this->openYear(); + $entry = $this->dues($year); + $this->entries->reverse($this->admin, $entry->id, ['reason' => 'Erreur de saisie', 'occurredOn' => '2025-11-21'], $this->clock); + + $again = $this->dues($year, ['occurredOn' => '2025-11-22']); + + self::assertSame($this->musician->id(), $again->memberUserId); + $this->refused($year, ['occurredOn' => '2025-11-23'], 'periodMonth'); + } + + /** @return iterable, 1: string}> */ + public static function invalid(): iterable + { + yield 'mois sans adhérent' => [['memberUserId' => ''], 'memberUserId']; + yield 'adhérent sans mois' => [['periodMonth' => ''], 'periodMonth']; + yield 'adhérent texte' => [['memberUserId' => 'Jean'], 'memberUserId']; + yield 'adhérent négatif' => [['memberUserId' => '-1'], 'memberUserId']; + yield 'adhérent zéro' => [['memberUserId' => '0'], 'memberUserId']; + yield 'adhérent exposant' => [['memberUserId' => '1e0'], 'memberUserId']; + yield 'adhérent inconnu' => [['memberUserId' => '99999999'], 'memberUserId']; + yield 'mois avec jour' => [['periodMonth' => '2025-11-05'], 'periodMonth']; + yield 'mois 13' => [['periodMonth' => '2025-13'], 'periodMonth']; + yield 'mois tableau' => [['periodMonth' => ['2025-11']], 'periodMonth']; + yield 'hors cotisation' => [['category' => 'subventions'], 'memberUserId']; + } + + /** @param array $override */ + #[Test] + #[DataProvider('invalid')] + public function testInvalidMemberFieldsAreRefusedByNameAndNothingIsWritten(array $override, string $field): void + { + $year = $this->openYear(); + + $this->refused($year, $override, $field); + + self::assertSame(0, (int) $this->pdo->query('SELECT COUNT(*) FROM accounting_entries')->fetchColumn()); + } + + #[Test] + public function testADisabledAccountCannotBeCredited(): void + { + $this->refused($this->openYear(), ['memberUserId' => (string) $this->gone->id()], 'memberUserId'); + } + + #[Test] + public function testADuesEntryWithoutMemberStaysPossible(): void + { + $entry = $this->record($this->openYear(), ['category' => 'cotisations']); + + self::assertNull($entry->memberUserId); + self::assertNull($entry->periodMonth); + } + + #[Test] + public function testAnonymisingTheAccountLeavesTheEntryIntact(): void + { + $year = $this->openYear(); + $entry = $this->dues($year); + $this->pdo->prepare("UPDATE users SET display_name = 'Ancien membre', email = 'ancien@invalid.test', is_active = 0 WHERE id = ?")->execute([$this->musician->id()]); + + $kept = $this->entryRepository->find($entry->id); + + self::assertSame($entry->amountCents, $kept?->amountCents); + self::assertSame($this->musician->id(), $kept?->memberUserId); + } +} diff --git a/tests/Security/IdorMatrixTest.php b/tests/Security/IdorMatrixTest.php index 6fe4b9f..7b5108d 100644 --- a/tests/Security/IdorMatrixTest.php +++ b/tests/Security/IdorMatrixTest.php @@ -207,6 +207,7 @@ public static function deniedAccessProvider(): iterable ['POST', '/api/admin/accounting/years', ['label' => 'Piraté', 'startsOn' => '2025-10-01', 'endsOn' => '2026-09-30', 'openingBalance' => '0'], ['anon', 'stranger', 'memberA', 'managerA', 'outsiderB', 'dual']], ['POST', '/api/admin/accounting/years/1/entries', ['occurredOn' => '2025-11-05', 'label' => 'Piraté', 'amount' => '1', 'direction' => 'recette', 'category' => 'cotisations', 'paymentMethod' => 'virement'], ['anon', 'stranger', 'memberA', 'managerA', 'outsiderB', 'dual']], ['POST', '/api/admin/accounting/statements', ['__upload' => true, 'fiscalYearId' => '1', 'periodStart' => '2025-10-01', 'periodEnd' => '2025-10-31'], ['anon', 'stranger', 'memberA', 'managerA', 'outsiderB', 'dual']], + ['POST', '/api/admin/accounting/cotisations/exemptions', ['memberUserId' => '1', 'periodMonth' => '2025-10', 'exempt' => true], ['anon', 'stranger', 'memberA', 'managerA', 'outsiderB', 'dual']], ['POST', '/api/admin/accounting/recurring', ['label' => 'Piraté', 'direction' => 'depense', 'category' => 'loyer', 'paymentMethod' => 'virement', 'periodicity' => 'mensuelle', 'dueDay' => '5', 'firstMonth' => '2025-10'], ['anon', 'stranger', 'memberA', 'managerA', 'outsiderB', 'dual']], ['PUT', '/api/admin/accounting/recurring/1', ['label' => 'Piraté', 'direction' => 'depense', 'category' => 'loyer', 'paymentMethod' => 'virement', 'periodicity' => 'mensuelle', 'dueDay' => '5', 'firstMonth' => '2025-10'], ['anon', 'stranger', 'memberA', 'managerA', 'outsiderB', 'dual']], ['POST', '/api/admin/accounting/recurring/1/confirm', ['period' => '2025-10-01'], ['anon', 'stranger', 'memberA', 'managerA', 'outsiderB', 'dual']], @@ -498,7 +499,7 @@ public function testAccountingPageIsForAdministratorsOnly(): void #[Test] public function testStatementPagesAndDownloadAreForAdministratorsOnly(): void { - foreach (['/admin/accounting/statements', '/admin/accounting/statements/1/file', '/admin/accounting/recurring'] as $path) { + foreach (['/admin/accounting/statements', '/admin/accounting/statements/1/file', '/admin/accounting/recurring', '/admin/accounting/cotisations'] as $path) { self::assertSame(302, $this->call('anon', 'GET', $path)[0], "anonyme {$path}"); foreach (['stranger', 'memberA', 'managerA', 'outsiderB', 'dual'] as $actor) { self::assertSame(403, $this->call($actor, 'GET', $path)[0], "{$actor} {$path}"); @@ -506,6 +507,7 @@ public function testStatementPagesAndDownloadAreForAdministratorsOnly(): void } self::assertSame(200, $this->call('admin', 'GET', '/admin/accounting/statements')[0]); self::assertSame(200, $this->call('admin', 'GET', '/admin/accounting/recurring')[0]); + self::assertSame(200, $this->call('admin', 'GET', '/admin/accounting/cotisations')[0]); self::assertSame(404, $this->call('admin', 'GET', '/admin/accounting/statements/' . self::MISSING_ID . '/file')[0]); foreach (['0', '-1', 'abc', '1e0', '007', '99999999999999999999', '%00'] as $malformed) { self::assertSame(403, $this->call('admin', 'GET', "/admin/accounting/statements/{$malformed}/file")[0], $malformed);