Merge pull request #210 from goteo/feat/enriched-project-supports

feat: Enriched ProjectSupports
This commit is contained in:
2026-05-22 18:15:08 +02:00
committed by GitHub
14 changed files with 309 additions and 161 deletions
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260522111940 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add matchfunding bool property to ProjectSupport';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE project_support ADD matchfunding TINYINT(1) DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE project_support DROP matchfunding');
}
}
@@ -10,10 +10,12 @@ use App\ApiResource\Accounting\AccountingApiResource;
use App\ApiResource\Accounting\TransactionApiResource;
use App\ApiResource\MoneyOutput;
use App\Entity\Project\Support;
use App\Mapping\Transformer\SupportDisplayNameMapTransformer;
use App\Money\Totalization\TotalizedMoney;
use App\State\ApiResourceStateProvider;
use App\State\MoneyTotalStateProvider;
use App\State\Project\SupportStateProcessor;
use AutoMapper\Attribute\MapFrom;
use Symfony\Component\Validator\Constraints as Assert;
/**
@@ -76,6 +78,10 @@ class SupportApiResource
#[API\ApiFilter(filterClass: SearchFilter::class, strategy: 'exact')]
public ?AccountingApiResource $origin;
#[API\ApiProperty(writable: false)]
#[MapFrom(Support::class, transformer: SupportDisplayNameMapTransformer::class)]
public string $displayName;
/**
* The Transactions that were issued to the Project by the origin.
*
@@ -98,6 +104,13 @@ class SupportApiResource
#[API\ApiFilter(BooleanFilter::class)]
public bool $anonymous = true;
/**
* If this ProjectSupport comes from a MatchCall this flag will be true.
*/
#[API\ApiProperty(writable: false)]
#[API\ApiFilter(BooleanFilter::class)]
public bool $matchfunding;
/**
* A message of support from the User to the Project.
*/
+2 -2
View File
@@ -86,7 +86,7 @@ trait TerritoryPumpTrait
private const COMMON_VARIATIONS = [
'PERÚ' => ['PÉROU'],
'ECUADOR' => ['EQUADOR'],
'MÉXICO' => ['MESSICO', 'MX'],
'MÉXICO' => ['MEXICO', 'MESSICO', 'MX'],
'CIUDAD DE MÉXICO' => ['CDMX'],
'EUROPA' => ['EUROPEAN UNION', 'EUROPE'],
'FRANCIA' => ['FRANCE'],
@@ -124,7 +124,7 @@ trait TerritoryPumpTrait
'VIZCAYA' => ['BIZKAIA'],
'IRÚN' => ['IRUN'],
'NAVARRA' => ['NAVARRE', 'NAFARROA'],
'PAMLONA' => ['IRUÑEA'],
'PAMPLONA' => ['IRUÑEA'],
'CANARIAS' => ['ISLAS CANARIAS', 'CANARY ISLANDS'],
];
}
+10
View File
@@ -5,6 +5,7 @@ namespace App\Entity\Project;
use App\Entity\Accounting\Accounting;
use App\Entity\Accounting\Transaction;
use App\Entity\EmbeddableMoney;
use App\Entity\Matchfunding\MatchCall;
use App\Mapping\Provider\EntityMapProvider;
use App\Repository\Project\SupportRepository;
use AutoMapper\Attribute\MapProvider;
@@ -50,6 +51,9 @@ class Support
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $message = null;
#[ORM\Column(nullable: true)]
private ?bool $matchfunding = null;
public function __construct()
{
$this->transactions = new ArrayCollection();
@@ -68,6 +72,7 @@ class Support
public function setOrigin(?Accounting $origin): static
{
$this->origin = $origin;
$this->matchfunding = $origin->getOwnerClass() === MatchCall::class;
return $this;
}
@@ -143,4 +148,9 @@ class Support
return $this;
}
public function isMatchfunding(): ?bool
{
return $this->matchfunding;
}
}
+3 -3
View File
@@ -119,7 +119,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, Account
private ?array $links = null;
#[ORM\Embedded(class: Territory::class)]
private ?object $territory = null;
private ?Territory $territory = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $description = null;
@@ -382,12 +382,12 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, Account
return $this;
}
public function getTerritory(): ?object
public function getTerritory(): ?Territory
{
return $this->territory;
}
public function setTerritory(?object $territory): static
public function setTerritory(?Territory $territory): static
{
$this->territory = $territory;
@@ -0,0 +1,80 @@
<?php
namespace App\EventListener;
use App\Entity\Gateway\Charge;
use App\Entity\Gateway\Checkout;
use App\Entity\Project\Support;
use App\Gateway\CheckoutStatus;
use App\Service\Project\SupportService;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener;
use Doctrine\ORM\Event\PreUpdateEventArgs;
use Doctrine\ORM\Events;
#[AsEntityListener(
event: Events::preUpdate,
method: 'preUpdate',
entity: Checkout::class
)]
class CheckoutSupportsListener
{
use RecomputingListenerTrait;
public function __construct(
private SupportService $supportService,
) {}
public function preUpdate(Checkout $checkout, PreUpdateEventArgs $event): void
{
if (!$event->hasChangedField('status')) {
return;
}
if ($event->getNewValue('status') !== CheckoutStatus::Charged) {
return;
}
$supports = $this->prepareSupports($checkout);
foreach ($supports as $support) {
$this->computeChangeSet($event->getObjectManager(), $support);
}
}
/**
* @return Support[]
*/
public function prepareSupports(Checkout $checkout): array
{
/** @var Charge[] */
$charges = $checkout->getCharges()->toArray();
$origin = $checkout->getOrigin();
$projects = [];
$transactionsByProject = [];
foreach ($charges as $charge) {
$project = $charge->getTarget()->getProject();
if (!$project) {
continue;
}
$projectId = $project->getId();
$projects[$projectId] = $project;
$transactionsByProject[$projectId] = [
...$transactionsByProject[$projectId] ?? [],
...$charge->getTransactions(),
];
}
$supports = [];
foreach ($transactionsByProject as $projectId => $transactions) {
$support = $this->supportService->getSupport($projects[$projectId], $origin);
$support = $this->supportService->withTransactions($support, $transactions);
$supports[] = $support;
}
return $supports;
}
}
@@ -1,125 +0,0 @@
<?php
namespace App\EventListener;
use App\Entity\Accounting\Accounting;
use App\Entity\Accounting\Transaction;
use App\Entity\EmbeddableMoney;
use App\Entity\Gateway\Charge;
use App\Entity\Gateway\Checkout;
use App\Entity\Project\Project;
use App\Entity\Project\Support;
use App\Money\Money;
use App\Money\MoneyService;
use App\Repository\Project\SupportRepository;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\PreFlushEventArgs;
use Doctrine\ORM\Events;
#[AsEntityListener(
event: Events::preFlush,
method: 'preFlush',
entity: Checkout::class
)]
class GatewayCheckoutSupportsListener
{
public function __construct(
private SupportRepository $supportRepository,
private MoneyService $moneyService,
) {}
public function preFlush(Checkout $checkout, PreFlushEventArgs $args): void
{
/** @var EntityManagerInterface */
$em = $args->getObjectManager();
$uow = $em->getUnitOfWork();
foreach ($uow->getScheduledEntityUpdates() as $entity) {
if (!$entity instanceof Checkout) {
continue;
}
$changes = $uow->getEntityChangeSet($entity);
if (!isset($changes['status']) || !$entity->isCharged()) {
continue;
}
$supports = $this->prepareSupports($entity);
foreach ($supports as $support) {
$em->persist($support);
$uow->computeChangeSet($em->getClassMetadata(Support::class), $support);
}
}
}
/**
* @return Support[]
*/
public function prepareSupports(Checkout $checkout): array
{
/** @var Charge[] */
$charges = $checkout->getCharges()->toArray();
$origin = $checkout->getOrigin();
$projects = [];
$transactionsByProject = [];
foreach ($charges as $charge) {
$project = $charge->getTarget()->getProject();
if (!$project) {
continue;
}
$projectId = $project->getId();
$projects[$projectId] = $project;
$transactionsByProject[$projectId] = [
...$transactionsByProject[$projectId] ?? [],
...$charge->getTransactions(),
];
}
$supports = [];
foreach ($transactionsByProject as $projectId => $transactions) {
$supports[] = $this->getSupport($projects[$projectId], $origin, $transactions);
}
return $supports;
}
/**
* Create ProjectSupport for the given data.
*
* @param Transaction[] $transactions
*/
private function getSupport(Project $project, Accounting $origin, array $transactions): Support
{
/** @var Support|null */
$projectSupport = $this->supportRepository->findOneBy([
'project' => $project,
'origin' => $origin,
]);
if (!$projectSupport) {
$projectSupport = new Support();
}
$projectSupport->setProject($project);
$projectSupport->setOrigin($origin);
$projectSupport->setAnonymous(false);
$money = new Money(0, $project->getAccounting()->getCurrency());
foreach ($transactions as $transaction) {
$money = $this->moneyService->add($transaction->getMoney(), $money);
$projectSupport->addTransaction($transaction);
}
$projectSupport->setMoney(EmbeddableMoney::of($money));
return $projectSupport;
}
}
+23 -29
View File
@@ -3,10 +3,11 @@
namespace App\EventListener;
use App\Entity\Gateway\Charge;
use App\Entity\Project\Project;
use App\Gateway\ChargeStatus;
use App\Service\Matchfunding\MatchfundingService;
use App\Service\Project\SupportService;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\PreUpdateEventArgs;
use Doctrine\ORM\Events;
@@ -15,52 +16,45 @@ use Doctrine\ORM\Events;
method: 'preUpdate',
entity: Charge::class
)]
#[AsEntityListener(
event: Events::postUpdate,
method: 'postUpdate',
entity: Charge::class
)]
final class MatchfundingListener
{
private ?Charge $charge = null;
use RecomputingListenerTrait;
public function __construct(
private MatchfundingService $matchfunding,
private EntityManagerInterface $entityManager,
private SupportService $supportService,
) {}
public function preUpdate(Charge $charge, PreUpdateEventArgs $event)
public function preUpdate(Charge $charge, PreUpdateEventArgs $event): void
{
$this->charge = null;
if (count($event->getEntityChangeSet()) === 0) {
if (!$event->hasChangedField('status')) {
return;
}
if ($charge->getStatus() !== ChargeStatus::InCharge) {
if ($event->getNewValue('status') !== ChargeStatus::InCharge) {
return;
}
$target = $charge->getTarget();
if (!$target instanceof Project) {
return;
}
$transactions = $this->matchfunding->match($charge);
if (\count($transactions) > 0) {
foreach ($transactions as $transaction) {
$charge->addTransaction($transaction);
}
$this->charge = $charge;
}
}
public function postUpdate()
{
if ($this->charge === null) {
if ($transactions === []) {
return;
}
$this->entityManager->persist($this->charge);
$this->entityManager->flush();
$em = $event->getObjectManager();
foreach ($transactions as $transaction) {
$support = $this->supportService->getSupport($target, $charge->getCheckout()->getOrigin());
$support = $this->supportService->withTransactions($support, [$transaction]);
$this->computeChangeSet($em, $support);
$this->charge = null;
$matchSupport = $this->supportService->getSupport($target, $transaction->getOrigin());
$matchSupport = $this->supportService->withTransactions($matchSupport, [$transaction]);
$matchSupport->setAnonymous(false);
$this->computeChangeSet($em, $matchSupport);
}
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\EventListener;
use Doctrine\ORM\EntityManagerInterface;
trait RecomputingListenerTrait
{
/**
* Appropriately updates the Doctrine's Unit of Work and marks entities for persistence.
*/
private function computeChangeSet(EntityManagerInterface $em, object $entity): void
{
$uow = $em->getUnitOfWork();
$meta = $em->getClassMetadata($entity::class);
$em->persist($entity);
$uow->computeChangeSet($meta, $entity);
}
}
@@ -0,0 +1,46 @@
<?php
namespace App\Mapping\Transformer;
use App\Entity\Accounting\AccountingOwnerInterface;
use App\Entity\Matchfunding\MatchCall;
use App\Entity\Project\Support;
use App\Entity\User\User;
use AutoMapper\Transformer\PropertyTransformer\PropertyTransformerInterface;
class SupportDisplayNameMapTransformer implements PropertyTransformerInterface
{
public function __construct(
private UserDisplayNameMapTransformer $userDisplayNameMapTransformer,
) {}
/**
* @param Support $source
*/
public function transform(mixed $value, object|array $source, array $context): mixed
{
$origin = $source->getOrigin();
if (!$origin) {
throw new \Exception(\sprintf('The Support with ID %s does not have an origin', $source->getId()));
}
return match ($origin->getOwnerClass()) {
User::class => $this->userDisplayNameMapTransformer->transform($value, $origin->getUser(), $context),
MatchCall::class => $origin->getMatchCall()->getTitle(),
default => $this->attemptDisplayName($origin->getOwner()),
};
}
private function attemptDisplayName(AccountingOwnerInterface $owner): string
{
if (\method_exists($owner, 'getTitle')) {
return $owner->getTitle();
}
if (\method_exists($owner, 'getName')) {
return $owner->getName();
}
throw new \Exception(\sprintf("AccountingOwnerInterface '%s' does not provide a displayable name", $owner::class));
}
}
@@ -3,6 +3,7 @@
namespace App\Service\Matchfunding;
use App\Entity\Accounting\Transaction;
use App\Entity\EmbeddableMoney;
use App\Entity\Gateway\Charge;
use App\Entity\Matchfunding\MatchAgainst;
use App\Entity\Matchfunding\MatchCallSubmissionStatus;
@@ -61,7 +62,7 @@ class MatchfundingService
->match($strategy->getFactor(), $toBeMatched, $strategy->getLimit());
$transaction = new Transaction();
$transaction->setMoney($matched);
$transaction->setMoney(EmbeddableMoney::of($matched));
$transaction->setOrigin($submission->getCall()->getAccounting());
$transaction->setTarget($submission->getProject()->getAccounting());
@@ -10,6 +10,8 @@ class NominatimService
{
public const NOMINATIM_BASE_URI = 'https://nominatim.openstreetmap.org';
private float $lastRequestTime = 0.0;
/**
* Cache calls to Nominatim for 1 hour.
* This ensures freshness of the data but minimizes bulk-operations hit rates to Nominatim.
@@ -46,6 +48,7 @@ class NominatimService
function (CacheItemInterface $item) use ($endpoint, $options) {
$item->expiresAfter(self::NOMINATIM_CACHE_TTL);
$this->throttle();
$response = $this->httpClient->request('GET', $endpoint, $options);
return \json_decode($response->getContent(), true);
@@ -76,4 +79,18 @@ class NominatimService
],
]);
}
private function throttle(): void
{
$minInterval = 1.1;
$now = microtime(true);
$delta = $now - $this->lastRequestTime;
if ($delta < $minInterval) {
usleep((int) (($minInterval - $delta) * 1000000));
}
$this->lastRequestTime = microtime(true);
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace App\Service\Project;
use App\Entity\Accounting\Accounting;
use App\Entity\Accounting\Transaction;
use App\Entity\EmbeddableMoney;
use App\Entity\Project\Project;
use App\Entity\Project\Support;
use App\Money\Money;
use App\Money\MoneyService;
use App\Repository\Project\SupportRepository;
/**
* Helpers to work with `App\Entity\Project\Support` entities.
*/
class SupportService
{
public function __construct(
private SupportRepository $supportRepository,
private MoneyService $moneyService,
) {}
/**
* Obtain a Support (new or existing) for a Project and origin.
*/
public function getSupport(
Project $project,
Accounting $origin,
): Support {
/** @var Support|null $support */
$support = $this->supportRepository->findOneBy([
'project' => $project,
'origin' => $origin,
]);
if ($support === null) {
$support = new Support();
$support->setProject($project);
$support->setOrigin($origin);
}
return $support;
}
/**
* Add new Transactions and recalculate the Money for an existing Support.
*
* @param Transaction[] $transactions
*/
public function withTransactions(Support $support, array $transactions): Support
{
foreach ($transactions as $transaction) {
$support->addTransaction($transaction);
}
$money = new Money(0, $support->getProject()->getAccounting()->getCurrency());
foreach ($support->getTransactions() as $transaction) {
$money = $this->moneyService->add($transaction->getMoney(), $money);
}
$support->setMoney(EmbeddableMoney::of($money));
return $support;
}
}
+1 -1
View File
@@ -53,7 +53,7 @@ class UserService
// Only lowercase a-z, numbers, underscore and middle dots in user handles
$nval = \preg_replace('/[^a-z0-9_.]|^\.|\.$/', '_', \strtolower($nval));
$nval = \str_pad($nval, $min, \sprintf('#%s', \hash('md5', $value)));
$nval = \str_pad($nval, $min, \sprintf('_%s', \hash('md5', $value)));
$nval = \substr($nval, 0, $max);
if (strlen(str_replace('_', '', $nval)) < 1) {