diff --git a/src/ApiResource/Accounting/AccountingBalancePoint.php b/src/ApiResource/Accounting/AccountingBalancePoint.php new file mode 100644 index 0000000..bb73529 --- /dev/null +++ b/src/ApiResource/Accounting/AccountingBalancePoint.php @@ -0,0 +1,59 @@ + new QueryParameter( + required: true, + schema: ['type' => 'string', 'format' => 'iri-reference'], + ), + 'start' => new QueryParameter( + required: true, + schema: ['type' => 'string', 'format' => 'date-time'], + ), + 'interval' => new QueryParameter( + schema: ['type' => 'string', 'default' => '24h'], + constraints: [new Assert\Choice(['24h'])], + ), + 'end' => new QueryParameter( + schema: ['type' => 'string', 'format' => 'date-time', 'default' => 'now'], + ), + ], +)] +class AccountingBalancePoint +{ + /** + * The start date for items aggregated in this point. + */ + public \DateTimeInterface $start; + + /** + * The end date for items aggregated in this point. Not inclusive. + */ + public \DateTimeInterface $end; + + /** + * Resulting balance for items in this point. + */ + public Money $balance; + + /** + * The number of items aggregated in this point. + */ + public int $length; +} diff --git a/src/Entity/Accounting/Transaction.php b/src/Entity/Accounting/Transaction.php index afa9a1b..a0a1cab 100644 --- a/src/Entity/Accounting/Transaction.php +++ b/src/Entity/Accounting/Transaction.php @@ -3,6 +3,7 @@ namespace App\Entity\Accounting; use App\Entity\Money; +use App\Entity\Trait\TimestampedCreationEntity; use App\Repository\Accounting\TransactionRepository; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Validator\Constraints as Assert; @@ -18,6 +19,8 @@ use Symfony\Component\Validator\Constraints as Assert; #[ORM\Entity(repositoryClass: TransactionRepository::class)] class Transaction { + use TimestampedCreationEntity; + #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] diff --git a/src/Repository/Accounting/TransactionRepository.php b/src/Repository/Accounting/TransactionRepository.php index 625b233..a160df0 100644 --- a/src/Repository/Accounting/TransactionRepository.php +++ b/src/Repository/Accounting/TransactionRepository.php @@ -23,18 +23,39 @@ class TransactionRepository extends ServiceEntityRepository } /** + * @param \DateTimeInterface|null $dateStart a date to act as lower bound of the selection + * @param \DateTimeInterface|null $dateEnd A date to act as upper bound of the selection. Current date will be used if null. + * * @return Transaction[] Transactions originated from or targeting the Accounting */ - public function findByAccounting(Accounting $accounting): array - { - return $this->createQueryBuilder('t') - ->andWhere('t.origin = :val') - ->orWhere('t.target = :val') - ->setParameter('val', $accounting) - ->orderBy('t.id', 'ASC') - ->getQuery() - ->getResult() - ; + public function findByAccounting( + Accounting $accounting, + ?\DateTimeInterface $dateStart = null, + ?\DateTimeInterface $dateEnd = null, + ): array { + $queryBuilder = $this->createQueryBuilder('t'); + $accountingExpr = $queryBuilder->expr(); + + if ($dateEnd === null) { + $dateEnd = new \DateTime(); + } + + $query = $queryBuilder + ->andWhere($accountingExpr->orX( + $accountingExpr->eq('t.origin', $accounting->getId()), + $accountingExpr->eq('t.target', $accounting->getId()), + )) + ->andWhere('t.dateCreated <= :dateEnd') + ->setParameter('dateEnd', $dateEnd); + + if ($dateStart !== null) { + $query->andWhere('t.dateCreated >= :dateStart') + ->setParameter('dateStart', $dateStart); + } + + $query->orderBy('t.id', 'ASC'); + + return $query->getQuery()->getResult(); } /** diff --git a/src/Service/AccountingService.php b/src/Service/AccountingService.php index cc5d179..954214a 100644 --- a/src/Service/AccountingService.php +++ b/src/Service/AccountingService.php @@ -2,7 +2,9 @@ namespace App\Service; +use App\ApiResource\Accounting\AccountingBalancePoint; use App\Entity\Accounting\Accounting; +use App\Entity\Accounting\Transaction; use App\Entity\Money; use App\Entity\User\User; use App\Gateway\Wallet\WalletService; @@ -26,9 +28,9 @@ class AccountingService } $balance = new Money(0, $accounting->getCurrency()); - $transactions = $this->transactionRepository->findByAccounting($accounting); + $trxs = $this->transactionRepository->findByAccounting($accounting); - foreach ($transactions as $transaction) { + foreach ($trxs as $transaction) { if ($transaction->getTarget() === $accounting) { $balance = $this->money->add($transaction->getMoney(), $balance); } @@ -40,4 +42,57 @@ class AccountingService return $balance; } + + /** + * Calculates an AccountingBalancePoint series for a given Accounting over a period of time. + * + * @param Accounting $accounting The Accounting to calc the data series for + * @param \DatePeriod $period A period of time for the desired transactions and time unit of the serie + * + * @return array One AccountingBalancePoint for each interval inside the date period range + */ + public function calcBalancePoints( + Accounting $accounting, + \DatePeriod $period, + ): array { + $trxs = $this->transactionRepository->findByAccounting( + $accounting, + $period->getStartDate(), + $period->getEndDate(), + ); + $points = []; + + foreach ($period as $lowerBound) { + $upperBound = \DateTime::createFromInterface($lowerBound); + $upperBound->add($period->getDateInterval()); + + /** @var Transactions[] */ + $periodTrxs = [...\array_filter($trxs, function (Transaction $trx) use ($lowerBound, $upperBound) { + return $trx->getDateCreated() >= $lowerBound + && $trx->getDateCreated() < $upperBound; + })]; + + $balance = new Money(0, $accounting->getCurrency()); + + foreach ($periodTrxs as $trx) { + if ($trx->getTarget() === $accounting) { + $balance = $this->money->add($trx->getMoney(), $balance); + } + + if ($trx->getOrigin() === $accounting) { + $balance = $this->money->substract($trx->getMoney(), $balance); + } + } + + $point = new AccountingBalancePoint(); + $point->start = $lowerBound; + $point->end = $upperBound; + $point->balance = $balance; + $point->length = \count($periodTrxs); + + $points[] = $point; + } + + return $points; + } } diff --git a/src/State/Accounting/AccountingBalancePointStateProvider.php b/src/State/Accounting/AccountingBalancePointStateProvider.php new file mode 100644 index 0000000..853eecb --- /dev/null +++ b/src/State/Accounting/AccountingBalancePointStateProvider.php @@ -0,0 +1,53 @@ +getParameters(); + + $accounting = $this->getAccounting($parameters); + $period = $this->buildPeriod($parameters); + + return $this->accountingService->calcBalancePoints($accounting, $period); + } + + private function getAccounting(Parameters $parameters): Accounting + { + $parameter = $parameters->get('accounting'); + $resource = $this->iriConverter->getResourceFromIri($parameter->getValue()); + + /** @var Accounting */ + $accounting = $this->autoMapper->map($resource, Accounting::class); + + return $accounting; + } + + private function buildPeriod(Parameters $parameters): \DatePeriod + { + $start = new \DateTimeImmutable($parameters->get('start')->getValue()); + + $interval = $parameters->get('interval')->getValue(); + $interval = new \DateInterval(\sprintf('PT%s', \strtoupper($interval))); + + $end = new \DateTimeImmutable($parameters->get('end')->getValue()); + + return new \DatePeriod($start, $interval, $end); + } +} diff --git a/tests/Service/AccountingServiceTest.php b/tests/Service/AccountingServiceTest.php new file mode 100644 index 0000000..097952b --- /dev/null +++ b/tests/Service/AccountingServiceTest.php @@ -0,0 +1,97 @@ +accountingService = static::getContainer()->get(AccountingService::class); + $this->entityManager = static::getContainer()->get(EntityManagerInterface::class); + } + + public function getBalancedAccountings(): array + { + $tipjars = []; + + for ($i = 0; $i < 10; ++$i) { + $tipjar = new Tipjar(); + $tipjar->setName(\sprintf('TEST_TIPJAR_%d', $i)); + + $this->entityManager->persist($tipjar); + + $tipjars[] = $tipjar; + } + + $this->entityManager->flush(); + + for ($i = 0; $i < \count($tipjars) * 5; ++$i) { + $origin = $i % 2 ? $tipjars[\random_int(0, 5)] : $tipjars[\random_int(5, 9)]; + $target = $i % 2 ? $tipjars[\random_int(5, 9)] : $tipjars[\random_int(0, 5)]; + + $trx = new Transaction(); + $trx->setMoney(new Money(random_int(100, 999), 'EUR')); + $trx->setOrigin($origin->getAccounting()); + $trx->setTarget($target->getAccounting()); + + $this->entityManager->persist($trx); + } + + $this->entityManager->flush(); + + return \array_map(function (Tipjar $tipjar) { + return $tipjar->getAccounting(); + }, $tipjars); + } + + public function provideDateIntervals(): array + { + return [ + [new \DateInterval('PT1H'), new \DateInterval('PT10M'), 6], + [new \DateInterval('PT1H'), new \DateInterval('PT23M'), 3], + [new \DateInterval('PT2M'), new \DateInterval('PT37S'), 4], + ]; + } + + /** + * @dataProvider provideDateIntervals + */ + public function testBalancePointsLengthIsOfPeriod(\DateInterval $started, \DateInterval $interval, int $count) + { + $accountings = $this->getBalancedAccountings(); + + $now = new \DateTimeImmutable('now'); + $period = new \DatePeriod($now->sub($started), $interval, $now); + + foreach ($accountings as $accounting) { + $points = $this->accountingService->calcBalancePoints($accounting, $period); + + $this->assertCount($count, $points); + + foreach ($points as $point) { + $this->assertInstanceOf(AccountingBalancePoint::class, $point); + } + + $end = $points[\array_key_last($points)]; + + $this->assertNotEquals(0, $end->length); + $this->assertNotEquals(0, $end->balance->amount); + } + } +}