Skip to content

Commit

Permalink
new: Add contract interval billing
Browse files Browse the repository at this point in the history
  • Loading branch information
marien-probesys committed Nov 8, 2023
2 parents 6f3a65f + 95e5a27 commit e40e54a
Show file tree
Hide file tree
Showing 11 changed files with 155 additions and 1 deletion.
42 changes: 42 additions & 0 deletions migrations/Version20231107141114AddBillingIntervalToContract.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

// This file is part of Bileto.
// Copyright 2022-2023 Probesys
// SPDX-License-Identifier: AGPL-3.0-or-later

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Platforms\MariaDBPlatform;
use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

final class Version20231107141114AddBillingIntervalToContract extends AbstractMigration
{
public function getDescription(): string
{
return 'Add the billing_interval column to the contract table.';
}

public function up(Schema $schema): void
{
$dbPlatform = $this->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');
}
}
}
5 changes: 5 additions & 0 deletions src/Controller/Organizations/ContractsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ public function new(
'maxHours' => 10,
'startAt' => $startAt,
'endAt' => $endAt,
'billingInterval' => 0,
'notes' => '',
]);
}
Expand All @@ -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');

Expand All @@ -109,6 +111,7 @@ public function create(
'maxHours' => $maxHours,
'startAt' => $startAt,
'endAt' => $endAt,
'billingInterval' => $billingInterval,
'notes' => $notes,
'error' => $translator->trans('csrf.invalid', [], 'errors'),
]);
Expand All @@ -118,6 +121,7 @@ public function create(
$contract->setOrganization($organization);
$contract->setName($name);
$contract->setMaxHours($maxHours);
$contract->setBillingInterval($billingInterval);
$contract->setNotes($notes);

if ($startAt) {
Expand All @@ -136,6 +140,7 @@ public function create(
'maxHours' => $maxHours,
'startAt' => $startAt,
'endAt' => $endAt,
'billingInterval' => $billingInterval,
'notes' => $notes,
'errors' => Utils\ConstraintErrorsFormatter::format($errors),
]);
Expand Down
8 changes: 7 additions & 1 deletion src/Controller/Tickets/MessagesController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);

Expand Down
15 changes: 15 additions & 0 deletions src/Entity/Contract.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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;
}
}
36 changes: 36 additions & 0 deletions templates/organizations/contracts/new.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,42 @@
/>
</div>

<div class="flow flow--small">
<label for="billing-interval">
{{ 'contracts.new.billing_interval' | trans }}

<small class="text--secondary">
{{ 'forms.optional' | trans }}
</small>
</label>

<p class="form__caption" id="billing-interval-caption">
{{ 'contracts.new.billing_interval.caption' | trans }}
</p>

{% if errors.billingInterval is defined %}
<p class="form__error" role="alert" id="billing-interval-error">
<span class="sr-only">{{ 'forms.error' | trans }}</span>
{{ errors.billingInterval }}
</p>
{% endif %}

<input
type="number"
id="billing-interval"
name="billingInterval"
value=""
min="0"
step="1"
class="input--size3"
aria-describedby="billing-interval-caption"
{% if errors.billingInterval is defined %}
aria-invalid="true"
aria-errormessage="billing-interval-error"
{% endif %}
/>
</div>

<div class="flow flow--small">
<label for="notes">
{{ icon('user-secret') }}
Expand Down
6 changes: 6 additions & 0 deletions templates/organizations/contracts/show.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@
</span>
</div>

{% if contract.billingInterval %}
<p>
{{ 'contracts.show.billing_interval' | trans({ count: contract.billingInterval }) }}
</p>
{% endif %}

<div class="flow flow--small">
<span class="text--small" id="contract-hours-consumed">
{{ 'contracts.hours_consumed' | trans({ 'hours': contract.consumedMinutes | formatMinutes, 'maxHours': contract.maxHours }) | raw }}
Expand Down
3 changes: 3 additions & 0 deletions tests/Controller/Organizations/ContractsControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -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,
]);

Expand All @@ -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());
}

Expand Down
34 changes: 34 additions & 0 deletions tests/Controller/Tickets/MessagesControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions tests/Factory/ContractFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ protected function getDefaults(): array
'startAt' => $startAt,
'endAt' => $endAt,
'maxHours' => self::faker()->numberBetween(1, 9000),
'billingInterval' => 0,
'notes' => '',
];
}
Expand Down
3 changes: 3 additions & 0 deletions translations/messages+intl-icu.en_GB.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions translations/messages+intl-icu.fr_FR.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit e40e54a

Please sign in to comment.