Skip to content
Merged
7 changes: 7 additions & 0 deletions .claude/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -472,3 +472,10 @@ Migration 042 (`accounting_statements`), `Statement`, `StatementRepositoryInterf
- **Écart** : solde de la banque selon le livre à la fin de la période (solde d'ouverture de l'exercice + écritures hors espèces jusqu'à `period_end`) comparé au solde saisi du relevé (facultatif). Limite connue : le solde d'ouverture de l'exercice est compté en entier côté banque, la caisse en espèces n'ayant pas de solde propre.
- **Téléchargement** : toujours en pièce jointe, `nosniff`, CSP `sandbox`, `Cache-Control: private, no-store`, flux ; fichier absent : 404.
- **À faire (livraison 10)** : inclure `accounting-statements` dans les sauvegardes et le rapport d'orphelins (`DocumentOrphanReport`).

## Comptabilité : écritures récurrentes (#362, livraison 5)

Migration 043 (`accounting_recurring_templates`, `accounting_recurring_occurrences`), `src/Accounting/Service/Recurring/` (politique de saisie, calendrier pur `RecurrenceSchedule`, `RecurringTemplateService`, `RecurringOccurrenceService`), page `/admin/accounting/recurring`, API `POST/PUT /api/admin/accounting/recurring[/{id}]` et `POST …/{id}/confirm|ignore`.
- **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.
7 changes: 7 additions & 0 deletions config/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@

use App\Accounting\Controller\AccountingPageController;
use App\Accounting\Controller\Api\AccountingApiController;
use App\Accounting\Controller\Api\RecurringApiController;
use App\Accounting\Controller\Api\StatementApiController;
use App\Accounting\Controller\RecurringPageController;
use App\Accounting\Controller\StatementPageController;
use App\Association\Controller\Api\AssociationBoardApiController;
use App\Association\Controller\Api\AssociationTextApiController;
Expand Down Expand Up @@ -61,6 +63,7 @@
['GET', '/admin/assemblies/{id}', [AssemblyPageController::class, 'edit']],
['GET', '/admin/assemblies/{id}/print', [AssemblyPageController::class, 'print']],
['GET', '/admin/accounting', [AccountingPageController::class, 'index']],
['GET', '/admin/accounting/recurring', [RecurringPageController::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 @@ -125,6 +128,10 @@
['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/recurring', [RecurringApiController::class, 'create']],
['PUT', '/api/admin/accounting/recurring/{id}', [RecurringApiController::class, 'update']],
['POST', '/api/admin/accounting/recurring/{id}/confirm', [RecurringApiController::class, 'confirm']],
['POST', '/api/admin/accounting/recurring/{id}/ignore', [RecurringApiController::class, 'ignore']],
['POST', '/api/admin/accounting/years/{id}/entries', [AccountingApiController::class, 'recordEntry']],
['POST', '/api/admin/accounting/years/{id}/close', [AccountingApiController::class, 'closeYear']],
['POST', '/api/admin/accounting/years/{id}/approve', [AccountingApiController::class, 'approveYear']],
Expand Down
47 changes: 47 additions & 0 deletions config/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,18 @@

use App\Accounting\Controller\AccountingPageController;
use App\Accounting\Controller\Api\AccountingApiController;
use App\Accounting\Controller\Api\RecurringApiController;
use App\Accounting\Controller\Api\StatementApiController;
use App\Accounting\Controller\RecurringPageController;
use App\Accounting\Presenter\RecurringView;
use App\Accounting\Repository\MysqlRecurringOccurrenceRepository;
use App\Accounting\Repository\MysqlRecurringTemplateRepository;
use App\Accounting\Repository\RecurringOccurrenceRepositoryInterface;
use App\Accounting\Repository\RecurringTemplateRepositoryInterface;
use App\Accounting\Service\Recurring\RecurrenceSchedule;
use App\Accounting\Service\Recurring\RecurringOccurrenceService;
use App\Accounting\Service\Recurring\RecurringTemplateService;
use App\Accounting\Service\Recurring\TemplateInputPolicy;
use App\Accounting\Controller\StatementPageController;
use App\Accounting\Presenter\StatementListView;
use App\Accounting\Repository\MysqlStatementRepository;
Expand Down Expand Up @@ -734,6 +745,42 @@
new StatementListView(new MoneyFormatter()),
));

$container->set(RecurringTemplateRepositoryInterface::class, fn ($c) => new MysqlRecurringTemplateRepository($c->get(PDO::class)));
$container->set(RecurringOccurrenceRepositoryInterface::class, fn ($c) => new MysqlRecurringOccurrenceRepository($c->get(PDO::class)));

$container->set(RecurringTemplateService::class, fn ($c) => new RecurringTemplateService(
$c->get(AdminAccess::class),
new TemplateInputPolicy(new AccountingAmountParser()),
$c->get(RecurringTemplateRepositoryInterface::class),
$c->get(LoggerInterface::class),
));

$container->set(RecurringOccurrenceService::class, fn ($c) => new RecurringOccurrenceService(
$c->get(AdminAccess::class),
$c->get(RecurringTemplateRepositoryInterface::class),
$c->get(RecurringOccurrenceRepositoryInterface::class),
$c->get(FiscalYearRepositoryInterface::class),
$c->get(EntryService::class),
new RecurrenceSchedule(),
$c->get(LoggerInterface::class),
));

$container->set(RecurringApiController::class, fn ($c) => new RecurringApiController(
$c->get(RecurringTemplateService::class),
$c->get(RecurringOccurrenceService::class),
$c->get(AuthGuard::class),
));

$container->set(RecurringPageController::class, fn ($c) => new RecurringPageController(
$c->get(TemplateRendererInterface::class),
$c->get(CsrfTokenManager::class),
$c->get(AuthGuard::class),
$c->get(FiscalYearService::class),
$c->get(RecurringTemplateService::class),
$c->get(RecurringOccurrenceService::class),
new RecurringView(new MoneyFormatter()),
));

$container->set(AssociationBoardRepositoryInterface::class, fn ($c) => new MysqlAssociationBoardRepository($c->get(PDO::class)));

$container->set(AssociationBoardService::class, fn ($c) => new AssociationBoardService(
Expand Down
38 changes: 38 additions & 0 deletions database/migrations/043_create_accounting_recurring.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
-- Écritures récurrentes (#362), livraison 5 : modèles (loyer, électricité, assurance, frais bancaires…) et échéances traitées. Un modèle
-- ne crée JAMAIS d'écriture seul : chaque échéance arrive dans la liste « À confirmer » et c'est la confirmation (ou l'abandon) du
-- trésorier qui la décide. `amount_cents` NULL = montant variable (électricité), à saisir à chaque échéance. Une échéance est décidée UNE
-- fois (clé unique modèle + mois) : le double clic et deux onglets ne créent jamais deux écritures. Les modèles se désactivent, ne se
-- suppriment pas (l'historique des décisions reste lisible).
CREATE TABLE accounting_recurring_templates (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
label VARCHAR(150) NOT NULL,
direction ENUM('recette', 'depense') NOT NULL,
category VARCHAR(30) NOT NULL,
payment_method ENUM('especes', 'virement', 'cheque', 'carte') NOT NULL,
amount_cents BIGINT UNSIGNED NULL,
voucher_ref VARCHAR(100) NULL,
periodicity ENUM('mensuelle', 'trimestrielle', 'annuelle') NOT NULL,
due_day TINYINT UNSIGNED NOT NULL,
first_month DATE NOT NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_by_user_id INT UNSIGNED NOT NULL,
created_at DATETIME NOT NULL,
CONSTRAINT chk_recurring_day CHECK (due_day BETWEEN 1 AND 28),
CONSTRAINT chk_recurring_amount CHECK (amount_cents IS NULL OR amount_cents > 0),
CONSTRAINT fk_recurring_creator FOREIGN KEY (created_by_user_id) REFERENCES users(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE TABLE accounting_recurring_occurrences (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
template_id INT UNSIGNED NOT NULL,
period DATE NOT NULL,
status ENUM('confirmee', 'ignoree') NOT NULL,
entry_id INT UNSIGNED NULL,
decided_by_user_id INT UNSIGNED NOT NULL,
decided_at DATETIME NOT NULL,
UNIQUE KEY uq_recurring_occurrence (template_id, period),
UNIQUE KEY uq_recurring_entry (entry_id),
CONSTRAINT fk_occurrence_template FOREIGN KEY (template_id) REFERENCES accounting_recurring_templates(id),
CONSTRAINT fk_occurrence_entry FOREIGN KEY (entry_id) REFERENCES accounting_entries(id),
CONSTRAINT fk_occurrence_decider FOREIGN KEY (decided_by_user_id) REFERENCES users(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
26 changes: 26 additions & 0 deletions public/assets/js/admin/accounting/accounting-requests.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,29 @@ export function depositStatementRequest(yearId, values, file) {

return { path: '/api/admin/accounting/statements', options: { method: 'POST', body } };
}

const TEMPLATE_FIELDS = ['label', 'direction', 'category', 'paymentMethod', 'amount', 'voucherRef', 'periodicity', 'dueDay', 'firstMonth', 'isActive'];

/** Modèle récurrent : tous les champs du modèle ; une case décochée envoie « false », jamais l'absence. */
export function templateRequest(templateId, values) {
const body = pick(values, TEMPLATE_FIELDS);
body.isActive = values.isActive === true || values.isActive === '1';
const path = templateId === '' ? '/api/admin/accounting/recurring' : `/api/admin/accounting/recurring/${idOrThrow(templateId, 'de modèle')}`;

return { path, options: { method: templateId === '' ? 'POST' : 'PUT', body: JSON.stringify(body) } };
}

/** Confirmation d'une échéance : seuls l'échéance, le montant (variable) et la pièce partent ; le reste vient du modèle, côté serveur. */
export function confirmOccurrenceRequest(templateId, values) {
return {
path: `/api/admin/accounting/recurring/${idOrThrow(templateId, 'de modèle')}/confirm`,
options: { method: 'POST', body: JSON.stringify(pick(values, ['period', 'amount', 'voucherRef'])) },
};
}

export function ignoreOccurrenceRequest(templateId, values) {
return {
path: `/api/admin/accounting/recurring/${idOrThrow(templateId, 'de modèle')}/ignore`,
options: { method: 'POST', body: JSON.stringify(pick(values, ['period'])) },
};
}
26 changes: 25 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, recordEntryRequest, reverseRequest } from './accounting-requests.js';
import { approveYearRequest, closeYearRequest, createYearRequest, depositStatementRequest, 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 @@ -63,3 +63,27 @@ test('le dépôt d\'un relevé part en multipart avec les seuls champs attendus,
assert.throws(() => depositStatementRequest('4x', {}, file));
assert.throws(() => depositStatementRequest('4', { replaces: '0' }, file));
});

test('modèle récurrent : création en POST, modification en PUT, case décochée = false, identifiant contrôlé', () => {
const values = { label: 'Loyer', direction: 'depense', category: 'loyer', paymentMethod: 'virement', amount: '498,00', voucherRef: '', periodicity: 'trimestrielle', dueDay: '5', firstMonth: '2025-10', isActive: '1', role: 'admin' };
const created = templateRequest('', values);
assert.equal(created.path, '/api/admin/accounting/recurring');
assert.equal(created.options.method, 'POST');
assert.equal(JSON.parse(created.options.body).isActive, true);
assert.equal('role' in JSON.parse(created.options.body), false);
const updated = templateRequest('4', { ...values, isActive: undefined });
assert.equal(updated.path, '/api/admin/accounting/recurring/4');
assert.equal(updated.options.method, 'PUT');
assert.equal(JSON.parse(updated.options.body).isActive, false);
assert.throws(() => templateRequest('4/x', values));
});

test('confirmer ou abandonner une échéance n\'envoie que l\'échéance, le montant et la pièce', () => {
const confirm = confirmOccurrenceRequest('4', { period: '2025-10-01', amount: '63,40', voucherRef: 'F-1', label: 'Piraté', direction: 'recette' });
assert.equal(confirm.path, '/api/admin/accounting/recurring/4/confirm');
assert.deepEqual(JSON.parse(confirm.options.body), { period: '2025-10-01', amount: '63,40', voucherRef: 'F-1' });
const ignore = ignoreOccurrenceRequest('4', { period: '2025-10-01', amount: '1' });
assert.equal(ignore.path, '/api/admin/accounting/recurring/4/ignore');
assert.deepEqual(JSON.parse(ignore.options.body), { period: '2025-10-01' });
assert.throws(() => confirmOccurrenceRequest('0', {}));
});
121 changes: 121 additions & 0 deletions public/assets/js/admin/accounting/admin-recurring.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/**
* Page admin « Écritures récurrentes » (#362) : confirmer ou abandonner une échéance, créer ou modifier un modèle. Appelle l'API (apiFetch,
* CSRF) et recharge la page au succès ; aucun envoi de formulaire natif. Le serveur revérifie tout.
*/
import { apiFetch } from '../../core/api.js';
import { showToast } from '../../core/toast.js';
import { clearErrors, reportFailure } from '../form-feedback.js';
import { categoriesFor } from './accounting-form.js';
import { confirmOccurrenceRequest, failureMessage, ignoreOccurrenceRequest, templateRequest } from './accounting-requests.js';

const OCCURRENCE_FIELDS = ['amount', 'voucherRef', 'period', 'occurredOn'];
const TEMPLATE_FIELDS = ['label', 'direction', 'category', 'paymentMethod', 'amount', 'voucherRef', 'periodicity', 'dueDay', 'firstMonth'];

function syncCategories(form) {
const select = form.elements.category;
const all = JSON.parse(select.dataset.categories ?? '[]');
const previous = select.value;
select.replaceChildren(...categoriesFor(form.elements.direction.value, all).map((category) => new Option(category.label, category.value)));
select.value = [...select.options].some((option) => option.value === previous) ? previous : (select.options[0]?.value ?? '');
}

function fill(form, template) {
form.elements.id.value = String(template.id ?? '');
for (const key of ['label', 'direction', 'paymentMethod', 'voucherRef', 'periodicity', 'dueDay', 'firstMonth']) {
form.elements[key].value = String(template[key === 'paymentMethod' ? 'method' : key] ?? '');
}
syncCategories(form);
form.elements.category.value = template.category ?? form.elements.category.value;
form.elements.amount.value = template.amountInput ?? '';
form.elements.isActive.checked = template.isActive !== false;
}

function openTemplate(section, template) {
const form = section.querySelector('[data-template-form]');
clearErrors(form, TEMPLATE_FIELDS);
form.hidden = false;
section.querySelector('[data-action="new-template"]').hidden = true;
form.querySelector('[data-template-title]').textContent = template === null ? 'Nouveau modèle' : 'Modifier le modèle';
if (template === null) {
form.reset();
form.elements.id.value = '';
syncCategories(form);
} else {
fill(form, template);
}
form.elements.label.focus();
}

function closeTemplate(section) {
const form = section.querySelector('[data-template-form]');
form.hidden = true;
section.querySelector('[data-action="new-template"]').hidden = false;
}

function occurrenceValues(form) {
return { period: form.dataset.period, amount: form.elements.amount?.value ?? '', voucherRef: form.elements.voucherRef?.value ?? '' };
}

async function send(form, fields, build, done) {
clearErrors(form, fields);
const buttons = form.querySelectorAll('button');
buttons.forEach((button) => { button.disabled = true; });
try {
const { path, options } = build();
done(await apiFetch(path, options));
} catch (error) {
buttons.forEach((button) => { button.disabled = false; });
reportFailure(form, fields, error, failureMessage);
}
}

function onClick(event) {
const target = event.target.closest('[data-action]');
const section = target?.closest('[data-recurring]');
if (section === null || section === undefined) {
return;
}
switch (target.dataset.action) {
case 'new-template': openTemplate(section, null); break;
case 'cancel-template': closeTemplate(section); break;
case 'edit-template': openTemplate(section, JSON.parse(target.closest('[data-template]').dataset.template)); break;
case 'ignore-occurrence': {
const form = target.closest('[data-occurrence-form]');
send(form, OCCURRENCE_FIELDS, () => ignoreOccurrenceRequest(form.dataset.templateId, { period: form.dataset.period }), (done) => {
showToast('Échéance abandonnée.', 'success');
window.location.assign(done.url);
});
break;
}
default:
}
}

function onSubmit(event) {
const form = event.target;
if (form.matches('[data-occurrence-form]')) {
event.preventDefault();
send(form, OCCURRENCE_FIELDS, () => confirmOccurrenceRequest(form.dataset.templateId, occurrenceValues(form)), (done) => {
showToast(`Écriture n° ${done.number} enregistrée.`, 'success');
window.location.assign(done.url);
});
} else if (form.matches('[data-template-form]')) {
event.preventDefault();
const values = Object.fromEntries(TEMPLATE_FIELDS.map((key) => [key, form.elements[key].value]));
values.isActive = form.elements.isActive.checked;
send(form, TEMPLATE_FIELDS, () => templateRequest(form.elements.id.value, values), () => {
showToast('Modèle enregistré.', 'success');
window.location.reload();
});
}
}

export function initAdminRecurring(root = document) {
root.addEventListener('click', onClick);
root.addEventListener('submit', onSubmit);
root.addEventListener('change', (event) => {
if (event.target.matches('[data-template-form] [name="direction"]')) {
syncCategories(event.target.closest('[data-template-form]'));
}
});
}
Loading
Loading