diff --git a/migrations/Version20260522111940.php b/migrations/Version20260522111940.php new file mode 100644 index 0000000..8b1b112 --- /dev/null +++ b/migrations/Version20260522111940.php @@ -0,0 +1,26 @@ +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'); + } +} diff --git a/src/ApiResource/Project/SupportApiResource.php b/src/ApiResource/Project/SupportApiResource.php index 62c3f0e..6531595 100644 --- a/src/ApiResource/Project/SupportApiResource.php +++ b/src/ApiResource/Project/SupportApiResource.php @@ -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. */ diff --git a/src/Benzina/TerritoryPumpTrait.php b/src/Benzina/TerritoryPumpTrait.php index d2a4174..f9924de 100644 --- a/src/Benzina/TerritoryPumpTrait.php +++ b/src/Benzina/TerritoryPumpTrait.php @@ -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'], ]; } diff --git a/src/Entity/Project/Support.php b/src/Entity/Project/Support.php index 9b41fe5..5c6dcb8 100644 --- a/src/Entity/Project/Support.php +++ b/src/Entity/Project/Support.php @@ -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; + } } diff --git a/src/Entity/User/User.php b/src/Entity/User/User.php index d279ba5..bad0900 100644 --- a/src/Entity/User/User.php +++ b/src/Entity/User/User.php @@ -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; diff --git a/src/EventListener/CheckoutSupportsListener.php b/src/EventListener/CheckoutSupportsListener.php new file mode 100644 index 0000000..6ae1fd2 --- /dev/null +++ b/src/EventListener/CheckoutSupportsListener.php @@ -0,0 +1,80 @@ +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; + } +} diff --git a/src/EventListener/GatewayCheckoutSupportsListener.php b/src/EventListener/GatewayCheckoutSupportsListener.php deleted file mode 100644 index 860685c..0000000 --- a/src/EventListener/GatewayCheckoutSupportsListener.php +++ /dev/null @@ -1,125 +0,0 @@ -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; - } -} diff --git a/src/EventListener/MatchfundingListener.php b/src/EventListener/MatchfundingListener.php index e5ef57a..54d883e 100644 --- a/src/EventListener/MatchfundingListener.php +++ b/src/EventListener/MatchfundingListener.php @@ -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); + } } } diff --git a/src/EventListener/RecomputingListenerTrait.php b/src/EventListener/RecomputingListenerTrait.php new file mode 100644 index 0000000..4a0490d --- /dev/null +++ b/src/EventListener/RecomputingListenerTrait.php @@ -0,0 +1,20 @@ +getUnitOfWork(); + $meta = $em->getClassMetadata($entity::class); + + $em->persist($entity); + $uow->computeChangeSet($meta, $entity); + } +} diff --git a/src/Mapping/Transformer/SupportDisplayNameMapTransformer.php b/src/Mapping/Transformer/SupportDisplayNameMapTransformer.php new file mode 100644 index 0000000..49212ae --- /dev/null +++ b/src/Mapping/Transformer/SupportDisplayNameMapTransformer.php @@ -0,0 +1,46 @@ +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)); + } +} diff --git a/src/Service/Matchfunding/MatchfundingService.php b/src/Service/Matchfunding/MatchfundingService.php index 5424906..917a718 100644 --- a/src/Service/Matchfunding/MatchfundingService.php +++ b/src/Service/Matchfunding/MatchfundingService.php @@ -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()); diff --git a/src/Service/Nominatim/NominatimService.php b/src/Service/Nominatim/NominatimService.php index 5ec3db8..7d4fa75 100644 --- a/src/Service/Nominatim/NominatimService.php +++ b/src/Service/Nominatim/NominatimService.php @@ -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); + } } diff --git a/src/Service/Project/SupportService.php b/src/Service/Project/SupportService.php new file mode 100644 index 0000000..279af36 --- /dev/null +++ b/src/Service/Project/SupportService.php @@ -0,0 +1,66 @@ +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; + } +} diff --git a/src/Service/UserService.php b/src/Service/UserService.php index 6c2eadc..358911e 100644 --- a/src/Service/UserService.php +++ b/src/Service/UserService.php @@ -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) {