Merge branch 'develop' into fix/gv4-048-paypal-webhook-signature-verification

This commit is contained in:
2025-05-14 11:55:28 +02:00
23 changed files with 213 additions and 116 deletions
@@ -5,6 +5,7 @@ namespace App\ApiResource\Accounting;
use ApiPlatform\Doctrine\Orm\State\Options;
use ApiPlatform\Metadata as API;
use App\ApiResource\Project\ProjectApiResource;
use App\ApiResource\TipjarApiResource;
use App\ApiResource\User\UserApiResource;
use App\Entity\Accounting\Accounting;
use App\Entity\Money;
@@ -56,7 +57,7 @@ class AccountingApiResource
/**
* The resource owning this Accounting.
*
* @return UserApiResource|ProjectApiResource|Tipjar
* @return UserApiResource|ProjectApiResource|TipjarApiResource
*/
public function getOwner(): ?object
{
@@ -79,5 +80,5 @@ class AccountingApiResource
public ?ProjectApiResource $project = null;
#[API\ApiProperty(readable: false, writable: false)]
public ?Tipjar $tipjar = null;
public ?TipjarApiResource $tipjar = null;
}
@@ -24,7 +24,7 @@ use Symfony\Component\Validator\Constraints as Assert;
provider: ApiResourceStateProvider::class
)]
#[API\Get()]
#[API\GetCollection(security: "is_granted('IS_AUTHENTICATED_FULLY')")]
#[API\GetCollection()]
#[API\Patch(
input: ChargeUpdationDto::class,
processor: ChargeStateProcessor::class,
@@ -37,7 +37,7 @@ class ChargeApiResource
/**
* The Checkout to which this Charge item belongs to.
*/
#[API\ApiProperty(writable: false)]
#[API\ApiProperty(writable: false, security: 'is_granted("IS_AUTHENTICATED_FULLY")')]
public CheckoutApiResource $checkout;
/**
@@ -85,5 +85,8 @@ class ChargeApiResource
*/
#[Assert\NotBlank()]
#[API\ApiFilter(Filter\SearchFilter::class, strategy: 'exact')]
public ChargeStatus $status = ChargeStatus::Pending;
public ChargeStatus $status = ChargeStatus::InPending;
public \DateTimeInterface $dateCreated;
public \DateTimeInterface $dateUpdated;
}
@@ -88,7 +88,7 @@ class CheckoutApiResource
* The status of this Checkout, as confirmed by the Gateway.
*/
#[API\ApiProperty(writable: false)]
public CheckoutStatus $status = CheckoutStatus::Pending;
public CheckoutStatus $status = CheckoutStatus::InPending;
/**
* A list of related hyperlinks, as provided by the Gateway.
@@ -110,6 +110,9 @@ class CheckoutApiResource
#[MapFrom(Checkout::class, transformer: 'parseTrackings')]
public array $trackings = [];
public \DateTimeInterface $dateCreated;
public \DateTimeInterface $dateUpdated;
public static function parseLinks(array $values)
{
return \array_map(fn($value) => Link::tryFrom($value), $values);
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace App\ApiResource;
use ApiPlatform\Doctrine\Orm\State\Options;
use ApiPlatform\Metadata as API;
use App\ApiResource\Accounting\AccountingApiResource;
use App\Entity\Tipjar;
use App\State\ApiResourceStateProcessor;
use App\State\ApiResourceStateProvider;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Unlike other Money recipients a Tipjar receives money with no further goal.\
* \
* Tips to the platform owners and other no-purpose money can target a Tipjar.
*/
#[API\ApiResource(
shortName: 'Tipjar',
stateOptions: new Options(entityClass: Tipjar::class),
provider: ApiResourceStateProvider::class,
processor: ApiResourceStateProcessor::class
)]
#[API\GetCollection()]
#[API\Post()]
#[API\Get()]
#[API\Delete()]
#[API\Patch()]
class TipjarApiResource
{
#[API\ApiProperty(identifier: true, writable: false)]
public int $id;
#[API\ApiProperty(writable: false)]
public AccountingApiResource $accounting;
/**
* Human readable, non white space, unique string.
*/
#[Assert\NotBlank()]
public string $name;
}
+10 -11
View File
@@ -5,7 +5,6 @@ namespace App\ApiResource;
use ApiPlatform\Metadata as API;
use App\Filter\ResourceVersionResourceFilter;
use App\Filter\ResourceVersionResourceIdFilter;
use App\Service\ApiService;
use App\State\ResourceVersionStateProvider;
use Gedmo\Loggable\Entity\LogEntry;
@@ -23,7 +22,7 @@ class Version
{
public function __construct(
private readonly LogEntry $log,
private readonly object $entity,
private readonly string $shortName,
) {}
/**
@@ -34,14 +33,6 @@ class Version
return $this->log->getId();
}
/**
* The ID of the version for this specific resource.
*/
public function getVersion(): ?int
{
return $this->log->getVersion();
}
/**
* The type of action that performed the recorded changes.
*/
@@ -55,7 +46,7 @@ class Version
*/
public function getResource(): string
{
return ApiService::toResource($this->log->getObjectClass());
return $this->shortName;
}
/**
@@ -66,6 +57,14 @@ class Version
return $this->log->getObjectId();
}
/**
* The version number for this specific resource.
*/
public function getResourceVersion(): ?int
{
return $this->log->getVersion();
}
/**
* The changed resource data, i.e the new values of the changed properties.
*/
+1 -1
View File
@@ -182,7 +182,7 @@ class CheckoutsPump implements PumpInterface
private function getCheckoutStatus(array $record): CheckoutStatus
{
if ($record['status'] < 1) {
return CheckoutStatus::Pending;
return CheckoutStatus::InPending;
}
return CheckoutStatus::Charged;
@@ -2,9 +2,9 @@
namespace App\DependencyInjection\Compiler;
use App\Service\ApiService;
use ApiPlatform\Metadata\ApiResource;
use App\Service\VersionedResourceService;
use Gedmo\Mapping\Annotation as Gedmo;
use Gedmo\Mapping\Annotation\Loggable;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
@@ -13,27 +13,48 @@ class VersionedResourcePass implements CompilerPassInterface
public function process(ContainerBuilder $container): void
{
$allClasses = \get_declared_classes();
$entityClasses = \array_filter($allClasses, function (string $className) {
return \str_starts_with($className, 'App\\Entity\\');
$resourceClasses = \array_filter($allClasses, function (string $className) {
return \str_starts_with($className, 'App\\ApiResource\\');
});
$versionedResourceNames = [];
foreach ($entityClasses as $entityClass) {
$reflectionClass = new \ReflectionClass($entityClass);
$attributes = $reflectionClass->getAttributes(Gedmo\Loggable::class);
$versionedResources = [];
foreach ($resourceClasses as $resourceClass) {
$resourceReflection = new \ReflectionClass($resourceClass);
$resourceAttributes = $resourceReflection->getAttributes(ApiResource::class);
if (empty($attributes)) {
if (empty($resourceAttributes)) {
continue;
}
$versionedResourceNames = [
...$versionedResourceNames,
ApiService::toResource($entityClass),
$resourceAttribute = $resourceAttributes[0];
$resourceAttributeArgs = $resourceAttribute->getArguments();
if (!\array_key_exists('stateOptions', $resourceAttributeArgs)) {
continue;
}
if (!\array_key_exists('shortName', $resourceAttributeArgs)) {
continue;
}
/** @var string */
$resourceEntityClass = $resourceAttributeArgs['stateOptions']->getEntityClass();
$entityReflection = new \ReflectionClass($resourceEntityClass);
$entityLoggable = $entityReflection->getAttributes(Loggable::class);
if (empty($entityLoggable)) {
continue;
}
$versionedResources = [
...$versionedResources,
$resourceAttributeArgs['shortName'] => $resourceEntityClass,
];
}
/** @var VersionedResourceService */
$versionedResourceService = $container->get(VersionedResourceService::class);
$versionedResourceService->compileNames($versionedResourceNames);
$versionedResourceService->compileNames($versionedResources);
}
}
-33
View File
@@ -1,33 +0,0 @@
<?php
namespace App\Doctrine;
use App\Entity\Gateway\Charge;
use App\Security\Trait\UserTrait;
use Doctrine\ORM\QueryBuilder;
final class GatewayChargeExtension extends AbstractQueryResourceExtensionInterface
{
use UserTrait;
protected function supports(string $resourceClass): bool
{
return $resourceClass === Charge::class;
}
protected function applyFilters(QueryBuilder $queryBuilder, string $rootAlias): void
{
$user = $this->getAuthenticatedUser();
if ($this->isAdmin($user)) {
return;
}
$queryBuilder
->leftJoin("$rootAlias.checkout", 'c')
->leftJoin('c.origin', 'co')
->leftJoin('co.user', 'u')
->andWhere('u.id = :userId')
->setParameter('userId', $user->getId());
}
}
+6 -1
View File
@@ -6,6 +6,8 @@ use App\Entity\Accounting\Accounting;
use App\Entity\Accounting\Transaction;
use App\Entity\Money;
use App\Entity\Project\Support;
use App\Entity\Trait\TimestampedCreationEntity;
use App\Entity\Trait\TimestampedUpdationEntity;
use App\Gateway\ChargeStatus;
use App\Gateway\ChargeType;
use App\Mapping\Provider\EntityMapProvider;
@@ -27,6 +29,9 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: ChargeRepository::class)]
class Charge
{
use TimestampedCreationEntity;
use TimestampedUpdationEntity;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
@@ -90,7 +95,7 @@ class Charge
public function __construct()
{
$this->transactions = new ArrayCollection();
$this->status = ChargeStatus::Pending;
$this->status = ChargeStatus::InPending;
}
public function getId(): ?int
+1 -1
View File
@@ -109,7 +109,7 @@ class Checkout
public function __construct()
{
$this->status = CheckoutStatus::Pending;
$this->status = CheckoutStatus::InPending;
$this->charges = new ArrayCollection();
}
+1 -1
View File
@@ -15,7 +15,7 @@ enum ProjectStatus: string
/**
* Owner finished editing and Project is ready for review.
*/
case InPending = 'in_pending';
case ToReview = 'to_review';
/**
* An admin is reviewing the Project.
+3 -7
View File
@@ -2,10 +2,11 @@
namespace App\Entity;
use ApiPlatform\Metadata as API;
use App\Entity\Accounting\Accounting;
use App\Entity\Interface\AccountingOwnerInterface;
use App\Mapping\Provider\EntityMapProvider;
use App\Repository\TipjarRepository;
use AutoMapper\Attribute\MapProvider;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
@@ -14,12 +15,8 @@ use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
* \
* Tips to the platform owners and other no-purpose money can target a Tipjar.
*/
#[MapProvider(EntityMapProvider::class)]
#[UniqueEntity(fields: ['name'], message: 'A Tipjar with that name already exists.')]
#[API\GetCollection()]
#[API\Post()]
#[API\Get()]
#[API\Delete()]
#[API\Patch()]
#[ORM\Entity(repositoryClass: TipjarRepository::class)]
class Tipjar implements AccountingOwnerInterface
{
@@ -34,7 +31,6 @@ class Tipjar implements AccountingOwnerInterface
#[ORM\Column(length: 255)]
private ?string $name = null;
#[API\ApiProperty(writable: false)]
#[ORM\OneToOne(inversedBy: 'tipjar', cascade: ['persist'])]
private ?Accounting $accounting = null;
+2 -6
View File
@@ -6,7 +6,6 @@ use App\Entity\Gateway\Charge;
use App\Gateway\ChargeStatus;
use App\Gateway\GatewayLocator;
use App\Gateway\RefundStrategy;
use App\Service\Gateway\ChargeService;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener;
use Doctrine\ORM\Event\PreUpdateEventArgs;
use Doctrine\ORM\Events;
@@ -22,7 +21,6 @@ class ChargeRefundListener
public function __construct(
private GatewayLocator $gatewayLocator,
private ChargeService $transactionService,
) {}
private function processGatewayRefund(Charge $charge): void
@@ -38,8 +36,8 @@ class ChargeRefundListener
}
if (
$args->getOldValue(self::FIELD_STATUS) === ChargeStatus::Charged
&& $args->getNewValue(self::FIELD_STATUS) === ChargeStatus::ToRefund
$args->getOldValue(self::FIELD_STATUS) === ChargeStatus::Charged->value
&& $args->getNewValue(self::FIELD_STATUS) === ChargeStatus::ToRefund->value
) {
$refundStrategy = $charge->getCheckout()->getRefundStrategy();
match ($refundStrategy) {
@@ -49,8 +47,6 @@ class ChargeRefundListener
$refundStrategy->name
)),
};
$this->transactionService->addRefundTransaction($charge);
}
}
}
+1 -1
View File
@@ -47,7 +47,7 @@ final class ResourceVersionResourceFilter extends AbstractFilter
'description' => 'The name of the resource.',
'schema' => [
'type' => Type::BUILTIN_TYPE_STRING,
'enum' => $this->versionedResourceService->getNames(),
'enum' => \array_keys($this->versionedResourceService->getNames()),
],
'openapi' => [
'allowEmptyValue' => false,
+8
View File
@@ -5,6 +5,7 @@ namespace App\Gateway;
use App\Entity\Gateway\Charge;
use App\Entity\Gateway\Checkout;
use App\Repository\Gateway\CheckoutRepository;
use App\Service\Gateway\ChargeService;
use App\Service\Gateway\CheckoutService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
@@ -19,6 +20,7 @@ abstract class AbstractGateway implements GatewayInterface
protected CheckoutService $checkoutService;
protected CheckoutRepository $checkoutRepository;
protected ChargeService $chargeService;
protected EntityManagerInterface $entityManager;
#[Required]
@@ -33,6 +35,12 @@ abstract class AbstractGateway implements GatewayInterface
$this->checkoutRepository = $checkoutRepository;
}
#[Required]
public function setChargeService(ChargeService $chargeService)
{
$this->chargeService = $chargeService;
}
#[Required]
public function setEntityManager(EntityManagerInterface $entityManager)
{
+1 -1
View File
@@ -4,7 +4,7 @@ namespace App\Gateway;
enum ChargeStatus: string
{
case Pending = 'pending';
case InPending = 'in_pending';
case Charged = 'charged';
+1 -1
View File
@@ -4,7 +4,7 @@ namespace App\Gateway;
enum CheckoutStatus: string
{
case Pending = 'pending';
case InPending = 'in_pending';
case Charged = 'charged';
}
+25
View File
@@ -259,4 +259,29 @@ class PaypalGateway extends AbstractGateway
],
];
}
public function processRefund(Charge $charge): void
{
$trackings = $charge->getCheckout()->getTrackings();
$captureTracking = current(array_filter(
$trackings,
fn(Tracking $tracking) => $tracking->title === self::TRACKING_TITLE_TRANSACTION && $tracking->value
));
if (!$captureTracking) {
throw new \Exception('PayPal capture ID not found in tracking.');
}
$captureId = $captureTracking->value;
$response = $this->paypal->refundCapture($captureId, [
'amount' => $this->getPaypalMoney($charge),
]);
if (!in_array($response['status'], ['COMPLETED', 'PENDING'], true)) {
throw new \Exception(sprintf('Refund failed. PayPal status: %s', $response['status']));
}
$this->chargeService->addRefundTransaction($charge);
}
}
+22
View File
@@ -191,4 +191,26 @@ class PaypalService
return json_decode($rawBody, true);
}
public function refundCapture(string $captureId, array $payload): array
{
$response = $this->httpClient->request(
'POST',
"/v2/payments/captures/{$captureId}/refund",
[
'auth_bearer' => $this->getAuthToken()['access_token'],
'json' => $payload,
]
);
if ($response->getStatusCode() !== Response::HTTP_CREATED) {
throw new \Exception(sprintf(
"Refund for capture '%s' failed with status %d.",
$captureId,
$response->getStatusCode()
));
}
return $response->toArray();
}
}
+2
View File
@@ -234,5 +234,7 @@ class StripeGateway extends AbstractGateway
'payment_intent' => $paymentIntent->id,
'amount' => $charge->getMoney()->amount,
]);
$this->chargeService->addRefundTransaction($charge);
}
}
-24
View File
@@ -1,24 +0,0 @@
<?php
namespace App\Service;
class ApiService
{
/**
* Converts an Entity class name into an API resource name.
*/
public static function toResource(string $className): string
{
$classPieces = explode('\\', $className);
return lcfirst(end($classPieces));
}
/**
* Converts to an Entity class name from an API resource name.
*/
public static function toEntity(string $resourceName): string
{
return sprintf('App\\Entity\\%s', ucfirst($resourceName));
}
}
+32 -4
View File
@@ -43,7 +43,7 @@ class VersionedResourceService
/**
* Stores the given names in lock file.
*
* @param string[] $names
* @param array<string, string> $names
*/
public function compileNames(array $names)
{
@@ -51,17 +51,45 @@ class VersionedResourceService
\file_put_contents(
self::getNamesLockFile(),
implode(PHP_EOL, $names)
json_encode($names)
);
}
/**
* @return array<string> List of the versioned resource names
* @return array<string, string> List of the versioned resource names
*
* @see compileNames()
*/
public static function getNames(): array
{
return explode(PHP_EOL, \file_get_contents(self::getNamesLockFile()));
return json_decode(\file_get_contents(self::getNamesLockFile()), true);
}
public static function getEntityFromResource(string $resourceShortName): string
{
$names = self::getNames();
if (\array_key_exists($resourceShortName, $names)) {
return $names[$resourceShortName];
}
throw new \Exception(\sprintf(
"The resource shortName '%s' does not match to a known versioned resource",
$resourceShortName
));
}
public static function getResourceFromEntity(string $entityClass): string
{
foreach (self::getNames() as $shortName => $entityFqcl) {
if ($entityFqcl === $entityClass) {
return $shortName;
}
}
throw new \Exception(\sprintf(
"The entity class name '%s' does not match to a known versioned resource",
$entityClass
));
}
}
+8 -5
View File
@@ -6,7 +6,7 @@ use ApiPlatform\Metadata as API;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\Version;
use App\Service\ApiResourceNormalizer;
use App\Service\VersionedResourceService;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\Mapping\MappingException;
use Gedmo\Loggable\Entity\LogEntry;
@@ -33,6 +33,8 @@ class ResourceVersionStateProvider implements ProviderInterface
$context['request']->query->get('resource'),
$context['request']->query->get('resourceId')
);
default:
throw new \Exception('Operation not supported for Version resource', 1);
}
}
@@ -48,8 +50,9 @@ class ResourceVersionStateProvider implements ProviderInterface
}
$entity = $this->entityManager->find($log->getObjectClass(), $log->getObjectId());
$resourceName = VersionedResourceService::getResourceFromEntity($entity::class);
return new Version($log, $entity);
return new Version($log, $resourceName);
}
/**
@@ -57,10 +60,10 @@ class ResourceVersionStateProvider implements ProviderInterface
*/
private function getVersions(string $resourceName, int $resourceId): array
{
$resourceClass = ApiResourceNormalizer::toEntity($resourceName);
$entityClass = VersionedResourceService::getEntityFromResource($resourceName);
try {
$entity = $this->entityManager->find($resourceClass, $resourceId);
$entity = $this->entityManager->find($entityClass, $resourceId);
} catch (MappingException $e) {
throw new NotFoundHttpException(sprintf("Resource '%s' does not exist", $resourceName));
}
@@ -73,7 +76,7 @@ class ResourceVersionStateProvider implements ProviderInterface
$versions = [];
foreach ($logs as $log) {
$versions[] = new Version($log, $entity);
$versions[] = new Version($log, $resourceName);
}
return $versions;