From 670d96873ea9a4fcd4f800bc90f295260cc6878a Mon Sep 17 00:00:00 2001 From: Marien Fressinaud Date: Tue, 7 Nov 2023 16:36:40 +0100 Subject: [PATCH 1/2] Allow to set a billing interval in contracts --- ...1107141114AddBillingIntervalToContract.php | 42 +++++++++++++++++++ .../Organizations/ContractsController.php | 5 +++ src/Entity/Contract.php | 15 +++++++ .../organizations/contracts/new.html.twig | 36 ++++++++++++++++ .../organizations/contracts/show.html.twig | 6 +++ .../Organizations/ContractsControllerTest.php | 3 ++ tests/Factory/ContractFactory.php | 1 + translations/messages+intl-icu.en_GB.yaml | 3 ++ translations/messages+intl-icu.fr_FR.yaml | 3 ++ 9 files changed, 114 insertions(+) create mode 100644 migrations/Version20231107141114AddBillingIntervalToContract.php diff --git a/migrations/Version20231107141114AddBillingIntervalToContract.php b/migrations/Version20231107141114AddBillingIntervalToContract.php new file mode 100644 index 00000000..7358ad80 --- /dev/null +++ b/migrations/Version20231107141114AddBillingIntervalToContract.php @@ -0,0 +1,42 @@ +connection->getDatabasePlatform(); + if ($dbPlatform instanceof PostgreSQLPlatform) { + $this->addSql('ALTER TABLE contract ADD billing_interval INT DEFAULT 0 NOT NULL'); + } elseif ($dbPlatform instanceof MariaDBPlatform) { + $this->addSql('ALTER TABLE contract ADD billing_interval INT DEFAULT 0 NOT NULL'); + } + } + + public function down(Schema $schema): void + { + $dbPlatform = $this->connection->getDatabasePlatform(); + if ($dbPlatform instanceof PostgreSQLPlatform) { + $this->addSql('ALTER TABLE contract DROP billing_interval'); + } elseif ($dbPlatform instanceof MariaDBPlatform) { + $this->addSql('ALTER TABLE contract DROP billing_interval'); + } + } +} diff --git a/src/Controller/Organizations/ContractsController.php b/src/Controller/Organizations/ContractsController.php index e0cd8303..f06f8121 100644 --- a/src/Controller/Organizations/ContractsController.php +++ b/src/Controller/Organizations/ContractsController.php @@ -78,6 +78,7 @@ public function new( 'maxHours' => 10, 'startAt' => $startAt, 'endAt' => $endAt, + 'billingInterval' => 0, 'notes' => '', ]); } @@ -96,6 +97,7 @@ public function create( $maxHours = $request->request->getInt('maxHours'); $startAt = $request->request->getString('startAt'); $endAt = $request->request->getString('endAt'); + $billingInterval = $request->request->getInt('billingInterval'); $notes = $request->request->getString('notes'); $csrfToken = $request->request->getString('_csrf_token'); @@ -109,6 +111,7 @@ public function create( 'maxHours' => $maxHours, 'startAt' => $startAt, 'endAt' => $endAt, + 'billingInterval' => $billingInterval, 'notes' => $notes, 'error' => $translator->trans('csrf.invalid', [], 'errors'), ]); @@ -118,6 +121,7 @@ public function create( $contract->setOrganization($organization); $contract->setName($name); $contract->setMaxHours($maxHours); + $contract->setBillingInterval($billingInterval); $contract->setNotes($notes); if ($startAt) { @@ -136,6 +140,7 @@ public function create( 'maxHours' => $maxHours, 'startAt' => $startAt, 'endAt' => $endAt, + 'billingInterval' => $billingInterval, 'notes' => $notes, 'errors' => Utils\ConstraintErrorsFormatter::format($errors), ]); diff --git a/src/Entity/Contract.php b/src/Entity/Contract.php index 95b4b77d..7baf741c 100644 --- a/src/Entity/Contract.php +++ b/src/Entity/Contract.php @@ -97,6 +97,9 @@ class Contract implements MetaEntityInterface, ActivityRecordableInterface #[ORM\OneToMany(mappedBy: 'contract', targetEntity: TimeSpent::class)] private Collection $timeSpents; + #[ORM\Column(options: ["default" => 0])] + private ?int $billingInterval = null; + public function __construct() { $this->tickets = new ArrayCollection(); @@ -319,4 +322,16 @@ public function removeTimeSpent(TimeSpent $timeSpent): static return $this; } + + public function getBillingInterval(): ?int + { + return $this->billingInterval; + } + + public function setBillingInterval(int $billingInterval): static + { + $this->billingInterval = $billingInterval; + + return $this; + } } diff --git a/templates/organizations/contracts/new.html.twig b/templates/organizations/contracts/new.html.twig index 654b2151..0d71344b 100644 --- a/templates/organizations/contracts/new.html.twig +++ b/templates/organizations/contracts/new.html.twig @@ -145,6 +145,42 @@ /> +
+ + +

+ {{ 'contracts.new.billing_interval.caption' | trans }} +

+ + {% if errors.billingInterval is defined %} + + {% endif %} + + +
+
+ {% if contract.billingInterval %} +

+ {{ 'contracts.show.billing_interval' | trans({ count: contract.billingInterval }) }} +

+ {% endif %} +
{{ 'contracts.hours_consumed' | trans({ 'hours': contract.consumedMinutes | formatMinutes, 'maxHours': contract.maxHours }) | raw }} diff --git a/tests/Controller/Organizations/ContractsControllerTest.php b/tests/Controller/Organizations/ContractsControllerTest.php index e7b73240..983dc993 100644 --- a/tests/Controller/Organizations/ContractsControllerTest.php +++ b/tests/Controller/Organizations/ContractsControllerTest.php @@ -138,6 +138,7 @@ public function testPostCreateCreatesAContractAndRedirects(): void $maxHours = 10; $startAt = new \DateTimeImmutable('2023-09-01'); $endAt = new \DateTimeImmutable('2023-12-31'); + $billingInterval = 30; $notes = 'Some notes'; $this->assertSame(0, ContractFactory::count()); @@ -148,6 +149,7 @@ public function testPostCreateCreatesAContractAndRedirects(): void 'maxHours' => $maxHours, 'startAt' => $startAt->format('Y-m-d'), 'endAt' => $endAt->format('Y-m-d'), + 'billingInterval' => $billingInterval, 'notes' => $notes, ]); @@ -160,6 +162,7 @@ public function testPostCreateCreatesAContractAndRedirects(): void $this->assertEquals($startAt, $contract->getStartAt()); $expectedEndAt = $endAt->modify('23:59:59'); $this->assertEquals($expectedEndAt, $contract->getEndAt()); + $this->assertSame($billingInterval, $contract->getBillingInterval()); $this->assertSame($notes, $contract->getNotes()); } diff --git a/tests/Factory/ContractFactory.php b/tests/Factory/ContractFactory.php index ca366cdc..d25d5039 100644 --- a/tests/Factory/ContractFactory.php +++ b/tests/Factory/ContractFactory.php @@ -65,6 +65,7 @@ protected function getDefaults(): array 'startAt' => $startAt, 'endAt' => $endAt, 'maxHours' => self::faker()->numberBetween(1, 9000), + 'billingInterval' => 0, 'notes' => '', ]; } diff --git a/translations/messages+intl-icu.en_GB.yaml b/translations/messages+intl-icu.en_GB.yaml index acbcfac8..6c5fb115 100644 --- a/translations/messages+intl-icu.en_GB.yaml +++ b/translations/messages+intl-icu.en_GB.yaml @@ -56,9 +56,12 @@ contracts.index.number: '{count, plural, =0 {No contracts} one {1 contract} othe contracts.index.title: Contracts contracts.max_hours: 'Max number of hours' contracts.name: Name +contracts.new.billing_interval: 'Billing interval in minutes' +contracts.new.billing_interval.caption: 'The time spent on tickets will be rounded up for billing purposes (e.g. for 15 minutes of time spent, 30 minutes will be deducted for a corresponding billing interval).' contracts.new.submit: 'Create the contract' contracts.new.title: 'New contract' contracts.notes: 'Private notes' +contracts.show.billing_interval: 'Billing interval: {count, plural, one {1 minute} other {# minutes}}' contracts.show.notes_confidential: confidential contracts.start_at: 'Start on' contracts.status: Status diff --git a/translations/messages+intl-icu.fr_FR.yaml b/translations/messages+intl-icu.fr_FR.yaml index 46f5c115..d55e41e2 100644 --- a/translations/messages+intl-icu.fr_FR.yaml +++ b/translations/messages+intl-icu.fr_FR.yaml @@ -56,9 +56,12 @@ contracts.index.number: '{count, plural, =0 {Aucun contrat} one {1 contrat} othe contracts.index.title: Contrats contracts.max_hours: 'Nombre max. d’heures' contracts.name: Nom +contracts.new.billing_interval: 'Intervalle de facturation en minutes' +contracts.new.billing_interval.caption: 'Le temps passé sur les tickets sera arrondi pour le décompte de la facturation (ex. pour 15 minutes de temps passés, 30 minutes seront décomptés pour un intervalle de facturation correspondant).' contracts.new.submit: 'Créer le contrat' contracts.new.title: 'Nouveau contrat' contracts.notes: 'Notes privées' +contracts.show.billing_interval: "Intervalle de facturation\_: {count, plural, one {1 minute} other {# minutes}}" contracts.show.notes_confidential: confidentielles contracts.start_at: 'Démarre le' contracts.status: Statut From 95e5a278318345960977e6499d9a108202ff5522 Mon Sep 17 00:00:00 2001 From: Marien Fressinaud Date: Tue, 7 Nov 2023 17:45:23 +0100 Subject: [PATCH 2/2] Calculate the charged spent time based on billing interval --- src/Controller/Tickets/MessagesController.php | 8 ++++- .../Tickets/MessagesControllerTest.php | 34 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/Controller/Tickets/MessagesController.php b/src/Controller/Tickets/MessagesController.php index 7d25d51f..4b371590 100644 --- a/src/Controller/Tickets/MessagesController.php +++ b/src/Controller/Tickets/MessagesController.php @@ -183,9 +183,11 @@ public function create( if ($minutesSpent > 0 && $security->isGranted('orga:create:tickets:time_spent', $organization)) { $contract = $ticket->getOngoingContract(); + $minutesCharged = $minutesSpent; if ($contract) { $contractAvailableMinutes = $contract->getRemainingMinutes(); + $billingInterval = $contract->getBillingInterval(); // If there is more spent time than time available in the // contract, we don't want to charge the entire time. So we @@ -203,13 +205,17 @@ public function create( // Calculate the remaining time, and make sure it will not // be charged. $minutesSpent = $minutesSpent - $contractAvailableMinutes; + $minutesCharged = $minutesSpent; $contract = null; + } elseif ($billingInterval > 0) { + $minutesCharged = intval(ceil($minutesSpent / $billingInterval)) * $billingInterval; + $minutesCharged = min($minutesCharged, $contractAvailableMinutes); } } $timeSpent = new TimeSpent(); $timeSpent->setTicket($ticket); - $timeSpent->setTime($minutesSpent); + $timeSpent->setTime($minutesCharged); $timeSpent->setRealTime($minutesSpent); $timeSpent->setContract($contract); diff --git a/tests/Controller/Tickets/MessagesControllerTest.php b/tests/Controller/Tickets/MessagesControllerTest.php index 42721367..cf651fcf 100644 --- a/tests/Controller/Tickets/MessagesControllerTest.php +++ b/tests/Controller/Tickets/MessagesControllerTest.php @@ -152,6 +152,40 @@ public function testPostCreateAcceptsTimeSpent(): void $this->assertSame($ticket->getId(), $timeSpent->getTicket()->getId()); } + public function testPostCanAssociateTimeSpentToContract(): void + { + $client = static::createClient(); + $user = UserFactory::createOne(); + $client->loginUser($user->object()); + $this->grantOrga($user->object(), [ + 'orga:create:tickets:messages', + 'orga:create:tickets:time_spent', + ]); + $contract = ContractFactory::createOne([ + 'startAt' => Time::ago(1, 'week'), + 'endAt' => Time::fromNow(1, 'week'), + 'maxHours' => 10, + 'billingInterval' => 30, + ]); + $ticket = TicketFactory::createOne([ + 'createdBy' => $user, + ]); + $ticket->addContract($contract->object()); + $messageContent = 'My message'; + + $client->request('POST', "/tickets/{$ticket->getUid()}/messages/new", [ + '_csrf_token' => $this->generateCsrfToken($client, 'create ticket message'), + 'message' => $messageContent, + 'timeSpent' => 10, + ]); + + $timeSpent = TimeSpentFactory::first(); + $this->assertSame(30, $timeSpent->getTime()); + $this->assertSame(10, $timeSpent->getRealTime()); + $this->assertSame($ticket->getId(), $timeSpent->getTicket()->getId()); + $this->assertSame($contract->getId(), $timeSpent->getContract()->getId()); + } + public function testPostCreateCanCreateTwoTimeSpentIfContractIsAlmostFinished(): void { $client = static::createClient();