Merge branch 'main' into feature/prod_dockerfile

This commit is contained in:
David
2024-11-21 12:42:47 +01:00
committed by GitHub
128 changed files with 5358 additions and 2468 deletions
+15
View File
@@ -34,5 +34,20 @@ DATABASE_URL="mysql://goteo:goteo@mariadb:3306/goteo?serverVersion=10.11.2-Maria
CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
###< nelmio/cors-bundle ###
###> doctrineencryptbundle/doctrine-encrypt-bundle ###
DOCTRINE_ENCRYPT_KEY=""
CRYPTO_SECRET=""
###< doctrineencryptbundle/doctrine-encrypt-bundle ###
###> App\Gateway\Stripe\StripeGateway ###
STRIPE_API_KEY=""
STRIPE_WEBHOOK_SECRET=""
###< App\Gateway\Stripe\StripeGateway ###
###> 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\Gateway\Paypal\PaypalGateway ###
+1
View File
@@ -4,3 +4,4 @@ APP_SECRET='$ecretf0rt3st'
SYMFONY_DEPRECATIONS_HELPER=999999
PANTHER_APP_ENV=panther
PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots
CRYPTO_SECRET="CryptoSecret"
+1 -1
View File
@@ -8,7 +8,7 @@ jobs:
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
tools: php-cs-fixer
tools: php-cs-fixer:3.64
- name: Checkout repository
uses: actions/checkout@v4
+10
View File
@@ -22,3 +22,13 @@
/.php-cs-fixer.php
/.php-cs-fixer.cache
###< friendsofphp/php-cs-fixer ###
###> XDebug ###
/cachegrind*
###< XDebug ###
###> doctrineencryptbundle/doctrine-encrypt-bundle
.Halite.key
###< doctrineencryptbundle/doctrine-encrypt-bundle
.php-version
+3
View File
@@ -8,6 +8,9 @@ return (new PhpCsFixer\Config())
->setRules([
'@Symfony' => true,
'method_argument_space' => ['on_multiline' => 'ensure_fully_multiline'],
'function_declaration' => ['closure_fn_spacing' => 'none'],
'single_line_throw' => false,
'single_line_empty_body' => true,
'yoda_style' => [
'equal' => false,
'identical' => false,
+3 -2
View File
@@ -15,6 +15,7 @@
"doctrine/doctrine-bundle": "^2.12",
"doctrine/doctrine-migrations-bundle": "^3.3",
"doctrine/orm": "^2.19",
"doctrineencryptbundle/doctrine-encrypt-bundle": "^5.4",
"nelmio/cors-bundle": "^2.3",
"phpdocumentor/reflection-docblock": "^5.3",
"phpstan/phpdoc-parser": "^1.24",
@@ -28,6 +29,7 @@
"symfony/expression-language": "6.4.*",
"symfony/flex": "^2",
"symfony/framework-bundle": "6.4.*",
"symfony/http-client": "6.4.*",
"symfony/intl": "6.4.*",
"symfony/monolog-bundle": "^3.10",
"symfony/property-access": "6.4.*",
@@ -88,11 +90,10 @@
}
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.58",
"friendsofphp/php-cs-fixer": "3.64",
"phpunit/phpunit": "^9.5",
"symfony/browser-kit": "6.4.*",
"symfony/css-selector": "6.4.*",
"symfony/http-client": "6.4.*",
"symfony/maker-bundle": "^1.51",
"symfony/phpunit-bridge": "^7.0",
"zenstruck/foundry": "^1.36"
Generated
+1049 -587
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -12,4 +12,5 @@ return [
Stof\DoctrineExtensionsBundle\StofDoctrineExtensionsBundle::class => ['all' => true],
Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
Ambta\DoctrineEncryptBundle\AmbtaDoctrineEncryptBundle::class => ['all' => true],
];
@@ -0,0 +1,11 @@
ambta_doctrine_encrypt:
# If you want, you can use your own Encryptor. Encryptor must implements EncryptorInterface interface
# Default: Halite
# encryptor_class: Halite
# Path where to store the keyfiles
# Default: '%kernel.project_dir%'
# secret_directory_path: '%kernel.project_dir%'
# Secret string
secret: '%env(CRYPTO_SECRET)%'
+18 -11
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,14 +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)%'
# add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones
-1
View File
@@ -7,7 +7,6 @@ services:
container_name: goteo-v4-php
volumes:
- .:/app
- ./docker/php/conf.d/apc.ini:/usr/local/etc/php/conf.d/apc.ini:ro
- ./docker/php/conf.d/opcache.ini:/usr/local/etc/php/conf.d/opcache.ini:ro
nginx:
-7
View File
@@ -1,7 +0,0 @@
[apc]
apc.enabled=1
apc.enable_cli=1
apc.shm_size=32M
apc.ttl=0
apc.gc_ttl=3600
apc.mmap_file_mask=/tmp/apc.XXXXXX
+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
+1
View File
@@ -4,3 +4,4 @@ xdebug.client_host=host.docker.internal
xdebug.client_port=9003
xdebug.discover_client_host=0
xdebug.start_with_request=trigger
xdebug.output_dir=/app
+37
View File
@@ -0,0 +1,37 @@
<?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\Entity\Money;
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(
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;
public ?Money $balance = 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;
}
+76
View File
@@ -0,0 +1,76 @@
<?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 App\State\Gateway\CheckoutStateProvider;
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),
provider: CheckoutStateProvider::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()]
#[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;
}
+4 -5
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;
@@ -23,9 +23,8 @@ class Version
{
public function __construct(
private readonly LogEntry $log,
private readonly object $entity
) {
}
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());
}
/**
+76 -34
View File
@@ -3,12 +3,15 @@
namespace App\Command;
use App\Library\Benzina\Benzina;
use App\Library\Benzina\Pump\PumpInterface;
use App\Library\Benzina\Stream\PdoStream;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\ConsoleOutputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
@@ -19,7 +22,7 @@ use Symfony\Component\Console\Style\SymfonyStyle;
class BenzinaPumpCommand extends Command
{
public function __construct(
private Benzina $benzina
private Benzina $benzina,
) {
parent::__construct();
}
@@ -28,6 +31,14 @@ class BenzinaPumpCommand extends Command
{
$this->addArgument('table', InputArgument::REQUIRED);
$this->addOption(
'database',
null,
InputOption::VALUE_OPTIONAL,
'The address of the database to read from',
'mysql://goteo:goteo@mariadb:3306/benzina'
);
$this->addOption(
'batch-size',
null,
@@ -37,18 +48,11 @@ class BenzinaPumpCommand extends Command
);
$this->addOption(
'no-progress',
'skip-pumped',
null,
InputOption::VALUE_NONE,
'For progressive pumps, disables the override of already pumped progress'
);
$this->addOption(
'database',
null,
InputOption::VALUE_OPTIONAL,
'The address of the database to read from',
'mysql://goteo:goteo@mariadb:3306/benzina'
InputOption::VALUE_NEGATABLE,
'Skips feeding already pumped records in a batch',
true
);
$this->addUsage('app:benzina:pump --no-debug user');
@@ -65,61 +69,99 @@ EOF
);
}
/**
* @param ConsoleOutputInterface $output
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$batchSize = $input->getOption('batch-size');
$stream = new PdoStream(
$input->getOption('database'),
$input->getArgument('table'),
$input->getOption('batch-size')
$batchSize
);
$streamSize = $stream->size();
$streamBatches = $streamSize / $batchSize;
$streamSection = new SymfonyStyle($input, $output->section());
if ($streamSize < 1) {
$io->writeln('No data found at the given source. Skipping execution.');
$streamSection->writeln('No data found at the given source. Skipping execution.');
return Command::SUCCESS;
}
$pumps = $this->benzina->getPumps($stream);
$pumpsCount = count($pumps);
$streamSection->writeln(sprintf('Streaming %d records (%d batches).', $streamSize, $streamBatches));
$pumps = $this->benzina->getPumpsFor($stream);
$pumpsCount = \count($pumps);
$pumpsSection = new SymfonyStyle($input, $output->section());
if ($pumpsCount < 1) {
$io->writeln('No pumps available for the data. Skipping execution.');
$pumpsSection->writeln('No pumps support the streamed sample. Skipping execution.');
return Command::SUCCESS;
}
$io->writeln(sprintf('Found %d pumps.', $pumpsCount));
$io->listing(\array_map(function ($pump) {
$pumpsSection->writeln(sprintf('Streaming to %d pumps.', $pumpsCount));
$pumpsSection->listing(\array_map(function (PumpInterface $pump) {
return $pump::class;
}, $pumps));
$io->writeln(sprintf('Processing %d records.', $streamSize));
$progress = $io->createProgressBar();
$progress->start($streamSize);
$streamedBatchesSection = $output->section();
$streamedBatchesSection->writeln("\tStreamed batches:");
$streamedBatches = new ProgressBar($streamedBatchesSection);
$streamedBatches->start($streamBatches);
$streamedRecordsSection = $output->section();
$streamedRecordsSection->writeln("\tStreamed records:");
$streamedRecords = new ProgressBar($streamedRecordsSection);
$streamedRecords->start($streamSize);
$memUsageSection = $output->section();
$memUsageSection->writeln("\tMemory usage:");
$memUsage = new ProgressBar($memUsageSection);
$memUsage->start($this->getMemoryLimit());
while (!$stream->eof()) {
$data = $stream->read();
\memory_reset_peak_usage();
foreach ($pumps as $pump) {
$pump->configure([
'progress' => !$input->getOption('no-progress'),
$batch = $stream->read();
$streamBatches = $streamBatches - 1;
$streamedBatches->advance();
foreach ($pumps as $key => $pump) {
$pump->setConfig([
'skip-pumped' => $input->getOption('skip-pumped'),
]);
$pump->process($data);
$pump->pump($batch);
$memUsage->setProgress(\memory_get_peak_usage(false));
}
$progress->setProgress($stream->tell());
$streamed = $stream->tell();
$streamedRecords->setProgress($streamed);
}
$stream->close();
$progress->finish();
$io->writeln("\n");
$io->success('Data processed successfully!');
$endSection = new SymfonyStyle($input, $output->section());
$endSection->success('Data processed successfully!');
return Command::SUCCESS;
}
private function getMemoryLimit(): int
{
$limit = \ini_get('memory_limit');
if (preg_match('/^(\d+)(.)$/', $limit, $matches)) {
if ($matches[2] == 'M') {
$limit = $matches[1] * 1024 * 1024; // nnnM -> nnn MB
} elseif ($matches[2] == 'K') {
$limit = $matches[1] * 1024; // nnnK -> nnn KB
}
}
return $limit;
}
}
-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;
}
}
}
+2 -4
View File
@@ -16,14 +16,12 @@ use Symfony\Component\Console\Style\SymfonyStyle;
class SystemVarsListCommand extends Command
{
public function __construct(
private SystemVariableRepository $systemVarRepository
private SystemVariableRepository $systemVarRepository,
) {
parent::__construct();
}
protected function configure(): void
{
}
protected function configure(): void {}
protected function execute(InputInterface $input, OutputInterface $output): int
{
+4 -5
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,13 +19,12 @@ class GatewaysController extends AbstractController
public function __construct(
private GatewayLocator $gatewayLocator,
) {
}
) {}
#[Route('/gateway_redirects', name: self::REDIRECT)]
public function handleRedirect(Request $request): Response
{
$gateway = $this->gatewayLocator->getGateway($request->query->get('gateway'));
$gateway = $this->gatewayLocator->get($request->query->get('gateway'));
return $gateway->handleRedirect($request);
}
@@ -33,7 +32,7 @@ class GatewaysController extends AbstractController
#[Route('/gateway_webhooks', name: self::WEBHOOKS)]
public function handleWebhook(Request $request): Response
{
$gateway = $this->gatewayLocator->getGateway($request->query->get('gateway'));
$gateway = $this->gatewayLocator->get($request->query->get('gateway'));
return $gateway->handleWebhook($request);
}
@@ -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),
];
}
@@ -14,9 +14,8 @@ use Symfony\Component\DependencyInjection\Exception\ParameterCircularReferenceEx
final class SystemVariablesLoader implements EnvVarLoaderInterface
{
public function __construct(
private SystemVariableRepository $systemVarRepository
) {
}
private SystemVariableRepository $systemVarRepository,
) {}
public function loadEnvVars(): array
{
-249
View File
@@ -1,249 +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\ApiResource]
#[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;
#[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;
#[API\ApiProperty(writable: false, readableLink: false)]
#[ORM\OneToMany(mappedBy: 'origin', targetEntity: AccountingTransaction::class)]
private Collection $transactionsIssued;
#[API\ApiProperty(writable: false, readableLink: false)]
#[ORM\OneToMany(mappedBy: 'target', targetEntity: AccountingTransaction::class)]
private Collection $transactionsReceived;
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;
}
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;
}
/**
* @return Collection<int, AccountingTransaction>
*/
#[API\ApiProperty(readableLink: false)]
public function getTransactions(): Collection
{
$transactions = [
...$this->getTransactionsIssued()->toArray(),
...$this->getTransactionsReceived()->toArray(),
];
\usort($transactions, function ($a, $b) {
return $a->getId() - $b->getId();
});
return new ArrayCollection($transactions);
}
/**
* @return Collection<int, AccountingTransaction>
*/
public function getTransactionsIssued(): Collection
{
return $this->transactionsIssued;
}
public function addTransactionsIssued(AccountingTransaction $transaction): static
{
if (!$this->transactionsIssued->contains($transaction)) {
$this->transactionsIssued->add($transaction);
$transaction->setOrigin($this);
}
return $this;
}
public function removeTransactionsIssued(AccountingTransaction $transaction): static
{
if ($this->transactionsIssued->removeElement($transaction)) {
// set the owning side to null (unless already changed)
if ($transaction->getOrigin() === $this) {
$transaction->setOrigin(null);
}
}
return $this;
}
/**
* @return Collection<int, AccountingTransaction>
*/
public function getTransactionsReceived(): Collection
{
return $this->transactionsReceived;
}
public function addTransactionsReceived(AccountingTransaction $transaction): static
{
if (!$this->transactionsReceived->contains($transaction)) {
$this->transactionsReceived->add($transaction);
$transaction->setTarget($this);
}
return $this;
}
public function removeTransactionsReceived(AccountingTransaction $transaction): static
{
if ($this->transactionsReceived->removeElement($transaction)) {
// set the owning side to null (unless already changed)
if ($transaction->getTarget() === $this) {
$transaction->setTarget(null);
}
}
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,20 +35,16 @@ class AccountingTransaction
private ?Money $money = null;
/**
* The Accounting issuing the money of this Transaction.
* The Accounting from which the Transaction comes from.
*/
#[Assert\NotBlank()]
#[API\ApiProperty(writable: false)]
#[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.
*/
#[Assert\NotBlank()]
#[API\ApiProperty(writable: false)]
#[ORM\ManyToOne(inversedBy: 'transactionsReceived', cascade: ['persist'])]
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: false)]
private ?Accounting $target = null;
@@ -75,8 +72,6 @@ class AccountingTransaction
public function setOrigin(?Accounting $origin): static
{
$origin->addTransactionsIssued($this);
$this->origin = $origin;
return $this;
@@ -89,8 +84,6 @@ class AccountingTransaction
public function setTarget(?Accounting $target): static
{
$target->addTransactionsReceived($this);
$this->target = $target;
return $this;
+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 $gatewayName = 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 getGatewayName(): ?string
{
return $this->gatewayName;
}
public function setGatewayName(string $gatewayName): static
{
$this->gatewayName = $gatewayName;
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;
}
}
-105
View File
@@ -1,105 +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.
*/
#[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, readableLink: 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;
}
}
-242
View File
@@ -1,242 +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\DBAL\Types\Types;
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)]
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)]
#[API\ApiProperty(readableLink: true, writableLink: true)]
#[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;
/**
* An external identifier provided by the Gateway for the payment.\
* Required when a GatewayCheckout is completed.
*/
#[API\ApiProperty(writable: false)]
#[API\ApiFilter(SearchFilter::class)]
#[ORM\Column(length: 255, nullable: true)]
private ?string $gatewayReference = null;
/**
* The URL where the checkout with the Gateway is available.
*/
#[API\ApiProperty(writable: false)]
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $checkoutUrl = null;
/**
* 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 $migratedReference = 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;
}
public function getGatewayReference(): ?string
{
return $this->gatewayReference;
}
public function setGatewayReference(string $gatewayReference): static
{
$this->gatewayReference = $gatewayReference;
return $this;
}
public function getCheckoutUrl(): ?string
{
return $this->checkoutUrl;
}
public function setCheckoutUrl(string $checkoutUrl): static
{
$this->checkoutUrl = $checkoutUrl;
return $this;
}
public function isMigrated(): ?bool
{
return $this->migrated;
}
public function setMigrated(bool $migrated): static
{
$this->migrated = $migrated;
return $this;
}
public function getMigratedReference(): ?string
{
return $this->migratedReference;
}
public function setMigratedReference(?string $migratedReference): static
{
$this->migratedReference = $migratedReference;
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;
}
+1 -1
View File
@@ -33,7 +33,7 @@ class Money
public function __construct(
int $amount,
string $currency
string $currency,
) {
$this->amount = $amount;
$this->currency = $currency;
+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 $migratedReference = 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 getMigratedReference(): ?string
{
return $this->migratedReference;
}
public function setMigratedReference(?string $migratedReference): static
{
$this->migratedReference = $migratedReference;
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.
+39 -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;
@@ -37,10 +40,12 @@ use Symfony\Component\Validator\Constraints as Assert;
#[UniqueEntity(fields: ['username'], message: 'This usernames already exists.')]
#[UniqueEntity(fields: ['email'], message: 'This email address is already registered.')]
#[ORM\Entity(repositoryClass: UserRepository::class)]
class User implements UserInterface, UserOwnedInterface, PasswordAuthenticatedUserInterface
#[ORM\Index(fields: ['migratedId'])]
class User implements UserInterface, UserOwnedInterface, PasswordAuthenticatedUserInterface, AccountingOwnerInterface
{
use TimestampableCreationEntity;
use TimestampableUpdationEntity;
use MigratedEntity;
use TimestampedCreationEntity;
use TimestampedUpdationEntity;
#[ORM\Id]
#[ORM\GeneratedValue]
@@ -94,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;
/**
@@ -124,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 $migratedReference = null;
/**
* The projects owned by this User.
*/
@@ -145,9 +135,18 @@ class User implements UserInterface, UserOwnedInterface, PasswordAuthenticatedUs
#[ORM\OneToMany(mappedBy: 'owner', targetEntity: Project::class)]
private Collection $projects;
#[ORM\OneToOne(mappedBy: 'user', cascade: ['persist', 'remove'])]
private ?UserPersonal $personalData = null;
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();
@@ -285,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;
@@ -358,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 getMigratedReference(): ?string
{
return $this->migratedReference;
}
public function setMigratedReference(?string $migratedReference): static
{
$this->migratedReference = $migratedReference;
return $this;
}
/**
* @return Collection<int, Project>
*/
@@ -411,4 +386,21 @@ class User implements UserInterface, UserOwnedInterface, PasswordAuthenticatedUs
return $this;
}
public function getPersonalData(): ?UserPersonal
{
return $this->personalData;
}
public function setPersonalData(UserPersonal $personalData): static
{
// set the owning side of the relation if necessary
if ($personalData->getUser() !== $this) {
$personalData->setUser($this);
}
$this->personalData = $personalData;
return $this;
}
}
+92
View File
@@ -0,0 +1,92 @@
<?php
namespace App\Entity;
use Ambta\DoctrineEncryptBundle\Configuration\Encrypted;
use ApiPlatform\Metadata\ApiResource;
use App\Repository\UserPersonalRepository;
use Doctrine\ORM\Mapping as ORM;
/**
* UserPersonal is the detailed data of a User.
*
* The personal data of a user will be kept encrypted
*/
#[ORM\Entity(repositoryClass: UserPersonalRepository::class)]
#[ApiResource]
class UserPersonal
{
#[ORM\Id]
#[ORM\OneToOne(inversedBy: 'personalData', cascade: ['persist', 'remove'])]
#[ORM\JoinColumn(nullable: false)]
private ?User $user = null;
/**
* The Identity Document number of the User.
*/
#[ORM\Column(length: 255, nullable: true)]
#[Encrypted]
private ?string $identityDocument = null;
/**
* The type of indentity document that the User has, ie: Passport, DNI, NIE, etc.
*/
#[ORM\Column(length: 255, nullable: true)]
#[Encrypted]
private ?string $identityDocumentType = null;
/**
* The Postal Code of where the User lives.
*/
#[ORM\Column(length: 255, nullable: true)]
#[Encrypted]
private ?string $postalCode = null;
public function getUser(): ?User
{
return $this->user;
}
public function setUser(User $user): static
{
$this->user = $user;
return $this;
}
public function getIdentityDocument(): ?string
{
return $this->identityDocument;
}
public function setIdentityDocument(?string $identityDocument): static
{
$this->identityDocument = $identityDocument;
return $this;
}
public function getIndetityDocumentType(): ?string
{
return $this->identityDocumentType;
}
public function setIndetityDocumentType(?string $identityDocumentType): static
{
$this->identityDocumentType = $identityDocumentType;
return $this;
}
public function getPostalCode(): ?string
{
return $this->postalCode;
}
public function setPostalCode(?string $postalCode): static
{
$this->postalCode = $postalCode;
return $this;
}
}
+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;
}
}
+177
View File
@@ -0,0 +1,177 @@
<?php
namespace App\Entity;
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.
*/
#[ORM\Entity(repositoryClass: WalletStatementRepository::class)]
class WalletStatement
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\OneToOne(cascade: ['persist'])]
#[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,43 @@
<?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\Event\PostPersistEventArgs;
use Doctrine\ORM\Events;
#[AsEntityListener(
event: Events::postPersist,
method: 'processTransaction',
entity: Transaction::class
)]
final class WalletTransactionsListener
{
public function __construct(
private WalletService $wallet,
) {}
/**
* Generates an income statement for User-received Transactions.
*/
public function processTransaction(
Transaction $transaction,
PostPersistEventArgs $event,
) {
if (!$transaction->getTarget()->getOwner() instanceof User) {
return;
}
$income = $this->wallet->save($transaction);
if ($income->getId() !== null) {
return;
}
$event->getObjectManager()->persist($income);
$event->getObjectManager()->flush();
}
}
+2 -2
View File
@@ -17,7 +17,7 @@ final class OrderedLikeFilter extends AbstractFilter
protected ManagerRegistry $managerRegistry,
?LoggerInterface $logger = null,
protected ?array $properties = null,
protected ?NameConverterInterface $nameConverter = null
protected ?NameConverterInterface $nameConverter = null,
) {
parent::__construct($managerRegistry, $logger, $properties, $nameConverter);
}
@@ -29,7 +29,7 @@ final class OrderedLikeFilter extends AbstractFilter
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
?Operation $operation = null,
array $context = []
array $context = [],
): void {
if (
!$this->isPropertyEnabled($property, $resourceClass)
+1 -1
View File
@@ -31,7 +31,7 @@ final class ResourceVersionResourceFilter extends AbstractFilter
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
?Operation $operation = null,
array $context = []
array $context = [],
): void {
return;
}
@@ -17,7 +17,7 @@ final class ResourceVersionResourceIdFilter extends AbstractFilter
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
?Operation $operation = null,
array $context = []
array $context = [],
): void {
return;
}
+2 -2
View File
@@ -17,7 +17,7 @@ final class UserQueryFilter extends AbstractFilter
protected ManagerRegistry $managerRegistry,
?LoggerInterface $logger = null,
protected ?array $properties = null,
protected ?NameConverterInterface $nameConverter = null
protected ?NameConverterInterface $nameConverter = null,
) {
parent::__construct($managerRegistry, $logger, $properties, $nameConverter);
}
@@ -29,7 +29,7 @@ final class UserQueryFilter extends AbstractFilter
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
?Operation $operation = null,
array $context = []
array $context = [],
): void {
if (
!$this->isPropertyEnabled($property, $resourceClass)
@@ -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';
@@ -0,0 +1,21 @@
<?php
namespace App\Gateway\Exception;
class DuplicateGatewayException extends \Exception
{
public const MESSAGE_DUPLICATE = 'Duplicate Gateway name \'%s\' from class %s, name is already in use by class %s';
public function __construct(
string $duplicateName,
string $duplicateClass,
string $existingClass,
) {
parent::__construct(sprintf(
self::MESSAGE_DUPLICATE,
$duplicateName,
$duplicateClass,
$existingClass
));
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Gateway\Exception;
class MissingGatewayException extends \Exception
{
public const MESSAGE_NOT_FOUND = 'Could not match \'%s\' to the name of any available Gateway';
public function __construct(string $missingName)
{
parent::__construct(\sprintf(self::MESSAGE_NOT_FOUND, $missingName));
}
}
@@ -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 sendData(GatewayCheckout $checkout): GatewayCheckout;
public function process(Checkout $checkout): Checkout;
/**
* When a user is redirected by the gateway we must handle the redirection
+84
View File
@@ -0,0 +1,84 @@
<?php
namespace App\Gateway;
use App\Entity\Gateway\Checkout;
use App\Gateway\Exception\DuplicateGatewayException;
use App\Gateway\Exception\MissingGatewayException;
class GatewayLocator
{
/** @var GatewayInterface[] */
private array $gatewaysByName = [];
/** @var GatewayInterface[] */
private array $gatewaysByClass = [];
public function __construct(iterable $instanceof)
{
foreach (\iterator_to_array($instanceof) as $key => $gateway) {
$this->gatewaysByClass[$gateway::class] = $gateway;
}
self::validateGatewayNames($this->gatewaysByClass);
foreach ($this->gatewaysByClass as $class => $gateway) {
$this->gatewaysByName[$gateway::getName()] = $gateway;
}
}
/**
* @return <string, GatewayInterface>
*/
public function getAll(): array
{
return $this->gatewaysByName;
}
/**
* @param string $name Name of the Gateway interface implementation
*
* @throws \Exception When the `$name` does not match to that of an implemented Gateway
*/
public function get(string $name): GatewayInterface
{
if (!\array_key_exists($name, $this->gatewaysByName)) {
throw new MissingGatewayException($name);
}
return $this->gatewaysByName[$name];
}
/**
* @throws \Exception When the $checkout::gateway does not match to that of an implemented Gateway
*/
public function getForCheckout(Checkout $checkout): GatewayInterface
{
return $this->get($checkout->getGatewayName());
}
/**
* 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)) {
throw new DuplicateGatewayException(
$gatewayName,
$gatewayClass::class,
$gatewaysValidated[$gatewayName]::class
);
}
$gatewaysValidated[$gatewayName] = $gatewayClass;
}
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Gateway;
class Link
{
/**
* The complete target URL.
*/
public string $href;
/**
* The link relation type, which serves as an ID for a link that unambiguously describes the semantics of the link.
*
* @see https://www.iana.org/assignments/link-relations/link-relations.xhtml
*/
public string $rel;
/**
* The HTTP method required to make the related call.
*/
public string $method;
/**
* The type of the link indicates who is the intended user of a link.\
* `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 LinkType $type;
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace App\Gateway;
enum LinkType: string
{
/**
* A 'debug' type link indicates a link that is helpful to developers and platform maintainers to get info about the checkout.
*/
case Debug = 'debug';
/**
* A 'payment' type link indicates a link where the checkout is available to an end user for payment.
*/
case Payment = 'payment';
}
+248
View File
@@ -0,0 +1,248 @@
<?php
namespace App\Gateway\Paypal;
use ApiPlatform\Api\IriConverterInterface;
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;
/**
* @see https://developer.paypal.com/studio/checkout/standard/integrate
*/
class PaypalGateway implements GatewayInterface
{
public const PAYPAL_API_ADDRESS_LIVE = 'https://api-m.paypal.com';
public const PAYPAL_API_ADDRESS_SANDBOX = 'https://api-m.sandbox.paypal.com';
/** @see https://developer.paypal.com/docs/api/orders/v2/#orders_get!c=200&path=status&t=response */
public const PAYPAL_STATUS_APPROVED = 'APPROVED';
public const PAYPAL_STATUS_COMPLETED = 'COMPLETED';
/** @see https://developer.paypal.com/api/rest/webhooks/event-names/#orders */
public const PAYPAL_EVENT_ORDER_COMPLETED = 'CHECKOUT.ORDER.COMPLETED';
/**
* @see https://developer.paypal.com/docs/api/orders/v2/
*/
private const PAYPAL_ORDER_INTENT = 'CAPTURE';
public const TRACKING_TITLE_ORDER = 'PayPal Order ID';
public const TRACKING_TITLE_TRANSACTION = 'PayPal Transaction ID';
public function __construct(
private PaypalService $paypal,
private EntityManagerInterface $entityManager,
private IriConverterInterface $iriConverter,
private CheckoutService $checkoutService,
private CheckoutRepository $checkoutRepository,
) {}
public static function getName(): string
{
return 'paypal';
}
public static function getSupportedChargeTypes(): array
{
return [
ChargeType::Single,
];
}
public function process(Checkout $checkout): Checkout
{
$order = $this->paypal->postOrder([
'intent' => self::PAYPAL_ORDER_INTENT,
'purchase_units' => $this->getPaypalPurchaseUnits($checkout),
'payment_source' => $this->getPaypalPaymentSource($checkout),
]);
$tracking = new Tracking();
$tracking->title = self::TRACKING_TITLE_ORDER;
$tracking->value = $order['id'];
$checkout->addTracking($tracking);
foreach ($order['links'] as $linkData) {
$linkType = \in_array($linkData['rel'], ['approve', 'payer-action'])
? LinkType::Payment
: LinkType::Debug;
$link = new Link();
$link->href = $linkData['href'];
$link->rel = $linkData['rel'];
$link->method = $linkData['method'];
$link->type = $linkType;
$checkout->addLink($link);
}
return $checkout;
}
public function handleRedirect(Request $request): RedirectResponse
{
if ($request->query->get('type') !== CheckoutService::RESPONSE_TYPE_SUCCESS) {
throw new \Exception(sprintf('Checkout was not completed successfully.'));
}
$checkoutId = $request->query->get('checkoutId');
$checkout = $this->checkoutRepository->find($checkoutId);
if ($checkout === null) {
throw new \Exception(sprintf("Checkout '%s' could not be found.", $checkoutId));
}
if ($checkout->getStatus() === CheckoutStatus::Charged) {
return new RedirectResponse($this->iriConverter->getIriFromResource($checkout));
}
$orderId = $request->query->get('token');
if (!$orderId) {
throw new \Exception(sprintf('PayPal checkout order ID not provided by the gateway.'));
}
$order = $this->paypal->getOrder($orderId);
if ($order['status'] !== self::PAYPAL_STATUS_APPROVED) {
throw new \Exception(sprintf("PayPal checkout '%s' has not yet been processed successfully by the gateway.", $orderId));
}
$capture = $this->paypal->captureOrderPayment($order);
if ($capture['status'] !== self::PAYPAL_STATUS_COMPLETED) {
throw new \Exception(sprintf("Payment capture for PayPal checkout '%s' was not completed.", $orderId));
}
foreach ($capture['purchase_units'] as $purchaseUnit) {
$tracking = new Tracking();
$tracking->title = self::TRACKING_TITLE_TRANSACTION;
$tracking->value = $purchaseUnit['payments']['captures'][0]['id'];
$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));
}
public function handleWebhook(Request $request): Response
{
try {
$event = $this->paypal->verifyWebhook($request);
} catch (\Exception $e) {
return new JsonResponse([
'status' => 'ERROR',
'message' => $e->getMessage(),
'requestBody' => $request->getContent(),
'requestHeaders' => $request->headers->all(),
], Response::HTTP_ACCEPTED);
}
switch ($event['event_type']) {
case self::PAYPAL_EVENT_ORDER_COMPLETED:
return $this->handleOrderCompleted($event);
default:
return new Response('Event not supported', Response::HTTP_ACCEPTED);
}
return new Response();
}
private function handleOrderCompleted(array $event)
{
$orderId = $event['resource']['id'];
$checkout = $this->checkoutRepository->findOneByTracking(self::TRACKING_TITLE_ORDER, $orderId);
if ($checkout === null) {
throw new \Exception(sprintf("Could not find any Checkout by the Tracking '%s'", $orderId), 1);
}
foreach ($event['resource']['purchase_units'] as $purchaseUnit) {
$tracking = new Tracking();
$tracking->title = self::TRACKING_TITLE_TRANSACTION;
$tracking->value = $purchaseUnit['payments']['captures'][0]['id'];
$checkout->addTracking($tracking);
}
$checkout = $this->checkoutService->chargeCheckout($checkout);
}
private function getPaypalMoney(Charge $charge): array
{
$brick = BrickMoney::ofMinor(
$charge->getMoney()->amount,
$charge->getMoney()->currency
);
return [
'value' => $brick->getAmount()->__toString(),
'currency_code' => $brick->getCurrency()->getCurrencyCode(),
];
}
private function getPaypalPurchaseUnits(Checkout $checkout): array
{
$units = [];
foreach ($checkout->getCharges() as $charge) {
$money = $this->getPaypalMoney($charge);
$reference = $this->checkoutService->generateTracking($checkout, $charge);
$units[] = [
'reference_id' => $reference,
'custom_id' => $reference,
'items' => [
[
'name' => $charge->getTitle(),
'description' => $charge->getDescription(),
'quantity' => '1',
'unit_amount' => [
...$money,
],
],
],
'amount' => [
...$money,
'breakdown' => [
'item_total' => [
...$money,
],
],
],
];
}
return $units;
}
private function getPaypalPaymentSource(Checkout $checkout): array
{
return [
'paypal' => [
'experience_context' => [
'return_url' => $this->checkoutService->generateRedirectUrl($checkout),
],
],
];
}
}
+178
View File
@@ -0,0 +1,178 @@
<?php
namespace App\Gateway\Paypal;
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
use Symfony\Component\HttpClient\HttpOptions;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class PaypalService
{
private CacheInterface $cache;
public function __construct(
private string $paypalApiAddress,
private string $paypalClientId,
private string $paypalClientSecret,
private string $paypalWebhookId,
private HttpClientInterface $httpClient,
) {
$this->cache = new FilesystemAdapter(\preg_replace(
'/[^-+_\.A-Za-z0-9]/',
'-',
$this->paypalApiAddress
));
$httpOptions = new HttpOptions();
$httpOptions->setBaseUri($paypalApiAddress);
$this->setHttpClient($httpClient->withOptions($httpOptions->toArray()));
}
public function setHttpClient(HttpClientInterface $httpClient): static
{
$this->httpClient = $httpClient;
return $this;
}
/**
* Calls the PayPal API to generate an OAuth2 token.
*
* @return array The API response body
*/
public function generateAuthToken(): array
{
$response = $this->httpClient->request(Request::METHOD_POST, '/v1/oauth2/token', [
'auth_basic' => [$this->paypalClientId, $this->paypalClientSecret],
'body' => 'grant_type=client_credentials',
]);
return $response->toArray();
}
/**
* Retrieves the PayPal OAuth2 token data from cache (if available) or generates a new one.
*
* @return array The OAuth2 token data
*/
public function getAuthToken(): array
{
return $this->cache->get(urlencode('/v1/oauth2/token'), function (ItemInterface $item): array {
$tokenData = $this->generateAuthToken();
$item->expiresAfter($tokenData['expires_in']);
return $tokenData;
});
}
/**
* Creates a PayPal Order resource with the given data.
*
* @see https://developer.paypal.com/docs/api/orders/v2/#orders_create
*/
public function postOrder(array $order): array
{
$response = $this->httpClient->request(Request::METHOD_POST, '/v2/checkout/orders', [
'auth_bearer' => $this->getAuthToken()['access_token'],
'json' => $order,
]);
$content = \json_decode($response->getContent(), true);
if (!\in_array($response->getStatusCode(), [Response::HTTP_OK, Response::HTTP_CREATED])) {
throw new \Exception($content['message']);
}
return $content;
}
/**
* Retrieve a PayPal Order resource.
*
* @see https://developer.paypal.com/docs/api/orders/v2/#orders_get
*/
public function getOrder(string $orderId): array
{
$request = $this->httpClient->request(
Request::METHOD_GET,
sprintf('/v2/checkout/orders/%s', $orderId),
[
'auth_bearer' => $this->getAuthToken()['access_token'],
]
);
if ($request->getStatusCode() !== Response::HTTP_OK) {
throw new \Exception(sprintf("PayPal checkout '%s' could not be requested.", $orderId));
}
return \json_decode($request->getContent(), true);
}
/**
* Process post user-approval payment capture for a given Order.
*
* @see https://developer.paypal.com/docs/api/orders/v2/#orders_capture
*/
public function captureOrderPayment(array $order): array
{
$link = \array_filter($order['links'], function ($order) {
return $order['rel'] === 'capture';
});
if (empty($link)) {
throw new \Exception(sprintf("PayPal checkout '%s' was not ready for capture.", $order['id']));
}
$link = \array_pop($link);
$request = $this->httpClient->request(
$link['method'],
$link['href'],
[
'auth_bearer' => $this->getAuthToken()['access_token'],
'headers' => ['Content-Type' => 'application/json'],
]
);
if ($request->getStatusCode() !== Response::HTTP_CREATED) {
throw new \Exception(sprintf("Payment capture for PayPal checkout '%s' was unsuccessful.", $order['id']));
}
return \json_decode($request->getContent(), true);
}
/**
* Verifies a PayPal webhook request.
*
* @param Request $request The webhook request
*
* @return array The webhook event data
*
* @throws \Exception If the verification fails
*/
public function verifyWebhook(Request $request): array
{
$headers = $request->headers;
$isSignatureValid = \openssl_verify(
implode('|', [
$headers->get('paypal-transmission-id'),
$headers->get('paypal-transmission-type'),
$this->paypalWebhookId,
\crc32($request->getContent()),
]),
\base64_decode($headers->get('paypal-transmission-sig')),
\openssl_pkey_get_public(\file_get_contents($headers->get('paypal-cert-url')))
) === 1;
if (!$isSignatureValid) {
throw new \Exception('Could not verify PayPal webhook signature.');
}
return \json_decode($request->getContent(), true);
}
}
@@ -1,14 +1,18 @@
<?php
namespace App\Library\Economy\Payment;
namespace App\Gateway\Stripe;
use ApiPlatform\Api\IriConverterInterface;
use App\Controller\GatewaysController;
use App\Entity\AccountingTransaction;
use App\Entity\GatewayChargeType;
use App\Entity\GatewayCheckout;
use App\Entity\GatewayCheckoutStatus;
use App\Repository\GatewayCheckoutRepository;
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;
@@ -17,21 +21,20 @@ 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
{
private const RESPONSE_TYPE_SUCCESS = 'success';
public const TRACKING_TITLE_CHECKOUT = 'Stripe Checkout Session ID';
private StripeClient $stripe;
public function __construct(
private string $stripeApiKey,
private string $stripeWebhookSecret,
private RouterInterface $router,
private GatewayCheckoutRepository $gatewayCheckoutRepository,
private CheckoutService $checkoutService,
private CheckoutRepository $checkoutRepository,
private EntityManagerInterface $entityManager,
private IriConverterInterface $iriConverter
private IriConverterInterface $iriConverter,
) {
$this->stripe = new StripeClient($stripeApiKey);
}
@@ -41,52 +44,41 @@ class StripeGateway implements GatewayInterface
return 'stripe';
}
public function sendData(GatewayCheckout $checkout): GatewayCheckout
public static function getSupportedChargeTypes(): array
{
$successUrl = $this->router->generate(
GatewaysController::REDIRECT,
[
'type' => self::RESPONSE_TYPE_SUCCESS,
'gateway' => $this->getName(),
],
RouterInterface::ABSOLUTE_URL
);
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}
// is not properly sent to Stripe and the redirection fails,
// that's why we add the session_id template variable like this.
// https://docs.stripe.com/payments/checkout/custom-success-page?lang=php#modify-the-success-url
'success_url' => sprintf('%s&session_id={CHECKOUT_SESSION_ID}', $successUrl),
'success_url' => sprintf('%s&session_id={CHECKOUT_SESSION_ID}', $this->checkoutService->generateRedirectUrl($checkout)),
]);
$checkout->setCheckoutUrl($session->url);
$checkout->setGatewayReference($session->id);
$link = new Link();
return $checkout;
}
$link->href = $session->url;
$link->rel = 'approve';
$link->method = Request::METHOD_GET;
$link->type = LinkType::Payment;
private function handleSuccess(GatewayCheckout $checkout): GatewayCheckout
{
$checkout->setStatus(GatewayCheckoutStatus::Charged);
$checkout->addLink($link);
foreach ($checkout->getCharges() as $charge) {
$transaction = new AccountingTransaction();
$transaction->setMoney($charge->getMoney());
$transaction->setOrigin($checkout->getOrigin());
$transaction->setTarget($charge->getTarget());
$tracking = new Tracking();
$tracking->title = self::TRACKING_TITLE_CHECKOUT;
$tracking->value = $session->id;
$this->entityManager->persist($transaction);
$charge->setTransaction($transaction);
$this->entityManager->persist($charge);
}
$this->entityManager->flush();
$checkout->addTracking($tracking);
return $checkout;
}
@@ -96,15 +88,17 @@ class StripeGateway implements GatewayInterface
$sessionId = $request->query->get('session_id');
$session = $this->stripe->checkout->sessions->retrieve($sessionId);
$checkout = $this->gatewayCheckoutRepository->findOneBy(
['gatewayReference' => $sessionId]
);
$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 ($request->query->get('type') !== self::RESPONSE_TYPE_SUCCESS) {
if ($checkout->getStatus() === CheckoutStatus::Charged) {
return $checkout;
}
if ($request->query->get('type') !== CheckoutService::RESPONSE_TYPE_SUCCESS) {
return $checkout;
}
@@ -112,7 +106,10 @@ class StripeGateway implements GatewayInterface
return $checkout;
}
$checkout = $this->handleSuccess($checkout);
$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));
@@ -135,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;
}
}
@@ -146,7 +153,7 @@ class StripeGateway implements GatewayInterface
return StripeSession::MODE_PAYMENT;
}
private function getStripeLineItems(GatewayCheckout $checkout): array
private function getStripeLineItems(Checkout $checkout): array
{
$items = [];
@@ -154,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'];
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace App\Gateway;
use Symfony\Component\Validator\Constraints as Assert;
class Tracking
{
/**
* A descriptive title for the tracking number.
*/
#[Assert\NotBlank()]
public string $title;
/**
* The tracking number given by the Gateway.
*/
#[Assert\NotBlank()]
public string $value;
}
+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';
}
+86
View File
@@ -0,0 +1,86 @@
<?php
namespace App\Gateway\Wallet;
use App\Entity\Accounting\Transaction;
use App\Entity\Gateway\Checkout;
use App\Entity\Money;
use App\Gateway\ChargeType;
use App\Gateway\GatewayInterface;
use App\Library\Economy\MoneyService;
use App\Service\Gateway\CheckoutService;
use Doctrine\ORM\EntityManagerInterface;
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,
private CheckoutService $checkoutService,
private EntityManagerInterface $entityManager,
) {}
public function process(Checkout $checkout): Checkout
{
$origin = $checkout->getOrigin();
$available = $this->wallet->getBalance($origin);
if ($this->money->isLess($available, $this->getChargeTotal($checkout))) {
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());
$expenditure = $this->wallet->spend($transaction);
$this->entityManager->persist($expenditure);
$this->entityManager->flush();
}
$checkout = $this->checkoutService->chargeCheckout($checkout);
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;
}
}
+126
View File
@@ -0,0 +1,126 @@
<?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 $statementRepository,
private EntityManagerInterface $entityManager,
) {}
/**
* @return \App\Entity\WalletStatement[]
*/
public function getStatements(Accounting $accounting): array
{
return $this->statementRepository->findByAccounting($accounting);
}
public function getBalance(Accounting $accounting): Money
{
$total = Brick\Money::ofMinor(0, $accounting->getCurrency());
$statements = $this->statementRepository->findByTarget($accounting);
foreach ($statements as $statement) {
$balance = $this->money->toBrick($statement->getBalance());
$total = $total->plus($balance);
}
return $this->money->toMoney($total);
}
/**
* Obtain an `incoming` WalletStatement for the Transaction.
*
* @param Transaction $transaction The Transaction targetting a wallet
*
* @return WalletStatement An incoming statement for the transaction
*/
public function save(Transaction $transaction): WalletStatement
{
$statement = $this->statementRepository->findByTransaction($transaction);
if ($statement) {
return $statement;
}
$statement = new WalletStatement();
$statement->setTransaction($transaction);
$statement->setDirection(StatementDirection::Incoming);
$statement->setBalance($transaction->getMoney());
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);
$spendGoal = $this->money->substract($balanceSpent, $spendGoal);
$spentTotal = $this->money->add($balanceSpent, $spentTotal);
}
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
+2 -2
View File
@@ -11,7 +11,7 @@ class Benzina
private array $availablePumps = [];
public function __construct(
iterable $instanceof
iterable $instanceof,
) {
$this->availablePumps = \iterator_to_array($instanceof);
}
@@ -23,7 +23,7 @@ class Benzina
*
* @return PumpInterface[]
*/
public function getPumps(StreamInterface $stream, int $sampleSize = 1): array
public function getPumpsFor(StreamInterface $stream, int $sampleSize = 1): array
{
$sample = $stream->read($sampleSize);
$stream->rewind();
+13 -2
View File
@@ -4,10 +4,21 @@ namespace App\Library\Benzina\Pump;
abstract class AbstractPump implements PumpInterface
{
protected array $config;
protected array $config = [];
public function configure(array $config = []): void
public function setConfig(array $config = []): void
{
$this->config = $config;
}
public function getConfig(?string $key = null, mixed $default = null): mixed
{
if ($key !== null) {
return \array_key_exists($key, $this->config)
? $this->config[$key]
: $default;
}
return $this->config;
}
}
+116 -91
View File
@@ -2,34 +2,42 @@
namespace App\Library\Benzina\Pump;
use App\Entity\Accounting;
use App\Entity\AccountingTransaction;
use App\Entity\GatewayCharge;
use App\Entity\GatewayChargeType;
use App\Entity\GatewayCheckout;
use App\Entity\GatewayCheckoutStatus;
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\ProgressivePumpTrait;
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\Library\Benzina\Pump\Trait\DoctrinePumpTrait;
use App\Repository\ProjectRepository;
use App\Repository\TipjarRepository;
use App\Repository\UserRepository;
use App\Service\Gateway\CheckoutService;
use Doctrine\ORM\EntityManagerInterface;
class CheckoutsPump extends AbstractPump implements PumpInterface
{
use ArrayPumpTrait;
use ProgressivePumpTrait;
use DoctrinePumpTrait;
public const TRACKING_TITLE_V3 = 'v3 Invest ID';
public const TRACKING_TITLE_PAYMENT = 'v3 Invest Payment';
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 CHECKOUT_URL_DEFAULT = 'https://www.goteo.org/invest';
private const MAX_INT = 2147483647;
@@ -69,34 +77,30 @@ class CheckoutsPump extends AbstractPump implements PumpInterface
private UserRepository $userRepository,
private ProjectRepository $projectRepository,
private TipjarRepository $tipjarRepository,
private EntityManagerInterface $entityManager
) {
}
private EntityManagerInterface $entityManager,
private CheckoutService $checkoutService,
) {}
public function supports(mixed $data): bool
public function supports(mixed $batch): bool
{
if (!\is_array($data) || !\array_key_exists(0, $data)) {
if (!\is_array($batch) || !\array_key_exists(0, $batch)) {
return false;
}
return $this->hasAllKeys($data[0], self::INVEST_KEYS);
return $this->hasAllKeys($batch[0], self::INVEST_KEYS);
}
public function process(mixed $data): void
public function pump(mixed $batch): void
{
$users = $this->getUsers($data);
$projects = $this->getProjects($data);
$batch = $this->skipPumped($batch, 'id', Checkout::class, 'migratedId');
$tipjar = $this->getPlatformTipjar();
$pumped = $this->getPumped(GatewayCheckout::class, $data, ['migratedReference' => 'id']);
$users = $this->getPumpedUsers($batch);
$projects = $this->getPumpedProjects($batch);
foreach ($data as $key => $record) {
if ($this->isPumped($record, $pumped, ['migratedReference' => 'id'])) {
continue;
}
if (!\array_key_exists($record['project'], $projects)) {
foreach ($batch as $key => $record) {
if (!$record['user'] || empty($record['user'])) {
continue;
}
@@ -109,54 +113,52 @@ class CheckoutsPump extends AbstractPump implements PumpInterface
}
$user = $users[$record['user']];
$project = $projects[$record['project']];
$checkout = new GatewayCheckout();
$checkout = new Checkout();
$checkout->setOrigin($user->getAccounting());
$checkout->setStatus($this->getCheckoutStatus($record));
$checkout->setGateway($this->getCheckoutGateway($record));
$checkout->setGatewayReference($this->getCheckoutReference($record));
$checkout->setCheckoutUrl(self::CHECKOUT_URL_DEFAULT);
$checkout->setGatewayName($this->getCheckoutGateway($record));
foreach ($this->getCheckoutTrackings($record) as $tracking) {
$checkout->addTracking($tracking);
}
$checkout->setMigrated(true);
$checkout->setMigratedReference($record['id']);
$checkout->setMetadata([
'payment' => $record['payment'],
'transaction' => $record['transaction'],
'preapproval' => $record['preapproval'],
]);
$checkout->setMigratedId($record['id']);
$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']));
$charge->setTarget($project->getAccounting());
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());
$this->entityManager->persist($tip);
$checkout->addCharge($tip);
}
$this->entityManager->persist($charge);
$checkout->addCharge($charge);
if ($checkout->getStatus() === GatewayCheckoutStatus::Charged) {
foreach ($checkout->getCharges() as $charge) {
$transaction = new AccountingTransaction();
$transaction->setMoney($charge->getMoney());
$transaction->setOrigin($checkout->getOrigin());
$transaction->setTarget($charge->getTarget());
$charge->setTransaction($transaction);
$this->entityManager->persist($transaction);
$this->entityManager->persist($charge);
}
if ($checkout->getStatus() === CheckoutStatus::Charged) {
$checkout = $this->checkoutService->chargeCheckout($checkout);
}
$this->entityManager->persist($checkout);
@@ -166,32 +168,38 @@ class CheckoutsPump extends AbstractPump implements PumpInterface
$this->entityManager->clear();
}
private function getUsers(array $data): array
/**
* @return array<string, \App\Entity\User>
*/
private function getPumpedUsers(array $batch): array
{
$users = $this->userRepository->findBy(['migratedReference' => \array_map(function ($data) {
return $data['user'];
}, $data)]);
$users = $this->userRepository->findBy(['migratedId' => \array_map(function ($batch) {
return $batch['user'];
}, $batch)]);
$usersByMigratedReference = [];
$usersByMigratedId = [];
foreach ($users as $user) {
$usersByMigratedReference[$user->getMigratedReference()] = $user;
$usersByMigratedId[$user->getMigratedId()] = $user;
}
return $usersByMigratedReference;
return $usersByMigratedId;
}
private function getProjects(array $data): array
/**
* @return array<string, \App\Entity\Project>
*/
private function getPumpedProjects(array $batch): array
{
$projects = $this->projectRepository->findBy(['migratedReference' => \array_map(function ($data) {
return $data['project'];
}, $data)]);
$projects = $this->projectRepository->findBy(['migratedId' => \array_map(function ($batch) {
return $batch['project'];
}, $batch)]);
$projectsByMigratedReference = [];
$projectsByMigratedId = [];
foreach ($projects as $project) {
$projectsByMigratedReference[$project->getMigratedReference()] = $project;
$projectsByMigratedId[$project->getMigratedId()] = $project;
}
return $projectsByMigratedReference;
return $projectsByMigratedId;
}
private function getPlatformTipjar(): Tipjar
@@ -205,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
@@ -235,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
@@ -264,20 +268,41 @@ class CheckoutsPump extends AbstractPump implements PumpInterface
}
}
private function getCheckoutReference(array $record): string
/**
* @return Tracking[]
*/
private function getCheckoutTrackings(array $record): array
{
$v3Tracking = new Tracking();
$v3Tracking->title = self::TRACKING_TITLE_V3;
$v3Tracking->value = $record['id'];
$trackings = [$v3Tracking];
if (!empty($record['payment'])) {
return $record['payment'];
$payment = new Tracking();
$payment->title = self::TRACKING_TITLE_PAYMENT;
$payment->value = $record['payment'];
$trackings[] = $payment;
}
if (!empty($record['transaction'])) {
return $record['transaction'];
$transaction = new Tracking();
$transaction->title = self::TRACKING_TITLE_TRANSACTION;
$transaction->value = $record['transaction'];
$trackings[] = $transaction;
}
if (!empty($record['preapproval'])) {
return $record['preapproval'];
$preapproval = new Tracking();
$preapproval->title = self::TRACKING_TITLE_PREAPPROVAL;
$preapproval->value = $record['preapproval'];
$trackings[] = $preapproval;
}
return '';
return $trackings;
}
}
+19 -28
View File
@@ -2,19 +2,19 @@
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;
use App\Library\Benzina\Pump\Trait\ArrayPumpTrait;
use App\Library\Benzina\Pump\Trait\ProgressivePumpTrait;
use App\Library\Benzina\Pump\Trait\DoctrinePumpTrait;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
class ProjectsPump extends AbstractPump implements PumpInterface
{
use ArrayPumpTrait;
use ProgressivePumpTrait;
use DoctrinePumpTrait;
private const PROJECT_KEYS = [
'id',
@@ -96,31 +96,25 @@ class ProjectsPump extends AbstractPump implements PumpInterface
public function __construct(
private UserRepository $userRepository,
private EntityManagerInterface $entityManager
) {
}
private EntityManagerInterface $entityManager,
) {}
public function supports(mixed $data): bool
public function supports(mixed $batch): bool
{
if (!\is_array($data) || !\array_key_exists(0, $data)) {
if (!\is_array($batch) || !\array_key_exists(0, $batch)) {
return false;
}
return $this->hasAllKeys($data[0], self::PROJECT_KEYS);
return $this->hasAllKeys($batch[0], self::PROJECT_KEYS);
}
public function process(mixed $data): void
public function pump(mixed $batch): void
{
$pumped = $this->getPumped(Project::class, $data, ['migratedReference' => 'id']);
$owners = $this->getOwners($data);
$batch = $this->skipPumped($batch, 'id', Project::class, 'migratedId');
foreach ($data as $key => $record) {
$isPumped = $this->isPumped($record, $pumped, ['migratedReference' => 'id']);
if ($isPumped) {
continue;
}
$owners = $this->getOwners($batch);
foreach ($batch as $key => $record) {
if (!\array_key_exists($record['owner'], $owners)) {
continue;
}
@@ -134,14 +128,11 @@ class ProjectsPump extends AbstractPump implements PumpInterface
$project->setOwner($owners[$record['owner']]);
$project->setStatus($this->getProjectStatus($record['status']));
$project->setMigrated(true);
$project->setMigratedReference($record['id']);
$project->setMigratedId($record['id']);
$project->setDateCreated(new \DateTime($record['created']));
$accounting = $this->getAccounting($record);
$accounting->setProject($project);
$project->setDateUpdated(new \DateTime());
$this->entityManager->persist($project);
$this->entityManager->persist($accounting);
}
$this->entityManager->flush();
@@ -151,15 +142,15 @@ class ProjectsPump extends AbstractPump implements PumpInterface
/**
* @return User[]
*/
private function getOwners(array $data): array
private function getOwners(array $record): array
{
$users = $this->userRepository->findBy(['migratedReference' => \array_map(function ($data) {
return $data['owner'];
}, $data)]);
$users = $this->userRepository->findBy(['migratedId' => \array_map(function ($record) {
return $record['owner'];
}, $record)]);
$owners = [];
foreach ($users as $user) {
$owners[$user->getMigratedReference()] = $user;
$owners[$user->getMigratedId()] = $user;
}
return $owners;
+22 -12
View File
@@ -4,24 +4,34 @@ namespace App\Library\Benzina\Pump;
interface PumpInterface
{
/**
* Determines if the data is supported by this pump.
*
* @param mixed $data The streamed records, e.g. rows from an user table
*/
public function supports(mixed $data): bool;
/**
* Sets flexible configuration values for this pump.
*
* @param array $config the configuration array
* @param array $config The configuration array
*/
public function configure(array $config = []): void;
public function setConfig(array $config = []): void;
/**
* Process the data to be pumped.
* Read the configuration values for this pump.
*
* @param mixed $data The streamed records, e.g. rows from an user table
* @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 value at the specified key, or full keys and values if null
*/
public function process(mixed $data): void;
public function getConfig(?string $key = null, mixed $default = null): mixed;
/**
* Determines if a data batch is supported by this pump.
*
* @param mixed $batch A sample of the streamed records, e.g. rows from an user table
*/
public function supports(mixed $batch): bool;
/**
* Pump a data batch into a final destination.
*
* @param mixed $batch The streamed records, e.g. rows from an user table
*/
public function pump(mixed $batch): void;
}
@@ -0,0 +1,55 @@
<?php
namespace App\Library\Benzina\Pump\Trait;
use App\Library\Benzina\Pump\PumpInterface;
use Doctrine\ORM\EntityManagerInterface;
trait DoctrinePumpTrait
{
public function __construct(
private EntityManagerInterface $entityManager,
) {
if (!$this instanceof PumpInterface) {
throw new \Exception('DoctrinePumpTrait can only be used with PumpInterface classes');
}
}
/**
* Skip already pumped records from a pumping stream batch.
*
* @param array $batch The batch of records being pumped
* @param array $batchKey The key in the batch records to match against the entity key
* @param string $entityClass The class where the data is being pumped to
* @param string $entityKey The property of the pumped to class to match against the batch key
*
* @return array The batch of records being pumped, sliced out of already pumped records
*/
public function skipPumped(
array $batch,
string $batchKey,
string $entityClass,
string $entityKey,
): array {
if (!$this->getConfig('skip-pumped')) {
return $batch;
}
$pumped = $this->entityManager
->getRepository($entityClass)
->findBy([$entityKey => \array_map(function (array $record) use ($batchKey) {
return $record[$batchKey];
}, $batch)]);
$pumpedKeys = [];
foreach ($pumped as $key => $entity) {
$entityKeyGetter = sprintf('get%s', \ucfirst($entityKey));
$pumpedKeys[] = $entity->$entityKeyGetter();
}
return \array_filter($batch, function (array $record) use ($batchKey, $pumpedKeys) {
return !\in_array($record[$batchKey], $pumpedKeys);
});
}
}
@@ -1,103 +0,0 @@
<?php
namespace App\Library\Benzina\Pump\Trait;
use Doctrine\ORM\EntityManagerInterface;
/**
* A progressive pump behaves with idempotency with the pumping data.
*
* To do so the pump must check matching, already pumped, data for the currently pumping data and skip it.
*
* This is an example:
* ```
* $alreadyPumped = $this->getPumped(Entity::class, $data, ['entityId' => 'dataId']);
* ...
* if ($this->isPumped($record, $alreadyPumped)) continue;
* ```
*/
trait ProgressivePumpTrait
{
public function __construct(
private EntityManagerInterface $entityManager
) {
}
/**
* Retrieve already pumped data from the pumping data batch.
*
* @param string $entityClass The class to pump the data to
* @param array $pumpingBatch The pumping data batch
* @param array $matchCriteria An array with the matching criteria between the entity key and the data key
*/
public function getPumped(
string $entityClass,
array $pumpingBatch,
array $matchCriteria
): array {
$repository = $this->entityManager->getRepository($entityClass);
$entityKey = \array_keys($matchCriteria)[0];
$pumpingKey = $matchCriteria[$entityKey];
$pumped = $repository->findBy([$entityKey => \array_map(function ($data) use ($pumpingKey) {
return $data[$pumpingKey];
}, $pumpingBatch)]);
$pumpedByKey = [];
foreach ($pumped as $pumped) {
$getter = sprintf('get%s', ucfirst($entityKey));
$pumpedByKey[$pumped->$getter()] = $pumped;
}
return $pumpedByKey;
}
/**
* Determine if a pumping data record is already in a pumped data batch.
*
* @param array $pumpingRecord The data record to be pumped
* @param array $pumpedBatch The pumped data batch
* @param array $matchCriteria An array with the matching criteria between the entity key and the data key
*/
public function isPumped(
array $pumpingRecord,
array $pumpedBatch,
array $matchCriteria
): bool {
if (
isset($this->config)
&& $this->config['progress'] === false
&& \array_key_exists('progress', $this->config)
) {
return false;
}
$entityKey = \array_keys($matchCriteria)[0];
$pumpingKey = $matchCriteria[$entityKey];
return \array_key_exists($pumpingRecord[$pumpingKey], $pumpedBatch);
}
/**
* Obtain the pumped record in a pumped data batch from a pumping data record.
*
* @param array $pumpingRecord The data record being pumped
* @param array $pumpedBatch The pumped data batch
* @param array $matchCriteria An array with the matching criteria between the entity key and the data key
*/
public function getPumpedRecord(
array $pumpingRecord,
array $pumpedBatch,
array $matchCriteria
): mixed {
$entityKey = \array_keys($matchCriteria)[0];
$pumpingKey = $matchCriteria[$entityKey];
if ($this->isPumped($pumpingRecord, $pumpedBatch, $matchCriteria)) {
return $pumpedBatch[$pumpingRecord[$pumpingKey]];
}
return false;
}
}
+19 -23
View File
@@ -2,16 +2,15 @@
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\ProgressivePumpTrait;
use App\Library\Benzina\Pump\Trait\DoctrinePumpTrait;
use Doctrine\ORM\EntityManagerInterface;
class UsersPump extends AbstractPump implements PumpInterface
{
use ArrayPumpTrait;
use ProgressivePumpTrait;
use DoctrinePumpTrait;
public const USER_KEYS = [
'id',
@@ -51,30 +50,29 @@ class UsersPump extends AbstractPump implements PumpInterface
];
public function __construct(
private EntityManagerInterface $entityManager
) {
}
private EntityManagerInterface $entityManager,
) {}
public function supports(mixed $data): bool
public function supports(mixed $batch): bool
{
if (!\is_array($data) || !\is_array($data[0])) {
if (!\is_array($batch) || !\is_array($batch[0])) {
return false;
}
return $this->hasAllKeys($data[0], self::USER_KEYS);
return $this->hasAllKeys($batch[0], self::USER_KEYS);
}
public function process(mixed $data): void
public function pump(mixed $batch): void
{
$pumped = $this->getPumped(User::class, $data, ['migratedReference' => 'id']);
$batch = $this->skipPumped($batch, 'id', User::class, 'migratedId');
foreach ($data as $key => $record) {
if ($this->isPumped($record, $pumped, ['migratedReference' => 'id'])) {
continue;
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']);
@@ -82,13 +80,11 @@ class UsersPump extends AbstractPump implements PumpInterface
$user->setName($record['name']);
$user->setActive(false);
$user->setMigrated(true);
$user->setMigratedReference($record['id']);
$accounting = new Accounting();
$accounting->setUser($user);
$user->setMigratedId($record['id']);
$user->setDateCreated($created);
$user->setDateUpdated(new \DateTime());
$this->entityManager->persist($user);
$this->entityManager->persist($accounting);
}
$this->entityManager->flush();
@@ -113,12 +109,12 @@ class UsersPump extends AbstractPump implements PumpInterface
return $username;
}
private function getUsername(array $data): string
private function getUsername(array $record): string
{
$username = $this->normalizeUsername($data['id']);
$username = $this->normalizeUsername($record['id']);
if (!$username) {
$username = $this->normalizeUsername($data['email']);
$username = $this->normalizeUsername($record['email']);
}
return $username;
+1 -1
View File
@@ -13,7 +13,7 @@ class PdoStream implements StreamInterface
public function __construct(
string $database,
private string $tablename,
private int $sizeOfBatch = 99
private int $sizeOfBatch = 99,
) {
$parsedUrl = parse_url($database);
$dbdata = [
@@ -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,89 +0,0 @@
<?php
namespace App\Library\Economy\Payment;
use App\DependencyInjection\Compiler\GatewaysCompilerPass;
use App\Entity\GatewayCheckout;
class GatewayLocator
{
/** @var GatewayInterface[] */
private array $gatewaysByName = [];
/** @var GatewayInterface[] */
private array $gatewaysByClass = [];
public function __construct(iterable $instanceof)
{
foreach (\iterator_to_array($instanceof) as $key => $gateway) {
$this->gatewaysByClass[$gateway::class] = $gateway;
}
GatewaysCompilerPass::validateGatewayNames($this->gatewaysByClass);
foreach ($this->gatewaysByClass as $class => $gateway) {
$this->gatewaysByName[$gateway::getName()] = $gateway;
}
}
/**
* @return array<string> List of the fully-qualified class names of the available interfaces
*/
public function getClasses(): array
{
return \array_keys($this->gatewaysByClass);
}
/**
* @return array<string> List of names of the available interfaces
*/
public function getNames(): array
{
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[]
*/
public function getGateways(): array
{
return $this->gatewaysByName;
}
/**
* @param string $name Name of the Gateway interface implementation
*
* @throws \Exception When the $name does not match to that of an implemented Gateway
*/
public function getGateway(string $name): GatewayInterface
{
if (!\array_key_exists($name, $this->gatewaysByName)) {
throw new \Exception("No such Gateway with the name $name");
}
return $this->gatewaysByName[$name];
}
/**
* @throws \Exception When the $checkout::gateway does not match to that of an implemented Gateway
*/
public function getGatewayOf(GatewayCheckout $checkout): GatewayInterface
{
$gateway = $checkout->getGateway();
if (!$gateway) {
throw new \Exception('The given GatewayCheckout does not specify a Gateway');
}
return $this->getGateway($gateway);
}
}
@@ -1,11 +0,0 @@
<?php
namespace App\Library\Economy\Payment;
abstract class PaypalGateway implements GatewayInterface
{
public static function getName(): string
{
return 'paypal';
}
}
@@ -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,76 @@
<?php
namespace App\Mapping\Accounting;
use App\ApiResource\Accounting as Resource;
use App\Entity\Accounting as Entity;
use App\Entity\Interface\AccountingOwnerInterface;
use App\Entity\Money;
use App\Entity\User;
use App\Gateway\Wallet\WalletService;
use App\Library\Economy\MoneyService;
use App\Repository\Accounting\AccountingRepository;
use App\Repository\Accounting\TransactionRepository;
use Doctrine\ORM\EntityManagerInterface;
class AccountingMapper
{
public function __construct(
private EntityManagerInterface $entityManager,
private AccountingRepository $accountingRepository,
private TransactionRepository $transactionRepository,
private WalletService $wallet,
private MoneyService $money,
) {}
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;
$resource->balance = $this->getBalance($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;
}
private function getBalance(AccountingOwnerInterface $owner): Money
{
if ($owner instanceof User) {
return $this->wallet->getBalance($owner->getAccounting());
}
$accounting = $owner->getAccounting();
$balance = new Money(0, $accounting->getCurrency());
$transactions = $this->transactionRepository->findByAccounting($accounting);
foreach ($transactions as $transaction) {
if ($transaction->getTarget() === $accounting) {
$balance = $this->money->add($transaction->getMoney(), $balance);
}
if ($transaction->getOrigin() === $accounting) {
$balance = $this->money->substract($transaction->getMoney(), $balance);
}
}
return $balance;
}
}
+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->getForCheckout($entity);
$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->setGatewayName($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;
@@ -0,0 +1,92 @@
<?php
namespace App\Repository\Accounting;
use App\Entity\Accounting\Accounting;
use App\Entity\Accounting\Transaction;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Transaction>
*
* @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 TransactionRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Transaction::class);
}
/**
* @return Transaction[] Transactions originated from or targeting the Accounting
*/
public function findByAccounting(Accounting $accounting): array
{
return $this->createQueryBuilder('t')
->andWhere('t.origin = :val')
->orWhere('t.target = :val')
->setParameter('val', $accounting)
->orderBy('t.id', 'ASC')
->getQuery()
->getResult()
;
}
/**
* @return Transaction[] Transactions originated from the Accounting
*/
public function findByOrigin(Accounting $origin): array
{
return $this->createQueryBuilder('t')
->andWhere('t.origin = :val')
->setParameter('val', $origin)
->orderBy('t.id', 'ASC')
->getQuery()
->getResult()
;
}
/**
* @return Transaction[] Transactions targetting the Accounting
*/
public function findByTarget(Accounting $origin): array
{
return $this->createQueryBuilder('t')
->andWhere('t.target = :val')
->setParameter('val', $origin)
->orderBy('t.id', 'ASC')
->getQuery()
->getResult()
;
}
// /**
// * @return Transaction[] Returns an array of Transaction objects
// */
// public function findByExampleField($value): array
// {
// return $this->createQueryBuilder('t')
// ->andWhere('t.exampleField = :val')
// ->setParameter('val', $value)
// ->orderBy('t.id', 'ASC')
// ->setMaxResults(10)
// ->getQuery()
// ->getResult()
// ;
// }
// public function findOneBySomeField($value): ?Transaction
// {
// return $this->createQueryBuilder('t')
// ->andWhere('t.exampleField = :val')
// ->setParameter('val', $value)
// ->getQuery()
// ->getOneOrNullResult()
// ;
// }
}
@@ -1,48 +0,0 @@
<?php
namespace App\Repository;
use App\Entity\AccountingTransaction;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<AccountingTransaction>
*
* @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)
*/
class AccountingTransactionRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, AccountingTransaction::class);
}
// /**
// * @return AccountingTransaction[] Returns an array of AccountingTransaction objects
// */
// public function findByExampleField($value): array
// {
// return $this->createQueryBuilder('t')
// ->andWhere('t.exampleField = :val')
// ->setParameter('val', $value)
// ->orderBy('t.id', 'ASC')
// ->setMaxResults(10)
// ->getQuery()
// ->getResult()
// ;
// }
// public function findOneBySomeField($value): ?AccountingTransaction
// {
// return $this->createQueryBuilder('t')
// ->andWhere('t.exampleField = :val')
// ->setParameter('val', $value)
// ->getQuery()
// ->getOneOrNullResult()
// ;
// }
}
@@ -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,48 +0,0 @@
<?php
namespace App\Repository;
use App\Entity\GatewayCheckout;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
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[] 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;
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace App\Repository;
use App\Entity\UserPersonal;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<UserPersonal>
*
* @method UserPersonal|null find($id, $lockMode = null, $lockVersion = null)
* @method UserPersonal|null findOneBy(array $criteria, array $orderBy = null)
* @method UserPersonal[] findAll()
* @method UserPersonal[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class UserPersonalRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, UserPersonal::class);
}
}
+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()
// ;
// }
}

Some files were not shown because too many files have changed in this diff Show More