Merge pull request #61 from goteo/feat/accounting-balance-data-series
[feat] Accounting balance data series
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\ApiResource\Accounting;
|
||||
|
||||
use ApiPlatform\Metadata as API;
|
||||
use ApiPlatform\Metadata\QueryParameter;
|
||||
use App\Entity\Money;
|
||||
use App\State\Accounting\AccountingBalancePointStateProvider;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* AccountingBalancePoints represent a dated aggregate Accounting balance.\
|
||||
* \
|
||||
* Each point is the sum of money at incoming and outgoing transactions in an Accounting for a period of time,
|
||||
* you can query multiple balance points to obtain the evolution of the Accounting's balance over time.
|
||||
*/
|
||||
#[API\ApiResource(shortName: 'AccountingBalancePoint')]
|
||||
#[API\GetCollection(
|
||||
provider: AccountingBalancePointStateProvider::class,
|
||||
parameters: [
|
||||
'accounting' => 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;
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<int, AccountingBalancePoint> 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\State\Accounting;
|
||||
|
||||
use ApiPlatform\Metadata\IriConverterInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\Metadata\Parameters;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Entity\Accounting\Accounting;
|
||||
use App\Mapping\AutoMapper;
|
||||
use App\Service\AccountingService;
|
||||
|
||||
class AccountingBalancePointStateProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private IriConverterInterface $iriConverter,
|
||||
private AutoMapper $autoMapper,
|
||||
private AccountingService $accountingService,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
|
||||
{
|
||||
$parameters = $operation->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Service;
|
||||
|
||||
use App\ApiResource\Accounting\AccountingBalancePoint;
|
||||
use App\Entity\Accounting\Transaction;
|
||||
use App\Entity\Money;
|
||||
use App\Entity\Tipjar;
|
||||
use App\Service\AccountingService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
use Zenstruck\Foundry\Test\ResetDatabase;
|
||||
|
||||
class AccountingServiceTest extends KernelTestCase
|
||||
{
|
||||
use ResetDatabase;
|
||||
|
||||
private AccountingService $accountingService;
|
||||
private EntityManagerInterface $entityManager;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
self::bootKernel();
|
||||
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user