diff --git a/src/ApiResource/Accounting/AccountingApiResource.php b/src/ApiResource/Accounting/AccountingApiResource.php index 29c26c5..befca59 100644 --- a/src/ApiResource/Accounting/AccountingApiResource.php +++ b/src/ApiResource/Accounting/AccountingApiResource.php @@ -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; } diff --git a/src/ApiResource/Gateway/ChargeApiResource.php b/src/ApiResource/Gateway/ChargeApiResource.php index e6343af..35b1533 100644 --- a/src/ApiResource/Gateway/ChargeApiResource.php +++ b/src/ApiResource/Gateway/ChargeApiResource.php @@ -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; } diff --git a/src/ApiResource/Gateway/CheckoutApiResource.php b/src/ApiResource/Gateway/CheckoutApiResource.php index ca76b60..7e904a5 100644 --- a/src/ApiResource/Gateway/CheckoutApiResource.php +++ b/src/ApiResource/Gateway/CheckoutApiResource.php @@ -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); diff --git a/src/ApiResource/TipjarApiResource.php b/src/ApiResource/TipjarApiResource.php new file mode 100644 index 0000000..5b58421 --- /dev/null +++ b/src/ApiResource/TipjarApiResource.php @@ -0,0 +1,42 @@ +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. */ diff --git a/src/Benzina/CheckoutsPump.php b/src/Benzina/CheckoutsPump.php index a7cd1a2..010a92a 100644 --- a/src/Benzina/CheckoutsPump.php +++ b/src/Benzina/CheckoutsPump.php @@ -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; diff --git a/src/DependencyInjection/Compiler/VersionedResourcePass.php b/src/DependencyInjection/Compiler/VersionedResourcePass.php index 5f8e9c2..231baef 100644 --- a/src/DependencyInjection/Compiler/VersionedResourcePass.php +++ b/src/DependencyInjection/Compiler/VersionedResourcePass.php @@ -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); } } diff --git a/src/Doctrine/GatewayChargeExtension.php b/src/Doctrine/GatewayChargeExtension.php deleted file mode 100644 index cbdb24d..0000000 --- a/src/Doctrine/GatewayChargeExtension.php +++ /dev/null @@ -1,33 +0,0 @@ -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()); - } -} diff --git a/src/Entity/Gateway/Charge.php b/src/Entity/Gateway/Charge.php index 802396c..75eb828 100644 --- a/src/Entity/Gateway/Charge.php +++ b/src/Entity/Gateway/Charge.php @@ -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 diff --git a/src/Entity/Gateway/Checkout.php b/src/Entity/Gateway/Checkout.php index ab31a94..de40774 100644 --- a/src/Entity/Gateway/Checkout.php +++ b/src/Entity/Gateway/Checkout.php @@ -109,7 +109,7 @@ class Checkout public function __construct() { - $this->status = CheckoutStatus::Pending; + $this->status = CheckoutStatus::InPending; $this->charges = new ArrayCollection(); } diff --git a/src/Entity/Project/ProjectStatus.php b/src/Entity/Project/ProjectStatus.php index a2ca179..388fa95 100644 --- a/src/Entity/Project/ProjectStatus.php +++ b/src/Entity/Project/ProjectStatus.php @@ -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. diff --git a/src/Entity/Tipjar.php b/src/Entity/Tipjar.php index 8a177e7..40a4d40 100644 --- a/src/Entity/Tipjar.php +++ b/src/Entity/Tipjar.php @@ -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; diff --git a/src/EventListener/ChargeRefundListener.php b/src/EventListener/ChargeRefundListener.php index 861db7f..6f2d889 100644 --- a/src/EventListener/ChargeRefundListener.php +++ b/src/EventListener/ChargeRefundListener.php @@ -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); } } } diff --git a/src/Filter/ResourceVersionResourceFilter.php b/src/Filter/ResourceVersionResourceFilter.php index 24ab0ad..95573bf 100644 --- a/src/Filter/ResourceVersionResourceFilter.php +++ b/src/Filter/ResourceVersionResourceFilter.php @@ -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, diff --git a/src/Gateway/AbstractGateway.php b/src/Gateway/AbstractGateway.php index 5695771..c8a821a 100644 --- a/src/Gateway/AbstractGateway.php +++ b/src/Gateway/AbstractGateway.php @@ -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) { diff --git a/src/Gateway/ChargeStatus.php b/src/Gateway/ChargeStatus.php index e25cda7..06af830 100644 --- a/src/Gateway/ChargeStatus.php +++ b/src/Gateway/ChargeStatus.php @@ -4,7 +4,7 @@ namespace App\Gateway; enum ChargeStatus: string { - case Pending = 'pending'; + case InPending = 'in_pending'; case Charged = 'charged'; diff --git a/src/Gateway/CheckoutStatus.php b/src/Gateway/CheckoutStatus.php index b82392f..a288d55 100644 --- a/src/Gateway/CheckoutStatus.php +++ b/src/Gateway/CheckoutStatus.php @@ -4,7 +4,7 @@ namespace App\Gateway; enum CheckoutStatus: string { - case Pending = 'pending'; + case InPending = 'in_pending'; case Charged = 'charged'; } diff --git a/src/Gateway/Paypal/PaypalGateway.php b/src/Gateway/Paypal/PaypalGateway.php index 849d8bf..4c07538 100644 --- a/src/Gateway/Paypal/PaypalGateway.php +++ b/src/Gateway/Paypal/PaypalGateway.php @@ -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); + } } diff --git a/src/Gateway/Paypal/PaypalService.php b/src/Gateway/Paypal/PaypalService.php index 0b39833..c75621b 100644 --- a/src/Gateway/Paypal/PaypalService.php +++ b/src/Gateway/Paypal/PaypalService.php @@ -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(); + } } diff --git a/src/Gateway/Stripe/StripeGateway.php b/src/Gateway/Stripe/StripeGateway.php index 2f2dc85..31b0d4f 100644 --- a/src/Gateway/Stripe/StripeGateway.php +++ b/src/Gateway/Stripe/StripeGateway.php @@ -234,5 +234,7 @@ class StripeGateway extends AbstractGateway 'payment_intent' => $paymentIntent->id, 'amount' => $charge->getMoney()->amount, ]); + + $this->chargeService->addRefundTransaction($charge); } } diff --git a/src/Service/ApiService.php b/src/Service/ApiService.php deleted file mode 100644 index 3ac8503..0000000 --- a/src/Service/ApiService.php +++ /dev/null @@ -1,24 +0,0 @@ - $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 List of the versioned resource names + * @return array 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 + )); } } diff --git a/src/State/ResourceVersionStateProvider.php b/src/State/ResourceVersionStateProvider.php index 3698d73..9a475d9 100644 --- a/src/State/ResourceVersionStateProvider.php +++ b/src/State/ResourceVersionStateProvider.php @@ -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;