Merge pull request #30 from GoteoFoundation/feat/add-wallet

[feat] Add Wallet Gateway
This commit is contained in:
2024-11-11 11:21:08 +01:00
committed by GitHub
98 changed files with 2887 additions and 1539 deletions
+4 -4
View File
@@ -39,15 +39,15 @@ DOCTRINE_ENCRYPT_KEY=""
CRYPTO_SECRET=""
###< doctrineencryptbundle/doctrine-encrypt-bundle ###
###> App\Library\Economy\Payment\StripeGateway ###
###> App\Gateway\Stripe\StripeGateway ###
STRIPE_API_KEY=""
STRIPE_WEBHOOK_SECRET=""
###< App\Library\Economy\Payment\StripeGateway ###
###< App\Gateway\Stripe\StripeGateway ###
###> App\Library\Economy\Payment\PaypalGateway ###
###> App\Gateway\Paypal\PaypalGateway ###
# PAYPAL_API_ADDRESS="https://api-m.paypal.com"
PAYPAL_API_ADDRESS="https://api-m.sandbox.paypal.com"
PAYPAL_CLIENT_ID=""
PAYPAL_CLIENT_SECRET=""
PAYPAL_WEBHOOK_ID="WEBHOOK_ID"
###< App\Library\Economy\Payment\PaypalGateway ###
###< App\Gateway\Paypal\PaypalGateway ###
+2
View File
@@ -8,6 +8,8 @@ return (new PhpCsFixer\Config())
->setRules([
'@Symfony' => true,
'method_argument_space' => ['on_multiline' => 'ensure_fully_multiline'],
'function_declaration' => ['closure_fn_spacing' => 'none'],
'single_line_empty_body' => true,
'yoda_style' => [
'equal' => false,
'identical' => false,
Generated
+79 -73
View File
@@ -4,20 +4,20 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "20e503cd4b7c41eca5ee6fcf83fa4ae8",
"content-hash": "0d0e2940f9aaecab4ba3c50bc5822de6",
"packages": [
{
"name": "api-platform/core",
"version": "v3.4.1",
"version": "v3.4.4",
"source": {
"type": "git",
"url": "https://github.com/api-platform/core.git",
"reference": "05f7d795252d32424b49336e5184557569809796"
"reference": "77d3ff3e8ddf88546eaf288a399d11f130553606"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/api-platform/core/zipball/05f7d795252d32424b49336e5184557569809796",
"reference": "05f7d795252d32424b49336e5184557569809796",
"url": "https://api.github.com/repos/api-platform/core/zipball/77d3ff3e8ddf88546eaf288a399d11f130553606",
"reference": "77d3ff3e8ddf88546eaf288a399d11f130553606",
"shasum": ""
},
"require": {
@@ -223,9 +223,9 @@
],
"support": {
"issues": "https://github.com/api-platform/core/issues",
"source": "https://github.com/api-platform/core/tree/v3.4.1"
"source": "https://github.com/api-platform/core/tree/v3.4.4"
},
"time": "2024-09-23T07:27:56+00:00"
"time": "2024-10-19T10:05:07+00:00"
},
{
"name": "beberlei/doctrineextensions",
@@ -708,16 +708,16 @@
},
{
"name": "doctrine/common",
"version": "3.4.4",
"version": "3.4.5",
"source": {
"type": "git",
"url": "https://github.com/doctrine/common.git",
"reference": "0aad4b7ab7ce8c6602dfbb1e1a24581275fb9d1a"
"reference": "6c8fef961f67b8bc802ce3e32e3ebd1022907286"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/doctrine/common/zipball/0aad4b7ab7ce8c6602dfbb1e1a24581275fb9d1a",
"reference": "0aad4b7ab7ce8c6602dfbb1e1a24581275fb9d1a",
"url": "https://api.github.com/repos/doctrine/common/zipball/6c8fef961f67b8bc802ce3e32e3ebd1022907286",
"reference": "6c8fef961f67b8bc802ce3e32e3ebd1022907286",
"shasum": ""
},
"require": {
@@ -779,7 +779,7 @@
],
"support": {
"issues": "https://github.com/doctrine/common/issues",
"source": "https://github.com/doctrine/common/tree/3.4.4"
"source": "https://github.com/doctrine/common/tree/3.4.5"
},
"funding": [
{
@@ -795,20 +795,20 @@
"type": "tidelift"
}
],
"time": "2024-04-16T13:35:33+00:00"
"time": "2024-10-08T15:53:43+00:00"
},
{
"name": "doctrine/dbal",
"version": "3.9.1",
"version": "3.9.3",
"source": {
"type": "git",
"url": "https://github.com/doctrine/dbal.git",
"reference": "d7dc08f98cba352b2bab5d32c5e58f7e745c11a7"
"reference": "61446f07fcb522414d6cfd8b1c3e5f9e18c579ba"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/doctrine/dbal/zipball/d7dc08f98cba352b2bab5d32c5e58f7e745c11a7",
"reference": "d7dc08f98cba352b2bab5d32c5e58f7e745c11a7",
"url": "https://api.github.com/repos/doctrine/dbal/zipball/61446f07fcb522414d6cfd8b1c3e5f9e18c579ba",
"reference": "61446f07fcb522414d6cfd8b1c3e5f9e18c579ba",
"shasum": ""
},
"require": {
@@ -824,7 +824,7 @@
"doctrine/coding-standard": "12.0.0",
"fig/log-test": "^1",
"jetbrains/phpstorm-stubs": "2023.1",
"phpstan/phpstan": "1.12.0",
"phpstan/phpstan": "1.12.6",
"phpstan/phpstan-strict-rules": "^1.6",
"phpunit/phpunit": "9.6.20",
"psalm/plugin-phpunit": "0.18.4",
@@ -892,7 +892,7 @@
],
"support": {
"issues": "https://github.com/doctrine/dbal/issues",
"source": "https://github.com/doctrine/dbal/tree/3.9.1"
"source": "https://github.com/doctrine/dbal/tree/3.9.3"
},
"funding": [
{
@@ -908,7 +908,7 @@
"type": "tidelift"
}
],
"time": "2024-09-01T13:49:23+00:00"
"time": "2024-10-10T17:56:43+00:00"
},
{
"name": "doctrine/deprecations",
@@ -1500,16 +1500,16 @@
},
{
"name": "doctrine/migrations",
"version": "3.8.1",
"version": "3.8.2",
"source": {
"type": "git",
"url": "https://github.com/doctrine/migrations.git",
"reference": "7760fbd0b7cb58bfb50415505a7bab821adf0877"
"reference": "5007eb1168691225ac305fe16856755c20860842"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/doctrine/migrations/zipball/7760fbd0b7cb58bfb50415505a7bab821adf0877",
"reference": "7760fbd0b7cb58bfb50415505a7bab821adf0877",
"url": "https://api.github.com/repos/doctrine/migrations/zipball/5007eb1168691225ac305fe16856755c20860842",
"reference": "5007eb1168691225ac305fe16856755c20860842",
"shasum": ""
},
"require": {
@@ -1583,7 +1583,7 @@
],
"support": {
"issues": "https://github.com/doctrine/migrations/issues",
"source": "https://github.com/doctrine/migrations/tree/3.8.1"
"source": "https://github.com/doctrine/migrations/tree/3.8.2"
},
"funding": [
{
@@ -1599,20 +1599,20 @@
"type": "tidelift"
}
],
"time": "2024-08-28T13:17:28+00:00"
"time": "2024-10-10T21:35:27+00:00"
},
{
"name": "doctrine/orm",
"version": "2.19.7",
"version": "2.20.0",
"source": {
"type": "git",
"url": "https://github.com/doctrine/orm.git",
"reference": "168ac31084226f94d42e7461a40ff5607a56bd35"
"reference": "8ed6c2234aba019f9737a6bcc9516438e62da27c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/doctrine/orm/zipball/168ac31084226f94d42e7461a40ff5607a56bd35",
"reference": "168ac31084226f94d42e7461a40ff5607a56bd35",
"url": "https://api.github.com/repos/doctrine/orm/zipball/8ed6c2234aba019f9737a6bcc9516438e62da27c",
"reference": "8ed6c2234aba019f9737a6bcc9516438e62da27c",
"shasum": ""
},
"require": {
@@ -1641,7 +1641,9 @@
"doctrine/annotations": "^1.13 || ^2",
"doctrine/coding-standard": "^9.0.2 || ^12.0",
"phpbench/phpbench": "^0.16.10 || ^1.0",
"phpstan/phpstan": "~1.4.10 || 1.11.1",
"phpstan/extension-installer": "~1.1.0 || ^1.4",
"phpstan/phpstan": "~1.4.10 || 1.12.6",
"phpstan/phpstan-deprecation-rules": "^1",
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.6",
"psr/log": "^1 || ^2 || ^3",
"squizlabs/php_codesniffer": "3.7.2",
@@ -1698,9 +1700,9 @@
],
"support": {
"issues": "https://github.com/doctrine/orm/issues",
"source": "https://github.com/doctrine/orm/tree/2.19.7"
"source": "https://github.com/doctrine/orm/tree/2.20.0"
},
"time": "2024-08-23T06:54:57+00:00"
"time": "2024-10-11T11:47:24+00:00"
},
{
"name": "doctrine/persistence",
@@ -1801,16 +1803,16 @@
},
{
"name": "doctrine/sql-formatter",
"version": "1.4.1",
"version": "1.5.1",
"source": {
"type": "git",
"url": "https://github.com/doctrine/sql-formatter.git",
"reference": "7f83911cc5eba870de7ebb11283972483f7e2891"
"reference": "b784cbde727cf806721451dde40eff4fec3bbe86"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/doctrine/sql-formatter/zipball/7f83911cc5eba870de7ebb11283972483f7e2891",
"reference": "7f83911cc5eba870de7ebb11283972483f7e2891",
"url": "https://api.github.com/repos/doctrine/sql-formatter/zipball/b784cbde727cf806721451dde40eff4fec3bbe86",
"reference": "b784cbde727cf806721451dde40eff4fec3bbe86",
"shasum": ""
},
"require": {
@@ -1818,6 +1820,7 @@
},
"require-dev": {
"doctrine/coding-standard": "^12",
"ergebnis/phpunit-slow-test-detector": "^2.14",
"phpstan/phpstan": "^1.10",
"phpunit/phpunit": "^10.5",
"vimeo/psalm": "^5.24"
@@ -1850,22 +1853,22 @@
],
"support": {
"issues": "https://github.com/doctrine/sql-formatter/issues",
"source": "https://github.com/doctrine/sql-formatter/tree/1.4.1"
"source": "https://github.com/doctrine/sql-formatter/tree/1.5.1"
},
"time": "2024-08-05T20:32:22+00:00"
"time": "2024-10-21T18:21:57+00:00"
},
{
"name": "doctrineencryptbundle/doctrine-encrypt-bundle",
"version": "5.4.0",
"version": "5.4.1",
"source": {
"type": "git",
"url": "https://github.com/DoctrineEncryptBundle/DoctrineEncryptBundle.git",
"reference": "aa60745e1548213f11467a1d41308d6c03f23faf"
"reference": "556c2ec0156d519378e6d62a61c2e924e4583e13"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/DoctrineEncryptBundle/DoctrineEncryptBundle/zipball/aa60745e1548213f11467a1d41308d6c03f23faf",
"reference": "aa60745e1548213f11467a1d41308d6c03f23faf",
"url": "https://api.github.com/repos/DoctrineEncryptBundle/DoctrineEncryptBundle/zipball/556c2ec0156d519378e6d62a61c2e924e4583e13",
"reference": "556c2ec0156d519378e6d62a61c2e924e4583e13",
"shasum": ""
},
"require": {
@@ -1915,22 +1918,22 @@
],
"support": {
"issues": "https://github.com/DoctrineEncryptBundle/DoctrineEncryptBundle/issues",
"source": "https://github.com/DoctrineEncryptBundle/DoctrineEncryptBundle/tree/5.4.0"
"source": "https://github.com/DoctrineEncryptBundle/DoctrineEncryptBundle/tree/5.4.1"
},
"time": "2024-03-02T18:14:49+00:00"
"time": "2024-10-13T10:08:12+00:00"
},
{
"name": "gedmo/doctrine-extensions",
"version": "v3.16.1",
"version": "v3.17.1",
"source": {
"type": "git",
"url": "https://github.com/doctrine-extensions/DoctrineExtensions.git",
"reference": "e85560ed96f977b8c29428a99222cb2ef2f0e80d"
"reference": "eabb45018c5a4362b46c5beae3881261da89f900"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/doctrine-extensions/DoctrineExtensions/zipball/e85560ed96f977b8c29428a99222cb2ef2f0e80d",
"reference": "e85560ed96f977b8c29428a99222cb2ef2f0e80d",
"url": "https://api.github.com/repos/doctrine-extensions/DoctrineExtensions/zipball/eabb45018c5a4362b46c5beae3881261da89f900",
"reference": "eabb45018c5a4362b46c5beae3881261da89f900",
"shasum": ""
},
"require": {
@@ -1947,14 +1950,14 @@
},
"conflict": {
"doctrine/annotations": "<1.13 || >=3.0",
"doctrine/dbal": "<3.2 || >=4.0",
"doctrine/dbal": "<3.7 || >=5.0",
"doctrine/mongodb-odm": "<2.3 || >=3.0",
"doctrine/orm": "<2.14.0 || 2.16.0 || 2.16.1 || >=4.0"
},
"require-dev": {
"doctrine/annotations": "^1.13 || ^2.0",
"doctrine/cache": "^1.11 || ^2.0",
"doctrine/dbal": "^3.2",
"doctrine/dbal": "^3.7 || ^4.0",
"doctrine/doctrine-bundle": "^2.3",
"doctrine/mongodb-odm": "^2.3",
"doctrine/orm": "^2.14.0 || ^3.0",
@@ -2026,7 +2029,7 @@
"support": {
"email": "gediminas.morkevicius@gmail.com",
"issues": "https://github.com/doctrine-extensions/DoctrineExtensions/issues",
"source": "https://github.com/doctrine-extensions/DoctrineExtensions/tree/v3.16.1",
"source": "https://github.com/doctrine-extensions/DoctrineExtensions/tree/v3.17.1",
"wiki": "https://github.com/Atlantic18/DoctrineExtensions/tree/main/doc"
},
"funding": [
@@ -2047,7 +2050,7 @@
"type": "github"
}
],
"time": "2024-06-25T16:22:14+00:00"
"time": "2024-10-07T22:30:27+00:00"
},
{
"name": "godruoyi/php-snowflake",
@@ -2737,16 +2740,16 @@
},
{
"name": "phpstan/phpdoc-parser",
"version": "1.31.0",
"version": "1.33.0",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpdoc-parser.git",
"reference": "249f15fb843bf240cf058372dad29e100cee6c17"
"reference": "82a311fd3690fb2bf7b64d5c98f912b3dd746140"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/249f15fb843bf240cf058372dad29e100cee6c17",
"reference": "249f15fb843bf240cf058372dad29e100cee6c17",
"url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/82a311fd3690fb2bf7b64d5c98f912b3dd746140",
"reference": "82a311fd3690fb2bf7b64d5c98f912b3dd746140",
"shasum": ""
},
"require": {
@@ -2778,9 +2781,9 @@
"description": "PHPDoc parser with support for nullable, intersection and generic types",
"support": {
"issues": "https://github.com/phpstan/phpdoc-parser/issues",
"source": "https://github.com/phpstan/phpdoc-parser/tree/1.31.0"
"source": "https://github.com/phpstan/phpdoc-parser/tree/1.33.0"
},
"time": "2024-09-22T11:32:18+00:00"
"time": "2024-10-13T11:25:22+00:00"
},
{
"name": "psr/cache",
@@ -4512,22 +4515,25 @@
},
{
"name": "symfony/flex",
"version": "v2.4.6",
"version": "v2.4.7",
"source": {
"type": "git",
"url": "https://github.com/symfony/flex.git",
"reference": "4dc11919791f81d087a12db2ab4c7e044431ef6b"
"reference": "92f4fba342161ff36072bd3b8e0b3c6c23160402"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/flex/zipball/4dc11919791f81d087a12db2ab4c7e044431ef6b",
"reference": "4dc11919791f81d087a12db2ab4c7e044431ef6b",
"url": "https://api.github.com/repos/symfony/flex/zipball/92f4fba342161ff36072bd3b8e0b3c6c23160402",
"reference": "92f4fba342161ff36072bd3b8e0b3c6c23160402",
"shasum": ""
},
"require": {
"composer-plugin-api": "^2.1",
"php": ">=8.0"
},
"conflict": {
"composer/semver": "<1.7.2"
},
"require-dev": {
"composer/composer": "^2.1",
"symfony/dotenv": "^5.4|^6.0",
@@ -4557,7 +4563,7 @@
"description": "Composer plugin for Symfony",
"support": {
"issues": "https://github.com/symfony/flex/issues",
"source": "https://github.com/symfony/flex/tree/v2.4.6"
"source": "https://github.com/symfony/flex/tree/v2.4.7"
},
"funding": [
{
@@ -4573,7 +4579,7 @@
"type": "tidelift"
}
],
"time": "2024-04-27T10:22:22+00:00"
"time": "2024-10-07T08:51:54+00:00"
},
{
"name": "symfony/framework-bundle",
@@ -8293,16 +8299,16 @@
},
{
"name": "nikic/php-parser",
"version": "v5.2.0",
"version": "v5.3.1",
"source": {
"type": "git",
"url": "https://github.com/nikic/PHP-Parser.git",
"reference": "23c79fbbfb725fb92af9bcf41065c8e9a0d49ddb"
"reference": "8eea230464783aa9671db8eea6f8c6ac5285794b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/23c79fbbfb725fb92af9bcf41065c8e9a0d49ddb",
"reference": "23c79fbbfb725fb92af9bcf41065c8e9a0d49ddb",
"url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/8eea230464783aa9671db8eea6f8c6ac5285794b",
"reference": "8eea230464783aa9671db8eea6f8c6ac5285794b",
"shasum": ""
},
"require": {
@@ -8345,9 +8351,9 @@
],
"support": {
"issues": "https://github.com/nikic/PHP-Parser/issues",
"source": "https://github.com/nikic/PHP-Parser/tree/v5.2.0"
"source": "https://github.com/nikic/PHP-Parser/tree/v5.3.1"
},
"time": "2024-09-15T16:40:33+00:00"
"time": "2024-10-08T18:51:32+00:00"
},
{
"name": "phar-io/manifest",
@@ -11146,7 +11152,7 @@
],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": [],
"stability-flags": {},
"prefer-stable": true,
"prefer-lowest": false,
"platform": {
@@ -11154,6 +11160,6 @@
"ext-ctype": "*",
"ext-iconv": "*"
},
"platform-dev": [],
"platform-dev": {},
"plugin-api-version": "2.6.0"
}
+18 -18
View File
@@ -12,12 +12,12 @@ services:
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
_instanceof:
App\Gateway\GatewayInterface:
tags: ['app.gateway.gateway']
App\Library\Benzina\Pump\PumpInterface:
tags: ['app.lib.benzina.pump.pump']
App\Library\Economy\Currency\ExchangeInterface:
tags: ['app.lib.economy.currency.exchange']
App\Library\Economy\Payment\GatewayInterface:
tags: ['app.lib.economy.payment.gateway']
# makes classes in src/ available to be used as services
# this creates a service per class whose id is the fully-qualified class name
@@ -37,6 +37,22 @@ services:
arguments:
- '@App\OpenApi\OpenApiFactory.inner'
App\Gateway\GatewayLocator:
arguments:
- !tagged 'app.gateway.gateway'
App\Gateway\Paypal\PaypalService:
arguments:
- '%env(PAYPAL_API_ADDRESS)%'
- '%env(PAYPAL_CLIENT_ID)%'
- '%env(PAYPAL_CLIENT_SECRET)%'
- '%env(PAYPAL_WEBHOOK_ID)%'
App\Gateway\Stripe\StripeGateway:
arguments:
- '%env(STRIPE_API_KEY)%'
- '%env(STRIPE_WEBHOOK_SECRET)%'
App\Service\Auth\AuthService:
arguments:
- '%env(APP_SECRET)%'
@@ -49,21 +65,5 @@ services:
arguments:
- !tagged 'app.lib.economy.currency.exchange'
App\Library\Economy\Payment\GatewayLocator:
arguments:
- !tagged 'app.lib.economy.payment.gateway'
App\Library\Economy\Payment\StripeGateway:
arguments:
- '%env(STRIPE_API_KEY)%'
- '%env(STRIPE_WEBHOOK_SECRET)%'
App\Library\Economy\Payment\PaypalGatewayService:
arguments:
- '%env(PAYPAL_API_ADDRESS)%'
- '%env(PAYPAL_CLIENT_ID)%'
- '%env(PAYPAL_CLIENT_SECRET)%'
- '%env(PAYPAL_WEBHOOK_ID)%'
# add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones
+1 -2
View File
@@ -1,8 +1,7 @@
[opcache]
opcache.enable=1
opcache.enable_cli=1
opcache.revalidate_freq=0
opcache.validate_timestamps=1
opcache.preload=/app/config/preload.php
opcache.memory_consumption=256
opcache.max_accelerated_files=20000
opcache.max_wasted_percentage=10
+1 -1
View File
@@ -1,7 +1,7 @@
[opcache]
opcache.enable=1
opcache.enable_cli=1
opcache.preload=/app/config/preload.php
opcache.preload_user=www-data
opcache.memory_consumption=256
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace App\ApiResource\Accounting;
use ApiPlatform\Doctrine\Orm\State\Options;
use ApiPlatform\Metadata as API;
use App\Entity\Accounting as Entity;
use App\Entity\Interface\AccountingOwnerInterface;
use App\State\Accounting\AccountingStateProcessor;
use App\State\Accounting\AccountingStateProvider;
/**
* Accountings represent payment services used to perform the necessary payments for Transactions between Accounts.\
* \
* For each Accounting there is an internal implementation that handles the creation and validation of Transactions between Accounts.
* These implementations make use of external or internal services to gather the funds that are inside a Transaction,
* perform corroboration of funds and store the Transactions into the system.
*/
#[API\ApiResource(
shortName: 'Accounting',
stateOptions: new Options(entityClass: Entity\Accounting::class),
provider: AccountingStateProvider::class,
processor: AccountingStateProcessor::class,
)]
#[API\GetCollection()]
#[API\Get()]
#[API\Patch(security: 'is_granted("ACCOUNTING_EDIT", object)')]
class Accounting
{
public ?int $id = null;
public ?string $currency = null;
public ?AccountingOwnerInterface $owner = null;
}
+63
View File
@@ -0,0 +1,63 @@
<?php
namespace App\ApiResource\Gateway;
use ApiPlatform\Doctrine\Orm\State\Options;
use ApiPlatform\Metadata as API;
use App\ApiResource\Accounting\Accounting;
use App\Entity\Gateway\Charge as EntityCharge;
use App\Entity\Money;
use App\Gateway\ChargeType;
use App\State\Gateway\ChargeStateProvider;
use Symfony\Component\Validator\Constraints as Assert;
/**
* A Charge represents a payment item to be included in a Checkout for payment at a Gateway.
*/
#[API\ApiResource(
shortName: 'GatewayCharge',
stateOptions: new Options(entityClass: EntityCharge::class),
provider: ChargeStateProvider::class
)]
#[API\Get()]
class Charge
{
#[API\ApiProperty(writable: false, identifier: true)]
public ?int $id = null;
/**
* How this item should be processed by the Gateway.\
* \
* `single` is for one time payments.\
* `recurring` is for payments repeated over time.
*/
#[Assert\NotBlank()]
public ChargeType $type = ChargeType::Single;
/**
* A short, descriptive string for this charge item.\
* May be displayed to the payer.
*/
#[Assert\NotBlank()]
public string $title;
/**
* Detailed information about the charge item.\
* May be displayed to the payer.
*/
public ?string $description = null;
/**
* The Accounting receiving the money after a successful payment.
*/
#[Assert\NotBlank()]
public Accounting $target;
/**
* The money to-be-paid for this item at the Gateway.
*
* It is money before fees and taxes, not accountable.
*/
#[Assert\NotBlank()]
public Money $money;
}
+74
View File
@@ -0,0 +1,74 @@
<?php
namespace App\ApiResource\Gateway;
use ApiPlatform\Doctrine\Orm\State\Options;
use ApiPlatform\Metadata as API;
use App\ApiResource\Accounting\Accounting;
use App\Entity\Gateway as Entity;
use App\Gateway\CheckoutStatus;
use App\Gateway\Link;
use App\Gateway\Tracking;
use App\State\Gateway\CheckoutStateProcessor;
use Symfony\Component\Validator\Constraints as Assert;
/**
* A GatewayCheckout represents a payment session with a Gateway.
*/
#[API\ApiResource(
shortName: 'GatewayCheckout',
stateOptions: new Options(entityClass: Entity\Checkout::class),
processor: CheckoutStateProcessor::class
)]
#[API\GetCollection()]
#[API\Post()]
#[API\Get()]
class Checkout
{
#[API\ApiProperty(writable: false, identifier: true)]
public ?int $id = null;
/**
* The desired Gateway to checkout with.
*/
#[Assert\NotBlank()]
public Gateway $gateway;
/**
* The Accounting paying for the charges.
*/
#[Assert\NotBlank()]
public Accounting $origin;
/**
* A list of the payment items to be charged to the origin.
*
* @var Charge[]
*/
#[API\ApiProperty(readableLink: true, writableLink: true)]
#[Assert\NotBlank()]
#[Assert\Count(min: 1)]
public array $charges = [];
/**
* The status of this Checkout, as confirmed by the Gateway.
*/
#[API\ApiProperty(writable: false)]
public CheckoutStatus $status = CheckoutStatus::Pending;
/**
* A list of related hyperlinks, as provided by the Gateway.
*
* @var Link[]
*/
#[API\ApiProperty(writable: false)]
public array $links = [];
/**
* A list of related tracking codes and numbers, as provided by the Gateway.
*
* @var Tracking[]
*/
#[API\ApiProperty(writable: false)]
public array $trackings = [];
}
@@ -1,10 +1,9 @@
<?php
namespace App\ApiResource;
namespace App\ApiResource\Gateway;
use ApiPlatform\Metadata as API;
use App\Library\Economy\Payment\GatewayInterface;
use App\State\GatewayStateProvider;
use App\State\Gateway\GatewayStateProvider;
/**
* Gateways represent payment services used to perform the necessary payments for Transactions between Accounts.\
@@ -13,17 +12,16 @@ use App\State\GatewayStateProvider;
* These implementations make use of external or internal services to gather the funds that are inside a Transaction,
* perform corroboration of funds and store the Transactions into the system.
*/
#[API\ApiResource(shortName: 'Gateway')]
#[API\GetCollection(provider: GatewayStateProvider::class)]
#[API\Get(provider: GatewayStateProvider::class)]
class Gateway
{
public function __construct(private readonly GatewayInterface $gateway)
{
}
#[API\ApiProperty(identifier: true)]
public function getName(): string
{
return $this->gateway->getName();
}
public ?string $name = null;
/**
* @var array<int, \App\Gateway\ChargeType>
*/
public ?array $supports = null;
}
+3 -4
View File
@@ -5,7 +5,7 @@ namespace App\ApiResource;
use ApiPlatform\Metadata as API;
use App\Filter\ResourceVersionResourceFilter;
use App\Filter\ResourceVersionResourceIdFilter;
use App\Service\ApiResourceNormalizer;
use App\Service\ApiService;
use App\State\ResourceVersionStateProvider;
use Gedmo\Loggable\Entity\LogEntry;
@@ -24,8 +24,7 @@ class Version
public function __construct(
private readonly LogEntry $log,
private readonly object $entity,
) {
}
) {}
/**
* The ID of the version record.
@@ -56,7 +55,7 @@ class Version
*/
public function getResource(): string
{
return ApiResourceNormalizer::toResource($this->log->getObjectClass());
return ApiService::toResource($this->log->getObjectClass());
}
/**
+2 -2
View File
@@ -50,9 +50,9 @@ class BenzinaPumpCommand extends Command
$this->addOption(
'skip-pumped',
null,
InputOption::VALUE_OPTIONAL,
InputOption::VALUE_NEGATABLE,
'Skips feeding already pumped records in a batch',
false
true
);
$this->addUsage('app:benzina:pump --no-debug user');
-48
View File
@@ -1,48 +0,0 @@
<?php
namespace App\Command;
use App\DependencyInjection\Compiler\GatewaysCompilerPass;
use App\Library\Economy\Payment\GatewayLocator;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:gateways:setup',
description: 'Post install setup for Gateways.',
)]
class GatewaysSetupCommand extends Command
{
private const SUCCESS_MESSAGE = 'Gateways setup completed successfully!';
private const FAILURE_MESSAGE = 'Could not setup Gateways. Please review the error.';
public function __construct(
private GatewayLocator $gatewayLocator,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
try {
$classes = $this->gatewayLocator->getClasses();
GatewaysCompilerPass::validateGatewayNames($classes);
$names = $this->gatewayLocator->getNames();
GatewaysCompilerPass::compileGatewayNames($names);
$io->success(self::SUCCESS_MESSAGE);
return Command::SUCCESS;
} catch (\Exception $e) {
$io->error(self::FAILURE_MESSAGE);
throw $e;
}
}
}
+1 -3
View File
@@ -21,9 +21,7 @@ class SystemVarsListCommand extends Command
parent::__construct();
}
protected function configure(): void
{
}
protected function configure(): void {}
protected function execute(InputInterface $input, OutputInterface $output): int
{
+2 -3
View File
@@ -2,7 +2,7 @@
namespace App\Controller;
use App\Library\Economy\Payment\GatewayLocator;
use App\Gateway\GatewayLocator;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -19,8 +19,7 @@ class GatewaysController extends AbstractController
public function __construct(
private GatewayLocator $gatewayLocator,
) {
}
) {}
#[Route('/gateway_redirects', name: self::REDIRECT)]
public function handleRedirect(Request $request): Response
@@ -1,158 +0,0 @@
<?php
namespace App\DependencyInjection\Compiler;
use App\Library\Economy\Payment\GatewayInterface;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
class GatewaysCompilerPass implements CompilerPassInterface
{
public const GATEWAYS_DIR = 'gateways';
public const GATEWAY_NAMES_LOCK = 'gateway_names_compiled.lock';
/**
* @return string The path to the gateways dir inside the project's var dir
*/
public static function getCompileDir(): string
{
return sprintf(
'%s%svar%s%s',
\dirname(__DIR__, 3),
DIRECTORY_SEPARATOR,
DIRECTORY_SEPARATOR,
self::GATEWAYS_DIR,
);
}
private static function makeCompileDir()
{
$compileDir = self::getCompileDir();
if (!\is_dir($compileDir)) {
\mkdir($compileDir, 0777, true);
}
}
/**
* @return string The path to the gateway names lock file
*/
public static function getLockFile(): string
{
return sprintf(
'%s%s%s',
self::getCompileDir(),
DIRECTORY_SEPARATOR,
self::GATEWAY_NAMES_LOCK
);
}
private static function writeLockFile(array $lines)
{
self::makeCompileDir();
\file_put_contents(
self::getLockFile(),
implode(PHP_EOL, $lines)
);
}
private static function getGatewaysNamespace(): string
{
return join('\\', \array_slice(explode('\\', GatewayInterface::class), 0, -1));
}
private function getGatewayNames(array $gatewayClasses): array
{
$names = [];
foreach ($gatewayClasses as $class) {
$names[] = $class::getName();
}
return $names;
}
private static function getGatewayClasses(string $classesDir): array
{
$namespace = self::getGatewaysNamespace();
$economyDirPaths = \scandir($classesDir);
$classes = [];
foreach ($economyDirPaths as $path) {
if ($path === '.' || $path === '..') {
continue;
}
$className = \rtrim($path, '.php');
if (!\str_ends_with($className, 'Gateway')) {
continue;
}
$reflection = new \ReflectionClass(sprintf("%s\%s", $namespace, $className));
if ($reflection->isAbstract()) {
continue;
}
$classes[] = $reflection->getName();
}
return $classes;
}
/**
* Ensures the gateway names are unique for each gateway.
*
* @param array $gatewayClasses Fully-qualified Gateway class names
*
* @throws \Exception If there are two different Gateway classes with the same name
*
* @see \App\Library\Economy\Payment\GatewayInterface::getName() To see the names returned by the implementations
*/
public static function validateGatewayNames(array $gatewayClasses): void
{
$gatewaysValidated = [];
foreach ($gatewayClasses as $gatewayClass) {
$gatewayName = $gatewayClass::getName();
if (\array_key_exists($gatewayName, $gatewaysValidated)) {
$exceptionMessage = sprintf(
"Duplicate Gateway name '%s' from class %s, name is already in use by class %s",
$gatewayName,
$gatewayClass,
$gatewaysValidated[$gatewayName]
);
throw new \Exception($exceptionMessage);
}
$gatewaysValidated[$gatewayName] = $gatewayClass;
}
}
/**
* Stores the gateway names in disk.
*
* @param array $names The names returned by the gateways
*
* @see \App\Library\Economy\Payment\GatewayInterface::getName() To see the names returned by the implementations
*/
public static function compileGatewayNames(array $names): void
{
self::writeLockFile($names);
}
public function process(ContainerBuilder $container): void
{
$classesDir = join(DIRECTORY_SEPARATOR, [
$container->getParameter('kernel.project_dir'),
'src',
...\array_slice(explode('\\', self::getGatewaysNamespace()), 1),
]);
$gatewayClasses = self::getGatewayClasses($classesDir);
self::validateGatewayNames($gatewayClasses);
$gatewayNames = $this->getGatewayNames($gatewayClasses);
self::compileGatewayNames($gatewayNames);
}
}
@@ -2,7 +2,7 @@
namespace App\DependencyInjection\Compiler;
use App\Service\ApiResourceNormalizer;
use App\Service\ApiService;
use App\Service\VersionedResourceService;
use Gedmo\Mapping\Annotation as Gedmo;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
@@ -28,7 +28,7 @@ class VersionedResourcePass implements CompilerPassInterface
$versionedResourceNames = [
...$versionedResourceNames,
ApiResourceNormalizer::toResource($entityClass),
ApiService::toResource($entityClass),
];
}
@@ -15,8 +15,7 @@ final class SystemVariablesLoader implements EnvVarLoaderInterface
{
public function __construct(
private SystemVariableRepository $systemVarRepository,
) {
}
) {}
public function loadEnvVars(): array
{
-236
View File
@@ -1,236 +0,0 @@
<?php
namespace App\Entity;
use ApiPlatform\Metadata as API;
use App\Repository\AccountingRepository;
use App\Service\ApiResourceNormalizer;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Accountings are the receivers and the issuers of Transactions.
*/
#[ORM\Entity(repositoryClass: AccountingRepository::class)]
#[API\Get()]
#[API\Patch(security: 'is_granted("ACCOUNTING_EDIT", object)')]
class Accounting
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
/**
* The preferred currency for monetary operations.\
* 3-letter ISO 4217 currency code.
*/
#[Assert\Currency()]
#[ORM\Column(length: 3)]
private ?string $currency = null;
/**
* @var Collection<int, AccountingTransaction>
*/
#[API\ApiProperty(writable: false)]
#[ORM\OneToMany(mappedBy: 'origin', targetEntity: AccountingTransaction::class)]
private Collection $transactionsIssued;
/**
* @var Collection<int, AccountingTransaction>
*/
#[API\ApiProperty(writable: false)]
#[ORM\OneToMany(mappedBy: 'target', targetEntity: AccountingTransaction::class)]
private Collection $transactionsReceived;
#[API\ApiProperty(writable: false, readable: false)]
#[ORM\Column(length: 255)]
private ?string $ownerClass = null;
#[API\ApiProperty(writable: false)]
#[ORM\OneToOne(mappedBy: 'accounting', cascade: ['persist', 'remove'])]
private ?User $user = null;
#[API\ApiProperty(writable: false)]
#[ORM\OneToOne(mappedBy: 'accounting', cascade: ['persist', 'remove'])]
private ?Project $project = null;
#[API\ApiProperty(writable: false)]
#[ORM\OneToOne(mappedBy: 'accounting', cascade: ['persist', 'remove'])]
private ?Tipjar $tipjar = null;
public function __construct()
{
/*
* TO-DO: This property must be loaded from App's configuration,
* ideally a configuration that can be updated via a frontend, not env var only
*/
$this->currency = 'EUR';
$this->transactionsIssued = new ArrayCollection();
$this->transactionsReceived = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getCurrency(): ?string
{
return $this->currency;
}
public function setCurrency(string $currency): static
{
$this->currency = $currency;
return $this;
}
/**
* @return Collection<int, AccountingTransaction>
*/
public function getTransactionsIssued(): Collection
{
return $this->transactionsIssued;
}
public function addTransactionsIssued(AccountingTransaction $transactionsIssued): static
{
if (!$this->transactionsIssued->contains($transactionsIssued)) {
$this->transactionsIssued->add($transactionsIssued);
$transactionsIssued->setOrigin($this);
}
return $this;
}
public function removeTransactionsIssued(AccountingTransaction $transactionsIssued): static
{
if ($this->transactionsIssued->removeElement($transactionsIssued)) {
// set the owning side to null (unless already changed)
if ($transactionsIssued->getOrigin() === $this) {
$transactionsIssued->setOrigin(null);
}
}
return $this;
}
/**
* @return Collection<int, AccountingTransaction>
*/
public function getTransactionsReceived(): Collection
{
return $this->transactionsReceived;
}
public function addTransactionsReceived(AccountingTransaction $transactionsReceived): static
{
if (!$this->transactionsReceived->contains($transactionsReceived)) {
$this->transactionsReceived->add($transactionsReceived);
$transactionsReceived->setTarget($this);
}
return $this;
}
public function removeTransactionsReceived(AccountingTransaction $transactionsReceived): static
{
if ($this->transactionsReceived->removeElement($transactionsReceived)) {
// set the owning side to null (unless already changed)
if ($transactionsReceived->getTarget() === $this) {
$transactionsReceived->setTarget(null);
}
}
return $this;
}
public function getOwnerClass(): ?string
{
return $this->ownerClass;
}
public function setOwnerClass(string $ownerClass): static
{
// ensure ownership does not change
if (
$this->ownerClass !== null
&& $this->ownerClass !== $ownerClass
) {
throw new \Exception('Are you trying to commit fraud? Cannot change ownership of an Accounting.');
}
$this->ownerClass = $ownerClass;
return $this;
}
public function getOwnerResource(): string
{
return ApiResourceNormalizer::toResource($this->ownerClass);
}
public function getUser(): ?User
{
return $this->user;
}
public function setUser(User $user): static
{
// set the owning side of the relation if necessary
if ($user->getAccounting() !== $this) {
$user->setAccounting($this);
}
// set the owner class
$this->setOwnerClass($user::class);
$this->user = $user;
return $this;
}
public function getProject(): ?Project
{
return $this->project;
}
public function setProject(Project $project): static
{
// set the owning side of the relation if necessary
if ($project->getAccounting() !== $this) {
$project->setAccounting($this);
}
// set the owner class
$this->setOwnerClass($project::class);
$this->project = $project;
return $this;
}
public function getTipjar(): ?Tipjar
{
return $this->tipjar;
}
public function setTipjar(Tipjar $tipjar): static
{
// set the owning side of the relation if necessary
if ($tipjar->getAccounting() !== $this) {
$tipjar->setAccounting($this);
}
// set the owner class
$this->setOwnerClass($tipjar::class);
$this->tipjar = $tipjar;
return $this;
}
}
+167
View File
@@ -0,0 +1,167 @@
<?php
namespace App\Entity\Accounting;
use App\Entity\Interface\AccountingOwnerInterface;
use App\Entity\Project;
use App\Entity\Tipjar;
use App\Entity\User;
use App\Repository\Accounting\AccountingRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Accountings are the receivers and the issuers of Transactions.
*/
#[ORM\Entity(repositoryClass: AccountingRepository::class)]
class Accounting
{
public const OWNER_CHANGE_NOT_ALLOWED = 'Are you trying to commit fraud? Cannot change ownership of an Accounting.';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
/**
* The preferred currency for monetary operations.\
* 3-letter ISO 4217 currency code.
*/
#[Assert\Currency()]
#[ORM\Column(length: 3)]
private ?string $currency = null;
#[ORM\Column(length: 255)]
private ?string $owner = null;
#[ORM\OneToOne(mappedBy: 'accounting', cascade: ['persist'])]
private ?User $user = null;
#[ORM\OneToOne(mappedBy: 'accounting', cascade: ['persist'])]
private ?Project $project = null;
#[ORM\OneToOne(mappedBy: 'accounting', cascade: ['persist'])]
private ?Tipjar $tipjar = null;
public function __construct()
{
/*
* TO-DO: This property must be loaded from App's configuration,
* ideally a configuration that can be updated via a frontend, not env var only
*/
$this->currency = 'EUR';
}
public function getId(): ?int
{
return $this->id;
}
public function getCurrency(): ?string
{
return $this->currency;
}
public function setCurrency(string $currency): static
{
$this->currency = $currency;
return $this;
}
public function getOwner(): AccountingOwnerInterface
{
$owner = $this->owner;
return $this->$owner;
}
public function setOwner(AccountingOwnerInterface $owner): static
{
if ($owner instanceof User) {
$this->owner = 'user';
return $this->setUser($owner);
}
if ($owner instanceof Project) {
$this->owner = 'project';
return $this->setProject($owner);
}
if ($owner instanceof Tipjar) {
$this->owner = 'tipjar';
return $this->setTipjar($owner);
}
throw new \Exception(sprintf('%s is not a recognized AccountingOwnerInterface', $owner::class));
}
public function getUser(): ?User
{
return $this->user;
}
public function setUser(?User $user): static
{
// unset the owning side of the relation if necessary
if ($user === null && $this->user !== null) {
$this->user->setAccounting(null);
}
// set the owning side of the relation if necessary
if ($user !== null && $user->getAccounting() !== $this) {
$user->setAccounting($this);
}
$this->user = $user;
return $this;
}
public function getProject(): ?Project
{
return $this->project;
}
public function setProject(?Project $project): static
{
// unset the owning side of the relation if necessary
if ($project === null && $this->project !== null) {
$this->project->setAccounting(null);
}
// set the owning side of the relation if necessary
if ($project !== null && $project->getAccounting() !== $this) {
$project->setAccounting($this);
}
$this->project = $project;
return $this;
}
public function getTipjar(): ?Tipjar
{
return $this->tipjar;
}
public function setTipjar(?Tipjar $tipjar): static
{
// unset the owning side of the relation if necessary
if ($tipjar === null && $this->tipjar !== null) {
$this->tipjar->setAccounting(null);
}
// set the owning side of the relation if necessary
if ($tipjar !== null && $tipjar->getAccounting() !== $this) {
$tipjar->setAccounting($this);
}
$this->tipjar = $tipjar;
return $this;
}
}
@@ -1,9 +1,10 @@
<?php
namespace App\Entity;
namespace App\Entity\Accounting;
use ApiPlatform\Metadata as API;
use App\Repository\AccountingTransactionRepository;
use App\Entity\Money;
use App\Repository\Accounting\TransactionRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
@@ -16,8 +17,8 @@ use Symfony\Component\Validator\Constraints as Assert;
* AccountingTransactions are generated for each GatewayCharge in a GatewayCheckout once it becomes successful.
*/
#[API\Get()]
#[ORM\Entity(repositoryClass: AccountingTransactionRepository::class)]
class AccountingTransaction
#[ORM\Entity(repositoryClass: TransactionRepository::class)]
class Transaction
{
#[ORM\Id]
#[ORM\GeneratedValue]
@@ -34,16 +35,16 @@ class AccountingTransaction
private ?Money $money = null;
/**
* The Accounting issuing the money of this Transaction.
* The Accounting from which the Transaction comes from.
*/
#[ORM\ManyToOne(inversedBy: 'transactionsIssued', cascade: ['persist'])]
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: false)]
private ?Accounting $origin = null;
/**
* The Accounting receiving the money of this Transaction.
* The Accounting where the Transaction goes to.
*/
#[ORM\ManyToOne(inversedBy: 'transactionsReceived', cascade: ['persist'])]
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: false)]
private ?Accounting $target = null;
+176
View File
@@ -0,0 +1,176 @@
<?php
namespace App\Entity\Gateway;
use App\Entity\Accounting\Accounting;
use App\Entity\Accounting\Transaction;
use App\Entity\Money;
use App\Gateway\ChargeType;
use App\Repository\Gateway\ChargeRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
/**
* A GatewayCharge represents a monetary payment that can be done by an issuer at checkout with the Gateway.
*/
#[ORM\Entity(repositoryClass: ChargeRepository::class)]
class Charge
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(inversedBy: 'charges')]
private ?Checkout $checkout = null;
/**
* The type represents the kind of payment for the charged money.
*/
#[Assert\NotBlank()]
#[ORM\Column()]
private ?ChargeType $type = null;
/**
* A short, descriptive text for this charge operation.
*/
#[Assert\NotBlank()]
#[ORM\Column(length: 255)]
private ?string $title = null;
/**
* Detailed message about this charge operation.
*/
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $description = null;
/**
* The Accounting receiving the consequent Transaction for this GatewayCharge.
*/
#[Assert\NotBlank()]
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: false)]
private ?Accounting $target = null;
/**
* The charged monetary sum.
*/
#[Assert\NotBlank()]
#[ORM\Embedded(Money::class)]
private ?Money $money = null;
/**
* @var Collection<int, Transaction>
*/
#[ORM\ManyToMany(targetEntity: Transaction::class, cascade: ['persist'])]
private Collection $transactions;
public function __construct()
{
$this->transactions = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getCheckout(): ?Checkout
{
return $this->checkout;
}
public function setCheckout(?Checkout $checkout): static
{
$this->checkout = $checkout;
return $this;
}
public function getType(): ?ChargeType
{
return $this->type;
}
public function setType(ChargeType $type): static
{
$this->type = $type;
return $this;
}
public function getTarget(): ?Accounting
{
return $this->target;
}
public function setTarget(?Accounting $target): static
{
$this->target = $target;
return $this;
}
public function getMoney(): ?Money
{
return $this->money;
}
public function setMoney(Money $money): static
{
$this->money = $money;
return $this;
}
public function getTitle(): ?string
{
return $this->title;
}
public function setTitle(string $title): static
{
$this->title = $title;
return $this;
}
public function getDescription(): ?string
{
return $this->description;
}
public function setDescription(?string $description): static
{
$this->description = $description;
return $this;
}
/**
* @return Collection<int, Transaction>
*/
public function getTransactions(): Collection
{
return $this->transactions;
}
public function addTransaction(Transaction $transaction): static
{
if (!$this->transactions->contains($transaction)) {
$this->transactions->add($transaction);
}
return $this;
}
public function removeTransaction(Transaction $transaction): static
{
$this->transactions->removeElement($transaction);
return $this;
}
}
+257
View File
@@ -0,0 +1,257 @@
<?php
namespace App\Entity\Gateway;
use App\Entity\Accounting\Accounting;
use App\Entity\Trait\MigratedEntity;
use App\Entity\Trait\TimestampedCreationEntity;
use App\Entity\Trait\TimestampedUpdationEntity;
use App\Gateway\CheckoutStatus;
use App\Gateway\Link;
use App\Gateway\Tracking;
use App\Repository\Gateway\CheckoutRepository;
use App\Validator\SupportedChargeTypes;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Gedmo\Mapping\Annotation as Gedmo;
use Symfony\Component\Validator\Constraints as Assert;
/**
* A GatewayCheckout bundles the data to perform a payment operation with a Gateway.\
* \
* Once the Gateway validates the payment as successful the GatewayCheckout will be updated
* and respective AccountingTransactions will be generated for each Charge.
*/
#[Gedmo\Loggable()]
#[ORM\Entity(repositoryClass: CheckoutRepository::class)]
#[ORM\Index(fields: ['migratedId'])]
class Checkout
{
use MigratedEntity;
use TimestampedCreationEntity;
use TimestampedUpdationEntity;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
/**
* The Accounting that will issue the Transactions of the GatewayCharges after a successful checkout.
*/
#[Assert\NotBlank()]
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: false)]
private ?Accounting $origin = null;
/**
* The status of the checkout with the Gateway.
*/
#[Gedmo\Versioned]
#[ORM\Column()]
private ?CheckoutStatus $status = null;
/**
* The GatewayCharges to be charged at checkout with the gateway.
*
* @var Collection<int, Charge>
*/
#[Assert\NotBlank()]
#[Assert\Count(min: 1)]
#[SupportedChargeTypes()]
#[ORM\OneToMany(mappedBy: 'checkout', targetEntity: Charge::class, cascade: ['persist'])]
private Collection $charges;
/**
* The name of the Gateway implementation to checkout with.
*/
#[Assert\NotBlank()]
#[ORM\Column(length: 255)]
private ?string $gateway = null;
/**
* A list of URLs provided by the Gateway for this checkout.\
* e.g: Fulfill payment, API resource address.
*
* @var Link[]
*/
#[ORM\Column]
private array $links = [];
/**
* A list of tracking codes provided by the Gateway for this checkout.\
* e.g: Order ID, Payment Capture ID, Checkout Session Token.
*
* @var Tracking[]
*/
#[ORM\Column]
private array $trackings = [];
public function __construct()
{
$this->status = CheckoutStatus::Pending;
$this->charges = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getOrigin(): ?Accounting
{
return $this->origin;
}
public function setOrigin(?Accounting $origin): static
{
$this->origin = $origin;
return $this;
}
public function getStatus(): ?CheckoutStatus
{
return $this->status;
}
public function setStatus(CheckoutStatus $status): static
{
$this->status = $status;
return $this;
}
/**
* @return Collection<int, Charge>
*/
public function getCharges(): Collection
{
return $this->charges;
}
/**
* @param Collection<int, Charge> $charges
*/
public function setCharges(Collection $charges): static
{
$this->charges = new ArrayCollection();
foreach ($charges as $charge) {
$this->charges->add($charge);
$charge->setCheckout($this);
}
return $this;
}
public function addCharge(Charge $charge): static
{
if (!$this->charges->contains($charge)) {
$this->charges->add($charge);
$charge->setCheckout($this);
}
return $this;
}
public function removeCharge(Charge $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;
}
public function getGateway(): ?string
{
return $this->gateway;
}
public function setGateway(string $gateway): static
{
$this->gateway = $gateway;
return $this;
}
/**
* @return Link[]
*/
public function getLinks(): array
{
return $this->links;
}
/**
* @param Link[] $links
*/
public function setLinks(array $links): static
{
$this->links = $links;
return $this;
}
public function addLink(Link $link): static
{
$this->links = [...$this->links, $link];
return $this;
}
public function removeLink(Link $link): static
{
$this->links = \array_filter(
$this->links,
function (Link $existingLink) use ($link) {
return $existingLink->href !== $link->href;
}
);
return $this;
}
/**
* @return Tracking[]
*/
public function getTrackings(): array
{
return $this->trackings;
}
/**
* @param Tracking[] $trackings
*/
public function setTrackings(array $trackings): static
{
$this->trackings = $trackings;
return $this;
}
public function addTracking(Tracking $tracking): static
{
$this->trackings = [...$this->trackings, $tracking];
return $this;
}
public function removeTracking(Tracking $tracking): static
{
$this->trackings = \array_filter(
$this->trackings,
function (Tracking $existingTracking) use ($tracking) {
return $existingTracking->title !== $tracking->title
&& $existingTracking->value !== $tracking->value;
}
);
return $this;
}
}
-106
View File
@@ -1,106 +0,0 @@
<?php
namespace App\Entity;
use ApiPlatform\Metadata as API;
use App\Repository\GatewayChargeRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
/**
* A GatewayCharge represents a monetary payment that can be done by an issuer at checkout with the Gateway.
*/
#[API\ApiResource()]
#[ORM\Entity(repositoryClass: GatewayChargeRepository::class)]
class GatewayCharge
{
// TO-DO: This message should be translatable to the User's language
public const MESSAGE_STATEMENT = 'PAGO EN GOTEO.ORG';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
/**
* The type of a GatewayCharge represents the kind of payment.
*/
#[Assert\NotBlank()]
#[ORM\Column()]
private ?GatewayChargeType $type = null;
/**
* The charged monetary sum.
*/
#[Assert\NotBlank()]
#[ORM\Embedded(Money::class)]
private ?Money $money = null;
/**
* The Accounting receiving the consequent Transaction for this GatewayCharge.
*/
#[Assert\NotBlank()]
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: false)]
private ?Accounting $target = null;
/**
* The AccountingTransaction generated for this charge after the checkout with the Gateway.
*/
#[API\ApiProperty(writable: false)]
#[ORM\OneToOne(cascade: ['persist', 'remove'])]
private ?AccountingTransaction $transaction = null;
public function getId(): ?int
{
return $this->id;
}
public function getType(): ?GatewayChargeType
{
return $this->type;
}
public function setType(GatewayChargeType $type): static
{
$this->type = $type;
return $this;
}
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 getTransaction(): ?AccountingTransaction
{
return $this->transaction;
}
public function setTransaction(?AccountingTransaction $transaction): static
{
$this->transaction = $transaction;
return $this;
}
}
-276
View File
@@ -1,276 +0,0 @@
<?php
namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata as API;
use App\Entity\Trait\TimestampableCreationEntity;
use App\Entity\Trait\TimestampableUpdationEntity;
use App\Repository\GatewayCheckoutRepository;
use App\State\GatewayCheckoutProcessor;
use App\Validator\GatewayName;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Gedmo\Mapping\Annotation as Gedmo;
use Symfony\Component\Validator\Constraints as Assert;
/**
* A GatewayCheckout bundles the data to perform a payment operation with a Gateway.\
* \
* Once the Gateway validates the payment as successful the GatewayCheckout will be updated
* and respective AccountingTransactions will be generated for each GatewayCharge.
*/
#[Gedmo\Loggable()]
#[API\GetCollection()]
#[API\Post(processor: GatewayCheckoutProcessor::class)]
#[API\Get()]
#[API\ApiFilter(filterClass: SearchFilter::class, properties: ['origin' => 'exact', 'charges.target' => 'exact'])]
#[ORM\Entity(repositoryClass: GatewayCheckoutRepository::class)]
#[ORM\Index(fields: ['migratedId'])]
class GatewayCheckout
{
use TimestampableCreationEntity;
use TimestampableUpdationEntity;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
/**
* The Accounting that will issue the Transactions of the GatewayCharges after a successful checkout.
*/
#[Assert\NotBlank()]
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: false)]
private ?Accounting $origin = null;
/**
* The GatewayCharges to be charged at checkout with the Gateway.
*/
#[Assert\NotBlank()]
#[Assert\Count(min: 1)]
#[ORM\ManyToMany(targetEntity: GatewayCharge::class, cascade: ['persist'])]
private Collection $charges;
/**
* The status of the checkout with the Gateway.
*/
#[Gedmo\Versioned]
#[API\ApiProperty(writable: false)]
#[API\ApiFilter(SearchFilter::class)]
#[ORM\Column()]
private ?GatewayCheckoutStatus $status = null;
/**
* The name of the Gateway implementation to checkout with.
*/
#[GatewayName]
#[Assert\NotBlank()]
#[API\ApiFilter(SearchFilter::class)]
#[ORM\Column(length: 255)]
private ?string $gateway = null;
/**
* A list of URLs provided by the Gateway for this checkout.\
* e.g: Fulfill payment, API resource address.
*
* @var GatewayLink[]
*/
#[API\ApiProperty(writable: false)]
#[ORM\Column]
private array $gatewayLinks = [];
/**
* A list of tracking codes provided by the Gateway for this checkout.\
* e.g: Order ID, Payment Capture ID, Checkout Session Token.
*
* @var GatewayTracking[]
*/
#[API\ApiProperty(writable: false)]
#[ORM\Column]
private array $gatewayTrackings = [];
/**
* GatewayCheckout was migrated from an invest record in Goteo v3 platform.
*/
#[API\ApiProperty(writable: false)]
#[ORM\Column]
private ?bool $migrated = false;
/**
* The id of the original invest record in the Goteo v3 platform.
*/
#[API\ApiProperty(writable: false)]
#[ORM\Column(length: 255, nullable: true)]
private ?string $migratedId = null;
/**
* A free-form collection of additional data associated with this checkout operation.
*/
#[ORM\Column(nullable: true)]
private ?array $metadata = null;
public function __construct()
{
$this->charges = new ArrayCollection();
$this->status = GatewayCheckoutStatus::Pending;
}
public function getId(): ?int
{
return $this->id;
}
public function getOrigin(): ?Accounting
{
return $this->origin;
}
public function setOrigin(?Accounting $origin): static
{
$this->origin = $origin;
return $this;
}
/**
* @return Collection<int, GatewayCharge>
*/
public function getCharges(): Collection
{
return $this->charges;
}
public function addCharge(GatewayCharge $charge): static
{
if (!$this->charges->contains($charge)) {
$this->charges->add($charge);
}
return $this;
}
public function removeCharge(GatewayCharge $charge): static
{
$this->charges->removeElement($charge);
return $this;
}
public function getStatus(): ?GatewayCheckoutStatus
{
return $this->status;
}
public function setStatus(GatewayCheckoutStatus $status): static
{
$this->status = $status;
return $this;
}
public function getGateway(): ?string
{
return $this->gateway;
}
public function setGateway(string $gateway): static
{
$this->gateway = $gateway;
return $this;
}
/**
* @return GatewayLink[]
*/
public function getGatewayLinks(): array
{
return $this->gatewayLinks;
}
public function addGatewayLink(GatewayLink $link): static
{
$this->gatewayLinks = [...$this->gatewayLinks, $link];
return $this;
}
public function removeGatewayLink(GatewayLink $link): static
{
$this->gatewayLinks = \array_filter(
$this->gatewayLinks,
function (GatewayLink $existingLink) use ($link) {
return $existingLink->href !== $link->href;
}
);
return $this;
}
/**
* @return GatewayTracking[]
*/
public function getGatewayTrackings(): array
{
return $this->gatewayTrackings;
}
public function addGatewayTracking(GatewayTracking $tracking): static
{
$this->gatewayTrackings = [...$this->gatewayTrackings, $tracking];
return $this;
}
public function removeGatewayTracking(GatewayTracking $tracking): static
{
$this->gatewayTrackings = \array_filter(
$this->gatewayTrackings,
function (GatewayTracking $existingTracking) use ($tracking) {
return $existingTracking->title !== $tracking->title
&& $existingTracking->value !== $tracking->value;
}
);
return $this;
}
public function isMigrated(): ?bool
{
return $this->migrated;
}
public function setMigrated(bool $migrated): static
{
$this->migrated = $migrated;
return $this;
}
public function getMigratedId(): ?string
{
return $this->migratedId;
}
public function setMigratedId(?string $migratedId): static
{
$this->migratedId = $migratedId;
return $this;
}
public function getMetadata(): ?array
{
return $this->metadata;
}
public function setMetadata(?array $metadata): static
{
$this->metadata = $metadata;
return $this;
}
}
@@ -0,0 +1,14 @@
<?php
namespace App\Entity\Interface;
use App\Entity\Accounting\Accounting;
interface AccountingOwnerInterface
{
public function getId(): ?int;
public function getAccounting(): ?Accounting;
public function setAccounting(Accounting $accounting): static;
}
+15 -47
View File
@@ -4,8 +4,11 @@ namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata as API;
use App\Entity\Trait\TimestampableCreationEntity;
use App\Entity\Trait\TimestampableUpdationEntity;
use App\Entity\Accounting\Accounting;
use App\Entity\Interface\AccountingOwnerInterface;
use App\Entity\Trait\MigratedEntity;
use App\Entity\Trait\TimestampedCreationEntity;
use App\Entity\Trait\TimestampedUpdationEntity;
use App\Repository\ProjectRepository;
use Doctrine\ORM\Mapping as ORM;
@@ -23,10 +26,11 @@ use Doctrine\ORM\Mapping as ORM;
'owner' => 'exact',
])]
#[ORM\Entity(repositoryClass: ProjectRepository::class)]
class Project
class Project implements AccountingOwnerInterface
{
use TimestampableCreationEntity;
use TimestampableUpdationEntity;
use MigratedEntity;
use TimestampedCreationEntity;
use TimestampedUpdationEntity;
#[ORM\Id]
#[ORM\GeneratedValue]
@@ -44,8 +48,7 @@ class Project
* A Project's Accounting represents how much money the Project has raised from the community.
*/
#[API\ApiProperty(writable: false)]
#[ORM\OneToOne(inversedBy: 'project', cascade: ['persist', 'remove'])]
#[ORM\JoinColumn(nullable: false)]
#[ORM\OneToOne(inversedBy: 'project', cascade: ['persist'])]
private ?Accounting $accounting = null;
/**
@@ -64,23 +67,12 @@ class Project
#[ORM\Column(type: 'string', enumType: ProjectStatus::class)]
private ProjectStatus $status;
/**
* Project was migrated from Goteo v3 platform.
*/
#[API\ApiProperty(writable: false)]
#[ORM\Column]
private ?bool $migrated = null;
/**
* The previous id of this Project in the Goteo v3 platform.
*/
#[API\ApiProperty(writable: false)]
#[ORM\Column(length: 255, nullable: true)]
private ?string $migratedId = null;
public function __construct()
{
$this->migrated = false;
$accounting = new Accounting();
$accounting->setOwner($this);
$this->accounting = $accounting;
}
public function getId(): ?int
@@ -105,7 +97,7 @@ class Project
return $this->accounting;
}
public function setAccounting(Accounting $accounting): static
public function setAccounting(?Accounting $accounting): static
{
$this->accounting = $accounting;
@@ -135,28 +127,4 @@ class Project
return $this;
}
public function isMigrated(): ?bool
{
return $this->migrated;
}
public function setMigrated(bool $migrated): static
{
$this->migrated = $migrated;
return $this;
}
public function getMigratedId(): ?string
{
return $this->migratedId;
}
public function setMigratedId(?string $migratedId): static
{
$this->migratedId = $migratedId;
return $this;
}
}
+13 -4
View File
@@ -3,6 +3,8 @@
namespace App\Entity;
use ApiPlatform\Metadata as API;
use App\Entity\Accounting\Accounting;
use App\Entity\Interface\AccountingOwnerInterface;
use App\Repository\TipjarRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
@@ -19,7 +21,7 @@ use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
#[API\Delete()]
#[API\Patch()]
#[ORM\Entity(repositoryClass: TipjarRepository::class)]
class Tipjar
class Tipjar implements AccountingOwnerInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
@@ -33,10 +35,17 @@ class Tipjar
private ?string $name = null;
#[API\ApiProperty(writable: false)]
#[ORM\OneToOne(inversedBy: 'tipjar', cascade: ['persist', 'remove'])]
#[ORM\JoinColumn(nullable: false)]
#[ORM\OneToOne(inversedBy: 'tipjar', cascade: ['persist'])]
private ?Accounting $accounting = null;
public function __construct()
{
$accounting = new Accounting();
$accounting->setOwner($this);
$this->accounting = $accounting;
}
public function getId(): ?int
{
return $this->id;
@@ -59,7 +68,7 @@ class Tipjar
return $this->accounting;
}
public function setAccounting(Accounting $accounting): static
public function setAccounting(?Accounting $accounting): static
{
$this->accounting = $accounting;
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace App\Entity\Trait;
use ApiPlatform\Metadata as API;
use Doctrine\ORM\Mapping as ORM;
trait MigratedEntity
{
/**
* Entity was migrated from the Goteo v3 platform.
*/
#[API\ApiProperty(writable: false)]
#[ORM\Column()]
protected ?bool $migrated = false;
/**
* Previous ID of the entity in the Goteo v3 platform.
*/
#[API\ApiProperty(writable: false)]
#[ORM\Column(length: 255, nullable: true)]
protected ?string $migratedId = null;
public function isMigrated(): ?bool
{
return $this->migrated;
}
public function setMigrated(bool $migrated): static
{
$this->migrated = $migrated;
return $this;
}
public function getMigratedId(): ?string
{
return $this->migratedId;
}
public function setMigratedId(string $migratedId): static
{
$this->migratedId = $migratedId;
return $this;
}
}
@@ -7,7 +7,7 @@ use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Gedmo\Mapping\Annotation as Gedmo;
trait TimestampableCreationEntity
trait TimestampedCreationEntity
{
/**
* Entity creation timestamp.
@@ -7,7 +7,7 @@ use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Gedmo\Mapping\Annotation as Gedmo;
trait TimestampableUpdationEntity
trait TimestampedUpdationEntity
{
/**
* Entity last update timestamp.
+18 -47
View File
@@ -3,9 +3,12 @@
namespace App\Entity;
use ApiPlatform\Metadata as API;
use App\Entity\Accounting\Accounting;
use App\Entity\Interface\AccountingOwnerInterface;
use App\Entity\Interface\UserOwnedInterface;
use App\Entity\Trait\TimestampableCreationEntity;
use App\Entity\Trait\TimestampableUpdationEntity;
use App\Entity\Trait\MigratedEntity;
use App\Entity\Trait\TimestampedCreationEntity;
use App\Entity\Trait\TimestampedUpdationEntity;
use App\Filter\OrderedLikeFilter;
use App\Filter\UserQueryFilter;
use App\Repository\UserRepository;
@@ -38,10 +41,11 @@ use Symfony\Component\Validator\Constraints as Assert;
#[UniqueEntity(fields: ['email'], message: 'This email address is already registered.')]
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Index(fields: ['migratedId'])]
class User implements UserInterface, UserOwnedInterface, PasswordAuthenticatedUserInterface
class User implements UserInterface, UserOwnedInterface, PasswordAuthenticatedUserInterface, AccountingOwnerInterface
{
use TimestampableCreationEntity;
use TimestampableUpdationEntity;
use MigratedEntity;
use TimestampedCreationEntity;
use TimestampedUpdationEntity;
#[ORM\Id]
#[ORM\GeneratedValue]
@@ -95,8 +99,7 @@ class User implements UserInterface, UserOwnedInterface, PasswordAuthenticatedUs
private ?bool $emailConfirmed = null;
#[API\ApiProperty(writable: false)]
#[ORM\OneToOne(inversedBy: 'user', cascade: ['persist', 'remove'])]
#[ORM\JoinColumn(nullable: false)]
#[ORM\OneToOne(inversedBy: 'user', cascade: ['persist'])]
private ?Accounting $accounting = null;
/**
@@ -125,20 +128,6 @@ class User implements UserInterface, UserOwnedInterface, PasswordAuthenticatedUs
#[ORM\Column(length: 255, nullable: true)]
private ?string $name = null;
/**
* User was migrated from Goteo v3 platform.
*/
#[API\ApiProperty(writable: false)]
#[ORM\Column]
private ?bool $migrated = null;
/**
* The previous id of this User in the Goteo v3 platform.
*/
#[API\ApiProperty(writable: false)]
#[ORM\Column(length: 255, nullable: true)]
private ?string $migratedId = null;
/**
* The projects owned by this User.
*/
@@ -151,7 +140,13 @@ class User implements UserInterface, UserOwnedInterface, PasswordAuthenticatedUs
public function __construct()
{
$this->migrated = false;
$accounting = new Accounting();
$accounting->setOwner($this);
$this->accounting = $accounting;
$this->emailConfirmed = false;
$this->active = false;
$this->tokens = new ArrayCollection();
$this->projects = new ArrayCollection();
@@ -289,7 +284,7 @@ class User implements UserInterface, UserOwnedInterface, PasswordAuthenticatedUs
return $this->accounting;
}
public function setAccounting(Accounting $accounting): static
public function setAccounting(?Accounting $accounting): static
{
$this->accounting = $accounting;
@@ -362,30 +357,6 @@ class User implements UserInterface, UserOwnedInterface, PasswordAuthenticatedUs
return $this;
}
public function isMigrated(): ?bool
{
return $this->migrated;
}
public function setMigrated(bool $migrated): static
{
$this->migrated = $migrated;
return $this;
}
public function getMigratedId(): ?string
{
return $this->migratedId;
}
public function setMigratedId(?string $migratedId): static
{
$this->migratedId = $migratedId;
return $this;
}
/**
* @return Collection<int, Project>
*/
+2 -2
View File
@@ -5,7 +5,7 @@ namespace App\Entity;
use ApiPlatform\Metadata as API;
use App\Dto\UserTokenLoginDto;
use App\Entity\Interface\UserOwnedInterface;
use App\Entity\Trait\TimestampableCreationEntity;
use App\Entity\Trait\TimestampedCreationEntity;
use App\Repository\UserTokenRepository;
use App\State\UserTokenLoginProcessor;
use Doctrine\ORM\Mapping as ORM;
@@ -25,7 +25,7 @@ use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: UserTokenRepository::class)]
class UserToken implements UserOwnedInterface
{
use TimestampableCreationEntity;
use TimestampedCreationEntity;
#[ORM\Id]
#[ORM\GeneratedValue]
+79
View File
@@ -0,0 +1,79 @@
<?php
namespace App\Entity;
use App\Repository\WalletFinancementRepository;
use Doctrine\ORM\Mapping as ORM;
/**
* A Financement represents how much money was moved from incoming to outgoing Wallet Statements.
*/
#[ORM\Entity(repositoryClass: WalletFinancementRepository::class)]
class WalletFinancement
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
/**
* The monetary value used in this Financement.
*/
#[ORM\Embedded(class: Money::class)]
private ?Money $money = null;
/**
* An incoming Statement that originally saved the money in this Financement.
*/
#[ORM\ManyToOne(inversedBy: 'financesto')]
#[ORM\JoinColumn(nullable: false)]
private ?WalletStatement $origin = null;
/**
* An outgoing Statement that eventually spent the money in this Financement.
*/
#[ORM\ManyToOne(inversedBy: 'financedBy')]
#[ORM\JoinColumn(nullable: false)]
private ?WalletStatement $target = null;
public function getId(): ?int
{
return $this->id;
}
public function getMoney(): ?Money
{
return $this->money;
}
public function setMoney(Money $money): static
{
$this->money = $money;
return $this;
}
public function getOrigin(): ?WalletStatement
{
return $this->origin;
}
public function setOrigin(?WalletStatement $origin): static
{
$this->origin = $origin;
return $this;
}
public function getTarget(): ?WalletStatement
{
return $this->target;
}
public function setTarget(?WalletStatement $target): static
{
$this->target = $target;
return $this;
}
}
+179
View File
@@ -0,0 +1,179 @@
<?php
namespace App\Entity;
use ApiPlatform\Metadata as API;
use App\Entity\Accounting\Transaction;
use App\Gateway\Wallet\StatementDirection;
use App\Repository\WalletStatementRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
/**
* WalletStatements are used by the Wallet Gateway to track available balances and money movements.\
* \
* Statements are relative to a Transaction, which either:\
* \
* a. Targets an User's Accounting, for which an incoming Statement will be created and which will hold the money until it is spent.\
* b. Originates from an User's Accounting via the Wallet Gateway, which will create an outgoing Statement with money financed from previous incoming statements.
*/
#[API\ApiResource()]
#[ORM\Entity(repositoryClass: WalletStatementRepository::class)]
class WalletStatement
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\OneToOne(cascade: ['persist', 'remove'])]
#[ORM\JoinColumn(nullable: false)]
private ?Transaction $transaction = null;
/**
* An `incoming` direction Statement means the Transaction saved money into the wallet.\
* An `outgoing` direction Statement means the Transaction spent money from the wallet.
*/
#[ORM\Column(enumType: StatementDirection::class)]
private ?StatementDirection $direction = null;
/**
* For incoming Statements, the balance tells the available money.\
* i.e: money not used yet by outgoing Statements.
*/
#[ORM\Embedded(class: Money::class)]
private ?Money $balance = null;
/**
* A collection of Financements for outgoing Statements that were made using money from this Statement.
*
* Only not empty for incoming Statements.
*
* @var Collection<int, WalletFinancement>
*/
#[ORM\OneToMany(mappedBy: 'origin', targetEntity: WalletFinancement::class, cascade: ['persist'])]
private Collection $financesTo;
/**
* A collection of Financements from incoming Statements that provided money for this Statement.
*
* Only not empty for outgoing Statements.
*
* @var Collection<int, WalletFinancement>
*/
#[ORM\OneToMany(mappedBy: 'target', targetEntity: WalletFinancement::class, cascade: ['persist'])]
private Collection $financedBy;
public function __construct()
{
$this->financesTo = new ArrayCollection();
$this->financedBy = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getTransaction(): ?Transaction
{
return $this->transaction;
}
public function setTransaction(Transaction $transaction): static
{
$this->transaction = $transaction;
return $this;
}
public function getDirection(): ?StatementDirection
{
return $this->direction;
}
public function hasDirection(StatementDirection $direction): bool
{
return $this->direction === $direction;
}
public function setDirection(StatementDirection $direction): static
{
$this->direction = $direction;
return $this;
}
public function getBalance(): ?Money
{
return $this->balance;
}
public function setBalance(Money $balance): static
{
$this->balance = $balance;
return $this;
}
/**
* @return Collection<int, WalletFinancement>
*/
public function getFinancesTo(): Collection
{
return $this->financesTo;
}
public function addFinancesTo(WalletFinancement $financesTo): static
{
if (!$this->financesTo->contains($financesTo)) {
$this->financesTo->add($financesTo);
$financesTo->setOrigin($this);
}
return $this;
}
public function removeFinancesTo(WalletFinancement $financesTo): static
{
if ($this->financesTo->removeElement($financesTo)) {
// set the owning side to null (unless already changed)
if ($financesTo->getOrigin() === $this) {
$financesTo->setOrigin(null);
}
}
return $this;
}
/**
* @return Collection<int, WalletFinancement>
*/
public function getFinancedBy(): Collection
{
return $this->financedBy;
}
public function addFinancedBy(WalletFinancement $financedBy): static
{
if (!$this->financedBy->contains($financedBy)) {
$this->financedBy->add($financedBy);
$financedBy->setTarget($this);
}
return $this;
}
public function removeFinancedBy(WalletFinancement $financedBy): static
{
if ($this->financedBy->removeElement($financedBy)) {
// set the owning side to null (unless already changed)
if ($financedBy->getTarget() === $this) {
$financedBy->setTarget(null);
}
}
return $this;
}
}
@@ -0,0 +1,38 @@
<?php
namespace App\EventListener;
use App\Entity\Accounting\Transaction;
use App\Entity\User;
use App\Gateway\Wallet\WalletService;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\PostPersistEventArgs;
use Doctrine\ORM\Events;
#[AsEntityListener(
event: Events::postPersist,
method: 'processTransaction',
entity: Transaction::class
)]
final class WalletTransactionsListener
{
public function __construct(
private EntityManagerInterface $entityManager,
private WalletService $wallet,
) {}
/**
* Generates a WalletStatement for User-received Transactions.
*/
public function processTransaction(
Transaction $transaction,
PostPersistEventArgs $event,
) {
$target = $transaction->getTarget();
if ($target->getOwner() instanceof User) {
$this->wallet->save($transaction);
}
}
}
@@ -1,8 +1,8 @@
<?php
namespace App\Entity;
namespace App\Gateway;
enum GatewayChargeType: string
enum ChargeType: string
{
/**
* A one-time charge to the money payer.
@@ -1,8 +1,8 @@
<?php
namespace App\Entity;
namespace App\Gateway;
enum GatewayCheckoutStatus: string
enum CheckoutStatus: string
{
case Pending = 'pending';
@@ -1,6 +1,8 @@
<?php
namespace App\Library\Economy\Payment;
namespace App\Gateway\Gateway;
use App\Gateway\GatewayInterface;
abstract class CashGateway implements GatewayInterface
{
@@ -1,6 +1,8 @@
<?php
namespace App\Library\Economy\Payment;
namespace App\Gateway\Gateway;
use App\Gateway\GatewayInterface;
abstract class CecaGateway implements GatewayInterface
{
@@ -1,6 +1,8 @@
<?php
namespace App\Library\Economy\Payment;
namespace App\Gateway\Gateway;
use App\Gateway\GatewayInterface;
abstract class DropGateway implements GatewayInterface
{
@@ -1,20 +1,20 @@
<?php
namespace App\Library\Economy\Payment;
namespace App\Gateway;
use App\Entity\GatewayCheckout;
use App\Entity\Gateway\Checkout;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Gateway interfaces are in charge of connecting with payment gateways to send them the data in `GatewayCheckout` instances.\
* Gateway interfaces are in charge of connecting with payment gateways to send them the data in `Checkout` instances.\
* \
* The flow is:
* 1. A client app creates a `GatewayCheckout` record
* 2. `App\State\GatewayCheckoutProcessor` calls the `sendData` method of the respective interface for the new `GatewayCheckout`
* 3. The interface connects with the gateway and sends the data for the `GatewayCheckout`
* 4. The interface updates the `GatewayCheckout` with the checkout url and reference given by the gateway for the checkout session
* 1. A client app creates a `Checkout` record
* 2. `App\State\GatewayCheckoutProcessor` calls the `sendData` method of the respective interface for the new `Checkout`
* 3. The interface connects with the gateway and sends the data for the `Checkout`
* 4. The interface updates the `Checkout` with the checkout url and reference given by the gateway for the checkout session
* 5. `App\State\GatewayCheckoutProcessor` returns the response data to the client app
* 6. The client app redirects the user to the payment with the gateway
* 7. The gateway processes the payment and redirects the user back to v4
@@ -28,14 +28,19 @@ interface GatewayInterface
*/
public static function getName(): string;
/**
* @return \App\Gateway\ChargeType[] The charge types that can be processed by this Gateway
*/
public static function getSupportedChargeTypes(): array;
/**
* Connects with the payment gateway and creates a checkout session so the gateway can process the payment.
*
* @param GatewayCheckout $checkout The GatewayCheckout with the data for the payment to be charged
* @param Checkout $checkout The Checkout with the data for the payment to be charged
*
* @return GatewayCheckout The GatewayCheckout updated with the data given by the gateway
* @return Checkout The Checkout updated with the data given by the gateway
*/
public function process(GatewayCheckout $checkout): GatewayCheckout;
public function process(Checkout $checkout): Checkout;
/**
* When a user is redirected by the gateway we must handle the redirection
@@ -1,9 +1,8 @@
<?php
namespace App\Library\Economy\Payment;
namespace App\Gateway;
use App\DependencyInjection\Compiler\GatewaysCompilerPass;
use App\Entity\GatewayCheckout;
use App\Entity\Gateway\Checkout;
class GatewayLocator
{
@@ -19,7 +18,7 @@ class GatewayLocator
$this->gatewaysByClass[$gateway::class] = $gateway;
}
GatewaysCompilerPass::validateGatewayNames($this->gatewaysByClass);
self::validateGatewayNames($this->gatewaysByClass);
foreach ($this->gatewaysByClass as $class => $gateway) {
$this->gatewaysByName[$gateway::getName()] = $gateway;
@@ -42,16 +41,6 @@ class GatewayLocator
return \array_keys($this->gatewaysByName);
}
/**
* @return array<string> List of the available Gateway names
*
* @see GatewaysCompilerPass::compileGatewayNames()
*/
public static function getNamesStatic(): array
{
return explode(PHP_EOL, \file_get_contents(GatewaysCompilerPass::getLockFile()));
}
/**
* @return GatewayInterface[]
*/
@@ -77,7 +66,7 @@ class GatewayLocator
/**
* @throws \Exception When the $checkout::gateway does not match to that of an implemented Gateway
*/
public function getGatewayOf(GatewayCheckout $checkout): GatewayInterface
public function getGatewayOf(Checkout $checkout): GatewayInterface
{
$gateway = $checkout->getGateway();
if (!$gateway) {
@@ -86,4 +75,32 @@ class GatewayLocator
return $this->getGateway($gateway);
}
/**
* Ensures the gateway names are unique for each gateway.
*
* @param array $gatewayClasses Fully-qualified Gateway class names
*
* @throws \Exception If there are two different Gateway classes that return the same name string
*/
private static function validateGatewayNames(array $gatewayClasses): void
{
$gatewaysValidated = [];
foreach ($gatewayClasses as $gatewayClass) {
$gatewayName = $gatewayClass::getName();
if (\array_key_exists($gatewayName, $gatewaysValidated)) {
$exceptionMessage = sprintf(
"Duplicate Gateway name '%s' from class %s, name is already in use by class %s",
$gatewayName,
$gatewayClass,
$gatewaysValidated[$gatewayName]
);
throw new \Exception($exceptionMessage);
}
$gatewaysValidated[$gatewayName] = $gatewayClass;
}
}
}
@@ -1,8 +1,8 @@
<?php
namespace App\Entity;
namespace App\Gateway;
class GatewayLink
class Link
{
/**
* The complete target URL.
@@ -26,5 +26,5 @@ class GatewayLink
* `debug` links are for developers and platform maintainers to get useful information about the checkout.\
* `payment` links are for end-users who must visit this link to complete the checkout.
*/
public GatewayLinkType $type;
public LinkType $type;
}
@@ -1,8 +1,8 @@
<?php
namespace App\Entity;
namespace App\Gateway;
enum GatewayLinkType: string
enum LinkType: string
{
/**
* A 'debug' type link indicates a link that is helpful to developers and platform maintainers to get info about the checkout.
@@ -1,23 +1,24 @@
<?php
namespace App\Library\Economy\Payment;
namespace App\Gateway\Paypal;
use ApiPlatform\Api\IriConverterInterface;
use App\Entity\GatewayCharge;
use App\Entity\GatewayCheckout;
use App\Entity\GatewayCheckoutStatus;
use App\Entity\GatewayLink;
use App\Entity\GatewayLinkType;
use App\Entity\GatewayTracking;
use App\Repository\GatewayCheckoutRepository;
use App\Service\GatewayCheckoutService;
use App\Entity\Gateway\Charge;
use App\Entity\Gateway\Checkout;
use App\Gateway\ChargeType;
use App\Gateway\CheckoutStatus;
use App\Gateway\GatewayInterface;
use App\Gateway\Link;
use App\Gateway\LinkType;
use App\Gateway\Tracking;
use App\Repository\Gateway\CheckoutRepository;
use App\Service\Gateway\CheckoutService;
use Brick\Money\Money as BrickMoney;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\RouterInterface;
/**
* @see https://developer.paypal.com/studio/checkout/standard/integrate
@@ -43,21 +44,26 @@ class PaypalGateway implements GatewayInterface
public const TRACKING_TITLE_TRANSACTION = 'PayPal Transaction ID';
public function __construct(
private PaypalGatewayService $paypal,
private RouterInterface $router,
private PaypalService $paypal,
private EntityManagerInterface $entityManager,
private IriConverterInterface $iriConverter,
private GatewayCheckoutService $checkoutService,
private GatewayCheckoutRepository $checkoutRepository,
) {
}
private CheckoutService $checkoutService,
private CheckoutRepository $checkoutRepository,
) {}
public static function getName(): string
{
return 'paypal';
}
public function process(GatewayCheckout $checkout): GatewayCheckout
public static function getSupportedChargeTypes(): array
{
return [
ChargeType::Single,
];
}
public function process(Checkout $checkout): Checkout
{
$order = $this->paypal->postOrder([
'intent' => self::PAYPAL_ORDER_INTENT,
@@ -65,24 +71,24 @@ class PaypalGateway implements GatewayInterface
'payment_source' => $this->getPaypalPaymentSource($checkout),
]);
$tracking = new GatewayTracking();
$tracking->setValue($order['id']);
$tracking->setTitle(self::TRACKING_TITLE_ORDER);
$tracking = new Tracking();
$tracking->title = self::TRACKING_TITLE_ORDER;
$tracking->value = $order['id'];
$checkout->addGatewayTracking($tracking);
$checkout->addTracking($tracking);
foreach ($order['links'] as $linkData) {
$linkType = \in_array($linkData['rel'], ['approve', 'payer-action'])
? GatewayLinkType::Payment
: GatewayLinkType::Debug;
? LinkType::Payment
: LinkType::Debug;
$link = new GatewayLink();
$link->setHref($linkData['href']);
$link->setRel($linkData['rel']);
$link->setMethod($linkData['method']);
$link->setType($linkType);
$link = new Link();
$link->href = $linkData['href'];
$link->rel = $linkData['rel'];
$link->method = $linkData['method'];
$link->type = $linkType;
$checkout->addGatewayLink($link);
$checkout->addLink($link);
}
return $checkout;
@@ -90,7 +96,7 @@ class PaypalGateway implements GatewayInterface
public function handleRedirect(Request $request): RedirectResponse
{
if ($request->query->get('type') !== GatewayCheckoutService::RESPONSE_TYPE_SUCCESS) {
if ($request->query->get('type') !== CheckoutService::RESPONSE_TYPE_SUCCESS) {
throw new \Exception(sprintf('Checkout was not completed successfully.'));
}
@@ -98,10 +104,10 @@ class PaypalGateway implements GatewayInterface
$checkout = $this->checkoutRepository->find($checkoutId);
if ($checkout === null) {
throw new \Exception(sprintf("GatewayCheckout '%s' could not be found.", $checkoutId));
throw new \Exception(sprintf("Checkout '%s' could not be found.", $checkoutId));
}
if ($checkout->getStatus() === GatewayCheckoutStatus::Charged) {
if ($checkout->getStatus() === CheckoutStatus::Charged) {
return new RedirectResponse($this->iriConverter->getIriFromResource($checkout));
}
@@ -121,15 +127,18 @@ class PaypalGateway implements GatewayInterface
}
foreach ($capture['purchase_units'] as $purchaseUnit) {
$tracking = new GatewayTracking();
$tracking->setValue($purchaseUnit['payments']['captures'][0]['id']);
$tracking->setTitle(self::TRACKING_TITLE_TRANSACTION);
$tracking = new Tracking();
$tracking->title = self::TRACKING_TITLE_TRANSACTION;
$tracking->value = $purchaseUnit['payments']['captures'][0]['id'];
$checkout->addGatewayTracking($tracking);
$checkout->addTracking($tracking);
}
$checkout = $this->checkoutService->chargeCheckout($checkout);
$this->entityManager->persist($checkout);
$this->entityManager->flush();
// TO-DO: This should redirect the user to a GUI
return new RedirectResponse($this->iriConverter->getIriFromResource($checkout));
}
@@ -164,21 +173,21 @@ class PaypalGateway implements GatewayInterface
$checkout = $this->checkoutRepository->findOneByTracking(self::TRACKING_TITLE_ORDER, $orderId);
if ($checkout === null) {
throw new \Exception(sprintf("Could not find any GatewayCheckout by the GatewayTracking '%s'", $orderId), 1);
throw new \Exception(sprintf("Could not find any Checkout by the Tracking '%s'", $orderId), 1);
}
foreach ($event['resource']['purchase_units'] as $purchaseUnit) {
$tracking = new GatewayTracking();
$tracking->setValue($purchaseUnit['payments']['captures'][0]['id']);
$tracking->setTitle(self::TRACKING_TITLE_TRANSACTION);
$tracking = new Tracking();
$tracking->title = self::TRACKING_TITLE_TRANSACTION;
$tracking->value = $purchaseUnit['payments']['captures'][0]['id'];
$checkout->addGatewayTracking($tracking);
$checkout->addTracking($tracking);
}
$checkout = $this->checkoutService->chargeCheckout($checkout);
}
private function getPaypalMoney(GatewayCharge $charge): array
private function getPaypalMoney(Charge $charge): array
{
$brick = BrickMoney::ofMinor(
$charge->getMoney()->amount,
@@ -191,21 +200,21 @@ class PaypalGateway implements GatewayInterface
];
}
private function getPaypalPurchaseUnits(GatewayCheckout $checkout): array
private function getPaypalPurchaseUnits(Checkout $checkout): array
{
$units = [];
foreach ($checkout->getCharges() as $charge) {
$money = $this->getPaypalMoney($charge);
$reference = $this->checkoutService->getGatewayReference($checkout, $charge);
$reference = $this->checkoutService->generateTracking($checkout, $charge);
$units[] = [
'reference_id' => $reference,
'custom_id' => $reference,
'items' => [
[
'name' => $charge::MESSAGE_STATEMENT,
'description' => $charge::MESSAGE_STATEMENT,
'name' => $charge->getTitle(),
'description' => $charge->getDescription(),
'quantity' => '1',
'unit_amount' => [
...$money,
@@ -226,7 +235,7 @@ class PaypalGateway implements GatewayInterface
return $units;
}
private function getPaypalPaymentSource(GatewayCheckout $checkout): array
private function getPaypalPaymentSource(Checkout $checkout): array
{
return [
'paypal' => [
@@ -1,6 +1,6 @@
<?php
namespace App\Library\Economy\Payment;
namespace App\Gateway\Paypal;
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
use Symfony\Component\HttpClient\HttpOptions;
@@ -10,7 +10,7 @@ use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class PaypalGatewayService
class PaypalService
{
private CacheInterface $cache;
@@ -21,7 +21,11 @@ class PaypalGatewayService
private string $paypalWebhookId,
private HttpClientInterface $httpClient,
) {
$this->cache = new FilesystemAdapter();
$this->cache = new FilesystemAdapter(\preg_replace(
'/[^-+_\.A-Za-z0-9]/',
'-',
$this->paypalApiAddress
));
$httpOptions = new HttpOptions();
$httpOptions->setBaseUri($paypalApiAddress);
@@ -58,7 +62,7 @@ class PaypalGatewayService
*/
public function getAuthToken(): array
{
return $this->cache->get($this->paypalApiAddress, function (ItemInterface $item): array {
return $this->cache->get(urlencode('/v1/oauth2/token'), function (ItemInterface $item): array {
$tokenData = $this->generateAuthToken();
$item->expiresAfter($tokenData['expires_in']);
@@ -1,16 +1,18 @@
<?php
namespace App\Library\Economy\Payment;
namespace App\Gateway\Stripe;
use ApiPlatform\Api\IriConverterInterface;
use App\Entity\GatewayChargeType;
use App\Entity\GatewayCheckout;
use App\Entity\GatewayCheckoutStatus;
use App\Entity\GatewayLink;
use App\Entity\GatewayLinkType;
use App\Entity\GatewayTracking;
use App\Repository\GatewayCheckoutRepository;
use App\Service\GatewayCheckoutService;
use App\Entity\Gateway\Checkout;
use App\Entity\User;
use App\Gateway\ChargeType;
use App\Gateway\CheckoutStatus;
use App\Gateway\GatewayInterface;
use App\Gateway\Link;
use App\Gateway\LinkType;
use App\Gateway\Tracking;
use App\Repository\Gateway\CheckoutRepository;
use App\Service\Gateway\CheckoutService;
use Doctrine\ORM\EntityManagerInterface;
use Stripe\Checkout\Session as StripeSession;
use Stripe\StripeClient;
@@ -19,7 +21,6 @@ use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\RouterInterface;
class StripeGateway implements GatewayInterface
{
@@ -30,9 +31,8 @@ class StripeGateway implements GatewayInterface
public function __construct(
private string $stripeApiKey,
private string $stripeWebhookSecret,
private RouterInterface $router,
private GatewayCheckoutService $checkoutService,
private GatewayCheckoutRepository $checkoutRepository,
private CheckoutService $checkoutService,
private CheckoutRepository $checkoutRepository,
private EntityManagerInterface $entityManager,
private IriConverterInterface $iriConverter,
) {
@@ -44,10 +44,18 @@ class StripeGateway implements GatewayInterface
return 'stripe';
}
public function process(GatewayCheckout $checkout): GatewayCheckout
public static function getSupportedChargeTypes(): array
{
return [
ChargeType::Single,
ChargeType::Recurring,
];
}
public function process(Checkout $checkout): Checkout
{
$session = $this->stripe->checkout->sessions->create([
'customer_email' => $checkout->getOrigin()->getUser()->getEmail(),
'customer_email' => $this->getStripeCustomer($checkout),
'mode' => $this->getStripeMode($checkout),
'line_items' => $this->getStripeLineItems($checkout),
// Because Symfony's Router encodes query parameters, the value {CHECKOUT_SESSION_ID}
@@ -57,20 +65,20 @@ class StripeGateway implements GatewayInterface
'success_url' => sprintf('%s&session_id={CHECKOUT_SESSION_ID}', $this->checkoutService->generateRedirectUrl($checkout)),
]);
$link = new GatewayLink();
$link = new Link();
$link->setHref($session->url);
$link->setRel('approve');
$link->setMethod(Request::METHOD_GET);
$link->setType(GatewayLinkType::Payment);
$link->href = $session->url;
$link->rel = 'approve';
$link->method = Request::METHOD_GET;
$link->type = LinkType::Payment;
$checkout->addGatewayLink($link);
$checkout->addLink($link);
$tracking = new GatewayTracking();
$tracking->setValue($session->id);
$tracking->setTitle(self::TRACKING_TITLE_CHECKOUT);
$tracking = new Tracking();
$tracking->title = self::TRACKING_TITLE_CHECKOUT;
$tracking->value = $session->id;
$checkout->addGatewayTracking($tracking);
$checkout->addTracking($tracking);
return $checkout;
}
@@ -83,14 +91,14 @@ class StripeGateway implements GatewayInterface
$checkout = $this->checkoutRepository->find($request->query->get('checkoutId'));
if ($checkout === null) {
throw new \Exception(sprintf("Stripe checkout '%s' exists but no GatewayCheckout with that reference was found.", $sessionId));
throw new \Exception(sprintf("Stripe checkout '%s' exists but no Checkout with that reference was found.", $sessionId));
}
if ($checkout->getStatus() === GatewayCheckoutStatus::Charged) {
if ($checkout->getStatus() === CheckoutStatus::Charged) {
return $checkout;
}
if ($request->query->get('type') !== GatewayCheckoutService::RESPONSE_TYPE_SUCCESS) {
if ($request->query->get('type') !== CheckoutService::RESPONSE_TYPE_SUCCESS) {
return $checkout;
}
@@ -100,6 +108,9 @@ class StripeGateway implements GatewayInterface
$checkout = $this->checkoutService->chargeCheckout($checkout);
$this->entityManager->persist($checkout);
$this->entityManager->flush();
// TO-DO: This should redirect the user to a GUI
return new RedirectResponse($this->iriConverter->getIriFromResource($checkout));
}
@@ -121,10 +132,20 @@ class StripeGateway implements GatewayInterface
}
}
private function getStripeMode(GatewayCheckout $checkout): string
private function getStripeCustomer(Checkout $checkout): string
{
$owner = $checkout->getOrigin()->getOwner();
if (!$owner instanceof User) {
return '';
}
return $owner->getEmail();
}
private function getStripeMode(Checkout $checkout): string
{
foreach ($checkout->getCharges() as $charge) {
if ($charge->getType() === GatewayChargeType::Recurring) {
if ($charge->getType() === ChargeType::Recurring) {
return StripeSession::MODE_SUBSCRIPTION;
}
}
@@ -132,7 +153,7 @@ class StripeGateway implements GatewayInterface
return StripeSession::MODE_PAYMENT;
}
private function getStripeLineItems(GatewayCheckout $checkout): array
private function getStripeLineItems(Checkout $checkout): array
{
$items = [];
@@ -140,13 +161,13 @@ class StripeGateway implements GatewayInterface
$price = [
'currency' => $charge->getMoney()->currency,
'unit_amount' => $charge->getMoney()->amount,
'product_data' => [
'name' => $charge::MESSAGE_STATEMENT,
'statement_descriptor' => $charge::MESSAGE_STATEMENT,
],
'product_data' => \array_filter([
'name' => $charge->getTitle(),
'statement_descriptor' => $charge->getDescription(),
], fn($v) => $v !== null),
];
if ($charge->getType() === GatewayChargeType::Recurring) {
if ($charge->getType() === ChargeType::Recurring) {
$price['recurring'] = ['interval' => 'month'];
}
@@ -1,10 +1,10 @@
<?php
namespace App\Entity;
namespace App\Gateway;
use Symfony\Component\Validator\Constraints as Assert;
class GatewayTracking
class Tracking
{
/**
* A descriptive title for the tracking number.
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace App\Gateway\Wallet;
enum StatementDirection: string
{
/**
* The Transaction was received by the Accounting.
*/
case Incoming = 'incoming';
/**
* The Transaction was issued by the Accounting.
*/
case Outgoing = 'outgoing';
}
+83
View File
@@ -0,0 +1,83 @@
<?php
namespace App\Gateway\Wallet;
use App\Entity\Accounting\Transaction;
use App\Entity\Gateway\Checkout;
use App\Entity\Money;
use App\Entity\WalletStatement;
use App\Gateway\ChargeType;
use App\Gateway\GatewayInterface;
use App\Library\Economy\MoneyService;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class WalletGateway implements GatewayInterface
{
public static function getName(): string
{
return 'wallet';
}
public static function getSupportedChargeTypes(): array
{
return [
ChargeType::Single,
];
}
public function __construct(
private WalletService $wallet,
private MoneyService $money,
) {}
public function process(Checkout $checkout): Checkout
{
$origin = $checkout->getOrigin();
$available = $this->wallet->getBalance($origin);
$chargeTotal = $this->getChargeTotal($checkout);
if ($this->money->isLess($available, $chargeTotal)) {
throw new \Exception("Can't spend more than what you have!");
}
$charges = $checkout->getCharges();
foreach ($charges as $charge) {
$transaction = new Transaction();
$transaction->setMoney($charge->getMoney());
$transaction->setOrigin($origin);
$transaction->setTarget($charge->getTarget());
$outgoing = new WalletStatement();
$outgoing->setTransaction($transaction);
$outgoing->setDirection(StatementDirection::Outgoing);
$outgoing = $this->wallet->spend($transaction);
}
return $checkout;
}
public function handleRedirect(Request $request): RedirectResponse
{
return new RedirectResponse('');
}
public function handleWebhook(Request $request): Response
{
return new Response();
}
private function getChargeTotal(Checkout $checkout): Money
{
$total = new Money(0, $checkout->getOrigin()->getCurrency());
$charges = $checkout->getCharges();
foreach ($charges as $charge) {
$total = $this->money->add($charge->getMoney(), $total);
}
return $total;
}
}
+130
View File
@@ -0,0 +1,130 @@
<?php
namespace App\Gateway\Wallet;
use App\Entity\Accounting\Accounting;
use App\Entity\Accounting\Transaction;
use App\Entity\Money;
use App\Entity\WalletFinancement;
use App\Entity\WalletStatement;
use App\Library\Economy\MoneyService;
use App\Repository\WalletStatementRepository;
use Brick\Money as Brick;
use Doctrine\ORM\EntityManagerInterface;
class WalletService
{
public function __construct(
private MoneyService $money,
private WalletStatementRepository $walletStatementRepository,
private EntityManagerInterface $entityManager,
) {}
/**
* @return \App\Entity\WalletStatement[]
*/
public function getStatements(Accounting $accounting): array
{
return $this->walletStatementRepository->findByAccounting($accounting);
}
public function getBalance(Accounting $accounting): Money
{
$total = Brick\Money::ofMinor(0, $accounting->getCurrency());
$statements = $this->getStatements($accounting);
foreach ($statements as $statement) {
$balance = $this->money->toBrick($statement->getBalance());
if (!$statement->hasDirection(StatementDirection::Incoming)) {
continue;
}
$total = $total->plus($balance);
}
return $this->money->toMoney($total);
}
/**
* Puts the money of a Transaction into the target wallet.
*
* @param Transaction $transaction The Transaction targetting a wallet
*
* @return WalletStatement An incoming statement for the target wallet
*/
public function save(Transaction $transaction): WalletStatement
{
$statement = new WalletStatement();
$statement->setTransaction($transaction);
$statement->setDirection(StatementDirection::Incoming);
$statement->setBalance($transaction->getMoney());
$this->entityManager->persist($statement);
$this->entityManager->flush();
return $statement;
}
/**
* Takes the money of a Transaction from the origin wallet.
*
* @param Transaction $transaction The Transaction originating from a wallet
*
* @return WalletStatement An outgoing statement financed by previous incoming statements
*/
public function spend(Transaction $transaction): WalletStatement
{
$origin = $transaction->getOrigin();
$spendGoal = $transaction->getMoney();
$spentTotal = new Money(0, $spendGoal->currency);
$outgoing = new WalletStatement();
$outgoing->setTransaction($transaction);
$outgoing->setDirection(StatementDirection::Outgoing);
$statements = $this->getStatements($origin);
foreach ($statements as $statement) {
if ($spendGoal->amount === 0) {
break;
}
if (!$statement->hasDirection(StatementDirection::Incoming)) {
continue;
}
$incoming = $statement;
$balance = $incoming->getBalance();
if ($balance->amount === 0) {
continue;
}
if ($this->money->isLess($spendGoal, $balance)) {
$balanceSpent = $spendGoal;
} else {
$balanceSpent = $balance;
}
$financement = new WalletFinancement();
$financement->setMoney($balanceSpent);
$incoming->setBalance($this->money->substract($balanceSpent, $balance));
$incoming->addFinancesTo($financement);
$outgoing->setBalance($this->money->add($balanceSpent, $spentTotal));
$outgoing->addFinancedBy($financement);
$this->entityManager->persist($incoming);
$this->entityManager->persist($outgoing);
$spendGoal = $this->money->substract($balanceSpent, $spendGoal);
$spentTotal = $this->money->add($balanceSpent, $spentTotal);
}
$this->entityManager->flush();
return $outgoing;
}
}
-6
View File
@@ -2,7 +2,6 @@
namespace App;
use App\DependencyInjection\Compiler\GatewaysCompilerPass;
use App\DependencyInjection\Compiler\VersionedResourcePass;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\DependencyInjection\Compiler\PassConfig;
@@ -15,11 +14,6 @@ class Kernel extends BaseKernel
protected function build(ContainerBuilder $container): void
{
$container->addCompilerPass(
new GatewaysCompilerPass(),
PassConfig::TYPE_AFTER_REMOVING
);
$container->addCompilerPass(
new VersionedResourcePass(),
PassConfig::TYPE_AFTER_REMOVING
+4 -6
View File
@@ -11,14 +11,12 @@ abstract class AbstractPump implements PumpInterface
$this->config = $config;
}
public function getConfig(?string $key = null, mixed $default = null): array
public function getConfig(?string $key = null, mixed $default = null): mixed
{
if ($key !== null) {
return [
$key => \array_key_exists($key, $this->config)
? $this->config[$key]
: $default,
];
return \array_key_exists($key, $this->config)
? $this->config[$key]
: $default;
}
return $this->config;
+39 -38
View File
@@ -2,26 +2,25 @@
namespace App\Library\Benzina\Pump;
use App\Entity\Accounting;
use App\Entity\GatewayCharge;
use App\Entity\GatewayChargeType;
use App\Entity\GatewayCheckout;
use App\Entity\GatewayCheckoutStatus;
use App\Entity\GatewayTracking;
use App\Entity\Gateway\Charge;
use App\Entity\Gateway\Checkout;
use App\Entity\Money;
use App\Entity\Tipjar;
use App\Gateway\ChargeType;
use App\Gateway\CheckoutStatus;
use App\Gateway\Gateway\CashGateway;
use App\Gateway\Gateway\CecaGateway;
use App\Gateway\Gateway\DropGateway;
use App\Gateway\Paypal\PaypalGateway;
use App\Gateway\Stripe\StripeGateway;
use App\Gateway\Tracking;
use App\Gateway\Wallet\WalletGateway;
use App\Library\Benzina\Pump\Trait\ArrayPumpTrait;
use App\Library\Benzina\Pump\Trait\DoctrinePumpTrait;
use App\Library\Economy\Payment\CashGateway;
use App\Library\Economy\Payment\CecaGateway;
use App\Library\Economy\Payment\DropGateway;
use App\Library\Economy\Payment\PaypalGateway;
use App\Library\Economy\Payment\StripeGateway;
use App\Library\Economy\Payment\WalletGateway;
use App\Repository\ProjectRepository;
use App\Repository\TipjarRepository;
use App\Repository\UserRepository;
use App\Service\GatewayCheckoutService;
use App\Service\Gateway\CheckoutService;
use Doctrine\ORM\EntityManagerInterface;
class CheckoutsPump extends AbstractPump implements PumpInterface
@@ -34,6 +33,10 @@ class CheckoutsPump extends AbstractPump implements PumpInterface
public const TRACKING_TITLE_TRANSACTION = 'v3 Invest Transaction';
public const TRACKING_TITLE_PREAPPROVAL = 'v3 Invest Preapproval';
public const CHARGE_TITLE_PROJECT = 'Pago en Goteo v3 - Donación a proyecto';
public const CHARGE_TITLE_POOL = 'Pago en Goteo v3 - Carga de monedero';
public const CHARGE_TITLE_TIP = 'Pago en Goteo v3 - Propina a la plataforma';
private const PLATFORM_TIPJAR_NAME = 'platform';
private const MAX_INT = 2147483647;
@@ -75,9 +78,8 @@ class CheckoutsPump extends AbstractPump implements PumpInterface
private ProjectRepository $projectRepository,
private TipjarRepository $tipjarRepository,
private EntityManagerInterface $entityManager,
private GatewayCheckoutService $checkoutService,
) {
}
private CheckoutService $checkoutService,
) {}
public function supports(mixed $batch): bool
{
@@ -90,7 +92,7 @@ class CheckoutsPump extends AbstractPump implements PumpInterface
public function pump(mixed $batch): void
{
$batch = $this->skipPumped($batch, 'id', GatewayCheckout::class, 'migratedId');
$batch = $this->skipPumped($batch, 'id', Checkout::class, 'migratedId');
$tipjar = $this->getPlatformTipjar();
@@ -112,13 +114,13 @@ class CheckoutsPump extends AbstractPump implements PumpInterface
$user = $users[$record['user']];
$checkout = new GatewayCheckout();
$checkout = new Checkout();
$checkout->setOrigin($user->getAccounting());
$checkout->setStatus($this->getCheckoutStatus($record));
$checkout->setGateway($this->getCheckoutGateway($record));
foreach ($this->getCheckoutTrackings($record) as $tracking) {
$checkout->addGatewayTracking($tracking);
$checkout->addTracking($tracking);
}
$checkout->setMigrated(true);
@@ -127,32 +129,35 @@ class CheckoutsPump extends AbstractPump implements PumpInterface
$checkout->setDateCreated(new \DateTime($record['invested']));
$checkout->setDateUpdated(new \DateTime());
$charge = new GatewayCharge();
$charge = new Charge();
$charge->setType($this->getChargeType($record));
$charge->setMoney($this->getChargeMoney($record['amount'], $record['currency']));
if (empty($record['project'])) {
$charge->setTitle(self::CHARGE_TITLE_POOL);
$charge->setTarget($user->getAccounting());
}
if (!empty($record['project'])) {
$project = $projects[$record['project']];
$charge->setTitle(self::CHARGE_TITLE_PROJECT);
$charge->setTarget($project->getAccounting());
}
$checkout->addCharge($charge);
if ($record['donate_amount'] > 0) {
$tip = new GatewayCharge();
$tip->setType(GatewayChargeType::Single);
$tip = new Charge();
$tip->setType(ChargeType::Single);
$tip->setTitle(self::CHARGE_TITLE_TIP);
$tip->setMoney($this->getChargeMoney($record['donate_amount'], $record['currency']));
$tip->setTarget($tipjar->getAccounting());
$checkout->addCharge($tip);
}
if ($checkout->getStatus() === GatewayCheckoutStatus::Charged) {
if ($checkout->getStatus() === CheckoutStatus::Charged) {
$checkout = $this->checkoutService->chargeCheckout($checkout);
}
@@ -208,23 +213,19 @@ class CheckoutsPump extends AbstractPump implements PumpInterface
$tipjar = new Tipjar();
$tipjar->setName(self::PLATFORM_TIPJAR_NAME);
$accounting = new Accounting();
$accounting->setTipjar($tipjar);
$this->entityManager->persist($tipjar);
$this->entityManager->persist($accounting);
$this->entityManager->flush();
return $tipjar;
}
private function getChargeType(array $record): GatewayChargeType
private function getChargeType(array $record): ChargeType
{
if (\in_array($record['method'], ['stripe_subscription'])) {
return GatewayChargeType::Recurring;
return ChargeType::Recurring;
}
return GatewayChargeType::Single;
return ChargeType::Single;
}
private function getChargeMoney(int $amount, string $currency): Money
@@ -238,13 +239,13 @@ class CheckoutsPump extends AbstractPump implements PumpInterface
return new Money($amount, $currency);
}
private function getCheckoutStatus(array $record): GatewayCheckoutStatus
private function getCheckoutStatus(array $record): CheckoutStatus
{
if ($record['status'] < 1) {
return GatewayCheckoutStatus::Pending;
return CheckoutStatus::Pending;
}
return GatewayCheckoutStatus::Charged;
return CheckoutStatus::Charged;
}
private function getCheckoutGateway(array $record): string
@@ -268,18 +269,18 @@ class CheckoutsPump extends AbstractPump implements PumpInterface
}
/**
* @return GatewayTracking[]
* @return Tracking[]
*/
private function getCheckoutTrackings(array $record): array
{
$v3Tracking = new GatewayTracking();
$v3Tracking = new Tracking();
$v3Tracking->title = self::TRACKING_TITLE_V3;
$v3Tracking->value = $record['id'];
$trackings = [$v3Tracking];
if (!empty($record['payment'])) {
$payment = new GatewayTracking();
$payment = new Tracking();
$payment->title = self::TRACKING_TITLE_PAYMENT;
$payment->value = $record['payment'];
@@ -287,7 +288,7 @@ class CheckoutsPump extends AbstractPump implements PumpInterface
}
if (!empty($record['transaction'])) {
$transaction = new GatewayTracking();
$transaction = new Tracking();
$transaction->title = self::TRACKING_TITLE_TRANSACTION;
$transaction->value = $record['transaction'];
@@ -295,7 +296,7 @@ class CheckoutsPump extends AbstractPump implements PumpInterface
}
if (!empty($record['preapproval'])) {
$preapproval = new GatewayTracking();
$preapproval = new Tracking();
$preapproval->title = self::TRACKING_TITLE_PREAPPROVAL;
$preapproval->value = $record['preapproval'];
+2 -7
View File
@@ -2,7 +2,7 @@
namespace App\Library\Benzina\Pump;
use App\Entity\Accounting;
use App\Entity\Accounting\Accounting;
use App\Entity\Project;
use App\Entity\ProjectStatus;
use App\Entity\User;
@@ -97,8 +97,7 @@ class ProjectsPump extends AbstractPump implements PumpInterface
public function __construct(
private UserRepository $userRepository,
private EntityManagerInterface $entityManager,
) {
}
) {}
public function supports(mixed $batch): bool
{
@@ -133,11 +132,7 @@ class ProjectsPump extends AbstractPump implements PumpInterface
$project->setDateCreated(new \DateTime($record['created']));
$project->setDateUpdated(new \DateTime());
$accounting = $this->getAccounting($record);
$accounting->setProject($project);
$this->entityManager->persist($project);
$this->entityManager->persist($accounting);
}
$this->entityManager->flush();
+2 -2
View File
@@ -17,9 +17,9 @@ interface PumpInterface
* @param string|null $key A configuration array key to return
* @param mixed|null $default Any value to return in case the configuration key does not exist
*
* @return array The configuration array at the specified key, or all keys if null
* @return array The configuration value at the specified key, or full keys and values if null
*/
public function getConfig(?string $key = null, mixed $default = null): array;
public function getConfig(?string $key = null, mixed $default = null): mixed;
/**
* Determines if a data batch is supported by this pump.
@@ -31,7 +31,7 @@ trait DoctrinePumpTrait
string $entityClass,
string $entityKey,
): array {
if (!$this->getConfig('skip-pumped', false)) {
if (!$this->getConfig('skip-pumped')) {
return $batch;
}
+8 -8
View File
@@ -2,7 +2,6 @@
namespace App\Library\Benzina\Pump;
use App\Entity\Accounting;
use App\Entity\User;
use App\Library\Benzina\Pump\Trait\ArrayPumpTrait;
use App\Library\Benzina\Pump\Trait\DoctrinePumpTrait;
@@ -52,8 +51,7 @@ class UsersPump extends AbstractPump implements PumpInterface
public function __construct(
private EntityManagerInterface $entityManager,
) {
}
) {}
public function supports(mixed $batch): bool
{
@@ -69,8 +67,12 @@ class UsersPump extends AbstractPump implements PumpInterface
$batch = $this->skipPumped($batch, 'id', User::class, 'migratedId');
foreach ($batch as $key => $record) {
$created = new \DateTime($record['created']);
if ($created < new \DateTime('2000-01-01')) {
$created = new \DateTime($record['modified']);
}
$user = new User();
$user->setAccounting(new Accounting());
$user->setUsername($this->getUsername($record));
$user->setPassword($record['password'] ?? '');
$user->setEmail($record['email']);
@@ -79,12 +81,10 @@ class UsersPump extends AbstractPump implements PumpInterface
$user->setActive(false);
$user->setMigrated(true);
$user->setMigratedId($record['id']);
$accounting = new Accounting();
$accounting->setUser($user);
$user->setDateCreated($created);
$user->setDateUpdated(new \DateTime());
$this->entityManager->persist($user);
$this->entityManager->persist($accounting);
}
$this->entityManager->flush();
@@ -125,11 +125,11 @@ class EuropeanCentralBankExchange implements ExchangeInterface
return 100;
}
public function getConversion(MoneyContainer $money, string $currency): EntityMoney
public function convert(MoneyContainer $money, string $toCurrency): EntityMoney
{
$converted = $this->converter->convert(
$money,
$currency,
$toCurrency,
null,
RoundingMode::HALF_EVEN
);
@@ -140,14 +140,14 @@ class EuropeanCentralBankExchange implements ExchangeInterface
);
}
public function getConversionRate(string $source, string $target): float
public function getConversionRate(string $fromCurrency, string $toCurrency): float
{
return $this->provider->getExchangeRate($source, $target)->toFloat();
return $this->provider->getExchangeRate($fromCurrency, $toCurrency)->toFloat();
}
public function getConversionDate(string $source, string $target): \DateTimeInterface
public function getConversionDate(string $fromCurrency, string $toCurrency): \DateTimeInterface
{
$this->provider->getExchangeRate($source, $target);
$this->provider->getExchangeRate($fromCurrency, $toCurrency);
return $this->date;
}
@@ -2,7 +2,7 @@
namespace App\Library\Economy\Currency;
use App\Entity\Money as EntityMoney;
use App\Entity\Money;
use Brick\Money\Exception\CurrencyConversionException;
use Brick\Money\MoneyContainer;
@@ -19,32 +19,32 @@ interface ExchangeInterface
public function getWeight(): int;
/**
* @param MoneyContainer $money The money to be converted
* @param string $currency The currency to convert to
* @param MoneyContainer $money The money to be converted
* @param string $toCurrency The currency to convert to
*
* @return EntityMoney The converted Money
* @return Money The converted Money
*
* @throws CurrencyConversionException If the exchange rate is not available
*/
public function getConversion(MoneyContainer $money, string $currency): EntityMoney;
public function convert(MoneyContainer $money, string $toCurrency): Money;
/**
* @param string $source The currency to convert from
* @param string $target The currency to convert to
* @param string $fromCurrency The currency to convert from
* @param string $toCurrency The currency to convert to
*
* @return float The rate of the conversion
*
* @throws CurrencyConversionException If the exchange rate is not available
*/
public function getConversionRate(string $source, string $target): float;
public function getConversionRate(string $fromCurrency, string $toCurrency): float;
/**
* @param string $source The currency to convert from
* @param string $target The currency to convert to
* @param string $fromCurrency The currency to convert from
* @param string $toCurrency The currency to convert to
*
* @return \DateTimeInterface The date and time at which the rate was last updated
*
* @throws CurrencyConversionException If the exchange rate is not available
*/
public function getConversionDate(string $source, string $target): \DateTimeInterface;
public function getConversionDate(string $fromCurrency, string $toCurrency): \DateTimeInterface;
}
+85
View File
@@ -0,0 +1,85 @@
<?php
namespace App\Library\Economy;
use App\Entity\Money;
use App\Library\Economy\Currency\ExchangeLocator;
use Brick\Money as Brick;
class MoneyService
{
public function __construct(
private ExchangeLocator $exchangeLocator,
) {}
public static function toMoney(Brick\Money $brick): Money
{
return new Money(
$brick->getMinorAmount()->toInt(),
$brick->getCurrency()->getCurrencyCode()
);
}
public static function toBrick(Money $money): Brick\Money
{
return Brick\Money::ofMinor($money->amount, $money->currency);
}
/**
* Adds `a` to `b`.
*/
public function add(Money $a, Money $b): Money
{
$a = $this->convert($a, $b->currency);
$ab = self::toBrick($b)->plus($a);
return self::toMoney($ab);
}
/**
* Substracts `a` from `b`.
*/
public function substract(Money $a, Money $b): Money
{
$a = $this->convert($a, $b->currency);
$ab = self::toBrick($b)->minus($a);
return self::toMoney($ab);
}
/**
* Compares if one Money is of less value than other.
*
* @return bool `true` if `$money` is less than `$than`
*/
public function isLess(Money $money, Money $than): bool
{
$money = $this->convert($money, $than->currency);
return $money->isLessThan(self::toBrick($than));
}
/**
* Compares if one Money is of greater or equal value than other.
*
* @return bool `true` if `$money` is more than or same as `$than`
*/
public function isMoreOrSame(Money $money, Money $than): bool
{
$money = $this->convert($money, $than->currency);
return $money->isGreaterThanOrEqualTo(self::toBrick($than));
}
private function convert(Money $money, string $toCurrency): Brick\Money
{
$fromCurrency = $money->currency;
if ($fromCurrency === $toCurrency) {
return self::toBrick($money);
}
$exchange = $this->exchangeLocator->getExchangeFor($fromCurrency, $toCurrency);
return self::toBrick($exchange->convert(self::toBrick($money), $toCurrency));
}
}
@@ -1,11 +0,0 @@
<?php
namespace App\Library\Economy\Payment;
abstract class WalletGateway implements GatewayInterface
{
public static function getName(): string
{
return 'wallet';
}
}
@@ -0,0 +1,42 @@
<?php
namespace App\Mapping\Accounting;
use App\ApiResource\Accounting as Resource;
use App\Entity\Accounting as Entity;
use App\Repository\Accounting\AccountingRepository;
use Doctrine\ORM\EntityManagerInterface;
class AccountingMapper
{
public function __construct(
private EntityManagerInterface $entityManager,
private AccountingRepository $accountingRepository,
) {}
public function toResource(Entity\Accounting $entity): Resource\Accounting
{
$owner = $entity->getOwner();
$resource = new Resource\Accounting();
$resource->id = $entity->getId();
$resource->currency = $entity->getCurrency();
$resource->owner = $owner;
return $resource;
}
public function toEntity(Resource\Accounting $resource): Entity\Accounting
{
$entity = new Entity\Accounting();
if ($resource->id !== null) {
$entity = $this->accountingRepository->find($resource->id);
}
$entity->setCurrency($resource->currency);
$entity->setOwner($resource->owner);
return $entity;
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Mapping\Gateway;
use App\ApiResource\Gateway as Resource;
use App\Entity\Gateway as Entity;
use App\Mapping\Accounting\AccountingMapper;
use App\Repository\Gateway\ChargeRepository;
class ChargeMapper
{
public function __construct(
private AccountingMapper $accountingMapper,
private ChargeRepository $chargeRepository,
) {}
public function toResource(Entity\Charge $entity): Resource\Charge
{
$resource = new Resource\Charge();
$resource->id = $entity->getId();
$resource->type = $entity->getType();
$resource->title = $entity->getTitle();
$resource->description = $entity->getDescription();
$resource->money = $entity->getMoney();
$resource->target = $this->accountingMapper->toResource($entity->getTarget());
return $resource;
}
public function toEntity(Resource\Charge $resource): Entity\Charge
{
$entity = new Entity\Charge();
if ($resource->id !== null) {
$entity = $this->chargeRepository->find($resource->id);
}
$entity->setType($resource->type);
$entity->setTitle($resource->title);
$entity->setDescription($resource->description);
$entity->setMoney($resource->money);
$entity->setTarget($this->accountingMapper->toEntity($resource->target));
return $entity;
}
}
+69
View File
@@ -0,0 +1,69 @@
<?php
namespace App\Mapping\Gateway;
use App\ApiResource\Gateway as Resource;
use App\Entity\Gateway as Entity;
use App\Gateway\GatewayLocator;
use App\Mapping\Accounting\AccountingMapper;
use App\Repository\Gateway\CheckoutRepository;
use Doctrine\Common\Collections\ArrayCollection;
class CheckoutMapper
{
public function __construct(
private GatewayLocator $gatewayLocator,
private GatewayMapper $gatewayMapper,
private CheckoutRepository $checkoutRepository,
private AccountingMapper $accountingMapper,
private ChargeMapper $chargeMapper,
) {}
public function toResource(Entity\Checkout $entity): Resource\Checkout
{
$resource = new Resource\Checkout();
$resource->id = $entity->getId();
$gateway = $this->gatewayLocator->getGateway($entity->getGateway());
$resource->gateway = $this->gatewayMapper->toResource($gateway);
$resource->origin = $this->accountingMapper->toResource($entity->getOrigin());
$charges = [];
foreach ($entity->getCharges() as $charge) {
$charges[] = $this->chargeMapper->toResource($charge);
}
$resource->charges = $charges;
$resource->status = $entity->getStatus();
$resource->links = $entity->getLinks();
$resource->trackings = $entity->getTrackings();
return $resource;
}
public function toEntity(Resource\Checkout $resource): Entity\Checkout
{
$entity = new Entity\Checkout();
if ($resource->id !== null) {
$entity = $this->checkoutRepository->find($resource->id);
}
$entity->setGateway($resource->gateway->name);
$entity->setOrigin($this->accountingMapper->toEntity($resource->origin));
$charges = [];
foreach ($resource->charges as $charge) {
$charges[] = $this->chargeMapper->toEntity($charge);
}
$entity->setCharges(new ArrayCollection($charges));
$entity->setStatus($resource->status);
$entity->setLinks($resource->links);
return $entity;
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace App\Mapping\Gateway;
use App\ApiResource\Gateway\Gateway;
use App\Gateway\GatewayInterface;
class GatewayMapper
{
public function toResource(GatewayInterface $gateway): Gateway
{
$resource = new Gateway();
$resource->name = $gateway::getName();
$resource->supports = $gateway::getSupportedChargeTypes();
return $resource;
}
}
+1 -3
View File
@@ -12,9 +12,7 @@ use ApiPlatform\OpenApi\OpenApi;
class OpenApiFactory implements OpenApiFactoryInterface
{
public function __construct(private OpenApiFactoryInterface $decorated)
{
}
public function __construct(private OpenApiFactoryInterface $decorated) {}
private function updateOperation(Operation $operation): Operation
{
@@ -1,8 +1,8 @@
<?php
namespace App\Repository;
namespace App\Repository\Accounting;
use App\Entity\Accounting;
use App\Entity\Accounting\Accounting;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
@@ -1,28 +1,28 @@
<?php
namespace App\Repository;
namespace App\Repository\Accounting;
use App\Entity\AccountingTransaction;
use App\Entity\Accounting\Transaction;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<AccountingTransaction>
* @extends ServiceEntityRepository<Transaction>
*
* @method AccountingTransaction|null find($id, $lockMode = null, $lockVersion = null)
* @method AccountingTransaction|null findOneBy(array $criteria, array $orderBy = null)
* @method AccountingTransaction[] findAll()
* @method AccountingTransaction[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
* @method Transaction|null find($id, $lockMode = null, $lockVersion = null)
* @method Transaction|null findOneBy(array $criteria, array $orderBy = null)
* @method Transaction[] findAll()
* @method Transaction[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class AccountingTransactionRepository extends ServiceEntityRepository
class TransactionRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, AccountingTransaction::class);
parent::__construct($registry, Transaction::class);
}
// /**
// * @return AccountingTransaction[] Returns an array of AccountingTransaction objects
// * @return Transaction[] Returns an array of Transaction objects
// */
// public function findByExampleField($value): array
// {
@@ -36,7 +36,7 @@ class AccountingTransactionRepository extends ServiceEntityRepository
// ;
// }
// public function findOneBySomeField($value): ?AccountingTransaction
// public function findOneBySomeField($value): ?Transaction
// {
// return $this->createQueryBuilder('t')
// ->andWhere('t.exampleField = :val')
@@ -1,28 +1,28 @@
<?php
namespace App\Repository;
namespace App\Repository\Gateway;
use App\Entity\GatewayCharge;
use App\Entity\Gateway\Charge;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<GatewayCharge>
* @extends ServiceEntityRepository<Charge>
*
* @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)
* @method Charge|null find($id, $lockMode = null, $lockVersion = null)
* @method Charge|null findOneBy(array $criteria, array $orderBy = null)
* @method Charge[] findAll()
* @method Charge[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class GatewayChargeRepository extends ServiceEntityRepository
class ChargeRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, GatewayCharge::class);
parent::__construct($registry, Charge::class);
}
// /**
// * @return GatewayCharge[] Returns an array of GatewayCharge objects
// * @return Charge[] Returns an array of Charge objects
// */
// public function findByExampleField($value): array
// {
@@ -36,7 +36,7 @@ class GatewayChargeRepository extends ServiceEntityRepository
// ;
// }
// public function findOneBySomeField($value): ?GatewayCharge
// public function findOneBySomeField($value): ?Charge
// {
// return $this->createQueryBuilder('g')
// ->andWhere('g.exampleField = :val')
@@ -0,0 +1,57 @@
<?php
namespace App\Repository\Gateway;
use App\Entity\Gateway\Checkout;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Checkout>
*
* @method Checkout|null find($id, $lockMode = null, $lockVersion = null)
* @method Checkout|null findOneBy(array $criteria, array $orderBy = null)
* @method Checkout[] findAll()
* @method Checkout[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class CheckoutRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Checkout::class);
}
/**
* @return Checkout|null
*/
public function findOneByTracking(string $title, string $value): ?array
{
// TO-DO: Use a JSON lookup into the 'trackings' column to see if it contains title and value
return null;
}
// /**
// * @return Checkout[] Returns an array of Checkout 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): ?Checkout
// {
// return $this->createQueryBuilder('g')
// ->andWhere('g.exampleField = :val')
// ->setParameter('val', $value)
// ->getQuery()
// ->getOneOrNullResult()
// ;
// }
}
@@ -1,65 +0,0 @@
<?php
namespace App\Repository;
use App\Entity\GatewayCheckout;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<GatewayCheckout>
*
* @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|null
*/
public function findOneByTracking(string $title, string $value): array
{
return $this->createQueryBuilder('g')
->join('g.gatewayTrackings', 'gt', Join::WITH, 'gt.checkout = g.id')
->andWhere('gt.value = :val')
->andWhere('gt.title = :title')
->setParameter('val', $value)
->setParameter('title', $title)
->getQuery()
->getOneOrNullResult()
;
}
// /**
// * @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()
// ;
// }
}
+1 -1
View File
@@ -2,7 +2,7 @@
namespace App\Repository;
use App\Entity\Accounting;
use App\Entity\Accounting\Accounting;
use App\Entity\Project;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
+1 -1
View File
@@ -2,7 +2,7 @@
namespace App\Repository;
use App\Entity\Accounting;
use App\Entity\Accounting\Accounting;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
@@ -0,0 +1,43 @@
<?php
namespace App\Repository;
use App\Entity\WalletFinancement;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<WalletFinancement>
*/
class WalletFinancementRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, WalletFinancement::class);
}
// /**
// * @return WalletFinancement[] Returns an array of WalletFinancement objects
// */
// public function findByExampleField($value): array
// {
// return $this->createQueryBuilder('w')
// ->andWhere('w.exampleField = :val')
// ->setParameter('val', $value)
// ->orderBy('w.id', 'ASC')
// ->setMaxResults(10)
// ->getQuery()
// ->getResult()
// ;
// }
// public function findOneBySomeField($value): ?WalletFinancement
// {
// return $this->createQueryBuilder('w')
// ->andWhere('w.exampleField = :val')
// ->setParameter('val', $value)
// ->getQuery()
// ->getOneOrNullResult()
// ;
// }
}
@@ -0,0 +1,91 @@
<?php
namespace App\Repository;
use App\Entity\Accounting\Accounting;
use App\Entity\WalletStatement;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<WalletStatement>
*/
class WalletStatementRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, WalletStatement::class);
}
/**
* @return WalletStatement[]
*/
public function findByAccounting(Accounting $accounting): array
{
return $this->createQueryBuilder('w')
->join('w.transaction', 'wt', Join::WITH, 'wt.id = w.transaction')
->where('wt.origin = :val')
->orWhere('wt.target = :val')
->setParameter('val', $accounting->getId())
->orderBy('w.id', 'ASC')
->getQuery()
->getResult()
;
}
/**
* @return WalletStatement[]
*/
public function findByOrigin(Accounting $accounting): array
{
return $this->createQueryBuilder('w')
->join('w.transaction', 'wt', Join::WITH, 'wt.id = w.transaction')
->andWhere('wt.origin = :val')
->setParameter('val', $accounting->getId())
->orderBy('w.id', 'ASC')
->getQuery()
->getResult()
;
}
/**
* @return WalletStatement[]
*/
public function findByTarget(Accounting $accounting): array
{
return $this->createQueryBuilder('w')
->join('w.transaction', 'wt', Join::WITH, 'wt.id = w.transaction')
->andWhere('wt.target = :val')
->setParameter('val', $accounting->getId())
->orderBy('w.id', 'ASC')
->getQuery()
->getResult()
;
}
// /**
// * @return WalletStatement[] Returns an array of WalletStatement objects
// */
// public function findByExampleField($value): array
// {
// return $this->createQueryBuilder('w')
// ->andWhere('w.exampleField = :val')
// ->setParameter('val', $value)
// ->orderBy('w.id', 'ASC')
// ->setMaxResults(10)
// ->getQuery()
// ->getResult()
// ;
// }
// public function findOneBySomeField($value): ?WalletStatement
// {
// return $this->createQueryBuilder('w')
// ->andWhere('w.exampleField = :val')
// ->setParameter('val', $value)
// ->getQuery()
// ->getOneOrNullResult()
// ;
// }
}
+1 -3
View File
@@ -9,9 +9,7 @@ use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
class AccessTokenHandler implements AccessTokenHandlerInterface
{
public function __construct(private UserTokenRepository $userTokenRepository)
{
}
public function __construct(private UserTokenRepository $userTokenRepository) {}
public function getUserBadgeFrom(#[\SensitiveParameter] string $accessToken): UserBadge
{
+1 -1
View File
@@ -14,7 +14,7 @@ class AccountingVoter extends Voter
protected function supports(string $attribute, mixed $subject): bool
{
return in_array($attribute, [self::EDIT])
&& $subject instanceof \App\Entity\Accounting;
&& $subject instanceof \App\Entity\Accounting\Accounting;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
@@ -2,10 +2,7 @@
namespace App\Service;
/**
* Normalizes between Entity classnames and API resource names.
*/
class ApiResourceNormalizer
class ApiService
{
/**
* Converts an Entity class name into an API resource name.
+1 -2
View File
@@ -13,8 +13,7 @@ class AuthService
public function __construct(
private string $appSecret,
) {
}
) {}
/**
* @return array{CORS_ALLOW_ORIGIN: string, SESSION_LIFETIME: int}
@@ -1,23 +1,22 @@
<?php
namespace App\Service;
namespace App\Service\Gateway;
use App\Controller\GatewaysController;
use App\Entity\AccountingTransaction;
use App\Entity\GatewayCharge;
use App\Entity\GatewayCheckout;
use App\Entity\GatewayCheckoutStatus;
use App\Entity\Accounting\Transaction;
use App\Entity\Gateway\Charge;
use App\Entity\Gateway\Checkout;
use App\Entity\Gateway\CheckoutStatus;
use Symfony\Component\Routing\RouterInterface;
class GatewayCheckoutService
class CheckoutService
{
public const RESPONSE_TYPE_SUCCESS = 'success';
public const RESPONSE_TYPE_FAILURE = 'failure';
public function __construct(
private RouterInterface $router,
) {
}
) {}
/**
* Generates a suitable value for redirection params to external gateways.
@@ -25,7 +24,7 @@ class GatewayCheckoutService
* @return string Absolute URL to the gateways redirection handler
*/
public function generateRedirectUrl(
GatewayCheckout $checkout,
Checkout $checkout,
string $type = self::RESPONSE_TYPE_SUCCESS,
array $parameters = [],
): string {
@@ -46,7 +45,7 @@ class GatewayCheckoutService
*
* @return string A tracking code suitable for external gateway data matching
*/
public function generateTracking(GatewayCheckout $checkout, GatewayCharge $charge): string
public function generateTracking(Checkout $checkout, Charge $charge): string
{
return \sprintf(
'%s-%s',
@@ -60,7 +59,7 @@ class GatewayCheckoutService
*
* @return string A tracking code suitable for external gateway data matching
*/
public function generateCheckoutTracking(GatewayCheckout $checkout): string
public function generateCheckoutTracking(Checkout $checkout): string
{
return \sprintf(
'AO%d-CO%d',
@@ -74,7 +73,7 @@ class GatewayCheckoutService
*
* @return string A tracking code suitable for external gateway data matching
*/
public function generateChargeTracking(GatewayCharge $charge): string
public function generateChargeTracking(Charge $charge): string
{
return \sprintf(
'CH%d-AT%d',
@@ -84,23 +83,23 @@ class GatewayCheckoutService
}
/**
* Updates a successful GatewayCheckout and generates the transactions for each charge.
* Updates a successful Checkout and generates the transactions for each charge.
*
* @param GatewayCheckout $checkout The GatewayCheckout to be updated
* @param Checkout $checkout The Checkout to be updated
*
* @return GatewayCheckout The updated GatewayCheckout with updated charges and generated transactions
* @return Checkout The updated Checkout with updated charges and generated transactions
*/
public function chargeCheckout(GatewayCheckout $checkout): GatewayCheckout
public function chargeCheckout(Checkout $checkout): Checkout
{
$checkout->setStatus(GatewayCheckoutStatus::Charged);
$checkout->setStatus(CheckoutStatus::Charged);
foreach ($checkout->getCharges() as $charge) {
$transaction = new AccountingTransaction();
$transaction = new Transaction();
$transaction->setMoney($charge->getMoney());
$transaction->setOrigin($checkout->getOrigin());
$transaction->setTarget($charge->getTarget());
$charge->setTransaction($transaction);
$charge->addTransaction($transaction);
}
return $checkout;
@@ -0,0 +1,35 @@
<?php
namespace App\State\Accounting;
use ApiPlatform\Doctrine\Common\State\PersistProcessor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\ApiResource\Accounting as ApiResource;
use App\Entity\Accounting as Entity;
use App\Mapping\Accounting\AccountingMapper;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
class AccountingStateProcessor implements ProcessorInterface
{
public function __construct(
private AccountingMapper $accountingMapper,
#[Autowire(service: PersistProcessor::class)]
private ProcessorInterface $persistProcessor,
) {}
/**
* @param ApiResource\Accounting $data
*
* @return ApiResource\Accounting
*/
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = [])
{
/** @var Entity\Accounting */
$entity = $this->accountingMapper->toEntity($data);
$this->persistProcessor->process($entity, $operation, $uriVariables, $context);
return $this->accountingMapper->toResource($entity);
}
}
@@ -0,0 +1,57 @@
<?php
namespace App\State\Accounting;
use ApiPlatform\Doctrine\Orm\State\CollectionProvider;
use ApiPlatform\Doctrine\Orm\State\ItemProvider;
use ApiPlatform\Metadata\CollectionOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\Pagination\TraversablePaginator;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\Accounting as ApiResource;
use App\Entity\Accounting as Entity;
use App\Mapping\Accounting\AccountingMapper;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
class AccountingStateProvider implements ProviderInterface
{
public function __construct(
#[Autowire(service: ItemProvider::class)]
private ProviderInterface $itemProvider,
#[Autowire(service: CollectionProvider::class)]
private ProviderInterface $collectionProvider,
private AccountingMapper $accountingMapper,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
{
if ($operation instanceof CollectionOperationInterface) {
$accountings = $this->collectionProvider->provide($operation, $uriVariables, $context);
$resources = [];
foreach ($accountings as $accounting) {
$resources[] = $this->toResource($accounting);
}
return new TraversablePaginator(
new \ArrayIterator($resources),
$accountings->getCurrentPage(),
$accountings->getItemsPerPage(),
$accountings->getTotalItems()
);
}
$accounting = $this->itemProvider->provide($operation, $uriVariables, $context);
return $this->toResource($accounting);
}
private function toResource(?Entity\Accounting $accounting): ?ApiResource\Accounting
{
if ($accounting === null) {
return null;
}
return $this->accountingMapper->toResource($accounting);
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\State\Gateway;
use ApiPlatform\Doctrine\Orm\State\ItemProvider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Mapping\Gateway\ChargeMapper;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
class ChargeStateProvider implements ProviderInterface
{
public function __construct(
#[Autowire(service: ItemProvider::class)]
private ProviderInterface $itemProvider,
private ChargeMapper $chargeMapper,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
{
$charge = $this->itemProvider->provide($operation, $uriVariables, $context);
if ($charge === null) {
return null;
}
return $this->chargeMapper->toResource($charge);
}
}
@@ -0,0 +1,37 @@
<?php
namespace App\State\Gateway;
use ApiPlatform\Doctrine\Common\State\PersistProcessor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\ApiResource\Gateway as Resource;
use App\Gateway\GatewayLocator;
use App\Mapping\Gateway\CheckoutMapper;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
class CheckoutStateProcessor implements ProcessorInterface
{
public function __construct(
private CheckoutMapper $checkoutMapper,
#[Autowire(service: PersistProcessor::class)]
private ProcessorInterface $innerProcessor,
private GatewayLocator $gatewayLocator,
) {}
/**
* @param Resource\Checkout $data
*
* @return Resource\Checkout
*/
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = [])
{
$entity = $this->checkoutMapper->toEntity($data);
$entity = $this->innerProcessor->process($entity, $operation, $uriVariables, $context);
$entity = $this->gatewayLocator->getGateway($data->gateway->name)->process($entity);
$entity = $this->innerProcessor->process($entity, $operation, $uriVariables, $context);
return $this->checkoutMapper->toResource($entity);
}
}
@@ -1,41 +1,21 @@
<?php
namespace App\State;
namespace App\State\Gateway;
use ApiPlatform\Metadata as API;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\Gateway;
use App\Library\Economy\Payment\GatewayLocator;
use App\ApiResource\Gateway\Gateway;
use App\Gateway\GatewayLocator;
use App\Mapping\Gateway\GatewayMapper;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class GatewayStateProvider implements ProviderInterface
{
public function __construct(
private GatewayLocator $gateways,
) {
}
private function getGateways(): array
{
$gateways = [];
foreach ($this->gateways->getGateways() as $gateway) {
$gateways[] = new Gateway($gateway);
}
return $gateways;
}
private function getGateway(string $name): Gateway
{
try {
$gateway = $this->gateways->getGateway($name);
return new Gateway($gateway);
} catch (\Exception $e) {
throw new NotFoundHttpException('Not Found');
}
}
private GatewayMapper $gatewayMapper,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
{
@@ -46,4 +26,25 @@ class GatewayStateProvider implements ProviderInterface
return $this->getGateway($uriVariables['name']);
}
}
private function getGateways(): array
{
$gateways = [];
foreach ($this->gateways->getGateways() as $gateway) {
$gateways[] = $this->gatewayMapper->toResource($gateway);
}
return $gateways;
}
private function getGateway(string $name): Gateway
{
try {
$gateway = $this->gateways->getGateway($name);
return $this->gatewayMapper->toResource($gateway);
} catch (\Exception $e) {
throw new NotFoundHttpException('Not Found');
}
}
}
-34
View File
@@ -1,34 +0,0 @@
<?php
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\GatewayCheckout;
use App\Library\Economy\Payment\GatewayLocator;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
class GatewayCheckoutProcessor implements ProcessorInterface
{
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private ProcessorInterface $innerProcessor,
private GatewayLocator $gatewayLocator,
private EntityManagerInterface $entityManager,
) {
}
/**
* @param GatewayCheckout $data
*/
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = [])
{
$checkout = $this->innerProcessor->process($data, $operation, $uriVariables, $context);
$gateway = $this->gatewayLocator->getGatewayOf($checkout);
$checkout = $gateway->process($checkout);
return $this->innerProcessor->process($data, $operation, $uriVariables, $context);
}
}
+1 -2
View File
@@ -14,8 +14,7 @@ class UserStateProcessor implements ProcessorInterface
public function __construct(
private ProcessorInterface $innerProcessor,
private UserPasswordHasherInterface $userPasswordHasher,
) {
}
) {}
/**
* @return T2
+1 -2
View File
@@ -21,8 +21,7 @@ class UserTokenLoginProcessor implements ProcessorInterface
private UserRepository $userRepository,
private EntityManagerInterface $entityManager,
private UserPasswordHasherInterface $userPasswordHasher,
) {
}
) {}
/**
* @param UserTokenLoginDto $data
-21
View File
@@ -1,21 +0,0 @@
<?php
namespace App\Validator;
use App\Library\Economy\Payment\GatewayLocator;
use Symfony\Component\Validator\Constraints\Choice;
/**
* @Annotation
* @Target({"PROPERTY", "METHOD", "ANNOTATION"})
*/
#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
class GatewayName extends Choice
{
public function __construct()
{
parent::__construct(
options: GatewayLocator::getNamesStatic()
);
}
}
-37
View File
@@ -1,37 +0,0 @@
<?php
namespace App\Validator;
use App\Library\Economy\Payment\GatewayLocator;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\Constraints\Choice;
use Symfony\Component\Validator\Constraints\ChoiceValidator;
class GatewayNameValidator extends ChoiceValidator
{
public function __construct(
private GatewayLocator $gateways,
) {
}
public function validate($value, Constraint $constraint): void
{
/* @var App\Validator\GatewayName $constraint */
if ($value === null || $value === '') {
return;
}
try {
if ($this->gateways->getGateway($value)) {
return;
}
} catch (\Exception $e) {
$this->context->buildViolation($constraint->message)
->setParameter('{{ value }}', $this->formatValue($value))
->setParameter('{{ choices }}', $this->formatValues($constraint->choices))
->setCode(Choice::NO_SUCH_CHOICE_ERROR)
->addViolation();
}
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace App\Validator;
use Symfony\Component\Validator\Constraint;
/**
* Validates that a GatewayCheckout has GatewayCharges with charge types supported by the given Gateway.
*/
#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
class SupportedChargeTypes extends Constraint
{
public string $message = 'The charge type "{{ value }}" is not supported by the "{{ gateway }}" Gateway.';
}
@@ -0,0 +1,44 @@
<?php
namespace App\Validator;
use App\Gateway\GatewayLocator;
use Doctrine\Common\Collections\Collection;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
class SupportedChargeTypesValidator extends ConstraintValidator
{
public function __construct(
private GatewayLocator $gatewayLocator,
) {}
public function validate(mixed $value, Constraint $constraint): void
{
if ($value === null || $value === '') {
return;
}
if (!$value instanceof Collection) {
return;
}
/** @var Collection<int, \App\Entity\Gateway\Charge> */
$charges = $value;
$checkout = $charges->toArray()[0]->getCheckout();
$gateway = $this->gatewayLocator->getGatewayOf($checkout);
foreach ($charges as $charge) {
if (!\in_array(
$charge->getType(),
$gateway->getSupportedChargeTypes()
)) {
$this->context->buildViolation($constraint->message)
->setParameter('{{ value }}', $charge->getType()->value)
->setParameter('{{ gateway }}', $gateway->getName())
->addViolation();
}
}
}
}
@@ -1,8 +1,8 @@
<?php
namespace App\Tests\Entity;
namespace App\Tests\Entity\Accounting;
use App\Entity\Accounting;
use App\Entity\Accounting\Accounting;
use App\Entity\Tipjar;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
@@ -27,7 +27,7 @@ class AccountingTest extends KernelTestCase
$tipjarA->setName('TEST_TIPJAR_A');
$accountingA = new Accounting();
$accountingA->setTipjar($tipjarA);
$accountingA->setOwner($tipjarA);
$this->entityManager->persist($tipjarA);
$this->entityManager->flush();
@@ -38,14 +38,14 @@ class AccountingTest extends KernelTestCase
->getAccounting();
$this->assertNotNull($accountingA->getId());
$this->assertEquals(Tipjar::class, $accountingA->getOwnerClass());
$this->assertEquals('TEST_TIPJAR_A', $accountingA->getTipjar()->getName());
$this->assertEquals($tipjarA->getId(), $accountingA->getOwner()->getId());
$this->assertEquals(Tipjar::class, $accountingA->getOwner()::class);
$tipjarB = new Tipjar();
$tipjarB->setName('TEST_TIPJAR_B');
$accountingB = new Accounting();
$accountingB->setTipjar($tipjarB);
$accountingB->setOwner($tipjarB);
$this->entityManager->persist($tipjarB);
$this->entityManager->flush();
@@ -56,7 +56,7 @@ class AccountingTest extends KernelTestCase
->getAccounting();
$this->assertNotNull($accountingB->getId());
$this->assertEquals(Tipjar::class, $accountingB->getOwnerClass());
$this->assertEquals('TEST_TIPJAR_B', $accountingB->getTipjar()->getName());
$this->assertEquals($tipjarB->getId(), $accountingB->getOwner()->getId());
$this->assertEquals(Tipjar::class, $accountingB->getOwner()::class);
}
}
@@ -1,21 +1,21 @@
<?php
namespace App\Tests\Library\Economy\Payment;
namespace App\Tests\Gateway\Paypal;
use App\Library\Economy\Payment\PaypalGatewayService;
use App\Gateway\Paypal\PaypalService;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
class PaypalGatewayServiceTest extends KernelTestCase
class PaypalServiceTest extends KernelTestCase
{
private PaypalGatewayService $paypalService;
private PaypalService $paypalService;
public function setUp(): void
{
self::bootKernel();
$this->paypalService = static::getContainer()->get(PaypalGatewayService::class);
$this->paypalService = static::getContainer()->get(PaypalService::class);
}
public function testAuthenticates(): void
+265
View File
@@ -0,0 +1,265 @@
<?php
namespace App\Tests\Gateway\Wallet;
use App\Entity\Accounting\Transaction;
use App\Entity\Money;
use App\Entity\Tipjar;
use App\Entity\User;
use App\Gateway\Wallet\StatementDirection;
use App\Gateway\Wallet\WalletService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Zenstruck\Foundry\Test\ResetDatabase;
class WalletServiceTest extends KernelTestCase
{
use ResetDatabase;
private EntityManagerInterface $entityManager;
private WalletService $walletService;
public function setUp(): void
{
self::bootKernel();
$this->entityManager = static::getContainer()->get(EntityManagerInterface::class);
$this->walletService = static::getContainer()->get(WalletService::class);
}
private function getUser(): User
{
$user = new User();
$user->setUsername('wallettestuser');
$user->setEmail('testuser@wallet.com');
$user->setPassword('wallettestpassword');
$this->entityManager->persist($user);
$this->entityManager->flush();
return $user;
}
private function getTipjar(): Tipjar
{
$tipjar = new Tipjar();
$tipjar->setName('WALLET_TEST_TIPJAR');
$this->entityManager->persist($tipjar);
$this->entityManager->flush();
return $tipjar;
}
public function testTransactionsAddFunds()
{
$tipjar = $this->getTipjar()->getAccounting();
$user = $this->getUser()->getAccounting();
$statements = $this->walletService->getStatements($user);
$this->assertCount(0, $statements);
$balance = $this->walletService->getBalance($user);
$this->assertEquals(0, $balance->amount);
$this->assertEquals($user->getCurrency(), $balance->currency);
$incoming = new Transaction();
$incoming->setMoney(new Money(100, 'EUR'));
$incoming->setOrigin($tipjar);
$incoming->setTarget($user);
/*
* $this->walletService->save($incoming);
* called automatically on Transaction persist.
*
* @see \App\EventListener\WalletTransactionsListener
*/
$this->entityManager->persist($incoming);
$this->entityManager->flush();
$balance = $this->walletService->getBalance($user);
$this->assertEquals(100, $balance->amount);
$this->assertEquals('EUR', $balance->currency);
$statements = $this->walletService->getStatements($user);
$this->assertCount(1, $statements);
$statement = $statements[0];
$this->assertEquals(100, $statement->getBalance()->amount);
$this->assertEquals(StatementDirection::Incoming->value, $statement->getDirection()->value);
$this->assertEquals('EUR', $balance->currency);
$this->assertCount(0, $statement->getFinancesTo());
$this->assertCount(0, $statement->getFinancedBy());
}
public function testTransactionsGetFinanced()
{
$tipjar = $this->getTipjar()->getAccounting();
$user = $this->getUser()->getAccounting();
$incoming = new Transaction();
$incoming->setMoney(new Money(100, 'EUR'));
$incoming->setOrigin($tipjar);
$incoming->setTarget($user);
$this->entityManager->persist($incoming);
$this->entityManager->flush();
$outgoing = new Transaction();
$outgoing->setMoney(new Money(20, 'EUR'));
$outgoing->setOrigin($user);
$outgoing->setTarget($tipjar);
$this->walletService->spend($outgoing);
$balance = $this->walletService->getBalance($user);
$this->assertEquals(80, $balance->amount);
$this->assertEquals('EUR', $balance->currency);
$statements = $this->walletService->getStatements($user);
$this->assertCount(2, $statements);
$statement = $statements[0];
$this->assertEquals(80, $statement->getBalance()->amount);
$this->assertEquals(StatementDirection::Incoming->value, $statement->getDirection()->value);
$this->assertEquals('EUR', $balance->currency);
$this->assertCount(1, $statement->getFinancesTo());
$this->assertCount(0, $statement->getFinancedBy());
$statement = $statements[1];
$this->assertEquals(20, $statement->getBalance()->amount);
$this->assertEquals(StatementDirection::Outgoing->value, $statement->getDirection()->value);
$this->assertEquals('EUR', $balance->currency);
$this->assertCount(0, $statement->getFinancesTo());
$this->assertCount(1, $statement->getFinancedBy());
$outgoing = new Transaction();
$outgoing->setMoney(new Money(30, 'EUR'));
$outgoing->setOrigin($user);
$outgoing->setTarget($tipjar);
$this->walletService->spend($outgoing);
$balance = $this->walletService->getBalance($user);
$this->assertEquals(50, $balance->amount);
$this->assertEquals('EUR', $balance->currency);
$statements = $this->walletService->getStatements($user);
$this->assertCount(3, $statements);
$statement = $statements[0];
$this->assertEquals(50, $statement->getBalance()->amount);
$this->assertEquals(StatementDirection::Incoming->value, $statement->getDirection()->value);
$this->assertEquals('EUR', $balance->currency);
$this->assertCount(2, $statement->getFinancesTo());
$this->assertCount(0, $statement->getFinancedBy());
$statement = $statements[1];
$this->assertEquals(20, $statement->getBalance()->amount);
$this->assertEquals(StatementDirection::Outgoing->value, $statement->getDirection()->value);
$this->assertEquals('EUR', $balance->currency);
$this->assertCount(0, $statement->getFinancesTo());
$this->assertCount(1, $statement->getFinancedBy());
$financement = $statements[0]->getFinancesTo()[0];
$this->assertEquals(20, $financement->getMoney()->amount);
$this->assertEquals('EUR', $financement->getMoney()->currency);
$this->assertSame($financement, $statement->getFinancedBy()[0]);
$this->assertEquals($financement->getId(), $statement->getFinancedBy()[0]->getId());
$statement = $statements[2];
$this->assertEquals(30, $statement->getBalance()->amount);
$this->assertEquals(StatementDirection::Outgoing->value, $statement->getDirection()->value);
$this->assertEquals('EUR', $balance->currency);
$this->assertCount(0, $statement->getFinancesTo());
$this->assertCount(1, $statement->getFinancedBy());
$financement = $statements[0]->getFinancesTo()[1];
$this->assertEquals(30, $financement->getMoney()->amount);
$this->assertEquals('EUR', $financement->getMoney()->currency);
$this->assertSame($financement, $statement->getFinancedBy()[0]);
$this->assertEquals($financement->getId(), $statement->getFinancedBy()[0]->getId());
}
public function testBalanceIsFIFO()
{
$tipjar = $this->getTipjar()->getAccounting();
$user = $this->getUser()->getAccounting();
$incoming = new Transaction();
$incoming->setMoney(new Money(10, 'EUR'));
$incoming->setOrigin($tipjar);
$incoming->setTarget($user);
$this->entityManager->persist($incoming);
$this->entityManager->flush();
$incoming = new Transaction();
$incoming->setMoney(new Money(11, 'EUR'));
$incoming->setOrigin($tipjar);
$incoming->setTarget($user);
$this->entityManager->persist($incoming);
$this->entityManager->flush();
$outgoing = new Transaction();
$outgoing->setMoney(new Money(12, 'EUR'));
$outgoing->setOrigin($user);
$outgoing->setTarget($tipjar);
$this->walletService->spend($outgoing);
$incoming = new Transaction();
$incoming->setMoney(new Money(13, 'EUR'));
$incoming->setOrigin($tipjar);
$incoming->setTarget($user);
$this->entityManager->persist($incoming);
$this->entityManager->flush();
$balance = $this->walletService->getBalance($user);
$this->assertEquals(22, $balance->amount);
$this->assertEquals('EUR', $balance->currency);
$statements = $this->walletService->getStatements($user);
$this->assertCount(4, $statements);
$statement = $statements[0];
$this->assertEquals(0, $statement->getBalance()->amount);
$this->assertEquals(StatementDirection::Incoming->value, $statement->getDirection()->value);
$this->assertEquals('EUR', $balance->currency);
$this->assertCount(1, $statement->getFinancesTo());
$this->assertCount(0, $statement->getFinancedBy());
$statement = $statements[1];
$this->assertEquals(9, $statement->getBalance()->amount);
$this->assertEquals(StatementDirection::Incoming->value, $statement->getDirection()->value);
$this->assertEquals('EUR', $balance->currency);
$this->assertCount(1, $statement->getFinancesTo());
$this->assertCount(0, $statement->getFinancedBy());
$statement = $statements[2];
$this->assertEquals(12, $statement->getBalance()->amount);
$this->assertEquals(StatementDirection::Outgoing->value, $statement->getDirection()->value);
$this->assertEquals('EUR', $balance->currency);
$this->assertCount(0, $statement->getFinancesTo());
$this->assertCount(2, $statement->getFinancedBy());
$statement = $statements[3];
$this->assertEquals(13, $statement->getBalance()->amount);
$this->assertEquals(StatementDirection::Incoming->value, $statement->getDirection()->value);
$this->assertEquals('EUR', $balance->currency);
$this->assertCount(0, $statement->getFinancesTo());
$this->assertCount(0, $statement->getFinancedBy());
}
}
@@ -0,0 +1,61 @@
<?php
namespace App\Tests\Library\Economy;
use App\Entity\Money;
use App\Library\Economy\MoneyService;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* WARNING: This test compares currencies, which change over time
* and might cause the test to fail without justifiable cause in the code.
*
* Test currencies and values have been selected for their stability to minimize chances of external factors such as
* * Hyper inflation/deflation
* * Currency intervention
* * Demonetization
*/
class MoneyServiceTest extends KernelTestCase
{
private MoneyService $moneyService;
public function setUp(): void
{
self::bootKernel();
$this->moneyService = static::getContainer()->get(MoneyService::class);
}
public function testComparesLess()
{
$a = new Money(300, 'EUR');
$b = new Money(200, 'EUR');
$this->assertTrue($this->moneyService->isLess($b, $a));
$this->assertFalse($this->moneyService->isLess($a, $b));
$c = new Money(100, 'JPY');
// We are cooked if this fails and I'm not talking about code
$this->assertTrue($this->moneyService->isLess($c, $a));
}
public function testComparesMore()
{
$a = new Money(100, 'GBP');
$b = new Money(100, 'GBP');
$this->assertTrue($this->moneyService->isMoreOrSame($a, $b));
$this->assertTrue($this->moneyService->isMoreOrSame($b, $a));
$c = new Money(300, 'GBP');
$this->assertTrue($this->moneyService->isMoreOrSame($c, $b));
$this->assertFalse($this->moneyService->isMoreOrSame($b, $c));
$d = new Money(100, 'MXN');
$this->assertFalse($this->moneyService->isMoreOrSame($d, $c));
$this->assertFalse($this->moneyService->isMoreOrSame($d, $a));
}
}