Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .claude/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
1 change: 1 addition & 0 deletions .claude/rgpd.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 »
Expand Down
3 changes: 3 additions & 0 deletions config/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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']],
Expand Down Expand Up @@ -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']],
Expand Down
26 changes: 26 additions & 0 deletions config/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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),
));

Expand All @@ -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)));
Expand Down
22 changes: 22 additions & 0 deletions database/migrations/044_accounting_member_dues.sql
Original file line number Diff line number Diff line change
@@ -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;
60 changes: 60 additions & 0 deletions public/assets/css/pages/accounting.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
18 changes: 17 additions & 1 deletion public/assets/js/admin/accounting/accounting-requests.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) } };
}
Expand Down Expand Up @@ -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 }) },
};
}
22 changes: 21 additions & 1 deletion public/assets/js/admin/accounting/accounting-requests.test.js
Original file line number Diff line number Diff line change
@@ -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' }), {
Expand Down Expand Up @@ -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));
});
16 changes: 15 additions & 1 deletion public/assets/js/admin/accounting/admin-accounting.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Expand All @@ -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;
Expand Down Expand Up @@ -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 !== '') {
Expand Down Expand Up @@ -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]'));
}
});
}
Loading
Loading