From 1d577a1e86e3378a7c18bea23b52b9e8fc34fe39 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Tue, 26 Mar 2024 10:43:32 +0100 Subject: [PATCH 01/19] Normalize username on pumping failure --- src/Library/Benzina/Pump/UsersPump.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Library/Benzina/Pump/UsersPump.php b/src/Library/Benzina/Pump/UsersPump.php index 9517ddc..bca058f 100644 --- a/src/Library/Benzina/Pump/UsersPump.php +++ b/src/Library/Benzina/Pump/UsersPump.php @@ -30,7 +30,7 @@ class UsersPump implements PumpInterface foreach ($data as $key => $userData) { $username = $this->normalizeUsername($userData['id']); if (!$username) { - continue; + $username = $this->normalizeUsername($userData['email']); } $user = new User; From 1b1796c4e26aa84c49775c359aadb9ecf309e473 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Tue, 26 Mar 2024 13:51:49 +0100 Subject: [PATCH 02/19] Make user and projects searchable --- src/Entity/Project.php | 4 +++- src/Entity/User.php | 5 +++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/Entity/Project.php b/src/Entity/Project.php index b0faef1..76fd22a 100644 --- a/src/Entity/Project.php +++ b/src/Entity/Project.php @@ -2,6 +2,7 @@ namespace App\Entity; +use ApiPlatform\Doctrine\Orm\Filter\SearchFilter; use ApiPlatform\Metadata as API; use App\Repository\ProjectRepository; use Doctrine\ORM\Mapping as ORM; @@ -12,13 +13,14 @@ use Doctrine\ORM\Mapping as ORM; * Since they can be recipients of funding, they are assigned an Accounting when created. * A Project's Accounting represents how much money the Project has raised from the community. */ -#[ORM\Entity(repositoryClass: ProjectRepository::class)] #[API\GetCollection()] #[API\Post(security: 'is_granted("ROLE_USER")')] #[API\Get()] #[API\Put(security: 'is_granted("AUTH_PROJECT_EDIT")')] #[API\Delete(security: 'is_granted("AUTH_PROJECT_EDIT")')] #[API\Patch(security: 'is_granted("AUTH_PROJECT_EDIT")')] +#[API\ApiFilter(filterClass: SearchFilter::class, properties: ['title' => 'partial'])] +#[ORM\Entity(repositoryClass: ProjectRepository::class)] class Project { #[ORM\Id] diff --git a/src/Entity/User.php b/src/Entity/User.php index 11dba7f..4d6ad75 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -2,6 +2,7 @@ namespace App\Entity; +use ApiPlatform\Doctrine\Orm\Filter\SearchFilter; use ApiPlatform\Metadata as API; use App\Repository\UserRepository; use Doctrine\Common\Collections\ArrayCollection; @@ -25,6 +26,10 @@ use Symfony\Component\Validator\Constraints as Assert; #[API\Put(security: 'is_granted("AUTH_USER_EDIT")')] #[API\Delete(security: 'is_granted("AUTH_USER_EDIT")')] #[API\Patch(security: 'is_granted("AUTH_USER_EDIT")')] +#[API\ApiFilter(filterClass: SearchFilter::class, properties: [ + 'username' => 'partial', + 'name' => 'partial' +])] #[UniqueEntity(fields: ['username'], message: 'This usernames already exists.')] #[UniqueEntity(fields: ['email'], message: 'This email address is already registered.')] #[ORM\Entity(repositoryClass: UserRepository::class)] From 9101660378acf7ba8175a4e9d4aa5329f5c53a41 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Tue, 26 Mar 2024 14:01:23 +0100 Subject: [PATCH 03/19] Add intl extension --- docker/php/Dockerfile | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docker/php/Dockerfile b/docker/php/Dockerfile index 7fe4809..ee2ed10 100644 --- a/docker/php/Dockerfile +++ b/docker/php/Dockerfile @@ -32,6 +32,12 @@ RUN apt install -y \ && docker-php-ext-install \ zip +# Intl +RUN apt install -y \ + libicu-dev \ +&& docker-php-ext-install \ + intl + # Composer COPY --from=composer:latest /usr/bin/composer /usr/local/bin/composer From 12d6367c1093959938d09d53f389d94107e80c5c Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Wed, 27 Mar 2024 09:33:07 +0100 Subject: [PATCH 04/19] Add Bundle stof/doctrine-extensions-bundle --- composer.json | 1 + composer.lock | 259 ++++++++++++++++++++++++++++++++++++++++++++- config/bundles.php | 1 + symfony.lock | 12 +++ 4 files changed, 272 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 05c02fc..e4f05f5 100644 --- a/composer.json +++ b/composer.json @@ -16,6 +16,7 @@ "nelmio/cors-bundle": "^2.3", "phpdocumentor/reflection-docblock": "^5.3", "phpstan/phpdoc-parser": "^1.24", + "stof/doctrine-extensions-bundle": "^1.11", "subiabre/doctrine-snowflakes": "^2.0", "symfony/asset": "6.4.*", "symfony/console": "6.4.*", diff --git a/composer.lock b/composer.lock index ba88be0..9e9a0c9 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "b558cc67d1647e5cfb49487057a3af3d", + "content-hash": "e65a6d86be5982961b544a7bfa7704c9", "packages": [ { "name": "api-platform/core", @@ -172,6 +172,55 @@ }, "time": "2024-02-20T09:52:06+00:00" }, + { + "name": "behat/transliterator", + "version": "v1.5.0", + "source": { + "type": "git", + "url": "https://github.com/Behat/Transliterator.git", + "reference": "baac5873bac3749887d28ab68e2f74db3a4408af" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Behat/Transliterator/zipball/baac5873bac3749887d28ab68e2f74db3a4408af", + "reference": "baac5873bac3749887d28ab68e2f74db3a4408af", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "require-dev": { + "chuyskywalker/rolling-curl": "^3.1", + "php-yaoi/php-yaoi": "^1.0", + "phpunit/phpunit": "^8.5.25 || ^9.5.19" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Behat\\Transliterator\\": "src/Behat/Transliterator" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Artistic-1.0" + ], + "description": "String transliterator", + "keywords": [ + "i18n", + "slug", + "transliterator" + ], + "support": { + "issues": "https://github.com/Behat/Transliterator/issues", + "source": "https://github.com/Behat/Transliterator/tree/v1.5.0" + }, + "time": "2022-03-30T09:27:43+00:00" + }, { "name": "brick/math", "version": "0.11.0", @@ -1607,6 +1656,134 @@ }, "time": "2022-05-23T21:33:49+00:00" }, + { + "name": "gedmo/doctrine-extensions", + "version": "v3.15.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine-extensions/DoctrineExtensions.git", + "reference": "2a89103f4984d8970f3855284c8c04e6e6a63c0f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine-extensions/DoctrineExtensions/zipball/2a89103f4984d8970f3855284c8c04e6e6a63c0f", + "reference": "2a89103f4984d8970f3855284c8c04e6e6a63c0f", + "shasum": "" + }, + "require": { + "behat/transliterator": "^1.2", + "doctrine/collections": "^1.2 || ^2.0", + "doctrine/common": "^2.13 || ^3.0", + "doctrine/deprecations": "^1.0", + "doctrine/event-manager": "^1.2 || ^2.0", + "doctrine/persistence": "^2.2 || ^3.0", + "php": "^7.4 || ^8.0", + "psr/cache": "^1 || ^2 || ^3", + "psr/clock": "^1", + "symfony/cache": "^5.4 || ^6.0 || ^7.0" + }, + "conflict": { + "doctrine/annotations": "<1.13 || >=3.0", + "doctrine/dbal": "<3.2 || >=4.0", + "doctrine/mongodb-odm": "<2.3 || >=3.0", + "doctrine/orm": "<2.14.0 || 2.16.0 || 2.16.1 || >=3.0" + }, + "require-dev": { + "doctrine/annotations": "^1.13 || ^2.0", + "doctrine/cache": "^1.11 || ^2.0", + "doctrine/dbal": "^3.2", + "doctrine/doctrine-bundle": "^2.3", + "doctrine/mongodb-odm": "^2.3", + "doctrine/orm": "^2.14.0", + "friendsofphp/php-cs-fixer": "^3.14.0", + "nesbot/carbon": "^2.71 || ^3.0", + "phpstan/phpstan": "^1.10.2", + "phpstan/phpstan-doctrine": "^1.0", + "phpstan/phpstan-phpunit": "^1.0", + "phpunit/phpunit": "^9.6", + "rector/rector": "^0.19", + "symfony/console": "^5.4 || ^6.0 || ^7.0", + "symfony/phpunit-bridge": "^6.0 || ^7.0", + "symfony/yaml": "^5.4 || ^6.0 || ^7.0" + }, + "suggest": { + "doctrine/mongodb-odm": "to use the extensions with the MongoDB ODM", + "doctrine/orm": "to use the extensions with the ORM" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.13-dev" + } + }, + "autoload": { + "psr-4": { + "Gedmo\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gediminas Morkevicius", + "email": "gediminas.morkevicius@gmail.com" + }, + { + "name": "Gustavo Falco", + "email": "comfortablynumb84@gmail.com" + }, + { + "name": "David Buchmann", + "email": "david@liip.ch" + } + ], + "description": "Doctrine behavioral extensions", + "homepage": "http://gediminasm.org/", + "keywords": [ + "Blameable", + "behaviors", + "doctrine", + "extensions", + "gedmo", + "loggable", + "nestedset", + "odm", + "orm", + "sluggable", + "sortable", + "timestampable", + "translatable", + "tree", + "uploadable" + ], + "support": { + "email": "gediminas.morkevicius@gmail.com", + "issues": "https://github.com/doctrine-extensions/DoctrineExtensions/issues", + "source": "https://github.com/doctrine-extensions/DoctrineExtensions/tree/v3.15.0", + "wiki": "https://github.com/Atlantic18/DoctrineExtensions/tree/main/doc" + }, + "funding": [ + { + "url": "https://github.com/l3pp4rd", + "type": "github" + }, + { + "url": "https://github.com/mbabker", + "type": "github" + }, + { + "url": "https://github.com/phansys", + "type": "github" + }, + { + "url": "https://github.com/stof", + "type": "github" + } + ], + "time": "2024-02-12T15:17:22+00:00" + }, { "name": "godruoyi/php-snowflake", "version": "3.0.0", @@ -2354,6 +2531,86 @@ }, "time": "2021-07-14T16:46:02+00:00" }, + { + "name": "stof/doctrine-extensions-bundle", + "version": "v1.11.0", + "source": { + "type": "git", + "url": "https://github.com/stof/StofDoctrineExtensionsBundle.git", + "reference": "9f7023e4c8a1c00a5627d41c1027a3f89e477630" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/stof/StofDoctrineExtensionsBundle/zipball/9f7023e4c8a1c00a5627d41c1027a3f89e477630", + "reference": "9f7023e4c8a1c00a5627d41c1027a3f89e477630", + "shasum": "" + }, + "require": { + "gedmo/doctrine-extensions": "^3.15.0", + "php": "^7.4 || ^8.0", + "symfony/cache": "^5.4 || ^6.0 || ^7.0", + "symfony/config": "^5.4 || ^6.0 || ^7.0", + "symfony/dependency-injection": "^5.4 || ^6.0 || ^7.0", + "symfony/event-dispatcher": "^5.4 || ^6.0 || ^7.0", + "symfony/http-kernel": "^5.4 || ^6.0 || ^7.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.10", + "phpstan/phpstan-deprecation-rules": "^1.1", + "phpstan/phpstan-phpunit": "^1.3", + "phpstan/phpstan-strict-rules": "^1.5", + "phpstan/phpstan-symfony": "^1.3", + "symfony/mime": "^5.4 || ^6.0 || ^7.0", + "symfony/phpunit-bridge": "^v6.4.1 || ^7.0.1", + "symfony/security-core": "^5.4 || ^6.0 || ^7.0" + }, + "suggest": { + "doctrine/doctrine-bundle": "to use the ORM extensions", + "doctrine/mongodb-odm-bundle": "to use the MongoDB ODM extensions", + "symfony/mime": "To use the Mime component integration for Uploadable" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Stof\\DoctrineExtensionsBundle\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christophe Coevoet", + "email": "stof@notk.org" + } + ], + "description": "Integration of the gedmo/doctrine-extensions with Symfony", + "homepage": "https://github.com/stof/StofDoctrineExtensionsBundle", + "keywords": [ + "behaviors", + "doctrine2", + "extensions", + "gedmo", + "loggable", + "nestedset", + "sluggable", + "sortable", + "timestampable", + "translatable", + "tree" + ], + "support": { + "issues": "https://github.com/stof/StofDoctrineExtensionsBundle/issues", + "source": "https://github.com/stof/StofDoctrineExtensionsBundle/tree/v1.11.0" + }, + "time": "2024-02-13T14:43:20+00:00" + }, { "name": "subiabre/doctrine-snowflakes", "version": "2.0", diff --git a/config/bundles.php b/config/bundles.php index 16b2903..af8eb8c 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -11,4 +11,5 @@ return [ Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true], Zenstruck\Foundry\ZenstruckFoundryBundle::class => ['dev' => true, 'test' => true], Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true], + Stof\DoctrineExtensionsBundle\StofDoctrineExtensionsBundle::class => ['all' => true], ]; diff --git a/symfony.lock b/symfony.lock index be69af2..c52da5e 100644 --- a/symfony.lock +++ b/symfony.lock @@ -66,6 +66,18 @@ "tests/bootstrap.php" ] }, + "stof/doctrine-extensions-bundle": { + "version": "1.11", + "recipe": { + "repo": "github.com/symfony/recipes-contrib", + "branch": "main", + "version": "1.2", + "ref": "e805aba9eff5372e2d149a9ff56566769e22819d" + }, + "files": [ + "config/packages/stof_doctrine_extensions.yaml" + ] + }, "symfony/console": { "version": "6.3", "recipe": { From c59f15bcc2e41d674704ab81e3b377fb6b4a0cd4 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Wed, 27 Mar 2024 09:33:22 +0100 Subject: [PATCH 05/19] Configure loggable doctrine extension --- config/packages/doctrine.yaml | 6 ++++++ config/packages/stof_doctrine_extensions.yaml | 8 ++++++++ 2 files changed, 14 insertions(+) create mode 100644 config/packages/stof_doctrine_extensions.yaml diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index 559641f..a10c213 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -21,6 +21,12 @@ doctrine: dir: '%kernel.project_dir%/src/Entity' prefix: 'App\Entity' alias: App + + gedmo_loggable: + type: attribute + prefix: Gedmo\Loggable\Entity + dir: "%kernel.project_dir%/vendor/gedmo/doctrine-extensions/src/Loggable/Entity" + is_bundle: false when@test: doctrine: diff --git a/config/packages/stof_doctrine_extensions.yaml b/config/packages/stof_doctrine_extensions.yaml new file mode 100644 index 0000000..e069aaa --- /dev/null +++ b/config/packages/stof_doctrine_extensions.yaml @@ -0,0 +1,8 @@ +# Read the documentation: https://symfony.com/doc/current/bundles/StofDoctrineExtensionsBundle/index.html +# See the official DoctrineExtensions documentation for more details: https://github.com/doctrine-extensions/DoctrineExtensions/tree/main/doc +stof_doctrine_extensions: + default_locale: en_US + + orm: + default: + loggable: true From a759f51b4b24626bcdfc3f365d831d9bc6876194 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Wed, 27 Mar 2024 09:33:33 +0100 Subject: [PATCH 06/19] Allow to update User entity --- src/Entity/User.php | 16 +++++++----- src/Security/Voter/UserVoter.php | 42 ++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 6 deletions(-) create mode 100644 src/Security/Voter/UserVoter.php diff --git a/src/Entity/User.php b/src/Entity/User.php index 4d6ad75..7188e9f 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -8,6 +8,7 @@ use App\Repository\UserRepository; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; +use Gedmo\Mapping\Annotation as Gedmo; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; use Symfony\Component\Security\Core\User\UserInterface; @@ -20,12 +21,13 @@ use Symfony\Component\Validator\Constraints as Assert; * Users are the usual issuers of funding, however an User's Accounting can still be a Transaction recipient. * This allows to keep an User's "wallet", witholding their non-raised fundings into their Accounting. */ +#[Gedmo\Loggable()] #[API\GetCollection()] -#[API\Post()] +#[API\Post(validationContext: ['groups' => ['default', 'postValidation']])] #[API\Get()] -#[API\Put(security: 'is_granted("AUTH_USER_EDIT")')] -#[API\Delete(security: 'is_granted("AUTH_USER_EDIT")')] -#[API\Patch(security: 'is_granted("AUTH_USER_EDIT")')] +#[API\Put(security: 'is_granted("USER_EDIT", object)')] +#[API\Delete(security: 'is_granted("USER_EDIT", object)')] +#[API\Patch(security: 'is_granted("USER_EDIT", object)')] #[API\ApiFilter(filterClass: SearchFilter::class, properties: [ 'username' => 'partial', 'name' => 'partial' @@ -43,6 +45,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface /** * Human readable, non white space, unique string. */ + #[Gedmo\Versioned] #[Assert\NotBlank()] #[Assert\Length(min: 4, max: 30)] #[Assert\Regex('/^[a-z0-9_]+$/')] @@ -66,12 +69,13 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface /** * Plain-text, will be hashed by the platform. */ - #[Assert\NotBlank()] + #[Assert\NotBlank(['groups' => ['postValidation']])] #[Assert\Length(min: 12)] #[API\ApiProperty(writable: true, readable: false)] #[SerializedName('password')] private ?string $plainPassword = null; + #[Gedmo\Versioned] #[Assert\NotBlank()] #[Assert\Email()] #[ORM\Column(length: 255)] @@ -202,7 +206,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface return $this; } - public function getPlainPassword(): string + public function getPlainPassword(): ?string { return $this->plainPassword; } diff --git a/src/Security/Voter/UserVoter.php b/src/Security/Voter/UserVoter.php new file mode 100644 index 0000000..5757ced --- /dev/null +++ b/src/Security/Voter/UserVoter.php @@ -0,0 +1,42 @@ +getUser(); + + if (!$user instanceof UserInterface) { + return false; + } + + switch ($attribute) { + case self::EDIT: + return $user->getUserIdentifier() === $subject->getUserIdentifier() + || \in_array('ROLE_ADMIN', $user->getRoles()); + case self::VIEW: + return true; + } + + return false; + } +} From 20d7b984215fcaf9be0b94cf279da930ac688967 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Wed, 27 Mar 2024 10:00:15 +0100 Subject: [PATCH 07/19] Update user resource ownership --- src/Entity/Interface/UserOwnedInterface.php | 12 ++++++++++++ src/Entity/User.php | 18 ++++++++++++------ src/Entity/UserToken.php | 18 ++++++++++-------- src/Security/AccessTokenHandler.php | 2 +- ...{AuthOwnerVoter.php => UserOwnedVoter.php} | 19 ++++++++++++------- 5 files changed, 47 insertions(+), 22 deletions(-) create mode 100644 src/Entity/Interface/UserOwnedInterface.php rename src/Security/Voter/{AuthOwnerVoter.php => UserOwnedVoter.php} (65%) diff --git a/src/Entity/Interface/UserOwnedInterface.php b/src/Entity/Interface/UserOwnedInterface.php new file mode 100644 index 0000000..076b4b3 --- /dev/null +++ b/src/Entity/Interface/UserOwnedInterface.php @@ -0,0 +1,12 @@ +username; } + + public function getOwner(): ?User + { + return $this; + } public function isOwnedBy(User $user): bool { @@ -263,7 +269,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface { if (!$this->tokens->contains($token)) { $this->tokens->add($token); - $token->setOwnedBy($this); + $token->setOwner($this); } return $this; @@ -273,8 +279,8 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface { if ($this->tokens->removeElement($token)) { // set the owning side to null (unless already changed) - if ($token->getOwnedBy() === $this) { - $token->setOwnedBy(null); + if ($token->getOwner() === $this) { + $token->setOwner(null); } } diff --git a/src/Entity/UserToken.php b/src/Entity/UserToken.php index 1f4a4a0..03dfd21 100644 --- a/src/Entity/UserToken.php +++ b/src/Entity/UserToken.php @@ -4,6 +4,7 @@ namespace App\Entity; use ApiPlatform\Metadata as API; use App\Dto\UserTokenLoginDto; +use App\Entity\Interface\UserOwnedInterface; use App\Repository\UserTokenRepository; use App\State\UserTokenLoginProcessor; use Doctrine\ORM\Mapping as ORM; @@ -18,9 +19,10 @@ use Doctrine\ORM\Mapping as ORM; * `pat_` means the token was created via a login flow. */ #[API\Post(input: UserTokenLoginDto::class, processor: UserTokenLoginProcessor::class)] -#[API\Delete(security: 'is_granted("AUTH_OWNER")')] +#[API\Get(security: 'is_granted("USER_OWNED", object)')] +#[API\Delete(security: 'is_granted("USER_OWNED", object)')] #[ORM\Entity(repositoryClass: UserTokenRepository::class)] -class UserToken +class UserToken implements UserOwnedInterface { #[ORM\Id] #[ORM\GeneratedValue] @@ -38,7 +40,7 @@ class UserToken */ #[ORM\ManyToOne(inversedBy: 'tokens')] #[ORM\JoinColumn(nullable: false)] - private ?User $ownedBy = null; + private ?User $owner = null; public function getId(): ?int { @@ -57,20 +59,20 @@ class UserToken return $this; } - public function getOwnedBy(): ?User + public function getOwner(): ?User { - return $this->ownedBy; + return $this->owner; } - public function setOwnedBy(?User $ownedBy): static + public function setOwner(?User $owner): static { - $this->ownedBy = $ownedBy; + $this->owner = $owner; return $this; } public function isOwnedBy(User $user): bool { - return $this->ownedBy->getUserIdentifier() === $user->getUserIdentifier(); + return $this->owner->getUserIdentifier() === $user->getUserIdentifier(); } } diff --git a/src/Security/AccessTokenHandler.php b/src/Security/AccessTokenHandler.php index c2467de..bc9488c 100644 --- a/src/Security/AccessTokenHandler.php +++ b/src/Security/AccessTokenHandler.php @@ -21,6 +21,6 @@ class AccessTokenHandler implements AccessTokenHandlerInterface throw new BadCredentialsException(); } - return new UserBadge($token->getOwnedBy()->getUserIdentifier()); + return new UserBadge($token->getOwner()->getUserIdentifier()); } } diff --git a/src/Security/Voter/AuthOwnerVoter.php b/src/Security/Voter/UserOwnedVoter.php similarity index 65% rename from src/Security/Voter/AuthOwnerVoter.php rename to src/Security/Voter/UserOwnedVoter.php index 83964cf..f8f6225 100644 --- a/src/Security/Voter/AuthOwnerVoter.php +++ b/src/Security/Voter/UserOwnedVoter.php @@ -2,21 +2,28 @@ namespace App\Security\Voter; +use App\Entity\Interface\UserOwnedInterface; +use App\Entity\User; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authorization\Voter\Voter; use Symfony\Component\Security\Core\User\UserInterface; -class AuthOwnerVoter extends Voter +class UserOwnedVoter extends Voter { - public const OWNER = 'AUTH_OWNER'; + public const OWNED = 'USER_OWNED'; protected function supports(string $attribute, mixed $subject): bool { - return in_array($attribute, [self::OWNER]) - && $subject !== null - && method_exists($subject, 'isOwnedBy'); + if (!in_array($attribute, [self::OWNED])) { + return false; + } + + return $subject instanceof UserOwnedInterface; } + /** + * @param User|UserOwnedInterface $subject + */ protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool { $user = $token->getUser(); @@ -27,7 +34,5 @@ class AuthOwnerVoter extends Voter } return $subject->isOwnedBy($user); - - return false; } } From 3d7651846074dfb1175ef8a504cc54f6cc5309df Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Wed, 27 Mar 2024 10:09:33 +0100 Subject: [PATCH 08/19] Mark password as required in the api --- src/Entity/User.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Entity/User.php b/src/Entity/User.php index 98b9c27..7d0f4cf 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -72,7 +72,7 @@ class User implements UserInterface, UserOwnedInterface, PasswordAuthenticatedUs */ #[Assert\NotBlank(['groups' => ['postValidation']])] #[Assert\Length(min: 12)] - #[API\ApiProperty(writable: true, readable: false)] + #[API\ApiProperty(writable: true, readable: false, required: true)] #[SerializedName('password')] private ?string $plainPassword = null; From e7b546225bf8b17e306ee0fa88c88333c99cbc5f Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Wed, 27 Mar 2024 14:42:10 +0100 Subject: [PATCH 09/19] Fix bug with old reference to token owner setter --- src/Service/Auth/AuthService.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Service/Auth/AuthService.php b/src/Service/Auth/AuthService.php index fd7da70..2971905 100644 --- a/src/Service/Auth/AuthService.php +++ b/src/Service/Auth/AuthService.php @@ -28,7 +28,7 @@ class AuthService { $token = new UserToken; - $token->setOwnedBy($user); + $token->setOwner($user); $token->setToken(sprintf('%s%s', $type->value, hash( self::TOKEN_HASH_ALGO, join('', [ From 58ce6c7fea5c986e01a2b7759769bdbc08f44f85 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Wed, 27 Mar 2024 15:27:19 +0100 Subject: [PATCH 10/19] Add Version API resource --- src/ApiResource/Version.php | 67 ++++++++++++++++++++++++++++++ src/State/VersionStateProvider.php | 62 +++++++++++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 src/ApiResource/Version.php create mode 100644 src/State/VersionStateProvider.php diff --git a/src/ApiResource/Version.php b/src/ApiResource/Version.php new file mode 100644 index 0000000..c5504c5 --- /dev/null +++ b/src/ApiResource/Version.php @@ -0,0 +1,67 @@ +log->getId(); + } + + public function getVersion(): ?int + { + return $this->log->getVersion(); + } + + public function getAction(): ?string + { + return $this->log->getAction(); + } + + public function getResource(): string + { + $classPieces = explode('\\', $this->log->getObjectClass()); + + return end($classPieces); + } + + public function getResouceId(): int + { + return $this->log->getObjectId(); + } + + public function getResourceData() + { + $entity = $this->entity; + + foreach ($this->log->getData() as $changedProperty => $changedValue) { + $setter = sprintf("set%s", ucfirst($changedProperty)); + $entity->$setter($changedValue); + } + + return $entity; + } + + public function getResourceChanges() + { + return $this->log->getData(); + } + + public function getDateCreated(): ?\DateTimeInterface + { + return $this->log->getLoggedAt(); + } +} diff --git a/src/State/VersionStateProvider.php b/src/State/VersionStateProvider.php new file mode 100644 index 0000000..ad7642a --- /dev/null +++ b/src/State/VersionStateProvider.php @@ -0,0 +1,62 @@ +versionRepository = $this->entityManager->getRepository(LogEntry::class); + } + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null + { + switch ($operation::class) { + case API\Get::class: + return $this->getVersion($uriVariables['id']); + case API\GetCollection::class: + return $this->getVersions( + $context['request']->query->get('resource'), + $context['request']->query->get('resourceId') + ); + } + } + + private function getVersion(int $id) + { + $log = $this->versionRepository->find($id); + + if (!$log) { + throw new NotFoundHttpException("Not Found"); + } + + $entity = $this->entityManager->find($log->getObjectClass(), $log->getObjectId()); + + return new Version($log, $entity); + } + + private function getVersions(string $resource, int $resourceId) + { + $entity = $this->entityManager->find(sprintf("App\\Entity\\%s", $resource), $resourceId); + $logs = $this->versionRepository->getLogEntries($entity); + + $versions = []; + foreach ($logs as $log) { + $versions[] = new Version($log, $entity); + } + + return $versions; + } +} From 9c555275b04dfc59cfae69e551f56d48f8af461c Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Tue, 2 Apr 2024 11:12:22 +0200 Subject: [PATCH 11/19] Specify resource and resourceId required query params to retrieve versions --- src/ApiResource/Version.php | 4 ++ src/Entity/User.php | 2 +- src/Filter/VersionResourceFilter.php | 58 ++++++++++++++++++++++++++ src/Filter/VersionResourceIdFilter.php | 42 +++++++++++++++++++ 4 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 src/Filter/VersionResourceFilter.php create mode 100644 src/Filter/VersionResourceIdFilter.php diff --git a/src/ApiResource/Version.php b/src/ApiResource/Version.php index c5504c5..0a6f473 100644 --- a/src/ApiResource/Version.php +++ b/src/ApiResource/Version.php @@ -3,9 +3,13 @@ namespace App\ApiResource; use ApiPlatform\Metadata as API; +use App\Filter\VersionResourceFilter; +use App\Filter\VersionResourceIdFilter; use App\State\VersionStateProvider; use Gedmo\Loggable\Entity\LogEntry; +#[API\ApiFilter(VersionResourceFilter::class, properties: ['resource'])] +#[API\ApiFilter(VersionResourceIdFilter::class, properties: ['resourceId'])] #[API\GetCollection(provider: VersionStateProvider::class)] #[API\Get(provider: VersionStateProvider::class)] class Version diff --git a/src/Entity/User.php b/src/Entity/User.php index 7d0f4cf..52bc4da 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -157,7 +157,7 @@ class User implements UserInterface, UserOwnedInterface, PasswordAuthenticatedUs { return (string) $this->username; } - + public function getOwner(): ?User { return $this; diff --git a/src/Filter/VersionResourceFilter.php b/src/Filter/VersionResourceFilter.php new file mode 100644 index 0000000..fe7b33d --- /dev/null +++ b/src/Filter/VersionResourceFilter.php @@ -0,0 +1,58 @@ +properties as $property => $strategy) { + $description["$property"] = [ + 'property' => $property, + 'type' => Type::BUILTIN_TYPE_STRING, + 'required' => true, + 'description' => 'The name of the resource.', + 'schema' => [ + 'type' => Type::BUILTIN_TYPE_STRING, + 'enum' => ['User'] + ], + 'openapi' => [ + 'allowEmptyValue' => false, + ], + ]; + } + + return $description; + } +} diff --git a/src/Filter/VersionResourceIdFilter.php b/src/Filter/VersionResourceIdFilter.php new file mode 100644 index 0000000..0b68986 --- /dev/null +++ b/src/Filter/VersionResourceIdFilter.php @@ -0,0 +1,42 @@ +properties as $property => $strategy) { + $description["$property"] = [ + 'property' => $property, + 'type' => Type::BUILTIN_TYPE_INT, + 'required' => true, + 'description' => 'The ID of the named resource.', + 'openapi' => [ + 'allowEmptyValue' => false, + ], + ]; + } + + return $description; + } +} From 430583f6ba29b9abd12a0ffc4279a40a9b3dc66b Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Tue, 2 Apr 2024 11:29:40 +0200 Subject: [PATCH 12/19] Add docblocks --- src/ApiResource/Version.php | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/ApiResource/Version.php b/src/ApiResource/Version.php index 0a6f473..1ee3550 100644 --- a/src/ApiResource/Version.php +++ b/src/ApiResource/Version.php @@ -8,6 +8,12 @@ use App\Filter\VersionResourceIdFilter; use App\State\VersionStateProvider; use Gedmo\Loggable\Entity\LogEntry; +/** + * Some resources are versioned. This means v4 keeps track of the changes performed in subsets of specific properties within these resources.\ + * \ + * This allows us to keep track of the flow and the evolution of records in the platform. + * Looking at the changes done between one version and the next one we can reconstruct how a resource was at a certain point in time. + */ #[API\ApiFilter(VersionResourceFilter::class, properties: ['resource'])] #[API\ApiFilter(VersionResourceIdFilter::class, properties: ['resourceId'])] #[API\GetCollection(provider: VersionStateProvider::class)] @@ -20,21 +26,33 @@ class Version ) { } + /** + * The ID of the version record. + */ public function getId(): ?int { 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. + */ public function getAction(): ?string { return $this->log->getAction(); } + /** + * The type of the recorded resource. + */ public function getResource(): string { $classPieces = explode('\\', $this->log->getObjectClass()); @@ -42,11 +60,17 @@ class Version return end($classPieces); } + /** + * The ID of the recorded resource. + */ public function getResouceId(): int { return $this->log->getObjectId(); } + /** + * The full resource data, reconstructed from the current resource data merged with versioned data. + */ public function getResourceData() { $entity = $this->entity; @@ -59,11 +83,17 @@ class Version return $entity; } + /** + * The changed resource data, i.e the new values of the changed properties. + */ public function getResourceChanges() { return $this->log->getData(); } + /** + * The date at which this version was created. + */ public function getDateCreated(): ?\DateTimeInterface { return $this->log->getLoggedAt(); From b45ced3f5d6e4378b49ea1b6d42003b90b4fbc70 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Tue, 2 Apr 2024 11:34:33 +0200 Subject: [PATCH 13/19] Normalize resource name --- src/State/VersionStateProvider.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/State/VersionStateProvider.php b/src/State/VersionStateProvider.php index ad7642a..b2238b6 100644 --- a/src/State/VersionStateProvider.php +++ b/src/State/VersionStateProvider.php @@ -49,7 +49,7 @@ class VersionStateProvider implements ProviderInterface private function getVersions(string $resource, int $resourceId) { - $entity = $this->entityManager->find(sprintf("App\\Entity\\%s", $resource), $resourceId); + $entity = $this->entityManager->find($this->resourceToEntity($resource), $resourceId); $logs = $this->versionRepository->getLogEntries($entity); $versions = []; @@ -59,4 +59,9 @@ class VersionStateProvider implements ProviderInterface return $versions; } + + private function resourceToEntity(string $resource): string + { + return sprintf("App\\Entity\\%s", ucfirst(strtolower($resource))); + } } From 6f9937f394db92d4cd8b38532cad9f96e3ad3609 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Tue, 2 Apr 2024 11:37:34 +0200 Subject: [PATCH 14/19] Add return types --- src/State/VersionStateProvider.php | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/State/VersionStateProvider.php b/src/State/VersionStateProvider.php index b2238b6..859e58c 100644 --- a/src/State/VersionStateProvider.php +++ b/src/State/VersionStateProvider.php @@ -34,7 +34,10 @@ class VersionStateProvider implements ProviderInterface } } - private function getVersion(int $id) + /** + * @throws NotFoundHttpException + */ + private function getVersion(int $id): Version { $log = $this->versionRepository->find($id); @@ -47,7 +50,10 @@ class VersionStateProvider implements ProviderInterface return new Version($log, $entity); } - private function getVersions(string $resource, int $resourceId) + /** + * @return Version[] + */ + private function getVersions(string $resource, int $resourceId): array { $entity = $this->entityManager->find($this->resourceToEntity($resource), $resourceId); $logs = $this->versionRepository->getLogEntries($entity); From e3c97f77611141a9a5ffaf1aa549ae7869435f1a Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Wed, 3 Apr 2024 13:58:17 +0200 Subject: [PATCH 15/19] Add checkout and charge entities for gateways --- src/Entity/GatewayCharge.php | 97 +++++++++++++++ src/Entity/GatewayCheckout.php | 120 +++++++++++++++++++ src/Repository/GatewayChargeRepository.php | 48 ++++++++ src/Repository/GatewayCheckoutRepository.php | 48 ++++++++ 4 files changed, 313 insertions(+) create mode 100644 src/Entity/GatewayCharge.php create mode 100644 src/Entity/GatewayCheckout.php create mode 100644 src/Repository/GatewayChargeRepository.php create mode 100644 src/Repository/GatewayCheckoutRepository.php diff --git a/src/Entity/GatewayCharge.php b/src/Entity/GatewayCharge.php new file mode 100644 index 0000000..7ae246a --- /dev/null +++ b/src/Entity/GatewayCharge.php @@ -0,0 +1,97 @@ +id; + } + + public function getMoney(): ?Money + { + return $this->money; + } + + public function setMoney(Money $money): static + { + $this->money = $money; + + return $this; + } + + public function getTarget(): ?Accounting + { + return $this->target; + } + + public function setTarget(?Accounting $target): static + { + $this->target = $target; + + return $this; + } + + public function getExtradata(): ?array + { + return $this->extradata; + } + + public function setExtradata(?array $extradata): static + { + $this->extradata = $extradata; + + return $this; + } + + public function getCheckout(): ?GatewayCheckout + { + return $this->checkout; + } + + public function setCheckout(?GatewayCheckout $checkout): static + { + $this->checkout = $checkout; + + return $this; + } +} diff --git a/src/Entity/GatewayCheckout.php b/src/Entity/GatewayCheckout.php new file mode 100644 index 0000000..99dedc0 --- /dev/null +++ b/src/Entity/GatewayCheckout.php @@ -0,0 +1,120 @@ +charges = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getGateway(): ?string + { + return $this->gateway; + } + + public function setGateway(string $gateway): static + { + $this->gateway = $gateway; + + return $this; + } + + public function getOrigin(): ?Accounting + { + return $this->origin; + } + + public function setOrigin(?Accounting $origin): static + { + $this->origin = $origin; + + return $this; + } + + /** + * @return Collection + */ + public function getCharges(): Collection + { + return $this->charges; + } + + public function addCharge(GatewayCharge $charge): static + { + if (!$this->charges->contains($charge)) { + $this->charges->add($charge); + $charge->setCheckout($this); + } + + return $this; + } + + public function removeCharge(GatewayCharge $charge): static + { + if ($this->charges->removeElement($charge)) { + // set the owning side to null (unless already changed) + if ($charge->getCheckout() === $this) { + $charge->setCheckout(null); + } + } + + return $this; + } +} diff --git a/src/Repository/GatewayChargeRepository.php b/src/Repository/GatewayChargeRepository.php new file mode 100644 index 0000000..9748d28 --- /dev/null +++ b/src/Repository/GatewayChargeRepository.php @@ -0,0 +1,48 @@ + + * + * @method GatewayCharge|null find($id, $lockMode = null, $lockVersion = null) + * @method GatewayCharge|null findOneBy(array $criteria, array $orderBy = null) + * @method GatewayCharge[] findAll() + * @method GatewayCharge[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + */ +class GatewayChargeRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, GatewayCharge::class); + } + + // /** + // * @return GatewayCharge[] Returns an array of GatewayCharge objects + // */ + // public function findByExampleField($value): array + // { + // return $this->createQueryBuilder('g') + // ->andWhere('g.exampleField = :val') + // ->setParameter('val', $value) + // ->orderBy('g.id', 'ASC') + // ->setMaxResults(10) + // ->getQuery() + // ->getResult() + // ; + // } + + // public function findOneBySomeField($value): ?GatewayCharge + // { + // return $this->createQueryBuilder('g') + // ->andWhere('g.exampleField = :val') + // ->setParameter('val', $value) + // ->getQuery() + // ->getOneOrNullResult() + // ; + // } +} diff --git a/src/Repository/GatewayCheckoutRepository.php b/src/Repository/GatewayCheckoutRepository.php new file mode 100644 index 0000000..5f17bfd --- /dev/null +++ b/src/Repository/GatewayCheckoutRepository.php @@ -0,0 +1,48 @@ + + * + * @method GatewayCheckout|null find($id, $lockMode = null, $lockVersion = null) + * @method GatewayCheckout|null findOneBy(array $criteria, array $orderBy = null) + * @method GatewayCheckout[] findAll() + * @method GatewayCheckout[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + */ +class GatewayCheckoutRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, GatewayCheckout::class); + } + +// /** +// * @return GatewayCheckout[] Returns an array of GatewayCheckout objects +// */ +// public function findByExampleField($value): array +// { +// return $this->createQueryBuilder('g') +// ->andWhere('g.exampleField = :val') +// ->setParameter('val', $value) +// ->orderBy('g.id', 'ASC') +// ->setMaxResults(10) +// ->getQuery() +// ->getResult() +// ; +// } + +// public function findOneBySomeField($value): ?GatewayCheckout +// { +// return $this->createQueryBuilder('g') +// ->andWhere('g.exampleField = :val') +// ->setParameter('val', $value) +// ->getQuery() +// ->getOneOrNullResult() +// ; +// } +} From c93851015fee30efd7c4089c6f543f6784ae5aa2 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Wed, 3 Apr 2024 13:59:00 +0200 Subject: [PATCH 16/19] Transactions should be generated after Gateways checkout --- src/Entity/Transaction.php | 23 ------------- .../Economy/Payment/GatewayInterface.php | 12 +++---- .../Economy/Payment/GatewayLocator.php | 15 ++++++--- src/State/GatewayCheckoutProcessor.php | 33 +++++++++++++++++++ 4 files changed, 49 insertions(+), 34 deletions(-) create mode 100644 src/State/GatewayCheckoutProcessor.php diff --git a/src/Entity/Transaction.php b/src/Entity/Transaction.php index 605c850..75e2b3f 100644 --- a/src/Entity/Transaction.php +++ b/src/Entity/Transaction.php @@ -4,8 +4,6 @@ namespace App\Entity; use ApiPlatform\Metadata as API; use App\Repository\TransactionRepository; -use App\State\TransactionStateProcessor; -use App\Validator\GatewayName; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Validator\Constraints as Assert; @@ -17,7 +15,6 @@ use Symfony\Component\Validator\Constraints as Assert; */ #[ORM\Entity(repositoryClass: TransactionRepository::class)] #[API\GetCollection()] -#[API\Post(processor: TransactionStateProcessor::class)] #[API\Get()] class Transaction { @@ -50,14 +47,6 @@ class Transaction #[ORM\JoinColumn(nullable: false)] private ?Accounting $target = null; - /** - * The Gateway processing this Transaction. - */ - #[Assert\Valid()] - #[Assert\NotBlank()] - #[ORM\Embedded(class: TransactionGateway::class)] - private ?TransactionGateway $gateway = null; - public function getId(): ?int { return $this->id; @@ -98,16 +87,4 @@ class Transaction return $this; } - - public function getGateway(): ?TransactionGateway - { - return $this->gateway; - } - - public function setGateway(TransactionGateway $gateway): static - { - $this->gateway = $gateway; - - return $this; - } } diff --git a/src/Library/Economy/Payment/GatewayInterface.php b/src/Library/Economy/Payment/GatewayInterface.php index 23ff31e..8a3ee2b 100644 --- a/src/Library/Economy/Payment/GatewayInterface.php +++ b/src/Library/Economy/Payment/GatewayInterface.php @@ -2,7 +2,7 @@ namespace App\Library\Economy\Payment; -use App\Entity\Transaction; +use App\Entity\GatewayCheckout; interface GatewayInterface { @@ -12,11 +12,11 @@ interface GatewayInterface public static function getName(): string; /** - * When processing a Transaction a Gateway must validate it using it's own means. + * When processing a GatewayCheckout a Gateway must validate it using it's own means. * - * Gateways are trusted to have secured the funds in the Transaction. - * @param Transaction - * @return Transaction + * Gateways are trusted to have secured the funds in the GatewayCheckout. + * @param GatewayCheckout + * @return GatewayCheckout */ - public function process(Transaction $transaction): Transaction; + public function process(GatewayCheckout $transaction): GatewayCheckout; } diff --git a/src/Library/Economy/Payment/GatewayLocator.php b/src/Library/Economy/Payment/GatewayLocator.php index 7713fe4..f7fd094 100644 --- a/src/Library/Economy/Payment/GatewayLocator.php +++ b/src/Library/Economy/Payment/GatewayLocator.php @@ -2,7 +2,7 @@ namespace App\Library\Economy\Payment; -use App\Entity\Transaction; +use App\Entity\GatewayCheckout; class GatewayLocator { @@ -135,12 +135,17 @@ class GatewayLocator } /** - * @param Transaction $transaction + * @param GatewayCheckout $checkout * @return GatewayInterface - * @throws \Exception When the $transaction::gateway::name does not match to that of an implemented Gateway + * @throws \Exception When the $checkout::gateway does not match to that of an implemented Gateway */ - public function getGatewayOf(Transaction $transaction): GatewayInterface + public function getGatewayOf(GatewayCheckout $checkout): GatewayInterface { - return $this->getGateway($transaction->getGateway()->getName()); + $gateway = $checkout->getGateway(); + if (!$gateway) { + throw new \Exception("The given GatewayCheckout does not specify a Gateway"); + } + + return $this->getGateway($gateway); } } diff --git a/src/State/GatewayCheckoutProcessor.php b/src/State/GatewayCheckoutProcessor.php new file mode 100644 index 0000000..006c97a --- /dev/null +++ b/src/State/GatewayCheckoutProcessor.php @@ -0,0 +1,33 @@ +gatewayLocator->getGatewayOf($data); + + $this->entityManager->persist($data); + $this->entityManager->flush(); + + $gateway->process($data); + + return $data; + } +} From e9137bc0861a99f34ba63fb25fbf9af333e6a74f Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Thu, 4 Apr 2024 13:22:11 +0200 Subject: [PATCH 17/19] Update GatewayCheckout docblock --- src/Entity/GatewayCheckout.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Entity/GatewayCheckout.php b/src/Entity/GatewayCheckout.php index 99dedc0..4b7a073 100644 --- a/src/Entity/GatewayCheckout.php +++ b/src/Entity/GatewayCheckout.php @@ -12,7 +12,8 @@ use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Validator\Constraints as Assert; /** - * GatewayCheckout + * A GatewayCheckout bundles the data to perform a charge operation at a Gateway. + * Use it in order to create Transactions and have the transferred money be backed by a Gateway's payment processing. */ #[API\GetCollection()] #[API\Post(processor: GatewayCheckoutProcessor::class)] From 83e824329878cbfc873eab7abbf6c71c25c57fcd Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Thu, 4 Apr 2024 13:22:27 +0200 Subject: [PATCH 18/19] Fix GatewayInterface checkout var --- src/Library/Economy/Payment/GatewayInterface.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Library/Economy/Payment/GatewayInterface.php b/src/Library/Economy/Payment/GatewayInterface.php index 8a3ee2b..8e26a34 100644 --- a/src/Library/Economy/Payment/GatewayInterface.php +++ b/src/Library/Economy/Payment/GatewayInterface.php @@ -15,8 +15,8 @@ interface GatewayInterface * When processing a GatewayCheckout a Gateway must validate it using it's own means. * * Gateways are trusted to have secured the funds in the GatewayCheckout. - * @param GatewayCheckout + * @param GatewayCheckout $checkout * @return GatewayCheckout */ - public function process(GatewayCheckout $transaction): GatewayCheckout; + public function process(GatewayCheckout $checkout): GatewayCheckout; } From b56a5d0f28afb02869fede3c686ef80f7a75fbcd Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Thu, 4 Apr 2024 13:22:50 +0200 Subject: [PATCH 19/19] Save Gateway references in Transaction --- src/Entity/Transaction.php | 40 +++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/src/Entity/Transaction.php b/src/Entity/Transaction.php index 75e2b3f..810b97a 100644 --- a/src/Entity/Transaction.php +++ b/src/Entity/Transaction.php @@ -24,7 +24,7 @@ class Transaction private ?int $id = null; /** - * The monetary value to be received at target and issued at origin. + * The monetary value received at target and issued at origin. */ #[Assert\Valid()] #[Assert\NotBlank()] @@ -47,6 +47,20 @@ class Transaction #[ORM\JoinColumn(nullable: false)] private ?Accounting $target = null; + /** + * The name of the Gateway implementation that secured the money in this Transaction. + */ + #[Assert\NotBlank()] + #[ORM\Column(length: 255)] + private ?string $gateway = null; + + /** + * An external identifier provided by the Gateway for this Transaction. + */ + #[Assert\NotBlank()] + #[ORM\Column(length: 255)] + private ?string $gatewayReference = null; + public function getId(): ?int { return $this->id; @@ -87,4 +101,28 @@ class Transaction return $this; } + + public function getGateway(): ?string + { + return $this->gateway; + } + + public function setGateway(string $gateway): static + { + $this->gateway = $gateway; + + return $this; + } + + public function getGatewayReference(): ?string + { + return $this->gatewayReference; + } + + public function setGatewayReference(string $gatewayReference): static + { + $this->gatewayReference = $gatewayReference; + + return $this; + } }