From fa1e7ce47c7a1d5fdce5185d260139bc1ed215c8 Mon Sep 17 00:00:00 2001 From: Ronan Lenouvel Date: Fri, 16 Oct 2026 14:17:37 +0200 Subject: [PATCH 1/7] =?UTF-8?q?=E2=9C=A8=20feat(Recurring):=20mod=C3=A8les?= =?UTF-8?q?=20d'=C3=A9critures=20r=C3=A9currentes,=20=C3=A9ch=C3=A9ances?= =?UTF-8?q?=20d=C3=A9cid=C3=A9es=20une=20seule=20fois,=20calendrier=20(#36?= =?UTF-8?q?2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../043_create_accounting_recurring.sql | 38 ++++++++ src/Accounting/Entity/Periodicity.php | 31 ++++++ src/Accounting/Entity/RecurringTemplate.php | 29 ++++++ .../RecurringTemplateNotFoundException.php | 14 +++ .../MysqlRecurringOccurrenceRepository.php | 46 +++++++++ .../MysqlRecurringTemplateRepository.php | 96 +++++++++++++++++++ ...RecurringOccurrenceRepositoryInterface.php | 18 ++++ .../RecurringTemplateRepositoryInterface.php | 22 +++++ .../Service/Recurring/PendingOccurrence.php | 18 ++++ .../Service/Recurring/RecurrenceSchedule.php | 49 ++++++++++ .../Recurring/RecurrenceScheduleTest.php | 79 +++++++++++++++ 11 files changed, 440 insertions(+) create mode 100644 database/migrations/043_create_accounting_recurring.sql create mode 100644 src/Accounting/Entity/Periodicity.php create mode 100644 src/Accounting/Entity/RecurringTemplate.php create mode 100644 src/Accounting/Exception/RecurringTemplateNotFoundException.php create mode 100644 src/Accounting/Repository/MysqlRecurringOccurrenceRepository.php create mode 100644 src/Accounting/Repository/MysqlRecurringTemplateRepository.php create mode 100644 src/Accounting/Repository/RecurringOccurrenceRepositoryInterface.php create mode 100644 src/Accounting/Repository/RecurringTemplateRepositoryInterface.php create mode 100644 src/Accounting/Service/Recurring/PendingOccurrence.php create mode 100644 src/Accounting/Service/Recurring/RecurrenceSchedule.php create mode 100644 tests/Accounting/Service/Recurring/RecurrenceScheduleTest.php 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/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 @@ +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/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/tests/Accounting/Service/Recurring/RecurrenceScheduleTest.php b/tests/Accounting/Service/Recurring/RecurrenceScheduleTest.php new file mode 100644 index 0000000..e31545c --- /dev/null +++ b/tests/Accounting/Service/Recurring/RecurrenceScheduleTest.php @@ -0,0 +1,79 @@ + */ + private function periods(RecurringTemplate $t, string $from, string $to): array + { + return array_map(static fn (\DateTimeImmutable $d): string => $d->format('Y-m'), (new RecurrenceSchedule())->periodsBetween($t, new \DateTimeImmutable($from), new \DateTimeImmutable($to))); + } + + #[Test] + public function testAQuarterlyTemplateIsDueEveryThreeMonthsFromItsFirstMonth(): void + { + self::assertSame(['2025-10', '2026-01', '2026-04', '2026-07'], $this->periods($this->template(Periodicity::Trimestrielle), '2025-10-01', '2026-09-30')); + } + + #[Test] + public function testAMonthlyTemplateIsDueEveryMonthAndAnAnnualOneOnce(): void + { + self::assertCount(12, $this->periods($this->template(Periodicity::Mensuelle), '2025-10-01', '2026-09-30')); + self::assertSame(['2025-10'], $this->periods($this->template(Periodicity::Annuelle), '2025-10-01', '2026-09-30')); + self::assertSame(['2026-10'], $this->periods($this->template(Periodicity::Annuelle), '2026-10-01', '2027-09-30')); + } + + #[Test] + public function testTheDueDayDecidesWhetherAMonthIsReachedYet(): void + { + $monthly = $this->template(Periodicity::Mensuelle, '2025-10-01', 5); + + self::assertSame(['2025-10'], $this->periods($monthly, '2025-10-01', '2025-10-05'), 'le 5 inclus'); + self::assertSame([], $this->periods($monthly, '2025-10-01', '2025-10-04')); + } + + #[Test] + public function testNothingIsDueBeforeTheFirstMonthOrOutsideTheWindow(): void + { + self::assertSame([], $this->periods($this->template(Periodicity::Mensuelle, '2026-03-01'), '2025-10-01', '2026-02-28')); + self::assertSame(['2026-04'], $this->periods($this->template(Periodicity::Trimestrielle), '2026-04-01', '2026-04-30')); + } + + #[Test] + public function testAScheduledMonthIsTheFirstOfAMonthOnTheRhythm(): void + { + $s = new RecurrenceSchedule(); + $quarterly = $this->template(Periodicity::Trimestrielle); + + self::assertTrue($s->isScheduled($quarterly, new \DateTimeImmutable('2026-01-01'))); + self::assertFalse($s->isScheduled($quarterly, new \DateTimeImmutable('2026-02-01'))); + self::assertFalse($s->isScheduled($quarterly, new \DateTimeImmutable('2026-01-15')), 'pas un premier du mois'); + self::assertFalse($s->isScheduled($quarterly, new \DateTimeImmutable('2025-07-01')), 'avant le premier mois'); + } + + #[Test] + public function testTheDueDateStaysInTheMonthWhateverItsLength(): void + { + $t = $this->template(Periodicity::Mensuelle, '2026-02-01', 28); + + self::assertSame('2026-02-28', (new RecurrenceSchedule())->dueOn($t, new \DateTimeImmutable('2026-02-01'))->format('Y-m-d')); + } +} From 8e9d4f6ef96daf3e338e7cb242fa80dffe87096a Mon Sep 17 00:00:00 2001 From: Ronan Lenouvel Date: Fri, 16 Oct 2026 14:17:37 +0200 Subject: [PATCH 2/7] =?UTF-8?q?=E2=9C=A8=20feat(Recurring):=20confirmation?= =?UTF-8?q?=20ou=20abandon=20d'une=20=C3=A9ch=C3=A9ance=20par=20le=20servi?= =?UTF-8?q?ce=20d'=C3=A9criture,=20dans=20la=20m=C3=AAme=20transaction=20(?= =?UTF-8?q?#362)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Accounting/Service/EntryService.php | 12 +- .../Recurring/RecurringOccurrenceService.php | 144 +++++++++++ .../Recurring/RecurringTemplateService.php | 67 ++++++ .../Service/Recurring/TemplateDraft.php | 28 +++ .../Service/Recurring/TemplateInputPolicy.php | 89 +++++++ .../Accounting/Service/AccountingTestCase.php | 11 + .../Recurring/RecurringServicesTest.php | 226 ++++++++++++++++++ .../Recurring/TemplateInputPolicyTest.php | 98 ++++++++ 8 files changed, 672 insertions(+), 3 deletions(-) create mode 100644 src/Accounting/Service/Recurring/RecurringOccurrenceService.php create mode 100644 src/Accounting/Service/Recurring/RecurringTemplateService.php create mode 100644 src/Accounting/Service/Recurring/TemplateDraft.php create mode 100644 src/Accounting/Service/Recurring/TemplateInputPolicy.php create mode 100644 tests/Accounting/Service/Recurring/RecurringServicesTest.php create mode 100644 tests/Accounting/Service/Recurring/TemplateInputPolicyTest.php 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/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/tests/Accounting/Service/AccountingTestCase.php b/tests/Accounting/Service/AccountingTestCase.php index 3b1d4e8..d0acd5a 100644 --- a/tests/Accounting/Service/AccountingTestCase.php +++ b/tests/Accounting/Service/AccountingTestCase.php @@ -7,6 +7,12 @@ use App\Accounting\Repository\MysqlEntryRepository; use App\Accounting\Repository\MysqlFiscalYearRepository; use App\Accounting\Repository\MysqlStatementRepository; +use App\Accounting\Repository\MysqlRecurringOccurrenceRepository; +use App\Accounting\Repository\MysqlRecurringTemplateRepository; +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\Service\StatementFileStore; use App\Accounting\Service\StatementInputPolicy; use App\Accounting\Service\StatementService; @@ -33,6 +39,8 @@ abstract class AccountingTestCase extends RepositoryTestCase protected MysqlFiscalYearRepository $yearRepository; protected MysqlEntryRepository $entryRepository; protected StatementService $statements; + protected RecurringTemplateService $templates; + protected RecurringOccurrenceService $occurrences; protected string $statementDir; protected User $admin; protected User $musician; @@ -50,6 +58,9 @@ protected function setUp(): void $this->yearRepository = new MysqlFiscalYearRepository($this->pdo); $this->entryRepository = new MysqlEntryRepository($this->pdo); $this->buildServices($this->yearRepository, $this->entryRepository, $this->pdo); + $templates = new MysqlRecurringTemplateRepository($this->pdo); + $this->templates = new RecurringTemplateService(new AdminAccess(), new TemplateInputPolicy(new AccountingAmountParser()), $templates, $this->logger()); + $this->occurrences = new RecurringOccurrenceService(new AdminAccess(), $templates, new MysqlRecurringOccurrenceRepository($this->pdo), $this->yearRepository, $this->entries, new RecurrenceSchedule(), $this->logger()); $this->statementDir = sys_get_temp_dir() . '/rb-acc-statements-' . bin2hex(random_bytes(4)); $this->statements = new StatementService(new AdminAccess(), new StatementInputPolicy(new AccountingAmountParser()), $this->yearRepository, $this->entryRepository, new MysqlStatementRepository($this->pdo), new StatementFileStore($this->statementDir), new TransactionRunner($this->pdo), $this->logger()); $this->admin = $this->users->save(new User(0, 'admin@rehearsalbox.test', 'hash', 'Admin', UserRole::Admin, true, 0, null)); diff --git a/tests/Accounting/Service/Recurring/RecurringServicesTest.php b/tests/Accounting/Service/Recurring/RecurringServicesTest.php new file mode 100644 index 0000000..5146ac6 --- /dev/null +++ b/tests/Accounting/Service/Recurring/RecurringServicesTest.php @@ -0,0 +1,226 @@ +today = new \DateTimeImmutable('2026-01-20'); + } + + /** @param array $override */ + private function template(array $override = []): int + { + return $this->templates->create($this->admin, $override + ['label' => 'Loyer du local', 'direction' => 'depense', 'category' => 'loyer', 'paymentMethod' => 'virement', 'amount' => '498,00', 'voucherRef' => 'Virement loyer', 'periodicity' => 'trimestrielle', 'dueDay' => '5', 'firstMonth' => '2025-10'], $this->clock); + } + + private function year(): \App\Accounting\Entity\FiscalYear + { + return $this->yearRepository->find($this->openYear()) ?? throw new \LogicException(); + } + + private function entryCount(): int + { + return (int) $this->pdo->query('SELECT COUNT(*) FROM accounting_entries')->fetchColumn(); + } + + #[Test] + public function testOnlyAdministratorsManageTemplatesAndTheirOccurrences(): void + { + $id = $this->template(); + $year = $this->year(); + $denied = 0; + foreach ([$this->musician, $this->gone] as $actor) { + foreach ([ + fn () => $this->templates->create($actor, []), + fn () => $this->templates->update($actor, $id, []), + fn () => $this->templates->list($actor), + fn () => $this->occurrences->pending($actor, $year, $this->today), + fn () => $this->occurrences->confirm($actor, $id, ['period' => '2025-10-01'], $this->today), + fn () => $this->occurrences->ignore($actor, $id, ['period' => '2025-10-01'], $this->today), + ] as $call) { + try { + $call(); + } catch (AccessDeniedException) { + ++$denied; + } + } + } + self::assertSame(12, $denied); + self::assertSame(0, $this->entryCount()); + self::assertSame(0, (int) $this->pdo->query('SELECT COUNT(*) FROM accounting_recurring_occurrences')->fetchColumn()); + } + + #[Test] + public function testPendingListsReachedUndecidedDuesOfActiveTemplatesOnly(): void + { + $this->template(); + $this->template(['label' => 'Ancien', 'isActive' => false]); + $year = $this->year(); + + $pending = $this->occurrences->pending($this->admin, $year, $this->today); + + self::assertSame(['2025-10-05', '2026-01-05'], array_map(static fn ($p) => $p->dueOn->format('Y-m-d'), $pending), 'avril et juillet ne sont pas atteints'); + self::assertSame('Loyer du local', $pending[0]->template->label); + self::assertSame(0, $this->entryCount(), 'lister ne crée jamais une écriture'); + } + + #[Test] + public function testConfirmingCreatesTheEntryThroughTheLedgerRulesAndRemovesTheDueFromTheList(): void + { + $id = $this->template(); + $year = $this->year(); + + $entry = $this->occurrences->confirm($this->admin, $id, ['period' => '2025-10-01'], $this->today, $this->clock); + + self::assertSame(1, $entry->number); + self::assertSame(49800, $entry->amountCents); + self::assertSame('2025-10-05', $entry->occurredOn->format('Y-m-d')); + self::assertSame('Loyer du local', $entry->label); + self::assertSame(['2026-01-05'], array_map(static fn ($p) => $p->dueOn->format('Y-m-d'), $this->occurrences->pending($this->admin, $year, $this->today))); + self::assertSame($entry->id, (int) $this->pdo->query('SELECT entry_id FROM accounting_recurring_occurrences')->fetchColumn()); + } + + #[Test] + public function testAVariableAmountMustBeTypedAtEachDueAndAFixedOneCanBeOverridden(): void + { + $electricity = $this->template(['label' => 'EDF', 'category' => 'electricite', 'amount' => '', 'periodicity' => 'mensuelle']); + $this->year(); + + try { + $this->occurrences->confirm($this->admin, $electricity, ['period' => '2025-10-01'], $this->today, $this->clock); + self::fail('montant attendu'); + } catch (AccountingValidationException $e) { + self::assertArrayHasKey('amount', $e->fields()); + } + self::assertSame(0, $this->entryCount()); + self::assertSame(0, (int) $this->pdo->query('SELECT COUNT(*) FROM accounting_recurring_occurrences')->fetchColumn(), 'rien n\'est décidé si l\'écriture échoue'); + + $entry = $this->occurrences->confirm($this->admin, $electricity, ['period' => '2025-10-01', 'amount' => '63,40', 'voucherRef' => 'Facture EDF 10'], $this->today, $this->clock); + self::assertSame(6340, $entry->amountCents); + $rent = $this->template(); + self::assertSame(51000, $this->occurrences->confirm($this->admin, $rent, ['period' => '2025-10-01', 'amount' => '510'], $this->today, $this->clock)->amountCents); + } + + #[Test] + public function testAnOccurrenceIsDecidedOnlyOnceWhateverTheWay(): void + { + $id = $this->template(); + $this->year(); + $this->occurrences->confirm($this->admin, $id, ['period' => '2025-10-01'], $this->today, $this->clock); + + foreach ([ + fn () => $this->occurrences->confirm($this->admin, $id, ['period' => '2025-10-01'], $this->today, $this->clock), + fn () => $this->occurrences->ignore($this->admin, $id, ['period' => '2025-10-01'], $this->today, $this->clock), + ] as $second) { + try { + $second(); + self::fail('déjà traitée'); + } catch (AccountingValidationException $e) { + self::assertArrayHasKey('period', $e->fields()); + } + } + self::assertSame(1, $this->entryCount(), 'le double clic ne crée pas une deuxième écriture (et son numéro est rendu)'); + self::assertSame(2, $this->yearRepository->find((int) $this->pdo->query('SELECT fiscal_year_id FROM accounting_entries')->fetchColumn())?->nextEntryNumber); + } + + #[Test] + public function testIgnoringRecordsTheDecisionWithoutAnyEntry(): void + { + $id = $this->template(); + $year = $this->year(); + + $this->occurrences->ignore($this->admin, $id, ['period' => '2025-10-01'], $this->today, $this->clock); + + self::assertSame(0, $this->entryCount()); + self::assertSame(['2026-01-05'], array_map(static fn ($p) => $p->dueOn->format('Y-m-d'), $this->occurrences->pending($this->admin, $year, $this->today))); + } + + #[Test] + public function testAFutureOffRhythmOrMalformedDueIsRefused(): void + { + $id = $this->template(); + $this->year(); + foreach (['2026-04-01', '2025-11-01', '2025-07-01', '2025-10-05', 'hier', '', '2025-13-01'] as $period) { + try { + $this->occurrences->confirm($this->admin, $id, ['period' => $period], $this->today, $this->clock); + self::fail("refus attendu pour {$period}"); + } catch (AccountingValidationException $e) { + self::assertArrayHasKey('period', $e->fields(), $period); + } + } + self::assertSame(0, $this->entryCount()); + } + + #[Test] + public function testAnInactiveOrUnknownTemplateCannotBeConfirmed(): void + { + $inactive = $this->template(['isActive' => false]); + $this->year(); + try { + $this->occurrences->confirm($this->admin, $inactive, ['period' => '2025-10-01'], $this->today, $this->clock); + self::fail('refus attendu'); + } catch (AccountingValidationException) { + } + $this->expectException(RecurringTemplateNotFoundException::class); + $this->occurrences->confirm($this->admin, 99999999, ['period' => '2025-10-01'], $this->today, $this->clock); + } + + #[Test] + public function testTheEntryDateMustFallInAnOpenYearAndAClosedYearHasNoPendingList(): void + { + $id = $this->template(); + $year = $this->year(); + try { + $this->occurrences->confirm($this->admin, $id, ['period' => '2025-10-01', 'occurredOn' => '2024-01-01'], $this->today, $this->clock); + self::fail('refus attendu'); + } catch (AccountingValidationException $e) { + self::assertArrayHasKey('occurredOn', $e->fields()); + } + self::assertSame(0, $this->entryCount()); + $this->years->close($this->admin, $year->id, 0, $this->clock); + $closed = $this->yearRepository->find($year->id) ?? throw new \LogicException(); + self::assertSame([], $this->occurrences->pending($this->admin, $closed, $this->today)); + } + + #[Test] + public function testEditingATemplateNeverRewritesWhatWasAlreadyDecided(): void + { + $id = $this->template(); + $this->year(); + $entry = $this->occurrences->confirm($this->admin, $id, ['period' => '2025-10-01'], $this->today, $this->clock); + + $this->templates->update($this->admin, $id, ['label' => 'Loyer révisé', 'direction' => 'depense', 'category' => 'loyer', 'paymentMethod' => 'virement', 'amount' => '510,00', 'voucherRef' => 'V', 'periodicity' => 'trimestrielle', 'dueDay' => '5', 'firstMonth' => '2025-10']); + + self::assertSame(49800, $this->entryRepository->find($entry->id)?->amountCents); + self::assertSame('Loyer du local', $this->entryRepository->find($entry->id)?->label); + $this->expectException(RecurringTemplateNotFoundException::class); + $this->templates->update($this->admin, 99999999, ['label' => 'x', 'direction' => 'depense', 'category' => 'loyer', 'paymentMethod' => 'virement', 'periodicity' => 'mensuelle', 'dueDay' => '5', 'firstMonth' => '2025-10']); + } + + #[Test] + public function testTheLogReceivesIdentifiersNeverLabelsOrAmounts(): void + { + $id = $this->template(['label' => 'Loyer secret-FR76']); + $this->year(); + $this->occurrences->confirm($this->admin, $id, ['period' => '2025-10-01'], $this->today, $this->clock); + + $log = implode("\n", $this->logged); + self::assertStringContainsString('échéance confirmée', $log); + self::assertStringNotContainsString('FR76', $log); + self::assertStringNotContainsString('49800', $log); + } +} diff --git a/tests/Accounting/Service/Recurring/TemplateInputPolicyTest.php b/tests/Accounting/Service/Recurring/TemplateInputPolicyTest.php new file mode 100644 index 0000000..c9f936f --- /dev/null +++ b/tests/Accounting/Service/Recurring/TemplateInputPolicyTest.php @@ -0,0 +1,98 @@ + $override */ + private function input(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' => '2025-10']; + } + + private function parse(array $input): \App\Accounting\Service\Recurring\TemplateDraft + { + return (new TemplateInputPolicy(new AccountingAmountParser()))->parse($input); + } + + #[Test] + public function testAFixedTemplateIsReadInCentsAndAVariableOneHasNoAmount(): void + { + $fixed = $this->parse($this->input()); + self::assertSame(49800, $fixed->amountCents); + self::assertSame(Periodicity::Trimestrielle, $fixed->periodicity); + self::assertSame(5, $fixed->dueDay); + self::assertSame('2025-10-01', $fixed->firstMonth->format('Y-m-d')); + self::assertTrue($fixed->isActive); + + foreach (['', null] as $empty) { + self::assertNull($this->parse($this->input(['amount' => $empty, 'category' => 'electricite']))->amountCents); + } + } + + /** @return iterable, 1: string}> */ + public static function invalid(): iterable + { + yield 'libellé vide' => [['label' => ' '], 'label']; + yield 'libellé en tableau' => [['label' => ['x']], 'label']; + yield 'sens inconnu' => [['direction' => 'autre'], 'direction']; + yield 'catégorie d\'un autre sens' => [['category' => 'cotisations'], 'category']; + yield 'moyen inconnu' => [['paymentMethod' => 'bitcoin'], 'paymentMethod']; + yield 'périodicité inconnue' => [['periodicity' => 'hebdomadaire'], 'periodicity']; + yield 'montant négatif' => [['amount' => '-5'], 'amount']; + yield 'montant nul' => [['amount' => '0'], 'amount']; + yield 'montant exposant' => [['amount' => '1e3'], 'amount']; + yield 'jour 0' => [['dueDay' => '0'], 'dueDay']; + yield 'jour 29' => [['dueDay' => '29'], 'dueDay']; + yield 'jour 31' => [['dueDay' => 31], 'dueDay']; + yield 'jour texte' => [['dueDay' => '5a'], 'dueDay']; + yield 'jour zéro initial' => [['dueDay' => '05'], 'dueDay']; + yield 'mois sans tiret' => [['firstMonth' => '202510'], 'firstMonth']; + yield 'mois 13' => [['firstMonth' => '2025-13'], 'firstMonth']; + yield 'mois avec jour' => [['firstMonth' => '2025-10-05'], 'firstMonth']; + yield 'pièce trop longue' => [['voucherRef' => str_repeat('x', 101)], 'voucherRef']; + } + + /** @param array $override */ + #[Test] + #[DataProvider('invalid')] + public function testAnInvalidFieldIsReportedByName(array $override, string $field): void + { + try { + $this->parse($this->input($override)); + self::fail('refus attendu'); + } catch (AccountingValidationException $e) { + self::assertArrayHasKey($field, $e->fields()); + } + } + + #[Test] + public function testAllErrorsComeBackTogether(): void + { + try { + $this->parse(['label' => '', 'dueDay' => '99']); + self::fail('refus attendu'); + } catch (AccountingValidationException $e) { + self::assertGreaterThanOrEqual(5, count($e->fields())); + } + } + + #[Test] + public function testTheActiveFlagIsStrict(): void + { + self::assertFalse($this->parse($this->input(['isActive' => false]))->isActive); + self::assertFalse($this->parse($this->input(['isActive' => 'oui']))->isActive); + self::assertTrue($this->parse($this->input(['isActive' => '1']))->isActive); + } +} From 2738c73a95bdfbf7f315f80306ce37800667c725 Mon Sep 17 00:00:00 2001 From: Ronan Lenouvel Date: Fri, 16 Oct 2026 14:17:37 +0200 Subject: [PATCH 3/7] =?UTF-8?q?=E2=9C=A8=20feat(Recurring):=20page=20et=20?= =?UTF-8?q?API=20des=20=C3=A9critures=20r=C3=A9currentes=20(#362)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/routes.php | 7 + config/services.php | 47 +++++ .../Controller/Api/RecurringApiController.php | 61 +++++++ .../Controller/RecurringPageController.php | 49 +++++ src/Accounting/Presenter/RecurringView.php | 69 +++++++ src/Http/ExceptionTranslator.php | 2 + .../Http/AccountingRecurringApiTest.php | 172 ++++++++++++++++++ 7 files changed, 407 insertions(+) create mode 100644 src/Accounting/Controller/Api/RecurringApiController.php create mode 100644 src/Accounting/Controller/RecurringPageController.php create mode 100644 src/Accounting/Presenter/RecurringView.php create mode 100644 tests/Accounting/Http/AccountingRecurringApiTest.php 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/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/Presenter/RecurringView.php b/src/Accounting/Presenter/RecurringView.php new file mode 100644 index 0000000..d5bdbbd --- /dev/null +++ b/src/Accounting/Presenter/RecurringView.php @@ -0,0 +1,69 @@ + $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/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/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(' + + From 3be76bd5f00fedb4a7d47931e1a1c0b188199725 Mon Sep 17 00:00:00 2001 From: Ronan Lenouvel Date: Fri, 16 Oct 2026 14:17:37 +0200 Subject: [PATCH 5/7] =?UTF-8?q?=E2=9C=A8=20feat(Recurring):=20confirmation?= =?UTF-8?q?,=20abandon=20et=20mod=C3=A8les=20depuis=20l'=C3=A9cran,=20requ?= =?UTF-8?q?=C3=AAtes=20test=C3=A9es=20(#362)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/accounting/accounting-requests.js | 26 ++++ .../accounting/accounting-requests.test.js | 26 +++- .../js/admin/accounting/admin-recurring.js | 121 ++++++++++++++++++ public/assets/js/app.js | 2 + 4 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 public/assets/js/admin/accounting/admin-recurring.js 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(); From 3dcdb8217b7d162abb9356e4d540321446291a27 Mon Sep 17 00:00:00 2001 From: Ronan Lenouvel Date: Fri, 16 Oct 2026 14:17:37 +0200 Subject: [PATCH 6/7] =?UTF-8?q?=E2=9C=85=20test(IdorMatrix):=20refus=20des?= =?UTF-8?q?=20routes=20des=20=C3=A9critures=20r=C3=A9currentes=20(#362)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/Security/IdorMatrixTest.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/Security/IdorMatrixTest.php b/tests/Security/IdorMatrixTest.php index 428704f..6fe4b9f 100644 --- a/tests/Security/IdorMatrixTest.php +++ b/tests/Security/IdorMatrixTest.php @@ -207,6 +207,10 @@ public static function deniedAccessProvider(): iterable ['POST', '/api/admin/accounting/years', ['label' => 'Piraté', 'startsOn' => '2025-10-01', 'endsOn' => '2026-09-30', 'openingBalance' => '0'], ['anon', 'stranger', 'memberA', 'managerA', 'outsiderB', 'dual']], ['POST', '/api/admin/accounting/years/1/entries', ['occurredOn' => '2025-11-05', 'label' => 'Piraté', 'amount' => '1', 'direction' => 'recette', 'category' => 'cotisations', 'paymentMethod' => 'virement'], ['anon', 'stranger', 'memberA', 'managerA', 'outsiderB', 'dual']], ['POST', '/api/admin/accounting/statements', ['__upload' => true, 'fiscalYearId' => '1', 'periodStart' => '2025-10-01', 'periodEnd' => '2025-10-31'], ['anon', 'stranger', 'memberA', 'managerA', 'outsiderB', 'dual']], + ['POST', '/api/admin/accounting/recurring', ['label' => 'Piraté', 'direction' => 'depense', 'category' => 'loyer', 'paymentMethod' => 'virement', 'periodicity' => 'mensuelle', 'dueDay' => '5', 'firstMonth' => '2025-10'], ['anon', 'stranger', 'memberA', 'managerA', 'outsiderB', 'dual']], + ['PUT', '/api/admin/accounting/recurring/1', ['label' => 'Piraté', 'direction' => 'depense', 'category' => 'loyer', 'paymentMethod' => 'virement', 'periodicity' => 'mensuelle', 'dueDay' => '5', 'firstMonth' => '2025-10'], ['anon', 'stranger', 'memberA', 'managerA', 'outsiderB', 'dual']], + ['POST', '/api/admin/accounting/recurring/1/confirm', ['period' => '2025-10-01'], ['anon', 'stranger', 'memberA', 'managerA', 'outsiderB', 'dual']], + ['POST', '/api/admin/accounting/recurring/1/ignore', ['period' => '2025-10-01'], ['anon', 'stranger', 'memberA', 'managerA', 'outsiderB', 'dual']], ['POST', '/api/admin/accounting/years/1/close', ['revision' => 0], ['anon', 'stranger', 'memberA', 'managerA', 'outsiderB', 'dual']], ['POST', '/api/admin/accounting/years/1/approve', ['approvedOn' => '2026-11-01'], ['anon', 'stranger', 'memberA', 'managerA', 'outsiderB', 'dual']], ['POST', '/api/admin/accounting/entries/1/reverse', ['reason' => 'Piraté'], ['anon', 'stranger', 'memberA', 'managerA', 'outsiderB', 'dual']], @@ -494,13 +498,14 @@ public function testAccountingPageIsForAdministratorsOnly(): void #[Test] public function testStatementPagesAndDownloadAreForAdministratorsOnly(): void { - foreach (['/admin/accounting/statements', '/admin/accounting/statements/1/file'] as $path) { + foreach (['/admin/accounting/statements', '/admin/accounting/statements/1/file', '/admin/accounting/recurring'] as $path) { self::assertSame(302, $this->call('anon', 'GET', $path)[0], "anonyme {$path}"); foreach (['stranger', 'memberA', 'managerA', 'outsiderB', 'dual'] as $actor) { self::assertSame(403, $this->call($actor, 'GET', $path)[0], "{$actor} {$path}"); } } self::assertSame(200, $this->call('admin', 'GET', '/admin/accounting/statements')[0]); + self::assertSame(200, $this->call('admin', 'GET', '/admin/accounting/recurring')[0]); self::assertSame(404, $this->call('admin', 'GET', '/admin/accounting/statements/' . self::MISSING_ID . '/file')[0]); foreach (['0', '-1', 'abc', '1e0', '007', '99999999999999999999', '%00'] as $malformed) { self::assertSame(403, $this->call('admin', 'GET', "/admin/accounting/statements/{$malformed}/file")[0], $malformed); From 9fd822390b87281ae673ab7e13b69ebe79875ced Mon Sep 17 00:00:00 2001 From: Ronan Lenouvel Date: Fri, 16 Oct 2026 14:17:37 +0200 Subject: [PATCH 7/7] =?UTF-8?q?=F0=9F=93=96=20docs(Accounting):=20=C3=A9cr?= =?UTF-8?q?itures=20r=C3=A9currentes=20(#362)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/architecture.md | 7 +++++++ 1 file changed, 7 insertions(+) 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.