diff --git a/.claude/architecture.md b/.claude/architecture.md index 45e8b44..5922bec 100644 --- a/.claude/architecture.md +++ b/.claude/architecture.md @@ -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. diff --git a/config/routes.php b/config/routes.php index 6528289..8754d3e 100644 --- a/config/routes.php +++ b/config/routes.php @@ -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; @@ -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']], @@ -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']], diff --git a/config/services.php b/config/services.php index 4a34429..14400c5 100644 --- a/config/services.php +++ b/config/services.php @@ -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; @@ -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( diff --git a/database/migrations/043_create_accounting_recurring.sql b/database/migrations/043_create_accounting_recurring.sql new file mode 100644 index 0000000..8f79714 --- /dev/null +++ b/database/migrations/043_create_accounting_recurring.sql @@ -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; diff --git a/public/assets/js/admin/accounting/accounting-requests.js b/public/assets/js/admin/accounting/accounting-requests.js index cb803cf..6efd046 100644 --- a/public/assets/js/admin/accounting/accounting-requests.js +++ b/public/assets/js/admin/accounting/accounting-requests.js @@ -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'])) }, + }; +} diff --git a/public/assets/js/admin/accounting/accounting-requests.test.js b/public/assets/js/admin/accounting/accounting-requests.test.js index 3defd6a..7167b79 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, 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' }), { @@ -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', {})); +}); diff --git a/public/assets/js/admin/accounting/admin-recurring.js b/public/assets/js/admin/accounting/admin-recurring.js new file mode 100644 index 0000000..0208e74 --- /dev/null +++ b/public/assets/js/admin/accounting/admin-recurring.js @@ -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]')); + } + }); +} diff --git a/public/assets/js/app.js b/public/assets/js/app.js index 9826440..4f28307 100644 --- a/public/assets/js/app.js +++ b/public/assets/js/app.js @@ -12,6 +12,7 @@ import { initAdminGroups } from './group/admin-groups.js'; 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 { initAdminBoard } from './admin/board/admin-board.js'; import { initAdminTexts } from './admin/texts/admin-texts.js'; import { initAdminAssemblies } from './admin/assembly/admin-assemblies.js'; @@ -44,6 +45,7 @@ document.addEventListener('DOMContentLoaded', () => { initAdminAssemblies(); initAdminAccounting(); initAdminStatements(); + initAdminRecurring(); initAdminBoard(); initAdminTexts(); initBookingsBadge(); diff --git a/src/Accounting/Controller/Api/RecurringApiController.php b/src/Accounting/Controller/Api/RecurringApiController.php new file mode 100644 index 0000000..4978040 --- /dev/null +++ b/src/Accounting/Controller/Api/RecurringApiController.php @@ -0,0 +1,61 @@ +authGuard->requireRole(UserRole::Admin); + + return new JsonResponse(['id' => $this->templates->create($admin, $request->allBody())], 201); + } + + public function update(Request $request, string $id): JsonResponse + { + $admin = $this->authGuard->requireRole(UserRole::Admin); + $templateId = StrictId::orDenied($id); + $this->templates->update($admin, $templateId, $request->allBody()); + + return new JsonResponse(['id' => $templateId]); + } + + public function confirm(Request $request, string $id): JsonResponse + { + $admin = $this->authGuard->requireRole(UserRole::Admin); + $entry = $this->occurrences->confirm($admin, StrictId::orDenied($id), $request->allBody(), new \DateTimeImmutable('today')); + + return new JsonResponse(['id' => $entry->id, 'number' => $entry->number, 'url' => '/admin/accounting/recurring'], 201); + } + + public function ignore(Request $request, string $id): JsonResponse + { + $admin = $this->authGuard->requireRole(UserRole::Admin); + $this->occurrences->ignore($admin, StrictId::orDenied($id), $request->allBody(), new \DateTimeImmutable('today')); + + return new JsonResponse(['url' => '/admin/accounting/recurring']); + } +} diff --git a/src/Accounting/Controller/RecurringPageController.php b/src/Accounting/Controller/RecurringPageController.php new file mode 100644 index 0000000..69ea774 --- /dev/null +++ b/src/Accounting/Controller/RecurringPageController.php @@ -0,0 +1,49 @@ +authGuard->requireRole(UserRole::Admin); + $year = YearChooser::choose($this->years->list($admin), StrictId::from($request->query('year'))); + + return new Response($this->renderer->render('admin/accounting/recurring', [ + 'csrfToken' => $this->csrfTokenManager->getToken(), + 'page' => $this->view->page( + $year === null ? [] : $this->occurrences->pending($admin, $year, new \DateTimeImmutable('today')), + $this->templates->list($admin), + $year?->isOpen() ?? false, + ), + 'yearId' => $year?->id, + 'currentUserRole' => $admin->role(), + ])); + } +} diff --git a/src/Accounting/Entity/Periodicity.php b/src/Accounting/Entity/Periodicity.php new file mode 100644 index 0000000..ee6e187 --- /dev/null +++ b/src/Accounting/Entity/Periodicity.php @@ -0,0 +1,31 @@ + 1, + self::Trimestrielle => 3, + self::Annuelle => 12, + }; + } + + public function label(): string + { + return match ($this) { + self::Mensuelle => 'Chaque mois', + self::Trimestrielle => 'Chaque trimestre', + self::Annuelle => 'Chaque année', + }; + } +} diff --git a/src/Accounting/Entity/RecurringTemplate.php b/src/Accounting/Entity/RecurringTemplate.php new file mode 100644 index 0000000..11a9ab5 --- /dev/null +++ b/src/Accounting/Entity/RecurringTemplate.php @@ -0,0 +1,29 @@ +amountCents === null; + } +} diff --git a/src/Accounting/Exception/RecurringTemplateNotFoundException.php b/src/Accounting/Exception/RecurringTemplateNotFoundException.php new file mode 100644 index 0000000..0c3a558 --- /dev/null +++ b/src/Accounting/Exception/RecurringTemplateNotFoundException.php @@ -0,0 +1,14 @@ + $pending + * @param list $templates + * @return array + */ + public function page(array $pending, array $templates, bool $yearOpen): array + { + return [ + 'yearOpen' => $yearOpen, + 'pending' => array_map(fn (PendingOccurrence $p): array => [ + 'templateId' => $p->template->id, + 'period' => $p->period->format('Y-m-d'), + 'label' => $p->template->label, + 'dueOn' => $p->dueOn->format('d/m/Y'), + 'dueIso' => $p->dueOn->format('Y-m-d'), + 'direction' => $p->template->direction->value, + 'amount' => $p->template->amountCents === null ? '' : $this->money->format($p->template->amountCents), + 'isVariable' => $p->template->isVariable(), + 'voucher' => $p->template->voucherRef ?? '', + 'needsVoucher' => $p->template->direction === Direction::Depense && ($p->template->voucherRef ?? '') === '', + ], $pending), + 'templates' => array_map(fn (RecurringTemplate $t): array => [ + 'id' => $t->id, + 'label' => $t->label, + 'direction' => $t->direction->value, + 'directionLabel' => $t->direction->label(), + 'category' => $t->category->value, + 'categoryLabel' => $t->category->label(), + 'method' => $t->paymentMethod->value, + 'methodLabel' => $t->paymentMethod->label(), + 'amount' => $t->amountCents === null ? 'Variable' : $this->money->format($t->amountCents), + 'amountInput' => $t->amountCents === null ? '' : intdiv($t->amountCents, 100) . ',' . str_pad((string) ($t->amountCents % 100), 2, '0', STR_PAD_LEFT), + 'voucher' => $t->voucherRef ?? '', + 'periodicity' => $t->periodicity->value, + 'periodicityLabel' => $t->periodicity->label(), + 'dueDay' => $t->dueDay, + 'firstMonth' => $t->firstMonth->format('Y-m'), + 'isActive' => $t->isActive, + ], $templates), + 'form' => [ + 'directions' => array_map(static fn (Direction $d): array => ['value' => $d->value, 'label' => $d->label()], Direction::cases()), + 'categories' => array_map(static fn (EntryCategory $c): array => ['value' => $c->value, 'label' => $c->label(), 'direction' => $c->direction()->value], EntryCategory::cases()), + 'methods' => array_map(static fn (PaymentMethod $m): array => ['value' => $m->value, 'label' => $m->label()], PaymentMethod::cases()), + 'periodicities' => array_map(static fn (Periodicity $p): array => ['value' => $p->value, 'label' => $p->label()], Periodicity::cases()), + 'firstMonth' => date('Y-m'), + ], + ]; + } +} diff --git a/src/Accounting/Repository/MysqlRecurringOccurrenceRepository.php b/src/Accounting/Repository/MysqlRecurringOccurrenceRepository.php new file mode 100644 index 0000000..f765ba8 --- /dev/null +++ b/src/Accounting/Repository/MysqlRecurringOccurrenceRepository.php @@ -0,0 +1,46 @@ +pdo->prepare( + 'INSERT INTO accounting_recurring_occurrences (template_id, period, status, entry_id, decided_by_user_id, decided_at) + VALUES (:template, :period, :status, :entry, :actor, :now)' + )->execute(['template' => $templateId, 'period' => $period->format('Y-m-d'), 'status' => $status, 'entry' => $entryId, 'actor' => $actorId, 'now' => $now->format('Y-m-d H:i:s')]); + } catch (\PDOException $e) { + if (($e->errorInfo[1] ?? null) === self::MYSQL_DUPLICATE_KEY) { + return false; + } + throw $e; + } + + return true; + } + + public function decidedFor(array $templateIds): array + { + if ($templateIds === []) { + return []; + } + $statement = $this->pdo->prepare('SELECT template_id, period FROM accounting_recurring_occurrences WHERE template_id IN (' . implode(',', array_fill(0, count($templateIds), '?')) . ')'); + $statement->execute($templateIds); + $decided = []; + foreach ($statement->fetchAll(\PDO::FETCH_ASSOC) as $row) { + $decided[$row['template_id'] . '|' . $row['period']] = true; + } + + return $decided; + } +} diff --git a/src/Accounting/Repository/MysqlRecurringTemplateRepository.php b/src/Accounting/Repository/MysqlRecurringTemplateRepository.php new file mode 100644 index 0000000..8c30908 --- /dev/null +++ b/src/Accounting/Repository/MysqlRecurringTemplateRepository.php @@ -0,0 +1,96 @@ +pdo->prepare( + 'INSERT INTO accounting_recurring_templates + (label, direction, category, payment_method, amount_cents, voucher_ref, periodicity, due_day, first_month, is_active, created_by_user_id, created_at) + VALUES (:label, :direction, :category, :method, :amount, :voucher, :periodicity, :day, :month, :active, :actor, :now)' + )->execute(self::params($draft) + ['actor' => $actorId, 'now' => $now->format('Y-m-d H:i:s')]); + + return (int) $this->pdo->lastInsertId(); + } + + public function update(int $id, TemplateDraft $draft): bool + { + $statement = $this->pdo->prepare( + 'UPDATE accounting_recurring_templates SET label = :label, direction = :direction, category = :category, payment_method = :method, + amount_cents = :amount, voucher_ref = :voucher, periodicity = :periodicity, due_day = :day, first_month = :month, is_active = :active + WHERE id = :id' + ); + $statement->execute(self::params($draft) + ['id' => $id]); + + // MySQL ne compte pas une mise à jour sans changement : on vérifie l'existence. + return $statement->rowCount() > 0 || $this->find($id) !== null; + } + + public function find(int $id): ?RecurringTemplate + { + $statement = $this->pdo->prepare('SELECT ' . self::COLUMNS . ' FROM accounting_recurring_templates WHERE id = :id'); + $statement->execute(['id' => $id]); + $row = $statement->fetch(\PDO::FETCH_ASSOC); + + return $row === false ? null : self::hydrate($row); + } + + public function listAll(int $limit): array + { + $statement = $this->pdo->query('SELECT ' . self::COLUMNS . ' FROM accounting_recurring_templates ORDER BY is_active DESC, id DESC LIMIT ' . max(1, $limit)); + + return $statement === false ? [] : array_map(self::hydrate(...), $statement->fetchAll(\PDO::FETCH_ASSOC)); + } + + /** @return array */ + private static function params(TemplateDraft $draft): array + { + return [ + 'label' => $draft->label, + 'direction' => $draft->direction->value, + 'category' => $draft->category->value, + 'method' => $draft->paymentMethod->value, + 'amount' => $draft->amountCents, + 'voucher' => $draft->voucherRef, + 'periodicity' => $draft->periodicity->value, + 'day' => $draft->dueDay, + 'month' => $draft->firstMonth->format('Y-m-d'), + 'active' => $draft->isActive ? 1 : 0, + ]; + } + + /** @param array $row */ + private static function hydrate(array $row): RecurringTemplate + { + return new RecurringTemplate( + (int) $row['id'], + (string) $row['label'], + Direction::from((string) $row['direction']), + EntryCategory::from((string) $row['category']), + PaymentMethod::from((string) $row['payment_method']), + $row['amount_cents'] === null ? null : (int) $row['amount_cents'], + $row['voucher_ref'] === null ? null : (string) $row['voucher_ref'], + Periodicity::from((string) $row['periodicity']), + (int) $row['due_day'], + new \DateTimeImmutable((string) $row['first_month']), + (bool) $row['is_active'], + ); + } +} diff --git a/src/Accounting/Repository/RecurringOccurrenceRepositoryInterface.php b/src/Accounting/Repository/RecurringOccurrenceRepositoryInterface.php new file mode 100644 index 0000000..6b2673b --- /dev/null +++ b/src/Accounting/Repository/RecurringOccurrenceRepositoryInterface.php @@ -0,0 +1,18 @@ + $templateIds + * @return array clés `idModèle|AAAA-MM-01` des échéances déjà décidées + */ + public function decidedFor(array $templateIds): array; +} diff --git a/src/Accounting/Repository/RecurringTemplateRepositoryInterface.php b/src/Accounting/Repository/RecurringTemplateRepositoryInterface.php new file mode 100644 index 0000000..6e73dd8 --- /dev/null +++ b/src/Accounting/Repository/RecurringTemplateRepositoryInterface.php @@ -0,0 +1,22 @@ + les plus récents d'abord, au plus `$limit` */ + public function listAll(int $limit): array; +} diff --git a/src/Accounting/Service/EntryService.php b/src/Accounting/Service/EntryService.php index 2762976..95ef596 100644 --- a/src/Accounting/Service/EntryService.php +++ b/src/Accounting/Service/EntryService.php @@ -41,15 +41,16 @@ public function __construct( * @param array $input voir EntryInputPolicy::parse * @throws FiscalYearNotFoundException * @throws FiscalYearLockedException exercice clos ou approuvé + * @param (callable(AccountingEntry): void)|null $inTransaction appelé APRÈS l'écriture et DANS la même transaction : s'il lève, rien n'est écrit * @throws AccountingValidationException saisie refusée, ou date hors de l'exercice */ - public function record(User $actor, int $fiscalYearId, array $input, ?\DateTimeImmutable $now = null): AccountingEntry + public function record(User $actor, int $fiscalYearId, array $input, ?\DateTimeImmutable $now = null, ?callable $inTransaction = null): AccountingEntry { $this->access->assertAdmin($actor); $draft = $this->policy->parse($input); $now ??= new \DateTimeImmutable(); - $entry = $this->transactions->run(function () use ($fiscalYearId, $draft, $actor, $now): AccountingEntry { + $entry = $this->transactions->run(function () use ($fiscalYearId, $draft, $actor, $now, $inTransaction): AccountingEntry { $year = $this->years->lock($fiscalYearId) ?? throw new FiscalYearNotFoundException(); if (!$year->isOpen()) { throw new FiscalYearLockedException(); @@ -58,7 +59,12 @@ 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') . ').']); } - return $this->write($year->id, $year->nextEntryNumber, $draft, null, $actor, $now); + $entry = $this->write($year->id, $year->nextEntryNumber, $draft, null, $actor, $now); + if ($inTransaction !== null) { + $inTransaction($entry); + } + + return $entry; }); $this->logger->info('Comptabilité : écriture saisie', ['actor' => $actor->id(), 'year' => $fiscalYearId, 'entry' => $entry->id]); diff --git a/src/Accounting/Service/Recurring/PendingOccurrence.php b/src/Accounting/Service/Recurring/PendingOccurrence.php new file mode 100644 index 0000000..a604dd6 --- /dev/null +++ b/src/Accounting/Service/Recurring/PendingOccurrence.php @@ -0,0 +1,18 @@ +format('d') !== '01' || $period < $template->firstMonth) { + return false; + } + $diff = ((int) $period->format('Y') - (int) $template->firstMonth->format('Y')) * 12 + (int) $period->format('n') - (int) $template->firstMonth->format('n'); + + return $diff % $template->periodicity->months() === 0; + } + + public function dueOn(RecurringTemplate $template, \DateTimeImmutable $period): \DateTimeImmutable + { + return $period->setDate((int) $period->format('Y'), (int) $period->format('n'), $template->dueDay); + } + + /** + * Échéances dont la date est comprise entre `$from` et `$to` incluses, de la plus ancienne à la plus récente. + * + * @return list<\DateTimeImmutable> premier jour du mois de chaque échéance + */ + public function periodsBetween(RecurringTemplate $template, \DateTimeImmutable $from, \DateTimeImmutable $to): array + { + $periods = []; + $month = $template->firstMonth; + for ($i = 0; $i < self::SAFETY_LIMIT && $month <= $to; ++$i) { + $due = $this->dueOn($template, $month); + if ($due >= $from && $due <= $to) { + $periods[] = $month; + } + $month = $month->modify('+' . $template->periodicity->months() . ' months'); + } + + return $periods; + } +} diff --git a/src/Accounting/Service/Recurring/RecurringOccurrenceService.php b/src/Accounting/Service/Recurring/RecurringOccurrenceService.php new file mode 100644 index 0000000..b5b32bf --- /dev/null +++ b/src/Accounting/Service/Recurring/RecurringOccurrenceService.php @@ -0,0 +1,144 @@ + échéances atteintes de l'exercice ouvert, de la plus ancienne à la plus récente (rien pour un exercice clos) */ + public function pending(User $actor, FiscalYear $year, \DateTimeImmutable $today): array + { + $this->access->assertAdmin($actor); + if (!$year->isOpen()) { + return []; + } + $active = array_values(array_filter($this->templates->listAll(self::TEMPLATE_LIMIT), static fn (RecurringTemplate $t): bool => $t->isActive)); + $decided = $this->occurrences->decidedFor(array_map(static fn (RecurringTemplate $t): int => $t->id, $active)); + $to = min($today, $year->endsOn); + $pending = []; + foreach ($active as $template) { + foreach ($this->schedule->periodsBetween($template, $year->startsOn, $to) as $period) { + if (!isset($decided[$template->id . '|' . $period->format('Y-m-d')])) { + $pending[] = new PendingOccurrence($template, $period, $this->schedule->dueOn($template, $period)); + } + } + } + usort($pending, static fn (PendingOccurrence $a, PendingOccurrence $b): int => [$a->dueOn, $a->template->id] <=> [$b->dueOn, $b->template->id]); + + return $pending; + } + + /** + * @param array $input `period` (AAAA-MM-01), `amount` (obligatoire si le montant est variable), `occurredOn` (défaut : la date + * d'échéance), `voucherRef` (défaut : celle du modèle) + * @throws RecurringTemplateNotFoundException + * @throws AccountingValidationException échéance inconnue, future ou déjà décidée ; saisie refusée ; aucun exercice ouvert à cette date + */ + public function confirm(User $actor, int $templateId, array $input, \DateTimeImmutable $today, ?\DateTimeImmutable $now = null): AccountingEntry + { + $this->access->assertAdmin($actor); + $now ??= new \DateTimeImmutable(); + [$template, $period] = $this->dueOccurrence($templateId, $input, $today); + $errors = []; + $dateText = self::text($input['occurredOn'] ?? null) !== '' ? self::text($input['occurredOn']) : $this->schedule->dueOn($template, $period)->format('Y-m-d'); + $date = EntryInputPolicy::date($dateText, 'occurredOn', $errors); + $year = $date === null ? null : $this->years->findOpenContaining($date); + if ($date === null || $year === null) { + throw new AccountingValidationException($errors + ['occurredOn' => 'Aucun exercice ouvert à cette date.']); + } + $amount = self::text($input['amount'] ?? null) !== '' ? $input['amount'] : ($template->amountCents === null ? null : self::euros($template->amountCents)); + $voucher = self::text($input['voucherRef'] ?? null) !== '' ? $input['voucherRef'] : $template->voucherRef; + + $entry = $this->entries->record($actor, $year->id, [ + 'occurredOn' => $dateText, + 'label' => $template->label, + 'amount' => $amount, + 'direction' => $template->direction->value, + 'category' => $template->category->value, + 'paymentMethod' => $template->paymentMethod->value, + 'voucherRef' => $voucher, + ], $now, function (AccountingEntry $written) use ($template, $period, $actor, $now): void { + if (!$this->occurrences->decide($template->id, $period, 'confirmee', $written->id, $actor->id(), $now)) { + throw new AccountingValidationException(['period' => 'Cette échéance est déjà traitée.']); + } + }); + $this->logger->info('Comptabilité : échéance confirmée', ['actor' => $actor->id(), 'template' => $templateId, 'entry' => $entry->id]); + + return $entry; + } + + /** + * @param array $input `period` (AAAA-MM-01) + * @throws RecurringTemplateNotFoundException + * @throws AccountingValidationException échéance inconnue, future ou déjà décidée + */ + public function ignore(User $actor, int $templateId, array $input, \DateTimeImmutable $today, ?\DateTimeImmutable $now = null): void + { + $this->access->assertAdmin($actor); + [$template, $period] = $this->dueOccurrence($templateId, $input, $today); + if (!$this->occurrences->decide($template->id, $period, 'ignoree', null, $actor->id(), $now ?? new \DateTimeImmutable())) { + throw new AccountingValidationException(['period' => 'Cette échéance est déjà traitée.']); + } + $this->logger->info('Comptabilité : échéance abandonnée', ['actor' => $actor->id(), 'template' => $templateId]); + } + + /** + * @param array $input + * @return array{0: RecurringTemplate, 1: \DateTimeImmutable} le modèle actif et le mois de son échéance atteinte + */ + private function dueOccurrence(int $templateId, array $input, \DateTimeImmutable $today): array + { + $template = $this->templates->find($templateId) ?? throw new RecurringTemplateNotFoundException(); + $errors = []; + $period = EntryInputPolicy::date($input['period'] ?? null, 'period', $errors); + if ($period === null || !$template->isActive || !$this->schedule->isScheduled($template, $period) || $this->schedule->dueOn($template, $period) > $today) { + throw new AccountingValidationException(['period' => 'Cette échéance n\'existe pas ou n\'est pas encore atteinte.']); + } + + return [$template, $period]; + } + + private static function text(mixed $value): string + { + return is_string($value) ? trim($value) : ''; + } + + private static function euros(int $cents): string + { + return intdiv($cents, 100) . ',' . str_pad((string) ($cents % 100), 2, '0', STR_PAD_LEFT); + } +} diff --git a/src/Accounting/Service/Recurring/RecurringTemplateService.php b/src/Accounting/Service/Recurring/RecurringTemplateService.php new file mode 100644 index 0000000..ecb0c54 --- /dev/null +++ b/src/Accounting/Service/Recurring/RecurringTemplateService.php @@ -0,0 +1,67 @@ + $input voir TemplateInputPolicy::parse + * @throws AccountingValidationException + */ + public function create(User $actor, array $input, ?\DateTimeImmutable $now = null): int + { + $this->access->assertAdmin($actor); + $id = $this->templates->insert($this->policy->parse($input), $actor->id(), $now ?? new \DateTimeImmutable()); + $this->logger->info('Comptabilité : modèle récurrent créé', ['actor' => $actor->id(), 'template' => $id]); + + return $id; + } + + /** + * @param array $input voir TemplateInputPolicy::parse + * @throws RecurringTemplateNotFoundException + * @throws AccountingValidationException + */ + public function update(User $actor, int $id, array $input): void + { + $this->access->assertAdmin($actor); + $draft = $this->policy->parse($input); + if (!$this->templates->update($id, $draft)) { + throw new RecurringTemplateNotFoundException(); + } + $this->logger->info('Comptabilité : modèle récurrent modifié', ['actor' => $actor->id(), 'template' => $id]); + } + + /** @return list */ + public function list(User $actor): array + { + $this->access->assertAdmin($actor); + + return $this->templates->listAll(self::LIST_LIMIT); + } +} diff --git a/src/Accounting/Service/Recurring/TemplateDraft.php b/src/Accounting/Service/Recurring/TemplateDraft.php new file mode 100644 index 0000000..a39d535 --- /dev/null +++ b/src/Accounting/Service/Recurring/TemplateDraft.php @@ -0,0 +1,28 @@ + $input `label`, `direction`, `category`, `paymentMethod`, `amount` (vide = variable), `voucherRef`, + * `periodicity`, `dueDay` (1 à 28), `firstMonth` (AAAA-MM), `isActive` + * @throws AccountingValidationException + */ + public function parse(array $input): TemplateDraft + { + $errors = []; + $label = EntryInputPolicy::line($input['label'] ?? null, 'label', EntryInputPolicy::LABEL_MAX, true, 'Le libellé est obligatoire.', $errors); + $direction = is_string($input['direction'] ?? null) ? Direction::tryFrom($input['direction']) : null; + $category = is_string($input['category'] ?? null) ? EntryCategory::tryFrom($input['category']) : null; + $method = is_string($input['paymentMethod'] ?? null) ? PaymentMethod::tryFrom($input['paymentMethod']) : null; + $periodicity = is_string($input['periodicity'] ?? null) ? Periodicity::tryFrom($input['periodicity']) : null; + $errors += $direction === null ? ['direction' => 'Choisissez argent reçu ou argent dépensé.'] : []; + $errors += $category === null ? ['category' => 'Choisissez une catégorie.'] : []; + $errors += $method === null ? ['paymentMethod' => 'Choisissez un moyen de paiement.'] : []; + $errors += $periodicity === null ? ['periodicity' => 'Choisissez la périodicité.'] : []; + if ($direction !== null && $category !== null && $category->direction() !== $direction) { + $errors['category'] = 'Cette catégorie ne correspond pas au sens de l\'écriture.'; + } + $amount = null; + $rawAmount = $input['amount'] ?? null; + if ($rawAmount !== null && $rawAmount !== '') { + $amount = $this->amounts->parse($rawAmount); + if ($amount === null) { + $errors['amount'] = 'Saisissez un montant en euros, par exemple 166,00, ou laissez vide s\'il varie.'; + } + } + $voucher = EntryInputPolicy::line($input['voucherRef'] ?? null, 'voucherRef', EntryInputPolicy::VOUCHER_MAX, false, '', $errors); + $day = self::day($input['dueDay'] ?? null); + if ($day === null) { + $errors['dueDay'] = 'Le jour d\'échéance est compris entre 1 et 28.'; + } + $firstMonth = self::month($input['firstMonth'] ?? null); + if ($firstMonth === null) { + $errors['firstMonth'] = 'Saisissez le premier mois, par exemple 2025-10.'; + } + if ($errors !== [] || $label === null || $direction === null || $category === null || $method === null || $periodicity === null || $day === null || $firstMonth === null) { + throw new AccountingValidationException($errors); + } + + return new TemplateDraft($label, $direction, $category, $method, $amount, $voucher, $periodicity, $day, $firstMonth, self::flag($input['isActive'] ?? true)); + } + + private static function day(mixed $value): ?int + { + $text = is_int($value) ? (string) $value : $value; + if (is_string($text) && preg_match('/\A(?:[1-9]|1[0-9]|2[0-8])\z/D', $text) === 1) { + return (int) $text; + } + + return null; + } + + private static function month(mixed $value): ?\DateTimeImmutable + { + if (is_string($value) && preg_match('/\A[0-9]{4}-(?:0[1-9]|1[0-2])\z/D', $value) === 1 && $value >= '1900-01' && $value <= '2999-12') { + return new \DateTimeImmutable($value . '-01'); + } + + return null; + } + + private static function flag(mixed $value): bool + { + return $value === true || $value === 1 || $value === '1' || $value === 'true'; + } +} diff --git a/src/Http/ExceptionTranslator.php b/src/Http/ExceptionTranslator.php index 9636b57..c2059f8 100644 --- a/src/Http/ExceptionTranslator.php +++ b/src/Http/ExceptionTranslator.php @@ -9,6 +9,7 @@ use App\Accounting\Exception\FiscalYearChangedException; use App\Accounting\Exception\FiscalYearLockedException; use App\Accounting\Exception\FiscalYearNotFoundException; +use App\Accounting\Exception\RecurringTemplateNotFoundException; use App\Accounting\Exception\StatementNotFoundException; use App\Association\Exception\AssociationBoardNotFoundException; use App\Association\Exception\AssociationBoardValidationException; @@ -78,6 +79,7 @@ public function translate(\Throwable $e): ?JsonResponse $e instanceof UserNotFoundException => new JsonResponse(['error' => 'Utilisateur introuvable.'], 404), $e instanceof FiscalYearNotFoundException => new JsonResponse(['error' => 'Exercice introuvable.'], 404), $e instanceof EntryNotFoundException => new JsonResponse(['error' => 'Écriture introuvable.'], 404), + $e instanceof RecurringTemplateNotFoundException => new JsonResponse(['error' => 'Modèle introuvable.'], 404), $e instanceof StatementNotFoundException => new JsonResponse(['error' => 'Relevé introuvable.'], 404), $e instanceof AssociationBoardNotFoundException => new JsonResponse(['error' => 'Poste introuvable.'], 404), $e instanceof AssociationTextNotFoundException => new JsonResponse(['error' => 'Texte introuvable.'], 404), diff --git a/templates/admin/accounting/index.php b/templates/admin/accounting/index.php index 3c719a8..a669697 100644 --- a/templates/admin/accounting/index.php +++ b/templates/admin/accounting/index.php @@ -51,7 +51,7 @@ -

Relevés de compte

+

Relevés de compte Écritures récurrentes

diff --git a/templates/admin/accounting/recurring.php b/templates/admin/accounting/recurring.php new file mode 100644 index 0000000..aa5acd8 --- /dev/null +++ b/templates/admin/accounting/recurring.php @@ -0,0 +1,182 @@ + $page voir RecurringView::page + * @var int|null $yearId + * @var \App\Account\Entity\UserRole $currentUserRole + */ +$adminTab = 'accounting'; +$form = $page['form']; +?> + + + + + + + Admin — Écritures récurrentes — RehearsalBox + + + + + +
+ + +

Écritures récurrentes

+

Le loyer, l'électricité, l'assurance… reviennent à date fixe. Chaque échéance attend ici votre confirmation : rien n'est écrit dans le livre tout seul.

+ +
+

À confirmer

+ +

Il n'y a pas d'exercice ouvert : créez-le dans la comptabilité pour confirmer des échéances.

+ +

Aucune échéance à confirmer pour l'instant.

+ +
    + +
  • +
    +
    + + +
    + + +
    + + + +
    + + +
    + + + +
    + + + + +
    + + +
    +
    +
  • + +
+ + +

Modèles

+ +

Aucun modèle : ajoutez le loyer, l'électricité…

+ +
    + +
  • +
    + + +
    + +
    + +
    +
  • + +
+ + +
+ + +
+
+
+ + + + + diff --git a/tests/Accounting/Http/AccountingRecurringApiTest.php b/tests/Accounting/Http/AccountingRecurringApiTest.php new file mode 100644 index 0000000..9ab1a51 --- /dev/null +++ b/tests/Accounting/Http/AccountingRecurringApiTest.php @@ -0,0 +1,172 @@ +makeUser('admin', UserRole::Admin); + $this->makeUser('musicien', UserRole::Musicien); + } + + /** @return array */ + private function body(array $override = []): array + { + return $override + ['label' => 'Loyer du local', 'direction' => 'depense', 'category' => 'loyer', 'paymentMethod' => 'virement', 'amount' => '498,00', 'voucherRef' => 'Virement loyer', 'periodicity' => 'trimestrielle', 'dueDay' => '5', 'firstMonth' => date('Y-m', strtotime('-5 months'))]; + } + + private function template(array $override = []): int + { + [$status, $body] = $this->call('admin', 'POST', '/api/admin/accounting/recurring', $this->body($override)); + self::assertSame(201, $status, $body); + + return (int) $this->json($body)['id']; + } + + private function year(): int + { + $start = date('Y-m-01', strtotime('-6 months')); + $end = date('Y-m-d', strtotime($start . ' +12 months -1 day')); + [$status, $body] = $this->call('admin', 'POST', '/api/admin/accounting/years', ['label' => 'Courant', 'startsOn' => $start, 'endsOn' => $end, 'openingBalance' => '0']); + self::assertSame(201, $status, $body); + + return (int) $this->json($body)['id']; + } + + private function period(): string + { + return date('Y-m-01', strtotime('-5 months')); + } + + private function entries(): int + { + return (int) $this->pdo->query('SELECT COUNT(*) FROM accounting_entries')->fetchColumn(); + } + + #[Test] + public function testAnAdministratorCreatesUpdatesAndConfirmsAnOccurrence(): void + { + $this->year(); + $id = $this->template(); + + [$status] = $this->call('admin', 'PUT', "/api/admin/accounting/recurring/{$id}", $this->body(['label' => 'Loyer révisé'])); + self::assertSame(200, $status); + [$status, $body] = $this->call('admin', 'POST', "/api/admin/accounting/recurring/{$id}/confirm", ['period' => $this->period()]); + + self::assertSame(201, $status, $body); + self::assertSame(1, $this->json($body)['number']); + self::assertSame('Loyer révisé', (string) $this->pdo->query('SELECT label FROM accounting_entries')->fetchColumn()); + [$status] = $this->call('admin', 'POST', "/api/admin/accounting/recurring/{$id}/confirm", ['period' => $this->period()]); + self::assertSame(422, $status, 'double clic'); + self::assertSame(1, $this->entries()); + } + + #[Test] + public function testIgnoringIsRecordedWithoutAnEntry(): void + { + $this->year(); + $id = $this->template(); + + [$status] = $this->call('admin', 'POST', "/api/admin/accounting/recurring/{$id}/ignore", ['period' => $this->period()]); + + self::assertSame(200, $status); + self::assertSame(0, $this->entries()); + self::assertSame('ignoree', (string) $this->pdo->query('SELECT status FROM accounting_recurring_occurrences')->fetchColumn()); + } + + /** @return iterable */ + public static function writes(): iterable + { + yield 'créer' => ['POST', '/api/admin/accounting/recurring']; + yield 'modifier' => ['PUT', '/api/admin/accounting/recurring/1']; + yield 'confirmer' => ['POST', '/api/admin/accounting/recurring/1/confirm']; + yield 'abandonner' => ['POST', '/api/admin/accounting/recurring/1/ignore']; + } + + #[Test] + #[DataProvider('writes')] + public function testAnonymousGets401AMusicianGets403AndAMissingCsrfIsRefused(string $method, string $path): void + { + $this->year(); + $id = $this->template(); + $path = str_replace('/1', "/{$id}", $path); + $body = $this->body(['period' => $this->period()]); + + self::assertSame(401, $this->call('anon', $method, $path, $body)[0]); + self::assertSame(403, $this->call('musicien', $method, $path, $body)[0]); + self::assertSame(403, $this->call('admin', $method, $path, $body, [], false)[0]); + self::assertSame(0, $this->entries()); + self::assertSame(1, (int) $this->pdo->query('SELECT COUNT(*) FROM accounting_recurring_templates')->fetchColumn()); + } + + #[Test] + public function testMalformedIdsAreForbiddenAndUnknownOnesAre404WithoutEchoingTheId(): void + { + $this->year(); + foreach (['0', '-1', 'abc', '1e0', '007', '99999999999999999999', '%00'] as $id) { + foreach (['confirm', 'ignore'] as $action) { + self::assertSame(403, $this->call('admin', 'POST', "/api/admin/accounting/recurring/{$id}/{$action}", ['period' => $this->period()])[0], "{$id} {$action}"); + } + self::assertSame(403, $this->call('admin', 'PUT', "/api/admin/accounting/recurring/{$id}", $this->body())[0], $id); + } + [$status, $body] = $this->call('admin', 'POST', '/api/admin/accounting/recurring/99999999/confirm', ['period' => $this->period()]); + self::assertSame(404, $status); + self::assertStringNotContainsString('99999999', $body); + self::assertSame(404, $this->call('admin', 'PUT', '/api/admin/accounting/recurring/99999999', $this->body())[0]); + } + + #[Test] + public function testInvalidInputIs422WithOneMessagePerField(): void + { + [$status, $body] = $this->call('admin', 'POST', '/api/admin/accounting/recurring', ['label' => '', 'dueDay' => '40']); + + self::assertSame(422, $status); + $fields = $this->json($body)['fields']; + self::assertArrayHasKey('label', $fields); + self::assertArrayHasKey('dueDay', $fields); + self::assertSame(0, (int) $this->pdo->query('SELECT COUNT(*) FROM accounting_recurring_templates')->fetchColumn()); + } + + #[Test] + public function testAFuturePeriodIsRefusedAndForgedFieldsAreIgnored(): void + { + $this->year(); + $id = $this->template(); + + [$status] = $this->call('admin', 'POST', "/api/admin/accounting/recurring/{$id}/confirm", ['period' => date('Y-m-01', strtotime('+2 years'))]); + self::assertSame(422, $status); + + $this->call('admin', 'POST', "/api/admin/accounting/recurring/{$id}/confirm", ['period' => $this->period(), 'label' => 'Piraté', 'direction' => 'recette', 'category' => 'cotisations', 'paymentMethod' => 'especes', 'entryNumber' => 99, 'createdBy' => 777]); + $row = $this->pdo->query('SELECT label, direction, category, payment_method, entry_number FROM accounting_entries')->fetch(\PDO::FETCH_ASSOC); + self::assertSame(['label' => 'Loyer du local', 'direction' => 'depense', 'category' => 'loyer', 'payment_method' => 'virement', 'entry_number' => 1], array_map(static fn ($v) => is_numeric($v) ? (int) $v : $v, $row)); + } + + #[Test] + public function testThePageIsForAdministratorsAndEscapesLabels(): void + { + $this->year(); + $this->template(['label' => '']); + + [$status, $html] = $this->call('admin', 'GET', '/admin/accounting/recurring'); + + self::assertSame(200, $status); + self::assertStringNotContainsString('